Compare commits

...
Author SHA1 Message Date
Ludovic Marie 8f84d9c34b Union union/pt2-c2: #2570 461a8757e 2026-10-29 16:44:44 +01:00
Ludovic Marie 7bf26655bf Union union/pt2-c2: #3337 de25583ee 2026-10-29 16:44:43 +01:00
Ludovic Marie fe6c0ea19f Union union/pt2-c2: #3148 04cd6ece2 2026-10-29 16:44:42 +01:00
Ludovic Marie 574b22a39b Union union/pt2-c2: #3522 5485e189f 2026-10-29 16:44:41 +01:00
Ludovic Marie a76a48c309 Union union/pt2-c2: #3479 dc8f01ea1 2026-10-29 16:44:40 +01:00
Ludovic Marie 11b4bdf530 Union union/pt2-c2: #3478 b65c319b2 2026-10-29 16:44:39 +01:00
Ludovic Marie 43169d3e48 Union union/pt2-c2: #3477 8fb51b0c4 2026-10-29 16:44:38 +01:00
Ludovic Marie 96106738c8 Union union/pt2-c2: #3476 0a00df764 2026-10-29 16:44:37 +01:00
Ludovic Marie b418ada8aa Union union/pt2-c2: #3391 f5811c797 2026-10-29 16:44:34 +01:00
Ludovic Marie c17e9ae76f Union union/pt2-c2: #3390 34c5dce05 2026-10-29 16:44:33 +01:00
Ludovic Marie a1f09d9a17 Union union/pt2-c2: #3366 52cc53a4f 2026-10-29 16:44:31 +01:00
Ludovic Marie 62a23ba025 Merge pull request #3325 from ThatCakeIsAPie/feat/create-world-data-packs
🤖 Agent: verified by Ludovic's AI agent (Claude): follows CreateWorldScreen's temp data pack folder flow; main merged, clippy and the datapack/title/pack screen tests pass, verify green. Queued for the merger.
2026-10-29 16:37:10 +01:00
Ludovic Marie 2cacd5561c Merge pull request #3725 from horizon-rust/feat/launcher-java-join
🤖 Agent: launcher matrix 40899770886 green at this head; queued.
2026-10-29 16:32:56 +01:00
Ludovic MarieandClaude Opus 5.5 f5811c797a Merge main into the branch
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 16:25:16 +01:00
Ludovic MarieandClaude Opus 5.5 34c5dce056 Merge main into the branch
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 16:25:16 +01:00
levzzz a491181961 Merge pull request #3253 from hectoreccles/perf/opt-01-storage
perf: storage primitives (dirty sections, packed index, palette scan, Fx maps)
2026-10-29 20:18:07 +05:00
levzzz 4f69f45167 Merge pull request #3450 from d3grando/fix-3319
End the dragon fight when /kill removes the dragon
2026-10-29 20:16:49 +05:00
levzzz 2f9d889dcc Merge pull request #3367 from ThatCakeIsAPie/perf/worldgen-decoration-reads
Decoration: inlined block reads, column scans, per-chunk heightmap reads
2026-10-29 20:16:24 +05:00
levzzz a459a00584 Merge pull request #3365 from ThatCakeIsAPie/perf/worldgen-biome-stage
Biome stage: build free neighbour biome chunks first, cheaper id lookups
2026-10-29 20:16:13 +05:00
levzzz ede6d6dfbd Merge pull request #3364 from ThatCakeIsAPie/perf/worldgen-structure-climate
Structure checks: one climate volume per column, and each worker keeps its samples
2026-10-29 20:16:02 +05:00
levzzz 2cf38dd095 Merge pull request #3368 from ThatCakeIsAPie/perf/far-tiles-biome-memo
Far tiles: resolve each biome quart once per tile, and a far-tile bench
2026-10-29 20:15:34 +05:00
Ludovic Marie 89f4b34c1f Merge remote-tracking branch 'origin/main' into feat/launcher-java-join
# Conflicts:
#	launcher/src/main.rs
2026-10-29 16:15:13 +01:00
Ludovic Marie 84c5e0b63b Merge pull request #3561 from d3grando/fix-3243
🤖 Agent: read in full (pr-triage-1). A keyboard key bound to attack/use keeps clicking while held, as KeyboardHandler.keyPress sends every repeat to KeyMapping.set/click; horizon-ctl 'repeat' keeps the key held. Unit test added.
2026-10-29 16:07:52 +01:00
Ludovic Marie 7e42cc8ece Merge pull request #2745 from levzzz5154/fix/flowing-water-mobs
🤖 Agent: verified by Ludovic's AI agent (Claude): the review points are addressed (tagged pigs, enclosed arenas, cleanup); flow-push and bubble-column pass on the server thread and in-frame (current carries 2.8/2.7 blocks vs 0 in still water; the column lifts higher than plain water). Tools only. Queued for the merger. Re-run at 9039a268f (arenas now built at y 100, husk line back to main's): lan_e2e flow-push and bubble-column PASS on the server thread (flowing 2.87 vs still 0.0; column 1.404 vs plain 0.785) and in-frame (2.776 vs 0.0; 1.42 vs 0.725).
2026-10-29 15:54:46 +01:00
Ludovic Marie 0a95d1c047 Merge pull request #3404 from degepie/fix/issue-3306-zombify-villager
🤖 Agent: verified by Ludovic's AI agent (Claude) on union/pt2-a 7e322f591 (main + 8 PRs): fmt, workspace clippy -D warnings, horizon-client/protocol/command/client-core/world tests, fast build; lan_e2e hurt, cure-discount, commands, piston-push, hands, helduse on the server thread and in-frame, plus portals (server thread): all pass (cure-discount: 14 wheat for the cured villager's emerald, host and guest). Queued; the merger waits for CI parity on this head.
2026-10-29 15:51:13 +01:00
Ludovic Marie 2286d42b7f Merge pull request #2973 from kacpherek/fix/advancement-triggers-summon-loot-craft
🤖 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 15:31:29 +01:00
levzzz b869bdcacd Merge pull request #3591 from d3grando/fix-3185
Wolves: any target makes a wolf angry, so an alerted pack looks angry
2026-10-29 19:25:29 +05:00
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
Ludovic MarieandClaude Opus 5.5 f2337d0a73 Launcher: a server's Join starts a Java instance into it
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 14:52:27 +01:00
levzzzandClaude Opus 5.5 e852db5e60 A wolf angry at the player stays angry across a reload
On the server thread a reloaded wolf ticks before the player joins, and
the anger ended because no player counted as no valid target. Vanilla's
updatePersistentAnger ends it only once it finds the player and the player
is in creative or spectator, or the difficulty is peaceful.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 18:50:37 +05:00
levzzz f781769177 Merge remote-tracking branch 'hr/main' into pr-3591 2026-10-29 18:49:06 +05: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
Ludovic Marie c784e572d7 Merge remote-tracking branch 'origin/main' into pt3/3253 2026-10-29 13:56:14 +01: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
levzzzandClaude Opus 5.5 9039a268f6 Build the fluid arenas up in the air, and leave the husks to main
Both fluid checks built their walls at the host's feet, in the host's chunk,
so a player standing there could be shut in stone and suffocate. They now
build at y 100, as check_piston_push does, and the last clear takes the
floor away as well. The boat dismount check's husks go back to NoAI, as on
main: that change belongs to no fluid check.

flow-push, bubble-column, digging and frames pass in that order on the
server thread and --in-frame.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 17:23:08 +05:00
Ludovic MarieandClaude Opus 5.5 461a8757e3 Merge main into the branch
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 13:19:44 +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 MarieandClaude Opus 5.5 3b261d1081 Merge main into the branch
Keep main's 'names' check beside flow-push and bubble-column.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 13:07:56 +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 dc8f01ea19 Merge main into the branch
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 12:28:51 +01:00
Ludovic MarieandClaude Opus 5.5 b65c319b2a Merge main into the branch
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 12:28:50 +01:00
Ludovic MarieandClaude Opus 5.5 8fb51b0c4a Merge main into the branch
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 12:28:50 +01:00
Ludovic MarieandClaude Opus 5.5 0a00df7640 Merge main into the branch
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 12:28:49 +01: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 MarieandClaude Opus 5.5 dffd3973bc Merge main into perf/far-tiles-biome-memo
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 12:07:12 +01:00
Ludovic MarieandClaude Opus 5.5 e60455d12c Merge main into perf/worldgen-biome-stage
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 12:07:11 +01:00
Ludovic MarieandClaude Opus 5.5 043f9c7b7b Merge main into perf/worldgen-structure-climate
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 12:07:11 +01:00
Ludovic Marie 73ae9f6cf1 Merge branch 'upd/3478' into upd/3479 2026-10-29 11:46:40 +01:00
Ludovic Marie 69fc342a1a Merge remote-tracking branch 'origin/main' into upd/3478
# Conflicts:
#	crates/horizon-client/src/game/world_loading.rs
#	crates/horizon-client/src/vanillagen.rs
#	crates/horizon-world/src/worldgen/generator.rs
2026-10-29 11:46:37 +01:00
Ludovic Marie ad7904946e Merge remote-tracking branch 'origin/main' into upd/3477
# Conflicts:
#	crates/horizon-world/src/worldgen/generator.rs
2026-10-29 11:45:59 +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 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 ca1b200c79 Merge remote-tracking branch 'origin/main' into upd/3391
# Conflicts:
#	crates/horizon-world/src/worldgen/generator.rs
2026-10-29 11:09:33 +01:00
Ludovic Marie b603db6176 Merge remote-tracking branch 'origin/main' into upd/3390
# Conflicts:
#	crates/horizon-world/src/worldgen/generator.rs
2026-10-29 11:09:18 +01:00
Ludovic Marie 779b773530 Merge remote-tracking branch 'origin/main' into upd/3367
# Conflicts:
#	crates/horizon-world/src/worldgen/generator_decoration.rs
2026-10-29 11:08:25 +01:00
Ludovic Marie 1d01a2d696 Merge remote-tracking branch 'origin/main' into upd/3325 2026-10-29 10:51:09 +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 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 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 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 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 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 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 04cd6ece23 Drop the test that only exercised std::mem::replace (the fix is the one line in as_guest) 2026-10-29 10:16:08 +01:00
Ludovic Marie 9db690b1e0 Merge remote-tracking branch 'origin/main' into upd/3328 2026-10-29 10:15:48 +01:00
Ludovic Marie 9218dc3e7d Leave horizon-server's lists test as on main (not part of this fix) 2026-10-29 10:15:47 +01:00
Ludovic Marie 5b66bd0ec0 Merge remote-tracking branch 'origin/main' into upd/3148 2026-10-29 10:15:46 +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
levzzzandOpenAI Codex a2e111a461 Keep fluid-test pigs mobile with zero AI movement speed
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-29 14:12:18 +05: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 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
levzzzandOpenAI Codex fe867a6132 Track tagged fluid-test pigs and always clean enclosed arenas
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-29 14:02:58 +05:00
levzzzandOpenAI Codex 8742e1768e Merge current main before isolating fluid checks
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-29 14:01:20 +05:00
levzzzandOpenAI Codex c91ca07115 Merge current main and use its preset-aware terrain interface
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-29 13:58:50 +05:00
d3grandoandClaude Opus 5.5 e086b2cfdd Wolves: any target makes a wolf angry, so an alerted pack looks angry
Wolf.aiStep ends with NeutralMob.updatePersistentAnger(level, true): a
wolf with a target is angry at it, and the 20-39 s timer
(PERSISTENT_ANGER_TIME) starts over every tick it holds one. Horizon only
set the anger in hurtServer when the player hit the wolf, so the pack
mates HurtByTargetGoal alerted attacked without the angry texture, and the
random draws of an angry wolf came in the wrong order. isAngryAt(player)
now reads the anger's target, and stopBeingAngry ends it when the time
runs out, the target mob dies, or the player is in creative, spectator
or peaceful. Saving a wolf keeps that anger target instead of replacing
it with the current target.

The mob_ai harness prints a wolf's anger and gets a wolf_hurt scenario.

Fixes #3185

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 15:58:42 +07: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
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
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 e2146572a6 Repeat attack and use clicks while a key bound to them is held
KeyboardHandler.keyPress sends a held key's repeats to KeyMapping.set and
KeyMapping.click like the press, so a keyboard key bound to key.attack
keeps attacking while it is held. Horizon dropped the repeats of keys
bound to a mouse mapping, so such a key hit once. horizon-ctl's
`key KEY repeat` now keeps a mouse-mapped key held too, as the window's
repeat does.

Fixes #3243

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 15:10:33 +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
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
Senjin 624be7df86 Merge remote-tracking branch 'origin/main' into hdr-f3 2026-10-29 08:32:55 +01:00
Alexander KrylovandHermes Agent 5485e189f8 Options: Horizon's own captions read the bundled language
The options pages, the mod and shader pack lists, the agent button and the
multiplayer friend screens drew their own labels as literals, so a client with
ru_ru stayed English there. They read `horizon.*` keys now, and the bundled
namespace carries both languages for every one of them.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-29 08:35:09 +03: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 74d986ca44 Merge main into this branch
game/golems.rs: main's #3123 marks a built iron golem player-created; this
branch's SUMMONED_ENTITY trigger runs after it, as in
CarvedPumpkinBlock.trySpawnGolem (setPlayerCreated, then spawnGolemInWorld).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 06:13:05 +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
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
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 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
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
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
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
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
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 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 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
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 8428ff0f59 Merge main into fix/advancement-triggers-summon-loot-craft
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 01:43:53 +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
d3grandoandClaude Opus 5.5 48342a7ef3 End the dragon fight when /kill removes the dragon
kill_dragon only dropped the entity, so the fight, still not killed,
spawned a fresh dragon on its next tick and never opened the exit
portal. EnderDragon.kill calls dragonFight.setDragonKilled after
removing the dragon: call dragon_killed there too, in the End.

The command execution corpus gets an End group that kills the fight's
dragon and probes the dragon, the exit portal and the egg.

Fixes #3319

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 07:08:10 +07: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 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
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
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
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
ThatCakeIsAPieandClaude Opus 5.5 a849545f63 Harness: chunks generated with a world's data packs against vanilla
test_datapack_worldgen_parity.py runs the whole-chunk oracle and
chunk_dump with HORIZON_DATAPACKS: the oracle selects every pack of the
folder after vanilla through vanilla's pack repository, chunk_dump reads
them through worldgen::overrides into GeneratorData::load_with.
tools/fixtures/datapacks holds Horizon's own pack (MIT), overriding a
noise, a density function, the overworld noise settings, a placed
feature and a biome tag, and adding a noise and two density functions.
Each site must match vanilla with the pack and differ from Horizon
without it. HORIZON_PARITY_DATAPACKS compares another folder of packs.

docs/world-generation.md describes what a pack changes and what Horizon
leaves out; the README lists world generation among what data packs do.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 18:12:53 -05:00
ThatCakeIsAPieandClaude Opus 5.5 b0e65dec06 Data packs: a world's packs change its world generation
The enabled packs of <world>/datapacks (in datapacks.json's order) now
reach the chunk generator: their worldgen files replace and join the
prepared bundles, and the log lists what Horizon leaves out (new biomes,
dimension types, folders the generator does not read, noise taller than
the level). Generator data is cached per set of pack entries, beside the
plain bundle the world screens preload. datapack::enabled_ids reads which
packs are on, as /datapack does.

A world without data packs reads the same bundle as before. Sapling
growth and bone meal still place vanilla's features.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 18:12:53 -05:00
ThatCakeIsAPieandClaude Opus 5.5 d1ef7b4fb0 Worldgen: data packs' entries over the prepared bundles
WorldgenOverrides maps a data pack's worldgen files (noise, density
functions, noise settings, material rules and conditions, carvers,
features, placed features, structures, sets, pools, processor lists,
biomes, level stems) to the prepared bundle each joins, the highest pack
winning, and its block, fluid and biome tag files to the tag bundles in
pack order, as vanilla builds its registries from the enabled packs.
Carvers and features use 26.3's folder names (worldgen/carver,
worldgen/feature). A pack's new biome is left out, with the tag entries
and climate points that name it. Dimension types are reported, not
applied: each level keeps its vanilla build height for now.

Nothing calls it yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 18:12:52 -05:00
ThatCakeIsAPieandClaude Opus 5.5 25662576ff Worldgen: the generator loads a world's data packs over the bundles
GeneratorData::load_with reads every bundle through WorldgenOverrides
(the loader threads included), so a data pack's noises, density
functions, noise settings, rules, carvers, features, structures and
biome changes reach generation. Pack tag files join the prepared tags;
a tag that names something missing is left out with a warning, as
vanilla's TagLoader does, where the prepared tags alone must still all
resolve. A pack's level stem replaces the world preset's dimension.
Noise settings that reach past the dimension's build height fill only
the part inside it (NoiseSettings.clampToHeightAccessor), and the
generator says so, since every level keeps its vanilla height for now.

load is load_with with no packs, so vanilla generation is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 18:12:52 -05: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
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
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
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
degepieandClaude Opus 5.5 66e1fc5079 Credit the curing player of a zombie villager on the server thread
ZombieVillager.mobInteract records the interacting player as the
conversion starter and finishConversion credits the player
level.getPlayerByUUID finds: the ZOMBIE_VILLAGER_CURED gossip and the
cured_zombie_villager trigger. Horizon used the server Game's own
level.player_uuid for both, so on the server thread (and for LAN guests)
the gossip went to a UUID nobody trades as and the player got no
discount and no Zombie Doctor (#3306).

The starter is now the acting player, and the cure credits the host or
the placed guest in this level with that UUID. lan_e2e gains a
cure-discount check: host and guest each cure one and pay 14 wheat
instead of 20.

Fixes #3306.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-10-29 02:27:31 +05: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
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 598668e4bc Far terrain: stop decoding saved chunks for every distant column
SavedLodSampler::grid put saved chunks over every far tile of 16 blocks
a column or finer: for each column it opened the region file and
decoded the whole saved chunk, and each sampling thread kept only 128.
In a save with many thousands of chunks, the far view stalled for many
minutes after its first one.

- SaveDir keeps which chunks each region file holds (10 s), so columns
  over unsaved ground no longer open region files.
- Decoded saved chunks are shared by the sampling threads (1024, 30 s).
- With Reduced far detail, tiles coarser than 4 blocks a column (about
  1 km out and beyond) keep the generator's terrain, which saved chunks
  were made from; edits that far away show when their tiles refine on
  approach. Full keeps saved chunks up to 16 blocks a column, as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:57:22 -05:00
ThatCakeIsAPieandClaude Opus 5.5 a1a249e2f6 Far tiles: approximate only with Reduced far detail
The previous two commits trade the exact far tile for speed in wide
cells. They now apply only with Reduced far detail (FarDetail::Reduced,
which already "changes the picture"); Full keeps the exact tiles.

- GeneratorWorker::lod_grid takes `approximate`: one biome per 4x4
  columns, the coarser vertical lattice, and density only around the
  surface (with shared 2D planes) happen only when it is set. Without it
  the tile is sampled as before.
- LodSampler::set_approximate, and Lod::set_reduced_detail sets it for
  the sampling threads; tiles sampled after a change follow it.
- lod_grid_bench --approximate samples as Reduced does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:57:21 -05:00
ThatCakeIsAPieandClaude Opus 5.5 eb5e0bb914 Far tiles: sample density only around the surface, share 2D planes
lod_grid sampled the density lattice over the whole build height. It now
finds each tile's surface band on a 64-block lattice made of the fine
lattice's points, then samples the fine lattice from 64 below the lowest
surface to 128 above the highest (and above sea level); below the band a
column is one span of the default block. Thin parts above an air sample
of the coarse lattice (floating islands, overhang lips) can be missed.

Most of a far tile's density work is 2D noise (y-fixed slices), which
the two passes would each compute: SamplerContext::with_planes keeps a
y-fixed slice's plane for its x/z lattice, so the band pass reuses the
coarse pass's. Opt-in, and only far sampling turns it on.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:57:21 -05:00
ThatCakeIsAPieandClaude Opus 5.5 d93c3cdd82 Far tiles: one biome per 4x4 columns, a coarser vertical lattice
Approximations for wide far cells, where they are hard to see at their
distance:

- One biome for 4x4 of a tile's columns, the first one asked (its
  surface's), in cells up to 64 blocks wide: far cells don't show a biome
  change a few blocks down, and climate changes over hundreds of blocks.
  Painting asks through the biome zoom, which strays into neighbouring
  quarts; per quart, those strays were most of a tile's climate samples.
- The interpolated density on a vertical lattice of 16 blocks for cells 32
  blocks wide or more, and of 32 from 128, instead of 8.

Distant tiles' biomes and some surface blocks differ from the per-quart
answers, and in the widest tiles the coarser lattice moves the surface.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:57:20 -05:00
ThatCakeIsAPieandClaude Opus 5.5 91ed783e8d Far tiles: resolve each biome quart once per tile
lod_grid paints eight depths of every far column through the surface
rule, and each depth asked the biome resolver again: a full climate
sample and parameter search per query, though neighbouring columns and
depths share quarts. The tile now keeps the answers per quart
(Biomes::Memo), for painting and for the columns' surface biome.

lod_grid_bench, vanilla seed 1234: surface heights, blocks and biomes
are the same at every level. At level 6 (64 blocks a column) some
painted blocks below the surface differ: the tile's cached sampler
context answers a repeated quart from what it sampled before, so the
order of queries mattered there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:57:19 -05:00
ThatCakeIsAPieandClaude Opus 5.5 bc3a724c01 lod_grid_bench: far terrain sampling cost per tile level
Times GeneratorWorker::lod_grid as the client's far terrain calls it (16 x
16 columns a tile) for each tile level, with two checksums: everything the
tiles hold (an exact speedup keeps it) and the surface and water heights
alone (the terrain's shape, which an approximation of materials or biomes
keeps). --dump writes each column's height, block and biome.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:57:19 -05:00
Lyle ColeandClaude Opus 5.5 51244a64f6 Decoration: column scans, per-chunk heightmap reads, zoom-free uniform biomes
Three read paths in decoration:
- Speleothem clusters (and large dripstone) scan each column up and down
  for the floor and ceiling, up to 512 blocks, one region read per block.
  FeatureWorld::scan_column walks a chunk's column directly: an all-air
  section is tested once (the predicate is a pure state test), a mixed
  one is read without a section lookup per block. Same blocks, same stop.
- OreFeature's any_height_at_least read each column of its square through
  a region lookup and a checked heightmap read; now each chunk's part of
  the square is read from its heightmap in one pass (any column will do,
  so order is free).
- Placement biome checks ran the biome zoom (eight hashed corner
  distances) for every position. When the 2x2x2 quarts the zoom picks
  from lie in biome sections that each hold one biome, the same one, the
  answer is that biome whatever the zoom picks; per-section uniformity is
  worked out once per region.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:57:18 -05:00
Lyle ColeandClaude Opus 5.5 1d25065363 Decoration: cut per-region and per-shape-update setup
Three costs that came before any feature work:
- the biomes present around the chunk sorted every quart of the nine
  chunks' columns into a BTreeSet; a bitset gives the same ids in the same
  ascending order;
- every updateShape call built and dropped a whole Ticks (tick maps,
  deadline index) just to capture the ticks it schedules; capturing
  leaves the rest untouched, so the region keeps one;
- the nine stand-in chunks left in the proto chunks while the region
  holds their terrain were allocated at full height; one section does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:57:18 -05:00
ThatCakeIsAPieandClaude Opus 5.5 22f0e30d07 Block reads: inline the region-to-palette path
A block read in decoration (and the surface stage's reads) went through
out-of-line calls: the region's chunk lookup, TerrainChunk::block,
TerrainBlocks::block and BlockSection::get, whose io::Result error path
kept it from inlining. They now inline into the feature code (the fast
build has no cross-crate LTO, so the core accessors need #[inline]), and
TerrainBlocks reads the section through an infallible accessor: its index
is a local index, always below 4096. #1790 takes the division out of
PackedStorage::read; this leaves storage alone.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:57:17 -05:00
Lyle ColeandClaude Opus 5.5 5a75089f61 Biome stage: biome ids by bitset, names by a last-entry memo
Two lookups in the biome stage:
- the biome ids present around the chunk were gathered by inserting
  every quart of the nine neighbour chunks into a BTreeSet; a bitset
  gives the same ids in the same ascending order;
- each quart's biome name was looked up in the name -> id map. Names
  point into the biome source's parameter list and neighbouring quarts
  mostly resolve to the same entry, so the last entry's id is kept and
  the map is asked only when the name changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:57:15 -05:00
Lyle ColeandClaude Opus 5.5 483ad5209c Biome stage: build free neighbour biome chunks before waiting
Each chunk needs the biome chunks of its 3x3 neighbourhood, built once by
whichever thread gets there first (per-chunk work lock). With several
workers on neighbouring chunks, a thread often blocked on a neighbour
another thread was building while other neighbours were still unbuilt.
The stage now first builds (or reads) every neighbour whose lock is free
(try_lock), then waits for the ones still being built. Each biome chunk
is still built exactly once, deterministically; only the order of the
work changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:57:15 -05:00
Lyle ColeandClaude Opus 5.5 91369d11c5 Structure column checks: sample the column's climate in one volume
couldStructureExistInColumn (jigsaw projection, onTopOfChunkCenter,
temples, mansions) samples the column through createResolverForChunk:
one bulk climate volume of 1 x n x 1 quarts, then each quart from the
bottom. Horizon took one scalar climate sample per quart instead, and a
column with no valid biome walks all of them, so a few such start chunks
held most of the structure stage while neighbours waited on them. The
column is now one prepared volume through the biome stage's resolver
path, as vanilla does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:57:14 -05:00
ThatCakeIsAPieandClaude Opus 5.5 82eeba7753 Structure checks: keep each worker's single noise-biome samples
Structure placement asks the biome of one quart at a time: several
structures test the same quarts of a start chunk, and column checks walk
whole columns when no quart matches. Each answer is a full climate sample
and parameter search. Workers now keep the answers, up to 65536 quarts;
the generator is seed-bound, so they cannot go stale.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:57:14 -05: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
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
Jose Gil c9bd057d70 feat(launcher): add Spanish language support 2026-10-28 15:43:54 -04:00
Jose Gil 8339f6d12f fix(launcher): sync clipboard on Linux Wayland and X11 2026-10-28 15:34:15 -04:00
degepieandClaude Opus 5.5 de25583ee5 perf_bench: compile the driver mod with JAVA_HOME's javac
The vanilla runs start `$JAVA_HOME/bin/java`, but the driver mod was
built with whatever `javac` came first on PATH. With JAVA_HOME on a JDK 25
and an older default JDK on PATH (21 on Ubuntu 24.04, for example),
`javac --release 25` fails with "release version 25 not supported" before
any game starts. Use JAVA_HOME's javac when JAVA_HOME is set, as the java
lookups below already do; without it, PATH is used as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-10-29 00:21:18 +05: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
ThatCakeIsAPieandClaude Opus 5.5 24e316855e Create World: the Data Packs screen
The More tab's Data Packs button opens vanilla's PackSelectionScreen (the
resource pack screen's port, titled dataPack.title) on a new mcworld-
folder: its packs, with their icons, and the built-in one, re-listed when
the folder changes. Create New World copies the folder's packs into the
world's datapacks/ and writes datapacks.json with the selection, as
CreateWorldScreen.copyTempDataPackDirToNewWorld does. Cancel deletes the
folder. Worlds made without opening the screen are unchanged.

The pack screen parity harness also runs the screen under dataPack.title.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 12:50:12 -05:00
Hector Eccles e41443c64c fix: address review findings
- palette lookups only match live slots, not freed ones still on the free list (#3253)
2026-10-28 13:42:04 -04: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
Hector EcclesandOpenCode d46bed5c36 perf: hash the world's integer bookkeeping maps with Fx
World.modified, inhabited, chest_openers and block_names used std SipHash
on small integer keys, and modified/inhabited rehash as they grow while an
area unloads. Use the existing crate::fast_hash maps; none of their
iterations is order-sensitive (save order comes from the batch list and
ChunkExtras::block_entity_order). chunks_with_block_entities takes the
names map by hasher so the faster map still fits.

BlockEntities::map stays std HashMap: crate::lan::chunks::body takes the
default-hasher map by type and lan/** is outside this change.

Co-Authored-By: OpenCode (DeepSeek V4.1 Flash) <[email protected]>
2026-10-28 11:07:24 -04:00
Hector EcclesandOpenCode 9b9d045150 perf: find section palette ids with a scan and a last-state cache
BlockSection kept a HashMap of state ids per section (24 tables and
allocations per proto chunk) and hashed on every worldgen write. Resident
palettes are almost always tiny, and the resident container already scans
them; use the same scan with a one-entry last lookup so a run of one state
is a compare. set/compact behaviour and the palette, counts and free list
are unchanged.

Co-Authored-By: OpenCode (DeepSeek V4.1 Flash) <[email protected]>
2026-10-28 11:07:24 -04:00
Hector EcclesandOpenCode 1e5cc9031f perf: share the multiply-shift index between paletted storage and PackedStorage
PackedStorage divided by its per-word width on every read and write, while
PalettedContainer already used an exact multiply-shift. Move MAGIC/locate/
mask into storage.rs and use them from both; only storages past 16,384
entries keep the division path.

Co-Authored-By: OpenCode (DeepSeek V4.1 Flash) <[email protected]>
2026-10-28 11:07:24 -04:00
Hector EcclesandOpenCode ee351caed8 perf: mark only the distinct sections a block write dirties
A write looped 27 block offsets through the dirty-section map; all but a
few hashed the same keys. Mark the exact section set instead: one key in
the interior, the adjacent section on a section face, a neighbour column
on a chunk border, and the diagonal where both borders meet (at most eight
keys). The mesher still sees the same 3x3x3 neighbourhood.

Co-Authored-By: OpenCode (DeepSeek V4.1 Flash) <[email protected]>
2026-10-28 11:07:24 -04: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
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
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
Shreyas 380d71d8a8 lists: fall back to offline resolution when services do not answer in tests 2026-10-28 16:31:23 +05:30
Shreyas 0543cf0345 Select the picked hotbar slot in singleplayer and LAN
Preserve the guest's updated hotbar selection when restoring the host
player state after an action. The previous restore sequence discarded
the guest's new selection while keeping the inventory changes.

The held-slot response now uses the selected slot produced by the pick,
so integrated-server and LAN players select the picked block or spawn
egg. Keep the host's selection independent of the guest's selection.

Vanilla reference: ServerGamePacketListenerImpl.tryPickItem,
Minecraft.pickBlockOrEntity, and ClientPacketListener.handleSetHeldSlot.

Fixes #3031
2026-10-28 16:00:22 +05:30
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
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 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 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 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
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
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
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 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
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 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 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
Ludovic Marie 50ebc9de60 Merge remote-tracking branch 'origin/main' into cg24/f2875
# Conflicts:
#	README.md
2026-10-27 22:27:57 +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
Ludovic MarieandClaude Opus 5.5 8237c15741 Merge bubble-column-entities (#2723) into flowing-water-mobs
Both add a LAN check at the same place.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:26:58 +01:00
Ludovic MarieandClaude Opus 5.5 b4de9dc23c Leave the current's push to main's
main pushes mobs with the fluid current since #2554 (kacpherek, in
update_fluids, with Entity.isPushedByFluid: fish, axolotls, turtles,
frogs and swimming drowned stay put); with both, a mob was pushed twice and
the water creatures were pushed too. The flow-push LAN check stays: it
covers main's push.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:26:24 +01:00
Ludovic MarieandClaude Opus 5.5 4a4cad6864 Merge main into flowing-water-mobs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:26:24 +01:00
Ludovic MarieandClaude Opus 5.5 75c149afde Leave the bubble column push to main's
main pushes mobs in a bubble column since #2555 (kacpherek, Entity's
applyEffectsFromBlocks after the move); with both, a mob got the push
twice. The bubble-column LAN check stays: it covers main's push.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:26:17 +01:00
Ludovic MarieandClaude Opus 5.5 6f785d1a28 Merge main into bubble-column-entities
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:26:11 +01:00
Ludovic MarieandClaude Opus 5.5 e1d825312f Read the flat world's horizon from WorldGen's preset
main's dark disc and void fog read WorldGen.flat, which is now the preset
(seen by sirrayi's review).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:25:52 +01:00
Ludovic MarieandClaude Opus 5.5 5ea61ed3d8 Merge main into world-types
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:25:45 +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
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 3dc958611c Fire recipe_crafted when a crafting, stonecutter or smithing result is taken
Vanilla's ServerPlayer.triggerRecipeCrafted passes the recipe and the stacks that went in; ResultSlot, StonecutterMenu and SmithingMenu award it. This lets trim_with_any_armor_pattern and craft_decorated_pot_using_only_sherds complete, and smithing and stonecutter results now unlock their recipe too.
2026-10-27 22:02:48 +01:00
Kacpherek 33c15a6d79 Fire player_generates_container_loot when a loot roll is opened
Vanilla's RandomizableContainer.unpackLootTable fires it for chests, trapped chests and chest minecarts, and BrushableBlockEntity does for the first brush of suspicious sand or gravel. This lets loot_bastion and salvage_sherd complete; opening marks the roll unpacked so it fires once.
2026-10-27 22:02:31 +01:00
Kacpherek f6209003de Fire summoned_entity for built golems and the respawned dragon
Vanilla fires SUMMONED_ENTITY in CarvedPumpkinBlock.spawnGolemInWorld for players within 5 blocks of the new golem, and in EnderDragonFight.setRespawnStage(END) for the new dragon's fight players. This lets summon_iron_golem and respawn_dragon complete.
2026-10-27 22:02:13 +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 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 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
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
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
iLostName 66b705cec3 sounds: list each namespace's sound files once instead of a lookup per sound 2026-10-27 14:52:56 +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
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 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
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
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
Senjin fea8346f23 Merge remote-tracking branch 'origin/main' into hdr-f3 2026-10-27 13:10:29 +01:00
Ludovic MarieandClaude Opus 5.5 ed01a93df6 Merge main into fix/world-types
main's Superflat Customize screen (#2214) still set the removed WorldOptions.flat: it now picks WorldPreset::Flat,
and the agent's customize-flat state reads the preset. structure_mobs.rs's test passes the new preset argument
(minecraft:normal) to VanillaTerrain::for_dimension.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 12:39:11 +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
Claude Codeandminimax-m3.1-flash 25b84dfc2e Format the world preset test
rustfmt keeps the registry read on one line.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 16:11:14 +05:00
Claude Codeandminimax-m3.1-flash d98dc8edc9 Read the test registry without the explicit deref
clippy's explicit_auto_deref asks for the LazyLock itself, which
derefs to the registry the generator data keeps.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 16:11:14 +05:00
Claude Codeandminimax-m3.1-flash bf05176692 Add the amplified, large-biome and single-biome world types
The create screen only offered Default and Superflat, so the amplified
world type of #1873 could not be chosen at all, and the generator
always built the overworld's noise settings.

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

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 16:11:14 +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
levzzzandminimax-m3.1-flash 4a9bb0ce17 Format the branch with the CI's rustfmt
The CI's rustfmt (stable) writes these signatures on one line; the local
toolchain's rustfmt split them, so the check job failed on the formatting
alone.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 16:06:37 +05:00
levzzz cd5216e012 Merge remote-tracking branch 'upstream/main' into fix/flowing-water-mobs 2026-10-27 15:38:53 +05:00
levzzzandminimax-m3.1-flash 786c2482a1 Carry mobs along with a fluid current
A mob standing in flowing water was not carried along by it: the fluid machinery that computes a current (horizon-world's entity_fluid, the port of vanilla's EntityFluidInteraction) was wired to the player, the dropped items, the arrows and the primed TNT, but the mobs use a reduced fluid model of their own and never felt a current.

The mobs now run the same per-tick fluid update as the other bodies (Entity.updateFluidInteraction): the flow of the fluid they stand in is accumulated over the blocks of their box and added to their velocity, with the water scale 0.014 and the lava scale 0.007 (0.00233 without the fast lava dimension attribute), the current of a player being the average of the flow vectors and of any other body its direction.

The new flow-push LAN case puts a mob in a flowing water block and a second one in a pool of still sources, and checks that only the mob in the current is carried along.

The branch also drops the unreachable second "minecraft:rabbit" arm in crates/horizon-client/src/entity_nbt.rs, which clippy rejects upstream.

Fixes #2549

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 15:38:51 +05: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
levzzzandminimax-m3.1-flash 0cac3a9153 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:15:54 +05: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
levzzz 9a84148376 Merge remote-tracking branch 'upstream/main' into fix/bubble-column-entities 2026-10-27 14:57:01 +05:00
levzzzandminimax-m3.1-flash 5678030da2 Carry mobs up and down with a bubble column
A mob standing in a bubble column was not moved by it: the block's
`entityInside` had no counterpart on the entity side, so only the local
player (whose motion code has it) and the arrows were affected. The mobs
now get the same push vanilla gives them in `BubbleColumnBlock.entityInside`
-> `Entity.onAboveBubbleColumn` / `Entity.onInsideBubbleColumn`: the body's
velocity along the vertical axis is raised by 0.1 (0.06 when the block above
the column is solid) and capped at 1.8 (0.7), or lowered by 0.03 and floored
at -0.9 (-0.3) for a column that drags down, and the fall distance is reset
inside a column.

The new `bubble-column` LAN case puts a mob in a soul sand column and a
second one in plain water, and checks that only the mob in the column is
carried up.

Fixes #2546

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 14:57:01 +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
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
247 changed files with 13439 additions and 2172 deletions

No files matched your search

+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
+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).
+2 -2
View File
@@ -31,7 +31,7 @@ Horizon is Minecraft: Java Edition 26.3 rewritten in Rust: a native game that re
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)) and a GPU with Vulkan on Linux and Windows, or any Apple Silicon Mac. Nothing else: with no Minecraft on your computer, Play downloads 26.3 and the Java it needs from Mojang for your account, as the official launcher does, then prepares it once. An install you already have (the official launcher, Prism, MultiMC and other launchers all work) is used instead. Step by step, with macOS's first-open prompt and the first launch: [Install and first launch](https://playhorizon.dev/docs/install).
**You need** a Microsoft account that owns 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>
@@ -46,7 +46,7 @@ The [documentation](https://playhorizon.dev/docs/) covers each of these.
- **The game:** singleplayer with vanilla's world generation, mobs, villagers, raids, redstone, every command, the HUD and the recipe book, on saves in vanilla's format. Worlds from 1.8 to 26.2 are upgraded as vanilla does. [Status table](#project-status).
- **Horizon's own extras:** new menus (vanilla's are one setting away), graphics presets, distant terrain up to 1024 chunks, controller support, optional zoom, minimap and more, all off by default where they would change the vanilla look. See [Settings](https://playhorizon.dev/docs/settings).
- **Resource packs and data packs:** resource packs work, and data packs load functions, predicates and item modifiers.
- **Resource packs and data packs:** resource packs work, and data packs load functions, predicates, item modifiers and world generation (vanilla's heights and biomes for now).
- **Playing with others:** invite friends from the pause menu's Friends button with a code, Open to LAN, and joining servers, online-mode ones with the Microsoft account the launcher signs in with. See [Multiplayer](https://playhorizon.dev/docs/multiplayer).
- **Mods:** Horizon has its own mods, installed from the launcher. The official ones (Zoom, FPS Counter, Keystrokes and Minimap) are native Rust that the launcher builds into your game, with no Rust to install; older WebAssembly mods still run while they move. Java mod jars do not run in Horizon. See [Mods and data packs](https://playhorizon.dev/docs/mods).
+102 -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() {
@@ -351,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));
}
}
+7
View File
@@ -266,6 +266,13 @@ mod tests {
assert_eq!(en.get("horizon.options.controller"), Some("Controller"));
let ru = Language::load(&[&empty], "ru_ru");
assert_eq!(ru.get("horizon.options.controller"), Some("Геймпад"));
// Every bundled string carries both languages: a key added to one file
// and forgotten in the other shows as a key name on screen.
for key in en.map().keys() {
assert!(ru.get(key).is_some(), "{key} has no ru_ru value");
}
assert_eq!(en.get("horizon.pages.mods"), Some("Mods"));
assert_eq!(ru.get("horizon.pages.mods"), Some("Моды"));
}
#[test]
@@ -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 ---------------------------------------------
+30 -25
View File
@@ -403,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 {
@@ -588,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(), "/");
@@ -256,6 +256,10 @@ impl Biomes {
.copied()
.unwrap_or(0)
}
/// Whether the game has the biome `name`.
pub fn contains(&self, name: &str) -> bool {
self.index.contains_key(name)
}
pub fn get(&self, id: u16) -> &BiomeInfo {
&self.list[usize::from(id).min(self.list.len() - 1)]
}
@@ -654,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() {
@@ -672,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,
@@ -697,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
@@ -2258,6 +2273,69 @@ 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 {
+35 -7
View File
@@ -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:?}");
}
}
+59 -3
View File
@@ -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).
@@ -42,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`.
@@ -378,6 +417,58 @@ impl BlockEntity {
}
pub use crate::providers::Providers;
/// 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")
}
/// 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,
};
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,
}
}
/// `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
}
}
/// `AbstractFurnaceBlockEntity.createExperience`: `count * experience`, floored, and one
/// more with the chance of what is left over (a level random draw only when there is some).
pub fn create_experience(
+288 -1
View File
@@ -2,7 +2,10 @@
//! the parts of them commands use: `.mcfunction` functions (with macros and
//! line continuation), function tags and predicates.
use crate::zip::Zip;
use horizon_world::nbt::Tag;
use horizon_world::{
nbt::Tag,
worldgen::overrides::{self, PackFile, WorldgenOverrides},
};
use std::{
collections::BTreeMap,
path::{Path, PathBuf},
@@ -125,6 +128,114 @@ pub fn discover(world: &Path) -> Vec<DataPack> {
packs
}
// ---- Create World ------------------------------------------------------------
/// Create World's Data Packs screen (`PackSelectionScreen` on vanilla's
/// `mcworld-` folder): the folder's packs (`PackSource.WORLD`) and the
/// built-in one (`resources` holds its `pack.png`), which can be unselected
/// and stays at the bottom. Horizon has no experiments, so their feature
/// packs are not offered.
pub fn screen_packs(dir: &Path, resources: &Path) -> Vec<crate::pack_screen::PackInfo> {
use crate::{
pack_screen::{Compat, PackInfo, Source, load_icon},
packs::Position,
text::{Style, Text},
};
let format = horizon_core::version::DATA_PACK_FORMAT;
let mut infos: Vec<PackInfo> = crate::packs::folder(dir, format)
.into_iter()
.map(|p| PackInfo {
icon: p.icon_png().as_deref().and_then(load_icon),
id: p.id,
title: p.title,
description: p.raw_description,
description_style: p.description_style,
source: Source::World,
compat: p.compatibility,
required: false,
position: Position::Top,
fixed: false,
})
.collect();
infos.push(PackInfo {
id: "vanilla".into(),
title: Text::translate("dataPack.vanilla.name", &[]),
description: Text::translate("dataPack.vanilla.description", &[]),
description_style: Style::default(),
source: Source::BuiltIn,
compat: Compat::Compatible,
icon: std::fs::read(resources.join("pack.png"))
.ok()
.as_deref()
.and_then(load_icon),
required: false,
position: Position::Bottom,
fixed: false,
});
infos
}
/// A new empty folder for Create World's packs
/// (`Files.createTempDirectory("mcworld-")`).
pub fn temp_folder() -> std::io::Result<PathBuf> {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let dir = std::env::temp_dir().join(format!("mcworld-{}-{nanos}", std::process::id()));
std::fs::create_dir_all(&dir)?;
Ok(dir)
}
/// What's in a folder of packs, to notice packs added or removed while the
/// screen shows it (`PackSelectionScreen.Watcher`).
pub fn folder_stamp(dir: &Path) -> Vec<(String, Option<std::time::SystemTime>)> {
let mut stamp: Vec<_> = std::fs::read_dir(dir)
.into_iter()
.flatten()
.flatten()
.map(|e| {
let modified = e.metadata().and_then(|m| m.modified()).ok();
(e.file_name().to_string_lossy().into_owned(), modified)
})
.collect();
stamp.sort();
stamp
}
/// Create World's packs into the new world before it opens
/// (`copyTempDataPackDirToNewWorld`): every pack of `dir` goes to
/// `<world>/datapacks`, and `datapacks.json` enables those `selected`
/// (lowest priority first) and disables the rest.
pub fn install(dir: &Path, world: &Path, selected: &[String]) -> Result<(), String> {
let err = |p: &Path, e: std::io::Error| format!("{}: {e}", p.display());
let target = world.join("datapacks");
std::fs::create_dir_all(&target).map_err(|e| err(&target, e))?;
let mut disabled = Vec::new();
for pack in crate::packs::folder(dir, horizon_core::version::DATA_PACK_FORMAT) {
let (crate::packs::Source::Dir(from) | crate::packs::Source::Zip(from)) = &pack.source;
copy(from, &target.join(pack.id.trim_start_matches("file/")))?;
if !selected.contains(&pack.id) {
disabled.push(pack.id);
}
}
if !selected.iter().any(|id| id == "vanilla") {
disabled.push("vanilla".into());
}
let json = serde_json::json!({ "enabled": selected, "disabled": disabled });
let file = world.join("datapacks.json");
std::fs::write(&file, json.to_string()).map_err(|e| err(&file, e))
}
/// A file, or a folder and everything in it.
fn copy(from: &Path, to: &Path) -> Result<(), String> {
let err = |e: std::io::Error| format!("{} -> {}: {e}", from.display(), to.display());
if !from.is_dir() {
return std::fs::copy(from, to).map(drop).map_err(err);
}
std::fs::create_dir_all(to).map_err(err)?;
for e in std::fs::read_dir(from).map_err(err)?.flatten() {
copy(&e.path(), &to.join(e.file_name()))?;
}
Ok(())
}
// ---- functions ---------------------------------------------------------------
/// A `.mcfunction`: plain command lines and macro lines.
@@ -421,9 +532,138 @@ fn hash_normalize(s: &str) -> String {
}
}
/// The ids of `packs` (a world's, from `discover`) that are enabled, lowest
/// priority first: those `<world>/datapacks.json` lists as enabled, in its
/// order, then every pack it lists neither way, in name order.
pub fn enabled_ids(world: &Path, packs: &[DataPack]) -> Vec<String> {
let saved: Option<serde_json::Value> = std::fs::read(world.join("datapacks.json"))
.ok()
.and_then(|b| serde_json::from_slice(&b).ok());
let list = |key: &str| -> Vec<String> {
saved
.as_ref()
.and_then(|v| v[key].as_array())
.map(|a| {
a.iter()
.filter_map(|s| s.as_str().map(str::to_owned))
.collect()
})
.unwrap_or_default()
};
let disabled = list("disabled");
let mut enabled: Vec<String> = list("enabled")
.into_iter()
.filter(|id| packs.iter().any(|p| &p.id == id))
.collect();
for p in packs {
if !enabled.contains(&p.id) && !disabled.contains(&p.id) {
enabled.push(p.id.clone());
}
}
enabled
}
// ---- worldgen ----------------------------------------------------------------
/// The worldgen entries of `packs` (lowest priority first, the built-in pack
/// left out: the prepared bundles are it) for the chunk generator, and what
/// it leaves out, for the log. `biomes` says which biomes the game has: a
/// pack's new biome is left out (the game has no colours, sounds or id for it).
pub fn worldgen_overrides(
packs: &[&DataPack],
biomes: impl Fn(&str) -> bool,
) -> (WorldgenOverrides, Vec<String>) {
let mut out = WorldgenOverrides::default();
let mut not_read: BTreeMap<String, usize> = BTreeMap::new();
let mut warnings = Vec::new();
for pack in packs {
for file in pack.list_kinds(&overrides::PACK_KINDS) {
let read = || String::from_utf8(pack.read(&file)?).ok();
match out.add_file(&pack.id, &file, read) {
PackFile::Read | PackFile::Other => {}
PackFile::DimensionType { id, min_y, height } => warnings.push(format!(
"{}: dimension type {id} (min_y {min_y}, height {height}) is not used: \
the dimension keeps its vanilla build height",
pack.id
)),
PackFile::NotRead(kind) => {
*not_read.entry(format!("{}: {kind}", pack.id)).or_default() += 1
}
PackFile::Unreadable => warnings.push(format!("{}: {file}: unreadable", pack.id)),
}
}
}
warnings.extend(out.finish(biomes));
for (kind, files) in not_read {
warnings.push(format!("{kind}: {files} files the generator does not read"));
}
(out, warnings)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn enabled_packs_override_worldgen_in_order() {
let dir = std::env::temp_dir().join(format!("hz-datapack-worldgen-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let write = |pack: &str, p: &str, t: &str| {
let path = dir.join("datapacks").join(pack).join(p);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, t).unwrap();
};
for pack in ["a", "b", "off"] {
write(pack, "pack.mcmeta", "{}");
write(pack, "data/minecraft/worldgen/noise/cave.json", pack);
write(
pack,
"data/minecraft/tags/worldgen/biome/is_hill.json",
"{\"values\":[]}",
);
}
write("a", "data/minecraft/worldgen/biome/new_peaks.json", "{}");
write("a", "data/minecraft/worldgen/biome/plains.json", "{}");
write(
"a",
"data/minecraft/worldgen/world_preset/normal.json",
"{}",
);
write(
"b",
"data/minecraft/dimension_type/overworld.json",
"{\"min_y\":-64,\"height\":2096}",
);
// `b` is enabled first, so `a` (enabled after it) wins.
std::fs::write(
dir.join("datapacks.json"),
"{\"enabled\":[\"file/b\",\"file/a\"],\"disabled\":[\"file/off\"]}",
)
.unwrap();
let packs = discover(&dir);
let enabled = enabled_ids(&dir, &packs);
assert_eq!(enabled, ["file/b", "file/a"]);
let enabled: Vec<&DataPack> = enabled
.iter()
.map(|id| packs.iter().find(|p| &p.id == id).unwrap())
.collect();
let (o, warnings) = worldgen_overrides(&enabled, |b| b == "minecraft:plains");
assert_eq!(
o.entries("noises").collect::<Vec<_>>(),
[("minecraft:cave", "a")]
);
assert_eq!(
o.entries("biomes").collect::<Vec<_>>(),
[("minecraft:plains", "{}")]
);
let tag_packs: Vec<&str> = o.tags("biome_tags").map(|(_, p, _)| p).collect();
assert_eq!(tag_packs, ["file/b", "file/a"]);
assert_eq!(warnings.len(), 3, "{warnings:?}");
assert!(warnings[0].contains("height 2096"));
assert!(warnings[1].contains("minecraft:new_peaks"));
assert!(warnings[2].contains("worldgen/world_preset"));
let _ = std::fs::remove_dir_all(&dir);
}
use horizon_world::nbt::parse_snbt;
#[test]
@@ -566,4 +806,51 @@ mod tests {
assert_eq!(p.list_kinds(&kinds), wanted);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn create_world_installs_its_packs() {
let root = std::env::temp_dir().join(format!("hz-datapack-install-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let (dir, world) = (root.join("mcworld"), root.join("world"));
let write = |pack: &str, p: &str, t: &str| {
let path = dir.join(pack).join(p);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, t).unwrap();
};
write("fns", "pack.mcmeta", r#"{"pack":{"pack_format":121}}"#);
write("fns", "data/x/function/f.mcfunction", "say hi");
write("off", "pack.mcmeta", r#"{"pack":{"pack_format":121}}"#);
std::fs::create_dir_all(&dir).unwrap();
let mut zip = zip::ZipWriter::new(std::fs::File::create(dir.join("old.zip")).unwrap());
zip.start_file("pack.mcmeta", zip::write::SimpleFileOptions::default())
.unwrap();
std::io::Write::write_all(&mut zip, br#"{"pack":{"pack_format":48}}"#).unwrap();
zip.finish().unwrap();
let infos = screen_packs(&dir, &root);
let ids: Vec<&str> = infos.iter().map(|p| p.id.as_str()).collect();
assert_eq!(ids, ["file/fns", "file/off", "file/old.zip", "vanilla"]);
let compat: Vec<_> = infos.iter().map(|p| p.compat).collect();
use crate::pack_screen::Compat;
assert_eq!(
compat,
[
Compat::Compatible,
Compat::Compatible,
Compat::TooOld,
Compat::Compatible
]
);
let selected = ["vanilla", "file/old.zip", "file/fns"].map(String::from);
install(&dir, &world, &selected).unwrap();
let packs = discover(&world);
let ids: Vec<&str> = packs.iter().map(|p| p.id.as_str()).collect();
assert_eq!(ids, ["file/fns", "file/off", "file/old.zip"]);
let saved: serde_json::Value =
serde_json::from_slice(&std::fs::read(world.join("datapacks.json")).unwrap()).unwrap();
assert_eq!(
saved,
serde_json::json!({"enabled": selected, "disabled": ["file/off"]})
);
let _ = std::fs::remove_dir_all(&root);
}
}
@@ -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>;
+91 -5
View File
@@ -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 {
+150 -1
View File
@@ -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 _};
@@ -966,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> {
@@ -1059,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::*;
@@ -1221,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.
+161 -26
View File
@@ -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,7 +538,7 @@ 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>,
@@ -765,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 {
@@ -776,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,
@@ -853,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
@@ -890,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 {
@@ -1100,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);
@@ -1128,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,
@@ -2016,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,
@@ -2573,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(),
@@ -2661,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,
@@ -2821,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;
}
@@ -3206,6 +3271,19 @@ 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, marker: &std::path::Path) {
@@ -3222,6 +3300,14 @@ impl Renderer {
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)
@@ -3231,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.
@@ -3615,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,
@@ -3666,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: [
@@ -3706,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));
@@ -4208,6 +4308,7 @@ impl Renderer {
}
let [
entity_quads,
text_quads,
crumbling_quads,
translucent_crumbling_quads,
water_mask_quads,
@@ -4220,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
@@ -4298,7 +4400,7 @@ impl Renderer {
self.stars.prepare(
&mut self.uploads,
&mut encoder,
view_proj * rotation,
jittered * rotation,
brightness,
);
}
@@ -4471,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.
@@ -4622,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
@@ -4698,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);
@@ -4728,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);
@@ -4757,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, &[]);
@@ -4776,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 {
@@ -4802,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, &[]);
@@ -4820,6 +4935,7 @@ impl Renderer {
pass.draw_indexed(
0..swirl_quads * 6,
((entity_quads
+ text_quads
+ crumbling_quads
+ translucent_crumbling_quads
+ water_mask_quads)
@@ -4913,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,
);
}
@@ -5041,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);
@@ -5452,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;
@@ -5516,6 +5650,7 @@ mod tests {
(11, 2),
(15, 10),
(7, 3),
(35, 11),
(4, 4),
(12, 7),
]
@@ -5525,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>()
@@ -5533,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]
@@ -5704,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)
};
+122 -13
View File
@@ -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,17 +25,21 @@ 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 {
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,
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,
}
}
@@ -102,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,
@@ -130,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
},
@@ -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}");
}
}
@@ -1,13 +1,44 @@
//! Resolution scale and FXAA (Horizon settings): the world renders into its
//! Resolution scale, FXAA and TAA (Horizon settings): the world renders into its
//! own target at the scaled size, then one full-screen pass copies or
//! anti-aliases it onto the screen, under the hand and HUD. With neither on,
//! anti-aliases it onto the screen, under the hand and HUD. With none on,
//! the world renders straight to the screen.
//!
//! TAA jitters the world's projection by a sub-pixel offset each frame and blends
//! the frame into a history reprojected through the depth buffer: its motion
//! vectors are the camera's, so what moves on its own leans on the neighbourhood
//! clamp alone.
use glam::{DMat4, DVec3, Mat4, Vec2};
/// The TAA history format: 8 bits would band as frames accumulate.
const HISTORY_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
struct WorldTarget {
size: [u32; 2],
color: wgpu::TextureView,
depth: wgpu::TextureView,
group: wgpu::BindGroup,
taa: Option<History>,
}
/// TAA's two history textures at the output size (the world's, times the upscale), written
/// in turn: `resolve[i]` writes `history[i]` from the world and `history[1 - i]`; `show[i]`
/// copies `history[i]` to the screen.
struct History {
size: [u32; 2],
history: [wgpu::TextureView; 2],
resolve: [wgpu::BindGroup; 2],
show: [wgpu::BindGroup; 2],
}
/// `world_post.wgsl`'s `Taa`.
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct TaaUniform {
/// This frame's unjittered clip space to the previous frame's.
reproject: [[f32; 4]; 4],
/// xy: this frame's jitter in clip space; z: 1 when the history is unusable.
jitter: [f32; 4],
}
pub struct Post {
@@ -19,7 +50,21 @@ pub struct Post {
/// World resolution relative to the window (0.5..=2.0).
pub scale: f32,
pub antialias: bool,
pub taa: bool,
/// Output pixels per rendered pixel while TAA is on (1: native): the world renders
/// this much smaller and TAA rebuilds it at the resolution scale's size.
pub upscale: f32,
/// TAA runs this frame (`jitter`): not on the title screen or under a shader pack.
taa_on: bool,
target: Option<WorldTarget>,
taa_layout: wgpu::BindGroupLayout,
taa_pipeline: wgpu::RenderPipeline,
taa_uniform: wgpu::Buffer,
/// Frames jittered so far (the Halton index), and which history the next resolve writes.
taa_frame: u32,
/// The last TAA frame's unjittered `view_proj` and camera, `None` once a frame ran without it.
taa_previous: Option<(DMat4, DVec3)>,
taa_this_frame: TaaUniform,
}
impl Post {
@@ -49,15 +94,55 @@ impl Post {
},
],
});
let texture = |binding, sample_type| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type,
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
};
let float = wgpu::TextureSampleType::Float { filterable: true };
let taa_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Horizon TAA bindings"),
entries: &[
texture(0, float),
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
texture(2, float),
texture(3, wgpu::TextureSampleType::Depth),
wgpu::BindGroupLayoutEntry {
binding: 4,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
],
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Horizon post"),
bind_group_layouts: &[Some(&layout)],
..Default::default()
});
let pipeline = |entry: &str| {
let taa_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Horizon TAA"),
bind_group_layouts: &[Some(&taa_layout)],
..Default::default()
});
let pipeline = |entry: &str, layout: &wgpu::PipelineLayout, format| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Horizon post"),
layout: Some(&pipeline_layout),
layout: Some(layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_post"),
@@ -88,41 +173,102 @@ impl Post {
..Default::default()
});
Self {
copy: pipeline("fs_copy"),
fxaa: pipeline("fs_fxaa"),
copy: pipeline("fs_copy", &pipeline_layout, format),
fxaa: pipeline("fs_fxaa", &pipeline_layout, format),
taa_pipeline: pipeline("fs_taa", &taa_pipeline_layout, HISTORY_FORMAT),
taa_uniform: device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Horizon TAA"),
size: std::mem::size_of::<TaaUniform>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
}),
layout,
taa_layout,
sampler,
format,
scale: 1.0,
antialias: false,
taa: false,
upscale: 1.0,
taa_on: false,
target: None,
taa_frame: 0,
taa_previous: None,
taa_this_frame: bytemuck::Zeroable::zeroed(),
}
}
/// The world's rendered size and the size it is shown at (TAA's history).
fn sizes(&self, window: [u32; 2]) -> ([u32; 2], [u32; 2]) {
let output = window.map(|v| ((v as f32 * self.scale).round() as u32).clamp(1, 8192));
let upscale = if self.taa_on { self.upscale } else { 1.0 };
(
output.map(|v| ((v as f32 / upscale).round() as u32).max(1)),
output,
)
}
/// TAA's sub-pixel jitter this frame, as a clip-space matrix to multiply into the
/// world's projection (identity while `on` is false); `view_proj` is the unjittered
/// projection, relative to `camera`. Call it before `prepare`, once per frame.
pub fn jitter(&mut self, window: [u32; 2], on: bool, view_proj: Mat4, camera: DVec3) -> Mat4 {
self.taa_on = on && self.taa;
if !self.taa_on {
self.taa_previous = None;
return Mat4::IDENTITY;
}
self.taa_frame = self.taa_frame.wrapping_add(1);
let (size, _) = self.sizes(window);
// Halton (2, 3) over 8 frames per output pixel (FSR's count), in rendered
// pixels around the centre.
let phases = (8.0 * self.upscale * self.upscale).ceil() as u32;
let i = self.taa_frame % phases + 1;
let offset = Vec2::new(halton(i, 2), halton(i, 3)) - 0.5;
let jitter = offset * 2.0 / Vec2::new(size[0] as f32, size[1] as f32);
let current = view_proj.as_dmat4();
let (reproject, reset) = match self.taa_previous {
Some((previous, previous_camera)) => (
reprojection(previous, previous_camera, current, camera),
0.0,
),
None => (DMat4::IDENTITY, 1.0),
};
self.taa_previous = Some((current, camera));
self.taa_this_frame = TaaUniform {
reproject: reproject.as_mat4().to_cols_array_2d(),
jitter: [jitter.x, jitter.y, reset, 0.0],
};
Mat4::from_translation(jitter.extend(0.0))
}
/// The world's colour and depth targets this frame, or `None` to draw
/// it straight to the screen.
/// `always`: a target even at scale 1 without FXAA (a shader pack reads it).
/// `always`: a target even at scale 1 without anti-aliasing (a shader pack reads it).
pub fn prepare(
&mut self,
device: &wgpu::Device,
window: [u32; 2],
always: bool,
) -> Option<(wgpu::TextureView, wgpu::TextureView)> {
if self.scale == 1.0 && !self.antialias && !always {
if self.scale == 1.0 && !self.antialias && !self.taa_on && !always {
self.target = None;
return None;
}
let size = window.map(|v| ((v as f32 * self.scale).round() as u32).clamp(1, 8192));
if self.target.as_ref().is_none_or(|t| t.size != size) {
let extent = wgpu::Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
};
let make_tex = |label, format, usage| {
let (size, output) = self.sizes(window);
let history_size = self.taa_on.then_some(output);
if self
.target
.as_ref()
.is_none_or(|t| t.size != size || t.taa.as_ref().map(|h| h.size) != history_size)
{
let make_tex = |label, size: [u32; 2], format, usage| {
device.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
size: extent,
size: wgpu::Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
@@ -131,25 +277,68 @@ impl Post {
view_formats: &[],
})
};
let color = make_tex(
"Scaled world",
self.format,
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
)
.create_view(&Default::default());
// TEXTURE_BINDING: occlusion culling reads it.
let sampled =
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING;
let color = make_tex("Scaled world", size, self.format, sampled)
.create_view(&Default::default());
// TEXTURE_BINDING: occlusion culling and TAA read it.
let depth = make_tex(
"Scaled world depth",
size,
wgpu::TextureFormat::Depth32Float,
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
sampled,
)
.create_view(&Default::default());
let group = self.bind(device, &color);
let taa = history_size.map(|output| {
let history = [0, 1].map(|_| {
make_tex("TAA history", output, HISTORY_FORMAT, sampled)
.create_view(&Default::default())
});
let resolve = [0, 1].map(|i| {
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Horizon TAA"),
layout: &self.taa_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&color),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(&self.sampler),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::TextureView(&history[1 - i]),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::TextureView(&depth),
},
wgpu::BindGroupEntry {
binding: 4,
resource: self.taa_uniform.as_entire_binding(),
},
],
})
});
let show = [0, 1].map(|i| self.bind(device, &history[i]));
History {
size: output,
history,
resolve,
show,
}
});
// New textures: no history yet.
self.taa_this_frame.jitter[2] = 1.0;
self.target = Some(WorldTarget {
size,
color,
depth,
group,
taa,
});
}
self.target
@@ -180,10 +369,43 @@ impl Post {
})
}
/// Draw the scaled world onto `screen`.
pub fn finish(&self, encoder: &mut wgpu::CommandEncoder, screen: &wgpu::TextureView) {
/// Draw the scaled world onto `screen`, through TAA when this frame's `jitter` had it on.
pub fn finish(
&self,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
screen: &wgpu::TextureView,
) {
let Some(target) = &self.target else { return };
self.finish_with(encoder, screen, &target.group);
let Some(taa) = target.taa.as_ref().filter(|_| self.taa_previous.is_some()) else {
self.finish_with(encoder, screen, &target.group);
return;
};
let i = (self.taa_frame % 2) as usize;
queue.write_buffer(
&self.taa_uniform,
0,
bytemuck::bytes_of(&self.taa_this_frame),
);
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: &taa.history[i],
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Horizon TAA"),
color_attachments: &attachments,
..Default::default()
});
pass.set_pipeline(&self.taa_pipeline);
pass.set_bind_group(0, &taa.resolve[i], &[]);
pass.draw(0..3, 0..1);
drop(pass);
self.finish_with(encoder, screen, &taa.show[i]);
}
/// Draw what `group` binds (`bind`) onto `screen`.
@@ -216,3 +438,51 @@ impl Post {
pass.draw(0..3, 0..1);
}
}
/// This frame's clip space to the previous frame's, for camera-relative projections:
/// a point `camera - previous_camera` further from the previous camera than from this one.
fn reprojection(previous: DMat4, previous_camera: DVec3, current: DMat4, camera: DVec3) -> DMat4 {
previous * DMat4::from_translation(camera - previous_camera) * current.inverse()
}
/// The radical inverse of `i` in `base`: a low-discrepancy sequence in 0..1.
fn halton(mut i: u32, base: u32) -> f32 {
let (mut f, mut r) = (1.0, 0.0);
while i > 0 {
f /= base as f32;
r += f * (i % base) as f32;
i /= base;
}
r
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reprojection_finds_where_a_block_was_last_frame() {
let projection = DMat4::perspective_rh(1.2, 16.0 / 9.0, 1024.0, 0.05);
let previous = projection * DMat4::from_rotation_y(0.3);
let current = projection * DMat4::from_rotation_y(0.35);
let (previous_camera, camera) = (DVec3::new(10.0, 64.0, 5.0), DVec3::new(10.5, 64.2, 4.0));
let block = DVec3::new(14.0, 66.0, -8.0);
let clip = |m: DMat4, eye: DVec3| m * (block - eye).extend(1.0);
let now = clip(current, camera);
let then = reprojection(previous, previous_camera, current, camera) * (now / now.w);
let expected = clip(previous, previous_camera);
assert!((then.truncate() / then.w - expected.truncate() / expected.w).length() < 1e-9);
}
#[test]
fn jitter_stays_inside_the_pixel() {
for i in 1..=8 {
let (x, y) = (halton(i, 2), halton(i, 3));
assert!((0.0..1.0).contains(&x) && (0.0..1.0).contains(&y));
}
assert_eq!(
[halton(1, 2), halton(2, 2), halton(3, 2)],
[0.5, 0.25, 0.75]
);
}
}
+106 -4
View File
@@ -96,9 +96,9 @@ impl PresentMode {
}
}
/// The graphics API the renderer runs on. Only Windows has a choice: the
/// other systems always use their one backend (Vulkan on Linux, Metal on
/// macOS). Applies at the next start.
/// The graphics API the renderer runs on. Windows picks Vulkan, DirectX 12 or
/// OpenGL; Linux picks Vulkan or OpenGL; macOS always uses its one backend
/// (Metal). Applies at the next start.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum GraphicsApi {
/// Vulkan, then DirectX 12 (Windows), then OpenGL when neither has a surface, adapter or device.
@@ -116,6 +116,26 @@ impl GraphicsApi {
GraphicsApi::Dx12,
GraphicsApi::OpenGl,
];
/// The APIs this system offers, in the order the setting cycles through
/// them: DirectX 12 is Windows' third backend, and macOS has no choice.
pub fn choices() -> &'static [GraphicsApi] {
const WINDOWS: [GraphicsApi; 4] = [
GraphicsApi::Auto,
GraphicsApi::Vulkan,
GraphicsApi::Dx12,
GraphicsApi::OpenGl,
];
const LINUX: [GraphicsApi; 3] =
[GraphicsApi::Auto, GraphicsApi::Vulkan, GraphicsApi::OpenGl];
const MACOS: [GraphicsApi; 1] = [GraphicsApi::Auto];
if cfg!(windows) {
&WINDOWS
} else if cfg!(target_os = "macos") {
&MACOS
} else {
&LINUX
}
}
pub fn name(self) -> &'static str {
match self {
GraphicsApi::Auto => "auto",
@@ -200,6 +220,61 @@ impl FarDetail {
}
}
/// TAA upscaling: the world renders this much smaller than the resolution scale's size,
/// and TAA builds the full-size picture from the jittered frames (FSR's and DLSS's ratios).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum Upscaling {
#[default]
Native,
Quality,
Balanced,
Performance,
UltraPerformance,
}
impl Upscaling {
pub const ALL: [Upscaling; 5] = [
Upscaling::Native,
Upscaling::Quality,
Upscaling::Balanced,
Upscaling::Performance,
Upscaling::UltraPerformance,
];
pub fn name(self) -> &'static str {
match self {
Upscaling::Native => "native",
Upscaling::Quality => "quality",
Upscaling::Balanced => "balanced",
Upscaling::Performance => "performance",
Upscaling::UltraPerformance => "ultraPerformance",
}
}
pub fn label(self) -> &'static str {
match self {
Upscaling::Native => "Native",
Upscaling::Quality => "Quality",
Upscaling::Balanced => "Balanced",
Upscaling::Performance => "Performance",
Upscaling::UltraPerformance => "Ultra Performance",
}
}
/// Output pixels per rendered pixel, along each axis.
pub fn ratio(self) -> f32 {
match self {
Upscaling::Native => 1.0,
Upscaling::Quality => 1.5,
Upscaling::Balanced => 1.7,
Upscaling::Performance => 2.0,
Upscaling::UltraPerformance => 3.0,
}
}
fn parse(s: &str) -> Self {
Self::ALL
.into_iter()
.find(|u| u.name() == s)
.unwrap_or_default()
}
}
static REDUCED_SUGGESTED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
/// Whether the settings suggest Reduced far detail: set when the renderer
/// starts, on the GPUs `lod_capped` names (far terrain slows them most).
@@ -262,6 +337,10 @@ pub struct Graphics {
pub resolution_scale: u32,
/// FXAA over the world.
pub fxaa: bool,
/// TAA over the world (FXAA is off while it is on).
pub taa: bool,
/// TAA upscaling; anything but `Native` comes with `taa` on.
pub upscaling: Upscaling,
/// HDR output (HDR10, or scRGB), where the display path offers it.
pub hdr: bool,
/// HDR paper white (where SDR white sits), in nits.
@@ -299,6 +378,8 @@ impl Default for Graphics {
overlay: Overlay::Off,
resolution_scale: 100,
fxaa: false,
taa: false,
upscaling: Upscaling::Native,
hdr: false,
hdr_paper_white: 200,
hdr_peak: 1000,
@@ -361,6 +442,8 @@ impl Graphics {
"workers" => g.workers = number(g.workers).min(64),
"resolutionScale" => g.resolution_scale = number(g.resolution_scale).clamp(50, 200),
"fxaa" => g.fxaa = flag(g.fxaa),
"taa" => g.taa = flag(g.taa),
"upscaling" => g.upscaling = Upscaling::parse(value),
"hdr" => g.hdr = flag(g.hdr),
"hdrPaperWhite" => g.hdr_paper_white = number(g.hdr_paper_white).clamp(80, 480),
"hdrPeak" => g.hdr_peak = number(g.hdr_peak).clamp(200, 2000),
@@ -381,7 +464,7 @@ impl Graphics {
}
pub fn serialize(&self) -> String {
format!(
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nbatteryMode:{}\ngraphicsApi:{}\npickedShaderPack:{}\n",
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nbatteryMode:{}\ngraphicsApi:{}\npickedShaderPack:{}\n",
self.preset.name(),
self.lod_distance,
self.far_detail.name(),
@@ -406,6 +489,8 @@ impl Graphics {
},
self.resolution_scale,
bool_text(self.fxaa),
bool_text(self.taa),
self.upscaling.name(),
bool_text(self.hdr),
self.hdr_paper_white,
self.hdr_peak,
@@ -486,6 +571,21 @@ pub fn apply_preset(preset: Preset, s: &mut crate::settings::Settings) {
mod tests {
use super::*;
#[test]
fn the_api_choice_matches_the_system() {
let choices = GraphicsApi::choices();
if cfg!(windows) {
assert_eq!(choices, GraphicsApi::ALL);
} else if cfg!(target_os = "macos") {
assert_eq!(choices, [GraphicsApi::Auto]);
} else {
assert_eq!(
choices,
[GraphicsApi::Auto, GraphicsApi::Vulkan, GraphicsApi::OpenGl]
);
}
}
#[test]
fn round_trips() {
let g = Graphics {
@@ -506,6 +606,8 @@ mod tests {
overlay: Overlay::Graph,
resolution_scale: 75,
fxaa: true,
taa: true,
upscaling: Upscaling::Balanced,
hdr: true,
hdr_paper_white: 240,
hdr_peak: 1400,
+104 -37
View File
@@ -2,7 +2,7 @@
//! they show, in what groups, with what description and cost, and their
//! input. How they look comes from `graphics_style`.
use crate::{
graphics::{FarDetail, GraphicsApi, Overlay, Pacing, PresentMode, Preset},
graphics::{FarDetail, GraphicsApi, Overlay, Pacing, PresentMode, Preset, Upscaling},
graphics_style::{Cost, STYLE},
hud::{HudAtlas, HudBatch},
settings::{Opt, Settings},
@@ -36,6 +36,7 @@ pub enum H {
Overlay,
ResolutionScale,
Antialiasing,
Upscaling,
Hdr,
HdrPaperWhite,
HdrPeak,
@@ -221,7 +222,12 @@ pub fn rows(level: Level) -> Vec<Row> {
h(
H::Antialiasing,
Medium,
"FXAA smooths jagged edges of the world; slightly softer image.",
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.",
),
h(
H::Upscaling,
High,
"Renders the world smaller and rebuilds it with TAA. Faster, softer.",
),
h(
H::Hdr,
@@ -276,7 +282,7 @@ pub fn rows(level: Level) -> Vec<Row> {
h(
H::GraphicsApi,
None,
"Vulkan, or DirectX 12 where Vulkan fails. Next start.",
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.",
),
h(
H::UploadBudget,
@@ -291,8 +297,8 @@ pub fn rows(level: Level) -> Vec<Row> {
),
],
};
// Only Windows has more than one graphics API.
if !cfg!(windows) {
// Only Windows and Linux have more than one graphics API.
if cfg!(target_os = "macos") {
rows.retain(
|row| !matches!(row, Row::Setting(s) if s.item == Item::Horizon(H::GraphicsApi)),
);
@@ -303,9 +309,13 @@ pub fn rows(level: Level) -> Vec<Row> {
const UPLOAD_STEPS: [u32; 8] = [0, 256, 512, 1024, 2048, 4096, 8192, 16384];
/// Horizon's graphics screens supplement the vanilla options on Video Settings.
fn extra_rows(level: Level) -> Vec<Row> {
/// Upscaling is only there while TAA is, which does it.
fn extra_rows(level: Level, settings: &Settings) -> Vec<Row> {
let mut rows = rows(level);
rows.retain(|row| !matches!(row, Row::Setting(s) if matches!(s.item, Item::Vanilla(_))));
if !settings.horizon.taa {
rows.retain(|row| !matches!(row, Row::Setting(s) if s.item == Item::Horizon(H::Upscaling)));
}
// Drop headings whose settings were all on the parent screens.
let mut has_setting = false;
rows.reverse();
@@ -383,7 +393,15 @@ impl H {
n => labelled("Worker Threads", n),
},
H::ResolutionScale => labelled("Resolution Scale", format!("{}%", g.resolution_scale)),
H::Antialiasing => labelled("Anti-aliasing", if g.fxaa { "FXAA" } else { "Off" }),
H::Antialiasing => labelled(
"Anti-aliasing",
match (g.fxaa, g.taa) {
(_, true) => "TAA",
(true, false) => "FXAA",
(false, false) => "Off",
},
),
H::Upscaling => labelled("Upscaling", g.upscaling.label()),
H::Hdr => labelled("HDR", on_off(g.hdr)),
H::HdrPaperWhite => labelled("Paper White", format!("{} nits", g.hdr_paper_white)),
H::HdrPeak => labelled("Peak Brightness", format!("{} nits", g.hdr_peak)),
@@ -441,10 +459,25 @@ impl H {
H::Weather => g.weather = !g.weather,
H::AnimatedTextures => g.animated_textures = !g.animated_textures,
H::PresentMode => g.present_mode = step(&PresentMode::ALL, g.present_mode, back),
H::Antialiasing => g.fxaa = !g.fxaa,
// Off, FXAA, TAA.
H::Antialiasing => {
let i = if g.taa { 2 } else { usize::from(g.fxaa) };
let i = (i + if back { 2 } else { 1 }) % 3;
(g.fxaa, g.taa) = (i == 1, i == 2);
if !g.taa {
g.upscaling = Upscaling::Native;
}
}
// Upscaling is TAA's: picking a ratio turns it on.
H::Upscaling => {
g.upscaling = step(&Upscaling::ALL, g.upscaling, back);
if g.upscaling != Upscaling::Native {
(g.fxaa, g.taa) = (false, true);
}
}
H::Hdr => g.hdr = !g.hdr,
H::BatteryMode => g.battery = !g.battery,
H::GraphicsApi => g.api = step(&GraphicsApi::ALL, g.api, back),
H::GraphicsApi => g.api = step(GraphicsApi::choices(), g.api, back),
H::Pacing => {
g.pacing = match g.pacing {
Pacing::Efficient => Pacing::Precise,
@@ -481,7 +514,6 @@ impl Item {
Item::Horizon(H::Fog) => g.fog,
Item::Horizon(H::Weather) => g.weather,
Item::Horizon(H::AnimatedTextures) => g.animated_textures,
Item::Horizon(H::Antialiasing) => g.fxaa,
Item::Horizon(H::Hdr) => g.hdr,
Item::Horizon(H::BatteryMode) => g.battery,
Item::Vanilla(Opt::ViewBobbing) => s.view_bobbing,
@@ -570,6 +602,7 @@ impl Item {
pub enum Action {
ButtonClick,
Changed,
SliderDrag,
OpenAdvanced,
OpenSimple,
Done,
@@ -603,8 +636,8 @@ impl GraphicsUi {
fn bounds(h: f32) -> (f32, f32) {
(STYLE.band, h - STYLE.band)
}
fn content_height(level: Level) -> f32 {
extra_rows(level).iter().fold(4.0, |height, row| {
fn content_height(level: Level, s: &Settings) -> f32 {
extra_rows(level, s).iter().fold(4.0, |height, row| {
height
+ match row {
Row::Header(_) => STYLE.header_height,
@@ -616,17 +649,18 @@ impl GraphicsUi {
fn layout(
&mut self,
level: Level,
s: &Settings,
w: f32,
h: f32,
) -> (Vec<Widget>, Vec<(f32, &'static str)>, f32) {
let content = Self::content_height(level);
let content = Self::content_height(level, s);
self.clamp_scroll(h, content);
let (top, _) = Self::bounds(h);
let x = STYLE.row_x(w);
let mut y = top + 4.0 - self.scroll;
let mut widgets = Vec::new();
let mut headers = Vec::new();
for row in extra_rows(level) {
for row in extra_rows(level, s) {
match row {
Row::Header(name) => {
headers.push((y, name));
@@ -680,7 +714,7 @@ impl GraphicsUi {
in_world: bool,
) {
let (w, h) = (batch.width, batch.height);
let (widgets, headers, content) = self.layout(level, w, h);
let (widgets, headers, content) = self.layout(level, s, w, h);
let (top, bottom) = Self::bounds(h);
STYLE.background(batch, atlas, top, bottom, in_world);
let shown = |y: f32, height: f32| y >= top && y + height <= bottom;
@@ -690,7 +724,7 @@ impl GraphicsUi {
STYLE.header(batch, atlas, &Text::literal(name), x, y);
}
}
let descriptions: Vec<(Cost, &'static str)> = extra_rows(level)
let descriptions: Vec<(Cost, &'static str)> = extra_rows(level, s)
.into_iter()
.filter_map(|r| match r {
Row::Setting(s) => Some((s.cost, s.text)),
@@ -791,7 +825,7 @@ impl GraphicsUi {
drag: bool,
right: bool,
) -> Vec<Action> {
let (widgets, _, _) = self.layout(level, w, h);
let (widgets, _, _) = self.layout(level, s, w, h);
let (top, bottom) = Self::bounds(h);
if drag {
if let Some(item) = self.dragging
@@ -800,7 +834,7 @@ impl GraphicsUi {
.find(|wd| matches!(wd.target, Target::Item(i) if i == item))
{
item.set_slider(s, ui::slider_value(wd.x, wd.w, x));
return vec![Action::Changed];
return vec![Action::SliderDrag];
}
return Vec::new();
}
@@ -822,11 +856,11 @@ impl GraphicsUi {
if item.slider(s).is_some() {
self.dragging = Some(item);
item.set_slider(s, ui::slider_value(wd.x, wd.w, x));
vec![Action::SliderDrag]
} else {
item.cycle(s, right, framebuffer);
return vec![Action::ButtonClick, Action::Changed];
vec![Action::ButtonClick, Action::Changed]
}
vec![Action::Changed]
}
};
}
@@ -835,8 +869,8 @@ impl GraphicsUi {
pub fn release(&mut self) -> bool {
self.dragging.take().is_some()
}
pub fn scroll(&mut self, level: Level, h: f32, amount: f32) {
let content = Self::content_height(level);
pub fn scroll(&mut self, level: Level, s: &Settings, h: f32, amount: f32) {
let content = Self::content_height(level, s);
self.clamp_scroll(h, content);
self.scroll -= amount * 18.0;
self.clamp_scroll(h, content);
@@ -852,7 +886,7 @@ mod tests {
let mut s = Settings::default();
let mut ui = GraphicsUi::default();
let size = (480.0, 320.0);
let (widgets, _, _) = ui.layout(Level::Advanced, size.0, size.1);
let (widgets, _, _) = ui.layout(Level::Advanced, &s, size.0, size.1);
let button = widgets
.iter()
.find(|w| matches!(w.target, Target::Button(_)))
@@ -897,7 +931,7 @@ mod tests {
false,
false
),
vec![Action::Changed]
vec![Action::SliderDrag]
);
assert_eq!(
ui.click(
@@ -909,7 +943,7 @@ mod tests {
true,
false
),
vec![Action::Changed]
vec![Action::SliderDrag]
);
assert!(ui.release());
assert!(!ui.release());
@@ -917,45 +951,47 @@ mod tests {
#[test]
fn scrolling_stops_at_both_limits_before_layout() {
let s = Settings::default();
for level in [Level::Simple, Level::Advanced] {
let mut ui = GraphicsUi::default();
let h = 2.0 * STYLE.band + 10.0;
let (top, bottom) = GraphicsUi::bounds(h);
let max = GraphicsUi::content_height(level) - (bottom - top);
let max = GraphicsUi::content_height(level, &s) - (bottom - top);
assert!(max > 4.5);
for _ in 0..3 {
ui.scroll(level, h, 1000.0);
ui.scroll(level, &s, h, 1000.0);
assert_eq!(ui.scroll, 0.0);
}
for _ in 0..3 {
ui.scroll(level, h, -1000.0);
ui.scroll(level, &s, h, -1000.0);
assert_eq!(ui.scroll, max);
}
ui.scroll(level, h, 0.25);
ui.scroll(level, &s, h, 0.25);
assert_eq!(ui.scroll, max - 4.5);
ui.scroll(level, h, 1000.0);
ui.scroll(level, h, -0.25);
ui.scroll(level, &s, h, 1000.0);
ui.scroll(level, &s, h, -0.25);
assert_eq!(ui.scroll, 4.5);
}
}
#[test]
fn resizing_clamps_before_positioning_graphics_rows() {
let s = Settings::default();
for level in [Level::Simple, Level::Advanced] {
let mut ui = GraphicsUi::default();
ui.scroll(level, 2.0 * STYLE.band + 10.0, -1000.0);
ui.scroll(level, &s, 2.0 * STYLE.band + 10.0, -1000.0);
assert!(ui.scroll > 0.0);
let (widgets, headers, _) = ui.layout(level, 480.0, 4000.0);
let (widgets, headers, _) = ui.layout(level, &s, 480.0, 4000.0);
assert_eq!(ui.scroll, 0.0);
if let Some((y, _)) = headers.first() {
assert_eq!(*y, STYLE.band + 4.0);
}
let (expected, _, _) = GraphicsUi::default().layout(level, 480.0, 4000.0);
let (expected, _, _) = GraphicsUi::default().layout(level, &s, 480.0, 4000.0);
assert_eq!(
widgets.iter().map(|w| w.y).collect::<Vec<_>>(),
expected.iter().map(|w| w.y).collect::<Vec<_>>()
);
ui.scroll(level, 4000.0, -1000.0);
ui.scroll(level, &s, 4000.0, -1000.0);
assert_eq!(ui.scroll, 0.0);
}
}
@@ -963,7 +999,7 @@ mod tests {
#[test]
fn classic_submenus_only_add_horizon_settings_without_empty_groups() {
for level in [Level::Simple, Level::Advanced] {
let rows = extra_rows(level);
let rows = extra_rows(level, &Settings::default());
assert!(rows.iter().any(|row| matches!(row, Row::Setting(_))));
for (i, row) in rows.iter().enumerate() {
match row {
@@ -1051,6 +1087,37 @@ mod tests {
assert_eq!(s.horizon.upload_budget_kib, 0);
}
#[test]
fn antialiasing_cycles_off_fxaa_taa() {
let mut s = Settings::default();
let item = Item::Horizon(H::Antialiasing);
for label in ["FXAA", "TAA", "Off"] {
item.cycle(&mut s, false, [854, 480]);
assert_eq!(item.label(&s).plain(), format!("Anti-aliasing: {label}"));
}
item.cycle(&mut s, true, [854, 480]);
assert_eq!((s.horizon.fxaa, s.horizon.taa), (false, true));
// Upscaling needs TAA: a ratio turns it on, leaving TAA goes back to Native.
s.horizon.taa = false;
let upscaling = Item::Horizon(H::Upscaling);
upscaling.cycle(&mut s, false, [854, 480]);
assert_eq!(upscaling.label(&s).plain(), "Upscaling: Quality");
assert!(s.horizon.taa);
item.cycle(&mut s, false, [854, 480]);
assert_eq!(s.horizon.upscaling, Upscaling::Native);
// The Upscaling row is only on the screen while TAA is on.
let shown = |s: &Settings| {
extra_rows(Level::Advanced, s)
.iter()
.any(|row| matches!(row, Row::Setting(r) if r.item == upscaling))
};
assert!(!shown(&s));
s.horizon.taa = true;
assert!(shown(&s));
}
#[test]
fn entity_culling_is_opt_in_and_toggles_from_advanced_graphics() {
let mut s = Settings::default();
@@ -1067,7 +1134,7 @@ mod tests {
let mut ui = GraphicsUi::default();
let size = (480.0, 4000.0);
let (widgets, _, _) = ui.layout(Level::Advanced, size.0, size.1);
let (widgets, _, _) = ui.layout(Level::Advanced, &s, size.0, size.1);
let switch = widgets
.iter()
.find(|w| matches!(w.target, Target::Item(i) if i == item))
+152 -11
View File
@@ -1001,6 +1001,35 @@ impl HudBatch {
y: f32,
color: u32,
shadow: bool,
) -> f32 {
self.styled_text(atlas, text, x, y, color, shadow, false)
}
/// `text_styled` for text drawn in the world (`TextRenderable.render`
/// with `flat` false): each quad's `depth` is its z in text units, the
/// glyphs `Font.SHADOW_DEPTH` in front of their shadow, a bold copy 0.001
/// in front of its glyph and the lines 0.01 (`BakedSheetGlyph`), so they
/// do not fight over the same pixels.
pub fn text_styled_world(
&mut self,
atlas: &HudAtlas,
text: &crate::text::Text,
x: f32,
y: f32,
color: u32,
shadow: bool,
) -> f32 {
self.styled_text(atlas, text, x, y, color, shadow, true)
}
#[allow(clippy::too_many_arguments)]
fn styled_text(
&mut self,
atlas: &HudAtlas,
text: &crate::text::Text,
x: f32,
y: f32,
color: u32,
shadow: bool,
world: bool,
) -> f32 {
// StringDecomposer.iterateFormatted inside Font.drawInternal:
// `§` codes inside a span restyle what follows and are not drawn.
@@ -1011,7 +1040,13 @@ impl HudBatch {
let alpha = (color >> 24) as u8;
let mut glyphs = Vec::new();
let mut cursor = x;
let mut previous = None;
for (i, (ch, style)) in text.chars().enumerate() {
// Font.PreparedTextBuilder.accept: lines start 1 left of a
// sequence's first character. Language.getVisualOrder makes a
// sequence of each run of one style, which world text has.
let first = i == 0 || world && previous != Some(style);
previous = Some(style);
// The style's font (`Style.getFont`), the default one unless set.
let mut g = font.glyph_styled(ch, style);
// Font.StringRenderOutput.accept: obfuscated characters draw a
@@ -1032,7 +1067,7 @@ impl HudBatch {
style.is_italic(),
style.underlined == Some(true),
style.strikethrough == Some(true),
i == 0,
first,
advance,
));
cursor += advance;
@@ -1040,19 +1075,25 @@ impl HudBatch {
self.patches.extend(font.take_patches());
let white = font.white();
let passes: &[bool] = if shadow { &[true, false] } else { &[false] };
// ARGB.scaleRGB(color, 0.25): the shadow's colour.
let shade = |c: u32| {
let b = c.to_le_bytes();
rgba(
(f32::from(b[0]) * 0.25) as u8,
(f32::from(b[1]) * 0.25) as u8,
(f32::from(b[2]) * 0.25) as u8,
b[3],
)
};
for &is_shadow in passes {
for &(gx, g, c, bold, italic, underline, strike, first, advance) in &glyphs {
// In the world a glyph whose shadow is 0 has none (hasShadow).
let has_shadow = shadow && !(world && shade(c) == 0);
if is_shadow && !has_shadow {
continue;
}
let (dx, col) = if is_shadow {
let b = c.to_le_bytes();
(
g.offset,
rgba(
(f32::from(b[0]) * 0.25) as u8,
(f32::from(b[1]) * 0.25) as u8,
(f32::from(b[2]) * 0.25) as u8,
b[3],
),
)
(g.offset, shade(c))
} else {
(0.0, c)
};
@@ -1092,21 +1133,62 @@ impl HudBatch {
}
None => {}
};
let base = if has_shadow && !is_shadow { 0.03 } else { 0.0 };
let lift = |batch: &mut Self, start: usize, z: f32| {
if world {
for v in &mut batch.vertices[start..] {
v.depth = z;
}
}
};
let start = self.vertices.len();
draw(self, 0.0);
lift(self, start, base);
if bold {
let start = self.vertices.len();
draw(self, g.offset);
lift(self, start, base + 0.001);
}
let x0 = if first { px - 1.0 } else { px };
let start = self.vertices.len();
if strike {
self.rect_uv(x0, py + 3.5, px + advance - x0, 1.0, white, col);
}
if underline {
self.rect_uv(x0, py + 8.0, px + advance - x0, 1.0, white, col);
}
lift(self, start, base + 0.01);
}
}
cursor - x
}
/// SubmitNodeCollection.submitNameTag's text in its own space (x right,
/// y down, in GUI pixels): centred at `-Font.width / 2`, drawn as
/// Font.prepareText draws any text (styles, fonts and glyph boxes; no
/// shadow), over `background` (RGBA) unless its alpha is 0. Returns how
/// many vertices at the start of the batch are the background.
pub fn name_tag(
&mut self,
atlas: &HudAtlas,
text: &crate::text::Text,
y: f32,
color: u32,
background: u32,
) -> usize {
let x = -atlas.font.width_text(text) / 2.0;
let start = self.vertices.len();
let advance = self.text_styled(atlas, text, x, y, color, false);
if background >> 24 == 0 {
return 0;
}
// PreparedTextBuilder.markBackground: one pixel round the advances,
// from a pixel above the line to 9 below its top.
let glyphs = self.vertices.split_off(start);
self.fill(atlas, x - 1.0, y - 1.0, advance + 1.0, 10.0, background);
let behind = self.vertices.len() - start;
self.vertices.extend(glyphs);
behind
}
}
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
@@ -1822,6 +1904,65 @@ mod tests {
assert!(missing.rect.is_none() && missing.right == 5.0);
}
#[test]
fn name_tags_draw_bold_text_and_tall_glyphs_like_any_text() {
// `ascii.png`'s 8x8 'a' (4 pixels wide) and `accented.png`'s 9x12
// 'й' (height 12, ascent 10: the breve above the line), 7 wide.
let root = std::env::temp_dir().join(format!("horizon-name-tag-{}", std::process::id()));
let textures = root.join("assets/minecraft/textures/font");
fs::create_dir_all(root.join("assets/minecraft/font")).unwrap();
fs::create_dir_all(&textures).unwrap();
fs::write(
root.join("assets/minecraft/font/default.json"),
r#"{"providers":[
{"type":"bitmap","file":"minecraft:font/a.png","ascent":7,"chars":["a"]},
{"type":"bitmap","file":"minecraft:font/tall.png","height":12,"ascent":10,"chars":["й"]}]}"#,
)
.unwrap();
for (name, (w, h), used) in [("a", (8, 8), 4), ("tall", (9, 12), 7)] {
let pixels: Vec<u8> = (0..w * h)
.flat_map(|i| [255, 255, 255, if i % w < used { 255 } else { 0 }])
.collect();
let file = fs::File::create(textures.join(format!("{name}.png"))).unwrap();
let mut encoder = png::Encoder::new(file, w, h);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header().unwrap();
writer.write_image_data(&pixels).unwrap();
}
let atlas = HudAtlas::build(&root).unwrap();
let _ = fs::remove_dir_all(&root);
let text = crate::text::Text::literal("aй").bold();
let mut batch = HudBatch::new([1, 1]);
let behind = batch.name_tag(&atlas, &text, 0.0, u32::MAX, 0x3F00_0000);
// Each quad's box: x0, y0, x1, y1.
let boxes: Vec<[f32; 4]> = batch
.vertices
.as_chunks::<6>()
.0
.iter()
.map(|q| [q[0].pos[0], q[0].pos[1], q[2].pos[0], q[2].pos[1]])
.collect();
// Advances 5 and 8, plus 1 each for bold: Font.width 15, centred.
let x = -7.5;
// Each glyph twice (bold), the copy a pixel right; 'й' in its 9x12
// box from 3 pixels above the line, each bold quad 0.1 larger.
let near = |a: [f32; 4], b: [f32; 4]| a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-4);
let expected = [
[x - 0.1, -0.1, x + 8.1, 8.1],
[x + 0.9, -0.1, x + 9.1, 8.1],
[x + 5.9, -3.1, x + 15.1, 9.1],
[x + 6.9, -3.1, x + 16.1, 9.1],
];
for (got, want) in boxes[1..].iter().zip(expected) {
assert!(near(*got, want), "{got:?} != {want:?}");
}
assert_eq!(boxes.len(), 1 + expected.len());
// The background first: a pixel round the advances, 10 pixels tall.
assert_eq!(behind, 6);
assert_eq!(boxes[0], [x - 1.0, -1.0, x + 15.0, 9.0]);
}
#[test]
fn effect_icons_are_painted_from_the_mob_effect_directory() {
use crate::{effects::EffectInstance, hud_layout, hud_overlays, inventory_effects};
@@ -59,13 +59,7 @@ pub fn panels(
translate("effect.duration.infinite").unwrap_or_else(|| "∞".to_owned())
} else {
// StringUtil.formatTickDuration uses the level's current rate.
let seconds = (effect.duration as f32 / tick_rate) as i32;
let (hours, minutes, seconds) = (seconds / 3600, seconds / 60 % 60, seconds % 60);
if hours > 0 {
format!("{hours:02}:{minutes:02}:{seconds:02}")
} else {
format!("{minutes:02}:{seconds:02}")
}
crate::brewing::format_tick_duration(effect.duration, tick_rate)
};
let width = max_width.min(39 + text_width(&name).max(text_width(&duration)));
EffectPanel {
+68 -8
View File
@@ -25,6 +25,16 @@ const MAX_SAVED: usize = 1024;
/// (`Lod::observing`).
pub const OBSERVE_AHEAD: usize = 8;
const SAMPLE_JOBS: usize = 4;
/// Threads that sample far tiles beside the mesher. Machines with 12 or more
/// threads use up to 6 (measured: a cold 1024-chunk LoD loads about a quarter
/// sooner than with 3); the rest keep a quarter of their threads, at most 3.
fn sampling_threads(cores: usize) -> usize {
if cores >= 12 {
(cores / 2).min(6)
} else {
(cores / 4).clamp(1, 3)
}
}
/// While the near terrain loads on a machine with four threads or fewer, far
/// terrain starts one sample job at most this often and no detail: it would
/// otherwise take the cores the near chunks and the frame need.
@@ -524,6 +534,8 @@ struct Shared {
kinds: std::sync::OnceLock<Vec<u8>>,
wake: Condvar,
stop: AtomicBool,
/// Reduced far detail: samplers approximate wide cells.
approximate: AtomicBool,
}
/// One background worker and at most two outstanding sample jobs. Only the
/// current view's quadtree and a bounded set of visited summaries are retained.
@@ -550,6 +562,8 @@ pub struct Lod {
detail_request_at: std::time::Instant,
/// Four threads or fewer: far work waits for the near terrain (`hold`).
small: bool,
/// Sample jobs that may be queued or running at once.
sample_jobs: usize,
/// The near terrain is loading (`set_near_loading`).
near_loading: bool,
/// When `request_samples` last started a job while held.
@@ -570,12 +584,14 @@ pub struct Lod {
}
impl Lod {
pub fn new(source: Arc<dyn LodSource>, context: Arc<WorkerContext>) -> Self {
let cores = thread::available_parallelism().map_or(1, |n| n.get());
let shared = Arc::new(Shared {
jobs: Mutex::new(VecDeque::new()),
observed: Mutex::new(FxHashMap::default()),
kinds: std::sync::OnceLock::new(),
wake: Condvar::new(),
stop: AtomicBool::new(false),
approximate: AtomicBool::new(false),
});
let worker = shared.clone();
let mesh_context = context.clone();
@@ -583,10 +599,10 @@ impl Lod {
let handle = thread::Builder::new()
.name("Far terrain".into())
.spawn(move || {
// Ticks and frames come first (horizon_threads).
horizon_threads::behind_server();
let mut sampler = source.sampler();
let count = thread::available_parallelism()
.map_or(1, |n| n.get() / 4)
.clamp(1, 3);
let count = sampling_threads(cores);
let mut sampling = Vec::new();
for _ in 0..count {
let Some(mut sample) = sampler.fork() else {
@@ -598,6 +614,7 @@ impl Lod {
thread::Builder::new()
.name("Far sampling".into())
.spawn(move || {
horizon_threads::behind_server();
loop {
let key = {
let mut jobs = shared.jobs.lock().unwrap();
@@ -619,6 +636,9 @@ impl Lod {
}
};
let [x, _, z] = key.origin();
sample.set_approximate(
shared.approximate.load(Ordering::Relaxed),
);
let samples =
sample.grid(x, z, key.size() / CELLS as i32, CELLS);
if shared.stop.load(Ordering::Relaxed) {
@@ -681,6 +701,7 @@ impl Lod {
}
Job::Sample(key) => {
let [x, _, z] = key.origin();
sampler.set_approximate(worker.approximate.load(Ordering::Relaxed));
let samples = sampler.grid(x, z, key.size() / CELLS as i32, CELLS);
Done::Sample { key, samples }
}
@@ -746,7 +767,8 @@ impl Lod {
detail_masks: FxHashMap::default(),
detail_request_pending: false,
detail_request_at: std::time::Instant::now(),
small: thread::available_parallelism().map_or(1, |n| n.get()) <= 4,
small: cores <= 4,
sample_jobs: SAMPLE_JOBS.max(sampling_threads(cores) + 1),
near_loading: false,
held_sample_at: std::time::Instant::now(),
ready: FxHashMap::default(),
@@ -976,9 +998,11 @@ impl Lod {
self.near_loading = loading;
}
/// Reduced far detail (`graphics::FarDetail::Reduced`): the next update
/// replans when it changes.
/// replans when it changes, and tiles sampled from then on approximate
/// wide cells (`GeneratorWorker::lod_grid`).
pub fn set_reduced_detail(&mut self, reduced: bool) {
self.reduced_detail = reduced;
self.shared.approximate.store(reduced, Ordering::Relaxed);
}
fn held(&self) -> bool {
self.small && self.near_loading
@@ -1243,7 +1267,13 @@ impl Lod {
self.pending.is_empty() && self.held_sample_at.elapsed() >= HELD_SAMPLE_STEP,
)
} else {
SAMPLE_JOBS.saturating_sub(self.pending.len())
// The extra sampling threads wait until the near terrain is in.
let slots = if self.near_loading {
SAMPLE_JOBS
} else {
self.sample_jobs
};
slots.saturating_sub(self.pending.len())
};
if free == 0 {
return;
@@ -1291,8 +1321,8 @@ impl Lod {
}
// Only the first `free` keys not already pending are taken. A key is
// listed at most twice (as a root and as a node), so the best
// `2 * (SAMPLE_JOBS + pending)` entries hold them, in order.
let best = 2 * (SAMPLE_JOBS + self.pending.len());
// `2 * (sample_jobs + pending)` entries hold them, in order.
let best = 2 * (self.sample_jobs + self.pending.len());
if wanted.len() > best {
wanted.select_nth_unstable(best);
wanted.truncate(best);
@@ -2000,6 +2030,7 @@ mod tests {
lod.ready
.insert(parent, Arc::new(vec![Column::default(); 256]));
lod.active = [parent].into_iter().collect();
lod.sample_jobs = SAMPLE_JOBS;
lod.pending = (0..SAMPLE_JOBS - 1)
.map(|i| TileKey {
x: 999 + i as i32,
@@ -2058,6 +2089,7 @@ mod tests {
}
lod.active = [near, far].into_iter().collect();
lod.shared.jobs.lock().unwrap().clear();
lod.sample_jobs = SAMPLE_JOBS;
lod.pending = (0..SAMPLE_JOBS - 1)
.map(|i| TileKey {
x: 999 + i as i32,
@@ -2091,6 +2123,34 @@ mod tests {
(1..=32).map(|x| (x, 0)).collect::<Vec<_>>(),
"one unfinished fine tile must not starve neighbouring requests"
);
// The extra sampling threads' slots open once the near terrain is in.
lod.small = false;
lod.sample_jobs = SAMPLE_JOBS + 3;
lod.plan = Plan::new([-256, -256], 2, 64);
lod.ready.clear();
lod.active.clear();
lod.pending.clear();
lod.meshing = true;
lod.shared.jobs.lock().unwrap().clear();
let queued = |lod: &mut Lod, near_loading: bool| {
lod.set_near_loading(near_loading);
lod.shared.jobs.lock().unwrap().clear();
lod.pending.clear();
lod.update([-256., 64., -256.], 0., 2, 64, Some(FxHashSet::default()));
let jobs = lod.shared.jobs.lock().unwrap();
jobs.iter()
.filter(|job| matches!(job, Job::Sample(_)))
.count()
};
assert_eq!(queued(&mut lod, true), SAMPLE_JOBS);
assert_eq!(queued(&mut lod, false), SAMPLE_JOBS + 3);
Ok(())
}
#[test]
fn extra_sampling_threads_only_on_large_machines() {
assert_eq!([1, 2, 4, 8, 11].map(sampling_threads), [1, 1, 1, 2, 2]);
assert_eq!([12, 16, 64].map(sampling_threads), [6, 6, 6]);
}
}
+207 -100
View File
@@ -217,7 +217,7 @@ impl Opt {
Opt::NotificationDisplayTime => "options.notifications.display_time",
Opt::Narrator => "options.narrator",
Opt::TextBackground => "options.accessibility.text_background",
Opt::ChatDelay => "Chat Delay",
Opt::ChatDelay => "horizon.options.chat_delay",
Opt::DistortionEffects => "options.screenEffectScale",
Opt::FovEffects => "options.fovEffectScale",
Opt::DarknessPulsing => "options.darknessEffectScale",
@@ -603,25 +603,25 @@ impl Opt {
} else {
"options.key.hold"
}),
Opt::PadLightbar => Text::literal(match s.controller.lightbar {
gamepad::Lightbar::Health => "Health",
gamepad::Lightbar::Dimension => "Dimension",
gamepad::Lightbar::Off => "Off",
Opt::PadLightbar => tr(match s.controller.lightbar {
gamepad::Lightbar::Health => "horizon.options.lightbar.health",
gamepad::Lightbar::Dimension => "horizon.options.lightbar.dimension",
gamepad::Lightbar::Off => "options.off",
}),
Opt::PadGlyphs => Text::literal(match s.controller.glyphs {
GlyphStyle::Auto => "Auto",
GlyphStyle::Xbox => "Xbox",
GlyphStyle::PlayStation => "PlayStation",
GlyphStyle::Nintendo => "Nintendo",
Opt::PadGlyphs => tr(match s.controller.glyphs {
GlyphStyle::Auto => "horizon.options.glyphs.auto",
GlyphStyle::Xbox => "horizon.options.glyphs.xbox",
GlyphStyle::PlayStation => "horizon.options.glyphs.playstation",
GlyphStyle::Nintendo => "horizon.options.glyphs.nintendo",
}),
Opt::PadMenuNav => Text::literal(match s.controller.menu_nav {
gamepad::MenuNav::Focus => "Focus",
gamepad::MenuNav::Pointer => "Pointer",
Opt::PadMenuNav => tr(match s.controller.menu_nav {
gamepad::MenuNav::Focus => "horizon.options.menu_focus",
gamepad::MenuNav::Pointer => "horizon.options.menu_pointer",
}),
Opt::PadKeyboard => Text::literal(match s.controller.keyboard {
gamepad::TextKeyboard::Auto => "Auto",
gamepad::TextKeyboard::Horizon => "Horizon",
gamepad::TextKeyboard::Steam => "Steam",
Opt::PadKeyboard => tr(match s.controller.keyboard {
gamepad::TextKeyboard::Auto => "horizon.options.keyboard.auto",
gamepad::TextKeyboard::Horizon => "horizon.options.keyboard.horizon",
gamepad::TextKeyboard::Steam => "horizon.options.keyboard.steam",
}),
_ => Text::empty(),
}
@@ -1004,13 +1004,19 @@ pub enum Event {
ButtonClick,
/// Close the options screens (back to the pause menu or title).
Close,
/// Settings changed: apply them.
/// Settings changed: apply and save them.
Changed,
/// A slider is being dragged: apply settings live but defer saving.
SliderDrag,
/// The language changed (reload translations).
Language(String),
/// The pack selection changed (reload resources).
Packs(Vec<String>),
/// Create World's Data Packs screen closed with a new selection.
DataPacks(Vec<String>),
OpenPackFolder,
/// Data Packs' Open Pack Folder: create the folder if needed and open it.
OpenDataPackFolder(std::path::PathBuf),
OpenModFolder,
/// The Shader Packs page opened: list `shaderpacks/` into `shader_packs`.
ListShaderPacks,
@@ -1020,6 +1026,9 @@ pub enum Event {
/// Open the mod's own settings page (capability `ui`): the installed
/// mod's index in `mods.installed`.
ModSettings(usize),
/// Native mod `.0`'s page opened: the game asks the mod for its labels
/// again (they were made with the list, maybe in another language).
NativeSettings(usize),
/// Button `.1` of native mod `.0`'s page was pressed: the game tells the
/// mod and refreshes the page.
NativeButton(usize, usize),
@@ -1073,6 +1082,13 @@ pub struct OptionsUi {
pub pack_selection: Vec<String>,
/// The open pack screen, made at its first frame.
pub pack_screen: Option<crate::pack_screen::PackScreen>,
/// The Packs page is Create World's Data Packs screen on this folder.
data_pack_dir: Option<std::path::PathBuf>,
/// The game's resources, for the built-in data pack's icon.
data_pack_resources: std::path::PathBuf,
/// The folder's entries when its packs were listed, and when they were
/// last compared with it.
data_pack_stamp: (Vec<(String, Option<std::time::SystemTime>)>, Instant),
/// A reload failure, dismissed before returning to the current page.
pub resource_error: Option<String>,
/// When the pack screen's search box took the focus (its cursor blink).
@@ -1107,6 +1123,9 @@ impl Default for OptionsUi {
pack_infos: Vec::new(),
pack_selection: Vec::new(),
pack_screen: None,
data_pack_dir: None,
data_pack_resources: Default::default(),
data_pack_stamp: (Vec::new(), Instant::now()),
resource_error: None,
pack_focus: Instant::now(),
in_world: false,
@@ -1159,22 +1178,14 @@ fn option_widget(s: &Settings, opt: Opt, x: f32, y: f32, w: f32) -> Widget {
/// What a button opening one of Horizon's pages says on hover.
fn horizon_page_tooltip(page: Page) -> Option<&'static str> {
Some(match page {
Page::Horizon => "Horizon's own settings: graphics, controllers, extras and mods.",
Page::Graphics => "Graphics, simply: each setting says what it costs.",
Page::GraphicsAdvanced => {
"Every graphics setting, Horizon's included, with what each costs."
}
Page::Controller => "Controllers: their buttons, sticks, vibration and menu navigation.",
Page::Extras => {
"Optional additions, all off by default: the game stays vanilla until you turn one on."
}
Page::Agent => "Let your own AI agent play: horizon-ctl and MCP clients on this computer.",
Page::ShaderPacks => {
"Shader packs from the shaderpacks folder: Horizon's own and those in the shaders/ format. One runs at a time."
}
Page::Mods => {
"Mods made for Horizon, from the horizon-mods folder. Each runs in a sandbox and stays off until you turn it on."
}
Page::Horizon => "horizon.pages.settings.tooltip",
Page::Graphics => "horizon.pages.graphics.tooltip",
Page::GraphicsAdvanced => "horizon.pages.graphics_advanced.tooltip",
Page::Controller => "horizon.pages.controller.tooltip",
Page::Extras => "horizon.pages.extras.tooltip",
Page::Agent => "horizon.pages.agent.tooltip",
Page::ShaderPacks => "horizon.pages.shader_packs.tooltip",
Page::Mods => "horizon.pages.mods.tooltip",
_ => return None,
})
}
@@ -1214,9 +1225,48 @@ impl OptionsUi {
pub fn open(&mut self, in_world: bool) {
self.open_at(in_world, Page::Main);
}
/// Create World's Data Packs (`openDataPackSelectionScreen`): the Packs
/// page on the new world's pack folder `dir`, `selected` chosen;
/// `resources` holds the built-in pack's icon.
pub fn open_data_packs(
&mut self,
dir: std::path::PathBuf,
selected: Vec<String>,
resources: std::path::PathBuf,
) {
self.open_at(false, Page::Packs);
self.pack_infos = crate::datapack::screen_packs(&dir, &resources);
self.data_pack_resources = resources;
self.pack_selection = selected;
self.data_pack_stamp = (crate::datapack::folder_stamp(&dir), Instant::now());
self.data_pack_dir = Some(dir);
}
/// `PackSelectionScreen.tick`'s watcher: once a second, the Data Packs
/// folder's packs are listed again if it changed, the selection kept
/// (the search text and scroll start over).
fn watch_data_packs(&mut self, atlas: &HudAtlas, size: (f32, f32)) {
let Some(dir) = &self.data_pack_dir else {
return;
};
if self.data_pack_stamp.1.elapsed().as_secs() < 1 {
return;
}
let stamp = crate::datapack::folder_stamp(dir);
let changed = stamp != self.data_pack_stamp.0;
self.data_pack_stamp = (stamp, Instant::now());
if !changed {
return;
}
self.pack_infos = crate::datapack::screen_packs(dir, &self.data_pack_resources);
if let Some(p) = self.pack_screen.take() {
self.pack_selection = p.commit();
self.packs(atlas, size).changed = p.changed;
}
}
/// Open a page whose Done/Escape returns directly to the calling screen.
pub fn open_at(&mut self, in_world: bool, page: Page) {
self.stack.clear();
self.data_pack_dir = None;
self.push(page);
self.in_world = in_world;
self.selected_key = None;
@@ -1264,16 +1314,16 @@ impl OptionsUi {
Page::Language => "options.language.title",
Page::Font => "options.font.title",
Page::Packs => "resourcePack.title",
Page::Mods => "Mods",
Page::Mods => "horizon.pages.mods",
Page::NativeMod(_) => "Mod Settings",
Page::ShaderPacks => "Shader Packs",
Page::ShaderPacks => "horizon.pages.shader_packs",
Page::Graphics | Page::GraphicsAdvanced => "options.videoTitle",
Page::Controller => "Controller Settings",
Page::PadBinds => "Controller Binds",
Page::Extras => "Horizon Extras",
Page::Controller => "horizon.pages.controller",
Page::PadBinds => "horizon.pages.controller_binds",
Page::Extras => "horizon.pages.extras",
Page::Credits => "credits_and_attribution.screen.title",
Page::Horizon => "Horizon Settings",
Page::Agent => "AI Agent",
Page::Horizon => "horizon.pages.settings",
Page::Agent => "horizon.pages.agent",
}
}
fn scrollbar_x(&self, width: f32) -> f32 {
@@ -1411,21 +1461,23 @@ impl OptionsUi {
Page::Horizon => {
// Two to a row, as vanilla's OptionsList.addSmall lays out
// buttons; each opens a page built from vanilla's widgets.
let open = |label: &str, page: Page| {
button(0.0, 0.0, 150.0, Text::literal(label), Act::Push(page))
};
let open =
|key: &str, page: Page| button(0.0, 0.0, 150.0, tr(key), Act::Push(page));
vec![
Row::Buttons(
open("Graphics...", Page::Graphics),
Some(open("Advanced Graphics...", Page::GraphicsAdvanced)),
open("horizon.pages.open.graphics", Page::Graphics),
Some(open(
"horizon.pages.open.graphics_advanced",
Page::GraphicsAdvanced,
)),
),
Row::Buttons(
open("Shader Packs...", Page::ShaderPacks),
Some(open("Controller Settings...", Page::Controller)),
open("horizon.pages.open.shader_packs", Page::ShaderPacks),
Some(open("horizon.pages.open.controller", Page::Controller)),
),
Row::Buttons(
open("Extras...", Page::Extras),
Some(open("Mods...", Page::Mods)),
open("horizon.pages.open.extras", Page::Extras),
Some(open("horizon.pages.open.mods", Page::Mods)),
),
// No AI hides the AI Agent page and everything AI.
Row::Buttons(
@@ -1433,7 +1485,7 @@ impl OptionsUi {
Some(if s.extras.no_ai {
self.extra_button(s, crate::extras::Item::NoAi)
} else {
open("AI Agent...", Page::Agent)
open("horizon.pages.open.agent", Page::Agent)
}),
),
]
@@ -1452,7 +1504,7 @@ impl OptionsUi {
0.0,
0.0,
150.0,
Text::literal("Copy Connection Details"),
tr("horizon.agent.copy_details"),
Act::CopyAgentDetails,
)),
)];
@@ -1466,14 +1518,14 @@ impl OptionsUi {
0.0,
0.0,
150.0,
Text::literal("Horizon Graphics..."),
tr("horizon.pages.open.horizon_graphics"),
Act::Push(Page::Graphics),
),
Some(button(
0.0,
0.0,
150.0,
Text::literal("Advanced Graphics..."),
tr("horizon.pages.open.graphics_advanced"),
Act::Push(Page::GraphicsAdvanced),
)),
)];
@@ -1568,7 +1620,7 @@ impl OptionsUi {
0.0,
0.0,
310.0,
Text::literal("Controller Settings..."),
tr("horizon.pages.open.controller"),
Act::Push(Page::Controller),
),
None,
@@ -1581,7 +1633,7 @@ impl OptionsUi {
0.0,
0.0,
310.0,
Text::literal("Controller Binds..."),
tr("horizon.pages.open.controller_binds"),
Act::Push(Page::PadBinds),
),
None,
@@ -1639,9 +1691,7 @@ impl OptionsUi {
Page::Font => pairs(&[Opt::ForceUnicode, Opt::JapaneseGlyphs]),
Page::Mods => {
if s.mods.installed.is_empty() {
return vec![Row::Header(
"No mods: put .hmod files in the horizon-mods folder",
)];
return vec![Row::Header("horizon.mods.empty")];
}
s.mods
.installed
@@ -1651,9 +1701,9 @@ impl OptionsUi {
let mut label = Text::literal(format!("{}: ", installed.title))
.append(on_off(s.mods.is_on(i)));
if installed.problem.is_some() {
label = label.append(" (Stopped)");
label = label.append(tr("horizon.mods.stopped"));
} else if installed.dev {
label = label.append(" (Dev)");
label = label.append(tr("horizon.mods.dev"));
}
let dead = installed.dev || installed.id.is_empty();
// A running mod with a settings page (capability
@@ -1664,7 +1714,7 @@ impl OptionsUi {
0.0,
0.0,
150.0,
Text::literal("Settings"),
tr("horizon.mods.settings"),
Act::ModSettings(i),
);
Row::Buttons(
@@ -1681,17 +1731,17 @@ impl OptionsUi {
Page::NativeMod(i) => self.native_rows(s, i),
Page::ShaderPacks => {
if s.shader_packs.is_empty() {
return vec![Row::Header(
"No shader packs: put them in the shaderpacks folder",
)];
return vec![Row::Header("horizon.shader_packs.empty")];
}
s.shader_packs
.iter()
.enumerate()
.map(|(i, pack)| {
let kind = match pack.kind {
crate::shader_pack_list::Kind::Horizon => " (Horizon)",
crate::shader_pack_list::Kind::Classic => "",
crate::shader_pack_list::Kind::Horizon => {
lang::get("horizon.shader_packs.kind_horizon")
}
crate::shader_pack_list::Kind::Classic => String::new(),
};
let label = Text::literal(format!("{}{kind}: ", pack.title))
.append(on_off(s.horizon.shader_pack == pack.file));
@@ -1751,8 +1801,8 @@ impl OptionsUi {
// Vanilla's Telemetry Data and Credits slots: Horizon's
// settings and its mods, as a mod adds its button here.
// Credits & Attribution (WinScreen's credits) follows them.
("Horizon Settings...", Some(Page::Horizon)),
("Mods...", Some(Page::Mods)),
("horizon.pages.open.horizon_settings", Some(Page::Horizon)),
("horizon.pages.open.mods", Some(Page::Mods)),
("options.credits_and_attribution", Some(Page::Credits)),
];
let rows = entries.len().div_ceil(2) as f32;
@@ -2185,18 +2235,12 @@ impl OptionsUi {
content = y + self.scroll - top + 2.0;
out[list_start..].iter_mut().for_each(|w| w.list = true);
let folder = match page {
Page::Mods => Some(("Open Mod Folder", Act::OpenModFolder)),
Page::ShaderPacks => Some(("Open Pack Folder", Act::OpenShaderPackFolder)),
Page::Mods => Some(("horizon.mods.open_folder", Act::OpenModFolder)),
Page::ShaderPacks => Some(("pack.openFolder", Act::OpenShaderPackFolder)),
_ => None,
};
if let Some((label, act)) = folder {
out.push(button(
cx - 154.0,
h - 27.0,
150.0,
Text::literal(label),
act,
));
out.push(button(cx - 154.0, h - 27.0, 150.0, tr(label), act));
out.push(button(cx + 4.0, h - 27.0, 150.0, tr("gui.done"), Act::Done));
} else {
out.push(done(h - 27.0));
@@ -2268,6 +2312,7 @@ impl OptionsUi {
return;
}
if self.page() == Page::Packs {
self.watch_data_packs(atlas, (w, h));
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, self.in_world);
let blink = self.pack_focus.elapsed().as_millis() as u64;
let m = crate::screen_draw::Measure(&atlas.font);
@@ -2308,7 +2353,7 @@ impl OptionsUi {
} = &wd.kind
&& let Some(text) = horizon_page_tooltip(*page)
{
tooltip = Some(atlas.font.split(&Text::literal(text), 170.0));
tooltip = Some(atlas.font.split(&tr(text), 170.0));
}
let key = match &wd.kind {
WKind::Button {
@@ -2335,10 +2380,7 @@ impl OptionsUi {
} = &wd.kind
&& !opt.available()
{
tooltip = Some(atlas.font.split(
&Text::literal("This feature is not yet available in Horizon."),
170.0,
));
tooltip = Some(atlas.font.split(&tr("horizon.options.unavailable"), 170.0));
}
if let WKind::Button {
act: Act::ToggleMod(i),
@@ -2479,9 +2521,7 @@ impl OptionsUi {
if search.value.is_empty() && !search.focused {
batch.text_styled(
atlas,
&Text::literal("Search settings...")
.with_color(text::GRAY)
.italic(),
&tr("horizon.options.search").with_color(text::GRAY).italic(),
search.text_x(),
search.text_y(),
u32::MAX,
@@ -2526,6 +2566,7 @@ impl OptionsUi {
match action {
Action::ButtonClick => events.push(Event::ButtonClick),
Action::Changed => events.push(Event::Changed),
Action::SliderDrag => events.push(Event::SliderDrag),
Action::OpenAdvanced => self.push(Page::GraphicsAdvanced),
Action::OpenSimple => {
if self.stack.len() >= 2
@@ -2562,7 +2603,10 @@ impl OptionsUi {
self.pack_focus = Instant::now();
}
match action {
crate::pack_screen::Action::OpenFolder => events.push(Event::OpenPackFolder),
crate::pack_screen::Action::OpenFolder => events.push(match &self.data_pack_dir {
Some(dir) => Event::OpenDataPackFolder(dir.clone()),
None => Event::OpenPackFolder,
}),
crate::pack_screen::Action::Done => events.extend(self.back(s)),
crate::pack_screen::Action::None => {}
}
@@ -2606,7 +2650,7 @@ impl OptionsUi {
let v = ui::slider_value(wd.x, wd.w, x);
self.slider_value = Some((opt, v, Instant::now()));
set_slider(opt, s, v);
events.push(Event::Changed);
events.push(Event::SliderDrag);
}
return events;
}
@@ -2711,6 +2755,7 @@ impl OptionsUi {
events.push(Event::ButtonClick);
if s.mods.installed.get(i).is_some_and(|m| m.native.is_some()) {
self.push(Page::NativeMod(i));
events.push(Event::NativeSettings(i));
} else {
events.push(Event::ModSettings(i));
}
@@ -2742,7 +2787,7 @@ impl OptionsUi {
let v = ui::slider_value(wd.x, wd.w, x);
self.slider_value = Some((opt, v, Instant::now()));
set_slider(opt, s, v);
events.push(Event::Changed);
events.push(Event::SliderDrag);
}
WKind::LanguageRow { code, .. } => {
if self.language_choice.as_deref() == Some(code.as_str()) {
@@ -2775,7 +2820,7 @@ impl OptionsUi {
let slider = self.dragging.take().is_some();
let graphics_slider = self.graphics.release();
if slider || graphics_slider {
vec![Event::ButtonClick]
vec![Event::ButtonClick, Event::Changed]
} else {
Vec::new()
}
@@ -2791,7 +2836,7 @@ impl OptionsUi {
return;
}
if let Some(level) = Self::graphics_level(self.page()) {
self.graphics.scroll(level, size.1, amount);
self.graphics.scroll(level, s, size.1, amount);
return;
}
match &mut self.pack_screen {
@@ -2813,8 +2858,14 @@ impl OptionsUi {
}
None => {
self.pack_focus = Instant::now();
let title = if self.data_pack_dir.is_some() {
"dataPack.title"
} else {
"resourcePack.title"
};
self.pack_screen = Some(crate::pack_screen::PackScreen::new(
&m,
crate::text::Text::translate(title, &[]),
w,
h,
self.in_world,
@@ -2846,13 +2897,18 @@ impl OptionsUi {
}
Page::Packs => {
// PackSelectionScreen.onClose: PackSelectionModel.commit.
let data = self.data_pack_dir.take();
if let Some(p) = self.pack_screen.take()
&& p.changed
{
let ids = p.commit();
s.resource_packs = ids.clone();
self.pack_selection = ids.clone();
events.push(Event::Packs(ids));
if data.is_some() {
events.push(Event::DataPacks(ids));
} else {
s.resource_packs = ids.clone();
self.pack_selection = ids.clone();
events.push(Event::Packs(ids));
}
}
}
_ => {}
@@ -3239,8 +3295,15 @@ mod tests {
ui.show(Page::Mods);
let empty = ui.layout(&s, size.0, size.1).0;
assert!(empty.iter().any(|w| {
matches!(&w.kind, WKind::Text { text, .. } if text.plain().contains(".hmod"))
matches!(&w.kind, WKind::Text { text, .. } if text.plain() == "horizon.mods.empty")
}));
// The empty page points at the bundled key; the English line behind it
// is what a player reads, so both halves are checked here.
let en = crate::lang::Language::load(&[], "en_us");
assert!(
en.get("horizon.mods.empty")
.is_some_and(|line| line.contains(".hmod"))
);
let open_folder = empty
.iter()
.find(|w| {
@@ -3329,6 +3392,50 @@ mod tests {
assert_eq!(ui.page(), Page::Main);
}
#[test]
fn opening_a_native_mods_page_asks_the_game_for_its_labels_again() {
let atlas = atlas();
let mut s = Settings::default();
let mut ui = OptionsUi::default();
let size = (480.0, 240.0);
s.mods.installed.push(crate::mod_list::Installed {
id: "native".into(),
title: "Native 1.0".into(),
description: String::new(),
problem: None,
dev: false,
has_settings: false,
native: Some(crate::mod_list::NativePage::default()),
});
ui.show(Page::Mods);
let (widgets, ..) = ui.layout(&s, size.0, size.1);
let settings = widgets
.iter()
.find(|w| {
matches!(
w.kind,
WKind::Button {
act: Act::ModSettings(0),
..
}
)
})
.expect("a native mod's row has a Settings button");
let events = ui.click(
&mut s,
&atlas,
settings.x + 1.0,
settings.y + 1.0,
size,
[480, 240],
false,
false,
);
// Its labels were made with the list, maybe in another language.
assert_eq!(events.last(), Some(&Event::NativeSettings(0)));
assert_eq!(ui.page(), Page::NativeMod(0));
}
#[test]
fn option_tooltip_keys_follow_value_dependent_descriptions() {
let mut s = Settings::default();
@@ -3765,22 +3872,22 @@ mod tests {
let (x, y) = (slider.x + slider.w - 1.0, slider.y + 1.0);
assert_eq!(
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false),
vec![Event::Changed]
vec![Event::SliderDrag]
);
assert_eq!(s.master_volume, 1.0);
assert_eq!(ui.release(), vec![Event::ButtonClick]);
assert_eq!(ui.release(), vec![Event::ButtonClick, Event::Changed]);
assert!(ui.release().is_empty());
assert_eq!(
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false),
vec![Event::Changed]
vec![Event::SliderDrag]
);
for cx in [-100.0, 250.0, 1000.0] {
assert_eq!(
ui.click(&mut s, &atlas, cx, -100.0, size, [854, 480], true, false),
vec![Event::Changed]
vec![Event::SliderDrag]
);
}
assert_eq!(ui.release(), vec![Event::ButtonClick]);
assert_eq!(ui.release(), vec![Event::ButtonClick, Event::Changed]);
assert!(
ui.click(&mut s, &atlas, x, y, size, [854, 480], true, false)
.is_empty()
@@ -318,6 +318,8 @@ pub enum Action {
/// The pack screen and its state.
#[derive(Clone, Debug)]
pub struct PackScreen {
/// `resourcePack.title`, or `dataPack.title` from Create World.
pub title: Text,
pub width: i32,
pub height: i32,
pub in_world: bool,
@@ -342,6 +344,7 @@ impl PackScreen {
/// `PackSelectionScreen.init`: the lists filled, the search box focused.
pub fn new(
m: &Measure,
title: Text,
width: i32,
height: i32,
in_world: bool,
@@ -360,6 +363,7 @@ impl PackScreen {
.filter_map(|p| Some((icon_texture(&p.id), p.icon.clone()?)))
.collect();
let mut s = Self {
title,
width,
height,
in_world,
@@ -382,7 +386,7 @@ impl PackScreen {
}
/// The header layout: title, drop info and search box, centred.
fn header(&self, m: &Measure) -> [(i32, i32); 3] {
let tw = m.width(&tr("resourcePack.title"));
let tw = m.width(&self.title);
let dw = m.width(&tr("pack.dropInfo"));
let col = tw.max(dw).max(200);
let frame = self.width.max(col);
@@ -837,7 +841,7 @@ impl PackScreen {
return out;
}
let [(tx, ty), (dx, dy), _] = self.header(m);
d::string_widget(&mut out, m, &tr("resourcePack.title"), tx, ty, 0);
d::string_widget(&mut out, m, &self.title, tx, ty, 0);
d::string_widget(
&mut out,
m,
@@ -1105,7 +1109,7 @@ mod icon_tests {
pack("high_contrast", Some(contrast.clone())),
pack("file/Iconless", None),
];
let screen = PackScreen::new(&m, 480, 320, false, packs, &[]);
let screen = PackScreen::new(&m, tr("resourcePack.title"), 480, 320, false, packs, &[]);
let mut draws = screen.draw(&m, (0, 0), 0);
let mut patches = Vec::new();
screen.bind_icons(&mut draws, &atlas, &mut patches);
@@ -1152,7 +1156,8 @@ mod click_sound_tests {
crate::hud::HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets"))
.unwrap();
let m = Measure(&atlas.font);
let mut screen = PackScreen::new(&m, 480, 320, false, Vec::new(), &[]);
let title = tr("resourcePack.title");
let mut screen = PackScreen::new(&m, title, 480, 320, false, Vec::new(), &[]);
assert!(!screen.button_at(&m, 0.0, 0.0));
assert!(!screen.button_at(
&m,
+87 -13
View File
@@ -181,9 +181,15 @@ pub fn compatible(meta: &serde_json::Value) -> bool {
/// `PackCompatibility.forVersion`: too old, too new, compatible, or unknown
/// when the metadata names no format.
pub fn compatibility(meta: &serde_json::Value) -> crate::pack_screen::Compat {
compatibility_for(meta, RESOURCE_FORMAT)
}
/// `compatibility` against `current`, the resource or the data pack format.
pub fn compatibility_for(
meta: &serde_json::Value,
current: (i64, i64),
) -> crate::pack_screen::Compat {
use crate::pack_screen::Compat;
let pack = &meta["pack"];
let current = RESOURCE_FORMAT;
let range = if let (Some(min), Some(max)) = (
format_of(&pack["min_format"]),
format_of(&pack["max_format"]),
@@ -275,6 +281,10 @@ fn read_meta(source: &Source) -> Option<serde_json::Value> {
serde_json::from_slice(&bytes).ok()
}
fn pack(id: String, title: Text, source: Source) -> Option<Pack> {
pack_for(id, title, source, RESOURCE_FORMAT)
}
/// A pack whose compatibility is against `format`.
fn pack_for(id: String, title: Text, source: Source, format: (i64, i64)) -> Option<Pack> {
let meta = read_meta(&source)?;
let (raw_description, description_style) = component_root(&meta["pack"]["description"]);
Some(Pack {
@@ -284,7 +294,7 @@ fn pack(id: String, title: Text, source: Source) -> Option<Pack> {
raw_description,
description_style,
compatible: compatible(&meta),
compatibility: compatibility(&meta),
compatibility: compatibility_for(&meta, format),
overlays: overlays_of(&meta),
filter: filter_of(&meta),
source,
@@ -305,7 +315,13 @@ pub fn discover(resources: &Path, packs_dir: &Path) -> Vec<Pack> {
out.push(p);
}
}
let mut user: Vec<Pack> = fs::read_dir(packs_dir)
out.extend(folder(packs_dir, RESOURCE_FORMAT));
out
}
/// The packs (folders and zips) in `dir`, by name, as `FolderRepositorySource`
/// finds them; their compatibility is against `format`.
pub fn folder(dir: &Path, format: (i64, i64)) -> Vec<Pack> {
let mut user: Vec<Pack> = fs::read_dir(dir)
.into_iter()
.flatten()
.flatten()
@@ -319,12 +335,11 @@ pub fn discover(resources: &Path, packs_dir: &Path) -> Vec<Pack> {
} else {
return None;
};
pack(format!("file/{name}"), Text::literal(name), source)
pack_for(format!("file/{name}"), Text::literal(name), source, format)
})
.collect();
user.sort_by(|a, b| a.id.cmp(&b.id));
out.extend(user);
out
user
}
/// A file a pack provides: its path (folder packs) and its name inside the pack.
type Provided = (Option<PathBuf>, String);
@@ -380,11 +395,45 @@ fn link(target: &Path, at: &Path) -> io::Result<()> {
if target.is_dir() {
Ok(())
} else {
fs::copy(target, at).map(|_| ())
// The mirror only reads these files, and unix links them: a hardlink gives
// the same view without writing the 639 MB prepared root again, which was
// 33 s of a cold Windows launch with a pack on. Another volume (or a
// filesystem without hardlinks) still copies.
fs::hard_link(target, at).or_else(|_| fs::copy(target, at).map(|_| ()))
}
}
/// A directory junction, which Windows creates without privileges (a directory symlink needs
/// developer mode). The mirror falls back to copying the tree when it fails.
#[cfg(windows)]
fn junction(target: &Path, at: &Path) -> io::Result<()> {
use std::os::windows::process::CommandExt;
// cmd parses its own line: Rust quotes only paths with spaces, so `&` or `^` in an
// unquoted path would split or change the command. A Windows path cannot hold `"`.
let quoted = |path: &Path| -> io::Result<std::ffi::OsString> {
let mut quoted = std::ffi::OsString::from("\"");
quoted.push(std::path::absolute(path)?);
quoted.push("\"");
Ok(quoted)
};
let status = std::process::Command::new("cmd")
.args(["/C", "mklink", "/J"])
.raw_arg(quoted(at)?)
.raw_arg(quoted(target)?)
.creation_flags(0x0800_0000) // CREATE_NO_WINDOW
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()?;
status
.success()
.then_some(())
.ok_or_else(|| io::Error::other("mklink /J failed"))
}
#[cfg(not(windows))]
fn junction(_: &Path, _: &Path) -> io::Result<()> {
Err(io::ErrorKind::Unsupported.into())
}
/// Build `out` as `vanilla` with the packs' files layered on top (packs in
/// load order, last wins). Untouched directories are symlinks into vanilla.
/// load order, last wins). Untouched directories are links into vanilla.
pub fn build_overlay(vanilla: &Path, packs: &[Pack], out: &Path) -> io::Result<()> {
cache::build(vanilla, packs, out)
}
@@ -516,11 +565,17 @@ fn build_overlay_uncached(
}
}
}
} else if filters.is_empty()
&& !touched.contains(&child)
&& vanilla.join(&child).is_dir()
&& junction(&vanilla.join(&child), &out.join(&child)).is_ok()
{
// Linked whole, like the unix symlink: no file in it is visited or copied.
} else if touched.contains(&child)
// Windows cannot link a directory the way unix does (`link` above), so an
// untouched one has to be mirrored: skipping it hid textures/font and the
// title panorama from the overlay, and a font json that names those sheets
// then failed to load (#1688).
// Without a junction Windows cannot link a directory the way unix does (`link`
// above), so an untouched one has to be mirrored: skipping it hid textures/font
// and the title panorama from the overlay, and a font json that names those
// sheets then failed to load (#1688).
|| (vanilla.join(&child).is_dir() && (cfg!(not(unix)) || !filters.is_empty()))
{
mirror(
@@ -939,7 +994,8 @@ mod tests {
}
#[test]
fn overlay_layers_packs_over_vanilla() {
let base = std::env::temp_dir().join(format!("horizon-packs-{}", std::process::id()));
// `&` would split an unquoted `cmd /C mklink` line.
let base = std::env::temp_dir().join(format!("horizon-packs-&{}", std::process::id()));
let _ = fs::remove_dir_all(&base);
let vanilla = base.join("vanilla");
fs::create_dir_all(vanilla.join("assets/minecraft/textures/block")).unwrap();
@@ -960,6 +1016,13 @@ mod tests {
)
.unwrap();
fs::write(vanilla.join("facts.json"), b"[]").unwrap();
// A directory no pack touches (#1688: a font json names sheets in it).
fs::create_dir_all(vanilla.join("assets/minecraft/textures/font")).unwrap();
fs::write(
vanilla.join("assets/minecraft/textures/font/ascii.png"),
b"font",
)
.unwrap();
let pack_dir = base.join("packs/Mine");
fs::create_dir_all(pack_dir.join("assets/minecraft/textures/block")).unwrap();
fs::write(
@@ -997,6 +1060,17 @@ mod tests {
br#"{"a":"vanilla","b":"pack"}"#
);
assert_eq!(fs::read(out.join("facts.json")).unwrap(), b"[]");
assert_eq!(
fs::read(out.join("assets/minecraft/textures/font/ascii.png")).unwrap(),
b"font"
);
// Linked, not mirrored: a junction on Windows, a symlink on unix.
assert!(
fs::symlink_metadata(out.join("assets/minecraft/textures/font"))
.unwrap()
.file_type()
.is_symlink()
);
let _ = fs::remove_dir_all(base);
}
#[test]
@@ -8,8 +8,9 @@ use std::io::Read;
use std::{collections::BTreeSet, fs, io, path::Path, path::PathBuf};
// Bump when overlay/merge semantics change. Markers are disposable cache data.
// Version 4 mirrored every untouched directory file by file on Windows.
// Version 3 could omit untouched vanilla directories on Windows (#1649, #1688).
const VERSION: u32 = 4;
const VERSION: u32 = 5;
#[derive(Default, Serialize, Deserialize)]
pub(super) struct Dependencies {
+95 -9
View File
@@ -491,15 +491,69 @@ pub fn texture_hash(url: &str) -> Option<String> {
/// `SkinManager.TextureCache.registerTexture`: the texture id of a skin,
/// `skins/` and the SHA-1 of its hash's UTF-16 code units.
pub fn skin_location(hash: &str) -> String {
location("skins/", hash)
}
/// `registerTexture` in the cape cache: `capes/` and the same SHA-1.
pub fn cape_location(hash: &str) -> String {
location("capes/", hash)
}
fn location(prefix: &str, hash: &str) -> String {
let units: Vec<u8> = hash.encode_utf16().flat_map(u16::to_le_bytes).collect();
let digest = sha1(&units);
let mut out = String::from("skins/");
let mut out = String::from(prefix);
for b in digest {
out.push_str(&format!("{b:02x}"));
}
out
}
/// `PlayerSkin.cape`: the cape texture of textures whose lookup gave a skin
/// (every download ready), if they name one.
pub fn cape(textures: &Textures) -> Option<String> {
textures
.cape
.as_deref()
.and_then(texture_hash)
.map(|hash| cape_location(&hash))
}
/// A cape in a 64x64 skin slot: the 64x32 picture (or the older 22x17,
/// which has the same layout) in its top half. Vanilla draws a cape of any
/// size; a larger one is scaled down to 64x32 here.
pub fn process_cape(width: u32, height: u32, rgba: &[u8]) -> Option<Vec<u8>> {
let (w, h) = (width as usize, height as usize);
if w == 0 || h == 0 || rgba.len() < w * h * 4 {
return None;
}
let mut img = vec![0u8; 64 * 64 * 4];
let (fit_w, fit_h) = if w <= 64 && h <= 32 { (w, h) } else { (64, 32) };
for y in 0..fit_h {
for x in 0..fit_w {
let (sx, sy) = (x * w / fit_w, y * h / fit_h);
let (s, d) = ((sy * w + sx) * 4, (y * 64 + x) * 4);
img[d..d + 4].copy_from_slice(&rgba[s..s + 4]);
}
}
Some(img)
}
/// The cape the launcher gives an offline name (`HORIZON_OFFLINE_CAPE`, a
/// PNG's path; launcher/src/skins.rs), read once as `process_cape` lays it out.
pub fn offline_cape() -> Option<&'static [u8]> {
static CAPE: std::sync::OnceLock<Option<Vec<u8>>> = std::sync::OnceLock::new();
CAPE.get_or_init(|| {
let path = std::env::var_os("HORIZON_OFFLINE_CAPE").filter(|p| !p.is_empty())?;
let image = std::fs::read(&path)
.map_err(|e| e.to_string())
.and_then(|bytes| crate::hud::decode_bytes(&bytes).map_err(|e| e.to_string()))
.and_then(|(w, h, rgba)| process_cape(w, h, &rgba).ok_or("not an image".into()));
image
.inspect_err(|e| eprintln!("Horizon: no offline cape ({}): {e}", path.display()))
.ok()
})
.as_deref()
}
/// Where a texture download stands.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Download {
@@ -650,9 +704,10 @@ impl Downloader {
while let Ok((key, image)) = self.done.try_recv() {
let state = match image {
Some(rgba) => {
if key.0 == Kind::Skin {
let location = skin_location(&key.1);
self.queue(location, rgba);
match key.0 {
Kind::Skin => self.queue(skin_location(&key.1), rgba),
Kind::Cape => self.queue(cape_location(&key.1), rgba),
Kind::Elytra => {}
}
Download::Ready
}
@@ -685,7 +740,8 @@ impl Downloader {
.and_then(|b| crate::hud::decode_bytes(&b).ok())
.and_then(|(w, h, rgba)| match kind {
Kind::Skin => process_legacy_skin(w, h, &rgba),
_ => Some(rgba),
Kind::Cape => process_cape(w, h, &rgba),
Kind::Elytra => Some(rgba),
});
let _ = send.send((key, image));
});
@@ -716,16 +772,21 @@ impl Downloader {
if self.queued.contains(location) {
return true;
}
let Some(digest) = location.strip_prefix("skins/") else {
return false;
};
let (digest, process): (_, fn(u32, u32, &[u8]) -> Option<Vec<u8>>) =
if let Some(digest) = location.strip_prefix("skins/") {
(digest, process_legacy_skin)
} else if let Some(digest) = location.strip_prefix("capes/") {
(digest, process_cape)
} else {
return false;
};
let image = self.images.get(location).cloned().or_else(|| {
let file = self
.root
.join(if digest.len() > 2 { &digest[..2] } else { "xx" })
.join(digest);
let (w, h, rgba) = crate::hud::decode_bytes(&std::fs::read(&file).ok()?).ok()?;
process_legacy_skin(w, h, &rgba)
process(w, h, &rgba)
});
let Some(image) = image else {
return false;
@@ -961,6 +1022,31 @@ mod tests {
assert_eq!(d.take_ready(), vec![("skins/def".to_owned(), vec![4])]);
}
/// A cape fills the top half of a 64x64 slot; a larger one is scaled to 64x32.
#[test]
fn capes_fill_the_top_half_of_a_slot() {
let at = |x: usize, y: usize| (y * 64 + x) * 4;
let legacy = super::process_cape(22, 17, &[7; 22 * 17 * 4]).unwrap();
assert_eq!(legacy.len(), 64 * 64 * 4);
assert_eq!(legacy[at(21, 16)], 7);
assert_eq!(legacy[at(22, 16)], 0);
let mut hd = vec![0u8; 128 * 64 * 4];
hd[(62 * 128 + 126) * 4] = 9;
let hd = super::process_cape(128, 64, &hd).unwrap();
assert_eq!(hd[at(63, 31)], 9);
assert_eq!(hd[at(0, 32)], 0);
assert!(super::process_cape(64, 32, &[0; 4]).is_none());
let textures = super::Textures {
cape: Some("http://textures.minecraft.net/texture/abc".into()),
..Default::default()
};
let cape = super::cape(&textures).unwrap();
assert_eq!(
cape["capes/".len()..],
super::skin_location("abc")["skins/".len()..]
);
}
#[test]
fn profile_components_supply_unsigned_head_skins() {
let value = "eyJ0ZXh0dXJlcyI6IHsiU0tJTiI6IHsidXJsIjogImh0dHBzOi8vdGV4dHVyZXMubWluZWNyYWZ0Lm5ldC90ZXh0dXJlL2FiYzEyMyJ9fX0=";
@@ -6,7 +6,11 @@
//! instance's random.
use horizon_core::random::RandomSource;
use serde_json::Value;
use std::{collections::HashMap, fs, path::Path};
use std::{
collections::{HashMap, HashSet},
fs,
path::Path,
};
/// `SoundManager.EMPTY_SOUND_LOCATION`.
pub const EMPTY: &str = "minecraft:empty";
@@ -224,6 +228,34 @@ fn parse_sound(v: &Value) -> Option<Sound> {
sentinel: Sentinel::None,
})
}
/// The files under a namespace's `sounds/`, as `sounds/<path>` with `/`.
fn sound_files(namespace: &Path) -> HashSet<String> {
let mut files = HashSet::new();
let mut stack = vec![namespace.join("sounds")];
while let Some(dir) = stack.pop() {
let Ok(entries) = fs::read_dir(&dir) else {
continue;
};
for e in entries.flatten() {
let path = e.path();
// The listing's own type; a link is followed, as `is_file` does.
let Ok(kind) = e.file_type() else {
continue;
};
let (dir, file) = if kind.is_symlink() {
(path.is_dir(), path.is_file())
} else {
(kind.is_dir(), kind.is_file())
};
if dir {
stack.push(path);
} else if file && let Ok(rel) = path.strip_prefix(namespace) {
files.insert(rel.to_string_lossy().replace('\\', "/"));
}
}
}
files
}
impl SoundRegistry {
/// Every `assets/<namespace>/sounds.json` under a resource root, files
/// validated against `assets/<namespace>/sounds/`.
@@ -236,13 +268,19 @@ impl SoundRegistry {
.filter_map(|e| e.file_name().to_str().map(str::to_owned))
.collect();
namespaces.sort();
// Each namespace's sound files are listed once, as the resource manager
// lists a pack, instead of one file lookup per sound (thousands).
let files: HashMap<&str, HashSet<String>> = namespaces
.iter()
.map(|ns| (ns.as_str(), sound_files(&assets.join(ns))))
.collect();
let exists = |ns: &str, path: &str| files.get(ns).is_some_and(|f| f.contains(path));
let mut registry = Self::default();
for namespace in namespaces {
let Ok(text) = fs::read(assets.join(&namespace).join("sounds.json")) else {
for namespace in &namespaces {
let Ok(text) = fs::read(assets.join(namespace).join("sounds.json")) else {
continue;
};
let exists = |ns: &str, path: &str| assets.join(ns).join(path).is_file();
registry.add_file(&namespace, &text, &exists);
registry.add_file(namespace, &text, &exists);
}
registry
}
@@ -369,6 +407,24 @@ mod tests {
use super::*;
use horizon_core::random::LegacyRandom;
#[test]
fn load_keeps_the_sound_files_a_root_holds() {
let root = std::env::temp_dir().join(format!("horizon-sounds-{}", std::process::id()));
let ns = root.join("assets/minecraft");
fs::create_dir_all(ns.join("sounds/block/note")).unwrap();
fs::write(ns.join("sounds/block/note/harp.ogg"), b"").unwrap();
fs::write(
ns.join("sounds.json"),
br#"{"note": {"sounds": ["block/note/harp", "block/note/gone"]}}"#,
)
.unwrap();
let r = SoundRegistry::load(&root);
let _ = fs::remove_dir_all(&root);
let sounds = &r.get("minecraft:note").unwrap().sounds;
let kept: Vec<&str> = sounds.iter().map(|s| s.location.as_str()).collect();
assert_eq!(kept, ["minecraft:block/note/harp"]);
}
#[test]
fn references_weigh_as_much_as_their_event() {
let mut r = SoundRegistry::default();
@@ -99,6 +99,9 @@ pub trait LodSampler: Send {
fn drain_details(&mut self) -> Vec<LodDetail> {
Vec::new()
}
/// Reduced far detail (`graphics::FarDetail::Reduced`): `grid` may then
/// approximate wide cells.
fn set_approximate(&mut self, _approximate: bool) {}
fn sample(&mut self, x: i32, z: i32) -> Option<crate::lod::Surface>;
fn column(&mut self, x: i32, z: i32) -> crate::lod::Column {
crate::lod::Column::from_surface(self.sample(x, z))
+19 -29
View File
@@ -24,6 +24,9 @@ struct Globals {
outline_params: vec4<f32>,
// Horizon Extras' dynamic lights: camera-relative position and level (dynamic_light.rs).
dynamic_lights: array<vec4<f32>, 16>,
// The sunrise fan's 16 triangles (gfx/sunrise_fan.rs): xyz the apex weight's normal, w the
// triangle's volume with the camera; then each rim vertex's normal against the apex.
sunrise_fan: array<vec4<f32>, 32>,
}
@group(0) @binding(0) var<uniform> globals: Globals;
@group(0) @binding(1) var sprites: texture_2d_array<f32>;
@@ -197,39 +200,26 @@ fn sky_fog(color: vec3<f32>, dist: vec2<f32>) -> vec3<f32> {
let f = max(linear_fog(dist.x, 0.0, end), linear_fog(dist.y, end, end));
return mix(color, globals.fog_color.rgb, f * globals.fog_color.a);
}
fn rotate_x90(v: vec3<f32>) -> vec3<f32> { return vec3(v.x, -v.z, v.y); }
// Weight of the fan centre where the ray crosses the sunrise fan (0 when missed).
// Weight of the fan centre where the ray crosses the sunrise fan (0 when missed):
// Moller-Trumbore from the origin along d, over normals built once a frame
// (gfx/sunrise_fan.rs). The barycentrics are apex/s, k1/s and -k0/s; s is -det.
fn sunrise_weight(d: vec3<f32>) -> f32 {
let alpha = globals.sunrise.a;
if alpha <= 0.001 { return 0.0; }
// sin(sunAngle) < 0 flips the fan to the other horizon; sun.z = -sin(sunAngle).
let flip = select(0.0, 180.0, -globals.sun.z < 0.0);
let theta = radians(flip + 90.0);
let c = cos(theta);
let s = sin(theta);
if globals.sunrise.a <= 0.001 { return 0.0; }
var best = 0.0;
let apex = rotate_x90(vec3(-100.0 * s, 100.0 * c, 0.0));
var k0 = dot(d, globals.sunrise_fan[16].xyz);
for (var i = 0; i < 16; i++) {
let a0 = f32(i) * 2.0 * PI / 16.0;
let a1 = f32(i + 1) * 2.0 * PI / 16.0;
let l0 = vec3(sin(a0) * 120.0, cos(a0) * 120.0, -cos(a0) * 40.0 * alpha);
let l1 = vec3(sin(a1) * 120.0, cos(a1) * 120.0, -cos(a1) * 40.0 * alpha);
let r0 = rotate_x90(vec3(l0.x * c - l0.y * s, l0.x * s + l0.y * c, l0.z));
let r1 = rotate_x90(vec3(l1.x * c - l1.y * s, l1.x * s + l1.y * c, l1.z));
// Moller-Trumbore from the origin along d.
let e1 = r0 - apex;
let e2 = r1 - apex;
let pv = cross(d, e2);
let det = dot(e1, pv);
if abs(det) < 1e-6 { continue; }
let tv = -apex;
let u = dot(tv, pv) / det;
let qv = cross(tv, e1);
let v = dot(d, qv) / det;
let t = dot(e2, qv) / det;
if u >= 0.0 && v >= 0.0 && u + v <= 1.0 && t > 0.0 {
best = max(best, 1.0 - u - v);
let triangle = globals.sunrise_fan[i];
let k1 = dot(d, globals.sunrise_fan[16 + (i + 1) % 16].xyz);
// A hit needs s on the side of w, so k0 and k1 on either side of 0.
if k0 * triangle.w <= 0.0 && k1 * triangle.w >= 0.0 {
let apex = dot(d, triangle.xyz);
let s = apex + k1 - k0;
if abs(s) >= 1e-6 && s * triangle.w > 0.0 && apex * s >= 0.0 && k1 * s >= 0.0
&& k0 * s <= 0.0 {
best = max(best, apex / s);
}
}
k0 = k1;
}
return best;
}
+83 -16
View File
@@ -79,27 +79,31 @@ pub fn load_metadata(root: &Path) {
}
*NINE_SLICE.write().unwrap_or_else(|e| e.into_inner()) = Some(map);
}
/// The sprite's nine-slice metadata; without any (no resources loaded), its
/// GUI size and the widgets' usual borders.
fn scaling(sprite: &str, r: &Rect) -> NineSlice {
/// The sprite's nine-slice metadata, when its `.png.mcmeta` has any.
fn nine_slice_metadata(sprite: &str) -> Option<NineSlice> {
NINE_SLICE
.read()
.unwrap_or_else(|e| e.into_inner())
.as_ref()
.and_then(|m| m.get(sprite).copied())
.unwrap_or(NineSlice {
width: r.w,
height: r.h,
border: match sprite {
"gui/sprites/widget/button"
| "gui/sprites/widget/button_highlighted"
| "gui/sprites/widget/button_disabled" => [3; 4],
"gui/sprites/widget/slider_handle"
| "gui/sprites/widget/slider_handle_highlighted" => [2, 2, 2, 3],
_ => [1; 4],
},
stretch_inner: false,
})
}
/// The sprite's nine-slice metadata; without any (no resources loaded), its
/// GUI size and the widgets' usual borders.
fn scaling(sprite: &str, r: &Rect) -> NineSlice {
nine_slice_metadata(sprite).unwrap_or(NineSlice {
width: r.w,
height: r.h,
border: match sprite {
"gui/sprites/widget/button"
| "gui/sprites/widget/button_highlighted"
| "gui/sprites/widget/button_disabled" => [3; 4],
"gui/sprites/widget/slider_handle" | "gui/sprites/widget/slider_handle_highlighted" => {
[2, 2, 2, 3]
}
_ => [1; 4],
},
stretch_inner: false,
})
}
/// The region `[x, y, w, h]` of a sprite `size` GUI pixels large, in its
/// own units (`TextureAtlasSprite.getU(x / width)`).
@@ -152,6 +156,35 @@ pub fn nine_slice(
draw_nine_slice(batch, *r, scaling(sprite, r), [x, y, w, h], color);
}
}
/// `GuiGraphicsExtractor.blitSprite`: a sprite over `w` × `h` as its
/// `gui.scaling` says, nine-sliced, or stretched when it has no metadata.
/// (`tile`, which no vanilla sprite uses, isn't read yet: it stretches.)
pub fn blit_sprite(
batch: &mut HudBatch,
atlas: &HudAtlas,
sprite: &str,
x: f32,
y: f32,
w: f32,
h: f32,
color: u32,
) {
if let Some(r) = atlas.sprites.get(sprite) {
draw_sprite(batch, *r, nine_slice_metadata(sprite), [x, y, w, h], color);
}
}
fn draw_sprite(
batch: &mut HudBatch,
r: Rect,
nine_slice: Option<NineSlice>,
[x, y, w, h]: [f32; 4],
color: u32,
) {
match nine_slice {
Some(n) => draw_nine_slice(batch, r, n, [x, y, w, h], color),
None => batch.rect_uv(x, y, w, h, r, color),
}
}
fn draw_nine_slice(
batch: &mut HudBatch,
r: Rect,
@@ -1378,6 +1411,40 @@ mod tests {
assert_quad(q[0], ([4.0, 4.0, 54.0, 54.0], [0.0, 0.0, 1.0, 1.0]));
}
#[test]
fn blit_sprite_nine_slices_a_sprite_whose_metadata_says_so() {
// Vanilla's tooltip/frame: 100x100, border 10, stretched centre.
let n = faithful(
r#"{"gui":{"scaling":{"type":"nine_slice","width":100,"height":100,"border":10,"stretch_inner":true}}}"#,
);
let mut batch = HudBatch::new([640, 480]);
draw_sprite(
&mut batch,
sprite(100, 100),
Some(n),
[10.0, 10.0, 160.0, 26.0],
u32::MAX,
);
let q = quads(&batch);
assert_eq!(q.len(), 9);
// The corners keep their 10 pixels instead of stretching with the box.
assert_quad(q[0], ([10.0, 10.0, 20.0, 20.0], [0.0, 0.0, 0.1, 0.1]));
assert_quad(q[5], ([160.0, 26.0, 170.0, 36.0], [0.9, 0.9, 1.0, 1.0]));
}
#[test]
fn blit_sprite_stretches_a_sprite_without_metadata() {
let mut batch = HudBatch::new([640, 480]);
draw_sprite(
&mut batch,
sprite(9, 9),
None,
[0.0, 0.0, 27.0, 18.0],
u32::MAX,
);
let q = quads(&batch);
assert_eq!(q.len(), 1);
assert_quad(q[0], ([0.0, 0.0, 27.0, 18.0], [0.0, 0.0, 1.0, 1.0]));
}
#[test]
fn borders_shrink_to_half_the_drawn_size() {
let n = NineSlice {
width: 20,
+91 -25
View File
@@ -1,12 +1,10 @@
//! Resident client world: streamed chunk columns plus the bookkeeping the game
//! loop needs to relight and remesh after edits.
use crate::chunk::{Chunk, MIN_Y, SECTIONS};
use crate::fast_hash::{FxHashMap, FxHashSet};
use horizon_core::random::LegacyRandom;
use horizon_world::registry::BlockStateId;
use std::{
collections::{HashMap, HashSet},
sync::Arc,
};
use std::sync::Arc;
/// Sections (x, section index, z) waiting to be remeshed, kept by column
/// (a bit per section index), so the mesh scheduler looks at each column once
@@ -65,14 +63,14 @@ pub struct World {
/// Saved entities of chunks that just loaded, spawned by the game.
pub loaded_entities: Vec<((i32, i32), Vec<horizon_world::nbt::Tag>)>,
/// Chunks changed since the last save.
pub modified: HashSet<(i32, i32)>,
pub modified: FxHashSet<(i32, i32)>,
/// Block entities, and the order they tick in.
pub block_entities: crate::block_entities::BlockEntities,
/// Each chest's `ContainerOpenersCounter`: opener count and largest
/// interaction range.
pub chest_openers: HashMap<(i32, i32, i32), (i32, f64)>,
pub chest_openers: FxHashMap<(i32, i32, i32), (i32, f64)>,
/// `BaseContainerBlockEntity.name`: custom names of container block entities.
pub block_names: HashMap<(i32, i32, i32), String>,
pub block_names: FxHashMap<(i32, i32, i32), String>,
/// States (by id) whose writes are recorded in `watched_writes` (sculk
/// blocks, which need a block entity).
pub watched: Vec<bool>,
@@ -80,7 +78,7 @@ pub struct World {
pub watched_writes: Vec<((i32, i32, i32), u32, u32)>,
/// `ChunkAccess.inhabitedTime` of resident chunks: ticks a player spent
/// within spawning range (local difficulty).
pub inhabited: HashMap<(i32, i32), i64>,
pub inhabited: FxHashMap<(i32, i32), i64>,
pub game_time: i64,
pub day_time: i64,
pub weather: Weather,
@@ -177,13 +175,13 @@ impl World {
dirty_light: Default::default(),
light_edits: Vec::new(),
loaded_entities: Vec::new(),
modified: HashSet::new(),
modified: FxHashSet::default(),
block_entities: Default::default(),
chest_openers: HashMap::new(),
block_names: HashMap::new(),
chest_openers: FxHashMap::default(),
block_names: FxHashMap::default(),
watched: Vec::new(),
watched_writes: Vec::new(),
inhabited: HashMap::new(),
inhabited: FxHashMap::default(),
game_time: 0,
day_time: 1000,
weather: Weather::default(),
@@ -274,20 +272,40 @@ impl World {
log.push((x, y, z));
}
self.modified.insert((x >> 4, z >> 4));
// The mesher reads a 3x3x3 neighbourhood for AO, so a write marks
// the sections it can reach: this section, the one above/below
// only on a section face, and a neighbour column only on a chunk
// border (the diagonal only where both borders meet).
let sy = (y - MIN_Y) >> 4;
for dz in -1..=1 {
for dy in -1..=1 {
for dx in -1..=1 {
let (bx, by, bz) = (x + dx, y + dy, z + dz);
let s = (by - MIN_Y) >> 4;
if !(0..SECTIONS as i32).contains(&s) {
continue;
}
self.dirty_sections.insert((bx >> 4, s, bz >> 4));
}
let local_y = y & 15;
let mark = |dirty: &mut DirtySections, bx: i32, bz: i32| {
dirty.insert((bx >> 4, sy, bz >> 4));
if local_y == 0 && sy > 0 {
dirty.insert((bx >> 4, sy - 1, bz >> 4));
}
if local_y == 15 && sy + 1 < SECTIONS as i32 {
dirty.insert((bx >> 4, sy + 1, bz >> 4));
}
};
let (bx, bz) = (x & 15, z & 15);
mark(&mut self.dirty_sections, x, z);
if bx == 0 {
mark(&mut self.dirty_sections, x - 1, z);
} else if bx == 15 {
mark(&mut self.dirty_sections, x + 1, z);
}
if bz == 0 {
mark(&mut self.dirty_sections, x, z - 1);
} else if bz == 15 {
mark(&mut self.dirty_sections, x, z + 1);
}
if (bx == 0 || bx == 15) && (bz == 0 || bz == 15) {
mark(
&mut self.dirty_sections,
if bx == 0 { x - 1 } else { x + 1 },
if bz == 0 { z - 1 } else { z + 1 },
);
}
let _ = sy;
if self.chunks.get(&(x >> 4, z >> 4)).is_some_and(|c| c.lit) {
self.light_edits.push(((x, y, z), previous, state.0));
} else {
@@ -407,11 +425,59 @@ impl Default for HealthRules {
#[cfg(test)]
mod tests {
use super::World;
use crate::chunk::Chunk;
use super::{DirtySections, World};
use crate::chunk::{Chunk, MIN_Y, SECTIONS};
use horizon_world::registry::BlockStateId;
use std::sync::Arc;
/// Every block write marks the same section set the 27-cell loop did, but
/// only the distinct keys: one in the interior, a neighbour section on a
/// section face, a neighbour column on a chunk border and the diagonal at
/// a corner (eight keys at a corner on a section face).
#[test]
fn writes_mark_exactly_the_reachable_sections() {
let mut world = World::new(BlockStateId(0), 0);
world.chunks.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
for (x, y, z) in [
(3, 70, 3),
(0, 70, 3),
(15, 70, 3),
(3, 70, 0),
(0, 70, 0),
(15, 70, 15),
(0, MIN_Y, 0),
(0, 80, 0),
(15, MIN_Y + SECTIONS as i32 * 16 - 1, 15),
] {
world.dirty_sections.clear();
world.set(x, y, z, BlockStateId(1)).unwrap();
let mut expected = DirtySections::default();
for dz in -1..=1 {
for dy in -1..=1 {
for dx in -1..=1 {
let (bx, by, bz) = (x + dx, y + dy, z + dz);
let s = (by - MIN_Y) >> 4;
if (0..SECTIONS as i32).contains(&s) {
expected.insert((bx >> 4, s, bz >> 4));
}
}
}
}
let mut got: Vec<_> = world.dirty_sections.columns().collect();
let mut want: Vec<_> = expected.columns().collect();
got.sort_unstable();
want.sort_unstable();
assert_eq!(got, want, "write at {x} {y} {z}");
if (x & 15, z & 15) == (3, 3) {
assert_eq!(world.dirty_sections.len(), 1);
}
if (x & 15, z & 15, y & 15) == (0, 0, 0) && y > MIN_Y {
// Four columns, each with the section below and this one.
assert_eq!(world.dirty_sections.len(), 8);
}
}
}
/// A box misses a state only where no section it reaches lists it, and
/// unloaded columns or heights past the world count as their air.
#[test]
@@ -55,3 +55,79 @@ fn luma(c: vec3<f32>) -> f32 {
}
return vec4(b, centre.a);
}
// TAA (`world_post.rs`): blend this frame into the history, reprojected from the
// depth buffer through the camera's motion and clamped to this frame's 3x3
// neighbourhood so that what moved or appeared does not ghost.
struct Taa {
reproject: mat4x4<f32>,
// xy: this frame's jitter in clip space; z: 1 when the history is unusable.
jitter: vec4<f32>,
};
@group(0) @binding(2) var history: texture_2d<f32>;
@group(0) @binding(3) var depth: texture_depth_2d;
@group(0) @binding(4) var<uniform> taa: Taa;
// Catmull-Rom over the history in 5 bilinear taps: sharper than one under motion.
fn sample_history(uv: vec2<f32>) -> vec3<f32> {
let size = vec2<f32>(textureDimensions(history));
let pos = uv * size;
let centre = floor(pos - 0.5) + 0.5;
let f = pos - centre;
let w0 = f * (-0.5 + f * (1.0 - 0.5 * f));
let w1 = 1.0 + f * f * (-2.5 + 1.5 * f);
let w2 = f * (0.5 + f * (2.0 - 1.5 * f));
let w3 = f * f * (-0.5 + 0.5 * f);
let w12 = w1 + w2;
let t0 = (centre - 1.0) / size;
let t3 = (centre + 2.0) / size;
let t12 = (centre + w2 / w12) / size;
let c = textureSampleLevel(history, world_sampler, vec2(t12.x, t0.y), 0.0).rgb * (w12.x * w0.y)
+ textureSampleLevel(history, world_sampler, vec2(t0.x, t12.y), 0.0).rgb * (w0.x * w12.y)
+ textureSampleLevel(history, world_sampler, t12, 0.0).rgb * (w12.x * w12.y)
+ textureSampleLevel(history, world_sampler, vec2(t3.x, t12.y), 0.0).rgb * (w3.x * w12.y)
+ textureSampleLevel(history, world_sampler, vec2(t12.x, t3.y), 0.0).rgb * (w12.x * w3.y);
let weight = w12.x * w0.y + w0.x * w12.y + w12.x * w12.y + w3.x * w12.y + w12.x * w3.y;
return max(c / weight, vec3(0.0));
}
// It runs at the history's size, which upscaling makes larger than the world's.
// The history is unjittered: each output pixel takes the world pixel whose
// jittered sample lands nearest its centre, weighted by how near it lands.
@fragment fn fs_taa(in: PostOut) -> @location(0) vec4<f32> {
let size = vec2<f32>(textureDimensions(world));
// The jitter moved the scene by `jitter` in clip space: where this pixel's
// point was drawn, in world pixels.
let drawn = (in.uv + taa.jitter.xy * vec2(0.5, -0.5)) * size;
let last = vec2<i32>(size) - 1;
let p = clamp(vec2<i32>(floor(drawn)), vec2(0), last);
let centre = textureLoad(world, p, 0);
var lo = centre.rgb;
var hi = centre.rgb;
// Reversed depth: the nearest of the 3x3 is the largest, and its motion keeps
// the edges of what is in front from trailing.
var near = 0.0;
for (var y = -1; y <= 1; y++) {
for (var x = -1; x <= 1; x++) {
let q = clamp(p + vec2(x, y), vec2(0), last);
let c = textureLoad(world, q, 0).rgb;
lo = min(lo, c);
hi = max(hi, c);
near = max(near, textureLoad(depth, q, 0));
}
}
let ndc = vec2(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0);
let previous = taa.reproject * vec4(ndc, near, 1.0);
let uv = vec2(0.5, -0.5) * previous.xy / previous.w + 0.5;
if taa.jitter.z > 0.0 || any(uv != clamp(uv, vec2(0.0), vec2(1.0))) {
return vec4(centre.rgb, centre.a);
}
let past = clamp(sample_history(uv), lo, hi);
// How far the sample landed from this pixel's centre, in output pixels: a
// Gaussian of it scales the new frame's share, so that frames whose sample
// fell far away (most of them when upscaling) barely move the pixel.
let off = (drawn - (vec2<f32>(p) + 0.5)) * vec2<f32>(textureDimensions(history)) / size;
let share = 0.1 * exp(-2.0 * dot(off, off));
// The alpha carries the emissive mark through to HDR output (hdr.wgsl).
return vec4(mix(past, centre.rgb, share), centre.a);
}
@@ -11,5 +11,57 @@
"horizon.options.look_deadzone": "Look Deadzone",
"horizon.options.button_glyphs": "Button Glyphs",
"horizon.options.menu_navigation": "Menu Navigation",
"horizon.options.on_screen_keyboard": "On-Screen Keyboard"
"horizon.options.on_screen_keyboard": "On-Screen Keyboard",
"horizon.options.chat_delay": "Chat Delay",
"horizon.options.lightbar.health": "Health",
"horizon.options.lightbar.dimension": "Dimension",
"horizon.options.glyphs.auto": "Auto",
"horizon.options.glyphs.xbox": "Xbox",
"horizon.options.glyphs.playstation": "PlayStation",
"horizon.options.glyphs.nintendo": "Nintendo",
"horizon.options.menu_focus": "Focus",
"horizon.options.menu_pointer": "Pointer",
"horizon.options.keyboard.auto": "Auto",
"horizon.options.keyboard.horizon": "Horizon",
"horizon.options.keyboard.steam": "Steam",
"horizon.options.unavailable": "This feature is not yet available in Horizon.",
"horizon.options.search": "Search settings...",
"horizon.pages.mods": "Mods",
"horizon.pages.shader_packs": "Shader Packs",
"horizon.pages.controller": "Controller Settings",
"horizon.pages.controller_binds": "Controller Binds",
"horizon.pages.extras": "Horizon Extras",
"horizon.pages.settings": "Horizon Settings",
"horizon.pages.agent": "AI Agent",
"horizon.pages.settings.tooltip": "Horizon's own settings: graphics, controllers, extras and mods.",
"horizon.pages.graphics.tooltip": "Graphics, simply: each setting says what it costs.",
"horizon.pages.graphics_advanced.tooltip": "Every graphics setting, Horizon's included, with what each costs.",
"horizon.pages.controller.tooltip": "Controllers: their buttons, sticks, vibration and menu navigation.",
"horizon.pages.extras.tooltip": "Optional additions, all off by default: the game stays vanilla until you turn one on.",
"horizon.pages.agent.tooltip": "Let your own AI agent play: horizon-ctl and MCP clients on this computer.",
"horizon.pages.shader_packs.tooltip": "Shader packs from the shaderpacks folder: Horizon's own and those in the shaders/ format. One runs at a time.",
"horizon.pages.mods.tooltip": "Mods made for Horizon, from the horizon-mods folder. Each runs in a sandbox and stays off until you turn it on.",
"horizon.pages.open.graphics": "Graphics...",
"horizon.pages.open.graphics_advanced": "Advanced Graphics...",
"horizon.pages.open.shader_packs": "Shader Packs...",
"horizon.pages.open.controller": "Controller Settings...",
"horizon.pages.open.extras": "Extras...",
"horizon.pages.open.mods": "Mods...",
"horizon.pages.open.agent": "AI Agent...",
"horizon.pages.open.horizon_graphics": "Horizon Graphics...",
"horizon.pages.open.controller_binds": "Controller Binds...",
"horizon.pages.open.horizon_settings": "Horizon Settings...",
"horizon.mods.empty": "No mods: put .hmod files in the horizon-mods folder",
"horizon.mods.stopped": " (Stopped)",
"horizon.mods.dev": " (Dev)",
"horizon.mods.settings": "Settings",
"horizon.mods.open_folder": "Open Mod Folder",
"horizon.shader_packs.empty": "No shader packs: put them in the shaderpacks folder",
"horizon.shader_packs.kind_horizon": " (Horizon)",
"horizon.agent.copy_details": "Copy Connection Details",
"horizon.multiplayer.join": "Join",
"horizon.multiplayer.cancel_reconnecting": "Cancel Reconnecting",
"horizon.multiplayer.join_friend": "Join a Friend",
"horizon.multiplayer.invite_code": "Invite Code",
"horizon.multiplayer.relay": "Relay (optional)"
}
@@ -11,5 +11,57 @@
"horizon.options.look_deadzone": "Мёртвая зона обзора",
"horizon.options.button_glyphs": "Символы кнопок",
"horizon.options.menu_navigation": "Навигация по меню",
"horizon.options.on_screen_keyboard": "Экранная клавиатура"
"horizon.options.on_screen_keyboard": "Экранная клавиатура",
"horizon.options.chat_delay": "Задержка чата",
"horizon.options.lightbar.health": "Здоровье",
"horizon.options.lightbar.dimension": "Измерение",
"horizon.options.glyphs.auto": "Авто",
"horizon.options.glyphs.xbox": "Xbox",
"horizon.options.glyphs.playstation": "PlayStation",
"horizon.options.glyphs.nintendo": "Nintendo",
"horizon.options.menu_focus": "Фокус",
"horizon.options.menu_pointer": "Указатель",
"horizon.options.keyboard.auto": "Авто",
"horizon.options.keyboard.horizon": "Horizon",
"horizon.options.keyboard.steam": "Steam",
"horizon.options.unavailable": "Эта возможность в Horizon пока недоступна.",
"horizon.options.search": "Поиск настроек...",
"horizon.pages.mods": "Моды",
"horizon.pages.shader_packs": "Шейдерпаки",
"horizon.pages.controller": "Настройки контроллера",
"horizon.pages.controller_binds": "Назначения кнопок контроллера",
"horizon.pages.extras": "Дополнения Horizon",
"horizon.pages.settings": "Настройки Horizon",
"horizon.pages.agent": "ИИ-агент",
"horizon.pages.settings.tooltip": "Собственные настройки Horizon: графика, контроллеры, дополнения и моды.",
"horizon.pages.graphics.tooltip": "Графика без лишнего: у каждой настройки написано, чего она стоит.",
"horizon.pages.graphics_advanced.tooltip": "Все графические настройки, включая собственные настройки Horizon, и их цена.",
"horizon.pages.controller.tooltip": "Контроллеры: кнопки, стики, вибрация и навигация по меню.",
"horizon.pages.extras.tooltip": "Дополнительные возможности, все выключены по умолчанию: игра остаётся ванильной, пока вы не включите что-то.",
"horizon.pages.agent.tooltip": "Пусть играет ваш ИИ-агент: horizon-ctl и MCP-клиенты на этом компьютере.",
"horizon.pages.shader_packs.tooltip": "Шейдерпаки из папки shaderpacks: собственные паки Horizon и паки в формате shaders/. Одновременно работает один.",
"horizon.pages.mods.tooltip": "Моды для Horizon из папки horizon-mods. Каждый работает в песочнице и выключен, пока вы его не включите.",
"horizon.pages.open.graphics": "Графика...",
"horizon.pages.open.graphics_advanced": "Продвинутая графика...",
"horizon.pages.open.shader_packs": "Шейдерпаки...",
"horizon.pages.open.controller": "Настройки контроллера...",
"horizon.pages.open.extras": "Дополнения...",
"horizon.pages.open.mods": "Моды...",
"horizon.pages.open.agent": "ИИ-агент...",
"horizon.pages.open.horizon_graphics": "Графика Horizon...",
"horizon.pages.open.controller_binds": "Назначения кнопок...",
"horizon.pages.open.horizon_settings": "Настройки Horizon...",
"horizon.mods.empty": "Модов нет: положите файлы .hmod в папку horizon-mods",
"horizon.mods.stopped": " (остановлен)",
"horizon.mods.dev": " (разработка)",
"horizon.mods.settings": "Настройки",
"horizon.mods.open_folder": "Открыть папку модов",
"horizon.shader_packs.empty": "Шейдерпаков нет: положите их в папку shaderpacks",
"horizon.shader_packs.kind_horizon": " (Horizon)",
"horizon.agent.copy_details": "Скопировать данные подключения",
"horizon.multiplayer.join": "Подключиться",
"horizon.multiplayer.cancel_reconnecting": "Отменить переподключение",
"horizon.multiplayer.join_friend": "Играть с другом",
"horizon.multiplayer.invite_code": "Код приглашения",
"horizon.multiplayer.relay": "Ретранслятор (необязательно)"
}
@@ -302,7 +302,7 @@ fn avatar(c: &Value, out: &mut impl Write) -> Result<()> {
for op in c["ops"].as_array().unwrap() {
let name = op[0].as_str().unwrap();
match name {
"tick" => state.tick(),
"tick" => state.tick([0.0; 3]),
"move" => state.add_walked(fl(&op[1]), fl(&op[2])),
"bob" => state.update_bob(b(&op[1]) && !b(&op[2]) && !b(&op[3]), vec3(&op[4])),
"reset" => state.reset_bob(),
@@ -0,0 +1,74 @@
//! Double chests for tools/test_chest_pair_parity.py: reads one JSON case per
//! line (see VanillaChestPairOracle), a chest's block state at the origin and
//! another block state at the offset "at", and prints what
//! `horizon_client::containers::ChestHalf` pairs them into, as the game's
//! menus and hoppers do: "double" with the right half's x,z then the left
//! half's, or "single".
//!
//! usage: chest_pair_fixture INPUT OUTPUT
use horizon_client::containers::{ChestHalf, is_chest_block};
use serde_json::Value;
use std::collections::BTreeMap;
use std::fs;
/// `minecraft:chest[facing=north,type=left]` as its name and properties.
fn parse(state: &str) -> Option<(&str, BTreeMap<&str, &str>)> {
let (name, props) = match state.split_once('[') {
Some((name, rest)) => (name, rest.strip_suffix(']')?),
None => (state, ""),
};
let props = props
.split(',')
.filter(|p| !p.is_empty())
.map(|p| p.split_once('='))
.collect::<Option<_>>()?;
Some((name, props))
}
fn half<'a>(name: &'a str, props: &BTreeMap<&str, &'a str>) -> Option<ChestHalf<'a>> {
Some(ChestHalf {
block: name,
kind: props.get("type")?,
facing: props.get("facing")?,
})
}
fn case(line: &str) -> Option<String> {
let case: Value = serde_json::from_str(line).ok()?;
let (name, props) = parse(case["state"].as_str()?)?;
let (other_name, other_props) = parse(case["other"].as_str()?)?;
let at = (
i32::try_from(case["at"][0].as_i64()?).ok()?,
i32::try_from(case["at"][1].as_i64()?).ok()?,
);
if !is_chest_block(name) {
return None;
}
let own = half(name, &props)?;
let paired = own.connected_step() == Some(at)
&& half(other_name, &other_props).is_some_and(|other| own.pairs_with(&other));
Some(if !paired {
"single".to_owned()
} else if own.kind == "right" {
format!("double 0,0 {},{}", at.0, at.1)
} else {
format!("double {},{} 0,0", at.0, at.1)
})
}
pub fn main() -> horizon_client::Result<()> {
let args: Vec<String> = crate::args().collect();
let input = fs::read_to_string(
args.get(1)
.ok_or("usage: chest_pair_fixture INPUT OUTPUT")?,
)?;
let lines: Vec<String> = input
.lines()
.map(|line| case(line).unwrap_or_else(|| "error".to_owned()))
.collect();
fs::write(
args.get(2).ok_or("missing OUTPUT")?,
lines.join("\n") + "\n",
)?;
Ok(())
}
@@ -0,0 +1,98 @@
//! Container menu titles for tools/test_container_title_parity.py: reads one
//! JSON case per line (see VanillaContainerTitleOracle) and prints the title
//! the server opens the container's menu with, as canonical NBT. A single
//! block entity is placed as the game places it (`block_entity_item::place`,
//! whose `CustomName` the game keeps in `World::block_names`), then named by
//! `containers::display_name`; a double chest's halves by
//! `containers::double_chest_name`.
//!
//! usage: container_title_fixture INPUT OUTPUT
use horizon_client::block_entity_item::place;
use horizon_client::block_entity_nbt::canonical;
use horizon_client::containers::{default_name_key, display_name, double_chest_name};
use horizon_client::save::text;
use horizon_world::nbt::{Tag, parse_snbt};
use serde_json::Value;
use std::fs;
/// ParityData.canon of a text component: sorted keys.
fn canon(tag: &Tag, out: &mut String) {
match tag {
Tag::Compound(map) => {
let mut keys: Vec<_> = map.iter().map(|(k, v)| (k.to_string_lossy(), v)).collect();
keys.sort_by(|a, b| a.0.cmp(&b.0));
out.push('{');
for (i, (k, v)) in keys.into_iter().enumerate() {
if i > 0 {
out.push(',');
}
out.push_str(&k);
out.push(':');
canon(v, out);
}
out.push('}');
}
Tag::List(items) => {
out.push('[');
for (i, item) in items.iter().enumerate() {
if i > 0 {
out.push(',');
}
canon(item, out);
}
out.push(']');
}
other => out.push_str(&horizon_client::item_data::canon_tag(other)),
}
}
fn snbt(value: &Value) -> Option<Tag> {
parse_snbt(value.as_str()?).ok().map(|(tag, _)| tag)
}
/// The custom name the game keeps for a block entity saved as `value`.
fn custom_name(value: &Value) -> Option<Option<String>> {
let saved = canonical(&snbt(value)?)?;
Some(text(saved.get("CustomName")))
}
fn case(line: &str) -> Option<String> {
let case: Value = serde_json::from_str(line).ok()?;
let title = if let Some(first) = case.get("first") {
let first = custom_name(first)?;
let second = custom_name(&case["second"])?;
double_chest_name(first.as_deref(), second.as_deref())
} else {
let saved = canonical(&snbt(&case["nbt"])?)?;
// BlockItem.updateBlockEntityComponents, as Game::apply_placed_item runs it.
let placed = match case.get("patch") {
Some(p) => match snbt(p)? {
Tag::Compound(patch) => place(&saved, &patch, true),
_ => return None,
},
None => saved,
};
let key = default_name_key(case["block"].as_str()?)?;
display_name(text(placed.get("CustomName")).as_deref(), key)
};
let mut out = String::new();
canon(&title, &mut out);
Some(out)
}
pub fn main() -> horizon_client::Result<()> {
let args: Vec<String> = crate::args().collect();
let input = fs::read_to_string(
args.get(1)
.ok_or("usage: container_title_fixture INPUT OUTPUT")?,
)?;
let lines: Vec<String> = input
.lines()
.map(|line| case(line).unwrap_or_else(|| "error".to_owned()))
.collect();
fs::write(
args.get(2).ok_or("missing OUTPUT")?,
lines.join("\n") + "\n",
)?;
Ok(())
}
@@ -24,9 +24,11 @@ mod breaking_fixture;
mod camera_probe;
mod cauldron_fixture;
mod chat_screen_fixture;
mod chest_pair_fixture;
mod cloud_fixture;
mod command_parse_fixture;
mod container_screen_probe;
mod container_title_fixture;
mod creative_mining_probe;
mod creative_search_fixture;
mod cushion_placement_probe;
@@ -90,6 +92,7 @@ mod subtitle_fixture;
mod tab_list_fixture;
mod team_rules_fixture;
mod text_display_fixture;
mod text_display_render_fixture;
mod toggle_keys_probe;
mod tooltip_display_fixture;
mod trading_fixture;
@@ -115,9 +118,11 @@ probes! {
cushion_placement_probe,
cauldron_fixture,
chat_screen_fixture,
chest_pair_fixture,
cloud_fixture,
command_parse_fixture,
container_screen_probe,
container_title_fixture,
creative_search_fixture,
emitter_particles_probe,
enchanting_fixture,
@@ -180,6 +185,7 @@ probes! {
toggle_keys_probe,
tooltip_display_fixture,
text_display_fixture,
text_display_render_fixture,
trading_fixture,
translucency_fixture,
weather_fixture,
@@ -67,8 +67,9 @@ struct Scenario {
/// The player hits a mob for 1 at the start of a tick: (tick, mob index).
hurts: Vec<(i32, usize)>,
/// The player feeds an animal at the start of a tick (Animal.mobInteract):
/// (tick, mob index); the scenario only feeds each animal its food.
feeds: Vec<(i32, usize)>,
/// (tick, mob index, None); the scenario only feeds each animal its food.
/// `interact` uses another item on a horse: (tick, mob index, Some(item)).
feeds: Vec<(i32, usize, Option<String>)>,
hand: Option<String>,
mobs: Vec<MobSpec>,
}
@@ -108,7 +109,14 @@ fn parse(text: &str) -> Vec<Scenario> {
}
"heart" => s.hearts.push(f.iter().map(|x| (*x).to_owned()).collect()),
"hurt" => s.hurts.push((f[1].parse().unwrap(), f[2].parse().unwrap())),
"feed" => s.feeds.push((f[1].parse().unwrap(), f[2].parse().unwrap())),
"feed" => s
.feeds
.push((f[1].parse().unwrap(), f[2].parse().unwrap(), None)),
"interact" => s.feeds.push((
f[1].parse().unwrap(),
f[2].parse().unwrap(),
Some(f[3].to_owned()),
)),
"player" => {
s.player = Some([
f[1].parse().unwrap(),
@@ -725,6 +733,15 @@ fn print(out: &mut String, t: i32, i: usize, m: &Mob, player_down: bool, now: i6
.map_or("-".into(), |p| format!("{},{},{}", p[0], p[1], p[2]));
let _ = write!(out, "\tcg {} {held} {opened}", m.extra.variant3);
}
if m.species() == Species::Wolf {
// NeutralMob anger left (the angry texture), -1 when calm.
let anger = if m.extra.anger > 0 {
i64::from(m.extra.anger)
} else {
-1
};
let _ = write!(out, "\twolf {anger}");
}
if let Some(b) = horizon_client::mobs::bee::get(m) {
let anger = if b.anger_end > 0 {
b.anger_end - now
@@ -1209,10 +1226,22 @@ pub fn main() -> horizon_client::Result<()> {
}
events.append(&mut mobs[i].outbox);
}
for &(tick, i) in &s.feeds {
for (tick, i, item) in &s.feeds {
let (tick, i) = (*tick, *i);
if tick != t || i >= mobs.len() || mobs[i].removed {
continue;
}
// Horse.mobInteract's held-item branch (makeMad).
if let Some(item) = item {
horizon_client::mobs::equine::held_item(
&mut mobs[i],
item,
false,
item == "minecraft:golden_dandelion",
&mut events,
);
continue;
}
if let Some(Some((name, volume, pitch))) =
horizon_client::mobs::ai::feed_animal(&mut mobs[i], &mut level_random)
{
@@ -126,6 +126,7 @@ pub fn main() -> horizon_client::Result<()> {
.collect();
let mut screen = PackScreen::new(
&m,
Text::translate(c["title"].as_str().unwrap_or("resourcePack.title"), &[]),
int(&c["width"]),
int(&c["height"]),
c["inWorld"] == true,
@@ -0,0 +1,70 @@
//! A text display's text for differential tests against vanilla's
//! `TextDisplayRenderer.submitInner` (tools/test_text_display_render_parity.py):
//! decodes each case's entity data from vanilla's wire bytes, lays the text out
//! with `horizon_client::text_display` and prints the pose and every quad in
//! text units [x, y, z, ARGB], as VanillaTextDisplayRenderOracle does.
//!
//! usage: text_display_render_fixture RESOURCES INPUTS OUTPUT
use horizon_client::{
hud::{HudAtlas, HudBatch},
hud_layout::background_color,
text_display::{self, Lines, TextDisplayData},
};
use horizon_protocol::{ids, packets::play::Clientbound};
use serde_json::{Value, json};
use std::{error::Error, io::Write};
pub fn main() -> Result<(), Box<dyn Error>> {
let args: Vec<String> = crate::args().collect();
let [_, resources, inputs, output] = args.as_slice() else {
return Err("usage: text_display_render_fixture RESOURCES INPUTS OUTPUT".into());
};
let atlas = HudAtlas::build(std::path::Path::new(resources))?;
let mut out = std::io::BufWriter::new(std::fs::File::create(output)?);
for line in std::fs::read_to_string(inputs)?.lines() {
let input: Value = serde_json::from_str(line)?;
let hex = input["wire"].as_str().unwrap_or_default();
let bytes: Vec<u8> = (0..hex.len())
.step_by(2)
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16))
.collect::<Result<_, _>>()?;
let Clientbound::SetEntityData(packet) =
Clientbound::decode(ids::play::clientbound::SET_ENTITY_DATA, &bytes)?
else {
return Err("not entity data".into());
};
let mut d = TextDisplayData::default();
d.apply(&packet.values);
let lines = Lines::split(&atlas.font, &d.text, d.line_width);
let mut batch = HudBatch::new([1, 1]);
let default_background = background_color(
input["chat_only"].as_bool().unwrap_or(false),
input["background_opacity"].as_f64().unwrap_or(0.0),
0.25,
);
text_display::draw(&mut batch, &atlas, &d, &lines, default_background);
let quads: Vec<Value> = batch
.vertices
.as_chunks::<6>()
.0
.iter()
.map(|q| {
let corners: Vec<Value> = [0, 1, 2, 5]
.map(|i| {
let v = q[i];
let [r, g, b, a] = v.color.to_le_bytes().map(u32::from);
let argb = (a << 24 | r << 16 | g << 8 | b) as i32;
json!([v.pos[0], v.pos[1], v.depth, argb])
})
.into();
corners.into()
})
.collect();
writeln!(
out,
"{}",
json!({"pose": lines.pose().to_cols_array(), "quads": quads})
)?;
}
Ok(())
}
+16
View File
@@ -877,6 +877,22 @@ pub fn item_entity(
&& item(&v["item"], stack, w)
}
/// `RecipeCraftedTrigger.TriggerInstance.matches`: each ingredient predicate
/// takes a different one of the stacks used, in order.
pub fn recipe_ingredients(v: &Value, used: &[ItemView], w: &dyn World) -> bool {
let Some(preds) = v.as_array() else {
return true;
};
let mut remaining: Vec<&ItemView> = used.iter().collect();
preds.iter().all(|p| {
let Some(i) = remaining.iter().position(|it| item(p, it, w)) else {
return false;
};
remaining.remove(i);
true
})
}
/// A `ContextAwarePredicate`: a list of loot conditions (or one condition).
pub fn conditions(v: &Value, ctx: &Entities, w: &dyn World) -> bool {
match v {
+165
View File
@@ -527,6 +527,53 @@ impl Creative {
pub fn scroller_offset(&self) -> f32 {
((Self::SCROLL_HEIGHT - 17.0) * self.scroll) as i32 as f32
}
/// The tabs whose `CreativeModeTab.contains(stack)` holds, in
/// `CreativeModeTabs.tabs()` order, the search tab left out: the stack is
/// among the tab's search-tab items, by item and components. The export
/// lists each tab's own items; the search tab's stacks that no tab shows
/// (the enchanted books below their top level, `SEARCH_TAB_ONLY`) belong
/// to the tabs that show their item.
pub fn tabs_containing(&self, stack: &ItemStack) -> Vec<usize> {
let category = |t: &Tab| !t.search && !t.inventory;
let tabs_where = |shows: &dyn Fn(&ItemStack) -> bool| -> Vec<usize> {
(0..self.tabs.len())
.filter(|&i| category(&self.tabs[i]) && self.tabs[i].items.iter().any(shows))
.collect()
};
let found = tabs_where(&|s| same_components(s, stack));
if found.is_empty() && self.search_items.iter().any(|s| same_components(s, stack)) {
return tabs_where(&|s| s.item == stack.item);
}
found
}
/// The `#namespace:path` lines `getTooltipFromContainerItem` adds for a
/// search tab stack while the search is a `#` query: the item tags
/// `updateVisibleTags` keeps for the text after `#` (the path contains it;
/// with a colon, the namespace and the path each contain their part) that
/// the stack has.
pub fn search_tag_lines(&self, stack: &ItemStack) -> Vec<String> {
let Some(text) = self.search.value.strip_prefix('#') else {
return Vec::new();
};
let Some(entry) = self
.search_items
.iter()
.position(|s| same_components(s, stack))
.and_then(|i| self.search_index.get(i))
else {
return Vec::new();
};
let visible = |(ns, path): &(String, String)| match text.split_once(':') {
None => path.contains(text),
Some((n, p)) => ns.contains(java_trim(n)) && path.contains(java_trim(p)),
};
entry
.tags
.iter()
.filter(|t| visible(t))
.map(|(ns, path)| format!("#{ns}:{path}"))
.collect()
}
/// Tab button position relative to the screen origin (CreativeModeInventoryScreen.getTabX/Y).
pub fn tab_position(tab: &Tab) -> (f32, f32) {
let mut x = tab.column as f32 * 27.0;
@@ -543,6 +590,21 @@ impl Creative {
fn any<'a>(strings: impl IntoIterator<Item = &'a str>, text: &str) -> bool {
strings.into_iter().any(|s| s.contains(text))
}
/// `ItemStack.isSameItemSameComponents`, the count left out, as the
/// tabs' `ItemStackLinkedSet` compares: no components and empty ones match.
fn same_components(a: &ItemStack, b: &ItemStack) -> bool {
let none = crate::item_data::StackData::default();
a.item == b.item
&& a.damage == b.damage
&& a.data().unwrap_or(&none) == b.data().unwrap_or(&none)
}
/// `CreativeModeInventoryScreen.getTooltipFromContainerItem`: the plain
/// tooltip only for a category tab's own grid slots (`CustomCreativeSlot`);
/// everywhere else in the screen the tab names (and, on the search tab's
/// grid, the tag lines) follow the item's name.
pub fn tooltip_lists_tabs(category_tab: bool, grid_slot: bool) -> bool {
!(category_tab && grid_slot)
}
/// String.toLowerCase(Locale.ROOT).
fn java_lower(s: &str) -> String {
s.to_lowercase()
@@ -690,6 +752,109 @@ mod tests {
(carried, effect)
}
/// Tabs as the export lists them: two block tabs sharing the oak log,
/// ingredients with the top-level book only, the search tab with every
/// stack (a level 1 book only there) and its tags, the inventory.
fn tooltip_tabs() -> Creative {
let book = |level: u8| {
format!(
r#"{{components:{{"minecraft:stored_enchantments":{{"minecraft:sharpness":{level}}}}},count:1,id:"minecraft:enchanted_book"}}"#
)
};
let log = r#"{count:1,id:"minecraft:oak_log"}"#;
let dirt = r#"{count:1,id:"minecraft:dirt"}"#;
let tab = |id: &str, kind: &str, items: Vec<String>| serde_json::json!({"id": id, "type": kind, "title": id, "icon": log, "items": items});
let mut search = tab(
"minecraft:search",
"SEARCH",
vec![log.into(), dirt.into(), book(1), book(5)],
);
search["lines"] = serde_json::json!([[], [], [], []]);
search["tags"] = serde_json::json!([
[
"minecraft:logs",
"minecraft:oak_logs",
"minecraft:logs_that_burn"
],
["minecraft:dirt"],
[],
[]
]);
Creative::from_json(&serde_json::json!({"tabs": [
tab("minecraft:building_blocks", "CATEGORY", vec![log.into()]),
tab("minecraft:natural_blocks", "CATEGORY", vec![log.into(), dirt.into()]),
tab("minecraft:hotbar", "HOTBAR", vec![]),
search,
tab("minecraft:ingredients", "CATEGORY", vec![book(5)]),
tab("minecraft:inventory", "INVENTORY", vec![]),
]}))
.unwrap()
}
#[test]
fn tooltip_lists_the_tabs_that_contain_the_stack() {
let c = tooltip_tabs();
let ids = |s: &ItemStack| -> Vec<&str> {
c.tabs_containing(s)
.into_iter()
.map(|i| c.tabs[i].id.as_str())
.collect()
};
// In tab order, the search and inventory tabs left out; the count
// doesn't matter.
assert_eq!(
ids(&stack("oak_log", 37)),
["minecraft:building_blocks", "minecraft:natural_blocks"]
);
assert_eq!(ids(&stack("dirt", 1)), ["minecraft:natural_blocks"]);
// A stack no tab holds: other components, or no creative item.
let mut damaged = stack("oak_log", 1);
damaged.damage = 3;
assert!(ids(&damaged).is_empty());
assert!(ids(&stack("barrier", 1)).is_empty());
// A level 1 book is a search-tab-only stack of the ingredients tab.
let books: Vec<&ItemStack> = c
.search_items()
.iter()
.filter(|s| &*s.item == "minecraft:enchanted_book")
.collect();
assert_eq!(ids(books[0]), ["minecraft:ingredients"]);
assert_eq!(ids(books[1]), ["minecraft:ingredients"]);
}
#[test]
fn tooltip_tab_names_skip_only_a_category_tab_grid() {
assert!(!tooltip_lists_tabs(true, true));
// The hotbar row of a category tab, the search tab's grid and hotbar,
// the inventory tab.
assert!(tooltip_lists_tabs(true, false));
assert!(tooltip_lists_tabs(false, true));
assert!(tooltip_lists_tabs(false, false));
}
#[test]
fn tag_search_lists_the_matching_tags_of_the_stack() {
let mut c = tooltip_tabs();
let log = stack("oak_log", 1);
assert!(c.search_tag_lines(&log).is_empty());
c.search.set_value("logs");
assert!(c.search_tag_lines(&log).is_empty());
c.search.set_value("#logs");
assert_eq!(
c.search_tag_lines(&log),
[
"#minecraft:logs",
"#minecraft:oak_logs",
"#minecraft:logs_that_burn"
]
);
c.search.set_value("#oak");
assert_eq!(c.search_tag_lines(&log), ["#minecraft:oak_logs"]);
c.search.set_value("#mine : burn");
assert_eq!(c.search_tag_lines(&log), ["#minecraft:logs_that_burn"]);
assert!(c.search_tag_lines(&stack("dirt", 1)).is_empty());
}
#[test]
fn search_tab_number_keys_swap_over_a_slot_before_typing() {
let filled = Hover::Display { filled: true };
+28
View File
@@ -152,6 +152,14 @@ impl DebugCharts {
}
self.packet_tick = (self.packet_tick + 1) % 20;
}
/// Whether `draw` (or the lightmap view) puts something in the bottom
/// left and the bottom right corner, for a `remote` server or not.
pub fn corners(&self, remote: bool) -> [bool; 2] {
[
self.fps || (self.network && remote),
(self.fps && !self.tick_times.is_empty()) || self.network || self.lightmap,
]
}
/// The charts along the bottom of the screen (`extractRenderState`):
/// frame times left and tick times right, or bandwidth left (remote
/// servers only) and ping right. `mspt` is the level's milliseconds
@@ -525,6 +533,26 @@ mod tests {
assert!(overlay && c.fps);
}
#[test]
fn corners_are_where_the_charts_are_drawn() {
let mut c = DebugCharts::default();
assert_eq!(c.corners(true), [false, false]);
// Frame times left; tick times right once the server logged a tick.
c.fps = true;
assert_eq!(c.corners(false), [true, false]);
c.tick_times.log(1);
assert_eq!(c.corners(false), [true, true]);
// Ping right; bandwidth left on a remote server only.
c.fps = false;
c.network = true;
assert_eq!(c.corners(false), [false, true]);
assert_eq!(c.corners(true), [true, true]);
// The lightmap view, bottom right.
c.network = false;
c.lightmap = true;
assert_eq!(c.corners(true), [false, true]);
}
#[test]
fn the_lightmap_puts_full_sky_light_at_the_top_left() {
let mut pixels = [0u8; 16 * 16 * 4];
+40
View File
@@ -953,7 +953,20 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
continue;
};
let v = f64::from_bits(*bits);
// A mount's rolled health and speed stay attribute bases, which
// the server syncs (its hearts over the rider's hotbar).
let equine = crate::mobs::equine::is_equine(m.species());
match id.as_str() {
"minecraft:max_health" if equine => crate::mobs::finalize::set_base(
&mut m,
crate::mobs::finalize::Attr::MaxHealth,
v,
),
"minecraft:movement_speed" if equine => crate::mobs::finalize::set_base(
&mut m,
crate::mobs::finalize::Attr::MovementSpeed,
v,
),
"minecraft:max_health" => m.attrs.max_health = v as f32,
"minecraft:movement_speed" => m.attrs.speed = v,
"minecraft:attack_damage" => m.attrs.attack_damage = v as f32,
@@ -1692,6 +1705,33 @@ pub fn fireball_from_nbt(tag: &Tag, player: Option<[i32; 4]>) -> Option<Projecti
Some(p)
}
/// `FireworkRocketEntity.readAdditionalSaveData`: Life, LifeTime (0 when
/// absent: a bare `/summon` explodes at once), FireworksItem and
/// ShotAtAngle, with `Projectile`'s Owner and LeftOwner.
pub struct RocketNbt {
pub position: Vec3,
pub motion: Vec3,
pub life: i32,
pub lifetime: i32,
pub item: Option<crate::items::ItemStack>,
pub angled: bool,
pub owner: Option<Who>,
pub left_owner: bool,
}
pub fn firework_rocket_from_nbt(tag: &Tag, player: Option<[i32; 4]>) -> Option<RocketNbt> {
let owner = uuid_from_tag(tag.get("Owner"));
Some(RocketNbt {
position: position(tag)?,
motion: motion(tag),
life: number(tag.get("Life")).unwrap_or(0) as i32,
lifetime: number(tag.get("LifeTime")).unwrap_or(0) as i32,
item: tag.get("FireworksItem").and_then(item_from_nbt),
angled: flag(tag, "ShotAtAngle"),
owner: (player.is_some() && owner == player).then_some(Who::Player),
left_owner: flag(tag, "LeftOwner"),
})
}
/// `FallingBlockEntity`: BlockState, Time, DropItem, HurtEntities.
#[allow(clippy::too_many_arguments)]
pub fn falling_block_to_nbt(
+169 -1
View File
@@ -12,6 +12,8 @@
#[derive(Clone, Copy, Debug)]
pub enum ItemPose {
Swing,
/// A STAB swing (`SwingAnimationType` 2): a spear's jab.
Stab,
Using,
Eat {
remaining: f32,
@@ -86,6 +88,9 @@ pub fn item_pose(left: bool, inverse_height: f32, attack: f32, pose: ItemPose) -
return Mat4::from_translation(Vec3::new(invert * 0.56, -0.52, -0.72))
* crate::spear::first_person_use(&kinetic, ticks_since_hit, time_held, !left);
}
if matches!(pose, ItemPose::Stab) {
return m * crate::spear::first_person_attack(attack, !left);
}
if matches!(pose, ItemPose::SpinAttack) {
return m
* Mat4::from_translation(Vec3::new(invert * -0.4, 0.8, 0.3))
@@ -172,8 +177,94 @@ impl MapHold {
}
}
/// `renderPlayerArm` up to `renderPlayerHand`: the pose of the bare `left`
/// or right arm.
pub fn player_arm_pose(left: bool, inverse_height: f32, attack: f32) -> glam::Mat4 {
use glam::{Mat4, Vec3};
use horizon_core::geometry::trig::Mth;
let f = if left { -1.0 } else { 1.0 };
let pi = std::f32::consts::PI;
let root = attack.sqrt();
let x = -0.3 * (root * pi).msin();
let y = 0.4 * (root * std::f32::consts::TAU).msin();
let z = -0.4 * (attack * pi).msin();
let z_rot = (attack * attack * pi).msin();
let y_rot = (root * pi).msin();
let deg = |d: f32| d.to_radians();
Mat4::from_translation(Vec3::new(
f * (x + 0.640_000_05),
y - 0.6 + inverse_height * -0.6,
z + -0.719_999_97,
)) * Mat4::from_rotation_y(deg(f * 45.0))
* Mat4::from_rotation_y(deg(f * y_rot * 70.0))
* Mat4::from_rotation_z(deg(f * z_rot * -20.0))
* Mat4::from_translation(Vec3::new(-f, 3.6, 3.5))
* Mat4::from_rotation_z(deg(f * 120.0))
* Mat4::from_rotation_x(deg(200.0))
* Mat4::from_rotation_y(deg(f * -135.0))
* Mat4::from_translation(Vec3::new(f * 5.6, 0.0, 0.0))
}
/// `renderTwoHandedMap` up to its arms: the swing, the tilt from the view
/// pitch (`calculateMapTilt`) and the turn towards the player.
pub fn two_handed_map_base(pitch: f32, inverse_height: f32, attack: f32) -> glam::Mat4 {
use glam::{Mat4, Vec3};
use horizon_core::geometry::trig::Mth;
let pi = std::f32::consts::PI;
let y_swing = -0.2 * (attack * pi).msin();
let z_swing = -0.4 * (attack.sqrt() * pi).msin();
let tilt = (1.0 - pitch / 45.0 + 0.1).clamp(0.0, 1.0);
let tilt = -(tilt * pi).mcos() * 0.5 + 0.5;
Mat4::from_translation(Vec3::new(0.0, -y_swing / 2.0, z_swing))
* Mat4::from_translation(Vec3::new(
0.0,
0.04 + inverse_height * -1.2 + tilt * -0.5,
-0.72,
))
* Mat4::from_rotation_x((tilt * -85.0).to_radians())
}
/// The arms under a held map, as (left arm, pose before `renderPlayerHand`):
/// `renderOneHandedMap`'s arm turned 10 degrees outwards, or
/// `renderTwoHandedMap`'s two `renderMapHand` arms under the map's
/// `two_handed_map_base`. Vanilla skips them for an invisible player.
pub fn map_arm_poses(
hold: MapHold,
left: bool,
pitch: f32,
inverse_height: f32,
attack: f32,
) -> Vec<(bool, glam::Mat4)> {
use glam::{Mat4, Vec3};
let deg = |d: f32| d.to_radians();
match hold {
MapHold::OneHanded => {
let f = if left { -1.0 } else { 1.0 };
let m = Mat4::from_translation(Vec3::new(f * 0.125, -0.125, 0.0))
* Mat4::from_rotation_z(deg(f * 10.0))
* player_arm_pose(left, inverse_height, attack);
vec![(left, m)]
}
MapHold::TwoHanded => {
let base = two_handed_map_base(pitch, inverse_height, attack)
* Mat4::from_rotation_y(deg(90.0));
[false, true]
.into_iter()
.map(|left| {
let f = if left { -1.0 } else { 1.0 };
let hand = Mat4::from_rotation_y(deg(92.0))
* Mat4::from_rotation_x(deg(45.0))
* Mat4::from_rotation_z(deg(f * -41.0))
* Mat4::from_translation(Vec3::new(f * 0.3, -1.1, 0.45));
(left, base * hand)
})
.collect()
}
}
}
/// `renderOneHandedMap` up to `renderMap`: the pose of a map in the hand on
/// the `left` or right arm (the arm under it is not drawn).
/// the `left` or right arm (its arm: [`map_arm_poses`]).
pub fn one_handed_map_pose(left: bool, inverse_height: f32, attack: f32) -> glam::Mat4 {
use glam::{Mat4, Vec3};
use horizon_core::geometry::trig::Mth;
@@ -945,6 +1036,83 @@ mod item_pose_tests {
}
}
#[test]
fn maps_draw_the_arms_under_them_as_vanilla() {
// PoseStack.last().pose() (column-major) where renderOneHandedMap's
// renderPlayerArm and renderTwoHandedMap's renderMapHand call
// renderPlayerHand, replayed with vanilla's PoseStack, Axis and Mth.
let near = |got: glam::Mat4, want: [f32; 16], what: &str| {
let got = got.to_cols_array();
for (g, w) in got.iter().zip(want) {
assert!((g - w).abs() < 1e-5, "{what}: {got:?}");
}
};
// (left, inverse height, attack, arm)
#[rustfmt::skip]
let one: [(bool, f32, f32, [f32; 16]); 3] = [
(false, 0.0, 0.0, [
-0.2350201, -0.7860474, -0.5717472, 0.0,
0.24694103, 0.52063686, -0.8172866, 0.0,
0.9400987, -0.33326668, 0.07174723, 0.0,
0.65913033, -1.1543381, -0.73980355, 1.0,
]),
(true, 0.25, 0.3, [
-0.4967606, 0.84289914, -0.20676011, 0.0,
0.7202682, 0.2674863, -0.6400506, 0.0,
-0.4841926, -0.46687466, -0.7399901, 0.0,
-0.59715486, -1.4160433, -0.7328731, 1.0,
]),
(false, 0.6, 0.85, [
-0.47358897, -0.8403256, -0.2637542, 0.0,
0.081554025, 0.25634, -0.96313995, 0.0,
0.87696207, -0.47764266, -0.052867785, 0.0,
0.61887383, -1.6482887, -0.7229744, 1.0,
]),
];
for (left, inverse_height, attack, want) in one {
let arms = map_arm_poses(MapHold::OneHanded, left, 0.0, inverse_height, attack);
assert_eq!(arms.len(), 1, "one arm under a one-handed map");
assert_eq!(arms[0].0, left, "the arm of the hand holding it");
near(
arms[0].1,
want,
&format!("one {left} {inverse_height} {attack}"),
);
}
// (pitch, inverse height, attack, right arm, left arm)
#[rustfmt::skip]
let two: [(f32, f32, f32, [f32; 16], [f32; 16]); 2] = [
(0.0, 0.0, 0.0, [
-0.7380599, 0.44766384, 0.50484115, 0.0,
-0.67428374, -0.46198538, -0.57611704, 0.0,
-0.024677532, -0.76561517, 0.64282537, 0.0,
0.5091893, -0.16204369, 0.3544525, 1.0,
], [
-0.77043974, -0.3951866, -0.50025004, 0.0,
0.637035, -0.5076031, -0.58010805, 0.0,
-0.024677532, -0.76561517, 0.64282537, 0.0,
-0.48071152, -0.12760738, 0.35746527, 1.0,
]),
(30.0, 0.2, 0.5, [
-0.7380599, -0.11453738, 0.6649426, 0.0,
-0.67428374, 0.16124588, -0.72065324, 0.0,
-0.024677532, -0.98024535, -0.1962398, 0.0,
0.5091893, -0.9508533, -0.13439482, 1.0,
], [
-0.77043974, 0.14373614, -0.6210978, 0.0,
0.637035, 0.13586378, -0.75876695, 0.0,
-0.024677532, -0.98024535, -0.1962398, 0.0,
-0.48071152, -0.9316926, -0.1056232, 1.0,
]),
];
for (pitch, inverse_height, attack, right, left) in two {
let arms = map_arm_poses(MapHold::TwoHanded, false, pitch, inverse_height, attack);
assert_eq!(arms.len(), 2, "both arms under a two-handed map");
assert_eq!((arms[0].0, arms[1].0), (false, true), "right, then left");
near(arms[0].1, right, &format!("two right {pitch}"));
near(arms[1].1, left, &format!("two left {pitch}"));
}
}
#[test]
fn ranged_draw_uses_native_charge_curves_and_caps() {
for (ticks, stretch) in [
(0.0, 1.0),
+235 -108
View File
@@ -1200,9 +1200,10 @@ struct WorldRest {
nether_environment: Environment,
end_environment: Environment,
behaviours: Behaviours,
player_arm: Option<horizon_client::mobs::Part>,
/// The slim model's right arm (3 pixels wide).
player_arm_slim: Option<horizon_client::mobs::Part>,
/// The player model's right and left arms.
player_arm: [Option<horizon_client::mobs::Part>; 2],
/// The slim model's right and left arms (3 pixels wide).
player_arm_slim: [Option<horizon_client::mobs::Part>; 2],
player_type: Option<Arc<horizon_client::mobs::MobType>>,
/// `SpinAttackEffectModel` (Riptide's spinning boxes).
spin_attack: Option<horizon_client::mobs::Part>,
@@ -1275,12 +1276,13 @@ impl Resources {
fn threads(&self) -> usize {
self.core.threads
}
fn player_arm(&self, slim: bool) -> &Option<horizon_client::mobs::Part> {
if slim {
fn player_arm(&self, slim: bool, left: bool) -> &Option<horizon_client::mobs::Part> {
let arms = if slim {
&self.rest().player_arm_slim
} else {
&self.rest().player_arm
}
};
&arms[usize::from(left)]
}
fn player_type(&self) -> &Option<Arc<horizon_client::mobs::MobType>> {
&self.rest().player_type
@@ -1384,6 +1386,23 @@ fn ticking_chunk(world: &World, x: i32, z: i32) -> bool {
world.chunks.get(&(x, z)).is_some_and(|c| c.lit)
&& (-1..=1).all(|dz| (-1..=1).all(|dx| world.chunks.contains_key(&(x + dx, z + dz))))
}
/// Key events carry the scancode's US-layout code, which ignores XKB options like
/// `caps:swapescape`. For the keys whose meaning the keymap may remap, take the code
/// from the keysym the keymap resolved instead.
fn follow_keymap(event: &mut WindowEvent) {
use winit::keyboard::{Key, NamedKey};
if let WindowEvent::KeyboardInput { event, .. } = event
&& let Key::Named(named) = &event.logical_key
{
let code = match named {
NamedKey::Escape => KeyCode::Escape,
NamedKey::CapsLock => KeyCode::CapsLock,
_ => return,
};
event.physical_key = PhysicalKey::Code(code);
}
}
fn drop_whole_stack(physical_keys: &HashSet<KeyCode>) -> bool {
physical_keys.contains(&KeyCode::ControlLeft) || physical_keys.contains(&KeyCode::ControlRight)
}
@@ -1432,6 +1451,9 @@ struct Game<'a> {
lod: Option<horizon_client::lod::Lod>,
lod_source_attempted: bool,
lod_observe: horizon_client::fast_hash::FxHashSet<(i32, i32)>,
/// Beacon beam sections kept after their chunk unloads, so far terrain
/// LOD still draws their beams (`beacon_geometry` refreshes them).
far_beacons: HashMap<(i32, i32, i32), Vec<(u32, i32)>>,
/// The detail revision, centre and radius `Lod::update` last had covered for.
lod_covered_at: Option<(u64, (i32, i32), i32)>,
options: Options,
@@ -3775,6 +3797,7 @@ impl Game<'_> {
// ShulkerBoxBlock.canOpen: the lid has no room; nothing opens.
} else if let Some(entity) = BlockEntity::for_block(&name) {
self.world.block_entities.get_or_insert_with(key, || entity);
self.unpack_opened_loot(key, &name);
let centre = [
f64::from(key.0) + 0.5,
f64::from(key.1) + 0.5,
@@ -4215,8 +4238,15 @@ impl Game<'_> {
}
/// `Minecraft.startUseItem`: the main hand, then the off hand.
fn use_item_as_player(&mut self) -> Result<Option<Hand>> {
// Spectators only look (MultiPlayerGameMode.useItemOn/useItem pass).
// Spectators only look (MultiPlayerGameMode.useItemOn/useItem pass),
// but on a server a spectator's use on a block opens the block's
// menu (ServerPlayerGameMode.useItemOn).
if self.spectator() {
if matches!(self.guest_use, Some(GuestUse::OnBlock(_)))
&& let Some(hit) = self.pick()
{
self.spectator_open_menu(hit.pos);
}
return Ok(None);
}
// A carrot or warped fungus on a stick boosts the ridden pig or strider.
@@ -4767,7 +4797,9 @@ impl Game<'_> {
self.tick_crossbow(slot, ticks, &stack);
return;
}
if &*stack.item == "minecraft:trident" {
// TridentItem and EnderEyeItem: used until released (an eye's
// getUseDuration 0 never counts down to finishUsingItem).
if matches!(&*stack.item, "minecraft:trident" | "minecraft:ender_eye") {
self.using = Some((slot, ticks + 1));
return;
}
@@ -6540,9 +6572,13 @@ impl Game<'_> {
/// The recipe id and result of the crafting grid.
/// `RecipeCraftingHolder.awardUsedRecipes`: a crafted recipe becomes known
/// (special ones never do).
fn award_crafted(&mut self, recipes: &[String]) {
fn award_crafted(&mut self, crafts: &[(String, Vec<Option<ItemStack>>)]) {
for (recipe, ingredients) in crafts {
self.trigger_recipe_crafted(recipe, ingredients);
}
let recipes: Vec<String> = crafts.iter().map(|(r, _)| r.clone()).collect();
let index = &self.res.stations().recipe_index;
let (_, unlocked) = self.level.recipe_book.add_recipes(index, recipes);
let (_, unlocked) = self.level.recipe_book.add_recipes(index, &recipes);
self.recipe_unlocked(&unlocked);
}
fn craft_recipe(&self) -> Option<(String, ItemStack)> {
@@ -6633,9 +6669,15 @@ impl Game<'_> {
}
};
part(batch, tex, 0.0, 0.0, 176.0, 166.0, ox, oy);
let title = self.container_title(self.translate(key, fallback));
let tw = HudBatch::text_width(atlas, &title);
batch.text(
// A server's furnace menu has the title it was given.
let title = self.remote_menu_title().unwrap_or_else(|| {
horizon_client::text::Text::literal(
self.container_title(self.translate(key, fallback)),
)
.resolve_legacy()
});
let tw = atlas.font.width_text(&title);
batch.text_styled(
atlas,
&title,
ox + (176.0 - tw) / 2.0,
@@ -7329,6 +7371,13 @@ impl Game<'_> {
let at = [p.position.x, p.position.y, p.position.z];
self.award_furnace_recipes(pos, at);
}
} else if self.creative_inventory_open() {
// CreativeModeInventoryScreen.slotClicked's THROW on the Survival Inventory tab: the
// stack goes to the server (handleCreativeModeItemDrop), the slot follows with the
// others (SetCreativeModeSlot).
for stack in dropped {
self.remote_creative_throw(stack);
}
}
if kind == Kind::Loom {
if let Some(s) = self.station.as_mut() {
@@ -7528,7 +7577,8 @@ impl Game<'_> {
return;
}
if let Some(moved) = self.last_quick_moved.clone() {
if self.remote.is_some() {
// The creative screen clicks its own menu, on a server too.
if self.remote.is_some() && self.screen != Screen::Creative {
self.remote_quick_move_all(slot, &moved);
} else {
self.menu_session(|menu, env, to_menu| {
@@ -7916,6 +7966,7 @@ impl Game<'_> {
while let Some((recipe, result)) = self.craft_recipe() {
let made = (result.item.clone(), result.count);
let cells = self.craft_size * self.craft_size;
let ingredients = self.craft_grid[..cells].to_vec();
let remainders = self.res.recipes().remainders(
&recipe,
&self.craft_grid[..cells],
@@ -7955,7 +8006,7 @@ impl Game<'_> {
}
crafted += 1;
self.stats.add("crafted", &made.0, made.1 as i32);
self.award_crafted(&[format!("minecraft:{recipe}")]);
self.award_crafted(&[(format!("minecraft:{recipe}"), ingredients)]);
if !shift || crafted >= 64 {
break;
}
@@ -8068,7 +8119,7 @@ impl Game<'_> {
for m in &self.mobs {
// Blaze.isOnFire: a charged blaze burns.
let blazing = m.species() == horizon_client::mobs::Species::Blaze && m.extra.flag;
if (m.fire_ticks > 0 || blazing) && !m.removed {
if (m.is_on_fire() || blazing) && !m.removed {
let at = m.previous.lerp(m.position, alpha);
horizon_client::fire_render::flames(
[at.x, at.y, at.z],
@@ -8200,11 +8251,8 @@ impl Game<'_> {
p.z + (q.z - p.z) * a,
)
};
let mut casters: Vec<(glam::DVec3, &str)> = self
.mobs
.iter()
.map(|m| (lerp(m.previous, m.position), m.kind.id.as_str()))
.collect();
let mut casters: Vec<(glam::DVec3, &str)> =
horizon_client::shadows::mob_casters(&self.mobs, a).collect();
casters.extend(
self.dropped
.iter()
@@ -9097,6 +9145,15 @@ impl Game<'_> {
return Ok(());
}
self.join_world_rest()?;
// Create World's data packs, in the world before it opens.
if let Some(dir) = create.and_then(|o| o.pack_dir.as_deref()) {
let world = std::env::current_dir()?.join("saves").join(folder);
let packs = create.map_or(&[][..], |o| &o.data_packs[..]);
if let Err(e) = horizon_client::datapack::install(dir, &world, packs) {
log::warn!("Horizon: data packs for {folder}: {e}");
}
let _ = std::fs::remove_dir_all(dir);
}
if self
.options
.execution_role
@@ -9228,7 +9285,8 @@ impl Game<'_> {
book.to_be_displayed.retain(|r| index.contains(r));
self.dim = dim;
self.world_root = save_root.clone();
self.world_flat = horizon_client::save::WorldGen::read(&save_root).is_some_and(|w| w.flat);
self.world_flat = horizon_client::save::WorldGen::read(&save_root)
.is_some_and(|w| w.preset == horizon_client::save::WorldPreset::Flat);
self.block_ticks = Ticks::new(self.level.seed as i32);
self.tick_chunks.clear();
self.clear_entities();
@@ -10621,6 +10679,36 @@ impl Game<'_> {
}
self.damage_held(1);
}
/// `renderPlayerHand` (`AvatarRenderer.renderHand`): the player's own
/// `left` or right arm with its skin at pose `m`, in its rest pose
/// turned 0.1 radians outwards.
fn first_person_arm(&self, left: bool, m: glam::Mat4, light: u32, out: &mut Vec<Vertex>) {
let slim = self.player_model.as_ref().is_some_and(|m| m.slim);
let Some(arm) = self.res.player_arm(slim, left).as_ref() else {
return;
};
// The player's own skin, as the inventory's model shows it.
let sprites = &self.gui.atlas.sprites;
let Some(r) = self
.player_model
.as_ref()
.and_then(|m| m.skin.as_deref())
.and_then(|skin| sprites.get(skin))
.or_else(|| sprites.get("entity/player/wide/steve"))
.copied()
else {
return;
};
let z_rot = if left { -0.1 } else { 0.1 };
let pose = |p: &horizon_client::mobs::Part| {
let mut pose = p.pose;
pose[3] = 0.0;
pose[4] = 0.0;
pose[5] = if p.name == arm.name { z_rot } else { 0.0 };
pose
};
horizon_client::mobs::render_model(arm, m, &pose, [r.u0, r.v0, r.u1, r.v1], light, 0, out);
}
/// FirstPersonHandsAndItemsRenderer: the right arm or the held items of
/// both hands in view space, converted to camera-relative world space.
fn hand_geometry(&mut self, alpha: f64) -> Vec<Vertex> {
@@ -10664,7 +10752,6 @@ impl Game<'_> {
eye[2].floor() as i32,
);
let packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
let deg = |d: f32| d.to_radians();
let shown = self.hands_to_render();
let used_hand = self.used_hand();
let spinning = self
@@ -10678,7 +10765,6 @@ impl Game<'_> {
continue;
}
let (attack, inverse_height) = (attacks[i], inverse_heights[i]);
let sqrt_attack = attack.sqrt();
// The main hand on its arm, the off hand on the other one.
let left = self.settings.main_hand_left != (hand == Hand::Off);
let Some(stack) = item else {
@@ -10686,63 +10772,24 @@ impl Game<'_> {
if hand == Hand::Off {
continue;
}
let slim = self.player_model.as_ref().is_some_and(|m| m.slim);
let Some(arm) = self.res.player_arm(slim).clone() else {
continue;
};
// The player's own skin, as the inventory's model shows it.
let sprites = &self.gui.atlas.sprites;
let Some(r) = self
.player_model
.as_ref()
.and_then(|m| m.skin.as_deref())
.and_then(|skin| sprites.get(skin))
.or_else(|| sprites.get("entity/player/wide/steve"))
.copied()
else {
continue;
};
// renderPlayerArm (right arm)
let x_swing = -0.3 * (sqrt_attack * std::f32::consts::PI).sin();
let y_swing = 0.4 * (sqrt_attack * std::f32::consts::TAU).sin();
let z_swing = -0.4 * (attack * std::f32::consts::PI).sin();
let z_rot = (attack * attack * std::f32::consts::PI).sin();
let y_rot = (sqrt_attack * std::f32::consts::PI).sin();
let m = to_world
* glam::Mat4::from_translation(glam::Vec3::new(
x_swing + 0.64,
y_swing - 0.6 + inverse_height * -0.6,
z_swing - 0.72,
))
* glam::Mat4::from_rotation_y(deg(45.0))
* glam::Mat4::from_rotation_y(deg(y_rot * 70.0))
* glam::Mat4::from_rotation_z(deg(z_rot * -20.0))
* glam::Mat4::from_translation(glam::Vec3::new(-1.0, 3.6, 3.5))
* glam::Mat4::from_rotation_z(deg(120.0))
* glam::Mat4::from_rotation_x(deg(200.0))
* glam::Mat4::from_rotation_y(deg(-135.0))
* glam::Mat4::from_translation(glam::Vec3::new(5.6, 0.0, 0.0));
let pose = |p: &horizon_client::mobs::Part| {
let mut pose = p.pose;
pose[3] = 0.0;
pose[4] = 0.0;
pose[5] = 0.0;
pose
};
horizon_client::mobs::render_model(
&arm,
m,
&pose,
[r.u0, r.v0, r.u1, r.v1],
packed,
0,
&mut out,
);
* horizon_client::first_person::player_arm_pose(false, inverse_height, attack);
self.first_person_arm(false, m, packed, &mut out);
continue;
};
if let Some(id) = maps::map_id(&stack) {
use horizon_client::first_person::{MapHold, one_handed_map_pose};
match MapHold::of(hand, off_hand_empty) {
use horizon_client::first_person::{MapHold, map_arm_poses, one_handed_map_pose};
let hold = MapHold::of(hand, off_hand_empty);
// renderOneHandedMap and renderTwoHandedMap: the arms under
// the map, unless the player is invisible.
if self.potions.effects.get("invisibility").is_none() {
for (arm, pose) in map_arm_poses(hold, left, self.pitch, inverse_height, attack)
{
self.first_person_arm(arm, to_world * pose, packed, &mut out);
}
}
match hold {
MapHold::TwoHanded => self.two_handed_map_geometry(
to_world,
attack,
@@ -10823,6 +10870,14 @@ impl Game<'_> {
}
}
_ if spinning => ItemPose::SpinAttack,
// ItemInHandRenderer: a STAB swing of this hand jabs.
_ if self
.swing
.current
.is_some_and(|c| c.hand == hand && c.animation.kind == 2) =>
{
ItemPose::Stab
}
_ => ItemPose::Swing,
};
let m = to_world
@@ -11263,6 +11318,38 @@ impl Game<'_> {
batch.fill(atlas, x - 3.0, y + 10.0, w + 6.0, 1.0, rgba(40, 0, 127, 80));
batch.text(atlas, text, x, y, u32::MAX, true);
}
/// `CreativeModeInventoryScreen.getTooltipFromContainerItem`: the lines
/// that follow the item's name, none for a category tab's own grid slots
/// (`grid`): the names of the tabs containing the stack, in blue, then on
/// the search tab's grid the tags a `#` search shows, in dark purple.
fn creative_tooltip_tabs(
&self,
stack: &ItemStack,
grid: bool,
) -> Vec<(horizon_client::text::Text, u32)> {
let screen = &self.creative_screen;
let tab = screen.tab();
let category = !tab.search && !tab.inventory;
if !horizon_client::creative::tooltip_lists_tabs(category, grid) {
return Vec::new();
}
let blue = rgba(85, 85, 255, 255);
let dark_purple = rgba(170, 0, 170, 255);
let mut lines: Vec<(horizon_client::text::Text, u32)> = screen
.tabs_containing(stack)
.into_iter()
.map(|i| {
let title = self.creative_tab_title(&screen.tabs[i]);
(horizon_client::text::Text::literal(title), blue)
})
.collect();
if tab.search && grid {
for tag in screen.search_tag_lines(stack) {
lines.push((horizon_client::text::Text::literal(tag), dark_purple));
}
}
lines
}
fn draw_creative(&mut self, batch: &mut HudBatch) {
let (ox, oy) = Self::creative_origin(batch);
let selected = self.creative_screen.selected;
@@ -11345,7 +11432,8 @@ impl Game<'_> {
search.max_length = clipboard::CREATIVE_SEARCH_CHARS;
search.draw(batch, atlas, None);
}
let mut hovered: Option<ItemStack> = None;
// The hovered stack, and whether it is a grid slot (`CustomCreativeSlot`).
let mut hovered: Option<(ItemStack, bool)> = None;
if tab.inventory {
self.draw_player_preview(
batch,
@@ -11395,7 +11483,7 @@ impl Game<'_> {
&& self.cursor.1 < y + 16.0
{
batch.fill(&self.gui.atlas, x, y, 16.0, 16.0, rgba(255, 255, 255, 128));
hovered = Some(stack.clone());
hovered = Some((stack.clone(), true));
}
}
}
@@ -11409,7 +11497,7 @@ impl Game<'_> {
&& self.cursor.1 < y + 16.0
{
batch.fill(&self.gui.atlas, x, y, 16.0, 16.0, rgba(255, 255, 255, 128));
hovered = self.inventory[c].clone();
hovered = self.inventory[c].clone().map(|s| (s, false));
}
}
let scrollable = self.creative_screen.rows(&self.pack.names) > 5;
@@ -11444,13 +11532,18 @@ impl Game<'_> {
if tab.inventory {
if Self::creative_trash_hovered(batch, self.cursor) {
self.tooltip(batch, "Destroy Item", self.cursor.0, self.cursor.1);
} else {
self.draw_slot_tooltip(batch);
} else if self.carried.is_none()
&& let Some(slot) = self.slot_at(batch, self.cursor.0, self.cursor.1)
&& let Some(stack) = self.slot_view(slot)
{
let tabs = self.creative_tooltip_tabs(&stack, false);
self.draw_stack_tooltip_with(batch, &stack, self.cursor.0, self.cursor.1, tabs);
}
} else if self.carried.is_none()
&& let Some(item) = hovered
&& let Some((item, grid)) = hovered
{
self.draw_stack_tooltip(batch, &item, self.cursor.0, self.cursor.1);
let tabs = self.creative_tooltip_tabs(&item, grid);
self.draw_stack_tooltip_with(batch, &item, self.cursor.0, self.cursor.1, tabs);
}
self.draw_inventory_effects(batch, (ox, oy), Creative::WIDTH as i32);
}
@@ -11866,8 +11959,9 @@ impl Game<'_> {
// GameRenderer.shouldRenderBlockOutline: not with the HUD hidden (F1), and only for a
// player who may build. Adventure players only get one with a CAN_BREAK or
// CAN_PLACE_ON item and spectators with a menu block; Horizon reads neither the
// components nor opens menus for spectators, so those never show it.
let outline = if self.screen == Screen::Playing && !self.ui.hide_gui && self.may_build() {
// components nor opens menus for spectators, so those never show it. An open
// screen does not hide it: vanilla checks the HUD and the game mode, not the screen.
let outline = if !self.ui.hide_gui && self.may_build() {
self.pick()
.map(|h| {
let state = self.world.block(h.pos[0], h.pos[1], h.pos[2]);
@@ -12065,21 +12159,7 @@ impl Game<'_> {
environment_fog: (self.dim == Dim::Nether).then_some((sky.fog_start, sky.fog_end)),
cave_culling,
water_fog,
fog: Some({
let mut fog = self.frame_fog(camera, &sky, biome, water_fog);
// Horizon's fog setting: no distance fog (fluids keep theirs).
if !self.settings.horizon.fog
&& !underwater
&& self.darkness_fog().is_none()
&& !self.boss_overlay.should_create_world_fog()
&& self.potions.effects.get("blindness").is_none()
&& fog.environmental_end >= (self.streaming.radius * 16) as f32
{
(fog.render_distance_start, fog.render_distance_end) = (1e9, 2e9);
(fog.environmental_start, fog.environmental_end) = (1e9, 2e9);
}
fog
}),
fog: Some(self.frame_fog(camera, &sky, biome, water_fog)),
dark_disc: self.dark_disc(camera),
outline,
outline_after_terrain,
@@ -12563,6 +12643,7 @@ impl Game<'_> {
self.lod = None;
self.lod_source_attempted = false;
self.lod_observe.clear();
self.far_beacons.clear();
self.lod_covered_at = None;
if let Some(renderer) = &mut self.renderer {
renderer.clear_lods();
@@ -12812,10 +12893,11 @@ impl ApplicationHandler for Game<'_> {
self.apply_settings();
self.previous = Instant::now();
}
fn window_event(&mut self, event_loop: &ActiveEventLoop, id: WindowId, event: WindowEvent) {
fn window_event(&mut self, event_loop: &ActiveEventLoop, id: WindowId, mut event: WindowEvent) {
if self.window.as_ref().is_none_or(|w| w.id() != id) {
return;
}
follow_keymap(&mut event);
if let WindowEvent::KeyboardInput { event, .. } = &event
&& let PhysicalKey::Code(key) = event.physical_key
{
@@ -13188,6 +13270,13 @@ impl ApplicationHandler for Game<'_> {
Action::Connect { address } => {
self.connect_server(&address, &self.options.player_name())?
}
Action::DataPacks { dir, selected } => {
self.keys.clear();
self.screen = Screen::Options;
self.options_from_title = true;
let resources = self.vanilla_root();
self.ui.options.open_data_packs(dir, selected, resources);
}
Action::None => {}
}
}
@@ -13559,8 +13648,15 @@ impl ApplicationHandler for Game<'_> {
)?;
}
Err(button) => {
if self.screen == Screen::Playing && !event.repeat {
self.play_mouse(button, event.state == ElementState::Pressed)?;
let action = match (event.state, event.repeat) {
(ElementState::Released, _) => KeyAction::Release,
(_, false) => KeyAction::Press,
(_, true) => KeyAction::Repeat,
};
if let Some(pressed) =
keyboard_button(self.screen == Screen::Playing, action)
{
self.play_mouse(button, pressed)?;
}
}
}
@@ -13931,6 +14027,7 @@ impl<'a> Game<'a> {
streaming,
lod: None,
lod_source_attempted: false,
far_beacons: HashMap::new(),
lod_observe: Default::default(),
lod_covered_at: None,
options,
@@ -14859,10 +14956,8 @@ fn load_world_rest(
.collect(),
mob_data,
player_type: perspective::player_type(root),
player_arm: horizon_client::mobs::load_model(root, "minecraft:player#main")
.and_then(|m| m.children.into_iter().find(|c| c.name == "right_arm")),
player_arm_slim: horizon_client::mobs::load_model(root, "minecraft:player_slim#main")
.and_then(|m| m.children.into_iter().find(|c| c.name == "right_arm")),
player_arm: player_arms(root, "minecraft:player#main"),
player_arm_slim: player_arms(root, "minecraft:player_slim#main"),
spin_attack: horizon_client::mobs::load_model(root, "minecraft:spin_attack#main"),
})
}
@@ -15310,10 +15405,11 @@ impl Loading {
horizon_client::hud::GUI_SCALE.store(self.settings.gui_scale, Ordering::Relaxed);
self.window = Some(window);
}
fn window_event(&mut self, event_loop: &ActiveEventLoop, id: WindowId, event: WindowEvent) {
fn window_event(&mut self, event_loop: &ActiveEventLoop, id: WindowId, mut event: WindowEvent) {
if self.window.as_ref().is_none_or(|w| w.id() != id) {
return;
}
follow_keymap(&mut event);
if let Err(error) = self.menu_event(event_loop, event) {
self.error = Some(error);
event_loop.exit();
@@ -15708,6 +15804,17 @@ pub fn run() -> Result<()> {
Ok(())
}
/// The right and left arms of a player model layer (`PlayerModel.rightArm`
/// and `leftArm`, with their sleeves).
fn player_arms(root: &std::path::Path, key: &str) -> [Option<horizon_client::mobs::Part>; 2] {
let model = horizon_client::mobs::load_model(root, key);
["right_arm", "left_arm"].map(|name| {
model
.as_ref()
.and_then(|m| m.children.iter().find(|c| c.name == name).cloned())
})
}
// Player.tick: cap the sleeping fade at 100, then fade out over 10 waking ticks.
fn tick_sleep_timer(timer: u32, sleeping: bool) -> u32 {
if sleeping {
@@ -15719,6 +15826,15 @@ fn tick_sleep_timer(timer: u32, sleeping: bool) -> u32 {
}
}
/// `KeyboardHandler.keyPress` for a key bound to a mouse mapping (key.attack,
/// key.use, key.pickItem): in game, a press and every repeat of the held key
/// reach `KeyMapping.set(key, true)` and `KeyMapping.click` alike, so a held
/// key keeps attacking; a release lets go. `None` behind a screen, which takes
/// the key.
fn keyboard_button(playing: bool, action: KeyAction) -> Option<bool> {
playing.then_some(action != KeyAction::Release)
}
fn reset_world_progress(adv: &mut advancements::AdvState, stats: &mut stats::Stats) {
*adv = Default::default();
*stats = Default::default();
@@ -15800,6 +15916,17 @@ mod tests {
use horizon_client::container_screen::{Button, Clicks, Release, Target};
use horizon_client::keymapping::{KeyAction, SPRINT, ToggleKeys};
/// A key bound to key.attack keeps attacking while it is held: each
/// repeat clicks like the press (#3243).
#[test]
fn a_held_key_bound_to_a_button_repeats_its_clicks() {
assert_eq!(keyboard_button(true, KeyAction::Press), Some(true));
assert_eq!(keyboard_button(true, KeyAction::Repeat), Some(true));
assert_eq!(keyboard_button(true, KeyAction::Release), Some(false));
for action in [KeyAction::Press, KeyAction::Repeat, KeyAction::Release] {
assert_eq!(keyboard_button(false, action), None);
}
}
#[test]
fn sleep_fade_lasts_ten_ticks_after_waking() {
let alpha = |timer| match hud_overlays::sleep_overlay(320, 240, timer).first() {
+60 -4
View File
@@ -588,10 +588,17 @@ impl Game<'_> {
.announced
.push(Text::translate(key, &[player, name]));
}
None => self.system_message(Text::translate(
key,
&[Text::literal(super::chat::PLAYER_NAME), name],
)),
None => {
let text = Text::translate(key, &[self.player_display_text(), name]);
// The guests hear of the host's advancement too.
if let Some(host) = &self.lan {
host.hub
.system_message(horizon_client::lan::data::component_nbt(
&text.component(),
));
}
self.system_message(text);
}
}
}
}
@@ -1366,6 +1373,55 @@ impl Game<'_> {
adv::conditions(&c["player"], &pctx, w)
});
}
/// `RecipeCraftedTrigger`: the recipe and the stacks that went into it
/// (`ServerPlayer.triggerRecipeCrafted`); an empty slot is air.
pub(super) fn trigger_recipe_crafted(
&mut self,
recipe: &str,
ingredients: &[Option<ItemStack>],
) {
let me = self.player_entity();
let used: Vec<ItemView> = ingredients
.iter()
.map(|s| {
s.as_ref().map_or_else(
|| ItemView {
id: "minecraft:air".to_owned(),
..Default::default()
},
item_view,
)
})
.collect();
let recipe = recipe.to_owned();
self.trigger("minecraft:recipe_crafted", move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
};
adv::conditions(&c["player"], &pctx, w)
&& adv::id_matches(&c["recipes"], "recipe", &recipe, w)
&& adv::recipe_ingredients(&c["ingredients"], &used, w)
});
}
/// `LootTableTrigger`: a container's loot table unpacked for this player
/// (`RandomizableContainer.unpackLootTable`).
pub(super) fn trigger_loot_table(&mut self, table: &str) {
let me = self.player_entity();
let table = table.to_owned();
self.trigger("minecraft:player_generates_container_loot", move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
};
adv::conditions(&c["player"], &pctx, w)
&& adv::id_matches(&c["loot_tables"], "loot_table", &table, w)
});
}
/// `BredAnimalsTrigger`.
pub(super) fn trigger_bred(
&mut self,
+6 -4
View File
@@ -536,7 +536,9 @@ impl Game<'_> {
title
.recent_worlds()
.iter()
.map(|w| json!({ "folder": w.folder, "name": w.name, "creative": w.creative }))
.map(
|w| json!({ "folder": w.folder, "name": w.name, "creative": w.game_mode == 1 }),
)
.collect::<Vec<_>>()
};
match &title.mode {
@@ -546,7 +548,7 @@ impl Game<'_> {
selected,
} => json!({
"mode": "select-world",
"worlds": list.iter().map(|w| json!({ "folder": w.folder, "name": w.name, "creative": w.creative })).collect::<Vec<_>>(),
"worlds": list.iter().map(|w| json!({ "folder": w.folder, "name": w.name, "creative": w.game_mode == 1 })).collect::<Vec<_>>(),
"selected": selected,
}),
Mode::Create {
@@ -564,7 +566,7 @@ impl Game<'_> {
"hardcore": options.hardcore,
"difficulty": horizon_client::title::DIFFICULTIES[options.difficulty()],
"allow_commands": options.commands(*creative),
"superflat": options.flat,
"world_type": options.world_type.id(),
"generate_structures": options.structures,
"bonus_chest": options.bonus_chest(),
}),
@@ -577,7 +579,7 @@ impl Game<'_> {
} => json!({
"mode": "customize-flat",
"fields": { "name": name, "seed": seed },
"superflat": options.flat,
"superflat": options.world_type == horizon_client::save::WorldPreset::Flat,
"biome": flat.settings().biome,
"layers": flat.settings().layers.iter().map(|layer| json!({
"block": layer.block,
+14 -13
View File
@@ -616,9 +616,6 @@ impl Game<'_> {
};
for a in &self.armor_stands {
let entity_start = out.len();
if a.invisible {
continue;
}
// ArmorStand.shouldRenderAtSqrDistance: 256 times the box's mean
// edge (4 for a marker's empty box), whatever the entity distance.
// LivingEntityRenderer grows the culling box by 0.5 for a dragon head.
@@ -690,16 +687,20 @@ impl Game<'_> {
a.position.z.floor() as i32,
);
let packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
horizon_client::mobs::render::draw(
model,
base,
&poses,
wood,
0xFFFF_FFFF,
packed,
0,
out,
);
// LivingEntityRenderer.submit: an invisible stand has no body render type, but its
// layers (held items, custom head, armour) are submitted all the same.
if !a.invisible {
horizon_client::mobs::render::draw(
model,
base,
&poses,
wood,
0xFFFF_FFFF,
packed,
0,
out,
);
}
self.armor_stand_items(a, model, base, &posed, packed, out);
// HumanoidArmorLayer: head, chest, legs, feet.
for (slot, layer) in [
+77 -23
View File
@@ -105,10 +105,28 @@ impl Game<'_> {
let Some(BlockEntity::Beacon(mut b)) = self.world.block_entities.take(&key) else {
return;
};
self.tick_beacon(key, &mut b);
self.tick_beacon(key, &mut b, false);
self.world.block_entities.put(key, BlockEntity::Beacon(b));
}
fn tick_beacon(&mut self, (x, y, z): (i32, i32, i32), b: &mut Beacon) {
/// `ClientLevel.tickBlockEntities` for beacons: a server's chunk data
/// carries no beam sections, so the client builds them too; effects,
/// sounds and the advancement stay with the server.
pub(super) fn client_tick_beacons(&mut self) {
let keys: Vec<_> = self
.world
.block_entities
.iter()
.filter_map(|(k, e)| matches!(e, BlockEntity::Beacon(_)).then_some(*k))
.collect();
for key in keys {
let Some(BlockEntity::Beacon(mut b)) = self.world.block_entities.take(&key) else {
continue;
};
self.tick_beacon(key, &mut b, true);
self.world.block_entities.put(key, BlockEntity::Beacon(b));
}
}
fn tick_beacon(&mut self, (x, y, z): (i32, i32, i32), b: &mut Beacon, client: bool) {
let mut check = if b.last_check_y < y {
b.checking.clear();
b.last_check_y = y - 1;
@@ -153,7 +171,7 @@ impl Game<'_> {
if !b.sections.is_empty() {
b.levels = self.beacon_base(x, y, z);
}
if b.levels > 0 && !b.sections.is_empty() {
if !client && b.levels > 0 && !b.sections.is_empty() {
self.beacon_effects([x, y, z], b);
self.beacon_sound([x, y, z], "minecraft:block.beacon.ambient");
}
@@ -163,10 +181,12 @@ impl Game<'_> {
b.sections = std::mem::take(&mut b.checking);
let (was, is) = (previous > 0, b.levels > 0);
if !was && is {
self.beacon_sound([x, y, z], "minecraft:block.beacon.activate");
let levels = b.levels;
self.for_each_player(|g| g.beacon_constructed([x, y, z], levels));
} else if was && !is {
if !client {
self.beacon_sound([x, y, z], "minecraft:block.beacon.activate");
let levels = b.levels;
self.for_each_player(|g| g.beacon_constructed([x, y, z], levels));
}
} else if was && !is && !client {
self.beacon_sound([x, y, z], "minecraft:block.beacon.deactivate");
}
}
@@ -551,7 +571,12 @@ impl Game<'_> {
}
}
/// `BeaconRenderer`: each section's rotating solid beam and its glow.
pub(super) fn beacon_geometry(&self, camera: glam::DVec3, alpha: f64, out: &mut Vec<Vertex>) {
pub(super) fn beacon_geometry(
&mut self,
camera: glam::DVec3,
alpha: f64,
out: &mut Vec<Vertex>,
) {
let Some(s) = self
.gui
.atlas
@@ -563,15 +588,28 @@ impl Game<'_> {
};
let time = (self.world.game_time.rem_euclid(40)) as f32 + alpha as f32;
let scoping = self.scoping();
let view = f64::from(self.settings.render_distance * 16);
for (k, e) in &self.world.block_entities {
let BlockEntity::Beacon(b) = e else { continue };
// `getBeamSections` is empty while Levels is 0.
if b.levels == 0 || b.sections.is_empty() {
continue;
// `BeaconRenderer.shouldRender` culls at the render distance; far
// terrain extends that to its own distance.
let view = f64::from(self.terrain_distance() * 16);
let world = &self.world;
if self.lod.is_some() {
// The beam stays after its chunk unloads; once the chunk is back,
// its block entity decides whether the beam is still there.
self.far_beacons.retain(|&pos, _| {
let (x, _, z) = pos;
!world.chunks.contains_key(&(x >> 4, z >> 4))
|| world.block_entities.get(&pos).and_then(beam).is_some()
});
} else {
self.far_beacons.clear();
}
for (k, e) in &world.block_entities {
if let Some(sections) = beam(e) {
self.far_beacons.insert(*k, sections.to_vec());
}
}
for (k, sections) in &self.far_beacons {
let base = glam::DVec3::new(f64::from(k.0) + 0.5, f64::from(k.1), f64::from(k.2) + 0.5);
// `BeaconRenderer.shouldRender`: horizontal distance under render distance × 16.
let offset = base - camera;
let horizontal = glam::DVec2::new(offset.x, offset.z).length();
if horizontal >= view {
@@ -586,14 +624,30 @@ impl Game<'_> {
let origin = (glam::DVec3::new(f64::from(k.0), f64::from(k.1), f64::from(k.2))
- camera)
.as_vec3();
horizon_client::block_entity_render::beacon_beam(
origin,
&b.sections,
time,
scale,
s,
out,
);
horizon_client::block_entity_render::beacon_beam(origin, sections, time, scale, s, out);
}
}
}
/// The beam sections of an active beacon (`BeaconRenderer` draws no beam for
/// a beacon without a pyramid or with all sections cleared).
fn beam(e: &BlockEntity) -> Option<&[(u32, i32)]> {
match e {
BlockEntity::Beacon(b) if b.levels > 0 && !b.sections.is_empty() => Some(&b.sections),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_active_beacons_have_beams() {
let mut b = Beacon::default();
assert!(beam(&BlockEntity::Beacon(Box::new(b.clone()))).is_none());
b.levels = 1;
b.sections.push((0xFFFFFF, 10));
assert_eq!(
beam(&BlockEntity::Beacon(Box::new(b))),
Some(&[(0xFFFFFF, 10)][..])
);
}
}
+24 -17
View File
@@ -658,15 +658,30 @@ impl Game<'_> {
}
/// `BoatItem.use`: aim at a block or the water surface.
pub(super) fn place_boat(&mut self, stack: &ItemStack) -> bool {
let Some((wood, chest)) = any_boat_wood(&stack.item) else {
let Some(boat) = self.boat_to_place(stack) else {
return false;
};
if !self.boat_models.has(wood, chest) {
return false;
self.next_entity += 1;
let key = (
boat.position.x.floor() as i32 >> 4,
boat.position.z.floor() as i32 >> 4,
);
self.world.modified.insert(key);
self.boats.push(boat);
if !self.creative {
self.consume_held();
}
let Some((eye, dir)) = self.aim() else {
return false;
};
self.swing_arm();
true
}
/// The boat `BoatItem.use` would place for `stack` (its SUCCESS), or
/// None when it aims at nothing (PASS) or the spot is taken (FAIL).
pub(super) fn boat_to_place(&self, stack: &ItemStack) -> Option<Boat> {
let (wood, chest) = any_boat_wood(&stack.item)?;
if !self.boat_models.has(wood, chest) {
return None;
}
let (eye, dir) = self.aim()?;
let reach = self.reach();
// ClipContext.Fluid.ANY: the first fluid surface (water or lava) or
// block along the ray.
@@ -706,7 +721,7 @@ impl Game<'_> {
}
(Some(b), None) => b.0,
(None, Some(w)) => w.0,
(None, None) => return false,
(None, None) => return None,
};
let mut boat = Boat::new(
self.next_entity + 1,
@@ -730,17 +745,9 @@ impl Game<'_> {
subject: None,
};
if !scene.unobstructed(inner).unwrap_or(false) || self.boat_entity_collision(bb) {
return false;
return None;
}
self.next_entity += 1;
let key = (hit[0].floor() as i32 >> 4, hit[2].floor() as i32 >> 4);
self.world.modified.insert(key);
self.boats.push(boat);
if !self.creative {
self.consume_held();
}
self.swing_arm();
true
Some(boat)
}
pub(super) fn boat_hit(&self) -> Option<(usize, f64)> {
let (eye, dir) = self.aim()?;
+26 -11
View File
@@ -95,6 +95,11 @@ impl Game<'_> {
/// `BookViewScreen.mouseClicked`: the click event of the text under the
/// cursor on the page.
fn book_link_at(&self, page: &Text, left: f32) -> Option<horizon_client::text::ClickEvent> {
self.book_style_at(page, left)?.click
}
/// `ActiveTextCollector` over the page: the style of the character
/// under the cursor, for its click and hover events.
fn book_style_at(&self, page: &Text, left: f32) -> Option<horizon_client::text::Style> {
let (mx, my) = self.cursor;
let row = ((my - 32.0) / 9.0).floor();
if row < 0.0 || row >= LINES as f32 {
@@ -109,7 +114,7 @@ impl Game<'_> {
for (c, style) in line.chars() {
x -= self.gui.atlas.font.advance(c, style);
if x < 0.0 {
return style.click.clone();
return Some(style.clone());
}
}
None
@@ -139,22 +144,22 @@ impl Game<'_> {
}
}
ClickEvent::OpenUrl(url) => self.click_url(url),
ClickEvent::RunCommand(command) => self.click_command(&command),
ClickEvent::SuggestCommand(command) => {
// defaultHandleClickEvent: the chat screen takes it over.
if self.player.is_some() {
self.close_book();
self.open_chat(&command);
}
// closeContainerOnServer, then clickCommandAction with no screen
// after the command: the book closes and the command runs.
ClickEvent::RunCommand(command) => {
self.close_book();
self.click_command(&command);
}
// Screen.defaultHandleClickEvent: copied, the book stays open.
ClickEvent::CopyToClipboard(value) => {
use horizon_client::clipboard::Clipboard;
self.clipboard.set(&value);
}
// BookViewScreen.handleClickEvent: the rest is
// Screen.defaultHandleGameClickEvent's.
// defaultHandleGameClickEvent: the dialog opens over the book.
ClickEvent::ShowDialog(dialog) => self.show_dialog_ref(&dialog),
_ => {}
// SuggestCommand's insertText does nothing on a book; a server's
// text never opens a file.
ClickEvent::SuggestCommand(_) | ClickEvent::OpenFile(_) => {}
}
}
/// `WritableBookItem.use` / `WrittenBookItem.use` (`Player.openItemGui`).
@@ -1314,6 +1319,16 @@ impl Game<'_> {
true,
);
}
// HoveredTextEffects.TOOLTIP_AND_CURSOR: the hovered words'
// show_text hover event, as the chat shows it.
if let Some(hover) = pages
.get(*page)
.and_then(|t| self.book_style_at(t, left))
.and_then(|s| s.hover)
{
let lines = self.wrapped_rows(&hover, (w as i32 / 2).max(200));
self.draw_tooltip_lines(batch, &lines, mx, my);
}
}
}
}
+4 -2
View File
@@ -142,9 +142,11 @@ impl Game<'_> {
let Some(mut tag) = spill::saved_entity(&self.world, pos) else {
return;
};
if tag.get("LootTable").is_none() {
let Some(horizon_world::nbt::Tag::String(table)) = tag.get("LootTable") else {
return;
}
};
let table = table.to_string_lossy();
self.trigger_loot_table(&table);
let loot = horizon_client::structure_blocks::loot_data(&self.options.resources);
decorated_pot::unpack_loot(
&mut tag,
+114 -61
View File
@@ -12,7 +12,11 @@ use horizon_client::{
screen_draw::Measure,
text::{ClickEvent, Style, Text},
};
use std::collections::BTreeMap;
use std::{
collections::BTreeMap,
path::{Path, PathBuf},
sync::{Arc, Mutex},
};
/// The tree the chat parses and completes with: a server's, once it has
/// sent one (ClientPacketListener.commands), else the game's own.
@@ -123,6 +127,64 @@ impl UiState {
}
}
/// The ids `build_registries` lists from the prepared resources' folders.
/// The folders don't change while the game runs, so each root is read once
/// instead of at every world opening (thousands of files).
struct ResourceIds {
recipes: Vec<String>,
damage_types: Vec<String>,
loot_tables: Vec<String>,
templates: Vec<String>,
}
static RESOURCE_IDS: Mutex<Vec<(PathBuf, Arc<ResourceIds>)>> = Mutex::new(Vec::new());
fn resource_ids(root: &Path) -> Arc<ResourceIds> {
let mut cache = RESOURCE_IDS.lock().unwrap_or_else(|e| e.into_inner());
if let Some((_, ids)) = cache.iter().find(|(cached, _)| cached == root) {
return ids.clone();
}
let data = root.join("data/minecraft");
let ids = Arc::new(ResourceIds {
recipes: ids_under(&data.join("recipe"), "json"),
damage_types: ids_under(&data.join("damage_type"), "json"),
loot_tables: ids_under(&data.join("loot_table"), "json"),
templates: ids_under(&root.join("worldgen/structure/minecraft"), "nbt"),
});
cache.push((root.to_owned(), ids.clone()));
ids
}
/// The `minecraft:` ids of the `extension` files under `root`, sorted.
fn ids_under(root: &Path, extension: &str) -> Vec<String> {
let mut out = Vec::new();
let mut stack = vec![root.to_owned()];
while let Some(dir) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for e in entries.flatten() {
let path = e.path();
// The listing's own type, without another look at each file; a
// link is followed, as `Path::is_dir` does.
let is_dir = e
.file_type()
.is_ok_and(|t| t.is_dir() || (t.is_symlink() && path.is_dir()));
if is_dir {
stack.push(path);
} else if path.extension().and_then(|x| x.to_str()) == Some(extension)
&& let Ok(rel) = path.strip_prefix(root)
{
out.push(format!(
"minecraft:{}",
rel.with_extension("").to_string_lossy().replace('\\', "/")
));
}
}
}
out.sort();
out
}
impl Game<'_> {
/// `PlayerList.isOp` for the single-player owner: the world's Allow Commands.
pub(super) fn allow_commands(&self) -> bool {
@@ -146,61 +208,11 @@ impl Game<'_> {
let permission = owner_permission(self.allow_commands());
let reg = &mut self.ui.registries;
reg.permission = permission;
{
let dir = |sub: &str| -> Vec<String> {
let root = self.options.resources.join("data/minecraft").join(sub);
let mut out = Vec::new();
let mut stack = vec![root.clone()];
while let Some(d) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&d) else {
continue;
};
for e in entries.flatten() {
let p = e.path();
if p.is_dir() {
stack.push(p);
} else if p.extension().and_then(|x| x.to_str()) == Some("json")
&& let Ok(rel) = p.strip_prefix(&root)
{
out.push(format!(
"minecraft:{}",
rel.with_extension("").to_string_lossy().replace('\\', "/")
));
}
}
}
out.sort();
out
};
self.ui.registries.recipes = dir("recipe");
self.ui.registries.damage_types = dir("damage_type");
self.ui.registries.loot_tables = dir("loot_table");
self.ui.registries.templates = {
let root = self.options.resources.join("worldgen/structure/minecraft");
let mut out = Vec::new();
let mut stack = vec![root.clone()];
while let Some(d) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&d) else {
continue;
};
for e in entries.flatten() {
let p = e.path();
if p.is_dir() {
stack.push(p);
} else if p.extension().and_then(|x| x.to_str()) == Some("nbt")
&& let Ok(rel) = p.strip_prefix(&root)
{
out.push(format!(
"minecraft:{}",
rel.with_extension("").to_string_lossy().replace('\\', "/")
));
}
}
}
out.sort();
out
};
}
let ids = resource_ids(&self.options.resources);
self.ui.registries.recipes = ids.recipes.clone();
self.ui.registries.damage_types = ids.damage_types.clone();
self.ui.registries.loot_tables = ids.loot_tables.clone();
self.ui.registries.templates = ids.templates.clone();
self.ui.registries.sounds = self.audio.event_names();
self.ui.registries.attributes = [
"armor",
@@ -416,8 +428,18 @@ impl Game<'_> {
.collect();
}
/// The block the player is looking at, for coordinate suggestions.
/// `ClientSuggestionProvider.getRelevantCoordinates` and
/// `getAbsoluteCoordinates` read `Minecraft.hitResult` only when it is a
/// block: an entity in front of the block leaves the `~` defaults.
fn refresh_dynamic_registries(&mut self) {
self.ui.registries.target_block = self.pick().map(|h| h.pos);
let hit = if self.pick_entity_hit().is_some() {
None
} else {
self.pick()
};
self.ui.registries.target_block = hit.as_ref().map(|h| h.pos);
self.ui.registries.target_point =
hit.map(|h| h.point.map(horizon_client::java_text::format_fixed2));
// ClientPacketListener.getOnlinePlayerNames on a server.
if let Some(names) = self.remote_online_names() {
self.ui.registries.players = names;
@@ -961,7 +983,7 @@ impl Game<'_> {
}
}
/// `Font.split`: a tooltip text wrapped at `width`, one white row per line.
fn wrapped_rows(&self, text: &Text, width: i32) -> Vec<(Text, u32)> {
pub(super) fn wrapped_rows(&self, text: &Text, width: i32) -> Vec<(Text, u32)> {
let split = self
.gui
.atlas
@@ -1126,9 +1148,19 @@ impl Game<'_> {
None => self.combat_death_message(),
};
self.ui.death_text = Some(message.clone());
// ServerPlayer.die: a team that hides death messages keeps it out of
// the dying player's own chat (the death screen still shows it).
if horizon_client::scoreboard::own_death_message_shown(self.player_team()) {
// ServerPlayer.die: the team's death message visibility decides who
// hears it, the dying player's own chat included (the death screen
// still shows it).
let board = &self.score.board;
if let Some(host) = &self.lan {
host.hub.system_message_to(
horizon_client::lan::data::component_nbt(&message.component()),
|other| {
horizon_client::scoreboard::death_message_reaches(board, PLAYER_NAME, other)
},
);
}
if horizon_client::scoreboard::death_message_reaches(board, PLAYER_NAME, PLAYER_NAME) {
self.system_message(message);
}
}
@@ -1224,3 +1256,24 @@ fn owner_permission(allow_commands: bool) -> u8 {
/// `ConfirmLinkScreen`'s buttons, left to right: yes, copy, no.
const LINK_YES: usize = 0;
const LINK_COPY: usize = 1;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resource_ids_are_listed_once_per_root() {
let root = std::env::temp_dir().join(format!("horizon-ids-{}", std::process::id()));
let recipe = root.join("data/minecraft/recipe");
std::fs::create_dir_all(recipe.join("sub")).unwrap();
std::fs::write(recipe.join("b.json"), "{}").unwrap();
std::fs::write(recipe.join("sub/a.json"), "{}").unwrap();
std::fs::write(recipe.join("notes.txt"), "").unwrap();
let first = resource_ids(&root);
assert_eq!(first.recipes, ["minecraft:b", "minecraft:sub/a"]);
assert!(first.loot_tables.is_empty());
std::fs::write(recipe.join("c.json"), "{}").unwrap();
assert!(Arc::ptr_eq(&first, &resource_ids(&root)));
let _ = std::fs::remove_dir_all(&root);
}
}
+4
View File
@@ -57,6 +57,10 @@ pub(super) struct CombatTracker {
/// The mob exploding now (`ServerExplosion.source`): its team's friendly
/// fire decides whom the blast may hurt.
pub(super) explosion_source: Option<u32>,
/// The exploding mob named itself (`MobEvent::ExplosionSource`), so the
/// blast leaves it out (`getEntities(source, box)`). A source guessed
/// from the mobs near the centre is still hurt: it may be a bystander.
pub(super) explosion_source_named: bool,
/// The blast now going off comes from a primed TNT, creeper, wither
/// skull, wither or TNT minecart, the sources that empty a hive they
/// destroy (`BeehiveBlock.getDrops`).
+105 -19
View File
@@ -925,24 +925,7 @@ impl Game<'_> {
Ok(1)
}
"say" | "me" | "msg" => self.cmd_say(id, ctx),
"list" => {
let names = if ctx.has("uuids") {
Text::translate(
"commands.list.nameAndId",
&[
Text::literal(PLAYER_NAME),
Text::literal(self.player_uuid_string()),
],
)
} else {
Text::literal(PLAYER_NAME)
};
self.system_message(Text::translate(
"commands.list.players",
&[Text::literal("1"), Text::literal("8"), names],
));
Ok(1)
}
"list" => self.cmd_list(ctx),
_ => Err(err("command.failed", &[])),
}
}
@@ -1929,7 +1912,10 @@ impl Game<'_> {
fn spawned_mark(&self) -> (u32, usize) {
(
self.next_entity,
self.tnts.len() + self.falling.len() + self.creatures.projectiles.len(),
self.tnts.len()
+ self.falling.len()
+ self.creatures.projectiles.len()
+ self.rockets.len(),
)
}
/// BlockStateArgument -> registry state.
@@ -2086,6 +2072,49 @@ impl Game<'_> {
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
true
}
/// `RandomizableContainer.unpackLootTable` for a player who opens a
/// container: a roll the container still holds fires
/// `player_generates_container_loot` for its table, and from then on the
/// container saves its items, not the table.
pub(super) fn unpack_opened_loot(&mut self, key: (i32, i32, i32), name: &str) {
let Some(rolled) = self
.world
.chunks
.get(&(key.0 >> 4, key.2 >> 4))
.and_then(|c| c.saved.as_ref())
.and_then(|s| s.rolled_loot.iter().find(|r| r.pos == key))
.cloned()
else {
return;
};
// The roll is still there only while the container holds exactly its items.
let still_rolled = self
.world
.block_entities
.get_mut(&key)
.is_some_and(|entity| {
let mut tag = horizon_client::save::encode_block_entity(key, entity, name);
match &mut tag {
horizon_world::nbt::Tag::Compound(map) => rolled.restore(map),
_ => false,
}
});
if still_rolled {
self.trigger_loot_table(&rolled.table);
}
let Some(chunk) = self.world.chunks.get_mut(&(key.0 >> 4, key.2 >> 4)) else {
return;
};
let chunk = std::sync::Arc::make_mut(chunk);
let mut extras = chunk
.saved
.take()
.map(std::sync::Arc::unwrap_or_clone)
.unwrap_or_default();
extras.rolled_loot.retain(|r| r.pos != key);
chunk.saved = Some(std::sync::Arc::new(extras));
self.world.modified.insert((key.0 >> 4, key.2 >> 4));
}
/// RandomizableContainer.tryLoadLootTable for a tag a command gives a
/// container (`setblock`, `data`): an empty container with a `LootTable`
/// is rolled now, as a loaded chunk's are (`save::roll_loaded_loot`), and
@@ -3096,6 +3125,63 @@ impl Game<'_> {
}
out
}
/// ListPlayersCommand: every online player, the host of a hosted world
/// first and then the guests in join order, with the count and the max.
fn cmd_list(&mut self, ctx: &Context) -> R<i32> {
// A single-player owner is a placed guest of the in-process server, so
// it is the host and needs no entry of its own.
let owner_listed = self
.lan
.as_ref()
.is_some_and(|h| h.hub.singleplayer_owner.is_some());
// The host is online unless this is a dedicated server, which has none.
let host = (self.options.dedicated.is_none() && !owner_listed).then(|| match &self.lan {
Some(h) => (h.host_uuid, h.host_name.clone()),
None => (self.player_uuid(), PLAYER_NAME.to_owned()),
});
let guests = self
.lan
.iter()
.flat_map(|h| h.hub.guests.iter())
.filter(|g| g.placed)
.map(|g| (g.uuid, g.name.clone()));
let players: Vec<(u128, String)> = host.into_iter().chain(guests).collect();
let max = self
.lan
.as_ref()
.map_or(horizon_client::lan::MAX_PLAYERS, |h| {
h.hub.lan.shared.max_players()
});
let uuids = ctx.has("uuids");
let mut names = Text::empty();
for (k, (uuid, name)) in players.iter().enumerate() {
if k > 0 {
// ComponentUtils.formatList: gray commas.
names.push(Text::colored(", ", text::GRAY));
}
if uuids {
names.push(Text::translate(
"commands.list.nameAndId",
&[
Text::literal(name.as_str()),
Text::literal(horizon_protocol::crypto::uuid_string(*uuid)),
],
));
} else {
names.push(self.player_text(name, *uuid));
}
}
let count = players.len();
self.system_message(Text::translate(
"commands.list.players",
&[
Text::literal(count.to_string()),
Text::literal(max.to_string()),
names,
],
));
Ok(count as i32)
}
fn cmd_say(&mut self, id: &str, ctx: &Context) -> R<i32> {
let raw = match ctx.get(if id == "me" { "action" } else { "message" }) {
Some(Arg::Str(s)) => s.clone(),
@@ -1115,10 +1115,10 @@ impl Game<'_> {
}
}
impl Game<'_> {
/// The player's profile id as `UUID.toString()`: the saved id, else the
/// offline id of the player's name.
pub(in crate::game) fn player_uuid_string(&self) -> String {
let id = self.level.player_uuid.map_or_else(
/// The player's profile id: the saved id, else the offline id of the
/// player's name.
pub(in crate::game) fn player_uuid(&self) -> u128 {
self.level.player_uuid.map_or_else(
|| horizon_protocol::crypto::offline_uuid(super::super::chat::PLAYER_NAME),
|[a, b, c, d]| {
(u128::from(a as u32) << 96)
@@ -1126,15 +1126,6 @@ impl Game<'_> {
| (u128::from(c as u32) << 32)
| u128::from(d as u32)
},
);
let h = format!("{id:032x}");
format!(
"{}-{}-{}-{}-{}",
&h[..8],
&h[8..12],
&h[12..16],
&h[16..20],
&h[20..]
)
}
}
@@ -73,7 +73,7 @@ impl Game<'_> {
/// `EntitySelector.findEntities` with a UUID: the player with it, else
/// the entity.
pub(super) fn target_by_uuid(&self, uuid: u128) -> Option<Target> {
pub(in crate::game) fn target_by_uuid(&self, uuid: u128) -> Option<Target> {
let own = self.lan_acting_uuid.or_else(|| {
self.level.player_uuid.map(|[a, b, c, d]| {
(u128::from(a as u32) << 96)
@@ -6,7 +6,7 @@ use horizon_protocol::packets::play::{self, Clientbound as C};
impl Game<'_> {
/// ComponentArgument with selectors resolved for the source
/// (ComponentUtils.updateForEntity: names joined by ", ").
fn component_arg(&mut self, ctx: &Context, name: &str) -> R<Text> {
pub(in crate::game) fn component_arg(&mut self, ctx: &Context, name: &str) -> R<Text> {
let Some(Arg::Str(json)) = ctx.get(name) else {
return Ok(Text::empty());
};
@@ -124,6 +124,21 @@ impl Game<'_> {
"freeze" => DamageKind::Freeze,
"in_wall" => DamageKind::InWall,
"thorns" => DamageKind::Thorns,
"cramming" => DamageKind::Cramming,
"falling_anvil" => DamageKind::FallingAnvil,
"falling_block" => DamageKind::FallingBlock,
"fireworks" => DamageKind::Fireworks,
"generic_kill" => DamageKind::GenericKill,
"lightning_bolt" => DamageKind::LightningBolt,
"out_of_world" => DamageKind::OutOfWorld,
"outside_border" => DamageKind::OutsideBorder,
"sonic_boom" => DamageKind::SonicBoom,
"spit" => DamageKind::Spit,
"stalagmite" => DamageKind::Stalagmite,
"starve" => DamageKind::Starve,
"sulfur_cube_hot" => DamageKind::SulfurCubeHot,
"wind_charge" => DamageKind::WindCharge,
"wither" => DamageKind::Wither,
_ => DamageKind::Generic,
}
}
+2 -1
View File
@@ -361,7 +361,8 @@ impl Game<'_> {
if action != "up" {
self.play_mouse(button, true)?;
}
if action != "down" {
// A repeat keeps the key held, as the window's.
if action == "up" || action == "tap" {
self.play_mouse(button, false)?;
}
}
@@ -25,7 +25,11 @@ impl Game<'_> {
return;
}
rows[index] = self.inventory[..hotbar::SIZE].to_vec();
let _ = hotbar::save(&path, &rows);
if let Err(e) = hotbar::save(&path, &rows) {
// saveHotbar sets the message inside its try, so a failed save says nothing.
log::warn!("the creative hotbar was not saved: {e}");
return;
}
let key = |mapping: &str| keybinds::display_name(self.settings.keys.get(mapping));
let message = Text::translate(
"inventory.hotbarSaved",
+37 -22
View File
@@ -2,7 +2,7 @@
//! chunk-generation spawning (`NaturalSpawner`), mob events (attacks,
//! breeding, conversions, drops), projectiles, player interactions
//! (feeding, shearing, milking, spawn eggs, throwing) and rendering.
use super::{Dim, Game, view_vector};
use super::{Dim, Game, dispenser, view_vector};
use horizon_assets::item_model::DisplayContext;
use horizon_client::{
Result,
@@ -857,7 +857,7 @@ impl Game<'_> {
}
self.score_entity_removed(m.id);
self.raider_died(m, by_player);
self.villager_murdered(m, by_player);
self.villager_murdered(m, by_player, killer.flatten());
let pos = [m.position.x, m.position.y + 0.5, m.position.z];
self.drop_death_loot(&mut dying, by_player, consumed, pos);
self.create_wither_rose(m);
@@ -1019,7 +1019,13 @@ impl Game<'_> {
} else {
from_loot(&stack)
};
self.spawn_item(item, pos, [0.0, 0.2, 0.0], 10);
// Every death drop is its own ItemEntity, and the drop
// entity's own random (not the level's) gives it the
// constructor's horizontal push (its motion otherwise
// stays [0, 0.2, 0] and the drops land stacked at the
// mob's centre, without disturbing the level random that
// the orbs' merge ids and block ticks read next).
self.spawn_item(item, pos, dispenser::new_item_motion(), 10);
}
drops::Drop::Orb(value) if !consumed => self.spawn_orbs(pos, value as u32),
drops::Drop::Orb(_) => {}
@@ -1125,7 +1131,9 @@ impl Game<'_> {
.loot_table(table, false, None, &mut self.world.random),
};
for d in stacks {
self.spawn_item(d, at, [0.0, 0.2, 0.0], 10);
// The ItemEntity constructor's motion, from the entity's own
// random (not the level's).
self.spawn_item(d, at, dispenser::new_item_motion(), 10);
}
}
/// Adds a projectile, giving it an id from the entity counter
@@ -1301,6 +1309,7 @@ impl Game<'_> {
self.note_attacker("minecraft:explosion", id);
}
self.combat.explosion_source = source_mob;
self.combat.explosion_source_named = noted;
// Only a creeper or a wither empties the hives it blows up.
// The creeper is already gone from the list, so the event
// carries its kind.
@@ -1310,6 +1319,7 @@ impl Game<'_> {
self.explode(at, radius)
};
self.combat.explosion_source = None;
self.combat.explosion_source_named = false;
result?
}
MobEvent::AttackMob {
@@ -1467,7 +1477,11 @@ impl Game<'_> {
extra: None,
},
at,
[0.0, 0.2, 0.0],
// The ItemEntity constructor's motion, from the entity's
// own random (not the level's), so spawnAtLocation drops
// (a fox's berries, a panda's held food, a goat's horn)
// land apart.
dispenser::new_item_motion(),
10,
),
MobEvent::DropGift { table, at } => {
@@ -1476,14 +1490,9 @@ impl Game<'_> {
.items()
.loot_table(table, false, None, &mut self.world.random);
for stack in stacks {
// The ItemEntity constructor's random motion.
let r = &mut self.world.random;
let v = [
r.next_double() * 0.2 - 0.1,
0.2,
r.next_double() * 0.2 - 0.1,
];
self.spawn_item(stack, at, v, 10);
// The ItemEntity constructor's motion, from the
// entity's own random (not the level's).
self.spawn_item(stack, at, dispenser::new_item_motion(), 10);
}
}
// `Level.setBlockAndUpdate`: neighbour and shape updates, so a crop
@@ -4552,15 +4561,10 @@ impl Game<'_> {
out,
);
if &*mob.kind.id == "minecraft:player" {
// PlayerRenderer's WingsLayer and SpinAttackEffectLayer.
self.player_elytra_geometry(
mob,
mob.extra.chest.as_deref(),
camera,
alpha as f32,
packed,
out,
);
// PlayerRenderer's CapeLayer, WingsLayer and SpinAttackEffectLayer.
let chest = mob.extra.chest.as_deref();
self.player_cape_geometry(mob, chest, camera, alpha as f32, packed, out);
self.player_elytra_geometry(mob, chest, camera, alpha as f32, packed, out);
self.spin_attack_geometry(mob, camera, alpha as f32, packed, out);
}
self.mob_block_geometry(mob, camera, alpha, packed, out);
@@ -4880,6 +4884,17 @@ pub(super) fn damage_type(kind: horizon_client::mobs::DamageKind, player: bool)
K::Freeze => "minecraft:freeze",
K::OutOfWorld => "minecraft:out_of_world",
K::Cramming => "minecraft:cramming",
K::FallingAnvil => "minecraft:falling_anvil",
K::Fireworks => "minecraft:fireworks",
K::GenericKill => "minecraft:generic_kill",
K::LightningBolt => "minecraft:lightning_bolt",
K::OutsideBorder => "minecraft:outside_border",
K::Spit => "minecraft:spit",
K::Stalagmite => "minecraft:stalagmite",
K::Starve => "minecraft:starve",
K::SulfurCubeHot => "minecraft:sulfur_cube_hot",
K::WindCharge => "minecraft:wind_charge",
K::Wither => "minecraft:wither",
}
}
/// A Horizon item stack as a loot stack (id, count, damage, enchantments).
+1 -1
View File
@@ -203,7 +203,7 @@ impl Game<'_> {
power,
1.0,
);
self.launch_angled_rocket(at, shot.velocity, ammo);
self.launch_angled_rocket(at, shot.velocity, ammo, Some(Who::Player));
}
if !rocket {
let kind = ProjectileKind::Arrow {
@@ -416,6 +416,13 @@ impl Game<'_> {
],
);
}
// Horizon's own: HDR's output while the setting is on, under the GPU (a group of its
// own would move others to the other column).
if self.settings.horizon.hdr
&& let Some(r) = self.renderer.as_ref()
{
d.group("system", [r.hdr_line()]);
}
// Horizon's own: the shader pack's GPU time per pass (an empty group would move others).
let pass_lines = self.renderer.as_ref().map_or_else(Vec::new, |r| {
debug_overlay::shader_pass_lines(&r.shader_pass_times())
@@ -917,6 +924,17 @@ impl Game<'_> {
(t.pitch, t.yaw),
);
}
// EntityType.EYE_OF_ENDER: 0.25 by 0.25, never turned.
for (previous, now) in self.end.eye_motions() {
let v = |p: glam::DVec3| Vec3::new(p.x, p.y, p.z);
g.hitbox(
at(v(previous), v(now)),
(0.25, 0.25),
0.2125,
false,
(0.0, 0.0),
);
}
for r in &self.rockets {
// Projectile.updateRotation: the look follows the motion.
let v = r.velocity;
+20 -23
View File
@@ -298,15 +298,14 @@ impl Lists {
}
}
/// The world presets of 26.3's `worldgen/world_preset`.
const WORLD_PRESETS: [&str; 7] = [
"minecraft:normal",
"minecraft:flat",
"minecraft:large_biomes",
"minecraft:amplified",
"minecraft:single_biome_surface",
"minecraft:debug_all_block_states",
"minecraft:flat_all_dimensions",
/// DedicatedServerProperties.LEGACY_PRESET_NAMES: the old `level-type`
/// names, before the presets had their own ids.
const LEGACY_PRESET_NAMES: [(&str, horizon_client::save::WorldPreset); 2] = [
("default", horizon_client::save::WorldPreset::Normal),
(
"largebiomes",
horizon_client::save::WorldPreset::LargeBiomes,
),
];
/// Identifier.tryParse: `namespace:path`, or a path in `minecraft`.
@@ -323,11 +322,18 @@ impl Game<'_> {
/// DedicatedServerProperties.createDimensions for a world that doesn't
/// exist yet: `level-type`'s preset, a flat world's `generator-settings`
/// and `generate-structures`, written before the world opens so that
/// streaming reads them. Horizon generates the normal and flat presets.
/// streaming reads them.
fn write_world_gen(&self, dir: &std::path::Path, props: &ServerProperties) -> Result<()> {
let level_type = &props.level_type;
let preset = parse_identifier(level_type)
.filter(|id| WORLD_PRESETS.contains(&id.as_str()))
.as_deref()
.and_then(horizon_client::save::WorldPreset::of_id)
.or_else(|| {
LEGACY_PRESET_NAMES
.iter()
.find(|(name, _)| *name == level_type)
.map(|(_, preset)| *preset)
})
.unwrap_or_else(|| {
log_from(
"Worker-Main-1",
@@ -336,18 +342,9 @@ impl Game<'_> {
"Failed to parse level-type {level_type}, defaulting to minecraft:normal"
),
);
"minecraft:normal".to_owned()
horizon_client::save::WorldPreset::Normal
});
let flat = preset == "minecraft:flat";
if !flat && preset != "minecraft:normal" {
log_from(
"Worker-Main-1",
"WARN",
&format!(
"Horizon doesn't generate the {preset} world preset yet: using minecraft:normal"
),
);
}
let flat = preset == horizon_client::save::WorldPreset::Flat;
let settings = if flat {
let biomes = self.res.biomes();
let (settings, errors) = horizon_client::flatgen::settings_from_json(
@@ -363,7 +360,7 @@ impl Game<'_> {
None
};
let options = horizon_client::save::WorldGen {
flat,
preset,
structures: props.generate_structures,
bonus_chest: false,
};
+1 -1
View File
@@ -987,7 +987,7 @@ impl Game<'_> {
"minecraft:firework_rocket" => {
let mut shot = Projectile::new(ProjectileKind::Snowball, at, None);
shot.shoot(&mut own, dir, power, uncertainty);
self.launch_angled_rocket(at, shot.velocity, &one);
self.launch_angled_rocket(at, shot.velocity, &one, None);
}
_ => {
let kind = match item.as_str() {
+40 -5
View File
@@ -801,10 +801,7 @@ impl Game<'_> {
}
if !self.fight.killed {
if self.dragon.is_none() {
// createNewDragon: over the podium, holding pattern, a random yaw.
let yaw = self.world.random.next_float() * 360.0;
let seed = self.world.random.next_long();
self.dragon = Some(Dragon::new(Vec3::new(0.0, 128.0, 0.0), yaw, seed));
self.create_new_dragon();
}
self.fight.crystal_scan += 1;
if self.fight.crystal_scan >= 100 {
@@ -3022,10 +3019,17 @@ impl Game<'_> {
}
/// `EnderDragon.kill` (`/kill`): `Entity.kill` removes the entity, it
/// does not hurt it (the dragon flies its death flight when its health
/// runs out).
/// runs out). The fight's dragon ends the fight there and then
/// (`dragonFight.setDragonKilled`): the exit portal, a gateway and the
/// first dragon's egg, and no new dragon. No experience: `tickDeath`
/// drops it, and a killed dragon never gets there.
pub(super) fn kill_dragon(&mut self, id: u32) {
if self.dragon.as_ref().is_some_and(|d| d.id == id) {
self.dragon = None;
// ServerLevel.getDragonFight: only the End has a fight.
if self.dim == Dim::End {
self.dragon_killed();
}
}
}
/// Any entity touching `room` (`Level.getEntities(null, box)`).
@@ -3317,6 +3321,12 @@ impl Game<'_> {
}
}
}
/// `EnderDragonFight.createNewDragon`: over the podium, holding pattern, a random yaw.
fn create_new_dragon(&mut self) {
let yaw = self.world.random.next_float() * 360.0;
let seed = self.world.random.next_long();
self.dragon = Some(Dragon::new(Vec3::new(0.0, 128.0, 0.0), yaw, seed));
}
/// The END stage: the portal crystals blow up (no block damage) and a
/// new dragon takes over.
fn finish_respawn(&mut self) {
@@ -3343,6 +3353,31 @@ impl Game<'_> {
}
}
self.fight.killed = false;
// setRespawnStage(END): the new dragon is summoned for every player
// of the fight (the boss bar's players: alive within 192 blocks).
self.create_new_dragon();
let view = self
.dragon
.as_ref()
.map(|d| horizon_client::advancements::EntityView {
kind: "minecraft:ender_dragon".to_owned(),
position: [d.position.x, d.position.y, d.position.z],
..Default::default()
});
if let Some(view) = view {
self.for_each_player(|g| {
let near = g.player.as_ref().is_some_and(|p| {
!p.health.dead_or_dying()
&& p.position.x.powi(2)
+ (p.position.y - 128.0).powi(2)
+ p.position.z.powi(2)
<= 192.0 * 192.0
});
if near {
g.trigger_entity("minecraft:summoned_entity", "entity", &view);
}
});
}
self.fight.dirty = true;
self.count_crystals();
}
+17 -1
View File
@@ -181,6 +181,10 @@ impl EndState {
survive: false,
});
}
/// Where each eye in flight was last tick and is now (the hitbox view).
pub(super) fn eye_motions(&self) -> impl Iterator<Item = (glam::DVec3, glam::DVec3)> + '_ {
self.eyes.iter().map(|e| (e.previous, e.position))
}
/// `EyeOfEnder.tick` on a server's client: it moves by its last known
/// motion; only the server steers it.
pub(super) fn step_remote_eyes(&mut self) {
@@ -329,6 +333,13 @@ impl Game<'_> {
.clone()
}
/// EnderEyeItem.use's `getPlayerPOVHitResult`: whether the block aimed
/// at is an End portal frame (the use passes there).
pub(super) fn aims_at_end_portal_frame(&self) -> bool {
self.pick()
.is_some_and(|hit| &*self.block_name(hit.pos) == "minecraft:end_portal_frame")
}
/// EnderEyeItem.useOn: fill an empty End portal frame, then light the
/// portal when the ring of twelve filled frames is complete.
pub(super) fn use_ender_eye(&mut self, stack: &ItemStack, use_on_block: bool) -> Result<bool> {
@@ -345,7 +356,12 @@ impl Game<'_> {
if matches!(self.guest_use, Some(super::GuestUse::OnBlock(_))) {
return Ok(false);
}
// EnderEyeItem.use: throw the eye towards the nearest stronghold.
// EnderEyeItem.use: startUsingItem (getUseDuration 0, so the use
// lasts until the button lets go), then the eye is thrown towards
// the nearest stronghold if there is one (CONSUME without).
if self.using.is_none() {
self.using = Some((self.held_slot(), 0));
}
self.throw_ender_eye();
return Ok(true);
};
+7 -2
View File
@@ -382,9 +382,14 @@ impl Game<'_> {
}
self.lan = Some(host);
}
// Mobs.
// Mobs, but not the one exploding (`getEntities(source, box)`): a
// wither's spawn blast neither hurts nor pushes it.
let spared = exploding.filter(|_| self.combat.explosion_source_named);
let mut mobs = std::mem::take(&mut self.mobs);
for m in mobs.iter_mut().filter(|m| m.alive()) {
for m in mobs
.iter_mut()
.filter(|m| m.alive() && spared != Some(m.id))
{
let hurtable = teams_allow(self, self.mob_team(m.id));
let feet = [m.position.x, m.position.y, m.position.z];
let dist = distance(feet) / diameter;
+206 -24
View File
@@ -7,10 +7,12 @@ use horizon_assets::item_model::DisplayContext;
use horizon_client::{
block_entity_render::vertex_light,
collisions::SceneCollisions,
entity_nbt::RocketNbt,
items::ItemStack,
mesher::Vertex,
mobs::{Damage, DamageKind},
mobs::{Damage, DamageKind, Species, Who},
particles::{FireworkExplosion, ParticleOptions, ParticleType},
projectiles::ProjectileWorld,
};
use horizon_core::{
geometry::{Aabb, Vec3},
@@ -30,9 +32,14 @@ pub(super) struct Rocket {
pub(super) explosions: Vec<FireworkExplosion>,
/// Boosting the player (attachedToEntity).
pub(super) attached: bool,
/// On a server, the player it boosts when not this game's own: a guest's
/// connection.
/// On a server, the guest (by connection) who launched it, or whom it
/// boosts: `owner`'s `Who::Player` then means them, not the host.
pub(super) guest: Option<u32>,
/// `Projectile.getOwner`: the player who launched it, credited with
/// what its blast hurts.
pub(super) owner: Option<Who>,
/// `Projectile.leftOwner`: until then it can't hit its owner.
left_owner: bool,
/// `DATA_ATTACHED_TO_TARGET`: the server entity the rocket follows;
/// boosting happens when it is the local player.
pub(super) attached_to: Option<u32>,
@@ -57,11 +64,36 @@ impl Rocket {
explosions,
attached: false,
guest: None,
owner: None,
left_owner: false,
attached_to: None,
angled: false,
item,
}
}
/// A rocket loaded from its NBT (`/summon`, a saved chunk).
pub(super) fn from_nbt(r: RocketNbt) -> Self {
let item = r
.item
.unwrap_or_else(|| ItemStack::new("minecraft:firework_rocket", 1));
let (_, explosions) = fireworks_of(&item);
Self {
id: 0,
position: r.position,
previous: r.position,
velocity: r.motion,
life: r.life,
lifetime: r.lifetime,
explosions,
attached: false,
guest: None,
owner: r.owner,
left_owner: r.left_owner,
attached_to: None,
angled: r.angled,
item,
}
}
/// One tick of a server's client: the rocket flies on by itself between
/// the server's updates (a rocket that follows an entity is placed by the
/// caller).
@@ -72,6 +104,19 @@ impl Rocket {
}
self.life += 1;
}
/// The move of a rocket attached to no entity: sped up and rising, or an
/// angled one's own motion.
fn next_motion(&self) -> Vec3 {
if self.angled {
self.velocity
} else {
Vec3::new(
self.velocity.x * 1.15,
self.velocity.y + 0.04,
self.velocity.z * 1.15,
)
}
}
/// `FireworkRocketEntity.tick` for a rocket attached to no entity: speed
/// up (an angled one keeps its motion) and rise, then move. True when a
/// block stopped it.
@@ -85,15 +130,7 @@ impl Rocket {
self.position.z + h,
),
);
let wanted = if self.angled {
self.velocity
} else {
Vec3::new(
self.velocity.x * 1.15,
self.velocity.y + 0.04,
self.velocity.z * 1.15,
)
};
let wanted = self.next_motion();
let Ok(moved) = scene.resolve(bounds, wanted, 0.0, false) else {
return false;
};
@@ -157,6 +194,8 @@ impl Game<'_> {
explosions,
attached,
guest: None,
owner: None,
left_owner: false,
attached_to: None,
angled: false,
item: stack.clone(),
@@ -172,7 +211,9 @@ impl Game<'_> {
hit.point[1] + f64::from(hit.face[1]) * 0.15,
hit.point[2] + f64::from(hit.face[2]) * 0.15,
);
let rocket = self.new_rocket(at, stack, false);
let mut rocket = self.new_rocket(at, stack, false);
rocket.owner = Some(Who::Player);
rocket.guest = self.lan_acting;
self.rockets.push(rocket);
if !self.creative {
self.consume_held();
@@ -182,10 +223,18 @@ impl Game<'_> {
}
/// A rocket shot at an angle (`DATA_SHOT_AT_ANGLE`): out of a dispenser
/// (`FireworkRocketItem.asProjectile`) or a crossbow.
pub(super) fn launch_angled_rocket(&mut self, at: Vec3, velocity: Vec3, stack: &ItemStack) {
pub(super) fn launch_angled_rocket(
&mut self,
at: Vec3,
velocity: Vec3,
stack: &ItemStack,
owner: Option<Who>,
) {
let mut rocket = self.new_rocket(at, stack, false);
rocket.velocity = velocity;
rocket.angled = true;
rocket.owner = owner;
rocket.guest = self.lan_acting.filter(|_| owner == Some(Who::Player));
self.rockets.push(rocket);
}
/// `FireworkRocketItem.use` while gliding: a rocket attached to the player.
@@ -197,6 +246,7 @@ impl Game<'_> {
}
let at = self.player.as_ref().map_or(Vec3::ZERO, |p| p.position);
let mut rocket = self.new_rocket(at, stack, true);
rocket.owner = Some(Who::Player);
rocket.guest = self.lan_acting;
self.rockets.push(rocket);
if !self.creative {
@@ -206,10 +256,33 @@ impl Game<'_> {
true
}
pub(super) fn tick_rockets(&mut self) {
let targets = if self.rockets.is_empty() {
Vec::new()
} else {
self.rocket_targets()
};
let mut i = 0;
while i < self.rockets.len() {
let mut r = self.rockets.remove(i);
r.previous = r.position;
// Projectile.tick: whether it has left its owner, before it moves.
let owner = self.rocket_owner(&r);
if !r.left_owner {
let h = 0.125;
let reach = Aabb::new(
Vec3::new(r.position.x - h, r.position.y, r.position.z - h),
Vec3::new(r.position.x + h, r.position.y + 2.0 * h, r.position.z + h),
)
.expand_towards(r.velocity)
.inflate(Vec3::new(1.0, 1.0, 1.0));
r.left_owner = owner.is_none_or(|o| {
!targets
.iter()
.any(|(who, b)| *who == o && b.intersects(reach))
});
}
let skip = owner.filter(|_| !r.left_owner);
let mut hit = None;
if r.attached
&& let Some(conn) = r.guest
{
@@ -235,7 +308,10 @@ impl Game<'_> {
r.position = p.position;
r.velocity = p.velocity;
}
hit = self.rocket_struck(&targets, &r, r.velocity, skip);
} else {
// getHitResultOnMoveVector before the move.
hit = self.rocket_struck(&targets, &r, r.next_motion(), skip);
let mut scene = SceneCollisions {
world: &self.world,
binding: self.collision,
@@ -246,6 +322,20 @@ impl Game<'_> {
r.life = r.lifetime;
}
}
// hitTargetOrDeflectSelf on an entity: a breeze turns it back
// (ProjectileDeflection.REVERSE), anything else sets it off.
match hit {
Some(Who::Mob(id))
if self
.mobs
.iter()
.any(|m| m.id == id && m.species() == Species::Breeze) =>
{
r.velocity = r.velocity.scale(-0.5);
}
Some(_) => r.life = r.lifetime,
None => {}
}
let at = [r.position.x, r.position.y, r.position.z];
if r.life == 0 {
self.audio.play(
@@ -327,17 +417,28 @@ impl Game<'_> {
})
.collect()
};
for (id, amount) in hits {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
m.damage(
amount,
Damage {
kind: DamageKind::Explosion,
attacker: None,
from: Some(at),
},
);
// damageSources().fireworks(this, getOwner()): a guest's rocket
// hurts as theirs, and the mobs it hit answer to them.
let owner = r.owner;
if let Some(conn) = r.guest.filter(|_| owner.is_some())
&& let Some(mut host) = self.lan.take()
{
let ran = self
.as_guest(&mut host, conn, |g| g.rocket_hurt_mobs(&hits, owner, at))
.is_some();
if ran {
for (id, _) in &hits {
if self.mobs.iter().any(|m| m.id == *id) {
host.mob_players.insert(*id, conn);
}
}
}
self.lan = Some(host);
if !ran {
self.rocket_hurt_mobs(&hits, owner, at);
}
} else {
self.rocket_hurt_mobs(&hits, owner, at);
}
}
let camera = self.eye_position().unwrap_or(at);
@@ -345,6 +446,87 @@ impl Game<'_> {
self.particles
.fireworks(at, v, r.explosions.clone(), camera);
}
fn rocket_hurt_mobs(&mut self, hits: &[(u32, f32)], attacker: Option<Who>, at: [f64; 3]) {
for &(id, amount) in hits {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
m.damage(
amount,
Damage {
kind: DamageKind::Explosion,
attacker,
from: Some(at),
},
);
}
}
}
/// The owner as `rocket_targets` names it: a guest by their entity id.
fn rocket_owner(&self, r: &Rocket) -> Option<Who> {
let owner = r.owner?;
let Some(conn) = r.guest.filter(|_| owner == Who::Player) else {
return Some(owner);
};
let host = self.lan.as_ref()?;
let g = host.hub.guests.iter().find(|g| g.conn == conn)?;
Some(Who::Mob(g.entity_id as u32))
}
/// What a rocket can hit (`Projectile.canHitEntity`): living mobs, the
/// dragon's parts and crystals, the player and the guests.
fn rocket_targets(&self) -> Vec<(Who, Aabb)> {
let mut targets: Vec<(Who, Aabb)> = self
.mobs
.iter()
.filter(|m| m.alive())
.map(|m| (Who::Mob(m.id), m.bounds()))
.collect();
if let Some(p) = self.player_view()
&& p.alive
&& !self.spectator()
{
targets.push((Who::Player, p.bounds));
}
targets.extend(
self.special_targets()
.into_iter()
.map(|(id, b)| (Who::Mob(id), b)),
);
targets.extend(self.lan_projectile_targets());
targets
}
/// `ProjectileUtil.getHitResultOnMoveVector`'s entity half: the nearest
/// target on the move, short of the block it clips, each box grown by
/// `computeMargin` (`tickCount` is the rocket's life plus one).
fn rocket_struck(
&self,
targets: &[(Who, Aabb)],
r: &Rocket,
motion: Vec3,
skip: Option<Who>,
) -> Option<Who> {
let from = r.position;
let mut to = from.add_vector(motion);
let w = self.mob_world_base(&[]);
if let Some((at, ..)) = (super::creatures::ProjWorld {
w: &w,
targets: Vec::new(),
})
.clip(from, to)
{
to = at;
}
let margin = (f64::from(r.life - 1) / 20.0).clamp(0.0, 0.3);
targets
.iter()
.filter(|(who, _)| Some(*who) != skip)
.filter_map(|(who, b)| {
let at = b
.inflate(Vec3::new(margin, margin, margin))
.clip(from, to)?;
Some((*who, from.distance_squared(at)))
})
.min_by(|a, b| a.1.total_cmp(&b.1))
.map(|(who, _)| who)
}
/// FireworkRocketEntity.handleEntityEvent: use the synchronized item data.
pub(super) fn remote_firework_explosion(&mut self, id: i32) {
let camera = self.eye_position();
+1 -1
View File
@@ -164,7 +164,7 @@ impl Game<'_> {
(p.position.x - start[0]) as f32,
(p.position.z - start[2]) as f32,
);
self.avatar.tick();
self.avatar.tick(start);
self.avatar
.update_bob(ground && alive && !swimming, velocity);
if riding {
@@ -572,6 +572,75 @@ impl Game<'_> {
.chain(self.fns.enabled.iter().cloned())
.collect()
}
/// `FileUtil.isPathPartPortable`: no trailing dot and no Windows device
/// name (CON, PRN, AUX, NUL, COM and LPT with a digit from 1 to 9).
fn is_portable_name(name: &str) -> bool {
let stem = name.split('.').next().unwrap_or_default();
let device = match stem.len() {
3 => matches!(stem, "com" | "con" | "prn" | "aux" | "nul"),
4 => matches!(&stem[..3], "com" | "lpt") && matches!(stem.as_bytes()[3], b'1'..=b'9'),
_ => false,
};
!device && !name.ends_with('.')
}
/// `DataPackCommand.createPack`: a pack folder with its `data` folder and a
/// `pack.mcmeta` in the world's datapacks folder.
fn create_pack(&mut self, ctx: &Context) -> R<i32> {
let id = match ctx.get("id") {
Some(Arg::Str(s)) => s.clone(),
_ => String::new(),
};
let description = self.component_arg(ctx, "description")?;
// FileUtil.isValidPathSegment: lowercase letters, digits, '-', '.' and '_'.
let valid = !id.is_empty()
&& id != "."
&& id != ".."
&& id
.chars()
.all(|c| matches!(c, 'a'..='z' | '0'..='9' | '-' | '.' | '_'));
if !valid {
return Err(err("commands.datapack.create.invalid_name", &[&id]));
}
if !Self::is_portable_name(&id) {
return Err(err("commands.datapack.create.invalid_full_name", &[&id]));
}
// Without a world there is no datapacks folder to write to.
if self.world_root.as_os_str().is_empty() {
return Err(err("commands.datapack.create.io_failure", &[&id]));
}
let datapacks = self.world_root.join("datapacks");
let pack = datapacks.join(&id);
if pack.exists() {
return Err(err("commands.datapack.create.already_exists", &[&id]));
}
// The data pack version (SharedConstants), written as a bare major
// number for both bounds, as vanilla does.
let pack_format = horizon_core::version::DATA_PACK_FORMAT.0;
let mcmeta = serde_json::json!({
"pack": {
"description": description.component(),
"min_format": pack_format,
"max_format": pack_format,
}
});
let written = std::fs::create_dir_all(&datapacks)
.and_then(|()| std::fs::create_dir(&pack))
.and_then(|()| std::fs::create_dir(pack.join("data")))
.and_then(|()| {
std::fs::write(
pack.join("pack.mcmeta"),
serde_json::to_string_pretty(&mcmeta).unwrap_or_default(),
)
});
if written.is_err() {
return Err(err("commands.datapack.create.io_failure", &[&id]));
}
self.feedback(Text::translate(
"commands.datapack.create.success",
&[Text::literal(id)],
));
Ok(1)
}
pub(super) fn cmd_datapack(&mut self, id: &str, ctx: &Context) -> R<i32> {
self.ensure_datapacks();
let name = match ctx.get("name") {
@@ -590,6 +659,7 @@ impl Game<'_> {
|| g.fns.packs.iter().any(|p| p.id == n)
};
match id {
"datapack.create" => self.create_pack(ctx),
"datapack.list" => {
let want_enabled = ctx.has("enabled") || (!ctx.has("available"));
let want_available = ctx.has("available") || (!ctx.has("enabled"));
+29
View File
@@ -85,8 +85,36 @@ impl Game<'_> {
{
m.extra.player_created = true;
}
self.trigger_golem_summoned(id);
self.update_pattern_blocks(&blocks)
}
/// `CarvedPumpkinBlock.spawnGolemInWorld`: SUMMONED_ENTITY for each player
/// whose box is within 5 blocks of the golem's box (the local player here).
fn trigger_golem_summoned(&mut self, id: Option<u32>) {
let Some(golem) = id
.and_then(|id| self.mobs.iter().find(|m| m.id == id))
.cloned()
else {
return;
};
let me = self.player_entity();
let half = f64::from(golem.width()) / 2.0;
let height = f64::from(golem.height());
let reach = 5.0;
let [x, y, z] = me.position;
let p = golem.position;
// The player's box is 0.6 wide and 1.8 tall.
let near = x + 0.3 >= p.x - half - reach
&& x - 0.3 <= p.x + half + reach
&& y + 1.8 >= p.y - reach
&& y <= p.y + height + reach
&& z + 0.3 >= p.z - half - reach
&& z - 0.3 <= p.z + half + reach;
if near {
let view = self.mob_entity(&golem);
self.trigger_entity("minecraft:summoned_entity", "entity", &view);
}
}
/// Whether placing a golem head at `head` completes a golem.
pub(super) fn completes_golem(&self, head: [i32; 3]) -> bool {
self.find_golem(head).is_some() || self.copper_golem_base(head)
@@ -192,6 +220,7 @@ impl Game<'_> {
f64::from(head[2]) + 0.5,
];
let id = self.create_triggered("minecraft:copper_golem", at, 0.0);
self.trigger_golem_summoned(id);
self.update_pattern_blocks(&cells)?;
let direction = match facing.as_str() {
"south" => horizon_core::geometry::Direction::South,
+108 -23
View File
@@ -10,12 +10,14 @@ use horizon_client::{
first_person::{Hand, HandWorld, Hit, SwingAnimation, UseResult, swing_duration},
items::ItemStack,
};
use horizon_core::geometry::{Aabb, Vec3};
/// The off hand's place in `visible_hands` (the main hand uses its hotbar slot).
const OFFHAND: usize = usize::MAX;
/// `AttackRange.isInRange`: float bounds, then inclusive double comparison.
fn attack_range_contains(range: &serde_json::Value, distance: f64, creative: bool) -> bool {
/// `AttackRange.effectiveMinRange`, `effectiveMaxRange` (for a player) and
/// `hitboxMargin` of an `attack_range` component.
fn attack_reach(range: &serde_json::Value, creative: bool) -> (f32, f32, f32) {
let value = |key: &str, default: f32| range[key].as_f64().map_or(default, |v| v as f32);
let (min, max) = if creative {
(
@@ -25,7 +27,34 @@ fn attack_range_contains(range: &serde_json::Value, distance: f64, creative: boo
} else {
(value("min_reach", 0.0), value("max_reach", 3.0))
};
let margin = value("hitbox_margin", 0.3);
(min, max, value("hitbox_margin", 0.3))
}
fn v3(v: [f64; 3]) -> Vec3 {
Vec3::new(v[0], v[1], v[2])
}
/// `LocalPlayer.raycastHitResult` with an item that has an attack range
/// (`AttackRange.getClosesetHit`): the distance from the eye to the nearest
/// box hit along the item's own `reach`, which goes past the plain pick's
/// 3 blocks (a spear's 4.5). A miss falls back to the plain pick (`None`).
fn reach_pick(
boxes: &[(usize, Aabb)],
eye: Vec3,
look: Vec3,
movement: Vec3,
reach: (f32, f32, f32),
clip: impl Fn(Vec3, Vec3) -> Option<Vec3>,
) -> Option<f64> {
super::spear::hits_in_reach(boxes, eye, look, movement, reach, clip)
.into_iter()
.map(|(_, at)| eye.distance_squared(at).sqrt())
.reduce(f64::min)
}
/// `AttackRange.isInRange`: float bounds, then inclusive double comparison.
fn attack_range_contains(range: &serde_json::Value, distance: f64, creative: bool) -> bool {
let (min, max, margin) = attack_reach(range, creative);
distance >= f64::from(min - margin) && distance <= f64::from(max + margin)
}
@@ -261,10 +290,6 @@ impl Game<'_> {
/// it is alive (the nearest entity under the crosshair, before any block).
pub(super) fn crosshair_living(&self) -> Option<bool> {
let nearest = |hits: &[Option<f64>]| hits.iter().flatten().copied().reduce(f64::min);
let living = nearest(&[
self.mob_hit().map(|h| h.1),
self.armor_stand_hit().map(|h| h.1),
])?;
let other = nearest(&[
self.painting_hit().map(|h| h.1),
self.frame_hit().map(|h| h.1),
@@ -272,24 +297,49 @@ impl Game<'_> {
self.cart_hit().map(|h| h.1),
self.dragon_hit().map(|h| h.1),
]);
if !self.before_block(living) || other.is_some_and(|o| o <= living) {
return None;
}
let active = self
.using
.and_then(|(slot, _)| self.inventory.get(slot).and_then(Option::as_ref))
.or_else(|| self.held());
if let Some(stack) = active {
let components = self
.pack
let range = active.and_then(|stack| {
self.pack
.item_renderer
.models
.components(&horizon_client::item_render::stack_view(stack));
if let Some(range) = components.get("minecraft:attack_range")
&& !attack_range_contains(range, living, self.creative)
{
return None;
}
.components(&horizon_client::item_render::stack_view(stack))
.get("minecraft:attack_range")
.cloned()
});
let spear = range.as_ref().and_then(|r| {
let eye = self.eye_position()?;
let look = super::view_vector(self.yaw, self.pitch);
let reach = attack_reach(r, self.creative);
let boxes = self.hittable_mob_boxes();
let clip = |from: Vec3, to: Vec3| {
let length = from.distance_squared(to).sqrt();
let d = to.subtract(from).scale(1.0 / length);
(length > 0.0)
.then(|| self.clip_ray([from.x, from.y, from.z], [d.x, d.y, d.z], length))
.flatten()
.map(|hit| Vec3::new(hit.point[0], hit.point[1], hit.point[2]))
};
let movement = self.known_player_movement();
reach_pick(&boxes, v3(eye), v3(look), movement, reach, clip)
});
let living = match spear {
Some(distance) => distance,
None => nearest(&[
self.mob_hit().map(|h| h.1),
self.armor_stand_hit().map(|h| h.1),
])?,
};
if (spear.is_none() && !self.before_block(living)) || other.is_some_and(|o| o <= living) {
return None;
}
// Hud.extractCrosshair: with an attack range, the hit must be in it.
if let Some(range) = &range
&& !attack_range_contains(range, living, self.creative)
{
return None;
}
// Only alive mobs are hit, so a picked living entity is alive.
Some(true)
@@ -324,12 +374,12 @@ impl Game<'_> {
let Some(hit) = self.pick() else {
return false;
};
let name = &*self
let id = &*self
.res
.facts()
.get(self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]))
.name;
let name = name.trim_start_matches("minecraft:");
let name = id.trim_start_matches("minecraft:");
match name {
// BlockBehaviour.getMenuProvider and `instanceof Portal`.
"crafting_table" | "enchanting_table" | "anvil" | "chipped_anvil" | "damaged_anvil"
@@ -338,8 +388,9 @@ impl Game<'_> {
| "dispenser" | "dropper" | "crafter" | "lectern" | "beacon" | "nether_portal"
| "end_portal" | "end_gateway" => true,
// ChestBlock.getMenuProvider: none while a redstone conductor
// sits on top of either half (a sitting cat aside).
"chest" | "trapped_chest" => {
// sits on top of either half (a sitting cat aside). Copper
// chests are `ChestBlock`s too.
_ if horizon_client::containers::is_chest_block(id) => {
let key = (hit.pos[0], hit.pos[1], hit.pos[2]);
let blocked = |p: (i32, i32, i32)| {
let above = self.world.block(p.0, p.1 + 1, p.2);
@@ -563,6 +614,40 @@ impl HandWorld for GameHands<'_, '_> {
mod tests {
use super::*;
/// A zombie-sized box whose near face is `distance` blocks ahead of an
/// eye at the origin looking along +z.
fn mob_ahead(distance: f64) -> Vec<(usize, Aabb)> {
let min = Vec3::new(-0.3, -1.0, distance);
vec![(0, Aabb::new(min, min.add_vector(Vec3::new(0.6, 1.95, 0.6))))]
}
#[test]
fn a_spear_picks_a_mob_past_the_plain_reach() {
// #3209: a mob 4 blocks ahead is out of the plain pick's 3 blocks but
// within a spear's attack range (min 2, max 4.5, margin 0.125), so the
// crosshair picks it and the attack indicator shows.
let spear = serde_json::json!({
"min_reach": 2.0, "max_reach": 4.5,
"min_creative_reach": 2.0, "max_creative_reach": 6.5,
"hitbox_margin": 0.125,
});
let (eye, look) = (Vec3::ZERO, Vec3::new(0.0, 0.0, 1.0));
let boxes = mob_ahead(4.0);
let reach = attack_reach(&spear, false);
let pick = reach_pick(&boxes, eye, look, Vec3::ZERO, reach, |_, _| None);
assert_eq!(pick, Some(4.0));
assert!(attack_range_contains(&spear, 4.0, false));
// Past the maximum plus the margin: no pick.
let far = mob_ahead(4.7);
assert_eq!(
reach_pick(&far, eye, look, Vec3::ZERO, reach, |_, _| None),
None
);
// A block nearer than the minimum reach takes the pick.
let wall = |from: Vec3, _: Vec3| Some(from.add_vector(Vec3::new(0.0, 0.0, 1.0)));
assert_eq!(reach_pick(&boxes, eye, look, Vec3::ZERO, reach, wall), None);
}
#[test]
fn attack_range_uses_inclusive_float_bounds() {
let range = serde_json::json!({
@@ -425,6 +425,7 @@ impl Game<'_> {
return result.and(stopped);
}
let mut next = Instant::now();
let mut was_paused = false;
while !flags.stop.load(Ordering::Relaxed) {
self.run_tasks();
if self.agent.hold_server {
@@ -434,6 +435,14 @@ impl Game<'_> {
continue;
}
let paused = flags.paused.load(Ordering::Relaxed);
if paused && !was_paused {
// IntegratedServer.tickServer: the tick the game pauses saves
// everything (saveEverything, without flushing), as an
// autosave does.
log::info!("Saving and pausing game...");
self.save_game_with(false)?;
}
was_paused = paused;
if paused {
// Only the connections run.
self.tick_server_timed(true)?;
@@ -458,6 +458,7 @@ impl Game<'_> {
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn civil_dates_follow_the_gregorian_calendar() {
@@ -465,4 +466,27 @@ mod tests {
assert_eq!(civil(19_716), (2023, 12, 25));
assert_eq!(civil(11_016), (2000, 2, 29));
}
#[test]
fn a_shield_is_used_for_the_vanilla_s_time() {
// Item.getUseDuration: an item with `blocks_attacks` (a shield) is
// used for 72000 ticks, so its use animation (the arm held up)
// runs instead of ending at once (#2289).
let mut shield = Components::new();
shield.insert("minecraft:blocks_attacks".into(), json!({}));
assert_eq!(use_duration(&shield), 72_000);
// Food is used for as long as it takes to eat it.
let mut food = Components::new();
food.insert(
"minecraft:consumable".into(),
json!({"consume_seconds": 1.6}),
);
assert_eq!(use_duration(&food), 32);
// The bow keeps its own use time.
let mut bow = Components::new();
bow.insert("minecraft:item_model".into(), json!("minecraft:bow"));
assert_eq!(use_duration(&bow), 72_000);
// Anything else is not used at all.
assert_eq!(use_duration(&Components::new()), 0);
}
}
+9 -1
View File
@@ -328,7 +328,7 @@ fn mob_meta(
// Entity.DATA_SHARED_FLAGS_ID: on fire 0x01, swimming 0x10 (a drowned
// under water, `Drowned.setSwimming`), invisible 0x20 (the effect's flag)
// and glowing 0x40 (LivingEntity.updateGlowingStatus).
let shared = i8::from(m.fire_ticks > 0)
let shared = i8::from(m.is_on_fire())
| i8::from(m.species() == Species::Drowned && m.extra.flag) << 4
| i8::from(m.invisible || horizon_client::mobs::trader::is_invisible(m)) << 5
| i8::from(m.is_currently_glowing()) << 6;
@@ -801,6 +801,14 @@ fn mob_meta(
}
defaults.push((18, serializer::BOOLEAN, Value::Bool(false)));
}
// Skeleton.DATA_STRAY_CONVERSION_ID (the first entry after Mob's):
// the shaking of the freezing conversion.
"minecraft:skeleton" => {
if e.counter2 > 0 {
meta.push((16, serializer::BOOLEAN, Value::Bool(true)));
}
defaults.push((16, serializer::BOOLEAN, Value::Bool(false)));
}
// AbstractVillager.DATA_UNHAPPY_COUNTER (after AgeableMob's two).
"minecraft:wandering_trader" => unhappy_counter(m, meta, defaults),
// Hoglin.DATA_IMMUNE_TO_ZOMBIFICATION (after AgeableMob's two) and
+59 -58
View File
@@ -675,9 +675,7 @@ impl Game<'_> {
let new_mode = self.mode_id();
self.mode = mode;
self.creative = creative;
self.selected = std::mem::replace(&mut g.selected, selected);
// `g.selected` now holds the host's value: swap the inventories back.
std::mem::swap(&mut self.selected, &mut g.selected);
g.selected = std::mem::replace(&mut self.selected, selected);
std::mem::swap(&mut self.inventory, &mut g.inventory);
std::mem::swap(&mut self.armor, &mut g.armor);
std::mem::swap(&mut self.offhand, &mut g.offhand);
@@ -833,35 +831,23 @@ impl Game<'_> {
.facts()
.get(self.world.block(pos.0, pos.1, pos.2))
.name;
let (menu, title, slots) = if &**name == "minecraft:ender_chest" {
let (menu, slots) = if &**name == "minecraft:ender_chest" {
// EnderChestBlock.useWithoutItem: the player's own inventory.
(
"minecraft:generic_9x3",
"container.enderchest",
Slots::Ender,
)
("minecraft:generic_9x3", Slots::Ender)
} else if let Some(BlockEntity::Furnace(f)) = self.world.block_entities.get(&pos) {
// AbstractFurnaceBlock.openContainer: FurnaceMenu, BlastFurnaceMenu, SmokerMenu.
let (menu, title) = match f.kind.recipe_type() {
"minecraft:blasting" => ("minecraft:blast_furnace", "container.blast_furnace"),
"minecraft:smoking" => ("minecraft:smoker", "container.smoker"),
_ => ("minecraft:furnace", "container.furnace"),
let menu = match f.kind.recipe_type() {
"minecraft:blasting" => "minecraft:blast_furnace",
"minecraft:smoking" => "minecraft:smoker",
_ => "minecraft:furnace",
};
(menu, title, Slots::Block(pos))
(menu, Slots::Block(pos))
} else if let Some(BlockEntity::Crafter(_)) = self.world.block_entities.get(&pos) {
// CrafterBlock.useWithoutItem: CrafterMenu over the block entity.
(
"minecraft:crafter_3x3",
"container.crafter",
Slots::Block(pos),
)
("minecraft:crafter_3x3", Slots::Block(pos))
} else if let Some(BlockEntity::Brewing(_)) = self.world.block_entities.get(&pos) {
// BrewingStandBlock.useWithoutItem: BrewingStandMenu.
(
"minecraft:brewing_stand",
"container.brewing",
Slots::Block(pos),
)
("minecraft:brewing_stand", Slots::Block(pos))
} else if let Some((other, right)) = self.chest_partner(pos) {
// ChestBlock.combine: the right half's slots come first.
let halves = if right {
@@ -869,24 +855,33 @@ impl Game<'_> {
} else {
Slots::Double(other, pos)
};
("minecraft:generic_9x6", "container.chestDouble", halves)
("minecraft:generic_9x6", halves)
} else {
let Some(BlockEntity::Chest(items)) = self.world.block_entities.get(&pos) else {
return false;
};
let (menu, title) = match items.len() {
27 => ("minecraft:generic_9x3", "container.chest"),
9 => ("minecraft:generic_3x3", "container.dispenser"),
5 => ("minecraft:hopper", "container.hopper"),
let menu = match items.len() {
27 => "minecraft:generic_9x3",
9 => "minecraft:generic_3x3",
5 => "minecraft:hopper",
_ => return false,
};
let title = match &**name {
"minecraft:barrel" => "container.barrel",
"minecraft:dropper" => "container.dropper",
n if n.ends_with("shulker_box") => "container.shulkerBox",
_ => title,
};
(menu, title, Slots::Block(pos))
(menu, Slots::Block(pos))
};
// The menu provider's getDisplayName: a container's custom name
// (BaseContainerBlockEntity.getName) or its default name.
let custom = |k: (i32, i32, i32)| self.world.block_names.get(&k).map(String::as_str);
let title = match slots {
Slots::Ender => horizon_protocol::text::translatable("container.enderchest", &[]),
Slots::Double(first, second) => {
horizon_client::containers::double_chest_name(custom(first), custom(second))
}
Slots::Block(k) => {
let Some(key) = horizon_client::containers::default_name_key(name) else {
return false;
};
horizon_client::containers::display_name(custom(k), key)
}
};
let Some(items) = host
.hub
@@ -898,14 +893,8 @@ impl Game<'_> {
// ServerPlayer.openMenu closes the menu that was open.
self.guest_container_closed(host, conn);
self.lan_station_closed(host, conn, None);
host.hub.open_container(
conn,
menu,
horizon_protocol::text::translatable(title, &[]),
pos,
slots,
items,
);
host.hub
.open_container(conn, menu, title, pos, slots, items);
true
}
@@ -1186,9 +1175,6 @@ impl Game<'_> {
}
S::UseItemOn(p) => {
host.hub.ack(conn, p.sequence);
if spectator {
return;
}
// handleUseItemOn: within reach, and the hit point within the block.
let inside = p
.cursor
@@ -1331,6 +1317,9 @@ impl Game<'_> {
let g = &mut host.hub.guests[gi];
let old = g.menu_slot(slot);
g.set_menu_slot(slot, item.clone());
// InventoryMenu.setRemoteSlot: the client already has it, so broadcastChanges
// sends nothing back that could undo a click made since.
g.menu_sent[slot] = item.clone();
// InventoryMenu's ArmorSlot and off-hand slot: setByPlayer.
let equipment = match slot {
5..=8 => Some(["head", "chest", "legs", "feet"][slot - 5]),
@@ -1667,9 +1656,7 @@ impl Game<'_> {
host.hub.teleport(gi, Some(at));
}
fn guest_tick_death(&mut self, host: &mut LanHost, gi: usize) {
let g = &mut host.hub.guests[gi];
g.death_time += 1;
if g.death_time >= 20 {
if host.hub.guests[gi].tick_death() {
self.guest_drop_carried(host, gi);
}
}
@@ -1888,8 +1875,11 @@ impl Game<'_> {
// ListPlayersCommand needs no permission and lists every player.
let words: Vec<&str> = command.trim_start_matches('/').split(' ').collect();
if words[0] == "list" && (words.len() == 1 || words[1..] == ["uuids"]) {
let me = (self.options.dedicated.is_none() && self.player.is_some())
.then_some((host.host_uuid, host.host_name.as_str()));
// A single-player owner is a placed guest already: no host entry.
let me = (self.options.dedicated.is_none()
&& self.player.is_some()
&& host.hub.singleplayer_owner.is_none())
.then_some((host.host_uuid, host.host_name.as_str()));
let content = host.hub.list_reply(me, words.len() == 2);
host.hub.send_to(
conn,
@@ -2372,14 +2362,25 @@ impl Game<'_> {
None => self.guest_death_message(conn, me),
};
let message = horizon_client::lan::data::component_nbt(&text.component());
let team = self.score.board.players_team(&name);
if horizon_client::scoreboard::own_death_message_shown(team) {
host.hub.system_message(message.clone());
if self.options.dedicated.is_some() {
let board = &self.score.board;
host.hub.system_message_to(message.clone(), |other| {
horizon_client::scoreboard::death_message_reaches(board, &name, other)
});
if self.options.dedicated.is_some() {
// Only a message for everyone is logged (`broadcastSystemMessage`).
if board.players_team(&name).is_none_or(|t| {
t.death_message_visibility == horizon_client::scoreboard::Visibility::Always
}) {
super::dedicated::log("INFO", &format!("System chat: {}", text.plain()));
} else if self.player.is_some() {
self.system_message(text);
}
} else if self.player.is_some()
&& horizon_client::scoreboard::death_message_reaches(
board,
&name,
super::chat::PLAYER_NAME,
)
{
self.system_message(text);
}
message
} else {
+12 -1
View File
@@ -129,8 +129,8 @@ impl Game<'_> {
let dropped = std::mem::take(&mut menu.dropped);
let equipped = std::mem::take(&mut menu.equipped);
let brewed = std::mem::take(&mut menu.brewed);
let crafted = std::mem::take(&mut menu.crafted);
menu.drops.clear();
menu.crafted.clear();
store_player(menu, &mut host.hub.guests[gi]);
if !brewed.is_empty() {
let conn = host.hub.guests[gi].conn;
@@ -140,6 +140,14 @@ impl Game<'_> {
}
});
}
if !crafted.is_empty() {
let conn = host.hub.guests[gi].conn;
self.as_guest(host, conn, |g| {
for (recipe, ingredients) in &crafted {
g.trigger_recipe_crafted(recipe, ingredients);
}
});
}
if !equipped.is_empty() {
let conn = host.hub.guests[gi].conn;
self.as_guest(host, conn, |g| {
@@ -242,6 +250,9 @@ impl Game<'_> {
_ => None,
};
if host.hub.guests[gi].spectator() {
// handleContainerClick: nothing changes, and sendAllDataToRemote
// takes back what the client predicted.
host.hub.resend_menu(conn, p.container, &self.world);
return true;
}
let mut menu = host
@@ -167,6 +167,24 @@ impl Game<'_> {
e.meta.push((15, serializer::BYTE, Value::Byte(flags)));
}
e.meta.extend(stand_poses(a));
// Entity.DATA_CUSTOM_NAME and DATA_CUSTOM_NAME_VISIBLE: a hologram's text.
if let Some(name) = &a.custom_name {
e.meta.push((
2,
serializer::OPTIONAL_COMPONENT,
Value::OptText(Some(horizon_client::scoreboard::json_to_tag(
&name.component(),
))),
));
if a.name_visible {
e.meta.push((3, serializer::BOOLEAN, Value::Bool(true)));
}
}
// A name taken away or hidden again (`/data`) is sent as none / false.
e.defaults
.push((2, serializer::OPTIONAL_COMPONENT, Value::OptText(None)));
e.defaults
.push((3, serializer::BOOLEAN, Value::Bool(false)));
// A pose put back to its default (`/data`) is sent as that default.
e.defaults.push((15, serializer::BYTE, Value::Byte(0)));
for (i, default) in horizon_client::entities::ArmorStand::DEFAULT_POSE
+13 -26
View File
@@ -109,16 +109,6 @@ fn offset(p: Pos, d: Direction) -> Pos {
let s = step(d);
(p.0 + s[0], p.1 + s[1], p.2 + s[2])
}
fn clockwise(d: Direction) -> Direction {
match d {
Direction::North => Direction::East,
Direction::East => Direction::South,
Direction::South => Direction::West,
Direction::West => Direction::North,
other => other,
}
}
/// State-level container queries shared by the game and comparator signals.
struct Containers<'a> {
world: &'a World,
@@ -176,7 +166,7 @@ impl Containers<'_> {
_ => Kind::Generic,
};
let size = entity.slots().len();
if matches!(name, "minecraft:chest" | "minecraft:trapped_chest") {
if horizon_client::containers::is_chest_block(name) {
let is_blocked = |p: Pos| {
let above = self.world.block(p.0, p.1 + 1, p.2);
self.behaviours.facts(above).flags
@@ -186,22 +176,19 @@ impl Containers<'_> {
if blocked_chests && is_blocked(pos) {
return None;
}
let kind_prop = self.prop(state, "type").unwrap_or("single");
if kind_prop != "single"
&& let Some(facing) = self.prop(state, "facing").and_then(dir_from_name)
let half = |s: BlockStateId| {
Some(horizon_client::containers::ChestHalf {
block: self.name(s),
kind: self.prop(s, "type")?,
facing: self.prop(s, "facing")?,
})
};
if let Some(own) = half(state)
&& let Some((dx, dz)) = own.connected_step()
{
// ChestBlock.getConnectedDirection
let connected = if kind_prop == "left" {
clockwise(facing)
} else {
clockwise(clockwise(clockwise(facing)))
};
let other = offset(pos, connected);
let other = (pos.0 + dx, pos.1, pos.2 + dz);
let other_state = self.world.block(other.0, other.1, other.2);
let paired = self.name(other_state) == name
&& self
.prop(other_state, "type")
.is_some_and(|t| t != "single" && t != kind_prop)
let paired = half(other_state).is_some_and(|o| own.pairs_with(&o))
&& self
.world
.block_entities
@@ -211,7 +198,7 @@ impl Containers<'_> {
if blocked_chests && is_blocked(other) {
return None;
}
let parts = if kind_prop == "right" {
let parts = if own.kind == "right" {
vec![(pos, size), (other, 27)]
} else {
vec![(other, 27), (pos, size)]
@@ -246,9 +246,15 @@ impl Game<'_> {
self.paint_screen(batch, ox, oy, background, true);
let atlas = &self.gui.atlas;
let grey = rgba(64, 64, 64, 255);
let title = self.container_title(self.translate("container.crafter", "Crafter"));
let tw = HudBatch::text_width(atlas, &title);
batch.text(
// A server's crafter menu has the title it was given.
let title = self.remote_menu_title().unwrap_or_else(|| {
horizon_client::text::Text::literal(
self.container_title(self.translate("container.crafter", "Crafter")),
)
.resolve_legacy()
});
let tw = atlas.font.width_text(&title);
batch.text_styled(
atlas,
&title,
ox + ((176.0 - tw) / 2.0).trunc(),
+4 -15
View File
@@ -1308,7 +1308,7 @@ impl Game<'_> {
}
}
/// `renderTwoHandedMap`: map `id` held in both hands, in view space (the
/// arms under it are not drawn).
/// arms under it: `first_person::map_arm_poses`).
pub(super) fn two_handed_map_geometry(
&mut self,
to_world: glam::Mat4,
@@ -1318,22 +1318,11 @@ impl Game<'_> {
light: u32,
out: &mut Vec<Vertex>,
) {
use horizon_core::geometry::trig::Mth;
let deg = |d: f32| d.to_radians();
let sqrt_attack = attack.sqrt();
let y_swing = -0.2 * (attack * std::f32::consts::PI).sin();
let z_swing = -0.4 * (sqrt_attack * std::f32::consts::PI).sin();
// calculateMapTilt
let tilt = (1.0 - self.pitch / 45.0 + 0.1).clamp(0.0, 1.0);
let tilt = -(tilt * std::f32::consts::PI).cos() * 0.5 + 0.5;
let base = to_world
* glam::Mat4::from_translation(glam::Vec3::new(0.0, -y_swing / 2.0, z_swing))
* glam::Mat4::from_translation(glam::Vec3::new(
0.0,
0.04 + inverse_height * -1.2 + tilt * -0.5,
-0.72,
))
* glam::Mat4::from_rotation_x(deg(tilt * -85.0));
let swing = (sqrt_attack * std::f32::consts::PI).sin();
* horizon_client::first_person::two_handed_map_base(self.pitch, inverse_height, attack);
let swing = (attack.sqrt() * std::f32::consts::PI).msin();
let m = base
* glam::Mat4::from_rotation_x(deg(swing * 20.0))
* glam::Mat4::from_scale(glam::Vec3::splat(2.0));
@@ -1028,6 +1028,17 @@ impl Game<'_> {
}
best
}
/// `ContainerEntity.unpackChestVehicleLootTable` for a player who opens a
/// chest minecart: a roll it still holds fires `player_generates_container_loot`
/// for its table, and the cart keeps its items from then on.
fn unpack_cart_loot(&mut self, i: usize) {
let Some(loot) = self.carts[i].loot.take() else {
return;
};
if loot.items == horizon_client::save::encode_items(&self.carts[i].items) {
self.trigger_loot_table(&loot.table);
}
}
/// `interact`: ride, open, fuel or ignite the cart under the crosshair.
pub(super) fn interact_cart(&mut self) -> bool {
let Some((i, d)) = self.cart_hit() else {
@@ -1047,6 +1058,7 @@ impl Game<'_> {
}
// AbstractMinecartContainer.interact: no sneak check.
CartKind::Chest | CartKind::Hopper => {
self.unpack_cart_loot(i);
let c = self.carts[i].clone();
let key = (i32::MAX - 15, CART_KEY_Y, c.id as i32);
self.world
@@ -1227,6 +1239,20 @@ impl Game<'_> {
light: u32,
diffuse: bool,
out: &mut Vec<Vertex>,
) {
self.block_model_vertices_tinted(state, seed, m, light, diffuse, 0xFF_FFFF, out);
}
/// `block_model_vertices` with `tint` (RGB) on the faces that have a tint index.
#[allow(clippy::too_many_arguments)]
pub(super) fn block_model_vertices_tinted(
&self,
state: BlockStateId,
seed: i64,
m: glam::Mat4,
light: u32,
diffuse: bool,
tint: u32,
out: &mut Vec<Vertex>,
) {
let Ok(models) = self.pack.render.model_catalog.collect_seeded(state, seed) else {
return;
@@ -1253,6 +1279,12 @@ impl Game<'_> {
} else {
0xFF00_0000 | shade << 16 | shade << 8 | shade
};
let color = if face.quad.tint_index >= 0 {
let tint = horizon_client::mobs::render::abgr(tint);
horizon_client::hud_layout::multiply(color, tint)
} else {
color
};
let normal = if diffuse {
use horizon_client::item_render::{direction_vector, transform_normal};
let n = transform_normal(m, direction_vector(face.quad.facing));
+44 -90
View File
@@ -1,6 +1,6 @@
//! Blocks mobs carry: block items in a hand (`ItemInHandLayer`) and the
//! block an enderman holds (`CarriedBlockLayer`), drawn as shaded cubes like
//! dropped block items. Also the block models mobs wear (`SnowGolemHeadLayer`,
//! block an enderman holds (`CarriedBlockLayer`), drawn as baked block models.
//! Also the block models mobs wear (`SnowGolemHeadLayer`,
//! `MushroomCowMushroomLayer`).
use super::Game;
use horizon_client::{
@@ -9,46 +9,6 @@ use horizon_client::{
mobs::{Mob, Species, render},
};
/// The six faces of the unit cube with their shade: top, bottom, the two
/// z faces, the two x faces.
const FACES: [([[f32; 3]; 4], f32, u8); 6] = [
(
[[0., 1., 1.], [1., 1., 1.], [1., 1., 0.], [0., 1., 0.]],
1.0,
0,
),
(
[[0., 0., 0.], [1., 0., 0.], [1., 0., 1.], [0., 0., 1.]],
0.5,
0,
),
(
[[1., 0., 0.], [0., 0., 0.], [0., 1., 0.], [1., 1., 0.]],
0.8,
1,
),
(
[[0., 0., 1.], [1., 0., 1.], [1., 1., 1.], [0., 1., 1.]],
0.8,
1,
),
(
[[0., 0., 0.], [0., 0., 1.], [0., 1., 1.], [0., 1., 0.]],
0.6,
2,
),
(
[[1., 0., 1.], [1., 0., 0.], [1., 1., 0.], [1., 1., 1.]],
0.6,
2,
),
];
/// The sprites of a cube's top, z sides and x sides, and the top's tint.
struct Cube<'a> {
sprites: [&'a str; 3],
tint: u32,
}
/// `CustomHeadLayer.translateToHead` and the banner's `head` display.
fn banner_matrix(head: glam::Mat4) -> glam::Mat4 {
head * glam::Mat4::from_translation(glam::Vec3::new(0.0, -0.25, 0.0))
@@ -178,38 +138,6 @@ impl Game<'_> {
render::draw(flag, m, &flag_poses, r, color, light, 0, out);
}
}
fn push_cube(
&self,
t: glam::Mat4,
cube: &Cube,
light: u32,
overlay: u32,
out: &mut Vec<Vertex>,
) {
for (corners, shade, side) in FACES {
let name = cube.sprites[usize::from(side)];
let Some(r) = self.gui.atlas.sprites.get(name).copied() else {
continue;
};
let tint = if side == 0 { cube.tint } else { 0xFF_FFFF };
let c = |sft: u32| (((tint >> sft) & 255) as f32 * shade) as u32;
let color = 0xFF00_0000 | c(0) << 16 | c(8) << 8 | c(16);
let uvs = [[r.u0, r.v1], [r.u1, r.v1], [r.u1, r.v0], [r.u0, r.v0]];
for (corner, uv) in corners.iter().zip(uvs) {
let p = t.transform_point3(glam::Vec3::from(*corner));
out.push(Vertex {
normal: [0; 4],
pos: p.to_array(),
uv,
layer: overlay,
color,
light,
glint_uv: [0.0, 0.0],
});
}
}
}
/// The blocks one mob holds or carries.
pub(super) fn mob_block_geometry(
&self,
@@ -222,11 +150,13 @@ impl Game<'_> {
self.captain_banner_geometry(mob, camera, alpha, light, out);
self.held_item_geometry(mob, camera, alpha, light, true, out);
self.worn_block_geometry(mob, camera, alpha, light, out);
self.golem_flower_geometry(mob, camera, alpha, light, out);
if mob.species() == Species::Enderman
&& let Some(state) = mob.extra.carried
&& let Some((sprite, tint)) = self.block_face_sprite(state)
&& let Some((_, tint)) = self.block_face_sprite(state)
{
// CarriedBlockLayer: OverlayTexture.NO_OVERLAY, never the hurt tint.
// CarriedBlockLayer: OverlayTexture.NO_OVERLAY, never the hurt tint;
// the block's baked model (seed 42) on the unit cube, not centred.
let base = render::base_transform(mob, alpha as f32, camera);
let t = base
* glam::Mat4::from_translation(glam::Vec3::new(0.0, 0.6875, -0.75))
@@ -234,20 +164,43 @@ impl Game<'_> {
* glam::Mat4::from_rotation_y(45.0f32.to_radians())
* glam::Mat4::from_translation(glam::Vec3::new(0.25, 0.1875, 0.25))
* glam::Mat4::from_scale(glam::Vec3::new(-0.5, -0.5, 0.5))
* glam::Mat4::from_rotation_y(90.0f32.to_radians())
* glam::Mat4::from_translation(glam::Vec3::splat(-0.5));
self.push_cube(
t,
&Cube {
sprites: [&sprite, &sprite, &sprite],
tint,
},
light,
0,
out,
);
* glam::Mat4::from_rotation_y(90.0f32.to_radians());
self.block_model_vertices_tinted(state, 42, t, light, true, tint, out);
}
}
/// `IronGolemFlowerLayer`: the poppy in the right arm while the golem
/// offers it (`getOfferFlowerTick() > 0`), drawn with `NO_OVERLAY`.
fn golem_flower_geometry(
&self,
mob: &Mob,
camera: glam::DVec3,
alpha: f64,
light: u32,
out: &mut Vec<Vertex>,
) {
use glam::{Mat4, Vec3};
if mob.species() != Species::IronGolem
|| mob.extra.counter <= 0
|| horizon_client::mobs::trader::is_invisible(mob)
{
return;
}
let Some(state) = self.pack.assets.registry.default_state("minecraft:poppy") else {
return;
};
let Some(arm) =
render::part_transform(mob, alpha as f32, camera, self.res.mob_data(), "right_arm")
else {
return;
};
let t = arm
* Mat4::from_translation(Vec3::new(-1.1875, 1.0625, -0.9375))
* Mat4::from_translation(Vec3::splat(0.5))
* Mat4::from_scale(Vec3::splat(0.5))
* Mat4::from_rotation_x((-90.0f32).to_radians())
* Mat4::from_translation(Vec3::splat(-0.5));
self.block_model_vertices(state, 42, t, light, true, out);
}
/// `SnowGolemHeadLayer` and `MushroomCowMushroomLayer`: the carved pumpkin
/// on a snow golem's head, and the mushrooms on an adult mooshroom's back
/// and head, as baked block models.
@@ -387,9 +340,10 @@ impl Game<'_> {
owner.crossbow_charge_duration = 25;
}
use horizon_assets::item_model::DisplayContext as Context;
// PandaHoldsItemLayer shows the item as it lies on the ground.
// PandaHoldsItemLayer and FoxHeldItemLayer show the item as it lies
// on the ground.
let (context, ordinal) = match (mob.species(), left) {
(Species::Panda, _) => (Context::Ground, 7),
(Species::Panda | Species::Fox, _) => (Context::Ground, 7),
(_, true) => (Context::ThirdPersonLeftHand, 1),
(_, false) => (Context::ThirdPersonRightHand, 2),
};
+12 -2
View File
@@ -6,7 +6,7 @@ use super::Game;
use horizon_client::hud::HudBatch;
#[cfg(feature = "mods")]
use {
horizon_client::{hud::rgba, items::ItemStack, text::Text},
horizon_client::{hud::rgba, items::ItemStack, text::Text, ui},
horizon_mods::Shape,
std::time::Instant,
};
@@ -108,7 +108,17 @@ impl Game<'_> {
let r = s.rect;
let name = s.sprite.strip_prefix("minecraft:").unwrap_or(&s.sprite);
let name = format!("gui/sprites/{name}");
batch.sprite_scaled(atlas, &name, ox + r.x, oy + r.y, r.w, r.h, argb(s.color));
// As vanilla's blitSprite: a frame or a button keeps its border.
ui::blit_sprite(
batch,
atlas,
&name,
ox + r.x,
oy + r.y,
r.w,
r.h,
argb(s.color),
);
}
Shape::Item(i) => {
if self.res.items().get(&i.item).is_none() {
+96 -20
View File
@@ -1074,26 +1074,15 @@ impl Game<'_> {
batch.fill(atlas, xx, yy, 1.0, wh, 0xFF50_5050);
}
let content = l.rows.len() as f32 * 12.0;
let first = (scroll.amount / 12.0) as usize;
let last = (first + ((rows_bottom - rows_top) / 12.0).ceil() as usize + 1)
.min(l.rows.len());
for (vi, ri) in (first..last).enumerate() {
let ry = rows_top + vi as f32 * 12.0 - (scroll.amount % 12.0);
if ui::hovered(self.cursor, x, ry, MOD_UI_COLUMN - 6.0, 12.0) {
batch.fill(atlas, x + 1.0, ry, MOD_UI_COLUMN - 8.0, 12.0, 0x20FF_FFFF);
}
if l.selected == Some(ri as u32) {
batch.fill(atlas, x + 1.0, ry, 3.0, 12.0, 0xFFFF_FFFF);
}
batch.text_styled(
atlas,
&Text::literal(l.rows[ri].clone()),
x + 6.0,
ry + 2.0,
u32::MAX,
true,
);
}
draw_list_rows(
batch,
atlas,
self.cursor,
&l.rows,
l.selected,
scroll.amount,
(x, wy, wh),
);
ui::scrollbar(
batch,
atlas,
@@ -1231,6 +1220,51 @@ impl Game<'_> {
}
}
/// A mod screen's `list` rows in its frame at (`x`, `wy`), `wh` high, scrolled by `scroll`: the
/// rows in view, the hovered one lit and the selected one marked. As AbstractSelectionList draws
/// its entries inside `enableScissor`, they are cut at the frame's inside: the row a scroll shows
/// in part, and the one below a full view, never draw over the frame or below it.
#[cfg(feature = "mods")]
fn draw_list_rows(
batch: &mut HudBatch,
atlas: &horizon_client::hud::HudAtlas,
cursor: (f32, f32),
rows: &[String],
selected: Option<u32>,
scroll: f32,
(x, wy, wh): (f32, f32, f32),
) {
let rows_top = wy + 4.0;
let rows_bottom = wy + wh - 4.0;
let first = (scroll / 12.0) as usize;
let last = (first + ((rows_bottom - rows_top) / 12.0).ceil() as usize + 1).min(rows.len());
let start = batch.vertices.len();
for (vi, ri) in (first..last).enumerate() {
let ry = rows_top + vi as f32 * 12.0 - (scroll % 12.0);
if ui::hovered(cursor, x, ry, MOD_UI_COLUMN - 6.0, 12.0) {
batch.fill(atlas, x + 1.0, ry, MOD_UI_COLUMN - 8.0, 12.0, 0x20FF_FFFF);
}
if selected == Some(ri as u32) {
batch.fill(atlas, x + 1.0, ry, 3.0, 12.0, 0xFFFF_FFFF);
}
batch.text_styled(
atlas,
&Text::literal(rows[ri].clone()),
x + 6.0,
ry + 2.0,
u32::MAX,
true,
);
}
batch.clip_since(
start,
x + 1.0,
wy + 1.0,
x + MOD_UI_COLUMN - 1.0,
wy + wh - 1.0,
);
}
#[cfg(feature = "mods")]
const MOD_UI_TOP: f32 = 40.0;
#[cfg(feature = "mods")]
@@ -1238,3 +1272,45 @@ const MOD_UI_COLUMN: f32 = 200.0;
/// A key row's height with its gap, as the widgets below it space out.
#[cfg(feature = "mods")]
const MOD_UI_KEY_ROW: f32 = 26.0;
#[cfg(all(test, feature = "mods"))]
mod tests {
use super::*;
use horizon_client::hud::HudAtlas;
#[test]
fn list_rows_stay_inside_the_frame_scrolled_or_not() {
let atlas =
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap();
// #3237: 22 rows in a frame of 6 (mod_ui_layout's height for a long list). The 7th row
// (index 6), drawn for a scroll that shows it in part, is selected and under the
// cursor, so its fills reach past the frame unless they are cut.
let rows: Vec<String> = (0..22).map(|i| format!("Row {i}")).collect();
let (x, wy, wh) = (100.0, 40.0, 6.0 * 12.0 + 8.0);
for scroll in [0.0, 6.0, 30.0] {
let mut batch = HudBatch::new([640, 480]);
let seventh_row = wy + 4.0 + 6.0 * 12.0 - scroll % 12.0;
draw_list_rows(
&mut batch,
&atlas,
(x + 10.0, seventh_row + 2.0),
&rows,
Some(6 + (scroll / 12.0) as u32),
scroll,
(x, wy, wh),
);
assert!(!batch.vertices.is_empty(), "scroll {scroll}: rows drawn");
let (top, bottom) = batch
.vertices
.iter()
.map(|v| v.pos[1])
.fold((f32::MAX, f32::MIN), |(lo, hi), y| (lo.min(y), hi.max(y)));
assert!(
top >= wy + 1.0 && bottom <= wy + wh - 1.0,
"scroll {scroll}: rows span {top}..{bottom}, the frame's inside is {}..{}",
wy + 1.0,
wy + wh - 1.0
);
}
}
}
+44 -5
View File
@@ -142,13 +142,36 @@ impl Game<'_> {
{
return false;
}
// SkeletonHorse.mobInteract: nothing for an untamed one.
if species == Species::SkeletonHorse && !m.extra.tame {
return false;
}
let sneaking = self.keys.contains(&winit::keyboard::KeyCode::ShiftLeft)
|| self.keys.contains(&winit::keyboard::KeyCode::ShiftRight);
// Horse / ZombieHorse.mobInteract: food is fed, and anything else in
// hand makes an untamed one mad instead of seating the player.
let dandelion = [self.held(), self.offhand.as_ref()]
.into_iter()
.flatten()
.any(|s| &*s.item == "minecraft:golden_dandelion");
let mut events = Vec::new();
match equine::held_item(
&mut self.mobs[index],
item,
sneaking,
dandelion,
&mut events,
) {
Some(equine::HeldItem::Feed) => return self.feed_mount(index, item),
Some(equine::HeldItem::Refused) => {
let _ = self.handle_mob_events(events);
return true;
}
None => {}
}
let m = &self.mobs[index];
// AbstractHorse.mobInteract: shift + use opens a tamed adult's inventory.
if !m.is_baby()
&& m.extra.tame
&& (self.keys.contains(&winit::keyboard::KeyCode::ShiftLeft)
|| self.keys.contains(&winit::keyboard::KeyCode::ShiftRight))
{
if !m.is_baby() && m.extra.tame && sneaking {
self.open_mount_menu(index);
return true;
}
@@ -489,6 +512,22 @@ impl Game<'_> {
MobEvent::Buck { mob } => {
if self.riding == Some(mob) {
self.dismount_mount();
} else {
// ejectPassengers: a server's player gets off too
// (leave_guest tells their client).
let rider = self.lan.as_ref().and_then(|h| {
h.hub
.guests
.iter()
.find(|g| g.vehicle == Some(mob))
.map(|g| g.conn)
});
if let Some(conn) = rider
&& let Some(mut host) = self.lan.take()
{
self.as_guest(&mut host, conn, |g| g.dismount_mount());
self.lan = Some(host);
}
}
// RunAroundLikeCrazyGoal.tick: entity event 6, smoke.
self.mob_event_particles(mob, 6);
+36 -66
View File
@@ -7,7 +7,7 @@
//! or a server) show whenever `LivingEntityRenderer.shouldShowName` allows.
use super::{Game, view_vector};
use horizon_client::{
block_entity_render::vertex_light, mesher::Vertex, mobs::Mob, save::uuid_text,
block_entity_render::vertex_light, hud::HudBatch, mesher::Vertex, mobs::Mob, save::uuid_text,
scoreboard as sb, text::Text,
};
@@ -289,7 +289,7 @@ impl Game<'_> {
/// faded, on its background; any other in full over the world, its faded
/// copy and background in `see_through`, over everything.
pub(super) fn name_tag_geometry(
&self,
&mut self,
camera: glam::DVec3,
alpha: f64,
out: &mut Vec<Vertex>,
@@ -298,26 +298,31 @@ impl Game<'_> {
// The text alpha that goes with getBackgroundOpacity(0.25):
// max((0.25 + 0.75) / 2, 0.5), as ARGB.color (the floor of 255 times it).
const FADED: u32 = 0x7F;
let mut patches = Vec::new();
for tag in self.name_tags(camera, alpha) {
let o = tag.at - camera;
// `gbuffers_entities` draws name tag text (2.3); a sneaking player's faded tag, on its
// background, blends.
if tag.discrete {
self.mark_translucent_entity(out.len(), "minecraft:name_tag");
self.name_tag_quads(o, &tag.lines, FADED, true, 0, vertex_light(tag.light), out);
let light = vertex_light(tag.light);
patches.extend(self.name_tag_quads(o, &tag.lines, FADED, true, 0, light, out));
continue;
}
// LightCoordsUtil.lightCoordsWithEmission(lightCoords, 2).
let lit = (tag.light >> 4).max(2) << 4 | (tag.light & 15).max(2);
self.mark_entity(out.len(), "minecraft:name_tag");
self.name_tag_quads(o, &tag.lines, 0xFF, false, 0, vertex_light(lit), out);
let light = vertex_light(lit);
patches.extend(self.name_tag_quads(o, &tag.lines, 0xFF, false, 0, light, out));
// RenderPipelines.TEXT_SEE_THROUGH: no depth test, no lightmap.
self.name_tag_quads(o, &tag.lines, FADED, true, 2, 0, see_through);
patches.extend(self.name_tag_quads(o, &tag.lines, FADED, true, 2, 0, see_through));
}
self.map_patches.extend(patches);
}
/// The lines of one name tag, `o` from the camera: the text at
/// `text_alpha` in its layer, on the translucent black background
/// (in `text_layer` like the glyphs, a hair behind) when `background`.
/// Returns the glyph pixels the atlas still needs.
#[allow(clippy::too_many_arguments)]
fn name_tag_quads(
&self,
@@ -328,7 +333,7 @@ impl Game<'_> {
text_layer: u32,
light: u32,
out: &mut Vec<Vertex>,
) {
) -> Vec<horizon_client::font::Patch> {
// Options.getBackgroundOpacity(0.25) black, as ARGB.color.
const BACKGROUND: u32 = 0x3F00_0000;
// The camera's facing (Camera.rotation()), turned round in third-person
@@ -342,85 +347,50 @@ impl Game<'_> {
let forward = glam::DVec3::from_array(view_vector(view_yaw, view_pitch));
let right = forward.cross(glam::DVec3::Y).normalize_or_zero();
let up = right.cross(forward).normalize_or_zero();
let white = self.gui.atlas.font.white();
let away = forward.as_vec3() * 0.002;
let mut batch = HudBatch::new([1, 1]);
for (text, lift) in lines {
let o = (o + glam::DVec3::new(0.0, *lift, 0.0)).as_vec3();
let (r, u) = (right.as_vec3() * 0.025, up.as_vec3() * 0.025);
// Text space: x right, y down (scale 0.025, -0.025).
let at = |x: f32, y: f32| o + r * x - u * y;
// Component.getVisualOrderText.
let shown = horizon_client::bidi::visual(text);
let chars: Vec<(char, u32)> = shown
.spans
.iter()
.flat_map(|s| {
let rgb = s.style.color.unwrap_or(0xFF_FF_FF);
// RGBA8, alpha in the top byte.
let color = text_alpha << 24
| ((rgb & 0xFF) << 16)
| (rgb & 0xFF00)
| ((rgb >> 16) & 0xFF);
s.text.chars().map(move |c| (c, color))
})
.collect();
let width: f32 = chars
.iter()
.map(|(c, _)| self.gui.atlas.glyph(*c).map_or(6.0, |g| g.1))
.sum();
let x0 = -(width / 2.0).trunc();
let y0 = if text.plain() == "deadmau5" {
-10.0
} else {
0.0
};
let mut quad = |a: [f32; 4], uv: [f32; 4], color: u32, behind: bool, layer: u32| {
let corners = [(a[0], a[3]), (a[2], a[3]), (a[2], a[1]), (a[0], a[1])];
let uvs = [
[uv[0], uv[3]],
[uv[2], uv[3]],
[uv[2], uv[1]],
[uv[0], uv[1]],
];
for ((x, y), uv) in corners.into_iter().zip(uvs) {
batch.vertices.clear();
// Font.prepareText, as any text: bold, italic, fonts and each
// glyph's own box (accented.png's are 12 pixels tall).
let behind = batch.name_tag(
&self.gui.atlas,
text,
y0,
text_alpha << 24 | 0xFF_FFFF,
if background { BACKGROUND } else { 0 },
);
for (i, quad) in batch.vertices.as_chunks::<6>().0.iter().enumerate() {
let shift = if i * 6 < behind {
away
} else {
glam::Vec3::ZERO
};
// The corners from the bottom left, as the world draws quads.
for v in [quad[5], quad[2], quad[1], quad[0]] {
out.push(Vertex {
normal: [0; 4],
pos: (at(x, y) + if behind { away } else { glam::Vec3::ZERO }).to_array(),
uv,
layer,
color,
pos: (at(v.pos[0], v.pos[1]) + shift).to_array(),
uv: v.uv,
layer: text_layer,
color: v.color,
light,
glint_uv: [0.0, 0.0],
});
}
};
if background {
quad(
[x0 - 1.0, y0 - 1.0, x0 + width, y0 + 8.0],
[white.u0, white.v0, white.u1, white.v1],
BACKGROUND,
true,
text_layer,
);
}
let mut x = x0;
for (ch, color) in chars {
let Some((g, advance)) = self.gui.atlas.glyph(ch) else {
x += 6.0;
continue;
};
if ch != ' ' {
quad(
[x, y0, x + 8.0, y0 + 8.0],
[g.u0, g.v0, g.u1, g.v1],
color,
false,
text_layer,
);
}
x += advance;
}
}
// Glyphs rasterized on first use (Unifont's) upload with the frame.
batch.patches
}
}
+45 -3
View File
@@ -305,7 +305,8 @@ impl Game<'_> {
let _ = mods;
}
/// The buttons of mod `mod_id`'s settings page, under its keys: push
/// their labels when it is yours. Asked again after each click.
/// their labels when it is yours. Asked again each time the page opens
/// and after each click.
pub(super) fn native_settings(&mut self, mod_id: &str, buttons: &mut Vec<String>) {
let _ = (mod_id, buttons);
}
@@ -341,9 +342,12 @@ impl Game<'_> {
pub(super) fn native_window(&self) -> bool {
self.options.execution_role == ExecutionRole::Client
}
/// Whether a box in `corner` shows: F3's debug screen takes the top left.
/// Whether a box in `corner` shows: F3's debug screen takes both top
/// corners (its left and right columns), and a bottom corner while one
/// of its charts is drawn there.
pub(super) fn native_corner_shown(&self, corner: Corner) -> bool {
!self.debug || corner != Corner::TopLeft
let charts = self.debug_charts.corners(!self.has_singleplayer_server());
!debug_takes(corner, self.debug, charts)
}
/// The width of `text` in GUI pixels.
pub(super) fn native_text_width(&self, text: &str) -> f32 {
@@ -533,6 +537,14 @@ impl Game<'_> {
return;
};
self.native_settings_click(&id, button);
self.native_settings_refresh(i);
}
/// Native mod `i`'s page opened: its labels asked again, so that they
/// follow the language (and the mod's state) since the list was made.
pub(super) fn native_settings_refresh(&mut self, i: usize) {
let Some(id) = self.settings.mods.installed.get(i).map(|m| m.id.clone()) else {
return;
};
let mut buttons = Vec::new();
self.native_settings(&id, &mut buttons);
let row = self.settings.mods.installed.get_mut(i);
@@ -602,6 +614,19 @@ impl Game<'_> {
}
}
/// Whether F3's debug screen, open when `debug`, covers `corner`
/// (`DebugScreenOverlay.extractRenderState`): its left and right columns
/// the top corners, and its charts the bottom corners `charts` (bottom left,
/// bottom right) says they are drawn in.
fn debug_takes(corner: Corner, debug: bool, charts: [bool; 2]) -> bool {
debug
&& match corner {
Corner::TopLeft | Corner::TopRight => true,
Corner::BottomLeft => charts[0],
Corner::BottomRight => charts[1],
}
}
/// `v` within `lo..=hi`; `lo` when it is not a number.
fn finite(v: f32, lo: f32, hi: f32) -> f32 {
if v.is_finite() { v.clamp(lo, hi) } else { lo }
@@ -625,4 +650,21 @@ mod tests {
}
}
}
/// Vanilla's F3 draws a column in each top corner (`extractLines`, left
/// and right aligned), and its charts along the bottom.
#[test]
fn the_debug_screen_takes_both_top_corners_and_its_charts_corners() {
use super::{Corner, debug_takes};
for corner in Corner::ALL {
assert!(!debug_takes(corner, false, [true, true]), "{corner:?}");
}
assert!(debug_takes(Corner::TopLeft, true, [false, false]));
assert!(debug_takes(Corner::TopRight, true, [false, false]));
assert!(!debug_takes(Corner::BottomLeft, true, [false, false]));
assert!(!debug_takes(Corner::BottomRight, true, [false, false]));
assert!(debug_takes(Corner::BottomLeft, true, [true, false]));
assert!(!debug_takes(Corner::BottomRight, true, [true, false]));
assert!(debug_takes(Corner::BottomRight, true, [false, true]));
}
}
Loaded 100 of 247 files, more files were not shown because too many files have changed in this diff. Show more