Compare commits

..
Author SHA1 Message Date
Ludovic Marie 976e3f3858 Merge pull request #5094 from horizon-rust/fix/launcher-test-real-temp-dir
🤖 Agent: test-only fix for main's red macOS launcher job (since b49925118): the resource-pack tests run under the temp dir with its links resolved. pr_ready --quick green (port-pack 97, launcher 305 tests). CI green. Still needs main merged in: no conflict. The symlinked-home case in resourcepacks::directory is left to gravitacia and levzzz5154 (PR body).
2026-10-31 15:47:20 +01:00
Ludovic Marie af55a73486 Merge pull request #4271 from horizon-rust/perf/dedicated-no-item-models
🤖 Agent: a dedicated server skips the item models. Main merged in (bcbf367fc, clean). pr_ready --quick green. CI green; parity passed on the second rerun. Earlier runs failed mob_ai, remote_fireball and lunge_motion once each, a different set each time, and all three pass on this head locally (mob_ai 237/237, remote_fireball 2/2, lunge_motion 3/3). rdbtCVS approved.
2026-10-31 15:45:20 +01:00
Ludovic Marie 125875b844 Merge pull request #5092 from mulareal/fix-integrated-player-save-5065
🤖 Agent: Verified in the real game: on main, a single-player world (server thread) comes back at spawn with an empty inventory after Save and Quit; with this PR merged into main, position and inventory are kept on the server thread and in-frame, and a dedicated server keeps a player's position and inventory over leave/rejoin and a server restart. The regression check is #5105 (lan_e2e reopen_player: fails on main, passes with this). Thanks @mulareal!
2026-10-31 15:22:00 +01:00
Ludovic Marie 68ac6f647e Merge pull request #5165 from horizon-rust/server-admin/config-backups
🤖 Agent: verified: pr_ready green, CI green (retargeted to main after #5163 merged), tools/server_admin_e2e.py passed with the API on server.toml's fixed [api] address and token (live difficulty change, engine.path refused, backup made, downloaded byte for byte and deleted).
2026-10-31 14:59:11 +01:00
Ludovic Marie 57aa58c8f9 Merge pull request #5163 from horizon-rust/server-admin/api
🤖 Agent: verified: pr_ready green, CI green, tools/server_admin_e2e.py passed against a real dedicated server (players, op/deop, whitelist, ban/pardon by name and IP, kick, Prometheus). Base of the server-admin stack (#5165, #5166 follow).
2026-10-31 14:53:43 +01:00
Ludovic MarieandClaude Opus 5.5 d9c733419c horizon-server: config and backups in the control API, and a fixed address and token
GET /api/v1/config shows server.toml's settings (the API token hidden);
PATCH /api/v1/config changes them as `config set` does, live where vanilla
can, but never [engine], [panel], [api] or the RCON, query and management
keys. DELETE /api/v1/backups?name= and GET /api/v1/backups/download?name=
(streamed from the file). server.toml's new [api] section fixes the API's
address and token for a program that runs elsewhere, such as a moderation
bot; loopback or tailnet only unless allow_public. Panel changes of any
method need the X-Horizon header.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 14:37:44 +01:00
Ludovic MarieandClaude Opus 5.5 c50f97ec8f horizon-server: players, moderation and Prometheus metrics in the control API
GET /api/v1/players lists who is online (address, ping, op level) and vanilla's
lists; POST players/{kick,ban,pardon,ban-ip,pardon-ip,op,deop} and
whitelist[/add|/remove] run vanilla's console command on the running game and
answer whether the list changed and the console's lines; GET
metrics/prometheus serves the latest sample as Prometheus text.
tools/server_admin_e2e.py runs it all against a real server with two bots.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 14:36:38 +01:00
levzzz 27cb8f96bf Merge pull request #3891 from levzzz5154/launcher/native-mods-where
Launcher: show where native mods are installed and what each instance's build plays
2026-10-31 18:26:08 +05:00
Ludovic Marie c7dcaf2b7d Merge pull request #4257 from horizon-rust/perf/dedicated-no-face-visibility
🤖 Agent: a dedicated server skips the face visibility (19 MB). Main merged in (a672a51eb): the skip moved into main's load_core visibility worker, and PackResources::load's dead draws parameter was dropped. pr_ready --quick green. CI green; parity passed on the rerun. The first run's tab_list (4/1000), lunge_motion and remote_fireball failures didn't reproduce: tab_list matches 1000/1000 locally on the same merged tree. rdbtCVS approved the pre-merge head (dismissed by the push).
2026-10-31 14:19:53 +01:00
Ludovic Marie bd541458cd Merge pull request #1777 from horizon-rust/feat/lithostitched-elements
🤖 Agent: Lithostitched limited/guaranteed jigsaw elements beside main's YUNG rules. rdbtCVS's change request (a fallback pool's guaranteed entries were never tried) is fixed and approved at c556f5b5b. Main merged in (clean). CI green, parity included. pr_ready: only main's 8 macOS resource-pack tests fail (#5094).
2026-10-31 13:57:53 +01:00
Ludovic Marie 01bbb4c654 Merge pull request #4276 from horizon-rust/perf/dedicated-no-client-tables
🤖 Agent: a dedicated server skips the block entity renderer and animate tables. Main merged in (d7c6e36cb): the skip moved into main's load_world_rest worker closures. pr_ready --quick green. CI green; parity passed on the rerun (head merged into main). The first run's world_spawn#4 was vanilla's documented spawn-height variance on seed -2080727421395798566; vanilla and Horizon both give y 72 locally, 3 runs out of 3. Reviews: DUzzL and dimaniojk LGTM.
2026-10-31 13:48:07 +01:00
Ludovic Marie 38a97ea64e Merge pull request #4279 from horizon-rust/perf/shared-loot-tables
🤖 Agent: shared loot tables per root and packs. rdbtCVS's change request (the cache key ignored root file changes) was fixed in fc4641bc8, and rdbtCVS approved it 2026-10-31. Main merged in (b546ffb87, clean). pr_ready --quick green; CI green, parity included (head merged into main c3905131b).
2026-10-31 13:44:17 +01:00
levzzz 2abd1ac013 Merge main into launcher/native-mods-where 2026-10-31 17:22:54 +05:00
levzzz 36f4bc00d6 Merge pull request #3870 from levzzz5154/launcher/native-builds-stay-put
Launcher: native rebuilds stay on the instance's Horizon, in its instance, fast
2026-10-31 17:21:22 +05:00
Ludovic Marie 3c68c05d0a Merge pull request #1171 from horizon-rust/feat/macos-x64
🤖 Agent: macOS on Intel (x86_64 game and launcher builds), approved by Ludovic. Main merged in (13fd2ee72, README conflict only). rdbtCVS's review is addressed in 7f99b2e8c: the install smoke test skips only for macos-x64 and only on a parsed list with no build; a failing launcher or script fails the step (5 cases checked locally). The Intel Rust-toolchain limit for native mods is documented. CI green (verify, tests, clippy, launcher). pr_ready: only main's macOS resource-pack test fails (#5094).
2026-10-31 12:47:21 +01:00
Ludovic MarieandClaude Opus 5.5 7f99b2e8cc macOS on Intel: only the Intel launcher may skip its install smoke test
rdbtCVS's review: the "no build yet" skip covered every platform and every
failure (a launcher that failed four times, a script error). Now only the
macos-x64 entry skips, and only on a version list that parsed and had no
build for it (exit 3); anything else fails the step as before. Plus the
known limit in docs/native-mods.md: no Rust toolchain for Intel Macs yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 12:27:00 +01:00
Ludovic Marie c556f5b5b8 Merge remote-tracking branch 'origin/main' into HEAD 2026-10-31 12:19:25 +01:00
Ludovic Marie b546ffb877 Merge remote-tracking branch 'origin/main' into HEAD 2026-10-31 12:13:44 +01:00
Ludovic MarieandClaude Opus 5.5 a6f30aa763 Resource-pack tests: run under the temp dir with its links resolved
resourcepacks::directory refuses a link anywhere in a destination's
ancestry. macOS's temp dir is under /var, a link to /private/var, so 7
horizon-port-pack tests and the launcher's
resource_only_profile_can_play_without_enabling_or_overwriting_packs
failed on every Mac, and main's launcher job fails on macOS since
b49925118.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 12:10:26 +01:00
Ludovic Marie bcbf367fc6 Merge remote-tracking branch 'origin/main' into HEAD 2026-10-31 12:08:34 +01:00
mularealandAntigravity 65d4d06ec5 Fix single-player position and inventory saving on the integrated server (#5065)
Co-Authored-By: Antigravity <[email protected]>
2026-10-31 11:57:07 +01:00
Ludovic Marie d7c6e36cb9 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-10-31 11:56:07 +01:00
Ludovic Marie a672a51ebd Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-10-31 11:55:33 +01:00
Ludovic Marie 13fd2ee724 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	README.md
2026-10-31 11:54:10 +01:00
Ludovic Marie c3905131bf Merge pull request #5074 from horizon-rust/feat/anticheat-prediction-vanilla
🤖 Agent: proven. tools/anticheat_e2e.py --check prediction-vanilla: the vanilla 26.3 client behind 20-120 ms jitter never flagged in 6/6 runs (before these fixes 5/6 flagged); --check prediction (Horizon client) 3/3 clean, bot flagged and kicked; pr_ready green (1340 tests); server_knockback, dedicated_server, server_entities pass; 60 bots MSPT 4.08 off / 4.39 on. CI green (lunge_motion failed once, known flake, passes locally on this head and on rerun).
2026-10-31 11:53:04 +01:00
Ludovic MarieandClaude Opus 5.5 d7d0ead29d Anticheat prediction: the vanilla client on a jittery connection, never flagged
`tools/anticheat_e2e.py --check prediction-vanilla` runs the real vanilla 26.3 client
(tools/vanilla_client.py) against a server with the prediction on, through the 20-120 ms jitter
relay. VanillaMover (mods/horizon/src/test/java) wraps vanilla's Main unchanged and presses its key
mappings from stdin, so every move is vanilla's own movement code. It walks, sprint-jumps, strafes
and sneaks, with Speed given and cleared, over packed ice, swims, walks into cows, jumps at a block
set in front of it and glides with an elytra and a firework. It runs only when asked: it needs a
screen session.

It found four replay gaps, which a Horizon client hid because it runs the replay's own code:

- ClientPacketListener.handleUpdateAttributes replaces the client's modifiers with the server's,
  its sprint modifier included, and the client goes on sprinting as it was. The replay set the
  body's sprinting to the server's and then back to the client's, which put the modifier back: a
  sprint-jump after a teleport landed 0.03 blocks off. It now takes the attributes and keeps the
  client's sprinting.
- The changes a teleport's accept came after were dropped by the restart when the ticks before it
  hadn't seen them (an effect cleared: the replay kept Speed). Replay::finish applies them: the
  client had them all when it accepted the teleport.
- Under an excuse the replay can jump where the client couldn't (on a block it hadn't received),
  so the jump delay is unknown for the ticks after: a jump held then is played both ways
  (candidate 4).
- A teleport that comes while the replay is excused starts the replay excused too, so it takes
  the client's velocity until they agree.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 11:10:14 +01:00
Ludovic Marie 13ce6bcc64 Merge pull request #5041 from horizon-rust/feat/anticheat-prediction-e2e
🤖 Agent: prediction (d) verified: pr_ready green (1340 tests), anticheat_e2e all four checks pass on main+#5012, the prediction check 3/3 (real client behind 20-120 ms jitter never flagged, bot flagged and kicked); step/spider/jesus unit tests pass; tests and tools only.
2026-10-31 10:28:16 +01:00
Ludovic MarieandClaude Opus 5.5 807a818395 Anticheat prediction (d): e2e with a real client on a jittery connection, step/spider/jesus tests
`tools/anticheat_e2e.py --check prediction` starts a dedicated server with `[anticheat]` on and
connects a real Horizon client through a relay that holds every packet 20-120 ms, in order. The
client walks, sprint-jumps, strafes and sneaks, with Speed and Jump Boost given and cleared, over
packed ice and soul sand, swims, launches with riptide, walks into cows that are then killed, has
blocks changed in front of and under it, flies in creative, glides with an elytra and a firework,
and rides a boat: the check fails if it is ever flagged. A horizon-bots walker, which moves by its
own rules and sends no keys, must be flagged and sent back.

Unit tests: a step up a full block, a spider up a wall and a jesus on water each count from the
tick they start, and nothing before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 10:06:44 +01:00
Ludovic Marie 51c9565411 Merge pull request #5012 from horizon-rust/feat/anticheat-prediction-check
🤖 Agent: prediction (c) verified: pr_ready green (1353 tests), server_knockback/dedicated_server/server_entities pass, anticheat e2e off/timer/ground pass; a real client behind 20-120 ms jitter never flagged in 8/8 runs while a bot is flagged and sent back; bench MSPT off 4.52/4.49, on 4.63/4.94 (60 bots); off by default.
2026-10-31 10:06:30 +01:00
Ludovic MarieandClaude Opus 5.5 71d7921132 Anticheat prediction (c): flag moves off the replay, set back to where they agreed
A move that lands farther than `[anticheat.prediction] tolerance` (0.03 blocks) from the replay
flags, weighing its offset in tolerances (at most 5 a tick); the setback sends the player to where
its moves last agreed. What the replay can't play yet excuses the tick and the ones after until the
moves agree again (40 ticks at most): another entity or player near (farther for a fast player), a
block change or an entity removal near the player that the client hadn't seen, a block change the
server hasn't sent, abilities the client hadn't seen, a moving piston, a chunk the client doesn't
have, a riptide launch, a teleport that keeps motion. Under an excuse the replay takes the client's
velocity, so it agrees again within a few ticks.

The replay now also nudges the player out of a block it stands in (LocalPlayer
.moveTowardsClosestSpace, worked out from the blocks as the game does for its own player), keeps
its body across setbacks, and plays the ticks that came in a burst with a teleport's accept before
starting again there, so their jump delay goes on through it.

The bot harness checks and the bench keep the prediction from flagging the bots, which move by
their own rules.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 09:29:57 +01:00
rdbt 9d979caa48 Merge pull request #5019 from rdbtCVS/perf/preload-world-at-launch
Preload the world data of a launch that names a world
2026-10-31 07:45:52 +00:00
rdbtCVSandClaude Opus 5.5 09a4cd9baf Merge main into perf/preload-world-at-launch
Main split the resource load into load_core; the launch world's preload now
starts once the core tables (and so the biomes) are in.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 07:40:27 +00:00
rdbt c18ed2db67 Merge pull request #5020 from rdbtCVS/perf/server-thread-overlap 2026-10-31 07:37:17 +00:00
rdbt c41a8d0d96 Merge pull request #5018 from rdbtCVS/perf/parallel-world-rest 2026-10-31 07:36:54 +00:00
rdbt fc680ec318 Merge pull request #5015 from rdbtCVS/perf/parallel-core-resources 2026-10-31 07:36:51 +00:00
rdbt c535285302 Merge pull request #5013 from rdbtCVS/t3/adaptive-server-view-distance 2026-10-31 07:36:38 +00:00
rdbt 763528025d Merge pull request #4962 from MSedgeMC/fix/candle-snuff
Pass flint and steel over a lit candle cake, and snuff through one path
2026-10-31 07:15:45 +00:00
rdbtCVSandClaude Opus 5.5 5bd13dad80 Send saved chunks past the view distance (max_view_distance)
A dedicated server's new `max_view_distance` (server.toml, off by default)
sends each player, past `view_distance`, the chunks the world has saved:
read on two reader threads, sent and dropped, never generated, loaded or
ticked. Bodies sent are kept five minutes (96 MB) for players walking back,
dropped when their column changes. A brake pulls the ring in by two chunks
while ticks average over 40 ms, never inside the view, and lets it out by
one every 5 s after 30 s under 25 ms; reads pause while the save's writer
is behind. The docs point at /pregen to fill the ring.

Fixes #5008

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 07:13:51 +00:00
rdbt 03fc2d3f2d Merge pull request #4990 from MSedgeMC/fix/villager-age-boundary
Run a mob's age boundary when it grows up, not only when it spawns
2026-10-31 07:11:07 +00:00
levzzz 1dfea0b84e Merge pull request #4669 from Qwerner88880/hooks-clean
Native mods: mod messages and event hook points (API next)
2026-10-31 12:05:00 +05:00
rdbtCVSandClaude Opus 5.5 eae1e21bd0 Load the world-rest tables in parallel
load_world_rest built its tables one after another on the world-rest-loader
thread (~550 ms warm). Only the recipes and the stations read another table
(the items), so the items chain, the block behaviours, the block entity
renderer, the animate and fog tables, the mob types and data, and the
transformers with the player models now load together in a thread scope,
within the worker budget (sequential when it is one thread). The rest
chain becomes its longest path: behaviours, or items -> recipes -> stations.

Closes #5000

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 07:02:33 +00:00
levzzzandClaude Sonnet 5.5 97b2e10df5 Merge hr/main; renumber mod messages and event hooks to API 40; not-joined error first, send allowance per server tick, sweep and spear kills
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 11:59:21 +05:00
rdbtCVSandClaude Opus 5.5 5bda60aa5d perf(server-thread): prepare the world beside the server's Game
Opening a single-player world built the server thread's own Game, then
prepared the world, then read the registries the server sends, one after
the other. None reads what another makes: the world now prepares on a
helper thread (WorldLoader) while the server thread builds its Game, and
the registries without data packs are read on another; a world without
packs adopts them, one with packs loads its own as before. The dedicated
server reads the registries beside its world's preparation too.

Closes #5001

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 06:56:46 +00:00
levzzz 3bffd9b52e Merge pull request #4677 from Qwerner88880/hooks-items
Native mods: hook points for item behaviour (API next)
2026-10-31 11:56:33 +05:00
rdbtCVSandClaude Opus 5.5 a648bef38c Load the core tables beside each other: the menu bundle, the block models and the block facts no longer wait in line
load_resources built the world bundle's core on one thread: the menu bundle,
then the registry, the block models and sprites, the block facts, the light
tables, the pack tables and the collision and motion tables, one after the
other. Most of them only read the registry. Now the menu bundle loads beside
load_core; past the registry, the block models (then the item models, which
share their parsed models), the face visibility, the collisions, the biomes
and the environment load beside the block facts, and the light tables beside
the motion tables once the facts are in. Only the mesh tables wait for all
of them. A pack failure in any of them still rolls back to the game's own
resources, and a single worker setting still loads them in turn.

Closes #4999

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 06:56:29 +00:00
rdbtCVSandClaude Opus 5.5 e3b213f8f9 Preload the world data of a launch that names a world
`--world` and `--quickPlaySingleplayer` launches paid the worldgen bundle,
the Overworld's decoration and the loot tables (~0.5-0.8 s) inside
`WorldRequest::prepare`: `preload_world_data` only runs on the world
screens. Such a launch now starts the same preload as soon as the block
registry and the biomes are loaded, for the world's own data packs and
preset, so the opening finds them in the cache. A world they don't fit
reads its own data when it opens, as before.

Closes #4998

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 06:56:01 +00:00
rdbt 530a1253e6 Merge pull request #4424 from apeirs/fix-merchant-space-reselect
Trading: Space and Enter press the focused offer again (hold Space to refill)
2026-10-31 06:52:29 +00:00
levzzzandClaude Sonnet 5.5 ae45f040b7 Merge hr/main; renumber item behaviour hooks to API 39
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 11:52:01 +05:00
levzzz 3d1b94f97c Merge pull request #4725 from Qwerner88880/hooks-signal
Native mods: hook points for redstone signals from a mod's block (API next)
2026-10-31 11:50:18 +05:00
levzzzandClaude Sonnet 5.5 57bee439bb Merge hr/main; renumber redstone signal hooks to API 38; native_update_signal_neighbors takes a dimension
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 11:44:40 +05:00
levzzz 367d519e3c Merge pull request #4799 from Qwerner88880/hooks-mobs
Native mods: helpers for spawning and moving mobs (API next)
2026-10-31 11:42:30 +05:00
levzzzandClaude Sonnet 5.5 13aecbe1e5 Merge hr/main; renumber mob helpers to API 37; dimension argument, verb-first names, /summon bounds, /tp dismount, readers gated, removed mobs unsaved
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 11:37:26 +05:00
rdbt 7aa0e28fae Merge pull request #4419 from DUzzL/fix/issue-4404-wind-burst-armor-stand
Run the mace weapon hit on armour stands
2026-10-31 06:35:04 +00:00
levzzz 202e37981f Merge pull request #4838 from Qwerner88880/hooks-recipes
Native mods: recipes of a mod's own types (API next)
2026-10-31 11:34:53 +05:00
rdbt 6ee48d0e47 Merge pull request #4961 from MSedgeMC/fix/knowledge-book-unlock
Make a knowledge book teach its recipes
2026-10-31 06:30:10 +00:00
levzzzandClaude Sonnet 5.5 836ea22364 Merge hr/main; renumber recipe helpers to API 36; guard outside a world, read bare ids, test loading
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 11:29:48 +05:00
Ludovic Marie 877d0dac10 Merge pull request #5003 from horizon-rust/feat/anticheat-prediction-history
🤖 Agent: verified: pr_ready green except #4662's known test (fails on main with .local); server_knockback, dedicated_server, server_entities pass; a real Horizon client replays 638/638 ticks with offset 0.0 (effects given and cleared); anticheat stays off by default; CI tests, clippy and verify pass.
2026-10-31 07:28:13 +01:00
levzzz 94b3b4aca5 Merge pull request #4893 from sirrayi/fix/mod-commands-chat
Chat: native mods' commands join the tree the chat colours and completes
2026-10-31 11:25:32 +05:00
levzzzandClaude Sonnet 5.5 73958d8397 Merge hr/main; renumber native_commands to API 35; leave a name the game has alone
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 11:19:52 +05:00
levzzz 37ad1f5c9e Merge pull request #4880 from reservedbytes/feat/native-scheduled-tick
feat(mods): native mods can change their blocks on scheduled ticks
2026-10-31 11:17:58 +05:00
rdbt 73db376775 Merge pull request #4994 from horizon-rust/ci/parallel-verify
CI: run verify's checks as three parallel jobs (~8 min → ~4 min)
2026-10-31 06:17:31 +00:00
Ludovic MarieandClaude Opus 5.5 84f633b56b Anticheat prediction (b2): replay what the server sent, on the tick the client took it
What the server sends a player that changes how its client moves goes
into the record with the server tick that sent it: a velocity
(SetEntityMotion for the player itself, as the client decodes its
LpVec3), an explosion's knockback (added), and attributes and effects
(UpdateAttributes, mob effects). Guest::send notes them while recording;
the tick's ping places them. The replay applies each before the first
client tick whose pong had seen that server tick, which is when the
client handled it. A client that ignores a push (anti-knockback) is off
from that tick on. The attributes and effects are copied when they
change, no longer every tick.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 07:17:26 +01:00
rdbt a3c5fe138e Merge pull request #4964 from MSedgeMC/fix/moving-piston-drops
Drop the block a moving piston carries when something destroys it
2026-10-31 06:15:38 +00:00
levzzzandClaude Sonnet 5.5 4db1495bfb Merge hr/main; renumber scheduled-tick hook to API 34
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 11:09:05 +05:00
levzzz ed5565713e Merge pull request #4872 from reservedbytes/feat/native-random-tick
feat(mods): native mods can change their blocks on random ticks
2026-10-31 11:06:56 +05:00
Ludovic Marie 90994feaca Merge pull request #4989 from horizon-rust/feat/anticheat-prediction-replay
🤖 Agent: prediction (b), the replay, off by default, flags nothing. Verified: a real Horizon client on a dedicated server, 262 ticks walking, sprint-jumping, strafing and sneaking all replayed to 1e-6; lan::prediction tests (honest exact, 1.3x speed and fly off); pr_ready --quick (only main's #4662 test fails); auto_jump, lunge_motion, mace, trident, server_knockback, dedicated_server, server_entities pass. Cost 5.6 us per player tick when on, 0 off.
2026-10-31 07:04:30 +01:00
rdbtCVSandClaude Opus 5.5 f2a99d6238 CI: run verify's checks as three parallel jobs
verify ran the public-tree check, the Python tests, clippy and the Rust tests one
after another (~8 min). They now run as the python, clippy and tests jobs at once,
and verify gathers them (always(), failing unless all three passed), so the
required check keeps its name.

A pull request's public-tree check reads only its own commits
(check_public_tree.py --range base..head) instead of every ref's whole history;
main's pushes still run --history.

Closes #4978

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 06:03:29 +00:00
levzzzandClaude Sonnet 5.5 e776e8e94a Merge hr/main; renumber random-tick hook to API 33; guard the Game property writers, test with_property
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 10:58:07 +05:00
levzzz 3d0512446d Merge pull request #4899 from reservedbytes/feat/native-shape-update
feat(mods): native mods can change their blocks on shape updates
2026-10-31 10:55:37 +05:00
rdbt a595a30164 Merge pull request #4981 from navinor/test/living-use-flags
Pin the living use flags to vanilla's 1/3/2 wire values
2026-10-31 05:53:07 +00:00
rdbt dffc94645e Merge pull request #4982 from navinor/fix/horizon-threads-useless-vec
Pass the short names test a slice, which clippy asks for
2026-10-31 05:53:01 +00:00
MSedgeMC 3edd56c5e4 Run a mob's age boundary when it grows up, not only when it spawns
AgeableMob.setAge calls ageBoundaryReached exactly on the crossing, and that
is where a goat's attack damage becomes 2, a hoglin's 6, a sulfur cube's
size 2. Horizon wrote the age field directly in both growth paths, so the
hook only ever ran at spawn: a baby goat that grew up kept the baby 1.0
damage forever.

The per-tick growth, the feeding age-up, a sheep eating grass and a sulfur
cube's own feeding now all go through set_age, so every species gets its
boundary work. The turtle keeps its hand-patched scute drop: that one needs
a level, the mob_drops gamerule and a loot table, which a Mob hook cannot
reach.
2026-10-31 11:21:18 +05:30
rdbtCVS b526d836a0 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	tools/lan_e2e.py
2026-10-31 05:49:48 +00:00
rdbt 0b82e9f872 Merge pull request #4941 from reservedbytes/fix/place-template-flags
fix(commands): /place template places as a structure template does
2026-10-31 05:48:14 +00:00
rdbt 3f722b80a8 Merge pull request #4822 from josegil1909/fix/4815-servers-i18n
feat(launcher): localize Servers dashboard, overview, and Tailscale panel
2026-10-31 05:48:10 +00:00
MSedgeMC f554e16b02 Draw the cake snuff smoke where vanilla draws it
A snuffed candle cake emitted its block event but the client drew the smoke
half a block too low: the offset table was indexed by the state's candles
property, which a cake does not have, so it fell into the one-candle row.
The client's own prediction also only matched names ending in candle, so on
a real client a cake's snuff drew nothing at all.

Both blocks now share one table with the cake's own offset, and the
prediction knows the cake's half-block hit rule.
2026-10-31 11:17:31 +05:30
MSedgeMC 92415234d9 Pass flint and steel over a lit candle cake, and snuff through one path
A candle cake's useItemOn passes for flint and steel and fire charges before
anything else, so the item lights a fire beside the cake. Horizon answered
the click with useWithoutItem, which eats the cake: a lit candle cake
vanished when you tried to light a fire next to it.

Both hand-snuff branches also duplicated AbstractCandleBlock.extinguish
instead of calling extinguish_candle, and only a plain candle drew the wick
smoke. They now go through the one helper, and a cake's snuff smokes too.
2026-10-31 11:17:18 +05:30
levzzzandClaude Sonnet 5.5 f96b795bf6 Merge hr/main; renumber shape-update hook to API 32; validate a mod's state id; cover the incoming state in the test
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 10:46:43 +05:00
levzzz 2d6d006d39 Merge pull request #4665 from ludovicthenot/native-player-tick-hook
Native mods: a hook point for each player's server tick (API 22)
2026-10-31 10:43:33 +05:00
rdbt 6eca577874 Merge pull request #4973 from MSedgeMC/chore/ledger-stale-rows-2
Retarget another thirteen ledger rows that are already ported
2026-10-31 05:38:47 +00:00
rdbt c559d125b5 Merge pull request #4977 from sl4ppleware/fix/seat-gone-on-rejoin
No more sitting on a ghost cushion after rejoining (a robot noticed again)
2026-10-31 05:38:43 +00:00
Aaron WangandOpenCode 819cd2eebc Pass the short names test a slice, which clippy asks for
clippy 1.99 flags \&vec![0; 100]\ as a useless_vec. The test only borrows the buffer for the call, so \&[0; 100]\ is the same argument without the allocation. This is the only change; it unblocks the workspace clippy step for every PR.

Co-Authored-By: OpenCode (DeepSeek V4.1 Flash) <[email protected]>
2026-10-31 01:37:46 -04:00
Ludovic Marie 1e62ad7914 Merge pull request #4955 from horizon-rust/fix/main-green-2
🤖 Agent: fixes 3 of parity-main's failures on main: block_update and random_tick (the probe skips the new structure/test block events, as it skips command_block), mob_ai bee_sting (the probe counts MobEvent::StingPlayer as a hit), and save_roundtrip (post_effects stored even when empty, as vanilla does), plus #4662's motion test, which failed with the vanilla data. Verified: those harnesses pass locally and fail on main; CI parity 59/61, where the 2 failures were main's join line, since fixed by #4950; verify green; pr_ready --quick (its one failure is #4780's candle test, which fails on main too).
2026-10-31 06:37:37 +01:00
rdbtCVS 57d8a70a2c Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	launcher/lang/de.json
#	launcher/lang/es.json
#	launcher/lang/fr.json
#	launcher/lang/pl.json
#	launcher/lang/ru.json
#	launcher/lang/uk.json
#	launcher/lang/zh.json
2026-10-31 05:34:49 +00:00
rdbt 0fb65197a7 Merge pull request #4811 from josegil1909/fix/4802-modpacks-i18n
feat(launcher): localize Modpacks tab and port coverage report
2026-10-31 05:33:17 +00:00
rdbt d4dff9534c Merge pull request #4114 from DUzzL/fix/issue-4104-elytra-body-jitter
Smooth the elytra body lean between ticks
2026-10-31 05:32:50 +00:00
rdbt a73db6ad95 Merge pull request #3983 from DUzzL/fix/issue-3976-bots-report-bytes-join
horizon-bots: report bytes in/out and join-time percentiles
2026-10-31 05:32:27 +00:00
levzzzandClaude Sonnet 5.5 6718c7dd9a Merge hr/main; renumber player-tick hook to API 31
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 10:32:21 +05:00
Aaron WangandOpenCode d331af9962 Pin the living use flags to vanilla's 1/3/2 wire values
LivingEntity.startUsingItem sets flag 1 for the use and flag 2 for the off hand, so an off-hand use is 3; stopUsingItem clears only flag 1, so its stop arrives as 2. Pin the sender and both readers (mob data and the local player's synced use) to those values with tests, and say which bit means what where the reader lives.

Co-Authored-By: OpenCode (DeepSeek V4.1 Flash) <[email protected]>
2026-10-31 01:31:55 -04:00
rdbt 389a918c16 Merge pull request #4972 from TobiasLogic/shaderpacks/far-render-distance
Shader packs: far is the chunk render distance, not the distant terrain's reach
2026-10-31 05:31:26 +00:00
levzzz bdebd8103c Merge pull request #4656 from ludovicthenot/native-left-click-block-hook
Native mods: a hook point for left-clicking a block (API 21)
2026-10-31 10:30:42 +05:00
sl4ppleware 5e7b9a5075 Rejoining a server rides nothing until the server pairs a vehicle: no sitting on a broken cushion 2026-10-31 08:29:50 +03:00
MSedgeMC dd94337c2e Retarget another thirteen ledger rows that are already ported
The ledger is where AGENTS.md sends every contributor before parity work,
and its missing rows keep sending people at behaviour that exists: the iron
golem's creator rule and crack sound, the candle and candle-cake snuff, the
villager's trade reach, the piglin's target-distance rule, the allay's
save data. Each row here now names the function that ports it, verified
against the jar and the tree.

Three rows stay missing on purpose: the moving piston's drops, the
pufferfish's puff state and the enderman's carried block are waiting on
open PRs, so citing them would name functions main does not have yet.
2026-10-31 10:56:32 +05:30
Ludovic MarieandClaude Opus 5.5 7f969cb3a5 Anticheat prediction (b): play each client tick again and compare
Each client tick the record holds is played again with the client's own
movement code (MotionWorld::replayed_tick: LocalPlayer.aiStep with the
client's keys and the sprinting it said it had), on a copy of the
player, from where the last tick ended, starting at a teleport the
client accepted, and compared with where the client said it went. The
replay then goes on from the client's position. The keys and sprinting
are known exactly and never guessed; what the server can't know is
tried, every combination, the tick as heard first: the ground on the
first tick after a start, and the item use on the tick the server's
changed. It flags nothing yet: the check is the next PR.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 06:26:20 +01:00
Jose Gil 0c5074bba1 feat(launcher): localize Servers dashboard, overview, and Tailscale panel 2026-10-31 01:20:32 -04:00
levzzzandClaude Sonnet 5.5 fcaaed088b Merge hr/main; renumber left-click-block hook to API 30, narrow the cancel contract
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 10:20:20 +05:00
rdbt 4de90a13d4 Merge pull request #4971 from rdbtCVS/readme-trim-launcher-shots
README: shorter text, launcher screenshots from 0.2.30
2026-10-31 05:20:09 +00:00
levzzz cbda897a5e Merge pull request #4648 from ludovicthenot/native-interact-entity-hook
Native mods: a hook point for right-clicking an entity (API 20)
2026-10-31 10:18:37 +05:00
rdbtCVSandClaude Opus 5.5 86a40451e8 README: shorter text, launcher screenshots from 0.2.30
Trims each section to what a reader needs and links the docs for the
rest, about 35% fewer words. The generated gap block and the parity
table's format are unchanged (tests/test_parity*.py).

The Home and Settings screenshots were taken with launcher 0.2.30, which
moved the window to the new layout (instance tabs, Join a friend, accent
colours); the old ones showed 0.2.28.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 05:17:33 +00:00
rdbt 017afee3a1 Merge pull request #3982 from DUzzL/fix/issue-3972-bench-load-driver
bench: one script for Horizon, vanilla and Pumpkin under the same bot load
2026-10-31 05:15:19 +00:00
rdbt b2f0b58fc9 Merge pull request #4047 from elythepreciouscat/fix/fps-cap-vsync-off
Linux: OpenGL presents uncapped with VSync off
2026-10-31 05:15:01 +00:00
rdbtCVS 7f884ad64b Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	launcher/lang/de.json
#	launcher/lang/es.json
#	launcher/lang/fr.json
#	launcher/lang/pl.json
#	launcher/lang/ru.json
#	launcher/lang/uk.json
#	launcher/lang/zh.json
2026-10-31 05:10:34 +00:00
rdbt 568159fa1e Merge pull request #4800 from josegil1909/fix/4798-settings-ai-telemetry-i18n
feat(launcher): localize AI agent and Data collection settings sections
2026-10-31 05:09:27 +00:00
rdbt 6bbc5cb3b0 Merge pull request #4956 from Detimes/fix-4417-resource-preflight
horizon-server: check the prepared game files before starting the engine (#4417)
2026-10-31 05:08:35 +00:00
rdbt 6e263d79f6 Merge pull request #4950 from rdbtCVS/fix/own-join-line-parity
Server: the newcomer doesn't read its own join line, as in vanilla
2026-10-31 05:07:17 +00:00
levzzzandClaude Sonnet 5.5 2959076c75 Merge hr/main; renumber right-click-entity hook to API 29
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 10:06:35 +05:00
rdbt ff5d14d4c2 Merge pull request #4960 from MSedgeMC/fix/witch-magic-absorb
Pin the witch's magic resistance with a test
2026-10-31 05:05:35 +00:00
MSedgeMC c39db2247c Drop the block a moving piston carries when something destroys it
MovingPistonBlock.getDrops returns the loot of the state the block entity
is moving, or nothing when the block entity is gone. Horizon had no loot
for a moving piston at all, so /setblock ... destroy and /fill ... destroy
made a piston in flight swallow the block it carried.

A hand break still drops nothing, as vanilla's does: removeBlock clears the
block entity before the player's drops are drawn.
2026-10-31 10:35:05 +05:30
rdbt e3cba0ac6f Merge pull request #3806 from TraiNguyenVan/docs/readme-launcher-screenshots
README: fresh launcher screenshots, and the website's hero as the banner
2026-10-31 05:05:00 +00:00
levzzz e91d4e3f01 Merge pull request #4869 from arismoko/native-tag-checks
Native mods: read block and item tags
2026-10-31 10:04:36 +05:00
rdbt eac39ece47 Merge pull request #4402 from levzzz5154/docs/native-hooks-neoforge
Docs: model native hook points on NeoForge 26.3 events (proposal)
2026-10-31 05:04:29 +00:00
rdbt b251abc575 Merge pull request #3984 from horizon-rust/docs/server-next
docs/server-next.md: the next server's design, measured against vanilla and Pumpkin
2026-10-31 05:04:02 +00:00
rdbt 09e315cb24 Merge pull request #4482 from horizon-rust/docs/permissive-licenses-only
CONTRIBUTING: another project's code only when its license allows it
2026-10-31 05:03:58 +00:00
TobiasLogicandClaude Opus 5.5 48709c8091 Shader packs: far is the chunk render distance, not the distant terrain's reach
With Distant Terrain on, Frame::distance is the distant terrain's reach (LOD Distance 128: 2048
blocks), and packs got it as far. Iris gives the chunk render distance, and packs fade terrain into
the sky by it (Photon's and Complementary's border fog), so with far terrain on the fog began near
the far terrain's edge and distant land showed unfogged to the horizon. far is now
Frame::detail_distance.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 12:03:07 +07:00
MSedgeMC f8b5fed2f0 Make a knowledge book teach its recipes
KnowledgeBookItem.use reads the recipe ids in the book's recipes component
and awards them, and vanilla consumes the book before it looks at them, so
even an invalid book is used up. Horizon had no handler at all: a knowledge
book in hand fell through the item chain and did nothing, so the item was
inert.

The award path is the one /recipe give and crafting already use, and a book
naming a recipe the server does not know fails as vanilla's Invalid recipe
log does, without learning the valid ids beside it.
2026-10-31 10:30:53 +05:30
rdbtCVSandClaude Opus 5.5 4141a127fb Merge main into fix/issue-4404-wind-burst-armor-stand
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 04:57:52 +00:00
levzzzandClaude Sonnet 5.5 1ba05096b8 Merge hr/main; take native hooks API 28
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 09:55:33 +05:00
Ludovic Marie 19021623be Merge pull request #4954 from horizon-rust/feat/player-motion-clone
🤖 Agent: PlayerMotion: Clone (prediction b0), no behaviour change. Verified: pr_ready --quick (the one failure is main's #4662 test), the new copy tests, harnesses attribute, attribute_health, attribute_updates, live_attribute_updates, live_attributes, attribute_map_queries pass.
2026-10-31 05:55:19 +01:00
levzzzandClaude Sonnet 5.5 d27ec41434 Merge hr/main; renumber right-click-entity hook to API 28
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 09:54:52 +05:00
rdbtCVS 5993df106f Merge remote-tracking branch 'origin/main' into pr-3983
# Conflicts:
#	crates/horizon-bots/src/main.rs
2026-10-31 04:54:36 +00:00
levzzz 81b30898d7 Merge pull request #4840 from reservedbytes/feat/mod-mob-effects
feat(mods): content mods declare their own mob effects
2026-10-31 09:53:37 +05:00
levzzz 4fc225edb9 Merge pull request #4600 from JustKarkat/feat/native-health-hud-hook
Native mods: the player-health HUD rows as a hook point
2026-10-31 09:53:11 +05:00
MSedgeMC 28754451ea Pin the witch's magic resistance with a test
Witch.getDamageAfterMagicAbsorb takes the four #witch_resistant_to
damage types at 15% and nothing from a hit it caused itself. Horizon does
this in Mob::damage_with, after armour and protection, in vanilla's order,
and nothing asserted it: the port ledger still says missing, and a reader
looking in mobs/witch.rs would find only goals and drinking.

The tests cover the four tag kinds, a physical hit passing through
untouched, and a witch's own indirect magic doing nothing.
2026-10-31 10:23:05 +05:30
arismoko 142424b58f Native mods: read block and item tags 2026-10-30 23:52:42 -05:00
rdbtCVS 1ad4d8633b Merge remote-tracking branch 'origin/main' into pr-4114
# Conflicts:
#	crates/horizon-client/src/game/lan.rs
#	crates/horizon-client/src/mobs/render.rs
2026-10-31 04:48:05 +00:00
rdbtCVSandClaude Opus 5.5 600058493e bench: bytes per player and second over each bot's time online
The bandwidth divided every bot's bytes by the whole run, so a bot that
joined late was counted for seconds it wasn't on and the rate came out
low. Divide by each bot's own connected time (bot i joins i * join_every
after the first), leave failed bots out, and say in the docstring and the
table row that it averages from login on, join included.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 04:47:30 +00:00
DetimesandClaude Sonnet 5.5 fdd6f507ec horizon-server: check the prepared game files before starting the engine (#4417)
A prepared folder that was copied only in part made the engine crash with
"os error 2" at every restart. `init` and `run` now check what the
horizon-resources.json manifest records: the derived files are there and
match its hashes, and the font and texture folders aren't empty, and name
what is wrong.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 04:45:22 +00:00
Ludovic Marie 7e170c6a64 Merge pull request #4949 from horizon-rust/fix/parity-main-bisect-per-harness
🤖 Agent: parity-main's bisect judges each failed harness on its own, so one harness that fails at the base (lunge_motion on 10-31) no longer hides the merges that broke the others. Verified: tests/test_parity_main.py (mixed and two-culprit cases), pr_ready --quick (487 Python tests), CI green.
2026-10-31 05:45:10 +01:00
rdbtCVSandClaude Opus 5.5 0291f63727 CONTRIBUTING: CC0-1.0 is a public-domain dedication, not a Creative Commons license
The off-limits list named "Creative Commons licenses" while CC0-1.0 is
allowed. Name the licenses meant (CC BY, CC BY-SA, CC BY-NC...) and say
CC0 is a dedication, not one of them. The template and
how-horizon-is-built.md now name BSD-2-Clause and BSD-3-Clause, as
CONTRIBUTING does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 04:44:56 +00:00
levzzzandClaude Sonnet 5.5 27c38eb27b Merge hr/main into feat/mod-mob-effects
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 09:44:50 +05:00
rdbtCVSandClaude Opus 5.5 2ed80412b1 docs/server-next.md: line total, join worst, quiet channel and tracker paths
The gameplay parts listed sum to about 420 000 lines, not 300 000.
Horizon's worst join was 249 ms (50 bots), as the table says.
The Horizon-brand payload is horizon:quiet_commands, and Entities::tick
and mob_meta live in lan/entity.rs and game/lan.rs (lan/tracking.rs is
the generated tracking table).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 04:43:33 +00:00
levzzzandClaude Sonnet 5.5 a49b9a6b88 Address review: simplify mob_effect_id, read effects through Builtin::parse, test command-registries, docs
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 09:43:09 +05:00
levzzz 53e565516f Merge pull request #4925 from reservedbytes/feat/content-crafting-remainder
feat(mods): content items declare a crafting remainder
2026-10-31 09:43:05 +05:00
levzzzandClaude Sonnet 5.5 9a4eb4add8 Merge hr/main; renumber player-health HUD hook to API 27
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 09:41:30 +05:00
Ludovic MarieandClaude Opus 5.5 c58859aedf PlayerMotion: Clone, for the movement prediction's replays
The prediction plays each client tick again on a copy of the player, one
copy per candidate input. Everything in PlayerMotion derives Clone but
its attributes: their identity allocator was a Box<dyn FnMut>, now a
Box<dyn Identities> (any FnMut() -> i32 that is Clone), and
ObjectOpenMap's table (an Rc<RefCell>) is copied, not shared, so a
copy's changes never reach the original.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 05:40:59 +01:00
levzzz 0d327cc093 Merge pull request #4475 from reservedbytes/feat/native-config-dir
feat(mods): native mods get the game's config folder
2026-10-31 09:39:52 +05:00
levzzzandClaude Sonnet 5.5 cffc83bb8b Merge hr/main into feat/content-crafting-remainder
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 09:33:46 +05:00
levzzz f253ba22d4 Merge pull request #4921 from reservedbytes/feat/content-block-item-components
feat(mods): block items patch their template item's data components
2026-10-31 09:31:51 +05:00
levzzzandClaude Sonnet 5.5 78858b1d42 Drop the translations no code asks for any more
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 09:30:59 +05:00
Ludovic MarieandClaude Opus 5.5 cb59fcd162 Main's parity: probes for the new block and sting events, post_effects always saved
parity-main has failed since a batch of merges on 10-31:

- block_update, random_tick: #4703 and #4724 added the structure_block,
  test_block and test_block_tick block events, which the probe printed as
  unknown (`X`). Their work lives on the block entity (game/test_blocks.rs,
  the structure block screen); a fresh structure block has no name to save
  or load. The probe skips them, as it does command_block.
- mob_ai bee_sting: a bee's sting on the player became
  MobEvent::StingPlayer (stingers stuck in the body); the probe didn't count
  it as a hit on the player, so the player's hurt sound was missing.
- save_roundtrip: ServerPlayer.addAdditionalSaveData stores `post_effects`
  even when it is empty; Horizon left the key out.

Also #4662's a_swimming_passenger_sits_up_even_when_standing_does_not_fit,
which fails whenever the vanilla data is present: the player stood in the
air at y 9.65 (the scene's floor away from the pyramid tops out at y 4), so
standing fit. The feet now go 0.35 into that floor.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 05:30:39 +01:00
levzzzandClaude Sonnet 5.5 eda9860e1d Merge hr/main; renumber native config folder to API 26
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 09:30:19 +05:00
Jose Gil 5b492b240f feat(launcher): localize Modpacks tab and port coverage report 2026-10-31 00:29:57 -04:00
levzzz 0a28a10e93 Merge remote-tracking branch 'hr/main' into own/3891 2026-10-31 09:29:52 +05:00
levzzz 9e06cafb83 Merge #3855 into native-mods-where 2026-10-31 09:29:52 +05:00
levzzz 7110152122 Merge #3870 into native-mods-where 2026-10-31 09:29:38 +05:00
levzzz 6c78d82c5d Merge pull request #4574 from arismoko/native-server-sounds
Native mods: play vanilla sounds from server code
2026-10-31 09:29:20 +05:00
levzzzandClaude Sonnet 5.5 a82a28179d Merge main into native-mods-on-by-default; GitHub installs no longer set the removed switch; Up to date compares each mod's files
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 09:29:08 +05:00
Jose Gil 44d3e49db8 feat(launcher): localize AI agent and Data collection settings sections 2026-10-31 00:27:20 -04:00
levzzzandClaude Sonnet 5.5 a3ba9256a4 Native hooks proposal: bring up to date with main, cite NeoForge's public docs per claim, drop uncited detail
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 09:27:06 +05:00
levzzz a00475b910 Merge remote-tracking branch 'hr/main' into rv/4402 2026-10-31 09:26:26 +05:00
Ludovic MarieandClaude Opus 5.5 e5c713c00b parity-main: judge each failed harness on its own when bisecting
bisect ran the failed harnesses as one group and asked "does any of them
fail?". On 10-31 (run 41157154666) lunge_motion failed at the base while
the 8 others passed there, so the whole group "failed at the base too" and
no merge was named for 8 real regressions.

run_harnesses now returns the harnesses that failed (from parity.py's
last-run.json). bisect drops those that pass again at HEAD (flakes),
reports those that fail at the base too, and searches the merge that broke
the rest; harnesses broken by different merges each get theirs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 05:26:17 +01:00
levzzz 8b1758dc73 Merge pull request #4579 from gravitacia/codex/roadmap-resource-packs
Import resource packs with modpacks
2026-10-31 09:25:28 +05:00
rdbt 623e7b03dc Merge pull request #3988 from senjinthedragon/hdr-windows-auto
HDR: Auto on Windows, from the luminances the OS reports
2026-10-31 04:24:36 +00:00
levzzz a2bc79274b Merge pull request #4814 from josegil1909/fix/4812-mods-page-i18n
feat(launcher): localize Mods browser, filters, and GitHub mods panel
2026-10-31 09:22:09 +05:00
rdbtCVSandClaude Opus 5.5 8d9d4d94e3 Server: the newcomer doesn't read its own join line, as in vanilla
PlayerList.placeNewPlayer broadcasts multiplayer.player.joined before
players.add, so only the others read it. #4634 sent it to the newcomer
too, which made dedicated_server and secure_chat fail. Fixes #4943.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 04:21:35 +00:00
levzzzandClaude Sonnet 5.5 809dd639e2 Merge hr/main; renumber native sounds hook to API 25
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 09:19:50 +05:00
rdbt 970376b6c5 Merge pull request #4058 from ult1mq/codex/windows-allocator-options
Add an opt-in Windows Segment Heap build feature
2026-10-31 04:19:16 +00:00
levzzz ffb3d2d1ea Merge pull request #4817 from arismoko/native-set-block-4808
Native mods: set a server block (API next)
2026-10-31 09:18:46 +05:00
levzzz 0d1e3dfd41 Merge remote-tracking branch 'hr/main' into fix/pr-4921 2026-10-31 09:18:26 +05:00
levzzz e5dde40e72 Merge pull request #4700 from arismoko/cleanup-content-identity
Validate content registry identity data
2026-10-31 09:18:19 +05:00
levzzzandClaude Sonnet 5.5 ff5db37b0e Check block component values for max_stack_size and rarity
Block files have no top-level keys for them, so the same 1..=99 and rarity checks apply inside components.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 09:16:30 +05:00
levzzz 37fd66562f Merge remote-tracking branch 'hr/main' into fix/pr-4921 2026-10-31 09:16:12 +05:00
rdbt 07042859e1 Merge pull request #4939 from reservedbytes/fix/dragon-fire-and-wind-charge
fix(dragon): parts refuse fire and a perched dragon shrugs off wind charges
2026-10-31 04:15:48 +00:00
levzzzandClaude Sonnet 5.5 b499251187 Resource packs: a bad pack never blocks import or launch; hard-link fallback for downloads
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 09:15:43 +05:00
rdbt 6197116a9e Merge pull request #4940 from horizon-rust/server-asks/spectator-check
tools: end-to-end check that spectators stay hidden on a dedicated server
2026-10-31 04:15:43 +00:00
Ludovic Marie ad531e6374 Merge pull request #4903 from horizon-rust/fix/server-packet-small
🤖 Agent: rebased after the merger's conflict STOP. Verified on 16fd80bdc: pr_ready --quick (the one failure is main's #4662 test), tools/server_packet_rules_e2e.py and tools/server_container_click_e2e.py pass.
2026-10-31 05:15:31 +01:00
rdbtCVSandClaude Opus 5.5 dfb6d1f0fb Merge main into hdr-windows-auto: docs/graphics.md keeps main's HDR paragraph with the Windows Auto sentence
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 04:15:17 +00:00
levzzz 9f2a4433ec Merge remote-tracking branch 'hr/main' into fix/pr-4579 2026-10-31 09:13:56 +05:00
levzzz 7c9efae60a Merge pull request #3745 from UMCEKO/mods/port-no-phantom-items
Port pack: item models only an item's overrides use are not items
2026-10-31 09:13:50 +05:00
levzzz c829600ac6 Merge pull request #3744 from UMCEKO/mods/port-armor-equipment
Port pack: armour of a mod's own material draws its own textures
2026-10-31 09:13:19 +05:00
rdbt f4088409ee Merge pull request #4927 from Web-mc1/fix-pvp-death-message
Name the attacking and diying player in PvP death messages
2026-10-31 04:13:03 +00:00
rdbt 3063237a06 Merge pull request #4906 from TobiasLogic/shaderpacks/iris-shadow-camera
Shader packs: sunPathRotation and the shadow camera as Iris has them
2026-10-31 04:11:48 +00:00
rdbt e75d4bc1b6 Merge pull request #4938 from k3yh/fix/phantom-line-of-sight
Phantoms target only a player they can see (#4929)
2026-10-31 04:11:45 +00:00
levzzzandClaude Sonnet 5.5 ceca6301a6 Launcher: update the Mods page header translations for the trimmed text
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 09:11:26 +05:00
rdbt 1b62d8d1f2 Merge pull request #4920 from reservedbytes/fix/dragon-crystal-credit
fix(dragon): a destroyed crystal credits its player, else the nearest
2026-10-31 04:11:14 +00:00
rdbt 52e186749d Merge pull request #4934 from reservedbytes/fix/explosion-pushes-refused-mobs
fix(explosion): mobs are pushed even when the hit is refused
2026-10-31 04:11:10 +00:00
rdbt ba40840da7 Merge pull request #4931 from reservedbytes/fix/creeper-blast-credit
fix(combat): a creeper's blast is a player_explosion
2026-10-31 04:10:23 +00:00
levzzzandClaude Sonnet 5.5 c97b85dc0b Polish: fix mixed-script word in the GitHub mods panel text
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 09:10:22 +05:00
levzzz fd4a625f7e Merge remote-tracking branch 'hr/main' into fix/pr-4814 2026-10-31 09:10:09 +05:00
levzzzandClaude Sonnet 5.5 bceac87629 Native mods: renumber set-block hook to API 24, create block entities, drain level events, fix flags docs
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 09:09:46 +05:00
levzzz e005da2cc0 Merge pull request #4478 from rdbtCVS/launcher-hide-unusable-sources
Launcher: hide the Mods page's sources and tabs that aren't usable yet
2026-10-31 09:09:43 +05:00
arismoko 406648ebe2 Native mods: play vanilla sounds from server code 2026-10-30 23:09:35 -05:00
rdbt 477ae00887 Merge pull request #4470 from horizon-rust/chore/claim-limit-8
/claim: hold up to 8 open issues, not counting ones a PR already fixes
2026-10-31 04:09:33 +00:00
levzzz 3c481aa243 Merge pull request #4848 from horizon-rust/fix/mods-screen-native-hint
Mods page: native mods installed but not built say so
2026-10-31 09:09:02 +05:00
levzzz ddd00efcbb Merge pull request #4836 from horizon-rust/fix/native-mods-linux
Launcher: say what a Linux native build lacks before it starts
2026-10-31 09:08:23 +05:00
levzzz c6cf721a91 Merge remote-tracking branch 'hr/main' into fix/pr-4700 2026-10-31 09:08:20 +05:00
levzzz 85c0d5fe3c Merge pull request #4746 from arismoko/native-level-tick
Native mods: per-level tick hooks (API 23)
2026-10-31 09:08:10 +05:00
Ludovic MarieandClaude Opus 5.5 048dafddad tools: end-to-end check that spectators stay hidden on a dedicated server
Two real Horizon clients on a dedicated server: the survival player's
client must not be sent the spectator at all (no entity, so no name tag,
armour or crosshair target), right after the switch, after a teleport onto
the player, a death and respawn, a trip to the Nether and back, either
player joining again; it shows the player again in survival, and the
spectator still sees everyone (vanilla's ServerPlayer.broadcastToPlayer).

It fails on the commit before #4204 (the spectator was sent, only
invisible) and passes on main.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 05:05:27 +01:00
levzzzandClaude Opus 5.5 d33d377090 Merge main into launcher/native-builds-stay-put; review fixes
Conflict: rust_toolchain.rs `Toolchain::command` doc (main's no-console note, #4841).

Review fixes (arismoko 1-4, dimaniojk 2):
- low_priority on Windows sets CREATE_NO_WINDOW | BELOW_NORMAL_PRIORITY_CLASS: creation_flags
  replaces the whole value, so with main's process::command the console window came back.
- Linux I/O: best-effort level 7 instead of the idle class, which can stall a build while the
  game streams chunks.
- builds_run_at_the_lowest_priority reads the child's niceness and I/O priority back with
  getpriority/ioprio_get instead of `ionice || echo idle`.
- outside_configs doc says what it is: the filesystem root above the launcher's data.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 08:58:37 +05:00
reservedbytes f651954913 fix(commands): /place template places as a structure template does 2026-10-31 02:57:18 -01:00
Ludovic Marie 8f91f896a5 Merge pull request #4919 from horizon-rust/feat/anticheat-prediction-record
🤖 Agent: prediction (a), recording only, off by default. Verified: pr_ready (1288 tests), lan_bot test of the record and the pong rules, server_entities and server_player_damage harnesses pass. dedicated_server's one diff is main's #4634 join line (fix: #4937). 60-bot A/B: main 4.35 MSPT, off 4.41, on 4.33 (noise).
2026-10-31 04:49:13 +01:00
reservedbytes 27888dc18d fix(dragon): parts refuse fire and a perched dragon shrugs off wind charges 2026-10-31 02:48:04 -01:00
Ludovic MarieandClaude Opus 5.5 16fd80bdcf Server packet rules: world border, carried item, game mode, beacon, pick block
ServerGamePacketListenerImpl, where the server differed:
- handleAttack and handleInteract: a target outside the world border is
  ignored (WorldBorder.isWithinBounds(target.blockPosition())).
- handleSetCarriedItem: a slot past the hotbar logs "<name> tried to set
  an invalid carried item"; a new slot stops only a main-hand use.
- handleChangeGameMode without the permission logs "Player <name> tried
  to change game mode to <mode> without required permissions".
- handleSetBeaconPacket: effects the beacon can't give, or no payment,
  disconnect (multiplayer.disconnect.generic) with "Player <name> tried
  to set invalid beacon effects".
- handlePickItemFromBlock: only a loaded block.

horizon-bots gains --raw TICK:ID:HEX, and
tools/server_packet_rules_e2e.py checks the first four on a dedicated
server.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 04:46:44 +01:00
k3yhandClaude Opus 5.5 b4da90182f Phantoms target only a player they can see (#4929)
Phantom's target, strategy and its target's keep checks call
canAttack(level, player, TargetingConditions.DEFAULT), whose test needs
line of sight (Sensing.hasLineOfSight). Horizon's checks only looked at
the player being alive and in survival, so a phantom took a player
boxed under a roof or glass and kept swooping at it. Check sight there
too; the sweep keeps vanilla's check without it.

The test world fixture takes a closure that places blocks, and the
fox test's player moves to a shared test_player.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 22:44:47 -05:00
rdbt 87a9d03fd8 Merge pull request #4050 from ult1mq/codex/mesh-buffer-reuse
Reuse worker meshing scratch and bounded mesh capacity
2026-10-31 03:37:29 +00:00
rdbtCVSandClaude Opus 5.5 43e29d4556 Merge main into codex/mesh-buffer-reuse
SectionMesh::clear also resets the new tight bounds (#4244), and the padding
reuse test expects a missing neighbour to continue the centre's edge column
(ee7d443c8).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 03:28:02 +00:00
rdbt 480f6b3d1b Merge pull request #3993 from Vasysik/perf/sunrise-sector-selection
Reduce sunrise fan intersection work with projected sector selection
2026-10-31 03:25:53 +00:00
rdbt d4c60e0ac4 Merge pull request #4386 from elythepreciouscat/perf/light-chunk-scratch-and-traversal
Chunk lighting: reuse worker scratch buffers and optimize flood-fill traversal
2026-10-31 03:18:59 +00:00
rdbtCVSandClaude Opus 5.5 4af27b50a8 sunrise_fan: restore the comments' Möller and × characters
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 03:16:52 +00:00
reservedbytes 0b83386c7e fix(explosion): mobs are pushed even when the hit is refused 2026-10-31 02:16:27 -01:00
rdbt a10b26b9ef Merge pull request #4244 from elythepreciouscat/perf/lce-tight-bounds-prioritized-meshing
Render: tight chunk section bounds for resident frustum culling
2026-10-31 03:16:20 +00:00
levzzz 8acefbec58 Merge pull request #4926 from levzzz5154/parity/BAN-notices
Show ban notices before the title screen (Gui.addInitialScreens)
2026-10-31 08:16:06 +05:00
rdbt da2acdad41 Merge pull request #4924 from horizon-rust/fix/launcher-de-json
Launcher: fix lang/de.json breaking the launcher job on main
2026-10-31 03:15:08 +00:00
reservedbytes d020b5f93c fix(combat): a creeper's blast is a player_explosion 2026-10-31 02:14:40 -01:00
rdbt 1641b3ecc0 Merge pull request #4008 from Joansitoh/fix/windows-pack-reload-speed
Resource packs: native overlay junctions and parallel staging on Windows (7 s → 2.3 s)
2026-10-31 03:11:56 +00:00
levzzzandClaude Sonnet 5.5 e8b180b01e Show the account's ban notices before the title screen
Gui.addInitialScreens: BanNoticeScreens for a multiplayer ban, a forced name
change and a banned skin, fetched off the main thread for an online session;
the Multiplayer button follows Minecraft.allowsMultiplayer.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 08:07:37 +05:00
rdbtCVSandClaude Opus 5.5 c84b15d426 Launcher: de.json parses again and has only the strings every language has
#4123 left a closing brace in the middle of lang/de.json, so the file no
longer parsed and the launcher showed English for German. It also added
99 strings no other language translates, which the i18n test rejects:
a string translated in one language must be in all of them. Those 99
leave de.json until the other languages get them too (the German text
stays in 669aa04e6); #4123's corrections to existing strings stay.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 03:07:16 +00:00
Web-mc1andClaude 23f917e309 Name the attacking player in PvP death messages
Co-Authored-By: Claude <[email protected]>
2026-10-31 16:04:08 +13:00
rdbt 8ba17d98d9 Merge pull request #3893 from josegil1909/feat/client-spanish-lang
feat(client): add Spanish language support for Horizon settings
2026-10-31 03:02:55 +00:00
rdbt e51c33efdd Merge pull request #4644 from WooCaash/fix/4558-item-bubble-column-jump
Move grounded items when a bubble column lifts them
2026-10-31 03:01:22 +00:00
rdbt 388bdfe072 Merge pull request #3960 from Joansitoh/fix/skins-after-pack-reload
Skins and maps upload again after a resource pack reload
2026-10-31 03:00:41 +00:00
rdbt e2b0b817d7 Merge pull request #4287 from Sticky-Bug/feat/monochrome-logo-option
options: enable Monochrome Logo option (darkMojangStudiosBackground)
2026-10-31 03:00:37 +00:00
rdbt 678f0ac6f4 Merge pull request #4188 from GIGABAIT93/fix/scaled-riders
Riders sit in their scale, and on text displays: NPC names over big heads
2026-10-31 03:00:11 +00:00
rdbt de76c32fe3 Merge pull request #4789 from WooCaash/fix/4408-chat-links
Fix chat open_url links from legacy clickEvent JSON
2026-10-31 03:00:07 +00:00
rdbt af2df073d7 Merge pull request #4908 from senjinthedragon/hdr-end-flash
HDR: the End sky and End flash keep the brightest mark
2026-10-31 02:59:48 +00:00
rdbt b7fef4337b Merge pull request #4914 from sirrayi/tools/port-ledger-blockpattern
Port ledger: the BlockPattern rows point at the file that ports it
2026-10-31 02:59:44 +00:00
levzzz 465a8fda1e Merge pull request #4918 from levzzz5154/parity/B4-oit-rest
Improved transparency: the rest of the entity render types with an OIT pipeline set
2026-10-31 07:58:04 +05:00
rdbt 69ba6c5645 Merge pull request #4506 from MSedgeMC/fix/allay-potion-match
Make an allay tell potions apart
2026-10-31 02:55:25 +00:00
rdbt 61883f815e Merge pull request #4502 from MSedgeMC/fix/brute-angry-sound
Fixes the `PiglinBruteAi.playActivitySound` and `maybePlayActivitySound` rows in the port ledger. (No issue.)
2026-10-31 02:55:21 +00:00
rdbt a25e830b28 Merge pull request #4123 from kerimgrande/main
add more german translations
2026-10-31 02:52:33 +00:00
rdbt 8bb6e0dc11 Merge pull request #4504 from MSedgeMC/fix/spawn-egg-components
Apply a spawn egg's components to the mob it spawns
2026-10-31 02:52:01 +00:00
levzzzandClaude Sonnet 5.5 4bbb1d3b42 Improved transparency: xp orbs, shulker shells, player heads, dragon eyes, HDR eye mark
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 07:50:17 +05:00
levzzz 3dc5b432fe Merge pull request #4907 from levzzz5154/parity/K4-exact-components
Hash exact component predicates in container and bundle_contents predicates
2026-10-31 07:41:12 +05:00
rdbtCVS b043f3977b Merge remote-tracking branch 'origin/main' into fix-4504
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
#	crates/horizon-client/src/game/dispenser.rs
2026-10-31 02:40:58 +00:00
sirrayiandDeepSeek Harness cb8feeeff9 Port ledger: the BlockPattern rows point at the file that ports it
#4892 moved the golem patterns onto block_pattern.rs and deleted
golems.rs::find_pattern, so the rows naming golem.rs::Game::find_golem for
BlockPattern.matches and BlockPattern.translateAndRotate named a function that
no longer holds the port (#4913). They now point at block_pattern.rs, which is
where the wither already used it, and BlockPattern.find gets the row it never
had; the four golem pattern rows name their constants.

Generated with DeepSeek Harness (deepseek-v4.1-flash)

Co-Authored-By: DeepSeek Harness <[email protected]>
2026-10-31 02:40:24 +00:00
levzzz d454cd53fb Merge pull request #4909 from levzzz5154/parity/B3-oit-entities
Improved transparency: entity render types with an OIT pipeline set
2026-10-31 07:38:47 +05:00
Łukasz KopańskiandCursor e5089d3fe4 Retrigger verify after label/note-only checks.
Co-Authored-By: Cursor <[email protected]>
2026-10-31 03:38:21 +01:00
Łukasz KopańskiandCursor dd68ab987a Drop the idle-item vertical gate; cover bubble lift with real ticks.
Gravity always leaves |vy| non-zero before the move check, so || vertical
ran move every tick for resting items and broke vanilla's 1-in-4 skip.
Idle ticks already push via BubbleColumnBlock.entityInside; keep that and
assert the stone velocity sequence plus a soul-sand column rise.

Co-Authored-By: Cursor <[email protected]>
2026-10-31 03:38:21 +01:00
Łukasz KopańskiandCursor 3bd2b8b820 rustfmt the bubble-column item idle path.
Co-Authored-By: Cursor <[email protected]>
2026-10-31 03:38:21 +01:00
Łukasz KopańskiandCursor d46cb2ca35 Move grounded items when a bubble column lifts them.
Idle ItemEntity ticks skipped move for 3/4 ticks even after the column
set vertical speed, so drops on soul sand/magma only hopped. Apply the
column inside tick (before drag) like vanilla, and still move when vy
is already set.

Co-Authored-By: Cursor <[email protected]>
2026-10-31 03:38:21 +01:00
rdbtCVS e3a24beec6 Merge remote-tracking branch 'origin/main' into fix-4506
# Conflicts:
#	crates/horizon-client/src/game/allays.rs
2026-10-31 02:38:05 +00:00
rdbtCVS 587b85d675 Merge remote-tracking branch 'origin/main' into fix-4502
# Conflicts:
#	crates/horizon-client/src/mobs/piglin.rs
2026-10-31 02:37:59 +00:00
rdbt deff92036a Merge pull request #4551 from foudretbr/fox-night-screech
Foxes screech at night when no player is near
2026-10-31 02:34:55 +00:00
rdbt 230d0d5911 Merge pull request #4643 from Joansitoh/fix/arrow-pickup-animation
Arrows fly into whoever picks them up on servers and the server thread
2026-10-31 02:34:51 +00:00
rdbt fcd9f1b401 Merge pull request #4661 from DenisHumen/fix/bundle-wheel-in-stations
Bundles: the wheel selects in a crafting table's and a station's menu
2026-10-31 02:34:47 +00:00
rdbt 68f554e7dc Merge pull request #4546 from MSedgeMC/chore/ledger-stale-rows
Retarget ledger rows that are already ported
2026-10-31 02:34:43 +00:00
rdbt ee637b2567 Merge pull request #4857 from mulareal/fix-death-sound-4570
death: play the player death sound only once on fatal damage
2026-10-31 02:34:39 +00:00
rdbt cc6bfe2bc3 Merge pull request #4696 from WooCaash/fix/4684-bucket-empty-sound
Play empty-bucket sounds on the predicting client
2026-10-31 02:34:35 +00:00
rdbt 57b2aa470b Merge pull request #4866 from Iam-Sanja/fix/dedicated-playerdata-overwrite
fix: prevent dedicated world saves from overwriting player data
2026-10-31 02:34:31 +00:00
rdbt c8ad6a8840 Merge pull request #4879 from xFedeT/fix/server-level-dat-fallback
A dedicated server takes level.dat_old when level.dat does not read
2026-10-31 02:34:27 +00:00
rdbt 57064f15d0 Merge pull request #4662 from WooCaash/fix/4509-cushion-swim-pose
Sit up on a cushion after swimming
2026-10-31 02:34:23 +00:00
rdbt 21f460c630 Merge pull request #4659 from WooCaash/fix/4548-sulfur-cube-fuse-anim
Sync SulfurCube MAX_FUSE for the client fuse swell
2026-10-31 02:34:19 +00:00
rdbt d05e95409d Merge pull request #4730 from WooCaash/fix/4714-server-day-night
Fix inverted day/night on multiplayer servers
2026-10-31 02:34:15 +00:00
rdbt f4852b0660 Merge pull request #4512 from xFedeT/fix/random-stand-parity
RandomStandGoal parity: named as vanilla's, herd scenarios for the equines
2026-10-31 02:34:09 +00:00
rdbt 9304a5387e Merge pull request #4876 from josegil1909/fix/4821-horse-footstep-sounds
fix(client): play horse footstep sounds while riding
2026-10-31 02:34:04 +00:00
rdbt 6b25a75238 Merge pull request #4895 from horizon-rust/fix/villager-show-trades-offers
Roll a villager's offers when it shows trades
2026-10-31 02:34:00 +00:00
levzzzandClaude Sonnet 5.5 36e376591a Merge hr/main into parity/K4-exact-components
Replaces the name-based COMPONENT_BOOLS heuristic with the per-component codec shapes.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 07:31:26 +05:00
rdbt 926875ace4 Merge pull request #4710 from WooCaash/fix/4694-dedicated-peaceful-difficulty
Apply server.properties difficulty on dedicated startup
2026-10-31 02:30:07 +00:00
levzzzandClaude Sonnet 5.5 1b4ef1de92 Improved transparency: entity render types with an OIT pipeline set
Translucent, emissive, eyes and breeze wind entity quads, lightning and the
additive dragon geometry go through the OIT stages when the option is on.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 07:29:37 +05:00
levzzzandClaude Sonnet 5.5 91e1f8a8cd Hash exact component predicates nested in container and bundle_contents predicates
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 07:27:05 +05:00
rdbt 575c9b20df Merge pull request #4892 from sirrayi/fix/golem-block-pattern
Golems: express the golem patterns with the shared BlockPattern finder
2026-10-31 02:25:13 +00:00
rdbt e65680f925 Merge pull request #4761 from arismoko/tidy-native-particle-queue
Tidy the native particle queue
2026-10-31 02:24:52 +00:00
rdbt e24e645156 Merge pull request #4864 from sirrayi/fix/redstone-piston-retract
Redstone: a retracting piston no longer breaks the dust on top of it
2026-10-31 02:24:48 +00:00
rdbt d5ff486d31 Merge pull request #4660 from DenisHumen/fix/looting-mob-drops
Looting on the killer's weapon counts on a server's mobs
2026-10-31 02:24:43 +00:00
rdbt b477ff2689 Merge pull request #4885 from ErikKalikas/launcher-native-build-offline
Launcher: keep native mods' commit dates so a kept build works offline
2026-10-31 02:23:54 +00:00
rdbt 8a65204b3a Merge pull request #4884 from xFedeT/fix/hash-container-predicate
Hash a container or bundle_contents predicate whose item predicate has exact components
2026-10-31 02:23:50 +00:00
rdbt 8115a3a4b1 Merge pull request #4779 from WooCaash/fix/4343-controller-right-stick-look
Fix 8BitDo Ultimate 2 right-stick look on Linux
2026-10-31 02:23:46 +00:00
Ludovic MarieandClaude Opus 5.5 d26c8fba0a Anticheat prediction (a): record each client tick and the server ticks it saw
The first step of the 1:1 movement prediction (planned with rdbtCVS on
(the packets between two ServerboundClientTickEndPackets: keys held,
sprinting, rotation, the move and its flags), the last two seconds of
them. Each server tick ends with a ClientboundPingPacket whose id is the
tick; the client's pong, which comes before its next tick, says from
which client tick on that server tick's packets were in effect.

A `prediction` check in [anticheat] turns it on (with the anticheat). It
records only and flags nothing yet; the next PRs play each tick again
with vanilla's movement code and compare. Pongs don't count as actions
(the idle timeout), as in vanilla.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 03:23:22 +01:00
rdbt 65bc3d80b5 Merge pull request #4837 from mulareal/fix-slime-attack-sound-4569
mobs: play slime and magma cube attack sound only when hurtServer succeeds
2026-10-31 02:23:09 +00:00
SenjinandClaude Opus 5.5 5c5107b72d HDR: the End sky and End flash keep the brightest mark
#4060's tier in fs_sky_celestial gave geometry that sets no tier (tier 0) the
lowest mark instead of the brightest it had before. Found by DUzzL's review.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 03:22:47 +01:00
rdbtCVSandClaude Opus 5.5 0a6d8aa90a Dedicated startup: a hardcore world stays hard
MinecraftServer.setDifficulty forces HARD for a hardcore world, so
difficulty=peaceful in server.properties must not make one peaceful.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 02:22:36 +00:00
rdbt 497e13808f Merge pull request #4634 from cryingtoilet/fix/4536-own-join-message
Send a joining player its own join message (#4536)
2026-10-31 02:21:45 +00:00
rdbt 90ca4e0491 Merge pull request #4697 from horizon-rust/fix/floating-unloaded-chunks
Floating kick: a chunk not loaded yet is not air
2026-10-31 02:21:30 +00:00
rdbt 23d5461fe0 Merge pull request #4748 from sirrayi/lod-sampler-cache
LOD: skip density work for tiles wholly inside visited chunks
2026-10-31 02:21:26 +00:00
rdbt 65688ae0f0 Merge pull request #4744 from apeirs/fluid-spread-no-clones
Fluids: spread without cloning shapes or locking per step
2026-10-31 02:21:22 +00:00
rdbt 8b718c5d5e Merge pull request #4841 from horizon-rust/fix/native-build-no-console
Launcher: native builds open no console window on Windows
2026-10-31 02:20:47 +00:00
rdbt c5d7bae5fe Merge pull request #4608 from k3yh/fix/vulkan-acquire-fence-fifo-only
Windows Vulkan: wait for the acquire fence only with VSync (gfx-rs/wgpu#10714)
2026-10-31 02:20:43 +00:00
rdbt b296f7e2ea Merge pull request #4875 from reservedbytes/tooling/pr-ready-rust-version
fix(tools): pr_ready says when local rust is older than ci's
2026-10-31 02:20:39 +00:00
rdbt 0f4db0c569 Merge pull request #4641 from WooCaash/fix/4565-enderman-boat-sit
Sit an enderman that rides a boat
2026-10-31 02:20:36 +00:00
rdbt f541e240fb Merge pull request #4819 from Qwerner88880/fix-item-command-guest-slots
/item reaches a connected player's slots
2026-10-31 02:20:32 +00:00
levzzz 0ba58d9229 Merge pull request #4898 from levzzz5154/parity/ime-preedit
IME: show composition text under the caret and place the candidate window
2026-10-31 07:20:30 +05:00
rdbt 450d8c35dc Merge pull request #4692 from doodlyyousuf/fix/4421-boat-ice-slide
Boats slide on ice (getGroundFriction)
2026-10-31 02:20:28 +00:00
levzzz e280d96cf2 Merge pull request #4900 from levzzz5154/parity/T5-gametest-dims
Game tests run in any dimension
2026-10-31 07:20:20 +05:00
levzzz 241684332d Merge pull request #4897 from levzzz5154/parity/B2-oit-features
Improved transparency: route features, particles, weather, world border and flat draws
2026-10-31 07:19:59 +05:00
levzzz 59ec871592 Merge pull request #4901 from levzzz5154/parity/place-entities
/place structure and /place jigsaw: spawn the entities the pieces carry
2026-10-31 07:18:13 +05:00
reservedbytes b4a85e32e3 fix(dragon): a destroyed crystal credits its player, else the nearest 2026-10-31 01:17:47 -01:00
rdbt cbf9b8d5ab Merge pull request #4762 from josegil1909/fix/es-sponsor-chip-overflow
fix(launcher): shorten Spanish sponsor discount label to prevent chip overflow
2026-10-31 02:17:44 +00:00
rdbt 00643bca10 Merge pull request #4769 from josegil1909/fix/launcher-telemetry-notice-layout
fix(launcher): constrain telemetry notice width in settings to prevent overflow
2026-10-31 02:17:39 +00:00
rdbt 7ac8e3a30a Merge pull request #4606 from apeirs/auto-upload-time-cap
Chunk uploads: a 2 ms cap per frame when the player set no budget
2026-10-31 02:17:35 +00:00
rdbt 1fdcf6b385 Merge pull request #4630 from WooCaash/fix/4550-tnt-slime-bounce
Bounce primed TNT when a slime piston hits it
2026-10-31 02:17:31 +00:00
rdbt 6d669b0a59 Merge pull request #4695 from WooCaash/fix/4681-strip-sound-once
Play axe strip sounds only once for the actor
2026-10-31 02:17:27 +00:00
rdbt 93b9775439 Merge pull request #4709 from WooCaash/fix/4508-slime-honey-item-texture
Cull back faces on translucent slime/honey items
2026-10-31 02:16:57 +00:00
rdbt c723e0894c Merge pull request #4701 from WooCaash/fix/4688-cherry-grove-lod-tint
Keep cherry leaf pink in far LOD terrain
2026-10-31 02:16:53 +00:00
rdbt 6f1e0a2463 Merge pull request #4734 from WooCaash/fix/4190-dragon-crystal-rays
Clear end crystal revival beams when dragon respawn ends
2026-10-31 02:16:49 +00:00
rdbt 08191e2f40 Merge pull request #4640 from cryingtoilet/fix/4562-activator-rail-eject
Place a rider ejected by an activator rail beside the cart (#4562)
2026-10-31 02:16:45 +00:00
rdbt 439e0ee259 Merge pull request #4771 from reservedbytes/fix/starvation-recorded-once
fix(combat): starvation is recorded once
2026-10-31 02:16:41 +00:00
rdbt a18e7d5d1f Merge pull request #4874 from DmytroChup/fix-4777
Apply equipment attribute modifiers from the off hand
2026-10-31 02:16:37 +00:00
Ludovic Marie ec27a1e0b0 Merge pull request #4890 from horizon-rust/fix/decode-limits
🤖 Agent: unit tests: a 32768-byte payload (play and configuration) and a 32501-char suggestion are refused, the limits pass; 8 emoji pass a 16-unit limit and fail 15. 3 server harnesses pass; pr_ready --quick green.
2026-10-31 03:14:35 +01:00
Ludovic Marie 1d4b04ceb3 Merge pull request #4888 from horizon-rust/fix/container-click-slot-range
🤖 Agent: proved with tools/server_container_click_e2e.py on a dedicated server: a THROW click on a chest's slot 63 no longer throws inventory slot 9 (it did on main); lan_bot a_click_past_the_menu_is_ignored fails on main's containers.rs and passes here. 3 server harnesses and lan_e2e chest/containers/large_chest/shulker_menu/clicks pass; menu-transfer fails the same on main (its stale count takes the armour ids 500-504).
2026-10-31 03:14:32 +01:00
rdbtCVS 07c8bdbc0b Merge main 2026-10-31 02:14:21 +00:00
rdbtCVS adfda4bd26 Merge main 2026-10-31 02:14:20 +00:00
rdbtCVS 502a3026aa Merge main 2026-10-31 02:14:18 +00:00
rdbtCVS 8c7779e17f Merge main 2026-10-31 02:14:17 +00:00
rdbtCVS 9d9fe2298e Merge main 2026-10-31 02:14:16 +00:00
reservedbytes 717b49cbc9 feat(mods): native mods can change their blocks on shape updates 2026-10-31 01:12:21 -01:00
levzzz 3839d80076 Merge remote-tracking branch 'hr/main' into parity/ime-preedit 2026-10-31 07:11:54 +05:00
levzzz 26752debe1 Merge remote-tracking branch 'hr/main' into parity/place-entities 2026-10-31 07:11:54 +05:00
reservedbytes 7dd10acb86 feat(mods): content items declare a crafting remainder 2026-10-31 01:11:48 -01:00
reservedbytes 2a8ae891ae feat(mods): block items patch their template item's data components 2026-10-31 01:11:47 -01:00
levzzz 65d68266e2 Merge remote-tracking branch 'hr/main' into parity/B2-oit-features 2026-10-31 07:11:46 +05:00
levzzz c418e970be Merge remote-tracking branch 'hr/main' into parity/T5-gametest-dims 2026-10-31 07:11:26 +05:00
rdbt 8930bb3faf Merge pull request #4902 from horizon-rust/fix/streaming-section-mask-build
Fix main's build: meshing candidates carry a SectionMask
2026-10-31 02:11:22 +00:00
TobiasLogicandClaude Opus 5.5 d01ff3b8a4 Shader packs: sunPathRotation and the shadow camera as Iris has them
sunPathRotation tilted the sun's path toward +Z; Iris tilts it toward -Z. The shadow camera now
matches Iris's matrices: the view undoes the tilt, turns about Z by the light's angle from the
zenith, then 90 degrees about X, with its origin at interval / 2 - camera % interval on each world
axis; the 100-block offset toward the light is in shadowProjection, whose orthographic depths run
from -near to far. Measured with probe packs in Minecraft 26.3 + Iris 1.11.7 (Iris's source not
read); the_shadow_camera_matches_iris checks the values.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 09:09:44 +07:00
rdbtCVSandClaude Opus 5.5 cd9867da5c Fix main's build: meshing candidates carry a SectionMask
#3961 typed the candidates' sections as u32 while the level-shape work made
a column's dirty sections a SectionMask; SectionMask now orders so
nearest_first can sort them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 02:05:11 +00:00
levzzzandClaude Sonnet 5.5 1ee73d821d Spawn the entities /place structure and /place jigsaw's pieces carry
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 07:04:39 +05:00
levzzz 7680fee325 Merge remote-tracking branch 'hr/main' into parity/B2-oit-features 2026-10-31 07:04:12 +05:00
levzzzandClaude Sonnet 5.5 860dbf51e1 Fix main's build: streaming candidates hold a SectionMask
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 07:04:05 +05:00
levzzzandClaude Sonnet 5.5 3ef3272ee2 Game tests run in any dimension: per-dimension grids, level swapped in for placement, levels with tests keep ticking
Also fixes the streaming candidate list type (SectionMask) that broke main.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 07:03:33 +05:00
levzzzandClaude Sonnet 5.5 9cc33bdda8 Fix main's build: streaming candidates hold a SectionMask
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 07:02:31 +05:00
levzzz 2d621405c6 Merge remote-tracking branch 'hr/main' into parity/ime-preedit 2026-10-31 07:00:51 +05:00
levzzzandClaude Sonnet 5.5 a0b0a5ba50 Show the input method's composition under the caret and place its candidate window there
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 07:00:51 +05:00
levzzzandClaude Sonnet 5.5 5fbb18a139 Improved transparency: route features, particles, weather, border and flat draws
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 07:00:12 +05:00
levzzz 1fdd195790 Merge pull request #4527 from kacpherek/fix/screens-batch
Screens, HUD and chat parity: 29 fixes (toasts, chat trust, recipe book, bundles, world list, sounds)
2026-10-31 06:59:04 +05:00
levzzz 95fe89ca0d Merge pull request #4886 from levzzz5154/parity/T4-test-highlight
/test pos: the highlight packet and the client's block highlight
2026-10-31 06:57:48 +05:00
rdbtCVSandClaude Sonnet 5.5 cfb61d623a Roll a villager's offers when it shows trades
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 01:57:38 +00:00
levzzz 711f099ac8 Merge pull request #4342 from MSedgeMC/fix/4309-place-structure
Wire /place structure to the live level
2026-10-31 06:56:49 +05:00
sirrayiandDeepSeek Harness 4f43f49cf7 Redstone: a retracting piston no longer breaks the dust on top of it
PistonBaseBlock.triggerEvent writes the retracting base as a `moving_piston`
and still updates the neighbours around it, so the dust above the base is asked
whether it can survive. The exported support facts of a moving block carry no
block entity and call every face unsupported, while vanilla reads that block
through PistonMovingBlockEntity.getCollisionShape, whose shape holds the
extended base; the dust dropped and was removed (#4728).

Both dust support checks now read the block through Sim::support_state, the
indirection #/5782df1c0 added for the same class of mistake on a lever: the
survive check uses sturdy(below, Up) and updateShape's down arm passes the
position instead of the neighbour's raw state.

Generated with DeepSeek Harness (deepseek-v4.1-flash)

Co-Authored-By: DeepSeek Harness <[email protected]>
2026-10-31 01:56:00 +00:00
rdbt ad7b45d3b6 Merge pull request #3961 from WhyFovius/perf/chunk-streaming-priority
Streaming: light and mesh jobs bounded in flight, nearest first
2026-10-31 01:54:27 +00:00
rdbt 35fbfb00dd Merge pull request #3987 from senjinthedragon/hdr-shortcut
HDR: a shortcut key to its settings, unbound by default
2026-10-31 01:54:21 +00:00
rdbt 9ca4d10841 Merge pull request #4177 from Domanffe/feat/game-ru-strings
Graphics screens: their text through the language table
2026-10-31 01:54:15 +00:00
rdbt 9e2c0dfde2 Merge pull request #4148 from Domanffe/fix/launcher-language-row
Launcher: the language row is a dropdown, not a segment per language
2026-10-31 01:54:11 +00:00
rdbt 7b52aa1f85 Merge pull request #4175 from DUzzL/fix/issue-4174-online-servers-default
Check Minecraft accounts for new launcher servers by default
2026-10-31 01:54:06 +00:00
rdbt 05a7663aab Merge pull request #4062 from senjinthedragon/hdr-blocks-2
HDR: light sources tiered from a look at each in game
2026-10-31 01:54:02 +00:00
rdbt 2f82051fb4 Merge pull request #4060 from senjinthedragon/hdr-moon-2
HDR: the moon shines at the low tier, not the sun's
2026-10-31 01:53:57 +00:00
rdbt f1b2d7b86f Merge pull request #4061 from senjinthedragon/hdr-eyes-2
HDR: mobs' glowing eyes shine like glowstone
2026-10-31 01:53:53 +00:00
rdbt 223a97787d Merge pull request #4415 from SlaVKsVolks/contribute/horizon-ledger-jar-20261030
Port ledger: support builds with JAR-only inputs
2026-10-31 01:53:48 +00:00
rdbt e5e27a7aac Merge pull request #4426 from SlaVKsVolks/contribute/horizon-benchmark-conditions-20261030
Benchmark: reject comparisons with incompatible conditions
2026-10-31 01:53:43 +00:00
Ludovic MarieandClaude Opus 5.5 a65989354d Packets past vanilla's decoder limits disconnect
What vanilla's codecs refuse, so its decoder disconnects the player:
- a custom payload over 32767 bytes (DiscardedPayload), in configuration
  and in play;
- a command suggestion request over 32500 characters (it was ignored);
- a string longer than its limit in Java's UTF-16 units: a character
  outside the BMP counts twice (Utf8String.read), where Horizon counted
  Unicode characters.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 02:51:11 +01:00
levzzzandClaude Sonnet 5.5 83c79c4d4f Add restore_held_keys to the capture test's fixture
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 06:49:42 +05:00
levzzz 5b372e2f35 Merge pull request #4883 from levzzz5154/parity/B2-oit
Improved transparency: water mask and cloud passes
2026-10-31 06:49:15 +05:00
levzzzandClaude Sonnet 5.5 e3eb634eba /test pos: ClientboundGameTestHighlightPosPacket and the client's block highlight
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 06:47:28 +05:00
levzzzandClaude Sonnet 5.5 c00e5801aa Merge hr/main into fix/screens-batch
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 06:46:50 +05:00
rdbt b1f7c78d54 Merge pull request #4306 from MSedgeMC/fix/sheared-undercoat
Keep the sheep undercoat when the sheep is sheared
2026-10-31 01:46:39 +00:00
rdbt f9676a54ce Merge pull request #4197 from kacpherek/fix/gameplay-world-scale
Scale attribute: the view-blocking check follows the player's scale
2026-10-31 01:46:31 +00:00
rdbt b9f5a45c62 Merge pull request #4268 from DUzzL/fix/issue-4254-scaffolding-sneak-descend
Let a sneaking player descend through scaffolding on the server
2026-10-31 01:46:22 +00:00
rdbt b357010174 Merge pull request #4137 from DUzzL/fix/issue-4127-armor-stand-passenger
Seat armour-stand name holograms on the player they ride
2026-10-31 01:46:14 +00:00
levzzz 9b611a200b Merge remote-tracking branch 'hr/main' into fix/pr-4342 2026-10-31 06:44:26 +05:00
levzzz 2fc88ea632 Merge pull request #4881 from levzzz5154/parity/T3-test-env
Game tests: test environments
2026-10-31 06:44:15 +05:00
levzzz 10a4ec4e2d Merge pull request #4347 from DUzzL/fix/issue-4338-ime-commit
Accept IME text composition in the game's text fields
2026-10-31 06:44:11 +05:00
levzzzandClaude Sonnet 5.5 5ddb4828b5 Fix clippy collapsible ifs; open the Optimize test's world editor before its data goes stale
Edit is disabled for a world that needs file fixing (LevelSummary.canEdit),
so the test now opens the edit screen first.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 06:43:10 +05:00
ErikKalikasandClaude Opus 5.5 9bb2c8818d Launcher: keep commit dates so a native build of split-pin mods works offline
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 03:42:53 +02:00
levzzz 40f8bd80da Merge pull request #4588 from phexuss/fix/smithing-armor-stand-preview
Show the armour stand preview in the smithing table
2026-10-31 06:42:14 +05:00
levzzz e44f25cae1 Merge pull request #4581 from phexuss/fix/shoulder-parrots
Draw the parrots on the player's shoulders
2026-10-31 06:42:06 +05:00
sirrayi 65d2b5e8fe Merge remote-tracking branch 'origin/main' into lod-sampler-cache-v2 2026-10-31 01:42:04 +00:00
rdbt c620da0f19 Merge pull request #4265 from DenisHumen/fix/parrot-stands-when-hurt
A hit stands a sitting tame parrot up
2026-10-31 01:39:59 +00:00
rdbt e1093d6800 Merge pull request #4311 from Teiwazik/feat/pack-folder-watch
Resource pack screen: find packs added to the folder while it is open
2026-10-31 01:39:53 +00:00
rdbt 148b33f41d Merge pull request #4109 from Benflex1/fix/4080-inventory-effect-tooltip-order
Draw item tooltips over inventory effect panels
2026-10-31 01:39:47 +00:00
rdbt b80768cd4a Merge pull request #4184 from ASOwnerYT/fix/tamed-wolf-owner
Tamed pets no longer attack the owner who hits them
2026-10-31 01:39:40 +00:00
levzzz c8a8bf357b Merge hr/main into parity/B2-oit 2026-10-31 06:39:25 +05:00
levzzz d914ceeb53 Merge pull request #4850 from levzzz5154/parity/B1-oit
Improved transparency: order-independent terrain pass
2026-10-31 06:39:16 +05:00
Federico TaglianiandClaude Sonnet 5.5 b213e5346b Hash a container or bundle_contents predicate whose item predicate has exact components
The nested components travel as NBT, whose Codec.BOOL values are bytes. The
bool fields of the component codecs are named, so the predicate hashes as
vanilla's HashOps does; a byte under any other name still leaves it unhashed.
Hashes printed by vanilla 26.3 for four cases (glint override true and false,
damage and a custom name with bold, a food with can_always_eat).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 02:39:05 +01:00
Ludovic Marie 2509598c13 Merge pull request #4877 from horizon-rust/fix/client-loaded-gate
🤖 Agent: proved with tools/server_client_loaded_e2e.py on a dedicated server: a client that never sends PlayerLoaded stays put for 1.5 s and moves after the 60-tick timeout, a loaded one moves at once (fails on main). 7 server harnesses and lan_e2e respawn/spawned/portals/digging/deathmining pass; pr_ready --quick green.
2026-10-31 02:38:53 +01:00
rdbtCVSandClaude Opus 5.5 f0d20beff8 Merge main into fix/sheared-undercoat
Keep main's jeb_ wool colours; only SheepWoolLayer stops on a sheared
sheep, SheepWoolUndercoatLayer still draws.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 01:37:05 +00:00
levzzzandClaude Sonnet 5.5 934136e813 Merge hr/main into the /place structure branch
Main added set_block_entity and entities to LevelFeatureWorld; drop the
duplicate impl and initialise the new field. Keep both corpus groups.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 06:36:49 +05:00
levzzz b2bf03c749 Merge hr/main 2026-10-31 06:36:30 +05:00
levzzz 22b8af021c Merge pull request #4878 from levzzz5154/parity/T3-test-cmds
/test verify, locate, pos, create
2026-10-31 06:36:26 +05:00
rdbtCVSandClaude Opus 5.5 111081b1bc Merge main into fix/gameplay-world-scale
Carry the scale into main's near-plane view-blocking check
(LevelExtractor.getViewBlockingState: 0.1F * getScale()).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 01:33:28 +00:00
levzzz 1d383e73b3 Merge remote-tracking branch 'hr/main' into fix/pr-4347 2026-10-31 06:33:28 +05:00
levzzzandClaude Sonnet 5.5 bb674debd6 Merge hr/main; smithing preview holds every non-armour item in the left hand
SmithingScreen.updateArmorStandPreview's switch falls to the left hand for a
null slot and for every slot but head, chest, legs and feet (main hand, off
hand, body, saddle), not nothing.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 06:33:06 +05:00
levzzzandClaude Sonnet 5.5 32b24a9e8e Merge hr/main into fix/screens-batch
Drops the portal loading reason, the shallow-angle sprint check and the
incompatible-server ping, which main ports already; keeps the Reconfiguring
screen on main's level loading, and the held-key restore beside main's
offscreen grab.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 06:32:51 +05:00
levzzzandClaude Sonnet 5.5 ee837e88be Merge parity/B1-oit into parity/B2-oit
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 06:32:39 +05:00
rdbtCVSandClaude Opus 5.5 470fb25a89 Merge main into fix/issue-4254-scaffolding-sneak-descend
Resolve the conflict with main's changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 01:31:44 +00:00
rdbtCVSandClaude Opus 5.5 40fee24b86 Merge main into fix/issue-4127-armor-stand-passenger
Resolve the conflict with main's changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 01:31:41 +00:00
levzzzandClaude Sonnet 5.5 29c318b541 Translate the OIT shader with terrain.wgsl in the HLSL test
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 06:30:48 +05:00
sirrayiandDeepSeek Harness a01203c3b2 Chat: native mods' commands join the tree the chat colours and completes
Dispatcher::with_mod_commands still does what #463 added, but since WebAssembly
code mods went away nothing but tests called it, so a native mod's command ran
while the chat box drew it as unknown, never completed it and never listed it
(#4613).

native_commands is the hook point that names them (API 23), and chat_tree_for
builds the tree once where its base changes -- at the start of a connection and
when the server sends its own -- so the chat and the completion provider see one
built tree instead of the mods being asked at each keystroke.

Generated with DeepSeek Harness (deepseek-v4.1-flash)

Co-Authored-By: DeepSeek Harness <[email protected]>
2026-10-31 01:30:13 +00:00
levzzzandClaude Sonnet 5.5 a8f57b1997 Merge hr/main; sync shoulder parrots through DATA_SHOULDER_PARROT_LEFT/RIGHT
The layer read the local player's saved compounds, so other players' parrots
never drew and a server-thread window had none. The server now syncs the
OptionalInt variant (Player ids 19 and 20), clients read it for every player,
and Parrot.Variant.byId clamps out-of-range ids instead of using the first.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 06:30:09 +05:00
Ludovic MarieandClaude Opus 5.5 94e2666542 lan_bot: a click past a chest's menu is ignored
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 02:29:58 +01:00
Ludovic MarieandClaude Opus 5.5 2541cba005 Container clicks past the menu are ignored, as in vanilla
AbstractContainerMenu.isValidSlotIndex: a click on a slot past the open
menu does nothing. The hub mapped it to the inventory past its 36 slots
(a chest's slot 63 reached the main inventory's slot 9), so a click
could throw or take items outside the menu. The bundle selection
(setSelectedBundleItemIndex) has the same range now.

The packets' codecs: an unknown click type reads as PICKUP (ByIdMap,
OutOfBoundsStrategy.ZERO), and a bundle index below -1 disconnects
(ServerboundSelectBundleItemPacket's constructor).

horizon-bots gains --click, and tools/server_container_click_e2e.py
checks a click on a chest's slot 63 on a dedicated server.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 02:29:58 +01:00
sirrayiandDeepSeek Harness 9c54d6b294 Golems: express the golem patterns with the shared BlockPattern finder
Vanilla has one matcher, BlockPattern.find, for the golem and wither structures;
Horizon had two, so orientation and matching-order fixes had to be made twice
(#4430, one such case was #3929).

The four golem patterns are BlockPattern constants beside the wither's now, and
find_pattern is gone. The search order, orientation set and cell mapping are
unchanged: the tests keep the orientations they had and add the matching order
and the base pattern's wildcard head.

Generated with DeepSeek Harness (deepseek-v4.1-flash)

Co-Authored-By: DeepSeek Harness <[email protected]>
2026-10-31 01:29:46 +00:00
levzzz 6eac540995 Merge pull request #4862 from levzzz5154/parity/K3-hash
Hash block predicates with nbt or partial predicates and books with filtered text
2026-10-31 06:29:01 +05:00
rdbtCVSandClaude Opus 5.5 93892c4049 Merge main into fix/parrot-stands-when-hurt
Keep both test sets in mobs/tame.rs (main's wolf shake tests, the sitting tests here).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 01:28:47 +00:00
levzzz 067b5877d2 Merge hr/main 2026-10-31 06:28:19 +05:00
levzzz 52890d2f8f Merge hr/main 2026-10-31 06:28:05 +05:00
reservedbytes 6521dffcee feat(mods): native mods can change their blocks on scheduled ticks 2026-10-31 00:28:03 -01:00
levzzz cbc12f96bf Merge pull request #4873 from levzzz5154/parity/T3-test-verify
Game tests: entity removal, force-loaded chunks
2026-10-31 06:28:01 +05:00
Federico TaglianiandClaude Sonnet 5.5 b1c5484243 Optimize world: level.dat_old stands in for a level.dat that does not read
OptimizeWorldScreen.create reads the level data through
getUnfixedDataTagWithFallback too.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 02:27:20 +01:00
rdbtCVSandClaude Opus 5.5 59dc4a6f14 Merge main into feat/pack-folder-watch
Resolve the conflict with main's changes in game.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 01:26:56 +00:00
rdbtCVSandClaude Opus 5.5 f1ed2f36e6 Merge main into fix/4080-inventory-effect-tooltip-order
Resolve the conflict with main's changes in game.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 01:26:52 +00:00
reservedbytes 1028b8f19c fix(tools): pr_ready says when local rust is older than ci's 2026-10-31 00:26:29 -01:00
reservedbytes c832b61617 feat(mods): native mods can change their blocks on random ticks 2026-10-31 00:26:27 -01:00
Dmytro ChuprynaandClaude Opus 5.5 4cc2631bc0 Apply equipment attribute modifiers from the off hand
Include offhand in apply_equipment and treat EquipmentSlotGroup.HAND as both hands in equipment_modifier_applies, matching vanilla 26.3.

Fixes #4777

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 02:26:25 +01:00
Federico TaglianiandClaude Sonnet 5.5 4e8bdad871 The dedicated server takes level.dat_old when level.dat does not read
LevelStorageAccess.getUnfixedDataTagWithFallback, which the server's Main
calls, falls back to level.dat_old and restores it in place
(restoreLevelDataFromOld). Horizon refused to open such a world unless it
was opened from the world list, which a dedicated server does not have. The
client keeps asking on RecoverWorldDataScreen, as WorldOpenFlows does.
Port map: collectIssues, restoreLevelDataFromOld and readLevelSummary were
still listed as missing after #4219.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 02:25:28 +01:00
rdbtCVSandClaude Opus 5.5 f77d6da3bd Merge main into fix/tamed-wolf-owner
Keep both new mob_ai cases (wolf_shake/wolf_rain from main, wolf_owner_hurt here).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 01:24:36 +00:00
rdbt 46a3761a41 Merge pull request #4240 from kacpherek/fix/trouble-portals-carts
Portal trips keep their destination running, and minecarts follow the track steps a server sends
2026-10-31 01:23:28 +00:00
rdbt 8b4aa65bbd Merge pull request #1760 from horizon-rust/perf/structure-start-convoy
World generation: workers build free structure starts instead of queueing
2026-10-31 01:23:23 +00:00
Jose Gil 8676990b18 fix(client): play horse footstep sounds while riding 2026-10-30 21:23:17 -04:00
levzzz 6f899414a7 Merge hr/main into parity/B1-oit 2026-10-31 06:23:17 +05:00
Jose Gil e3bcd0fdba fix(client): return true in remote_start_held_use for goat horn 2026-10-30 21:23:14 -04:00
levzzz a52f904ff5 Merge hr/main 2026-10-31 06:22:24 +05:00
levzzz f4d91f2fbb Merge hr/main into parity/T3-test-verify 2026-10-31 06:22:01 +05:00
levzzzandClaude Sonnet 5.5 cf8cab121c Game tests: test environments (game rules, weather, clock time, difficulty, functions, all_of)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 06:22:01 +05:00
levzzz 7bcd15e22d Merge remote-tracking branch 'hr/main' into parity/K3-hash 2026-10-31 06:21:45 +05:00
levzzzandClaude Sonnet 5.5 c17233b793 /test verify, locate, pos and create
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 06:19:39 +05:00
Ludovic MarieandClaude Opus 5.5 c644282b7b lan_bot: the hub tests' guests have loaded their level
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 02:19:26 +01:00
Ludovic MarieandClaude Opus 5.5 704dff73bd Ignore a player's moves and actions until their client has loaded
ServerGamePacketListenerImpl.hasClientLoaded: until the client sends
ServerboundPlayerLoadedPacket, or 60 ticks pass (60 again after a
respawn), and while the player is dead, vanilla ignores their moves,
vehicle moves, block actions, item use, attacks, interactions, spectator
actions and player commands, and PlayerInput doesn't change crouching.
The server only skipped a rotation-only move before the first position,
and never re-armed the wait after a respawn.

horizon-bots gains --no-loaded, and tools/server_client_loaded_e2e.py
checks a dedicated server ignores such a client for its first 3 seconds.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 02:19:26 +01:00
rdbt 2fb0d5b493 Merge pull request #4870 from horizon-rust/fix/main-goat-horn-return
Fix main's build: the goat horn's held use returns true
2026-10-31 01:18:57 +00:00
sirrayiandomp b8511b4888 LOD: test the all-cached sampler path
Extract the cache fast path and check it returns exactly each cell's
Column::reduced, including negative chunk coordinates and a partial miss.

Co-Authored-By: omp (DeepSeek V4.1 Flash) <[email protected]>
2026-10-31 01:15:11 +00:00
levzzzandClaude Sonnet 5.5 88fd421269 Game tests: remove entities in a cleared test area and on success, force-load the test's chunks
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 06:14:10 +05:00
Cosmic ce1500dbbb Merge pull request #4835 from horizon-rust/fix/pack-path-audit
Resource packs: zip entries never leave the overlay folder
2026-10-30 21:13:30 -04:00
rdbt 3811132bd2 Merge pull request #4261 from Teiwazik/fix/overlay-vanilla-stamps
Pack overlay: stamp vanilla's files by their metadata on Windows
2026-10-31 01:11:17 +00:00
rdbt 4c6d49a02f Merge pull request #4030 from d3grando/fix-3061-pad
Controller toggle-sneak comes back after a screen closes
2026-10-31 01:11:12 +00:00
Ludovic MarieandClaude Opus 5.5 4f17e42f93 Fix main's build: the goat horn's held use returns true
#4085 made remote_start_held_use return whether the use starts, and the
goat horn's arm (from the goat horn PR, merged beside it) still ended in
`return;`. The horn starts its own use (remote_use_instrument), as the
trident and the shield do, so it returns true like them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 02:10:37 +01:00
levzzz ddd3d4d0e3 Merge pull request #4861 from levzzz5154/parity/E2-post-chain-shaders
Post chains run the resources' own GLSL, with texture and depth inputs
2026-10-31 06:07:59 +05:00
rdbt 12e5483917 Merge pull request #4218 from DenisHumen/fix/server-service-windows
Servers: "Run in the background and at login" works on Windows without admin rights
2026-10-31 01:04:07 +00:00
levzzzandClaude Sonnet 5.5 d7ff063035 Hash block predicates with nbt or partial predicates, and books with filtered text, as vanilla does
BlockPredicate.nbt (NbtPredicate flattened to its SNBT string), the partial
DataComponentPredicate map, and Filterable.filtered of writable and written
book pages and titles now reach HashedPatchMap.create. StringTagVisitor moves
to horizon-world so the protocol can use it.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 06:03:34 +05:00
sirrayi c597f06d65 Merge remote-tracking branch 'origin/main' into lod-sampler-cache-v2 2026-10-31 01:03:00 +00:00
rdbt c904d2fa1c Merge pull request #4152 from DUzzL/fix/issue-4147-same-click-block-replace
Run a same-click attack before the use in single player
2026-10-31 01:02:45 +00:00
rdbt 094a083540 Merge pull request #4231 from DenisHumen/fix/creative-armor-swap
Creative armour swap gives the worn piece back to the hand
2026-10-31 01:02:41 +00:00
rdbt 5c188c619b Merge pull request #4154 from spidercraft/far-terrain-nether-respawn
Far terrain: the dimension the player is in, not the server's level in place
2026-10-31 01:02:37 +00:00
rdbt cce03e9367 Merge pull request #4085 from DenisHumen/fix/bow-no-arrows
Bows and crossbows: no use animation with nothing to shoot
2026-10-31 01:02:32 +00:00
rdbt c08119ea7a Merge pull request #4139 from DUzzL/fix/issue-4133-toggle-sprint-alt-tab
Keep latched toggle sprint and sneak across a short focus loss
2026-10-31 01:02:28 +00:00
levzzz 088390b4fe Merge remote-tracking branch 'hr/main' into parity/E2-post-chain-shaders 2026-10-31 06:00:47 +05:00
Ludovic Marie 4f5c8307e8 Merge pull request #4852 from horizon-rust/fix/creative-slot-validation
🤖 Agent: proved with tools/server_creative_slot_e2e.py on a dedicated server: slots 1..45 within the stack size are kept; slot 0, slot 46, a sword ×2 and pearls ×17 are ignored; a stack of 100 disconnects (the same run fails on main). pr_ready --quick green; dedicated_server, server_entities, server_player_damage pass.
2026-10-31 01:58:46 +01:00
levzzzandClaude Sonnet 5.5 b3516f66e2 Post chains run the resources' own GLSL: texture and depth inputs, translated with naga
PostChain/PostPass over any shader the active resource root provides
(vanilla's post shaders and packs' own), translated GLSL to naga at run
time and bound as vanilla binds them (SamplerInfo, Globals, the pass's
uniform blocks, a sampler per input). Targets keep GL's rows. The WGSL
stand-ins are the fallback when a pass does not translate.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 05:56:47 +05:00
rdbt db83571428 Merge pull request #4597 from xFedeT/fix/move-through-village-map
Port map: MoveThroughVillageGoal is ported for zombies
2026-10-31 00:54:38 +00:00
levzzz c58580ead2 Merge pull request #4853 from levzzz5154/parity/T2-test-retries
Game tests: retries and flaky tests
2026-10-31 05:54:15 +05:00
mularealandAntigravity 1aae74b8b1 death: play the player death sound only once on fatal damage
Fixes #4570

Co-Authored-By: Antigravity <[email protected]>
2026-10-31 01:53:07 +01:00
levzzz 270431ec55 Merge pull request #4851 from levzzz5154/fix/win-disk-space-unsafe
Fix the Windows build: free disk space without unsafe code
2026-10-31 05:53:00 +05:00
rdbt 6c9ab657fd Merge pull request #4481 from xFedeT/fix/parrot-follow-mob-goal
Parrots follow other mobs and hover near them (FollowMobGoal)
2026-10-31 00:52:36 +00:00
rdbt 4851a7ab4d Merge pull request #4542 from xFedeT/fix/piglin-back-up
Crossbow piglins back away from a target within 5 blocks (BackUpIfTooClose)
2026-10-31 00:51:59 +00:00
levzzz 7570dcff24 Merge pull request #4843 from levzzz5154/parity/I-aniso
Texture Filtering (RGSS, Anisotropic) and Max Anisotropy on terrain
2026-10-31 05:47:13 +05:00
rdbt 9e2440253f Merge pull request #4357 from xFedeT/fix/spawn-egg-peaceful-tooltip
Spawn egg tooltip: the red "Disabled in Peaceful" line for a monster's egg
2026-10-31 00:46:55 +00:00
levzzz ee4108fdaf Merge remote-tracking branch 'hr/main' into parity/T2-test-retries 2026-10-31 05:46:07 +05:00
levzzzandClaude Sonnet 5.5 5d41303466 Game tests: retries and flaky tests
RetryOptions (/test run .. numberOfTimes untilFailed), ReportGameListener's attempt and success counters, GameTestRunner.rerunTest and runScheduledRerunTests, max_attempts / required_successes.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 05:46:06 +05:00
levzzzandClaude Sonnet 5.5 0e515dbfd3 Improved transparency: water mask and cloud passes
LevelRenderer.executeOitWaterMask and CloudRenderer.renderOit.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 05:45:56 +05:00
levzzz 5747b52d0c Merge pull request #4845 from levzzz5154/parity/T2-error-marker
Test instance error markers: filled box and billboard text
2026-10-31 05:45:26 +05:00
levzzzandClaude Opus 5.5 bc105393f0 Fix the Windows build: read free disk space without unsafe code
#4732 called GetDiskFreeSpaceExW through an unsafe extern block in
save.rs, but the client lib is #![forbid(unsafe_code)], so every
Windows build failed. Linux CI never compiles the cfg(windows) branch.
fs4::available_space answers the same question safely on every
platform, so the same code now runs on Linux CI too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 05:44:09 +05:00
Ludovic MarieandClaude Opus 5.5 5fe97cd6d1 Mods page: native mods installed but not built say so
Options > Mods said "No mods: put .hmod files in the horizon-mods
folder" while the launcher had installed native mods (.hnmod) that this
game wasn't built with, so players copied the files into horizon-mods
(#4770). Each such mod now has its row, "(Not built)", whose tooltip
says to build the game with it in the launcher and play the instance
that build is in. A native build's own mods (its native-mods.json) and
mods already listed get no second row. The toggle is the mod.<id> line
the launcher's next build follows.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 01:40:33 +01:00
levzzz e1c4d5dc88 Merge remote-tracking branch 'hr/main' into parity/T2-error-marker 2026-10-31 05:39:02 +05:00
levzzzandClaude Sonnet 5.5 49b4dbec3d Test instance error markers: filled translucent box and billboard text
TestInstanceRenderer.submitErrorMarker: Gizmos.cuboid with a fill (not a stroke) and Gizmos.billboardText at scale 0.16, always on top.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 05:39:01 +05:00
levzzzandClaude Sonnet 5.5 2e1da6db52 Texture Filtering (None, RGSS, Anisotropic) and Max Anisotropy on terrain
Port LevelRenderer's chunk layer sampler (linear, clamped, anisotropy when
Anisotropic) with texture_sampling.glsl's sampleNearest and sampleRGSS in
terrain.wgsl. None keeps the nearest sampler and plain sampling. The two Video
rows are enabled; Max Anisotropy greys out unless Anisotropic is chosen.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 05:38:43 +05:00
Jose Gil 1f2ebdbd91 feat(client): add Spanish language support for Horizon settings 2026-10-30 20:37:52 -04:00
Ludovic Marie 0820541751 Merge pull request #4796 from horizon-rust/fix/dedicated-op-ip-bans
🤖 Agent: proven on the Mac mini: server_ops_e2e (Horizon client as op, levels 2/3/4), dedicated_server, server_ops_feedback, chat_screen, command_parse, command_exec; pr_ready --quick green. Stacked on #4795: merge after it.
2026-10-31 01:37:41 +01:00
Iam-Sanja cd53a3c0dd fix: prevent dedicated world saves from overwriting player data 2026-10-31 00:34:54 +00:00
levzzz c39b4ed4b8 Merge pull request #4833 from levzzz5154/parity/T3-test-command
/test: run, runmultiple, runthese, runclosest, runthat, runfailed, reset, clear, stop
2026-10-31 05:34:53 +05:00
levzzzandClaude Sonnet 5.5 9fd99d5a4d Improved transparency: the order-independent terrain pass
GameRenderer.useImprovedTransparency gates LevelRenderer.executeOit for the translucent terrain: depth bounds, transmittance and accumulate stages against the opaque depth, executeDepthBoundsCull, and the composite. Off: unchanged.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 05:34:32 +05:00
Ludovic MarieandClaude Opus 5.5 4ecf521195 Launcher: native builds open no console window on Windows
cargo, horizon-mixins and rustc are console programs and the launcher
has no console to share, so a native build showed an empty cmd window
for as long as it ran (#4770). Toolchain::command now starts them
through process::command, with CREATE_NO_WINDOW like the launcher's
other background programs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 01:34:02 +01:00
Ludovic MarieandClaude Opus 5.5 adf54b0c68 Launcher: say what a Linux native build lacks before it starts
A native build on Linux compiles C (ring, hidapi) and builds against the
system's ALSA and libudev through pkg-config. Desktop distributions ship
none of their development files, and SteamOS's GCC has no C library
headers, so builds failed minutes in with a raw cargo error (#4770). The
check before downloading now looks for cc, the C library's headers,
pkg-config, alsa and libudev, and names what is missing with the
install command for the distribution in /etc/os-release.

A failed build's error also starts with cargo's first `error` lines,
which a build script's output pushed out of the last twelve.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 01:31:05 +01:00
mularealandAntigravity f554aa5a6c mobs: play slime and magma cube attack sound only when hurtServer succeeds
Fixes #4569

Co-Authored-By: Antigravity <[email protected]>
2026-10-31 01:30:51 +01:00
Ludovic Marie 9510efd2fe Merge pull request #4795 from horizon-rust/fix/dedicated-commands-tree
🤖 Agent: proven on the Mac mini: chat_screen, command_parse, command_exec 925/925, dedicated_server, server_ops_feedback, server_player_targets; pr_ready --quick green. Fixes main's red parity (#4680's tree, #4702's /fetchprofile ticks).
2026-10-31 01:30:45 +01:00
levzzz 747ccd0b5b Merge pull request #4828 from levzzz5154/parity/E3-entity-post-effect
Spectating a creeper, spider or enderman puts its post effect on the screen
2026-10-31 05:30:12 +05:00
Ludovic MarieandClaude Opus 5.5 11eecf5a6a Resource packs: zip entries never leave the overlay folder
A pack's zip entry named with `..` parts was joined to the overlay folder as
it was and written there, so a pack (a server's too) could put a file outside
it. Entries with empty, `.` or `..` parts, `:` or NUL are now left out, and
overlay directories are one plain folder, as vanilla's DIR_VALIDATOR has it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 01:29:01 +01:00
levzzz 396ff1e5f9 Merge pull request #4826 from levzzz5154/parity/E2-post-effects-server
/posteffect: per-player post effects sent with ClientboundPostEffects
2026-10-31 05:26:54 +05:00
levzzz cb93abfe66 Merge pull request #4825 from levzzz5154/parity/E1-post-chain
Post-effect chains: draw post_effect JSON over the world, handle ClientboundPostEffects
2026-10-31 05:26:19 +05:00
levzzz 70ede6a0ef Merge pull request #4827 from levzzz5154/parity/R2-load
Loading screen: integrated server's chunk map and progress, new-world delay
2026-10-31 05:26:17 +05:00
levzzz 7a6dd1ffc9 Merge hr/main into parity/T3-test-command 2026-10-31 05:26:02 +05:00
levzzz 656b7c7c9b Merge pull request #4824 from levzzz5154/parity/T2-runner
Game tests: the runner, block-based and function tests; RUN starts the test
2026-10-31 05:25:53 +05:00
levzzzandClaude Sonnet 5.5 e0cee924f0 Update the test command corpus note
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 05:24:57 +05:00
levzzz 061a7dda03 Merge pull request #4823 from levzzz5154/parity/R2-cam
Spectator cameras on more entities, camera spectators hidden from spectators
2026-10-31 05:22:01 +05:00
levzzzandClaude Sonnet 5.5 baae358894 /test: run, runmultiple, runthese, runclosest, runthat, runfailed, reset*, clear*, stop
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 05:21:47 +05:00
levzzzandClaude Sonnet 5.5 83b29e43ff Put the spectated creeper, spider or enderman's post effect on the screen
GameRenderer.checkEntityPostEffect on setCameraEntity and on the perspective toggle.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 05:20:43 +05:00
Ludovic MarieandClaude Opus 5.5 f472da81d1 Dedicated server: /ban-ip, /pardon-ip and /banlist for operators
#4680 let operators run /op, /deop, /ban, /pardon and /whitelist in game;
the IP bans and the ban list were still console-only, so an op typing them
got "Unknown or incomplete command" while the client's tree offered them.
BanIpCommands (an address or an online player's, its players kicked with
ip_banned), PardonIpCommand and BanListCommands (told to the source only),
at admins' level (3) as in vanilla, on the dedicated tree only.

tools/server_ops_e2e.py: a Horizon game as an op at ops.json level 2, 3 and
4 types /tp, /gamemode, /op, /deop, /whitelist, /ban-ip, /banlist,
/pardon-ip, /kick, /ban and /pardon on a dedicated server with bots as
targets; levels 3 and 4 run them all, level 2 only /tp and /gamemode.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 01:20:26 +01:00
levzzzandClaude Sonnet 5.5 d35bf3ba33 Integrated server's load progress and chunk map on the loading screen, 500 ms new-world delay
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 05:19:32 +05:00
levzzz 6667abcb04 Merge pull request #4820 from levzzz5154/parity/R2-disk
Dedicated servers warn operators of low disk space
2026-10-31 05:19:30 +05:00
levzzzandClaude Sonnet 5.5 9b5e0026f3 Keep /posteffect per player and send ClientboundPostEffectsPacket
ServerPlayer.postEffects lives on each guest, is saved as post_effects,
sent on join/respawn and at the end of the tick when dirty.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 05:17:57 +05:00
huiandClaude Opus 5.5 d9517672ef Native mods: recipes of a mod's own types (API 23)
A recipe file whose type is outside minecraft stays loaded as its JSON, by
type: native_recipes_of_type reads them, and native_recipe_ingredient and
native_recipe_result read the usual fields.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 01:17:35 +01:00
levzzz 12eb38e131 Merge hr/main into parity/T2-runner 2026-10-31 05:16:30 +05:00
levzzz 3c1d4b39c6 Merge pull request #4816 from levzzz5154/parity/T1-runner
Test blocks: trigger, reset, redstone signal and the start block's power
2026-10-31 05:16:21 +05:00
levzzzandClaude Sonnet 5.5 c5abd34fb1 Game tests: the runner, block-based and function tests, RUN starts the test
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 05:15:51 +05:00
levzzzandClaude Sonnet 5.5 434577c913 Spectator cameras on projectiles, dragon parts, TNT and falling blocks; hide camera spectators from spectators
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 05:14:04 +05:00
levzzzandClaude Sonnet 5.5 a55abdfe89 Draw post_effect chains over the world (PostChain, PostPass, handlePostEffects)
The chains in assets/<ns>/post_effect/*.json are read from the active
resource root at run time and run over the frame after the entity outline
and before the GUI, through Horizon's own stand-ins for the vanilla post
shaders. ClientboundPostEffectsPacket sets LocalPlayer.postEffects.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 05:11:29 +05:00
levzzzandClaude Sonnet 5.5 5a70e0ba0d Dedicated servers send ClientboundLowDiskSpaceWarningPacket to operators
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 05:09:47 +05:00
levzzzandClaude Sonnet 5.5 56dcdc8464 Test blocks: trigger, reset, the redstone signal and the start block's power
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 05:07:45 +05:00
levzzz 2a009ad16f Merge pull request #4805 from levzzz5154/parity/U-levelload
Client LevelLoadTracker: the loading screen waits for the server's start and the player's section
2026-10-31 05:06:23 +05:00
Jose Gil 0b912926a3 feat(launcher): localize Mods browser, filters, and GitHub mods panel 2026-10-30 20:05:42 -04:00
Ludovic Marie 37b1f605d5 Merge pull request #4766 from horizon-rust/fix/refused-pull-no-event
🤖 Agent: verified: CI verify passes; pr_ready --quick clean but for the candle cake test that fails on main too; piston harnesses pass; the vanilla path is unchanged (nothing is refused without a mod). Follow-up to #4739's review. Queued.
2026-10-31 01:05:23 +01:00
huiandClaude Opus 5.5 d65cd8af19 /item reaches a connected player's slots
slots_named only had slots for the player in place, so from the console
every player slot was "not found". It now goes through for_player.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 01:04:22 +01:00
levzzz a6cf9d0370 Merge pull request #4804 from levzzz5154/parity/G-spectate
Spectator camera on the server (handleSpectatorAction, SetCamera)
2026-10-31 05:02:39 +05:00
levzzz 68e9da706c Merge pull request #4810 from levzzz5154/parity/C-chunk-fade
Chunk sections fade in over chunkSectionFadeInTime
2026-10-31 05:01:49 +05:00
sirrayiandomp 15dce73eb4 LOD: skip density work for tiles wholly inside visited chunks
VanillaLodSampler::grid evaluated the whole density lattice first and only
then checked its per-cell cache, so a tile covered by visited chunks paid for
every sample and discarded all of them. Check the cache first and return the
reduced columns directly when every cell is covered.

Co-Authored-By: omp (DeepSeek V4.1 Flash) <[email protected]>
2026-10-31 00:01:33 +00:00
arismoko f98890b386 Native mods: add server block-setting helper 2026-10-30 19:00:52 -05:00
Ludovic MarieandClaude Opus 5.5 f2f768bb36 command_exec harness: /fetchprofile answers on the next tick, as the oracle's
The other half of main's command_exec failure (since #4702): the oracle's
Services resolve no profile, at once, so /fetchprofile fails on the next
tick. The game asked Mojang's services over the network on a thread that
the harness's back-to-back ticks outran: no line in the recorded ticks.
The probe gets a discovery address that refuses at once
(HORIZON_API_DISCOVERY=http://127.0.0.1:9/), and the harness waits for
lookups in flight before each scripted tick (profile_fetches_pending).
925/925.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 00:57:57 +01:00
levzzz dd4f61225b Merge remote-tracking branch 'hr/main' into parity/U-levelload 2026-10-31 04:57:57 +05:00
levzzzandClaude Sonnet 5.5 1b99614b7b test_world_input_capture: follow the level load tracker's PlayerLoaded
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 04:57:56 +05:00
levzzz 2789409634 Merge pull request #4797 from levzzz5154/parity/U-multiline
Dialogs: multiline text inputs are a MultiLineEditBox
2026-10-31 04:56:24 +05:00
Ludovic MarieandClaude Opus 5.5 9f4f39addc Creative-slot packets follow vanilla's rules on the server
handleSetCreativeModeSlot takes slots 1 to 45 only (slot 0, the crafting
result, and anything past the inventory menu are ignored) and a stack no
bigger than its own stack size. Its codec takes counts of 1 to 99: more
fails the packet and disconnects the player. The server took slot 0 and
any count. The drop path (slot < 0) now uses the same rule.

horizon-bots gains --give SLOT:ID:COUNT,... and
tools/server_creative_slot_e2e.py checks it on a dedicated server.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 00:54:28 +01:00
reservedbytes 50cd60645b feat(mods): content mods declare their own mob effects 2026-10-30 22:53:59 -01:00
levzzzandClaude Sonnet 5.5 d9cbb0f4e6 Level loading: the client's LevelLoadTracker state machine
startClientLoad on login and respawn, the loading start game event, the
player's section, the 30 second timeout and the close delay. The
LevelLoadingScreen stays up (by the respawn's portal reason) until the level
is ready, and PlayerLoaded is sent then.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 04:53:56 +05:00
levzzz 3bfec3857d Merge pull request #4788 from levzzz5154/parity/U-chunk-toast
Toasts for chunk load and save failures and low disk space
2026-10-31 04:53:54 +05:00
levzzzandClaude Sonnet 5.5 37aa729526 Server side of the spectator camera: handleSpectatorAction, SetCamera, camera follow for guests
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 04:53:42 +05:00
levzzzandClaude Sonnet 5.5 1c6992a28c Chunk sections fade in over chunkSectionFadeInTime, block entities wait for 0.3
RenderSection.getVisibility, ChunkSectionInfo visibility, terrain.vsh/fsh mix with fog colour,
LevelExtractor.extractVisibleBlockEntities gate.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 04:51:14 +05:00
Federico TaglianiandClaude Sonnet 5.5 b57d440412 Spawn egg tooltip: any item with entity_data, and an id without its namespace
TypedEntityData.addToTooltip hangs on the entity_data component, not on spawn
eggs. SulfurCube is allowed in Peaceful in vanilla (no notAllowedInPeaceful), so
the egg correctly shows no line.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 00:50:28 +01:00
Federico TaglianiandClaude Sonnet 5.5 253b1f8780 Spawn egg tooltip: the red "Disabled in Peaceful" line in Peaceful
TypedEntityData.addToTooltip shows it under an egg whose entity type is not
allowed in Peaceful (a monster); Horizon showed only the op-only warning.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 00:49:25 +01:00
Federico TaglianiandClaude Sonnet 5.5 d671006122 FollowMob matches vanilla tick for tick: goal name, update interval, mob_ai scenarios
tools/test_mob_ai_parity.py gets parrot_cow_0..2 and parrot_cow_far: the
parrot and the cow match vanilla's FollowMobGoal for 300 ticks (position,
goals, look control, path and random draws).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 00:49:06 +01:00
Federico TaglianiandClaude Sonnet 5.5 bddb7c34be Parrots follow other mobs and hover near them (FollowMobGoal)
register_parrot lacked the priority 3 FollowMobGoal(1.0, 3.0, 7.0): a parrot
flies after the first visible mob of another kind within 7 blocks and keeps
about 3 blocks from it. lan_e2e check parrot_follows_mob.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 00:48:49 +01:00
Jan PresandOpenCode 23bceb746d Send a joining player its own join message
PlayerList.placeNewPlayer adds the player to the list and then calls
broadcastSystemMessage, so the newcomer reads "X joined the game" at
the top of its chat. Horizon broadcast the line with broadcast_except,
which skipped exactly that player; the message also went out before the
player was placed in the list.

Place the player first and send the line to every placed guest after the
initial packets, matching placeNewPlayer's order.

Fixes #4536

Co-Authored-By: OpenCode (DeepSeek V4.1 Flash) <[email protected]>
2026-10-30 23:48:36 +00:00
Federico TaglianiandClaude Sonnet 5.5 0c66d4678d RandomStandGoal: named as vanilla's, mob_ai herd scenarios for the equines
The row said horses rear with the angry sound and donkeys and mules never
get the goal. They do (the ambient sound, baby variants included); what
was missing was a scenario. horse, donkey, mule, zombie_horse and
skeleton_horse herds (8 each, 1200 ticks) and single equines now match
vanilla; the goal's name was 'RandomStand', vanilla's list says
'RandomStandGoal'.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 00:48:25 +01:00
levzzzandClaude Sonnet 5.5 7e902a6886 Dialog text inputs with a multiline option are a MultiLineEditBox
Port MultiLineEditBox, MultilineTextField and AbstractTextAreaWidget's frame and
scroll bar as multiline_edit::MultiLineEdit: wrapping to the box, caret and
selection moving by line, word and page keys, character and line limits, mouse
placement, drag and wheel, and the character count. TextInputHandler.addControl
sizes the box by its height or max_lines.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 04:48:23 +05:00
Federico TaglianiandClaude Sonnet 5.5 9ad0a3abf1 Crossbow piglins back away from a target within 5 blocks (BackUpIfTooClose)
Refs #3521

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 00:47:45 +01:00
levzzz 101280753b Merge pull request #4784 from levzzz5154/parity/L2-screen
Port OptimizeWorldScreen and the Edit World flow into it
2026-10-31 04:47:25 +05:00
Federico TaglianiandClaude Sonnet 5.5 90e269dc36 Port map: MoveThroughVillageGoal is ported for zombies
Refs #3521

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 00:47:20 +01:00
814a8592da Treat only integer SNBT bytes as style flags.
Co-Authored-By: Composer <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-10-31 00:43:28 +01:00
levzzz d5c4248f62 Merge pull request #4782 from levzzz5154/parity/K2-hash-3
Hash block predicates and inline trim, instrument and banner pattern holders in container clicks
2026-10-31 04:41:50 +05:00
levzzzandClaude Sonnet 5.5 0580d262da Toast chunk load and save failures and low disk space from the integrated server
IntegratedServer.reportChunkLoadFailure / reportChunkSaveFailure: region read
errors and writer-thread write errors are logged, checked for low disk space and
queued for the window, which shows SystemToast.onChunkLoadFailure / OnChunkSaveFailure
(red title) and onLowDiskSpace (ten seconds).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 04:41:35 +05:00
43f142115b Fix chat open_url links from legacy clickEvent JSON.
Tellraw/SNBT still send clickEvent with value and 1b style flags;
parse those so links underline and open ConfirmLink like vanilla.

Co-Authored-By: Composer <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-10-31 00:41:22 +01:00
levzzz d3a5cf3925 Merge hr/main into parity/L2-screen 2026-10-31 04:38:54 +05:00
levzzzandClaude Sonnet 5.5 4bbd8f3e8e Port OptimizeWorldScreen and the Edit World flow into it
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 04:38:53 +05:00
levzzz a71733bfe7 Merge pull request #4774 from levzzz5154/parity/L1-upgrader
Port WorldUpgrader (Optimize World worker)
2026-10-31 04:38:49 +05:00
levzzz 79c76034a0 Merge pull request #4773 from levzzz5154/parity/bl2-leash-holder
Boats can hold leads (Entity.interact sneak branch)
2026-10-31 04:37:39 +05:00
Ludovic MarieandClaude Opus 5.5 1af0acfc37 Commands: /op, /ban, /pardon, /deop and /whitelist only on a dedicated server
#4680 registered them in the one tree every server uses, so single player's
and a LAN world's /help and Tab offered them (chat_screen and command_exec
failed on every PR's parity). Vanilla registers them only when
Commands.CommandSelection.includeDedicated: tree_extra::register_dedicated
holds them, vanilla::build_for(dev, dedicated) adds them, and the dedicated
server turns them on with set_dedicated_commands in preflight (single
player's server thread never runs it).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 00:37:08 +01:00
levzzz e8897035e4 Merge pull request #4772 from levzzz5154/parity/K2-hash-2
Hash item behaviour components (food, tool, equippable, attributes, ...) in container clicks
2026-10-31 04:34:57 +05:00
Łukasz KopańskiandCursor 0bfa991471 Fix 8BitDo Ultimate 2 right-stick look on Linux.
gilrs' bundled SDL DB only has a Windows row for this pad; on Linux/BT the
right stick sits on ABS_Z/ABS_RX, so Horizon never turned the camera. Inject
the missing SDL mappings and fall back by vendor/product when no row matches.

Fixes #4343

Co-Authored-By: Cursor <[email protected]>
2026-10-31 00:33:40 +01:00
levzzzandClaude Sonnet 5.5 a26ebcb37e Port WorldUpgrader and RegionStorageUpgrader (Optimize World's worker)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 04:31:13 +05:00
levzzzandClaude Sonnet 5.5 8fc8f02820 Boats and mobs on a lead can hang on a boat (Entity.interact sneak branch, game events)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 04:30:07 +05:00
levzzzandClaude Sonnet 5.5 d3fd453cfa Hash block predicates and direct trim, instrument and banner pattern holders
can_place_on and can_break (blocks, state properties, exact components),
armor trims, instruments and banner patterns with inline holders, and
provides_trim_material, decoded from their stream form.

Part of #3518

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 04:28:41 +05:00
levzzz be529ea12d Merge pull request #4767 from levzzz5154/parity/51-crossed-arms
Villagers and wandering traders hold items in crossed arms (CrossedArmsItemLayer)
2026-10-31 04:27:40 +05:00
levzzz 3275d82094 Merge pull request #4764 from levzzz5154/parity/K2-hash
Hash container, profile, sign, variant and other stream-only components in container clicks
2026-10-31 04:26:02 +05:00
huiandClaude Opus 5.5 ed7f8b3998 Merge main; redstone signal hooks are API 23
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 00:25:35 +01:00
levzzz 2dcafe3ee3 Merge pull request #4763 from levzzz5154/parity/BL-boatlead
Make boats and rafts leashable (AbstractBoat is Leashable)
2026-10-31 04:24:22 +05:00
huiandClaude Opus 5.5 429fda5680 Merge main; item behaviour hooks are API 23
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 00:24:21 +01:00
huiandClaude Opus 5.5 fe4b750827 Merge main; mod messages and event hooks are API 23
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 00:24:20 +01:00
levzzzandClaude Sonnet 5.5 a87db94c53 Hash the item behaviour components like the vanilla client (part 2)
food, consumable, use_remainder, use_cooldown, damage_resistant, tool, weapon,
equippable, repairable, death_protection, blocks_attacks, piercing_weapon,
kinetic_weapon, swing animations, use_effects, attribute_modifiers, break_sound,
the fuels, compostable and mob_visibility, decoded from their stream form into
the codec form HashOps sees.

Part of #3518

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 04:22:36 +05:00
Jose Gil f15483df83 fix(launcher): constrain telemetry notice width in settings to prevent overflow 2026-10-30 19:21:32 -04:00
levzzzandClaude Sonnet 5.5 33d8b6ebeb CrossedArmsItemLayer: villagers and wandering traders show their held item; ShowTradesToPlayer displays matching offers
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 04:19:32 +05:00
Ludovic MarieandClaude Opus 5.5 917e58b2da Native mods: a refused sticky pull sends clients the blocks, not the retraction
A client isn't asked whether a piston may move: replaying the retraction's
block event, it would pull the block the server kept. When the level
refuses a pull, the event isn't broadcast (as for a push that fails), and
clients get the piston's and the head's block changes instead.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 00:18:37 +01:00
levzzzandClaude Sonnet 5.5 c830a170ad Hash the components Horizon keeps as stream bytes like the vanilla client (part 1)
Containers, pot decorations, block state, bees, typed entity data, sign text,
the NBT-only components, dye and mob variants, scalars, potion custom effects
and profiles are decoded from their stream form into the codec form HashOps
sees. The profile's skin patch is now read as vanilla writes it (texture ids,
slim flag) instead of two strings per texture.

Part of #3518

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 04:18:05 +05:00
levzzzandClaude Sonnet 5.5 5eb665b1c3 Make boats and rafts leashable (AbstractBoat implements Leashable)
Lead on/off, Leashable.tickLeash (elastic pull incl. quad, snap), save/load of the
holder, entity link packets, quad leash render for boats, lead drop on destroy.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 04:17:22 +05:00
huiandClaude Opus 5.5 e35be2e5bc Native mods: helpers for spawning and moving mobs (API 23)
native_spawn_mob, native_mob, native_mobs, native_mob_teleport,
native_mob_push, native_mob_set_health, native_kill_mob and
native_remove_mob, in the new native_mobs.rs. No new hook points.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 00:15:50 +01:00
Jose Gil d72c67d81a fix(launcher): shorten Spanish sponsor discount label to prevent chip overflow 2026-10-30 19:15:06 -04:00
Ludovic Marie 672b204c0a Merge pull request #4757 from horizon-rust/feat/report-threads
🤖 Agent: verified. Report-only change (Linux /proc and cgroup reads, null elsewhere); summary test covers both cases; pr_ready --quick green.
2026-10-31 00:14:40 +01:00
levzzz a79436fb97 Merge pull request #4741 from levzzz5154/parity/R-client
Finish rope hold position, sniffer digging sound and world stem check (parity)
2026-10-31 04:14:38 +05:00
levzzz 3da2b20cc8 Merge pull request #4754 from levzzz5154/parity/H-cmdcart
Command block minecart: edit screen, SetCommandMinecart, activator rail command, NBT
2026-10-31 04:13:41 +05:00
levzzz 4b461a9aae Merge pull request #4753 from levzzz5154/parity/R-render
Finish spectator player model and transient block removal (parity)
2026-10-31 04:12:04 +05:00
Ludovic Marie 9990eabc7f Merge pull request #4755 from horizon-rust/docs/anticheat-native-mod
🤖 Agent: docs only (docs/anticheat.md: where the anticheat lives, moves to a native mod once move/packet-timing hooks exist); CI green
2026-10-31 00:09:45 +01:00
reservedbytes 2d0c6e984f feat(mods): native mods get the game's config folder 2026-10-30 22:07:54 -01:00
levzzz d9e7cae928 Merge pull request #4749 from levzzz5154/parity/R-server
Finish dragon killing blow, lightning item bystanders and look-at entity form (parity)
2026-10-31 04:07:37 +05:00
levzzz dc29fd238b Merge hr/main into parity/R-client 2026-10-31 04:06:30 +05:00
Ludovic MarieandClaude Opus 5.5 393a461720 Server report: threads, and the container's limits on tasks and memory
The official server ran out of threads tonight (EAGAIN starting one, which
stopped its region writer until #4726). The report now says how many
threads the server runs and what the system allows: Max processes from
/proc/self/limits, and the container's pids.current/pids.max and
memory.current/memory.max from /sys/fs/cgroup. Linux only; null elsewhere.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 00:05:44 +01:00
levzzzandClaude Sonnet 5.5 973eeadcfd Command block minecart: editor, SetCommandMinecart, activator rail command, entity data, NBT
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 04:05:18 +05:00
arismoko a5ff7b1b31 Native mods: add per-level tick hooks 2026-10-30 18:05:12 -05:00
Ludovic MarieandClaude Opus 5.5 f82149a3dd docs: the anticheat moves into a native mod once the hooks exist
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 00:05:00 +01:00
levzzzandClaude Sonnet 5.5 0dc793fdb5 PlayerModel.setupAnim (spectator), LevelRenderer.submitTransientBlocks (compile start): finish the partial ports
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 04:04:43 +05:00
levzzz 9054950e06 Merge pull request #4742 from levzzz5154/parity/K-component-hash
Hash item components in container clicks like the vanilla client
2026-10-31 04:04:19 +05:00
Ludovic Marie 84ebab8a78 Merge pull request #4739 from horizon-rust/feat/smp-grief-hooks
🤖 Agent: verified: CI verify passes; local pr_ready (fmt, clippy, tests: the one failing test fails on main too); local parity 125/128 affected harnesses pass, command_exec and chat_screen differ only by main's new /op, /ban... (e1a530a71), remote_fireball passed on rerun; end to end with the SMP mod on a dedicated server (horizon-mods#72 proof). Queued.
2026-10-31 00:04:07 +01:00
arismoko 45f155e801 Tidy native particle queue 2026-10-30 18:00:35 -05:00
levzzzandClaude Sonnet 5.5 b307359409 KilledTrigger (dragon), LightningStrikeTrigger (items), ClientboundPlayerLookAtPacket (entity form): finish the partial ports
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 03:59:20 +05:00
apeirsandClaude Opus 5.5 bcb2087d2f Fluids: spread without cloning shapes or locking per step
FlowingFluid.canPassThroughWall runs for every step of a spread's slope
search. It cloned up to four VoxelShapes per call (the shared block and
empty shapes among them: five atomic reference counts each, on cache lines
every generating thread also touches) and took the global occlusion cache's
mutex once or twice. The shapes are now borrowed and compared by identity
(is_block, a new is_empty_identity), and the cache is an RwLock. The
spread's SpreadContext caches are flat arrays around the origin instead of
two HashMaps. Same comparisons, same results; only the cost changes.

Fixes #4743

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 23:57:23 +01:00
levzzz 424dc918e7 Merge hr/main into parity/K-component-hash 2026-10-31 03:56:09 +05:00
levzzz ce12bd5b6d Merge pull request #4738 from levzzz5154/parity/K-hashops
Port HashOps (CRC32C) for item component hashes
2026-10-31 03:55:54 +05:00
levzzz 0d04976fa1 Merge pull request #4736 from levzzz5154/parity/01-chatdraft
Chat drafts: ChatScreen exit reasons (shouldDiscardDraft, removed)
2026-10-31 03:55:15 +05:00
levzzzandClaude Sonnet 5.5 303e418b08 LocalPlayer.getRopeHoldPosition, SnifferSoundInstance.tick, openWorldCheckWorldStemCompatibility: finish the partial ports
getRopeHoldPosition: main-arm side, the eased yaw/pitch (Mth.lerp(pt*0.5, yRot, yRotO)) and getEyePosition(pt).
SnifferSoundInstance: follows the sniffer, stops when it is removed, has a target or stops digging.
openWorldCheckWorldStemCompatibility: reads the enabled data packs' dimensions and a world that
still needs file fixes, so the backup question comes on the first open.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 03:54:33 +05:00
ElyTheCatandAntigravity 337692aab3 fix(resident): share section_box helper, test tight bounds, and pad for shader waving
- Share section_box(min, bounds) helper between visible and in_frustum.
- Restrict Resident::insert to #[cfg(test)], removing #[allow(dead_code)].
- Exercise randomised tight bounds in column_walk_finds_exactly_the_sections_in_view.
- Add SectionMesh::compute_bounds unit test in mesher.rs.
- Expand BOUNDS_PAD to 0.5 blocks to accommodate vertex displacement from shader packs.

Co-Authored-By: Antigravity <[email protected]>
2026-10-31 02:21:40 +03:30
levzzz 403df829f3 Merge pull request #4723 from levzzz5154/parity/13-riding-nautilus
Nautilus riding sound and legacy server list ping
2026-10-31 03:50:31 +05:00
levzzzandClaude Sonnet 5.5 8e40d11b27 Hash item components in HashedStack like the vanilla client
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 03:50:23 +05:00
Ludovic Marie c95194b676 Merge remote-tracking branch 'origin/main' into HEAD 2026-10-30 23:49:21 +01:00
levzzzandClaude Sonnet 5.5 e921ddb7c5 Port HashOps (CRC32C) for item component hashes
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 03:45:37 +05:00
levzzz a4fec395ee Merge pull request #4733 from levzzz5154/parity/D-transient
Show a landed falling block until its mesh is ready (AddTransientBlock)
2026-10-31 03:44:54 +05:00
levzzzandClaude Sonnet 5.5 d8d6dd15aa Chat drafts follow ChatScreen.ExitReason: an interrupted chat keeps its text
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 03:43:26 +05:00
Ludovic Marie e825d36af8 Merge pull request #4675 from horizon-rust/feat/anticheat-ground
🤖 Agent: review findings fixed (happy ghast rider bound, grace for ground removed server-side); pr_ready --quick green; anticheat_e2e ground and off pass; rdbtCVS's placement question answered and passed to Ludovic
2026-10-30 23:41:19 +01:00
Łukasz KopańskiandCursor 9bab876eb4 Clear portal end crystal revival beams when respawn ends
Portal crystals on the exit portal were left out of reset_spike_crystals,
so their beam targets could stay set after a successful revive or abort.

Co-Authored-By: Cursor <[email protected]>
2026-10-30 23:41:12 +01:00
reservedbytes 2f41aedb39 fix(combat): starvation is recorded once 2026-10-30 21:41:04 -01:00
levzzz ae19796505 Merge pull request #4731 from levzzz5154/parity/F-stuck-arrows
Draw arrows and bee stingers stuck in a player's body
2026-10-31 03:40:25 +05:00
levzzz ec156a856f Merge pull request #4732 from levzzz5154/parity/17-open
World open: backup question for customized/experimental worlds, low disk space, unreadable level data
2026-10-31 03:39:42 +05:00
Ludovic Marie 7192a53328 Merge remote-tracking branch 'origin/main' into HEAD 2026-10-30 23:36:46 +01:00
levzzzandClaude Sonnet 5.5 d49bcab591 Show the block a falling block lands as (ClientboundAddTransientBlockPacket)
The server tells the players tracking a landed falling block to show the
block for a moment, and the client draws it as a moving block until its
section mesh has been rebuilt or a second has passed.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 03:36:01 +05:00
Łukasz KopańskiandCursor 230ae976e1 Format SetTime clock picker for rustfmt
Co-Authored-By: Cursor <[email protected]>
2026-10-30 23:34:26 +01:00
Ludovic Marie 5f5acece3f Merge remote-tracking branch 'origin/main' into feat/smp-grief-hooks 2026-10-30 23:34:04 +01:00
Ludovic MarieandClaude Opus 5.5 51119592e2 Native mods: pass the bucket's stack as it is (clippy)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 23:34:03 +01:00
levzzz f4a81d27ee Merge pull request #4729 from levzzz5154/parity/16-video
Video Settings: Exclusive Fullscreen (and mode), Graphics API, Texture Filtering rows
2026-10-31 03:33:48 +05:00
levzzzandClaude Sonnet 5.5 1f4dba50e3 WorldOpenFlows: backup question for customized and experimental worlds, low disk space, unreadable level data
askForBackup / openWorldCheckWorldStemCompatibility: a world with
legacy_custom_options or a non-vanilla dimension asks for a backup first.
openWorldCheckDiskSpace: under 64 MiB usable asks Continue or Back.
openWorldLoadLevelData: a world opened by name (control socket) whose
level.dat does not read shows the Recover World Data screen, and a world
opened without a title no longer becomes a new random-seed world.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 03:32:14 +05:00
levzzzandClaude Sonnet 5.5 7a09947e46 Draw arrows and bee stingers stuck in a player's body
LivingEntity's arrow and stinger counts are synched entity data (12, 13):
the server counts a hurting arrow hit and a bee sting, lets them fall out
as LivingEntity.tick does, and the clients draw them with StuckInBodyLayer's
placement (ArrowLayer, BeeStingerLayer).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 03:31:07 +05:00
Ludovic MarieandClaude Opus 5.5 6034bab012 Anticheat ground: a happy ghast's rider, and a grace for vanished ground
A player standing on a happy ghast is 4.0 above it: the carrier test
now takes from a block above to 4.5 below (carries_player, tested). A
claim at the height where the ground last held the player, within 10
ticks, isn't judged: the block may have gone server-side (broken,
pushed by a piston) before the client heard.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 23:28:16 +01:00
Ludovic Marie 1b06b53500 Merge pull request #4726 from horizon-rust/fix/region-writer-thread-limit
🤖 Agent: verified. Fixes the official server's 21:54Z crash (region writer panicked on EAGAIN when spawning a helper); unit test covers refused helpers; pr_ready --quick green.
2026-10-30 23:28:05 +01:00
Łukasz KopańskiandCursor e5f3507585 Fix server day/night by applying only the level's world clock
SetTime packets can update the End clock alone; falling back to the first
clock made overworld clients take game_time as day_time, so the sky looked
inverted while the server still refused beds during day.

Co-Authored-By: Cursor <[email protected]>
2026-10-30 23:27:41 +01:00
Ludovic Marie 1f3ac390da Merge remote-tracking branch 'origin/main' into HEAD 2026-10-30 23:26:44 +01:00
levzzzandClaude Sonnet 5.5 6907fe0a79 Video Settings: Exclusive Fullscreen, its mode, Graphics API, Texture Filtering and Anisotropic Filtering rows
VideoSettingsScreen.addOptions/displayOptions/qualityOptions: the Exclusive
Fullscreen Mode row, the Exclusive Fullscreen and Graphics API options, and the
Texture Filtering and Anisotropic Filtering options (greyed out until the
terrain sampler reads them). Exclusive fullscreen takes effect through winit's
exclusive video modes; the Graphics API option drives Horizon's own API setting
and asks for a restart.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 03:21:26 +05:00
levzzz a12b5151e1 Merge pull request #4724 from levzzz5154/parity/A4b-test-instance
Test instance block: entity, packets, actions, edit screen and renderer
2026-10-31 03:21:19 +05:00
Ludovic Marie 65b312814b Merge pull request #4722 from horizon-rust/perf/sculk-portal-per-mob
🤖 Agent: verified. Same tables, order and events; A/B with 60 bots: sculk entities −25%, entity portals −44%. pr_ready --quick green; sculk parity runs in CI.
2026-10-30 23:17:17 +01:00
Sticky-Bug a834bdb7cb Merge remote-tracking branch 'origin/main' into feat/monochrome-logo-option
# Conflicts:
#	crates/horizon-client-render/src/level_loading.rs
2026-10-31 03:44:56 +05:30
Ludovic Marie a01414e035 Merge remote-tracking branch 'origin/main' into HEAD 2026-10-30 23:14:28 +01:00
levzzz 771394b76d Merge pull request #4720 from levzzz5154/parity/40-worker-thread
Crash report and exit on a streaming worker thread panic
2026-10-31 03:12:57 +05:00
levzzz 5f06dbbc16 Merge remote-tracking branch 'hr/main' into parity/A4b-test-instance 2026-10-31 03:12:54 +05:00
levzzz ef7ef9959c Merge pull request #4721 from levzzz5154/parity/A4a-test-block
Test block: block entity, packet, handler and edit screen
2026-10-31 03:12:50 +05:00
levzzzandClaude Sonnet 5.5 a1f56c2f2e Ride-sound for nautilus and legacy server list ping (parity #3518)
LocalPlayer.startRiding starts RidingEntitySoundInstance for an AbstractNautilus (underwater loop);
RidingEntitySoundInstance reads the ridden entity's isUnderWater. ServerStatusPinger falls back to
pingLegacyServer when the status ping fails, and a protocol mismatch shows as incompatible.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 03:11:51 +05:00
ElyTheCatandAntigravity 09ecc06126 fix(gl): drop premature Immediate startup default and clarify EGL swap intervals
- Revert startup present_mode default in gfx.rs to preserve Mailbox -> Fifo
  ordering for Vulkan and DX12 before settings load.
- Add comments in egl.rs explaining the configure-time vs present-time swap
  interval fallback handling.
- Note regression guard purpose for the GLES [Fifo, Immediate] present mode test.

Co-Authored-By: Antigravity <[email protected]>
2026-10-31 01:41:18 +03:30
huiandClaude Opus 5.5 fbb8f4e6d7 native_inventory_tick leaves the stack in its slot and runs when frozen
A helper called from the hook could fill the emptied slot, and putting the
stack back then lost what it gave. The hook now gets the slot, and
native_carried_stack reads and changes the stack in place. The host's call
moves out of `if running`: /tick freeze stops the world, not the player.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 23:09:18 +01:00
Ludovic MarieandClaude Opus 5.5 c3c2ffc43e Saves: a thread the system refuses no longer stops the region writer
The region writer shares a batch's compression out to scoped helper threads.
thread::scope's spawn panics when the system refuses a thread, and on the
official server (a container at its limit of threads, EAGAIN) that panic
stopped the writer: chunks and entities failed to save for four minutes, then
the server stopped with "the region writer stopped".

Helpers now start with Builder::spawn_scoped; one that can't start leaves its
share to the others, and the writer thread takes chunks too (helpers - 1
threads plus itself, as many as before), so at worst it does the whole batch
alone.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 23:06:51 +01:00
levzzz 0ec594d9d3 Merge remote-tracking branch 'hr/main' into parity/A4a-test-block 2026-10-31 03:05:24 +05:00
levzzz 98b6a442cf Merge pull request #4716 from levzzz5154/parity/A3-jigsaw
Jigsaw block: block entity, packets, handlers and edit screen
2026-10-31 03:05:21 +05:00
levzzz 96caf0bf88 Merge pull request #4718 from levzzz5154/parity/10-minor
Sprint: a shallow wall slide no longer stops the sprint (isHorizontalCollisionMinor)
2026-10-31 03:05:10 +05:00
levzzzandClaude Sonnet 5.5 31192110c6 Test instance block: block entity, packets, handler, edit screen, renderer
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 03:04:45 +05:00
levzzzandClaude Sonnet 5.5 00b1e2c12a A panic on a streaming worker thread writes a crash report and ends the game
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 03:04:16 +05:00
rdbt b0cd391031 Merge pull request #4159 from spidercraft/far-terrain-fade
Far terrain fades in and out chunk by chunk, near chunks too
2026-10-30 22:02:19 +00:00
rdbt 22c09057fc Merge pull request #4158 from spidercraft/far-terrain-look
Far terrain: shaded water and canopies, swamp grass, no floor pillars or water walls
2026-10-30 22:02:15 +00:00
rdbt 7366ff29b5 Merge pull request #4157 from spidercraft/far-terrain-speed
Far terrain: real chunks out to level 2 on idle threads, loading outward from the player
2026-10-30 22:02:10 +00:00
rdbt fd92adb803 Merge pull request #4156 from spidercraft/far-terrain-cache
Far terrain: decorated chunks kept on disk, read again next session
2026-10-30 22:02:05 +00:00
levzzz b914de7e69 Merge pull request #4715 from levzzz5154/parity/31-lookat
Send PlayerLookAt for /tp and /rotate facing
2026-10-31 03:01:16 +05:00
Ludovic Marie c5d4217dbb Merge pull request #3251 from 1038april/fix/remote-sprint-hit-slowdown
Apply the attacker's 0.6 speed cut after a knockback hit on another p…
2026-10-30 23:00:09 +01:00
huiandClaude Opus 5.5 53aa834866 Native mods: hook points for redstone signals from a mod's block (API 22)
native_block_signal (BlockState.getSignal and getDirectSignal),
native_is_signal_source (BlockState.isSignalSource) and the helper
native_update_signal_neighbors (Level.updateNeighborsAt).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 22:57:11 +01:00
levzzzandClaude Sonnet 5.5 3c5dd6b03b Port LocalPlayer.isHorizontalCollisionMinor so a shallow wall slide keeps the sprint
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 02:57:06 +05:00
levzzz c4c4b63aba Merge remote-tracking branch 'hr/main' into parity/A3-jigsaw 2026-10-31 02:55:41 +05:00
levzzz 56f9a17682 Merge pull request #4712 from levzzz5154/parity/A2-bounding-box
Structure block: bounding box and invisible block markers
2026-10-31 02:55:38 +05:00
levzzzandClaude Sonnet 5.5 35212f09a5 Send ClientboundPlayerLookAtPacket for /tp facing and /rotate facing
ServerPlayer.lookAt tells the player's client; /rotate also used the
target's eyes instead of the source anchor.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 02:53:05 +05:00
ElyTheCatandAntigravity 19de197927 test(light): bound scratch queues and pin lighting against baseline algorithm
- Add clear_queues() on LightScratch to shrink queue and sky_queue when peak capacity exceeds 65,536 elements.
- Add old_light_chunk baseline in test module to preserve pre-optimization lighting algorithm.
- Add test light_chunk_matches_old_algorithm comparing new and old lighting outputs across random chunk regions, including Nether (sky_light=false) and missing neighbour configurations.
- Add test scratch_queues_shrink_above_capacity_cap verifying bounded scratch buffer retention.

Co-Authored-By: Antigravity <[email protected]>
2026-10-31 01:22:48 +03:30
umcekoandClaude Opus 5.5 ce208cac3a Port pack: an override's target is a look only if no item needs it
Targets matched by path alone, so an override to another namespace's item/foo
dropped the mod's own item/foo, and a registered item an override points to lost
its definition. Now only the mod's own namespace counts, the oracle's registered
items are never dropped, and models that only reach each other stay items.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 00:52:10 +03:00
umcekoandClaude Opus 5.5 98ecd84b54 Port pack: armour names only an equipment asset that exists
A material carry writes no asset for (layers outside models/armor/, a namespace
the mod doesn't own) kept its asset_id, so the armour drew nothing. It now keeps
the template's asset and reports a difference. Missing items.json in the prepared
resources is logged, as vanilla_blocks' failure is, instead of stopping the carry.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 00:51:39 +03:00
umcekoandClaude Opus 5.5 99050f7ac2 Merge mods/port-armor-equipment into mods/port-no-phantom-items
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 00:51:39 +03:00
levzzzandClaude Sonnet 5.5 e017850f93 Test block: block entity, packet, handler, edit screen
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 02:51:28 +05:00
Ludovic Marie 0d1dccd5ff Merge pull request #4563 from horizon-rust/feat/launcher-brand-logos
🤖 Agent: R8c approved by Ludovic, retargeted to main after #4552, main merged, full pr_ready green, CI green. The review found no defect. Its focus-state note: shell::action and button() draw no focus either, so nothing regresses. Queued.
2026-10-30 22:50:47 +01:00
umcekoandClaude Opus 5.5 e73e31c709 Merge main into mods/port-armor-equipment
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 00:50:45 +03:00
levzzz 6307b54dea Merge pull request #4706 from levzzz5154/parity/07-ui
Show the accessibility onboarding on a first launch; cartography preview draws renderOnFrame markers
2026-10-31 02:50:03 +05:00
Ludovic MarieandClaude Opus 5.5 51629f9085 Server perf: sculk and portal checks no longer cost per mob per tick
On the official server (62 players, 1,445 mobs), tick_sculk_entities took
2.0 ms and entity_portals 1.5 ms a tick, 7% of a 47.9 ms tick.

- sculk: whether the block under a mob is climbable, or a redstone ore, comes
  from per-state tables built with the others in sculk_prepare, instead of a
  tag lookup by string and an Arc<str> clone per mob; a step knows whether a
  warden made it from the caller, instead of a scan of every mob.
- portals: canUsePortal is asked only for the entities that have a portal
  state (in a portal or cooling down), not for every mob, whose check scanned
  every server player; positions before the tick go in an FxHashMap.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 22:49:35 +01:00
levzzz b74a78c970 Merge pull request #4708 from levzzz5154/parity/32-compstr
Compile selector and nbt entity strings when decoding text components
2026-10-31 02:49:28 +05:00
Ludovic Marie 890e625fc7 Merge remote-tracking branch 'origin/main' into HEAD 2026-10-30 22:47:46 +01:00
levzzz e694791bb5 Merge remote-tracking branch 'hr/main' into parity/A2-bounding-box 2026-10-31 02:46:24 +05:00
levzzz 65030881fc Merge pull request #4704 from levzzz5154/parity/A1c-structure-screen
Structure block: StructureBlockEditScreen
2026-10-31 02:46:20 +05:00
foudretbrandClaude Opus 5.5 c094313726 Foxes screech at night when no player is near (#4533)
Fox.getAmbientSound: when it isn't bright outside, one time in ten, an
awake fox with no non-spectator player within 16 blocks of its box picks
entity.fox.screech. Fox.playAmbientSound plays it at volume 2; anything
else goes through Mob.playAmbientSound, which draws getAmbientSound again
and plays its pick through makeSound.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 22:46:07 +01:00
levzzzandClaude Sonnet 5.5 ceb5791a69 Jigsaw block: block entity, packets, handlers, edit screen
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 02:46:03 +05:00
Łukasz KopańskiandCursor 703f9ec127 Apply server.properties difficulty after load_world.
forceDifficulty only set ui.difficulty; health_rules (despawn/spawn) and
level.dat still followed the world's saved difficulty, so peaceful in
server.properties left slimes alive until /difficulty peaceful.

Co-Authored-By: Cursor <composer>
2026-10-30 22:45:34 +01:00
GIGABAIT93andClaude Opus 5.5 cd52b16c8f Merge main (#4468 seats displays on anything)
main's comment over the display pass now says the vehicle's attachment
is in its scale, as riders::passenger_attachment gives it here.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 23:43:43 +02:00
levzzzandClaude Sonnet 5.5 4fa552591a Compile selector and nbt entity strings when a text component is decoded
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 02:42:56 +05:00
Łukasz KopańskiandCursor f935ff68a5 Cull back faces on translucent dropped items.
ITEM_TRANSLUCENT draws without depth writes, so both sides of a slime/honey
shell were stacking partial alpha and looking opaque. Glass stayed fine
because most of its texels discard at alpha 0.

Co-Authored-By: Cursor <composer>
2026-10-30 22:42:48 +01:00
levzzzandClaude Sonnet 5.5 699afa8007 Show the accessibility onboarding on a first launch; draw only renderOnFrame markers on the cartography map
Gui.addInitialScreens queues AccessibilityOnboardingScreen first while onboardAccessibility is set:
the title box, Narrator (inactive without a narrator), Music & Sounds, Accessibility Settings,
Language and Continue, fading in over 2 s and out over 1 s, Escape or Continue finishing it
(Options.onboardingAccessibilityFinished). GuiGraphicsExtractor.map draws only the decorations
with renderOnFrame, so the player arrows and markers no longer show on the cartography preview.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 02:41:35 +05:00
levzzz dc35e78173 Merge pull request #4702 from levzzz5154/parity/33-adv
Parity: fall_after_explosion cause, effects_changed updates, /fetchprofile lookups
2026-10-31 02:38:17 +05:00
levzzz 49db9b276b Merge remote-tracking branch 'hr/main' into parity/A1c-structure-screen 2026-10-31 02:38:03 +05:00
levzzz 0b95a7a921 Merge pull request #4703 from levzzz5154/parity/A1b-structure-server
Structure block: handleSetStructureBlock (save, load, scan)
2026-10-31 02:37:59 +05:00
Łukasz KopańskiandCursor d9d8f5e2e8 rustfmt LOD leaf tint match arm.
Co-Authored-By: Cursor <composer>
2026-10-30 22:36:24 +01:00
Ludovic MarieandClaude Opus 5.5 524071f9cf Native mods: hook points that keep fluids, fire, pistons, explosions, buckets and blows out of guarded land (API 22)
A server mod that guards land (the SMP's claims) can now refuse what changes
blocks without a player's break or place: a fluid flowing or fire spreading
into a block (native_block_spread), a piston's move (native_piston_move), an
explosion's blocks (native_explosion_detonate takes out the spared ones), a
bucket (native_fill_bucket) and a player's blow on an entity
(native_attack_entity, on the server's attack packet, so frames, armor
stands, animals and players). horizon-world asks through two LevelAccess
methods that allow by default; fire asks after its random draws, so a
refusal leaves vanilla's random order as it was.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 22:36:17 +01:00
Ludovic Marie 5d3a45f09f Merge remote-tracking branch 'origin/main' into feat/launcher-brand-logos 2026-10-30 22:32:41 +01:00
Ludovic Marie ab92024350 Merge pull request #4552 from horizon-rust/feat/launcher-instance-icons
🤖 Agent: R8b approved by Ludovic, retargeted to main after #4520, main merged, full pr_ready green, CI green; the review found no defect; queued.
2026-10-30 22:31:32 +01:00
Łukasz KopańskiandCursor 5168e4db30 cargo fmt
Co-Authored-By: Cursor <[email protected]>
2026-10-30 22:30:43 +01:00
Łukasz KopańskiandCursor 5e2e5b4291 Drop unrelated item_render WIP from the fmt commit.
Co-Authored-By: Cursor <[email protected]>
2026-10-30 22:30:19 +01:00
Łukasz KopańskiandCursor 05d810136c cargo fmt
Co-Authored-By: Cursor <[email protected]>
2026-10-30 22:29:54 +01:00
levzzzandClaude Sonnet 5.5 b91f0330f4 Port FallAfterExplosion cause, EffectsChanged updates and /fetchprofile name|id lookups
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 02:28:40 +05:00
huiandClaude Opus 5.5 4b8f735c7b Say which mobs native_mob_spawned reports as not natural
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 22:28:15 +01:00
levzzz dc6e548538 Merge remote-tracking branch 'hr/main' into parity/A1b-structure-server 2026-10-31 02:27:38 +05:00
levzzz 95f097fd06 Merge pull request #4687 from levzzz5154/parity/A1-structure
Structure block: block entity, save format and packet codec
2026-10-31 02:27:33 +05:00
rdbt c6dd5e2205 Merge pull request #4526 from horizon-rust/fix/prepare-output-version-3
horizon-prepare: OUTPUT_VERSION 3 (creative tabs exporter changed in #4052)
2026-10-30 21:26:59 +00:00
Łukasz KopańskiandCursor 8036e0de7f Keep cherry leaf pink in far LOD terrain.
LOD applied the biome foliage tint to every *_leaves block. Cherry (and
other pre-coloured leaves) already carry their colour in the texture, so
that tint turned cherry groves yellow at a distance (#4688).

Co-Authored-By: Cursor <[email protected]>
2026-10-30 22:26:31 +01:00
levzzzandClaude Sonnet 5.5 1604f8abee BlockEntityWithBoundingBoxRenderer: the structure block's box and invisible-block markers
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 02:25:30 +05:00
levzzz f9d0a88d27 Merge pull request #4685 from levzzz5154/parity/42-ui
Show the file fixer's progress screen while a world upgrades
2026-10-31 02:25:18 +05:00
Łukasz KopańskiandCursor 874c4d1cdb Play empty-bucket sounds on the predicting client.
BucketItem.emptyContents uses playSound(player): the client must hear the
empty (or nether evaporate) sound from its DryLevel prediction, and the
server must leave that guest out of the broadcast (#4684).

Co-Authored-By: Cursor <[email protected]>
2026-10-30 22:24:22 +01:00
levzzzandClaude Sonnet 5.5 c739b7af73 StructureBlockEditScreen
The structure block's edit screen with vanilla's layout and lang keys, opened
by using the block (BlockEntity.usedBy), over a shared edit-screen runner the
jigsaw and test blocks will use.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 02:22:58 +05:00
levzzzandClaude Sonnet 5.5 177aff1240 Structure block: handleSetStructureBlock, save, load, scan and redstone trigger
The server side of the structure block: ServerGamePacketListenerImpl.
handleSetStructureBlock, StructureBlockEntity.saveStructure/placeStructure/
detectSize/loadStructureInfo, StructureTemplate.fillFromWorld + save, the
StructureTemplateManager cache and generated/<ns>/structures/<path>.nbt,
StructureBlock.neighborChanged/trigger/setPlacedBy, and placeInWorld on the
live level.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 02:22:44 +05:00
Łukasz KopańskiandCursor 88a19fab33 Play axe strip sounds only once for the actor.
The client already predicts strip/scrape/till in predict_item_on_block;
Level.playSound(player) must leave that guest out of the broadcast so the
same sound is not heard again from lan_broadcast_effects (#4681).

Co-Authored-By: Cursor <[email protected]>
2026-10-30 22:22:31 +01:00
levzzz 062d66e495 Merge pull request #4680 from levzzz5154/parity/34-profiles
Add /op, /deop, /ban, /pardon and /whitelist to a dedicated server's commands
2026-10-31 02:20:02 +05:00
Muhammad Asif 7fb5b6f214 Give boats ice friction from the hull's collision slab
AbstractBoat.getGroundFriction averages the friction of blocks whose
collision shape meets a thin box just under the hull. Horizon only
counted full_collision or legacy_solid cells, so ice (and other
non-full solids) left the boat InAir at 0.9 instead of OnLand.

Fixes #4421
2026-10-30 21:19:14 +00:00
levzzzandClaude Sonnet 5.5 aa44f715e6 Structure block entity: save format, update tag, packet codec
StructureBlockEntity (fields, saveAdditional/loadAdditional, renderMode,
getRenderableBox, the corner scan) as a block entity kind, synced in the
chunk update tag like saveCustomOnly, and ServerboundSetStructureBlockPacket.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 02:17:03 +05:00
levzzzandClaude Sonnet 5.5 ffe68c936f Show FileFixerProgressScreen while a world's files are fixed
Part of #3518. UpgradeProgress counts the file fixes and the data upgrade; the
world-opening screen draws vanilla's layout from it, with Cancel.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 02:16:00 +05:00
Ludovic Marie d2fe5c0768 Merge remote-tracking branch 'origin/main' into feat/launcher-instance-icons
# Conflicts:
#	THIRD_PARTY_NOTICES.md
2026-10-30 22:15:52 +01:00
Ludovic MarieandClaude Opus 5.5 0d0e9d4213 Floating kick: a chunk not loaded yet is not air around the player
#4622's noBlocksAround read every block through World::block, which gives
air for a chunk the server has not loaded. A survival player's first move
after joining a dedicated server can come before the spawn chunks are
generated: the move counted as floating and nothing cleared it, so the
player was kicked 80 ticks later ("Flying is not enabled on this server").
Vanilla loads the chunks it reads and sees the ground. cursor_death and
pearl_death failed about every other run on it (#4526's parity run).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 22:15:48 +01:00
Ludovic Marie f788711fe1 Merge pull request #4520 from horizon-rust/feat/launcher-icons
🤖 Agent: R8a approved by Ludovic (logos everywhere), main merged, full pr_ready green, CI green; queued.
2026-10-30 22:14:45 +01:00
Ludovic Marie c6132e2b9b Merge pull request #4676 from horizon-rust/nightly/no-review-needed
Nightly: /nightly no longer needs an approving review
2026-10-30 22:13:44 +01:00
huiandClaude Opus 5.5 4c88da0096 Merge main; item behaviour hooks are API 22
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 22:12:40 +01:00
huiandClaude Opus 5.5 6830f201d5 Merge main; mod messages and event hooks are API 22
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 22:12:39 +01:00
levzzzandClaude Sonnet 5.5 e1a530a71e Add /op, /deop, /ban, /pardon and /whitelist as commands of a dedicated server
GameProfileArgument.SelectorResult.getNames and the five commands that use
it, in vanilla's tree shape and permission level (admins).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 02:09:57 +05:00
Ludovic Marie b5b451de7c Merge pull request #4610 from horizon-rust/feat/smp-claim-hooks
🤖 Agent: verified. pr_ready --quick passed; 31/32 affected harnesses passed locally, and lunge_motion fails on main too, see the body. Hook points API 21 for the SMP's claims and teleports. Nothing changes without a native mod.
2026-10-30 22:08:16 +01:00
levzzz 1498477aec Merge pull request #4674 from levzzz5154/parity/04-dialog
Dialog suggest_command click fills the chat input
2026-10-31 02:07:57 +05:00
DenisHumenandClaude Opus 5.5 8b23993cfb lan_e2e looting: register the check, count picked-up drops
The check was missing from the dispatch table (KeyError), and the guest
picks the porkchops up before they are counted: count the ground and
both players' inventories.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 23:04:38 +02:00
Ludovic MarieandClaude Sonnet 5.5 c527d60b75 Nightly: /nightly no longer needs an approving review
Ludovic decided on 2026-10-30, after the Team asked (levzzz5154: they are often
blocked on the launcher), that a trusted reviewer or Ludovic can pick any open
pull request to main for the Nightly, reviewed or not.

Kept: only Ludovic and the listed reviewers, the `paused` line, the
CODEOWNERS-guarded files, MAX_PRS, MAX_BUILDS_PER_DAY, open and targeting main,
the build without secrets, and Ludovic's `remove`. Each pull request is built at
the head it was picked at.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 22:04:22 +01:00
arismoko 16cb6a1204 Validate content registry identity data 2026-10-30 16:03:46 -05:00
huiandClaude Opus 5.5 3c257d870c Native mods: hook points for item behaviour (API 21)
native_hurt_enemy (Item.hurtEnemy), native_inventory_tick
(Item.inventoryTick, every carried stack each tick; the mod may change it)
and native_on_equip (LivingEntity.onEquipItem).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 22:03:43 +01:00
levzzz 0a825b42a2 Merge pull request #4672 from levzzz5154/parity/35-trig
Advancement triggers: durability ranges, killing_blow on all KilledTrigger uses, player bystanders
2026-10-31 02:01:59 +05:00
Ludovic Marie 75867c69b9 Merge remote-tracking branch 'origin/main' into feat/launcher-icons 2026-10-30 22:01:02 +01:00
levzzz 475d096fe2 Merge pull request #4671 from levzzz5154/parity/18-avatar-ears
Draw deadmau5's ears (Deadmau5EarsLayer)
2026-10-31 01:58:48 +05:00
Ludovic MarieandClaude Opus 5.5 c0f09fed88 Merge main: mod screens took API 18 to 20, these hooks are API 21
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 21:58:20 +01:00
levzzzandClaude Sonnet 5.5 04c7cfc1af Dialog suggest_command click inserts the command into the chat screen under it
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 01:57:18 +05:00
Ludovic MarieandClaude Opus 5.5 907ae1bbb1 Merge origin/main; a fallback pool's guaranteed elements are tried too
Main's YUNG rules (#3bc38cebb: priority, min_required_depth, ignore_bounds,
the start not counted) stay as they are in `Rules`; Lithostitched's limited
and guaranteed elements keep their own `Limit` beside them, checked by both
vanilla and YUNG draws, and `Pick` carries either one's key.

rdbtCVS's review: the vanilla candidates listed only the target pool's
guaranteed entries and filtered every pool's out of the draw, so a fallback's
guaranteed elements were never tried, at the most depth either. Each pool now
lists its own guaranteed entries first when it is reached
(`a_fallbacks_guaranteed_entries_are_tried`). docs/mods.md says so.

The structure layout harness keeps main's yung-probe profiles beside
lithostitched-probe.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 21:56:12 +01:00
levzzz 70436b6f7d Merge pull request #4666 from levzzz5154/parity/00-container-death
Close a dead guest's open container on respawn
2026-10-31 01:52:31 +05:00
levzzzandClaude Sonnet 5.5 ecd85c497f Advancement triggers: item durability ranges, killing_blow on every KilledTrigger, players as lightning bystanders
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 01:51:40 +05:00
Ludovic Marie fd16400b91 Merge remote-tracking branch 'origin/main' into HEAD 2026-10-30 21:51:28 +01:00
levzzzandClaude Sonnet 5.5 8bad582185 Draw deadmau5's ears: Deadmau5EarsLayer
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 01:49:42 +05:00
spiderandClaude Opus 5.5 963b7d8bd9 Far terrain: chunks leaving the render distance stay in front until they have faded
A chunk leaving the render distance popped to far terrain:

- Its sections were drawn only within the render distance, or in columns
  far tiles still leave out, so once fading out (leave_section) they were
  culled at once: the fade ran unseen. Columns with sections fading out
  are drawn past it now.
- Far terrain's coarser blocks stand a little out of the real ones, and
  0.2% of the distance behind did not keep them back: the tile coming in
  under a column hid its sections the moment it was meshed with it, held
  or fading. Sections past the render distance (lod::HELD) and fading out
  go 2% of their distance toward the camera.

Measured frame by frame over a forest, render distance 10 to 3: the
change spreads over about 0.33 s instead of landing in one frame.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 20:49:23 +00:00
spiderandClaude Opus 5.5 ee7d443c8f Far terrain: shaded water and canopies, swamp grass, no floor pillars or water walls
- Skylight goes straight down generated columns: water and leaves take a
  level a block, one span per level as a real chunk's light has them.
  Full light to the bottom lit sea beds as if in the open, and coarse cells
  merging water and sand showed that light as white dots across the sea.
  Solid blocks pass it on: under them far terrain shows cliffs and
  overhangs, which vanilla lights sideways (they went black).
- Kelp and seagrass in saved and generated chunks are the water they hold
  (loaded chunks: #4144).
- Grass takes vanilla's colour at each cell (BiomeColors' grass with its
  swamp and dark forest modifiers), not the biome's flat one.
- Buried gaps are filled only where no open air reaches them through
  neighbouring gaps (a flood fill): the air under a forest canopy was
  filled with the floor block, pillars up to the leaves.
- The seal far tiles put where near terrain begins is opaque only: near
  water carries on into far water, and the seal showed through it as a
  light wall.
- A section beside a chunk not loaded meshes against its own edge column,
  not lit air: the outermost chunks' water drew a light sheet toward it
  (fluids show both sides).

Seed 1234, LOD Distance 512: 9.27M far quads become 11.22M, the trunks and
forest floor under canopies.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 20:49:23 +00:00
spiderandClaude Opus 5.5 3554efb04f Far terrain: a circular range; tiles leaving it fade out instead of being cut
The renderer cut far tiles whose nearest point was past the LOD distance,
a circle, every frame, while the plan kept tiles out to a square: tiles
popped out at the circle as the player moved, before the plan dropped
them and before their fade-out could run. The plan's range is now the
same circle (TileKey::reach) and the renderer no longer cuts by distance
(the frustum only): a tile leaving the range leaves the plan and fades out
(upload_lods), as tiles entering it fade in.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 20:49:23 +00:00
spiderandClaude Opus 5.5 94f48bbf6a Far terrain: near chunks and far tiles fade out as they came
Unloading popped where loading faded:

- A section whose column far terrain now draws (a shorter render
  distance, the player moving away) fades out over it, chunk by chunk as it
  came, and is released once it has (Renderer::leave_section), instead of
  in one frame. Columns far tiles still leave out stay drawn until they
  don't (#3765), then fade out the same way.
- A far tile leaving the plan with nothing to replace it (out of range: a
  shorter LOD distance, the player moving away) fades out. A tile replaced
  by new tiles, or by near chunks fading in, stays whole behind them as
  before: two patterns fading at once left holes between them.

The fade code gains a fading-out range (65 to 128 sixty-fourths, per
level 129 values): a section's largest code, 129, keeps its UV step exact
(checked in tiles_fade_in_on_their_own_grid).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 20:49:23 +00:00
spiderandClaude Opus 5.5 ada4e6567d Far terrain fades in chunk by chunk, and near chunks fade in over it
New far tiles fade in over the ones they replace, and new near sections
over far terrain, instead of appearing at once. Each chunk-sized column of
a tile (wider ones for coarse tiles) starts in turn over the first half of
600 ms and fades over the second, as a fine ordered dither fixed to the
terrain: its unit is the power of two of a block nearest 1.5 pixels, so it
stays fine up close, does not sparkle far off and does not crawl as the
camera moves. The tiles replaced stay whole behind until it is done: no
pixel drawn twice, no hole.

- The fade rides in the instance's UV step (negated past a fade code);
  terrain.wgsl and GLSL packs take the step as fract(abs(w)), unchanged
  for whole tiles and sections.
- A fading tile or section is drawn a hair toward the camera; far tiles
  0.2% of their distance back, so near chunks win over tiles still under
  them (near water lies a little below far water: a smaller step left two
  waters, opaque).
- Water and other see-through faces are not dithered, and near water is
  drawn before far water, replaced tiles' last: depth keeps each layer to
  where the one before has none.
- A tile re-meshed around new near chunks or finer tiles keeps drawing its
  old mesh until they are in; a tile over nothing shows whole (its hidden
  chunks opened holes to the sea bed).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 20:49:23 +00:00
spiderandClaude Opus 5.5 fb9db82d1b Far terrain: real chunks out to level 2 on idle threads, loading outward from the player
- Decoration (real trees and structures) runs on a third of the threads, up
  to 12, at idle priority, instead of one; batches of 8x8 chunks share
  their proto border (2.25 protos a chunk instead of 4): 16.6 ms a chunk
  instead of 26.9.
- Tiles up to level 2 (cells of 4 blocks) are decorated, not only 0 and 1;
  a detail request asks for up to 512 chunks instead of 32, which capped
  detail at one decoration thread's pace.
- Tiles mesh across the sampling threads, each with its own material
  cache, instead of on the far terrain thread alone.
- Only the tiles the plan keeps are sampled, each at the level its
  distance calls for, nearest first: far terrain grows outward from the
  player in rings, whichever way they look. Coarse tiles sampled first
  under fine ones beside the player cost a far tile's work for terrain
  replaced moments later. The view-ahead focus and Lod::update's yaw go.
- The stop flags are set under the lock their threads wait on: a thread
  between its check and its wait missed the wake, and closing the world
  waited forever (more likely with more threads).

Fresh world, LOD Distance 512: every tile sampled after about 12 s instead
of 15; real detail after 40 s 500k cells instead of 447k.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 20:49:16 +00:00
rdbtCVSandClaude Opus 5.5 8922550fdf Far terrain cache: a chunk cached already is not written again
A generation batch hands back its neighbours too, and drain_details put
every one of them, cached or not: the append-only region files grew by
the same chunks each session until the build key changed. The build key
already clears stale chunks, so one cached stays as it is.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 20:49:16 +00:00
Ludovic MarieandClaude Opus 5.5 23f553e285 Merge origin/main: Intel Mac builds in game-build.yml and the Nightly
Main builds the game in game-build.yml and publishes it as the Nightly
(nightly-build.yml) since #3342; release.yml only decides. So the macos-x64
matrix entry, BIN and --target move to game-build.yml, the Nightly signs and
publishes horizon-macos-x64.zip, launcher.yml keeps main's smoke test with
the x64 entry and a guard while no build has an Intel asset, and
platform_assets_are_published reads game-build.yml and nightly-build.yml.
horizon-server's updater (newer than this branch) gets the Intel asset too.
website/ moved to horizon-site (#2286): its changes are dropped here.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 21:48:04 +01:00
Ludovic Marie 1085b42fc5 Merge pull request #4657 from horizon-rust/fix/log-flood-limit
🤖 Agent: verified. Unit test covers the per-line limit, the note and the left-out count; info lines are never limited. pr_ready --quick green.
2026-10-30 21:48:03 +01:00
spiderandClaude Opus 5.5 2e321e385a Far terrain: decorated chunks kept on disk, read again next session
Far terrain decorates the chunks of its nearest ring with the world's own
generator (vanillagen/lod.rs), kept in memory only, 1024 of them: every
session generated them again. They now go to
dimensions/<dim>/data/horizon_far_terrain/, one append-only file per region
(lod/cache.rs, about 6 KB a chunk deflated), and a detail request reads
them back before asking the generator.

- Saved chunks still take precedence over cached ones.
- A file written by another build, block registry or format is cleared on
  first use; a torn last record (a crash mid-write) is cut off.
- Only detail requests read the cache: reading it for every cell of the
  coarse tiles' sampling slowed their sampling more than it saved.

Rejoining a world at LOD Distance 512: 343k refined far cells after 45 s
with the cache, 244k without.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 20:47:55 +00:00
levzzz 9c471a85ce Merge pull request #4653 from levzzz5154/parity/36-packs
Parity: validate pack.mcmeta formats, clear packs on a failed reload
2026-10-31 01:47:37 +05:00
huiandClaude Opus 5.5 25950c64ad Native mods: mod messages and event hook points (API 21)
Hook points for a mod's server and client halves to talk
(native_net_from_client, native_net_from_server, with the send helpers in
native_net.rs, on the new horizon:mod_net payload), and for hearing players
and mobs: player_died, mob_spawned, mob_killed, item_dropped and
item_picked_up.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 21:47:22 +01:00
JustKarkatandZCode 31054f520e Merge main into the branch; the hook point is API 21
Main consumed hook APIs 14 to 20 (block breaking/placing, mod block
entities, containers, menus, screens and their buttons). The
player-health HUD rows hook takes API 21: the doc line, the version,
the catalog entry and the docs paragraph move with it. The hook's call
site in hud_layers merged unchanged.

Co-Authored-By: ZCode <[email protected]>
2026-10-30 15:46:00 -05:00
levzzz 0bb993afd7 Merge pull request #4658 from levzzz5154/parity/29-render
Wind charge renderer: age includes the partial tick
2026-10-31 01:45:51 +05:00
Ludovic Marie a62fa7375d Merge remote-tracking branch 'origin/main' into HEAD 2026-10-30 21:45:33 +01:00
levzzz 6f26c1f355 Merge pull request #4655 from levzzz5154/parity/36-adv
Parity: passenger predicate, boat vehicles, sulfur cube shear trigger
2026-10-31 01:44:23 +05:00
levzzzandClaude Sonnet 5.5 2052e55b0b Close a dead guest's open container when it respawns
AbstractContainerScreen.tick closes the container of a dead player; a guest's
chest stayed counted as open (lid up) after its death.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 01:43:45 +05:00
Ludovic MarieandClaude Opus 5.5 8975278e5e Anticheat: the ground check (no-fall, walking on water)
A move that says it ends on the ground is judged when nothing there can
hold a player: only air and fluids around the feet, no collision a tenth
of a block down, no boat, minecart, shulker or happy ghast near. The
false claim is dropped, so vanilla's fall damage follows, and the levels
send back, alert and kick as configured. Creative, spectators and
gliders are skipped. Off unless [anticheat] turns it on.

horizon-bots: --height and --ground. anticheat_e2e --check ground: a
hovering bot and one on water that claim the ground are flagged and
kicked, bots walking and standing on the ground never are.
server_bench --ground: survival bots on a flat world's ground.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 21:42:45 +01:00
levzzz c4c7df589a Merge pull request #4652 from levzzz5154/parity/12-options
Pause menu bug and feedback links, title credits text button
2026-10-31 01:40:27 +05:00
Neferr fb4fcb04ec Native mods: a hook point for each player's server tick (API 22)
native_player_tick runs once a tick for every living player in the world, at the end of their ServerPlayer.tick (after digging, item use, cooldowns, statistics and advancements), inside their own turn so the helpers read that player. Fixes #4663.
2026-10-30 21:39:58 +01:00
DenisHumenandClaude Opus 5.5 23200373f6 Bundles: the wheel selects in a crafting table's and a station's menu
The client offered the wheel to a bundle in the player's inventory only
in the inventory and chest-like menus, and the server read the slot of
ServerboundSelectBundleItemPacket as the inventory menu's. In a crafting
table, an enchanting table or another station the bundle never scrolled
(#4436). Now the inventory part of any menu takes the wheel, and the
server maps a crafting table's or station's slot onto the guest's
inventory.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 22:39:09 +02:00
Łukasz KopańskiandCursor b6c585706f Sit up on a cushion after swimming.
A cushion seat clips the feet into the support block, so pose fitting
rejects standing and would keep a prior swim pose. Force standing for
passengers when that happens, and use the passenger pose path on the
server while a guest rides.

Co-Authored-By: Cursor <[email protected]>
2026-10-30 21:38:40 +01:00
DenisHumenandClaude Opus 5.5 daa6d3b1ca Looting on the killer's weapon counts on a server's mobs
A mob's death runs in the server's tick, after the swing that killed it,
so drop_death_loot read the killer's weapon from the server's own player
(self.held()), who holds nothing. On the server thread every player is
a guest, so Looting never added to a mob's drops (#4496). The killer is
now the guest who struck the last blow, with their hands and armour as
EnchantmentHelper.getEnchantmentLevel reads them. lan_e2e gets a
`looting` check.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 22:38:15 +02:00
levzzzandClaude Sonnet 5.5 f387c1d78f fmt
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 01:35:54 +05:00
Łukasz KopańskiandCursor 468dd3c24b Sync SulfurCube MAX_FUSE so clients show the fuse swell.
Guests never received entity data id 20, so a primed cube jumped
straight to the boom with no scale animation. Push/apply the fuse and
tick it down on the client for the renderer.

Co-Authored-By: Cursor <[email protected]>
2026-10-30 21:35:23 +01:00
Ludovic Marie 571e0e5341 Merge pull request #4650 from horizon-rust/fix/vehicle-moved-wrongly
🤖 Agent: verified. lan_e2e boat_shoved fails on main (boat stuck, 70 'moved wrongly' lines) and passes here (paddles 3.0, 1 line); mounts and riding pass; boats fails the same on main (fist breaking, unrelated). pr_ready --quick green.
2026-10-30 21:34:44 +01:00
levzzzandClaude Sonnet 5.5 68b90d860b Wind charge: spin and scroll with the partial tick (WindChargeRenderer.submit)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 01:30:01 +05:00
Ludovic MarieandClaude Opus 5.5 53ba582419 Log: limit a warning or error from one line of code to 100 a minute
A warning repeated on every packet filled the official server's log at up to
6,700 lines a second (16 MB in 30 minutes). Past 100 warnings or errors from
one line of code in a minute, the log says so once, counts the rest, and says
how many it left out when that line logs again in a later minute. Below the
limit the log is vanilla's, line for line; info lines (chat, joins) are never
limited.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 21:29:35 +01:00
rdbt c995c9b76e Merge pull request #4098 from DUzzL/fix/issue-4086-main-hand
Send the stored Main Hand option on join
2026-10-30 20:28:41 +00:00
levzzzandClaude Sonnet 5.5 ab2ab8ef61 Advancements: passenger predicate, boats as vehicles, sulfur cube shearing trigger
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 01:28:33 +05:00
rdbt 9e267fbf9e Merge pull request #4636 from JustKarkat/fix/target-anger-death-forgiveness
A dying player's forgiveness reaches the enderman, zombified piglin and iron golem
2026-10-30 20:28:30 +00:00
rdbt 618469513b Merge pull request #4632 from ludovicthenot/fix-key-names-layout
Key names follow the keyboard layout: an AZERTY player's Q key shows A
2026-10-30 20:28:24 +00:00
Neferr 0c9b29d20a Native mods: a hook point for left-clicking a block (API 21)
native_left_click_block hears the start of a player's dig (the block, the face hit) where ServerPlayerGameMode.handleBlockBreakAction takes it, after its reach, spawn protection and world border checks, before Block.attack or a creative break; a mod that cancels it keeps the block, which the ack puts back on the client. Fixes #4654.
2026-10-30 21:28:23 +01:00
rdbt e387764263 Merge pull request #4626 from AurelienBelloir/parity/block-entity-updates
Send beacon, end gateway and creaking heart update tags
2026-10-30 20:28:19 +00:00
rdbt 0e6e9ab098 Merge pull request #4624 from AurelienBelloir/parity/team-packets-to-guests
Send a team leave before the join that moves a player
2026-10-30 20:28:14 +00:00
rdbt 6f3757c8e4 Merge pull request #4621 from WooCaash/fix/4567-pillager-ominous-banner
Give raid captains the ominous banner stack for clients
2026-10-30 20:28:09 +00:00
rdbt 49878e9aee Merge pull request #4617 from WooCaash/fix/4428-title-screen-toasts
Draw ToastManager toasts over the title screen
2026-10-30 20:28:03 +00:00
rdbt dd63a5cc5f Merge pull request #4603 from xFedeT/fix/piglin-doors
Piglins open the doors on their path (InteractWithDoor)
2026-10-30 20:27:58 +00:00
rdbt 8e83c3eb23 Merge pull request #4596 from xFedeT/fix/path-recompute
Mobs re-path when a block changes near their route (PathNavigation.shouldRecomputePath)
2026-10-30 20:27:52 +00:00
rdbt 48f539e7d3 Merge pull request #4595 from doodlyyousuf/fix/sound-packet-seed
Give each server sound packet its own seed
2026-10-30 20:27:46 +00:00
rdbt 6db9492f5f Merge pull request #4587 from phexuss/fix/cartography-map-markers
Draw map markers on the cartography table's preview
2026-10-30 20:27:41 +00:00
rdbt 3ad2bda85f Merge pull request #4585 from xFedeT/fix/frog-preferred-jump
Port map: the frog and goat long jump rows point at frog.rs (ported in #3946)
2026-10-30 20:27:34 +00:00
rdbt 2817362973 Merge pull request #4582 from phexuss/fix/enchanting-book
Draw the enchanting table's book in its screen
2026-10-30 20:27:27 +00:00
rdbt 4acfc70975 Merge pull request #4578 from phexuss/fix/happy-ghast-riding-sound
Play the happy ghast's riding sound while the player rides one
2026-10-30 20:27:22 +00:00
levzzzandClaude Sonnet 5.5 d214aa61d7 Validate pack.mcmeta formats like PackFormat.IntermediaryFormat; clear packs on a failed reload
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 01:27:18 +05:00
rdbt 797d6512ea Merge pull request #4568 from xFedeT/fix/armadillo-follow-adult
Baby armadillos follow the nearest visible adult (AdultSensor, BabyFollowAdult 5..16)
2026-10-30 20:27:16 +00:00
rdbt 3c66c314b9 Merge pull request #4566 from CocofireHD/fix/pillager-crossbow
A pillager shows the arrow in its loaded crossbow
2026-10-30 20:27:11 +00:00
rdbt 337b0e67fb Merge pull request #4564 from doodlyyousuf/fix/item-slime-piston-bounce
Bounce dropped items on slime, including piston-moved slime
2026-10-30 20:27:05 +00:00
rdbt 12a7bf8b29 Merge pull request #4559 from xFedeT/fix/frog-breeding
Frogs breed: slimeball feeding, pregnancy, and frogspawn laid on water
2026-10-30 20:27:00 +00:00
rdbt 3a906e8647 Merge pull request #4544 from AurelienBelloir/test/windows-world-preset-dirs
Name the world preset test's folders without a colon
2026-10-30 20:26:54 +00:00
rdbt d2b451af13 Merge pull request #4540 from DmytroChup/fix-4431
Use workspace base64 and sha1 crates in skins.rs
2026-10-30 20:26:48 +00:00
rdbt 5560aadd24 Merge pull request #4523 from xFedeT/fix/kid-tag-run-off
mob_ai: baby villagers play tag, checked tick for tick against vanilla
2026-10-30 20:26:42 +00:00
levzzzandClaude Sonnet 5.5 795c8e96a0 Pause menu bug/feedback links and the title's credits text button
PauseScreen.createPauseMenu: the Report Bugs and Give Feedback icon buttons open
CommonLinks through a trusted ConfirmLinkScreen. PlainTextButton on the title's
bottom text opens Credits & Attribution.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 01:26:37 +05:00
rdbt f257d62aa3 Merge pull request #4518 from xFedeT/fix/copper-golem-doors
Copper golems open the doors on their path (InteractWithDoor)
2026-10-30 20:26:37 +00:00
rdbt 4bd0bd7dc1 Merge pull request #4516 from arismoko/vanilla-server-block-writes
Route vanilla server block writes through level updates
2026-10-30 20:26:32 +00:00
rdbt b0d000b4b4 Merge pull request #4501 from xFedeT/fix/iron-golem-village-parity
Iron golems draw the sprint particle's randoms; village goal parity scenarios
2026-10-30 20:26:26 +00:00
rdbt 860a3ec2b2 Merge pull request #4497 from xFedeT/fix/fish-school-parity
Fish schools recruit in vanilla's order, with mob_ai parity scenarios (FollowFlockLeaderGoal)
2026-10-30 20:26:21 +00:00
rdbt 0c65b58a65 Merge pull request #4492 from xFedeT/fix/dolphin-follow-ridden
Dolphins swim after the boat or nautilus a player steers (FollowPlayerRiddenEntityGoal)
2026-10-30 20:26:16 +00:00
rdbt 5ab1a8f543 Merge pull request #4468 from Bloodulon/pr/display-passengers
Client: seat displays and hitboxes on whatever carries them
2026-10-30 20:26:11 +00:00
rdbt 0e282c022c Merge pull request #4467 from Bloodulon/pr/interaction-entities
Client: draw click boxes and hit-test server interaction entities
2026-10-30 20:26:06 +00:00
rdbt dcddbbe841 Merge pull request #4457 from DenisHumen/fix/player-attribute-save
Save and restore the player's attribute bases
2026-10-30 20:26:00 +00:00
rdbt 03f7a69993 Merge pull request #4367 from elythepreciouscat/feat/quit-shortcuts
Controls: implement Quit Shortcuts toggle and window-close blocking
2026-10-30 20:25:55 +00:00
rdbt 2d4e6ba0be Merge pull request #4339 from MSedgeMC/fix/3962-cooking-recipe-book
Encode cooking recipes into the recipe book
2026-10-30 20:25:50 +00:00
rdbt cab2754fe1 Merge pull request #4332 from WhyFovius/fix/player-hurt-sound
Combat: the hurt player hears its own hurt sound (single player and multiplayer)
2026-10-30 20:25:44 +00:00
rdbt dfb9e41079 Merge pull request #4307 from MSedgeMC/fix/4129-nametag-pose-height
Hang nametags from the pose box, not the sneak flag
2026-10-30 20:25:38 +00:00
rdbt 1374959d6f Merge pull request #4304 from MSedgeMC/fix/eye-of-ender-lift
Lift the thrown eye of ender by the item ground transform
2026-10-30 20:25:33 +00:00
rdbt 0f1a100391 Merge pull request #4283 from DUzzL/fix/issue-4269-creative-tab-tooltip
Pad the creative tab tooltip like the item tooltip
2026-10-30 20:25:27 +00:00
rdbt ac6f38c3d6 Merge pull request #4277 from DUzzL/fix/issue-4266-candle-on-cake
Place a candle on an uneaten cake
2026-10-30 20:25:22 +00:00
rdbt 5d47a8557c Merge pull request #4270 from DenisHumen/fix/lectern-scroll
Keep the mouse wheel off the hotbar while a book or lectern is open
2026-10-30 20:25:16 +00:00
rdbt 48960bffd6 Merge pull request #4263 from DUzzL/fix/issue-4251-lectern-take-book
Close the lectern book screen after taking the book
2026-10-30 20:25:10 +00:00
rdbt 5370ae92cf Merge pull request #4258 from DUzzL/fix/issue-4248-render-icons
Clip HUD item icons as polygons at the scissor edge
2026-10-30 20:25:05 +00:00
rdbt 77e8224441 Merge pull request #4249 from DenisHumen/fix/return-to-sender
Return to Sender: a fireball struck back credits the player's kill
2026-10-30 20:24:59 +00:00
rdbt c003ada028 Merge pull request #4246 from DenisHumen/fix/falling-block-offset
Falling blocks keep their random model offset, so pointed dripstone doesn't jump when it falls
2026-10-30 20:24:53 +00:00
rdbt 4daf4be5c0 Merge pull request #4238 from DUzzL/fix/issue-4234-worldspawn-after-levels
Stop the levels E2E test leaving an Overworld spawn point
2026-10-30 20:24:47 +00:00
rdbt b3f160b674 Merge pull request #4216 from DenisHumen/fix/shield-server-stop
Lower the shield when a server stops the player's use
2026-10-30 20:24:42 +00:00
rdbt 13451e7207 Merge pull request #4207 from GIGABAIT93/fix/port-publish-utf8
port_publish: read gh's and Kira's output as UTF-8
2026-10-30 20:24:37 +00:00
rdbt 00c456d313 Merge pull request #4196 from kacpherek/fix/gameplay-mp-combat
Riders never claim to be on the ground, so Grim stops flagging boats
2026-10-30 20:24:31 +00:00
rdbt 6c8e7e3e59 Merge pull request #4195 from kacpherek/fix/discord-carts-boats
A player who boards a boat while gliding leaves the elytra pose
2026-10-30 20:24:26 +00:00
rdbt a71dfda331 Merge pull request #4194 from kacpherek/fix/discord-dupes
Creative inventory: send the edit with the click, so holding Q over a stack can't duplicate it
2026-10-30 20:24:21 +00:00
rdbt 0d1681d649 Merge pull request #4193 from DUzzL/fix/issue-4190-crystal-beam-reset
Clear a respawned end crystal's beam for LAN guests
2026-10-30 20:24:15 +00:00
rdbt 318ed97be4 Merge pull request #4172 from DUzzL/fix/issue-4168-pack-download-timeout
Time out stalled resource pack downloads
2026-10-30 20:24:10 +00:00
Ludovic MarieandClaude Opus 5.5 fde63c0597 Server: reset a ridden vehicle's last good position every tick
ServerGamePacketListenerImpl.tick sets vehicleFirstGood/LastGood to where the
vehicle is at every tick. Horizon set it only on boarding and after an
accepted move, so once the server moved the vehicle itself (/tp, a piston,
a push), the rider's moves were checked against a stale position: each one
failed, was sent back, and the client's answer failed again. The boat stayed
stuck and the official server logged up to 6,700 "moved wrongly" lines a
second.

lan_e2e boat_shoved: a piston shoves, then /tp moves, the boat the host and
the guest each ride; they paddle on, with at most a few "moved wrongly" lines.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 21:24:05 +01:00
rdbt 1803841469 Merge pull request #4146 from DUzzL/fix/issue-4145-gateway-exact-teleport-key
Use the ExactTeleport key in the levels E2E end-gateway setup
2026-10-30 20:24:04 +00:00
rdbt 660babec5b Merge pull request #4141 from DUzzL/fix/issue-4134-slot-gap-hover
Hit-test the one-pixel border around inventory slots
2026-10-30 20:23:59 +00:00
rdbt 3613a6eb0d Merge pull request #4138 from DUzzL/fix/issue-4131-crystal-random-rotation
Randomise the spin phase of end crystals placed by the server
2026-10-30 20:23:53 +00:00
rdbt 49544daa24 Merge pull request #4132 from DUzzL/fix/issue-4118-attribute-main-hand
Apply held-item attribute modifiers to the main hand
2026-10-30 20:23:47 +00:00
rdbt 44a07ac4bf Merge pull request #4125 from Benflex1/fix/3515-trade-sound
Play the merchant's yes sound on a shift-clicked trade
2026-10-30 20:23:42 +00:00
rdbt 1beefecb54 Merge pull request #4108 from DUzzL/fix/issue-4096-remote-default-skin
Show the default skin when a remote player's skin sprite is missing
2026-10-30 20:23:34 +00:00
rdbt a2fa7b322d Merge pull request #4101 from DUzzL/fix/issue-4090-lingering-cloud-color
Keep the potion colour on lingering clouds
2026-10-30 20:23:28 +00:00
levzzz e654389d2a Merge pull request #4646 from levzzz5154/parity/26-render
Draw a happy ghast's quad leads and ropes, bob pufferfish
2026-10-31 01:23:22 +05:00
rdbt 0ce6c9fe94 Merge pull request #4093 from arismoko/fix/server-lists-offline-test
Keep stopped server list test offline
2026-10-30 20:23:19 +00:00
rdbt d57bcbca83 Merge pull request #4081 from xFedeT/fix/inventory-preview-lighting
Inventory preview: the player is lit as ENTITY_IN_UI, not by a guess
2026-10-30 20:23:13 +00:00
rdbt 521a01282b Merge pull request #4075 from xFedeT/fix/ctrl-click-right-click
Right Click Emulation: Control + left click is a right click on macOS
2026-10-30 20:23:07 +00:00
rdbt aa1a9b9379 Merge pull request #4012 from elythepreciouscat/fix/armor-rendering
Render: multi-layer equipment definitions and overlays
2026-10-30 20:23:02 +00:00
rdbt 176cfef84f Merge pull request #4007 from DUzzL/fix/issue-4000-invisible-armor
Invisible players keep their armour, elytra and held items
2026-10-30 20:22:56 +00:00
rdbt 9d59efaf3f Merge pull request #3927 from DenisHumen/fix/launcher-trash-com
Launcher: moving a world to the trash no longer closes it on Windows
2026-10-30 20:22:51 +00:00
rdbt d9cbe12a4f Merge pull request #3917 from quintyo/fix/hand-swap-scale
Fix item model hand swap animation scale
2026-10-30 20:22:45 +00:00
rdbt 2b2851a0dc Merge pull request #3869 from spidercraft/fix/friends-code-ends-with-world
Play with Friends: leaving the world ends its invite code
2026-10-30 20:22:39 +00:00
rdbt 1adb5cf92d Merge pull request #3841 from DUzzL/fix/inventory-server-transfer
Reset the client menu when a server replaces the player
2026-10-30 20:22:34 +00:00
rdbt 4742c9b3f5 Merge pull request #3839 from GIGABAIT93/fix/offscreen-pointer
--offscreen: hide the window and leave the system pointer alone
2026-10-30 20:22:28 +00:00
rdbt 1f82ef2116 Merge pull request #3811 from DenisHumen/fix/launcher-menus-close-outside
Launcher: the version and account menus close on a click outside them
2026-10-30 20:22:23 +00:00
rdbt b9e348c2c0 Merge pull request #3669 from martipops/fix/stick-on-servers
Controller movement on servers: the stick moves as the keys it sets do
2026-10-30 20:22:17 +00:00
Łukasz KopańskiandCursor 9b6a9a6b55 Sort anim.rs imports for rustfmt.
Co-Authored-By: Cursor <[email protected]>
2026-10-30 21:19:50 +01:00
levzzz f4da935e0f Merge pull request #4289 from Sylviromi/native-mod-screen-widgets
Native mods: buttons, tooltips and item icons on mod screens
2026-10-31 01:19:39 +05:00
Neferr 1698ff41f8 Native mods: a hook point for right-clicking an entity (API 20)
native_interact_entity hears a player's right click on an entity (its id and type, and the stack in the hand) where Player.interactOn starts, once per hand, on the server for every player; a mod that claims it swings the hand instead of the entity's and the item's own interaction. Fixes #4647.
2026-10-30 21:17:22 +01:00
JoansitohandClaude Sonnet 5.5 7ee905eab2 Merge upstream/main into fix/skins-after-pack-reload
Adopt main's SkinSlots for skins: add SkinSlots::forget and call it, with
the map slots' forget, once the new atlas is installed.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 21:14:09 +01:00
GIGABAIT93andClaude Opus 5.5 2977badbb8 sync_riders: name what it leaves out, not what it keeps
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 22:13:32 +02:00
Ludovic MarieandClaude Opus 5.5 bfeb1e69a8 Merge origin/main; server-next: S-5 is done (owners.md), Appendix A is a snapshot
docs/README.md keeps main's smp.md and owners.md lines beside server-next.md
(DUzzL's review).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 21:13:14 +01:00
Ludovic MarieandClaude Opus 5.5 fc4641bc8e Shared loot tables: a table changed on disk is read again
rdbtCVS's review: the key held the root's path and the packs' files, not the
root's own files, so a loot table edited at the same path while an Items or
LootData still held the map got the old one. The key now also hashes each
root table's path, size and modification time; a test edits a table while
the first map is held.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 21:11:45 +01:00
levzzzandClaude Sonnet 5.5 066eb31ad5 Draw a happy ghast's quad leads and ropes, bob pufferfish, scroll breeze wind by partial tick
PufferfishRenderer.setupRotations, RopesLayer.submit, LeashFeatureRenderer/EntityRenderer quad leash, RenderTypes.breezeWind.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 01:10:38 +05:00
GIGABAIT93andClaude Opus 5.5 563ce7338e Merge main; scale the rider seat before the rearing offset
passenger_attachment scales main's base_attachment, then adds
equine::rear_offset unscaled (AbstractHorse.getPassengerAttachmentPoint).
seat() moves above ride()'s doc comment; position_riders seats a mob on a
text display on the first pass only; sync_riders says why display mounts
aren't its concern.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 22:10:20 +02:00
Łukasz KopańskiandCursor 0d9dcfd247 rustfmt the seated-enderman anim test.
Co-Authored-By: Cursor <[email protected]>
2026-10-30 21:09:39 +01:00
levzzz 073afd484e Merge pull request #4639 from levzzz5154/parity/37-deathmsg
Reload sends tags and recipes and reloads advancements; code of conduct task
2026-10-31 01:08:21 +05:00
JoansitohandClaude Sonnet 5.5 e10efca1bc horizon-threads: check the junction reparse buffer's length sums
The mount point buffer added name_bytes + 2 and + 12 in u16, which overflows
for paths near the 32k-character limit. Build it in a helper with checked
arithmetic that returns InvalidInput, so the caller falls back to copying,
and pin the layout with a Windows unit test.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 21:07:56 +01:00
JoansitohandClaude Sonnet 5.5 8ae79aaad4 Merge upstream/main into fix/windows-pack-reload-speed
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 21:07:28 +01:00
levzzzandClaude Sonnet 5.5 2bc03baa6b Merge main; buttons, tooltips and item icons on mod screens are API 20
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 01:04:57 +05:00
JoansitohandClaude Opus 5.5 3f38f88bee Arrows fly into whoever picks them up on servers and the server thread (#4486)
ClientPacketListener.handleTakeItemEntity adds an ItemPickupParticle for any
taken entity and plays the pickup sound where it was. A server's client only
did so for items and orbs: a taken arrow or trident vanished at once. It now
flies to its collector and sounds at the arrow, as on a LAN guest.

horizon-ctl's status counts the pickup particles by kind (`pickups`), and
lan_e2e's `arrow_pickup` check has each player pick up an arrow and both
clients draw it flying.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 21:04:30 +01:00
Łukasz KopańskiandCursor 88e36f9220 Retrigger verify after a flaky Mojang lookup in horizon-server lists.
Co-Authored-By: Cursor <[email protected]>
2026-10-30 21:03:40 +01:00
levzzz 3a5ac7ffed Merge pull request #4106 from Sylviromi/native-mod-screens
Native mods: mod-drawn screens for mod blocks (own GUI textures, layout, data)
2026-10-31 01:03:37 +05:00
Łukasz KopańskiandCursor 355b5f099e Sit an enderman that rides a boat.
humanoid_pub ignored isPassenger, so EndermanModel never got the
humanoid riding pose before its limb clamp.

Co-Authored-By: Cursor <[email protected]>
2026-10-30 21:03:09 +01:00
levzzz 0a7ad01ed6 Merge pull request #4635 from levzzz5154/parity/23-misc
Parity: view-blocking overlay from isViewBlocking and the near plane
2026-10-31 01:00:57 +05:00
Ludovic Marie f8f1679153 Merge remote-tracking branch 'origin/main' into HEAD 2026-10-30 21:00:18 +01:00
levzzzandClaude Sonnet 5.5 be0445848d Reload: send tags, recipes and the recipe book to players and reload their advancements; offer the code of conduct
PlayerList.reloadResources and ServerConfigurationPacketListenerImpl.addOptionalTasks.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 01:00:13 +05:00
Jan PresandOpenCode d049c7a175 Place a rider ejected by an activator rail beside the cart
AbstractMinecart.activateMinecart ejects its passengers on a powered
activator rail, and getDismountLocationForPassenger puts them on the
nearest block. Horizon cleared the ride but left the player at the seat
position, so the cart appeared to keep its rider.

tick_cart_riding now runs the same dismount placement when the tick
cleared the ride, for the window's player and for a server player
(each body goes through this function).

Fixes #4562

Co-Authored-By: OpenCode (DeepSeek V4.1 Flash) <[email protected]>
2026-10-30 19:58:11 +00:00
JustKarkatandZCode f5c79065e9 A dying player's forgiveness reaches the target-anger mobs
The forgiveness dispatch's default arm is the tame gate, which needs
extra.angry_at — a field only the wolf's anger writes. The enderman and
the zombified piglin hold their persistent anger as their target (the
stare goal, the hurt-by path) and the iron golem keeps a timer and its
target, so none of the three were ever forgiven when the player they
were angry at died, while vanilla's NeutralMob.playerDied ends the anger
of all three through stopBeingAngry (Enderman and ZombifiedPiglin are
NeutralMob implementers in 26.3, checked with javap).

Their arms clear the target the dying player held, the last hurt by
with it, and for the golem the timer; anger at another mob stays, and
the tame, bee and polar-bear paths are untouched.

Follow-up to #3507, closed for main carrying the area and gate fixes:
this is the piece main still misses, on a fresh small branch as asked.

Co-Authored-By: ZCode <[email protected]>
2026-10-30 14:57:09 -05:00
levzzzandClaude Sonnet 5.5 a14cac7f7c Merge main; mod screens are API 19; the server menu skips the slots a mod screen hides
A slot the mod's screen hides was still in the server's menu, so a shift-click
could move items into a slot the player cannot see. The server asks the same
native_screen when it opens the menu and the menu's clicks and quick moves skip
those slots. Also documents that data slots are 16-bit and start empty.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 00:56:49 +05:00
levzzzandClaude Sonnet 5.5 08918a8a7f Show the view-blocking block overlay by BlockState.isViewBlocking against the near plane
Frustum.getNearPlaneBounds, then stairs and slabs by outline, snow at 8 layers, others by isSuffocating.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 00:54:36 +05:00
levzzz 34118b285f Merge pull request #4622 from levzzz5154/parity/38-srvpkts
Server players: the flying kick and the build limit message told twice
2026-10-31 00:54:18 +05:00
levzzz b680d71bdc Merge pull request #4097 from Sylviromi/native-mod-neighbours
Native mods: what a machine's tick reaches (neighbouring mod block entities, its block's state)
2026-10-31 00:53:30 +05:00
Ludovic MarieandClaude Opus 5.5 1eedd39c76 lunge_motion: the jump's positions follow the server's ticks, not 50 ms sleeps
LocatorBarClient's arc slept 50 ms between positions, which drifts from the
server's ticks: on a busy runner the stab of `arc 8` came 8, 9 or 10 server
ticks after the jump, on vanilla as on Horizon, so the falling and sprinting
motions differed run to run (#4526's parity run failed on it). Each position
now waits until the game time moves past the latest `time query gametime`
reply, so the stab of `arc N` lands N ticks after the jump on both servers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 20:49:29 +01:00
Łukasz KopańskiandCursor 0c1b92ce82 Fix TNT bounce unit test attributes after a bad insert.
Co-Authored-By: Cursor <[email protected]>
2026-10-30 20:49:22 +01:00
Łukasz KopańskiandCursor d2839ff264 Bounce primed TNT when a slime piston hits it.
Fixes #4550: moveCollidedEntities' slime causeBounce skipped PrimedTnt,
so TNT sitting on slime did not launch when a piston pushed the block.

Co-Authored-By: Cursor <[email protected]>
2026-10-30 20:46:52 +01:00
Neferr 74351c8057 Key names follow the keyboard layout: an AZERTY player's Q key shows A
Keys are kept by position (SDL scancodes), as 26.3 keeps them, but named by what the US layout writes. Each key press now records what the player's layout writes on that key (winit's key_without_modifiers), and display_name shows it, as SDL_GetKeyFromScancode names keys with SDL's default keycode options: letter and symbol keys only, Latin letters only on a letter key, digits kept on the number row. Fixes #4592.
2026-10-30 20:46:36 +01:00
levzzz 8486a32926 Merge pull request #4620 from levzzz5154/parity/21-antenna
Copper golem antenna: apply the item's block_state component
2026-10-31 00:46:17 +05:00
levzzzandClaude Sonnet 5.5 31300e6846 Merge hr/main into parity/38-srvpkts
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 00:45:58 +05:00
Ludovic MarieandClaude Opus 5.5 cf6e5c2474 Merge main: mod inventories and menus took API 16 and 17, these hooks are API 18
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 20:45:35 +01:00
DUzzL 0a568f21aa Add armor stand targets to the mace parity harness
Hits on a living armor stand now run through the same oracle and fixture
as mob hits: smash, Wind Burst, nearby knockback and weapon wear, with a
shared hit_armor_stand entry in the game. The stand's own wobble and break
effects are left out of the comparison.
2026-10-30 20:45:16 +01:00
levzzzandClaude Sonnet 5.5 deec9041ac Merge main; neighbour and block-state helpers are API 18
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 00:44:25 +05:00
levzzz c822f6eb82 Merge pull request #4072 from Sylviromi/native-mod-menus
Native mods: menus for mod containers (vanilla slot grids and furnaces)
2026-10-31 00:43:16 +05:00
Łukasz KopańskiandCursor e1314bbcc9 Give raid captains the ominous banner stack for clients.
Fixes #4567: wear_banner only set the white_banner id, so SetEquipment
sent a plain banner and clients without PatrolLeader drew white cloth.
Keep Raid.getOminousBannerInstance on armor[0] and treat that stack as
captain for CustomHeadLayer.

Co-Authored-By: Cursor <[email protected]>
2026-10-30 20:42:23 +01:00
levzzzandClaude Sonnet 5.5 8d82e986f9 Server players: the floating kick, and the build limit told twice at the top
ServerGamePacketListenerImpl.noBlocksAround, getMaximumFlyingTicks and
tickPlayer's clientIsFloating / aboveGroundTickCount check, and
handleUseItemOn's second build-limit message for a failed placement on top
of the world.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 00:42:12 +05:00
levzzzandClaude Sonnet 5.5 d51a2eb268 Copper golem antenna block takes the item's block_state component (CopperGolemRenderer.extractRenderState)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 00:39:26 +05:00
levzzz b9f756ebad Merge pull request #4612 from levzzz5154/parity/20-render
Cat: lie on top of a sleeping player like vanilla
2026-10-31 00:36:20 +05:00
Ludovic Marie cf38f00b54 Merge pull request #3724 from aangeraakt/claude/kind-dijkstra-aaxkhw
🤖 Agent: pr-triage-1 (Ludovic's AI agent, Claude), not Ludovic himself. Main moved and conflicted in `NetConfig`: its new `horizon_clients_only` field now sits beside `owner_profile` (faf7d52f2). Rechecked on main: `parity.py --changed` 110/110, `pr_ready --quick` pass, lan_e2e `tab`, `self` and `chest` pass. Queued again.
2026-10-30 20:36:09 +01:00
levzzzandClaude Sonnet 5.5 76ab943b40 Merge main; menus for mod containers are API 17
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 00:35:35 +05:00
levzzz 6f846cf6a5 Merge pull request #4611 from levzzz5154/parity/39-build-limit-shoulder
Server players: build limit overlay and shoulder parrot ambient sound
2026-10-31 00:35:18 +05:00
Łukasz KopańskiandCursor d0fbd3f638 Draw ToastManager toasts over the title screen.
Fixes #4428: the title early-return skipped advancement, tutorial and
pack toasts, so a resource-reload failure never appeared there.

Co-Authored-By: Cursor <[email protected]>
2026-10-30 20:33:44 +01:00
levzzz a14c9886ce Merge pull request #4055 from Sylviromi/native-mod-inventories
Native mods: item slots for mod block entities (hoppers, comparators, drops)
2026-10-31 00:33:32 +05:00
Ludovic Marie 07641bb0b2 Merge pull request #4607 from horizon-rust/feat/anticheat-framework
🤖 Agent: pr_ready --quick green on af5528ef2; dedicated_server, server_entities, server_player_damage pass; anticheat_e2e: a 1.5x timer bot is flagged, set back, alerted and kicked, 6 legit bots never flagged, nothing runs when [anticheat] is absent; 60-bot A/B: off = main within noise
2026-10-30 20:32:35 +01:00
Ludovic MarieandClaude Opus 5.5 bf29bd9166 Merge main: block entities took API 15, these hooks are API 16
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 20:30:00 +01:00
levzzzandClaude Sonnet 5.5 820913dc4c Cat: a lying cat on top of a sleeping player sits 0.15 further along (CatRenderer.setupRotations)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 00:27:53 +05:00
Ludovic MarieandClaude Opus 5.5 e021395210 Native mods: native_action_bar, which reaches the acting player in their hook
native_tell reaches nobody inside a player's swap (the host is out of
self.lan there): a claim's refusal is told on the action bar instead,
through the acting guest's queued packets.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 20:26:46 +01:00
levzzzandClaude Sonnet 5.5 e311967b29 Server players: build limit overlay, shoulder parrot ambient sound
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 00:26:45 +05:00
levzzzandClaude Sonnet 5.5 5edf728ee3 Merge main; item slots for mod block entities are API 16; a block entity that breaks its own block drops its contents
The tick takes the block entity out of the world while the mod runs. If the
mod removed or replaced its block meanwhile, the saved items were dropped with
the taken value. They now spill like Containers.dropContents does on removal.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 00:23:21 +05:00
levzzz 6e8e088c25 Merge pull request #3996 from Sylviromi/native-mod-block-entities
Native mods: block entities for mod blocks (create, tick, save)
2026-10-31 00:21:02 +05:00
apeirsandClaude Opus 5.5 24e36962aa Chunk uploads: a 2 ms cap per frame when the player set no budget
With Chunk Updates and Upload Budget at 0 (the defaults), update_world
uploaded every mesh finished that frame, so a burst of finished sections
landed in one frame. Without the player's budgets, a burst now goes through
the queued path and uploads stop once they took 2 ms of the frame (at least
one per frame), nearest sections first; a frame's batch of 8 or fewer still
uploads at once without queueing. The player's own budgets work as before.

Fixes #4605

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 20:19:47 +01:00
k3yhandClaude Opus 5.5 da4ded1d7c Windows Vulkan: wait for the acquire fence only with VSync (gfx-rs/wgpu#10714)
wgpu-hal 30 waits on Windows for a fence that vkAcquireNextImageKHR
signals, in every present mode. It was added for FIFO, where AMD and
NVIDIA drivers present through DXGI and stuttered without it
(gfx-rs/wgpu#8310). With VSync off (Immediate, Mailbox) the driver
signals that fence only once the GPU has finished the previous frame, so
get_current_texture held the CPU for the whole GPU frame and the GPU sat
idle while the next one was recorded: 72% busy with a shader pack on an
RX 7900 XT. Apply the upstream fix (gfx-rs/wgpu#10714, not in a release
yet), which creates the fence only for Fifo and FifoRelaxed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 14:19:27 -05:00
kerim 669aa04e69 Merge branch 'main' into main 2026-10-30 20:19:03 +01:00
Ludovic Marie c27092a567 Merge remote-tracking branch 'origin/main' into HEAD 2026-10-30 20:17:29 +01:00
levzzz 17b1f8fd19 Merge pull request #4598 from levzzz5154/registry/form-links
Mod registry form: list a project by its repository, no updates through the form
2026-10-31 00:16:49 +05:00
Federico TaglianiandClaude Sonnet 5.5 6b3aacf619 Piglins open the doors on their path (InteractWithDoor)
Refs #3521

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 20:15:46 +01:00
kerim 6c023bdca0 fix de.json 2026-10-30 20:13:31 +01:00
levzzzandClaude Sonnet 5.5 f2d9c04794 Merge main; block entity hooks are API 15 (main is at 14)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 00:11:56 +05:00
Ludovic Marie 19ca1c8b33 Merge pull request #3762 from spidercraft/fsr-optical-flow
🤖 Agent: pr-triage-1 (Ludovic's AI agent, Claude), not Ludovic himself. Gate for this part (1/3), on 785796786 merged with main, Mac mini M4 Pro (Metal). **License**, checked against CONTRIBUTING's new rule on other projects' code (#4482): - FidelityFX SDK v1.1.4 is commit c6efa6bf7f20. At that commit, `LICENSE.txt` and the header of every file this port follows are AMD's MIT text, copyright line included: all ten `sdk/include/FidelityFX/gpu/opticalflow/*.h`, `sdk/src/components/opticalflow/ffx_opticalflow.cpp` and `gpu/spd/ffx_spd.h`. - `optical_flow.rs` follows `ffx_opticalflow.cpp` (its passes, their order and their dispatch sizes). So I added the same notice at its top and named that file in `THIRD_PARTY_NOTICES.md`, with a link to the SDK at the commit (785796786). Nothing else changed. #3763 and #3764 contain the old head and need this merged in. **Proof:** - `pr_ready --quick` passes. `parity.py --changed` has nothing to run: the selector maps these files only to lan_integration, which runs when named or nightly. - `shader_validates` and `finds_a_shift_and_a_scene_change` pass on Metal. **Cost per frame:** - Off: zero. `OpticalFlow` is constructed nowhere until part 3. - On: measured locally with a throwaway test (not pushed) that dispatches it on two alternating frames, 100 frames after a warm-up: | size | GPU, median (p90) | CPU encode (debug build) | |---|---|---| | 1280×720 | 2.33 ms (2.96) | 0.27 ms | | 1920×1080 | 3.30 ms (3.76) | 0.29 ms | | 2560×1440 | 4.68 ms (5.53) | 0.31 ms | | 3840×2160 | 8.48 ms (9.35) | 0.32 ms | Part 3 should keep this in mind: on an M4 Pro the flow alone costs a fifth of a 60 fps frame at 1080p, before the interpolation, so frame generation only gains where the real frame takes much longer than that. Keep it off by default, as planned. - Smaller notes, none blocking: - `dispatch` creates the `prepare_luma` bind group every frame; it could be cached per colour view. - The scene-change "last workgroup" handoff relies on relaxed WGSL atomics being visible across workgroups, which holds on current drivers. Queued for the merger (`skip-release`: players see no change).
2026-10-30 20:10:55 +01:00
JustKarkatandZCode 83e8e0ba1f Native mods: the player-health HUD rows as a hook point
API 14 adds native_health_hud: once a frame the HUD draws the health rows,
the hook hears the layout's inputs and the screen's GUI size, and a mod that
answers with draws replaces all the rows — armour, hearts, food, air and the
heights above the hotbar. Nothing answered draws Horizon's own, and the
call is before the atlas re-borrow so the frame is unchanged for every mod
that does not answer.

This is the hook the colored hearts HUD mod needs (the extras' one-row
tinted layout replaces the rows); a mixin cannot reach HealthHud::layout or
hud_layers, whose bodies hold closures horizon-mixins refuses to inject
into.

Co-Authored-By: ZCode <[email protected]>
2026-10-30 14:10:44 -05:00
levzzzandClaude Sonnet 5.5 b781de899d Form service: list a project by its repository, take no updates for it
A submission with repo (and no file) opens a pull request adding an entry that
links the public GitHub repository; its versions are the repository's releases.
An update through the form of a project that has a repo is refused with
'publish a GitHub release in <repo>'. Projects without a repo keep the file path.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 00:08:06 +05:00
levzzz af8f2fb7c2 Merge pull request #4593 from levzzz5154/registry/entry-repo-releases
Registry: an entry is a repository link and a page; versions come from its releases
2026-10-31 00:08:02 +05:00
levzzz 04687c99c6 Merge pull request #4589 from levzzz5154/registry/links-and-releases
Launcher: accept a GitHub release asset as a registry file
2026-10-31 00:06:45 +05:00
Ludovic Marie b90b7d8aac Merge pull request #3743 from UMCEKO/mods/equipment-every-namespace
🤖 Agent: pr-triage-1 (Ludovic's AI agent, Claude), not Ludovic himself. I merged main into this branch (97f2c57e5, 6350e5219). On main, an equipment kind now holds every layer (leather's overlay is layer 1). So the namespace resolution now runs for each layer's texture, and `content_mods_equipment_loads_under_its_namespace` expects a list of layers. `hud.rs` keeps your namespaced sprite names with main's decoded image. Proof, on main with #3741 and #3742: - `parity.py --changed` 110/110; - `pr_ready --quick` pass, including both new tests. - In the game: pr-triage-3's pack test at your heads (comment on #3740) showed amethyst, copper and chorundum armour drawing their own textures. - A root without content mods: `content_namespaces::namespaces` is empty, so no extra folder is walked, and every vanilla texture still takes the `strip_prefix(textures)` branch. Same sprites as before. Queued after #3742.
2026-10-30 20:06:28 +01:00
Ludovic Marie 03331b3d52 Merge pull request #3742 from UMCEKO/mods/port-item-stats
🤖 Agent: pr-triage-1 (Ludovic's AI agent, Claude), not Ludovic himself. I merged main into this branch through #3741's carry (no conflicts of its own). It's proven with #3741 and #3743 on main: `parity.py --changed` 110/110 and `pr_ready --quick` pass. Queued after #3741.
2026-10-30 20:06:23 +01:00
Ludovic Marie bf4e738618 Merge pull request #3741 from UMCEKO/mods/harness-item-components
🤖 Agent: pr-triage-1 (Ludovic's AI agent, Claude), not Ludovic himself. I merged main into this branch (fd41f0e36; `mod_port_probe.rs` now uses main's `PackOverlay` loaders). I also made the one change pr-triage-3 asked for in its pack test on #3740 (a502eccc9): the attribute normaliser drops 1.21.1's `player.` prefix too, so spears' `entity_interaction_range` matches the oracle. Proven together with #3742 and #3743 on main: `parity.py --changed` 110/110 and `pr_ready --quick` pass. Queued for the merger; #3742 and #3743 follow.
2026-10-30 20:04:07 +01:00
Ludovic MarieandClaude Opus 5.5 1de9f9fc69 Native mods: right click on a block, the acting player, server commands (API 15)
native_right_click_block hears a player's right click on a block before
the block's own use and the item's, and may cancel it; native_acting_player
says who acts inside a player's hook; native_run_command runs a command as
the server's console (teleports across levels); server players carry
their health. What a server mod needs for land claims and teleports.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 20:03:12 +01:00
Federico TaglianiandClaude Sonnet 5.5 2b85acfeaf Mobs re-path when a block changes near their route (PathNavigation.shouldRecomputePath)
Refs #3521

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 20:03:07 +01:00
Muhammad AsifandOhMyAgent e74907f551 Give each server sound packet its own seed
Hub::sound used the server tick as ClientboundSoundPacket.seed, so two
sounds in the same tick picked the same client variant. Level.playSound
draws soundSeedGenerator.nextLong per call.

Closes #4576.

Co-Authored-By: OhMyAgent (grok-4.6) <[email protected]>
2026-10-31 00:00:27 +05:00
levzzzandClaude Sonnet 5.5 85e7517b76 Registry: an entry is a link and a page; its versions are the repository's releases
An entry names its GitHub repository (repo). The page fields may come from
horizon-registry.toml in that repository (the entry's own win), and the versions
the registry's CI read from the repository's releases are kept in
versions/<id>.json and listed beside the entry's own [[versions]], which become
optional. cargo horizon-mod registry release|page run a submission's checks on a
release and print the version/page for that cache. The index schema is unchanged.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 23:59:13 +05:00
Ludovic MarieandClaude Opus 5.5 faf7d52f20 Merge main into claude/kind-dijkstra-aaxkhw
Conflict: main's horizon_clients_only beside owner_profile in NetConfig.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 19:57:26 +01:00
umcekoandClaude Opus 5.5 9765a0341d Port pack: item models only an item's overrides use are not items
Without the oracle (the mod browser's check), every models/item file
became an item, so 1.21.1's armour trim looks, which the armour's model
switches to through `overrides`, came over as items of their own:
Simplest Emerald Gear declared 49 items where it registers 9. A model
another item model's overrides name is a variant of that item now, and
gets neither a declaration nor an item definition.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 21:57:04 +03:00
umcekoandClaude Opus 5.5 21ec14c0b2 Port pack: armour of a mod's own material draws its own textures
Ported armour took the iron template's equippable, so worn it drew as
iron. Each armour material of the mod's namespaces now becomes a 26.3
equipment asset from its 1.21.1 layers (the models/armor layer textures
copied to textures/entity/equipment/humanoid and humanoid_leggings), and
the items' equippable names it with the material's equip sound. The
harness checks that the textures Horizon resolves for worn armour are the
jar's own: Simplest Emerald Gear's item stats 72/72.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 21:57:04 +03:00
Ludovic Marie cb2a7fb1b6 Merge pull request #4561 from horizon-rust/docs/smp-gambling
🤖 Agent: docs only (docs/smp.md), checked at 4cbfa78: the gambling rules match the orchestrator's decision of 2026-10-30 (on for the official server only, off in the mod by default; in-game currency only, never bought with real money or cashed out, no paid random items), and the coinflip lines match horizon-mods #58 as merged; pr_ready and CI green.
2026-10-30 19:56:36 +01:00
Ludovic MarieandClaude Opus 5.5 7857967863 Optical flow: AMD's notice on the Rust side too, and the SDK commit
optical_flow.rs follows ffx_opticalflow.cpp (passes, order, dispatch
sizes), so it keeps the SDK's copyright line and MIT notice like the WGSL
port; THIRD_PARTY_NOTICES names that file and links the SDK at the v1.1.4
commit, as CONTRIBUTING's rule on other projects' code asks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 19:56:02 +01:00
levzzzandClaude Sonnet 5.5 4fabf2025c Launcher: install registry files from a GitHub release asset
The registry's index will list the release assets of a mod's own repository
(https://github.com/<owner>/<repo>/releases/download/<tag>/<name>), so an author
ships a version without a registry pull request. The launcher accepted only the
registry's own releases; it now also accepts that exact address shape. The
index's SHA-256 still pins the bytes.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 23:55:22 +05:00
levzzz 3c580814c8 Merge pull request #4483 from ludovicthenot/native-block-hooks
Native mods: hook points for breaking and placing a block (API 6)
2026-10-30 23:52:42 +05:00
Ludovic MarieandClaude Opus 5.5 876d0003d5 Anticheat framework, off by default, with a timer check
A new crate, horizon-anticheat, holds server.toml's [anticheat] settings, each player's violation
levels (they decay every second) and the checks' rules, with no game state. The dedicated server's
hub runs the checks where it handles packets and carries out a flag: send the player back, alert the
console, the log file and online operators, then kick; never a ban. Each check has its own toggle and
thresholds. Without [anticheat] (the default, and the generated server.toml only has a commented
example) the hub holds no anticheat and the server does what vanilla's does.

The first check is timer: vanilla's client ends each tick with ServerboundClientTickEndPacket at the
server's tick rate, so a client further ahead of the wall clock than 300 ms is flagged, with a capped
balance for lag spikes and slow server ticks.

horizon-bots gets --timer F (a sped-up client), tools/anticheat_e2e.py runs a real dedicated server
with legit and cheating bots, and server_bench.py --anticheat measures the cost.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 19:49:49 +01:00
Claude d63d89bb86 Show the armour stand preview in the smithing table
SmithingScreen.updateArmorStandPreview puts the result on a stand without
its base plate and with its arms (armour in its slot, anything else in the
off hand), drawn by extractEntityInInventory beside the slots. The world's
armour stand drawing is split out per stand so the screen can reuse it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Rx5M6NS763D2WVnb2HmcTH
2026-10-30 18:46:36 +00:00
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
Claude 72f66bb71c Draw map markers on the cartography table's preview
MapRenderer.render in a screen draws the map's decorations over its image,
all of them since the map isn't in an item frame.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Rx5M6NS763D2WVnb2HmcTH
2026-10-30 18:42:28 +00:00
levzzzandClaude Sonnet 5.5 f969e62a56 Merge main: block hooks are API 14
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 23:42:16 +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
gravitacia 574c600551 Use imported resource packs in launcher profiles
Preserve profile edits, allow resource-only play, and distinguish imported records from registry packs. Finish archive conflict checks and document the partial roadmap milestone.
2026-10-30 18:31:48 +00:00
gravitacia f7dc8dd714 Fetch indexed resource packs and report carry-over outcomes 2026-10-30 18:31:48 +00:00
gravitacia 6fa052b4e0 Carry embedded and instance resource packs into imports 2026-10-30 18:31:48 +00: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
Claude aced527885 Draw the enchanting table's book in its screen
EnchantmentScreen.tickBook/extractBook: the book opens while an offer has a
cost and turns to a new page when the enchanted item changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Rx5M6NS763D2WVnb2HmcTH
2026-10-30 18:26:59 +00:00
Claude bec89a8927 Draw the parrots on the player's shoulders
A parrot that lands on its owner is kept in ShoulderEntityLeft/Right, but
nothing drew it, so it seemed to vanish. Port ParrotOnShoulderLayer and
AbstractClientPlayer.getParrotVariantOnShoulder: the parrot model in
ParrotModel.Pose.ON_SHOULDER with the player's walk animation, age and
head turn, 0.4 to the side and 1.5 (1.3 crouching) up the player's model,
on the player in third person and in the inventory preview.

Part of #3518.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Rx5M6NS763D2WVnb2HmcTH
2026-10-30 18:26:58 +00:00
Claude 648531c6da Play the happy ghast's riding sound while the player rides one
LocalPlayer.startRiding starts a RidingEntitySoundInstance for a happy
ghast (entity.happy_ghast.riding, volume 5 * clampedLerp(speed, 0, 1)),
as it does the two inside sounds for a minecart; Horizon only had the
minecart's. Port the instance: it stops when the player gets off or the
ghast is silenced, and is silent under water and below 0.01 speed.

Part of #3518.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Rx5M6NS763D2WVnb2HmcTH
2026-10-30 18:26:58 +00: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
DUzzL c08c051a92 Document the menu-transfer check 2026-10-30 19:24:04 +01:00
levzzz 46c09f1c70 Merge remote-tracking branch 'hr/main' into fix/pr-3891 2026-10-30 23:24:03 +05:00
levzzzandClaude Sonnet 5.5 ae9a990929 Merge launcher/native-mods-on-by-default into native-mods-where
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 23:24:02 +05:00
Ludovic MarieandClaude Opus 5.5 3b284f173f Merge mods/port-item-stats into mods/equipment-every-namespace
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 19:24:00 +01:00
Ludovic MarieandClaude Opus 5.5 18a900a360 Merge mods/harness-item-components into mods/port-item-stats
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 19:24:00 +01:00
Ludovic MarieandClaude Opus 5.5 a502eccc92 Mod port harness: drop 1.21.1's player. attribute prefix too
1.21.2 renamed player.entity_interaction_range, player.block_interaction_range
and the other player.* attributes along with generic.*, so a spear's
interaction range never matched the oracle's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 19:24:00 +01:00
levzzzandClaude Sonnet 5.5 a4a90ad0a1 Merge launcher/native-builds-stay-put into native-mods-where
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 23:23:31 +05:00
levzzzandClaude Sonnet 5.5 e06a5ced37 Merge main into native-builds-stay-put (version-mismatch build option alongside base_for)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 23:22:37 +05:00
Federico TaglianiandClaude Sonnet 5.5 8dff50318a Port map: the frog and goat long jump rows point at frog.rs (ported in #3946)
Refs #3521

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 19:22:06 +01: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
levzzzandClaude Sonnet 5.5 d2c4e9ac87 Merge main into native-mods-on-by-default; the version-mismatch panel moves to the Mods page's Native build row
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 23:20:29 +05: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
apeirsandClaude Opus 5.5 29b58bb0fc Trading: Space and Enter press the focused offer again (hold Space to refill)
AbstractButton.keyPressed presses the focused button on Enter, Space or
numpad Enter, key repeats included, and the offer button clicked last keeps
focus through slot clicks. Each press reruns MerchantScreen.postButtonClick
(tryMoveItems + SelectTrade), so holding Space refills the payment slots
after every trade. Horizon tracked the focus but no key pressed it, and
screens never saw key repeats. horizon-ctl's key command takes the same path.

lan_e2e: merchant-reselect trades three times from one offer click, on the
host and on a guest.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 19:18:31 +01:00
Federico TaglianiandClaude Sonnet 5.5 c8c02381b5 Baby armadillos follow the nearest visible adult (AdultSensor, BabyFollowAdult 5..16)
Refs #3521

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 19:18:27 +01:00
levzzzandClaude Sonnet 5.5 e7fae3d587 Launcher i18n: drop removed native-mods strings, add Polish for the new ones
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 23:17:25 +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 2e52f01d71 Merge main into native-mods-where
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 23:16:11 +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
CocofireHDandHermes Agent c1a5052c43 A pillager shows the arrow in its loaded crossbow
The crossbow model picks its charged form from the stack's
`charged_projectiles` component. Nothing set it for a mob that loads its own
crossbow, and the mob renderer and the equipment packet kept only the item
name, so a mob's components never reached the model at all. Set and clear the
component with the pillager's charge state, draw the stack the mob actually
holds, and keep the stack the equipment packet sends.

Fixes #4521.

Co-Authored-By: Hermes Agent (deepseek-v4.1-flash) <[email protected]>
2026-10-30 19:13:35 +01: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
Muhammad AsifandOhMyAgent fd855f8815 Bounce dropped items on slime, including piston-moved slime
ItemEntity.tick zeroed Y on any vertical collision, so items never
got slime restitution. A moving piston carrying slime has no
bounceRestitution of its own. Landing now bounces at 0.8 (non-living
Entity.restituteMovementAfterCollisions), and a piston slime launch
clears on_ground so ItemEntity.tick does not skip movement.

Vanilla: ItemEntity.tick, Entity.move, PistonMovingBlockEntity.causeBounce.

Co-Authored-By: OhMyAgent (grok-4.6) <[email protected]>
2026-10-30 23:11:12 +05:00
AurelienBelloirandClaude Opus 5.5 c1db3bad48 Leave ExactTeleport out of an end gateway's update tag when unset
TheEndGatewayBlockEntity.saveAdditional writes Age and exit_portal, then
puts ExactTeleport (as true) only when exactTeleport is set. getUpdateTag
is saveCustomOnly, so vanilla's chunk packets and BlockEntityData never
carry ExactTeleport = false. entity_tag wrote the key unconditionally;
it now only writes it when the flag is set, as save.rs already does.
The test checks both cases round-trip.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 19:10:37 +01: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
Ludovic MarieandClaude Opus 5.5 4cbfa78bee docs/smp.md: the gambling rules, on for the official server only
In-game currency only, never bought with real money, never cashed out, no
paid random items; off in the mod by default, on for the official server.
How a coinflip's stakes are held, as the mod does it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 19:08:30 +01: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
DUzzL 3fa621d254 Save the previous velocity for remote players and test the elytra yaw lerp 2026-10-30 19:07:23 +01:00
Federico TaglianiandClaude Sonnet 5.5 a9b932e4c2 Frogs breed: slimeball feeding, pregnancy, and frogspawn laid on water
Refs #3521

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 19:07:17 +01: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
AurelienBelloirandClaude Opus 5.5 0c9ac0cfae Send beacon, end gateway and creaking heart update tags
BeaconBlockEntity, TheEndGatewayBlockEntity and CreakingHeartBlockEntity
override getUpdatePacket (ClientboundBlockEntityDataPacket.create) with
getUpdateTag = saveCustomOnly, so vanilla puts their data in the chunk
packet (BlockEntityInfo.create) and in the BlockEntityData that
ChunkHolder.broadcastBlockEntityIfNeeded sends for a block the level
reports changed (sendBlockUpdated, /data merge block). The LAN host's
entity_tag had no tag for them, so guests got none: a gateway's Age (its
spawning beam) and a beacon's effects never reached them.

entity_tag now gives the three their saveAdditional fields: primary and
secondary effect and Levels; Age, exit_portal and ExactTeleport; the
creaking UUID. The rest of the row from #3520 was already in place: the
host's block log is its sendBlockUpdated, and sign edits, sign dyes, ink
and wax, shelves, campfires, vaults, spawners, trial spawners, decorated
pots and /data already push to it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 19:05:45 +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
Ludovic MarieandClaude Opus 5.5 e51783d5bd Launcher: Sign in with Microsoft and loader logos (redesign R8c)
Every button that signs in is now Microsoft's "Sign in with Microsoft",
drawn to Microsoft's published branding (41 pixels, the four-square
symbol from its official SVG, dark #2F2F2F / light white with a #8C8C8C
border, semibold 15 pixels): the Overview's account row, preparing
without the game, the sign-in sheet and Settings. The New instance
dialog's loader cards show Fabric's and Quilt's logos.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 19:01:11 +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 ddf1be8050 Launcher i18n: drop removed native-mods strings, add Polish for the new ones
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 22:56:29 +05:00
AurelienBelloirandClaude Opus 5.5 9b47769757 Send a team leave before the join that moves a player
ServerScoreboard.addPlayerToTeam sends the old team's leave
(createPlayerPacket REMOVE) before the new team's join, and a client's
Scoreboard.removePlayerFromTeam throws IllegalStateException when the
player already sits in another team. The LAN host's scoreboard diff sent
teams in name order with joins before leaves, so moving a player to a
team sorting earlier (red to blue) made a vanilla guest drop the packet
with an error and Horizon's remote client take the player out of the
new team. All leaves now go before any add or join.

The remote client's handleSetPlayerTeamPacket leave now only takes a
player out of the named team, as removePlayerFromTeam(name, team) does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:55:55 +01: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
levzzzandClaude Sonnet 5.5 32e8e51cfc Merge main into native-mods-on-by-default
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 22:53:10 +05:00
levzzzandClaude Opus 5.5 5f0ac95135 Native hooks doc: NeoForge's public documentation only, firing points from vanilla
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 22:51:38 +05:00
levzzz ca2955ef0c Merge main 2026-10-30 22:51:24 +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
Ludovic MarieandClaude Opus 5.5 b6e7d3e7c0 Launcher: instance icons, pictures and loader marks (redesign R8b)
An instance's icon, in the sidebar, Home's hero and its dialog, is now its
own picture first: one the player chose (Choose image…, scaled to 128
pixels and kept as icon.png in its game folder) or its modpack's icon,
written when the Mods page installs a pack. Else the block or colour
chosen in the dialog; else its mark: the loader's logo (Quilt's and
NeoForge's shipped under CC0 and CC BY 4.0, Fabric's and Forge's fetched
from their own sites), the grass block for plain Minecraft, Horizon's for
Horizon. The letter tile is the last resort.

The dialog's icon tiles sit in rows of eleven: a wrapping row was
measured as one line and the plate ended above the buttons.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:46:24 +01:00
MSedgeMC 0d31254815 Retarget ledger rows that are already ported
The port ledger is where AGENTS.md sends every contributor before parity
work, and thirteen rows still said missing for behaviour that is on main:
the spectral arrow's post-hurt effects and trail, the axolotl's bucket
hand-back and rehydration, the allay's note block memory and vibration
listener, the pillager's raid banner, and three splash potion methods.

Each now points at the function that ports it, verified against the 26.3
jar and the tree. Three pillager rows were transcribed from a branch that
is unreachable in vanilla: a white banner is a BannerItem, so pickUpItem
sends it to Raider.pickUpItem before wantsItem is ever consulted.
2026-10-30 23:12:22 +05:30
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 MarieandClaude Opus 5.5 6350e5219d Equipment test: a kind holds every layer, as on main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:35:44 +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 MarieandClaude Opus 5.5 97f2c57e5d Merge main into mods/equipment-every-namespace
Through mods/port-item-stats with main merged. Conflicts: main's
equipment layers became every layer of a kind (leather's overlay), so each
layer's texture now resolves its namespace there; hud.rs keeps the
namespaced sprite names with main's decoded image.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:35:24 +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
AurelienBelloirandClaude Opus 5.5 4b32fb8cf7 Name the world preset test's folders without a colon
each_world_preset_writes_its_own_generator joined the preset id
(`minecraft:normal`) to its temporary folder, and Windows refuses `:` in
a file name (os error 267), so the test failed there on every run.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:34:48 +01:00
Ludovic MarieandClaude Opus 5.5 5d7c622895 Merge main into mods/port-item-stats
Through mods/harness-item-components with main merged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:34:42 +01:00
Ludovic MarieandClaude Opus 5.5 fd41f0e36f Merge main into mods/harness-item-components
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:34:29 +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
Ludovic MarieandClaude Opus 5.5 9845e2cd9b Fit SyncData::empty to main's SyncData
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:31:24 +01:00
Ludovic MarieandClaude Opus 5.5 b01025353e Merge main into claude/kind-dijkstra-aaxkhw
Conflicts: main's integrated_open (owner_profile joins its NetConfig), the
level shapes beside owner_profile in NetConfig, SyncData::load's pack
overlay, and main's lazy skin drawing in update_remote_skins (the offline
skin is stored when its slot is gone, as the offline cape is). Also brings
back conn.rs's prompts_become_the_components_vanilla_sends test, which the
branch had replaced by mistake.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:30:29 +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
Dmytro ChuprynaandClaude Opus 5.5 907b6e1dcc Use workspace base64 and sha1 crates in skins.rs
Replace hand-rolled base64 decoding and SHA-1 hashing in crates/horizon-client-render/src/skins.rs with the base64 and sha1 crates already used across the workspace, preserving the existing decoding fallbacks and compatibility tests.

Fixes #4431

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:21:05 +01: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
Ludovic MarieandClaude Opus 5.5 8fcfb3b8f4 Run parity with the run-parity label (the check this PR fixes)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:18:42 +01:00
Kacpherek d848b1e05d Put guests in a changed default game mode when it is forced
Only a join or the world-options force switch applied the forced mode, so
/defaultgamemode or the default-mode button left guests in the old mode.
IntegratedServer.setWorldGameType and DefaultGameModeCommands.setMode call
updateGameModeBasedOnPermission / enforceGameTypeForPlayers, which sets every
online guest without game master permission to the new default.
2026-10-30 18:15:59 +01:00
Kacpherek c727c26612 Show the smithing table's onboarding tooltips on empty slots
Only the upgrade error tooltip existed, and it was one unwrapped line.
SmithingScreen.extractOnboardingTooltips shows Add Smithing Template over the
empty template slot, and the template's base or additions slot description
over an empty base or addition slot; every one is split at 115 pixels
(font.split(component, 115)).
2026-10-30 18:15:59 +01:00
Kacpherek 9e6453c457 Mark servers of another protocol as incompatible in the list
Every server that answered the ping showed ping bars and a player count.
ServerSelectionList.OnlineServerEntry sets State.INCOMPATIBLE when the
status protocol differs from the client's: the red version name replaces the
player count, the icon is server_list/incompatible, its tooltip reads
multiplayer.status.incompatible, and the player list stays on the name.
2026-10-30 18:15:59 +01:00
Kacpherek af84c3a547 Make Home and End in a book page move to the line's edges
Home and End jumped to the start and end of the whole page. In vanilla's
MultilineTextField.keyPressed they seek getCursorLineView().beginIndex and
endIndex, the visual line holding the caret; only Ctrl+Home and Ctrl+End go
to the page's ends.
2026-10-30 18:15:59 +01:00
Kacpherek 04f73f422a Re-read the held keys when the game takes the pointer back
A movement key pressed or kept down behind a screen did not count once the
screen closed, so the walk stopped until the key was pressed again.
MouseHandler.grabMouse calls KeyMapping.setAll off macOS
(InputQuirks.RESTORE_KEY_STATE_AFTER_MOUSE_GRAB), which sets every mapping
from the keys physically down, except sneak and sprint while they toggle.
2026-10-30 18:15:59 +01:00
Kacpherek 7886e3f5d7 Close the sign editor when the player walks out of reach
The editor only closed when its sign was removed (and only on a server's
client). AbstractSignEditScreen.tick closes it whenever isValid fails, which
also covers SignBlockEntity.playerIsTooFarAwayToEdit: eyes further than the
block interaction range plus 4 blocks. Both paths now run the same check.
2026-10-30 18:15:59 +01:00
Kacpherek e8e251df67 Grey out empty Items and Mobs tabs on the Statistics screen
An Items or Mobs tab with no rows could still be selected and showed an
empty list. StatsScreen.setTabActiveStateAndTooltip makes such a tab
inactive (grey label, no click) and gives it the gui.stats.none_found
tooltip (StatsScreen.java).
2026-10-30 18:15:59 +01:00
Kacpherek 1fb244e0a5 Show Take Book on a lectern only to players who may build
An adventure or spectator player saw Take Book on every lectern. Vanilla's
LecternScreen.createMenuControls adds Done and Take Book only when
player.mayBuild() holds, and otherwise falls back to BookViewScreen's single
wide Done button (LecternScreen.java).
2026-10-30 18:15:59 +01:00
Kacpherek 0bc5e0de9c World list: disable and mark worlds that cannot be played
Play stayed enabled for a world locked by another instance or from an incompatible series, Edit and Re-Create stayed enabled for a world whose files need upgrading, and the hover icon was always the join arrow. LevelSummary (isDisabled, primaryActionActive, canEdit, canRecreate, primaryActionMessage) and WorldSelectionList.WorldListEntry (canInteract, extractContent) grey those buttons out, show Upgrade and Play, ignore a double click or icon click on a disabled world, draw the error or marked join sprite with its tooltip, and tooltip the buttons of a world that needs its files fixed. The info line names an incompatible version in red and shows a backup world's version in italics, red for a downgrade.
2026-10-30 18:15:59 +01:00
Kacpherek 6557c0604c Re-Create keeps the world's data packs and alerts on an unreadable world
Re-Create World took the name, seed and options but none of the world's data packs, and a world whose level.dat would not read did nothing when the button was pressed. WorldSelectionList.WorldListEntry.recreateWorld (WorldSelectionList.java) copies the world's datapacks into the new screen's pack folder (CreateWorldScreen.createTempDataPackDirFromExistingWorld) and keeps its enabled packs, and on failure shows an AlertScreen with selectWorld.recreate.error.title and .text over the list.
2026-10-30 18:15:59 +01:00
Kacpherek f3a05ae1a3 Reopen the sound output when its device is lost or the default changes
Unplugging headphones or switching the system output left Horizon silent until restart: the stream's error callback only logged. SoundEngine.shouldChangeDevice (SoundEngine.java) reloads the engine when the open device disconnects, when the system default changes with no device chosen in the options, or when the chosen device comes back. Audio::tick now watches the stream's DeviceNotAvailable and looks at the default output once a second, then stops all sounds and opens the output again, as SoundEngine.reload does.
2026-10-30 18:15:59 +01:00
Kacpherek 6f0c7b1441 Send language, chat colors and particles in the client information
Settings::client_information filled only chat visibility, skin layers, main hand and server listing; the language stayed en_us, chat colours on and particles at All whatever the options said. Options.buildPlayerInformation (Options.java) sends languageCode, chatColors and particles as set.
2026-10-30 18:15:59 +01:00
Kacpherek bb1007fae9 Show the Amplified help tooltip on the world type button
Create New World's World tab showed no tooltip for Amplified. Vanilla (CreateWorldScreen.WorldTab, WorldCreationUiState.WorldTypeEntry.isAmplified) gives the type button the generator.minecraft.amplified.info tooltip whenever the selected preset is Amplified.
2026-10-30 18:15:59 +01:00
Kacpherek d5a8d23be6 Creative screen: switch to the survival inventory when creative ends
The creative inventory stayed open after the player left creative mode
(/gamemode survival, or a server's game mode change), so items could
still be taken from it. CreativeModeInventoryScreen.containerTick opens
an InventoryScreen once the player no longer hasInfiniteMaterials.
2026-10-30 18:15:59 +01:00
Kacpherek b5dace7edd Recipe book: dragging across the search box selects text
A press in the recipe book's search box placed the caret, but moving the
mouse with the button held did nothing. RecipeBookComponent.mouseDragged
forwards the drag to the search box while it is focused, and
EditBox.onDrag moves the selection end to the pointer.
2026-10-30 18:15:59 +01:00
Kacpherek e39901dc6f Recipe book: a narrow window's open book disables the slots under it
With the book open in a window under 379 px wide it covers the menu, yet
the slots under it could still be hovered and clicked, and an outside
click dropped the carried stack. AbstractRecipeBookScreen.isHovering is
false while the book is visible and widthTooNarrow, and its mouseClicked
returns true for every click in that state instead of passing it to
AbstractContainerScreen.
2026-10-30 18:15:59 +01:00
Kacpherek 963f465dc6 Recipe book: close after placing a recipe in a narrow window
In a window narrower than 379 px the book is not offset next to the menu,
and Horizon kept it open over the menu after a recipe was placed.
RecipeBookComponent.mouseClicked calls setVisible(false) after a placed
recipe when isOffsetNextToMainGUI is false, which stores the closed state
and sends the settings. A refused click (tryPlaceRecipe false) leaves it
open.
2026-10-30 18:15:59 +01:00
Kacpherek 0ada161e69 Container screens: a bundle's tooltip shows while carrying a stack
Hovering a bundle with a stack on the cursor showed no tooltip at all.
AbstractContainerScreen.extractTooltip shows the hovered item's tooltip
with a carried stack when its tooltip image says
showTooltipWithItemInHand, which ClientBundleTooltip does. Applied to
the inventory, container, station, trading, beacon and creative screens.
2026-10-30 18:15:59 +01:00
Kacpherek ca5315dc0d Bundle tooltip: show the selected item's name above the grid
The bundle tooltip drew the grid and the bar but not the box with the
selected item's name above it. ClientBundleTooltip.extractSelectedItemTooltip
(ClientBundleTooltip.java) draws the stack's styled hover name in a
tooltip of its own, centred on the component less 12, 15 above its top,
placed by DefaultTooltipPositioner (+12, -12, kept on screen).
2026-10-30 18:15:59 +01:00
Kacpherek 536fe04942 Container screens: draw the slot highlight sprites
The hovered slot got a flat 50% white square over its item. Vanilla draws
the 24x24 container/slot_highlight_back sprite behind the item and
slot_highlight_front over it, at the slot minus 4 (AbstractContainerScreen
.extractSlotHighlightBack and extractSlotHighlightFront), and skips slots
whose isHighlightable is false (the crafter's NonInteractiveResultSlot).
Done for the inventory, container, station, trading, beacon and creative
screens; the quick-craft drag preview keeps its 0x80ffffff fill as in
AbstractContainerScreen.extractSlot.
2026-10-30 18:15:59 +01:00
Kacpherek 14b5b031d3 Music Toast: the Now Playing toast with its music notes
The Music Toast option did nothing: the music manager queued show and
hide requests that nothing read, and no toast existed. ToastManager
makes a NowPlayingToast under Pause and Pause and Toast
(initializeMusicToast, setMusicToastDisplayState), shows it when a song
starts and hides it when music stops (showNowPlayingToast,
hideNowPlayingToast); NowPlayingToast.update times it out after five
seconds of Notification Time, extractToast draws the nine-slice
background, the music notes tinted by ColorLerper MUSIC_NOTE
(tickMusicNotes) and the song's language key in light grey. It slides
in from the left over every screen but the pause screen, which draws it
itself while music is audible (PauseScreen.rendersNowPlayingToast).
2026-10-30 18:15:58 +01:00
Kacpherek 397e947fac Advancement toast: wrap a long title and fade the label into it
A long advancement title stayed on one line at the toast's right edge.
AdvancementToast.extractRenderState (AdvancementToast.java) splits the
title at 125 pixels; with more than one line it shows the frame label
centred and fading out until 1.5 s of full visibility, then the title
lines centred and fading in over 0.3 s.
2026-10-30 18:15:58 +01:00
Kacpherek db2c7b799d Chat: grey Not Secure bar, Modified tag and Only Show Secure Chat
Player chat lines never carried a tag, so the grey bar for an unsigned
message and the Modified bar and icon for a server-edited one were
missing, and Only Show Secure Chat did nothing. ChatTrustLevel.evaluate
(ChatTrustLevel.java) calls a message not secure when it has no
signature or is older than seven minutes, modified when the signed
content is not in the line or the replacement uses another font, and
createTag turns that into a GuiMessageTag. ChatListener.showMessageToPlayer
drops not secure lines with the option on, and the player's own message
on their own server is always secure. ChatComponent shows the tag text
over the bar and the icon, and the icon on the hovered message.
2026-10-30 18:15:58 +01:00
Kacpherek bc005e8ac2 Title splash: sometimes show the player's name as NAME IS YOU
The title screen only ever drew a list entry. SplashManager.getSplash
(SplashManager.java), outside the three special dates, rolls
nextInt(size) == 42 and then shows the logged-in user's name in
upper case followed by " IS YOU" instead of a list entry.
2026-10-30 18:15:58 +01:00
Kacpherek be75260bc0 Show the Reconfiguring screen while a server reconfigures the client
The client kept drawing the old world while a proxy or server sent it back to
configuration. ClientPacketListener.handleConfigurationStart flushes the held
chat and clearClientLevel opens ServerReconfigScreen: connect.reconfiguring
over the panorama with a Disconnect button that only becomes active after
600 ticks (DISCONNECT_TIME); the layout is a vertical LinearLayout with cells
padded by 10. handleLogin replaces it with the loading screen.
2026-10-30 18:15:58 +01:00
Kacpherek f5f968df5c Nudge the player out of a block it stands inside
A player whose body corner sat in a solid block (a block placed or pushed
onto the player) was left there. LocalPlayer.aiStep calls
moveTowardsClosestSpace at the four corners (0.35 of the width out) unless
the player is a spectator or a passenger: a corner in a suffocating block
sets a 0.1 speed on the axis of the nearest side that is free (suffocatesAt,
LocalPlayer.java).
2026-10-30 18:15:58 +01:00
Kacpherek fd4c3e01e9 Keep sprinting along a wall you push into at a shallow angle
The local player's minor_horizontal_collision was a stub returning false,
so any bump into a wall stopped the sprint. LocalPlayer.
isHorizontalCollisionMinor compares the move the wall allowed with the
direction of xxa/zza turned by the yaw, and counts it minor when the angle
is under 0.13962634 radians (8 degrees); Player.shouldStopRunSprinting
ignores a minor collision.
2026-10-30 18:15:58 +01:00
Kacpherek bd19371acf Delay far firework sounds by their distance
Firework bursts played their blast and twinkle at once, however far they
were. ClientLevel.playSound with distanceDelay, as FireworkParticles passes
it, plays a sound beyond 10 blocks of the camera later, by sqrt(distance) /
40 seconds (SoundManager.playDelayed); drips stay immediate.
2026-10-30 18:15:58 +01:00
Kacpherek 4f3a68c450 Show the portal loading screen on a server's dimension change
A remote respawn always drew the panorama loading screen. Vanilla's
ClientPacketListener.handleRespawn picks a LevelLoadingScreen.Reason with
determineLevelLoadingReason (not after a death; the Nether or the End on
either side gives NETHER_PORTAL or END_PORTAL) and
LevelLoadingScreen.extractBackground draws the portal sprite or the End
backdrop for those instead of the blurred panorama.
2026-10-30 18:15:58 +01:00
Ludovic MarieandClaude Opus 5.5 2e97b2f5f9 horizon-prepare: OUTPUT_VERSION 3 (the creative tabs exporter's Operator Utilities tab)
#4052 changed VanillaCreativeTabsExporter, which the preparer runs, so the
prepared output changed (digest 5e50c240...) while OUTPUT_VERSION stayed 2.
Every parity run on the Mac mini's runner since fails "the prepared output
changed", and players would keep resources prepared before it. The digest is
the one the runner's own preparation of main gave, twice.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:15:32 +01:00
Ludovic MarieandClaude Opus 5.5 78c2b92c5e /claim: an issue a pull request already fixes no longer counts toward the limit
rdbtCVS, 2026-10-30: a review can take 48 hours, so counting issues whose fix is already in a PR held contributors
back. The open-claims search adds -linked:pr (issues linked to a pull request by a closing reference).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:15:16 +01:00
Ludovic Marie fcfe236b92 Merge main into chore/claim-limit-8 2026-10-30 18:14:58 +01: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
Federico TaglianiandClaude Sonnet 5.5 8373160c35 mob_ai: baby villagers play tag, checked tick for tick against vanilla
Refs #3521

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 18:12:27 +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
DUzzL ddbaa5a64c Cover the menu reset on a server transfer with a lan_e2e check 2026-10-30 18:09:40 +01:00
DUzzLandOpenAI Codex GPT-6 01e9ac5702 Reset the client menu when the server replaces the player
Co-Authored-By: OpenAI Codex GPT-6 <[email protected]>
2026-10-30 18:09:40 +01:00
Ludovic MarieandClaude Opus 5.5 f429f93420 Launcher: mod icons and a revalidating image cache (redesign R8a)
Every mod row shows the mod's own icon: Modrinth's for a mod the browser
installed (one bulk request per instance, the id-to-URL map kept in
mod-browser/project-icons.json), else the icon the jar names in its
fabric.mod.json, quilt.mod.json or mods.toml, else a glyph. Icons are
decoded off the UI thread and scaled to the row's size.

The image cache moves under the launcher's data folder, keeps each
image's ETag/Last-Modified and revalidates week-old images with a
conditional request. The Mods tab builds only its visible rows.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:08:26 +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
Federico TaglianiandClaude Sonnet 5.5 c96733f3c6 Copper golems open the doors on their path (InteractWithDoor)
CopperGolemAi's core activity has InteractWithDoor at priority 3: it opens
the doors on the path and closes the ones passed. The brain had none, so a
golem whose way between two chests ran through a closed door never got
through. The mob_ai probe now plays the sound of a door a mob opens or
closes, with the pitch drawn from the level random.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 18:06:05 +01:00
DUzzL 5459f6e232 Invisible players keep their armour, elytra and held items 2026-10-30 18:05:37 +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 Marie 52a0865aac Merge main into perf/dedicated-no-client-tables 2026-10-30 18:02:21 +01:00
Ludovic Marie 19eeab4a2a Merge main into perf/dedicated-no-item-models 2026-10-30 18:02:19 +01:00
Ludovic Marie 714f7a76da Merge main into perf/dedicated-no-face-visibility 2026-10-30 18:02:17 +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
Сидоренко Егор АлекчеевичandCodex ca27b2de97 Merge main and preserve the upstream mimalloc allocator
Resolve the allocator and lockfile conflicts against 8f376a471. Keep
main's horizon_threads allocator, startup configuration, and dependency
settings unchanged. Retain windows-mimalloc as a compatibility no-op and
keep the independent, opt-in Segment Heap resource experiment.

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-10-30 19:50:35 +03:00
MSedgeMC 1d4c9fa4db Make an allay tell potions apart
AllayAi.allayConsidersItemEqual wants a stack with the same item and no
differing potion contents, so an allay carrying Swiftness ignores a Healing
potion on the ground. Horizon compared item ids only, so any allay picked up
every potion, and it kept only the id of what it carried, losing the
components on the way back out.
2026-10-30 22:14:04 +05:30
MSedgeMC 864f314abf Apply a spawn egg's components to the mob it spawns
EntityType.create finalizes the mob, then PostSpawnProcessor puts the
stack's implicit components on it, so an egg carrying chicken/variant=warm
spawns a warm chicken rather than the biome's. Horizon had the bucket half
of that hook and nothing for eggs: the stack never reached the mob.

The bucket loader becomes the shared item-to-mob hook, with arms for the
chicken, cow and pig variant and sound variant, the mooshroom variant and
the sheep colour, and both spawn paths call it after the mob is finalized.
2026-10-30 22:13:48 +05:30
arismoko a02d0c4a1a Route server block changes through level updates 2026-10-30 11:43:09 -05:00
MSedgeMC 971e8a90f0 Growl when a piglin brute starts a fight
PiglinBruteAi voices entity.piglin_brute.angry when the brute's activity
switches into the fight and then rolls the level random every tick at
0.0125, which comes to about one growl per eighty ticks while it fights.
Horizon had the activity machinery and the sound for piglins only, gated to
Piglin and Hoglin, so a brute's activity stayed idle and it never made a
sound.
2026-10-30 22:11:52 +05:30
Federico TaglianiandClaude Sonnet 5.5 46dfd693f5 Iron golems draw the sprint particle's randoms; mob_ai village scenarios
IronGolem.canSpawnSprintParticle: a golem that moves draws nextInt(5) each
tick and, one time in five, the particle's two coordinates, even though a
server drops the particle. Horizon drew none, so a golem's random drifted
from vanilla's. iron_golem_village_0/1 and iron_golem_outside_village
(GolemRandomStrollInVillageGoal, MoveBackToVillageGoal) now match vanilla.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 17:40:44 +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 MarieandClaude Opus 5.5 5a7cfb443f lan_e2e: check that a sprinting guest's hit slows it
The guest sprints at the host, releases Ctrl (the sprint holds while W
stays down), and left-clicks the host. Vanilla's attacker side of
causeExtraKnockback cuts its horizontal speed to 0.6 and ends the sprint:
the speed drops below the walking speed and climbs back to it. On main
the guest keeps its sprint speed (0.1532) after the hit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 17:37:36 +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
Federico TaglianiandClaude Sonnet 5.5 b947048ad6 Fish schools recruit in vanilla's entity order; mob_ai school scenarios
The row said FollowFlockLeaderGoal was missing: it is ported (#3026) but had
no parity scenario. cod_school, salmon_school and tropical_fish_school
(five fish each, 400 ticks) found two differences: a leader recruits the
fish in vanilla's getEntitiesOfClass order (sections by x, z, y, then
entry order: Mob.section), which decides who is left out of a school of
at most five; and the oracle and probe draw the goal's first start delay
again once the mob's random is seeded. All three scenarios now match.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 17:27:36 +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
Federico TaglianiandClaude Sonnet 5.5 511b974107 Dolphins swim after the boat or nautilus a player steers (FollowPlayerRiddenEntityGoal)
Dolphin.registerGoals adds FollowPlayerRiddenEntityGoal for AbstractBoat and
AbstractNautilus at priority 8. The player the mob sees tells the vehicle it
steers (PlayerView.vehicle). mob_ai scenarios dolphin_boat_* (the oracle
gets a boat the script moves) match vanilla tick for tick; Mob.moveRelative
adds its zero vector like vanilla (-0.0 becomes 0.0).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 17:13:26 +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
rdbtCVSandClaude Opus 5.5 4cf3a14ba0 Launcher: keep the Resource packs tab, on Modrinth
Modrinth's resource packs work, so their tab stays, with its source switch
(Modrinth first). HORIZON_LAUNCHER_PAGE=resourcepacks opens it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 15:45:10 +00:00
Ludovic Marie c505204c24 Merge remote-tracking branch 'origin/main' into feat/launcher-night-instance 2026-10-30 16:44:27 +01:00
Neferr fbac0ae2d9 Native mods: hook points for breaking and placing a block (API 6)
native_destroy_block hears a player break a block once the game allowed it (ServerPlayerGameMode.destroyBlock) and may cancel it, which keeps the block and its drops; native_place_block hears a block placed from an item (BlockItem.place). A mod stops a placement by claiming native_use_item_on, which runs before it. Fixes #4474.
2026-10-30 16:43:43 +01:00
Ludovic MarieandClaude Opus 5.5 234d5e1cd6 CONTRIBUTING: another project's code only when its license allows it
Ludovic, 2026-10-30, after arismoko's proposal in #contributors: reading and using another project's source is now
allowed, but only under MIT, MIT-0, BSD-2/3-Clause, 0BSD, ISC, zlib, Apache-2.0, Unlicense or CC0. Everything else
(GPL, LGPL like Iris, MPL, Polyform like Sodium, custom, closed, unlicensed) stays off limits, even to read. The
contributor checks the license at the commit and per file, names the project, commit and license in the PR, keeps
the notices and adds a THIRD_PARTY_NOTICES.md entry. The PR template gets an "Other projects' code" section.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 16:43:39 +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
rdbtCVSandClaude Opus 5.5 0416fe600c Launcher: hide the Mods page's sources and tabs that aren't usable yet
The Mods and Shader packs tabs search Horizon's registry only: no Modrinth /
CurseForge switch. The Modpacks, Resource packs and Data packs tabs are hidden;
Modpacks still opens from the sidebar (its tab shows while open) with its source
switch. A Java instance's Mods tab still searches Modrinth, with its filters.

Closes #4477

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 15:35:21 +00:00
Ludovic Marie 69d6254b70 Merge remote-tracking branch 'origin/main' into perf/dedicated-no-client-tables 2026-10-30 16:32:25 +01:00
Ludovic Marie fc485bb996 Merge remote-tracking branch 'origin/main' into perf/dedicated-no-item-models 2026-10-30 16:32:25 +01:00
Ludovic Marie 9ea981a2f3 Merge remote-tracking branch 'origin/main' into perf/dedicated-no-face-visibility 2026-10-30 16:32:24 +01:00
ElyTheCat 548fca6308 Merge remote-tracking branch 'origin/main' into fix/armor-rendering
# Conflicts:
#	crates/horizon-client/src/game/armor_stands.rs
#	crates/horizon-client/src/game/entity_model_loading_tests.rs
#	crates/horizon-client/src/game/perspective.rs
2026-10-30 18:55:54 +03:30
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 MarieandClaude Opus 5.5 a78bee4642 /claim: hold up to 8 open issues, as on Kira
GitHub's /claim refused a 6th issue while Kira's Discord /claim allows 8 (raised from 3 on 10-25 at rdbtCVS's ask: a
contributor running several agents needs more). A claim counts until its issue closes, even with an open PR, so 5 left
DUzzL's agent stuck with every issue already fixed in a PR. One limit, 8, in both places and in CONTRIBUTING.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 16:16:38 +01:00
ElyTheCat 60203586ca Fix syntax error and Opt::available in options_ui.rs after merge 2026-10-30 18:43:15 +03:30
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
bloodulon 17aab6b601 Merge branch 'pr/interaction-entities' into pr/display-passengers
# Conflicts:
#	crates/horizon-client/src/game/remote.rs
2026-10-30 20:11:03 +05:00
bloodulon 8aff0594b5 rustfmt fix in remote.rs 2026-10-30 20:10:06 +05: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
bloodulon aa284acb23 Merge remote-tracking branch 'origin/main' into pr/display-passengers
# Conflicts:
#	crates/horizon-client/src/game/remote_vehicles.rs
2026-10-30 20:02:42 +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
bloodulon 1167274823 Merge remote-tracking branch 'origin/main' into pr/interaction-entities 2026-10-30 20:00:26 +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
ElyTheCat 7ba8d7ee65 Merge branch 'main' into feat/quit-shortcuts 2026-10-30 18:27:01 +03:30
levzzz 67e69ee447 Merge remote-tracking branch 'hr/main' into fix/pr-2990 2026-10-30 19:56:52 +05:00
levzzz a814e9d5a4 Merge remote-tracking branch 'hr/main' into fix/pr-3957 2026-10-30 19:55:53 +05:00
Ludovic MarieandClaude Opus 5.5 36c489208b Launcher: the instance's tabs under Home's hero (redesign R7a)
Home's lower part was one long page: the setup rows, the console, worlds, servers, packs and
screenshots one after the other. They become tabs under the hero: Overview (what Play needs),
Worlds, Multiplayer, Packs, then Screenshots and Console once they hold something. Each tab names
how many it holds; its own buttons sit at the strip's end. The "Ready to play" heading goes: the
hero's state line and the Overview tab already say it.

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 15:54:56 +01:00
levzzzandClaude Sonnet 5.5 b513f1b152 Merge hr/main into fix-3518-delete-chat
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 19:54:43 +05:00
levzzz 988049b64f Merge hr/main into fix-2885-wolfwet 2026-10-30 19:54:37 +05:00
levzzzandClaude Sonnet 5.5 a712692527 Merge hr/main into fix-2755
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 19:54:29 +05:00
levzzzandClaude Sonnet 5.5 6f9467703a Merge hr/main into fix/crossbow-firework-impact
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 19:54:15 +05:00
levzzz edc5593c76 Merge remote-tracking branch 'hr/main' into fix/pr-4199 2026-10-30 19:54:04 +05:00
Ludovic Marie 04976d95eb Merge pull request #4320 from horizon-rust/fix/light-edge-neighbours
🤖 Agent: server light-edge fix (#4294): verify, launcher and parity green on the head merged into main; playhorizon dedicated_server 5/5 pass (main 5/5 fail), no idle/join regression.
2026-10-30 15:53:21 +01:00
levzzz 65b0155b8e Merge remote-tracking branch 'hr/main' into fix/pr-2417 2026-10-30 19:52:35 +05:00
levzzz 702f03a4b9 Merge remote-tracking branch 'hr/main' into fix/pr-3951 2026-10-30 19:51:34 +05:00
DenisHumenandClaude Opus 5.5 6a066d9076 Save and restore the player's attribute bases (#4425)
LivingEntity.addAdditionalSaveData writes AttributeMap.pack as the
player's `attributes` list and readAdditionalSaveData applies it before
the health. Horizon kept the list it loaded and never wrote the live map,
so /attribute base changes (scale, max_health, ...) were lost on reopen.

The host writes the live attributes in sync_level and applies the saved
list when its first body loads; a LAN guest does the same through
playerdata::store and its first body after joining.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 16:51:25 +02:00
levzzz f83505a484 Merge remote-tracking branch 'hr/main' into fix/pr-4363 2026-10-30 19:51:07 +05:00
levzzz 9a644f3e72 Merge remote-tracking branch 'hr/main' into fix/pr-2577 2026-10-30 19:51:07 +05:00
levzzz a2d6d33d30 Merge remote-tracking branch 'hr/main' into fix/pr-4077 2026-10-30 19:51:07 +05:00
levzzz 1ab895ea01 Merge remote-tracking branch 'hr/main' into fix/pr-3550 2026-10-30 19:51:07 +05:00
levzzzandClaude Sonnet 5.5 f646dc1e0d Merge hr/main into fix-3518-delete-chat
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 19:50:58 +05:00
levzzz 3132240f21 Merge pull request #4438 from horizon-rust/fix/creatures-test-modules
Fix main's test build: duplicate tests module in creatures.rs, two stale test signatures
2026-10-30 19:50:42 +05:00
levzzzandClaude Sonnet 5.5 41d70dc6c5 Merge hr/main into fix-2755
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 19:49:23 +05:00
levzzzandClaude Sonnet 5.5 da5895b445 Merge hr/main into fix/item-damage-parity: keep only HotFloor and the PR's unique parts; heal to attrs.max_health
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 19:49:04 +05:00
levzzzandClaude Sonnet 5.5 7cee64a389 Fix main's test build: Aabb in_water stub in end_gateway
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 19:48:35 +05:00
levzzzandClaude Sonnet 5.5 1bb3647ed6 Merge hr/main into fix/split-misc-b; pass the held stack to armor_stand_spawn (fixes E0425)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 19:47:34 +05:00
levzzzandClaude Sonnet 5.5 f47cec589b Draw the mooshroom variant before the child is borrowed
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 19:46:02 +05:00
levzzzandClaude Sonnet 5.5 6286de3abd Merge hr/main into fix/split-misc-a; mooshroom offspring variant in vanilla's RNG order
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 19:45:25 +05:00
levzzz 17e5640d94 Merge remote-tracking branch 'hr/main' into fix/pr-4363 2026-10-30 19:45:23 +05:00
levzzzandClaude Sonnet 5.5 c30f519c16 Skip chunk copies only of vehicles restored from the player's file
The early skips in spawn_loaded_entities and load_mob matched any remembered UUID, so stale id to UUID entries dropped chunk minecarts after a reopen. Track the UUIDs restore_saved_vehicle brought in for this load instead.

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

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

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 19:34:44 +05:00
levzzz bf5b9b089e Merge remote-tracking branch 'hr/main' into fix/pr-3550 2026-10-30 19:34:39 +05:00
levzzz 3caf55ae8a Merge pull request #4160 from JulesB40/perf/mimalloc-allocator
Chunk generation 18% faster on many cores: mimalloc as the game's allocator
2026-10-30 19:34:30 +05:00
levzzz 96824f711d Merge pull request #4144 from Fauxvale/fix/far-watery-and-nearly-full
Far terrain: kelp is water, and powder snow and paths are kept
2026-10-30 19:34:12 +05:00
levzzz 6daaf0449a Merge pull request #4110 from DUzzL/fix/issue-4094-block-break-speed
Apply the block_break_speed attribute to mining speed
2026-10-30 19:33:54 +05:00
levzzz 8fbe40df70 Merge pull request #4103 from DUzzL/fix/issue-4091-attack-speed
Read the attack speed base from the player's attribute
2026-10-30 19:33:37 +05:00
levzzz 02dc9e2e30 Merge pull request #4084 from d3grando/fix-2783
Runtime spawns run Mob.finalizeSpawn and wither skeletons hit for 4
2026-10-30 19:33:19 +05:00
levzzz f15c9098b6 Merge pull request #4406 from posok124521/fix/trident-block-clip
Thrown tridents: clip blocks with vanilla's centre ray, not the 0.5 box
2026-10-30 19:30:52 +05:00
levzzz bdbcbf2970 Merge pull request #4353 from CocofireHD/fix/shulker-container-sync
Shulker boxes show their contents to the player who holds them
2026-10-30 19:30:32 +05:00
levzzz 7a5bbcd7c8 Merge pull request #4328 from repositorykreml1n/codex/fix-tnt-duper-3856
TNT: stop client piston replay from creating a second primed entity
2026-10-30 19:30:01 +05:00
levzzz ed0d4931b0 Merge pull request #4325 from MSedgeMC/fix/4273-brush-arm-pose
Pose a brushing arm like vanilla, in both hands
2026-10-30 19:29:40 +05:00
levzzz 8f022a0b27 Merge pull request #4297 from hhamaj/fix/lunge-keeps-air-motion
A server's copy of a player jumps with them, so Lunge keeps the jump
2026-10-30 19:29:14 +05:00
levzzz e07b94681b Merge pull request #4274 from MSedgeMC/fix/3958-jeb-sheep
Render the jeb_ sheep's wool colour, as vanilla does
2026-10-30 19:28:02 +05:00
levzzz c679ef53ea Merge pull request #4252 from CocofireHD/fix/item-name-display-name
Item names in command feedback follow the item_name component
2026-10-30 19:27:22 +05:00
levzzz 7bc7f9e783 Merge pull request #4222 from hhamaj/fix/pearl-turns-wind-charge
A pearl turns the wind charge it hits, which then speeds up
2026-10-30 19:26:39 +05:00
levzzz 19327a55b0 Merge pull request #4219 from d3grando/fix-3517-corrupt-leveldat-list
List worlds with an unreadable level.dat and offer recovery
2026-10-30 19:26:05 +05:00
levzzz df1874cced Merge pull request #4206 from DenisHumen/fix/frog-walk-anim
Frogs walk their legs at vanilla's rate
2026-10-30 19:23:57 +05:00
levzzz 726f588668 Merge pull request #4183 from Username59138/fix/command-block-one-data-path
Command blocks: one data path; the command reaches only the player who opens it
2026-10-30 19:23:16 +05:00
levzzz dbc84adbd3 Merge pull request #4082 from d3grando/fix-3072-pearl
Thrown pearls feel the water their box touches, as vanilla's isInWater
2026-10-30 19:20:37 +05:00
levzzz dcc393acec Merge pull request #4073 from d3grando/fix-2866
Swapping the same item into the off hand plays the re-equip animation
2026-10-30 19:20:16 +05:00
Михаил РоговцевandOpenAI Codex 6b5428915b Keep the swap-scale PR narrowly scoped
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-30 17:20:10 +03:00
levzzz bb6985ecb7 Merge pull request #4068 from d3grando/fix-3183
Glint reads the clock and options once per frame, not per item
2026-10-30 19:19:41 +05:00
levzzz 574d031d14 Merge pull request #4064 from oscargzss/nonconformant-adapter-retry
renderer: try a backend again with non-conformant adapters
2026-10-30 19:19:20 +05:00
levzzz 5edb5defbb Merge pull request #4052 from d3grando/fix-3393-optab
Creative inventory: the Operator Utilities tab for operators
2026-10-30 19:18:58 +05:00
levzzz 91dc405a0f Merge pull request #4048 from ult1mq/codex/lod-retirement
Fix detail requests for retiring fine LOD leaves
2026-10-30 19:18:14 +05:00
levzzz 587f81f9c6 Merge pull request #4019 from d3grando/fix-3788
Pose plugins hide the player's own body again
2026-10-30 19:17:34 +05:00
levzzz 73be91512c Merge pull request #4010 from WhyFovius/fix/fullscreen-loading-crash
Renderer: fullscreen loading crash (Validation acquire + animated sprites layout)
2026-10-30 19:17:15 +05:00
levzzz d10f82a7cb Merge pull request #3999 from d3grando/fix-2786
Light a column again when an edit darkens it beside an unlit one
2026-10-30 19:16:56 +05:00
levzzz 17dd36e93a Merge pull request #3994 from d3grando/fix-2740
Applying a resource pack no longer freezes the window
2026-10-30 19:16:37 +05:00
levzzz 81d38acadc Merge pull request #3936 from abit-foggy/fix/intel-gen11-section-flicker
fix(renderer): use flat draws on Intel Gen11 Vulkan to prevent section flicker (#3529)
2026-10-30 19:16:17 +05:00
levzzz 62ddcb7909 Merge pull request #3721 from xFedeT/fix/skeleton-path-stops-in-shade
Skeletons: a path out of the shade stops at the first node the sky sees
2026-10-30 19:15:58 +05:00
levzzz c3db4a63d5 Merge pull request #4262 from d3grando/fix-3514-pearl-gateway
Ender pearls go through End gateways
2026-10-30 19:14:35 +05:00
levzzz ffae67874b Merge pull request #4259 from d3grando/fix-3514-tame-wolf-kill-credit
Tame wolves' kills count for their owner
2026-10-30 19:14:17 +05:00
levzzz a52ce752a0 Merge pull request #4247 from d3grando/fix-3518-tag-query
F3+I copies the server's data on servers and LAN
2026-10-30 19:13:57 +05:00
levzzz 17bd76282b Merge pull request #4243 from d3grando/fix-3518-sound-slider-preview
Volume sliders play a preview sound on the title screen's options
2026-10-30 19:13:35 +05:00
levzzz 174df925ca Merge pull request #4241 from d3grando/fix-3513-enderman-carried-drop
Endermen drop the block they carry when killed
2026-10-30 19:13:18 +05:00
levzzz cb77d4cb54 Merge pull request #4239 from d3grando/fix-3518-set-camera
Spectators on a server see through the entity they spectate
2026-10-30 19:12:56 +05:00
SlaVKsVolksandOpenAI Codex GPT-6.1 Sol 320778a3d2 fix(bench): reject incomparable benchmark reports
Check the documented comparison conditions before printing tables, and report missing or conflicting settings without producing a misleading comparison.

Co-Authored-By: OpenAI Codex GPT-6.1 Sol <[email protected]>
2026-10-30 11:12:46 -03:00
levzzz cba5d53333 Merge pull request #4236 from d3grando/fix-3514-explosions-break-decorations
Explosions break paintings, cushions and armour stands
2026-10-30 19:12:38 +05:00
levzzz 50f2ca1636 Merge pull request #4229 from d3grando/fix-3518-custom-chat-completions
Chat completes the server's custom chat completions
2026-10-30 19:12:20 +05:00
levzzz 79efd6a2dc Merge pull request #4226 from d3grando/fix-3512-armadillo-breeding
Armadillos breed, follow spider eyes and their parents
2026-10-30 19:12:03 +05:00
levzzz 22115dfb11 Merge pull request #4225 from d3grando/fix-3513-cube-hurts-golem
Slimes and magma cubes hurt the iron golems they touch
2026-10-30 19:11:47 +05:00
levzzz 34abe889db Merge pull request #4224 from d3grando/fix-2920
Riders move their horses on servers, and jump them
2026-10-30 19:11:31 +05:00
Ludovic Marie 3ecec2ca7b Merge branch 'pt1-3490' into pt1-3491 2026-10-30 15:02:27 +01:00
Ludovic Marie 6b44f2ba99 Merge branch 'pt1-3489' into pt1-3490 2026-10-30 15:02:27 +01:00
Ludovic Marie f8f276748b Merge branch 'pt1-3488' into pt1-3489 2026-10-30 15:02:26 +01:00
Ludovic MarieandClaude Opus 5.5 736d22009e native_world_read: a block's bounds are its world's
main's read_block took the build range from Dim::min_y and max_y, which
this branch replaces with each level's shape: it now reads the bounds
of the world it reads from (the level's own, data packs included).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 15:01:35 +01:00
Ludovic Marie 32bb54e90c Merge remote-tracking branch 'origin/main' into pt1-3488 2026-10-30 14:58:25 +01:00
SlaVKsVolksandOpenAI Codex GPT-6.1 Sol b46c30af80 fix(port-ledger): support jar-only citation checks
Resolve declared and inherited fields from bytecode when source files are absent, while retaining source-based citation checks.

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

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

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

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

Fixes #4365

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

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

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

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

Co-Authored-By: Cursor Agent <[email protected]>
2026-10-30 16:30:01 +03:00
levzzzandClaude Opus 5.5 ea678bc6e6 Docs: proposal to model native hook points on NeoForge 26.3 events
Names, firing points, sides and cancel contracts from NeoForge's 26.3.x events (no code
copied), a catalog field for the counterpart, how the hooks we have and the PRs in flight
map, a priority list, and the capability/menu shape for mod block entities.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 13:52:19 +01:00
Ludovic Marie e4472a7423 Merge pull request #4232 from horizon-rust/perf/load-time-faces
🤖 Agent: verify and both parity jobs are green at c8ee23f2b (merged-head rerun passed at 12:35Z); approved by rdbtCVS. Queued for the merger.
2026-10-30 13:50:33 +01:00
ElyTheCatandAntigravity 95d06ac8cd perf(light): reuse worker scratch buffers and optimize flood-fill traversal
- Reuse states, sky, block, queue, and unpacking buffers via thread-local LightScratch.
- Fast-exit wavefront traversal when neighbor light is already >= level - 1.
- Hoist from_row occlusion table index and avoid Y-axis column checks in spread loop.
- Fast-path open sky ray stepping for empty air-to-air boundaries.
- Benchmark: 3267.62 µs/chunk -> 1371.04 µs/chunk (58.0% faster, 2.38x speedup).

Co-Authored-By: Antigravity <[email protected]>
2026-10-30 16:18:09 +03:30
Ludovic MarieandClaude Opus 5.5 0cbb5bae35 Launcher: the sign-in sheet in the night look (R5)
The owner gate's screen becomes a sheet: an opaque plate cut in steps over
the dimmed launcher. "Sign in to play", why (the account that owns Java
Edition, like other launchers; no Minecraft install needed, Horizon
downloads it from Mojang for that account), the code in mono on a raised
box with Copy code, Open link as the main action, Not now, and "Waiting for
Microsoft… the window goes on by itself." The link reads as typed
(microsoft.com/link). The gate itself is unchanged: when it shows, what it
asks, and what Play does without an owner.
HORIZON_LAUNCHER_PAGE=sign-in shows the sheet with a made-up code for
screenshots; nothing asks Microsoft.

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

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

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

Fixes #4312

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 13:00:05 +01:00
ElyTheCatandAntigravity 59a1f51d34 Controls: implement Quit Shortcuts toggle and window-close blocking
In vanilla Minecraft 26.3, the Quit Shortcuts option (options.quitShortcuts,
default true) controls whether keyboard shortcuts (Alt+F4 on Windows/Linux,
Cmd+Q on macOS) can close the game window. When disabled, close requests
initiated via quit shortcuts are ignored to prevent accidental quits, while
window close buttons continue to work.

Horizon already loaded and serialized quitShortcuts in options.txt and
had the cycle button wired in options_ui, but the setting was excluded from
Opt::available and the close handler never checked InputQuirks.isQuitShortcutDown.

- Add is_quit_shortcut_down in horizon-client-input::input checking
  platform modifiers and physical keys (Command+Q on macOS, Alt+F4 elsewhere).
- Check is_quit_shortcut_down on WindowEvent::CloseRequested in game.rs
  and startup_menu.rs when quit_shortcuts is disabled.
- Enable Opt::QuitShortcuts in Opt::available so the toggle is clickable in
  Controls with its vanilla tooltip.
- Add unit tests in horizon-client-input, horizon-client-render, and horizon-client.

Fixes #4336.
Part of #3518.

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

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

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

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 12:16:37 +01:00
Ludovic Marie 71571ed2cc Merge branch 'pt1-3490' into pt1-3491 2026-10-30 12:14:41 +01:00
Ludovic Marie 9d23d4e542 Merge branch 'pt1-3489' into pt1-3490 2026-10-30 12:14:40 +01:00
Ludovic Marie 2c39ef7cbf Merge branch 'pt1-3488' into pt1-3489 2026-10-30 12:14:40 +01:00
Ludovic Marie 21ecb7f80b Merge branch 'pt1-3487' into pt1-3488 2026-10-30 12:14:40 +01:00
DUzzL e2dbf8a03f Enable the input method in World Options only while a text box has focus 2026-10-30 12:11:18 +01:00
DUzzL 21bdf4f9a3 Accept committed IME text in the focused text field
Allow the window's input method only while a text box has focus and route
WindowEvent::Ime(Commit) to the same sinks as typed text. Preedit drawing and
the candidate window position are not handled yet.
2026-10-30 12:02:53 +01:00
Federico TaglianiandClaude Sonnet 5.5 5f7338178c Port map: four rows of #3515 that are already ported; an item frame keeps to the build height
OminousBottleAmplifier.addToTooltip, SuspiciousStewEffects.addToTooltip and
CrossbowItem.createProjectile (the crossbow hit sound) are in the game; a
frame may go on a vertical face (ItemFrameItem.mayPlace) and only needs the
build height, which place_item_frame now checks.

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 12:00:25 +01:00
MSedgeMC dec8429c3d Wire /place structure to the live level
PlaceCommand.placeStructure generates a start for the position's chunk with
a constant-true biome predicate, checks that every chunk of the start's box
is loaded, and runs placeInChunk over that box in the live level. Horizon
implemented /place template, /place feature and /place jigsaw, and the
structure arm validated the id and then always failed with
command.failed, which is the "unexpected error" the report shows.

The arm now generates the start through the existing structure machinery
with every biome valid (the command's own predicate, not worldgen's), reuses
place_in_chunk over the live-level feature world, and reports vanilla's
success and failure messages. LevelFeatureWorld also stores a template's
block-entity data now, so a placed chest keeps its loot table instead of
losing it.
2026-10-30 16:29:35 +05:30
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
MSedgeMC 6a03966205 Encode cooking recipes into the recipe book
AbstractCookingRecipe.display() gives smelting, blasting, smoking and
campfire one and the same FurnaceRecipeDisplay: the ingredient, any fuel,
the result, the station, the cooking time and the experience. Only the
station and the book category differ, and the category comes from the
recipe, not from the client.

book_entries encoded shaped and shapeless crafting only and returned an
empty list for everything else, so the furnace, blast furnace and smoker
books were empty for every player whose window is a client: single player
on its server thread, LAN guests and dedicated players alike.

One shared arm now writes that display, and the category switches follow
the jar: smelting food/blocks/other, blasting blocks and one misc tab,
smoking, and campfire (which no book tab lists, as in vanilla).
2026-10-30 16:26:42 +05:30
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
SylviromiandClaude Opus 5.5 d3d714e9eb Native mods: native_data, a data file of the resources, content mods' included (API 9)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 13:45:33 +03:00
DUzzL cf5290e835 Outline the slider track while dragged 2026-10-30 11:44:54 +01:00
unknownandClaude b3f866c5a6 Combat: the hurt player hears the own hurt sound again
The server thread plays every guest's MotionEvent::HurtSound into its
own silent Audio, so single player and multiplayer heard nothing (#4149);
the client's ClientboundDamageEventPacket handler never played it either,
unlike vanilla's LivingEntity.handleDamageEvent.

Vanilla splits the sound in two: the hurt player's own client plays it
from handleDamageEvent (Player.getHurtSound: the damage type's effects,
volume 1.0, a pitch that walks 1.0 ± 0.2 on the level random), while the
server's Entity.playSound reaches every other player around but that one.

The DamageEvent handler now resolves the packet's damage type through the
synchronized registry and plays hurt_sound(effects) at the player, and
tick_guests sends the server's sound to the hub for everyone but the hurt
player (Some(conn), the same split the death sound and the honey slide
already use).

Fixes #4149

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 11:37:14 +01:00
ElyTheCatandAntigravity 1bea267634 perf(resident): bypass bounds lookup for inside columns and precompute column coordinates
When a column is completely inside the view frustum (inside == true),
skip bounds lookup and intersection testing for all of its sections.
Additionally, precompute column-invariant world coordinates (ox, oz,
min_x, min_z, and xz_sq) outside the section loop.

This eliminates the diagonal view frustum culling regression, reducing
diagonal traversal time from 84.5 µs (fallback) down to 79.5 µs.

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

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

The first-person sweep is not here: PR #3951 already implements it.
2026-10-30 15:51:23 +05:30
v3nn7andCodex 7090cd2d38 Project entity shadows onto partial block bounds
Co-Authored-By: Codex <[email protected]>
2026-10-30 11:20:04 +01:00
Ludovic Marie 9a0e718599 Merge pull request #3656 from jaszkus/fix/waterlogged-bucket
🤖 Agent: Merged by Ludovic's AI agent (Claude) after its proof run: buckets place and collect water in waterlogged blocks, as vanilla does.
2026-10-30 11:17:03 +01:00
v3nn7andCodex 66cc52fd9d Keep debug overlay off the title screen
Co-Authored-By: Codex <[email protected]>
2026-10-30 11:12:38 +01:00
DUzzL adc6e8626e Update port map note for the slider track outline 2026-10-30 11:11:18 +01:00
DUzzL 3bf35d81c8 Draw the slider track outline only for focus, not hover 2026-10-30 11:06:39 +01:00
simonandClaude Opus 5.5 61066655fe Mirror the attacking client's Player.attack, not the server's, and add its harness
The no-damage guard and the knockback check read the held item, effects and
enchantments. On a client none of them count: items and effects only change
attributes on a server, ATTACK_DAMAGE and ATTACK_KNOCKBACK aren't synced, only
ServerPlayer overrides getEnchantedDamage, and getKnockback only adds
enchantments on a ServerLevel. So an empty-hand sprint hit under Weakness does
slow a vanilla client, and a Knockback sword alone doesn't.

test_remote_attack_parity runs the real Player.attack on a LocalPlayer shell
against a RemotePlayer and a zombie and compares the attack strength, velocity
and sprint flag: 51,840 cases identical on 26.3.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 19:55:21 +10:00
ElyTheCatandAntigravity 1338b93815 test(resident): add tight bounds culling benchmark
Co-Authored-By: Antigravity <[email protected]>
2026-10-30 13:25:05 +03:30
Сидоренко Егор АлекчеевичandCodex 5f72e129b2 Merge main into mesh buffer reuse
Preserve layout-relative section coordinates and main's worker idle-memory
release while retaining bounded mesh buffers and worker scratch reuse.
Extend reuse regressions to custom vertical layouts.

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-10-30 12:46:35 +03: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
Сидоренко Егор АлекчеевичandCodex 5a453aac0f Merge main into Windows allocator options
Resolve Cargo.lock against e84a47c06 while preserving the independent,
off-by-default Windows allocator features and main's native-mod cleanup.

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-10-30 12:38:47 +03:00
JulesB40andClaude Opus 5.5 4e86e200a5 Fireworks: every free rocket with explosions goes off on a block
FireworkRocketEntity.tick checks each rocket's move against blocks
(getHitResultOnMoveVector, COLLIDER shapes), not only a rocket shot at an
angle, and onHitBlock explodes it when it has explosions. A rocket launched
from the ground stayed stuck under a ceiling until its lifetime; it now
bursts there. The block hit comes from the ray rocket_struck already casts,
so an angled rocket uses it too instead of its box's collision.
lan_e2e's rocket-ceilings check covers it.

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 10:35:54 +01:00
SylviromiandClaude Opus 5.5 3df5f5d5c4 Native mods: native_block, the block at a position (API 9)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 12:34:16 +03:00
TeiwazikandClaude Opus 5.5 5455bc98f4 Resource pack screen: find packs added to the folder while it is open
PackSelectionScreen watches resourcepacks/ and lists the packs again when its
contents change. Horizon listed them once, when the screen opened: a pack
copied in after needed the screen closed and opened again. The folder's
entries (name, size, write time) are now compared once a second while the
resource pack screen is open; on a change the screen is made again over the
new list, keeping the selection made on it so far. Create World's data packs,
on the same page, are left alone.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NbhTrA2NFEcA5CdtUTS1eH
2026-10-30 10:29:11 +01:00
ElyTheCatandAntigravity 7c5de46635 Render: tight chunk section bounds for resident frustum culling
Compute the exact axis-aligned bounding box of a section's terrain mesh
vertices during meshing and use it during resident frustum culling.

Sections containing flat terrain (e.g. flat floors or water surfaces) or
geometry that does not touch the camera frustum are culled without drawing,
eliminating false positives from the fallback 16x16x16 bounding box.

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

Co-Authored-By: jaszkus's AI agent <[email protected]>
2026-10-30 10:27:41 +01:00
MSedgeMC c0f16e65e2 Hang nametags from the pose box, not the sneak flag
Vanilla anchors a name tag at the entity's NAME_TAG attachment,
(0, bbHeight, 0) of the box of the current pose, and the submitter lifts it
0.5 more. The sneak flag only decides isDiscrete (the see-through pass).

Horizon took the height from the flag for players, so a player swimming or
gliding with shift held (pose box 0.6, shared crouch bit set) got a tag at
2.0 instead of 1.1, and riding with the flag gave 2.0 against vanilla's
2.3. The height now comes from m.height(), which reads the pose box.

That branch was only needed because the local player's model never filled
extra.avatar_pose (guests and server players get it from DATA_POSE), so
tick_player_model now stores the pose it already computes. Crouching and
standing heights are unchanged at 2.0 and 2.3.
2026-10-30 14:52:26 +05:30
Ludovic Marie e84a47c069 Merge pull request #3485 from ThatCakeIsAPie/feat/level-shape-6
🤖 Agent: verified on main + this head. I merged main in (through the carried #3484); save.rs keeps main's mob imports. Checks: the 52 harnesses this change can affect, dedicated_server, server_entities, server_dimension_players, pr_ready and lan_e2e (worldspawn, light, levels, distance, portals, chest, forced-levels: chunk packets to a LAN guest in each level) pass. The changes are per chunk, not per block. Queued to merge; #3486 follows.
2026-10-30 10:21:17 +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
MSedgeMC dd0a1e4972 Keep the sheep undercoat when the sheep is sheared
Vanilla's two wool layers disagree about shearing: SheepWoolLayer returns
early on a sheared sheep, while SheepWoolUndercoatLayer has no sheared
test at all (its guard is invisibility, jeb_ or a non-white colour, and
adult). Horizon gated both pushes behind one !sheared arm, so a sheared
dyed sheep drew nothing at all.

The sheared guard now wraps only the wool push; the undercoat keeps its
existing conditions and is pushed first, as before. Unsheared output is
unchanged, and the test asserts (model, texture, colour) for the sheared
dyed, sheared white, baby and unsheared cases.
2026-10-30 14:48:48 +05:30
MSedgeMC 57eac62cd2 Lift the thrown eye of ender by the item ground transform
Vanilla draws eye of ender with ThrownItemRenderer(context, 1.0F, true),
which only scales and billboards; the visible lift comes from the item
model. item/ender_eye parents item/generated with no display override, so
its ground transform's translation [0, 2, 0] applies: 2/16 of a block,
outside the 0.5 scale, along the camera's up axis.

Horizon's hand-rolled billboard centred on the entity, so the eye sat 0.125
blocks low. The quad maths moves into eye_quad with a named GROUND_LIFT and
tests that pin both the offset and its axis (a tilted camera must move the
quad along its own up, not world Y).
2026-10-30 14:47:34 +05:30
ElyTheCatandAntigravity 18f79e7683 Merge fork/fix/armor-rendering into fix/armor-rendering
Co-Authored-By: Antigravity <[email protected]>
2026-10-30 12:32:50 +03:30
Nguyễn Văn Trãi a24fe033cd Merge branch 'main' into docs/readme-launcher-screenshots 2026-10-30 16:02:24 +07:00
Ludovic Marie ba6344a59c Merge pull request #4217 from horizon-rust/fix/keepalive-timeout
🤖 Agent: verify and parity green at c3601b74f (the merged-head parity rerun passed); approved by rdbtCVS. Queued for the merger.
2026-10-30 09:49:11 +01:00
TeiwazikandClaude Opus 5.5 525d7366eb Pack overlay: the read flag has no use on Unix
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NbhTrA2NFEcA5CdtUTS1eH
2026-10-30 09:44:43 +01:00
Ludovic Marie 483d832a31 Merge pull request #3484 from ThatCakeIsAPie/feat/level-shape-5
🤖 Agent: verified on main + this head. I merged main in (through the carried #3483). save.rs keeps main's mob imports. LevelShape::for_dir, which the carried #3481 had moved beside the type for the crate split, is gone, as this PR intends: SaveDir::new takes the level's shape. Checks: the 108 harnesses this change can affect, dedicated_server, pr_ready and lan_e2e (worldspawn, light, levels, distance, portals, chest, forced-levels) pass. Queued to merge; #3485 follows.
2026-10-30 09:43:13 +01:00
hhamajandClaude Opus 5.5 9465fb9cb7 A server's copy of a player jumps with them, so Lunge keeps the jump
handlePlayerPositionChange calls jumpFromGround when a player on the
ground moves up without being on the ground. Horizon only charged the
jump's food exhaustion, so the server's copy fell from rest, and the
velocity a jab's impulse sends the player replaced their jump with that
fall: Lunge in the air pushed them down.

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

Fixes #4278

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 11:41:22 +03:00
Sticky-Bug a2d7a9f6c5 options: enable Monochrome Logo option (darkMojangStudiosBackground)
Enable Opt::MonochromeLogo in options_ui so it can be toggled on the
Accessibility Settings screen.
In level_loading, define LoadingOverlay brand background colors
(LOGO_BACKGROUND_COLOR = 0xEF323D, LOGO_BACKGROUND_COLOR_DARK = 0x000000)
and draw_loading_overlay.
Use draw_loading_overlay in startup_menu and game reload overlay to fill
the frame with the brand background color selected by darkMojangStudiosBackground.
2026-10-30 14:05:05 +05:30
SylviromiandClaude Opus 5.5 0c9468753d Native mods: buttons, tooltips and item icons on mod screens
native_screen_click turns a press on a mod screen into a menu button id,
sent as ContainerButtonClick; native_menu_button runs it on the server for
the mod block entity behind the menu. native_screen_tooltip shows lines
under the mouse. NativeScreen gets its width, the inventory's left column
and own_labels, so a mod can lay out a window that isn't vanilla's. Helpers:
native_item (an item's icon, any size), native_cursor, native_screen_title,
native_block_property, native_insert_item (into a container, as a hopper).
API 9.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 11:26:11 +03:00
ElyTheCatandAntigravity 0887fd0c69 Merge branch 'origin/main' into fix/armor-rendering
Drop CustomHeadLayer changes now merged in main (#3605), keeping
multi-layer equipment parsing and rendering for leather overlay.

Co-Authored-By: Antigravity <[email protected]>
2026-10-30 11:52:05 +03:30
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
Ludovic Marie 50fd82435c Merge remote-tracking branch 'origin/main' into perf/shared-loot-tables 2026-10-30 09:19:17 +01:00
DUzzL ca04a6de62 Draw creative tab and trash tooltips with the item tooltip box 2026-10-30 09:12:31 +01:00
Ludovic Marie 45982faa5c Merge pull request #4245 from horizon-rust/perf/one-loot-data
🤖 Agent: all checks green including parity; approved by rdbtCVS; no comments. Queued for the merger.
2026-10-30 09:11:26 +01:00
MSedgeMC c361096ab0 Draw thrown items where vanilla puts them
Vanilla's thrown snowball, egg and ender pearl are drawn by
ThrownItemRenderer, which only scales and billboards the item: the visible
lift comes from item/generated's ground transform, translation [0, 2, 0]
of 16 units, i.e. 0.125 blocks scaled by the renderer's scale (1.0 for the
throwables, 0.75 and 3.0 for the fireballs). Horizon's hand-rolled
billboard centred the sprite on the entity, so every thrown item drew
0.125 blocks low.

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

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

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

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

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

Known gap, not introduced here: a sheared jeb_ (or dyed) sheep still loses
its undercoat. Vanilla's SheepWoolUndercoatLayer has no sheared guard
while Horizon's outer arm does, which is pre-existing and left for its own
issue.
2026-10-30 13:38:04 +05:30
Ludovic MarieandClaude Opus 5.5 d3be73f9d0 Share one loot table map between Items and the loot data of the same root and packs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 09:07:10 +01:00
DUzzL dab1861c21 Let a candle be placed on an uneaten cake (CakeBlock.useItemOn)
Fixes #4266
2026-10-30 09:06:38 +01:00
Ludovic Marie 5d80bf5b89 Merge pull request #3483 from ThatCakeIsAPie/feat/level-shape-4
🤖 Agent: verified on main + this head. I merged main in (through the carried #3482). The crate split had moved gfx.rs, gfx/resident.rs, mesher.rs, streaming.rs and world.rs to horizon-client-render, so this PR's changes to them are reapplied there. World::set keeps main's narrower remesh marking (only the sections a write can reach), now with the chunk's own min_y and section count. Checks: the 108 harnesses this change can affect, dedicated_server, pr_ready and lan_e2e (worldspawn, light, levels, distance, portals, chest, forced-levels) pass. chunk_load_bench gives the same meshes as main, and meshing time is within the machine's noise. Queued to merge; #3484 follows.
2026-10-30 09:04:16 +01:00
Ludovic MarieandClaude Opus 5.5 4d42e8b877 Dedicated server: no block entity renderer or animate tables, which only frames read
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 09:02:47 +01:00
Ludovic MarieandClaude Opus 5.5 fb8321177b Pass the world to nav_move_to_path in the callers main added
Main's piglin WalkAway goal and MoveThroughVillageGoal still called the
two-argument form.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 09:01:29 +01:00
Ludovic Marie 3bff52e242 Merge remote-tracking branch 'origin/main' into fix-3721 2026-10-30 08:59:22 +01:00
DenisHumenandClaude Opus 5.5 c7cfd91f14 Keep the mouse wheel off the hotbar while a book or lectern is open
MouseHandler.onScroll gives the wheel to the open screen and turns the
hotbar only when there is none. Books, lecterns, signs, server dialogs
and the link and pack prompts are drawn over gameplay while the screen
stays Playing, so the wheel still changed the selected slot under them.

Fixes #4253

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 09:58:55 +02:00
Ludovic MarieandClaude Opus 5.5 d12fd7341c Dedicated server: parse no item models, which only the item renderer bakes
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 08:58:54 +01:00
DUzzL 9ca7f51d05 Sneaking players descend through scaffolding on the server (#4254) 2026-10-30 08:55:14 +01:00
DenisHumenandClaude Opus 5.5 e17aabe322 A hit stands a sitting tame parrot up
Parrot.hurtServer calls setOrderedToSit(false) before
LivingEntity.hurtServer, as Wolf.hurtServer does. Horizon only did it
for wolves, and only after the damage landed, so a sitting parrot kept
sitting when hit, and a wolf hit inside its hurt frames kept sitting.
Both now stand up in accepts_damage, which runs where vanilla's override
does (after isInvulnerableTo, before the hurt frames).

Part of #3512 (row Parrot.hurtServer).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 09:52:54 +02:00
DUzzL 8e10fc9924 Lectern: close the book screen after Take Book 2026-10-30 08:50:52 +01:00
DUzzL bf103fc383 Keep GUI blend ranges aligned when clip_since rewrites vertices 2026-10-30 08:50:52 +01:00
d3grandoandClaude Opus 5.5 95d9303204 Ender pearls go through End gateways
A thrown ender pearl flew straight through an End gateway: the gateway
passes in game/end.rs only collected dropped items and mobs. Pearls now
join them (EndGatewayBlock.entityInside, Entity.handlePortal): the pearl
lands at the exit with no motion and rotation 0
(EndGatewayBlock.getPortalDestination for a ThrownEnderpearl), waits in
the arrivals queue while the exit loads, keeps its landing chunk loaded
for TicketType.PORTAL's 300 ticks (placePortalTicket), and then takes
its owner where it lands. ThrownEnderpearl.onInsideBlock fires the
owner's enter_block trigger (Remote Getaway), for a guest as them.

Part of #3514.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 14:44:47 +07:00
Ludovic MarieandClaude Opus 5.5 fb2fb00ec4 MeshTables: no occlusion faces from a server's empty visibility
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 08:42:32 +01:00
DUzzL f69bf3deb5 Clip GUI item triangles at the scissor instead of dropping them by centre
Advancement icons at the top and bottom of the tree lost one of their two
triangles when the scissor cut through them. HudBatch::clip_since now clips
non-rectangular geometry as polygons, interpolating UV, glint UV and depth.

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 14:38:42 +07:00
Ludovic MarieandClaude Opus 5.5 c24f9a1693 Dedicated server: no render face visibility, which only the mesher and block entity renderer read
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 08:35:22 +01:00
DenisHumenandClaude Opus 5.5 393d90d870 Return to Sender: a fireball struck back credits the player's kill
The large and small fireball hits on a mob didn't record the player who
owned the fireball (LivingEntity.hurtServer's setLastHurtByPlayer), as an
arrow's hit does. On the default single player the host plays as a guest
on its server thread, so a ghast killed by its own fireball, turned back,
was credited to nobody: no Return to Sender, Monster Hunter or kill
statistic.

tools/test_return_to_sender_parity.py strikes a still fireball into a
ghast with no AI on both single-player paths and expects the advancement.

Fixes #4212

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

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

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

Fixes #3514

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 14:31:26 +07:00
TeiwazikandClaude Opus 5.5 b91fb97b1c Pack overlay: stamp vanilla's files by their metadata on Windows
Without a change timestamp, Windows checked every file of a cached overlay by
its bytes, vanilla's included: tens of thousands of opens, each scanned by the
antivirus, took minutes on every resource reload. Vanilla's files now keep to
their size and write and creation times, as on Unix; the packs' own files are
still read. The marker version moves to 6.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NbhTrA2NFEcA5CdtUTS1eH
2026-10-30 08:31:09 +01:00
Ludovic Marie bb64067197 Merge pull request #4200 from horizon-rust/fix/suggestions-one-per-tick
🤖 Agent: parity rerun green (the earlier dedicated_server light-layers failure was a load flake); approved by rdbtCVS; no blocking comments. Queued for the merger.
2026-10-30 08:29:23 +01:00
SylviromiandClaude Opus 5.5 7255ecd851 Native mods: mod screens are API 8
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 10:27:06 +03:00
Sylviromi a1e033003a Merge branch 'native-mod-neighbours' into native-mod-screens 2026-10-30 10:27:06 +03:00
SylviromiandClaude Opus 5.5 a3aa60d7bc Native mods: neighbour helpers are API 7
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 10:27:05 +03:00
SylviromiandClaude Opus 5.5 0e573b8e25 Merge native-mod-inventories; menus are API 6
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 10:26:57 +03:00
Sylviromi b4181cf43a Merge branch 'native-mod-menus' into native-mod-neighbours 2026-10-30 10:26:57 +03:00
SylviromiandClaude Opus 5.5 14d7f036c3 Native mods: item slots are API 5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 10:26:49 +03:00
SylviromiandClaude Opus 5.5 c89bf6241d Native mods: a mod screen takes data only for its own window
ContainerSetData for another window (an old or replaced one) no longer
reaches the open mod screen.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 10:26:36 +03:00
Ludovic Marie cfab69e790 Merge pull request #4233 from horizon-rust/fix/claim-agent-prefix
🤖 Agent: /claim accepts agents' '🤖 Agent:' prefix; 480 Python tests pass.
2026-10-30 08:25:35 +01:00
DenisHumenandClaude Opus 5.5 0271c62825 Falling blocks keep their random model offset (pointed dripstone)
FallingBlockRenderer draws the block through
ModelBlockRenderer.tesselateBlock, which moves it by
BlockState.getOffset(BlockPos.containing(x, maxY, z)). Horizon drew
falling blocks centred, so a pointed dripstone (OffsetType.XZ) jumped
sideways when it broke off and fell.

Fixes #4102

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 09:25:16 +02:00
Sylviromi 0bebf89220 Merge branch 'native-mod-neighbours' into native-mod-screens 2026-10-30 10:24:48 +03:00
SylviromiandClaude Opus 5.5 3c2a49c4ef Native mods: neighbour changes notify after the change; properties update neighbours
native_with_mod_block_entity runs the mod's change first, then marks the
chunk changed and, when items moved, lets comparators read the container
again (native_mod_block_entity_at notified before). native_set_block_property
goes through Level.setBlock with UPDATE_ALL, so neighbours and observers hear
of it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 10:24:44 +03:00
Ludovic MarieandClaude Opus 5.5 2c526aa767 Load the loot data once when a world opens with the packs world generation used
World generation loads the loot data with no packs while the world is
prepared; opening the world then forgot it and loaded a second copy, which
the generator's copy kept alive beside it (8 MB for the whole run). Now
opening a world keeps the loaded copy when its root and packs are the same.

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

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

Part of #3518.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 14:22:23 +07:00
Sylviromi 1eeda33ae5 Merge branch 'native-mod-menus' into native-mod-neighbours 2026-10-30 10:21:47 +03:00
SylviromiandClaude Opus 5.5 aba4ee3d56 Native mods: a mod menu shows only its container's slots
A menu reads, writes and sends only the declared slot count; items saved
past it (a container that shrank) stay saved and untouched.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 10:21:46 +03:00
DUzzL 803bff885b lan_e2e: level_respawn_points leaves no Overworld spawnpoint (#4234) 2026-10-30 08:20:27 +01:00
Sylviromi 51b39ee118 Merge branch 'native-mod-inventories' into native-mod-menus
# Conflicts:
#	crates/horizon-client-render/src/chunk/block_entity.rs
#	crates/horizon-client-render/src/containers.rs
#	crates/horizon-client/src/game/native_hooks.rs
2026-10-30 10:19:41 +03:00
Ludovic MarieandClaude Opus 5.5 3a11501e23 Dedicated server: bake no block models, which only the renderer draws
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 08:19:39 +01:00
d3grandoandClaude Opus 5.5 3c6999d700 Spectators on a server see through the entity they spectate
ClientboundSetCameraPacket (/spectate, a spectator's click) was decoded as
an unknown packet, so the view stayed on the player while the server kept
that player on the camera entity: the view did not turn with the mob, sat
at the player's eye height inside the mob's body, and the client's own
moves made the server rubber-band it ("moved too quickly").

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 14:19:25 +07:00
SylviromiandClaude Opus 5.5 4459fe3989 Native mods: a mod's own Items stays its own
Items become slots only when they are an item list saved exactly so; any
other Items stays in the mod's compound. A block without a container gets
its Items back in its compound before its tick, and breaking it drops
nothing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 10:19:08 +03:00
Ludovic Marie d129fb7cfe Merge pull request #3482 from ThatCakeIsAPie/feat/level-shape-3
🤖 Agent: verified on main + this head. I merged main in (through the carried #3481); it merged cleanly. Checks: the 108 harnesses this change can affect, dedicated_server, pr_ready and lan_e2e (light, levels, distance, portals, chest, forced-levels) pass. worldspawn fails only when it runs after levels, on main from before this stack as well: the harness gives the guest its own spawnpoint (#4234), so that isn't this PR. The light flood fill reads the level's column instead of constants: chunk_load_bench main vs this branch gives the same best time for generating and lighting (28.95 ms per column) and the same meshes. Queued to merge; #3483 follows.
2026-10-30 08:19:07 +01:00
Ludovic MarieandClaude Opus 5.5 92e956be80 /claim: accept the "🤖 Agent:" prefix agents put on every comment
Agents start each comment with "🤖 Agent:" (CLAUDE.md), so their
"🤖 Agent: /claim" was never seen: the workflow only started on comments
beginning with /claim, and parse() read the first line as is. rdbt
noticed agents' claims missing (2026-10-30).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 08:16:24 +01:00
Sylviromi 027aef4b3b Merge branch 'native-mod-block-entities' into native-mod-inventories
# Conflicts:
#	crates/horizon-client-render/src/containers.rs
#	crates/horizon-client/src/game/native_hooks.rs
2026-10-30 10:15:56 +03:00
Kacpherek 64b7e511b1 Play the minecart steps a server sends along the track
Horizon ignored ClientboundMoveMinecartPacket, which a server sends in place of move packets for minecarts on NewMinecartBehavior (the minecart_improvements experiment), so such a cart and a rider in it stayed where they were added until a teleport. Decode the packet's steps (MinecartStep: position, movement, yRot, xRot, weight) and play them as NewMinecartBehavior.lerpClientPositionAndRotation does: queued steps become the current ones over 3 ticks by their weights (getCurrentLerpStep), and the cart takes the position, movement and yaw they give at the end of each tick. A teleport puts such a cart there at once (its interpolation handler is NO_OP). The cart's pitch still comes from its rails.

Fixes #3796
2026-10-30 08:15:21 +01:00
SylviromiandClaude Opus 5.5 2de673c4f8 Merge main; block entity hooks are API 4 (#3706 took 3)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 10:15:13 +03:00
Kacpherek d0c5f220bc Keep the destination of a portal trip running with a portal ticket
Horizon only lit the exit chunks long enough to land an entity that went through a portal, then nothing in that dimension ticked while no player was in it, so an item or mob stayed frozen there and never came back through. Vanilla places TicketType.PORTAL (300 ticks, radius 3, level 30 of both kinds, keeps the dimension active) on the exit chunk when the entity arrives (Entity.placePortalTicket via TeleportTransition.PLACE_PORTAL_TICKET and NetherPortalBlock.getExitPortal, EndPortalBlock.getPortalDestination). Add the ticket to the level's chunk tickets, tick the dimension while one is held and stream its area.

Fixes #3292
2026-10-30 08:11:41 +01:00
d3grandoandClaude Opus 5.5 956bd64c5d Death loot parity: endermen carrying each holdable block
VanillaEntityDeathLootOracle sets the carried block, and the corpus kills
endermen carrying every enderman_holdable block, by a player with
Looting and by generic damage.

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 12:09:41 +05:00
DenisHumenandClaude Opus 5.5 dc3d702e01 Creative armour swap gives the worn piece back to the hand
Equippable.swapWithEquipmentSlot: in creative a single item swaps with the
worn piece too (the hand gets copyAndClear of the worn stack, the slot a
copy), and from a larger stack the worn piece goes to the inventory. The
game kept the new piece in the hand and deleted the worn one, so the held
item never changed and the hand never played its swap animation.

Fixes #4150

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

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

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

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

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

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

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

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

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

Part of #3512

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 14:03:02 +07:00
bloodulonandQoder 912a9febc7 Seat displays and hitboxes on whatever carries them
ModelEngine mounts a model's item displays (its bones) and interaction
hitboxes (its click boxes) on a pivot, usually an area effect cloud, and
moves only the pivot. #3776 seats text displays on mobs and the local
player through the passenger attachment; an item display riding a pivot
display or an area effect cloud, and a hitbox riding a pivot, had no
seat at all and stayed where the server first spawned them.

Every display and interaction box now asks its vehicle where to sit:
mobs through their passenger attachment, boats and minecarts from their
seat, armor stands from their box, another display or interaction from
its position, and any other tracked entity (the area effect cloud) from
its feet. Riders of riders settle over three passes, as mobs already did.

Co-Authored-By: Qoder <[email protected]>
2026-10-30 12:02:24 +05:00
levzzz b1dfde280b Merge pull request #3948 from kacpherek/fix/split-spear-ai-golem
Mobs use spears; iron golems keep to their village
2026-10-30 12:00:15 +05:00
d3grandoandClaude Opus 5.5 84ac601f3a Slimes and magma cubes hurt the iron golems they touch
AbstractCubeMob.push(entity) calls dealDamage on an IronGolem whose
pushEntities takes the cube, but Horizon only ported the playerTouch path,
so cubes chased golems (they target them) without ever hurting them.

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

Part of #3513.

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 13:59:23 +07:00
bloodulonandQoder 558a9a13e9 Draw the click boxes of server interaction entities
Servers (Denizen, Nexo, ModelEngine) spawn minecraft:interaction entities:
invisible boxes with a width, height and response flag that a script
answers. The client knew nothing of them, so clicks aimed at the box hit
the block behind or nothing, and the map's NPC models could not be
clicked.

A spawned interaction becomes a RemoteInteraction (SetEntityData ids 8,
9 and 10), pickable for use and attack from its feet up its height, and
its teleports replace its position at once (Entity's no-op
InterpolationHandler). The click ends at the box: SUCCESS when it
responds, and the arm swings; CONSUME otherwise. Either way the Interact
packet carries the hand, nothing behind the box is used, and pick-empty
leaves no entity to pick.

Co-Authored-By: Qoder <[email protected]>
2026-10-30 11:55:42 +05:00
levzzzandClaude Opus 5.5 1bd934d3fb Merge main into fix/split-spear-ai-golem
lan.rs: keep both the spear hit event (2) and main's fox eating event (45).

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

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

Fixes #3299

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 09:51:13 +03:00
levzzzandClaude Opus 5.5 03498ad1c7 Merge main into fix/split-spear-ai-golem
piglin.rs: keep main's piglin/hoglin brain memories and add this branch's spear memories to them; the fight activity keeps the spear behaviours before the melee one and main's NotRiding repellent goal; piglin_pose_holding takes both main's passenger flag and the spear.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 11:50:00 +05:00
levzzz fbd7288a1d Merge pull request #3949 from kacpherek/fix/split-mob-sounds-behaviour
Mob sounds and behaviour: fox, zoglin, turtle, iron golem, sheep, cat, nautilus, pig and more as vanilla
2026-10-30 11:48:46 +05:00
levzzz 6966a69d36 Merge pull request #3946 from kacpherek/fix/split-agelock-goat-allay
Goats long jump between ledges, golden dandelions lock a baby's growth, allays remember a note block
2026-10-30 11:48:36 +05:00
Ludovic Marie e4fb44e067 Merge pull request #4185 from horizon-rust/fix/guest-inbox-cap
🤖 Agent: all checks green on 5a017c900 (inbox capped in packets and bytes; A/B in the description). Queued.
2026-10-30 07:45:06 +01:00
levzzzandClaude Opus 5.5 0c441c9009 Merge main into fix/split-mob-sounds-behaviour
ai.rs: main's piglin_ambient/hoglin_ambient now pick the angry and retreat sounds from the brain's activity; keep those and add this branch's zoglin angry sound.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 11:41:48 +05:00
Ludovic Marie c48d6773c5 Merge pull request #3481 from ThatCakeIsAPie/feat/level-shape-2
🤖 Agent: verified on main + this head. I merged main into the branch: the crate split had moved chunk.rs to horizon-client-render, so the LevelShape helpers this PR uses from save.rs are pub now and LevelShape::for_dir sits beside the type (an impl in horizon-client on a type of horizon-client-render no longer compiles). Checks: the 108 harnesses this change can affect, dedicated_server, pr_ready and lan_e2e (light, levels, distance, worldspawn, portals, chest, forced-levels) pass. chunk_load_bench main vs this branch: same meshes (checksum), mesh time unchanged (42.0–44.2 vs 42.7–43.2 µs per section). Queued to merge; the rest of the level-shape stack follows in order.
2026-10-30 07:41:33 +01:00
DenisHumenandClaude Opus 5.5 e3e2404a1f horizon-server: install the Windows logon task without admin rights
`service install` (the launcher's "Run in the background and at login"
switch) ran `schtasks /Create /SC ONLOGON`. That trigger fires at every
user's logon, and Windows lets only an administrator create it, so the
switch failed with "Access is denied" for a normal user.

Create the task from XML instead, with the logon trigger and the
principal naming the current user (LeastPrivilege, interactive token),
which needs no elevation. The task also drops schtasks' defaults that
would stop a server: the 72-hour run limit and not starting on battery,
and Task Scheduler restarts the supervisor a minute after it fails.

Fixes #3733

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 08:41:08 +02:00
levzzz 2160cd016a Merge pull request #3791 from xFedeT/fix/flyers-scan
Ghast: target position, move control timing, look angle and shot sounds as vanilla
2026-10-30 11:39:28 +05:00
levzzz a99ef86dc6 Merge pull request #3942 from kacpherek/fix/split-piglin-hoglin
Piglins and hoglins retreat, retaliate, grunt and ride like vanilla
2026-10-30 11:39:12 +05:00
d3grandoandClaude Opus 5.5 699fae6296 Riders move their horses on servers, and jump them
On a vanilla or Paper server the controlling passenger's client moves a
saddled horse, donkey, mule or camel (Entity.isLocalInstanceAuthoritative,
LivingEntity.travelRidden), reports it with ServerboundMoveVehiclePacket and
sends START_RIDING_JUMP on a released jump (LocalPlayer.sendRidingJump). The
server only turns it (travelRidden without canSimulateMovement). Horizon's
client only charged the jump bar and waited for the server to move the
horse, so on those servers the horse never moved nor jumped (#2920).

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

Fixes #2920

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 13:37:32 +07:00
DenisHumenandClaude Opus 5.5 6f42cbb13f Lower the shield when a server stops the player's use
On a server, the client ignored DATA_LIVING_ENTITY_FLAGS for its own
player. When the server stopped the use (an axe hit disabling the raised
shield: Player.disableShield → stopUsingItem), the shield stayed up on
screen while hits landed. LocalPlayer.onSyncedDataUpdated stops a running
use when the flag clears and starts one in the flagged hand when it sets.

Fixes #4115

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

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

Part of #3517

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

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

Fixes #3518

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 13:30:38 +07:00
Ludovic MarieandClaude Opus 5.5 c3601b74f3 Server: the game thread is the keep-alive timeout's only judge; kick, then close a second later
The writer thread's own check raced it and logged "Disconnected" instead of
vanilla's "Timed out".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 07:30:07 +01: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
GIGABAIT93andClaude Opus 5.5 6ffe36494e port_publish: read gh's and Kira's output as UTF-8
gh() and kira() ran subprocess with text=True and no encoding, so on
Windows Python decoded their UTF-8 output with the code page: an issue
body's → came back as →, and merged() wrote it back on the next update.
Follows d3grando's review on #3587.

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 13:22:55 +07:00
Ludovic Marie 68f036059e Merge pull request #4181 from horizon-rust/tools/bots-abusive-client
🤖 Agent: all checks green on 6edf1ff46 (horizon-bots only, skip-release). Queued.
2026-10-30 07:22:09 +01:00
Ludovic MarieandClaude Opus 5.5 0b73217d3d Merge main (through the carried #3485) into level-shape-7
The crate split moved chunk.rs, lod.rs and minimap.rs to
horizon-client-render: this commit's changes are reapplied there.

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 13:20:25 +07:00
Ludovic MarieandClaude Opus 5.5 99998b9f3c Merge main (through the carried #3484) into level-shape-6
Keeps main's mobs imports in save.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 07:20:00 +01:00
Ludovic MarieandClaude Opus 5.5 c2db12a1a0 Merge main (through the carried #3483) into level-shape-5
Keeps main's mobs imports in save.rs. LevelShape::for_dir, which the
carried #3481 moved to chunk.rs for the crate split, goes as this
commit removes it: SaveDir::new takes the level's shape instead.

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

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

Part of #3518 (ClientPacketListener.handleChunksBiomes)

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

Fixes #2848

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

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

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

Part of #3513.

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

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

Co-Authored-By: Codex <[email protected]>
2026-10-30 09:17:05 +03:00
Ludovic MarieandClaude Opus 5.5 0c149c7d1c Merge main (through the carried #3482) into level-shape-4
The crate split moved gfx.rs, gfx/resident.rs, mesher.rs, streaming.rs and
world.rs to horizon-client-render: this commit's changes are reapplied there.
World::set keeps main's narrower remesh marking (only the sections a write
can reach) with the chunk's own min_y and section count.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 07:15:27 +01:00
d3grandoandClaude Opus 5.5 3e536cb541 Merge origin/main into fix-3061-pad
game/pad.rs: main dropped the unused mouse::Rotation import; this branch's keymapping::SNEAK import stays.

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

Refs horizon-rust/horizon#3856.

Co-Authored-By: Codex <[email protected]>
2026-10-30 09:14:38 +03:00
arismoko 05275d7682 Expose native server block change observations 2026-10-30 01:13:03 -05:00
arismoko 907b5729b1 Expose native neighbour-change observations 2026-10-30 01:13:03 -05:00
arismoko c2670e7bd2 Expose read-only native server block queries 2026-10-30 01:13:03 -05:00
arismoko 2a6ae514f8 Expose native server tick hooks 2026-10-30 01:13:03 -05:00
Ludovic MarieandClaude Opus 5.5 5fb90b440c Server: time out a client that stops answering keep-alives, even while its writer is blocked
The writer thread sent and checked the keep-alives; a client that stopped
reading blocked it in a write, so it was never timed out and held its slot.
The game thread now checks the keep-alive clock once a second and closes the
socket, which ends the blocked write.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 07:11:58 +01:00
d3grandoandClaude Opus 5.5 e445e0edf6 Spectating players are tracked only by other spectators
ChunkMap.TrackedEntity.updatePlayer pairs an entity with a player only
when Entity.broadcastToPlayer allows it, and ServerPlayer's override hides
a spectator from every player who is not a spectator (a spectating viewer
sees a player whose camera is its own, which a LAN guest's always is).
The hub's tracker had no such check, so a guest in spectator mode stayed
paired with everyone: its AddEntity, equipment and nametag still reached
the other players, and their crosshair still picked it.

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

Part of #3520

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

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

Part of #3520 (Connection#exceptionCaught).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 13:08:10 +07:00
Ludovic Marie bd9b325872 Merge pull request #3674 from d3grando/fix-3129
🤖 Agent: Ludovic's AI agent (Claude), proof. With your check fix (one loaf each, bites read once they settle), `lan_e2e` `eating` and `eat` pass 2 runs out of 2 on the server thread and 2 out of 2 `--in-frame`. That was in a union with #3706 and #3551 merged into main at a6aa6a071: 111 of 112 affected harnesses passed, none failed, and clippy and the tests passed. Queued.
2026-10-30 07:07:53 +01:00
Ludovic Marie b51637616b Merge pull request #3706 from arismoko/upstream/native-lifecycle
🤖 Agent: Ludovic's AI agent (Claude), proof. Thanks for taking up the review points (the context now carries only authority and epoch, and the crash paths are documented). This head was in a union with #3674 and #3551 merged into main at a6aa6a071. `pr_ready`: 111 of 112 affected harnesses passed, none failed; the 112th is the LAN suite, which runs by name only. clippy passed, and 1541 tests passed. `lan_e2e` (chest, eat, eating) passes twice on the server thread and twice `--in-frame`. Your container proof covers the dedicated runs and identical saves. Queued.
2026-10-30 07:04:11 +01:00
Ludovic MarieandClaude Opus 5.5 5a017c9006 lan_bot: queue test packets with their size
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 07:02:33 +01:00
Kacpherek c1c7cdb3c1 Clear a passenger's ground state every tick
Player.tick calls setOnGround(false) for any passenger, so the look packets a rider sends (ServerboundMovePlayerPacket.Rot) never carry onGround. Our passenger tick skipped the player's own motion step, so the ground flag from before boarding stayed set and every packet claimed the rider stood on something while the boat bobbed, which Grim flags as NoFall and Simulation.

Fixes #3793
2026-10-30 07:00:49 +01:00
Ludovic Marie c18e893143 Merge branch 'pt1-3481' into pt1-3482 2026-10-30 06:46:22 +01:00
Kacpherek 8880df58c1 End a glide when a player boards a boat
A player who boarded a boat while gliding kept the FallFlying pose: passenger_pose_tick cleared swimming but not fall_flying, so update_player_pose still picked FallFlying and fall_fly_ticks kept counting. Vanilla LivingEntity.updateFallFlying (in aiStep, run by rideTick's tick) clears the glide flag once canGlide fails for a passenger (canGlide needs !isPassenger()), and LivingEntity.tick resets fallFlyTicks when not gliding (LivingEntity.java).

Fixes #3093
2026-10-30 06:39:28 +01: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 85b5ff7294 Scale the view-blocking probe height with the player's scale
The block overlay (the dark stretched texture over the screen) probes eight points around the eye, 0.1 apart in height. Vanilla multiplies that by LivingEntity.getScale (LevelExtractor.getViewBlockingStateAndPos). Horizon used a fixed 0.1, so a player of scale 0.01, whose eye is 0.0162 above the feet, probed the ground block and got its overlay over the whole screen.

Part of #3809
2026-10-30 06:34:55 +01:00
Ludovic MarieandClaude Opus 5.5 b9f8ea2f27 Server: answer one command suggestion request a tick, the latest (ServerCommandSuggestionsProvider)
A client asking 100 times a tick took the server to 16 TPS: every request was parsed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 06:33:55 +01:00
Ludovic MarieandClaude Opus 5.5 536c3b90fd LevelShape helpers are pub, and for_dir sits beside the type
Since the crate split, chunk.rs is in horizon-client-render while save.rs
(horizon-client) reads min_section, section_count, has_section and
for_dir, so they are pub and for_dir moves out of save.rs's impl block.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 06:33:02 +01:00
DUzzL 6b0d88bf88 Send a cleared end crystal beam to LAN guests (#4190)
A crystal whose beam target went back to none was left out of the entity state with no listed default, so guests kept the respawn beam.
2026-10-30 06:32:09 +01: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
Ludovic MarieandClaude Opus 5.5 4abef77bba Server: cap a player's waiting packets in bytes too (16 MB)
400 full books a tick passed the packet count while holding hundreds of MB.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 06:31:48 +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
Ludovic Marie bc3383d1bf Merge remote-tracking branch 'origin/main' into pt1-3481 2026-10-30 06:31:05 +01:00
Ludovic Marie b76ae35f5b Merge pull request #3480 from ThatCakeIsAPie/feat/level-shape-1
🤖 Agent: pr-triage-1 (Ludovic's AI agent, Claude), not Ludovic himself. I merged main into this branch (eb3f03d7f, a merge commit, fast-forward). The only conflict was `lib.rs`'s re-export list: `section_mask` now sits beside main's `potent_sulfur` and `split_layout`. The full proof the orchestrator asked for, on this head merged into today's main: - the 121 vanilla harnesses this branch can reach pass, and `dedicated_server` too; - pr_ready passes; - lan_e2e passes on the server-thread host: light, levels, distance, portals, chest, forced-levels and worldspawn. In-frame is deprecated now. worldspawn failed once on timing, then passed 2/2. Speed: the masks only grow from 4 to 32 bytes, in per-section bookkeeping (dirty sections, the light log, LOD detail columns). There is no allocation and no per-block work, so frame and tick costs stay the same. Queued for merge. #3481 comes next, once this one is in.
2026-10-30 06:30:54 +01:00
Kacpherek f83be65d79 Send creative inventory edits with the click, not at tick end
A creative click on a server changed the local slot but sent it at the end of the tick, while a thrown stack went out at once. A slot update the server sent in between (the pickup of an item a held Q had thrown) replaced the unsent change and was marked as synced, so the throw was never taken off the stack and the count climbed. CreativeModeInventoryScreen.slotClicked ends in broadcastChanges (CreativeInventoryListener.slotChanged -> handleCreativeModeItemAdd), so the changed slots follow the click immediately; Horizon now sends them right behind it.

Fixes #3090
2026-10-30 06:30:25 +01:00
Kacpherek 1a4ee0e7ee Release the probe lock when a world is checked for being open elsewhere
Checking a world on the title screen takes the session lock for an instant; hand it back explicitly, as SessionLock does, so a quit world is never listed as locked. Drop the stale note that powder snow climbing is not ported for rabbits.
2026-10-30 06:30:14 +01:00
Kacpherek 1e5aaec352 Skip the note block head sound on a client level 2026-10-30 06:29:39 +01:00
Kacpherek 360582703d Merge origin/main into fix/split-rendering 2026-10-30 06:29:18 +01:00
Kacpherek cb0be4adbe Merge remote-tracking branch 'origin/main' into fix/split-misc-b
# Conflicts:
#	crates/horizon-client/src/game/remote_mob_data.rs
#	crates/horizon-client/src/game/remote_projectiles.rs
2026-10-30 06:28:40 +01:00
Kacpherek 43777b9d16 Stub the redstone ore dust in the remote mining fixture 2026-10-30 06:28:23 +01:00
Kacpherek ef917292ed Merge remote-tracking branch 'origin/main' into fix/split-blocks
# Conflicts:
#	crates/horizon-client/src/game/remote.rs
2026-10-30 06:28:07 +01:00
Ludovic Marie c6bf75b3b1 Merge pull request #3740 from UMCEKO/mods/oracle-item-components
🤖 Agent: Ludovic's AI agent (Claude), proof. The real NeoForge pack test above covers this PR's exporter. On a 3-mod pack it wasn't developed against, the exported item facts matched 52 of 52 values read with vanilla's own commands on the same server, without the exporter. CI verify is green. Queued, first in the stack's order.
2026-10-30 06:21:42 +01:00
Ludovic Marie 08e7c00806 Merge remote-tracking branch 'origin/main' into upd-3721
# Conflicts:
#	tools/test_mob_ai_parity.py
2026-10-30 06:14:43 +01:00
Ludovic Marie a6aa6a071a Merge pull request #4142 from horizon-rust/perf/idle-generator-scratch
🤖 Agent: verified by server-tick-1. A chunk worker idle for 5 s frees its generator's density scratch and lookup caches (caches only, results unchanged), and the last idle worker returns freed memory to the OS once; −71 MB idle RSS on Linux. pr_ready green.
2026-10-30 06:10:05 +01:00
levzzzandClaude Opus 5.5 c9a87963ef Merge main into fix/split-agelock-goat-allay
frog.rs: keep the goat jump cooldown and main's vanilla memory id long_jump_cooling_down.
remote_mob_data.rs: keep the tadpole AGE_LOCKED line beside main's mooshroom comment.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 10:07:25 +05:00
GIGABAIT93andClaude Opus 5.5 531cb06614 Riders sit in their scale, and on text displays
An NPC plugin enlarges its fake players (minecraft:scale) and seats the
sitting ones on an invisible text display. Three gaps showed together:

- a vehicle's passenger attachment ignored its scale, so a name riding a
  1.5x NPC sat at 1.8 blocks, in its face (getDimensions scales the
  attachments with the box);
- players and mannequins had no vehicle attachment on a mob or a display
  (Avatar.DEFAULT_VEHICLE_ATTACHMENT, 0.6, scaled); only boats had it;
- a mob riding a text display kept the server's position for it instead
  of its seat (rideTick), so the plugin's teleport left it in the air.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 07:04:50 +02:00
Ludovic MarieandClaude Opus 5.5 6edf1ff467 horizon-bots: costly floods (use, click, suggest, book, sign, recipe) and --no-read
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 05:58:24 +01:00
levzzz 33f417fb32 Merge pull request #4051 from d3grando/fix-3199
A camel and the husk on its back no longer push each other
2026-10-30 09:55:26 +05:00
Ludovic Marie ee883db4b9 Merge pull request #4117 from horizon-rust/perf/shared-advancement-tree
🤖 Agent: verified by server-tick-1, after merging main. ServerAdvancements and the game's criteria share one advancement tree (Tree::shared, Weak-held) when the world's packs bring no advancement files; with pack advancements each loads its own, as main does. −9 MB idle RSS on Linux. pr_ready green.
2026-10-30 05:55:06 +01:00
My NameandClaude Opus 5.5 7e6da998fa README: the creative toolbar keys are wired
The known issues still said C/X aren't wired to the hotbar loader; they are
since 85f34393f (game/creative_hotbar.rs), and #3693 added the Saved Hotbars tab.

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 07:44:42 +03:00
levzzzandClaude Opus 5.5 bb0bf9e1c8 Pass PackOverlay::NONE to Items::load in the same-vehicle push test
Main added a PackOverlay argument to Items::load after this branch was written.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 09:44:29 +05:00
levzzz a6a734da85 Merge commit 'refs/pull/4051/head' of https://github.com/horizon-rust/horizon into HEAD 2026-10-30 09:39:36 +05:00
levzzzandClaude Opus 5.5 3b82d4770f Merge main into fix/own-head-pitch-follows-mouse
Keeps both look_at_rotation (main) and position_packet_pitch (this branch) in remote.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 09:39:17 +05:00
Ludovic MarieandClaude Opus 5.5 fa50f136fe Server: disconnect a player whose packets pile up past a second of its budget
The per-player inbox had no limit: a client sending 10 000 packets a tick
grew the server by about 1 GB a minute (#4179).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 05:38:59 +01:00
Ludovic MarieandClaude Opus 5.5 eb3f03d7f3 Merge main into this branch (split_layout and potent_sulfur re-exports)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 05:38:07 +01:00
Ludovic Marie 061751a023 Merge pull request #4095 from horizon-rust/carry/fusseltv-idle-spawn-area
🤖 Agent: pr-triage-1 (Ludovic's AI agent, Claude), not Ludovic himself. Proof of this head merged into today's main (101d3b0be, which now has #3300, #2698 and #3802): - the 108 vanilla harnesses this branch can reach pass, `dispenser` and `dedicated_server` included; - pr_ready passes; - lan_e2e passes: worldspawn, forced-levels, distance, spawned, levels, light, mobs. Queued for merge.
2026-10-30 05:33:12 +01:00
Alexander KrylovandHermes Agent 58cf8aa10c Graphics screens: their text through the language table
The graphics screens' captions, values, headers, titles, cost tags, blocked
reasons and descriptions were English display text handed to the widgets, so
no key ever reached i18n. The text is the key now: labelled() translates a
caption and its value, the description is translated before it wraps, and the
headers, titles and buttons go through lang::get where they are drawn.
Numbers in values became templates the table can hold.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-30 07:28:14 +03:00
Ludovic Marie 5851a80ce0 Merge pull request #3735 from xFedeT/fix/zombified-piglin-turtle-egg-goal
🤖 Agent: Ludovic's AI agent (Claude), triage. #3712 landed with a mob_ai case of its own, so I merged main into your branch again (no rebase). The only conflict was the case list in `tools/test_mob_ai_parity.py`, and both sides are kept. `parity.py mob_ai` passes on main with this and #3721/#3735 merged. Requeued.
2026-10-30 05:24:41 +01:00
Ludovic Marie bf6c0b8a24 Merge pull request #4078 from horizon-rust/perf/shared-face-visibility
🤖 Agent: the parity rerun is green (dedicated_server's ender pearl step passed; the 03:10 failure was load). Requeued, same head.
2026-10-30 05:19:39 +01:00
Ludovic Marie 05f5c2197b Merge pull request #4092 from horizon-rust/perf/shared-feature-block-tags
🤖 Agent: verified by server-tick-1. Worldgen binds the feature block tags once (GeneratorData::feature_block_bindings) and shares them through Arcs; the maps' readers are unchanged, so decoration gives the same result. −11.6 MB allocated on the dedicated server. pr_ready green; parity 152/153, the failing dispenser case comes from main (golem PRs #4022/#3959), not from this change.
2026-10-30 05:17:46 +01:00
Ludovic MarieandClaude Opus 5.5 3ffa45a5cf horizon-bots: flood-chunks climbs to cruising height first
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 05:17:03 +01:00
Ludovic Marie 05114a3673 Merge remote-tracking branch 'origin/main' into upd-3735
# Conflicts:
#	tools/test_mob_ai_parity.py
2026-10-30 05:14:03 +01:00
Ludovic Marie 9ee6b293d4 Merge remote-tracking branch 'origin/main' into upd-3721
# Conflicts:
#	tools/test_mob_ai_parity.py
2026-10-30 05:14:02 +01:00
Ludovic MarieandClaude Opus 5.5 d322e068e2 horizon-bots: flood-chunks, one legal move a tick that never waits for chunks
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 04:54:54 +01:00
DUzzL 6f654f9180 Launcher: keep --check-accounts as a hidden no-op for old scripts 2026-10-30 04:54:15 +01:00
Ludovic Marie df583d34af Merge pull request #4056 from DUzzL/fix/issue-4037-armadillo-campfire
🤖 Agent: merging DUzzL's campfire contact damage for mobs (tested in game on both single-player paths: armadillos, cows and sheep burn on lit campfires, Infested spawns silverfish). Thanks!
2026-10-30 04:49:27 +01:00
DUzzL b636b37e9a Launcher: new servers check Minecraft accounts by default
The create screen no longer offers the offline choice; the server's
settings page still turns account checks off, and the CLI takes --offline.
2026-10-30 04:46:04 +01:00
Ludovic Marie 737657443d Merge pull request #3712 from xFedeT/fix/villager-grows-up-brain
🤖 Agent: Ludovic's AI agent (Claude), triage. #3729 landed with mob_ai cases of its own. I merged main into your branch (no rebase). The only conflict was where the new cases go in `tools/test_mob_ai_parity.py`, and both sets are kept. The Rust merged clean, so the proof above still holds. Requeued at the merged head.
2026-10-30 04:44:09 +01:00
Ludovic Marie 101d3b0bec Merge pull request #3773 from xFedeT/fix/cat-goals
🤖 Agent: Ludovic's AI agent (Claude), proof. This PR was in a union of five mob PRs (#3712, #3721, #3729, #3735, #3773) merged into current main. `parity.py --changed origin/main`: 55 of 55 affected harnesses passed, `mob_ai` included. `horizon-client` lib tests: 726 passed. clippy `-D warnings` is clean. The only conflicts were the new cases in `tools/test_mob_ai_parity.py`, as resolved on your branch. Queued.
2026-10-30 04:32:06 +01:00
Ludovic Marie 0359138d94 Merge pull request #2698 from horizon-rust/cg20/fusseltv-worldgen-mobs
🤖 Agent: pr-triage-1 (Ludovic's AI agent, Claude), not Ludovic himself. Since sirrayi's review, I merged main into this branch twice; the last merge is 655b331b1. In the merges: - `VanillaTerrain` keeps main's world presets (2607d06f9). This branch only adds `region_random` and `spawn_generated`, and its tests call `for_dimension` without the preset argument. - Main's goat and frog code (`goat_init_memories`, the goat ram cooldown, the frog's long jump) used main's memory names, while this branch saves vanilla's ids. Both now use vanilla's: `long_jump_cooling_down` and `ram_cooldown_ticks`. - main's mooshroom kind test sits beside #2544's baby villager test. Tested on today's main: - With #3802, #4095 and #3300 together: 123 of the 125 harnesses this branch can reach pass, along with pr_ready and lan_e2e (mobs, spawned, worldspawn, drops). The 2 failures don't come from this branch: `dispenser` passes on main plus this branch alone, and `command_exec` failed because the vanilla server was under load. - On this head alone: `dispenser`, `finalize_spawn`, `natural_spawn` and `mob_ai` pass, and the 48 harnesses that cover goat.rs, frog.rs and finalize.rs pass 48/48. Queued for merge. When it merges, GitHub marks @FusselTV's #2544 and #2586 as merged.
2026-10-30 04:32:02 +01:00
Ludovic Marie b5ec91342e Merge remote-tracking branch 'origin/main' into upd-3735
# Conflicts:
#	tools/test_mob_ai_parity.py
2026-10-30 04:32:00 +01:00
Ludovic Marie f1eedfcb40 Merge remote-tracking branch 'origin/main' into upd-3721
# Conflicts:
#	tools/test_mob_ai_parity.py
2026-10-30 04:31:59 +01:00
Ludovic Marie 6883b74274 Merge remote-tracking branch 'origin/main' into upd-3712
# Conflicts:
#	tools/test_mob_ai_parity.py
2026-10-30 04:31:58 +01:00
Ludovic MarieandClaude Opus 5.5 d9e5234fa3 horizon-bots: abusive client behaviours (flood-move, flood-acks, flood-packets) to test a server's limits
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 04:27:58 +01:00
d3grandoandClaude Opus 5.5 9aeb985305 lan_e2e eating: one loaf each, and read the bites once they settle
The eater held the use for 2.2 s with two loaves: the held use started
the second loaf and let go of it a few ticks in, around its second bite
(Consumable.shouldEmitParticlesAndSounds: every 4 ticks once 7 of 32 are
past). That bite reaches the watcher from the server, whose use runs on
its own ticks behind the eater's client, so read 10 ticks after the hold
the watcher could still be one short (9 of 10, only when the eater drew
that bite).

Each player now eats a single loaf, so the count is the loaf's 6 bites
plus the last bite and FoodProperties' sound, and both sides' counts
are read once neither has changed for 20 ticks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 10:26:33 +07:00
DUzzL effec4887d Time out stalled server resource pack downloads
The download queue's HTTP agent had no connect or read timeout, so a server
that accepted the connection and went silent blocked the single queue thread
and the pack screen forever. Fail the request after 30s instead.

Fixes #4168
2026-10-30 04:20:36 +01:00
Ludovic MarieandClaude Opus 5.5 0ec337f17e Merge main into this branch (world data packs' advancements: a tree is shared only without pack advancements)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 04:17:22 +01:00
Ludovic Marie a93ffc2f36 Merge pull request #3847 from horizon-rust/perf/spawner-idle
🤖 Agent: verified by server-tick-1: a spawner asks for an alive player in range before the spawn level is built, as BaseSpawner.serverTick returns first thing without one (no draw, no change); natural_level 1.6 % → 0.5 % of the tick thread at 100 bots; verify green, clippy -p horizon-client clean (main's log_display and axolotl test failures are main's); merging once CI parity passes.
2026-10-30 04:16:53 +01:00
Ludovic Marie 6e0605ad33 Merge pull request #4161 from horizon-rust/fix/lan-e2e-gateway-exact
🤖 Agent: queuing the lan_e2e levels test fix (#4145): the End gateway's ExactTeleport key and closing the guest's credits; levels passes on main's binary, pr_ready green.
2026-10-30 03:54:35 +01:00
Ludovic Marie 3bd99f79ae Merge pull request #3300 from masa-masa-0314/fix/datapack-recipe-loot-tags
🤖 Agent: pr-triage-1 (Ludovic's AI agent, Claude), not Ludovic himself. @masa-masa-0314, this PR is on the maintainer's priority list, so instead of asking you again I merged main into your branch myself. Your commits are not rewritten; it took three main merges today, the last one at ada669266. In the conflicts: - main's new code (cushions, crafted-recipe triggers, creative slots, item use) now reads the world's tables through your `self.items()`, `world_items!`, `world_stations!` and `world_recipes!`; - main's new tests pass `PackOverlay::NONE`; - `game/mod_render.rs` stays deleted, as main removed the wasm mod UI. Tested together with #3802, #4095 and #2698 on today's main: - 123 of the 125 vanilla harnesses this branch can reach pass. `dispenser` fails because of another PR in that set; it passes on main plus this PR alone. `command_exec`'s failure was the vanilla server under load; on this head it matches 902/902. - pr_ready passes. - lan_e2e passes (crafting, recipes, drops, mobs, spawned, cushions). Queued for merge. Thank you for the data-pack work!
2026-10-30 03:54:30 +01:00
Ludovic MarieandClaude Opus 5.5 655b331b1e Merge main into this branch (goat memory init, mooshroom kinds test)
Main's goat_init_memories, goat ram and frog jump code keep their brain
memories under main's names; this branch saves them under vanilla's ids.
Both now use vanilla's: long_jump_cooling_down and ram_cooldown_ticks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 03:53:15 +01:00
levzzz a46ce6898b Merge pull request #3729 from xFedeT/fix/constructor-path-maluses
Mobs: the path maluses vanilla's constructors set (fire, lava, powder snow, cocoa, trapdoors)
2026-10-30 07:50:58 +05:00
Ludovic Marie 8a7cd0deaf Merge pull request #4089 from horizon-rust/fix/command-exec-oracle-settle
🤖 Agent: fixes main's red command_exec (oracle timeout under load); passed fresh locally on current main.
2026-10-30 03:49:50 +01:00
Ludovic Marie 6269b7a21e Merge pull request #4136 from horizon-rust/fix/portal-overlay-cooldown
🤖 Agent: queuing the portal trip fixes (Discord: Nether portal overlay stuck after a dimension change; End portal leaving the player in the air): the overlay fades in the arrival portal, and the player stays on the loading screen until the exit is found. Tested in game on both paths; lan_e2e portals passes. pr_ready is green except dispenser, main's regression from #3959 fixed by #4120.
2026-10-30 03:46:07 +01:00
Ludovic Marie c5410785c0 Merge pull request #3748 from bunchy7s/fix/command-block-screen
🤖 Agent: Ludovic's AI agent (Claude), proof. This PR was in a union of 16 needs-proof PRs merged into main at 1dde830ba. Parity: 195 of 196 affected harnesses passed, none failed. clippy and rustfmt passed. `lan_e2e --check commands` passes on the server thread and `--in-frame`. The command block's edit screen refreshing from the server's data is covered there. Queued.
2026-10-30 03:42:24 +01:00
Ludovic MarieandClaude Opus 5.5 ada669266f Merge main into this branch (creative display slots)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 03:30:02 +01:00
JulesB40andClaude Opus 5.5 85ff723581 Allocator: say which heaps mi_collect frees
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 03:21:13 +01:00
JulesB40andClaude Opus 5.5 5b695d5a26 Allocator: give free pages back at once
With mimalloc's default 1 s purge delay the server held 26% and the client
37% more memory than with the system heap. Purging at once keeps the server
within 3% and the client within 9%, and generation keeps most of its speed-up
(+18% chunks a second on 24 workers, +3% on one).

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 03:21:13 +01:00
DUzzL e8c32f6610 Run single-player use clicks after attack clicks within a tick
A place and a break in one click now replace the targeted block as in
vanilla's handleKeybinds: attack first, then use against the hit result
picked before the break. Fixes #4147.
2026-10-30 03:13:48 +01:00
Ludovic Marie b9c54ecd50 Merge pull request #4027 from horizon-rust/perf/dedicated-no-sprite-pixels
🤖 Agent: verified by server-tick-1. Stacked on #4009: a dedicated server keeps the block models and sprite layers but no texture pixels or animation frames, which only the renderer reads; −28 MB idle RSS on Linux. dedicated_server passes locally; merge after #4009.
2026-10-30 03:12:58 +01:00
spiderandClaude Opus 5.5 d87ead957e Far terrain: the dimension the player is in, not the server's level in place
The window built its far terrain from whichever level the server thread
had in place when it asked. Dying in the Nether and respawning in the
Overworld could catch the Nether there, and Nether far terrain stood around
the Overworld until the world was reopened. Levels::lod_source_in gives the
player's dimension's, in place or parked.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 03:12:23 +01:00
Alexander KrylovandHermes Agent 56402b95f3 Launcher: the language row is a dropdown, not a segment per language
Nine segments are wider than the row, which left its sentence a character
wide and wrapping one letter per line. The row shows the choice and a
menu now, and the sentence stays only while the language follows the
system's.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-30 05:06:21 +03:00
asownerytandClaude Opus 5.5 7037d586ca Tamed pets no longer turn on the owner who hits them
Vanilla's TamableAnimal.canAttack refuses the pet's owner (isOwnedBy), so
HurtByTargetGoal never starts when the owner hits their wolf, cat or
parrot, and no target or anger follows. Horizon's player check
(Mob::spares_players) only knew the player-built iron golem, so a tamed
wolf hunted its owner after one hit (#4119). It now spares a
TamableAnimal's owner too, in every place that check is made.

NonTameRandomTargetGoal also checks isTame before its random interval
roll: the wolf's and cat's untamed-only target goals now gate before the
draw, so a tamed pet's random sequence follows vanilla.

mob_ai harness: a new `owned` flag (tamed by the scenario's player) and
the wolf_owner_hurt scenario.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 15:05:11 +13:00
Ludovic MarieandClaude Opus 5.5 24bea8540d Make lan_e2e levels' guest_end step match vanilla's End exit
The End gateway's exact flag is `ExactTeleport` in TheEndGatewayBlockEntity's
NBT; `exact_teleport` was ignored, so the gateway searched for a landing and
left the guest away from the spawn platform. The guest's first exit from the
End then rolls the credits (ServerPlayer.showEndCredits) and only closing
them takes it home, as the portals step already does for the host.

Fixes #4145

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 08:55:23 +07:00
DUzzL 8ec6e3d9f4 Update port ledger note for slot_at hover margin 2026-10-30 02:53:03 +01:00
DUzzL a97afdc93f Hover the nearest slot when the pointer is in the gap between slots
Vanilla's AbstractContainerScreen.isHovering grows each 16x16 slot by one
pixel on every side; slot_at used the bare 16x16 box, so the 2px gaps
between slots selected nothing. Fixes #4134.
2026-10-30 02:46:03 +01:00
Ludovic MarieandClaude Opus 5.5 f03c1771af Drop the old level's pending teleport when a portal trip starts
Its late acceptance set the player's position back to the old level's
coordinates, so a trip that started right after a teleport waited forever for
chunks around the exit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 02:45:25 +01:00
levzzz 25f9cd3a99 Merge pull request #3875 from xFedeT/fix/boat-paddle-input-latch
Boats: steer with the input latched on the previous tick
2026-10-30 06:44:08 +05:00
Ludovic MarieandClaude Opus 5.5 f257391453 Chunk workers: give freed memory back once every worker is idle
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 02:42:50 +01:00
Ludovic MarieandClaude Opus 5.5 e497995b44 Chunk workers: free the generator's scratch buffers after 5 s with nothing to do
Each streaming worker keeps its generator's density scratch buffers and
lookup caches between jobs, megabytes per worker, for as long as the world
is open: about 51 MB on the Mac mini's dedicated server with nobody on. A
worker that has waited 5 s with an empty queue now frees them (and the
mesher's face cache); the next job makes them again. Results do not change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 02:42:50 +01:00
DUzzL 3acfebb2fe Document the armor stand seat offset 2026-10-30 02:41:31 +01:00
DUzzL 77738c6356 Keep latched sneak and sprint across a short focus loss
Focus loss cleared the key set, which also held the keys synthesized from the sneak/sprint toggles. A quick alt-tab opens no pause screen, so nothing restored them. Re-sync both toggles after the clear. Fixes #4133.
2026-10-30 02:38:18 +01:00
d3grandoandClaude Opus 5.5 03ba7662d5 Merge origin/main into fix-2740
game/server_packs.rs: main's WORLD_PACK_ID is kept; PackReload and RELOAD_LINES stay in game/packs.rs, where this branch moved them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 08:37:34 +07:00
Ludovic MarieandClaude Opus 5.5 2c91574bbb Ignore a portal traveller's old moves until it lands
Without the teleport change_level used to send at once, moves the client had
sent from the old level were taken as positions in the new one, and the level
loaded its chunks there instead of around the exit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 02:37:29 +01:00
DUzzL 557c4c5cf9 Start remote end crystal spin from a random phase
A crystal spawned from an add-entity packet kept time 0, so every crystal
placed in multiplayer or LAN looked identically rotated. Vanilla's
EndCrystal constructor draws time from nextInt(100000).
2026-10-30 02:36:05 +01:00
levzzzandClaude Opus 5.5 f0ac52bf7f Merge main into fix/boat-paddle-input-latch (resolve tools/lan_e2e.py)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 06:35:03 +05:00
TraiNguyenVanandQoder 300d048a1c README: the launcher's two pictures, retaken signed in on 0.2.28
Both are fresh 1280x800 captures of launcher 0.2.28, taken with a
Microsoft account selected so the chip reads "<name> / Microsoft account"
as it does for players. The name is blurred with a feathered gaussian, in
the chip and in the Settings account row, so no account name is readable
in either file.

The instance uses Horizon's own violet accent tile (#5b3fd6, the H) rather
than Minecraft's grass block, which the launcher only composites from the
player's own prepared textures. It has v0.2.97 prepared, so Resources reads
"Prepared for Minecraft 26.3"; the playing count and the newest Nightly
line are live values and will drift.

README alt text is unchanged; it describes the same two pages.

Co-Authored-By: Qoder <[email protected]>
2026-10-30 08:34:36 +07:00
DUzzL fc26a5f40a Seat armor stands that ride a mob or the player (frozen name tag)
A server's name tag hologram rides the player as an armor stand. The client
ignored SetPassengers for armor stands, so the stand stayed where it spawned
while the real name tag followed the player.
2026-10-30 02:31:19 +01:00
levzzz 1f3ccb0fc3 Merge pull request #3747 from xFedeT/fix/break-effects-for-popped-blocks
Break effects: a block that pops off when its support is mined plays its sound and particles for the miner
2026-10-30 06:30:23 +05:00
Ludovic Marie fcbb0d627a Merge pull request #4120 from horizon-rust/fix/golem-base-any-head
🤖 Agent: fixes main's red dispenser (golem base check from #3959); pr_ready green, 33 harnesses pass.
2026-10-30 02:28:55 +01:00
levzzz 30666b33b6 Merge pull request #3655 from jaszkus/feat/debug-stick
Add the Debug Stick
2026-10-30 06:24:29 +05:00
levzzz ada67586d0 Merge pull request #3642 from iLostName/off-hand-waits-for-main-hand
off hand: filling a composter or using bone meal ends the click before the off-hand torch
2026-10-30 06:24:12 +05:00
levzzzandClaude Opus 5.5 6ca9531468 Merge main into fix/boat-paddle-input-latch (resolve tools/lan_e2e.py)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 06:22:44 +05:00
levzzzandClaude Opus 5.5 972719f088 Merge main into fix/break-effects-for-popped-blocks (resolve tools/lan_e2e.py)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 06:22:23 +05:00
kerim 4aa8663777 Merge branch 'main' into main 2026-10-30 02:21:10 +01:00
DUzzL 29363ee3e9 Apply the held item's main-hand modifiers to the player's attributes
/attribute reported the base attack damage, attack speed and reach with a
weapon in hand because apply_equipment skipped the main-hand slot. Apply
them and refresh when the held item changes. Fixes #4118.
2026-10-30 02:19:33 +01:00
levzzz b548c70594 Merge pull request #4018 from xFedeT/fix/wind-burst-smash
Mace smash on another player: Wind Burst launches the attacker, the fall stops
2026-10-30 06:18:40 +05:00
Ludovic Marie 4fef8a8682 Merge pull request #3921 from horizon-rust/feat/parity-headline
🤖 Agent: merging the parity headline (#3859 step 5): verify green, gap tests pass, the site built against it shows the paragraph without markers.
2026-10-30 02:18:15 +01:00
Benflex 7cb03beeed Play the merchant's yes sound on a shift-clicked trade
MerchantMenu.playTradeSound plays the trader's notify-trade sound
(villager or wandering trader), but the game dropped the queued
TradeSound event. Play it at the trade's origin with volume and pitch 1.

Part of #3515.
2026-10-30 02:17:10 +01:00
levzzzandClaude Sonnet 5.5 a80678b890 Merge main into fix/split-mob-sounds-behaviour (resolve game/creatures.rs, game/potions.rs, mobs/leash.rs)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 06:16:25 +05:00
levzzz be8a82a40b Merge pull request #3955 from kacpherek/fix/split-animals
Animals behave like vanilla: turtles, goats, foxes, bees, axolotls, camels and horses
2026-10-30 06:13:52 +05:00
levzzz e54e8003e5 Merge pull request #3756 from horizon-rust/fix/server-brand-horizon
Server brand: Horizon's server names itself "horizon"
2026-10-30 06:13:35 +05:00
levzzzandClaude Sonnet 5.5 bc31cef0a7 Merge main into feat/debug-stick (no conflicts)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 06:08:52 +05:00
kerim 6aff9307fe add more german translations 2026-10-30 02:08:34 +01:00
levzzzandClaude Sonnet 5.5 2b2a8bedd4 Merge main into off-hand-waits-for-main-hand (no conflicts)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 06:08:11 +05:00
levzzzandClaude Sonnet 5.5 6db70a1ae4 Merge main into fix/boat-paddle-input-latch (resolve tools/lan_e2e.py)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 06:07:50 +05:00
levzzzandClaude Sonnet 5.5 0d5cd6a591 Merge main into fix/break-effects-for-popped-blocks (resolve tools/lan_e2e.py)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 06:07:30 +05:00
levzzzandClaude Sonnet 5.5 6d67bf43bb Merge main into fix/wind-burst-smash (resolve tools/lan_e2e.py)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 06:07:11 +05:00
levzzz 6d59158973 Merge pull request #3643 from iLostName/crossbow-near-tnt-minecart
minecart: right-clicking a tnt minecart uses the held item, so a crossbow shoots at it
2026-10-30 06:06:30 +05:00
levzzz 647050d7aa Merge pull request #3953 from kacpherek/fix/split-screens
Menus, tooltips, world opening and chat now behave like vanilla
2026-10-30 06:05:42 +05:00
levzzz 1c333aa336 Merge pull request #3944 from kacpherek/fix/split-sulfur-glowsquid-water
Sulfur geysers and potent sulfur, glow squid dark ticks, and splash water bottles that douse fire
2026-10-30 06:05:24 +05:00
levzzz 41d772380a Merge pull request #3952 from kacpherek/fix/split-shelves-signs
Signs run the click commands in their text, as vanilla
2026-10-30 06:05:08 +05:00
levzzz df00ca8693 Merge pull request #3954 from kacpherek/fix/split-server
Fix server and LAN-host behaviour: spam kicks, straw beds, shulkers, ravagers, item frames, advancements and tab list
2026-10-30 06:04:51 +05:00
levzzz 589164c071 Merge pull request #3693 from Username59138/feat/creative-saved-hotbars
Creative inventory: the Saved Hotbars tab, as vanilla shows it
2026-10-30 06:04:34 +05:00
levzzz ed196ffd66 Merge pull request #4015 from d3grando/fix-3393-fallingblock
Falling sand and gravel ride bubble columns
2026-10-30 06:01:50 +05:00
DUzzL d14b3e27ba Save the previous velocity for LAN guest bodies too 2026-10-30 01:59:19 +01:00
levzzz 00480831ce Merge pull request #3995 from xFedeT/fix/fishing-hook-smooth
Fishing hook on a server's client moves between the tracker's updates, as FishingHook.tick does
2026-10-30 05:57:43 +05:00
DUzzL 5c4eb67ba2 Interpolate elytra lean velocity from the previous tick 2026-10-30 01:50:50 +01:00
levzzz 2fa595676e Merge pull request #3645 from iLostName/pillar-place-sound
block placement: a click on a block is handled before the move behind it, so pillar blocks are heard
2026-10-30 05:49:32 +05:00
FauxandClaude Opus 5.5 edefd8ef0c Far terrain: kelp is water, and powder snow and paths are kept
A chunk's summary skipped blocks that neither draw solid nor collide as a
full block. Kelp and seagrass reaching the surface left holes in the far
sea, and patches of powder snow left holes in mountains (paths, farmland
and stairs dips).

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012gFJzABp1uePB4gm32PuaC
2026-10-29 17:48:18 -07:00
Benflex 7fe6f090e1 Draw item tooltips over inventory effect panels
The survival and creative inventories drew the active-effect panels
after the item tooltip, so the panel backgrounds covered the tooltip
text. Draw the panels with the rest of the screen and keep only the
hovered effect's tooltip for the end, after the item and slot tooltips.

Fixes #4080.
2026-10-30 01:43:11 +01:00
Sylviromi 7aaa68865f Merge branch 'native-mod-neighbours' into native-mod-screens 2026-10-30 03:38:55 +03:00
SylviromiandClaude Opus 5.5 723a2a44cb Native mods: set a block's property from a tick (a machine's lit)
native_set_block_property (a helper, API 3): as a furnace's tick sets
its lit, so a machine shows when it works.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 03:38:55 +03:00
levzzz d3338640bc Merge pull request #4002 from DUzzL/fix/issue-3997-guest-arrow-pickup
Arrows can be picked up by LAN guests and the single-player window (#3997)
2026-10-30 05:38:17 +05:00
Aria Figueredo ea10a17842 Expose native world lifecycle hooks 2026-10-29 19:36:15 -05:00
Ludovic Marie fcb14be442 Merge pull request #4100 from horizon-rust/fix/no-official-server-in-harness-runs
🤖 Agent: Ludovic's AI agent (Claude), pr-triage-3. Harness and horizon-ctl runs no longer add the official server to servers.dat in the working directory (the repo root) or send the players ping; before/after checked with a fixture run in a scratch folder. Queued.
2026-10-30 01:32:33 +01:00
levzzz f1c0ab982a Merge pull request #4040 from phexuss/fix/remote-look-at
Turn the player when a server sends PlayerLookAt
2026-10-30 05:32:12 +05:00
DUzzL 91bd434281 Apply block_break_speed attribute to mining speed
Read the player's minecraft:block_break_speed attribute and multiply the destroy speed by it in both the server mining tick and the client's destroy-progress prediction, matching Player.getDestroySpeed.

Fixes #4094.
2026-10-30 01:32:11 +01:00
levzzz 173d00dadb Merge pull request #3668 from martipops/fix/damage-other-players
/damage hurts players other than the one running it
2026-10-30 05:31:56 +05:00
levzzz ea7e58787d Merge pull request #3675 from d3grando/fix-3236
Mooshrooms keep their colour: save Type, sync DATA_TYPE at 18
2026-10-30 05:31:39 +05:00
levzzzandClaude Sonnet 5.5 7ce7388582 Merge main into fix/split-animals (resolve crates/horizon-client/src/mobs/living.rs)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 05:30:56 +05:00
DUzzL d2e26c32d4 Players with a missing skin sprite draw the default skin, not nothing
A remote player or mannequin whose skin location had no atlas sprite
(a stored slot without its upload, an evicted skin) drew no body. The
model now falls back to the default skin texture, and a skin counts as
stored only when its sprite is in the atlas, so it is stored again.

Refs #4096
2026-10-30 01:30:27 +01:00
levzzzandClaude Sonnet 5.5 6fea98f668 Merge main into fix/split-spear-ai-golem (resolve crates/horizon-client/src/mobs/goals.rs)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 05:29:40 +05:00
levzzzandClaude Sonnet 5.5 3ee203767a Merge main into fix/own-head-pitch-follows-mouse (resolve crates/horizon-client/src/game/perspective.rs)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 05:28:23 +05:00
levzzz a013e649c9 Merge pull request #4014 from DUzzL/fix/issue-4003-guest-trial-omen
Trial/Bad Omen of server players make trial spawners ominous
2026-10-30 05:28:08 +05:00
levzzzandClaude Sonnet 5.5 b78e407c12 Merge main into fix/split-agelock-goat-allay (resolve crates/horizon-client/src/mobs/ai.rs)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 05:26:51 +05:00
levzzzandClaude Sonnet 5.5 9858aec68b Merge main into fix/split-piglin-hoglin (resolve crates/horizon-client/src/game/creatures.rs, mobs.rs)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 05:25:06 +05:00
SylviromiandClaude Opus 5.5 7df9ed0545 Native mods: screens for mod blocks, drawn with the mod's GUI textures
A content mod's textures/gui files are GUI sprites under its namespace
(mymod:gui/...). On the client, native_screen lays out a mod block's menu
screen (height, slot positions, the inventory's row) and
native_screen_draw draws it, over a plain panel with the slots' frames,
from the menu's data slots, which the client now keeps for it; the
helpers native_sprite and native_sprite_size draw any part of a sprite.
The server's menu is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 03:25:06 +03:00
levzzzandClaude Sonnet 5.5 7852c700f1 Merge main into fix-3199 (resolve crates/horizon-client/src/mobs/ai.rs, camel.rs)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 05:23:23 +05:00
Ludovic MarieandClaude Opus 5.5 14fad0786e Game: harness and horizon-ctl runs don't add the official server or ping
The official server's servers.dat entry and its marker go into the game
folder, which is the working directory, and the "N playing now" ping
starts at the same place. The guard skipped servers, benchmarks,
offscreen and frame-limited runs, but not a harness's fixture
(HORIZON_DISPENSE_FIXTURE, HORIZON_SCULK_FIXTURE, HORIZON_COMMAND_SCRIPT,
HORIZON_NET_REPORT) or a run horizon-ctl drives. Harnesses start those
in the source checkout, so a parity run left servers.dat and
horizon_official_server.txt in the repo root and counted itself as a
player. The guard now reuses synchronous_startup, which already names
every one of these runs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 01:23:18 +01:00
levzzz 0dfa513424 Merge pull request #4043 from DUzzL/fix/issue-4031-fall-damage-sound
Fall damage plays its sound in multiplayer and for LAN guests
2026-10-30 05:22:21 +05:00
Federico TaglianiandClaude Sonnet 5.5 735cdab711 Merge origin/main into fix/skeleton-path-stops-in-shade
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 01:21:32 +01:00
DUzzL ce91a02d84 Use the player's attack_speed base for the attack cooldown (#4091) 2026-10-30 01:20:23 +01:00
levzzzandClaude Sonnet 5.5 51c10dd31d Merge main into fix/villager-grows-up-brain (resolve tools/test_mob_ai_parity.py)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 05:20:20 +05:00
levzzzandClaude Sonnet 5.5 0d9399ead9 Merge main into fix/constructor-path-maluses (resolve tools/test_mob_ai_parity.py)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 05:20:03 +05:00
Ludovic MarieandClaude Opus 5.5 923f425f19 Advancements: load the tree once for the server's packets and the game's criteria
ServerAdvancements (the advancement packets a server sends) and the game
that runs the criteria each read and parsed every advancement file into a
tree of their own: 7 MB each, the same twice. Tree::shared keeps one per
data folder while anyone holds it, and both use it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 01:20:01 +01:00
levzzzandClaude Sonnet 5.5 2be6cda974 Merge main into fix/zombified-piglin-turtle-egg-goal (resolve tools/test_mob_ai_parity.py)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 05:19:36 +05:00
levzzzandClaude Sonnet 5.5 9ad9d087ef Merge main into fix/boat-paddle-input-latch (resolve tools/lan_e2e.py)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 05:18:24 +05:00
levzzz 79593d80b6 Merge pull request #3659 from d3grando/fix-3091
Let parrots fly: flying navigation and move control, flapping and gliding
2026-10-30 05:18:18 +05:00
levzzzandClaude Sonnet 5.5 58853fdf50 Merge main into fix/wind-burst-smash (resolve tools/lan_e2e.py)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 05:18:06 +05:00
levzzzandClaude Sonnet 5.5 834819ddf4 Merge main into fix/fishing-hook-smooth (resolve tools/lan_e2e.py)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 05:17:45 +05:00
levzzzandClaude Sonnet 5.5 7835010339 Merge main into fix/break-effects-for-popped-blocks (resolve tools/lan_e2e.py)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 05:17:22 +05:00
levzzz 590542c5ee Merge pull request #4004 from d3grando/fix-3006
Both mouse buttons at once replace the block in creative
2026-10-30 05:16:53 +05:00
DUzzL 2c803ae875 fix(client): send lingering potion cloud colour to clients
The area_effect_cloud entity states are built while the LAN hub is out of
`self.lan` (tick_lan takes it), so encoding the potion cloud's particle
through `network_particle` always returned None and DATA_PARTICLE was
never sent. Clients then fell back to AreaEffectCloud.DEFAULT_PARTICLE, the
white entity effect, for every potion. Encode it through the hub passed to
lan_projectile_states instead, as the dragon breath particle already is.

Closes #4090
2026-10-30 01:15:30 +01:00
Federico TaglianiandClaude Sonnet 5.5 8f7c959679 Navigation: trimPath runs in moveTo, before the node count check
PathNavigation.moveTo trims the path first and refuses it when the trim
leaves no node. The trim was deferred to the next nav_tick, so move_to
answered true for a tick and goals started on a path that was already empty.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 01:14:41 +01:00
Ludovic Marie 4cb754474d Merge pull request #3732 from xFedeT/fix/wither-skeleton-parity
🤖 Agent: Ludovic's AI agent (Claude), triage. Wither skeleton: its piglin target goal comes first among the priority 3 goals (vanilla's registerGoals order). levzzz5154's main merge touched only the mob_ai case list; the Rust change is the same as on the CI-parity-green head, and verify is green. Requeued.
2026-10-30 01:13:47 +01:00
Ludovic Marie 0855cc910f Merge pull request #3850 from Fauxvale/fix/far-open-water-light
🤖 Agent: Ludovic's AI agent (Claude), triage. Far terrain: no dark band in the sea where chunks were loaded: checked against vanilla, and proven in union pt3-w3 (29 PRs, Mac mini): pr_ready full, parity 109/110 affected passed (lan_integration runs nightly). Queued.
2026-10-30 01:13:42 +01:00
Ludovic Marie 793337b0ed Merge pull request #3892 from matyden/telemetry/scrub-user-named-user
🤖 Agent: Ludovic's AI agent (Claude), triage. Telemetry: a user named "user" keeps the <user> label whole: checked against vanilla, and proven in union pt3-w3 (29 PRs, Mac mini): pr_ready full, parity 109/110 affected passed (lan_integration runs nightly). Queued.
2026-10-30 01:13:38 +01:00
Ludovic Marie a603c95eba Merge pull request #3802 from ThatCakeIsAPie/fix/datapack-block-state-providers
🤖 Agent: Ludovic's AI agent (Claude), triage. Data packs: a pack's block state providers join the state_providers bundle: checked against vanilla, and proven in union pt3-w3 (29 PRs, Mac mini): pr_ready full, parity 109/110 affected passed (lan_integration runs nightly). Queued.
2026-10-30 01:13:34 +01:00
Ludovic Marie 564b8a4b9e Merge pull request #3599 from doodlyyousuf/fix/pearl-hits-wind-charge
🤖 Agent: Ludovic's AI agent (Claude), triage. Let thrown pearls hit wind charges: checked against vanilla, and proven in union pt3-w3 (29 PRs, Mac mini): pr_ready full, parity 109/110 affected passed (lan_integration runs nightly). Queued.
2026-10-30 01:13:30 +01:00
Ludovic MarieandClaude Opus 5.5 a16097dd96 Merge main into this branch (cushion placement checks, lan_bot sync test)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 01:11:09 +01:00
DUzzL baa05a30ec Send the stored Main Hand option in ClientInformation
The Skin Customization Main Hand setting was saved to options.txt and
applied to the local hand, but the ClientInformation packet sent at join
always hardcoded main_hand = 1 (right). The server therefore never
learned the choice, so mirrored and server-side arm visuals stayed on the
right.

Session setup now receives the ClientInformation built from the settings
(Settings::client_information), the same value already used for the
resend-on-change path. Adds round-trip and mapping tests (LEFT = 0,
RIGHT = 1).
2026-10-30 01:10:05 +01:00
arismoko 94e167b796 Keep stopped server list test offline 2026-10-29 19:09:58 -05:00
Ludovic MarieandClaude Opus 5.5 4c802c1342 Merge main into this branch
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 01:08:59 +01:00
Ludovic Marie 304a3ca76e Merge pull request #3371 from horizon-rust/draft/remove-wasm-mods-docs
🤖 Agent: WebAssembly removal 4/4 (docs), on #3353, merged up to date with main (roadmap: main's, with the native-mod wording reapplied; docs-only on top of the union proven in proven.txt). Merge after #3353; horizon-site#6 follows.
2026-10-30 01:08:50 +01:00
Federico TaglianiandClaude Sonnet 5.5 4ef3c1f54a Merge origin/main into fix/skeleton-path-stops-in-shade
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 01:08:27 +01:00
levzzz d163f38b6a Merge pull request #4005 from d3grando/fix-3117
Cushions swing the arm when placed and let a seated player change seats
2026-10-30 05:08:20 +05:00
levzzz e2fa940581 Merge pull request #3607 from degepie/fix/issue-3024-worn-armor-glint
Armour: worn pieces and the elytra show the enchantment glint (#3024)
2026-10-30 05:06:49 +05:00
Ludovic MarieandClaude Opus 5.5 a32ab2c5e4 Merge main into this branch (world presets, worldgen data packs, noise clamp)
Main now builds a world's preset itself (2607d06f9): VanillaTerrain keeps
main's for_world/for_world_with signatures, this branch adds region_random
and spawn_generated beside them, and its tests call for_dimension without
the preset argument.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 01:06:35 +01:00
Ludovic Marie 03b464ec0e Merge pull request #3902 from DUzzL/fix/issue-3882-defer-dataversion-stamp
🤖 Agent: Ludovic's AI agent (Claude), triage. Stamp DataVersion only after the world data is upgraded: checked against vanilla, and proven in union pt3-w3 (29 PRs, Mac mini): pr_ready full, parity 109/110 affected passed (lan_integration runs nightly). Queued.
2026-10-30 01:06:35 +01:00
Ludovic Marie 186095dd37 Merge pull request #3908 from DUzzL/fix/issue-3883-camel-pose-tick
🤖 Agent: Ludovic's AI agent (Claude), triage. Fix camel freeze after spawn and LastPoseTick saved as a sign: checked against vanilla, and proven in union pt3-w3 (29 PRs, Mac mini): pr_ready full, parity 109/110 affected passed (lan_integration runs nightly). Queued.
2026-10-30 01:06:30 +01:00
levzzz a4f0482bd9 Merge pull request #3792 from GIGABAIT93/fix/npc-skins
Player entities keep the skin they found: NPCs show theirs
2026-10-30 05:06:24 +05:00
Ludovic MarieandClaude Opus 5.5 2ba6caa289 Golems: a pumpkin's base check takes any block at the head
canSpawnGolem (a dispenser's carved pumpkin) and the setBlock that places a
head ask before the head is there, with vanilla's base patterns (" ", "#",
"#") and ("~ ~", "###", "~#~"), whose head cell takes any block. #3959's
find_pattern required the head there too, so a dispenser no longer built a
snow or iron golem: dispenser parity failed on main (carved_pumpkin, 1 of
6000 cases).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 01:06:05 +01:00
Federico Tagliani 5f5d0434cb Merge remote-tracking branch 'origin/main' into fix/cat-goals 2026-10-30 01:05:14 +01:00
Federico TaglianiandClaude Sonnet 5.5 11159a1ed5 Merge fork/fix/cat-goals (maintainer's merge of main)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 01:05:13 +01:00
levzzz bf8d58facd Merge pull request #4045 from d3grando/fix-3684-hitbox
A small player's server box follows minecraft:scale
2026-10-30 05:04:26 +05:00
Ludovic MarieandClaude Opus 5.5 a8e3cebc5f Merge main into this branch (wasm mod UI removed, item use slows sprint, crafted-recipe triggers, world presets)
Main's new code reads the world's tables (self.items(), world_stations!,
world_recipes!); main's new tests pass PackOverlay::NONE.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 01:03:05 +01:00
Ludovic MarieandClaude Opus 5.5 30fcf2c30c command_exec oracle: wait for entities by the clock, not 400 ticks
Vanilla loads entity sections on other threads, so on a loaded runner
400 ticks (~2 s) ran out before the chunks around the player were
entity-ticking: the oracle threw "entities around the player did not
load" and main's parity has failed on command_exec since 2026-10-29 22:05.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 01:01:05 +01:00
levzzz 9d72c1ddcd Merge pull request #3716 from xFedeT/fix/merchant-closes-out-of-range
Villagers: the trade screen closes when the trader is gone or out of reach
2026-10-30 05:00:59 +05:00
levzzz 62caef810a Merge pull request #3934 from DUzzL/fix/issue-3912-turtle-ambient
Turtle: silent in water, in the air and as a baby
2026-10-30 05:00:41 +05:00
Federico TaglianiandClaude Sonnet 5.5 1c16f98098 Merge origin/main into fix/cat-goals: the sprint now comes from main
main's feline_server_ai_step sets the cat's sprint and Mob::movement_speed
adds the +30% for it, so the cat arm of custom_server_ai_step, the sprint dust
draws and the speed_bonus arm (a second +30%) are dropped; the avoidance goal
order and the CatAvoidEntityGoal probe name stay.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 01:00:39 +01:00
levzzz 69d2086e7d Merge pull request #3623 from degepie/fix/issue-3026-fish-schools
Fish form schools: port FollowFlockLeaderGoal (#3026)
2026-10-30 05:00:16 +05:00
levzzz 0829657bcc Merge pull request #3868 from spidercraft/feat/lan-online-mode
LAN and friends worlds authenticate guests when the host is signed in: their skins and capes show
2026-10-30 04:59:38 +05:00
Ludovic MarieandClaude Opus 5.5 88e79c1119 Keep a portal traveller on the loading screen until its exit is found
Vanilla finds a portal's exit (the End platform, the Nether portal, the spawn
column after the End) before ServerPlayer.teleport sends the Respawn packet.
Horizon finds it once the destination has loaded, and meanwhile showed the
client at the approximate point: in the air above the End's void or at the
raw world spawn. The client now gets its position, LEVEL_CHUNKS_LOAD_START
and the portal sound only when the trip lands, so it stays on its level
loading screen as in vanilla.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 00:58:15 +01:00
Ludovic Marie 1dde830baf Merge pull request #3353 from horizon-rust/draft/remove-wasm-mods-launcher
🤖 Agent: WebAssembly removal 3/4 (launcher: a WebAssembly mod needs a native port), on #3351; proven with #3351 and #3371 on union/wasm-removal (proven.txt). Launcher clippy and its 275 tests pass. Merge after #3351.
2026-10-30 00:56:58 +01:00
Ludovic Marie 588ca0cd22 Merge pull request #3152 from senjinthedragon/hdr-screenshot
🤖 Agent: pr-triage-1 (Ludovic's AI agent, Claude), not Ludovic himself. I merged main into your branch (a fast-forward merge commit, 0e4f8e279; none of your commits rewritten). Since this PR was opened, main changed the HDR parameters (black level, UI brightness, tiers: 20 floats) and screenshots (`ScreenshotRequest`, world icons). In the merge: - `Hdr::params` fills main's layout for both the swapchain and your HDR10 capture; - only full-frame screenshots get the `-hdr.png` copy, not world icons; - the docs sentence moved into main's HDR paragraph. Tested on this head merged into main: - The harnesses it can reach pass (59/60). The one failure, `command_exec`'s `/data get` `custom_name`, comes from main. - fmt, clippy and the render tests pass. - On macOS (HDR10 output), a screenshot wrote both PNGs: the HDR one is 16-bit RGB with cICP 9/16/0/1 and cLLI 1000/203. Queued for merge.
2026-10-30 00:56:54 +01:00
Ludovic Marie 4488cda84f Merge pull request #3808 from doodlyyousuf/fix/release-notes-canonical-repo
🤖 Agent: Ludovic's AI agent (Claude), triage. Tooling only: release notes always link horizon-rust/horizon, even when a fork's CI builds them. verify is green. Queued.
2026-10-30 00:56:51 +01:00
Ludovic MarieandClaude Opus 5.5 2fd01bd8bd Merge main (0208c75ef) into the docs layer
The roadmap: main's (Now at most 8 items) with this layer's native-mod
wording reapplied; the WebAssembly runtime is removed, not being removed.
docs/README.md: main's data pack upgrade line kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 00:55:10 +01:00
levzzzandClaude Sonnet 5.5 ba5585da84 Merge main into fix/npc-skins (resolve crates/horizon-client/src/game/remote.rs)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 04:53:10 +05:00
Ludovic Marie d409f691dc Merge remote-tracking branch 'origin/main' into upd-3642
# Conflicts:
#	crates/horizon-client/src/game/remote.rs
2026-10-30 00:52:43 +01:00
d3grandoandClaude Opus 5.5 4d1f533af6 Creative tab selection test: give the category tab a stack
The fixture's CATEGORY tab was empty, and an empty category tab is now
hidden (CreativeModeTab.shouldDisplay), so tab 0 was no longer the tab the
test selects. It now holds a stone stack.

Part of #3393.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 06:52:26 +07:00
levzzzandClaude Sonnet 5.5 cc683c75f0 Merge main into fix/issue-3024-worn-armor-glint (resolve crates/horizon-client/src/game/perspective.rs)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 04:51:24 +05:00
SylviromiandClaude Opus 5.5 dc925bf620 Native mods: reach a neighbouring mod block entity from a tick
native_mod_block_entity_at (a helper, API 3): the mod block entity at a
position, to read or change from another's tick, as a generator charging
the machine beside it. As setChanged would, its chunk is saved next time
and comparators read it again; a tick that moved its own items tells
comparators too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 02:50:39 +03:00
Ludovic Marie 57e49b068e Merge remote-tracking branch 'origin/main' into upd-3655
# Conflicts:
#	tests/test_remote_mining.py
2026-10-30 00:50:35 +01:00
d3grandoandClaude Opus 5.5 89f40134b2 Merge origin/main into fix-3199
mobs/ai.rs: this branch's same_vehicle_push_tests and main's tests appended after it are both kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 06:50:11 +07:00
levzzzandClaude Sonnet 5.5 490a33aea9 Merge main into fix/issue-3912-turtle-ambient (resolve crates/horizon-client/src/mobs/ai.rs)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 04:48:40 +05:00
DenisHumenandClaude Opus 5.5 6297ea09f6 Bows and crossbows: no use animation with nothing to shoot
BowItem.use and CrossbowItem.use (uncharged) FAIL without a projectile
unless the player has infinite materials, and Minecraft.startUseItem only
calls itemUsed for a SUCCESS, trying the other hand otherwise. The client's
remote_use_item treated the bow as used anyway, so the hand dipped as if the
draw started and stopped.

remote_start_held_use now reports a failed bow or uncharged crossbow use
(a charged crossbow still succeeds, it shoots), and remote_use_item passes
the turn on.

Fixes #4079

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 01:48:20 +02:00
Ludovic MarieandClaude Opus 5.5 7cce0bf15b Keep the portal cooldown on a server's client so the overlay fades in the arrival portal
handleRespawn gives the new LocalPlayer the server's portalCooldown, and the
client's setAsInsidePortal only restarts it while the player stands in the
arrival portal. Horizon sent 0 and ignored it, so the purple overlay built up
again and stayed on screen after a dimension change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 00:48:17 +01:00
Ludovic MarieandClaude Opus 5.5 a536b00c30 Worldgen: bind the feature block tags once, shared by decoration, structures and live features
Decoration, structure placement and live features each built their own
FeatureBlockBindings, binding every block and every block tag to its state
set: 5.5 MB each, the same three times. GeneratorData now binds them once
(feature_block_bindings) and the bindings share the maps through Arcs. Each
decorated region also cloned the whole tag map; it now shares it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 00:48:07 +01:00
Sylviromi b916a49fbf Merge branch 'native-mod-inventories' into native-mod-menus 2026-10-30 02:47:20 +03:00
Sylviromi 99791ea225 Merge branch 'native-mod-block-entities' into native-mod-inventories 2026-10-30 02:47:20 +03:00
SylviromiandClaude Opus 5.5 807901034b Native mods: a mod block entity's tick marks its chunk changed
Review follow-up: as a ticking block entity's setChanged does, so a save
after an autosave still writes what the tick changed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 02:47:12 +03:00
SylviromiandClaude Opus 5.5 3cb4177026 Native mods: furnace menus for mod containers, with their data slots
A mod container may open a furnace's menu (furnace, blast furnace,
smoker): its block entity's data slots, which the mod sets in its tick and
nothing saves, draw the flame and the arrow as a furnace's dataAccess
does. The tick hook now takes the block entity whole (id, nbt, items,
data), so fields added later don't change its signature.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 02:46:50 +03: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
levzzzandClaude Sonnet 5.5 4289f22f00 Merge main into fix/merchant-closes-out-of-range (resolve tools/lan_e2e.py)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 04:46:19 +05:00
Federico TaglianiandClaude Sonnet 5.5 e4b5b0f07f Inventory preview: the player is lit as Lighting.Entry.ENTITY_IN_UI
The shading was a rough guess (front 1.0, top 0.4); it is now
minecraft_mix_light with the two inventory lights, ambient 0.4 and power 0.6.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 00:45:21 +01:00
levzzzandClaude Sonnet 5.5 e4d8570fbc Merge main into fix/villager-grows-up-brain (resolve tools/test_mob_ai_parity.py)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 04:42:41 +05:00
levzzzandClaude Sonnet 5.5 4e3a161ac6 Merge main into fix/constructor-path-maluses (resolve tools/test_mob_ai_parity.py)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 04:41:45 +05:00
levzzzandClaude Sonnet 5.5 6cd8cc4987 Merge main into fix/wither-skeleton-parity (resolve tools/test_mob_ai_parity.py)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 04:41:08 +05:00
levzzzandClaude Sonnet 5.5 30da32b83c Merge main into fix/zombified-piglin-turtle-egg-goal (resolve tools/test_mob_ai_parity.py)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 04:40:41 +05:00
d3grandoandClaude Opus 5.5 f84b88b2e9 Thrown pearls feel the water their box touches, as vanilla's isInWater
Projectile::tick chose the water inertia (0.8) from the fluid of the block
at the projectile's feet, every tick including the first. Vanilla's
ThrowableProjectile.applyInertia and AbstractHurtingProjectile.applyInertia
read Entity.isInWater (wasTouchingWater), which the previous tick's
Entity.baseTick -> updateFluidInteraction set from the deflated bounding
box after the move (EntityFluidInteraction.update: some water surface at or
above the box's bottom), and which is still false on the first tick.

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

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

Part of #3072.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 06:40:21 +07:00
levzzzandClaude Sonnet 5.5 173e278206 Merge main into fix/flyers-scan (resolve tools/test_mob_ai_parity.py)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 04:40:15 +05:00
d3grandoandClaude Opus 5.5 6b8e3b8f5f Runtime spawns run Mob.finalizeSpawn and wither skeletons hit for 4
Mobs spawned while the game runs (/summon, spawn eggs, spawners, natural
spawns, raids) go through the reduced spawning::finalize, which skipped
Mob.finalizeSpawn and WitherSkeleton.finalizeSpawn:

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

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

Part of #2783.

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 06:37:22 +07:00
Ludovic MarieandClaude Opus 5.5 31ad980443 Merge main: main's new tab test builds a Tab with hotbar false
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 00:33:49 +01:00
DUzzL 084394a849 Keep campfire damage from making mobs panic 2026-10-30 00:32:02 +01:00
Ludovic Marie e8d5059b6a Merge remote-tracking branch 'origin/main' into upd-3693 2026-10-30 00:31:02 +01: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
Ludovic Marie 12e23c20a8 Merge branch 'upd-3988' into upd2-3988 2026-10-30 00:27:42 +01:00
Federico Tagliani df19d46a5a Merge remote-tracking branch 'origin/main' into fix/ctrl-click-right-click 2026-10-30 00:27:40 +01:00
Ludovic MarieandClaude Opus 5.5 691f958bc4 Merge main: log_display serves Windows too; DisplayWatch built field by field
Main limited log_display to Linux, where only Wayland called it; the OS
poll calls it on every platform now. On macOS and Windows the struct has
only `polled`, so `..Self::default()` was clippy's needless_update.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 00:27:00 +01:00
SenjinandClaude Opus 5.5 8c079ed9d7 HDR Windows Auto: builds on Windows; the peak is never below Paper White
log_display was still Linux-only, so this didn't build on Windows (found and
fixed by martipops's test). Windows' SDR brightness slider can put white above
the reported peak (456 against a calibrated 380 on a Samsung QD-OLED): Auto's
peak is now at least its Paper White, so highlights stay at white instead of
dropping below it.

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 06:24:02 +07:00
Federico TaglianiandClaude Sonnet 5.5 d9d0bd3289 Right Click Emulation: Control + left click is a right click on macOS
The option was in Options > Controls and saved, but nothing read it.
MacosUtil.setCtrlClickEmulatesRightClick makes the click a right click, and
its release too.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 00:22:16 +01:00
Ludovic Marie b246a02f55 Merge remote-tracking branch 'origin/main' into upd-3988
# Conflicts:
#	docs/graphics.md
2026-10-30 00:22:13 +01:00
SylviromiandClaude Opus 5.5 def1dbef33 Native mods: a mod container opens a vanilla slot menu
ModContainer.menu names a plain slot grid (hopper, generic_3x3,
generic_9x1..9x6) with as many slots; using the block opens it over the
block entity's slots, through the menu code vanilla's containers use, titled
with the block's name (Block.getDescriptionId).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 02:21:47 +03:00
Ludovic MarieandClaude Opus 5.5 23c5335910 Block entity rendering: reuse the meshes' face visibility instead of loading it again
BlockEntityRenderer loaded occlusion.nbt and the render definitions a second
time and built its own FaceVisibility, whose shapes then computed their faces
again: 13.6 MB of face shapes, twice. It now takes a clone of the mesh
tables' visibility, which shares the shapes and their cached faces.
Probes and tests that load the renderer alone still load their own.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 00:18:41 +01:00
Ludovic MarieandClaude Opus 5.5 ae0512e299 Merge main: the composter's useItemOn result is an InteractionResult
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 00:17:37 +01:00
septydev c449d797bd Fix account chip text overflow 2026-10-30 01:17:01 +02:00
Ludovic Marie 8c3d85eb58 Merge remote-tracking branch 'origin/main' into upd-3642 2026-10-30 00:16:56 +01:00
oscargzss 8bbfc5cbf0 renderer: try a backend again with non-conformant adapters
wgpu hides every adapter whose driver reports Vulkan conformance version 0
unless `ALLOW_UNDERLYING_NONCOMPLIANT_ADAPTER` is set, and Mesa reports
0.0.0.0 for hasvk, which drives Ivy Bridge and Haswell. Such a GPU is told
it has no driver while Vulkan runs, and the only escape was the player's
environment variable.

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

Fixes #4059
2026-10-29 17:15:13 -06:00
Ludovic Marie 4592a7e799 Merge remote-tracking branch 'origin/main' into upd-3655
# Conflicts:
#	crates/horizon-client/src/game/remote.rs
2026-10-30 00:14:42 +01:00
Ludovic Marie 5dc59b0f6a Merge remote-tracking branch 'origin/main' into upd-3748
# Conflicts:
#	crates/horizon-client/src/game/command_blocks.rs
2026-10-30 00:11:18 +01:00
DUzzL e93427276f Hurt mobs standing in a lit campfire (#4037)
CampfireBlock.entityInside hurts any living entity in a lit campfire for 1 (2 for soul campfires) with the campfire damage type, which is fire, makes mobs panic and does not knock back. Mobs ignored campfires entirely.
2026-10-30 00:11:15 +01:00
d3grandoandClaude Opus 5.5 5550a6c327 Read the glint clock and options once per frame, not per item
Game::glint() and glint_ticks() read the clock and looked glintSpeed and
glintStrength up by name (a linear scan of the options table) for every
item submitted, so a frame with thousands of dropped items read the clock
thousands of times. Vanilla snapshots the options once per frame
(GameRenderer.extract -> extractOptions into OptionsRenderState) and
TextureTransform.setupGlintTexturing reads Util.getMillis() once per glint
render-type batch. Capture a GlintFrame at the start of each render() and
let world items, armour, held items and GUI items read it.

Fixes #3183

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 06:06:42 +07:00
Сидоренко Егор АлекчеевичandCodex 3813fba81c Reuse worker meshing scratch and bounded mesh capacity
Keep primitive scratch per worker and recycle cleared CPU mesh buffers through a count/byte-bounded worker-only pool. Cover reset, missing neighbors, visibility, pool accounting and complete geometry across resource reloads.

Fixes #4041

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-10-30 02:06:08 +03:00
d3grandoandClaude Opus 5.5 70e83d9f65 Don't assert that a zombie pushes the ridden camel
Entity.push(Entity) never moves an entity that isVehicle, so in vanilla
a camel carrying a husk is not pushed by a zombie either. The test now
checks only that the camel still pushes the zombie.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 06:04:11 +07:00
Сидоренко Егор АлекчеевичandCodex 93f42ead5b Add independent opt-in Windows allocator build features
Expose Segment Heap manifest integration and mimalloc separately, with unchanged defaults and reproduction/PE validation instructions.

Fixes #4042

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

Fixes #4039

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-10-30 02:01:27 +03:00
ElyTheCatandAntigravity 05f68f3fdc Linux: OpenGL presents uncapped with VSync off
On Linux with OpenGL (the GLES wgpu backend), wgpu-hal hard-coded its
surface capabilities present_modes to [Fifo] only (//TODO), never
offering Immediate. When VSync was turned off and Max Framerate was set
to Unlimited:
1. chosen_present_mode filtered supported modes and could only select
   Fifo since Immediate was missing from the adapter's capabilities.
2. In gfx.rs, pre_present_notify is called whenever present_mode is Fifo,
   which on Wayland requests a wl_surface.frame callback from the compositor,
   pacing RedrawRequested to the display refresh rate (60 FPS).
3. In egl.rs, eglSwapInterval(0) was never called, leaving EGL to default
   to swap interval 1 (60 FPS on X11 and Wayland).

- In wgpu-hal: report [Fifo, Immediate] present modes for GLES on all
  platforms, and call eglSwapInterval with 0 for Immediate and 1 for Fifo
  in configure and present.
- In gfx.rs: consider Immediate on startup surface configuration if
  supported, matching vsync_present_mode.
- In window.rs: add unit test for present mode choice with [Fifo, Immediate].

Co-Authored-By: Antigravity <[email protected]>
2026-10-30 02:30:12 +03:30
DUzzL 8a773bca20 Play the fall damage sound for multiplayer clients and LAN guests 2026-10-30 00:00:06 +01:00
SylviromiandClaude Opus 5.5 f57fe836c4 Native mods: item slots for mod block entities (hoppers, comparators, drops)
A mod block's block entity gets item slots when its mod says the block has
a container (native_block_entity_container: the slot count, the slots each
face reaches, and which slots take input or give output, as a
WorldlyContainer). Hoppers, droppers and comparators use them through the
container code vanilla's blocks use, breaking the block drops them
(Containers.dropContents), they save as Items, and the tick hook gets them.

Fixes #4034.

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

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

Part of #3393.

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 05:54:10 +07:00
Ludovic MarieandClaude Opus 5.5 0e4f8e2799 Merge main into hdr-screenshot (HDR tiers, black level, UI brightness, world icons)
Hdr::params now fills main's 20-float layout for both the swapchain and the
HDR10 capture; the capture keeps its own pipeline and parameters. Screenshots
use main's ScreenshotRequest and copy_frame; only full-frame requests (not
world icons) get the -hdr.png copy.

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 23:48:04 +01:00
Ludovic Marie 3c8a73f86f Merge remote-tracking branch 'origin/main' into upd-3773
# Conflicts:
#	tools/test_mob_ai_parity.py
2026-10-29 23:46:15 +01:00
Ludovic Marie f58660a875 Merge remote-tracking branch 'origin/main' into upd-3732
# Conflicts:
#	tools/test_mob_ai_parity.py
2026-10-29 23:46:12 +01:00
d3grandoandClaude Opus 5.5 c744e51084 Keep same-vehicle mobs in the cramming count, and test the camel fix
Entity.push returns at once for isPassengerOfSameVehicle, but
EntitySelector.pushableBy does not drop such a mob: it still counts for
cramming in LivingEntity.pushEntities, and Warden.doPush still runs for
it before Entity.push. push_entities now skips only the push itself.

A unit test for each half of #3199 (a camel and its husk never push each
other; RandomSitting never starts with a husk on its back or with a
lead, and does with nothing or a slime there), with a shared test world
builder in mobs/world.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 05:44:16 +07:00
d3grandoandClaude Opus 5.5 7f1edb9263 A camel and the husk on its back no longer push each other
LivingEntity.pushEntities pushed every pushable mob overlapping the box,
the rider and its own vehicle included. A husk sits half a block toward
the camel's front (Camel.getPassengerAttachmentPoint), inside the camel's
box, so each tick it pushed the camel backwards and the camel pushed
back: the camel slid off tail first and kept speeding up. Entity.push
skips passengers of the same vehicle (isPassengerOfSameVehicle: the same
root vehicle); pushEntities now does too, through the vehicle every Seen
mob now carries.

CamelAi.RandomSitting also checked only for a player on the camel's back.
Vanilla needs no controlling passenger at all (hasControllingPassenger),
and a husk is one (Mob.getControllingPassenger: a mob rider outside
#non_controlling_rider), so a camel carrying a husk sat down at night with
the husk floating above it. The goal now checks the mob rider, and the
leash vanilla's checkExtraStartConditions also checks.

Fixes #3199

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 05:44:16 +07:00
SenjinandClaude Opus 5.5 ef61ac7c6c HDR: light sources tiered from a look at each in game
Beacon, end rod, furnace and redstone lamp shine bright; the froglights,
shroomlight and every copper bulb low; the respawn anchor incandescent at
every charge. Sea pickles, brown mushrooms, the dragon egg and the end
portal frame only glow faintly, as glow lichen, and stay as in SDR.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 23:44:15 +01:00
SenjinandClaude Opus 5.5 0a72d58c25 HDR: mobs' glowing eyes shine like glowstone
The eyes render type's layers (spiders', endermen's and phantoms' eyes,
the breeze's, the creaking's, the copper golem's) carry the bright tier
in the overlay's high bits, as light-giving particles do, so the HDR mark
pass lifts them: they glow to frighten, so they shine above a torch. The
warden's fading layers and the dragon's additive eyes are left as they
are.

Part of #1441.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 23:42:13 +01:00
Claude 396ba2e5e7 Turn the player when a server sends PlayerLookAt
ClientboundPlayerLookAtPacket was decoded as an unknown packet, so
`/teleport ... facing` and `/rotate ... facing` on a server never turned
the camera. Decode it and port ClientPacketListener.handleLookAt:
Entity.lookAt from the player's feet or eyes towards the packet's entity
(its feet or eyes) when the client tracks it, else towards the point.

Part of #3518.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Rx5M6NS763D2WVnb2HmcTH
2026-10-29 22:42:00 +00:00
ElyTheCat 0239b3c97f Merge branch 'main' into fix/armor-rendering 2026-10-30 02:09:12 +03:30
SenjinandClaude Opus 5.5 decdf49937 HDR: the moon shines at the low tier, not the sun's
The sun and the moon marked the frame alike, so the moon sat in the
sun's tier, whose lift window only its brightest pixels reach: it hardly
lifted at all. Each body's quads now carry their tier in the overlay's
high bits (as particles do), and fs_sky_celestial writes that tier's
mark: the sun's as before, the moon's low.

Part of #1441.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 23:38:38 +01:00
Ludovic MarieandClaude Opus 5.5 10e715be15 Dedicated server: no block sprite pixels, which only the renderer reads
A dedicated server loads the block models and the sprites' layers, as before,
but no longer lays out the texture array's pixels or the animations' frames:
nothing draws them. The integrated server shares the window's resources and
is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 23:36:59 +01:00
d3grandoandClaude Opus 5.5 a8b5151ce5 Controller toggle-sneak comes back after a screen closes
The pad's toggle-sneak flipped sneaking with a hold-mode Shift press or
release, so KeyMapping.releaseAll dropped it when a screen opened and
restoreToggleStatesOnScreenClosed had nothing to restore. ToggleKeys now
has a pad toggle: it flips the sneak mapping as ToggleKeyMapping.setDown
does with its option on, and while it holds sneaking the mapping toggles
(released and remembered by a screen, restored when it closes, a shift
release behind the screen ignored), as vanilla's toggle crouch does.

Part of #3061.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 05:34:50 +07:00
d3grandoandClaude Opus 5.5 5b3b108515 A small player's server box follows minecraft:scale
The server's copy of a player never ran LivingEntity.refreshDirtyAttributes,
so after /attribute ... minecraft:scale it kept the old 0.6 x 1.8 box until
the pose changed: a small player was sent back from every wall they walked
along and suffocated next to one. guest_server_tick now runs aiStep's spin
countdown, refreshDirtyAttributes (onAttributeUpdated(SCALE) ->
refreshDimensions) and updatePlayerPose in vanilla's order, and in_wall
takes the eye box from the body's width (Entity.isInWall, width * 0.8)
instead of a fixed 0.48.

Part of #3684.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 05:34:25 +07:00
SylviromiandClaude Opus 5.5 27c3c0be31 Native mods: the block entity hooks in the hook catalog
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 01:28:25 +03:00
Sylviromi 52399eb28a Merge main into native-mod-block-entities: the hook catalog 2026-10-30 01:27:37 +03:00
d3grandoandClaude Opus 5.5 dff6db1e86 Hide the own body when the server sets the player's invisible flag
Entity.isInvisible reads shared flag 5, which a server sets with the
Invisibility effect or alone through Player.setInvisible, as pose plugins
(GSit's lay, crawl, spin) do to hide the real body under the pose. The client
read the flag for other entities only and hid its own body, cape, first-person
arm and name tag (and thinned its effect swirls) only for the effect, so the
real body showed under the pose.

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

Fixes #3788

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 23:23:20 +01:00
Ludovic MarieandClaude Opus 5.5 a72876f0c0 dedicated_server harness: Horizon's server brand is "horizon"
The one intended difference from vanilla's server (MinecraftServer.getServerModName):
the harness checks Horizon sends brand=horizon, then compares the rest as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 23:22:02 +01:00
Federico Tagliani 39314175e9 Merge remote-tracking branch 'origin/main' into fix/wind-burst-smash 2026-10-29 23:21:58 +01:00
Federico TaglianiandClaude Sonnet 5.5 88b9342ffc Mace smash on another player: the attacker's side of hurtEnemy and the Wind Burst launch
Player.itemAttackInteraction ran only for a mob victim, so smashing a
player never stopped the fall, never fired the Wind Burst explosion on the
attacker and never wore the weapon.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-29 23:21:57 +01:00
DUzzL 21a033cea0 Trial Omen and Bad Omen of a server's players make trial spawners ominous 2026-10-29 23:21:28 +01:00
ElyTheCatandAntigravity f3a3c1291b Render: custom head items and multi-layer equipment
In vanilla Minecraft (26.3), items equipped in the head slot without an
armour asset (such as carved pumpkins, player heads, and mob skulls) are
rendered via CustomHeadLayer with ItemDisplayContext.HEAD transform
(down 0.25, rotated 180 degrees, scaled 0.625; skulls scaled 1.1875) for
both the local player, remote players/guests, and humanoid mobs.

Additionally, equipment assets can specify multiple layers under their
layer kinds (e.g. equipment/leather.json has layer 0 leather and layer 1
leather_overlay). Horizon previously only parsed the first layer index 0,
causing overlays like leather_overlay to be dropped.

- ArmorStandAssets: load all humanoid and wings layers in equipment JSON
  definitions into a list of layers.
- In armor_layers and armor_stand_geometry: render all humanoid layers
  for equipped items in order, including non-dyeable overlays.
- custom_head_geometry: implement CustomHeadLayer for worn non-armour
  head items and skulls, drawing worn skulls with their special model
  (including player profile skin texture) and items (e.g. carved pumpkin)
  via the item rendering pipeline.
- Hook custom_head_geometry into player model rendering, inventory preview,
  remote players/mobs, and LAN guests.
- Update port ledger mappings for CustomHeadLayer and EquipmentLayerRenderer.

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

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

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

Fixes #3866

Co-Authored-By: Claude <[email protected]>
2026-10-30 01:17:39 +03:00
SylviromiandClaude Opus 5.5 da98be3276 Native mods: a save leaves out a mod block entity mid-tick
Review of #3996: while native_block_entity_tick runs, the block entity
at its position is an empty-id placeholder; a save then skips it rather
than writing `id: ""`. The hook's doc says so, and that clients (the
window's own in single player too) don't get mod block entities yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 01:14:23 +03:00
Joansitoh acce84e884 Resource packs: create overlay junctions natively and load models and GUI atlas side by side
Each untouched directory of the Windows overlay was a `cmd /C mklink /J` process,
about 100 ms apiece: 34 of them were 3.4 s of a 4.3 s overlay for a 44-file server
pack. The junction is now an FSCTL_SET_REPARSE_POINT on an empty directory, in
horizon-threads (render forbids unsafe code). The world's models and sprites, the
GUI atlas and the panorama read the same root and nothing of each other, so the
reload now builds them on three threads.
2026-10-29 23:14:15 +01:00
d3grandoandClaude Opus 5.5 a18c9206d9 Falling sand and gravel ride bubble columns
FallingBlockEntity.tick runs Entity.applyEffectsFromBlocks after its move,
and BubbleColumnBlock.entityInside lifts or drags it like any other entity
(Entity.handleOnAboveBubbleColumn / handleOnInsideBubbleColumn). Horizon's
fall_step only looked for cobwebs on the path, so sand dropped into a soul
sand column sank to the bottom.

The cobweb sweep now visits each block checkInsideBlocks reaches once, with
vanilla's intersects flag (every block of a move a block or longer, else
only those the box ends in), and a bubble column there pushes the block and
resets its fall below the surface. The bubble column lookup and push are
split out of bubble_column_push so items and mobs share them. Velocity is
zeroed on collided axes before the effects, as Entity.move does.

Part of #3393

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 05:12:39 +07:00
DUzzL ad9f9a66f6 LAN guests and single-player windows pick up arrows they touch (#3997) 2026-10-29 23:11:26 +01:00
levzzz e9b2bdef24 Merge remote-tracking branch 'hr/main' into launcher/native-builds-stay-put
# Conflicts:
#	launcher/src/native_mods.rs
2026-10-30 03:07:52 +05:00
Ludovic MarieandClaude Opus 5.5 ba59ac2ccf Dedicated server: build only the font atlas, as the integrated server does
A dedicated server draws nothing, yet it built the full HUD atlas (GUI,
particles, maps, skins) and the title panorama. The integrated server's
game already holds only the font, which books and chat measure with, and a
one-pixel panorama; both now come from UiResources::server, and the
dedicated server's synchronous load uses it too.

Idle, fresh world, 3 interleaved runs: RSS 836/836/841 -> 771/792/765 MB,
ready 5.05 -> 4.32 s (median).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 23:06:19 +01:00
Federico TaglianiandClaude Sonnet 5.5 7ca3671df3 tests: the remote_motion fixture knows the hook motion method
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-29 23:02:36 +01:00
SylviromiandClaude Opus 5.5 967ec25579 Native mods: block entities for mod blocks (hook points API 3)
A block entity whose id is a mod's loads as BlockEntity::Mod (its id and
saved compound) instead of staying raw chunk data, and saves back as it
was. Three hook points let a native mod give its blocks block entities
(native_block_entity), say which tick (native_block_entity_ticks) and
tick them on the server in vanilla's order (native_block_entity_tick).
Player placement, /setblock, dispensers and horizon-ctl make them through
one Game::new_block_entity.

Fixes #3966.

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

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

Fixes #2786

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 04:59:05 +07:00
d3grandoandClaude Opus 5.5 0ce65d34c6 Both mouse buttons at once replace the block in creative
Minecraft.tick picks the crosshair's hitResult once, before
handleKeybinds, and startAttack, startUseItem, continueAttack and
pickBlockOrEntity all read that one result. Horizon re-picked for every
click, so after a creative START predicted the break, the use in the
same tick aimed past the broken block (at nothing, or the block behind).
InteractionInput::tick now pins the tick's hit result around the clicks.

The server also deferred a creative START to its guest tick, after the
UseItemOn that followed it in the same batch, which then placed against
the old block before it broke. ServerPlayerGameMode.handleBlockBreakAction
destroys at once for an instabuild player (destroyAndAck "creative
destroy"); the host now does too.

Fixes #3006

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 04:58:07 +07:00
Federico Tagliani 379ec36d52 Merge remote-tracking branch 'origin/main' into fix/fishing-hook-smooth 2026-10-29 22:55:59 +01:00
Federico TaglianiandClaude Sonnet 5.5 7f70737138 Fishing hook on a server's client: its own movement between the tracker's updates
FishingHook.tick runs on a client, easing towards the last update while it
flies, floats and rides what it hooked. Horizon only eased towards the
updates, which come every five ticks, so the bobber stood still for two
ticks of five and jumped.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-29 22:55:58 +01:00
Ludovic MarieandClaude Opus 5.5 18da9c988a docs/server-next.md: beat Pumpkin on idle memory and start too (< 32 MB, < 0.1 s), and how
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 22:50:14 +01:00
Ludovic MarieandClaude Opus 5.5 86ebc77e25 docs/server-next.md: the next server's design, measured against vanilla and Pumpkin
Horizon's dedicated server, vanilla 26.3 and Pumpkin 0.2.1 (black box) with
the same horizon-bots at 10/50/100 players; numeric targets; everything in
this repo, shared by the integrated server, LAN and --dedicated; parallel
ticking that keeps vanilla's order; the network front, tracker deltas,
off-thread saving; the Horizon fast path; online mode on by default; the
plugin API (with levzzz5154); the plan and the open PRs on the theme.

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 04:45:47 +07:00
DUzzL 2017653aff bench: one script that runs Horizon, vanilla or Pumpkin under the same horizon-bots load 2026-10-29 22:44:39 +01:00
d3grandoandClaude Opus 5.5 b80c1a9eef lan_e2e: cushions check covers the placing swing and changing seats
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 04:41:45 +07:00
d3grandoandClaude Opus 5.5 682f8a20fa Cushions swing the arm when placed and let a seated player change seats
CushionItem.useOn returns SUCCESS, so the hand swings: place_cushion now
swings, and a client of a server predicts the same SUCCESS once
Cushion.canBePlacedAt holds (the placement checks are split out as
cushion_spawn for both). Cushion.interact sits the player down through
Entity.startRiding, which first stops riding the current vehicle, so a
player on one cushion moves to another instead of being refused.
LivingEntity.stopRiding off a cushion now plays the get-up sound and
puts the rider beside it (shared with getting up on sneak).

Fixes #3117

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 04:41:35 +07:00
SenjinandClaude Opus 5.5 4b004cf7d6 HDR: Auto on Windows, from the luminances the OS reports
Where no Wayland compositor answers, Auto takes what wgpu reads from the OS
(Surface::display_hdr_info): on Windows DXGI's MaxLuminance as the peak and the
SDR white level of the SDR content brightness slider as Paper White, both in
absolute nits, HDR output's scale there. No event says they changed, so they're
read again every 2 seconds while HDR output is on, and logged once per change.
Untested on Windows so far.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 22:40:32 +01:00
AmadeusandOpenAI Codex 79360c5867 Reduce sunrise fan intersection work with projected sector selection
Keep the full barycentric reference and seam fallbacks; add CPU and GPU equivalence checks plus shader-pack helper isolation coverage.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-30 00:37:45 +03:00
SenjinandClaude Opus 5.5 7b98a22fe1 HDR shortcut: Done goes back to the game, not the pause menu
The key opens Graphics: HDR straight from the game, so closing it returns
there and takes the mouse again (options_to_game, like
options_to_world_options for Chat Settings).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 22:35:10 +01:00
SenjinandClaude Opus 5.5 0a8ac2e89c HDR: a shortcut key to its settings, unbound by default
Graphics: HDR gets a Shortcut row, bound like the sort key on Extras: click it,
press a key or mouse button (Escape unbinds). The key lives in
horizon-graphics.txt as hdrKey, never in options.txt, which stays vanilla's.
Pressed in the game where the display offers HDR, it opens Graphics: HDR
straight away, and Done goes back to the game: tune paper white and peak while
looking at the world.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 22:35:10 +01:00
unknownandOpenCode Agent 89b737d88a Streaming: light and mesh jobs bounded in flight, nearest first
The light and mesh stages of Streaming::update submitted every ready
column's jobs at once, in hash order. At a wide render distance the
queue - and lighting, and meshing, and each job's 3x3 region snapshot -
grew with the number of loaded columns, requeue re-ranked that whole
heap under the queue lock each time the player crossed a chunk border,
and the workers ground through jobs whose columns were long gone,
their results dropped only after the work was done (#3629: at render
distance 1000 the backlog survived lowering the distance, and chunks
around the player waited behind it).

Both stages now hand the workers only as much as they can chew, chosen
from everything that is ready each update, nearest first: what stays
dirty is asked for again, with the current centre, as capacity frees -
the way vanilla's chunk task queues hold bounded per-worker work and
follow the current ticket levels. Generation (workers * 3) and
decoration (workers * 2) already had such bounds.

Fixes #3629

Co-Authored-By: OpenCode Agent (GLM) <[email protected]>
2026-10-30 00:24:28 +03:00
Joansitoh f4c6f8dc29 Skins and maps upload again after a resource pack reload
Refs #3122, #3694
2026-10-29 22:24:22 +01:00
Kacpherek c1de70edd4 Keep a bred baby fox or goat free of the spawn finalize extras
Breeding spawns the baby through spawning::finalize, which now gives a fox a mouth item and a goat a lost horn and a random screaming roll; Fox.getBreedOffspring and Goat.getBreedOffspring run no finalizeSpawn (Fox.java, Goat.java). The baby fox is emptied, the baby goat keeps both horns and gets a parent's screaming or one chance in fifty.
2026-10-29 22:19:35 +01:00
Kacpherek 149c4b87f8 Reopen the chat with its kept draft
T and / always opened the chat with the caller's text. ChatComponent.createScreen (ChatComponent.java:442) restores the saved draft, and ChatScreen.removed (ChatScreen.java) keeps it on Escape when Save Chat Drafts is on.
2026-10-29 22:19:26 +01:00
Kacpherek a6b3066f85 Kick guests who spam chat or commands
A dedicated server never kicked a spamming guest: chat-spam-threshold-seconds and command-spam-threshold-seconds were read but unused. Vanilla adds 20 per message or command, decays one a tick and disconnects over the threshold with disconnect.spam, except for operators (ServerGamePacketListenerImpl.detectRateSpam, TickThrottler.java:23).
2026-10-29 22:19:23 +01:00
Kacpherek 08918a8ab0 Load a world's own resource pack when the world opens
A save's resourcepacks/resources.zip was never loaded, so its textures were missing. Vanilla WorldOpenFlows.loadBundledResourcePack pushes it as a local pack (ServerPackManager.pushLocalPack, ServerPackManager.java:61) before the world connects.
2026-10-29 22:19:23 +01:00
Kacpherek fe08d02d5d Hold player chat back while Chat Delay is set
Player and disguised chat showed at once, so Chat Delay was ignored. ChatListener.handleMessage, tick and flushQueue (ChatListener.java:75-120) queue the lines and show one per delay.
2026-10-29 22:19:23 +01:00
Kacpherek 359e83b338 Step option cycle buttons with the mouse wheel
The wheel over an option button only scrolled the list. CycleButton.mouseScrolled (CycleButton.java:115) cycles its value, up going back, and the list does not move.
2026-10-29 22:19:19 +01:00
Kacpherek 8b17c0383e Make foxes from snowy biomes snow foxes on natural spawns
Natural and spawner foxes were always red; vanilla Fox.Variant.byBiome picks the snow variant from spawns_snow_foxes (Fox.java, Fox.Variant).
2026-10-29 22:19:10 +01:00
Kacpherek 407dd201f8 Swap another player's held items when it swaps hands
Entity event 55 was ignored, so another player's main and off hand kept their items after a swap on the server; vanilla LivingEntity.handleEntityEvent calls swapHandItems (LivingEntity.java).
2026-10-29 22:19:09 +01:00
Kacpherek 889931c0b9 Let natural foxes hold food or a feather in their mouth
Only structure mobs ran Fox.populateDefaultEquipmentSlots, so a fox from a natural or spawner spawn never held an emerald, egg, rabbit foot or the like (Fox.java).
2026-10-29 22:19:09 +01:00
Kacpherek 509536179d Give natural goats their screaming, lost horn and ram cooldowns
Only structure mobs and the probe ran Goat.finalizeSpawn, so a goat from a natural or spawner spawn never screamed, lost a horn or had its ram cooldowns (Goat.java, GoatAi.initMemories).
2026-10-29 22:19:09 +01:00
Kacpherek a9d2ae39d9 Call only the pets of the same owner when a tamed mob is hurt
HurtByTargetGoal.alertOthers called every nearby wolf, including other owners' and the attacker's own tamed pets; vanilla checks TamableAnimal.getOwner and considersEntityAsAlly (HurtByTargetGoal.java, TamableAnimal.java).
2026-10-29 22:19:04 +01:00
Kacpherek 9d44aa4ba0 Stop a tamed unsaddled skeleton horse from taking steering from its rider
A tamed skeleton horse obeyed its rider without a saddle; AbstractHorse.getControllingPassenger (AbstractHorse.java) needs the saddle, and SkeletonTrapGoal only tames the trap horse.
2026-10-29 22:19:04 +01:00
Kacpherek 0c34e3f36c Refill axolotls' air from a splashed water potion
A splashed water bottle did nothing to axolotls; AbstractThrownPotion.affectEntitiesAround (AbstractThrownPotion.java) rehydrates each axolotl in the box, adding 1800 air up to the maximum.
2026-10-29 22:19:03 +01:00
Kacpherek 4049220715 Make a horse thud when it lands instead of the generic fall sound
A landing horse played the generic fall sound and no thud; AbstractHorse.causeFallDamage (AbstractHorse.java) plays entity.horse.land (baby variant for babies) at 0.4 for falls over one block.
2026-10-29 22:19:03 +01:00
Kacpherek 1139057d3e Play the camel saddle sound when a camel is saddled by hand
Saddling a camel with a saddle in hand played the horse sound; Camel.getSaddleSound (Camel.java) is the camel's saddle equip sound.
2026-10-29 22:19:03 +01:00
Kacpherek 9f3cba308c Send a flap game event from a flying bee every other tick
A bee in the air never gave off its flap vibration, so sculk sensors never heard it; Entity.processFlappingMovement with Bee.isFlapping (Bee.java) emits minecraft:flap.
2026-10-29 22:19:03 +01:00
Kacpherek 80b68bae0c Hold a sitting camel's rotation under its rider
A camel that refuses to move (sitting or changing pose) still turned with its rider's view; Camel.getRiddenRotation (Camel.java) keeps its rotation.
2026-10-29 22:19:03 +01:00
Kacpherek 48efd53e00 Breed axolotl babies with a random parent's colour and a rare blue
Axolotl.getBreedOffspring took the first parent's colour, never blue and never persistent; vanilla rolls one in 1200 for blue, else picks either parent and sets the baby persistent (Axolotl.java).
2026-10-29 22:19:03 +01:00
Kacpherek c7440b78d5 Hang a bee's lead at half its eye height
The lead hung at the default leash offset; Bee.getLeashOffset (Bee.java) puts it at half the eye height, a fifth of the width ahead.
2026-10-29 22:19:03 +01:00
Kacpherek 0703ae6bd4 Keep a mannequin's profile when it is saved and loaded
A saved mannequin lost its profile, so after a reload it wore the default skin. Vanilla saves and reads it as ResolvableProfile (Mannequin.addAdditionalSaveData / readAdditionalSaveData, Mannequin.java:126 and :144).
2026-10-29 22:19:02 +01:00
Kacpherek ec6ea46499 Break item frames caught in an explosion
A blast never reached item frames, so they stayed up and kept their item. Vanilla kills a non-fixed frame in reach and drops it with its item (BlockAttachedEntity.hurtServer, ServerExplosion.hurtEntities; ItemFrame.java:173).
2026-10-29 22:19:02 +01:00
Kacpherek 62c4d91aa5 Send the hat layer change to every player's tab list
A guest who toggled the hat skin layer was not announced: vanilla sends UPDATE_HAT to all players in handleClientInformation (ServerGamePacketListenerImpl.java:2180), and the join entry now carries the hat state too.
2026-10-29 22:19:02 +01:00
Kacpherek d5d42c8a5f Let ravagers trample crops they walk through
Ravagers only broke leaves, so crops in their path survived; CropBlock.entityInside (CropBlock.java:153) destroys a crop a ravager's box enters when mobGriefing is on.
2026-10-29 22:19:02 +01:00
Kacpherek 765ed5d0c2 Fire the thrown-item pickup advancement for the player
Picking up an item the player threw never granted thrown_item_picked_up_by_player, so You've Got a Friend in Me was unreachable; ServerPlayer.onItemPickup (ServerPlayer.java:2324) fires it with the stack left after the take.
2026-10-29 22:19:02 +01:00
Kacpherek a63b69c300 Fire thrown-item pickup advancement for piglins
A piglin taking gold you threw never granted thrown_item_picked_up_by_entity, so Oh Shiny stayed locked; LivingEntity.onItemPickup (LivingEntity.java:3429) fires it for the taker, before the stack is split.
2026-10-29 22:19:02 +01:00
Kacpherek 126f997703 Make live shulkers solid for the player
Walking ignored live shulkers, so the player passed through them; Shulker.canBeCollidedWith (Shulker.java:487) returns isAlive and Entity.canCollideWith uses it.
2026-10-29 22:19:02 +01:00
Kacpherek 6befa8622d Scale the LAN host's entity tracking by the Entity Distance option
The integrated host ignored entityDistanceScaling, so guests saw entities at the default range; IntegratedServer.getScaledTrackingDistance (IntegratedServer.java:451) scales each tracking range by it.
2026-10-29 22:19:02 +01:00
Kacpherek 4fe17c268c Show spectating guests as invisible to others
A guest in spectator mode sent no invisible flag, so others saw a full model; ServerPlayer.updateInvisibilityStatus (ServerPlayer.java:2054) sets it for every spectator.
2026-10-29 22:19:02 +01:00
Kacpherek 1474b118b4 Break a straw bed when its sleeper gets up in the Overworld
A straw bed stayed in place after its sleeper woke up, but the Overworld's straw_bed_rule has destroy_on_leave, so vanilla breaks it (AbstractBedBlock.java:119 onStopSleeping, StrawBedBlock.java destroyBed).
2026-10-29 22:18:57 +01: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 4fcaff9935 Give a water bucket back for a tropical fish bucket fed to an axolotl
Feeding an axolotl a tropical fish bucket used the whole item; vanilla hands back a water bucket (Axolotl.usePlayerItem in Axolotl.java).
2026-10-29 22:18:54 +01:00
Kacpherek 0554db3906 Calm neutral mobs when the player who angered them dies
With forgive_dead_players on, wolves, bees, polar bears and other neutral mobs kept their anger after the player died. ServerPlayer.tellNeutralMobsThatIDied (ServerPlayer.java:991) makes mobs within 32 by 10 blocks run NeutralMob.playerDied, which stops the anger at that player (NeutralMob.java:122).
2026-10-29 22:18:54 +01:00
Kacpherek e94f1917a7 Give bred goats their cooldowns and screaming inheritance
A bred baby goat started without ram or long jump cooldowns and was never screaming. Goat.getBreedOffspring (Goat.java:152) runs GoatAi.initMemories, then takes screaming from a random parent or rolls 2 percent.
2026-10-29 22:18:54 +01:00
Kacpherek 713b2774aa Swim turtles with their own water travel
Turtles used the generic fluid travel in water (0.02 speed, 0.8 drag). Turtle.travelInWater (Turtle.java:266) moves at 0.1 with 0.9 drag and sinks slowly unless it has a target or is going home near its home.
2026-10-29 22:18:54 +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 882e3a3005 Run the click event of text in dialog messages
Clicking styled text in a dialog message or item description did nothing. DialogBodyHandlers.java:54 and :108 run the text's click event through DialogScreen.runAction.
2026-10-29 22:18:51 +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 d684cafca1 Read the saved version id and name as LevelVersion.parse does
The downgrade check compared Data.DataVersion; vanilla LevelVersion.parse takes Version.Id (no Version reads as 0, no Id as the current one) and names a Version without a Name as the current version. Both world checks now share that reading, and the play doc lists the new outcomes.
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 20ad245e02 Ask for a backup before opening a world saved by a newer version
Horizon opened a newer world without asking. WorldOpenFlows.openWorldCheckVersionCompatibility (WorldOpenFlows.java:352) asks for a backup first, with the red downgrade question (BackupStatus.DOWNGRADE in LevelSummary.java).
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 6840b57201 Show the incompatible version alert instead of opening such a world
Horizon opened a world from another version series without a check. WorldOpenFlows.openWorldCheckVersionCompatibility (WorldOpenFlows.java:352) shows the red Incompatible version AlertScreen and keeps the world closed.
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 f31e30a081 Select the word under a double click on a book page
A double click on a book page only moved the caret, so no word was selected. Vanilla MultiLineEditBox.onClick and MultilineTextField.selectWordAtCursor (MultiLineEditBox.java:89, MultilineTextField.java:184) select the word before the cursor.
2026-10-29 22:18:46 +01:00
Kacpherek 913ad15462 Let Ctrl plus the mouse wheel change the GUI scale on the Video page
The wheel only scrolled the list there, and Ctrl was ignored. VideoSettingsScreen.mouseScrolled (File.java:263) steps guiScale by one and resets the list to the top instead.
2026-10-29 22:18:46 +01:00
Kacpherek 1976c27870 Join the selected world with Enter or Space on the world list
Enter and Space on a selected world did nothing, so keyboard players could not join from the list; vanilla WorldListEntry.keyPressed joins on selection keys (WorldSelectionList.java:602).
2026-10-29 22:18:46 +01:00
Kacpherek 7af0a21f2d Join the typed server address on Enter in Direct Connection
Enter did nothing on Direct Connection; vanilla DirectJoinServerScreen.keyPressed selects the server when the Join button is active (DirectJoinServerScreen.java:32).
2026-10-29 22:18:46 +01:00
Kacpherek 7a2121d84f Create the world on Enter in Create New World
Enter only confirmed a world rename, so it did nothing on Create New World; vanilla CreateWorldScreen.keyPressed calls onCreate on confirmation (CreateWorldScreen.java:360).
2026-10-29 22:18:46 +01:00
Kacpherek 3d5407ab11 Show the ghost recipe item tooltip on hover
Hovering a ghost ingredient or result in a crafting grid or furnace showed nothing; vanilla GhostSlots.extractTooltip shows the ghost item for the hovered slot (GhostSlots.java:65).
2026-10-29 22:18:46 +01:00
Kacpherek ef890e667f Show the item tooltip on hover in the statistics Items tab
Hovering an item icon in the Items tab showed nothing; vanilla ItemRowWidget.extractTooltip draws the item tooltip at the slot's corner (StatsScreen.java:512).
2026-10-29 22:18:46 +01:00
Kacpherek b5bcf031e5 Name a shield after its banner base colour
A shield with a banner base colour showed as plain Shield; vanilla ShieldItem.getName uses item.minecraft.shield.<colour> (ShieldItem.java:12).
2026-10-29 22:18:46 +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 1e3f05fe8c Make the spear-ai-golem fixes build on current main 2026-10-29 22:18:23 +01:00
Kacpherek 45e1d44be2 Draw the candle snuff smoke on the clicking client
In single player the server side skips the wick smoke, so the client draws it itself, as CandleBlock.useItemOn does on the client (AbstractCandleBlock.extinguish).
2026-10-29 22:15:19 +01:00
Kacpherek e0a1b471ff Draw the block in a copper golem's antenna
The block item in a copper golem's antenna slot was never drawn. BlockDecorationLayer.submit (BlockDecorationLayer.java:33) draws it at CopperGolemModel.applyBlockOnAntennaTransform (CopperGolemModel.java:311).
2026-10-29 22:15:13 +01:00
Kacpherek 131458ee0f Play the head's sound from a note block under a custom head
A note block with a player head on top always clicked, ignoring the head's note sound. NoteBlock.getCustomSoundId (NoteBlock.java:167) takes the sound from the skull above; no skull or sound means silence.
2026-10-29 22:15:13 +01:00
Kacpherek 90db82ba0e Hold a witch's potion at her nose instead of in her hand
A drinking witch's potion was drawn in the hand with no potion contents. WitchItemLayer.applyTranslation (WitchItemLayer.java:17) holds the potion at the nose as a potion item.
2026-10-29 22:15:13 +01:00
Kacpherek d21886b2f3 Snuff a lit candle with an empty hand
An empty hand on a lit candle did nothing; only arrows lit them. CandleBlock.useItemOn (CandleBlock.java:75) calls AbstractCandleBlock.extinguish: unlit, wick smoke, candle extinguish sound, block_change.
2026-10-29 22:15:13 +01:00
Kacpherek f250e84ecf Refuse item counts above the max stack in item commands
item replace/fill/override accepted counts up to 99 above an item's max stack. ItemInput.createItemStack (ItemInput.java:21) throws arguments.item.overstacked instead.
2026-10-29 22:15:05 +01:00
Kacpherek a1454e00a2 Show the entity tooltip for chat hover events with advanced tooltips
Hovering a show_entity chat part showed nothing. With advancedItemTooltips on, vanilla lists the name, the entity type and the UUID (GuiGraphicsExtractor.componentHoverEffect; HoverEvent.java:84).
2026-10-29 22:15:05 +01:00
Kacpherek 994820e697 Hold a spyglass to the eye for other players
A spyglass in use was drawn in the hand, not at the eye. PlayerItemInHandLayer.renderItemHeldToEye (PlayerItemInHandLayer.java:40) clamps the head pitch to -30 to 90 degrees and holds the item on the head.
2026-10-29 22:15:05 +01:00
Kacpherek 75b37506ab Draw skulls worn in the mob head slot
Zombie, skeleton, creeper and player heads in a mob's head slot were not drawn. CustomHeadLayer.submit (CustomHeadLayer.java:44) puts the skull model on the head, and resolveSkullRenderType (CustomHeadLayer.java:69) takes a player head's skin from its profile.
2026-10-29 22:15:05 +01:00
Kacpherek 8ef51c4b69 Sweep a brush in use like vanilla
A brush in use was held still. FirstPersonHandsAndItemsRenderer.applyBrushTransform (FirstPersonHandsAndItemsRenderer.java:286) swings it back and forth over the use.
2026-10-29 22:15:05 +01:00
Kacpherek c2bd40f270 Show the pointing hand over chat insertion text while Shift is held
Hovering a chat part with an insertion never asked for the pointing hand; vanilla requests it in ChatComponent.java:672 when Shift is held (ChatScreen passes insertionClickMode).
2026-10-29 22:15:05 +01:00
Kacpherek 245ea982b9 Show oak plank crumbs when an armor stand breaks
A stand broken by a creative hand or the second hit made no plank particles; ArmorStand.showBreakingParticles sends ten oak plank block particles from the box (ArmorStand.java:369).
2026-10-29 22:15:04 +01:00
Kacpherek 6528d566a1 Hide spell particles near the eye while scoping with a spyglass
Spell particles stayed visible in the spyglass view; SpellParticle.isCloseToScopingPlayer sets their alpha to 0 within 3 blocks of the eye in first person while isScoping (SpellParticle.java).
2026-10-29 22:15:04 +01:00
Kacpherek 89d4b1ce31 Tilt the iron golem body side to side while it walks
Only the arms and legs swung; IronGolemRenderer.setupRotations also rotates the body about Z by 6.5 degrees of a triangle wave of the walk position.
2026-10-29 22:15:04 +01:00
Kacpherek 2010ef412f Draw the wool undercoat on a white jeb_ sheep
A white sheep named jeb_ had no undercoat; SheepWoolUndercoatLayer.submit draws it for isJebSheep as well as for a dyed wool.
2026-10-29 22:15:04 +01:00
Kacpherek 0e42a29fda Cycle the wool of a jeb_ sheep through the dye colours
A sheep named jeb_ kept its dyed colour; SheepRenderState.getWoolColor fades it through ColorLerper.getLerpedColor(SHEEP) (SheepRenderer.java, MobRenderer.checkMagicName).
2026-10-29 22:15:04 +01:00
Kacpherek 56b327bd94 Puff red dust when a lever is switched on
Switching a lever on showed no dust puff at the lever. LeverBlock.useWithoutItem (LeverBlock.java:56) calls makeParticle at full scale on the clicking client when the lever ends up powered.
2026-10-29 22:15:01 +01:00
Kacpherek f6df8b8339 Show the red dust burst when a redstone ore is touched
Hitting, stepping on or right-clicking a redstone ore lit it with no particles. RedStoneOreBlock.interact (RedStoneOreBlock.java:61) calls spawnParticles each time, lit or not.
2026-10-29 22:15:00 +01:00
Kacpherek 6529206304 Keep the light level when a light block is picked
Pick-block on a light gave a level 0 light. LightBlock.getCloneItemStack and setLightOnStack (LightBlock.java:100,104) put the level into the item's block state.
2026-10-29 22:14:42 +01:00
Kacpherek 88dc2c5f8d Make the piglin and hoglin fixes build on current main 2026-10-29 22:14:32 +01:00
Kacpherek bb7b1b27ad Play the dried ghast place sound when it is put down
Placing a dried ghast was silent. DriedGhastBlock.setPlacedBy (DriedGhastBlock.java:189) plays block.dried_ghast.place, or place_in_water when waterlogged.
2026-10-29 22:14:25 +01:00
Kacpherek a68a897af5 Break cushions struck by lightning
A bolt's box left cushions intact. Cushion.thunderHit (Cushion.java:138) kills the cushion and drops its item, as break_cushion does.
2026-10-29 22:14:25 +01:00
Kacpherek 296fd3d839 Break one turtle egg at a time when a survival player mines a stack
Mining a stack of 2 to 4 turtle eggs removed the whole stack; TurtleEggBlock.playerDestroy (TurtleEggBlock.java:138, decreaseEggs) plays the break and keeps the rest.
2026-10-29 22:14:25 +01:00
Kacpherek 33f3444a49 Chain a hanging sign onto a wall hanging sign
Right-clicking the side of a wall hanging sign with a hanging sign opened the sign instead of chaining. WallHangingSignBlock.useItemOn passes the use on when the hit is not on the editable faces (WallHangingSignBlock.java:53).
2026-10-29 22:14:25 +01:00
Kacpherek 2711a4a005 Break pointed dripstone hit by a fast thrown trident
A thrown trident faster than 0.6 that stuck in pointed dripstone left it standing. SpeleothemBlock.onProjectileHit destroys it, reading the deltaMovement from before the hit (SpeleothemBlock.java:181, AbstractArrow.onHitBlock).
2026-10-29 22:14:25 +01:00
Kacpherek c37cab6b6a Show vault connection particles for every connected player
Only the local player got the connection particles from a vault, so guests connected to it saw none. VaultBlockEntity.Client.emitConnectionParticlesForNearbyPlayers loops over every connected player (VaultBlockEntity.java:172).
2026-10-29 22:14:24 +01:00
Kacpherek cbf57eb6ee Keep a copper golem statue's pose when it is picked
Middle-click on a statue gave it standing, whatever its pose. CopperGolemStatueBlock.getCloneItemStack sets the block_state component to the pose (CopperGolemStatueBlockEntity.getItem, CopperGolemStatueBlock.java:134).
2026-10-29 22:14:24 +01:00
Kacpherek e1c7a297bc Pick a decorated pot with its sherds
Middle-click on a decorated pot gave an empty pot. DecoratedPotBlock.getCloneItemStack returns createDecoratedPotInstance(decorations), so the sherds come with the pick; the pot's item only comes with include_data (DecoratedPotBlock.java:211).
2026-10-29 22:14:24 +01:00
Kacpherek bc3161c619 Ring a bell hit by a wind charge burst
A wind charge burst that reached a bell sent a use on its top face, which the bell rejected, so it stayed silent. BellBlock.onExplosionHit calls attemptToRing with no direction, so the bell rings on its facing (BellBlock.java:197).
2026-10-29 22:14:24 +01:00
Kacpherek dd649971c8 Pick the potted plant instead of an empty flower pot
Middle-click on a flower pot with a plant gave an empty pot. FlowerPotBlock.getCloneItemStack returns new ItemStack(potted) when the pot is not empty (FlowerPotBlock.java:115).
2026-10-29 22:14:24 +01:00
Kacpherek 50a1911cea Play the creaking heart idle sound at night with logs on all sides
A non-uprooted creaking heart with pale oak logs on six sides never made its idle creak; CreakingHeartBlock.animateTick (CreakingHeartBlock.java:65) plays it 1 time in 16 while CREAKING_ACTIVE is on.
2026-10-29 22:14:24 +01:00
Kacpherek b82ebb2261 Snuff a lit candle cake with an empty hand
A right-click with an empty hand on the lit candle of a candle cake did nothing. CandleCakeBlock.useItemOn extinguishes it when the hit is above half a block, with the extinguish sound and a block change event.
2026-10-29 22:14:24 +01:00
Kacpherek 8880e0d373 Play the hoglin retreat grunt near a repellent
A hoglin near a repellent made no retreat grunt; vanilla plays entity.hoglin.retreat there (HoglinAi.java:231 getSoundForActivity).
2026-10-29 22:13:47 +01:00
Kacpherek b9ddeb1820 Start a gliding player's rocket half a block out at the rocket hand
The rocket of a gliding player started at the player's feet; vanilla offsets it 0.5 block toward the hand holding it (Entity.java:2688 getHandHoldingItemAngle, FireworkRocketEntity.java:138).
2026-10-29 22:13:41 +01:00
Kacpherek 50121630ce Ignore knockback on a creaking that cannot move
A creaking a player looks at still got shoved by hits; vanilla ignores knockback while it cannot move (Creaking.java:451 knockback).
2026-10-29 22:13:41 +01:00
Kacpherek edd7a9cb61 Spawn the crumbs when a held tool or armour piece breaks
A tool or armour piece that broke made no crumb particles; vanilla spawns five item crumbs for it (LivingEntity.java:1516 breakItem).
2026-10-29 22:13:41 +01:00
Kacpherek 5270924ba9 Show the crumbs of the food a fox is eating
Fox.handleEntityEvent(45) throws eight item crumbs from in front of its mouth; the server broadcasts 45 with the eating sound (Fox.java:289, :227).
2026-10-29 22:13:41 +01:00
Kacpherek 97eda2b7f5 Play the tadpole grow-up sound at volume 0.15
Tadpole.ageUp plays TADPOLE_GROW_UP at 0.15 from the tadpole's position, not at full volume from the eyes (Tadpole.java:227).
2026-10-29 22:13:41 +01:00
Kacpherek 86c1795ec6 Save and load a fox's sleeping, sitting and crouching flags
Fox.addAdditionalSaveData writes Sleeping, Sitting and Crouching from its flags and Fox.readAdditionalSaveData reads them back; a reload no longer wakes a sleeping fox (Fox.java:445). Trusted players are still not ported.
2026-10-29 22:13:41 +01:00
Kacpherek b469038d45 Feed a saddled pig held food instead of mounting it
Pig.mobInteract and Strider.mobInteract take a click holding food to Animal.mobInteract before any ride (Pig.java:167, Strider.java:401).
2026-10-29 22:13:41 +01:00
Kacpherek d17c1bc0e9 Keep a tame nautilus near its home, within 32 or 16 blocks
AbstractNautilus.checkRestriction sets the home at the nautilus with getNautilusRestrictionRadius (32, or 16 for a baby or saddled one) when it is not leashed or ridden (AbstractNautilus.java:237, :241).
2026-10-29 22:13:41 +01:00
Kacpherek ec77633cfe Let a cat lured with fish approach its picked player unscared
CatTemptGoal keeps a selectedPlayer re-rolled every 500 to 600 ticks and does not scare it (Cat.java:677).
2026-10-29 22:13:41 +01:00
Kacpherek 5909dc68b6 Lower a sheep's head to eat grass on every client
Sheep.handleEntityEvent(10) sets eatAnimationTick to 40 and EatBlockGoal.start broadcasts event 10 (Sheep.java:119, EatBlockGoal.java:40); the server now sends it and clients start and count down the eating head.
2026-10-29 22:13:41 +01:00
Kacpherek bb7adf1207 Cull lightning bolts beyond 64 blocks times the view scale
A bolt past 64 blocks (scaled by the entity distance) was still drawn; vanilla keeps only the sky flash and thunder there (LightningBolt.java:250).
2026-10-29 22:13:41 +01:00
Kacpherek 97294e3838 Hurt an Enderman that a splash water potion lands near
A water splash did nothing to an Enderman; AbstractThrownPotion.affectEntitiesAround hurts it 1 and Enderman.hurtWithCleanWater makes it teleport.
2026-10-29 22:13:41 +01:00
Kacpherek 01cf25687f Mix both parents' dye colours for sheep, wolf and cat babies
A baby took one parent's colour by a coin flip; DyeColor.getMixedColor mixes them with the two-dye crafting recipe, else picks one (Sheep.java:281, Wolf.java:608, Cat.java:383).
2026-10-29 22:13:41 +01:00
Kacpherek 2151ef21f2 Put a wolf's leash rope at 0.6 of its eye height
The rope point was at full eye height; Wolf.getLeashOffset (Wolf.java:659) uses 0.6 times it.
2026-10-29 22:13:41 +01:00
Kacpherek d686f4b9d7 Play the iron golem damage sound when it cracks
Hits that moved an iron golem to a new crack level were silent; IronGolem.hurtServer plays IRON_GOLEM_DAMAGE then (IronGolem.java:209).
2026-10-29 22:13:41 +01:00
Kacpherek 2b90acbc0d Play the fox bite sound after each melee strike
A fox's melee strike made no sound; FoxMeleeAttackGoal plays FOX_BITE after doHurtTarget (Fox.java:1101).
2026-10-29 22:13:41 +01:00
Kacpherek 2e49c68051 Keep turtles silent in water and as babies
Turtles in water or babies played the land ambient; Turtle.getAmbientSound plays TURTLE_AMBIENT_LAND only on land and grown up, else Mob's silent default (Turtle.java:176).
2026-10-29 22:13:41 +01:00
Kacpherek aa30c8e9d2 Growl the angry zoglin sound while it has a target
A zoglin with an attack target grunted its ambient instead of entity.zoglin.angry; Zoglin.getAmbientSound picks ZOGLIN_ANGRY when ATTACK_TARGET is set (Zoglin.java:286).
2026-10-29 22:13:41 +01:00
Kacpherek 4dcb669759 Play the fox sleep sound while a fox naps
A sleeping fox played its day ambient; Fox.getAmbientSound returns FOX_SLEEP while sleeping (Fox.java:677).
2026-10-29 22:13:40 +01:00
abit-foggy 40b0467e20 fix(renderer): use flat draws on Intel Gen11 Vulkan to prevent section flicker (#3529) 2026-10-29 11:12:39 -10:00
DUzzL ada08437e5 Turtle: silent in water, in the air and as a baby (Turtle.getAmbientSound) 2026-10-29 22:12:16 +01:00
Kacpherek 997bbef785 Walk zombies through their village at night
Zombies, husks and zombie villagers wandered with the plain stroll only. Vanilla Zombie.addBehaviourGoals adds MoveThroughVillageGoal(1.0, onlyAtNight, 4, canBreakDoors) at priority 6 (MoveThroughVillageGoal.java): within 6 sections of a village, pick the land spot (15, 7) whose nearest taken, unvisited village POI is closest, path to that POI (or ten blocks toward it, or only as far as a wooden door), and remember the 16 last POIs reached. The weighted land position now also honours a restricted home like vanilla's LandRandomPos.getPos.
2026-10-29 22:08:19 +01:00
Kacpherek 753660c0b4 Run the click command of a sign when a player uses it
Clicking a sign with a run_command in its text did nothing. SignBlock.useWithoutItem and useItemOn call SignBlockEntity.executeClickCommandsIfPresent (SignBlockEntity.java:182): each line of the side the player faces runs its run_command (as the player, from the block centre, permission level 2, no output: createCommandSourceStack with CommandSource.NULL), opens its show_dialog or logs its custom action, but only when the sign has allow_op_features; otherwise the action bar says click actions are disabled. A waxed sign still plays its failed-interaction sound, and a sign that ran one does not open the editor.
2026-10-29 22:08:07 +01:00
Kacpherek eba7cb0409 Make iron golems return to and patrol their village
Golems strolled anywhere at 0.6 (the plain stroll) and never went home. Vanilla IronGolem.registerGoals (IronGolem.java:68) adds MoveBackToVillageGoal at priority 2 (a stroll every 10 ticks, toward the section within 2 closest to a village, only outside the village) and GolemRandomStrollInVillageGoal at 4 (every 240 ticks: 3 in 10 anywhere, else toward a villager who wants a golem or a taken POI in a village section, from the level's random). Both are RandomStrollGoal subclasses, so Stroll takes an overriding spot picker and canUse test; the village sections and POI tickets come from the villager brain's helpers.
2026-10-29 22:08:07 +01:00
Kacpherek 2d477df4fd Let a waxed sign with a click command stop hanging signs chaining
A hanging sign used on another hanging sign always chained below it, even when the aimed sign had a run_command to run. SignBlockEntity.canExecuteClickCommands (SignBlockEntity.java:178) is a waxed sign whose facing side has one, and CeilingHangingSignBlock and WallHangingSignBlock shouldTryToChainAnotherHangingSign pass only without it. The game now asks the sign, and hands the answer to the wall hanging sign check through PlayerContext.
2026-10-29 22:08:07 +01:00
Kacpherek 71092caa14 Make piglins back off 9 to 11 blocks after a spear charge
A piglin that charged with a spear stayed on its target. SpearRetreat starts on the RETREAT status with a target, a spear and no item in use when LandRandomPos.getPosAway finds a spot 9 to 11 blocks away (checkExtraStartConditions), remembers it as SPEAR_FLEEING_POSITION, walks there for up to 100 ticks while looking at the target, and ends by erasing the status so the next approach starts (SpearRetreat.java).
2026-10-29 22:08:07 +01:00
Kacpherek 5968cd0f09 Make piglins run at the target and reposition mid-charge
A charging piglin stood still. SpearAttack.tick moves it every tick: run at the target (SpearAttack.java), and within 2 blocks of it or at the end of the path pick a spot 6 to 7 blocks past it (LandRandomPos.getPosAway, 2 more blocks on a mount) to run to, erasing it at the end of the path so it runs at the target again, until SPEAR_ENGAGE_TIME ends.
2026-10-29 22:08:07 +01:00
Kacpherek 8ce62d3aaa Make piglins raise the spear to charge a target
After closing in, a piglin with a golden spear never lowered it to attack. SpearAttack starts on the CHARGING status with a target, a spear and no item in use: it becomes aggressive, sets SPEAR_ENGAGE_TIME to the weapon's use time (KineticWeapon.computeDamageUseDuration), erases SPEAR_CHARGE_POSITION and starts using the spear (SpearAttack.start); the charge counts down while it looks at the target and ends in the RETREAT status (SpearAttack.java).
2026-10-29 22:08:07 +01:00
Kacpherek 2153681d47 Make piglins with a golden spear close in on 10 blocks
A piglin holding a golden spear (about 1 in 20 adults) walked up and meleed. PiglinAi.initFightActivity puts SpearApproach before the melee behaviour: with a target, a spear and no item in use, and no SPEAR_STATUS yet (checkExtraStartConditions), it sets the status APPROACH and walks to the target until it is within 10 blocks (SpearApproach.java), then the status is CHARGING. A piglin with a spear no longer melees (MeleeAttack.isHoldingUsableNonMeleeWeapon).
2026-10-29 22:08:07 +01:00
Kacpherek 349127673c Douse fire, candles and campfires where a water bottle lands
A potion that hit a block had no face and never put anything out. Thrown potions now keep the block and the face they entered it by, and AbstractThrownPotion.onHitBlock (server side, before the entity effects) douses the cell in front of that face, the cell behind it and the four horizontal neighbours when the potion is in #douses_fire. AbstractThrownPotion.douseFire destroys a fire block without drops, puts out a lit candle or candle cake (AbstractCandleBlock.extinguish) and a lit campfire (level event 1009, the block-change game event, lit false) (AbstractThrownPotion.java:48, :122).
2026-10-29 22:08:00 +01:00
Kacpherek 32e16761bd Hurt water-sensitive mobs and put out burning ones with a splash
A water potion only hurt endermen, and nothing it splashed on stopped burning. AbstractThrownPotion.affectEntitiesAround goes over every living entity in the potion's box grown by (4, 2, 4) and under 4 blocks away: #hurts_water_sensitive_entities hurts the sensitive ones (blaze, snow golem, strider, enderman) with indirectMagic and #extinguishes_entities calls extinguishFire on the burning ones, players and armor stands included (AbstractThrownPotion.java:94).
2026-10-29 22:08:00 +01:00
Kacpherek 57a2512025 Add putting out a burning mob and a lit candle from outside
Entity.extinguishFire plays the extinguish sound for a burning entity and then clears its fire; AbstractCandleBlock.extinguish unlights a candle or candle cake with its sound and the block-change game event (no source entity, and no smoke from a server). Mobs only lost their fire in water or rain, and candles only to a player's empty hand or a wind charge.
2026-10-29 22:07:51 +01:00
Kacpherek 25d15d2a0a Add the indirect_magic damage type for thrown potions
A thrown potion hurts through DamageSources.indirectMagic(potion, owner), which bypasses armor, is resisted by witches and wolf armor, and, unlike magic, knocks back from the potion. Mobs only had magic (no knockback) or a player attack. Add the kind, its constructor and the damage_type id, and keep Enderman.hurtServer's random teleport chance off it: the thrown-potion branch teleports without it (Entity.java, DamageTypeTags, Enderman.java).
2026-10-29 22:07:51 +01:00
Kacpherek a1a45472c8 Read splash potion effects around the hit from the potion tags
A thrown potion tested for plain water by id to hurt endermen and refill axolotls. Vanilla asks PotionContents.is for #hurts_water_sensitive_entities, #rehydrates_axolotls (and #douses_fire, #extinguishes_entities) in AbstractThrownPotion. Add the lookup over the data pack's tags/potion and use it for the two checks that exist.
2026-10-29 22:07:51 +01:00
Kacpherek 37f9481bb7 Add SignText.hasAnyClickCommands for a sign side
A sign side had no way to say whether its text carries a run_command click event. SignText.hasAnyClickCommands (SignText.java:120) is true when any line's style has a RunCommand click event. Horizon reads the saved click_event the way ClickEvent.CODEC does for the three actions a sign acts on (run_command, show_dialog, custom).
2026-10-29 22:07:19 +01:00
Kacpherek 04fb868e99 Keep click events and allow_op_features on loaded signs
Horizon flattened every sign line to plain text, so a click event in the text was lost the moment a sign loaded. SignText keeps each line's Component and its Style; the sign now keeps each line's click_event next to its text, writes it back with the line (an edited line keeps its style, SignBlockEntity.updateMessages), and reads and writes the allow_op_features flag of SignBlockEntity.loadAdditional and saveAdditional.
2026-10-29 22:07:15 +01:00
Kacpherek 7ed6012c79 Tick potent sulfur geysers on the server and the client
Wet or dormant sulfur never made anyone nauseous, periodic geysers never erupted, and continuous ones neither pushed entities nor showed their plume. Run PotentSulfurBlock.getTicker's tickers by state (PotentSulfurBlockEntity.java): the server gives nausea every 10 ticks, counts the geyser down with its positional random and flips dormant and erupting, and throws mobs, items and primed TNT up the column; the client releases gas clouds, draws the plume and plays its sound from the eruption block event, and pushes the local player, who moves on its own client. A window that simulates the world itself does both.
2026-10-29 22:06:57 +01:00
Kacpherek a8be1d9ee0 Add the potent sulfur block entity and its block events
Potent sulfur had no block entity of its own here, so no countdown was kept or saved and a geyser that began erupting made no sound or block event. Add PotentSulfurBlockEntity's waitingCountdown (saved as countdown) and eruptionTick, create it with the block and keep it through saves. PotentSulfurBlock.onPlace now queues the eruption block event, plays the start sound and signals block_activate for erupting and continuous states, triggerEvent reports the event handled, and updateShape resets the countdown when a block that was no geyser turns dormant (PotentSulfurBlock.java validBlockState).
2026-10-29 22:06:57 +01:00
Kacpherek b6f3d91b09 Raise the spear arm of zombies and piglins
HumanoidMobRenderer.getArmPose gives an arm holding a spear the SPEAR pose, which AnimationUtils.animateZombieArms leaves alone; the mobs here drew zombie or piglin arms around it. Zombies, husks, zombie villagers, zombified piglins and piglins now hold the spear out and charge it by their use time, and a piglin admiring gold no longer counts as using the spear in its main hand.
2026-10-29 22:06:55 +01:00
Kacpherek 26f46141c7 Make zombies and zombified piglins charge with a spear
Zombies, husks, zombie villagers and zombified piglins spawned with a spear just melee. SpearUseGoal (priority 2, and 1 for zombified piglins) walks to 10 blocks, lowers the spear for KineticWeapon.computeDamageUseDuration (halved, the goal ticks every other tick), charges along the target, repositions 6 to 7 blocks away, then backs off 9 to 11 blocks (LandRandomPos.getPosAway with a minimum distance) for up to 50 goal ticks. A mount's chargeSpeedModifier and the 2 blocks a passenger adds are kept.
2026-10-29 22:06:55 +01:00
Kacpherek 4f5a70ee53 Take ten points off a goat's fall damage
Goat.calculateFallDamage is the base damage minus 10, as Frog's is minus 5. Goats now leap down to five blocks and the arc lifts them higher still, so without it every ledge jump would hurt them.
2026-10-29 22:06:52 +01:00
Kacpherek d0761d3c50 Let goats long jump between ledges when their cooldown runs out
GoatAi.initLongJumpActivity adds LongJumpMidJump and LongJumpToRandomPos (5 wide, 5 high, velocity 3.5714288) to the activity that runs after the ram activity and before the idle one: no tempting player, no breed target, no walk target and no LONG_JUMP_COOLDOWN_TICKS. It lands on LongJumpToRandomPos.defaultAcceptableLandingSpot (a solid block below, no path malus).
2026-10-29 22:06:52 +01:00
Kacpherek caca2ef2d4 Land a long jumping goat with a thud and a 10 percent slide
LongJumpMidJump.stop slows a mob that lands to a tenth of its horizontal speed (multiply(0.1F, 1.0, 0.1F), the float widened to a double), plays the activity's landing sound at volume 2 (GoatAi: entity.goat.step), stands it up and samples the cooldown. A goat has no swim activity, so water does not end its flight.
2026-10-29 22:06:52 +01:00
Kacpherek 8874140947 Play the goat's long jump sound when it leaps
LongJumpToRandomPos.tick plays the activity's jump sound at the leap. GoatAi.initLongJumpActivity gives goats entity.goat.long_jump and screaming goats entity.goat.screaming.long_jump; the arc, the 40 tick wait and the jump boost are the same as the frog's.
2026-10-29 22:06:52 +01:00
Kacpherek a369faeb32 Let allays remember a note block they heard
An allay never learned a liked note block, so it always brought items to the player. AllayAi.hearNoteblock (AllayAi.java:108), called from Allay.VibrationUser.onReceiveVibration on NOTE_BLOCK_PLAY, stores the block as LIKED_NOTEBLOCK_POSITION with a 600 tick cooldown and renews the cooldown when the same block plays again.
2026-10-29 22:06:52 +01:00
Kacpherek 6dc2f77e99 Make an allay with a liked note block hear only that block
Allay.VibrationUser.canReceiveVibration (Allay.java:564) refuses vibrations without AI and, once a note block is liked, accepts only that block while the allay is within 1024 blocks of it in its dimension. Horizon accepted every vibration.
2026-10-29 22:06:52 +01:00
Kacpherek bb4da395a3 Add GlobalPos.isCloseEnough and the allay's liked note block memory
The liked note block slot (extra.home) was never set and was a bare block position. Vanilla keeps LIKED_NOTEBLOCK_POSITION as a GlobalPos (GlobalPos.java:31) and AllayAi.getItemDepositPosition drops it when it is over 1024 blocks away, in another dimension, no longer a note block or its cooldown ran out; the throw target is BlockPosTracker(pos.above()), the centre of that block. Saved in Brain.memories like vanilla.
2026-10-29 22:06:52 +01:00
Kacpherek 2555b46e7c Give allays a vibration listener that hears note blocks
An allay never listened for vibrations, so note blocks played near it went unnoticed. Vanilla gives it a VibrationSystem listener (Allay.VibrationUser, 16 blocks, GameEventTags.ALLAY_CAN_LISTEN = note_block_play) ticked after super.tick() in Allay.tick, saved under "listener".
2026-10-29 22:06:52 +01:00
Kacpherek 65a23ca98e Port map: the golden dandelion age lock is ported
The map still said a golden dandelion did nothing: drop the missing rows for
AgeableMob.mobInteract, canUseGoldenDandelion, setAgeLockedData, setAgeLocked,
makeAgeLockedParticle and Tadpole.setAgeLockedData (the code now cites them),
and say AgeableMob.defineSynchedData sends AGE_LOCKED too.
2026-10-29 22:06:52 +01:00
Kacpherek 24101477da Let a golden dandelion lock or release a baby animal's growth
Using a golden dandelion on a baby animal did nothing. AgeableMob.mobInteract (reached by Animal, Dolphin, SulfurCube and the other subclasses after their own checks) runs setAgeLocked with setAgeLockedData and returns SUCCESS when canUseGoldenDandelion holds; Tadpole.mobInteract does the same. A scared panda or one on its back (Panda.mobInteract) and an armadillo in its shell (Armadillo.mobInteract) answer first. A server's client treats the click as taken like the server does (AgeableMob.java:68).
2026-10-29 22:06:52 +01:00
Kacpherek e04428b60c Use up the golden dandelion, keep the mob and play the lock sound
Golden dandelion consumption, persistence and the use or unuse sound were missing. AgeableMob.setAgeLocked runs setAgeLockedData, consumes one item (not in creative), setPersistenceRequired when the mob is now locked, and plays golden_dandelion.use or .unuse at the block position (middle of the block, 1.0, 1.0, PLAYERS) (AgeableMob.java:88).
2026-10-29 22:06:52 +01:00
Kacpherek 79ed5ba529 Add the golden dandelion check for age locking a baby
AgeableMob.canUseGoldenDandelion(item, isBaby, cooldown, mob): the held item is a golden dandelion, the mob is a baby, its ageLockParticleTimer is 0 and its type is not in the cannot_be_age_locked entity tag (zombie horse, skeleton horse, villager) (AgeableMob.java:78).
2026-10-29 22:06:52 +01:00
Kacpherek a569559196 Add the age lock toggle a golden dandelion applies to a tadpole
A golden dandelion on a tadpole did nothing. Tadpole.setAgeLockedData sets AGE_LOCKED to its opposite, setAge(0) (back to the start of its 24000-tick growth) and ageLockParticleTimer = 40 (Tadpole.java:168).
2026-10-29 22:06:52 +01:00
Kacpherek ac74644dc9 Add the age lock toggle a golden dandelion applies to an ageable mob
Nothing toggled AgeLocked in play. AgeableMob.setAgeLockedData sets AGE_LOCKED to its opposite, setAge(getBabyStartAge()) (-24000, a sniffer's -48000, through the age boundary) and ageLockParticleTimer = 40 (AgeableMob.java:82).
2026-10-29 22:06:51 +01:00
Kacpherek c419845102 Draw the growth particles above a mob a golden dandelion touched
There was no age lock particle timer, so no pause or reset growth particles. AgeableMob.makeAgeLockedParticle runs at the end of AgeableMob.aiStep and Tadpole.aiStep: while ageLockParticleTimer is above 0, every even tick it sends one PAUSE_MOB_GROWTH (locked) or RESET_MOB_GROWTH particle at getRandomX(1.0), getRandomY(0.2) + bbHeight (+ 0.2F when locked), getRandomZ(1.0), then counts down (AgeableMob.java:225).
2026-10-29 22:06:51 +01:00
Kacpherek a6e91967a1 Hold an age-locked baby as a baby and sync the lock to clients
Nothing honoured AgeLocked in the shared age tick: a locked baby kept growing, a locked baby fed food still aged, and the lock never reached remote clients. AgeableMob.aiStep only counts a baby up when canAgeUp (isBaby && !isAgeLocked), and only for AgeableMob subclasses (it was also counting a tadpole's own age back down, so it never grew up). Animal.mobInteract, Panda.mobInteract and Dolphin.mobInteract feed a baby only when canAgeUp. AgeableMob.AGE_LOCKED (entry 17 after DATA_BABY_ID) and Tadpole.AGE_LOCKED are synched; MushroomCow.DATA_TYPE moves to 18 behind them (AgeableMob.java).
2026-10-29 22:06:51 +01:00
Kacpherek af8751173f Keep a riding piglin to the ride activity's behaviours
PiglinAi.initRideHoglinActivity lists Mount, SetEntityLookTarget(loved item, 8.0), a look at what it sees while a passenger and DismountOrSkipMounting; it has no idle movement or repellent avoidance, so a piglin heading for a hoglin no longer wanders off or strolls. A riding piglin looks at a player holding gold within 8 blocks, not 14.
2026-10-29 22:06:47 +01:00
Kacpherek af0bc10c34 Take a baby piglin off a baby it rides once its ride target ends
A baby piglin stayed on its mount forever. PiglinAi.updateActivity ends with stopRiding when RIDE_TARGET is gone and PiglinAi.isBabyRidingBaby (a baby piglin on a baby piglin or baby hoglin) holds, so a ride lasts as long as the 10 to 30 second memory.
2026-10-29 22:06:47 +01:00
Kacpherek 7fd82c846b Make a riding piglin get off a vehicle it no longer wants
No dismount rule ran. DismountOrSkipMounting keeps the vehicle (or else the ride target) only while it is alive and closer than 8 blocks and PiglinAi.wantsToStopRiding says no: the vehicle is grown, dead, the piglin was hurt in the last 40 ticks (HURT_BY), or the vehicle is a piglin that rides nothing itself. Otherwise the piglin gets off and forgets RIDE_TARGET.
2026-10-29 22:06:46 +01:00
Kacpherek f27a098c2d Walk a baby piglin to its ride target and mount it
No brain sent a mob to another to climb on it. Mount.create (ride activity) does nothing for a passenger, boards the ride target once closer than a block, and otherwise looks at it and sets a 0.8 speed walk to it; MoveToTargetSink ends that walk a block from the target's block and aims again when it moved over 2 blocks.
2026-10-29 22:06:46 +01:00
Kacpherek 08e206c459 Make an idle baby piglin pick a baby hoglin to ride
Nothing ever set RIDE_TARGET. PiglinSpecificSensor remembers the nearest visible baby hoglin, and PiglinAi.babySometimesRideBabyHoglin (idle activity) copies it into RIDE_TARGET for 10 to 30 seconds once SetEntityLookTargetSometimes.Ticker lets a baby through, every 10 to 40 seconds, with draws from the level random.
2026-10-29 22:06:46 +01:00
Kacpherek 5bc9606d99 Give a piglin a ride target and a ride activity
Brain activities wait on their memory: PiglinAi.getActivities lists a RIDE activity that is valid while RIDE_TARGET is there, between celebrating and idle in PiglinAi.updateActivity's order. RIDE_TARGET holds a mob and expires like any memory with an expiry.
2026-10-29 22:06:46 +01:00
Kacpherek c4f0c49c0e Pose a riding piglin sitting
PiglinModel extends HumanoidModel, whose setupAnim bends the legs and arms of a passenger; piglin_pose never set the riding flag, so a piglin on a mount stood stiff.
2026-10-29 22:06:46 +01:00
Kacpherek ff70b164cc Let a mob's brain board another mob and get off it
Only spawning put a mob on another; no brain could. Entity.startRiding checks the boarding cooldown, a free seat and no riding cycle, and Piglin.startRiding sends a baby piglin that boards a hoglin to the top of up to three piglins stacked on it. LivingEntity.stopRiding sets the passenger down at Animal.getDismountLocationForPassenger. A hoglin seats its rider at 1.49375 (0.875 for a baby), a baby piglin hangs 0.1875 below its seat (Piglin.BABY_DIMENSIONS).
2026-10-29 22:06:46 +01:00
Kacpherek 66c20c246d Open the new piglin and hoglin doc comments with their vanilla method
Doc wording only: each function that ports a vanilla method now starts its comment with the method name.
2026-10-29 22:06:46 +01:00
Kacpherek 32f2156b13 Count a piglin's hunted-recently wait down once a tick
Two additions to Piglin's per-tick state each counted the same memory down, so the HUNTED_RECENTLY wait that setAvoidTargetAndDontHuntForAWhile and StartHuntingHoglin start (30 to 120 seconds, PiglinAi.TIME_BETWEEN_HUNTS) ran out twice as fast.
2026-10-29 22:06:46 +01:00
Kacpherek 810a0152d3 Make idle piglins watch a player who holds gold
Piglins never turned to look at a player holding a gold ingot or another loved item. The idle and celebrate activities of PiglinAi.java start with SetEntityLookTarget on a visible player within 14 blocks for whom isPlayerHoldingLovedItem holds; add that look as a goal.
2026-10-29 22:06:46 +01:00
Kacpherek c124b94c8b Keep adult hoglins 8 blocks from the adult piglins they see
Idle hoglins ignored adult piglins and let them walk right up. HoglinAi.initIdleActivity walks an adult away from the nearest visible adult piglin at 0.4 once it is closer than 8 blocks, when it has no walk of its own (SetWalkTargetAwayFrom in HoglinAi.java).
2026-10-29 22:06:46 +01:00
Kacpherek 845343e019 Play a piglin's and hoglin's activity sound as its ambient grunt
Piglins and hoglins played their angry grunt whenever they had a target and otherwise the plain ambient one. Piglin.getAmbientSound and Hoglin.getAmbientSound play the sound of the activity the mob is in (PiglinAi.getSoundForCurrentActivity, HoglinAi.getSoundForCurrentActivity).
2026-10-29 22:06:46 +01:00
Kacpherek 89ecd6cd9d Grunt when a piglin or hoglin changes activity
A hoglin or piglin that started a fight, a retreat, a celebration or an admiring said nothing; only the ambient roll played. HoglinAi.updateActivity and PiglinAi.updateActivity make getSoundForActivity's sound (retreat, angry, admiring, celebrate, jealous) when the activity changes (Hoglin.java, Piglin.java).
2026-10-29 22:06:46 +01:00
Kacpherek e314b5c6e1 Make a hurt piglin flee, retreat or retaliate as vanilla does
A hit piglin ended its celebration and, for a player's hit, its admiring, but babies fought back instead of fleeing, an avoid target of another kind stayed, and an adult piglin took on hoglins it should retreat from. PiglinAi.wasHurtBy (Piglin.hurtServer) drops the admiring and the celebration for any attacker but a piglin, sends a baby into a 100-tick flight and calls the adults, retreats an adult from hoglins that outnumber piglins, and lets it retaliate only when maybeRetaliate allows.
2026-10-29 22:06:46 +01:00
Kacpherek be2963b1d6 Make a hurt hoglin retreat or retaliate as vanilla does
A hit hoglin only ended its pacified state and let the hurt-by goal target the attacker, so a baby never fled and an adult took on piglins and hoglins it should leave alone. HoglinAi.wasHurtBy (Hoglin.hurtServer) sends a baby into the retreat and lets an adult retaliate only when maybeRetaliate allows it.
2026-10-29 22:06:46 +01:00
Kacpherek d68d59f6b6 Make an outnumbered hoglin retreat after it hits a piglin
A hoglin that hit a piglin kept fighting even when piglins outnumbered the hoglins around it. HoglinAi.onHitTarget (Hoglin.doHurtTarget) sets the piglin as its avoid target and broadcasts the retreat to the adult hoglins it sees.
2026-10-29 22:06:46 +01:00
Kacpherek 7a98019450 Set an avoid target and broadcast a retreat to the adults nearby
A hoglin or piglin that decides to retreat had no way to set the avoid target, pick the nearest of its targets or warn the adults around it. Add HoglinAi.setAvoidTarget, PiglinAi.setAvoidTargetAndDontHuntForAWhile, retreatFromNearestTarget and broadcastRetreat; the broadcast reaches the other mob as an event that its next tick handles before its goals.
2026-10-29 22:06:46 +01:00
Kacpherek c40d0112a6 Let piglins and hoglins retreat from their avoid target
Hoglins and piglins never ran from piglins, hoglins or zombified mobs through an avoid target: baby hoglins panicked instead and piglins used a plain avoid goal. Add the retreat activity's walk away (HoglinAi.initRetreatActivity at 1.3 and 15 blocks, PiglinAi.initRetreatActivity at 1.0 and 12), the copies of a nemesis or zombified mob into AVOID_TARGET (PiglinAi.java), wantsToStopFleeing and the outnumbering tests, and keep the idle behaviours and the attack goals out of the retreat.
2026-10-29 22:06:45 +01:00
Kacpherek 89cbb09961 Let mobs stab with a spear they are using
A mob that held a spear could not use it: nothing started its use or ran KineticWeapon.damageEntities for it. A mob's use now counts ticks like the player's, stabs along its view from delay_ticks on (conditions scaled by 0.2, reach by the AttackRange mob_factor), hurts and pushes the player or mobs hit, dismounts riders and broadcasts entity event 2 (LivingEntity.stabAttack, causeExtraKnockback). A piglin's admiring is told apart from a spear in use by the off hand.
2026-10-29 22:06:40 +01:00
Kacpherek d43b957afc Add the sulfur geyser and noxious gas particles
Potent sulfur geysers draw four new particle types and two emitters, and none existed. Add noxious_gas, noxious_gas_cloud, geyser, geyser_base, geyser_poof and geyser_plume the way the vanilla providers build them (GeyserEruptionParticle.java, GeyserPlumeParticle.java, GeyserBaseParticle.java, NoxiousGasParticle.java, NoxiousGasCloudParticle.java): the gas fades from half its life, the base puffs burst upward, the plume shoots up and falls back, and the cloud and the eruption spawn the rest every tick. The cloud asks PotentSulfurBlockEntity.canBeReachedByNoxiousGas, ported with the other level reads of that class in the new potent_sulfur module. Particle packets with geyser data now map to and from the options.
2026-10-29 22:06:36 +01:00
Kacpherek 93dd53e1c6 Brighten a glow squid's body when it is not dark
A glow squid was lit like any mob, so it never glowed in the dark. GlowSquidRenderer.getBlockLightLevel lerps 1 - darkTicks / 10 over 0..15 and uses 15 outright, else the larger of that and the usual block light (GlowSquidRenderer.java:26).
2026-10-29 22:06:36 +01:00
Kacpherek 1a371c3e20 Count down the glow squid dark ticks and shed glow particles
A glow squid kept no dark timer and shed no glow. GlowSquid.aiStep runs after Squid.aiStep on the server and on clients: it lowers a positive dark tick count by one and calls addParticle(GLOW) at getRandomX(0.6), getRandomY(), getRandomZ(0.6) (GlowSquid.java:83). Only a client's level draws it; the server throws the same three random doubles.
2026-10-29 22:06:35 +01:00
Kacpherek f1a0963f2a Dim a glow squid for 100 ticks when it is hit
A hit glow squid never got its dark timer, so it did not dim. GlowSquid.hurtServer sets the dark ticks to 100 once Squid.hurtServer took the damage (GlowSquid.java:97).
2026-10-29 22:06:32 +01:00
Kacpherek 2947c2d71f Share the spear hit search with the mob code
The stab's entity search (ProjectileUtil.getHitEntitiesAlong) lived in the game crate's spear module; mobs that stab need it from the client library, so it moves to the spear module there and the game keeps using it.
2026-10-29 22:06:26 +01:00
Kacpherek 952fc5a1f5 Add LandRandomPos.getPosTowards for mobs that lean toward a spot
Horizon only had getPosAway on land. Vanilla's LandRandomPos.getPosTowards (LandRandomPos.java:23) takes the direction to the point and goes through getPosInDirection, a quarter turn (float cast pi/2) either side. getPosAway now shares that helper, so it uses the same float-cast angle.
2026-10-29 22:06:26 +01:00
Kacpherek ba7f106d53 Sense the adults a piglin or hoglin sees and track its activity
Piglins and hoglins had no brain memories: nothing counted the adult piglins and hoglins in sight (HoglinSpecificSensor, PiglinSpecificSensor), kept an avoid target with an expiry, or noted which activity they were in. Add them as mob state ticked from customServerAiStep (Hoglin.java, Piglin.java).
2026-10-29 22:06:24 +01:00
Kacpherek 9191728d12 Make a goat that cannot long jump wait 300 to 600 ticks
LongJumpToRandomPos.checkExtraStartConditions needs the mob on the ground and out of water, lava and honey, and otherwise sets LONG_JUMP_COOLDOWN_TICKS to half a sample of TIME_BETWEEN_LONG_JUMPS (GoatAi: 600 to 1200). A goat has no swim activity to keep it out of the check, so it runs in water too.
2026-10-29 22:06:23 +01:00
Kacpherek bd89ae3df7 Sync and save the glow squid dark ticks
A glow squid had nowhere to keep DATA_DARK_TICKS_REMAINING: nothing held it, sent it to clients, or saved it. GlowSquid defines it as the first entry after AgeableMob's two (index 18) and writes DarkTicksRemaining (GlowSquid.java). It lives in the mob's counter; the server sends it as synched data, a client applies it and the save keeps it.
2026-10-29 22:06:23 +01:00
Kacpherek 5ea674c6e6 Search an 11 block box for a goat's long jump
LongJumpToRandomPos.start lists every block within the activity's width and height of the mob (GoatAi: 5 and 5, so 11 x 11 x 11; the frog's is 9 x 5 x 9). A goat's activity is a plain LongJumpToRandomPos, so it skips LongJumpToPreferredBlock.start's draw from the mob's random.
2026-10-29 22:06:23 +01:00
Kacpherek d8ac144995 Share the frog's long jump goal pieces with goats
LongJumpToRandomPos and LongJumpMidJump serve both mobs in vanilla. The goal now sets the friction flag and the pose through helpers that know the goat's state, and the default landing spot and the cooldown count down (CountDownCooldownTicks) can be used by a goat. Frogs behave as before.
2026-10-29 22:06:23 +01:00
Kacpherek a7f03f52fd Give goats a long jumping pose with its smaller box and friction flag
A goat that long jumps takes Pose.LONG_JUMPING, which Goat.getDefaultDimensions scales by 0.7, and LongJumpUtil checks the arc with that pose's box (LongJumpUtil.isClearTransition). The pose goes to remote clients like the frog's, and the goat holds LivingEntity.discardFriction.
2026-10-29 22:06:23 +01:00
Михаил РоговцевandOpenAI Codex 880eb7350f Cover spear swing across a held item switch
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-30 00:04:53 +03:00
Ludovic MarieandClaude Opus 5.5 fabb6d78ec Parity headline: a plain paragraph, no HTML markers
The website renders the "Project status" intro with its own markdown, which
prints HTML comments as text: the block is now found by its first words.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 21:56:45 +01:00
Ludovic MarieandClaude Opus 5.5 3082b36894 Parity headline: the player-visible gaps still open, on the README and the site
tools/parity_gaps.py counts the rows not yet ticked in the port ledger's
ten gap lists (#3512-#3521) and writes them first under "Project status"
(by area, linked) and in a badge beside the 100% one. The website shows the
block already: it renders that section's intro. Today: 833 open of 833.
Refreshed in the weekly roadmap pass (roadmap/README.md); tests check the
block, the badge and the site's heading agree, without the network.

Part of #3859.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 21:54:39 +01:00
Михаил РоговцевandOpenAI Codex 15a22366e3 Test swap scale from displayed item models
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-29 23:53:29 +03:00
DUzzL c9053670cd Fix camel pose time at spawn and in LastPoseTick saves
A camel kept its pose change as a signed age, so a camel finalized at age 0 had pose time 0 and could not move for 52 ticks, and LastPoseTick was saved as a sign. Keep a signed age anchor plus a sitting flag, and save and load LastPoseTick as a game time.
2026-10-29 21:42:33 +01:00
Михаил РоговцевandOpenAI Codex a554d6e7c4 Fix item model hand swap animation scale
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-29 23:39:54 +03:00
DUzzL 4269899778 Stamp the current DataVersion only after the world data is upgraded
upgrade_world wrote level.dat at the current DataVersion before converting chunks, so a failed conversion was never retried. The level.dat write now comes last.
2026-10-29 21:37:45 +01:00
levzzzandClaude Opus 5.5 32cce66761 Launcher: call the Horizon update button "Rebuild on Horizon X"
A mod row's "Update to 2.0.0" sat just below "Update to Horizon X"; the two
did different things under the same word.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 01:32:10 +05:00
levzzzandClaude Opus 5.5 f2a3a76c07 Launcher: simpler Build condition for clippy 1.95
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 01:29:03 +05:00
levzzzandClaude Opus 5.5 8ee30558da Launcher: show where native mods are installed and what each instance's build plays
- Each installed native mod says "Installed in {instance}" and has Open folder
  (<instance>/horizon-native-mods/); "Installs into {instance}" shows even with
  one instance.
- The Native build row is the picked instance's own: a Not built / Needs
  rebuild / Up to date badge, the native-* build it plays and the Horizon it
  was built on (native-mods.json's base). A build's progress and error show
  only on the instance being built.
- Update to Horizon {version} when update_for finds a newer release or
  Nightly; its trust warning says it compiles from scratch and takes longer.
  Build never moves the Horizon version.
- The instance list shows a native build as "Native build · Nightly 10-28".
- Strings in all six languages; docs/native-mods.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 01:26:33 +05:00
TJKDev1andClaude Opus 5.5 6eb26eb046 Third person: the player's head pitch follows the mouse between ticks
Entity.turn adds the turn to xRotO as well as xRot, so the local
player's getXRot(partialTick) follows the mouse every frame. Every turn
(mouse, pad, arrow keys, the control `turn`) now goes through
turn_player, which moves both ends of the model's head pitch and ends
in AbstractBoat.onPassengerTurned's clamp. handleRotatePlayer snaps the
model (setOldRot), and handleMovePlayer moves its old pitch by the
packet as setOldPosAndRot does. A change the tick makes still eases in.

Fixes #3844

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 21:24:09 +01:00
Federico Tagliani d1129a9fce Merge remote-tracking branch 'origin/main' into fix/boat-paddle-input-latch 2026-10-29 21:17:14 +01:00
Federico TaglianiandClaude Sonnet 5.5 0cfb0400c8 Boats: steer with the input latched on the previous tick
LocalPlayer.rideTick sets the boat's input after the boat ticked, so
AbstractBoat.tick steers with the previous tick's keys and the paddle
packet it sends describes the motion it just made. Horizon set the keys
before ticking, a tick ahead of that packet (GrimAC Simulation flags).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-29 21:15:45 +01:00
matydenandClaude Opus 5.5 230d89f7ec Telemetry: a user named "user" keeps the <user> label whole
The scrubber puts <user> in place of the folder name in /Users/... and
C:\Users\... paths, then replaces the machine's user name wherever it
stands alone. When that name is "user", the second pass matched the
word inside the label it had just written, so a crash report read
/Users/<<user>>/... and telemetry's a_crash_keeps_only_what_says_what_happened
failed on such machines (seen in #578 and #3838). A match between "<"
and ">" is now left as it is.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 22:15:04 +02:00
levzzzandClaude Opus 5.5 d9a2f157a3 Merge launcher/native-mods-on-by-default (#3855) into launcher/native-mods-where
Stacks PR 3 on both #3870 and #3855. native_mods.rs takes #3855's side: the
experimental switch's NOTICE, turns_on and needs_notice go.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 01:14:11 +05:00
spiderandClaude Opus 5.5 e716347329 Play with Friends: leaving the world ends its invite code
On the server thread (the default single player), Save & Quit goes
through leave_server, which stopped the integrated server but kept the
sharing state: back in a world, the old code still showed with nothing
behind it. leave_server now ends the code, as lan_close already did for
the in-frame path. test_friends checks it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 21:06:22 +01:00
levzzzandClaude Opus 5.5 abeb5845dd Launcher: native builds use the fast profile, at low priority
- The workspace's `fast` profile (no LTO, 16 codegen units, incremental) where
  the pinned source has it, else `release`; native-mods.json records which.
- The game's build and horizon-mixins' install run at nice 19 with idle I/O
  (Linux), nice 19 (macOS), below-normal priority (Windows).
- CARGO_BUILD_JOBS passes through: it changes no flag.
- A native build's copied programs keep their permissions (a clone left
  horizon-ctl and horizon-server not executable).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 01:05:56 +05:00
spiderandClaude Opus 5.5 be48b7a4a9 LAN and friends worlds authenticate guests when the host is signed in
IntegratedServer.initServer uses authentication, so a vanilla LAN is in
online mode: each guest's hasJoined answer brings their signed textures
property, and everyone sees their skin and cape. Horizon's LAN stayed in
offline mode, so guests joined with no properties and showed as default
skins without capes.

OnlineMode::lan() makes the key pair when an account is signed in (an
offline name keeps offline mode), for the server thread and the in-frame
host alike; friend codes reach the same port through the tunnel. As in
ServerLoginPacketListenerImpl.handleHello, the integrated server's own
player over memory is never asked to authenticate.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 21:03:58 +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
DenisHumenandClaude Opus 5.5 6774e47aae Launcher: moving a world to the trash no longer closes it on Windows
The trash crate starts COM on the calling thread and panics when that
thread already runs COM in the other mode (RPC_E_CHANGED_MODE). Deleting a
world runs on a worker of GPUI's thread pool, which can, and a panic in that
callback cannot unwind, so the launcher aborted and the world stayed.
The system trash now runs on a thread of its own, and a panic there comes
back as an error.

Fixes #3784

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 21:56:57 +02:00
levzzzandClaude Opus 5.5 aba29be6af Launcher: native mods are on by default; Build moves to the Mods page
The "Native mods (experimental)" switch leaves Settings: native mods are
always on. Old settings.json files still read (the `native_mods` key is
ignored); `native_mods_noticed` now records the one-time trust notice
before the first native install, which no longer asks to turn anything on.

The Mods page's installed list gets a "Native build" row for the selected
instance: the build's step or error, Build when the version it plays
doesn't hold the native mods turned on (compared with its
native-mods.json), else "Up to date". Build still opens the red trust
warning. An installed native mod now says which instance it went into.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 00:30:53 +05:00
levzzzandClaude Opus 5.5 647ec89bfc Launcher: native rebuilds stay on the instance's Horizon and in its instance
- A build starts from the version the instance plays; a native build's next one
  compiles its own recorded revision (native-mods.json) from its own game files.
  `native-build --update` moves to the newest installed release or Nightly, and
  `native_mods::update_for` says which one a window could offer.
- The build pins the instance the mods are installed in, the default one included;
  no more hidden "Native mods" instance (old ones keep working).
- Toolchain commands drop RUSTFLAGS, CARGO_PROFILE_*, CARGO_BUILD_*, CARGO_TARGET_*,
  CARGO_INCREMENTAL and friends, and start Cargo from the disk's root with
  --manifest-path, so the player's ~/.cargo/config.toml can't change the flags.
- prune keeps the three newest native builds no instance plays.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 00:28:38 +05:00
d3grandoandClaude Opus 5.5 ef3163c244 Merge origin/main into fix-3236
tools/lan_e2e.py: the mooshroom check and main's shulker_menu and keys_released checks, all kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 02:13:41 +07:00
FauxandClaude Opus 5.5 96877dcf48 Far terrain: no dark band in the sea where chunks were loaded
A chunk's summary kept the chunk's own light, which fades a level per block
of water, while the seed's far columns light water and its floor as open
sky. The chunks a player passed then drew a darker band through the far sea.
Summaries now light open water and its floor as open sky, and the water is
one span again.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 12:11:33 -07:00
GIGABAIT93 7d227270a2 Merge branch 'main' of https://github.com/horizon-rust/horizon into feat/launch-api-client
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-10-29 21:11:06 +02:00
d3grandoandClaude Opus 5.5 3931a46585 lan_e2e: the player beside the eater hears each bite once too
check_eating (each player eats a loaf in front of the other) now also
counts entity.generic.eat on both sides: the eater and the watcher must
each hear one bite per crumb burst the eater draws, plus
FoodProperties.onConsume's sound, for the loaf eaten (#3129).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 02:07:35 +07:00
degepieandClaude Opus 5.5 bcfeeeeb2a Merge main into fix/issue-3024-worn-armor-glint
main added render_layers (armour without the body) and a body_visible flag;
its extra layers now carry the overlay too, like render_with's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-10-30 00:03:43 +05:00
Ludovic MarieandClaude Opus 5.5 e28ed6d877 Spawners: no level built while no player is in range
`BaseSpawner.serverTick` returns first thing without an alive player within
its range (`isNearPlayer`) or with `spawner_blocks_work` off, before any
draw. Each spawner still built the spawn code's level every tick for it:
every player with its box and mode, the nearest-player list and the mob
world. Ask the players directly first, without collecting them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 19:58:32 +01:00
GIGABAIT93andClaude Opus 5.5 e855ded821 Test that an offscreen game never grabs, warps or hides the pointer
The capture fixture compiles the client's own capture body; it now has
the game's options, and an offscreen game must keep its grab as its own
state without touching the window's pointer.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 20:44:20 +02:00
d3grandoandClaude Opus 5.5 34c77f9eac lan_e2e: check that a mooshroom's colour reaches the guest
New `mooshroom` check: a mooshroom summoned with Type "brown" is drawn
brown on the host and the guest, and a red one a bolt strikes
(MushroomCow.thunderHit) turns brown on both screens, with the server's
Type "brown". DATA_TYPE is synched data entry 18 (#3236).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 01:35:21 +07:00
GIGABAIT93andClaude Opus 5.5 4264d34f1c --offscreen: hide the window and leave the system pointer alone
An offscreen game renders into a texture, so its window only ever showed
white, yet entering a world grabbed the system pointer: on Windows it was
clipped to one pixel even while the window wasn't in front, until the
person alt-tabbed. The window is now created hidden, the grab is the
game's own state (agents still turn the view with mouse-move), and the
real mouse's raw motion never steers an offscreen game.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 20:32:38 +02:00
TraiNguyenVanandQoder d169963556 README: the banner drops the lede, so it carries no harness count
The site's hero says 281 differential harnesses; the README's prose says
more than 300. Keeping that line in a screenshot puts the two side by
side, so the banner is now the kicker, the headline and the landscape
(1600x600) and the README owns the count.

Co-Authored-By: Qoder <[email protected]>
2026-10-30 01:25:41 +07:00
Ludovic MarieandClaude Opus 5.5 20ffb882bd Merge the launcher layer (main c7065027b) into the docs layer
main's edit to the WebAssembly server mods section goes with the section.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 19:23:40 +01:00
Ludovic MarieandClaude Opus 5.5 38a315331d Merge the crates layer (main c7065027b) into the launcher layer
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 19:22:50 +01:00
TraiNguyenVanandQoder 573f398a2a README: the Settings picture is named launcher-settings.webp
The 0.2.26 capture replaced the Patch notes page but kept its file name.
Nothing else referenced it, so the name now follows the page it shows.

Co-Authored-By: Qoder <[email protected]>
2026-10-30 01:21:09 +07:00
TraiNguyenVanandQoder 37de786282 README: the banner is the website's hero, captured from playhorizon.dev
The banner was a hand-written HTML page (docs/media/banner.html) imitating
the design system. It now shows the real site: playhorizon.dev's hero in
its dark theme, captured with Playwright. The navbar, the hero's buttons
and the launcher version line are hidden first, so nothing in the picture
goes stale, and the crop starts 56 px above the kicker and ends at the
hero's bottom edge (1600x654).

banner.html is removed and its capture documented in
docs/media/brand/README.md; the README alt text follows, and the CI path
test uses banner.webp as its content-only example.

Co-Authored-By: Qoder <[email protected]>
2026-10-30 01:11:50 +07:00
d3grandoandClaude Opus 5.5 e09cbf2b9e Merge origin/main into fix-3091
Conflicts kept from both sides:
- mobs/ai.rs: parrots register through parrot::register (flight), next to
  main's own rabbit and ocelot arms.
- tools/test_mob_ai_parity.py: the parrot scenarios and main's wolf_hurt.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 01:07:41 +07:00
GIGABAIT93 88e10db8e5 Merge branch 'main' of https://github.com/horizon-rust/horizon into feat/launch-api-client
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-10-29 19:54:47 +02:00
Ludovic MarieandClaude Opus 5.5 1c4b029b95 Merge the launcher change (main d009ebd) into the docs
main's content mod docs (items patch their template's components) kept; its
change to the WebAssembly API reference goes with the page.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 18:54:01 +01:00
Ludovic MarieandClaude Opus 5.5 31c0c3dfe4 Merge the crate removal (main d009ebd) into the launcher change
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 18:52:54 +01:00
Federico Tagliani fea840e0d4 Merge origin/main into fix/break-effects-for-popped-blocks 2026-10-29 18:38:08 +01:00
Federico TaglianiandClaude Sonnet 5.5 582331c3ea lan_e2e: popped_break_effects mines by hand in survival, torch on top
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-29 18:32:47 +01:00
GIGABAIT93 8b65dc4516 Merge branch 'main' of https://github.com/horizon-rust/horizon into feat/launch-api-client
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-10-29 19:26:44 +02:00
Federico TaglianiandClaude Sonnet 5.5 eed02f0cda lan_e2e: popped_break_effects check for blocks that pop off a mined support
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-29 18:20:27 +01:00
Claude f4bdc66781 Never wait on the owner's profile fetch: list its skin once it lands
The owner's login to its server thread is main's again: no properties
and no wait. The player list gives the integrated server's owner the
signed-in account's properties instead (Hub::player_info), taken from
the background fetch only if it has already answered, so a slow, failing
or absent network can't delay opening a world. A LAN guest joins after
the world is open, well after the fetch.

Tests: the owner's login doesn't wait on a fetch that never answers; the
player list's owner entry before, after and without the fetch; and in
tests/lan_bot.rs (needs the prepared resources) a TCP guest's player
list carries the owner's textures and none of its own.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CxQvrTahuwEpRxJpTvWpjG
2026-10-29 17:15:39 +00:00
DenisHumenandClaude Opus 5.5 e4c1af1fff Launcher: the version and account menus close on a click outside them
Both menus only closed from their own button or a row. They now close on
a mouse-down anywhere outside them; a press that does so on the menu's
own button no longer reopens it when it ends. Fixes #3216.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 19:02:25 +02:00
Federico TaglianiandClaude Sonnet 5.5 dacde01da2 lan_e2e: merchant-close check for the trade screen closing out of reach
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-29 17:55:16 +01:00
Muhammad Asif 26c139f901 Keep launcher links on horizon-rust/horizon in release notes
Player-facing notes used GITHUB_REPOSITORY, so fork CI rewrote the
launcher releases href and failed
test_a_game_release_opens_with_the_way_to_the_launcher.
2026-10-29 21:51:25 +05:00
TraiNguyenVanandQoder d36f45e213 README: the launcher's Home and Settings pages, freshly captured
The two launcher pictures dated from the 0.1.5 UI: the Play page (now
Home) and Patch notes, which no longer shows what the launcher is for.
Both are replaced in place with 1280x800 captures of 0.2.26, and the
alt texts follow; nothing else in the README changes.

Taken in a scratch home with the offline name Steve, one instance and
v0.2.97 prepared, so no account or server data appears.

Co-Authored-By: Qoder <[email protected]>
2026-10-29 23:51:10 +07:00
levzzzandClaude Opus 5.5 d4f466623d Merge main into fix/issue-3024-worn-armor-glint: the armour glint moves to overlay 36
Main gave overlay 35 to text drawn with a polygon offset (TEXT_POLYGON_OFFSET_OVERLAY,
its own material in gfx/entity_batch.rs), so armour with 35 would be drawn as that
material. The test now lists it among the overlays the glint must not take.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 21:47:51 +05:00
levzzzandClaude Opus 5.5 0503ee0cbf gfx: rustfmt's order for the frame_gen module
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 21:40:52 +05:00
ThatCakeIsAPieandClaude Opus 5.5 c9999e3336 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]>
2026-10-29 11:36:10 -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
ThatCakeIsAPieandClaude Opus 5.5 3a37c32d8d Merge upstream main into feat/level-shape-1
world.rs keeps main's FxHashMap/FxHashSet imports beside this branch's
SectionMask. Main's writes_mark_exactly_the_reachable_sections sorted
(column, mask) pairs, and a SectionMask has no order: it sorts by the
column, which each pair has once.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 11:23:30 -05:00
GIGABAIT93andClaude Opus 5.5 91591e4dcf Player skins: skin_entries as a function, with a test
skin_entries holds the lookup update_remote_skins does, so a test can
play an NPC plugin's add, spawn and remove: the entity keeps the skin
its entry gave, a player spawned before its entry takes it when it
comes, and a removed entity forgets it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 18:14:55 +02:00
Federico TaglianiandClaude Sonnet 5.5 6180a78195 Ghast: target position, move control timing, look angle and shot sounds as vanilla
getSuitableFlyToPosition returns the first random position (no mirror under
the heightmap unless 64 tries fail); GhastMoveControl tests floatDuration
before decrementing it; GhastLookGoal turns with the float 180/pi and
Mth.atan2; the warning cry and the shot are level events whose pitch is the
client's, not draws from the ghast's random.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-29 17:14:38 +01:00
GIGABAIT93andClaude Opus 5.5 731896f222 Player entities keep the skin they found: NPCs show theirs
NPC plugins add an NPC's profile to the player list, spawn it, and take
the entry off the list a moment later to hide it there. Vanilla keeps
it: AbstractClientPlayer.getPlayerInfo looks the PlayerInfo up once and
holds it, so getSkin still reads its textures. PlayerInfoRemove dropped
the profile's textures here, so on tensa.co.ua every NPC showed its own
skin for about two seconds, then a default one (efe, zuri, sunny, kai).

level_player_textures keeps, per player entity, the textures it found
first, as level_player_names already keeps its name; update_remote_skins
resolves a player whose entry is gone from them. A new entity, a removed
one and a new level forget theirs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 17:58:21 +02:00
Federico TaglianiandClaude Sonnet 5.5 7703ebcf95 Cat: avoidance goal last, sprint while dashing
Cat.reassessTameGoals adds the player-avoidance goal after registerGoals, so
it is evaluated last. Cat.customServerAiStep sets sprinting while the move
control's speed modifier is 1.33: +30% movement speed, and Entity.baseTick
draws twice from the mob's random for the sprint dust. The probe names the
goal CatAvoidEntityGoal as vanilla's subclass.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-29 16:42:19 +01:00
spiderandClaude Opus 5.5 a91763379f FSR 3.1 optical flow, ported to WGSL (frame generation 1/3)
AMD FidelityFX SDK 1.1.4's optical flow (MIT): luma pyramids, scene change
detection, and a 7-level block search with filter and scale, as WGSL compute
passes that run on WebGPU's default limits. Not used by the game yet; the
interpolation and the setting follow.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 16:32:10 +01:00
Ludovic MarieandClaude Opus 5.5 8ca96ad967 Server brand: Horizon's server names itself "horizon"
The integrated, LAN and dedicated server sent minecraft:brand "vanilla"
(MinecraftServer.getServerModName) while Horizon's client sends "horizon".
A modded server names itself, so Horizon's does too: F3 shows it, and a
server admin can tell Horizon servers apart (bunchy7s, Discord).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 16:14:47 +01:00
Federico TaglianiandClaude Sonnet 5.5 c11d57d1f8 Break effects: a block that pops off when its support is mined plays its break sound and particles for the miner
The server sent every level event of an acting player's action with that
player excluded, so a torch or flower that popped off when the player mined
the block it stood on was silent (no break sound, no particles) for the
miner. Block.playerWillDestroy excludes the player only from the destroy
events of the block it breaks (and its other half); what the break sets off
afterwards goes through destroyBlock with no player. Only the events up to the
broken block's are the player's own now.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-29 15:46:59 +01:00
Federico TaglianiandClaude Sonnet 5.5 0b6c48be77 Zombified piglin: it runs Zombie.registerGoals too, with the turtle egg goal
ZombifiedPiglin extends Zombie and only overrides addBehaviourGoals, so it
keeps the turtle egg goal (priority 4) that Zombie.registerGoals adds. Horizon
left it out, so the goal never drew its start delay and the mob's random
stream drifted from vanilla's on its second tick.

Adds a zombified_piglin_player scenario to the mob_ai harness.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-29 15:23:54 +01:00
bunchy7s 815beda680 Refresh command block edit screen with server data
Send the command block's block-entity data when opening its screen and
apply incoming block-entity updates to the open edit screen. This makes
the command and output settings match the server instead of a stale
client copy, mirroring vanilla's openCommandBlock and
handleBlockEntityData behaviour.

Files:
- crates/horizon-client/src/game.rs
- crates/horizon-client/src/game/command_blocks.rs
- crates/horizon-client/src/game/remote.rs
- crates/horizon-client/src/lan/chunks.rs
2026-10-29 09:19:03 -05:00
ThatCakeIsAPieandClaude Opus 5.5 8e0939f3e8 Merge upstream main into feat/level-shape-1
Main's horizon-client-render split (#3186) moved chunk.rs, world.rs,
streaming.rs and gfx.rs, which this branch changes: section_mask.rs moves
with them into horizon-client-render, and horizon-client re-exports it
beside the other moved modules. lan/chunks.rs keeps both imports (main's
spawner traits and SectionMask).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 09:14:39 -05:00
umcekoandClaude Opus 5.5 a9d14449e8 Equipment assets and their textures load from content mods' namespaces
Worn armour found its equipment asset only under minecraft, and the atlas
packed only minecraft's entity textures, so a content mod's armour could
not draw as itself. EquipmentAssetManager reads `equipment/` in every
namespace: the armour layers now load from the content mods' namespaces
too, under `<ns>:<name>`, and their textures pack as
`<ns>:entity/equipment/<layer>/<path>`. A root without content mods reads
exactly what it did.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 17:13:19 +03:00
umcekoandClaude Opus 5.5 d39715cca0 Port pack: items of vanilla tool and armour classes keep their stats
With the oracle's facts, every item came over as a stick: a ported sword
had no damage, no durability and no mining rules. An item of a vanilla
tool or armour class now gets the iron item of its class and slot as its
template, and the oracle's durability, attribute modifiers (1.21.1's ids
upgraded), tool rules, enchantability and repair items as a component
patch, minus what the template already has. The mod's tool rules replace
the template's whole (its tier names its own incorrect-for-drops tag);
what 1.21.1 kept in the class, a sword sparing blocks in creative, stays
the template's. Simplest Emerald Gear's item stats: 4/49 -> 64/64 against
the mod on NeoForge.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 17:13:19 +03:00
umcekoandClaude Opus 5.5 f904f44047 Mod port harness: compare items' stats with the NeoForge oracle
The items check only saw an item exist with its stack size, so a ported
sword carried as a stick passed. The probe now dumps what Horizon's loaders
make of each item (durability, attribute modifiers, tool rules,
enchantability, repair items, equipment slot, food) and the harness checks
them against the oracle's components, as `item_stats`, with 1.21.1's
`generic.` attribute ids read as 26.3's and vanilla's newer tool rules
allowed beside the mod's own.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 17:13:19 +03:00
umcekoandClaude Opus 5.5 df96b6787d NeoForge oracle: export items' data components and armour facts
Each item now carries its default components (vanilla's codecs, 1.21.1's
form), its effective attribute modifiers (armour's come from a method,
not a component), enchantability and repair items, and for armour its
slot, defense, toughness, knockback resistance, equip sound and texture
layers: the ground truth an item port is compared against. The export
also creates its output folder instead of failing to write.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 17:13:19 +03:00
Federico TaglianiandClaude Sonnet 5.5 0c79bae87d Wither skeleton: its piglin target goal comes first among the priority 3 goals
WitherSkeleton.registerGoals adds its AbstractPiglin target goal before it
calls super.registerGoals, so among the priority 3 target goals it is
evaluated before the iron golem and turtle ones. Horizon added it last, so
the goals drew their random intervals in another order and a wither
skeleton's random stream drifted from vanilla's on its second tick.

Adds a wither_skeleton_player scenario to the mob_ai harness.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-29 15:04:55 +01:00
Federico TaglianiandClaude Sonnet 5.5 5071edb248 Mobs: the path maluses vanilla's constructors set (fire, lava, powder snow, cocoa, trapdoors)
Animal, AbstractVillager, AbstractPiglin and CopperGolem set FIRE to -1 and
FIRE_IN_NEIGHBOR to 16; Fox, Parrot, Frog, Goat, Wolf, Blaze, ZombifiedPiglin
and WitherSkeleton set their own. Horizon left every mob on PathType's
defaults (FIRE 16, FIRE_IN_NEIGHBOR 8), so animals, villagers and piglins
walked through fire that vanilla paths around.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-29 14:55:47 +01:00
Federico TaglianiandClaude Sonnet 5.5 51005638e0 Skeletons: a path out of the shade stops at the first node the sky sees
GroundPathNavigation.trimPath, with avoidSun set by RestrictSunGoal, cuts a
path that starts out of the sun at its first sunlit node, so a skeleton
chasing a player by day waits in the shade. Horizon never trimmed a path.

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

Fix #3719.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-29 16:45:47 +03:00
Federico TaglianiandClaude Sonnet 5.5 94eff29428 Villagers: the trade screen closes when the trader is gone or out of reach
AbstractVillager.stillValid keeps a trade screen open only while the trader
lives and the player is within the entity interaction range plus 4 of its
box. Neither the host's own trade screen nor a guest's (which includes the
single-player window on the server thread) was checked, so the screen
stayed open after walking away.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-29 14:36:20 +01:00
Federico TaglianiandClaude Sonnet 5.5 136349ae95 Villagers: a baby that grows up gets the adult brain (Villager.ageBoundaryReached)
Horizon built a villager's brain once, with its baby flag, so a villager
that grew up kept the baby schedule (play) and never went to work. At the
age boundary the brain is now rebuilt as Villager.refreshBrain does: running
behaviours stop, the packed memories carry over and the sensors' first scans
are delayed by draws from the mob's random.

Adds villager_grows_up scenarios to the mob_ai harness.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-29 14:22:45 +01:00
Ludovic MarieandClaude Opus 5.5 5cdf9a9151 Docs: Coordinates HUD joins Horizon's official native mods
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 14:09:02 +01:00
Claude 26fa54369c Show your own skin: offline skins from the launcher, the host's skin on LAN
- The game now reads HORIZON_OFFLINE_SKIN / HORIZON_OFFLINE_SKIN_MODEL,
  which the launcher already sets for an offline name, and draws that skin
  (slim or classic) on the local player where neither the server nor a
  signed-in account gives one. It was read for capes only.
- On the window's own server thread (single player, and the world opened
  to LAN), the owner's memory connection now logs in with the signed-in
  account's profile properties (IntegratedServer.getSingleplayerProfile),
  so the player list carries the owner's signed skin to LAN guests instead
  of the default one. The profile is fetched while the world loads; the
  login waits for it at most 10 s.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CxQvrTahuwEpRxJpTvWpjG
2026-10-29 12:55:49 +00:00
Ludovic Marie 4812518327 Merge main into the branch
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
2026-10-29 13:53:24 +01:00
Ludovic MarieandClaude Opus 5.5 7daca92c4a rustfmt: sulfur cubes join the baby-flag group
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 13:53:01 +01:00
Username59138andClaude Opus 5.5 fa77b35140 Creative inventory: the Saved Hotbars tab, as vanilla shows it
The HOTBAR tab was skipped since the creative tabs came in (#49, "Not
covered: ... the saved-hotbars tab"), while saving and loading toolbars
with C or X and a hotbar key already worked.

- CreativeModeInventoryScreen.selectTab on the HOTBAR tab: the nine
  saved rows of hotbar.nbt; an empty row shows a paper in its own slot
  with creative_slot_lock and the item name inventory.hotbarInfo, named
  after the bound save and hotbar keys. The rows are read when the tab
  is selected and when the inventory opens on it.
- CustomCreativeSlot.mayPickup: a locked stack can't be clicked.
- Creative::shown gives a grid cell's stack, None for a toolbar's empty
  slot, for the clicks, the hover, the drawing and horizon-ctl.
- VanillaCreativeSearchOracle compared the tabs without the HOTBAR one,
  which Horizon didn't show; it now compares every tab vanilla shows
  (shouldDisplay). The tab's display items are empty on both sides: its
  contents come from HotbarManager when it is selected.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 15:52:12 +03:00
masa-masa-0314andZCode Agent 845dbae91b Fix a recursive fallback in the world content tables' accessors
`Game::items`/`recipes`/`stations` fell back to themselves when the world
had no data packs instead of to `Resources.rest`, so a world without packs
recursed until the thread spun at 100%: the window never joined its
integrated server and showed "Loading terrain..." forever.

Co-Authored-By: ZCode Agent <[email protected]>
2026-10-29 20:42:34 +08:00
Ludovic Marie a167a89215 Merge branch 'draft/remove-wasm-mods-crates' into draft/remove-wasm-mods-launcher 2026-10-29 13:39:00 +01:00
Ludovic Marie 953a5a84ee Merge branch 'draft/remove-wasm-mods-launcher' into draft/remove-wasm-mods-docs 2026-10-29 13:39:00 +01:00
jaszkusandjaszkus's AI agent 37a2cc0057 Add the Debug Stick
DebugStickItem: a left click selects a block-state property and a right click cycles its value (Shift reverses it), stored in the minecraft:debug_stick_state component. The client never breaks the block and swings on use; the server cycles the state and sends the block update.

The sneak direction follows the acting player's sneak input (host, toggled sneak or guest), a guest's command permission is swapped for its turn so game-master checks use the guest's level, and useOn returns the vanilla SUCCESS/FAIL, triggers ITEM_USED_ON_BLOCK on success and only swings the local actor's client.

Co-Authored-By: jaszkus's AI agent <[email protected]>
2026-10-29 13:37:04 +01:00
Ludovic Marie 029870bb90 Merge branch 'draft/remove-wasm-mods-launcher' into draft/remove-wasm-mods-docs
# Conflicts:
#	README.md
#	ROADMAP.md
#	roadmap/roadmap.json
#	roadmap/roadmap.toml
2026-10-29 13:32:46 +01:00
Shreyas d27a7cbce3 Sulfur cubes: align babyhood and egg offspring with vanilla 26.3
- Route sulfur cube baby flag through baby_index (entry 16) in lan.rs
- Remove client-side baby state derivation from size in remote_mob_data.rs
- Revert finalize.rs age_boundary_reached and set_baby to vanilla behavior
- Ensure spawn_egg_offspring sets size 1 on new sulfur cube offspring
- Drop lists.rs change from this branch
2026-10-29 18:00:54 +05:30
Ludovic Marie 1afbdca0f0 Merge branch 'draft/remove-wasm-mods-crates' into draft/remove-wasm-mods-launcher 2026-10-29 13:26:49 +01:00
martipopsandClaude Opus 5.5 88e7afbedf Controller movement on servers: the stick moves as the keys it sets do
A server only hears the movement keys (ServerboundPlayerInputPacket), and
vanilla's client moves exactly as they do (KeyboardInput.tick: 8 directions,
full speed). With analog movement a player walked at angles and speeds the
keys can't produce, and a server that predicts movement from them could pull
it back. On a server that isn't this game's own server thread the stick now
moves as its keys; single player keeps analog movement.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JLHp82HU35NWsN14K4taHT
2026-10-29 07:57:25 -04:00
martipopsandClaude Opus 5.5 5601fea75e Let /damage hurt players other than the acting one
cmd_damage only handled the window's own player and mobs: any other player
(a LAN guest, a dedicated server's player, a splitscreen player) fell through
to "Target is invulnerable". Run it as that player through for_player, as
/effect does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JLHp82HU35NWsN14K4taHT
2026-10-29 07:53:41 -04:00
jaszkusandjaszkus's AI agent e83ef39df4 Place and collect water in waterlogged blocks
A water bucket fills the clicked LiquidBlockContainer (stairs, slabs, fences, trapdoors and other SimpleWaterloggedBlocks) instead of the block next to it, and an empty bucket's SOURCE_ONLY ray now targets waterlogged blocks too, so their water can be picked up again. The empty-bucket use also reports the vanilla FAIL/PASS result, applies the sneak rule, keeps creative's filled-result behaviour and lets only creative players waterlog a barrier.

Co-Authored-By: jaszkus's AI agent <[email protected]>
2026-10-29 12:52:43 +01:00
d3grandoandClaude Opus 5.5 158075a8e8 Eating: the eater hears each bite once
Consumable.emitParticlesAndSounds plays its bite through
Player.playSound, which is level.playSound(player, ...): the server
sends it to everyone but the eater, whose own client plays it. A guest's
server turn played it into the broadcast to all, so the eater (every
single-player player, on the server thread) heard each bite twice: its
client's and the server's.

The server now sends a guest's Player.playSound to the others only, and
the client plays the last bite itself at USE_ITEM_COMPLETE
(completeUsingItem's emitParticlesAndSounds(16)), which it drew the
crumbs of but left silent. lan_e2e's eat check counts the bites heard
against the crumb bursts drawn.

Fixes #3129

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 18:41:12 +07:00
d3grandoandClaude Opus 5.5 e8c1a797c8 Mooshrooms keep their colour: save Type, sync DATA_TYPE at 18
MushroomCow.addAdditionalSaveData / readAdditionalSaveData were never
ported, so a brown mooshroom (summoned, or turned by lightning) loaded
back red. MushroomCow.DATA_TYPE is entry 18 (after AgeableMob's
DATA_BABY_ID and AGE_LOCKED), not 17: on a vanilla server the colour
change from MushroomCow.thunderHit never reached the renderer, and a
Horizon host sent an INT where vanilla clients expect AGE_LOCKED's
BOOLEAN.

Fixes #3236

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 18:39:41 +07:00
d3grandoandClaude Opus 5.5 9fa7dddbe4 Let parrots fly: flying navigation and move control, flapping and gliding
Parrot.createNavigation and Parrot.calculateFlapping had no port, so a
parrot walked and hopped like a land mob, fell like a stone off a block
and never flew, tame or not.

- Parrot(): FlyingMoveControl(10, false), whose idle tick lets gravity
  back on; the FIRE_IN_NEIGHBOR, FIRE and COCOA maluses at -1; and
  createNavigation's FlyingPathNavigation (no doors, floats).
- ParrotWanderGoal: in water, LandRandomPos(15, 15); otherwise (all but
  one time in a thousand) getTreePos, the first empty block with an
  empty one above over leaves or a log within 3/6/3 in betweenClosed
  order; else WaterAvoidingRandomFlyingGoal.getPosition, now shared as
  Mob::flying_stroll_pos.
- calculateFlapping after aiStep: the wing state (folded on the ground
  or as a passenger), and a fall through the air slowed to 0.6. A
  multiplayer client runs the wing part for the parrots it displays.
- Parrot.isFlapping/onFlap from Entity.processFlappingMovement: the
  entity.parrot.fly beat each half flapSpeed flown.
- canFlyToOwner: a parrot may teleport onto leaves next to its owner.

tools/test_mob_ai_parity.py gets four parrot scenarios (two on open
grass, one by a tree, one let go ten blocks up), on Peaceful because
Parrot.getAmbient's imitation roll uses the level's random. They match
vanilla on every tick; on main they diverge at tick 1.

Fixes #3091

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 18:29:08 +07:00
iLostName 3c9b2599d8 block placement: a click on a block is handled before the move behind it, so pillar blocks are heard
The hub applied a player's move before the game ran the UseItemOn sent ahead of it, so BlockItem.canPlace checked the box after the move: mid-jump the server placed a block the client had refused, and only the client plays the place sound. The move now waits for the next tick, as it already does behind a dig. Fixes #3568.
2026-10-29 11:50:46 +01:00
iLostName ce2b51090f minecart: right-clicking a tnt minecart uses the held item, so a crossbow shoots at it
MinecartTNT has no interact of its own, so Entity.interact passes and Minecraft.startUseItem goes on to the item; the server client ended every click on a minecart there. A command block minecart without a game master, and a rideable one when sneaking or ridden, pass the same way. Fixes #3588.
2026-10-29 11:48:45 +01:00
iLostName 30d33e077f off hand: filling a composter or using bone meal ends the click before the off-hand torch
ComposterBlock.useItemOn and BoneMealItem.useOn are SUCCESS on vanilla's client, so Minecraft.startUseItem never tries the off hand; the server client predicted both as passing. A sapling, azalea or mushroom still passes there, as isValidBonemealTarget needs a ServerLevel. Fixes #3536.
2026-10-29 11:44:34 +01:00
degepieandClaude Opus 5.5 35345be102 Fish form schools: port FollowFlockLeaderGoal (#3026)
Cod, salmon and tropical fish had only panic, player avoidance and random
swimming. AbstractSchoolingFish adds FollowFlockLeaderGoal (priority 5, no
flags): after a 100-110 tick start delay a fish joins a nearby school with
room or gathers the free fish around it, and followers path to their
leader while it lives and is within 11 blocks. FishSwimGoal stops while the
fish follows (canRandomSwim), a leader forgets an empty school on a 1/200
level-random roll, and schools hold 8 (5 for salmon).

Recruiting changes other fish, so it goes through two MobEvents that
apply_brain_events applies after the recruiter's tick.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-10-29 15:09:49 +05:00
masa-masa-0314andZCode Agent 6e2ac2522d Re-trigger the pull request checks (no run arrived for the head commit)
Co-Authored-By: ZCode Agent <[email protected]>
2026-10-29 17:42:05 +08:00
masa-masa-0314andZCode Agent e72a8c5c6e Data packs: load the world's packs into the content tables
A world's data packs now change what the game plays with, the way
`ReloadableServerResources` loads a server's: recipes, loot tables,
advancements and tags from `<world>/datapacks` merge over the resource
root's own, at world open and on `/reload`. A pack's recipe or
advancement replaces the game's file for the same id
(`SimpleJsonResourceReloadListener` hands out the top resource of each
stack); tag files join the game's entries with `replace` dropping the
lower sources (`TagLoader.load`). Worlds without packs keep loading
exactly as before.

`datapack::DataStore` collects the packs' `recipe/`, `loot_table/`,
`advancement/` and `tags/…/` files (`DataStore.files`, the built-in
pack excluded: its files are the resource root's own, which the tables
read from disk). `horizon_world::loot::PackOverlay` carries them into
the loaders: `Items` (loot tables, item/block/entity_type tags),
`Recipes`, `StationData` (smithing, stonecutting, brewing, the recipe
book index), `advancements::Tree` and `LootData` (structure-block
containers). The game keeps the pack tables in `Game::world_tables`
and reads them through `Game::items`/`recipes`/`stations` (the
`world_items!`-family macros give the same field-level borrows at
conflict sites), rebuilding them when the world opens and on
`/reload`, with the advancement tree and the loot and LAN sync caches
forgotten alongside.

Not covered yet: worldgen registries, trade/banner/enchantment
registry files from packs, and the prepare-time behavior bundles
(mining facts stay with `block-behaviours.json`).

Co-Authored-By: ZCode Agent <[email protected]>
2026-10-29 17:42:05 +08:00
Muhammad Asif c74d170fbc rustfmt pearl and wind-charge boxes 2026-10-29 14:36:21 +05:00
Ludovic MarieandClaude Opus 5.5 21773ca7f5 Merge main into this branch
streaming.rs moved to horizon-client-render (#3186) and main now sweeps far
columns only on some updates (if sweep); this branch's changes (no area at a
negative radius, rank_full_task, its test) are applied there.

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:27:37 +01:00
Muhammad Asif ce45e065c3 Let thrown pearls hit wind charges
AbstractWindCharge is pickable, so ThrownEnderpearl's
ProjectileUtil.getEntityHitResult can strike it and onHit teleports.
Horizon only targeted mobs and players, so a pearl passed through a
charge. Add the charge's entity box to the pearl's targets.
2026-10-29 14:23:49 +05: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
degepieandClaude Opus 5.5 861be0b075 Armour: worn pieces and the elytra show the enchantment glint
The player's armour layers (local player, other players, LAN guests)
never carried the glint, and the elytra drew with overlay 0. The armour
glint's overlay code, 13, was also the boat water mask's, so a glinting
layer drew depth only: an armour stand's enchanted chestplate was
invisible.

EquipmentLayerRenderer.renderLayers: each layer and its trim take the
armour glint when ItemStack.hasFoil, never the hurt tint (NO_OVERLAY).
ARMOR_GLINT moves to 35, past every material overlay of the entity
batch.

Fixes #3024.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-10-29 13:50:58 +05:00
v3nn7andCodex c4f7b4f964 Fix teleport commands without explicit targets
Co-Authored-By: Codex <[email protected]>
2026-10-29 09:23:48 +01:00
GIGABAIT93andClaude Opus 5.5 cbd1b1c93e Launch API: horizon-client's options for other launchers (#3545)
The game's side of the Launch API, experimental, version 0.1.0:
--game-dir (vanilla's --gameDir), --home, --fullscreen (vanilla's, not
saved to options.txt), --session-stdin and --launch-api, which prints
the API version and the options a launcher may pass as JSON.

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:19:25 +02:00
Senjin c075240fa3 Merge remote-tracking branch 'origin/main' into hdr-screenshot 2026-10-29 08:32:56 +01:00
Ludovic MarieandClaude Opus 5.5 7d43179850 Docs: the official native mods in the hook points section
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 07:46:57 +01:00
Ludovic MarieandClaude Opus 5.5 64ff13890a Launcher: WebAssembly mods need a native port
The registry keeps its WebAssembly mods (Mod Menu, lookat, blockscope…)
in the index, so the modpacks that need them keep installing. The browser
neither lists nor installs one: its versions say "needs a native port".
A modpack still installs it, with its code left out (the game doesn't run
it), and Installed shows "Not ported: its code (needs a native port…)".
A WebAssembly mod is a `.hmod` that may do more than add content; content
mods are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 07:46:57 +01:00
Ludovic MarieandClaude Sonnet 5.5 708f0b48bf Docs: WebAssembly code mods are gone; content and native mods remain
docs/mods.md is now about content mods (`cargo horizon-mod`, the manifest, the
content fence), native mods, publishing, the official native mods and a short
note on the removed WebAssembly mods. docs/api/mods.md and mods-reference.md
(the code API) are deleted. Other pages, the CLAUDE.md crate table and the
roadmap (five WebAssembly API items removed, others reworded for native mods)
no longer describe the sandbox, the SDK or the WIT.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-29 07:46:57 +01:00
Claude 0e4c964d0a Keep Player.attack's no-damage guard before the attacker's sprint-hit cut
Player.attack ends before hurtOrSimulate and causeExtraKnockback when
neither the scaled ATTACK_DAMAGE (clamped to [0, 2048], so Weakness can't
push it below 0) nor the scaled enchantment damage is left. The remote
path cut the attacker's speed on knockback alone, so an empty-hand sprint
hit under Weakness I still slowed the client.

The decision moves to attack_strength::attacker_slows_after_hit, with the
knockback formula shared with the local path as
attack_strength::extra_knockback, and unit tests for a charged and an
uncharged sprint hit, a knockback weapon, no sprint, a no-damage hit
under Weakness, enchantment damage alone, and a server-raised attack
speed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018ksHJwFp11RfYk6S9xnyCf
2026-10-29 14:06:14 +10:00
Claude 2228a7456e Apply the attacker's 0.6 speed cut after a knockback hit on another player
MultiPlayerGameMode.attack runs Player.attack on the client too. For a
mob the client's hurtOrSimulate fails, so the hit has no attacker side,
but RemotePlayer.hurtClient is true: a sprinting (or knockback-enchanted)
hit on another player runs causeExtraKnockback's attacker side on the
client, cutting horizontal speed to 0.6 and stopping the sprint. The
remote attack path only sent the packet, so after such a hit the client
kept its speed while the server's player slowed, and the server pulled
the player back (seen on Hypixel when sprint-hitting a nearby player).

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

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

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

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

Memory only: every value read is the same.

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

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

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

Every value read is the one in use today.

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 18:12:56 -05:00
ThatCakeIsAPieandClaude Opus 5.5 9e91278794 Chunk packets map between the level and the client's column by the level's shape
The LAN encoder encodes a level of a LevelShape: its chunks hold the
shape's column, of which the level's own sections go on the wire, and the
heightmaps it sends count the column's sections from the chunk's min_y
(client_heightmaps_in takes it). A section's block entities sit at the
chunk's min_y. The remote client decodes chunk and light packets into the
column its ChunkFormat holds (the vanilla one until the server's
dimension types set it) instead of the fixed -64..320. A test encodes a
131-section column and checks its sections and light layers all go out.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 18:12:56 -05:00
ThatCakeIsAPieandClaude Opus 5.5 fdb4e917c0 Saves read and write a chunk's own column, and SaveDir takes its level's shape
SaveDir::new takes the level's LevelShape from its caller instead of
guessing it from the folder name (LevelShape::for_dir goes). A saved chunk
is decoded into its level's column (decode_with, decode_proto), the
sections outside it left out; encoding, POI sections, heightmaps and the
saved light layers count sections from the chunk's own min_y, and distant
terrain read from saves spans the level's column. A test saves a
131-section chunk (yPos -4, 12-bit heightmaps) and reads it back whole,
and into the vanilla column without the sections above it.

Vanilla levels pass their vanilla shapes, so saves are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 18:12:55 -05:00
ThatCakeIsAPieandClaude Opus 5.5 c2b55479f1 World, streaming, meshing and the renderer read the level's column
World holds its level's LevelShape, and its chunks' column is the shape's.
Writes check the chunk's own range, the heightmap fallbacks and may_hold
read the column, and dirty section indexes are bounded by the mask instead
of the vanilla constant. Streaming marks the column's sections dirty, the
mesher places sections from the chunk's min_y, and the renderer builds its
occlusion graph, complete-column mask and section origins for the column
of the level it was cleared for (clear_sections takes it). A test streams
a 131-section level (To The Max's height) and checks that columns light
and mesh to y 2031, and that an edit at y 1800 relights and remeshes.

Every level is still the vanilla Overworld's shape here.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 18:12:54 -05:00
ThatCakeIsAPieandClaude Opus 5.5 a6d8409466 Light reads the chunk's column: flood fill, layers and relighting span its sections
light_chunk floods up to the centre chunk's own top, and the light it
returns, the saved and received light layers and the relighter's section
keys span the chunk's sections instead of the vanilla constants. The
relighter takes the column from the world it lights (LightWorld::layout,
the column its chunks hold), and LightChanges counts section indexes in
that column, so a taller level's sections near its top get relit and
remeshed too. Vanilla's column is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 18:12:54 -05:00
Lyle ColeandClaude Opus 5.5 46c62bdf2a LevelShape: each level's shape as one value, and the column its chunks hold
LevelShape (skylight, min_y, height, and now logical_height) moves from
save.rs to chunk.rs, where every subsystem can read it, and checks a
dimension type as its codec does. Its column is the span of sections a
chunk of the level holds: the level's own range widened to the vanilla
Overworld's -64..320, so vanilla's three levels keep the 24 sections
they have always had. ColumnLayout is that span
as a value; Chunk records its own min_y, and get, set, highest, light,
biome and the heightmap cache read the chunk's sections instead of the
constants.

Nothing makes a chunk of another column yet: vanilla is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 18:12:54 -05:00
Lyle ColeandClaude Opus 5.5 0c639904f1 Section sets past 32 sections: SectionMask replaces the u32 column masks
Dirty sections, the light edit log, random-tick and portal section caches,
the renderer's meshed-column masks and the LAN encoder's light masks held a
bit per section in a u32 or u64, which capped a column at 32 sections. A
256-bit SectionMask holds every layout a dimension type allows (254 sections
plus the two light borders). No behaviour change: the column is still 24
sections.

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

Vanilla reference: AbstractCubeMob.ID_SIZE, SulfurCube.setSpawnSize.

Fixes #3151
2026-10-28 17:13:45 +05:30
SenjinandClaude Opus 5.5 c7b02f8568 HDR: screenshots get an HDR10 copy, as a PNG tagged BT.2020 with PQ
F2 kept only the SDR frame. With HDR on it now also writes `…-hdr.png` beside
vanilla's screenshot: the HDR pass drawn once more into a 10-bit PQ target of its
own (so scRGB swapchains get one too), read back and saved as a 16-bit PNG with
the cICP chunk of PNG's third edition (BT.2020, PQ, full range) and cLLI (peak,
paper white). Each 10-bit value keeps its top bits below it, so 0 stays 0 and
1023 becomes 65535. Vanilla's screenshot is written as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 11:54:07 +01: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
FusselTVandClaude Opus 5.5 8401cde519 Dedicated server: nothing kept around the spawn while nobody is on
While nobody was online, the dedicated server behaved as if a player
stood at the world spawn: at startup it streamed a view-distance area
there and placed a stand-in player, and afterwards it kept that area
loaded, generated and ticked. Vanilla only loads the chunks its spawn
spiral tests (MinecraftServer.setInitialSpawn: getChunk(FULL), whose
3x3 is decorated in one generation task), has no spawn ticket, and finds
a player's spawn when one joins. In a pre-generated world the startup
area decorated in another order made ore blobs and a mineshaft start's
saved flags near spawn differ from vanilla's.

A streaming radius below 0 now means no area around the centre. The
dedicated server only finds the world spawn, loading each tested chunk
as an extra area of radius -1 ranked as one FULL task (x-outer,
z-inner), and keeps the radius at -1 while no guest is online.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 09:36:12 +01:00
levzzz a3489d33b4 Merge remote-tracking branch 'upstream/main' into fix/root-vehicle-restore 2026-10-27 10:53:42 +05:00
Ludovic Marie 9bb3fc077e Merge #2570 2026-10-27 06:43:30 +01:00
Ludovic MarieandClaude Opus 5.5 2aa9056259 Structure mobs test: pass the world preset for_dimension now takes
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:43:30 +01:00
Ludovic Marie 8117e2767f Merge main 2026-10-27 06:43:23 +01:00
Ludovic MarieandClaude Opus 5.5 bc5ec6fb46 Structure mobs test: pass the world preset #2570 added to for_dimension
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:42:39 +01:00
Ludovic Marie e5ebeddb90 Merge #2585
# Conflicts:
#	crates/horizon-client/src/entity_nbt.rs
2026-10-27 06:42:33 +01:00
Ludovic Marie d0b42fb88e Merge #2570 2026-10-27 06:42:19 +01:00
Ludovic Marie bca77d1ac4 Merge origin/main 2026-10-27 06:42:18 +01:00
Ludovic MarieandClaude Opus 5.5 815c09b1f8 Merge #2214 (world presets in the create-world screen)
The superflat screen and the agent's state read the world preset that
#2214 adds instead of the old flat flag; drop a stray parenthesis in
the horizon-prepare hint.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:40:03 +01:00
Claude Codeandminimax-m3.1-flash 0104eeaf33 Format the world preset test
rustfmt keeps the registry read on one line.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 05:06:02 +05:00
levzzz 9c16d40db1 Merge remote-tracking branch 'upstream/main' into fix/root-vehicle-restore 2026-10-27 04:54:43 +05:00
Claude Codeandminimax-m3.1-flash 729a224eef Read the test registry without the explicit deref
clippy's explicit_auto_deref asks for the LazyLock itself, which
derefs to the registry the generator data keeps.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 04:52:31 +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
FusselTVandClaude Opus 5.5 22054eee90 Worldgen: chunk generation spawns its animals as vanilla, ticking or not
NaturalSpawner.spawnMobsForChunkGeneration runs in the generation
pipeline (spawnOriginalMobs), with the WorldGenRegion's random for the
spawn rules and finalizeSpawn. Horizon ran it from the game tick for
chunks that were fresh and lit, so:

- a world generated with ticks frozen, as a seed generator does, got no
  animals at all, and a chunk saved before a tick saw it lost them;
- the rules and finalizeSpawn drew from other randoms, and the reduced
  spawning::finalize gave no variants, babies or group data;
- the spawn list came from the biome's noise cell, not the zoomed biome
  (fullResolutionBiomes) vanilla reads;
- members of a pack were kept apart, though a WorldGenRegion has no
  entities in the way.

A chunk now gets its animals as soon as it is lit, whatever the tick
state. The positions come from the same selection, the rules and the
finalize from the chunk's region random, and each mob is made with its
constructor's state, finalized like a structure mob
(Reason::ChunkGeneration, group data per pack) and loaded from the NBT
generation would save. The natural spawn oracle's generation mode sees
no entities, as a WorldGenRegion.

Goats and frogs keep their long jump and ram cooldowns under vanilla's
memory names (long_jump_cooling_down, ram_cooldown_ticks), and rabbits
save the attack damage attribute Rabbit.setVariant makes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 00:37:18 +01:00
Claude Codeandminimax-m3.1-flash 52f0e38d41 Add the amplified, large-biome and single-biome world types
The create screen only offered Default and Superflat, so the amplified
world type of #1873 could not be chosen at all, and the generator
always built the overworld's noise settings.

The world-type button now cycles through the presets vanilla's
WorldPresetTags.NORMAL tag holds (normal, flat, large_biomes, amplified,
single_biome_surface), the chosen preset is written into the new world's
world_gen_settings.dat with the generator
worldgen/world_preset/<preset>.json has, and the generator is built from
that preset, so an amplified world gets the amplified noise settings.
A world opened again reads its preset back from its generator.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 04:20:01 +05:00
FusselTVandClaude Opus 5.5 1a80673d50 Worldgen: structure mobs save the fields vanilla's chunk keeps
ProtoChunk.addEntity saves a structure's entity as soon as it is placed,
and the chunk loads it again when it becomes full, so vanilla keeps what
each entity's own save writes. Horizon kept the template's NBT with its
model on top, and its model left out some fields:

- the keys in vanilla's templates that no entity reads (food_level,
  lastRestock, CatType, OwnerUUID, ...) are dropped;
- villagers save VillagerDataFinalized and their age (AgeableMob writes
  Age, ForcedAge and AgeLocked whenever canBeABaby, not only for
  creatures; never for a frog, a parrot or a zombie nautilus), and the
  age is read back for the same mobs (a baby villager or hoglin no
  longer grows up on load);
- iron golems and the other neutral mobs save anger_end_time;
- zombies, drowned and piglins save the movement speed attribute that
  their setBaby creates on load;
- a drowned with a nautilus shell keeps drop_chances (offhand 2.0), and
  saved drop chances are read back;
- the rider of a new chicken jockey is saved as it was when the chicken
  was added, before its equipment and attributes, as vanilla's chunk
  saves the chicken with its passenger at that point.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 23:12:29 +01: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
Ludovic Marie 67591edf8f Merge remote-tracking branch 'origin/main' into perf/structure-start-convoy 2026-10-25 21:31:03 +01:00
Ludovic Marie e2ca936487 Merge main 2026-10-25 14:21:48 +01:00
Ludovic MarieandClaude Opus 5.5 cc2a2a8741 Lithostitched's limited and guaranteed elements and its processor-list modifiers
The game reads lithostitched:limited and lithostitched:guaranteed pool
elements as written (spec-lithostitched §3): a limited element places its
delegate at most `limit` times per start, a guaranteed one is tried first
at each jigsaw of its pool until `count` are placed, and neither goes below
a parent shallower than `min_depth` (IQ-10). Layouts stay vanilla's (L5);
with no such element vanilla's draws are unchanged. Saved pieces of these
types read as their delegate.

The carry keeps them (delegating still places its delegate), writes
add_processor_list_processors as a processor-list addition (§2.2, 1.8.1's
`processor_lists` key) and both modifier kinds with their priority; the
game merges additions by `horizon:priority`, then mod and file order.

Harnesses: the layout harness checks the probe pack's limits on Horizon's
own probe villages, and the blocks harness gets a lithostitched-probe
profile; the probe pack is built into a jar from the oracle's profile.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:56:59 +01:00
Ludovic MarieandClaude Opus 5.5 4eb85dbdf9 World generation: workers build free structure starts instead of queueing
ChunkGenerator.createReferences asks for the structure starts of the 17x17
chunks around each chunk. Every worker asked for them in the same order and
waited on the chunk's work lock while another worker built its starts, so at
a cold start (a new world, the first structures of a seed) the workers queued
one behind the other: in a fresh generator with 12 workers on the Mac mini,
39% of the generation time was spent blocked in that lock (sampled).

references() now builds the starts no other worker is building first, and
waits for the rest only then; the work locks' map is sharded by chunk, as
the start cache already is, because every worker now asks for the locks of
the whole neighbourhood. Starts and the order of the references are the same:
each chunk's starts are still built once and kept by the first builder.
Blocked time in the same run: 39% -> 1.4% of generation samples.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 06:11:58 +01:00
Ludovic MarieandClaude Opus 5.5 1102fa42df macOS on Intel: x86_64 builds of the game and the launcher (#1161)
release.yml and launcher.yml build x86_64-apple-darwin on the Mac mini's
runner beside Apple Silicon: horizon-macos-x64.zip, and the launcher's
horizon-launcher-macos-x64.zip and .dmg, signed and published with the
rest, on the same unsigned path as arm64. Their checks and smoke tests run
the x86_64 programs under Rosetta 2. A launcher picks its assets from one
table (PLATFORMS) by OS and processor, and a test checks the workflows
publish every asset in it. package.sh names the assets per architecture
and refuses a binary built for the other one. The server installer, the
website's Download button (Chromium tells Intel Macs apart; other browsers
get an Intel Mac link) and the docs know the Intel build.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 08:06:35 +02:00
892 changed files with 116012 additions and 12806 deletions

No files matched your search

+6
View File
@@ -21,6 +21,12 @@
<!-- The vanilla classes and methods this follows (tools/rea_vanilla.py), and the harness that checks it.
"Horizon-only" for a feature vanilla doesn't have; "none" for tooling or docs. -->
## Other projects' code
<!-- "none", or for each project you read or copied from: its link at the commit, its license (only MIT,
BSD-2-Clause, BSD-3-Clause, Apache-2.0, ISC, zlib, 0BSD, MIT-0, Unlicense or CC0-1.0), and what you took.
CONTRIBUTING.md "Only original code". -->
## Self-check
<!-- Paste the block `python3 tools/pr_ready.py` prints (`--base upstream/main` from a fork). -->
+70 -12
View File
@@ -1,7 +1,7 @@
name: checks
# verify: every pull request and every push to main, on GitHub's runners (the repository is public). It is the
# check main requires, so it never has a condition: a skipped job counts as passed, and every run of this workflow
# must report a real verify (a pull request that changes no Rust finishes it in seconds instead).
# check main requires, so it always runs: a skipped job counts as passed, and every run of this workflow must report
# a real verify. It gathers the python, clippy and tests jobs (a change to docs alone skips their Rust steps).
# parity: the vanilla harnesses a pull request's changes can affect, on the Mac mini's runner, which holds the
# maintainer's own Minecraft files (Horizon never downloads the game); .github/workflows/parity.yml. Only for a pull
# request that changes a harness, oracle, probe or the runner: the rest are checked on main by parity-main. A fork's pull
@@ -24,7 +24,10 @@ env:
HORIZON_TELEMETRY_URL: "off"
jobs:
verify:
# verify's checks run as three jobs at once (python, clippy, tests: ~4 min instead of ~8 one after another), and
# verify gathers them. A failed job makes its dependents skip, and a skipped required check counts as passed, so
# verify runs always() and fails unless all three passed.
python:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
@@ -34,6 +37,28 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: '3.12'
# A pull request adds only its own commits to main, so it reads those; main's pushes read every ref's history.
- name: Keep game artifacts out of Git
env:
EVENT: ${{ github.event_name }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
python tools/check_public_tree.py
if [ "$EVENT" = pull_request ]; then
python tools/check_public_tree.py --range "$BASE_SHA..$HEAD_SHA"
else
python tools/check_public_tree.py --history
fi
- name: Test offline research tooling
run: python -m unittest discover -s tests -v
clippy:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Does this pull request need Rust checks?
if: github.event_name == 'pull_request'
id: rust
@@ -41,22 +66,16 @@ jobs:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: python3 tools/ci/rust_changes.py "$BASE_SHA" "$HEAD_SHA" >> "$GITHUB_OUTPUT"
- name: Keep game artifacts out of Git
run: |
python tools/check_public_tree.py
python tools/check_public_tree.py --history
- name: Test offline research tooling
run: python -m unittest discover -s tests -v
- name: Install Rust toolchain and the ALSA and udev headers the client links against
if: steps.rust.outputs.required != 'false'
run: |
sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
rustup toolchain install stable --profile minimal --component rustfmt --component clippy
# Dependencies and the workspace's own build, saved from main's runs (pull requests read main's cache).
# Dependencies and the workspace's own check, saved from main's runs (pull requests read main's cache).
- uses: Swatinem/rust-cache@v2
if: steps.rust.outputs.required != 'false'
with:
shared-key: verify
shared-key: verify-clippy
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Check formatting and lint
if: steps.rust.outputs.required != 'false'
@@ -65,8 +84,35 @@ jobs:
cargo +stable clippy --workspace --all-targets -- -D warnings
# The server build (released as horizon-engine): the game without sound or controllers.
cargo +stable clippy -p horizon-client --all-targets --no-default-features -- -D warnings
# Libraries, binaries and integration tests, then doc tests: not the ~275 examples, which clippy above already
tests:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Does this pull request need Rust checks?
if: github.event_name == 'pull_request'
id: rust
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: python3 tools/ci/rust_changes.py "$BASE_SHA" "$HEAD_SHA" >> "$GITHUB_OUTPUT"
- name: Install Rust toolchain and the ALSA and udev headers the client links against
if: steps.rust.outputs.required != 'false'
run: |
sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
rustup toolchain install stable --profile minimal
# Dependencies and the workspace's own build, saved from main's runs (pull requests read main's cache).
- uses: Swatinem/rust-cache@v2
if: steps.rust.outputs.required != 'false'
with:
shared-key: verify
save-if: ${{ github.ref == 'refs/heads/main' }}
# Libraries, binaries and integration tests, then doc tests: not the ~275 examples, which clippy already
# type-checks and the parity job builds and runs (they were most of this step's build and link time).
# cargo test, not nextest: nextest's process per test reloads each binary's shared tables for every test, and on
# these 4-core runners it was no faster (#4978).
- name: Test Rust core and voxel kernel correctness
if: steps.rust.outputs.required != 'false'
run: |
@@ -75,6 +121,18 @@ jobs:
# prepared vanilla data, so without it they pass at once.
cargo +stable test --workspace --lib mod_content::tests:: -- --ignored
cargo +stable test --workspace --doc
verify:
if: always()
needs: [python, clippy, tests]
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Did python, clippy and tests pass?
env:
RESULTS: ${{ join(needs.*.result, ' ') }}
run: |
echo "python, clippy, tests: $RESULTS"
for result in $RESULTS; do [ "$result" = success ] || exit 1; done
# The launcher is its own workspace (launcher/Cargo.toml), so verify's cargo runs never build it. A pull request that
# changes it or a crate it builds is checked here, and so is every push to main: two pull requests can each pass
# alone and break it together (#2059 and the registry ratings did, 2026-10-26).
+3 -1
View File
@@ -22,7 +22,9 @@ jobs:
if: >-
github.event_name == 'issue_comment' && github.event.comment.user.type != 'Bot' && (
startsWith(github.event.comment.body, '/claim') ||
startsWith(github.event.comment.body, '/unclaim'))
startsWith(github.event.comment.body, '/unclaim') ||
startsWith(github.event.comment.body, '🤖 Agent: /claim') ||
startsWith(github.event.comment.body, '🤖 Agent: /unclaim'))
# One command per issue at a time, so two `/claim`s can't both find it free. On the job, not the workflow:
# the skipped runs of ordinary comments must not cancel a waiting one.
concurrency:
+23 -10
View File
@@ -1,6 +1,7 @@
name: game build
# Builds the game for every platform from `ref`: the Java exporters, then Linux and the Linux server and Windows on
# GitHub, macOS on the Mac mini's runner with a 1500-frame offscreen run of a new world (smoke-test.json). Called by
# GitHub, macOS on the Mac mini's runner with a 1500-frame offscreen run of a new world (smoke-test.json): Apple
# Silicon, and Intel cross-built there for x86_64-apple-darwin and checked under Rosetta 2 (#1161). Called by
# release.yml (the releases) and nightly-build.yml (the opt-in Nightly), always from main's copy of this file: the
# caller's commit, never the one being built. It holds no secrets: signing the checksums is the callers' own job,
# which never runs what this one built. See docs/release-flow.md.
@@ -104,6 +105,13 @@ jobs:
mac: true
dir: horizon-macos-arm64
asset: horizon-macos-arm64.zip
# Intel Macs: built on the same runner for another target; its checks and smoke test run the x86_64
# programs under Rosetta 2.
- name: macos-x64
mac: true
target: x86_64-apple-darwin
dir: horizon-macos-x64
asset: horizon-macos-x64.zip
runs-on: ${{ matrix.mac && (inputs.trusted && vars.MAC_RUNNER || fromJSON('["self-hosted", "macOS", "horizon-nightly"]')) || matrix.os }}
# Notarizing the zip can take a few minutes.
timeout-minutes: ${{ matrix.mac && 60 || 40 }}
@@ -114,6 +122,8 @@ jobs:
HORIZON_VERSION: ${{ inputs.version }}
# A nightly's date and commit ("2026-10-28 0c02067"), which the game shows; empty for a release.
HORIZON_NIGHTLY: ${{ inputs.nightly }}
# Where cargo puts the programs: target/release, or target/<target>/release for another target.
BIN: ${{ matrix.target && format('target/{0}/release', matrix.target) || 'target/release' }}
defaults:
run:
shell: bash
@@ -128,6 +138,8 @@ jobs:
# keeps its preparations outside the checkout.
clean: true
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
if: runner.environment == 'github-hosted'
with:
@@ -138,11 +150,11 @@ jobs:
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
- name: Build the client, horizon-ctl, horizon-prepare and horizon-server
run: cargo build --release --locked -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
- name: Every program the release folder takes was built
run: |
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
test -f "target/release/$binary${{ matrix.exe }}" || { echo "::error::target/release/$binary${{ matrix.exe }} is missing"; exit 1; }
test -f "$BIN/$binary${{ matrix.exe }}" || { echo "::error::$BIN/$binary${{ matrix.exe }} is missing"; exit 1; }
done
- name: The signing keychain, unlocked
if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != '' && inputs.developer_id
@@ -157,15 +169,15 @@ jobs:
run: |
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
codesign --force --options runtime --timestamp --keychain "$MACOS_SIGN_KEYCHAIN" \
--identifier "dev.horizon.${binary#horizon-}" --sign "$MACOS_SIGN_IDENTITY" "target/release/$binary"
codesign --verify --strict --verbose=2 "target/release/$binary"
--identifier "dev.horizon.${binary#horizon-}" --sign "$MACOS_SIGN_IDENTITY" "$BIN/$binary"
codesign --verify --strict --verbose=2 "$BIN/$binary"
done
- name: Only system libraries
# Windows: no non-system DLL (a "DLL not found" dialog on some players' machines). Linux: the standard
# loader and libraries every desktop distribution has. macOS: only the system's own libraries.
run: |
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
file="target/release/$binary${{ matrix.exe }}"
file="$BIN/$binary${{ matrix.exe }}"
if [[ "$RUNNER_OS" == macOS ]]; then
others=$(otool -L "$file" | tail -n +2 | awk '{print $1}' | grep -v -E '^(/usr/lib/|/System/Library/)' || true)
[[ -z "$others" ]] || { echo "::error::$binary links $others"; exit 1; }
@@ -176,7 +188,7 @@ jobs:
- name: Every program starts
run: |
set -euo pipefail
bin=target/release
bin=$BIN
"$bin/horizon-client${{ matrix.exe }}" --help | grep -q -- '--resources'
"$bin/horizon-ctl${{ matrix.exe }}" --help > /dev/null
"$bin/horizon-prepare${{ matrix.exe }}" --help | grep -q -- '--minecraft-dir'
@@ -202,7 +214,7 @@ jobs:
home="$RUNNER_TEMP/horizon-smoke"
rm -rf "$home" && mkdir -p "$home"
version=$(sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' research-lock.json | head -1)
client="$PWD/target/release/horizon-client"
client="$PWD/$BIN/horizon-client"
resources="$PWD/.local/horizon-client/$version"
(cd "$home" && HORIZON_HOME="$home" HORIZON_NO_AUDIO=1 "$client" --resources "$resources" --offscreen \
--world smoke --seed 1 --render-distance 4 --world-frames 1500 --timeout-seconds 180 \
@@ -217,8 +229,9 @@ jobs:
EOF
# Published with the release; the publish job checks it again before it publishes anything.
cp "$home/smoke.json" "$RUNNER_TEMP/smoke-test.json"
# One smoke-test.json per build, the Apple Silicon one's; the Intel build must pass its own run too.
- uses: actions/upload-artifact@v4
if: runner.environment == 'self-hosted'
if: runner.environment == 'self-hosted' && !matrix.target
with:
name: smoke-test.json
path: ${{ runner.temp }}/smoke-test.json
@@ -231,7 +244,7 @@ jobs:
- name: Package
run: |
set -euo pipefail
cargo run --locked -p xtask -- package "target/release/horizon-client${{ matrix.exe }}" "dist/${{ matrix.dir }}"
cargo run --locked -p xtask -- package "$BIN/horizon-client${{ matrix.exe }}" "dist/${{ matrix.dir }}"
cd dist
rm -f "${{ matrix.asset }}"
case "${{ matrix.asset }}" in
+36 -9
View File
@@ -8,7 +8,8 @@ name: launcher
# feature. The version and the tag are given to the build (HORIZON_LAUNCHER_VERSION, HORIZON_LAUNCHER_TAG): no bump
# commits.
# Never marked latest. Pushes to launcher branches only build. RELEASES_PAUSED=1 stops releases.
# macOS: with the repository variable MACOS_SIGN_IDENTITY set, Horizon.app and the .dmg are signed with
# macOS: Apple Silicon, and Intel cross-built for x86_64-apple-darwin on the same runner, its tests and smoke test
# under Rosetta 2 (#1161). With the repository variable MACOS_SIGN_IDENTITY set, Horizon.app and the .dmg are signed with
# Developer ID, notarized and stapled, from the runner's signing keychain (packaging/macos/README.md);
# unset (forks), they are signed ad hoc.
# gh workflow run launcher --ref <branch> # dry run: builds everything, publishes nothing
@@ -105,6 +106,13 @@ jobs:
mac: true
asset: horizon-launcher-macos-arm64.zip
binary: horizon-launcher
mac_arch: arm64
- name: macos-x64
mac: true
target: x86_64-apple-darwin
asset: horizon-launcher-macos-x64.zip
binary: horizon-launcher
mac_arch: x64
- name: windows
os: windows-latest
asset: horizon-launcher-windows-x64.zip
@@ -117,6 +125,8 @@ jobs:
timeout-minutes: ${{ matrix.mac && 60 || 40 }}
env:
RUSTFLAGS: ${{ matrix.rustflags }}
# Where cargo puts the launcher: target/release, or target/<target>/release for another target.
BIN: ${{ matrix.target && format('target/{0}/release', matrix.target) || 'target/release' }}
# Horizon's Azure app for Microsoft sign-in (docs/microsoft-account.md): a repository
# variable, not a secret. Unset, the launcher says sign-in is not configured.
HORIZON_MSA_CLIENT_ID: ${{ vars.HORIZON_MSA_CLIENT_ID }}
@@ -140,6 +150,8 @@ jobs:
# parity jobs, and nothing they leave may end up in a launcher release (target/ is rebuilt from scratch).
clean: true
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
if: runner.environment == 'github-hosted'
with:
@@ -160,17 +172,17 @@ jobs:
echo "LAUNCHER_FEATURES=gpui/runtime_shaders" >> "$GITHUB_ENV"
fi
# Every test target (the library, the binary, tests/), on each system the launcher ships for.
- run: cargo test --release --locked ${LAUNCHER_FEATURES:+--features "$LAUNCHER_FEATURES"}
- run: cargo build --release --locked ${LAUNCHER_FEATURES:+--features "$LAUNCHER_FEATURES"}
- run: cargo test --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} ${LAUNCHER_FEATURES:+--features "$LAUNCHER_FEATURES"}
- run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} ${LAUNCHER_FEATURES:+--features "$LAUNCHER_FEATURES"}
- name: The version is the release's
run: |
out=$(target/release/${{ matrix.binary }} --version)
out=$("$BIN/${{ matrix.binary }}" --version)
echo "$out"
expected="${HORIZON_LAUNCHER_VERSION:-$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)}"
[[ "$out" == "horizon-launcher $expected" ]] || { echo "::error::expected horizon-launcher $expected"; exit 1; }
- name: Only system libraries
run: |
file=target/release/${{ matrix.binary }}
file="$BIN/${{ matrix.binary }}"
case "$RUNNER_OS" in
Windows) python ../tools/check_imports.py "$file" ;;
macOS) others=$(otool -L "$file" | tail -n +2 | awk '{print $1}' | grep -v -E '^(/usr/lib/|/System/Library/)' || true)
@@ -185,13 +197,28 @@ jobs:
export HORIZON_LAUNCHER_HOME="${RUNNER_TEMP//\\//}/horizon-launcher-smoke"
export HORIZON_HOME="$HORIZON_LAUNCHER_HOME/game"
rm -rf "$HORIZON_LAUNCHER_HOME"
launcher="$PWD/target/release/${{ matrix.binary }}"
launcher="$PWD/$BIN/${{ matrix.binary }}"
# GitHub's API rate-limits shared runner addresses now and then: try a few times.
retry() { for i in 1 2 3 4; do "$@" && return 0; sleep $((i * 15)); done; return 1; }
retry "$launcher" versions
"$launcher" links --json
# The Intel Mac launcher can come out before any game build has its asset (#1161): then there is nothing
# to install yet. Only that entry skips, and only on a version list that parsed and had no build here
# (exit 3); a launcher or a script that fails still fails the step.
if [[ "${{ matrix.name }}" == macos-x64 ]]; then
versions=$(retry "$launcher" --json versions)
no_build='import json, sys; sys.exit(3 if not any(r["available_here"] for r in json.load(sys.stdin)) else 0)'
status=0
python3 -c "$no_build" <<<"$versions" || status=$?
if [[ $status == 3 ]]; then
echo "::warning::No game build has an asset for ${{ matrix.name }} yet: install not tested"
rm -rf "$HORIZON_LAUNCHER_HOME"
exit 0
fi
[[ $status == 0 ]] || exit $status
fi
retry "$launcher" install latest
"$launcher" versions --installed
"$launcher" links --json
# `install latest` is the Nightly since #3342 (versions/nightly-<date>-<commit>), a vX.Y.Z before.
clients=("$HORIZON_LAUNCHER_HOME"/versions/*/horizon-client*)
client="${clients[0]}"
@@ -224,11 +251,11 @@ jobs:
# Developer ID): packaging/macos.
python3 -m venv "$RUNNER_TEMP/dmg" && "$RUNNER_TEMP/dmg/bin/pip" install --quiet dmgbuild==1.6.5
version=${HORIZON_LAUNCHER_VERSION:-$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)}
DMGBUILD="$RUNNER_TEMP/dmg/bin/dmgbuild" ../packaging/macos/package.sh target/release/${{ matrix.binary }} "$version" out
DMGBUILD="$RUNNER_TEMP/dmg/bin/dmgbuild" ../packaging/macos/package.sh "$BIN/${{ matrix.binary }}" "$version" out ${{ matrix.mac_arch }}
rm -rf out/Horizon.app
else
mkdir -p dist
cp target/release/${{ matrix.binary }} dist/
cp "$BIN/${{ matrix.binary }}" dist/
if [[ "${{ matrix.asset }}" == *.tar.gz ]]; then
(cd dist && tar czf ../out/${{ matrix.asset }} ${{ matrix.binary }})
else
+6 -4
View File
@@ -172,7 +172,8 @@ jobs:
EPHEMERAL: ${{ needs.pick.outputs.dry_run == 'true' && 'yes' || '' }}
run: |
bash tools/ci/sign_release.sh nightly dist/horizon-linux-x86_64.tar.gz dist/horizon-windows-x64.zip \
dist/horizon-macos-arm64.zip dist/horizon-server-linux-x86_64.tar.gz dist/horizon-source.tar.gz
dist/horizon-macos-arm64.zip dist/horizon-macos-x64.zip dist/horizon-server-linux-x86_64.tar.gz \
dist/horizon-source.tar.gz
- uses: actions/upload-artifact@v4
with:
name: signature
@@ -233,9 +234,10 @@ 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 on Apple Silicon (horizon-macos-arm64.zip)" \
"dist/horizon-macos-x64.zip#Game files the launcher installs (Nightly): macOS on Intel (horizon-macos-x64.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
+4 -6
View File
@@ -52,8 +52,9 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
| `horizon-prepare` | Builds `.local/horizon-client/26.3` from the player's own Minecraft install (runs the Java exporters) |
| `horizon-server` | `horizon-server`: `server.toml`, CLI, supervisor of the headless game ([docs/hosting.md](docs/hosting.md)) |
| `horizon-server-files` | `server.properties`, eula, ops/whitelist/bans, usercache |
| `horizon-anticheat` | The dedicated server's anticheat, off by default: `[anticheat]` settings, violation levels, the checks' rules; the hub runs them (`lan/anticheat.rs`) ([docs/anticheat.md](docs/anticheat.md)) |
| `horizon-friends`, `horizon-relay` | Play with Friends: invite codes, encrypted tunnel, the relay ([docs/play-with-friends.md](docs/play-with-friends.md)) |
| `horizon-mods`, `horizon-mod-sdk`, `cargo-horizon-mod` | Sandboxed WebAssembly mods: runtime, SDK + WIT, `cargo horizon-mod` ([docs/mods.md](docs/mods.md)) |
| `horizon-mods`, `cargo-horizon-mod` | Content mods (`.hmod`: manifest, content fence, no code), the registry index and `.hpack`, and `cargo horizon-mod` (`new`, `build`, `run`, `check`, `registry`) ([docs/mods.md](docs/mods.md)). Code mods are native, built by the launcher ([docs/native-mods.md](docs/native-mods.md)) |
| `horizon-port-pack` | Porting Java modpacks ([docs/modpacks.md](docs/modpacks.md)) |
| `horizon-shader-api` | Horizon's own WGSL shader packs ([docs/api/shaders.md](docs/api/shaders.md)): `horizon-shaders.toml`, the `.hshader` package; shared by the game, the launcher and the registry |
| `horizon-shaders` | Shader packs (the `shaders/` folder format), rebuilt clean-room from a written spec of the format; no wgpu |
@@ -73,7 +74,7 @@ reads `README.md`, `launcher/links.json`, `docs/api/`, `roadmap/roadmap.json` an
python3 tools/pr_ready.py # the self-check: fmt, clippy -D warnings, touched crates' tests, harnesses; paste its block in the PR
python3 tools/pr_ready.py --quick # same without harnesses
cargo test -p horizon-client --lib --bins --tests # skips the 104 examples (pr_ready does this); `--doc` separately
cargo test -p horizon-client --features horizon-client/mods --lib mod_content::tests:: -- --ignored # the 5 slow content-root tests, #[ignore]d; pr_ready runs them when mod content, assets, blocks or items change
cargo test -p horizon-client --lib mod_content::tests:: -- --ignored # the 5 slow content-root tests, #[ignore]d; pr_ready runs them when mod content, assets, blocks or items change
cargo build --profile fast -p horizon-client # optimized, quick to link: use it to play or run lan_e2e
python3 tools/parity.py --changed origin/main # the harnesses your branch can affect (CI's parity job)
python3 tools/parity.py --changed-files paths.txt --list # which harnesses cover these files, without running them
@@ -93,9 +94,6 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
- What a change reruns (of 329 harnesses, measured 2026-10-23): a file of the game binary (`game.rs`, `game/`)
~25, a file of the client lib ~103, of `horizon-world` ~103–109, of `horizon-core` ~119. Plan for it.
- Rust integration tests: `crates/horizon-client/tests/` (`lan_bot.rs`, `generation_saves.rs`, `streaming_meshes.rs`).
- **macOS:** `cargo build/test --workspace` may fail to link the two wasm guest cdylibs (undefined
`horizon:mods/events` symbols) until #1446 lands: add `--exclude coords-hud --exclude predator-missile --exclude horizon-mods-test-guest --exclude horizon-mods-test-ui-guest`.
Their real check is `cargo test -p horizon-mods` (builds them for `wasm32-unknown-unknown`).
- Linux needs `libasound2-dev libudev-dev`; `--no-default-features` drops audio and controllers (the server build).
- Run and drive the game (starts the `horizon-client` beside `horizon-ctl`; other arguments go to the game):
`target/fast/horizon-ctl --socket $PWD/ctl.sock launch --world W --creative --offscreen --wait --resources $PWD/.local/horizon-client/26.3`,
@@ -138,7 +136,7 @@ the same `horizon_client::<module>` paths), `G:` = bin (`crates/horizon-client/s
| Multiplayer client | L: `net.rs` (network thread); G: `remote.rs` (`fn handle_packet`, `fn tick_remote`), `remote_*.rs` (mob data, objects, projectiles, vehicles, stations) |
| LAN host / server side | L: `lan/` (`conn.rs` handshake + login threads, `hub.rs` + `hub/` play state, `chunks.rs`, `entity.rs`, `tracking.rs`, `guest.rs`, `playerdata.rs`); G: `lan.rs` (`LanHost`), `lan_actions.rs` (`fn as_guest`, `fn tick_guests`), `lan_*.rs` per feature, `lan_entities.rs` (tracked entities) |
| Integrated server | G: `integrated.rs` (the server thread), `levels.rs` (one level per dimension, `fn tick_server`), `server_travel.rs` |
| Dedicated server | G: `dedicated.rs`, `dedicated/status.rs`; crates `horizon-server-files`, `horizon-server` |
| Dedicated server | G: `dedicated.rs`, `dedicated/status.rs`; crates `horizon-server-files`, `horizon-server`, `horizon-anticheat` (L: `lan/anticheat.rs`) |
| Save and load | L: `save.rs` (level.dat, regions, playerdata), `entity_nbt.rs`, `options_file.rs`; `horizon-client-core/src/block_entity_nbt.rs`; G: `saving.rs`, `persist.rs` (entities); `crates/horizon-world/src/{region.rs,nbt.rs,datafix/}` |
| Input | game.rs `fn window_event`, `fn key_pressed`, `fn play_mouse`; crate `horizon-client-input` (`keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `clipboard.rs`); L: `osk.rs`; G: `pad.rs`, `pad_menu.rs` |
| Audio | L: `audio.rs` (mixer), `sound_engine.rs`, `sound_events.rs`, `music.rs`, `ambience.rs`; G: `music.rs`, `subtitle_hud.rs` |
+26 -8
View File
@@ -47,14 +47,31 @@ them (`git config core.hooksPath .githooks` runs it before every commit).
The pre-push hook checks the full history reachable from the branches and tags being pushed. Run
`python3 tools/check_public_tree.py --history` to audit the history of all local refs.
## Only original code
## Only original code, or code whose license allows it
Horizon's code is written from Minecraft 26.3 itself and from public documentation, never from another mod, loader
or renderer. Don't read, copy, translate or paraphrase the source of Iris, OptiFine, Sodium or any other project,
and don't send code an AI tool wrote from it. Their user documentation and file formats are fine to read, and their
packs and mods are fine to run and observe. Shader packs were removed on 24 October 2026 (#1561) because their code
had been written from another project's source; they come back only as a clean-room rewrite from a written
description of the pack format.
Horizon is MIT. Its code is written from Minecraft 26.3 itself and from public documentation. Since 30 October 2026
another project's source may also be read and used, **only when that project's license allows it**:
- **Allowed:** MIT, MIT-0, BSD-2-Clause, BSD-3-Clause, 0BSD, ISC, zlib, Apache-2.0, Unlicense, and the CC0-1.0
public-domain dedication. That code may be read, copied or adapted.
- **Everything else is off limits, even to read:** GPL, LGPL (Iris), AGPL, MPL, EPL, Polyform (Sodium), the Creative
Commons licenses (CC BY, CC BY-SA, CC BY-NC and the others; CC0 is a public-domain dedication, not one of them),
custom or "source available" licenses, all rights reserved (OptiFine), and any project with no license or an
unclear one. Don't read, copy, translate or paraphrase their source, and don't send code an AI tool
wrote from it. Being able to download a mod or a pack does not give you the right to read its files.
- **Check the license yourself.** Read the LICENSE file at the commit you use and the header of each file you take:
some repositories mix licenses, and a file under another license is off limits. A license that changed: the one
at your commit counts. In doubt, don't: ask in #contributors first.
- **Say it in the pull request:** the project, a link to the commit, its license, and what you read or copied. A
pull request that used another project's source without saying so is closed.
- **Copied or adapted code** keeps its copyright line and license notice at the top of the file, and gets an entry
in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). Apache-2.0 code also keeps its NOTICE text and says what
was changed.
User documentation and file formats are fine to read, and packs and mods are fine to run and observe, whatever
their license. Shader packs were removed on 24 October 2026 (#1561) because their code had been written from
another project's source; they come back only as a clean-room rewrite from a written description of the pack
format.
## Before asking for review
@@ -82,7 +99,8 @@ claimed on Discord with Kira's `/claim`): pick another, or ask its holder in the
enough), then claim it. `/unclaim` gives it back.
A claim with no open pull request linking the issue (`Fixes #123` in the description, or a draft) is released after
5 days, with a comment: open a draft early. You can hold 5 open issues at a time. Agents claim the same way.
5 days, with a comment: open a draft early. You can hold 8 open issues at a time, not counting the ones your
pull requests already fix. Agents claim the same way.
When your pull request changes the same functions as another open one, or closes the same issue, a bot comment names
it (edited on every push, never repeated): talk to its author in the thread before you both finish the same work.
Generated
+50
View File
@@ -561,6 +561,12 @@ dependencies = [
"typenum",
]
[[package]]
name = "cty"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b365fabc795046672053e29c954733ec3b05e4be654ab130fe8f1f94d7051f35"
[[package]]
name = "cursor-icon"
version = "1.2.0"
@@ -824,6 +830,16 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fs4"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e72ed92b67c146290f88e9c89d60ca163ea417a446f61ffd7b72df3e7f1dfd5"
dependencies = [
"rustix 1.1.5",
"windows-sys 0.61.2",
]
[[package]]
name = "futures-core"
version = "0.3.34"
@@ -1068,6 +1084,14 @@ dependencies = [
"digest",
]
[[package]]
name = "horizon-anticheat"
version = "0.1.0"
dependencies = [
"serde",
"toml",
]
[[package]]
name = "horizon-assets"
version = "0.1.0"
@@ -1103,8 +1127,10 @@ dependencies = [
"bytemuck",
"chrono",
"flate2",
"fs4",
"gilrs",
"glam",
"horizon-anticheat",
"horizon-assets",
"horizon-bench",
"horizon-client-core",
@@ -1169,6 +1195,7 @@ name = "horizon-client-render"
version = "0.1.0"
dependencies = [
"ab_glyph",
"base64",
"bytemuck",
"chrono",
"cpal",
@@ -1197,6 +1224,7 @@ dependencies = [
"rustix 1.1.5",
"serde",
"serde_json",
"sha1",
"sha2",
"symphonia",
"unicode-bidi",
@@ -1344,6 +1372,7 @@ name = "horizon-server"
version = "0.1.0"
dependencies = [
"chrono",
"horizon-anticheat",
"horizon-bench",
"horizon-release-sign",
"horizon-server-files",
@@ -1412,6 +1441,8 @@ name = "horizon-threads"
version = "0.1.0"
dependencies = [
"libc",
"libmimalloc-sys",
"mimalloc",
"windows-sys 0.61.2",
]
@@ -1787,6 +1818,16 @@ version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "libmimalloc-sys"
version = "0.1.49"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a45a52f43e1c16f667ccfe4dd8c85b7f7c204fd5e3bf46c5b0db9a5c3c0b8e9"
dependencies = [
"cc",
"cty",
]
[[package]]
name = "libredox"
version = "0.1.25"
@@ -1878,6 +1919,15 @@ dependencies = [
"libc",
]
[[package]]
name = "mimalloc"
version = "0.1.52"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d4139bb28d14ad1facf21d5eb8825051b326e172d216b39f6d31df53cc97862"
dependencies = [
"libmimalloc-sys",
]
[[package]]
name = "minisign-verify"
version = "0.3.0"
+5 -1
View File
@@ -13,6 +13,7 @@ members = [
"crates/horizon-command",
"crates/horizon-items",
"crates/horizon-server-files",
"crates/horizon-anticheat",
"crates/horizon-server",
"crates/horizon-bots",
"crates/horizon-assets",
@@ -52,7 +53,10 @@ large_enum_variant = "allow"
# UHD 620/630) crashes on it with an access violation (#746). It also fixes the
# Metal backend's lookup of the CoreGraphics HDR colour space names, which handed
# CoreGraphics a symbol's address as the string and crashed HDR output on macOS.
# Drop this and the folder once a wgpu release has both fixes.
# And gfx-rs/wgpu#10714: on Windows, Vulkan waits for the acquire fence only in
# FIFO (VSync), where it was meant to pace frames; with VSync off it made every
# frame wait for the GPU to finish the one before (#4604).
# Drop this and the folder once a wgpu release has these fixes.
[patch.crates-io]
wgpu-hal = { path = "third_party/wgpu-hal" }
+40 -56
View File
@@ -1,5 +1,5 @@
<p align="center">
<img src="docs/media/banner.webp" alt="Horizon: Minecraft: Java Edition 26.3, rewritten in Rust and checked against vanilla by more than 300 differential harnesses" width="100%">
<img src="docs/media/banner.webp" alt="The Horizon website: the game you know, rebuilt in Rust" width="100%">
</p>
<p align="center">
@@ -7,6 +7,7 @@
<img src="https://img.shields.io/badge/Minecraft-Java%2026.3-5D9C3F" alt="Minecraft Java 26.3">
<img src="https://img.shields.io/badge/Rust-stable-B7410E?logo=rust&logoColor=white" alt="Rust">
<img src="https://img.shields.io/badge/vanilla%20parity-100%25-F0795A" alt="Vanilla parity 100%">
<a href="#project-status"><img src="https://img.shields.io/badge/player--visible%20gaps-833%20open-F0795A" alt="Player-visible gaps open"></a>
<img src="https://img.shields.io/badge/platform-Linux%20%7C%20macOS%20%7C%20Windows-2A2470" alt="Linux, macOS and Windows">
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-3B2F7E" alt="MIT license"></a>
</p>
@@ -23,34 +24,32 @@
# Horizon
Horizon is Minecraft: Java Edition 26.3 rewritten in Rust: a native game that reads the same worlds, follows the same rules and generates the same terrain as the Java game, and is checked against it by more than 300 differential harnesses. You play it through a small launcher, on the files from your own copy of Minecraft. It is free, open source and unofficial.
Minecraft: Java Edition 26.3, rewritten in Rust. Same worlds, same rules, same terrain, checked against the Java game by more than 300 differential harnesses. Free, open source and unofficial.
> Not an official Minecraft product. Not approved by or associated with Mojang or Microsoft. Horizon contains no Minecraft code, assets or data: you need your own legally obtained copy of Minecraft: Java Edition 26.3.
> Not an official Minecraft product, nor approved by or associated with Mojang or Microsoft. Horizon contains no Minecraft code, assets or data: you need your own copy of Java Edition 26.3.
## Download
Horizon is installed through **the Horizon launcher**. Get it from [playhorizon.dev](https://playhorizon.dev) (the Download button picks your system) or from [the launcher releases](https://github.com/horizon-rust/horizon/releases?q=launcher-v&expanded=true), for Linux x86-64, macOS on Apple Silicon or Windows x64. The launcher installs the game and keeps it up to date with each new build (Horizon ships on one channel, the Nightly), prepares its files from your copy of Minecraft (your own install, or downloaded from Mojang for your account), and updates itself.
Get **the Horizon launcher** from [playhorizon.dev](https://playhorizon.dev) or [the launcher releases](https://github.com/horizon-rust/horizon/releases?q=launcher-v&expanded=true) (Linux x86-64, macOS on Apple Silicon, macOS on Intel from the next Nightly, Windows x64). It installs and updates the game, prepares its files from your copy of Minecraft, and updates itself.
**You need** a Microsoft account that owns Minecraft: Java Edition (the launcher signs in with it, as other launchers do; see [Microsoft account](docs/microsoft-account.md)) and a GPU with Vulkan or OpenGL 3.3 on Linux, Vulkan or DirectX 12 on Windows, or any Apple Silicon Mac. Nothing else: with no Minecraft on your computer, Play downloads 26.3 and the Java it needs from Mojang for your account, as the official launcher does, then prepares it once. An install you already have (the official launcher, Prism, MultiMC and other launchers all work) is used instead. Step by step, with macOS's first-open prompt and the first launch: [Install and first launch](https://playhorizon.dev/docs/install).
**You need** a Microsoft account that owns Java Edition ([why](docs/microsoft-account.md)) and a GPU with Vulkan or OpenGL 3.3 (Linux), Vulkan or DirectX 12 (Windows), or a Mac (Apple Silicon, or Intel from the next Nightly). An existing Minecraft install (official launcher, Prism, MultiMC...) is reused; without one, Play downloads 26.3 from Mojang for your account. Step by step: [Install and first launch](https://playhorizon.dev/docs/install).
<table>
<tr>
<td width="50%"><img src="docs/media/launcher-play.webp" alt="The launcher's Play page: a version ready to play, its summary, Client and Server, the version picker and Play"></td>
<td width="50%"><img src="docs/media/launcher-patch-notes.webp" alt="The launcher's Patch notes page"></td>
<td width="50%"><img src="docs/media/launcher-play.webp" alt="The launcher's Home page: the selected instance ready to play, the version picker, Join a friend and the instance's tabs"></td>
<td width="50%"><img src="docs/media/launcher-settings.webp" alt="The launcher's Settings page: Microsoft account, offline name, updates, appearance and accent colour"></td>
</tr>
</table>
## What works today
The [documentation](https://playhorizon.dev/docs/) covers each of these.
- **The game:** vanilla world generation, mobs, villagers, raids, redstone, every command, the HUD and the recipe book, on vanilla saves. Worlds from 1.8 to 26.2 are upgraded. [Status](#project-status).
- **Extras:** new menus, graphics presets, distant terrain up to 1024 chunks, controllers, zoom, minimap and more, off by default where they change the vanilla look. [Settings](https://playhorizon.dev/docs/settings).
- **Packs:** resource packs, and data packs with functions, predicates, item modifiers and world generation.
- **Multiplayer:** invite friends with a code, Open to LAN, join servers (online mode included). [Multiplayer](https://playhorizon.dev/docs/multiplayer).
- **Mods:** content mods (blocks, items, data) and native Rust mods, built by the launcher with nothing to install. Java mod jars do not run. [Mods](https://playhorizon.dev/docs/mods).
- **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).
- **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:** Horizon has its own mods, installed from the launcher. The official ones (Zoom, FPS Counter, Keystrokes and Minimap) are native Rust that the launcher builds into your game, with no Rust to install; older WebAssembly mods still run while they move. Java mod jars do not run in Horizon. See [Mods and data packs](https://playhorizon.dev/docs/mods).
Screenshots, captured in the Rust client itself with vanilla assets and no mods or shaders:
In the Rust client, with vanilla assets and no mods or shaders:
<table>
<tr>
@@ -68,15 +67,15 @@ Screenshots, captured in the Rust client itself with vanilla assets and no mods
## Host a server
The launcher's **Servers** page creates, starts, backs up and updates a Horizon server with buttons, and invites friends over Tailscale. On a VPS, `horizon-server` installs with one command and runs without Java. Guide: [Host a server](https://playhorizon.dev/docs/hosting) ([reference](docs/hosting.md)).
The launcher's **Servers** page creates, starts, backs up and updates a server, and invites friends over Tailscale. On a VPS, `horizon-server` installs with one command and needs no Java. [Host a server](https://playhorizon.dev/docs/hosting) ([reference](docs/hosting.md)).
## Modpacks
The launcher's **Modpacks** page imports a Modrinth or CurseForge pack, shows which of its mods Horizon can carry over, and plays the ones it can as their own profile. Today that means content mods made of data and assets; mods with Java code are not ported yet. See [Modpacks](https://playhorizon.dev/docs/modpacks).
The launcher imports Modrinth and CurseForge packs and plays the mods Horizon can carry over: today, content mods made of data and assets, not Java code. [Modpacks](https://playhorizon.dev/docs/modpacks).
## Agent control
AI agents and scripts can play Horizon. `horizon-ctl`, shipped beside the game in every release folder, is a command line that reads the game as JSON and acts like a player; `horizon-ctl mcp` serves the same commands as an MCP server (one tool per command, screenshots returned as images).
AI agents and scripts can play Horizon. `horizon-ctl`, shipped with the game, reads its state as JSON and acts like a player; `horizon-ctl mcp` serves the same commands over MCP.
```sh
horizon-ctl launch --world Survival --seed 5 --wait # finds the version the launcher installed
@@ -87,16 +86,14 @@ horizon-ctl break -1 65 -3 # mines it; replies with
horizon-ctl open-inventory && horizon-ctl click-slot 0 # menus: slots, buttons by label, text fields
```
Commands block until the action is done and reply with what happened; errors are JSON with distinct exit codes, `horizon-ctl help` lists every command and `horizon-ctl COMMAND --help` explains one with examples. It talks to the game over a Unix socket on Linux and macOS and over a token-protected localhost port on Windows. See [Agent control in the native client guide](docs/native-client.md#agent-control-horizon-ctl-and-mcp).
Commands block until done and reply with the result; `horizon-ctl help` lists them all. [Agent control](docs/native-client.md#agent-control-horizon-ctl-and-mcp).
## Build from source
For developers. Requirements: Rust 1.90 or newer, Python 3.10 or newer, JDK 25, and on Linux the ALSA and udev development headers (`libasound2-dev libudev-dev` on Debian and Ubuntu). Development and the vanilla harnesses run on Linux.
Needs Rust 1.90+, Python 3.10+, JDK 25, and on Linux `libasound2-dev libudev-dev`. `horizon-prepare` builds the game's resources from **your own copy** of Java Edition 26.3 (checked against `research-lock.json`) and never downloads it:
Horizon contains no game files, and `horizon-prepare` never downloads them (the launcher downloads them from Mojang only for a signed-in account that owns the game). It builds its resources from **your own copy** of Minecraft: Java Edition 26.3, verified against the checksums in `research-lock.json`:
1. **Client:** install Java Edition with the official launcher and play 26.3 once. Horizon reads `versions/26.3/`, `libraries/` and `assets/` from the launcher's game directory (`~/.minecraft` on Linux). If yours lives elsewhere, pass `--minecraft-dir` or set `HORIZON_MINECRAFT_DIR`.
2. **Server jar:** the 26.3 server jar, from the `downloads.server.url` in your launcher's `versions/26.3/26.3.json` (or [minecraft.net/download/server](https://www.minecraft.net/en-us/download/server) while 26.3 is the current release). Pass it with `--server-jar` or set `HORIZON_SERVER_JAR`. It is only needed the first time.
1. **Client:** play 26.3 once in the official launcher. Horizon reads `~/.minecraft` (or `--minecraft-dir` / `HORIZON_MINECRAFT_DIR`).
2. **Server jar:** the 26.3 server jar (`downloads.server.url` in `versions/26.3/26.3.json`), passed once with `--server-jar` or `HORIZON_SERVER_JAR`.
```sh
git clone https://github.com/horizon-rust/horizon.git
@@ -106,70 +103,57 @@ cargo run --release -p horizon-prepare -- sounds # optional: sounds from your
cargo run --release -p horizon-client
```
Everything prepared lands in the gitignored `.local/`. It is derived from the game: never commit it or share it.
The client starts on the title screen. Command-line options (`--world`, `--seed`, `--creative`, `--render-distance`, `--dedicated`), controls, scripting through `horizon-ctl` and the architecture are described in [the native client guide](docs/native-client.md). The rest of the developer documentation is indexed in [docs/README.md](docs/README.md), including [the parity workflow](docs/parity-workflow.md).
Prepared files land in the gitignored `.local/`: never commit or share them. Options, controls and architecture: [the native client guide](docs/native-client.md); all developer docs: [docs/README.md](docs/README.md).
## Roadmap
What is being done now, what comes next and where you can help, in plain words and in technical detail: [ROADMAP.md](ROADMAP.md). [playhorizon.dev/hub](https://playhorizon.dev/hub) shows it with the work board, the journal of what shipped and how to help.
What is in progress, what comes next and where to help: [ROADMAP.md](ROADMAP.md), or [playhorizon.dev/hub](https://playhorizon.dev/hub) with the work board and what shipped.
## Contributing
Issues and pull requests are welcome: see [CONTRIBUTING.md](CONTRIBUTING.md) for how we work (vanilla first, small pull requests, labels), or [the contributing guide](https://playhorizon.dev/docs/contributing) on the site. Questions and ideas: [Discord](https://discord.gg/HcTeMZxe5E). Before opening a pull request, run the self-check and paste the block it prints:
Issues and pull requests are welcome: read [CONTRIBUTING.md](CONTRIBUTING.md) (vanilla first, small pull requests). Questions: [Discord](https://discord.gg/HcTeMZxe5E). Before a pull request, run the self-check and paste its output:
```sh
git config core.hooksPath .githooks
python3 tools/pr_ready.py
```
It runs rustfmt, clippy with warnings denied, the tests of the crates you touched, the Python tests, the public-tree check, and the vanilla harnesses your change can affect. CI runs the same checks on GitHub's Linux runners; the vanilla harnesses need the game, which Horizon never downloads, so they run on a maintainer's computer holding their own copy. Never commit game files, decompiled source or prepared resources: `tools/check_public_tree.py` rejects them, and tracked Java is allowed only under the project's own `dev/horizonlab` namespace.
It runs rustfmt, clippy, the touched crates' tests, the Python tests, the public-tree check and the vanilla harnesses your change affects. Never commit game files, decompiled source or prepared resources.
## Project status
The table below is the project's own audit of each subsystem. Every subsystem now matches vanilla in every harness that compares it. 100% means 100% of what the harnesses compare, not proof that nothing else differs. The remaining known differences are listed below.
**Player-visible gaps: 833 open** of the 833 the [port ledger](tools/port_ledger.py) found, reading every vanilla 26.3 method against Horizon's code: vanilla behaviour a player would notice that Horizon doesn't have yet. By area: Screens, HUD and client packets [197](https://github.com/horizon-rust/horizon/issues/3518); Entity and block rendering, models [129](https://github.com/horizon-rust/horizon/issues/3519); Animals [110](https://github.com/horizon-rust/horizon/issues/3512); Server and players on the server [106](https://github.com/horizon-rust/horizon/issues/3520); Blocks, redstone and fluids [86](https://github.com/horizon-rust/horizon/issues/3516); Entities, projectiles, vehicles and decorations [64](https://github.com/horizon-rust/horizon/issues/3514); Monsters and bosses [53](https://github.com/horizon-rust/horizon/issues/3513); Mob AI, villagers and raids [35](https://github.com/horizon-rust/horizon/issues/3521); World, world generation and saves [32](https://github.com/horizon-rust/horizon/issues/3517); Items, enchantments and inventories [21](https://github.com/horizon-rust/horizon/issues/3515). This number goes down as gaps close (`tools/parity_gaps.py`); the harness results below say that what is tested matches.
The table is the project's own audit: every subsystem matches vanilla in every harness that compares it. 100% means 100% of what the harnesses compare, not that nothing else differs.
### Java parity: 100%
Each subsystem scores 0 (absent), 0.25 (started, not playable), 0.5 (playable,
major vanilla features missing), 0.75 (playable, mostly complete) or 1 (complete
and verified against vanilla by differential tests). The percentage is the
average; `tests/test_parity.py` keeps it in sync with this table. Last audited
2026-10-13 against the harness results: all 281 harnesses pass on Linux, each
rerun whenever one of its inputs changed, and the Mac mini now runs every one of
them too (Rust uses Java's own float maths on each platform).
The harnesses measure behaviour. A second measure is method by method: the [port ledger](tools/port_ledger.py)
lists every method of vanilla 26.3 next to the Rust functions that port it, with a verdict for each one
(ported, folded into another function, missing, or needing no port), and the untested ports. Run
`python3 tools/port_ledger.py build`, then `coverage`, with the vanilla data in `.local/`.
Scores: 0 (absent), 0.25 (started), 0.5 (playable, major features missing), 0.75 (mostly complete) or 1 (complete and verified by differential tests); the percentage is their average. Last audited 2026-10-13: all 281 harnesses pass on Linux and macOS. The [port ledger](tools/port_ledger.py) adds a method-by-method view of what is ported.
| Subsystem | Score | Main gaps |
| --- | ---: | --- |
| Window, input and game loop | 1 | None: the game loop, options, camera, auto-jump, toggle sprint/sneak, local input and the frame limiter match vanilla |
| Assets and resources | 1 | None: item models match vanilla over 196,290 stacks and the client draws every item through them; the resource pack screen matches vanilla's drawing over 3,000 sessions of clicks, scrolling and searching, its incompatibility confirmation included |
| Network protocol and multiplayer | 1 | None: joins vanilla servers; the dedicated server (`--dedicated`) matches vanilla's files, status ping, login, configuration, play, chunks, chat, recipes, advancements and command tree, a client built from vanilla's packet code joins it, Horizon joins a vanilla server, and LAN discovery matches over 462 cases (offline mode only) |
| Window, input and game loop | 1 | None: game loop, options, camera, auto-jump, toggle sprint/sneak and frame limiter match |
| Assets and resources | 1 | None: item models match over 196,290 stacks; the resource pack screen over 3,000 sessions |
| Network protocol and multiplayer | 1 | None: joins vanilla servers; the dedicated server matches vanilla's files, login, configuration, play and command tree; LAN discovery over 462 cases (offline mode only) |
| Chunk storage and palettes | 1 | None: client and saves share paletted containers, identical to vanilla over 266 scenarios |
| Section meshing | 1 | None: 112 scenes and over a million quads identical to vanilla |
| Rendering | 1 | None: entity models, block entity renderers (407 cases, End portals, gateways and the loom's banner preview included) and their animations (296 cases, End gateway beams included), the HUD overlays (2,200 states), the recipe book (600 states), the player list, subtitles and the world border match vanilla |
| Rendering | 1 | None: entity models, block entity renderers (407 cases) and animations (296), HUD overlays (2,200 states), the recipe book (600), player list, subtitles and world border match |
| Lighting | 1 | None: saved and relit light matches vanilla, slabs and stairs included |
| World generation | 1 | None: 2,352 whole chunks over 225 structure sites, in all three dimensions, identical to vanilla |
| Region files and saves | 1 | None: saves round-trip with vanilla, and worlds from 1.8 to 26.2 upgrade as vanilla's DataFixers do, superflat worlds included; old options.txt and hotbar.nbt files are upgraded too |
| Entities and physics | 1 | None: attributes, dimensions, death loot, spawn rules, natural spawning (15,500 cases), raids and patrols (1,900 cases), spawned mobs' gear, enchantments and state (`finalizeSpawn`, 24,000 cases) and mob AI (92 scenarios: villager breeding and gossip, the warden, ghastlings and copper golems included) match vanilla |
| Interaction and inventory | 1 | None: menu clicks match over 8,000 click sequences, the loom and the crafter included; recipe book placement (6,000 cases), its buttons (1,200), the saved book (1,500) and the loom and crafter screens (1,600) match vanilla |
| Menus, HUD and chat | 1 | None: every command runs as vanilla (758 commands over 39,685 parsed inputs), scoreboards and teams match (81,000 operations), the player list matches over 872,000 draw calls with heads drawn from players' skins (authlib's textures, SkinManager's lookup, legacy skin conversion), and the chat matches focused and scrolled; the resource pack screen (selection over 4,000 cases, drawing and its incompatibility confirmation over 3,000 sessions) and the chat input with its command suggestions, usage lines, Tab completion and history (3,000 sessions on vanilla's client command tree) match vanilla |
| Region files and saves | 1 | None: saves round-trip with vanilla; worlds from 1.8 to 26.2 upgrade as vanilla's DataFixers do |
| Entities and physics | 1 | None: attributes, spawn rules, natural spawning (15,500 cases), raids and patrols (1,900), `finalizeSpawn` (24,000) and mob AI (92 scenarios) match |
| Interaction and inventory | 1 | None: menu clicks over 8,000 sequences; recipe book placement (6,000), buttons (1,200), saved book (1,500), loom and crafter screens (1,600) |
| Menus, HUD and chat | 1 | None: every command (758 over 39,685 inputs), scoreboards (81,000 operations), the player list (872,000 draw calls), chat and its suggestions (3,000 sessions) match |
| Audio | 1 | None: the sound engine, sound events, music, situational music, ambience and subtitles match vanilla |
| Block behaviours and ticking | 1 | None: block updates, random ticks, redstone, dispensers (6,000 cases), crafters (4,000 crafts), block entity removal (containers spill, 4,000 cases), copper golem chest sorting and the sculk family (435 scenarios: shrieker warnings and warden summoning included) match vanilla |
| Block behaviours and ticking | 1 | None: block updates, random ticks, redstone, dispensers (6,000 cases), crafters (4,000), container spills (4,000), copper golems and the sculk family (435 scenarios) match |
### Known issues
Skins served over https show the default skin unless already cached; pack icons show the unknown-pack texture in game; the creative hotbar keys (C/X) aren't wired to the hotbar loader; item tooltips lack attribute lines.
Skins served over https show the default skin unless already cached; pack icons show the unknown-pack texture in game; item tooltips lack attribute lines.
## Licence
[MIT](LICENSE), for the project's original work only. Dependencies keep their own licences. Minecraft is a trademark of Mojang Synergies AB; Minecraft code and assets belong to Mojang and Microsoft and are covered by the [Minecraft EULA](https://www.minecraft.net/en-us/eula). Decompiled game source and prepared resources stay on your machine and are never published.
Third-party credits and licenses: [Third-party notices](THIRD_PARTY_NOTICES.md).
[MIT](LICENSE), for the project's original work; dependencies keep their own licences ([notices](THIRD_PARTY_NOTICES.md)). Minecraft is a trademark of Mojang Synergies AB; its code and assets belong to Mojang and Microsoft under the [Minecraft EULA](https://www.minecraft.net/en-us/eula).
## Author
+13 -13
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 being removed ([#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>
@@ -326,11 +326,11 @@ A mapping table from intermediary and SRG names so pre-26.1 jars can be classifi
#### A modpack's own resource and shader packs
Importing a modpack also brings the resource packs and shader packs it ships with, where Horizon can use them.
Imported modpacks carry their resource packs into a profile of their own. Shader-pack conversion is still pending.
<details><summary>Technical</summary>
Porting a pack carries data, assets and worldgen today; the resource packs and shader packs bundled with it are left out. Resource packs can be carried as they are; shader packs go through the converter ([docs/modpacks.md](docs/modpacks.md)).
Resource-pack carry-over is implemented locally: Modrinth common/client overrides, CurseForge's override folder, installed instances (ZIP and directory packs), and hash-checked indexed ZIP downloads. The launcher keeps profile edits and lets resource-only profiles play; packs are enabled in Resource Packs. Synthetic import and profile tests cover the file pipeline; in-game rendering has not been verified. Shader-pack conversion is still pending, so this combined item remains partial ([docs/modpacks.md](docs/modpacks.md)).
</details>
@@ -350,7 +350,7 @@ Run Java mods that nobody has rewritten for Horizon yet, through a bridge or a c
<details><summary>Technical</summary>
Earlier research ([#527](https://github.com/horizon-rust/horizon/issues/527), closed) found that Java mods can't simply run, because they call Minecraft's own classes. Options to look at again: a jar-to-WebAssembly backend, or a bridge for simple Fabric mods. Whatever is chosen has to run inside the same sandbox and capability checks as native mods.
Earlier research ([#527](https://github.com/horizon-rust/horizon/issues/527), closed) found that Java mods can't simply run, because they call Minecraft's own classes. Option to look at again: a bridge for simple Fabric mods.
</details>
@@ -370,7 +370,7 @@ When a mod, shader pack or modpack hasn't been rewritten for Horizon, one button
<details><summary>Technical</summary>
Generalise the mod browser's Port step (`horizon-port-pack`: the data, assets, worldgen and block and item definitions of Java mods, .mrpack and CurseForge zips) to every kind of content: code mods where the API allows it (see 'Mods with code') and shader packs, converting an Iris/OptiFine pack into Horizon's format. The browser says what works before the install. Java code itself does not run ([docs/modpacks.md](docs/modpacks.md), [docs/mod-browser.md](docs/mod-browser.md)).
Generalise the mod browser's Port step (`horizon-port-pack`: the data, assets, worldgen and block and item definitions of Java mods, .mrpack and CurseForge zips) to every kind of content: code mods as native mods (see 'Mods with code') and shader packs, converting an Iris/OptiFine pack into Horizon's format. The browser says what works before the install. Java code itself does not run ([docs/modpacks.md](docs/modpacks.md), [docs/mod-browser.md](docs/mod-browser.md)).
</details>
@@ -380,7 +380,7 @@ Making mods that add machines, magic and mobs work in Horizon, in steps, startin
<details><summary>Technical</summary>
333 of the 496 mods in a big pack (ATM10) contain code. Issue [#1854](https://github.com/horizon-rust/horizon/issues/1854) ranks the API gaps (ticking block entities, networking, rendering, menus, inventories) that unlock them in tiers, with machines first as the recommended path and a furnace ported tick for tick as proof. The first API step is a draft ([#1189](https://github.com/horizon-rust/horizon/issues/1189), see 'Machines and blocks with inventories'). The path isn't started yet ([docs/modpacks.md](docs/modpacks.md)).
333 of the 496 mods in a big pack (ATM10) contain code. Issue [#1854](https://github.com/horizon-rust/horizon/issues/1854) ranks the gaps (ticking block entities, networking, rendering, menus, inventories) that unlock them in tiers, with machines first as the recommended path. Code mods are native mods now ([docs/native-mods.md](docs/native-mods.md)): each port is a horizon-mixins bundle. The path isn't started yet ([docs/modpacks.md](docs/modpacks.md)).
</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 being 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)
@@ -498,11 +498,11 @@ More: [docs/native-mods.md](docs/native-mods.md)
#### Orbital Laser on the normal game
The community mod Orbital Laser, made for a fork of Horizon, running on the normal game and listed in the registry as the showcase of the mod API.
The community mod Orbital Laser, made for a fork of Horizon, running on the normal game as a native mod and listed in the registry.
<details><summary>Technical</summary>
A pull request on [https://github.com/Stink-O/orbital-laser](https://github.com/Stink-O/orbital-laser) (MIT) once the input, ui, render and world capabilities exist (see 'Screens, input and drawing for mods'): today the mod needs them through its author's fork. Tested in the game, and listed in the registry.
A native port (a horizon-mixins bundle, docs/native-mods.md) of [https://github.com/Stink-O/orbital-laser](https://github.com/Stink-O/orbital-laser) (MIT), which today runs through its author's fork. Tested in the game, and listed in the registry.
</details>
@@ -524,7 +524,7 @@ Contraptions and physics like the Create and Aeronautics mods, as a mod for Hori
<details><summary>Technical</summary>
Issue [#805](https://github.com/horizon-rust/horizon/issues/805) (porting QOL mods) closed with this answer: it would come as a native Horizon mod on the mod API. It needs server mods, entities and rendering first.
Issue [#805](https://github.com/horizon-rust/horizon/issues/805) (porting QOL mods) closed with this answer: it would come as a native Horizon mod.
</details>
@@ -534,7 +534,7 @@ Hear nearby players talk, as with the voice chat mods of other Minecraft version
<details><summary>Technical</summary>
Needs server mods, mod networking and an audio interface in the mod API before anything can be built ([docs/api/mods.md](docs/api/mods.md)).
A native mod ([docs/native-mods.md](docs/native-mods.md)). It needs a way to carry voice between players and an audio path in the game before anything can be built.
</details>
@@ -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>
+42 -2
View File
@@ -3,8 +3,9 @@
## wgpu-hal
`third_party/wgpu-hal` is [wgpu-hal](https://github.com/gfx-rs/wgpu) 30.0.1 by the gfx-rs
developers, with one upstream fix applied
([gfx-rs/wgpu#10124](https://github.com/gfx-rs/wgpu/pull/10124)). It is licensed under MIT or
developers, with upstream fixes applied
([gfx-rs/wgpu#10124](https://github.com/gfx-rs/wgpu/pull/10124),
[gfx-rs/wgpu#10714](https://github.com/gfx-rs/wgpu/pull/10714)). It is licensed under MIT or
Apache-2.0; both licenses are in that folder (`LICENSE.MIT`, `LICENSE.APACHE`).
## Pixelify Sans
@@ -19,3 +20,42 @@ by The Pixelify Sans Project Authors, licensed under the SIL Open Font License 1
[Pixelarticons](https://github.com/halfmage/pixelarticons) by Gerrit Halfmann, under the MIT
license (`crates/horizon-theme/icons/pixel/LICENSE-pixelarticons.txt`). `star.svg` is Horizon's own, drawn on
the same grid.
## AMD FidelityFX SDK (FSR 3.1 frame generation)
`crates/horizon-client-render/src/gfx/frame_gen/optical_flow.wgsl` is a port to WGSL of the
optical flow shaders of the [AMD FidelityFX SDK](https://github.com/GPUOpen-LibrariesAndSDKs/FidelityFX-SDK)
1.1.4 (`sdk/include/FidelityFX/gpu/opticalflow`, at commit
[c6efa6bf7f20](https://github.com/GPUOpen-LibrariesAndSDKs/FidelityFX-SDK/tree/c6efa6bf7f2027b3ec94f28578bb5965eabb9e55)),
and `optical_flow.rs` follows the SDK's `sdk/src/components/opticalflow/ffx_opticalflow.cpp`
(its passes, their order and their dispatch sizes). Copyright (C) 2024 Advanced Micro Devices,
Inc., under the MIT license, whose text heads both files. AMD and FidelityFX are AMD's
trademarks; Horizon is not affiliated with AMD.
## Mod loader logos
The launcher shows the logo of the mod loader an instance plays with.
- `launcher/assets/logos/quilt.png` is the Quilt mini icon (`brand/128png/quilt_mini_icon_transparent.png`)
from [QuiltMC/art](https://github.com/QuiltMC/art), by the Quilt Project, dedicated to the public
domain under [CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/).
- `launcher/assets/logos/neoforge.png` is NeoForged's icon (`art/neoforged_icon_16x16.png`) from
[neoforged/.github](https://github.com/neoforged/.github/tree/main/art), © the NeoForged team,
created by Ridanisaurus, under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). It is
unmodified (the launcher scales it up by whole pixels).
- Fabric's and Forge's icons are not shipped: neither publishes a license for its logo. The launcher
fetches them from their own sites when an instance needs them
([fabric-loader's icon](https://github.com/FabricMC/fabric-loader/blob/master/src/main/resources/assets/fabricloader/icon.png),
[files.minecraftforge.net's](https://files.minecraftforge.net/)).
Each logo belongs to its project. Showing it says which loader an instance runs; it does not mean
the project endorses Horizon.
## Sign in with Microsoft
The launcher's sign-in buttons follow Microsoft's published branding guidelines for apps that sign in
with Microsoft accounts
([Sign in with Microsoft branding](https://learn.microsoft.com/en-us/entra/identity-platform/howto-add-branding-in-apps)).
The four-square Microsoft symbol in `launcher/src/microsoft.rs` reproduces the squares, positions and
colours of Microsoft's official `ms-symbollockup_mssymbol_19.svg` from that page, unmodified. Microsoft
and its logo are trademarks of the Microsoft group of companies; Horizon is not endorsed by Microsoft.
+457
View File
@@ -0,0 +1,457 @@
#!/usr/bin/env python3
"""Server load bench: the same horizon-bots load on Horizon, vanilla 26.3 or Pumpkin.
python3 bench/bench.py --server horizon --exe target/release/horizon-server --bots 50
python3 bench/bench.py --server vanilla --jar ~/mc/server.jar --bots 50 --pre-generated ~/worlds/seed12345
python3 bench/bench.py --server pumpkin --exe ./pumpkin --bots 50
Every run uses a fresh scratch folder (bound to 127.0.0.1, offline, creative so the bots can fly), starts the
server, lets `horizon-bots` join one every --join-every seconds, warms up, then measures for --seconds:
- TPS: `time query gametime` at the start and the end of the window, as game ticks per wall-clock second;
- MSPT: `tick query` every 5 s (average, P50, P95, P99 over the last 100 ticks), averaged over the window;
- RSS and CPU of the server's whole process tree, once a second (needs `ps`: Linux and macOS);
- bandwidth: `bytes_in`/`bytes_out` from the bots' report, per player and second online (each bot's whole time
on the server, its join included), when the bots report them (they do not yet: the field is null until S-6); join time: the bots' `joined_after_ms`.
It writes one JSON report (--out) and prints the Markdown table of the design (docs/design.md, section 1).
Vanilla is the owner's own server.jar (--jar): nothing of Mojang's is fetched or committed. Pumpkin is a released
binary run as a black box (--exe); its Bedrock listeners, LAN broadcast and telemetry are turned off in the
config this script writes (--pumpkin-config adds a TOML file of your own on top if a release names a key
differently). `--pre-generated DIR` copies a finished world into every run's scratch folder as `world`, so runs
repeat on the same terrain. Build the bots first: cargo build --release -p horizon-bots
"""
import argparse
import datetime
import json
import os
from pathlib import Path
import platform
import re
import shutil
import socket
import statistics
import subprocess
import sys
import tempfile
import threading
import time
ROOT = Path(__file__).resolve().parent.parent
EXE = '.exe' if os.name == 'nt' else ''
SERVERS = ('horizon', 'vanilla', 'pumpkin')
PROPERTIES = {
'level-seed': '12345',
'view-distance': '10',
'simulation-distance': '10',
'online-mode': 'false',
'white-list': 'false',
'gamemode': 'creative',
'force-gamemode': 'true',
'spawn-protection': '0',
'max-players': '200',
'difficulty': 'easy',
'server-ip': '127.0.0.1',
'enable-query': 'false',
'enable-rcon': 'false',
}
# Pumpkin's two config files. Bedrock, LAN broadcast and telemetry (plugins' included) are off.
PUMPKIN_CONFIGURATION = '''\
server_address = "127.0.0.1:{port}"
seed = "{seed}"
max_players = 200
view_distance = {view}
simulation_distance = {view}
online_mode = false
default_difficulty = "Easy"
allow_chat_reports = false
encryption = false
lan_broadcast = false
[networking.bedrock]
enabled = false
[networking.lan_broadcast]
enabled = false
[networking.query]
enabled = false
[networking.rcon]
enabled = false
'''
PUMPKIN_FEATURES = '''\
[telemetry]
enabled = false
[plugins]
enabled = false
'''
DONE = re.compile(r'Done|listening|Server started', re.IGNORECASE)
TICK_AVERAGE = re.compile(r'Average time per tick: ([0-9.]+) ?ms')
TICK_PERCENTILES = re.compile(r'P50: ([0-9.]+) ?ms P95: ([0-9.]+) ?ms P99: ([0-9.]+) ?ms')
GAMETIME = re.compile(r'The time is (-?\d+)')
def free_port():
with socket.socket() as s:
s.bind(('127.0.0.1', 0))
return s.getsockname()[1]
def parse_tick_query(lines):
"""MSPT numbers from the lines `tick query` printed, or None until both lines are there."""
average = percentiles = None
for line in lines:
if (m := TICK_AVERAGE.search(line)) and average is None:
average = float(m.group(1))
if (m := TICK_PERCENTILES.search(line)) and percentiles is None:
percentiles = tuple(float(g) for g in m.groups())
if average is None or percentiles is None:
return None
return {'average': average, 'p50': percentiles[0], 'p95': percentiles[1], 'p99': percentiles[2]}
def parse_gametime(lines):
"""The game time `time query gametime` printed, or None."""
for line in lines:
if m := GAMETIME.search(line):
return int(m.group(1))
return None
def tps(start_ticks, end_ticks, seconds):
"""Game ticks per wall-clock second over the window, or None when a query went unanswered."""
if start_ticks is None or end_ticks is None or seconds <= 0:
return None
return round((end_ticks - start_ticks) / seconds, 2)
def percentile(values, p):
if not values:
return None
ordered = sorted(values)
return ordered[min(len(ordered) - 1, int(p / 100 * len(ordered)))]
def cpu_seconds(text):
"""ps's cumulative CPU time: [[DD-]HH:]MM:SS[.ss]."""
days, _, rest = text.rpartition('-')
seconds = 0.0
for part in rest.split(':'):
seconds = seconds * 60 + float(part)
return seconds + (float(days) * 86400 if days else 0)
def tree_usage(root):
"""RSS (bytes) and CPU seconds of a process and all its descendants; None without `ps`."""
try:
out = subprocess.run(['ps', '-A', '-o', 'pid=,ppid=,rss=,time='], capture_output=True, text=True).stdout
except OSError:
return None
rows = {}
for line in out.splitlines():
parts = line.split()
if len(parts) == 4:
rows[int(parts[0])] = (int(parts[1]), int(parts[2]) * 1024, cpu_seconds(parts[3]))
tree, frontier = {root}, [root]
while frontier:
parent = frontier.pop()
for pid, (ppid, _, _) in rows.items():
if ppid == parent and pid not in tree:
tree.add(pid)
frontier.append(pid)
return sum(rows[p][1] for p in tree if p in rows), sum(rows[p][2] for p in tree if p in rows)
def prepare(kind, directory, port, args):
"""Writes the server's settings into its scratch folder and returns the command that starts it."""
directory = Path(directory)
if args.pre_generated:
shutil.copytree(args.pre_generated, directory / 'world')
props = dict(PROPERTIES, **{'server-port': str(port), 'level-seed': str(args.seed),
'view-distance': str(args.view_distance),
'simulation-distance': str(args.view_distance)})
if kind == 'pumpkin':
config = directory / 'config'
config.mkdir()
(config / 'configuration.toml').write_text(PUMPKIN_CONFIGURATION.format(
port=port, seed=args.seed, view=args.view_distance))
(config / 'features.toml').write_text(PUMPKIN_FEATURES)
if args.pumpkin_config:
with (config / 'configuration.toml').open('a') as f:
f.write('\n' + Path(args.pumpkin_config).read_text())
return [Path(args.exe).resolve()]
(directory / 'server.properties').write_text(''.join(f'{k}={v}\n' for k, v in props.items()))
(directory / 'eula.txt').write_text('eula=true\n')
if kind == 'vanilla':
return ['java', f'-Xms{args.heap}', f'-Xmx{args.heap}', '-jar', Path(args.jar).resolve(), 'nogui']
server = Path(args.exe).resolve()
subprocess.run([server, '--dir', directory, 'import', directory / 'server.properties'], check=True,
capture_output=True)
toml = directory / 'server.toml'
# No web panel in a bench.
toml.write_text(re.sub(r'(?m)^bind = "127\.0\.0\.1:8130"', 'bind = ""', toml.read_text()))
return [server, '--dir', directory, 'run']
class Server:
"""A server with its console on stdin and its output collected."""
def __init__(self, command, directory, log_path):
self.lines = []
self.lock = threading.Lock()
self.log = log_path.open('w', encoding='utf-8')
env = dict(os.environ, HORIZON_NO_AUDIO='1', HORIZON_HOME=str(directory / 'horizon-home'),
HORIZON_LAUNCHER_NO_SELF_UPDATE='1')
self.process = subprocess.Popen([str(c) for c in command], cwd=directory, stdin=subprocess.PIPE,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
encoding='utf-8', errors='replace', env=env)
threading.Thread(target=self._read, daemon=True).start()
def _read(self):
for line in self.process.stdout:
with self.lock:
self.lines.append(line.rstrip('\n'))
self.log.write(line)
self.log.flush()
def count(self):
with self.lock:
return len(self.lines)
def since(self, n):
with self.lock:
return self.lines[n:]
def wait_for(self, regex, timeout):
deadline = time.time() + timeout
while time.time() < deadline:
if any(regex.search(line) for line in self.since(0)):
return
if self.process.poll() is not None:
raise RuntimeError('the server exited:\n' + '\n'.join(self.since(0)[-20:]))
time.sleep(0.1)
raise RuntimeError(f'the server was not up within {timeout} s')
def ask(self, command, parse, timeout=5):
"""Sends a console command and returns parse(its output lines), or None if nothing parsed in time."""
start = self.count()
try:
self.process.stdin.write(command + '\n')
self.process.stdin.flush()
except (BrokenPipeError, OSError):
return None
deadline = time.time() + timeout
while time.time() < deadline:
if (value := parse(self.since(start))) is not None:
return value
time.sleep(0.05)
return None
def stop(self):
if self.process.poll() is None:
try:
self.process.stdin.write('stop\n')
self.process.stdin.flush()
self.process.wait(timeout=60)
except (BrokenPipeError, OSError, subprocess.TimeoutExpired):
self.process.kill()
self.process.wait()
self.log.close()
def bots_command(args, port, total):
return [str(args.bots_bin), '--server', f'127.0.0.1:{port}', '--bots', str(args.bots),
'--behaviour', args.behaviour, '--join-every', str(args.join_every), '--seconds', str(int(total)),
'--seed', str(args.seed), '--view-distance', str(args.view_distance), '--report', '-']
def split_bot_output(lines):
"""The per-second samples (JSON lines with a `t`) and the final summary the bots printed."""
samples, rest = [], []
for line in lines:
try:
sample = json.loads(line)
except ValueError:
sample = None
if isinstance(sample, dict) and 't' in sample:
samples.append(sample)
else:
rest.append(line)
text = ''.join(rest)
return samples, (json.loads(text) if text.strip() else {})
def bytes_per_player_second(per_bot, run_seconds, join_every):
"""The bots' bytes over the seconds they were connected: bot i connects i * join_every after the first and
stays to the end of the run. So it is each bot's average from its login on, its join included, not the
measuring window's alone (the bots report one total at the end). None until the bots count bytes (S-6)."""
counted = [(i, b) for i, b in enumerate(per_bot) if 'bytes_in' in b and not b.get('error')]
online = sum(max(run_seconds - i * join_every, 0) for i, _ in counted)
if not counted or online <= 0:
return None
return {k: round(sum(b.get(k, 0) for _, b in counted) / online) for k in ('bytes_in', 'bytes_out')}
def summarize(kind, args, ticks, rss, cpu, gametime, window_seconds, summary, start_seconds, run_seconds):
mean = lambda xs: round(statistics.fmean(xs), 2) if xs else None
joins = [b['joined_after_ms'] for b in summary.get('per_bot', []) if b.get('joined_after_ms', 0) > 0]
bandwidth = bytes_per_player_second(summary.get('per_bot', []), run_seconds, args.join_every)
return {
'server': kind,
'bots': args.bots,
'behaviour': args.behaviour,
'start_seconds': start_seconds,
'tps': tps(*gametime, window_seconds),
'mspt': {k: mean([t[k] for t in ticks]) for k in ('average', 'p50', 'p95', 'p99')},
'mspt_worst_p99': max((t['p99'] for t in ticks), default=None),
'rss_mb': {'mean': mean([r / 1e6 for r in rss]), 'max': round(max(rss) / 1e6, 1) if rss else None},
'cpu_percent': mean(cpu),
'bytes_per_player_second': bandwidth,
'join_ms': {'p50': percentile(joins, 50), 'worst': max(joins, default=None)},
'bots_joined': summary.get('joined'),
'bots_failed': [b for b in summary.get('per_bot', []) if b.get('error')],
'chunks_total': summary.get('chunks'),
'tick_queries': len(ticks),
}
def run_once(kind, args, out):
scratch = Path(tempfile.mkdtemp(prefix=f'horizon-bench-{kind}-'))
result = {'server': kind, 'bots': args.bots, 'behaviour': args.behaviour}
server = None
try:
port = free_port()
command = prepare(kind, scratch, port, args)
started = time.time()
server = Server(command, scratch, out / f'{kind}-{args.bots}.log')
server.wait_for(DONE, args.start_timeout)
start_seconds = round(time.time() - started, 2)
total = args.bots * args.join_every + args.warmup + args.seconds
bot_process = subprocess.Popen(bots_command(args, port, total), stdout=subprocess.PIPE,
stderr=subprocess.PIPE, text=True)
bot_lines = []
threading.Thread(target=lambda: bot_lines.extend(bot_process.stdout), daemon=True).start()
time.sleep(args.bots * args.join_every + args.warmup)
first_time = server.ask('time query gametime', parse_gametime)
began = time.time()
rss, cpu, ticks = [], [], []
usage = tree_usage(server.process.pid)
last_cpu, last_time = (usage[1] if usage else 0), time.time()
next_query = time.time()
while time.time() - began < args.seconds:
time.sleep(1)
now = time.time()
if usage and (u := tree_usage(server.process.pid)):
rss.append(u[0])
cpu.append((u[1] - last_cpu) / (now - last_time) * 100)
last_cpu, last_time = u[1], now
if now >= next_query:
if (t := server.ask('tick query', parse_tick_query)) is not None:
ticks.append(t)
next_query = now + 5
window = time.time() - began
last = server.ask('time query gametime', parse_gametime)
bot_process.wait(timeout=total + 120)
time.sleep(0.5)
_, summary = split_bot_output(list(bot_lines))
result.update(summarize(kind, args, ticks, rss, cpu, (first_time, last), window, summary, start_seconds,
total))
except Exception as e: # a failed run is reported, and the next one still runs
result['error'] = str(e)
finally:
if server:
server.stop()
shutil.rmtree(scratch, ignore_errors=True)
return result
def table(runs):
"""The Markdown table of the design: one column per server, one row per measure."""
ok = [r for r in runs if 'error' not in r]
head = '| | ' + ' | '.join(r['server'] for r in ok) + ' |\n|---|' + '---|' * len(ok) + '\n'
def cell(r, path):
value = r
for key in path:
value = value.get(key) if isinstance(value, dict) else None
return '-' if value is None else str(value)
rows = [
('Players', ('bots',)),
('TPS', ('tps',)),
('MSPT average', ('mspt', 'average')),
('MSPT p99 (mean of 5 s queries)', ('mspt', 'p99')),
('MSPT p99 (worst query)', ('mspt_worst_p99',)),
('RSS mean MB', ('rss_mb', 'mean')),
('CPU %', ('cpu_percent',)),
('Bytes in per player and s online', ('bytes_per_player_second', 'bytes_in')),
('Join p50 ms', ('join_ms', 'p50')),
('Join worst ms', ('join_ms', 'worst')),
('Start s', ('start_seconds',)),
]
body = ''.join(f'| {name} | ' + ' | '.join(cell(r, path) for r in ok) + ' |\n' for name, path in rows)
failed = ''.join(f'\n{r["server"]} failed: {r["error"].splitlines()[0]}\n' for r in runs if 'error' in r)
return head + body + failed
def check(args):
for kind in args.server:
if kind not in SERVERS:
sys.exit(f'--server: {kind!r} is not one of {", ".join(SERVERS)}')
if 'vanilla' in args.server and not args.jar:
sys.exit('vanilla needs --jar, your own server.jar')
if any(k != 'vanilla' for k in args.server) and not args.exe:
sys.exit('horizon and pumpkin need --exe, the server binary')
if not Path(args.bots_bin).exists():
sys.exit(f'{args.bots_bin} is missing: cargo build --release -p horizon-bots')
if args.pre_generated and not Path(args.pre_generated).is_dir():
sys.exit(f'--pre-generated {args.pre_generated} is not a folder')
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__.split('\n\n')[0])
parser.add_argument('--server', type=lambda s: s.split(','), default=['horizon'],
help='horizon, vanilla or pumpkin (comma-separated, one run each)')
parser.add_argument('--exe', help='the horizon-server or Pumpkin binary')
parser.add_argument('--jar', help="vanilla's server.jar (your own copy)")
parser.add_argument('--bots-bin', default=str(ROOT / 'target/release' / f'horizon-bots{EXE}'))
parser.add_argument('--bots', type=int, default=50)
parser.add_argument('--behaviour', default='mixed')
parser.add_argument('--join-every', type=float, default=0.5)
parser.add_argument('--warmup', type=int, default=30)
parser.add_argument('--seconds', type=int, default=60)
parser.add_argument('--seed', type=int, default=12345)
parser.add_argument('--view-distance', type=int, default=10)
parser.add_argument('--heap', default='4g', help='the Java heap for vanilla')
parser.add_argument('--pre-generated', help='a finished world folder, copied into every run as `world`')
parser.add_argument('--pumpkin-config', help='extra TOML appended to Pumpkin\'s configuration.toml')
parser.add_argument('--start-timeout', type=int, default=600)
parser.add_argument('--out', type=Path, default=ROOT / '.local/bench', help='the folder for report.json and logs')
args = parser.parse_args(argv)
check(args)
args.out.mkdir(parents=True, exist_ok=True)
runs = []
for kind in args.server:
print(f'{kind}, {args.bots} {args.behaviour} bots...', flush=True)
runs.append(run_once(kind, args, args.out))
print(json.dumps(runs[-1]), flush=True)
report = {
'kind': 'horizon-bench',
'date': datetime.datetime.now(datetime.timezone.utc).isoformat(timespec='seconds'),
'machine': {'system': platform.system(), 'machine': platform.machine(), 'cpus': os.cpu_count()},
'settings': {k: str(v) if isinstance(v, Path) else v for k, v in vars(args).items()},
'runs': runs,
}
path = args.out / 'report.json'
path.write_text(json.dumps(report, indent=2) + '\n')
print('\n' + table(runs) + f'\nWrote {path}')
return 1 if any('error' in r for r in runs) else 0
if __name__ == '__main__':
sys.exit(main())
+71 -1
View File
@@ -13,7 +13,9 @@ const SITE: &str = "https://horizon-rust.github.io/horizon-mods/";
const USAGE: &str = "usage: cargo horizon-mod registry build <checkout> --files <dir> --out <index.json> [--release <url>] [--site <url>] [--generated <time>]
cargo horizon-mod registry review <base checkout> <head checkout> --author <login> [--json]
cargo horizon-mod registry check <base checkout> <head checkout> --files <dir>
cargo horizon-mod registry list <checkout>";
cargo horizon-mod registry list <checkout>
cargo horizon-mod registry release <checkout> <id> <file> --tag <tag> --published <YYYY-MM-DD> [--notes <file>] [--signature <file>] [--game <version>]
cargo horizon-mod registry page <checkout> <id> <horizon-registry.toml>";
fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> {
super::flag(args, name)
@@ -154,6 +156,74 @@ pub fn main(args: &[String]) -> Result<(), String> {
}
Ok(())
}
// A release of a project's repository, downloaded: the checks of a submission, then the
// version as the index lists it (JSON on stdout), for the registry's release sync.
Some("release") => {
let [checkout, id, file] = pos[..] else {
return Err(USAGE.into());
};
let (tag, published) = (
flag(rest, "--tag").ok_or(USAGE)?,
flag(rest, "--published").ok_or(USAGE)?,
);
let read = |name: &str| {
flag(rest, name)
.map(|p| std::fs::read_to_string(p).map_err(|e| format!("{p}: {e}")))
.transpose()
};
let (notes, signature) = (read("--notes")?, read("--signature")?);
let entry = entry_of(checkout, id)?;
let path = Path::new(file);
let cache = registry::Cache::read(Path::new(checkout), id)?;
let entry = entry.resolved(&cache.page);
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
crate::check::check(
path,
entry.kind.clone(),
entry.key.as_deref(),
signature.as_deref(),
)?;
let version = registry::release_version(
&entry,
&registry::Release {
tag,
published,
notes: notes.as_deref().unwrap_or(""),
asset: flag(rest, "--name").unwrap_or(name),
game: flag(rest, "--game").unwrap_or("26.3"),
signature: signature.as_deref(),
},
path,
)?;
println!(
"{}",
serde_json::to_string(&version).map_err(|e| e.to_string())?
);
Ok(())
}
// The page file of a project's repository, checked against its entry: the page as JSON.
Some("page") => {
let [checkout, id, file] = pos[..] else {
return Err(USAGE.into());
};
let entry = entry_of(checkout, id)?;
let text = std::fs::read_to_string(file).map_err(|e| format!("{file}: {e}"))?;
let page = registry::page(&entry, &text)?;
println!(
"{}",
serde_json::to_string(&page).map_err(|e| e.to_string())?
);
Ok(())
}
_ => Err(USAGE.into()),
}
}
/// `mods/<id>.toml` of the checkout, which must name a repository.
fn entry_of(checkout: &str, id: &str) -> Result<registry::Entry, String> {
let (entries, _) = registry::load(Path::new(checkout)).map_err(lines)?;
entries
.into_iter()
.find(|e| e.id == id && e.repo.is_some())
.ok_or_else(|| format!("{id}: no entry with a repo"))
}
@@ -3,8 +3,8 @@ name = "{name}"
version = "0.1.0"
description = ""
authors = []
# A content mod: blocks and items from data/ and assets/, no code (API 0.2).
# A content mod: blocks, items and mob effects from data/ and assets/, no code (API 0.2).
api = "0.2"
side = "both"
# registries: "May add blocks and items to your worlds".
# registries: "May add blocks, items and mob effects to your worlds".
capabilities = ["registries"]
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "horizon-anticheat"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version = "1.85"
description = "The dedicated server's anticheat: its [anticheat] settings, violation levels and the checks' rules, with no game state (docs/anticheat.md)"
[dependencies]
serde = { version = "1", features = ["derive"] }
toml = "1.1"
[lints]
workspace = true
+326
View File
@@ -0,0 +1,326 @@
//! The dedicated server's anticheat (docs/anticheat.md), without the game: the `[anticheat]` section of
//! `server.toml`, each player's violation levels, and the rules of the checks. The server's hub
//! (`horizon_client::lan`) feeds it what players send and carries out what it decides.
//!
//! It is off unless the owner turns it on. A check that fails *flags* the player: its violation level
//! (VL) rises by the flag's weight and falls back by `decay` a second. From `setback` on, the move or
//! action is cancelled (the player is sent back where the server last had them); from `alert` on,
//! online operators and the console are told; from `kick` on, the player is disconnected. Nothing bans.
//! Any threshold at 0 turns that action off.
//!
//! The checks are written from vanilla 26.3's own rules and from what cheats are publicly described to
//! do, never from another anticheat's code.
use serde::{Deserialize, Serialize};
pub mod prediction;
pub mod timer;
/// What one check's violation level leads to.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Actions {
pub setback: f32,
pub alert: f32,
pub kick: f32,
/// Violation level lost each second.
pub decay: f32,
}
macro_rules! checks {
($(
$(#[$doc:meta])*
$field:ident: $Check:ident => $Config:ident [$setback:expr, $alert:expr, $kick:expr, $decay:expr] {
$( $(#[$xdoc:meta])* $extra:ident: $ty:ty = $default:expr, )*
}
)*) => {
/// Every check, in the order of the players' violation levels.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Check { $($Check,)* }
impl Check {
pub const ALL: &[Check] = &[$(Check::$Check,)*];
/// Its name in `server.toml` and in alerts.
pub fn name(self) -> &'static str {
match self { $(Check::$Check => stringify!($field),)* }
}
}
$(
$(#[$doc])*
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct $Config {
pub enabled: bool,
pub setback: f32,
pub alert: f32,
pub kick: f32,
pub decay: f32,
$( $(#[$xdoc])* pub $extra: $ty, )*
}
impl Default for $Config {
fn default() -> Self {
Self {
enabled: true,
setback: $setback,
alert: $alert,
kick: $kick,
decay: $decay,
$($extra: $default,)*
}
}
}
)*
/// `server.toml`'s `[anticheat]`: off by default.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Config {
pub enabled: bool,
/// Tell online operators of an alert in their chat (the console and the log always are).
pub alerts: bool,
/// The permission level a player needs to see alerts.
pub alert_level: u8,
/// Every flag, one line each, relative to the server's folder; empty: no file.
pub log: String,
$(pub $field: $Config,)*
}
impl Default for Config {
fn default() -> Self {
Self {
enabled: false,
alerts: true,
alert_level: 2,
log: "logs/anticheat.log".into(),
$($field: $Config::default(),)*
}
}
}
impl Config {
/// Whether `check` runs: the anticheat and the check both on.
pub fn runs(&self, check: Check) -> bool {
self.enabled && match check { $(Check::$Check => self.$field.enabled,)* }
}
pub fn actions(&self, check: Check) -> Actions {
match check {
$(Check::$Check => {
let c = &self.$field;
Actions { setback: c.setback, alert: c.alert, kick: c.kick, decay: c.decay }
})*
}
}
}
};
}
checks! {
/// Timer: more client ticks than the server's clock allows (a sped-up game). Vanilla's client ends
/// every tick with ServerboundClientTickEndPacket, at the server's tick rate; each one spends a tick's
/// time from a balance that wall-clock time fills, capped so a pause can't be saved up for a burst.
timer: Timer => TimerConfig [2.0, 5.0, 20.0, 0.02] {
/// How far ahead of the clock, in milliseconds, a client may get before it is flagged.
tolerance_ms: f64 = 300.0,
/// The most time, in milliseconds, a quiet connection banks for the ticks that follow (a lag
/// spike's backlog arrives at once).
max_lag_ms: f64 = 1000.0,
}
/// Ground: a move that says it ends on the ground with nothing there to stand on: no-fall (claiming
/// the ground to skip fall damage) and walking on water. The claim is always dropped, so vanilla's
/// fall damage follows; from `setback` on the player is also sent back.
ground: Ground => GroundConfig [5.0, 15.0, 60.0, 2.0] {}
/// Prediction: each move played again from the client's own inputs with vanilla's movement code,
/// and compared (speed, step, fly, jesus...). Each player's client ticks are recorded
/// (`prediction::Record`), with a ping a tick to place what the server changes, and played again
/// (horizon-client's `lan::prediction`). A move that lands farther than `tolerance` from the
/// replay flags, by how far it lands (one `tolerance` off weighs 1, at most 5 a tick), and the
/// setback puts the player back where its moves last agreed. A tick near what the replay can't
/// play yet (another entity, a block change or abilities the client hadn't seen, a moving piston,
/// a chunk the client doesn't have, a riptide launch) is excused, and so are the ticks after it
/// until the moves agree again.
prediction: Prediction => PredictionConfig [5.0, 15.0, 60.0, 2.0] {
/// How far, in blocks, a move may land from where the replay puts it.
tolerance: f64 = 0.03,
}
}
/// How many checks there are.
pub const CHECKS: usize = Check::ALL.len();
impl Config {
/// The `[anticheat]` section of a whole `server.toml`; the default (off) when it has none.
pub fn from_server_toml(text: &str) -> Result<Self, String> {
#[derive(Deserialize)]
struct File {
#[serde(default)]
anticheat: Config,
}
// Other sections are the supervisor's to check: only this one is read here.
let mut table: toml::Table = toml::from_str(text).map_err(|e| e.to_string())?;
let mut only = toml::Table::new();
if let Some(section) = table.remove("anticheat") {
only.insert("anticheat".into(), section);
}
let file: File = only
.try_into()
.map_err(|e: toml::de::Error| format!("[anticheat]: {e}"))?;
file.anticheat.check()?;
Ok(file.anticheat)
}
/// Thresholds can't be negative.
pub fn check(&self) -> Result<(), String> {
for &c in Check::ALL {
let a = self.actions(c);
if [a.setback, a.alert, a.kick, a.decay]
.iter()
.any(|v| !v.is_finite() || *v < 0.0)
{
return Err(format!(
"anticheat.{}: setback, alert, kick and decay are numbers from 0 up",
c.name()
));
}
}
let tolerance = self.prediction.tolerance;
if !tolerance.is_finite() || tolerance <= 0.0 {
return Err("anticheat.prediction: tolerance is a number of blocks above 0".into());
}
Ok(())
}
}
/// What a flag leads to.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Verdict {
/// The check's violation level after the flag.
pub vl: f32,
/// Cancel the move or action.
pub setback: bool,
/// Tell operators and the console (at most once a second per check).
pub alert: bool,
pub kick: bool,
}
/// One player's anticheat state. Clean players cost nothing: levels only change when a check flags.
#[derive(Clone, Debug, Default)]
pub struct Player {
vl: [f32; CHECKS],
/// The server tick of each check's last flag (its decay runs from there).
flagged: [u64; CHECKS],
/// The server tick of each check's last alert.
alerted: [Option<u64>; CHECKS],
pub timer: timer::Balance,
/// The server tick and height where the ground last held the player.
pub ground: Option<(u64, f64)>,
/// The client ticks the prediction plays again (only while it runs).
pub record: Option<Box<prediction::Record>>,
}
impl Player {
/// The check's violation level at `tick`, with its decay since the last flag.
pub fn vl(&self, config: &Config, check: Check, tick: u64) -> f32 {
let i = check as usize;
let seconds = tick.saturating_sub(self.flagged[i]) as f32 / 20.0;
(self.vl[i] - config.actions(check).decay * seconds).max(0.0)
}
/// `check` failed with this weight at server tick `tick`.
pub fn flag(&mut self, config: &Config, check: Check, weight: f32, tick: u64) -> Verdict {
let i = check as usize;
let a = config.actions(check);
let vl = self.vl(config, check, tick) + weight;
self.vl[i] = vl;
self.flagged[i] = tick;
let reached = |threshold: f32| threshold > 0.0 && vl >= threshold;
let alert = reached(a.alert) && self.alerted[i].is_none_or(|t| tick >= t + 20);
if alert {
self.alerted[i] = Some(tick);
}
Verdict {
vl,
setback: reached(a.setback),
alert,
kick: reached(a.kick),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn on() -> Config {
Config {
enabled: true,
..Config::default()
}
}
#[test]
fn off_by_default_and_without_a_section() {
assert!(!Config::default().enabled);
let c = Config::from_server_toml("[server]\nport = 25565\n").unwrap();
assert_eq!(c, Config::default());
assert!(!c.runs(Check::Timer));
assert!(!c.runs(Check::Ground));
}
#[test]
fn reads_its_section() {
let text = "[server]\nport = 1\n[anticheat]\nenabled = true\n[anticheat.timer]\nkick = 0\ntolerance_ms = 500\n";
let c = Config::from_server_toml(text).unwrap();
assert!(c.runs(Check::Timer));
assert_eq!(c.timer.kick, 0.0);
assert_eq!(c.timer.tolerance_ms, 500.0);
assert_eq!(c.timer.alert, TimerConfig::default().alert);
let zero = "[anticheat]\n[anticheat.prediction]\ntolerance = 0\n";
assert!(Config::from_server_toml(zero).is_err());
let off = "[anticheat]\nenabled = true\n[anticheat.timer]\nenabled = false\n";
assert!(!Config::from_server_toml(off).unwrap().runs(Check::Timer));
}
#[test]
fn refuses_unknown_keys_and_negative_levels() {
assert!(Config::from_server_toml("[anticheat]\nenable = true\n").is_err());
assert!(Config::from_server_toml("[anticheat.timer]\nkick = -1\n").is_err());
}
#[test]
fn levels_rise_decay_and_act_in_order() {
let mut c = on();
c.timer = TimerConfig {
setback: 2.0,
alert: 3.0,
kick: 5.0,
decay: 1.0,
..TimerConfig::default()
};
let mut p = Player::default();
let v = p.flag(&c, Check::Timer, 1.0, 100);
assert_eq!((v.setback, v.alert, v.kick), (false, false, false));
let v = p.flag(&c, Check::Timer, 1.0, 100);
assert_eq!((v.vl, v.setback, v.alert), (2.0, true, false));
let v = p.flag(&c, Check::Timer, 1.0, 100);
assert!(v.alert && !v.kick);
// At most one alert a second.
assert!(!p.flag(&c, Check::Timer, 0.5, 110).alert);
// 3 at tick 110 (half a level gone, half added); two seconds later two more are gone.
assert_eq!(p.vl(&c, Check::Timer, 150), 1.0);
assert_eq!(p.vl(&c, Check::Timer, 10_000), 0.0);
let v = p.flag(&c, Check::Timer, 5.0, 10_000);
assert!(v.kick && v.alert);
}
#[test]
fn zero_turns_an_action_off() {
let mut c = on();
c.timer.kick = 0.0;
let mut p = Player::default();
assert!(!p.flag(&c, Check::Timer, 1000.0, 1).kick);
}
}
+404
View File
@@ -0,0 +1,404 @@
//! The movement prediction's record: each of a player's client ticks as the server heard them, so
//! the server can play the tick again with vanilla's movement code and compare (docs/anticheat.md).
//!
//! A vanilla client ends every tick with ServerboundClientTickEndPacket, after the tick's input
//! (ServerboundPlayerInputPacket, sent when the keys change) and its move (ServerboundMovePlayerPacket,
//! when it moved or turned). Those packets arrive in order, so the packets between two tick ends are one
//! client tick.
//!
//! What the server changes about the player (a push, a teleport, an effect) takes effect on the client
//! when it handles the packet, which is some client ticks after the server sent it. At the end of each of
//! its ticks the server sends a ClientboundPingPacket whose id is that server tick; the client answers
//! with ServerboundPongPacket as soon as it handles it, before its next tick runs. A client tick knows
//! the last server tick it had handled: every change of that server tick or an earlier one was in effect
//! during the client tick, none of a later one.
use std::collections::VecDeque;
/// Client ticks kept: two seconds at 20 a second.
pub const KEPT: usize = 40;
/// Server changes kept, the oldest dropped first.
const CHANGES_KEPT: usize = 64;
/// Changes near the player kept until every kept tick has seen them.
const NEAR_KEPT: usize = 1024;
/// Something the server sent the player that changes how its client moves. The client applies it when
/// it handles the packet, before its next tick.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Change {
/// ClientboundSetEntityMotionPacket for the player itself: its new velocity, as the client
/// decodes it.
Velocity([f64; 3]),
/// ClientboundExplodePacket's player knockback, added to the velocity.
Knockback([f64; 3]),
/// ClientboundUpdateAttributesPacket or a mob effect for the player itself: the server's
/// attributes and effects, as they are when the replay reaches it.
State,
/// ClientboundPlayerAbilitiesPacket (a game mode, flight given or taken), or the player's chest
/// slot (a glider put on or taken off): the replay takes these from the server every tick, so a
/// tick played before the client had them may be off.
Abilities,
}
/// A teleport the client accepted: the replay starts again from it.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Restart {
/// Where it put the player.
pub at: [f64; 3],
/// It kept the player's motion (an End gateway's).
pub kept_motion: bool,
/// Client ticks ended and changes heard before it: the ones after are the replay's.
pub ended: u64,
pub changed: u64,
}
/// One client tick.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct ClientTick {
/// The keys held (ServerboundPlayerInputPacket's flags: forward, backward, left, right, jump,
/// shift, sprint), as last sent.
pub input: u8,
/// Sprinting, as the player's commands left it (ServerboundPlayerCommandPacket).
pub sprinting: bool,
/// The rotation the client last sent.
pub yaw: f32,
pub pitch: f32,
/// Where the tick's move said the player is, if it said: a move with a position.
pub position: Option<[f64; 3]>,
/// The tick's move packet's flags (1: on the ground, 2: against a wall), if it sent one.
pub flags: Option<u8>,
/// The last server tick whose packets the client had handled (its pong) before this tick ended.
pub seen: Option<u64>,
}
/// A player's last client ticks.
#[derive(Clone, Debug, Default)]
pub struct Record {
/// The tick being heard.
current: ClientTick,
/// The ended ones, oldest first, at most [`KEPT`].
ticks: VecDeque<ClientTick>,
/// The last server tick the client answered for.
seen: Option<u64>,
/// Client ticks ended since the record began.
ended: u64,
/// Where an accepted teleport put the player, until the replay starts again from there.
restart: Option<Restart>,
/// What the server changed, with the server tick that sent it, oldest first.
changes: VecDeque<(u64, Change)>,
/// Changes heard since the record began.
changed: u64,
/// The blocks the server changed near the player (None: an entity it stopped showing the player
/// from near it, which counts wherever the player has gone since), with the server tick that sent
/// each, until every kept tick has seen them.
near: VecDeque<(u64, Option<[i32; 3]>)>,
/// The server tick of the last of them dropped early ([`NEAR_KEPT`] came after it).
near_dropped: Option<u64>,
}
impl Record {
/// The tick's move: its position if it has one, the rotation if it has one, and its flags.
pub fn moved(&mut self, position: Option<[f64; 3]>, rotation: Option<(f32, f32)>, flags: u8) {
if position.is_some() {
self.current.position = position;
}
if let Some((yaw, pitch)) = rotation {
(self.current.yaw, self.current.pitch) = (yaw, pitch);
}
self.current.flags = Some(flags);
}
/// ServerboundPongPacket for the ping sent at the end of `server_tick`. False for an answer to a
/// ping that was never sent, or one older than an answer already heard: a vanilla client answers
/// every ping, once, in order.
pub fn pong(&mut self, server_tick: u64, sent_up_to: u64) -> bool {
if server_tick > sent_up_to || self.seen.is_some_and(|seen| server_tick <= seen) {
return false;
}
self.seen = Some(server_tick);
true
}
/// ServerboundClientTickEndPacket: the tick ends with these keys held and this sprinting. The
/// next tick starts with the same keys and rotation, until the client says otherwise.
pub fn tick_end(&mut self, input: u8, sprinting: bool) -> &ClientTick {
self.current.input = input;
self.current.sprinting = sprinting;
self.current.seen = self.seen;
let next = ClientTick {
yaw: self.current.yaw,
pitch: self.current.pitch,
..ClientTick::default()
};
let ended = std::mem::replace(&mut self.current, next);
if self.ticks.len() == KEPT {
self.ticks.pop_front();
}
self.ticks.push_back(ended);
self.ended += 1;
// What the oldest tick kept had seen, every later one had too.
if let Some(seen) = self.ticks.front().and_then(|t| t.seen) {
while self.near.front().is_some_and(|&(sent, _)| sent <= seen) {
self.near.pop_front();
}
self.near_dropped = self.near_dropped.filter(|&sent| sent > seen);
}
self.ticks.back().expect("just pushed")
}
/// The ended ticks, oldest first.
pub fn ticks(&self) -> impl DoubleEndedIterator<Item = &ClientTick> + ExactSizeIterator {
self.ticks.iter()
}
/// The last server tick the client answered for.
pub fn seen(&self) -> Option<u64> {
self.seen
}
/// Client ticks ended since the record began.
pub fn ended(&self) -> u64 {
self.ended
}
/// The ticks ended after the first `played`, oldest first; None when some of them are no longer
/// kept.
pub fn since(&self, played: u64) -> Option<impl Iterator<Item = &ClientTick>> {
let new = self.ended.checked_sub(played)?;
let new = usize::try_from(new)
.ok()
.filter(|&n| n <= self.ticks.len())?;
Some(self.ticks.iter().skip(self.ticks.len() - new))
}
/// `change`, sent during server tick `server_tick` (before that tick's ping).
pub fn changed(&mut self, server_tick: u64, change: Change) {
if self.changes.len() == CHANGES_KEPT {
self.changes.pop_front();
}
self.changes.push_back((server_tick, change));
self.changed += 1;
}
/// Changes heard since the record began.
pub fn changes(&self) -> u64 {
self.changed
}
/// The changes after the first `applied`, oldest first; None when some of them are no longer kept.
pub fn changes_since(
&self,
applied: u64,
) -> Option<impl Iterator<Item = &(u64, Change)> + Clone> {
let new = self.changed.checked_sub(applied)?;
let new = usize::try_from(new)
.ok()
.filter(|&n| n <= self.changes.len())?;
Some(self.changes.iter().skip(self.changes.len() - new))
}
/// During server tick `server_tick` (before that tick's ping) the server sent the client the block
/// at `pos`.
pub fn changed_near(&mut self, server_tick: u64, pos: [i32; 3]) {
self.note_near(server_tick, Some(pos));
}
/// During server tick `server_tick` the server stopped showing the client an entity near it (a
/// firework it flew with, a mob that pushed it): the client may still have it, wherever it goes.
pub fn entity_gone(&mut self, server_tick: u64) {
if self.near.back() != Some(&(server_tick, None)) {
self.note_near(server_tick, None);
}
}
fn note_near(&mut self, server_tick: u64, pos: Option<[i32; 3]>) {
if self.near.len() == NEAR_KEPT {
self.near_dropped = self.near.pop_front().map(|(sent, _)| sent);
}
self.near.push_back((server_tick, pos));
}
/// Whether a client tick that had `seen` up to that server tick (its [`ClientTick::seen`]) may
/// not have had a change at a position for which `near` holds: it still had the old block, or the
/// entity, where the server no longer has it.
pub fn unseen_near(&self, seen: Option<u64>, near: impl Fn([i32; 3]) -> bool) -> bool {
let unseen = |sent: u64| seen.is_none_or(|seen| sent > seen);
self.near_dropped.is_some_and(unseen)
|| self
.near
.iter()
.any(|&(sent, pos)| unseen(sent) && pos.is_none_or(&near))
}
/// The client accepted a teleport to `at` (ServerboundAcceptTeleportationPacket): it goes on from
/// there, standing still unless the teleport `kept_motion` (an End gateway's). The ticks it ended
/// before stay, for a replay that hasn't played them yet; the one under way starts again.
pub fn teleported(&mut self, at: [f64; 3], kept_motion: bool) {
self.restart_tick();
self.restart = Some(Restart {
at,
kept_motion,
ended: self.ended,
changed: self.changed,
});
}
/// The last teleport the client accepted, once.
pub fn take_restart(&mut self) -> Option<Restart> {
self.restart.take()
}
/// The ticks before now say nothing about the ones after.
pub fn clear(&mut self) {
self.ticks.clear();
self.restart_tick();
}
fn restart_tick(&mut self) {
self.current = ClientTick {
yaw: self.current.yaw,
pitch: self.current.pitch,
..ClientTick::default()
};
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_change_near_is_unseen_by_the_ticks_before_its_pong() {
let mut r = Record::default();
r.changed_near(4, [1, 2, 3]);
r.changed_near(5, [7, 2, 3]);
let near = |p: [i32; 3]| p[0] < 5;
assert!(r.unseen_near(None, near));
assert!(r.unseen_near(Some(3), near));
assert!(!r.unseen_near(Some(4), near));
assert!(r.unseen_near(Some(4), |_| true));
// Kept until the oldest tick kept had seen it: one that ended before the pong hadn't.
r.tick_end(0, false);
assert!(r.pong(5, 5));
r.tick_end(0, false);
assert!(r.unseen_near(Some(3), near));
for _ in 0..KEPT {
r.tick_end(0, false);
}
assert!(!r.unseen_near(None, |_| true));
// An entity gone counts anywhere.
r.entity_gone(5);
r.entity_gone(5);
assert!(r.unseen_near(Some(4), |_| false));
assert!(!r.unseen_near(Some(5), |_| false));
// One dropped early counts for the ticks that hadn't seen it.
for i in 0..=NEAR_KEPT as u64 {
r.changed_near(6 + i, [100, 0, 0]);
}
assert!(
r.unseen_near(Some(5), near),
"a change dropped unseen counts"
);
assert!(!r.unseen_near(Some(6), near));
}
#[test]
fn a_tick_is_the_packets_between_two_tick_ends() {
let mut r = Record::default();
r.moved(Some([1.0, 2.0, 3.0]), Some((90.0, 10.0)), 1);
let t = *r.tick_end(0b1001, true);
assert_eq!(t.position, Some([1.0, 2.0, 3.0]));
assert_eq!((t.yaw, t.pitch, t.flags), (90.0, 10.0, Some(1)));
assert_eq!((t.input, t.sprinting, t.seen), (0b1001, true, None));
// A tick without a move keeps the rotation and has no position.
let t = *r.tick_end(0, false);
assert_eq!((t.position, t.flags, t.yaw), (None, None, 90.0));
assert_eq!(r.ticks().len(), 2);
}
#[test]
fn pongs_say_which_server_ticks_a_client_tick_had_seen() {
let mut r = Record::default();
assert!(r.pong(5, 6));
r.tick_end(0, false);
assert!(r.pong(6, 6));
r.tick_end(0, false);
r.tick_end(0, false);
let seen: Vec<_> = r.ticks().map(|t| t.seen).collect();
assert_eq!(seen, [Some(5), Some(6), Some(6)]);
// Never sent, answered twice, or out of order.
assert!(!r.pong(9, 6));
assert!(!r.pong(6, 6));
assert!(!r.pong(4, 6));
assert_eq!(r.seen(), Some(6));
}
#[test]
fn only_the_last_two_seconds_are_kept() {
let mut r = Record::default();
for i in 0..KEPT + 5 {
r.moved(Some([i as f64, 0.0, 0.0]), None, 0);
r.tick_end(0, false);
}
assert_eq!(r.ticks().len(), KEPT);
assert_eq!(r.ticks().next().unwrap().position, Some([5.0, 0.0, 0.0]));
r.clear();
assert_eq!(r.ticks().len(), 0);
}
#[test]
fn the_replay_takes_the_ticks_it_has_not_played() {
let mut r = Record::default();
for i in 0..3 {
r.moved(Some([i as f64, 0.0, 0.0]), None, 0);
r.tick_end(0, false);
}
fn x(r: &Record, played: u64) -> Option<Vec<f64>> {
r.since(played)
.map(|t| t.map(|t| t.position.unwrap()[0]).collect())
}
assert_eq!(x(&r, 1), Some(vec![1.0, 2.0]));
assert_eq!(x(&r, 3), Some(vec![]));
// Played ahead of the record, or so far behind the ticks are gone.
assert_eq!(x(&r, 4), None);
r.clear();
assert_eq!(x(&r, 2), None);
assert_eq!(x(&r, 3), Some(vec![]));
r.moved(Some([8.0, 0.0, 0.0]), None, 0);
r.tick_end(0, false);
r.moved(Some([9.0, 0.0, 0.0]), None, 0);
r.teleported([5.0, 6.0, 7.0], false);
// A tick that ends before the replay starts again is still one after the teleport.
r.tick_end(0, false);
let restart = r.take_restart().unwrap();
assert_eq!((restart.at, restart.kept_motion), ([5.0, 6.0, 7.0], false));
assert_eq!(r.since(restart.ended).unwrap().count(), 1);
// The tick ended before it stays for a replay that hasn't played it; the move of the one
// under way is gone.
let positions: Vec<_> = r.since(3).unwrap().map(|t| t.position).collect();
assert_eq!(positions, [Some([8.0, 0.0, 0.0]), None]);
assert_eq!(r.take_restart(), None);
}
#[test]
fn changes_keep_the_server_tick_that_sent_them() {
let mut r = Record::default();
r.changed(3, Change::Velocity([0.0, 0.4, 0.0]));
r.changed(5, Change::State);
let all: Vec<_> = r.changes_since(0).unwrap().copied().collect();
assert_eq!(
all,
[(3, Change::Velocity([0.0, 0.4, 0.0])), (5, Change::State)]
);
assert_eq!(r.changes_since(1).unwrap().count(), 1);
assert!(r.changes_since(3).is_none());
for i in 0..CHANGES_KEPT as u64 {
r.changed(6 + i, Change::State);
}
// The first two are gone.
assert!(r.changes_since(1).is_none());
assert_eq!(r.changes_since(2).unwrap().count(), CHANGES_KEPT);
}
}
+111
View File
@@ -0,0 +1,111 @@
//! The timer check's balance: how far a client's ticks are behind (positive) or ahead (negative) of the
//! server's clock, in milliseconds.
//!
//! The server adds the wall-clock time of each of its ticks, and every ServerboundClientTickEndPacket
//! takes away the length of one tick at the server's tick rate (vanilla's client ticks at that rate,
//! `TickRateManager.millisecondsPerTick`). The balance is capped at `max_lag_ms` *after* the tick's
//! packets are counted: a slow server tick that handles a second of packets at once also added that
//! second, and a quiet connection banks at most `max_lag_ms` for the burst that ends a lag spike.
use crate::TimerConfig;
#[derive(Clone, Debug, Default)]
pub struct Balance {
pub ms: f64,
}
impl Balance {
/// The start of a server tick that came `elapsed_ms` after the previous one.
pub fn elapse(&mut self, elapsed_ms: f64, config: &TimerConfig) {
self.ms = self.ms.min(config.max_lag_ms) + elapsed_ms;
}
/// One client tick ended, at a tick rate of `tick_rate` a second. Some(how far ahead, in ms) when the
/// client is further ahead than the tolerance; the balance starts again from zero then, so a
/// sustained speed-up is flagged again each time it gets that far ahead.
pub fn tick_end(&mut self, tick_rate: f32, config: &TimerConfig) -> Option<f64> {
self.ms -= 1000.0 / f64::from(tick_rate.max(1.0));
if self.ms < -config.tolerance_ms {
let ahead = -self.ms;
self.ms = 0.0;
return Some(ahead);
}
None
}
/// The tick rate changed or the player arrived: start again.
pub fn reset(&mut self) {
self.ms = 0.0;
}
}
#[cfg(test)]
mod tests {
use super::*;
/// `seconds` of server ticks of `server_ms` each, with `per_tick` client ticks handled in each.
fn run(b: &mut Balance, server_ms: f64, per_tick: f64, seconds: f64) -> usize {
let c = TimerConfig::default();
let mut owed = 0.0;
let mut flags = 0;
for _ in 0..(seconds * 1000.0 / server_ms) as usize {
b.elapse(server_ms, &c);
owed += per_tick;
while owed >= 1.0 {
owed -= 1.0;
flags += usize::from(b.tick_end(20.0, &c).is_some());
}
}
flags
}
#[test]
fn a_vanilla_client_is_never_flagged() {
let mut b = Balance::default();
assert_eq!(run(&mut b, 50.0, 1.0, 600.0), 0);
// A server that runs at 10 ticks a second handles two client ticks in each.
assert_eq!(run(&mut b, 100.0, 2.0, 600.0), 0);
}
#[test]
fn a_slow_server_tick_with_its_backlog_is_not_flagged() {
let (mut b, c) = (Balance::default(), TimerConfig::default());
b.elapse(3000.0, &c);
for _ in 0..60 {
assert_eq!(b.tick_end(20.0, &c), None);
}
}
#[test]
fn a_short_network_stall_is_not_flagged() {
let (mut b, c) = (Balance::default(), TimerConfig::default());
// 800 ms with nothing arriving, then the 16 ticks sent meanwhile.
for _ in 0..16 {
b.elapse(50.0, &c);
}
b.elapse(50.0, &c);
for _ in 0..17 {
assert_eq!(b.tick_end(20.0, &c), None);
}
}
#[test]
fn a_sped_up_client_is_flagged_again_and_again() {
let mut b = Balance::default();
// 10 % faster: 5 ms ahead each tick, 300 ms after 3 s, so close to 20 flags a minute.
let flags = run(&mut b, 50.0, 1.1, 60.0);
assert!((15..=20).contains(&flags), "{flags}");
// Twice as fast: a flag every 7 ticks.
assert!(run(&mut Balance::default(), 50.0, 2.0, 10.0) >= 25);
}
#[test]
fn a_pause_cant_be_saved_for_a_burst() {
let (mut b, c) = (Balance::default(), TimerConfig::default());
for _ in 0..200 {
b.elapse(50.0, &c);
}
let flagged = (0..60).any(|_| b.tick_end(20.0, &c).is_some());
assert!(flagged);
}
}
+557 -15
View File
@@ -15,24 +15,28 @@
//! entered the view apart from chunks sent again while the bot still held them (`resent`), and counts the
//! times the server pulled a bot back (`pulled_back`: a teleport after the first, as for "moved too quickly").
//! The report also has wire bytes in and out (`bytes_in`, `bytes_out`, per bot and in total, after
//! compression) and join-time percentiles (`join_time`: p50, p99 and max in milliseconds).
use horizon_protocol::{
PROTOCOL_VERSION,
buf::{Reader, Writer},
connection::{ConnWriter, Connection},
connection::{ConnWriter, Connection, Counters},
crypto::offline_uuid,
ids,
item::Stack,
packets::{
Empty, configuration,
handshake::{self, Intention},
login::{self, ServerHello},
play,
play, recipes,
},
};
use serde_json::json;
use std::{
collections::HashSet,
sync::{
Arc, Mutex,
Arc, LazyLock, Mutex, OnceLock,
atomic::{AtomicBool, AtomicU64, Ordering},
},
thread,
@@ -44,29 +48,83 @@ 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
--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),
flood-packets (--flood rotations a tick),
flood-chunks (one move a tick, 9.9 blocks straight ahead: as fast as the
moved-too-quickly check allows, never waiting for chunks),
and --flood a tick of packets that cost the server work:
flood-use (place and break stone around itself), flood-click (inventory
clicks with a stale state), flood-suggest (command suggestions, every
other one 30 000 characters long), flood-book (100 pages of 1024 characters
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-loaded never say the level is loaded (ServerboundPlayerLoadedPacket): a vanilla server
ignores the player's moves and actions for its first 3 seconds
--no-read stop reading from the server once spawned (a client that lets the
server's replies pile up)
--join-every SECONDS default 1
--seconds N how long to run after the first bot joins; default 60
--view-distance N what the bots ask for; default 10
--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
--height H blocks above spawn the bots fly at (walk, explore, idle); default 24
--ground claim the ground in every move (with --height 0 on a flat world: walking
on it; higher, or over water: a no-fall or jesus cheat, to test an anticheat)
--give SLOT:ID:COUNT,... creative-slot packets sent once joined (inventory menu slot, item
registry id, count), to test what a server takes
--click X,Y,Z:SLOT:BUTTON:MODE 3 s after joining, open the block at X,Y,Z (a chest), then a second later
click SLOT of its window (ServerboundContainerClickPacket), to test what a server takes
--raw TICK:ID:HEX,... raw serverbound play packets (id, body in hex) sent TICK ticks after joining,
to test what a server takes
--timer F cheat, to test an anticheat: tick F times as fast as vanilla's client (1.5: 30 Hz)
--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,
FloodAcks,
FloodPackets,
FloodChunks,
FloodUse,
FloodClick,
FloodSuggest,
FloodBook,
FloodSign,
FloodRecipe,
}
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",
Self::FloodAcks => "flood-acks",
Self::FloodPackets => "flood-packets",
Self::FloodChunks => "flood-chunks",
Self::FloodUse => "flood-use",
Self::FloodClick => "flood-click",
Self::FloodSuggest => "flood-suggest",
Self::FloodBook => "flood-book",
Self::FloodSign => "flood-sign",
Self::FloodRecipe => "flood-recipe",
}
}
}
@@ -81,8 +139,26 @@ 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,
no_loaded: bool,
height: f64,
ground: bool,
/// --raw: (tick after joining, packet id, body).
raw: Vec<(u64, i32, Vec<u8>)>,
/// ServerboundSetCreativeModeSlotPackets to send once joined: (slot, item id, count).
give: Vec<(i16, i32, i32)>,
/// --click: the block to open, then the slot, button and click type in its window.
click: Option<([i32; 3], i16, i8, i32)>,
name: String,
/// The `minecraft:brand` to send, if any.
brand: Option<String>,
report: Option<String>,
/// How many times as fast as vanilla's 20 Hz the bots tick (a timer cheat above 1).
timer: f64,
}
fn options() -> Result<Options, String> {
@@ -96,8 +172,20 @@ 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,
no_loaded: false,
height: 24.0,
ground: false,
raw: Vec::new(),
give: Vec::new(),
click: None,
name: "Bot".into(),
report: None,
brand: None,
timer: 1.0,
};
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
@@ -105,6 +193,18 @@ fn options() -> Result<Options, String> {
print!("{HELP}");
std::process::exit(0);
}
if arg == "--no-read" {
o.no_read = true;
continue;
}
if arg == "--no-loaded" {
o.no_loaded = true;
continue;
}
if arg == "--ground" {
o.ground = true;
continue;
}
let value = args.next().ok_or(format!("{arg} needs a value"))?;
let number = |what: &str| format!("{arg} needs {what}, not {value:?}");
match arg.as_str() {
@@ -122,14 +222,89 @@ fn options() -> Result<Options, String> {
"--explore-speed" => {
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),
"--give" => {
for part in value.split(',') {
let n: Vec<&str> = part.split(':').collect();
let parsed = match n[..] {
[s, i, c] => s.parse().ok().zip(i.parse().ok()).zip(c.parse().ok()),
_ => None,
};
let ((slot, item), count) = parsed.ok_or_else(|| number("SLOT:ID:COUNT"))?;
o.give.push((slot, item, count));
}
}
"--click" => {
let bad = || number("X,Y,Z:SLOT:BUTTON:MODE");
let n: Vec<&str> = value.split(':').collect();
let [at, slot, button, mode] = n[..] else {
return Err(bad());
};
let at: Vec<i32> = at
.split(',')
.map(str::parse)
.collect::<Result<_, _>>()
.map_err(|_| bad())?;
let at = <[i32; 3]>::try_from(at).map_err(|_| bad())?;
let parse = |s: &str| s.parse::<i32>().map_err(|_| bad());
o.click = Some((at, parse(slot)? as i16, parse(button)? as i8, parse(mode)?));
}
"--raw" => {
for part in value.split(',') {
let bad = || number("TICK:ID:HEX");
let [tick, id, hex] = part.split(':').collect::<Vec<_>>()[..] else {
return Err(bad());
};
let body = (0..hex.len())
.step_by(2)
.map(|i| {
hex.get(i..i + 2)
.and_then(|b| u8::from_str_radix(b, 16).ok())
})
.collect::<Option<Vec<u8>>>()
.ok_or_else(bad)?;
o.raw.push((
tick.parse().map_err(|_| bad())?,
id.parse().map_err(|_| bad())?,
body,
));
}
}
"--height" => o.height = value.parse().map_err(|_| number("blocks"))?,
"--timer" => {
o.timer = value.parse().map_err(|_| number("a speed, like 1.5"))?;
if !(o.timer >= 0.1 && o.timer <= 20.0) {
return Err(format!("--timer {value}: use 0.1 to 20"));
}
}
_ => return Err(format!("unknown option {arg}; --help lists them")),
}
}
if !["idle", "walk", "explore", "mixed"].contains(&o.behaviour.as_str()) {
const BEHAVIOURS: [&str; 15] = [
"idle",
"walk",
"explore",
"mixed",
"stand",
"flood-move",
"flood-acks",
"flood-packets",
"flood-chunks",
"flood-use",
"flood-click",
"flood-suggest",
"flood-book",
"flood-sign",
"flood-recipe",
];
if !BEHAVIOURS.contains(&o.behaviour.as_str()) {
return Err(format!(
"--behaviour {}: use idle, walk, explore or mixed",
"--behaviour {}: use idle, walk, explore, mixed or a flood behaviour (--help lists them)",
o.behaviour
));
}
@@ -156,6 +331,48 @@ struct Bot {
teleport: Mutex<Option<[f64; 3]>>,
error: Mutex<Option<String>>,
joined_after_ms: AtomicU64,
/// The connection's byte counters (wire bytes, after compression and encryption), set once it is open.
counters: OnceLock<Arc<Counters>>,
/// 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>>,
}
impl Bot {
fn bytes_in(&self) -> u64 {
self.counters
.get()
.map_or(0, |c| c.bytes_in.load(Ordering::Relaxed).max(0) as u64)
}
fn bytes_out(&self) -> u64 {
self.counters
.get()
.map_or(0, |c| c.bytes_out.load(Ordering::Relaxed).max(0) as u64)
}
}
/// Nearest-rank percentile (`p` in 0..=100) of an ascending-sorted slice; 0 when it is empty.
fn percentile(sorted: &[u64], p: u64) -> u64 {
if sorted.is_empty() {
return 0;
}
let rank = (p * sorted.len() as u64).div_ceil(100).max(1) as usize;
sorted[rank - 1]
}
/// Join-time summary in milliseconds over the bots that got in: count, p50, p99 and max.
fn join_summary(mut times: Vec<u64>) -> serde_json::Value {
times.sort_unstable();
json!({
"count": times.len(),
"p50_ms": percentile(&times, 50),
"p99_ms": percentile(&times, 99),
"max_ms": times.last().copied().unwrap_or(0),
})
}
/// A small deterministic generator (SplitMix64), so every run moves the bots the same way.
@@ -185,6 +402,89 @@ fn raw(id: i32, write: impl FnOnce(&mut Writer)) -> Vec<u8> {
w.data
}
/// Item ids of the pinned version (26.3's item registry).
const STONE: i32 = 1;
const WRITABLE_BOOK: i32 = 1371;
/// The `i`th packet of a costly flood this tick, the bot at `pos`; none for other behaviours.
fn costly(
behaviour: Behaviour,
i: usize,
sequence: i32,
pos: [f64; 3],
random: &mut Random,
) -> Option<Vec<u8>> {
use ids::play::serverbound as sb;
static LONG_COMMAND: LazyLock<String> =
LazyLock::new(|| format!("/msg {}", "x".repeat(30_000)));
static BOOK: LazyLock<Vec<u8>> = LazyLock::new(|| {
play::Serverbound::EditBook(play::EditBook {
slot: 0,
pages: vec!["a".repeat(1024); 100],
title: None,
})
.encode()
});
let block = pos.map(|v| v.floor() as i32);
let packet = match behaviour {
Behaviour::FloodUse => {
// A block within reach: place stone there, or break what is there.
let at = [0, 1, 2].map(|k| block[k] + (random.unit() * 7.0) as i32 - 3);
if i % 2 == 0 {
play::Serverbound::UseItemOn(play::UseItemOn {
hand: 0,
pos: at,
face: 1,
cursor: [0.5, 1.0, 0.5],
inside: false,
world_border: false,
sequence,
})
} else {
play::Serverbound::PlayerAction(play::PlayerAction {
action: 0,
pos: at,
direction: 1,
sequence,
})
}
}
Behaviour::FloodClick => play::Serverbound::ContainerClick(play::ContainerClick {
container: 0,
state: 0,
slot: 36 + (i % 9) as i16,
button: 0,
input: 0,
changed: Vec::new(),
carried: None,
}),
Behaviour::FloodSuggest => {
let text = if i % 2 == 0 {
"/"
} else {
LONG_COMMAND.as_str()
};
return Some(raw(sb::COMMAND_SUGGESTION, |w| {
w.varint(sequence);
w.string(text);
}));
}
Behaviour::FloodBook => return Some(BOOK.clone()),
Behaviour::FloodSign => play::Serverbound::SignUpdate(play::SignUpdate {
pos: [block[0], block[1] - 1, block[2]],
lines: std::array::from_fn(|_| "a".repeat(384)),
front: true,
}),
Behaviour::FloodRecipe => play::Serverbound::PlaceRecipe(recipes::PlaceRecipe {
container: 0,
recipe: (i % 2042) as i32,
use_max: true,
}),
_ => return None,
};
Some(packet.encode())
}
fn err(e: impl std::fmt::Display) -> String {
e.to_string()
}
@@ -252,6 +552,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 {
@@ -279,8 +592,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())
@@ -339,6 +653,12 @@ fn read(
let mut batch = BatchSize::new();
let mut teleports = 0u64;
loop {
if bot.deaf.load(Ordering::Relaxed) {
while bot.online.load(Ordering::SeqCst) {
thread::sleep(Duration::from_millis(100));
}
return;
}
let (id, body) = match reader.recv() {
Ok(packet) => packet,
Err(e) => {
@@ -379,6 +699,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(())
@@ -465,7 +800,9 @@ fn run_bot(
}
};
let (reader, writer) = connection.split();
let _ = bot.counters.set(reader.counters.clone());
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());
@@ -488,13 +825,43 @@ fn run_bot(
bot.joined_after_ms
.store(asked.elapsed().as_millis() as u64, Ordering::SeqCst);
let send = |body: Vec<u8>| lock(&writer).send(&body).is_ok();
send(raw(sb::PLAYER_LOADED, |_| {}));
if !o.no_loaded {
send(raw(sb::PLAYER_LOADED, |_| {}));
}
bot.deaf.store(o.no_read, Ordering::Relaxed);
// What the costly floods need in hand (creative: the server takes the bot's word for it).
let tool = match behaviour {
Behaviour::FloodUse => Some(Stack::new(STONE, 64)),
Behaviour::FloodBook => Some(Stack::new(WRITABLE_BOOK, 1)),
_ => None,
};
for &(slot, item, count) in &o.give {
send(
play::Serverbound::SetCreativeModeSlot(play::SetCreativeModeSlot {
slot,
item: Some(Stack::new(item, count)),
})
.encode(),
);
}
if tool.is_some() {
send(
play::Serverbound::SetCreativeModeSlot(play::SetCreativeModeSlot {
slot: 36,
item: tool,
})
.encode(),
);
send(play::Serverbound::SetCarriedItem(play::SetCarriedItem { slot: 0 }).encode());
}
let mut sequence = 0;
let mut random = Random(o.seed ^ (index as u64).wrapping_mul(0x2545_F491_4F6C_DD1D));
let mut pos = spawn;
let cruise = spawn[1] + 24.0;
let cruise = spawn[1] + o.height;
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;
@@ -504,13 +871,46 @@ fn run_bot(
pos = at;
}
let here = ((pos[0].floor() as i32) >> 4, (pos[2].floor() as i32) >> 4);
if !lock(&bot.held).contains(&here) {
// The abusive client doesn't wait for its chunk, as a real client must.
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) {
let angle = random.unit() * std::f64::consts::TAU;
let reach = 64.0 + random.unit() * 192.0;
pos[0] += angle.cos() * reach;
pos[2] += angle.sin() * reach;
send(
play::Serverbound::MovePlayerPos(play::MovePlayerPos {
x: pos[0],
y: pos[1],
z: pos[2],
flags: 0,
})
.encode(),
);
}
let angle = random.unit() * std::f64::consts::TAU;
pos[0] += angle.cos() * 128.0;
pos[2] += angle.sin() * 128.0;
} else if behaviour == Behaviour::FloodChunks {
// Up to cruising height first, then just under the server's 10 blocks a tick:
// every tick asks for new chunks.
if pos[1] < cruise {
pos[1] = (pos[1] + 0.5).min(cruise);
} else {
pos[0] += heading.cos() * 9.9;
pos[2] += heading.sin() * 9.9;
}
} else if !lock(&bot.held).contains(&here) {
bot.waited.fetch_add(1, Ordering::Relaxed);
} else if pos[1] < cruise {
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);
@@ -529,7 +929,37 @@ fn run_bot(
pos[2] += dz / distance * step;
}
}
Behaviour::Explore => {
Behaviour::FloodMove
| Behaviour::FloodChunks
| Behaviour::FloodUse
| Behaviour::FloodClick
| Behaviour::FloodSuggest
| Behaviour::FloodBook
| Behaviour::FloodSign
| Behaviour::FloodRecipe => {}
Behaviour::FloodPackets => {
for i in 0..o.flood {
send(
play::Serverbound::MovePlayerRot(play::MovePlayerRot {
yaw: (i % 360) as f32,
pitch: 0.0,
flags: 0,
})
.encode(),
);
}
}
Behaviour::FloodAcks | Behaviour::Explore => {
if behaviour == Behaviour::FloodAcks {
for _ in 0..o.flood {
send(
play::Serverbound::ChunkBatchReceived(play::ChunkBatchReceived {
chunks_per_tick: 64.0,
})
.encode(),
);
}
}
// Flying in creative, by default about 22 blocks a second, which keeps new
// chunks coming as fast as the server sends them.
let step = o.explore_speed / 20.0;
@@ -538,12 +968,65 @@ fn run_bot(
}
}
}
if let Some((at, slot, button, input)) = o.click {
// Ticks of 50 ms: the block 3 s after joining, the click a second later.
if tick == 60 {
sequence += 1;
send(
play::Serverbound::UseItemOn(play::UseItemOn {
hand: 0,
pos: at,
face: 1,
cursor: [0.5, 1.0, 0.5],
inside: false,
world_border: false,
sequence,
})
.encode(),
);
} else if tick == 80 {
// The first window a player opens is 1.
send(
play::Serverbound::ContainerClick(play::ContainerClick {
container: 1,
state: 0,
slot,
button,
input,
changed: Vec::new(),
carried: None,
})
.encode(),
);
}
}
for (at, id, body) in &o.raw {
if *at == tick {
send(raw(*id, |w| w.bytes(body)));
}
}
// The costly floods go out wherever the bot is, chunks or not (--no-read gets none).
for i in 0..o.flood {
sequence += 1;
if let Some(body) = costly(behaviour, i, sequence, pos, &mut random) {
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 || o.ground),
})
.encode(),
) && send(raw(sb::CLIENT_TICK_END, |_| {}));
@@ -551,7 +1034,7 @@ fn run_bot(
bot.online.store(false, Ordering::SeqCst);
break;
}
next += Duration::from_millis(50);
next += Duration::from_secs_f64(0.05 / o.timer);
let now = Instant::now();
if next > now {
thread::sleep(next - now);
@@ -567,8 +1050,19 @@ 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,
"flood-acks" => Behaviour::FloodAcks,
"flood-packets" => Behaviour::FloodPackets,
"flood-chunks" => Behaviour::FloodChunks,
"flood-use" => Behaviour::FloodUse,
"flood-click" => Behaviour::FloodClick,
"flood-suggest" => Behaviour::FloodSuggest,
"flood-book" => Behaviour::FloodBook,
"flood-sign" => Behaviour::FloodSign,
"flood-recipe" => Behaviour::FloodRecipe,
// mixed: of every five bots, three walk, one explores, one stands.
_ => match index % 5 {
0 | 2 | 4 => Behaviour::Walk,
@@ -659,10 +1153,20 @@ fn main() {
"resent": bots.iter().map(|b| b.resent.load(Ordering::Relaxed)).sum::<u64>(),
"pulled_back": bots.iter().map(|b| b.pulled_back.load(Ordering::Relaxed)).sum::<u64>(),
"waited_ticks": bots.iter().map(|b| b.waited.load(Ordering::Relaxed)).sum::<u64>(),
"bytes_in": bots.iter().map(|b| b.bytes_in()).sum::<u64>(),
"bytes_out": bots.iter().map(|b| b.bytes_out()).sum::<u64>(),
"join_time": join_summary(
bots.iter()
.map(|b| b.joined_after_ms.load(Ordering::SeqCst))
.filter(|&ms| ms > 0)
.collect(),
),
"per_bot": bots.iter().enumerate().map(|(i, b)| json!({
"name": format!("{}{i}", o.name),
"behaviour": behaviour_of(&o.behaviour, i).name(),
"joined_after_ms": b.joined_after_ms.load(Ordering::SeqCst),
"bytes_in": b.bytes_in(),
"bytes_out": b.bytes_out(),
"chunks": b.chunks.load(Ordering::Relaxed),
"unique_chunks": b.unique.load(Ordering::Relaxed),
"resent": b.resent.load(Ordering::Relaxed),
@@ -670,6 +1174,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("-") {
@@ -695,3 +1201,39 @@ fn main() {
}
std::process::exit(if errors.is_empty() { 0 } else { 1 });
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn percentiles_use_nearest_rank() {
assert_eq!(percentile(&[], 50), 0);
assert_eq!(percentile(&[7], 99), 7);
let v: Vec<u64> = (1..=100).collect();
assert_eq!(percentile(&v, 50), 50);
assert_eq!(percentile(&v, 99), 99);
assert_eq!(percentile(&[10, 20, 30, 40], 50), 20);
}
#[test]
fn join_summary_sorts_and_reports_max() {
let s = join_summary(vec![300, 100, 200]);
assert_eq!(s["count"], 3);
assert_eq!(s["p50_ms"], 200);
assert_eq!(s["p99_ms"], 300);
assert_eq!(s["max_ms"], 300);
assert_eq!(join_summary(vec![])["count"], 0);
}
#[test]
fn bot_bytes_come_from_the_connection_counters() {
let bot = Bot::default();
assert_eq!((bot.bytes_in(), bot.bytes_out()), (0, 0));
let counters = Arc::new(Counters::default());
counters.bytes_in.store(1234, Ordering::Relaxed);
counters.bytes_out.store(56, Ordering::Relaxed);
let _ = bot.counters.set(counters);
assert_eq!((bot.bytes_in(), bot.bytes_out()), (1234, 56));
}
}
+118 -40
View File
@@ -186,6 +186,73 @@ impl Parser<'_> {
Err("unterminated string".into())
}
}
/// `click_event` / `hover_event`, or the pre-datafixer `clickEvent` /
/// `hoverEvent` names SNBT and older chat JSON still use.
fn style_event_object<'a>(
o: &'a serde_json::Map<String, Value>,
modern: &str,
legacy: &str,
) -> Option<&'a serde_json::Map<String, Value>> {
o.get(modern)
.or_else(|| o.get(legacy))
.and_then(Value::as_object)
}
/// Style flags from JSON bools, or SNBT `1b`/`0b` which parse as numbers.
fn style_flag(v: Option<&Value>) -> Option<bool> {
match v {
Some(Value::Bool(b)) => Some(*b),
Some(Value::Number(n)) => n.as_i64().map(|i| i != 0),
_ => None,
}
}
fn untrusted_http_url(url: &str) -> bool {
// ExtraCodecs.UNTRUSTED_URI / Util.parseAndValidateUntrustedUri: http and
// https only, scheme case-insensitive.
url.split_once(':').is_some_and(|(scheme, _)| {
scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https")
})
}
/// ClickEvent.CODEC plus the legacy `value` field TextComponentHoverAndClickEventFix
/// renames (`url` / `command` / `page`).
fn parse_click_event(e: &serde_json::Map<String, Value>) -> Option<crate::text::ClickEvent> {
let text = |k: &str| e.get(k).and_then(Value::as_str).map(str::to_owned);
let legacy = || text("value");
match e.get("action").and_then(Value::as_str) {
Some("open_url") => text("url")
.or_else(legacy)
.filter(|u| untrusted_http_url(u))
.map(crate::text::ClickEvent::OpenUrl),
Some("change_page") => e
.get("page")
.and_then(Value::as_i64)
.or_else(|| e.get("value").and_then(Value::as_i64))
.or_else(|| {
e.get("value")
.and_then(Value::as_str)
.and_then(|s| s.parse().ok())
})
.and_then(|p| i32::try_from(p).ok())
.filter(|p| *p >= 1)
.map(crate::text::ClickEvent::ChangePage),
Some("suggest_command") => text("command")
.or_else(legacy)
.map(crate::text::ClickEvent::SuggestCommand),
Some("run_command") => text("command")
.or_else(legacy)
.map(crate::text::ClickEvent::RunCommand),
Some("copy_to_clipboard") => text("value").map(crate::text::ClickEvent::CopyToClipboard),
// ClickEvent.ShowDialog.CODEC: a `minecraft:dialog` registry id or
// the dialog itself; the server may send it (isAllowedFromServer).
Some("show_dialog") => e.get("dialog").map(|dialog| {
crate::text::ClickEvent::ShowDialog(crate::scoreboard::json_to_tag(dialog))
}),
_ => None,
}
}
/// The style fields of a component object.
fn object_style(o: &serde_json::Map<String, Value>) -> Style {
let mut style = Style::default();
@@ -196,58 +263,36 @@ fn object_style(o: &serde_json::Map<String, Value>) -> Style {
});
}
style.shadow_color = o.get("shadow_color").and_then(shadow_color);
let flag = |k: &str| o.get(k).and_then(Value::as_bool);
style.bold = flag("bold");
style.italic = flag("italic");
style.underlined = flag("underlined");
style.strikethrough = flag("strikethrough");
style.obfuscated = flag("obfuscated");
style.bold = style_flag(o.get("bold"));
style.italic = style_flag(o.get("italic"));
style.underlined = style_flag(o.get("underlined"));
style.strikethrough = style_flag(o.get("strikethrough"));
style.obfuscated = style_flag(o.get("obfuscated"));
style.font = o.get("font").and_then(crate::text::font_id);
// ClickEvent.CODEC: the actions a chat line acts on here. A component
// from a server never opens a file.
if let Some(e) = o.get("click_event").and_then(Value::as_object) {
let text = |k: &str| e.get(k).and_then(Value::as_str).map(str::to_owned);
style.click = match e.get("action").and_then(Value::as_str) {
// ClickEvent.OpenUrl.CODEC: ExtraCodecs.UNTRUSTED_URI allows http and
// https only, the scheme in any case (Util.parseAndValidateUntrustedUri).
Some("open_url") => text("url")
.filter(|u| {
u.split_once(':').is_some_and(|(scheme, _)| {
scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https")
})
})
.map(crate::text::ClickEvent::OpenUrl),
// ClickEvent.ChangePage.CODEC: ExtraCodecs.POSITIVE_INT.
Some("change_page") => e
.get("page")
.and_then(Value::as_i64)
.and_then(|p| i32::try_from(p).ok())
.filter(|p| *p >= 1)
.map(crate::text::ClickEvent::ChangePage),
Some("suggest_command") => text("command").map(crate::text::ClickEvent::SuggestCommand),
Some("run_command") => text("command").map(crate::text::ClickEvent::RunCommand),
Some("copy_to_clipboard") => {
text("value").map(crate::text::ClickEvent::CopyToClipboard)
}
// ClickEvent.ShowDialog.CODEC: a `minecraft:dialog` registry id or
// the dialog itself; the server may send it (isAllowedFromServer).
Some("show_dialog") => e.get("dialog").map(|dialog| {
crate::text::ClickEvent::ShowDialog(crate::scoreboard::json_to_tag(dialog))
}),
_ => None,
};
if let Some(e) = style_event_object(o, "click_event", "clickEvent") {
style.click = parse_click_event(e);
}
// HoverEvent.ShowText and ShowItem.
if let Some(e) = o.get("hover_event").and_then(Value::as_object) {
// HoverEvent.ShowText, ShowItem and ShowEntity.
if let Some(e) = style_event_object(o, "hover_event", "hoverEvent") {
match e.get("action").and_then(Value::as_str) {
Some("show_text") => {
if let Some(value) = e.get("value") {
// Modern `value`, or the older `contents` / nested `text`.
let value = e
.get("value")
.or_else(|| e.get("contents"))
.or_else(|| e.get("text"));
if let Some(value) = value {
style.hover = Some(Box::new(to_text(value, &mut |s| Text::literal(s))));
}
}
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())));
}
_ => {}
}
}
@@ -503,10 +548,43 @@ mod tests {
let t = to_text(&v, &mut |_| Text::empty());
assert_eq!(t.plain(), "Hi there");
assert_eq!(t.spans[1].style.color, named_color("red"));
// SNBT `1b` is a number in JSON; style flags still take it as true.
assert_eq!(t.spans[1].style.bold, Some(true));
let (v, _) = parse_snbt("[\"a\",{\"text\":\"b\"}]").unwrap();
assert_eq!(to_text(&v, &mut |_| Text::empty()).plain(), "ab");
}
#[test]
fn legacy_click_event_open_url_uses_value_field() {
use crate::text::ClickEvent;
let modern = serde_json::json!({
"text": "site",
"click_event": {"action": "open_url", "url": "https://example.com"},
"underlined": true
});
let legacy = serde_json::json!({
"text": "site",
"clickEvent": {"action": "open_url", "value": "https://example.com"},
"underlined": 1
});
for component in [modern, legacy] {
let text = to_text(&component, &mut |_| Text::empty());
assert_eq!(
text.spans[0].style.click,
Some(ClickEvent::OpenUrl("https://example.com".into()))
);
assert_eq!(text.spans[0].style.underlined, Some(true));
}
let snbt = r#"{text:"site",underlined:1b,clickEvent:{action:open_url,value:"https://example.com"}}"#;
let (v, _) = parse_snbt(snbt).unwrap();
let text = to_text(&v, &mut |_| Text::empty());
assert_eq!(
text.spans[0].style.click,
Some(ClickEvent::OpenUrl("https://example.com".into()))
);
assert_eq!(text.spans[0].style.underlined, Some(true));
}
#[test]
fn the_font_is_inherited_and_kept() {
let (v, _) =
+87 -18
View File
@@ -1,32 +1,68 @@
//! Key display names (`InputConstants.Key.getDisplayName`), shared by key
//! mappings and `keybind` text components.
use crate::text::Text;
use std::{collections::BTreeMap, sync::RwLock};
/// InputConstants.Key.getDisplayName: one-character keys as the upper-case
/// character, others by translation.
/// The keys that write a symbol, with what they write on a US layout.
const SYMBOLS: [(&str, &str); 11] = [
("key.keyboard.minus", "-"),
("key.keyboard.equal", "="),
("key.keyboard.left.bracket", "["),
("key.keyboard.right.bracket", "]"),
("key.keyboard.backslash", "\\"),
("key.keyboard.semicolon", ";"),
("key.keyboard.apostrophe", "'"),
("key.keyboard.grave.accent", "`"),
("key.keyboard.comma", ","),
("key.keyboard.period", "."),
("key.keyboard.slash", "/"),
];
/// What the player's keyboard layout writes on each key that writes
/// something, by key name, as the window reported it (`learn_layout`).
static LAYOUT: RwLock<BTreeMap<String, String>> = RwLock::new(BTreeMap::new());
/// The keyboard layout writes `text` on the key `key` (`key.keyboard.q`):
/// its name from now on, as `SDL_GetKeyFromScancode` names it with SDL's
/// default keycode options. Only letter and symbol keys take it; a letter
/// key keeps its Latin letter on a non-Latin layout (`latin_letters`), and
/// the number row keeps its digits (`french_numbers`).
pub fn learn_layout(key: &str, text: &str) {
let Some(rest) = key.strip_prefix("key.keyboard.") else {
return;
};
let mut chars = text.chars();
let (Some(c), None) = (chars.next(), chars.next()) else {
return;
};
let letter_key = rest.len() == 1 && rest.as_bytes()[0].is_ascii_lowercase();
let latin = c.is_ascii() || ('\u{C0}'..='\u{24F}').contains(&c);
if c.is_control()
|| c.is_whitespace()
|| !(letter_key || SYMBOLS.iter().any(|(k, _)| *k == key))
|| (letter_key && c.is_alphabetic() && !latin)
{
return;
}
if let Ok(mut layout) = LAYOUT.write() {
layout.insert(key.to_owned(), c.to_uppercase().collect());
}
}
/// InputConstants.Key.getDisplayName: what the layout writes on the key,
/// upper-case, else the US layout's character, else by translation.
pub fn display_name(key: &str) -> Text {
if let Some(label) = LAYOUT.read().ok().and_then(|l| l.get(key).cloned()) {
return Text::literal(label);
}
if let Some(rest) = key.strip_prefix("key.keyboard.")
&& rest.chars().count() == 1
{
let c = rest.to_uppercase();
return Text::literal(c);
}
let symbol = match key {
"key.keyboard.minus" => Some("-"),
"key.keyboard.equal" => Some("="),
"key.keyboard.left.bracket" => Some("["),
"key.keyboard.right.bracket" => Some("]"),
"key.keyboard.backslash" => Some("\\"),
"key.keyboard.semicolon" => Some(";"),
"key.keyboard.apostrophe" => Some("'"),
"key.keyboard.grave.accent" => Some("`"),
"key.keyboard.comma" => Some(","),
"key.keyboard.period" => Some("."),
"key.keyboard.slash" => Some("/"),
_ => None,
};
if let Some(s) = symbol {
return Text::literal(s);
if let Some((_, s)) = SYMBOLS.iter().find(|(k, _)| *k == key) {
return Text::literal(*s);
}
if let Some(n) = key.strip_prefix("key.mouse.")
&& n.parse::<u32>().is_ok()
@@ -36,3 +72,36 @@ pub fn display_name(key: &str) -> Text {
}
Text::translate(key, &[])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keys_show_what_the_layout_writes() {
// The layout table is shared: these keys are this test's alone.
assert_eq!(display_name("key.keyboard.q").plain(), "Q");
// AZERTY: A on the Q key, M on the semicolon key, a dead ^ on the
// left bracket, a comma on the M key.
learn_layout("key.keyboard.q", "a");
learn_layout("key.keyboard.semicolon", "m");
learn_layout("key.keyboard.left.bracket", "^");
learn_layout("key.keyboard.m", ",");
assert_eq!(display_name("key.keyboard.q").plain(), "A");
assert_eq!(display_name("key.keyboard.semicolon").plain(), "M");
assert_eq!(display_name("key.keyboard.left.bracket").plain(), "^");
assert_eq!(display_name("key.keyboard.m").plain(), ",");
// The number row keeps its digits; a Cyrillic letter, nothing
// printable and keys that write nothing are left alone.
learn_layout("key.keyboard.1", "&");
learn_layout("key.keyboard.f", "а");
learn_layout("key.keyboard.g", "\u{8}");
learn_layout("key.keyboard.tab", "\t");
learn_layout("key.keyboard.h", "ab");
assert_eq!(display_name("key.keyboard.1").plain(), "1");
assert_eq!(display_name("key.keyboard.f").plain(), "F");
assert_eq!(display_name("key.keyboard.g").plain(), "G");
assert_eq!(display_name("key.keyboard.h").plain(), "H");
assert_eq!(display_name("key.keyboard.slash").plain(), "/");
}
}
+16
View File
@@ -66,6 +66,15 @@ impl ResourceSource for Bundled {
"assets/horizon/lang/uk_ua.json" => {
include_str!("../../horizon-client/assets/horizon/lang/uk_ua.json")
}
"assets/horizon/lang/es_es.json"
| "assets/horizon/lang/es_ar.json"
| "assets/horizon/lang/es_cl.json"
| "assets/horizon/lang/es_ec.json"
| "assets/horizon/lang/es_mx.json"
| "assets/horizon/lang/es_uy.json"
| "assets/horizon/lang/es_ve.json" => {
include_str!("../../horizon-client/assets/horizon/lang/es_es.json")
}
_ => return None,
};
Some(body.as_bytes().to_vec())
@@ -287,6 +296,13 @@ mod tests {
uk.get("horizon.options.on_screen_keyboard"),
Some("Екранна клавіатура")
);
let es = Language::load(&[&empty], "es_es");
assert_eq!(es.get("horizon.options.controller"), Some("Mando"));
let es_ve = Language::load(&[&empty], "es_ve");
assert_eq!(
es_ve.get("horizon.options.on_screen_keyboard"),
Some("Teclado en pantalla")
);
}
#[test]
@@ -55,6 +55,12 @@ particle_types! {
Rain = "rain",
Bubble = "bubble",
SulfurBubbles = "sulfur_bubbles",
NoxiousGas = "noxious_gas",
NoxiousGasCloud = "noxious_gas_cloud",
Geyser = "geyser",
GeyserBase = "geyser_base",
GeyserPoof = "geyser_poof",
GeyserPlume = "geyser_plume",
BubblePop = "bubble_pop",
BubbleColumnUp = "bubble_column_up",
CurrentDown = "current_down",
@@ -149,6 +155,10 @@ impl ParticleType {
matches!(
self,
Self::BlockMarker
| Self::Geyser
| Self::GeyserBase
| Self::GeyserPoof
| Self::GeyserPlume
| Self::DamageIndicator
| Self::ExplosionEmitter
| Self::Explosion
@@ -191,6 +201,8 @@ impl ParticleType {
// SpellParticle.
| Self::Effect | Self::InstantEffect | Self::EntityEffect
| Self::Witch | Self::Infested | Self::TrialOmen | Self::RaidOmen
// NoxiousGasParticle.
| Self::NoxiousGas
// EmissiveRisingParticle.
| Self::Soul | Self::SculkSoul
// SculkChargeParticle, SculkChargePopParticle, ShriekParticle,
+84 -5
View File
@@ -9,7 +9,8 @@ pub enum Category {
Harmful,
Neutral,
}
/// A `MobEffect`: id (without namespace), colour, category, instantaneous.
/// A `MobEffect`: id (vanilla's without namespace, a mod's `<ns>:<path>`),
/// colour, category, instantaneous.
#[derive(Clone, Copy, Debug)]
pub struct EffectDef {
pub id: &'static str,
@@ -84,11 +85,52 @@ pub const EFFECTS: &[EffectDef] = &[
fx("infested", 9214860, H),
fx("breath_of_the_nautilus", 65518, B),
];
pub fn effect(id: &str) -> Option<&'static EffectDef> {
let id = id.trim_start_matches("minecraft:");
EFFECTS.iter().find(|e| e.id == id)
/// The effects content mods declare, registered after vanilla's.
static MOD_EFFECTS: OnceLock<Vec<EffectDef>> = OnceLock::new();
/// Registers the content mods' effects, `<ns>:<path>` ids in registry order.
/// The list is set once per process: false when it already was, and the
/// effects stay those of the first call until the game restarts.
pub fn register_mod_effects(effects: impl IntoIterator<Item = (String, u32, Category)>) -> bool {
if MOD_EFFECTS.get().is_some() {
return false;
}
let defs: Vec<EffectDef> = effects
.into_iter()
.map(|(id, color, category)| {
debug_assert!(id.contains(':'), "{id} has no namespace");
fx(Box::leak(id.into_boxed_str()), color, category)
})
.collect();
MOD_EFFECTS.set(defs).is_ok()
}
/// Namespaced effect ids for mob effect storage.
/// The content mods' effects, in registry order.
pub fn mod_effects() -> &'static [EffectDef] {
MOD_EFFECTS.get().map_or(&[], Vec::as_slice)
}
/// Every registered effect: vanilla's, then the content mods'.
pub fn all_effects() -> impl Iterator<Item = &'static EffectDef> {
EFFECTS.iter().chain(mod_effects())
}
pub fn effect(id: &str) -> Option<&'static EffectDef> {
let short = id.trim_start_matches("minecraft:");
match EFFECTS.iter().find(|e| e.id == short) {
Some(e) => Some(e),
None => mod_effect(id),
}
}
/// Out of line, so vanilla's lookups (every frame, every tick) stay as they were.
#[cold]
#[inline(never)]
fn mod_effect(id: &str) -> Option<&'static EffectDef> {
mod_effects().iter().find(|e| e.id == id)
}
/// `MobEffect.getDescriptionId` (`Util.makeDescriptionId("effect", key)`):
/// `effect.<namespace>.<path>`, `/` in the path as `.`.
pub fn description_id(id: &str) -> String {
let (namespace, path) = id.split_once(':').unwrap_or(("minecraft", id));
format!("effect.{namespace}.{}", path.replace('/', "."))
}
/// Namespaced effect ids for mob effect storage; a mod's id already is.
pub fn namespaced(id: &'static str) -> &'static str {
static IDS: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
let key = id.strip_prefix("minecraft:").unwrap_or(id);
@@ -248,3 +290,40 @@ pub fn potion_color(id: Option<&str>) -> u32 {
_ => BASE_POTION_COLOR,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn description_ids_keep_the_namespace() {
assert_eq!(description_id("speed"), "effect.minecraft.speed");
assert_eq!(description_id("minecraft:speed"), "effect.minecraft.speed");
assert_eq!(description_id("ruby:curse/dark"), "effect.ruby.curse.dark");
}
/// The only test that registers: the registry is set once per process.
#[test]
fn mod_effects_follow_vanilla_once_registered() {
assert!(register_mod_effects([(
"ruby:glow".to_owned(),
0xFF0000,
Category::Neutral
)]));
assert!(!register_mod_effects([(
"ruby:late".to_owned(),
1,
Category::Harmful
)]));
let glow = effect("ruby:glow").unwrap();
assert_eq!(
(glow.color, glow.category, glow.instant),
(0xFF0000, Category::Neutral, false)
);
assert!(effect("glow").is_none() && effect("ruby:late").is_none());
assert_eq!(namespaced(glow.id), "ruby:glow");
assert_eq!(effect("minecraft:speed").unwrap().id, "speed");
let ids: Vec<_> = all_effects().map(|e| e.id).collect();
assert_eq!(ids.len(), EFFECTS.len() + 1);
assert_eq!(ids.last(), Some(&"ruby:glow"));
}
}
@@ -28,6 +28,10 @@ pub struct Registries {
/// Game rule name -> integer-valued.
pub game_rules: Vec<(String, bool)>,
pub players: Vec<String>,
/// `ClientSuggestionProvider.customCompletionSuggestions`: the extra chat
/// completions the server sent (ClientboundCustomChatCompletionsPacket),
/// each once.
pub custom_completions: std::collections::BTreeSet<String>,
/// Scoreboard objective names, team names and tracked score holders.
pub objectives: Vec<String>,
pub teams: Vec<String>,
@@ -87,7 +91,55 @@ pub struct RegistryIds {
pub tags: Vec<String>,
}
const EMPTY: &[String] = &[];
/// ClientboundCustomChatCompletionsPacket.Action.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CustomCompletionAction {
Add,
Remove,
Set,
}
impl CustomCompletionAction {
/// `Action.BY_ID`: `ByIdMap.continuous` with `OutOfBoundsStrategy.ZERO`,
/// so an unknown id is `ADD`.
pub fn from_id(id: i32) -> Self {
match id {
1 => Self::Remove,
2 => Self::Set,
_ => Self::Add,
}
}
}
/// `ClientSuggestionProvider.modifyCustomCompletions`: the server adds,
/// removes or replaces the extra chat completions.
pub fn modify_custom_completions(
set: &mut std::collections::BTreeSet<String>,
action: CustomCompletionAction,
entries: &[String],
) {
match action {
CustomCompletionAction::Add => set.extend(entries.iter().cloned()),
CustomCompletionAction::Remove => {
for e in entries {
set.remove(e);
}
}
CustomCompletionAction::Set => {
set.clear();
set.extend(entries.iter().cloned());
}
}
}
impl Registries {
/// `ClientSuggestionProvider.getCustomTabSuggestions`: what chat (not a
/// command) completes, the online players' names with the server's custom
/// completions, each once.
pub fn custom_tab_suggestions(&self) -> std::collections::BTreeSet<&str> {
self.players
.iter()
.chain(&self.custom_completions)
.map(String::as_str)
.collect()
}
/// Fill what the game did not build itself from vanilla's exported
/// registries (`command-registries.json`, written by
/// `VanillaCommandParseOracle registries`): built-in registries such as
+2 -173
View File
@@ -3,179 +3,8 @@
//! number formatting.
use crate::text::{self, Style, Text};
use horizon_world::nbt::Tag;
/// `Double.toString`.
pub fn java_double(v: f64) -> String {
java_float_text(v, &format!("{v}"), &format!("{v:e}"))
}
/// `Float.toString`.
pub fn java_float(v: f32) -> String {
java_float_text(f64::from(v), &format!("{v}"), &format!("{v:e}"))
}
fn java_float_text(v: f64, plain: &str, sci: &str) -> String {
if v.is_nan() {
return "NaN".into();
}
if v.is_infinite() {
return if v > 0.0 { "Infinity" } else { "-Infinity" }.into();
}
if v == 0.0 {
return if v.is_sign_negative() { "-0.0" } else { "0.0" }.into();
}
let a = v.abs();
if (1.0e-3..1.0e7).contains(&a) {
if plain.contains('.') {
plain.to_owned()
} else {
format!("{plain}.0")
}
} else {
let (mantissa, exponent) = sci.split_once('e').unwrap_or((sci, "0"));
let mantissa = if mantissa.contains('.') {
mantissa.to_owned()
} else {
format!("{mantissa}.0")
};
format!("{mantissa}E{exponent}")
}
}
/// `SnbtGrammar.escapeControlCharacters`.
fn escape_control(c: char) -> Option<&'static str> {
Some(match c {
'\u{8}' => "b",
'\u{c}' => "f",
'\n' => "n",
'\r' => "r",
'\t' => "t",
_ => return None,
})
}
/// `StringTag.quoteAndEscape`.
pub fn quote_and_escape(input: &str) -> String {
let mut out = String::from(" ");
let mut quote = '\0';
for c in input.chars() {
if c == '\\' {
out.push_str("\\\\");
} else if c != '"' && c != '\'' {
match escape_control(c) {
Some(e) => {
out.push('\\');
out.push_str(e);
}
None => out.push(c),
}
} else {
if quote == '\0' {
quote = if c == '"' { '\'' } else { '"' };
}
if quote == c {
out.push('\\');
}
out.push(c);
}
}
if quote == '\0' {
quote = '"';
}
out.replace_range(0..1, &quote.to_string());
out.push(quote);
out
}
fn unquoted_key(key: &str) -> bool {
if key.eq_ignore_ascii_case("true") || key.eq_ignore_ascii_case("false") {
return false;
}
let mut chars = key.chars();
let first_ok = chars
.next()
.is_some_and(|c| c.is_ascii_alphabetic() || c == '.' || c == '_');
// `[A-Za-z._]+[A-Za-z0-9._+-]*`
first_ok && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '+' | '-'))
}
fn string_of(t: &horizon_world::nbt::JavaString) -> String {
t.to_string_lossy()
}
/// `Tag.toString` (StringTagVisitor).
pub fn to_snbt(tag: &Tag) -> String {
let mut out = String::new();
visit(tag, &mut out);
out
}
fn visit(tag: &Tag, out: &mut String) {
match tag {
Tag::End => out.push_str("END"),
Tag::Byte(v) => out.push_str(&format!("{v}b")),
Tag::Short(v) => out.push_str(&format!("{v}s")),
Tag::Int(v) => out.push_str(&v.to_string()),
Tag::Long(v) => out.push_str(&format!("{v}L")),
Tag::Float(v) => {
out.push_str(&java_float(f32::from_bits(*v)));
out.push('f');
}
Tag::Double(v) => {
out.push_str(&java_double(f64::from_bits(*v)));
out.push('d');
}
Tag::String(s) => out.push_str(&quote_and_escape(&string_of(s))),
Tag::ByteArray(a) => {
out.push_str("[B;");
out.push_str(
&a.iter()
.map(|v| format!("{}B", *v as i8))
.collect::<Vec<_>>()
.join(","),
);
out.push(']');
}
Tag::IntArray(a) => {
out.push_str("[I;");
out.push_str(&a.iter().map(i32::to_string).collect::<Vec<_>>().join(","));
out.push(']');
}
Tag::LongArray(a) => {
out.push_str("[L;");
out.push_str(
&a.iter()
.map(|v| format!("{v}L"))
.collect::<Vec<_>>()
.join(","),
);
out.push(']');
}
Tag::List(l) => {
out.push('[');
for (i, t) in l.iter().enumerate() {
if i > 0 {
out.push(',');
}
visit(t, out);
}
out.push(']');
}
Tag::Compound(c) => {
out.push('{');
let mut entries: Vec<(String, &Tag)> =
c.iter().map(|(k, v)| (string_of(k), v)).collect();
entries.sort_by(|a, b| a.0.cmp(&b.0));
for (i, (k, v)) in entries.iter().enumerate() {
if i > 0 {
out.push(',');
}
if unquoted_key(k) {
out.push_str(k);
} else {
out.push_str(&quote_and_escape(k));
}
out.push(':');
visit(v, out);
}
out.push('}');
}
}
}
use horizon_world::snbt_text::string_of;
pub use horizon_world::snbt_text::{java_double, java_float, quote_and_escape, to_snbt};
// ---- the coloured form -----------------------------------------------------
+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()));
@@ -567,6 +567,34 @@ fn length(v: [f32; 2]) -> f32 {
(v[0] * v[0] + v[1] * v[1]).sqrt()
}
/// 8BitDo Ultimate 2 Wireless (`2dc8:6012`). gilrs' bundled SDL DB only has a
/// Windows row for it; on Linux/Bluetooth the right stick is on ABS_Z/ABS_RX
/// (`LeftZ`/`RightStickX`) until an SDL mapping matches.
pub const BITDO_ULTIMATE2_VID_PID: (u16, u16) = (0x2dc8, 0x6012);
/// Stick axes after correcting pads whose right stick gilrs still exposes on
/// `LeftZ`/`RightStickX` (no matching SDL row for this device version).
/// `sdl_mapped` is true when gilrs already applied an SDL gamecontroller row.
pub fn stick_axes(
left: [f32; 2],
right: [f32; 2],
left_z: f32,
vendor: Option<u16>,
product: Option<u16>,
sdl_mapped: bool,
) -> ([f32; 2], [f32; 2]) {
if !sdl_mapped
&& vendor == Some(BITDO_ULTIMATE2_VID_PID.0)
&& product == Some(BITDO_ULTIMATE2_VID_PID.1)
{
// Default map: RightStickX is the stick's Y, LeftZ its X; RightStickY
// is the right trigger and must not drive look.
(left, [left_z, right[0]])
} else {
(left, right)
}
}
/// A radial deadzone: inside it the stick is centred; beyond it the rest of
/// the travel is rescaled to 0..1 in the same direction, so diagonals aren't
/// clipped and the first movement past the deadzone is small.
@@ -1178,6 +1206,38 @@ mod tests {
assert_eq!(odd, PadSettings::default());
}
#[test]
fn ultimate2_right_stick_on_z_without_sdl() {
let (left, right) = stick_axes(
[0.2, 0.3],
[0.7, -0.9],
-0.5,
Some(BITDO_ULTIMATE2_VID_PID.0),
Some(BITDO_ULTIMATE2_VID_PID.1),
false,
);
assert_eq!(left, [0.2, 0.3]);
assert_eq!(right, [-0.5, 0.7], "X from LeftZ, Y from RightStickX");
let kept = stick_axes(
[0.2, 0.3],
[0.7, -0.9],
-0.5,
Some(BITDO_ULTIMATE2_VID_PID.0),
Some(BITDO_ULTIMATE2_VID_PID.1),
true,
);
assert_eq!(kept, ([0.2, 0.3], [0.7, -0.9]), "SDL row already remapped");
let other = stick_axes(
[0.0, 0.0],
[1.0, 0.0],
0.5,
Some(0x045e),
Some(0x028e),
false,
);
assert_eq!(other, ([0.0, 0.0], [1.0, 0.0]));
}
#[test]
fn radial_deadzone_rescales() {
assert_eq!(radial([0.1, 0.05], 0.15), [0.0, 0.0]);
+45
View File
@@ -2,6 +2,9 @@
//! Item-use and sneak multipliers come from the player's current state; this
//! module does not implement sprint eligibility, poses or the input lifecycle.
use std::collections::HashSet;
use winit::keyboard::{KeyCode, ModifiersState};
fn length(v: [f32; 2]) -> f32 {
f64::from(v[0] * v[0] + v[1] * v[1]).sqrt() as f32
}
@@ -65,3 +68,45 @@ fn square_movement(v: [f32; 2]) -> [f32; 2] {
},
)
}
/// `InputQuirks.isQuitShortcutDown`: checks whether the platform's window-closing
/// keyboard shortcut is held.
/// - On macOS: Command (`super_key()`) + `Q`.
/// - On Windows / Linux / other: Alt (`alt_key()`) + `F4`.
pub fn is_quit_shortcut_down(modifiers: ModifiersState, keys: &HashSet<KeyCode>) -> bool {
if cfg!(target_os = "macos") {
modifiers.super_key() && keys.contains(&KeyCode::KeyQ)
} else {
modifiers.alt_key() && keys.contains(&KeyCode::F4)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn quit_shortcut_follows_platform_modifiers_and_keys() {
let mut keys = HashSet::new();
let alt = ModifiersState::ALT;
let super_key = ModifiersState::SUPER;
assert!(!is_quit_shortcut_down(ModifiersState::empty(), &keys));
keys.insert(KeyCode::F4);
if cfg!(target_os = "macos") {
assert!(!is_quit_shortcut_down(alt, &keys));
keys.remove(&KeyCode::F4);
keys.insert(KeyCode::KeyQ);
assert!(is_quit_shortcut_down(super_key, &keys));
assert!(!is_quit_shortcut_down(alt, &keys));
} else {
assert!(is_quit_shortcut_down(alt, &keys));
assert!(!is_quit_shortcut_down(super_key, &keys));
keys.remove(&KeyCode::F4);
keys.insert(KeyCode::KeyQ);
assert!(!is_quit_shortcut_down(alt, &keys));
assert!(!is_quit_shortcut_down(super_key, &keys));
}
}
}
@@ -13,6 +13,12 @@ pub enum Button {
}
pub trait InteractionWorld {
/// Minecraft.pick before handleKeybinds: the tick's `hitResult`, which
/// every click of the tick reads, even after one of them changed the
/// block it names (a creative break, then a use that replaces it).
fn pick_hit_result(&mut self);
/// The tick's clicks are done: later picks follow the crosshair again.
fn release_hit_result(&mut self);
fn using_item(&self) -> bool;
fn release_using(&mut self);
fn start_attack(&mut self) -> bool;
@@ -48,6 +54,7 @@ impl InteractionInput {
pub fn tick(&mut self, world: &mut impl InteractionWorld, attack_down: bool, use_down: bool) {
let [attack, use_item, pick] = std::mem::take(&mut self.clicks);
world.pick_hit_result();
let mut instant = false;
if world.using_item() {
if !use_down {
@@ -70,6 +77,7 @@ impl InteractionInput {
world.start_use();
}
world.continue_attack(!instant && attack_down);
world.release_hit_result();
}
}
@@ -86,6 +94,8 @@ mod tests {
events: Vec<&'static str>,
}
impl InteractionWorld for World {
fn pick_hit_result(&mut self) {}
fn release_hit_result(&mut self) {}
fn using_item(&self) -> bool {
self.using
}
@@ -183,4 +193,67 @@ mod tests {
input.tick(&mut world, false, false);
assert_eq!(world.events, ["release", "continue"]);
}
/// A floating block in front of the crosshair, nothing behind it, and a
/// block item in the hand of a creative player.
#[derive(Default)]
struct Column {
block: bool,
hit: Option<Option<()>>,
placed: bool,
}
impl Column {
/// Minecraft.hitResult: the tick's pick, or a fresh one outside it.
fn target(&self) -> Option<()> {
self.hit.unwrap_or(self.block.then_some(()))
}
}
impl InteractionWorld for Column {
fn pick_hit_result(&mut self) {
self.hit = Some(self.target());
}
fn release_hit_result(&mut self) {
self.hit = None;
}
fn using_item(&self) -> bool {
false
}
fn release_using(&mut self) {}
fn start_attack(&mut self) -> bool {
// MultiPlayerGameMode.startDestroyBlock in creative: the
// predicted break leaves air.
if self.target().is_some() {
self.block = false;
}
!self.block
}
fn start_use(&mut self) {
// BlockItem.place: the clicked block, now air, can be replaced.
if self.target().is_some() && !self.block {
self.block = true;
self.placed = true;
}
}
fn pick(&mut self) {}
fn use_delay(&self) -> u32 {
0
}
fn continue_attack(&mut self, _: bool) {}
}
#[test]
fn attack_and_use_in_one_tick_share_the_hit_result() {
// #3006: both buttons at once replace the block the crosshair is on.
let mut input = InteractionInput::default();
let mut world = Column {
block: true,
..Default::default()
};
input.press(Button::Attack);
input.press(Button::Use);
input.tick(&mut world, false, false);
assert!(world.placed, "the use acts on the block the attack broke");
assert!(world.block);
assert!(world.hit.is_none(), "the next pick follows the crosshair");
}
}
@@ -284,6 +284,26 @@ pub fn mouse_button(name: &str) -> Option<MouseButton> {
}
pub use horizon_client_core::key_display::display_name;
/// A key event: what the layout writes on its key, without modifiers, names
/// that key from now on (`key_display::learn_layout`), so an AZERTY player's
/// Q key shows A.
pub fn learn_layout(event: &winit::event::KeyEvent) {
use winit::keyboard::{Key, PhysicalKey};
use winit::platform::modifier_supplement::KeyEventExtModifierSupplement;
let PhysicalKey::Code(code) = event.physical_key else {
return;
};
let Some(name) = key_name(code) else {
return;
};
let learn = horizon_client_core::key_display::learn_layout;
match event.key_without_modifiers() {
Key::Character(text) => learn(name, &text),
Key::Dead(Some(c)) => learn(name, c.encode_utf8(&mut [0; 4])),
_ => {}
}
}
/// Current bindings by mapping name.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct KeyBinds {
+89 -4
View File
@@ -10,6 +10,10 @@ pub struct KeyState {
released_by_screen_when_down: bool,
toggle: Option<bool>,
keyboard: bool,
/// Held down by a controller's toggle (Horizon's pad toggle-sneak, which
/// vanilla has no option for): until it lets go, the mapping toggles as
/// if its vanilla option were on, so a screen releases and restores it.
pad_toggled: bool,
}
impl KeyState {
@@ -20,6 +24,7 @@ impl KeyState {
released_by_screen_when_down: false,
toggle: None,
keyboard: true,
pad_toggled: false,
}
}
/// A `ToggleKeyMapping`; `keyboard` is whether the bound key is a keyboard key.
@@ -43,16 +48,28 @@ impl KeyState {
true
}
}
/// The mapping toggles: its option is on, or a pad toggled it down.
fn needs_toggle(&self, option: bool) -> bool {
option || self.pad_toggled
}
/// `setDown`; `needs_toggle` is the mapping's option value.
pub fn set_down(&mut self, down: bool, needs_toggle: bool) {
if self.toggle.is_some() && needs_toggle {
if self.toggle.is_some() && self.needs_toggle(needs_toggle) {
if down {
self.is_down = !self.is_down;
// Toggled back up: the pad's toggle is over.
self.pad_toggled &= self.is_down;
}
} else {
self.is_down = down;
}
}
/// A controller's toggle button: flips the mapping like `setDown(true)`
/// with its option on, whatever the option says.
pub fn pad_toggle(&mut self) {
self.is_down = !self.is_down;
self.pad_toggled = self.is_down;
}
/// `shouldSetOnIngameFocus` for a keyboard-bound, bound key.
pub fn should_set_on_ingame_focus(&self, needs_toggle: bool) -> bool {
self.keyboard && (self.toggle.is_none() || !needs_toggle)
@@ -61,7 +78,8 @@ impl KeyState {
pub fn release(&mut self, needs_toggle: bool) {
if self.toggle.is_some() {
// ToggleKeyMapping.release does not clear pending clicks.
if needs_toggle && self.is_down || self.released_by_screen_when_down {
if self.needs_toggle(needs_toggle) && self.is_down || self.released_by_screen_when_down
{
self.released_by_screen_when_down = true;
}
self.reset();
@@ -70,7 +88,8 @@ impl KeyState {
self.set_down(false, needs_toggle);
}
}
/// `ToggleKeyMapping.reset`.
/// `ToggleKeyMapping.reset`. A pad toggle stays on record, so the screen
/// that released it restores it.
pub fn reset(&mut self) {
self.is_down = false;
}
@@ -83,7 +102,10 @@ impl KeyState {
let Some(restore) = self.toggle else {
return false;
};
let result = restore && needs_toggle && self.keyboard && self.released_by_screen_when_down;
let result = restore
&& self.needs_toggle(needs_toggle)
&& self.keyboard
&& self.released_by_screen_when_down;
self.released_by_screen_when_down = false;
result
}
@@ -138,6 +160,15 @@ impl ToggleKeys {
_ => {}
}
}
/// A controller's toggle button bound to `slot` (the pad's toggle-sneak):
/// a press with no screen open flips the mapping and clicks it, as a
/// key press does with the mapping's toggle option on.
pub fn pad_toggle(&mut self, slot: usize, screen_open: bool) {
if !screen_open {
self.0[slot].pad_toggle();
self.0[slot].click();
}
}
/// `MouseHandler.onButton` for a button bound to `slot`: only without a
/// screen or overlay.
pub fn button(&mut self, slot: usize, pressed: bool, screen_open: bool, needs_toggle: bool) {
@@ -167,6 +198,9 @@ impl ToggleKeys {
pub fn respawn(&mut self) {
for key in &mut self.0 {
key.reset();
// As with the vanilla option, the death screen closing after
// this still restores a toggle it released.
key.pad_toggled &= key.released_by_screen_when_down;
}
}
}
@@ -197,6 +231,57 @@ mod tests {
keys.respawn();
assert!(!keys.is_down(SPRINT));
}
/// #3061: the pad's toggle-sneak (vanilla's toggle crouch off) comes
/// back after a screen, as the vanilla option's toggle does; a
/// keyboard shift-click in the inventory does not drop it.
#[test]
fn pad_toggle_is_restored_after_a_screen() {
let off = [false; 4];
let mut keys = ToggleKeys::default();
keys.pad_toggle(SNEAK, false);
assert!(keys.is_down(SNEAK));
keys.screen_opened(off);
assert!(!keys.is_down(SNEAK));
keys.key(SNEAK, KeyAction::Press, true, false);
keys.key(SNEAK, KeyAction::Release, true, false);
keys.screen_closed(off);
assert!(keys.is_down(SNEAK), "the pad's toggle is restored");
// Toggled back up, it stays up through the next screen.
keys.pad_toggle(SNEAK, false);
assert!(!keys.is_down(SNEAK));
keys.screen_opened(off);
keys.screen_closed(off);
assert!(!keys.is_down(SNEAK));
// A held key is not restored, as before.
keys.key(SNEAK, KeyAction::Press, false, false);
keys.screen_opened(off);
keys.key(SNEAK, KeyAction::Release, true, false);
keys.screen_closed(off);
assert!(!keys.is_down(SNEAK));
}
/// After the pad's toggle is over (toggled up, or reset by a respawn
/// with no screen to restore it), the shift key holds again.
#[test]
fn pad_toggle_ends_with_its_release() {
let off = [false; 4];
let mut keys = ToggleKeys::default();
keys.pad_toggle(SNEAK, false);
keys.respawn();
assert!(!keys.is_down(SNEAK));
keys.key(SNEAK, KeyAction::Press, false, false);
keys.key(SNEAK, KeyAction::Release, false, false);
assert!(!keys.is_down(SNEAK), "held, not toggled");
// The death screen releases it and closes after the respawn: the
// toggle comes back, as with the vanilla option.
keys.pad_toggle(SNEAK, false);
keys.screen_opened(off);
keys.respawn();
keys.screen_closed(off);
assert!(keys.is_down(SNEAK));
// A press with a screen open does nothing.
keys.pad_toggle(SNEAK, true);
assert!(keys.is_down(SNEAK));
}
#[test]
fn screen_release_restores_toggled_state() {
let mut k = KeyState::toggle(true, true);
+39
View File
@@ -342,6 +342,28 @@ pub fn mouse_position_after_grab_change(
}
}
/// `MacosUtil.setCtrlClickEmulatesRightClick`: with the option on (and only on
/// macOS), a left click made with Control held is a right click, and so is
/// its release, whatever Control does in between.
#[derive(Clone, Copy, Debug, Default)]
pub struct RightClickEmulation {
emulating: bool,
}
impl RightClickEmulation {
/// Whether the left button's `pressed` or released event is a right click.
/// `enabled` is the option on macOS.
pub fn is_right_click(&mut self, pressed: bool, control: bool, enabled: bool) -> bool {
if pressed {
self.emulating = enabled && control;
}
let right = self.emulating;
if !pressed {
self.emulating = false;
}
right
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -526,4 +548,21 @@ mod tests {
None
);
}
#[test]
fn control_click_is_a_right_click_until_released() {
let mut e = RightClickEmulation::default();
// The option off, or no Control: a left click stays one.
assert!(!e.is_right_click(true, true, false));
assert!(!e.is_right_click(false, true, false));
assert!(!e.is_right_click(true, false, true));
assert!(!e.is_right_click(false, true, true));
// Control held at the press: the press and the release are right clicks,
// even when Control was let go before the release.
assert!(e.is_right_click(true, true, true));
assert!(e.is_right_click(false, false, true));
// The next left click is its own.
assert!(!e.is_right_click(true, false, true));
assert!(!e.is_right_click(false, false, true));
}
}
+251 -1
View File
@@ -284,12 +284,18 @@ impl FramerateLimiter {
/// (`Window.updateFullscreenState` -> `Minecraft.fullscreenStateChanged`), and
/// the window is only asked to change when the option itself changes
/// (`Window.updateFullscreenIfChanged`), never re-forced to the option.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct FullscreenState {
/// The state the window system last reported (`Window.fullscreen`).
actual: bool,
/// The state last asked of the window system, or adopted from it.
requested: bool,
/// `Window.exclusiveFullscreen`.
exclusive: bool,
/// `Window.preferredFullscreenVideoMode`.
mode: Option<VideoMode>,
/// `Window.dirty`: the preferred mode changed and the screen has not been left yet.
mode_dirty: bool,
}
impl FullscreenState {
@@ -298,9 +304,70 @@ impl FullscreenState {
Self {
actual: false,
requested,
exclusive: false,
mode: None,
mode_dirty: false,
}
}
/// `Window.updateFullscreenIfChanged` with the exclusive option and preferred mode: asks the
/// window to enter, leave or change its fullscreen only when one of them changed.
/// `in_video_screen` holds a changed mode back until the Video Settings screen closes
/// (`changeFullscreenVideoMode`).
pub fn apply(
&mut self,
window: &winit::window::Window,
wanted: bool,
exclusive: bool,
preferred: Option<VideoMode>,
in_video_screen: bool,
) {
let request = self.request(wanted);
let reapply = self.set_exclusive(exclusive);
self.set_preferred_mode(preferred);
let mode_change = !in_video_screen && self.take_mode_change();
match request {
Some(false) => window.set_fullscreen(None),
Some(true) => window.set_fullscreen(Some(fullscreen_request(
window,
exclusive,
preferred.as_ref(),
))),
None if reapply || mode_change => window.set_fullscreen(Some(fullscreen_request(
window,
exclusive,
preferred.as_ref(),
))),
None => {}
}
}
/// `Window.setExclusiveFullscreen`: true when the option changed while fullscreen is
/// requested, so the window has to enter the other kind of fullscreen at once.
pub fn set_exclusive(&mut self, exclusive: bool) -> bool {
let changed = self.exclusive != exclusive;
self.exclusive = exclusive;
changed && self.requested
}
/// `Window.setPreferredFullscreenVideoMode`: a changed mode waits for `take_mode_change`.
pub fn set_preferred_mode(&mut self, mode: Option<VideoMode>) {
if self.mode != mode {
self.mode = mode;
self.mode_dirty = true;
}
}
/// `Window.changeFullscreenVideoMode` (the Video Settings screen closing): true once when
/// a changed mode has to be applied to a fullscreen window.
pub fn take_mode_change(&mut self) -> bool {
let apply = self.requested && self.mode_dirty;
if apply {
self.mode_dirty = false;
}
apply
}
/// `Window.updateFullscreenIfChanged`: the state to ask of the window
/// when the option `wanted` differs from what was last asked or adopted.
pub fn request(&mut self, wanted: bool) -> Option<bool> {
@@ -321,6 +388,132 @@ impl FullscreenState {
}
}
/// `VideoMode`: one display mode of a monitor, as `fullscreenResolution` in options.txt keeps it.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct VideoMode {
pub width: i32,
pub height: i32,
pub red_bits: i32,
pub green_bits: i32,
pub blue_bits: i32,
pub refresh_rate: f32,
}
impl VideoMode {
/// `VideoMode.read`: the JSON object `write` makes; any other string is None.
pub fn read(text: &str) -> Option<Self> {
let number = |key: &str| -> Option<f64> {
let at = text.find(&format!("\"{key}\""))? + key.len() + 2;
let rest = text[at..].trim_start().strip_prefix(':')?.trim_start();
let end = rest
.find(|c: char| !(c.is_ascii_digit() || matches!(c, '-' | '+' | '.' | 'e' | 'E')))
.unwrap_or(rest.len());
rest[..end].parse().ok()
};
if !text.trim_start().starts_with('{') {
return None;
}
Some(Self {
width: number("width")? as i32,
height: number("height")? as i32,
red_bits: number("red_bits")? as i32,
green_bits: number("green_bits")? as i32,
blue_bits: number("blue_bits")? as i32,
refresh_rate: number("refresh_rate")? as f32,
})
}
/// `VideoMode.write`: the Codec's JSON through Gson (a float prints as `60.0`).
pub fn write(&self) -> String {
let rate = if self.refresh_rate == self.refresh_rate.trunc() {
format!("{:.1}", self.refresh_rate)
} else {
self.refresh_rate.to_string()
};
format!(
"{{\"width\":{},\"height\":{},\"red_bits\":{},\"green_bits\":{},\"blue_bits\":{},\"refresh_rate\":{rate}}}",
self.width, self.height, self.red_bits, self.green_bits, self.blue_bits
)
}
/// `VideoMode.refreshRateLabel`: a whole rate without decimals, any other with two.
pub fn refresh_rate_label(&self) -> String {
if self.refresh_rate == self.refresh_rate.round_ties_even() {
(self.refresh_rate as i32).to_string()
} else {
format!("{:.2}", self.refresh_rate)
}
}
/// The colour depth `options.fullscreen.entry` shows.
pub fn bits(&self) -> i32 {
self.red_bits + self.green_bits + self.blue_bits
}
/// A winit mode: its depth is the total, split evenly over the channels.
pub fn from_winit(mode: &winit::monitor::VideoModeHandle) -> Self {
let depth = i32::from(mode.bit_depth());
let size = mode.size();
Self {
width: size.width as i32,
height: size.height as i32,
red_bits: depth / 3,
green_bits: depth / 3,
blue_bits: depth - 2 * (depth / 3),
refresh_rate: mode.refresh_rate_millihertz() as f32 / 1000.0,
}
}
}
/// `Monitor.videoModes`: the modes a monitor offers.
pub fn monitor_video_modes(monitor: &winit::monitor::MonitorHandle) -> Vec<VideoMode> {
monitor
.video_modes()
.map(|m| VideoMode::from_winit(&m))
.collect()
}
/// `Monitor.currentMode`: the monitor's desktop mode; winit has no such query, so the mode of
/// the monitor's own size with the highest refresh rate and depth.
fn monitor_current_mode(
monitor: &winit::monitor::MonitorHandle,
) -> Option<winit::monitor::VideoModeHandle> {
let size = monitor.size();
monitor
.video_modes()
.filter(|m| m.size() == size)
.max_by_key(|m| (m.refresh_rate_millihertz(), m.bit_depth()))
}
/// `Window.applyFullscreen`: the fullscreen the window asks for. Exclusive fullscreen takes
/// `Monitor.getPreferredVideoMode` (the preferred mode if the monitor has it, else the desktop
/// mode); a monitor with no usable mode falls back to borderless, as vanilla does.
pub fn fullscreen_request(
window: &winit::window::Window,
exclusive: bool,
preferred: Option<&VideoMode>,
) -> winit::window::Fullscreen {
use winit::window::Fullscreen;
if !exclusive {
return Fullscreen::Borderless(None);
}
let Some(monitor) = window
.current_monitor()
.or_else(|| window.primary_monitor())
else {
return Fullscreen::Borderless(None);
};
let wanted = preferred.and_then(|p| {
monitor
.video_modes()
.find(|m| VideoMode::from_winit(m) == *p)
});
match wanted.or_else(|| monitor_current_mode(&monitor)) {
Some(mode) => Fullscreen::Exclusive(mode),
None => Fullscreen::Borderless(None),
}
}
/// Horizon's icon for the window's title bar and the task bar (X11 and Windows; Wayland takes
/// it from the `horizon.desktop` entry, macOS from the app bundle). `None` if it can't be read.
pub fn icon() -> Option<winit::window::Icon> {
@@ -340,11 +533,63 @@ pub fn icon() -> Option<winit::window::Icon> {
mod tests {
use super::*;
#[test]
fn video_mode_round_trips_the_options_json() {
let mode = VideoMode {
width: 1920,
height: 1080,
red_bits: 8,
green_bits: 8,
blue_bits: 8,
refresh_rate: 60.0,
};
let text = mode.write();
assert_eq!(
text,
r#"{"width":1920,"height":1080,"red_bits":8,"green_bits":8,"blue_bits":8,"refresh_rate":60.0}"#
);
assert_eq!(VideoMode::read(&text), Some(mode));
assert_eq!(VideoMode::read("1920x1080"), None);
assert_eq!(VideoMode::read(r#"{"width":1}"#), None);
}
#[test]
fn refresh_rate_label_is_whole_or_two_decimals() {
let mut mode = VideoMode::read(
r#"{"width":1,"height":1,"red_bits":8,"green_bits":8,"blue_bits":8,"refresh_rate":144.0}"#,
)
.unwrap();
assert_eq!(mode.refresh_rate_label(), "144");
mode.refresh_rate = 59.94;
assert_eq!(mode.refresh_rate_label(), "59.94");
assert_eq!(mode.bits(), 24);
}
#[test]
fn the_window_icon_decodes() {
assert!(icon().is_some());
}
#[test]
fn exclusive_fullscreen_reapplies_only_while_fullscreen_is_requested() {
let mut f = FullscreenState::new(false);
assert!(!f.set_exclusive(true));
assert!(!f.set_exclusive(true));
assert_eq!(f.request(true), Some(true));
assert!(f.set_exclusive(false));
assert!(!f.set_exclusive(false));
}
#[test]
fn a_changed_video_mode_applies_once_when_fullscreen() {
let mode = VideoMode::read(
r#"{"width":1,"height":1,"red_bits":8,"green_bits":8,"blue_bits":8,"refresh_rate":60.0}"#,
);
let mut f = FullscreenState::new(false);
f.set_preferred_mode(mode);
assert!(!f.take_mode_change());
f.request(true);
f.set_preferred_mode(mode);
assert!(f.take_mode_change());
assert!(!f.take_mode_change());
}
#[test]
fn fullscreen_follows_the_option_only_when_it_changes() {
let mut f = FullscreenState::new(false);
@@ -428,6 +673,11 @@ mod tests {
vsync_present_mode(&[PresentMode::Fifo], false),
Some(PresentMode::Fifo)
);
// Regression guard: GLES capabilities report [Fifo, Immediate] without Mailbox.
assert_eq!(
vsync_present_mode(&[PresentMode::Fifo, PresentMode::Immediate], false),
Some(PresentMode::Immediate)
);
}
#[test]
fn vsync_on_waits_for_the_display_whatever_the_present_mode() {
+3 -1
View File
@@ -23,6 +23,7 @@ horizon-theme = { path = "../horizon-theme" }
horizon-threads = { path = "../horizon-threads" }
horizon-world = { path = "../horizon-world" }
ab_glyph = "0.2.32"
base64 = "0.22"
bytemuck = { version = "1.24", features = ["derive"] }
# Sound (ALSA on Linux); the `audio` feature.
cpal = { version = "0.16", optional = true }
@@ -31,11 +32,12 @@ flate2 = { version = "1.1.10", default-features = false, features = ["zlib-rs",
glam = "0.30"
log = "0.4"
miniz_oxide = "0.8"
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out"] }
naga = { version = "30.0.1", features = ["wgsl-in", "glsl-in", "hlsl-out"] }
png = "0.18"
pollster = "0.4"
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1.0.151", features = ["raw_value"] }
sha1 = "0.10"
sha2 = "0.10.9"
symphonia = { version = "0.5", default-features = false, features = ["ogg", "vorbis"] }
unicode-bidi = "0.3.18"
+28 -4
View File
@@ -192,16 +192,34 @@ impl Assets {
pub fn reload(&self, root: &Path) -> Result<RenderAssets> {
RenderAssets::load(root, self)
}
/// [`Assets::reload`] for a server, which draws nothing: the sprites'
/// layers, without the block or item models or the textures' pixels.
pub fn reload_for_server(&self, root: &Path) -> Result<RenderAssets> {
RenderAssets::load_with(root, self, false)
}
}
impl RenderAssets {
pub fn load(root: &Path, assets: &Assets) -> Result<Self> {
Self::load_with(root, assets, true)
}
fn load_with(root: &Path, assets: &Assets, draws: bool) -> Result<Self> {
let registry = assets.registry;
let mut sprites = crate::sprites::Sprites::with_disk_cache(root);
for name in MATERIALS {
sprites.insert(root, &format!("minecraft:block/{name}"))?;
}
let parsed = crate::model_library::ParsedModels::load(root);
let models = crate::models::prepare_with_models(root, registry, &mut sprites, &parsed)?;
// A server draws no block and no item: its block models (about 24 MB
// baked) and item model library (7 MB) stay empty.
let parsed = if draws {
crate::model_library::ParsedModels::load(root)
} else {
crate::model_library::ParsedModels::default()
};
let models = if draws {
crate::models::prepare_with_models(root, registry, &mut sprites, &parsed)?
} else {
crate::models::PreparedModels::default()
};
let model_library = parsed.into_item_library();
// Fluids are drawn by the fluid renderer, not block models; breaking
// progress uses the destroy-stage overlays.
@@ -218,7 +236,11 @@ impl RenderAssets {
sprites.insert(root, &format!("minecraft:block/destroy_stage_{stage}"))?;
}
sprites.save_disk_cache();
sprites.finish();
if draws {
sprites.finish();
} else {
sprites.finish_without_pixels();
}
// `finish` grows the buffer by doubling: keep only the bytes in use.
sprites.pixels.shrink_to_fit();
Ok(Self {
@@ -241,6 +263,7 @@ impl RenderAssets {
#[cfg(test)]
mod tests {
use crate::items::ConsumeEffect;
use horizon_world::loot::PackOverlay;
#[test]
fn always_edible_and_consume_effects_loaded() {
@@ -251,7 +274,8 @@ mod tests {
}
let assets = crate::assets::Assets::load(&root).unwrap();
let facts = crate::blocks::BlockFacts::load(&root, assets.registry).unwrap();
let items = crate::items::Items::load(&root, assets.registry, &facts).unwrap();
let items =
crate::items::Items::load(&root, assets.registry, &facts, &PackOverlay::NONE).unwrap();
let apple = items.get("minecraft:golden_apple").expect("golden apple");
assert!(apple.can_always_eat);
+146 -9
View File
@@ -47,6 +47,12 @@ struct Mixer {
listener_yaw: f32,
listener_pitch: f32,
limiter_peak: f32,
/// The output device the stream was opened on (`Library.currentDeviceName`).
#[cfg(feature = "audio")]
device_name: String,
/// The stream reported its device gone (`Library.isCurrentDeviceDisconnected`).
#[cfg(feature = "audio")]
device_lost: bool,
}
/// A sound the player could have heard (`SoundEventListener.onPlaySound`):
/// what the subtitle overlay needs.
@@ -165,6 +171,17 @@ pub struct Audio {
/// How many times each selection was stopped ("record *", "* minecraft:…", `*` for
/// any), with or without an audio device, as `heard`.
pub stopped: std::collections::BTreeMap<String, u32>,
/// The volume sliders' `SoundPreviewHandler`.
preview: crate::sound_preview::SoundPreview,
/// `SoundEngine.lastSeenDevices`: the system default output last seen.
#[cfg(feature = "audio")]
default_seen: Option<String>,
/// The other half of `lastSeenDevices`: every output device last seen.
#[cfg(feature = "audio")]
devices_seen: Vec<String>,
/// Ticks since the output devices were last looked at (`DeviceTracker.tick`).
#[cfg(feature = "audio")]
device_ticks: u32,
}
fn decode(path: &Path) -> Option<(Vec<f32>, u32)> {
use symphonia::core::{
@@ -504,19 +521,30 @@ fn open_device(
use cpal::SampleFormat as F;
let config = device.default_output_config()?;
let stream = match config.sample_format() {
F::F32 => build_stream::<f32>(device, &config, mixer),
F::F64 => build_stream::<f64>(device, &config, mixer),
F::I16 => build_stream::<i16>(device, &config, mixer),
F::I32 => build_stream::<i32>(device, &config, mixer),
F::I8 => build_stream::<i8>(device, &config, mixer),
F::U16 => build_stream::<u16>(device, &config, mixer),
F::U32 => build_stream::<u32>(device, &config, mixer),
F::U8 => build_stream::<u8>(device, &config, mixer),
F::F32 => build_stream::<f32>(device, &config, mixer.clone()),
F::F64 => build_stream::<f64>(device, &config, mixer.clone()),
F::I16 => build_stream::<i16>(device, &config, mixer.clone()),
F::I32 => build_stream::<i32>(device, &config, mixer.clone()),
F::I8 => build_stream::<i8>(device, &config, mixer.clone()),
F::U16 => build_stream::<u16>(device, &config, mixer.clone()),
F::U32 => build_stream::<u32>(device, &config, mixer.clone()),
F::U8 => build_stream::<u8>(device, &config, mixer.clone()),
other => Err(format!("unsupported sample format {other}").into()),
}?;
stream.play()?;
if let Ok(mut m) = mixer.lock() {
m.device_name = device.name().unwrap_or_default();
m.device_lost = false;
}
Ok((stream, config))
}
/// The name of the system's default output device.
#[cfg(feature = "audio")]
fn default_output_name() -> Option<String> {
cpal::default_host()
.default_output_device()
.and_then(|d| d.name().ok())
}
#[cfg(feature = "audio")]
fn build_stream<T: cpal::SizedSample + cpal::FromSample<f32>>(
device: &cpal::Device,
@@ -526,6 +554,7 @@ fn build_stream<T: cpal::SizedSample + cpal::FromSample<f32>>(
let rate = config.sample_rate().0;
let channels = config.channels() as usize;
let mut scratch = Vec::new();
let on_error = mixer.clone();
let stream = device.build_output_stream(
&config.config(),
move |data: &mut [T], _| {
@@ -539,7 +568,14 @@ fn build_stream<T: cpal::SizedSample + cpal::FromSample<f32>>(
*out = T::from_sample(*s);
}
},
|e| log::warn!("Horizon: audio stream error: {e}"),
move |e| {
log::warn!("Horizon: audio stream error: {e}");
if matches!(e, cpal::StreamError::DeviceNotAvailable)
&& let Ok(mut m) = on_error.lock()
{
m.device_lost = true;
}
},
None,
)?;
Ok(stream)
@@ -634,6 +670,13 @@ impl Audio {
capture: None,
heard: Default::default(),
stopped: Default::default(),
preview: Default::default(),
#[cfg(feature = "audio")]
default_seen: if live { default_output_name() } else { None },
#[cfg(feature = "audio")]
devices_seen: if live { output_devices() } else { Vec::new() },
#[cfg(feature = "audio")]
device_ticks: 0,
}
}
#[cfg(not(feature = "audio"))]
@@ -761,8 +804,64 @@ impl Audio {
}
self.engine.listening = self.record_heard;
}
/// `SoundEngine.shouldChangeDevice`: the open device was lost, the system
/// default changed (with no device chosen in the options), or the chosen
/// device came back; the output is then opened again. The devices are looked
/// at once a second.
#[cfg(feature = "audio")]
fn should_change_device(&mut self) -> bool {
let (current, lost) = match self.backend.mixer.lock() {
Ok(mut m) => (m.device_name.clone(), std::mem::take(&mut m.device_lost)),
Err(_) => return false,
};
if lost {
log::info!("Horizon: audio device was lost");
self.default_seen = default_output_name();
self.devices_seen = output_devices();
return true;
}
self.device_ticks += 1;
if self._stream.is_none() || self.device_ticks < 20 {
return false;
}
self.device_ticks = 0;
let default = default_output_name();
let devices = output_devices();
// Only a change in the device lists is looked at, as in vanilla: a
// preferred device that is listed but cannot open is not retried.
if default == self.default_seen && devices == self.devices_seen {
return false;
}
self.default_seen = default.clone();
let wanted = self.device_name.clone();
let available = devices.contains(&wanted);
self.devices_seen = devices;
if wanted.is_empty() {
let changed = default.as_deref().is_some_and(|d| d != current);
if changed {
log::info!("Horizon: system default audio device has changed");
}
return changed;
}
let changed = current != wanted && available;
if changed {
log::info!("Horizon: preferred audio device has become available");
}
changed
}
/// `SoundManager.tick(paused)`, once per client tick.
pub fn tick(&mut self, paused: bool, ctx: &TickContext) {
#[cfg(feature = "audio")]
{
if self.should_change_device() {
// SoundEngine.reload: stop every sound, open the output again.
self.stop_all();
let device = self.device_name.clone();
if let Err(e) = self.set_device(&device) {
log::warn!("Horizon: could not reopen the audio output: {e}");
}
}
}
self.sync_volumes();
self.engine.tick(paused, ctx, &mut self.backend);
}
@@ -798,6 +897,37 @@ impl Audio {
let seed = self.random.next_long();
self.play_seeded(event, at, volume, pitch, seed);
}
/// `ClientLevel.playLocalSound` with `distanceDelay`: a sound more than 10
/// blocks from the camera starts `sqrt(distance) / 40` seconds later.
pub fn play_distance_delayed(
&mut self,
event: &str,
at: [f64; 3],
volume: f32,
pitch: f32,
camera: [f64; 3],
) {
let distance_sqr: f64 = (0..3).map(|i| (at[i] - camera[i]).powi(2)).sum();
if distance_sqr <= 100.0 {
self.play(event, Some(at), volume, pitch);
return;
}
let seed = self.random.next_long();
if let Some(capture) = self.capture.as_mut() {
capture.push((event.to_owned(), at, volume, pitch));
}
*self.heard.entry(event.to_owned()).or_default() += 1;
self.sync_volumes();
let event = crate::sound_events::normalize(event);
let source = self
.source_override
.unwrap_or_else(|| crate::settings::sound_source(&event));
let instance =
SoundInstance::at(&event, source, volume, pitch, LegacyRandom::new(seed), at);
let id = self.engine.add(instance, false);
self.engine
.play_delayed(id, (distance_sqr.sqrt() / 40.0 * 20.0) as i32);
}
/// `play` for this client only: a sound a packet's handler plays
/// (`playLocalSound`), which the guests play from that packet themselves.
pub fn play_unshared(&mut self, event: &str, at: Option<[f64; 3]>, volume: f32, pitch: f32) {
@@ -817,6 +947,13 @@ impl Audio {
false,
);
}
/// `SoundPreviewHandler.preview`: a category volume slider moved to
/// `volume` on the title screen's options.
pub fn preview(&mut self, source: usize, volume: f32) {
let mut preview = std::mem::take(&mut self.preview);
preview.preview(self, source, volume);
self.preview = preview;
}
/// `play` with the seed a sound packet carries.
pub fn play_seeded(
&mut self,
+46 -6
View File
@@ -52,20 +52,20 @@ impl AuthSession {
/// The session file's JSON: {"name", "uuid", "access_token", "user_type": "msa", "xuid"}.
pub fn parse(text: &str) -> Result<Self, String> {
let v: serde_json::Value =
serde_json::from_str(text).map_err(|_| "the session file is not valid JSON")?;
serde_json::from_str(text).map_err(|_| "the session is not valid JSON")?;
let field = |k: &str| v.get(k).and_then(|s| s.as_str());
let name = field("name").ok_or("the session file has no name")?;
let name = field("name").ok_or("the session has no name")?;
if name.is_empty() || name.chars().count() > 16 {
return Err("the session file's name is not a valid player name".into());
return Err("the session's name is not a valid player name".into());
}
let uuid = field("uuid")
.and_then(parse_uuid)
.ok_or("the session file has no valid uuid")?;
.ok_or("the session has no valid uuid")?;
let access_token = field("access_token")
.filter(|t| !t.is_empty())
.ok_or("the session file has no access token")?;
.ok_or("the session has no access token")?;
if field("user_type").is_some_and(|t| t != "msa") {
return Err("the session file is not a Microsoft account session".into());
return Err("the session is not a Microsoft account session".into());
}
Ok(Self {
name: name.to_owned(),
@@ -203,6 +203,46 @@ pub fn endpoint(doc: &serde_json::Value, service: &str, name: &str) -> Option<St
.map(str::to_owned)
}
/// `Minecraft`'s constructor futures for the title screen: `UserApiService.fetchProperties`
/// (the `player` service's `getAttributes`, with the bearer token) and
/// `SessionService.fetchProfile(uuid, true)` (`getProfileById`). A failed
/// attributes call falls back to the offline properties, a failed profile call
/// to no actions, as vanilla does. Blocks: call it off the main thread.
pub fn fetch_account_status(session: &AuthSession) -> crate::ban_notice::AccountStatus {
use crate::ban_notice::{AccountStatus, parse_profile_actions, parse_properties};
let Ok(doc) = discovery() else {
return AccountStatus::default();
};
let properties = endpoint(&doc, "player", "getAttributes")
.and_then(|url| {
agent()
.get(&url)
.set("Authorization", &format!("Bearer {}", session.access_token))
.call()
.ok()?
.into_string()
.ok()
})
.and_then(|body| parse_properties(&body))
.unwrap_or_default();
let actions = endpoint(&doc, "session", "getProfileById")
.and_then(|url| {
let url = url.replace("{profileId}", &format!("{:032x}", session.uuid));
agent()
.get(&format!("{url}?unsigned=true"))
.call()
.ok()?
.into_string()
.ok()
})
.map(|body| parse_profile_actions(&body))
.unwrap_or_default();
AccountStatus {
properties,
actions,
}
}
/// The JoinMinecraftServerRequest body; `selectedProfile` is the undashed UUID.
pub fn join_request(session: &AuthSession, server_hash: &str) -> String {
serde_json::json!({
@@ -0,0 +1,373 @@
//! The ban notices `Gui.addInitialScreens` queues before the title screen:
//! `Minecraft.multiplayerBan()` (the account's MULTIPLAYER ban from the services
//! attributes call), the forced name change and the banned skin
//! (`ProfileResult.actions()`), and the text `BanNoticeScreens` shows for each.
//! The network calls are in `auth.rs`; this file parses what they return.
use crate::text::Text;
/// `CommonLinks.SUSPENSION_HELP`.
pub const SUSPENSION_HELP: &str = "https://aka.ms/mcjavamoderation";
/// authlib's `BanDetails` (the id is never shown).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BanDetails {
/// Epoch milliseconds the ban ends; `None` is permanent.
pub expires_ms: Option<i64>,
pub reason: Option<String>,
pub reason_message: Option<String>,
}
/// authlib's `UserProperties`, as far as the title screen reads it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct UserProperties {
/// `UserFlag.SERVERS_ALLOWED` (`privileges.multiplayerServer.enabled`).
pub servers_allowed: bool,
/// `bannedScopes.get("MULTIPLAYER")`.
pub multiplayer_ban: Option<BanDetails>,
}
impl Default for UserProperties {
/// `UserApiService.OFFLINE_PROPERTIES`: what a failed fetch falls back to.
fn default() -> Self {
Self {
servers_allowed: true,
multiplayer_ban: None,
}
}
}
/// authlib's `ProfileResult.actions()`.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ProfileActions {
pub forced_name_change: bool,
pub using_banned_skin: bool,
}
/// What the title screen learns about the account.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct AccountStatus {
pub properties: UserProperties,
pub actions: ProfileActions,
}
impl AccountStatus {
/// `Minecraft.allowsMultiplayer` (without the `--disableMultiplayer` flag).
pub fn allows_multiplayer(&self) -> bool {
self.properties.servers_allowed
&& self.properties.multiplayer_ban.is_none()
&& !self.actions.forced_name_change
}
}
/// One of the screens `Gui.addInitialScreens` adds.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Notice {
/// `BanNoticeScreens.create`.
Multiplayer(BanDetails),
/// `BanNoticeScreens.createNameBan` with the profile's name.
Name(String),
/// `BanNoticeScreens.createSkinBan`.
Skin,
}
/// `Gui.addInitialScreens`' ban part, in its order: the multiplayer ban, the
/// name ban, the skin ban.
pub fn initial_notices(status: &AccountStatus, profile_name: &str) -> Vec<Notice> {
let mut notices = Vec::new();
if let Some(ban) = &status.properties.multiplayer_ban {
notices.push(Notice::Multiplayer(ban.clone()));
}
if status.actions.forced_name_change {
notices.push(Notice::Name(profile_name.to_owned()));
}
if status.actions.using_banned_skin {
notices.push(Notice::Skin);
}
notices
}
impl Notice {
/// The screen's title (bold).
pub fn title(&self) -> Text {
let key = match self {
Self::Multiplayer(ban) if ban.expires_ms.is_some() => "gui.banned.title.temporary",
Self::Multiplayer(_) => "gui.banned.title.permanent",
Self::Name(_) => "gui.banned.name.title",
Self::Skin => "gui.banned.skin.title",
};
Text::translate(key, &[]).bold()
}
/// The screen's message; `now_ms` is `Instant.now()`.
pub fn message(&self, now_ms: i64) -> Text {
let link = || Text::literal(SUSPENSION_HELP);
match self {
Self::Multiplayer(ban) => Text::translate(
"gui.banned.description",
&[reason_text(ban), status_text(ban, now_ms), link()],
),
Self::Name(name) => Text::translate(
"gui.banned.name.description",
&[Text::colored(name.clone(), YELLOW), link()],
),
Self::Skin => Text::translate("gui.banned.skin.description", &[link()]),
}
}
}
/// `ChatFormatting.YELLOW`.
const YELLOW: u32 = 0xFFFF55;
/// `BanReason.byId`: the translation key suffix.
fn ban_reason(id: i32) -> Option<&'static str> {
Some(match id {
2 => "false_reporting",
5 => "hate_speech",
16 | 25 => "hate_terrorism_notorious_figure",
17 | 19 | 23 | 31 => "generic_violation",
21 => "harassment_or_bullying",
27 => "defamation_impersonation_false_information",
28 => "drugs",
29 => "fraud",
30 => "spam_or_advertising",
32 => "nudity_or_pornography",
33 | 35 | 36 => "sexually_inappropriate",
34 => "extreme_violence_or_gore",
53 => "imminent_harm_to_person_or_property",
_ => return None,
})
}
/// `BanNoticeScreens.getBanReasonText`.
fn reason_text(ban: &BanDetails) -> Text {
let numeric = ban
.reason
.as_deref()
.filter(|r| !r.is_empty() && r.bytes().all(|b| b.is_ascii_digit()));
let Some(reason) = numeric else {
return Text::translate("gui.banned.description.unknownreason", &[]);
};
// Integer.parseInt: past i32 the vanilla screen would throw; show the code as is.
let id = reason.parse::<i32>().ok();
let text = if let Some(key) = id.and_then(ban_reason) {
Text::translate(&format!("gui.banned.reason.{key}"), &[]).bold()
} else if let Some(message) = &ban.reason_message {
Text::translate(
"gui.banned.description.reason_id_message",
&[Text::literal(reason), Text::literal(message.clone())],
)
.bold()
} else {
Text::translate("gui.banned.description.reason_id", &[Text::literal(reason)]).bold()
};
Text::translate("gui.banned.description.reason", &[text])
}
/// `BanNoticeScreens.getBanStatusText`.
fn status_text(ban: &BanDetails, now_ms: i64) -> Text {
match ban.expires_ms {
Some(expires) => {
let duration = Text::translate(
"gui.banned.description.temporary.duration",
&[duration_text(expires - now_ms)],
)
.bold();
Text::translate("gui.banned.description.temporary", &[duration])
}
None => Text::translate("gui.banned.description.permanent", &[]).bold(),
}
}
/// `BanNoticeScreens.getBanDurationText`: days past 72 hours, else hours,
/// else minutes (`Duration.toHours` and friends truncate toward zero).
fn duration_text(millis: i64) -> Text {
let count = |n: i64| Text::literal(n.to_string());
let hours = millis / 3_600_000;
if hours > 72 {
Text::translate("gui.days", &[count(millis / 86_400_000)])
} else if hours < 1 {
Text::translate("gui.minutes", &[count(millis / 60_000)])
} else {
Text::translate("gui.hours", &[count(hours)])
}
}
/// `Instant` as authlib's gson adapter reads it: an ISO-8601 string, or epoch milliseconds.
fn parse_instant(value: &serde_json::Value) -> Option<i64> {
match value {
serde_json::Value::Number(n) => n.as_i64(),
serde_json::Value::String(s) => chrono::DateTime::parse_from_rfc3339(s)
.ok()
.map(|t| t.timestamp_millis())
.or_else(|| s.parse().ok()),
_ => None,
}
}
/// `UserAttributesResponse` into `UserProperties` (`fetchProperties`). Bad JSON
/// is an error, which vanilla answers with the offline properties.
pub fn parse_properties(body: &str) -> Option<UserProperties> {
let v: serde_json::Value = serde_json::from_str(body).ok()?;
let servers_allowed = v
.pointer("/privileges/multiplayerServer/enabled")
.and_then(|e| e.as_bool())
.unwrap_or(false);
let multiplayer_ban = v
.pointer("/banStatus/bannedScopes/MULTIPLAYER")
.filter(|b| b.is_object())
.map(|b| {
let text = |k: &str| b.get(k).and_then(|s| s.as_str()).map(str::to_owned);
BanDetails {
expires_ms: b.get("expires").and_then(parse_instant),
reason: text("reason"),
reason_message: text("reasonMessage"),
}
});
Some(UserProperties {
servers_allowed,
multiplayer_ban,
})
}
/// `MinecraftProfilePropertiesResponse.profileActions` into `ProfileActionType`s.
pub fn parse_profile_actions(body: &str) -> ProfileActions {
let mut actions = ProfileActions::default();
let v: serde_json::Value = serde_json::from_str(body).unwrap_or_default();
for action in v
.get("profileActions")
.and_then(|a| a.as_array())
.into_iter()
.flatten()
{
match action.get("type").and_then(|t| t.as_str()) {
Some("FORCED_NAME_CHANGE") => actions.forced_name_change = true,
Some("USING_BANNED_SKIN") => actions.using_banned_skin = true,
_ => {}
}
}
actions
}
#[cfg(test)]
mod tests {
use super::*;
const HOUR: i64 = 3_600_000;
/// 2026-11-01T00:00:00Z.
const EXPIRES: i64 = 1_793_491_200_000;
#[test]
fn parses_a_multiplayer_ban() {
let body = r#"{"privileges":{"multiplayerServer":{"enabled":false}},
"banStatus":{"bannedScopes":{"MULTIPLAYER":{"banId":"6b0f0a0e-0000-4000-8000-000000000000",
"expires":"2026-11-01T00:00:00Z","reason":"21","reasonMessage":"x"}}}}"#;
let p = parse_properties(body).unwrap();
assert!(!p.servers_allowed);
let ban = p.multiplayer_ban.unwrap();
assert_eq!(ban.expires_ms, Some(EXPIRES));
assert_eq!(ban.reason.as_deref(), Some("21"));
let numeric =
parse_properties(r#"{"banStatus":{"bannedScopes":{"MULTIPLAYER":{"expires":5}}}}"#)
.unwrap();
assert_eq!(numeric.multiplayer_ban.unwrap().expires_ms, Some(5));
assert!(parse_properties("nope").is_none());
let clean = parse_properties(
r#"{"privileges":{"multiplayerServer":{"enabled":true}},"banStatus":{"bannedScopes":{}}}"#,
)
.unwrap();
assert!(clean.servers_allowed && clean.multiplayer_ban.is_none());
}
#[test]
fn parses_profile_actions() {
let a = parse_profile_actions(
r#"{"id":"x","profileActions":[{"type":"FORCED_NAME_CHANGE"},{"type":"OTHER"}]}"#,
);
assert!(a.forced_name_change && !a.using_banned_skin);
let a = parse_profile_actions(r#"{"profileActions":[{"type":"USING_BANNED_SKIN"}]}"#);
assert!(!a.forced_name_change && a.using_banned_skin);
assert_eq!(parse_profile_actions("{}"), ProfileActions::default());
}
#[test]
fn queues_in_vanilla_order() {
let ban = BanDetails {
expires_ms: None,
reason: None,
reason_message: None,
};
let status = AccountStatus {
properties: UserProperties {
servers_allowed: true,
multiplayer_ban: Some(ban.clone()),
},
actions: ProfileActions {
forced_name_change: true,
using_banned_skin: true,
},
};
assert_eq!(
initial_notices(&status, "Steve"),
vec![
Notice::Multiplayer(ban),
Notice::Name("Steve".into()),
Notice::Skin
]
);
assert!(!status.allows_multiplayer());
assert!(initial_notices(&AccountStatus::default(), "Steve").is_empty());
assert!(AccountStatus::default().allows_multiplayer());
let no_servers = AccountStatus {
properties: UserProperties {
servers_allowed: false,
multiplayer_ban: None,
},
..Default::default()
};
assert!(!no_servers.allows_multiplayer());
}
/// The message's component JSON: the keys and arguments, whatever language is loaded.
fn json(notice: &Notice, now: i64) -> String {
notice.message(now).component().to_string()
}
#[test]
fn ban_text_follows_banned_screens() {
let temp = |reason: Option<&str>, message: Option<&str>| {
Notice::Multiplayer(BanDetails {
expires_ms: Some(100 * HOUR),
reason: reason.map(str::to_owned),
reason_message: message.map(str::to_owned),
})
};
assert_eq!(
temp(None, None).title().translation_key(),
Some("gui.banned.title.temporary")
);
let perm = Notice::Multiplayer(BanDetails {
expires_ms: None,
reason: Some("5".into()),
reason_message: None,
});
assert_eq!(
perm.title().translation_key(),
Some("gui.banned.title.permanent")
);
let text = json(&perm, 0);
assert!(text.contains("gui.banned.reason.hate_speech"));
assert!(text.contains("gui.banned.description.permanent"));
assert!(text.contains(SUSPENSION_HELP));
// Unknown ids show their code, with the message when there is one.
let with_message = json(&temp(Some("999"), Some("why")), 0);
assert!(with_message.contains("gui.banned.description.reason_id_message"));
assert!(with_message.contains("why"));
assert!(json(&temp(Some("999"), None), 0).contains("gui.banned.description.reason_id\""));
assert!(json(&temp(Some("abc"), None), 0).contains("gui.banned.description.unknownreason"));
// 100 hours left is past 72: days (4); less is hours; under an hour is minutes.
let duration = |now| json(&temp(None, None), now);
assert!(duration(0).contains("gui.days") && duration(0).contains("[4]"));
assert!(duration(60 * HOUR).contains("gui.hours") && duration(60 * HOUR).contains("[40]"));
let minutes = duration(100 * HOUR - 90_000);
assert!(minutes.contains("gui.minutes") && minutes.contains("[1]"));
assert!(json(&Notice::Name("Bob".into()), 0).contains("Bob"));
assert!(json(&Notice::Skin, 0).contains(SUSPENSION_HELP));
}
}
+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::*;
@@ -23,7 +23,7 @@
//! Which states tick is a table by state id, built by the game from the block
//! registry; whether a slot is made is decided when the slot is promoted at
//! the start of the next tick, from the block state then.
use crate::chunk::block_entity::BlockEntity;
use crate::chunk::block_entity::{BlockEntity, ModContainer};
use crate::fast_hash::{FxHashMap, FxHashSet};
use std::collections::HashMap;
use std::sync::Arc;
@@ -70,6 +70,9 @@ pub struct BlockEntities {
replaced: FxHashSet<Pos>,
/// `Ticking` by block state id; empty until the game builds it.
states: Vec<Ticking>,
/// The containers of mod blocks' block entities, by block state id;
/// built with `states`.
mod_containers: FxHashMap<u32, Arc<ModContainer>>,
}
impl std::ops::Deref for BlockEntities {
@@ -152,6 +155,13 @@ impl BlockEntities {
pub fn ticking(&self, state: u32) -> Ticking {
self.states.get(state as usize).copied().unwrap_or_default()
}
pub fn set_mod_containers(&mut self, containers: FxHashMap<u32, Arc<ModContainer>>) {
self.mod_containers = containers;
}
/// The container of a mod block's block entity in block state `state`.
pub fn mod_container(&self, state: u32) -> Option<&Arc<ModContainer>> {
self.mod_containers.get(&state)
}
fn next(&mut self) -> u32 {
let n = self.next_slot;
@@ -789,6 +789,8 @@ pub struct Scene<'a> {
pub glyphs: u64,
}
/// Empty by default (a dedicated server): no state has a block entity model.
#[derive(Default)]
pub struct BlockEntityRenderer {
models: HashMap<&'static str, ModelPart>,
kinds: Vec<Kind>,
@@ -865,13 +867,23 @@ const VIEW_DISTANCE: f64 = 64.0;
impl BlockEntityRenderer {
pub fn load(root: &Path, registry: &BlockRegistry) -> crate::Result<Self> {
let visibility = crate::models::load_visibility(root, registry)?;
Self::load_with_visibility(root, registry, visibility)
}
/// [`BlockEntityRenderer::load`] over the face visibility the meshes use
/// (`MeshTables::visibility`): a clone shares its shapes and their faces.
pub fn load_with_visibility(
root: &Path,
registry: &BlockRegistry,
visibility: FaceVisibility,
) -> crate::Result<Self> {
let all: Value = serde_json::from_slice(&std::fs::read(root.join("entity-models.json"))?)?;
let models = MODELS
.iter()
.filter_map(|(key, layer)| all.get(*layer).map(|v| (*key, ModelPart::parse(v))))
.collect();
let mut renderer = Self::new(models, registry);
renderer.visibility = Some(Mutex::new(crate::models::load_visibility(root, registry)?));
renderer.visibility = Some(Mutex::new(visibility));
// Sherd items: provides_pottery_pattern -> decorated_pot_pattern asset id.
if let Ok(bytes) = std::fs::read(root.join("items.json")) {
let items: Value = serde_json::from_slice(&bytes)?;
@@ -1104,7 +1116,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,
@@ -1113,7 +1129,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]))
@@ -1467,6 +1492,7 @@ impl BlockEntityRenderer {
} else {
None
};
let has_profile = skin.is_some();
let texture = skin.as_deref().unwrap_or(texture);
let (Some(model), Some(paint)) =
(self.models.get(model), paint(texture, 0xFFFFFF, packed))
@@ -1501,7 +1527,13 @@ impl BlockEntityRenderer {
"right_ear" => p[5] = (f64::from(t * PI * 0.2).cos() + 2.5) as f32 * 0.2,
_ => {}
};
let start = out.len();
draw_part(model, "root", m, &anim, paint, out);
// `SkullBlockRenderer.resolveSkullRenderType`: a head with a profile takes
// the player skin's `entityTranslucent` render type.
if has_profile {
crate::gfx::mark_oit(&mut out[start..]);
}
}
Kind::Bell => {
let (Some(model), Some(paint)) = (
@@ -2363,6 +2395,7 @@ mod tests {
lines: [line.into(), "two".into(), String::new(), "four".into()],
color: 3,
glowing,
..Default::default()
};
let first_text = crate::signs::SignData {
front: text("one", false),
@@ -24,6 +24,9 @@ struct Out {
return out;
}
@fragment fn fs_border(in: Out) -> @location(0) vec4<f32> {
return border_color(in);
}
fn border_color(in: Out) -> vec4<f32> {
let color = textureSample(forcefield, forcefield_sampler, in.uv);
if color.a == 0.0 {
discard;
+54 -20
View File
@@ -7,12 +7,13 @@ use crate::{
items::{ItemStack, Items},
providers::Providers,
};
use horizon_world::loot::PackOverlay;
use serde_json::Value;
use std::{collections::HashSet, fs, path::Path};
pub use horizon_client_core::potions::{
BASE_POTION_COLOR, Category, EFFECTS, EffectDef, POTIONS, PotionDef, effect, namespaced,
potion, potion_color,
BASE_POTION_COLOR, Category, EFFECTS, EffectDef, POTIONS, PotionDef, all_effects,
description_id, effect, mod_effects, namespaced, potion, potion_color, register_mod_effects,
};
/// `MobEffectUtil.formatDuration` at 20 TPS.
pub fn format_duration(ticks: i32) -> String {
@@ -35,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>,
@@ -63,8 +80,7 @@ pub fn effect_lines<'a>(
.into_iter()
.map(|(e, duration, amp)| {
let e = e.trim_start_matches("minecraft:");
let mut line =
translate(&format!("effect.minecraft.{e}")).unwrap_or_else(|| e.to_owned());
let mut line = translate(&description_id(e)).unwrap_or_else(|| e.to_owned());
if amp > 0 {
line.push(' ');
line.push_str(
@@ -142,20 +158,41 @@ pub struct Brewing {
pub inputs: HashSet<String>,
}
impl Brewing {
pub fn load(root: &Path, items: &Items) -> Self {
let mut files: Vec<_> = fs::read_dir(root.join("data/minecraft/recipe/brewing"))
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.collect();
files.sort();
let mut recipes = Vec::new();
for p in files {
let Ok(bytes) = fs::read(&p) else { continue };
let Ok(v) = serde_json::from_slice::<Value>(&bytes) else {
pub fn load(root: &Path, items: &Items, overlay: &PackOverlay) -> Self {
let mut sources: Vec<(String, Value)> =
fs::read_dir(root.join("data/minecraft/recipe/brewing"))
.into_iter()
.flatten()
.flatten()
.filter_map(|e| {
let bytes = fs::read(e.path()).ok()?;
let v = serde_json::from_slice::<Value>(&bytes).ok()?;
Some((
format!(
"minecraft:brewing/{}",
e.file_name().to_string_lossy().strip_suffix(".json")?
),
v,
))
})
.collect();
// The world's packs' brewing recipes over the game's own: the top
// pack's file for each id replaces it (`SimpleJsonResourceReloadListener`).
for (id, files) in overlay.kind("recipe") {
if !id.starts_with("minecraft:brewing/") {
continue;
}
let Ok(v) = serde_json::from_slice::<Value>(PackOverlay::top(files)) else {
continue;
};
match sources.iter_mut().find(|(n, _)| n == id) {
Some(slot) => slot.1 = v,
None => sources.push((id.to_owned(), v)),
}
}
sources.sort_by(|a, b| a.0.cmp(&b.0));
let mut recipes = Vec::new();
for (id, v) in sources {
if v["type"] != "minecraft:brewing" {
continue;
}
@@ -169,10 +206,7 @@ impl Brewing {
output = output.with_potion(potion);
}
recipes.push(BrewingRecipe {
id: format!(
"minecraft:brewing/{}",
p.file_stem().and_then(|s| s.to_str()).unwrap_or("")
),
id,
input: PotionIngredient::parse(&v["input"], items),
reagent: PotionIngredient::parse(&v["reagent"], items),
output,
@@ -627,6 +627,34 @@ pub fn perspective(
m[11] = -1.0;
m
}
/// `Frustum.getNearPlaneBounds`: the box around the four corners of the
/// culling frustum's NDC `z = -1` plane, relative to the camera (the caller
/// moves it to the eye). The culling projection is `Camera.update`'s
/// `setPerspective(fov, aspect, 0.05, depthFar, zZeroToOne)`.
pub fn near_plane_bounds(
fov_degrees: f32,
width: f32,
height: f32,
yaw: f32,
pitch: f32,
far: f32,
) -> ([f64; 3], [f64; 3]) {
let projection =
glam::Mat4::from_cols_array(&perspective(fov_degrees, width, height, 0.05, far, true));
// Frustum.calculateFrustum: projection * modelView.
let clip_to_world = (projection * view_matrix(yaw, pitch)).inverse();
let mut min = [f64::INFINITY; 3];
let mut max = [f64::NEG_INFINITY; 3];
for (x, y) in [(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)] {
let p = clip_to_world * glam::Vec4::new(x, y, -1.0, 1.0);
let p = [p.x / p.w, p.y / p.w, p.z / p.w];
for k in 0..3 {
min[k] = min[k].min(f64::from(p[k]));
max[k] = max[k].max(f64::from(p[k]));
}
}
(min, max)
}
/// `GameRenderer.render3dHud`: the first-person hand and held items are
/// drawn with their own projection at `hudFov` (70 degrees, changed only by
/// death and fluids), never the FOV option or the sprint, speed and bow
@@ -834,6 +862,19 @@ pub fn spinning_effect_matrix(intensity: f32, angle_degrees: f32) -> glam::Mat4
#[cfg(test)]
mod tests {
#[test]
fn near_plane_bounds_sit_half_a_near_distance_ahead_of_the_eye() {
// Facing +Z (yaw 0): the zero-to-one culling projection puts NDC
// z = -1 at -far * near / (2 far - near) in front of the camera.
let far = 512.0;
let (min, max) = super::near_plane_bounds(70.0, 1600.0, 900.0, 0.0, 0.0, far);
let depth = f64::from(far * 0.05 / (2.0 * far - 0.05));
assert!((min[2] - depth).abs() < 1e-4 && (max[2] - depth).abs() < 1e-4);
let half_height = depth * 35.0f64.to_radians().tan();
assert!((max[1] - half_height).abs() < 1e-4 && (min[1] + half_height).abs() < 1e-4);
let half_width = half_height * 1600.0 / 900.0;
assert!((max[0] - half_width).abs() < 1e-3 && (min[0] + half_width).abs() < 1e-3);
}
/// `moveCloak` trails a quarter of the way, jumps from 10 blocks away; the
/// cape leans back as the player walks forward (south, body yaw 0).
#[test]
+278 -24
View File
@@ -21,6 +21,14 @@ 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;
/// `GuiMessageTag.CHAT_MODIFIED_INDICATOR_COLOR`.
const MODIFIED_INDICATOR: u32 = 0x60_6060;
/// 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)]
@@ -163,15 +171,56 @@ impl ChatOptions {
struct Message {
added: u64,
content: Text,
indicator: Option<u32>,
tag: Option<std::sync::Arc<Tag>>,
source: ChatSource,
/// `GuiMessage.signature`: a signed player message's, for deletion.
signature: Option<Box<Signature>>,
}
/// A wrapped line (`GuiMessage.Line`).
#[derive(Clone, Debug)]
struct Line {
added: u64,
content: Text,
indicator: Option<u32>,
tag: Option<std::sync::Arc<Tag>>,
}
/// `GuiMessageTag`: the bar at the left of a message's lines, the tooltip
/// over it, and for a modified message the icon after each line.
#[derive(Clone, Debug, PartialEq)]
pub struct Tag {
pub color: u32,
/// `GuiMessageTag.Icon.CHAT_MODIFIED`.
pub icon: bool,
pub text: Text,
}
impl Tag {
/// `GuiMessageTag.systemSinglePlayer`, in `color`.
pub fn system_single_player(color: u32) -> Self {
Self {
color,
icon: false,
text: Text::translate("chat.tag.system_single_player", &[]),
}
}
/// `GuiMessageTag.chatNotSecure`.
pub fn not_secure() -> Self {
Self {
color: SYSTEM_INDICATOR,
icon: false,
text: Text::translate("chat.tag.not_secure", &[]),
}
}
/// `GuiMessageTag.chatModified`: the tooltip adds the original content in grey.
pub fn modified(original_content: &str) -> Self {
Self {
color: MODIFIED_INDICATOR,
icon: true,
text: Text::empty()
.append(Text::translate("chat.tag.modified", &[]))
.append(Text::literal("\n"))
.append(Text::literal(original_content).with_color(text::GRAY)),
}
}
}
/// `ChatComponent.AlphaCalculator.timeBased`: opaque for 180 ticks, then
@@ -205,6 +254,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 {
@@ -225,38 +277,99 @@ impl Chat {
ticks: u64,
focused: bool,
) {
self.add_message(&atlas.font, content, indicator, source, ticks, focused);
let tag = indicator.map(|color| std::sync::Arc::new(Tag::system_single_player(color)));
self.add_message(&atlas.font, content, tag, 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>,
tag: Option<Tag>,
ticks: u64,
focused: bool,
) {
self.add_player_message(&atlas.font, content, signature, tag, ticks, focused);
}
#[cfg(test)]
fn add_signed_message(
&mut self,
font: &crate::font::Font,
content: Text,
signature: Option<Signature>,
ticks: u64,
focused: bool,
) {
self.add_player_message(font, content, signature, None, ticks, focused);
}
/// `ChatComponent.addPlayerMessage`: the tag the message's trust level made.
fn add_player_message(
&mut self,
font: &crate::font::Font,
content: Text,
signature: Option<Signature>,
tag: Option<Tag>,
ticks: u64,
focused: bool,
) {
self.push_message(
font,
Message {
added: ticks,
content,
tag: tag.map(std::sync::Arc::new),
source: ChatSource::Player,
signature: signature.map(Box::new),
},
focused,
);
}
fn add_message(
&mut self,
font: &crate::font::Font,
content: Text,
indicator: Option<u32>,
tag: Option<std::sync::Arc<Tag>>,
source: ChatSource,
ticks: u64,
focused: bool,
) {
if !self.abilities.shows(source) {
self.push_message(
font,
Message {
added: ticks,
content,
tag,
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);
self.received += 1;
}
fn add_lines(&mut self, font: &crate::font::Font, message: &Message, focused: bool) {
let width = self.max_width();
// GuiMessage.splitLines: room for the tag's icon (its width, 4 before and 2 after).
let icon = if message.tag.as_ref().is_some_and(|tag| tag.icon) {
9 + 4 + 2
} else {
0
};
let width = self.max_width() - icon;
let content = self.displayed(&message.content);
for line in font.wrap_components(&content, width) {
if focused && self.scroll > 0 {
@@ -268,7 +381,7 @@ impl Chat {
Line {
added: message.added,
content: line,
indicator: message.indicator,
tag: message.tag.clone(),
},
);
}
@@ -319,8 +432,68 @@ 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(),
tag: Some(std::sync::Arc::new(Tag::system_single_player(
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) {
@@ -431,8 +604,8 @@ impl Chat {
shadow: true,
});
}
if let Some(indicator) = line.indicator {
let color = hud_layout::as8(opacity) << 24 | indicator & 0xFF_FFFF;
if let Some(tag) = &line.tag {
let color = hud_layout::as8(opacity) << 24 | tag.color & 0xFF_FFFF;
out.push(hud_layout::fill(
-4,
bottom - entry_height,
@@ -593,12 +766,91 @@ impl Chat {
}
None
}
/// Whether the cursor is on a line's indicator bar while chat is focused
/// (`DrawingFocusedGraphicsAccess.handleTag`), whose tooltip is the
/// message tag's text.
pub fn indicator_at(&self, height: i32, x: f64, y: f64) -> bool {
self.line_at(height, x, y)
.is_some_and(|(line, x)| line.indicator.is_some() && (-4.0..-2.0).contains(&x))
/// `DrawingFocusedGraphicsAccess.handleTag` and `handleTagIcon` with the
/// cursor at GUI position `(x, y)` while chat is focused: the tooltip of
/// the tag under it (its bar, or its icon) and the icons to draw, those
/// under the cursor and those of the message the cursor is over.
pub fn focused_tags(
&self,
font: &crate::font::Font,
height: i32,
x: f64,
y: f64,
) -> (Option<Text>, Vec<GuiDraw>) {
let o = &self.options;
let scale = o.scale as f32;
let chat_bottom = hud_layout::floor((height - 40) as f32 / scale);
let entry_height = o.line_height();
let bottom_to_text =
(8.0 * (o.line_spacing + 1.0) - 4.0 * o.line_spacing + 0.5).floor() as i32;
let (mouse_x, mouse_y) = (x / f64::from(scale) - 4.0, y / f64::from(scale));
let inside = |left: i32, top: i32, right: i32, bottom: i32| {
mouse_x >= f64::from(left)
&& mouse_x < f64::from(right)
&& mouse_y >= f64::from(top)
&& mouse_y < f64::from(bottom)
};
let visible = self
.lines
.len()
.saturating_sub(self.scroll)
.min(self.lines_per_page(true));
let mut tooltip = None;
let mut icons = Vec::new();
let mut hovered_message = false;
for i in (0..visible).rev() {
let index = i + self.scroll;
let line = &self.lines[index];
let bottom = chat_bottom - i as i32 * entry_height;
let top = bottom - entry_height;
let width = font.width_text(&line.content).ceil() as i32;
hovered_message |= inside(0, top, width, bottom);
// GuiMessage.Line.endOfEntry: the next newer line is another message's.
let end_of_entry = index == 0
|| match (&line.tag, &self.lines[index - 1].tag) {
(Some(a), Some(b)) => !std::sync::Arc::ptr_eq(a, b),
(None, None) => false,
_ => true,
};
let force_icon = end_of_entry && hovered_message;
if end_of_entry {
hovered_message = false;
}
let Some(tag) = &line.tag else { continue };
if inside(-4, top, -2, bottom) {
tooltip = Some(tag.text.clone());
}
if tag.icon {
let (left, text_bottom) = (width + 4, bottom - bottom_to_text + 9);
let icon_top = text_bottom - 9 - 1;
let over = inside(left, icon_top, left + 9, text_bottom);
if over {
tooltip = Some(tag.text.clone());
}
if force_icon || over {
icons.push(GuiDraw::Sprite {
sprite: "icon/chat_modified".into(),
x: left,
y: icon_top,
w: 9,
h: 9,
region: None,
color: hud_layout::WHITE,
blend: hud_layout::Blend::Textured,
});
}
}
}
if icons.is_empty() {
return (tooltip, icons);
}
let mut draws = vec![GuiDraw::Push(vec![
PoseOp::Scale(scale, scale),
PoseOp::Translate(4.0, 0.0),
])];
draws.extend(icons);
draws.push(GuiDraw::Pop);
(tooltip, draws)
}
/// Plain text of the newest messages (for tests and the control socket).
pub fn recent_lines(&self, n: usize) -> Vec<String> {
@@ -929,7 +1181,9 @@ impl ChatScreen {
} else if !command.chars().all(char::is_whitespace) {
let partial = &command[..cursor_b];
let mut builder = SuggestionsBuilder::new(partial, last_word_index(partial));
builder.suggest_matching(c.registries.players.iter().map(String::as_str));
// ClientSuggestionProvider.getCustomTabSuggestions: the players
// and the server's custom chat completions.
builder.suggest_matching(c.registries.custom_tab_suggestions());
let (range, list) = merge_suggestions(partial, builder.out);
self.awaiting = None;
self.pending = Some((range.0, list));
+112 -1
View File
@@ -33,7 +33,7 @@ fn add(chat: &mut Chat, font: &Font, content: Text) {
Line {
added: 0,
content,
indicator: None,
tag: None,
},
);
}
@@ -244,6 +244,58 @@ fn mod_commands_are_coloured_and_complete_like_vanilla_ones() {
assert_eq!(screen.value(), "/coords");
}
#[test]
fn chat_completes_the_servers_custom_completions_with_the_players() {
// CommandSuggestions.updateCommandInfo completes chat from
// ClientSuggestionProvider.getCustomTabSuggestions: the online players
// and what ClientboundCustomChatCompletionsPacket added.
use crate::command::{CustomCompletionAction as Action, modify_custom_completions};
let font = font();
let m = Measure(&font);
let mut registries = Registries {
players: vec!["Steve".into()],
..Registries::default()
};
let entries = |e: &[&str]| e.iter().map(|s| s.to_string()).collect::<Vec<_>>();
let set = &mut registries.custom_completions;
modify_custom_completions(set, Action::from_id(0), &entries(&["Alex", "#trade"]));
modify_custom_completions(set, Action::Add, &entries(&["Notch", "Steve"]));
modify_custom_completions(set, Action::Remove, &entries(&["Alex"]));
// An id past the last action is ADD (OutOfBoundsStrategy.ZERO).
modify_custom_completions(set, Action::from_id(7), &entries(&["Zuri"]));
assert_eq!(
registries
.custom_tab_suggestions()
.into_iter()
.collect::<Vec<_>>(),
["#trade", "Notch", "Steve", "Zuri"]
);
let completed = |registries: &Registries, line: &str| {
let dispatcher = crate::command::vanilla::game_dispatcher();
let c = Commands {
dispatcher,
registries,
};
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, full());
screen.type_text(&m, &c, line);
// Chat shows no list by itself: Tab shows it, Tab again uses it.
for _ in 0..2 {
screen.key(&m, &c, &[], winit::keyboard::KeyCode::Tab, false, false);
}
screen.value().to_owned()
};
assert_eq!(completed(&registries, "hi no"), "hi Notch");
assert_eq!(completed(&registries, "sell #tr"), "sell #trade");
assert_eq!(completed(&registries, "hi st"), "hi Steve");
assert_eq!(completed(&registries, "hi al"), "hi al");
// SET replaces them all; the players stay.
let set = &mut registries.custom_completions;
modify_custom_completions(set, Action::Set, &entries(&["Alex"]));
assert_eq!(completed(&registries, "hi no"), "hi no");
assert_eq!(completed(&registries, "hi al"), "hi Alex");
assert_eq!(completed(&registries, "hi st"), "hi Steve");
}
#[test]
fn ask_server_nodes_wait_for_the_servers_completions() {
// ClientSuggestionProvider.customSuggestion: an `ask_server` node at the
@@ -467,3 +519,62 @@ fn a_guests_line_follows_the_player_message_permission() {
);
}
}
/// `ChatComponent.deleteMessageOrDelay`: a deleted message shows for 60
/// ticks from when it was added, then becomes the grey italic
/// `chat.deleted_marker` line with the system bar; the other lines stay.
#[test]
fn a_deleted_message_shows_60_ticks_then_becomes_the_marker() {
let font = font();
let mut chat = Chat::default();
let secret = [7; 256];
chat.add_signed_message(
&font,
Text::literal("<Steve> secret"),
Some(secret),
100,
false,
);
chat.add_signed_message(
&font,
Text::literal("<Alex> hi"),
Some([8; 256]),
110,
false,
);
let lines =
|chat: &Chat| -> Vec<String> { chat.lines.iter().map(|l| l.content.plain()).collect() };
let marker = Chat::deleted_marker().plain();
chat.delete_message_with(&font, &secret, 130);
assert_eq!(
lines(&chat),
["<Alex> hi", "<Steve> secret"],
"too new: delayed"
);
chat.process_deletions(&font, 159);
assert_eq!(lines(&chat), ["<Alex> hi", "<Steve> secret"]);
chat.process_deletions(&font, 160);
assert_eq!(lines(&chat), ["<Alex> hi".to_owned(), marker.clone()]);
assert!(chat.deletions.is_empty());
let line = &chat.lines[1];
assert_eq!(line.tag.as_ref().map(|t| t.color), 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());
}
+356 -20
View File
@@ -8,10 +8,208 @@ use std::sync::{Arc, OnceLock};
pub mod block_entity;
/// The vanilla Overworld's column, which every dimension's chunks hold for
/// now (`LevelShape::column`).
pub const MIN_Y: i32 = -64;
pub const HEIGHT: i32 = 384;
pub const SECTIONS: usize = (HEIGHT / 16) as usize;
/// A level's build range and skylight, from its dimension type (`min_y`,
/// `height`, `logical_height`, `has_skylight`), as `LevelHeightAccessor` and
/// `SerializableChunkData.write` read them.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct LevelShape {
pub sky_light: bool,
pub min_y: i32,
pub height: i32,
/// `DimensionType.logicalHeight`: how high portals and chorus fruit reach.
pub logical_height: i32,
}
impl LevelShape {
pub const OVERWORLD: Self = Self {
sky_light: true,
min_y: MIN_Y,
height: HEIGHT,
logical_height: HEIGHT,
};
pub const NETHER: Self = Self {
sky_light: false,
min_y: 0,
height: 256,
logical_height: 128,
};
pub const END: Self = Self {
sky_light: true,
min_y: 0,
height: 256,
logical_height: 256,
};
/// A dimension type's shape, checked as its codec checks it.
pub fn new(
min_y: i32,
height: i32,
logical_height: i32,
sky_light: bool,
) -> Result<Self, String> {
ColumnLayout::new(min_y, height)?;
if !(0..=height).contains(&logical_height) {
return Err(format!(
"logical_height {logical_height} is outside 0..={height}"
));
}
Ok(Self {
sky_light,
min_y,
height,
logical_height,
})
}
/// One above the highest block (`getMaxY() + 1`).
pub fn max_y(self) -> i32 {
self.min_y + self.height
}
pub fn min_section(self) -> i32 {
self.min_y >> 4
}
pub fn section_count(self) -> usize {
(self.height >> 4) as usize
}
/// Whether section `y` (a section coordinate) is inside the level.
pub fn has_section(self, y: i32) -> bool {
(self.min_section()..self.min_section() + self.section_count() as i32).contains(&y)
}
/// The sections a chunk of this level holds: the level's, widened to the
/// vanilla Overworld's `-64..320` so that vanilla's Nether and End keep
/// the 24 sections they have always had.
pub fn column(self) -> ColumnLayout {
ColumnLayout::of_level(self.min_y, self.height)
}
}
/// The shapes of a world's three levels, from its dimension types.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct LevelShapes {
pub overworld: LevelShape,
pub nether: LevelShape,
pub end: LevelShape,
}
impl LevelShapes {
pub const VANILLA: Self = Self {
overworld: LevelShape::OVERWORLD,
nether: LevelShape::NETHER,
end: LevelShape::END,
};
/// The shape of the level `id` (`minecraft:overworld`,
/// `minecraft:the_nether`, `minecraft:the_end`); others get the
/// Overworld's.
pub fn get(&self, id: &str) -> LevelShape {
match id {
"minecraft:the_nether" => self.nether,
"minecraft:the_end" => self.end,
_ => self.overworld,
}
}
pub fn get_mut(&mut self, id: &str) -> &mut LevelShape {
match id {
"minecraft:the_nether" => &mut self.nether,
"minecraft:the_end" => &mut self.end,
_ => &mut self.overworld,
}
}
}
impl Default for LevelShapes {
fn default() -> Self {
Self::VANILLA
}
}
/// The span of block Y a chunk holds: from `min_y`, `sections` sections up
/// (`LevelShape::column`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ColumnLayout {
min_y: i32,
sections: usize,
}
impl ColumnLayout {
pub const VANILLA: Self = Self {
min_y: MIN_Y,
sections: SECTIONS,
};
/// The column of a level from `min_y`, `height` high
/// (`LevelShape::column`).
pub fn of_level(min_y: i32, height: i32) -> Self {
let low = min_y.min(MIN_Y);
let high = (min_y + height).max(MIN_Y + HEIGHT);
Self {
min_y: low,
sections: ((high - low) / 16) as usize,
}
}
/// `DimensionType.MIN_Y`, `MAX_Y`: 12 bits of `BlockPos` Y, less 32.
pub const LOWEST_Y: i32 = -2032;
pub const HIGHEST_Y: i32 = 2031;
/// A dimension type's `min_y` and `height`, as its codec accepts them:
/// multiples of 16, at least 16 high, within `LOWEST_Y..=HIGHEST_Y`.
pub fn new(min_y: i32, height: i32) -> Result<Self, String> {
if min_y % 16 != 0 || height % 16 != 0 {
return Err(format!(
"min_y {min_y} and height {height} must be multiples of 16"
));
}
if height < 16 {
return Err(format!("height {height} is below 16"));
}
if min_y < Self::LOWEST_Y
|| i64::from(min_y) + i64::from(height) > i64::from(Self::HIGHEST_Y) + 1
{
return Err(format!(
"min_y {min_y} with height {height} leaves {}..={}",
Self::LOWEST_Y,
Self::HIGHEST_Y
));
}
Ok(Self {
min_y,
sections: (height / 16) as usize,
})
}
pub fn min_y(self) -> i32 {
self.min_y
}
/// One above the highest block.
pub fn max_y(self) -> i32 {
self.min_y + self.height()
}
pub fn height(self) -> i32 {
self.sections as i32 * 16
}
pub fn sections(self) -> usize {
self.sections
}
/// The index of the section holding block `y`, if the column holds it.
pub fn section_index(self, y: i32) -> Option<usize> {
usize::try_from((y - self.min_y) >> 4)
.ok()
.filter(|&s| s < self.sections)
}
/// The minimum block Y of section `index`.
pub fn section_y(self, index: usize) -> i32 {
self.min_y + index as i32 * 16
}
}
/// `true` for each section of `layout`: what callers that keep no
/// per-section record (of fluids, say) pass as "every section may have it".
pub fn every_section(layout: ColumnLayout) -> &'static [bool] {
static ALL: [bool; crate::section_mask::SectionMask::CAPACITY] =
[true; crate::section_mask::SectionMask::CAPACITY];
&ALL[..layout.sections()]
}
impl Default for ColumnLayout {
fn default() -> Self {
Self::VANILLA
}
}
/// Biomes are 4x4x4 containers over the biome table (67 entries: a 7-bit
/// global palette).
pub const BIOME_STRATEGY: Strategy = Strategy::biomes(7);
@@ -45,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)]
@@ -138,9 +377,11 @@ impl Section {
pub struct Chunk {
pub x: i32,
pub z: i32,
/// The minimum block Y of `sections[0]` (the layout's when it was made).
min_y: i32,
pub sections: Vec<Arc<Section>>,
/// `Heightmap.Types.WORLD_SURFACE` per column (`z << 4 | x`): one above
/// the highest non-air block, or `MIN_Y`. Built on the first read; a
/// the highest non-air block, or the column's minimum Y. Built on the first read; a
/// `set` that can move a column's surface drops it.
surface: OnceLock<Box<[i16; 256]>>,
/// `Heightmap.Types.MOTION_BLOCKING` per column, kept like `surface`.
@@ -169,13 +410,19 @@ pub struct Chunk {
pub structure_starts: Vec<Arc<horizon_world::worldgen::structure::start::StructureStart>>,
}
impl Chunk {
/// An all-air chunk in the current `layout`.
pub fn empty(x: i32, z: i32) -> Self {
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,
sections: (0..SECTIONS)
.map(|_| Arc::new(Section::default()))
.collect(),
min_y: layout.min_y(),
sections: (0..layout.sections()).map(|_| empty.clone()).collect(),
surface: OnceLock::new(),
motion_blocking: OnceLock::new(),
lit: false,
@@ -190,13 +437,36 @@ impl Chunk {
entities: Vec::new(),
}
}
/// The minimum block Y of the column.
#[inline]
pub fn min_y(&self) -> i32 {
self.min_y
}
/// The column the chunk holds.
pub fn layout(&self) -> ColumnLayout {
ColumnLayout {
min_y: self.min_y,
sections: self.sections.len(),
}
}
/// One above the column's highest block.
#[inline]
pub fn max_y(&self) -> i32 {
self.min_y + self.sections.len() as i32 * 16
}
/// The index of the section holding block `y`, if the column holds it.
#[inline]
pub fn section_index(&self, y: i32) -> Option<usize> {
usize::try_from((y - self.min_y) >> 4)
.ok()
.filter(|&s| s < self.sections.len())
}
#[inline]
pub fn get(&self, x: i32, y: i32, z: i32) -> u32 {
let s = (y - MIN_Y) >> 4;
if !(0..SECTIONS as i32).contains(&s) {
return 0;
match self.section_index(y) {
Some(s) => self.sections[s].get(index(x, y, z)),
None => 0,
}
self.sections[s as usize].get(index(x, y, z))
}
#[inline]
pub fn block(&self, x: i32, y: i32, z: i32) -> BlockStateId {
@@ -211,16 +481,15 @@ impl Chunk {
.rev()
.filter(|(_, section)| !section.is_uniform(skip))
.find_map(|(s, _)| {
let base = MIN_Y + s as i32 * 16;
let base = self.min_y + s as i32 * 16;
(base..base + 16).rev().find(|&y| matches(y))
})
}
pub fn set(&mut self, x: i32, y: i32, z: i32, v: u32) -> u32 {
let s = (y - MIN_Y) >> 4;
if !(0..SECTIONS as i32).contains(&s) {
let Some(s) = self.section_index(y) else {
return v;
}
let previous = Arc::make_mut(&mut self.sections[s as usize]).set(index(x, y, z), v);
};
let previous = Arc::make_mut(&mut self.sections[s]).set(index(x, y, z), v);
// `Heightmap.update`: a write two or more below a map's height can't
// move it. Any other change rebuilds that map on the next read.
if previous != v {
@@ -236,7 +505,8 @@ impl Chunk {
previous
}
/// `getHeight(Heightmap.Types.WORLD_SURFACE, x, z)` (`x`, `z` within the
/// chunk): one above the highest block that isn't `air`, or `MIN_Y`.
/// chunk): one above the highest block that isn't `air`, or the column's
/// minimum Y.
/// Sections must change only through `set` once this has been read.
pub fn world_surface(&self, x: i32, z: i32, air: impl Fn(u32) -> bool) -> i32 {
let surface = self.surface.get_or_init(|| self.prime(|v| !air(v)));
@@ -244,7 +514,8 @@ impl Chunk {
}
/// `getHeight(Heightmap.Types.MOTION_BLOCKING, x, z)` (`x`, `z` within
/// the chunk): one above the highest state where `blocks` (a motion
/// blocking block or a fluid), or `MIN_Y`. Kept as `world_surface` is.
/// blocking block or a fluid), or the column's minimum Y. Kept as
/// `world_surface` is.
pub fn motion_blocking(&self, x: i32, z: i32, blocks: impl Fn(u32) -> bool) -> i32 {
let heights = self.motion_blocking.get_or_init(|| self.prime(blocks));
i32::from(heights[column(x, z)])
@@ -252,7 +523,7 @@ impl Chunk {
/// `Heightmap.primeHeightmaps`: each section from the top, skipping
/// those of one state that doesn't count, until every column is found.
fn prime(&self, counts: impl Fn(u32) -> bool) -> Box<[i16; 256]> {
let mut surface = Box::new([MIN_Y as i16; 256]);
let mut surface = Box::new([self.min_y as i16; 256]);
let mut found = [false; 256];
let mut left = 256;
let mut values = [0u32; 4096];
@@ -264,7 +535,7 @@ impl Chunk {
continue;
}
section.unpack(&mut values);
let base = MIN_Y + s as i32 * 16;
let base = self.min_y + s as i32 * 16;
for i in 0..256 {
if found[i] {
continue;
@@ -280,11 +551,11 @@ impl Chunk {
}
#[inline]
pub fn light(&self, x: i32, y: i32, z: i32) -> u8 {
let s = (y - MIN_Y) >> 4;
let s = (y - self.min_y) >> 4;
if s < 0 {
return 0;
}
if s >= SECTIONS as i32 {
if s >= self.sections.len() as i32 {
return 0xF0;
}
self.sections[s as usize].light(index(x, y, z))
@@ -297,7 +568,7 @@ impl Chunk {
/// the quart y clamped to the column).
#[inline]
pub fn biome(&self, x: i32, y: i32, z: i32) -> u16 {
let qy = ((y - MIN_Y) >> 2).clamp(0, HEIGHT / 4 - 1);
let qy = ((y - self.min_y) >> 2).clamp(0, self.sections.len() as i32 * 4 - 1);
self.sections[(qy >> 2) as usize]
.biomes
.get(((qy & 3) << 4 | ((z & 15) >> 2) << 2 | (x & 15) >> 2) as usize) as u16
@@ -504,4 +775,69 @@ mod tests {
}
}
}
#[test]
fn shapes_follow_the_dimension_type_codec() {
assert_eq!(ColumnLayout::new(-64, 384), Ok(ColumnLayout::VANILLA));
assert_eq!(ColumnLayout::default(), ColumnLayout::VANILLA);
// To The Max's Overworld: the highest top vanilla allows.
let tall = ColumnLayout::new(-64, 2096).unwrap();
assert_eq!((tall.sections(), tall.max_y()), (131, 2032));
assert!(ColumnLayout::new(-2032, 4064).is_ok());
for (min_y, height) in [(-64, 2112), (-2048, 64), (-60, 384), (0, 250), (0, 0)] {
assert!(
ColumnLayout::new(min_y, height).is_err(),
"{min_y} {height}"
);
}
assert_eq!(tall.section_index(-65), None);
assert_eq!(tall.section_index(-64), Some(0));
assert_eq!(tall.section_index(2031), Some(130));
assert_eq!(tall.section_index(2032), None);
assert_eq!(tall.section_y(130), 2016);
assert_eq!(
LevelShape::new(-64, 384, 384, true),
Ok(LevelShape::OVERWORLD)
);
assert_eq!(LevelShape::new(0, 256, 128, false), Ok(LevelShape::NETHER));
assert!(LevelShape::new(0, 256, 257, true).is_err());
assert!(LevelShape::new(-60, 384, 384, true).is_err());
}
#[test]
fn vanilla_levels_keep_the_overworld_column() {
for shape in [LevelShape::OVERWORLD, LevelShape::NETHER, LevelShape::END] {
assert_eq!(shape.column(), ColumnLayout::VANILLA, "{shape:?}");
}
let tall = LevelShape::new(-64, 2096, 2096, true).unwrap();
assert_eq!(tall.column(), ColumnLayout::new(-64, 2096).unwrap());
let deep = LevelShape::new(-512, 256, 256, false).unwrap();
assert_eq!(deep.column(), ColumnLayout::new(-512, 832).unwrap());
assert!(LevelShape::NETHER.has_section(15) && !LevelShape::NETHER.has_section(16));
assert!(!LevelShape::NETHER.has_section(-1));
}
#[test]
fn a_tall_column_stores_its_whole_height() {
let layout = ColumnLayout::new(-64, 2096).unwrap();
let mut chunk = Chunk::with_layout(0, 0, layout);
assert_eq!(
(chunk.min_y(), chunk.max_y(), chunk.sections.len()),
(-64, 2032, 131)
);
let stone = 1;
assert_eq!(chunk.set(5, 2031, 6, stone), 0);
assert_eq!(chunk.set(5, 2032, 6, stone), stone, "above the column");
assert_eq!(chunk.get(5, 2031, 6), stone);
assert_eq!(chunk.get(5, 2032, 6), 0);
assert_eq!(chunk.world_surface(5, 6, |v| v == 0), 2032);
assert_eq!(chunk.world_surface(0, 0, |v| v == 0), -64);
assert_eq!(chunk.highest(0, |y| y < 1000), None);
chunk.set(5, 999, 6, stone);
assert_eq!(chunk.highest(0, |y| y < 1000), Some(999));
assert_eq!(chunk.light(0, 2100, 0), 0xF0);
assert_eq!(chunk.light(0, -65, 0), 0);
chunk.set_biome_cell(0, 130 * 4 + 3, 0, 9);
assert_eq!(chunk.biome(0, 5000, 0), 9);
}
}
@@ -80,6 +80,78 @@ pub enum BlockEntity {
Shelf(Box<Shelf>),
/// `ChiseledBookShelfBlockEntity`: a `Container` of six one-book slots.
ChiseledBookshelf(Box<ChiseledBookshelf>),
/// A block entity of a mod (`native_block_entity`), kept as its saved
/// compound for the mod to read and change.
Mod(Box<ModBlockEntity>),
/// `PotentSulfurBlockEntity` (`game/potent_sulfur.rs`).
PotentSulfur(PotentSulfur),
/// `StructureBlockEntity` (`structure_block.rs`).
StructureBlock(Box<crate::structure_block::StructureBlock>),
/// `JigsawBlockEntity` (`jigsaw_block.rs`).
JigsawBlock(Box<crate::jigsaw_block::JigsawBlock>),
/// `TestBlockEntity` (`test_block.rs`).
TestBlock(Box<crate::test_block::TestBlock>),
/// `TestInstanceBlockEntity` (`test_instance_block.rs`).
TestInstanceBlock(Box<crate::test_instance_block::TestInstanceBlock>),
}
/// A mod's block entity: its type id (`mymod:machine`), its saved
/// compound without `id`, `x`, `y`, `z` and `Items`, its item slots
/// (`Items`) when its block has a container (`ModContainer`), and the data
/// slots its menu shows (`ContainerData`: a furnace menu's flame and arrow),
/// which the mod sets and nothing saves.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ModBlockEntity {
pub id: String,
pub nbt: Compound,
pub items: Vec<Option<ItemStack>>,
pub data: Vec<i32>,
}
/// The container of a mod block's block entity, as a `WorldlyContainer`
/// says it: its slot count, the slots each face reaches
/// (`getSlotsForFace`, by `Direction`: down, up, north, south, west, east),
/// and the slots hoppers may put into (`canPlaceItemThroughFace`) and take
/// from (`canTakeItemThroughFace`); and the vanilla menu that shows its
/// slots when the block is used, if any (`MOD_MENUS`).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ModContainer {
pub slots: usize,
pub faces: [Vec<usize>; 6],
pub inputs: Vec<usize>,
pub outputs: Vec<usize>,
pub menu: Option<String>,
}
/// The vanilla menus a mod container can open, with the slot count each
/// shows: slot grids, and the furnaces' (input, fuel, result), whose four
/// data slots draw the flame (`data[0]` of `data[1]`) and the arrow
/// (`data[2]` of `data[3]`), as `AbstractFurnaceBlockEntity.dataAccess`.
pub const MOD_MENUS: [(&str, usize); 11] = [
("minecraft:furnace", 3),
("minecraft:blast_furnace", 3),
("minecraft:smoker", 3),
("minecraft:hopper", 5),
("minecraft:generic_3x3", 9),
("minecraft:generic_9x1", 9),
("minecraft:generic_9x2", 18),
("minecraft:generic_9x3", 27),
("minecraft:generic_9x4", 36),
("minecraft:generic_9x5", 45),
("minecraft:generic_9x6", 54),
];
/// `PotentSulfurBlockEntity`: the seconds left to the geyser's next change of
/// state (`waitingCountdown`, saved as `countdown`) and the game time of its
/// last eruption block event, which the client's plume counts from.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct PotentSulfur {
pub waiting_countdown: i32,
pub eruption_tick: i64,
}
impl Default for PotentSulfur {
fn default() -> Self {
Self {
waiting_countdown: -1,
eruption_tick: -1,
}
}
}
/// `BrushableBlockEntity`: the loot table is resolved on the first brush.
#[derive(Clone, Debug, Default)]
@@ -228,6 +300,8 @@ pub struct SignText {
/// `DyeColor` id.
pub color: u8,
pub glowing: bool,
/// The `click_event` in each line's component style, as saved.
pub click: [Option<Tag>; 4],
}
/// `SignBlockEntity` / `HangingSignBlockEntity` data.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
@@ -236,6 +310,8 @@ pub struct SignData {
pub front: SignText,
pub back: SignText,
pub waxed: bool,
/// `allow_op_features`: the sign's click events may run commands.
pub allow_op_features: bool,
}
/// `VibrationInfo`.
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -82,6 +82,9 @@ impl crate::light::LightWorld for ChunkMap {
fn set_section_light(&mut self, cx: i32, s: usize, cz: i32, light: crate::chunk::Light) {
self.columns.set_section_light(cx, s, cz, light);
}
fn layout(&self) -> crate::chunk::ColumnLayout {
self.columns.layout()
}
}
#[cfg(test)]
@@ -305,7 +305,7 @@ impl<'w> BlockCollisionWorld for SceneCollisions<'w, '_> {
/// From the section's palette, without reading its blocks; a palette
/// of the whole registry reads every cell.
fn may_reach_out(&mut self, chunk: Self::Chunk, section_y: i32) -> io::Result<bool> {
let blocks = usize::try_from(section_y - (crate::chunk::MIN_Y >> 4))
let blocks = usize::try_from(section_y - (chunk.min_y() >> 4))
.ok()
.and_then(|s| chunk.sections.get(s))
.map(|section| &section.blocks);
+51 -2
View File
@@ -3,7 +3,8 @@
//! from the data-driven `context_int_provider`/`context_float_provider` files.
pub use crate::chunk::block_entity::{
Banner, Beacon, BlockEntity, Brushable, Campfire, ChiseledBookshelf, Crafter, CreakingHeart,
EndGateway, Furnace, FurnaceKind, Jukebox, Lectern, Shelf, Skull, SkullSkin,
EndGateway, Furnace, FurnaceKind, Jukebox, Lectern, MOD_MENUS, ModBlockEntity, ModContainer,
PotentSulfur, Shelf, Skull, SkullSkin,
};
use crate::items::ItemStack;
use std::collections::HashMap;
@@ -306,6 +307,30 @@ impl BlockEntity {
if name == "minecraft:chiseled_bookshelf" {
return Some(Self::ChiseledBookshelf(Box::default()));
}
if name == "minecraft:structure_block" {
// The entity's mode is the state's (`StructureBlock.MODE`); the block's
// state is not known here, so a new one starts in its default, SAVE.
return Some(Self::StructureBlock(Box::new(
crate::structure_block::StructureBlock::new(
crate::structure_block::StructureMode::Save,
),
)));
}
if name == "minecraft:test_block" {
// The mode is the state's, which is not known here.
return Some(Self::TestBlock(Box::new(
crate::test_block::TestBlock::new(crate::test_block::TestBlockMode::Fail),
)));
}
if name == "minecraft:test_instance_block" {
return Some(Self::TestInstanceBlock(Box::default()));
}
if name == "minecraft:jigsaw" {
return Some(Self::JigsawBlock(Box::default()));
}
if name == "minecraft:potent_sulfur" {
return Some(Self::PotentSulfur(PotentSulfur::default()));
}
let size = match name {
"minecraft:chest" | "minecraft:trapped_chest" | "minecraft:barrel" => 27,
n if n.ends_with("copper_chest") => 27,
@@ -316,7 +341,7 @@ impl BlockEntity {
};
Some(Self::Chest(vec![None; size]))
}
pub fn id(&self) -> &'static str {
pub fn id(&self) -> &str {
match self {
Self::Chest(items) => match items.len() {
HOPPER_SLOTS => "minecraft:hopper",
@@ -349,6 +374,12 @@ impl BlockEntity {
Self::Campfire(_) => "minecraft:campfire",
Self::ChiseledBookshelf(_) => "minecraft:chiseled_bookshelf",
Self::Shelf(_) => "minecraft:shelf",
Self::Mod(m) => &m.id,
Self::PotentSulfur(_) => "minecraft:potent_sulfur",
Self::StructureBlock(_) => "minecraft:structure_block",
Self::JigsawBlock(_) => "minecraft:jigsaw",
Self::TestBlock(_) => "minecraft:test_block",
Self::TestInstanceBlock(_) => "minecraft:test_instance_block",
}
}
pub fn contents(&self) -> Vec<ItemStack> {
@@ -367,8 +398,14 @@ impl BlockEntity {
| Self::CommandBlock(_)
| Self::EndGateway(_)
| Self::Skull(_)
| Self::PotentSulfur(_)
| Self::StructureBlock(_)
| Self::JigsawBlock(_)
| Self::TestBlock(_)
| Self::TestInstanceBlock(_)
| Self::DaylightDetector
| Self::Brushable(_) => Vec::new(),
Self::Mod(m) => m.items.iter().flatten().cloned().collect(),
Self::Lectern(l) => l.book.iter().cloned().collect(),
Self::Sculk(_) => Vec::new(),
Self::Banner(_) => Vec::new(),
@@ -391,11 +428,17 @@ impl BlockEntity {
| Self::Banner(_)
| Self::EndGateway(_)
| Self::Skull(_)
| Self::PotentSulfur(_)
| Self::StructureBlock(_)
| Self::JigsawBlock(_)
| Self::TestBlock(_)
| Self::TestInstanceBlock(_)
| Self::DaylightDetector
| Self::Brushable(_) => &[],
Self::Crafter(c) => &c.items,
Self::ChiseledBookshelf(b) => &b.items,
Self::Shelf(b) => &b.items,
Self::Mod(m) => &m.items,
Self::CreakingHeart(_)
| Self::Spawner(_)
| Self::TrialSpawner(_)
@@ -419,11 +462,17 @@ impl BlockEntity {
| Self::Banner(_)
| Self::EndGateway(_)
| Self::Skull(_)
| Self::PotentSulfur(_)
| Self::StructureBlock(_)
| Self::JigsawBlock(_)
| Self::TestBlock(_)
| Self::TestInstanceBlock(_)
| Self::DaylightDetector
| Self::Brushable(_) => &mut [],
Self::Crafter(c) => &mut c.items,
Self::ChiseledBookshelf(b) => &mut b.items,
Self::Shelf(b) => &mut b.items,
Self::Mod(m) => &mut m.items,
Self::CreakingHeart(_)
| Self::Spawner(_)
| Self::TrialSpawner(_)
+75 -15
View File
@@ -296,8 +296,9 @@ fn memory() -> String {
}
}
/// The report's details: Horizon's and the machine's, then what the game set as it ran.
pub fn system_details() -> Vec<(String, String)> {
/// The report's details: Horizon's and the machine's, then what the game set as it ran. A dedicated
/// server renders nothing: it leaves out the render settings it read from options.txt.
pub fn system_details(side: &str) -> Vec<(String, String)> {
let mut out = vec![
("Horizon Version".to_owned(), version()),
(
@@ -308,18 +309,30 @@ pub fn system_details() -> Vec<(String, String)> {
("CPU".to_owned(), cpu()),
("Memory".to_owned(), memory()),
];
out.extend(details().into_iter().map(|(k, v)| (k.to_owned(), v)));
out.extend(
details()
.into_iter()
.filter(|(k, _)| side != "server" || *k != "Render Settings")
.map(|(k, v)| (k.to_owned(), v)),
);
out
}
fn thread_name() -> String {
/// The thread's name as vanilla's reports say it: the main thread is the client's "Render thread"
/// and the dedicated server's "Server thread".
fn thread_name(side: &str) -> String {
let current = std::thread::current();
match current.name() {
Some("main") | None if side == "server" => "Server thread".into(),
Some("main") | None => "Render thread".into(),
Some(name) => name.into(),
}
}
/// What starts the stderr line naming a saved report, the path follows: the Launch API's
/// promise to launchers (docs/api/launch.md), so it never changes within a line of that API.
pub const SAVED_LINE: &str = "Horizon: crash report saved to ";
/// Writes the report and tells where it is; the log gets the error too.
fn deliver(home: &Path, report: Report) -> Option<PathBuf> {
if REPORTED.swap(true, Ordering::SeqCst) {
@@ -343,7 +356,7 @@ fn deliver(home: &Path, report: Report) -> Option<PathBuf> {
crate::telemetry::keep_crash(home, &report);
match report.save(home) {
Ok(path) => {
eprintln!("Horizon: crash report saved to {}", path.display());
eprintln!("{SAVED_LINE}{}", path.display());
Some(path)
}
Err(e) => {
@@ -365,9 +378,9 @@ pub fn report_watchdog(home: &Path, side: &'static str, message: String) -> Opti
description: "Watching Server".into(),
error: format!("{message} (Watching Server)"),
stack: String::new(),
thread: thread_name(),
thread: thread_name(side),
side,
details: system_details(),
details: system_details(side),
log: crate::logging::recent_lines(),
},
)
@@ -476,15 +489,31 @@ 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(),
}
}
/// Whether `name` is one of the streaming pool's threads, vanilla's `Util` worker threads.
fn is_worker_thread(name: Option<&str>) -> bool {
name.is_some_and(|n| n.starts_with("horizon-worker-"))
}
/// `Util.onThreadException`: a panic on a worker thread is made a report titled "Exception on
/// worker thread", with the thread's name, as `BlockableEventLoop.relayDelayCrash` hands it to
/// the main thread.
pub fn worker_panic_report(info: &std::panic::PanicHookInfo, side: &'static str) -> Report {
Report {
description: "Exception on worker thread".into(),
..panic_report(info, side)
}
}
/// Reports panics: on the main thread a crash report (the panic then ends the game, exit code
/// 101); on other threads the error goes to the log, as vanilla's uncaught exception handler
/// 101); on a worker thread (`Util.onThreadException`) a crash report too, and the game ends
/// with it; on other threads the error goes to the log, as vanilla's uncaught exception handler
/// does. The default hook still prints the panic. A panic inside `contain` is only logged.
pub fn install(home: PathBuf, side: &'static str) {
let default = std::panic::take_hook();
@@ -496,10 +525,14 @@ pub fn install(home: PathBuf, side: &'static str) {
let main = std::thread::current().name() == Some("main");
if main {
deliver(&home, panic_report(info, side));
} else if is_worker_thread(std::thread::current().name()) {
deliver(&home, worker_panic_report(info, side));
// The crash ends the game, as the relayed report does on vanilla's main thread.
std::process::exit(101);
} else {
log::error!(
"Caught previously unhandled exception in thread {}: {}",
thread_name(),
thread_name(side),
panic_message(info)
);
}
@@ -548,9 +581,9 @@ pub fn report_error(
description: DebugCrash::MESSAGE.into(),
error: DebugCrash::MESSAGE.into(),
stack: debug.stack.clone(),
thread: thread_name(),
thread: thread_name(side),
side,
details: system_details(),
details: system_details(side),
log: crate::logging::recent_lines(),
},
);
@@ -568,9 +601,9 @@ pub fn report_error(
description: "Fatal error".into(),
error: message,
stack: String::new(),
thread: thread_name(),
thread: thread_name(side),
side,
details: system_details(),
details: system_details(side),
log: crate::logging::recent_lines(),
},
)
@@ -668,6 +701,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!(
@@ -701,6 +754,13 @@ mod tests {
assert!(first.contains("crash.rs"), "{}", debug.stack);
}
#[test]
fn worker_threads_are_the_streaming_pool() {
assert!(is_worker_thread(Some("horizon-worker-3")));
assert!(!is_worker_thread(Some("main")));
assert!(!is_worker_thread(None));
}
#[test]
fn a_panic_becomes_a_report() {
// A real panic through a hook, as the game's would be.
+123 -12
View File
@@ -223,6 +223,35 @@ pub fn install(dir: &Path, world: &Path, selected: &[String]) -> Result<(), Stri
let file = world.join("datapacks.json");
std::fs::write(&file, json.to_string()).map_err(|e| err(&file, e))
}
/// Re-Create World's packs from a saved world: a new folder holding a copy of
/// its `datapacks` (`CreateWorldScreen.createTempDataPackDirFromExistingWorld`; `None` when the
/// world has none), and the selection its `datapacks.json` makes, lowest
/// priority first (`WorldOpenFlows.recreateWorldData` keeps the world's `DataConfiguration`).
/// The built-in pack is selected unless the world turned it off.
pub fn for_recreate(world: &Path) -> (Option<PathBuf>, Vec<String>) {
let packs = discover(world);
let mut selected = enabled_ids(world, &packs);
let vanilla_off = std::fs::read(world.join("datapacks.json"))
.ok()
.and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok())
.and_then(|v| v["disabled"].as_array().cloned())
.is_some_and(|a| a.iter().any(|id| id.as_str() == Some("vanilla")));
if !vanilla_off {
selected.insert(0, "vanilla".into());
}
if packs.is_empty() {
return (None, selected);
}
let dir = temp_folder().ok().and_then(|dir| {
if copy(&world.join("datapacks"), &dir).is_ok() {
Some(dir)
} else {
let _ = std::fs::remove_dir_all(&dir);
None
}
});
(dir, selected)
}
/// A file, or a folder and everything in it.
fn copy(from: &Path, to: &Path) -> Result<(), String> {
let err = |e: std::io::Error| format!("{} -> {}: {e}", from.display(), to.display());
@@ -423,9 +452,31 @@ pub struct DataStore {
pub predicates: BTreeMap<String, serde_json::Value>,
/// `item_modifier` files, for `/item modify`.
pub item_modifiers: BTreeMap<String, serde_json::Value>,
/// The content tables' raw files from the packs after the built-in one:
/// `recipe/`, `loot_table/`, `advancement/` and `tags/…/` files, by
/// `<kind-dir>/<ns>:<path>`, each id's files in pack order. The tables
/// merge them over what they read from the resource root.
pub files: BTreeMap<String, Vec<Vec<u8>>>,
/// Functions that failed to parse (id, message), for logging.
pub errors: Vec<(String, String)>,
}
/// `<kind-dir>/<ns>:<path>` for a pack file the content tables read:
/// `recipe/`, `loot_table/`, `advancement/` or `tags/<sub>/`, where the id
/// keeps the sub path so a nested tag kind stays addressable.
fn content_id(file: &str) -> Option<String> {
let rest = file.strip_prefix("data/")?;
let (ns, rest) = rest.split_once('/')?;
let (kind, rest) = rest.split_once('/')?;
let kind = match kind {
"recipe" | "loot_table" | "advancement" => kind,
"tags" => {
let (sub, name) = rest.rsplit_once('/')?;
return Some(format!("tags/{sub}/{ns}:{}", name.strip_suffix(".json")?));
}
_ => return None,
};
Some(format!("{kind}/{ns}:{}", rest.strip_suffix(".json")?))
}
/// `ns:path` for a file `data/<ns>/<kind>/<path>.<ext>`.
fn resource_id(file: &str, kind: &str, ext: &str) -> Option<String> {
let rest = file.strip_prefix("data/")?;
@@ -440,7 +491,22 @@ impl DataStore {
pub fn load(packs: &[&DataPack]) -> Self {
let mut s = Self::default();
for pack in packs {
for f in pack.list_kinds(&["function", "item_modifier", "predicate", "tags/function"]) {
// `tags` walks the whole tags tree, so a `tags/function` file is
// listed by both kinds: handle every file once.
let mut seen = std::collections::BTreeSet::new();
let builtin = pack.id == "vanilla";
for f in pack.list_kinds(&[
"advancement",
"function",
"item_modifier",
"loot_table",
"predicate",
"recipe",
"tags",
]) {
if !seen.insert(f.clone()) {
continue;
}
if let Some(id) = resource_id(&f, "function", ".mcfunction")
&& let Some(bytes) = pack.read(&f)
{
@@ -470,6 +536,14 @@ impl DataStore {
&& let Ok(v) = serde_json::from_slice(&bytes)
{
s.item_modifiers.insert(id, v);
} else if !builtin
&& let Some(id) = content_id(&f)
&& let Some(bytes) = pack.read(&f)
{
// The built-in pack's content files are the resource
// root's own, which the tables read from disk; only the
// world's packs come through here.
s.files.entry(id).or_default().push(bytes);
}
}
}
@@ -573,6 +647,37 @@ pub fn enabled_ids(world: &Path, packs: &[DataPack]) -> Vec<String> {
enabled
}
/// The level stems of `packs` (`data/<namespace>/dimension/<path>.json`, lowest priority first,
/// as `enabled_ids` orders them): the id `namespace:path` and the parsed JSON, a later pack's
/// file for an id replacing an earlier one's. This is `context.datapackDimensions()` of
/// `WorldOpenFlows.loadWorldStem`.
pub fn dimension_stems(packs: &[&DataPack]) -> BTreeMap<String, serde_json::Value> {
let mut out = BTreeMap::new();
for pack in packs {
for file in pack.list_kinds(&["dimension"]) {
let Some(rest) = file.strip_prefix("data/") else {
continue;
};
let Some((namespace, rest)) = rest.split_once('/') else {
continue;
};
let Some(path) = rest
.strip_prefix("dimension/")
.and_then(|p| p.strip_suffix(".json"))
else {
continue;
};
if let Some(json) = pack
.read(&file)
.and_then(|b| serde_json::from_slice(&b).ok())
{
out.insert(format!("{namespace}:{path}"), json);
}
}
}
out
}
// ---- worldgen ----------------------------------------------------------------
/// The worldgen entries of `packs` (lowest priority first, the built-in pack
@@ -591,11 +696,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
}
@@ -668,10 +768,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;
@@ -861,9 +964,17 @@ mod tests {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, t).unwrap();
};
write("fns", "pack.mcmeta", r#"{"pack":{"pack_format":121}}"#);
write(
"fns",
"pack.mcmeta",
r#"{"pack":{"min_format":121,"max_format":121}}"#,
);
write("fns", "data/x/function/f.mcfunction", "say hi");
write("off", "pack.mcmeta", r#"{"pack":{"pack_format":121}}"#);
write(
"off",
"pack.mcmeta",
r#"{"pack":{"min_format":121,"max_format":121}}"#,
);
std::fs::create_dir_all(&dir).unwrap();
let mut zip = zip::ZipWriter::new(std::fs::File::create(dir.join("old.zip")).unwrap());
zip.start_file("pack.mcmeta", zip::write::SimpleFileOptions::default())
+77 -13
View File
@@ -6,7 +6,8 @@ use horizon_client_core::potions;
#[derive(Clone, Debug, PartialEq)]
pub struct EffectInstance {
/// Effect id without namespace (`speed`, `poison`, ...).
/// Effect id: vanilla's without namespace (`speed`, `poison`, ...), a
/// content mod's `<ns>:<path>`.
pub id: &'static str,
/// Remaining ticks; -1 is infinite.
pub duration: i32,
@@ -148,6 +149,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 {
@@ -162,6 +172,9 @@ pub enum OmenTick {
pub struct PlayerEffects {
pub list: Vec<EffectInstance>,
pub tick_count: i32,
/// How many times `add` added or updated an effect: each is one
/// `onEffectAdded` or `onEffectUpdated` (`EffectsChangedTrigger`).
pub changes: u32,
}
impl PlayerEffects {
pub fn get(&self, id: &str) -> Option<&EffectInstance> {
@@ -172,13 +185,17 @@ impl PlayerEffects {
}
/// `LivingEntity.addEffect`; returns whether anything changed.
pub fn add(&mut self, effect: EffectInstance) -> bool {
match self.list.iter_mut().find(|e| e.id == effect.id) {
let changed = match self.list.iter_mut().find(|e| e.id == effect.id) {
Some(existing) => existing.update(&effect),
None => {
self.list.push(effect);
true
}
};
if changed {
self.changes = self.changes.wrapping_add(1);
}
changed
}
pub fn clear(&mut self) {
self.list.clear();
@@ -203,7 +220,7 @@ impl PlayerEffects {
.iter()
.map(|e| {
(
format!("minecraft:{}", e.id),
potions::namespaced(e.id).to_owned(),
e.duration,
e.amplifier,
e.ambient,
@@ -308,19 +325,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)
@@ -463,6 +490,19 @@ pub fn effect_modifier(effect: &str) -> Option<(&'static str, &'static str, f64,
mod tests {
use super::*;
#[test]
fn adding_counts_a_change_only_when_the_effect_changed() {
let mut fx = PlayerEffects::default();
fx.add(EffectInstance::new("speed", 100, 0));
assert_eq!(fx.changes, 1);
// A longer duration updates the effect (`onEffectUpdated`).
fx.add(EffectInstance::new("speed", 200, 0));
assert_eq!(fx.changes, 2);
// A shorter one with the same amplifier is hidden by nothing: no change.
fx.add(EffectInstance::new("speed", 50, 0));
assert_eq!(fx.changes, 2);
}
#[test]
fn bad_omen_in_a_village_becomes_raid_omen_over_two_ticks() {
let mut fx = PlayerEffects::default();
@@ -486,6 +526,30 @@ mod tests {
assert_eq!(fx.get("speed").unwrap().duration, 49);
assert_eq!(fx.get("raid_omen").unwrap().amplifier, 1);
}
/// The only test here that registers mod effects (once per process).
#[test]
fn a_mod_effect_saves_under_its_namespace() {
potions::register_mod_effects([(
"ruby:glow".to_owned(),
0xFF0000,
potions::Category::Neutral,
)]);
let mut fx = PlayerEffects::default();
fx.add(EffectInstance::new(
potions::effect("ruby:glow").unwrap().id,
600,
1,
));
let saved = fx.saved();
assert_eq!(saved, [("ruby:glow".to_owned(), 600, 1, false, true)]);
assert_eq!(PlayerEffects::from_saved(&saved).list, fx.list);
// Without its mod the saved effect is dropped, as an unknown id is.
assert!(
PlayerEffects::from_saved(&[("gone:glow".into(), 600, 1, false, true)])
.list
.is_empty()
);
}
#[test]
fn raid_omen_acts_on_its_last_tick() {
let mut fx = PlayerEffects::default();
+29 -7
View File
@@ -23,6 +23,7 @@ struct Globals {
// Horizon Extras' dynamic lights: camera-relative position and level (dynamic_light.rs).
dynamic_lights: array<vec4<f32>, 16>,
sunrise_fan: array<vec4<f32>, 32>,
filtering: vec4<f32>,
}
@group(0) @binding(0) var<uniform> globals: Globals;
@group(0) @binding(1) var atlas: texture_2d<f32>;
@@ -53,7 +54,7 @@ struct Out {
@location(9) light_color: vec4<f32>,
@location(10) @interpolate(flat) weather_rect: vec4<f32>,
// The `GLINT_TEXTURING` sample for overlay 7 (item foil), or the model UV
// `ARMOR_ENTITY_GLINT_TEXTURING` scrolls for overlay 13 (armour).
// `ARMOR_ENTITY_GLINT_TEXTURING` scrolls for overlay 36 (armour).
@location(11) glint_uv: vec2<f32>,
// 1 drops the quad's back-facing pixels (Vertex::normal w = 1), like a culling render type.
@location(12) @interpolate(flat) cull: u32,
@@ -131,7 +132,7 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
@location(5) normal: vec4<f32>, @location(6) glint_uv: vec2<f32>) -> Out {
var out = entity_vertex(position, uv, overlay & 0xffffu, color, light, normal);
out.glint_uv = glint_uv;
out.tier = overlay >> 16u;
out.tier = (overlay >> 16u) & 0xffu;
return out;
}
// Retained bolt-local geometry, translated using the same f64 camera
@@ -224,6 +225,9 @@ fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>,
}
// CRUMBLING_UV_END
@fragment fn fs_entity(in: Out, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
return entity_color(in, front);
}
fn entity_color(in: Out, front: bool) -> vec4<f32> {
var uv = in.uv;
if in.overlay == 6u { uv.y = in.wrap.x + fract(in.uv.y) * in.wrap.y; }
if in.overlay == 11u || in.overlay == 12u {
@@ -254,8 +258,8 @@ fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>,
// Enchantment glint: item.fsh's GLINT path, sampled from
// enchanted_glint_item at the `GLINT_TEXTURING` UV.
color = glint_over(color, textureSample(glint_tex, glint_sampler, in.glint_uv));
} else if in.overlay == 13u {
// Enchanted armour: enchanted_glint_armor at the scrolling model UV.
} else if in.overlay == 36u {
// Enchanted armour (mobs::render::ARMOR_GLINT): enchanted_glint_armor at the scrolling model UV.
color = glint_over(
color,
textureSample(glint_armor_tex, glint_sampler, armor_glint_uv(in.glint_uv)),
@@ -271,6 +275,9 @@ fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>,
// rendertype_entity_shadow.fsh: the shadow texture times the vertex colour,
// then fog, with no alpha cutout, so a faint shadow in dim light still draws.
@fragment fn fs_entity_shadow(in: Out) -> @location(0) vec4<f32> {
return shadow_color(in);
}
fn shadow_color(in: Out) -> vec4<f32> {
var color = textureSample(atlas, atlas_sampler, in.uv) * in.color;
color *= in.light_color;
let f = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
@@ -306,21 +313,33 @@ fn celestial_color(in: Out) -> vec4<f32> {
@fragment fn fs_sky_celestial(in: Out) -> @location(0) vec4<f32> {
let color = celestial_color(in);
let added = color.rgb * color.a;
return vec4(added, smoothstep(0.1, 0.3, max(added.r, max(added.g, added.b))));
// The sky under the disc keeps alpha 1 less this: 0 for the brightest tier
// (the sun), terrain.wgsl's mark for the lower ones (the moon). Tier 0 is
// geometry that sets none (the End sky, the End flash): it stays brightest.
let tier = select(4u, min(in.tier, 4u), in.tier > 0u);
let mark = 1.0 - 0.7 * f32(4u - tier) / 4.0;
return vec4(added, mark * smoothstep(0.1, 0.3, max(added.r, max(added.g, added.b))));
}
// RenderPipelines.ENERGY_SWIRL (entity.fsh with EMISSIVE and NO_OVERLAY): the sprite, scrolled by
// the UV and repeated within itself, times the layer colour 0xFF808080, unlit; the pipeline adds
// it to the frame. The light bytes carry the sprite's origin and the colour bytes its size, both
// as breaking::stage_light packs them.
@fragment fn fs_energy_swirl(in: Out) -> @location(0) vec4<f32> {
fn energy_swirl_base(in: Out) -> vec4<f32> {
let bytes = round(in.color * 255.0);
let size = vec2(bytes.x + bytes.y * 256.0, bytes.z + bytes.w * 256.0) / 65535.0;
let color = textureSample(atlas, atlas_sampler, crumbling_uv(in.uv, in.wrap, size, textureDimensions(atlas)));
if color.a < 0.1 { discard; }
return vec4(color.rgb * (128.0 / 255.0), color.a);
}
fn energy_swirl_fog(in: Out) -> f32 {
let f = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w));
return vec4(mix(color.rgb * (128.0 / 255.0), globals.fog_color.rgb, f * globals.fog_color.a), color.a);
return f * globals.fog_color.a;
}
@fragment fn fs_energy_swirl(in: Out) -> @location(0) vec4<f32> {
let color = energy_swirl_base(in);
return vec4(mix(color.rgb, globals.fog_color.rgb, energy_swirl_fog(in)), color.a);
}
// RenderPipelines.WATER_MASK: depth only (the pipeline masks the colour off).
@@ -449,6 +468,9 @@ struct WeatherTime { values: vec4<f32> }
return out;
}
@fragment fn fs_weather(in: Out) -> @location(0) vec4<f32> {
return weather_color(in);
}
fn weather_color(in: Out) -> vec4<f32> {
// Repeat within this sprite, never across the atlas. The atlas uses nearest
// filtering and no mipmaps, so fractional UVs have no seam derivatives.
let texel = weather_texel(in.uv, in.weather_rect, textureDimensions(atlas));
+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));
File diff suppressed because it is too large. Load diff
@@ -147,7 +147,7 @@ impl SpriteAnimator {
group,
});
}
let Some(pipeline) = pipeline else {
let Some((pipeline, layout)) = pipeline else {
let groups = keys
.iter()
.zip(&counts)
@@ -193,7 +193,6 @@ impl SpriteAnimator {
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let layout = pipeline.get_bind_group_layout(0);
let groups = keys
.iter()
.zip(&counts)
@@ -364,11 +363,15 @@ impl SpriteAnimator {
}
}
/// `animate.wgsl`'s pipeline, unless the device fails to make it: an OpenGL
/// driver can list compute shaders among its extensions while wgpu writes
/// the GLSL version of its core version, which has none (Mesa's
/// `MESA_GL_VERSION_OVERRIDE`, older drivers).
fn pipeline(device: &wgpu::Device) -> Option<wgpu::ComputePipeline> {
/// `animate.wgsl`'s pipeline and the bind group layout of its group 0,
/// unless the device fails to make the pipeline: an OpenGL driver can list
/// compute shaders among its extensions while wgpu writes the GLSL version
/// of its core version, which has none (Mesa's `MESA_GL_VERSION_OVERRIDE`,
/// older drivers). A device lost between the scopes' pushes answers pipeline
/// creation with a silent placeholder and reports its invalidity only when
/// the layout is asked for — inside the scopes that error is caught, outside
/// them it is uncaptured and panics (#3866).
fn pipeline(device: &wgpu::Device) -> Option<(wgpu::ComputePipeline, wgpu::BindGroupLayout)> {
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
let internal = device.push_error_scope(wgpu::ErrorFilter::Internal);
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
@@ -383,9 +386,10 @@ fn pipeline(device: &wgpu::Device) -> Option<wgpu::ComputePipeline> {
compilation_options: Default::default(),
cache: None,
});
let layout = pipeline.get_bind_group_layout(0);
let internal = pollster::block_on(internal.pop());
match internal.or(pollster::block_on(validation.pop())) {
None => Some(pipeline),
None => Some((pipeline, layout)),
Some(e) => {
log::warn!("Horizon renderer: animated sprites drawn on the CPU ({e})");
None
@@ -491,10 +491,6 @@ impl Cull {
}
}
pub fn depth_test(&self) -> DepthTest {
self.depth_test
}
/// In the world pass after phase 1 (`Early`): draw the tested boxes
/// against its depth. Leaves the pass's pipeline and group 0 changed.
pub fn test(&self, pass: &mut wgpu::RenderPass<'_>) {
@@ -9,7 +9,24 @@ use super::Vertex;
/// `RenderPipelines.ENTITY_SHADOW`), translucent-layer particles (15, drawn
/// after translucent terrain: `SubmitNodeCollection.submitQuadParticleGroup`)
/// and translucent item quads (32, drawn without depth writes).
const MATERIALS: usize = 9;
///
/// With improved transparency on, quads tagged `OIT_ENTITY` are a tenth
/// material at the end: the render types vanilla gives an `OitPipelineSet`
/// (translucent, emissive, eyes and breeze wind entity models). Off, the tag is
/// ignored and they stay in the first one, in their order.
const MATERIALS: usize = 10;
/// A bit of `Vertex::layer`, above the overlay (low 16 bits) and the HDR tier
/// (bits 16 to 23): the quad's render type has an `OitPipelineSet`
/// (`RenderTypes.entityTranslucent`, `entityTranslucentEmissive`, `eyes`,
/// `breezeWind`). Only the improved transparency path reads it.
pub const OIT_ENTITY: u32 = 1 << 24;
/// Tags quads as drawn by a render type vanilla gives an `OitPipelineSet`
/// (`OIT_ENTITY`): improved transparency draws them in its stages.
pub fn mark_oit(quads: &mut [Vertex]) {
for vertex in quads {
vertex.layer |= OIT_ENTITY;
}
}
/// The overlay of depth-only quads that hide water (`RenderTypes.waterMask`).
pub const WATER_MASK_OVERLAY: u32 = 13;
/// The overlay of energy swirl quads (`RenderTypes.energySwirl`: the charged creeper's and the
@@ -30,7 +47,10 @@ pub const TRANSLUCENT_GLINT_ITEM_OVERLAY: u32 = 33;
pub const TEXT_POLYGON_OFFSET_OVERLAY: u32 = 35;
/// The material of a quad by its overlay (`MATERIALS` order).
fn material(layer: u32) -> usize {
fn material(layer: u32, oit: bool) -> usize {
if oit && layer & OIT_ENTITY != 0 {
return 9;
}
match layer {
TEXT_POLYGON_OFFSET_OVERLAY => 1,
11 => 2,
@@ -51,6 +71,10 @@ pub(super) struct EntityBatch {
// frame, keeping their storage.
runs: [Vec<(usize, usize)>; MATERIALS],
pub(super) combined: Vec<Vertex>,
/// Additive quads tagged `OIT_ENTITY` that the last `update` moved into the
/// last material (improved transparency on): the dragon's eyes, which share
/// the additive buffer with its rays.
pub(super) moved_additive: u32,
}
impl EntityBatch {
@@ -60,6 +84,7 @@ impl EntityBatch {
additive: &[Vertex],
hand: &[Vertex],
see_through: &[Vertex],
oit: bool,
) -> [u32; MATERIALS] {
let quads = entities.as_chunks::<4>().0;
for runs in &mut self.runs {
@@ -68,10 +93,10 @@ impl EntityBatch {
let mut counts = [0usize; MATERIALS];
let mut start = 0;
while start < quads.len() {
let kind = material(quads[start][0].layer);
let kind = material(quads[start][0].layer, oit);
let end = quads[start + 1..]
.iter()
.position(|quad| material(quad[0].layer) != kind)
.position(|quad| material(quad[0].layer, oit) != kind)
.map_or(quads.len(), |n| start + 1 + n);
self.runs[kind].push((start, end - start));
counts[kind] += end - start;
@@ -87,7 +112,32 @@ impl EntityBatch {
.extend_from_slice(&entities[first * 4..(first + count) * 4]);
}
}
self.combined.extend_from_slice(additive);
// `RenderTypes.eyes` is blended like the other entities, not added: with
// improved transparency on, tagged additive quads join the last
// material, drawn without the additive overlay and the baked shading.
self.moved_additive = 0;
if oit {
for quad in additive.as_chunks::<4>().0 {
if quad[0].layer & OIT_ENTITY != 0 {
self.combined.extend(quad.iter().map(|v| Vertex {
layer: v.layer & !0xffff,
normal: [0; 4],
..*v
}));
counts[9] += 1;
self.moved_additive += 1;
}
}
}
if self.moved_additive == 0 {
self.combined.extend_from_slice(additive);
} else {
for quad in additive.as_chunks::<4>().0 {
if quad[0].layer & OIT_ENTITY == 0 {
self.combined.extend_from_slice(quad);
}
}
}
self.combined.extend_from_slice(hand);
self.combined.extend_from_slice(see_through);
counts.map(|count| count as u32)
@@ -156,13 +206,78 @@ mod tests {
hand: &[Vertex],
) {
let (expected, counts) = baseline(entities, additive, hand);
assert_eq!(batch.update(entities, additive, hand, &[]), counts);
assert_eq!(batch.update(entities, additive, hand, &[], false), counts);
assert_eq!(
bytemuck::cast_slice::<_, u8>(&batch.combined),
bytemuck::cast_slice::<_, u8>(&expected)
);
}
#[test]
fn oit_entity_quads_are_a_last_material_only_when_on() {
let quad = |layer: u32, x: f32| {
(0..4).map(move |_| Vertex {
pos: [x, 0.0, 0.0],
layer,
..geometry(1, false)[0]
})
};
// Plain, tagged, plain, shadow, tagged with a hurt overlay.
let entities: Vec<Vertex> = [
(0, 0.0),
(OIT_ENTITY | 3 << 16, 1.0),
(1, 2.0),
(SHADOW_OVERLAY, 3.0),
(OIT_ENTITY | 1, 4.0),
]
.into_iter()
.flat_map(|(layer, x)| quad(layer, x))
.collect();
let mut batch = EntityBatch::default();
// Off: the tag changes nothing, the quads keep their place.
let counts = batch.update(&entities, &[], &[], &[], false);
assert_eq!(counts, [4, 0, 0, 0, 0, 0, 1, 0, 0, 0]);
let xs: Vec<f32> = batch.combined.iter().step_by(4).map(|v| v.pos[0]).collect();
assert_eq!(xs, [0.0, 1.0, 2.0, 4.0, 3.0]);
// On: the tagged quads come last, in order.
let counts = batch.update(&entities, &[], &[], &[], true);
assert_eq!(counts, [2, 0, 0, 0, 0, 0, 1, 0, 0, 2]);
let xs: Vec<f32> = batch.combined.iter().step_by(4).map(|v| v.pos[0]).collect();
assert_eq!(xs, [0.0, 2.0, 3.0, 1.0, 4.0]);
}
#[test]
fn tagged_additive_quads_join_the_last_material_only_when_on() {
let quad = |layer: u32, x: f32| {
(0..4).map(move |_| Vertex {
pos: [x, 0.0, 0.0],
layer,
normal: [1, 2, 3, 4],
..geometry(1, false)[0]
})
};
// The dragon's eyes (tagged, overlay 2) and a ray (plain).
let additive: Vec<Vertex> = [(2 | OIT_ENTITY | 3 << 16, 1.0), (2, 2.0)]
.into_iter()
.flat_map(|(layer, x)| quad(layer, x))
.collect();
let entities: Vec<Vertex> = quad(0, 0.0).collect();
let mut batch = EntityBatch::default();
// Off: untouched.
let counts = batch.update(&entities, &additive, &[], &[], false);
assert_eq!((counts[9], batch.moved_additive), (0, 0));
assert_eq!(batch.combined.len(), 12);
assert_eq!(batch.combined[4].layer, 2 | OIT_ENTITY | 3 << 16);
// On: the eyes are an entity quad without the additive overlay, the ray stays.
let counts = batch.update(&entities, &additive, &[], &[], true);
assert_eq!((counts[0], counts[9], batch.moved_additive), (1, 1, 1));
let xs: Vec<f32> = batch.combined.iter().step_by(4).map(|v| v.pos[0]).collect();
assert_eq!(xs, [0.0, 1.0, 2.0]);
assert_eq!(batch.combined[4].layer, OIT_ENTITY | 3 << 16);
assert_eq!(batch.combined[4].normal, [0; 4]);
assert_eq!(batch.combined[8].layer, 2);
}
#[test]
fn stable_material_order_tails_and_reuse() {
let mut batch = EntityBatch::default();
@@ -227,7 +342,7 @@ mod tests {
equivalent(&mut batch, &entities, &additive, &hand);
for _ in 0..50 {
black_box(baseline(black_box(&entities), &additive, &hand));
black_box(batch.update(black_box(&entities), &additive, &hand, &[]));
black_box(batch.update(black_box(&entities), &additive, &hand, &[], false));
black_box(&batch.combined);
}
let mut before = Vec::new();
@@ -240,7 +355,7 @@ mod tests {
before.push(start.elapsed().as_nanos() / 100);
let start = Instant::now();
for _ in 0..100 {
black_box(batch.update(black_box(&entities), &additive, &hand, &[]));
black_box(batch.update(black_box(&entities), &additive, &hand, &[], false));
black_box(&batch.combined);
}
after.push(start.elapsed().as_nanos() / 100);
+17 -7
View File
@@ -115,7 +115,7 @@ pub struct Flat {
/// (9,300 draws) the CPU saves 1.3 ms. The bar was 4,096 draws when they
/// were 2.2 to 2.5 per section; a section's draw count now depends on the
/// sides it shows the eye (`mesher::Sides`), so it counts sections.
const MIN_SECTIONS: usize = 1600;
pub const MIN_SECTIONS: usize = 1600;
/// Whether flat draws apply: the backend emulates multi-draw, and vertex
/// shaders can read storage buffers large enough for an index page.
@@ -126,7 +126,20 @@ pub fn wanted(adapter: &wgpu::Adapter, device: &wgpu::Device) -> bool {
Ok("1") => return supported(adapter, device),
_ => {}
}
adapter.get_info().backend == wgpu::Backend::Metal && supported(adapter, device)
let info = adapter.get_info();
(info.backend == wgpu::Backend::Metal || is_broken_intel_vulkan(&info))
&& supported(adapter, device)
}
/// Intel Gen11 (Ice Lake) ANV Vulkan driver has broken `first_instance` for instance-step
/// vertex attributes (#3529), causing terrain sections to be drawn with another section's origin
/// and flicker.
pub fn is_broken_intel_vulkan(info: &wgpu::AdapterInfo) -> bool {
info.backend == wgpu::Backend::Vulkan
&& info.vendor == 0x8086
&& (info.name.contains("ICL")
|| info.name.contains("Ice Lake")
|| (info.device >= 0x8a50 && info.device <= 0x8a71))
}
fn supported(adapter: &wgpu::Adapter, device: &wgpu::Device) -> bool {
@@ -254,6 +267,7 @@ impl Flat {
device: &wgpu::Device,
terrain_layout: &wgpu::BindGroupLayout,
page_vertices: u64,
min_sections: usize,
pipeline: impl Fn(
&wgpu::PipelineLayout,
&str,
@@ -357,11 +371,7 @@ impl Flat {
phase1_calls: 0,
solid_calls: 0,
shadow_calls: [0; 2],
min_sections: if std::env::var("HORIZON_FLAT_DRAWS").as_deref() == Ok("1") {
0
} else {
MIN_SECTIONS
},
min_sections,
range_align: (u64::from(device.limits().min_storage_buffer_offset_alignment) / 16)
.max(1),
}
@@ -0,0 +1,7 @@
//! Frame generation: FSR 3.1's frame interpolation will show a frame halfway between each
//! two real ones. So far this holds its optical flow; the interpolation and the setting
//! that runs them come next.
// Unused until the interpolation and the renderer use it.
#[allow(dead_code)]
mod optical_flow;
@@ -0,0 +1,653 @@
// This file is part of the FidelityFX SDK.
//
// Copyright (C) 2024 Advanced Micro Devices, Inc.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files(the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and /or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions :
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
//! FSR 3.1's optical flow (AMD FidelityFX SDK 1.1.4, `ffx_opticalflow*`, MIT), ported to
//! WGSL (`optical_flow.wgsl`): block motion between the previous frame's colour and this
//! one's, and scene change detection, for frame interpolation (`interpolation.rs`).
//!
//! As in `ffx_opticalflow.cpp`: luma at the display size and 6 halvings, ping-ponged
//! between frames; a histogram of the luma for scene change detection; then from the
//! coarsest of 7 levels down, a block search seeded by the level above, a 3x3 filter and
//! a scale to the next level. The ported passes differ only where WGSL needs it: the luma
//! levels are `R32Float` holding AMD's 8-bit integers, wave operations are workgroup
//! atomics, and the luma pyramid is one dispatch per level instead of one for all (SPD).
//! The luma textures aren't cleared on a reset (AMD's are): its first 6 frames report a
//! scene change and no motion, and rewrite them.
//!
//! The vectors point from a block of this frame to where it was in the previous one, in
//! display pixels: content moving right by 4 pixels gives (-4, 0).
/// The size in pixels of the square blocks the flow has one vector for.
pub const BLOCK_SIZE: u32 = 8;
/// `OpticalFlowMaxPyramidLevels`: the search's levels.
const LEVELS: usize = 7;
/// `HistogramBins * HistogramsPerDim²`: the scene change histograms.
const HISTOGRAM_WIDTH: u64 = 256 * 9;
/// `optical_flow.wgsl`'s `Params`.
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct Params {
display: [i32; 2],
level: u32,
level_count: u32,
frame_index: u32,
transfer_function: u32,
min_max_luminance: [f32; 2],
}
struct Pipelines {
prepare_luma: wgpu::ComputePipeline,
luma_pyramid: wgpu::ComputePipeline,
scd_histogram: wgpu::ComputePipeline,
scd_divergence: wgpu::ComputePipeline,
search: wgpu::ComputePipeline,
filter: wgpu::ComputePipeline,
scale: wgpu::ComputePipeline,
}
/// One frame parity's bindings: which luma pyramid is this frame's.
struct Frame {
/// This frame's luma at the display size, where `prepare_luma` writes.
luma0: wgpu::TextureView,
luma_pyramid: [wgpu::BindGroup; LEVELS - 1],
scd_histogram: wgpu::BindGroup,
search: [wgpu::BindGroup; LEVELS],
scale: [wgpu::BindGroup; LEVELS - 1],
}
pub struct OpticalFlow {
display: [u32; 2],
pipelines: Pipelines,
/// One per level, the level's constants (the passes outside the search use level 0's).
params: Vec<wgpu::Buffer>,
/// The bindings of the frames whose luma lands in pyramid 0, and in pyramid 1.
frames: [Frame; 2],
filter: [wgpu::BindGroup; LEVELS],
scd_divergence: wgpu::BindGroup,
scd_histogram: wgpu::Buffer,
scd_previous_histogram: wgpu::Buffer,
scd_temp: wgpu::Buffer,
scd_output: wgpu::Buffer,
vectors: wgpu::TextureView,
flow_sizes: [[u32; 2]; LEVELS],
/// `resourceFrameIndex` and `frameIndex`.
resource_frame: u32,
frame_index: u32,
first: bool,
}
fn texture(
device: &wgpu::Device,
label: &str,
size: [u32; 2],
format: wgpu::TextureFormat,
) -> wgpu::TextureView {
device
.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
size: wgpu::Extent3d {
width: size[0].max(1),
height: size[1].max(1),
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
})
.create_view(&Default::default())
}
fn buffer(device: &wgpu::Device, label: &str, size: u64) -> wgpu::Buffer {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some(label),
size,
usage: wgpu::BufferUsages::STORAGE
| wgpu::BufferUsages::COPY_DST
| wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
})
}
/// Bind `entries` (binding number, resource) for `pipeline`'s own layout.
fn bind(
device: &wgpu::Device,
pipeline: &wgpu::ComputePipeline,
entries: &[(u32, wgpu::BindingResource)],
) -> wgpu::BindGroup {
let entries: Vec<_> = entries
.iter()
.map(|(binding, resource)| wgpu::BindGroupEntry {
binding: *binding,
resource: resource.clone(),
})
.collect();
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Optical flow"),
layout: &pipeline.get_bind_group_layout(0),
entries: &entries,
})
}
fn view(v: &wgpu::TextureView) -> wgpu::BindingResource<'_> {
wgpu::BindingResource::TextureView(v)
}
fn whole(b: &wgpu::Buffer) -> wgpu::BindingResource<'_> {
b.as_entire_binding()
}
impl OpticalFlow {
/// For frames of `display` size.
pub fn new(device: &wgpu::Device, display: [u32; 2]) -> Self {
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Optical flow"),
source: wgpu::ShaderSource::Wgsl(include_str!("optical_flow.wgsl").into()),
});
let pipeline = |entry: &str| {
device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some(entry),
layout: None,
module: &module,
entry_point: Some(entry),
compilation_options: Default::default(),
cache: None,
})
};
let pipelines = Pipelines {
prepare_luma: pipeline("prepare_luma"),
luma_pyramid: pipeline("luma_pyramid"),
scd_histogram: pipeline("scd_histogram_pass"),
scd_divergence: pipeline("scd_divergence_pass"),
search: pipeline("search_pass"),
filter: pipeline("filter_flow"),
scale: pipeline("scale_pass"),
};
let params: Vec<_> = (0..LEVELS)
.map(|_| {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Optical flow constants"),
size: std::mem::size_of::<Params>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
})
})
.collect();
let luma: [Vec<wgpu::TextureView>; 2] = [0, 1].map(|_| {
(0..LEVELS)
.map(|k| {
let size = display.map(|v| (v >> k).max(1));
texture(
device,
"Optical flow luma",
size,
wgpu::TextureFormat::R32Float,
)
})
.collect()
});
let mut flow_sizes = [[0; 2]; LEVELS];
flow_sizes[0] = display.map(|v| v.div_ceil(BLOCK_SIZE));
for l in 1..LEVELS {
flow_sizes[l] = flow_sizes[l - 1].map(|v| v.div_ceil(2));
}
let flow = |label| -> Vec<wgpu::TextureView> {
flow_sizes
.iter()
.map(|&size| texture(device, label, size, wgpu::TextureFormat::Rg32Sint))
.collect()
};
// Per level: the scaled prediction from the level above, the search's vectors and
// the filtered ones (level 0's are the output).
let (predicted, searched, filtered) = (
flow("Optical flow prediction"),
flow("Optical flow search"),
flow("Optical flow"),
);
let scd_histogram = buffer(device, "Scene change histogram", HISTOGRAM_WIDTH * 4);
let scd_previous_histogram = buffer(
device,
"Scene change previous histogram",
HISTOGRAM_WIDTH * 4,
);
let scd_temp = buffer(device, "Scene change temp", 12);
let scd_output = buffer(device, "Scene change detection", 12);
let frame = |current: usize| {
let (cur, prev) = (&luma[current], &luma[1 - current]);
Frame {
luma0: cur[0].clone(),
luma_pyramid: std::array::from_fn(|k| {
bind(
device,
&pipelines.luma_pyramid,
&[(2, view(&cur[k + 1])), (3, view(&cur[k]))],
)
}),
scd_histogram: bind(
device,
&pipelines.scd_histogram,
&[
(0, whole(&params[0])),
(3, view(&cur[0])),
(7, whole(&scd_histogram)),
],
),
search: std::array::from_fn(|l| {
bind(
device,
&pipelines.search,
&[
(0, whole(&params[l])),
(3, view(&cur[l])),
(4, view(&prev[l])),
(5, view(&predicted[l])),
(6, view(&searched[l])),
(10, whole(&scd_output)),
],
)
}),
scale: std::array::from_fn(|i| {
let l = i + 1;
bind(
device,
&pipelines.scale,
&[
(0, whole(&params[l])),
(3, view(&cur[l])),
(4, view(&prev[l])),
(5, view(&filtered[l])),
(6, view(&predicted[l - 1])),
(10, whole(&scd_output)),
],
)
}),
}
};
let frames = [frame(0), frame(1)];
let filter = std::array::from_fn(|l| {
bind(
device,
&pipelines.filter,
&[(5, view(&searched[l])), (6, view(&filtered[l]))],
)
});
let scd_divergence = bind(
device,
&pipelines.scd_divergence,
&[
(7, whole(&scd_histogram)),
(8, whole(&scd_previous_histogram)),
(9, whole(&scd_temp)),
(10, whole(&scd_output)),
],
);
Self {
display,
pipelines,
params,
frames,
filter,
scd_divergence,
scd_histogram,
scd_previous_histogram,
scd_temp,
scd_output,
vectors: filtered[0].clone(),
flow_sizes,
resource_frame: 0,
frame_index: 0,
first: true,
}
}
/// The flow from the previous call's `color` to this one's. `color`: `display` size,
/// a float texture holding Horizon's frame (gamma-encoded RGB, the swapchain's
/// values: AMD's SDR back buffer). `reset`: the previous frame is unrelated (first
/// frame, resize, teleport).
pub fn dispatch(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
color: &wgpu::TextureView,
reset: bool,
) {
let reset = reset || self.first;
self.first = false;
self.frame_index = if reset { 0 } else { self.frame_index + 1 };
if reset {
for b in [
&self.scd_temp,
&self.scd_output,
&self.scd_histogram,
&self.scd_previous_histogram,
] {
encoder.clear_buffer(b, 0, None);
}
}
for (level, buffer) in self.params.iter().enumerate() {
let params = Params {
display: self.display.map(|v| v as i32),
level: level as u32,
level_count: LEVELS as u32,
frame_index: self.frame_index,
transfer_function: 0,
min_max_luminance: [0.0, 1.0],
};
queue.write_buffer(buffer, 0, bytemuck::bytes_of(&params));
}
let frame = &self.frames[(self.resource_frame & 1) as usize];
let prepare = bind(
device,
&self.pipelines.prepare_luma,
&[
(0, whole(&self.params[0])),
(1, view(color)),
(2, view(&frame.luma0)),
],
);
let p = &self.pipelines;
let [w, h] = self.display;
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("Optical flow"),
timestamp_writes: None,
});
let mut run = |pipeline: &wgpu::ComputePipeline, group: &wgpu::BindGroup, x, y, z| {
pass.set_pipeline(pipeline);
pass.set_bind_group(0, group, &[]);
pass.dispatch_workgroups(x, y, z);
};
run(
&p.prepare_luma,
&prepare,
(w.div_ceil(2)).div_ceil(16),
(h.div_ceil(2)).div_ceil(16),
1,
);
for (k, group) in frame.luma_pyramid.iter().enumerate() {
let size = self.display.map(|v| (v >> (k + 1)).max(1));
run(
&p.luma_pyramid,
group,
size[0].div_ceil(8),
size[1].div_ceil(8),
1,
);
}
run(
&p.scd_histogram,
&frame.scd_histogram,
((w / 4) / 3).div_ceil(32),
16,
9,
);
run(&p.scd_divergence, &self.scd_divergence, 9, 3, 1);
for level in (0..LEVELS).rev() {
let luma = self.display.map(|v| (v >> level).max(1));
run(
&p.search,
&frame.search[level],
(luma[0].div_ceil(4) * 16).div_ceil(64),
luma[1].div_ceil(16),
1,
);
let size = self.flow_sizes[level];
run(
&p.filter,
&self.filter[level],
size[0].div_ceil(16),
size[1].div_ceil(4),
1,
);
if level > 0 {
let next = self.flow_sizes[level - 1];
run(
&p.scale,
&frame.scale[level - 1],
next[0].div_ceil(4),
next[1].div_ceil(4),
1,
);
}
}
drop(pass);
self.resource_frame = (self.resource_frame + 1) % 16;
}
/// This frame's vectors, one per `BLOCK_SIZE` block: `Rg32Sint`, the display size
/// over `BLOCK_SIZE` rounded up,
/// holding the values FSR's `R16G16_SINT` optical flow vector texture holds.
pub fn vectors(&self) -> &wgpu::TextureView {
&self.vectors
}
/// FSR's scene change detection texture (`R32_UINT`, 3 texels) as a storage
/// buffer of 3 `u32`: element `i` holds texel `i`'s value.
pub fn scene_change(&self) -> &wgpu::Buffer {
&self.scd_output
}
}
#[cfg(test)]
pub(super) mod tests {
use super::*;
#[test]
fn shader_validates() {
let module = naga::front::wgsl::parse_str(include_str!("optical_flow.wgsl")).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::empty(),
)
.validate(&module)
.unwrap();
}
/// A device with WebGPU's default limits (what every GPU wgpu runs on has), or `None`
/// without a GPU.
pub fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
pollster::block_on(adapter.request_device(&Default::default())).ok()
}
/// Noise in 4x4 pixel cells, moved by `shift` pixels: what was at `p` is at `p + shift`.
/// A `seed` of 100 or more gives cells of one primary each.
pub fn pattern(size: [u32; 2], seed: u32, shift: [i32; 2]) -> Vec<u8> {
let mut pixels = Vec::with_capacity((size[0] * size[1] * 4) as usize);
for y in 0..size[1] as i32 {
for x in 0..size[0] as i32 {
let (cx, cy) = ((x - shift[0]).div_euclid(4), (y - shift[1]).div_euclid(4));
let mut h = (cx as u32).wrapping_mul(0x9e37_79b9)
^ (cy as u32).wrapping_mul(0x85eb_ca6b)
^ seed.wrapping_mul(0xc2b2_ae35);
h ^= h >> 15;
h = h.wrapping_mul(0x2c1b_3c6d);
h ^= h >> 12;
let v = (h & 0xff) as u8;
if seed >= 100 {
// Primaries: no two neighbours' colours point the same way.
let mut c = [0, 0, 0, 255];
c[(h >> 8) as usize % 3] = 128 + v / 2;
pixels.extend_from_slice(&c);
} else {
pixels.extend_from_slice(&[v, v.wrapping_add(40), v / 2, 255]);
}
}
}
pixels
}
pub fn upload(
device: &wgpu::Device,
queue: &wgpu::Queue,
size: [u32; 2],
pixels: &[u8],
) -> wgpu::TextureView {
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
queue.write_texture(
texture.as_image_copy(),
pixels,
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(size[0] * 4),
rows_per_image: None,
},
texture.size(),
);
texture.create_view(&Default::default())
}
/// `texture`'s texels (`texel` bytes each), row by row.
pub fn read(
device: &wgpu::Device,
queue: &wgpu::Queue,
texture: &wgpu::Texture,
texel: u32,
) -> Vec<u8> {
let size = texture.size();
let row = (size.width * texel).next_multiple_of(256);
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: u64::from(row * size.height),
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let mut encoder = device.create_command_encoder(&Default::default());
encoder.copy_texture_to_buffer(
texture.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &buffer,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(row),
rows_per_image: None,
},
},
size,
);
queue.submit([encoder.finish()]);
buffer.slice(..).map_async(wgpu::MapMode::Read, |_| {});
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let mapped = buffer.slice(..).get_mapped_range().unwrap();
mapped
.chunks(row as usize)
.flat_map(|r| r[..(size.width * texel) as usize].to_vec())
.collect()
}
fn run(
device: &wgpu::Device,
queue: &wgpu::Queue,
flow: &mut OpticalFlow,
color: &wgpu::TextureView,
reset: bool,
) {
let mut encoder = device.create_command_encoder(&Default::default());
flow.dispatch(device, queue, &mut encoder, color, reset);
queue.submit([encoder.finish()]);
}
fn vectors(device: &wgpu::Device, queue: &wgpu::Queue, flow: &OpticalFlow) -> Vec<[i32; 2]> {
read(device, queue, flow.vectors.texture(), 8)
.chunks(8)
.map(|c| {
[
i32::from_le_bytes(c[0..4].try_into().unwrap()),
i32::from_le_bytes(c[4..8].try_into().unwrap()),
]
})
.collect()
}
/// A frame moved right by 16 and down by 8: the blocks inside report where they were
/// (-16, -8), and nothing reads as a scene change. A much darker frame then does.
#[test]
fn finds_a_shift_and_a_scene_change() {
let Some((device, queue)) = device() else {
return;
};
let size = [512, 288];
let still = upload(&device, &queue, size, &pattern(size, 1, [0, 0]));
let moved = upload(&device, &queue, size, &pattern(size, 1, [16, 8]));
// Scene changes are told by the luma's histogram: a much darker frame.
let dark: Vec<u8> = pattern(size, 2, [0, 0]).iter().map(|v| v / 5).collect();
let other = upload(&device, &queue, size, &dark);
let mut flow = OpticalFlow::new(&device, size);
// The first 6 frames after a reset are scene changes.
run(&device, &queue, &mut flow, &still, true);
for _ in 0..6 {
run(&device, &queue, &mut flow, &still, false);
}
run(&device, &queue, &mut flow, &moved, false);
let [w, h] = flow.flow_sizes[0];
let found = vectors(&device, &queue, &flow);
let inside: Vec<_> = (4..h - 4)
.flat_map(|y| (4..w - 4).map(move |x| (x, y)))
.map(|(x, y)| found[(y * w + x) as usize])
.collect();
let right = inside.iter().filter(|v| **v == [-16, -8]).count();
assert!(
right * 10 >= inside.len() * 9,
"{right} of {} blocks: {:?}",
inside.len(),
&inside[..8]
);
let scd = |device: &wgpu::Device, queue: &wgpu::Queue, flow: &OpticalFlow| {
let mut encoder = device.create_command_encoder(&Default::default());
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 12,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
encoder.copy_buffer_to_buffer(flow.scene_change(), 0, &buffer, 0, 12);
queue.submit([encoder.finish()]);
buffer.slice(..).map_async(wgpu::MapMode::Read, |_| {});
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let bytes = buffer.slice(..).get_mapped_range().unwrap().to_vec();
u32::from_le_bytes(bytes[4..8].try_into().unwrap())
};
assert_eq!(scd(&device, &queue, &flow) & 0xf, 0);
run(&device, &queue, &mut flow, &other, false);
assert_eq!(scd(&device, &queue, &flow) & 1, 1);
assert!(vectors(&device, &queue, &flow).iter().all(|v| *v == [0, 0]));
}
}
@@ -0,0 +1,537 @@
// This file is part of the FidelityFX SDK.
//
// Copyright (C) 2024 Advanced Micro Devices, Inc.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files(the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and /or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions :
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
// FidelityFX SDK 1.1.4's optical flow (ffx_opticalflow_*.h, the GLSL path), ported to WGSL
// for Horizon. What differs from AMD's (optical_flow.rs lists why):
// - luma levels are r32float (AMD: r8ui); loads truncate to the same integers;
// - the luma pyramid is one pass per level instead of SPD's single pass;
// - wave sums and minimums are workgroup atomics, and the reductions AMD leaves to a
// wave's lockstep have barriers;
// - the scene change textures are storage buffers;
// - the search reads its prediction from one texture and writes another (rg32sint can't
// be read_write everywhere).
struct Params {
// Display size (cbOF.iInputLumaResolution).
display: vec2<i32>,
level: u32,
level_count: u32,
frame_index: u32,
transfer_function: u32,
min_max_luminance: vec2<f32>,
};
@group(0) @binding(0) var<uniform> params: Params;
@group(0) @binding(1) var input_color: texture_2d<f32>;
@group(0) @binding(2) var luma_out: texture_storage_2d<r32float, write>;
// The current frame's luma at this level, and the previous frame's.
@group(0) @binding(3) var luma_in: texture_2d<f32>;
@group(0) @binding(4) var luma_previous: texture_2d<f32>;
// Search: the prediction from the level above; filter: the search; scale: the filtered.
@group(0) @binding(5) var flow_in: texture_2d<i32>;
@group(0) @binding(6) var flow_out: texture_storage_2d<rg32sint, write>;
@group(0) @binding(7) var<storage, read_write> scd_histogram: array<atomic<u32>>;
@group(0) @binding(8) var<storage, read_write> scd_previous_histogram: array<f32>;
@group(0) @binding(9) var<storage, read_write> scd_temp: array<atomic<u32>, 3>;
@group(0) @binding(10) var<storage, read_write> scd_output: array<atomic<u32>, 3>;
const SCD_OUTPUT_SCENE_CHANGE_SLOT: u32 = 0u;
const SCD_OUTPUT_HISTORY_BITS_SLOT: u32 = 1u;
const SCD_OUTPUT_COMPLETED_WORKGROUPS_SLOT: u32 = 2u;
fn load_luma(t: texture_2d<f32>, p: vec2<i32>) -> u32 {
let size = vec2<i32>(textureDimensions(t));
if any(p < vec2(0)) || any(p >= size) {
return 0u;
}
return u32(textureLoad(t, p, 0).x);
}
fn load_flow(p: vec2<i32>) -> vec2<i32> {
let size = vec2<i32>(textureDimensions(flow_in));
if any(p < vec2(0)) || any(p >= size) {
return vec2(0);
}
return textureLoad(flow_in, p, 0).xy;
}
fn store_flow(p: vec2<i32>, v: vec2<i32>) {
let size = vec2<i32>(textureDimensions(flow_out));
if all(p >= vec2(0)) && all(p < size) {
textureStore(flow_out, p, vec4(v, 0, 0));
}
}
fn packed_luma(width: i32, x: i32, luma0: u32, luma1: u32, luma2: u32, luma3: u32) -> u32 {
var packed = luma0 | (luma1 << 8u) | (luma2 << 16u) | (luma3 << 24u);
if x < 0 {
let filler = packed & 0xffu;
if x <= -1 {
packed = (packed << 8u) | filler;
}
if x <= -2 {
packed = (packed << 8u) | filler;
}
if x <= -3 {
packed = (packed << 8u) | filler;
}
} else if x > width - 4 {
let filler = packed & 0xff000000u;
if x >= width - 3 {
packed = (packed >> 8u) | filler;
}
if x >= width - 2 {
packed = (packed >> 8u) | filler;
}
if x >= width - 1 {
packed = (packed >> 8u) | filler;
}
}
return packed;
}
fn load_packed(t: texture_2d<f32>, p: vec2<i32>) -> u32 {
let width = params.display.x >> params.level;
let height = params.display.y >> params.level;
let q = vec2(clamp(p.x, 0, width - 4), clamp(p.y, 0, height - 1));
return packed_luma(
width,
p.x,
load_luma(t, q),
load_luma(t, q + vec2(1, 0)),
load_luma(t, q + vec2(2, 0)),
load_luma(t, q + vec2(3, 0)),
);
}
fn sad(a: u32, b: u32) -> u32 {
return u32(abs(i32(a & 0xffu) - i32(b & 0xffu))
+ abs(i32((a >> 8u) & 0xffu) - i32((b >> 8u) & 0xffu))
+ abs(i32((a >> 16u) & 0xffu) - i32((b >> 16u) & 0xffu))
+ abs(i32((a >> 24u) & 0xffu) - i32((b >> 24u) & 0xffu)));
}
fn qsad(a0_in: u32, a1_in: u32, b: u32) -> vec4<u32> {
var a0 = a0_in;
var a1 = a1_in;
var s: vec4<u32>;
s.x = sad(a0, b);
a0 = (a0 >> 8u) | ((a1 & 0xffu) << 24u);
a1 = a1 >> 8u;
s.y = sad(a0, b);
a0 = (a0 >> 8u) | ((a1 & 0xffu) << 24u);
a1 = a1 >> 8u;
s.z = sad(a0, b);
a0 = (a0 >> 8u) | ((a1 & 0xffu) << 24u);
s.w = sad(a0, b);
return s;
}
fn is_scene_changed() -> bool {
if params.frame_index <= 5u {
return true;
}
return (atomicLoad(&scd_output[SCD_OUTPUT_HISTORY_BITS_SLOT]) & 0xfu) != 0u;
}
// ffx_opticalflow_prepare_luma.h, for an SDR back buffer (transfer function 0: Horizon's
// frames); the HDR transfer functions follow AMD's.
fn luminance_to_perceived(luminance: f32) -> f32 {
var perceived: f32;
if luminance <= 216.0 / 24389.0 {
perceived = luminance * (24389.0 / 27.0);
} else {
perceived = pow(luminance, 1.0 / 3.0) * 116.0 - 16.0;
}
return perceived * 0.01;
}
fn linear_from_pq(x: vec3<f32>) -> vec3<f32> {
let p = pow(x, vec3(0.0126833));
return pow(clamp(p - vec3(0.835938), vec3(0.0), vec3(1.0)) / (vec3(18.8516) - 18.6875 * p), vec3(6.27739));
}
@compute @workgroup_size(16, 16)
fn prepare_luma(@builtin(global_invocation_id) gid: vec3<u32>) {
for (var y = 0; y < 2; y++) {
for (var x = 0; x < 2; x++) {
let pos = vec2<i32>(gid.xy) * 2 + vec2(x, y);
if any(pos >= params.display) {
continue;
}
let c = textureLoad(input_color, pos, 0).rgb;
var y_lum = 0.0;
if params.transfer_function == 0u {
y_lum = dot(c, vec3(0.2126, 0.7152, 0.0722));
} else if params.transfer_function == 1u {
let linear = linear_from_pq(c) * (10000.0 / params.min_max_luminance.y);
y_lum = luminance_to_perceived(dot(linear, vec3(0.2627, 0.678, 0.0593)));
} else if params.transfer_function == 2u {
let mn = params.min_max_luminance.x;
let mx = params.min_max_luminance.y;
let linear = (c - vec3(mn / 80.0)) / vec3((mx - mn) / 80.0);
y_lum = luminance_to_perceived(dot(linear, vec3(0.2126, 0.7152, 0.0722)));
}
textureStore(luma_out, pos, vec4(f32(u32(y_lum * 255.0)), 0.0, 0.0, 0.0));
}
}
}
// ffx_opticalflow_compute_luminance_pyramid.h: SPD's average of 2x2, a level per pass.
@compute @workgroup_size(8, 8)
fn luma_pyramid(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if any(p >= vec2<i32>(textureDimensions(luma_out))) {
return;
}
let s = p * 2;
let v = (textureLoad(luma_in, s, 0).x + textureLoad(luma_in, s + vec2(1, 0), 0).x
+ textureLoad(luma_in, s + vec2(0, 1), 0).x + textureLoad(luma_in, s + vec2(1, 1), 0).x) * 0.25;
textureStore(luma_out, p, vec4(v, 0.0, 0.0, 0.0));
}
// ffx_opticalflow_generate_scd_histogram.h
const LBASE: u32 = 10u;
var<workgroup> scd_buffer: array<atomic<u32>, 2560>;
@compute @workgroup_size(32, 8)
fn scd_histogram_pass(
@builtin(global_invocation_id) gid: vec3<u32>,
@builtin(local_invocation_index) local_index: u32,
) {
let histograms_per_dim = 3u;
let div = vec2<u32>(params.display) / histograms_per_dim;
let nx = gid.z % histograms_per_dim;
let ny = gid.z / histograms_per_dim;
let start = div * vec2(nx, ny);
let stop = start + div;
let offset = local_index * LBASE;
for (var i = 0u; i < LBASE; i++) {
atomicStore(&scd_buffer[offset + i], 0u);
}
workgroupBarrier();
var coord = vec2<i32>(i32(start.x + 4u * gid.x), i32(start.y + gid.y));
if u32(coord.x) < stop.x {
for (; u32(coord.y) < stop.y; coord.y += 128) {
let color = vec4(
load_luma(luma_in, coord),
load_luma(luma_in, coord + vec2(1, 0)),
load_luma(luma_in, coord + vec2(2, 0)),
load_luma(luma_in, coord + vec2(3, 0)),
) * LBASE;
let scrambling = local_index % LBASE;
atomicAdd(&scd_buffer[color.x + scrambling], 1u);
atomicAdd(&scd_buffer[color.y + scrambling], 1u);
atomicAdd(&scd_buffer[color.z + scrambling], 1u);
atomicAdd(&scd_buffer[color.w + scrambling], 1u);
}
}
workgroupBarrier();
var value = 0u;
for (var i = 0u; i < LBASE; i++) {
value += atomicLoad(&scd_buffer[offset + i]);
}
let histogram_start = 256u * gid.z;
atomicAdd(&scd_histogram[histogram_start + local_index], value);
}
// ffx_opticalflow_compute_scd_divergence.h
var<workgroup> source_histogram: array<f32, 256>;
var<workgroup> filtered_histogram: array<f32, 256>;
var<workgroup> temp_buffer: array<f32, 256>;
var<workgroup> temp_buffer2: array<vec2<f32>, 256>;
@compute @workgroup_size(256)
fn scd_divergence_pass(
@builtin(global_invocation_id) gid: vec3<u32>,
@builtin(local_invocation_index) local_index: u32,
) {
let factor = 1000000.0;
let where_to_stop = 3u * 9u - 1u;
let histogram_count = 9.0;
let kernel = array(0.0088122291, 0.027143577, 0.065114059, 0.12164907, 0.17699835, 0.20056541);
let li = i32(local_index);
source_histogram[li] = f32(atomicLoad(&scd_histogram[gid.x]));
workgroupBarrier();
let read = li - 5;
var val = 0.0;
val += kernel[0] * source_histogram[clamp(read + 0, 0, 255)];
val += kernel[1] * source_histogram[clamp(read + 1, 0, 255)];
val += kernel[2] * source_histogram[clamp(read + 2, 0, 255)];
val += kernel[3] * source_histogram[clamp(read + 3, 0, 255)];
val += kernel[4] * source_histogram[clamp(read + 4, 0, 255)];
val += kernel[5] * source_histogram[clamp(read + 5, 0, 255)];
val += kernel[4] * source_histogram[clamp(read + 6, 0, 255)];
val += kernel[3] * source_histogram[clamp(read + 7, 0, 255)];
val += kernel[2] * source_histogram[clamp(read + 8, 0, 255)];
val += kernel[1] * source_histogram[clamp(read + 9, 0, 255)];
val += kernel[0] * source_histogram[clamp(read + 10, 0, 255)];
val += 1.0;
if gid.y == 0u {
if li == 0 {
filtered_histogram[255] = 1.0;
} else {
filtered_histogram[li - 1] = val;
}
} else if gid.y == 1u {
filtered_histogram[li] = val;
} else if gid.y == 2u {
if li == 255 {
filtered_histogram[0] = 1.0;
} else {
filtered_histogram[li + 1] = val;
}
}
workgroupBarrier();
temp_buffer[li] = filtered_histogram[li];
workgroupBarrier();
for (var stride = 128; stride > 0; stride >>= 1u) {
if li < stride {
temp_buffer[li] += temp_buffer[li + stride];
}
workgroupBarrier();
}
filtered_histogram[li] /= temp_buffer[0];
let current = filtered_histogram[li];
let previous = scd_previous_histogram[gid.x];
temp_buffer2[li] = vec2(current * log(current / previous), previous * log(previous / current));
workgroupBarrier();
for (var stride = 128; stride > 0; stride >>= 1u) {
if li < stride {
temp_buffer2[li] += temp_buffer2[li + stride];
}
workgroupBarrier();
}
if li == 0 {
let sum = temp_buffer2[0];
let res_float = 1.0 - exp(-(abs(sum.x) + abs(sum.y)));
let res_uint = u32((res_float / histogram_count) * factor);
atomicAdd(&scd_temp[gid.y], res_uint);
let finished = atomicAdd(&scd_output[SCD_OUTPUT_COMPLETED_WORKGROUPS_SLOT], 1u);
if finished == where_to_stop {
let res0 = atomicLoad(&scd_temp[0]);
let res1 = atomicLoad(&scd_temp[1]);
let res2 = atomicLoad(&scd_temp[2]);
let scene_change = f32(min(res0, min(res1, res2))) / factor;
var history = atomicLoad(&scd_output[SCD_OUTPUT_HISTORY_BITS_SLOT]) << 1u;
if scene_change > 0.45 {
history |= 1u;
}
atomicStore(&scd_output[SCD_OUTPUT_SCENE_CHANGE_SLOT], bitcast<u32>(scene_change));
atomicStore(&scd_output[SCD_OUTPUT_HISTORY_BITS_SLOT], history);
atomicStore(&scd_output[SCD_OUTPUT_COMPLETED_WORKGROUPS_SLOT], 0u);
atomicStore(&scd_temp[0], 0u);
atomicStore(&scd_temp[1], 0u);
atomicStore(&scd_temp[2], 0u);
}
}
if gid.y == 1u {
scd_previous_histogram[gid.x] = current;
atomicStore(&scd_histogram[gid.x], 0u);
}
}
// ffx_opticalflow_compute_optical_flow_v5.h: block matching of 8x8 blocks over +-8 pixels,
// 2x2 blocks per workgroup of 64.
var<workgroup> pixels: array<array<u32, 2>, 8>;
// SearchBufferSizeY (24) rows of SearchBufferSizeX (6) packed lumas.
var<workgroup> search_buffer: array<u32, 144>;
var<workgroup> sad_map: array<array<array<u32, 5>, 16>, 4>;
var<workgroup> wave_min: atomic<u32>;
var<workgroup> wave_sum: atomic<u32>;
var<workgroup> scene_changed: u32;
fn abs_2(v: vec2<i32>) -> vec2<u32> {
let mask = v >> vec2(31u);
return vec2<u32>((v + mask) ^ mask);
}
// FFX_OPTICALFLOW_FIX_TOP_LEFT_BIAS: the nearest of equal SADs wins.
fn encode_search_coord(coord: vec2<i32>) -> u32 {
let a = abs_2(coord - 8);
return (a.y << 12u) | (a.x << 8u) | (u32(coord.y) << 4u) | u32(coord.x);
}
fn decode_search_coord(bits: u32) -> vec2<i32> {
return vec2(i32(bits & 0xfu) - 8, i32((bits >> 4u) & 0xfu) - 8);
}
fn calculate_qsads2(search_id: vec2<i32>) -> vec4<u32> {
var s = vec4(0u);
for (var dy = 0; dy < 8; dy++) {
let row = (search_id.y + dy) * 6;
let a0 = search_buffer[row + search_id.x];
let a1 = search_buffer[row + search_id.x + 1];
let a2 = search_buffer[row + search_id.x + 2];
s += qsad(a0, a1, pixels[dy][0]);
s += qsad(a1, a2, pixels[dy][1]);
}
return s;
}
@compute @workgroup_size(64)
fn search_pass(
@builtin(workgroup_id) group_id: vec3<u32>,
@builtin(local_invocation_index) local_index: u32,
) {
let li = i32(local_index);
let search_id = vec2(li & 3, (li >> 2u) & 15);
let lane_block = ((li >> 1u) & 1) | (((li >> 5u) & 1) << 1u);
let group = vec2<i32>(group_id.xy);
let px = (group << vec2(4u)) + search_id * vec2(4, 1);
if local_index == 0u {
scene_changed = u32(is_scene_changed());
}
if workgroupUniformLoad(&scene_changed) != 0u {
if (search_id.y & 7) == 0 && (search_id.x & 1) == 0 {
store_flow(px >> vec2(3u), vec2(0));
}
return;
}
let use_prediction = params.level != params.level_count - 1u;
let packed = load_packed(luma_in, px);
let sad_4blocks = sad(packed, load_packed(luma_previous, px));
let of_group = group << vec2(1u);
let pixel_group = group << vec2(4u);
for (var by = 0; by < 2; by++) {
for (var bx = 0; bx < 2; bx++) {
let block = vec2(bx, by);
var current = vec2(0);
if use_prediction {
current = load_flow(of_group + block);
}
if lane_block == by * 2 + bx {
pixels[search_id.y & 7][search_id.x & 1] = packed;
}
// LoadSearchBuffer
let base = pixel_group + block * 8 + current - vec2(8, 8);
for (var id = li; id < 144; id += 64) {
search_buffer[id] = load_packed(luma_previous, vec2(base.x + (id % 6) * 4, base.y + id / 6));
}
if local_index == 0u {
atomicStore(&wave_min, 0xffffffffu);
atomicStore(&wave_sum, 0u);
}
workgroupBarrier();
let q = calculate_qsads2(search_id);
// PrepareSadMap
let sx = search_id.x;
let sy = search_id.y;
sad_map[0][sy][sx] = (q.x << 16u) | encode_search_coord(vec2(sx * 4 + 0, sy));
sad_map[1][sy][sx] = (q.y << 16u) | encode_search_coord(vec2(sx * 4 + 1, sy));
sad_map[2][sy][sx] = (q.z << 16u) | encode_search_coord(vec2(sx * 4 + 2, sy));
sad_map[3][sy][sx] = (q.w << 16u) | encode_search_coord(vec2(sx * 4 + 3, sy));
workgroupBarrier();
// SadMapMinReduction256 and BlockSad64 (FFX_LOCAL_SEARCH_FALLBACK)
let min01 = min(sad_map[0][sy][sx], sad_map[1][sy][sx]);
let min23 = min(sad_map[2][sy][sx], sad_map[3][sy][sx]);
atomicMin(&wave_min, min(min01, min23));
atomicAdd(&wave_sum, select(0u, sad_4blocks, lane_block == bx + by * 2));
workgroupBarrier();
let min_sad = atomicLoad(&wave_min);
let block_sad_sum = atomicLoad(&wave_sum);
var v = current + decode_search_coord(min_sad);
if params.level == 0u && block_sad_sum <= (min_sad >> 16u) {
v = vec2(0);
}
if local_index == 0u {
store_flow(of_group + block, v);
}
// The next block reuses the shared buffers and counters.
workgroupBarrier();
}
}
}
// ffx_opticalflow_filter_optical_flow_v5.h: the vector of the 3x3 nearest to all the others.
@compute @workgroup_size(16, 4)
fn filter_flow(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
var mv: array<vec2<i32>, 9>;
var idx = 0;
for (var xx = -1; xx < 2; xx++) {
for (var yy = -1; yy < 2; yy++) {
mv[idx] = load_flow(p + vec2(xx, yy));
idx++;
}
}
var ret = 0xffffffffu;
for (var i = 0; i < 9; i++) {
var tmp = 0u;
for (var j = 0; j < 9; j++) {
let delta = mv[i] - mv[j];
tmp = u32(delta.x * delta.x) + (u32(delta.y * delta.y) + tmp);
}
ret = min((tmp << 4u) | u32(i), ret);
}
store_flow(p, mv[ret & 0xfu]);
}
// ffx_opticalflow_scale_optical_flow_advanced_v5.h: the next level's vectors, each the
// best of its 4 nearest at this level.
var<workgroup> nearest_vectors: array<array<array<vec2<i32>, 4>, 4>, 4>;
var<workgroup> local_region: array<array<array<u32, 4>, 4>, 4>;
var<workgroup> sads: array<array<array<u32, 4>, 4>, 4>;
@compute @workgroup_size(4, 4, 4)
fn scale_pass(
@builtin(global_invocation_id) gid: vec3<u32>,
@builtin(local_invocation_id) lid: vec3<u32>,
@builtin(local_invocation_index) local_index: u32,
) {
if local_index == 0u {
scene_changed = u32(is_scene_changed());
}
if workgroupUniformLoad(&scene_changed) != 0u {
store_flow(vec2<i32>(gid.xy), vec2(0));
return;
}
let x_offset = i32(lid.z % 2u) - 1 + i32(gid.x % 2u);
let y_offset = i32(lid.z / 2u) - 1 + i32(gid.y % 2u);
let src = vec2(i32(gid.x / 2u) + x_offset, i32(gid.y / 2u) + y_offset);
let nearest = load_flow(src);
nearest_vectors[lid.z][lid.y][lid.x] = nearest * 2;
let max_y = 4u;
for (var n = lid.z; n < max_y; n += 4u) {
let luma_pos = vec2(i32(gid.x) * 4, i32(gid.y * max_y + n));
local_region[n][lid.y][lid.x] = load_packed(luma_in, luma_pos);
}
workgroupBarrier();
var s = 0u;
for (var n = 0u; n < max_y; n++) {
let luma_pos = vec2(i32(gid.x) * 4, i32(gid.y * max_y + n)) + nearest;
s += sad(local_region[n][lid.y][lid.x], load_packed(luma_previous, luma_pos));
}
sads[lid.z][lid.y][lid.x] = s;
workgroupBarrier();
if lid.z == 0u {
var best_sad = 0xffffffffu;
var best = 0u;
for (var n = 0u; n < 4u; n++) {
if sads[n][lid.y][lid.x] < best_sad {
best_sad = sads[n][lid.y][lid.x];
best = n;
}
}
store_flow(vec2<i32>(gid.xy), nearest_vectors[best][lid.y][lid.x]);
}
}
+113 -37
View File
@@ -102,6 +102,10 @@ pub struct Hdr {
/// output keeps the hand at the world's brightness.
hand_mark: wgpu::RenderPipeline,
params: wgpu::Buffer,
/// The same pass drawing HDR10 for a screenshot (`capture`), with its own parameters,
/// so it can differ from the swapchain's in one frame.
capture_pipeline: wgpu::RenderPipeline,
capture_params: wgpu::Buffer,
pub output: Output,
/// The frame's format: what every other pipeline draws in.
format: wgpu::TextureFormat,
@@ -156,31 +160,33 @@ impl Hdr {
bind_group_layouts: &[Some(&layout)],
..Default::default()
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Horizon HDR"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_hdr"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_hdr"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: output.format(),
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
});
let pipeline_for = |format| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Horizon HDR"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_hdr"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_hdr"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
})
};
let alpha_pass = |label, blend| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(label),
@@ -223,18 +229,22 @@ impl Hdr {
alpha: scale_alpha,
}),
);
let params = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Horizon HDR parameters"),
size: 80,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let params = || {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Horizon HDR parameters"),
size: 80,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
})
};
Self {
layout,
pipeline,
pipeline: pipeline_for(output.format()),
clear_alpha,
hand_mark,
params,
params: params(),
capture_pipeline: pipeline_for(Output::Pq.format()),
capture_params: params(),
output,
format,
targets: None,
@@ -375,6 +385,64 @@ impl Hdr {
let Some(t) = &self.targets else {
return;
};
queue.write_buffer(
&self.params,
0,
bytemuck::cast_slice(&self.params(world, self.output == Output::Pq)),
);
Self::draw(encoder, &self.pipeline, &t.group, target);
}
/// The frame `present` writes, as HDR10 (BT.2020, PQ, 10 bits a channel) whatever
/// the swapchain is, in a texture of its own for a screenshot (`Rgb10a2Unorm`).
pub fn capture(
&self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
world: bool,
) -> Option<wgpu::Texture> {
let t = self.targets.as_ref()?;
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Horizon HDR screenshot"),
size: t.frame.size(),
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: Output::Pq.format(),
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = |t: &wgpu::Texture| t.create_view(&Default::default());
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Horizon HDR screenshot bindings"),
layout: &self.layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&view(&t.frame)),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(&view(&t.world)),
},
wgpu::BindGroupEntry {
binding: 2,
resource: self.capture_params.as_entire_binding(),
},
],
});
queue.write_buffer(
&self.capture_params,
0,
bytemuck::cast_slice(&self.params(world, true)),
);
Self::draw(encoder, &self.capture_pipeline, &group, &texture);
Some(texture)
}
/// hdr.wgsl's `Params`.
fn params(&self, world: bool, pq: bool) -> [f32; 20] {
let paper = self.paper_white.max(1.0);
let mut params = [0.0; 20];
params[..5].copy_from_slice(&[
@@ -382,7 +450,7 @@ impl Hdr {
(self.peak / paper).max(1.0),
f32::from(u8::from(world)),
f32::from(u8::from(self.debug)),
f32::from(u8::from(self.output == Output::Pq)),
f32::from(u8::from(pq)),
]);
params[5] = self.black.clamp(0.0, paper);
params[6] = self.ui.clamp(0.25, 1.0);
@@ -391,7 +459,15 @@ impl Hdr {
params[12 + i] = t.start;
params[16 + i] = t.full;
}
queue.write_buffer(&self.params, 0, bytemuck::cast_slice(&params));
params
}
fn draw(
encoder: &mut wgpu::CommandEncoder,
pipeline: &wgpu::RenderPipeline,
group: &wgpu::BindGroup,
target: &wgpu::Texture,
) {
let view = target.create_view(&Default::default());
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: &view,
@@ -407,8 +483,8 @@ impl Hdr {
color_attachments: &attachments,
..Default::default()
});
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &t.group, &[]);
pass.set_pipeline(pipeline);
pass.set_bind_group(0, group, &[]);
pass.draw(0..3, 0..1);
}
}
+305 -26
View File
@@ -3,7 +3,7 @@
use super::{Frustum, Renderer, SectionGpu};
use crate::{
fast_hash::{FxHashMap, FxHashSet},
lod::TileKey,
lod::{MAX_LEVEL, TileKey},
mesher::SectionMesh,
};
use glam::{DVec3, Vec3};
@@ -45,6 +45,47 @@ impl Pending {
pub(super) struct Tile {
pub mesh: SectionGpu,
bounds: Bounds,
/// When it began to fade in; none while the rest of its job uploads.
shown: Option<Instant>,
/// When its replacements began to fade in; it goes once they have.
dying: Option<Instant>,
/// Dying with nothing to replace it (out of range): it fades out.
alone: bool,
/// Its mesh before near chunks took some of its columns, drawn in its
/// place until they have faded in (from the job's end, the instant), so
/// no hole shows through them.
previous: Option<(SectionGpu, Option<Instant>)>,
}
/// How long a tile takes to fade in over the ones it replaces.
pub(super) const FADE: Duration = Duration::from_millis(600);
/// A tile or section fading in: 0 drawn whole, else 1 + `level` * 129 +
/// `shown`, `shown` 1 to 63 sixty-fourths of `FADE` (`fade_out_code`: 65 to
/// 128, fading out). Chunk by chunk (columns of its own grid, wider for
/// coarse tiles: 2^`level` blocks), each starting in turn over the first half
/// and fading over the second, as a fine ordered pattern fixed to the terrain
/// (terrain.wgsl's `faded`). It is drawn a hair toward the camera, the newest
/// most, over the solid tiles it replaces, which stay until it is whole
/// (`upload_lods`): no pixel fought over, no hole. None: nothing to draw yet.
pub(super) fn fade_code(shown: Option<Instant>, level: u8, now: Instant) -> Option<u32> {
let faded = now.saturating_duration_since(shown?).as_secs_f32() / FADE.as_secs_f32();
let shown = (faded * 64.0) as u32 + 1;
Some(if shown >= 64 {
0
} else {
1 + u32::from(level) * 129 + shown
})
}
/// A section past the render distance that far terrain is coming in under:
/// whole, a fading-out code at its start (terrain.wgsl's `fade_depth` draws
/// leaving sections in front of far terrain).
pub(super) const HELD: u32 = 1 + 64;
/// `fade_code` for one leaving since `since`, chunk by chunk as it came, over
/// what stays behind it; once `FADE` has passed it draws nothing.
pub(super) fn fade_out_code(since: Instant, level: u8, now: Instant) -> u32 {
let faded = now.saturating_duration_since(since).as_secs_f32() / FADE.as_secs_f32();
1 + u32::from(level) * 129 + 64 + ((faded * 64.0) as u32 + 1).min(64)
}
#[derive(Clone, Copy)]
@@ -72,13 +113,13 @@ impl Bounds {
Some(bounds)
}
fn visible(self, origin: DVec3, distance: f32, frustum: &Frustum) -> Option<u64> {
/// In view: the plan alone decides which tiles are in range (a circle),
/// so a tile leaving it fades out (`upload_lods`). Cut here at the range,
/// tiles popped out as the player moved, before the plan dropped them.
fn visible(self, origin: DVec3, frustum: &Frustum) -> Option<u64> {
let min = origin + self.min.as_dvec3();
let max = origin + self.max.as_dvec3();
let nearest = DVec3::ZERO.clamp(min, max);
if nearest.x * nearest.x + nearest.z * nearest.z > f64::from(distance).powi(2)
|| !frustum.intersects(min.as_vec3(), max.as_vec3())
{
if !frustum.intersects(min.as_vec3(), max.as_vec3()) {
return None;
}
Some(((min + max) * 0.5).length_squared().to_bits())
@@ -86,12 +127,19 @@ impl Bounds {
}
/// A tile's instance: like a section's, with the UV step of merged faces.
pub(super) fn instance(origin: [f32; 3]) -> [f32; 4] {
/// A fading tile's step is negated past its `fade_code`, which the shaders
/// read back as the whole part (they take the step as `fract(abs(w))`).
pub(super) fn instance(origin: [f32; 3], fade: u32) -> [f32; 4] {
let step = 1.0 / crate::mesher::TILED_UV_STEPS;
[
origin[0],
origin[1],
origin[2],
1.0 / crate::mesher::TILED_UV_STEPS,
if fade == 0 {
step
} else {
-(fade as f32 + step)
},
]
}
@@ -99,45 +147,119 @@ pub(super) struct Visible {
pub key: TileKey,
pub distance: u64,
pub origin: [f32; 3],
pub fade: u32,
/// Replaced, standing until its replacements are whole: its water goes
/// after theirs (`Renderer::render`'s water tiers), so depth leaves it only
/// where they have none yet.
pub old: bool,
}
impl Tile {
/// The mesh drawn this frame: the previous one while it stands in.
pub fn drawn(&self) -> &SectionGpu {
self.previous.as_ref().map_or(&self.mesh, |(mesh, _)| mesh)
}
pub fn drawn_mut(&mut self) -> &mut SectionGpu {
match &mut self.previous {
Some((mesh, _)) => mesh,
None => &mut self.mesh,
}
}
pub fn visible(
&self,
key: TileKey,
camera: DVec3,
distance: f32,
frustum: &Frustum,
now: Instant,
) -> Option<Visible> {
let fade = match self.dying {
Some(since) if self.alone => fade_out_code(since, key.level, now),
_ => fade_code(self.shown, key.level, now)?,
};
let origin = DVec3::from(key.origin().map(f64::from)) - camera;
Some(Visible {
key,
distance: self.bounds.visible(origin, distance, frustum)?,
distance: self.bounds.visible(origin, frustum)?,
origin: origin.as_vec3().to_array(),
fade,
old: self.dying.is_some() || self.previous.is_some(),
})
}
}
impl Renderer {
pub fn upload_lod(&mut self, key: TileKey, mesh: &SectionMesh) {
self.remove_lod(key);
if let Some(bounds) = Bounds::mesh(mesh)
&& let Some(mesh) = self.upload_mesh(mesh)
{
self.lods.insert(key, Tile { mesh, bounds });
/// A tile meshed again (finer detail) takes its place at once, but for
/// one meshed around near chunks or finer tiles that are new (`keep`):
/// its old mesh stands in while they fade in. A new one waits for the rest of its job
/// (`upload_lods`), then fades in.
pub fn upload_lod(&mut self, key: TileKey, mesh: &SectionMesh, keep: bool) {
let (shown, previous) = match self.lods.remove(&key) {
Some(old) if old.dying.is_none() => match old.previous {
Some(previous) => {
self.release_mesh(old.mesh);
(old.shown, Some(previous))
}
None if keep => (old.shown, Some((old.mesh, None))),
None => {
self.release_mesh(old.mesh);
(old.shown, None)
}
},
Some(old) => {
self.release_tile(old);
(None, None)
}
None => (None, None),
};
match Bounds::mesh(mesh).zip(self.upload_mesh(mesh)) {
Some((bounds, mesh)) => {
self.lods.insert(
key,
Tile {
mesh,
bounds,
shown,
dying: None,
alone: false,
previous,
},
);
}
None => {
if let Some((previous, _)) = previous {
self.release_mesh(previous);
}
}
}
}
fn release_tile(&mut self, tile: Tile) {
self.release_mesh(tile.mesh);
if let Some((previous, _)) = tile.previous {
self.release_mesh(previous);
}
}
pub fn remove_lod(&mut self, key: TileKey) {
if let Some(tile) = self.lods.remove(&key) {
self.release_mesh(tile.mesh);
self.release_tile(tile);
}
}
pub fn clear_lods(&mut self) {
self.lod_pending = None;
// No far terrain left to fade out over.
for (key, since) in std::mem::take(&mut self.lod_leaving) {
if self
.sections
.find(key)
.is_some_and(|s| s.leaving == Some(since))
{
self.release_section(key);
}
}
for (_, tile) in std::mem::take(&mut self.lods) {
self.release_mesh(tile.mesh);
self.release_tile(tile);
}
self.set_lod_cutout(Default::default());
}
@@ -160,14 +282,134 @@ impl Renderer {
}
/// Uploads queued tile meshes for about `budget` (at least one), then,
/// once all are in, retires the tiles they replace.
/// once all are in, fades them in over the tiles they replace.
pub fn upload_lods(&mut self, budget: Duration) {
let start = Instant::now();
self.release_left(start);
let faded: Vec<_> = self
.lods
.iter()
.filter(|(_, tile)| tile.dying.is_some_and(|d| start - d >= FADE))
.map(|(&key, _)| key)
.collect();
for key in faded {
self.remove_lod(key);
}
let stood_in: Vec<_> = self
.lods
.values_mut()
.filter_map(|tile| match tile.previous {
Some((_, Some(since))) if start - since >= FADE => tile.previous.take(),
_ => None,
})
.collect();
for (previous, _) in stood_in {
self.release_mesh(previous);
}
// The columns near chunks take in this job, and the tiles holding the
// job's new finer ones: a tile meshed again around them keeps its
// old mesh until they are in, so no hole shows through them.
let mut around_new = FxHashSet::default();
let new = self
.lod_pending
.iter()
.flat_map(|pending| pending.meshes.keys())
.filter(|key| !self.lods.contains_key(key))
.chain(
self.lods
.iter()
.filter(|(_, tile)| tile.shown.is_none())
.map(|(key, _)| key),
);
for &key in new {
let mut key = key;
while key.level < MAX_LEVEL && around_new.insert(key.parent()) {
key = key.parent();
}
}
let taken: Vec<_> = self
.lod_pending
.iter()
.flat_map(|pending| pending.cutout.iter().flatten())
.filter(|column| !self.lod_cutout.contains(column))
.copied()
.collect();
while let Some(pending) = &mut self.lod_pending {
let Some(&key) = pending.meshes.keys().next() else {
let pending = self.lod_pending.take().expect("pending tiles");
// A tile fades in over the ones it replaces. One over nothing
// shows whole: its hidden chunks would open holes to the sky
// or the sea bed (dark patches, as far terrain first loads).
let replaced: FxHashSet<TileKey> = pending
.removed
.iter()
.copied()
.filter(|key| self.lods.contains_key(key))
.collect();
let mut above_replaced = FxHashSet::default();
for &key in &replaced {
let mut key = key;
while key.level < MAX_LEVEL && above_replaced.insert(key.parent()) {
key = key.parent();
}
}
let over_replaced = |key: TileKey| {
let mut ancestor = key;
replaced.contains(&key)
|| above_replaced.contains(&key)
|| (key.level..MAX_LEVEL).any(|_| {
ancestor = ancestor.parent();
replaced.contains(&ancestor)
})
};
// A removed tile no new tile overlaps, and no near chunk takes
// a column of, leaves the plan (out of range): it fades out.
// Over near chunks fading in it stays whole: two patterns
// fading at once left holes between them.
// Two tiles overlap when one is the other or its ancestor:
// `kept` holds the new tiles and the near chunks' columns
// (level 0), `above` their ancestors.
let mut kept: FxHashSet<TileKey> = self
.lods
.iter()
.filter(|(_, tile)| tile.shown.is_none())
.map(|(&key, _)| key)
.collect();
let cut = pending.cutout.as_ref().unwrap_or(&self.lod_cutout);
kept.extend(cut.iter().map(|&(x, z)| TileKey { x, z, level: 0 }));
let mut above = FxHashSet::default();
for &key in &kept {
let mut key = key;
while key.level < MAX_LEVEL && above.insert(key.parent()) {
key = key.parent();
}
}
let alone = |key: TileKey| {
let mut ancestor = key;
!kept.contains(&key)
&& !above.contains(&key)
&& !(key.level..MAX_LEVEL).any(|_| {
ancestor = ancestor.parent();
kept.contains(&ancestor)
})
};
let whole = start.checked_sub(FADE).unwrap_or(start);
for (&key, tile) in &mut self.lods {
if tile.shown.is_none() {
tile.shown = Some(if over_replaced(key) { start } else { whole });
}
if let Some((_, since)) = &mut tile.previous {
since.get_or_insert(start);
}
}
for key in pending.removed {
self.remove_lod(key);
let alone = alone(key);
if let Some(tile) = self.lods.get_mut(&key)
&& tile.dying.is_none()
{
tile.dying = Some(start);
tile.alone = alone;
}
}
if let Some(cutout) = pending.cutout {
self.set_lod_cutout(cutout);
@@ -175,7 +417,13 @@ impl Renderer {
return;
};
let mesh = pending.meshes.remove(&key).expect("pending tile");
self.upload_lod(key, &mesh);
let [x, _, z] = key.origin();
let size = key.size();
let keep = around_new.contains(&key)
|| taken.iter().any(|&(cx, cz)| {
(x..x + size).contains(&(cx * 16)) && (z..z + size).contains(&(cz * 16))
});
self.upload_lod(key, &mesh, keep);
if start.elapsed() >= budget {
return;
}
@@ -194,7 +442,7 @@ impl Renderer {
.collect();
for key in released {
self.lod_retiring.remove(&key);
self.release_section(key);
self.leave_section(key);
}
}
}
@@ -204,6 +452,36 @@ mod tests {
use super::*;
use crate::mesher::TerrainVertex;
#[test]
fn tiles_fade_in_on_their_own_grid() {
let start = Instant::now();
let at = |ms| start + Duration::from_millis(ms);
// terrain.wgsl's `faded` reads the level and sixty-fourths back.
let decode = |code: u32| ((code - 1) / 129, (code - 1) % 129);
assert_eq!(fade_code(None, 0, at(0)), None, "waits for its job");
assert_eq!(fade_code(Some(start), 3, at(0)).map(decode), Some((3, 1)));
let mut last = 0;
for ms in [0, 100, 300, 500, 590] {
let (level, shown) = decode(fade_code(Some(start), 10, at(ms)).unwrap());
assert_eq!(level, 10);
assert!(shown > last && shown < 64, "{shown} at {ms} ms");
last = shown;
}
assert_eq!(fade_code(Some(start), 10, at(600)), Some(0), "whole");
// Fading out: 65 to 128, then nothing drawn (128) until released.
let mut last = 64;
for ms in [0, 100, 300, 500, 590] {
let (level, out) = decode(fade_out_code(start, 3, at(ms)));
assert_eq!(level, 3);
assert!(out > last && out < 128, "{out} at {ms} ms");
last = out;
}
assert_eq!(decode(fade_out_code(start, 3, at(700))), (3, 128));
// A section's largest code keeps its UV step exact (terrain_uv).
let w = 129.0f32 + 1.0 / 32768.0;
assert_eq!(w.fract(), 1.0 / 32768.0);
}
#[test]
fn joined_jobs_apply_as_if_in_turn() {
let key = |x| TileKey { x, z: 0, level: 0 };
@@ -244,16 +522,17 @@ mod tests {
* crate::camera::view_matrix(0.0, 0.0),
),
};
// The tile origin is farther than the range, but its far edge reaches
// the camera. Negative world origins therefore remain visible.
// The tile origin is far behind, but its far edge reaches the camera.
// Negative world origins therefore remain visible.
assert!(
bounds
.visible(DVec3::new(-1000.0, -80.0, -512.0), 256.0, &view)
.visible(DVec3::new(-1000.0, -80.0, -512.0), &view)
.is_some()
);
// Wholly behind the camera: out of view.
assert!(
bounds
.visible(DVec3::new(-2000.0, -80.0, -512.0), 256.0, &view)
.visible(DVec3::new(-512.0, -80.0, -2000.0), &view)
.is_none()
);
assert!(Bounds::mesh(&SectionMesh::default()).is_none());
+968
View File
@@ -0,0 +1,968 @@
//! Improved transparency: vanilla 26.3's order-independent transparency
//! (`LevelRenderer.executeOit`). The translucent layer is drawn three times
//! against the opaque world's depth, without writing it, into targets the
//! next stage reads (`oit.wgsl` says what each holds), then a full-screen pass
//! composites the sum. With the option off none of this exists and the world
//! draws as before.
use super::oit_features::Features;
use glam::Mat4;
/// The depth bounds' clear: `LevelRenderer.DEPTH_BOUNDS_CLEAR_COLOR`.
const BOUNDS_CLEAR: wgpu::Color = wgpu::Color {
r: -(f32::MAX as f64),
g: 0.0,
b: 0.0,
a: 0.0,
};
const BOUNDS_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba32Float;
const COEFFICIENT_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
/// The shader's bind group: `terrain.wgsl` holds groups 0 and 1.
const GROUP: u32 = 2;
/// `OitStage`.
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Stage {
DepthBounds,
Transmittance,
Accumulate,
}
/// What the device must support: max blending on 32-bit float targets.
pub fn features(adapter: &wgpu::Adapter) -> wgpu::Features {
adapter.features() & wgpu::Features::FLOAT32_BLENDABLE
}
pub fn supported(device: &wgpu::Device) -> bool {
device
.features()
.contains(wgpu::Features::FLOAT32_BLENDABLE)
}
/// What a stage's pipelines write: `OIT_*_SNIPPET`'s colour targets.
pub(super) fn stage_targets(stage: Stage) -> Vec<Option<wgpu::ColorTargetState>> {
let blend = |operation| wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::One,
dst_factor: wgpu::BlendFactor::One,
operation,
};
let target = |format, operation| {
Some(wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState {
color: blend(operation),
alpha: blend(operation),
}),
write_mask: wgpu::ColorWrites::ALL,
})
};
let add = wgpu::BlendOperation::Add;
match stage {
Stage::DepthBounds => vec![target(BOUNDS_FORMAT, wgpu::BlendOperation::Max)],
Stage::Transmittance => vec![
target(COEFFICIENT_FORMAT, add),
target(COEFFICIENT_FORMAT, add),
],
Stage::Accumulate => vec![target(COEFFICIENT_FORMAT, add)],
}
}
/// `ProjMat[2][2]` and `ProjMat[3][2]`: a device depth `d` is `y / (d + x)`
/// blocks along the view axis (`oit_linear_depth`).
fn projection_terms(projection: &Mat4) -> [f32; 2] {
[projection.z_axis.z, projection.w_axis.z]
}
struct Targets {
size: [u32; 2],
bounds: wgpu::Texture,
culled: wgpu::Texture,
views: Views,
/// The uniform alone, with the culled bounds, with those and the
/// coefficients, for the cull with the unculled bounds, and the composite.
alone: wgpu::BindGroup,
sampled: wgpu::BindGroup,
accumulate: wgpu::BindGroup,
cull: wgpu::BindGroup,
composite: wgpu::BindGroup,
/// The depth the terrain's stages test against while a water mask is
/// drawn (a copy of the world's, plus the masks), and the clouds' own.
terrain_depth: Option<Depth>,
cloud_depth: Option<Depth>,
}
struct Depth {
texture: wgpu::Texture,
view: wgpu::TextureView,
}
impl Depth {
fn new(device: &wgpu::Device, label: &str, size: [u32; 2]) -> Self {
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
size: extent(size),
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Depth32Float,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
Self {
view: texture.create_view(&Default::default()),
texture,
}
}
}
fn extent(size: [u32; 2]) -> wgpu::Extent3d {
wgpu::Extent3d {
width: size[0].max(1),
height: size[1].max(1),
depth_or_array_layers: 1,
}
}
/// What the translucent layer's other drawers add to a frame: each draws with
/// a pipeline per stage where it has one.
#[derive(Default)]
pub struct Extras<'a> {
/// `executeOitWaterMask`: the boats' water patches, depth only.
pub water_mask: Option<&'a dyn Fn(&mut wgpu::RenderPass<'_>)>,
/// `CloudRenderer.renderOit`: draws the clouds with the pipeline given.
pub clouds: Option<&'a dyn Fn(&mut wgpu::RenderPass<'_>, &wgpu::RenderPipeline)>,
/// "Features, World Border, Weather": draws what the entity, weather and
/// border shaders put after the opaque world, with `Oit::features`'s
/// pipelines of the stage.
pub features: Option<&'a dyn Fn(&mut wgpu::RenderPass<'_>, Stage)>,
}
struct Views {
bounds: wgpu::TextureView,
culled: wgpu::TextureView,
coefficients: [wgpu::TextureView; 2],
accumulated: wgpu::TextureView,
}
pub struct Oit {
uniform: wgpu::Buffer,
/// Layouts of the groups at `GROUP`: the uniform; with the bounds; with the
/// coefficients; and with the accumulation too.
layouts: [wgpu::BindGroupLayout; 4],
/// One per `Stage`.
terrain: [wgpu::RenderPipeline; 3],
/// The terrain's flat section draws (`Flat`), where the device has them.
flat: Option<[wgpu::RenderPipeline; 3]>,
features: Features,
clouds: [wgpu::RenderPipeline; 3],
cull: wgpu::RenderPipeline,
composite: wgpu::RenderPipeline,
targets: Option<Targets>,
}
impl Oit {
/// `terrain`: the terrain bind group layout that `vs_terrain` reads,
/// `flat` the flat draws' second group, `features` the entity, weather
/// and world border layouts,
/// `clouds` the cloud pipeline's two, `sprites` the terrain's override
/// constants, `format` the world target's.
pub fn new(
device: &wgpu::Device,
terrain: &wgpu::BindGroupLayout,
flat: Option<&wgpu::BindGroupLayout>,
features: [&wgpu::BindGroupLayout; 3],
clouds: [&wgpu::BindGroupLayout; 2],
sprites: [f64; 2],
format: wgpu::TextureFormat,
) -> Self {
let instance = wgpu::vertex_attr_array![5=>Float32x4];
let buffers = [
Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<super::TerrainVertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &super::TERRAIN_ATTRIBUTES,
}),
Some(wgpu::VertexBufferLayout {
array_stride: 16,
step_mode: wgpu::VertexStepMode::Instance,
attributes: &instance,
}),
];
let buffers = &buffers[..];
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Horizon improved transparency"),
source: wgpu::ShaderSource::Wgsl(
(include_str!("../terrain.wgsl").to_owned()
+ include_str!("../oit.wgsl")
+ include_str!("../oit_world.wgsl"))
.into(),
),
});
let uniform = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Improved transparency"),
size: 16,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let entry = |binding: u32, ty| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::FRAGMENT,
ty,
count: None,
};
let texture = |binding| {
entry(
binding,
wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: false },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
)
};
let params = entry(
0,
wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
);
let layout = |label, entries: &[wgpu::BindGroupLayoutEntry]| {
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some(label),
entries,
})
};
let layouts = [
layout("Improved transparency", &[params]),
layout("Improved transparency bounds", &[params, texture(1)]),
layout(
"Improved transparency coefficients",
&[params, texture(1), texture(2), texture(3)],
),
layout(
"Improved transparency composite",
&[params, texture(1), texture(2), texture(3), texture(4)],
),
];
let pipeline_layout = |first: Option<&wgpu::BindGroupLayout>, group: usize| {
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Improved transparency"),
bind_group_layouts: &[first, None, Some(&layouts[group])],
..Default::default()
})
};
let constants = [("SPRITE_ARRAYS", sprites[0]), ("SPRITE_SPLIT", sprites[1])];
let add = wgpu::BlendOperation::Add;
let depth = |write| {
Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(write),
depth_compare: Some(wgpu::CompareFunction::GreaterEqual),
stencil: Default::default(),
bias: Default::default(),
})
};
let pipeline = |label,
layout: &wgpu::PipelineLayout,
vertex: &str,
fragment: &str,
buffers: &[Option<wgpu::VertexBufferLayout<'_>>],
targets: &[Option<wgpu::ColorTargetState>],
depth_stencil| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(label),
layout: Some(layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some(vertex),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &constants,
..Default::default()
},
buffers,
},
primitive: wgpu::PrimitiveState {
cull_mode: vertex.starts_with("vs_terrain").then_some(wgpu::Face::Back),
..Default::default()
},
depth_stencil,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some(fragment),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &constants,
..Default::default()
},
targets,
}),
multiview_mask: None,
cache: None,
})
};
let terrain_pipeline = |label, fragment, stage| {
pipeline(
label,
&pipeline_layout(Some(terrain), stage as usize),
"vs_terrain",
fragment,
buffers,
&stage_targets(stage),
depth(false),
)
};
let terrain_pipelines = [
terrain_pipeline("OIT depth bounds", "fs_oit_bounds", Stage::DepthBounds),
terrain_pipeline(
"OIT transmittance",
"fs_oit_transmittance",
Stage::Transmittance,
),
terrain_pipeline("OIT accumulate", "fs_oit_accumulate", Stage::Accumulate),
];
// The same fragments over `vs_terrain_flat`: one vertex buffer, and
// the draw table at group 1.
let flat_pipelines = flat.map(|draw| {
let flat_buffers = &buffers[..1];
[
(
"OIT depth bounds (flat)",
"fs_oit_bounds",
Stage::DepthBounds,
),
(
"OIT transmittance (flat)",
"fs_oit_transmittance",
Stage::Transmittance,
),
(
"OIT accumulate (flat)",
"fs_oit_accumulate",
Stage::Accumulate,
),
]
.map(|(label, fragment, stage)| {
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Improved transparency flat terrain"),
bind_group_layouts: &[
Some(terrain),
Some(draw),
Some(&layouts[stage as usize]),
],
..Default::default()
});
pipeline(
label,
&layout,
"vs_terrain_flat",
fragment,
flat_buffers,
&stage_targets(stage),
depth(false),
)
})
});
let screen = |label, fragment, group, targets: &[_], depth_stencil| {
pipeline(
label,
&pipeline_layout(None, group),
"vs_sky",
fragment,
&[],
targets,
depth_stencil,
)
};
let replace = |format| {
Some(wgpu::ColorTargetState {
format,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})
};
let cull = screen(
"OIT cull depth bounds",
"fs_oit_cull",
1,
&[replace(BOUNDS_FORMAT)],
depth(true),
);
// `RenderPipelines.OIT_COMPOSITE`: premultiplied alpha over the world,
// the nearest see-through depth written.
let premultiplied = wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::One,
dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
operation: add,
};
let composite = screen(
"OIT composite",
"fs_oit_composite",
3,
&[Some(wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState {
color: premultiplied,
alpha: premultiplied,
}),
write_mask: wgpu::ColorWrites::ALL,
})],
Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(true),
depth_compare: Some(wgpu::CompareFunction::Always),
stencil: Default::default(),
bias: Default::default(),
}),
);
// The clouds: entity.wgsl's vertex shader and vertex format, with the
// stages' fragments.
let cloud_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Horizon improved transparency clouds"),
source: wgpu::ShaderSource::Wgsl(
(super::entity_shader()
+ include_str!("../oit.wgsl")
+ include_str!("../oit_clouds.wgsl"))
.into(),
),
});
let cloud_attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint8x4,5=>Snorm8x4,6=>Float32x2];
let cloud_buffers = [Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<super::Vertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &cloud_attributes,
})];
let cloud_pipeline = |label, fragment, stage: Stage| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(label),
layout: Some(
&device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Improved transparency clouds"),
bind_group_layouts: &[
Some(clouds[0]),
Some(clouds[1]),
Some(&layouts[stage as usize]),
],
..Default::default()
}),
),
vertex: wgpu::VertexState {
module: &cloud_shader,
entry_point: Some("vs_cloud"),
compilation_options: Default::default(),
buffers: &cloud_buffers,
},
primitive: wgpu::PrimitiveState {
cull_mode: Some(wgpu::Face::Back),
..Default::default()
},
// `OIT_CLOUDS`: only the bounds stage writes the cloud depth,
// so the later stages keep the nearest face of each cloud.
depth_stencil: depth(stage == Stage::DepthBounds),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &cloud_shader,
entry_point: Some(fragment),
compilation_options: Default::default(),
targets: &stage_targets(stage),
}),
multiview_mask: None,
cache: None,
})
};
let cloud_pipelines = [
cloud_pipeline(
"OIT cloud depth bounds",
"fs_oit_cloud_bounds",
Stage::DepthBounds,
),
cloud_pipeline(
"OIT cloud transmittance",
"fs_oit_cloud_transmittance",
Stage::Transmittance,
),
cloud_pipeline(
"OIT cloud accumulate",
"fs_oit_cloud_accumulate",
Stage::Accumulate,
),
];
let features = Features::new(device, &layouts, features[0], features[1], features[2]);
Self {
uniform,
layouts,
terrain: terrain_pipelines,
flat: flat_pipelines,
features,
clouds: cloud_pipelines,
cull,
composite,
targets: None,
}
}
/// The pipeline the terrain's translucent layer draws with in `stage`.
pub fn terrain(&self, stage: Stage) -> &wgpu::RenderPipeline {
&self.terrain[stage as usize]
}
/// The pipeline the terrain's translucent layer draws with in `stage`
/// as flat section draws, where they run.
pub fn flat(&self, stage: Stage) -> Option<&wgpu::RenderPipeline> {
self.flat.as_ref().map(|f| &f[stage as usize])
}
pub fn features(&self) -> &Features {
&self.features
}
/// Sizes the targets to the world's and writes this frame's projection.
pub fn prepare(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
size: [u32; 2],
projection: &Mat4,
wants: [bool; 2],
) {
queue.write_buffer(
&self.uniform,
0,
bytemuck::cast_slice(&[projection_terms(projection), [0.0; 2]].concat()),
);
let [mask, clouds] = wants;
if let Some(t) = self.targets.as_mut().filter(|t| t.size == size) {
if mask && t.terrain_depth.is_none() {
t.terrain_depth = Some(Depth::new(device, "OIT terrain depth", size));
}
if clouds && t.cloud_depth.is_none() {
t.cloud_depth = Some(Depth::new(device, "OIT cloud depth", size));
}
return;
}
let make = |label, format, usage| {
device.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
size: extent(size),
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage,
view_formats: &[],
})
};
let attachment =
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING;
let bounds = make(
"OIT depth bounds",
BOUNDS_FORMAT,
attachment | wgpu::TextureUsages::COPY_SRC,
);
let culled = make(
"OIT culled depth bounds",
BOUNDS_FORMAT,
attachment | wgpu::TextureUsages::COPY_DST,
);
let coefficients =
[0, 1].map(|_| make("OIT transmittance", COEFFICIENT_FORMAT, attachment));
let accumulated = make("OIT accumulate", COEFFICIENT_FORMAT, attachment);
let views = Views {
bounds: bounds.create_view(&Default::default()),
culled: culled.create_view(&Default::default()),
coefficients: coefficients.map(|t| t.create_view(&Default::default())),
accumulated: accumulated.create_view(&Default::default()),
};
let group = |layout: &wgpu::BindGroupLayout, views: &[&wgpu::TextureView]| {
let mut entries = vec![wgpu::BindGroupEntry {
binding: 0,
resource: self.uniform.as_entire_binding(),
}];
entries.extend(views.iter().enumerate().map(|(i, v)| wgpu::BindGroupEntry {
binding: i as u32 + 1,
resource: wgpu::BindingResource::TextureView(v),
}));
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Improved transparency"),
layout,
entries: &entries,
})
};
let [c0, c1] = &views.coefficients;
self.targets = Some(Targets {
size,
alone: group(&self.layouts[0], &[]),
sampled: group(&self.layouts[1], &[&views.culled]),
accumulate: group(&self.layouts[2], &[&views.culled, c0, c1]),
cull: group(&self.layouts[1], &[&views.bounds]),
composite: group(
&self.layouts[3],
&[&views.bounds, c0, c1, &views.accumulated],
),
terrain_depth: mask.then(|| Depth::new(device, "OIT terrain depth", size)),
cloud_depth: clouds.then(|| Depth::new(device, "OIT cloud depth", size)),
bounds,
culled,
views,
});
}
/// `LevelRenderer.executeOit`: `draw` records the terrain's translucent
/// layer into each stage's pass with that stage's pipeline, `extras` the
/// water masks and clouds, then the composite goes over `color`. `depth`
/// is the opaque world's: the stages only read it; the cull and the
/// composite write it.
pub fn run(
&self,
encoder: &mut wgpu::CommandEncoder,
color: &wgpu::TextureView,
depth: &wgpu::TextureView,
draw: &dyn Fn(&mut wgpu::RenderPass<'_>, Stage),
extras: &Extras<'_>,
) {
let Some(t) = &self.targets else { return };
let attachment = |view, load| {
Some(wgpu::RenderPassColorAttachment {
view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load,
store: wgpu::StoreOp::Store,
},
})
};
let clear = wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT);
let load = wgpu::LoadOp::Load;
let read_only = |view| {
Some(wgpu::RenderPassDepthStencilAttachment {
view,
depth_ops: None,
stencil_ops: None,
})
};
let water_mask = extras.water_mask.zip(t.terrain_depth.as_ref());
let cloud_depth = extras.clouds.zip(t.cloud_depth.as_ref());
if let Some((mask, _)) = water_mask {
self.execute_water_mask(encoder, t, color, depth, mask);
}
let terrain_depth = water_mask.map_or(depth, |(_, d)| &d.view);
let [c0, c1] = &t.views.coefficients;
let culled = &t.views.culled;
for (stage, label, group, first, then) in [
(
Stage::DepthBounds,
"OIT depth bounds",
&t.alone,
vec![attachment(
&t.views.bounds,
wgpu::LoadOp::Clear(BOUNDS_CLEAR),
)],
// The clouds' bounds join the culled ones.
vec![attachment(culled, load)],
),
(
Stage::Transmittance,
"OIT transmittance",
&t.sampled,
vec![attachment(c0, clear), attachment(c1, clear)],
vec![attachment(c0, load), attachment(c1, load)],
),
(
Stage::Accumulate,
"OIT accumulate",
&t.accumulate,
vec![attachment(&t.views.accumulated, clear)],
vec![attachment(&t.views.accumulated, load)],
),
] {
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(label),
color_attachments: &first,
depth_stencil_attachment: read_only(terrain_depth),
..Default::default()
});
pass.set_bind_group(GROUP, group, &[]);
draw(&mut pass, stage);
}
if let Some(features) = extras.features {
// Over the world's own depth, even where a water mask patched
// the terrain's: the masks only hide water.
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(label),
color_attachments: &[attachment(
match stage {
Stage::DepthBounds => &t.views.bounds,
Stage::Transmittance => c0,
Stage::Accumulate => &t.views.accumulated,
},
load,
)]
.into_iter()
.chain((stage == Stage::Transmittance).then(|| attachment(c1, load)))
.collect::<Vec<_>>(),
depth_stencil_attachment: read_only(depth),
..Default::default()
});
pass.set_bind_group(GROUP, group, &[]);
features(&mut pass, stage);
}
if stage == Stage::DepthBounds {
self.execute_depth_bounds_cull(encoder, t, depth);
}
if let Some((clouds, cloud_depth)) = cloud_depth {
self.render_clouds(
encoder,
t,
stage,
depth,
(label, group, &then),
(clouds, cloud_depth),
);
}
if stage == Stage::DepthBounds
&& let Some((mask, _)) = water_mask
{
self.execute_water_mask(encoder, t, color, depth, mask);
}
}
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("OIT composite"),
color_attachments: &[attachment(color, wgpu::LoadOp::Load)],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: depth,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
});
pass.set_pipeline(&self.composite);
pass.set_bind_group(GROUP, &t.composite, &[]);
pass.draw(0..3, 0..1);
}
/// `CloudRenderer.renderOit` for one stage. The bounds stage first copies
/// the world's depth (after the cull) and draws into it, so the later
/// stages, which only read it, keep each pixel's nearest cloud face.
fn render_clouds(
&self,
encoder: &mut wgpu::CommandEncoder,
t: &Targets,
stage: Stage,
depth: &wgpu::TextureView,
(label, group, targets): (
&str,
&wgpu::BindGroup,
&[Option<wgpu::RenderPassColorAttachment<'_>>],
),
(draw, cloud_depth): (
&dyn Fn(&mut wgpu::RenderPass<'_>, &wgpu::RenderPipeline),
&Depth,
),
) {
if stage == Stage::DepthBounds {
encoder.copy_texture_to_texture(
depth.texture().as_image_copy(),
cloud_depth.texture.as_image_copy(),
extent(t.size),
);
}
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(label),
color_attachments: targets,
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &cloud_depth.view,
depth_ops: (stage == Stage::DepthBounds).then_some(wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
});
pass.set_bind_group(GROUP, group, &[]);
draw(&mut pass, &self.clouds[stage as usize]);
}
/// `LevelRenderer.executeOitWaterMask`: the terrain's depth is the world's
/// again, with the boats' water patches on top.
fn execute_water_mask(
&self,
encoder: &mut wgpu::CommandEncoder,
t: &Targets,
color: &wgpu::TextureView,
depth: &wgpu::TextureView,
mask: &dyn Fn(&mut wgpu::RenderPass<'_>),
) {
let Some(terrain) = &t.terrain_depth else {
return;
};
encoder.copy_texture_to_texture(
depth.texture().as_image_copy(),
terrain.texture.as_image_copy(),
extent(t.size),
);
// The mask pipeline writes depth only but has a colour target.
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("OIT water mask"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: color,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &terrain.view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
});
mask(&mut pass);
}
/// `LevelRenderer.executeDepthBoundsCull`: the bounds are copied, then where
/// the nearest fully opaque see-through fragment is in front of the world,
/// the far bound stops there and its depth replaces the world's.
fn execute_depth_bounds_cull(
&self,
encoder: &mut wgpu::CommandEncoder,
t: &Targets,
depth: &wgpu::TextureView,
) {
encoder.copy_texture_to_texture(
t.bounds.as_image_copy(),
t.culled.as_image_copy(),
wgpu::Extent3d {
width: t.size[0].max(1),
height: t.size[1].max(1),
depth_or_array_layers: 1,
},
);
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("OIT cull depth bounds"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &t.views.culled,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: depth,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
});
pass.set_pipeline(&self.cull);
pass.set_bind_group(GROUP, &t.cull, &[]);
pass.draw(0..3, 0..1);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn validate(source: &str, entries: &[&str]) {
let module = naga::front::wgsl::parse_str(source).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap();
for entry in entries {
assert!(
module.entry_points.iter().any(|e| e.name == *entry),
"{entry}"
);
}
}
#[test]
fn the_terrain_shader_validates() {
validate(
&(include_str!("../terrain.wgsl").to_owned()
+ include_str!("../oit.wgsl")
+ include_str!("../oit_world.wgsl")),
&[
"fs_oit_bounds",
"fs_oit_transmittance",
"fs_oit_accumulate",
"fs_oit_cull",
"fs_oit_composite",
],
);
}
#[test]
fn the_cloud_shader_validates() {
validate(
&(super::super::entity_shader()
+ include_str!("../oit.wgsl")
+ include_str!("../oit_clouds.wgsl")),
&[
"fs_oit_cloud_bounds",
"fs_oit_cloud_transmittance",
"fs_oit_cloud_accumulate",
],
);
}
#[test]
fn the_feature_shaders_validate() {
let stages = |name: &str| {
["bounds", "transmittance", "accumulate"].map(|s| format!("fs_oit_{name}_{s}"))
};
let entity = ["entity", "shadow", "weather", "swirl"]
.map(stages)
.concat();
validate(
&(super::super::entity_shader()
+ include_str!("../oit.wgsl")
+ include_str!("../oit_features.wgsl")),
&entity.iter().map(String::as_str).collect::<Vec<_>>(),
);
let border = stages("border");
validate(
&(include_str!("../border.wgsl").to_owned()
+ include_str!("../oit.wgsl")
+ include_str!("../oit_border.wgsl")),
&border.iter().map(String::as_str).collect::<Vec<_>>(),
);
}
#[test]
fn a_device_depth_gives_the_distance_along_the_view_axis() {
// The world's reversed-Z projection: near plane at depth 1.
let projection = super::super::world_projection(70.0, 16.0 / 9.0, 512.0);
let [x, y] = projection_terms(&projection);
for distance in [0.1_f32, 1.0, 7.5, 64.0, 500.0] {
let clip = projection * glam::Vec4::new(0.0, 0.0, -distance, 1.0);
let device = clip.z / clip.w;
let linear = y / (device + x);
assert!(
(linear - distance).abs() < distance * 2e-3,
"{distance}: {linear}"
);
}
}
}
@@ -0,0 +1,248 @@
//! Improved transparency for what is drawn after the opaque world besides the
//! terrain and the clouds: `LevelRenderer.executeOit`'s "Features, World
//! Border, Weather" pass. Each kind keeps its classic vertex shader, vertex
//! format and depth state (minus the depth writes, as every `OIT_*` pipeline),
//! with the three stages' fragments (`oit_features.wgsl`, `oit_border.wgsl`).
use super::oit::{Stage, stage_targets};
/// One pipeline per stage.
type Set = [wgpu::RenderPipeline; 3];
pub struct Features {
/// `OIT_ENTITY`/`OIT_ITEM`: translucent items and particles.
pub entity: Set,
/// `OIT_TEXT_POLYGON_OFFSET`.
pub text: Set,
/// `OIT_CRUMBLING`.
pub crumbling: Set,
/// `OIT_ENTITY_SHADOW`.
pub shadow: Set,
/// `OIT_ENERGY_SWIRL`.
pub swirl: Set,
/// `OIT_LIGHTNING`: the retained bolts.
pub lightning: Set,
/// `OIT_DRAGON_RAYS`: the additive geometry after the bolts.
pub additive: Set,
/// `OIT_WEATHER`.
pub weather: Set,
/// `OIT_WORLD_BORDER`.
pub border: Set,
}
/// What a kind differs in.
struct Kind<'a> {
label: &'a str,
/// The entry points' common prefix: `fs_oit_<name>_<stage>`.
name: &'a str,
vertex: &'a str,
cull: Option<wgpu::Face>,
bias: wgpu::DepthBiasState,
}
const PLAIN: wgpu::DepthBiasState = wgpu::DepthBiasState {
constant: 0,
slope_scale: 0.0,
clamp: 0.0,
};
impl Features {
/// `layouts`: `Oit`'s stage groups (at group 2); `entity`, `weather` and
/// `border` the bind group layouts of the classic pipelines.
pub fn new(
device: &wgpu::Device,
layouts: &[wgpu::BindGroupLayout; 4],
entity: &wgpu::BindGroupLayout,
weather: &wgpu::BindGroupLayout,
border: &wgpu::BindGroupLayout,
) -> Self {
let entity_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Horizon improved transparency features"),
source: wgpu::ShaderSource::Wgsl(
(super::entity_shader()
+ include_str!("../oit.wgsl")
+ include_str!("../oit_features.wgsl"))
.into(),
),
});
let border_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Horizon improved transparency world border"),
source: wgpu::ShaderSource::Wgsl(
(include_str!("../border.wgsl").to_owned()
+ include_str!("../oit.wgsl")
+ include_str!("../oit_border.wgsl"))
.into(),
),
});
let entity_attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint8x4,5=>Snorm8x4,6=>Float32x2];
let weather_attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Float32x4,
3=>Float32x4,4=>Uint32x4,5=>Unorm8x4,6=>Uint8x4,7=>Uint32];
let border_attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2];
let entity_buffers = [Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<super::Vertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &entity_attributes,
})];
let weather_buffers = [Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<crate::weather::Vertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &weather_attributes,
})];
let border_buffers = [Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<crate::border_render::BorderVertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &border_attributes,
})];
let lightning_attributes = wgpu::vertex_attr_array![7=>Float32x3];
let lightning_buffers = [
entity_buffers[0].clone(),
Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<[f32; 3]>() as u64,
step_mode: wgpu::VertexStepMode::Instance,
attributes: &lightning_attributes,
}),
];
let set = |module: &wgpu::ShaderModule,
groups: [Option<&wgpu::BindGroupLayout>; 2],
buffers: &[Option<wgpu::VertexBufferLayout<'_>>],
kind: Kind<'_>| {
[Stage::DepthBounds, Stage::Transmittance, Stage::Accumulate].map(|stage| {
let suffix = ["bounds", "transmittance", "accumulate"][stage as usize];
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Improved transparency features"),
bind_group_layouts: &[groups[0], groups[1], Some(&layouts[stage as usize])],
..Default::default()
});
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(&format!("OIT {} {suffix}", kind.label)),
layout: Some(&layout),
vertex: wgpu::VertexState {
module,
entry_point: Some(kind.vertex),
compilation_options: Default::default(),
buffers,
},
primitive: wgpu::PrimitiveState {
cull_mode: kind.cull,
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(false),
depth_compare: Some(wgpu::CompareFunction::GreaterEqual),
stencil: Default::default(),
bias: kind.bias,
}),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module,
entry_point: Some(&format!("fs_oit_{}_{suffix}", kind.name)),
compilation_options: Default::default(),
targets: &stage_targets(stage),
}),
multiview_mask: None,
cache: None,
})
})
};
let entities = [Some(entity), None];
let kind = |label, name, cull, bias| Kind {
label,
name,
vertex: "vs_entity",
cull,
bias,
};
// The classic pipelines' biases: text and cracks (10, 1), shadows
// (128 ulps and a slope, over the block tops they lie on).
let offset = wgpu::DepthBiasState {
constant: 10,
slope_scale: 1.0,
clamp: 0.0,
};
let shadow_bias = wgpu::DepthBiasState {
constant: 128,
slope_scale: 0.015,
clamp: 0.0,
};
Self {
entity: set(
&entity_module,
entities,
&entity_buffers,
kind("entities", "entity", None, PLAIN),
),
text: set(
&entity_module,
entities,
&entity_buffers,
kind("text", "entity", None, offset),
),
crumbling: set(
&entity_module,
entities,
&entity_buffers,
kind("breaking cracks", "entity", Some(wgpu::Face::Back), offset),
),
shadow: set(
&entity_module,
entities,
&entity_buffers,
kind("entity shadows", "shadow", None, shadow_bias),
),
swirl: set(
&entity_module,
entities,
&entity_buffers,
kind("energy swirls", "swirl", None, PLAIN),
),
lightning: set(
&entity_module,
entities,
&lightning_buffers,
Kind {
label: "lightning",
name: "lightning",
vertex: "vs_lightning",
cull: None,
bias: PLAIN,
},
),
additive: set(
&entity_module,
entities,
&entity_buffers,
kind("additive entities", "lightning", None, PLAIN),
),
weather: set(
&entity_module,
[Some(entity), Some(weather)],
&weather_buffers,
Kind {
label: "weather",
name: "weather",
vertex: "vs_weather",
cull: None,
bias: PLAIN,
},
),
// `OIT_WORLD_BORDER`: a polygon offset of 3 towards the camera.
border: set(
&border_module,
[Some(border), None],
&border_buffers,
Kind {
label: "world border",
name: "border",
vertex: "vs_border",
cull: None,
bias: wgpu::DepthBiasState {
constant: 3,
slope_scale: 3.0,
clamp: 0.0,
},
},
),
}
}
}
@@ -176,7 +176,7 @@ impl PackAtlas {
hz_cell = vec2(float(hz_layer % {columns}u), float(hz_layer / {columns}u));\n \
hz_scale = vec2({:?}, {:?});\n \
hz_Vertex = vec4(hz_v_pos + hz_origin.xyz, 1.0);\n \
hz_MultiTexCoord0 = vec4((hz_cell + clamp(vec2(hz_v_uv) * hz_origin.w, 0.0, 1.0)) * hz_scale, 0.0, 1.0);\n \
hz_MultiTexCoord0 = vec4((hz_cell + clamp(vec2(hz_v_uv) * (hz_origin.w > 0.0 ? uintBitsToFloat(floatBitsToUint(hz_origin.w) & 0xff800000u) : fract(abs(hz_origin.w))), 0.0, 1.0)) * hz_scale, 0.0, 1.0);\n \
hz_MultiTexCoord1 = vec4(float(hz_v_light.y), float(hz_v_light.x), 0.0, 1.0);\n \
hz_MultiTexCoord2 = hz_MultiTexCoord1;\n \
hz_Color = vec4(hz_v_color.rgb, 1.0);\n \
@@ -349,7 +349,10 @@ impl PackEntities {
bytes[0..4].copy_from_slice(&(span.stage as i32).to_le_bytes());
bytes[4..8].copy_from_slice(&entity_id.to_le_bytes());
bytes[8..12].copy_from_slice(&block_id.to_le_bytes());
for (i, c) in entity_color(run[0].layer).iter().enumerate() {
for (i, c) in entity_color(run[0].layer & !super::entity_batch::OIT_ENTITY)
.iter()
.enumerate()
{
bytes[16 + 4 * i..20 + 4 * i].copy_from_slice(&c.to_le_bytes());
}
self.uniforms.extend_from_slice(&bytes);
@@ -408,9 +411,12 @@ pub(super) fn quad_indices(device: &wgpu::Device, quads: u32) -> wgpu::Buffer {
/// celestial body, cloud, beam, End portal, crack or energy swirl (overlay codes Horizon's entity
/// shader draws in its own way; entity.wgsl).
fn plain(run: &[Vertex]) -> bool {
run.iter()
.step_by(4)
.all(|v| !matches!(v.layer, 2 | 4 | 6 | 9 | 10 | 11 | 12 | 34))
run.iter().step_by(4).all(|v| {
!matches!(
v.layer & !super::entity_batch::OIT_ENTITY,
2 | 4 | 6 | 9 | 10 | 11 | 12 | 34
)
})
}
/// The programs of entity runs, in the order they draw.
@@ -203,9 +203,9 @@ fn body(angle: f32, tilt: f32) -> Vec3 {
}
/// `sunPathRotation`'s turn of the sky's bodies: `tilt` degrees about the world's X axis, so a
/// positive angle moves the noon sun toward +Z (south).
/// positive angle moves the noon sun toward -Z (north), as Iris does.
pub fn tilt_matrix(tilt: f32) -> Mat4 {
Mat4::from_rotation_x(tilt.to_radians())
Mat4::from_rotation_x(-tilt.to_radians())
}
/// The world direction of the shadow light (7.1): the sun by day, the moon by night.
@@ -232,38 +232,39 @@ pub fn shadow_culling(frame: &Frame, shadow: &Shadow) -> Mat4 {
depth * gl * view
}
/// Distance from the snapped point to the shadow camera, toward the light: the middle of the
/// default near and far planes (OQ-6).
const SHADOW_EYE: f64 = 128.0;
/// How far toward the light the shadow projection starts, in blocks: part of the projection,
/// not of `shadowModelView` (Iris's matrices, measured).
const SHADOW_EYE: f32 = 100.0;
/// The shadow camera (7.1; OQ-6, impl-plan.md): its view (player space to shadow view space)
/// and GL projection. The camera looks from the light, world +Z up (+Y when the light is near
/// Z); the point it centres moves in steps of `shadowIntervalSize` along the shadow view's
/// own axes, and it sits `SHADOW_EYE` blocks toward the light from it. Orthographic over
/// `shadowDistance` each side, or perspective with `shadowMapFov`.
/// The shadow camera as Iris places it (7.1; measured from the outside): its view (player
/// space to shadow view space) and GL projection. The view undoes `sunPathRotation`, turns the
/// light's path about Z by the light's angle from the zenith, then X by 90 degrees, so the
/// light lies on the view's +Z. Its origin is `shadowIntervalSize / 2 - camera % interval`
/// (the remainder takes the camera's sign) from the player on each world axis, so the map moves
/// in whole steps. The orthographic projection covers `shadowDistance` each side and depths
/// from `-near` to `far`, counted from `SHADOW_EYE` blocks toward the light; or perspective
/// with `shadowMapFov`.
pub fn shadow_camera(light: Vec3, camera: DVec3, shadow: &Shadow) -> (Mat4, Mat4) {
let light = light.as_dvec3();
let up = if light.z.abs() > 0.99 {
DVec3::Y
} else {
DVec3::Z
};
let rotation = DMat4::look_to_rh(DVec3::ZERO, -light, up);
let at = rotation.transform_vector3(camera);
let tilt = f64::from(shadow.sun_path_rotation).to_radians();
let untilted = DMat4::from_rotation_x(tilt).transform_vector3(light.as_dvec3());
let zenith = untilted.x.atan2(untilted.y);
let rotation = DMat4::from_rotation_x(std::f64::consts::FRAC_PI_2)
* DMat4::from_rotation_z(zenith)
* DMat4::from_rotation_x(tilt);
let step = f64::from(shadow.interval);
let snapped = if step > 0.0 {
(at / step).floor() * step
let origin = if step > 0.0 {
DVec3::splat(step / 2.0) - camera % step
} else {
at
DVec3::ZERO
};
let offset = at - snapped - DVec3::new(0.0, 0.0, SHADOW_EYE);
let view = (DMat4::from_translation(offset) * rotation).as_mat4();
let view = (rotation * DMat4::from_translation(-origin)).as_mat4();
let (near, far) = (shadow.near, shadow.far);
let eye = Mat4::from_translation(Vec3::new(0.0, 0.0, -SHADOW_EYE));
let projection = match shadow.fov {
Some(fov) => Mat4::perspective_rh_gl(fov.to_radians(), 1.0, near, far),
Some(fov) => Mat4::perspective_rh_gl(fov.to_radians(), 1.0, near, far) * eye,
None => {
let d = shadow.distance;
Mat4::orthographic_rh_gl(-d, d, -d, d, near, far)
Mat4::orthographic_rh_gl(-d, d, -d, d, -near, far) * eye
}
};
(view, projection)
@@ -360,7 +361,8 @@ impl FrameUniforms {
"viewHeight" => Value::f(&[size[1] as f32]),
"aspectRatio" => Value::f(&[aspect]),
"near" => Value::f(&[0.05]),
"far" => Value::f(&[frame.distance]),
// The chunk render distance, not the distant terrain's reach.
"far" => Value::f(&[frame.detail_distance]),
"rainStrength" => Value::f(&[inputs.rain]),
"wetness" => Value::f(&[self.wetness]),
"thunderStrength" => Value::f(&[inputs.thunder]),
@@ -486,8 +488,8 @@ mod tests {
fn noon_is_straight_up_and_the_sun_sets_in_the_west() {
assert!(body(0.0, 0.0).abs_diff_eq(Vec3::new(0.0, 100.0, 0.0), 1e-3));
assert!(body(90.0, 0.0).abs_diff_eq(Vec3::new(-100.0, 0.0, 0.0), 1e-3));
// sunPathRotation tilts noon toward +Z and leaves sunset on the horizon.
assert!(body(0.0, 30.0).abs_diff_eq(Vec3::new(0.0, 86.60254, 50.0), 1e-3));
// sunPathRotation tilts noon toward -Z and leaves sunset on the horizon.
assert!(body(0.0, 30.0).abs_diff_eq(Vec3::new(0.0, 86.60254, -50.0), 1e-3));
assert!(body(90.0, 30.0).abs_diff_eq(Vec3::new(-100.0, 0.0, 0.0), 1e-3));
}
@@ -500,10 +502,13 @@ mod tests {
let at = |p: Vec3| view.transform_point3(p);
// Straight down: a block below the player is farther from the light.
assert!(at(Vec3::new(0.0, -1.0, 0.0)).z < at(Vec3::ZERO).z);
// The player is less than a step from the centre, 128 blocks from the light.
let centre = at(Vec3::ZERO);
assert!(centre.x.abs() <= 2.0 && centre.y.abs() <= 2.0, "{centre}");
assert!((centre.z + 128.0).abs() < 1e-3, "{centre}");
// The view's origin is interval / 2 - camera % interval from the player on each axis,
// the remainder with the camera's sign: up to a step and a half on negative axes.
let origin = view.inverse().w_axis.truncate();
assert!(
origin.abs_diff_eq(Vec3::new(0.7, 1.0, 1.7), 1e-3),
"{origin}"
);
// Moving within a step keeps the world where it was in the map.
camera.x = 1000.6;
let (moved, _) = shadow_camera(Vec3::Y, camera, &shadow);
@@ -515,7 +520,38 @@ mod tests {
.abs_diff_eq(at(world), 1e-3)
);
// shadowDistance blocks reach the map's edge.
let edge = projection.project_point3(Vec3::new(160.0, 0.0, -128.0));
let edge = projection.project_point3(Vec3::new(160.0, 0.0, 0.0));
assert!((edge.x.abs() - 1.0).abs() < 1e-4, "{edge}");
}
/// Iris's matrices at 3000 ticks with `sunPathRotation = 30` and the camera at
/// (0.5, -58.38, 0.5), read from its uniforms by a probe pack.
#[test]
fn the_shadow_camera_matches_iris() {
let shadow = Shadow {
sun_path_rotation: 30.0,
..Shadow::default()
};
let light = Vec3::new(0.56782, 0.71287, -0.41158).normalize();
let (view, projection) = shadow_camera(light, DVec3::new(0.5, -58.38, 0.5), &shadow);
let iris_view = Mat4::from_cols_array_2d(&[
[0.82315, 0.0, 0.56782, 0.0],
[-0.49175, -0.5, 0.71287, 0.0],
[0.28391, -0.86603, -0.41158, 0.0],
[0.12508, 1.12301, -1.06188, 1.0],
]);
assert!(view.abs_diff_eq(iris_view, 2e-4), "{view}");
assert!(
(projection.z_axis.z + 0.0078110).abs() < 1e-6,
"{projection}"
);
assert!((projection.w_axis.z + 0.21851).abs() < 1e-4, "{projection}");
// Elsewhere the origin keeps to the steps: Iris put it at (-0.5, 1.38, 2.5) here.
let (view, _) = shadow_camera(light, DVec3::new(3.5, -58.38, -1.5), &shadow);
let origin = view.inverse().w_axis.truncate();
assert!(
origin.abs_diff_eq(Vec3::new(-0.5, 1.38, 2.5), 1e-3),
"{origin}"
);
}
}
+274 -23
View File
@@ -27,7 +27,10 @@ impl Column {
/// Values of type `T` per section key, in a dense list with stable keys and
/// slots that move only when a section is removed.
pub struct Resident<T> {
/// The minimum Y of section index 0 (the level's column).
min_y: i32,
items: Vec<(SectionKey, T)>,
bounds: Vec<Option<[Vec3; 2]>>,
slots: FxHashMap<SectionKey, u32>,
columns: Vec<Column>,
column_index: FxHashMap<(i32, i32), u32>,
@@ -36,7 +39,9 @@ pub struct Resident<T> {
impl<T> Default for Resident<T> {
fn default() -> Self {
Self {
min_y: crate::chunk::MIN_Y,
items: Vec::new(),
bounds: Vec::new(),
slots: FxHashMap::default(),
columns: Vec::new(),
column_index: FxHashMap::default(),
@@ -54,6 +59,14 @@ impl<T> Resident<T> {
pub fn values(&self) -> impl Iterator<Item = &T> {
self.items.iter().map(|(_, v)| v)
}
/// `key`'s value, when it is resident.
pub fn find(&self, key: SectionKey) -> Option<&T> {
self.slots.get(&key).map(|&slot| self.get(slot))
}
pub fn find_mut(&mut self, key: SectionKey) -> Option<&mut T> {
let slot = *self.slots.get(&key)?;
Some(self.get_mut(slot))
}
/// The section in `slot` (`Visible::slot`).
pub fn get(&self, slot: u32) -> &T {
&self.items[slot as usize].1
@@ -61,20 +74,32 @@ impl<T> Resident<T> {
pub fn get_mut(&mut self, slot: u32) -> &mut T {
&mut self.items[slot as usize].1
}
/// Sections from now on count from `min_y` (a level of another column).
pub fn set_min_y(&mut self, min_y: i32) {
self.min_y = min_y;
}
pub fn clear(&mut self) {
self.items.clear();
self.bounds.clear();
self.slots.clear();
self.columns.clear();
self.column_index.clear();
}
/// Adds or replaces `key`'s value.
/// Adds or replaces `key`'s value with default 16x16x16 bounds.
#[cfg(test)]
pub fn insert(&mut self, key: SectionKey, value: T) {
self.insert_with_bounds(key, value, None);
}
/// Adds or replaces `key`'s value with optional tight geometry bounds.
pub fn insert_with_bounds(&mut self, key: SectionKey, value: T, bounds: Option<[Vec3; 2]>) {
if let Some(&slot) = self.slots.get(&key) {
self.items[slot as usize].1 = value;
self.bounds[slot as usize] = bounds;
return;
}
let slot = self.items.len() as u32;
self.items.push((key, value));
self.bounds.push(bounds);
self.slots.insert(key, slot);
let column = match self.column_index.get(&(key.0, key.2)) {
Some(&c) => &mut self.columns[c as usize],
@@ -114,6 +139,7 @@ impl<T> Resident<T> {
} else {
column.bounds();
}
let _ = self.bounds.swap_remove(slot as usize);
let (_, value) = self.items.swap_remove(slot as usize);
// The last item now sits in `slot`: point its key and column at it.
if let Some(&(moved, _)) = self.items.get(slot as usize) {
@@ -144,10 +170,10 @@ pub struct Visible {
}
/// The camera-relative origin of the section at `key`.
fn origin(key: SectionKey, camera: DVec3) -> DVec3 {
fn origin(key: SectionKey, min_y: i32, camera: DVec3) -> DVec3 {
DVec3::new(
f64::from(key.0 * 16),
f64::from(crate::chunk::MIN_Y + key.1 * 16),
f64::from(min_y + key.1 * 16),
f64::from(key.2 * 16),
) - camera
}
@@ -160,30 +186,57 @@ fn camera_column(camera: DVec3) -> (i32, i32) {
)
}
/// The world AABB (min, max) of a section from its origin and optional tight bounds.
#[inline]
fn section_box(min: Vec3, bounds: Option<[Vec3; 2]>) -> (Vec3, Vec3) {
match bounds {
Some([bmin, bmax]) => (min + bmin, min + bmax),
None => (min, min + Vec3::splat(16.0)),
}
}
/// Whether the section at `key` is drawn: its column within the render
/// distance of the camera's (`SectionOcclusionGraph.isInViewDistance`), and
/// partly inside the frustum. `None` when it is not, else its squared centre
/// distance and origin. `Resident::visible` walks columns instead.
#[cfg(test)]
fn in_view(key: SectionKey, camera: DVec3, radius: i32, frustum: &Frustum) -> Option<(f64, Vec3)> {
in_view_with_bounds(key, camera, radius, frustum, None)
}
/// `in_view` with optional tight bounds.
#[cfg(test)]
fn in_view_with_bounds(
key: SectionKey,
camera: DVec3,
radius: i32,
frustum: &Frustum,
bounds: Option<[Vec3; 2]>,
) -> Option<(f64, Vec3)> {
if !in_view_distance(camera_column(camera), radius, key.0, key.2) {
return None;
}
in_frustum(key, camera, frustum, false)
in_frustum(key, crate::chunk::MIN_Y, camera, frustum, false, bounds)
}
/// `in_view` without the distance test; `inside`: the section's column is
/// entirely inside the frustum, so the section is too.
#[cfg(test)]
fn in_frustum(
key: SectionKey,
min_y: i32,
camera: DVec3,
frustum: &Frustum,
inside: bool,
bounds: Option<[Vec3; 2]>,
) -> Option<(f64, Vec3)> {
let origin = origin(key, camera);
let origin = origin(key, min_y, camera);
let min = origin.as_vec3();
if !inside && !frustum.intersects(min, min + Vec3::splat(16.0)) {
return None;
if !inside {
let (bmin, bmax) = section_box(min, bounds);
if !frustum.intersects(bmin, bmax) {
return None;
}
}
Some(((origin + DVec3::splat(8.0)).length_squared(), min))
}
@@ -216,24 +269,57 @@ impl<T> Resident<T> {
if !near && !beyond(column.x, column.z) {
continue;
}
let min = origin((column.x, column.low, column.z), camera).as_vec3();
let top = origin((column.x, column.high, column.z), camera).as_vec3();
let min = origin((column.x, column.low, column.z), self.min_y, camera).as_vec3();
let top = origin((column.x, column.high, column.z), self.min_y, camera).as_vec3();
let max = top + Vec3::splat(16.0);
let inside = frustum.contains(min, max);
if !inside && !frustum.intersects(min, max) {
continue;
}
for &(y, slot) in &column.sections {
let key = (column.x, y, column.z);
if near && !reachable(key) {
continue;
}
if let Some((d, o)) = in_frustum(key, camera, frustum, inside) {
let ox = f64::from(column.x * 16) - camera.x;
let oz = f64::from(column.z * 16) - camera.z;
let min_x = ox as f32;
let min_z = oz as f32;
let dx8 = ox + 8.0;
let dz8 = oz + 8.0;
let xz_sq = dx8 * dx8 + dz8 * dz8;
if inside {
for &(y, slot) in &column.sections {
let key = (column.x, y, column.z);
if near && !reachable(key) {
continue;
}
let oy = f64::from(self.min_y + y * 16) - camera.y;
let min = Vec3::new(min_x, oy as f32, min_z);
let dy8 = oy + 8.0;
let distance = xz_sq + dy8 * dy8;
out.push(Visible {
distance: d.to_bits(),
distance: distance.to_bits(),
key,
slot,
origin: o.to_array(),
origin: min.to_array(),
});
}
} else {
for &(y, slot) in &column.sections {
let key = (column.x, y, column.z);
if near && !reachable(key) {
continue;
}
let oy = f64::from(self.min_y + y * 16) - camera.y;
let min = Vec3::new(min_x, oy as f32, min_z);
let (bmin, bmax) = section_box(min, self.bounds[slot as usize]);
if !frustum.intersects(bmin, bmax) {
continue;
}
let dy8 = oy + 8.0;
let distance = xz_sq + dy8 * dy8;
out.push(Visible {
distance: distance.to_bits(),
key,
slot,
origin: min.to_array(),
});
}
}
@@ -255,7 +341,15 @@ mod tests {
for y in 0..24 {
// Leave holes so columns have uneven bounds.
if (x * 7 + y * 3 + z * 5).rem_euclid(4) != 0 {
r.insert((x, y, z), ());
// Mix of sections with fallback None and randomized tight bounds.
let bounds = if (x + y + z).rem_euclid(2) == 0 {
let y_min = ((x + z).rem_euclid(4)) as f32;
let y_max = y_min + 2.0 + ((y.rem_euclid(5)) as f32);
Some([Vec3::new(0.0, y_min, 0.0), Vec3::new(16.0, y_max, 16.0)])
} else {
None
};
r.insert_with_bounds((x, y, z), (), bounds);
}
}
}
@@ -281,12 +375,21 @@ mod tests {
let mut slow: Vec<(SectionKey, f64, [f32; 3])> = r
.items
.iter()
.filter_map(|&(k, _)| {
.enumerate()
.filter_map(|(slot, &(k, _))| {
let near = in_view_distance(camera_column(camera), radius, k.0, k.2);
if near {
in_view(k, camera, radius, &frustum).filter(|_| reachable(k))
in_view_with_bounds(k, camera, radius, &frustum, r.bounds[slot])
.filter(|_| reachable(k))
} else if beyond(k.0, k.2) {
in_frustum(k, camera, &frustum, false)
in_frustum(
k,
crate::chunk::MIN_Y,
camera,
&frustum,
false,
r.bounds[slot],
)
} else {
None
}
@@ -329,8 +432,8 @@ mod tests {
.columns
.iter()
.filter(|c| {
let min = origin((c.x, c.low, c.z), camera).as_vec3();
let top = origin((c.x, c.high, c.z), camera).as_vec3();
let min = origin((c.x, c.low, c.z), crate::chunk::MIN_Y, camera).as_vec3();
let top = origin((c.x, c.high, c.z), crate::chunk::MIN_Y, camera).as_vec3();
frustum.contains(min, top + Vec3::splat(16.0))
})
.count();
@@ -455,4 +558,152 @@ mod tests {
list.len()
);
}
#[test]
fn tight_mesh_bounds_culls_section_outside_frustum() {
let mut r = Resident::default();
let key = (0, 0, 0);
// Section geometry only occupies local Y in [0.0, 2.0]
let tight = Some([Vec3::new(0.0, 0.0, 0.0), Vec3::new(16.0, 2.0, 16.0)]);
r.insert_with_bounds(key, (), tight);
let camera = DVec3::new(8.0, f64::from(crate::chunk::MIN_Y + 15), -2.0);
let projection = super::super::world_projection(70.0, 16.0 / 9.0, 512.0);
let view = crate::camera::view_matrix(0.0, -60.0);
let frustum = Frustum {
planes: super::super::frustum(projection * view),
};
// Tight bounds culls the section whose geometry is entirely outside the frustum
let mut out = Vec::new();
r.visible(camera, 4, &frustum, |_| true, |_, _| false, &mut out);
assert!(
out.is_empty(),
"tight bounds should cull section whose geometry is below frustum"
);
// With fallback 16x16x16 bounds, the section intersects the frustum and is visible
r.insert_with_bounds(key, (), None);
out.clear();
r.visible(camera, 4, &frustum, |_| true, |_, _| false, &mut out);
assert_eq!(
out.len(),
1,
"full 16x16x16 bounds intersect frustum and are accepted"
);
}
#[test]
#[ignore = "benchmark"]
fn tight_bounds_benchmark() {
use std::time::Instant;
let mut r_fallback = Resident::default();
let mut r_tight = Resident::default();
let radius = 16;
for x in -radius..=radius {
for z in -radius..=radius {
// Section 0..4: underground rock
for y in 0..4 {
r_fallback.insert((x, y, z), ());
r_tight.insert_with_bounds((x, y, z), (), None);
}
// Section 4: surface terrain with grass / hills (e.g. Y in [0, 4] or [0, 6])
let height = 2.0 + (((x * 3 + z * 5).rem_euclid(5)) as f32) * 1.5;
let surface_bounds =
Some([Vec3::new(0.0, 0.0, 0.0), Vec3::new(16.0, height, 16.0)]);
r_fallback.insert((x, 4, z), ());
r_tight.insert_with_bounds((x, 4, z), (), surface_bounds);
}
}
println!("\n=== Tight Bounds Culling Benchmark ===");
println!("Total Resident Sections: {}", r_tight.len());
let test_views = [
("Looking level across terrain (pitch 0°)", 0.0, 0.0),
("Looking slightly up (pitch -15°)", 45.0, -15.0),
("Looking up towards sky (pitch -35°)", 90.0, -35.0),
("Looking down at ground (pitch 25°)", 180.0, 25.0),
("Diagonal view (yaw 135°, pitch -10°)", 135.0, -10.0),
];
let camera = DVec3::new(8.0, f64::from(crate::chunk::MIN_Y + 4 * 16 + 10), 8.0);
let projection = super::super::world_projection(70.0, 16.0 / 9.0, 512.0);
for (desc, yaw, pitch) in test_views {
let view = crate::camera::view_matrix(yaw, pitch);
let frustum = Frustum {
planes: super::super::frustum(projection * view),
};
let mut out_fallback = Vec::new();
let mut out_tight = Vec::new();
r_fallback.visible(
camera,
radius,
&frustum,
|_| true,
|_, _| false,
&mut out_fallback,
);
r_tight.visible(
camera,
radius,
&frustum,
|_| true,
|_, _| false,
&mut out_tight,
);
let culled_extra = out_fallback.len().saturating_sub(out_tight.len());
let pct_saved = if !out_fallback.is_empty() {
(culled_extra as f64 / out_fallback.len() as f64) * 100.0
} else {
0.0
};
// Timing over 1,000 passes
let iters = 1_000;
let t0 = Instant::now();
for _ in 0..iters {
out_fallback.clear();
r_fallback.visible(
camera,
radius,
&frustum,
|_| true,
|_, _| false,
&mut out_fallback,
);
}
let dur_fallback = t0.elapsed();
let t1 = Instant::now();
for _ in 0..iters {
out_tight.clear();
r_tight.visible(
camera,
radius,
&frustum,
|_| true,
|_, _| false,
&mut out_tight,
);
}
let dur_tight = t1.elapsed();
println!(
"{desc}\n Fallback 16x16: {} sections | {:?}\n Tight Bounds: {} sections | {:?}\n Reduction: -{} sections ({:.1}% culled)\n",
out_fallback.len(),
dur_fallback / iters,
out_tight.len(),
dur_tight / iters,
culled_extra,
pct_saved
);
}
}
}
@@ -55,9 +55,9 @@ mod tests {
let n = |i: usize| Vec3::from_slice(&normals[i][..3]);
let mut best = 0.0f32;
let mut k0 = d.dot(n(TRIANGLES));
for i in 0..TRIANGLES {
for (i, triangle) in normals.iter().take(TRIANGLES).enumerate() {
let k1 = d.dot(n(TRIANGLES + (i + 1) % TRIANGLES));
let w = normals[i][3];
let w = triangle[3];
if k0 * w <= 0.0 && k1 * w >= 0.0 {
let apex = d.dot(n(i));
let s = apex + k1 - k0;
@@ -75,6 +75,115 @@ mod tests {
best
}
// Projecting the fan onto the YZ plane leaves uniformly spaced rim
// angles after undoing its Y squash. Test both ray directions because
// the projection plane goes through the camera.
fn sector_weight(alpha: f32, sun: Vec3, d: Vec3) -> f32 {
let normals = normals(alpha, sun);
if d.y * d.y + d.z * d.z < 1e-10 {
return weight(&normals, d);
}
let n = |i: usize| Vec3::from_slice(&normals[i][..3]);
let apex = d.dot(n(0));
if apex * normals[0][3] < -1e-4 {
return 0.0;
}
let y = d.dot(n(16));
let x = -d.dot(n(20));
let ax = x.abs();
let ay = y.abs();
let t = std::f32::consts::SQRT_2 - 1.0;
let q = if ay > ax {
if ax < ay * t { 3 } else { 2 }
} else if ay > ax * t {
1
} else {
0
};
let sector = if x >= 0.0 {
if y >= 0.0 { q } else { 15 - q }
} else if y >= 0.0 {
7 - q
} else {
8 + q
};
let mut best = 0.0f32;
let k0 = d.dot(n(16 + sector as usize));
let k1 = d.dot(n(16 + ((sector + 1) % 16) as usize));
let offsets: &[i32] = if k0.abs().min(k1.abs()) < ax.max(ay) * 0.001 {
&[0, 8, -1, 1, 7, 9]
} else {
&[0, 8]
};
for &offset in offsets {
let i = (sector + offset).rem_euclid(16) as usize;
let k0 = d.dot(n(16 + i));
let k1 = d.dot(n(16 + (i + 1) % 16));
let w = normals[i][3];
let apex = d.dot(n(i));
let sum = apex + k1 - k0;
if sum.abs() >= 1e-6
&& sum * w > 0.0
&& apex * sum >= 0.0
&& k1 * sum >= 0.0
&& k0 * sum <= 0.0
{
best = best.max(apex / sum);
}
}
best
}
#[test]
fn angular_sector_matches_random_and_exact_rim_directions() {
let mut state = 19u32;
let mut random = || {
state = state.wrapping_mul(1664525).wrapping_add(1013904223);
(state >> 8) as f32 / 16777216.0
};
for _ in 0..1000 {
let alpha = 0.0011 + random() * 0.9989;
let z = if random() > 0.5 { 0.3 } else { -0.2 };
let sun = Vec3::new(0.0, (1.0f32 - z * z).sqrt(), z);
let normals = normals(alpha, sun);
let (_, rim) = vertices(alpha, sun);
let random_ray = Vec3::new(
random() * 2.0 - 1.0,
random() * 2.0 - 1.0,
random() * 2.0 - 1.0,
)
.normalize();
for ray in rim.into_iter().chain([random_ray]) {
let d = ray.normalize();
let (got, want) = (sector_weight(alpha, sun, d), weight(&normals, d));
assert!(
(got - want).abs() < 1e-4,
"{alpha} {sun} {d}: {got} != {want}"
);
}
}
}
#[test]
fn angular_sector_matches_all_triangle_search() {
for alpha in [0.0011f32, 0.05, 0.6, 1.0] {
for z in [-0.2f32, 0.3] {
let sun = Vec3::new(0.0, (1.0 - z * z).sqrt(), z);
let normals = normals(alpha, sun);
for yaw in 0..360 {
for pitch in -89..90 {
let (y, p) = ((yaw as f32).to_radians(), (pitch as f32).to_radians());
let d = Vec3::new(y.cos() * p.cos(), p.sin(), y.sin() * p.cos());
let (got, want) = (sector_weight(alpha, sun, d), weight(&normals, d));
assert!(
(got - want).abs() < 1e-4,
"alpha={alpha} z={z} d={d} {got} != {want}"
);
}
}
}
}
}
/// The shader's former per-pixel Möller-Trumbore over the same vertices.
fn reference(alpha: f32, sun: Vec3, d: Vec3) -> f32 {
let (apex, rim) = vertices(alpha, sun);
+108 -5
View File
@@ -10,19 +10,36 @@ const CHUNK: u64 = 1 << 20;
pub struct Uploads {
belt: wgpu::util::StagingBelt,
chunk: u64,
}
impl Uploads {
pub fn new(device: &wgpu::Device) -> Self {
Self::with_chunk(device, CHUNK)
}
/// A belt whose staging buffers hold `chunk` bytes (larger writes get
/// a buffer of their own).
/// A belt whose staging buffers hold `chunk` bytes (a larger write gets
/// a buffer of its own, rounded up to a power of two).
pub fn with_chunk(device: &wgpu::Device, chunk: u64) -> Self {
Self {
belt: wgpu::util::StagingBelt::new(device.clone(), chunk),
chunk,
}
}
/// A mapped staging slice of at least `size` bytes. The belt gives a
/// write larger than its chunk a buffer of exactly that size, keeps it for
/// good and only reuses it for writes that fit: an upload that grows a
/// little every frame (the entity batch while mobs appear) took a new
/// buffer each frame until the game ran out of memory (#2826). Rounded up
/// to a power of two, the large buffers it keeps are reused, one per
/// doubling.
fn allocate(&mut self, size: NonZeroU64, alignment: NonZeroU64) -> wgpu::BufferSlice<'_> {
let size = if size.get() > self.chunk {
size.checked_next_power_of_two().unwrap_or(size)
} else {
size
};
self.belt.allocate(size, alignment)
}
/// Copy tightly packed RGBA8 `pixels` (`width` by `height`) into one mip
/// level of one array layer, padding rows to the copy alignment.
pub fn texture(
@@ -41,7 +58,7 @@ impl Uploads {
};
let alignment = NonZeroU64::new(u64::from(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT))
.expect("non-zero alignment");
let slice = self.belt.allocate(size, alignment);
let slice = self.allocate(size, alignment);
{
let mut view = slice
.get_mapped_range_mut()
@@ -80,8 +97,20 @@ impl Uploads {
let Some(size) = NonZeroU64::new(bytes.len() as u64) else {
return;
};
self.belt
.write_buffer(encoder, target, offset, size)
// The belt's own `write_buffer` checks, kept now that a large write's
// slice is rounded up past them.
assert!(
offset.is_multiple_of(wgpu::COPY_BUFFER_ALIGNMENT)
&& size.get().is_multiple_of(wgpu::COPY_BUFFER_ALIGNMENT),
"buffer upload at {offset} of {size} bytes must be 4-byte aligned"
);
let alignment = NonZeroU64::new(wgpu::COPY_BUFFER_ALIGNMENT).expect("non-zero alignment");
let slice = self.allocate(size, alignment).slice(..size.get());
encoder.copy_buffer_to_buffer(slice.buffer(), slice.offset(), target, offset, size.get());
slice
.get_mapped_range_mut()
.expect("staging belt slices are mapped")
.slice(..)
.copy_from_slice(bytes);
}
/// Close this frame's staging buffers: they come back for reuse once the
@@ -217,6 +246,80 @@ mod tests {
}
}
/// Staging buffers the belt holds, once every submitted frame is done
/// with them. The belt only reports its buffers through `Debug`.
fn kept_buffers(device: &wgpu::Device, uploads: &mut Uploads) -> usize {
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
// Finishing an empty frame takes back every recalled buffer.
uploads.finish(&device.create_command_encoder(&Default::default()));
let belt = format!("{:?}", uploads.belt);
let count = |field: &str| -> usize {
let at = belt.find(&format!("{field}: ")).expect(&belt) + field.len() + 2;
let digits = belt[at..].split(|c: char| !c.is_ascii_digit()).next();
digits.and_then(|d| d.parse().ok()).expect(&belt)
};
count("active_chunks") + count("closed_chunks") + count("free_chunks")
}
/// #2826: a write larger than the chunk that grows a little every frame
/// (the entity batch while mobs appear) got a new exact-size buffer each
/// frame, all of them kept, until the game ran out of memory.
#[test]
fn growing_large_writes_reuse_the_belt_buffers() {
let Some((device, queue)) = device() else {
eprintln!("no GPU adapter: skipped");
return;
};
const CHUNK: u64 = 1 << 16;
const TARGET: usize = 4 * CHUNK as usize;
let target = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: TARGET as u64,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
});
let readback = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: TARGET as u64,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut uploads = Uploads::with_chunk(&device, CHUNK);
let mut expected = vec![0u8; TARGET];
let frames = 64;
for frame in 0..frames {
let mut encoder = device.create_command_encoder(&Default::default());
// One small write, then the large one, 1.5 to 3.5 chunks long.
uploads.buffer(&mut encoder, &target, TARGET as u64 - 4, &[9; 4]);
expected[TARGET - 4..].fill(9);
let len = (3 * CHUNK as usize / 2 + frame * 2048).next_multiple_of(4);
let bytes: Vec<u8> = (0..len).map(|i| (i + frame) as u8).collect();
uploads.buffer(&mut encoder, &target, 0, &bytes);
expected[..len].copy_from_slice(&bytes);
encoder.copy_buffer_to_buffer(&target, 0, &readback, 0, TARGET as u64);
uploads.finish(&encoder);
queue.submit([encoder.finish()]);
readback
.slice(..)
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
// Only the written bytes land: the rest of the target is untouched.
assert!(
*readback.slice(..).get_mapped_range().unwrap() == expected[..],
"frame {frame}"
);
readback.unmap();
}
// A chunk for the small writes and a buffer per doubling of the large
// one (2 and 4 chunks), maybe one more where a small write took the
// start of a buffer the large one needed whole: not one per frame.
let kept = kept_buffers(&device, &mut uploads);
assert!(
kept <= 4,
"the belt kept {kept} buffers over {frames} frames"
);
}
#[test]
fn belt_buffer_writes_land_in_order_at_their_offsets() {
let Some((device, queue)) = device() else {
@@ -124,6 +124,26 @@ impl Weather {
uploads.buffer(encoder, &self.vertices, 0, bytemuck::cast_slice(vertices));
uploads.buffer(encoder, &self.uniform, 0, bytemuck::bytes_of(&time));
}
/// The classic pipeline, for its layouts.
pub fn pipeline(&self) -> &wgpu::RenderPipeline {
&self.pipeline
}
/// `draw` with another pipeline of the same layouts and vertex format.
pub fn draw_with(
&self,
pass: &mut wgpu::RenderPass<'_>,
pipeline: &wgpu::RenderPipeline,
entities: &wgpu::BindGroup,
) {
if self.count == 0 {
return;
}
pass.set_pipeline(pipeline);
pass.set_bind_group(0, entities, &[]);
pass.set_bind_group(1, &self.group, &[]);
pass.set_vertex_buffer(0, self.vertices.slice(..));
pass.draw_indexed(0..self.count / 4 * 6, 0, 0..1);
}
pub fn draw(&self, pass: &mut wgpu::RenderPass<'_>, entities: &wgpu::BindGroup) {
if self.count == 0 {
return;
@@ -344,7 +344,7 @@ impl Post {
"Scaled world depth",
size,
wgpu::TextureFormat::Depth32Float,
sampled,
sampled | wgpu::TextureUsages::COPY_SRC,
)
.create_view(&Default::default());
let group = self.bind(device, &color);
+17 -1
View File
@@ -311,6 +311,8 @@ pub fn reduced_far_detail_suggested() -> bool {
REDUCED_SUGGESTED.load(std::sync::atomic::Ordering::Relaxed)
}
/// The name the options screen waits on to bind `Graphics::hdr_key`.
pub const HDR_KEY: &str = "horizon.hdrSettings";
static HDR_OFFERED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
/// Whether the display path offers HDR output (HDR10 or scRGB): set when the renderer
/// opens its surface; the settings open HDR's screen only then.
@@ -391,6 +393,8 @@ pub struct Graphics {
/// HDR brightness of the HUD and menus, in percent of paper white (25..=100); the
/// first-person hand stays at the world's.
pub hdr_ui_brightness: u32,
/// A vanilla key name (`HDR_KEY`): opens HDR's settings from the game. Unbound by default.
pub hdr_key: String,
/// Battery mode: at most 60 frames per second in a world and 30 in menus
/// or behind other windows, and half the worker threads. Off by default;
/// it changes when frames are drawn, never what they show or the ticks.
@@ -429,6 +433,7 @@ impl Default for Graphics {
hdr_peak: 0,
hdr_black_level: 0,
hdr_ui_brightness: 100,
hdr_key: "key.keyboard.unknown".into(),
battery: false,
api: GraphicsApi::Auto,
shader_pack: String::new(),
@@ -507,6 +512,7 @@ impl Graphics {
"hdrUiBrightness" => {
g.hdr_ui_brightness = number(g.hdr_ui_brightness).clamp(25, 100)
}
"hdrKey" if value.starts_with("key.") => g.hdr_key = value.to_owned(),
"batteryMode" => g.battery = flag(g.battery),
"graphicsApi" => g.api = GraphicsApi::from_name(value).unwrap_or_default(),
"pickedShaderPack" => g.shader_pack = value.to_owned(),
@@ -524,7 +530,7 @@ impl Graphics {
}
pub fn serialize(&self) -> String {
format!(
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nbatteryMode:{}\ngraphicsApi:{}\npickedShaderPack:{}\n",
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\ngraphicsApi:{}\npickedShaderPack:{}\n",
self.preset.name(),
self.lod_distance,
self.far_detail.name(),
@@ -556,11 +562,20 @@ impl Graphics {
self.hdr_peak,
self.hdr_black_level,
self.hdr_ui_brightness,
self.hdr_key,
bool_text(self.battery),
self.api.name(),
self.shader_pack,
)
}
/// Binds Horizon's graphics key `name` (`HDR_KEY`); false for another name.
pub fn set_key(&mut self, name: &str, value: String) -> bool {
match name {
HDR_KEY => self.hdr_key = value,
_ => return false,
}
true
}
pub fn save(&self, dir: &Path) {
let _ = fs::write(dir.join(FILE), self.serialize());
}
@@ -691,6 +706,7 @@ mod tests {
hdr_peak: 1400,
hdr_black_level: 12,
hdr_ui_brightness: 60,
hdr_key: "key.keyboard.h".into(),
battery: true,
api: GraphicsApi::Dx12,
shader_pack: "Golden Hour".into(),
@@ -3,6 +3,7 @@
//! lay out rows and handle input; a menu theme replaces this module.
use crate::{
hud::{HudAtlas, HudBatch},
lang,
text::Text,
ui,
};
@@ -131,8 +132,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(
@@ -144,10 +157,11 @@ impl Style {
cost: Cost,
text: &str,
) {
let tag = Text::literal(cost.label());
let tag = Text::literal(lang::get(cost.label()));
batch.text_styled(atlas, &tag, x, y, self.cost_colors[cost as usize], true);
let indent = atlas.font.width_text(&tag) + 4.0;
let lines = wrap(atlas, text, self.text_width(batch.width), indent);
let text = lang::get(text);
let lines = wrap(atlas, &text, self.text_width(batch.width), indent);
for (i, line) in lines.iter().take(2).enumerate() {
let lx = if i == 0 { x + indent } else { x };
batch.text_styled(
+62 -20
View File
@@ -5,6 +5,7 @@ use crate::{
graphics::{FarDetail, GraphicsApi, Overlay, Pacing, PresentMode, Preset, Upscaling},
graphics_style::{Cost, STYLE},
hud::{HudAtlas, HudBatch},
lang,
settings::{Opt, Settings},
text::Text,
ui,
@@ -43,6 +44,8 @@ pub enum H {
/// Opens `Level::Hdr`.
HdrSettings,
HdrBlackLevel,
/// Binds `Graphics::hdr_key`.
HdrKey,
HdrPeak,
HdrPaperWhite,
HdrUiBrightness,
@@ -153,6 +156,11 @@ pub fn rows(level: Level) -> Vec<Row> {
None,
"How bright the HUD and menus are, against paper white.",
),
h(
H::HdrKey,
None,
"A key that opens this screen from the game. Not bound at first.",
),
],
Level::Advanced => vec![
Row::Header("Rendering"),
@@ -354,11 +362,14 @@ fn extra_rows(level: Level, settings: &Settings) -> Vec<Row> {
rows
}
fn on_off(on: bool) -> &'static str {
if on { "On" } else { "Off" }
fn on_off(on: bool) -> String {
lang::get(if on { "On" } else { "Off" })
}
/// A control's caption and its value, both through the language table: Horizon's captions are
/// English sentences, and some of the values ("Off", "12 chunks") are words too.
fn labelled(caption: &str, value: impl std::fmt::Display) -> Text {
Text::literal(format!("{caption}: {value}"))
let value = value.to_string();
Text::literal(format!("{}: {}", lang::get(caption), lang::get(&value)))
}
impl H {
@@ -396,19 +407,21 @@ impl H {
H::Preset => labelled("Preset", g.preset.label()),
H::LodDistance => match g.lod_distance {
0 => labelled("LOD Distance", "Off"),
n => labelled("LOD Distance", format!("{n} chunks")),
n => labelled("LOD Distance", lang::tr("%s chunks", &[&n])),
},
H::FarDetail => labelled("Far Detail", g.far_detail.label()),
H::CaveCulling => labelled("Cave Culling", on_off(g.cave_culling)),
H::EntityCulling => labelled("Entity Culling", on_off(g.entity_culling)),
H::ChunkUpdates => match g.chunk_updates {
0 => labelled("Chunk Updates", "Unlimited"),
n => labelled("Chunk Updates", format!("{n} per frame")),
n => labelled("Chunk Updates", lang::tr("%s per frame", &[&n])),
},
H::UploadBudget => match g.upload_budget_kib {
0 => labelled("Upload Budget", "Unlimited"),
k if k >= 1024 => labelled("Upload Budget", format!("{} MiB/frame", k / 1024)),
k => labelled("Upload Budget", format!("{k} KiB/frame")),
k if k >= 1024 => {
labelled("Upload Budget", lang::tr("%s MiB/frame", &[&(k / 1024)]))
}
k => labelled("Upload Budget", lang::tr("%s KiB/frame", &[&k])),
},
H::Sky => labelled("Sky", on_off(g.sky)),
H::Fog => labelled("Fog", on_off(g.fog)),
@@ -423,7 +436,10 @@ impl H {
},
),
H::Workers => match g.workers {
0 => labelled("Worker Threads", format!("Auto ({})", g.worker_threads())),
0 => labelled(
"Worker Threads",
lang::tr("Auto (%s)", &[&g.worker_threads()]),
),
n => labelled("Worker Threads", n),
},
H::ResolutionScale => labelled("Resolution Scale", format!("{}%", g.resolution_scale)),
@@ -437,23 +453,32 @@ impl H {
),
H::Upscaling => labelled("Upscaling", g.upscaling.label()),
H::Hdr => labelled("HDR", on_off(g.hdr)),
H::HdrSettings => Text::literal("HDR Settings..."),
H::HdrSettings => Text::literal(lang::get("HDR Settings...")),
H::HdrPaperWhite => match g.hdr_paper_white {
0 => labelled(
"Paper White",
format!("Auto ({} nits)", g.paper_white_nits()),
lang::tr("Auto (%s nits)", &[&g.paper_white_nits()]),
),
n => labelled("Paper White", format!("{n} nits")),
n => labelled("Paper White", lang::tr("%s nits", &[&n])),
},
H::HdrPeak => match g.hdr_peak {
0 => labelled("Peak Brightness", format!("Auto ({} nits)", g.peak_nits())),
n => labelled("Peak Brightness", format!("{n} nits")),
0 => labelled(
"Peak Brightness",
lang::tr("Auto (%s nits)", &[&g.peak_nits()]),
),
n => labelled("Peak Brightness", lang::tr("%s nits", &[&n])),
},
H::HdrBlackLevel => labelled(
"Black Level",
format!("{:.2} nits", g.hdr_black_level as f32 / 100.0),
lang::tr(
"%s nits",
&[&format!("{:.2}", g.hdr_black_level as f32 / 100.0)],
),
),
H::HdrUiBrightness => labelled("UI Brightness", format!("{}%", g.hdr_ui_brightness)),
H::HdrKey => {
Text::literal("Shortcut: ").append(crate::keybinds::display_name(&g.hdr_key))
}
H::BatteryMode => labelled("Battery Mode", on_off(g.battery)),
H::GraphicsApi => labelled("Graphics API", g.api.label()),
H::Overlay => labelled(
@@ -618,7 +643,7 @@ impl Item {
match self {
Item::Vanilla(Opt::RenderDistance) if s.horizon.lod_distance > 0 => labelled(
"Full Detail Distance",
format!("{} chunks", s.render_distance),
lang::tr("%s chunks", &[&s.render_distance]),
),
Item::Vanilla(o) => o.label(s),
Item::Horizon(h) => h.label(s),
@@ -686,6 +711,8 @@ pub enum Action {
OpenAdvanced,
OpenSimple,
OpenHdr,
/// Wait for a key or mouse button to bind to this name.
BindKey(&'static str),
Done,
}
@@ -708,6 +735,8 @@ pub struct GraphicsUi {
/// The list's scroll amount and its held scrollbar (`AbstractScrollArea`).
scroll: ui::Scroll,
dragging: Option<Item>,
/// The key binding the options screen is waiting on (`Action::BindKey`).
pub binding: Option<&'static str>,
}
impl GraphicsUi {
@@ -818,7 +847,7 @@ impl GraphicsUi {
let x = STYLE.row_x(w);
for (y, name) in headers {
if shown(y, STYLE.header_height) {
STYLE.header(batch, atlas, &Text::literal(name), x, y);
STYLE.header(batch, atlas, &Text::literal(lang::get(name)), x, y);
}
}
let descriptions: Vec<(Cost, &'static str)> = extra_rows(level, s)
@@ -840,11 +869,17 @@ impl GraphicsUi {
if !shown(wd.y, 20.0) {
continue;
}
let label = item.label(s);
let mut label = item.label(s);
if item == Item::Horizon(H::HdrKey) && self.binding.is_some() {
// KeyBindsScreen: "> key <" while it waits.
label = Text::literal("Shortcut: > ")
.append(crate::keybinds::display_name(&s.horizon.hdr_key))
.append(" <");
}
let blocked = item.blocked(s);
let live = blocked.is_none();
if hover && let Some(why) = blocked {
tooltip = Some(atlas.font.split(&Text::literal(why), 170.0));
tooltip = Some(atlas.font.split(&Text::literal(lang::get(why)), 170.0));
}
match item.slider(s) {
Some(value) => STYLE.slider(
@@ -855,6 +890,7 @@ impl GraphicsUi {
value,
hover || self.dragging == Some(item),
live,
self.dragging == Some(item),
),
None => STYLE.button(
batch,
@@ -884,7 +920,7 @@ impl GraphicsUi {
batch,
atlas,
(wd.x, wd.y, wd.w),
&Text::literal(label),
&Text::literal(lang::get(label)),
hover,
true,
);
@@ -906,7 +942,7 @@ impl GraphicsUi {
Level::Advanced => "Graphics: Advanced",
Level::Hdr => "Graphics: HDR",
};
STYLE.title(batch, atlas, &Text::literal(title));
STYLE.title(batch, atlas, &Text::literal(lang::get(title)));
if let Some(lines) = tooltip {
ui::tooltip(batch, atlas, &lines, cursor);
}
@@ -964,6 +1000,12 @@ impl GraphicsUi {
Target::Item(Item::Horizon(H::HdrSettings)) => {
vec![Action::ButtonClick, Action::OpenHdr]
}
Target::Item(Item::Horizon(H::HdrKey)) => {
vec![
Action::ButtonClick,
Action::BindKey(crate::graphics::HDR_KEY),
]
}
Target::Item(item) => {
if item.slider(s).is_some() {
self.dragging = Some(item);
+311 -54
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.
@@ -413,49 +455,48 @@ impl HudAtlas {
for folder in folders {
walk(&textures.join(folder), &mut files);
}
// Content mods' equipment textures, which their equipment assets name as `<ns>:<path>`.
if folders.contains(&"entity") {
for namespace in horizon_items::content_namespaces::namespaces(root) {
walk(
&root.join(format!("assets/{namespace}/textures/entity/equipment")),
&mut files,
);
}
}
let assets = root.join("assets");
// Content mods' GUI textures, for their screens: `<namespace>:gui/...`.
if full && let Ok(namespaces) = fs::read_dir(&assets) {
for ns in namespaces.flatten() {
if ns.file_name() != "minecraft" {
walk(&ns.path().join("textures/gui"), &mut files);
}
}
}
files.retain(|p| {
let s = p.to_string_lossy();
!s.contains("title/background") && !s.contains("realms") && !s.contains("presets")
});
type Decoded = (PackedImage, Option<TextureFlags>, Option<(u32, u32)>, bool);
let decode_one = |path: &PathBuf| -> Option<Decoded> {
let name = path
.strip_prefix(&textures)
.ok()?
.with_extension("")
.to_string_lossy()
.replace('\\', "/");
let (mut w, mut h, pixels) = 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 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 name = match path.strip_prefix(&textures) {
Ok(rel) => rel.with_extension("").to_string_lossy().replace('\\', "/"),
Err(_) => {
let rel = path.strip_prefix(&assets).ok()?.with_extension("");
let rel = rel.to_string_lossy().replace('\\', "/");
let (namespace, rest) = rel.split_once("/textures/")?;
format!("{namespace}:{rest}")
}
};
let image = decode(path).ok()?;
let mcmeta = fs::read_to_string(path.with_extension("png.mcmeta")).ok();
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 (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 +548,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 +622,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;
@@ -644,6 +695,45 @@ pub struct HudVertex {
/// unless `uv.x >= 2`.
pub glint_uv: [f32; 2],
}
/// Sutherland-Hodgman against one half plane, interpolating every attribute.
fn clip_polygon(poly: &[HudVertex], axis: usize, limit: f32, keep_below: bool) -> Vec<HudVertex> {
let inside = |v: &HudVertex| {
if keep_below {
v.pos[axis] < limit
} else {
v.pos[axis] >= limit
}
};
let lerp = |a: &HudVertex, b: &HudVertex, t: f32| {
let mix = |p: f32, q: f32| p + (q - p) * t;
let mut v = *a;
v.pos = [mix(a.pos[0], b.pos[0]), mix(a.pos[1], b.pos[1])];
v.uv = [mix(a.uv[0], b.uv[0]), mix(a.uv[1], b.uv[1])];
v.depth = mix(a.depth, b.depth);
v.glint_uv = [
mix(a.glint_uv[0], b.glint_uv[0]),
mix(a.glint_uv[1], b.glint_uv[1]),
];
v.pos[axis] = limit;
v
};
let mut out = Vec::with_capacity(poly.len() + 2);
for i in 0..poly.len() {
let (a, b) = (&poly[i], &poly[(i + 1) % poly.len()]);
let (ia, ib) = (inside(a), inside(b));
if ia {
out.push(*a);
}
if ia != ib {
out.push(lerp(
a,
b,
(limit - a.pos[axis]) / (b.pos[axis] - a.pos[axis]),
));
}
}
out
}
/// The GUI Scale option (0 = auto).
pub static GUI_SCALE: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
/// One frame of overlay geometry in GUI pixels.
@@ -883,8 +973,8 @@ impl HudBatch {
}
}
/// Clip the quads drawn since `start` to `[x0, x1) x [y0, y1)` (the
/// GUI scissor): axis-aligned quads are cut with their UVs, others are
/// kept or dropped by their centre.
/// GUI scissor): axis-aligned quads are cut with their UVs, others (item
/// triangles) are clipped as polygons.
/// Scale the vertices pushed since `start` about `(cx, cy)` (a GUI pose
/// matrix of translate, scale, translate back).
pub fn scale_since(&mut self, start: usize, cx: f32, cy: f32, sx: f32, sy: f32) {
@@ -896,7 +986,11 @@ impl HudBatch {
pub fn clip_since(&mut self, start: usize, x0: f32, y0: f32, x1: f32, y1: f32) {
let quads: Vec<[HudVertex; 6]> = self.vertices[start..].as_chunks::<6>().0.to_vec();
self.vertices.truncate(start);
// Where each source quad now begins, to move the blend ranges (foil
// items are an additive run) along with their vertices.
let mut moved = Vec::with_capacity(quads.len() + 1);
for q in quads {
moved.push(self.vertices.len() as u32);
let corners = [q[0], q[1], q[2], q[5]];
let (mut x_lo, mut x_hi) = (f32::INFINITY, f32::NEG_INFINITY);
let (mut y_lo, mut y_hi) = (f32::INFINITY, f32::NEG_INFINITY);
@@ -915,10 +1009,26 @@ impl HudBatch {
find(x_lo, y_hi),
);
let (Some(tl), Some(tr), Some(br), Some(bl)) = rect else {
let cx = corners.iter().map(|v| v.pos[0]).sum::<f32>() / 4.0;
let cy = corners.iter().map(|v| v.pos[1]).sum::<f32>() / 4.0;
if cx >= x0 && cx < x1 && cy >= y0 && cy < y1 {
self.vertices.extend_from_slice(&q);
// Item geometry arrives as triangles: clip each against the
// rectangle and re-emit the fan, so a triangle straddling the
// edge keeps its visible part instead of vanishing whole.
for tri in [[q[0], q[1], q[2]], [q[3], q[4], q[5]]] {
let area = (tri[1].pos[0] - tri[0].pos[0]) * (tri[2].pos[1] - tri[0].pos[1])
- (tri[2].pos[0] - tri[0].pos[0]) * (tri[1].pos[1] - tri[0].pos[1]);
if area == 0.0 {
continue;
}
let mut poly = tri.to_vec();
for (axis, limit, keep_below) in
[(0, x0, false), (0, x1, true), (1, y0, false), (1, y1, true)]
{
poly = clip_polygon(&poly, axis, limit, keep_below);
}
for i in 1..poly.len().saturating_sub(1) {
// A triangle as the quad `a, b, c, c`.
let (a, b, c) = (poly[0], poly[i], poly[i + 1]);
self.vertices.extend_from_slice(&[a, b, c, a, c, c]);
}
}
continue;
};
@@ -942,6 +1052,14 @@ impl HudBatch {
tl.color,
);
}
moved.push(self.vertices.len() as u32);
let at = |v: u32| moved[((v as usize).saturating_sub(start) / 6).min(moved.len() - 1)];
for (range, _) in &mut self.blends {
if range.start as usize >= start {
*range = at(range.start)..at(range.end);
}
}
self.blends.retain(|(range, _)| !range.is_empty());
}
/// `blit` of the top-left `w x h` texels of a `tw x th` texture.
pub fn sprite_region(
@@ -1816,6 +1934,57 @@ impl HudRenderer {
mod tests {
use super::*;
#[test]
fn clip_since_keeps_the_visible_part_of_a_straddling_triangle() {
let mut batch = HudBatch::new([320, 240]);
let start = batch.vertices.len();
// A right triangle (a quad `a, b, c, c`) whose centre lies outside the rect.
batch.quad(
[[0.0, 0.0], [16.0, 0.0], [0.0, 16.0], [0.0, 16.0]],
[[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [0.0, 1.0]],
u32::MAX,
);
batch.clip_since(start, 0.0, 0.0, 16.0, 4.0);
assert!(!batch.vertices.is_empty());
assert!(batch.vertices.iter().all(|v| v.pos[1] <= 4.0));
let area: f32 = batch
.vertices
.as_chunks::<3>()
.0
.iter()
.map(|t| {
((t[1].pos[0] - t[0].pos[0]) * (t[2].pos[1] - t[0].pos[1])
- (t[2].pos[0] - t[0].pos[0]) * (t[1].pos[1] - t[0].pos[1]))
.abs()
/ 2.0
})
.sum();
// Rows 0..4 of the triangle: 16 * 16 / 2 - 12 * 12 / 2.
assert!((area - 56.0).abs() < 1e-3, "{area}");
}
#[test]
fn clip_since_moves_blend_ranges_with_their_vertices() {
let mut batch = HudBatch::new([320, 240]);
let start = batch.vertices.len();
let tri = |batch: &mut HudBatch, y: f32| {
batch.quad(
[[0.0, y], [8.0, y], [0.0, y + 8.0], [0.0, y + 8.0]],
[[0.0; 2]; 4],
u32::MAX,
);
};
tri(&mut batch, 100.0); // outside, dropped
let foil = batch.vertices.len();
tri(&mut batch, 0.0); // straddles the bottom edge
batch.blend_since(foil, Blend::Additive);
batch.clip_since(start, 0.0, 0.0, 16.0, 4.0);
assert_eq!(
batch.blends,
[(0..batch.vertices.len() as u32, Blend::Additive)]
);
}
/// Write a PNG `width x height` whose `frames` stacked rows are solid
/// colours, then its mcmeta beside it.
fn write_strip(path: &Path, width: u32, height: u32, frames: &[[u8; 4]], mcmeta: Option<&str>) {
@@ -1858,6 +2027,34 @@ mod tests {
atlas.pixels[at..at + 4].try_into().unwrap()
}
#[test]
fn content_mod_equipment_textures_pack_under_their_namespace() {
let root = std::env::temp_dir().join(format!("horizon-atlas-equip-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
let equipment = root.join("assets/gems/textures/entity/equipment/humanoid");
fs::create_dir_all(&equipment).unwrap();
write_strip(
&equipment.join("ruby.png"),
64,
32,
&[[200, 0, 0, 255]],
None,
);
let other = root.join("assets/gems/textures/item");
fs::create_dir_all(&other).unwrap();
write_strip(&other.join("ruby.png"), 16, 16, &[[0, 200, 0, 255]], None);
fs::write(
root.join(horizon_items::content_namespaces::MARKER),
br#"["gems"]"#,
)
.unwrap();
let atlas = HudAtlas::build(&root).unwrap();
let rect = atlas.sprites["gems:entity/equipment/humanoid/ruby"];
assert_eq!(texel_at(&atlas, rect, 0, 0), [200, 0, 0, 255]);
// Only equipment: the mods' other textures reach the atlas through atlas definitions.
assert!(!atlas.sprites.contains_key("gems:item/ruby"));
fs::remove_dir_all(&root).unwrap();
}
#[test]
fn animated_block_sprites_register_and_pack_their_first_mcmeta_frame() {
let root = animated_root();
let atlas = HudAtlas::build(&root).unwrap();
@@ -1890,6 +2087,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)
@@ -1946,6 +2176,33 @@ mod tests {
assert_eq!(((r.w, r.h), r.texels(size)), (gui, texels), "{name}");
}
}
/// A content mod's GUI textures join the atlas under its namespace;
/// its other textures and vanilla's names stay as they were.
#[test]
fn a_content_mods_gui_textures_are_sprites_under_its_namespace() {
let root = std::env::temp_dir().join(format!("horizon-mod-gui-{}", std::process::id()));
write_png(&root, "gui/container/furnace", 256, 256);
let png = |rel: &str, w: u32, h: u32| {
let path = root.join(rel);
fs::create_dir_all(path.parent().unwrap()).unwrap();
let mut encoder = png::Encoder::new(fs::File::create(path).unwrap(), w, h);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let pixels = vec![255; (w * h * 4) as usize];
encoder
.write_header()
.unwrap()
.write_image_data(&pixels)
.unwrap();
};
png("assets/mymod/textures/gui/container/machine.png", 176, 166);
png("assets/mymod/textures/block/machine.png", 16, 16);
let atlas = HudAtlas::build(&root).unwrap();
let _ = fs::remove_dir_all(&root);
assert_eq!(atlas.sprites["mymod:gui/container/machine"].w, 176);
assert!(atlas.sprites.contains_key("gui/container/furnace"));
assert!(!atlas.sprites.contains_key("mymod:block/machine"));
}
#[test]
fn the_default_textures_keep_a_4096_wide_atlas() {
let images = sized(400, 64, 64);
@@ -93,7 +93,7 @@ pub fn java_sort<T>(a: &mut [T], cmp: impl Fn(&T, &T) -> Ordering) {
/// A `MobEffectInstance` as `extractEffects` sees it.
#[derive(Clone, Debug, PartialEq)]
pub struct EffectIcon {
/// The effect id without namespace (`speed`): the sprite is `mob_effect/<id>`.
/// The effect id: vanilla's without namespace (`speed`), a mod's `<ns>:<path>`.
pub id: String,
/// `MobEffect.isBeneficial`.
pub beneficial: bool,
@@ -47,7 +47,7 @@ pub fn panels(
.into_iter()
.enumerate()
.map(|(i, (effect, icon))| {
let mut name = translate(&format!("effect.minecraft.{}", effect.id))
let mut name = translate(&horizon_client_core::potions::description_id(effect.id))
.unwrap_or_else(|| effect.id.to_owned());
if (1..=9).contains(&effect.amplifier) {
let level = effect.amplifier + 1;
@@ -0,0 +1,153 @@
//! `JigsawBlockEntity`: the jigsaw block's name, target, pool, joint, final
//! state and priorities, with its save format (`saveAdditional` /
//! `loadAdditional`).
use crate::structure_block::parse_identifier;
use horizon_client_core::nbt_value::{number, string, text};
use horizon_world::nbt::Tag;
/// `JigsawBlockEntity.EMPTY_ID` and `Pools.EMPTY`.
pub const EMPTY_ID: &str = "minecraft:empty";
/// `JigsawBlockEntity.JointType`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum JointType {
Rollable,
Aligned,
}
impl JointType {
pub const ALL: [Self; 2] = [Self::Rollable, Self::Aligned];
pub fn serialized_name(self) -> &'static str {
match self {
Self::Rollable => "rollable",
Self::Aligned => "aligned",
}
}
pub fn of_serialized_name(name: &str) -> Option<Self> {
Self::ALL.into_iter().find(|j| j.serialized_name() == name)
}
/// `JointType.getTranslatedName`'s lang key.
pub fn label_key(self) -> String {
format!("jigsaw_block.joint.{}", self.serialized_name())
}
/// `StructureTemplate.getDefaultJointType`: the joint of a block whose
/// front faces `front` (the first word of its `orientation` value).
pub fn default_for_front(front: &str) -> Self {
if is_vertical_front(front) {
Self::Rollable
} else {
Self::Aligned
}
}
}
/// `JigsawBlock.getFrontFacing(state)` from the `orientation` value
/// (`down_east`, `north_up`...): its front is the part before the `_`.
pub fn front_of_orientation(orientation: &str) -> &str {
orientation.split('_').next().unwrap_or(orientation)
}
pub fn is_vertical_front(front: &str) -> bool {
matches!(front, "up" | "down")
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct JigsawBlock {
pub name: String,
pub target: String,
pub pool: String,
pub joint: JointType,
pub final_state: String,
pub placement_priority: i32,
pub selection_priority: i32,
}
impl Default for JigsawBlock {
fn default() -> Self {
Self {
name: EMPTY_ID.into(),
target: EMPTY_ID.into(),
pool: EMPTY_ID.into(),
joint: JointType::Rollable,
final_state: "minecraft:air".into(),
placement_priority: 0,
selection_priority: 0,
}
}
}
impl JigsawBlock {
/// `saveAdditional` (also `getUpdateTag`, which is `saveCustomOnly`).
pub fn save(&self) -> Vec<(&'static str, Tag)> {
vec![
("name", string(&self.name)),
("target", string(&self.target)),
("pool", string(&self.pool)),
("final_state", string(&self.final_state)),
("joint", string(self.joint.serialized_name())),
("placement_priority", Tag::Int(self.placement_priority)),
("selection_priority", Tag::Int(self.selection_priority)),
]
}
/// `loadAdditional`. A missing joint takes `default_joint`
/// (`StructureTemplate.getDefaultJointType` of the block's state).
pub fn load(tag: &Tag, default_joint: JointType) -> Self {
let id = |key: &str| {
text(tag.get(key))
.and_then(|s| parse_identifier(&s))
.unwrap_or_else(|| EMPTY_ID.into())
};
Self {
name: id("name"),
target: id("target"),
pool: id("pool"),
final_state: text(tag.get("final_state")).unwrap_or_else(|| "minecraft:air".into()),
joint: text(tag.get("joint"))
.and_then(|j| JointType::of_serialized_name(&j))
.unwrap_or(default_joint),
placement_priority: number(tag.get("placement_priority")).unwrap_or(0) as i32,
selection_priority: number(tag.get("selection_priority")).unwrap_or(0) as i32,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn saves_and_loads_like_jigsaw_block_entity() {
let block = JigsawBlock {
name: "minecraft:bottom".into(),
target: "test:top".into(),
pool: "minecraft:village/plains/houses".into(),
joint: JointType::Aligned,
final_state: "minecraft:stone[x=1]".into(),
placement_priority: 4,
selection_priority: -1,
};
let tag = Tag::compound(block.save());
assert_eq!(text(tag.get("joint")).as_deref(), Some("aligned"));
assert_eq!(JigsawBlock::load(&tag, JointType::Rollable), block);
}
#[test]
fn load_defaults_like_vanilla() {
let tag = Tag::compound(vec![
("name", string("Bad Name")),
("target", string("door")),
]);
let block = JigsawBlock::load(&tag, JointType::Aligned);
assert_eq!(block.name, EMPTY_ID);
assert_eq!(block.target, "minecraft:door");
assert_eq!(block.pool, EMPTY_ID);
assert_eq!(block.final_state, "minecraft:air");
assert_eq!(block.joint, JointType::Aligned);
assert_eq!(JointType::default_for_front("up"), JointType::Rollable);
assert_eq!(
JointType::default_for_front(front_of_orientation("north_up")),
JointType::Aligned
);
}
}
@@ -53,6 +53,261 @@ pub enum Reason {
Other,
}
/// `LevelLoadTracker.CLIENT_WAIT_TIMEOUT_MS`.
pub const CLIENT_WAIT_TIMEOUT_MS: u64 = 30_000;
/// `LevelLoadTracker.LEVEL_LOAD_CLOSE_DELAY_MS`: how long a new world's loading
/// screen stays once the level is ready.
pub const LEVEL_LOAD_CLOSE_DELAY_MS: u64 = 500;
/// `ClientPacketListener.determineLevelLoadingReason`.
pub fn reason_for_respawn(player_died: bool, dimension: &str, old_dimension: &str) -> Reason {
const NETHER: &str = "minecraft:the_nether";
const END: &str = "minecraft:the_end";
if player_died {
Reason::Other
} else if dimension == NETHER || old_dimension == NETHER {
Reason::NetherPortal
} else if dimension == END || old_dimension == END {
Reason::EndPortal
} else {
Reason::Other
}
}
/// `LevelLoadTracker.ClientState`, with the times in milliseconds.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ClientState {
/// `WaitingForServer`: until the loading start game event.
WaitingForServer { timeout_after: u64 },
/// `WaitingForPlayerChunk`: until the section around the player is shown.
WaitingForPlayerChunk {
player_section_ready: bool,
timeout_after: u64,
},
/// `ClientLevelReady`.
Ready { ready_at: u64 },
}
/// What `WaitingForPlayerChunk.isReady` asks of the player.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct PlayerCheck {
/// `ClientLevel.isOutsideBuildHeight(camera y)`.
pub outside_build_height: bool,
pub spectator: bool,
pub alive: bool,
}
/// The client half of `LevelLoadTracker`: what the loading screen waits for
/// after a login or a respawn, and the screen it is shown on.
#[derive(Clone, Copy, Debug)]
pub struct LevelLoadTracker {
state: Option<ClientState>,
close_delay_ms: u64,
/// `LevelLoadingScreen.reason`.
pub reason: Reason,
}
impl LevelLoadTracker {
pub fn new(close_delay_ms: u64) -> Self {
Self {
state: None,
close_delay_ms,
reason: Reason::Other,
}
}
/// `startClientLoad`, for the screen `reason` (`startWaitingForNewLevel`).
pub fn start_client_load(&mut self, now: u64, reason: Reason) {
self.state = Some(ClientState::WaitingForServer {
timeout_after: now + CLIENT_WAIT_TIMEOUT_MS,
});
self.reason = reason;
}
/// The tracker's work is over and the screen has closed
/// (`ClientPacketListener.tick` drops it once the level is ready).
pub fn finish(&mut self) {
self.state = None;
}
/// A load is under way, so its screen is up.
pub fn is_active(&self) -> bool {
self.state.is_some()
}
/// `loadingPacketsReceived`: `LEVEL_CHUNKS_LOAD_START` came.
pub fn loading_packets_received(&mut self) {
if let Some(ClientState::WaitingForServer { timeout_after }) = self.state {
self.state = Some(ClientState::WaitingForPlayerChunk {
player_section_ready: false,
timeout_after,
});
}
}
/// `getPlayerCompiledSectionCallback` is not null: the renderer is to say
/// when the player's section is shown.
pub fn wants_player_section(&self) -> bool {
matches!(self.state, Some(ClientState::WaitingForPlayerChunk { .. }))
}
/// The callback: the player's section is compiled and visible.
pub fn player_section_ready(&mut self) {
if let Some(ClientState::WaitingForPlayerChunk {
player_section_ready,
..
}) = &mut self.state
{
*player_section_ready = true;
}
}
/// `tickClientLoad`.
pub fn tick_client_load(&mut self, now: u64, player: PlayerCheck) {
if let Some(ClientState::WaitingForPlayerChunk {
player_section_ready,
timeout_after,
}) = self.state
{
let ready = if now > timeout_after {
log::warn!(
"Timed out while waiting for the client to load chunks, letting the player into the world anyway"
);
true
} else if !player.outside_build_height && !player.spectator && player.alive {
player_section_ready
} else {
true
};
if ready {
self.state = Some(ClientState::Ready { ready_at: now });
}
}
}
/// `isLevelReady`.
pub fn is_level_ready(&self, now: u64) -> bool {
matches!(self.state, Some(ClientState::Ready { ready_at }) if now >= ready_at + self.close_delay_ms)
}
}
/// `LevelLoadListener.Stage`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Stage {
StartServer,
PrepareGlobalSpawn,
LoadInitialChunks,
LoadPlayerChunks,
}
/// `LevelLoadProgressTracker` (with `includePlayerChunks`).
#[derive(Debug, Default)]
struct ProgressTracker {
total_weight: usize,
finalized_weight: usize,
segment_weight: usize,
segment_fraction: f32,
progress: f32,
}
impl ProgressTracker {
fn tracks(stage: Stage) -> bool {
matches!(stage, Stage::LoadInitialChunks | Stage::LoadPlayerChunks)
}
fn begin_segment(&mut self, weight: usize) {
self.segment_weight = weight;
self.segment_fraction = 0.0;
self.update_progress();
}
fn finish_segment(&mut self) {
self.finalized_weight += self.segment_weight;
self.segment_weight = 0;
self.update_progress();
}
fn update_progress(&mut self) {
self.progress = if self.total_weight == 0 {
0.0
} else {
(self.finalized_weight as f32 + self.segment_fraction * self.segment_weight as f32)
/ self.total_weight as f32
};
}
fn start(&mut self, stage: Stage, total_chunks: usize) {
match stage {
Stage::LoadInitialChunks => {
self.total_weight = PREPARE_WEIGHT + total_chunks + PLAYER_CHUNKS;
self.begin_segment(PREPARE_WEIGHT);
self.finish_segment();
self.begin_segment(total_chunks);
}
Stage::LoadPlayerChunks => self.begin_segment(PLAYER_CHUNKS),
_ => {}
}
}
fn update(&mut self, stage: Stage, current: usize, total: usize) {
if Self::tracks(stage) {
self.segment_fraction = if total == 0 {
0.0
} else {
current as f32 / total as f32
};
self.update_progress();
}
}
fn finish(&mut self, stage: Stage) {
if Self::tracks(stage) {
self.finish_segment();
}
}
}
/// The integrated server's side of `LevelLoadTracker`: what its
/// `LevelLoadListener` reported (`serverProgress`, `hasProgress`) and the
/// chunk status map around the load focus (`ChunkLoadStatusView`), which the
/// server thread writes and the window's loading screen reads.
#[derive(Debug, Default)]
pub struct ServerLoadView {
tracker: std::sync::Mutex<ProgressTracker>,
/// `serverStage != null`.
has_progress: std::sync::atomic::AtomicBool,
/// `statusView.get(x, z)` for x and z in `0..=2 * STATUS_RADIUS`.
cells: std::sync::Mutex<Vec<Option<Status>>>,
}
impl ServerLoadView {
/// `LevelLoadListener.start`.
pub fn start(&self, stage: Stage, total_chunks: usize) {
Self::lock(&self.tracker).start(stage, total_chunks);
// `serverStage = stage`, whatever the stage.
self.has_progress
.store(true, std::sync::atomic::Ordering::Relaxed);
}
/// `LevelLoadListener.update`.
pub fn update(&self, stage: Stage, current: usize, total: usize) {
Self::lock(&self.tracker).update(stage, current, total);
}
/// `LevelLoadListener.finish`.
pub fn finish(&self, stage: Stage) {
Self::lock(&self.tracker).finish(stage);
}
/// `LevelLoadTracker.hasProgress`.
pub fn has_progress(&self) -> bool {
self.has_progress.load(std::sync::atomic::Ordering::Relaxed)
}
/// `LevelLoadTracker.serverProgress`.
pub fn server_progress(&self) -> f32 {
Self::lock(&self.tracker).progress
}
/// The map `ChunkLoadStatusView.get` reads, replaced as a whole.
pub fn set_status(&self, cells: Vec<Option<Status>>) {
*Self::lock(&self.cells) = cells;
}
/// `ChunkLoadStatusView.get`.
pub fn status(&self, x: i32, z: i32) -> Option<Status> {
let diameter = (STATUS_RADIUS * 2 + 1) as usize;
let (x, z) = (usize::try_from(x).ok()?, usize::try_from(z).ok()?);
if x >= diameter || z >= diameter {
return None;
}
Self::lock(&self.cells)
.get(x * diameter + z)
.copied()
.flatten()
}
fn lock<T>(m: &std::sync::Mutex<T>) -> std::sync::MutexGuard<'_, T> {
m.lock().unwrap_or_else(|e| e.into_inner())
}
}
/// `LevelLoadProgressTracker.get` once the server is prepared, with `ready`
/// of the player's 49 chunks loaded (no forced chunks to load first).
pub fn progress(ready: usize) -> f32 {
@@ -66,10 +321,25 @@ pub fn smooth(smoothed: f32, progress: f32) -> f32 {
}
/// `ARGB.opaque` as the batch's RGBA.
fn opaque(rgb: u32) -> u32 {
pub const fn opaque(rgb: u32) -> u32 {
0xFF00_0000 | (rgb & 0xFF) << 16 | (rgb & 0xFF00) | (rgb >> 16 & 0xFF)
}
/// `LoadingOverlay.LOGO_BACKGROUND_COLOR` (RGB: 239, 50, 61).
pub const LOGO_BACKGROUND_COLOR: u32 = opaque(0xEF323D);
/// `LoadingOverlay.LOGO_BACKGROUND_COLOR_DARK` (black).
pub const LOGO_BACKGROUND_COLOR_DARK: u32 = opaque(0x000000);
/// `LoadingOverlay.BRAND_BACKGROUND`: black when `darkMojangStudiosBackground` is set,
/// Mojang red otherwise.
pub fn brand_background(dark: bool) -> u32 {
if dark {
LOGO_BACKGROUND_COLOR_DARK
} else {
LOGO_BACKGROUND_COLOR
}
}
/// `LevelLoadingScreen.extractRenderState` over its background: the text,
/// the progress bar and the chunk map, `status(x, z)` for x and z in
/// `0..=2 * STATUS_RADIUS` from the map's north-west corner.
@@ -115,6 +385,27 @@ pub fn draw(
}
}
/// `LoadingOverlay.extractRenderState`: fills the frame with `BRAND_BACKGROUND`
/// (Mojang red, or black when `darkMojangStudiosBackground` is set) and draws the
/// progress line and bar.
pub fn draw_loading_overlay(
batch: &mut HudBatch,
atlas: &HudAtlas,
line: &str,
progress: f32,
dark: bool,
) {
batch.fill(
atlas,
0.0,
0.0,
batch.width,
batch.height,
brand_background(dark),
);
draw_progress(batch, atlas, line, progress);
}
/// The startup loading screen: the stage's line and a progress bar, centred
/// like `LevelLoadingScreen`'s text and bar.
pub fn draw_progress(batch: &mut HudBatch, atlas: &HudAtlas, line: &str, progress: f32) {
@@ -165,6 +456,118 @@ pub fn draw_connecting(
}
}
/// `FileFixerProgressScreen`'s Cancel button corner on a GUI `w` x `h`:
/// ((width - 200) / 2, height / 2 + 100), 200 x 20.
pub fn file_fixer_cancel(w: f32, h: f32) -> (f32, f32) {
(((w as i32 - 200) / 2) as f32, (h as i32 / 2 + 100) as f32)
}
/// `GuiGraphicsExtractor.centeredText` with the screen's grey (`-6250336`) or white.
fn centered_line(
batch: &mut HudBatch,
atlas: &HudAtlas,
text: &Text,
x_center: i32,
y: i32,
color: u32,
) {
let tw = atlas.font.width_text(text) as i32;
batch.text_styled(
atlas,
text,
(x_center - tw / 2) as f32,
y as f32,
color,
true,
);
}
/// `FileFixerProgressScreen.extractProgressBar`.
fn file_fixer_bar(batch: &mut HudBatch, atlas: &HudAtlas, x_center: i32, y: i32, progress: f32) {
let left = x_center - 100;
batch.fill(atlas, left as f32, y as f32, 200.0, 2.0, opaque(0));
batch.fill(
atlas,
left as f32,
y as f32,
(progress * 200.0).round(),
2.0,
opaque(0x00FF00),
);
}
/// `FileFixerProgressScreen.extractRenderState` (with `extractProgress`,
/// `extractProgressBar`, `extractFileStats`, `extractFileFixerCount`,
/// `extractTypeText`, `extractTypeProgress` and `extractScanning`) over the menu background: the
/// title, then either the scanning line or the running fixer's bar, the
/// totals, the stage, the type's label and its bar and percentage; and
/// Cancel, inactive once pressed.
pub fn draw_file_fixer(
batch: &mut HudBatch,
atlas: &HudAtlas,
progress: &horizon_world::datafix::progress::UpgradeProgress,
cursor: (f32, f32),
) {
const GREY: u32 = 0xFFA0_A0A0;
let (w, h) = (batch.width, batch.height);
crate::ui::menu_background(batch, atlas, 0.0, 0.0, w, h, false);
let (x_center, y_center) = ((w as i32) / 2, (h as i32) / 2);
let text_top = y_center - 50;
centered_line(
batch,
atlas,
&Text::translate("upgradeWorld.title", &[]),
x_center,
text_top,
u32::MAX,
);
let total = progress.total_file_fix_stats();
if total.total_operations() > 0 {
let of_type = progress.type_file_fix_stats();
let running = progress.running_file_fixer_stats();
let mut y = text_top + 9 + 3;
file_fixer_bar(batch, atlas, x_center, y, running.progress());
y += 7;
// extractFileStats
let converted = Text::tr(
"upgradeWorld.info.converted",
&[&total.finished_operations()],
);
centered_line(batch, atlas, &converted, x_center, y, GREY);
let sum = Text::tr("upgradeWorld.info.total", &[&total.total_operations()]);
centered_line(batch, atlas, &sum, x_center, y + 9 + 3, GREY);
y += 9 * 2 + 6;
// extractFileFixerCount
let stage = Text::tr(
"upgradeWorld.info.file_fix_stage",
&[&running.finished_operations(), &running.total_operations()],
);
centered_line(batch, atlas, &stage, x_center, y, GREY);
y += 9 + 30 - 5;
// extractTypeText
if let Some(ty) = progress.upgrade_type() {
let label = Text::translate(&ty.label_key(), &[]);
centered_line(batch, atlas, &label, x_center, y, GREY);
}
y += 9 + 3;
file_fixer_bar(batch, atlas, x_center, y, of_type.progress());
y += 7;
// extractTypeProgress: Mth.floor(progress * 100)
let percent = (of_type.progress() * 100.0).floor() as i32;
let text = Text::tr("upgradeWorld.progress.percentage", &[&percent]);
centered_line(batch, atlas, &text, x_center, y, GREY);
} else {
// extractScanning
let scanning = Text::translate("upgradeWorld.info.scanning", &[]);
centered_line(batch, atlas, &scanning, x_center, text_top + 9 + 3, GREY);
}
let (x, y) = file_fixer_cancel(w, h);
let active = !progress.is_canceled();
let hover = crate::ui::hovered(cursor, x, y, 200.0, 20.0);
let label = Text::translate("gui.cancel", &[]);
crate::ui::button(batch, atlas, x, y, 200.0, 20.0, &label, active, hover);
}
#[cfg(test)]
mod tests {
use super::*;
@@ -179,6 +582,20 @@ mod tests {
assert!((smooth(eased, 1.0) - 0.36).abs() < 1e-6);
}
#[test]
fn brand_background_matches_dark_mojang_studios_setting() {
assert_eq!(brand_background(true), LOGO_BACKGROUND_COLOR_DARK);
assert_eq!(brand_background(false), LOGO_BACKGROUND_COLOR);
assert_eq!(LOGO_BACKGROUND_COLOR_DARK, 0xFF00_0000);
assert_eq!(LOGO_BACKGROUND_COLOR, crate::hud::rgba(239, 50, 61, 255));
}
#[test]
fn file_fixer_cancel_sits_below_the_centre() {
assert_eq!(file_fixer_cancel(427.0, 240.0), (113.0, 220.0));
assert_eq!(file_fixer_cancel(426.0, 241.0), (113.0, 220.0));
}
#[test]
fn colours_are_opaque_rgba() {
assert_eq!(opaque(Status::Full.color()), u32::MAX);
@@ -190,3 +607,145 @@ mod tests {
);
}
}
#[cfg(test)]
mod tracker_tests {
use super::*;
const ALIVE: PlayerCheck = PlayerCheck {
outside_build_height: false,
spectator: false,
alive: true,
};
#[test]
fn server_progress_follows_the_listener_like_the_progress_tracker() {
let view = ServerLoadView::default();
assert!(!view.has_progress() && view.server_progress() == 0.0);
// A stage the tracker ignores still counts as progress being made.
view.start(Stage::StartServer, 0);
assert!(view.has_progress() && view.server_progress() == 0.0);
// prepareLevels with no forced chunks: the 10 of preparing, of 59.
view.start(Stage::LoadInitialChunks, 0);
view.finish(Stage::LoadInitialChunks);
assert_eq!(view.server_progress(), 10.0 / 59.0);
// PrepareSpawnTask: the 49 player chunks.
view.start(Stage::LoadPlayerChunks, 49);
view.update(Stage::LoadPlayerChunks, 20, 49);
assert_eq!(view.server_progress(), (10.0 + 20.0) / 59.0);
assert_eq!(view.server_progress(), progress(20));
view.update(Stage::LoadPlayerChunks, 49, 49);
view.finish(Stage::LoadPlayerChunks);
assert_eq!(view.server_progress(), 1.0);
}
#[test]
fn the_status_map_is_read_from_the_north_west_corner() {
let view = ServerLoadView::default();
assert_eq!(view.status(0, 0), None);
let diameter = (STATUS_RADIUS * 2 + 1) as usize;
let mut cells = vec![None; diameter * diameter];
cells[3 * diameter + 5] = Some(Status::Full);
view.set_status(cells);
assert_eq!(view.status(3, 5), Some(Status::Full));
assert_eq!(view.status(5, 3), None);
assert_eq!(view.status(-1, 0), None);
assert_eq!(view.status(diameter as i32, 0), None);
}
#[test]
fn the_load_waits_for_the_server_then_the_players_section() {
let mut tracker = LevelLoadTracker::new(0);
assert!(!tracker.is_active());
tracker.start_client_load(1_000, Reason::NetherPortal);
assert!(tracker.is_active() && tracker.reason == Reason::NetherPortal);
// Nothing is asked of the renderer before the start event, and a
// section shown early counts for nothing.
assert!(!tracker.wants_player_section());
tracker.player_section_ready();
tracker.tick_client_load(1_050, ALIVE);
assert!(!tracker.is_level_ready(1_050));
tracker.loading_packets_received();
assert!(tracker.wants_player_section());
tracker.tick_client_load(1_100, ALIVE);
assert!(!tracker.is_level_ready(1_100));
tracker.player_section_ready();
tracker.tick_client_load(1_150, ALIVE);
assert!(tracker.is_level_ready(1_150));
tracker.finish();
assert!(!tracker.is_active() && !tracker.is_level_ready(2_000));
}
#[test]
fn a_player_who_cannot_see_the_section_does_not_wait_for_it() {
for player in [
PlayerCheck {
outside_build_height: true,
..ALIVE
},
PlayerCheck {
spectator: true,
..ALIVE
},
PlayerCheck {
alive: false,
..ALIVE
},
] {
let mut tracker = LevelLoadTracker::new(0);
tracker.start_client_load(0, Reason::Other);
tracker.loading_packets_received();
tracker.tick_client_load(10, player);
assert!(tracker.is_level_ready(10));
}
}
#[test]
fn the_wait_times_out_after_thirty_seconds_but_only_once_the_server_has_started() {
let mut tracker = LevelLoadTracker::new(0);
tracker.start_client_load(0, Reason::Other);
tracker.tick_client_load(CLIENT_WAIT_TIMEOUT_MS + 1, ALIVE);
assert!(!tracker.is_level_ready(CLIENT_WAIT_TIMEOUT_MS + 1));
tracker.loading_packets_received();
tracker.tick_client_load(CLIENT_WAIT_TIMEOUT_MS, ALIVE);
assert!(!tracker.is_level_ready(CLIENT_WAIT_TIMEOUT_MS));
tracker.tick_client_load(CLIENT_WAIT_TIMEOUT_MS + 1, ALIVE);
assert!(tracker.is_level_ready(CLIENT_WAIT_TIMEOUT_MS + 1));
}
#[test]
fn a_new_worlds_screen_stays_for_the_close_delay() {
let mut tracker = LevelLoadTracker::new(LEVEL_LOAD_CLOSE_DELAY_MS);
tracker.start_client_load(0, Reason::Other);
tracker.loading_packets_received();
tracker.player_section_ready();
tracker.tick_client_load(100, ALIVE);
assert!(!tracker.is_level_ready(599));
assert!(tracker.is_level_ready(600));
}
#[test]
fn a_respawn_names_its_screen_by_the_dimensions() {
let (overworld, nether, end) = (
"minecraft:overworld",
"minecraft:the_nether",
"minecraft:the_end",
);
assert_eq!(
reason_for_respawn(false, nether, overworld),
Reason::NetherPortal
);
assert_eq!(
reason_for_respawn(false, overworld, nether),
Reason::NetherPortal
);
assert_eq!(reason_for_respawn(false, end, overworld), Reason::EndPortal);
assert_eq!(reason_for_respawn(false, end, nether), Reason::NetherPortal);
assert_eq!(
reason_for_respawn(false, overworld, overworld),
Reason::Other
);
// A death never shows a portal.
assert_eq!(reason_for_respawn(true, nether, overworld), Reason::Other);
}
}
+12
View File
@@ -5,6 +5,7 @@ pub mod assets;
pub mod atlas_sources;
pub mod audio;
pub mod auth;
pub mod ban_notice;
pub mod bidi;
pub mod biome;
pub mod block_entities;
@@ -39,6 +40,7 @@ pub mod hud_layout;
pub mod hud_overlays;
pub mod inventory_effects;
pub mod java_text;
pub mod jigsaw_block;
pub mod level_loading;
pub mod light;
pub mod lod;
@@ -50,6 +52,7 @@ pub mod mipmap;
pub mod mod_list;
pub mod model_library;
pub mod models;
pub mod multiline_edit;
pub mod options_file;
pub mod options_fixes;
pub mod options_ui;
@@ -59,23 +62,32 @@ pub mod packs;
pub mod par;
pub mod particles;
pub mod poi_sections;
pub mod post_chain;
pub mod post_glsl;
pub mod potent_sulfur;
pub mod preedit;
pub mod providers;
pub mod screen_draw;
pub mod screen_effects;
pub mod sculk;
pub mod section_mask;
pub mod settings;
pub mod shader_pack_list;
pub mod signs;
pub mod skins;
pub mod sound_engine;
pub mod sound_events;
pub mod sound_preview;
pub mod spans;
pub mod split_layout;
mod sprite_cache;
pub mod sprites;
pub mod streaming;
pub mod structure_block;
pub mod tab_list;
pub mod telemetry;
pub mod test_block;
pub mod test_instance_block;
pub mod testgen;
pub mod title_edition;
pub mod trims;
File diff suppressed because it is too large. Load diff
+387 -172
View File
@@ -7,6 +7,7 @@ use crate::{
mesher::SectionMesh,
streaming::{LodSource, WorkerContext},
};
pub mod cache;
mod mesh;
use horizon_world::registry::BlockStateId;
use std::{
@@ -40,7 +41,14 @@ fn sampling_threads(cores: usize) -> usize {
/// otherwise take the cores the near chunks and the frame need.
const HELD_SAMPLE_STEP: std::time::Duration = std::time::Duration::from_millis(500);
/// The coarsest tile level: `Plan` roots cover at most 1024 chunks around.
const MAX_LEVEL: u8 = 10;
pub(crate) const MAX_LEVEL: u8 = 10;
/// The coarsest tile level whose chunks are generated in full (trees,
/// structures) rather than sampled: cells of up to 4 blocks, out to about
/// 1024 blocks at Full far detail.
const DETAIL_LEVEL: u8 = 2;
/// The most chunks one detail request asks for, one request each 250 ms:
/// generated ones arrive at the decoration threads' pace, cached ones at once.
const DETAIL_REQUEST: usize = 512;
/// Top solid face and optional fluid face, in absolute block coordinates.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Surface {
@@ -161,8 +169,40 @@ impl Column {
self.spans.push(span);
}
}
/// `kinds`: `state_kinds`, per block state.
fn from_chunk(chunk: &Chunk, kinds: &[u8], x: i32, z: i32) -> Self {
/// Open water and its floor lit as open sky, as the seed's far columns
/// are: a chunk's own light fades a level per block of water, so the
/// chunks a player passed drew a darker band through the seed's sea.
/// The water is one span again.
fn light_open_water(&mut self, kinds: &[u8]) {
// Water; lava glows.
let water = |span: &Span| {
let kind = kinds.get(span.state.0 as usize).copied().unwrap_or(0);
kind & FLUID != 0 && kind >> 4 == 0
};
if !self.spans.last().is_some_and(water) {
return;
}
let floor = self
.spans
.iter()
.rposition(|span| !water(span))
.unwrap_or(0);
for (i, mut span) in std::mem::take(&mut self.spans).into_iter().enumerate() {
if i >= floor {
span.light |= 0xf0;
}
self.push(span);
}
}
/// `kinds`: `state_kinds`, per block state; `water`: `water_source(kinds)`,
/// what blocks holding water (kelp, seagrass, waterlogged ones) are drawn as.
fn from_chunk(
chunk: &Chunk,
kinds: &[u8],
water: Option<BlockStateId>,
x: i32,
z: i32,
) -> Self {
let kind = |state: BlockStateId| kinds.get(state.0 as usize).copied().unwrap_or(0);
let face_light = |y: i32| {
let mut sky = 0;
@@ -187,7 +227,7 @@ impl Column {
};
let mut column = Self::default();
for (sy, section) in chunk.sections.iter().enumerate() {
let min = MIN_Y + sy as i32 * 16;
let min = chunk.min_y() + sy as i32 * 16;
let uniform = section.blocks.bits() == 0
&& section.biomes.bits() == 0
&& matches!(section.light, crate::chunk::Light::Uniform(_));
@@ -197,11 +237,17 @@ impl Column {
let step = if uniform { 16 } else { 1 };
for offset in (0..16).step_by(step) {
let y = min + offset;
let state = BlockStateId(chunk.get(x, y, z));
let kind = kind(state);
let mut state = BlockStateId(chunk.get(x, y, z));
let mut kind = kind(state);
if kind & KEPT == 0 {
continue;
}
if kind & WATERY != 0
&& let Some(water) = water
{
state = water;
kind = kinds[water.0 as usize];
}
let light = face_light(y);
column.push(Span {
min: y as i16,
@@ -212,6 +258,7 @@ impl Column {
});
}
}
column.light_open_water(kinds);
let mut water = None;
for span in column.spans.iter().rev() {
if kind(span.state) & FLUID != 0 {
@@ -230,7 +277,7 @@ impl Column {
&& let Some((height, state)) = water
{
column.surface = Some(Surface {
height: MIN_Y,
height: chunk.min_y(),
state: BlockStateId(
kinds.iter().position(|&kind| kind & AIR != 0).unwrap_or(0) as u32
),
@@ -243,15 +290,28 @@ impl Column {
}
/// A chunk's 256 columns (`Column::from_chunk`), on the far-terrain worker.
fn summarize(chunk: &Chunk, kinds: &[u8]) -> Arc<[Column; 256]> {
let water = water_source(kinds);
Arc::new(std::array::from_fn(|i| {
Column::from_chunk(chunk, kinds, (i & 15) as i32, (i >> 4) as i32)
Column::from_chunk(chunk, kinds, water, (i & 15) as i32, (i >> 4) as i32)
}))
}
/// `state_kinds` bits: kept in columns, a fluid, the air state; the light
/// emission is in the high nibble.
/// The water source state: the first plain kept fluid without light
/// (`minecraft:water` comes before lava, and its level 0 first).
fn water_source(kinds: &[u8]) -> Option<BlockStateId> {
kinds
.iter()
.position(|&kind| kind == KEPT | FLUID)
.map(|i| BlockStateId(i as u32))
}
/// `state_kinds` bits: kept in columns, a fluid, the air state, a block
/// holding water; the light emission is in the high nibble.
const KEPT: u8 = 1;
const FLUID: u8 = 2;
const AIR: u8 = 4;
/// A block holding water that draws as none (kelp, seagrass, bubble columns,
/// waterlogged blocks that aren't kept): far terrain draws water there, or
/// kelp reaching the surface leaves holes in the sea.
const WATERY: u8 = 8;
/// What `Column::from_chunk` needs of each block state, read from the facts
/// once: matching their names for every block made observing a chunk slow.
fn state_kinds(facts: &BlockFacts) -> Vec<u8> {
@@ -270,14 +330,29 @@ fn state_kinds(facts: &BlockFacts) -> Vec<u8> {
|| f.full_collision
|| f.name.ends_with("_leaves")
|| chorus
|| fluid);
(u8::from(kept) * KEPT)
| (u8::from(fluid) * FLUID)
|| fluid
|| nearly_full(&f.name));
let watery = !kept && !facts.is_air(state) && f.fluid == crate::blocks::Fluid::Water;
(u8::from(kept || watery) * KEPT)
| (u8::from(fluid || watery) * FLUID)
| (u8::from(watery) * WATERY)
| (u8::from(state == facts.air) * AIR)
| (f.emission.min(15) << 4)
})
.collect()
}
/// Blocks that fill their cell but neither draw solid nor collide as a full
/// block: far terrain draws them as blocks, or patches of powder snow leave
/// holes in mountains (and paths and fields dips).
fn nearly_full(name: &str) -> bool {
matches!(
name,
"minecraft:powder_snow"
| "minecraft:dirt_path"
| "minecraft:farmland"
| "minecraft:honey_block"
) || name.ends_with("_stairs")
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct TileKey {
pub x: i32,
@@ -291,7 +366,7 @@ impl TileKey {
pub fn origin(self) -> [i32; 3] {
[self.x * self.size(), 0, self.z * self.size()]
}
fn parent(self) -> Self {
pub(crate) fn parent(self) -> Self {
Self {
x: self.x.div_euclid(2),
z: self.z.div_euclid(2),
@@ -305,6 +380,12 @@ impl TileKey {
level: self.level - 1,
})
}
/// The squared horizontal distance from `camera` to the tile's nearest point.
fn reach(self, camera: [i32; 2]) -> i64 {
let [x, _, z] = self.origin();
let gap = |c: i32, lo: i32| i64::from((lo - c).max(c - lo - self.size()).max(0));
gap(camera[0], x).pow(2) + gap(camera[1], z).pow(2)
}
fn distance(self, camera: [i32; 2]) -> i32 {
let [x, _, z] = self.origin();
(x - camera[0])
@@ -313,27 +394,6 @@ impl TileKey {
.max(camera[1] - z - self.size())
.max(0)
}
fn in_view(self, camera: [i32; 2], yaw: f32) -> bool {
let [x, _, z] = self.origin();
let (sin, cos) = yaw.sin_cos();
let mut forward = f32::NEG_INFINITY;
let mut left = f32::NEG_INFINITY;
let mut right = f32::NEG_INFINITY;
for (dx, dz) in [
(0, 0),
(self.size(), 0),
(0, self.size()),
(self.size(), self.size()),
] {
let (x, z) = ((x + dx - camera[0]) as f32, (z + dz - camera[1]) as f32);
let f = -x * sin + z * cos;
let side = x * cos + z * sin;
forward = forward.max(f);
left = left.max(f * 0.866 + side * 0.5);
right = right.max(f * 0.866 - side * 0.5);
}
forward > 0. && left > 0. && right > 0.
}
}
#[derive(Default)]
struct Plan {
@@ -385,7 +445,9 @@ impl Plan {
previous: &Self,
reduced: bool,
) {
if key.distance(camera) > range {
// The range is a circle, as the renderer's: a tile leaves it, and
// fades out (gfx/lod.rs), as the player moves.
if key.reach(camera) > i64::from(range) * i64::from(range) {
return;
}
self.nodes.insert(key);
@@ -578,7 +640,6 @@ pub struct Lod {
/// Reduced far detail (`set_reduced_detail`).
reduced_detail: bool,
pending: FxHashSet<TileKey>,
sample_nearby: bool,
detail_masks: FxHashMap<TileKey, [u64; 4]>,
detail_request_pending: bool,
detail_request_at: std::time::Instant,
@@ -683,7 +744,9 @@ impl Lod {
.expect("far sampling worker"),
);
}
let mut mesher = mesh::Mesher::default();
// One mesher per thread: each keeps its material cache.
let mut meshers: Vec<_> =
(0..count.max(1)).map(|_| mesh::Mesher::default()).collect();
'work: loop {
for (key, columns) in sampler.drain_details() {
if tx
@@ -743,24 +806,48 @@ impl Lod {
covered,
bounds,
} => {
let mut meshes = Vec::with_capacity(keys.len());
for key in keys {
if worker.stop.load(Ordering::Relaxed) {
break 'work;
}
let finer = finer.remove(&key).unwrap_or_default();
let input = snapshot.mesh_input(key, finer);
let mut mesh = mesher.mesh(
input.key,
&input.columns(),
&covered,
bounds,
&mesh_context,
&input.neighbors,
&input.finer,
);
mesh.sort_solid();
meshes.push((input.key, mesh));
let work: Vec<_> = keys
.into_iter()
.map(|key| (key, finer.remove(&key).unwrap_or_default()))
.collect();
let per_thread = work.len().div_ceil(meshers.len()).max(1);
let (inputs, cut) = (&snapshot, &covered);
let (stop, context) = (&worker.stop, &mesh_context);
let meshes: Vec<_> = thread::scope(|scope| {
let threads: Vec<_> = work
.chunks(per_thread)
.zip(meshers.iter_mut())
.map(|(work, mesher)| {
scope.spawn(move || {
let mut meshes = Vec::with_capacity(work.len());
for (key, finer) in work {
if stop.load(Ordering::Relaxed) {
break;
}
let input = inputs.mesh_input(*key, finer.clone());
let mut mesh = mesher.mesh(
input.key,
&input.columns(),
cut,
bounds,
context,
&input.neighbors,
&input.finer,
);
mesh.sort_solid();
meshes.push((input.key, mesh));
}
meshes
})
})
.collect();
threads
.into_iter()
.flat_map(|thread| thread.join().unwrap_or_default())
.collect()
});
if worker.stop.load(Ordering::Relaxed) {
break 'work;
}
Done::Mesh {
active: snapshot.active,
@@ -773,7 +860,11 @@ impl Lod {
break;
}
}
// Under the lock the samplers wait on: one between its check
// and its wait would miss the wake and never be joined.
let jobs = worker.jobs.lock();
worker.stop.store(true, Ordering::Relaxed);
drop(jobs);
worker.wake.notify_all();
for handle in sampling {
let _ = handle.join();
@@ -794,7 +885,6 @@ impl Lod {
reduced_detail: false,
plan: Plan::default(),
pending: FxHashSet::default(),
sample_nearby: true,
detail_masks: FxHashMap::default(),
detail_request_pending: false,
detail_request_at: std::time::Instant::now(),
@@ -1052,13 +1142,23 @@ impl Lod {
.active
.iter()
.filter(|key| {
key.level <= 1
key.level <= DETAIL_LEVEL
&& !self.plan.split.contains(key)
&& !(key.level == 0 && self.covered.contains(&(key.x, key.z)))
&& self.detail_masks.get(key) != Some(&[u64::MAX; 4])
})
.copied()
.collect();
// Retiring fine columns are empty stand-ins, excluded from `active`
// while their normal meshes still cover the gap. Request their detail
// explicitly: neither the active-tile requests nor sampling an already
// ready stand-in can fill it, so its GPU sections would never retire.
keys.extend(retiring_detail(
&self.plan,
&self.retiring,
&self.saved,
&self.detail_masks,
));
keys.sort_unstable_by_key(|key| (key.distance(camera), *key));
let mut chunks = FxHashSet::default();
for key in keys {
@@ -1079,7 +1179,7 @@ impl Lod {
}
}
}
if chunks.len() >= 32 {
if chunks.len() >= DETAIL_REQUEST {
break;
}
}
@@ -1089,7 +1189,7 @@ impl Lod {
let dz = i64::from(z * 16 + 8 - camera[1]);
dx * dx + dz * dz
});
chunks.truncate(32);
chunks.truncate(DETAIL_REQUEST);
if !chunks.is_empty() {
self.detail_request_pending = true;
self.shared
@@ -1106,7 +1206,6 @@ impl Lod {
pub fn update(
&mut self,
camera: [f64; 3],
yaw: f32,
detail: i32,
distance: u32,
covered: Option<FxHashSet<(i32, i32)>>,
@@ -1115,14 +1214,6 @@ impl Lod {
(((camera[1] - f64::from(MIN_Y + crate::chunk::HEIGHT)).clamp(0., 16384.)) as i32 / 32)
* 32;
let camera = [camera[0].floor() as i32, camera[2].floor() as i32];
// Fill the view ahead first; a nearest-only ring spends most work out
// of sight while visible forests remain undecorated noise terrain.
let ahead = (detail * 16 + 128).max(elevation * 2).min(4096) as f32;
let yaw = yaw.to_radians();
let focus = [
camera[0] - (yaw.sin() * ahead) as i32,
camera[1] + (yaw.cos() * ahead) as i32,
];
// Replan at chunk boundaries, not on every camera movement.
let area = (
[camera[0].div_euclid(16), camera[1].div_euclid(16)],
@@ -1155,10 +1246,8 @@ impl Lod {
if !self.meshing {
crate::spans::time("lod_select", || self.select());
}
crate::spans::time("lod_request", || self.request_detail(focus));
crate::spans::time("lod_wanted", || {
self.request_samples(camera, focus, yaw, detail, elevation)
});
crate::spans::time("lod_request", || self.request_detail(camera));
crate::spans::time("lod_wanted", || self.request_samples(camera));
meshes
}
fn receive(&mut self, meshes: &mut Vec<(TileKey, SectionMesh)>) {
@@ -1312,14 +1401,7 @@ impl Lod {
}
}
}
fn request_samples(
&mut self,
camera: [i32; 2],
focus: [i32; 2],
yaw: f32,
detail: i32,
elevation: i32,
) {
fn request_samples(&mut self, camera: [i32; 2]) {
// Every sample slot busy: no tile can be requested this frame.
let free = if self.held() {
usize::from(
@@ -1340,80 +1422,36 @@ impl Lod {
if self.held() {
self.held_sample_at = std::time::Instant::now();
}
// Load desired nearby leaves directly, alongside broad fallback coverage.
// Waiting for every ancestor/sibling made full detail take minutes to appear.
let mut wanted = Vec::new();
for &root in &self.plan.roots {
if self.plan.nodes.contains(&root) && !self.ready.contains_key(&root) {
wanted.push((0, 0, root));
}
}
for &key in &self.plan.nodes {
if !self.plan.split.contains(&key) && !self.ready.contains_key(&key) {
wanted.push((
if key.in_view(camera, yaw) { 1 } else { 3 },
i64::from(key.distance(focus)) * 4 + i64::from(key.distance(camera)),
key,
));
}
}
let mut fallback = Vec::new();
for &parent in &self.active {
if self.plan.split.contains(&parent) {
for child in parent.children() {
if self.plan.nodes.contains(&child) && !self.ready.contains_key(&child) {
// Covered near terrain bounds projected error. Using
// zero distance for a parent touching the camera kept
// refining it while huge visible walls waited elsewhere.
let priority =
i64::from(child.distance(camera).max((detail * 16).max(elevation)))
* 1024
/ i64::from(child.size());
fallback.push((
if child.in_view(camera, yaw) { 2 } else { 4 },
priority,
child,
));
}
}
}
}
// Only the first `free` keys not already pending are taken. A key is
// listed at most twice (as a root and as a node), so the best
// `2 * (sample_jobs + pending)` entries hold them, in order.
let best = 2 * (self.sample_jobs + self.pending.len());
// Only the tiles the plan keeps, each at the level its distance
// calls for, nearest first, whichever way the player looks: far
// terrain grows outward from them in rings. Coarse tiles first under
// fine ones near the player cost a far tile's work for terrain that
// fine tiles replace moments later.
let mut wanted: Vec<_> = self
.plan
.nodes
.iter()
.filter(|key| !self.plan.split.contains(key) && !self.ready.contains_key(key))
.map(|&key| (i64::from(key.distance(camera)), key))
.collect();
// Only the first `free` keys not already pending are taken: the best
// `sample_jobs + pending` entries hold them, in order.
let best = self.sample_jobs + self.pending.len();
if wanted.len() > best {
wanted.select_nth_unstable(best);
wanted.truncate(best);
}
wanted.sort_unstable();
// Alternate one nearby final tile with one broad refinement tile.
if let Some(fallback) = fallback
.into_iter()
.filter(|(_, _, k)| !self.pending.contains(k))
.min()
{
let index = if self.sample_nearby {
wanted
.iter()
.position(|(_, _, k)| !self.pending.contains(k))
.map_or(0, |i| i + 1)
} else {
0
};
wanted.insert(index, fallback);
}
let mut unique = FxHashSet::default();
let wanted: Vec<_> = wanted
.into_iter()
.map(|(_, _, key)| key)
.map(|(_, key)| key)
.filter(|key| !self.pending.contains(key) && unique.insert(*key))
.collect();
if !wanted.is_empty() {
let mut jobs = self.shared.jobs.lock().unwrap();
for key in wanted.into_iter().take(free) {
self.pending.insert(key);
self.sample_nearby = !self.sample_nearby;
jobs.push_back(Job::Sample(key));
}
drop(jobs);
@@ -1556,18 +1594,94 @@ impl Snapshot {
}
impl Drop for Lod {
fn drop(&mut self) {
// Under the jobs lock, as the waiters check it: see the samplers' stop.
let jobs = self.shared.jobs.lock();
self.shared.stop.store(true, Ordering::Relaxed);
drop(jobs);
self.shared.wake.notify_all();
if let Some(thread) = self.thread.take() {
let _ = thread.join();
}
}
}
/// Fine retiring stand-ins need actual column detail before `Plan::replaces`
/// can release their normal meshes. The caller keeps the usual 32-column
/// request budget and waits for the replacement mesh before changing cutout.
fn retiring_detail<'a>(
plan: &'a Plan,
retiring: &'a FxHashSet<(i32, i32)>,
saved: &'a Saved,
masks: &'a FxHashMap<TileKey, [u64; 4]>,
) -> impl Iterator<Item = TileKey> + 'a {
retiring.iter().filter_map(move |&(x, z)| {
let key = TileKey { x, z, level: 0 };
(plan.nodes.contains(&key)
&& !saved.contains_key(&(x, z))
&& masks.get(&key) != Some(&[u64::MAX; 4]))
.then_some(key)
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mesher::TerrainVertex;
#[test]
fn retiring_fine_stand_ins_request_detail_before_their_gpu_meshes_can_retire() {
let column = (12, 0);
let fine = TileKey {
x: 12,
z: 0,
level: 0,
};
let mut plan = Plan::default();
plan.nodes.insert(fine);
let ready: FxHashMap<TileKey, ()> = [(fine, ())].into_iter().collect();
let retiring = [column].into_iter().collect();
let saved = Saved::default();
let mut masks = FxHashMap::default();
// A covered stand-in is ready, but empty: ordinary sampling skips it,
// and selection leaves it out of `active`. It cannot yet replace the
// old full-detail column, even though the near world has unloaded it.
assert!(!plan.replaces(column, &ready, |key| masks.get(&key)
== Some(&[u64::MAX; 4])));
assert_eq!(
retiring_detail(&plan, &retiring, &saved, &masks).collect::<Vec<_>>(),
[fine]
);
// The existing RequestDetail -> apply_fine path marks complete detail;
// only then may selection retire it and publish the new mesh cutout.
masks.insert(fine, [u64::MAX; 4]);
assert!(
retiring_detail(&plan, &retiring, &saved, &masks)
.next()
.is_none()
);
assert!(plan.replaces(column, &ready, |key| masks.get(&key)
== Some(&[u64::MAX; 4])));
}
#[test]
fn retiring_detail_requests_only_planned_columns_still_missing_detail() {
let fine = |x| TileKey { x, z: 0, level: 0 };
let mut plan = Plan::default();
plan.nodes.extend((0..4).map(fine));
plan.nodes.insert(fine(5));
let retiring = (0..5).map(|x| (x, 0)).collect();
let saved = [((1, 0), Arc::new(std::array::from_fn(|_| Column::default())))]
.into_iter()
.collect();
let masks = [
(fine(2), [u64::MAX; 4]),
(fine(3), [u64::MAX, u64::MAX, u64::MAX, u64::MAX - 1]),
]
.into_iter()
.collect();
let mut requested: Vec<_> = retiring_detail(&plan, &retiring, &saved, &masks).collect();
requested.sort_unstable();
// Saved or complete detail needs no request; an unplanned retiree and
// a planned column that is not retiring must not consume the budget.
assert_eq!(requested, [fine(0), fine(3)]);
}
#[test]
fn reduced_far_detail_only_merges_tiles_away_from_the_near_terrain() {
let leaves = |plan: &Plan| {
plan.nodes
@@ -1589,8 +1703,11 @@ mod tests {
}
}
let (full, reduced) = (leaves(&full), leaves(&reduced));
// At 64 chunks most tiles are the near area's finest, the
// same in both: about half there, well under from 128.
let bound = if distance == 64 { full * 21 / 20 } else { full };
assert!(
reduced * 2 < full,
reduced * 2 < bound,
"{reduced} of {full} tiles at {distance}"
);
}
@@ -1725,8 +1842,9 @@ mod tests {
let before = Plan::new([0, 0], 2, 256);
let after = Plan::with_previous([16, 0], 2, 256, &before, 0, false);
let mut retained = 0;
let range = i64::from(256 * 16);
for key in &before.split {
if key.distance([16, 0]) < key.size() * 10 {
if key.distance([16, 0]) < key.size() * 10 && key.reach([16, 0]) <= range * range {
assert!(after.split.contains(key));
retained += 1;
}
@@ -1772,6 +1890,82 @@ mod tests {
);
}
#[test]
fn open_water_and_its_floor_are_lit_as_the_seed_lights_them() {
let span = |min, max, state, light| Span {
min,
max,
state: BlockStateId(state),
biome: 0,
light,
};
// 0 air, 1 stone, 2 water, 3 lava.
let kinds = [AIR, KEPT, KEPT | FLUID, KEPT | FLUID | 15 << 4];
// A chunk's own light: a cave, the floor, water fading a level a block.
let mut sea = Column {
surface: None,
spans: vec![
span(30, 40, 1, 0),
span(40, 48, 1, 0x10 | 2 << 12),
span(48, 61, 2, 0x20),
span(61, 62, 2, 0xe0),
span(62, 63, 2, 0xf0),
],
};
sea.light_open_water(&kinds);
assert_eq!(
sea.spans,
[
span(30, 40, 1, 0),
span(40, 48, 1, 0xf0 | 2 << 12),
span(48, 63, 2, 0xf0)
]
);
// Land, and lava, keep their light.
for top in [1, 3] {
let mut column = Column {
surface: None,
spans: vec![span(40, 50, 2, 0x20), span(50, 63, top, 0x80)],
};
let before = column.spans.clone();
column.light_open_water(&kinds);
assert_eq!(column.spans, before);
}
}
#[test]
fn nearly_full_blocks_are_kept() {
assert!(nearly_full("minecraft:powder_snow"));
assert!(nearly_full("minecraft:dirt_path"));
assert!(nearly_full("minecraft:oak_stairs"));
assert!(!nearly_full("minecraft:oak_fence"));
assert!(!nearly_full("minecraft:wheat"));
}
#[test]
fn kelp_to_the_surface_is_drawn_as_water() {
// 0 air, 1 stone, 2 water, 3 kelp (holds water, draws as none).
let kinds = [AIR, KEPT, KEPT | FLUID, KEPT | FLUID | WATERY];
let mut chunk = Chunk::empty(0, 0);
for x in [3, 4] {
for y in MIN_Y..40 {
chunk.set(x, y, 3, 1);
}
}
for y in 40..63 {
chunk.set(3, y, 3, 3);
chunk.set(4, y, 3, 2);
}
let water = water_source(&kinds);
assert_eq!(water, Some(BlockStateId(2)));
let kelp = Column::from_chunk(&chunk, &kinds, water, 3, 3);
let sea = Column::from_chunk(&chunk, &kinds, water, 4, 3);
assert_eq!(kelp.spans, sea.spans);
assert_eq!(
kelp.spans.last().map(|s| (s.min, s.max, s.state)),
Some((40, 63, BlockStateId(2)))
);
assert_eq!(kelp.surface.unwrap().water, Some((63, BlockStateId(2))));
assert_eq!(kelp.surface.unwrap().height, 40);
}
#[test]
fn coarse_voxels_reduce_the_area_instead_of_inflating_one_leaf() {
let span = |min, max, state| Span {
min,
@@ -1967,6 +2161,24 @@ mod tests {
chunk.set(8, 71, 8, chorus.0);
chunk.set(8, 72, 8, chorus.0);
chunk.set(8, 73, 8, flower.0);
// Kelp to the surface is water; powder snow and a waterlogged stair
// are kept as themselves.
let state = |name: &str| assets.registry.default_state(name).unwrap();
let (kelp, powder_snow) = (state("minecraft:kelp"), state("minecraft:powder_snow"));
let stair = facts
.states
.iter()
.position(|f| &*f.name == "minecraft:oak_stairs" && f.waterlogged)
.map(|i| BlockStateId(i as u32))
.unwrap();
chunk.set(2, 54, 2, stone.0);
for y in 55..64 {
chunk.set(2, y, 2, kelp.0);
}
chunk.set(6, 90, 6, stone.0);
chunk.set(6, 91, 6, powder_snow.0);
chunk.set(10, 54, 10, stone.0);
chunk.set(10, 55, 10, stair.0);
lod.observe(Arc::new(chunk.clone()), &facts);
receive_observed(&mut lod);
let observed = &lod.saved[&(-1, -1)];
@@ -1984,6 +2196,12 @@ mod tests {
.collect::<Vec<_>>(),
vec![(70, 71, end_stone), (71, 73, chorus), (73, 74, flower)]
);
let kelp_column = &observed[2 * 16 + 2];
assert_eq!(kelp_column.surface.unwrap().height, 55);
assert_eq!(kelp_column.surface.unwrap().water, Some((64, water)));
assert_eq!(observed[6 * 16 + 6].surface.unwrap().state, powder_snow);
assert_eq!(observed[6 * 16 + 6].surface.unwrap().height, 92);
assert_eq!(observed[10 * 16 + 10].surface.unwrap().state, stair);
let fine = TileKey {
x: -1,
@@ -2053,7 +2271,7 @@ mod tests {
let await_meshes = |lod: &mut Lod| {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
let meshes = lod.update(camera, 0.0, 2, 64, None);
let meshes = lod.update(camera, 2, 64, None);
if !meshes.is_empty() {
return meshes;
}
@@ -2071,7 +2289,7 @@ mod tests {
lod.ready.insert(child, lod.ready[&key].clone());
}
lod.reselect = true;
assert!(lod.update(camera, 0.0, 2, 64, None).is_empty());
assert!(lod.update(camera, 2, 64, None).is_empty());
assert!(
lod.active.contains(&key),
"parent retired before child meshes"
@@ -2101,7 +2319,7 @@ mod tests {
lod.replanned = true;
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
lod.update(camera, 0., 2, 64, None);
lod.update(camera, 2, 64, None);
if !lod.meshing && lod.active.is_empty() {
break;
}
@@ -2139,33 +2357,26 @@ mod tests {
level: 0,
})
.collect();
lod.sample_nearby = true;
lod.detail_request_pending = true;
lod.meshing = true;
lod.update(camera, 0.0, 2, 64, Some(FxHashSet::default()));
lod.update(camera, 2, 64, Some(FxHashSet::default()));
let Some(Job::Sample(nearby)) = lod.shared.jobs.lock().unwrap().pop_front() else {
panic!("nearby detail was not scheduled");
};
assert_eq!(nearby.level, 0);
assert!(
nearby.origin()[2] > -256 + 64,
"detail should load ahead of the view"
assert_eq!(
nearby.distance([-256, -256]),
0,
"the nearest tiles load first"
);
lod.finish_sample(nearby, vec![Column::default(); 256]);
lod.update(camera, 0.0, 2, 64, Some(FxHashSet::default()));
let Some(Job::Sample(fallback)) = lod.shared.jobs.lock().unwrap().pop_front() else {
panic!("fallback refinement was starved");
};
assert!(fallback.level > 0);
lod.pending.remove(&fallback);
lod.sample_nearby = true;
lod.update(camera, 90.0, 2, 64, Some(FxHashSet::default()));
let Some(Job::Sample(turned)) = lod.shared.jobs.lock().unwrap().pop_front() else {
panic!("turned view was not scheduled");
lod.update(camera, 2, 64, Some(FxHashSet::default()));
let Some(Job::Sample(next)) = lod.shared.jobs.lock().unwrap().pop_front() else {
panic!("the next ring was not scheduled");
};
assert!(
turned.origin()[0] < -256 - 64,
"loading priority should follow a turn"
!lod.plan.split.contains(&next) && next.distance([-256, -256]) <= 64,
"the next nearest final tile, never a coarse one under it: {next:?}"
);
lod.shared.jobs.lock().unwrap().clear();
let near = TileKey {
@@ -2198,17 +2409,19 @@ mod tests {
level: 0,
})
.collect();
lod.sample_nearby = false;
lod.update(camera, 0., 2, 64, Some(FxHashSet::default()));
let Some(Job::Sample(broad)) = lod.shared.jobs.lock().unwrap().pop_front() else {
panic!("projected-error refinement missing")
lod.update(camera, 2, 64, Some(FxHashSet::default()));
let Some(Job::Sample(first)) = lod.shared.jobs.lock().unwrap().pop_front() else {
panic!("no final tile scheduled")
};
assert_eq!(
broad.level, 5,
"a near tile touching the camera must not starve a huge visible distant tile"
(first.level, first.parent()),
(1, near),
"the nearest parent's final tiles come first"
);
lod.shared.jobs.lock().unwrap().clear();
lod.active = (0..40).map(|x| TileKey { x, z: 0, level: 0 }).collect();
lod.active = (0..DETAIL_REQUEST as i32 + 8)
.map(|x| TileKey { x, z: 0, level: 0 })
.collect();
lod.plan.split.clear();
lod.detail_masks.clear();
lod.covered = [(0, 0)].into_iter().collect();
@@ -2221,7 +2434,9 @@ mod tests {
};
assert_eq!(
chunks,
(1..=32).map(|x| (x, 0)).collect::<Vec<_>>(),
(1..=DETAIL_REQUEST as i32)
.map(|x| (x, 0))
.collect::<Vec<_>>(),
"one unfinished fine tile must not starve neighbouring requests"
);
@@ -2238,7 +2453,7 @@ mod tests {
lod.set_near_loading(near_loading);
lod.shared.jobs.lock().unwrap().clear();
lod.pending.clear();
lod.update([-256., 64., -256.], 0., 2, 64, Some(FxHashSet::default()));
lod.update([-256., 64., -256.], 2, 64, Some(FxHashSet::default()));
let jobs = lod.shared.jobs.lock().unwrap();
jobs.iter()
.filter(|job| matches!(job, Job::Sample(_)))
@@ -0,0 +1,356 @@
//! Decorated far-terrain chunks kept on disk, beside a dimension's save, so a
//! world opened again shows them without generating them again. One append-only
//! file per 32x32 chunk region; a file written by another build, block registry
//! or format is cleared on first use.
use super::{Column, Span, Surface};
use crate::fast_hash::FxHashMap;
use horizon_world::registry::BlockStateId;
use std::{
fs::{File, OpenOptions},
io::{Read, Seek, SeekFrom, Write},
path::PathBuf,
sync::{Arc, Mutex},
};
const MAGIC: &[u8; 4] = b"HLOD";
const FORMAT: u32 = 1;
const HEADER: u64 = 16;
/// A record: chunk x and z, then the deflated columns' length.
const RECORD: u64 = 12;
/// Columns of a chunk are a few kilobytes deflated; anything larger is damage.
const MAX_RECORD: u32 = 1 << 22;
type Columns = Arc<[Column; 256]>;
struct Region {
file: File,
/// Each chunk's latest record: the offset of its deflated columns, and
/// their length.
index: FxHashMap<(i32, i32), (u64, u32)>,
}
pub struct DiskCache {
dir: PathBuf,
key: u64,
// ponytail: one lock for every region, with file reads under it; per-region
// locks if the sampling threads ever wait on it.
regions: Mutex<FxHashMap<(i32, i32), Option<Region>>>,
}
impl DiskCache {
/// `key`: what the cached columns depend on (`build_key`); files with
/// another are cleared.
pub fn new(dir: PathBuf, key: u64) -> Self {
Self {
dir,
key,
regions: Mutex::default(),
}
}
pub fn get(&self, x: i32, z: i32) -> Option<Columns> {
let mut regions = self.regions.lock().ok()?;
let region = self.region(&mut regions, x, z, false)?;
let &(offset, len) = region.index.get(&(x, z))?;
let mut bytes = vec![0; len as usize];
region.file.seek(SeekFrom::Start(offset)).ok()?;
region.file.read_exact(&mut bytes).ok()?;
drop(regions);
decode(&bytes).map(Arc::new)
}
fn contains(&self, x: i32, z: i32) -> bool {
let Ok(mut regions) = self.regions.lock() else {
return false;
};
self.region(&mut regions, x, z, false)
.is_some_and(|region| region.index.contains_key(&(x, z)))
}
/// Writes a chunk not cached yet. One already cached stays as it is: the
/// build key clears stale ones, and a generation batch hands back
/// neighbours cached before, which appended again grew the files each
/// session.
pub fn put(&self, x: i32, z: i32, columns: &[Column; 256]) {
if self.contains(x, z) {
return;
}
let bytes = encode(columns);
let Ok(mut regions) = self.regions.lock() else {
return;
};
let Some(region) = self.region(&mut regions, x, z, true) else {
return;
};
// Another sampler's thread may have written it while this one encoded.
if region.index.contains_key(&(x, z)) {
return;
}
let mut record = Vec::with_capacity(RECORD as usize + bytes.len());
record.extend_from_slice(&x.to_le_bytes());
record.extend_from_slice(&z.to_le_bytes());
record.extend_from_slice(&(bytes.len() as u32).to_le_bytes());
record.extend_from_slice(&bytes);
let Ok(end) = region.file.seek(SeekFrom::End(0)) else {
return;
};
// One write, so a crash leaves at most a torn last record, which
// the next open cuts off.
if region.file.write_all(&record).is_ok() {
region
.index
.insert((x, z), (end + RECORD, bytes.len() as u32));
}
}
fn region<'a>(
&self,
regions: &'a mut FxHashMap<(i32, i32), Option<Region>>,
x: i32,
z: i32,
create: bool,
) -> Option<&'a mut Region> {
let at = (x >> 5, z >> 5);
if create && matches!(regions.get(&at), Some(None)) {
regions.remove(&at);
}
regions
.entry(at)
.or_insert_with(|| self.open(at, create))
.as_mut()
}
fn open(&self, (rx, rz): (i32, i32), create: bool) -> Option<Region> {
let path = self.dir.join(format!("r.{rx}.{rz}.hlod"));
if !create && !path.exists() {
return None;
}
if create {
std::fs::create_dir_all(&self.dir).ok()?;
}
let mut file = OpenOptions::new()
.read(true)
.write(true)
.create(create)
.truncate(false)
.open(&path)
.ok()?;
let mut header = [0; HEADER as usize];
let current = file.read_exact(&mut header).is_ok()
&& header[..4] == *MAGIC
&& header[4..8] == FORMAT.to_le_bytes()
&& header[8..] == self.key.to_le_bytes();
let mut index = FxHashMap::default();
if current {
let size = file.metadata().ok()?.len();
let mut at = HEADER;
let mut record = [0; RECORD as usize];
while at + RECORD <= size {
file.seek(SeekFrom::Start(at)).ok()?;
file.read_exact(&mut record).ok()?;
let field = |i: usize| u32::from_le_bytes(record[i..i + 4].try_into().unwrap());
let len = field(8);
if len > MAX_RECORD || at + RECORD + u64::from(len) > size {
break;
}
index.insert((field(0) as i32, field(4) as i32), (at + RECORD, len));
at += RECORD + u64::from(len);
}
if at < size {
file.set_len(at).ok()?;
}
} else {
file.set_len(0).ok()?;
file.seek(SeekFrom::Start(0)).ok()?;
let mut header = Vec::with_capacity(HEADER as usize);
header.extend_from_slice(MAGIC);
header.extend_from_slice(&FORMAT.to_le_bytes());
header.extend_from_slice(&self.key.to_le_bytes());
file.write_all(&header).ok()?;
}
Some(Region { file, index })
}
}
/// What cached columns depend on: this build (its commit, or for a local
/// build the executable's own time, so a rebuilt generator starts afresh),
/// the block states and the biomes.
pub fn build_key(states: usize, biomes: usize) -> u64 {
use std::hash::{Hash, Hasher};
let mut hasher = std::hash::DefaultHasher::new();
FORMAT.hash(&mut hasher);
env!("CARGO_PKG_VERSION").hash(&mut hasher);
match option_env!("HORIZON_COMMIT") {
Some(commit) => commit.hash(&mut hasher),
None => std::env::current_exe()
.and_then(|exe| exe.metadata()?.modified())
.ok()
.hash(&mut hasher),
}
states.hash(&mut hasher);
biomes.hash(&mut hasher);
hasher.finish()
}
fn encode(columns: &[Column; 256]) -> Vec<u8> {
let mut out = Vec::new();
for column in columns {
match column.surface {
None => out.push(0),
Some(s) => {
out.push(1 + u8::from(s.water.is_some()));
out.extend_from_slice(&s.height.to_le_bytes());
out.extend_from_slice(&s.state.0.to_le_bytes());
out.extend_from_slice(&s.biome.to_le_bytes());
if let Some((height, state)) = s.water {
out.extend_from_slice(&height.to_le_bytes());
out.extend_from_slice(&state.0.to_le_bytes());
}
}
}
out.extend_from_slice(&(column.spans.len() as u16).to_le_bytes());
for span in &column.spans {
out.extend_from_slice(&span.min.to_le_bytes());
out.extend_from_slice(&span.max.to_le_bytes());
out.extend_from_slice(&span.state.0.to_le_bytes());
out.extend_from_slice(&span.biome.to_le_bytes());
out.extend_from_slice(&span.light.to_le_bytes());
}
}
let mut deflate =
flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default());
// Writing to a Vec cannot fail.
deflate.write_all(&out).unwrap();
deflate.finish().unwrap()
}
fn decode(bytes: &[u8]) -> Option<[Column; 256]> {
let mut raw = Vec::new();
flate2::read::DeflateDecoder::new(bytes)
.take(u64::from(MAX_RECORD) * 8)
.read_to_end(&mut raw)
.ok()?;
let mut at = 0;
let mut take = |n: usize| {
let field = raw.get(at..at + n)?;
at += n;
Some(field)
};
let mut columns: [Column; 256] = std::array::from_fn(|_| Column::default());
for column in &mut columns {
let i32_ = |b: &[u8]| i32::from_le_bytes(b.try_into().unwrap());
let u32_ = |b: &[u8]| u32::from_le_bytes(b.try_into().unwrap());
let u16_ = |b: &[u8]| u16::from_le_bytes(b.try_into().unwrap());
let i16_ = |b: &[u8]| i16::from_le_bytes(b.try_into().unwrap());
let tag = take(1)?[0];
if tag > 2 {
return None;
}
if tag > 0 {
let height = i32_(take(4)?);
let state = BlockStateId(u32_(take(4)?));
let biome = u16_(take(2)?);
let water = if tag == 2 {
Some((i32_(take(4)?), BlockStateId(u32_(take(4)?))))
} else {
None
};
column.surface = Some(Surface {
height,
state,
water,
biome,
});
}
let count = u16_(take(2)?);
column.spans.reserve(count.into());
for _ in 0..count {
let span = Span {
min: i16_(take(2)?),
max: i16_(take(2)?),
state: BlockStateId(u32_(take(4)?)),
biome: u16_(take(2)?),
light: u16_(take(2)?),
};
if span.min >= span.max {
return None;
}
column.spans.push(span);
}
}
(at == raw.len()).then_some(columns)
}
#[cfg(test)]
mod tests {
use super::*;
fn columns(seed: u32) -> [Column; 256] {
std::array::from_fn(|i| {
let i = i as u32 + seed;
Column {
surface: (!i.is_multiple_of(3)).then(|| Surface {
height: 60 + (i % 7) as i32,
state: BlockStateId(i),
water: (i.is_multiple_of(2)).then_some((63, BlockStateId(9))),
biome: (i % 5) as u16,
}),
spans: (0..i % 4)
.map(|n| Span {
min: (n * 10) as i16 - 64,
max: (n * 10 + 4) as i16 - 64,
state: BlockStateId(n + i),
biome: 2,
light: 240,
})
.collect(),
}
})
}
#[test]
fn chunks_survive_reopening_and_a_torn_last_record() {
let dir = std::env::temp_dir().join(format!("horizon-lod-cache-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let cache = DiskCache::new(dir.clone(), 7);
assert!(cache.get(3, -40).is_none(), "nothing cached yet");
cache.put(3, -40, &columns(0));
cache.put(4, -40, &columns(1));
let path = dir.join("r.0.-2.hlod");
let size = std::fs::metadata(&path).unwrap().len();
// A chunk cached already is not appended again.
cache.put(3, -40, &columns(2));
assert_eq!(*cache.get(3, -40).unwrap(), columns(0));
assert_eq!(std::fs::metadata(&path).unwrap().len(), size);
drop(cache);
let file = OpenOptions::new().write(true).open(&path).unwrap();
file.set_len(size + 7).unwrap();
drop(file);
let cache = DiskCache::new(dir.clone(), 7);
assert_eq!(*cache.get(3, -40).unwrap(), columns(0));
assert_eq!(*cache.get(4, -40).unwrap(), columns(1));
assert_eq!(
std::fs::metadata(&path).unwrap().len(),
size,
"torn tail cut"
);
drop(cache);
// Another build's files are cleared, not read.
let cache = DiskCache::new(dir.clone(), 8);
assert!(cache.get(3, -40).is_none());
cache.put(5, -40, &columns(3));
assert!(cache.get(4, -40).is_none());
assert_eq!(*cache.get(5, -40).unwrap(), columns(3));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn damaged_records_read_as_missing() {
let bytes = encode(&columns(0));
assert_eq!(decode(&bytes).unwrap(), columns(0));
assert!(decode(&bytes[..bytes.len() / 2]).is_none());
assert!(decode(b"not deflate").is_none());
}
}
+204 -65
View File
@@ -74,10 +74,18 @@ impl Mesher {
covered_bounds,
neighbors,
finer,
|span, direction| self.material(span, direction, context),
|span, direction, at| self.material(span, direction, at, context),
)
}
fn material(&mut self, span: &Span, direction: Direction, context: &WorkerContext) -> Material {
/// `at`: the block column the face covers, for the grass colours that
/// change across a biome (swamps).
fn material(
&mut self,
span: &Span,
direction: Direction,
at: [i32; 2],
context: &WorkerContext,
) -> Material {
let styles = *self
.styles
.entry((span.state, direction as u8))
@@ -122,6 +130,12 @@ impl Mesher {
let tint = match name.strip_prefix("minecraft:").unwrap_or(name) {
"spruce_leaves" => Tint::Constant(0x61_9961),
"birch_leaves" => Tint::Constant(0x80_A755),
// Cherry (and other pre-coloured) leaves: the texture is
// already pink; biome foliage tint turns a grove yellow (#4688).
"cherry_leaves"
| "azalea_leaves"
| "flowering_azalea_leaves"
| "pale_oak_leaves" => Tint::None,
"leaf_litter" => Tint::Dry,
_ if leaves => Tint::Foliage,
_ => Tint::Grass,
@@ -179,7 +193,11 @@ impl Mesher {
face.map(|face| {
let rgb = match face.tint {
Tint::None => 0xffffff,
Tint::Grass => biome.grass,
Tint::Grass => {
context
.biomes
.grass(span.biome, f64::from(at[0]), f64::from(at[1]))
}
Tint::Foliage => biome.foliage,
Tint::Dry => biome.dry_foliage,
Tint::Water => biome.water,
@@ -256,7 +274,7 @@ fn mesh_columns(
columns: &[Column],
covered: &FxHashSet<(i32, i32)>,
covered_bounds: Option<[i32; 4]>,
material: impl FnMut(&Span, Direction) -> Material,
material: impl FnMut(&Span, Direction, [i32; 2]) -> Material,
) -> SectionMesh {
mesh_columns_with_neighbors(key, columns, covered, covered_bounds, &[], material)
}
@@ -267,7 +285,7 @@ fn mesh_columns_with_neighbors(
covered: &FxHashSet<(i32, i32)>,
covered_bounds: Option<[i32; 4]>,
neighbors: &[Column],
material: impl FnMut(&Span, Direction) -> Material,
material: impl FnMut(&Span, Direction, [i32; 2]) -> Material,
) -> SectionMesh {
assert!(neighbors.is_empty() || neighbors.len() == 4 * CELLS);
let stride = key.size() / CELLS as i32;
@@ -293,7 +311,7 @@ fn mesh_columns_with_edges(
covered_bounds: Option<[i32; 4]>,
neighbors: &[Vec<EdgeColumn>; 4],
finer: &[TileKey],
mut material: impl FnMut(&Span, Direction) -> Material,
mut material: impl FnMut(&Span, Direction, [i32; 2]) -> Material,
) -> SectionMesh {
assert_eq!(columns.len(), CELLS * CELLS);
let mut count = CELLS * CELLS;
@@ -325,17 +343,31 @@ fn mesh_columns_with_edges(
neighbors,
stride,
|x, z| cell_step(x, z) == stride,
|span| material(span, Direction::Up).fluid != 0,
|span| material(span, Direction::Up, [ox, oz]).fluid != 0,
);
// A cell's faces take the colours at its centre. The tile's neighbours'
// columns only hide faces, so their colours never show.
let centre = |i: usize| {
if i < CELLS * CELLS {
[
ox + (i % CELLS) as i32 * stride + stride / 2,
oz + (i / CELLS) as i32 * stride + stride / 2,
]
} else {
[ox, oz]
}
};
let prepared: Vec<Vec<Segment<'_>>> = filled
.iter()
.map(|column| &**column)
.chain(neighbors.iter().flatten().map(|edge| &edge.column))
.map(|column| {
.enumerate()
.map(|(index, column)| {
let at = centre(index);
let materials: Vec<_> = column
.spans
.iter()
.map(|s| material(s, Direction::Up))
.map(|s| material(s, Direction::Up, at))
.collect();
column
.spans
@@ -425,7 +457,7 @@ fn mesh_columns_with_edges(
u1: x1,
v1: z1,
},
material(segment.span, Direction::Down),
material(segment.span, Direction::Down, centre(z * CELLS + x)),
);
}
for (direction, nx, nz, edge, nw, plane, u0, u1) in [
@@ -470,21 +502,25 @@ fn mesh_columns_with_edges(
x1,
),
] {
let side = material(segment.span, direction);
let side = material(segment.span, direction, centre(z * CELLS + x));
// Near meshes may have culled against the real neighbor.
// Keep a finite seal where their full-detail area begins.
// Keep a finite seal where their full-detail area begins:
// opaque only, as the near water carries on into the far
// water and a seal shows through it as a wall.
if is_covered(nw[0], nw[1]) {
add_side(
&mut faces,
direction,
plane,
u0,
u1,
segment,
&[],
side,
own_sky,
);
if segment.material.opaque {
add_side(
&mut faces,
direction,
plane,
u0,
u1,
segment,
&[],
side,
own_sky,
);
}
} else if !edge {
add_side(
&mut faces, direction, plane, u0, u1, segment, column, side,
@@ -597,7 +633,7 @@ mod refinement_tests {
None,
&std::array::from_fn(|_| Vec::new()),
&[fine],
|_, _| Material {
|_, _, _| Material {
faces: [
Some(Paint {
layer: 1,
@@ -646,13 +682,15 @@ mod refinement_tests {
}
}
/// The tile's columns with the gaps no neighbour's open air reaches filled
/// with the material below them, or the fluid above them (kelp and seagrass
/// leave gaps in water): a cave seen from nowhere adds no faces. A
/// gap is open when a neighbouring column's top lies below the gap's top, so
/// that column's open air looks into it. Unknown neighbours, and cells that
/// `whole` rejects (they meet full-detail or finer terrain), count as open;
/// those cells keep their gaps.
/// The tile's columns with the gaps no open air reaches filled with the
/// material below them, or the fluid above them (kelp and seagrass leave gaps
/// in water): a cave seen from nowhere adds no faces. A gap is open when a
/// neighbouring column's top lies below the gap's top, so that column's open
/// air looks into it, when a gap of the tile's neighbour meets it, or when it
/// meets an open gap of a neighbouring column: the air under a canopy reaches
/// the sky through any clearing, where filling it raised pillars of the floor
/// block. Unknown neighbours, and cells that `whole` rejects (they meet
/// full-detail or finer terrain), count as open; those cells keep their gaps.
fn fill_buried_gaps<'a>(
columns: &'a [Column],
neighbors: &[Vec<EdgeColumn>; 4],
@@ -669,6 +707,18 @@ fn fill_buried_gaps<'a>(
.find(|span| span.min < span.max)
.map_or(i16::MIN, |span| span.max)
};
// Each column's gaps: the index of the span above, the gap's bottom and top.
let gaps_of = |column: &Column| -> Vec<(usize, i16, i16)> {
column
.spans
.windows(2)
.enumerate()
.filter(|(_, pair)| pair[0].max < pair[1].min)
.map(|(k, pair)| (k + 1, pair[0].max, pair[1].min))
.collect()
};
let meets =
|(lo, hi): (i16, i16), (other_lo, other_hi): (i16, i16)| lo < other_hi && other_lo < hi;
let tops: Vec<_> = (0..CELLS * CELLS)
.map(|i| {
if whole(i % CELLS, i / CELLS) {
@@ -678,57 +728,110 @@ fn fill_buried_gaps<'a>(
}
})
.collect();
let edge = |side: usize, cell: usize| {
let gaps: Vec<_> = (0..CELLS * CELLS)
.map(|i| {
if whole(i % CELLS, i / CELLS) {
gaps_of(&columns[i])
} else {
Vec::new()
}
})
.collect();
let touching = |side: usize, cell: usize| {
let (start, end) = (cell as i32 * stride, (cell as i32 + 1) * stride);
neighbors[side]
.iter()
.filter(|edge| edge.start < end && start < edge.end)
.filter(move |edge| edge.start < end && start < edge.end)
};
let edge = |side: usize, cell: usize| {
touching(side, cell)
.map(|edge| top(&edge.column))
.min()
.unwrap_or(i16::MIN)
};
// A gap of the tile's neighbour may be open through tiles this one can't
// see: it counts as open.
let edge_gap = |side: usize, cell: usize, gap: (i16, i16)| {
touching(side, cell).any(|edge| {
gaps_of(&edge.column)
.iter()
.any(|&(_, lo, hi)| meets((lo, hi), gap))
})
};
let around = |i: usize| {
let (x, z) = (i % CELLS, i / CELLS);
[
(x > 0).then(|| i - 1),
(x + 1 < CELLS).then(|| i + 1),
(z > 0).then(|| i - CELLS),
(z + 1 < CELLS).then(|| i + CELLS),
]
.into_iter()
.flatten()
};
let mut open: Vec<Vec<bool>> = gaps.iter().map(|g| vec![false; g.len()]).collect();
let mut reached = Vec::new();
for i in 0..CELLS * CELLS {
let (x, z) = (i % CELLS, i / CELLS);
let lowest = [
if x > 0 { tops[i - 1] } else { edge(0, z) },
if x + 1 < CELLS {
tops[i + 1]
} else {
edge(1, z)
},
if z > 0 { tops[i - CELLS] } else { edge(2, x) },
if z + 1 < CELLS {
tops[i + CELLS]
} else {
edge(3, x)
},
]
.into_iter()
.min()
.unwrap_or(i16::MIN);
for (g, &(_, lo, hi)) in gaps[i].iter().enumerate() {
let gap = (lo, hi);
if hi > lowest
|| (x == 0 && edge_gap(0, z, gap))
|| (x + 1 == CELLS && edge_gap(1, z, gap))
|| (z == 0 && edge_gap(2, x, gap))
|| (z + 1 == CELLS && edge_gap(3, x, gap))
{
open[i][g] = true;
reached.push((i, g));
}
}
}
while let Some((i, g)) = reached.pop() {
let (_, lo, hi) = gaps[i][g];
for j in around(i) {
for (h, &(_, other_lo, other_hi)) in gaps[j].iter().enumerate() {
if !open[j][h] && meets((lo, hi), (other_lo, other_hi)) {
open[j][h] = true;
reached.push((j, h));
}
}
}
}
columns
.iter()
.enumerate()
.map(|(i, column)| {
let (x, z) = (i % CELLS, i / CELLS);
if column.spans.len() < 2 || !whole(x, z) {
return Cow::Borrowed(column);
}
let open = [
if x > 0 { tops[i - 1] } else { edge(0, z) },
if x + 1 < CELLS {
tops[i + 1]
} else {
edge(1, z)
},
if z > 0 { tops[i - CELLS] } else { edge(2, x) },
if z + 1 < CELLS {
tops[i + CELLS]
} else {
edge(3, x)
},
]
.into_iter()
.min()
.unwrap_or(i16::MIN);
let buried = |below: &Span, above: &Span| below.max < above.min && above.min <= open;
if !column
.spans
.windows(2)
.any(|pair| buried(&pair[0], &pair[1]))
{
if open[i].iter().all(|&open| open) {
return Cow::Borrowed(column);
}
let mut filled = Column {
surface: column.surface,
spans: Vec::with_capacity(column.spans.len()),
};
for &span in &column.spans {
for (k, &span) in column.spans.iter().enumerate() {
let mut span = span;
if let Some(below) = filled.spans.last_mut()
&& buried(below, &span)
{
let buried = gaps[i]
.iter()
.zip(&open[i])
.any(|(&(above, _, _), &open)| above == k && !open);
if buried && let Some(below) = filled.spans.last_mut() {
if fluid(&span) {
span.min = below.max;
} else {
@@ -919,7 +1022,7 @@ mod tests {
})
.collect()
}
fn material(span: &Span, _: Direction) -> Material {
fn material(span: &Span, _: Direction, _: [i32; 2]) -> Material {
let water = span.state.0 == 2;
Material {
faces: [
@@ -1117,6 +1220,42 @@ mod tests {
assert!(cave[0].iter().all(|v| v.light == 0));
}
#[test]
fn air_under_a_canopy_stays_open_through_a_clearing() {
let column = |spans: Vec<Span>| Column {
surface: None,
spans,
};
// The tile's neighbours are solid hills: no open air from the sides.
let hill = || EdgeColumn {
start: 0,
end: CELLS as i32,
column: column(vec![span(0, 20, 1)]),
};
let neighbors = [vec![hill()], vec![hill()], vec![hill()], vec![hill()]];
let fill = |columns: &[Column]| -> Vec<Vec<Span>> {
fill_buried_gaps(columns, &neighbors, 1, |_, _| true, |_| false)
.iter()
.map(|filled| filled.spans.clone())
.collect()
};
let mut forest = vec![column(vec![span(0, 5, 1), span(10, 14, 3)]); CELLS * CELLS];
// A closed canopy hides the floor: filling it under there shows nowhere.
assert!(fill(&forest).iter().all(|spans| spans[0].max == 10));
// One clearing lets the sky in: the air under every tree stays air,
// not a pillar of the floor block up to the leaves.
forest[5 * CELLS + 5] = column(vec![span(0, 5, 1)]);
let filled = fill(&forest);
for (filled, column) in filled.iter().zip(&forest) {
assert_eq!(filled, &column.spans);
}
// A cave two columns wide sealed in rock is still filled.
let mut rock = vec![column(vec![span(0, 20, 1)]); CELLS * CELLS];
for cave in [5 * CELLS + 5, 5 * CELLS + 6] {
rock[cave] = column(vec![span(0, 8, 1), span(12, 20, 1)]);
}
assert!(fill(&rock).iter().all(|spans| spans == &[span(0, 20, 1)]));
}
#[test]
fn caves_no_neighbor_sees_into_add_no_faces() {
let mut columns = columns();
for column in &mut columns {
+133 -4
View File
@@ -8,11 +8,12 @@
//! Messages go through the `log` facade. Horizon's own still print their plain text on stderr,
//! as they did before the log existed; other crates' errors only reach the file (their
//! warnings are mostly the Vulkan loader's startup chatter).
use std::collections::VecDeque;
use std::collections::{HashMap, VecDeque};
use std::fs::{self, File, OpenOptions};
use std::io::{self, Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use std::sync::{Mutex, mpsc};
use std::time::{Duration, Instant};
use chrono::{DateTime, Local, NaiveDate};
@@ -20,6 +21,11 @@ use chrono::{DateTime, Local, NaiveDate};
pub const MAX_ARCHIVES_PER_DAY: u32 = 7;
/// Formatted lines kept in memory for crash reports.
const RECENT: usize = 50;
/// Warnings and errors one line of code may log in a [`FLOOD_WINDOW`]; past that they are counted,
/// not written, so a warning repeated every packet can't fill the disk. Vanilla has no such limit:
/// below it the log is vanilla's, line for line.
const FLOOD_LINES: u32 = 100;
const FLOOD_WINDOW: Duration = Duration::from_secs(60);
/// The platform data folder (what the `dirs` crate's data_dir gives).
pub fn data_dir() -> PathBuf {
@@ -45,9 +51,20 @@ pub fn data_dir() -> PathBuf {
std::env::temp_dir()
}
/// Where the launcher runs the game and keeps its saves: `HORIZON_HOME`, else `horizon` in the
/// platform data folder (launcher/src/backend.rs `game_home`).
/// `--home DIR`, set once at start; it wins over `HORIZON_HOME`.
static HOME: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
/// Makes `game_home` return `home` for the rest of the run (`--home`).
pub fn set_game_home(home: PathBuf) {
let _ = HOME.set(home);
}
/// Where the launcher runs the game and keeps its saves: `--home`, else `HORIZON_HOME`, else
/// `horizon` in the platform data folder (launcher/src/backend.rs `game_home`).
pub fn game_home() -> PathBuf {
if let Some(home) = HOME.get() {
return home.clone();
}
match std::env::var_os("HORIZON_HOME").filter(|h| !h.is_empty()) {
Some(h) => PathBuf::from(h),
None => data_dir().join("horizon"),
@@ -272,6 +289,59 @@ struct Logger {
/// What the main thread is called in the log: "Render thread", or "Server thread" for the
/// dedicated server, as in vanilla.
main_thread: &'static str,
floods: Mutex<Floods>,
}
/// What the flood limit does with one warning or error.
#[derive(Debug, PartialEq, Eq)]
enum Gate {
/// Write it, after saying how many lines like it were left out in the window before.
Write {
left_out: u32,
},
/// Write it, then say the next ones from this line of code wait for the next window.
Last,
Skip,
}
/// Warnings and errors per line of code, in windows of [`FLOOD_WINDOW`].
#[derive(Default)]
struct Floods {
sites: HashMap<(String, u32), Flood>,
}
struct Flood {
since: Instant,
written: u32,
left_out: u32,
}
impl Floods {
fn gate(&mut self, file: &str, line: u32, now: Instant) -> Gate {
let flood = self.sites.entry((file.to_owned(), line)).or_insert(Flood {
since: now,
written: 0,
left_out: 0,
});
let mut left_out = 0;
if now.duration_since(flood.since) >= FLOOD_WINDOW {
left_out = flood.left_out;
*flood = Flood {
since: now,
written: 0,
left_out: 0,
};
}
flood.written += 1;
match flood.written.cmp(&FLOOD_LINES) {
std::cmp::Ordering::Less => Gate::Write { left_out },
std::cmp::Ordering::Equal => Gate::Last,
std::cmp::Ordering::Greater => {
flood.left_out += 1;
Gate::Skip
}
}
}
}
static LOGGER: std::sync::OnceLock<Logger> = std::sync::OnceLock::new();
@@ -297,8 +367,37 @@ impl log::Log for Logger {
Some(name) => name,
None => "unnamed",
};
let gate = if record.level() <= log::Level::Warn {
let mut floods = self.floods.lock().unwrap_or_else(|e| e.into_inner());
floods.gate(
record.file().unwrap_or(record.target()),
record.line().unwrap_or(0),
Instant::now(),
)
} else {
Gate::Write { left_out: 0 }
};
let level = record.level();
match gate {
Gate::Skip => return,
Gate::Write { left_out } if left_out > 0 => self.write(
thread,
level,
&format!(
"{left_out} more lines like the next one were not logged in the last minute"
),
),
_ => {}
}
let stderr = ours(record.target()).then(|| message.clone());
self.send(thread, record.level(), &message, stderr);
self.send(thread, level, &message, stderr);
if gate == Gate::Last {
self.write(
thread,
level,
&format!("{FLOOD_LINES} lines like this one in a minute: the next ones are counted, not logged"),
);
}
}
/// Waits (a second at most) until every line logged before is in the file.
@@ -392,6 +491,7 @@ pub fn init(home: &Path, main_thread: &'static str) {
out,
recent: Mutex::new(VecDeque::new()),
main_thread,
floods: Mutex::new(Floods::default()),
}
});
if log::set_logger(logger).is_ok() {
@@ -467,6 +567,35 @@ pub fn newest_crash_report(home: &Path) -> Option<PathBuf> {
mod tests {
use super::*;
#[test]
fn a_warning_repeated_every_packet_is_counted_past_a_hundred_a_minute() {
let (mut floods, start) = (Floods::default(), Instant::now());
let gates: Vec<Gate> = (0..140)
.map(|i| floods.gate("lan_vehicles.rs", 345, start + Duration::from_millis(i * 7)))
.collect();
assert!(
gates[..99]
.iter()
.all(|g| *g == Gate::Write { left_out: 0 })
);
assert_eq!(gates[99], Gate::Last);
assert!(gates[100..].iter().all(|g| *g == Gate::Skip));
// Another line of code has its own count.
assert_eq!(
floods.gate("lan_vehicles.rs", 330, start),
Gate::Write { left_out: 0 }
);
// The next minute writes again, and says how many were left out.
assert_eq!(
floods.gate("lan_vehicles.rs", 345, start + FLOOD_WINDOW),
Gate::Write { left_out: 40 }
);
assert_eq!(
floods.gate("lan_vehicles.rs", 345, start + FLOOD_WINDOW),
Gate::Write { left_out: 0 }
);
}
fn day(d: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(2026, 10, d).unwrap()
}
+164 -18
View File
@@ -139,11 +139,50 @@ mod vertex_tests {
assert_eq!(hdr_tier("red_candle", 3), LOW);
assert_eq!(hdr_tier("glow_lichen", 7), 0);
assert_eq!(hdr_tier("stone", 0), 0);
assert_eq!(hdr_tier("ochre_froglight", 15), LOW);
assert_eq!(hdr_tier("respawn_anchor", 3), INCANDESCENT);
assert_eq!(hdr_tier("waxed_copper_bulb", 15), LOW);
assert_eq!(hdr_tier("sea_pickle", 15), 0);
// Unlisted ones by their light level.
assert_eq!(hdr_tier("ochre_froglight", 15), INCANDESCENT);
assert_eq!(hdr_tier("vault", 12), INCANDESCENT);
assert_eq!(hdr_tier("sculk_sensor", 1), LOW);
}
}
#[cfg(test)]
mod section_mesh_tests {
use super::*;
#[test]
fn compute_bounds_empty_solid_and_translucent() {
let mut mesh = SectionMesh::default();
assert_eq!(mesh.compute_bounds(), None);
let v1 = TerrainVertex::new([1.0, 2.0, 3.0], [0.0, 0.0], 0, 0, 0);
let v2 = TerrainVertex::new([4.0, 5.0, 6.0], [0.0, 0.0], 0, 0, 0);
mesh.solid.push(v1);
mesh.solid.push(v2);
let pad = glam::Vec3::splat(SectionMesh::BOUNDS_PAD);
assert_eq!(
mesh.compute_bounds(),
Some([
glam::Vec3::new(1.0, 2.0, 3.0) - pad,
glam::Vec3::new(4.0, 5.0, 6.0) + pad,
])
);
let v3 = TerrainVertex::new([0.5, 7.0, 8.0], [0.0, 0.0], 0, 0, 0);
mesh.translucent.push(v3);
assert_eq!(
mesh.compute_bounds(),
Some([
glam::Vec3::new(0.5, 2.0, 3.0) - pad,
glam::Vec3::new(4.0, 7.0, 8.0) + pad,
])
);
}
}
pub struct SectionMesh {
pub solid: Vec<TerrainVertex>,
/// The buckets `solid` is sorted in by the side its quads face: empty
@@ -158,6 +197,8 @@ pub struct SectionMesh {
/// each frame by `block_entity_render` like vanilla's per-section
/// `SectionCompiler.Results.blockEntities`.
pub block_entities: Vec<([i32; 3], u32)>,
/// Axis-aligned bounding box [min, max] of actual terrain mesh vertices in section-local space [0, 16].
pub bounds: Option<[glam::Vec3; 2]>,
}
impl Default for SectionMesh {
fn default() -> Self {
@@ -167,19 +208,66 @@ impl Default for SectionMesh {
translucent: Vec::new(),
visibility: ALL_VISIBLE,
block_entities: Vec::new(),
bounds: None,
}
}
}
impl SectionMesh {
/// Padding applied to mesh vertex bounds to prevent clipping at the frustum
/// boundary when shader packs with vertex displacement (e.g. waving foliage, water) are active.
pub const BOUNDS_PAD: f32 = 0.5;
/// Retain geometry capacity after an upload, but none of its old contents.
pub(crate) fn clear(&mut self) {
self.solid.clear();
self.translucent.clear();
self.block_entities.clear();
self.sides = Sides::default();
self.visibility = ALL_VISIBLE;
self.bounds = None;
}
pub(crate) fn capacity_bytes(&self) -> usize {
self.solid.capacity() * std::mem::size_of::<TerrainVertex>()
+ self.translucent.capacity() * std::mem::size_of::<TerrainVertex>()
+ self.block_entities.capacity() * std::mem::size_of::<([i32; 3], u32)>()
}
/// Sorts the solid quads by the side they face, for the renderer to
/// leave out those facing away from the eye (`sides`).
pub fn sort_solid(&mut self) {
self.sides = sides::sort(&mut self.solid);
self.bounds = self.compute_bounds();
}
/// Computes the tight axis-aligned bounding box [min, max] of actual terrain geometry vertices.
pub fn compute_bounds(&self) -> Option<[glam::Vec3; 2]> {
if self.solid.is_empty() && self.translucent.is_empty() {
return None;
}
let mut min = glam::Vec3::splat(f32::INFINITY);
let mut max = glam::Vec3::splat(f32::NEG_INFINITY);
for v in &self.solid {
let p = glam::Vec3::from(v.pos);
min = min.min(p);
max = max.max(p);
}
for v in &self.translucent {
let p = glam::Vec3::from(v.pos);
min = min.min(p);
max = max.max(p);
}
let pad = glam::Vec3::splat(Self::BOUNDS_PAD);
Some([min - pad, max + pad])
}
}
pub const ALL_VISIBLE: u64 = (1 << 36) - 1;
/// VisGraph.resolve: flood fill non-opaque cells and connect the faces each region touches.
#[cfg(test)]
fn visibility(pad: &Padded, info: &[Info]) -> u64 {
visibility_reusing(pad, info, &mut Vec::new())
}
fn visibility_reusing(pad: &Padded, info: &[Info], stack: &mut Vec<usize>) -> u64 {
// The all-visible shortcut can leave a partially filled stack behind.
stack.clear();
// Opaque cells are already visited: resolve each state's opacity once,
// then reuse this map for the flood fill and the uniform-section shortcuts.
let mut seen = [false; 4096];
@@ -201,7 +289,6 @@ fn visibility(pad: &Padded, info: &[Info]) -> u64 {
return 0;
}
let mut result = 0u64;
let mut stack = Vec::new();
for start in 0..4096usize {
if seen[start] {
continue;
@@ -458,7 +545,12 @@ impl MeshTables {
});
let mut face = [0u8; 6];
let mut cover = [0f32; 6];
for d in Direction::ALL {
// A server's visibility knows no state (`FaceVisibility::empty`):
// it meshes no fluid, so these stay unset.
for d in Direction::ALL
.into_iter()
.filter(|_| !visibility.is_empty())
{
let (face_id, shape) = visibility
.occlusion_face(BlockStateId(id as u32), d)
.ok_or("missing occlusion face")?;
@@ -631,21 +723,30 @@ fn pi(x: i32, y: i32, z: i32) -> usize {
((y + B) as usize * P + (z + B) as usize) * P + (x + B) as usize
}
/// Blocks and light of one section plus a `B`-block border.
#[derive(Default)]
struct Padded {
blocks: Vec<u32>,
light: Vec<u8>,
}
impl Padded {
#[cfg(test)]
fn new(region: &[[Option<&Chunk>; 3]; 3], sy: i32) -> Self {
let mut blocks = vec![0u32; P * P * P];
let mut light = vec![0u8; P * P * P];
let base_y = MIN_Y + sy * 16;
let mut pad = Self::default();
pad.fill(region, sy, &mut Box::new([0; 4096]));
pad
}
fn fill(&mut self, region: &[[Option<&Chunk>; 3]; 3], sy: i32, own: &mut [u32; 4096]) {
self.blocks.resize(P * P * P, 0);
self.blocks.fill(0);
self.light.resize(P * P * P, 0);
let (blocks, light) = (&mut self.blocks, &mut self.light);
let base_y = region[1][1].map_or(MIN_Y, Chunk::min_y) + sy * 16;
// The section itself is unpacked in one pass; the border cells of the
// neighbouring sections are read one by one.
let mut own = Box::new([0u32; 4096]);
own.fill(0);
let center = region[1][1].map(|c| &c.sections[sy as usize]);
if let Some(section) = center {
section.unpack(&mut own);
section.unpack(own);
}
for y in -B..16 + B {
let wy = base_y + y;
@@ -662,15 +763,33 @@ impl Padded {
c.get(x & 15, wy, z & 15)
};
light[i] = c.light(x & 15, wy, z & 15);
} else if let Some(c) = region[1][1] {
// A neighbour not loaded continues this chunk's edge
// column. As lit air, the outermost chunks' water drew
// a wall toward it (fluids show both sides), a light
// sheet in the water where far terrain carries on.
// Vanilla draws no chunk without its neighbours.
let (ex, ez) = (x.clamp(0, 15), z.clamp(0, 15));
blocks[i] = c.get(ex, wy, ez);
light[i] = c.light(ex, wy, ez);
} else {
light[i] = 0xF0;
}
}
}
}
Self { blocks, light }
}
}
/// Per-worker temporary arrays. They never hold resource-pack references or
/// geometry still owned by the renderer, and total about 88 KiB per worker.
#[derive(Default)]
pub(crate) struct MeshScratch {
padded: Padded,
own: Option<Box<[u32; 4096]>>,
visibility: Vec<usize>,
}
#[cfg(test)]
mod reuse_tests;
/// Set on a terrain vertex's sprite layer when its texture alpha must be ignored.
pub const OPAQUE_LAYER: u16 = 1 << 15;
/// The bits of `TerrainVertex::color`'s alpha byte that hold how brightly its face shines
@@ -693,14 +812,25 @@ pub fn hdr_tier(name: &str, level: u8) -> u8 {
match name {
_ if level == 0 => 0,
"glowstone" | "jack_o_lantern" | "campfire" | "soul_campfire" | "soul_lantern"
| "soul_torch" | "soul_wall_torch" | "soul_fire" => BRIGHT,
| "soul_torch" | "soul_wall_torch" | "soul_fire" | "beacon" | "end_rod" | "furnace"
| "redstone_lamp" => BRIGHT,
"torch" | "wall_torch" | "lantern" | "lava" | "crying_obsidian" | "fire"
| "sea_lantern" => INCANDESCENT,
"redstone_torch" | "redstone_wall_torch" | "magma_block" => LOW,
// Glowing at every charge, not only the brighter ones.
"respawn_anchor" => INCANDESCENT,
"redstone_torch"
| "redstone_wall_torch"
| "magma_block"
| "ochre_froglight"
| "verdant_froglight"
| "pearlescent_froglight"
| "shroomlight" => LOW,
// Its flame is a particle.
_ if name.ends_with("candle") || name.ends_with("candle_cake") => LOW,
// One tier at every oxidation stage, waxed or not.
_ if name.ends_with("copper_bulb") => LOW,
// A faint glow in the dark, as in SDR.
"glow_lichen" => 0,
"glow_lichen" | "sea_pickle" | "brown_mushroom" | "dragon_egg" | "end_portal_frame" => 0,
_ if level >= 10 => INCANDESCENT,
_ => LOW,
}
@@ -840,15 +970,31 @@ pub struct Mesher<'a> {
impl Mesher<'_> {
/// `region[dz][dx]` are the chunks around the meshed column (centre 1,1).
pub fn mesh(&mut self, region: &[[Option<&Chunk>; 3]; 3], sy: i32) -> SectionMesh {
let mut out = SectionMesh::default();
self.mesh_reusing(
region,
sy,
SectionMesh::default(),
&mut MeshScratch::default(),
)
}
pub(crate) fn mesh_reusing(
&mut self,
region: &[[Option<&Chunk>; 3]; 3],
sy: i32,
mut out: SectionMesh,
scratch: &mut MeshScratch,
) -> SectionMesh {
out.clear();
let center = region[1][1].expect("centre chunk");
let section = &center.sections[sy as usize];
if section.is_uniform(0) {
return out;
}
let pad = Padded::new(region, sy);
out.visibility = visibility(&pad, &self.tables.info);
let base_y = MIN_Y + sy * 16;
let own = scratch.own.get_or_insert_with(|| Box::new([0; 4096]));
scratch.padded.fill(region, sy, own);
let pad = &scratch.padded;
out.visibility = visibility_reusing(pad, &self.tables.info, &mut scratch.visibility);
let base_y = center.min_y() + sy * 16;
let mut tints = Tints::new(
region,
self.biomes,
@@ -882,7 +1028,7 @@ impl Mesher<'_> {
if state.fluid.kind() != FluidKind::Empty {
let water = tints.get(Resolver::Water, x, y, z);
let nether = self.nether_shading;
fluid::tesselate(self.tables, nether, &pad, water, &mut out, [x, y, z]);
fluid::tesselate(self.tables, nether, pad, water, &mut out, [x, y, z]);
}
if !state.model {
continue;
@@ -932,7 +1078,7 @@ impl Mesher<'_> {
continue;
}
self.face(
&pad,
pad,
&mut tints,
&mut out,
[x, y, z],
@@ -0,0 +1,208 @@
use super::*;
#[test]
fn padded_reuse_resets_missing_neighbors_and_matches_chunk_reads_across_layouts() {
let mut pad = Padded::default();
let mut own = Box::new([u32::MAX; 4096]);
for min_y in [MIN_Y, -256, 0, 256] {
let layout = crate::chunk::ColumnLayout::new(min_y, 32).unwrap();
let base_y = min_y + 16;
let mut center = Chunk::with_layout(0, 0, layout);
center.set(0, base_y, 0, 7);
center.set(15, base_y + 15, 15, 9);
let mut neighbor = Chunk::with_layout(1, 0, layout);
neighbor.set(0, base_y + 2, 3, 11);
let mut region = [[None; 3]; 3];
region[1][1] = Some(&center);
region[1][2] = Some(&neighbor);
pad.fill(&region, 1, &mut own);
let buffers = (pad.blocks.as_ptr(), pad.light.as_ptr());
for current in [region, [[None; 3]; 3], region] {
pad.fill(&current, 1, &mut own);
for y in -B..16 + B {
for z in -B..16 + B {
for x in -B..16 + B {
let chunk = current[((z + 16) >> 4) as usize][((x + 16) >> 4) as usize];
// A missing neighbour continues the centre's edge column.
let (block, light) = match (chunk, current[1][1]) {
(Some(c), _) => (
c.get(x & 15, base_y + y, z & 15),
c.light(x & 15, base_y + y, z & 15),
),
(None, Some(c)) => {
let (ex, ez) = (x.clamp(0, 15), z.clamp(0, 15));
(c.get(ex, base_y + y, ez), c.light(ex, base_y + y, ez))
}
(None, None) => (0, 0xF0),
};
assert_eq!(pad.blocks[pi(x, y, z)], block);
assert_eq!(pad.light[pi(x, y, z)], light);
}
}
}
assert_eq!(buffers, (pad.blocks.as_ptr(), pad.light.as_ptr()));
}
}
}
/// Run explicitly with prepared assets; never silently pass without exercising geometry.
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn reused_mesh_matches_fresh_geometry_across_sections_and_resource_reload() {
let root = std::env::var_os("HORIZON_MESH_TEST_RESOURCES")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client/26.3")
});
let assets = Assets::load(&root).unwrap();
let facts = BlockFacts::load(&root, assets.registry).unwrap();
let biomes = Biomes::load(&root).unwrap();
let state = |name| assets.registry.default_state(name).unwrap().0;
let names = [
"minecraft:stone",
"minecraft:glass",
"minecraft:grass_block",
"minecraft:oak_leaves",
"minecraft:water",
"minecraft:chest",
];
let mut dense = Chunk::empty(0, 0);
for z in 0..16 {
for x in 0..16 {
dense.set(
x,
MIN_Y + 4,
z,
state(names[((x + z) as usize) % names.len()]),
);
}
}
let mut sparse = Chunk::empty(-3, 5);
sparse.set(0, MIN_Y + 15, 0, state("minecraft:short_grass"));
sparse.set(15, MIN_Y + 1, 15, state("minecraft:lava"));
let empty = Chunk::empty(0, 0);
let mut neighbor = Chunk::empty(1, 0);
neighbor.set(0, MIN_Y + 4, 0, state("minecraft:stone"));
let mut custom = Chunk::with_layout(2, -4, crate::chunk::ColumnLayout::new(-256, 64).unwrap());
custom.set(1, -252, 2, state("minecraft:chest"));
let mut scratch = MeshScratch::default();
let mut recycled = SectionMesh::default();
// The same scratch and output buffers survive a resource reload. They must
// contain no references into the previous RenderAssets or MeshTables.
for reload in 0..2 {
let render = assets.reload(&root).unwrap();
let visibility = crate::models::load_visibility(&root, assets.registry).unwrap();
let tables = MeshTables::new(&assets, &render, &facts, visibility.clone()).unwrap();
let mut mesher = Mesher {
nether_shading: false,
assets: &assets,
render: &render,
tables: &tables,
biomes: &biomes,
visibility,
zoom: BiomeZoom::new(42),
blend_radius: 2,
smooth_lighting: reload == 0,
cutout_leaves: reload == 0,
};
for (case, (center, east)) in [
(&empty, None),
(&dense, Some(&neighbor)),
(&sparse, None),
(&empty, None),
(&dense, None),
(&custom, None),
]
.into_iter()
.enumerate()
{
let mut region = [[None; 3]; 3];
region[1][1] = Some(center);
region[1][2] = east;
let mut fresh = mesher.mesh(&region, 0);
recycled = mesher.mesh_reusing(&region, 0, recycled, &mut scratch);
if reload == 0 && case == 0 {
assert!(scratch.own.is_none());
assert_eq!(scratch.padded.blocks.capacity(), 0);
assert_eq!(scratch.padded.light.capacity(), 0);
assert_eq!(scratch.visibility.capacity(), 0);
}
fresh.sort_solid();
recycled.sort_solid();
assert_eq!(
bytemuck::cast_slice::<TerrainVertex, u8>(&fresh.solid),
bytemuck::cast_slice::<TerrainVertex, u8>(&recycled.solid)
);
assert_eq!(
bytemuck::cast_slice::<TerrainVertex, u8>(&fresh.translucent),
bytemuck::cast_slice::<TerrainVertex, u8>(&recycled.translucent)
);
assert_eq!(fresh.block_entities, recycled.block_entities);
assert_eq!(fresh.visibility, recycled.visibility);
assert_eq!(fresh.sides, recycled.sides);
assert_eq!(fresh.bounds, recycled.bounds);
if std::ptr::eq(center, &dense) {
assert!(!fresh.solid.is_empty());
assert!(!fresh.translucent.is_empty());
assert!(!fresh.block_entities.is_empty());
}
if std::ptr::eq(center, &custom) {
assert_eq!(
fresh.block_entities,
[([33, -252, -62], state("minecraft:chest"))]
);
}
}
}
}
/// An isolated scratch-stage comparison, not a whole-mesher or FPS benchmark.
/// Output each sample so the caller can retain the observations and spread.
#[test]
#[ignore = "opt-in scratch microbenchmark; run with --nocapture --test-threads=1"]
fn benchmark_padded_reuse() {
const ITERATIONS: usize = 4096;
let mut chunk = Chunk::empty(0, 0);
for y in 0..16 {
for z in 0..16 {
for x in 0..16 {
chunk.set(x, MIN_Y + y, z, 1 + ((x + y + z) % 7) as u32);
}
}
}
let mut region = [[None; 3]; 3];
region[1][1] = Some(&chunk);
let mut pad = Padded::new(&region, 0);
let mut own = Box::new([0; 4096]);
let pointers = (pad.blocks.as_ptr(), pad.light.as_ptr(), own.as_ptr());
let bytes = pad.blocks.capacity() * 4 + pad.light.capacity() + 4096 * 4;
// Warm both code paths and the process allocator before recording samples.
for _ in 0..1024 {
std::hint::black_box(Padded::new(std::hint::black_box(&region), 0));
pad.fill(std::hint::black_box(&region), 0, &mut own);
std::hint::black_box(&pad);
}
for sample in 0..9 {
// Alternate execution order to expose, rather than hide, order effects.
for reused in [sample % 2 == 0, sample % 2 != 0] {
let start = std::time::Instant::now();
for _ in 0..ITERATIONS {
if reused {
pad.fill(std::hint::black_box(&region), 0, &mut own);
std::hint::black_box(&pad);
} else {
std::hint::black_box(Padded::new(std::hint::black_box(&region), 0));
}
}
println!(
"{{\"sample\":{sample},\"reused\":{reused},\"iterations\":{ITERATIONS},\"elapsed_ms\":{:.6},\"scratch_bytes\":{bytes}}}",
start.elapsed().as_secs_f64() * 1000.0
);
assert_eq!(
pointers,
(pad.blocks.as_ptr(), pad.light.as_ptr(), own.as_ptr())
);
}
}
}
@@ -187,6 +187,21 @@ fn visibility_matches_reference() {
}
}
#[test]
fn visibility_scratch_reuse_keeps_shortcuts_and_subsequent_fills_exact() {
let info = [info(false), info(true), info(false), info(true)];
let mut stack = vec![usize::MAX];
for _ in 0..3 {
for (name, pad) in cases() {
assert_eq!(
visibility_reusing(&pad, &info, &mut stack),
reference(&pad, &info),
"{name}"
);
}
}
}
#[test]
fn visibility_preserves_every_face_pair() {
let info = [info(false), info(true), info(false), info(true)];
+8 -7
View File
@@ -8,7 +8,7 @@
//! changed.
use crate::{
blocks::{BlockFacts, Fluid, MAP_COLORS},
chunk::{Chunk, MIN_Y, SECTIONS},
chunk::Chunk,
fast_hash::FxHashMap,
world::World,
};
@@ -113,7 +113,8 @@ fn scan_column(
return (color | WALL, head as i16, 0);
}
}
while y > MIN_Y {
let bottom = chunk.min_y();
while y > bottom {
let (mut color, flags) = palette.get(chunk.get(x, y, z));
if color == 0 {
y -= 1;
@@ -123,7 +124,7 @@ fn scan_column(
let fluid = flags & FLUID;
if fluid != 0 {
let mut below = y - 1;
while below > MIN_Y && palette.get(chunk.get(x, below, z)).1 & FLUID != 0 {
while below > bottom && palette.get(chunk.get(x, below, z)).1 & FLUID != 0 {
depth = depth.saturating_add(1);
below -= 1;
}
@@ -134,11 +135,11 @@ fn scan_column(
}
return (color, y as i16, depth);
}
(0, MIN_Y as i16, 0)
(0, bottom as i16, 0)
}
/// The highest block of a chunk worth scanning from.
fn top_of(chunk: &Chunk, air: u32, layer: Layer) -> i32 {
let surface = (0..SECTIONS)
let surface = (0..chunk.sections.len())
.rev()
.find(|&s| {
chunk
@@ -146,7 +147,7 @@ fn top_of(chunk: &Chunk, air: u32, layer: Layer) -> i32 {
.get(s)
.is_some_and(|sec| !sec.is_uniform(air))
})
.map_or(MIN_Y, |s| MIN_Y + s as i32 * 16 + 15);
.map_or(chunk.min_y(), |s| chunk.min_y() + s as i32 * 16 + 15);
match layer {
Layer::Surface => surface,
Layer::Below(head) => surface.min(head),
@@ -156,7 +157,7 @@ fn scan_tile(chunk: &Chunk, palette: &Palette, air: u32, layer: Layer, revision:
let top = top_of(chunk, air, layer);
let mut tile = Tile {
color: [0; 256],
height: [MIN_Y as i16; 256],
height: [chunk.min_y() as i16; 256],
depth: [0; 256],
revision,
layer,
@@ -12,6 +12,11 @@ use std::{
pub const FILE: &str = "horizon-mods.txt";
/// The folder holding `.hmod` files, beside `options.txt`.
pub const DIR: &str = "horizon-mods";
/// Where the launcher installs native mods (`<id>.hnmod`), beside [`DIR`]. They run only in a
/// game the launcher built with them.
pub const NATIVE_DIR: &str = "horizon-native-mods";
/// Why a native mod installed in [`NATIVE_DIR`] doesn't run in this game.
pub const NOT_BUILT: &str = "Installed, not built into this game: build the game with it in the launcher (Settings > Native mods), then play the instance that build is in.";
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ModList {
@@ -45,6 +45,7 @@ pub struct ModelInstance {
pub layer: u32,
pub shade: f32,
}
#[derive(Default)]
pub struct PreparedModels {
pub catalog: StateModels,
pub placements: Vec<(BlockPos, BlockStateId)>,
@@ -42,6 +42,9 @@ pub struct BakedModel {
pub cube: Option<[super::cube::CubeStyle; 6]>,
}
type VariantKey = (String, [u8; 3], bool);
/// Empty by default: no state has a model (a dedicated server, which draws
/// nothing).
#[derive(Default)]
pub struct StateModels {
/// Each definition's choices, a variant standing as its index in `baked`.
choices: Vec<CompiledChoices<u32>>,
Loaded 100 of 892 files, more files were not shown because too many files have changed in this diff. Show more