Compare commits

..
Author SHA1 Message Date
levzzz cef8fa7e6d Merge pull request #3240 from some-du6e/fix/spear-jab-animation
Spears: the jab animates in first and third person
2026-10-29 19:17:10 +05:00
levzzz de8ffbb294 Merge pull request #3288 from hectoreccles/feat/beacon-beam-lod
Keep beacon beams visible outside the render distance
2026-10-29 19:07:51 +05:00
Ludovic Marie c177fef871 Merge pull request #2986 from kacpherek/fix/entity-rendering-parity
🤖 Agent: PR triage (Ludovic's AI agent, Claude): read in full, building on rdbt's approving review. Enderman's carried block is its baked model (seed 42, not centred) as CarriedBlockLayer, FoxHeldItemLayer draws the mouth item in the ground context (sleeping/baby offsets), IronGolemFlowerLayer the poppy while getOfferFlowerTick() > 0, Skeleton.DATA_STRAY_CONVERSION_ID (16) is sent and read so freezing skeletons shake, and the shadow radii match GiantMobRenderer (0.5 x 6), ElderGuardian 1.2, HappyGhast 2.0, Armadillo 0.4, Shulker 0. verify green; CI parity on this head.
2026-10-29 15:04:46 +01:00
Ludovic Marie 45ecf7bef0 Merge pull request #3718 from horizon-rust/feat/launcher-java-mod-toggle
🤖 Agent: launcher matrix 40883773220 green on all platforms at this head; queued.
2026-10-29 14:57:38 +01:00
Ludovic Marie 181040539d Merge pull request #3713 from horizon-rust/feat/launcher-post-exit
🤖 Agent: launcher matrix 40884459459 green on all platforms at this head; queued.
2026-10-29 14:57:33 +01:00
levzzz 57d4801bff Merge pull request #3583 from d3grando/fix-3430
Mobs: undead with NoAI burn in daylight
2026-10-29 18:44:13 +05:00
Ludovic Marie 0050a65600 Merge remote-tracking branch 'origin/main' into feat/launcher-post-exit
# Conflicts:
#	launcher/src/cli.rs
#	launcher/src/main.rs
2026-10-29 14:42:02 +01:00
Ludovic MarieandClaude Opus 5.5 0914c90455 Launcher: the post-exit test's Windows command keeps its last word
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 14:38:52 +01:00
Ludovic MarieandClaude Opus 5.5 2dd4903b5e Launcher: turn single mods on and off in Java instances
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 14:37:50 +01:00
levzzz a3b4d135aa Merge pull request #2706 from RoyalPixelsDE/perf/surface-skip-same-writes
Worldgen: the surface step skips writes that change nothing
2026-10-29 18:35:56 +05:00
levzzz 8474ac55c9 Merge pull request #3423 from d3grando/fix-3362
GUI: screens keep vanilla's int layout at any window size
2026-10-29 18:33:46 +05:00
levzzz bcb3d50624 Merge pull request #3349 from degepie/fix/world-list-hardcore
World list: show Hardcore Mode in red, and adventure and spectator by name (#3298)
2026-10-29 18:33:40 +05:00
Ludovic Marie 9c62d4f850 Merge pull request #3688 from horizon-rust/feat/launcher-java-instance-settings
🤖 Agent: launcher matrix 40876178731 green on all platforms at this head; queued.
2026-10-29 14:33:04 +01:00
Ludovic Marie ed45e27d15 Merge pull request #3697 from horizon-rust/fix/parity-test-macos-tmp
🤖 Agent: test-only fix (macOS temp symlink); verify and launcher green, 40 tests OK on macOS; queued.
2026-10-29 14:27:30 +01:00
Ludovic MarieandClaude Opus 5.5 edd340a621 Launcher: a command after the game exits, and the instance's variables
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 14:22:49 +01:00
levzzz 9c15e18c6f Merge pull request #2951 from kacpherek/fix/list-damage-datapack-commands
Fix /list, the missing /damage types and /datapack create
2026-10-29 18:14:54 +05:00
levzzz e5338ff202 Merge pull request #2713 from iLostName/reset-hud-between-servers
remote: titles, tab list, scoreboard and effects don't carry over to the next server
2026-10-29 18:14:33 +05:00
Ludovic Marie b13b4f2c7f Merge pull request #3679 from horizon-rust/feat/launcher-import-apps
🤖 Agent: launcher matrix 40874504029 green on all platforms at this head; queued.
2026-10-29 14:14:00 +01:00
levzzz 2730949b41 Merge pull request #3339 from josegil1909/feat/launcher-i18n-poc
Launcher: Spanish language support
2026-10-29 18:05:50 +05:00
levzzz 73cbd863af Merge pull request #2411 from kacpherek/fix/death-advancement-messages-to-guests
Send the host's death and advancement messages to guests and follow team visibility
2026-10-29 18:05:31 +05:00
Ludovic MarieandClaude Opus 5.5 9c67dd3b76 tests: resolve the temp root in the parity dependency-path test
On macOS the temp folder is under /var, a symlink to /private/var. dependencies()
resolves each path before comparing it with ROOT, so an unresolved temp ROOT
dropped every file and the test failed on macOS (Linux CI didn't see it).
The real ROOT is already resolved.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 14:03:24 +01:00
levzzz 702f7e2b93 Merge pull request #3359 from hamza-cskn/fix/invisible-armor-stands
Invisible armor stands: draw their equipment, and send their names to clients
2026-10-29 18:01:20 +05:00
levzzzandClaude Opus 5.5 765cdb6bb0 Merge main into fix-3362: keep both new multiplayer tests
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 18:00:27 +05:00
levzzz 4103382062 Merge pull request #2995 from ASOwnerYT/fix/2869-horse-max-health
Horses, donkeys, mules and llamas sync their rolled max health
2026-10-29 17:56:44 +05:00
levzzz 7f9cc4084d Merge pull request #2345 from kacpherek/fix/burn-in-daylight-list
Follow the burn in daylight tag for mobs that burn in the sun
2026-10-29 17:56:40 +05:00
levzzz 89f949155c Merge pull request #3589 from d3grando/fix-3427
Commands: coordinate suggestions skip entities and offer the hit point
2026-10-29 17:55:49 +05:00
levzzz 129ad4fdf8 Merge pull request #3573 from Vadyanik/fix/xkb-caps-swapescape
Follow the XKB keymap for Escape and Caps Lock
2026-10-29 17:55:43 +05:00
levzzz ea3c3532b6 Merge pull request #3570 from d3grando/fix-3426
Creative tooltips list the tabs that hold the item, as vanilla does
2026-10-29 17:55:36 +05:00
levzzz 46a5e18630 Merge pull request #3569 from d3grando/fix-3431
Villager gossip: hits and murders count against the player who did them
2026-10-29 17:55:29 +05:00
levzzz 703ba156da Merge pull request #3565 from d3grando/fix-2862
Copper golem eyes glow on the body model, not the empty eyes layer
2026-10-29 17:55:22 +05:00
levzzz dd40744480 Merge pull request #3564 from d3grando/fix-3158
Key binds: a moved key no longer keeps its default action
2026-10-29 17:55:16 +05:00
levzzz cca5298864 Merge pull request #3560 from d3grando/fix-3429
Count a zombie villager's cure down in its tick, with or without AI
2026-10-29 17:55:09 +05:00
levzzz 44c88b6c7b Merge pull request #3559 from d3grando/fix-3209
Attack indicator shows at a spear's extra range
2026-10-29 17:55:02 +05:00
levzzz 6a9ff4a38f Merge pull request #3558 from d3grando/fix-3136
Draw the arms under a map held in first person
2026-10-29 17:54:56 +05:00
levzzz 4c33a0fdfe Merge pull request #3557 from d3grando/fix-3289
Invisible mobs cast no shadow
2026-10-29 17:54:49 +05:00
levzzz 2e01c336a3 Merge pull request #3554 from d3grando/fix-3293
Draw glowing sign text in front of its outline
2026-10-29 17:54:42 +05:00
levzzz d26740a103 Merge pull request #3553 from d3grando/fix-3381
Projectiles hit a seated enderman
2026-10-29 17:54:36 +05:00
levzzzandClaude Opus 5.5 fdcefc0d90 /list uuids in single player shows the player's saved profile id, as the other commands do, instead of the offline id of the name.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 17:54:35 +05:00
levzzz 1c87226ac9 Merge pull request #3511 from Domanffe/fix/windows-overlay-hardlink-render
Windows: link the overlay's mirrored files instead of copying them
2026-10-29 17:54:28 +05:00
levzzz 9c86860e40 Merge pull request #3498 from JustKarkat/fix/mob-drop-scatter
Mob-origin item drops get the ItemEntity constructor's random motion
2026-10-29 17:54:19 +05:00
levzzz ca3d2d32af Merge pull request #3467 from AHHhHHHHH-H/pr/far-sampling-threads
Far terrain: sample on up to 6 threads when the machine has 12 or more, behind the game
2026-10-29 17:54:10 +05:00
levzzz 1b0923d8cf Merge pull request #3464 from d3grando/native-settings-refresh-on-open
Native mod settings: ask for the labels again when the page opens
2026-10-29 17:54:04 +05:00
levzzz 68f50bbd41 Merge pull request #3463 from d3grando/native-hooks-f3-corners
native_corner_shown: F3 takes the top right and its charts' corners
2026-10-29 17:53:55 +05:00
levzzz 47bce76078 Merge pull request #3456 from d3grando/fix-3312
Tooltips: potion effect and armour trim lines in the selected language
2026-10-29 17:53:48 +05:00
levzzz 125e540126 Merge pull request #3449 from d3grando/fix-3302
Keep the player model's legs still while flying straight up
2026-10-29 17:53:41 +05:00
levzzz 1578a97c79 Merge pull request #3444 from d3grando/fix-3408
Fog option off clears the Nether's fog too
2026-10-29 17:53:34 +05:00
levzzz dbd5fb7f62 Merge pull request #3438 from Joansitoh/fix/windows-overlay-junctions
Windows: link untouched directories into the pack overlay with junctions
2026-10-29 17:53:27 +05:00
levzzz 2a8dc29c69 Merge pull request #3437 from d3grando/fix-resize-absurd-size
Window: a size above the GPU's largest texture no longer crashes
2026-10-29 17:53:20 +05:00
levzzz 81a0e7e9a5 Merge pull request #3428 from d3grando/win-parity-sighup
parity: catch SIGHUP only where it exists, so parity.py starts on Windows
2026-10-29 17:53:13 +05:00
levzzz ce980a90de Merge pull request #3416 from d3grando/fix-3084-cyrillic-bold
Name tags: draw text as Font.prepareText does (bold, tall glyphs)
2026-10-29 17:53:07 +05:00
levzzz dd32d3dc38 Merge pull request #3399 from d3grando/fix-mod-screen-list-clip
Mod screens: a list's rows are cut at its frame
2026-10-29 17:53:00 +05:00
levzzz 29f427dabe Merge pull request #3363 from ThatCakeIsAPie/fix/launcher-windows-unused-import
launcher: drop an unused import in the Windows make_link
2026-10-29 17:52:54 +05:00
levzzz b156515edd Merge pull request #3330 from josegil1909/fix/launcher-clipboard-linux
Launcher: sync clipboard on Linux Wayland and X11
2026-10-29 17:52:42 +05:00
levzzz 0fdf1aeee1 Merge pull request #3328 from echo-ilovech3ss/fix/creative-piston-head-drop
Piston head: destroy fitting base without drops in creative mode
2026-10-29 17:52:35 +05:00
levzzz ee2fb39605 Merge pull request #3241 from SolomonNumb1/fix/modifier-tooltip
tooltip fix
2026-10-29 17:52:27 +05:00
levzzz 559e36ee04 Merge pull request #3193 from SlaVKsVolks/contribute/horizon-windows-parity-20261028
Parity: select affected harnesses from Windows dependency records
2026-10-29 17:52:20 +05:00
levzzz 7037510e65 Merge pull request #3167 from sleepe229/fix/3143-sunset-sky-cost
Build the sunset fan once a frame instead of in every sky pixel
2026-10-29 17:52:13 +05:00
levzzz 417a1cebc1 Merge pull request #3161 from jaszkus/fix/block-outline-open-screens
Keep the block outline while a screen is open
2026-10-29 17:52:06 +05:00
levzzz 9edc7eb80a Merge pull request #3130 from d3grando/mod-sprites-nine-slice
Mod API: a sprite shape draws as vanilla's blitSprite, nine-sliced by its gui.scaling
2026-10-29 17:52:00 +05:00
Ludovic MarieandClaude Opus 5.5 5667e9ebd1 Merge main into the branch
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 13:51:56 +01:00
levzzz d9628314ab Merge pull request #3113 from kacpherek/fix/ender-eye-debug-hitbox
Show a thrown eye of ender's hitbox with F3+B
2026-10-29 17:51:53 +05:00
levzzz 4984a1329a Merge pull request #3103 from Domanffe/fix/creative-hotbar-save-message
Creative hotbar: say 'Saved' only when it was saved
2026-10-29 17:51:47 +05:00
levzzz d22b63b9ca Merge pull request #3101 from Domanffe/fix/scoreboard-write-failure
Scoreboard: report a failed write instead of dropping it
2026-10-29 17:51:41 +05:00
levzzz dc5e8c45f7 Merge pull request #3099 from Domanffe/fix/launcher-servers-atomic-list
Launcher: write the server list beside it and rename
2026-10-29 17:51:34 +05:00
levzzz 2782a9057a Merge pull request #3076 from Domanffe/fix/port-publish-posix-path
Port publish: a body names the diff store with Posix separators
2026-10-29 17:51:27 +05:00
levzzz 66620f22a3 Merge pull request #2996 from feeldev12/feat/book-click-events
fix(client): book text runs and copies its click commands and shows its hover text
2026-10-29 17:51:20 +05:00
levzzz 18070bb4ce Merge pull request #2814 from sirrayi/fix/snbt-depth-and-words
SNBT: bound nesting depth, keep nan/inf words as strings
2026-10-29 17:51:14 +05:00
levzzz b575402076 Merge pull request #2753 from iLostName/world-access-failure-toast
title: a world open in another instance shows the access failure toast instead of crashing
2026-10-29 17:51:07 +05:00
levzzz 5b3c3684b1 Merge pull request #2721 from senjinthedragon/hdr-f3
HDR: show the output on the F3 screen and in the log
2026-10-29 17:51:00 +05:00
levzzz 29b4b72dd5 Merge pull request #2712 from iLostName/server-list-player-tooltip
server list: the player count's tooltip lists the status sample
2026-10-29 17:50:52 +05:00
levzzz 9bc8447871 Merge pull request #2711 from iLostName/options-slider-save-on-release
options: sliders apply while dragged and save on release
2026-10-29 17:50:46 +05:00
levzzzandClaude Opus 5.5 39592697c7 The launcher's Spanish covers the strings added on main: native mods, moving an instance to the Trash, and the build's new time estimate.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 17:50:21 +05:00
Ludovic MarieandClaude Opus 5.5 d785041833 Launcher: Java instances get a window size and join a server on launch
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 13:46:35 +01:00
Ludovic Marie 56c1100644 Merge pull request #3678 from horizon-rust/feat/launcher-java-mods-page
🤖 Agent: Mods page for a selected Java instance (its version and loader; no Horizon port check). Horizon instances unchanged. Verify green; matrix 40873026396 green on Windows, macOS and Linux.
2026-10-29 13:41:31 +01:00
Ludovic Marie a881d284a7 Merge pull request #3433 from xKumorio/fix-kelp-sheet-mips
🤖 Agent: verified by pr-triage-3. Now only the kelp mipmap fix in gfx/animate.rs (the save.rs part is on main via #3088, port_publish.py is in #3587, as the author offered). union/pt3-2 285ecb6e8 (main 2b294bbe3 + #2417 #2951 #2981 #3000 #3417 #3433 #2544 #2586): fmt, clippy -D warnings (horizon-client, -client-render, -client-core, -world, -items, all targets), their tests pass.
2026-10-29 13:41:27 +01:00
levzzz 08c0fc1286 Merge remote-tracking branch 'hr/main' into pr-2951 2026-10-29 17:40:55 +05:00
levzzz e81e23e64e Merge remote-tracking branch 'hr/main' into pr-3339 2026-10-29 17:40:54 +05:00
Ludovic Marie 5005a7ad9f Merge remote-tracking branch 'origin/main' into feat/launcher-import-apps
# Conflicts:
#	launcher/src/cli.rs
#	launcher/src/other_launchers.rs
2026-10-29 13:34:39 +01:00
Ludovic Marie 87a6a7a161 Merge pull request #3634 from horizon-rust/fix/launcher-java-loader-follow-ups
🤖 Agent: Java instances use their loader for mods; copy-from keeps a Fabric or Quilt loader. Verify green; matrix 40858947625 green on Windows, macOS and Linux.
2026-10-29 13:28:14 +01:00
Ludovic Marie 5ceba01475 Merge pull request #3630 from horizon-rust/fix/launcher-new-account-join
🤖 Agent: new accounts' token from launcher/login, falling back to login_with_xbox; the owner gate is unchanged. martipops confirmed it with a new account on an online-mode server. Verify green; matrix 40858081820 green on Windows, macOS and Linux.
2026-10-29 13:28:09 +01:00
da9d0dcbe1 The dragon follows the server that flies it (#2765)
* The dragon follows the server that flies it

The client draws the dragon from its own state (game/dragon.rs), but a
server's dragon only sent its health, its phase and its damage: its
moves never reached that state, so the dragon was drawn where it had
spawned while the server's dragon flew around the island, and the local
flight machine flew it away from the server's moves as well.

The server's move packets now move the dragon the client draws, the
way every other entity of a server's follows them, and a server's
dragon is no longer flown by the client's own phase machine: the
server flies it, the client flaps its wings, keeps its flight history
and places its parts.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>

* Keep the steering comment

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>

* Ease the server's dragon through the lerp tick_remote_dragon reads

The frozen dragon came from move_entity: the dragon also has a copy in the
mob list, so its moves took the mob path and returned before r.lerps, and
tick_remote_dragon never had a target. The moves now skip that copy and
reach r.lerps, so tick_remote_dragon eases the drawn dragon over the three
steps of LivingEntity's interpolation, as the code it already had meant to.

This replaces the snap to each move and the remote flag in tick_dragon:
a server's client never runs tick_dragon (tick_end is the local level's),
and the snap left the dragon still between the server's updates.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Code <[email protected]>
Co-authored-by: minimax-m3.1-flash (Claude Code) <[email protected]>
Co-authored-by: Ludovic Marie <[email protected]>
2026-10-29 17:26:37 +05:00
Ludovic MarieandClaude Opus 5.5 dc66e37847 Launcher: copy CurseForge app and Modrinth App instances into Java instances
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 13:25:16 +01:00
Ludovic MarieandClaude Opus 5.5 e2a9909521 Launcher: the Mods page searches and installs for a selected Java instance
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 13:24:18 +01:00
Ludovic Marie 19faf89699 Merge pull request #2720 from ASOwnerYT/claude/2697-horse-make-mad-4431aa
🤖 Agent: proven on the integration branch union/pt1-wave1 (22 PRs on main 13d7d9995): tools/pr_ready.py all green (clippy -D warnings, 1686 tests, the 140 affected vanilla harnesses pass), lan_e2e on both single-player paths (22 checks, the only failure, boats, fails on main too), agent_control_e2e consume and pause-save. Thank you!
2026-10-29 13:08:15 +01:00
Ludovic Marie f230017ec1 Merge pull request #2839 from iLostName/sounds-index-without-stat
🤖 Agent: PR triage (Ludovic's AI agent, Claude): main merged in (ff). sounds.json files are listed once per namespace instead of a lookup per sound, same resolution; its earlier parity failure was the remote_fireball wind-charge load flake, and every other selected harness passed. union/pt3-1 edfb0e31d (main 2b294bbe3 + 11 PRs): clippy -D warnings clean, tests 1342/0. CI parity on this head.
2026-10-29 13:08:11 +01:00
Ludovic Marie ba86a4d76d Merge pull request #3653 from horizon-rust/ci/world-spawn-vanilla-reuse
🤖 Agent: ci-queue-1: world_spawn run by hand on 3 seeds (3/3 match with the cache empty, then reused: 12 s -> 4.7 s a seed; PARITY_JAVA_CACHE=0 reruns vanilla); new unit tests; pr_ready --quick green (411 Python tests).
2026-10-29 13:02:18 +01:00
Ludovic Marie a734609ab4 Merge pull request #2807 from levzzz5154/fix/shield-blocking-pose
🤖 Agent: gate cg29: the parity failure at e9c6751a1 was command_exec's vanilla oracle (it threw 'entities around the player did not load' on playhorizon), not this PR; the other 47 selected harnesses passed in that run, and command_exec passes on main + this head on the Mac mini. Head unchanged since cg27's proof.
2026-10-29 13:02:13 +01:00
Ludovic Marie eab3195848 Merge pull request #2799 from iLostName/join-without-rescanning-registries
🤖 Agent: gate cg29: the parity failure at cf0b111bd was remote_fireball's wind charge (3 of 6 samples), a load flake of that harness seen on unrelated PRs today; this PR only touches chat.rs. union/cg29-1 (main b19b4e92d + #3104 #2799 #2819 #2812): fmt, clippy -p horizon-client/-client-render/-client-core --all-targets -D warnings, tests 1334/0, fast build; lan_e2e team-push pvp frames minecarts boats deflect cushions commands tab on the server thread and --in-frame all pass. CI parity rerun on this head.
2026-10-29 13:02:09 +01:00
Ludovic Marie 0d2f7b86d5 Merge pull request #2812 from socksprox/fix/text-display-depth
🤖 Agent: proven by contrib-gate-28 (requeued after a cancelled parity run): gate fix for a trailing § in a part plus a test, main merged after B9; fmt, clippy -D warnings, tests pass; text_display_render 600/600 cases match vanilla; CI parity for the rest.
2026-10-29 13:02:04 +01:00
Ludovic Marie e79992e80b Merge pull request #3448 from d3grando/fix-3307
🤖 Agent: proven on the integration branch union/pt1-wave1 (22 PRs on main 13d7d9995): tools/pr_ready.py all green (clippy -D warnings, 1686 tests, the 140 affected vanilla harnesses pass), lan_e2e on both single-player paths (22 checks, the only failure, boats, fails on main too), agent_control_e2e consume and pause-save. Thank you!
2026-10-29 12:59:23 +01:00
Ludovic Marie 248aa8b302 Merge pull request #3415 from d3grando/fix-3245
🤖 Agent: proven on the integration branch union/pt1-wave1 (22 PRs on main 13d7d9995): tools/pr_ready.py all green (clippy -D warnings, 1686 tests, the 140 affected vanilla harnesses pass), lan_e2e on both single-player paths (22 checks, the only failure, boats, fails on main too), agent_control_e2e consume and pause-save. Thank you!
2026-10-29 12:59:19 +01:00
Ludovic Marie cf586e4159 Merge pull request #3442 from d3grando/fix-copper-chest-double
🤖 Agent: proven on the integration branch union/pt1-wave1 (22 PRs on main 13d7d9995): tools/pr_ready.py all green (clippy -D warnings, 1686 tests, the 140 affected vanilla harnesses pass), lan_e2e on both single-player paths (22 checks, the only failure, boats, fails on main too), agent_control_e2e consume and pause-save. Thank you!
2026-10-29 12:59:14 +01:00
Ludovic Marie 7443236850 Merge pull request #3445 from d3grando/fix-3414
🤖 Agent: proven on the integration branch union/pt1-wave1 (22 PRs on main 13d7d9995): tools/pr_ready.py all green (clippy -D warnings, 1686 tests, the 140 affected vanilla harnesses pass), lan_e2e on both single-player paths (22 checks, the only failure, boats, fails on main too), agent_control_e2e consume and pause-save. Thank you!
2026-10-29 12:59:09 +01:00
Ludovic Marie 5e391eb673 Merge pull request #3500 from spidercraft/fix/firework-gaps
🤖 Agent: proven on the integration branch union/pt1-wave1 (22 PRs on main 13d7d9995): tools/pr_ready.py all green (clippy -D warnings, 1686 tests, the 140 affected vanilla harnesses pass), lan_e2e on both single-player paths (22 checks, the only failure, boats, fails on main too), agent_control_e2e consume and pause-save. Thank you!
2026-10-29 12:54:07 +01:00
Ludovic Marie c10553eebf Merge pull request #3439 from spidercraft/taa
🤖 Agent: proven on the integration branch union/pt1-wave1 (22 PRs on main 13d7d9995): tools/pr_ready.py all green (clippy -D warnings, 1686 tests, the 140 affected vanilla harnesses pass), lan_e2e on both single-player paths (22 checks, the only failure, boats, fails on main too), agent_control_e2e consume and pause-save. Thank you!
2026-10-29 12:54:03 +01:00
Ludovic Marie 215d8e779a Merge pull request #3411 from spidercraft/cape-rendering
🤖 Agent: proven on the integration branch union/pt1-wave1 (22 PRs on main 13d7d9995): tools/pr_ready.py all green (clippy -D warnings, 1686 tests, the 140 affected vanilla harnesses pass), lan_e2e on both single-player paths (22 checks, the only failure, boats, fails on main too), agent_control_e2e consume and pause-save. Thank you!
2026-10-29 12:53:59 +01:00
Ludovic Marie 58a4fd8263 Merge pull request #3571 from d3grando/fix-3501
🤖 Agent: proven on the integration branch union/pt1-wave1 (22 PRs on main 13d7d9995): tools/pr_ready.py all green (clippy -D warnings, 1686 tests, the 140 affected vanilla harnesses pass), lan_e2e on both single-player paths (22 checks, the only failure, boats, fails on main too), agent_control_e2e consume and pause-save. Thank you!
2026-10-29 12:53:55 +01:00
Ludovic MarieandClaude Opus 5.5 3c63c5913e run-parity: a push cancels the old head's run, other labels cancel nothing; cancel-on-close takes every head (#3651)
- run-parity.yml also starts on synchronize, only to cancel the queued or running run of the
  head that was replaced (its job runs on the run-parity label alone, as before).
- Labels other than run-parity get a concurrency group of their own: in the shared one, adding
  needs-proof cancelled a parity run queued 9 s before (run 40766180150).
- cancel-on-close cancels the closed pull request's runs on every head (its branch in its own
  repository), not only the last: #3104's run on an older head stayed queued after its merge.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-29 12:52:10 +01:00
Ludovic Marie 7f870d7447 Merge pull request #3563 from d3grando/fix-3322
🤖 Agent: proven on the integration branch union/pt1-wave1 (22 PRs on main 13d7d9995): tools/pr_ready.py all green (clippy -D warnings, 1686 tests, the 140 affected vanilla harnesses pass), lan_e2e on both single-player paths (22 checks, the only failure, boats, fails on main too), agent_control_e2e consume and pause-save. Thank you!
2026-10-29 12:51:30 +01:00
Ludovic Marie f49659c78b Merge pull request #3586 from d3grando/fix-3246
🤖 Agent: proven on the integration branch union/pt1-wave1 (22 PRs on main 13d7d9995): tools/pr_ready.py all green (clippy -D warnings, 1686 tests, the 140 affected vanilla harnesses pass), lan_e2e on both single-player paths (22 checks, the only failure, boats, fails on main too), agent_control_e2e consume and pause-save. Thank you!
2026-10-29 12:51:26 +01:00
Ludovic Marie 62ff5241a4 Merge pull request #3566 from d3grando/fix-3034
🤖 Agent: proven on the integration branch union/pt1-wave1 (22 PRs on main 13d7d9995): tools/pr_ready.py all green (clippy -D warnings, 1686 tests, the 140 affected vanilla harnesses pass), lan_e2e on both single-player paths (22 checks, the only failure, boats, fails on main too), agent_control_e2e consume and pause-save. Thank you!
2026-10-29 12:51:22 +01:00
Ludovic Marie 4d460c7374 Merge pull request #3577 from d3grando/fix-3505
🤖 Agent: proven on the integration branch union/pt1-wave1 (22 PRs on main 13d7d9995): tools/pr_ready.py all green (clippy -D warnings, 1686 tests, the 140 affected vanilla harnesses pass), lan_e2e on both single-player paths (22 checks, the only failure, boats, fails on main too), agent_control_e2e consume and pause-save. Thank you!
2026-10-29 12:51:19 +01:00
Ludovic Marie d99f7d6d7b Merge pull request #3579 from d3grando/fix-3147
🤖 Agent: proven on the integration branch union/pt1-wave1 (22 PRs on main 13d7d9995): tools/pr_ready.py all green (clippy -D warnings, 1686 tests, the 140 affected vanilla harnesses pass), lan_e2e on both single-player paths (22 checks, the only failure, boats, fails on main too), agent_control_e2e consume and pause-save. Thank you!
2026-10-29 12:51:15 +01:00
Ludovic Marie 49c6c47f98 Merge pull request #3562 from d3grando/fix-3474
🤖 Agent: proven on the integration branch union/pt1-wave1 (22 PRs on main 13d7d9995): tools/pr_ready.py all green (clippy -D warnings, 1686 tests, the 140 affected vanilla harnesses pass), lan_e2e on both single-player paths (22 checks, the only failure, boats, fails on main too), agent_control_e2e consume and pause-save. Thank you!
2026-10-29 12:48:31 +01:00
Ludovic Marie b029efa3d3 Merge pull request #3454 from d3grando/fix-3308
🤖 Agent: proven on the integration branch union/pt1-wave1 (22 PRs on main 13d7d9995): tools/pr_ready.py all green (clippy -D warnings, 1686 tests, the 140 affected vanilla harnesses pass), lan_e2e on both single-player paths (22 checks, the only failure, boats, fails on main too), agent_control_e2e consume and pause-save. One small follow-up for later, not blocking: Player.updatePlayerPose keeps the desired pose for a passenger even where it doesn't fit (isPassenger), which update_player_pose doesn't check yet. Thank you!
2026-10-29 12:48:27 +01:00
Ludovic Marie 37b4ee3abd Merge pull request #3585 from d3grando/fix-3280
🤖 Agent: proven on the integration branch union/pt1-wave1 (22 PRs on main 13d7d9995): tools/pr_ready.py all green (clippy -D warnings, 1686 tests, the 140 affected vanilla harnesses pass), lan_e2e on both single-player paths (22 checks, the only failure, boats, fails on main too), agent_control_e2e consume and pause-save. Thank you!
2026-10-29 12:48:22 +01:00
Ludovic Marie b07f2775a2 Merge pull request #3555 from d3grando/fix-3424
🤖 Agent: proven on the integration branch union/pt1-wave1 (22 PRs on main 13d7d9995): tools/pr_ready.py all green (clippy -D warnings, 1686 tests, the 140 affected vanilla harnesses pass), lan_e2e on both single-player paths (22 checks, the only failure, boats, fails on main too), agent_control_e2e consume and pause-save. Thank you!
2026-10-29 12:48:17 +01:00
Ludovic Marie e831cb0333 Merge pull request #3504 from ASOwnerYT/fix/3425-potion-duration-padding
🤖 Agent: read in full (pr-triage-1), checked against vanilla 26.3 StringUtil.formatTickDuration (Mth.floor(ticks / tickrate), %02d on every field, hh:mm:ss from an hour up): the tooltip and the inventory's effect panel now share that one port. Client text only.
2026-10-29 12:48:13 +01:00
Ludovic Marie 6a5cd3cc28 Merge pull request #2875 from elythepreciouscat/fix/linux-opengl
🤖 Agent: verified by Ludovic's AI agent (Claude): main merged (README conflict resolved), clippy and tests pass, verify green. Queued for the merger.
2026-10-29 12:44:44 +01:00
JustKarkatandJustKarkat's AI agent (ZCode) 16090d3d4a Drop motion from the ItemEntity's own random, not the level's
Review fix on #3498: the 5-arg ItemEntity(level, x, y, z, stack)
constructor draws its push from this.random, a fresh source — not
this.level().random. Death loot, gift loot and MobEvent::DropItem now
use dispenser::new_item_motion() (already the item's own random), and
the DropGift arm on main had the same level-random draw and joins them.
The level random stays untouched for whatever reads it next (orb merge
ids, block ticks).

Co-Authored-By: JustKarkat's AI agent (ZCode)
2026-10-29 06:41:30 -05:00
d3grando 31cfdb4b95 Merge remote-tracking branch 'origin/main' into fix-3408
# Conflicts:
#	crates/horizon-client/src/game/render_tick.rs
2026-10-29 18:34:07 +07:00
Ludovic MarieandClaude Opus 5.5 3b5643628a world_spawn: reuse a seed's vanilla spawn under parity.py's oracle reuse
Vanilla's server is ~11 s of each seed's ~13 s, 60 seeds a run, and world_spawn
was 25 % of the PR parity runners' harness time on the Mac mini (37 % on
playhorizon): parity_shim.py never caches it, since a server's console is a pipe.
Its spawn depends only on the pinned jar, the JDK, the command line and the
seed's server.properties, which make the key. --fresh, --all and parity.py's
fresh retry of a failure create every vanilla world as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 12:27:23 +01:00
Ludovic Marie 4a9501d2d9 Merge pull request #2383 from kacpherek/fix/fog-lava-and-night-vision
🤖 Agent: PR triage (Ludovic's AI agent, Claude): main merged in (ff). LavaFogEnvironment.setupFog: spectator -8..renderDistance*0.5, Fire Resistance 0..5, else 0.25..1, sky/cloud end = environmental end; FogRenderer.computeFogColor's brightenFactor = nightVisionScale (endsWithin(200) flicker) unless Darkness. Proven in cg19 union R1; union/pt3-1 edfb0e31d (main 2b294bbe3 + 11 PRs): clippy -D warnings clean, tests 1342/0. CI parity on this head.
2026-10-29 12:22:41 +01:00
Ludovic Marie 8a290d810a Merge pull request #2533 from kacpherek/fix/server-mob-animation-sync-2
🤖 Agent: PR triage (Ludovic's AI agent, Claude): main merged in (ff, no conflict). The PR's diff is the one proven in cg19's union A2 at e5ca012ed (server-thread clients animate ravager, hoglin, iron golem, creaking, vex, allay…; entity event and data ids checked against 26.3 then). union/pt3-1 edfb0e31d (main 2b294bbe3 + 11 PRs): clippy -D warnings clean, tests 1342/0. CI parity on this head.
2026-10-29 12:07:58 +01:00
Ludovic Marie 43e7700791 Merge pull request #3447 from d3grando/fix-3396
🤖 Agent: read in full (pr-triage-1). Item names use Util.makeDescriptionId's key in the item's own namespace (item.<ns>.<path with / as .>, then block.), so mod items translate; vanilla items unchanged. Approved by rdbtCVS.
2026-10-29 12:00:14 +01:00
Ludovic Marie 4baa571566 Merge pull request #2819 from dreadpiratejoelius/fix/remote-player-teams
🤖 Agent: gate cg29: parity failure at 5f80ddffc was the remote_fireball wind-charge load flake. Gate fix 48ab8b093: team-push puts both players back (it left the guest's next spot inside a hill, pvp failed after it on the server thread). Team by profile name matches Player.getScoreboardName / createEntityFromPacket. union/cg29-1 (main b19b4e92d + #3104 #2799 #2819 #2812): fmt, clippy -p horizon-client/-client-render/-client-core --all-targets -D warnings, tests 1334/0, fast build; lan_e2e team-push pvp frames minecarts boats deflect cushions commands tab on the server thread and --in-frame all pass (team-push -> pvp -> tab both paths with the fix). CI parity on this head.
2026-10-29 11:43:56 +01:00
Ludovic MarieandClaude Opus 5.5 a7e279cfed Launcher: Java instances' mods follow their loader, and copies keep theirs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 11:36:30 +01:00
Ludovic Marie 7c3364d30d Merge pull request #3627 from horizon-rust/ci/nightly-own-runner
🤖 Agent: the nightly's untrusted macOS jobs move to ludovics-mac-mini-nightly (registered, online, same trust as horizon-game, never the release runner). test_nightly updated; verify green.
2026-10-29 11:33:22 +01:00
Ludovic MarieandClaude Opus 5.5 2fe5fa0939 Launcher: new Microsoft accounts get a token that joins online-mode servers
The Minecraft token now comes from launcher/login, with login_with_xbox as the fallback.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 11:30:01 +01:00
Ludovic Marie 64990cd321 Merge pull request #3619 from horizon-rust/feat/launcher-fabric-quilt
🤖 Agent: Fabric and Quilt for Java instances: profiles from their meta services, libraries sha1-checked, paths through mojang::relative; inheritsFrom loop guard with its test. Verify green; matrix 40855348257 green on Windows, macOS and Linux.
2026-10-29 11:27:53 +01:00
Ludovic Marie 78b778463f Merge pull request #3295 from RVZO6/fix/menu-separator
🤖 Agent: PR triage (Ludovic's AI agent, Claude): main merged in (ff). header_separator/footer_separator are 32x2 textures: tiling them as 32x32 squares sampled 2/32 of the height. tiled_sized tiles them at [32, 2] (both rows, last column clipped), with a unit test; ui::tiled keeps its square form. union/pt3-1 edfb0e31d (main 2b294bbe3 + 11 PRs): clippy -D warnings clean, tests 1342/0. CI parity on this head.
2026-10-29 11:27:49 +01:00
Ludovic Marie 76864807ba Merge pull request #3111 from senjinthedragon/hdr-gallery
🤖 Agent: read in full (pr-triage-1). A dev-only Python tool (tools/hdr_gallery.py) that writes a datapack of every light-giving block into a world folder for tuning HDR; no network, no subprocess, reads only the prepared facts files, writes only under the given world's datapacks/. Nothing players run changes (skip-release).
2026-10-29 11:27:45 +01:00
Ludovic MarieandClaude Opus 5.5 1f3cc4b80e Nightly: build on the Mac mini's own nightly runner, not behind PR parity
The untrusted (nightly) build ran its macOS jobs on the horizon-game runner,
which also takes every pull request's parity job. On 2026-10-29 the nightly's
macOS build sat queued from 08:17Z behind 100 run-parity runs (oldest
00:24Z), so no nightly shipped for hours.

It now runs on ludovics-mac-mini-nightly (labels self-hosted, macOS,
horizon-nightly): the same machine and trust as the horizon-game runner,
still never the release runner. The runner is registered and online.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 11:22:05 +01:00
Ludovic Marie 1816cf0b52 Merge pull request #3612 from horizon-rust/feat/launcher-import-launchers
🤖 Agent: imports from Prism/MultiMC/official launcher, read-only on their folders (copy into a new Java instance), formats from public docs. Verify green; matrix 40853481205 green on Windows, macOS and Linux.
2026-10-29 11:15:16 +01:00
Ludovic Marie 258c5946b5 Merge pull request #3603 from horizon-rust/feat/launcher-java-modrinth
🤖 Agent: Modrinth for Java instances, filtered by version and loader; downloads go through download::save (safe names, checksums). Verify green; matrix 40853843158 green on Windows, macOS and Linux.
2026-10-29 11:15:11 +01:00
Ludovic Marie 452224a28a Merge pull request #3617 from horizon-rust/docs/native-mods-on-after-install
🤖 Agent: docs only (native-mods.md); pr_ready --quick green (408 Python tests); skip-release.
2026-10-29 11:09:54 +01:00
Ludovic Marie f8988d5a73 Merge pull request #3000 from hectoreccles/lod-small-end-islands
🤖 Agent: requeued by pr-triage-3 after a main merge (was dc3fc6961, cg27-verified, rdbt approved; stopped on a conflict with #2894). Both LOD tests kept. union/pt3-2 285ecb6e8 (main 2b294bbe3 + #2417 #2951 #2981 #3000 #3417 #3433 #2544 #2586): fmt, clippy -D warnings (horizon-client, -client-render, -client-core, -world, -items, all targets), their tests pass.
2026-10-29 11:05:54 +01:00
Ludovic MarieandClaude Opus 5.5 184e1ee3d6 Launcher: refuse a version whose inheritsFrom loops
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 11:05:00 +01:00
Ludovic MarieandClaude Opus 5.5 c5702d6c29 Launcher: Fabric and Quilt for Java instances
A Java instance can play with a mod loader: `instance create
--minecraft 1.21.4 --loader fabric|quilt [--loader-version V]`, or
`instance loader <instance> fabric|quilt|none [--version V]`, with
`--list` for the loader's versions (the newest stable one by default;
Quilt's list comes unordered, so versions are sorted).

Play installs the loader's profile once from its metadata service into
versions/, beside the game's, with its libraries in libraries/, each
checked against its sha1 (the profile's, else the one its Maven
repository publishes), then starts the profile merged into the game.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 11:04:59 +01:00
Ludovic MarieandClaude Opus 5.5 e6412d6e5c Launcher: the CLI test expects mods search to take --instance
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:57:49 +01:00
Ludovic MarieandClaude Opus 5.5 90b583896a Docs: native mods turn on when a player installs one; Linux builds them
Since #3596 installing a native mod turns native mods on after a notice;
the intro and the Trust section still said they were off by default. And
a full game build on Linux is proven (an x86_64 server with no Rust of its
own, the launcher's toolchain, Zoom's proof passing on the built game), so
it leaves "Left to do" for the measurements.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:55:27 +01:00
Ludovic MarieandClaude Opus 5.5 7db6a085bf Launcher: copy Prism, MultiMC and official launcher instances into Java instances
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:55:17 +01:00
Ludovic Marie 0b74b68aac Merge pull request #3598 from horizon-rust/feat/launcher-java-play
🤖 Agent: Play for Java instances. Diff reviewed: the owner gate holds (a session only for an account that owns the game; downloads via download_version_for_owner); #3451's guards kept. Verify green; build matrix 40851406886 green on Windows, macOS and Linux.
2026-10-29 10:51:41 +01:00
Ludovic Marie 1db57affe6 Merge pull request #3600 from horizon-rust/feat/launcher-java-inherits
🤖 Agent: inheritsFrom merge (child libraries first with parent duplicates dropped, arguments appended, parent jar). Diff reviewed; verify green; build matrix 40849739616 green on Windows, macOS and Linux.
2026-10-29 10:47:10 +01:00
Ludovic MarieandClaude Opus 5.5 5c18ae844b Drop a stray doc line after MAX_LEVEL
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:45:13 +01:00
Ludovic Marie 584a59ad8c Merge remote-tracking branch 'origin/main' into upd/3467 2026-10-29 10:45:08 +01:00
Ludovic Marie e1844d064e Merge pull request #2971 from kacpherek/fix/skin-online-sound-options
🤖 Agent: requeued by contrib-gate-28 after merging main (B9 render crate split; stopped on a conflict or a known flake). fmt, clippy -D warnings and tests pass on the new head; proven earlier by cg26/cg27.
2026-10-29 10:41:47 +01:00
Ludovic MarieandClaude Opus 5.5 1231679c4d Launcher: Modrinth mods and resource packs for Java instances
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:40:02 +01:00
Ludovic Marie 9e9b9862ee Merge remote-tracking branch 'origin/main' into feat/launcher-java-play
# Conflicts:
#	launcher/lang/de.json
#	launcher/lang/fr.json
#	launcher/lang/ru.json
2026-10-29 10:39:44 +01:00
Ludovic Marie 7c31259376 Books: suggest_command does nothing on a book, as vanilla's insertText there (every click event is matched) 2026-10-29 10:39:00 +01:00
Ludovic Marie 40c1ab898f Merge remote-tracking branch 'origin/main' into upd/2996
# Conflicts:
#	crates/horizon-client/src/game/books.rs
2026-10-29 10:38:25 +01:00
Ludovic Marie 13d7d9995d Merge pull request #3596 from horizon-rust/fix/launcher-native-mods-auto-on
🤖 Agent: Ludovic's decision 10-29 (native mods on when one is installed, notice once). Diff reviewed; verify green; build matrix 40848959744 green on Windows, macOS and Linux.
2026-10-29 10:35:00 +01:00
Ludovic Marie 945a98f8bc Merge remote-tracking branch 'origin/main' into upd/2875
# Conflicts:
#	README.md
2026-10-29 10:34:30 +01:00
Ludovic MarieandClaude Opus 5.5 7fa0fe50f7 Format modifier lines with format_translation
TranslatableContents.decomposeTemplate is already ported as
text::format_translation (%s, %n$s, %%), so the modifier line uses it instead
of a second, partial parser.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:34:19 +01:00
Ludovic Marie 611d1ef51e Merge remote-tracking branch 'origin/main' into upd/3103 2026-10-29 10:32:04 +01:00
Ludovic MarieandClaude Opus 5.5 49866a4482 Leave tools/port_publish.py to #3587
The same Windows path fix is in #3587 (with the UTF-8 body file), so this
branch keeps only the kelp mipmap change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:32:04 +01:00
Ludovic Marie 6a06853032 Merge remote-tracking branch 'origin/main' into upd/3076 2026-10-29 10:32:02 +01:00
Ludovic MarieandClaude Opus 5.5 25f4545038 Merge main into this branch
lod.rs: main's chorus plant test (#2894) and this branch's small island test are
both kept; docs/graphics.md names chorus plants and small islands.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:29:46 +01:00
Ludovic MarieandClaude Opus 5.5 54e56ca215 Merge main into this branch
save.rs: main already reads session.lock after the lock is dropped (#3088), so
main's version of that test is kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:29:20 +01:00
Ludovic Marie 92c462a9b1 Merge pull request #2845 from levzzz5154/fix/structure-solid-render
🤖 Agent: verified by contrib-gate-27. Read in full; matches StructurePiece's isSolidRender checks and the reorient order (N, E, S, W). union/cg27-1 (main + this wave): fmt, clippy -D warnings (horizon-client, horizon-world, horizon-client-core), their tests and the fast build pass; lan_e2e damage, minecarts, projectiles, sculk_container, shield, push and hazards pass on the server thread and in-frame. Harness parity runs in CI on this exact head (the Mac mini's load is too high for local parity).
2026-10-29 10:27:57 +01:00
Ludovic MarieandClaude Opus 5.5 cdefed7a19 Merge main into this branch
#damages_helmet holds falling_anvil, falling_block and falling_stalactite (26.3's
tag): main added the stalactite, this branch the anvil; both kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:27:54 +01:00
Ludovic Marie b8f48f3ad0 Merge remote-tracking branch 'origin/main' into upd/2814 2026-10-29 10:27:22 +01:00
Ludovic MarieandClaude Opus 5.5 abca441ad4 Launcher: Java versions that inherit from another (loader profiles)
A version file with `inheritsFrom` (what Fabric, Quilt and other mod
loaders install) is merged into the version it names, as the official
launcher reads it: its fields over the parent's, its libraries first (a
parent's library it names again is left out), its arguments after the
parent's, and the parent's client jar (`jar`). On a version from before
1.13, a profile's own argument lists join the old line instead of
replacing it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:25:28 +01:00
Ludovic Marie 99b52c2617 Merge remote-tracking branch 'origin/main' into feat/launcher-java-play
# Conflicts:
#	launcher/lang/fr.json
#	launcher/lang/ru.json
#	launcher/src/main.rs
2026-10-29 10:23:05 +01:00
Ludovic Marie 77bfff8eb5 Merge pull request #3595 from horizon-rust/docs/roadmap-triage-10-29
🤖 Agent: docs only (roadmap.toml + generated ROADMAP.md/roadmap.json + README). roadmap.py build --check and check --offline pass; verify green; parity correctly skipped.
2026-10-29 10:22:55 +01:00
Ludovic Marie 8ea9a2c849 Merge remote-tracking branch 'origin/main' into fix/launcher-native-mods-auto-on
# Conflicts:
#	launcher/lang/fr.json
#	launcher/lang/ru.json
2026-10-29 10:20:52 +01:00
Ludovic MarieandClaude Opus 5.5 cc15e8ebb3 Rename the hitbox view's eye_positions to eye_motions
Main's c5fe5d4bc added EndState::eye_positions (id and position, for
status); the hitbox view needs the previous and current positions.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:19:58 +01:00
Ludovic MarieandClaude Opus 5.5 db8fa8377d Merge main into this branch
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:18:46 +01:00
Ludovic MarieandClaude Opus 5.5 dbeb13ff6b Merge main into this branch
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:18:44 +01:00
Ludovic MarieandClaude Opus 5.5 39d23ac256 Merge main into this branch
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:18:43 +01:00
Ludovic MarieandClaude Opus 5.5 d900488d12 Merge main into this branch after the render crate split (#3186)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:18:43 +01:00
Ludovic MarieandClaude Opus 5.5 e7827deabb Merge main into this branch
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:18:41 +01:00
Ludovic MarieandClaude Opus 5.5 d2d5dd00ad Merge main into this branch
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:18:38 +01:00
Ludovic MarieandClaude Opus 5.5 916150f02c Merge main into this branch
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:18:37 +01:00
Ludovic Marie 5302f6a22a Creative piston head test: check the base drops nothing (main's shape update now removes the head, which drops its empty loot) 2026-10-29 10:18:33 +01:00
Ludovic MarieandClaude Opus 5.5 aa7a149858 Merge main into this branch
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:18:31 +01:00
Ludovic MarieandClaude Opus 5.5 504aa1dca6 Merge main into this branch
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:18:30 +01:00
Ludovic MarieandClaude Opus 5.5 3497737ecc Merge main into this branch after the render crate split (#3186)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:18:05 +01:00
Ludovic Marie 9db690b1e0 Merge remote-tracking branch 'origin/main' into upd/3328 2026-10-29 10:15:48 +01:00
Ludovic MarieandClaude Opus 5.5 70a6aa45aa Merge main into this branch after the render crate split (#3186)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:15:19 +01:00
Ludovic MarieandClaude Opus 5.5 8c0b674fe8 Merge main into this branch (German launcher strings, old-version pruning)
Keeps Spanish beside German, adds the six strings main added since, and drops
the nightly strings main removed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:14:54 +01:00
Ludovic Marie 2b294bbe3a Merge pull request #3506 from horizon-rust/fix/launcher-delete-instance
🤖 Agent: launcher-java-1 verified this head: PR checks green (verify, launcher), launcher build matrix green on Windows, macOS and Linux (dry run https://github.com/horizon-rust/horizon/actions/runs/40817273793), pr_ready --quick green with the new test deleted_instances_go_to_the_trash_and_are_forgotten; docs/launcher-commands.md regenerated.
2026-10-29 10:13:16 +01:00
Ludovic MarieandClaude Opus 5.5 f220157a0f Launcher: installing a native mod turns native mods on
Ludovic's decision (2026-10-29). Native mods were on only once the
player found Settings > Native mods (experimental). Now installing a
native mod turns them on, so Settings offers its Build at once; players
who never install one keep them off.

The first native mod's Install shows a notice once, before anything is
installed: native mods compile into a game of their own, the first
build downloads Rust and can take 20 to 30 minutes, later builds are
faster (Install and turn on native mods · Cancel). `mods install`
turns them on the same way and prints the notice. The build itself
stays behind its trust warning, now saying 20 to 30 minutes too, and
turning a mod off in the game's Options > Mods is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:12:09 +01:00
jaszkusandjaszkus's AI agent ddd1dbdf91 Keep the block outline while a screen is open
GameRenderer.shouldRenderBlockOutline() checks the hidden HUD (F1) and the game mode, not the open screen, and Minecraft.tick() keeps the block raycast current while chat is up. Horizon also required Screen::Playing, so the outline vanished as soon as chat opened.

Co-Authored-By: jaszkus's AI agent <[email protected]>
2026-10-29 10:10:54 +01:00
Ludovic MarieandClaude Opus 5.5 3f28d6dbd1 Roadmap: triage the suggestion queue, native mods and Java instances; README refresh
Roadmap (from the 59 suggestions in the queue and what merged since):
- Microsoft sign-in shipped in [email protected].
- New: Minecraft Java instances in the launcher, native mods built by the
  launcher, the port ledger, a new launcher design, hosting panel setups
  (Pterodactyl, AMP), joining older-version servers through a native
  protocol adapter, Windows ARM64, splitscreen (#2054).
- Mods items reworded for native hook points instead of the WebAssembly
  API; scripts for mods moved to Later; asked-for client mods added.

README: more than 300 harnesses, the Nightly channel, native mods, and
the port ledger as a second, method-by-method measure.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:10:47 +01:00
Ludovic MarieandClaude Opus 5.5 5bc76cbba9 Launcher clipboard: one loop over the desktop's copy tools
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:10:03 +01:00
Ludovic Marie c3dceac2aa Merge remote-tracking branch 'origin/main' into upd/3101
# Conflicts:
#	crates/horizon-client/src/game/scoreboard.rs
2026-10-29 10:09:08 +01:00
Ludovic Marie d203ca04be Merge pull request #2894 from hectoreccles/lod-chorus-vegetation
🤖 Agent: requeued by contrib-gate-28 after merging main (B9 render crate split; stopped on a conflict or a known flake). fmt, clippy -D warnings and tests pass on the new head; proven earlier by cg26/cg27.
2026-10-29 09:54:49 +01:00
d3grandoandClaude Opus 5.5 4bd3210f78 Commands: coordinate suggestions skip entities and offer the hit point
ClientSuggestionProvider.getRelevantCoordinates and getAbsoluteCoordinates
read Minecraft.hitResult only when it is a block hit. Horizon took the
block behind a mob under the crosshair, and Vec3/Vec2 arguments (summon,
tp, worldborder center...) only ever offered ~. Now an entity before the
block leaves the ~ defaults, and Vec3/Vec2 offer the exact hit point with
two decimals (prettyPrint: "%.2f", Locale.ROOT).

Fixes #3427

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 15:52:30 +07:00
vadyanikandClaude Sonnet 5.5 6255c02096 Follow the XKB keymap for Escape and Caps Lock
Key events carry the scancode's US-layout code, which ignores options like
caps:swapescape. Take Escape and Caps Lock from the resolved keysym instead.

Fixes #3133

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-29 10:42:32 +02:00
d3grandoandClaude Opus 5.5 ced6323bb2 Single player: save when the game pauses
IntegratedServer.tickServer saves everything on the tick the game
becomes paused and logs "Saving and pausing game...". Horizon's server
thread only stopped ticking, so a paused world was saved no sooner than
the next autosave or quit. The server thread now saves (as the autosave
does) once each time the game pauses.

agent_control_e2e gets pause-save: opening the pause menu writes
level.dat.

Fixes #3280

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 15:36:00 +07:00
d3grandoandClaude Opus 5.5 698e7fd662 Input: release held keys when leaving a server
Keys held when the player left a server stayed in Game::keys: once the
title or disconnect screen is open their releases go to that screen, so
W and Space held through a LAN host closing the world walked and jumped
on in the next world. Vanilla's Gui.setScreen calls KeyMapping.releaseAll
whenever a screen opens; leave_server now releases the held keys and the
attack and use buttons before it shows the title or disconnect screen.

lan_e2e gets keys_released: a guest holding W and Space leaves and
rejoins, and must stand still with no keys held.

Fixes #3246

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 15:35:58 +07:00
d3grandoandClaude Opus 5.5 cf0b5a8e63 Mobs: undead with NoAI burn in daylight
NoAI only skips Mob.serverAiStep; Mob.aiStep still calls burnUndead, so
a NoAI zombie at noon catches fire like any other. Horizon skipped the
whole of ai_step for NoAI mobs, and the daylight burn with it. The burn
is now its own burn_undead, run with or without AI.

The mob_ai harness gets zombie_noai_day_burn (a NoAI zombie in the sun).

Fixes #3430

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 15:35:53 +07:00
d3grandoandClaude Opus 5.5 7808916266 Ominous bottles list their Bad Omen level and duration in the tooltip
OminousBottleAmplifier.addToTooltip lists MobEffectInstance(BAD_OMEN,
120000, amplifier) as PotionContents.addPotionTooltip does: blue (NEUTRAL),
"Bad Omen IV (1:40:00)". Nothing drew it, and the amplifier never reached
the client either: the server's network_stack left component 65 out and the
protocol read it as raw bytes. It now goes over the wire as a VarInt
(OminousBottleAmplifier.STREAM_CODEC) and item_sync decodes it.

Fixes #3501

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 15:32:08 +07:00
d3grandoandClaude Opus 5.5 53beacfd13 Let the off hand go when a bucket, bottle or boat has no target
On a server client (single player included) remote_use_item predicted
SUCCESS for every bucket, glass bottle and boat, which ended
Minecraft.startUseItem before the off hand's turn: a Riptide trident
there never charged. BucketItem, BottleItem and BoatItem.use PASS or
FAIL with nothing to fill, empty or place, and startUseItem stops only
on SUCCESS. The client now checks the target first (a waterlogged block
counts as a water source, as SimpleWaterloggedBlock is a BucketPickup);
place_boat is split into boat_to_place (no side effects) so both share
BoatItem's checks. A powder snow bucket (SolidBucketItem, only useOn)
always passes its use.

Fixes #3147

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 15:31:50 +07:00
d3grandoandClaude Opus 5.5 6a59002283 Creative tooltips list the tabs that hold the item, as vanilla does
CreativeModeInventoryScreen.getTooltipFromContainerItem keeps the plain
tooltip only for a category tab's own grid slots. Everywhere else in the
creative screen (the search tab, the hotbar row under a category tab,
the inventory tab) it puts, after the name, the display name of every
tab whose CreativeModeTab.contains(stack) holds, in blue, and on the
search tab's grid during a `#` search the stack's visible tags
(updateVisibleTags), in dark purple. Horizon drew the plain tooltip
everywhere.

Creative::tabs_containing matches a tab's stacks by item and components;
the search tab's own stacks that no tab shows (enchanted books below
their top level) belong to the tab that shows their item.
Creative::search_tag_lines follows updateVisibleTags' matching, and
draw_stack_tooltip_with inserts the lines from line 1.

Fixes #3426

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 15:27:35 +07:00
d3grandoandClaude Opus 5.5 ab9ceb6a6d Wither: stay still while charging, and spare itself from its spawn blast
While a wither charges (Invul > 0), WitherBoss.customServerAiStep returns
before setAlternativeTarget(0, ...), so aiStep has no target to fly to.
Horizon stored the target's position first, so the charging wither flew
toward whatever it targeted. The position is now taken after the charge.

The blast that ends the charge is level.explode(this, ...): the wither is
the source and ServerExplosion's getEntities(source, box) leaves it out.
The wither now names itself as the explosion source (as a creeper does),
and the blast's mob loop skips a source named that way, so it is neither
hurt nor pushed. A source only guessed from the mobs near the centre (a
sulfur cube, already off the list) may be a bystander and is still hurt.

The command_exec corpus gets a "wither spawn" group: a wither summoned
with Invul:20 next to a pig, its position mid-charge and its health after
the blast.

Fixes #3322

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 15:25:46 +07:00
d3grandoandClaude Opus 5.5 46d466f7c3 An eye of ender stays in use while the button is held, slowing the player
EnderEyeItem.use calls startUsingItem before it looks for a stronghold
(unless an End portal frame is aimed at), and getUseDuration is 0, so the
use never completes: the player is slowed until the button lets go, with
or without a stronghold, and a held button throws one eye, not one every
four ticks. The server and in-frame use now start it, and a server's
client starts it too and keeps it like a bow's until released.

Fixes #3505

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 15:24:52 +07:00
d3grandoandClaude Opus 5.5 113cafd2d1 Copper golem eyes glow on the body model, not the empty eyes layer
CopperGolemRenderer's LivingEntityEmissiveLayer (RenderTypes.eyes) draws
the eye texture on a CopperGolemModel baked from ModelLayers.COPPER_GOLEM,
the body's layer. Horizon drew it on copper_golem#eyes, whose prepared model
has no polygons, so the eyes showed the body texture's black sockets.

Fixes #2862

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 15:24:31 +07:00
d3grandoandClaude Opus 5.5 ab640c7646 Respawn anchor: glowstone charges it on a server instead of being placed
On a server (and the default server-thread single player) the client runs
the block's useItemOn on a copy of its level to decide whether the click
ends there. horizon-world's use_item_on had no RespawnAnchorBlock case, so
glowstone on an anchor fell through to useWithoutItem (PASS on an empty
anchor) and the client predicted a glowstone block that the server then
took back.

- horizon-world use_item_on: glowstone on an anchor below 4 charges charges
  it (RespawnAnchorBlock.charge) and returns SUCCESS.
- The client's off-hand turn runs useItemOn too, so off-hand glowstone
  charges the anchor instead of predicting a placement.
- The main hand PASSes when the off hand's glowstone is to charge the
  anchor, so a charged anchor isn't used (useWithoutItem) and the off-hand
  turn is sent.

Fixes #3034

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 15:23:20 +07:00
Ludovic Marie 11494e17f9 Merge pull request #3104 from Expected333/vr/aim-ray
🤖 Agent: gate cg29: re-checked after your second main merge (70b51333d): same-ray refactor, no behaviour change. fmt, clippy -D warnings, horizon-client tests 796/0 and the 33 harnesses its files reach (33/33) on this head; union/cg29-1 (with #2799 #2819 #2812): parity --changed 124/124 and lan_e2e frames minecarts boats deflect cushions pvp commands on the server thread and --in-frame.
2026-10-29 09:23:11 +01:00
d3grandoandClaude Opus 5.5 b2a5abb885 Key binds: a moved key no longer keeps its default action
KeyBinds::translate_all turns a pressed key into the default keys of the
mappings bound to it, so the game's code follows the player's bindings.
It also followed the debug mappings (F3+A reload chunks, F3+S, F3+1 the
profiler chart...), whose defaults are the gameplay keys A, S, D, T, L
and 1 to 4. So after moving key.hotbar.1 off 1, or key.left off A, the
old key was still "bound" to a debug mapping whose default was itself,
and kept selecting slot 1 or strafing left.

The debug keys act only with F3 held (KeyboardHandler.handleDebugKeys),
and debug_keys.rs already reads the physical key and the binding for
them, so translation now leaves them out.

Fixes #3158

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 15:21:02 +07:00
d3grandoandClaude Opus 5.5 1da38a5c46 Villager gossip: hits and murders count against the player who did them
On the server thread every player is a guest of the server's Game, but a
villager's hurt gossip (minor_negative) and its witnesses' murder gossip
(major_negative) were filed under self.level.player_uuid, the world's
singleplayer UUID. Trading reads the reputation under the guest's own UUID,
so hurting or killing villagers never raised that player's prices.

Both now go to the player in last_hurt_by_player (the host, or the guest by
connection), as Villager.setLastHurtByMob and
tellWitnessesThatIWasMurdered report the attacking player.

Fixes #3431

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 15:20:21 +07:00
d3grandoandClaude Opus 5.5 2bb3903b49 Draw the arms under a map held in first person
renderOneHandedMap draws the holding arm (renderPlayerArm, turned 10
degrees outwards) and renderTwoHandedMap both arms (renderMapHand), each
with renderPlayerHand, unless the player is invisible. Horizon drew only
the map. first_person::map_arm_poses gives those poses; the bare arm's
pose (player_arm_pose) and the two-handed map's base
(two_handed_map_base) move to first_person.rs so the empty hand and the
map share them. The game keeps both arms of the player models, and
first_person_arm draws either with the skin and renderHand's 0.1 radian
zRot.

Fixes #3136

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 15:19:44 +07:00
d3grandoandClaude Opus 5.5 c8915be58c Attack indicator shows at a spear's extra range
The crosshair's living target came only from mob_hit, capped at the
3 blocks of entityInteractionRange, so the attack indicator never showed
for a mob a spear could reach at 4 blocks. LocalPlayer.raycastHitResult
first picks along the held item's AttackRange (getClosesetHit: from the
min reach to the max plus the player's movement, boxes grown by the
hitbox margin, stopped by block outlines) and only falls back to the
plain pick on a miss. crosshair_living now does the same, then keeps
Hud's AttackRange.isInRange check.

The spear's stab search (getHitEntitiesAlong) becomes free functions
over mob boxes with a block clip, shared by the stab (collider) and the
crosshair (outline).

Fixes #3209

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 15:14:13 +07:00
Ludovic Marie ff0e16e3b9 Merge pull request #2955 from kacpherek/fix/server-code-of-conduct-screen
🤖 Agent: verified by contrib-gate-27. Read in full; matches handleCodeOfConduct and ServerData.acceptedCodeOfConduct. Main merged. Note: adjacent to #2981 in net.rs. union/cg27-3 (main + this wave): fmt, clippy -D warnings, horizon-client/horizon-protocol tests (1395/0) and the fast build pass; lan_e2e sleep, bed-occupied, sleep-villager, sleep-reload, arrows, objects, commands and tab pass on the server thread and in-frame. Harness parity runs in CI on this exact head (the Mac mini's load is too high for local parity).
2026-10-29 09:13:24 +01:00
d3grandoandClaude Opus 5.5 b1d3c9b92b Rockets: a rocket used while gliding no longer raises the off-hand shield
FireworkRocketItem.use answers SUCCESS while the player is fall flying
(it launches the boosting rocket), so Minecraft.startUseItem stops at the
main hand. On a server client Horizon only counted items with a use of
their own as ending the click, so the rocket passed and the off hand's
shield went up too, holding the use until the button let go. On foot the
rocket still passes (PASS), as in vanilla, and the shield still rises.

Fixes #3424

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 15:11:39 +07:00
d3grandoandClaude Opus 5.5 8dffd2b847 Count a zombie villager's cure down in its tick, with or without AI
ZombieVillager.tick counts villagerConversionTime down before super.tick
for every living, converting zombie villager; NoAI only skips
serverAiStep. Horizon counted it in customServerAiStep, so a NoAI zombie
villager never finished curing.

Fixes #3429

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 15:08:17 +07:00
d3grandoandClaude Opus 5.5 2a7ee57bab Leave the vehicle when a dead player is removed or respawns
A player who died in a boat stayed its passenger: after the respawn the
server sat the new player back in the boat (and in the void it died
again). In vanilla the dead body is removed 20 ticks after death
(LivingEntity.tickDeath's remove(KILLED)), and PlayerList.respawn
removes it at once (ServerLevel.removePlayerImmediately); both go
through Entity.setRemoved, which calls stopRiding.

- Guest::tick_death takes the body out of its vehicle at 20 ticks, and
  Hub::respawn does it for an earlier respawn (immediate_respawn).
- The client's Respawn after a death (no KEEP_ENTITY_DATA) leaves the
  vehicle, as vanilla's new LocalPlayer rides nothing; a dimension
  change (keep 3) keeps it.
- In-frame, respawn_player stops the riding of the window's player.

Fixes #3474

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 15:08:11 +07:00
d3grandoandClaude Opus 5.5 4245540711 Invisible mobs cast no shadow
EntityRenderer.extractShadow skips an entity whose render state is
invisible (Entity.isInvisible). The shadow pass took every mob: the
casters now come from shadows::mob_casters, which leaves out a mob with
the shared invisible flag from a server or the Invisibility effect on a
mob ticked in the window (Mob::is_invisible).

Fixes #3289

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 15:05:01 +07:00
d3grandoandClaude Opus 5.5 6bd34b64d6 Draw glowing sign text in front of its outline
draw_text emitted the eight outline copies and the text on the same
plane in one mesh, so glowing sign text z-fought with its outline and
broke up. Vanilla's TextFeatureRenderer.renderText draws the outline
NORMAL and the text over it with Font.DisplayMode.POLYGON_OFFSET; the
block-entity mesh has no such pass, so an outlined call's text is now
lifted a twentieth of a font pixel towards the reader.

Fixes #3293

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 15:04:14 +07:00
hashtheboardandClaude Opus 5.5 70b51333d3 Merge origin/main into vr/aim-ray
lan_pvp.rs: keep main's mace::Spared import, drop view_vector (aim() replaces it).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 08:58:22 +01:00
d3grandoandClaude Opus 5.5 a690ccef3c Projectiles hit a seated enderman
Enderman.hurtServer teleports away from projectile damage only when
!isPassenger(). Horizon's on_damage rejected every projectile, so an
enderman riding a boat or a mob made arrows vanish and shrugged off
snowballs and eggs. A free enderman still dodges.

Fixes #3381

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 14:55:02 +07:00
Ludovic Marie 8f0c178957 Merge pull request #2850 from deezhizyu/fix/sweep-attack-2764
🤖 Agent: verified by contrib-gate-27. Read in full; the sweep matches Player.attack and doSweepAttack (sword, charged, on the ground, not sprinting, the entities in the sweep box). union/cg27-2 (main c6af85743 + this wave): fmt, clippy -D warnings, horizon-client tests (1293/0) and the fast build pass; lan_e2e team-push, pvp, thrown, projectiles, mounts and riding pass on the server thread. Harness parity runs in CI on this exact head (the Mac mini's load is too high for local parity).
2026-10-29 08:53:36 +01:00
Ludovic Marie 5a30084c1d Merge pull request #2798 from iLostName/options-languages-without-reading
🤖 Agent: verified by contrib-gate-27. Read in full: the language list now comes from pack.mcmeta without reading every lang file (LanguageManager). union/cg27-1 (main + this wave): fmt, clippy -D warnings (horizon-client, horizon-world, horizon-client-core), their tests and the fast build pass; lan_e2e damage, minecarts, projectiles, sculk_container, shield, push and hazards pass on the server thread and in-frame. Harness parity runs in CI on this exact head (the Mac mini's load is too high for local parity).
2026-10-29 08:33:22 +01:00
Senjin 624be7df86 Merge remote-tracking branch 'origin/main' into hdr-f3 2026-10-29 08:32:55 +01:00
Ludovic MarieandClaude Opus 5.5 48ab8b0934 lan_e2e team-push: put both players back where they stood
The check left the host 3 blocks east of the guest's spot; pvp's
face_each_other then put the guest inside the hill east of the spawn, where
it suffocated and both attacks hit the wall (server thread, after team-push).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 08:28:49 +01:00
Ludovic Marie 9e49037875 Merge pull request #2947 from kacpherek/feat/launcher-prune-old-versions
🤖 Agent: proven by contrib-gate-28: main merged (Nightly-only install path: nightly::after_update then prune_old_versions), German strings added; launcher fmt, clippy -D warnings, cargo test 247+45+… passed; isolated-home run of horizon-launcher update pruned v0.2.80/v0.2.88 and the old Nightly, kept the 2 newest releases and the selected one. Orchestrator approved the feature on 10-22 (cg22).
2026-10-29 08:20:00 +01:00
Ludovic Marie 9169aec896 Merge pull request #3088 from Domanffe/test/session-lock-reads-after-release
🤖 Agent: needs-proof check by Ludovic's AI agent (Claude), not by Ludovic himself. Passed on a union of 11 contributor PRs on main 430e5e4c9: fmt, clippy -D warnings, the horizon-client and horizon-items tests (all pass), and lan_e2e tab chest drops signs frames containers bubble-column with the host on its server thread and in-frame (digging and frames passed on their rerun, after #2745's checks had left water behind). Test-only. Parity: 46 of 268 harnesses passed locally before the Mac mini got too busy, so CI's run-parity on this head decides.
2026-10-29 08:19:40 +01:00
Kacper cddd325067 Port ledger: every vanilla method next to the Rust code that ports it, with a verdict for all of them (#3139)
* Add the port ledger: vanilla methods next to the Rust functions that port them

tools/port_ledger.py indexes the decompiled sources and the Rust tree into .local/port-ledger/ledger.json and answers where/what/class/coverage queries both ways, with the gaps where a port isn't wired like vanilla (seam, client, class, callee, stale).

* Document the port ledger

* Port ledger: bytecode calls, wider matching, drift, issue and check

Read the game jar's bytecode for exact call targets, constants and ids, and
resolve Rust calls through the receiver's type. Match more methods: same-named
fns in an impl or file named for the class (the impl type must fit the class),
and callees of known pairs linked through the call graph. Leave trivial methods
out of coverage.

New gap kind drift: a port that never names a sound, particle, item, block,
effect or game event its vanilla method uses, or lacks one of its rare numbers.
Seam gaps on the old in-frame path sort last; maths and geometry callees no
longer make seam or callee gaps.

New commands: issue (the classes, Rust code and gaps a bug report points at),
check (what a branch touches, gaps it opens and closes against its base), diff,
and a searchable offline report.html.

* Document the port ledger's new matching, gaps and commands

* Port ledger: class homes, structural matches and the verdict map

Give every vanilla class its Rust home files, weighed from all its matches.
In those files, match a method to the fn that touches the same fields and
reaches the ports of the same callees. Read tools/port_map/*.tsv, where a
person or agent records a verdict for what nothing else finds (port, inline,
missing, skip with a reason), and list the next unanswered batch with todo.
Count field-only constructors as trivial and fold initializer lambdas into
<clinit> and <init>.

* Map the first three batches: animals, inventory screens, world generation

* Document class homes, structural matches and the verdict map

* Map 13 game-logic batches and add map-check

map-check checks verdict-map files against the last build without
rebuilding, so several mappers can check their own rows at once.

* Map the rest of the game logic: classes with no Rust trace, then method batches

* Map client packet handling, entity renderers and models, block renderers

Also let PORT_LEDGER_NO_MAP build without the verdict map, to measure the
automatic matches on their own.

* Map client rendering, particles, sounds and resources

* map-check: accept full Class#method keys

* Map client screens, toasts, recipe book and world screens

* Add a vanilla-parity skill: start parity fixes from the port ledger

* issue: read registry names, so a mooshroom report finds MushroomCow

* Map the remaining vanilla methods: commands, network, packs, advancements and the last ten

* Follow the furnace code to furnace.rs on main; answer Fox.getBreedOffspring
2026-10-29 08:15:06 +01:00
Ludovic Marie b19b4e92d3 Merge pull request #3015 from DanielGodriNeto/perf/sprite-disk-cache
🤖 Agent: requeued by contrib-gate-28 after merging main (B9: sprite_cache.rs moved into horizon-client-render with sprites.rs); fmt, clippy -D warnings, horizon-client and horizon-client-render tests pass. Proven earlier by cg27 (sprite_cache_parity OK).
2026-10-29 07:35:53 +01:00
Ludovic Marie 1727cea578 Merge pull request #2989 from kacpherek/fix/save-format-parity
🤖 Agent: needs-proof check by Ludovic's AI agent (Claude), not by Ludovic himself. Passed on a union of 11 contributor PRs on main 430e5e4c9: fmt, clippy -D warnings, the horizon-client and horizon-items tests (all pass), and lan_e2e tab chest drops signs frames containers bubble-column with the host on its server thread and in-frame (digging and frames passed on their rerun, after #2745's checks had left water behind). world_border.dat lands in the overworld data folder, the border from a main world carries over, and previousPlayerGameType and the .dat_old files are written. Downgrading to an older build keeps the level.dat border: expected. Parity: 46 of 268 harnesses passed locally before the Mac mini got too busy, so CI's run-parity on this head decides.
2026-10-29 07:33:44 +01:00
hashtheboardandClaude Opus 5.5 5c0f6aa56c Merge origin/main into vr/aim-ray
minecarts.rs: both sides dropped an import (view_vector here, BlockInteraction on main).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 06:57:47 +01:00
Ludovic Marie 364922fa5e Merge pull request #2993 from kacpherek/fix/world-rules-parity
🤖 Agent: verified by contrib-gate-27. Read in full; checked against Mob.isSunBurnTick, ServerLevel.globalLevelEvent, Projectile.mayInteract/mayBreak and the DecoratedPot/ChorusFlower hits; small fix pushed (sculk mounts from the tick's snapshot). Note: #3104 touches the same minecarts.rs imports, so the second to land needs main merged. union/cg27-1 (main + this wave): fmt, clippy -D warnings (horizon-client, horizon-world, horizon-client-core), their tests and the fast build pass; lan_e2e damage, minecarts, projectiles, sculk_container, shield, push and hazards pass on the server thread and in-frame. Harness parity runs in CI on this exact head (the Mac mini's load is too high for local parity).
2026-10-29 06:52:45 +01:00
Ludovic Marie 1d8f853946 Merge pull request #2954 from kacpherek/fix/nautilus-inventory
🤖 Agent: needs-proof check by Ludovic's AI agent (Claude), not by Ludovic himself. Passed on a union of 12 contributor PRs on main 77fa9fb89: fmt, clippy -D warnings, the tests of horizon-client/-items/-protocol/-world (all pass) and the Python tests, and 19 lan_e2e checks with the host on its server thread (all pass) and in-frame (pass; frames passed on its rerun). lan_e2e mounts PASS on both paths. Parity: CI's run-parity on this head.
2026-10-29 06:44:24 +01:00
Alexander KrylovandHermes Agent 3c7e4ba574 Windows: link the overlay's mirrored files instead of copying them
`link` copied every file the mirror hands the overlay, so a selection with a
pack on wrote the prepared root out again: 639 MB and 28 181 files here, and a
cold overlay build of 34.4 s against 20.8 s when the same files are hardlinks.
Unix already links them and the overlay only reads them.

A different volume, or a filesystem without hardlinks, still copies.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-29 08:21:34 +03:00
Ludovic MarieandClaude Opus 5.5 fb27a63dce Merge main into this branch
Main now has the render crate split (#3186); Git followed the moved files,
and nothing in this branch needed changing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 06:13:14 +01:00
Ludovic MarieandClaude Opus 5.5 33f3347d84 Merge main into this branch
Main now has the render crate split (#3186); Git followed the moved files,
and nothing in this branch needed changing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 06:13:13 +01:00
Alexander KrylovandHermes Agent b27d4b85e1 Launcher: flush the server list before renaming it
`fs::write` hands the bytes to the page cache and returns, so the rename
can be journalled while the data is not: after a power cut `servers.json`
comes back at the right length and full of zeros, which `list()` reads as
no servers at all — the case the rename was meant to prevent.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-29 08:10:50 +03:00
Ludovic Marie 8f69fb313f Merge pull request #3123 from kacpherek/fix/player-built-golem-forgives
🤖 Agent: proven by contrib-gate-28: main merged (kept #2838's anger fields), vanilla IronGolem.canAttack, a built golem spares the player on the server thread and --in-frame (a summoned one fights back), union parity --changed 81/81 PASS, clippy and tests clean.
2026-10-29 06:00:25 +01:00
asowneryt d5a7e8420b Merge branch 'main' of https://github.com/horizon-rust/horizon into fix/3425-potion-duration-padding 2026-10-29 17:47:19 +13:00
Ludovic MarieandClaude Opus 5.5 bd80ffd540 Merge main into this branch after the render crate split (#3186)
hud.rs, font.rs, gfx.rs and gfx/entity_batch.rs moved to
horizon-client-render and took this branch's changes with them; the render
harness hashes hud.rs and font.rs at their new paths.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 05:44:39 +01:00
d3grandoandClaude Opus 5.5 1df28d1793 Mod API: a sprite shape draws as vanilla's blitSprite, nine-sliced by its gui.scaling
render's sprite shape stretched every sprite over its rect, so a mod drawing
tooltip/background and tooltip/frame (or widget/button) in a box of another
size got smeared borders. It now goes through ui::blit_sprite, which does what
GuiGraphicsExtractor.blitSprite does with the sprite's GuiSpriteScaling:
nine-sliced when its .png.mcmeta says nine_slice (47 vanilla GUI sprites),
stretched otherwise. Tile, which no vanilla sprite uses and Horizon doesn't
read yet, still stretches.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 11:42:14 +07:00
d3grandoandClaude Opus 5.5 168bc8ffd5 Native mod settings: ask for the labels again when the page opens
A native mod's settings page showed the labels native_settings gave
when Options > Mods was listed, refreshed only after a click on one of
its buttons, so after a language change (or a change in the mod's own
state) the page kept the old labels until clicked. Opening the page now
sends Event::NativeSettings, and the game asks the mod again
(native_settings_refresh, split from native_settings_event).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 11:42:07 +07:00
Ludovic MarieandClaude Opus 5.5 2d966fb3ad Merge main into this branch after the render crate split (#3186)
sprites.rs, assets.rs and atlas_sources.rs moved to horizon-client-render,
so sprite_cache.rs moves there too (mod sprite_cache; in that crate's lib.rs)
and horizon-client's lib.rs loses its line.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 05:41:59 +01:00
d3grandoandClaude Opus 5.5 8bb8239603 Copper chests join into double chests
ChestBlock.combine pairs every ChestBlock, and copper chests in each
weathering state, waxed or not, are ChestBlocks. Horizon placed and drew
them as double chests but only paired chests and trapped chests when
opening a menu, so a double copper chest opened as one 27-slot half. Hoppers,
comparators and the spectator crosshair missed them the same way.

The pairing now lives in horizon_client::containers (is_chest_block,
ChestHalf) and follows DoubleBlockCombiner.combineWithNeigbour: the same
block, the other half's type and the same facing (the facing check was
missing for every chest). The menus, hoppers and the in-frame chest screen
use it.

New harness tools/test_chest_pair_parity.py (VanillaChestPairOracle,
chest_pair_fixture): 3660/3660 cases, 3640/3660 with the old rule.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 11:41:57 +07:00
d3grandoandClaude Opus 5.5 0352ad0e19 Name tags: draw text as Font.prepareText does (bold, tall glyphs)
Name tags had their own glyph loop: every glyph an 8x8 quad at the line
top and no style. accented.png's 9x12 glyphs (й, Й, Ё...) were squeezed
into the line, bold, italic, underline, strikethrough, obfuscated and the
font style were ignored, the line was centred at -trunc(width / 2) and the
background stopped 8 pixels below the line top.

Vanilla (SubmitNodeCollection.submitNameTag -> TextFeatureRenderer ->
Font.prepareText) draws them like any text, centred at -Font.width / 2,
on a background from y - 1 to y + 9 (PreparedTextBuilder.markBackground).
HudBatch::name_tag lays the line out with text_styled, and names.rs maps
its quads into the world. Fixes #3084.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 11:41:56 +07:00
d3grandoandClaude Opus 5.5 702d7cf2cc Show custom names as container titles (#3245)
The server opened every block container with its default title
(container.chest, container.shulkerBox...), so renamed shulker boxes,
copper chests and every other container lost their names on the
server thread, on LAN and on dedicated servers. The menu title now
follows BaseContainerBlockEntity.getDisplayName (the custom name, else
the default name) and, for double chests, ChestBlock's
MENU_PROVIDER_COMBINER (right half, then left, then
container.chestDouble).

The shulker box screen always drew "Shulker Box", and the furnace and
crafter screens ignored the server's title; they now draw it like the
other container screens.

New harness tools/test_container_title_parity.py
(VanillaContainerTitleOracle against container_title_fixture, 148
cases), and a lan_e2e "names" check.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 11:41:56 +07:00
d3grandoandClaude Opus 5.5 526887be89 Tooltips: potion effect and armour trim lines in the selected language
stack_tooltip_lines read the potion effect names and the armour trim
lines through Enchantments::translate, which holds en_us only, so a
Russian game showed "Slowness IV" and "Upgrade: Coast Armor Trim" among
Russian lines. They now go through the same translator as the other
tooltip lines: the selected language, then the pinned English names.

Vanilla builds these lines from translatable components
(PotionContents.addPotionTooltip and getPotionDescription,
ArmorTrim.addToTooltip).

Part of #3312 (the raw %2$s: %1$s attribute lines are #3241).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 11:41:55 +07:00
d3grandoandClaude Opus 5.5 a5ae075a5d Fog option off clears the Nether's fog too
The Horizon Fog option only cleared the distance fog when that fog ended
at or beyond the render distance. The Nether's visual/fog_end_distance is
96 blocks, so its fog stayed at any render distance over 6 chunks. Clear
it whenever the camera's fog is atmospheric and no blindness, darkness or
boss world fog applies; the fog of lava, powder snow and water keeps its
limits, as before.

Fixes #3408

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 11:41:54 +07:00
d3grandoandClaude Opus 5.5 14a4d2726d Window: a size above the GPU's largest texture no longer crashes
Windows lets a program size a window up to 65535 pixels tall with
SetWindowPos; its client area (984x65496 here) is past the 32768 an RTX
5080 takes, and Renderer::resize handed it to Surface::configure, which
panics ("width and height must be within the maximum supported texture
size"). Renderer::resize now treats a size the swapchain can't take like
a minimized window: it keeps the last size and draws nothing until the
next resize. Renderer::new clamps its first size to the same limit.

Vanilla: Window.onFramebufferResize ignores a size that is not positive
and keeps the last one.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 11:41:48 +07:00
Ludovic MarieandClaude Opus 5.5 38712c49ec Merge main into this branch after the render crate split (#3186)
gfx/sunrise_fan.rs follows gfx/ to crates/horizon-client-render/src/gfx/.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 05:40:48 +01:00
Ludovic Marie 62a01db8f0 Merge pull request #3186 from horizon-rust/refactor/horizon-client-render
🤖 Agent: crate split B9: 83 modules move to crates/horizon-client-render (pure move, re-exported under the old paths), announced in #horizon-team at 00:5xZ. Full pr_ready green (2377 workspace tests, parity 356 affected / 0 failed; rustfmt fixed in this head). The PR body lists the open PRs that need a fix and the exact change for each.
2026-10-29 05:37:12 +01:00
Ludovic MarieandClaude Opus 5.5 a287f701e6 Text displays: a § ending a part breaks only that part; doc back on format_translation
StringDecomposer.iterateFormatted stops at a § with no code after it and the
line-break finder goes on with the next part; split_formatted gave up on the
whole text instead. legacy_format had been inserted under
format_translation's doc comment.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 05:33:53 +01:00
Ludovic MarieandClaude Opus 5.5 8871ba0b28 Launcher: German strings for old-version pruning
main added de.json after this branch; its i18n test asks every language
for every string.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 05:31:29 +01:00
Ludovic Marie 299c0b495d Merge pull request #3419 from horizon-rust/feat/launcher-java-command
🤖 Agent: launcher-java-1 verified this head: PR checks green (verify, launcher), launcher build matrix green on Windows, macOS and Linux (dry run https://github.com/horizon-rust/horizon/actions/runs/40793866687, after the Windows path fix), pr_ready --quick green.
2026-10-29 05:24:17 +01:00
Ludovic Marie c29d37de1d Merge pull request #3451 from horizon-rust/fix/launcher-join-while-preparing
🤖 Agent: launcher-java-1 verified this head: PR checks green (verify, launcher), launcher build matrix green on Windows, macOS and Linux (dry run https://github.com/horizon-rust/horizon/actions/runs/40793347222), pr_ready --quick green with the new test shortcuts_start_only_when_play_would.
2026-10-29 05:24:13 +01:00
Ludovic MarieandClaude Opus 5.5 cbacadae22 Launcher: delete an instance, and forget ones whose folder is gone
Instances could be made but never deleted (#horizon-bugs: one stayed
in the list). The instance row gets Delete…: after a confirmation, the
instance's folder (an own instance's, or an imported or registry pack's
work folder) goes to the Trash, where the player can get it back, and
its choices and the selection go with it. Horizon's own instance, the
game's home, stays. Not while a game runs. `instance delete` does the
same from the command line.

An own instance whose folder was removed outside the launcher is
dropped from instances.json when the list is next read, so a new one
of that name starts clean (only when instances/ itself can be read).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 05:20:50 +01:00
Ludovic MarieandClaude Opus 5.5 caa1be7abe Merge main into feat/launcher-prune-old-versions
main went Nightly-only (#3342): install_tag runs nightly::after_update (it
removes the older Nightlies) and then prunes the old release versions; the
Settings page drops the Nightly row main removed and keeps the pruning switch.
Both sets of new strings are kept in fr.json and ru.json.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 05:17:21 +01:00
Ludovic MarieandClaude Opus 5.5 1970ab1a7b Merge main into fix/player-built-golem-forgives
Keeps both new Extra fields: main's anger_end/angry_at (#2838) and this
branch's player_created.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 05:15:37 +01:00
asownerytandClaude Opus 5.5 85032102a8 Potion tooltips zero-pad effect durations as vanilla does
Potion, splash and lingering potion and tipped arrow tooltips printed
durations as m:ss ("Speed (3:00)"); vanilla's StringUtil.formatTickDuration
pads every field to two digits ("Speed (03:00)", "01:00:00" from an hour up).

brewing.rs now has one port of formatTickDuration, with vanilla's floor of
ticks / tick rate, and the inventory effect panel uses it too instead of its
own copy, so the two agree on the same screen. Tooltips still format at
20 TPS until their callers pass the level's rate.

Fixes #3425.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 16:53:25 +13:00
Ludovic Marie 0e57b1f6cf Merge pull request #2838 from levzzz5154/fix/neutral-mob-anger
🤖 Agent: needs-proof check by Ludovic's AI agent (Claude), not by Ludovic himself. Passed on a union of 11 contributor PRs on main 430e5e4c9: fmt, clippy -D warnings, the horizon-client and horizon-items tests (all pass), and lan_e2e tab chest drops signs frames containers bubble-column with the host on its server thread and in-frame (digging and frames passed on their rerun, after #2745's checks had left water behind). Save round trip main -> PR -> main -> PR keeps the mobs. Follow-up, not a blocker: vanilla NeutralMob.readPersistentAngerSaveData falls back to the old AngerTime key when anger_end_time is missing, and this reads only anger_end_time, so an angry wolf from an older world calms down. angry_at is kept only for the offline single player. Parity: 46 of 268 harnesses passed locally before the Mac mini got too busy, so CI's run-parity on this head decides.
2026-10-29 04:41:16 +01:00
socksproxandClaude Opus 5.5 c3987df0b2 Text displays: vanilla text depths and polygon offset, with a render harness
A text display's background, shadow and glyphs were coplanar, so they
z-fought (server holograms looked speckled). World text now gets vanilla's
depths (background -0.01, shadow 0, glyph 0.03, bold copy +0.001, lines
+0.01) and text displays draw with TEXT_POLYGON_OFFSET's depth bias.

The new text_display_render harness runs vanilla's TextDisplayRenderer and
Font on baked glyphs and compares every quad; it found and this fixes: § codes
kept across wrapped lines (StringSplitter.splitLines on the raw parts), line
effects starting 1 left at each style run, and no shadow when its colour is 0.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 04:04:44 +01:00
Ludovic Marie c9d61dcab7 Merge pull request #2976 from kacpherek/fix/2756-thrown-trident-frozen
🤖 Agent: needs-proof check by Ludovic's AI agent (Claude), not by Ludovic himself. Passed on a union of 12 contributor PRs on main 77fa9fb89: fmt, clippy -D warnings, the tests of horizon-client/-items/-protocol/-world (all pass) and the Python tests, and 19 lan_e2e checks with the host on its server thread (all pass) and in-frame (pass; frames passed on its rerun). lan_e2e thrown and projectiles PASS on both paths. Parity: CI's run-parity on this head.
2026-10-29 03:58:45 +01:00
spiderandClaude Opus 5.5 6c50b7d980 Firework rockets: explode on the entities they hit, credit their owner, and load from NBT
FireworkRocketEntity.tick checks its move for entities (getHitResultOnMoveVector)
and goes off on the first it meets; a breeze turns it back. Its owner (the player
who launched it from a block, a glide or a crossbow) is credited with what the
blast hurts, as the guest on a server. /summon and saved chunks now load a real
rocket (Life, LifeTime, FireworksItem, ShotAtAngle, Owner, LeftOwner).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 03:56:00 +01:00
asowneryt 1fa4a780f0 Merge branch 'main' of https://github.com/ludovic111/horizon into claude/2697-horse-make-mad-4431aa
# Conflicts:
#	crates/horizon-client/src/mobs/ai.rs
#	crates/horizon-client/src/mobs/equine.rs
2026-10-29 15:55:44 +13:00
Ludovic Marie 8dfce86201 Merge pull request #3105 from malordin/fix/language-list-check
🤖 Agent: verified by contrib-gate-27. Read in full: the language list is missing when assets/pack.mcmeta is (lang.rs reads languages from it, and horizon-prepare takes it from the asset index); the jar's lang folder always holds en_us.json and deprecated.json, so the old file count never told. Launcher clippy -D warnings is clean, and prepared_versions_get_what_the_asset_store_adds_without_preparing_again passes. CI parity runs on this head.
2026-10-29 03:44:52 +01:00
Ludovic Marie 7a8c5a2642 Merge pull request #2974 from kacpherek/fix/advancement-triggers-distance-effects
🤖 Agent: verified by contrib-gate-27. Read in full; checked against ServerPlayer's fall, lava, Nether and explosion tracking; fix pushed: the durability trigger fires only when wear is left (ItemStack.hurtAndBreak). union/cg27-1 (main + this wave): fmt, clippy -D warnings (horizon-client, horizon-world, horizon-client-core), their tests and the fast build pass; lan_e2e damage, minecarts, projectiles, sculk_container, shield, push and hazards pass on the server thread and in-frame. Harness parity runs in CI on this exact head (the Mac mini's load is too high for local parity).
2026-10-29 03:44:32 +01:00
Ludovic Marie dd9c9433b6 Merge pull request #3085 from kacpherek/fix/throws-use-known-movement
🤖 Agent: verified by contrib-gate-27. Read in full; throws take getKnownMovement (main's rider movement kept). Main merged. union/cg27-2 (main c6af85743 + this wave): fmt, clippy -D warnings, horizon-client tests (1293/0) and the fast build pass; lan_e2e team-push, pvp, thrown, projectiles, mounts and riding pass on the server thread. Harness parity runs in CI on this exact head (the Mac mini's load is too high for local parity).
2026-10-29 03:40:22 +01:00
JustKarkatandJustKarkat's AI agent (ZCode) d372e9ece9 Mob-origin item drops get the ItemEntity constructor's random motion
A mob's death loot, its gift table and spawnAtLocation drops (a fox's
berries, a panda's held food, a goat's horn) spawned with a fixed
[0, 0.2, 0] motion, so every item landed stacked at one exact point; the
5-arg ItemEntity(level, x, y, z, stack) constructor throws each one with
a random horizontal push of ±0.1 and 0.2 up, like vanilla. Block drops
(already going popResource's path) unaffected.

Fixes #3144.

Co-Authored-By: JustKarkat's AI agent (ZCode)
2026-10-28 21:37:01 -05:00
Ludovic MarieandClaude Opus 5.5 e037d39314 rustfmt: rewrap horizon-client's re-export of the render modules
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 03:31:26 +01:00
spiderandClaude Opus 5.5 3c514d5c17 Merge main into cape-rendering
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 03:31:04 +01:00
Ludovic Marie 9b12c89191 Merge pull request #3037 from ASOwnerYT/claude/dreamy-khayyam-1553ca
🤖 Agent: verified by contrib-gate-27. Read in full; matches AbstractBedBlock.useWithoutItem (OCCUPIED), kickVillagerOutOfBed, checkBedExists and the saved sleeping_pos; its own lan_e2e checks pass. union/cg27-3 (main + this wave): fmt, clippy -D warnings, horizon-client/horizon-protocol tests (1395/0) and the fast build pass; lan_e2e sleep, bed-occupied, sleep-villager, sleep-reload, arrows, objects, commands and tab pass on the server thread and in-frame. Harness parity runs in CI on this exact head (the Mac mini's load is too high for local parity).
2026-10-29 03:22:46 +01:00
Ludovic Marie ac17fd2b0f Merge pull request #2949 from kacpherek/fix/saved-world-preset
🤖 Agent: needs-proof check by Ludovic's AI agent (Claude), not by Ludovic himself. Passed on a union of 11 contributor PRs on main 430e5e4c9: fmt, clippy -D warnings, the horizon-client and horizon-items tests (all pass), and lan_e2e tab chest drops signs frames containers bubble-column with the host on its server thread and in-frame (digging and frames passed on their rerun, after #2745's checks had left water behind). A saved amplified world generates amplified terrain in new chunks at 3000/3000 (main: normal), screenshots taken. Parity: 46 of 268 harnesses passed locally before the Mac mini got too busy, so CI's run-parity on this head decides.
2026-10-29 03:03:35 +01:00
rdbt e9027c77c2 Merge pull request #3440 from kerimgrande/main
adding german translation to the launcher (+ tidied up my formatting :P)
2026-10-29 01:46:10 +00:00
rdbt 95bd72abaf fix translation 2026-10-29 01:37:26 +00:00
Ludovic Marie 0fc7603da8 Merge pull request #2963 from kacpherek/fix/end-portal-frame-effects
🤖 Agent: needs-proof check by Ludovic's AI agent (Claude), not by Ludovic himself. Passed on a union of 12 contributor PRs on main 77fa9fb89: fmt, clippy -D warnings, the tests of horizon-client/-items/-protocol/-world (all pass) and the Python tests, and 19 lan_e2e checks with the host on its server thread (all pass) and in-frame (pass; frames passed on its rerun). Parity: CI's run-parity on this head.
2026-10-29 02:34:14 +01:00
Ludovic MarieandClaude Opus 5.5 833b9385ee Merge main into world-access-failure-toast
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 02:33:08 +01:00
Ludovic Marie 68b626fc9d Merge pull request #2950 from kacpherek/fix/loot-set-components-trim
🤖 Agent: needs-proof check by Ludovic's AI agent (Claude), not by Ludovic himself. Passed on a union of 11 contributor PRs on main 430e5e4c9: fmt, clippy -D warnings, the horizon-client and horizon-items tests (all pass), and lan_e2e tab chest drops signs frames containers bubble-column with the host on its server thread and in-frame (digging and frames passed on their rerun, after #2745's checks had left water behind). Parity: 46 of 268 harnesses passed locally before the Mac mini got too busy, so CI's run-parity on this head decides.
2026-10-29 02:08:18 +01:00
Ludovic MarieandClaude Opus 5.5 5edfd95da6 Sprite cache test: escape the corrupt header's bytes
The raw 0x01 and NUL bytes in the byte string made git, grep and GitHub treat sprite_cache.rs as a binary file.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 02:05:22 +01:00
Ludovic MarieandClaude Opus 5.5 21ba38e633 Merge main into fix/list-damage-datapack-commands
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 02:00:37 +01:00
Ludovic MarieandClaude Opus 5.5 ae4ffbea8f Merge main into fix/server-code-of-conduct-screen
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 02:00:37 +01:00
AHHhHHHHH-HandClaude Sonnet 5.5 940b642233 Far terrain: sample on up to 6 threads when the machine has 12 or more, behind the game
A cold 1024-chunk LOD loaded about a quarter sooner with 6 sampling threads than with 3. The extra slots only open once the nearby chunks are in, and the far threads now run behind the server like the chunk workers, so frames and ticks keep their cores.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-28 21:50:15 -03:00
d3grandoandClaude Opus 5.5 d192946a6f native_corner_shown: F3 takes the top right and its charts' corners
Vanilla's debug screen draws a column in each top corner
(DebugScreenOverlay.extractLines, left- and right-aligned), but the
helper only reported the top left as taken, so a native mod's box in the
top right was drawn under F3's right column. It now reports both top
corners while F3 is open, and a bottom corner while a debug chart is
drawn there (frame and tick times, bandwidth and ping, the lightmap
view), from the new DebugCharts::corners, which follows draw().

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 07:50:00 +07:00
Ludovic MarieandClaude Opus 5.5 af3aa00377 Merge main into fix/2869-horse-max-health
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 01:43:53 +01:00
Ludovic MarieandClaude Opus 5.5 5f80ddffce Merge main into fix/remote-player-teams
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 01:43:53 +01:00
Ludovic MarieandClaude Opus 5.5 b9449b2348 Merge main into fix/skin-online-sound-options
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 01:43:53 +01:00
Ludovic MarieandClaude Opus 5.5 c2c364f195 Merge main into fix/throws-use-known-movement
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 01:43:52 +01:00
Ludovic MarieandClaude Opus 5.5 b1532a179a Merge origin/main into refactor/horizon-client-render
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 01:38:42 +01:00
Ludovic MarieandClaude Opus 5.5 ff85f100ec refactor: datapack, audio and sound_engine join horizon-client-render
They use only modules already in the render crate (zip, snbt, settings,
sound_events), and open PRs call them from options_ui (#3325: datapack,
#3355: audio). Pure move: cpal and symphonia and the `audio` feature
move with them (horizon-client's `audio` forwards to it), and
sound_engine's test backend is also built for the `test-support` feature
that horizon-client's tests turn on. audio_harness and
docs/architecture.md point at the new paths.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 01:38:29 +01:00
Ludovic Marie c6af857433 Merge pull request #2969 from kacpherek/fix/backup-before-world-upgrade
🤖 Agent: needs-proof check by Ludovic's AI agent (Claude), not by Ludovic himself. Passed on a union of 11 contributor PRs on main 430e5e4c9: fmt, clippy -D warnings, the horizon-client and horizon-items tests (all pass), and lan_e2e tab chest drops signs frames containers bubble-column with the host on its server thread and in-frame (digging and frames passed on their rerun, after #2745's checks had left water behind). A world with DataVersion 3955 shows the backup prompt (vanilla text and buttons). Create Backup and Load writes backups/<date>_W.zip and opens the world, now at 5023. Parity: 46 of 268 harnesses passed locally before the Mac mini got too busy, so CI's run-parity on this head decides.
2026-10-29 01:36:49 +01:00
Ludovic MarieandClaude Opus 5.5 dc3fc6961e Decoration: World keeps its own doc comment
OriginWorld was inserted between World's doc comment and World, so the
comment documented both structs as one.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 01:35:11 +01:00
Ludovic MarieandClaude Opus 5.5 1a71386829 Sculk: take each mob's vehicle from the tick's snapshot
The mount events looked every mob up again in the mob list, once per mob
and tick; the snapshot the loop walks already reads the mob.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 01:34:57 +01:00
Ludovic MarieandClaude Opus 5.5 e12c5d4f23 Advancements: the durability trigger fires only when wear is left
ItemStack.hurtAndBreak calls applyDamage, and so ItemDurabilityTrigger,
only when processDurabilityChange leaves damage: a hit Unbreaking saves
fires nothing. One helper, Game::wear, takes the loss and fires the
trigger for every call site.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 01:34:42 +01:00
Ludovic Marie 8f91683323 Merge pull request #3435 from horizon-rust/feat/native-hooks-v2-recipes
🤖 Agent: hook points v2, part 2/2 (the item panel and recipe pages for native mods, NATIVE_HOOKS_API 2), rebased onto main after #3434. pr_ready --quick green on 11f22e2 (clippy, 1289 client tests); full pr_ready with parity green on the pre-rebase head (33/33 selected). The native Recipes mod's proof passes on it (horizon-mods#24).
2026-10-29 01:24:38 +01:00
Ludovic MarieandClaude Opus 5.5 c5221cc92d Launcher: java_launch's Windows paths, one part at a time
The log config and old versions' asset folder were joined from
"a/b" strings; on Windows their tests compared those mixed
separators with paths joined part by part. Join them part by part.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 01:23:12 +01:00
Ludovic Marie 471d815aeb Merge pull request #3187 from horizon-rust/refactor/spawner-hive-traits
🤖 Agent: crate split B8: spawner, trial spawner and beehive behaviour become extension traits (pure move). Main merged, including #3064 and #2952 (their new methods now live on the traits). Full pr_ready green: 1286 tests, parity 82 affected / 0 failed.
2026-10-29 01:20:26 +01:00
Ludovic MarieandClaude Opus 5.5 40073ab9c0 Launcher: Join and other quick starts wait for the version's setup
A server's Join started the game while its version was still being
downloaded and prepared for the first time: a white window opened and
the download went on. The button only looked disabled while a game
ran, and its click (like a world's Play, a friend's code and a pack's
Play) reached `play()`, which never asked whether a preparation was
under way or the version was prepared.

Every quick start now asks what Play asks (`starts_now`, from
`play_press`): prepared, no preparation running, no game running or
starting. `play()` and `play_pack` refuse otherwise, and the buttons
are drawn from the same rule.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 01:18:24 +01:00
d3grandoandClaude Opus 5.5 fd0aeb5331 Stop swimming and sit up when boarding a boat while swimming
A riding player skips the movement tick, which held the swimming
update, so a player who boarded a boat while swimming kept the swimming
pose: eye 0.4 above the feet, the camera at the waterline and the model
lying under the boat.

Vanilla's Entity.rideTick runs Player.tick for a passenger:
Entity.updateSwimming clears the swim (it needs !isPassenger()) and
Player.tick ends with updatePlayerPose. The riding branch of the remote
tick now runs the same two steps (MotionWorld::passenger_pose_tick).

Fixes #3308

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 07:18:00 +07:00
Ludovic Marie e9c6751a1b Merge main into fix/shield-blocking-pose 2026-10-29 01:14:31 +01:00
Ludovic Marie cf0b111bdd Merge main into join-without-rescanning-registries 2026-10-29 01:14:31 +01:00
Ludovic Marie 5c2bc0af4a Merge pull request #2966 from kacpherek/fix/arrow-weapon-knockback
🤖 Agent: needs-proof check by Ludovic's AI agent (Claude), not by Ludovic himself. Passed on a union of 11 contributor PRs on main 430e5e4c9: fmt, clippy -D warnings, the horizon-client and horizon-items tests (all pass), and lan_e2e tab chest drops signs frames containers bubble-column with the host on its server thread and in-frame (digging and frames passed on their rerun, after #2745's checks had left water behind). Parity: 46 of 268 harnesses passed locally before the Mac mini got too busy, so CI's run-parity on this head decides.
2026-10-29 01:12:34 +01:00
Ludovic MarieandClaude Opus 5.5 11f22e26fa Native mods: the item panel and recipe pages for native mods
The item panel beside container screens and its recipe pages move to
game/native_panel.rs and game/native_panel_recipes.rs, built in every
build, with their state in Game::panel and native types (NativeStack,
NativeRecipeView, NativeRecipeLayout, NativePanelEvent). A native mod
shows them with native_panel_items and native_panel_recipes and hears
the player's clicks, searches, page turns and keys over items through
the new hook point native_panel_event: NATIVE_HOOKS_API is 2.

A WebAssembly mod's panel feeds the same state through game/mod_panel.rs
(feature `mods`) and hears its events as before; a native mod's panel
wins when both show one. Players see no change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 01:12:08 +01:00
d3grandoandClaude Opus 5.5 bf7179c48b Keep the player model's legs still while flying straight up
The walk animation counted vertical movement while flying or gliding.
Vanilla's LivingEntity.calculateEntityAnimation only counts it for
omnidirectional air movers, and Entity.omnidirectionalAirMover is false
for every player, so only the horizontal distance walks the model.

Fixes #3302

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 07:08:09 +07:00
d3grandoandClaude Opus 5.5 dbb03a42d3 Creative inventory tab: click locally on a server, as vanilla does (#3307)
On a server (single player's server thread included) the creative screen's
Survival Inventory tab sent its clicks as ContainerClick, so the server held
the picked-up stack while the Destroy Item slot only cleared the client's:
the stack came back on the next click or on close, and moving a stack
doubled it. CreativeModeInventoryScreen.slotClicked clicks the player's own
InventoryMenu and sends each changed slot (SetCreativeModeSlot); Horizon now
does the same.

- remote_click: the creative screen clicks its own menu, never a ContainerClick;
  a Shift double click there quick-moves locally too.
- Q / Ctrl+Q over a slot of the tab sends the thrown stack
  (handleCreativeModeItemDrop) instead of losing it.
- The creative slot sync covers the armour slots (InventoryMenu 5..=8).
- The server marks a creative-set slot as sent (InventoryMenu.setRemoteSlot),
  so it no longer echoes it back over a newer click, and the client marks only
  the slots a server packet changed as synced.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 07:04:12 +07:00
d3grandoandClaude Opus 5.5 01f3d91d04 Item names: translate items outside the minecraft namespace
`item_tooltip::name_line` stripped only a `minecraft:` prefix and looked
up `item.minecraft.<path>` and `block.minecraft.<path>`. A content mod's
item (`ruby:ruby`) never found its name: the name above the hotbar
showed the raw id ("ruby:ruby"), and the recipe book and creative
searches matched that instead of the item's name.

The keys are now `Util.makeDescriptionId`'s, in the item's own
namespace with `/` as `.`: `item.<namespace>.<path>`, then the block's
for a block item. Without a translation the name falls back to the path
with spaces, as `creative::Names::item` does.

Vanilla: Item.getDescriptionId, Item.Properties (ITEM_DESCRIPTION_ID,
BLOCK_DESCRIPTION_ID), Util.makeDescriptionId.

Fixes #3396

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 06:58:55 +07:00
d3grandoandClaude Opus 5.5 99b6b8f3e8 Spectators open chests and other containers, read-only
A spectator's right click on a chest did nothing: the client predicted
the use like any player's, and the server dropped a spectator's
UseItemOn. Follow vanilla instead:

- MultiPlayerGameMode.performUseItemOn: a spectator's use goes to the
  server, predicts nothing and ends the click (CONSUME, no swing).
- ServerPlayerGameMode.useItemOn: the server opens the block's menu
  (BlockState.getMenuProvider) for a spectator, with no lid, sound,
  game event, stat or piglin anger. Chests, trapped chests, barrels,
  shulker boxes, hoppers, dispensers, droppers, furnaces, the brewing
  stand and the crafter open; a blocked chest still doesn't.
- ServerGamePacketListenerImpl.handleContainerClick: a spectator's
  click changes nothing, and sendAllDataToRemote now takes back what
  the client predicted.

Fixes #3414

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 06:52:13 +07:00
Ludovic Marie e6bb2f74c7 Merge pull request #2941 from ASOwnerYT/tools/bat-oracle-final-fields
🤖 Agent: needs-proof check by Ludovic's AI agent (Claude), not by Ludovic himself. Passed on a union of 12 contributor PRs on main 77fa9fb89: fmt, clippy -D warnings, the tests of horizon-client/-items/-protocol/-world (all pass) and the Python tests, and 19 lan_e2e checks with the host on its server thread (all pass) and in-frame (pass; frames passed on its rerun). Parity: CI's run-parity on this head.
2026-10-29 00:51:04 +01:00
Ludovic Marie 08c67f3d2f Merge pull request #2851 from levzzz5154/fix/dyed-armor-colour
🤖 Agent: needs-proof check by Ludovic's AI agent (Claude), not by Ludovic himself. Passed on a union of 12 contributor PRs on main 77fa9fb89: fmt, clippy -D warnings, the tests of horizon-client/-items/-protocol/-world (all pass) and the Python tests, and 19 lan_e2e checks with the host on its server thread (all pass) and in-frame (pass; frames passed on its rerun). Main merged in (95ecc9d77): armour stands keep main's layer_color. Parity: CI's run-parity on this head.
2026-10-29 00:47:12 +01:00
Ludovic Marie db8c6d6331 Merge pull request #3225 from masa-masa-0314/fix/dialog-command-packet
🤖 Agent: needs-proof check by Ludovic's AI agent (Claude), not by Ludovic himself. Passed on a union of 12 contributor PRs on main 77fa9fb89: fmt, clippy -D warnings, the tests of horizon-client/-items/-protocol/-world (all pass) and the Python tests, and 19 lan_e2e checks with the host on its server thread (all pass) and in-frame (pass; frames passed on its rerun). Parity: CI's run-parity on this head.
2026-10-29 00:41:16 +01:00
spiderandClaude Opus 5.5 fc409d28f4 TAA: no jitter shake, and Upscaling shows only with TAA
The resolve looked the history up at this frame's jittered position, so a
still camera wobbled in an 8-frame pattern (twice the frame-to-frame change
of TAA off in a still view), worse when upscaling widens the jitter. The
history is unjittered now: each output pixel looks it up at its own centre,
takes the world pixel whose jittered sample lands nearest that centre, and
scales the frame's 10% share by a Gaussian of the distance, so frames whose
sample lands far away barely move it.

The graphics screen drops the Upscaling row while Anti-aliasing is not TAA.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 00:38:26 +01:00
kerim e665b6bf00 Merge branch 'main' of https://github.com/kerimgrande/horizon 2026-10-29 00:35:16 +01:00
kerim 0048f04f1b edit formatting 2026-10-29 00:35:07 +01:00
spiderandClaude Opus 5.5 fa727a627a TAA upscaling: Native, Quality, Balanced, Performance and Ultra Performance
An Upscaling button under Anti-aliasing (`upscaling` in horizon-graphics.txt,
Native by default). The world renders 1.5x, 1.7x, 2x or 3x smaller along each
axis than the resolution scale's size (FSR's and DLSS's ratios) and TAA resolves
into a history at full size, each output pixel taking the world pixel it falls
in; the jitter runs over 8 phases per output pixel. Picking a ratio turns TAA
on, leaving TAA goes back to Native, and nothing is upscaled on the title
screen or under a shader pack.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 00:31:15 +01:00
Joansitoh dc44f98818 Packs overlay: quote mklink's paths, compile the junction only on Windows
cmd reads its own command line and Rust quotes only paths with spaces, so a
path with `&` or `^` split or changed the mklink call and that player fell back
to the slow mirror. The overlay test now runs under a path with `&` and checks
the untouched directory is a link, not a copy.
2026-10-29 00:30:13 +01:00
spiderandClaude Opus 5.5 511eb49629 TAA: a sub-pixel jitter, camera motion vectors from depth, and a history clamped to the frame
Anti-aliasing now cycles Off, FXAA and TAA (`taa` in horizon-graphics.txt, off by
default). The world's projection is jittered over a Halton (2, 3) sequence of 8
frames; the resolve reprojects each pixel through the depth buffer and the
camera's motion since the last frame, samples the Rgba16Float history with a
5-tap Catmull-Rom, clamps it to the frame's 3x3 neighbourhood and blends 10% of
the new frame in. It runs at the resolution scale, before the hand and HUD, and
stays off under a shader pack.

Motion vectors come from the camera alone: mobs and other moving things lean on
the clamp. Per-object motion is the follow-up.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 00:23:04 +01:00
Ludovic Marie 791d8b8950 Merge pull request #3434 from horizon-rust/feat/native-hooks-v2-tables
🤖 Agent: hook points v2, part 1/2 (recipe tables and helpers for native mods). pr_ready green on the stack's head 1dba99f: clippy, 1286 client tests, parity 33/33 selected; the native Recipes mod builds on it and its proof passes. Merging it queues part 2, #3435.
2026-10-29 00:16:10 +01:00
Joansitoh 90014f63d2 Windows: link untouched directories into the pack overlay with junctions, not a file-by-file mirror
Refs #3385, #3234
2026-10-29 00:15:45 +01:00
kerim b9a040dc21 Merge branch 'horizon-rust:main' into main 2026-10-29 00:07:46 +01:00
Ludovic Marie fe3333f3f0 Merge pull request #2945 from kacpherek/fix/creative-slot-item-components
🤖 Agent: needs-proof check by Ludovic's AI agent (Claude), not by Ludovic himself. Passed on a union of 12 contributor PRs on main 77fa9fb89: fmt, clippy -D warnings, the tests of horizon-client/-items/-protocol/-world (all pass) and the Python tests, and 19 lan_e2e checks with the host on its server thread (all pass) and in-frame (pass; frames passed on its rerun). Against a vanilla 26.3 server (127.0.0.1, offline): moving a renamed/unbreakable/lored sword and a shulker box holding 5 diamonds in the creative inventory kept every component and did not kick. Parity: CI's run-parity on this head.
2026-10-29 00:07:34 +01:00
kerim 1dce2ab885 added german translation to the launcher 2026-10-29 00:04:23 +01:00
Cosmic b56048d996 Merge pull request #3221 from 1038april/fix/remote-team-push-names
Look up players teams by name for client-side pushes on servers
2026-10-28 19:02:05 -04:00
xKumorioandClaude Opus 5.5 a278572906 Three Windows-only test failures in pr_ready
session_lock_is_exclusive read session.lock while its lock was held.
Windows file locks are mandatory, so that read failed with OS error 33.
It now reads the file once the first lock is dropped.

port_publish wrote the store path as str(path) with the home replaced by
"~", which kept Windows backslashes (~\code\...). The path under the home
is now written as ~/ plus its POSIX form, as on Linux. The temporary body
file is opened as UTF-8, so the "→" in a facts line no longer fails to
encode in the Windows code page.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 03:44:55 +05:00
Ludovic MarieandClaude Opus 5.5 78e449714a Merge refactor/spawner-hive-traits (B8 with main) into refactor/horizon-client-render
crash.rs: main's separator-neutral manifest path, with the render crate's suffix.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 23:43:22 +01:00
Ludovic MarieandClaude Opus 5.5 ab0f591673 Merge origin/main into refactor/spawner-hive-traits
#3064's TrialConfig::encode and TrialSpawner::override_entity become
methods of TrialConfigExt and TrialSpawnerExt; game/spawner.rs (#3064)
and lan/chunks.rs (#2952) import the extension traits they now need.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 23:42:52 +01:00
d3grandoandClaude Opus 5.5 2882f0764d parity: catch SIGHUP only where it exists, so parity.py starts on Windows
Python's signal module has no SIGHUP on Windows, so exit_on_termination
raised AttributeError and every parity.py command (--list, --changed,
--status, and pr_ready's local harness run) stopped before doing anything.
SIGTERM is still caught everywhere, SIGHUP wherever the platform has it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 05:41:08 +07:00
xKumorioandClaude Opus 5.5 859b6be783 Kelp stops blinking out far away: animated sprites mipmap over the whole sheet
Vanilla's SpriteContents mipmaps the animation's original image, every
frame of the sheet at once (byMipLevel = {originalImage}, then
MipmapGenerator.generateMipLevels), and AnimatedTexture uploads each frame
from that chain at frameX/frameY >> level. Horizon mipmapped each frame on
its own, so solidify and each level's alpha-to-coverage scale were the
frame's. A 16px frame has a single texel square at its 2x2 level: its
coverage there is 0 or a multiple of 1/16, and the bisection could settle
on a scale that left the whole level under the cutout. Kelp frames 0 and 18
did, so far kelp vanished every time the animation reached them (only with
mipmaps on, as reported).

source_chains stacks the frames into one sheet, runs mipmap::generate over
it and cuts each level back into per-frame chains, so every frame shares
the sheet's coverage scale. A frame alone (source_chain) is unchanged.

Fixes #1703. Fixes #3073.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 03:33:32 +05:00
Ludovic Marie 142388e1c1 Merge pull request #3361 from horizon-rust/launcher-mixins-0d6b103
🤖 Agent: MIXINS_REV bump to 0d6b103; launcher builds green on Linux, macOS and Windows at this head.
2026-10-28 23:31:50 +01:00
d3grandoandClaude Opus 5.5 e8896336da GUI: screens keep vanilla's int layout at any window size
Direct Connection, Add/Edit Server, Join a Friend, Connection Lost, the
death screen and the sign editor's Done button placed their widgets at
width / 2.0 - 100 and height / 4.0 + N in floats. Vanilla's Screen
width and height are ints, so `this.height / 4` drops the remainder;
Horizon kept it, and whenever the GUI height was not a multiple of 4 (a
maximized or tall window: 1009 / 4 = 253 GUI pixels) or the width was
odd, buttons, boxes and centred text landed on a half or quarter GUI
pixel. At scale 3 that is 1.5 device pixels: borders came out thinner on
one side and glyph rows uneven (#3362).

The positions now use int halves and quarters as DirectJoinServerScreen,
ManageServerScreen, DeathScreen and AbstractSignEditScreen do, and
centred text is width / 2 - font.width / 2 (GuiGraphicsExtractor
.centeredText).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 05:27:47 +07:00
Ludovic Marie 93b7ddd09e Merge branch 'main' into launcher-mixins-0d6b103 2026-10-28 23:21:42 +01:00
Ludovic MarieandClaude Opus 5.5 6a221890bd Native mods: recipe tables, recipe book, place and give for native mods (hook points v2)
The item and recipe tables WebAssembly mods read through capability
`recipes` move to game/native_recipes.rs, built in every build (no `mods`
feature), as native types (NativeRecipes, NativeRecipe, NativeItem,
NativeRecipeStack, NativeRecipeKind). Native mods get helpers for them:
native_recipes, native_known_recipes, native_place_recipe and native_give,
refused as recipes.place and recipes.give are. WebAssembly mods read the
same tables, converted once to the runtime's types.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 23:19:12 +01:00
Ludovic Marie a7841ccb18 Merge remote-tracking branch 'origin/main' into feat/launcher-java-command 2026-10-28 23:18:24 +01:00
Ludovic Marie c9a103852c Merge pull request #3406 from horizon-rust/feat/launcher-any-version-download
🤖 Agent: launcher-java-1 verified this head (main merged in for #3402's Windows fix): PR checks green (verify, launcher) and the launcher build matrix green on Windows, macOS and Linux (dry run https://github.com/horizon-rust/horizon/actions/runs/40775800425). No CLI change, so docs/launcher-commands.md is unchanged.
2026-10-28 23:12:15 +01:00
Ludovic Marie a15faef940 Merge pull request #2768 from levzzz5154/fix/world-difficulty-survives-reopen
🤖 Agent: needs-proof check by Ludovic's AI agent (Claude), not by Ludovic himself. Passed on a union of 19 contributor PRs: clippy -D warnings, the touched crates' tests (1391 passed), 80/80 parity harnesses, and lan_e2e with the host on its server thread and in-frame. save round trip main -> PR -> main -> PR: difficulty Hard/Peaceful/Easy read back at each hop and in level.dat difficulty_settings, server thread and --in-frame. Queued for merge at 35581f52e.
2026-10-28 23:12:12 +01:00
Ludovic Marie 58cd73ee0b Merge pull request #2763 from ASOwnerYT/claude/issue-2746-fix-pr-f0eb69
🤖 Agent: needs-proof check by Ludovic's AI agent (Claude), not by Ludovic himself. Passed on a union of 19 contributor PRs: clippy -D warnings, the touched crates' tests (1391 passed), 80/80 parity harnesses, and lan_e2e with the host on its server thread and in-frame. changes test_music_parity.py (music harness PASS in the run). Queued for merge at 985ece811.
2026-10-28 23:12:08 +01:00
Ludovic Marie e2a3e92424 Merge pull request #3011 from feeldev12/feat/integrated-loads-async
🤖 Agent: needs-proof check by Ludovic's AI agent (Claude), not by Ludovic himself. Passed on a union of 19 contributor PRs: clippy -D warnings, the touched crates' tests (1391 passed), 80/80 parity harnesses, and lan_e2e with the host on its server thread and in-frame. every server-thread launch of this run (screenshots, save round trips) loaded through the new async start. Queued for merge at 356b69aea.
2026-10-28 23:12:04 +01:00
Ludovic Marie 6f8c7a0e5d Merge pull request #3012 from feeldev12/feat/connect-screen
🤖 Agent: needs-proof check by Ludovic's AI agent (Claude), not by Ludovic himself. Passed on a union of 19 contributor PRs: clippy -D warnings, the touched crates' tests (1391 passed), 80/80 parity harnesses, and lan_e2e with the host on its server thread and in-frame. screenshot: ConnectScreen with the status and a working Cancel back to the multiplayer list (main: flat screen, no Cancel). Queued for merge at 752fc2170.
2026-10-28 23:09:50 +01:00
Ludovic Marie 04cbda1b14 Merge pull request #2752 from iLostName/faster-server-connect
🤖 Agent: needs-proof check by Ludovic's AI agent (Claude), not by Ludovic himself. Passed on a union of 19 contributor PRs: clippy -D warnings, the touched crates' tests (1391 passed), 80/80 parity harnesses, and lan_e2e with the host on its server thread and in-frame. Queued for merge at de8dd66ac.
2026-10-28 23:09:46 +01:00
Ludovic Marie 2de8bbd84b Merge pull request #2998 from feeldev12/perf/server-loot-preload
🤖 Agent: needs-proof check by Ludovic's AI agent (Claude), not by Ludovic himself. Passed on a union of 19 contributor PRs: clippy -D warnings, the touched crates' tests (1391 passed), 80/80 parity harnesses, and lan_e2e with the host on its server thread and in-frame. Queued for merge at fdefcfeac.
2026-10-28 23:09:42 +01:00
Ludovic Marie a7177cfbd7 Merge pull request #3228 from iLostName/own-death-sound
🤖 Agent: needs-proof check by Ludovic's AI agent (Claude), not by Ludovic himself. Passed on a union of 19 contributor PRs: clippy -D warnings, the touched crates' tests (1391 passed), 80/80 parity harnesses, and lan_e2e with the host on its server thread and in-frame. the server sends no death sound to the dying guest, so it is not doubled. Queued for merge at 7b93b310a.
2026-10-28 23:09:38 +01:00
Ludovic Marie b558a845a0 Merge pull request #3066 from iLostName/item-pickup-one-sound
🤖 Agent: needs-proof check by Ludovic's AI agent (Claude), not by Ludovic himself. Passed on a union of 19 contributor PRs: clippy -D warnings, the touched crates' tests (1391 passed), 80/80 parity harnesses, and lan_e2e with the host on its server thread and in-frame. pickup sound pitch matches handleTakeItemEntity. Queued for merge at 0112a6fea.
2026-10-28 23:09:33 +01:00
Ludovic MarieandClaude Opus 5.5 7826732a5c Launcher: Play starts a Java instance's Minecraft
With a Java instance selected, Play builds its game off the window's
thread (`backend::java_game`: the owner gate, the version and Java
downloaded the first time, their lines in the log), then starts it as
the Horizon client starts. No account that owns the game opens the
sign-in screen, as for Horizon. Joining a friend and quick play stay
Horizon's.

The Play page names the instance's Minecraft version (a Game row in
place of Horizon's resources and Minecraft folder), needs no Horizon
version, and the sidebar shows the version. An old version's logged
session line never reaches the window.

The translations test also reads strings rustfmt moved to the line
after `tr(`/`trf(`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 23:08:34 +01:00
Ludovic Marie 9f1a998403 Merge pull request #2833 from levzzz5154/fix/empty-compressed-packet
🤖 Agent: needs-proof check by Ludovic's AI agent (Claude), not by Ludovic himself. Passed on a union of 19 contributor PRs: clippy -D warnings, the touched crates' tests (1391 passed), 80/80 parity harnesses, and lan_e2e with the host on its server thread and in-frame. zero-length frame rejected as Varint21FrameDecoder does. Queued for merge at 65bca4ce9.
2026-10-28 23:07:10 +01:00
Ludovic Marie 527cd7225c Merge pull request #2816 from sirrayi/fix/malformed-network-input
🤖 Agent: needs-proof check by Ludovic's AI agent (Claude), not by Ludovic himself. Passed on a union of 19 contributor PRs: clippy -D warnings, the touched crates' tests (1391 passed), 80/80 parity harnesses, and lan_e2e with the host on its server thread and in-frame. Queued for merge at 2812cf201.
2026-10-28 23:07:06 +01:00
Ludovic Marie f64fdeaa75 Merge pull request #2813 from sirrayi/fix/guest-block-reach
🤖 Agent: needs-proof check by Ludovic's AI agent (Claude), not by Ludovic himself. Passed on a union of 19 contributor PRs: clippy -D warnings, the touched crates' tests (1391 passed), 80/80 parity harnesses, and lan_e2e with the host on its server thread and in-frame. reach checks match ServerGamePacketListenerImpl.handleUseItemOn/handleBlockBreakAction; digging/mining/instant_digging lan_e2e PASS. Queued for merge at 3be828d01.
2026-10-28 23:07:01 +01:00
Ludovic Marie a0c79c8b26 Merge pull request #2805 from ASOwnerYT/fix/2775-jukebox-offhand-eject
🤖 Agent: needs-proof check by Ludovic's AI agent (Claude), not by Ludovic himself. Passed on a union of 19 contributor PRs: clippy -D warnings, the touched crates' tests (1391 passed), 80/80 parity harnesses, and lan_e2e with the host on its server thread and in-frame. jukebox use read against JukeboxBlock.useItemOn/useWithoutItem. Queued for merge at b38fe43b0.
2026-10-28 23:06:57 +01:00
Ludovic Marie 0284d3bb16 Merge pull request #2952 from kacpherek/fix/lan-block-entity-data
🤖 Agent: needs-proof check by Ludovic's AI agent (Claude), not by Ludovic himself. Passed on a union of 19 contributor PRs: clippy -D warnings, the touched crates' tests (1391 passed), 80/80 parity harnesses, and lan_e2e with the host on its server thread and in-frame. screenshot: the single-player window (server thread) now shows banner patterns and the vault's display item (main: plain banner, empty vault). Follow-up: the trial spawner's mob still does not show on the server-thread window (it does in-frame), not a regression. Queued for merge at 1deb3bd36.
2026-10-28 23:06:54 +01:00
Ludovic Marie 72b4d8ecbb Merge pull request #3395 from horizon-rust/feat/launcher-java-instances
🤖 Agent: launcher-java-1 verified this head: PR checks green (verify, launcher) and the launcher build matrix green on Windows, macOS and Linux (dry run https://github.com/horizon-rust/horizon/actions/runs/40775337700); docs/launcher-commands.md is regenerated.
2026-10-28 23:02:35 +01:00
Ludovic Marie 1ed31e54eb Merge pull request #3062 from kacpherek/fix/shield-arm-and-banner
🤖 Agent: needs-proof check by Ludovic's AI agent (Claude), not by Ludovic himself. Passed on a union of 19 contributor PRs: clippy -D warnings, the touched crates' tests (1391 passed), 80/80 parity harnesses, and lan_e2e with the host on its server thread and in-frame. screenshot: patterned shields in the hotbar and offhand on the single-player window (main: plain). Queued for merge at 99008baad.
2026-10-28 23:00:42 +01:00
Ludovic Marie b3aaa04129 Merge pull request #3055 from kacpherek/fix/horse-feeding-particles-on-server
🤖 Agent: needs-proof check by Ludovic's AI agent (Claude), not by Ludovic himself. Passed on a union of 19 contributor PRs: clippy -D warnings, the touched crates' tests (1391 passed), 80/80 parity harnesses, and lan_e2e with the host on its server thread and in-frame. horse/llama/camel foods match handleEating (no isAgeLocked check: the client has no synced flag). Queued for merge at 9d32bebad.
2026-10-28 23:00:37 +01:00
Ludovic Marie bb38695695 Merge pull request #3052 from kacpherek/fix/hand-drops-after-use-on-server
🤖 Agent: needs-proof check by Ludovic's AI agent (Claude), not by Ludovic himself. Passed on a union of 19 contributor PRs: clippy -D warnings, the touched crates' tests (1391 passed), 80/80 parity harnesses, and lan_e2e with the host on its server thread and in-frame. Queued for merge at e24394ca3.
2026-10-28 23:00:33 +01:00
Ludovic Marie 6bda112225 Merge pull request #3050 from kacpherek/fix/disconnect-no-wait
🤖 Agent: needs-proof check by Ludovic's AI agent (Claude), not by Ludovic himself. Passed on a union of 19 contributor PRs: clippy -D warnings, the touched crates' tests (1391 passed), 80/80 parity harnesses, and lan_e2e with the host on its server thread and in-frame. Queued for merge at a5ee1e588.
2026-10-28 23:00:29 +01:00
Ludovic MarieandClaude Opus 5.5 dcd733c7c1 Launcher: play a Java instance from the command line
`horizon-launcher play --instance <java instance>` starts its Minecraft:
Java Edition. Past the owner gate itself (no developer option), the
version is downloaded from Mojang the first time, the Java it needs is
the instance's own, one found here or Mojang's (downloaded), and the
game starts in the instance's folder as the account that owns it. With
no answer from the services, an owner plays with its last token.

Options only Horizon has (--version, --world, --creative, --join,
--server) are refused for Java instances; --arg passes through. The
window's Play still refuses them and names this command.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 22:59:59 +01:00
Ludovic Marie 77fa9fb89f Merge pull request #3388 from horizon-rust/fix/native-mods-mixins-cargo
🤖 Agent: verify green; launcher-only, one function. pr_ready green (launcher tests 228/228). Parity --changed: nothing selected (lan_integration is nightly-only, and the launcher is not in the LAN suite). Proof end to end with no system Rust: the launcher's toolchain built zoom on macOS (546 s) and Linux (729 s, launcher from this branch's dry-run 40767814775), and zoom's offscreen proof passed on both launcher-built games. Before the fix both stopped at E_METADATA.
2026-10-28 22:58:12 +01:00
Ludovic Marie 153d18a102 Merge pull request #3346 from horizon-rust/feat/native-mod-settings
🤖 Agent: verify green. pr_ready on the Mac mini (vanilla data): fmt, clippy, horizon-client 1264 tests, Python 401, parity 75 affected, 74 passed, none failed. Earlier offscreen proofs: the four native mods listed ON with working settings pages. New on/off commit tested by mod_list::tests::toggles_installed_and_native_mods_but_not_dev_ones; horizon-mods#22's mods build on this head with native_on (check all ok).
2026-10-28 22:55:57 +01:00
spiderandClaude Opus 5.5 e2b2b2ccd0 Players wear their capes: CapeLayer, the cloak's swing, and the elytra in the cape's texture
A player's cape texture was looked up and downloaded, then thrown away: nothing kept the image
and nothing drew it. Now:
- Cape downloads go into the skin slots as `capes/<sha1>` (SkinManager's cape cache), a 64x32
  texture in the top half of a 64x64 slot; an evicted one uploads again from memory or the disk
  cache. A larger cape is scaled to 64x32, the older 22x17 fits as it is.
- `PlayerSkin.cape` reaches the player models (other players and the local one), which load
  `player#cape` (PlayerCapeModel).
- CapeLayer draws it: no cape under an elytra's wings, moved out over a chestplate, hidden with
  the Cape skin-customisation option (the local player). PlayerCapeModel.setupAnim turns the cape
  by `ModelPart.rotateBy` with AvatarRenderer.extractCapeState's flap and leans, from
  ClientAvatarState's cloak (moveCloak: a quarter of the way each tick, snapping from 10 blocks),
  its bob and walked distance; other players tick theirs as RemotePlayer does.
- WingsLayer.getPlayerElytraTexture: a player with a cape wears it on the elytra.
- `HORIZON_OFFLINE_CAPE`, which the launcher sets for an offline name, is the local player's
  cape when its profile has none.

Other players' Cape option (Avatar.DATA_PLAYER_MODE_CUSTOMISATION) is left to #2971, which
tracks it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 22:55:56 +01:00
Ludovic MarieandClaude Opus 5.5 95ecc9d771 Merge main into fix/dyed-armor-colour
main's e7f5201c1 colours the armour stand's layers with its own layer_color; keep it there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 22:54:04 +01:00
Ludovic Marie e470f3a0c9 Merge remote-tracking branch 'origin/main' into feat/launcher-any-version-download 2026-10-28 22:53:45 +01:00
Ludovic Marie f93c28a230 Merge pull request #2932 from RVZO6/fix/dig-down-jitter
🤖 Agent: needs-proof check by Ludovic's AI agent (Claude), not by Ludovic himself. Passed on a union of 19 contributor PRs: clippy -D warnings, the touched crates' tests (1391 passed), 80/80 parity harnesses, and lan_e2e with the host on its server thread and in-frame. Queued for merge at 32a0a11fe.
2026-10-28 22:53:41 +01:00
Ludovic Marie b9721d816d Merge pull request #2809 from socksprox/fix/1528-portal-arrival
🤖 Agent: needs-proof check by Ludovic's AI agent (Claude), not by Ludovic himself. Passed on a union of 19 contributor PRs: clippy -D warnings, the touched crates' tests (1391 passed), 80/80 parity harnesses, and lan_e2e with the host on its server thread and in-frame. Queued for merge at 9fcfdaa1e.
2026-10-28 22:53:37 +01:00
Ludovic Marie a49e72201a Merge branch 'feat/launcher-java-instances' into feat/launcher-java-play 2026-10-28 22:51:29 +01:00
Ludovic Marie 2034db1df6 Merge remote-tracking branch 'origin/main' into feat/launcher-java-instances 2026-10-28 22:50:15 +01:00
Ludovic MarieandClaude Opus 5.5 5091896e2a Launcher: the real game's command from its version JSON
java_launch builds the command that starts Minecraft: Java Edition as
the official launcher reads a version file: mainClass; the classpath of
this system's libraries (rules with os name and arch, duplicates once)
and the client jar; arguments.jvm / arguments.game with their rules
(every optional feature off) and ${...} placeholders, or the old
minecraftArguments line with -Djava.library.path and -cp; an old
version's natives unpacked without extract.exclude; virtual and
map_to_resources asset indexes laid out by name; Mojang's logging setup
(-Dlog4j.configurationFile) plus -Dlog4j2.formatMsgNoLookups=true;
-Xmx from the instance and the player's JVM arguments.

Nothing reaches the network; the next PR wires it to Play.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 22:50:02 +01:00
Ludovic Marie 6edc4e950d Merge pull request #3402 from horizon-rust/fix/launcher-java-test-windows
🤖 Agent: verified: launcher build matrix dry run 40773579704 green on Windows, macOS and Linux (java::tests::mojangs_runtimes_are_found_in_their_layout passes on Windows); PR checks green. Test-only fix for main's Windows build after #3386.
2026-10-28 22:49:19 +01:00
Ludovic MarieandClaude Opus 5.5 d4120a9ef0 Launcher: download the version's logging setup with it (Log4Shell fix for 1.7-1.18)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 22:45:58 +01:00
Ludovic MarieandClaude Opus 5.5 795a0db7e4 Launcher: download any Minecraft version for an owner, natives included
The owner-gated download learns versions Horizon doesn't pin, for Java
instances: Pins::for_version(id, sha1) takes a version from Mojang's
list; its version file is checked against that sha1, and the client's
sha1 then comes from the checked file. backend::download_version_for_owner
puts it in Horizon's own game folder through the same path as the
pinned one (download_for_owner asks the strict gate again, then
download_for → mojang::download), so the one download call site and
its gate test hold.

Versions before 1.19 keep a library's natives as a classifier of their
own (natives map, ${arch} for Windows): mojang::download now fetches
this system's, checked like every other file.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 22:42:25 +01:00
Ludovic MarieandClaude Opus 5.5 75b8a176d4 Launcher tests: Mojang runtime fixture with Windows' java.exe
java::tests::mojangs_runtimes_are_found_in_their_layout failed on
Windows (main after #3386): the fixture's homes had only bin/javaw.exe
there, and the runtime search (horizon_prepare::java::minecraft_runtimes)
looks for bin/java.exe, so it found nothing. Mojang's Windows runtimes
ship both, so the fixture now does too. The platform folder comes from
mojang::java_platform, the name the download lays out, instead of a
hard-coded mac-os-arm64.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 22:35:49 +01:00
Ludovic MarieandClaude Opus 5.5 21a0656530 docs: launcher command reference for the Java instance commands
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 22:32:11 +01:00
Ludovic Marie 071580e716 Merge pull request #3400 from horizon-rust/launcher-commands-doc
🤖 Agent: docs-only fix for the launcher reference test (java list/install sections); all checks green at this head.
2026-10-28 22:30:19 +01:00
Ludovic Marie ae01d872fb Merge remote-tracking branch 'origin/main' into feat/launcher-java-instances 2026-10-28 22:26:47 +01:00
Ludovic Marie d2f5a0d678 Merge pull request #3398 from horizon-rust/tests-ignore-port-probe
🤖 Agent: tests ignore T3's GET / port probe; account::tests 17/17 on the Mac mini (the_whole_chain failed before); merger waits for parity (online_mode).
2026-10-28 22:26:12 +01:00
Ludovic MarieandClaude Opus 5.5 d6ee462614 docs: the launcher reference lists the java commands
#3386 added `horizon-launcher java list|install` without regenerating
docs/launcher-commands.md, so the_launcher_reference_is_up_to_date failed on main's
launcher builds (and would stop the next launcher release).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 22:25:41 +01:00
Ludovic Marie 430e5e4c92 Merge pull request #3352 from horizon-rust/feat/native-mods-carry-settings
🤖 Agent: verify green. pr_ready: all green except the launcher test account::tests::the_whole_chain, which fails on this Mac only (a desktop browser's extra GET / on the device-code page); it passes in CI's launcher job. Local parity of this tree (Mac mini, --changed origin/main): 36/37, online_mode fails identically on main here (environment). Test the_native_mods_instance_gets_the_mods_settings_once passes.
2026-10-28 22:22:02 +01:00
Ludovic Marie f3d8535fcd Merge pull request #3348 from horizon-rust/ci/train-builds-nightlies
The batch train builds Nightlies: no more vX.Y.Z game releases
2026-10-28 22:15:22 +01:00
d3grandoandClaude Opus 5.5 8f00332979 Mod screens: a list's rows are cut at its frame
A mod screen's `list` drew one row more than its frame holds (the row a
scroll shows in part), uncut, so without scrolling the 7th row of a long
list sat below the frame, over the screen. As vanilla's
AbstractSelectionList, which draws its entries between enableScissor and
disableScissor, the rows (with their hover and selection marks) are now cut
at the frame's inside: the partly shown row is drawn in part. Fixes #3237.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 04:15:07 +07:00
Ludovic Marie b276d65b5f Merge branch 'main' into launcher-mixins-0d6b103 2026-10-28 22:13:02 +01:00
Ludovic MarieandClaude Opus 5.5 559ecb02f6 tests: ignore the bare GET / a local tool sends to every new port
T3 Code probes each new localhost listener with GET / (seen on the Mac mini: the t3
process). The online_mode harness and account::tests::the_whole_chain recorded it as an
unexpected call and failed there only. Neither fake service has a root, so it is ignored.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 22:11:32 +01:00
Ludovic Marie 11b8757bc7 Merge pull request #3386 from horizon-rust/feat/launcher-java-runtimes
🤖 Agent: verified: pr_ready --quick green on the Mac mini (233 launcher tests), CI launcher/verify/parity-scope green; java install 21 checked end to end in scratch homes (Mojang's runtime runs, real folders untouched).
2026-10-28 22:07:50 +01:00
Ludovic MarieandClaude Opus 5.5 b44da42ca4 Launcher: Minecraft: Java Edition instances (model, versions, CLI)
A Java instance is an own instance whose instances.json entry carries a
`java` section: the Minecraft version it plays and, optionally, its own
Java home, memory and JVM arguments. It gets an empty game folder (the
real game keeps its own options); a copy keeps the section.

- minecraft_versions: Mojang's public version_manifest_v2 (releases,
  snapshots, old betas and alphas, latest), kept in the data folder so
  the list shows offline. Only metadata: game files stay owner-gated.
- Play refuses a Java instance with a clear sentence (resolve_version
  and instances::apply, so the window, the CLI and MCP all say it) until
  the launcher starts the real game.
- CLI: instance create <name> --minecraft <id|latest|latest-snapshot>,
  instance versions [--snapshots] [--old], instance java <instance>
  [--home|--auto] [--memory] [--args]; instance list shows the version.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 22:04:34 +01:00
Ludovic Marie 9669600c38 Merge pull request #3323 from horizon-rust/feat/native-hooks-api-build
🤖 Agent: verify green; launcher-only (native_mods.rs, index native.rs). pr_ready green, launcher tests pass. Local parity of #3352's tree (which contains this PR): all affected harnesses pass except online_mode, which fails identically on main on this Mac (environment).
2026-10-28 21:58:51 +01:00
Ludovic Marie 528c322d16 Merge pull request #3321 from horizon-rust/feat/official-mods-turn-native
🤖 Agent: verify green. pr_ready green. Local parity (Mac mini, --changed origin/main): 36 of 37 pass. online_mode fails identically on main on this Mac (a stray GET / to its fake service, IndexError), so it is not this change. Registry test a_mod_may_turn_native_once and launcher test a_mod_that_turned_native_keeps_its_hmod_until_removed pass.
2026-10-28 21:58:46 +01:00
Ludovic MarieandClaude Opus 5.5 a9f8e64331 Native mods: horizon-mixins finds the launcher's cargo
`horizon-mixins apply` runs `cargo metadata`, but the launcher started it as a
plain command: on a computer with no Rust of its own every native build stopped
at E_METADATA. It now gets the launcher's toolchain like the build does.
Found by building zoom end to end with no system Rust, on macOS and Linux.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 21:50:20 +01:00
Ludovic MarieandClaude Opus 5.5 01d0f45c2d Native mods: turn one off in Options > Mods
A native mod's row now toggles too. Off writes `mod.<id>:false`, so the
launcher's next build leaves the mod out, and `native_on` tells the mod to rest
until then; on again writes `true`. The WebAssembly row of the same id is no
longer listed: the native row stands for the mod. Asked by levzzz5154.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 21:46:13 +01:00
Ludovic MarieandClaude Opus 5.5 228af35f2a Launcher: Java runtimes for Minecraft: Java Edition instances
The first piece of Java instances: which Java a version needs and where
it comes from, as other launchers do it.

- java::Need reads a version JSON's javaVersion (major + Mojang
  component); versions from before that field ran Java 8 (jre-legacy).
- Runtimes are found without starting them, from each Java home's
  release file: Mojang's that Horizon downloaded (minecraft/runtime/ in
  the data folder, the one preparing uses) and installed ones (JAVA_HOME,
  the PATH, the usual installer folders).
- pick: an instance's own Java home, else the exact major (Mojang's
  first, never a newer one by itself), else None = download Mojang's.
- install downloads Mojang's runtime through mojang::java_runtime
  (sha1-checked, resumable), refusing on Linux without glibc.
- CLI: horizon-launcher java [list|install <major|component>].

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 21:45:05 +01:00
Ludovic Marie abd1755f95 Merge pull request #2770 from socksprox/fix/2594-allay-travel-flying
🤖 Agent: read the diff at c216561da: `Allay.travel` -> `travelFlying(input, getSpeed())` via a new `travel_flying_speeds`, and `WaterAvoidingRandomFlyingGoal.getPosition` stroll targets. verify green, merges cleanly; the merger lands it once this head's parity run (queued) passes.
2026-10-28 21:42:59 +01:00
degrandoandClaude Opus 5.5 81f339814b Native mods build on Windows: MinGW-w64 with the toolchain, and a stage path horizon-mixins takes (#3377)
On Windows a native build stopped twice before compiling anything, then once while compiling:
- `windows-sys` 0.61 imports through raw-dylib, and on windows-gnu rustc makes those import
  libraries with dlltool, which runs the GNU assembler. rust-mingw's dlltool isn't on the build's
  PATH, and rust-mingw has no assembler, so even horizon-mixins didn't build.
- `canonicalize` gives a verbatim path there (`\\?\C:\...`), and horizon-mixins' output check stats
  `\\?\C:`, the volume itself: os error 1, E_IO.
- `ring`, `hidapi` and wasmtime compile C with `cc`, and rust-mingw's gcc only links.

The toolchain now brings MinGW-w64 on Windows (WinLibs' GCC 16.2.0 on MinGW-w64 14.0.0, MSVCRT
like rust-mingw, one zip pinned by SHA-256, its mingw64/ without the documentation) and puts its
bin/ on the build's PATH after Rust's. A toolchain installed before it counts as not installed, so
the next build installs it. The stage path loses the verbatim prefix of a drive path.

Measured on Windows 11 (Ryzen 7 9800X3D) with Veinminer 0.3.0 on Nightly 83f15fa: the launcher
installs the toolchain and builds the game in 8 min 3 s (6 min 57 s compiling), and the mod works
in game.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-29 01:34:22 +05:00
Ludovic Marie 0590a85d16 Merge pull request #3378 from horizon-rust/launcher-windows-tests
🤖 Agent: Windows launcher tests 219/219 pass with this (they failed on main: server delete Access denied, CRLF test); the remaining smoke failure is #3375's fix.
2026-10-28 21:32:38 +01:00
Ludovic Marie be6beb6ec7 Merge pull request #3375 from horizon-rust/ci-launcher-smoke-nightly
🤖 Agent: smoke test glob for the Nightly folder (#3342 broke every launcher build's smoke); CI green.
2026-10-28 21:30:32 +01:00
Ludovic MarieandClaude Opus 5.5 9cdfc5bd44 launcher: two Windows-only failures (server delete, a test's line ends)
- Deleting a stopped server always failed on Windows ("Access is denied"): the folder was
  moved while its lock file was open. The lock is released just before the move there; a
  supervisor starting in between holds it again and the move fails, so a live server stays.
- mojang_downloads_only_for_an_owner searched source text for "\n", which a Windows
  checkout writes as CRLF.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 21:20:49 +01:00
Ludovic MarieandClaude Opus 5.5 26af55000a launcher CI: the smoke test finds the installed Nightly
Since #3342 `install latest` installs the Nightly into versions/nightly-<date>-<commit>,
so the vX.Y.Z glob found nothing and every launcher build's smoke test failed (it also
blocks the next launcher release). Any version folder now, and a clear error when none.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 21:18:49 +01:00
Ludovic Marie 3782473f52 Merge pull request #3358 from horizon-rust/nightly-approved-only
🤖 Agent: Nightly approval rule (Ludovic 10-28); tests.test_nightly + test_nightly_set 43 pass; CI green.
2026-10-28 21:15:30 +01:00
Ludovic Marie 9cbc96ee7b Merge pull request #3354 from horizon-rust/feat/launcher-owner-play
🤖 Agent: verified: pr_ready --quick green on the Mac mini (226 launcher tests), CI launcher/verify/parity-scope green; Play prepares then plays for an owner, download path and owner gate unchanged.
2026-10-28 21:12:53 +01:00
Ludovic Marie af0fef9196 Merge pull request #2754 from iLostName/server-list-f5-refresh
🤖 Agent: read the diff at 4c4fcedf4: F5 refreshes the server list (`JoinMultiplayerScreen.keyPressed` -> `refreshServerList`), only on the list screen. verify green, merges cleanly; the merger lands it once this head's parity run (queued) passes.
2026-10-28 21:09:32 +01:00
Ludovic MarieandClaude Opus 5.5 5a2d71d2ea launcher: build native mods with horizon-mixins 0d6b103
levzzz5154's update (optional public visibility for support modules); bundles that
pin their own mixins revision keep it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 21:09:24 +01:00
Ludovic MarieandClaude Opus 5.5 bbec03c14f Nightly: only pull requests the Team or Ludovic's agents approved at that head
The Nightly is the version every player installs: /nightly set and add refuse a pull
request without an approving review at its current head from a trusted reviewer or
ludovic111 (never its author). The scheduled build keeps the approved head when a newer
one waits for review, and drops pull requests nobody approved. Dry runs need none.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 21:03:07 +01:00
Hamza CoşkunandClaude Opus 5.5 0e2b244120 Send an armor stand's custom name and its visibility to its clients
The server never sent DATA_CUSTOM_NAME or DATA_CUSTOM_NAME_VISIBLE for an
armor stand, so hologram text didn't show for its clients: single player on
its server thread, LAN guests and dedicated servers. Both are sent now, and go
back to none / false when /data takes them away.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 22:57:42 +03:00
Hamza CoşkunandClaude Opus 5.5 835674f45d Draw an invisible armor stand's equipment, not just skip it
LivingEntityRenderer.submit gives an invisible stand no body render type but
still submits its layers: held items, the custom head and the armour. The
renderer skipped the whole stand instead, so floating heads, item displays and
armour on invisible stands never showed, in-frame and on any server that sends
the invisible flag (vanilla, Paper, Minestom). Only the wooden body is skipped now.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 22:57:42 +03:00
ThatCakeIsAPieandClaude Opus 5.5 c6efb367b0 launcher: drop an unused import in the Windows make_link
9f1fa0a0a moved the mklink call into junction(), which imports
CommandExt itself; make_link kept its own copy, unused, so the
launcher's clippy with warnings denied fails on Windows.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:57:13 -05:00
Ludovic Marie 3c9bb38e88 Merge pull request #3345 from horizon-rust/feat/native-hook-points
🤖 Agent: verify green. pr_ready on this stack (with #3346): clippy, 1264 horizon-client tests, parity 76 affected all passing on the Mac mini. With no native mod every hook is an empty call. Proof: the four official native mods (horizon-rust/horizon-mod-*) apply with horizon-mixins and play on it, alone and together, in an offscreen game driven by horizon-ctl.
2026-10-28 20:54:06 +01:00
Ludovic MarieandClaude Opus 5.5 dc943f95e8 Launcher: Play downloads and prepares Minecraft for an owner
An owner with no Minecraft on the computer no longer needs to find
"Download and prepare": Play on a version not prepared yet prepares it
(from the player's install when there is one, else downloaded from
Mojang for the signed-in owner, Java included) and then starts the game,
as the official launcher does. Without an owner Play stays off.

With no install found, the Resources row offers the download alone and
the Minecraft folder row says none is needed. The CLI's not-prepared
error and the READMEs stop asking for a local install and Java.

The download path itself (download_for_owner, mojang.rs) is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 20:51:58 +01:00
Ludovic Marie 312218a47f Merge pull request #3087 from Domanffe/fix/crash-report-relative-paths
🤖 Agent: read the diff at da9950c75: crash reports strip the workspace prefix after turning `` into `/`, so Windows builds shorten paths too; other hosts unchanged. verify green. Queued for the merger.
2026-10-28 20:50:24 +01:00
Ludovic Marie 83b5c5ed53 Merge pull request #3075 from Domanffe/chore/hooks-find-python
🤖 Agent: read the diff at e8cb6ae83: the pre-commit and pre-push hooks run check_public_tree.py with the first of python3/python/py that really runs Python 3 (Git for Windows' Store stub exits non-zero), and fail loudly if none does. verify green. Queued for the merger.
2026-10-28 20:50:19 +01:00
degepieandClaude Opus 5.5 2acf1d7798 World list: show Hardcore Mode in red, and adventure and spectator by name
The Select World list built its info line from a creative flag only, so a
hardcore world read "Survival Mode" and adventure and spectator worlds read
"Survival Mode" too (#3298). LevelSummary.createInfo uses `gameMode.hardcore`
in CommonColors.RED (-65536) for a hardcore world, else `gameMode.` + the
GameType's name, then commands and the version. WorldEntry now carries the
level's game mode and hardcore flag (both already read from level.dat), and
`world_info` builds that line as styled text. The horizon-ctl JSON keeps its
`creative` / `mode` fields as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-10-29 00:48:01 +05:00
Ludovic Marie 578cd262a5 Merge pull request #3342 from horizon-rust/feat/launcher-nightly-only
🤖 Agent: verify, launcher and parity-scope green at a1bdb0d22. pr_ready: launcher fmt/clippy clean, launcher tests 2 new (every launcher updates to the current Nightly; players of the newest release move to it), the only failure account::tests::the_whole_chain also fails on main on this Mac mini. CLI end to end in scratch homes: install latest / update installed nightly-<date>-<commit>, checksums signed for the nightly channel, worlds untouched. Heads-up posted in #horizon-team.
2026-10-28 20:46:38 +01:00
Jose Gil c9bd057d70 feat(launcher): add Spanish language support 2026-10-28 15:43:54 -04:00
Ludovic MarieandClaude Opus 5.5 868256f1a8 The batch train builds Nightlies: no more vX.Y.Z game releases
Step 5 of Nightly only (Ludovic, 2026-10-28). release.yml keeps its triggers and its decision
(5 player-facing pull requests settled 5 minutes, or the oldest 6 hours, release-now,
skip-release, RELEASES_PAUSED, release-blocker, a commit where checks and parity-main passed),
counted since the commit the published Nightly was built on. When it decides, it starts
`nightly build` on that commit (`base`) instead of building vX.Y.Z:

- release_train: the last release of the game is the published `nightly`; one Nightly build at a
  time and at most 12 a day (nightly_set's cap), except for release-now and force (`urgent`).
- nightly-build.yml: an `urgent` input passes the cap; the notes say the launcher installs it.
- docs: release-flow (Game releases, Nightly only, Notes, Rolling back), versioning.

The last vX.Y.Z stays GitHub's latest for launchers and servers from before the switch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 20:41:56 +01:00
Ludovic MarieandClaude Opus 5.5 1149c92cb8 Native mods: a settings page in Options > Mods
Native mods list themselves (native_mods) and give their settings page's
buttons (native_settings, native_settings_click); their keys show as Key
Binds rows and the player's choice is kept as the WebAssembly mods' keys
were (key.<id>.<name> in horizon-mods.txt). A native mod takes over the
WebAssembly mod of the same id, whose row says so.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 20:39:52 +01:00
Ludovic MarieandClaude Opus 5.5 360272fd79 Native mods: stable hook points (game/native_hooks.rs)
Empty, cfg-free and macro-free functions the game calls at fixed places
(frame, tick, HUD corners, keys, wheel, chat commands, zoom, minimap), for
horizon-mixins bundles to hook with HEAD, and helpers that read and draw
what Horizon's HUD extras do and keep a mod's settings in the WebAssembly
mod's own files. NATIVE_HOOKS_API = 1 names their version, for the
launcher to build mods made for it on any Horizon that has it. A test
keeps cfg and macros out. The frame and click counters now run without
WebAssembly mods, and the HUD extras always place their corners.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 20:39:52 +01:00
Ludovic Marie 1d6d4cf0a0 Merge pull request #3335 from horizon-rust/t3/launcher-developer-options
🤖 Agent: launcher fmt/clippy/223 tests + pr_ready green; gate tests off/on; mojang_downloads_only_for_an_owner pins no download without an owner
2026-10-28 20:38:18 +01:00
Ludovic MarieandClaude Opus 5.5 a1bdb0d22d Launcher: the Nightly is the only game build, for everyone
Step 3 of Nightly only (Ludovic, 2026-10-28). Every launcher installs and updates only the
Nightly, with no setting:

- The update check reads the `nightly` pre-release from the release list it already fetches and
  offers it as the update, installed as `nightly-<date>-<commit>` after its checksums signed for
  the Nightly channel check. Nightlies rank after every release (`ReleaseOrder::Nightly`); a
  second build of the same day is an update too.
- A player on the newest release, on a Nightly or on nothing moves to the new Nightly at the next
  check; a release picked by hand and instances pinned to a version stay. Worlds live in the
  game's folder, so nothing touches them. Older Nightlies go once nothing plays them.
- The "Nightly builds" setting, its separate install path and its `nightly` key are gone; the old
  "Nightly" instance stays and keeps following the Nightly.
- Versions lists the current Nightly, the last week's and the releases from before; the update
  line names a Nightly by its title.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 20:36:24 +01:00
Ludovic Marie 930252e2e4 Merge pull request #3326 from horizon-rust/ci/nightly-green-base
🤖 Agent: verify green; pr_ready on the Mac mini (399 Python tests incl. new nightly notes tests, launcher clippy/tests); notes checked on real data (56 PRs since Nightly 9b772b2). Step 1 of Nightly only: the Nightly still opt-in, now built on main's newest green commit and held by release-blocker.
2026-10-28 20:36:15 +01:00
Ludovic Marie 7dba3b4d73 Merge pull request #3331 from horizon-rust/feat/servers-follow-nightly
🤖 Agent: verify green; pr_ready on the Mac mini (horizon-server + horizon-release-sign 66 tests incl. new Nightly ordering and signature tests); install.sh installed the real current Nightly end to end in temp folders. Step 2 of Nightly only: servers follow the Nightly.
2026-10-28 20:34:26 +01:00
Jose Gil 8339f6d12f fix(launcher): sync clipboard on Linux Wayland and X11 2026-10-28 15:34:15 -04:00
Ludovic Marie e0bf5f6599 Merge pull request #3329 from horizon-rust/fix/portal-rider-vehicle-timer
🤖 Agent: verify green on 74c2d13d77. New lan_e2e check portal_rider (--server-thread): fails before the review change (rider left after 75 ticks, its own 80-tick delay), passes after (5 ticks, arrives in the Nether on the same horse); portals check passes. Single player on the server thread: through in 0.33 s, still riding the same horse; in-frame unchanged. pr_ready all green (1272 horizon-client tests, clippy, 401 Python tests); parity --changed: all 41 affected harnesses pass.
2026-10-28 20:32:24 +01:00
Ludovic MarieandClaude Opus 5.5 c7504433c0 launcher: developer options section
A hidden Settings section, off by default; nothing changes for players.
Game downloads stay behind the existing owner check, which a test pins.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 20:29:03 +01:00
Ludovic MarieandClaude Opus 5.5 41266ae70b Native mods: the Native mods instance gets the mods' settings from the default home
Mods installed in the launcher's default home build into the Native mods
instance, which starts empty: a player moving from the WebAssembly Zoom,
Minimap, FPS Counter or Keystrokes to its native version lost their
settings there. The build now copies each mod's storage folder and key
lines from the default home into the instance, once: nothing the instance
already has is overwritten.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 20:25:34 +01:00
Ludovic Marie a3acb5f0d2 Merge pull request #3082 from Domanffe/test/port-harness-symlink-skip
🤖 Agent: read the diff at f4d6f9c0e: test_port_harness skips the reports-link case where the host can't make symlinks. verify green. Queued for the merger.
2026-10-28 20:22:19 +01:00
Ludovic Marie 49b77529dd Merge pull request #2811 from ESHAYAT102/feat/server-delete-confirmation
🤖 Agent: read the diff at 84390f6dc: Delete server in its settings: refused unless the server is in the list, not installed as a service, and its supervisor lock (`.horizon/lock`) can be taken; the folder goes to the system trash, never `rm`. Unit tests cover a live lock, a non-server folder and a failing trash. verify and launcher green; no harness reaches it. Queued for the merger, which waits for this head's parity run.
2026-10-28 20:22:15 +01:00
Ludovic Marie dafa794cb3 Merge pull request #2762 from ESHAYAT102/fix/stopped-server-settings
🤖 Agent: read the diff at bcdbc252f: the sidebar's stopped-server entry now goes through `open_server`, which loads the server's settings instead of only setting `servers.open`. verify and launcher green; no harness reaches launcher code. Queued for the merger, which waits for this head's parity run.
2026-10-28 20:22:09 +01:00
Cosmic b7df7bd9e0 Merge pull request #3318 from degepie/fix/accept-26.3-republish-5
Prepare: accept Mojang's fifth 26.3 republish (5df1795e, index 917ff6d7)
2026-10-28 15:17:44 -04:00
Ludovic MarieandClaude Opus 5.5 40e2fe0608 Servers follow the Nightly: horizon-server update, install.sh and install.ps1
Step 2 of making the Nightly the only build players get (Ludovic, 2026-10-28). A server runs what
its players run:

- `horizon-server update` (and the supervisor's check, the panel's update) reads the `nightly`
  release, GitHub's latest while there is none, checks its SHA256SUMS signed for the Nightly
  channel and installs it as `nightly-<date>-<commit>`. Nightlies order after every release by
  date; another build of the same day is an update too (`update_available`).
- install.sh and install.ps1 install the current Nightly by default, under the same name.
- horizon-release-sign: `nightly_version` and `nightly_date`, shared with the launcher next.

Servers on an older horizon-server keep the last vX.Y.Z (GitHub's latest) until the release that
carries this one: it moves them over at their next update.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 20:11:22 +01:00
Shreyas 0320e7d593 Piston head: destroy fitting base without drops in creative mode
In creative mode, playerWillDestroy on an extended piston head destroys
the base piston without dropping resources, matching vanilla Java Edition
PistonHeadBlock.playerWillDestroy.

Fixes #3317
2026-10-29 00:39:39 +05:30
Ludovic Marie a1e56a4d1b Merge pull request #3284 from horizon-rust/perf/sign-text-cache
🤖 Agent: verified. 3458 text signs in view, same scene/build profile, Mac mini: 124 → 222 fps (CPU 8.01 → 4.46 ms/frame), no-sign view unchanged (~1620 fps); look unchanged (pixel diff 6/268800, below run-to-run noise). New unit test checks the cached text against the uncached renderer across text/glow/state/font changes; entity batch equivalence test passes. Local: fmt, clippy -D warnings, horizon-client tests, Python tests and the 50 parity harnesses parity.py --changed picks all pass; CI verify green.
2026-10-28 20:03:48 +01:00
Ludovic Marie ab32a64d08 Merge pull request #2791 from levzzz5154/fix/spawn-egg-baby
🤖 Agent: read the diff at 70068076f: spawn-egg offspring rule split into `spawn_egg_is_for` / `spawn_egg_offspring` (`SpawnEggItem.spawnOffspringFromSpawnEgg`), behaviour unchanged, with a test. verify green, parity green on this head (merged into main), merges cleanly.
2026-10-28 20:03:44 +01:00
Ludovic Marie f843f28a27 Merge pull request #2748 from levzzz5154/fix/turtle-walk-animation-rate
🤖 Agent: read the diff at 186198afe: test only: the turtle's limb speed follows its walk (`LivingEntity.updateWalkAnimation`, `TurtleMoveControl`). verify green, parity green on this head (merged into main), merges cleanly.
2026-10-28 20:01:08 +01:00
Ludovic Marie abcc2e7c80 Merge pull request #3238 from masa-masa-0314/fix/show-dialog-click
🤖 Agent: read the diff at 4d477067c ("Chat and book links: a show_dialog click event opens its dialog") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 20:01:04 +01:00
Ludovic Marie ac78481794 Merge pull request #3210 from PH4N7OMx/codex/windows-default-browser
🤖 Agent: read the diff at 998c94bee ("fix(windows): open URLs with the registered protocol handler") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 20:00:59 +01:00
Ludovic Marie 650601848f Merge pull request #3127 from kacpherek/fix/shulker-bullet-model
🤖 Agent: read the diff at 4a18a4bde ("Shulker bullets are drawn as the spinning bullet model, not a flat spr") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 20:00:55 +01:00
Ludovic Marie f9ea299952 Merge pull request #3124 from kacpherek/fix/mob-crowd-push
🤖 Agent: read the diff at f6b467eba ("Crowded mobs take cramming damage, as vanilla") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 20:00:50 +01:00
Ludovic MarieandClaude Opus 5.5 52dc27fa40 Nightly: build on main's newest green commit, hold for release-blocker, write player notes
Step 1 of making the Nightly the only build players get (Ludovic, 2026-10-28). Before every
launcher follows it, a Nightly gets a release's safety:

- `nightly.py pick` builds on main's newest commit where checks and parity-main passed
  (release_train.green_main_head), or the new `base` input, instead of main's tip; no green commit
  builds nothing.
- `publish` holds while a release-blocker is open (dry runs only warn, so a blocker's fix can
  still be tried with `/nightly dry-run`).
- The union/* fallback goes: with nothing picked a Nightly is main alone, so players never flip
  between a union and main from one build to the next.
- The notes list what reached main since the previous Nightly (New, Fixed, known issues,
  thanks), as a release's did, then the picked pull requests.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 20:00:49 +01:00
Ludovic Marie b6eb028bb6 Merge pull request #3100 from Domanffe/perf/name-tags-in-range-first
🤖 Agent: read the diff at 5f8afb17f ("Name tags: copy a custom name only inside the tag's range") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:57:52 +01:00
Ludovic Marie 9dffa8a2a9 Merge pull request #3097 from Domanffe/perf/effect-panel-strings
🤖 Agent: read the diff at f7bb1ceb7 ("Effect panels: borrow the name and reuse the icon key") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:57:48 +01:00
Ludovic Marie cd89bc6dc1 Merge pull request #3095 from Domanffe/perf/server-row-sprite-path
🤖 Agent: read the diff at b9494af97 ("Server list: build each row's sprite key once") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:57:44 +01:00
Ludovic Marie 8a863ad378 Merge pull request #3092 from Domanffe/perf/creative-tab-by-reference
🤖 Agent: read the diff at d1ead6a5f ("Creative screen: draw the selected tab from a reference") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:57:40 +01:00
Ludovic Marie a27561da88 Merge pull request #3086 from Domanffe/perf/draw-slot-item-by-reference
🤖 Agent: read the diff at 3511d556e ("Inventory draw: a slot is drawn from a reference, not a copy") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:57:36 +01:00
Ludovic Marie ed7213df2e Merge pull request #3083 from Domanffe/perf/hud-paint-reuses-sprite-key
🤖 Agent: read the diff at 4bf633e8a ("HUD paint: build the sprite key in one reused buffer") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:54:50 +01:00
Ludovic Marie b1b27240d4 Merge pull request #3070 from iLostName/boat-view-clamp-every-turn
🤖 Agent: read the diff at a1b32304a ("boat: the view stops at 105 degrees at every turn, so it doesn't jitte") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:54:45 +01:00
Ludovic Marie 8a2a31e6a3 Merge pull request #3069 from iLostName/no-hitting-ridden-mob
🤖 Agent: read the diff at 7934a0b18 ("riding: the crosshair skips the mob being ridden, so it can't be hit") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:54:41 +01:00
Ludovic Marie 0264b39aa6 Merge pull request #3068 from iLostName/creeper-death-message
🤖 Agent: read the diff at 992c63eef ("death message: a creeper's blast names the creeper, not the last mob t") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:54:37 +01:00
Ludovic Marie 83935015cf Merge pull request #3067 from iLostName/pickup-fills-offhand
🤖 Agent: read the diff at a967ca434 ("item pickup: a stack in the off hand fills before the inventory, as ge") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:54:33 +01:00
Ludovic Marie 506a9c8f01 Merge pull request #3065 from iLostName/f1-only-without-screen
🤖 Agent: read the diff at c0b560527 ("F1 hides the HUD only with no screen open, as key.toggleGui does") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:50:56 +01:00
Ludovic Marie 475b22ff19 Merge pull request #3063 from iLostName/redstone-ore-lights-on-use
🤖 Agent: read the diff at e06134f71 ("redstone ore: a right-click lights it, as RedStoneOreBlock.useItemOn d") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:50:51 +01:00
Ludovic Marie 0d1f56e015 Merge pull request #3060 from kacpherek/fix/name-tags-face-front-camera
🤖 Agent: read the diff at c66554126 ("Name tags read the right way in the front third-person view") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:50:46 +01:00
Ludovic Marie 958c02593c Merge pull request #3056 from kacpherek/fix/spear-charge-while-riding
🤖 Agent: read the diff at 4a1c5fc54 ("Spears: charges hit while riding, and a stab stops the sprint") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:50:42 +01:00
Ludovic Marie a266eee6d2 Merge pull request #3102 from Domanffe/fix/random-sequences-write-failure
🤖 Agent: read the diff at 23d214606: the villagers' random_sequences write logs a warning on failure. verify green. Queued for the merger.
2026-10-28 19:50:37 +01:00
Ludovic Marie 62898b4cf6 Merge pull request #3053 from kacpherek/fix/entity-shadows-no-cutout
🤖 Agent: read the diff at 012a3cf90 ("Draw entity shadows in dim light like vanilla") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:47:45 +01:00
Ludovic Marie d08e05283a Merge pull request #3038 from hectoreccles/perf/spawn-candidate-buffers
🤖 Agent: read the diff at a48fcf0d1 ("Reuse natural spawn candidate buffers") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:47:41 +01:00
Ludovic Marie e7a07f88b3 Merge pull request #3035 from hectoreccles/perf/streaming-scan-interval
🤖 Agent: read the diff at 2eb3de274 ("Sweep far chunks every eighth tick") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:47:36 +01:00
Ludovic Marie 4e59dab892 Merge pull request #3033 from hectoreccles/perf/block-entity-ticker-list
🤖 Agent: read the diff at 67511d974 ("Reuse the block entity ticker list between ticks") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:47:32 +01:00
Ludovic Marie a1f4841526 Merge pull request #3032 from hectoreccles/perf/hopper-cooldown-first
🤖 Agent: read the diff at df90f62d3 ("Hopper ticks check their block only when they move") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:47:27 +01:00
Ludovic MarieandClaude Opus 5.5 4e804cd813 Native mods on the hook points build on any Horizon that has them
A .hnmod may name the native hook points' version it uses ("hooks": 1 in
horizon-native.json) instead of pinning a commit. The launcher builds such
mods on the commit the other mods pin, else on the installed version's,
once that commit's NATIVE_HOOKS_API (read from GitHub) matches; else, with
no pin, on the commit they were tested on. Its own copy of each such mod
is re-pinned to the build's commit for horizon-mixins. So Horizon's
official mods build together, on the player's own game, with no
re-release for each Nightly. The mods feature is passed only when the
source still has it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 19:46:58 +01:00
Ludovic Marie 7dd4b772c9 Merge pull request #2997 from feeldev12/feat/link-prompt-copy
🤖 Agent: read the diff at 5c14d1814 ("fix(client): the link prompt has ConfirmLinkScreen's Copy to Clipboard") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:44:13 +01:00
Ludovic Marie 03dd78d67b Merge pull request #2994 from kacpherek/fix/particles-sounds-parity
🤖 Agent: read the diff at 0f8e3a9b0 ("Match vanilla mob and arrow sounds: horses, hostile fall and swim, hur") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:44:09 +01:00
Ludovic Marie 6267cd3e15 Merge pull request #2992 from kacpherek/fix/mob-ai-villager-parity
🤖 Agent: read the diff at efa8df83b ("Villagers hear bells, use bone meal, fetch dropped items and play tag ") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:44:04 +01:00
Ludovic Marie 265640ac18 Merge pull request #2991 from kacpherek/fix/block-behaviour-parity
🤖 Agent: read the diff at ffb659460 ("Match vanilla for eight block behaviours: ender chest, doors, hanging ") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:44:00 +01:00
Ludovic Marie f5a3088f0d Merge pull request #2985 from kacpherek/fix/hostile-mob-parity
🤖 Agent: read the diff at f69088012 ("Hostile mobs: hoglin timing, crossbow aim, witch resistance, wither sk") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:43:56 +01:00
Ludovic Marie 3ccbba76df Merge pull request #2984 from kacpherek/fix/passive-mob-parity
🤖 Agent: read the diff at 58422bcc1 ("Passive mobs: sound variants, donkey and mule stats, no mule breeding,") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:39:39 +01:00
Ludovic Marie c519ff6c56 Merge pull request #2977 from ASOwnerYT/fix/2832-return-to-sender
🤖 Agent: read the diff at c4348be8d ("Return to Sender needs the ghast's own fireball: check player_killed_e") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:39:34 +01:00
Ludovic Marie 400d15ab3f Merge pull request #2975 from kacpherek/fix/advancement-triggers-small
🤖 Agent: read the diff at c3e19973c ("Fire lightning, spear, nest, shear, allay and sculk advancement trigge") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:39:29 +01:00
Ludovic Marie d6971605d4 Merge pull request #2957 from kacpherek/fix/smooth-camera-mouse
🤖 Agent: read the diff at 7ab2b20bd ("Let a mouse button toggle the cinematic camera") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:39:24 +01:00
Ludovic Marie 7dc818eb5b Merge pull request #2948 from kacpherek/fix/hdr-crash-macos
🤖 Agent: read the diff at dddcc9972 ("Fix the crash on every start with HDR on in macOS, and start with HDR ") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:39:20 +01:00
Ludovic Marie 2772738d47 Merge pull request #2938 from ASOwnerYT/fix/2802-projectile-death-message
🤖 Agent: read the diff at 513aed64c ("Name the projectile in the death message when it has no owner") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:35:20 +01:00
Ludovic Marie 131625c414 Merge pull request #2906 from kacpherek/fix/2904-fullscreen-crash
🤖 Agent: read the diff at 22cab3eeb ("Open windowed after the window system kills the game in fullscreen") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:35:15 +01:00
Ludovic Marie 478ae35b53 Merge pull request #2880 from leifiyoo/dyed-leather-armour-colour
🤖 Agent: read the diff at 8057d2751 ("Worn dyed leather armour shows its dye colour") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:35:11 +01:00
Ludovic Marie 5f868011ee Merge pull request #2843 from levzzz5154/fix/breeze-wind-scroll
🤖 Agent: read the diff at f507a067c ("The breeze's wind and the wind charge scroll their textures") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:35:06 +01:00
Ludovic Marie 67712eeeef Merge pull request #2836 from levzzz5154/fix/spawn-egg-persistence
🤖 Agent: read the diff at 28f906c37 ("A summoned or egg-spawned mob is not persistent") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:35:02 +01:00
Ludovic Marie 5b7d19f02b Merge pull request #3080 from Domanffe/fix/port-regen-python-that-runs
🤖 Agent: read the diff at 93e61ef93: port_regen.python() probes python3/python/py and keeps the first that runs Python 3 (cached), with a test. verify green. Queued for the merger.
2026-10-28 19:30:16 +01:00
Ludovic MarieandClaude Opus 5.5 e2f3d1a85a Registry: a WebAssembly mod's entry may turn native once
An official mod moving to native hook points switches its registry entry
from `mod` to `native-mod`. Review now allows that one change (never
back): the entry's old `.hmod` versions stay published and are still
checked as `.hmod`s, while the index lists only its `.hnmod` versions.

The launcher's update to the native version keeps the player's `.hmod`:
the game runs it until a native build has the mod, then skips it.
Removing the mod removes both files.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 19:15:38 +01:00
74c2d13d77 Merge origin/main into the portal rider travel branch
Co-Authored-By: levzzz5154 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 19:02:22 +01:00
9ff0d98010 Let the vehicle take its riders through a portal at once
In vanilla 26.3 a passenger has no portal process of its own
(`Entity.canUsePortal(false)` is false for a passenger, so
`NetherPortalBlock.entityInside` skips it); the vehicle's goes through
on the next tick (`NetherPortalBlock.getPortalTransitionTime` is 0 for
anything that isn't a player) and `Entity.teleportCrossDimension`
carries the passengers. Here the rider's own timer decided, so a
survival player on a horse waited 80 ticks in the portal.

A server player who rides now starts no Nether or End portal process. A
horse, boat or minecart ridden only by server players uses the entity
portal path on a server, and once through, each rider's trip starts
with the vehicle (the trip's `vehicle`). Out of the End every rider must
have seen the credits (`Entity.canTeleport`). The level the vehicle
waits in stays open until it has moved. Elsewhere (an in-frame LAN
host) a ridden vehicle stays rather than leave its rider behind.

lan_e2e gets a `portal_rider` check (--server-thread).

Co-Authored-By: levzzz5154 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 18:54:57 +01:00
Ludovic Marie f66f66c3b4 Merge pull request #2815 from sirrayi/fix/zip-untrusted-sizes
🤖 Agent: read the diff at ce848af06: zip hardening: the raw buffer's capacity is capped at the archive length and inflate at 1 GiB with saturating maths, so a zip64 entry claiming u64::MAX/2 errs instead of aborting; real packs are unchanged (the limit stays size*2+1024 below 1 GiB). Test included. verify green; no harness reaches it. Queued for the merger, which waits for this head's parity run.
2026-10-28 18:39:20 +01:00
42be3018dd Merge origin/main into the portal rider travel branch
main now sets a 300-tick portal cooldown with `set_portal_cooldown(id)`
(#2352), so the vehicle's arrival uses it; the world spawn trip
(#2355) gets the new `source` and `vehicle` fields; the parked level
keeps both the public mob list and the leash knot ids (#2123).

Co-Authored-By: levzzz5154 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 18:39:01 +01:00
Ludovic Marie 7cbb8c4506 Merge pull request #2808 from levzzz5154/fix/shulker-body-yaw
🤖 Agent: read the diff at a8903cba0: test only: the shulker's body stays at 0 while its head turns (`ShulkerBodyRotationControl`, `ShulkerLookControl`). verify green, parity green on this head (merged into main), merges cleanly.
2026-10-28 18:35:28 +01:00
Ludovic Marie 6a05885ee3 Merge pull request #2797 from levzzz5154/fix/screen-cursor-centre
🤖 Agent: read the diff at 1d693cb3c: `MouseHandler.grabMouse`/`releaseMouse`: the cursor is centred only when the grab state changes; fixture test updated. verify green, parity green on this head (merged into main), merges cleanly.
2026-10-28 18:35:22 +01:00
Ludovic Marie c979ce46e9 Merge pull request #2771 from ASOwnerYT/claude/select-world-blank-deletion-332d14
🤖 Agent: read the diff at f10558380: `WorldSelectionList.handleNewLevels`: an emptied list opens Create New World, whose Cancel returns to the title. verify green, parity green on this head (merged into main), merges cleanly.
2026-10-28 18:35:13 +01:00
Ludovic Marie 0c5c38ac9e Merge pull request #2736 from levzzz5154/fix/2273-entity-view-scale
🤖 Agent: read the diff at fa940d5b6: `entity_view_scale` extracted from `Entity.setViewScale` (clamp(renderDistance / 8, 1, 2.5) * entityDistanceScaling) with tests for the 48-block chicken range. verify green, parity green on this head (merged into main), merges cleanly.
2026-10-28 18:35:09 +01:00
Ludovic Marie ae3b9e273f Merge pull request #2732 from levzzz5154/fix/mining-hit-sound
🤖 Agent: read the diff at fcc7a818b: the remote-mining fixture now records that the hit sound plays once at dig start (`ClientLevel.addBreakingBlockEffects`), plus the `dig_sounds` LAN check for #2277. verify green, parity green on this head (merged into main), merges cleanly.
2026-10-28 18:35:04 +01:00
Ludovic Marie d0b6e48921 Merge pull request #3281 from horizon-rust/perf/world-entry-decoration-cache
🤖 Agent: verify green; pr_ready on the Mac mini passes (94 affected harnesses, 93 run and pass incl. the worldgen ones; lan_integration by name only; new test decoration_is_shared_by_biome_source). Through the Singleplayer screen, 2 workers, medians of 5: client joined 0.45 → 0.31 s (create) and 0.46 → 0.31 s (reload); reload first controllable frame 0.60 → 0.42 s.
2026-10-28 18:20:09 +01:00
RVZO6andCodex 61713b8359 Fix menu separator texture sampling
Use both rows of the separator textures when tiling headers and footers, including the create-world screen.

Co-Authored-By: Codex (GPT-6.1 Terra) <[email protected]>
2026-10-28 13:16:26 -04:00
Ludovic Marie 07e4a86658 Merge pull request #2817 from sirrayi/fix/server-http-line-limit
🤖 Agent: read the diff at d3be8dbf0: horizon-server HTTP API: request and header lines over 8 KiB are a 400 instead of growing without bound; test with a 100 KB line. verify green; no harness reaches it. Queued for the merger, which waits for this head's parity run.
2026-10-28 17:58:46 +01:00
Hector EcclesandDeepSeek V4.1 Flash 22a4620643 Build beacon beam sections on server clients
A chunk's block entity data carries a beacon's levels and powers but not
its beam sections, so in the default server-thread path and on LAN or
multiplayer clients the sections stayed empty and no beam was drawn.
Tick beacons with ClientLevel.tickBlockEntities' beam pass, without the
effects, sounds and advancement the server owns.

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
2026-10-28 12:54:44 -04:00
Hector EcclesandDeepSeek V4.1 Flash 8422225047 Keep beacon beams visible past the render distance
Remember each active beacon's beam sections when its chunk unloads, so
far terrain LOD keeps drawing the beam until the chunk loads again and
its block entity decides whether it is still there.

The cache only runs with far terrain on, and clears on world, dimension
or LOD changes.

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
2026-10-28 12:54:44 -04:00
degepieandClaude Opus 5.5 3c98062662 Prepare: accept Mojang's fifth 26.3 republish (5df1795e, index 917ff6d7)
Mojang republished the 26.3 version JSON again. The live copy (5df1795e)
points at asset index 34 917ff6d7; neither was in research-lock.json, so
`horizon-prepare --prepare` from a fresh official-launcher install stops
with "is not a 26.3 version file Horizon knows", and the sounds step warns
about an unknown asset index. Jars, libraries and javaVersion are
unchanged; the index differs from 0c8f43b2 only in 142 minecraft/lang/*
objects and pack.mcmeta (uz_uz moved in the language list).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-10-28 21:51:58 +05:00
Ludovic Marie e1b2b0b2fd Merge pull request #2742 from levzzz5154/fix/fire-animated-textures
🤖 Agent: read the diff at a77639022: test only: every graphics preset keeps animated textures on (#2064 stays fixed). verify green; parity selects no harness for it. Queued for the merger, which waits for this head's parity run.
2026-10-28 17:47:18 +01:00
levzzzandCodex 9755416a02 Fix Windows path assertions in the launcher toolchain test (#3285)
Compare managed compiler and PATH entries as platform paths, including Windows executable suffixes. Fixes the Windows assertion that blocked the native-capable launcher release.

Validation: pr_ready passed (401 Python and 219 launcher tests), and the release dry run passed tests and packaged smoke checks on Linux, macOS and Windows.

Co-Authored-By: Codex <[email protected]>
2026-10-28 21:42:53 +05:00
Ludovic MarieandClaude Opus 5.5 0281365907 Signs: keep laid-out sign text between frames, batch entity quads in runs
A wall of 3458 text signs ran at 128 fps against 1642 without them: each
frame laid every sign's text out again glyph by glyph (Font::glyph_of was a
quarter of the main thread), then regrouped every entity quad by material
through a per-quad index list.

- block_entity_render: a sign's text vertices are kept relative to its
  block and moved to the camera each frame. They are laid out again when
  the sign data, block state, light, glowing outline distance or font
  (Font::epoch: a new font, a glyph cache reset, the Unicode and Japanese
  options) changes; signs out of view for 100 ticks are dropped.
- gfx/entity_batch: quads are grouped in runs of one material and whole
  runs copied (2-4x faster on model-like input, the same when every quad
  changes material).

Fixes #1461 (the cost per sign; the vanilla text distance of 64 blocks
and the look are unchanged).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 17:28:08 +01:00
rdbt a5fcb49900 Merge pull request #3262 from Domanffe/fix/chat-log-duplicate
Log a chat message once, as [CHAT]
2026-10-28 16:27:04 +00:00
Ludovic MarieandClaude Opus 5.5 6f6229c2d4 Worldgen: build a biome source's feature decoration once, ahead of the world
`Decoration::new` decodes the placed features of every biome a source can
place: about 140 ms of the ~185 ms `OverworldGenerator::for_world` took on
each world opening (Mac mini). It depends on the worldgen resources and the
biome source alone, not on the seed, so `GeneratorData` now keeps it by
biome source (`decoration_for`, like the ring cache) and every generator
built for that source shares it.

The world screens' preload (#2481) already reads the worldgen bundle while
the player picks or creates a world; it now also builds the Overworld's
decoration (`prepare_decoration`), so opening the world finds it ready.
Reopening a world or returning to a dimension in the same session builds
nothing again either.

Mac mini, --profile fast, offscreen, seed 1234, RD 12, 2 workers, server
thread, through the Singleplayer screen, medians of 5 alternated with main:
  server ready to join: create 0.45 -> 0.31 s, reload 0.46 -> 0.31 s
  first controllable frame: reload 0.60 -> 0.42 s, create 0.85 -> 0.79 s

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 17:22:05 +01:00
Ludovic Marie 0ca3a5be98 Merge pull request #3077 from Domanffe/test/parity-reach-posix-fixture
🤖 Agent: read the diff at aef4186de: test_parity_reach's fixture records dependencies with `as_posix()`, as the tool does. verify green. Queued for the merger.
2026-10-28 17:19:37 +01:00
Ludovic Marie 8993772de0 Merge pull request #3078 from Domanffe/fix/port-harness-windows-log-path
🤖 Agent: read the diff at 28e3286e4: port_harness also picks up Windows `C:...x.log` paths from javac's "see ..." lines. verify green. Queued for the merger.
2026-10-28 17:00:51 +01:00
Ludovic Marie 6a261a00a5 Merge pull request #2886 from socksprox/fix/launcher-forget-offline-name
🤖 Agent: read the diff at 5096b4110: launcher offline names: Forget also deletes the name's skin and cape files, async skin picks bail if the name was forgotten meanwhile, account rows load-mutate-save, and "New name" rolls a fresh PlayerNNNNN into every game home. Tests included. verify and launcher green; no harness reaches launcher code. Queued for the merger, which waits for this head's parity run.
2026-10-28 17:00:45 +01:00
Ludovic Marie f8b83eea51 Merge pull request #3250 from horizon-rust/perf/world-entry-pump-between-ticks
🤖 Agent: verify green; pr_ready on the Mac mini passes (34 affected harnesses, 33 run and pass, lan_integration by name: 7 lan_e2e checks incl. spawned/respawn/portals/forced-levels pass). World entry, 2 workers, RD 12, medians of 3: create 8.05 → 3.57 s, reload 3.72 → 1.42 s to all view columns loaded.
2026-10-28 16:54:43 +01:00
Alexander KrylovandHermes Agent 88398e8bda Log a chat message once
`chat_line` logged `[chat]` and then `Chat::add_message` logged `[CHAT]` for
the same message, so every line appeared twice, and the `[chat]` copy slipped
past the `[CHAT]` filter `log_for_report` uses to keep chat out of an attached
log.

Vanilla's `ChatComponent.logChatMessage` writes the one line `add_message`
already writes, so the `chat_line` call is the leftover.

Fixes #3261

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-28 18:36:21 +03:00
Ludovic Marie 6d3489d30b Merge pull request #3064 from iLostName/trial-spawner-spawn-egg
🤖 Agent: read the diff at 557a82738: a spawn egg on a trial spawner follows `TrialSpawner.overrideEntityToSpawn` -> `FullConfig.overrideEntity` in the 26.3 source: data reset, normal and ominous configs replaced by `Holder.direct(withSpawning(type))` (saved inline through the new `TrialConfig::encode`, `optionalFieldOf` defaults left out), then `setState(INACTIVE)`; plain spawners unchanged. Round-trip test included. verify and parity green on this head. Queued for the merger.
2026-10-28 16:35:54 +01:00
Ludovic MarieandClaude Opus 5.5 7a84c93843 Server: feed the chunk workers between ticks while players' chunks load
A server thread (single player's, a LAN host's, a dedicated server's) ran
`streaming.update` once a tick, and `schedule_generation` keeps at most
three load or generate jobs per worker in flight. Joining a world was
capped at about six columns a tick (120 a second with two workers) while
the workers idled most of each tick. Vanilla's server runs its chunk
tasks while it waits for the next tick (`MinecraftServer.waitUntilNextTick`).

`pump_pregen`, now `pump_chunks`, already did that for a /pregen task and
force-loaded chunks; it now also pumps while the players' chunks are in
flight, except while the server is paused or a respawn search waits for
a chunk (its area is only asked for by the tick's own update).

Mac mini, --profile fast, offscreen, seed 1234, RD 12, server thread,
medians of 3, all 637 view columns in and meshed once:
  2 workers: create 8.05 -> 3.57 s, reload 3.72 -> 1.42 s
  4 workers: create 4.97 -> 2.16 s, reload 2.23 -> 1.27 s

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 16:26:03 +01:00
Ludovic Marie cba52f4e5d Merge pull request #3218 from horizon-rust/fix-server-junction
🤖 Agent: verified: verify, launcher and parity-scope green; local fmt, clippy -D warnings and launcher tests pass. Fixes #3200. Queued for merge.
2026-10-28 16:04:17 +01:00
SolomonNumb1 9d7b4e8a2d tooltip fix 2026-10-28 20:03:12 +05:00
some-du6eandClaude Opus 5.5 f52af64e89 Spears: the jab animates in first and third person
A spear's left click is a STAB swing (SwingAnimationType 2), but every
swing was drawn as a WHACK. Port SpearAnimations.firstPersonAttack,
thirdPersonAttackHand and thirdPersonAttackItem, and keep a swing's type
for mobs and other players (from ClientboundSwingAnimationPacket) and
the local player's model.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 09:02:21 -06:00
masa-masa-0314andZCode 4d477067c3 Chat and books: open a show_dialog click event's dialog
A server may put a show_dialog click event on a chat line or a book
page; ClickEvent.Action.SHOW_DIALOG allows it from a server, and vanilla
opens the dialog through Screen.defaultHandleGameClickEvent. Horizon
dropped the click event while parsing the component (no arm for it in
component.rs, no ClickEvent variant to carry it), so clicking the text
did nothing.

The dialog now survives as ClickEvent::ShowDialog (a registry id or the
inline dialog), parses from click_event, round-trips back out, and both
click handlers (chat and BookViewScreen) open it with show_dialog_ref,
the same path a server's show_dialog packet takes.

Co-Authored-By: ZCode <[email protected]>
2026-10-28 22:52:46 +08:00
masa-masa-0314andZCode 60773a0e92 Dialog: send the show and clear packets the commands promise
/dialog show and /dialog clear ran, printed their feedback and told
nobody: the dialog state they set was never read, and nothing built
ClientboundShowDialogPacket or ClearDialogPacket. DialogCommand's own
ServerPlayer.openDialog/closeDialog send the packet to each target, so
the command now resolves the argument (a minecraft:dialog registry entry
by index, or the inline dialog as sent) and sends it to every target:
the host's own player opens it on the screen, LAN guests get the packet
through the hub.

Co-Authored-By: ZCode <[email protected]>
2026-10-28 22:21:00 +08:00
Ludovic MarieandClaude Opus 5.5 9f1fa0a0a2 launcher: quote server junction paths and show why mklink failed
Creating a server on Windows failed with a bare "mklink /J failed" (#3200).
servers.rs passed the paths to cmd unquoted, so a user folder with `&`, `^`
or `(` broke cmd's parsing; sources.rs already quoted them. Both now use
one sources::junction, which also puts cmd's own message in the error.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 15:15:13 +01:00
Claude 0291a5864f Look up players' teams by name for client-side pushes on servers
remote_pushes found teams through mob_teams, which keys entities by UUID
text and the local player by PLAYER_NAME. On a server, players are team
members by profile name (Player.getScoreboardName), so neither lookup hit
and every player pushed the local player, even on teams with
collisionRule never. The server applied no push and corrected the
position, so walking into players jittered (seen on Hypixel).

Use the profile name for the local player and the tab list name for other
players; other entities keep their UUID.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018ksHJwFp11RfYk6S9xnyCf
2026-10-29 00:13:19 +10:00
Ludovic Marie fe84cc07a1 Merge pull request #3208 from horizon-rust/launcher-sidebar-sponsor
🤖 Agent: verified: verify, launcher and parity-scope green; local cargo fmt, clippy -D warnings and launcher tests pass. Queued for merge.
2026-10-28 14:55:08 +01:00
Ludovic Marie d4e42cf726 Merge pull request #3195 from horizon-rust/feat/launcher-native-mods-window
🤖 Agent: proven with a real launcher GUI run in isolated dirs (Settings → Native mods row, red warning naming veinminer 0.3.0, trust → cached build reused, status line); launcher clippy and 265 tests green; #3194 merged and this now targets main with only its own 5 files. Verify green at 505de825.
2026-10-28 14:51:41 +01:00
PH4N7OMxandOpenAI Codex 998c94bee3 fix(windows): open URLs with the registered protocol handler
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-28 14:46:01 +01:00
Ludovic MarieandClaude Opus 5.5 466512c90e launcher: a Sponsor link in the sidebar footer
The GitHub Sponsors link was only on the patch notes page. Show it beside
Discord and Website in the sidebar footer (heart icon), from links.json's
sponsor entry, translated in fr and ru.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:44:51 +01:00
Ludovic Marie 4db071e281 Merge pull request #3194 from horizon-rust/feat/launcher-native-mods-page
🤖 Agent: proven with a real launcher run in isolated dirs: registry install of Veinminer's .hnmod into an instance, native-build of that instance's enabled native mods, instance pinned to the build; launcher clippy and tests green after merging main (#3140 and #3189 merged). Verify green at 1f6511d0.
2026-10-28 14:40:43 +01:00
Ludovic MarieandClaude Opus 5.5 d2e7a54082 rustfmt: wrap horizon-client's re-export of the render modules
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:32:21 +01:00
Ludovic Marie 2649c5e506 Merge pull request #3202 from horizon-rust/ci/nightly-cap-12
Nightly: 12 builds a day instead of 6
2026-10-28 14:27:40 +01:00
Ludovic MarieandClaude Opus 5.5 505de825db Merge feat/launcher-native-mods-page (main merged in)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:27:32 +01:00
Ludovic MarieandClaude Opus 5.5 1f6511d0f3 Merge main into feat/launcher-native-mods-page (#3140 and #3189 merged)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:26:02 +01:00
Ludovic MarieandClaude Opus 5.5 1523c118d8 Nightly: 12 builds a day instead of 6
The Team tests fixes on the Nightly and hit the cap of 6 builds a day
(Ludovic, 2026-10-28: raise it to 12).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:21:08 +01:00
Ludovic Marie 9239089b76 Merge pull request #3140 from levzzz5154/feat/native-mod-bundles
🤖 Agent: merging levzzz5154's native-mod registry kind; proven with the real Veinminer .hnmod through registry build, the launcher's install check and a native build that ran (see comment).
2026-10-28 14:20:09 +01:00
Ludovic Marie 281ab9759c Merge pull request #3180 from horizon-rust/refactor/furnace-cooking
🤖 Agent: verify passed at 8b5a27ed3. Move only (crate split B7): the furnace's recipe logic leaves containers as the FurnaceCooking extension trait. Full pr_ready green on this head (1261 tests, parity 69 affected / 0 failed); #2959, #2960 and #3131 still merge cleanly. Ready once parity is green.
2026-10-28 14:13:59 +01:00
Ludovic MarieandClaude Opus 5.5 febad05c78 launcher: French and Russian for the Native mods row
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:07:55 +01:00
Ludovic MarieandClaude Opus 5.5 519edfac19 docs: native mods in the launcher window
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:06:21 +01:00
Ludovic Marie 6561df77f6 Merge pull request #3189 from horizon-rust/feat/launcher-native-build
🤖 Agent: merging: native-build compiles horizon-mixins bundles on their pinned Nightly with the launcher's own Rust; proven by building levzzz's Veinminer .hnmod on 83f15fa (580 s cold, 2m46s warm) and /veinminer answering in the built game.
2026-10-28 14:05:32 +01:00
Ludovic MarieandClaude Opus 5.5 cd7f177104 Launcher: a Native mods switch and Build in Settings, with the warning
Settings → Game → Native mods (experimental), off by default. On, Build names the native mods
turned on in the instance Play starts, warns in red that they run with full access to the
computer, and compiles them only from "I trust these mods: build"; the row shows the step while
it runs, the error if it fails, and the build the instance plays. Ported from native-mixins-1.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:04:35 +01:00
SlaVKsVolksandOpenAI Codex GPT-6.1 Terra 54360a74e3 fix(parity): normalize Windows dependency records for selection
Port the production path portability repair developed in Beyond onto original Horizon. Keep stored records and narrowed-root annotations intact; cover legacy local/shared records, changed probes and oracles, and reachability.

Co-Authored-By: OpenAI Codex GPT-6.1 Terra <[email protected]>

Co-Authored-By: OpenAI Codex GPT-6.1 Sol <[email protected]>
2026-10-28 09:58:22 -03:00
Ludovic MarieandClaude Opus 5.5 07ab7ccfc1 Merge the native build's measured numbers
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 13:54:36 +01:00
Ludovic MarieandClaude Opus 5.5 727f6c932a docs/native-mods.md: the measured build times and sizes
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 13:54:36 +01:00
Ludovic MarieandClaude Opus 5.5 cd1f58ef72 Launcher: native mods from the registry install and build like the others
- native-mod entries (levzzz5154's #3140) are listed as mods and install as
  horizon-native-mods/<id>.hnmod (the folder #3112's inventory reads), turned on in
  horizon-mods.txt, after the .hnmod is checked as the registry checks it.
- native-build with no folder builds the native mods turned on in the instance, and that
  instance then plays the build (the game's own home builds into the Native mods instance).
- Every registry project says who made it; a project not made by Horizon says it is not made
  or endorsed by the Horizon project, with the mod policy's link (playhorizon.dev/mods/policy).
  A native mod says it runs with full access to the computer and which Horizon it builds on.
- Pruning keeps every native build an instance plays.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 13:54:35 +01:00
Ludovic Marie 291aae00e8 Merge pull request #3174 from horizon-rust/ci/nightly-source
🤖 Agent: merging: nightly publishes horizon-source.tar.gz (signed) and tags its commit nightly-source/<date>-<short>; proven by a full dry run of the workflow (run 40686585965) and tests/test_nightly.py.
2026-10-28 13:49:40 +01:00
Ludovic Marie bec8cce82d Merge pull request #3179 from horizon-rust/feat/launcher-rust-toolchain
🤖 Agent: merging: the launcher's portable Rust 1.99.1 (pinned SHA-256 components, own data dir); proven by a real isolated rust install + crate build on the Mac mini and the launcher tests.
2026-10-28 13:49:37 +01:00
Ludovic MarieandClaude Opus 5.5 0ee2425d40 Merge levzzz5154's #3140 (native-mod registry kind, .hnmod validator)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 13:44:24 +01:00
Ludovic MarieandClaude Opus 5.5 7f3bd901bc Launcher: build native mods on the Nightly they pin, with its own Rust
horizon-launcher native-build FOLDER --trust-native-code compiles mixin bundles (a folder with a
horizon-mixin.toml, or an unpacked .hnmod from the registry) into a game of their own:
- the Horizon commit the mods pin (all must agree; else the installed version's), its source from
  GitHub by commit (Nightlies keep theirs as nightly-source/ tags);
- horizon-mixins at the commit the mods were tested with, built once with the launcher's Rust;
- horizon-mixins apply, then the release's cargo build, with the portable toolchain;
- installed as native-<commit>-<key>, played by the Native mods instance.
Based on native-mixins-1's unmerged work; docs/native-mods.md describes it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 13:43:52 +01:00
Ludovic MarieandClaude Opus 5.5 d308df6167 Launcher: a portable Rust toolchain of its own, for native mods
Native mods compile the game on the player's computer (levzzz5154: many native mods per build). The
launcher now downloads the official rustc, cargo and rust-std components of Rust 1.99.1 (plus
rust-mingw on Windows, the windows-gnu host that needs no Visual Studio) from static.rust-lang.org,
each pinned by SHA-256, into <launcher data>/rust/, and runs them with its own CARGO_HOME. No rustup,
nothing installed on the system. `horizon-launcher rust status|install|remove`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 13:32:09 +01:00
Ludovic MarieandClaude Opus 5.5 a7efe597ea refactor: the client's world data, rendering and screens become horizon-client-render
80 modules of horizon-client's lib (chunks, meshing, light, streaming, gfx, the HUD,
menus, settings, resource packs, particles, block entity data) and its 8 WGSL shaders move
to crates/horizon-client-render with git mv. Nothing in them reaches gameplay. horizon-client
re-exports each under its old path, so horizon_client::gfx and the rest are unchanged.

Beyond the moves: ParsedModels, lod's Column::reduced/push and the spawner's Next,
Spawner.next and Selector.current become pub; Properties::for_tests sits behind a
test-support feature; crash.rs strips the new crate's path; tools and docs point at the
new paths, and three harnesses that hash every client source hash the new crate too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 13:23:12 +01:00
Ludovic MarieandClaude Opus 5.5 5ded675a7d Nightly: publish the exact source, and tag its commit for good
The nightly release names its commit, and now also attaches horizon-source.tar.gz (git archive of that
commit, made from git objects in pick and signed with the builds) and tags the commit
nightly-source/<date>-<short>. The dated release and the nightly/build-* branch are pruned after 7 days;
the tag is not, so native mods pinned to a Nightly's commit keep building.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 13:16:48 +01:00
Ludovic Marie f70501aa42 Merge branch 'refactor/cut-biome-spawn-parsers' into refactor/horizon-client-render 2026-10-28 13:13:50 +01:00
Ludovic Marie d0a29a0f66 Merge branch 'refactor/furnace-cooking' into refactor/spawner-hive-traits 2026-10-28 13:13:03 +01:00
Ludovic Marie 564162a0b0 Merge pull request #2741 from horizon-rust/refactor/cut-biome-spawn-parsers
🤖 Agent: verify passed at 0f565f4e4. Move only: parse_entries/parse_costs/SpawnCost go from mobs/spawn_selection.rs to biome.rs (re-exported), cutting biome -> mobs (step A of the cycle plan). pr_ready --quick green (1315 tests). Ready to merge once parity is green.
2026-10-28 13:12:01 +01:00
Ludovic Marie 8b5a27ed3f Merge remote-tracking branch 'origin/main' into refactor/furnace-cooking 2026-10-28 13:11:00 +01:00
Stepan Bogomazov cd97b34460 Build the sunset fan once a frame instead of in every sky pixel
While the sunrise/sunset glow shows (about 11500-14500 and 22500-500),
sunrise_weight rebuilt the fan's 16 triangles for every pixel of the
fullscreen sky pass, with four sin/cos per triangle. The triangles depend
only on the glow's alpha and the sun's horizon, so they are now built on
the CPU and passed in Globals as normals; the shader's Moller-Trumbore
reduces to dot products, and only triangles whose rim normals change sign
along the ray are tested in full. The weights are unchanged (a test checks
them against the old per-pixel code).

Sky pass at 1920x1080, dawn: Intel UHD 730 10.2 ms -> 1.8 ms (0.6 ms by
day), RTX 3060 0.47 ms -> 0.27 ms.

Fixes #3143
2026-10-28 14:27:17 +03:00
Hermes AgentandHermes Agent c0505424bd fix(launcher): keep native sources hidden until build integration lands
Co-Authored-By: Hermes Agent (OpenAI) <[email protected]>
2026-10-28 15:27:51 +05:00
Hermes AgentandHermes Agent 137045e321 feat(mods): validate source-only native mod bundles
Co-Authored-By: Hermes Agent (OpenAI) <[email protected]>
2026-10-28 15:20:36 +05:00
Kacpherek 4a18a4bde4 Draw the shulker bullet with its model instead of a sprite
The bullet fell through to the item billboard and showed the whole spark texture. Vanilla ShulkerBulletRenderer draws the tumbling bullet model plus a 1.5x translucent copy at full block light.
2026-10-28 10:47:31 +01:00
Kacpherek f6b467eba9 Hurt crammed mobs by 6 at the max entity cramming limit
Mobs only drew the cramming random and never took damage, so a crowd was never thinned. Vanilla's LivingEntity.pushEntities hurts a mob by 6 on a quarter of the ticks while max_entity_cramming or more non-riding entities are pushable around it.
2026-10-28 10:47:13 +01:00
Kacpherek e3fda9ce67 Keep golems built by a player from attacking players
A built iron golem was never marked player created, so it targeted its builder. Vanilla sets PlayerCreated on build (saved in NBT) and IronGolem.canAttack refuses players, which every target goal honours.
2026-10-28 10:43:10 +01:00
Kacpherek 03f3fb057d Show thrown eyes of ender in the hitbox view
F3+B drew every entity list but the eyes in flight, so a thrown eye of ender had no box. EntityType.EYE_OF_ENDER is 0.25 by 0.25 and never turned.
2026-10-28 10:35:17 +01:00
SenjinandClaude Opus 5.5 8502d86e24 tools/hdr_gallery.py: an HDR test gallery of every block that gives light
Writes a datapack into a world: hdr:gallery (one or two of each family) and
hdr:gallery_full (every block with a light level, in its brightest state), each
alone in dark stone tunnels, after hdr:setup holds the world at midnight with no
weather, mobs or random ticks. To judge and tune how light sources shine in HDR
output (#1441) under one standard; the block list comes from the prepared
resources, so it follows the pinned version.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 10:19:33 +01:00
malordinandClaude Sonnet 5.5 abdff5c89d Launcher: tell a prepared version without the language list by pack.mcmeta
`lacks_languages` counted the files in `assets/minecraft/lang` and took one or none as
"English alone". The client jar carries two there, `en_us.json` and `deprecated.json`,
so a real prepared version never counted as lacking and the languages #1594 copies from
the asset store were never added to one prepared before it, or by a preparer that could
not read the store. The test's fixture held `en_us.json` alone, which hid it.

The list the language screen offers, `pack.mcmeta`, is only in the asset index, so its
absence is what says the languages are missing. The fixture now has `deprecated.json`.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-28 13:50:26 +05:00
AlexanderandHermes Agent 3fe4834ceb Creative hotbar: say 'Saved' only when it was saved
hotbar::save's error was dropped and the action bar showed "Saved hotbar N"
anyway. Log the failure and skip the message, as saveHotbar does by setting
it inside the try.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-28 11:39:17 +03:00
AlexanderandHermes Agent 23d2146066 Villagers: report a failed random_sequences write
The merchant's random sequences (the salts that keep its offers stable) were
written with the result dropped, so a failed write rerolled every trade on
the next load with nothing in the log. Log it like the other save paths.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-28 11:38:23 +03:00
hashtheboardandClaude Opus 5.5 b78ff5f55e client: aim every crosshair target along one ray (VR groundwork)
Game::aim() returns the eyes and the view direction; pick, pick_fluid
and every *_hit go through it instead of rebuilding the pair. A tracked
controller can later stand in for it. No behaviour change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 09:38:16 +01:00
AlexanderandHermes Agent be8b86d470 Scoreboard: report a failed write instead of dropping it
save_scoreboard wrote scoreboard.dat and world_border.dat with `let _ =`, so
a full disk or a read-only world lost objectives, scores and the border with
nothing in the log. Log the failure the way command storage and boss bars
already do.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-28 11:37:19 +03:00
AlexanderandHermes Agent 5f8afb17fc Name tags: copy a custom name only inside the tag's range
name_tags cloned every named mob's custom name each frame and dropped the tag
afterwards when the mob was beyond 64 blocks. Test the distance first, so the
copy happens only for a mob the frame can show.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-28 11:26:00 +03:00
AlexanderandHermes Agent f755d7fba0 Launcher: write the server list beside it and rename
servers.json was written in place, so an interrupted write left a truncated
list and every server vanished from the sidebar. Write servers.json.tmp and
rename it, as the launcher's other state files do.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-28 11:18:41 +03:00
AlexanderandHermes Agent f7bb1ceb79 Effect panels: borrow the name and reuse the icon key
The panel list is painted every frame the inventory is open. An unclipped
name was cloned per panel and the icon key was a fresh format! per panel; the
name is borrowed and the key is built in one buffer now.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-28 11:16:09 +03:00
AlexanderandHermes Agent b9494af976 Server list: build each row's sprite key once
The row made the short sprite name (format! or to_owned) and then wrapped it
in format!("gui/sprites/{sprite}") for the atlas. Build the full key in the
match arm and hand it straight to sprite_scaled.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-28 11:14:14 +03:00
AlexanderandHermes Agent d1ead6a5fa Clippy: tab is already a reference
Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-28 11:09:17 +03:00
AlexanderandHermes Agent 9d9e1a97a8 rustfmt
Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-28 10:58:47 +03:00
AlexanderandHermes Agent 3511d556ef rustfmt
Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-28 10:58:20 +03:00
AlexanderandHermes Agent 4ff7118db7 Creative screen: draw the selected tab from a reference
tab_headers() builds a header per tab without the item lists, and the caller
then cloned the selected one again: three Strings copied every frame the
screen is open. Borrow it instead.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-28 10:55:47 +03:00
AlexanderandHermes Agent 1743bbc525 Tests: read the session lock after releasing it
LockFileEx refuses a read through another handle while the lock is held, so
reading session.lock between acquire and drop fails on Windows with error 33.
The exclusivity assertion stays; the bytes are read once the lock is released.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-28 10:47:04 +03:00
AlexanderandHermes Agent da9950c75b Crash report: strip the workspace prefix on Windows too
The root came from CARGO_MANIFEST_DIR.strip_suffix("crates/horizon-client"),
which never matches a Windows manifest (backslashes), so every workspace frame
was printed as /src/... instead of crates/horizon-client/src/... Normalise the
separators of the manifest and of each frame before shortening.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-28 10:45:04 +03:00
AlexanderandHermes Agent 626068da3a Inventory draw: a slot is drawn from a reference, not a copy
draw_slot_item needs neither ownership nor &mut self: it reads the carried
stack only for a drag. Taking `&Option<ItemStack>` and &self drops the copy
every caller made, including the two the creative hotbar made per slot each
frame (and one clone per hovered slot becomes a clone only when hovered).

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-28 10:40:11 +03:00
Kacpherek cd2fea1f02 Throw projectiles with the thrower's known movement
Pearls, snowballs, eggs, splash potions, tridents, wind charges and arrows added the server's stored velocity of the player, which for a guest can hold knockback or a fall from ticks ago, so some throws dove. Projectile.shootFromRotation adds getKnownMovement: the move the client last sent.
2026-10-28 08:38:46 +01:00
AlexanderandHermes Agent 4bf633e8a9 HUD paint: build the sprite key in one reused buffer
paint() runs for every drawn HUD call each frame and made a fresh String per
sprite to look it up in the atlas: sprite.to_string() for a mob_effect and
format!("gui/sprites/{sprite}") otherwise. One buffer outside the loop keeps
the same keys without the allocation.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-28 10:33:46 +03:00
AlexanderandHermes Agent f4d6f9c0ed Tests: skip the reports-link case when the host cannot link
os.symlink needs Developer Mode or an elevated token on Windows, so the test
errored with WinError 1314 instead of exercising own_reports. Skip it there
and keep it on hosts that can link.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-28 10:30:03 +03:00
AlexanderandHermes Agent 93e61ef936 Port regen: hand the tools a Python that runs
python() returned shutil.which("python3"), which on Git for Windows is the
Microsoft Store stub (prints "Python", exits 49): every tool it launched
failed before its first line. Probe python3, python and py and keep the first
that is Python 3, once per process.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-28 10:27:21 +03:00
AlexanderandHermes Agent 28e3286e40 Port harness: read the log a Windows javac error names
named_logs only matched a Posix absolute path (`see /...log`), so on Windows
horizon-prepare's "see C:\...\export.log" matched nothing and its javac
errors were never shown. The pattern now takes either form.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-28 10:21:06 +03:00
AlexanderandHermes Agent aef4186de4 Tests: the parity-reach fixture names files the way the tool does
narrow() and its graph key files by Posix path (`crates/a/src/used.rs`), so
a fixture built from str(relative_to) on Windows produced backslashes and all
seven tests errored in setUp. as_posix() in the fixture matches the tool.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-28 10:16:41 +03:00
AlexanderandHermes Agent b08e04cd8a Port publish: a body names the diff store with Posix separators
str(Path) keeps the host's separators, so a Windows run wrote
`~\code\horizon-parity\...` into the published body while every other line
of that body is the same on every machine. as_posix() makes it stable.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-28 10:16:00 +03:00
AlexanderandHermes Agent e8cb6ae83b Hooks: run the public tree check through a Python that works
Git for Windows puts a Microsoft Store stub on PATH as python3: it prints
"Python" and exits 49, so both hooks failed before running the check. The
hooks now try python3, python and py and use the first that is Python 3.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-28 10:14:16 +03:00
Kacpherek 99008baad9 Hold items at the middle of a slim arm
A player with a slim skin held items at the wide arm's hand, half a pixel off the 3-pixel arm. PlayerModel.translateToHand shifts the arm by 0.5 toward the hand before the item's offset, as SkeletonModel does by 1.
2026-10-28 07:24:11 +01:00
Kacpherek e1bc2cbea3 Show banner patterns on shields sent by a server
The banner_patterns and base_color components were read past on the wire, so a stack from a server never carried them and its shield drew plain. Decode both (reference patterns; direct ones stay raw) and write them back, as vanilla's BannerPatternLayers and DyeColor stream codecs do.
2026-10-28 07:24:11 +01:00
Kacpherek c66554126d Turn name tags to the camera in third-person front view
Name tags faced the player's own look, so in the front view (F5 twice) every name and hologram read mirrored. They now face the camera, turned round in that view, like Camera.rotation().
2026-10-28 06:58:59 +01:00
Kacpherek 4a1c5fc54c Stop sprinting after a spear stab with knockback
Player.causeExtraKnockback stops the attacker's sprint, as the melee path already does.
2026-10-28 06:57:48 +01:00
iLostName 7b93b310a0 death: the player hears their own death sound on a server and in single player
LivingEntity.handleEntityEvent 3 plays it for the dying player; the server's goes to the others. Fixes #3021.
2026-10-28 06:52:29 +01:00
iLostName a1b32304a3 boat: the view stops at 105 degrees at every turn, so it doesn't jitter
Fixes #2983.
2026-10-28 06:52:29 +01:00
iLostName 7934a0b180 riding: the crosshair skips the mob being ridden, so it can't be hit
Fixes #2870.
2026-10-28 06:52:28 +01:00
iLostName 992c63eefb death message: a creeper's blast names the creeper, not the last mob that hit
The creeper is out of the mob list when its blast lands, so its name now comes with the explosion event. Fixes #2978.
2026-10-28 06:52:28 +01:00
iLostName a967ca4340 item pickup: a stack in the off hand fills before the inventory, as getSlotWithRemainingSpace does
Fixes #2865.
2026-10-28 06:52:28 +01:00
iLostName 0112a6feac item pickup: one sound per item, played by the client at the item
The server sent its own pickup sound beside TakeItemEntity, so single player heard two. Fixes #3029, fixes #2861.
2026-10-28 06:52:27 +01:00
iLostName c0b560527f F1 hides the HUD only with no screen open, as key.toggleGui does
Fixes #2942.
2026-10-28 06:52:27 +01:00
iLostName 557a827383 trial spawner: a spawn egg sets its mob, as overrideEntityToSpawn does
Fixes #3041.
2026-10-28 06:52:26 +01:00
iLostName e06134f71a redstone ore: a right-click lights it, as RedStoneOreBlock.useItemOn does
Fixes #3044.
2026-10-28 06:52:15 +01:00
Kacpherek 330314327a Give a mounted spear charge its mount's speed
The charge read the rider's own last move, which a rider never sends, so on horseback it had no speed and hit nothing. ServerPlayer.getKnownSpeed gives a rider its vehicle's movement.
2026-10-28 06:30:14 +01:00
Kacpherek 9d32bebad3 Show the happy particle when feeding a baby horse on a server
AbstractHorse.handleEating draws it on the client's own copy of the mob; Horizon drew it only on the server's level, which a server's client never sees. Same for llamas and camels.
2026-10-28 06:26:23 +01:00
Kacpherek 012a3cf908 Use the End's ambient light for entity shadows
DimensionType.ambientLight is 0.25 in the End; shadows used 0, so they were weaker there.
2026-10-28 06:10:38 +01:00
Kacpherek 0e03cac368 Draw faint entity shadows instead of cutting them out
Shadows used the entity fragment shader, which discards alpha under 0.1, so they vanished at block light 7 and below and faded out a few blocks away. rendertype_entity_shadow.fsh has no cutout.
2026-10-28 06:10:38 +01:00
Kacpherek e24394ca3a Drop the hand after placing or using an item on a server
The server-client use path never called LocalPlayer.itemUsed, so the held item stayed up after a placement. Vanilla drops it after a swinging useItemOn that spent the item (or in creative) and after every successful useItem.
2026-10-28 06:09:40 +01:00
Kacpherek a5ee1e588c Leave a server at once instead of waiting for the reader thread
On Windows a socket shutdown doesn't wake a read already waiting, so Disconnect froze the window until the server closed the connection. Vanilla closes the connection and moves on.
2026-10-28 05:59:37 +01:00
Hector EcclesandOpenCode a48fcf0d16 Reuse natural spawn candidate buffers
Every spawning pass built a new 289-per-player candidate Vec and a new
dedup set. Keep both in a scratch struct on the game and clear them between
passes, so their capacity survives. The first pass and the thunder pass use
the same buffers.

Co-Authored-By: OpenCode <[email protected]>
2026-10-27 21:55:47 -04:00
Hector EcclesandOpenCode 2eb3de2748 Sweep far chunks every eighth tick
Streaming walked every loaded column and every proto chunk on each update to
drop the ones that had fallen out of range. The sweep now runs at once when
the view moves and every eighth tick otherwise; unloads can wait 400 ms
without changing what is visible or generated.

Co-Authored-By: OpenCode <[email protected]>
2026-10-27 21:47:29 -04:00
Hector EcclesandOpenCode 67511d9749 Reuse the block entity ticker list between ticks
The list of ticking block entities was cloned into a new Vec on every tick,
even when no slot had been freed and nothing had been added. Keep it behind an
Arc and clone the Arc instead; promotions and sweeps use Arc::make_mut, so the
list is copied only when it actually changes.

Also skip the block lookup in tick_spawner: the caller only reaches it for a
BlockEntity::Spawner, and changing the block removes or replaces that entity.

Co-Authored-By: OpenCode <[email protected]>
2026-10-27 21:40:12 -04:00
Hector EcclesandOpenCode df90f62d35 Hopper ticks check their block only when they move
A hopper on its 8-tick cooldown looked up its block and compared the state
name every tick, then returned without moving. Advance the cooldown first and
only resolve the block when it is due to move. The sweep that drops hoppers
whose block entity is gone can run once a second; a hopper that ticks still
checks its own block.

Co-Authored-By: OpenCode <[email protected]>
2026-10-27 21:28:52 -04:00
asownerytandClaude Opus 5.5 f1212a0e5a Beds: occupied while someone sleeps in them, and sleeping villagers are saved
LivingEntity.startSleeping sets the bed head's OCCUPIED and stopSleeping
clears it (the foot follows through updateShape):

- Villagers send MobEvent::BedOccupied when they lie down and get up
  (brain, hurt, bed gone, kicked out, death), and keep the bed they lie
  in as sleeping_pos.
- A player's bed is marked when it lies down (try_sleep) and freed in the
  level's tick once nobody sleeps in it any more, which covers waking, the
  Leave Bed button, death, a broken bed and a guest leaving; the server's
  last save (and closing an in-frame world) frees every player's bed, as
  PlayerList.removeAll does.
- AbstractBedBlock.useWithoutItem: an occupied bed wakes the villager in it
  (kickVillagerOutOfBed) or says "This bed is occupied".
- LivingEntity's sleeping_pos is saved and loaded, so a villager asleep at
  a save is still asleep in its occupied bed when the world opens again.
- The in-frame player gets up when its bed is gone (checkBedExists), and a
  client that leaves a server while asleep is no longer stuck in bed.

lan_e2e: bed-occupied and sleep-reload.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:24:58 +13:00
asownerytandClaude Opus 5.5 df4877a10b lan_e2e: sleep-villager, a bed wakes its villager and the sleeper's body shows
The guest uses a bed a villager has lain down in: the villager gets up and
the guest stays up, then the next use lies down. `status` reports whether
the world view draws the player's own body (`own_body`): yes in bed, in
first person too, and no once up again in first person.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:01:21 +13:00
asownerytandClaude Opus 5.5 cc575e2c6a Sleeping: villagers lie on the pillow, the player's body shows, beds wake villagers
- A sleeping villager is moved toward the foot by its standing eye height
  (LivingEntityRenderer's getEyeHeight(Pose.STANDING)), not by the 0.2 of
  its sleeping box, so its head lies on the pillow instead of past the bed.
- The player's own body is drawn while sleeping, in third person and in
  first person (LevelExtractor skips the camera entity unless it sleeps),
  lying the way the bed faces.
- Using a bed a villager sleeps in wakes the villager and keeps the player
  up (AbstractBedBlock.kickVillagerOutOfBed), instead of both sharing it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:01:21 +13:00
DanielGodriNeto e14d6f79b1 perf(assets): keep decoded block sprites on disk between launches 2026-10-27 20:38:51 -03:00
feeldev 752fc2170a fix(client): ConnectScreen as 26.3 draws it, with Cancel
Joining a server showed a flat dark fill with the status and the address.
ConnectScreen draws the menu background over the blurred panorama, the
status centred at height / 2 - 50, and a 200-wide Cancel at height / 4 +
132 that aborts the connection and goes back; Escape does nothing there
(shouldCloseOnEsc is false). Once logged in, the LevelLoadingScreen's
Loading terrain takes over until the player is placed.
2026-10-27 18:05:11 -05:00
feeldev 356b69aea7 fix(client): the window keeps drawing while single player's server starts
Minecraft.doWorldLoad shows the LevelLoadingScreen and keeps running the
client while its server prepares the world. Horizon's window waited on the
server thread's ready message instead, frozen (not responding on Windows)
for the whole load. The server's answer is now polled each frame from
resume_world_entry, the LevelLoadingScreen's title is drawn meanwhile,
and the window joins once the server is ready. Runs without a window and
the synchronous harness and control runs still wait for it, as before.
2026-10-27 18:02:04 -05:00
Ludovic MarieandClaude Opus 5.5 827bd7f609 Launcher: test which versions pruning deletes
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 23:32:54 +01:00
Ludovic MarieandClaude Opus 5.5 efa8df83b3 villagers: a torchflower crop is never ripe
TorchflowerCropBlock.getMaxAge is 2 while its age property stops at 1, so
CropBlock.isMaxAge never holds for it: HarvestFarmland leaves it alone and
UseBonemeal keeps it as a target until it turns into a torchflower.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 23:06:19 +01:00
Ludovic MarieandClaude Opus 5.5 58422bcc19 Bred babies keep the classic sound set; allays walk back to within 4 blocks
- Animal.spawnChildFromBreeding creates the baby with getBreedOffspring and
  no finalizeSpawn, so its sound variant stays the default (classic); the
  spawn path here had just drawn a random one.
- StayCloseToTarget sets a walk target with closeEnough 4: once started at
  16 blocks, the allay walks until MoveToTargetSink.reachedTarget (Manhattan
  block distance 4), not only back inside 16.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:54:14 +01:00
feeldev fdefcfeac0 perf(client): read the loot tables while joining a server
On a server's world the loot tables were first read on the game thread by
the first block placed (StepContext's loot), a ~0.6 s freeze on a phone.
They are now read on a helper thread as the connection starts, like the
world screens' preload does for local worlds, and kept from then on.
2026-10-27 16:54:03 -05:00
feeldev 5c14d18141 fix(client): the link prompt has ConfirmLinkScreen's Copy to Clipboard button
ConfirmLinkScreen.addButtons: an untrusted link asks with three 100-wide
buttons, Yes, Copy to Clipboard and No; Copy puts the link on the
clipboard and answers no. confirm_screen::Choice lays out a row of any
number of buttons; Confirm keeps its two of 150 through it.
2026-10-27 16:52:15 -05:00
Hector EcclesandDeepSeek V4.1 Flash a96f66ce1e Replay vanilla's small End island placement
The hash estimator placed islands with the right density but different
roots, so an estimate could vanish when the real chunk arrived. Reproduce
the feature's decoration seed, step-local index and placement modifiers
instead, run the feature on a no-op world to keep the random stream in
sync, and use those exact origins for the far cone. The per-cell biome
sampling is no longer needed; the feature's biome filter decides.

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
2026-10-27 17:52:07 -04:00
Hector EcclesandDeepSeek V4.1 Flash b561f83220 Draw small End islands in LOD terrain
The small_end_islands biome's only feature is end_island_decorated, so
unexplored End void rendered empty beyond the refined rings. Carry the
cell biome for void direct samples and estimate the feature: rarity and
count from its placement modifiers, in-square positions, the 55..70
height range, and the vanilla end-stone cone.

Vanilla reference: EndIslandFeature (BasicFeature::EndIsland and
minecraft:end_island_decorated), already ported in horizon-world.

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
2026-10-27 17:52:07 -04:00
Ludovic MarieandClaude Opus 5.5 150a29974d Merge main into fix/burn-in-daylight-list
lan.rs mob_meta now builds all shared flags on main: its on-fire bit uses
is_on_fire, so a fire-immune mob never reports burning. game.rs keeps main's
charged-blaze flames with is_on_fire.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:43:53 +01:00
Kacpherek 06fe3e8e69 Raise the bounce game event when a player bounces
A slime block or bed bounce made no vibration. Vanilla's Entity.restituteMovementAfterCollisions posts GameEvent.BOUNCE after bounceOn (Entity.java:891); the bounce motion event was dropped.
2026-10-27 22:40:48 +01:00
Kacpherek 158d99a528 Raise entity_mount and entity_dismount game events
Riding a vehicle and getting off it made no vibrations, so sculk sensors and wardens never heard it. Vanilla's startRiding and removeVehicle post entity_mount and entity_dismount at the vehicle (Entity.java:2533, 2573); derived here from the vehicle a rider had last tick.
2026-10-27 22:40:48 +01:00
Kacpherek 98da8f82b8 Enforce max_command_forks in /execute
/execute had no limit on how many sources its modifiers produce. Vanilla's BuildContexts ends the chain with command.forkLimit when the sources would reach the max_command_forks rule (silently after a forking modifier).
2026-10-27 22:40:48 +01:00
Kacpherek 465832b0e4 Honour global_sound_events for the wither, dragon and portal sounds
The wither spawn roar was a local sound at the wither, and none of the three global events checked the rule. Vanilla sends them as global level events (heard everywhere) only with global_sound_events on; without it they are plain level events the client has no sound for (ServerLevel.globalLevelEvent).
2026-10-27 22:40:48 +01:00
Kacpherek 0318c132eb Let projectiles break chorus flowers and decorated pots
Arrows, snowballs and the other impact projectiles never broke either block. Vanilla's onProjectileHit destroys them (a pot is cracked first) when projectiles_can_break_blocks is on and the shooter may break blocks (Projectile.mayBreak).
2026-10-27 22:40:48 +01:00
Kacpherek 0f8e3a9b09 Format the mob sound changes 2026-10-27 22:40:25 +01:00
Kacpherek 4ce1eb6b0a Play the crossbow hit sound for arrows shot from a crossbow
CrossbowItem.createProjectile sets the arrow's sound event to item.crossbow.hit; every arrow hit played entity.arrow.hit.
2026-10-27 22:40:16 +01:00
Kacpherek 47d249e7dd Pick hurt and death sounds by water, size, age and shell
Drowned and guardians use their water or land pair, tiny slimes and magma cubes the _small pair, a closed shulker hurt_closed and a baby turtle the _baby pair, as getHurtSound and getDeathSound do. Horizon always used the plain sound.
2026-10-27 22:40:10 +01:00
Kacpherek ea6ebae264 Fix horse standing, angry, ambient and baby horse voices
RandomStandGoal plays getAmbientStandSound (the ambient sound) for every horse, not entity.horse.angry for horses only; makeMad uses getAngrySound (zombie horse its own, skeleton horse none, baby horse baby_horse.angry); a baby horse and a skeleton horse in water pick their own ambient, hurt and death sounds.
2026-10-27 22:40:10 +01:00
Kacpherek d878f3bed2 Give donkeys, mules and baby horses their own jump and eating sounds
playJumpSound is entity.donkey.jump or entity.mule.jump (skeleton_horse.jump_water in water); Horse.getEatingSound is entity.baby_horse.eat for a baby. Horizon played the horse's for all.
2026-10-27 22:39:37 +01:00
Kacpherek ec5fc9d5e4 Play horse hoof, wood, gallop and breathing steps
AbstractHorse.playStepSound plays entity.horse.step (baby_horse.step), step_wood on wood or for the first five ridden steps, and gallops every third step with Horse.playGallopSound's breathing. Horizon played the block's step sound.
2026-10-27 22:39:37 +01:00
Kacpherek 99948fdf3f Play the hostile fall sounds when a monster takes fall damage
Monster.getFallSounds is entity.hostile.small_fall and big_fall; Horizon played the generic pair.
2026-10-27 22:39:37 +01:00
Kacpherek 73ad5f1dbb Play the hostile swim sound for swimming monsters and hoglins
Monster and Hoglin override getSwimSound with entity.hostile.swim; Horizon played the generic swim sound for them.
2026-10-27 22:39:37 +01:00
Ludovic MarieandClaude Opus 5.5 eff8ae41c6 Merge main into fix/death-advancement-messages-to-guests
Kept system_message_to next to main's overlay_message in lan/hub.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:39:30 +01:00
Kacpherek 6e26990ad3 Run cargo fmt on the villager changes
Formatting only.
2026-10-27 22:39:05 +01:00
Ludovic MarieandClaude Opus 5.5 4d0962e611 Merge main into fix/fog-lava-and-night-vision
Kept fog_night_vision next to main's level_flat/level_min_y and main's
flat-world dark disc.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:38:12 +01:00
Kacpherek ffb6594600 Format the block behaviour changes 2026-10-27 22:37:59 +01:00
Kacpherek 632179bda6 Format
cargo fmt.
2026-10-27 22:36:44 +01:00
Ludovic Marie 4b4950d7c4 Merge branch 'refactor/be-layer-deps' into refactor/furnace-cooking 2026-10-27 22:36:31 +01:00
Ludovic MarieandClaude Opus 5.5 09043cd199 trident: only the owner picks up its trident while the owner is in the level
ThrownTrident.playerTouch hands the touch to AbstractArrow only when the
player owns the trident or the owner is gone (getOwner() == null). Without
this check any guest standing on another player's landed or returning
trident took it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:34:28 +01:00
Ludovic MarieandClaude Opus 5.5 1d693cb3c3 Read mouse.rs where the input crate keeps it now
#2750 moved it to crates/horizon-client-input.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:33:10 +01:00
Kacpherek c5dead28a0 Draw the poppy an iron golem offers
IronGolemFlowerLayer draws a poppy at the right arm while getOfferFlowerTick() is above 0, with NO_OVERLAY; Horizon only ran the offer, never drew it.
2026-10-27 22:33:06 +01:00
Kacpherek 15099724ed Draw the item a fox holds in its mouth
FoxHeldItemLayer puts the main hand item at the head's position, rolled and turned by the render state, in the ground display context; Horizon drew nothing.
2026-10-27 22:33:06 +01:00
Ludovic MarieandClaude Opus 5.5 69bb386a6d Merge main into dyed-armor-colour
main sends a wolf armor's damage and components in the body slot; the
worn armour's stacks go beside them, and the body slot never takes the
helmet's stack.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:33:03 +01:00
Ludovic MarieandClaude Opus 5.5 23e3cf465c Merge main into screen-cursor-centre
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:33:03 +01:00
Kacpherek 0d3ca74bd0 Break turtle eggs by stepping on them
TurtleEggBlock.stepOn calls destroyEgg(100) for any step that is not careful; only the fall path (1 in 3) existed.
2026-10-27 22:33:01 +01:00
Kacpherek 52a820e708 Match vanilla's shadow radius for armadillos, elder guardians, happy ghasts and shulkers
ArmadilloRenderer 0.4, ElderGuardianRenderer 1.2, HappyGhastRenderer 2.0 and ShulkerRenderer 0.0; Horizon used the 0.5 default for all four.
2026-10-27 22:32:59 +01:00
Kacpherek f69088012b Format the hostile mob fixes 2026-10-27 22:32:05 +01:00
Kacpherek 154aaa2051 Fix clippy and format the passive mob fixes 2026-10-27 22:32:05 +01:00
Kacpherek 1ab273641e Disarm a tripwire with shears before breaking it
TripWireBlock.playerWillDestroy sets DISARMED (flags 260) and fires the shear event when the main hand holds shears; the property was never set.
2026-10-27 22:31:36 +01:00
Kacpherek 85f2a9ad87 Draw an enderman's carried block as its baked model
CarriedBlockLayer submits the block's model on the unit cube. Horizon drew a cube of one face sprite, centred half a block off, so grass lost its sides and flowers became cubes.
2026-10-27 22:31:30 +01:00
Kacpherek 11b36e3867 Read and write saved NBT without the node cap
Chunks, entities, POI and .dat files were capped at 1M nodes, so a chunk with thousands of full containers would not load. Vanilla reads them with NbtAccounter.unlimitedHeap().
2026-10-27 22:31:27 +01:00
Kacpherek 785d579f47 Stop vines spreading when the spread_vines rule is off
VineBlock.randomTick only runs when GameRules.SPREAD_VINES is on; Horizon ignored the rule.
2026-10-27 22:31:17 +01:00
Kacpherek 382cc39713 Save and restore previousPlayerGameType
ServerPlayer.storeGameTypes writes the previous game mode (the switcher's return mode) next to playerGameType; Horizon never wrote it, so a fresh save dropped it.
2026-10-27 22:30:45 +01:00
Kacpherek 16d71b0a24 Cast a giant's shadow at 3 blocks
GiantMobRenderer passes 0.5 times its scale of 6 as the shadow radius; the default 0.5 was used.
2026-10-27 22:30:06 +01:00
Kacpherek f83310a891 Shake a skeleton while it freezes into a stray
Skeleton.isShaking is its freezing conversion, synced as DATA_STRAY_CONVERSION_ID; Horizon never sent or read it, so the shake never showed.
2026-10-27 22:30:06 +01:00
Kacpherek 3fe8aa76b2 Build a golem whenever a carved pumpkin is placed
CarvedPumpkinBlock.onPlace runs on every placement; /setblock, /fill, /clone and piston moves never completed a golem.
2026-10-27 22:30:05 +01:00
Kacpherek f351bcb06b Keep a map's banner and frame markers when it is saved
Horizon wrote empty banners and frames lists, so a vanilla map lost its markers on the first save. They are kept as read; a locked copy keeps the banner markers (MapItemSavedData.locked).
2026-10-27 22:29:58 +01:00
Kacpherek bbe656e4ce Use the monsters_burn window for sunlight burning
Undead burned whenever sky darkness was below 4, including the End and storms with a bright sky. Vanilla's Mob.isSunBurnTick requires the gameplay/monsters_burn attribute: overworld only, true from tick 23460 to 12542 (day.json).
2026-10-27 22:29:48 +01:00
Kacpherek 1861c82434 Make TNT, TNT minecarts and End crystal blasts honour the drop decay rules
They always used plain destruction. Vanilla's ExplosionInteraction.TNT picks decay or plain destruction from tnt_explosion_drop_decay, and BLOCK (End crystals, the dragon respawn spikes) from block_explosion_drop_decay (ServerLevel.getDestroyType, EndCrystal.java:102, DragonRespawnStage.java:64).
2026-10-27 22:29:48 +01:00
Kacpherek dc75e77eb1 Pick a parent's variant at random for pig, cow and chicken babies
Pig, Cow and Chicken.getBreedOffspring take either parent's variant with equal odds; Horizon always took the initiator's.
2026-10-27 22:29:41 +01:00
Kacpherek 94e8c85fd0 Roll a natural salmon's size
Salmon.finalizeSpawn picks small 30, medium 50 or large 15; natural spawns kept variant 0, so every salmon rendered small.
2026-10-27 22:29:29 +01:00
Kacpherek 28c30923f4 Play the sound variant of pigs, cows, chickens, wolves and cats
Vanilla plays the mob's sound variant set, the baby set for babies (Pig/Cow/Chicken/Wolf/Cat.getSoundSet); Horizon always played the classic adult sounds and never gave a natural spawn a variant. Tame cats also purreow one time in four (Cat.getAmbientSound).
2026-10-27 22:29:29 +01:00
Kacpherek 8ba30576d1 Back up an unreadable player file and fall back to its .dat_old
A player file that failed to load was written over with no copy. Vanilla copies it to <uuid>_corrupted_<time>.dat and loads <uuid>.dat_old (PlayerDataStorage.load).
2026-10-27 22:29:07 +01:00
Kacpherek 8f26771053 Fall back to level.dat_old and keep an unreadable level.dat
An unreadable level.dat was treated as a new world and overwritten. Vanilla reads level.dat_old instead and moves the bad file to level.dat_corrupted_<time> (LevelStorageSource.restoreLevelDataFromOld).
2026-10-27 22:29:07 +01:00
Kacpherek 2b74cfae4a Drop a beehive with its bees when it is broken in creative
BeehiveBlock.playerWillDestroy drops the hive item with bees and honey_level when drops are prevented and block_drops is on; creative breaks dropped nothing.
2026-10-27 22:29:06 +01:00
Kacpherek 60a4edaef9 Chain a hanging sign under a ceiling hanging sign
CeilingHangingSignBlock.useItemOn passes for a hanging sign item on the DOWN face; Horizon opened the editor instead of placing the next sign.
2026-10-27 22:28:29 +01:00
Ludovic MarieandClaude Opus 5.5 5096b41100 Merge main into #2886: offline names need an owner since main
Keep main's owner check (play_as takes the store, Sign out updates
the owner state) with the branch's load-mutate-save and skin cleanup.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:28:22 +01:00
Kacpherek e9eaa66542 Offer the iron golem's poppy for 400 game ticks, not 800
OfferFlowerGoal.start sets tick to adjustedTickDelay(400), which is 200 goal ticks. Horizon counted 400 goal ticks, so the offer lasted twice as long.
2026-10-27 22:28:16 +01:00
Kacpherek 33921460cc Save the world border in the overworld's data folder
Horizon wrote data/minecraft/world_border.dat at the world root, which vanilla ignores. Vanilla's ServerLevel keeps it in each dimension's data folder; the old root file is still read as a fallback.
2026-10-27 22:28:04 +01:00
Kacpherek 2c2aa41619 Clear a door's lower half when the tool would not drop it
DoorBlock.playerWillDestroy removes the bottom half for creative or !hasCorrectToolForDrops (iron door by hand); only creative was handled.
2026-10-27 22:27:59 +01:00
Ludovic Marie 50ebc9de60 Merge remote-tracking branch 'origin/main' into cg24/f2875
# Conflicts:
#	README.md
2026-10-27 22:27:57 +01:00
Kacpherek f164427cba Use the full not_scary_for_pufferfish list for the puff trigger
Turtles, guardians, tadpoles, nautiluses and sulfur cubes no longer puff a pufferfish, and axolotls do, as in Pufferfish.PUFFER_SCARY_MOB and the tag.
2026-10-27 22:27:50 +01:00
Ludovic MarieandClaude Opus 5.5 9fcfdaa1ea Portal arrivals: rustfmt, and name vanilla's portal ticket
resolve_arrivals keeps its own doc comment; the area an arrival keeps is
vanilla's TicketType.PORTAL, which has no ENTITY type.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:27:47 +01:00
Kacpherek b77301b6eb Let an allay wander up to 16 blocks from its player
StayCloseToTarget only pulls an allay back once its target is 16 blocks away (closerThan(tooFar)); Horizon pulled it back beyond 4.
2026-10-27 22:27:44 +01:00
Ludovic MarieandClaude Opus 5.5 3be828d012 Merge main into #2813: reach first, then spawn protection, then the border
handleBlockBreakAction refuses a dig out of reach before it looks at spawn
protection; ServerLevel.mayInteract (spawn protection, then the world
border) comes after.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:27:32 +01:00
Kacpherek e08adcd31c Anger nearby piglins when an ender chest is opened
EnderChestBlock.useWithoutItem calls PiglinAi.angerNearbyPiglins(true); the ender chest branch skipped the generic container path that does it.
2026-10-27 22:27:31 +01:00
feeldev d480ce24dd fix(client): book text runs and copies its click commands and shows its hover text
BookViewScreen.handleClickEvent: a run_command click closes the book
(closeContainerOnServer, then clickCommandAction with no screen after it)
and runs the command; copy_to_clipboard copies and leaves the book open;
suggest_command does nothing, as BookViewScreen has no insertText. The
page's words show their show_text hover event as a tooltip
(HoveredTextEffects.TOOLTIP_AND_CURSOR), as the chat does.
2026-10-27 16:27:30 -05:00
Kacpherek 830a9d3d84 Give the skeleton horse vanilla's water drag
SkeletonHorse.getWaterSlowDown is 0.96; Horizon used the 0.8 default so it swam too slowly.
2026-10-27 22:27:21 +01:00
Kacpherek 6f0d56512d Stop mules from breeding
Mule inherits AbstractHorse.canMate, which is false; only horses and donkeys (Horse/Donkey.canMate) find a partner.
2026-10-27 22:27:21 +01:00
Kacpherek 482444afc4 Roll vanilla's donkey, mule and zombie horse attributes on spawn
A natural donkey or mule rolled a horse jump; vanilla rolls only health and leaves jump at 0.5. A zombie horse uses its own jump 0.5-0.7 and speed (9+3 rolls)/42.16 (AbstractChestedHorse, ZombieHorse).
2026-10-27 22:27:09 +01:00
Kacpherek 0d7f842b09 Hurt the dragon with a wind charge
AbstractWindCharge.onHitEntity hurts whatever it hits for 1, the dragon's parts included (crystals are not hit); Horizon only looked at ordinary mobs.
2026-10-27 22:26:23 +01:00
Kacpherek a1dfcbf2b3 Remove the dragon's flame cloud when it takes off
DragonSittingFlamingPhase.end discards the cloud it made; Horizon left it to last its full 200 ticks after the phase ended.
2026-10-27 22:26:23 +01:00
Kacpherek 5703fdfdc4 Let slimes, ghasts and calm piglins not block a bed
ServerPlayer.startSleepInBed looks for Monster entities that isPreventingPlayerRest; slimes, ghasts, phantoms and unangry zombified piglins are not. Horizon counted every hostile-category mob.
2026-10-27 22:26:23 +01:00
Kacpherek 57494e6871 Slow mobs in cobwebs again
WebBlock.entityInside makes a mob stuck at 0.25/0.05/0.25 (0.5/0.25/0.5 with Weaving) and ends its fall; spiders and withers ignore it. The block-effects pass replaced the earlier cobweb code and dropped it.
2026-10-27 22:26:23 +01:00
Kacpherek ad8ef0b7e8 Wither a skull's target by difficulty, and only on a hit
WitherSkull.onHitEntity adds Wither for 10 s on Normal, 40 s on Hard and nothing on Easy, only when the hit hurt; Horizon always gave 10 s.
2026-10-27 22:26:23 +01:00
Kacpherek f9fca5688d Give witches their resistance to magic
Witch.getDamageAfterMagicAbsorb takes 85% off #witch_resistant_to (magic, indirect_magic, sonic_boom, thorns) and all of the witch's own damage; Horizon had no such rule.
2026-10-27 22:26:23 +01:00
Kacpherek 4156a6ac93 Aim a mob's crossbow by the world difficulty
RangedAttackMob.rangedAttackUncertainty is 14 - 4 * difficulty; Horizon always used the Normal value 6.
2026-10-27 22:26:23 +01:00
Kacpherek 6c3defa821 Hit with a hoglin or zoglin every 40 ticks, 15 for a baby
HoglinAi and Zoglin use MeleeAttack.create(40) for adults and create(15) for babies; Horizon used the 20-tick default.
2026-10-27 22:26:23 +01:00
Ludovic MarieandClaude Opus 5.5 8057d27511 Worn armour: only a dyeable layer takes the stack's dye
getColorForLayer returns white for a layer without Dyeable, whatever
dyed_color the stack has.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:26:12 +01:00
Kacpherek 4e814a152a Make every wolf beg for a bone or its food
BegGoal.playerHoldingInteresting is a bone or Wolf.isFood for any wolf. Horizon had untamed wolves beg only for bones and tamed wolves only for food.
2026-10-27 22:25:20 +01:00
Kacpherek 3f378dda96 Make a chased child villager run away in tag
PlayTagWithOtherKids always chased the first friend. Vanilla flees to a village spot (LandRandomPos 20, 8, speed 0.6) when a friend chases it, otherwise chases the kid with one to five chasers, else any friend.
2026-10-27 22:25:02 +01:00
Ludovic MarieandClaude Opus 5.5 70068076f2 Keep set_baby's doc comment on set_baby
The two new functions went in between it and its comment.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:24:39 +01:00
Ludovic MarieandClaude Opus 5.5 2466ccec98 Write and read the anger only for the NeutralMobs that keep it
anger_end_time and angry_at are NeutralMob's: here the wolf, iron golem,
polar bear, enderman and zombified piglin. The bee keeps its own anger and
a piglin's is a brain memory. Every other mob wrote anger_end_time: -1,
which vanilla never writes for them, and a bee's value from a vanilla save
was replaced by it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:24:33 +01:00
Ludovic MarieandClaude Opus 5.5 d3121cea67 Merge main into neutral-mob-anger
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:24:27 +01:00
Kacpherek 59b67af6a8 Send villagers to the dropped items they want
NearestItemSensor was a no-op and GoToWantedItem never started for villagers. Vanilla scans 32 blocks every 20 ticks for the nearest item the villager wants and sees, and walks to it at 0.5 when it is within 4 blocks.
2026-10-27 22:23:43 +01:00
Ludovic MarieandClaude Opus 5.5 f1a8cc6a0d Merge main into join-without-rescanning-registries
main moved hover_rows out of chat.rs and added UiState::new; the resource
id cache sits after it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:22:46 +01:00
Kacpherek 1b469a542f Let villagers with bone meal grow crops
UseBonemeal never started: it computed the bone meal count and returned false. Vanilla picks a crop below max age around the villager every 10 ticks, holds bone meal and grows it, then picks again 40 ticks later, for up to 80 cycles.
2026-10-27 22:21:24 +01:00
Kacpherek 567e92e2a2 Let villagers harvest torchflower crops
TorchflowerCropBlock is a CropBlock with max age 1. It was missing from the villagers' crop lists, so farmers planted torchflower seeds but never harvested them.
2026-10-27 22:20:12 +01:00
Kacpherek 5388064d88 Ring the bell for real when a villager rings it
RingBell calls BellBlock.attemptToRing: the bell shakes, the sound plays and the block_change game event fires. Horizon only played the sound.
2026-10-27 22:19:17 +01:00
Kacpherek 20ffd8e538 Tell villagers within 32 blocks that a bell was rung
BellBlockEntity.updateEntities sets HEARD_BELL_TIME on living entities within 32 blocks. Nothing set it, so a rung bell never sent villagers into the hide activity.
2026-10-27 22:18:52 +01:00
asownerytandClaude Opus 5.5 d321b656f6 Horses, donkeys, mules and llamas sync their rolled max health
finalizeSpawn's randomizeAttributes, setOffspringAttributes and a loaded
save wrote the rolled max health (and a horse's speed) straight into the
mob's stats instead of the attribute bases, so the server never sent them
and a rider's client drew the type's 53 health: 26 hearts. They are bases
now (AttributeInstance.setBaseValue), synced like vanilla's.

Fixes #2869

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 10:14:00 +13:00
Kacpherek 5dd3bf02d3 Tick thrown tridents on a server that has no player of its own
tick_tridents quit when there was no local player, so a server never moved, hit with, stuck or returned a trident. It now finds the owner and pickers among the level's players (guests), returns Loyalty tridents to the thrower and lets guests pick them up; the spawn packet carries the thrower's id, as Projectile.getAddEntityPacket does.
2026-10-27 22:08:18 +01:00
Kacpherek c3e19973cf Fire item_used_on_block for a compass used on a lodestone
Vanilla fires ITEM_USED_ON_BLOCK when a compass locks onto a lodestone (ServerPlayerGameMode via useOn).
2026-10-27 22:08:13 +01:00
Kacpherek 7aaa4b433b Fire avoid_vibration when a sneaking step is ignored
Vanilla's VibrationSystem fires AVOID_VIBRATION for a sneaking player whose step a sculk sensor or a warden ignores; a shrieker cannot trigger it.
2026-10-27 22:08:13 +01:00
Kacpherek 3cf601f760 Fire kill_mob_near_sculk_catalyst for the player who hurt the mob
Vanilla's SculkCatalystBlockEntity fires it for the player who last hurt a mob whose experience a catalyst takes.
2026-10-27 22:08:13 +01:00
Kacpherek 39327fca33 Fire allay_drop_item_on_block when an allay drops at the liked block
Vanilla's AllayAi.throwItem fires ALLAY_DROP_ITEM_ON_BLOCK for the liked player with the block below the deposit.
2026-10-27 22:08:12 +01:00
Kacpherek 6489cdf829 Fire player_interacted_with_entity for leashing, feeding and repairing
Vanilla's PlayerInteractTrigger fires after a successful item use on an entity: leashing, feeding a baby sniffer, repairing wolf armour. Item predicates now check the damage component, and guests' interactions fire it too.
2026-10-27 22:08:12 +01:00
Kacpherek 1727f2acf1 Fire player_sheared_equipment when wolf armour is sheared off
Vanilla's Mob.shearItem fires PLAYER_SHEARED_EQUIPMENT with the armour and the mob. A wolf's armour now counts as its body slot, with its durability, for entity and item predicates.
2026-10-27 22:08:04 +01:00
Kacpherek 079963f27e Fire bee_nest_destroyed with the bees left inside
Vanilla's BeehiveBlock.playerDestroy fires BEE_NEST_DESTROYED after emptying the hive, with the tool and the bees still inside; a silk touch break keeps them in.
2026-10-27 22:07:25 +01:00
Kacpherek 3c72bf2d79 Fire spear_mobs when a spear stabs several mobs in one use
Vanilla's KineticWeapon fires SPEAR_MOBS with the number of living entities stabbed in the use, so stabbing five or more completes the spear advancement.
2026-10-27 22:07:25 +01:00
Kacpherek 16cda1637b Fire lightning_strike and channeled_lightning for the right players
Vanilla's LightningBolt fires LIGHTNING_STRIKE when it is discarded, for players within 256 blocks, counting the fires it set and the mobs it missed as bystanders, and CHANNELED_LIGHTNING for the player who channelled it. The lightning rod advancement no longer passes for every bolt.
2026-10-27 22:07:24 +01:00
Kacpherek 7bfc737d81 Fire item_durability_changed when a player's item takes damage
Vanilla's ItemStack.applyDamage fires ITEM_DURABILITY_CHANGED for the owning player, with the stack as it was before the damage. Tools, weapons, armour, elytra, shield, mace, brush and the warped fungus on a stick now fire it, which lets ride_strider complete. The durability and delta ranges are not read; no advancement uses them.
2026-10-27 22:06:27 +01:00
Kacpherek 2e8c8e1dc7 Fire effects_changed when the player's effects change
Vanilla's ServerPlayer.onEffectAdded, onEffectUpdated and onEffectsRemoved fire EFFECTS_CHANGED; the axolotl is the source of the Regeneration it gives (Axolotl.applySupportingEffects). This lets all_effects, all_potions and kill_axolotl_target complete. Changes are seen once per tick, so one undone within the tick does not fire.
2026-10-27 22:06:27 +01:00
Kacpherek 727bd7f2dc Fire nether_travel when the player returns from the Nether
Vanilla's ServerPlayer keeps enteredNetherPosition when the player goes into the Nether and triggerDimensionChangeTriggers fires NETHER_TRAVEL with it on the way back to the Overworld. This lets fast_travel complete.
2026-10-27 22:05:42 +01:00
Kacpherek e54cf96271 Fire ride_entity_in_lava while riding a vehicle through lava
Vanilla's ServerPlayer.trackEnteredOrExitedLavaOnVehicle fires RIDE_ENTITY_IN_LAVA each tick the vehicle is in lava, measured from where the ride entered it. This lets ride_strider_in_overworld_lava complete.
2026-10-27 22:05:24 +01:00
Kacpherek 47bb5c668f Fire fall_after_explosion for a fall after a wind charge blast
Vanilla's ServerPlayer.onExplosionHit remembers the blast and trackStartFallingPosition fires FALL_AFTER_EXPLOSION when the player then falls. This lets who_needs_rockets complete.
2026-10-27 22:05:16 +01:00
Kacpherek a90e3bc36d Fire fall_from_height when a fall from high up ends
Vanilla's ServerPlayer.resetFallDistance fires FALL_FROM_HEIGHT with the position the fall started from (trackStartFallingPosition). This lets fall_from_world_height complete.
2026-10-27 22:05:06 +01:00
asowneryt 513aed64c5 Merge upstream main into fix/2802-projectile-death-message 2026-10-28 10:02:41 +13:00
Kacpherek 305ffc4e39 Send End portal frame fill effects to nearby players 2026-10-27 22:02:24 +01:00
Kacpherek cac7fa95fd Show the End gateway spawn burst and sound 2026-10-27 22:02:24 +01:00
Kacpherek 1db835fa0c Apply arrow weapon knockback enchantments 2026-10-27 22:02:19 +01:00
Kacpherek 60c5d9fefd Apply falling stalactite damage and landing sounds 2026-10-27 22:02:18 +01:00
Kacpherek 7ab2b20bd5 Let mouse buttons toggle cinematic camera 2026-10-27 22:02:17 +01:00
Kacpherek dddcc99726 Start with HDR off after a start that died switching it on
HDR output now writes horizon-hdr-pending before it is switched on and removes it after the first presented HDR frame. A start that finds the file turns hdr off in horizon-graphics.txt and logs it, so a native crash in HDR output costs one start, not every start.
2026-10-27 22:02:16 +01:00
Kacpherek 736842d40f Unlock the Distortion Effects slider
The option was greyed out and always 100 percent. The slider now moves and saves screenEffectScale, which vanilla's Options.screenEffectScale scales camera shakes and the nausea effects by.
2026-10-27 22:01:26 +01:00
Kacpherek ba37fa75d6 Add the Directional Audio and Music Toast sound rows
Vanilla's SoundOptionsScreen has both next to Show Subtitles and Music Frequency. Nothing in Horizon's audio code reads them yet, so they are greyed out. Audio Device is left out: Horizon opens the default output and cannot switch it.
2026-10-27 22:01:14 +01:00
Kacpherek e9fbcfef78 Add the Online options page with Allow Server Listing
Vanilla's OnlineOptionsScreen has Allow Server Listing, which ClientInformation carries to servers, and the notification and presence options. Horizon has no friends list or Realms, so those three rows are greyed out.
2026-10-27 22:00:52 +01:00
Kacpherek 206b7acee4 Add the Skin Customisation options page and hide skin layers
The seven skin layers and Main Hand were saved but had no page. Vanilla's SkinCustomizationScreen lists them; the layers hide on the player model and reach servers in ClientInformation (Options.createInformation), and other players' layers follow Avatar.DATA_PLAYER_MODE_CUSTOMISATION.
2026-10-27 22:00:31 +01:00
Kacpherek 222dc28be9 Open the saddle and armour screen on a tame nautilus
Shift + use on a tame nautilus returned without opening anything. Vanilla AbstractNautilus.mobInteract opens NautilusInventoryScreen (saddle and body armour, no chest), and E while riding it opens the same screen. The mount menu now covers nautilus with its slot layout, texture, armour sprite and saddle sound.
2026-10-27 21:59:47 +01:00
Kacpherek 98d0c6d1a3 Show the server's Code of Conduct and wait for the answer
Horizon accepted every server's Code of Conduct without showing it. The text now opens on a screen with Acknowledge and Disconnect, and the server waits for the answer. The accepted text's hash is kept per server in servers.dat, as vanilla does (acceptedCodeOfConduct, String.hashCode).
2026-10-27 21:59:47 +01:00
Kacpherek 81be26d686 Accept flat_all_dimensions as a dedicated level-type
26.3's world presets include minecraft:flat_all_dimensions, so the dedicated server's level-type list accepts it. It still generates normal terrain.
2026-10-27 21:59:46 +01:00
Kacpherek 1a8d0ef420 Make unknown loot conditions and number providers agree
Unknown loot conditions fail and unknown number providers give 0 in both loot engines. The block engine used to pass conditions and give 1. location_check stays true there, as it has no world access at the offset.
2026-10-27 21:59:46 +01:00
Kacpherek 41f60562fd Add /datapack create
/datapack create <id> <description> fell into the disable branch and replied with an empty unknown-pack key. It now writes <world>/datapacks/<id>/ with a data folder and a pack.mcmeta, and replies with vanilla's success and failure keys (DataPackCommand.createPack).
2026-10-27 21:59:46 +01:00
Kacpherek 1deb3bd36c Send decorated pot sherds to LAN guests
Decorated pots showed no sherds for LAN guests. A pot is not a block entity here, so the hub and the chunk encoder read the saved tag for it and send the sherds, loot table and item (DecoratedPotBlockEntity.getUpdateTag, saveCustomOnly). Inserting into a pot marks it for update, and a client stores a pot update with the chunk.
2026-10-27 21:59:46 +01:00
Kacpherek afd40fdbfa Add the structure set's locate_offset to /locate
/locate adds the set's locate_offset (buried treasure's [9, 0, 9]) to the chunk corner, as StructurePlacement.getLocatePos does.
2026-10-27 21:59:46 +01:00
Kacpherek 511f2c42d8 Apply loot set_components so trial armour keeps its trim
Trial chamber equipment tables put a copper trim on chainmail and iron armour with set_components (SetComponentsFunction), which Horizon ignored. The trim now stays on the mob's worn armour, is saved with the mob and goes onto its drops.
2026-10-27 21:59:46 +01:00
Kacpherek 136b352e4f Give 15 vanilla damage types their own /damage kind
/damage fell back to the Generic kind for cramming, falling_anvil, falling_block, fireworks, generic_kill, lightning_bolt, out_of_world, outside_border, sonic_boom, spit, stalagmite, starve, sulfur_cube_hot, wind_charge and wither. Each now has its own kind and follows the vanilla damage type tags. Creaking, Warden and Wither ask "bypasses invulnerability" instead of "is Generic".
2026-10-27 21:59:46 +01:00
Kacpherek 2ad7b68710 Send the trial spawner mob to LAN guests
Trial spawners never showed their mob for LAN guests. The update tag now carries the spawn data the mob is drawn from, as TrialSpawnerStateData does. next_mob_spawns_at is left out: the client never reads it and it depends on the block state.
2026-10-27 21:59:46 +01:00
Kacpherek 2607d06f94 Use a saved world's preset for new chunks
New chunks always used minecraft:normal, so saved amplified, large-biome and single-biome worlds got normal terrain. The preset is now read from the world's overworld generator (noise settings and biome source), and far decoration uses it too.
2026-10-27 21:59:46 +01:00
Kacpherek ff1b01616f List every online player in /list
/list printed a fixed "1 of a max of 8" with only the host. It now lists the host of a single-player or LAN world first, then the placed guests in join order, with the real count and the hub's max, as ListPlayersCommand does. /list uuids shows each name with its UUID. A single-player owner is already a guest of the in-process server and is not listed twice.
2026-10-27 21:59:46 +01:00
Kacpherek 203fb2db89 Send the vault display item to LAN guests
Vaults never showed their display item for LAN guests. The update tag now carries the shared data that holds it (VaultBlockEntity.getUpdateTag). A guest's update keeps the turn the client has built up, since the tag does not carry it.
2026-10-27 21:59:46 +01:00
Kacpherek 2f7edd1524 Send banner patterns to LAN guests
Banners showed no pattern layers for LAN guests and the single-player window. The update tag now carries the patterns, the custom name and the components, as BannerBlockEntity.getUpdateTag (saveWithoutMetadata) does.
2026-10-27 21:59:46 +01:00
Kacpherek b8f0cde900 Fix the Metal HDR colour space lookup that crashed macOS at start
wgpu-hal's Metal backend read the CoreGraphics colour space names (kCGColorSpaceITUR_2100_PQ and others) as if dlsym returned the string, but it returns the address of the global that holds it. Configuring a PQ swapchain passed that address to CGColorSpaceCreateWithName, which faulted with no Rust panic. Read through the pointer.
2026-10-27 21:59:16 +01:00
Kacpherek 732be56485 Launcher: show what installed versions take on disk
The Versions page measures each installed version's folder on a background thread and shows the size on its row and the total under the title, so players can see where 20 GB went.
2026-10-27 21:59:07 +01:00
Kacpherek ab955d66c4 Launcher: prune after each install and add a Settings switch
The window runs the same cleanup as the command line once an install or update is saved, and Settings gets a 'Delete old versions automatically' switch (on by default), translated to French and Russian.
2026-10-27 21:58:52 +01:00
asownerytandClaude Opus 5.5 c4348be8d2 Check the killing blow of player_killed_entity: Return to Sender needs the ghast's fireball
KilledTrigger.TriggerInstance.matches tests killing_blow, a
DamageSourcePredicate, against the damage that killed the entity.
Horizon ignored it, so any ghast kill awarded Return to Sender (and any
breeze kill Blowback, any skeleton kill from 50 blocks Sniper Duel).

Fixes #2832

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 09:56:46 +13:00
Kacpherek 8fdcb37ddd Ask for a backup before upgrading a world that needs file fixes
Opening a world saved before DataVersion 4899 upgraded it in place with no prompt. Vanilla asks first (BackupStatus.FILE_FIXING_REQUIRED): Create Backup and Load, I Know What I'm Doing!, or Cancel. The backup reuses Make Backup, then the world opens, as EditWorldScreen does.
2026-10-27 21:56:00 +01:00
Kacpherek e9b71c288a Launcher: delete old release versions after an install or update
Each version is about 790 MB and an update adds one. After an install the launcher now deletes every release version except the newest two, the selected one, the ones instances pin and any the game runs from. Turn it off with prune-old-versions, change the count with keep-versions.
2026-10-27 21:55:05 +01:00
AndriiandClaude Sonnet 5.5 db27568929 Merge upstream main into fix/sweep-attack-2764
Fold upstream's player sweep (Sweep, sweep_players) into mace::sweep_damage;
keep melee_damage in the dragon attack; keep enchanted_damage next to
enchant_damage_bonus.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-27 21:54:35 +01:00
Kacpherek 9c9ce0b435 Keep a server item's block entity data for the op warning
minecraft:block_entity_data stayed raw bytes after the item sync, so a command block or spawner from a server had no data for the op warning or a spawner's tooltip. It is read as TypedEntityData (the block entity type, then its data) and given the id the saved component carries.
2026-10-27 21:52:10 +01:00
Kacpherek af41d0325c Read a server item's container contents for its tooltip
minecraft:container stayed raw bytes after the item sync, so a shulker box from a server showed no items in its tooltip. It is now read as ItemContainerContents (an optional template per slot), the way the saved container is.
2026-10-27 21:52:10 +01:00
Kacpherek e21957efd7 Send a server's own components back for unedited creative stacks
The creative slot packet rebuilt every stack from the few components Horizon models, so moving an item with a container, unbreakable, lore or a kept raw component sent the server a stripped copy. A stack read from the wire now keeps its patch, and it goes back as it came while nothing but the count has changed.
2026-10-27 21:52:10 +01:00
Kacpherek 6790913e0b Send creative slot stacks with length-prefixed components
SetCreativeModeSlot uses ItemStack.OPTIONAL_UNTRUSTED_STREAM_CODEC, which puts each component value behind its byte length. Horizon wrote the plain codec, so a vanilla server read the first byte of every component as a length and kicked the player with a DecoderException.
2026-10-27 21:48:00 +01:00
asownerytandClaude Opus 5.5 deea1a5b83 entity_model: make the bat's animation states accessible before setting them
VanillaEntityAnimOracle.batHistory set Bat.flyAnimationState and
restAnimationState through getField(..).set without setAccessible, which
Java refuses for a final field on every JDK: the bat case failed with
IllegalAccessException and took the harness down with it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 09:45:11 +13:00
RVZO6andClaude Opus 5.5 32a0a11fed Keep a move behind a dig and its arm swing for the next tick
The hub hands a dig (handlePlayerAction) to the game after its pass, and
a move queued right behind it waited for the next tick so it was not
checked against the block the player had just broken. A creative break
sends the arm swing (Punch) between the dig and the move, so the move
was handled in the same pass: the block was still there, the move was
refused and the player sent back. Digging straight down, the player
sank into the block, was put back on top of it a tick later, then fell.

Any move left in the queue now waits, with what is queued before it,
as vanilla handles the packets in order. Traced with horizon-ctl in a
creative world: the player rose back after 6 to 7 of 8 breaks before,
after none now.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 15:47:14 -04:00
Ludovic MarieandClaude Opus 5.5 f6807fe7cd refactor: spawner, trial spawner and beehive logic as extension traits
Move-only step B8 of the horizon-client crate split. The spawner, trial
spawner and beehive data live in chunk::block_entity since the
BlockEntity cut; their behaviour needs mobs, so it stays above. Once the
data is in its own crate, Rust allows no inherent impl outside it, so
each impl block becomes an extension trait in the same module, with the
same method bodies:

- beehive: OccupantExt, BeehiveExt
- mobs::spawner: SpawnDataExt, SpawnerExt
- mobs::trial_spawner: TrialConfigExt, TrialSpawnerExt

The Default impls of Spawner, TrialConfig and TrialSpawner (plain field
values) move next to their structs, since a foreign trait impl must sit
with its type. Callers add one `use` for the trait.

No behaviour change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 20:00:59 +01:00
Ludovic MarieandClaude Opus 5.5 a34a41e1b5 refactor: move the furnace's recipe logic out of containers
Move-only step B7 of the horizon-client crate split. `impl Furnace`
needed `crafting::Recipes`, the one gameplay module containers still
reached. Its four methods (set_input, recipe_experience, input_clicked,
tick) move unchanged to a new `furnace` module, as the extension trait
`FurnaceCooking` implemented for the Furnace data, with the two helpers
only they use (remainder, total_time). Callers add one `use`.

containers now depends only on modules of the render/world layer
(skins and tab_list included), so it can move into that crate whole.

No behaviour change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 19:59:55 +01:00
Kacpherek 22cab3eeb3 Open windowed after the window system kills the game in fullscreen
When winit's event loop ends with ExitFailure (on Linux, a compositor
dropping the connection after a protocol error) while fullscreen is on,
turn fullscreen off in options.txt before the error goes on, so a
fullscreen the system can't show doesn't crash every launch (#2904).
2026-10-27 19:02:20 +01:00
Hector EcclesandDeepSeek V4.1 Flash 783c25e9bc Draw chorus plants in End LOD terrain
Distant End terrain now estimates chorus plants from the biome's own
chorus_plant feature: the stem height and the age-5 tip follow the real
feature, while its placement modifiers give the density. Real chorus also
survives the refined, visited and saved far-column summaries, which
previously dropped every non-solid block.

Vanilla reference: ChorusPlantFeature and ChorusFlowerBlock.generatePlant,
already ported in horizon-world.

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
2026-10-27 13:15:22 -04:00
socksproxandDevin 9b6faeb8cf launcher: the game's made-up name can be rolled for a new one
The game's own name (horizon-player.txt) isn't a kept offline name, so
Forget never applied to it and there was no way to be rid of it. Settings
> Account shows it with "New name", which writes a fresh PlayerNNNNN to
every existing game home — the default and each instance's — so the
player stays the same (new) player in its worlds, as PLAYER_FILES' copies
do.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 18:13:55 +01:00
socksproxandDevin 8cfc13fe2c launcher: the account list follows changes made outside the window
accounts.json was read once at start-up, so an offline name added or
forgotten through the CLI or another window left the running window
without the name's row (and its Forget button) until relaunch. Page
switches and opening the account chip's menu read it again, as the
game's made-up name already was.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 17:49:50 +01:00
socksproxandDevin a7e3bcf79f launcher: a forgotten offline name is gone for good
Forget removed the name from accounts.json but left skins/<name>.png and
skins/<name>.cape.png behind, the skin panel's file pick could still
record a look for a name that no longer existed, and the account rows'
Play as / Play offline / Sign out saved the window's stale account list,
which could write a name forgotten elsewhere (CLI, another window) back.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 17:41:07 +01:00
SenjinandClaude Opus 5.5 be26c6db88 HDR: log what the display offers, and when HDR turns on or off
The log already names the adapter and the surface format; a report of HDR
trouble now also shows whether the surface offers HDR10 or scRGB, and the
output, swapchain format and nits it turned on with.

Part of #1441.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:57:42 +01:00
leifiyooandClaude Haiku 5.5 e7f5201c1f Client: worn dyed leather armour shows its dye colour
EquipmentLayerRenderer.getColorForLayer takes a dyeable layer's colour from
the stack's minecraft:dyed_color and falls back to the asset's undyed colour.
The player's own armour and LAN guests' armour, plus armour stands, used only
the undyed colour, so dyed leather looked brown.

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
2026-10-27 16:30:53 +01:00
ElyTheCat c06a7d4511 Linux: OpenGL can present to the window
wgpu-hal's GLES backend takes its EGL display from the display handle the
wgpu instance was created with. Horizon created every instance without one
(`new_without_display_handle`), so on Linux EGL fell back to
`EGL_MESA_platform_surfaceless`: those configs cannot make a window surface,
the GL adapter reported every surface as incompatible (`gl not compatible with
provided surface`), and Auto never reached its OpenGL fallback.

Give the instance the window's X11 or Wayland display handle, and offer the
OpenGL choice in Options > Graphics on Linux (DirectX 12 stays Windows-only).

Tested on Intel HD 4000 with Mesa crocus (OpenGL 4.2), on X11 and Wayland:
graphicsApi:opengl, HORIZON_GRAPHICS_API=opengl and WGPU_BACKEND=gl each run
and present, and Auto falls back to OpenGL when Vulkan has no ICD.
2026-10-27 18:57:55 +03:30
AndriiandClaude Sonnet 5.5 8e36842891 Merge upstream main into fix/sweep-attack-2764
Drops the player-victim sweep in favour of #2659 and uses the
enchantment's sweeping_damage_ratio and upstream's particle queue.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-27 16:04:16 +01:00
levzzzandminimax-m3.1-flash 7b841af095 Merge the dyed armour colour fix onto upstream/main
Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 20:01:00 +05:00
levzzzandminimax-m3.1-flash 033c89a1b2 A dyed leather armour keeps its colour
The worn armour was always tinted with the colour leather has when it is
undyed, so a dyed leather piece showed the undyed brown; the trim of
another player's armour was not drawn at all, because only the item id
of the worn pieces was kept.

The vanilla's EquipmentLayerRenderer tints a dyeable layer with the
stack's dye colour (DataComponents.DYED_COLOR) and the colour it has
when undyed otherwise, and draws the trim as a layer over the armour.
The worn pieces keep their stack now, so the dye colour and the trim
reach the renderer (#2774).

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 20:00:53 +05:00
sirrayiandDevin 0580626784 Server: refuse digging and using blocks outside the world border
ServerLevel.mayInteract also requires WorldBorder.isWithinBounds(pos): START_DESTROY_BLOCK outside the border starts no dig, and UseItemOn uses nothing there (the block updates are still sent back). Part of #2743.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 14:58:38 +00:00
Claude Codeandminimax-m3.1-flash 2dc154ff7f The structure pieces ask isSolidRender, not the collision cube
The legacy structure pieces asked "is the collision shape a full block"
for the blocks around them, when they choose a chest's facing
(StructurePiece.reorient) and when the mineshaft checks its floor. That
is a different set of blocks than vanilla's BlockState.isSolidRender:
glass, leaves, ice, a spawner and a barrier are full collision blocks
but do not draw as solid, and soul sand and mud draw as solid without
a full collision shape.

The pieces now ask the blocks' own facts, the flag the worldgen already
keeps for every state (BlockProperty::SolidRender, which the exporter
writes from state.isSolidRender()), the way the monster room's own
reorient does. The chest's facing is decided in one function that takes
the blocks around the chest, so the rule can be checked against
vanilla's.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 19:50:19 +05:00
levzzz f6e63d252d Merge remote-tracking branch 'upstream/main' into HEAD 2026-10-27 19:32:30 +05:00
AndriiandClaude Sonnet 5.5 4f13db42b0 Share the sweep arc options and the crit burst helper
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-27 15:32:04 +01:00
levzzzandminimax-m3.1-flash f507a067cd The breeze's wind and the wind charge scroll their textures
Vanilla draws the breeze's wind layer and the wind charge with
`RenderTypes.breezeWind`, whose texture scrolls with the entity's age: by
0.02 of the texture per tick for the breeze's wind layer and 0.03 for the
wind charge. Horizon drew both with a still texture, so the breeze's gust
and the wind charge did not move.

The draw now takes a scroll offset and wraps the vertex's U inside the
texture, as the texture matrix of the vanilla's render type does.

Fixes #2760

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 19:30:29 +05:00
levzzzandminimax-m3.1-flash 84d204327f A neutral mob's anger survives the save and the reload
Vanilla keeps a neutral mob's anger in the entity tag: `NeutralMob.
addPersistentAngerSaveData` writes the `anger_end_time`, the absolute
game time the anger ends at, and the `angry_at` the target, and
`readPersistentAngerSaveData` reads them back, so a mob that is unloaded
and loaded again is still after whoever hurt it.

Horizon kept the anger as a countdown in memory only, so it was lost
with the chunk. The save now writes the anger end time and the target
with the vanilla's keys, and the load gives the mob back the anger that
is left of it and its target.

Fixes #2784

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 19:26:34 +05:00
levzzz 28f906c37d Merge remote-tracking branch 'upstream/main' into HEAD 2026-10-27 19:21:10 +05:00
levzzzandminimax-m3.1-flash f8f2f904bc A summoned or egg-spawned mob is not persistent
Vanilla never calls `Mob.setPersistenceRequired` for a mob it spawns: the
spawn egg's offspring, the spawn egg on a block and `/summon` all leave
the mob to the despawn rule, and only the NBT can make it persistent.
Horizon marked every one of them persistent, so they never despawn and
they are left out of the mob cap.

The three paths now spawn the mob like before, without the persistence
flag, and the command execution corpus checks it against vanilla: a
summoned zombie is not selected by `nbt={PersistenceRequired:1b}`.

Fixes #2787

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 19:21:08 +05:00
levzzzandminimax-m3.1-flash 65bca4ce98 net: skip a compressed frame that carries no packet
A server may send a compressed frame whose uncompressed length is 0 and
which holds no packet: `ConnReader::recv` read the packet id from the
empty body and failed with "needed 1 bytes, 0 left", which ended the
connection. Vanilla skips such a frame: its PacketDecoder only decodes a
packet from a readable buffer, so an empty frame produces no packet and
the connection stays up.

A frame with no bytes at all is a corrupted frame in vanilla ("Frame
length cannot be zero"), so the frame splitter now says that instead of
failing later with a missing byte.

Fixes #2739

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 19:08:49 +05:00
AndriiandClaude Sonnet 5.5 574f3587ed Hit boats and carts with the full attack damage, sweep victims in section order
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-27 15:05:34 +01:00
iLostName 66b705cec3 sounds: list each namespace's sound files once instead of a lookup per sound 2026-10-27 14:52:56 +01:00
AndriiandClaude Sonnet 5.5 ee6b5bf585 Match Player.attack more closely: enchant bonus, magic crit, no frame sweep
The main hit takes getEnchantedDamage on the real attack damage, a dragon
part is never a critical hit, and the enchanted-hit burst is shown when the
enchantments add damage. Vehicles are hurt before the sweep and sounds.
Frames, paintings and cushions end the attack in skipAttackInteraction, so
they no longer sweep. Swept armor stands are knocked back.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-27 14:49:05 +01:00
sirrayiandDevin 780abb9832 SNBT: bound nesting depth, keep nan/inf words as strings
Deeply nested [[[... in a command, function, command block or old world overflowed the stack and aborted the process; both SNBT parsers now stop at 512 levels. Bare words such as nan, infd or Infinity were parsed as floats by Rust's parser (and became JSON null in text components); only digits make a number.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 13:36:51 +00:00
sirrayiandDevin 2812cf201d Protocol: don't panic on malformed chunk sections, compressed frames or legacy pings
A server sending a palette wider than 64 bits divided by zero in storage_len (and 33-64 bits overflowed shifts); SimpleBitStorage allows at most 32. A non-canonical data-length VarInt in a compressed frame was misread. An empty first read in the legacy ping handler indexed past the end.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 13:36:51 +00:00
sirrayiandDevin ce848af067 Zip: don't trust an entry's declared sizes for allocation
A server resource pack whose zip64 sizes claim ~2^63 bytes aborted the client on Vec::with_capacity. Cap the capacity at the archive's length and the inflate limit at 1 GiB, with saturating arithmetic.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 13:36:51 +00:00
sirrayiandDevin d3be8dbf05 horizon-server: bound HTTP request and header lines
read_line had no limit, and the read timeout applies per read, so a client streaming bytes without a newline to the API or the web panel grew the supervisor's memory without bound. Lines longer than 8 KiB now get a 400.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 13:36:51 +00:00
sirrayiandDevin 9bbc54bd4c Server: refuse block actions out of a player's reach
Breaking and using blocks (START/STOP_DESTROY_BLOCK, UseItemOn) were carried out for any position a client sent, so a modified client could dig or place anywhere in loaded chunks. Check isWithinBlockInteractionRange(pos, 1.0) and the hit point as vanilla's handleBlockBreakAction and handleUseItemOn do.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 13:36:50 +00:00
AndriiandClaude Sonnet 5.5 52c667f423 Sweep from stands, frames and dragon parts, and hurt other players
- start a sweep (and the attack sounds, with a living target's critical
  hit) when a punch lands on an armor stand, an item frame or a dragon part
- spare the entity the attack hit, and swing the arm for the stands and
  frames, which did not
- hurt the other players in the sweep box through the pvp rule and teams,
  for the host and for guests, with the 0.4 knockback; queued like critical
  hits until the hub is back in place

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-27 14:33:16 +01:00
sleeperandClaude Opus 5.5 dcb7476d12 Keep a player entity's profile name after its list entry goes
A player entity keeps the profile it was created with
(RemotePlayer.getGameProfile), whose name is its scoreboard name, even
once ClientboundPlayerInfoRemovePacket drops it from the player list.
Servers rely on that to hide their NPCs (fake players) from the list
after spawning them on a team, as Hypixel SkyBlock does with
collisionRule never. Horizon read the name from the list, so an NPC
removed from it lost its team and pushed the local player again.

Remote::level_player_names keeps the name each player entity spawned
with; Game::remote_player_name and remote_player_names (teams, name tags,
glowing) read it, falling back to the list entry.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 13:23:38 +00:00
levzzz 152334eee3 Merge remote-tracking branch 'upstream/main' into fix/screen-cursor-centre 2026-10-27 18:23:26 +05:00
socksproxandDevin (SWE-2 High) 4fdc93257f Let portal arrivals tick in their destination without a player
An item or mob sent through a portal into a playerless dimension waited
forever: resolve_arrivals needed lit chunks, the level never streamed any,
and levels_to_tick skipped it entirely, so its Age stayed frozen until
someone followed and it was picked up intact (#1528). Vanilla's portal
move lands the entity under an ENTITY ticket, which keeps the chunk
entity-ticking while it ages and despawns on time.

Pending arrivals now add their landing chunk to the level's streaming
extra areas and keep their destination level in levels_to_tick, so it
lights, resolves, and ticks them just as if a player stood there.

Co-Authored-By: Devin (SWE-2 High) <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 14:21:24 +01:00
levzzzandminimax-m3.1-flash 67e0d54062 tests: give the capture fixture the mouse position helper
The fixture compiles `Game::capture` on its own, so the helper it now
calls has to be part of it: the test extracts it from the client, like it
extracts the methods, and the call inside the fixture loses the crate
path.

The fixture also asserts the grab path now: grabbing the pointer puts
the mouse at the window's centre, as `MouseHandler.grabMouse` does.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 18:18:07 +05:00
asownerytandClaude Opus 5.5 53cb3a19d1 Name the projectile in the death message when it has no owner
AbstractArrow.onHitEntity makes an ownerless arrow (or trident) its own
causing entity, and fireballs, spit and shulker bullets are the
DamageSource's direct entity. Horizon recorded no entity for an ownerless
projectile's hit, so DamageSource.getLocalizedDeathMessage's one-argument
form showed the raw two-placeholder template (%1$s was shot by %2$s).

Fixes #2802

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 02:16:56 +13:00
Eshayat Al-WasiuandCodex 84390f6dc1 Match server deletion input to button row width
Co-Authored-By: Codex (GPT-6.1) <[email protected]>
2026-10-27 19:16:53 +06:00
Eshayat Al-WasiuandCodex 9d191c28f2 Narrow server deletion confirmation input
Co-Authored-By: Codex (GPT-6.1) <[email protected]>
2026-10-27 19:16:53 +06:00
Eshayat Al-WasiuandCodex 5d42108a98 Place deletion confirmation help below input
Co-Authored-By: Codex (GPT-6.1) <[email protected]>
2026-10-27 19:16:53 +06:00
Eshayat Al-WasiuandCodex 1972ef3bef Use shared settings row styling for server deletion
Co-Authored-By: Codex (GPT-6.1) <[email protected]>
2026-10-27 19:16:53 +06:00
Eshayat Al-WasiuandCodex e8145b07fa Remove redundant server deletion confirmation hint
Co-Authored-By: Codex (GPT-6.1) <[email protected]>
2026-10-27 19:16:53 +06:00
Eshayat Al-WasiuandCodex 5e7295a631 Align server deletion controls with the settings rows
Co-Authored-By: Codex (GPT-6.1) <[email protected]>
2026-10-27 19:16:52 +06:00
Eshayat Al-WasiuandCodex 761a770174 Emphasize the server name in the delete confirmation
Co-Authored-By: Codex (GPT-6.1) <[email protected]>
2026-10-27 19:16:52 +06:00
Eshayat Al-WasiuandCodex 7f0a61954b Add a confirmed server delete action to launcher settings
Move stopped servers to the system trash after name confirmation. Refuse active supervisor locks and background services, and refresh the sidebar after removal.

Co-Authored-By: Codex (GPT-6.1) <[email protected]>
2026-10-27 19:16:52 +06:00
levzzzandminimax-m3.1-flash 826147d716 Merge upstream/main into fix/shield-blocking-pose
Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 18:14:14 +05:00
levzzzandminimax-m3.1-flash a8903cba07 Merge upstream/main into fix/shulker-body-yaw
Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 18:14:07 +05:00
levzzzandminimax-m3.1-flash 0b4fa473e7 Test the shield's blocking pose and its use duration
A shield's use animation is the BLOCK one: AvatarRenderer.getArmPose
raises the arm for it and Item.getUseDuration gives it 72000 ticks, so
the arm stays up while it is used. The fix (kacpherek's #2310) sets the
blocking flag for the other players and gives the shield its use
duration; these tests pin both ends: the arm goes across the body while
the shield is used, and the shield's use duration is vanilla's 72000
ticks, not the 0 an item without an use animation gets (#2289).

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 18:13:31 +05:00
sleeperandClaude Opus 5.5 045cabb7f2 Find other players' teams by profile name
Entity.getTeam looks a team member up by getScoreboardName, which is a
player's profile name (Player.getScoreboardName) and any other entity's
UUID. Game::mob_team, mob_teams and player_allies used the UUID for every
entity a server sent, players included, so another player's team was
never found. On a server that turns collisions off with a collisionRule
never team (Hypixel SkyBlock), the other players still pushed the local
player; the same lookup feeds allies, friendly fire, glowing colours and
the team checks explosions make.

Game::scoreboard_name now picks the profile name for another player (from
the player list, via Game::remote_player_name) and the UUID otherwise.

lan_e2e gains a team-push check: the host pushes the guest without a
team, and doesn't once the host's team has collisionRule never.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 13:10:03 +00:00
levzzzandminimax-m3.1-flash b85ce82160 Merge kacpherek's PR #2310: the shield goes up in first person and other players show the blocking pose
Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 17:48:33 +05:00
AndriiandClaude Sonnet 5.5 49ce3e6d5a Sweep in vanilla's order and against everything it hits
- play the sweep sound before the victims are hurt, then draw the arc
- hurt armor stands (not marker ones) in the sweep box through the same
  hit a direct punch gives them
- run the victims' damage through getEnchantedDamage with the real base
- sweep around a boat or a minecart that takes the hit, which only played
  the sound before

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-27 13:48:20 +01:00
levzzzandminimax-m3.1-flash 00df6c646f The shulker's body stays square to its block while the head turns
Shulker.recreateFromPacket sets the body rotation to 0 and
Shulker.ShulkerBodyRotationControl.clientTick is empty, so a shulker's
body never turns: it stays square to the block it is fixed to and only
its head turns. The fix (#2311) is already on main, but nothing held it
in place: the entity model harness compares the model's parts and cannot
see the entity's own rotation. This test drives the body rotation
control and the look control and pins both halves of the vanilla's
behaviour - the body stays at 0 and the head still turns to its target
(#1661).

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 17:45:49 +05:00
levzzz 1f040d06eb Merge remote-tracking branch 'upstream/main' into fix/screen-cursor-centre 2026-10-27 17:45:16 +05:00
levzzzandminimax-m3.1-flash d13b478ca2 ui: the mouse position is the centre whenever the grab state changes
`MouseHandler.grabMouse` and `MouseHandler.releaseMouse` both put the
mouse at the window's centre, so a screen opened from gameplay starts
with the cursor in the middle. Horizon centred it when the pointer was
released, but not when it was grabbed again, so closing a screen left
the cursor where the screen had it.

The rule is one function now, `mouse_position_after_grab_change`, and the
game applies it on both sides of the grab state change.

Fixes #1925

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 17:44:55 +05:00
AndriiandClaude Sonnet 5.5 3e0f4cf653 Sweep attacks hurt nearby mobs and show their arc
Player.doSweepAttack was only a particle and a sound that never fired: the
player's speed field is refreshed by the local movement step only, so it was
0 for the player a server simulates and isSweepAttack could not pass.

- read the movement speed attribute when checking for a sweep
- hurt the other mobs in the target's box inflated by (1, 0.25, 1) and under
  3 blocks away for 1 + sweeping ratio * damage, scaled by the charge, with
  0.4 knockback and the post-attack effects when the hurt lands
- send the arc as a LevelParticles packet so a server's clients draw it
- take its position from Mth's sine table

Fixes #2764

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-27 13:39:33 +01:00
asownerytandClaude Opus 5.5 b38fe43b09 Jukebox: keep the disc on the server thread; the off hand never ejects it
JukeboxBlock.useItemOn returns TRY_WITH_EMPTY_HAND when a disc is in, and
only the main hand goes on to useWithoutItem (popOutTheItem). The client now
predicts that the jukebox takes the click, so no off-hand UseItemOn follows
the insert, and the server ignores an off-hand turn on a full jukebox.

Fixes #2775

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 01:30:20 +13:00
iLostName ea7df1d0dc registries: list the resources' recipe, loot table and template ids once, not at every join 2026-10-27 13:29:06 +01:00
levzzz 91de744250 Merge remote-tracking branch 'upstream/main' into fix/spawn-egg-baby 2026-10-27 17:24:11 +05:00
levzzzandminimax-m3.1-flash 1654cb51db The spawn egg's offspring is a named, tested rule
The behaviour is in main (#2275): `Mob.checkAndHandleImportantInteractions`
uses a spawn egg of the mob's own kind on a mob, and
`SpawnEggItem.spawnOffspringFromSpawnEgg` spawns the offspring as a baby
of that kind at the parent's position, and one egg less in hand.

The rule now lives in the library, where it is named and tested:
`spawn_egg_is_for` (the egg is for the mob's own kind) and
`spawn_egg_offspring` (the baby, with the parent's variant and colour).

Fixes #2269

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 17:24:09 +05:00
Senjin fea8346f23 Merge remote-tracking branch 'origin/main' into hdr-f3 2026-10-27 13:10:29 +01:00
levzzzandminimax-m3.1-flash 35581f52e2 Keep the clippy rules happy in the difficulty test
The level is built with the struct update syntax instead of a field
assignment after Default::default(), which clippy's
field_reassign_with_default rejects.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 16:46:15 +05:00
asownerytandClaude Opus 5.5 f105583803 Select World: deleting the last world opens Create New World
WorldSelectionList.handleNewLevels opens a fresh CreateWorldScreen when
the singleplayer list comes back empty, with Cancel going to the title
screen. Horizon went back to an empty list instead.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 00:42:44 +13:00
socksproxandDevin (SWE-2 High) c216561daa Allays fly: their travel is travelFlying and their stroll hovers
An allay stood on the ground and fell when the block under it broke:
Mob.travel sent it down the ordinary air travel, which applies the
0.08 gravity attribute whenever noGravity is off. Vanilla's Allay.travel
is travelFlying(input, getSpeed()) — a travel with no gravity term —
so an allay hangs mid-air even before a goal moves it.

- Family::Allay routes to allay::travel, which calls travelFlying with
  the move control's current speed (LivingEntity.speed), like
  Allay.travel(input, getSpeed()); travel_flying is split so the three
  speeds can differ per mob.
- Stroll::flying picked a land position, so the stroll never produced
  a MoveTo mid-air. WaterAvoidingRandomFlyingGoal.getPosition tries
  HoverRandomPos (8, 7, pi/2, 3, 1) on the view direction, then
  AirAndWaterRandomPos (8, 4, -2); Stroll::flying now does the same.
  The wither registers the same vanilla goal and benefits equally.

Verified against the mob_ai probe: an allay spawned at y=-55 stays at
-55, then RandomStrollGoal fires and it drifts to air targets, never
falling — where it used to drop to the ground.

Fixes #2594.

Co-Authored-By: Devin (SWE-2 High) <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 12:40:16 +01:00
asowneryt e36a480063 Merge remote-tracking branch 'upstream/main' into claude/2697-horse-make-mad-4431aa
# Conflicts:
#	crates/horizon-client/src/game/mounts.rs
2026-10-28 00:27:26 +13:00
levzzzandminimax-m3.1-flash 618d20bd08 The world's difficulty follows the level data
The difficulty is a property of the world: vanilla keeps it in the level
data (`PrimaryLevelData.getDifficulty`/`setDifficulty` over
`Data.difficulty_settings`), so it survives leaving and rejoining the
world and a downloaded world keeps the difficulty it was made with.

Horizon read the difficulty from the level data when the world opened,
but `/difficulty` only changed the game's own copy, so the level data
kept the old value and wrote it back: the world came back on normal.
The level data now follows the game, both when the command changes the
difficulty and when a LAN guest asks the host to change it.

Fixes #2571

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 16:22:50 +05:00
asownerytandClaude Opus 5.5 985ece811b Stop the menu music when a world loads
Minecraft.setLevel -> updateLevelInEngines stops every sound
(SoundManager.stop), so the title-screen track ends on joining and the
music manager then waits the level's music delay. Horizon only stopped
sounds when leaving a world, so music.menu kept playing in it.

- A server's client (single player's server thread, LAN, multiplayer)
  stops every sound when it makes a new level: on Login and on a
  Respawn into another level. A dimension-changing Respawn also calls
  MusicManager.stopPlaying, as handleRespawn does.
- The in-frame path does the same when the opened world's first player
  is placed.
- Vanilla's Minecraft.player exists from handleLogin on; here it is made
  at the first teleport, so getSituationalMusic counts a logged-in client
  as in the world. Otherwise the menu's 0..10-tick replace delay would
  start the first game track almost at once.
- test_music_parity.py scripts the join: a menu-like track, stopall,
  game music, then a dimension change's stop.

Fixes #2746

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 00:18:31 +13:00
levzzzandminimax-m3.1-flash c734c72fc9 The world's difficulty survives leaving and rejoining it
Vanilla keeps the difficulty in the level data: `Data.difficulty_settings`
(`LevelSettings.DifficultySettings`) holds the name of the difficulty, the
hardcore flag and the lock. Horizon read and wrote `Data.Difficulty`, a
byte that 26.3 no longer has, so the world always opened on normal and
`/difficulty` never reached the file: the level data writes the
difficulty settings, and the game patched a key that was not there.

The difficulty now comes from and goes to `difficulty_settings`, and the
level data follows the game, so a world set to hard is hard when it is
opened again and a downloaded world keeps the difficulty it was made
with.

Fixes #2571

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 16:16:40 +05:00
levzzzandminimax-m3.1-flash fa940d5b6b Add a regression test for the entity draw distance
The view scale introduced by #2317 (Entity.setViewScale in
LevelExtractor.extractVisibleEntities) decides how far an entity is drawn:
Entity.shouldRenderAtSqrDistance multiplies the bounding box's mean edge by
64 and by that scale, so a chicken (0.4 by 0.7 by 0.4) is drawn up to 48
blocks away at the vanilla defaults. Without the scale it vanished at 32
blocks, the distance reported in #2273. The merged fix carried no test, so
the scale formula now lives in a free function and the draw distance of a
chicken, of a broken bounding box and of every render distance is pinned.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 16:11:15 +05:00
Ludovic MarieandClaude Opus 5.5 e5ca012ed8 Merge main into fix/server-mob-animation-sync-2
Brings main in so the branch has one merge base with it (GitHub saw a
conflict through the criss-cross bases; the merge itself is clean).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 12:08:06 +01:00
Eshayat Al-WasiuandCodex bcdbc252fe Load server details when opening from the launcher sidebar
Use the shared open_server path so stopped servers load settings immediately and switching servers clears stale detail state.

Co-Authored-By: Codex (GPT-6.1) <[email protected]>
2026-10-27 17:07:42 +06:00
iLostName b042426180 lang: opening Options lists the languages without reading every file 2026-10-27 12:00:36 +01:00
levzzzandminimax-m3.1-flash 186198afe6 Pin the turtle's walk animation to vanilla's rate
The turtle's flailing feet (#1776) were the walk animation running at its
maximum: the generic MoveControl walked the turtle at its full movement
speed (0.25), so LivingEntity.updateWalkAnimation's min(distance * 4, 1)
saturated at 1.0 and TurtleModel swung the legs at the maximum rate and
amplitude. PR #2565 gave the turtle its own pace, but its tests only pin
the forward input the control sets, not the rate of the animation the
reporter saw. This test pins the symptom: the turtle covers 0.18447
blocks a tick and its walk animation runs at 0.73789, vanilla's
min(distance * 4, 1) for that pace.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 15:44:33 +05:00
levzzz a77639022c Merge remote-tracking branch 'upstream/main' into fix/fire-animated-textures 2026-10-27 15:40:21 +05:00
levzzz fcc7a818b4 Merge remote-tracking branch 'upstream/main' into fix/mining-hit-sound 2026-10-27 15:36:16 +05:00
levzzzandminimax-m3.1-flash a3e581b56f Keep the animated textures on in every graphics preset
The Performance preset used to turn the animated textures off, which froze
the fire, the water, the lava and the portals: players reported a fire with
no animation (#2064). The presets keep them on now, and a test keeps it
that way, so a preset that freezes the animation again fails the build
instead of the report.

Fixes #2064

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 15:35:06 +05:00
iLostName 4c4fcedf40 server list: F5 refreshes it, as in vanilla 2026-10-27 11:30:44 +01:00
iLostName ee9d8a656f title: a world open in another instance shows the access failure toast instead of crashing 2026-10-27 11:28:35 +01:00
iLostName de8dd66ac2 connection: try a server's addresses side by side, 250 ms apart 2026-10-27 11:24:59 +01:00
iLostName 6fc2006447 dns: leave out scoped IPv6 name servers on Windows, as JNDI does 2026-10-27 11:24:59 +01:00
levzzzandminimax-m3.1-flash 7e68f1ed32 Remove the duplicate rabbit arm in entity_nbt
The merge of #2585 left two "minecraft:rabbit" arms in the same match, so
the second one is unreachable and clippy's -D warnings rejects the build
(upstream's own checks job is red on it since 0e793be2a). The first arm
already writes RabbitType and MoreCarrotTicks, so the second one is dead
code; its note about the legacy id moves to the arm that runs.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 15:14:59 +05:00
levzzzandminimax-m3.1-flash 9ebccd6a59 Cover the mining sound cadence in the LAN harness
The fix for #2277 landed in #2350: the server's destroy progress level
events (2019 and 2020) play the block's hit sound, so the digger and the
players around hear the same hits. Nothing checked that, though. Add a LAN
check that holds the attack on a stone block and compares the hit sounds
both sides count, and assert in the remote mining fixture that the client
plays the hit sound once, when the dig starts, and not in the loop. On the
tree before #2350 the check fails: the digger counted 20 hits against the 10
the player nearby heard.

Fixes #2277

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 15:14:04 +05:00
Ludovic MarieandClaude Opus 5.5 0f565f4e4c refactor: biome's spawn-list parsers live in biome.rs
parse_entries, parse_costs and SpawnCost read the biome JSON's
natural_mob_spawns and build biome::SpawnEntry; biome.rs reached into
mobs::spawn_selection for them, one of the edges that keep the client lib
in a single cycle. They move to biome.rs and spawn_selection re-exports
them. No behaviour change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:11:39 +01:00
asowneryt dae67a88cc Merge remote-tracking branch 'upstream/main' into claude/2697-horse-make-mad-4431aa
# Conflicts:
#	crates/horizon-client/src/game/mounts.rs
2026-10-27 22:58:40 +13:00
levzzzandminimax-m3.1-flash 44850bdd55 Merge upstream/main into the portal rider travel branch
The level that is not in place needs both the lookup by dimension (this
branch) and the list of the parked levels (upstream).

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 14:58:02 +05:00
iLostName 758d66bf44 remote: titles, tab list, scoreboard and effects don't carry over to the next server 2026-10-27 10:09:12 +01:00
iLostName b201dafa75 server list: the player count's tooltip lists the status sample 2026-10-27 10:07:15 +01:00
iLostName 7c58bf004f options: the slider test expects saving on release 2026-10-27 10:06:32 +01:00
iLostName 4119284a4f options: sliders apply while dragged and save on release 2026-10-27 09:55:35 +01:00
SenjinandClaude Opus 5.5 957a3c6622 HDR: show the output on the F3 screen
While the HDR setting is on, F3 shows the swapchain HDR presents to, with
the paper white and peak ("HDR: HDR10 (PQ), 200/500 nits"), or that the
display offers none. With the setting off nothing is added, so the screen
stays as vanilla's.

Part of #1441.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 09:33:26 +01:00
asownerytandClaude Opus 5.5 1758b147b8 Untamed horses rear at a held item instead of taking the rider (#2697)
Horse, AbstractChestedHorse (donkeys, mules, llamas) and ZombieHorse.mobInteract:
with something other than food in hand, an untamed one is made mad (makeMad:
standIfPossible and the angry sound) and the player is not seated; an untamed
skeleton horse ignores the click (SkeletonHorse.mobInteract). The shared
equine::held_item decides it for the game and the mob_ai probe.

makeMad goes through makeSound (0.8 volume, the voice pitch from the mob's
random), with the baby horse's and the zombie horse's own angry sounds.
Donkeys and mules rear too (canPerformRearing is false only for llamas), a
rearing or eating horse runs no goals (AbstractHorse.isImmobile), and
RandomStandGoal plays the ambient sound, as vanilla's getAmbientStandSound.

On a server thread, a horse that bucks (RunAroundLikeCrazyGoal) now throws
off the server player riding it, not only the window's own player.

mob_ai harness: `interact` uses an item on a mob (Mob.mobInteract), with
sword scenarios for horses, donkeys, mules, zombie and skeleton horses and
llamas, foals included.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 20:36:10 +13:00
levzzzandminimax-m3.1-flash 33e81f464e Keep a rider with the vehicle it rides through a portal
A server player who rode a horse, a boat or a minecart into a Nether
portal arrived in the other dimension on foot: the vehicle went through
the portal on its own (as an entity, with a new entity id) while the
player travelled alone, and the ride was broken. Vanilla carries the
passengers with the vehicle (`Entity.teleportCrossDimension` recreates
the vehicle in the other dimension and the passengers ride it again),
and a passenger does not use a portal on its own (`Entity.canUsePortal`,
`NetherPortalBlock.entityInside`). The vehicle a server player rides
now travels with it: it is left out of the entity portal path, and it
moves to the destination level with the player when the trip arrives,
keeping its entity id (and its UUID and the compound it was loaded
with), so the ride is never broken. It stands where its rider arrives
and does not go back through the exit portal for a while
(`Entity.setPortalCooldown`).

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 10:30:03 +05:00
FusselTVandClaude Opus 5.5 3788dff6a1 Worldgen: the surface step skips writes that change nothing
Most of the Nether's surface rule ends in netherrack, written over the
netherrack the noise step placed: every solid block paid for a section
write, two state table reads, four heightmap updates and a fluid check
for nothing. Column::set_block now leaves a block that already holds the
state (a fluid is still marked for post-processing, as every write marks
it).

chunkgen_bench, 576 chunks, 1 thread: Nether surface 1.81 -> 1.52 ms per
chunk; the terrain snapshots of seeds 12345 and 1 (Nether and Overworld)
are byte for byte the same.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 03:54:40 +01:00
Kacpherek 4e96037c78 Never show fire-immune mobs as on fire 2026-10-26 20:42:58 +01:00
Kacpherek 5b066b1b78 Send the host's death and advancement messages to guests and follow team visibility 2026-10-26 19:53:09 +01:00
Kacpherek 36ebd0c545 Follow vanilla's lava fog for Fire Resistance and spectators, and brighten the fog colour with Night Vision 2026-10-26 19:34:12 +01:00
Kacpherek 4eb95c4d3e Follow the burn in daylight tag for mobs that burn in the sun 2026-10-26 18:53:26 +01:00
666 changed files with 49487 additions and 4967 deletions

No files matched your search

+76
View File
@@ -0,0 +1,76 @@
---
name: vanilla-parity
description: Fix a Horizon bug or gap so it behaves 1:1 like vanilla 26.3, starting from the port ledger (tools/port_ledger.py) instead of grepping. Use for any issue, Discord report or PR that makes Horizon match vanilla: "X doesn't work like in Java", a missing sound, particle, animation, mob behaviour, block rule, screen or packet.
---
# Vanilla parity with the port ledger
The port ledger pairs every vanilla method with the Rust fn that ports it (or says it is missing, or needs
no port) and lists where a port isn't wired like vanilla. Ask it first; grep only for what it can't answer.
Full reference: `docs/port-ledger.md`.
## 0. Have a fresh ledger
```sh
python3 tools/port_ledger.py build # ~20 s; again after pulling main
```
It needs the vanilla jar (the launcher's `versions/26.3/26.3.jar`, or `--jar`) and/or the lab's decompiled
sources (`python3 tools/lab.py decompile`, or `--sources DIR`). If `build` says it has no vanilla code, run
the lab first; don't guess vanilla from memory.
## 1. From the report to the code
```sh
python3 tools/port_ledger.py issue 3236 # a GitHub issue number
python3 tools/port_ledger.py issue "mooshroom stays red after lightning" # or the report's text
```
It ranks the vanilla classes the report names and lists, per class, its Rust home files, where each method is
ported, and the known gaps (`missing` rows from the verdict map, seam/client/drift gaps). Often the cause is
already written there ("Type is not saved"). Then drill in:
```sh
python3 tools/port_ledger.py where MushroomCow.readAdditionalSaveData # ports, callers, callees, gaps
python3 tools/port_ledger.py class MushroomCow # every method: where, or missing
python3 tools/port_ledger.py what crates/horizon-client/src/mobs/farm.rs:120 # which vanilla method a fn ports
```
A ledger answer is a lead, not a verdict: about 1 in 12 map rows is wrong. Read the vanilla method (path and
line are in the output) and the Rust fn before changing anything.
## 2. Port it the vanilla way
- Translate the vanilla method, don't reinvent it: same order of operations, same constants, the same random
draws in the same order (`horizon_core::random`), Java `Math` through `horizon_core::jmath`.
- Open the fn's doc with the vanilla method it ports: `` /// `MushroomCow.readAdditionalSaveData`: ... ``.
That one line is what makes the next agent find it.
- Fix the path players run: single player is a client of the integrated server, so server logic lives in
`game/lan*.rs` and `src/lan/`, and what only the client's copy shows (particles, local sounds, hand
animation) must be reached from `game/remote*.rs`. A fix on the old in-frame path alone fixes nothing.
- Look at the neighbours the ledger lists for the same class: a `client` or `seam` gap next to your fix is
usually the same bug on the other path.
## 3. Before the PR
```sh
python3 tools/port_ledger.py check --base origin/main
```
It lists the ports your branch touches with their open gaps, citations to add, stale citations, and (against
the base) the gaps the branch closes and opens. Close what belongs to your fix, cite what you ported, leave
no new stale citation. If you ported a method the verdict map called `missing`, change its row in
`tools/port_map/*.tsv` to `port` with the fn (`file.rs::Owner::fn`) and run
`python3 tools/port_ledger.py map-check tools/port_map/<file>.tsv`.
Then the usual: the harness for the area (`docs/architecture.md`), `cargo check`/tests per `AGENTS.md`, and
a small PR that says which vanilla method it now matches.
## Finding work
```sh
python3 tools/port_ledger.py gaps --kind seam # ported on two paths, wired on one: the best leads
python3 tools/port_ledger.py gaps --kind client # client-only effects the server path never reaches
python3 tools/port_ledger.py gaps --kind drift # a port missing a sound, particle, item or constant
rg -n "player:" tools/port_map/*.tsv # missing behaviour a player would see
```
+10 -1
View File
@@ -1,2 +1,11 @@
#!/bin/sh
exec python3 tools/check_public_tree.py
# Git for Windows puts a `python3` on PATH that is only the Microsoft Store
# stub: it prints "Python" and exits non-zero, so the hook fails with no other
# output. Use the first name that actually runs Python 3.
for py in python3 python py; do
if "$py" -c "import sys; sys.exit(sys.version_info < (3,))" >/dev/null 2>&1; then
exec "$py" tools/check_public_tree.py
fi
done
echo "check_public_tree: no working Python 3 found" >&2
exit 1
+10 -1
View File
@@ -1,2 +1,11 @@
#!/bin/sh
exec python3 tools/check_public_tree.py --pre-push
# Git for Windows puts a `python3` on PATH that is only the Microsoft Store
# stub: it prints "Python" and exits non-zero, so the hook fails with no other
# output. Use the first name that actually runs Python 3.
for py in python3 python py; do
if "$py" -c "import sys; sys.exit(sys.version_info < (3,))" >/dev/null 2>&1; then
exec "$py" tools/check_public_tree.py --pre-push
fi
done
echo "check_public_tree: no working Python 3 found" >&2
exit 1
+6 -4
View File
@@ -16,12 +16,14 @@ jobs:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
BRANCH: ${{ github.event.pull_request.head.ref }}
SHA: ${{ github.event.pull_request.head.sha }}
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
run: |
# Every head the pull request had, not only its last: a run-parity label added on an older head left that
# run queued after the merge (#3104, 2026-10-29). Its branch in its own repository names its runs (another
# fork can have a branch of the same name).
for status in queued in_progress waiting pending requested; do
gh run list -R "$REPO" --branch "$BRANCH" --status "$status" --limit 100 \
--json databaseId,event,headSha \
-q ".[] | select(.event == \"pull_request\" and .headSha == \"$SHA\") | .databaseId"
gh api -X GET "repos/$REPO/actions/runs" -f branch="$BRANCH" -f event=pull_request -f status="$status" \
-f per_page=100 --jq ".workflow_runs[] | select(.head_repository.full_name == \"$HEAD_REPO\") | .id"
done | sort -u | while read -r id; do
gh run cancel -R "$REPO" "$id" || true
done
+5 -4
View File
@@ -37,9 +37,10 @@ permissions:
jobs:
exporters:
# This runner holds the maintainer's own pinned Minecraft jars. Only our classes leave it. An untrusted build
# goes to the Mac mini runner that already runs pull requests' parity jobs (macOS + horizon-game:
# ludovics-mac-mini), never the release runner (MAC_RUNNER: ludovics-mac-mini-release) nor parity-main's.
runs-on: ${{ inputs.trusted && vars.MAC_RUNNER || fromJSON('["self-hosted", "macOS", "horizon-game"]') }}
# goes to the Mac mini's nightly runner (macOS + horizon-nightly: ludovics-mac-mini-nightly, beside the one that
# runs pull requests' parity jobs, so a nightly never waits behind their queue), never the release runner
# (MAC_RUNNER: ludovics-mac-mini-release) nor parity-main's.
runs-on: ${{ inputs.trusted && vars.MAC_RUNNER || fromJSON('["self-hosted", "macOS", "horizon-nightly"]') }}
# Three full preparations (two isolated, one batched) besides the compile.
timeout-minutes: 60
steps:
@@ -103,7 +104,7 @@ jobs:
mac: true
dir: horizon-macos-arm64
asset: horizon-macos-arm64.zip
runs-on: ${{ matrix.mac && (inputs.trusted && vars.MAC_RUNNER || fromJSON('["self-hosted", "macOS", "horizon-game"]')) || matrix.os }}
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 }}
env:
+3 -1
View File
@@ -192,9 +192,11 @@ jobs:
retry "$launcher" install latest
"$launcher" versions --installed
"$launcher" links --json
clients=("$HORIZON_LAUNCHER_HOME"/versions/v*/horizon-client*)
# `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]}"
echo "installed client: $client"
[[ -f "$client" ]] || { echo "::error::no game installed under versions/"; exit 1; }
# The game's Linux build is the game's to keep portable (release.yml checks it): a
# release linked to a Nix store is a warning here, not a broken launcher.
soft() { if [[ "$RUNNER_OS" == "Linux" ]]; then "$@" || echo "::warning::$* failed"; else "$@"; fi; }
+54 -13
View File
@@ -1,11 +1,12 @@
name: nightly build
run-name: nightly build${{ inputs.request && format(' (request {0})', inputs.request) || '' }}
# The opt-in Nightly channel (docs/release-flow.md "Nightly"; Ludovic, 2026-10-27). Once a night, and when a trusted
# The Nightly, the only build Horizon ships since 2026-10-28 (docs/release-flow.md "Nightly"; Ludovic). When the batch
# train decides (release.yml, with the commit as `base`), once a night, and when a trusted
# reviewer asks (`/nightly 2640 2642`, nightly-command.yml), it builds main plus the pull requests the reviewers
# picked, merged in order (tools/ci/nightly_set.py). With none picked, it builds the newest union branch on origin
# (union/<agent>-<name>: main plus pull requests the contribution gate proved together), or main when no union is
# newer. It publishes the result as the `nightly` pre-release. Launchers install it only for players who turn Nightly
# on. The previous nightly stays a week as nightly-<date>.
# picked, merged in order (tools/ci/nightly_set.py), or main alone with none picked. Main is its newest commit where
# checks and parity-main passed (or the `base` input), as for a release, and no Nightly is published while a
# release-blocker is open. It publishes the result as the `nightly` pre-release, which every launcher and
# `horizon-server update` install. The previous nightly stays a week as nightly-<date>.
#
# Why the signing key never meets the pull requests' code:
# - `pick` merges the picked heads onto main with `git merge-tree --write-tree`: git objects only, no working tree,
@@ -13,11 +14,11 @@ run-name: nightly build${{ inputs.request && format(' (request {0})', inputs.req
# request that changes a path CODEOWNERS gives to Ludovic (this folder, tools/ci/, the signing and update code),
# pushes the merge as nightly/build-<date>-<n> and saves the set on the nightly-state branch.
# - This file, game-build.yml and every script below run from main's copy: a schedule runs the default branch, and
# `uses: ./…` takes the caller's commit. The union's own .github/ is never read.
# `uses: ./…` takes the caller's commit. A picked pull request's own .github/ is never read.
# - `build` (game-build.yml, `trusted: false`) never reaches a release's tools: its macOS jobs run on the runner
# that already runs pull requests' parity jobs, never the release runner; its Rust cache has its own key and is
# never saved; and releases don't read the machine's shared sccache.
# - `build` (game-build.yml) checks out and compiles the merge (or the union) with no secrets: its token reads contents only and
# - `build` (game-build.yml) checks out and compiles the merge with no secrets: its token reads contents only and
# isn't kept in the checkout; the macOS programs are signed ad hoc (Developer ID needs the runner's keychain,
# which stays locked for nightlies).
# - `sign` is the only job with RELEASE_SIGNING_KEY. It checks out nothing but main's tools/ci/sign_release.sh, which
@@ -25,6 +26,9 @@ run-name: nightly build${{ inputs.request && format(' (request {0})', inputs.req
# names the channel ("horizon release nightly"), so a nightly's signature can't pass for a release's.
# - `publish` verifies the signature with main's horizon-release-sign, checks the smoke test, and only then moves
# the tag. Like sign, it never executes what build produced.
# - The source ships with the build: `pick` writes horizon-source.tar.gz with `git archive` (git objects again, nothing
# run), `sign` signs it with the builds, and `publish` attaches it and tags the commit nightly-source/<date>-<short>
# for good (the dated release and the build branch go after 7 days; native mods pin the commit: docs/native-mods.md).
# gh workflow run "nightly build" # now (skips when the pick is the same commit as last night's)
# gh workflow run "nightly build" -f force=true # build and publish even if unchanged
# gh workflow run "nightly build" -f dry_run=true # build and sign with a throwaway key, publish nothing
@@ -53,6 +57,14 @@ on:
description: A dry run's own set, `2640@<head> 2642@<head>` (the saved set stays)
type: string
default: ''
base:
description: The commit of main to build on (default main's newest with green checks and parity-main)
type: string
default: ''
urgent:
description: Past the day's cap of 12 builds (the train, for a release-now pull request or a forced run)
type: boolean
default: false
permissions:
contents: read
concurrency:
@@ -68,6 +80,7 @@ jobs:
contents: write # nightly/build-<date>-<n> and the nightly-state branch (main is protected)
issues: write # says in the thread when the picked pull requests conflict
pull-requests: write
actions: read # main's newest green checks and parity-main runs (the base)
outputs:
build: ${{ steps.pick.outputs.build }}
sha: ${{ steps.pick.outputs.sha }}
@@ -76,6 +89,7 @@ jobs:
date: ${{ steps.pick.outputs.date }}
version: ${{ steps.pick.outputs.version }}
nightly: ${{ steps.pick.outputs.nightly }}
source_tag: ${{ steps.pick.outputs.source_tag }}
dry_run: ${{ steps.mode.outputs.dry_run }}
steps:
- uses: actions/checkout@v4
@@ -92,6 +106,8 @@ jobs:
DRY_RUN: ${{ steps.mode.outputs.dry_run }}
NIGHTLY_ISSUE: ${{ inputs.issue }}
NIGHTLY_PRS: ${{ inputs.prs }}
NIGHTLY_BASE: ${{ inputs.base }}
NIGHTLY_URGENT: ${{ inputs.urgent }}
run: |
python3 tools/ci/nightly.py pick ${FORCE:+--force} | tee -a "$GITHUB_STEP_SUMMARY"
{ echo; cat nightly-notes.md; } >> "$GITHUB_STEP_SUMMARY"
@@ -103,6 +119,18 @@ jobs:
GH_TOKEN: ${{ github.token }}
run: |
{ echo "### nightly/build-* branches"; python3 tools/ci/nightly.py prune-builds --days 7; } | tee -a "$GITHUB_STEP_SUMMARY"
- name: The source of the build (horizon-source.tar.gz)
if: steps.pick.outputs.build == 'true'
env:
SHA: ${{ steps.pick.outputs.sha }}
run: git archive --format=tar.gz --prefix="horizon-${SHA:0:7}/" -o horizon-source.tar.gz "$SHA"
- uses: actions/upload-artifact@v4
if: steps.pick.outputs.build == 'true'
with:
name: horizon-source
path: horizon-source.tar.gz
if-no-files-found: error
retention-days: 3
- uses: actions/upload-artifact@v4
with:
name: nightly-notes
@@ -144,7 +172,7 @@ 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-macos-arm64.zip dist/horizon-server-linux-x86_64.tar.gz dist/horizon-source.tar.gz
- uses: actions/upload-artifact@v4
with:
name: signature
@@ -164,6 +192,7 @@ jobs:
timeout-minutes: 15
permissions:
contents: write
issues: read # the open release-blocker issues (gate)
steps:
- uses: actions/checkout@v4
with:
@@ -178,27 +207,39 @@ jobs:
run: |
key=(); [[ -n "$EPHEMERAL" ]] && key=(--key "$(cat dist/ephemeral.pub)")
cargo run --locked --quiet -p horizon-release-sign -- verify --tag nightly --publishing "${key[@]}" dist
- name: The smoke test drew a world
# No release-blocker check: a nightly is how blockers get tested.
run: python3 tools/release_train.py gate --kind game --smoke dist/smoke-test.json
- name: Ready to publish (the smoke test drew a world, no release-blocker is open)
# Right before publishing, as for a release: an issue labelled while the builds ran still holds it. A dry run
# only reports what a real run would do (that is how a blocker's fix is tried).
env:
GH_TOKEN: ${{ github.token }}
DRY_RUN: ${{ needs.pick.outputs.dry_run }}
run: |
if ! python3 tools/release_train.py gate --kind game --smoke dist/smoke-test.json --blockers; then
[[ "$DRY_RUN" == true ]] || { echo "::error::Nothing published"; exit 1; }
echo "::warning::A real run would publish nothing"
fi
- name: Publish as the `nightly` pre-release
if: needs.pick.outputs.dry_run != 'true'
env:
GH_TOKEN: ${{ github.token }}
SHA: ${{ needs.pick.outputs.sha }}
DATE: ${{ needs.pick.outputs.date }}
SOURCE_TAG: ${{ needs.pick.outputs.source_tag }}
run: |
set -euo pipefail
python3 tools/ci/nightly.py rotate
signature=(); [[ -f dist/SHA256SUMS.sig ]] && signature=(dist/SHA256SUMS.sig)
# A pre-release, never GitHub's latest: launchers on the release channel never see it.
# A pre-release, never GitHub's latest: the latest stays the last vX.Y.Z, which launchers and servers from
# 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-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
python3 tools/ci/nightly.py keep-source "$SHA" "$DATE"
python3 tools/ci/nightly.py prune --days 7
- name: Dry run, the nightly that would be published
if: needs.pick.outputs.dry_run == 'true'
@@ -230,7 +271,7 @@ jobs:
elif [[ "$PUBLISHED" == success && "$DRY_RUN" == true ]]; then
text="the dry run built and signed $NAME; nothing was published. $RUN"
elif [[ "$PUBLISHED" == success ]]; then
text="published $NAME: https://github.com/$GITHUB_REPOSITORY/releases/tag/nightly. Launchers with Nightly on install it at their next start."
text="published $NAME: https://github.com/$GITHUB_REPOSITORY/releases/tag/nightly. Launchers install it at their next check."
else
text="the build failed, nothing was published: $RUN"
fi
+27 -139
View File
@@ -1,22 +1,15 @@
name: release
# Automatic game releases (docs/release-flow.md). After every green `checks` run on main, every 10 minutes (so a
# release waiting for merges to settle goes out without another merge) and by hand, tools/release_train.py decides,
# for a commit whose checks and parity-main (the vanilla harnesses on main) both passed:
# a release when 5 pull requests merged since the last one changed what players run and the newest merged 5 minutes
# ago, or when the oldest has waited 6 hours, or at once for a pull request labelled release-now.
# RELEASES_PAUSED=1 (repository variable) stops them; so does an open issue labelled release-blocker, forced runs too.
# Builds Linux (an older Ubuntu, so the binary runs on any recent distribution and on NixOS through nix-ld) and
# Windows on GitHub, macOS on the Mac mini's runner (MAC_RUNNER=macos-latest sends it to GitHub), then
# publishes vX.Y.Z on the release line in .github/release-line.txt (docs/versioning.md; without it, the dated
# vYYYY.MM.DD[.N] from before 0.2) with notes from the merged pull requests, as the release and GitHub's latest: one
# channel, which launchers, `horizon-server update` and install.sh follow at once. So it is published only after the
# checks that used to gate a promotion pass in this run: the Mac mini's smoke test drew a world, and no release-blocker
# is open (tools/release_train.py gate); otherwise nothing is published. macOS: with the repository variable
# MACOS_SIGN_IDENTITY set, the programs are signed with
# Developer ID and the hardened runtime and the zip is notarized, from the runner's signing keychain
# (packaging/macos/README.md); unset (forks), ad hoc.
# gh workflow run release --ref <branch> # dry run: builds and notes, publishes nothing
# gh workflow run release -f dry_run=false -f force=true # on main: release now, whatever the rules say
# Automatic game releases (docs/release-flow.md). Horizon ships only the Nightly since 2026-10-28 (Ludovic): this
# workflow is the batch train that decides when a new Nightly goes out, and `nightly build` (nightly-build.yml) builds,
# signs and publishes it. After every green `checks` run on main, every 10 minutes (so a batch waiting for merges to
# settle goes out without another merge) and by hand, tools/release_train.py decides, for a commit whose checks and
# parity-main (the vanilla harnesses on main) both passed: a Nightly when 5 pull requests merged since the published
# Nightly changed what players run and the newest merged 5 minutes ago, or when the oldest has waited 6 hours, or at
# once for a pull request labelled release-now. RELEASES_PAUSED=1 (repository variable) stops it; so does an open issue
# labelled release-blocker, forced runs too (nightly-build.yml checks again before publishing). One Nightly build at a
# time, at most 12 a day (release-now and force=true pass the cap). No `vX.Y.Z` game release is made any more.
# gh workflow run release --ref <branch> # dry run: decides, starts nothing
# gh workflow run release -f dry_run=false -f force=true # on main: a Nightly now, whatever the rules say
on:
workflow_run:
# A game release ships a commit both passed on (parity-main runs on batches: the newest commit it passed on).
@@ -28,17 +21,19 @@ on:
workflow_dispatch:
inputs:
dry_run:
description: Build everything and write the notes, but publish nothing
description: Decide only; start no Nightly build
type: boolean
default: true
force:
description: Release even without player-facing changes, before merges settle or while paused
description: A Nightly even without player-facing changes, before merges settle, while paused or past the day's cap
type: boolean
default: false
permissions:
contents: read
# The open release-blocker issues (decide).
issues: read
# The checks, parity-main and nightly build runs (decide).
actions: read
concurrency:
# One release at a time. A hand-started dry run publishes nothing, so it can't race a real release: it gets a
# group of its own. In the shared group, a release queued behind it replaced it as the pending run and GitHub
@@ -54,9 +49,8 @@ jobs:
timeout-minutes: 10
outputs:
release: ${{ steps.decide.outputs.release }}
tag: ${{ steps.decide.outputs.tag }}
version: ${{ steps.decide.outputs.version }}
head: ${{ steps.decide.outputs.head }}
urgent: ${{ steps.decide.outputs.urgent }}
dry_run: ${{ steps.mode.outputs.dry_run }}
steps:
- uses: actions/checkout@v4
@@ -76,127 +70,21 @@ jobs:
run: |
python3 tools/release_train.py decide --kind game ${HEAD_SHA:+--head "$HEAD_SHA"} ${FORCE:+--force} | tee decision.json
echo "### $(jq -r '.reason' decision.json)" >> "$GITHUB_STEP_SUMMARY"
# The version the programs are built with (docs/versioning.md): the tag without its v.
tag=$(jq -r '.tag' decision.json)
echo "version=${tag#v}" >> "$GITHUB_OUTPUT"
# A dry run builds whatever the decision, to test the builds.
if [[ "${{ steps.mode.outputs.dry_run }}" == true ]]; then echo "release=true" >> "$GITHUB_OUTPUT"; fi
build:
nightly:
# The Nightly is the release: nightly-build.yml builds the decided commit plus the reviewers' picks, signs it for
# the Nightly channel and publishes it once its smoke test passed and no release-blocker is open.
needs: decide
if: needs.decide.outputs.release == 'true'
# Every platform's archive, and macOS's smoke test (game-build.yml).
uses: ./.github/workflows/game-build.yml
with:
ref: ${{ needs.decide.outputs.head }}
version: ${{ needs.decide.outputs.version }}
trusted: true # main: the release runner, the release cache, no shared sccache
sign:
needs: [decide, build]
if: needs.decide.outputs.release == 'true' && needs.decide.outputs.dry_run != 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
# The only job that holds the release signing key, a secret of the `release` environment, which only main's real
# releases enter (dry runs sign with a throwaway key). It runs no code from the repository but
# tools/ci/sign_release.sh, which calls only the runner's sha256sum and OpenSSL (docs/release-flow.md).
environment: ${{ needs.decide.outputs.dry_run != 'true' && 'release' || '' }}
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: tools/ci/sign_release.sh
sparse-checkout-cone-mode: false
persist-credentials: false
- uses: actions/download-artifact@v4
with:
path: dist
pattern: horizon-*
merge-multiple: true
- name: Sign the checksums (SHA256SUMS, SHA256SUMS.sig)
env:
RELEASE_SIGNING_KEY: ${{ secrets.RELEASE_SIGNING_KEY }}
RELEASE_SIGNING_KEY_NEXT: ${{ secrets.RELEASE_SIGNING_KEY_NEXT }}
EPHEMERAL: ${{ needs.decide.outputs.dry_run == 'true' && 'yes' || '' }}
TAG: ${{ needs.decide.outputs.tag }}
run: |
bash tools/ci/sign_release.sh "$TAG" 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
- uses: actions/upload-artifact@v4
with:
name: signature
path: dist/SHA256SUMS*
if-no-files-found: error
retention-days: 3
- uses: actions/upload-artifact@v4
if: needs.decide.outputs.dry_run == 'true'
with:
name: ephemeral.pub
path: dist/ephemeral.pub
retention-days: 3
publish:
needs: [decide, build, sign]
runs-on: ubuntu-latest
timeout-minutes: 15
timeout-minutes: 5
permissions:
contents: write
issues: read
actions: write # starts `nightly build`
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Notes
env:
- env:
GH_TOKEN: ${{ github.token }}
HEAD_SHA: ${{ needs.decide.outputs.head }}
URGENT: ${{ needs.decide.outputs.urgent }}
run: |
python3 tools/release_train.py notes --kind game --tag "${{ needs.decide.outputs.tag }}" \
--head "${{ needs.decide.outputs.head }}" --out notes > /dev/null
{ echo "## $(cat notes/title.txt)"; echo; cat notes/body.md; } >> "$GITHUB_STEP_SUMMARY"
- uses: actions/download-artifact@v4
with:
path: dist
merge-multiple: true
- name: The signature, checked as launchers check it
env:
TAG: ${{ needs.decide.outputs.tag }}
EPHEMERAL: ${{ needs.decide.outputs.dry_run == 'true' && 'yes' || '' }}
run: |
key=(); [[ -n "$EPHEMERAL" ]] && key=(--key "$(cat dist/ephemeral.pub)")
cargo run --locked --quiet -p horizon-release-sign -- verify --tag "$TAG" --publishing "${key[@]}" dist
- name: Ready to publish (the smoke test passed, no release-blocker is open)
# Right before publishing: an issue labelled while the builds ran still holds this release. A dry run only
# reports what a real run would do.
env:
GH_TOKEN: ${{ github.token }}
DRY_RUN: ${{ needs.decide.outputs.dry_run }}
run: |
if ! python3 tools/release_train.py gate --kind game --smoke dist/smoke-test.json --blockers; then
[[ "$DRY_RUN" == true ]] || { echo "::error::Nothing published"; exit 1; }
echo "::warning::A real run would publish nothing"
fi
- name: Publish ${{ needs.decide.outputs.tag }}
if: needs.decide.outputs.dry_run != 'true'
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.decide.outputs.tag }}
run: |
signature=(); [[ -f dist/SHA256SUMS.sig ]] && signature=(dist/SHA256SUMS.sig)
# The release and the latest at once: there is one channel (docs/release-flow.md). Each archive gets a
# label (file#label), which GitHub's page shows instead of the file name, so nobody takes the game's
# files for the download (#1647). Launchers, servers and install.sh match the name, which stays.
gh release create "$TAG" --target "${{ needs.decide.outputs.head }}" --title "$(cat notes/title.txt)" \
--notes-file notes/body.md --latest \
"dist/horizon-linux-x86_64.tar.gz#Game files the launcher installs: Linux (horizon-linux-x86_64.tar.gz)" \
"dist/horizon-windows-x64.zip#Game files the launcher installs: Windows (horizon-windows-x64.zip)" \
"dist/horizon-macos-arm64.zip#Game files the launcher installs: macOS (horizon-macos-arm64.zip)" \
"dist/horizon-server-linux-x86_64.tar.gz#Dedicated server for Linux (horizon-server-linux-x86_64.tar.gz)" \
dist/SHA256SUMS "${signature[@]}" dist/smoke-test.json
- name: Dry run, the release that would be published
if: needs.decide.outputs.dry_run == 'true'
run: |
echo "Would publish ${{ needs.decide.outputs.tag }} at ${{ needs.decide.outputs.head }} as the release and latest: $(cat notes/title.txt)"
ls -l dist
- uses: actions/upload-artifact@v4
with:
name: release-notes
path: notes/
retention-days: 3
gh workflow run nightly-build.yml --repo "$GITHUB_REPOSITORY" --ref main -f base="$HEAD_SHA" -f urgent="$URGENT"
echo "### Started \`nightly build\` on ${HEAD_SHA:0:9}" >> "$GITHUB_STEP_SUMMARY"
+6 -2
View File
@@ -5,11 +5,15 @@ name: run-parity
# skipped `verify`, and GitHub counts a skipped required check as passed.
on:
pull_request:
types: [labeled]
# A push starts a run only to cancel the old head's (see concurrency): the job runs on the label alone.
types: [labeled, synchronize]
permissions:
contents: read
concurrency:
group: run-parity-${{ github.event.pull_request.number }}
# The run-parity label and pushes share one group per pull request, so the newest run cancels the one before: a
# pushed pull request's run tested a head the merge would no longer land. Any other label gets a group of its own:
# in the shared one, adding needs-proof cancelled the parity run queued seconds before (run 40766180150).
group: run-parity-${{ github.event.pull_request.number }}${{ github.event.action == 'labeled' && github.event.label.name != 'run-parity' && format('-{0}', github.run_id) || '' }}
cancel-in-progress: true
env:
# Test runs never reach the telemetry server: no live-count ping, no uploads (docs/telemetry.md).
+7 -2
View File
@@ -35,11 +35,12 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
| Crate | Owns |
|---|---|
| `horizon-client` | The game: a lib (`src/lib.rs`, ~170 flat modules) and the bin (`src/main.rs` → `game.rs` + `game/`), plus `horizon-ctl` (`src/bin/horizon-ctl/`). Also the dedicated server (`--dedicated`) |
| `horizon-client` | The game: a lib (`src/lib.rs`, ~100 flat modules: gameplay, networking, title screens) and the bin (`src/main.rs` → `game.rs` + `game/`), plus `horizon-ctl` (`src/bin/horizon-ctl/`). Also the dedicated server (`--dedicated`) |
| `horizon-items` | Blocks and items below gameplay: `blocks.rs` (block facts), `items.rs`, `item_data.rs` (components), `enchant.rs`, `cooldowns.rs`, `bundle.rs`, `item_nbt.rs`. Re-exported as `horizon_client::items`... |
| `horizon-command` | Command parsing (Brigadier): the vanilla tree, argument types, selectors, suggestions, the tree's network form. Re-exported as `horizon_client::command` |
| `horizon-client-core` | The client lib's bottom layer: `text.rs`, `lang.rs`, `snbt.rs`, `nbt_path.rs`, `component.rs`, `scoreboard.rs`, `bossbar.rs`, `gamerules.rs`, `block_entity_nbt.rs`, registry/particle/sound tables. Re-exported under the old paths (`horizon_client::text`...) |
| `horizon-client-input` | Key bindings and key names, toggle keys, mouse options, controller settings and glyphs, the clipboard, window options (`keybinds.rs`, `gamepad.rs`, `mouse.rs`, `window.rs`...). Re-exported under the old paths |
| `horizon-client-render` | The client's world data, rendering and screens (chunks, meshing, light, streaming, `gfx`, HUD, menus, settings): ~80 modules that horizon-client re-exports under the same paths. Nothing in it reaches gameplay |
| `horizon-world` | Vanilla world data with no gameplay loop: NBT, regions, chunks, `datafix/`, `worldgen/`, `level/` (server-side block behaviour, ticks, redstone), entity physics, items, effects, food, damage |
| `horizon-core` | Simulation primitives: Java `Random`s, `jmath` (HotSpot-exact `Math`), positions, noise, density, paletted storage, `version.rs` (the pinned version) |
| `horizon-protocol` | Network protocol 777: codecs, framing, encryption, `packets/{handshake,status,login,configuration,play}.rs`, `entity_data.rs` |
@@ -99,6 +100,9 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
## Vanilla reference
- Start parity work from the port ledger, not grep: `python3 tools/port_ledger.py issue N` (or `where
Class.method`, `what file:line`), and `check --base origin/main` before the PR. How:
[.claude/skills/vanilla-parity/SKILL.md](.claude/skills/vanilla-parity/SKILL.md), [docs/port-ledger.md](docs/port-ledger.md).
- Decompiled sources: `.local/minecraft/26.3/sources/{client,server}/` (`python3 tools/lab.py decompile`).
- Bytecode queries (callers through lambdas, every overload):
`python3 tools/rea_vanilla.py method net.minecraft.world.level.block.DispenserBlock dispenseFrom`
@@ -110,7 +114,8 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
## horizon-client: where things live
`L:` = lib (`crates/horizon-client/src/`), `G:` = bin (`crates/horizon-client/src/game/`). The lib can't see
`L:` = lib (`crates/horizon-client/src/`, or `crates/horizon-client-render/src/` for the modules that crate holds, under
the same `horizon_client::<module>` paths), `G:` = bin (`crates/horizon-client/src/game/`). The lib can't see
`Game`; bin code reaches the lib as `horizon_client::<module>`.
| Subsystem | Files |
Generated
+80 -2
View File
@@ -876,6 +876,12 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
[[package]]
name = "dunce"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]]
name = "either"
version = "1.19.0"
@@ -1372,7 +1378,6 @@ dependencies = [
"ab_glyph",
"bytemuck",
"chrono",
"cpal",
"flate2",
"gilrs",
"glam",
@@ -1380,6 +1385,7 @@ dependencies = [
"horizon-bench",
"horizon-client-core",
"horizon-client-input",
"horizon-client-render",
"horizon-command",
"horizon-core",
"horizon-dualsense",
@@ -1398,6 +1404,7 @@ dependencies = [
"log",
"miniz_oxide 0.8.9",
"naga",
"open",
"png",
"pollster",
"rustix 1.1.5",
@@ -1405,7 +1412,6 @@ dependencies = [
"serde_json",
"sha1",
"sha2",
"symphonia",
"unicode-bidi",
"ureq",
"wgpu",
@@ -1434,6 +1440,48 @@ dependencies = [
"winit",
]
[[package]]
name = "horizon-client-render"
version = "0.1.0"
dependencies = [
"ab_glyph",
"bytemuck",
"chrono",
"cpal",
"flate2",
"glam",
"horizon-assets",
"horizon-bench",
"horizon-client-core",
"horizon-client-input",
"horizon-command",
"horizon-core",
"horizon-hdr",
"horizon-items",
"horizon-protocol",
"horizon-shader-api",
"horizon-shaders",
"horizon-telemetry",
"horizon-theme",
"horizon-threads",
"horizon-world",
"log",
"miniz_oxide 0.8.9",
"naga",
"png",
"pollster",
"rustix 1.1.5",
"serde",
"serde_json",
"sha2",
"symphonia",
"unicode-bidi",
"ureq",
"wgpu",
"winit",
"zip",
]
[[package]]
name = "horizon-command"
version = "0.1.0"
@@ -1877,6 +1925,25 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "is-docker"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
dependencies = [
"once_cell",
]
[[package]]
name = "is-wsl"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
dependencies = [
"is-docker",
"once_cell",
]
[[package]]
name = "itertools"
version = "0.14.0"
@@ -2691,6 +2758,17 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "open"
version = "5.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d8c8b8e725ad26404d6896eb2f064294a0a7b4f1d9120210d85544684c4ca95"
dependencies = [
"dunce",
"is-wsl",
"libc",
]
[[package]]
name = "orbclient"
version = "0.3.55"
+5 -2
View File
@@ -9,6 +9,7 @@ members = [
"crates/horizon-client",
"crates/horizon-client-core",
"crates/horizon-client-input",
"crates/horizon-client-render",
"crates/horizon-command",
"crates/horizon-items",
"crates/horizon-server-files",
@@ -61,8 +62,10 @@ large_enum_variant = "allow"
# wgpu-hal 30.0.1 with gfx-rs/wgpu#10124: wgpu 30 creates a Vulkan descriptor
# pool with no pool sizes while it creates the device, which Vulkan allows only
# since 1.3.215, and Intel's Windows driver for 6th-10th gen iGPUs (HD 520/530,
# UHD 620/630) crashes on it with an access violation (#746). Drop this and the
# folder once a wgpu release has the fix.
# 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.
[patch.crates-io]
wgpu-hal = { path = "third_party/wgpu-hal" }
+11 -6
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 281 differential harnesses" width="100%">
<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%">
</p>
<p align="center">
@@ -23,15 +23,15 @@
# 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 281 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.
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.
> 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.
## 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 and updates the game, prepares its files from your own copy of Minecraft, and updates itself.
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.
**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)); Minecraft: Java Edition 26.3, played once (the official launcher, Prism, MultiMC and other launchers all work); a Java 25 runtime, for the one-time preparation; and a GPU with Vulkan on Linux and Windows, or any Apple Silicon Mac. 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 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).
<table>
<tr>
@@ -48,7 +48,7 @@ The [documentation](https://playhorizon.dev/docs/) covers each of these.
- **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 and item modifiers.
- **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 mod system (Rust compiled to WebAssembly). Java mod jars do not run. See [Mods and data packs](https://playhorizon.dev/docs/mods).
- **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:
@@ -93,7 +93,7 @@ Commands block until the action is done and reply with what happened; errors are
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.
Horizon contains no game files and never downloads them. It builds its resources from **your own copy** of Minecraft: Java Edition 26.3, verified against the checksums in `research-lock.json`:
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.
@@ -139,6 +139,11 @@ average; `tests/test_parity.py` keeps it in sync with this table. Last audited
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/`.
| 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 |
+111 -30
View File
@@ -16,7 +16,7 @@ Horizon is Minecraft: Java Edition 26.3 rewritten from scratch in Rust, so it ru
- **Single player as a server: the last differences**: Single player now runs a small server in the background, as vanilla does. We are fixing what still behaves differently there; every fix also works for LAN and server players.
- **Horizon's mod registry**: One place for mods, modpacks and resource packs made for Horizon: browse them on the website and install them from the launcher in one click.
**Next**: Microsoft sign-in (coming soon); CurseForge in the mod browser (coming soon); Fresh benchmarks against vanilla; Pistons push players and mobs; Horizon on Intel Macs; Faster world creation and entry on small CPUs; Servers that hold 20 TPS with 50 to 100 players; Low-end computers: Steam Deck, old Macs, 8 GB; Native upscaling; A batch of targeted optimisations; A Convert to Rust button for what isn't rewritten yet; Mods with code: machines, magic, mobs; Data packs in the mod browser; Prepare a version without Java; Server console, bans and a full admin API; Hosting polish: Windows service, safe updates; Guided hosting with Tailscale and an invite button; An official Docker image for servers; One single-player path: remove the in-frame world; Villages and combat, exactly as in vanilla; Choose the audio output device; Mods that read and change the world; Machines and blocks with inventories (API 0.4); Lua and other languages for mods; Well-known shader looks, rewritten for Horizon; A visible beta label; Wayland and Hyprland polish; A versioned agent-control protocol; Port mods with your own AI agent; Horizon on X (coming soon); Pipeline protection and a GitHub App for agents; Signed updates everywhere, installers included; A server you install with one command.
**Next**: A new launcher design; CurseForge in the mod browser (coming soon); Fresh benchmarks against vanilla; Pistons push players and mobs; Horizon on Intel Macs; Faster world creation and entry on small CPUs; Servers that hold 20 TPS with 50 to 100 players; Low-end computers: Steam Deck, old Macs, 8 GB; Native upscaling; A batch of targeted optimisations; A Convert to Rust button for what isn't rewritten yet; Mods with code: machines, magic, mobs; Data packs in the mod browser; Prepare a version without Java; Server console, bans and a full admin API; Hosting polish: Windows service, safe updates; Guided hosting with Tailscale and an invite button; An official Docker image for servers; One single-player path: remove the in-frame world; Villages and combat, exactly as in vanilla; Choose the audio output device; Mods that read and change the world; Machines and blocks with inventories (API 0.4); Well-known shader looks, rewritten for Horizon; A visible beta label; Wayland and Hyprland polish; A versioned agent-control protocol; Port mods with your own AI agent; Horizon on X (coming soon); Pipeline protection and a GitHub App for agents; Signed updates everywhere, installers included; A server you install with one command; Ready-made setups for hosting panels.
**How to help**: Play it and tell us what differs from vanilla (Discord or GitHub), run the benchmark on your computer, vote for what you want next, or pick one of the Help wanted items.
@@ -104,37 +104,49 @@ More: [docs/mods.md](docs/mods.md)
</details>
#### Native mods, built by the launcher
Horizon's own mods become native code that the launcher builds into your game, with no Rust to install: Zoom, FPS Counter, Keystrokes and Minimap already are, and Recipes is next.
<details><summary>Technical</summary>
Stable hook points in the game (game/native_hooks.rs, [#3345](https://github.com/horizon-rust/horizon/issues/3345), [#3346](https://github.com/horizon-rust/horizon/issues/3346); v2 with the item panel and recipe pages, [#3434](https://github.com/horizon-rust/horizon/issues/3434), [#3435](https://github.com/horizon-rust/horizon/issues/3435)), horizon-mixins applied by the launcher with its own portable Rust ([#3388](https://github.com/horizon-rust/horizon/issues/3388)), on/off in Options > Mods. The official mods live in horizon-mods/native/ and are in the registry as native mods. Next: Recipes, native mods on automatically when a player installs one (with a notice about the first build), then the WebAssembly runtime is removed ([#3350](https://github.com/horizon-rust/horizon/issues/3350), [#3351](https://github.com/horizon-rust/horizon/issues/3351), [#3353](https://github.com/horizon-rust/horizon/issues/3353), [#3371](https://github.com/horizon-rust/horizon/issues/3371)).
More: [docs/native-mods.md](docs/native-mods.md)
</details>
#### Mods that run on the server
Mods that run on the server side of a world, so a dedicated server or a LAN world can have mods that react to players joining, chat and blocks being broken or placed, and that add commands.
<details><summary>Technical</summary>
API 0.3, experimental first ([#1833](https://github.com/horizon-rust/horizon/issues/1833), in review): the same sandboxed component runs on the server thread and the dedicated server, with world events (block broken or placed, entity spawned or killed, player joined or left), game rules and server commands with typed arguments. The world, item and entity interfaces come after (see 'Mods that read and change the world'). [docs/api/mods.md](docs/api/mods.md), [docs/mods.md](docs/mods.md).
The same hook points on the server thread and the dedicated server: world events (block broken or placed, entity spawned or killed, player joined or left), game rules and server commands with typed arguments. The earlier WebAssembly API 0.3 ([#1833](https://github.com/horizon-rust/horizon/issues/1833)) is the reference for what to cover.
More: [docs/native-mods.md](docs/native-mods.md)
</details>
#### Screens, input and drawing for mods
Mods will be able to open their own screens and settings pages and react to keys and item use, so bigger mods, such as the Orbital Laser, can run on the normal game.
Mods will be able to open their own screens and settings pages, react to keys and item use, and draw, so bigger mods, such as the Orbital Laser, run on the normal game.
<details><summary>Technical</summary>
Capability `ui` is in review ([#1864](https://github.com/horizon-rust/horizon/issues/1864)): mod-owned screens and a settings page under Options > Mods, built from vanilla's widgets. Still to design: `input` (an item's use, key binds, the crosshair target), `render` (a budgeted draw list on the native shader API) and `world` (changing blocks). Orbital Laser is the acceptance test: it needs all four today through its author's fork (see 'Orbital Laser on the normal game').
More: [docs/mods.md](docs/mods.md)
On native hook points ([docs/native-mods.md](docs/native-mods.md)): settings pages under Options > Mods and the item panel are there ([#3346](https://github.com/horizon-rust/horizon/issues/3346), [#3435](https://github.com/horizon-rust/horizon/issues/3435)). Next: mod-owned screens built from vanilla's widgets, an item's use, key binds and the crosshair target, and a budgeted draw list on the native shader API. Orbital Laser is the acceptance test.
</details>
#### Tools for people who make mods
Better tools for mod makers: a test runner that needs no game window, templates for each kind of mod, and clear promises about what keeps working from one version to the next.
Better tools for mod makers: templates for each kind of mod, a test run with no game window, and clear promises about which hook points keep working from one version to the next.
<details><summary>Technical</summary>
`cargo horizon-mod test` runs a mod's test scripts against a fake game snapshot ([#1816](https://github.com/horizon-rust/horizon/issues/1816)) and, for server mods, scripted server events ([#1837](https://github.com/horizon-rust/horizon/issues/1837)); the content template and the guides are merged ([#1859](https://github.com/horizon-rust/horizon/issues/1859), [#1810](https://github.com/horizon-rust/horizon/issues/1810), [docs/api/stability.md](docs/api/stability.md)). Left: templates for hud, server and machine mods, typed wrappers in the SDK, a frozen 0.1 compatibility mod that every runtime must load, a CI check that fails when a stable reference entry is removed without a new major version, and the SDK on crates.io once the API is stable.
Native mod template and the content mod template (horizon-mods), `native.py check` that applies mods on a chosen Horizon and builds them together, a weekly check of every official mod on the newest Nightly. Left: a versioned list of stable hook points (NATIVE_HOOKS_API), a test runner without a window, and docs on the site for native mods.
More: [https://playhorizon.dev/docs/test-a-mod](https://playhorizon.dev/docs/test-a-mod)
More: [#3388](https://github.com/horizon-rust/horizon/issues/3388) · [docs/native-mods.md](docs/native-mods.md)
</details>
@@ -244,6 +256,20 @@ Horizon's own way to make shader packs, with tools to write and test them. Every
</details>
### Launcher
#### Minecraft Java instances in the launcher
Play the real Java game from the Horizon launcher too: vanilla and modded instances, any version, mods from Modrinth filtered to what fits, and your instances imported from other launchers.
<details><summary>Technical</summary>
For an account that owns the game: any version downloaded with the Java it needs ([#3406](https://github.com/horizon-rust/horizon/issues/3406)), Java instances, the version list and the CLI ([#3395](https://github.com/horizon-rust/horizon/issues/3395)), the start command read from the version JSON ([#3419](https://github.com/horizon-rust/horizon/issues/3419)). Next: Play for a Java instance, Fabric and Quilt, then NeoForge and Forge; Modrinth with compatibility filtering and dependencies; imports from Prism, MultiMC, the CurseForge app, the Modrinth App, ATLauncher and the official launcher, each from its documented on-disk format.
More: [#3506](https://github.com/horizon-rust/horizon/issues/3506)
</details>
### Horizon extras
#### Discord Rich Presence
@@ -268,6 +294,16 @@ A review of the whole tree: dead code and stubs removed ([#1432](https://github.
</details>
#### Every vanilla method, tracked
A public list of every method of the Java game and where Horizon does it, so anyone can see what is ported, what is missing and pick something to fix.
<details><summary>Technical</summary>
[tools/port_ledger.py](tools/port_ledger.py) and tools/port_map/ ([#3139](https://github.com/horizon-rust/horizon/issues/3139)): every 26.3 method outside the skipped packages has a verdict (port, inline, missing, skip, trivial), linked to the harnesses that cover it ([#3526](https://github.com/horizon-rust/horizon/issues/3526)), with one tracking issue per area. Bug fixes start from it (.claude/skills/vanilla-parity).
</details>
## Next
### Performance
@@ -444,6 +480,16 @@ A player's idea (#horizon-ideas). `horizon-server` already runs headless; left:
</details>
#### Ready-made setups for hosting panels
Install a Horizon server from the panels hosts use, such as Pterodactyl and AMP, the usual way for each panel.
<details><summary>Technical</summary>
Asked in the suggestion queue for Pterodactyl eggs and AMP templates. Builds on the one-command install ('A server you install with one command') and horizon-server's server.toml ([docs/hosting.md](docs/hosting.md)).
</details>
### Mods and API
#### Mods that read and change the world
@@ -452,9 +498,9 @@ Mods that can see and change blocks, read inventories and items, and find and af
<details><summary>Technical</summary>
After server mods: read blocks, states and biomes in loaded chunks and change them through the server's block log, so clients and the parity harnesses see vanilla's updates; the player's inventory, held item, open container and item stacks with components; entity queries near a position, a safe subset of entity data and data-driven mod entity types. Snapshots and batched calls per tick, never per-block host calls in hot paths. [docs/api/mods.md](docs/api/mods.md) ('Not there yet').
Hook points for reading blocks, states and biomes and changing them through the server's block log, so clients and the parity harnesses see vanilla's updates; the player's inventory and item stacks with components; entity queries near a position. Batched per tick, never per-block calls in hot paths.
More: [docs/modpacks.md](docs/modpacks.md)
More: [docs/native-mods.md](docs/native-mods.md) · [docs/modpacks.md](docs/modpacks.md)
</details>
@@ -468,16 +514,6 @@ Block entities and menus: ticking machines with slots, server menu logic and the
</details>
#### Lua and other languages for mods
Write mods as Lua scripts, in the style of KubeJS, with no compiler, and later in other languages that pass the tests. Every language runs in the same sandbox.
<details><summary>Technical</summary>
Lua first: Horizon ships a Lua interpreter that is itself a WebAssembly mod, and a script mod's manifest names it and the capabilities it uses, so a script can do exactly what a compiled mod with the same manifest can, with hot reload of `main.lua`. Then C, C++, Zig, Go (TinyGo), AssemblyScript and JavaScript or TypeScript are measured against Rust on the same tick-heavy example, and each is supported only with a template, an example and a CI test. [docs/api/mods.md](docs/api/mods.md) ('Ways in').
</details>
### Gameplay parity
#### Pistons push players and mobs
@@ -536,15 +572,13 @@ More: [docs/shaders.md](docs/shaders.md)
### Launcher
#### Microsoft sign-in (coming soon)
#### A new launcher design
Coming soon: sign in with your Microsoft account to play on online-mode servers. The code is ready; it waits for Microsoft's approval.
A redesigned launcher: every button where you expect it, the fewest clicks to play, add an instance or add mods, smooth animations, and instant startup.
<details><summary>Technical</summary>
Device-code sign-in in the launcher and online-mode login in the client ([docs/microsoft-account.md](docs/microsoft-account.md)). It is turned on by setting the app's client id once Minecraft API access is approved; until then the launcher shows "Coming soon".
More: [#1270](https://github.com/horizon-rust/horizon/issues/1270)
Based on the site concept by the Team (Bricolage Grotesque, a night sky and an animated pixel sunset). Screens and flows are designed first, then small PRs; tokens in [crates/horizon-theme](crates/horizon-theme) and rules in [docs/design-system.md](docs/design-system.md), the game's vanilla look unchanged. Measured before and after: startup, frame time and idle CPU/GPU, reduced motion honoured. Mods screens separate Horizon native mods from Java mods; a running instance can be stopped from the launcher.
</details>
@@ -708,6 +742,18 @@ Issue [#1270](https://github.com/horizon-rust/horizon/issues/1270) asks for Ely.
</details>
#### Join servers on older versions
Join servers that run older Minecraft versions, such as 1.21 or 1.8 PvP servers, from Horizon.
<details><summary>Technical</summary>
Asked often in the suggestion queue. Horizon speaks 26.3's protocol; the path is a native protocol adapter in Rust that translates older versions' packets, not a downloaded Java proxy. Java instances in the launcher already play any version with the real game.
More: [#3023](https://github.com/horizon-rust/horizon/issues/3023)
</details>
#### Online-mode checks for Play with Friends
Friends who join through an invite code could prove who they are with their Microsoft account, as on online-mode servers.
@@ -720,6 +766,18 @@ Play with Friends joins over an encrypted tunnel with an invite code ([docs/play
### Mods and API
#### Scripts for mods
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.
More: [docs/native-mods.md](docs/native-mods.md)
</details>
#### 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.
@@ -732,13 +790,13 @@ A pull request on [https://github.com/Stink-O/orbital-laser](https://github.com/
#### Native versions of popular client mods
Built-in versions of the client mods players ask for: block info on hover, a recipe viewer like JEI, a world map and schematic overlays.
Built-in versions of the client mods players ask for: block info on hover, a world map, inventory tweaks and schematic overlays.
<details><summary>Technical</summary>
Done as extras: a mod list and menu, a minimap and a zoom ([docs/extras.md](docs/extras.md)). Left: block info tooltips, a recipe viewer (a contributor offered to port JEI if it becomes a native feature), a world map and a schematic overlay. They need the read-only interfaces `inventory-read`, `recipes`, `world-read` and `render` in the mod API, or they go into the game directly.
Done: a mod list and menu, minimap, zoom, keystrokes, an FPS counter and a recipe viewer, as native mods (horizon-mods/native/). Asked for: an angled world map that shows hills, overhangs and caves, inventory tweaks (drag and scroll to move items), block info tooltips and schematic overlays.
More: [docs/mods.md](docs/mods.md)
More: [docs/extras.md](docs/extras.md) · [docs/native-mods.md](docs/native-mods.md)
</details>
@@ -784,6 +842,16 @@ The game side is merged ([docs/controllers.md](docs/controllers.md)): pads read
</details>
#### Splitscreen
Play with a friend on the same screen, each with a controller.
<details><summary>Technical</summary>
Issue [#2054](https://github.com/horizon-rust/horizon/issues/2054); a contributor is working on it. Two local players on the same world, each with their own camera and HUD, built on the server thread's multiplayer.
</details>
#### The full debug screen
Vanilla's complete F3 screen: debug options, a profiler chart, ten-second captures and an atlas dump.
@@ -834,7 +902,7 @@ More graphics options: multisample anti-aliasing, anisotropic filtering and an e
<details><summary>Technical</summary>
Not implemented ([docs/graphics.md](docs/graphics.md)). Anisotropic filtering needs linear magnification in wgpu, which blurs close blocks, so close-up sharpness has to be kept first; MSAA would sit beside FXAA; exclusive fullscreen where the backend supports it.
Not implemented ([docs/graphics.md](docs/graphics.md)). Anisotropic filtering needs linear magnification in wgpu, which blurs close blocks, so close-up sharpness has to be kept first; MSAA would sit beside FXAA, and SMAA or TAA are asked for too; exclusive fullscreen where the backend supports it.
</details>
@@ -862,6 +930,18 @@ Issue [#1709](https://github.com/horizon-rust/horizon/issues/1709): aarch64 buil
</details>
#### Horizon on Windows ARM64
A native Horizon for Windows on ARM computers, such as Snapdragon laptops.
<details><summary>Technical</summary>
Releases are x64 for Windows and Linux and arm64 for macOS. Needs an aarch64-pc-windows-msvc build in the release workflow and a machine to test on.
More: [docs/release-flow.md](docs/release-flow.md)
</details>
#### Android, iOS and the browser
Versions of Horizon for Android, iOS and web browsers. Nothing official yet; contributors have started the first pieces.
@@ -968,6 +1048,7 @@ Armour and tools in End city chests should come enchanted, as in vanilla.
## Recently shipped
- **Microsoft sign-in** (launcher@0.2.17): Sign in with your Microsoft account in the launcher to play on online-mode servers. [docs/microsoft-account.md](docs/microsoft-account.md) · [#2879](https://github.com/horizon-rust/horizon/issues/2879)
- **OpenGL for old computers** (v0.2.63): Horizon now starts on old PCs whose graphics card has no Vulkan or DirectX 12 driver: it switches to OpenGL by itself, and Options > Graphics can pick OpenGL by hand. [#1917](https://github.com/horizon-rust/horizon/issues/1917) · [#1920](https://github.com/horizon-rust/horizon/issues/1920) · [#1936](https://github.com/horizon-rust/horizon/issues/1936)
- **Server resource packs** (v0.2.48): Servers can send you their resource pack: Horizon asks, downloads it and applies it, like vanilla. [#1562](https://github.com/horizon-rust/horizon/issues/1562) · [#1569](https://github.com/horizon-rust/horizon/issues/1569) · [#1570](https://github.com/horizon-rust/horizon/issues/1570)
- **Single player on its own server thread** (v0.2.39): Single player runs on a server thread, as vanilla does, so the world keeps ticking smoothly while the window draws. [#1514](https://github.com/horizon-rust/horizon/issues/1514) · [docs/server-levels.md](docs/server-levels.md)
+4
View File
@@ -97,6 +97,9 @@ impl Report {
for r in &f.requires {
out += &format!(" needs {} {} ({})\n", r.id, r.version, &r.sha256[..12]);
}
if self.kind == Kind::NativeMod {
out += " native source: unsandboxed; not executed by the registry check. Compile and test against the exact pinned Horizon and Mixins revisions before review.\n";
}
if self.kind == Kind::Mod && !self.code {
out += " no code: content only, nothing to run\n";
} else if self.kind == Kind::Mod {
@@ -118,6 +121,7 @@ impl Report {
pub fn kind_of(path: &Path) -> Option<Kind> {
match path.extension()?.to_str()? {
"hmod" => Some(Kind::Mod),
"hnmod" => Some(Kind::NativeMod),
"hpack" => Some(Kind::Modpack),
"zip" => Some(Kind::ResourcePack),
"hshader" => Some(Kind::Shader),
+107 -51
View File
@@ -11,13 +11,18 @@ pub fn parse_snbt(input: &str) -> Result<(Value, usize), String> {
let mut p = Parser {
s: input.as_bytes(),
i: 0,
depth: 0,
};
let v = p.value()?;
Ok((v, p.i))
}
/// Nesting deeper than NBT's own limit (`NbtAccounter`'s 512) is refused
/// rather than overflowing the stack.
const SNBT_MAX_DEPTH: usize = 512;
struct Parser<'a> {
s: &'a [u8],
i: usize,
depth: usize,
}
impl Parser<'_> {
fn ws(&mut self) {
@@ -41,57 +46,22 @@ impl Parser<'_> {
self.ws();
match self.peek() {
Some(b'{') => {
self.i += 1;
let mut map = Map::new();
self.ws();
if self.peek() == Some(b'}') {
self.i += 1;
return Ok(Value::Object(map));
}
loop {
self.ws();
let key = self.string()?;
self.expect(b':')?;
map.insert(key, self.value()?);
self.ws();
match self.peek() {
Some(b',') => self.i += 1,
Some(b'}') => {
self.i += 1;
return Ok(Value::Object(map));
}
_ => return Err("expected ',' or '}'".into()),
}
if self.depth >= SNBT_MAX_DEPTH {
return Err(format!("tag nesting too deep at {}", self.i));
}
self.depth += 1;
let v = self.object();
self.depth -= 1;
v
}
Some(b'[') => {
self.i += 1;
let mut list = Vec::new();
self.ws();
// Typed arrays [B;...], [I;...], [L;...].
if matches!(
self.s.get(self.i..self.i + 2),
Some([b'B' | b'I' | b'L', b';'])
) {
self.i += 2;
}
self.ws();
if self.peek() == Some(b']') {
self.i += 1;
return Ok(Value::Array(list));
}
loop {
list.push(self.value()?);
self.ws();
match self.peek() {
Some(b',') => self.i += 1,
Some(b']') => {
self.i += 1;
return Ok(Value::Array(list));
}
_ => return Err("expected ',' or ']'".into()),
}
if self.depth >= SNBT_MAX_DEPTH {
return Err(format!("tag nesting too deep at {}", self.i));
}
self.depth += 1;
let v = self.array();
self.depth -= 1;
v
}
Some(b'"' | b'\'') => Ok(Value::String(self.string()?)),
Some(_) => {
@@ -105,10 +75,20 @@ impl Parser<'_> {
_ => {
let digits = word
.trim_end_matches(['b', 'B', 's', 'S', 'l', 'L', 'f', 'F', 'd', 'D']);
match (digits.parse::<i64>(), digits.parse::<f64>()) {
(Ok(n), _) if digits.len() + 1 >= word.len() => Value::from(n),
(_, Ok(f)) if digits.len() + 1 >= word.len() => Value::from(f),
_ => Value::String(word),
// Only digits (and an exponent) make a number: `nan`
// or `Infinity`, which Rust's parse would take, stays
// a string.
if digits
.bytes()
.any(|c| c.is_ascii_alphabetic() && c != b'e' && c != b'E')
{
Value::String(word)
} else {
match (digits.parse::<i64>(), digits.parse::<f64>()) {
(Ok(n), _) if digits.len() + 1 >= word.len() => Value::from(n),
(_, Ok(f)) if digits.len() + 1 >= word.len() => Value::from(f),
_ => Value::String(word),
}
}
}
})
@@ -116,6 +96,59 @@ impl Parser<'_> {
None => Err("expected a value".into()),
}
}
fn object(&mut self) -> Result<Value, String> {
self.i += 1;
let mut map = Map::new();
self.ws();
if self.peek() == Some(b'}') {
self.i += 1;
return Ok(Value::Object(map));
}
loop {
self.ws();
let key = self.string()?;
self.expect(b':')?;
map.insert(key, self.value()?);
self.ws();
match self.peek() {
Some(b',') => self.i += 1,
Some(b'}') => {
self.i += 1;
return Ok(Value::Object(map));
}
_ => return Err("expected ',' or '}'".into()),
}
}
}
fn array(&mut self) -> Result<Value, String> {
self.i += 1;
let mut list = Vec::new();
self.ws();
// Typed arrays [B;...], [I;...], [L;...].
if matches!(
self.s.get(self.i..self.i + 2),
Some([b'B' | b'I' | b'L', b';'])
) {
self.i += 2;
}
self.ws();
if self.peek() == Some(b']') {
self.i += 1;
return Ok(Value::Array(list));
}
loop {
list.push(self.value()?);
self.ws();
match self.peek() {
Some(b',') => self.i += 1,
Some(b']') => {
self.i += 1;
return Ok(Value::Array(list));
}
_ => return Err("expected ',' or ']'".into()),
}
}
}
fn unquoted(&mut self) -> String {
let start = self.i;
while let Some(c) = self.peek() {
@@ -195,6 +228,11 @@ fn object_style(o: &serde_json::Map<String, Value>) -> Style {
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,
};
}
@@ -346,4 +384,22 @@ mod tests {
let t = to_text(&v, &mut |_| Text::empty());
assert_eq!(t.spans[0].style.font, None);
}
#[test]
fn snbt_nesting_is_bounded() {
let nest = |n: usize| format!("{}1{}", "[".repeat(n), "]".repeat(n));
assert!(parse_snbt(&nest(512)).is_ok());
assert!(parse_snbt(&nest(513)).is_err());
assert!(parse_snbt(&nest(100_000)).is_err());
}
#[test]
fn bare_words_that_look_numeric_stay_strings() {
let (v, _) = parse_snbt("{text:nan}").unwrap();
assert_eq!(v["text"], Value::String("nan".into()));
let (v, _) = parse_snbt("{text:Infinity}").unwrap();
assert_eq!(v["text"], Value::String("Infinity".into()));
let (v, _) = parse_snbt("{n:1.5}").unwrap();
assert_eq!(v["n"], Value::from(1.5));
}
}
+30 -1
View File
@@ -40,6 +40,10 @@ static BIDIRECTIONAL: AtomicBool = AtomicBool::new(false);
/// A source of resource files: the prepared root plus packs, highest priority last.
pub trait ResourceSource {
fn read(&self, path: &str) -> Option<Vec<u8>>;
/// Whether `path` is there, without reading it where the source can tell.
fn exists(&self, path: &str) -> bool {
self.read(path).is_some()
}
/// The namespaces under `assets/` (resource packs may add their own).
fn namespaces(&self) -> Vec<String> {
vec!["minecraft".into()]
@@ -68,6 +72,9 @@ impl ResourceSource for std::path::PathBuf {
fn read(&self, path: &str) -> Option<Vec<u8>> {
std::fs::read(self.join(path)).ok()
}
fn exists(&self, path: &str) -> bool {
self.join(path).is_file()
}
fn namespaces(&self) -> Vec<String> {
std::fs::read_dir(self.join("assets"))
.into_iter()
@@ -190,7 +197,7 @@ pub fn available(sources: &[&dyn ResourceSource]) -> Vec<LanguageInfo> {
for (code, info) in value["language"].as_object().into_iter().flatten() {
// Only languages whose file exists somewhere are selectable.
let file = format!("assets/minecraft/lang/{code}.json");
if code != DEFAULT && !sources.iter().any(|s| s.read(&file).is_some()) {
if code != DEFAULT && !sources.iter().any(|s| s.exists(&file)) {
continue;
}
found.insert(
@@ -284,4 +291,26 @@ mod tests {
);
assert_eq!(langs[1].label(), "Français (France)");
}
#[test]
fn languages_are_listed_without_reading_their_files() {
struct NoLangReads;
impl ResourceSource for NoLangReads {
fn read(&self, path: &str) -> Option<Vec<u8>> {
assert!(!path.contains("/lang/"), "read {path}");
(path == "assets/pack.mcmeta").then(|| {
br#"{"language":{"fr_fr":{"name":"French"},"de_de":{"name":"German"}}}"#
.to_vec()
})
}
fn exists(&self, path: &str) -> bool {
path == "assets/minecraft/lang/fr_fr.json"
}
}
let langs = available(&[&NoLangReads]);
assert_eq!(
langs.iter().map(|l| l.code.as_str()).collect::<Vec<_>>(),
["en_us", "fr_fr"]
);
}
}
@@ -54,6 +54,9 @@ pub struct Registries {
pub function_tags: Vec<String>,
/// The block the source is looking at (relevant coordinates).
pub target_block: Option<[i32; 3]>,
/// `ClientSuggestionProvider.getAbsoluteCoordinates`: the exact point of
/// that block hit, each coordinate as `%.2f` (`Locale.ROOT`).
pub target_point: Option<[String; 3]>,
/// Every registry's element and tag ids (`minecraft:item` -> ...), as
/// the server's registry access and reloadable registries hold them.
pub by_registry: BTreeMap<String, RegistryIds>,
+16 -4
View File
@@ -662,10 +662,22 @@ pub fn name_shown(own: Option<&Team>, viewer: Option<&Team>, v: NameView) -> boo
}
!v.hud_hidden && !v.is_camera && visible && !v.is_vehicle
}
/// `ServerPlayer.die`: whether a player's death message reaches the player
/// themselves (the team broadcasts leave out the dying player).
pub fn own_death_message_shown(team: Option<&Team>) -> bool {
team.is_none_or(|t| t.death_message_visibility == Visibility::Always)
/// `ServerPlayer.die`: whether `other` hears the death message of `dying`
/// (`broadcastSystemMessage`, `broadcastSystemToTeam` or
/// `broadcastSystemToAllExceptTeam`, by the team's death message visibility).
pub fn death_message_reaches(board: &Scoreboard, dying: &str, other: &str) -> bool {
let Some(team) = board.players_team(dying) else {
return true;
};
let same_team = board
.players_team(other)
.is_some_and(|t| t.name == team.name);
match team.death_message_visibility {
Visibility::Always => true,
Visibility::Never => false,
Visibility::HideForOtherTeams => same_team && other != dying,
Visibility::HideForOwnTeam => !same_team,
}
}
// ---- objectives and scores ---------------------------------------------
+38 -25
View File
@@ -50,6 +50,9 @@ pub enum ClickEvent {
/// `ClickEvent.RunCommand`: the command, with or without its `/`.
RunCommand(String),
CopyToClipboard(String),
/// `ClickEvent.ShowDialog`: a `minecraft:dialog` registry id, or the
/// dialog itself as the component carried it.
ShowDialog(horizon_world::nbt::Tag),
/// A local file to open with the system handler.
/// Only allow this in locally constructed messages, never server/LAN text.
OpenFile(String),
@@ -400,31 +403,7 @@ impl Text {
continue;
}
let Some(code) = chars.next() else { break };
let code = code.to_ascii_lowercase();
let next = if let Some(c) = COLORS.iter().find(|c| c.0 == code) {
// Style.applyLegacyFormat: a colour clears the decorations.
Style {
color: Some(c.2),
bold: Some(false),
italic: Some(false),
underlined: Some(false),
strikethrough: Some(false),
obfuscated: Some(false),
..style.clone()
}
} else {
let mut s = style.clone();
match code {
'k' => s.obfuscated = Some(true),
'l' => s.bold = Some(true),
'm' => s.strikethrough = Some(true),
'n' => s.underlined = Some(true),
'o' => s.italic = Some(true),
'r' => s = span.style.clone(),
_ => {}
}
s
};
let next = legacy_format(&style, code, &span.style);
if next != style {
if !current.is_empty() {
out.spans.push(Span {
@@ -557,6 +536,11 @@ fn style_json(style: &Style) -> Vec<(String, Value)> {
ClickEvent::RunCommand(command) => {
json!({ "action": "run_command", "command": command })
}
// ClickEvent.ShowDialog.CODEC: the dialog the server sent it with.
ClickEvent::ShowDialog(dialog) => json!({
"action": "show_dialog",
"dialog": crate::scoreboard::tag_to_json(dialog),
}),
ClickEvent::OpenFile(path) => json!({ "action": "open_file", "path": path }),
};
out.push(("click_event".to_owned(), event));
@@ -580,6 +564,35 @@ fn style_json(style: &Style) -> Vec<(String, Value)> {
out
}
/// `Style.applyLegacyFormat` for the code after a `§`, `§r` going back to
/// `reset` (`StringDecomposer.iterateFormatted`); an unknown code changes nothing.
pub fn legacy_format(style: &Style, code: char, reset: &Style) -> Style {
let code = code.to_ascii_lowercase();
if let Some(c) = COLORS.iter().find(|c| c.0 == code) {
// A colour clears the decorations.
return Style {
color: Some(c.2),
bold: Some(false),
italic: Some(false),
underlined: Some(false),
strikethrough: Some(false),
obfuscated: Some(false),
..style.clone()
};
}
let mut s = style.clone();
match code {
'k' => s.obfuscated = Some(true),
'l' => s.bold = Some(true),
'm' => s.strikethrough = Some(true),
'n' => s.underlined = Some(true),
'o' => s.italic = Some(true),
'r' => s = reset.clone(),
_ => {}
}
s
}
/// TranslatableContents.decomposeTemplate: `%s` takes the next argument,
/// `%n$s` a numbered one and `%%` is a percent sign. Malformed templates are
/// shown verbatim.
+48 -13
View File
@@ -300,9 +300,8 @@ impl Default for KeyBinds {
}
}
/// Each default key once, in order: two mappings that share a default key
/// (Advancements and the debug profiler both on L) are one press of it, not
/// two (the second would close the screen the first opened). Vanilla clicks
/// both mappings, but the profiler one only acts with F3 held.
/// (Pick Block and the spectator hotbar both on the middle button) are one
/// press of it, not two.
fn distinct<T: PartialEq>(keys: Vec<T>) -> Vec<T> {
let mut out = Vec::with_capacity(keys.len());
for k in keys {
@@ -368,6 +367,25 @@ impl KeyBinds {
.map(|m| m.0)
.filter(move |n| self.get(n) == key)
}
/// The mappings bound to `key` that [`KeyBinds::translate_all`] follows:
/// all but the debug keys. Those act only with F3 held
/// (`KeyboardHandler.handleDebugKeys`, `debug_keys.rs` reads the physical
/// key and the binding itself); their defaults (A, S, D, T, L, 1 to 4...)
/// would otherwise turn the key back into its old gameplay meaning.
fn translated_mappings<'a>(&'a self, key: &'a str) -> impl Iterator<Item = &'static str> + 'a {
MAPPINGS
.iter()
.filter(|m| m.2 != "debug")
.map(|m| m.0)
.filter(move |n| self.get(n) == key)
}
/// Whether a mapping (other than a debug key) whose default is `key` was
/// bound elsewhere.
fn default_moved(&self, key: &str) -> bool {
MAPPINGS
.iter()
.any(|(n, d, c)| *d == key && *c != "debug" && !self.is_default(n))
}
/// Translate a physical key into the default key of the mapping it is
/// bound to, so game code written against vanilla defaults follows the
/// player's bindings. `None` means the key now does nothing; mouse
@@ -384,7 +402,7 @@ impl KeyBinds {
return vec![Ok(key)];
};
let bound: Vec<_> = self
.mappings_for(name)
.translated_mappings(name)
.filter_map(|mapping| {
let default = Self::default_of(mapping);
match key_code(default) {
@@ -397,10 +415,7 @@ impl KeyBinds {
return distinct(bound);
}
// An unbound key whose default meaning was moved elsewhere is inert.
if MAPPINGS
.iter()
.any(|(n, d, _)| *d == name && !self.is_default(n))
{
if self.default_moved(name) {
return Vec::new();
}
vec![Ok(key)]
@@ -413,7 +428,7 @@ impl KeyBinds {
pub fn translate_mouse_all(&self, button: MouseButton) -> Vec<Result<KeyCode, MouseButton>> {
let name = mouse_name(button);
let bound: Vec<_> = self
.mappings_for(&name)
.translated_mappings(&name)
.filter_map(|mapping| {
let default = Self::default_of(mapping);
match mouse_button(default) {
@@ -425,10 +440,7 @@ impl KeyBinds {
if !bound.is_empty() {
return distinct(bound);
}
if MAPPINGS
.iter()
.any(|(n, d, _)| *d == name && !self.is_default(n))
{
if self.default_moved(&name) {
return Vec::new();
}
vec![Err(button)]
@@ -508,6 +520,29 @@ mod tests {
assert_eq!(k.conflicts("key.forward"), ["key.jump"]);
}
#[test]
fn a_moved_key_keeps_no_default_action() {
// #3158: the debug keys (F3+A, F3+1...) share these keys' defaults,
// and must not hand them back their gameplay meaning.
let mut k = KeyBinds::default();
k.set("key.hotbar.1", "key.keyboard.q");
assert_eq!(k.translate_all(KeyCode::Digit1), []);
assert_eq!(
k.translate_all(KeyCode::KeyQ),
[Ok(KeyCode::KeyQ), Ok(KeyCode::Digit1)]
);
k.set("key.inventory", "key.keyboard.a");
k.set("key.left", "key.keyboard.e");
assert_eq!(k.translate_all(KeyCode::KeyA), [Ok(KeyCode::KeyE)]);
assert_eq!(k.translate_all(KeyCode::KeyE), [Ok(KeyCode::KeyA)]);
k.set("key.back", "key.keyboard.down");
assert_eq!(k.translate_all(KeyCode::KeyS), []);
// Keys left alone still act, and a moved debug key changes nothing.
k.set("key.debug.fpsCharts", "key.keyboard.9");
assert_eq!(k.translate_all(KeyCode::Digit2), [Ok(KeyCode::Digit2)]);
assert_eq!(k.translate_all(KeyCode::Digit9), [Ok(KeyCode::Digit9)]);
assert_eq!(k.translate_all(KeyCode::F3), [Ok(KeyCode::F3)]);
}
#[test]
fn display_names() {
assert_eq!(display_name("key.keyboard.w").plain(), "W");
assert_eq!(display_name("key.keyboard.slash").plain(), "/");
+41
View File
@@ -324,6 +324,24 @@ pub fn ease_in_out(t: f64) -> f64 {
0.5 - 0.5 * (std::f64::consts::PI * t).cos()
}
/// `MouseHandler.grabMouse` / `releaseMouse`: the mouse position is the
/// window's centre whenever the pointer's grab state changes, so a screen
/// opened from gameplay starts with the cursor in the middle. A release of
/// an already free pointer (or a grab of an already grabbed one) leaves the
/// position where it is, as vanilla does.
pub fn mouse_position_after_grab_change(
grab: bool,
was_grabbed: bool,
size: [u32; 2],
scale: f32,
) -> Option<(f32, f32)> {
if grab == was_grabbed {
None
} else {
Some((size[0] as f32 / 2.0 / scale, size[1] as f32 / 2.0 / scale))
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -485,4 +503,27 @@ mod tests {
assert_eq!(look.on_scroll(0.0, 0.4, false, 1.0), None);
assert_eq!(look.on_scroll(0.0, 0.7, false, 1.0), Some((1, (0, 1))));
}
#[test]
fn the_mouse_position_is_the_centre_when_the_grab_changes() {
// A grab of a free pointer and a release of a grabbed one both put
// the position at the window's centre (`MouseHandler.grabMouse`,
// `MouseHandler.releaseMouse`).
assert_eq!(
mouse_position_after_grab_change(true, false, [1920, 1080], 1.0),
Some((960.0, 540.0))
);
assert_eq!(
mouse_position_after_grab_change(false, true, [1920, 1080], 2.0),
Some((480.0, 270.0))
);
// Without a change of the grab state the position stays where it is.
assert_eq!(
mouse_position_after_grab_change(false, false, [1920, 1080], 1.0),
None
);
assert_eq!(
mouse_position_after_grab_change(true, true, [1920, 1080], 1.0),
None
);
}
}
+67
View File
@@ -0,0 +1,67 @@
[package]
name = "horizon-client-render"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version = "1.90"
description = "The client's world data, rendering and screens: chunks, meshing, light, streaming, the renderer, the HUD, menus and settings; no gameplay"
[dependencies]
horizon-assets = { path = "../horizon-assets" }
horizon-bench = { path = "../horizon-bench" }
horizon-client-core = { path = "../horizon-client-core" }
horizon-client-input = { path = "../horizon-client-input" }
horizon-command = { path = "../horizon-command" }
horizon-core = { path = "../horizon-core" }
horizon-hdr = { path = "../horizon-hdr" }
horizon-items = { path = "../horizon-items" }
horizon-protocol = { path = "../horizon-protocol" }
horizon-shader-api = { path = "../horizon-shader-api" }
horizon-shaders = { path = "../horizon-shaders" }
horizon-telemetry = { path = "../horizon-telemetry", default-features = false, features = ["live"] }
horizon-theme = { path = "../horizon-theme" }
horizon-threads = { path = "../horizon-threads" }
horizon-world = { path = "../horizon-world" }
ab_glyph = "0.2.32"
bytemuck = { version = "1.24", features = ["derive"] }
# Sound (ALSA on Linux); the `audio` feature.
cpal = { version = "0.16", optional = true }
chrono = { version = "0.4.42", default-features = false, features = ["clock", "std"] }
flate2 = { version = "1.1.10", default-features = false, features = ["zlib-rs", "runtime_detection"] }
glam = "0.30"
log = "0.4"
miniz_oxide = "0.8"
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out"] }
png = "0.18"
pollster = "0.4"
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1.0.151", features = ["raw_value"] }
sha2 = "0.10.9"
symphonia = { version = "0.5", default-features = false, features = ["ogg", "vorbis"] }
unicode-bidi = "0.3.18"
ureq = { version = "2", default-features = false, features = ["tls"] }
wgpu = { version = "30.0.1", default-features = false, features = ["std", "parking_lot", "vulkan", "metal", "wgsl", "naga-ir"] }
winit = "0.30.13"
zip = { version = "2", default-features = false, features = ["deflate"] }
[target.'cfg(unix)'.dependencies]
rustix = { version = "1", default-features = false, features = ["std", "time"] }
[target.'cfg(windows)'.dependencies]
wgpu = { version = "30.0.1", default-features = false, features = ["dx12"] }
[target.'cfg(not(target_os = "macos"))'.dependencies]
wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
[features]
# Sound output: horizon-client's `audio` feature.
audio = ["dep:cpal"]
# Helpers horizon-client's tests build their fixtures with.
test-support = []
[lints]
workspace = true
[dev-dependencies]
horizon-items = { path = "../horizon-items", features = ["test-support"] }
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out", "msl-out", "spv-out"] }
@@ -42,11 +42,11 @@ impl HeightmapState for StateFacts {
}
#[derive(Clone)]
pub struct Properties(Arc<Vec<StateFacts>>);
#[cfg(test)]
#[cfg(any(test, feature = "test-support"))]
impl Properties {
/// Heightmap facts for tests, per state: (air, has a fluid, blocks
/// motion, blocks motion but is not leaves). State 0 is minecraft:air.
pub(crate) fn for_tests(states: &[(bool, bool, bool, bool)]) -> Self {
pub fn for_tests(states: &[(bool, bool, bool, bool)]) -> Self {
Self(Arc::new(
states
.iter()
@@ -196,7 +196,7 @@ impl Assets {
impl RenderAssets {
pub fn load(root: &Path, assets: &Assets) -> Result<Self> {
let registry = assets.registry;
let mut sprites = crate::sprites::Sprites::default();
let mut sprites = crate::sprites::Sprites::with_disk_cache(root);
for name in MATERIALS {
sprites.insert(root, &format!("minecraft:block/{name}"))?;
}
@@ -217,6 +217,7 @@ impl RenderAssets {
for stage in 0..10 {
sprites.insert(root, &format!("minecraft:block/destroy_stage_{stage}"))?;
}
sprites.save_disk_cache();
sprites.finish();
// `finish` grows the buffer by doubling: keep only the bytes in use.
sprites.pixels.shrink_to_fit();
@@ -187,7 +187,14 @@ pub fn list_textures(root: &Path) -> Vec<String> {
};
for e in entries.flatten() {
let p = e.path();
if p.is_dir() {
// `file_type` comes with the listing; `Path::is_dir` is a stat per
// entry, kept for links (which it follows).
let dir = match e.file_type() {
Ok(t) if t.is_symlink() => p.is_dir(),
Ok(t) => t.is_dir(),
Err(_) => p.is_dir(),
};
if dir {
stack.push(p);
} else if p.extension().is_some_and(|x| x == "png")
&& let Ok(rel) = p.strip_prefix(&base)
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -20,12 +20,68 @@ pub struct SpawnEntry {
/// A `ConstantInt` count (sampling draws nothing).
pub constant: bool,
}
/// A `natural_mob_spawns` / `spawn_overrides` entry list.
pub fn parse_entries(list: &Value) -> Vec<SpawnEntry> {
list.as_array()
.into_iter()
.flatten()
.filter_map(|e| {
let count = &e["count"];
let (min, max, constant) = match count {
Value::Number(n) => {
let n = n.as_u64().unwrap_or(1) as u32;
(n, n, true)
}
c if c["type"].as_str() == Some("minecraft:constant") => {
let n = c["value"].as_u64().unwrap_or(1) as u32;
(n, n, true)
}
c => (
c["min_inclusive"].as_u64().unwrap_or(1) as u32,
c["max_inclusive"].as_u64().unwrap_or(1) as u32,
false,
),
};
Some(SpawnEntry {
entity: e["type"].as_str()?.to_owned(),
weight: e["weight"].as_u64().unwrap_or(1) as u32,
min,
max,
constant,
})
})
.collect()
}
/// `MobSpawnSettings.MobSpawnCost`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SpawnCost {
pub energy_budget: f64,
pub charge: f64,
}
/// The `spawn_costs` of a `natural_mob_spawns` value.
pub fn parse_costs(costs: &Value) -> HashMap<String, SpawnCost> {
costs
.as_object()
.into_iter()
.flatten()
.map(|(k, c)| {
(
k.clone(),
SpawnCost {
energy_budget: c["energy_budget"].as_f64().unwrap_or(0.0),
charge: c["charge"].as_f64().unwrap_or(0.0),
},
)
})
.collect()
}
#[derive(Clone, Debug)]
pub struct BiomeInfo {
/// `natural_mob_spawns` by category (creature, monster, ...).
pub spawns: HashMap<String, Vec<SpawnEntry>>,
/// `natural_mob_spawns` spawn costs by entity type.
pub spawn_costs: HashMap<String, crate::mobs::spawn_selection::SpawnCost>,
pub spawn_costs: HashMap<String, SpawnCost>,
/// The `gameplay/creature_world_gen_spawn_probability` attribute.
pub creature_probability: f32,
/// The `gameplay/increased_fire_burnout` attribute (jungles, swamps, peaks,
@@ -150,12 +206,12 @@ impl Biomes {
};
let categories = &natural["spawns_by_category"];
for (category, entries) in categories.as_object().into_iter().flatten() {
let list = crate::mobs::spawn_selection::parse_entries(entries);
let list = parse_entries(entries);
spawns.insert(category.clone(), list);
}
list.push(BiomeInfo {
spawns,
spawn_costs: crate::mobs::spawn_selection::parse_costs(&natural["spawn_costs"]),
spawn_costs: parse_costs(&natural["spawn_costs"]),
creature_probability:
attributes["minecraft:gameplay/creature_world_gen_spawn_probability"]
.as_f64()
@@ -26,6 +26,7 @@
use crate::chunk::block_entity::BlockEntity;
use crate::fast_hash::{FxHashMap, FxHashSet};
use std::collections::HashMap;
use std::sync::Arc;
pub type Pos = (i32, i32, i32);
@@ -46,7 +47,7 @@ pub enum Ticking {
pub struct BlockEntities {
map: HashMap<Pos, BlockEntity>,
/// `Level.blockEntityTickers`: positions and the slot each was given.
tickers: Vec<(Pos, u32)>,
tickers: Arc<Vec<(Pos, u32)>>,
/// `LevelChunk.tickersInLevel`: the live slot of each ticking position.
live: FxHashMap<Pos, u32>,
/// Positions that may need a slot, in the order vanilla would have made
@@ -233,7 +234,7 @@ impl BlockEntities {
}
pub fn clear(&mut self) {
self.map.clear();
self.tickers.clear();
self.tickers = Arc::default();
self.live.clear();
self.added.clear();
self.pending.clear();
@@ -274,7 +275,7 @@ impl BlockEntities {
/// and freed ones leave it. `state` reads the block state at a position.
/// Returns the slots to tick, in order; check each with `is_live` before
/// ticking it, as the ticks before it can free it.
pub fn tickers(&mut self, state: impl Fn(Pos) -> u32) -> Vec<(Pos, u32)> {
pub fn tickers(&mut self, state: impl Fn(Pos) -> u32) -> Arc<Vec<(Pos, u32)>> {
for (pos, token) in std::mem::take(&mut self.added) {
if self.pending.get(&pos) != Some(&token) {
continue;
@@ -288,21 +289,21 @@ impl BlockEntities {
}
let slot = self.next();
self.live.insert(pos, slot);
self.tickers.push((pos, slot));
Arc::make_mut(&mut self.tickers).push((pos, slot));
}
if !std::mem::take(&mut self.freed) && self.out.is_empty() {
return self.tickers.clone();
return Arc::clone(&self.tickers);
}
self.out.clear();
let (live, map) = (&mut self.live, &self.map);
self.tickers.retain(|(p, slot)| {
Arc::make_mut(&mut self.tickers).retain(|(p, slot)| {
let mine = live.get(p) == Some(slot);
if mine && !map.contains_key(p) {
live.remove(p);
}
mine && map.contains_key(p)
});
self.tickers.clone()
Arc::clone(&self.tickers)
}
/// Whether a slot from `tickers` still ticks.
pub fn is_live(&self, (pos, slot): (Pos, u32)) -> bool {
@@ -329,8 +330,8 @@ mod tests {
}
fn order(b: &mut BlockEntities, states: &HashMap<Pos, u32>) -> Vec<Pos> {
b.tickers(|p| states.get(&p).copied().unwrap_or(0))
.into_iter()
.map(|(p, _)| p)
.iter()
.map(|(p, _)| *p)
.collect()
}
@@ -435,7 +435,8 @@ pub struct BellTick {
pub ended: Option<(Vec<([f64; 3], i32)>, Vec<u64>)>,
}
fn closer_to_center_than(pos: [i32; 3], p: [f64; 3], distance: f64) -> bool {
/// `Vec3i.closerToCenterThan`: `p` is within `distance` of the block's centre.
pub fn closer_to_center_than(pos: [i32; 3], p: [f64; 3], distance: f64) -> bool {
let dx = f64::from(pos[0]) + 0.5 - p[0];
let dy = f64::from(pos[1]) + 0.5 - p[1];
let dz = f64::from(pos[2]) + 0.5 - p[2];
@@ -597,6 +597,18 @@ fn first_line(line: &str, max_width: f32, advance: &dyn Fn(char) -> f32) -> Stri
line.to_owned()
}
/// `AbstractSignRenderer.isOutlineVisible`: the camera within 16 blocks of
/// the sign's centre.
fn sign_text_near(pos: [i32; 3], camera: DVec3) -> bool {
DVec3::new(
f64::from(pos[0]) + 0.5,
f64::from(pos[1]) + 0.5,
f64::from(pos[2]) + 0.5,
)
.distance_squared(camera)
< 256.0
}
/// `AbstractSignRenderer.submitSignText`: four centred lines, glowing text
/// full-bright with an 8x outline in the dark colour.
fn sign_text_calls(
@@ -642,10 +654,20 @@ fn sign_text_calls(
.collect()
}
/// How far the text of an outlined call sits in front of its outline, in
/// text units (`Font.DisplayMode.POLYGON_OFFSET`, see `draw_text`).
const TEXT_OVER_OUTLINE: f32 = 0.05;
/// Draws a text call with the font atlas: 8x8 glyph cells, the outline as
/// eight one-pixel offsets under the text.
///
/// `TextFeatureRenderer.renderText` draws the outline glyphs NORMAL and the
/// text over them POLYGON_OFFSET, so the coplanar text always wins the depth
/// test. Here both share one mesh, so the text is lifted a hair towards the
/// reader instead (a twentieth of a font pixel, invisible) rather than
/// z-fighting with the outline under it.
fn draw_text(scene: &Scene, call: &TextCall, out: &mut Vec<Vertex>) {
let mut emit = |dx: f32, dy: f32, argb: u32| {
let mut emit = |dx: f32, dy: f32, z: f32, argb: u32| {
let mut x = call.x + dx;
let y0 = call.y + dy;
for ch in call.text.chars() {
@@ -660,7 +682,7 @@ fn draw_text(scene: &Scene, call: &TextCall, out: &mut Vec<Vertex>) {
normal: [0; 4],
pos: call
.matrix
.transform_point3(Vec3::new(cx, cy, 0.0))
.transform_point3(Vec3::new(cx, cy, z))
.to_array(),
uv,
layer: 0,
@@ -685,10 +707,15 @@ fn draw_text(scene: &Scene, call: &TextCall, out: &mut Vec<Vertex>) {
(0.0, 1.0),
(1.0, 1.0),
] {
emit(dx, dy, call.outline);
emit(dx, dy, 0.0, call.outline);
}
}
emit(0.0, 0.0, call.color);
let z = if call.outline != 0 {
TEXT_OVER_OUTLINE
} else {
0.0
};
emit(0.0, 0.0, z, call.color);
}
/// Block-entity fields the level ticks, at one position (vanilla's own field
@@ -757,6 +784,9 @@ pub struct Scene<'a> {
pub skull: &'a dyn Fn([i32; 3]) -> Option<String>,
/// Font glyph cell (none for blank glyphs such as the space) and advance.
pub glyph: &'a dyn Fn(char) -> (Option<Rect>, f32),
/// Changes whenever `glyph` may answer differently (`Font::epoch`): sign
/// text laid out under another value is laid out again.
pub glyphs: u64,
}
pub struct BlockEntityRenderer {
@@ -768,7 +798,30 @@ pub struct BlockEntityRenderer {
pot_patterns: HashMap<String, String>,
/// `Block.shouldRenderFace` for End gateway faces (its occlusion pair cache needs a lock).
visibility: Option<Mutex<FaceVisibility>>,
sign_texts: Mutex<SignTexts>,
}
/// Laid-out sign text by position. `AbstractSignRenderer` lays the text out
/// again each frame, glyph by glyph; its vertices only change with what is
/// in the key, so they are kept relative to the block and moved to the camera.
#[derive(Default)]
struct SignTexts {
signs: crate::fast_hash::FxHashMap<[i32; 3], SignTextEntry>,
/// Game time of the last sweep of signs out of view.
swept: i64,
}
struct SignTextEntry {
state: u32,
data: crate::signs::SignData,
packed: u8,
/// Within the glowing text outline's distance (only kept for glowing text).
near: bool,
glyphs: u64,
/// Game time it was last drawn.
used: i64,
vertices: Vec<Vertex>,
}
/// Game ticks a sign's text is kept after it was last drawn.
const SIGN_TEXT_KEEP: i64 = 100;
const MODELS: [(&str, &str); 27] = [
("chest", "minecraft:chest#main"),
("double_chest_left", "minecraft:double_chest_left#main"),
@@ -898,6 +951,7 @@ impl BlockEntityRenderer {
closed_chests,
pot_patterns: HashMap::new(),
visibility: None,
sign_texts: Mutex::default(),
}
}
fn kind(&self, state: u32) -> &Kind {
@@ -932,11 +986,81 @@ impl BlockEntityRenderer {
let origin = (DVec3::new(f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2]))
- scene.camera)
.as_vec3();
let base = Mat4::from_translation(origin);
let packed = scene.world.light(pos[0], pos[1], pos[2]);
if matches!(kind, Kind::Sign { .. }) && scene.texts.is_none() {
self.sign_text(scene, *pos, *state, origin, packed, out);
continue;
}
let base = Mat4::from_translation(origin);
self.render_one(scene, *pos, kind, base, packed, lights, false, true, out);
}
}
/// A sign's text from the cache, laid out again when its data, light,
/// state, outline or font changed.
fn sign_text(
&self,
scene: &Scene,
pos: [i32; 3],
state: u32,
origin: Vec3,
packed: u8,
out: &mut Vec<Vertex>,
) {
let Some(data) = (scene.sign)(pos) else {
return;
};
let near = (data.front.glowing || data.back.glowing) && sign_text_near(pos, scene.camera);
let Ok(mut cache) = self.sign_texts.lock() else {
return;
};
let now = scene.game_time;
if (now - cache.swept).abs() >= SIGN_TEXT_KEEP {
cache.swept = now;
cache
.signs
.retain(|_, e| (now - SIGN_TEXT_KEEP..=now).contains(&e.used));
}
let glyphs = scene.glyphs;
let signs = &mut cache.signs;
if !signs.get(&pos).is_some_and(|e| {
(e.state, e.packed, e.near, e.glyphs) == (state, packed, near, glyphs)
&& e.data == *data
}) {
let mut vertices = signs.remove(&pos).map_or_else(Vec::new, |e| e.vertices);
vertices.clear();
self.render_one(
scene,
pos,
self.kind(state),
Mat4::IDENTITY,
packed,
diffuse_lights(scene.nether),
false,
true,
&mut vertices,
);
signs.insert(
pos,
SignTextEntry {
state,
data: data.clone(),
packed,
near,
glyphs,
used: now,
vertices,
},
);
}
let Some(entry) = signs.get_mut(&pos) else {
return;
};
entry.used = now;
out.extend(entry.vertices.iter().map(|v| Vertex {
pos: (Vec3::from_array(v.pos) + origin).to_array(),
..*v
}));
}
/// One block entity at `pos` drawn under `base` with the given packed light
/// (the parity fixture's entry point).
pub fn render_block(
@@ -1168,13 +1292,7 @@ impl BlockEntityRenderer {
m *= Mat4::from_translation(Vec3::new(0.0, 1.0 / 3.0, 0.046_666_667))
* Mat4::from_scale(s * 0.010_416_667);
}
let near = DVec3::new(
f64::from(pos[0]) + 0.5,
f64::from(pos[1]) + 0.5,
f64::from(pos[2]) + 0.5,
)
.distance_squared(scene.camera)
< 256.0;
let near = sign_text_near(pos, scene.camera);
let calls = sign_text_calls(scene, data, text, m, packed, near);
match scene.texts {
Some(sink) => sink.borrow_mut().extend(calls),
@@ -1954,6 +2072,7 @@ mod tests {
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyphs: 0,
};
// Compare the cached world path with the original model renderer,
// including atlas replacement, current light and animated fallback.
@@ -2154,6 +2273,191 @@ mod tests {
assert_eq!(out[2].uv, [0.5, 0.5]);
}
/// TextFeatureRenderer.renderText: an outlined call's text is drawn over
/// its outline (Font.DisplayMode.POLYGON_OFFSET), never level with it,
/// and plain text stays on the sign's plane.
#[test]
fn outlined_sign_text_sits_in_front_of_its_outline() {
let world = World::new(BlockStateId(0), 1);
let sprites = HashMap::new();
let glyph = |_: char| {
let rect = Rect {
u0: 0.0,
v0: 0.0,
u1: 0.0625,
v1: 0.0625,
w: 8,
h: 8,
};
(Some(rect), 6.0)
};
let scene = Scene {
camera: DVec3::ZERO,
orientation: Quat::IDENTITY,
anim: &|_| BlockAnim::default(),
texts: None,
partial: 0.0,
game_time: 0,
max_y: 320,
world: &world,
sprites: &sprites,
nether: false,
lid: &|_| 0.0,
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &glyph,
glyphs: 0,
};
let mut call = TextCall {
matrix: Mat4::IDENTITY,
x: 0.0,
y: 0.0,
text: "Hi".into(),
color: 0xFFFF_FF00,
outline: 0xFF33_3300,
light: 240 | 240 << 8,
};
let mut out = Vec::new();
draw_text(&scene, &call, &mut out);
// Eight outline copies, then the text, four corners a glyph.
assert_eq!(out.len(), 9 * 2 * 4);
let (outline, text) = out.split_at(8 * 2 * 4);
let front = outline.iter().map(|v| v.pos[2]).fold(f32::MIN, f32::max);
assert!(
text.iter().all(|v| v.pos[2] > front),
"text z {:?} not in front of the outline at {front}",
text.iter().map(|v| v.pos[2]).collect::<Vec<_>>()
);
call.outline = 0;
out.clear();
draw_text(&scene, &call, &mut out);
assert_eq!(out.len(), 2 * 4);
assert!(out.iter().all(|v| v.pos[2] == 0.0));
}
#[test]
fn cached_sign_text_matches_and_follows_changes() {
let renderer = BlockEntityRenderer {
models: HashMap::new(),
kinds: vec![
Kind::Sign {
hanging: false,
wall: false,
angle: 180.0,
},
Kind::Sign {
hanging: true,
wall: true,
angle: 90.0,
},
],
closed_chests: Default::default(),
pot_patterns: HashMap::new(),
visibility: None,
sign_texts: Mutex::default(),
};
let world = World::new(BlockStateId(0), 1);
let sprites = HashMap::new();
let text = |line: &str, glowing| crate::signs::SignText {
lines: [line.into(), "two".into(), String::new(), "four".into()],
color: 3,
glowing,
};
let first_text = crate::signs::SignData {
front: text("one", false),
..Default::default()
};
let changed = crate::signs::SignData {
front: text("changed", false),
..Default::default()
};
let glowing = crate::signs::SignData {
back: text("glow", true),
..changed.clone()
};
let wide = std::cell::Cell::new(6.0);
let glyph = |c: char| {
let u = f32::from(c as u8 % 16) / 16.0;
let rect = Rect {
u0: u,
v0: 0.0,
u1: u + 0.0625,
v1: 0.0625,
w: 8,
h: 8,
};
((c != ' ').then_some(rect), wide.get())
};
let (first_sign, changed_sign, glowing_sign) = (
|_| Some(&first_text),
|_| Some(&changed),
|_| Some(&glowing),
);
let mut scene = Scene {
camera: DVec3::new(3.25, 70.5, -4.75),
orientation: Quat::IDENTITY,
anim: &|_| BlockAnim::default(),
texts: None,
partial: 0.0,
game_time: 0,
max_y: 320,
world: &world,
sprites: &sprites,
nether: false,
lid: &|_| 0.0,
sign: &first_sign,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &glyph,
glyphs: 0,
};
let check = |scene: &Scene, pos: [i32; 3], state: u32| {
let mut cached = Vec::new();
renderer.render(scene, [(pos, state)].iter(), &mut cached);
let corner = DVec3::new(f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2]));
let mut original = Vec::new();
renderer.render_one(
scene,
pos,
renderer.kind(state),
Mat4::from_translation((corner - scene.camera).as_vec3()),
world.light(pos[0], pos[1], pos[2]),
diffuse_lights(false),
false,
true,
&mut original,
);
assert!(!cached.is_empty());
assert_eq!(cached.len(), original.len());
for (a, b) in cached.iter().zip(&original) {
assert!((Vec3::from_array(a.pos) - Vec3::from_array(b.pos)).length() < 1e-5);
assert_eq!((a.uv, a.color, a.light), (b.uv, b.color, b.light));
}
cached.len()
};
let (pos, other) = ([1, 70, 2], [-3, 64, 9]);
let first = check(&scene, pos, 0);
// The camera moves: the same text, moved.
scene.camera.x += 0.5;
check(&scene, pos, 0);
// New text, glowing text near and far, another state, other glyphs.
scene.sign = &changed_sign;
assert_ne!(check(&scene, pos, 0), first);
scene.sign = &glowing_sign;
check(&scene, pos, 0);
scene.camera.z -= 20.0;
check(&scene, pos, 0);
check(&scene, pos, 1);
check(&scene, other, 1);
wide.set(5.0);
scene.glyphs = 1;
check(&scene, pos, 1);
// Signs out of view for a while are dropped.
scene.game_time = 2 * SIGN_TEXT_KEEP;
check(&scene, pos, 0);
assert_eq!(renderer.sign_texts.lock().unwrap().signs.len(), 1);
}
#[test]
fn first_line_wraps_on_bytes_without_splitting_a_char() {
let advance = |c: char| if c == ' ' { 4.0 } else { 6.0 };
@@ -2214,6 +2518,7 @@ mod tests {
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyphs: 0,
};
let mut verts = Vec::new();
renderer.render_block(&scene, [0; 3], state.0, Mat4::IDENTITY, 0xff, &mut verts);
@@ -14,14 +14,19 @@ pub use horizon_client_core::potions::{
BASE_POTION_COLOR, Category, EFFECTS, EffectDef, POTIONS, PotionDef, effect, namespaced,
potion, potion_color,
};
/// `MobEffectUtil.formatDuration` at 20 TPS: m:ss or h:mm:ss.
/// `MobEffectUtil.formatDuration` at 20 TPS.
pub fn format_duration(ticks: i32) -> String {
let secs = ticks / 20;
format_tick_duration(ticks, 20.0)
}
/// `StringUtil.formatTickDuration`: mm:ss, or hh:mm:ss from an hour up, every
/// field two digits.
pub fn format_tick_duration(ticks: i32, tick_rate: f32) -> String {
let secs = (ticks as f32 / tick_rate).floor() as i32;
let (h, m, s) = (secs / 3600, secs / 60 % 60, secs % 60);
if h > 0 {
format!("{h}:{m:02}:{s:02}")
format!("{h:02}:{m:02}:{s:02}")
} else {
format!("{m}:{s:02}")
format!("{m:02}:{s:02}")
}
}
/// `item.minecraft.potion.effect.<name>` style translation key for a potion item.
@@ -317,10 +322,14 @@ mod tests {
potion_name_key("minecraft:splash_potion", Some("minecraft:long_swiftness")),
"item.minecraft.splash_potion.effect.swiftness"
);
assert_eq!(format_duration(9600), "8:00");
assert_eq!(format_duration(450), "0:22");
assert_eq!(format_duration(9600), "08:00");
assert_eq!(format_duration(450), "00:22");
assert_eq!(format_duration(140), "00:07");
assert_eq!(format_duration(1800), "01:30");
assert_eq!(format_duration(72000), "01:00:00");
assert_eq!(format_tick_duration(9600, 40.0), "04:00");
let t = tooltip(Some("minecraft:strong_poison"), 1.0, &|_| None);
assert_eq!(t[0].0, "poison II (0:21)");
assert_eq!(t[0].0, "poison II (00:21)");
assert_eq!(POTIONS.len(), 46);
}
@@ -377,4 +386,23 @@ mod tests {
stand.tick(&brewing, &items, &providers);
assert_eq!(stand.brew_time, 0);
}
/// #3312: `PotionContents.getPotionDescription` names the effects with
/// translatable components, so a Russian game reads them from ru_ru.json
/// (the prepared one; skipped without it), as the tooltip's other lines.
#[test]
fn effect_lines_read_the_selected_language() {
let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("assets/minecraft/lang/ru_ru.json").exists() {
return;
}
let ru = crate::lang::Language::load(&[&root], "ru_ru");
let translate = |key: &str| ru.get(key).map(str::to_owned);
let lines = tooltip(Some("minecraft:turtle_master"), 0.125, &translate);
assert_eq!(lines.len(), 2);
assert!(lines[0].0.starts_with("Замедление IV ("), "{lines:?}");
assert!(lines[1].0.starts_with("Сопротивление III ("), "{lines:?}");
}
}
@@ -653,18 +653,57 @@ pub fn modify_fov_based_on_death_or_fluid(
fov
}
/// `ClientAvatarState`: the walked distance and view bob of a player.
/// `ClientAvatarState`: the walked distance, view bob and cloak of a player.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct AvatarBob {
pub walk_dist: f32,
pub walk_dist_o: f32,
pub bob: f32,
pub bob_o: f32,
/// `xCloak`, `yCloak`, `zCloak`: a point trailing the player, which the cape leans from.
pub cloak: [f64; 3],
pub cloak_o: [f64; 3],
}
impl AvatarBob {
/// `ClientAvatarState.tick` (at the start of `AbstractClientPlayer.tick`).
pub fn tick(&mut self) {
/// `ClientAvatarState.tick` (at the start of `AbstractClientPlayer.tick`)
/// with the player's position then: `moveCloak` eases the cloak a quarter
/// of the way there, or puts it there from 10 blocks away.
pub fn tick(&mut self, position: [f64; 3]) {
self.walk_dist_o = self.walk_dist;
self.cloak_o = self.cloak;
for i in 0..3 {
let d = position[i] - self.cloak[i];
if d > 10.0 || d < -10.0 {
self.cloak[i] = position[i];
self.cloak_o[i] = position[i];
} else {
self.cloak[i] += d * 0.25;
}
}
}
/// `AvatarRenderer.extractCapeState`: the cape's `capeFlap`, `capeLean`
/// and `capeLean2` (degrees) for the player at `position` (interpolated)
/// with its body turned `body_yaw` and its `fallFlyingScale`.
pub fn cape(
&self,
partial: f32,
position: [f64; 3],
body_yaw: f32,
fall_flying_scale: f32,
) -> [f32; 3] {
let d: [f64; 3] = std::array::from_fn(|i| {
lerp_d(f64::from(partial), self.cloak_o[i], self.cloak[i]) - position[i]
});
let rad = f64::from(body_yaw * DEG_TO_RAD);
let (sin, cos) = (f64::from(trig::sin(rad)), f64::from(-trig::cos(rad)));
let mut flap = (d[1] as f32 * 10.0).clamp(-6.0, 32.0);
let lean = ((d[0] * sin + d[2] * cos) as f32 * 100.0 * (1.0 - fall_flying_scale))
.clamp(0.0, 150.0);
let lean2 = ((d[0] * cos - d[2] * sin) as f32 * 100.0).clamp(-20.0, 20.0);
let bob = self.interpolated_bob(partial);
let walk = lerp(partial, self.walk_dist_o, self.walk_dist);
flap += trig::sin(f64::from(walk * 6.0)) * 32.0 * bob;
[flap, lean, lean2]
}
/// `LocalPlayer.move`: `addWalkedDistance(Mth.length(dx, dz) * 0.6F)`
/// with the float horizontal displacement.
@@ -795,6 +834,23 @@ pub fn spinning_effect_matrix(intensity: f32, angle_degrees: f32) -> glam::Mat4
#[cfg(test)]
mod tests {
/// `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]
fn the_cloak_trails_the_player() {
let mut a = super::AvatarBob::default();
a.tick([0.0, 64.0, 0.0]);
assert_eq!((a.cloak, a.cloak_o), ([0.0, 64.0, 0.0], [0.0, 64.0, 0.0]));
a.tick([0.0, 64.0, 1.0]);
assert_eq!(a.cloak, [0.0, 64.0, 0.25]);
let [flap, lean, lean2] = a.cape(1.0, [0.0, 64.0, 1.0], 0.0, 0.0);
assert_eq!((flap, lean, lean2), (0.0, 75.0, 0.0));
// Falling lifts the flap, at most 32 degrees.
assert_eq!(a.cape(1.0, [0.0, 60.0, 0.25], 0.0, 0.0)[0], 32.0);
// Gliding takes the lean away.
assert_eq!(a.cape(1.0, [0.0, 64.0, 1.0], 0.0, 1.0)[1], 0.0);
}
#[test]
fn depth_far_covers_the_cloud_range() {
// renderDistance 2 with the default Cloud Distance (128 chunks).
File renamed without changes.
File renamed without changes.
@@ -230,7 +230,7 @@ pub struct Info {
/// `VibrationSelector`: the closest, then highest-frequency candidate of a tick.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Selector {
pub(crate) current: Option<(Info, i64)>,
pub current: Option<(Info, i64)>,
}
/// `VibrationSystem.Data`.
#[derive(Clone, Debug, Default, PartialEq)]
@@ -278,7 +278,7 @@ pub struct SpawnData {
/// Which `SpawnData` is next: one of the potentials (the same object in
/// vanilla, so changing it changes the potential) or its own.
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum Next {
pub enum Next {
Potential(usize),
Own(SpawnData),
}
@@ -288,7 +288,7 @@ pub struct Spawner {
pub spawn_delay: i32,
/// `SpawnPotentials`: data and weight.
pub potentials: Vec<(SpawnData, i32)>,
pub(crate) next: Option<Next>,
pub next: Option<Next>,
pub min_spawn_delay: i32,
pub max_spawn_delay: i32,
pub spawn_count: i32,
@@ -301,6 +301,24 @@ pub struct Spawner {
pub spin: f64,
pub o_spin: f64,
}
impl Default for Spawner {
fn default() -> Self {
Self {
spawn_delay: 20,
potentials: Vec::new(),
next: None,
min_spawn_delay: 200,
max_spawn_delay: 800,
spawn_count: 4,
max_nearby_entities: 6,
required_player_range: 16,
spawn_range: 4,
client_delay: 20,
spin: 0.0,
o_spin: 0.0,
}
}
}
/// `TrialSpawnerConfig`.
#[derive(Clone, Debug, PartialEq)]
pub struct TrialConfig {
@@ -314,6 +332,26 @@ pub struct TrialConfig {
pub loot_tables_to_eject: Vec<(String, i32)>,
pub items_to_drop_when_ominous: String,
}
impl Default for TrialConfig {
/// `TrialSpawnerConfig.Builder`'s defaults.
fn default() -> Self {
Self {
spawn_range: 4,
total_mobs: 6.0,
simultaneous_mobs: 2.0,
total_mobs_added_per_player: 2.0,
simultaneous_mobs_added_per_player: 1.0,
ticks_between_spawn: 40,
spawn_potentials: Vec::new(),
loot_tables_to_eject: vec![
("minecraft:spawners/trial_chamber/consumables".into(), 1),
("minecraft:spawners/trial_chamber/key".into(), 1),
],
items_to_drop_when_ominous:
"minecraft:spawners/trial_chamber/items_to_drop_when_ominous".into(),
}
}
}
/// A `Holder<TrialSpawnerConfig>` as saved: a registry id
/// (`minecraft:trial_chamber/breeze/normal`) or an inline config.
#[derive(Clone, Debug, Default, PartialEq)]
@@ -347,6 +385,28 @@ pub struct TrialSpawner {
/// and their weights (the rolled counts).
pub dispensing: Option<Vec<ItemStack>>,
}
impl Default for TrialSpawner {
fn default() -> Self {
Self {
normal: ConfigSlot::default(),
ominous: ConfigSlot::default(),
target_cooldown_length: 36000,
required_player_range: 14,
detected_players: Vec::new(),
current_mobs: Vec::new(),
cooldown_ends_at: 0,
next_mob_spawns_at: 0,
total_mobs_spawned: 0,
next_spawn_data: None,
ejecting_loot_table: None,
is_ominous: false,
has_display: false,
spin: 0.0,
o_spin: 0.0,
dispensing: None,
}
}
}
/// `VaultConfig`.
#[derive(Clone, Debug, PartialEq)]
pub struct VaultConfig {
@@ -5,11 +5,8 @@ pub use crate::chunk::block_entity::{
Banner, Beacon, BlockEntity, Campfire, ChiseledBookshelf, Crafter, CreakingHeart, EndGateway,
Furnace, FurnaceKind, Jukebox, Lectern, Skull, SkullSkin,
};
use crate::{
crafting::Recipes,
items::{ItemStack, Items},
};
use std::{collections::HashMap, sync::Arc};
use crate::items::ItemStack;
use std::collections::HashMap;
impl FurnaceKind {
pub fn recipe_type(self) -> &'static str {
@@ -45,6 +42,45 @@ pub fn recipe_used_key(id: &str) -> String {
/// A hopper's slots: the only plain container with five.
pub const HOPPER_SLOTS: usize = 5;
/// `getDefaultName` of the `BaseContainerBlockEntity` that `block` holds: the
/// translation key of its menu's title when it has no custom name. Copper
/// chests hold a `ChestBlockEntity`, shulker boxes of every colour a
/// `ShulkerBoxBlockEntity`.
pub fn default_name_key(block: &str) -> Option<&'static str> {
Some(match block {
"minecraft:chest" | "minecraft:trapped_chest" => "container.chest",
n if n.ends_with("copper_chest") => "container.chest",
"minecraft:barrel" => "container.barrel",
n if n.ends_with("shulker_box") => "container.shulkerBox",
"minecraft:dispenser" => "container.dispenser",
"minecraft:dropper" => "container.dropper",
"minecraft:hopper" => "container.hopper",
"minecraft:furnace" => "container.furnace",
"minecraft:smoker" => "container.smoker",
"minecraft:blast_furnace" => "container.blast_furnace",
"minecraft:brewing_stand" => "container.brewing",
"minecraft:crafter" => "container.crafter",
_ => return None,
})
}
/// `BaseContainerBlockEntity.getDisplayName` (`getName`), the title of the
/// `ClientboundOpenScreenPacket` that opens it: the block entity's custom
/// name (`CustomName`, a literal), else its default name.
pub fn display_name(custom: Option<&str>, default_key: &str) -> horizon_world::nbt::Tag {
match custom {
Some(name) => horizon_protocol::text::literal(name),
None => horizon_protocol::text::translatable(default_key, &[]),
}
}
/// `ChestBlock.MENU_PROVIDER_COMBINER`'s `getDisplayName` for a double chest:
/// the first half's custom name (the right half, `DoubleBlockCombiner`'s
/// `FIRST`), else the second's, else `container.chestDouble`.
pub fn double_chest_name(first: Option<&str>, second: Option<&str>) -> horizon_world::nbt::Tag {
display_name(first.or(second), "container.chestDouble")
}
impl Campfire {
/// `placeFood`'s slot: the first empty one, given the item's
/// `campfire_cooking` time; it takes one item of `held`.
@@ -380,165 +416,56 @@ impl BlockEntity {
}
}
pub use crate::providers::Providers;
fn remainder(item: &str) -> Option<&'static str> {
match item {
"minecraft:lava_bucket" | "minecraft:water_bucket" | "minecraft:milk_bucket" => {
Some("minecraft:bucket")
}
_ => None,
}
/// A `ChestBlock`, which `ChestBlock.combine` can join into a double chest:
/// chests, trapped chests and copper chests in every weathering state, waxed
/// or not (`CopperChestBlock` extends `ChestBlock`). Ender chests are
/// `EnderChestBlock`s and always single.
pub fn is_chest_block(name: &str) -> bool {
matches!(name, "minecraft:chest" | "minecraft:trapped_chest") || name.ends_with("copper_chest")
}
impl Furnace {
/// `AbstractFurnaceBlockEntity.setItem` on the input slot: anything but
/// the same item with the same components restarts cooking, with the
/// total time of the new input's recipe (200 ticks without one).
pub fn set_input(&mut self, recipes: &Recipes, stack: Option<ItemStack>) {
let same = match (&self.items[0], &stack) {
(Some(old), Some(new)) => old.same_item_same_components(new),
_ => false,
/// A chest block's name with its `type` and `facing` properties.
#[derive(Clone, Copy, Debug)]
pub struct ChestHalf<'a> {
pub block: &'a str,
pub kind: &'a str,
pub facing: &'a str,
}
impl ChestHalf<'_> {
/// `ChestBlock.getConnectedDirection` as an (x, z) step to the other
/// half: clockwise from the facing for a left half, counter-clockwise for
/// a right one. None for a single chest.
pub fn connected_step(&self) -> Option<(i32, i32)> {
let step = |facing: &str| match facing {
"north" => Some((0, -1)),
"south" => Some((0, 1)),
"west" => Some((-1, 0)),
"east" => Some((1, 0)),
_ => None,
};
self.items[0] = stack;
if !same {
self.cooking_total = self.items[0]
.as_ref()
.and_then(|input| recipes.cook(self.kind.recipe_type(), input))
.map_or(200, |recipe| total_time(recipe.time, self.speed));
self.cooking_timer = 0;
let (x, z) = step(self.facing)?;
// Turning clockwise seen from above takes (x, z) to (-z, x).
match self.kind {
"left" => Some((-z, x)),
"right" => Some((z, -x)),
_ => None,
}
}
/// `recipesUsed`, each recipe with its `experience` (0 for one this data pack does
/// not have), in the order `getRecipesToAwardAndPopExperience` pays them.
pub fn recipe_experience(&self, recipes: &Recipes) -> Vec<(u32, f32)> {
self.recipes_used
.iter()
.map(|(id, count)| (*count, recipes.cooking_experience(id)))
.collect()
}
/// A menu click wrote its slots into `items`, and `before` was the input
/// slot until then: the menu called `setItem` on it only if it changed
/// (`Slot.setByPlayer`; partial takes and merges keep the stack).
pub fn input_clicked(&mut self, recipes: &Recipes, before: Option<ItemStack>) {
if self.items[0] != before {
let after = std::mem::replace(&mut self.items[0], before);
self.set_input(recipes, after);
}
}
/// One server tick. Returns `Some(lit)` when the LIT block state must change.
pub fn tick(
&mut self,
recipes: &Recipes,
items: &Items,
providers: &Providers,
) -> Option<bool> {
let was_lit = self.lit_remaining > 0;
let mut lit = false;
if self.lit_remaining > 0 {
self.lit_remaining -= 1;
lit = self.lit_remaining > 0;
}
let fast = self.kind.fast();
let has_ingredient = self.items[0].is_some();
let has_fuel = self.items[1].is_some();
if lit || has_fuel && has_ingredient {
if let Some(ingredient) = self.items[0].clone() {
if let Some(recipe) = recipes.cook(self.kind.recipe_type(), &ingredient) {
if self.cooking_timer == 0 {
self.cooking_total = total_time(recipe.time, self.speed);
}
let max = items.get(&recipe.result.item).map_or(64, |i| i.max_stack);
let can_burn = match &self.items[2] {
None => true,
Some(out) => {
out.item == recipe.result.item && out.count + recipe.result.count <= max
}
};
if can_burn {
if !lit {
let fuel = self.items[1].clone();
let time = fuel
.as_ref()
.map_or(0, |f| providers.burn_time(items, f, fast));
let speed = fuel
.as_ref()
.map_or(1.0, |f| providers.speed(items, f, fast));
self.lit_remaining = time;
self.lit_total = time;
self.speed = speed;
if self.cooking_total > 0 && self.cooking_timer < self.cooking_total {
let ratio = self.cooking_timer as f32 / self.cooking_total as f32;
self.cooking_total = total_time(recipe.time, self.speed);
self.cooking_timer =
(ratio * self.cooking_total as f32).ceil() as i32;
}
if time > 0 {
if let Some(f) = self.items[1].as_mut() {
let item = f.item.clone();
f.count -= 1;
if f.count == 0 {
self.items[1] = remainder(&item).map(|r| ItemStack {
item: Arc::from(r),
count: 1,
damage: 0,
extra: None,
});
}
}
lit = true;
}
}
if lit {
self.cooking_timer += 1;
if self.cooking_timer >= self.cooking_total {
self.cooking_timer = 0;
self.cooking_total = total_time(recipe.time, self.speed);
match &mut self.items[2] {
Some(out) => out.count += recipe.result.count,
None => self.items[2] = Some(recipe.result.clone()),
}
*self
.recipes_used
.entry(recipe_used_key(&recipe.id))
.or_insert(0) += 1;
// AbstractFurnaceBlockEntity.burn: drying a wet sponge fills
// the bucket in the fuel slot.
if self.items[0]
.as_ref()
.is_some_and(|i| &*i.item == "minecraft:wet_sponge")
&& self.items[1]
.as_ref()
.is_some_and(|f| &*f.item == "minecraft:bucket")
{
self.items[1] =
Some(ItemStack::new("minecraft:water_bucket", 1));
}
if let Some(i) = self.items[0].as_mut() {
i.count -= 1;
if i.count == 0 {
self.items[0] = None;
}
}
}
} else {
self.cooking_timer = 0;
}
} else {
self.cooking_timer = 0;
}
}
} else {
self.cooking_timer = 0;
}
} else if self.cooking_timer > 0 {
self.cooking_timer = (self.cooking_timer - 2).clamp(0, self.cooking_total);
}
(was_lit != lit).then_some(lit)
}
}
fn total_time(time: u32, speed: f32) -> i32 {
if speed > 0.0 {
(time as f32 / speed).ceil() as i32
} else {
time as i32
/// `DoubleBlockCombiner.combineWithNeigbour` as `ChestBlock.combine` calls
/// it: `other`, the block in this half's connected direction, is its other
/// half when it is the same block, a half of the other side and faces the
/// same way. Copper chests in different weathering states never join
/// (`CopperChestBlock` keeps both halves in one state instead).
pub fn pairs_with(&self, other: &ChestHalf) -> bool {
is_chest_block(self.block)
&& self.kind != "single"
&& other.block == self.block
&& other.kind != "single"
&& other.kind != self.kind
&& other.facing == self.facing
}
}
@@ -326,16 +326,18 @@ fn panic_message(info: &std::panic::PanicHookInfo) -> String {
/// frames go. Paths inside the workspace lose the build machine's prefix.
pub fn stack_lines(backtrace: &str, from_panic: bool) -> String {
// The workspace as it was on the machine that built this binary (it need not exist here).
let manifest = env!("CARGO_MANIFEST_DIR");
// A report names workspace files the same way on every host, whatever
// separator the build machine used.
let manifest = env!("CARGO_MANIFEST_DIR").replace('\\', "/");
let root = manifest
.strip_suffix("crates/horizon-client")
.unwrap_or(manifest);
.strip_suffix("crates/horizon-client-render")
.unwrap_or(&manifest);
let mut frames: Vec<(String, Option<String>)> = Vec::new();
for line in backtrace.lines() {
let line = line.trim();
if let Some(at) = line.strip_prefix("at ") {
if let Some(frame) = frames.last_mut() {
frame.1 = Some(short_path(at, root).to_owned());
frame.1 = Some(short_path(&at.replace('\\', "/"), root).to_owned());
}
} else if let Some((n, name)) = line.split_once(": ")
&& n.chars().all(|c| c.is_ascii_digit())
@@ -603,7 +605,7 @@ mod tests {
);
assert_eq!(
stack_lines(&bt, true),
"horizon_client::crash::debug_crash_tick (crates/horizon-client/src/crash.rs:359:9)\nwinit::dispatch (winit-0.30.13/src/event_loop.rs:642:56)\nmain\n"
"horizon_client::crash::debug_crash_tick (crates/horizon-client-render/src/crash.rs:359:9)\nwinit::dispatch (winit-0.30.13/src/event_loop.rs:642:56)\nmain\n"
);
}
@@ -22,6 +22,7 @@ struct Globals {
outline_params: vec4<f32>,
// Horizon Extras' dynamic lights: camera-relative position and level (dynamic_light.rs).
dynamic_lights: array<vec4<f32>, 16>,
sunrise_fan: array<vec4<f32>, 32>,
}
@group(0) @binding(0) var<uniform> globals: Globals;
@group(0) @binding(1) var atlas: texture_2d<f32>;
@@ -263,6 +264,16 @@ fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>,
// rendertype_outline.fsh: the vertex colour (the team colour; outline vertices carry no
// normal, so no cardinal light) wherever the texture is not fully clear.
// 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> {
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),
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w));
return vec4(mix(color.rgb, globals.fog_color.rgb, f * globals.fog_color.a), color.a);
}
@fragment fn fs_outline(in: Out) -> @location(0) vec4<f32> {
if textureSample(atlas, atlas_sampler, in.uv).a == 0.0 { discard; }
return vec4(in.color.rgb, 1.0);
@@ -203,6 +203,13 @@ pub struct FogInputs<'a> {
/// `BossHealthOverlay.shouldCreateWorldFog`: a boss bar with
/// `createWorldFog` is shown (the Ender Dragon's).
pub boss_world_fog: bool,
/// The camera entity is a spectator (`LavaFogEnvironment`).
pub spectator: bool,
/// The camera entity has Fire Resistance (`LavaFogEnvironment`).
pub fire_resistance: bool,
/// `GameRenderer.nightVisionScale` while Night Vision is on without
/// Darkness, else 0 (`FogRenderer.computeFogColor`'s brightenFactor).
pub night_vision: f32,
}
/// `FogRenderer.setupFog` + `computeFogColor`.
@@ -272,7 +279,11 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog
color[1] += (color[1] * 0.6 - color[1]) * t;
color[2] += (color[2] * 0.6 - color[2]) * t;
}
let brighten = if eye_in_water { water_vision } else { 0.0 };
let brighten = if eye_in_water {
water_vision
} else {
input.night_vision
};
if color.iter().all(|&c| c != 0.0) {
let scale = 1.0 / color[0].max(color[1]).max(color[2]);
color = color.map(|c| c + (c * scale - c) * brighten);
@@ -288,10 +299,16 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog
};
match input.fog_type {
FogType::Lava => {
fog.environmental_start = 0.25;
fog.environmental_end = 1.0;
fog.sky_end = 1.0;
fog.cloud_end = 1.0;
// LavaFogEnvironment.setupFog.
(fog.environmental_start, fog.environmental_end) = if input.spectator {
(-8.0, rd * 0.5)
} else if input.fire_resistance {
(0.0, 5.0)
} else {
(0.25, 1.0)
};
fog.sky_end = fog.environmental_end;
fog.cloud_end = fog.environmental_end;
}
FogType::PowderSnow => {
fog.environmental_start = 0.0;
@@ -362,6 +379,22 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog
}
fog
}
/// Horizon's Fog option turned off (`horizon-graphics.txt` `fog:false`):
/// no distance fog, in any dimension: the dimension's and biome's own fog
/// (`visual/fog_start_distance`, `fog_end_distance`: the Nether's ends at 96
/// blocks) and the render-distance fog. The fog of the fluid or powder snow
/// the camera is in, blindness, darkness and a boss's world fog keep their
/// limits. Vanilla has no such option.
pub fn clear_distance_fog(fog: &mut FogData, input: &FogInputs) {
if input.fog_type == FogType::Atmospheric
&& input.darkness.is_none()
&& !input.boss_world_fog
&& input.blindness.is_none()
{
(fog.render_distance_start, fog.render_distance_end) = (1e9, 2e9);
(fog.environmental_start, fog.environmental_end) = (1e9, 2e9);
}
}
/// `AtmosphericFogEnvironment.updateRainFogState`.
pub fn update_rain_fog(
state: &mut FogState,
@@ -405,6 +438,9 @@ mod tests {
darkness: None,
darken_world: 0.0,
boss_world_fog: false,
spectator: false,
fire_resistance: false,
night_vision: 0.0,
}
}
#[test]
@@ -432,6 +468,56 @@ mod tests {
assert_eq!(water.environmental_start, -8.0);
}
#[test]
fn fog_off_clears_the_nether_fog_too() {
// The Nether's visual/fog_start_distance and fog_end_distance (10, 96)
// end before the render distance; the Fog option still clears them.
let nether = Sky {
fog_start: 10.0,
fog_end: 96.0,
..Sky::default()
};
let tables = FogTables {
water_fog_end: vec![1.0],
};
let mut input = inputs(&nether);
input.skybox = false;
let mut fog = setup_fog(&tables, &FogState::default(), &input);
assert_eq!(fog.environmental_end, 96.0);
clear_distance_fog(&mut fog, &input);
assert!(fog.environmental_start >= 1e9 && fog.render_distance_start >= 1e9);
}
#[test]
fn fog_off_keeps_fluid_and_effect_fog() {
let sky = Sky {
fog_start: 10.0,
fog_end: 96.0,
..Sky::default()
};
let tables = FogTables {
water_fog_end: vec![1.0],
};
let kept = |input: &FogInputs| {
let mut fog = setup_fog(&tables, &FogState::default(), input);
let before = fog;
clear_distance_fog(&mut fog, input);
fog == before
};
for fog_type in [FogType::Lava, FogType::PowderSnow, FogType::Water] {
let mut input = inputs(&sky);
input.fog_type = fog_type;
assert!(kept(&input), "{fog_type:?}");
}
let mut input = inputs(&sky);
input.blindness = Some((100, false));
assert!(kept(&input));
let mut input = inputs(&sky);
input.darkness = Some(0.5);
assert!(kept(&input));
let mut input = inputs(&sky);
input.boss_world_fog = true;
assert!(kept(&input));
}
#[test]
fn boss_darkening_dims_fog_color() {
// FogRenderer.computeFogColor: lerp toward 70/60/60% of each channel.
let sky = Sky {
@@ -8,7 +8,7 @@
//! offsets), styled drawing and StringSplitter-style line wrapping.
use crate::{
hud::Rect,
text::{Style, Text},
text::{Span, Style, Text, legacy_format},
zip::Zip,
};
use ab_glyph::{Font as _, ScaleFont as _};
@@ -130,6 +130,8 @@ pub struct Font {
atlas: (u32, u32),
region: (u32, u32),
cache: RefCell<Cache>,
/// Set anew for each font and each `reset_cache` (see `epoch`).
generation: std::cell::Cell<u64>,
/// "Force Unicode Font": the `uniform` filter option.
pub uniform: bool,
/// "Japanese Glyph Variants": the `jp` filter option.
@@ -559,6 +561,11 @@ impl Region {
Some((ax, ay))
}
}
/// A value no font has had yet (`Font::epoch`).
fn next_generation() -> u64 {
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
}
impl Font {
/// Build from the atlas holding the bitmap font sheets (`sprites`, of
/// `atlas` size) with `region` the reserved rows.
@@ -603,6 +610,7 @@ impl Font {
.map_or(0, |d| d.as_nanos() as i64),
),
}),
generation: std::cell::Cell::new(next_generation()),
uniform: false,
japanese: false,
};
@@ -661,6 +669,13 @@ impl Font {
}
cache.region.cursor = (0, 0);
cache.region.row = 0;
self.generation.set(next_generation());
}
/// Changes whenever a glyph may come out different: another font, a
/// cache reset (glyphs move in the atlas) or other font options. Text laid
/// out under one epoch can be kept until it changes.
pub fn epoch(&self) -> u64 {
self.generation.get() << 2 | u64::from(self.uniform) << 1 | u64::from(self.japanese)
}
/// The glyph for a character in the default font (FontSet.getGlyph).
pub fn glyph(&self, c: char) -> Glyph {
@@ -951,6 +966,83 @@ impl Font {
}
out
}
/// `Font.split`: `StringSplitter.splitLines(text, width, Style.EMPTY)` over
/// the text's parts with their `§` codes in. A code styles what follows
/// (`§r` back to no style while measuring), and the part a line breaks in
/// goes on in the style at the break. Lines keep their codes for drawing.
pub fn split_formatted(&self, t: &Text, max_width: f32) -> Vec<Text> {
let max_width = max_width.max(1.0);
let mut parts: Vec<(Vec<char>, Style)> = t
.spans
.iter()
.filter(|s| !s.text.is_empty())
.map(|s| (s.text.chars().collect(), s.style.clone()))
.collect();
let mut flat: Vec<char> = parts.iter().flat_map(|p| p.0.iter().copied()).collect();
let mut out = Vec::new();
let mut force_new_line = false;
// One LineBreakFinder per line, over the parts left.
while let Some((at, split_style)) = self.line_break(&parts, max_width) {
let tail = flat[at];
let new_line = tail == '\n';
let skip = usize::from(new_line || tail == ' ');
force_new_line = new_line;
out.push(split_at(&mut parts, at, skip, &split_style));
flat.drain(..at + skip);
}
if !parts.is_empty() {
let mut rest = Text::default();
rest.spans = parts
.into_iter()
.map(|(text, style)| Span {
text: text.into_iter().collect(),
style,
})
.collect();
out.push(rest);
} else if force_new_line {
out.push(Text::empty());
}
out
}
/// `StringSplitter.LineBreakFinder` fed by `StringDecomposer.iterateFormatted`:
/// where the parts break (a character index) and the style there.
fn line_break(&self, parts: &[(Vec<char>, Style)], max_width: f32) -> Option<(usize, Style)> {
let (mut width, mut had_visible, mut last_space) = (0.0f32, false, None);
let mut offset = 0;
for (contents, part_style) in parts {
let mut style = part_style.clone();
let mut i = 0;
while i < contents.len() {
let c = contents[i];
if c == '§' {
// A `§` that ends its part ends only that part.
let Some(&code) = contents.get(i + 1) else {
break;
};
style = legacy_format(&style, code, &Style::default());
i += 2;
continue;
}
let at = offset + i;
if c == '\n' {
return Some((at, style));
}
if c == ' ' {
last_space = Some((at, style.clone()));
}
let advance = self.advance(c, &style);
width += advance;
if had_visible && width > max_width {
return Some(last_space.unwrap_or((at, style)));
}
had_visible |= advance != 0.0;
i += 1;
}
offset += contents.len();
}
None
}
/// `ComponentRenderUtils.wrapComponents`: the split lines, wrapped ones
/// indented by an unstyled space; one empty line for empty text.
pub fn wrap_components(&self, t: &Text, max_width: i32) -> Vec<Text> {
@@ -1044,6 +1136,47 @@ impl Font {
}
}
/// `StringSplitter.FlatComponents.splitAt`: the parts before `at` as a line,
/// `skip` characters dropped, and the part it ends in restyled as `style`.
fn split_at(parts: &mut Vec<(Vec<char>, Style)>, at: usize, skip: usize, style: &Style) -> Text {
let mut line = Text::default();
let mut push = |text: &[char], style: &Style| {
line.spans.push(Span {
text: text.iter().collect(),
style: style.clone(),
})
};
// Each part done is removed, so the one at hand is always the first.
let (mut position, mut in_skip) = (at, false);
while let Some((text, part_style)) = parts.first() {
let size = text.len();
if !in_skip {
if position > size {
push(text, part_style);
parts.remove(0);
position -= size;
continue;
}
if position > 0 {
push(&text[..position], part_style);
}
position += skip;
in_skip = true;
}
if position <= size {
if position == size {
parts.remove(0);
} else {
parts[0] = (text[position..].to_vec(), style.clone());
}
break;
}
parts.remove(0);
position -= size;
}
line
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1206,6 +1339,37 @@ mod tests {
assert!(!font.take_patches().is_empty());
}
/// StringDecomposer.iterateFormatted: a `§` that ends a part stops that
/// part only; the next part is still measured and can break.
#[test]
fn a_section_sign_ending_a_part_still_breaks_the_next() {
let sheet = png(16, 8, |x, _| x < 2);
let default = br#"{"providers":[{"type":"space","advances":{" ":4}},{"type":"bitmap","file":"minecraft:font/a.png","ascent":7,"chars":["ab"]}]}"#;
let root = root(
"section-sign",
&[
("assets/minecraft/font/default.json", default),
("assets/minecraft/textures/font/a.png", &sheet),
],
);
let font = load(&root);
let _ = std::fs::remove_dir_all(&root);
let mut text = Text::default();
text.spans = ["a§", "a a"]
.map(|part| Span {
text: part.into(),
style: Style::default(),
})
.into();
// 3 + 3 + 4 + 3 is past 12: the line breaks at the space.
let lines: Vec<String> = font
.split_formatted(&text, 12.0)
.iter()
.map(Text::plain)
.collect();
assert_eq!(lines, ["a§a", "a"]);
}
/// A renderer rebuilt over the same font (the loading screen's handoff)
/// makes its atlas texture from the textures alone, so the glyphs it had
/// rasterized into the previous texture must be placed into the new one.
@@ -39,8 +39,9 @@ mod entity_outline;
mod weather;
use entity_batch::EntityBatch;
pub use entity_batch::{
ENERGY_SWIRL_OVERLAY, SHADOW_OVERLAY, TRANSLUCENT_GLINT_ITEM_OVERLAY, TRANSLUCENT_ITEM_OVERLAY,
TRANSLUCENT_PARTICLE_OVERLAY, WATER_MASK_OVERLAY,
ENERGY_SWIRL_OVERLAY, SHADOW_OVERLAY, TEXT_POLYGON_OFFSET_OVERLAY,
TRANSLUCENT_GLINT_ITEM_OVERLAY, TRANSLUCENT_ITEM_OVERLAY, TRANSLUCENT_PARTICLE_OVERLAY,
WATER_MASK_OVERLAY,
};
/// Window.getAppropriateLineWidth, in framebuffer pixels.
@@ -93,6 +94,7 @@ mod page_runs;
pub mod panorama;
mod resident;
mod stars;
mod sunrise_fan;
mod timing;
pub mod translucency;
pub(crate) mod upload;
@@ -188,6 +190,8 @@ struct Globals {
outline_params: [f32; 4],
/// Horizon Extras' dynamic lights: camera-relative position and level (`dynamic_light`).
dynamic_lights: [[f32; 4]; crate::dynamic_light::MAX],
/// The sunrise fan's triangles as the sky shader tests them (`sunrise_fan::normals`).
sunrise_fan: [[f32; 4]; 2 * sunrise_fan::TRIANGLES],
}
pub type SectionKey = (i32, i32, i32);
struct SectionGpu {
@@ -425,6 +429,7 @@ pub struct Renderer {
lightning_pipeline: wgpu::RenderPipeline,
lightning: lightning::Renderer,
see_through_pipeline: wgpu::RenderPipeline,
text_polygon_offset_pipeline: wgpu::RenderPipeline,
celestial_pipeline: wgpu::RenderPipeline,
celestial_buffer: wgpu::Buffer,
end_sky: end_sky::EndSky,
@@ -533,12 +538,15 @@ pub struct Renderer {
block_entity_revision: u64,
/// VisGraph face connectivity per meshed section (cave culling).
occlusion: occlusion::Occlusion,
/// Resolution scale and FXAA (Horizon settings).
/// Resolution scale, FXAA and TAA (Horizon settings).
world_post: world_post::Post,
/// HDR output (Horizon setting): `Some` while the swapchain is HDR.
hdr: Option<hdr::Hdr>,
/// The HDR output the window's surface can present.
hdr_output: Option<hdr::Output>,
/// The marker file of an HDR output that has not presented a frame yet
/// (`graphics::HDR_MARKER`).
hdr_marker: Option<std::path::PathBuf>,
/// The shader pack's post-processing, and the dimension (legacy id) it was loaded for.
pack_post: Option<(i32, Option<pack_post::PackPost>)>,
/// The post passes of one of Horizon's own shader packs; never beside `pack_post`'s.
@@ -762,8 +770,14 @@ fn mip_chain(
/// pass is also what passes each draw's first instance to shaders reading
/// `instance_index`, and DirectX 12 is the fallback, where working matters
/// more than CPU time.
fn instance_descriptor(backends: wgpu::Backends) -> wgpu::InstanceDescriptor {
fn instance_descriptor(
backends: wgpu::Backends,
window: Option<&Arc<Window>>,
) -> wgpu::InstanceDescriptor {
let mut descriptor = wgpu::InstanceDescriptor::new_without_display_handle();
if let Some(window) = window {
descriptor = descriptor.with_display_handle(Box::new(WindowDisplay(window.clone())));
}
descriptor.backends = backends.with_env();
let dx12 = cfg!(windows) && descriptor.backends.contains(wgpu::Backends::DX12);
if !cfg!(debug_assertions) && !dx12 {
@@ -773,6 +787,21 @@ fn instance_descriptor(backends: wgpu::Backends) -> wgpu::InstanceDescriptor {
}
descriptor.with_env()
}
/// The window's display connection, so the OpenGL backend creates its EGL
/// display from the X11 or Wayland connection the window lives on. Without
/// one, wgpu-hal falls back to `EGL_MESA_platform_surfaceless`, whose configs
/// cannot present to a window; the backend then reports the surface as
/// incompatible and the game has no OpenGL to fall back to (`gl not
/// compatible with provided surface`).
#[derive(Debug, Clone)]
struct WindowDisplay(Arc<Window>);
impl wgpu::rwh::HasDisplayHandle for WindowDisplay {
fn display_handle(
&self,
) -> std::result::Result<wgpu::rwh::DisplayHandle<'_>, wgpu::rwh::HandleError> {
wgpu::rwh::HasDisplayHandle::display_handle(&*self.0)
}
}
/// The backends to open a device on, each tried in turn until one gives a
/// device. `api` is the player's choice (`GraphicsApi::with_env` applied);
/// `os` is `std::env::consts::OS`; `wgpu_backend`: whether `WGPU_BACKEND` is set,
@@ -850,6 +879,14 @@ fn check_layers(limits: &wgpu::Limits, layers: u32) -> Result<()> {
}
Ok(())
}
/// Whether the swapchain can take a window of `width` x `height`: each side from 1 to the
/// device's `max_texture_dimension_2d` (`max`), else `Surface::configure` panics. 0 is a
/// minimized window. Past `max`: Windows lets a program size a window up to 65535 pixels
/// (`SetWindowPos`), a 984x65496 client area, when an RTX 5080 takes 32768. Vanilla's
/// `Window.onFramebufferResize` likewise keeps the last size for one that is not positive.
fn drawable_size(width: u32, height: u32, max: u32) -> bool {
(1..=max).contains(&width) && (1..=max).contains(&height)
}
/// The swapchain format: a non-sRGB target, because vanilla blends and tints in gamma
/// space, and 8-bit, because wgpu gives `Rgba16Float` the linear `ExtendedSrgb` color
/// space: the compositor would read these gamma-encoded values as linear light and wash
@@ -887,7 +924,7 @@ async fn open_device(
if attempt > 0 {
log::warn!("Horizon renderer: trying {name} ({})", failures.join("; "));
}
let instance = wgpu::Instance::new(instance_descriptor(backends));
let instance = wgpu::Instance::new(instance_descriptor(backends, Some(window)));
let surface = if offscreen {
None
} else {
@@ -1097,9 +1134,14 @@ impl Renderer {
let info = adapter.get_info();
crate::graphics::suggest_reduced_far_detail(crate::graphics::lod_capped(&info));
let size = window.inner_size();
let max_side = device.limits().max_texture_dimension_2d;
let (config, present_modes, hdr_output) = if let Some(surface) = &surface {
let mut config = surface
.get_default_config(&adapter, size.width.max(1), size.height.max(1))
.get_default_config(
&adapter,
size.width.clamp(1, max_side),
size.height.clamp(1, max_side),
)
.ok_or("surface has no supported configuration")?;
let caps = surface.get_capabilities(&adapter);
config.format = surface_format(&caps.formats, config.format);
@@ -1125,14 +1167,21 @@ impl Renderer {
));
}
surface.configure(&device, &config);
(config, caps.present_modes.clone(), hdr::output(&caps))
let hdr_output = hdr::output(&caps);
if previous.is_none() {
log::info!(
"Horizon renderer: HDR output {}",
hdr_output.map_or("not offered by this display", hdr::Output::name)
);
}
(config, caps.present_modes.clone(), hdr_output)
} else {
let format = wgpu::TextureFormat::Bgra8Unorm;
let config = wgpu::SurfaceConfiguration {
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
format,
width: size.width.max(1),
height: size.height.max(1),
width: size.width.clamp(1, max_side),
height: size.height.clamp(1, max_side),
present_mode: wgpu::PresentMode::Immediate,
desired_maximum_frame_latency: 2,
alpha_mode: wgpu::CompositeAlphaMode::Opaque,
@@ -1966,7 +2015,7 @@ impl Renderer {
// ulps covers that spread at any resolution while staying far below
// the 0.001 a model's feet sit higher, so shadows never bleed onto
// the feet.
let shadow_blob_pipeline = entity_pipeline(
let shadow_blob_pipeline = entity_pipeline_with(
"Entity shadows",
None,
false,
@@ -1978,6 +2027,8 @@ impl Renderer {
},
alpha_blend,
false,
"fs_entity_shadow",
wgpu::ColorWrites::ALL,
);
// RenderPipelines.ITEM_TRANSLUCENT: blended, depth tested. Drawn before
// translucent terrain, it cannot write depth: the water behind a
@@ -2011,6 +2062,21 @@ impl Renderer {
alpha_blend,
false,
);
// RenderPipelines.TEXT_POLYGON_OFFSET: a text display's text and
// background win against the surface they lie on (bias 1, 10).
let text_polygon_offset_pipeline = entity_pipeline(
"Polygon offset text",
None,
true,
wgpu::CompareFunction::GreaterEqual,
wgpu::DepthBiasState {
constant: 10,
slope_scale: 1.0,
clamp: 0.0,
},
alpha_blend,
false,
);
let entity_pipeline = entity_pipeline(
"Entities",
None,
@@ -2568,6 +2634,7 @@ impl Renderer {
lightning_pipeline,
lightning: lightning::Renderer::new(),
see_through_pipeline,
text_polygon_offset_pipeline,
celestial_pipeline,
celestial_buffer,
end_sky: end_sky::EndSky::default(),
@@ -2644,6 +2711,7 @@ impl Renderer {
world_post,
hdr: None,
hdr_output,
hdr_marker: None,
pack_post: None,
native: None,
pass_timing: pack_timing::PassTiming::logging(),
@@ -2655,7 +2723,7 @@ impl Renderer {
hud,
menu_blur: None,
info,
suspended: size.width == 0 || size.height == 0,
suspended: !drawable_size(size.width, size.height, max_side),
drawn_sections: 0,
section_draws: 0,
section_draw_calls: 0,
@@ -2815,8 +2883,11 @@ impl Renderer {
);
}
}
/// Follows the window to `width` x `height`. A size the swapchain can't take
/// ([`drawable_size`]) keeps the last one and draws nothing until the next.
pub fn resize(&mut self, width: u32, height: u32) {
self.suspended = width == 0 || height == 0;
let max = self.device.limits().max_texture_dimension_2d;
self.suspended = !drawable_size(width, height, max);
if self.suspended {
return;
}
@@ -3200,14 +3271,43 @@ impl Renderer {
}
config
}
/// The F3 screen's HDR line while the setting is on: the swapchain HDR presents to with
/// its paper white and peak, or that the surface offers none.
pub fn hdr_line(&self) -> String {
match &self.hdr {
Some(hdr) => format!(
"HDR: {}, {}/{} nits",
hdr.output.name(),
hdr.paper_white.round(),
hdr.peak.round()
),
None => "HDR: not offered by this display".into(),
}
}
/// HDR output (Horizon setting): HDR10 or scRGB at `paper_white` nits, the world's
/// highlights expanded up to `peak` nits. Off where the surface can't present it.
pub fn set_hdr(&mut self, on: bool, paper_white: f32, peak: f32) {
pub fn set_hdr(&mut self, on: bool, paper_white: f32, peak: f32, marker: &std::path::Path) {
let output = self.hdr_output.filter(|_| on && self.surface.is_some());
if output.is_some() != self.hdr.is_some() {
// If the game dies before HDR output shows a frame, the marker is still there at
// the next start, which then comes up with HDR off.
if output.is_some() {
let _ = std::fs::write(marker, "HDR output was being switched on\n");
self.hdr_marker = Some(marker.to_owned());
} else if let Some(marker) = self.hdr_marker.take() {
let _ = std::fs::remove_file(marker);
}
let format = self.config.format.remove_srgb_suffix();
self.hdr = output.map(|output| hdr::Hdr::new(&self.device, format, output));
self.configure_surface();
match output {
Some(output) => log::info!(
"Horizon renderer: HDR on: {} ({:?}), {paper_white}/{peak} nits",
output.name(),
output.format()
),
None => log::info!("Horizon renderer: HDR off"),
}
}
if let Some(hdr) = &mut self.hdr
&& (hdr.paper_white, hdr.peak) != (paper_white, peak)
@@ -3217,9 +3317,12 @@ impl Renderer {
self.send_hdr_metadata();
}
}
pub fn set_world_post(&mut self, scale: f32, antialias: bool) {
/// `upscale`: output pixels per rendered pixel, for TAA to rebuild (1: native).
pub fn set_world_post(&mut self, scale: f32, antialias: bool, taa: bool, upscale: f32) {
self.world_post.scale = scale.clamp(0.25, 2.0);
self.world_post.antialias = antialias;
self.world_post.antialias = antialias && !taa;
self.world_post.taa = taa;
self.world_post.upscale = if taa { upscale.clamp(1.0, 4.0) } else { 1.0 };
}
/// Runs `post` over the world from now on (`None`: no pack), for the dimension `dimension`;
/// `block_ids` gives each block sprite layer's `mc_Entity` (id, fluid) pair.
@@ -3601,6 +3704,16 @@ impl Renderer {
frame.boss_darken,
);
let view_proj = self.view_projection(frame);
// TAA runs where the world post pass does, not under a shader pack's own.
let taa = !frame.title
&& self.pack_post.as_ref().is_none_or(|p| p.1.is_none())
&& self.native.as_ref().is_none_or(|n| n.post.is_none());
let jittered = self.world_post.jitter(
[self.config.width, self.config.height],
taa,
view_proj,
frame.camera,
) * view_proj;
if frame.title {
self.panorama.prepare(
&self.queue,
@@ -3652,7 +3765,7 @@ impl Renderer {
)
};
let globals = Globals {
view_proj: view_proj.to_cols_array_2d(),
view_proj: jittered.to_cols_array_2d(),
fog_color,
fog,
sky_color: [
@@ -3692,6 +3805,7 @@ impl Renderer {
}
lights
},
sunrise_fan: sunrise_fan::normals(frame.sky.sunrise_color[3], sun),
};
self.uploads
.buffer(&mut encoder, &self.globals, 0, bytemuck::bytes_of(&globals));
@@ -4194,6 +4308,7 @@ impl Renderer {
}
let [
entity_quads,
text_quads,
crumbling_quads,
translucent_crumbling_quads,
water_mask_quads,
@@ -4206,6 +4321,7 @@ impl Renderer {
.update(entities, frame.additive, frame.hand, frame.see_through);
let weather_quads = frame.weather.len() as u32 / 4;
let world_quads = entity_quads
+ text_quads
+ crumbling_quads
+ translucent_crumbling_quads
+ water_mask_quads
@@ -4284,7 +4400,7 @@ impl Renderer {
self.stars.prepare(
&mut self.uploads,
&mut encoder,
view_proj * rotation,
jittered * rotation,
brightness,
);
}
@@ -4457,6 +4573,8 @@ impl Renderer {
.flatten();
}
let pack_reads_world = pack_post || native_post;
// Phase 2 of occlusion culling draws over the depth, and a shader pack or TAA reads it.
let keep_depth = pack_reads_world || (taa && self.world_post.taa);
// The pack's program reads the vertices this frame's draws bind.
let pack_draws_terrain = pack_terrain && flat_frame == self.pack_terrain_flat;
// The shadow camera's lists, drawn by the pack's shadow programs.
@@ -4608,8 +4726,7 @@ impl Renderer {
view: world_depth,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(0.0),
// Phase 2 of occlusion culling draws over it; a shader pack reads it.
store: if cull.is_some() || pack_reads_world {
store: if cull.is_some() || keep_depth {
wgpu::StoreOp::Store
} else {
wgpu::StoreOp::Discard
@@ -4684,7 +4801,7 @@ impl Renderer {
&mut encoder,
world_view,
world_depth,
pack_reads_world,
keep_depth,
);
pass.set_bind_group(0, &self.group, &[]);
pack_frame(&mut pass);
@@ -4714,7 +4831,7 @@ impl Renderer {
&mut encoder,
world_view,
world_depth,
pack_reads_world,
keep_depth,
);
pass.set_bind_group(0, &self.group, &[]);
pack_frame(&mut pass);
@@ -4743,6 +4860,13 @@ impl Renderer {
pass.draw_indexed(0..entity_quads * 6, 0, 0..1);
pass.set_bind_group(0, &self.group, &[]);
}
if text_quads > 0 {
pass.set_pipeline(&self.text_polygon_offset_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.draw_indexed(0..text_quads * 6, (entity_quads * 4) as i32, 0..1);
pass.set_bind_group(0, &self.group, &[]);
}
if translucent_item_quads > 0 {
pass.set_pipeline(&self.translucent_item_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
@@ -4762,7 +4886,11 @@ impl Renderer {
pass.set_pipeline(&self.crumbling_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.draw_indexed(0..crumbling_quads * 6, (entity_quads * 4) as i32, 0..1);
pass.draw_indexed(
0..crumbling_quads * 6,
((entity_quads + text_quads) * 4) as i32,
0..1,
);
pass.set_bind_group(0, &self.group, &[]);
}
if shadow_quads > 0 {
@@ -4788,7 +4916,8 @@ impl Renderer {
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.draw_indexed(
0..water_mask_quads * 6,
((entity_quads + crumbling_quads + translucent_crumbling_quads) * 4) as i32,
((entity_quads + text_quads + crumbling_quads + translucent_crumbling_quads)
* 4) as i32,
0..1,
);
pass.set_bind_group(0, &self.group, &[]);
@@ -4806,6 +4935,7 @@ impl Renderer {
pass.draw_indexed(
0..swirl_quads * 6,
((entity_quads
+ text_quads
+ crumbling_quads
+ translucent_crumbling_quads
+ water_mask_quads)
@@ -4899,7 +5029,7 @@ impl Renderer {
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.draw_indexed(
0..translucent_crumbling_quads * 6,
((entity_quads + crumbling_quads) * 4) as i32,
((entity_quads + text_quads + crumbling_quads) * 4) as i32,
0..1,
);
}
@@ -5027,7 +5157,8 @@ impl Renderer {
native.timing.as_mut(),
);
} else if scaled.is_some() {
self.world_post.finish(&mut encoder, &view_target);
self.world_post
.finish(&self.queue, &mut encoder, &view_target);
}
if let Some(hdr) = &self.hdr {
hdr.keep_world(&mut encoder);
@@ -5231,6 +5362,12 @@ impl Renderer {
startup_step(format_args!("presenting the first frame"));
}
self.queue.present(surface_frame);
if self.hdr.is_some()
&& let Some(marker) = self.hdr_marker.take()
{
let _ = std::fs::remove_file(marker);
log::info!("Horizon renderer: HDR output presented its first frame");
}
if reconfigure && let Some(surface) = &self.surface {
Self::configure(
&self.device,
@@ -5432,6 +5569,23 @@ fn frustum(m: Mat4) -> [Vec4; 6] {
#[cfg(test)]
mod tests {
#[test]
fn drawable_size_skips_minimized_and_oversized_windows() {
use super::drawable_size;
assert!(drawable_size(1280, 720, 8192));
assert!(drawable_size(1, 1, 8192));
assert!(drawable_size(8192, 8192, 8192));
// Minimized.
assert!(!drawable_size(0, 720, 8192));
assert!(!drawable_size(1280, 0, 8192));
assert!(!drawable_size(0, 0, 8192));
// One past the device's largest texture.
assert!(!drawable_size(8193, 720, 8192));
assert!(!drawable_size(1280, 8193, 8192));
// The crash: a window `SetWindowPos` made 65535 pixels tall, on an RTX 5080 (32768).
assert!(!drawable_size(984, 65496, 32768));
assert!(!drawable_size(984, u32::MAX, 32768));
}
#[test]
fn surface_is_8_bit_and_not_srgb() {
use super::surface_format;
@@ -5496,6 +5650,7 @@ mod tests {
(11, 2),
(15, 10),
(7, 3),
(35, 11),
(4, 4),
(12, 7),
]
@@ -5505,7 +5660,7 @@ mod tests {
let mut batch = super::EntityBatch::default();
let counts = batch.update(&entities, &quad(3, 5), &quad(0, 6), &quad(0, 8));
let vertices = &batch.combined;
assert_eq!(counts, [4, 1, 1, 0, 0, 1, 1, 0]);
assert_eq!(counts, [4, 1, 1, 1, 0, 0, 1, 1, 0]);
assert_eq!(
vertices
.as_chunks::<4>()
@@ -5513,7 +5668,7 @@ mod tests {
.iter()
.map(|q| q[0].light)
.collect::<Vec<_>>(),
[0, 1, 3, 4, 2, 7, 9, 10, 5, 6, 8]
[0, 1, 3, 4, 11, 2, 7, 9, 10, 5, 6, 8]
);
}
#[test]
@@ -5684,7 +5839,7 @@ mod tests {
#[test]
fn indirect_draws_are_validated_only_in_debug_builds_and_on_directx_12() {
let validated = |backends| {
super::instance_descriptor(backends)
super::instance_descriptor(backends, None)
.flags
.contains(wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL)
};
@@ -103,10 +103,11 @@ impl SpriteAnimator {
let mut counts: Vec<usize> = Vec::new();
let mut sprites = Vec::with_capacity(animations.len());
for animation in animations {
let chains: Vec<_> = (0..animation.frame_count())
.map(|frame| source_chain(animation.pixels(frame), size, settings, animation.layer))
let pixels: Vec<_> = (0..animation.frame_count())
.map(|frame| animation.pixels(frame))
.collect();
let bytes: u64 = chains.iter().map(|(chain, _)| chain.len() as u64).sum();
let (chains, edge) = source_chains(&pixels, size, settings, animation.layer);
let bytes: u64 = chains.iter().map(|chain| chain.len() as u64).sum();
if bytes > limit {
log::warn!(
"Horizon: layer {}'s animation ({bytes} bytes) does not fit in a storage buffer; it stays still",
@@ -124,7 +125,7 @@ impl SpriteAnimator {
let data = &mut frames[buffer];
let offsets = chains
.iter()
.map(|(chain, _)| {
.map(|chain| {
let offset = (data.len() / 4) as u32;
data.extend_from_slice(chain);
offset
@@ -142,7 +143,7 @@ impl SpriteAnimator {
shown: None,
layer: animation.layer % split,
frames: offsets,
edge: chains[0].1,
edge,
group,
});
}
@@ -424,15 +425,63 @@ fn mix_level(frames: &[u8], [_, current, next, millis, edge]: [u32; 5], width: u
/// array's size from its own size's chain, its first level repeated into the
/// larger levels: that chain, which the shader repeats. Any other sprite: its
/// chain at the array's size.
#[cfg(test)]
fn source_chain(pixels: &[u8], size: u32, settings: &[Settings], layer: u32) -> (Vec<u8>, u32) {
let native = settings.get(layer as usize).map_or(0, |s| s.native_size);
let edge = if native.is_power_of_two() && native < size {
native
let (mut chains, edge) = source_chains(&[pixels], size, settings, layer);
(chains.swap_remove(0), edge)
}
/// Every frame's chain, laid out as one frame's `source_chain`, but mipmapped as
/// vanilla's `SpriteContents.increaseMipLevel` does: over the whole animation
/// sheet (the frames stacked), so `solidify` and the alpha-to-coverage scale
/// of each level are the sheet's, not each frame's. A frame alone has a
/// single texel square at its 2x2 level, and its coverage scale could round
/// a whole far frame transparent (kelp blinking out at distance).
fn source_chains(
frames: &[&[u8]],
size: u32,
settings: &[Settings],
layer: u32,
) -> (Vec<Vec<u8>>, u32) {
let s = settings.get(layer as usize).copied().unwrap_or_default();
let edge = if s.native_size.is_power_of_two() && s.native_size < size {
s.native_size
} else {
size
};
let frame = crate::sprites::resample(pixels, size, edge);
(super::mip_chain(&frame, edge, settings, layer, 1).0, edge)
let mut sheet = crate::mipmap::Image {
width: edge,
height: edge * frames.len() as u32,
pixels: Vec::with_capacity((edge * edge) as usize * frames.len()),
};
for frame in frames {
let frame = crate::sprites::resample(frame, size, edge);
sheet.pixels.extend_from_slice(frame.as_chunks::<4>().0);
}
let has_transparent = s.has_transparent || sheet.pixels.iter().any(|p| p[3] == 0);
let levels = crate::mipmap::generate(
sheet,
edge.trailing_zeros(),
Settings {
has_transparent,
..s
},
);
let chains = (0..frames.len())
.map(|frame| {
let mut chain = Vec::new();
for level in &levels {
let texels = (level.width * level.width) as usize;
chain.extend(
level.pixels[frame * texels..(frame + 1) * texels]
.iter()
.flatten(),
);
}
chain
})
.collect();
(chains, edge)
}
#[cfg(test)]
@@ -572,6 +621,63 @@ mod tests {
}
}
#[test]
fn kelp_frames_share_the_sheet_mips() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root
.join("assets/minecraft/textures/block/kelp.png")
.exists()
{
return;
}
let mut sprites = crate::sprites::Sprites::default();
for name in ["kelp", "kelp_plant", "seagrass", "tall_seagrass_top"] {
sprites
.insert(&root, &format!("minecraft:block/{name}"))
.unwrap();
}
sprites.finish();
// A level drawn nowhere: no texel passes the cutout's alpha test.
let blank = |chain: &[u8], edge: u32| {
let mut start = 0;
let mut level = edge;
let mut blank = Vec::new();
while level > 0 {
let bytes = (level * level * 4) as usize;
let texels = &chain[start..start + bytes];
blank.push(texels.as_chunks::<4>().0.iter().all(|p| p[3] < 128));
start += bytes;
level >>= 1;
}
blank
};
let mut blinked = 0;
for animation in &sprites.animations {
let size = sprites.size;
let pixels: Vec<_> = (0..animation.frame_count())
.map(|f| animation.pixels(f))
.collect();
let (chains, edge) = source_chains(&pixels, size, &sprites.mips, animation.layer);
for (frame, chain) in chains.iter().enumerate() {
// The 1x1 level has no texel square to measure coverage on
// (vanilla leaves it unscaled); every larger level shows.
let levels = blank(chain, edge);
assert!(
!levels[..levels.len() - 1].contains(&true),
"layer {} frame {frame}: {levels:?}",
animation.layer
);
let (alone, _) = source_chain(pixels[frame], size, &sprites.mips, animation.layer);
let alone = blank(&alone, edge);
blinked += usize::from(alone[..alone.len() - 1].contains(&true));
}
}
// Mipmapped frame by frame, some kelp frames vanished at the 2x2 level.
assert!(blinked > 0);
}
#[test]
fn rounding_matches_f32_mix_but_for_exact_halves() {
// The CPU blend this replaced: f32 mix, then rounded. They differ
@@ -730,8 +836,11 @@ mod tests {
queue.submit([encoder.finish()]);
for animation in &animations {
let blend = animation.blend(tick);
let chain = |f| source_chain(animation.pixels(f), size, &mips, animation.layer);
let ((current, edge), (next, _)) = (chain(blend.frame), chain(blend.next));
let pixels: Vec<_> = (0..animation.frame_count())
.map(|f| animation.pixels(f))
.collect();
let (chains, edge) = source_chains(&pixels, size, &mips, animation.layer);
let (current, next) = (&chains[blend.frame], &chains[blend.next]);
let layers = read_back(
&device,
&queue,
@@ -739,7 +848,7 @@ mod tests {
);
assert_eq!(
layers[(animation.layer % split) as usize],
reference(&current, &next, blend.millis, edge, size),
reference(current, next, blend.millis, edge, size),
"layer {} at tick {tick}",
animation.layer
);
@@ -2,13 +2,14 @@
use super::Vertex;
/// Entity materials by a quad's overlay (`Vertex::layer`), in draw order:
/// ordinary entities, breaking cracks (11), translucent breaking cracks (12),
/// the boat water mask (13, `RenderPipelines.WATER_MASK`), energy swirls
/// (34, `RenderPipelines.ENERGY_SWIRL`), entity shadows (14,
/// ordinary entities, world text drawn with a depth bias (35,
/// `RenderPipelines.TEXT_POLYGON_OFFSET`), breaking cracks (11), translucent
/// breaking cracks (12), the boat water mask (13, `RenderPipelines.WATER_MASK`),
/// energy swirls (34, `RenderPipelines.ENERGY_SWIRL`), entity shadows (14,
/// `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 = 8;
const MATERIALS: usize = 9;
/// 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
@@ -24,13 +25,31 @@ pub const TRANSLUCENT_PARTICLE_OVERLAY: u32 = 15;
pub const TRANSLUCENT_ITEM_OVERLAY: u32 = 32;
/// A translucent item with an enchantment glint.
pub const TRANSLUCENT_GLINT_ITEM_OVERLAY: u32 = 33;
/// The overlay of text in `Font.DisplayMode.POLYGON_OFFSET` (text displays):
/// drawn like an ordinary entity, pulled toward the camera.
pub const TEXT_POLYGON_OFFSET_OVERLAY: u32 = 35;
/// The material of a quad by its overlay (`MATERIALS` order).
fn material(layer: u32) -> usize {
match layer {
TEXT_POLYGON_OFFSET_OVERLAY => 1,
11 => 2,
12 => 3,
WATER_MASK_OVERLAY => 4,
ENERGY_SWIRL_OVERLAY => 5,
SHADOW_OVERLAY => 6,
TRANSLUCENT_PARTICLE_OVERLAY => 7,
TRANSLUCENT_ITEM_OVERLAY | TRANSLUCENT_GLINT_ITEM_OVERLAY => 8,
_ => 0,
}
}
#[derive(Default)]
pub(super) struct EntityBatch {
// Quad indices avoid copying vertices twice while grouping materials.
// Keep initialized high-water storage; local counts delimit active entries.
// Each bucket can hold all quads, removing per-quad Vec growth checks.
buckets: [Vec<usize>; MATERIALS],
// Runs of consecutive quads of one material (first quad, quad count): a
// model's quads share one, so whole runs are copied at once. Cleared each
// frame, keeping their storage.
runs: [Vec<(usize, usize)>; MATERIALS],
pub(super) combined: Vec<Vertex>,
}
@@ -43,50 +62,29 @@ impl EntityBatch {
see_through: &[Vertex],
) -> [u32; MATERIALS] {
let quads = entities.as_chunks::<4>().0;
for bucket in &mut self.buckets {
if bucket.len() < quads.len() {
bucket.resize(quads.len(), 0);
}
for runs in &mut self.runs {
runs.clear();
}
let mut counts = [0usize; MATERIALS];
for (index, quad) in quads.iter().enumerate() {
let material = match quad[0].layer {
11 => 1,
12 => 2,
WATER_MASK_OVERLAY => 3,
ENERGY_SWIRL_OVERLAY => 4,
SHADOW_OVERLAY => 5,
TRANSLUCENT_PARTICLE_OVERLAY => 6,
TRANSLUCENT_ITEM_OVERLAY | TRANSLUCENT_GLINT_ITEM_OVERLAY => 7,
_ => 0,
};
self.buckets[material][counts[material]] = index;
counts[material] += 1;
let mut start = 0;
while start < quads.len() {
let kind = material(quads[start][0].layer);
let end = quads[start + 1..]
.iter()
.position(|quad| material(quad[0].layer) != kind)
.map_or(quads.len(), |n| start + 1 + n);
self.runs[kind].push((start, end - start));
counts[kind] += end - start;
start = end;
}
let entity_vertices = quads.len() * 4;
self.combined.reserve(
(entity_vertices + additive.len() + hand.len() + see_through.len())
.saturating_sub(self.combined.len()),
);
self.combined.clear();
self.combined
.resize(entity_vertices, bytemuck::Zeroable::zeroed());
if counts.contains(&quads.len()) {
// Uniform materials are already ordered; copy the whole batch.
self.combined.copy_from_slice(&entities[..entity_vertices]);
} else {
let mut offset = 0;
for (bucket, &count) in self.buckets.iter().zip(&counts) {
let bucket = &bucket[..count];
let end = offset + count * 4;
for (output, &index) in self.combined[offset..end]
.as_chunks_mut::<4>()
.0
.iter_mut()
.zip(bucket)
{
*output = quads[index];
}
offset = end;
.reserve(entity_vertices + additive.len() + hand.len() + see_through.len());
for runs in &self.runs {
for &(first, count) in runs {
self.combined
.extend_from_slice(&entities[first * 4..(first + count) * 4]);
}
}
self.combined.extend_from_slice(additive);
@@ -109,9 +107,10 @@ mod tests {
) -> (Vec<Vertex>, [u32; MATERIALS]) {
let mut combined = Vec::with_capacity(entities.len() + additive.len() + hand.len());
let mut counts = [0; MATERIALS];
for (i, layer) in [0, 11, 12, 13, 34, 14, 15].into_iter().enumerate() {
for (i, layer) in [0, 35, 11, 12, 13, 34, 14, 15].into_iter().enumerate() {
for quad in entities.as_chunks::<4>().0 {
let material = match quad[0].layer {
35 => 35,
11 => 11,
12 => 12,
13 => 13,
@@ -137,7 +136,7 @@ mod tests {
pos: [index as f32, 2.0, 3.0],
uv: [index as f32, 5.0],
layer: if mixed {
[7, 11, 14, 12, 4, 13, 15, 0][index / 4 % 8]
[7, 11, 14, 35, 12, 4, 13, 15, 0][index / 4 % 9]
} else {
0
},
@@ -174,12 +173,12 @@ mod tests {
let hand = geometry(2, false);
// Warm every bucket and the combined buffer to their maximum sizes.
equivalent(&mut batch, &entities, &additive, &hand);
let capacities = batch.buckets.each_ref().map(|b| b.capacity());
let capacities = batch.runs.each_ref().map(|b| b.capacity());
let capacity = batch.combined.capacity();
let pointer = batch.combined.as_ptr();
for length in [0, 1, 3, 4, 511, entities.len(), 17, 0, entities.len()] {
equivalent(&mut batch, &entities[..length], &additive[..5], &hand[..3]);
assert_eq!(batch.buckets.each_ref().map(|b| b.capacity()), capacities);
assert_eq!(batch.runs.each_ref().map(|b| b.capacity()), capacities);
assert_eq!(batch.combined.capacity(), capacity);
assert_eq!(batch.combined.as_ptr(), pointer);
}
@@ -188,11 +187,11 @@ mod tests {
// Grow again, then confirm empty/small frames retain the new storage.
let larger = geometry(8192, true);
equivalent(&mut batch, &larger, &additive, &hand);
let capacities = batch.buckets.each_ref().map(|b| b.capacity());
let capacities = batch.runs.each_ref().map(|b| b.capacity());
let capacity = batch.combined.capacity();
for entities in [&larger[..7], &larger[..], &[]] {
equivalent(&mut batch, entities, &[], &[]);
assert_eq!(batch.buckets.each_ref().map(|b| b.capacity()), capacities);
assert_eq!(batch.runs.each_ref().map(|b| b.capacity()), capacities);
assert_eq!(batch.combined.capacity(), capacity);
}
}
@@ -255,9 +254,9 @@ mod tests {
before[7] as f64 / after[7] as f64,
batch.combined.capacity() * std::mem::size_of::<Vertex>()
+ batch
.buckets
.runs
.iter()
.map(|b| b.capacity() * std::mem::size_of::<usize>())
.map(|b| b.capacity() * std::mem::size_of::<(usize, usize)>())
.sum::<usize>()
);
}
@@ -14,6 +14,12 @@ pub enum Output {
}
impl Output {
pub fn name(self) -> &'static str {
match self {
Self::Pq => "HDR10 (PQ)",
Self::ScRgb => "scRGB",
}
}
pub fn format(self) -> wgpu::TextureFormat {
match self {
Self::Pq => wgpu::TextureFormat::Rgb10a2Unorm,
@@ -0,0 +1,131 @@
//! The sunrise/sunset fan (`SkyRenderer.renderSunriseAndSunset`) as the sky
//! shader tests it. The fan's 16 triangles depend only on the sunrise alpha
//! and the horizon the sun is on, so their vertices are built here once a
//! frame instead of in every sky pixel (#3143: with the per-pixel build, four
//! `sin`/`cos` per triangle, the sky pass took 10.2 ms at dawn at 1080p on an
//! Intel UHD 730 against 0.6 ms by day; now 1.8 ms).
//!
//! Möller-Trumbore from the camera reduces to triple products: with apex A
//! and rim vertices r0, r1, a ray d hits at barycentrics proportional to
//! (`[d, r0, r1]`, `[A, d, r1]`, `[A, r0, d]`), each a dot of d with a
//! constant normal, and their sum S is the negated determinant. A hit needs
//! S on the side of `[A, r0, r1]`, so only the triangles whose rim normals'
//! dots change sign there are tested in full.
use glam::Vec3;
pub const TRIANGLES: usize = 16;
/// Per triangle: xyz `r0 × r1` (its dot with a ray is the apex's weight
/// times S), w `[A, r0, r1]` (its sign is S's where the hit lies ahead).
/// Then per rim vertex: xyz `r × A`.
pub fn normals(alpha: f32, sun: Vec3) -> [[f32; 4]; 2 * TRIANGLES] {
let (apex, rim) = vertices(alpha, sun);
let mut out = [[0.0; 4]; 2 * TRIANGLES];
for i in 0..TRIANGLES {
let (r0, r1) = (rim[i], rim[(i + 1) % TRIANGLES]);
out[i] = r0.cross(r1).extend(apex.dot(r0.cross(r1))).to_array();
out[TRIANGLES + i] = r0.cross(apex).extend(0.0).to_array();
}
out
}
/// The fan's apex and rim in camera space, as vanilla builds them: the
/// fan turned to the horizon the sun sets on (or rises from), its rim
/// tilted by the alpha.
fn vertices(alpha: f32, sun: Vec3) -> (Vec3, [Vec3; TRIANGLES]) {
// sin(sunAngle) < 0 flips the fan to the other horizon; sun.z = -sin(sunAngle).
let flip = if -sun.z < 0.0 { 180f32 } else { 0.0 };
let (s, c) = (flip + 90.0).to_radians().sin_cos();
let rotate_x90 = |v: Vec3| Vec3::new(v.x, -v.z, v.y);
let apex = rotate_x90(Vec3::new(-100.0 * s, 100.0 * c, 0.0));
let rim = std::array::from_fn(|i| {
let a = i as f32 * std::f32::consts::TAU / TRIANGLES as f32;
let l = Vec3::new(a.sin() * 120.0, a.cos() * 120.0, -a.cos() * 40.0 * alpha);
rotate_x90(Vec3::new(l.x * c - l.y * s, l.x * s + l.y * c, l.z))
});
(apex, rim)
}
#[cfg(test)]
mod tests {
use super::*;
/// terrain.wgsl's `sunrise_weight` over the normals.
fn weight(normals: &[[f32; 4]; 2 * TRIANGLES], d: Vec3) -> f32 {
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 {
let k1 = d.dot(n(TRIANGLES + (i + 1) % TRIANGLES));
let w = normals[i][3];
if k0 * w <= 0.0 && k1 * w >= 0.0 {
let apex = d.dot(n(i));
let s = apex + k1 - k0;
if s.abs() >= 1e-6
&& s * w > 0.0
&& apex * s >= 0.0
&& k1 * s >= 0.0
&& k0 * s <= 0.0
{
best = best.max(apex / s);
}
}
k0 = k1;
}
best
}
/// 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);
let mut best = 0.0f32;
for i in 0..TRIANGLES {
let e1 = rim[i] - apex;
let e2 = rim[(i + 1) % TRIANGLES] - apex;
let pv = d.cross(e2);
let det = e1.dot(pv);
if det.abs() < 1e-6 {
continue;
}
let tv = -apex;
let u = tv.dot(pv) / det;
let qv = tv.cross(e1);
let v = d.dot(qv) / det;
let t = e2.dot(qv) / det;
if u >= 0.0 && v >= 0.0 && u + v <= 1.0 && t > 0.0 {
best = best.max(1.0 - u - v);
}
}
best
}
#[test]
fn normals_weigh_every_ray_as_the_per_pixel_fan_did() {
let mut hits = 0;
for (alpha, sun_z) in [(1.0f32, -0.2f32), (0.6, 0.3), (0.05, -0.9), (0.9, 0.05)] {
let sun = Vec3::new(0.0, (1.0 - sun_z * sun_z).sqrt(), sun_z);
let normals = normals(alpha, sun);
for yaw in 0..180 {
for pitch in -45..=45 {
let (yaw, pitch) = (
(yaw as f32 * 2.0).to_radians(),
(pitch as f32 * 2.0).to_radians(),
);
let d = Vec3::new(
yaw.cos() * pitch.cos(),
pitch.sin(),
yaw.sin() * pitch.cos(),
);
let (got, want) = (weight(&normals, d), reference(alpha, sun, d));
assert!(
(got - want).abs() < 1e-4,
"{alpha} {sun_z} {d}: {got} vs {want}"
);
hits += usize::from(want > 0.0);
}
}
}
// The fan covers part of the sky, not none of it.
assert!(hits > 1000, "{hits}");
}
}
Loaded 100 of 666 files, more files were not shown because too many files have changed in this diff. Show more