Compare commits

...
Author SHA1 Message Date
Ludovic MarieandClaude Opus 5.5 029de76f05 Vulkan, DX12: phase 1's solid draws made and counted on the GPU
Where multi-draws take their count from a buffer (MULTI_DRAW_INDIRECT_COUNT:
Vulkan 1.2+, DX12), the render thread no longer finds each phase 1 section's
facing runs nor uploads a draw per run: it lists one record per section, and
gpu_lists.wgsl's sides_draws writes one indirect draw per run the CPU would have
pushed (same count, base vertex and instance) into a call per pass and arena
page, drawn with multi_draw_indexed_indirect_count. Each call has room for
the most runs its sections' parts can have (Sides::part_runs). Same exclusions
as the flat path; HORIZON_GPU_LISTS=0 turns it off.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 04:24:52 +01:00
Ludovic MarieandClaude Opus 5.5 5e5a2c74d2 Flat draws: phase 1's solid lists made on the GPU
In frames drawn flat (Metal at render distance 32, Intel Gen11/12 Vulkan),
the render thread walked every phase 1 section's sides each frame to find the
runs of quads that face the eye, then the flat plan copied them into one index
list. Now each section uploads a row once (where its quads start, how Sides
sorted them), and each frame phase 1 pushes one record per section: the GPU
(gpu_lists.wgsl's flatten_sides) finds the same runs as Sides::visible_from and
writes their indices into a call per pass and arena page, drawn indirect.
Phase 2, translucent quads, tiles, mirrors, shader pack terrain and shadows
keep the CPU lists. HORIZON_GPU_LISTS=0 turns it off.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 04:24:42 +01:00
Ludovic Marie 2556a20c74 Merge pull request #6202 from JustKarkat/fix/collision-scan-budget
🤖 Agent: verify passes on 835f2b522; parity 171 passed on it merged into main 0454e97e3. The 5 failures aren't this change's. The budget only ever grows (max of the caller's 1,000,000 and the sweep's cells), so behaviour changes only where the old scan aborted. world_spawn#3 (seed -7565038591901794094, y 115 vs 108) searches with no_collision_or_liquid, not a movement, and its y follows chunk decoration order. sculk, first_person, server_player_targets (600 vs 599) and pack_screen are runner timing and environment. One cost to know about: scan_cells(sweep) adds a few multiplications to every movement, negligible next to the scan. Queued by pr-sweep-M.
2026-11-02 04:19:15 +01:00
Ludovic Marie cc2bc8fb84 Merge pull request #6503 from horizon-rust/perf/mesh-fill
Mesher: ~21% less CPU per section, identical meshes
2026-11-02 04:17:18 +01:00
Ludovic Marie e9eb640ac0 Merge pull request #6695 from horizon-rust/perf/recipes-read-once
🤖 Agent: recipe folder read once per load (json_folder), rebased on main after #6720. Verified: CI verify green on this head; recipe opens 28,882 → 8,172 at start (strace).
2026-11-02 04:13:14 +01:00
Ludovic MarieandClaude Opus 5.5 0bb31c7d42 Recipes: drop the imports the shared folder read left unused
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 03:55:56 +01:00
Ludovic MarieandClaude Opus 5.5 bf1fe4344d Recipes: the start reads the recipe folder once, not once per table
The crafting tables, the stations, the recipe book and brewing each listed and
read every file under data/minecraft/recipe again: a start to the title opened
each recipe file 7-8 times (14,581 opens for 2,043 files). json_folder::read
lists and reads a folder once and shares the bytes with any reader while one
holds them; the rest loader and /reload hold the recipe folder across the
tables, so it is read once, and nothing is kept after. Each table still parses
its own copy, with the same order, conditions and errors as before. Brewing
alone (a server join) still reads only recipe/brewing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 03:55:56 +01:00
Ludovic Marie 57cc476ac8 Merge pull request #6720 from horizon-rust/fix/mod-api-generated-current
🤖 Agent: regenerated mod API output (main's xtask check was stale after #6581). Verified: xtask mod-api --check passes, CI verify green on this head.
2026-11-02 03:55:44 +01:00
Ludovic Marie e058605ee6 Merge pull request #6686 from Ragbie/fix/attack-swap-pick
🤖 Agent: queued at 0d75e2e51 (with main and 53 other PRs: fmt, CI verify green).
2026-11-02 03:55:40 +01:00
Ludovic Marie bc807a6da1 Merge pull request #6673 from JustKarkat/fix/elytra-wall-damage
🤖 Agent: queued at 0d8198d62 (with main and 53 other PRs: fmt, CI verify green).
2026-11-02 03:55:36 +01:00
Ludovic Marie b8f73142f9 Merge pull request #6671 from senjinthedragon/hdr-lighting-2
🤖 Agent: queued at 2de5ff4ef (with main and 53 other PRs: fmt, CI verify green).
2026-11-02 03:54:02 +01:00
Ludovic Marie 00c25b5398 Merge pull request #6669 from senjinthedragon/hdr-fog-marks
🤖 Agent: queued at f5be69df7 (with main and 53 other PRs: fmt, CI verify green).
2026-11-02 03:53:57 +01:00
Ludovic Marie 3e382789c5 Merge pull request #6666 from xMelonekMaX/fix/server-forced-flight
🤖 Agent: queued at b440c53ce (with main and 53 other PRs: fmt, CI verify green).
2026-11-02 03:53:52 +01:00
Ludovic Marie 68a348299e Merge pull request #6661 from TREYX77/feat/native-framerate-limit
🤖 Agent: queued at 57b834671 (with main and 53 other PRs: fmt, CI verify green).
2026-11-02 03:53:49 +01:00
Ludovic Marie 3e20d5c7e6 Merge pull request #6649 from HrafnaValkyrja/fix/portals-tridents-rockets
🤖 Agent: queued at 5651a6af8 (with main and 53 other PRs: fmt, CI verify green).
2026-11-02 03:53:44 +01:00
Ludovic Marie 86fb2c8f82 Merge pull request #6647 from HrafnaValkyrja/fix/blast-wind-remaining
🤖 Agent: queued at 4aa12eb27 (with main and 53 other PRs: fmt, CI verify green).
2026-11-02 03:53:39 +01:00
Ludovic Marie f970ed281d Merge pull request #6634 from Akirabane/fix/tnt-bubble-particles
🤖 Agent: queued at 194163c33 (with main and 53 other PRs: fmt, CI verify green).
2026-11-02 03:53:35 +01:00
Ludovic Marie ebc5806996 Merge pull request #6633 from xMelonekMaX/fix/book-menu-visibility
🤖 Agent: queued at 0d95220d4 (with main and 53 other PRs: fmt, CI verify green).
2026-11-02 03:53:31 +01:00
Ludovic Marie f6a8608beb Merge pull request #6632 from sirrayi/fix/mob-hurt-armor-parity
🤖 Agent: queued at 779ab7dd4 (with main and 53 other PRs: fmt, CI verify green).
2026-11-02 03:53:27 +01:00
Ludovic Marie 6cafca9435 Merge pull request #6630 from mariuslozo-boop/fix/text-and-icon-label
🤖 Agent: queued at dd428288d (with main and 53 other PRs: fmt, CI verify green).
2026-11-02 03:53:22 +01:00
Ludovic Marie 0caab26bc4 Merge pull request #6629 from mariuslozo-boop/fix/finish-1927
🤖 Agent: queued at 78a634a19 (with main and 53 other PRs: fmt, CI verify green).
2026-11-02 03:53:18 +01:00
Ludovic Marie 31d5f62db1 Merge pull request #6628 from mariuslozo-boop/fix/finish-3684
🤖 Agent: queued at 3c0a4db87 (with main and 53 other PRs: fmt, CI verify green).
2026-11-02 03:53:14 +01:00
Ludovic Marie 35a64d25c2 Merge pull request #6617 from Qwerner88880/hooks-book-filters
🤖 Agent: queued at 621942429 (with main and 53 other PRs: fmt, CI verify green).
2026-11-02 03:53:09 +01:00
Ludovic Marie d8db492dca Merge pull request #6585 from MSedgeMC/fix/mount-seat-no-sweep
🤖 Agent: queued at 6d3917100 (with main and 53 other PRs: fmt, CI verify green).
2026-11-02 03:53:05 +01:00
Ludovic Marie b2bda56ed3 Merge pull request #6566 from MSedgeMC/fix/text-field-value
🤖 Agent: queued at 0cfd1d821 (with main and 53 other PRs: fmt, CI verify green).
2026-11-02 03:53:00 +01:00
Ludovic Marie e6f5a1ae7c Merge pull request #6639 from some-du6e/optifine/fusion-tiles
🤖 Agent: queued at 8a3f5f693 (with main and 53 other PRs: fmt, CI verify green).
2026-11-02 03:47:19 +01:00
Ludovic Marie cb14a7a1fb Merge pull request #6627 from some-du6e/optifine/emf-variables
🤖 Agent: queued at e4b2778ed (with main and 53 other PRs: fmt, CI verify green).
2026-11-02 03:47:15 +01:00
Ludovic Marie dd74d6b563 Merge pull request #6618 from navinor/fix/5533-farming-fresh-sol-e66af682
🤖 Agent: queued at 873ccc1ec (with main and 53 other PRs: fmt, CI verify green).
2026-11-02 03:47:11 +01:00
Ludovic Marie e3c831ab0d Merge pull request #6572 from some-du6e/optifine/emf-block-entities
🤖 Agent: queued at 55487cb93 (with main and 53 other PRs: fmt, CI verify green).
2026-11-02 03:47:07 +01:00
Ludovic Marie 47cbc4dcb5 Merge pull request #6637 from wxundup/feat/native-build-log-recording
🤖 Agent: queued at d9a30e08b (launcher CI green).
2026-11-02 03:45:19 +01:00
Ludovic Marie 49f6ceb448 Merge pull request #6600 from wxundup/feat/native-microphone-metadata
🤖 Agent: queued at e3e8af585 (launcher CI green).
2026-11-02 03:45:13 +01:00
Ludovic Marie c08375d5be Merge pull request #6506 from josegil1909/refactor/launcher-memory-slider-state
🤖 Agent: queued at cfc5c627b (launcher CI green).
2026-11-02 03:45:09 +01:00
Ludovic Marie 1b2812427b Merge pull request #6580 from horizon-rust/perf/dedicated-slow-tick-log
🤖 Agent: slow-tick log for the dedicated server, tested on playhorizon
2026-11-02 03:45:05 +01:00
Ludovic Marie 0b24d6d57b Merge pull request #6705 from ClickingMaster/fix/resolution-scale-slider-crash
🤖 Agent: queued at f9fb435f9 (with main and 53 other PRs: fmt, CI verify green).
2026-11-02 03:45:00 +01:00
Ludovic Marie d029fb659e Merge pull request #6703 from some-du6e/older-servers/1-packet-ids
🤖 Agent: queued at 07ad7d2ab (with main and 53 other PRs: fmt, CI verify green).
2026-11-02 03:44:57 +01:00
Ludovic Marie b3f74a5d42 Merge pull request #6694 from gigamans442/fix/shader-legacy-ssbo-20261101
🤖 Agent: queued at 7f0ea1224 (with main and 53 other PRs: fmt, CI verify green).
2026-11-02 03:44:53 +01:00
Ludovic Marie dbe52ccb4c Merge pull request #6692 from gigamans442/fix/shader-damaged-block-20261101
🤖 Agent: queued at d454f23ff (with main and 53 other PRs: fmt, CI verify green).
2026-11-02 03:44:49 +01:00
Ludovic Marie dc5a7553eb Merge pull request #6691 from gigamans442/fix/shader-image-helpers-20261101
🤖 Agent: queued at bd02c92df (with main and 53 other PRs: fmt, CI verify green).
2026-11-02 03:44:45 +01:00
Ludovic Marie c43f3d26f4 Merge pull request #6689 from gigamans442/fix/shader-blending-20261101
🤖 Agent: queued at e8cbb4c13 (with main and 53 other PRs: fmt, CI verify green).
2026-11-02 03:44:41 +01:00
Ludovic Marie c4f1b948db Merge pull request #6688 from gigamans442/fix/shader-history-20261101
🤖 Agent: queued at 2bce6d5b7 (with main and 53 other PRs: fmt, CI verify green).
2026-11-02 03:44:37 +01:00
Ludovic Marie 4a7e5d8f6f Merge pull request #6664 from Qwerner88880/hooks-ingredient-hook
🤖 Agent: queued at df3a7ffd7 (with main and 53 other PRs: fmt, CI verify green).
2026-11-02 03:44:33 +01:00
Ludovic Marie 652c11e914 Merge pull request #6616 from TiggerTakkyTTT/fix/server-management-game-mode
🤖 Agent: queued at 3a9559fe3 (with main and 53 other PRs: fmt, CI verify green).
2026-11-02 03:41:33 +01:00
Ludovic Marie 5c3a450532 Merge pull request #6615 from some-du6e/optifine/ctm-emissive
🤖 Agent: queued at 752049907 (with main and 53 other PRs: fmt, CI verify green).
2026-11-02 03:41:29 +01:00
Ludovic Marie be5494966e Merge pull request #6614 from Akirabane/fix/6594-fireball-item
🤖 Agent: queued at ab3ed2d7e (with main and 53 other PRs: fmt, CI verify green).
2026-11-02 03:41:24 +01:00
Ludovic Marie 09cf77babd Merge pull request #6611 from MSedgeMC/fix/boat-reboard-cases
🤖 Agent: queued at f693be236 (with main and 53 other PRs: fmt, CI verify green).
2026-11-02 03:41:20 +01:00
Ludovic Marie 219c3dce3c Merge pull request #6609 from some-du6e/optifine/ctm-docs
🤖 Agent: queued at f33fe927a (with main and 53 other PRs: fmt, CI verify green).
2026-11-02 03:41:17 +01:00
Ludovic Marie c59a3d651d Merge pull request #6606 from MSedgeMC/chore/ledger-verified-rows
🤖 Agent: queued at 0bf1609f2 (with main and 53 other PRs: fmt, CI verify green).
2026-11-02 03:41:13 +01:00
Ludovic Marie ccd8c0a3e4 Merge pull request #6605 from Teiwazik/shader/particle-lights
🤖 Agent: queued at ffba053a6 (with main and 53 other PRs: fmt, CI verify green).
2026-11-02 03:41:09 +01:00
Ludovic Marie c11e7ca6a3 Merge pull request #6604 from MSedgeMC/fix/advancement-command-cases
🤖 Agent: queued at d7469b82c (with main and 53 other PRs: fmt, CI verify green).
2026-11-02 03:41:05 +01:00
Ludovic Marie 02b0cb44fe Merge pull request #6603 from MSedgeMC/fix/merchant-restock-cases
🤖 Agent: queued at dd84588b5 (with main and 53 other PRs: fmt, CI verify green).
2026-11-02 03:41:01 +01:00
Ludovic Marie b4aa021fc7 Merge pull request #6602 from MSedgeMC/fix/recipe-book-cases
🤖 Agent: queued at b50698394 (with main and 53 other PRs: fmt, CI verify green).
2026-11-02 03:40:57 +01:00
Ludovic Marie 71f059c84b Merge pull request #6593 from some-du6e/optifine/ctm-orient-texture
🤖 Agent: queued at 9563b4159 (with main and 53 other PRs: fmt, CI verify green).
2026-11-02 03:40:52 +01:00
Ludovic Marie fc965ed486 Merge pull request #6592 from some-du6e/optifine/fusion-models
🤖 Agent: queued at 33b857768 (with main and 53 other PRs: fmt, CI verify green).
2026-11-02 03:40:48 +01:00
Ludovic Marie f885164054 Merge pull request #6591 from some-du6e/optifine/fusion-textures
🤖 Agent: queued at 6c5ee2d42 (with main and 53 other PRs: fmt, CI verify green).
2026-11-02 03:40:45 +01:00
Ludovic Marie 527b4f1b99 Merge pull request #6588 from some-du6e/optifine/fusion-layouts
🤖 Agent: queued at 442ac7ab4 (with main and 53 other PRs: fmt, CI verify green).
2026-11-02 03:40:41 +01:00
Ludovic Marie e58c8ff2f7 Merge pull request #6578 from some-du6e/optifine/ctm-chained
🤖 Agent: queued at ada1b8b1e (with main and 53 other PRs: fmt, CI verify green).
2026-11-02 03:40:37 +01:00
Ludovic Marie 3076bade1e Merge pull request #6556 from HrafnaValkyrja/refactor/entity-trait-pistons
🤖 Agent: queued at 10fcd8192 (with main and 53 other PRs: fmt, CI verify green).
2026-11-02 03:37:13 +01:00
Ludovic Marie 9fb793a432 Merge pull request #6530 from senjinthedragon/hdr-pack-marks
🤖 Agent: queued at ce465c570 (with main and 53 other PRs: fmt, CI verify green).
2026-11-02 03:37:09 +01:00
Ludovic Marie bf4bfbd6fb Merge pull request #6375 from breachato/fix/loot-hook-test-crlf
🤖 Agent: queued at 7630a2abc (with main and 53 other PRs: fmt, CI verify green).
2026-11-02 03:37:05 +01:00
Ludovic MarieandClaude Opus 5.5 00fb064655 Mod API: regenerate generated.rs after #6581's doc line
main's xtask check (mod_api::tests::generated_output_is_current) fails: the
hooks catalog's native_state_block mention landed without the regenerated file.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 03:35:35 +01:00
Ludovic Marie 10d2551439 Merge pull request #6709 from HrafnaValkyrja/fix/first-person-parity
🤖 Agent: read the change: HandSway::tick is vanilla's again (unwrapped yaw), and tick_wrapped unwraps the game's -180..180 yaw by short steps first, so #6451's boat-turn fix stays (HandSway::rotation already wraps view - bob). It fixes main's first_person failure since #6451 (yaw off by exactly 360°: seen on #6503's CI parity). Verify green; CI parity runs first_person on this head. Thanks!
2026-11-02 03:27:26 +01:00
Ludovic Marie 435730a405 Merge pull request #6636 from horizon-rust/perf/net-recv-buffer
🤖 Agent: verified by perf-net-client: CI verify green, pr_ready on playhorizon (only failure the known llvmpipe SIGSEGV in horizon-client-render GPU tests, untouched crate), horizon-protocol tests 160/160 on the Mac, and the branch's horizon-bots joined a real vanilla 26.3 server.jar and a Horizon dedicated server (20/20 bots, 14952 and 17682 chunks, no disconnects). Merging.
2026-11-02 03:17:44 +01:00
Ludovic Marie 3d80f8991c Merge pull request #6711 from horizon-rust/perf/worldgen-heightmap-fast
🤖 Agent: perf-gen-light verified this head: pr_ready --quick green, the 10 generation/light harnesses pass on playhorizon, worldgen output unchanged; -2.8% CPU per proto chunk.
2026-11-02 03:15:13 +01:00
Ludovic Marie fda7e48b33 Merge pull request #6579 from SamsVT/native-physical-window-size
🤖 Agent: queued at 6e55b944e.
2026-11-02 03:15:09 +01:00
Ludovic Marie de8dc9e86c Merge pull request #6581 from xFedeT/feat/native-state-block
🤖 Agent: queued at 989eb8a0b.
2026-11-02 03:15:05 +01:00
Ludovic Marie cbbd4151c8 Merge pull request #6518 from xFedeT/feat/native-server-announce
🤖 Agent: queued at b7e01926a.
2026-11-02 03:15:01 +01:00
Ludovic Marie 9891e04d64 Merge pull request #6534 from Detimes/feat-item-damaged-hook
🤖 Agent: queued at e8b62addb.
2026-11-02 03:14:57 +01:00
Ludovic Marie ae3f35dc32 Merge pull request #6550 from MSedgeMC/fix/mm-crafted-only
🤖 Agent: queued at 234a17bd6.
2026-11-02 03:14:53 +01:00
Ludovic Marie 94d4b3a866 Merge pull request #6551 from Detimes/feat-player-fish-hook
🤖 Agent: queued at 1929509dd.
2026-11-02 03:14:49 +01:00
Ludovic Marie 2b9fb6ac08 Merge pull request #6570 from MSedgeMC/fix/mm-difficulty
🤖 Agent: queued at eb94a3af3.
2026-11-02 03:14:45 +01:00
Ludovic Marie df0a98e064 Merge pull request #6701 from horizon-rust/tools/pacing-bench
🤖 Agent: by Ludovic's AI agent (Claude). pr_ready --quick passed on d1ee2d879 after the rebase onto #6698 (clippy, 2917 tests of horizon-client/-lib/-render, 600 Python tests). Tooling only (skip-release): pacing_bench.py, horizon-ctl stats raw/reset, spans::laps.
2026-11-02 03:14:39 +01:00
Ludovic Marie 987b517835 Merge pull request #6512 from MSedgeMC/fix/mm-loot
🤖 Agent: queued at 25f3bbd2c.
2026-11-02 03:14:36 +01:00
Ludovic Marie 7ee99da411 Merge pull request #6515 from HrafnaValkyrja/fix/lightning-entity-kinds
🤖 Agent: queued at c7a641587.
2026-11-02 03:14:32 +01:00
Ludovic Marie f4f62f9224 Merge pull request #6516 from Akirabane/feat/6484-control-hello
🤖 Agent: queued at 3f2b714ed.
2026-11-02 03:14:28 +01:00
Ludovic Marie 0a63fbf6bd Merge pull request #6559 from ClickingMaster/fix/data-player-uuid
🤖 Agent: queued at 755d2215c.
2026-11-02 03:14:24 +01:00
Ludovic Marie 5cb5d3b41c Merge pull request #6560 from Akirabane/feat/6524-furnace-fuel-remainder
🤖 Agent: queued at f30f3d840.
2026-11-02 03:14:20 +01:00
Ludovic Marie 1c6daa9085 Merge pull request #6519 from ErikKalikas/mod-loot-weather
🤖 Agent: queued at adee3fcc2.
2026-11-02 03:10:15 +01:00
Ludovic Marie 3fdddfe733 Merge pull request #6523 from xFedeT/fix/boat-post-per-boat
🤖 Agent: queued at e431c279f.
2026-11-02 03:10:11 +01:00
Ludovic Marie ab59debc6c Merge pull request #6529 from HrafnaValkyrja/fix/plates-remaining-kinds
🤖 Agent: queued at b86f3f517.
2026-11-02 03:10:07 +01:00
Ludovic Marie 4ede938844 Merge pull request #6532 from senjinthedragon/hdr-polish
🤖 Agent: queued at 0df0b4d19.
2026-11-02 03:10:03 +01:00
Ludovic Marie bbe3a66af7 Merge pull request #6533 from xFedeT/fix/trident-impact-point
🤖 Agent: queued at c50a0813b.
2026-11-02 03:09:59 +01:00
Ludovic Marie 2df2560f08 Merge pull request #6548 from xFedeT/fix/atlas-fit
🤖 Agent: queued at 4673402d9.
2026-11-02 03:09:55 +01:00
Ludovic Marie a2b65c9e4b Merge pull request #6554 from xFedeT/fix/guest-owner-hurt
🤖 Agent: queued at 4e1982901.
2026-11-02 03:09:50 +01:00
Ludovic Marie cf8e9608f8 Merge pull request #6561 from xFedeT/fix/server-edit-tab-order
🤖 Agent: queued at 1446ced38.
2026-11-02 03:09:45 +01:00
Ludovic Marie 2e3a6576d6 Merge pull request #6568 from xFedeT/fix/server-list-arrows
🤖 Agent: queued at e30335f71.
2026-11-02 03:09:41 +01:00
Ludovic Marie 20e28142a7 Merge pull request #6575 from HrafnaValkyrja/refactor/stable-entity-ids
🤖 Agent: queued at 968a1b147.
2026-11-02 03:09:36 +01:00
Ludovic Marie d56aa0216e Merge pull request #6577 from xFedeT/fix/menu-button-acting-player
🤖 Agent: queued at b4b5281ff.
2026-11-02 03:09:31 +01:00
Ludovic Marie e18d973c7d Merge pull request #6458 from some-du6e/optifine/cit-glint
🤖 Agent: queued at 65cfa7424.
2026-11-02 03:09:27 +01:00
Ludovic Marie d3ae610d55 Merge pull request #6392 from some-du6e/optifine/cit-glint-rules
🤖 Agent: queued at 205f2560b.
2026-11-02 03:09:23 +01:00
Ludovic Marie f58ef7dfae Merge pull request #6358 from some-du6e/optifine/cit-armor
🤖 Agent: queued at 4a6267b9c.
2026-11-02 03:09:18 +01:00
Ludovic Marie c594c860cf Merge pull request #6334 from some-du6e/optifine/cit-items
🤖 Agent: queued at 56e5ef6f0.
2026-11-02 03:09:14 +01:00
Ludovic MarieandClaude Opus 5.5 1929509ddd Name Bukkit's PlayerFishEvent as native_player_fish's model
NeoForge's ItemFishedEvent fires later, in FishingHook.retrieve once the
loot is rolled, and carries the drops and rod damage: not this hook's
contract (Dyna's review).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 03:08:09 +01:00
Ludovic Marie 43062d7bbd Merge pull request #6216 from arismoko/v2/ses_01M6BECNQ499TYMMBQBDF00VJ5-mod-api
🤖 Agent: queued ahead of #6509 (orchestrator's order). Hooks API unchanged (153); generated files current with main.
2026-11-02 03:01:18 +01:00
ClickingMasterandClaude Sonnet 5.5 f9fb435f94 Fall back instead of crashing when the scaled world runs out of video memory
Post::prepare built the scaled world targets while the old ones were still
alive and without catching a failed allocation, so dragging the Resolution
Scale slider could run a small GPU out of memory and wgpu's fatal default
handler panicked (#6607).

Drop the old target first, build the new one inside an out-of-memory and
validation error scope (a failed texture is invalid, so its view fails
validation too), and on failure step the scale down by a quarter, to a floor
of 0.25, then draw at the window's size.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 18:00:46 -08:00
HrafnaValkyrja cb77e8e745 Merge remote-tracking branch 'origin/main' into fix/first-person-parity 2026-11-01 20:56:11 -05:00
gigamans442andCodex GPT-6 d454f23ff6 Use fixed-size slice chunks in the crack projection helper
Follow the CI Clippy suggestion without changing the four-vertex grouping or ignored remainder.

Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-01 20:55:21 -05:00
some-du6eandClaude Opus 5.5 07ad7d2abc Protocol adapter: 26.2's packet ids against 26.3's (part of #6702)
The first layer of joining older servers: a ServerVersion type and, per
connection state, how a 26.2 server numbers each packet, generated from
both releases' packets reports. Nothing calls it yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 19:53:43 -06:00
Ludovic MarieandClaude Opus 5.5 d1ee2d879c pacing_bench: import its helpers as the other tools do (no sys.path change)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 02:50:38 +01:00
Ludovic MarieandClaude Opus 5.5 a3d814ec3f pacing_bench: the ocean flight scenario
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 02:50:38 +01:00
Ludovic MarieandClaude Opus 5.5 d5273ce67f Frame pacing benchmark: tools/pacing_bench.py, stats raw, spans::laps
perf_bench measures throughput on short flights; pacing_bench records every
frame of the sessions where players see hitches (64 blocks/s flight, eight
long teleports, Nether trips, inventory and pause menu spam) and reports p50,
p99, p99.9, max, 1% and 0.1% lows and frames over 33 ms, with the timed spans
of the slowest frames (HORIZON_SPIKES) beside them.

- horizon-ctl `stats raw`: every frame time since the last reset, as `fly ...
  raw` gives for a flight.
- spans::laps: a function's consecutive steps as spans, nothing read while
  spans are off. Renderer::draw_frame and Game::entity_geometry time their
  steps with it; they found the lead knot's model parse (41 ms) and the
  arena page frees (8-14 ms).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 02:50:38 +01:00
Ludovic Marie 09a669a6c3 Merge pull request #6698 from horizon-rust/perf/submit-wait-is-wait
🤖 Agent: by Ludovic's AI agent (Claude). pr_ready --quick passed on 08508f5d8 (clippy, horizon-client-render tests, 598 Python tests); CI parity decides the affected harnesses. Linux only: submit-thread queue waits now count in last_wait, so frame CPU, the frame graph and HORIZON_SPIKES stop reporting GPU waits as CPU.
2026-11-02 02:43:57 +01:00
HrafnaValkyrja 6470651ffe Merge remote-tracking branch 'origin/main' into fix/first-person-parity 2026-11-01 20:41:03 -05:00
arismoko 3370efe35e Merge remote-tracking branch 'upstream/main' into HEAD
# Conflicts:
#	crates/horizon-client-render/src/block_entities.rs
2026-11-02 01:40:54 +00:00
Ludovic Marie 29ba9fbb09 Merge pull request #6659 from horizon-rust/perf/title-before-world-renderer
🤖 Agent: the title's first frame before the world renderer's build. Verified: CI verify green on this head (incl. the new handoff-wait test); A/B in the PR (title median 3510 → 2482 ms).
2026-11-02 02:40:40 +01:00
Ludovic Marie 155aebb56b Merge pull request #6658 from horizon-rust/perf/title-panorama-beside-atlas
🤖 Agent: panorama decodes beside the GUI atlas. Verified: CI verify green on this head; A/B in the PR (ui.load median 764 → 441 ms).
2026-11-02 02:40:36 +01:00
Ludovic Marie bdc05a3853 Merge pull request #6552 from horizon-rust/perf/rest-before-handoff
🤖 Agent: rest loader starts before the handoff's CPU work. Verified: CI verify green on this head; A/B in the PR (rest ready −216/−233/−316 ms, 3/3 pairs).
2026-11-02 02:40:32 +01:00
Ludovic Marie 4aca4d1d38 Merge pull request #6498 from horizon-rust/fix/telemetry-title-when-drawn
🤖 Agent: telemetry to_title_ms is the title's first frame (skip-release). Verified: CI verify green on this head.
2026-11-02 02:40:29 +01:00
Ludovic Marie f837e61c67 Merge pull request #6497 from horizon-rust/perf/renderer-reuse-pipelines
🤖 Agent: renderer pipeline reuse across the loading/title/world renderers on one device. Verified: CI verify green on this head (fmt, clippy -D warnings, workspace tests), A/B in the PR (title artwork step median 4.8 → 1.5 s on llvmpipe).
2026-11-02 02:40:25 +01:00
gigamans442andCodex GPT-6 7f0ea1224d Use a local legacy identifier as the preservation control
The original uniform-name control already fails on unchanged upstream in the generated uniform block. Exercise the existing local identifier rewrite without expanding this SSBO-keyword fix into uniform code generation. The frozen upstream GPU fixture accepts this corrected control.

Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-01 20:40:15 -05:00
Ludovic Marie 7366f3f7aa Merge pull request #6621 from horizon-rust/perf/gizmo-box-index
🤖 Agent: verified: CI green on this head, A/B on the Mac M4 Pro at RD 32 shows 0.1-0.25 ms less main-thread CPU per frame (with the sibling PR), nothing drawn changes. Merging.
2026-11-02 02:36:38 +01:00
Ludovic Marie 10e4e4de5a Merge pull request #6620 from horizon-rust/perf/split-monitors-on-demand
🤖 Agent: verified: CI green on this head, A/B on the Mac M4 Pro at RD 32 shows 0.1-0.25 ms less main-thread CPU per frame (with the sibling PR), nothing drawn changes. Merging.
2026-11-02 02:36:35 +01:00
HrafnaValkyrja 01fa1b3e02 Merge remote-tracking branch 'origin/main' into fix/first-person-parity 2026-11-01 20:34:08 -05:00
HrafnaValkyrjaandClaude Sonnet 5.5 602923d8a7 Hand sway: tick follows vanilla's unwrapped yaw; the wrap moves to a separate method
6aec3da41 wrapped the yBob easing inside HandSway::tick, so a turn of more than 180
degrees in one tick (a teleport, a scripted rotation) eased the short way where vanilla's
unwrapped yaw eases the long way. tools/parity.py first_person went from 0 to 2,973
differing cases, all in yBob. tick is vanilla again; the game's wrapped yaw goes through
tick_wrapped, which unwraps it by its short steps first, so the boat case keeps its fix.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 20:33:50 -05:00
Ludovic Marie e99e1018a3 Merge pull request #6687 from horizon-rust/perf/arena-free-thread
🤖 Agent: by Ludovic's AI agent (Claude). pr_ready --quick passed on 873ff80cd (clippy, 989 horizon-client-render tests, 598 Python tests); CI parity decides the 132 affected harnesses. Emptied arena pages are freed 4 frames later on a helper thread instead of on the render thread.
2026-11-02 02:33:25 +01:00
gigamans442andCodex GPT-6 667dd7902a Shader packs: route destroy-stage overlays through gbuffers_damagedblock
Fixes #6680.

Retain upstream behavior outside this isolated host/compiler fix.

Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-01 20:28:33 -05:00
gigamans442andCodex GPT-6 d4b773dadf Shader translator: keep the buffer keyword in extension-enabled legacy SSBO blocks
Fixes #6683.

Retain upstream behavior outside this isolated host/compiler fix.

Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-01 20:24:42 -05:00
gigamans442andCodex GPT-6 bd02c92df2 Shader translator: preserve storage-image formats through qualified helper parameters
Fixes #6677.

Retain upstream behavior outside this isolated host/compiler fix.

Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-01 20:24:12 -05:00
gigamans442andCodex GPT-6 e8cbb4c13c Shader packs: honor per-colortex blend overrides after output remapping
Fixes #6675.

Retain upstream behavior outside this isolated host/compiler fix.

Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-01 20:23:51 -05:00
gigamans442andCodex GPT-6 2bce6d5b7d Shader packs: initialize previous-frame uniforms from the first real frame
Fixes #6674.

Retain upstream behavior outside this isolated host/compiler fix.

Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-01 20:23:41 -05:00
Evrim CicekandClaude Opus 5.5 0d75e2e519 Attack swap: pass the clip closure by value (clippy)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 04:23:23 +03:00
Evrim CicekandClaude Opus 5.5 dfaa2864d5 Attack swap e2e: a tagged pig, the world's own mobs cleared first
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 04:18:28 +03:00
Evrim CicekandClaude Opus 5.5 d4f736d2bf Attack swap e2e: the min-reach pig hovers at eye height, fully inside the spear's minimum
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 04:14:39 +03:00
Ludovic MarieandClaude Opus 5.5 337bb3ad1e Startup: the startup menu's test Loading has the title-frame fields; a test of the handoff's wait
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 02:10:51 +01:00
JustKarkat 0d8198d62d A glide's wall hit hurts by the speed the client flew
The server's copy of a guest never travels with the client's own
velocity: handleMovePlayer moves its body, and server_travel starts from
whatever delta movement the copy last had, a few hundredths while
gliding. LivingEntity.handleFallFlyingCollisions reads that as the speed
the glide lost (moveHorLength), so (moveHorLength - new) * 10 - 3 stayed
negative and flying into a wall never hurt.

Carry the tick's movement into the copy's delta movement before the
travel, as handleMovePlayer does, and guard the rule with a test: a glide
into a wall at 1.2 blocks a tick hurts by minecraft:fly_into_wall.

Fixes #6494
2026-11-01 19:10:37 -06:00
Ludovic Marie 0454e97e32 Merge pull request #6502 from horizon-rust/perf/mesh-bench
🤖 Agent: bench-only (chunk_load_bench measures the workers' mesh_reusing + sort_solid path, percentiles, --mesh-threads, --json). Verify green on this head (rebased on main db34ce484d); pr_ready block in the comments (its one failure, armor_stand_motion_tests, also fails in other agents' runs on playhorizon). skip-release.
2026-11-02 02:09:14 +01:00
SenjinandClaude Opus 5.5 ce465c5708 HDR: in a material stage too, a sprite that shines all over has no threshold
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 02:04:29 +01:00
SenjinandClaude Opus 5.5 2de5ff4ef1 HDR: light itself above paper white
Surfaces at the strongest block and sky light rise above paper white, sky light by how a face
meets the sun, and the whole of it eases into the peak. Part of the frame's alpha carries it
to the last pass, where it amounts to vanilla's lightmap going past 1. Off while a shader
pack's post or material stage runs. Fog and water take it away as they should.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 02:01:39 +01:00
RatmirandClaude Opus 5.5 57b8346713 Format the framerate limit hook call
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 01:58:50 +01:00
Evrim CicekandClaude Opus 5.5 cbed0c07bd Attack swap: the item's pick stops on a block, and its range holds the hit
Review:
- getHitEntitiesAlong returns the block it clipped when no entity is in the
  attack range, and filterHitResult keeps a block within reach, so only a
  miss falls back to the plain pick (a mob inside a spear's minimum reach
  with the floor behind it is not the target).
- Minecraft.startAttack attacks an entity only inside the main-hand item's
  attack_range.
- handleKeybinds takes the number keys before swap hands, drop, inventory,
  chat and pick-block, which act on the key here: they select the queued
  keys first.
- A remote client sends the new slot in the tick only when that tick acts
  (ensureHasSentCarriedItem); else the next tick's sync does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 03:58:12 +03:00
Ludovic Marie 20c6194da6 Merge pull request #6656 from horizon-rust/perf/entity-models-at-startup
🤖 Agent: by Ludovic's AI agent (Claude). pr_ready --quick passed on 33e2c32d3 (clippy, 593 horizon-client tests, 598 Python tests); CI parity decides the 60 affected harnesses. Moves six entity models' bake to the startup worker: removes a 41 ms render-thread hitch on first sight.
2026-11-02 01:58:12 +01:00
some-du6eandClaude Opus 5.5 8a3f5f6939 Fusion tiles: keep the sheet's texture section, and forget its old pixels
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:57:09 -06:00
Ludovic Marie b3a291b0e2 Merge pull request #6180 from Joansitoh/fix/6109-shield-partial-knockback
🤖 Agent: verify passes, and parity passes on 5fe8c6def merged into main 4bc8e359c (server_knockback PASS, with the new pvp-shield-partial and zombie-shield-partial cases). The code fix came in through #6381, so this PR adds the two harness cases that pin it. Queued by pr-sweep-M.
2026-11-02 01:56:30 +01:00
SenjinandClaude Opus 5.5 f5be69df70 HDR: fog hides light sources, animated sprites keep shining, the vignette keeps the lift
A light source's mark ignored fog: fully fogged, it stood out, through water since #6471.
A sprite that shines all over took a threshold from one frame of its animation, so other
frames' pixels dropped out. The vignette's darkest corners lost the lift at once.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 01:53:20 +01:00
RatmirandClaude Opus 5.5 fc111a8fc0 Add native_framerate_limit hook so native mods can lower the frame rate
A native mod (a Dynamic FPS port) needs to cap the frame rate while the
window is unfocused or minimised. Game::framerate_limit now asks
native_framerate_limit(focused, minimized, in_world, limit) after the
inactivity throttle and Battery Mode, and keeps the lower of its own limit
and the mod's; values below 1 are ignored. The API number comes from the
framerate_limit fragment.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 01:52:13 +01:00
HrafnaValkyrjaandClaude Opus 5.5 5651a6af8d Merge origin/main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 19:49:24 -05:00
Evrim CicekandClaude Opus 5.5 d073e1dc7e A number key and an attack in one tick swing the new item at the old one's target
Minecraft.tick picks the crosshair target (raycastHitResult: the held
item's attack_range, a spear's 4.5 blocks) before handleKeybinds takes the
number keys and then the attack clicks. Horizon changed the slot when the
key came in and picked the attacked mob with the plain 3 blocks at the
click, so a spear-to-mace attribute swap swung at nothing (#6376).

The number keys now wait for the tick: its pick (block and mob, with the
active item's attack_range) runs first, then the keys select, then the
clicks act; a remote client sends the new carried slot before them.
tools/attack_swap_e2e.py drives the client against a vanilla server.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 03:48:28 +03:00
huiandZCode AI agent (GLM-5.3) df3a7ffd73 Native mods: a recipe ingredient of a mod's own kind
An ingredient that names a type in a namespace outside minecraft: used
to fail the recipe file whole. It now reaches native_ingredient(kind,
data, items), a free function in crafting.rs which mixin bundles hook:
fill the item list with what the ingredient matches, as a tag is. The
list holds item ids only (components are not checked; a match-time
hook follows separately). Duplicates are removed keeping first-seen
order. The hook is called once per ingredient by each copy that loads
the recipes — the crafting tables', the network copy's — so at least
twice per load, and the answer must be the same. An unclaimed kind
leaves the list empty and the recipe fails, as an unknown kind does; a
bare type name is minecraft's own.

The catalog target names horizon_client_lib, the package crafting.rs
compiles into since #5446's split: horizon-mixins resolves the crate
by its package, and refuses horizon_client::crafting::... (proven on a
staged build; the same split left menu's and breathing's catalog
targets on the old crate name — their bundles would fail the same way,
left to their own fix).

Part of #5367: predicates over components need a match-time hook, not
this load-time list, and follow separately.

Co-Authored-By: ZCode AI agent (GLM-5.3)
2026-11-02 01:47:42 +01:00
Ludovic Marie fd88a545d9 Merge pull request #6179 from k3yh/fix/firework-selectors
🤖 Agent: verify passes on 3740468b5; parity 45 passed. The one failure is a timing flake: server_player_targets' console effect is 599 ticks against vanilla's 600, a tick off on a loaded runner, in a step with no firework rocket (this change only adds rockets to selectors). Job rerun. Queued by pr-sweep-M.
2026-11-02 01:45:59 +01:00
some-du6eandClaude Opus 5.5 351c82aded Fusion tiles: the sheet's own layer shows its first tile
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:43:36 -06:00
MSedgeMC 6d3917100d Forget the seat when the rider gets up
posed_rider kept the id of the last mount, so boarding the same boat, cart or animal again
compared equal and the camera swept to the seat instead of cutting to it. Not riding clears
it, so every mount is a new seat.
2026-11-02 00:43:30 +00:00
Ludovic MarieandClaude Opus 5.5 08508f5d86 rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 01:40:11 +01:00
Ludovic MarieandClaude Opus 5.5 873ff80cd9 Free emptied terrain buffer pages a few frames later, off the render thread
Arena::trim dropped an emptied page's buffer (up to 256 MB) on the render
thread; giving it back to the driver took that frame 8-14 ms of CPU on Mesa's
lavapipe (spans, RD 12 flights). A dimension change (clear_sections) dropped
every page at once the same way.

Arena::trim and Arena::clear now give the buffers back; the renderer keeps them
in a Graveyard for four frames, so the GPU is done with them and the bind
groups naming them (flat draws' page tables) are gone, then drops them on a
short-lived thread, as chunk::free_columns does for unloaded chunks. The
driver frees them as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 01:40:11 +01:00
Ludovic MarieandClaude Opus 5.5 1fad808164 Frame stats: waits for the submit thread count as waiting, not CPU
On Linux the frame's commands are submitted and presented on a submit thread
(#5779); every use of the queue first waits for the previous frame there. In a
GPU-bound view that wait (uploads in update_world, the frame's own writes, the
next submit_later, offscreen pacing in land_frame) was counted as the frame's
CPU time in FrameStats, F3, the frame graph, HORIZON_SPIKES and perf_bench's
cpu_ms: on lavapipe 15-150 ms "CPU" frames whose thread worked 0.1-0.5 ms.
Queue now sums the time spent blocked on it, and the renderer adds it to
last_wait.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 01:40:11 +01:00
Ludovic MarieandClaude Opus 5.5 33e2c32d35 Startup test: the fixture has the models now loaded at startup
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 01:40:10 +01:00
Ludovic MarieandClaude Opus 5.5 491d9f5e25 Entity models parsed at startup, not on first sight
The lead knot, wind charge, shulker bullet, wither skull, evoker fangs and a
raid captain's banner loaded their model on first draw, each by parsing the
whole entity-models.json (2.4 MB) on the render thread. The lead knot's ran on
the first frame of every world, with no lead in sight: 41 ms of CPU and
125-230 ms of wall time in that frame. They now come from the document
GameCpuResources already parses once at startup.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 01:40:10 +01:00
HrafnaValkyrja 78792c54ea Merge remote-tracking branch 'origin/main' into fix/portals-tridents-rockets 2026-11-01 19:38:05 -05:00
HrafnaValkyrjaandClaude Opus 5.5 dea101aafb Portals carry thrown tridents and firework rockets
Tridents and rockets ignored nether and End portals. They are now swept for
portal blocks, saved with the existing writers, re-spawned in the other
dimension and keep their portal cooldown on arrival. A rocket boosting a
player and a stuck trident that did not move stay.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 19:37:49 -05:00
Ludovic MarieandClaude Opus 5.5 0929294702 Startup: the title frame's wait starts once the title artwork's renderer is built
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 01:37:12 +01:00
HrafnaValkyrja 4aa12eb27f Merge remote-tracking branch 'origin/main' into fix/blast-wind-remaining 2026-11-01 19:36:02 -05:00
HrafnaValkyrjaandClaude Opus 5.5 d3c7a961e8 Wind bursts push firework rockets and thrown tridents
wind_knockback skipped both; vanilla's hurtEntities pushes every entity that
does not ignoreExplosion. Adds lan_e2e --check wind-remaining and lists
rockets and trident motion in simulation.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 19:35:43 -05:00
d9a30e08b3 Keep native build logs fresh through the complete attempt
Record early failures and errors selecting or exporting a compiled build,
without dropping compiler diagnostics or changing the returned build result.
Refresh the generated native-build CLI reference.

Co-Authored-By: GLM 5.3 <[email protected]>
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-01 19:30:34 -05:00
some-du6eandClaude Opus 5.5 33b857768d Fusion models: test inheriting a parent's predicates for every face
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:29:17 -06:00
some-du6eandClaude Opus 5.5 6c5ee2d427 Fusion textures: refuse metadata that is not an object
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:29:17 -06:00
some-du6eandClaude Opus 5.5 64688576ee Fusion packs: cut connecting texture sheets into a sprite per tile
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:29:17 -06:00
some-du6eandClaude Opus 5.5 c4c932d08a Fusion models: one loop resolves a face's key
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:29:17 -06:00
some-du6eandClaude Opus 5.5 778bb6d932 Fusion textures: test continuous defaults
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:29:17 -06:00
some-du6eandClaude Opus 5.5 553e69c479 Fusion models: a parent's predicates for every face fill the child's missing keys
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:29:17 -06:00
some-du6eandClaude Opus 5.5 8b1ab91f9f Fusion textures: field helpers answer an Option, and test every default
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:29:17 -06:00
some-du6eandClaude Opus 5.5 971b2d6dd5 Fusion packs: connecting models and their connections
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:29:17 -06:00
some-du6eandClaude Opus 5.5 3f4e6f0812 Fusion textures: options without a type are base, and no rounded seeds
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:29:17 -06:00
some-du6eandClaude Opus 5.5 67788b979f Fusion packs: read the fusion section of texture .mcmeta files
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:29:17 -06:00
some-du6eandClaude Opus 5.5 383eea634d Fusion predicates: clippy's identity_op in a test
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:29:17 -06:00
some-du6eandClaude Opus 5.5 442ac7ab41 Fusion layouts: clippy's identity_op in a test
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:29:08 -06:00
some-du6eandClaude Opus 5.5 574d9b148e Fusion predicates: name the field a bad block list came from
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:29:08 -06:00
some-du6eandClaude Opus 5.5 e440012293 Fusion predicates: test every direction name and state values
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:29:08 -06:00
some-du6eandClaude Opus 5.5 d7896df7d8 Fusion packs: connection predicates
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:29:08 -06:00
Akirabane 194163c333 Merge origin/main into fix/tnt-bubble-particles 2026-11-02 01:27:23 +01:00
Ludovic Marie 4bc8e359c2 Merge pull request #6625 from horizon-rust/feat/telemetry-export
🤖 Agent: server-only export endpoints (off without a token); fmt, clippy -D warnings and the full test suite pass inside crates/horizon-telemetry-server, CI green on this head.
2026-11-02 01:26:57 +01:00
AkirabaneandClaude Sonnet 5.5 f579f440f4 Primed TNT at the top of a bubble column sends its surface particles
Entity.handleOnAboveBubbleColumn ends in sendBubbleColumnParticles for every
entity at the top of a column, and PrimedTnt does not override it. The TNT
was pushed by the column but showed nothing at its surface. PrimedTnt::tick
now reports the column blocks it is at the top of (bubble_column_push_over,
added for mobs and items in #6474) and tick_tnt draws them with
Game::bubble_column_particles, which also sends them to the LAN guests.

Fixes #6626

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 01:22:33 +01:00
sirrayiandDeepSeek Harness 779ab7dd4c fix(mobs): a hit that bypasses armour, or isn't a horse's, wears no body piece
#5317 was merged with three deviations from vanilla 26.3 in the `ArmorHurt`
path that its review had already named (Joansitoh's agent:
`LivingEntity.java:1887`, `:1907-1909`, and `doHurtEquipment`'s amount):

- the gate was `drops::head_is_body` minus Wolf, which also matches donkey,
  mule, skeleton and zombie horse, camel, llama, nautilus, happy ghast and
  sulfur cube. `hurtArmor` is overridden by `Horse` alone — the base one is
  empty (`LivingEntity.java:1887`), `Wolf` and `Player` have their own rules —
  so those species wore a body item vanilla never touches;
- nothing tested `#bypasses_armor`, so a fall, drowning, starvation, the void
  or a magic hit wore the piece too (`hurtArmor` is called inside that gate,
  `LivingEntity.java:1907-1909`);
- the amount was `dealt`, computed after the armour, toughness and
  enchantment reductions and after the invulnerability window's subtraction,
  where vanilla hands `hurtArmor` the damage `getDamageAfterArmorAbsorb`
  received: 8 raw damage is 8/4 = 2 durability, not 1.

`Horse.hurtArmor` also asks `doHurtEquipment` for `EquipmentSlot.BODY` alone,
so the four armour slots are not this path's to wear; `hurt_mob_armor` now
wears the body piece only, which drops the slot loop with it.

`doHurtEquipment` also starts from the item's `equippable` component
(`Equippable equippable = item.get(DataComponents.EQUIPPABLE)`), which
`wearable` did not check: `equip_damage_on_hurt` defaults to true when the
component is absent (`horizon-items/src/items.rs`), so a damageable item
carrying no `equippable` — a sword put in a horse's body slot by
`/item replace` — wore out.

Tests: three cases in `mobs::armor_hurt_tests` (a donkey with a body item, a
fall on a horse, the amount before the reduction); each one fails on the
merged code and passes here. The `equippable` gate needs a live `Game`, so it
is the one part without a unit test.

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-11-02 00:20:51 +00:00
some-du6eandClaude Opus 5.5 e4b2778edb OptiFine entity models: the local player's model knows when it's in water
Its fluids were never set, so swimming read as crawling for its
animations.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:20:21 -06:00
HrafnaValkyrja 10fcd81920 Merge remote-tracking branch 'origin/main' into refactor/entity-trait-pistons 2026-11-01 19:17:34 -05:00
Maksymilian AdamkiewiczandCodex 0d95220d49 fix: hide the underlying menu while a book is open
Draw the active book without the retained inventory/container screen or its
native screen render hook, and hide that menu's slots from the agent UI.
When the book closes, leave the pointer available to a retained menu.

Add a standalone OpenBook packet proxy and book_menu check to the vanilla
server e2e harness. The check covers exclusive book widgets, page navigation,
return to the retained menu and mouse capture. It requires no container-close
fix and introduces no inventory packet queue.

This recreates the previously stacked book correction on current main,
preserving the gameplay change while isolating its network test setup.

Co-Authored-By: Codex <[email protected]>
2026-11-02 01:16:57 +01:00
Maksymilian AdamkiewiczandCodex b440c53cee Merge origin/main into fix/server-forced-flight
Keep the optional no-default-features probe build on the new horizon-client-lib package.

Co-Authored-By: Codex <[email protected]>
2026-11-02 01:16:27 +01:00
Ludovic MarieandClaude Sonnet 5.5 23d6bc0766 Telemetry server: protected export of benchmark reports and per-machine totals
GET /v1/export/benchmarks (raw reports, paginated by id) and
GET /v1/export/hardware (play sessions per machine description with CPU
threads and cores, paginated by offset). Both need a bearer token compared
in constant time (TELEMETRY_EXPORT_TOKEN), answer 404 while it is unset,
401 without it, gzip on request, and never carry an install ID. Wrong
tokens take from the address's rate limit; right ones do not.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 01:13:42 +01:00
arismoko 610db0d288 Mod API types that can grow are non_exhaustive
The enums and the structs a mod is handed (errors, holders, phases,
panel events, the lifecycle context, the render frame) can gain variants
and fields without breaking a mod. Flow and the structs mods build
(NativeZoom, NativeScreen, AttachmentType...) stay open.
2026-11-02 00:13:18 +00:00
some-du6eandClaude Opus 5.5 2fb130b16c Fusion layouts: say which overlay test pins a guess
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:12:55 -06:00
Ludovic Marie 81e528f925 Merge pull request #6496 from horizon-rust/perf/startup-trace
🤖 Agent: startup trace (HORIZON_STARTUP_TRACE + tools/startup_trace.py), skip-release. Verified: pr_ready on playhorizon (render lib tests --test-threads=1), CI verify green on this head after the rebase.
2026-11-02 01:09:39 +01:00
Ludovic MarieandClaude Opus 5.5 c33c3acf9b Mesher: as_chunks for the opacity rows (clippy)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 01:08:23 +01:00
Ludovic MarieandClaude Opus 5.5 44b03616c1 Mesher: ~21% less CPU per section, same meshes
Measured with chunk_load_bench (seed 1234 village, 4,440 sections, 1,598
with geometry): 265 -> 210 us of CPU per section with geometry, p99 670 ->
530 us, with the same vertices and visibility (checksum afdcd05aec013a23).

- Padded::fill copies the section row by row and reads each border run
  from a section found once (PalettedContainer::get_run), rather than a
  chunk, section and palette lookup per cell.
- Hidden blocks (all six neighbours isSolidRender) are found from an
  opacity bit row per padded row, read from a compact per-state table,
  instead of six lookups into the per-state Info.
- Biome tints keep each cell's colour once computed (a cell is in the blend
  square of up to 25 columns, and swamp grass samples noise), and mix the
  biome zoom's corner jitter once per quart cell (BiomeZoom::corner_jitter
  and select_corner, as generation already does).
- sides::sort sorts one integer key per quad (bucket, plane, index), which
  orders exactly as the stable sort did, in buffers each thread keeps; the
  sorted copy no longer replaces the mesh's recycled buffer.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 01:08:23 +01:00
Ludovic MarieandClaude Opus 5.5 5334979f60 chunk_load_bench: the workers' mesh path, per-section percentiles, parallel throughput
Meshes through Mesher::mesh_reusing with a recycled mesh and scratch, then
sort_solid, as the workers do; reports each section's best-of-rounds CPU time
(p50/p90/p99/max), the vertex bytes, the wall time of N threads meshing at
once, and optionally a JSON summary for A/B scripts. The checksum now covers
the visibility graph too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 01:06:52 +01:00
HrafnaValkyrjaandClaude Opus 5.5 adef15790d Entity macros pass & and &mut, not ref bindings
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 19:05:27 -05:00
Ludovic MarieandClaude Opus 5.5 42f7a5af94 Startup: the title's first frame comes before the world renderer's build
The loading screen built the world renderer on the main thread in the same
pass that swapped in the title artwork, so whenever the world tables were
ready by then the title waited for that build. The handoff now waits for one
presented title frame (or a second, for a window never asked to redraw), and
not at all when a world is waiting to open.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 01:05:24 +01:00
some-du6e ada1b8b1e4 Merge remote-tracking branch 'origin/main' into optifine/ctm-chained 2026-11-01 18:03:50 -06:00
some-du6eandClaude Opus 5.5 1a54e1cc56 Fusion layouts: overlay corners join two sides into one piece
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:03:09 -06:00
HrafnaValkyrjaandClaude Opus 5.5 133408899f Allow ref bindings in the entity match macro
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 19:02:13 -05:00
some-du6eandClaude Opus 5.5 1d20a9db1d OptiFine entity models: animations read blocking, crawling, swimming, held items and more
- is_blocking, is_using_item: the synced living entity flags (and a player's
  blocking item), as the client keeps them
- is_swimming, is_crawling: the swimming pose (a player's or mannequin's
  avatar pose, another mob's DATA_POSE, a drowned's swimming flag), crawling
  out of water
- is_holding_item_right/left: the held items on each arm, by the main arm
- is_swinging_right_arm/left_arm: the current swing's arm, the main arm when
  the swing names no hand
- is_ridden: the rider SetPassengers names
- is_sitting: foxes, pandas and camels from their own flags
- anger_time, height_above_ground, fluid_depth_up and is_wet's rain stay as
  they were, with comments naming what the client lacks

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:00:51 -06:00
some-du6eandClaude Opus 5.5 752049907d OptiFine CTM emissive tiles: blocks keep their overlays first; every tile pairs
Block overlays come before tiles under the shared cap, so a pack's many
tiles can't crowd out vanilla blocks'; any tile named by its file pairs,
not only those under ctm/; the doc comment is back on load_optifine.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:00:28 -06:00
huiandZCode AI agent (GLM-5.3) 6219424298 The recipe book shows a filter's swapped recipe unswapped
update_recipes applied the data's recipe filters (#5910) to its own
copy, and RecipeIndex to its index, so a removed recipe never reached
the book - but book_entries wrote from the network copy, which the
filters never touched: a replace_input or replace_output showed
unswapped in the book's displays, while the property sets and the
stonecutter carried the swapped recipe.

network_recipes now runs the filters once, over its files, before the
ingredient normalization - a swapped name resolves, and every copy
agrees on the swapped recipe. update_recipes parses the already
filtered copy without running them again, so one swap cannot feed a
later one twice.

Fixes #6595.

Co-Authored-By: ZCode AI agent (GLM-5.3)
2026-11-02 00:58:39 +01:00
TiggerTakkyTTTandCodex 3a9559fe3c Add live default game mode to server management
Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-01 23:57:53 +00:00
some-du6eandClaude Opus 5.5 55487cb931 OptiFine entity models: the module doc says a .jem paints the models it reshapes
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:56:24 -06:00
some-du6eandClaude Opus 5.5 6d89cb0195 OptiFine CTM: connected-texture tiles glow with their emissive overlays
A tile with <name><suffix>.png beside it (0.png and 0_e.png) pairs with
its overlay as block sprites do, so emissive glass, lamps and ores keep
glowing when connected textures replace their sprite.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:55:55 -06:00
k3yhandClaude Opus 5.5 3740468b53 Merge main into fix/firework-selectors
main now saves rockets (firework_rocket_to_nbt over RocketSave, Rocket::to_nbt):
drop this branch's own writer and use main's for /data get. A rocket has its id
from the start here, so its chunk save reads its UUID and kept compound by id,
as primed TNT's does, instead of by position.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:55:41 -05:00
HrafnaValkyrjaandClaude Opus 5.5 b12410ff52 Piston walk folds its chain
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 18:54:57 -05:00
Akirabane ab3ed2d7e8 Merge origin/main into fix/6594-fireball-item 2026-11-02 00:54:29 +01:00
Ludovic Marie db34ce484d Merge pull request #6504 from horizon-rust/fix/inline-object-probe-package
🤖 Agent: verify passes, and parity run 41356682661 passes inline_object (555 s) on 7aeddbcf4 merged into main 8d93d68f1. One-line harness fix, skip-release. Queued by pr-sweep-M.
2026-11-02 00:53:03 +01:00
some-du6eandClaude Opus 5.5 9cc9df16b4 Fusion layouts: credit the FusionExamples templates, and say where each layout comes from
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:51:21 -06:00
some-du6eandClaude Opus 5.5 f33fe927a9 Resource packs doc: CTM row review fixes
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:51:19 -06:00
MSedgeMC f693be2363 Boat authority cases: a rider who leaves, waits for the boat to stop, and boards again 2026-11-01 23:50:47 +00:00
some-du6eandClaude Opus 5.5 3237499eab Resource packs doc: connected textures, and why name= rules aren't loaded
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:48:16 -06:00
Ludovic MarieandClaude Opus 5.5 f10487540b Title screen: decode the panorama beside the GUI atlas
The menu bundle the title waits for built the GUI atlas, then decoded the
panorama's six faces after it. They now load side by side.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 00:46:39 +01:00
Ludovic MarieandClaude Opus 5.5 d5e348e8e8 Startup: load the world tables while the handoff's CPU work runs
The world tables (items, recipes, behaviours, mobs...) waited for the game's
own CPU tables and the world renderer's textures, which ran one after the
other on the resource loader. The tail now starts first, and the two
handoff jobs run side by side; a failure still waits for the tail.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 00:46:39 +01:00
Ludovic MarieandClaude Opus 5.5 182fbc30e5 Telemetry: the time to the title is the title's first frame
to_title_ms was the moment the menu bundle was ready, which can be seconds
before the renderer that draws it exists (its pipelines compile on the main
thread first): on llvmpipe it read 0.7-1.6 s for a title drawn after 11-15 s.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 00:46:39 +01:00
Ludovic MarieandClaude Opus 5.5 f57550ec8c Renderer: keep shader modules, layouts and pipelines for the next renderer on the device
The start builds three renderers on one device (the boot screen's, the title
artwork's, the world's), and resource reloads and graphics options build more.
Each compiled every shader module and pipeline again though most come out the
same. gfx/reuse.rs keeps them under their descriptors' Debug text and hands the
same object back; what the latest renderer did not ask for is let go.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 00:46:39 +01:00
Ludovic MarieandClaude Opus 5.5 ef56d07869 Startup trace: keep finish_open_world's line as native_lifecycle's test reads it
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 00:46:23 +01:00
Ludovic MarieandClaude Opus 5.5 54afdf6735 startup_trace.py: scratch folders through lab.game_env
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 00:46:23 +01:00
Ludovic MarieandClaude Opus 5.5 cd593e15b9 Startup trace: HORIZON_STARTUP_TRACE writes the start's steps per thread; tools/startup_trace.py
Every named step of the start (resource tables, the renderer's device and
pipelines, the world's open on the server thread) and its marks (title drawn,
first frame in a world, the view filled, a dimension change) go to a file as
Chrome trace events when HORIZON_STARTUP_TRACE names one. tools/startup_trace.py
starts the game as a player does and prints the timeline.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 00:46:23 +01:00
MSedgeMC 0bf1609f2e Clear seven ledger rows that main already implements
The piglin brute's two activity sounds, the three ChatListener lines behind Chat Delay, the
angry enderman's render offset and the tipped arrow's potion colour are all in Horizon; the
rows still said missing.
2026-11-01 23:45:25 +00:00
some-du6eandClaude Opus 5.5 9563b41599 OptiFine CTM orient=texture: no directions on planes off the block's axes
A plant's 45° plane snapped its texture's right along the face normal;
directions now need to lie within about 25° of a block axis.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:44:44 -06:00
TeiwazikandClaude Opus 5.5 ffba053a6a Shader API: particles as coloured lights
[[particle_lights]] names particles, with a colour and reach, that join the frame's coloured lights
at their position, with their alpha as intensity. MaterialInput.colored_light gives those lights at
a point to the material stage, which could not read them before.

Fixes #6584

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-02 00:44:36 +01:00
Akirabane b0b5c25998 Merge origin/main into fix/6594-fireball-item 2026-11-02 00:44:29 +01:00
AkirabaneandClaude Sonnet 5.5 9de27c4e08 A saved fireball carries its Item, as vanilla's Fireball does
Fireball.addAdditionalSaveData always writes Item, a fire charge by default,
and readAdditionalSaveData reads it back through setItem, which refuses an
empty stack and keeps a single one. Horizon wrote only the flight, so a
saved fireball or a /data get entity had no Item and a loaded one was lost.
fireball_to_nbt now writes it for the large and the small fireball (not the
wind charges, which are no Fireball), from the loaded item when there is one;
the projectile's loaded Item is kept beside its Tags. docs/server.md names
the control request RCON uses since #6443.

Fixes #6594

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 00:44:28 +01:00
MSedgeMC d7469b82c6 Advancement command parity cases: subtrees, single criteria, everything on no target 2026-11-01 23:43:37 +00:00
MSedgeMC dd84588b53 Merchant parity cases: the exact restock gaps and the trade price floor 2026-11-01 23:43:27 +00:00
MSedgeMC b506983946 Recipe book parity cases: empty batches, forgotten highlights, every book's settings 2026-11-01 23:43:19 +00:00
some-du6eandClaude Opus 5.5 daa63d0fa7 Fusion layouts: pin more template tiles, and one rule for rows and columns
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:43:12 -06:00
some-du6eandClaude Opus 5.5 a2f0a5baa8 OptiFine entity models: let the chest arm auto-deref its shapes (clippy)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:41:20 -06:00
some-du6eandClaude Opus 5.5 8057c41c06 OptiFine CTM orient=texture: review fixes, merged with the chained-tile branch
Panes, whose tiles draw with the cube's UVs, read those as their texture;
plain overlay keeps the standard directions (Continuity's orient leaves it
out); Axes::texture refuses NaN and quads turned off the block's axes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:40:49 -06:00
MariuslozoandClaude Sonnet 5.5 dd428288d1 A TextAndIcon button's label stays clear of its icon; tab labels stay inside their tab
SpriteIconButton.TextAndIcon.extractContents gives the label left = x + 2,
right = x + width - spriteWidth - 4 and centerX = x + width / 2, and
ActiveTextCollector.defaultScrollingHelper centres a label that fits on
Mth.clamp(centerX, left + lineWidth / 2, right - lineWidth / 2). Title's
Button::draw_label used x + 2 .. x + width - 2 for every button, so on the
first-launch Accessibility screen a label that fits (fil_ph "Settings sa
Aksesibilidad...", about 30 languages) ended under the icon and a wider one
(fr_ca, about 35 languages) slid under it.

ui::scrolling_text_around_at takes the centre; scrolling_text_at is the
default centre (left + right) / 2 and draws as before. Button::draw_label_at
passes the icon's room and the button's middle.

MenuTabBar.MenuTabButton.renderLabel hands the tab's label to
acceptScrollingWithDefaultCenter with a 1 px margin, its box 3 px lower on an
unselected tab, gray only on an inactive tab (WithInactiveMessage). The Create
World tabs drew it unclipped, so a label wider than its tab ran into the next
one; the Statistics tabs did too, drew every label at the selected tab's
height and grayed the active unselected ones. Both draw through
ui::menu_tab_label now.

The port ledger rows of acceptScrolling, RenderingTextCollector.acceptScrolling,
acceptScrollingWithDefaultCenter, defaultScrollingHelper,
extractScrollingStringOverContents, the slider's extractWidgetRenderState,
TextAndIcon.extractContents (it has a caller: the Accessibility screen) and
MenuTabButton.renderLabel say what is drawn now.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RodDbPWCByh96WKcbiEQpB
2026-11-02 00:40:40 +01:00
MariuslozoandClaude Opus 5.5 78a634a191 Localization: sign edit's Done, the AI Agent page, the newer graphics settings and the Extras page follow the language (#1927)
Vanilla screens already read the selected language (creative inventory with every tab, its
search, survival and hotbar tabs and the Destroy Item tooltip; pause, chat, death, crafting,
furnace, anvil, enchanting, brewing, trading, advancements, statistics, world select and
Create World were checked in de_de, pl_pl and ja_jp). What was left:

- Sign edit's Done button was a hard-coded "Done": now `gui.done` (also for its narration).
- The AI Agent page's four status lines were English sentences built in code: now
  `horizon.agent.*` keys, with the connection count and the time since the last command as
  arguments (`status_lines`, a function of the socket's state, so a test reaches it).
- Leaf Culling, Frame Generation, Shadow Cache, Screenshot Quality, Distant Plant Dither, the
  Culling header, the Synchronous/Asynchronous and Leaf Culling values, their descriptions and
  the controller binds' "Hotbar: Previous/Next Slot" were English keys that `en_us.json` did not
  have, so every language showed English. They are in the table now. The Entity Culling
  description's old key had drifted from the code; it is replaced. Translucent Batching, its
  description and the GPU reason that blocks it get their translations too.
- 17 Extras and Splitscreen keys added to en_us.json after de/pl/uk/fr were written had no
  translation there: added (Particles, Hide block-break particles, Smooth chat, Message
  indicator, Keep channels for UI sounds, Splitscreen).
- zh_cn gets the new keys too. es_es, ru_ru and tr_tr still lack about 130 keys each.

`lang::scoped` runs a closure with a language that only that thread sees and returns the keys
it asked for and the language did not have, so tests render in another language without
touching the process-wide one. The tests: every text of the three graphics pages is a key of
the table; the Horizon options pages in de_de and pl_pl; the agent page and the Done button in
de_de, pl_pl, fr_fr and en_us.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RodDbPWCByh96WKcbiEQpB
2026-11-02 00:40:16 +01:00
MariuslozoandClaude Opus 5.5 3c0a4db876 A player's attributes stay through a respawn and a change of level (#3684)
The server made a new body for a player from the defaults when they died and
respawned or went to another level, so /attribute @s minecraft:scale base set
0.1 was 1.0 again (box, eye height and reach as well), and so was every other
attribute a command or a datapack had set. Vanilla keeps the same ServerPlayer
through teleport, and ServerPlayer.restoreFrom copies the base values to the
body after a death (assignBaseValues). The old body's attributes now go to the
next one (with their permanent modifiers for a level change, base values only
after a death), and a player saved between two bodies writes them to the file.

restoreFrom then gives the respawned player its max health
(setHealth(getMaxHealth())): a max health base of 40 comes back with 40, not
with the fixed 20 the respawn set.

Also: a glide the client announced before the body's own tick used a
scale-1 box for that tick (Guest::dimensions); it takes the body's scale. The
port map's LivingEntity.getScale row still said there was no scale attribute.

Tests: the respawn health, the glide box, the carried attributes, a save
between bodies; lan_e2e check scale-body (host and guest: the Nether and back,
a death and respawn, the health after it).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RodDbPWCByh96WKcbiEQpB
2026-11-02 00:39:35 +01:00
Ludovic Marie 8d93d68f1d Merge pull request #6460 from some-du6e/fix/fireworks-remote
🤖 Agent: queued: reviewed, union-tested green at this head.
2026-11-02 00:36:13 +01:00
Ludovic Marie cf2db82999 Merge pull request #6508 from MSedgeMC/fix/boat-paddle-rider
🤖 Agent: queued: reviewed, union-tested green at this head.
2026-11-02 00:36:09 +01:00
some-du6e 5c2a3cc945 Merge branch 'optifine/ctm-chained' of https://github.com/some-du6e/horizon into optifine/ctm-chained 2026-11-01 17:36:05 -06:00
Ludovic Marie b5d9d1aeb4 Merge pull request #6448 from oxi-does-dev/fix-remote-use-swing
🤖 Agent: queued: reviewed, union-tested green at this head.
2026-11-02 00:36:04 +01:00
Ludovic Marie 5dfae6950b Merge pull request #6453 from MSedgeMC/fix/boat-fluid-current
🤖 Agent: queued: reviewed, union-tested green at this head.
2026-11-02 00:35:59 +01:00
some-du6e 8086d137e5 Merge remote-tracking branch 'origin/main' into optifine/ctm-chained
# Conflicts:
#	crates/horizon-client-render/src/connected.rs
2026-11-01 17:35:41 -06:00
some-du6eandClaude Opus 5.5 c3911b7608 OptiFine entity models: a shared .jem paints only the models it reshapes
A conduit.jem that reshapes the eye alone no longer paints the shell,
cage and wind, whose vanilla geometry keeps its texture's layout. The
part-name test gives every entry a box of its own, so swapped names show.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:35:38 -06:00
wxundandCodex gpt-6.1-sol e3e8af585e Merge origin/main after native server export landed
Preserve the maintainer merge and current main while adding macOS native
microphone purpose metadata as a separate follow-up.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-01 18:34:04 -05:00
00ee928306 Declare native-mod microphone usage in the macOS game bundle
Localize and XML-escape the microphone purpose string without requesting
permission or opening a device. Reuse the existing XML escape helper.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
Co-Authored-By: OpenCode GLM 5.3 <[email protected]>
Co-Authored-By: OpenCode Nemotron 3.5 Super VL <[email protected]>
2026-11-01 18:32:58 -05:00
Ludovic Marie a755b863e0 Merge pull request #6482 from Qwerner88880/hooks-holder-set
🤖 Agent: queued: reviewed, union-tested green at this head.
2026-11-02 00:32:31 +01:00
Ludovic Marie 026f3125f4 Merge commit '51946c9e4585a2e7f2e4af01af893fb6c1cfad0e' into s6179
# Conflicts:
#	crates/horizon-client/src/game/scoreboard.rs
2026-11-02 00:30:38 +01:00
Ludovic Marie 6ed2d97cda Merge pull request #6465 from HrafnaValkyrja/fix/portals-entity-kinds
🤖 Agent: queued: reviewed, union-tested green at this head.
2026-11-02 00:30:35 +01:00
HrafnaValkyrjaandClaude Opus 5.5 a651e476a3 Entity walk: static dispatch, no allocation
entity_handles is now a plain iterator and with_entity! matches to the concrete type, so a walk has no vtable and no Vec.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 18:30:28 -05:00
some-du6eandClaude Opus 5.5 b418ea15f0 OptiFine CTM: Continuity's orient=texture
A rule with orient=texture finds a face's left, right, up and down from
the quad's own texture (Axes::texture: the UV gradients of its corners,
snapped to the block's axes), so turned or mirrored models connect along
their texture. OptiFine's defaults are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:30:11 -06:00
Ludovic MarieandClaude Opus 5.5 69c11117a6 Merge main into #6578
Both tests kept: the chained tiles and #6450's stacked panes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 00:29:47 +01:00
MSedgeMC 0cfd1d821c Chat screen cases: long values with codes, scrolled and selected 2026-11-01 23:29:41 +00:00
Ludovic MarieandClaude Opus 5.5 5fe8c6defa Merge main into fix/6109-shield-partial-knockback
#6381 landed the same order (the shield's push before the hit's) in creatures.rs:
main's version is kept; this branch keeps its two server_knockback cases, which
pin that order for a half-blocking shield.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 00:28:53 +01:00
some-du6e 5b47e1f45b Merge main into optifine/emf-block-entities
# Conflicts:
#	docs/resource-packs.md
2026-11-01 17:28:49 -06:00
Ludovic Marie 51946c9e45 Merge pull request #6471 from senjinthedragon/hdr-underwater
🤖 Agent: queued: reviewed, union-tested green at this head.
2026-11-02 00:28:22 +01:00
Ludovic Marie 66397a963e Merge pull request #6480 from MSedgeMC/fix/split-pad-join
🤖 Agent: queued: reviewed, union-tested green at this head.
2026-11-02 00:28:18 +01:00
Ludovic MarieandClaude Opus 5.5 340dfd1d3e Worldgen writes deep under a heightmap's surface skip its update
Heightmap::update returns at once for a block two or more below the
column's first available height, but set_block_worldgen first checked the
state, the chunk bounds and went through Results for each heightmap of
each write. The surface rule rewrites most stone under y 8 to deepslate:
that cost about 14% of a proto chunk. The skipped path reads the packed
height directly and still rejects unknown states. Same heightmaps
(test: random writes against updating every map each write).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 00:27:23 +01:00
arismoko 246f47f20d Hooks take one event struct
Each trait hook now takes its host, where it has one, and one
generated #[non_exhaustive] <Hook>Event holding the hook point's
arguments, as NeoForge's events do: a later version can add a field
without breaking any mod. A number or bool a mod may change is a plain
field, written back once every mod has run, even after a cancel; other
mutable arguments stay &mut fields. One event goes through every mod,
so each sees the changes before it. Cancel and replace outputs stay the
return value. Dispatch signatures and the native_* hook points are
unchanged. Trait injections get the same pattern, their bindings
becoming the event's fields.
2026-11-01 23:27:23 +00:00
Ludovic MarieandClaude Opus 5.5 e431c279f4 Boats tick in turn even when a pass removes one
boat_server_passes runs inside the loop now, and lava breaks a boat
there: a for loop over the indexes skipped the boat that moved into the
broken one's place. The next boat is found after this one by its id.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 00:26:34 +01:00
Ludovic Marie 06f842dc3b Merge pull request #6481 from HrafnaValkyrja/fix/nogravity-entities
🤖 Agent: queued: reviewed, union-tested green at this head.
2026-11-02 00:26:25 +01:00
arismoko d7b307aed5 Merge main (3310fcafb9) into the mod API
Main brings the dedicated server export (#6462). Trait mods build for
the server target too: the generated game package owns horizon-client's
default features, so the server build's --no-default-features reaches
it, and no mod's API package turns them back on. The build key keeps
hashing the mods in bundle order, which decides trait dispatch order.
The launcher reference gains main's --server-output.
2026-11-01 23:25:54 +00:00
Ludovic Marie 9feaec5cda Merge pull request #6582 from horizon-rust/perf/baseline-20x-mac
🤖 Agent: perf-baseline-20x, Mac M4 Pro perf_bench reports (data only, loaded machine, skip-release); pr_ready --quick green (595 Python tests).
2026-11-02 00:25:47 +01:00
Ludovic Marie fc3456815f Merge pull request #6450 from some-du6e/optifine/ctm-panes
🤖 Agent: queued: reviewed, union-tested green at this head.
2026-11-02 00:25:43 +01:00
Ludovic Marie 3496fe24f7 Merge pull request #6455 from NerfPhaselock/perf/translucent-batching-switch
🤖 Agent: queued: reviewed, union-tested green at this head.
2026-11-02 00:25:38 +01:00
MSedgeMC 0a0c2fd5f1 Take a seat instead of sweeping to it
Vanilla puts a rider on its seat with Entity.moveTo, which sets the old position as well, so
the camera cuts to the cushion, boat or cart. Horizon kept the position the player stood at
as the previous one, so the first ridden tick drew the camera flying from there to the seat.
2026-11-01 23:25:36 +00:00
Ludovic Marie 0187d1e47e Merge pull request #6474 from Akirabane/fix/5768-bubble-column-particles
🤖 Agent: queued: reviewed, union-tested green at this head.
2026-11-02 00:25:34 +01:00
Ludovic Marie cd5d7c5c15 Merge pull request #6468 from HrafnaValkyrja/fix/selectors-entity-kinds
🤖 Agent: queued: reviewed, union-tested green at this head.
2026-11-02 00:25:30 +01:00
Ludovic Marie f9cc7072ae Merge pull request #6495 from xFedeT/fix/knockback-hook-before-resistance
🤖 Agent: queued: reviewed, union-tested green at this head.
2026-11-02 00:25:26 +01:00
some-du6eandClaude Opus 5.5 4fbeab00ae Fusion packs: which tiles of a connecting texture a face draws
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:25:11 -06:00
some-du6eandClaude Opus 5.5 88f4b82bfe OptiFine CTM chained tiles: rules after the pick still chain
Only rules before the one that picked were tried on the face's sprite;
later ones naming the picked tile chain as any other.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:24:43 -06:00
Ludovic Marie 3310fcafb9 Merge pull request #6451 from xFedeT/fix/hand-sway-yaw-wrap
🤖 Agent: queued: reviewed, union-tested green at this head.
2026-11-02 00:24:21 +01:00
Ludovic Marie 64d470d9f7 Merge pull request #6461 from xFedeT/fix/world-change-pose
🤖 Agent: queued: reviewed, union-tested green at this head.
2026-11-02 00:24:17 +01:00
Ludovic Marie 12af237df8 Merge pull request #6466 from Domanffe/fix/world-save-errors
🤖 Agent: queued: reviewed, union-tested green at this head.
2026-11-02 00:24:12 +01:00
Ludovic Marie 20ba9cbff2 Merge pull request #6476 from MSedgeMC/fix/lore-tooltip-styles
🤖 Agent: queued: reviewed, union-tested green at this head.
2026-11-02 00:24:09 +01:00
Ludovic Marie 9d4d1596e3 Merge pull request #6507 from anweisen/fix/chat-word-selection
🤖 Agent: queued: reviewed, union-tested green at this head.
2026-11-02 00:24:05 +01:00
Ludovic Marie 9e80c04264 Merge pull request #6470 from martipops/fix/pad-sprint-heal
🤖 Agent: queued: reviewed, union-tested green at this head.
2026-11-02 00:24:01 +01:00
Ludovic Marie 3ff9ae500b Merge pull request #6477 from MSedgeMC/fix/tooltip-display-table
🤖 Agent: queued: reviewed, union-tested green at this head.
2026-11-02 00:23:57 +01:00
Ludovic Marie 23c97e3d5a Merge pull request #6463 from Domanffe/fix/capture-warp-once
🤖 Agent: queued: reviewed, union-tested green at this head.
2026-11-02 00:23:53 +01:00
Ludovic Marie 901a47524c Merge pull request #6457 from some-du6e/optifine/etf-blocks-glow
🤖 Agent: queued: reviewed, union-tested green at this head.
2026-11-02 00:23:49 +01:00
Ludovic Marie 6a7eebd2b8 Merge pull request #6454 from some-du6e/optifine/etf-followups
🤖 Agent: queued: reviewed, union-tested green at this head.
2026-11-02 00:23:45 +01:00
Ludovic Marie 4067d11777 Merge pull request #6428 from MSedgeMC/fix/ui-single-biome
🤖 Agent: queued: reviewed, union-tested green at this head.
2026-11-02 00:23:41 +01:00
Ludovic Marie 336b0cd1a1 Merge pull request #6422 from rdbtCVS/mem-panorama-on-title
🤖 Agent: queued: reviewed, union-tested green at this head.
2026-11-02 00:23:37 +01:00
Ludovic Marie 60d5ec63aa Merge pull request #6307 from Detimes/feat-player-game-mode-hook
🤖 Agent: queued: reviewed, union-tested green at this head.
2026-11-02 00:23:33 +01:00
Ludovic Marie 50f98ecb33 Merge pull request #6443 from Akirabane/feat/6393-rcon-source
🤖 Agent: queued: reviewed, union-tested green at this head.
2026-11-02 00:23:29 +01:00
some-du6eandClaude Opus 5.5 7a39319074 OptiFine entity models: ender, trapped and copper chests keep vanilla's shape without their own .jem
- Trapped and ender chests read only their own files (cem_model.txt lists them with no
  fallback); copper chests have no CEM name and always draw vanilla's chest.
- An entry whose name isn't OptiFine's for the model reaches the vanilla part of that name,
  as mobs do; it is skipped only when neither exists.
- Tests: every OptiFine part name of the block entity tables lands on its vanilla part,
  book.jem reaches the lectern's book, a copper chest draws vanilla's geometry and UVs beside
  a chest.jem, and only entries no model has a part for are warned about.
- jem_files' precedence test moves next to it in horizon-optifine; read_small is private.
- docs/resource-packs.md names both test modules.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:23:25 -06:00
Ludovic Marie 82309edd06 Merge pull request #6442 from xFedeT/fix/code-item-remainder
🤖 Agent: queued: reviewed, union-tested green at this head.
2026-11-02 00:23:25 +01:00
Aaron WangandGPT-6.1 Sol 873ccc1ecf fix(mobs): let farmers tend registered mod crops
Recognize farmland and crop templates through their retained class chains and plant loaded tagged block items in inventory order. Add vanilla-oracle classification and real content-root regressions.

Co-Authored-By: GPT-6.1 Sol <[email protected]>
2026-11-01 18:22:33 -05:00
Ludovic MarieandClaude Opus 5.5 0bb6a370c3 Benchmarks: Horizon main vs vanilla 26.3 on a loaded Mac mini M4 Pro
perf_bench reports for Horizon main 1bd24e4042, vanilla 26.3 and vanilla with
Sodium, Lithium, FerriteCore and ImmediatelyFast at render distances 8, 16 and
32, 1920x1080, three games each, with a page on why they are not player
numbers: the Mac ran at load 15-114, every game ran offscreen, and vanilla's
window was shrunk to 1920x930 by the screen.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 00:22:33 +01:00
Federico TaglianiandClaude Sonnet 5.5 989eb8a0be Native mods: native_state_block names the block a state belongs to
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 00:22:17 +01:00
some-du6eandClaude Opus 5.5 048f3b6e87 OptiFine CTM chained tiles: no rerun of rules already tried
The second pass skips rules tried on the face's own sprite (an overlay
naming both drew twice), and doesn't run when the picked tile is that
sprite itself (tiles=stone).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:22:03 -06:00
Ludovic Marie 82f158ae47 Merge pull request #5917 from kacpherek/perf/tracking-chunk-index
🤖 Agent: verified by perf-kacper-1: requeued after merging main (lan/entity.rs conflict: contains → contains_key for main's tracker map). clippy and the lan tests pass on the merged tree. Server bench before: no MSPT regression (50 bots 6.11 → 6.18, 100 bots 9.88 → 10.27, noise). Parity passed on the previous head.
2026-11-02 00:21:05 +01:00
Ludovic MarieandClaude Opus 5.5 2704394355 Merge main into perf/tracking-chunk-index
Main's #6231 work turned the tracker's seen set into a map: the chunk-index
loop now asks contains_key.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 00:19:00 +01:00
SamSuandKimi 6e55b944e3 Add physical window size native helper
Co-Authored-By: Kimi <[email protected]>
2026-11-02 06:18:30 +07:00
some-du6eandClaude Opus 5.5 6fdeef19a7 OptiFine CTM: a picked tile can be matched by another rule
ctm.properties: "Tiles output by CTM rules can also be matched by another
rule" (matchTiles=optifine/ctm/mygrass/1.png). After a rule picks a tile,
the tile rules naming that tile (and this block, if they name blocks) are
tried once more; <default> there keeps the first pick.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:18:14 -06:00
some-du6e 65cfa74249 Merge branch 'optifine/cit-glint-rules' into optifine/cit-glint 2026-11-01 17:18:09 -06:00
Federico TaglianiandClaude Sonnet 5.5 b4b5281ff8 A mod menu button runs as the player who pressed it
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 00:17:41 +01:00
some-du6e 205f2560b5 Merge branch 'optifine/cit-armor' into optifine/cit-glint-rules 2026-11-01 17:17:26 -06:00
some-du6e 4a6267b9c2 Merge branch 'optifine/cit-items' into optifine/cit-armor 2026-11-01 17:16:40 -06:00
some-du6eandClaude Opus 5.5 56e5ef6f01 OptiFine CIT: a sub-model swap has no untinted texture
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:15:43 -06:00
Ludovic Marie c9d49d4328 Merge pull request #6500 from horizon-rust/perf/solid-pass-parts
🤖 Agent: verified by perf-kacper-1: verify green, pr_ready --quick green (979 render tests), retargeted to main after #6499, mergeable. A/B on the Mac mini M4 Pro at RD 32 + far 256 (100 fps cap): GPU 5.66 → 4.21 ms (pre-#6005: 4.77), CPU +0.19 ms; RD 12: GPU 1.25 → 0.97 ms, CPU 0.65 → 0.63 ms. Screenshots identical to pre-#6005 apart from clouds, mobs, water and crops moving. Metal only; other backends keep one solid pass.
2026-11-02 00:14:45 +01:00
HrafnaValkyrja 968a1b1475 Merge remote-tracking branch 'origin/main' into refactor/stable-entity-ids 2026-11-01 18:14:08 -05:00
HrafnaValkyrjaandClaude Opus 5.5 c6dcaa6b46 Take entity ids at creation for bolts, thrown potions, clouds, fireballs, eyes, tridents
These kinds were pushed with id 0 and got one only when a LAN guest was
connected. They now draw it from the one counter when created, as vanilla's
Entity constructor does. Part of #6537.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 18:13:54 -05:00
Ludovic Marie 7a319ed7bc Merge pull request #6499 from horizon-rust/perf/fade-idle-skip
🤖 Agent: verified by perf-kacper-1: verify green, pr_ready --quick green (975 render tests), A/B on the Mac mini M4 Pro at RD 32 + far 256: CPU 2.91 → 2.88 ms, GPU 4.45 → 4.39 ms; RD 32 without far terrain CPU 1.61 → 1.54 ms. No visible change (the fade work it skips is a no-op while nothing fades).
2026-11-02 00:12:55 +01:00
some-du6eandClaude Opus 5.5 5f75afdbd0 OptiFine entity models: the block entity test scene has no emissive overlays
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:10:21 -06:00
some-du6e 988167a17a Merge branch 'optifine/cit-glint-rules' into optifine/cit-glint 2026-11-01 17:10:01 -06:00
MSedgeMC eb94a3af37 Tell native mods when the server's difficulty changes
MinecraftServer.setDifficulty is the one door /difficulty, a dedicated
server's management and a test environment's difficulty all go through, and
nothing told a mod about it. native_difficulty_changed(from, to) now runs
there, after the change, with the two difficulty names, and only when the
difficulty is another one.
2026-11-01 23:09:54 +00:00
some-du6e 5a28700eb4 Merge branch 'optifine/cit-armor' into optifine/cit-glint-rules 2026-11-01 17:09:29 -06:00
Federico TaglianiandClaude Sonnet 5.5 e30335f719 Server list: Up and Down select, Shift moves the server
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 00:09:18 +01:00
some-du6e 3dc01cdcb4 Merge branch 'optifine/cit-items' into optifine/cit-armor 2026-11-01 17:08:59 -06:00
some-du6eandClaude Opus 5.5 53768a814b OptiFine CIT: a plain texture is drawn once, untinted, so maps and firework stars show the pack's own colours
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:08:26 -06:00
Ludovic Marie fe12948459 Merge pull request #6462 from wxundup/feat/native-dedicated-export
🤖 Agent: requeued after merging main (language tables: main's plus this PR's ten strings); launcher job green on this head.
2026-11-02 00:08:18 +01:00
MSedgeMC d02c07cde6 A text field draws its value as typed, § codes included 2026-11-01 23:07:17 +00:00
Federico TaglianiandClaude Sonnet 5.5 1446ced385 Tab walks the edit-server and friend screens through their buttons
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 00:06:47 +01:00
Akirabane f30f3d8402 Merge origin/main into feat/6524-furnace-fuel-remainder 2026-11-02 00:01:05 +01:00
AkirabaneandClaude Sonnet 5.5 92967740a6 A furnace fuel leaves its crafting remainder, not only a bucket
AbstractFurnaceBlockEntity.consumeFuel takes the fuel item's own crafting
remainder: it takes the fuel slot once the stack is used up, and is dropped
at the furnace while more of the fuel is left. furnace.rs only knew the lava,
water and milk buckets by name, so a content mod's fuel with a
crafting_remainder burnt without leaving it. The remainder now comes from the
items table (the buckets carry theirs there), and Furnace::tick hands the
dropped ones to its caller, which drops them with Containers.dropItemStack's
helper.

Fixes #6524

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 00:00:57 +01:00
ClickingMasterandClaude Opus 5.5 755d2215c5 /data get entity @s prints the acting player's own UUID, not the world owner's
entity_nbt_in_level stamped UUID from level.player_uuid, the world owner's,
on a guest's turn too: on the server thread every player is a guest, so
/data get entity @s UUID gave every player offline_uuid("Player"). Use the
acting guest's UUID, as acting_uuid does. lan_e2e data_uuid checks the
guest and the host.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:00:41 -08:00
Ludovic MarieandClaude Opus 5.5 c53bea9730 Receive path: one buffer read into in place, one inflater, block pipe reads
ConnReader read 16 KB at a time into the stack, appended it, copied each
frame out, drained the rest of the buffer to the front for every packet,
and copied the body again; each compressed packet built a new inflate
state. Reads now go straight into the buffer behind what is pending, a
frame is consumed by moving an offset, its payload is copied once, and
one flate2 Decompress is reset per packet. The in-memory pipe (single
player) copies with VecDeque's slices instead of byte by byte.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 23:59:59 +01:00
Federico TaglianiandClaude Sonnet 5.5 4e19829015 Tamed wolves defend and help a guest owner: keep the guest's last attacker and victim in their view
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 23:59:00 +01:00
MSedgeMC 234a17bd63 Fire the item crafted hook when a crafting result is taken
ResultSlot.onTake is where vanilla awards a craft, so the crafting grid's take
now tells native mods: native_item_crafted(player, recipe, stack) runs with the
player's uuid, the recipe the grid matched and the stack taken, on the
integrated server and a dedicated server, guests included. The menu records the
recipe and stack for a guest's click; the host's click fires it directly, once
per craft a shift-click takes.
2026-11-01 22:56:17 +00:00
Ludovic MarieandClaude Opus 5.5 f632e8c4af Merge main into #6462
The language tables keep main's and add this PR's ten new strings.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 23:55:10 +01:00
Detimes 5cd3a00276 Native mods: a player's fishing rod, cast to reel-in, cancellable (native_player_fish) 2026-11-01 22:53:25 +00:00
HrafnaValkyrjaandClaude Opus 5.5 918e2a372d Add an Entity trait and move pistons onto it
First slice of #6509, no behaviour change. A new kind is one line in entity_lists! and one impl Entity.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 17:52:58 -05:00
Federico TaglianiandClaude Sonnet 5.5 4673402d9b Atlas layout: fall back to first-fit shelves before giving up on a pack
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 23:48:53 +01:00
arismoko 99fcc59bf6 Merge remote-tracking branch 'upstream/main' into v2/ses_01M6BECNQ499TYMMBQBDF00VJ5-mod-api 2026-11-01 22:45:06 +00:00
Ludovic Marie f8bfa06b90 Merge pull request #6501 from josegil1909/fix/launcher-untranslated-strings
🤖 Agent: queued: reviewed, launcher CI green on this head.
2026-11-01 23:43:44 +01:00
Ludovic Marie 05a6f766e5 Merge pull request #6475 from Domanffe/fix/launcher-account-save-error
🤖 Agent: queued: reviewed, launcher CI green on this head.
2026-11-01 23:43:40 +01:00
Ludovic Marie cccf1363dd Merge pull request #6478 from Domanffe/fix/launcher-settings-atomic
🤖 Agent: queued: reviewed, launcher CI green on this head.
2026-11-01 23:43:36 +01:00
Ludovic Marie d63d23327f Merge pull request #6483 from Domanffe/fix/launcher-instances-save-error
🤖 Agent: queued: reviewed, launcher CI green on this head.
2026-11-01 23:43:33 +01:00
Federico TaglianiandClaude Sonnet 5.5 c50a0813b3 A trident's impact hook gets the hit location, not the start or the backed-off stop
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 23:43:30 +01:00
Detimes e8b62addbe Native mods: a player's item losing durability, changeable (native_item_damaged) 2026-11-01 22:41:32 +00:00
Ludovic MarieandClaude Opus 5.5 7482479b19 Merge #6392 (main merged) into #6458
Both kept: the CIT glint state and HDR's item tiers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 23:40:42 +01:00
Ludovic MarieandClaude Opus 5.5 198c640a26 Merge #6358 (main merged) into #6392
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 23:40:15 +01:00
Ludovic MarieandClaude Opus 5.5 23edb9108b Merge #6334 (main merged) into #6358
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 23:40:15 +01:00
HrafnaValkyrjaandClaude Opus 5.5 2ca605090c Merge origin/main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 17:39:32 -05:00
Ludovic MarieandClaude Opus 5.5 67eeb06049 Splitscreen: waiting players claim a pressed controller before P1 does
claim_for_lost runs first in a world, so a controller that comes back
still takes back its own player (or the one waiting longest) when P1
waits too; only what it passes on, with P1 the one waiting, goes to P1.
The extras doc names Y (play as P1).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 23:39:03 +01:00
HrafnaValkyrja b86f3f517f Merge remote-tracking branch 'origin/main' into fix/plates-remaining-kinds 2026-11-01 17:38:15 -05:00
HrafnaValkyrjaandClaude Opus 5.5 99b02b99c8 Plates and tripwires count lightning bolts
A lightning bolt does not override Entity.isIgnoringBlockTriggers (bytecode: only Marker, OminousItemSpawner, Display, ArmorStand, Interaction and Bat do), so BasePressurePlateBlock.getEntityCount and TripWireBlock.checkPressed count it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 17:38:05 -05:00
Federico TaglianiandClaude Sonnet 5.5 2e6f8a98fb A boat's EntityTickEvent.Post follows its own tick, before the next boat's
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 23:37:52 +01:00
Ludovic Marie f66168aa25 Merge pull request #6423 from rdbtCVS/mem-free-blur-outline-targets
🤖 Agent: requeued after the runner's dead .horizon-prepare.lock link was cleared and parity rerun (same head).
2026-11-01 23:36:57 +01:00
Ludovic Marie 14d9cb5d8d Merge pull request #6421 from rdbtCVS/mem-chunk-light-layers
🤖 Agent: requeued after the runner's dead .horizon-prepare.lock link was cleared and parity rerun (same head).
2026-11-01 23:36:53 +01:00
Ludovic Marie 4dc0a456dc Merge pull request #6420 from rdbtCVS/mem-shared-face-visibility
🤖 Agent: requeued after the runner's dead .horizon-prepare.lock link was cleared and parity rerun (same head).
2026-11-01 23:36:49 +01:00
Ludovic Marie 185bc0b3e3 Merge pull request #6419 from rdbtCVS/mem-vram-memory-hints
🤖 Agent: requeued after the runner's dead .horizon-prepare.lock link was cleared and parity rerun (same head).
2026-11-01 23:36:45 +01:00
Ludovic Marie 0bf9d01153 Merge pull request #6404 from cristibctr/codex/fix-4572-action-latency
🤖 Agent: requeued after the runner's dead .horizon-prepare.lock link was cleared and parity rerun (same head).
2026-11-01 23:36:42 +01:00
Ludovic Marie f81b809ae9 Merge pull request #6394 from MSedgeMC/chore/port-map-stale-rows
🤖 Agent: requeued after the runner's dead .horizon-prepare.lock link was cleared and parity rerun (same head).
2026-11-01 23:36:38 +01:00
Ludovic Marie b3befb195b Merge pull request #6407 from breachato/fix/6366-projectile-impact-gaps
🤖 Agent: merging "A player's thrown trident fires the projectile impact hook" (head 05c7ec158) after review by Ludovic's AI agent; thank you, @breachato.
2026-11-01 23:36:34 +01:00
Senjin 0df0b4d191 Merge remote-tracking branch 'origin/main' into hdr-polish
# Conflicts:
#	docs/graphics.md
2026-11-01 23:36:32 +01:00
Ludovic Marie ff1dd85a1c Merge pull request #6406 from breachato/fix/6365-mount-hook-gaps
🤖 Agent: merging "Fill three gaps in the native entity mount hook" (head 3d8c14eb0) after review by Ludovic's AI agent; thank you, @breachato.
2026-11-01 23:36:30 +01:00
Ludovic MarieandClaude Opus 5.5 cfc5c627bb Drop the memory slider's language test
It set the launcher's process-wide language (Russian, Spanish) while other
launcher tests run in parallel and read tr(), and it checked a copy of
the view's match rather than the view.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 23:35:37 +01:00
Ludovic MarieandClaude Opus 5.5 bc22eb04e7 Merge main into #6471
main's hand_light_view is the same view as kept_world_view: the tint pass
uses it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 23:34:36 +01:00
Ludovic Marie bcf163d11d Merge pull request #6164 from some-du6e/mods/fire-odds
🤖 Agent: verify passes on 5107acc19; parity 39 passed. The 3 failures are not this PR's: lunge_motion (known flake), inline_object (main's harness still builds client_probe with -p horizon-client since the client-lib split) and pack_screen (oracle errors: lang files missing from the runner's prepared resources). Queued by pr-sweep-M.
2026-11-01 23:32:14 +01:00
Federico TaglianiandClaude Sonnet 5.5 b7e01926a2 Native mods: native_server_event posts an announce event the Discord bridge relays
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 23:31:46 +01:00
Ludovic MarieandClaude Opus 5.5 6edff51558 The knockback hook test and docs follow the hook's new place
The hook now sees the raw strength: a 0.5 resistance hands it 0.4, and a
full resistance stops whatever push the hook leaves, as NeoForge's
LivingKnockBackEvent does. API 125's paragraph and the hook's doc say so.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 23:31:25 +01:00
Akirabane 3f2b714ed6 Merge origin/main into feat/6484-control-hello 2026-11-01 23:30:03 +01:00
MSedgeMC 25f3bbd2ce Run a mod's loot function on the items engine's drops
The items engine (block drops, entity drops, loot_table, interact_loot)
answered a mod's loot condition but had no arm for a mod's loot function, so a
block's own table could not carry one. Its modifiers now build the stack as the
main loot engine's LootStack, call native_loot_function, and write back only
what the hook changed: item, count, damage, enchantments, potion, ominous
bottle amplifier, stew effects, names and the components the engine keeps as
saved.
2026-11-01 22:26:35 +00:00
HrafnaValkyrja c7a641587b Merge remote-tracking branch 'origin/main' into fix/lightning-entity-kinds 2026-11-01 17:26:32 -05:00
HrafnaValkyrjaandClaude Opus 5.5 1bd7d0a5c3 Lightning breaks cushions by box, not by feet point
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 17:26:16 -05:00
arismoko db483268db Merge remote-tracking branch 'upstream/main' into v2/ses_01M6BECNQ499TYMMBQBDF00VJ5-mod-api 2026-11-01 22:24:40 +00:00
Ludovic MarieandClaude Opus 5.5 2d5237b3be Merge main into #6334
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 23:23:52 +01:00
Ludovic Marie 01e150e30d Merge pull request #6437 from horizon-rust/perf/cfb8-hw-aes
🤖 Agent: perf-net-client. Hardware AES for the connection cipher; online-mode RD 32 join receive CPU 8-9.5 s -> 0.5-0.6 s (EPYC replay), decrypt x240 on M4. pr_ready on playhorizon, ARM path tested on the Mac, CI verify+launcher green.
2026-11-01 23:23:48 +01:00
MSedgeMC fb37d6424f Only the controlling rider's paddle packets move a boat
ServerGamePacketListenerImpl.handlePaddleBoat applies a paddle packet only
when the sender is the vehicle's controlling passenger, and Entity.addPassenger
seats the first player in front. The server now numbers the riders of a vehicle
as they get in and takes paddle packets from the first one only.

tools/test_boat_paddle_reset_parity.py gains a boat with two players: the
second rider's paddle packets must leave the metadata alone, the first rider's
still change it, and once the first rider gets out the second one drives.
2026-11-01 22:23:42 +00:00
Jose Gil 011bccf726 refactor(launcher): decouple memory slider save state from pre-translated strings 2026-11-01 18:21:26 -04:00
Ludovic MarieandClaude Opus 5.5 7aeddbcf44 inline_object harness: build client_probe from horizon-client-lib
Since the client library split the example belongs to horizon-client-lib, so
`cargo build -p horizon-client --example client_probe` exits 101 and the harness
fails on every PR that selects it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 23:20:07 +01:00
ErikKalikasandClaude Opus 5.5 adee3fcc26 Native mods: no rain or thunder in a mod loot kind's Nether and End
`loot_place_at` reported the shared weather levels in every dimension.
Vanilla's `Level.isRaining` and `isThundering` also need
`canHaveWeather()`, false with a ceiling (the Nether) and in the End, so a
mod's weather condition saw rain there while the Overworld had it.

Fixes #6486

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 00:18:12 +02:00
AkirabaneandClaude Sonnet 5.5 1e03f61c98 Agent control: a hello command answers the protocol, the release and the command groups
docs/api/agent-control.md listed "A version" as not there yet, and
stability.md planned a hello reply. horizon-ctl hello (and its MCP tool)
now replies {protocol, release, features}: protocol is the control
protocol's own version (0.1.0, the first numbered line), release the game's
version (the string MCP serverInfo carries, which both pages still called
planned), features the command groups the game serves.

Fixes #6484

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 23:17:30 +01:00
Federico TaglianiandClaude Sonnet 5.5 3c6de1ba34 Knockback hook test: the hook sees the raw strength
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 23:14:31 +01:00
Federico TaglianiandClaude Sonnet 5.5 c305aac57a The native knockback hook runs on the raw strength, before the knockback resistance
LivingEntity.knockback scales strength by KNOCKBACK_RESISTANCE inside the method, and NeoForge's
LivingKnockBackEvent fires at its start, so a mod's change to the strength lands on the raw
value. Horizon ran the hook after the resistance had scaled it, for mobs and for the player.
The hook now sees the raw strength and the resistance scales what it leaves.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 23:13:15 +01:00
Ludovic MarieandClaude Opus 5.5 c9d06599c7 Dedicated server: log slow ticks and vanilla's overload warning
A dedicated server now times its spans every tick and writes a tick slower
than 250 ms to the log with where its time went (HORIZON_SPIKES's line;
the variable still sets another threshold). A server more than two seconds
behind, which already skipped the ticks it missed, now says so as vanilla's
MinecraftServer.runServer does ("Can't keep up! Is the server overloaded?
Running Nms or N ticks behind"), at most every 15 seconds.

The official server shows 10-13 s freezes (bursts of 'moved too quickly'
from 15+ players at once) with nothing in its log to say where they went.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 23:12:40 +01:00
Jose Gil 686537c322 feat(launcher): localize native mod build steps, categories, world mode, and server errors 2026-11-01 18:11:42 -04:00
Ludovic Marie 4fb8955bf4 Merge main into #6422
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client-render/src/gfx/panorama.rs
2026-11-01 23:09:16 +01:00
Alexander KrylovandHermes Agent f681e4bbdc Launcher: a failed instances.json save is printed
Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-11-02 01:01:01 +03:00
Ludovic Marie eb0d9e5805 Merge pull request #6445 from MSedgeMC/fix/tnt-cart-collision
🤖 Agent: merging "Blow up a TNT minecart that runs into a block" (head 34e0a1295) after review by Ludovic's AI agent; thank you, @MSedgeMC.
2026-11-01 23:00:17 +01:00
Ludovic Marie 6993333eaf Merge pull request #6440 from xFedeT/fix/player-equipment-event
🤖 Agent: merging "LivingEquipmentChangeEvent covers the server's players" (head 234145482) after review by Ludovic's AI agent; thank you, @xFedeT.
2026-11-01 23:00:12 +01:00
Ludovic Marie 1e74fa4647 Merge pull request #6438 from senjinthedragon/hdr-items
🤖 Agent: merging "HDR: glowing items shine, dropped, framed and held, the hand's too" (head e03324cd4) after review by Ludovic's AI agent; thank you, @senjinthedragon.
2026-11-01 23:00:06 +01:00
Ludovic Marie d58f5ddcd1 Merge pull request #6396 from ErikKalikas/mod-loot-context
🤖 Agent: merging "Native mods: a mod loot kind sees where the roll happens (origin, block entity, level)" (head 26103c104) after review by Ludovic's AI agent; thank you, @ErikKalikas.
2026-11-01 23:00:01 +01:00
Ludovic Marie a9065e3ee6 Merge pull request #6446 from some-du6e/optifine/ctm-overlay-orient
🤖 Agent: merging "OptiFine packs: CTM overlays face the right way on rotated blocks" (head 9fbd459b6) after review by Ludovic's AI agent; thank you, @some-du6e.
2026-11-01 22:59:56 +01:00
Ludovic Marie 427ec651f9 Merge pull request #6430 from breachato/fix/6405-tooltip-lore-rarity
🤖 Agent: merging "A stack's rarity component sets the colour of its name" (head 2a2a74923) after review by Ludovic's AI agent; thank you, @breachato.
2026-11-01 22:59:53 +01:00
Ludovic Marie e1cc1a1bbd Merge pull request #6429 from breachato/fix/6388-boat-tick-post
🤖 Agent: merging "A boat's entity tick Post comes after the rest of its tick" (head ae295952c) after review by Ludovic's AI agent; thank you, @breachato.
2026-11-01 22:59:49 +01:00
Ludovic MarieandClaude Opus 5.5 de59ab97c5 Metal: each solid pass draws only its own quads
#6005 drew every solid list twice on Metal, once per pass, and each pass
clipped the other's quads in the vertex shader: at render distance 32 with
far terrain the cutout pass cost 2.2 ms of GPU, most of it shading opaque
vertices it then dropped.

Sorting a mesh's solid quads by facing side now keeps its opaque and cutout
quads apart (mesher::Part), and Sides draws either part. Where the renderer
draws two passes (Metal), meshes put all their opaque buckets first, so each
part is one or two runs; elsewhere each bucket holds its opaque quads, then its
cutout ones, and one pass draws the same runs as before. Phase 1 lists every
mesh's opaque runs, then its cutout runs (a fading mesh's quads all go to the
cutout pass), and flat draws start a call where the passes split.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 22:59:46 +01:00
Ludovic MarieandClaude Opus 5.5 e296a7ee38 Skip per-section fade work while nothing fades
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 22:59:45 +01:00
Ludovic Marie da344a09d3 Merge pull request #6426 from HrafnaValkyrja/fix/save-loaded-entities
🤖 Agent: merging "Save and reload keeps primed TNT, rockets and thrown tridents" (head fd93627b1) after review by Ludovic's AI agent; thank you, @HrafnaValkyrja.
2026-11-01 22:59:45 +01:00
MSedgeMC f21e244981 Splitscreen: a controller joins as P2 while P1 has the keyboard and mouse
route_pads handed the first controller pressed in a world to P1 whenever no player had joined
yet, whatever P1 was playing with, so with P1 on the keyboard and mouse the only controller took
P1's controls instead of offering to join.

A pad is P1's without asking only in a menu, where it drives the menus, or while P1's own
controller is gone and its view offers "Use another". Anywhere else in a world the press starts
the join offer, where A joins as the next player and Y gives the pad to P1.
2026-11-01 21:59:42 +00:00
Ludovic Marie 3303a42a48 Merge pull request #6417 from xFedeT/fix/dead-player-closes-container
🤖 Agent: merging "A player who dies with a container open closes it at death, not at respawn" (head c7d2b1199) after review by Ludovic's AI agent; thank you, @xFedeT.
2026-11-01 22:59:41 +01:00
Ludovic Marie 60f0a35647 Merge pull request #6414 from HrafnaValkyrja/fix/piston-push-entities
🤖 Agent: merging "Pistons and shulker lids push falling blocks, XP orbs and projectiles" (head 6578a0abf) after review by Ludovic's AI agent; thank you, @HrafnaValkyrja.
2026-11-01 22:59:37 +01:00
Ludovic Marie 37dfdd4a98 Merge pull request #6411 from MSedgeMC/fix/ui-panorama
🤖 Agent: merging "Title panorama: read the faces through the resource layers" (head bbec3f1a4) after review by Ludovic's AI agent; thank you, @MSedgeMC.
2026-11-01 22:59:34 +01:00
Ludovic Marie f69e907e44 Merge pull request #6410 from MSedgeMC/fix/ui-mouse-capture
🤖 Agent: merging "Lock the pointer again when the window regains focus" (head e7d70fbe7) after review by Ludovic's AI agent; thank you, @MSedgeMC.
2026-11-01 22:59:30 +01:00
Ludovic Marie 6b1e609afb Merge pull request #6401 from Ragbie/fix/parity-windows
🤖 Agent: merging "parity.py runs harnesses on Windows: file locks, process trees, no peak or shim there" (head 5ebf42f7b) after review by Ludovic's AI agent; thank you, @Ragbie.
2026-11-01 22:59:25 +01:00
Federico TaglianiandClaude Sonnet 5.5 a1fac8df39 A remainder that is the mod's own block item is not dangling
Content::read and Content::declare accept the mod's blocks that have an item as a crafting
remainder; drop_dangling_remainders now counts them too, so a valid mod is not left out.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 22:59:05 +01:00
some-du6e a3718e2d8c Merge optifine/etf-followups into optifine/etf-blocks-glow 2026-11-01 15:59:03 -06:00
Alexander KrylovandHermes Agent c7b045112b Launcher: settings.json is replaced, not truncated, and a failed save is printed
Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-11-02 00:58:56 +03:00
MSedgeMC d6b55b3c95 Tooltip display harness: every flag combination
Add the two missing rows of the tooltip_display visibility table to
tools/test_tooltip_display_parity.py: hide_tooltip with hidden components and
a non-creative flag, and a creative flag with no hidden components.
2026-11-01 21:58:46 +00:00
MSedgeMC 5937b56a66 Lore tooltip harness: a line's own component styles
Add three lore stacks to tools/test_lore_tooltip_parity.py: a line with no
text of its own whose explicit italic:0b reaches its children, a plain-string
child next to an object child with bold:0b, and a packed #rrggbb colour with a
strikethrough a child inherits.
2026-11-01 21:58:40 +00:00
some-du6eandClaude Opus 5.5 d4565803b4 OptiFine emissive textures: the lookups are Copy closures, passed by value
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:58:35 -06:00
anweisenandClaude Opus 5.5 1e5c904d18 Chat: Ctrl and Shift read the live modifiers, so Ctrl+Shift+arrows select by word
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 22:56:40 +01:00
Alexander KrylovandHermes Agent ba579191f9 Launcher: a failed account save is printed instead of dropped
Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-11-02 00:56:19 +03:00
HrafnaValkyrjaandClaude Opus 5.5 50583c8b53 Honour NoGravity on arrows, orbs, TNT, items, boats and minecarts
These kinds applied their own gravity and ignored NoGravity:1b, so a
summoned one fell (Entity.isNoGravity / getGravity is 0 in vanilla).
Arrows, orbs and fireballs also dropped NoGravity on save.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 16:55:53 -05:00
SenjinandClaude Opus 5.5 52219e4771 HDR: plain numbers on its screen, black level on Auto, the shortcut only in a world
The sliders' "nits" were real nits only where the compositor passes PQ through, not on
KWin or macOS, and the game can't tell which: the values stay, the unit goes. Black Level
0 is now Auto, what the system reports, rounded down so an OLED stays at true black. The
HDR shortcut no longer reaches the title screen through the mouse or control socket.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 22:54:35 +01:00
MSedgeMC f252ef542d Drop a needless conversion in the boat's eye height
HEIGHT is already an f64, so f64::from(HEIGHT) failed clippy's useless_conversion.
2026-11-01 21:54:09 +00:00
Federico TaglianiandClaude Sonnet 5.5 f798193428 A refused code declaration leaves out only the content mod whose file item needed it
The file check now accepts a remainder naming a code-declared item before that declaration is
validated. If accept_declared then refuses the declaration, the file item's remainder dangles
and the overlay failed for every content mod. A mod with an item whose remainder it does not
have is left out alone, with a warning.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 22:53:27 +01:00
some-du6e d513df7b8f Merge optifine/etf-followups into optifine/etf-blocks-glow 2026-11-01 15:52:43 -06:00
SenjinandClaude Opus 5.5 863a9eab0d HDR: light sources shine through a shader pack's material stage
The stage wrote alpha 1, so with a pack's material no light source on terrain shone.
It now writes the mark as fs_solid does, from the sprite before the pack's material.
Its emission also read a tier that shines all over as the top tier.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 22:52:20 +01:00
some-du6eandClaude Opus 5.5 e4e5f05b40 OptiFine emissive textures: pass the lookups, not closures around them
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:52:14 -06:00
Ludovic Marie 1e1d41e588 Merge main into #6307
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
#	docs/native-mods.md
2026-11-01 22:47:49 +01:00
Federico TaglianiandClaude Sonnet 5.5 6e79752f94 A file-declared item may name a code-declared item as its crafting remainder
Content::read checked remainders against the mod's files only, before the native mods'
declarations were collected, so a remainder naming an item the same mod declares in code refused
the whole content mod. read_declaring takes the ids native_items declares in the mod's
namespace, and the remainder check accepts them.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 22:47:30 +01:00
huiandZCode AI agent (GLM-5.3) 4f48e48a43 Recipe book sync sends empty ingredients for NeoForge's object shapes
holder_set writes any JSON object as an empty list (varint 1), so a
recipe whose ingredient is one of NeoForge's standard shapes —
neoforge:compound, intersection, difference — reaches a connected
client with an empty ingredient slot. The crafting copy resolves these
shapes to flat item lists, but the network copy loaded its recipes from
disk separately, and the resolved lists never reached it: neither the
book's displays nor UpdateRecipes' property sets and stonecutter.

The network copy now loads through one place, network_recipes: every
recipe file and overlay recipe with each ingredient object — NeoForge's
shapes, a brewing slot's item predicate — replaced by its flat item
list, covering the shaped key's values, ingredients, ingredient, the
smithing trio and the brewing pair. A recipe that keeps an ingredient
object no loader can parse (a mod's own kind) is dropped: the server
does not load it, and an empty ingredient slot in its place misleads
the client. Only vanilla recipe types are normalized; a mod's own type
keeps its JSON whole. A missing item catalog leaves the shapes
unresolved with a warning instead of silently.

An item the registries do not know is left out of the ingredients it
was named in, not sent as id 0.

The lan_bot test drives the whole path live: a server over the vanilla
data with a pack of compound recipes, two guests joining, each reading
its own UpdateRecipes and RecipeBookAdd.

Fixes #6368.

Co-Authored-By: ZCode AI agent (GLM-5.3)
2026-11-01 22:45:52 +01:00
some-du6eandClaude Opus 5.5 df6f6c13bf OptiFine emissive textures: the cap warning names the pack's 96
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:45:43 -06:00
some-du6e f8cb56f3a2 Merge branch 'optifine/cit-glint-rules' into optifine/cit-glint 2026-11-01 15:43:19 -06:00
Akirabane 7255d001cc Merge origin/main into fix/5768-bubble-column-particles 2026-11-01 22:42:40 +01:00
some-du6eandClaude Opus 5.5 4a50aaa08d rustfmt the client re-exports
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:42:31 -06:00
AkirabaneandClaude Sonnet 5.5 3df30d4d84 Mobs and dropped items at the top of a bubble column send its surface particles
Entity.handleOnAboveBubbleColumn ends in sendBubbleColumnParticles: two SPLASH
and two BUBBLE particles over the column block, sent by the server to the
players around. Only the player and arrows showed them. A mob (tick
applyEffectsFromBlocks) and a dropped item now ask for the same particles
through MobEvent::BubbleColumnParticles and the item tick loop, drawn and
sent to the LAN guests by one Game::bubble_column_particles, which the
arrow event uses too (its particles reach the guests now).

Refs #5768

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 22:42:28 +01:00
HrafnaValkyrjaandClaude Opus 5.5 eabcca7b27 Merge origin/main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 16:42:20 -05:00
some-du6e f0a5634e53 Merge branch 'optifine/cit-glint-rules' into optifine/cit-glint 2026-11-01 15:42:04 -06:00
some-du6eandClaude Opus 5.5 ab908681fe rustfmt the client re-exports
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:42:04 -06:00
some-du6eandClaude Opus 5.5 ff3b1380f8 rustfmt the client re-exports
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:42:03 -06:00
MSedgeMC 34e0a1295f Keep the off-track path from arming the collision detonation
comeOffTrack keeps the delta its move resolved, so a cart an obstacle stopped there is
standing still as far as MinecartTNT.tick is concerned; only the rail paths re-apply the
track's velocity and detonate.
2026-11-01 21:41:34 +00:00
some-du6eandClaude Opus 5.5 cba1912a1f OptiFine emissive textures: the pack's overlays leave native mods their 32
Round 2 review fixes: load_optifine stops at 96 pairs so native mods' 32 always fit,
and table no longer cuts; it runs after the connected-texture tiles load, so a tile
identical to a block makes its layer shared (no overlay) instead of glowing; the 32-cap
test uses 40 distinct bases; insert keeps its doc; the docs list the limits.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:41:30 -06:00
Federico TaglianiandClaude Sonnet 5.5 2341454826 A respawned player's equipment is heard again from empty
PlayerList.respawn builds a new ServerPlayer whose lastEquipmentItems starts empty, so its first
detectEquipmentUpdates reports every worn item. The last-heard equipment is cleared at the
respawn.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 22:40:52 +01:00
some-du6e ab13381576 Merge optifine/etf-followups into optifine/etf-blocks-glow 2026-11-01 15:40:46 -06:00
some-du6eandClaude Opus 5.5 742df9fb59 OptiFine emissive textures: thrown tridents glow where the game draws them
Round 2 review: the trident glow moves to trident_geometry (tridents the server sends,
and those flying to whoever picks them up); the in-frame projectile branch it was in is
deprecated. A dropped variant beside its texture also takes its blink frames and their
overlays out of the atlas.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:40:02 -06:00
some-du6e 5b41c18eed Merge branch 'optifine/cit-glint-rules' into optifine/cit-glint 2026-11-01 15:39:02 -06:00
Alexander KrylovandHermes Agent 11bdbd4712 Maps and stopwatches: a failed save is logged instead of dropped
Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-11-02 00:38:39 +03:00
some-du6e 1c100d6d77 Merge branch 'optifine/cit-armor' into optifine/cit-glint-rules
# Conflicts:
#	crates/horizon-client/src/lib.rs
2026-11-01 15:38:32 -06:00
Ludovic Marie dd289c8904 Merge pull request #6389 from Brineexee/launcher-ui-fix
🤖 Agent: merging "Launcher: version picker overflow and low-contrast text/buttons" (head b2d84a7f9) after review by Ludovic's AI agent; thank you, @Brineexee.
2026-11-01 22:38:20 +01:00
Ludovic Marie 9a28fbe3c7 Merge pull request #6268 from horizon-rust/perf/server-item-renderer
🤖 Agent: verify green; idle anon memory A/B in the body (5 rounds, 5/5 lower); server-only memory change, no tick path touched.
2026-11-01 22:38:15 +01:00
Ludovic Marie 244a2c8155 Merge pull request #6266 from horizon-rust/perf/command-export-extract
🤖 Agent: verify green; idle anon memory A/B in the body (5 rounds, 5/5 lower); server-only memory change, no tick path touched.
2026-11-01 22:38:11 +01:00
Ludovic Marie 2930023ff1 Merge pull request #6265 from horizon-rust/perf/lan-recipe-text
🤖 Agent: verify green; idle anon memory A/B in the body (5 rounds, 5/5 lower); server-only memory change, no tick path touched.
2026-11-01 22:38:06 +01:00
Ludovic Marie 75974b885c Merge pull request #6347 from loicpiller/fix/6084
🤖 Agent: merging "feat(mods): add experience pickup and mending hooks" (head 23e3ae415) after review by Ludovic's AI agent; thank you, @loicpiller.
2026-11-01 22:38:01 +01:00
martipopsandClaude Opus 5.5 0f5e1c8773 Keep a controller's sprint and sneak held through the modifier heal
heal_hold_latches releases sneak and sprint when the OS says Shift or Ctrl is up,
to recover a keyboard release that never arrived. A controller's stick click acts
as that key without setting the modifier, so the heal released it the next frame,
before a tick could start the sprint: left stick click stopped sprinting. Skip a
mapping the pad holds (or whose release waits for a tick), as modifiers::reconcile
already does.

Fixes #6377

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:37:56 -05:00
Ludovic Marie 7d2786d072 Merge pull request #6288 from xFedeT/fix/selector-tag-players
🤖 Agent: merging "Players on a server keep their tags: @a[tag=...] and /tag work for guests" (head 66359b492) after review by Ludovic's AI agent; thank you, @xFedeT.
2026-11-01 22:37:55 +01:00
Ludovic Marie a86ac72d59 Merge pull request #6306 from Detimes/feat-player-dimension-hook
🤖 Agent: merging "Native mods: a player changing dimension (native_player_changed_dimension, part of #5816)" (head 0e9f118ca) after review by Ludovic's AI agent; thank you, @Detimes.
2026-11-01 22:37:51 +01:00
some-du6e 74664db436 Merge branch 'optifine/cit-items' into optifine/cit-armor
# Conflicts:
#	crates/horizon-client/src/game/armor_stands.rs
#	crates/horizon-client/src/lib.rs
2026-11-01 15:37:40 -06:00
some-du6e 854042fa91 Merge remote-tracking branch 'origin/main' into optifine/cit-items 2026-11-01 15:36:25 -06:00
Alexander KrylovandHermes Agent b72f0b52f8 Windows: the grab warps the pointer once, and the capture probe passes
Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-11-02 00:35:21 +03:00
some-du6eandClaude Opus 5.5 2a396df108 OptiFine CIT: vanilla's glint stays off the GUI only; layers discard and fade like vanilla's; broken rules don't match; a capped array; one run per layer
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:35:10 -06:00
HrafnaValkyrjaandClaude Opus 5.5 415ef54a47 Portals carry XP orbs and thrown projectiles
Experience orbs, snowballs, eggs, potions and fireballs ignored nether and
End portals; vanilla sends them through like items. They are now swept for
portal blocks, saved and re-spawned in the other dimension, and keep their
portal cooldown on arrival so they do not go straight back.
Tridents and firework rockets still stay behind (no save/load writer yet).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 16:33:51 -05:00
k3yhandClaude Opus 5.5 79aa093cc0 Merge main into fix/firework-selectors
Keep both Target::Rocket and #6282's Target::Spawner at every site.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:33:28 -05:00
HrafnaValkyrjaandClaude Opus 5.5 2b08d635d9 Selectors and /data see Tags on orbs, projectiles, crystals and the dragon
Tags given to a summoned experience orb, arrow, snowball, egg, ender
pearl, fireball, small fireball, wind charge, End crystal or Ender Dragon
were dropped: @e[tag=] found nothing and /tag wrote nothing. They now keep
their Tags by entity id, and /data get writes them back. /data get entity
on a wind charge answered "no entity"; it is written like the other
fireballs now. lan_e2e --check selector-tags covers it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 16:33:14 -05:00
Federico TaglianiandClaude Sonnet 5.5 89ca25c574 LivingEquipmentChangeEvent covers the server's players
LivingEntity.collectEquipmentChanges compares every living entity's slots with the last seen
stacks, players included, so a hotbar selection that moves the main hand or an item replaced in
place fires the event with the previous stack. Horizon compared mobs only. Each guest's main
hand, off hand and armor are compared the same way, per tick and only while a mod listens.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 22:33:08 +01:00
some-du6e 2c012408c6 Merge optifine/etf-followups into optifine/etf-blocks-glow 2026-11-01 15:32:57 -06:00
some-du6eandClaude Opus 5.5 facacb2138 OptiFine emissive textures: cutout block overlays, native mods kept to 32, safer pairing
Review fixes:
- native_emissive::table builds the frame's overlay table: native mods' pairs first
  (their own 32), then the packs' OptiFine pairs flagged to draw cutout (opaque where
  alpha >= 0.1, as OptiFine draws SOLID textures' overlays), sorted so the terrain
  shader's per-vertex search stops at the first larger base layer
- an overlay that doesn't load is skipped (Sprites::try_insert), not drawn as the
  missing texture; blocks sharing a layer (identical pixels) get no overlay; pairs past
  the cap and oversized files are logged
- tests for the table and for the broken and shared cases

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:31:54 -06:00
some-du6eandClaude Opus 5.5 514d4b0125 OptiFine emissive textures: test a variant blink frame's overlay in the atlas, fix the docs row
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:28:24 -06:00
SenjinandClaude Opus 5.5 a503ce42c9 HDR: light sources keep shining under water
The underwater overlay raised the frame's alpha, which HDR output reads as something
covering the world, so nothing shone with the eye in water. It tints the whole view,
so with HDR on it now leaves that alpha alone and tints the kept world too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 22:28:19 +01:00
some-du6eandClaude Opus 5.5 dfc2b8b7c0 OptiFine emissive textures: a blink frame glows with its own overlay only
Review: the texture's overlay is mostly its glowing open eyes, which painted over the
shut ones and hid the blink. Frames pair with their own _blink_e (tested through the
renderer and the atlas); the docs list where overlays live, and that trims aren't covered.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:27:23 -06:00
fcae565042 Add dedicated native-mod engine build and export
Reuse the launcher native build pipeline for a headless server target,
export an independent runtime, and document manual server selection.
Keep server cache entries outside playable versions and preserve export
permissions, resources, and post-build data.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
Co-Authored-By: OpenCode GLM 5.3 <[email protected]>
Co-Authored-By: OpenCode Nemotron 3.5 Super VL <[email protected]>
2026-11-01 16:26:35 -05:00
some-du6eandClaude Opus 5.5 723fbc2c8d OptiFine CTM panes: hide strips only on the block's top and bottom planes
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:26:28 -06:00
Federico TaglianiandClaude Sonnet 5.5 243c194bb7 Toggled sneak and sprint do not outlive the world
LocalPlayer's constructor calls KeyMapping.resetToggleKeys, so a toggled sneak or sprint (the
sitting and swimming poses the report sees) is not still on in the next world. close_world now
puts every toggle mapping up and releases the latched sneak and sprint keys.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 22:25:45 +01:00
arismoko 20d6193c00 Merge upstream main (ed102e0592) 2026-11-01 21:24:31 +00:00
some-du6eandClaude Opus 5.5 a8f55afe52 OptiFine CTM panes: only a tile rule that can draw on a pane connects it
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:22:39 -06:00
some-du6eandClaude Opus 5.5 80b5aaf80d OptiFine CTM panes: tile rules on a pane's sprites connect it too
A pack connecting glass with matchTiles=glass retiled panes but left their
seams; a pane counts as connected when any rule names it or one of its
sides' sprites.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:20:10 -06:00
some-du6eandClaude Opus 5.5 1b896eb3a6 OptiFine emissive textures: only block sprites pair for the terrain
Items draw from the HUD atlas, so their overlays would only take table slots; the docs
list placed blocks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:18:32 -06:00
some-du6eandClaude Opus 5.5 7cf29b6238 OptiFine emissive textures: blocks glow too
For each loaded block or item sprite whose <name><suffix>.png sits beside it, the
overlay is loaded into the sprite array and paired with it in the terrain shader's
overlay table, which draws it full bright over the base in the same fragment (the
native-mod emissive path). The table holds 128 pairs (native mods keep their 32);
horizon_optifine::emissive::suffix is shared with the entity overlays.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:17:44 -06:00
some-du6eandClaude Opus 5.5 ec627f27bb OptiFine CTM panes: cube UVs only when drawn; pin every arm in the test
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:16:35 -06:00
some-du6eandClaude Opus 5.5 69be592cb2 OptiFine emissive and ETF blinking: the gaps the merged PRs' last reviews found
- the armour stand's own body glows, and its overlays are read per lookup, not held while
  wings_geometry reads the same lock
- the thrown trident glows (ETF's guide names it)
- a random variant's blink frame pairs with its own _e overlay, which the atlas packs; a
  blink frame without one keeps its texture's glow instead of going dark every blink
- blinkLength near u32::MAX no longer overflows
- blink frames come after every variant in the atlas budget, and a dropped variant beside
  its texture takes its overlay out of the atlas too
- docs/resource-packs.md lists random, emissive and blinking textures

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:16:21 -06:00
MSedgeMC 75d84aaacd Push a boat by the current it sits in
Entity.baseTick immerses a boat and adds the flow of the water or lava it sits in to its
velocity, before AbstractBoat.floatBoat scales that velocity. Horizon's boat tick never ran
the fluid step, so a boat in flowing water moved as if the water were still.
2026-11-01 21:15:42 +00:00
Federico TaglianiandClaude Sonnet 5.5 6aec3da419 The hands' sway takes the short way round when the yaw wraps
LocalPlayer's yaw runs on without wrapping, so yRot - yBob is always the short way. Ours is kept
in -180..180 and a boat turns its rider through the wrap: the difference jumped by 360 degrees
for a tick, swinging the hands 36 degrees (a tenth of it) and then easing back by halves, like
jelly. The difference is wrapped, both when yBob eases and when the sway is read.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 22:13:54 +01:00
some-du6eandClaude Opus 5.5 56be7fdcb8 Tell the spark's three motion components apart in the own-rocket test
A second world_runs in the tick now fails the probe by name.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:12:39 -06:00
some-du6eandClaude Opus 5.5 28e10cbf95 OptiFine CTM: connected glass panes look as OptiFine draws them
Vanilla's pane models mirror some faces' textures, which put a connected
tile's borders on the wrong side; OptiFine's pane models don't, so tiles
on panes take the cube's own UVs. Panes with connected textures also hide
the top and bottom strips they share with the same pane stacked on them
(Continuity's Glass Pane Culling Fix), where the other pane has that part.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:12:28 -06:00
some-du6eandClaude Opus 5.5 cba084f110 OptiFine emissive textures: read the overlays where armour stands draw their armour
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:12:22 -06:00
some-du6eandClaude Opus 5.5 d27c2b4f8b OptiFine emissive textures: armour stands, elytra and the dragon glow too
Round 2 review fixes: the armour-stand armour loop, the elytra (when no cape is drawn on
it) and the ender dragon draw their texture's _e overlay full bright; render::FULL_BRIGHT
is shared; tests for the extra layers' glow flag and for overlays coming after every
variant in the atlas budget; the suffix test pins a lone backslash.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:12:22 -06:00
Ludovic MarieandClaude Opus 5.5 9c554326ee launcher/Cargo.lock: horizon-protocol depends on aes
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 22:12:19 +01:00
NerfPhaselockandClaude Opus 5.5 89c7d58967 Graphics: a Translucent Batching switch, on by default
Graphics: Advanced, Performance gets a Translucent Batching switch after
Graphics API ("Saves time"; it applies at the next start, as Graphics API
does). Where the device can't batch (translucent_batching_offered) it is
greyed and reads Off while the saved value waits, and neither a click, the
wheel nor click-then-Enter changes it; on macOS, where Metal never offers
it, it is hidden beside Graphics API.

The setting is now on by default, under a new key, batchTranslucent: every
save wrote translucentBatching:false while the setting was off by default
and had no switch, so under that key the new default would have reached no
one who had saved an option since. The old key stays ignored, as shaderPack
is.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:11:08 -05:00
Ludovic MarieandClaude Opus 5.5 ae295952ce Boats: keep the ticked list only while a mod hears the ticks
Without mods the round doesn't listen and the Posts send nothing, so the
per-tick list of ticked boats (an entry and a kind per boat) isn't built.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 22:10:56 +01:00
Ludovic Marie ed102e0592 Merge pull request #6383 from MSedgeMC/fix/cm-registry-origin
🤖 Agent: merging "Registry: a page's Origin against the site's origin" (head 10d8d8429) after review by Ludovic's AI agent; thank you, @MSedgeMC.
2026-11-01 22:10:20 +01:00
Ludovic Marie 8a99fa72df Merge pull request #6352 from MSedgeMC/fix/gp-arrow-rot
🤖 Agent: merging "Draw an arrow between the rotations of the two ticks it turns over" (head c77c94cb2) after review by Ludovic's AI agent; thank you, @MSedgeMC.
2026-11-01 22:10:16 +01:00
Ludovic Marie 1dbad2a0f1 Merge pull request #6371 from JustKarkat/fix/fox-item-pickup
🤖 Agent: merging "Foxes take their food's consume effects and survive on a totem" (head 6def6595b) after review by Ludovic's AI agent; thank you, @JustKarkat.
2026-11-01 22:10:11 +01:00
Ludovic Marie b6819c6d39 Merge pull request #6385 from HrafnaValkyrja/fix/items-stuck-in-web
🤖 Agent: merging "Cobweb and powder snow slow dropped items" (head ee3f1787f) after review by Ludovic's AI agent; thank you, @HrafnaValkyrja.
2026-11-01 22:10:07 +01:00
Ludovic Marie 1910805793 Merge pull request #6390 from MSedgeMC/fix/dolphin-dry-out
🤖 Agent: merging "Dry a dolphin out with dry_out, not drowning" (head 74ae0056e) after review by Ludovic's AI agent; thank you, @MSedgeMC.
2026-11-01 22:10:03 +01:00
Ludovic Marie 71ba24b1bb Merge pull request #6387 from MSedgeMC/fix/dolphin-save
🤖 Agent: merging "Save a dolphin's fish and moistness" (head 82de32426) after review by Ludovic's AI agent; thank you, @MSedgeMC.
2026-11-01 22:09:59 +01:00
oxi-does-devandOpenCode GLM 5.3 f5b36c5bac Using items on a server: play the first-person swing
MultiPlayerGameMode.useItem runs ItemStack.use on the client and swings
the hand on a shouldSwing() result (SUCCESS); CONSUME ends the click
without one. The remote path sent the packet and predicted everything
else but never swung, so a thrown pearl, splash potion, wind charge,
snowball or experience bottle, a bucket fill/empty, a spawn egg, a
writable book and equipping armor from the hand only did the itemUsed
dip on a server (#6425, #6409). The useItemOn half already swung.

Co-Authored-By: OpenCode GLM 5.3 <[email protected]>
2026-11-02 00:09:57 +03:00
MSedgeMC a41747ac51 Merge upstream/main into fix/ui-single-biome
The title tests gained a button-label test at the same place the single-biome
tests were added; both stay.
2026-11-01 21:09:14 +00:00
some-du6eandClaude Opus 5.5 c34b27ef8a Read whether the level runs without ticking the rate manager again
world_runs is TickRateManager.tick: called a second time for the own
rockets, it spent two of a /tick step's ticks each client tick. The
test also runs the real rocket_spark now.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:08:58 -06:00
some-du6eandClaude Opus 5.5 846fbbc98d OptiFine CIT: GUI glints only on stacks with foil
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:07:41 -06:00
Ludovic Marie 683bd01ee8 Merge pull request #6381 from MSedgeMC/fix/shield-push-order
🤖 Agent: merging "Push the shield before the hit that it only partly blocked" (head c30dc0feb) after review by Ludovic's AI agent; thank you, @MSedgeMC.
2026-11-01 22:07:25 +01:00
some-du6e 711e912310 Merge branch 'optifine/cit-glint-rules' into optifine/cit-glint 2026-11-01 15:07:23 -06:00
Ludovic Marie 087e4ca3be Merge pull request #6374 from HrafnaValkyrja/fix/hopper-item-cycle
🤖 Agent: merging "Hopper takes a falling item on entry" (head 7a7f5e7ba) after review by Ludovic's AI agent; thank you, @HrafnaValkyrja.
2026-11-01 22:07:22 +01:00
Ludovic Marie 34fe826383 Merge pull request #6349 from MSedgeMC/fix/sv-button-scroll
🤖 Agent: merging "Button, slider and title-screen labels wider than the widget scroll, as vanilla does" (head 26e6743fb) after review by Ludovic's AI agent; thank you, @MSedgeMC.
2026-11-01 22:07:18 +01:00
Ludovic Marie 10908a4f78 Merge pull request #6293 from mariuslozo-boop/fix/pack-screen-long-label
🤖 Agent: merging "Buttons scroll a label wider than they are, as vanilla does, instead of panicking (Resource Packs screen in 8 languages)" (head 76e2ffdb8) after review by Ludovic's AI agent; thank you, @mariuslozo-boop.
2026-11-01 22:07:14 +01:00
Ludovic Marie 5520771857 Merge pull request #6286 from xMelonekMaX/fix/inline-sprite-shadows
🤖 Agent: merging "Render inline text sprite shadows like vanilla" (head 7ab188cf5) after review by Ludovic's AI agent; thank you, @xMelonekMaX.
2026-11-01 22:07:11 +01:00
Ludovic Marie b61bafb604 Merge pull request #6337 from NerfPhaselock/perf/translucent-compaction
🤖 Agent: merging "Translucent batching (3/4): the large pages shrink once the view has settled" (head b91226cac) after review by Ludovic's AI agent; thank you, @NerfPhaselock.
2026-11-01 22:07:07 +01:00
Ludovic Marie ce19187ac9 Merge pull request #6339 from Joansitoh/fix/6241-bool-uniform-shadow
🤖 Agent: merging "Shader packs: a parameter or local named like a bool uniform hides it" (head 9696cd39e) after review by Ludovic's AI agent; thank you, @Joansitoh.
2026-11-01 22:07:03 +01:00
Ludovic Marie 64ac231d51 Merge pull request #6370 from OsamahDev1/docs/hosting-online-mode
🤖 Agent: merging "docs(hosting): Horizon players join online-mode servers and sign their chat" (head a0f851b33) after review by Ludovic's AI agent; thank you, @OsamahDev1.
2026-11-01 22:06:59 +01:00
Ludovic Marie 5725650c07 Merge pull request #6344 from DmytroChup/fix/remote-game-event-parity
🤖 Agent: merging "Remote: handle arrow hit, pufferfish sting and limited crafting in GameEvent" (head f983b543a) after review by Ludovic's AI agent; thank you, @DmytroChup.
2026-11-01 22:06:54 +01:00
some-du6eandClaude Opus 5.5 dccfdc7b51 OptiFine CIT: glints only on stacks with foil; enchantment rules need a texture; a graceful cycle end
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:06:50 -06:00
Ludovic Marie b7a976ff18 Merge pull request #6340 from MSedgeMC/fix/golem-move-towards
🤖 Agent: merging "Send an iron golem to a spot near its target, not straight at it" (head 367515af3) after review by Ludovic's AI agent; thank you, @MSedgeMC.
2026-11-01 22:06:49 +01:00
Ludovic Marie 69ce4e4555 Merge pull request #6350 from MSedgeMC/fix/title-tab-list
🤖 Agent: merging "Let Tab reach the world list so a keyboard player can join" (head 0de0b1d1b) after review by Ludovic's AI agent; thank you, @MSedgeMC.
2026-11-01 22:06:46 +01:00
Ludovic Marie 0f365f9404 Merge pull request #6354 from cristibctr/codex/fix-6320-gpu-timer
🤖 Agent: merging "Fix GPU timer panic after readback buffer destruction" (head 27cd7982a) after review by Ludovic's AI agent; thank you, @cristibctr.
2026-11-01 22:06:42 +01:00
some-du6eandClaude Opus 5.5 9fbd459b63 OptiFine CTM overlays: drawn the standard way up, in the face's pieces
Overlays took the face's model UVs, so on dirt's, sand's and grass's
randomly turned tops a transition picked for the north side drew on
another side; they now take the cube's own UVs, as Continuity's spec says
overlays do. Over a ctm_compact face, an overlay is cut into the same
pieces, so it keeps the face's depths and translucent sort order.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:06:38 -06:00
Ludovic Marie 3ed7d99b1e Merge pull request #6363 from Ragbie/fix/relay-attach-time-wait
🤖 Agent: merging "Play with friends: the host attaches from any port when its direct port times out" (head f3de190aa) after review by Ludovic's AI agent; thank you, @Ragbie.
2026-11-01 22:06:38 +01:00
Ludovic Marie 6743972c5e Merge pull request #6341 from Joansitoh/fix/6267-gl-nested-arrays
🤖 Agent: merging "Fix OpenGL shader: flatten colored_lights (no ARB_arrays_of_arrays)" (head 7affb5b47) after review by Ludovic's AI agent; thank you, @Joansitoh.
2026-11-01 22:06:34 +01:00
Ludovic Marie b499fcf45b Merge pull request #6338 from Ragbie/fix/render-tests-gpu-hang
🤖 Agent: merging "Render tests: no OpenGL instance on Windows, where WGL hung them" (head 2ca08a955) after review by Ludovic's AI agent; thank you, @Ragbie.
2026-11-01 22:06:30 +01:00
MSedgeMC da313872c5 Blow up a TNT minecart that runs into a block
MinecartTNT.tick detonates when the cart is in a horizontal collision and its delta
horizontally is still at least 0.1 a tick - on a rail the track re-applies the velocity
after the move, so a cart driven into a block keeps it. Horizon zeroes a cart's velocity
where the collision is resolved, so the check always read zero and the cart just stopped.
2026-11-01 21:06:09 +00:00
Ludovic MarieandClaude Opus 5.5 23e3ae415e Native hooks: the XP pickup and Mending hooks as a fragment, pickup gated on a listener
Completes the previous commit: the API number goes back to main's (the
stamp tool assigns it after merge), the hooks are listed in
tools/native-hook-fragments/experience_orb_pickup.json, and a guest is
swapped in for the orb pickup hook only when a mod sets listened in
native_experience_orb_pickup_listened, so a server without mods pays
nothing more per pickup.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 22:06:08 +01:00
Ludovic MarieandClaude Opus 5.5 5327c34478 Native hooks: XP pickup and Mending as a hook fragment, pickup gated on a listener
The hooks go through tools/native-hook-fragments/ instead of a hand-set
API 154 (the number is stamped after merge); a guest is swapped in for the
orb pickup hook only when a mod listens (native_experience_orb_pickup_listened),
so a server without mods pays nothing more per pickup; the Python test that
compiled extracted source text is dropped (it pinned the code's wording).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 22:05:14 +01:00
some-du6eandClaude Opus 5.5 6f9d929bbd Put a server client's own rocket at the main arm, and keep it ticking
getHandHoldingItemAngle follows the player's main arm, so a left-handed
player's rocket sits on the left. The rockets boosting this player now
tick after the whole player tick, so they keep following it while it
waits for chunks. A rocket used on a block passes on cooldown, and in
adventure mode only off its can_place_on, as ItemStack.useOn does.

The boost and the hand offset are one helper shared with the in-frame
tick, and the test runs the real hand angle against vanilla's view
vector.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:04:23 -06:00
arismoko 289ddc5017 Merge upstream main (885f47a49a)
# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
#	native-hooks.json
2026-11-01 21:04:19 +00:00
some-du6e 3711915577 Merge main into optifine/emf-block-entities
# Conflicts:
#	crates/horizon-client-render/src/block_entity_render.rs
#	crates/horizon-client/src/game/packs.rs
2026-11-01 15:03:10 -06:00
Ludovic Marie 293444390f Merge main into #6307
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
#	docs/native-mods.md
2026-11-01 22:01:36 +01:00
Loïc PILLERandCodex GPT-6.1-Terra a15fb72e18 fix(mods): revalidate mending before zero repair
Co-Authored-By: Codex GPT-6.1-Terra <[email protected]>
2026-11-01 22:01:09 +01:00
Loïc PILLERandCodex GPT-6.1-Terra 6286215b22 fix(mods): guard reentrant mending and bound orb experience
Co-Authored-By: Codex GPT-6.1-Terra <[email protected]>
2026-11-01 22:01:09 +01:00
Loïc PILLERandCodex GPT-6.1-Terra 2dde8afb43 feat(mods): add experience pickup and mending hooks
Co-Authored-By: Codex GPT-6.1-Terra <[email protected]>
2026-11-01 22:01:04 +01:00
Ludovic Marie 0e9f118caa Merge main into #6306
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
#	docs/native-mods.md
2026-11-01 22:00:30 +01:00
breachatoandClaude Opus 5.5 3d8c14eb04 Boat and minecart pickups walk the mobs by id
A mount hook that removes mobs shifts self.mobs, so the indices collected
before the loop could name another mob, which could then be picked up or
pushed without having touched the vehicle (the id check only guarded the
mount write). Both loops now carry the mob ids and look each one up.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 22:00:04 +01:00
Ludovic Marie 66359b4924 Merge main into #6288
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/game/scoreboard.rs
2026-11-01 21:59:43 +01:00
Ludovic Marie 885f47a49a Merge pull request #6441 from horizon-rust/automation/native-hook-stamp
🤖 Agent: generated native hook stamp from the workflow's own branch (Actions can't open PRs here); queued for the merger.
2026-11-01 21:58:08 +01:00
some-du6e 3c60503ad7 Merge branch 'optifine/cit-glint-rules' into optifine/cit-glint 2026-11-01 14:57:10 -06:00
MSedgeMC d00ded86c3 Single biome: sort the rows with cached keys
sort_by_cached_key lowercases each name once instead of on every comparison,
and the row under the pointer is read with a plain bounds check.
2026-11-01 20:57:05 +00:00
some-du6e 77758dad24 Merge branch 'optifine/cit-armor' into optifine/cit-glint-rules 2026-11-01 14:56:24 -06:00
some-du6eandClaude Opus 5.5 1153b64f46 OptiFine CIT: the atlas fallback test owns its names
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:55:38 -06:00
github-actions[bot] 5820d843e2 Stamp native hook API fragments 2026-11-01 20:53:50 +00:00
Akirabane 4ef3033c21 Merge remote-tracking branch 'origin/main' into feat/6393-rcon-source 2026-11-01 21:52:52 +01:00
AkirabaneandClaude Sonnet 5.5 be156ad9fc RCON: commands run from a source named Rcon, and broadcast-rcon-to-ops tells the operators
An RCON command used to run as the server console, so the operators saw nothing of it and its
`say` read `[Server]`. It now goes through its own control request (`rcon <line>`): the same
console at level 4, with the source named `Rcon` as RconConsoleSource.createCommandSourceStack
names it. Its feedback reaches the operators in game as `[Rcon: ...]` while
`broadcast-rcon-to-ops` is on (CommandSource.shouldInformAdmins), its `say` reads `[Rcon] ...`
and leaves the reply empty, and the console and an RCON client take success feedback whatever
`send_command_feedback` says (CommandSource.acceptsSuccess), as a player's source does not.

Fixes #6393

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 21:52:51 +01:00
MSedgeMC 90c7ae2071 Port ledger: the buffet biome picker is ported
The six CreateBuffetWorldScreen rows said missing; they now name the screen's
functions in title/buffet.rs, and the two CreateWorldScreen rows that mention
the Customize button name both editors PresetEditor.EDITORS has.
2026-11-01 20:51:03 +00:00
Ludovic Marie cfc7278a48 Merge pull request #6305 from Detimes/feat-player-flight-hook
🤖 Agent: merging "Native mods: a player starting or stopping flight" (head 7673b4511, main merged in, both hooks kept) after review by Ludovic's AI agent; thank you, @Detimes.
2026-11-01 21:51:00 +01:00
Ludovic Marie 90d851e5ed Merge pull request #6220 from some-du6e/optifine/etf-block-entities
🤖 Agent: verify passes on 7db8c6c7b (top of the #6215 stack, main merged). OptiFine ETF on block entities; render only. Queued by pr-sweep-M.
2026-11-01 21:50:54 +01:00
Ludovic Marie 87aec930ad Merge pull request #6219 from some-du6e/optifine/etf-blink
🤖 Agent: verify passes on 07343d831 (stack on #6217). ETF blinking; render only. Queued by pr-sweep-M.
2026-11-01 21:50:50 +01:00
Ludovic Marie 06bc6b3b59 Merge pull request #6217 from some-du6e/optifine/etf-emissive
🤖 Agent: verify passes on 65ae7147a (stack on #6215, main merged). OptiFine emissive textures; render only. Queued by pr-sweep-M.
2026-11-01 21:50:45 +01:00
Ludovic Marie 4789fc28f6 Merge pull request #6369 from HrafnaValkyrja/fix/effect-amplifier-nbt
🤖 Agent: merging "Mob effect NBT leaves out amplifier 0" (head bfd6dc917) after review by Ludovic's AI agent; thank you, @HrafnaValkyrja.
2026-11-01 21:50:41 +01:00
Ludovic Marie 9f7712f51b Merge pull request #6361 from CocofireHD/fix/tnt-tags
🤖 Agent: merging "Tags: a primed TNT can be tagged" (head ae5d7c78a) after review by Ludovic's AI agent; thank you, @CocofireHD.
2026-11-01 21:50:36 +01:00
Ludovic Marie ee416aa977 Merge pull request #6408 from horizon-rust/fix/stamp-energy-fragment
🤖 Agent: Opened and checked by Ludovic's AI agent (Claude): JSON-only; stamp_native_hooks.py stamps all 72 pending fragments on this head, tests.test_stamp_native_hooks OK; unblocks the native hook stamp workflow on main.
2026-11-01 21:50:31 +01:00
Senjin e03324cd4e Merge remote-tracking branch 'origin/main' into hdr-items
# Conflicts:
#	docs/graphics.md
2026-11-01 21:50:29 +01:00
arismoko af63c75b86 The attachments test builds a mod block entity from its fields
Main's block-entity revision (#6260) made ModBlockEntity's fields private.
2026-11-01 20:47:16 +00:00
MSedgeMC 2a53f80b1f Customize the single-biome preset, as vanilla does
PresetEditor.EDITORS has a screen for the flat preset
(CreateFlatWorldScreen) and for the single-biome preset
(CreateBuffetWorldScreen); the other presets have none. Horizon enabled
Customize for flat worlds only, and its single-biome overworld always fixed
minecraft:plains, so the preset could not be edited at all.

The button now opens CreateBuffetWorldScreen for the single-biome preset: the
biome registry of the prepared resources, sorted by the name each is shown
under and narrowed by a search box that matches the name case-folded, with
Done and Cancel in the footer. Done hands the biome back to Create New World
as WorldCreationUiState.updateDimensions does with
fixedBiomeConfigurator: NoiseBasedChunkGenerator over a FixedBiomeSource with
the overworld's noise settings, so the chosen biome becomes the overworld's
fixed biome source. Re-Create starts the screen on the world's own biome, and
changing the world type starts it over at plains, as a new preset's
dimensions do.
2026-11-01 20:45:42 +00:00
some-du6e 1b0808bbdb Merge branch 'optifine/cit-glint-rules' into optifine/cit-glint 2026-11-01 14:44:23 -06:00
Ludovic Marie 88c4211d55 Merge pull request #6356 from HrafnaValkyrja/fix/protection-generic
🤖 Agent: merging "Protection applies to generic damage on mobs" (head 5c0086ceb) after review by Ludovic's AI agent; thank you, @HrafnaValkyrja.
2026-11-01 21:43:54 +01:00
some-du6e c2f5c37f3d Merge branch 'optifine/cit-armor' into optifine/cit-glint-rules 2026-11-01 14:43:53 -06:00
Ludovic Marie 4dbd6a9baf Merge pull request #6351 from breachato/fix/6316-level-hooks-same-type
🤖 Agent: merging "A respawn into another level of the same dimension type makes a new level" (head 0ccddf82b) after review by Ludovic's AI agent; thank you, @breachato.
2026-11-01 21:43:49 +01:00
Ludovic Marie e6ab7980f3 Merge pull request #6237 from MSedgeMC/fix/flat-delete-select
🤖 Agent: verify passes on b16454fd2 (main merged, both flat.rs tests kept). Superflat screen selection after delete; UI only. Queued by pr-sweep-M.
2026-11-01 21:43:43 +01:00
Ludovic Marie 097bf97b05 Merge pull request #6348 from breachato/fix/6317-unload-on-reconfiguration
🤖 Agent: merging "The client level unloads when the server sends it back to configuration" (head 8b728305b) after review by Ludovic's AI agent; thank you, @breachato.
2026-11-01 21:43:38 +01:00
Ludovic Marie 5ea187e227 Merge pull request #6322 from iLostName/dragon-death-dissolve
🤖 Agent: merging "ender dragon: the dying model dissolves through dragon_exploding's mask instead of fading" (head 7458a6ca1) after review by Ludovic's AI agent; thank you, @iLostName.
2026-11-01 21:43:32 +01:00
Ludovic Marie d4aee3bca1 Merge pull request #6321 from iLostName/flowing-lava-camera-inside
🤖 Agent: merging "camera fluid: lava fog starts when a corner of the near plane dips under the lava" (head 30715a97d) after review by Ludovic's AI agent; thank you, @iLostName.
2026-11-01 21:43:26 +01:00
some-du6eandClaude Opus 5.5 1aa8c36e6a OptiFine CIT: the worn layer's and wings' atlas fallback in the lib, tested
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:43:21 -06:00
Ludovic Marie 5a7e0899eb Merge pull request #6225 from MSedgeMC/fix/silverfish-walk-target
🤖 Agent: verify passes on 0d3eb998a (rebased on main after #6189); silverfish back into infested stone, checked against the vanilla bytecode. Queued by pr-sweep-M.
2026-11-01 21:43:19 +01:00
Ludovic Marie 849c73ce94 Merge pull request #6308 from senjinthedragon/hdr-lift-2
🤖 Agent: merging "HDR: a light source lifts by its texel's own brightness, not the lit pixel's" (head 3451589c3) after review by Ludovic's AI agent; thank you, @senjinthedragon.
2026-11-01 21:43:14 +01:00
Ludovic Marie bdfa3e2b5c Merge pull request #6285 from some-du6e/perf/lod-settle-faster
🤖 Agent: merging "Far terrain: finishes loading in about a minute instead of seven on 4-thread machines" (head 4d53099db) after review by Ludovic's AI agent; thank you, @some-du6e.
2026-11-01 21:43:08 +01:00
Ludovic Marie c86d0e4252 Merge pull request #6319 from MSedgeMC/fix/gp-horse-float
🤖 Agent: merging "A ridden horse floats instead of sinking" (head 7dda810ac) after review by Ludovic's AI agent; thank you, @MSedgeMC.
2026-11-01 21:43:02 +01:00
Ludovic Marie 9d415f81e8 Merge pull request #6314 from Sticky-Bug/fix/sheep-graze-particles
🤖 Agent: merging "Broadcast level event 2001 when sheep graze grass (#6144)" (head 627e538c3) after review by Ludovic's AI agent; thank you, @Sticky-Bug.
2026-11-01 21:42:58 +01:00
Ludovic Marie 2584880942 Merge pull request #6311 from Akirabane/fix/6152-submerged-mount
🤖 Agent: merging "Riders get off a mount that can't swim with one when their eyes are in water" (head fd43c4620) after review by Ludovic's AI agent; thank you, @Akirabane.
2026-11-01 21:42:53 +01:00
Ludovic Marie b29ac4857f Merge pull request #6309 from xFedeT/fix/alt-codes-text
🤖 Agent: merging "Type keyless characters (Windows Alt codes) into the focused text field" (head 76b26c980) after review by Ludovic's AI agent; thank you, @xFedeT.
2026-11-01 21:42:48 +01:00
Ludovic Marie ce180fd93e Merge pull request #6284 from some-du6e/fix/6133-baby-ageables
🤖 Agent: merging "Baby happy ghasts and nautiluses look like babies on a server" (head 02be02992) after review by Ludovic's AI agent; thank you, @some-du6e.
2026-11-01 21:42:43 +01:00
some-du6e d6b801c610 Merge branch 'optifine/cit-items' into optifine/cit-armor 2026-11-01 14:42:42 -06:00
Ludovic Marie b456d148f2 Merge pull request #6282 from k3yh/fix/ominous-spawner-summon
🤖 Agent: merging "Summon and select ominous item spawners" (head ebec5efda) after review by Ludovic's AI agent; thank you, @k3yh.
2026-11-01 21:42:39 +01:00
some-du6eandClaude Opus 5.5 41db622c34 OptiFine CIT: a plain texture takes every layer, so the pack's art isn't tinted
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:41:59 -06:00
HrafnaValkyrja fd93627b14 Merge remote-tracking branch 'origin/main' into fix/save-loaded-entities 2026-11-01 15:41:24 -05:00
HrafnaValkyrjaandClaude Sonnet 5.5 5a226d4585 Save primed TNT, firework rockets and thrown tridents
entity_chunks wrote none of them, so a world save or chunk unload dropped
them; tridents also had no load arm. Keys follow PrimedTnt,
FireworkRocketEntity and AbstractArrow/ThrownTrident addAdditionalSaveData.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 15:41:11 -05:00
Federico TaglianiandClaude Sonnet 5.5 c7d2b1199c A player who dies with a container open closes it at death, not at respawn
AbstractContainerScreen.tick closes the container as soon as the player is no longer alive, so
the server's doCloseContainer runs at death. Horizon closed a dead guest's container only in
guest_respawn, so the lid stayed up and PlayerContainerEvent.Close reached mods only after the
Respawn button. tick_player_menus now closes the menu (and drops the lid) for a dead guest and
counts a dead guest's other menus as closed.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 21:40:42 +01:00
breachatoandClaude Opus 5.5 05c7ec158f The trident probe reads clip_block_stop's face too
clip_block_stop now also returns the face normal; the probe ignores it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 21:40:19 +01:00
breachatoandClaude Opus 5.5 4fe32489bd A boat's entity tick Post comes after the rest of its tick
ServerLevel.tickNonPassenger runs AbstractBoat.tick in full (its lava
damage, the ejection of riders under water too long, the bubble column and
what it picks up or pushes) before EntityTickEvent.Post. tick_boats sent
Post right after tick_boat, before those passes, so a mod reading a boat
there saw it before them, and a boat it moved was then pushed and picked up
from there in the same tick. The ticked boats' Posts now go out, in order,
after the passes.

Fixes #6388

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 21:39:49 +01:00
breachatoandClaude Opus 5.5 2a2a749231 A stack's rarity component sets the colour of its name
ItemStack.getRarity reads the stack's minecraft:rarity, which is the item's
default unless a command or a server set it, and then upgrades it for
enchantments. Horizon's item_tooltip::rarity started from the item's default
only, so /give stick[rarity=epic] showed a white (or, enchanted, aqua) name
instead of a magenta one. The rarity component, which both the command and
the network paths keep among the stack's components, now comes first.

Part of #6405 (the rarity; the missing lore is not covered here).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 21:38:57 +01:00
Ludovic Marie 7673b45113 Merge remote-tracking branch 'origin/main' into prO/6305
# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
#	docs/native-mods.md
2026-11-01 21:36:51 +01:00
some-du6e 4625227d07 Merge branch 'optifine/cit-glint-rules' into optifine/cit-glint 2026-11-01 14:36:47 -06:00
some-du6eandClaude Opus 5.5 70fbc73c11 OptiFine CIT: glint tests import the reader
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:36:18 -06:00
arismoko 81c056227c Regenerate the mod API for main's draw_block docs 2026-11-01 20:33:03 +00:00
HrafnaValkyrja 6578a0abfd Merge remote-tracking branch 'origin/main' into fix/piston-push-entities 2026-11-01 15:32:22 -05:00
arismoko 58866f96a0 Merge upstream main (f4833621f9) 2026-11-01 20:31:46 +00:00
some-du6eandClaude Opus 5.5 d061ee0c99 OptiFine CIT: GUI glints ask for the stack's hand
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:30:29 -06:00
some-du6e 52fc61258d Merge branch 'optifine/cit-glint-rules' into optifine/cit-glint 2026-11-01 14:30:13 -06:00
some-du6eandClaude Opus 5.5 3238c1c18f OptiFine CIT: glint keys only on enchantment rules, hand= on glints, method=none, finite values, the cap before useGlint
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:29:41 -06:00
MSedgeMC bbec3f1a4a Read the title panorama through the resource layers
Vanilla registers the six CubeMapTexture faces with the texture manager, so
they resolve through the resource manager's pack stack: the topmost pack that
has a face supplies it, and the vanilla pack is the bottom layer. Horizon
flattens the stack into one overlay root and read the panorama from that root
alone, without the game's own resources the atlas build falls back to, so a
face missing from the overlay turned the whole cube into the grey placeholder
even when the game's resources had every face. The warning also dropped the
path it tried, leaving only the io error.

Each face is now taken from the first layer that has it, with the game's
resources behind the pack overlay, and a cube no layer completes falls back to
the placeholder with every path it looked in, plus the horizon-prepare hint
the sounds warning already gives.
2026-11-01 20:28:55 +00:00
MSedgeMC e7d70fbe7c Lock the pointer again when the window regains focus
Vanilla's grab outlives the window's focus: MouseHandler.grabMouse sets
mouseGrabbed and the disabled cursor mode, nothing releases either when the
focus goes, and the mode is re-applied when the window comes back, so the
pointer is locked again without a click (and put back at the window's
centre). Horizon released the pointer on every focus loss and never took it
back, and winit only applies a cursor clip while the window is focused and
does not re-apply one on WM_SETFOCUS, so the grab was gone: the pointer
stayed where it was, often outside the window, and clicks went to whatever
was under it.

The window now remembers whether it held the pointer when the focus went and
takes it again on the focus return, through the same capture(true) the rest of
the game uses. A screen that is open keeps the pointer free, and the existing
press-to-grab path stays as vanilla's fallback.
2026-11-01 20:26:42 +00:00
Ludovic Marie f4833621f9 Merge pull request #6215 from some-du6e/optifine/etf-fixes
🤖 Agent: verify passes on 8512214d0 (main merged, with #6246's cem_texture/paints_with_jem kept). OptiFine ETF fixes, base of the #6217/#6219/#6220 stack; render-only. Queued by pr-sweep-M.
2026-11-01 21:25:47 +01:00
breachatoandClaude Opus 5.5 7630a2abc3 native_breathing_stays_hookable reads breathing.rs with LF line endings
The same Windows checkout problem as the loot test: the breathing hook test
(new on main) looks for "\n}\n" in breathing.rs, which a core.autocrlf
checkout has in CRLF. It normalises the line endings first too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 21:25:33 +01:00
breachato a8ac6c8323 Merge remote-tracking branch 'upstream/main' into fix/loot-hook-test-crlf 2026-11-01 21:25:18 +01:00
Ludovic MarieandClaude Opus 5.5 cf686de992 Hook fragments: drop comment files that hold no API marker
block_entity_energy, data_map_types and item_fluid_tank listed files without
their @API marker, so stamp_native_hooks.py stopped on main and the native
hook stamp workflow has failed since #6291's merge. The hooks' own markers
are in native_hooks.rs, which the tool always stamps.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 21:24:07 +01:00
some-du6e b773895f68 Merge branch 'optifine/cit-armor' into optifine/cit-glint-rules 2026-11-01 14:23:24 -06:00
some-du6eandClaude Opus 5.5 c74b542a6c OptiFine CIT: worn armour counts as the main hand, tested in the lib; no allocation without a rule
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:22:50 -06:00
levzzz cefa8d988c Merge pull request #6296 from levzzz5154/mods/container-events
Native mods: a player opening and closing a container, PlayerContainerEvent
2026-11-02 01:22:01 +05:00
cristibctrandCodex 354e2e632d Send player mining drops in the same server tick
Finish guest actions before collecting block and entity updates. Drain packets before this snapshot even when world ticking is frozen. Add a two-client regression that queues an instant Survival break behind a server barrier at one TPS.

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-01 22:21:38 +02:00
Ludovic Marie 7e6f3d6bee Merge pull request #6264 from some-du6e/optifine/cit-overlay
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): ModelLibrary::overlaid / resolve_with_textures / bake_resolved for CIT model and texture swaps (unused until the next PR; tests); carries #6232 (CIT textures into the GUI atlas, load time only); main merged by me (lib split, sprites prefetch). verify green on 17e6fd55b.
2026-11-01 21:21:20 +01:00
Ludovic Marie 53c51005f5 Merge pull request #6260 from ErikKalikas/mod-block-entity-revision
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): ModBlockEntity revision behind Deref/DerefMut: kept mod block entity geometry checked by two integers instead of a deep compare (11 ms → 0.04 ms/frame at 4096 large machines); update tags merge without bumping when unchanged; main merged (ModEnergyBuffer imports). verify green on 978591ea1.
2026-11-01 21:21:16 +01:00
some-du6e f8588d4738 Merge branch 'optifine/cit-items' into optifine/cit-armor 2026-11-01 14:20:21 -06:00
Ludovic Marie 7db8c6c7b6 Merge branch 's6219' into s6220 2026-11-01 21:20:10 +01:00
Ludovic MarieandClaude Opus 5.5 07343d8312 Give the CEM sprite test's MobData the blinks field
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 21:20:08 +01:00
Ludovic Marie 08a64f07b7 Merge branch 's6217' into s6219 2026-11-01 21:20:08 +01:00
Ludovic MarieandClaude Opus 5.5 65ae7147aa Give the CEM sprite test's MobData the emissive field
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 21:20:07 +01:00
some-du6eandClaude Opus 5.5 c19317af24 OptiFine CIT: layer numbers from the claimed slot names
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:19:49 -06:00
some-du6e a5e220e097 Merge branch 'optifine/cit-items' into optifine/cit-armor 2026-11-01 14:19:29 -06:00
some-du6eandClaude Opus 5.5 616738141a OptiFine CIT: the generic texture takes the layers no texture.<name> claims; block items take it on every face
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:18:58 -06:00
arismoko 4fd32d3906 Merge upstream main (dd69edfbca)
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-11-01 20:18:05 +00:00
levzzz aa9c7c3ce1 Merge pull request #6360 from levzzz5154/mods/standalone-models
Native mods: their own models baked with the blocks', ModelEvent.RegisterStandalone
2026-11-02 01:17:30 +05:00
Evrim CicekandClaude Opus 5.5 5ebf42f7bf parity.py runs harnesses on Windows: file locks, process trees, no peak or shim there
parity.py started on Windows since #3428 but stopped before any harness
ran: machine_slot locked its slots with fcntl, which Windows lacks. The
rest of the run path was POSIX too: a timed-out harness was killed with
os.killpg, trace_main read the peak memory with resource, and the oracle
shim went on PATH as a `java` symlink to a Python script.

- tools/file_lock.py: an exclusive lock, flock or msvcrt's byte lock; used
  by machine_slot, integration_env.suite_lock and test_datafix_parity.
- kill_tree: the process group where there is one, taskkill /T on Windows.
- Without resource, the trace has no peak_bytes and the scheduler assumes
  DEFAULT_MEMORY, as for a harness that never ran.
- No oracle shim on Windows: a program started without a shell is found
  there only as an .exe, and the shim's execv returns before the program.
  Harnesses run their oracles fresh.

Fixes #6391

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 23:17:01 +03:00
Ludovic Marie 7e06642fa2 Merge pull request #6270 from Detimes/feat-sign-changed-hook
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): native_sign_changed after every updateSignText check, through as_guest_messaged; cancel keeps the text and resends the block entity; no cost without a sign update. Union of 6247+6260+6270+6279 with main: Python tests 594 OK, stamp_native_hooks OK; verify green on 6b6b55279.
2026-11-01 21:17:00 +01:00
Ludovic Marie 700a9e9e60 Merge pull request #6252 from MSedgeMC/fix/wither-skull-resistance
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): WitherSkull.getBlockExplosionResistance cap; main merged (kept #6262's test beside this one). verify green on ea514b4c6.
2026-11-01 21:16:55 +01:00
Brineexee b2d84a7f9e Merge branch 'main' into launcher-ui-fix 2026-11-01 21:16:12 +01:00
MSedgeMC 73e19ffba7 Clear three ledger rows that are no longer missing
CreateWorldScreen.keyPressed, KeyMapping.setAll and EnchantmentHelper.createBook are all
implemented on main; the rows were written before the code that covers them, so they now
point at the wrong work.
2026-11-01 20:14:49 +00:00
HrafnaValkyrjaandClaude Sonnet 5.5 534c6ee114 lan_e2e: piston-entities check for a falling block and an arrow in a piston's way
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 15:14:26 -05:00
some-du6eandClaude Opus 5.5 89b6008f8b OptiFine entity models: packs' .jem files reshape chests, shulker boxes, banners and other block entities
- block_entity_cem.rs applies a pack's .jem to the block-entity models: chest(_left/_right),
  trapped_chest(_left/_right), ender_chest, shulker_box, banner / banner_base / banner_flag,
  head_*, bell, book / enchanting_book / lectern_book, conduit and decorated_pot, with
  OptiFine's part names mapped to the vanilla ones (knob -> lock, stand -> pole, slate -> flag...)
- Entries replace or join the vanilla part's boxes and take their scale; animations come later
- The .jem's texture paints its model when the atlas has it; trapped and ender chests borrow
  the chest's geometry without its texture, copper chests take the geometry only
- Closed-chest meshes are baked from the reshaped models, per chest model
- BlockEntityShapes follow pack reloads (built on the reload worker, swapped on apply); without
  .jem files the renderer keeps sharing vanilla's shapes
- The .jem discovery and reading move from mobs/cem.rs to horizon_optifine::cem::files

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:13:19 -06:00
MSedgeMC c77c94cb22 Merge main into the arrow rotation interpolation 2026-11-01 20:12:33 +00:00
Ludovic MarieandClaude Opus 5.5 6def6595b5 Fox chorus test: skip without the vanilla data, as CI's unit tests run
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 21:12:06 +01:00
Ludovic Marie 7ee14b2df6 Merge pull request #6247 from Xmilon/native/content-state-vanilla-order
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): content block states laid out in StateDefinition order (names sorted, first slowest) instead of refusing a property that sorts first; ids unchanged for blocks that already loaded (test); load-time only. Union of 6247+6260+6270+6279 with main: Python tests 594 OK, stamp_native_hooks OK; verify green on b40d434d4.
2026-11-01 21:11:55 +01:00
MSedgeMC 0d3eb998ae Send a silverfish back into the stone it came from
Silverfish.getWalkTargetValue gives a position standing on an infested host block a value
of 10, so a silverfish wanders back toward stone rather than away from it. Horizon had no
silverfish case, so stone scored the same as any other floor and the goal was missing.
2026-11-01 20:11:35 +00:00
SenjinandClaude Opus 5.5 f3ae538469 HDR: glowing items shine, dropped, framed and held, the hand's too
An item takes the tier of its block, through the same table as dynamic lights.
World items are marked as other entities are. The hand stays at the world's
brightness: its own pass writes the kept world's alpha, 1 where the hand drew and
the mark where it holds a light source, and HDR output reads that for hand pixels.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 21:11:18 +01:00
levzzz dd69edfbca Merge pull request #6291 from levzzz5154/mods/native-render-player
Native mods: drawing a player and adding layers to entities, RenderPlayerEvent and AddLayers
2026-11-02 01:11:14 +05:00
breachatoandClaude Opus 5.5 4690ae1cdb Fill three gaps in the native entity mount hook
- A guest's mount events carried entity id 0: native_acting_entity looked the
  acting guest up through the hub, which is often out of `lan` while the
  guest acts. The guest's entity id now travels with the action
  (lan_acting_entity), like its name, UUID and permission.
- A mob a boat or minecart picks up now asks native_mob_may_ride first, as
  vanilla's pickups go through Entity.startRiding; a canceled one stays off.
- Boarding a cushion no longer seats a player whose dismount from the old
  vehicle a mod canceled: startRiding stops when stopRiding left the
  passenger seated.

Fixes #6365

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 21:11:02 +01:00
levzzz 7221836b35 Merge pull request #6298 from levzzz5154/mods/living-equipment
Native mods: a mob's equipment changing, LivingEquipmentChangeEvent
2026-11-02 01:09:06 +05:00
levzzz 1ccdaacf6e Merge pull request #6331 from levzzz5154/mods/item-fluids
Native mods: fluid in an item, Capabilities.FluidHandler.ITEM
2026-11-02 01:08:54 +05:00
Ludovic Marie b3c2b6dddb Merge pull request #6250 from some-du6e/optifine/ctm-overlay
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): CTM overlays (OptiFine/Continuity docs only); my commit b41a21a6b skips the overlay pass for faces without overlays, so meshing without CTM costs the same as before. verify green on b41a21a6b.
2026-11-01 21:08:21 +01:00
Ludovic Marie fa59636c5b Merge pull request #6245 from some-du6e/optifine/emf-anim-fixes
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): CEM animation fixes, re-read at the new head (test tweak + main merge only); no-pack draw path unchanged. verify green on bf724b491.
2026-11-01 21:08:16 +01:00
MSedgeMC 74ae0056e8 Dry a dolphin out with dry_out, not drowning
Dolphin.tick hurts a dolphin whose moistness ran out with damageSources().dryOut(), so the
death message says it dried out; Horizon used the drown source, which reads as drowning.
2026-11-01 20:08:12 +00:00
breachatoandClaude Opus 5.5 2b4b7d06f3 A player's thrown trident fires the projectile impact hook
native_projectile_impact's doc comment lists tridents among the projectiles
it covers, but a trident a player throws flies in tick_tridents, not
tick_projectiles, and never called the hook. tick_tridents now tells the
mods about its mob and block hits (the face from the block clip, which
clip_block_stop now returns), and a canceled one does nothing: no damage,
sound or deflection on a mob, and on a block the trident stops where it would
have hit without sticking, as the doc comment says of arrows.

Part of #6366 (the trident; the canceled arrow that still sticks needs a
decision on where a canceled arrow goes).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 21:07:45 +01:00
levzzz ac2f223719 Merge pull request #6280 from levzzz5154/mods/entity-tick
Native mods: an entity's tick, EntityTickEvent.Pre / Post
2026-11-02 01:05:36 +05:00
levzzz ceb9787171 Merge remote-tracking branch 'hr/main' into mods/standalone-models
# Conflicts:
#	crates/horizon-client-render/src/models/catalog.rs
#	crates/horizon-client/src/game/mods.rs
#	crates/horizon-client/src/game/native_hooks.rs
2026-11-02 01:04:55 +05:00
HrafnaValkyrjaandClaude Sonnet 5.5 326a9f6241 Pistons and shulker lids push falling blocks, XP orbs, projectiles, rockets and tridents
Vanilla's PistonMovingBlockEntity.moveCollidedEntities (and
ShulkerBoxBlockEntity.moveCollidedEntities) move every entity from
getEntities except PushReaction.IGNORE_ENTITY, which only a marker
armor stand returns. push_targets covered players, mobs, items, boats,
carts, stands and TNT only.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 15:04:50 -05:00
sv 26e6743fba Title screens scroll a label wider than its button, as vanilla does
Title::draw's button loop drew every title-screen label centred over its own
full width, so a label wider than the button ran over the buttons beside it
(Create World's 590 px World Type label in so_so, Multiplayer's Join Server in
hi_in). AbstractButton.extractDefaultLabel hands the label to
extractScrollingStringOverContents with a 2 px margin, and vanilla slides it
inside the button; the loop now draws through ui::scrolling_text, which is
that helper.
2026-11-01 20:04:41 +00:00
rdbtCVSandClaude Opus 5.5 45129f3116 Menu blur and entity outline targets are freed while no frame uses them
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 20:04:27 +00:00
rdbtCVSandClaude Opus 5.5 ff41750d06 Chunk encoder: sections' light layers are not kept once sent
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 20:04:27 +00:00
rdbtCVSandClaude Opus 5.5 1b56d2d431 Meshing workers share the face visibility's states and faces
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 20:04:27 +00:00
rdbtCVSandClaude Opus 5.5 745fafeafe Video memory in 8-64 MiB blocks: half the reserved VRAM was unused
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 20:04:27 +00:00
rdbtCVSandClaude Opus 5.5 7a4638971e Title panorama: the cube only while the title shows, its faces kept as their files
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 20:04:27 +00:00
MSedgeMC 82de324266 Save a dolphin's fish and moistness
Dolphin.addAdditionalSaveData writes GotFish and Moistness; Horizon kept both in the mob
but wrote neither, so a fed dolphin forgot it was fed and a wet one came back from a reload
dry and drowned.
2026-11-01 20:04:25 +00:00
Ludovic Marie f3959d23a9 Merge main into #6296
# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
2026-11-01 21:03:40 +01:00
arismoko 411e7ee665 Merge upstream main (8101f23df4)
# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
2026-11-01 20:02:57 +00:00
Ludovic Marie 17e6fd55bf Merge remote-tracking branch 'origin/main' into fix-6264
# Conflicts:
#	crates/horizon-client-render/src/sprites.rs
#	crates/horizon-client/src/lib.rs
2026-11-01 21:02:48 +01:00
levzzz 80dfb5f357 Merge pull request #6359 from levzzz5154/mods/native-blocks-items
Native mods: blocks and items declared in code, DeferredRegister
2026-11-02 01:02:01 +05:00
levzzz 2a9d2b57db Merge pull request #6299 from levzzz5154/mods/living-breathe
Native mods: breathing, LivingBreatheEvent
2026-11-02 01:01:50 +05:00
ErikKalikasandClaude Opus 5.5 26103c104e Native mods: a mod loot kind sees where the roll happens
NativeLootContext gains `place`: the roll's origin (LootContextParams.ORIGIN),
the broken block's entity as it was (BLOCK_ENTITY, saved with its id,
position and Items) and the level (dimension, game time, time of day, rain,
thunder), for conditions like vanilla's time_check and weather_check. The
game sets it with `with_native_loot_place` around a player's and an
explosion's block drops and a mob's death loot; both loot engines put it in
the context. Other rolls carry None. API fragment `loot_context_place`.

Fixes #6200

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 22:00:49 +02:00
MSedgeMC 10d8d84295 Registry: a page's Origin against the site's origin, not its whole URL
A submission or a rating is taken only from the site's own pages: its Origin
header, then the session and its CSRF token. The registry's README says the
request must come from REGISTRY_PUBLIC_URL's origin, and that is what a
browser sends: a scheme, a host and a port, never a path.

The gate compared the Origin to REGISTRY_PUBLIC_URL as whole strings, so a
public URL that carries a path (the form is a page below the site's root) or
another case in its host name turned the site's own page away with "send it
from the registry's pages" (403). The origin is now taken from the public
URL, case folded as a host name is, and the environment value is trimmed of
whitespace before it is used, as a pasted deploy variable may carry it.

Fixes #6159
2026-11-01 19:59:12 +00:00
some-du6e 9a15e99e66 Merge branch 'optifine/cit-glint-rules' into optifine/cit-glint 2026-11-01 13:59:10 -06:00
some-du6e 5777305264 Merge branch 'optifine/cit-armor' into optifine/cit-glint-rules 2026-11-01 13:59:10 -06:00
some-du6eandClaude Opus 5.5 3fcd220b56 OptiFine CIT: worn armour reads the stack's max damage; the layer and wing choice in the lib, tested; one sprite collector; docs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 13:59:10 -06:00
Ludovic Marie b16454fd22 Merge remote-tracking branch 'origin/main' into s6237
# Conflicts:
#	crates/horizon-client/src/title/flat.rs
2026-11-01 20:58:41 +01:00
MSedgeMC c30dc0febf Push the shield before the hit that it only partly blocked
hurtServer runs applyItemBlocking (the shield's knockback) before dealDefaultKnockback
(the hit's own), and knockback halves the old motion, so the order decides the direction.
Horizon made the hit's push first and the shield's second, which sent a half-blocked
defender toward the attacker at twice the speed.
2026-11-01 19:58:26 +00:00
Ludovic Marie 2c9b58e72a Merge pull request #6277 from some-du6e/packs/sprite-prefetch
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): Sprites::prefetch decodes block and CTM textures on every thread, inserts keep their order (test: same layers/pixels with and without); load-time only, no per-frame cost; includes #6254. verify green on 5543bffed.
2026-11-01 20:57:44 +01:00
Ludovic Marie fac351e3b3 Merge pull request #6254 from some-du6e/packs/overlay-parallel
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): overlay build walks each folder's run of the sorted map and inflates zip entries on every thread (1686→691 ms cold); test checks every file of a 3-pack stack; pack-bench.yml is ubuntu-latest, contents: read, manual/push only. verify green on e0eb57399.
2026-11-01 20:57:39 +01:00
levzzz 783f2d31ca Merge remote-tracking branch 'hr/main' into mods/living-equipment
# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
2026-11-02 00:57:34 +05:00
Ludovic Marie 95abca8807 Merge pull request #6189 from MSedgeMC/fix/villager-ambient
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): Villager.getAmbientSound sleeping/trading branches; main merged, test modules kept whole. verify green on 736fe7c62.
2026-11-01 20:57:34 +01:00
Ludovic Marie 978591ea13 Merge remote-tracking branch 'origin/main' into fix-6260
# Conflicts:
#	crates/horizon-client-render/src/containers.rs
#	crates/horizon-client/src/game/native_hooks.rs
2026-11-01 20:57:30 +01:00
levzzz 96ca047ac7 Merge remote-tracking branch 'hr/main' into mods/item-fluids
# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
2026-11-02 00:57:27 +05:00
Ludovic MarieandClaude Opus 5.5 34a22fde39 Merge main into #6291 (main has remote_player_name)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 20:55:09 +01:00
levzzz cfbae5bd0d Merge remote-tracking branch 'hr/main' into mods/entity-tick
# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
2026-11-02 00:55:08 +05:00
some-du6e a584067100 Merge branch 'optifine/cit-items' into optifine/cit-armor 2026-11-01 13:55:00 -06:00
some-du6eandClaude Opus 5.5 4d4de49de9 OptiFine CIT: a plain texture is the whole item; texture.<name> on a layer keeps the rule's model; 26.x names beat old ones
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 13:54:28 -06:00
HrafnaValkyrja ee3f1787ff Merge remote-tracking branch 'origin/main' into fix/items-stuck-in-web 2026-11-01 14:54:08 -05:00
HrafnaValkyrjaandClaude Opus 5.5 1f6a8bb3da Cobweb and powder snow slow dropped items
Dropped items now get makeStuckInBlock in cobwebs and powder snow.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 14:54:08 -05:00
breachatoandClaude Opus 5.5 9e09e3c4c1 native_loot_kinds_stay_hookable reads loot.rs with LF line endings
The test looks for "\n}\n" to find the end of each loot hook's body in
loot.rs, read with include_str!. A Windows checkout with core.autocrlf has
the file in CRLF, so the search found nothing and the test panicked on every
branch there. It now normalises the line endings first, as the tests next to
it do.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 20:52:22 +01:00
HrafnaValkyrja 7a7f5e7ba1 Merge remote-tracking branch 'origin/main' into fix/hopper-item-cycle 2026-11-01 14:50:46 -05:00
HrafnaValkyrjaandClaude Opus 5.5 01ba87ebc2 Hopper takes a falling item on entry
An item entering a hopper's pickup area is taken on that tick, as vanilla's entityInside does, so transfers stay in step.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 14:50:46 -05:00
levzzz 8101f23df4 Merge pull request #6332 from levzzz5154/mods/data-maps
Native mods: data maps, RegisterDataMapTypesEvent
2026-11-02 00:50:15 +05:00
Ludovic Marie cf60b9e15c Merge branch 's6219' into s6220 2026-11-01 20:50:01 +01:00
Ludovic Marie 09fac98139 Merge branch 's6217' into s6219
# Conflicts:
#	crates/horizon-client/src/mobs/render.rs
2026-11-01 20:50:00 +01:00
levzzz ae0eed976d Merge pull request #6330 from levzzz5154/mods/block-energy
Native mods: energy in a mod block, Capabilities.Energy.BLOCK
2026-11-02 00:49:59 +05:00
Ludovic Marie 1ae31db400 Merge pull request #6212 from arslanarm/shaderpacks/center-depth-smooth
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): centerDepthSmooth; main merged (union with #6211's uniforms). verify green on e5aa08936.
2026-11-01 20:49:47 +01:00
Ludovic Marie aff1bcc05f Merge optifine/etf-fixes (main merged) into optifine/etf-emissive
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/mobs/render.rs
2026-11-01 20:49:39 +01:00
levzzz 294ae20e48 Merge pull request #6297 from levzzz5154/mods/player-tracking
Native mods: a player starting and stopping tracking an entity, PlayerEvent.StartTracking
2026-11-02 00:49:38 +05:00
levzzz 648a748dc1 Merge pull request #6292 from levzzz5154/mods/native-map-decorations
Native mods: their own icons on maps, the client half of MapDecorationType
2026-11-02 00:49:28 +05:00
levzzz ce7aa310ae Merge pull request #6290 from levzzz5154/mods/native-gui-layers
Native mods: drawing between the HUD's own layers, RegisterGuiLayersEvent
2026-11-02 00:49:17 +05:00
levzzz fdd70c76fa Merge pull request #6289 from levzzz5154/mods/native-interaction-key
Native mods: the attack, use and pick keys, InputEvent.InteractionKeyMappingTriggered
2026-11-02 00:49:12 +05:00
levzzz 09865ff96a Merge pull request #6281 from levzzz5154/mods/entity-mount
Native mods: an entity mounting or dismounting, EntityMountEvent
2026-11-02 00:49:01 +05:00
levzzz 850cfc5448 Merge pull request #6279 from levzzz5154/mods/projectile-impact
Native mods: a projectile's impact, ProjectileImpactEvent
2026-11-02 00:48:11 +05:00
CocofireHDandHermes Agent ae5d7c78ae Tags: a primed TNT can be tagged
`/tag` reads a target's `Tags` out of `persist.raw` for eight kinds of target
(`tags_of`), and `raw_mut` -- the only writer, reached from `set_entity_tags`
-- spelled seven of them: `Target::Tnt` fell through to `_ => None`, so
`/tag add` and `/tag remove` on a primed TNT printed success and stored
nothing.

Vanilla has no such split: `TagCommand.addTag` calls `Entity.addTag` and
`removeTag` calls `Entity.removeTag` on whatever the selector returned.

Measured in a running client (creative world, `horizon-ctl`): before, `add
boom` left `Primed TNT has 1 tag(s): seeded` and `remove seeded` left it there
too; with the arm, `boom, seeded` then `boom`, and
`@e[type=minecraft:tnt,tag=boom]` matches.

Co-Authored-By: Hermes Agent (deepseek-v4.1-flash) <[email protected]>
2026-11-01 20:48:01 +01:00
Ludovic MarieandClaude Opus 5.5 8512214d0e Merge main into optifine/etf-fixes
#6246 picks a .jem's texture through cem_texture and paints_with_jem:
render_visible takes main's choice, and drawn_textures follows it (the
.jem texture and the vanilla one it falls back to are both listed).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 20:47:47 +01:00
Ludovic Marie 5706ce95c7 Merge main into #6299
# Conflicts:
#	crates/horizon-client/src/lib.rs
2026-11-01 20:46:24 +01:00
HrafnaValkyrja bfd6dc917d Merge remote-tracking branch 'origin/main' into fix/effect-amplifier-nbt 2026-11-01 14:44:40 -05:00
HrafnaValkyrjaandClaude Opus 5.5 1a89bbb759 Mob effect NBT leaves out amplifier 0
Vanilla omits a default amplifier when it writes active_effects.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 14:44:39 -05:00
some-du6e 3e962c2dd1 Merge branch 'optifine/cit-glint-rules' into optifine/cit-glint 2026-11-01 13:44:13 -06:00
some-du6e a5b544fa16 Merge branch 'optifine/cit-armor' into optifine/cit-glint
# Conflicts:
#	crates/horizon-client/src/game/item_draw.rs
#	crates/horizon-client/src/item_render/cit.rs
2026-11-01 13:44:13 -06:00
some-du6eandClaude Opus 5.5 8ba7410f91 OptiFine CIT: enchantment glint layers and cit.properties, chosen per stack
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 13:43:39 -06:00
some-du6e ee27509165 Merge branch 'optifine/cit-items' into optifine/cit-armor 2026-11-01 13:43:20 -06:00
Ludovic MarieandClaude Opus 5.5 ccbc2d1fe5 Native mods: compare a player's open menu without building it each tick
tick_player_menus built the open menu's name as a String and cloned the
last heard one for every player with a menu open, every tick. It now
compares borrowed fields and builds the owned menu only on a change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 20:41:56 +01:00
Ludovic Marie ea514b4c6b Merge remote-tracking branch 'origin/main' into fix-6252
# Conflicts:
#	crates/horizon-client/src/game/explosion.rs
2026-11-01 20:41:08 +01:00
some-du6e bf724b4919 Merge main into optifine/emf-anim-fixes
# Conflicts:
#	crates/horizon-client/src/mobs/render.rs
2026-11-01 13:40:25 -06:00
cristibctrandCodex 27cd7982a3 Fix GPU timer readback after buffer destruction
Stop timing after readback failure and do not unmap an unreadable buffer. Cover normal readback and destruction after a successful map callback.

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-01 21:40:23 +02:00
Ludovic MarieandClaude Opus 5.5 67e3fadf72 Native mods: pose players and mobs for the render hooks only while a mod listens
remote_player_names() built every player's name as Strings for each
player drawn, every frame, and native_mob_layer posed every mob in view
with no mod to ask. A direct lookup (remote_player_name) replaces the
list, and native_player_render_listened gates both hooks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 20:40:23 +01:00
Ludovic MarieandClaude Opus 5.5 b41a21a6bb CTM overlays: a face without overlays skips the overlay pass
The mesher runs this for every face of every section: work out the face's
translucency and walk the overlay slots only when a rule gave one.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 20:39:58 +01:00
MSedgeMC 244c866b50 Draw an arrow between the rotations of the two ticks it turns over
AbstractArrow.tick turns its aim 20% of the way to the flight direction each
tick, and vanilla draws the model between the last two ticks' rotations
(Entity.setOldPosAndRot, then Mth.lerp(partialTick, yRotO, yRot)). Horizon
kept only a previous position, so a turning arrow was drawn in whole-tick
steps of up to 36 degrees, a tick ahead of the position it was drawn at.
2026-11-01 19:39:52 +00:00
JoansitohandClaude Sonnet 5.5 9696cd39ea End a for body's hiding at a rewritten return/discard statement too
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 20:39:44 +01:00
JoansitohandClaude Sonnet 5.5 93ce3a81a7 End a for-header variable's scope at an unbraced body's statement; stronger block test
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 20:39:08 +01:00
JoansitohandClaude Sonnet 5.5 db13dfa15c Hide a bool uniform behind for-header variables and second declarators; sharper tests
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 20:39:08 +01:00
JoansitohandClaude Sonnet 5.5 be512ea4e9 Scope a hidden name to its block
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 20:39:08 +01:00
JoansitohandClaude Sonnet 5.5 7ebed75520 Test the bool uniform read in main
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 20:39:08 +01:00
JoansitohandClaude Sonnet 5.5 5db3adb4e5 Shader packs: a parameter or local named like a bool uniform hides it
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 20:39:08 +01:00
Ludovic Marie e139ab2a32 Merge pull request #6246 from some-du6e/optifine/emf-model-fixes
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): CEM texture fixes: a .jem texture paints its own layer only, atlas-missing texture falls back to vanilla, EMF folder precedence; no-pack path does less work (no texture clone); docs-only sources. verify green on 8a7c9259f.
2026-11-01 20:38:52 +01:00
HrafnaValkyrja 5c0086ceb0 Merge remote-tracking branch 'origin/main' into fix/protection-generic 2026-11-01 14:38:41 -05:00
MSedgeMC 0de0b1d1b7 Let Tab reach the world list so a keyboard player can join
SelectWorldScreen's world list takes keyboard focus and its rows answer Enter and Space;
in Horizon Tab went from the search box straight to the buttons, so the list could only
ever be used with a mouse and a keyboard player had no way to open a world.
2026-11-01 19:38:26 +00:00
some-du6eandClaude Opus 5.5 ed15952d2e OptiFine entity models: the globals test fills is_in_gui so a storing preview shows
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 13:38:20 -06:00
MSedgeMC 5057e98cd3 Widgets scroll a label wider than themselves, as vanilla does
ui::button, ui::button_as, ui::slider and ui::slider_ext clipped a label
wider than the widget by characters, so its end was unreadable ("Graphics
API: Prefer Vulkan", "Simulation Distance: 32 Chunk", "> Left Contro").

Vanilla slides it instead: AbstractWidget.extractScrollingStringOverContents
hands the label to ActiveTextCollector.defaultScrollingHelper, which draws it
left-aligned at left - (int) pos inside a scissor of the widget's box, pos
sliding over the overflow with a sine period of max(overflow * 0.5, 3) s.

The two ui.rs widget paths now draw through scrolling_text, which is that
helper; centered_text keeps its clipped centring for titles and text lines,
which vanilla does not scroll.
2026-11-01 19:38:00 +00:00
Evrim CicekandClaude Opus 5.5 f3de190aaf Play with friends: the host attaches from any port when its direct port times out
The host attaches every relayed session from its direct port, so each
attach to a relay reuses one address pair. The relay closes a finished
session first and keeps that pair in TIME_WAIT; a relay on Windows then
ignores the host's next attach from the same port (Linux takes it), the
connect times out instead of reporting the address in use, and
connect_from's fallback never runs. The second guest within a couple of
minutes got HostUnreachable.

connect_relay now gives the relay's addresses that timed out from the
direct port one more try from any port, after every address had its try
from the direct port (a dead IPv6 route doesn't eat the relay's attach
window twice). An attach from another port no longer offers what the relay
saw of it as a direct candidate: nothing listens there. The direct race's
dials keep their port.

Fixes #6342

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 22:37:49 +03:00
Ludovic Marie 4e0492a050 Merge pull request #6251 from senjinthedragon/hdr-png-icc
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): HDR screenshot gains an ICC v2 profile (BT.2020, PQ to 203 nits) beside cICP; screenshot path only, no per-frame cost; unit tests on the profile. verify green on 4fa1a55b0.
2026-11-01 20:37:47 +01:00
Ludovic MarieandClaude Opus 5.5 fff651702e Native mods: an item merged before its tick hears no Post; a minecart's Post after it is stored
Review points from #6280 (arismoko): an item merged into an earlier one
never ticked, so it is taken out of the round; a minecart's Post now
comes once the ticked cart is written back, so a mod reads and changes
the cart as it is. Unit test for the round.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 20:37:45 +01:00
Ludovic Marie d57b4fcb5b Merge pull request #6239 from MSedgeMC/fix/merchant-scroll
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): MerchantScreen.mouseScrolled: (int)(scrollOff - scrollY), clamped. verify green on 8801bf499.
2026-11-01 20:37:43 +01:00
Ludovic Marie 5554d04638 Merge pull request #6236 from HrafnaValkyrja/fix/armor-stand-fall
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): three stale tests fixed (armor stand first tick, TNT water and up bubble column); test-only. verify green on 9cabc6278.
2026-11-01 20:37:39 +01:00
Ludovic Marie d7645f1972 Merge pull request #6274 from MSedgeMC/fix/gp-boat-momentum
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): AbstractBoat.tick non-authoritative branch zeroes motion and deltaRotation: the boat stops on dismount. verify green on 9425bb9ac.
2026-11-01 20:37:35 +01:00
levzzzandClaude Opus 5.5 7a9f20a33f Native mods: models no blockstate names, ModelEvent.RegisterStandalone
native_standalone_models(models) is asked once at start for the ids of
models a block entity or a mob draws as parts. Every resource load, a pack
reload included, bakes them beside the block models (StateModels::
bake_standalone; one a state already bakes unturned is shared, one that
doesn't read is logged and left out). native_draw_model(draw, model, tint)
draws one under a block entity's or mob's pose, as native_draw_block draws
a block's model. API from a fragment.

Step 1 of #6303 (the issue stays open for steps 2 to 4).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 00:37:34 +05:00
Ludovic Marie 4fe36748e0 Merge pull request #6262 from MSedgeMC/fix/items-ignore-keep-blast
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): ItemEntity.ignoreExplosion via ServerExplosion.shouldAffectBlocklikeEntities (javap checked: griefing off + KEEP ignores items; no wind charges reach server_explode). verify green on 37fc39e48.
2026-11-01 20:37:30 +01:00
Ludovic Marie 263781cd8f Merge pull request #6240 from MSedgeMC/fix/recipe-magnifier
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): magnifier hit box = RecipeBookComponent's ScreenRectangle.of(xOrigin+8, width searchBox.getX()-xOrigin) (javap checked). verify green.
2026-11-01 20:37:27 +01:00
breachatoandClaude Opus 5.5 8b728305b1 The client level unloads when the server sends it back to configuration
ClientPacketListener.handleConfigurationStart calls
Minecraft.clearClientLevel, which drops the client level there. Horizon's
Reconfiguring cleared the level state but kept heard_level, so
native_client_level_unload waited for the next login (after the new level's
load) or a disconnect, and a mod kept the old level's state through the
configuration screen, as on a proxy's server switch. Reconfiguring now takes
heard_level and fires the unload.

Fixes #6317

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 20:36:22 +01:00
breachatoandClaude Opus 5.5 0ccddf82b8 A respawn into another level of the same dimension type makes a new level
ClientPacketListener.handleRespawn keeps its ClientLevel only when the
spawn's level key is the current level's, and makes a new one otherwise.
Horizon compared the dimension type's name, so moving between two levels
sharing a type (a data pack or mod dimension using the Overworld's) kept the
old level's chunks and entities, and fired neither native_client_level_load
nor native_client_level_unload. It now compares the level key the client
last entered (heard_level), so such a move clears the world and fires both
hooks, with the right level named on the next unload.

Fixes #6316

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 20:35:43 +01:00
Ludovic MarieandClaude Opus 5.5 84a5b49f98 Native mods: tell entities' ticks only while a mod listens (native_entity_tick_listened)
On every server, each tick, the mob and item rounds cloned each ticking
mob's type into a list and each boat built its type name, with no mod to
tell. A mod that hooks native_entity_tick_pre/post now also hooks
native_entity_tick_listened and sets it; otherwise no round is kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 20:35:19 +01:00
some-du6eandClaude Opus 5.5 382c9e7499 OptiFine CIT: texture.<name> on the layer it names, OptiFine's old sub-names, broken models stay vanilla; docs row
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 13:34:20 -06:00
levzzzandClaude Opus 5.5 bed9c5fd86 Native mods: blocks and items declared in code, DeferredRegister / RegisterEvent
native_blocks(blocks) and native_items(items), asked once at start on every
side, take horizon_mods::content::Block and Item: the declarations a content
mod's data/<id>/horizon/block/ and item/ files hold. They join the content
plan, inside the content mod whose id is their namespace when it is on, else
as a content mod with no files of its own (Content::declared), and pass the
files' checks (Content::declare, shared check_block and check_item), so their
state and network ids, saves and the joining client's content check are the
files'. A namespace whose declarations are refused is logged and left out.
The content root's stamp counts the declarations. API from a fragment.

Step 1 of #6300 (the issue stays open for its later steps).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 00:33:47 +05:00
Ludovic MarieandClaude Opus 5.5 b70801e92a Merge optifine/etf-blink (#6219, rebuilt on the fixed #6217) into optifine/etf-block-entities
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 20:33:04 +01:00
Ludovic MarieandClaude Opus 5.5 9d0ca26de9 Merge optifine/etf-emissive (#6217, fixed, main merged) into optifine/etf-blink
render.rs: both sides added emissive tests at the same place; each kept whole.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 20:32:55 +01:00
Ludovic MarieandClaude Opus 5.5 f8a2d8b4f3 Give the two test armour layers the glows flag
render_with's extra layers carry whether they glow since the emissive
change; two tests still built 4-tuples.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 20:32:12 +01:00
Ludovic Marie 62bdd29b23 Merge pull request #6234 from Ragbie/fix/capture-test-windows
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): test fixture expects Windows' extra warp before the grab; test-only. verify green on f7301189a.
2026-11-01 20:32:01 +01:00
Ludovic MarieandClaude Opus 5.5 f9ef840898 Native mods: read mobs' equipment only while a mod listens (native_living_equipment_listened)
The per-tick comparison cloned every ticking mob's equipment each tick in a
game without mods. A mod that hooks native_living_equipment_change now
also hooks native_living_equipment_listened and sets it; otherwise nothing
is read or kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 20:31:57 +01:00
Ludovic MarieandClaude Opus 5.5 20e3381dd6 Merge optifine/etf-fixes (#6215, main merged) into optifine/etf-emissive
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 20:31:48 +01:00
MSedgeMC 367515af39 Send an iron golem to a spot near its target, not straight at it
MoveTowardsTargetGoal.canUse asks DefaultRandomPos.getPosTowards for a random spot within
16 blocks and 7 up or down, a quarter turn off the target, and start walks to that spot.
Horizon navigated straight at the target's own position, so a golem always drove into the
entity it was after.
2026-11-01 19:31:45 +00:00
Ludovic MarieandClaude Opus 5.5 edccd5b58b Merge main into optifine/etf-fixes
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 20:31:40 +01:00
o.alzahraniandClaude Opus 5.5 a0f851b33e docs(hosting): Horizon players join online-mode servers and sign their chat
"Before you start" still said Horizon's client can't sign in to a Microsoft
account, so Horizon players could join offline servers only, and that it has
no chat session. Both stopped being true: the launcher signs players in
(docs/microsoft-account.md), the game joins online-mode servers, and since
#4375, #4376 and #4377 (Nightly 2026-11-01) it signs its chat.

- Online mode: every player joins with their own account, vanilla and
  Horizon players alike.
- Offline servers: the same safety advice, and only players under an
  offline name are limited to them.
- Secure chat: Horizon's client signs its chat too.

Refs #3332 (the website half is horizon-rust/horizon-site#38)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 22:31:18 +03:00
arismoko b4c7f5e422 An idle level's entity attachments leave memory when it closes
They are saved with its entities, but stayed in the server's map until
the world closed.
2026-11-01 19:30:14 +00:00
Maksymilian AdamkiewiczandCodex 53ba4b5a0a fix: preserve server-forced flight without toggle permission
LocalPlayer.aiStep gates flight toggling with Abilities.mayfly; it does not
clear a server-supplied flying state when that permission is false. Stop
clearing flight on every movement tick so zero-speed server-forced flight
holds the player in place, matching vanilla 26.3.

Add a local-input regression and extend the resident movement parity harness
with independent flight flags, zero/default speeds and explicit flight end.

Validation: 39 movement tests pass; eight flight scenes match both vanilla
jars; player_travel and living_motion parity pass; focused Clippy passes;
the fast-profile game binary builds. Contributor confirmed the correction
on benchmark.ogulniega.com.

Full resident scene parity also exposes an unrelated attribute-cache mismatch
for negative fall damage, reproducible without movement/abilities operations.

Full pr_ready --quick is blocked by missing ALSA development files and an
existing Python block-entity fixture missing Entities.has_mods.

Co-Authored-By: Codex <[email protected]>
2026-11-01 20:30:06 +01:00
arismoko a5806d4e3c A staged save writes the level's attachments too
horizon-ctl save / save-all on a dedicated server, and closing a world
through the staged save, wrote the forced chunks but not the level's
attachments.
2026-11-01 19:29:38 +00:00
Sticky-BugandAntigravity 627e538c35 Broadcast level event 2001 when sheep graze grass (#6144)
Co-Authored-By: Antigravity <[email protected]>
2026-11-02 00:59:01 +05:30
Ludovic Marie 10e3b7cdd7 Merge pull request #6261 from CocofireHD/fix/sheep-grazing-mob-griefing
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): EatBlockGoal.tick: destroyBlock and grass->dirt behind MOB_GRIEFING, Mob.ate outside (javap checked); two goal-driven tests. verify green on 7d7a73e84.
2026-11-01 20:28:50 +01:00
Ludovic Marie 59259f6d5a Merge pull request #6258 from MSedgeMC/fix/md-crossbow
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): CrossbowItem.onUseTick start/mid flags (reset under 0.2, each plays once): a charge jumped by a mod still plays both; unit test. verify green on 00c7de376.
2026-11-01 20:28:46 +01:00
Ludovic Marie 8f27fb01be Merge pull request #6275 from MSedgeMC/fix/piglin-conversion-offhand
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): Piglin.finishConversion runs PiglinAi.cancelAdmiring first: the admired off-hand item drops; same ADMIRING_ITEM test as the activity picker. verify green on 7671485c3.
2026-11-01 20:28:39 +01:00
Ludovic Marie e2e54fd5b8 Merge pull request #6313 from horizon-rust/fix/vehicle-floating-kick
🤖 Agent: proof: vanilla's floating-vehicle kick (handleMoveVehicle clientVehicleIsFloating + tickPlayer aboveGroundVehicleTickCount, getMaximumFlyingTicks: boat 160, horse 80) ported; lan_bot::a_boat_floating_too_long_kicks_its_driver passes with real resources (160 ok, 161st kicks, reset on a non-floating move), all 16 lan_bot pass, fmt + clippy pass, CI verify pass.
2026-11-01 20:28:35 +01:00
Ludovic MarieandClaude Opus 5.5 2166052a66 Projectile impacts: a mod asks to hear them, so the tick copies nothing otherwise
native_hears_projectile_impacts (same fragment): without a mod setting it,
tick_projectiles keeps no copy of each flying projectile (an arrow's clone is
a Box, two item stacks and a Vec) and the impact hook is not called, so a
server without such a mod pays nothing per tick.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 20:28:31 +01:00
some-du6eandClaude Opus 5.5 5328ecaf13 OptiFine entity models: the preview test's comment gives the right starting values
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 13:25:52 -06:00
arismoko d76b622eae The catalog's helper targets name the module that defines them
46 of 78 named native_hooks while the method lives in another game
module; the generator found methods by name anywhere and checked the
target only against two module names. It now records each method's
module and requires the target to match it.
2026-11-01 19:24:29 +00:00
JustKarkatandDeepSeek v4.1 Flash 5e07ad2fc1 Foxes take their food's consume effects and survive on a totem
`Fox.aiStep` finishes the item in the mouth with `ItemStack.finishUsingItem`,
whose `Consumable.onConsume` runs the item's `on_consume_effects`: a chorus
fruit now teleports the fox. The chase is `TeleportRandomlyConsumeEffect`
over `Entity.randomTeleport`: 16 draws in a cube of the item's diameter, the
drawn height clamped to the level, ground found by dropping to the first
motion-blocking block, free space without liquid, and not a
`#consumable_does_not_teleport_to` block; then the fox's own teleport sound,
the portal trail (level event 2017) and a reset fall distance.

`LivingEntity.checkTotemDeathProtection` was ported for players only, so a
fox holding a totem of undying died with it. `Mob::damage` now runs the same
rule for every mob: the first hand with the item, the item used up, health
set to 1, `DeathProtection.TOTEM_EFFECTS` applied and entity event 35
broadcast for the clients' totem particles and sound. A blow in
`#bypasses_invulnerability` (`/kill`, the void) still ignores it.

Fixes #5839

Co-Authored-By: DeepSeek v4.1 Flash <[email protected]>
2026-11-01 13:24:02 -06:00
Ludovic MarieandClaude Opus 5.5 736fe7c62e Merge main into pr-6189
mobs/ai.rs: #6218 and #6228 landed test modules at the same place; each
module kept whole beside main's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 20:23:51 +01:00
Ludovic Marie aef5284bbe Merge pull request #6193 from MSedgeMC/fix/nautilus-leash
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): nautilus canBeLeashed: not while aggravated. verify green on 9685f3a12.
2026-11-01 20:23:41 +01:00
Ludovic Marie 8c0a52ca4d Merge pull request #6190 from MSedgeMC/fix/trader-save
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): wandering trader DespawnDelay and wander_target saved and loaded. verify green on 802ab62f2.
2026-11-01 20:23:37 +01:00
Ludovic Marie 13a0e7b176 Merge pull request #6186 from MSedgeMC/fix/evoker-fang-sturdy
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): evoker fangs test isFaceSturdy(UP), not a full cube. verify green on 177d2c9f5.
2026-11-01 20:23:33 +01:00
Ludovic Marie ebcee9d1b0 Merge pull request #6174 from MSedgeMC/fix/zombie-nautilus-variant
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): zombie nautilus egg applies its variant component. verify green on f58c0ae1b.
2026-11-01 20:23:29 +01:00
Ludovic Marie 27fe715312 Merge pull request #6172 from MSedgeMC/fix/drowned-uncertainty
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): drowned trident uncertainty from difficulty (RangedAttackMob; Drowned field is -1). verify green on 3217c6759.
2026-11-01 20:23:25 +01:00
Ludovic Marie 519899f762 Merge pull request #6171 from MSedgeMC/fix/creeper-max-fall
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): Creeper.getMaxFallDistance health-1 branch (javap checked); test skips without vanilla data. verify green on 4d31db99b.
2026-11-01 20:23:21 +01:00
Ludovic Marie f1d2b825e7 Merge pull request #6158 from breachato/fix/6125-spawn-egg-on-vines
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): SpawnEggItem.useOn: a clicked block without collision takes the mob (bytecode checked). verify green on cbb7efe89.
2026-11-01 20:23:17 +01:00
some-du6eandClaude Opus 5.5 8a7c9259f0 OptiFine entity models: say which random textures follow a .jem's texture
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 13:22:10 -06:00
some-du6eandClaude Opus 5.5 787b60eeae OptiFine entity models: comments say when a preview stores, the globals test catches a storing preview
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 13:20:50 -06:00
levzzzandClaude Sonnet 5.5 168eb9ff04 Native mods: data maps (RegisterDataMapTypesEvent), first step
native_data_map_types registers a mod's data map types for items and blocks.
Their values come from data/<mod>/data_maps/<registry>/<name>.json in the
world's data packs and from files a mod pushes through native_data_pack_files,
merged each time the server's tables are built: replace, values (entries and
#tags) and remove, a newer value overriding an older. native_data_map and
native_data_map_entries read them. Fixes #6312.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 00:19:39 +05:00
NerfPhaselockandClaude Opus 5.5 b91226cac9 Renderer: shrink the large translucent pages once the view has settled
Translucent Batching's vertex and sorted-index pages grow up to about 2 GiB
and never shrank, so a page kept its high-water size after the water that
filled it was gone (an ocean left behind, a lower render distance). Now a
page of 64 MiB or more that a right-sized page (grown(used)) would halve,
or make 256 MiB smaller, gets that page in video memory and is closed:
#3466's move_off_page empties it into the new page within the same copy
budget as the section arena's compaction (32 MiB a frame, 8 MiB for sorted
indices), and trim drops it. Each mesh's pointer switches in the step that
records its copy. With no room for the new page, or a range no mesh owns,
the page stays as it is and the next try waits 5 s.

A shrink starts only a second after terrain stops arriving, in a frame the
section arena's compaction copied nothing (ShrinkGate). The game reports
terrain arriving: the view's outermost columns not all there yet
(ViewArrival, capped at the server's view distance and given up 30 s after
the view last changed), events the server sent that wait to be handled, far
tiles being sampled, and, for those 30 s, re-meshes and pending uploads. The
renderer also waits while translucent sections or far tiles wait to upload.
A shrink under way finishes. move_off_page always moves the first range it
is given, so a range larger than the budget no longer keeps a page closed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:19:36 -05:00
some-du6e 89dfcf7563 Merge branch 'optifine/cit-armor' into optifine/cit-glint 2026-11-01 13:19:29 -06:00
some-du6eandClaude Opus 5.5 72ab16d4dd OptiFine CIT: the atlas test expects armour textures packed now
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 13:19:29 -06:00
some-du6e 4125d1ae76 Merge branch 'optifine/cit-atlas' into optifine/cit-overlay 2026-11-01 13:19:14 -06:00
some-du6e bcbffc7f16 Merge branch 'optifine/cit-overlay' into optifine/cit-items 2026-11-01 13:19:14 -06:00
some-du6e b11a97df5d Merge branch 'optifine/cit-items' into optifine/cit-armor 2026-11-01 13:19:14 -06:00
some-du6eandClaude Opus 5.5 3bd26833e1 OptiFine CIT: still CIT textures are packed whole, not cut to a square
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 13:18:34 -06:00
JoansitohandClaude Sonnet 5.5 7affb5b478 Fix OpenGL shader error: flatten colored_lights to avoid ARB_arrays_of_arrays (#6267)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 20:18:33 +01:00
some-du6eandClaude Opus 5.5 4d53099dbb Far terrain: say what the near terrain's loading does to the queue
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 13:18:32 -06:00
Ludovic Marie 904ef97e51 Merge pull request #6228 from MSedgeMC/fix/witch-ignore-raiders
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): Witch HurtByTargetGoal(Raider.class). verify green on 215771565.
2026-11-01 20:18:05 +01:00
Ludovic Marie da31739ea5 Merge pull request #6218 from MSedgeMC/fix/endermite-goals
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): Endermite.registerGoals (javap checked: priorities, alert others). verify green on 38f7febdc.
2026-11-01 20:18:00 +01:00
Ludovic Marie ac4eb02342 Merge pull request #6204 from MSedgeMC/fix/bat-flap
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): Bat.isFlapping: awake, every tenth tick, sculk FLAP. verify green on e35d09582.
2026-11-01 20:17:55 +01:00
Ludovic Marie 8163dfde6f Merge pull request #6184 from xFedeT/fix/carrot-stick-swing
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): FoodOnAStickItem.use passes on a client: no predicted swing. verify green on aba7fe0ba.
2026-11-01 20:17:51 +01:00
Ludovic Marie 327a5b1706 Merge pull request #6178 from xFedeT/fix/spear-damage-indicator
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): spear stab damage indicators sent to served players, as attack_mob. verify green on 473941651.
2026-11-01 20:17:46 +01:00
Ludovic Marie e105b176c9 Merge pull request #6160 from xFedeT/fix/camel-dash-release
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): camel keeps a pending dash until it lands (AbstractHorse.tickRidden). verify green on 5fe9fc298.
2026-11-01 20:17:43 +01:00
arismoko 14aa71000a Merge upstream main (ff8e46848d)
# Conflicts:
#	crates/horizon-client/src/client_lib.rs
#	crates/horizon-client/src/entry.rs
#	crates/horizon-client/src/lib.rs
2026-11-01 19:17:20 +00:00
Ludovic MarieandClaude Opus 5.5 e5aa08936c Merge main into shader-packs centerDepthSmooth
#6211's status and End flash uniforms landed in the same list and match:
both sides kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 20:15:51 +01:00
Evrim CicekandClaude Opus 5.5 2ca08a955f Render tests: no OpenGL instance on Windows
Each GPU test made a wgpu instance with every backend, so up to one thread
per core opened Vulkan, DirectX 12 and WGL at once while finished tests'
threads exited. On Windows with an NVIDIA driver the render lib tests hung
most runs (8 of 10 here, RTX 4070; #5238). The tests now take their
instance from gfx::test_gpu, which on Windows opens Vulkan and DirectX 12
only, as the game prefers there; WGPU_BACKEND still overrides. Linux and
macOS are unchanged.

Fixes #5238

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 22:15:44 +03:00
arismoko c866256981 Attachments::remove removes a value that doesn't decode
It returned the decode error and left the value in place, so a corrupt
saved value could never be removed. It is removed now, and the error
still returned.
2026-11-01 19:13:48 +00:00
arismoko 2c4194b100 Entity attachments are pruned without touching other entity memory
While any entity had an attachment, prune_entity_memory ran every tick,
and it forgot the UUIDs of entities outside its list (projectiles, TNT,
orbs), which then got a new random UUID each tick. The tick now drops
only the attachments of entities that left, by the same check that
admits an entity as a holder.
2026-11-01 19:13:48 +00:00
arismoko 30268823b4 The staged game package finds horizon-client beside it
Since it moved under crates/, its path dependencies pointed at
crates/crates/.
2026-11-01 19:13:48 +00:00
some-du6eandClaude Opus 5.5 c26f5f2fc7 Streaming: terrain_loading, not a method named like the loading-screen field
Review fix: `Streaming::loading` is also the field that says the loading
screen is up; without the parentheses the far terrain would still compile
and follow the wrong one.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 13:13:31 -06:00
Ludovic Marie ff8e46848d Merge pull request #5446 from Xmilon/native/api-mods
🤖 Agent: queued 18211933b: main merged, package selections fixed (old_world_e2e, dragon_render, launch_api docs), pr_ready --quick green except 2 TNT tests that fail on main too; dragon_render + arrow harnesses pass.
2026-11-01 20:12:23 +01:00
Ludovic Marie 473cd6da7e Merge pull request #6230 from Ragbie/fix/friends-deadline-windows
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): horizon-friends deadline test ends on Windows; test-only. verify green on 89bd321b4.
2026-11-01 20:12:19 +01:00
Ludovic Marie 3a6ef0ffa2 Merge pull request #6224 from levzzz5154/mods/owner-console-native-commands
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): owner console reaches native registered commands; Python hook tests pass merged with main. verify green on c73e087d7.
2026-11-01 20:12:15 +01:00
iLostName 7458a6ca1a ender dragon: the dying model dissolves through dragon_exploding's mask instead of fading 2026-11-01 20:12:01 +01:00
iLostName 30715a97d9 camera fluid: lava fog starts when a corner of the near plane dips under the lava 2026-11-01 20:12:01 +01:00
MSedgeMC 7dda810ac9 A ridden horse floats instead of sinking
LivingEntity.travelInFluid lifts a mount that carries a passenger, is in
minecraft:can_float_while_ridden and is deep enough in the water. Horizon's
mob travel skipped that step, so a horse with a player on it only sank with
its rider.
2026-11-01 19:11:59 +00:00
arismoko 6a91220891 A mod version without minor or patch satisfies a requirement
[mod] version = "1" (as the fixtures write it) failed the strict
semver parse when another mod depended on it; missing parts now count
as zero, as in Cargo's requirements.
2026-11-01 19:11:49 +00:00
arismoko 9e88c7c344 Docs: the catalog's helpers list, mod_api contracts, dependency order 2026-11-01 19:11:49 +00:00
arismoko fc53c6766e pr_ready checks the mod API whenever the generator's inputs change
It ran the check only for xtask/, native-hooks.json and native_hooks.rs,
while the generator also reads every game file, mod_api.rs, loot.rs,
item_defaults.rs and native_render.rs.
2026-11-01 19:11:48 +00:00
levzzz e5a4324ca3 Merge pull request #6263 from Xmilon/fix/registry-lockfile
Mod registry: refresh its Cargo.lock for horizon-mods' png dependency
2026-11-02 00:10:38 +05:00
arismoko 5228dba36e Other players' attachments are reachable during a guest's turn
Guests' attachments lived in their hub records, which a guest's turn
can't reach (as_guest has the hub out of self.lan), so Holder::Player
for anyone but the acting player failed in player_tick, use_item_on,
interact_entity and the other guest-turn hooks. They now live on the
server Game by UUID from join to leave, and go back into the record
only for playerdata to save.
2026-11-01 19:10:23 +00:00
some-du6eandClaude Opus 5.5 617a56e44d Far terrain: dropped samples go back in line, and the held pace restarts
Review fixes: on the server thread the near terrain can start and stop
loading between chunk batches. Each start dropped the queue and rebuilt
the whole wanted heap; the dropped tiles now go back into it where they
were. A sample still running when the hold starts no longer lets the
next one start at once. The queue test no longer moves the list it
checks later.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 13:09:59 -06:00
some-du6eandClaude Opus 5.5 f345299ba2 OptiFine CIT: the texture budget charges a packed frame, skips refused files, and keeps going past one that doesn't fit
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 13:09:16 -06:00
arismoko f88f211fa4 Level attachments: Horizon's namespace, no empty file, no blocked world
Every save wrote <dim>/data/minecraft/horizon_attachments.dat, with mod
data or without, and a malformed file stopped the world or dimension
from opening. The file is now data/horizon/attachments.dat, written
only once a level has attachments, and a file that doesn't read is
moved aside with a warning while the level opens without them.
2026-11-01 19:08:19 +00:00
Ludovic Marie 0e609939ce Merge pull request #6211 from arslanarm/shaderpacks/status-end-flash-uniforms
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): status and End flash uniforms for shader packs, from the public uniform reference. verify green on 7745fddd2.
2026-11-01 20:07:26 +01:00
Ludovic Marie 2fea6f90ee Merge pull request #6210 from arslanarm/shaderpacks/sky-binds-ssbos
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): sky pass binds pack SSBOs (crash fix). verify green on 4fbbee3d1.
2026-11-01 20:07:21 +01:00
Ludovic Marie 6949060eb9 Merge pull request #6201 from levzzz5154/mods/native-client-level-events
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): client LevelEvent hooks, empty bodies; Python hook tests pass merged with main. verify green on ba04f5032.
2026-11-01 20:07:16 +01:00
Ludovic Marie 22f6fad2a0 Merge pull request #6198 from some-du6e/optifine/ctm-draw
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): OptiFine CTM drawn in the mesher; nothing changes without CTM files. verify green on 82c95b710.
2026-11-01 20:07:10 +01:00
Ludovic Marie 92641832ea Merge pull request #6194 from Domanffe/roadmap/benchmark-example-link
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): roadmap link fix. verify green on 2d1d81794.
2026-11-01 20:07:05 +01:00
Ludovic Marie df5526d993 Merge pull request #6192 from Domanffe/fix/playerdata-save-errors-2
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): failed playerdata writes are logged. verify green on 152617ece.
2026-11-01 20:07:00 +01:00
Ludovic Marie 5e14f850d9 Merge pull request #6181 from NerfPhaselock/perf/translucent-bind-used
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): translucent batching pages bound up to their last range; off by default. verify green on 18ac513e8.
2026-11-01 20:06:55 +01:00
Ludovic Marie 55bacdf91f Merge pull request #6177 from arslanarm/shaderpacks/translator-fixes
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): four shader translator fixes (bool-shadowing part removed: LGPL source); horizon-shaders tests in verify. verify green on 6e554aaa1.
2026-11-01 20:06:51 +01:00
Ludovic Marie 23f75271bf Merge pull request #6173 from Joansitoh/fix/6111-dragon-head-anim
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): worn dragon head jaw follows wornHeadAnimationPos incl. the vehicle's positionScale; supersedes #6166. verify green on f5161d9eb.
2026-11-01 20:06:44 +01:00
Ludovic Marie 7f23246f55 Merge pull request #6170 from some-du6e/fix/beacon-beam-remote
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): beacon update tag keeps the client's beam (loadAdditional reads only powers); unit test. verify green on 2d5ee61a5.
2026-11-01 20:06:34 +01:00
Ludovic Marie d146862edc Merge pull request #6168 from xFedeT/fix/arm-cpu-name
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): ARM Linux CPU name from implementer/part; unit tests on real cpuinfo shapes. verify green on ce02c0329.
2026-11-01 20:06:25 +01:00
some-du6e 9c34aa0193 Merge branch 'optifine/cit-armor' into optifine/cit-glint 2026-11-01 13:06:16 -06:00
Ludovic Marie 8d38e82f97 Merge pull request #6167 from Xmilon/native/mod-random-ticks
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): random_ticks for content blocks; registry lockfile refresh; hook/mod Python tests pass merged with main. verify green on 5d08602df.
2026-11-01 20:06:13 +01:00
arismoko 2df26ddb98 Types moved into mod_api stay reachable at their old homes
NativeDispensed, NativeServerPlayer, NativeCommand, the lifecycle types,
NativeZoom, NativeLootDrops and NativeScreenView moved into mod_api and
were imported back privately, so mixin code naming them at their old
paths hit E0603. They are re-exported there with their old visibility.
2026-11-01 19:06:08 +00:00
Ludovic Marie b714abcda2 Merge pull request #6165 from Xmilon/native/content-state-order
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): mod block state property sorting before the template's is refused with a clear error (message whitespace fixed). verify green on dc1597e11.
2026-11-01 20:06:05 +01:00
Ludovic Marie 53adac781f Merge pull request #6163 from levzzz5154/mods/native-level-events
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): server LevelEvent load/unload hooks, empty bodies, one unload per load; hook/mod Python tests pass on the head merged with main. verify green on c60b4c8d4.
2026-11-01 20:06:00 +01:00
Ludovic Marie 2cc2172fb5 Merge pull request #6161 from Joansitoh/fix/6107-superflat-grass-tint
🤖 Agent: Reviewed by Ludovic's AI agent (Claude): Superflat grass icon uses the grass colormap (GrassEnv), as the inventory does. verify green on b3384f9bd.
2026-11-01 20:05:54 +01:00
some-du6e a7d11428c2 Merge branch 'optifine/cit-items' into optifine/cit-armor 2026-11-01 13:05:44 -06:00
arismoko 6eed5914a9 Trait injections can't panic before the mods are installed
main_with installed the mod list after logging, the crash handler and
the RAM limit, and an injection's helper indexed the list, so an
injection into any of those panicked in a release build. The list is
now installed first, and the helper does nothing when its mod is
absent.
2026-11-01 19:05:37 +00:00
arismoko 6c435045c3 Stage a trait-only bundle's data/
Only horizon-mixins staged a bundle's data/ into native-data/<id>/, and
a bundle with no mixin content never reaches it, so native_data! did
not compile and native_built_data was empty. The launcher now stages
those bundles' data/ where horizon-mixins would.
2026-11-01 19:05:37 +00:00
arismoko 72614f85f2 Trait-mod builds keep the game's Windows stack size and icon
The launcher's generated package owns the horizon-client binary but had
no build script, so on Windows a build with a trait mod fell back to a
1 MB main-thread stack and lost the icon. It is now staged beside
horizon-client and builds with that crate's build.rs, which finds
packaging/ where it looks.
2026-11-01 19:05:37 +00:00
some-du6e bc1f50970f Merge branch 'optifine/cit-overlay' into optifine/cit-items 2026-11-01 13:05:13 -06:00
some-du6eandClaude Opus 5.5 025794de83 Far terrain: test both holds' budgets and the dropped tiles' return
Review fixes: the dropped tiles must come back best first, and larger
machines keep the first SAMPLE_JOBS of the queue. retiring_detail gets its
doc comment back, and docs/graphics.md says one sampling thread below 8.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 13:04:55 -06:00
some-du6eandClaude Opus 5.5 6415af6dfc OptiFine entity models: a broken baby file gives way to the adult's, docs name the animations
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 13:04:08 -06:00
some-du6e dc2877fbfb Merge branch 'optifine/cit-atlas' into optifine/cit-overlay 2026-11-01 13:04:03 -06:00
levzzzandClaude Sonnet 5.5 a87bf9d4ca Native mods: fluid handler on items (Capabilities.FluidHandler.ITEM)
native_item_fluid_tank makes a mod item a fluid container with a capacity;
its contents are kept in the stack's custom_data under horizon_fluid.
native_item_fluid reads one item of a stack, native_item_fluid_fill and
native_item_fluid_drain answer how much it takes or gives and the single item
it becomes. Vanilla's buckets are built in: empty, water and lava, a whole
bucket or nothing. Fixes #6278.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 00:03:05 +05:00
some-du6eandClaude Opus 5.5 0f49685690 OptiFine CIT: textures past what the atlas holds are left out before decoding; mipmap test; layout doc in place
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 13:02:58 -06:00
some-du6eandClaude Opus 5.5 bf3a4dfbfa OptiFine entity models: a preview-only entity keeps counting, globals read once per frame
The inventory's own player in first person is drawn only by the preview,
so a preview stores when the world didn't draw the entity this frame or
the last. Shared variables are read as each entity's frame began, like
its own, so drawing it again shows the same value.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 13:02:27 -06:00
arismoko aefc95065c Block-entity attachments survive an in-place rewrite
BlockEntities::insert dropped the position's attachments on every
call, so a jukebox taking a disc, a lectern taking a book or
/data merge block lost them, where vanilla keeps the block entity.
A replacement of the same type (the same id for a mod's) now keeps
them; any other replacement, and a removal, still starts them over.
2026-11-01 19:01:10 +00:00
Federico TaglianiandClaude Sonnet 5.5 76b26c9803 Type the characters that come with no key (Windows Alt codes) into the focused text field
Alt + numeric keypad digits make Windows send a WM_CHAR after the Alt is released, which winit
turns into a key event with no physical key but with the character as text. Every screen's key
handler reads the key code first and returned, so the character was lost. GLFW's char callback
types it; the event now goes straight to the focused text field.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 20:00:40 +01:00
some-du6eandClaude Opus 5.5 c95422cbf1 Far terrain: give way to the near terrain at once, and count its light views
Review fixes:
- When the near terrain starts loading again, the queued sample jobs
  past the held budget are dropped (they come back once it is in): with
  32 a thread queued, the hold otherwise waited for the whole queue.
- `Streaming::loading` counts neighbours' light views, as `busy` does.
- Without sampling threads, the far worker takes summaries and detail
  requests before the queued samples.
- One filter for the columns that take generated detail; the status pair
  is `detail_target_columns` / `refined_target_columns`.
- docs/graphics.md: the sampling and decoration threads and the hold as
  they are now.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 12:59:36 -06:00
arismoko 3677c2bf45 Stamping keeps a fragment's mod_api and stamps the mod API's files
The stamp tool dropped a fragment hook's mod_api contract, and replaced
markers only in native_hooks.rs and a fragment's comments files, while
mod_api.rs and the generated files copy hook and helper comments with
their markers. After a stamp the generated files were stale and
xtask mod-api --check failed. Now every marker is also stamped in
mod_api.rs and the four generated files.
2026-11-01 18:59:13 +00:00
CocofireHDandHermes Agent 7d7a73e84c Sheep grazing: the eat goal only changes the world with mobGriefing on
`EatBlockGoal.tick` gates both the `destroyBlock` of an edible block and the
grass block's `levelEvent(2001)` + `setBlock(DIRT)` behind `MOB_GRIEFING`, and
keeps `Mob.ate` outside that gate. Horizon pushed the two `MobEvent`s
unconditionally, so a sheep on a grass block with the rule off turned it to
dirt: measured in a running client, 0 -> 1 dirt blocks at 41.6 s of a 150 s
window.

`MobWorld.mob_griefing` already carries the rule (it gates an allay's pickup,
the dragon's block breaking, blasts and a mob's projectiles); this goal was the
outlier. The test world gains `with_built_test_world_rules` so the flag is
reachable from a test, and the new cases tick a sheep for 6000 ticks through
`mobs::ai::tick` with the rule on and off, one per half of the gate: dirt
events 4 on and 0 off with `EntityEvent` 10 in both, and the short grass under
its feet destroyed on, untouched off. Inverting the gate fails both.

Co-Authored-By: Hermes Agent (deepseek-v4.1-flash) <[email protected]>
2026-11-01 19:58:38 +01:00
some-du6e a5c60577cf Merge branch 'optifine/cit-atlas' into optifine/cit-overlay 2026-11-01 12:57:41 -06:00
some-du6eandClaude Opus 5.5 078c43479b Item models: the built-ins test checks the built-ins
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 12:57:41 -06:00
levzzzandClaude Sonnet 5.5 38f320860a Native mods: breathing, LivingBreatheEvent
native_living_breathe(entity, kind, can_breathe, consume, refill) is called
every tick for a player (on the server's copy) and for a mob with the
generic air logic. Horizon's own answers come in as the defaults and a mod
may change them; the air arithmetic moves to a new breathing module, which
is a free function hook like the loot and menu ones, because the player's
motion and the mob tick have no Game.

Fixes #6248.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 23:56:45 +05:00
some-du6eandClaude Opus 5.5 5060a5bee3 OptiFine CIT: Horizon's own sprites and the layout stay theirs; item mipmaps and PBR maps for CIT textures
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 12:56:45 -06:00
some-du6eandClaude Opus 5.5 21acaaa65f OptiFine entity models: the preview test expects what the world last stored
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 12:56:06 -06:00
some-du6eandClaude Opus 5.5 96f70dc451 OptiFine entity models: the render test's spider wears the spider texture again
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 12:55:13 -06:00
some-du6eandClaude Opus 5.5 02be02992c A server's parrot or slime sent as a baby stays grown; fix the baby flag test
AgeableMob.isBaby is canBeABaby() && getAge() < 0, so a vanilla client
ignores DATA_BABY_ID on a frog, parrot, slime, magma cube, camel husk or
zombie nautilus. The client now does too (drops::can_be_baby). The test
gives each type a model, which MobTypes::load needs, and checks a parrot.
baby_index reuses doors::is_zombie.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 12:54:20 -06:00
Federico TaglianiandClaude Sonnet 5.5 234a37bb95 Players on a server keep their tags: tag add/remove and @a[tag=...] work for guests
tags_of answered nothing for a server's player (Target::Guest) and raw_mut had no compound to
write to, so tag <player> add <tag> was dropped and a tag= selector never matched. A guest's tags
live in the saved player compound its file is written from (Guest.saved).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 19:54:13 +01:00
levzzzandClaude Sonnet 5.5 4155022685 Native mods: a mod's own icons on filled maps (MapDecorationType, client half)
native_map_decorations(map, decorations) is asked each time a map is drawn
(held, in an item frame, on the cartography table's screen); a mod pushes
NativeMapDecoration icons that are drawn after the vanilla ones with the same
pose. Typed decorations saved in the map need code-side registration and are
the later step.

Fixes #6269.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 23:52:59 +05:00
Akirabane fd43c46207 Merge remote-tracking branch 'origin/main' into fix/6152-submerged-mount 2026-11-01 19:52:02 +01:00
AkirabaneandClaude Sonnet 5.5 8ecf339e5b Riders: a rider whose eyes are in water gets off a mount that can't swim with one
LivingEntity.baseTick stops a rider whose eyes are in water (not in a bubble column) from riding a
vehicle of the dismounts_underwater tag: camel, chicken, donkey, happy ghast, horse, llama, mule,
pig, ravager, spider, strider, trader llama and zombie horse. Horizon only did it for boats. The
window's player and a server's player (through the same tick as the mount's input) and the mobs
that ride a mob (a baby zombie on a chicken) now get off; a skeleton horse keeps its rider.
tools/test_mount_submerged_parity.py compares 14 mounts with a vanilla server, passenger packet by
passenger packet.

Fixes #6152

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 19:52:01 +01:00
Ludovic MarieandClaude Opus 5.5 18211933bf Gameplay tests run from horizon-client's lib target: dragon render harness, launch_api docs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 19:51:15 +01:00
some-du6eandClaude Opus 5.5 6833b98494 Merge main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 12:51:01 -06:00
Ludovic MarieandClaude Opus 5.5 eded4b24dd Structure block boxes: look only at structure and test blocks each frame
bounding_box_gizmos went through every loaded block entity each frame to find the structure
and test instance blocks whose box it may draw. BlockEntities now keeps their positions
(like the mod block entities), and the frame looks at those only. A sample of an RD 32
mountain flight on an M4 Pro spent 0.12 ms of every frame in that scan.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 19:50:18 +01:00
some-du6e 089d3a6d58 Merge branch 'optifine/cit-armor' into optifine/cit-glint 2026-11-01 12:50:01 -06:00
some-du6e d4080d5d7f Merge branch 'optifine/cit-items' into optifine/cit-armor 2026-11-01 12:50:01 -06:00
some-du6e 6fb7637cf1 Merge branch 'optifine/cit-overlay' into optifine/cit-items
# Conflicts:
#	crates/horizon-client-render/src/hud.rs
2026-11-01 12:50:01 -06:00
Ludovic MarieandClaude Opus 5.5 f87882144e Splitscreen: list the monitors only while it is on, once a second
Split::about_to_wait listed the monitors every frame, splitscreen on or off. On macOS each
listing is a round trip to the window server (SLSGetActiveDisplayList): a sample of an RD 32
flight on an M4 Pro spent 0.1-0.3 ms of every frame there, 2-3 % of the main thread.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 19:49:35 +01:00
some-du6eandClaude Opus 5.5 868069dea7 Baby happy ghasts and nautiluses look like babies on a server
baby_index listed the AgeableMob kinds by hand and missed eight of them,
the happy ghast and the nautilus among them. A server's client never read
their DATA_BABY_ID (16), and this game's own server never sent it, so a
hatched dried ghast was drawn and boxed as an adult (#6133). The index now
comes from finalize::ageable, which lists every vanilla AgeableMob, plus
the zombies, the zoglin and the piglin.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 12:49:11 -06:00
some-du6e 0a59f3e04c Merge branch 'optifine/cit-atlas' into optifine/cit-overlay 2026-11-01 12:48:12 -06:00
some-du6eandClaude Opus 5.5 30a809f267 Item models: the built-in generated model stays; tests that check bakes by hand
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 12:48:12 -06:00
levzzzandClaude Sonnet 5.5 b8dc376f86 Native mods: energy handler on blocks (Capabilities.Energy.BLOCK)
native_block_entity_energy declares a mod block's energy buffer as
SimpleEnergyHandler is built: capacity, the most one insert and one extract
moves, and the faces a fill may come through and a drain may leave by. The
amount is kept in the block entity's saved compound as the int `energy`.

native_energy reads it through a side, native_energy_insert and
native_energy_extract move energy (with `simulate`), and native_move_energy
is EnergyHandlerUtil.move between two blocks. Fixes #6227.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 23:46:54 +05:00
MSedgeMC 7671485c32 Drop the item a piglin was admiring when it zombifies
Piglin.finishConversion calls PiglinAi.cancelAdmiring before it converts, which spawns the
off-hand item the piglin was admiring and clears the hand. Horizon copied the whole off hand
to the new zombified piglin, so the item that should have been on the ground travelled with it.
2026-11-01 18:46:32 +00:00
some-du6eandClaude Opus 5.5 91a89466d9 OptiFine CIT: too many CIT textures leave them out, not the packs; refused ids read nothing; strips bounded by size
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 12:45:59 -06:00
levzzzandClaude Sonnet 5.5 0b4fdee290 Native mods: an entity mounting or dismounting a vehicle, EntityMountEvent
Add native_entity_mount, asked before a player or a mob gets on or off a
boat, minecart, cushion or mount (including /ride, stabs, bucks, teleports),
on the server thread and a dedicated server. A mod can cancel it.

Fixes #6273.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 23:45:47 +05:00
MSedgeMC 9425bb9acf A boat stops when its rider gets out
AbstractBoat.tick drops a boat's cached motion on the side that does not drive
it, so the level takes a boat over at a standstill when its rider leaves.
Horizon kept the velocity of the ride, and the empty boat went on gliding.
2026-11-01 18:45:41 +00:00
some-du6eandClaude Opus 5.5 d53fcde29a OptiFine entity models: pass the preview closure by value (clippy)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 12:44:02 -06:00
Dmytro ChuprynaandAntigravity f983b543ab Remote: handle arrow hit, pufferfish sting and limited crafting in GameEvent
Match vanilla 26.3 ClientPacketListener.handleGameEvent by playing the arrow hit player sound on event 6 at eye height, the pufferfish sting sound on event 9 at entity position, and updating do_limited_crafting on event 12.

Co-Authored-By: Antigravity <[email protected]>
2026-11-01 19:43:15 +01:00
DetimesandClaude Sonnet 5.5 9fabb5b74c Merge upstream/main: respawn hook and dimension hook both at the end of guest_respawn
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 18:42:18 +00:00
some-du6eandClaude Opus 5.5 07bdf9af4e OptiFine entity models: armour cracks keep their texture, and a broken file gives way
- A layer takes its .jem's texture only when it is the first layer drawn on that model
  (and not on the body's): the wolf armour does, the cracks over it on wolf_armor#main
  keep theirs. One helper picks the .jem texture when the atlas has it, for the body
  and the layers, without cloning a String first.
- Fallbacks follow chains up to three steps, babies included: cat_baby#collar takes
  cat.jem's geometry (not its texture) through cat_collar.
- jem_files keeps every file of a name, EMF's first; each layer tries its candidates in
  order (its own names by name, then its fallbacks, nearest first) and a file that fails
  to read or parse gives way to the next. Each file is parsed at most once, and only
  when a layer tries it.
- Warn once at load when a .jem's texture names a file missing from the pack root.
- Drop the `over` test wrapper and the bool of fallback_names (a fallback onto another
  layer is geometry-only); LAYERS and FALLBACKS are slices; the "first by name" rule is
  written once.
- Tests: a sheep's wool draws in its sheep_wool.jem texture; a wolf armour .jem paints
  the armour but not its cracks; cat_baby#collar from cat.jem; a broken llama_decor.jem
  lets trader_llama_decor.jem apply, a broken EMF file lets OptiFine's. The ignored
  real-data test also checks both columns of FALLBACKS and every baby layer, and says
  its part names are a sample.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 12:41:39 -06:00
some-du6eandClaude Opus 5.5 9cdbe813a6 OptiFine CTM overlays: their pass follows the tile the face drew
An overlay over a face whose picked tile is translucent goes in the
translucent pass with it. The connectTiles test now resolves the sprite
name through build.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 12:41:28 -06:00
DetimesandClaude Sonnet 5.5 81e64df8b2 Merge upstream/main: keep both hooks
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 18:40:54 +00:00
levzzzandClaude Sonnet 5.5 27722b0578 Native mods: player render and entity layers (RenderPlayerEvent, AddLayers)
native_player_submit draws a player with a mod's own model (other players and
the window's player in third person); native_mob_layer adds geometry to every
mob and player after the vanilla model, in the same feet-and-body frame
native_mob_submit uses.

Fixes #6257.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 23:40:43 +05:00
Ludovic MarieandClaude Opus 5.5 77cb31ce19 Connection cipher: hardware AES, CFB8 decryption many bytes at once
Every received byte on an online-mode server went through a byte-wise
software AES (~450 ns a byte, about 2 MB/s for a whole core). CFB8 decryption
knows each byte's register up front (the ciphertext before it), so runs of
bytes are encrypted together: ARMv8 AES instructions on Apple Silicon (a small
allow(unsafe_code) module for the intrinsics), the RustCrypto aes crate
(MIT OR Apache-2.0, AES-NI picked at run time) elsewhere.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 19:40:33 +01:00
some-du6eandClaude Opus 5.5 df333744c6 OptiFine CIT: CIT ids stay in the root and out of the disk cache; textures decode in parallel and huge ones are refused unread
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 12:39:27 -06:00
some-du6eandClaude Opus 5.5 587872b392 OptiFine random entities: a pack reload resets the miss throttle too
Round 2 review: the first-sight and new-scan check clears an answered miss like the
regular check does; docs say a miss is answered once between checks; the test goes on
to the next regular check and a miss answered after it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 12:39:10 -06:00
Ludovic Marie dea6dea98e Merge pull request #6226 from horizon-rust/fix/anticheat-level-entity-states
🤖 Agent: proof: the official server's boat false positives (1357 claims at y 63.0857639 = a floating boat's top, isInferno banned twice) come from LanHost.states being the last level's entities when moves are read; states now park with their level. pr_ready --quick: fmt, clippy pass; the one lib test failure (armor stand, outside this change) passed in CI verify.
2026-11-01 19:37:39 +01:00
some-du6eandClaude Opus 5.5 123bd3ff7a OptiFine CIT: item swap tests keep their files until the sprites are baked
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 12:37:28 -06:00
[email protected]andClaude Opus 5.5 b40d434d4b fix(mods): pass the property lookup by value
clippy::needless_borrows_for_generic_args: the closure is Copy and block_order
takes it by value.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 19:37:21 +01:00
DetimesandClaude Sonnet 5.5 6b6b552795 Merge upstream/main: keep both hooks
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 18:37:18 +00:00
[email protected]andClaude Opus 5.5 ce0f4ad290 Mod registry: refresh its Cargo.lock for horizon-mods' png dependency
The registry is its own workspace, and its workflow runs on any change under
crates/horizon-mods. Its lockfile lacks png (and fdeflate, miniz_oxide), which
horizon-mods needs, so `cargo clippy --locked` refuses it and `check` fails on
every PR that touches horizon-mods.

Fixes #6196

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 19:37:02 +01:00
DetimesandClaude Sonnet 5.5 e560fdd4bc Merge upstream/main: keep both hooks in native_hooks.rs
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 18:36:45 +00:00
levzzzandClaude Sonnet 5.5 a6a386723a Native mods: entities ticking, with a Pre that can cancel (EntityTickEvent)
native_entity_tick_pre and native_entity_tick_post for the mobs, item
entities, minecarts and boats that tick in an entity-ticking chunk, on a
server. Set canceled in the Pre and the entity does not tick this tick, and
its Post is not told. Fixes #6249.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 23:35:54 +05:00
some-du6eandClaude Opus 5.5 fa8f4bdd0c OptiFine entity models: previews leave the world's animation variables alone
- Within a frame every run starts from the frame's starting variables; only the
  frame's first run outside a GUI preview stores them, and global_var /
  global_varb are stored only by such a run.
- is_right_handed reads Mob::main_arm_left (the player's main hand option too).
- Variables refuse infinity as well as NaN and keep their old value.
- The saved table stays bounded: cleared if still at twice SAVED after eviction.
- is_in_gui is a thread-local set by in_gui() with a drop guard, no longer a
  Frame field; parts write back only changed visibility and new skip_draws;
  one lock() helper; one entry() per run for the entity's variables.
- Tests: non-finite variables keep 7, the client's synced flags and main arm,
  the 4096-expression cap, and a preview not changing the next frame's value.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 12:35:52 -06:00
Detimes 4eeb923824 Merge remote-tracking branch 'upstream/main' into feat-player-flight-hook
# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
#	docs/native-mods.md
2026-11-01 18:35:45 +00:00
some-du6eandClaude Opus 5.5 c3b316607b OptiFine CTM overlays: review fixes
layer=cutout stays a cutout whatever the tile's alpha, but an overlay on a
translucent face goes in the translucent pass with it; connectBlocks and
connectTiles count only for method=overlay, as documented; a tintBlock the
game lacks tints nothing; connectBlocks looks states up by block. Also
fixes two compile errors the first overlay commit had.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 12:35:43 -06:00
some-du6eandClaude Opus 5.5 5543bffed5 Sprites: the prefetch test inserts its tile after the names
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 12:35:25 -06:00
levzzzandClaude Sonnet 5.5 096802ee36 Native mods: equipment changes of mobs (LivingEquipmentChangeEvent)
native_living_equipment_change for mobs and armor stands: each tick, a slot
that holds another stack than the mods last heard is told, with the slot's
name and the stacks before and after. Mob::equipment_stacks gives the slots
(and the equipment sent to clients now reads it). Fixes #6242.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 23:35:22 +05:00
some-du6eandClaude Opus 5.5 5e553f703e OptiFine CIT: GUI items draw their enchantment glint layers
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 12:35:02 -06:00
some-du6eandClaude Opus 5.5 13424686cb OptiFine CIT: enchantment glint layers and cit.properties, chosen per stack
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 12:35:02 -06:00
MariuslozoandClaude Opus 5.5 76e2ffdb87 Port ledger: ClickableStyleFinder.acceptScrolling's note no longer says there is no scrolling text (#6129)
Buttons scroll now; what stays missing is the click and hover style
lookup over a scrolling label.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RodDbPWCByh96WKcbiEQpB
2026-11-01 19:33:26 +01:00
MSedgeMC 37fc39e48a Leave dropped items alone when a mob's blast keeps blocks
ItemEntity.ignoreExplosion asks ServerExplosion.shouldAffectBlocklikeEntities, so with
mobGriefing off a blast that keeps blocks (a creeper's or a ghast's) never touches a
dropped item. Horizon hurt and pushed every item inside the blast whatever the rule.
2026-11-01 18:33:10 +00:00
MSedgeMC 00c7de3765 Crossbow: a tick that jumps the charge still plays each loading sound
CrossbowItem.onUseTick keeps a flag for the start and the middle sound and
plays each once the charge reaches its point, so a charge a mod's tick moved
past both points in one tick plays both. Horizon compared the charge with the
tick before it (`before < 0.2 && now >= 0.2`), so the sounds a jump passed
were skipped; only the end sound played.

The charge's two flags now live on the game beside `using` and are cleared
when a use starts and while the charge is under the first point, which is
vanilla's rule. `charge_sounds` holds it as a function of its own, with a test
for the jump, the ordinary crossing, a second charge and Quick Charge.
2026-11-01 18:32:10 +00:00
levzzzandClaude Sonnet 5.5 caea1cd292 Native mods: HUD layers around vanilla's (RenderGuiLayerEvent, RegisterGuiLayersEvent)
native_gui_layer_pre(layer, batch, canceled) and native_gui_layer_post(layer,
batch) fire around each vanilla HUD layer Horizon draws, so a mod draws below
or above a layer, or hides it. The jump/locator/experience bar is drawn as one
layer, and the mount's jump bar and the title and action bar are split out of
the passes that drew them together.

Fixes #6243.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 23:29:59 +05:00
ErikKalikasandClaude Opus 5.5 bbb8ad3e13 Mod block entities: name vanilla's handleBlockEntityData for the client's merge
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 20:29:46 +02:00
SenjinandClaude Opus 5.5 3451589c31 HDR: a light source lifts by its texel's own brightness, not the lit pixel's
The lift came from the finished pixel, after the block's light and face shading:
a soul lantern (light 10) lifted less of its glass than a lantern, and on two of
its sides less than on the other two. The shaders that mark the frame now work
the lift out from the texel before lighting, with the tier windows from the
globals, and write it into the mark's alpha: a band per tier, the amount within
it. HDR output reads it back and applies the gain to the lit pixel as before, so
a dimmer light still shows dimmer, but shines alike on every face.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 19:29:22 +01:00
MSedgeMC e915bbc0ec Cap a dangerous wither skull's blast at 0.8 resistance
WitherSkull.getBlockExplosionResistance reports min(0.8, resistance) for a block
WitherBoss.canDestroy, so a blue skull's blast tears through blast-resistant blocks.
Horizon used each block's own resistance, so a blue skull left obsidian-like blocks standing.
2026-11-01 18:28:49 +00:00
Ludovic MarieandClaude Opus 5.5 1a39a7b3ca Kick the driver of a vehicle that floats too long
ServerGamePacketListenerImpl's clientVehicleIsFloating and
aboveGroundVehicleTickCount: a vehicle move that doesn't go down, by a vehicle
that hadn't come down onto something, with nothing but air around it (water
holds a boat up), floats; a driver whose vehicle floats for more than
getMaximumFlyingTicks of the vehicle (160 ticks for a boat, 80 for a horse) is
kicked with "multiplayer.disconnect.flying", unless the server allows flight.
The server had no check on vehicle moves beyond moved too quickly and moved
wrongly, so a boat flying over land was never stopped.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 19:27:32 +01:00
levzzzandClaude Sonnet 5.5 7a88ad618a Native mods: a player opening and closing a menu (PlayerContainerEvent)
native_player_container_open and native_player_container_close, per player
and per menu, with the menu type's name and a block menu's position. Any menu
a server player opens: containers, furnaces, crafting tables, trades,
workstations, lecterns, mounts, a mod's own. Fixes #6188.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 23:27:15 +05:00
some-du6eandClaude Opus 5.5 165eb0ad3a Swing the hand when a firework rocket is used on a server
FireworkRocketItem.useOn and, while gliding, FireworkRocketItem.use
return SUCCESS (SwingSource.PREDICTED) on the client too: vanilla's
startUseItem swings the hand itself and stops, and a vanilla server
swings it for the other players only. A server's client never swung for
either use, and a rocket used on a block also sent UseItem and gave the
off hand its turn (a flint and steel there lit the block too, #2216).
The use on a block passes while gliding (the use boosts instead) and in
adventure mode (ItemStack.useOn).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 12:26:41 -06:00
some-du6eandClaude Opus 5.5 6df52fab75 Show the trail of a rocket boosting this player on a server
A rocket attached to a server client's own player followed it and
boosted it in the entity tick, before the player's own move that tick,
so its spark spawned where the player (and the camera) had just been and
was never in view: an elytra boost left no trail. In vanilla the player
ticks first (ClientLevel.tickEntities); the rocket then boosts the glide,
sits at the rocket hand (getHandHoldingItemAngle, half a block out) and
adds its spark there. tick_own_rockets now does that after the player's
tick; rockets of other entities and free ones tick as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 12:26:35 -06:00
some-du6eandClaude Opus 5.5 5161c303b6 OptiFine emissive textures: overlays after variants in the budget, a safer suffix, more tests
Review fixes: overlays count against the atlas budget only after every variant; the
suffix is read from the first file that sets it, Latin-1 too, and never holds a path
separator or ..; docs say what is paired and drawn; tests for a layer's glow, a
variant's own overlay (and none from the base), the atlas packing it, and where the
suffix is read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 12:26:34 -06:00
some-du6e dd4b388f3e Merge branch 'optifine/ctm-draw' into optifine/ctm-overlay
# Conflicts:
#	crates/horizon-client-render/src/mesher.rs
2026-11-01 12:26:33 -06:00
ErikKalikas e18c1abd07 Merge remote-tracking branch 'upstream/main' into mod-block-entity-revision
# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
2026-11-01 20:26:09 +02:00
levzzzandClaude Sonnet 5.5 0fd8c97066 Native mods: a player starting and stopping tracking an entity (PlayerEvent)
native_player_start_tracking and native_player_stop_tracking: the hub's
per-client Tracker keeps what started and stopped, with the entity's ID, UUID
and type, from every place that changes the set (the tick's range checks, a
forgotten or cleared set, a leaving player), and the game tells the mods each
tick. Fixes #6231.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 23:25:18 +05:00
Ludovic MarieandClaude Opus 5.5 4d31db99b5 Skip the creeper fall test without the vanilla data
CI's verify has no extracted data, so with_test_world doesn't run the
closure there; the other mob tests ignore its result the same way.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 19:25:09 +01:00
levzzzandClaude Sonnet 5.5 de0b65b26b Native mods: a projectile's impact a mod can cancel (ProjectileImpactEvent)
native_projectile_impact for each impact tick_projectiles finds, with the
projectile's id, type and owner and the entity or block it hit. A canceled
impact flies the tick again through what it hit (Projectile::tick_through,
arrows::tick_through): nothing of the hit happens and the projectile flies on.
Fixes #6203.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 23:24:38 +05:00
[email protected] 7cf0bceb8b Merge remote-tracking branch 'origin/native/content-state-order' into native/content-state-vanilla-order
# Conflicts:
#	crates/horizon-client/src/mod_content.rs
#	crates/horizon-client/src/mod_content_tests.rs
2026-11-01 19:23:40 +01:00
some-du6eandClaude Opus 5.5 82c95b7102 OptiFine CTM: compact cuts shared by both axes; one biome lookup
Cutting both axes at every crossing keeps the pieces on the quad's
diagonal square, so a split face shades exactly as the whole one does.
Tints::get and the biome rules share Tints::biome_id.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 12:23:18 -06:00
k3yhandClaude Opus 5.5 ebec5efda8 Summon and select ominous item spawners
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 13:23:14 -05:00
some-du6e 96ffa46f2e Merge optifine/etf-fixes into optifine/etf-emissive 2026-11-01 12:23:02 -06:00
some-du6eandClaude Opus 5.5 509bf3c9e1 OptiFine emissive textures: players' armour glows too
Players' worn armour reaches render_visible as extra layers (rects, no names):
armor_layers now adds each layer's _e overlay as a full-bright extra layer.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 12:23:02 -06:00
some-du6eandClaude Opus 5.5 e44848cf9b OptiFine entity models: a .jem's texture leaves overlays alone, and EMF's files win
- A .jem's texture paints only its own layer; overlays drawn on the body's
  model (collars, eyes, villager type/profession, armour cracks) keep theirs.
- A .jem texture the atlas lacks falls back to the vanilla one instead of
  hiding the mob; a texture outside assets/minecraft/textures/ is logged.
- emf/cem beats optifine/cem in both forms, the flat file beats the folder
  form, a directory named x.jem is no file; scan is sorted.
- Two names of one layer (llama decor, undead horse armor): first by name.
- Fallbacks salmon_small/large -> salmon, cat_collar -> cat (geometry only),
  player_slim -> player; nautilus_baby gets none.
- Small pufferfish fins and rabbit hind feet map to Horizon's part names.
- Files no layer uses aren't parsed; no CEM files: no vanilla map built.
- Reuse render::find_part; docs list custom entity models and mobs/cem.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 12:22:44 -06:00
[email protected]andClaude Opus 5.5 29c9854e60 fix(mods): lay a content block's states out in vanilla's order
A block's states were its template's states times the declared combinations,
the template's properties varying slowest, but StateTable::new reads a state
table as vanilla's StateDefinition does: every property sorted by name, the
first name slowest. A declared name that sorts before one of the template's
(`bites` before `facing` on carved_pumpkin) stopped the game at load.

The states are now laid out in that order, with one lookup from a template
state and a declared combination to the state's row, used for the template
state's new id (the default state included), the collision shape fan-out and
the cached shapes. Declared names that sort after the template's lay out as
before, so those blocks' ids do not move. The refusal this replaces is gone.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 19:22:25 +01:00
Ludovic Marie 455b6916b4 Merge pull request #6238 from horizon-rust/perf/block-entity-scans-arrivals
🤖 Agent: server-tps-1: creaking heart scan follows chunk arrivals; tick_block_entities −0.45 ms/tick on the 50-bot bench (A/B alternated), test passes; merging once CI is green.
2026-11-01 19:21:16 +01:00
MSedgeMC 927f2f5e71 Widen the recipe book's magnifier hit box to the search box
RecipeBookComponent puts the magnifier icon's clickable rectangle from eight pixels past the
book's left edge to the search box's own left edge, 25 pixels wide. Horizon used 17, so the
last eight pixels of the magnifier did not focus the search box.
2026-11-01 18:20:27 +00:00
levzzzandClaude Sonnet 5.5 3cad89933a Native mods: interaction key presses (InputEvent.InteractionKeyMappingTriggered)
native_interaction_key(button, key_mapping, hand, swing_hand, canceled) fires
before the attack, use and pick-block keys act on the crosshair, for a single
player's window and a server's player. A cancel skips the action; a cancelled
attack or use still swings unless the mod clears swing_hand.

Fixes #6213.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 23:20:22 +05:00
MSedgeMC 8801bf4991 Scroll the merchant's offers by the wheel delta, not its sign
MerchantScreen.mouseScrolled moves scrollOff by (int)(scrollOff - scrollY), so one event
with a two-notch delta moves two rows. Horizon moved exactly one row per event whatever
the delta was.
2026-11-01 18:20:22 +00:00
MSedgeMC 1384ae37e7 Keep the selected layer row after deleting a flat layer
CreateFlatWorldScreen.DetailsList.deleteLayer selects the row at the same visual position
after the removal, or the last row when the last one went. Horizon deselected everything,
so deleting a layer left the screen with no row highlighted.
2026-11-01 18:20:18 +00:00
some-du6eandClaude Opus 5.5 12aa338862 OptiFine random entities: budget every variant, one cem key, and a miss answered once a check
Review fixes: the atlas budget counts variants beside their texture too; the body's
custom-model key is one helper for render_visible and drawn_textures; a texture that
keeps missing makes the mob choose at most once between regular checks; select's doc
points to matching; tests for the budget, a layer following the body's miss, and a
render assert that can't pass on nothing drawn.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 12:20:13 -06:00
HrafnaValkyrjaandClaude Opus 5.5 9cabc62783 Fix three stale tests that CI skips
They only run with prepared resources, so they went stale unseen. The armor stand test expects a fall on tick 1, where a summoned stand starts moving on tick 2; the two TNT tests set up the wrong scene.

Fixes #6235

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 13:19:25 -05:00
some-du6eandClaude Opus 5.5 e300bc82d5 OptiFine entity models: animations no longer re-show hidden parts or run twice a frame
- A part's visible / visible_boxes start from what vanilla set and are written back
  only when an animation changes them; visible_boxes 1 keeps vanilla's skipDraw.
- One const Frame default (first person); mob_geometry fills paused, first and
  third person; GUI previews (inventory player, mount box) set is_in_gui.
- A second draw of an entity in one frame starts from that frame's starting
  variables; a full table forgets only entities not drawn for 600 frames.
- global_var / global_varb live in one process-wide registry shared by all entities.
- is_aggressive, is_right_handed, is_sitting, is_tamed, is_sprinting, is_sneaking
  read the synced mob fields.
- Non-finite part values keep the vanilla pose; NaN never lands in a variable.
- At most 4096 expressions per model; FRAME is read once per run.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 12:18:07 -06:00
some-du6eandClaude Opus 5.5 c999da7473 OptiFine CTM: compact quarters meet where the sprite's halves do
Cut a ctm_compact face where its UVs cross 0.5, not at its middle, so a
dirt path's side or a slab's gets its quarters in the right place (a slab
side isn't cut across at all); no allocation per face. The blend test now
uses corners only the triangle split gets right.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 12:17:56 -06:00
MariuslozoandClaude Opus 5.5 bee5c7da72 Buttons reach the label through vanilla's scrolling chain; drop the unreproducible offset hash (#6129)
screen_draw::button now goes extractDefaultLabel ->
scrolling_string_over_contents (AbstractWidget.extractScrollingStringOverContents,
margin 2) -> scrolling_with_default_center
(ActiveTextCollector.acceptScrollingWithDefaultCenter) -> scrolling_text
(defaultScrollingHelper), as vanilla does. Same draws; it closes the
callee gap the port ledger opened on button (extractDefaultLabel without
a port of extractScrollingStringOverContents). Left open: the callee gap
on AbstractWidget.getWidth, a trivial accessor passed in as `w`.

the_offset_follows_the_vanilla_period_and_easing ended with a hash over
2.4M offsets from a Java transcription kept outside the repository: nobody
could regenerate it, and it checked a copy of the formula, not vanilla's
code. The pack screen harness now runs vanilla's own defaultScrollingHelper
at fixed clocks instead; the hand-checkable spot values stay.

scrolling_text says it must not run inside another Draw::Scissor (paint
replaces scissors instead of intersecting them; no caller does today), and
the clippy too_many_arguments allows go: the workspace allows it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RodDbPWCByh96WKcbiEQpB
2026-11-01 19:17:43 +01:00
Ludovic MarieandClaude Opus 5.5 6e554aaa1f Drop the bool-uniform shadowing fix
It came from reading an LGPL pack's source (Pixel Perfect), which
CONTRIBUTING puts off limits even to read. The other four fixes rest on
the GLSL specification and an MIT pack, and stay.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 19:16:07 +01:00
arismoko 4290465dbe Mark the mod API's generated files as generated 2026-11-01 18:15:42 +00:00
MariuslozoandClaude Opus 5.5 3ef7e2e8f7 Pack screen harness: scrolling button labels in 8 languages at a fixed clock (#6129)
A fifth of the cases now run in another language (the eight whose "Open
Pack Folder" is wider than its button, plus fr_fr and de_de, which fit)
at a fixed Util.getMillis. The oracle injects the language (en_us, then
the case's file from the prepared assets), sets Util's time source to the
case's clock, and records a text's own scissor (defaultScrollingHelper's
withScissor) as a scissor around it, the way Horizon draws it. The fixture
loads the same language before it lays the screen out.

3000/3000 match, 427 of them a scrolling footer label (offsets 0 to -25
px). With the old clamp in screen_draw::button the fixture panics
("min > max"), so the harness now catches #6129. The outputs are read as
UTF-8: the Macedonian labels broke cp1252 decoding on Windows.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RodDbPWCByh96WKcbiEQpB
2026-11-01 19:15:35 +01:00
MSedgeMC 2157715650 Stop a witch from turning on the raider that hit it
Witch.registerGoals adds HurtByTargetGoal(this, Raider.class), so a raider that damages a
witch is never taken as its target; Horizon used a plain HurtByTarget, so raid friendly fire
made the witch fight the raid.
2026-11-01 18:15:00 +00:00
Maksymilian AdamkiewiczandCodex 7ab188cf5a fix: render inline text sprite shadows like vanilla
Render inline atlas sprites and player heads in the text shadow pass instead
of skipping them. Use the text tint, shadow colour and one-pixel offset,
vanilla's sprite baseline, and foreground depth for world text.

Reference: Minecraft 26.3 Font.PreparedTextBuilder, PlainTextRenderable.render
and AtlasGlyphProvider.Instance.renderSprite, inspected from the pinned jar.

Add a vanilla oracle and Rust probe covering 96 sprite geometry/colour cases,
plus regression coverage for inline player head shadows.

Validation: inline_object parity passes three consecutive runs; 27 HUD tests
pass; renderer/client probe Clippy passes with -D warnings and no default
features; rustfmt and public-tree checks pass.

Full pr_ready --quick remains blocked by missing ALSA development libraries,
mixed system Rust/Clippy toolchains, and an existing Python fixture missing
Entities.has_mods (test_mod_block_entity_any_template).

Co-Authored-By: Codex <[email protected]>
2026-11-01 19:13:51 +01:00
some-du6e 19327fd08f Merge branch 'optifine/cit-items' into optifine/cit-armor
# Conflicts:
#	crates/horizon-client/src/lib.rs
2026-11-01 12:13:26 -06:00
Ludovic MarieandClaude Opus 5.5 c6c3f3f96d Merge main into fix/firework-selectors
#6135 landed; main brings it and the rest of today's merges.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 19:13:02 +01:00
some-du6e e78f5366c7 Merge branch 'optifine/cit-overlay' into optifine/cit-items 2026-11-01 12:12:57 -06:00
some-du6e 5b4391fce3 Merge branch 'optifine/cit-atlas' into optifine/cit-overlay 2026-11-01 12:12:55 -06:00
some-du6e efbf43422e Merge remote-tracking branch 'origin/main' into optifine/cit-atlas
# Conflicts:
#	crates/horizon-client-render/src/hud.rs
#	crates/horizon-client-render/src/sprites.rs
#	crates/horizon-client/src/lib.rs
2026-11-01 12:12:43 -06:00
Ludovic MarieandClaude Opus 5.5 dc1597e116 Join the state-order error's two lines with a continuation
The message carried the source's indentation between "the" and "game";
the test now checks the block's id and the single spaces.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 19:12:15 +01:00
SenjinandClaude Opus 5.5 4fa1a55b08 HDR: screenshots carry an ICC profile, for programs that don't read cICP
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 19:12:06 +01:00
levzzzandClaude Sonnet 5.5 c73e087d70 Native mods: a registered command run from a single player's owner console
The owner console (horizon-ctl command, the MCP command tool) ran every
line through the vanilla dispatcher, so a command a native mod registered
answered "Unknown or incomplete command". It now offers a registered name
to native_perform_command first, as the owner at level 4, the way the
owner's own chat does. The dedicated console was done in #5815.

Fixes #5682.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 23:11:35 +05:00
MSedgeMC 38f7febdc4 Give an endermite its own goal list
Endermite.registerGoals is the silverfish-style monster list: powder-snow climbing, a
melee attack, a stroll at 1.0, and a HurtByTargetGoal that alerts the others. Horizon
never put the endermite in the hostile family, so it fell back to the generic monster
block: stroll at 0.8, no powder-snow climb, and a hurt-by-target goal that never alerts.
2026-11-01 18:11:10 +00:00
Evrim CicekandClaude Opus 5.5 f7301189ab test_world_input_capture: expect Windows' extra warp before the grab
#6126 warps the pointer before the Locked grab on Windows (#6076), so a
focused grab warps twice there. The fixture still expected one warp and
failed on Windows (CI's Linux run kept the old count). It now expects two
on Windows, one elsewhere, which also pins #6126's warp.

Fixes #6214

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 21:09:39 +03:00
some-du6e 4148b254a3 Merge optifine/etf-blink into optifine/etf-block-entities 2026-11-01 12:07:44 -06:00
JustKarkatandJustKarkat's AI agent 835f2b5222 Collision scans: a movement's budget covers its sweep
A movement scan walks every cell of the box the movement expands to, so a
diagonal move covers about N x N of them. The client passes a 1,000,000
candidate budget, which a legal `movement_speed` of 400 passes within a
second of running: the scan then ended in `VisitBudget` and the game
aborted with "collision scan candidate budget exhausted" (#6124).

Vanilla walks the same box with no counter at all, so the movement is slow
there, never fatal. The budget now only has to cover the cells the scan can
visit (the sweep, and the step's own box raised by max_step), which leaves
every ordinary movement identical and makes the pathological one stall
instead of exiting.

`scan_cells` shares the cursor arithmetic with `BlockCollisionScan::new`
rather than repeating it.

Co-Authored-By: JustKarkat's AI agent (ZCode) <[email protected]>
2026-11-01 12:07:41 -06:00
JoansitohandClaude Sonnet 5.5 f5161d9eba Resolve the local player's mount and test the worn head's real vehicle lookup
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 19:07:38 +01:00
MSedgeMC e35d09582c Make a bat flap the sculk sensors while it is awake
Entity.processFlappingMovement emits a flap game event when the entity flaps over air;
Bee and Bat each override isFlapping, a bee every other tick while flying and a bat every
tenth tick while it is not resting. Horizon only modelled the bee, so a bat flying around
was silent to sculk sensors and a warden.
2026-11-01 18:07:37 +00:00
some-du6e afe44e4d77 Merge optifine/etf-emissive into optifine/etf-blink 2026-11-01 12:07:10 -06:00
levzzzandClaude Opus 5.5 ba04f50325 Native mods: the client's level made and dropped (LevelEvent on the client)
native_client_level_load when the client makes a level (joining a server, a
dimension change), native_client_level_unload when it drops one (replaced,
told after the new one's load as Minecraft.setLevel does, or leaving the
server). The client half of #6154.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 23:06:24 +05:00
Ludovic MarieandClaude Opus 5.5 3dceebf36b Build horizon-ctl and the client examples from horizon-client-lib
old_world_e2e asked horizon-client-lib for the horizon-client binary, which
only horizon-client has; three e2e docstrings, the agent-control guide and
xtask's comment still built horizon-ctl from horizon-client.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 19:06:12 +01:00
some-du6e e23b8c8eee Merge remote-tracking branch 'origin/main' into optifine/ctm-draw
# Conflicts:
#	crates/horizon-client-render/src/mesher.rs
2026-11-01 12:06:11 -06:00
JoansitohandClaude Sonnet 5.5 c61ce6401e Apply the baby walk scale to a worn head's vehicle position
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 19:05:30 +01:00
JoansitohandClaude Sonnet 5.5 b0be23cd59 Animate worn Ender Dragon heads like vanilla's wornHeadAnimationPos
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 19:05:30 +01:00
some-du6e d1df8b8996 Merge optifine/etf-fixes into optifine/etf-emissive 2026-11-01 12:05:27 -06:00
Ludovic MarieandClaude Opus 5.5 0ae22962bc Cargo.lock: horizon-client-lib depends on horizon-optifine
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 19:05:09 +01:00
Alexander KrylovandHermes Agent 2d1d81794b Roadmap: the benchmark item's example issue is not one of its links
Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-11-01 21:04:18 +03:00
Ludovic Marie aea589b3b9 Merge main into the client library split 2026-11-01 19:03:54 +01:00
Ludovic MarieandClaude Opus 5.5 a1e81ddf66 Merge the author's main merge (8421d34) into the client library split
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 19:03:54 +01:00
MSedgeMC 9685f3a127 Refuse the lead while a nautilus is angry
Nautilus.canBeLeashed and ZombieNautilus.canBeLeashed return false while
AbstractNautilus.isAggravated, which is the brain holding ANGRY_AT or ATTACK_TARGET.
Horizon let a nautilus be leashed at any time, so a lead could drag off a nautilus in
the middle of a charge.
2026-11-01 18:03:41 +00:00
[email protected]andClaude Opus 5.5 5d08602df3 Mod registry: refresh its Cargo.lock for horizon-mods' png dependency
The registry is its own workspace, and its workflow runs on any change under
crates/horizon-mods. Its lockfile on main lacks png (and fdeflate, miniz_oxide),
which horizon-mods now needs, so `cargo clippy --locked` refuses it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 19:03:23 +01:00
Ludovic MarieandClaude Opus 5.5 dea8e3b54c Merge main into the client library split
Ports main's lib.rs (block_edit, cit, lod_net, ram_limit, social_screen) into
client_lib.rs, main.rs's --player-dir logging and ram_limit into entry.rs, and
the horizon-optifine dependency into horizon-client-lib.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 19:02:57 +01:00
Alexander KrylovandHermes Agent 152617ece7 Player data: a failed save is logged instead of dropped
Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-11-01 21:02:51 +03:00
some-du6e d3b8e147b2 Merge main into optifine/etf-block-entities 2026-11-01 12:02:30 -06:00
Ludovic Marie 1bd24e4042 Merge pull request #6151 from horizon-rust/fix/xp-orb-command-exec
🤖 Agent: test-only fix for command_exec on main: the boxed-in orb case (#5646) depended on vanilla's unseeded orb random; a stone ring keeps the top the only free side. verify + parity (command_exec PASS on the merge) green.
2026-11-01 19:02:25 +01:00
MSedgeMC 63d612913b Name the trading closure instead of splitting it 2026-11-01 18:02:20 +00:00
MSedgeMC f58c0ae1bf Fit the new match arm on one line 2026-11-01 18:02:19 +00:00
some-du6e 5eac2ed4e0 Merge main into optifine/etf-blink 2026-11-01 12:01:56 -06:00
MSedgeMC 802ab62f2b Save a wandering trader's visit timer and target
WanderingTrader.addAdditionalSaveData writes DespawnDelay and the wander target; Horizon
wrote neither, so a trader reloaded mid-visit forgot its countdown and started over, and
came back with nothing to walk to.
2026-11-01 18:01:12 +00:00
MSedgeMC 4cc8041d8b Keep a villager quiet in bed and loud at the trade table
Villager.getAmbientSound returns null while the villager sleeps and the trade sound
while a player has its menu open. Horizon always played the plain ambient, so a sleeping
villager talked in its bed and a villager in a trade screen never made the trade sound.
2026-11-01 18:01:07 +00:00
some-du6e ead5f88ea3 Merge main into optifine/etf-emissive 2026-11-01 12:00:20 -06:00
Ludovic Marie 343d6f96f3 Merge pull request #6137 from xFedeT/fix/sulfur-extra-knockback
🤖 Agent: Sulfur cube extra knockback; main merged in, #6146's resistance test now passes launch's effect scale (1.0).
2026-11-01 19:00:08 +01:00
Ludovic Marie accbe7865e Merge pull request #6155 from horizon-rust/fix/native-build-ascii-root
🤖 Agent: fixes #6009, native builds use a real ASCII folder in ProgramData instead of the 8.3 short name that rustc expands back to the Cyrillic path; Cyrillic unit test, clippy for x86_64-pc-windows-gnu and pr_ready --quick green. Not run on a real Windows machine.
2026-11-01 19:00:04 +01:00
Ludovic Marie a58ee066ec Merge pull request #6141 from horizon-rust/fix/launcher-github-rate-limit
🤖 Agent: launcher release reads survive GitHub's anonymous rate limit (5-min disk cache, ETag, Kira mirror fallback, clear error); launcher fmt/clippy/tests + pr_ready --quick green, live-tested in isolated dirs. The mirror answers once horizon-rust/kira#109 is deployed.
2026-11-01 19:00:00 +01:00
some-du6eandClaude Opus 5.5 5107acc191 Native mods: fire stands beside a block that only ignites; fuller hook docs
A test that fire in the air beside a block stays while its ignite odds are
above zero and that burn odds of zero never destroy it (round-1 review:
the fire test only reached burning out, and a swapped tuple went unseen).
native_fire_odds says to answer only for your own blocks and to keep the
odds within vanilla's 0 to 100; the fragment's summary names the campfire.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:58:52 -06:00
some-du6eandClaude Opus 5.5 b1a756ee59 OptiFine random entities: vanilla on a miss, custom-model and warden textures choose too
Fixes found reviewing #5744 after it merged:
- no rule matching, and ETF's 0, draw the texture itself, not <name>1.png
- choose walks the textures render_visible draws: a .jem's own texture and the
  warden's glowing layers with their own rules get picks
- rechecks count this window's ticks, so a frozen world (game_time stopped) still
  rechecks every mob once a second instead of one in 20 every tick
- a texture drawn with rules and no pick (a wolf just tamed) chooses on the next tick
- a scan's generation replaces the stale flag: every splitscreen player's mobs choose
  again after a reload
- variant images past a 4096x2048-texel budget stay out of the atlas instead of
  failing the whole pack set
- tests for layers with their own rules, the villager exemption, the facts, the render
  lookup and its fallback, and the atlas naming

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:58:25 -06:00
Evrim CicekandClaude Opus 5.5 89bd321b4a horizon-friends: the deadline test reads after the limit, so it ends on Windows
The test blocked in a read with no timeout and waited for the deadline's
shutdown to wake it. Linux wakes a blocked reader on shutdown; Windows does
not, so the test never finished there (#6183). It now reads once the limit
has passed, and a new test checks the case the deadline exists for: a peer
that sends one byte at a time, under the read timeout the callers set.

Fixes #6183

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 20:57:25 +03:00
Ludovic Marie 9f683428a6 Merge pull request #6142 from Qwerner88880/hooks-loot
🤖 Agent: Requeued with main merged in (the branch predated #6143's test fix).
2026-11-01 18:57:17 +01:00
Ludovic MarieandClaude Opus 5.5 3c2fca6a60 Merge main into #6137 (#6146's resistance test passes launch's new scale)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:57:12 +01:00
MSedgeMC 177d2c9f55 Test the sturdy up face when placing evoker fangs
EvokerAttackSpellGoal.createSpellEntity walks down from the top of the target until it
finds a block whose up face is sturdy, which is BlockState.isFaceSturdy(.., UP) and not
"is a full cube". Horizon required a full collision shape, so on a soul sand, mud or
full snow layer floor the fang search skipped the block under the evoker and stood the
fang on that block's own collision top, two sixteenths inside it.
2026-11-01 17:56:51 +00:00
Arslan ArmanulyandClaude Opus 5.5 e266d70800 Shader packs: centerDepthSmooth
depthtex0 at the screen's centre, smoothed with OptiFine's default centerDepthHalflife (1 tick):
a depth texture can only be copied whole, so a one-pixel pass loads the texel into a 1x1
R32Float target, copied into one of three small buffers and read back frames later without
waiting for the GPU (as timing.rs reads its timestamps). Packs focus depth of field with it;
it was zero.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 22:56:41 +05:00
Arslan ArmanulyandClaude Opus 5.5 7745fddd20 Shader packs: Iris's player status uniforms and the End flash
- currentPlayerHealth/Hunger/Air/Armor (fractions of their maximum) and maxPlayerHealth/
  Hunger/Air/Armor, -1 in creative and spectator where the shaders.properties reference says
  so; isSpectator, isRiding, feetInWater, inSwimmingAnimation, is_burning, is_hurt,
  is_invisible (spectators too), is_sneaking, is_sprinting, read as the HUD reads them.
- endFlashIntensity and endFlashPosition (view space, length 100): the End flash state that
  draws the flash sprite, 0 outside the End.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 22:56:41 +05:00
Arslan ArmanulyandClaude Opus 5.5 4fbbee3d13 Shader packs: the sky pass binds the pack's storage buffers
A sky program that reads the pack's SSBOs (Noble's gbuffers_textured, the fallback of
gbuffers_skytextured) was drawn without bind group 4: wgpu refused the frame ("expects a
BindGroup to be set at index 4") and the game panicked. The sky pass now binds them as the
other passes do.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 22:56:41 +05:00
Federico TaglianiandClaude Sonnet 5.5 aba7fe0ba0 A carrot or warped fungus on a stick swings only when the server boosts
FoodOnAStickItem.use passes on a client, and the server answers SUCCESS_SERVER (which swings the
hand) only when the mount can be boosted. The client counted both sticks as items whose use ends
the click, so every right click swung the hand, boost cooling down or not.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 18:56:25 +01:00
some-du6eandClaude Opus 5.5 2d5ee61a5c Name handleBlockEntityData in keep_client_state's doc
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:56:20 -06:00
Ludovic Marie 7311b1e178 Merge pull request #6135 from k3yh/fix/more-selectors
🤖 Agent: Requeued with main merged in (the branch predated #6143's test fix).
2026-11-01 18:55:28 +01:00
some-du6eandClaude Opus 5.5 a819b8f81c Test the beacon carry-over; drop the border filter
The update tag's carry-over of client state (a vault's and a trial
spawner's spin, a beacon's beam) is now keep_client_state, tested with a
real decoded beacon update tag: the beam stays, the powers and levels are
the tag's. Beacon::keep_beam and its test go.

The world border filter on the beacon tick goes to its own change: the
other remote block entity ticks skip the border too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:53:16 -06:00
Ludovic MarieandClaude Opus 5.5 02bc0cad54 Keep each level's tracked entities with its level
LanHost.states, the entities a level's moves are checked against before its
tick describes them again (the anticheat's ground check, move collisions, the
prediction's excuses), was one buffer that every level pass refilled. On a
server with players in several dimensions, the Overworld's moves met the
Nether's or the End's entities: a player standing on a boat at sea had no boat
under them, and the ground check kicked and banned players for it on the
official server (claims at y 63.086, a floating boat's top).

The states now park with their level (Parked.lan_states, swapped in
enter_level), like the level's other entity state.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:53:09 +01:00
Federico TaglianiandClaude Sonnet 5.5 473941651f A spear stab sends its damage indicator hearts to the players the server serves
Player.damageStatsAndHearts sends DAMAGE_INDICATOR particles to the level. Player.attack's path
also queued them for the served players (the window of the server thread, LAN guests); the
stab's own copy only drew them on the server's side, so the window never saw them.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 18:52:29 +01:00
arismoko 0813600988 Merge upstream main (975c07d915)
Keeps the branch mergeable into main. Conflicts:
- lib.rs and main.rs keep the split's layout. Main's new block_edit
  re-export and ram_limit module go into client_lib.rs, and ram_limit::start
  moves to entry.rs beside crash::install.
- block_entities.rs keeps both the attachment maps and main's per-chunk
  index of mod block entities.
The generated trait picks up the updated block_entity docs.
2026-11-01 17:52:28 +00:00
arismoko 21a8f529c0 Mod API: renderer hooks and block-entity drawing
world_drawn and render_needs dispatch through an ordered RenderHooks
trait, which a mod returns from Hooks::render. The renderer list is
installed once beside the mod list and called on the render thread
while the frame is built. block_entity_submit and the existing
block-entity drawing helpers come through the generated trait and
Host, so a trait mod can draw its block entities without a mixin.
2026-11-01 17:52:28 +00:00
arismoko 2b91e83a48 Mod API: dependencies between trait mods
A trait mod can depend on another mod's [api] crate. Required and
optional semver requirements resolve against the installed bundles.
Missing dependencies, version mismatches and cycles are rejected, and
hooks dispatch in a stable dependency order. Optional features turn on
only when that mod is installed, so mods call each other through
ordinary Rust functions rather than a runtime registry.
2026-11-01 17:52:28 +00:00
arismoko fb17636912 Mod API: attachments for server-owned mod data
Mods can attach typed data to players, entities, block entities,
chunks and levels through AttachmentType<T> and a Holder. Values
decode lazily, unknown and undecodable entries round-trip untouched,
persistent values encode when they change and memory-only values go
away on unload. Opted-in player values survive respawn.

Entity values load and save at the entity compound boundary, with
passengers and portal arrivals. Player values follow guest and
playerdata state. Block-entity values use a per-dimension side table,
chunk values live in chunk extras, and level values live in the
dimension's data/minecraft/horizon_attachments.dat. Errors are typed.
There is no client sync.
2026-11-01 17:52:28 +00:00
MariuslozoandClaude Opus 5.5 7a9207e1e2 Buttons scroll a label wider than they are, instead of panicking in clamp (#6129)
AbstractButton.extractDefaultLabel hands the label to ActiveTextCollector
.defaultScrollingHelper: a message wider than right - left is not centred but
drawn left-aligned, scissored to the button, and slid back and forth with
Util.getMillis (period max(overflow * 0.5, 3) s, sine easing, (int) cast);
only a message that fits takes Mth.clamp(centerX, left + w / 2, right - w / 2).
screen_draw::button always clamped, so a label wider than its button made
i32::clamp panic (min > max). In the Resource Packs footer that is "Open Pack
Folder" in af_za, ca_es, io_en, isv, mk_mk, oc_fr, tl_ph and vp_vl (150 px
button, label 150 to 171 px), which crashed the client when the screen opened.

screen_draw::scrolling_text ports the helper and scroll_offset its formula
(checked against a Java run of the same formula); button takes the clock as
now_ms, PackScreen::draw passes it on (OptionsUi uses hud_overlays::now_ms,
the chat's Leave Bed button too), so tests and the parity fixture stay
deterministic. The footer is drawn by draw_footer, which takes its labels, so
a test can use a language's strings without changing the global language.

ui::centered_text (HudBatch buttons and sliders) still clips by characters; it
cannot panic, and is left as a gap in tools/port_map.

Fixes #6129

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RodDbPWCByh96WKcbiEQpB
2026-11-01 18:51:53 +01:00
MSedgeMC c7286edfbe Give a zombie nautilus spawn egg its variant
ZombieNautilus.applyImplicitComponents applies the zombie_nautilus/variant component,
so a spawn egg or a bucket can pick the coral variant. Horizon applied the chicken, cow
and pig variants from the same components but had no arm for the nautilus, so the egg
always left the biome's temperate variant in place.
2026-11-01 17:51:51 +00:00
Arslan ArmanulyandClaude Opus 5.5 0d171868af Shader packs: five translation fixes (textureGatherOffsets, scalar dot, returns, bool uniforms)
- textureGatherOffsets (GLSL 4.60, 8.9.4), which naga lacks: a prelude helper gathers each
  footprint at P moved by its offset over the texture size, for float, int and uint samplers,
  a component and the depth compare form (Mellow's deferred1).
- dot(float, float) and normalize(float), GLSL genType overloads naga's vector-only built-ins
  lack: scalar overloads in the prelude unless the pack defines its own.
- discard and return guarded in braces, `{ if (true) discard; }`: the old `if (true) discard`
  took the pack's `else` (`if (c) discard; else x = 1.0;` lost its else). A return with a value
  followed by code in its block is guarded too (Miniature's hand program failed validation).
- A returned value converts to the function's type (`return 1;` in a float function), as GLSL
  does and naga does not: 73 more composite programs translate over 40 packs.
- A parameter or local named like a bool uniform hides it in its function instead of becoming
  `(hz_bool_name != 0)` in a declaration (Pixel Perfect's fogify).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 22:51:44 +05:00
MSedgeMC 3217c6759b Read the difficulty for a drowned's trident spread
RangedAttackMob.rangedAttackUncertainty is 14 - difficulty.getId() * 4, so a drowned
throws straighter the harder the world is. Horizon hard-coded the normal-difficulty 6,
which made every drowned as accurate as a hard-mode one on easy and peaceful.
2026-11-01 17:50:43 +00:00
Ludovic MarieandClaude Opus 5.5 082ad987a6 Merge main into #6142 (the mod block entity test fix, #6143)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:49:52 +01:00
Ludovic MarieandClaude Opus 5.5 4aaba7056d Merge main into #6137 (the mod block entity test fix, #6143)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:49:27 +01:00
MSedgeMC 097b9781ad Let a creeper drop by its own health while it is chasing
Creeper.getMaxFallDistance is
getTarget() == null ? getComfortableFallDistance(0) : getComfortableFallDistance(getHealth() - 1),
so a creeper that has a target accepts a drop as deep as its health allows. Horizon only
had Mob's shared health-minus-a-third formula, so a creeper would not path down a drop
that vanilla walks off.

The same helper is now used for the base case too: getComfortableFallDistance is
Mth.floor(f + 3.0F), which is what the hard-coded "+ 3" was.
2026-11-01 17:49:26 +00:00
some-du6e 16610216d4 Merge remote-tracking branch 'upstream/main' into t3/issue-5499
# Conflicts:
#	crates/horizon-client/src/game.rs
#	crates/horizon-client/src/game/native_hooks.rs
#	crates/horizon-world/src/level/tests.rs
2026-11-01 11:48:56 -06:00
Ludovic MarieandClaude Opus 5.5 e61a51dd3b Merge main into #6135 (the mod block entity test fix, #6143)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:48:52 +01:00
Federico TaglianiandClaude Sonnet 5.5 ce02c03296 Linux on ARM: name the CPU from its implementer and part
An arm64 kernel prints no model name in /proc/cpuinfo, only CPU implementer and CPU part, so
telemetry sent an empty cpu, the benchmark report had no hardware.cpu (and the server then
refused it), and the crash report said aarch64. linux_cpu_name reads model name, else maps the
implementer and part as lscpu does (ARM Neoverse-N1, Apple, Ampere...), else the board's Model.
The three readers share it.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 18:48:10 +01:00
some-du6eandClaude Opus 5.5 7359afe6e1 Keep a server client's beacon beam through update tags
A host sends a beacon's update tag when a block there changes or its
powers are set, and the client replaced the whole block entity with each
one: its beam sections were empty until the next tick walked them again,
so the beam blinked out on every update. BeaconBlockEntity.loadAdditional
reads only the powers; the beam the client walked now stays, as a
vault's spin does.

The client's beacon tick also follows LevelChunk.isTicking: only inside
the world border.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:48:09 -06:00
Ludovic MarieandClaude Opus 5.5 6a6e34e1ec Launcher: release lists past GitHub's rate limit (disk cache, Kira mirror)
Players behind a shared address spend GitHub's 60 anonymous API calls an
hour together and then can't install or update ("GitHub's rate limit was
reached"). The launcher's API reads (release list, launcher tags, commits)
now go through launcher/src/github.rs:

- each answer is saved in <data>/cache/github/ and used again for 5 minutes
  without asking (three starts in a row spend one call, measured), then
  revalidated with its ETag (an anonymous 304 still counts, measured; it
  saves the download);
- a 429, or a 403 with GitHub's rate-limit headers, or no connection: Horizon's
  own repository is read through Kira's read-only mirror
  (mcp.playhorizon.dev/github/public, kira#109);
- both failing: the last saved answer, else an error naming both.

A plain 403 is no longer called a rate limit. Release files still come from
their browser_download_url (github.com -> release-assets.githubusercontent.com),
which the API limit doesn't cover.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:46:42 +01:00
[email protected]andClaude Opus 5.5 576fb98696 fix(mods): refuse a declared state property that sorts before the template's
A content block's states are its template's states times the declared
combinations, the template's properties varying slowest. StateTable::new
reads a state table as vanilla's StateDefinition does, sorted by property
name with the first name slowest, so a declared name that sorts before one of
the template's (`bites` before `facing` on carved_pumpkin) stopped the game at
load with "registry state order differs from vanilla's state definition".

Such a block is now refused when the plan is built, with its id and the
property to rename, so the mod's row says why and the game starts.

Fixes #6139

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:46:41 +01:00
Ludovic Marie 41501a243a Merge pull request #6119 from Sticky-Bug/feat/ender-dragon-hurt-flash
🤖 Agent: Dragon damage events reach clients (hurt flash, parts' ids); the hurt sound stays the server's; rustfmt; verify passes.
2026-11-01 18:46:31 +01:00
Ludovic Marie 7cb2a7c122 Merge pull request #6153 from xFedeT/fix/ctrl-word-navigation
🤖 Agent: Command block, block editors and debug options read Ctrl and Shift from either side (hasControlDown/hasShiftDown).
2026-11-01 18:46:25 +01:00
Ludovic Marie d2d3bda9c7 Merge pull request #6150 from Akirabane/feat/6108-gs4-query
🤖 Agent: horizon-server serves vanilla's GS4 query over UDP when enable-query is on (off by default, query.port).
2026-11-01 18:46:21 +01:00
Ludovic Marie 70fd95333e Merge pull request #6149 from breachato/fix/6122-orb-pickup-sound
🤖 Agent: Each experience orb taken plays its pickup sound at the orb (ClientPacketListener.handleTakeItemEntity).
2026-11-01 18:46:16 +01:00
Ludovic Marie 8a199ab3c2 Merge pull request #6146 from Joansitoh/fix/6113-sulfur-knockback-resistance
🤖 Agent: Sulfur cube knockback keeps negative knockback resistance (clamped to -2..1) as vanilla.
2026-11-01 18:46:11 +01:00
Ludovic Marie d4370c5ffa Merge pull request #6132 from Domanffe/fix/container-hotbar-keybinds
🤖 Agent: In containers the off hand and hotbar keys follow the controls (checkHotbarKeyPressed with the bound keys).
2026-11-01 18:46:07 +01:00
Ludovic Marie c5d7ff74fc Merge pull request #6130 from xFedeT/fix/mob-melee-knockback
🤖 Agent: A mob's melee knockback uses attack_knockback plus the weapon's, along its yaw (Mob.doHurtTarget), through the target's knockback resistance.
2026-11-01 18:46:03 +01:00
Ludovic Marie d2e0233e37 Merge pull request #6128 from masa-masa-0314/l10n/zh-complete
🤖 Agent: Chinese (zh_cn) strings for Horizon's newer pages.
2026-11-01 18:45:59 +01:00
Ludovic Marie c4a76f4cb7 Merge pull request #6127 from readerkid/fix/cauldron-prediction
🤖 Agent: A cauldron click on a server predicts the cauldron's reaction (CauldronInteraction), not the held bucket's own use.
2026-11-01 18:45:55 +01:00
Ludovic Marie dabcb464a3 Merge pull request #6126 from xFedeT/fix/windows-cursor-warp-before-grab
🤖 Agent: On Windows the pointer warps to the window's centre before the Locked grab (ClipCursor 1x1), so it is not left frozen elsewhere.
2026-11-01 18:45:49 +01:00
some-du6eandClaude Opus 5.5 07b7fc0ad8 Native mods: a mod block catches fire, and a campfire smokes over it
Two hooks on an authoritative server's level, for #5499:

- native_fire_odds answers FireBlock's flammability table
  (FireBlock.setFlammable's igniteOdds and burnOdds) before vanilla's
  table, so fire spreads to, burns and stands beside a mod block.
- native_smoke_source answers CampfireBlock.isSmokeSource for the block
  under a campfire, when the campfire is placed and when that block
  changes, so a mod block makes a signal fire as a hay bale does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:45:24 -06:00
JoansitohandClaude Sonnet 5.5 b3384f9bd2 Share the layer icon resolve with its test; move GrassEnv above slot docs
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 18:45:05 +01:00
JoansitohandClaude Sonnet 5.5 80ad7fac9b Format flat.rs
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 18:45:05 +01:00
JoansitohandClaude Sonnet 5.5 d46f1a6831 Tint the Superflat layer grass block icon with the grass colormap
Fixes #6107

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 18:45:05 +01:00
Federico TaglianiandClaude Sonnet 5.5 5fe9fc298f A camel's pending dash waits for the ground instead of being dropped
AbstractHorse keeps a pending jump until the mount is on the ground (and not still jumping); only
Camel.onPlayerJump refuses it at the start (cooling down or off the ground). Horizon cleared the
rider's pending jump on every tick, dash or not, so a release on a tick the camel was not on
the ground (a bounce after landing, a slope) dashed sometimes and not other times.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 18:43:22 +01:00
[email protected]andClaude Opus 5.5 9697811a90 feat(mods): a content block's random_ticks property
A mod block only got random ticks when its template ticks, so a crop,
sapling or soil on a template that never ticks (dirt, stone) never grew.
`properties.random_ticks` is `true` or `false` for every state, or
`{ "property", "values" }` for the states whose own state property is one of
the values. It sets the state's `random_ticks` flag, which
`isRandomlyTicking`, the sections' random tick counts and
`native_random_tick` already read.

Fixes #6138

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:43:07 +01:00
some-du6eandClaude Opus 5.5 e23cc14244 Far decoration: a third of the threads, rounded up
A 4-thread machine had one far decoration thread, which left idle time
while the far trees and structures came in. The threads run at idle
priority, behind the near chunks, the ticks and the frame.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:39:19 -06:00
some-du6eandClaude Opus 5.5 064cd6c99c Status: how many far columns have their generated detail
`lod.detail_target_columns`: the drawn far columns that take their chunk's
generated detail (trees, structures), and `lod.detailed_target_columns`:
how many have it, as `target_tiles` and `sampled_target_tiles` do for
sampling.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:39:18 -06:00
arismoko 1b1610facd Mod API: [[injection]] into a mod's own Hooks, with a patched-source cache
A trait mod can declare HEAD injections that the launcher turns into a
generated trait method plus an ordinary horizon-mixins helper, so the
injected code calls the mod's own Hooks object. Listen and cancellable
unit HEAD injections are supported. Other kinds, and targets outside
horizon-client, are rejected with a clear error.

Patched game sources are cached by Horizon and mixins revisions, Rust
version, profile, features, bundle order and mixin inputs, so editing
a trait mod's own crate rebuilds without another mixins pass. Only
bundles with mixin content are staged for horizon-mixins: before, a
trait-only bundle installed beside a mixin bundle failed with E_MANIFEST.
2026-11-01 17:38:20 +00:00
arismoko eb6b666426 Mod API: a generated Host for documented Game helpers
Trait mods had no way to call the game back. Host is generated from an
explicit `helpers` allowlist in native-hooks.json: each listed public
native_* helper becomes an object-safe method with the helper's
receiver and docs, implemented by forwarding to Game. Helpers whose
signatures use gameplay-private types or generics stay out, and the
docs list them with the reason.
2026-11-01 17:38:20 +00:00
arismoko 93b36334e2 Typed native mod API: a generated Hooks trait linked by the launcher
Native mods could only reach the game through mixins, so every build
patched the game's source. A mod can now implement
horizon_client::mod_api::Hooks instead.

The trait is generated from native-hooks.json. Each catalog hook gets a
mod_api contract (listen, cancel, replace or collect), and
`cargo run -p xtask -- mod-api` writes the trait methods and their
dispatch from the hook functions' own signatures and docs. Hooks keep
their exact receivers. Level hooks get read-only or mutable level hosts,
and library hooks (loot tables, default components) dispatch through a
set-once table that entry::main_with installs. Types that mods need move
out of the game into mod_api, which re-exports library types rather
than copying them.

The launcher links a bundle's [api] crate into a generated executable
that calls entry::main_with with every trait mod, in bundle order. A
build with only trait mods skips horizon-mixins. pr_ready runs
`xtask mod-api --check` when the catalog, the hooks or xtask change.
2026-11-01 17:38:20 +00:00
levzzzandClaude Opus 5.5 c60b4c8d46 Native mods: a server's levels loading and unloading (LevelEvent)
native_level_load when a level opens, before its chunks; native_level_unload
once it has closed, after its last save: a level idle for 300 ticks, and every
level still open when the server stops or fails. Fixes #6154.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 22:37:20 +05:00
HrafnaValkyrja 2e16f1ffdd Merge remote-tracking branch 'origin/main' into fix/protection-generic 2026-11-01 12:37:00 -05:00
HrafnaValkyrjaandClaude Opus 5.5 212d78cced Protection applies to generic damage on mobs
Enchantment protection skipped generic damage. It now counts, as vanilla's PROTECTION tag rules do.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 12:37:00 -05:00
Ludovic MarieandClaude Opus 5.5 936453136f Native mods: build in a real ASCII folder, not a short name (#6009)
0.2.33 handed MinGW-w64 the data folder's short (8.3) name when the user
name is Cyrillic, and linking still failed: "No such file or directory" on
C:/Users/\xca\xe8... A short name is only another spelling of the same
folder, and rustc canonicalizes its own path to find its sysroot, which
spells the long name again for the linker.

build_dir now uses a real folder of ProgramData's
(ProgramData\horizon-launcher-<id>, one per data folder) whenever the data
folder's path isn't ASCII; the short name is kept only as the fallback when
that folder can't be created. A unit test covers a Cyrillic data folder.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:35:36 +01:00
Federico TaglianiandClaude Sonnet 5.5 77ede7fc60 Command block, structure/jigsaw block editors and debug options read Shift and Ctrl from the live modifiers
These screens asked self.keys for Ctrl and Shift, which only gameplay and the chat fill, so
Ctrl+arrows (word by word), Ctrl+Backspace and Shift+arrows (selection) did nothing in them.
They read InputWithModifiers.hasControlDown / hasShiftDown now, as dialogs and chat do (Cmd
instead of Ctrl on macOS, hasControlDownWithQuirk).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 18:32:36 +01:00
arismoko 0f8b7dbdd0 Merge upstream main (308ef65272) into mod-api-rework
Brings mod-api-rework up to the main this API was built and tested on. The only conflict is crates/horizon-client/src/lib.rs: it keeps the split's layout, and main's new library modules move into client_lib.rs.
2026-11-01 17:32:16 +00:00
JoansitohandClaude Sonnet 5.5 8195aa8b36 Shield: the item's blocking knockback comes before the hit's default knockback
LivingEntity.hurtServer runs applyItemBlocking (blockedByItem's push) before
dealDefaultKnockback; hurt_player_from ran them the other way round, so a
partly blocking shield pushed its holder the wrong way. Adds two partial-shield
cases to the server knockback harness. Fixes #6109.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 18:30:53 +01:00
Ludovic MarieandClaude Opus 5.5 e03987888d Command-exec parity: the boxed-in orb's outcome no longer depends on its random
The "experience orb stuck" case (#5646) assumed the push out of a block only
changed how many ticks the escape takes. It does not: once the orb's middle
rises past y 216 with its box still in the 3x3 block, Entity.moveTowardsClosestSpace
finds north, south, west and east free with the same 0.5 distance, picks north
(the first), and depending on the draws the orb slides off the block (z 23.25,
22.x, or down to y 215 on the floor). Vanilla's orb random is unseeded
(RandomSource.create()), so both sides land on different outcomes from run to
run: the harness failed on main with an orb at y 215 against vanilla's 216.

A ring of stone one block higher keeps the top the only free side all the way
out; the case still pins the push (without it the orb stays at y 215).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:30:05 +01:00
Ludovic MarieandClaude Opus 5.5 e0cb84d361 Merge main into #6119 (the mod block entity test fix)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:29:53 +01:00
Ludovic Marie 975c07d915 Merge pull request #6106 from horizon-rust/feat/native-inventories
🤖 Agent: queued for merge: pr_ready green on 6deec70 (block above); native helpers for the SMP's /invsee and market (fragment, numbered at stamping).
2026-11-01 18:29:11 +01:00
JoansitohandClaude Sonnet 5.5 d73a40748d Sulfur cube: compute the player push factor in double, with a tested helper
Part of #6113

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 18:27:59 +01:00
JoansitohandClaude Sonnet 5.5 2bdc10aa27 Sulfur cube: keep negative knockback resistance (-2..1) like vanilla
Part of #6113

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 18:27:58 +01:00
Ludovic Marie f1900a0b1a Merge pull request #6143 from horizon-rust/fix/mod-be-test-has-mods
🤖 Agent: Fixes main's verify: the mod block entity test's stub gets BlockEntities::has_mods (#6117). Verified: the test passes locally.
2026-11-01 18:27:53 +01:00
Ludovic Marie 5afd7d35cd Merge pull request #6123 from breachato/fix/5773-glider-components
🤖 Agent: Gliding asks canGlideUsing for every slot: a stack's own glider and equippable components (or a server's patch) count; main's wear kept; no allocation for stacks without components.
2026-11-01 18:26:52 +01:00
Ludovic MarieandClaude Opus 5.5 7e422bd881 Commands: cache what the registries take from the export, not the export
command_export kept the parsed command-registries.json (4.2 MB of
serde_json Values) for the whole run, though the registries take only
part of it. Cache Registries::from_export, the fields fill_from_export
fills (2.2 MB), and fill from it with fill_from_exported. A dedicated
server opens its world once: it keeps nothing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:26:28 +01:00
Ludovic MarieandClaude Opus 5.5 830cdd2647 Mod block entity test: the stub has has_mods, as BlockEntities does since #6117
has_mod_block_entity asks BlockEntities::has_mods first; the fixture's stub
lacked it, so the Python test (and verify on every PR) failed on main.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:25:57 +01:00
k3yhandClaude Opus 5.5 eb4b64e1e4 Selectors find firework rockets
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 12:25:37 -05:00
Ludovic MarieandClaude Opus 5.5 842be48d6e Dedicated server: item renderer without the client item definitions
A dedicated server's ItemRenderer kept what only drawing reads: the
~1,500 client item definitions (items/**/*.json), the special
renderers' entity models and the grass colormap, 1.4 MB of the 7.3 MB.
ItemRenderer::load_for_server keeps the prototypes (the stacks' default
components, which gameplay reads) and the item ids.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:24:48 +01:00
some-du6eandClaude Opus 5.5 88cde780fb Resource packs: decode block sprites and OptiFine tiles on every thread
Block model baking and connected textures added their textures one at a time,
reading and decoding each PNG in turn. They now hand the whole list to
Sprites::prefetch first, which decodes it with par_map, then insert in their
own order as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:24:28 -06:00
breachatoandClaude Opus 5.5 841a0adca0 Taking an experience orb always plays its pickup sound, at the orb
ClientPacketListener.handleTakeItemEntity plays entity.experience_orb.pickup
at the orb for every orb taken; the server sends no sound (ExperienceOrb
.playerTouch plays none). On a server Horizon played nothing, expecting the
server's sound, so collecting a furnace's experience was silent. In a local
world it played once at the eyes, and not at all when mending took the
points. Each orb taken now sounds at the orb in both.

Fixes #6122

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:24:17 +01:00
some-du6eandClaude Opus 5.5 e0eb57399b Pack bench: a bench the base can't build says so
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:23:58 -06:00
Ludovic Marie f081099e51 Merge pull request #5905 from reservedbytes/feat/native-item-use-tick-release
🤖 Agent: verified with main + #5866 + #5905: pr_ready --quick green (1756 tests), 18 item-use and menu harnesses pass on the 26.3 jars.
2026-11-01 18:23:34 +01:00
Ludovic Marie e576d02cd1 Merge pull request #5866 from reservedbytes/feat/mod-menu-engine
🤖 Agent: verified with main + #5866 + #5905: pr_ready --quick green (1756 tests), 18 item-use and menu harnesses pass on the 26.3 jars.
2026-11-01 18:23:30 +01:00
Ludovic MarieandClaude Opus 5.5 e6ed585f5a LAN server: keep recipe files as compact JSON text, parse per book entry
ServerAdvancements kept all ~2,000 recipe files parsed (4.6 MB of
serde_json Values) for the server's whole run, only to build recipe book
entries when a player joins or unlocks a recipe. Keep each file as its
compact JSON text (0.57 MB) and parse the one an entry needs: parsing
every recipe takes 1.2 ms, the worst case of a full book on join.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:22:53 +01:00
Federico TaglianiandClaude Sonnet 5.5 f27a64e873 A sulfur cube with a block takes a blow's extra knockback through SulfurCube.knockback
SulfurCube.hurtServer answers true for the blows it only knocks about, so Player.attack goes on
with causeExtraKnockback (sprint, the Knockback enchantment), which reaches the cube's own
knockback with comesFromEffect: the launch scaled by power * 0.25 on top of sqrt(damage).
Horizon answered false, so attack_mob skipped the whole hit's aftermath, and the extra push
went through the generic Mob::knockback. The answer is now true for the tag, and the player's
extra push goes through Mob::knockback_effect.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 18:21:30 +01:00
some-du6eandClaude Opus 5.5 3ffb7b0663 Resource packs: build a big pack's overlay on every thread
The overlay walk looked at every overridden file once per folder it visited, and
inflated and wrote a zip pack's files one at a time.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:18:41 -06:00
some-du6eandClaude Opus 5.5 6bd9098e3b Resource packs: a benchmark of loading big packs, on generated files
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:16:23 -06:00
Ludovic Marie 1e02316c27 Merge pull request #5940 from TiggerTakkyTTT/fix/server-management-settings
🤖 Agent: MOTD also stored in server.properties and server.toml as vanilla's DedicatedServer.setMotd; main merged (difficulty kept); fmt, clippy, horizon-server tests, management_protocol + dedicated_server harnesses pass at 87e8e3122.
2026-11-01 18:16:18 +01:00
breachatoandClaude Opus 5.5 cbb7efe894 Spawn eggs used on vines spawn the mob in the vines
SpawnEggItem.useOn spawns in the clicked block itself when its collision
shape is empty (vines, grass, flowers) and only otherwise in the block on the
clicked face. Horizon always took the face's block, so an egg used on vines
put the mob a block out in the air, where it fell instead of climbing.

Fixes #6125

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:15:41 +01:00
Akirabane 153dca4120 Merge remote-tracking branch 'origin/main' into feat/6108-gs4-query 2026-11-01 18:15:10 +01:00
Ludovic Marie d01e5a10f7 Merge pull request #6120 from josegil1909/docs/agents-i18n-rule
🤖 Agent: AGENTS.md: user-facing strings go through the translation helpers.
2026-11-01 18:14:48 +01:00
Ludovic Marie 9acb5c683f Merge pull request #6118 from Joansitoh/fix/arrow-deflect-wind-charge
🤖 Agent: Arrows hit wind charges and deflect off them (Projectile.isPickable, deflect REVERSE 0.2), no extra cost without wind charges.
2026-11-01 18:14:45 +01:00
Ludovic Marie 83b55c8782 Merge pull request #6117 from Xmilon/native/mod-block-entity-any-template
🤖 Agent: Native block entities on any template; per-chunk list for drawing, and block changes look one up only once one exists (no cost without mods).
2026-11-01 18:14:40 +01:00
Ludovic Marie dd1ba6ea31 Merge pull request #6115 from josegil1909/fix/launcher-servers-and-layout-i18n
🤖 Agent: Launcher Servers tab strings and buttons go through tr/trf with translations; layout fixes.
2026-11-01 18:14:36 +01:00
Ludovic Marie b69ff595d2 Merge pull request #6114 from 4404c/secure-chat/confirm-command
🤖 Agent: Commands the player didn't type go through ClientPacketListener.sendUnattendedCommand's checks: confirm on parse errors or elevated permissions, never send a message argument unseen.
2026-11-01 18:14:32 +01:00
Ludovic Marie 050a5de2c2 Merge pull request #6102 from breachato/fix/5858-turtle-egg-trample
🤖 Agent: A player trampling turtle eggs sends the egg's break sound and particles (decreaseEggs level events), heard by guests too.
2026-11-01 18:14:27 +01:00
Ludovic MarieandClaude Opus 5.5 dc77793cdd rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:14:26 +01:00
Ludovic Marie 1225f2ec53 Merge pull request #6101 from Domanffe/fix/windows-version-from-registry
🤖 Agent: Telemetry and crash reports read the Windows build from the registry instead of the localized cmd /C ver.
2026-11-01 18:14:23 +01:00
Ludovic Marie ca6f6978bd Merge pull request #6100 from HrafnaValkyrja/fix/lava-and-rose
🤖 Agent: Fire and lava hit mobs after baseTick's timers (applyEffectsFromBlocks in aiStep), effects tick after them, and wither roses use the world difficulty.
2026-11-01 18:14:17 +01:00
Ludovic MarieandClaude Opus 5.5 06c4662593 Creaking hearts: scan only the chunks that arrived, not every chunk each tick
scan_for_hearts read every resident chunk each tick (is it lit, was it
scanned) and then checked every scanned key against the chunk map: with
the 17,000 chunks of a 54-player server that is two hash lookups and a
cache miss per chunk, every tick. The scan now follows the chunk map's
membership changes: a chunk that arrives waits in a small set until it
is lit and scanned, one that leaves is forgotten. A chunk gone and back
between two ticks stays scanned, as before; a map that can't replay its
changes (cleared, or too many of them) is rebuilt from its keys. Ready
chunks are scanned in key order.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:13:19 +01:00
Ludovic MarieandClaude Opus 5.5 40b712232e Merge main into #6123: gliding_pieces (main's wear and armor walk) asks can_glide_using, the hands count too; a stack without components answers from its item
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:11:46 +01:00
k3yhandClaude Opus 5.5 0d342f6c72 Selectors find falling blocks and thrown items
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 12:11:36 -05:00
AkirabaneandClaude Sonnet 5.5 bfb5b219ff horizon-server: GS4 query, vanilla's UDP status port (enable-query, query.port)
The supervisor serves vanilla's GS4 query as QueryThreadGs4 does: the handshake (type 9, a token
below 2^24 as text), then the basic and the full stat to a request that carries the token, each
byte for byte what a vanilla 26.3 server sends for the same settings (checked on the wire). The
players come from the game's server-status, read at most once a second; the full stat is kept
5 seconds as in vanilla. Off by default; an invalid port leaves it off with vanilla's warning.

Refs #6108

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 18:11:14 +01:00
Alexander KrylovandHermes Agent 4842287372 Inventory: the swap-offhand and hotbar keys follow the controls
Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-11-01 20:10:38 +03:00
Federico TaglianiandClaude Sonnet 5.5 914c15d349 A mob's melee push is LivingEntity.knockback along its facing, with attack_knockback
Mob.doHurtTarget calls causeExtraKnockback(target, getKnockback): (attack_knockback attribute plus
the Knockback enchantment) / 2, pushed along the attacker's yaw through LivingEntity.knockback,
which scales it by 1 - the target's knockback resistance, and slows the attacker by 0.6. Horizon
added a fixed velocity along the line to the target, with no resistance, no slowdown and no
attack_knockback. The attribute is now read (type default, or the mob's own from NBT).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 18:08:46 +01:00
Ludovic MarieandClaude Opus 5.5 feee25c88c Mod block entities: a block change looks one up only once one exists
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:07:54 +01:00
Ludovic MarieandClaude Opus 5.5 6deec70473 Server stacks carry their lore to clients
A stack's minecraft:lore (as loaded, or as a server mod wrote it into
StackData.other) is now sent in its component patch, so a native mod's
menu items show lines under their name (prices, changes) in a stock
client.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:07:34 +01:00
[email protected]andClaude Sonnet 5.5 f2b9a3c6d4 perf(mods): per-chunk list of mod block entities for the renderer
BlockEntities keeps the positions of its Mod block entities by chunk, so the
renderer reads only those within view distance instead of scanning every
loaded block entity each frame.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 18:07:09 +01:00
NerfPhaselockandClaude Opus 5.5 18ac513e8c Renderer: bind the large translucent pages up to their last range in use
wgpu clears what a vertex or index binding covers that nothing has written
yet, on the GPU, before the pass runs. Draws bound Translucent Batching's
large pages whole, so a page that had just grown had its new tail, up to
256 MiB, cleared in the next frame. Arena::binding gives a page's buffer up
to the end of its last range in use (a grain at least: wgpu refuses an empty
slice), and draw_sections binds the translucent arena's and the sorted
indices' pages through it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 12:07:04 -05:00
Ludovic Marie 3adcfcbb36 Merge pull request #6093 from breachato/fix/6028-tab-offhand-in-containers
🤖 Agent: A container screen still checks its hotbar keys after Tab or an arrow moves the focus (AbstractContainerScreen.keyPressed), so Tab bound to the off hand swaps.
2026-11-01 18:04:44 +01:00
masa-masa-0314andGLM 5.3 f53460278d Horizon strings: the Chinese bundle catches up with the new pages
The splitscreen, friends, extras, HDR calibration, on-screen keyboard and
splitscreen-profile keys landed in en_us after zh_cn's last pass; a
Chinese player saw English on those pages. The bundle is complete again
(308 keys of 308), with the terms the earlier translations set: 分屏,
档案 for a profile, 与好友一起玩 for Play with Friends.

Co-Authored-By: GLM 5.3 (ZCode) <[email protected]>
2026-11-02 01:03:01 +08:00
Ludovic MarieandClaude Opus 5.5 7dbda4f4f5 Dragon damage event: the hurt flash only; the hurt sound stays the server's
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:02:18 +01:00
Federico TaglianiandClaude Sonnet 5.5 97a3f78a3e Windows: warp the pointer to the window's centre before the Locked grab
winit's Locked on Windows is ClipCursor to a 1x1 rectangle at the pointer's current spot, and
SetCursorPos stays inside the clip, so the warp that followed the grab could not move it: the
pointer froze wherever it was (over another window or monitor). InputConstants.grabMouse warps to
the centre first; macOS already did, and now Windows does too.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 18:01:12 +01:00
Ludovic Marie c86e861455 Merge pull request #5696 from spidercraft/fsr-frame-generation-setting
🤖 Agent: main merged again (submit thread, leaf and entity culling). With frame generation on, a frame is submitted in place so its present thread presents it, and the hand stays out of the world pass so it draws over the real and the made frame; frameGeneration sits beside leafCulling in the settings format. verify green on 39737da; on playhorizon cargo check -p horizon-client --all-targets and the graphics (33), frame_gen (9) and gfx (176) tests pass. Thanks @spidercraft.
2026-11-01 17:59:39 +01:00
Ludovic Marie 11427c2960 Merge pull request #6097 from Akirabane/fix/6066-6067-command-block-feedback
🤖 Agent: Command block and command minecart admin lines go to operators through broadcastToAdmins (host and console included); Done's confirmation reaches only the editing player.
2026-11-01 17:55:43 +01:00
Ludovic Marie 1600ce6fa9 Merge pull request #6096 from k3yh/fix/tnt-selectors
🤖 Agent: Primed TNT is an entity for selectors, /tag, /data, /tp and /kill; it gets its entity id when primed.
2026-11-01 17:55:39 +01:00
Ludovic Marie 9ddc5e7498 Merge pull request #6095 from xFedeT/fix/singleplayer-owner
🤖 Agent: A world without singleplayer_uuid gives its owner their own (or a new) player file, never the newest other player's (PlayerList.loadPlayerData).
2026-11-01 17:55:35 +01:00
Ludovic Marie ff518b90c1 Merge pull request #6090 from HrafnaValkyrja/fix/mob-effects
🤖 Agent: Mobs act on instant health/damage, Resistance, Absorption (saved as AbsorptionAmount) and Slow Falling, as LivingEntity does.
2026-11-01 17:55:31 +01:00
Ludovic Marie 46ba16b36a Merge pull request #6089 from Xmilon/native/xp-orb-merge
🤖 Agent: Native hooks experience_orb_can_merge / experience_orb_merge around ExperienceOrb.scanForMerges and the spawn merge; no-op stubs without native mods.
2026-11-01 17:55:27 +01:00
Ludovic Marie 74672ea0e9 Merge pull request #6103 from horizon-rust/perf/lazy-block-tags
🤖 Agent: measured on playhorizon (alternated, THP off): idle anonymous −4.2 MB on every start; Done and 20-bot MSPT unchanged (3 rounds). pr_ready ✅ (368 horizon-world tests); clippy -D warnings and fmt clean on the Mac.
2026-11-01 17:55:23 +01:00
Ludovic Marie ac18068674 Merge pull request #5859 from doodlyyousuf/fix/5720-atlas-custom-directory
🤖 Agent: Review by Ludovic's AI agent (Claude). Extra atlas folders from blocks.json are packed (one walked list, 42e6769). Verify green. The parity failure was the runner's dead .horizon-prepare.lock link (2 s, before any harness), not this change; parity rerun.
2026-11-01 17:55:19 +01:00
Ludovic Marie ab0c403b75 Merge pull request #5737 from Stink-O/native/read-columns
🤖 Agent: Review by Ludovic's AI agent (Claude). Native column and surface-height reads on #5735, bounded by MAX_READ. Verify green on the whole stack. The parity failure was the runner's dead .horizon-prepare.lock link (2 s, before any harness), not this change; parity rerun.
2026-11-01 17:55:15 +01:00
Ludovic Marie 816ebe9925 Merge pull request #5735 from Stink-O/native/edit-blocks
🤖 Agent: Review by Ludovic's AI agent (Claude). Native bulk block edit API on #5733 (merged); each new block gets its own block entity (65ef100, tested). Verify green. The parity failure was the runner's dead .horizon-prepare.lock link (2 s, before any harness), not this change; parity rerun.
2026-11-01 17:55:11 +01:00
Jose Gil 196076a67c docs: require translation-ready UI strings in AGENTS.md 2026-11-01 12:54:11 -04:00
JoansitohandClaude Sonnet 5.5 f25c0dd45a Arrows bounce off a wind charge instead of vanishing
AbstractArrow.onHitEntity: the charge cannot be hurt, so the arrow takes
deflect(REVERSE, 0.2) and falls. The harness now also checks the arrow is
still in the world a second after the shot.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 17:53:22 +01:00
JoansitohandClaude Sonnet 5.5 f97f250f7e Arrows strike wind charges and turn them along the shooter's look
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 17:53:22 +01:00
[email protected]andClaude Sonnet 5.5 7abfbbea71 feat(mods): native block entities on any template
new_block_entity no longer needs the block's template to have a block entity.
The level asks the game whether a mod block entity lives at a position
(LevelAccess::has_mod_block_entity) to remove it with its block and to keep
pistons from moving it, and the renderer lists mod block entities the
template's flag leaves out.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 17:53:20 +01:00
Sticky-BugandAntigravity ddd0860591 Broadcast damage event when Ender Dragon is hurt (#5999)
Co-Authored-By: Antigravity <[email protected]>
2026-11-01 22:23:13 +05:30
Jose Gil d799220b1f feat(launcher): localize Servers tabs, action buttons, and layout fixes 2026-11-01 12:52:06 -04:00
some-du6eandClaude Opus 5.5 99e71addf8 Far terrain: queue 32 sample jobs per thread once the near terrain is in
Results are taken and jobs started once a frame. With four jobs in flight
and tiles taking milliseconds, the sampling threads sat idle for most of a
slow frame. Queued tiles the view leaves are dropped at the replan.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 10:50:54 -06:00
some-du6eandClaude Opus 5.5 71c2161c73 Far terrain: hold back only while the near terrain loads, not while it remeshes
On machines with four threads or fewer, far terrain samples one tile per
500 ms and meshes nothing while the near terrain loads. It asked
`Streaming::busy`, which counts every section being remeshed: block,
light and fluid updates near the player kept it held back long after the
near chunks were in. `Streaming::loading` counts chunks being generated,
decorated, finished or lit and sections meshing for the first time.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 10:50:53 -06:00
Ludovic Marie 58eaf93f13 Merge pull request #6086 from HrafnaValkyrja/fix/minecart-mob-rider
🤖 Agent: A cart with any passenger (mob, guest or the player) uses the occupied slowdown and speed scale (AbstractMinecart.getSlowdownFactor keys on isVehicle).
2026-11-01 17:50:16 +01:00
Ludovic Marie e076162fb3 Merge pull request #5811 from ASOwnerYT/fix/cauldron-bucket
🤖 Agent: Review by Ludovic's AI agent (Claude). A bucket used on a cauldron fills or empties the cauldron only, as vanilla's CauldronInteraction consumes the use; kacpherek found nothing more. Main merged in (lan_e2e check list: both sides kept, cauldron included). Verify green.
2026-11-01 17:50:11 +01:00
Ludovic Marie 9b391d7c80 Merge pull request #5645 from mariuslozo-boop/i18n/horizon-strings-c
🤖 Agent: #5644 merged in. pl_pl and uk_ua merged key by key with main's newer shader-option and split-profile strings (main's wording kept where both had one). All 9 lang files valid, no repeated keys, no key en_us lacks, placeholders match. On playhorizon: cargo check -p horizon-client --all-targets, lang/extras/options_ui tests pass. verify green on 8726619. Thanks @mariuslozo-boop.
2026-11-01 17:50:06 +01:00
Ludovic Marie eea324d489 Merge pull request #5644 from mariuslozo-boop/i18n/horizon-strings-b
🤖 Agent: #5643 (main merged) merged in; en_us.json union, valid JSON, no repeated keys.
2026-11-01 17:50:02 +01:00
Ludovic Marie 983eec0256 Merge pull request #5643 from mariuslozo-boop/i18n/horizon-strings-a
🤖 Agent: main merged again: its new Chat rows in Extras (Smooth chat, Message indicator and their header) read the language table too, en_us keys added. verify green on 79b92a3.
2026-11-01 17:49:58 +01:00
Ludovic Marie 88d3609268 Merge pull request #5708 from navinor/fix/attack-strength-on-attack
🤖 Agent: Review by Ludovic's AI agent (Claude). Attacking a boat, minecart or armor stand resets the attack strength, as Player.attack does for every entity. Main merged in; verify green.
2026-11-01 17:49:54 +01:00
Ludovic MarieandClaude Opus 5.5 d1bc26f7af Native mods: other players' inventories and drawn maps (fragment)
native_inventory(uuid, kind) reads a player's inventory (Player: 41 slots,
main + armour + off hand; Ender: 27), live when they are on the server,
else from players/data/<uuid>.dat; never while they log in, are dead or
swapped in. native_move_stack moves one stack, checked against what the
mod showed, where it fits whole, or nothing moves; at most one side is a
file, written before the live side changes. native_send_map sends a
128x128 map picture to a player. For the official server's /invsee,
/endersee and the market's price charts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:49:47 +01:00
Alexander KrylovandHermes Agent f3b1922606 Telemetry: the Windows version comes from the registry, not cmd /C ver
Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-11-01 19:48:15 +03:00
HrafnaValkyrjaandClaude Opus 5.5 ea7c0c4856 Lava and fire hit mobs on time, and wither roses wither them
Lava and fire now apply after the mob's timers count down, as vanilla's order has it. The wither rose checks world difficulty and invulnerability only.

Fixes #6099

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 11:47:48 -05:00
NazaandClaude Opus 5.5 38c0c6dbaf horizon-ctl: the command confirmation takes the keys
`key esc` went past it and closed the chat behind, unlike the game's own
key handling, where Escape answers no.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 13:45:38 -03:00
k3yhandClaude Opus 5.5 2b01586564 Selectors find primed TNT
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:44:44 -05:00
Federico TaglianiandClaude Sonnet 5.5 be35f0cd18 A world without singleplayer_uuid gives its owner a new player, not another player's file
Level::read took the newest file of players/data when level.dat had no singleplayer_uuid, so
a world copied from a server gave the owner someone else's inventory and position, and the
owner's save then wrote into that player's file and named them as the world's owner.
PlayerList.loadPlayerData has no such lookup: the owner loads their own file, else starts new.
The fallback is gone, and save_owner names the owner's own profile in singleplayer_uuid
(MinecraftServer.saveAllChunks writes the integrated server's profile).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 17:44:11 +01:00
Ludovic Marie 1c47bf7fde Merge pull request #6078 from Xmilon/container-viewers-openers
🤖 Agent: Mod container viewers count the host's own player and copper golems too (ContainerOpenersCounter counts any ContainerUser), every tick from tick_world; spectators excluded.
2026-11-01 17:43:13 +01:00
Ludovic Marie 3fa0d50e2c Merge pull request #6071 from Corbonat/fix/enchantment-levels-6030
🤖 Agent: Enchantment levels outside 1..=255 drop that entry and keep the rest (ItemEnchantments codec); levels past X keep their untranslated key unless a language names them.
2026-11-01 17:43:08 +01:00
Akirabane cb24175ff3 Merge remote-tracking branch 'origin/main' into fix/6066-6067-command-block-feedback 2026-11-01 17:42:21 +01:00
AkirabaneandClaude Sonnet 5.5 fdd8066b77 Command blocks: the output reaches the operators, the editor's confirmation reaches the editor
A command block's or minecart's `chat.type.admin` line went to the server game's own chat, so no
operator saw it; it now joins the broadcastToAdmins queue (to every operator when
send_command_feedback, the world's host too, the console when log_admin_commands). A command that
sends its success unbroadcast (`sendSuccess(.., false)`) no longer reaches the operators either.
The confirmation of Done on a command block or a command minecart (advMode.setCommand.*) is sent
to the editing player only, through as_guest_messaged.

Fixes #6066
Fixes #6067

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 17:41:14 +01:00
Ludovic Marie 07c41af9db Merge pull request #6055 from JustKarkat/fix/ime-text-input
🤖 Agent: IME: the options searches, game rule boxes and block editor fields allow the input method, and the chat TextField reports its caret so the composition and candidate window show (TextInputManager.setTextInputArea).
2026-11-01 17:40:00 +01:00
Ludovic Marie 2691d2eb82 Merge pull request #6081 from doodlyyousuf/fix/5837-armor-stand-powder-snow
🤖 Agent: Leather-boot armor stands walk on powder snow; main merged in (main already lerps stands, its interpolation kept); verify passes.
2026-11-01 17:39:57 +01:00
Ludovic Marie 3dc0154745 Merge pull request #5733 from Stink-O/native/bulk-block-edits
🤖 Agent: Review by Ludovic's AI agent (Claude). Bulk block edits write whole sections; a resent column keeps its unchanged sections, takes the packet's light (arismoko rechecked it in game) and remeshes biome-only changes with all neighbours (kacpherek's point, aec1d41). Main merged in. Verify green.
2026-11-01 17:39:53 +01:00
NazaandClaude Opus 5.5 ad5de29f1a Clicked commands ask first, as sendUnattendedCommand does
A command the player didn't type went straight to the server. Now
verifyCommand decides: a parse error or a restricted command asks before
it goes, and one with a message argument only reaches the chat box, or
the clipboard over a screen, for the player to send. Refs #6080.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 13:37:25 -03:00
HrafnaValkyrjaandClaude Opus 5.5 e5d3a0ec6c Mobs feel instant health and damage, Resistance, Absorption and Slow Falling
The mob effect tick and damage path never read the effect list. They now do, matching the real server.

Fixes #6087

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 11:37:06 -05:00
[email protected]andClaude Sonnet 5.5 962d50bffa feat(mods): native hooks to decide experience orb merging
native_experience_orb_can_merge and native_experience_orb_merge (cancellable)
at spawn and on the tick scan, plus native_set_experience_orb for value,
count and age.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 17:37:04 +01:00
huiandZCode AI agent (GLM-5.3) f7bff20187 Native mods: a loot table's function and condition of a mod's own kind
A loot table's function or condition that names a type in a namespace
outside minecraft: used to be silently skipped (functions) or failed
(conditions) at the roll. It now reaches free functions in loot.rs —
native_loot_function changes the stack, native_loot_condition says
whether it holds — which mixin bundles hook, as they do the game's own
hook points. The context brings the entity, the damage, the player who
killed it, the tool, the block, the luck, the biome and a seed from the
roll's random, drawn only when a mod's kind is in the table.

The tables the items engine rolls (block drops, entity drops,
loot_table, interact_loot) answer the condition hook with the tool and
block in the context; the function hook there follows when the stack
types converge. A bare type name is minecraft's own; an unclaimed kind
leaves the stack or fails the condition, as unknown kinds do.

Co-Authored-By: ZCode AI agent (GLM-5.3)
2026-11-01 17:36:51 +01:00
Ludovic Marie abbfe4996b Merge pull request #5990 from xFedeT/fix/overworld-dragon
🤖 Agent: A dragon summoned outside the End flies (EnderDragon.aiStep without a fight); its drawing came with #5789; main merged in.
2026-11-01 17:34:20 +01:00
Ludovic Marie 484f7f74bb Merge pull request #5822 from xFedeT/fix/fox-item-pickup
🤖 Agent: Review by Ludovic's AI agent (Claude). Fox and dolphin item pickup follows vanilla (canTakeItem/pickUpItem rules); server-side only, nothing on the frame path. Main merged in; verify green.
2026-11-01 17:34:13 +01:00
Ludovic Marie 59773fcece Merge pull request #5791 from kacpherek/fix/arrows-through-portals
🤖 Agent: Review by Ludovic's AI agent (Claude). Arrows travel through nether and End portals as the vanilla Entity.handlePortal path does. Main merged in; verify green.
2026-11-01 17:34:08 +01:00
Ludovic Marie 67797d1884 Merge pull request #6063 from oskal675-design/fix/piston-server-updates
🤖 Agent: /tick step broadcasts the command's count before the tick's block events, joins still get the remaining ticks (ServerTickRateManager.updateStepTicks / updateJoiningPlayer).
2026-11-01 17:32:50 +01:00
Ludovic Marie ca15b06a8c Merge pull request #6062 from doodlyyousuf/fix/5248-nixos-native-mod-build
🤖 Agent: On NixOS the native-mod build check names the launcher's shell.nix (now with udev and ALSA) instead of a package manager.
2026-11-01 17:32:44 +01:00
Ludovic Marie 856de4c13d Merge pull request #6016 from kacpherek/fix/gpu-device-loss
🤖 Agent: Device loss recovery; main merged in and the rebuilt renderer now gets the Translucent Batching arguments build_renderer takes since #5984 (the LAND-STOP).
2026-11-01 17:32:38 +01:00
Ludovic Marie dc262543a6 Merge pull request #6035 from mariuslozo-boop/tools/build-times-compare
🤖 Agent: tools/build_times.py compare, on top of #6034; tooling only, skip-release.
2026-11-01 17:32:32 +01:00
Ludovic Marie d2a39539be Merge pull request #6034 from mariuslozo-boop/tools/build-times
🤖 Agent: tools/build_times.py run: cold, no-op and incremental build times per crate in its own target folder, leaving the tree as it found it (tested); tooling only, skip-release.
2026-11-01 17:32:27 +01:00
Ludovic Marie 4684962b73 Merge pull request #6042 from oskal675-design/fix/piston-tick-step
🤖 Agent: A frozen level ticks no client block entities (Level.tickBlockEntities behind runsNormally): a /tick step advances a moving piston once; e2e against a vanilla server.
2026-11-01 17:32:21 +01:00
Ludovic Marie 85a65f9469 Merge pull request #6054 from mariuslozo-boop/fix/windows-cpu-name
🤖 Agent: Windows CPU brand from the registry (ProcessorNameString) for bench reports and telemetry, cached once, reg run with CREATE_NO_WINDOW; one reader shared with crash reports.
2026-11-01 17:32:16 +01:00
Ludovic Marie 7a5563f90f Merge pull request #6046 from doodlyyousuf/fix/tnt-bubble-column
🤖 Agent: Primed TNT runs applyEffectsFromBlocks' bubble column push after its move and before drag, as items and mobs already do; test with a soul sand column.
2026-11-01 17:32:09 +01:00
Ludovic Marie 810da289d6 Merge pull request #5981 from CayMac97/fix/png-ignore-checksums-smp-packs
🤖 Agent: PNG chunk checksums ignored as stb_image does, PostChain effect textures included (fix pushed for kacpherek's review).
2026-11-01 17:32:04 +01:00
Ludovic Marie ac29461e82 Merge pull request #5934 from doodlyyousuf/fix/5836-powder-snow-walk-particles
🤖 Agent: Powder snow snowflake now emitted on the server copy and sent to nearby players (PowderSnowBlock.entityInside, count 1, ALTERNATIVE randomization), the client copy no longer double-spawns; I merged main and dropped this branch's handleParticleEvent copy in favour of main's spread(); verify green.
2026-11-01 17:31:58 +01:00
Ludovic Marie 7e87aefe15 Merge pull request #5885 from doodlyyousuf/fix/5842-shulker-bullet-trail
🤖 Agent: Shulker bullet END_ROD trail on the client (ShulkerBullet.tick: two in the 0.3125 box), client visual only; I merged main (kept main's remote_projectile_power beside it), verify green on the merge.
2026-11-01 17:31:53 +01:00
Ludovic MarieandClaude Opus 5.5 1affe879c9 Merge main into #6081: main already lerps armor stands (keeps its interpolation, drops the remote_objects reset that would undo it); leather boots still walk on powder snow; tests pass BlockFacts to tick
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:26:27 +01:00
Ludovic MarieandClaude Opus 5.5 39737da855 Merge main into #5696 (entity culling modes)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:26:19 +01:00
HrafnaValkyrjaandClaude Opus 5.5 26dc0d815f Minecarts with a mob or guest aboard keep their speed
Only the local player counted as a rider, so other carts took the empty-cart drag. A guest or a mob passenger now counts too, as isVehicle does.

Fixes #6085

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 11:25:20 -05:00
Ludovic MarieandClaude Opus 5.5 1252c14927 Merge main into #5990 (#5789 already draws the dragon in every dimension; this keeps its flight outside the End)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:23:13 +01:00
Ludovic Marie 87e8e31222 Merge main into the server message methods
# Conflicts:
#	crates/horizon-client/src/game/dedicated/management.rs
#	crates/horizon-server/src/management_methods.rs
#	tools/test_management_protocol_parity.py
2026-11-01 17:21:34 +01:00
DoodlyyousufandGrok 4.6 3b68c85316 Walk leather-boot armor stands on powder snow and lerp their falls
Co-Authored-By: Grok 4.6 <[email protected]>
2026-11-01 16:20:28 +00:00
Ludovic Marie f6512a3f49 Merge pull request #5637 from mariuslozo-boop/feat/native-knockback-hook
🤖 Agent: verify and parity are green on levzzz5154's main merge, levzzz5154 approved it, and arismoko's in-game sample showed the hook changing and canceling the push. Queued for merge, thanks @mariuslozo-boop.
2026-11-01 17:20:15 +01:00
Ludovic Marie bc59771299 Merge #5735 into #5737
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/game/block_edits.rs
2026-11-01 17:20:05 +01:00
Ludovic MarieandClaude Opus 5.5 65ef1000fa Native bulk edits: each new block gets its own block entity
The constructor ran once per block type and every position took a clone, so a
mod's per-instance state (a serial, a seed) was shared. Vanilla calls
EntityBlock.newBlockEntity for each position; a block that makes none is still
asked once (arismoko's review).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:19:43 +01:00
Ludovic MarieandClaude Opus 5.5 ff91172d22 Merge #5733 into #5735
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:18:07 +01:00
Ludovic MarieandClaude Opus 5.5 aec1d411f7 Streaming: a resent column's new biomes remesh the sections above and below too
A block's tint can sample the biome quart of the section next to it, so a
biome-only resend of one section must remesh its vertical neighbours, as a
light update does (kacpherek's review).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:18:00 +01:00
Ludovic Marie f4d7d4d59d Merge pull request #5928 from Teiwazik/shader/vertex
🤖 Agent: shader API stack with main c9d474d merged in: fmt, clippy -D warnings, client-render + shader-api tests green at 54cbbadbb.
2026-11-01 17:17:33 +01:00
Ludovic Marie 0aa6a87d5e Merge pull request #5920 from Teiwazik/shader/material-blocks
🤖 Agent: shader API stack with main c9d474d merged in: fmt, clippy -D warnings, client-render + shader-api tests green at 54cbbadbb.
2026-11-01 17:17:28 +01:00
[email protected]andClaude Sonnet 5.5 74a8a866f8 fix(mods): count the host's own player and golems as container viewers
The count now runs in tick_world, so it also runs in-frame and without LAN.

Fixes #6077

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 17:16:10 +01:00
Ludovic Marie 45e95666d2 Merge main into #5811
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	tools/lan_e2e.py
2026-11-01 17:15:17 +01:00
ErikKalikasandClaude Opus 5.5 fbe0014279 Mod block entities: a revision instead of comparing kept geometry's entity
`ModBlockEntity` keeps its fields (`ModBlockEntityFields`) behind
`Deref`/`DerefMut`, and each mutable access counts its revision on.
A new or cloned entity takes a fresh one, so one replaced in place never
matches. `KeptGeometry` keeps the revision it drew instead of a clone of
the entity and compares that each frame, O(1) whatever the entity holds.
The client merges an update tag field by field, leaving the revision
alone when the server sends the same tag again. Equality, `Debug` and
the save format are the fields' alone.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:14:35 +02:00
Ludovic MarieandClaude Opus 5.5 f6d35e00e2 Merge #5735 (main merged) into #5737
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:13:48 +01:00
Ludovic MarieandClaude Opus 5.5 75e2c36a9d Merge #5733 (main merged) into #5735
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:13:41 +01:00
Ludovic Marie 74404c28fc Merge main into #5733
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/lib.rs
2026-11-01 17:13:40 +01:00
Ludovic Marie fcbfd000ee Merge pull request #5790 from kacpherek/fix/glider-any-slot
🤖 Agent: Review by Ludovic's AI agent (Claude). Dispensing an elytra onto a mob now picks the slot with the dispenser's own random, as vanilla's Util.getRandom does; the level random was used before (fixed on the branch). The earlier parity failure was command_exec (failing on main since #5646, experience orb case) plus lunge_motion (a known flake), neither touched here. Verify green on this head.
2026-11-01 17:12:57 +01:00
Ludovic Marie 6bfd219e30 Merge pull request #6059 from oskal675-design/fix/boat-ice-reboard-spin
🤖 Agent: Boat copy the local player does not drive drops its cached velocity and spin (AbstractBoat.tick); main merged in again (lan_e2e list).
2026-11-01 17:12:53 +01:00
Ludovic Marie 79e9d1b044 Merge pull request #6075 from xFedeT/fix/tnt-portal-travel
🤖 Agent: Primed TNT goes through nether portals like other entities (Entity.handlePortal), saved with its fuse and portal cooldown.
2026-11-01 17:12:49 +01:00
Ludovic Marie d6ce584b6a Merge pull request #6070 from HrafnaValkyrja/fix/explosions-push-all
🤖 Agent: Explosions break and push boats and push falling blocks (VehicleEntity.hurtServer, pushFromExplosion); a mob's blast spares vehicles with mob griefing off; wind charges take no push.
2026-11-01 17:12:45 +01:00
breachatoandClaude Opus 5.5 19dfc97138 A player trampling turtle eggs plays their break sound and particles
TurtleEggBlock.decreaseEggs plays block.turtle_egg.break at the block and
sends level event 2001 for the eggs left; the last egg goes through
Level.destroyBlock, which sends 2001 too. The player's landing events reached
the game, but its block sounds were dropped (only the two fall sounds were
played) and its level events ignored, so a trampled egg vanished silently.
Block sounds (the egg, a shelf mushroom's bounce) now play at the block for
the local player and LAN guests, and the level events go out.

Part of #5858 (the player trampling eggs).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:11:49 +01:00
Ludovic MarieandClaude Opus 5.5 516927b342 Merge main into #6059 (lan_e2e: boat_ice_reboard beside piglin_offspring)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:11:22 +01:00
Ludovic Marie 21936c6298 Merge pull request #6045 from HrafnaValkyrja/fix/wind-pushes-all
🤖 Agent: Wind bursts push orbs, falling blocks, boats and minecarts as vanilla; main merged in again (lan_e2e beside lightning-box).
2026-11-01 17:11:06 +01:00
Ludovic Marie 6d586e2b23 Merge pull request #5800 from 1038april/feat/ram-limit-game
🤖 Agent: Review by Ludovic's AI agent (Claude). Opt-in RAM limit; off, it costs one atomic store per frame. kacpherek's grace-period point is fixed and tested. I merged main again (ram_limit next to random_textures, render_distance_in_use next to the leaf-culling remesh). Verify green on this head.
2026-11-01 17:11:02 +01:00
Ludovic Marie 7101e9fd45 Merge pull request #6033 from elythepreciouscat/feat/async-entity-culling
🤖 Agent: Asynchronous entity culling mode, off by default; the main merge now builds (EntityCulling arm closed, one field in round_trips).
2026-11-01 17:10:58 +01:00
Ludovic MarieandClaude Opus 5.5 87266190f4 Merge #5644 (main merged) into #5645: main's Polish and Ukrainian shader-option strings kept
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:09:14 +01:00
Ludovic MarieandClaude Opus 5.5 f4283f7c82 Merge main into #6045 (lan_e2e: wind-bystanders beside lightning-box)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:08:55 +01:00
Ludovic MarieandClaude Opus 5.5 75efcb360a Merge #5643 (main merged) into #5644
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:08:50 +01:00
Federico TaglianiandClaude Sonnet 5.5 c25499d504 Primed TNT goes through nether portals
Entity.handlePortal applies to any entity that canUsePortal; primed TNT was not in the list of
what Horizon sends through (items, mobs, boats, minecarts). It now is: swept through the
portal box (0.98 across), saved as its tag (fuse, motion), and loaded at the exit with its
PortalCooldown, which the TNT loader now remembers so it does not go straight back. A TNT that
carries a mob stays.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 17:08:44 +01:00
Ludovic Marie 54cbbadbb7 Merge #5920's branch (main after #5900) into shader/vertex 2026-11-01 17:08:38 +01:00
Ludovic Marie 6472b1af4c Merge main into shader/material-blocks (after #5900) 2026-11-01 17:08:38 +01:00
Ludovic MarieandClaude Opus 5.5 79b92a3f8f Merge main into #5643: the new Chat rows read the language table
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:08:35 +01:00
Ludovic Marie c9d474dd91 Merge pull request #6053 from HrafnaValkyrja/fix/lightning-hits-all
🤖 Agent: Lightning tests every entity's box and destroys boats, carts and orbs as vanilla; main merged in (lan_e2e).
2026-11-01 17:07:56 +01:00
Ludovic Marie 308ef65272 Merge pull request #6064 from xFedeT/fix/tnt-minecart-collision
🤖 Agent: A minecart's move collides with the entities AbstractMinecart.canCollideWith accepts, so a TNT minecart explodes on another cart; costs nothing without carts.
2026-11-01 17:03:24 +01:00
Ludovic MarieandClaude Opus 5.5 3073eed985 Merge main into #5696: frame generation beside the submit thread
Frames with frame generation are submitted in place (its present thread
presents them), and the hand stays out of the world pass so it draws over
the real and the made frame.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:02:51 +01:00
HrafnaValkyrjaandClaude Opus 5.5 9a696bd74b Explosions push boats and falling blocks, not wind charges
Boats and falling blocks take the blast's push as vanilla's hurtEntities does. Wind charges no longer do, as in vanilla.

Fixes #6069

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 11:02:12 -05:00
Ludovic Marie 5e59e2f4ff Merge pull request #5900 from Teiwazik/shader/surface
🤖 Agent: shader API stack after main's #6005 and #5926: fmt, clippy -D warnings, client-render + shader-api tests green on the stack's last head 31d53c8b4; offscreen pack proof earlier on the same features.
2026-11-01 17:01:06 +01:00
ElyTheCatandOpenAI Codex 5472e113b2 Fix graphics culling merge errors
Remove the stale boolean assignment from the graphics round-trip fixture and close the Entity Culling UI match arm.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-01 19:28:46 +03:30
Ludovic MarieandClaude Opus 5.5 a4742538b2 Merge main into #5790
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:58:21 +01:00
Ludovic Marie f122a023af Merge pull request #6065 from horizon-rust/perf/world-prepare-overlap
🤖 Agent: measured on playhorizon (alternated, THP off, 2 series): Done −144 / −157 ms median, 11/11 starts faster; idle +1.2-2.5 MiB; 20-bot MSPT unchanged (5 rounds). pr_ready ✅ (1691 horizon-client tests); clippy -D warnings and fmt clean on the Mac.
2026-11-01 16:58:11 +01:00
Ludovic Marie 4bcfd21e9a Merge main into native item use tick and release
# Conflicts:
#	crates/horizon-client/src/game/remote.rs
2026-11-01 16:57:58 +01:00
Ludovic Marie df47724728 Merge pull request #5801 from 1038april/feat/ram-limit-launcher
🤖 Agent: Review by Ludovic's AI agent (Claude). Maximum memory row; default changed to no limit so big worlds and modpacks are never capped unasked. Launcher verify green on this head.
2026-11-01 16:57:39 +01:00
Ludovic Marie 1660c823e8 Merge pull request #5771 from masa-masa-0314/fix/shader-options-i18n
🤖 Agent: Review by Ludovic's AI agent (Claude). Translations of six existing Horizon strings in six languages; keys match en_us. I merged main; verify green.
2026-11-01 16:57:02 +01:00
Ludovic Marie 88ab49d369 Merge pull request #5717 from hasanbkaylan/patch-1
🤖 Agent: Review by Ludovic's AI agent (Claude). Turkish launcher strings only; checked the six fixes (typos, a stray Georgian letter). Verify green.
2026-11-01 16:56:53 +01:00
Ludovic MarieandClaude Opus 5.5 5501103883 Bind each feature block tag the first time it is read
FeatureBlockBindings::bind_tags built every block tag's state set up
front: vanilla's 300 block tags hold 406,779 states (blocks_motion and
mineable/pickaxe ~30,000 each), and world generation reads a few dozen of
them. BoundTags keeps each tag's block names and builds its set the first
time it is read (OnceLock); the blocks are still checked when the tags are
bound, so an unknown block fails as before and a later read cannot.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:56:35 +01:00
Ludovic MarieandClaude Opus 5.5 c2ea13fc7c Merge main into #6045 (lan_e2e: wind-bystanders beside boat_submerged)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:55:47 +01:00
Ludovic Marie 1f629b560d Merge main into #5800
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/game.rs
2026-11-01 16:55:26 +01:00
Ludovic MarieandClaude Opus 5.5 fee0fee89f Merge main into #6053 (lan_e2e: lightning-box beside boat_submerged)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:54:42 +01:00
Ludovic Marie d1e32b4cae Merge pull request #6049 from Xmilon/container-open-hook
🤖 Agent: Mod container viewer counts; new head: a spectator is not a viewer (BarrelBlockEntity.startOpen).
2026-11-01 16:54:08 +01:00
Ludovic Marie ceec693555 Merge pull request #6019 from Xmilon/native/screen-mouse-dragged
🤖 Agent: Screen mouse drag pre event; new head: any button release ends the drag as MouseHandler.onButton clears activeButton, main merged.
2026-11-01 16:54:02 +01:00
Ludovic Marie e42d07a271 Merge pull request #6060 from pikammmmm/fix/lazy-chunk-datafix
🤖 Agent: Older chunk, entity and POI records of a current world upgrade as they are read (SimpleRegionStorage.upgradeChunkTag), reusing the ported datafixes; old_chunk_bits still reads the raw record.
2026-11-01 16:53:58 +01:00
Ludovic Marie 16a40a720a Merge pull request #5918 from loicpiller/fix/5867-piglin-bartering-offhand
🤖 Agent: Baby piglin equipment and gold pickups as vanilla; main merged in again (lan_e2e: piglin_offspring beside blast-projectiles).
2026-11-01 16:53:52 +01:00
Ludovic Marie b6f5c163de Merge pull request #6037 from horizon-rust/fix/ac-sprint-restart
🤖 Agent: reviewed: the record takes the client's own START/STOP (vanilla: causeExtraKnockback stops the server's sprint, LocalPlayer.aiStep restarts it silently with the key held); a sprint kept without the key or a +0.05 speed still counts (tests); Horizon's toggle sprint reports the key as held like vanilla's ToggleKeyMapping. e2e: honest cow-hit sprint never flagged (8 flags before), cheat bot 13; pr_ready --quick green (1713 tests).
2026-11-01 16:53:46 +01:00
Ludovic Marie 1a575997d6 Merge pull request #5952 from xFedeT/fix/dedicated-crash-save
🤖 Agent: A panic in the dedicated server's tick (console, control socket and players' turns included) still saves the world; crash fixture gated so the tick pays one OnceLock load (fix pushed).
2026-11-01 16:53:41 +01:00
Ludovic Marie b42d248a0a Merge pull request #6029 from oskal675-design/fix/submerged-boat-ejection
🤖 Agent: Submerged boats eject their player after 60 underwater ticks even when the client drives the boat (AbstractBoat.tick on the server), the request kept until every rider left; harness against a vanilla server; main merged in (lan_e2e).
2026-11-01 16:53:35 +01:00
Ludovic Marie fba4b16003 Merge pull request #6043 from xFedeT/feat/social-interactions
🤖 Agent: Social Interactions screen (P, pause icon): hide a player's chat locally as Minecraft.isBlocked in ChatListener.showMessageToPlayer, vanilla layout and sorting; no Mojang services touched.
2026-11-01 16:53:30 +01:00
Ludovic Marie 8863f5a229 Merge pull request #5779 from rdbtCVS/perf/encoder-finish
🤖 Agent: Review by Ludovic's AI agent (Claude). Measured on macOS (M4 Pro, Metal, same binary A/B): with the thread on, 44–293 FPS with 20–180 ms p99 frames, against 296–549 FPS off. So I made it the default on Linux only, where it measured +32%, and opt-in elsewhere (HORIZON_SUBMIT_THREAD=1). Merged main (OIT conflict; the hand still draws after the composite). Verify green.
2026-11-01 16:53:25 +01:00
Ludovic Marie 5a11614d8e Merge pull request #6026 from oskal675-design/fix/inventory-slot-hover
🤖 Agent: Creative grid and hotbar hover use the padded slot target (AbstractContainerScreen.getHoveredSlot) for highlight and tooltip, as clicks already did; oracle-backed e2e.
2026-11-01 16:53:21 +01:00
Ludovic Marie 74a2fddd2f Merge pull request #5932 from oskal675-design/fix/armor-stand-frost-walker
🤖 Agent: Frost Walker's location effect runs for armor stands as vanilla's LivingEntity.tick; reviewed, verify green.
2026-11-01 16:53:16 +01:00
Federico TaglianiandClaude Sonnet 5.5 09bb7254ae A minecart runs into other carts, boats, mobs and players, so a TNT minecart sets off on them
Entity.collide takes the entities AbstractMinecart.canCollideWith (AbstractBoat.canVehicleCollide:
canBeCollidedWith or isPushable, but not a passenger of the same vehicle). Horizon's cart move
only saw blocks, so two carts never had a horizontal collision and a TNT minecart never exploded
on another one.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 16:51:45 +01:00
Ludovic MarieandClaude Opus 5.5 52da60975d Merge main into #6059 (lan_e2e: boat_ice_reboard beside blast-projectiles)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:50:26 +01:00
ElyTheCat 4a9fc00315 Merge branch 'main' into feat/async-entity-culling 2026-11-01 19:19:58 +03:30
Ludovic Marie 31d53c8b47 Merge #5920's branch (main after #5926) into shader/vertex 2026-11-01 16:49:03 +01:00
Ludovic Marie b0fee0d30d Merge main into shader/material-blocks (after #5926)
# Conflicts:
#	crates/horizon-client-render/src/mesher.rs
#	crates/horizon-client-render/src/mesher/reuse_tests.rs
#	crates/horizon-client-render/src/streaming.rs
#	crates/horizon-client/examples/chunk_load_bench.rs
#	crates/horizon-client/examples/probe/section_mesh_fixture.rs
#	crates/horizon-client/tests/streaming_meshes.rs
#	crates/horizon-client/tests/tall_column.rs
2026-11-01 16:49:02 +01:00
Ludovic Marie 183b53f0eb Merge pull request #6057 from HrafnaValkyrja/fix/mob-fall-on-blocks
🤖 Agent: Mobs run each block's fallOn through the landing registry (hay, honey, slime, powder snow, beds, dripstone...), the farmland roll on the mob's random as before; powder snow's entityInside forgets the fall.
2026-11-01 16:46:28 +01:00
Ludovic Marie 66e12814bd Merge pull request #6051 from HrafnaValkyrja/fix/projectile-land-event
🤖 Agent: PROJECTILE_LAND for thrown tridents, potions and fishing hooks (Projectile.onHit), so sculk sensors hear them; lan_e2e projectile_land check.
2026-11-01 16:46:24 +01:00
Ludovic Marie 7ba3d7ade7 Merge pull request #6061 from horizon-rust/perf/lazy-advancement-criteria
🤖 Agent: measured on playhorizon (alternated, THP off): idle anonymous −4.1 MB against #6041 on every start; Done and 20-bot MSPT unchanged. pr_ready ✅ (2099 tests + 21 content root); clippy -D warnings and fmt clean on the Mac.
2026-11-01 16:46:20 +01:00
Ludovic Marie 2801c8ac63 Merge pull request #6027 from Xmilon/native/mouse-scrolling
🤖 Agent: Two-axis gameplay scroll hook (fragment): fires on whole accumulated steps behind the existing gameplay gate; no-op without a mod.
2026-11-01 16:46:16 +01:00
Ludovic Marie 375fa93fe6 Merge pull request #6023 from Xmilon/native/input-key-events
🤖 Agent: Window keyboard input hook (fragment): the original winit KeyEvent after handling, real window events only; no-op without a mod.
2026-11-01 16:46:12 +01:00
Ludovic Marie 0796fa9963 Merge pull request #6058 from 4404c/docs/chat-validation-note
🤖 Agent: Docs only: microsoft-account.md now says other players' chat is validated (#5149); skip-release.
2026-11-01 16:46:06 +01:00
breachatoandClaude Opus 5.5 70c7040530 Any equipped item with glider and a matching equippable slot glides
LivingEntity.canGlide checks every equipment slot with canGlideUsing: the
stack has glider, its equippable slot is the one it is in, and its next damage
won't break it. Horizon only looked at the chest slot and the item's own
components, so a chestplate a server (or /give) gave glider and an equippable
couldn't glide. The client and the LAN host's replay now share one check that
reads the saved components and the server's patch, which can add or remove
either one; the patch's equippable isn't kept otherwise, so its slot is read
from the patch.

Fixes #5773

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:46:04 +01:00
Ludovic Marie f27399371d Merge pull request #6031 from Sticky-Bug/feat/flower-pot-hand-animation
🤖 Agent: A CONSUME use (empty flower pot with nothing to plant) no longer swings the arm: InteractionResult.shouldSwing is SUCCESS only, in use_hand and the remote prediction; level test covers it.
2026-11-01 16:46:01 +01:00
Ludovic Marie b938147a8e Merge pull request #5749 from some-du6e/optifine/cit-match
🤖 Agent: OptiFine series step verified (licence gate, gated to packs shipping the files, per-mob cost measured, verify green on 212746390). Thanks @some-du6e.
2026-11-01 16:45:57 +01:00
Ludovic Marie 8220ea6c65 Merge pull request #5712 from some-du6e/optifine/cit-rules
🤖 Agent: OptiFine series step verified (licence gate, gated to packs shipping the files, per-mob cost measured, verify green on e3ab4bd42). Thanks @some-du6e.
2026-11-01 16:45:53 +01:00
Ludovic Marie 7e05e90750 Merge pull request #5746 from some-du6e/optifine/ctm-mesh
🤖 Agent: OptiFine series step verified (licence gate, gated to packs shipping the files, per-mob cost measured, verify green on 936795944). Thanks @some-du6e.
2026-11-01 16:45:49 +01:00
Ludovic Marie 2bcace83be Merge pull request #5718 from some-du6e/optifine/ctm-connect
🤖 Agent: OptiFine series step verified (licence gate, gated to packs shipping the files, per-mob cost measured, verify green on 43b76c4a7). Thanks @some-du6e.
2026-11-01 16:45:45 +01:00
Ludovic Marie dde3ae3ce9 Merge pull request #5949 from Sylviromi/recipe-sync
🤖 Agent: Recipe sync hooks: arismoko's three points handled by the author (empty load resets clients, last part within its cap, per-load docs); main merged in by the author.
2026-11-01 16:45:41 +01:00
Ludovic Marie c7b4af6824 Merge pull request #5937 from xFedeT/fix/happy-ghast
🤖 Agent: requeued: kacpherek's review asked about per-tick cost; answered in the thread (a handful of obstacles, AABB tests only).
2026-11-01 16:45:37 +01:00
Ludovic Marie db90618377 Merge pull request #5998 from xFedeT/chore/port-map-triggers
🤖 Agent: Port map only: 16 advancement-trigger rows point at the triggers that fire on main; skip-release.
2026-11-01 16:45:33 +01:00
Ludovic Marie cb51db13a2 Merge pull request #5913 from xFedeT/fix/frog-animations
🤖 Agent: Frog eating a magma cube: drawn in and removed, and with the fix pushed here (mob_died runs on RemoveKilled) the froglight and XP drop as vanilla.
2026-11-01 16:45:27 +01:00
Ludovic MarieandClaude Opus 5.5 b84758065f Merge main into #5696: Frame Generation beside main's Leaf Culling setting
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:43:52 +01:00
CorbonatandCodex gpt-6.1-sol ac5f51226b Fix persistent enchantment level decoding and names
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-01 18:42:25 +03:00
doodlyyousufandhorizon-agent 07002b127b docs(launcher): say the NixOS shell covers native mod builds
Co-Authored-By: horizon-agent (GLM 5.2) <[email protected]>
2026-11-01 15:41:57 +00:00
doodlyyousufandhorizon-agent 79e783ec84 fix(launcher): name the NixOS shell instead of a package manager
On NixOS pkg-config only sees packages listed by a shell's setup hooks, so
installing them in environment.systemPackages leaves the check failing, and
the APT advice it fell through to did not apply. Point at the launcher's own
shell.nix, which now carries those packages.

Co-Authored-By: horizon-agent (GLM 5.2) <[email protected]>
2026-11-01 15:41:57 +00:00
oskal675-designandCodex GPT-6.1 58a3d74122 Preserve tick-step requests and send them before piston events
Co-Authored-By: Codex GPT-6.1 <[email protected]>
2026-11-01 20:41:54 +05:00
doodlyyousufandhorizon-agent 6b1f5be9ec fix(launcher): give the NixOS shell the ALSA and libudev files native mods need
The shell provided GPUI's libraries but not the two native mods' build looks up
with pkg-config, and the client it produces needs libudev at runtime.

Fixes #5248

Co-Authored-By: horizon-agent (GLM 5.2) <[email protected]>
2026-11-01 15:41:50 +00:00
[email protected]andClaude Opus 5.5 1ffa50bf87 Merge upstream/main and end a drag on any button release
Review on #6019: vanilla's MouseHandler.onButton clears activeButton on any
release, so left down, right down, left up sends no drag while right is still
held. track_drag_button now does the same and the test pins vanilla's
behaviour.

The merge keeps both blocks in game.rs: the drag button tracking and the
screen key release hook (#6014).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:41:45 +01:00
[email protected]andClaude Sonnet 5.5 4dccd4cf01 fix(mods): a spectator does not count as a container viewer
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 16:41:36 +01:00
Ludovic MarieandClaude Opus 5.5 9beedd8463 Merge main into the piglin branch (lan_e2e: piglin_offspring beside blast-projectiles)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:41:22 +01:00
HrafnaValkyrjaandClaude Opus 5.5 5c9bc076ac Merge origin/main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 10:40:28 -05:00
Ludovic MarieandClaude Opus 5.5 010239ced7 Crash fixture: scan the guests only when HORIZON_CRASH_FIXTURE names the point
as_guest runs for every guest action; the fixture's guest scan now waits
behind the OnceLock check, so a normal tick pays one load.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:39:06 +01:00
Ludovic Marie 690f4ca210 Merge pull request #5711 from some-du6e/optifine/ctm-rules
🤖 Agent: OptiFine series step verified (licence gate, gated to packs shipping the files, per-mob cost measured, verify green on 8881fc76f). Thanks @some-du6e.
2026-11-01 16:37:35 +01:00
Ludovic Marie 48798e24fb Merge pull request #5941 from HrafnaValkyrja/fix/explosions-push-projectiles
🤖 Agent: Explosions push projectiles, now thrown tridents too (fix pushed for kacpherek's review).
2026-11-01 16:37:30 +01:00
Ludovic Marie 2220e41e14 Merge pull request #5744 from some-du6e/optifine/etf-game
🤖 Agent: OptiFine series step verified (licence gate, gated to packs shipping the files, per-mob cost measured, verify green on a4bda5e9e). Thanks @some-du6e.
2026-11-01 16:37:25 +01:00
Ludovic Marie 6009612677 Merge pull request #5926 from elythepreciouscat/feat/leaf-culling
🤖 Agent: Leaf culling: the default stays vanilla's, server packs now turn it off like local packs (author's cf2c59e1b); main merged in (leafCulling beside translucentBatching).
2026-11-01 16:37:20 +01:00
Ludovic Marie a5093dae37 Merge pull request #5716 from some-du6e/optifine/etf-pack
🤖 Agent: OptiFine series step verified (licence gate, gated to packs shipping the files, per-mob cost measured, verify green on 19996b09c). Thanks @some-du6e.
2026-11-01 16:37:14 +01:00
Ludovic Marie aa33a8303c Merge pull request #5898 from Vaspyyy/feat/native-music
🤖 Agent: native_music hooks: main merged in, camera_distance kept and shader_params keeps its own doc.
2026-11-01 16:37:08 +01:00
Ludovic Marie f3548dd3b3 Merge pull request #5892 from Domanffe/feat/chat-animation
🤖 Agent: Smooth chat (off by default): main merged in, extras.rs keeps hideBlockParticles beside chatAnimation/chatIndicator.
2026-11-01 16:37:01 +01:00
Ludovic Marie dedc3bbcfb Merge pull request #5710 from some-du6e/optifine/etf-rules
🤖 Agent: OptiFine series step verified (licence gate, gated to packs shipping the files, per-mob cost measured, verify green on f2b47b87b). Thanks @some-du6e.
2026-11-01 16:36:56 +01:00
Ludovic Marie 4c9e871c0f Merge pull request #5665 from some-du6e/optifine/emf-animations
🤖 Agent: OptiFine series step verified (licence gate, gated to packs shipping the files, per-mob cost measured, verify green on 84bf62028). Thanks @some-du6e.
2026-11-01 16:36:50 +01:00
Ludovic Marie 3a5046218a Merge pull request #6014 from Xmilon/native/screen-key-released
🤖 Agent: Screen key release pre hook: the new head only isolates its test from the adjacent input hooks; no-op without a mod.
2026-11-01 16:36:42 +01:00
Ludovic Marie 653af80c4a Merge pull request #6003 from Xmilon/native/render-fog
🤖 Agent: native_render_fog hook: main merged by the author, frame_fog runs native_compute_fog_color then native_render_fog (NeoForge's ComputeFogColor then RenderFog); no-op without a mod.
2026-11-01 16:36:34 +01:00
Ludovic Marie e7f49e3695 Merge pull request #5925 from finalpantasy/fix/launcher-native-mod-refresh
🤖 Agent: requeued: kacpherek's review found no issue.
2026-11-01 16:36:27 +01:00
Ludovic Marie f49c54ba7f Merge pull request #5942 from oskal675-design/fix/spawn-eggs-on-source-fluids
🤖 Agent: requeued: parity failed only lunge_motion, a known flaky harness on the loaded Mac mini runner that this PR doesn't touch; rerunning.
2026-11-01 16:36:21 +01:00
Ludovic Marie 23fb690cff Merge pull request #5756 from finalpantasy/fix/native-sibling-lock-dependencies
🤖 Agent: Review by Ludovic's AI agent (Claude). kacpherek's review found nothing to change. The sibling-path check accepts only `../<id>` manifests of the same staged mod, and I added the underscore dependency tables, with a test. Launcher verify green.
2026-11-01 16:36:15 +01:00
Ludovic MarieandClaude Opus 5.5 bf890dc48e Merge main into lightning boxes (lan_e2e: lightning-box beside shulker_lift)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:36:11 +01:00
Ludovic Marie 47547980e4 Merge pull request #5731 from Stink-O/native/claimed-items
🤖 Agent: Review by Ludovic's AI agent (Claude). levzzz5154's review and his off-hand fix are in; uses an API fragment as docs/native-mods.md asks. I merged main (clean); verify green on this head.
2026-11-01 16:36:09 +01:00
Federico TaglianiandClaude Sonnet 5.5 8eff24080b Dedicated server: test that a panic in a player's turn or with the hub out saves the world and no player file
HORIZON_CRASH_FIXTURE=guest|hub panics the tick during a placed guest's turn or in a console
line while a player is in. tools/test_dedicated_crash_save.py joins a real client, crashes the
server, and checks the player's files are byte for byte what the last clean stop wrote and a
block placed before the panic is there on the next start. The console lines and the control
socket now run under the same catch_unwind as the tick: a panic there ended the process with
no save.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 16:34:52 +01:00
breachatoandClaude Opus 5.5 d07fd00f94 Containers still check the off hand key after Tab moves the focus
Screen.keyPressed moves the focus on Tab or an arrow and returns false, so
AbstractContainerScreen.keyPressed goes on to checkHotbarKeyPressed: with
key.swapOffhand bound to Tab, vanilla both focuses the recipe book button and
swaps the hovered slot with the off hand. Horizon's key focus kept the key,
so the inventory never saw it. In container screens, Tab and the arrows now
move the focus and pass the key on.

Fixes #6028

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:33:39 +01:00
Ludovic MarieandClaude Opus 5.5 9e58983bb7 Merge main into submerged boats (lan_e2e: boat_submerged beside shulker_lift)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:33:24 +01:00
Ludovic MarieandClaude Opus 5.5 e37e2f163f Device recovery: pass translucent batching to the rebuilt renderer
build_renderer takes the Translucent Batching setting and the picked pack's
flat drawing since #5984.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:31:23 +01:00
Ludovic Marie b773fa45c1 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-01 16:31:05 +01:00
TeiwazikandClaude Opus 5.5 51643e956a Merge the branch below, with main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 16:28:04 +01:00
HrafnaValkyrjaandClaude Opus 5.5 2b6ceab8a8 Mobs follow each block's landing rule
Mobs took the whole fall on every block. They now use the block's own fallOn behaviour, and powder snow makes them forget the fall.

Fixes #6056

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 10:25:26 -05:00
NazaandClaude Opus 5.5 6c577dcf69 Docs: other players' chat is validated since #5149
The chat signing section still said their signatures weren't verified.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 12:25:24 -03:00
ElyTheCat d66271131c Merge branch 'main' into feat/leaf-culling 2026-11-01 18:55:12 +03:30
Ludovic MarieandClaude Opus 5.5 d9ad8b2601 Merge main into native music (camera_distance hook kept; shader_params keeps its own doc)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:24:11 +01:00
JustKarkatandJustKarkat's AI agent c481ffedf2 IME: the chat box, the options searches and the remaining boxes take composed text
#4347 let the input method in, but Game::text_field_focused left four fields
out of the window's gate, so the OS was never asked for an input method while
they had the focus: the language list's search, the settings search, the
resource pack screen's search and a game master block's boxes. The title
screens' game-rule boxes were out too: focused_field is None while the rules
screen is open, and has_caret_field asks it.

The chat box — where the report typed — is a screen_draw::TextField, and that
box never reported its caret, so flush_ime had no area: the composition was
not drawn and set_ime_cursor_area was never called, which leaves typing blind
until the commit even with #4347 merged. TextField now reports the caret where
it draws the cursor, as EditBox and MultiLineEditBox already do.

Co-Authored-By: JustKarkat's AI agent (ZCode) <[email protected]>
2026-11-01 09:24:06 -06:00
Ludovic MarieandClaude Opus 5.5 295f6b408e Management protocol: a MOTD set is stored in server.properties, as DedicatedServer.setMotd
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:24:02 +01:00
Ludovic MarieandClaude Opus 5.5 83927e425b Merge main into chat-animation (extras: hideBlockParticles beside the chat extras)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:23:13 +01:00
oskal675-designandCodex GPT-6 326c36879b Clear cached boat motion when the client stops driving
Match AbstractBoat.tick by resetting velocity and angular velocity on non-authoritative client copies. Cover dismount/reboard ownership transitions with a real vanilla tick oracle and the actual headless Game ticker, plus ice reboarding for host and guest.

Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-01 20:22:46 +05:00
Ludovic MarieandClaude Opus 5.5 8bde063ed6 Effect textures ignore PNG chunk checksums too
PostChain's textures/effect inputs had their own png::Decoder with CRC
checks on (kacpherek's review).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:22:40 +01:00
HrafnaValkyrjaandClaude Opus 5.5 5b14688cb4 Lightning hits boats, minecarts and orbs, by box
The bolt tested entity feet and a short list of kinds. It now tests each entity's box, as LightningBolt.tick does, and destroys boats, minecarts and orbs.

Fixes #6052

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 10:22:02 -05:00
Ludovic MarieandClaude Opus 5.5 672fe27345 Merge main into #5800; a RAM cap turned off or changed gets its whole grace again
Resolves far_terrain_choice with main's server far terrain (#lod_net): the RAM
limit's far_off still wins. kacpherek's review: the watcher kept the Over timer
while the cap was off, so turning it back on while over could end the game at
once. Over now follows the cap and restarts its grace when it changes; tested.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:21:44 +01:00
HrafnaValkyrjaandClaude Opus 5.5 131c146a59 Sculk sensors hear tridents, potions and fishing hooks land
Horizon raised projectile_land only for the creatures list. Tridents, thrown potions and hooks now raise it too, as Projectile.onHit does.

Fixes #6050

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 10:21:25 -05:00
Ludovic MarieandClaude Opus 5.5 6d09cbb10f Explosions push thrown tridents too
Player-thrown tridents live in Game::tridents, apart from
creatures.projectiles, so the blast skipped them (kacpherek's review).
Same push as the other arrows: from the eyes (0.13), not in the ground.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:21:06 +01:00
ElyTheCatandCodex cf2c59e1bd Leaf culling: account for active server packs
Include server resource-pack overlays in leaf and entity culling safety checks, and restore the selected leaf mode when a server pack is removed.

Co-Authored-By: Codex <[email protected]>
2026-11-01 18:51:01 +03:30
[email protected]andClaude Sonnet 5.5 9e90c9c9b6 feat(mods): hear a mod container's viewers change
Fixes #6047

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 16:20:11 +01:00
Federico TaglianiandClaude Sonnet 5.5 3d6b8c6d18 Dropped items look only at the vehicles near them
The obstacle list was scanned in full for every moving item. A grid of 8-block cells hands each
item the boxes around where it can reach this tick. 5000 drops against 1000 spread-out vehicles:
12.6 ms of box checks become 0.45 ms.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 16:18:40 +01:00
Ludovic MarieandClaude Opus 5.5 743a051f37 Merge main into the piglin branch (corpus: piglin pickups beside experience orb stuck)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:18:38 +01:00
Ludovic Marie 5e9745363f Merge main into #5779
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client-render/src/gfx.rs
#	crates/horizon-client/src/game/mods.rs
2026-11-01 16:18:32 +01:00
SylviromiandClaude Opus 5.5 cd6a6e1c89 Merge main into recipe-sync (the recipe filters run inside Recipes::sources)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:17:45 +03:00
TeiwazikandClaude Opus 5.5 c80ab3bbcd Merge the branch below, with main
shader_packs.rs: main's `picked_pack`, owned here since `set_translucent_layers` borrows the game.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 16:17:42 +01:00
MariuslozoandClaude Opus 5.5 ebdd6a9d9a Windows CPU name: read the registry once, test it against the registry (#6032)
windows_cpu ran `reg` on every call: twice per Hardware::detect (its own
cpu() and System::detect's), and again for every telemetry item. The name
does not change while the game runs, so it is read once, as the module
says of its other command probes.

System's cpu() no longer runs `reg` on systems that are neither Linux,
macOS nor Windows.

The Windows test compared the name only against the family/model format,
so a parser that kept only part of the name would have passed. It now
checks the exact value against the registry as PowerShell reads it, and
expects PROCESSOR_IDENTIFIER where the registry has no name (some VMs),
instead of failing there. Its comment no longer says a CI runner runs it:
no Windows job tests this crate. The parser test gains tab columns and a
name with runs of inner spaces; a comment that said padding is kept now
says it is trimmed.

docs: benchmark-report.md and telemetry.md say when cpu is
PROCESSOR_IDENTIFIER.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RodDbPWCByh96WKcbiEQpB
2026-11-01 16:17:09 +01:00
Ludovic MarieandClaude Opus 5.5 1ed2aa47d4 Merge main into #5696: frameGeneration in main's serialize format beside translucentBatching
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:17:08 +01:00
HrafnaValkyrjaandClaude Opus 5.5 5f52402370 Wind bursts push orbs, falling blocks, boats and minecarts
They run ServerExplosion.hurtEntities like any other entity, so they take the same push. Dropped items are only pushed by a mace burst.

Fixes #6044

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 10:15:34 -05:00
pikammmmm f4d50e3122 Saves: upgrade older chunk, entity and POI records as they are read
Vanilla upgrades every record it reads from a region file
(SimpleRegionStorage.upgradeChunkTag, from ChunkMap.upgradeChunkTag,
EntityStorage and SectionStorage.PackedChunk.parse), so a world 26.3 has
opened keeps the chunks nobody loaded since an older version in that
version's format. Horizon upgraded region files only when level.dat itself
was older and decoded those records raw: a 1.18 to 26.2 chunk, whose palette
names its states with `Name` (26.x reads `id`), loaded as an all-air column.

SaveDir's region reads (read_record, load_far, lod_surfaces, the End gateway
migration and the POI claims merge) now run the chunk, entity-chunk and POI
fixes on older records, from vanilla's defaults (-1, POI 1945) when a record
has no DataVersion, with the dimension from its folder and the generator type
from world_gen_settings.dat, found once per SaveDir. old_chunk_bits keeps
reading the record as saved, as IOWorker.isOldChunk does.

Chunks of before 1.18 come out of the fixes with `below_zero_retrogen`, which
the client's chunk loading does not handle yet: they are still generated
again, as before.
2026-11-01 16:14:42 +01:00
Muhammad AsifandOhMyAgent f6dc590d3e Lift primed TNT in soul sand bubble columns
PrimedTnt.tick updated its fluid immersion but never ran
Entity.applyEffectsFromBlocks, so BubbleColumnBlock.entityInside never pushed
it (handleOnInsideBubbleColumn) and a primed TNT dropped into a soul sand
column sank.

The tick now asks motion::bubble_column_push for the columns its box touches,
after the move and before the drag, as items, mobs and armor stands already do.

Closes #6008.

Co-Authored-By: OhMyAgent (grok-4.6) <[email protected]>
2026-11-01 20:13:42 +05:00
Brineexee 8d8c5a8484 Launcher: Fix the version picker overflow and the low contrast text/buttons on several tabs
- Rename "FOLLOWS UPDATES" to "auto-update" so it fits the version picker bar
- Make data/resource/shader pack titles and "None yet" lines readable over the background
- Give Add pack, Open Folder, Add server and Show all the same style as Add mods
2026-11-01 16:13:15 +01:00
Federico TaglianiandClaude Sonnet 5.5 42c338167d Add the Social Interactions screen: hide players' chat
SocialInteractionsScreen (P, or the pause menu's player reporting icon): the All, Hidden and
Blocked tabs, a search box, a row per player with the hide/show chat button and vanilla's
disabled report button. Hiding a player drops their chat (Minecraft.isBlocked in
ChatListener) and says so in the chat line. Blocking and reporting are Mojang's services, so
Blocked stays empty and reporting stays disabled.

Part of #5954.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 16:13:06 +01:00
TeiwazikandClaude Opus 5.5 63250fd4e8 Merge main into the surface branch
gfx.rs: the split around the occlusion test keeps main's pass timing and its list of passes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 16:11:50 +01:00
SylviromiandClaude Opus 5.5 7268233fce Native mods: a load that asks for no recipe types puts the clients back on their own; the type list counts against the last part's cap
After a load that sent recipes, a later one whose mods ask for none sends
one last part with no types, so a joined client drops the server's old
recipes and hears of it (native_recipes_received with no types). The
last part's type list now counts against the payload cap: when it
doesn't fit, the recipes go in an earlier part. The sync hook's docs say
it is asked per load, the same for every player, where NeoForge asks per
joining player.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:11:11 +03:00
oskal675-designandCodex GPT-6.1 d1787596da Freeze client block entities between stepped world ticks
Gate client block-entity tickers on runsNormally, keeping moving pistons alive until subsequent server steps. Verify18 snapshots against an actual vanilla server, including extension, retraction and unfreeze controls. Part of #5977; server notification flags remain a separate follow-up.

Co-Authored-By: Codex GPT-6.1 <[email protected]>
2026-11-01 20:08:40 +05:00
Ludovic Marie e111cb947a Merge #5644 (main merged) into #5645 2026-11-01 16:07:25 +01:00
Ludovic Marie e4bbe6055b Merge #5643 (main merged) into #5644 2026-11-01 16:07:16 +01:00
Ludovic MarieandClaude Opus 5.5 5d01df7504 Merge main into #5643: Hide block-break particles, UI sound reserve and their headers read the language table
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:07:15 +01:00
[email protected]andClaude Opus 5.5 027d02a8b0 Merge upstream/main into native/input-key-events
Keep both: the window key hook and the merged raw mouse button hooks (#6010)
sit side by side in native_hooks.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:00:42 +01:00
Ludovic MarieandClaude Opus 5.5 b65e532430 Anticheat prediction: the record's sprint is the client's own, not the server's player's
A sprinting hit stops the server player's sprint (Player.causeExtraKnockback); the
client's own attack on a mob doesn't, and once the server's flag reaches it the
client starts the sprint again with the sprint key held, without a START_SPRINTING
(wasSprinting never changed). The record took the server's flag, so the replay
walked while the client sprinted: the live 'ground/air water, keys forward sprint'
flags. The record now takes the client's sprint commands; a sprint the server
stopped is played on only if vanilla's client could start it again (the sprint key,
or forward pressed again).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:00:41 +01:00
[email protected]andClaude Opus 5.5 2b878588bd Merge upstream/main into native/render-fog
Keep both fog hooks: the merged computed-color hook (#6000) runs first,
then the whole-fog hook sees the result and can change distances too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:00:23 +01:00
Muhammad AsifandOhMyAgent a2ddc9f681 Lift primed TNT in soul sand bubble columns
PrimedTnt.tick updated its fluid immersion but never ran
Entity.applyEffectsFromBlocks, so a bubble column's entityInside push never
reached it. A primed TNT dropped into a soul sand column sank.

The tick now asks motion::bubble_column_push for the columns its box touches
after the move and before the drag, as items and mobs already do.

Closes #6008.

Co-Authored-By: OhMyAgent (grok-4.6) <[email protected]>
2026-11-01 19:59:55 +05:00
Ludovic Marie 2127463906 Merge main into optifine/cit-match 2026-11-01 15:59:12 +01:00
Ludovic Marie e3ab4bd423 Merge main into optifine/cit-rules 2026-11-01 15:59:10 +01:00
Ludovic Marie 9367959444 Merge main into optifine/ctm-mesh 2026-11-01 15:59:09 +01:00
Ludovic Marie 43b76c4a74 Merge main into optifine/ctm-connect 2026-11-01 15:59:08 +01:00
Ludovic Marie 8881fc76f0 Merge main into optifine/ctm-rules 2026-11-01 15:59:07 +01:00
Ludovic Marie a4bda5e9e1 Merge main into optifine/etf-game 2026-11-01 15:59:06 +01:00
Ludovic Marie 19996b09c9 Merge main into optifine/etf-pack 2026-11-01 15:59:04 +01:00
Ludovic Marie f2b47b87b4 Merge main into optifine/etf-rules 2026-11-01 15:59:04 +01:00
Ludovic Marie 84bf62028b Merge main into optifine/emf-animations 2026-11-01 15:59:03 +01:00
Ludovic MarieandClaude Opus 5.5 4ea4cd559b Frame generation: its post draws pass no pass timing (main's finish_with takes one)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:56:05 +01:00
Loïc PILLERandCodex GPT-6.1 2fd0193da5 docs: add piglin offspring PR screenshot
Record the empty-handed baby produced by the guest spawn-egg regression, for the before/after comparison in the pull request.

Co-Authored-By: Codex GPT-6.1 <[email protected]>
2026-11-01 15:55:57 +01:00
Loïc PILLERandCodex GPT-6.1 724ea7929a fix(mobs): let baby piglins pick up and keep gold
Match PiglinAi.wantsToPickup and stopHoldingOffHandItem: respect the baby ignored-item tag, admire picked-up gold, and equip it or preserve the previous loved item instead of bartering. Babies have no preferred weapon tag.

Compare 31 observations with vanilla through the integrated-server command harness, including adult bartering, occupied baby hands and ignored leather. Extend the hand-rendering regression with a crossbow and a gold ingot. Refs #5867.

Co-Authored-By: Codex GPT-6.1 <[email protected]>
2026-11-01 15:55:57 +01:00
Ludovic MarieandClaude Opus 5.5 fea41f8676 Submit thread: on by default on Linux only, where it was measured faster
On the Mac mini (M4 Pro, Metal, offscreen, render distance 12) the same
binary ran 549/537/450 FPS without the thread and 44/51/293 with it, with
p99 frames of 178/160/21 ms against 4 ms. HORIZON_SUBMIT_THREAD=1 still
turns it on anywhere, =0 off; Windows is not measured yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:55:37 +01:00
Loïc PILLERandCodex GPT-6.1 efe1b716c7 fix(mobs): spawn baby piglins without adult equipment
Follow SpawnEggItem.spawnOffspringFromSpawnEgg: construct the piglin and mark it as a baby without running adult spawn finalization. Keep items picked up later intact.

Add a server-thread LAN regression that uses a spawn egg from the guest and checks that both clients see the same empty-handed baby. Refs #5867.

Co-Authored-By: Codex GPT-6.1 <[email protected]>
2026-11-01 15:55:23 +01:00
ElyTheCatandOpenAI Codex f68f5def32 Entity culling: add asynchronous occlusion mode
Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-01 18:24:59 +03:30
Ludovic MarieandClaude Opus 5.5 c12490337f Keep advancement criteria's conditions as JSON, read on first test
The ~1,300 recipe advancements' criteria held ~7,000 JSON objects as
serde_json Values (~4 MB of a server's idle heap), most never tested in a
game. Criterion.conditions is now a LazyJson: read into a Value the first
time a criterion is tested, then kept. The recipe and inventory indexes
read each criterion once without keeping it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:54:55 +01:00
HrafnaValkyrjaandClaude Opus 5.5 b2263f7eca Merge origin/main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 09:53:52 -05:00
MariuslozoandClaude Opus 5.5 2ae7c880c4 tools: build_times.py compare puts two reports side by side (#5794)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RodDbPWCByh96WKcbiEQpB
2026-11-01 15:52:35 +01:00
MariuslozoandClaude Opus 5.5 e30ff91de7 docs: build-times.md, how to run tools/build_times.py and the first numbers (#5794)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RodDbPWCByh96WKcbiEQpB
2026-11-01 15:52:34 +01:00
MariuslozoandClaude Opus 5.5 c65cb2b929 tools: build_times.py measures cold and incremental builds per crate (#5794)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RodDbPWCByh96WKcbiEQpB
2026-11-01 15:52:34 +01:00
MariuslozoandClaude Opus 5.5 7594506012 Windows: benchmark reports and telemetry name the CPU by its brand (#6032)
hardware.cpu in every benchmark report and the cpu of telemetry items read
PROCESSOR_IDENTIFIER on Windows, which always holds family, model and stepping
(AMD64 Family 25 Model 97 Stepping 2, AuthenticAMD), never the name. The
telemetry server groups its benchmark summary by that string, so chips of one
family would have shared a row.

horizon_telemetry::system::windows_cpu reads ProcessorNameString from the
registry (reg query, hidden window, trimmed) and falls back to
PROCESSOR_IDENTIFIER. horizon-bench, horizon-telemetry and the crash report
use it; the crash report's own copy of the parser is gone and its test moved
next to the shared one, with more cases (CRLF, padding, missing, garbage).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RodDbPWCByh96WKcbiEQpB
2026-11-01 15:50:55 +01:00
Sticky-BugandAntigravity a254e5ea31 Do not play hand animation when block interaction returns Consume (#5989)
In vanilla Minecraft, right-clicking an empty flower pot with an invalid item or empty hand consumes the action (InteractionResult::Consume) but does not swing the arm. In Horizon, use_hand unconditionally called swing_arm() whenever the interaction consumed the action, which also triggered swing_packet to LAN guests.

Gate swing_arm on InteractionResult::should_swing(), which only swings on Success.

Co-Authored-By: Antigravity <[email protected]>
2026-11-01 20:10:50 +05:30
[email protected]andCodex 202a24c553 test(mods): isolate release dispatch from adjacent input hooks
Co-Authored-By: Codex <[email protected]>
2026-11-01 15:40:48 +01:00
Ludovic MarieandClaude Opus 5.5 84bd812ecd docs(mods): a shift-click tops up a slot without asking may_place, as vanilla
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:40:29 +01:00
oskal675-designandCodex GPT-6 4647a2b257 Eject every player from boats submerged too long
Count consecutive underwater ticks on the server for client-driven boats and use the existing rider-ejection path. Keep the request visible to all passengers until they have left. Compare real vanilla and Horizon passenger outcomes and cover host and guest immersion on the integrated server.

Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-01 19:38:23 +05:00
[email protected]andCodex 76a6eed29c feat(mods): two-axis gameplay mouse scrolling event
Co-Authored-By: Codex <[email protected]>
2026-11-01 15:36:38 +01:00
Ludovic MarieandClaude Opus 5.5 03c7c60808 Merge main into #5696: draw_overlay keeps #5887's pass timings (the real frame's only)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:34:59 +01:00
[email protected]andCodex 906567c48d feat(mods): complete window keyboard input event
Co-Authored-By: Codex <[email protected]>
2026-11-01 15:32:11 +01:00
oskal675-designandCodex GPT-6.1 003985afa4 Keep Creative item tooltips continuous across slot gaps
Reuse the padded container slot target for Creative grid and hotbar highlights and tooltips. Compare 50 rendered tooltip samples against actual vanilla slot hit-testing, including quarter-pixel boundary and Survival controls.

Co-Authored-By: Codex GPT-6.1 <[email protected]>
2026-11-01 19:30:18 +05:00
Ludovic Marie 7791063dfe Merge optifine/cit-match 2026-11-01 15:29:50 +01:00
Ludovic Marie e44c119bc0 Merge the connected-textures clippy fix 2026-11-01 15:29:49 +01:00
Ludovic MarieandClaude Opus 5.5 49aa71cc5e Sprites::insert_file: clippy's needless borrow (decode_file takes a &Path now)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:29:47 +01:00
[email protected]andCodex 55d67aaa4d feat(mods): screen mouse drag pre event
Co-Authored-By: Codex <[email protected]>
2026-11-01 15:28:09 +01:00
[email protected]andCodex b88289edd1 test(mods): exercise actual mouse mapping release gate
Co-Authored-By: Codex <[email protected]>
2026-11-01 15:24:56 +01:00
[email protected]andCodex f8350d2240 feat(mods): screen key release pre event
Co-Authored-By: Codex <[email protected]>
2026-11-01 15:22:20 +01:00
Ludovic MarieandClaude Opus 5.5 1dd14c4cd2 Merge main into recipe-sync (server_data_loaded hook kept beside the recipe sync hooks)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:21:55 +01:00
KacpherekandCodex GPT-6 c92f0357c9 Merge upstream main into GPU recovery fix
Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-01 15:19:25 +01:00
Ludovic MarieandClaude Opus 5.5 01ae0bd791 Merge main into #5800
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:16:04 +01:00
Ludovic MarieandClaude Opus 5.5 97615443fe Launcher memory row: no limit until the player sets one
A 2048 MB default would give every player who updates a RAM limit they
never chose. On macOS and Linux the game starts stepping the render
distance down near 1843 MB, and big worlds or modpacks can pass that and
reach the last-resort crash. The row now starts at no limit, the end of
the slider; a typed or slid value is kept as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:14:09 +01:00
[email protected]andCodex e0c64b5cc8 feat(mods): adjust final world fog parameters
Co-Authored-By: Codex <[email protected]>
2026-11-01 15:13:38 +01:00
Ludovic MarieandClaude Opus 5.5 c486c3a7a6 Merge main into #5801
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:13:34 +01:00
Ludovic Marie 3d9197a87a Merge optifine/cit-match 2026-11-01 15:12:22 +01:00
Ludovic Marie 20815f9448 Merge the connected-textures BlockProperties fix 2026-11-01 15:12:21 +01:00
Ludovic MarieandClaude Opus 5.5 edee85f5d0 Connected textures: block state properties are main's BlockProperties
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:12:21 +01:00
Ludovic MarieandClaude Opus 5.5 70acb20025 Merge main into #5779
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:11:38 +01:00
Federico TaglianiandClaude Sonnet 5.5 48ab95a550 Port map: AmethystBlock's projectile chime is ported
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 15:10:48 +01:00
Ludovic MarieandClaude Opus 5.5 652981076f Native crates: read dev_dependencies and build_dependencies too
Cargo still accepts the underscore spellings before edition 2024, so a
dependency there skipped the lock check (levzzz5154's note on #5756).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:09:51 +01:00
Ludovic MarieandClaude Opus 5.5 4c69eab447 Merge main into feat/native-music (beside native_biome_colors)
The shader params doc comment goes back on native_shader_params: #5897 put
native_biome_colors between that comment and its function.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:09:24 +01:00
Ludovic MarieandClaude Opus 5.5 18a272eb7d Merge main into #5756
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:09:15 +01:00
Federico TaglianiandClaude Sonnet 5.5 c61f82e61e Port map: 16 advancement-trigger rows that fire already say port, not missing
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 15:07:09 +01:00
Ludovic MarieandClaude Opus 5.5 b618b02a68 Merge main into #5731
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:05:11 +01:00
Ludovic MarieandClaude Opus 5.5 4f6522cbeb Merge main into feat/chat-animation (chat extras beside splitscreen and uiSoundReserve)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:04:49 +01:00
Ludovic MarieandClaude Opus 5.5 a02d561d49 Merge main into #5791
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:59:51 +01:00
Federico TaglianiandClaude Sonnet 5.5 3f6221b268 An ender dragon summoned outside the End is drawn and flies
The dragon was drawn and ticked only in the End (its fight), so /summon ender_dragon in the
overworld put an invisible, motionless dragon there that only made sounds. EnderDragon.aiStep
does not depend on a fight (ServerLevel.getDragonFight is null outside the End): draw the
level's dragon in any dimension, and tick it outside the End without the fight.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 14:59:48 +01:00
Ludovic MarieandClaude Opus 5.5 337c3c2bb4 Merge main into #5822
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:58:01 +01:00
Ludovic MarieandClaude Opus 5.5 0a0d056239 Merge main into recipe-sync (both hooks kept beside native_data_pack_files)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:57:47 +01:00
Ludovic MarieandClaude Opus 5.5 42e6769488 Atlas: one list of walked texture folders, for the walk and its skip
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:57:32 +01:00
Ludovic MarieandClaude Opus 5.5 3244c41556 Merge main into #5859
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:57:24 +01:00
Ludovic Marie c865a354f2 Merge #5644 (main merged) into #5645 2026-11-01 14:57:04 +01:00
Ludovic Marie 3d26d4ccfa Merge #5643 (main merged) into #5644
# Conflicts:
#	crates/horizon-client/assets/horizon/lang/en_us.json
2026-11-01 14:57:03 +01:00
Ludovic MarieandClaude Opus 5.5 43f96dda2b Merge main into #5811
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:56:56 +01:00
Ludovic MarieandClaude Opus 5.5 aec9421cf3 Merge main into #5643: the Extras page's Splitscreen switch gets its language keys
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:56:43 +01:00
Ludovic MarieandClaude Opus 5.5 ba68d1505e Glide wear: pick the worn glider with the entity's own random
Util.getRandom(slotsWithGliders, this.random) draws from the entity's random,
so the pick must not take a draw from the level's stream.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:55:31 +01:00
Ludovic MarieandClaude Opus 5.5 9e1f8431fa Merge main into #5790
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:55:13 +01:00
Ludovic MarieandClaude Opus 5.5 536b8462a7 Merge main into #5708
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:55:00 +01:00
Ludovic MarieandClaude Opus 5.5 58055f7214 Merge #5735 (with #5733 and main) into #5737
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:52:04 +01:00
CayMac97 065e7b8efb Ignore PNG chunk checksum errors in sprites, fonts and textures
Certain server resource packs (notably on SMP servers like HugoSMP using custom font and GUI sheets) contain PNG textures with stripped or corrupted CRC-32 chunk checksums.

Vanilla Java Edition (26.3) reads images via STB Image (NativeImage.read), which does not enforce or fail on PNG chunk CRC-32 errors. Setting decoder.ignore_checksums(true) matches vanilla behavior and allows custom font sheets, emojis, scoreboard icons and sprites to decode properly.

Fixes #5979.
2026-11-01 14:51:54 +01:00
Ludovic MarieandClaude Opus 5.5 8ce6505397 Merge #5733 (with main) into #5735
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:50:06 +01:00
Ludovic MarieandClaude Opus 5.5 144ce26f37 Merge main into #5771
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:49:46 +01:00
Ludovic MarieandClaude Opus 5.5 c507ec4730 Resent columns: a section with the same blocks takes the packet's new light
keep_unchanged_sections kept the old section whole when its blocks matched, so a
server that resent a column with new light and no separate light update left a
resident client with stale light (arismoko's review on #5733). Vanilla's
handleLevelChunkWithLight always applies the packet's light. Such a section now
takes the packet's light and remeshes with its neighbours (light_updated); a
section whose light is unchanged is still shared with the old column.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:48:51 +01:00
Ludovic MarieandClaude Opus 5.5 d4da2469cc Merge main into #5733
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:48:12 +01:00
KacpherekandCodex GPT-6 d3fdb8d19c Recover the renderer after graphics device loss
Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-01 14:47:11 +01:00
Ludovic MarieandClaude Opus 5.5 2b50470645 Chat: vanilla's fade keeps whole ticks while Smooth chat is off
ChatComponent's 200-tick fade reads Gui.getGuiTicks, a whole number, so it
steps 20 times a second. Passing the frame fraction made every player's fade
smoother than vanilla's; it now applies only with the Smooth chat extra on.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:46:19 +01:00
Ludovic MarieandClaude Opus 5.5 0c69153b31 Frog: a mob eaten by a frog still drops its loot
RemoveKilled marked the dead target removed before the next tick's deaths
ran, and those skip removed mobs: a magma cube eaten by a frog dropped no
froglight and no experience. LivingEntity.die runs at the killing bite in
vanilla, before eatEntity removes it, so the handler runs mob_died first.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:41:39 +01:00
Ludovic MarieandClaude Opus 5.5 1382a8f1c6 horizon-client-render: horizon-optifine listed once
Random entity textures, connected textures and CIT each added the dependency.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:41:18 +01:00
Ludovic Marie 50f30b133f Merge remote-tracking branch 'du6e/optifine/cit-match' into of-union 2026-11-01 14:40:59 +01:00
Ludovic MarieandClaude Opus 5.5 7e30d60e9e Merge optifine/cit-rules: CIT beside CEM, random entities and CTM
paths: one valid() test shared by resolve, in_root (CTM) and CIT. THIRD_PARTY_NOTICES.md
credits CIT Resewn Continuation (MIT), whose conditions cit.rs follows.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:40:52 +01:00
Ludovic MarieandClaude Opus 5.5 75835557ff Merge optifine/ctm-mesh: connected textures beside main's LabPBR maps
RenderAssets keeps both the PBR maps and the connected-texture table; Sprites::insert_file
passes the root to main's register (a resource path has no namespace, so no PBR maps).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:40:51 +01:00
Ludovic MarieandClaude Opus 5.5 45161095d6 Merge main into fix/5836-powder-snow-walk-particles
main's spread() already applies RandomizationType ALTERNATIVE in
handleParticleEvent, so this branch's copy of it is dropped.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:40:35 +01:00
Ludovic MarieandClaude Opus 5.5 596b47850d Merge main into fix/5842-shulker-bullet-trail
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:39:24 +01:00
Ludovic Marie 5f38815063 Merge remote-tracking branch 'du6e/optifine/ctm-connect' into of-union 2026-11-01 14:39:05 +01:00
Ludovic Marie 9c12b9b065 Merge remote-tracking branch 'du6e/optifine/ctm-rules' into of-union
# Conflicts:
#	Cargo.lock
#	crates/horizon-optifine/Cargo.toml
#	crates/horizon-optifine/src/lib.rs
#	crates/horizon-optifine/src/paths.rs
2026-11-01 14:39:05 +01:00
Ludovic MarieandClaude Opus 5.5 fc292b22d1 Merge optifine/etf-game: random textures beside the custom entity models
Both sides of MobData, the pack reload and render_visible kept: a .jem's texture is
picked first, then its random variant. horizon-optifine listed once in horizon-client's
dependencies, and render_visible holds the CEM read guard instead of cloning its Arc.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:38:44 +01:00
Ludovic Marie b2b7de9edf Merge remote-tracking branch 'du6e/optifine/etf-pack' into of-union 2026-11-01 14:38:01 +01:00
Ludovic Marie ad5e50cbe6 Merge remote-tracking branch 'du6e/optifine/etf-rules' into of-union 2026-11-01 14:38:01 +01:00
Ludovic Marie b1b722682a Merge main into the OptiFine series 2026-11-01 14:38:00 +01:00
Ludovic MarieandClaude Opus 5.5 a94150a97c Merge main into #5696: frameGeneration beside main's texture budget and dithered mips
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:32:37 +01:00
finalpantasyandCodex ff45f545ce Merge upstream main to include its existing CI lint correction
Preserve the Home Mods refresh fix while incorporating upstream's correction
to BlockProperties::from_shared. The pull request remains one launcher file
relative to the pinned main revision.

Co-authored-by: Codex <[email protected]>
2026-11-01 08:30:55 -05:00
Federico TaglianiandClaude Sonnet 5.5 b183f74257 Dedicated server: a panic in the tick still saves the world, as runServer's finally does
MinecraftServer.runServer catches what the tick throws, writes the crash report, then
stops the server. A panic on the dedicated tick thread unwound out of run_dedicated, so
nothing since the last autosave was written. The tick is now under catch_unwind; a
panic ends the loop and saves the levels and the rest of the world state.

What the panic interrupted is not trusted: the guest whose turn (as_guest) it was has
older data in its record than the game holds swapped in, so it keeps its last saved file;
a hub taken out for the network tick (self.lan.take()) is gone with the unwind, so no
player is saved then.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 14:27:39 +01:00
Ludovic Marie 47b5a77bce Merge #5928 into the shader API union 2026-11-01 14:27:09 +01:00
Ludovic Marie 2947ff96b2 Merge #5920 into the shader API union 2026-11-01 14:27:09 +01:00
Ludovic Marie 90f08d3fbb Merge #5900 into the shader API union 2026-11-01 14:27:09 +01:00
Ludovic MarieandClaude Opus 5.5 6530425ace Load loot and live features beside the world as it opens
WorldRequest::prepare loaded the loot data (~40 ms on 4 CPUs) before the
world and installed the live features (bone meal's and saplings', ~46 ms)
after it, while the generator's decoration (~205 ms) kept one core busy.
Neither needs the world: they now run on scoped threads beside it and are
joined before prepare returns, so the loot data is still loaded before any
chunk is read. With a single worker thread they run in order as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:22:52 +01:00
TeiwazikandClaude Opus 5.5 9f3a88a41d Merge main: the lint fix
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 14:18:33 +01:00
TeiwazikandClaude Opus 5.5 c41d1ee7b4 Merge main: the lint fix
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 14:08:44 +01:00
HrafnaValkyrjaandClaude Opus 5.5 43ec4c8319 Explosions push projectiles
Arrows, tridents, snowballs, fire charges and rockets never felt a blast,
so TNT minecart arrow launchers did nothing. They now take vanilla's
push from their eye position, with each type's real eye height (0.13 for
arrows and tridents, 0 for wind charges). An arrow in the ground stays.

Fixes #5908

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 08:07:20 -05:00
SylviromiandClaude Opus 5.5 34ba8585ed Recipe book: a namespaced recipe keeps its id
The book's model and the native recipe list made every crafting and
cooking id `minecraft:<name>`, so a content mod's or a data pack's
`mymod:thing` became `minecraft:mymod:thing`. Only a bare vanilla name
takes the prefix now (crafting::recipe_id), and the native list reads
cooking times from the recipes in use, not the content root's alone.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:07:10 +03:00
SylviromiandClaude Opus 5.5 45480f7b1b Native mods: forget the recipe book's model through its own module
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:01:49 +03:00
SylviromiandClaude Opus 5.5 08fd8f8a1f Native mods: the synced recipes reach the recipe book's list, and the mods hear of them once the player is there
The window's recipe book model, which native_recipes lists the vanilla
kinds from, is made again from the server's recipes when they arrive.
The recipes of a join come before the window has its player, when the
helpers answer nothing, so native_recipes_received waits for it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:01:14 +03:00
Federico TaglianiandClaude Sonnet 5.5 8773007a60 A dropped item rests on a boat, a minecart, a shulker or a held-still happy ghast
Entity.collide stops a moving entity at the entities whose canBeCollidedWith(it) is
true. An item only looked at blocks, so one dropped onto the back of a happy ghast
(held still while a player stands there) fell through it, as it did through a boat.
The server's item tick now gets the boxes of boats, minecarts, living shulkers and
happy ghasts on a still timeout.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 14:00:04 +01:00
reservedbytes 57bac61271 feat(mods): native mods act on an item's use tick and release 2026-11-01 11:59:38 -01:00
TiggerTakkyTTTandCodex 4980a15644 Add live MOTD methods to the server management protocol
Read and set the dedicated server message through vanilla management
requests, updating the value used by server-list pings immediately.

Part of #5147

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-01 12:49:46 +00:00
DoodlyyousufandGrok 4.6 47f85029fe Send powder-snow walk snowflakes from the server
PowderSnowBlock.entityInside's SNOWFLAKE particles only ran on the
in-frame Game. The server copy skipped them, and guest ticks dropped
the event, so the window never drew them.

Emit them on server_block_damage, send ALTERNATIVE packets to nearby
players, and skip the client-side copy.

Fixes #5836

Co-Authored-By: Grok 4.6 <[email protected]>
2026-11-01 12:49:27 +00:00
SylviromiandClaude Opus 5.5 c79b897210 Native mods: send a server's recipes to its Horizon clients
A server's native mods name the recipe types their client halves need
(native_datapack_sync, NeoForge's OnDatapackSyncEvent.sendRecipes). The
server sends those types' final recipes, data packs merged and load
conditions held, to every Horizon client on a join and after each
/reload, on horizon:recipe_content in payloads within the clientbound
cap. The client's tables take them in place of its own for those types,
so native_recipes and native_recipes_of_type match the server, single
player included, and native_recipes_received (RecipesReceivedEvent) says
they came. With no type asked nothing is sent; vanilla clients are sent
nothing new.

Recipes::load is split into sources and from_sources so the server and
the client build from the same list.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:49:11 +03:00
ElyTheCat 5c8a4156ff Merge remote-tracking branch 'origin/main' into feat/leaf-culling 2026-11-01 16:17:51 +03:30
oskal675-designandCodex GPT-6.1 16a9935c6a Run armor stand location enchantments on equipment and movement
Apply the existing replace-disk evaluator for armor stand block changes,
landings and changed equipment slots. Keep piston support flags current
and verify the behavior against actual vanilla servers.

Remove a needless borrow in the upstream properties sort for Clippy.

Co-Authored-By: Codex GPT-6.1 <[email protected]>
2026-11-01 17:41:24 +05:00
TeiwazikandClaude Opus 5.5 724d94d0dd Shader API: the [vertex] stage, where corners of terrain are drawn
fn vertex(input: VertexInput) -> vec3<f32> moves terrain in the world and the shadow map, for
waving plants and rolling water. It needs a [material] and shares its module.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 13:39:42 +01:00
ElyTheCatandCodex 2a9a6c1e66 Graphics: capture leaf culling modes
Co-Authored-By: Codex <[email protected]>
2026-11-01 16:08:55 +03:30
finalpantasyandCodex f5014f4060 Fix Home Mods refresh after native files change during a read
Carry the fingerprint captured at read start into asynchronous completion. Keep an older result stale when a newer fingerprint was observed, allowing the next render to load the updated list.

Add a deterministic regression using owned temporary native-mod files and real discovery and fingerprints. It fails with the original completion behavior and verifies both files after the follow-up read.

Fixes #5907.

Co-authored-by: Codex <[email protected]>
2026-11-01 12:33:10 +00:00
TeiwazikandClaude Opus 5.5 dc4a9f2494 Shader API: [[materials]] blocks and translucent
An id can name blocks as well as sprites, and can move its sprites into the translucent pass. A
sprite pattern beats a block on the same sprite. Sections mesh again when the list changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 13:18:39 +01:00
Kacpherek a5bee31eb5 Check only nearby chunks' entities for a client that moved
A client that moved, or whose own entity changed section, had every
entity of the level checked against it each tick: players times
entities. sees caps the range at the client's view distance, so only
entities in chunks that close can be seen. The tick now keeps each
chunk's entities and walks only the chunks within reach; the flags,
pairs and their order are what checking every entity gave.
2026-11-01 13:15:51 +01:00
Federico TaglianiandClaude Sonnet 5.5 b45e1c9dd3 Frog: the tongue sets its target's velocity and a killed target vanishes at once
ShootTongue.tick sets the target's delta movement (setDeltaMovement, not an add) toward
the frog, and eatEntity removes a target that died (remove(KILLED)): the magma cube is
drawn into the mouth and disappears, with no death animation next to the frog.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 13:15:49 +01:00
ElyTheCatandAntigravity 81862bbc39 Merge origin/main into feat/leaf-culling; add benchmark screenshots
Resolve graphics.rs serialize conflict: insert leafCulling between
entityCulling and chunkUpdates, keeping main's textureBudgetMiB and
ditherCutoutMips additions intact.

Add four-mode benchmark screenshots for the PR.

Co-Authored-By: Antigravity <[email protected]>
2026-11-01 15:41:21 +03:30
DetimesandClaude Sonnet 5.5 d4ff4aecef Native mods: a player finishing a sign, rewritable or cancellable (native_sign_changed)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 11:52:57 +00:00
DetimesandClaude Sonnet 5.5 0c25ed9f23 Native mods: a player starting or stopping flight, cancellable (native_player_toggle_flight)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 11:50:19 +00:00
Detimes b244d5354a Native mods: a player changing dimension (native_player_changed_dimension) 2026-11-01 11:48:55 +00:00
Federico Tagliani c03ef707ed Merge remote-tracking branch 'origin/main' into fix/fox-item-pickup 2026-11-01 12:47:41 +01:00
oskal675-designandCodex GPT-6.1 278a1093ee Spawn eggs from source fluid item uses
Use the source-only fluid ray for SpawnEggItem.use, preserve packet-supplied block hits for useOn, and compare both paths against vanilla.

Co-Authored-By: Codex GPT-6.1 <[email protected]>
2026-11-01 16:42:07 +05:00
Alexander KrylovandHermes Agent 20f68001d8 Tests: build the smooth chat test's Chat in one go
Clippy's field_reassign_with_default on the new test.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-11-01 14:39:28 +03:00
TeiwazikandClaude Opus 5.5 e2850986c9 Shader API: set_surface and surface_at, per-pixel data for the post stage
A material stage stores a normal, a smoothness and a value of its own for solid terrain; a post
pass reads them, where that terrain still shows. Only a stage that calls set_surface pays for the
target and its passes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 12:34:46 +01:00
Alexander KrylovandHermes Agent e1babb9132 Chat: a new line slides in and fades in (Smooth chat extra)
The ChatAnimation mod's idea, not its code: a line slides in from the left
while it fades in over 5 ticks instead of appearing whole, and the indicator
bar can be turned off. Both are Extras, off (the bar: on) by default, so a
vanilla client draws exactly what it drew before.

Chat::layout takes the fractional tick now, so the slide moves every frame
and vanilla's own fade does too. Only the unfocused HUD chat animates: with
the chat screen open the lines are drawn where click_at maps them.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-11-01 14:23:56 +03:00
DoodlyyousufandGrok 4.6 bcf52293d9 Spawn a shulker bullet's END_ROD trail on the client
Vanilla ShulkerBullet.tick draws two END_ROD particles in its 0.3125
box every client tick. Horizon moved the bullet and drew the model,
but never spawned the trail, so the sparkle behind it was missing.

Fixes #5842

Co-Authored-By: Grok 4.6 <[email protected]>
2026-11-01 11:08:18 +00:00
levzzz fab6784831 Merge remote-tracking branch 'hr/main' into pr-5731
# Conflicts:
#	docs/native-mods.md
2026-11-01 16:06:56 +05:00
levzzz 4b57957180 Merge remote-tracking branch 'hr/main' into pr-5637 2026-11-01 15:48:10 +05:00
levzzz f10724e8b5 Merge remote-tracking branch 'hr/main' into pr-5731 2026-11-01 15:48:10 +05:00
Stink-OandClaude Opus 5.5 3073cb461e fix(mods): column reads answer why they read nothing, and read less a call
`native_surface_heights` and `native_read_columns` return `Result<_, String>` as `native_edit_blocks` does, so a mod
tells "wrong level" from "ask for less". A read is at most 1,048,576 blocks (was 16,777,216: it runs on the server
tick), a height query at most 65,536 columns, and an empty span (`below + above` of 0) is refused. The docs say the
tints are the biome's own at the column's top, not blended as the game draws them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 10:43:03 +00:00
VaspyyyandClaude Opus 5.5 76ba2858e9 Native mods: music a client mod chooses and layers (native_music, native_music_layers)
`native_music(music, volume)` changes the track the music manager plays
and its volume each tick (Minecraft.getSituationalMusic and
getMusicVolume; NeoForge SelectMusicEvent). `native_music_layers(layers)`
lists looping tracks played over it on the music source, each easing to
its own volume by 0.025 a tick; layers new in the same tick start
together, so a piece's stems stay in step and can grow fuller.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XnU6QRKvdg3SMVP1onGqS3
2026-11-01 10:42:32 +00:00
levzzz 1d34a59372 Merge remote-tracking branch 'hr/main' into pr-5733 2026-11-01 15:42:22 +05:00
DoodlyyousufandGrok 4.6 c50935983a Pack extra atlas directories a pack adds to blocks.json
HudAtlas walked only a fixed set of texture folders, then skipped
minecraft: file sources whose sprite name matched the texture path,
assuming the walk had packed them. Pack folders such as custom/ never
got a sprite, so item models that used them were invisible.

Keep the have.contains skip, and only treat matching File origins as
already packed when they sit under a walked folder.

Fixes #5720

Co-Authored-By: Grok 4.6 <[email protected]>
2026-11-01 10:42:19 +00:00
levzzzandClaude Opus 5.5 a34c63b88c Bulk edits: a resent column keeps the packet's light where it changed; the anticheat and the far ring hear of bulk chunks
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:32:03 +05:00
reservedbytes 25e5dbb3da feat(mods): mod menu types on the vanilla menu engine, and a hook for their slot rules 2026-11-01 09:25:26 -01:00
levzzz 7a2ea363ce Merge remote-tracking branch 'hr/main' into pr-5637
# Conflicts:
#	crates/horizon-client/src/game.rs
#	crates/horizon-client/src/game/creatures.rs
#	crates/horizon-client/src/game/explosion.rs
#	crates/horizon-client/src/game/lan_pvp.rs
#	crates/horizon-client/src/game/native_hooks.rs
#	crates/horizon-client/src/game/shield.rs
2026-11-01 15:17:03 +05:00
levzzzandClaude Opus 5.5 8bb24b5292 Claimed items: an off-hand claimed item sends no use; docs: main hand only, the movement check, claims over use durations
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:15:18 +05:00
levzzz f27e5c5796 Merge remote-tracking branch 'hr/main' into pr-5733 2026-11-01 15:10:25 +05:00
levzzz 4359398e57 Merge remote-tracking branch 'hr/main' into pr-5731 2026-11-01 15:10:25 +05:00
DetimesandClaude Sonnet 5.5 46ba79697b Native mods: a server player's game mode about to change, cancellable (native_player_game_mode_changed)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 09:29:36 +00:00
Federico TaglianiandClaude Sonnet 5.5 0a1dd1fe1f Foxes and dolphins pick up items as vanilla
Fox: FoxSearchForItemsGoal walks to the first dropped item within 8 blocks while
the mouth is empty (goal 11, after the stroll), and Fox.pickUpItem takes it:
the extra items of the stack drop at its feet, what it held is spat out ahead
(entity.fox.spit, pickup delay 40, thrown by the fox), it holds one item and its
eating clock restarts. Fox.canHoldItem swaps non-food for food once it has eaten.

Dolphin: MoveToItemGoal (priority 7) swims to an item floating in the water within
8 blocks with entity.dolphin.play, PlayWithItemsGoal (8) throws what it holds, and
Dolphin.pickUpItem takes a whole stack into an empty mouth. ItemGoal.drop throws
from the eye, forward at 0.3, with a pickup delay of 40.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 10:24:43 +01:00
simonandClaude Opus 5.5 048a9276d8 The game can keep under a RAM limit
HORIZON_MEMORY_LIMIT_MB and /ramlimit cap the RAM the game holds. On
Windows a hard working set limit enforces it, so a low limit costs speed
instead of crashing. Elsewhere, and when the game is slow and faulting
hard, far terrain and the render distance in use step down and come back.
The game crashes with a report only if it can't run under the limit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 19:05:48 +10:00
simonandClaude Opus 5.5 3ca72c5088 Launcher: a Maximum memory row on Home
A slider and a typed box, saved per instance. Horizon's limit defaults to
2048 MB and reaches the game as HORIZON_MEMORY_LIMIT_MB; no limit is a
choice of its own. A Java instance's row sets -Xmx.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 19:05:48 +10:00
Stink-OandClaude Opus 5.5 b808a05dd9 fix(mods): column reads are bounded and read each chunk's own height
`below + above` could overflow and `columns.len() * per` could ask for
gigabytes: a read past the level's height or 16,777,216 blocks now answers
None, reading nothing. Blocks past the top of the column read as air from
the chunk's own layout rather than the vanilla -64..320, the always-0 `tag`
and the unused `NativeSurfaceHeights` (leftovers of an async API) are gone,
the reads are tested through free functions on `World`, fork names are out
of the docs, and docs/native-mods.md describes the helpers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 08:37:30 +00:00
Stink-O ce05d13f46 Merge branch 'native/edit-blocks' into native/read-columns
# Conflicts:
#	crates/horizon-client/src/game/block_edits.rs
2026-11-01 08:37:30 +00:00
Stink-OandClaude Opus 5.5 c43496c5e8 fix(mods): one entry lookup a block entity name (clippy map_entry)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 08:30:41 +00:00
Stink-OandClaude Opus 5.5 41ae4e9ceb fix(mods): bulk edits check every run first and make block entities as native_set_block does
A run naming a palette entry or filter past the end panicked mid-edit, and a
filter index past u16::MAX wrapped: every run is now checked before anything
changes, and the edit answers Err. Palette entries and filters with NBT are
refused rather than silently dropped. New blocks get their block entity from
`new_block_entity`, so a mod block's own (API 15) is made, as
`native_set_block` does. The heightmap walk stops at the chunk's own bottom,
`Game::block_matches` calls `block_input_matches` instead of repeating it,
fork names are out of the docs, and docs/native-mods.md describes the helper.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 08:26:19 +00:00
Stink-O 78d6f69e90 Merge branch 'native/bulk-block-edits' into native/edit-blocks 2026-11-01 08:24:18 +00:00
Stink-OandClaude Opus 5.5 24067ef56f fix(mods): end a claimed item's use wherever the game resets the use
A respawn, a join, the hand's item changing or the server stopping the use
reset the player's use without the mod use knowing, so the next vanilla
use was taken for the mod's and cancelled. The mod use now records the
slot and ticks it runs at, only that use is the mod's, and every reset
sends the mod its UseStop. native_held_item goes (native_held gives the
held id; the use events carry the custom name and data), as do the unused
screen events and the fork's wording; docs/native-mods.md gets a section.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 08:12:36 +00:00
Stink-OandClaude Opus 5.5 53b34ed9b6 fix(world): bulk edits follow each chunk's layout; resent columns keep new biomes
`edit_runs` clamped runs to the vanilla column (-64..320), so a data pack's
taller dimension lost the blocks above or below it: each chunk's own
`min_y`/sections now bound its runs. `replace_remote` kept an unchanged
section whole, biomes included, so a resend with new biomes under the same
blocks kept the old ones: those sections now take the new biomes and
remesh. `edit_runs`'s doc lists what it does not record per block (block
entities, watched writes, block_log, path_writes, light_edits).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 08:08:58 +00:00
asownerytandClaude Opus 5.5 51b8552e70 Buckets used on a cauldron no longer pour beside it too
A client of the server (single player's window, a LAN guest, a dedicated
server's player) asked only horizon-world's useItemOn whether the clicked
block takes the item, and that has no cauldrons. So after UseItemOn the
click went on to UseItem: the client predicted the bucket's own pour beside
the cauldron (a water or lava block that showed until the server answered,
and the empty sound a second time), and in creative the server poured the
bucket a second time beside the cauldron, where it stayed.

Like vanilla's client (AbstractCauldronBlock.useItemOn, CauldronInteractions,
which act only server side), the client now answers the cauldron's
interaction without acting, ends the click on SUCCESS or CONSUME and swings
on SUCCESS. A sneak with an item in hand skips the cauldron on both sides
(Player.isSecondaryUseActive). lan_e2e gets a cauldron check, which fails on
upstream/main and passes with this.

Fixes #5294.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 21:04:53 +13:00
Stink-O 37d4b92459 Merge main into #5733 2026-11-01 08:00:14 +00:00
Stink-O 764cbf8214 Merge main into #5731 2026-11-01 08:00:13 +00:00
Kacpherek 2f31408502 Let arrows go through nether and End portals as vanilla does
Skeleton arrows passed through portal blocks and kept flying. The portal
sweep in find_entity_portals covered only items, mobs, empty boats and
minecarts, so an arrow never got a portal state and never changed
dimension.

Vanilla does not exempt projectiles: Entity.canUsePortal is true for a
living arrow that is not a passenger, so NetherPortalBlock.entityInside
and EndPortalBlock.entityInside (NetherPortalBlock.java,
EndPortalBlock.java) call setAsInsidePortal on it. AbstractArrow.tick
(AbstractArrow.java) then hands it to handlePortal, and the transition
time is 0 for anything that is not a player
(NetherPortalBlock.getPortalTransitionTime). A stuck arrow skips its move
while in the ground, so it enters no portal.

Arrows now join the portal sweep from where they stood at the start of
their tick, which also covers an arrow shot this tick. An arrow that went
through is saved with arrow_to_nbt as a waiting arrival, like an item or
mob. On load it restores its portal cooldown, so it does not go straight
back through the portal it came out of. Tridents are unchanged.
2026-11-01 08:42:58 +01:00
Kacpherek 6aa7f4c227 Glide with a glider in any worn armour slot, or a patched one
Horizon let a player glide only when the chest slot held a glider from
its own item definition. A glider worn in the helmet, leggings or boots
did not count, and neither did a glider component a server patched onto
the stack, which Paper plugins do. The glide's wear also always hit the
chest piece, so a helmet glider would wear down the chestplate.

Vanilla's LivingEntity.canGlide checks every slot: canGlideUsing needs
the item to have DataComponents.GLIDER, the slot its EQUIPPABLE names
to be that slot, and a next point of wear that won't break it. The
server's updateFallFlying then picks one such piece at random with
Util.getRandom and runs ItemStack.hurtAndBreak(1) on it.

Horizon now checks each worn piece this way (gliding_pieces, also used
for the server's copy of a guest), and the wear goes to a random glider
piece (wear_glider), for the local player and the guest path alike.
2026-11-01 08:42:22 +01:00
MariuslozoandClaude Opus 5.5 178ce862a7 Localization: translations follow the splitscreen.profiles keys and main's friend keys
pl_pl, uk_ua, de_de and fr_fr use the renamed horizon.splitscreen.profiles.*
keys. Main now keys the two "Connected to your friend" lines as
horizon.multiplayer.connected_friend_*, so de_de and fr_fr use those names,
and they gain main's two new keys (not_signed_in, invalid_address).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RodDbPWCByh96WKcbiEQpB
2026-11-01 08:41:21 +01:00
MariuslozoandClaude Opus 5.5 e1ba0bf533 Localization: Swiss German without ß; three translation fixes
Review of the bundled de, fr and uk strings:

- de_ch read de_de's file with its "ß"; vanilla's de_ch writes "ss" (1
  "ß" in its file, 312 in de_de's), so Bundled serves de_ch as de_de with
  "ß" spelled "ss".
- de "Shortcut: %s" was "Kurzanwahl" (a phone's speed dial): "Tastenkürzel".
- de: the calibration's white step hint trailed off ("..., bei deinem
  Licht."); fr: "one test picture at a time" had become "image test".
- uk: the Extras caption "Коліщатко: перенос" put a second colon on its
  button ("...: перенос: Увімк."): "Перенос коліщатком".
- The lang test checks one German and one French string and that de_at,
  fr_ca and fr_ch read the same file, instead of pinning two strings in six
  languages.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 08:40:56 +01:00
MariuslozoandClaude Sonnet 5.5 ee55ff3d67 Localization: add German and French for Horizon's own strings
de_de.json and fr_fr.json cover all 289 keys of en_us.json. de_at, de_ch,
fr_ca and fr_ch read the same files, as the es_* variants read es_es. AI
translation (Claude), not reviewed by a native speaker. Terms follow vanilla's
de_de/fr_fr (Optionen/Einstellungen, Fertig, Mehrspieler; Options, Terminé,
Multijoueur, "Oui/Non" for On/Off, a space before ":" and "?"). German uses
"du", French "vous", as vanilla does. The lang test now also loads the title
footer and the Graphics button in de, fr, pl and uk.

Refs #1927

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RodDbPWCByh96WKcbiEQpB
2026-11-01 08:40:56 +01:00
MariuslozoandClaude Sonnet 5.5 15b1dbaa14 Localization: complete Polish and Ukrainian for Horizon's own strings
pl_pl.json and uk_ua.json had 16 keys each; they now have all 289 keys of
en_us.json. AI translation (Claude), not reviewed by a native speaker. Terms
follow vanilla's own pl_pl/uk_ua files (Ustawienia/Налаштування, Paczki/
Пакети, Gotowe/Готово, byty/сутності, ...), and the ellipsis is the single
character vanilla uses in those languages. Placeholders (%s, %.2f) are kept.

Refs #1927

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RodDbPWCByh96WKcbiEQpB
2026-11-01 08:40:56 +01:00
MariuslozoandClaude Opus 5.5 0e64bf0818 Localization: splitscreen profile keys under horizon.splitscreen.profiles
Review on #5644: more splitscreen strings are coming, so the profile
screen's keys move from horizon.profiles.* to horizon.splitscreen.profiles.*.
The English text is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RodDbPWCByh96WKcbiEQpB
2026-11-01 08:40:54 +01:00
MariuslozoandClaude Sonnet 5.5 fd7a47175c Localization: HDR calibration, profiles, Play with Friends and the keyboard read the language table
More of Horizon's own screens drew English literals (or looked up keys that
en_us.json did not have, which also shows English). They now go through the
language table:

- HDR calibration screen: title, the three step names and hints, Cancel/Back/
  Save/Next (vanilla gui.cancel, gui.back, selectWorld.edit.save), and the
  "Shortcut:" row; the Options > Graphics HDR descriptions and the Calibrate
  buttons that had no en_us entry.
- Splitscreen profile page (split_profiles.rs): every label and line.
- Play with Friends screen, its chat notices and the two "Connected to your
  friend" loading lines.
- The on-screen keyboard's key labels and button hints.

English text stays the fallback (lang::get_or) so nothing changes without a
translation. en_us.json gains 55 keys.

Refs #1927

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RodDbPWCByh96WKcbiEQpB
2026-11-01 08:40:19 +01:00
MariuslozoandClaude Opus 5.5 cddfdcea34 Localization: the lang test checks every key the code names, CRLF or not
Review of the bundled lang file test:

- `every_horizon_key_the_code_names_is_in_en_us` skipped a key whose group
  had no other entry in en_us, so a new screen's keys were never checked
  (deleting `horizon.title.not_affiliated` passed). A key is now any
  `horizon.<group>.<name>` of lower-case letters, digits and `_`, with the
  one server address that looks like one listed.
- The CRLF assertion failed on a Git for Windows checkout
  (core.autocrlf=true) although CRLF is harmless to the parser; it goes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 08:40:07 +01:00
MariuslozoandClaude Sonnet 5.5 570e3c9656 Localization: test the bundled horizon lang files against en_us
Every bundled horizon lang file must be served by Bundled::read (a file with
no match arm is never loaded), parse as a flat map without a repeated key,
have no key en_us lacks (stale or renamed), and keep the placeholders of the
English text. Every `horizon.<group>.<name>` key the client sources name must
exist in en_us.json. A language does not have to be complete: a missing key
falls back to English.

Refs #1927

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 08:40:07 +01:00
MariuslozoandClaude Sonnet 5.5 b3209dd9b9 Localization: the title footer and the Extras page read the language table
The title screen's "Not affiliated with Mojang or Microsoft" and the whole
Horizon Extras page (section headers, the eleven captions, their tooltips and
the On/Off values) were English literals that never reached the language table,
so they stayed English in every language.

They now read `horizon.title.not_affiliated` and `horizon.extras.*` from the
bundled `horizon` namespace (lang::get_or, with the English text as fallback,
as the multiplayer buttons do). en_us.json gains the 27 keys; other languages
fall back to English until they are translated.

Refs #1927

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 08:40:07 +01:00
rdbtCVSandClaude Opus 5.5 c9614815f4 Renderer: finish and submit frames on a submit thread; the hand in the world pass
wgpu encodes a frame's commands in CommandEncoder::finish (~85 µs) and
submits them in ~25 µs, which held the window's thread at uncapped frame
rates. The frame now goes to a "Render submit" thread that finishes,
submits and presents it while the main thread builds the next one. Every
use of the queue waits for that frame first, so writes and submissions
keep their order. Frames with a screenshot readback, error scopes, VSync,
a reconfigure or the first frame's log stay in place. Culling's readback
starts on the submit thread right after the submission.
HORIZON_SUBMIT_THREAD=0 turns the thread off.

The first-person hand now draws at the end of the world pass, after a
depth reset, when nothing runs between them: one render pass fewer.

Closes #5777

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 06:55:23 +00:00
masa-masa-0314andGLM 5.3 320b94aaea Horizon strings: the shader options and split profiles speak every bundled language
The shader options page and the split-profile prompt added six keys to
`en_us` only; zh_cn, ru_ru, es_es, pl_pl, uk_ua and tr_tr showed the
English text through the fallback, and a language's own words were
missing where the page is most visible. All six bundles carry them now.

Co-Authored-By: GLM 5.3 (ZCode) <[email protected]>
2026-11-01 14:32:00 +08:00
finalpantasyandOpenAI Codex 5515c06762 Fix launcher validation of bundled sibling crate dependencies
Validate direct same-mod staged crate paths before excluding them from
registry requirements. Preserve lock checks and propagate discovery errors.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-01 01:41:48 -04:00
some-du6eandClaude Opus 5.5 807fd8b48a Merge main: the index crash fix after translucent sections (#5702)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 23:13:46 -06:00
some-du6eandClaude Opus 5.5 f6058569cb OptiFine CIT: pack armour and elytra textures with the rest
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 23:12:15 -06:00
some-du6eandClaude Opus 5.5 608545c23e OptiFine CIT: worn armour and elytra wear their pack's textures
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 23:12:15 -06:00
some-du6eandClaude Opus 5.5 bea7d2d0fb OptiFine CIT: items draw their pack's texture or model
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 23:12:14 -06:00
some-du6eandClaude Opus 5.5 6f0f1da64e Item models: bake a model with its textures swapped, over a library with extra models
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 23:12:14 -06:00
some-du6eandClaude Opus 5.5 f4a28f2bd4 OptiFine CIT: item rules' textures reach the atlas
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 23:12:14 -06:00
some-du6eandClaude Opus 5.5 d728e15b38 OptiFine CIT: zero book generations and flights read as 0; Latin-1 rule files load
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 23:12:14 -06:00
some-du6eandClaude Opus 5.5 a2b6822754 OptiFine emissive textures: block entities glow too
Chests (closed ones' baked geometry too), shulker boxes, bells, banners, decorated pots,
books and the other block entities draw each part again with their texture's _e
overlay at full brightness, shaded alike. The emissive overlays are shared by the
atlas and the mobs (an Arc), so the pack reload no longer moves them out of the atlas.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 23:11:46 -06:00
some-du6eandClaude Opus 5.5 73de5edebd OptiFine CTM: overlays
overlay draws the 17 transition tiles over a face toward the blocks or
sprites it names (connectBlocks, connectTiles); overlay_ctm, _random,
_repeat, _fixed and Continuity's _horizontal/_vertical forms pick a tile
as their method does. Overlays come before the rule that picks the face's
own sprite, draw after it at the same depth with an alpha cutout (or
translucent with layer=translucent), and are tinted by tintIndex and
tintBlock.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 23:11:39 -06:00
some-du6eandClaude Opus 5.5 1a50677e6b OptiFine CIT: match stacks against the rules, indexed by item and cached per stack
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 23:11:23 -06:00
some-du6eandClaude Opus 5.5 a9f96fe589 OptiFine CIT: load the enabled packs' rules
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 23:11:23 -06:00
some-du6eandClaude Opus 5.5 86a346540e OptiFine CIT: bucket, painting and 1.20.2 potion tags; anchored regexes; escaped emoji; linear key reads
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 23:10:52 -06:00
Stink-O 3d685f468f Clippy: the changed cells' rows through as_chunks, not chunks_exact (Rust 1.99's lint)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 1606f86e4c)
2026-11-01 05:07:30 +00:00
Stink-O ace8c981f5 Clippy: the changed cells' rows through as_chunks, not chunks_exact (Rust 1.99's lint)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 1606f86e4c)
2026-11-01 05:07:30 +00:00
Stink-OandClaude Opus 5.5 1606f86e4c Clippy: the changed cells' rows through as_chunks, not chunks_exact (Rust 1.99's lint)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 05:07:22 +00:00
some-du6e 0c8d593d3a Merge branch 'optifine/ctm-mesh' into optifine/ctm-draw 2026-10-31 23:07:01 -06:00
some-du6eandClaude Opus 5.5 e0e002d5c2 OptiFine CTM picks: compile fixes (model Direction, disjoint borrows)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 23:06:31 -06:00
some-du6eandClaude Opus 5.5 d4fde1af7b ETF blinking textures: mobs blink with their pack's _blink frames
horizon_optifine::blink finds each entity texture's <name>_blink.png (and _blink2.png,
half shut) beside it, a random variant's beside the variant, with blinkLength and
blinkFrequency from <name>_blink.properties. The entity atlas packs the variants'
frames; render_visible draws the frame instead of the texture while a mob blinks,
each mob at its own phase, and shut while a villager sleeps.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 23:06:11 -06:00
some-du6eandClaude Opus 5.5 352b2d08a8 OptiFine emissive textures: parenthesise the test's light value
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 23:05:39 -06:00
some-du6eandClaude Opus 5.5 7f0f3cba92 OptiFine CTM: compact quarters shade like the whole face
Blend within the quad's two triangles, as the GPU does, rather than
bilinearly.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 23:05:21 -06:00
some-du6e 4c8d6b3595 Merge optifine/emf-geometry into optifine/emf-animations 2026-10-31 23:01:46 -06:00
some-du6eandClaude Opus 5.5 9c56a91d82 OptiFine CTM: connected textures drawn in the world
The mesher asks ConnectedTextures for each face's sprite: a tile swaps the
quad's layer (in the pass its alpha needs), and ctm_compact's quarters
split the quad in four, its light and colour blended across them. Rules
that test biomes get the block's biome. Nothing changes without CTM files.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 23:00:39 -06:00
some-du6eandClaude Opus 5.5 e1908564c5 OptiFine emissive textures: mobs' _e overlays glow at full brightness
horizon_optifine::emissive reads the pack's suffix (optifine/emissive.properties,
ETF's etf/ and textures/ copies, _e without one) and finds each entity texture's
overlay beside it, a random variant's beside the variant. The entity atlas packs the
variants' overlays; render_visible draws the body and each layer again with its drawn
sprite's overlay, full bright with the model's shading (OptiFine's look), armour
included.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:59:01 -06:00
some-du6eandClaude Opus 5.5 5f9545fb9c OptiFine CTM picks: the test's rule order
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:57:30 -06:00
some-du6eandClaude Opus 5.5 c1e854dfcb OptiFine CTM picks: review fixes
A rule naming blocks and tiles needs both; block rules are indexed by
block, so a state tests only its own; textures/ tiles reuse their sprite;
overlay and custom-name rules load no tiles yet; top passes the top and
bottom faces on; tests for connect=tile, repeat, h+v, skips, defaults and
insert_file. Also fixes a double borrow.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:56:03 -06:00
some-du6eandClaude Opus 5.5 5d7f965b72 OptiFine random entities: mobs wear their pack's texture variants
The entity atlas packs the variants horizon_optifine::random_textures finds; each mob
chooses its variants when this window first sees it (biome, height, blocks, moon,
time and weather fixed then) and again once a second (name, profession, colour, baby,
health, size); render_visible draws the chosen variant's sprite for the body and
each layer. Layers without rules follow the body's variant (sheep wool), villager
layers choose their own. A pack reload makes every mob choose again.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:52:37 -06:00
some-du6eandClaude Opus 5.5 35f2791025 OptiFine random entities: map, not and_then, for the always-found rules id
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:52:05 -06:00
some-du6eandClaude Opus 5.5 719c862c93 OptiFine CIT: block entity and head tags that moved drop the rule; display keys stay in custom data
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:50:50 -06:00
some-du6eandClaude Opus 5.5 e713f1401e OptiFine CTM: tiles in the block sprites, and each face's rule picks one
Connected-texture tiles load as sprite layers (Sprites::insert_file reads
a texture outside textures/), and ConnectedTextures keeps each block
state's and sprite's rule list, built once at load. pick() walks them in
rule order for a face: ctm, ctm_compact (quarters and ctm.N), horizontal,
vertical, h+v, v+h, top, random (weights, loops, symmetry, linked),
repeat and fixed, with faces, heights, biomes, connect=block/state/tile,
innerSeams and the axis of logs. Without CTM files nothing is built.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:48:11 -06:00
some-du6eandClaude Opus 5.5 1c63577e62 OptiFine random entities: an oversized or unreadable rules file keeps the default
Review round 3: such a file now holds no rules (every mob wears the texture itself)
instead of falling back to an even choice; the cap is 64 KiB, and a test pins it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:46:20 -06:00
some-du6eandClaude Opus 5.5 8bdc00f39d OptiFine random entities: no leading-zero variants, and a size cap on rules files
Review round 2 fixes: creeper02.png is no variant 2; a .properties over 256 KiB is not
read; files() says it lists every variant; tests pin Latin-1 in a value that decides,
and the texture's own folder coming last.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:43:04 -06:00
some-du6eandClaude Opus 5.5 51f50aaeba OptiFine CTM geometry: clippy's identity_op in a test
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:42:39 -06:00
some-du6eandClaude Opus 5.5 d760738fb1 OptiFine CIT: tags that became components, Resewn's key names, and keys with a bare colon
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:42:34 -06:00
some-du6eandClaude Opus 5.5 cfdc34710f OptiFine random entities: one walk for every folder, and rules that stay in their folder
Review round 1 fixes: the texture's own folder is walked like the others (no more
probing, no gap cut where rules name a variant, inherited rules there too); inherited
rules only cut the file name, never a folder; variant 0 and names ending in _ are no
variants; Latin-1 .properties keep their rules; files() hands the atlas each variant's
path and name.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:41:48 -06:00
some-du6eandClaude Opus 5.5 d4b6fa4862 OptiFine random entities: find each texture's variants and rules in the packs
horizon_optifine::random_textures scans etf/random, optifine/random, optifine/mob,
mcpatcher/mob and (with a .properties beside it) textures/entity for numbered
variants and their rules, keyed by the entity atlas's texture names; textures
without their own rules use the name they extend (wolf_tame -> wolf).
paths::files_under walks a folder for files of given endings.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:41:48 -06:00
some-du6eandClaude Opus 5.5 db3b3dfb96 OptiFine random entities: a file holds at most 1024 rules
Each rule reads every entry, so a file of 200,000 rules took minutes; past the first
1024 rule numbers they are dropped (found reviewing the next PR, which reads them on
every pack load).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:40:53 -06:00
some-du6e 5ac3d9b85c Merge branch 'optifine/ctm-rules' into optifine/ctm-connect 2026-10-31 22:40:31 -06:00
some-du6eandClaude Opus 5.5 337e02a71e OptiFine CTM rules: the tile cap holds before every tile
Names were pushed before the final count check, so a 10 MB list built
hundreds of MB of paths first. matchTiles and connectTiles stop at the cap.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:39:49 -06:00
some-du6eandClaude Opus 5.5 9bffd28a6a OptiFine CTM geometry: test symmetry, covered corners and every turned face
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:35:08 -06:00
some-du6eandClaude Opus 5.5 32ee28a84c OptiFine CTM geometry: no fixed point at the origin, picks stay in range
The random hash steps by the golden ratio so the origin's down face isn't
stuck at mix's zero; weights that don't fit the tile count pick every tile
alike; dot products widen to i64; tests pin hand-computed values.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:32:09 -06:00
Stink-OandClaude Opus 5.5 baabab598f feat(mods): native server mods can read surface heights and columns
`native_surface_heights(dimension, columns, heightmap)` and `native_read_columns(dimension, columns, heightmap, (below, above))` for server mods, in the level being ticked: a column's heightmap height, or the blocks round it as a palette and indexes with the biome's grass, foliage and water tints, thousands of columns a call. Ported from Stink-O/horizon cbceb3f0e, a3ba97c94 and 20a8a553c.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 04:32:01 +00:00
Stink-OandClaude Opus 5.5 33fd9148b0 feat(mods): native server mods can edit blocks in bulk
`native_edit_blocks(dimension, edit)` for server mods: a palette of block states and filters read as commands read them, runs up columns from a height or a heightmap's, made at once in the level being ticked through `World::edit_runs`, with what `fill ... strict` does for block entities. Millions of blocks a second where `native_set_block` does one: for world generators, terraformers, schematics. Ported from Stink-O/horizon ee55a05e6.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 04:31:54 +00:00
Stink-OandClaude Opus 5.5 1bfb26017b perf(world): bulk block edits into chunk sections; resent columns relit only where they changed
`World::edit_runs` (block_edit.rs): runs of blocks up columns written straight into chunk sections, a section replaced at once (`Chunk::replace_blocks`); the chunks changed that way go whole to the players who see them (`World.bulk_log`). A column a server sends again while it is resident keeps its light and is relit and remeshed only round the cells that changed (`Streaming::replace_remote`, `PalettedContainer::same_storage`, `LightChanges::classes_differing`), which also makes any resent column cheaper. Tests for the runs, the storage comparison and the sections dirtied. Ported from Stink-O/horizon ee55a05e6 and 8820a77ca.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 04:31:46 +00:00
Stink-OandClaude Opus 5.5 b5c8018c47 feat(mods): native mods can raycast across the distant terrain and own an item's use
`native_raycast(distance)` and `native_view_hit()` (the first block along the view, up to 1024 blocks, across the distant terrain past the loaded chunks), `native_held_item()` (the main hand's stack, its custom name and data as SNBT), and `native_claim_items(mod_id, ids)`: a right-click with one of those items is the mod's alone (the server never hears of it), and `native_poll(mod_id)` gives `UseStart` and `UseStop` with the ticks it was held, for a held-to-charge item. Tests for the claims and the event queue. Ported from Stink-O/horizon ae531730c and 896853699.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 04:31:39 +00:00
some-du6eandClaude Opus 5.5 763a448985 OptiFine random entities: say what empty Facts.blocks means
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:29:11 -06:00
some-du6eandClaude Opus 5.5 90f5650007 OptiFine random entities: a blocks rule fails when no blocks are known
Round 6 review: with no block facts, '!stone' no longer holds (as a biome rule with no
biome already fails).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:27:41 -06:00
some-du6eandClaude Opus 5.5 df3306556b OptiFine CTM: the tile each method picks from a face's neighbours
horizon_optifine::ctm::connect: face texture directions (vanilla cube and
the axis-turned cube column), the 8-neighbour mask with innerSeams, and
the tile tables of OptiFine's templates: ctm's 47, ctm_compact's
quarters, horizontal, vertical, h+v, v+h and overlay's 17, plus repeat's
pattern place and a fixed, weighted random pick per position and face.
Shared with other connected-texture formats.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:26:39 -06:00
some-du6eandClaude Opus 5.5 e7c5e9d1ab OptiFine CIT: a missing named asset drops the rule; Resewn's hand spellings; ~ opens a segment
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:26:11 -06:00
hasanbkaylan 65c21a8463 Update tr.json 2026-11-01 07:24:29 +03:00
some-du6eandClaude Opus 5.5 2e80781c76 OptiFine random entities: the bad-condition test uses facts a dropped entry would pass
Round 5 review: run the never-match cases on a named mob in plains, so a lone '!' kept
in biomes or names turns the test red; the crate doc names ETF's docs too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:23:47 -06:00
some-du6eandClaude Opus 5.5 bb23a1ac37 OptiFine CTM rules: an empty namespace is minecraft; docs match the code
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:22:40 -06:00
some-du6eandClaude Opus 5.5 733c361874 OptiFine random entities: a lone ! or an empty weights line, and one key scan
Round 4 review fixes: '!' with nothing after it skips the rule; an empty weights line
is an even choice instead of dropping the rule; one pass over the rule's keys; biome
patterns through Name::parse; the cap test through max_texture and select.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:20:33 -06:00
some-du6eandClaude Opus 5.5 a83bb47af4 OptiFine CTM rules: namespaced sprites in textures/, refused tiles fail the file
matchTiles=ns:a/b now names ns:textures/a/b, as Continuity's tiles do; a
tile outside the resource root fails the file rather than shifting the
weights after it; weights past Integer.MAX_VALUE count 1.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:20:04 -06:00
some-du6eandClaude Opus 5.5 a8a1d74d15 OptiFine random entities: biome patterns, and empty conditions skip the rule
Round 3 review fixes: biomes read ETF's pattern:/regex: forms (matched with or without
the namespace) and their negation; an empty condition never matches; drop a no-op
check and two fixture rules that tested nothing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:16:36 -06:00
some-du6eandClaude Opus 5.5 b916c9a224 OptiFine CTM rules: assets/minecraft/ tiles stay in minecraft
A tile written as a full assets/minecraft/ path in another namespace's
file kept that file's namespace.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:14:19 -06:00
some-du6eandClaude Opus 5.5 7a2b81f86f OptiFine CTM rules: one bound on tile ranges
The range check can't underflow, so the per-token check was redundant.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:14:03 -06:00
some-du6eandClaude Opus 5.5 4bef210597 OptiFine CIT: bad ids and masks drop the rule, paths keep to the pack, and Resewn's lookups
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:13:51 -06:00
some-du6eandClaude Opus 5.5 9375dab632 OptiFine random entities: nbt list paths, seedSource and exact health percent
Round 2 review fixes: an nbt path ending in a list index no longer disables another
rule; seedSource=entity is accepted (vehicle skips the rule); health percent no
longer loses a point to f32 rounding; an unnamed entity passes '!name' lists; tests
check the even choice through select instead of private fields.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:11:59 -06:00
some-du6eandClaude Opus 5.5 c76b2a249a OptiFine CTM rules: close the tile cap, refuse backslashes and bad heights
The tile cap is checked before every token, so names before a range can't
underflow it; a path with a backslash is refused (Windows reads it as a
separator); a height bound that isn't a number fails the rule instead of
opening the range.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:10:20 -06:00
some-du6eandClaude Opus 5.5 ffb010b543 OptiFine random entities: a rule with a bad or unsupported condition never matches
Review fixes: conditions that don't parse (or nbt/teams/... that aren't read yet) skip
the rule instead of widening it to every entity; negated name lists exclude; an
unnamed entity fails name rules; variant indices stop at 1024; ETF's bare negative
ranges (-64--30) parse; more tests for each condition's failing side.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:07:58 -06:00
some-du6eandClaude Opus 5.5 943b1c7bc1 OptiFine CTM rules: bound tile ranges, reject bad names, read Latin-1
Review fixes: a tile range or repeat pattern can't ask for billions of
tiles; a name= that doesn't parse drops the rule instead of matching every
block; weights keep their positions; flags read as Boolean.parseBoolean;
files that aren't UTF-8 are read as ISO-8859-1, as Java does; the path
check is shared with paths::resolve. Tests for load, heights and the
broken values.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:06:56 -06:00
some-du6eandClaude Opus 5.5 68f749df47 OptiFine CIT: read the rules of optifine/cit and their component tests
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:05:14 -06:00
some-du6eandClaude Opus 5.5 f51a81bbed OptiFine packs: read connected-texture rules (optifine/ctm)
Parse optifine/ctm/**/*.properties into ordered rules: every method and
its tile count, matchTiles/matchBlocks (and the file-name forms), tiles
(ranges, names, paths, <skip>, <default>, Continuity's namespaced
sprites), faces, biomes, heights (with Continuity's a..b), name, connect,
connectTiles/connectBlocks, innerSeams, weights, randomLoops, symmetry,
linked, width/height, ctm.N, tintIndex/tintBlock, layer, and Continuity's
orient, prioritize and optifineOnly.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:02:41 -06:00
some-du6eandClaude Opus 5.5 6049010105 OptiFine random entities: read the rules that pick an entity's texture variant
horizon_optifine::random parses optifine/random .properties rules (textures/skins,
weights, seedOffset and the biome, height, name, profession, colour, baby, health,
moon, time, weather, size and block conditions) and picks a variant from an
entity's UUID-derived seed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:02:05 -06:00
Aaron Wang 8967e6abf1 A hit on a boat, minecart or armor stand starts the attack strength again
Player.attack calls onAttack, which resets the attack strength ticker, for
every entity that passes cannotAttack. Horizon did it for mobs, players and
the dragon, but not for boats, minecarts and armor stands, so on a server
only the client's swing packet reset it: a client that skipped the swing
hit vehicles at full strength every time. A fixed item frame refuses the
hit of a player who isn't in creative, and a cushion that of a player who
may not build, so skipAttackInteraction is false there and they reset too.

server_packet_rules_e2e checks it with two bots that hit a boat each in one
go without swinging: eight hits leave one standing (it breaks on the fifth
without the reset), 24 still break the other. horizon-bots' --attack takes
a UUID, so a bot can hit an entity summoned with one, and --attack-rounds
stops it after N rounds. The harness turns the anticheat off, which would
drop those attacks, and force-loads the boats' chunks.

Fixes #5631

Written with Claude Opus 5.5 in OpenCode.
2026-10-31 23:59:42 -04:00
ElyTheCatandAntigravity 4ca447371b Merge origin/main into feat/leaf-culling
Co-Authored-By: Antigravity <[email protected]>
2026-11-01 07:00:49 +03:30
ElyTheCatandAntigravity 4af3055742 Add configurable leaf culling (Hollow, Solid, SolidAggressive)
Co-Authored-By: Antigravity <[email protected]>
2026-11-01 06:59:23 +03:30
spiderandClaude Opus 5.5 a9c0d90d2d Frame Generation setting: FSR 3.1, its present thread and the hand
Options > Graphics > Display: Frame Generation, Off or FSR 3.1 (off by default,
frameGeneration in horizon-graphics.txt). The world is kept at the screen's size
without the hand and HUD, the optical flow and interpolation make the frame
halfway (with mobs' and dropped items' own motion vectors), the hand (at its
halfway pose), outlines, post effects, screen effects and HUD are drawn over it,
and a present thread shows it half a real frame before the real one. Off under
shader packs, HDR, the title screen and blurred menus.

When the present thread gets anything but a frame from the surface (outdated,
lost, occluded, timeout, validation, suboptimal) or dies, the renderer stops it
and acquires the next frame itself, so gfx.rs's normal path handles the surface;
frame generation resumes once that path has a frame again. The F3 screen's first
line adds the frames reaching the screen.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 04:28:26 +01:00
some-du6eandClaude Opus 5.5 a6fcdfaf84 OptiFine CEM: custom entity models animate
A .jem's animations compile with its model when the packs load and run
per mob per frame after the vanilla animation: part transforms and
visibility (this, part, part names, bone ids, a:b:c paths), per-entity
var.* and varb.*, and the entity, render and frame parameters of
OptiFine's and EMF's animation docs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:05:22 -06:00
MariuslozoandClaude Sonnet 5.5 be5d818a58 Native mods: a hook at LivingEntity.knockback to change or cancel a push
native_living_knockback (NeoForge's LivingKnockBackEvent) runs in
LivingEntity.knockback once the knockback resistance took its share and
before the motion changes, with the pushed entity's id and type, the
strength, the direction (x, z) and a cancel flag; a non-finite value a mod
leaves is put back. In 26.3 every knockback has a DamageSource, so the hook
names it (damage type, causing and direct entity) as
native_living_incoming_damage names the same hit (`Hurting`,
`hurt_server_args`). The API number comes from the fragment
(tools/native-hook-fragments).

It is reached by every LivingEntity.knockback Horizon makes on players
(including LAN guests and a dedicated server's) and mobs: a hit's own push
(dealDefaultKnockback), a sprinting or Knockback hit and a spear
(causeExtraKnockback), a sweep, a goat's ram (RamTarget, `mobAttack`) and a
mob's spear push, which now carry their source in MobEvent::Knockback, and
LivingEntity.blockedByItem's push on a shield's holder, which was
open-coded past it (and past the knockback resistance) in shield.rs and is
now knock_back_player(0.5, ...). A sulfur cube with a block hit by an entity
doesn't reach it (SulfurCube.knockback doesn't call super there). The docs
list the pushes Horizon still makes another way: a mob's melee push from its
attack knockback or a Knockback weapon, an armor stand's, the ender
dragon's.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:07:08 +01:00
readerkid 34f8c670f5 Cauldron clicks predict the cauldron, not the held bucket's use
Clicking a cauldron predicted nothing: the remote useItemOn dispatch had no
cauldron arm, so the click fell through to Item.use while the hand still held
the water bucket. BucketItem.use then emptied the bucket into the face that was
aimed at, which is the water block that showed for a couple of frames before
the server's cauldron state arrived, and the water left in the air when a full
cauldron was emptied into the bucket.

remote_cauldron_reacts now runs AbstractCauldronBlock.useItemOn/CauldronInteractions
on this client's level through a dry copy of the hand, like the host's
use_on_cauldron: the cauldron's new state and the sound it made are predicted
when the item is one the cauldron takes, and a Pass still leaves Item.use its
turn. Cauldrons are also hand operated blocks, so a guest's predicted cauldron
use forwards its sound to the other players the way doors and levers do.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
2026-10-31 14:06:31 -07:00
729 changed files with 94488 additions and 6307 deletions

No files matched your search

+5
View File
@@ -4,3 +4,8 @@ docs/launcher-commands.md text eol=lf
docs/horizon-ctl-commands.md text eol=lf
crates/horizon-theme/tokens.css text eol=lf
docs/api/*-reference.md text eol=lf
# Generated by `cargo run -p xtask -- mod-api`; `--check` compares them byte for byte.
crates/horizon-client/src/mod_api/generated.rs linguist-generated=true text eol=lf
crates/horizon-client/src/mod_api/generated_host.rs linguist-generated=true text eol=lf
crates/horizon-client/src/game/mod_api_host_generated.rs linguist-generated=true text eol=lf
crates/horizon-client-render/src/gfx/native_render/generated.rs linguist-generated=true text eol=lf
+61
View File
@@ -0,0 +1,61 @@
name: pack load bench
# How long resource packs take to load: crates/horizon-client-render/examples/pack_load_bench.rs on generated packs
# (no game files), on the branch and then on `base` with the branch's copy of the bench, on the same runner, so a pull
# request can show its numbers next to main's. Started by hand, or by a push to a `pack-bench/*` branch (a fork can't
# start a workflow its main lacks), against horizon-rust/horizon's main.
on:
push:
branches: ['pack-bench/**']
workflow_dispatch:
inputs:
base:
description: Ref to compare against
default: main
scale:
description: How many times the default pack sizes
default: '1'
permissions:
contents: read
env:
HORIZON_TELEMETRY_URL: "off"
CARGO_TERM_COLOR: never
jobs:
bench:
runs-on: ubuntu-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install Rust and the ALSA and udev headers
run: |
sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
rustup toolchain install stable --profile minimal
- uses: Swatinem/rust-cache@v2
with:
shared-key: pack-bench
- name: Bench the branch
env:
SCALE: ${{ inputs.scale || '1' }}
run: |
cargo run --profile fast -p horizon-client-render --example pack_load_bench -- --scale "$SCALE" > head.md
- name: Bench the base, with the branch's bench
env:
BASE: ${{ inputs.base || 'main' }}
SCALE: ${{ inputs.scale || '1' }}
run: |
git fetch https://github.com/horizon-rust/horizon.git "$BASE"
git worktree add ../base FETCH_HEAD
cp crates/horizon-client-render/examples/pack_load_bench.rs ../base/crates/horizon-client-render/examples/
# A bench row that times a function the base lacks keeps it from building there: then
# the branch's own rows compare the two ways.
(cd ../base && CARGO_TARGET_DIR="$GITHUB_WORKSPACE/target" cargo run --profile fast -p horizon-client-render \
--example pack_load_bench -- --scale "$SCALE") > base.md \
|| echo "The bench does not build on the base." > base.md
- name: Summary
run: |
{
echo "### Branch ($(git rev-parse --short HEAD))"; cat head.md
echo; echo "### Base (${{ inputs.base || 'main' }})"; cat base.md
} | tee -a "$GITHUB_STEP_SUMMARY"
+2 -1
View File
@@ -30,6 +30,7 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
change), none for a fix. Agents never add `release-now`: the maintainer decides it. Merges ship in batches of 5, or after
6 hours ([docs/release-flow.md](docs/release-flow.md)).
- Contributors first: `gh pr list --state open --search <area>` before you start; never duplicate theirs.
- **No hardcoded user-facing strings.** Keep UI translation-ready from the start. In the launcher (`launcher/`), wrap visible text with `tr("...")` or `trf("... {arg} ...", &[("arg", &val)])` from `crate::i18n` instead of raw string literals or `format!`. In the game, use translation keys (`Text::tr`, `lang::get`), never hardcoded English strings.
- **Claim before you start**: comment `/claim` on the issue (no issue yet: open one, then claim it). An assigned
issue is taken: pick another. `/unclaim` when you stop; a claim with no open PR linking it is released after 5 days.
@@ -158,7 +159,7 @@ state stays separate from the library tests. This compiles gameplay twice for ex
| Save and load | L: `save.rs` (level.dat, regions, playerdata), `entity_nbt.rs`, `options_file.rs`; `horizon-client-core/src/block_entity_nbt.rs`; G: `saving.rs`, `persist.rs` (entities); `crates/horizon-world/src/{region.rs,nbt.rs,datafix/}` |
| Input | game.rs `fn window_event`, `fn key_pressed`, `fn play_mouse`; crate `horizon-client-input` (`keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `clipboard.rs`); L: `osk.rs`; G: `pad.rs`, `pad_menu.rs` |
| Audio | L: `audio.rs` (mixer), `sound_engine.rs`, `sound_events.rs`, `music.rs`, `ambience.rs`; G: `music.rs`, `subtitle_hud.rs` |
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`; `horizon-client-core/src/lang.rs`; crate `horizon-assets`; G: `packs.rs` |
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`, `mobs/cem.rs` + `mobs/cem_anim.rs` (OptiFine/EMF entity models and their animations); `horizon-client-core/src/lang.rs`; crates `horizon-assets`, `horizon-optifine`; G: `packs.rs` |
| Agent control | L: `control.rs` (socket), `bin/horizon-ctl/`; G: `control.rs`, `agent.rs`, `events.rs`, `ui_control.rs` ([docs/agent-control.md](docs/agent-control.md)) |
| Mods | crate `horizon-mods`; G: `mods.rs`; L: `mod_content.rs`, `mod_list.rs` |
| Horizon extras, settings | L: `extras.rs`, `graphics.rs`, `settings.rs`, `minimap.rs`; G: `extras.rs`, `tweaks.rs`, `options.rs` |
Generated
+7
View File
@@ -1324,6 +1324,7 @@ dependencies = [
"horizon-items",
"horizon-mods",
"horizon-narrator",
"horizon-optifine",
"horizon-protocol",
"horizon-server-files",
"horizon-shader-api",
@@ -1451,6 +1452,7 @@ dependencies = [
"horizon-hdr",
"horizon-items",
"horizon-narrator",
"horizon-optifine",
"horizon-protocol",
"horizon-shader-api",
"horizon-shaders",
@@ -1572,6 +1574,7 @@ name = "horizon-optifine"
version = "0.1.0"
dependencies = [
"horizon-bench",
"log",
"regex",
]
@@ -1604,6 +1607,7 @@ dependencies = [
name = "horizon-protocol"
version = "0.1.0"
dependencies = [
"aes",
"flate2",
"getrandom 0.3.4",
"horizon-core",
@@ -5116,6 +5120,9 @@ name = "xtask"
version = "0.1.0"
dependencies = [
"horizon-theme",
"quote",
"serde_json",
"syn 2.0.119",
]
[[package]]
+49
View File
@@ -66,3 +66,52 @@ with Microsoft accounts
The four-square Microsoft symbol in `launcher/src/microsoft.rs` reproduces the squares, positions and
colours of Microsoft's official `ms-symbollockup_mssymbol_19.svg` from that page, unmodified. Microsoft
and its logo are trademarks of the Microsoft group of companies; Horizon is not endorsed by Microsoft.
## CIT Resewn
`crates/horizon-optifine/src/cit.rs` and `crates/horizon-client-render/src/cit.rs` follow the
condition semantics of [CIT Resewn Continuation](https://github.com/EthanVisagie/CIT-Resewn-Continuation-1.21.11)
(at `b792f9a`), a continuation of [CIT Resewn](https://github.com/SHsuperCM/CITResewn) by SHsuperCM: the
1.21 component paths, enchantment levels, percent damage, text matching and rule ordering. It is
licensed under the MIT license:
> Copyright (c) 2021 SHsuperCM
>
> Permission is hereby granted, free of charge, to any person obtaining a copy of this software
> and associated documentation files (the "Software"), to deal in the Software without
> restriction, including without limitation the rights to use, copy, modify, merge, publish,
> distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
> Software is furnished to do so, subject to the following conditions:
>
> The above copyright notice and this permission notice shall be included in all copies or
> substantial portions of the Software.
>
> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
> BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
> NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
> DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
## FusionExamples
The tile tables in `crates/horizon-optifine/src/fusion/layout.rs` were read off the connecting
texture templates (`templates/template <layout>.png`) of
[FusionExamples](https://github.com/SuperMartijn642/FusionExamples) (at `8c9c56e`), which is
licensed under the MIT license:
> Copyright (c) 2024 Martijn van den Brand
>
> Permission is hereby granted, free of charge, to any person obtaining a copy of this software
> and associated documentation files (the "Software"), to deal in the Software without
> restriction, including without limitation the rights to use, copy, modify, merge, publish,
> distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
> Software is furnished to do so, subject to the following conditions:
>
> The above copyright notice and this permission notice shall be included in all copies or
> substantial portions of the Software.
>
> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
> BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
> NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
> DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,758 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-11-01T22:55:52Z",
"app": {
"name": "horizon-client",
"version": "0.1.0",
"commit": "1bd24e4042"
},
"hardware": {
"os": "macos",
"arch": "aarch64",
"cpu_threads": 14,
"os_version": "27.0.1",
"cpu": "Apple M4 Pro",
"ram_mb": 65536,
"gpu": {
"name": "Apple M4 Pro",
"vendor": "",
"driver": "",
"backend": "metal",
"kind": "integrated"
}
},
"conditions": {
"scenario": "perf_bench:village,forest,ocean,mountain",
"seed": "1234",
"render_distance": 16,
"simulation_distance": 12,
"vsync": false,
"offscreen": true,
"repeats": 3,
"warmup_s": 2.0,
"load_avg_1m": 113.978,
"window": [
1920,
1080
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 1524.333,
"stdev": 364.142,
"min": 1112.127,
"max": 1802.295,
"ci95": 904.651
},
"startup.world_ms": {
"n": 3,
"mean": 14001.774,
"stdev": 8119.623,
"min": 7466.904,
"max": 23091.569,
"ci95": 20171.889
},
"chunks.village.load_ms": {
"n": 3,
"mean": 8159.696,
"stdev": 3897.327,
"min": 5313.528,
"max": 12601.663,
"ci95": 9682.279
},
"chunks.village.meshed_count": {
"n": 3,
"mean": 25431.333,
"stdev": 1389.728,
"min": 24149.0,
"max": 26908.0,
"ci95": 3452.555
},
"fps.village.avg_fps": {
"n": 3,
"mean": 568.391,
"stdev": 11.948,
"min": 558.517,
"max": 581.673,
"ci95": 29.684
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 270.644,
"stdev": 9.955,
"min": 259.156,
"max": 276.728,
"ci95": 24.732
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 209.951,
"stdev": 30.24,
"min": 191.819,
"max": 244.86,
"ci95": 75.126
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 8879.707,
"stdev": 1758.259,
"min": 7205.686,
"max": 10711.562,
"ci95": 4368.11
},
"chunks.forest.meshed_count": {
"n": 3,
"mean": 29366.667,
"stdev": 273.35,
"min": 29053.0,
"max": 29554.0,
"ci95": 679.094
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 333.026,
"stdev": 1.469,
"min": 331.478,
"max": 334.4,
"ci95": 3.649
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 193.014,
"stdev": 11.504,
"min": 185.57,
"max": 206.264,
"ci95": 28.58
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 137.067,
"stdev": 28.701,
"min": 108.337,
"max": 165.738,
"ci95": 71.302
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 6399.435,
"stdev": 2268.592,
"min": 4346.111,
"max": 8834.786,
"ci95": 5635.949
},
"chunks.ocean.meshed_count": {
"n": 3,
"mean": 25436.0,
"stdev": 813.154,
"min": 24718.0,
"max": 26319.0,
"ci95": 2020.149
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 411.986,
"stdev": 1.42,
"min": 410.981,
"max": 413.61,
"ci95": 3.527
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 209.307,
"stdev": 10.427,
"min": 197.391,
"max": 216.759,
"ci95": 25.905
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 126.88,
"stdev": 17.642,
"min": 106.534,
"max": 137.93,
"ci95": 43.829
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 14672.976,
"stdev": 9832.9,
"min": 8230.723,
"max": 25990.951,
"ci95": 24428.25
},
"chunks.mountain.meshed_count": {
"n": 3,
"mean": 27715.0,
"stdev": 1706.033,
"min": 26572.0,
"max": 29676.0,
"ci95": 4238.362
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 357.149,
"stdev": 2.391,
"min": 355.009,
"max": 359.73,
"ci95": 5.941
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 199.658,
"stdev": 12.172,
"min": 187.504,
"max": 211.847,
"ci95": 30.238
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 154.465,
"stdev": 13.484,
"min": 144.923,
"max": 169.891,
"ci95": 33.498
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 1892.0,
"stdev": 49.025,
"min": 1835.406,
"max": 1921.438,
"ci95": 121.794
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 1616.755,
"stdev": 47.256,
"min": 1564.734,
"max": 1657.031,
"ci95": 117.4
},
"memory.footprint_peak_mb": {
"n": 3,
"mean": 3083.815,
"stdev": 25.002,
"min": 3062.456,
"max": 3111.315,
"ci95": 62.113
},
"memory.footprint_steady_mb": {
"n": 3,
"mean": 2495.271,
"stdev": 36.569,
"min": 2453.284,
"max": 2520.151,
"ci95": 90.85
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 6981,
"seconds": 12.002,
"avg_fps": 581.673,
"low_1pct_fps": 276.049,
"low_01pct_fps": 191.819,
"p50_ms": 1.655,
"p95_ms": 2.267,
"p99_ms": 3.277,
"p999_ms": 4.174,
"max_ms": 7.418,
"cpu_p50_ms": 0.955,
"gpu_p50_ms": 1.646,
"histogram": [
0,
6445,
353,
176,
3,
3,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 1,
"frames": 6703,
"seconds": 12.001,
"avg_fps": 558.517,
"low_1pct_fps": 276.728,
"low_01pct_fps": 244.86,
"p50_ms": 1.725,
"p95_ms": 2.341,
"p99_ms": 3.396,
"p999_ms": 3.939,
"max_ms": 4.185,
"cpu_p50_ms": 0.767,
"gpu_p50_ms": 1.708,
"histogram": [
17,
6041,
442,
202,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 6780,
"seconds": 12.0,
"avg_fps": 564.982,
"low_1pct_fps": 259.156,
"low_01pct_fps": 193.174,
"p50_ms": 1.707,
"p95_ms": 2.311,
"p99_ms": 3.407,
"p999_ms": 4.984,
"max_ms": 5.322,
"cpu_p50_ms": 0.923,
"gpu_p50_ms": 1.698,
"histogram": [
5,
6118,
480,
163,
8,
6,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 3978,
"seconds": 12.001,
"avg_fps": 331.478,
"low_1pct_fps": 187.208,
"low_01pct_fps": 137.127,
"p50_ms": 2.93,
"p95_ms": 3.969,
"p99_ms": 4.719,
"p999_ms": 6.451,
"max_ms": 8.835,
"cpu_p50_ms": 0.857,
"gpu_p50_ms": 2.92,
"histogram": [
0,
29,
2279,
1541,
109,
14,
4,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 3999,
"seconds": 12.002,
"avg_fps": 333.199,
"low_1pct_fps": 185.57,
"low_01pct_fps": 108.337,
"p50_ms": 2.921,
"p95_ms": 3.862,
"p99_ms": 4.679,
"p999_ms": 6.817,
"max_ms": 11.683,
"cpu_p50_ms": 0.828,
"gpu_p50_ms": 2.903,
"histogram": [
3,
16,
2356,
1511,
97,
12,
1,
0,
2,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 4013,
"seconds": 12.001,
"avg_fps": 334.4,
"low_1pct_fps": 206.264,
"low_01pct_fps": 165.738,
"p50_ms": 2.915,
"p95_ms": 3.809,
"p99_ms": 4.525,
"p999_ms": 5.078,
"max_ms": 8.228,
"cpu_p50_ms": 0.861,
"gpu_p50_ms": 2.899,
"histogram": [
1,
18,
2357,
1553,
77,
5,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 4932,
"seconds": 12.001,
"avg_fps": 410.981,
"low_1pct_fps": 216.759,
"low_01pct_fps": 137.93,
"p50_ms": 2.353,
"p95_ms": 3.181,
"p99_ms": 4.01,
"p999_ms": 5.767,
"max_ms": 9.713,
"cpu_p50_ms": 0.888,
"gpu_p50_ms": 2.343,
"histogram": [
0,
163,
4474,
264,
23,
4,
1,
2,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 4937,
"seconds": 12.001,
"avg_fps": 411.366,
"low_1pct_fps": 213.772,
"low_01pct_fps": 136.177,
"p50_ms": 2.349,
"p95_ms": 3.258,
"p99_ms": 4.015,
"p999_ms": 5.985,
"max_ms": 8.539,
"cpu_p50_ms": 0.871,
"gpu_p50_ms": 2.339,
"histogram": [
0,
202,
4406,
295,
25,
5,
1,
2,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 4963,
"seconds": 11.999,
"avg_fps": 413.61,
"low_1pct_fps": 197.391,
"low_01pct_fps": 106.534,
"p50_ms": 2.335,
"p95_ms": 3.214,
"p99_ms": 4.111,
"p999_ms": 7.65,
"max_ms": 11.399,
"cpu_p50_ms": 0.878,
"gpu_p50_ms": 2.323,
"histogram": [
2,
228,
4417,
274,
28,
7,
2,
2,
1,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 4317,
"seconds": 12.001,
"avg_fps": 359.73,
"low_1pct_fps": 211.847,
"low_01pct_fps": 169.891,
"p50_ms": 2.7,
"p95_ms": 3.753,
"p99_ms": 4.366,
"p999_ms": 5.041,
"max_ms": 8.726,
"cpu_p50_ms": 1.113,
"gpu_p50_ms": 2.687,
"histogram": [
0,
68,
3445,
718,
80,
5,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 4281,
"seconds": 12.001,
"avg_fps": 356.707,
"low_1pct_fps": 199.624,
"low_01pct_fps": 144.923,
"p50_ms": 2.712,
"p95_ms": 3.806,
"p99_ms": 4.535,
"p999_ms": 5.453,
"max_ms": 8.354,
"cpu_p50_ms": 1.111,
"gpu_p50_ms": 2.694,
"histogram": [
0,
64,
3353,
717,
138,
5,
2,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 4261,
"seconds": 12.003,
"avg_fps": 355.009,
"low_1pct_fps": 187.504,
"low_01pct_fps": 148.582,
"p50_ms": 2.727,
"p95_ms": 4.083,
"p99_ms": 4.744,
"p999_ms": 6.091,
"max_ms": 7.442,
"cpu_p50_ms": 1.126,
"gpu_p50_ms": 2.711,
"histogram": [
0,
155,
3198,
719,
165,
17,
6,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
}
],
"label": "horizon-m4pro-1080p"
}
@@ -0,0 +1,758 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-11-01T23:17:23Z",
"app": {
"name": "horizon-client",
"version": "0.1.0",
"commit": "1bd24e4042"
},
"hardware": {
"os": "macos",
"arch": "aarch64",
"cpu_threads": 14,
"os_version": "27.0.1",
"cpu": "Apple M4 Pro",
"ram_mb": 65536,
"gpu": {
"name": "Apple M4 Pro",
"vendor": "",
"driver": "",
"backend": "metal",
"kind": "integrated"
}
},
"conditions": {
"scenario": "perf_bench:village,forest,ocean,mountain",
"seed": "1234",
"render_distance": 32,
"simulation_distance": 12,
"vsync": false,
"offscreen": true,
"repeats": 3,
"warmup_s": 2.0,
"load_avg_1m": 28.236,
"window": [
1920,
1080
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 2190.186,
"stdev": 1780.667,
"min": 906.481,
"max": 4223.028,
"ci95": 4423.779
},
"startup.world_ms": {
"n": 3,
"mean": 14254.027,
"stdev": 4604.903,
"min": 10130.899,
"max": 19223.339,
"ci95": 11440.138
},
"chunks.village.load_ms": {
"n": 3,
"mean": 6509.117,
"stdev": 1616.283,
"min": 5457.214,
"max": 8370.17,
"ci95": 4015.394
},
"chunks.village.meshed_count": {
"n": 3,
"mean": 48522.333,
"stdev": 1713.943,
"min": 46732.0,
"max": 50148.0,
"ci95": 4258.014
},
"fps.village.avg_fps": {
"n": 3,
"mean": 326.473,
"stdev": 6.811,
"min": 319.588,
"max": 333.208,
"ci95": 16.921
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 152.497,
"stdev": 34.142,
"min": 116.575,
"max": 184.525,
"ci95": 84.82
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 122.567,
"stdev": 33.914,
"min": 87.235,
"max": 154.858,
"ci95": 84.254
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 10363.339,
"stdev": 3122.178,
"min": 6760.012,
"max": 12265.121,
"ci95": 7756.547
},
"chunks.forest.meshed_count": {
"n": 3,
"mean": 82591.667,
"stdev": 1489.69,
"min": 81053.0,
"max": 84027.0,
"ci95": 3700.895
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 165.414,
"stdev": 4.368,
"min": 162.757,
"max": 170.455,
"ci95": 10.851
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 103.992,
"stdev": 20.41,
"min": 83.527,
"max": 124.346,
"ci95": 50.705
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 94.785,
"stdev": 24.344,
"min": 69.796,
"max": 118.428,
"ci95": 60.478
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 10450.642,
"stdev": 2599.832,
"min": 7510.454,
"max": 12445.71,
"ci95": 6458.863
},
"chunks.ocean.meshed_count": {
"n": 3,
"mean": 76405.667,
"stdev": 649.936,
"min": 75681.0,
"max": 76937.0,
"ci95": 1614.662
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 210.179,
"stdev": 5.279,
"min": 205.559,
"max": 215.932,
"ci95": 13.114
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 128.777,
"stdev": 30.982,
"min": 93.017,
"max": 147.571,
"ci95": 76.97
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 115.916,
"stdev": 39.398,
"min": 70.443,
"max": 139.838,
"ci95": 97.879
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 17168.619,
"stdev": 3613.606,
"min": 13749.902,
"max": 20949.796,
"ci95": 8977.42
},
"chunks.mountain.meshed_count": {
"n": 3,
"mean": 91622.667,
"stdev": 482.189,
"min": 91089.0,
"max": 92027.0,
"ci95": 1197.921
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 190.088,
"stdev": 2.175,
"min": 187.78,
"max": 192.099,
"ci95": 5.403
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 108.92,
"stdev": 14.244,
"min": 92.641,
"max": 119.094,
"ci95": 35.388
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 91.132,
"stdev": 12.82,
"min": 76.343,
"max": 99.09,
"ci95": 31.849
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 2536.536,
"stdev": 40.31,
"min": 2506.281,
"max": 2582.297,
"ci95": 100.144
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 2195.922,
"stdev": 37.757,
"min": 2167.078,
"max": 2238.656,
"ci95": 93.8
},
"memory.footprint_peak_mb": {
"n": 3,
"mean": 5559.837,
"stdev": 47.18,
"min": 5505.457,
"max": 5589.863,
"ci95": 117.211
},
"memory.footprint_steady_mb": {
"n": 3,
"mean": 4373.034,
"stdev": 82.743,
"min": 4311.909,
"max": 4467.191,
"ci95": 205.561
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 3920,
"seconds": 12.002,
"avg_fps": 326.623,
"low_1pct_fps": 116.575,
"low_01pct_fps": 87.235,
"p50_ms": 2.938,
"p95_ms": 4.356,
"p99_ms": 7.397,
"p999_ms": 9.497,
"max_ms": 13.274,
"cpu_p50_ms": 1.611,
"gpu_p50_ms": 2.871,
"histogram": [
0,
115,
2297,
1271,
149,
33,
10,
24,
18,
1,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 1,
"frames": 3835,
"seconds": 12.0,
"avg_fps": 319.588,
"low_1pct_fps": 156.39,
"low_01pct_fps": 125.608,
"p50_ms": 3.04,
"p95_ms": 4.322,
"p99_ms": 5.265,
"p999_ms": 7.732,
"max_ms": 8.183,
"cpu_p50_ms": 1.733,
"gpu_p50_ms": 2.936,
"histogram": [
0,
1,
1610,
2006,
142,
51,
15,
10,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 3999,
"seconds": 12.002,
"avg_fps": 333.208,
"low_1pct_fps": 184.525,
"low_01pct_fps": 154.858,
"p50_ms": 2.9,
"p95_ms": 4.01,
"p99_ms": 4.955,
"p999_ms": 6.193,
"max_ms": 6.805,
"cpu_p50_ms": 1.51,
"gpu_p50_ms": 2.788,
"histogram": [
0,
8,
2499,
1328,
132,
25,
7,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 1957,
"seconds": 12.004,
"avg_fps": 163.03,
"low_1pct_fps": 83.527,
"low_01pct_fps": 69.796,
"p50_ms": 6.011,
"p95_ms": 8.032,
"p99_ms": 10.903,
"p999_ms": 14.322,
"max_ms": 14.333,
"cpu_p50_ms": 1.555,
"gpu_p50_ms": 5.857,
"histogram": [
0,
5,
26,
16,
36,
883,
739,
194,
29,
22,
5,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 1954,
"seconds": 12.006,
"avg_fps": 162.757,
"low_1pct_fps": 104.102,
"low_01pct_fps": 96.13,
"p50_ms": 5.98,
"p95_ms": 7.665,
"p99_ms": 9.049,
"p999_ms": 10.323,
"max_ms": 10.482,
"cpu_p50_ms": 1.584,
"gpu_p50_ms": 5.715,
"histogram": [
0,
0,
0,
1,
9,
1028,
737,
130,
46,
3,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 2046,
"seconds": 12.003,
"avg_fps": 170.455,
"low_1pct_fps": 124.346,
"low_01pct_fps": 118.428,
"p50_ms": 5.719,
"p95_ms": 7.342,
"p99_ms": 7.815,
"p999_ms": 8.38,
"max_ms": 8.506,
"cpu_p50_ms": 1.384,
"gpu_p50_ms": 5.572,
"histogram": [
0,
0,
0,
0,
26,
1452,
396,
169,
3,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 2467,
"seconds": 12.001,
"avg_fps": 205.559,
"low_1pct_fps": 93.017,
"low_01pct_fps": 70.443,
"p50_ms": 4.708,
"p95_ms": 6.6,
"p99_ms": 9.432,
"p999_ms": 13.566,
"max_ms": 15.228,
"cpu_p50_ms": 1.664,
"gpu_p50_ms": 4.483,
"histogram": [
0,
18,
55,
87,
1811,
290,
143,
27,
20,
13,
2,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 2509,
"seconds": 12.002,
"avg_fps": 209.045,
"low_1pct_fps": 145.742,
"low_01pct_fps": 139.838,
"p50_ms": 4.642,
"p95_ms": 6.15,
"p99_ms": 6.72,
"p999_ms": 7.081,
"max_ms": 7.254,
"cpu_p50_ms": 1.74,
"gpu_p50_ms": 4.376,
"histogram": [
0,
0,
0,
78,
2008,
277,
142,
4,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 2591,
"seconds": 11.999,
"avg_fps": 215.932,
"low_1pct_fps": 147.571,
"low_01pct_fps": 137.466,
"p50_ms": 4.466,
"p95_ms": 5.947,
"p99_ms": 6.512,
"p999_ms": 7.262,
"max_ms": 7.283,
"cpu_p50_ms": 1.422,
"gpu_p50_ms": 4.286,
"histogram": [
0,
2,
0,
111,
2114,
248,
111,
5,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 2285,
"seconds": 12.002,
"avg_fps": 190.386,
"low_1pct_fps": 92.641,
"low_01pct_fps": 76.343,
"p50_ms": 5.038,
"p95_ms": 7.005,
"p99_ms": 9.383,
"p999_ms": 11.731,
"max_ms": 15.266,
"cpu_p50_ms": 2.239,
"gpu_p50_ms": 4.915,
"histogram": [
0,
0,
54,
45,
916,
985,
168,
78,
22,
15,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 2306,
"seconds": 12.004,
"avg_fps": 192.099,
"low_1pct_fps": 119.094,
"low_01pct_fps": 99.09,
"p50_ms": 5.017,
"p95_ms": 6.58,
"p99_ms": 7.586,
"p999_ms": 9.195,
"max_ms": 11.428,
"cpu_p50_ms": 2.261,
"gpu_p50_ms": 4.823,
"histogram": [
0,
0,
3,
12,
1070,
1003,
165,
43,
9,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 2254,
"seconds": 12.003,
"avg_fps": 187.78,
"low_1pct_fps": 115.026,
"low_01pct_fps": 97.962,
"p50_ms": 5.175,
"p95_ms": 6.791,
"p99_ms": 7.916,
"p999_ms": 9.284,
"max_ms": 11.986,
"cpu_p50_ms": 2.158,
"gpu_p50_ms": 4.942,
"histogram": [
0,
0,
0,
17,
774,
1199,
182,
69,
12,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
}
],
"label": "horizon-m4pro-1080p"
}
@@ -0,0 +1,758 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-11-01T22:55:52Z",
"app": {
"name": "horizon-client",
"version": "0.1.0",
"commit": "1bd24e4042"
},
"hardware": {
"os": "macos",
"arch": "aarch64",
"cpu_threads": 14,
"os_version": "27.0.1",
"cpu": "Apple M4 Pro",
"ram_mb": 65536,
"gpu": {
"name": "Apple M4 Pro",
"vendor": "",
"driver": "",
"backend": "metal",
"kind": "integrated"
}
},
"conditions": {
"scenario": "perf_bench:village,forest,ocean,mountain",
"seed": "1234",
"render_distance": 8,
"simulation_distance": 12,
"vsync": false,
"offscreen": true,
"repeats": 3,
"warmup_s": 2.0,
"load_avg_1m": 73.292,
"window": [
1920,
1080
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 7204.239,
"stdev": 1586.64,
"min": 5409.683,
"max": 8421.051,
"ci95": 3941.749
},
"startup.world_ms": {
"n": 3,
"mean": 11407.51,
"stdev": 2085.692,
"min": 9027.004,
"max": 12913.993,
"ci95": 5181.563
},
"chunks.village.load_ms": {
"n": 3,
"mean": 5116.146,
"stdev": 744.611,
"min": 4548.096,
"max": 5959.13,
"ci95": 1849.865
},
"chunks.village.meshed_count": {
"n": 3,
"mean": 7220.0,
"stdev": 137.292,
"min": 7092.0,
"max": 7365.0,
"ci95": 341.079
},
"fps.village.avg_fps": {
"n": 3,
"mean": 540.213,
"stdev": 182.263,
"min": 432.911,
"max": 750.659,
"ci95": 452.803
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 195.561,
"stdev": 75.888,
"min": 141.859,
"max": 282.38,
"ci95": 188.533
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 134.559,
"stdev": 74.125,
"min": 87.663,
"max": 220.016,
"ci95": 184.153
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 7185.67,
"stdev": 4093.192,
"min": 4323.79,
"max": 11874.133,
"ci95": 10168.874
},
"chunks.forest.meshed_count": {
"n": 3,
"mean": 8302.667,
"stdev": 538.894,
"min": 7853.0,
"max": 8900.0,
"ci95": 1338.794
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 364.068,
"stdev": 8.947,
"min": 355.173,
"max": 373.067,
"ci95": 22.229
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 145.064,
"stdev": 21.824,
"min": 121.995,
"max": 165.382,
"ci95": 54.218
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 97.299,
"stdev": 15.364,
"min": 81.075,
"max": 111.627,
"ci95": 38.17
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 4944.497,
"stdev": 1598.757,
"min": 3816.976,
"max": 6774.175,
"ci95": 3971.854
},
"chunks.ocean.meshed_count": {
"n": 3,
"mean": 7041.0,
"stdev": 276.949,
"min": 6821.0,
"max": 7352.0,
"ci95": 688.036
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 410.281,
"stdev": 35.314,
"min": 370.881,
"max": 439.08,
"ci95": 87.731
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 161.057,
"stdev": 15.769,
"min": 144.003,
"max": 175.109,
"ci95": 39.175
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 111.852,
"stdev": 16.415,
"min": 93.045,
"max": 123.299,
"ci95": 40.78
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 8844.497,
"stdev": 4291.678,
"min": 5269.587,
"max": 13604.075,
"ci95": 10661.98
},
"chunks.mountain.meshed_count": {
"n": 3,
"mean": 7381.0,
"stdev": 88.606,
"min": 7290.0,
"max": 7467.0,
"ci95": 220.127
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 466.294,
"stdev": 2.738,
"min": 464.707,
"max": 469.455,
"ci95": 6.801
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 164.71,
"stdev": 21.73,
"min": 141.437,
"max": 184.468,
"ci95": 53.984
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 90.643,
"stdev": 14.864,
"min": 76.851,
"max": 106.387,
"ci95": 36.928
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 1536.969,
"stdev": 19.205,
"min": 1516.188,
"max": 1554.063,
"ci95": 47.711
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 1379.422,
"stdev": 9.712,
"min": 1371.438,
"max": 1390.234,
"ci95": 24.129
},
"memory.footprint_peak_mb": {
"n": 3,
"mean": 2093.701,
"stdev": 13.714,
"min": 2078.862,
"max": 2105.909,
"ci95": 34.07
},
"memory.footprint_steady_mb": {
"n": 3,
"mean": 1883.495,
"stdev": 10.346,
"min": 1871.987,
"max": 1892.026,
"ci95": 25.702
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 9008,
"seconds": 12.0,
"avg_fps": 750.659,
"low_1pct_fps": 282.38,
"low_01pct_fps": 220.016,
"p50_ms": 1.047,
"p95_ms": 2.169,
"p99_ms": 3.163,
"p999_ms": 4.077,
"max_ms": 5.462,
"cpu_p50_ms": 0.447,
"gpu_p50_ms": 1.021,
"histogram": [
3979,
3877,
1033,
112,
6,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 1,
"frames": 5245,
"seconds": 12.0,
"avg_fps": 437.07,
"low_1pct_fps": 141.859,
"low_01pct_fps": 87.663,
"p50_ms": 1.822,
"p95_ms": 4.165,
"p99_ms": 5.399,
"p999_ms": 10.052,
"max_ms": 12.712,
"cpu_p50_ms": 0.652,
"gpu_p50_ms": 1.943,
"histogram": [
831,
1968,
606,
1582,
154,
82,
3,
3,
10,
4,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 5195,
"seconds": 12.0,
"avg_fps": 432.911,
"low_1pct_fps": 162.445,
"low_01pct_fps": 95.997,
"p50_ms": 1.806,
"p95_ms": 4.202,
"p99_ms": 5.259,
"p999_ms": 7.591,
"max_ms": 17.328,
"cpu_p50_ms": 0.636,
"gpu_p50_ms": 1.876,
"histogram": [
297,
2502,
650,
1469,
192,
73,
6,
1,
3,
1,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 4262,
"seconds": 12.0,
"avg_fps": 355.173,
"low_1pct_fps": 165.382,
"low_01pct_fps": 111.627,
"p50_ms": 2.917,
"p95_ms": 3.854,
"p99_ms": 5.022,
"p999_ms": 7.808,
"max_ms": 9.769,
"cpu_p50_ms": 0.59,
"gpu_p50_ms": 2.91,
"histogram": [
98,
416,
2506,
1080,
118,
29,
10,
1,
4,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 4477,
"seconds": 12.001,
"avg_fps": 373.067,
"low_1pct_fps": 121.995,
"low_01pct_fps": 81.075,
"p50_ms": 2.243,
"p95_ms": 4.687,
"p99_ms": 5.835,
"p999_ms": 11.831,
"max_ms": 13.367,
"cpu_p50_ms": 0.541,
"gpu_p50_ms": 2.346,
"histogram": [
183,
1975,
392,
887,
903,
99,
12,
6,
11,
6,
3,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 4368,
"seconds": 12.001,
"avg_fps": 363.964,
"low_1pct_fps": 147.816,
"low_01pct_fps": 99.196,
"p50_ms": 2.264,
"p95_ms": 4.763,
"p99_ms": 5.86,
"p999_ms": 7.641,
"max_ms": 11.427,
"cpu_p50_ms": 0.562,
"gpu_p50_ms": 2.203,
"histogram": [
73,
1978,
472,
695,
1004,
104,
35,
3,
1,
3,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 4451,
"seconds": 12.001,
"avg_fps": 370.881,
"low_1pct_fps": 175.109,
"low_01pct_fps": 119.212,
"p50_ms": 2.858,
"p95_ms": 4.279,
"p99_ms": 5.045,
"p999_ms": 6.057,
"max_ms": 10.866,
"cpu_p50_ms": 0.609,
"gpu_p50_ms": 2.922,
"histogram": [
193,
1245,
964,
1732,
270,
42,
2,
0,
1,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 5269,
"seconds": 12.0,
"avg_fps": 439.08,
"low_1pct_fps": 144.003,
"low_01pct_fps": 93.045,
"p50_ms": 1.767,
"p95_ms": 4.188,
"p99_ms": 5.191,
"p999_ms": 10.375,
"max_ms": 11.271,
"cpu_p50_ms": 0.545,
"gpu_p50_ms": 1.815,
"histogram": [
1239,
1550,
644,
1555,
201,
59,
1,
4,
10,
6,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 5051,
"seconds": 12.001,
"avg_fps": 420.881,
"low_1pct_fps": 164.058,
"low_01pct_fps": 123.299,
"p50_ms": 1.872,
"p95_ms": 4.455,
"p99_ms": 5.38,
"p999_ms": 6.923,
"max_ms": 11.189,
"cpu_p50_ms": 0.548,
"gpu_p50_ms": 1.874,
"histogram": [
519,
2114,
673,
1086,
577,
65,
13,
3,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 5635,
"seconds": 12.003,
"avg_fps": 469.455,
"low_1pct_fps": 184.468,
"low_01pct_fps": 106.387,
"p50_ms": 1.626,
"p95_ms": 3.874,
"p99_ms": 4.614,
"p999_ms": 5.913,
"max_ms": 18.185,
"cpu_p50_ms": 0.564,
"gpu_p50_ms": 1.604,
"histogram": [
1491,
1599,
704,
1688,
128,
20,
1,
2,
0,
1,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 5578,
"seconds": 12.003,
"avg_fps": 464.72,
"low_1pct_fps": 141.437,
"low_01pct_fps": 76.851,
"p50_ms": 1.587,
"p95_ms": 4.127,
"p99_ms": 5.085,
"p999_ms": 10.138,
"max_ms": 21.644,
"cpu_p50_ms": 0.588,
"gpu_p50_ms": 1.625,
"histogram": [
1321,
1798,
770,
1453,
173,
41,
2,
5,
7,
6,
1,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 5576,
"seconds": 11.999,
"avg_fps": 464.707,
"low_1pct_fps": 168.226,
"low_01pct_fps": 88.692,
"p50_ms": 1.627,
"p95_ms": 4.154,
"p99_ms": 4.908,
"p999_ms": 9.555,
"max_ms": 14.784,
"cpu_p50_ms": 0.595,
"gpu_p50_ms": 1.64,
"histogram": [
1054,
2049,
837,
1377,
217,
32,
3,
0,
2,
4,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
}
],
"label": "horizon-m4pro-1080p"
}
@@ -0,0 +1,68 @@
# Horizon main vs vanilla 26.3 on a loaded Mac mini M4 Pro (2026-11-01)
`tools/perf_bench.py` reports for Horizon main 1bd24e4042, vanilla 26.3 and vanilla with performance mods, render distances
8, 16 and 32, three games each, every game from a fresh world. **These are not player numbers:** the Mac mini was running other
work the whole time (1-minute load average 15-114 on 14 cores), and every game rendered without presenting. They are kept because
they're the first Mac reports and show how each game behaves under heavy CPU contention. The reports are the
`*-m4pro-1080p-rd*.json` files next to this page.
## Setup
- **Machine:** Mac mini, Apple M4 Pro (10 performance + 4 efficiency cores, 20-core GPU), 64 GB, macOS 27.0.1. Screen
1920x1080; the session was locked, so every game ran `offscreen` (Horizon renders into a texture; vanilla draws into a
window nobody sees).
- **Horizon:** main 1bd24e4042, `cargo build --release -p horizon-client`, Metal.
- **Vanilla:** the 26.3 client jar with Fabric Loader and the driver mod, Java 25 (Temurin), `-Xmx2G` with the launcher's G1
flags, OpenGL 4.1 (Apple's Metal-backed driver).
- **Vanilla + mods:** the same with Sodium mc26.3-0.9.2, Lithium mc26.3-0.26.2, FerriteCore 9.0.0, ImmediatelyFast 1.17.1 and
Fabric API (`tools/benchmark-deps/mods.lock.json`).
- **Order:** the games took turns inside each repeat (vanilla, mods, Horizon; then mods, Horizon, vanilla; then Horizon, vanilla,
mods), one game at a time.
## Why these numbers aren't a fair comparison
- **Load.** Load before each game: RD 8 46-82, RD 16 43-114, RD 32 15-59 (the later RD 32 games ran at 15-30). Horizon raises its
render and server threads to the user-interactive QoS class, and the JVM's threads stay at the default, so under this
contention macOS runs Horizon's frame first. Vanilla's 1% lows of 2-15 FPS show that, more than how fast either game is.
- **Resolution.** Vanilla's 1920x1080 window was shrunk to **1920x930** by the 1080p screen (menu and title bars); Horizon,
offscreen, rendered 1920x1080. Vanilla drew 14% fewer pixels, which favours it in GPU-bound scenes. Because the recorded
windows differ, `perf_bench.py --table` refuses to compare these reports. The tables below were made with the window check
skipped. A rerun at a size that fits the screen (1600x900) would avoid it.
## Results
Mean of 3 games ± 95% confidence interval. FPS over the 12-second flight (average / 1% low / 0.1% low):
| RD | scene | Horizon main | Vanilla 26.3 | Vanilla + mods |
| --- | --- | --- | --- | --- |
| 8 | forest | 364 ±22 / 145 ±54 / 97 ±38 | 66 ±22 / 8 ±10 / 6 ±8 | 47 ±42 / 4 ±2 / 3 ±1 |
| 8 | mountain | 466 ±7 / 165 ±54 / 91 ±37 | 46 ±37 / 5 ±7 / 4 ±6 | 67 ±35 / 8 ±5 / 4 ±4 |
| 8 | ocean | 410 ±88 / 161 ±39 / 112 ±41 | 50 ±44 / 6 ±13 / 5 ±11 | 35 ±16 / 5 ±4 / 4 ±4 |
| 8 | village | 540 ±453 / 196 ±189 / 135 ±184 | 42 ±16 / 4 ±3 / 3 ±0 | 33 ±46 / 4 ±6 / 3 ±4 |
| 16 | forest | 333 ±4 / 193 ±29 / 137 ±71 | 23 ±9 / 2 ±1 / 2 ±1 | 62 ±94 / 10 ±21 / 7 ±14 |
| 16 | mountain | 357 ±6 / 200 ±30 / 154 ±33 | 44 ±12 / 7 ±2 / 4 ±1 | 36 ±44 / 4 ±5 / 3 ±5 |
| 16 | ocean | 412 ±4 / 209 ±26 / 127 ±44 | 51 ±16 / 6 ±8 / 4 ±7 | 75 ±159 / 13 ±33 / 7 ±16 |
| 16 | village | 568 ±30 / 271 ±25 / 210 ±75 | 46 ±110 / 9 ±27 / 7 ±17 | 46 ±45 / 7 ±9 / 5 ±4 |
| 32 | forest | 165 ±11 / 104 ±51 / 95 ±60 | 68 ±122 / 18 ±49 / 14 ±42 | 114 ±132 / 29 ±44 / 21 ±34 |
| 32 | mountain | 190 ±5 / 109 ±35 / 91 ±32 | 29 ±19 / 3 ±2 / 2 ±3 | 57 ±49 / 7 ±2 / 3 ±3 |
| 32 | ocean | 210 ±13 / 129 ±77 / 116 ±98 | 44 ±80 / 5 ±4 / 2 ±1 | 119 ±176 / 32 ±66 / 17 ±37 |
| 32 | village | 326 ±17 / 152 ±85 / 123 ±84 | 81 ±99 / 15 ±20 / 11 ±11 | 92 ±28 / 13 ±6 / 9 ±3 |
Start to title / in-world, mean scene load, RSS steady / peak, physical footprint steady / peak (macOS `footprint`, which on Apple
Silicon includes the game's GPU allocations), load average before the first game:
| RD | Horizon main | Vanilla 26.3 | Vanilla + mods |
| --- | --- | --- | --- |
| 8 | 7.2 / 11.4, 6.5, 1379 / 1537, 1883 / 2094, 73 | 49.9 / 89.1, 18.2, 1811 / 1877, 2635 / 2709, 66 | 56.3 / 97.7, 17.9, 1927 / 2019, 2396 / 2441, 74 |
| 16 | 1.5 / 14.0, 9.5, 1617 / 1892, 2495 / 3084, 114 | 19.4 / 78.9, 33.3, 2431 / 2555, 3450 / 3713, 76 | 23.8 / 76.9, 27.1, 2355 / 2566, 3212 / 3431, 70 |
| 32 | 2.2 / 14.3, 11.1, 2196 / 2537, 4373 / 5560, 28 | 20.6 / 124.2, 44.2, 2838 / 2958, 4265 / 4618, 59 | 13.5 / 63.2, 29.9, 3705 / 4078, 4647 / 5250, 37 |
## What it shows
- Horizon is GPU-bound on the M4 Pro in every scene at every distance: its median GPU time per frame is 1.6-2.5 ms at RD 8 and
2.9-5.7 ms at RD 32, its render thread's CPU time 0.6-2.2 ms.
- In the least-loaded RD 32 games (load 15-22), vanilla + mods matched Horizon's average FPS over the forest (162 vs 163-170) and
the ocean (187 vs 209-216); Horizon's 1% lows were 2.4 times the modded client's there, and 15-17 times over the village and the
mountain.
- Horizon's resident memory is 1.2-1.7 times smaller than both clients'. Its physical footprint, GPU memory included, is level
with vanilla's at RD 32 (4373 vs 4265 MB steady) and above it at peak (5560 vs 4618 MB).
@@ -0,0 +1,707 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-11-01T22:55:52Z",
"app": {
"name": "minecraft",
"version": "26.3",
"mods": [
{
"name": "fabric-loader",
"version": "0.19.5"
}
]
},
"hardware": {
"os": "macos",
"arch": "aarch64",
"cpu_threads": 14,
"os_version": "27.0.1",
"cpu": "Apple M4 Pro",
"ram_mb": 65536,
"gpu": {
"name": "Apple M4 Pro",
"vendor": "Apple",
"driver": "4.1 Metal - 91.7",
"backend": "opengl",
"kind": ""
}
},
"conditions": {
"scenario": "perf_bench:village,forest,ocean,mountain",
"seed": "1234",
"render_distance": 16,
"simulation_distance": 12,
"vsync": false,
"offscreen": true,
"repeats": 3,
"warmup_s": 2.0,
"load_avg_1m": 75.854,
"window": [
1920,
930
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 19419.646,
"stdev": 10653.755,
"min": 8240.178,
"max": 29455.403,
"ci95": 26467.531
},
"startup.world_ms": {
"n": 3,
"mean": 78944.646,
"stdev": 36737.011,
"min": 51495.357,
"max": 120678.403,
"ci95": 91267.16
},
"chunks.village.load_ms": {
"n": 3,
"mean": 21947.683,
"stdev": 10432.774,
"min": 10427.451,
"max": 30758.368,
"ci95": 25918.54
},
"fps.village.avg_fps": {
"n": 3,
"mean": 45.524,
"stdev": 44.352,
"min": 15.749,
"max": 96.497,
"ci95": 110.185
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 9.141,
"stdev": 10.812,
"min": 1.86,
"max": 21.565,
"ci95": 26.862
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 6.727,
"stdev": 6.902,
"min": 1.785,
"max": 14.612,
"ci95": 17.146
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 39062.839,
"stdev": 21806.234,
"min": 15037.908,
"max": 57603.235,
"ci95": 54174.061
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 23.347,
"stdev": 3.693,
"min": 20.154,
"max": 27.392,
"ci95": 9.175
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 2.419,
"stdev": 0.269,
"min": 2.196,
"max": 2.718,
"ci95": 0.669
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 1.972,
"stdev": 0.438,
"min": 1.551,
"max": 2.425,
"ci95": 1.088
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 22097.713,
"stdev": 4463.742,
"min": 18053.319,
"max": 26887.066,
"ci95": 11089.445
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 51.279,
"stdev": 6.309,
"min": 44.786,
"max": 57.386,
"ci95": 15.673
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 6.15,
"stdev": 3.128,
"min": 3.444,
"max": 9.574,
"ci95": 7.77
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 4.161,
"stdev": 2.739,
"min": 2.487,
"max": 7.321,
"ci95": 6.803
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 50257.39,
"stdev": 34798.619,
"min": 25919.598,
"max": 90115.595,
"ci95": 86451.54
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 44.346,
"stdev": 4.882,
"min": 39.246,
"max": 48.975,
"ci95": 12.128
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 6.637,
"stdev": 0.69,
"min": 5.888,
"max": 7.246,
"ci95": 1.714
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 4.444,
"stdev": 0.6,
"min": 3.937,
"max": 5.107,
"ci95": 1.491
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 2554.552,
"stdev": 218.163,
"min": 2328.859,
"max": 2764.313,
"ci95": 541.991
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 2430.854,
"stdev": 230.654,
"min": 2186.328,
"max": 2644.531,
"ci95": 573.023
},
"memory.footprint_peak_mb": {
"n": 3,
"mean": 3713.311,
"stdev": 191.61,
"min": 3512.331,
"max": 3893.925,
"ci95": 476.025
},
"memory.footprint_steady_mb": {
"n": 3,
"mean": 3450.061,
"stdev": 76.233,
"min": 3370.253,
"max": 3522.128,
"ci95": 189.388
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 189,
"seconds": 12.001,
"avg_fps": 15.749,
"low_1pct_fps": 1.86,
"low_01pct_fps": 1.785,
"p50_ms": 22.219,
"p95_ms": 254.854,
"p99_ms": 515.134,
"p999_ms": 560.079,
"max_ms": 560.079,
"histogram": [
0,
0,
0,
1,
2,
2,
7,
9,
10,
16,
12,
11,
14,
16,
22,
9,
6,
5,
11,
10,
15,
9,
2,
0
]
},
{
"phase": "village",
"repeat": 1,
"frames": 292,
"seconds": 12.004,
"avg_fps": 24.325,
"low_1pct_fps": 3.998,
"low_01pct_fps": 3.783,
"p50_ms": 15.477,
"p95_ms": 165.164,
"p99_ms": 242.898,
"p999_ms": 264.363,
"max_ms": 264.363,
"histogram": [
0,
0,
0,
0,
4,
26,
20,
32,
20,
20,
17,
12,
12,
10,
17,
14,
20,
14,
16,
16,
21,
1,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 1158,
"seconds": 12.0,
"avg_fps": 96.497,
"low_1pct_fps": 21.565,
"low_01pct_fps": 14.612,
"p50_ms": 7.567,
"p95_ms": 22.849,
"p99_ms": 34.806,
"p999_ms": 62.86,
"max_ms": 74.011,
"histogram": [
0,
0,
0,
0,
1,
38,
452,
158,
102,
94,
125,
68,
34,
42,
30,
7,
4,
2,
1,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 271,
"seconds": 12.047,
"avg_fps": 22.496,
"low_1pct_fps": 2.196,
"low_01pct_fps": 1.551,
"p50_ms": 18.263,
"p95_ms": 189.284,
"p99_ms": 317.823,
"p999_ms": 644.675,
"max_ms": 644.675,
"histogram": [
0,
0,
0,
0,
0,
3,
22,
23,
13,
22,
22,
19,
24,
22,
14,
17,
12,
13,
9,
16,
15,
4,
1,
0
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 242,
"seconds": 12.007,
"avg_fps": 20.154,
"low_1pct_fps": 2.343,
"low_01pct_fps": 1.939,
"p50_ms": 19.762,
"p95_ms": 157.949,
"p99_ms": 376.292,
"p999_ms": 515.678,
"max_ms": 515.678,
"histogram": [
0,
0,
0,
0,
0,
13,
17,
16,
26,
19,
11,
9,
10,
11,
9,
16,
17,
12,
21,
19,
9,
6,
1,
0
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 331,
"seconds": 12.084,
"avg_fps": 27.392,
"low_1pct_fps": 2.718,
"low_01pct_fps": 2.425,
"p50_ms": 13.0,
"p95_ms": 162.797,
"p99_ms": 325.827,
"p999_ms": 412.349,
"max_ms": 412.349,
"histogram": [
0,
0,
0,
0,
3,
5,
26,
34,
42,
41,
24,
27,
15,
21,
22,
8,
10,
11,
10,
14,
10,
8,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 620,
"seconds": 12.001,
"avg_fps": 51.664,
"low_1pct_fps": 3.444,
"low_01pct_fps": 2.487,
"p50_ms": 10.358,
"p95_ms": 54.808,
"p99_ms": 181.112,
"p999_ms": 402.152,
"max_ms": 402.152,
"histogram": [
0,
0,
0,
0,
0,
30,
79,
95,
86,
90,
60,
54,
28,
28,
19,
9,
8,
8,
6,
8,
7,
5,
0,
0
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 689,
"seconds": 12.006,
"avg_fps": 57.386,
"low_1pct_fps": 9.574,
"low_01pct_fps": 7.321,
"p50_ms": 12.76,
"p95_ms": 43.737,
"p99_ms": 69.357,
"p999_ms": 136.592,
"max_ms": 136.592,
"histogram": [
0,
0,
0,
0,
0,
23,
46,
80,
80,
92,
68,
74,
54,
49,
51,
24,
22,
16,
6,
4,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 538,
"seconds": 12.013,
"avg_fps": 44.786,
"low_1pct_fps": 5.431,
"low_01pct_fps": 2.674,
"p50_ms": 14.011,
"p95_ms": 71.23,
"p99_ms": 141.423,
"p999_ms": 373.913,
"max_ms": 373.913,
"histogram": [
0,
0,
0,
0,
0,
1,
8,
38,
84,
83,
53,
62,
52,
54,
29,
12,
15,
13,
22,
10,
1,
1,
0,
0
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 588,
"seconds": 12.006,
"avg_fps": 48.975,
"low_1pct_fps": 6.777,
"low_01pct_fps": 3.937,
"p50_ms": 12.678,
"p95_ms": 64.221,
"p99_ms": 108.513,
"p999_ms": 254.008,
"max_ms": 254.008,
"histogram": [
0,
0,
0,
0,
4,
34,
21,
41,
108,
62,
55,
55,
40,
45,
41,
21,
16,
17,
18,
9,
0,
1,
0,
0
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 471,
"seconds": 12.001,
"avg_fps": 39.246,
"low_1pct_fps": 5.888,
"low_01pct_fps": 4.289,
"p50_ms": 17.65,
"p95_ms": 73.314,
"p99_ms": 135.3,
"p999_ms": 233.136,
"max_ms": 233.136,
"histogram": [
0,
0,
0,
0,
3,
12,
22,
29,
36,
40,
43,
38,
41,
46,
62,
29,
20,
19,
22,
5,
4,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 538,
"seconds": 12.004,
"avg_fps": 44.818,
"low_1pct_fps": 7.246,
"low_01pct_fps": 5.107,
"p50_ms": 15.959,
"p95_ms": 63.485,
"p99_ms": 109.639,
"p999_ms": 195.815,
"max_ms": 195.815,
"histogram": [
0,
0,
0,
0,
0,
4,
6,
21,
75,
56,
54,
78,
54,
69,
36,
23,
22,
18,
14,
7,
1,
0,
0,
0
]
}
],
"label": "vanilla-m4pro-1080p"
}
@@ -0,0 +1,707 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-11-01T23:17:23Z",
"app": {
"name": "minecraft",
"version": "26.3",
"mods": [
{
"name": "fabric-loader",
"version": "0.19.5"
}
]
},
"hardware": {
"os": "macos",
"arch": "aarch64",
"cpu_threads": 14,
"os_version": "27.0.1",
"cpu": "Apple M4 Pro",
"ram_mb": 65536,
"gpu": {
"name": "Apple M4 Pro",
"vendor": "Apple",
"driver": "4.1 Metal - 91.7",
"backend": "opengl",
"kind": ""
}
},
"conditions": {
"scenario": "perf_bench:village,forest,ocean,mountain",
"seed": "1234",
"render_distance": 32,
"simulation_distance": 12,
"vsync": false,
"offscreen": true,
"repeats": 3,
"warmup_s": 2.0,
"load_avg_1m": 58.672,
"window": [
1920,
930
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 20579.57,
"stdev": 13060.035,
"min": 10660.541,
"max": 35376.471,
"ci95": 32445.545
},
"startup.world_ms": {
"n": 3,
"mean": 124180.237,
"stdev": 102723.197,
"min": 51510.541,
"max": 241702.471,
"ci95": 255199.163
},
"chunks.village.load_ms": {
"n": 3,
"mean": 23389.087,
"stdev": 5416.249,
"min": 18538.934,
"max": 29233.632,
"ci95": 13455.795
},
"fps.village.avg_fps": {
"n": 3,
"mean": 81.421,
"stdev": 39.944,
"min": 35.32,
"max": 105.706,
"ci95": 99.234
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 15.288,
"stdev": 8.185,
"min": 5.907,
"max": 20.97,
"ci95": 20.334
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 10.544,
"stdev": 4.403,
"min": 5.804,
"max": 14.505,
"ci95": 10.938
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 48382.741,
"stdev": 32005.955,
"min": 26161.003,
"max": 85067.56,
"ci95": 79513.616
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 67.82,
"stdev": 49.16,
"min": 12.722,
"max": 107.193,
"ci95": 122.129
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 17.769,
"stdev": 19.765,
"min": 2.609,
"max": 40.124,
"ci95": 49.103
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 13.876,
"stdev": 16.851,
"min": 2.308,
"max": 33.21,
"ci95": 41.864
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 37630.797,
"stdev": 33094.177,
"min": 14901.304,
"max": 75599.155,
"ci95": 82217.128
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 44.125,
"stdev": 32.286,
"min": 13.532,
"max": 77.872,
"ci95": 80.209
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 4.619,
"stdev": 1.694,
"min": 3.247,
"max": 6.512,
"ci95": 4.208
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 2.354,
"stdev": 0.44,
"min": 1.967,
"max": 2.833,
"ci95": 1.094
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 67242.203,
"stdev": 17886.115,
"min": 56354.348,
"max": 87884.948,
"ci95": 44435.159
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 28.745,
"stdev": 7.77,
"min": 20.237,
"max": 35.466,
"ci95": 19.304
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 3.013,
"stdev": 0.849,
"min": 2.273,
"max": 3.94,
"ci95": 2.109
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 2.377,
"stdev": 1.031,
"min": 1.246,
"max": 3.263,
"ci95": 2.561
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 2958.083,
"stdev": 22.425,
"min": 2937.734,
"max": 2982.125,
"ci95": 55.71
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 2837.568,
"stdev": 165.159,
"min": 2648.188,
"max": 2951.719,
"ci95": 410.31
},
"memory.footprint_peak_mb": {
"n": 3,
"mean": 4618.337,
"stdev": 95.349,
"min": 4543.347,
"max": 4725.644,
"ci95": 236.878
},
"memory.footprint_steady_mb": {
"n": 3,
"mean": 4264.652,
"stdev": 87.422,
"min": 4173.589,
"max": 4347.91,
"ci95": 217.185
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 424,
"seconds": 12.004,
"avg_fps": 35.32,
"low_1pct_fps": 5.907,
"low_01pct_fps": 5.804,
"p50_ms": 17.195,
"p95_ms": 95.694,
"p99_ms": 164.6,
"p999_ms": 172.292,
"max_ms": 172.292,
"histogram": [
0,
0,
0,
0,
0,
0,
5,
19,
39,
45,
44,
52,
42,
41,
38,
21,
20,
21,
19,
12,
6,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 1,
"frames": 1239,
"seconds": 12.001,
"avg_fps": 103.238,
"low_1pct_fps": 20.97,
"low_01pct_fps": 14.505,
"p50_ms": 8.073,
"p95_ms": 18.618,
"p99_ms": 34.704,
"p999_ms": 53.789,
"max_ms": 84.091,
"histogram": [
0,
0,
0,
0,
0,
2,
275,
412,
260,
117,
60,
35,
25,
23,
14,
7,
5,
3,
1,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 1271,
"seconds": 12.024,
"avg_fps": 105.706,
"low_1pct_fps": 18.988,
"low_01pct_fps": 11.324,
"p50_ms": 7.988,
"p95_ms": 16.852,
"p99_ms": 35.156,
"p999_ms": 65.46,
"max_ms": 111.155,
"histogram": [
0,
0,
0,
0,
0,
3,
220,
500,
318,
101,
43,
21,
15,
19,
17,
4,
4,
5,
0,
1,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 154,
"seconds": 12.105,
"avg_fps": 12.722,
"low_1pct_fps": 2.609,
"low_01pct_fps": 2.308,
"p50_ms": 54.156,
"p95_ms": 216.738,
"p99_ms": 333.238,
"p999_ms": 433.205,
"max_ms": 433.205,
"histogram": [
0,
0,
0,
0,
0,
0,
0,
0,
1,
4,
5,
7,
9,
13,
25,
5,
7,
12,
17,
26,
18,
5,
0,
0
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 1287,
"seconds": 12.006,
"avg_fps": 107.193,
"low_1pct_fps": 40.124,
"low_01pct_fps": 33.21,
"p50_ms": 8.355,
"p95_ms": 14.243,
"p99_ms": 22.791,
"p999_ms": 29.023,
"max_ms": 31.2,
"histogram": [
0,
0,
0,
0,
0,
0,
0,
627,
416,
130,
43,
33,
16,
18,
4,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 1003,
"seconds": 12.005,
"avg_fps": 83.546,
"low_1pct_fps": 10.575,
"low_01pct_fps": 6.111,
"p50_ms": 9.556,
"p95_ms": 22.218,
"p99_ms": 53.64,
"p999_ms": 143.879,
"max_ms": 183.38,
"histogram": [
0,
0,
0,
0,
0,
0,
0,
57,
515,
239,
64,
45,
21,
30,
12,
4,
4,
5,
3,
3,
1,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 163,
"seconds": 12.045,
"avg_fps": 13.532,
"low_1pct_fps": 3.247,
"low_01pct_fps": 2.833,
"p50_ms": 52.758,
"p95_ms": 208.496,
"p99_ms": 263.074,
"p999_ms": 352.963,
"max_ms": 352.963,
"histogram": [
0,
0,
0,
0,
0,
0,
0,
0,
1,
2,
4,
5,
5,
14,
23,
10,
12,
17,
30,
18,
20,
2,
0,
0
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 951,
"seconds": 12.212,
"avg_fps": 77.872,
"low_1pct_fps": 6.512,
"low_01pct_fps": 1.967,
"p50_ms": 8.895,
"p95_ms": 31.377,
"p99_ms": 72.053,
"p999_ms": 508.284,
"max_ms": 508.284,
"histogram": [
0,
0,
0,
0,
0,
0,
0,
359,
306,
104,
47,
29,
31,
16,
18,
10,
9,
10,
6,
2,
3,
0,
1,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 495,
"seconds": 12.082,
"avg_fps": 40.97,
"low_1pct_fps": 4.097,
"low_01pct_fps": 2.261,
"p50_ms": 11.995,
"p95_ms": 80.625,
"p99_ms": 146.758,
"p999_ms": 442.316,
"max_ms": 442.316,
"histogram": [
0,
0,
0,
0,
0,
0,
3,
80,
109,
56,
31,
37,
24,
30,
28,
17,
17,
27,
24,
8,
2,
2,
0,
0
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 243,
"seconds": 12.008,
"avg_fps": 20.237,
"low_1pct_fps": 2.827,
"low_01pct_fps": 2.623,
"p50_ms": 21.487,
"p95_ms": 177.814,
"p99_ms": 315.716,
"p999_ms": 381.289,
"max_ms": 381.289,
"histogram": [
0,
0,
0,
0,
0,
0,
0,
0,
5,
20,
26,
24,
32,
25,
21,
8,
19,
13,
18,
15,
10,
7,
0,
0
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 431,
"seconds": 12.153,
"avg_fps": 35.466,
"low_1pct_fps": 2.273,
"low_01pct_fps": 1.246,
"p50_ms": 12.232,
"p95_ms": 106.955,
"p99_ms": 249.209,
"p999_ms": 802.438,
"max_ms": 802.438,
"histogram": [
0,
0,
0,
0,
0,
0,
0,
0,
66,
137,
62,
41,
21,
23,
20,
3,
14,
11,
11,
10,
8,
2,
2,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 367,
"seconds": 12.02,
"avg_fps": 30.533,
"low_1pct_fps": 3.94,
"low_01pct_fps": 3.263,
"p50_ms": 21.354,
"p95_ms": 104.182,
"p99_ms": 218.448,
"p999_ms": 306.483,
"max_ms": 306.483,
"histogram": [
0,
0,
0,
0,
0,
0,
0,
0,
4,
52,
38,
32,
44,
53,
47,
20,
21,
19,
16,
15,
4,
2,
0,
0
]
}
],
"label": "vanilla-m4pro-1080p"
}
@@ -0,0 +1,707 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-11-01T22:55:52Z",
"app": {
"name": "minecraft",
"version": "26.3",
"mods": [
{
"name": "fabric-loader",
"version": "0.19.5"
}
]
},
"hardware": {
"os": "macos",
"arch": "aarch64",
"cpu_threads": 14,
"os_version": "27.0.1",
"cpu": "Apple M4 Pro",
"ram_mb": 65536,
"gpu": {
"name": "Apple M4 Pro",
"vendor": "Apple",
"driver": "4.1 Metal - 91.7",
"backend": "opengl",
"kind": ""
}
},
"conditions": {
"scenario": "perf_bench:village,forest,ocean,mountain",
"seed": "1234",
"render_distance": 8,
"simulation_distance": 12,
"vsync": false,
"offscreen": true,
"repeats": 3,
"warmup_s": 2.0,
"load_avg_1m": 66.477,
"window": [
1920,
930
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 49895.61,
"stdev": 37830.08,
"min": 27410.441,
"max": 93571.631,
"ci95": 93982.713
},
"startup.world_ms": {
"n": 3,
"mean": 89080.61,
"stdev": 49418.222,
"min": 59243.441,
"max": 146123.631,
"ci95": 122771.577
},
"chunks.village.load_ms": {
"n": 3,
"mean": 29280.957,
"stdev": 15620.91,
"min": 18681.538,
"max": 47219.974,
"ci95": 38807.625
},
"fps.village.avg_fps": {
"n": 3,
"mean": 42.245,
"stdev": 6.241,
"min": 36.416,
"max": 48.83,
"ci95": 15.506
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 4.137,
"stdev": 1.2,
"min": 3.03,
"max": 5.412,
"ci95": 2.981
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 2.891,
"stdev": 0.196,
"min": 2.777,
"max": 3.118,
"ci95": 0.488
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 16212.425,
"stdev": 1584.155,
"min": 14800.702,
"max": 17925.672,
"ci95": 3935.576
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 66.392,
"stdev": 8.735,
"min": 57.65,
"max": 75.119,
"ci95": 21.699
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 8.327,
"stdev": 3.862,
"min": 6.012,
"max": 12.786,
"ci95": 9.595
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 6.154,
"stdev": 3.399,
"min": 3.873,
"max": 10.06,
"ci95": 8.444
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 9054.345,
"stdev": 1795.805,
"min": 7429.882,
"max": 10982.71,
"ci95": 4461.386
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 49.891,
"stdev": 17.901,
"min": 29.637,
"max": 63.591,
"ci95": 44.472
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 6.144,
"stdev": 5.262,
"min": 2.948,
"max": 12.218,
"ci95": 13.074
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 5.254,
"stdev": 4.377,
"min": 2.603,
"max": 10.306,
"ci95": 10.874
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 18171.415,
"stdev": 5234.921,
"min": 12170.39,
"max": 21800.551,
"ci95": 13005.314
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 45.9,
"stdev": 14.757,
"min": 29.055,
"max": 56.549,
"ci95": 36.661
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 4.83,
"stdev": 2.815,
"min": 2.104,
"max": 7.727,
"ci95": 6.994
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 3.986,
"stdev": 2.385,
"min": 1.597,
"max": 6.366,
"ci95": 5.924
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 1877.276,
"stdev": 149.19,
"min": 1724.094,
"max": 2022.125,
"ci95": 370.639
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 1810.693,
"stdev": 157.376,
"min": 1671.984,
"max": 1981.719,
"ci95": 390.975
},
"memory.footprint_peak_mb": {
"n": 3,
"mean": 2708.649,
"stdev": 200.476,
"min": 2538.472,
"max": 2929.644,
"ci95": 498.051
},
"memory.footprint_steady_mb": {
"n": 3,
"mean": 2635.498,
"stdev": 241.221,
"min": 2402.909,
"max": 2884.511,
"ci95": 599.274
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 586,
"seconds": 12.001,
"avg_fps": 48.83,
"low_1pct_fps": 3.03,
"low_01pct_fps": 2.779,
"p50_ms": 6.298,
"p95_ms": 102.59,
"p99_ms": 300.171,
"p999_ms": 359.883,
"max_ms": 359.883,
"histogram": [
0,
0,
30,
105,
67,
75,
58,
68,
43,
24,
14,
15,
8,
4,
8,
4,
7,
8,
18,
11,
11,
8,
0,
0
]
},
{
"phase": "village",
"repeat": 1,
"frames": 498,
"seconds": 12.003,
"avg_fps": 41.489,
"low_1pct_fps": 5.412,
"low_01pct_fps": 3.118,
"p50_ms": 14.331,
"p95_ms": 81.666,
"p99_ms": 133.706,
"p999_ms": 320.73,
"max_ms": 320.73,
"histogram": [
0,
0,
0,
5,
15,
23,
21,
37,
43,
41,
55,
45,
50,
45,
30,
15,
18,
18,
21,
13,
2,
1,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 437,
"seconds": 12.0,
"avg_fps": 36.416,
"low_1pct_fps": 3.97,
"low_01pct_fps": 2.777,
"p50_ms": 12.279,
"p95_ms": 133.385,
"p99_ms": 203.287,
"p999_ms": 360.113,
"max_ms": 360.113,
"histogram": [
0,
0,
0,
9,
28,
21,
19,
41,
52,
41,
31,
33,
27,
25,
27,
13,
13,
17,
12,
14,
12,
2,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 797,
"seconds": 12.002,
"avg_fps": 66.407,
"low_1pct_fps": 6.184,
"low_01pct_fps": 4.528,
"p50_ms": 7.208,
"p95_ms": 64.408,
"p99_ms": 126.005,
"p999_ms": 220.846,
"max_ms": 220.846,
"histogram": [
0,
0,
2,
22,
78,
137,
123,
136,
77,
58,
31,
14,
13,
14,
13,
10,
11,
21,
20,
13,
4,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 692,
"seconds": 12.003,
"avg_fps": 57.65,
"low_1pct_fps": 6.012,
"low_01pct_fps": 3.873,
"p50_ms": 10.783,
"p95_ms": 48.101,
"p99_ms": 121.963,
"p999_ms": 258.167,
"max_ms": 258.167,
"histogram": [
0,
0,
0,
13,
52,
52,
41,
62,
83,
88,
50,
46,
40,
50,
43,
20,
23,
9,
11,
6,
2,
1,
0,
0
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 902,
"seconds": 12.008,
"avg_fps": 75.119,
"low_1pct_fps": 12.786,
"low_01pct_fps": 10.06,
"p50_ms": 9.096,
"p95_ms": 38.051,
"p99_ms": 67.18,
"p999_ms": 99.405,
"max_ms": 99.405,
"histogram": [
0,
0,
0,
26,
115,
72,
65,
100,
118,
90,
66,
51,
54,
50,
39,
16,
17,
13,
10,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 687,
"seconds": 12.171,
"avg_fps": 56.446,
"low_1pct_fps": 3.267,
"low_01pct_fps": 2.853,
"p50_ms": 4.938,
"p95_ms": 103.639,
"p99_ms": 257.27,
"p999_ms": 350.504,
"max_ms": 350.504,
"histogram": [
0,
0,
28,
199,
125,
99,
51,
35,
32,
18,
5,
7,
11,
10,
11,
6,
2,
4,
7,
14,
15,
8,
0,
0
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 764,
"seconds": 12.014,
"avg_fps": 63.591,
"low_1pct_fps": 12.218,
"low_01pct_fps": 10.306,
"p50_ms": 11.549,
"p95_ms": 45.214,
"p99_ms": 67.493,
"p999_ms": 97.034,
"max_ms": 97.034,
"histogram": [
0,
0,
0,
9,
45,
41,
59,
58,
87,
101,
71,
85,
42,
56,
49,
15,
16,
22,
8,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 357,
"seconds": 12.046,
"avg_fps": 29.637,
"low_1pct_fps": 2.948,
"low_01pct_fps": 2.603,
"p50_ms": 10.947,
"p95_ms": 173.526,
"p99_ms": 311.134,
"p999_ms": 384.135,
"max_ms": 384.135,
"histogram": [
0,
0,
0,
11,
47,
31,
12,
34,
32,
23,
15,
26,
21,
14,
15,
7,
6,
16,
18,
6,
15,
8,
0,
0
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 355,
"seconds": 12.218,
"avg_fps": 29.055,
"low_1pct_fps": 2.104,
"low_01pct_fps": 1.597,
"p50_ms": 7.766,
"p95_ms": 145.251,
"p99_ms": 368.51,
"p999_ms": 626.205,
"max_ms": 626.205,
"histogram": [
0,
0,
41,
53,
25,
28,
15,
22,
7,
16,
10,
15,
17,
12,
15,
11,
11,
8,
14,
19,
7,
7,
2,
0
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 680,
"seconds": 12.025,
"avg_fps": 56.549,
"low_1pct_fps": 7.727,
"low_01pct_fps": 6.366,
"p50_ms": 11.633,
"p95_ms": 52.508,
"p99_ms": 111.848,
"p999_ms": 157.09,
"max_ms": 157.09,
"histogram": [
0,
0,
1,
32,
38,
43,
49,
71,
55,
55,
54,
57,
53,
41,
54,
21,
19,
17,
12,
7,
1,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 632,
"seconds": 12.132,
"avg_fps": 52.095,
"low_1pct_fps": 4.66,
"low_01pct_fps": 3.995,
"p50_ms": 8.601,
"p95_ms": 78.611,
"p99_ms": 172.405,
"p999_ms": 250.342,
"max_ms": 250.342,
"histogram": [
0,
0,
9,
75,
86,
53,
32,
57,
40,
41,
30,
38,
25,
24,
39,
16,
23,
7,
12,
15,
9,
1,
0,
0
]
}
],
"label": "vanilla-m4pro-1080p"
}
@@ -0,0 +1,723 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-11-01T22:55:52Z",
"app": {
"name": "minecraft",
"version": "26.3",
"mods": [
{
"name": "fabric-loader",
"version": "0.19.5"
},
{
"name": "sodium",
"version": "mc26.3-0.9.2-fabric"
},
{
"name": "lithium",
"version": "mc26.3-0.26.2-fabric"
},
{
"name": "ferrite-core",
"version": "9.0.0-fabric"
},
{
"name": "immediatelyfast",
"version": "1.17.1+26.3-fabric"
}
]
},
"hardware": {
"os": "macos",
"arch": "aarch64",
"cpu_threads": 14,
"os_version": "27.0.1",
"cpu": "Apple M4 Pro",
"ram_mb": 65536,
"gpu": {
"name": "Apple M4 Pro",
"vendor": "Apple",
"driver": "4.1 Metal - 91.7",
"backend": "opengl",
"kind": ""
}
},
"conditions": {
"scenario": "perf_bench:village,forest,ocean,mountain",
"seed": "1234",
"render_distance": 16,
"simulation_distance": 12,
"vsync": false,
"offscreen": true,
"repeats": 3,
"warmup_s": 2.0,
"load_avg_1m": 69.878,
"window": [
1920,
930
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 23843.039,
"stdev": 7524.513,
"min": 16643.766,
"max": 31655.294,
"ci95": 18693.436
},
"startup.world_ms": {
"n": 3,
"mean": 76908.706,
"stdev": 36719.804,
"min": 45051.766,
"max": 117069.294,
"ci95": 91224.412
},
"chunks.village.load_ms": {
"n": 3,
"mean": 19608.485,
"stdev": 9449.035,
"min": 13510.149,
"max": 30492.958,
"ci95": 23474.6
},
"fps.village.avg_fps": {
"n": 3,
"mean": 46.159,
"stdev": 17.954,
"min": 31.413,
"max": 66.152,
"ci95": 44.604
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 6.544,
"stdev": 3.452,
"min": 3.225,
"max": 10.116,
"ci95": 8.577
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 4.689,
"stdev": 1.808,
"min": 2.647,
"max": 6.085,
"ci95": 4.491
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 23344.685,
"stdev": 5493.145,
"min": 17038.709,
"max": 27089.821,
"ci95": 13646.83
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 61.892,
"stdev": 37.939,
"min": 22.354,
"max": 98.0,
"ci95": 94.254
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 9.736,
"stdev": 8.597,
"min": 2.296,
"max": 19.148,
"ci95": 21.359
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 6.651,
"stdev": 5.574,
"min": 2.202,
"max": 12.903,
"ci95": 13.847
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 27267.052,
"stdev": 21587.34,
"min": 10640.097,
"max": 51663.797,
"ci95": 53630.253
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 75.493,
"stdev": 63.885,
"min": 25.975,
"max": 147.604,
"ci95": 158.711
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 13.396,
"stdev": 13.366,
"min": 4.634,
"max": 28.78,
"ci95": 33.205
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 7.356,
"stdev": 6.609,
"min": 3.26,
"max": 14.98,
"ci95": 16.418
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 38284.867,
"stdev": 4237.4,
"min": 33922.931,
"max": 42385.65,
"ci95": 10527.134
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 35.918,
"stdev": 17.742,
"min": 18.135,
"max": 53.619,
"ci95": 44.077
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 3.999,
"stdev": 2.132,
"min": 2.453,
"max": 6.431,
"ci95": 5.297
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 3.017,
"stdev": 1.831,
"min": 1.775,
"max": 5.12,
"ci95": 4.549
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 2566.339,
"stdev": 218.228,
"min": 2339.094,
"max": 2774.266,
"ci95": 542.153
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 2354.852,
"stdev": 240.105,
"min": 2082.984,
"max": 2537.867,
"ci95": 596.503
},
"memory.footprint_peak_mb": {
"n": 3,
"mean": 3430.598,
"stdev": 77.063,
"min": 3345.395,
"max": 3495.426,
"ci95": 191.451
},
"memory.footprint_steady_mb": {
"n": 3,
"mean": 3211.931,
"stdev": 99.984,
"min": 3124.098,
"max": 3320.738,
"ci95": 248.394
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 377,
"seconds": 12.002,
"avg_fps": 31.413,
"low_1pct_fps": 3.225,
"low_01pct_fps": 2.647,
"p50_ms": 15.145,
"p95_ms": 128.716,
"p99_ms": 276.685,
"p999_ms": 377.749,
"max_ms": 377.749,
"histogram": [
0,
0,
0,
0,
17,
26,
21,
30,
23,
43,
16,
25,
28,
28,
35,
16,
14,
12,
15,
13,
11,
4,
0,
0
]
},
{
"phase": "village",
"repeat": 1,
"frames": 794,
"seconds": 12.003,
"avg_fps": 66.152,
"low_1pct_fps": 10.116,
"low_01pct_fps": 6.085,
"p50_ms": 11.137,
"p95_ms": 41.078,
"p99_ms": 73.531,
"p999_ms": 164.347,
"max_ms": 164.347,
"histogram": [
0,
0,
0,
20,
57,
65,
52,
70,
82,
88,
80,
67,
53,
48,
52,
18,
20,
10,
10,
1,
1,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 491,
"seconds": 12.001,
"avg_fps": 40.912,
"low_1pct_fps": 6.291,
"low_01pct_fps": 5.336,
"p50_ms": 15.962,
"p95_ms": 71.604,
"p99_ms": 137.056,
"p999_ms": 187.401,
"max_ms": 187.401,
"histogram": [
0,
0,
0,
0,
1,
5,
10,
32,
54,
60,
45,
49,
44,
49,
50,
22,
21,
20,
18,
8,
3,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 784,
"seconds": 12.002,
"avg_fps": 65.322,
"low_1pct_fps": 7.763,
"low_01pct_fps": 4.848,
"p50_ms": 10.024,
"p95_ms": 43.46,
"p99_ms": 94.19,
"p999_ms": 206.286,
"max_ms": 206.286,
"histogram": [
0,
0,
0,
17,
40,
69,
80,
95,
88,
82,
53,
58,
39,
49,
52,
21,
15,
14,
7,
3,
2,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 1176,
"seconds": 12.0,
"avg_fps": 98.0,
"low_1pct_fps": 19.148,
"low_01pct_fps": 12.903,
"p50_ms": 6.214,
"p95_ms": 30.125,
"p99_ms": 41.199,
"p999_ms": 73.675,
"max_ms": 81.328,
"histogram": [
0,
0,
3,
179,
205,
175,
115,
111,
79,
39,
21,
25,
34,
79,
77,
20,
10,
2,
2,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 271,
"seconds": 12.123,
"avg_fps": 22.354,
"low_1pct_fps": 2.296,
"low_01pct_fps": 2.202,
"p50_ms": 14.989,
"p95_ms": 214.282,
"p99_ms": 404.5,
"p999_ms": 454.134,
"max_ms": 454.134,
"histogram": [
0,
0,
0,
1,
5,
18,
19,
19,
28,
28,
13,
19,
20,
12,
21,
14,
5,
8,
4,
9,
18,
10,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 1772,
"seconds": 12.005,
"avg_fps": 147.604,
"low_1pct_fps": 28.78,
"low_01pct_fps": 14.98,
"p50_ms": 5.328,
"p95_ms": 15.261,
"p99_ms": 25.961,
"p999_ms": 58.586,
"max_ms": 74.925,
"histogram": [
0,
0,
34,
421,
331,
290,
175,
163,
122,
83,
44,
33,
26,
28,
16,
2,
2,
1,
1,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 635,
"seconds": 12.004,
"avg_fps": 52.9,
"low_1pct_fps": 6.774,
"low_01pct_fps": 3.828,
"p50_ms": 9.578,
"p95_ms": 61.195,
"p99_ms": 112.118,
"p999_ms": 261.228,
"max_ms": 261.228,
"histogram": [
0,
0,
0,
38,
49,
64,
63,
63,
51,
60,
27,
29,
27,
17,
37,
28,
30,
27,
15,
8,
1,
1,
0,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 312,
"seconds": 12.011,
"avg_fps": 25.975,
"low_1pct_fps": 4.634,
"low_01pct_fps": 3.26,
"p50_ms": 24.629,
"p95_ms": 115.493,
"p99_ms": 169.827,
"p999_ms": 306.737,
"max_ms": 306.737,
"histogram": [
0,
0,
0,
0,
0,
0,
2,
5,
10,
10,
24,
39,
33,
38,
47,
16,
16,
21,
23,
22,
5,
1,
0,
0
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 218,
"seconds": 12.021,
"avg_fps": 18.135,
"low_1pct_fps": 2.453,
"low_01pct_fps": 2.156,
"p50_ms": 17.447,
"p95_ms": 257.129,
"p99_ms": 377.63,
"p999_ms": 463.802,
"max_ms": 463.802,
"histogram": [
0,
0,
7,
13,
15,
18,
11,
9,
7,
16,
8,
3,
11,
10,
14,
7,
5,
16,
8,
15,
12,
13,
0,
0
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 653,
"seconds": 12.179,
"avg_fps": 53.619,
"low_1pct_fps": 6.431,
"low_01pct_fps": 5.12,
"p50_ms": 8.427,
"p95_ms": 58.968,
"p99_ms": 141.423,
"p999_ms": 195.302,
"max_ms": 195.302,
"histogram": [
0,
0,
0,
38,
66,
76,
79,
64,
45,
38,
20,
29,
22,
24,
46,
25,
36,
17,
14,
10,
4,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 432,
"seconds": 12.0,
"avg_fps": 35.999,
"low_1pct_fps": 3.112,
"low_01pct_fps": 1.775,
"p50_ms": 16.724,
"p95_ms": 83.13,
"p99_ms": 220.496,
"p999_ms": 563.493,
"max_ms": 563.493,
"histogram": [
0,
0,
0,
7,
14,
17,
23,
35,
35,
26,
24,
33,
34,
43,
43,
23,
27,
20,
15,
5,
4,
3,
1,
0
]
}
],
"label": "vanilla-mods-m4pro-1080p"
}
@@ -0,0 +1,723 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-11-01T23:17:23Z",
"app": {
"name": "minecraft",
"version": "26.3",
"mods": [
{
"name": "fabric-loader",
"version": "0.19.5"
},
{
"name": "sodium",
"version": "mc26.3-0.9.2-fabric"
},
{
"name": "lithium",
"version": "mc26.3-0.26.2-fabric"
},
{
"name": "ferrite-core",
"version": "9.0.0-fabric"
},
{
"name": "immediatelyfast",
"version": "1.17.1+26.3-fabric"
}
]
},
"hardware": {
"os": "macos",
"arch": "aarch64",
"cpu_threads": 14,
"os_version": "27.0.1",
"cpu": "Apple M4 Pro",
"ram_mb": 65536,
"gpu": {
"name": "Apple M4 Pro",
"vendor": "Apple",
"driver": "4.1 Metal - 91.7",
"backend": "opengl",
"kind": ""
}
},
"conditions": {
"scenario": "perf_bench:village,forest,ocean,mountain",
"seed": "1234",
"render_distance": 32,
"simulation_distance": 12,
"vsync": false,
"offscreen": true,
"repeats": 3,
"warmup_s": 2.0,
"load_avg_1m": 37.185,
"window": [
1920,
930
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 13521.661,
"stdev": 573.908,
"min": 13085.942,
"max": 14171.935,
"ci95": 1425.783
},
"startup.world_ms": {
"n": 3,
"mean": 63229.994,
"stdev": 6185.388,
"min": 56237.942,
"max": 67987.935,
"ci95": 15366.595
},
"chunks.village.load_ms": {
"n": 3,
"mean": 14807.746,
"stdev": 2020.358,
"min": 13414.414,
"max": 17124.849,
"ci95": 5019.253
},
"fps.village.avg_fps": {
"n": 3,
"mean": 91.753,
"stdev": 11.452,
"min": 78.555,
"max": 99.067,
"ci95": 28.451
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 13.105,
"stdev": 2.514,
"min": 10.354,
"max": 15.282,
"ci95": 6.244
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 9.203,
"stdev": 1.382,
"min": 7.689,
"max": 10.396,
"ci95": 3.432
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 24180.167,
"stdev": 777.711,
"min": 23290.973,
"max": 24733.555,
"ci95": 1932.098
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 113.871,
"stdev": 53.305,
"min": 56.542,
"max": 161.938,
"ci95": 132.428
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 28.748,
"stdev": 17.633,
"min": 12.823,
"max": 47.698,
"ci95": 43.807
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 20.551,
"stdev": 13.547,
"min": 8.937,
"max": 35.433,
"ci95": 33.655
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 19314.547,
"stdev": 5853.258,
"min": 15068.272,
"max": 25991.528,
"ci95": 14541.473
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 119.116,
"stdev": 71.027,
"min": 45.498,
"max": 187.231,
"ci95": 176.454
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 31.721,
"stdev": 26.624,
"min": 8.585,
"max": 60.822,
"ci95": 66.144
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 16.8,
"stdev": 14.937,
"min": 5.876,
"max": 33.821,
"ci95": 37.109
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 61290.415,
"stdev": 15063.343,
"min": 49453.256,
"max": 78246.003,
"ci95": 37422.439
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 56.905,
"stdev": 19.674,
"min": 37.807,
"max": 77.109,
"ci95": 48.878
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 7.384,
"stdev": 0.62,
"min": 6.67,
"max": 7.784,
"ci95": 1.539
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 3.48,
"stdev": 1.306,
"min": 2.526,
"max": 4.968,
"ci95": 3.244
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 4078.01,
"stdev": 79.593,
"min": 4020.391,
"max": 4168.828,
"ci95": 197.735
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 3705.104,
"stdev": 149.415,
"min": 3541.375,
"max": 3834.078,
"ci95": 371.196
},
"memory.footprint_peak_mb": {
"n": 3,
"mean": 5250.386,
"stdev": 54.229,
"min": 5200.662,
"max": 5308.208,
"ci95": 134.723
},
"memory.footprint_steady_mb": {
"n": 3,
"mean": 4647.172,
"stdev": 29.179,
"min": 4613.552,
"max": 4665.911,
"ci95": 72.492
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 1172,
"seconds": 12.004,
"avg_fps": 97.637,
"low_1pct_fps": 15.282,
"low_01pct_fps": 10.396,
"p50_ms": 7.394,
"p95_ms": 26.126,
"p99_ms": 50.505,
"p999_ms": 79.614,
"max_ms": 112.764,
"histogram": [
0,
0,
0,
24,
111,
198,
193,
170,
129,
109,
59,
42,
43,
23,
38,
10,
10,
7,
5,
1,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 1,
"frames": 1189,
"seconds": 12.002,
"avg_fps": 99.067,
"low_1pct_fps": 13.678,
"low_01pct_fps": 9.523,
"p50_ms": 7.061,
"p95_ms": 28.029,
"p99_ms": 58.166,
"p999_ms": 103.326,
"max_ms": 106.682,
"histogram": [
0,
0,
0,
59,
132,
223,
172,
171,
107,
90,
59,
39,
36,
28,
35,
14,
9,
9,
4,
2,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 943,
"seconds": 12.004,
"avg_fps": 78.555,
"low_1pct_fps": 10.354,
"low_01pct_fps": 7.689,
"p50_ms": 8.817,
"p95_ms": 31.62,
"p99_ms": 70.58,
"p999_ms": 130.049,
"max_ms": 130.049,
"histogram": [
0,
0,
0,
8,
34,
97,
118,
168,
126,
106,
69,
58,
49,
36,
32,
8,
9,
13,
8,
4,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 679,
"seconds": 12.009,
"avg_fps": 56.542,
"low_1pct_fps": 12.823,
"low_01pct_fps": 8.937,
"p50_ms": 14.344,
"p95_ms": 42.09,
"p99_ms": 65.756,
"p999_ms": 111.897,
"max_ms": 111.897,
"histogram": [
0,
0,
0,
0,
2,
33,
50,
62,
68,
60,
48,
67,
84,
76,
64,
27,
18,
14,
5,
1,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 1478,
"seconds": 12.003,
"avg_fps": 123.133,
"low_1pct_fps": 25.723,
"low_01pct_fps": 17.282,
"p50_ms": 6.388,
"p95_ms": 17.972,
"p99_ms": 29.029,
"p999_ms": 57.659,
"max_ms": 58.069,
"histogram": [
0,
0,
0,
3,
336,
298,
222,
186,
144,
77,
70,
57,
39,
23,
13,
5,
3,
2,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 1944,
"seconds": 12.005,
"avg_fps": 161.938,
"low_1pct_fps": 47.698,
"low_01pct_fps": 35.433,
"p50_ms": 5.168,
"p95_ms": 12.12,
"p99_ms": 17.337,
"p999_ms": 26.48,
"max_ms": 29.965,
"histogram": [
0,
0,
0,
18,
793,
527,
243,
125,
91,
47,
41,
34,
15,
7,
3,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 548,
"seconds": 12.044,
"avg_fps": 45.498,
"low_1pct_fps": 8.585,
"low_01pct_fps": 5.876,
"p50_ms": 15.416,
"p95_ms": 62.523,
"p99_ms": 98.757,
"p999_ms": 170.17,
"max_ms": 170.17,
"histogram": [
0,
0,
0,
0,
2,
15,
26,
52,
53,
58,
37,
56,
39,
58,
60,
27,
24,
18,
21,
1,
1,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 1496,
"seconds": 12.005,
"avg_fps": 124.619,
"low_1pct_fps": 25.756,
"low_01pct_fps": 10.702,
"p50_ms": 6.649,
"p95_ms": 15.751,
"p99_ms": 26.208,
"p999_ms": 76.546,
"max_ms": 110.33,
"histogram": [
0,
0,
0,
15,
250,
299,
266,
216,
163,
115,
68,
40,
32,
13,
14,
3,
0,
0,
1,
1,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 2247,
"seconds": 12.001,
"avg_fps": 187.231,
"low_1pct_fps": 60.822,
"low_01pct_fps": 33.821,
"p50_ms": 4.73,
"p95_ms": 8.497,
"p99_ms": 12.315,
"p999_ms": 21.724,
"max_ms": 42.26,
"histogram": [
0,
0,
0,
347,
987,
453,
211,
122,
67,
32,
15,
6,
4,
2,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 670,
"seconds": 12.007,
"avg_fps": 55.799,
"low_1pct_fps": 7.784,
"low_01pct_fps": 2.526,
"p50_ms": 12.887,
"p95_ms": 45.129,
"p99_ms": 76.315,
"p999_ms": 395.821,
"max_ms": 395.821,
"histogram": [
0,
0,
0,
0,
0,
16,
54,
79,
75,
81,
68,
60,
53,
70,
54,
17,
16,
9,
17,
0,
0,
1,
0,
0
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 455,
"seconds": 12.035,
"avg_fps": 37.807,
"low_1pct_fps": 6.67,
"low_01pct_fps": 4.968,
"p50_ms": 15.986,
"p95_ms": 82.012,
"p99_ms": 117.987,
"p999_ms": 201.279,
"max_ms": 201.279,
"histogram": [
0,
0,
0,
0,
0,
3,
27,
37,
47,
49,
37,
39,
31,
39,
34,
20,
24,
30,
26,
10,
2,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 933,
"seconds": 12.1,
"avg_fps": 77.109,
"low_1pct_fps": 7.697,
"low_01pct_fps": 2.946,
"p50_ms": 8.593,
"p95_ms": 34.696,
"p99_ms": 79.266,
"p999_ms": 339.486,
"max_ms": 339.486,
"histogram": [
0,
0,
0,
0,
0,
45,
176,
209,
196,
95,
45,
43,
26,
19,
30,
9,
9,
14,
13,
2,
1,
1,
0,
0
]
}
],
"label": "vanilla-mods-m4pro-1080p"
}
@@ -0,0 +1,723 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-11-01T22:55:52Z",
"app": {
"name": "minecraft",
"version": "26.3",
"mods": [
{
"name": "fabric-loader",
"version": "0.19.5"
},
{
"name": "sodium",
"version": "mc26.3-0.9.2-fabric"
},
{
"name": "lithium",
"version": "mc26.3-0.26.2-fabric"
},
{
"name": "ferrite-core",
"version": "9.0.0-fabric"
},
{
"name": "immediatelyfast",
"version": "1.17.1+26.3-fabric"
}
]
},
"hardware": {
"os": "macos",
"arch": "aarch64",
"cpu_threads": 14,
"os_version": "27.0.1",
"cpu": "Apple M4 Pro",
"ram_mb": 65536,
"gpu": {
"name": "Apple M4 Pro",
"vendor": "Apple",
"driver": "4.1 Metal - 91.7",
"backend": "opengl",
"kind": ""
}
},
"conditions": {
"scenario": "perf_bench:village,forest,ocean,mountain",
"seed": "1234",
"render_distance": 8,
"simulation_distance": 12,
"vsync": false,
"offscreen": true,
"repeats": 3,
"warmup_s": 2.0,
"load_avg_1m": 74.321,
"window": [
1920,
930
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 56311.461,
"stdev": 26982.658,
"min": 36031.835,
"max": 86935.815,
"ci95": 67034.048
},
"startup.world_ms": {
"n": 3,
"mean": 97731.794,
"stdev": 21691.925,
"min": 72951.835,
"max": 113284.815,
"ci95": 53890.078
},
"chunks.village.load_ms": {
"n": 3,
"mean": 13954.335,
"stdev": 10146.301,
"min": 7679.006,
"max": 25660.115,
"ci95": 25206.843
},
"fps.village.avg_fps": {
"n": 3,
"mean": 33.239,
"stdev": 18.526,
"min": 12.03,
"max": 46.264,
"ci95": 46.026
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 3.772,
"stdev": 2.399,
"min": 1.734,
"max": 6.416,
"ci95": 5.96
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 2.866,
"stdev": 1.762,
"min": 1.433,
"max": 4.834,
"ci95": 4.379
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 19803.961,
"stdev": 14910.114,
"min": 8220.752,
"max": 36626.588,
"ci95": 37041.766
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 47.303,
"stdev": 17.044,
"min": 32.698,
"max": 66.03,
"ci95": 42.343
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 4.365,
"stdev": 0.709,
"min": 3.658,
"max": 5.076,
"ci95": 1.761
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 3.128,
"stdev": 0.564,
"min": 2.651,
"max": 3.751,
"ci95": 1.402
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 18639.188,
"stdev": 8912.103,
"min": 11960.198,
"max": 28758.72,
"ci95": 22140.678
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 34.908,
"stdev": 6.372,
"min": 29.271,
"max": 41.822,
"ci95": 15.831
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 4.614,
"stdev": 1.558,
"min": 2.986,
"max": 6.09,
"ci95": 3.869
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 4.06,
"stdev": 1.662,
"min": 2.224,
"max": 5.463,
"ci95": 4.13
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 19191.558,
"stdev": 2902.338,
"min": 15845.729,
"max": 21030.7,
"ci95": 7210.388
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 67.004,
"stdev": 13.923,
"min": 51.98,
"max": 79.471,
"ci95": 34.589
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 7.862,
"stdev": 1.987,
"min": 6.339,
"max": 10.109,
"ci95": 4.936
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 3.993,
"stdev": 1.679,
"min": 2.06,
"max": 5.088,
"ci95": 4.172
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 2019.089,
"stdev": 53.398,
"min": 1960.188,
"max": 2064.328,
"ci95": 132.657
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 1927.195,
"stdev": 108.077,
"min": 1813.609,
"max": 2028.758,
"ci95": 268.5
},
"memory.footprint_peak_mb": {
"n": 3,
"mean": 2440.727,
"stdev": 72.508,
"min": 2375.394,
"max": 2518.738,
"ci95": 180.134
},
"memory.footprint_steady_mb": {
"n": 3,
"mean": 2395.53,
"stdev": 71.597,
"min": 2318.504,
"max": 2460.05,
"ci95": 177.872
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 146,
"seconds": 12.136,
"avg_fps": 12.03,
"low_1pct_fps": 1.734,
"low_01pct_fps": 1.433,
"p50_ms": 28.465,
"p95_ms": 339.625,
"p99_ms": 455.663,
"p999_ms": 697.859,
"max_ms": 697.859,
"histogram": [
0,
0,
1,
4,
9,
9,
5,
5,
8,
6,
5,
5,
1,
10,
9,
6,
11,
10,
5,
8,
15,
13,
1,
0
]
},
{
"phase": "village",
"repeat": 1,
"frames": 556,
"seconds": 12.018,
"avg_fps": 46.264,
"low_1pct_fps": 6.416,
"low_01pct_fps": 4.834,
"p50_ms": 13.72,
"p95_ms": 71.774,
"p99_ms": 120.078,
"p999_ms": 206.87,
"max_ms": 206.87,
"histogram": [
0,
0,
0,
6,
5,
16,
22,
57,
55,
64,
62,
62,
50,
38,
29,
19,
23,
16,
21,
9,
2,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 498,
"seconds": 12.022,
"avg_fps": 41.423,
"low_1pct_fps": 3.166,
"low_01pct_fps": 2.331,
"p50_ms": 7.7,
"p95_ms": 114.025,
"p99_ms": 246.232,
"p999_ms": 428.98,
"max_ms": 428.98,
"histogram": [
0,
0,
15,
63,
55,
58,
33,
38,
31,
22,
23,
17,
24,
24,
20,
11,
9,
10,
14,
15,
12,
4,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 521,
"seconds": 12.065,
"avg_fps": 43.182,
"low_1pct_fps": 4.362,
"low_01pct_fps": 3.751,
"p50_ms": 8.121,
"p95_ms": 110.621,
"p99_ms": 191.925,
"p999_ms": 266.575,
"max_ms": 266.575,
"histogram": [
0,
0,
1,
63,
27,
49,
61,
67,
39,
45,
27,
17,
13,
17,
7,
11,
13,
11,
18,
22,
11,
2,
0,
0
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 793,
"seconds": 12.01,
"avg_fps": 66.03,
"low_1pct_fps": 5.076,
"low_01pct_fps": 2.651,
"p50_ms": 8.218,
"p95_ms": 44.767,
"p99_ms": 143.139,
"p999_ms": 377.207,
"max_ms": 377.207,
"histogram": [
0,
0,
0,
60,
106,
55,
58,
127,
97,
64,
41,
28,
32,
31,
39,
12,
7,
8,
14,
9,
4,
1,
0,
0
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 395,
"seconds": 12.08,
"avg_fps": 32.698,
"low_1pct_fps": 3.658,
"low_01pct_fps": 2.983,
"p50_ms": 10.283,
"p95_ms": 171.818,
"p99_ms": 231.998,
"p999_ms": 335.242,
"max_ms": 335.242,
"histogram": [
0,
0,
6,
19,
13,
27,
42,
31,
53,
36,
22,
25,
20,
19,
10,
8,
7,
6,
11,
15,
23,
2,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 405,
"seconds": 12.043,
"avg_fps": 33.63,
"low_1pct_fps": 4.765,
"low_01pct_fps": 4.492,
"p50_ms": 14.686,
"p95_ms": 115.893,
"p99_ms": 196.822,
"p999_ms": 222.615,
"max_ms": 222.615,
"histogram": [
0,
0,
0,
5,
6,
16,
18,
49,
42,
36,
22,
30,
35,
32,
23,
10,
10,
22,
22,
16,
11,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 502,
"seconds": 12.003,
"avg_fps": 41.822,
"low_1pct_fps": 6.09,
"low_01pct_fps": 5.463,
"p50_ms": 15.264,
"p95_ms": 76.822,
"p99_ms": 144.7,
"p999_ms": 183.045,
"max_ms": 183.045,
"histogram": [
0,
0,
0,
9,
14,
12,
13,
47,
43,
52,
41,
40,
52,
40,
50,
15,
26,
17,
17,
9,
5,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 353,
"seconds": 12.06,
"avg_fps": 29.271,
"low_1pct_fps": 2.986,
"low_01pct_fps": 2.224,
"p50_ms": 10.806,
"p95_ms": 150.281,
"p99_ms": 268.607,
"p999_ms": 449.56,
"max_ms": 449.56,
"histogram": [
0,
0,
0,
29,
16,
24,
36,
35,
23,
31,
13,
16,
12,
18,
14,
9,
8,
16,
18,
17,
12,
6,
0,
0
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 624,
"seconds": 12.005,
"avg_fps": 51.98,
"low_1pct_fps": 7.137,
"low_01pct_fps": 5.088,
"p50_ms": 12.033,
"p95_ms": 75.22,
"p99_ms": 115.345,
"p999_ms": 196.553,
"max_ms": 196.553,
"histogram": [
0,
0,
4,
29,
48,
34,
38,
40,
61,
56,
50,
57,
54,
41,
33,
13,
15,
15,
28,
6,
2,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 835,
"seconds": 12.004,
"avg_fps": 69.562,
"low_1pct_fps": 6.339,
"low_01pct_fps": 2.06,
"p50_ms": 9.818,
"p95_ms": 37.917,
"p99_ms": 81.491,
"p999_ms": 485.481,
"max_ms": 485.481,
"histogram": [
0,
0,
6,
62,
63,
59,
75,
84,
75,
94,
59,
77,
55,
31,
36,
27,
10,
10,
5,
5,
1,
1,
0,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 954,
"seconds": 12.004,
"avg_fps": 79.471,
"low_1pct_fps": 10.109,
"low_01pct_fps": 4.832,
"p50_ms": 7.91,
"p95_ms": 38.672,
"p99_ms": 79.104,
"p999_ms": 206.935,
"max_ms": 206.935,
"histogram": [
0,
0,
13,
55,
70,
136,
119,
109,
93,
98,
52,
49,
48,
21,
30,
17,
14,
11,
17,
1,
1,
0,
0,
0
]
}
],
"label": "vanilla-mods-m4pro-1080p"
}
+7
View File
@@ -281,6 +281,13 @@ pub struct Player {
pub ground: Option<(u64, f64)>,
/// The client ticks the prediction plays again (only while it runs).
pub record: Option<Box<prediction::Record>>,
/// Sprinting as the client's own commands left it (ServerboundPlayerCommandPacket's
/// START_SPRINTING and STOP_SPRINTING: LocalPlayer.wasSprinting), which the record's ticks take.
/// Not the server's player: a hit with extra knockback stops its sprint
/// (Player.causeExtraKnockback), the client's own `Player.attack` doesn't (a mob's `hurtClient`
/// is false), and once the server's flag reaches it LocalPlayer.aiStep starts the sprint again
/// while the sprint key is held, with nothing to say: wasSprinting never changed.
pub sprinting: bool,
/// The attacks of the client tick being heard.
pub attacks: combat::Attacks,
}
+95 -2
View File
@@ -7,7 +7,7 @@ use crate::{
json::Result,
math::{Mat4, quaternion_xyz},
model::{Direction, DisplayTransform, GuiLight, ItemTransform, Material},
resolve::ModelLibrary,
resolve::{ModelLibrary, ResolvedCuboidModel},
};
use std::{collections::BTreeSet, f32::consts::PI};
@@ -110,6 +110,15 @@ pub fn bake_item_model(
let model = library
.resolve(name)
.or_else(|_| library.resolve(crate::resolve::MISSING_MODEL))?;
bake_resolved(&model, sprites)
}
/// Bake an already resolved model, e.g. one from
/// [`ModelLibrary::resolve_with_textures`].
pub fn bake_resolved(
model: &ResolvedCuboidModel,
sprites: &dyn SpriteLookup,
) -> Result<BakedItemModel> {
let known = |sprite: &str| sprite == MISSING_SPRITE || sprites.mask(sprite).is_some();
let material = |m: &Material| {
if known(&m.sprite) {
@@ -143,7 +152,7 @@ pub fn bake_item_model(
quads
} else {
let mut quads =
bake_block_model(&model, crate::bake::BlockModelTransform::new([0; 3], false))?;
bake_block_model(model, crate::bake::BlockModelTransform::new([0; 3], false))?;
for q in &mut quads {
q.material = material(&q.material);
}
@@ -298,3 +307,87 @@ fn extrude_layer(mask: &SpriteMask, material: &Material, layer: i32) -> Result<V
}
Ok(quads)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{CuboidModel, TextureSlot};
use std::collections::BTreeMap;
/// The stick: 2x2 with one transparent pixel. `item/cit`: one opaque pixel, so a bake
/// shows whose mask it extruded.
struct Sprites;
impl SpriteLookup for Sprites {
fn mask(&self, sprite: &str) -> Option<SpriteMask> {
match sprite {
"minecraft:item/stick" => Some(SpriteMask {
width: 2,
height: 2,
frames: vec![vec![true, false, false, false]],
}),
"horizon_cit:item/cit" => Some(SpriteMask {
width: 1,
height: 1,
frames: vec![vec![false]],
}),
_ => None,
}
}
}
fn library() -> ModelLibrary {
ModelLibrary::with_item_generator(BTreeMap::from(
[
("item/generated", r#"{"parent":"builtin/generated","display":{"gui":{"scale":[2,2,2]}}}"#),
("item/stick", r#"{"parent":"item/generated","textures":{"layer0":"item/stick"}}"#),
("horizon:loop", r#"{"parent":"horizon:loop2"}"#),
("horizon:loop2", r#"{"parent":"horizon:loop"}"#),
("block/slab", r##"{"textures":{"particle":"#side","side":"block/absent"},"elements":[{"from":[0,0,0],"to":[16,8,16],"faces":{"up":{"texture":"#side"},"down":{"texture":"#side","cullface":"down"}}}]}"##),
]
.map(|(name, json)| (name.to_owned(), CuboidModel::from_json(json).unwrap())),
))
.unwrap()
}
#[test]
fn baking_by_name_falls_back_to_the_missing_model() {
let library = library();
// Front, back, and the 8 unit edges around the L of 3 opaque pixels.
let stick = bake_item_model(&library, &Sprites, "item/stick").unwrap();
assert_eq!(stick.quads.len(), 10);
// An unknown sprite bakes as the missing sprite.
let slab = bake_item_model(&library, &Sprites, "block/slab").unwrap();
assert_eq!(slab.quads.len(), 2);
assert_eq!(slab.particle.sprite, MISSING_SPRITE);
// A cyclic chain, an error to `resolve`, bakes as the missing cube.
let cyclic = bake_item_model(&library, &Sprites, "horizon:loop").unwrap();
assert_eq!(cyclic.quads.len(), 6);
assert!(
cyclic
.quads
.iter()
.all(|q| q.material.sprite == MISSING_SPRITE)
);
}
#[test]
fn replaced_layer_bakes_with_its_own_sprite() {
let library = library();
let child = BTreeMap::from([(
"layer0".to_owned(),
TextureSlot::Material(Material {
sprite: "horizon_cit:item/cit".to_owned(),
force_translucent: false,
}),
)]);
let model = library.resolve_with_textures("item/stick", &child).unwrap();
let baked = bake_resolved(&model, &Sprites).unwrap();
// The CIT sprite's own mask, one opaque pixel: front, back and 4 edges.
assert_eq!(baked.quads.len(), 6);
assert!(
baked
.quads
.iter()
.all(|q| q.material.sprite == "horizon_cit:item/cit")
);
assert_eq!(baked.particle.sprite, "horizon_cit:item/cit");
assert_eq!(baked.transforms[5].as_ref().unwrap().scale, [2.0; 3]);
}
}
+275
View File
@@ -102,10 +102,74 @@ impl ModelLibrary {
missing,
})
}
/// This library plus `extra` models; an `extra` model replaces a
/// same-named one, but not the built-in missing and generated models.
/// Every existing model is shared, not copied.
pub fn overlaid(&self, extra: BTreeMap<String, CuboidModel>) -> Result<Self> {
let mut models = self.models.clone();
let mut added = BTreeSet::new();
for (name, model) in extra {
let name = identifier(&name)?;
if !added.insert(name.clone()) {
return Err(error("duplicate normalized model identifier"));
}
// The built-ins win, as `from_models` keeps `missing` and
// `with_item_generator` keeps `generated`.
if name != MISSING_MODEL && name != GENERATED_MODEL {
models.insert(name, Arc::new(model));
}
}
Ok(Self {
models,
missing: Arc::clone(&self.missing),
})
}
/// Follow the complete parent chain. A missing resource uses the built-in
/// model; a cyclic chain is rejected, as native discovery excludes it.
/// The returned model can outlive this library without copying its elements.
pub fn resolve(&self, name: &str) -> Result<ResolvedCuboidModel> {
self.resolve_chain(name, None)
}
/// Whether `name` and every parent it reaches are models of this library,
/// with no cycle: `resolve` puts the missing model in for any other.
pub fn resolves(&self, name: &str) -> bool {
let Ok(mut name) = identifier(name) else {
return false;
};
let mut visited = BTreeSet::new();
loop {
let Some(model) = self.models.get(&name) else {
return false;
};
if !visited.insert(name.clone()) {
return false;
}
match &model.parent {
Some(parent) => name = parent.clone(),
None => return true,
}
}
}
/// `name`'s resolved model with a virtual child in front of it that has
/// only `textures`: the child's slots win, and `#references` anywhere in
/// the chain re-bind through them (so `builtin/generated`'s
/// `particle: #layer0` follows a replaced `layer0`). The child's slots are
/// taken as given: sprites namespaced, references without their `#`, as
/// the JSON decoder leaves them. A cyclic chain is an
/// error, as for `resolve`: a caller wanting the missing model falls
/// back to it.
pub fn resolve_with_textures(
&self,
name: &str,
child: &BTreeMap<String, TextureSlot>,
) -> Result<ResolvedCuboidModel> {
self.resolve_chain(name, Some(child))
}
fn resolve_chain(
&self,
name: &str,
child: Option<&BTreeMap<String, TextureSlot>>,
) -> Result<ResolvedCuboidModel> {
let mut name = identifier(name)?;
let mut visited = BTreeSet::new();
let mut chain = Vec::new();
@@ -156,6 +220,9 @@ impl ModelLibrary {
for model in chain.into_iter().rev() {
slots.extend(model.textures.iter().map(|(k, v)| (k.clone(), v)));
}
if let Some(child) = child {
slots.extend(child.iter().map(|(k, v)| (k.clone(), v)));
}
let mut textures = BTreeMap::<String, Material>::new();
let mut unresolved_textures = BTreeSet::<String>::new();
for start in slots.keys() {
@@ -274,6 +341,195 @@ mod tests {
}
);
}
fn library(models: &[(&str, &str)]) -> ModelLibrary {
ModelLibrary::with_item_generator(
models
.iter()
.map(|(name, json)| (name.to_string(), CuboidModel::from_json(json).unwrap()))
.collect(),
)
.unwrap()
}
const GENERATED: &str = r#"{"parent":"builtin/generated","display":{"ground":{"translation":[0,2,0],"scale":[0.5,0.5,0.5]}}}"#;
const CIT: &str = "horizon_cit:minecraft/optifine/cit/x";
#[test]
fn child_textures_replace_slots_and_rebind_the_generated_particle() {
let library = library(&[
("item/generated", GENERATED),
(
"item/stick",
r#"{"parent":"item/generated","textures":{"layer0":"item/stick"}}"#,
),
]);
let child = BTreeMap::from([(
"layer0".to_owned(),
TextureSlot::Material(Material {
sprite: CIT.to_owned(),
force_translucent: false,
}),
)]);
let model = library.resolve_with_textures("item/stick", &child).unwrap();
assert!(model.uses_item_generator());
assert_eq!(model.material("layer0").unwrap().unwrap().sprite, CIT);
assert_eq!(model.material("particle").unwrap().unwrap().sprite, CIT);
assert_eq!(
model.display.contexts[6],
DisplayTransform::Explicit(Some(ItemTransform {
rotation: [0.0; 3],
translation: [0.0, 0.125, 0.0],
scale: [0.5; 3],
}))
);
// The library itself is untouched.
let plain = library.resolve("item/stick").unwrap();
assert_eq!(
plain.material("particle").unwrap().unwrap().sprite,
"minecraft:item/stick"
);
}
#[test]
fn overlaid_models_inherit_from_the_library_and_share_the_rest() {
let base = library(&[
("item/generated", GENERATED),
(
"item/handheld",
r#"{"parent":"item/generated","display":{"thirdperson_righthand":{"rotation":[0,-90,55]}}}"#,
),
(
"item/stick",
r#"{"parent":"item/generated","textures":{"layer0":"item/stick"}}"#,
),
(
"item/apple",
r#"{"parent":"item/generated","textures":{"layer0":"item/apple"}}"#,
),
]);
let cit = format!(r#"{{"parent":"item/handheld","textures":{{"layer0":"{CIT}"}}}}"#);
let over = base
.overlaid(BTreeMap::from([
(CIT.to_owned(), CuboidModel::from_json(&cit).unwrap()),
(
"item/stick".to_owned(),
CuboidModel::from_json(
r#"{"parent":"item/generated","textures":{"layer0":"item/bone"}}"#,
)
.unwrap(),
),
]))
.unwrap();
let model = over.resolve(CIT).unwrap();
assert!(model.uses_item_generator());
assert_eq!(model.material("particle").unwrap().unwrap().sprite, CIT);
let DisplayTransform::Explicit(Some(right)) = &model.display.contexts[1] else {
panic!("handheld's right-hand transform is inherited");
};
assert_eq!(right.rotation, [0.0, -90.0, 55.0]);
let sprite = |l: &ModelLibrary| {
l.resolve("item/stick").unwrap().textures["layer0"]
.sprite
.clone()
};
assert_eq!(sprite(&over), "minecraft:item/bone");
assert_eq!(sprite(&base), "minecraft:item/stick");
assert!(!base.resolve(CIT).unwrap().uses_item_generator());
assert!(Arc::ptr_eq(
&base.models["minecraft:item/apple"],
&over.models["minecraft:item/apple"]
));
}
fn material(sprite: &str) -> TextureSlot {
TextureSlot::Material(Material {
sprite: sprite.to_owned(),
force_translucent: false,
})
}
#[test]
fn child_slots_take_part_in_reference_chains() {
let library = library(&[
(
"item/two",
r##"{"textures":{"layer0":"#tex","layer1":"item/b","tex":"item/a"}}"##,
),
("horizon:loop", r#"{"parent":"horizon:loop2"}"#),
("horizon:loop2", r#"{"parent":"horizon:loop"}"#),
]);
let child = BTreeMap::from([
// A base alias binds through a slot only the child sets...
("tex".to_owned(), material(CIT)),
// ...and a child alias through the base's slots.
(
"particle".to_owned(),
TextureSlot::Reference("layer1".to_owned()),
),
// A child alias to itself resolves to nothing, without looping.
("self".to_owned(), TextureSlot::Reference("self".to_owned())),
]);
let model = library.resolve_with_textures("item/two", &child).unwrap();
assert_eq!(model.material("layer0").unwrap().unwrap().sprite, CIT);
assert_eq!(
model.material("particle").unwrap().unwrap().sprite,
"minecraft:item/b"
);
assert_eq!(model.material("self").unwrap(), None);
assert!(model.unresolved_textures.contains("self"));
// An absent base is the missing cube under the child; a cyclic one an error.
let absent = library
.resolve_with_textures("item/absent", &child)
.unwrap();
assert_eq!(absent.elements().len(), 1);
assert!(
library
.resolve_with_textures("horizon:loop", &child)
.is_err()
);
}
#[test]
fn an_overlay_replaces_parents_but_not_the_built_ins_and_refuses_twins() {
let base = library(&[
("item/generated", GENERATED),
("item/handheld", r#"{"parent":"item/generated"}"#),
(
"item/sword",
r#"{"parent":"item/handheld","textures":{"layer0":"item/sword"}}"#,
),
]);
let model = |json: &str| CuboidModel::from_json(json).unwrap();
let over = base
.overlaid(BTreeMap::from([
(
"item/handheld".to_owned(),
model(
r#"{"parent":"item/generated","display":{"head":{"rotation":[0,45,0]}}}"#,
),
),
// Neither built-in can be replaced.
("builtin/missing".to_owned(), model(r#"{"elements":[]}"#)),
("builtin/generated".to_owned(), model("{}")),
]))
.unwrap();
// The base's sword sees the overlay's parent; the base library does not.
let head = |l: &ModelLibrary| l.resolve("item/sword").unwrap().display.contexts[4].clone();
let DisplayTransform::Explicit(Some(rotated)) = head(&over) else {
panic!("the replaced parent's head transform");
};
assert_eq!(rotated.rotation, [0.0, 45.0, 0.0]);
assert_ne!(head(&base), head(&over));
// The generator kept: its `particle: #layer0`, which `{}` would drop.
let sword = over.resolve("item/sword").unwrap();
assert_eq!(
sword.material("particle").unwrap().unwrap().sprite,
"minecraft:item/sword"
);
// The missing cube kept, looked up by its own name, not as a fallback.
let missing = over.resolve("builtin/missing").unwrap();
assert_eq!(missing.elements().len(), 1);
// Two spellings of one id.
let twins = BTreeMap::from([
("item/x".to_owned(), model("{}")),
("minecraft:item/x".to_owned(), model("{}")),
]);
assert!(base.overlaid(twins).is_err());
}
#[test]
fn absent_parents_fall_back_but_cycles_invalidate_descendants() {
let a = CuboidModel::from_json(r#"{"parent":"horizon:b"}"#).unwrap();
@@ -293,4 +549,23 @@ mod tests {
"minecraft:missingno"
);
}
#[test]
fn a_model_resolves_only_with_its_whole_parent_chain() {
let library = library(&[
("item/generated", GENERATED),
(
"item/stick",
r#"{"parent":"item/generated","textures":{"layer0":"item/stick"}}"#,
),
("item/orphan", r#"{"parent":"item/gone"}"#),
("item/a", r#"{"parent":"item/b"}"#),
("item/b", r#"{"parent":"item/a"}"#),
]);
assert!(library.resolves("item/stick"));
assert!(library.resolves("minecraft:item/stick"));
assert!(!library.resolves("item/absent"));
assert!(!library.resolves("item/orphan"));
assert!(!library.resolves("item/a"));
assert!(!library.resolves("Item/Stick"));
}
}
+16 -6
View File
@@ -202,10 +202,8 @@ fn os_version() -> Option<String> {
#[cfg(target_os = "linux")]
fn cpu() -> Option<String> {
let info = std::fs::read_to_string("/proc/cpuinfo").ok()?;
// x86 names the CPU per core; ARM boards name the machine once.
key_value(&info, "model name", ':')
.or_else(|| key_value(&info, "Model", ':'))
.map(str::to_owned)
// x86 names the CPU per core; arm64 only gives the core's implementer and part.
horizon_telemetry::system::linux_cpu_name(&info)
}
#[cfg(target_os = "linux")]
@@ -227,12 +225,12 @@ fn key_value<'a>(text: &'a str, key: &str, sep: char) -> Option<&'a str> {
#[cfg(target_os = "windows")]
fn os_version() -> Option<String> {
output("cmd", &["/C", "ver"])
horizon_telemetry::system::windows_version()
}
#[cfg(target_os = "windows")]
fn cpu() -> Option<String> {
std::env::var("PROCESSOR_IDENTIFIER").ok()
horizon_telemetry::system::windows_cpu()
}
#[cfg(target_os = "windows")]
@@ -281,4 +279,16 @@ mod tests {
assert!(cpu_seconds(std::process::id()).unwrap() >= 0.0);
}
}
/// `hardware.cpu` is the brand name, not the family and model `PROCESSOR_IDENTIFIER` holds.
#[cfg(windows)]
#[test]
fn windows_reports_the_cpu_brand() {
let cpu = Hardware::detect().cpu.expect("a CPU name");
assert!(
!cpu.is_empty() && !cpu.contains(" Family ") && !cpu.contains("Stepping"),
"family and model, not a name: {cpu:?}"
);
assert_eq!(cpu, cpu.trim());
}
}
+41 -4
View File
@@ -73,8 +73,11 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
--attack NAME attack the player NAME twice a second, from wherever the bot is (a reach test),
swinging after each attack as vanilla's client does. NAME,NAME...: each of them
in the same tick (a multi-target kill aura, for the anticheat's tests);
the report gives each bot's last health
the report gives each bot's last health. A UUID instead of a NAME: the
entity with that UUID (a boat summoned with it, say)
--attack-after S start attacking S seconds after joining; default 5
--attack-rounds N stop after N rounds of attacks (each hits every target seen so far once);
default no limit
--no-swing attack without swinging (a kill aura hiding its swing, for the anticheat's tests)
--no-loaded never say the level is loaded (ServerboundPlayerLoadedPacket): a vanilla server
ignores the player's moves and actions for its first 3 seconds
@@ -153,9 +156,11 @@ struct Options {
seed: u64,
explore_speed: f64,
pvp: bool,
/// The player to attack, and how long after joining to start.
/// The players or entity UUIDs to attack, how long after joining to start,
/// and how many rounds.
attack: Option<String>,
attack_after: f64,
attack_rounds: u32,
no_swing: bool,
flood: usize,
no_read: bool,
@@ -190,6 +195,7 @@ fn options() -> Result<Options, String> {
pvp: true,
attack: None,
attack_after: 5.0,
attack_rounds: u32::MAX,
no_swing: false,
flood: 100,
no_read: false,
@@ -247,6 +253,7 @@ fn options() -> Result<Options, String> {
"--flood" => o.flood = value.parse().map_err(|_| number("packets a tick"))?,
"--attack" => o.attack = Some(value),
"--attack-after" => o.attack_after = value.parse().map_err(|_| number("seconds"))?,
"--attack-rounds" => o.attack_rounds = value.parse().map_err(|_| number("a number"))?,
"--name" => o.name = value,
"--report" => o.report = Some(value),
"--brand" => o.brand = Some(value),
@@ -401,6 +408,16 @@ fn join_summary(mut times: Vec<u64>) -> serde_json::Value {
})
}
/// An `--attack` target's UUID: a UUID as written (`8-4-4-4-12` hex), else
/// the offline UUID of the player with that name (a name has no `-`).
fn target_uuid(name: &str) -> u128 {
let hex: String = name.chars().filter(|&c| c != '-').collect();
match u128::from_str_radix(&hex, 16) {
Ok(uuid) if name.contains('-') && hex.len() == 32 => uuid,
_ => offline_uuid(name),
}
}
/// A small deterministic generator (SplitMix64), so every run moves the bots the same way.
struct Random(u64);
impl Random {
@@ -984,7 +1001,7 @@ fn run_bot(
.attack
.iter()
.flat_map(|names| names.split(','))
.map(|name| (offline_uuid(name), None))
.map(|name| (target_uuid(name), None))
.collect();
bot.online.store(true, Ordering::SeqCst);
{
@@ -1039,6 +1056,7 @@ fn run_bot(
send(play::Serverbound::SetCarriedItem(play::SetCarriedItem { slot: 0 }).encode());
}
let mut sequence = 0;
let mut rounds = 0;
let mut random = Random(o.seed ^ (index as u64).wrapping_mul(0x2545_F491_4F6C_DD1D));
let mut pos = spawn;
// Fight bots stay at their spawn height; the others climb to fly above the terrain.
@@ -1202,8 +1220,13 @@ fn run_bot(
send(body);
}
}
if joined.elapsed().as_secs_f64() >= o.attack_after && tick % 10 == 0 {
if joined.elapsed().as_secs_f64() >= o.attack_after
&& tick % 10 == 0
&& rounds < o.attack_rounds
{
let targets: Vec<i32> = lock(&bot.target).iter().filter_map(|t| t.1).collect();
// A round counts once a target is known.
rounds += u32::from(!targets.is_empty());
for id in targets {
send(play::Serverbound::Attack(play::Attack { id }).encode());
// Minecraft.startAttack swings right after the attack.
@@ -1446,6 +1469,20 @@ mod tests {
})
}
/// `--attack`: a UUID names that entity, anything else a player.
#[test]
fn attack_targets_a_uuid_or_a_player() {
assert_eq!(target_uuid("00000000-0000-0000-0000-00000000000a"), 10);
assert_eq!(
target_uuid("0000000a-0000-0001-0000-000000000002"),
0x0000000a_0000_0001_0000_000000000002
);
assert_eq!(target_uuid("V0"), offline_uuid("V0"));
// A name of 32 hex digits is a name: a UUID is written with dashes.
let name = "0123456789abcdef0123456789abcdef";
assert_eq!(target_uuid(name), offline_uuid(name));
}
#[test]
fn tracks_living_entities_only() {
let mut e = Entities::default();
+275 -2
View File
@@ -3,6 +3,7 @@
//! from `pack.mcmeta` `language` sections, and a process-wide current
//! language used by every screen.
use std::{
cell::RefCell,
collections::{BTreeMap, HashMap},
sync::{
Arc, RwLock,
@@ -34,6 +35,15 @@ pub struct Language {
pub const DEFAULT: &str = "en_us";
static CURRENT: RwLock<Option<Arc<Language>>> = RwLock::new(None);
/// A language that only the calling thread sees, and the keys it was asked for and did not have
/// (`scoped`).
struct Scope {
language: Arc<Language>,
missed: Vec<String>,
}
thread_local! {
static SCOPE: RefCell<Option<Scope>> = const { RefCell::new(None) };
}
/// The current language's `bidirectional`, read for every drawn string.
static BIDIRECTIONAL: AtomicBool = AtomicBool::new(false);
@@ -72,6 +82,19 @@ impl ResourceSource for Bundled {
"assets/horizon/lang/tr_tr.json" => {
include_str!("../../horizon-client/assets/horizon/lang/tr_tr.json")
}
"assets/horizon/lang/de_de.json" | "assets/horizon/lang/de_at.json" => {
include_str!("../../horizon-client/assets/horizon/lang/de_de.json")
}
// Swiss German writes "ss" for "ß", as vanilla's de_ch does.
"assets/horizon/lang/de_ch.json" => {
let de = include_str!("../../horizon-client/assets/horizon/lang/de_de.json");
return Some(de.replace('ß', "ss").into_bytes());
}
"assets/horizon/lang/fr_fr.json"
| "assets/horizon/lang/fr_ca.json"
| "assets/horizon/lang/fr_ch.json" => {
include_str!("../../horizon-client/assets/horizon/lang/fr_fr.json")
}
"assets/horizon/lang/es_es.json"
| "assets/horizon/lang/es_ar.json"
| "assets/horizon/lang/es_cl.json"
@@ -241,8 +264,50 @@ pub fn set(language: Language) {
*CURRENT.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(language));
}
pub fn current() -> Option<Arc<Language>> {
if let Some(language) = SCOPE.with(|s| s.borrow().as_ref().map(|s| s.language.clone())) {
return Some(language);
}
CURRENT.read().unwrap_or_else(|e| e.into_inner()).clone()
}
/// Runs `f` with `language` as the current language of this thread only, and returns what it
/// returned with the keys `get` and `get_or` looked up and `language` did not have, in the order
/// asked and without repeats. Nothing is set process-wide, so a test can draw a screen in another
/// language while the tests beside it read theirs. Scopes nest: the inner one wins, and the outer
/// one's misses do not include the inner one's. For tests: `bidirectional` stays the process-wide
/// language's.
pub fn scoped<R>(language: Language, f: impl FnOnce() -> R) -> (R, Vec<String>) {
/// Puts the outer scope back, also when `f` panics.
struct Restore(Option<Scope>);
impl Drop for Restore {
fn drop(&mut self) {
SCOPE.with(|s| *s.borrow_mut() = self.0.take());
}
}
let outer = SCOPE.with(|s| {
s.borrow_mut().replace(Scope {
language: Arc::new(language),
missed: Vec::new(),
})
});
let _restore = Restore(outer);
let result = f();
let missed = SCOPE.with(|s| {
s.borrow_mut()
.as_mut()
.map(|s| std::mem::take(&mut s.missed))
});
(result, missed.unwrap_or_default())
}
/// `key` was asked for in a `scoped` language that has no text for it.
fn missed(key: &str) {
SCOPE.with(|s| {
if let Some(scope) = s.borrow_mut().as_mut()
&& !scope.missed.iter().any(|m| m == key)
{
scope.missed.push(key.to_owned());
}
});
}
/// `ClientLanguage.defaultRightToLeft` of the current language.
pub fn bidirectional() -> bool {
BIDIRECTIONAL.load(Ordering::Relaxed)
@@ -254,13 +319,19 @@ pub fn code() -> String {
pub fn get(key: &str) -> String {
current()
.and_then(|l| l.get(key).map(str::to_owned))
.unwrap_or_else(|| key.to_owned())
.unwrap_or_else(|| {
missed(key);
key.to_owned()
})
}
/// The translation, or `fallback` when the key is missing.
pub fn get_or(key: &str, fallback: &str) -> String {
current()
.and_then(|l| l.get(key).map(str::to_owned))
.unwrap_or_else(|| fallback.to_owned())
.unwrap_or_else(|| {
missed(key);
fallback.to_owned()
})
}
pub fn has(key: &str) -> bool {
current().is_some_and(|l| l.get(key).is_some())
@@ -316,6 +387,20 @@ mod tests {
assert_eq!(tr.get("horizon.options.controller"), Some("Kontrolcü"));
let es = Language::load(&[&empty], "es_es");
assert_eq!(es.get("horizon.options.controller"), Some("Mando"));
// German and French, and their regional variants on the same file
// (Swiss German without "ß").
let de = Language::load(&[&empty], "de_de");
assert_eq!(de.get("horizon.osk.close"), Some("Schließen"));
let fr = Language::load(&[&empty], "fr_fr");
assert_eq!(fr.get("horizon.osk.close"), Some("Fermer"));
for (code, base) in [("de_at", &de), ("fr_ca", &fr), ("fr_ch", &fr)] {
let variant = Language::load(&[&empty], code);
assert_eq!(variant.map(), base.map(), "{code}");
}
let de_ch = Language::load(&[&empty], "de_ch");
assert_eq!(de_ch.get("horizon.osk.close"), Some("Schliessen"));
assert!(de_ch.map().values().all(|v| !v.contains('ß')));
assert_eq!(de_ch.len(), de.len());
let es_ve = Language::load(&[&empty], "es_ve");
assert_eq!(
es_ve.get("horizon.options.on_screen_keyboard"),
@@ -323,6 +408,194 @@ mod tests {
);
}
#[test]
fn a_scoped_language_is_the_calling_threads_and_reports_what_it_lacks() {
let empty = Mem(HashMap::new());
let outside = code();
let ((german, english_only, fallback, elsewhere), missed) =
scoped(Language::load(&[&empty], "de_de"), || {
(
get("horizon.osk.close"),
get("horizon.no_such_key"),
get_or("horizon.no_fallback", "English"),
// Another thread keeps the process-wide language.
std::thread::spawn(code).join().unwrap(),
)
});
assert_eq!(german, "Schließen");
assert_eq!(english_only, "horizon.no_such_key");
assert_eq!(fallback, "English");
assert_eq!(elsewhere, outside);
assert_eq!(missed, ["horizon.no_such_key", "horizon.no_fallback"]);
// The scope ends with its closure.
assert_eq!(code(), outside);
}
#[test]
fn scopes_nest_and_end_when_their_closure_panics() {
let empty = Mem(HashMap::new());
let outside = code();
let (inner, missed) = scoped(Language::load(&[&empty], "fr_fr"), || {
let (inner, inner_missed) = scoped(Language::load(&[&empty], "pl_pl"), || {
(code(), get("horizon.inner_miss"))
});
assert_eq!(inner_missed, ["horizon.inner_miss"]);
assert_eq!(code(), "fr_fr");
inner.0
});
assert_eq!(inner, "pl_pl");
assert!(missed.is_empty());
let panicked = std::panic::catch_unwind(|| {
scoped(Language::load(&[&Mem(HashMap::new())], "de_de"), || {
panic!("a test failed inside a scope")
})
});
assert!(panicked.is_err());
assert_eq!(code(), outside);
}
/// `crates/horizon-client/assets/horizon/lang`, where Horizon's own strings live.
fn bundled_dir() -> std::path::PathBuf {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../horizon-client/assets/horizon/lang")
}
/// The keys of a flat bundled file in file order, duplicates included (a map
/// would keep only the last of two equal keys). One `"key": "value"` per line.
fn file_keys(body: &str) -> Vec<String> {
body.lines()
.filter(|line| line.starts_with(" \""))
.map(|line| {
serde_json::Deserializer::from_str(line.trim_start())
.into_iter::<String>()
.next()
.and_then(Result::ok)
.unwrap_or_else(|| panic!("no key on line {line}"))
})
.collect()
}
/// The fields the code fills in (`%s`, `%d`, `%1$s`, `%.2f`, `%%`), sorted. A
/// percent sign that starts none of them ("100% is") is plain text.
fn placeholders(text: &str) -> Vec<String> {
let mut found = Vec::new();
let mut rest = text;
while let Some(at) = rest.find('%') {
rest = &rest[at + 1..];
let spec = rest
.find(|c: char| !(c.is_ascii_digit() || c == '$' || c == '.'))
.unwrap_or(rest.len());
let after = rest[spec..].chars().next();
if after.is_some_and(|c| matches!(c, 's' | 'd' | 'f' | '%')) {
let end = spec + 1;
found.push(format!("%{}", &rest[..end]));
rest = &rest[end..];
}
}
found.sort();
found
}
#[test]
fn bundled_files_are_served_and_have_no_stale_or_repeated_keys() {
let dir = bundled_dir();
let en_body = std::fs::read_to_string(dir.join("en_us.json")).unwrap();
let en: HashMap<String, String> = serde_json::from_str(&en_body).unwrap();
let mut seen = 0;
for entry in std::fs::read_dir(&dir).unwrap().flatten() {
let name = entry.file_name().into_string().unwrap();
let Some(code) = name.strip_suffix(".json") else {
continue;
};
seen += 1;
// A file that Bundled::read does not name is never loaded.
let path = format!("assets/horizon/lang/{name}");
assert!(
Bundled.read(&path).is_some(),
"{name} is not served by Bundled"
);
let body = std::fs::read_to_string(entry.path()).unwrap();
let map: HashMap<String, String> = serde_json::from_str(&body)
.unwrap_or_else(|e| panic!("{name} is not a flat string map: {e}"));
let keys = file_keys(&body);
assert_eq!(
keys.len(),
map.len(),
"{name} repeats a key or has a multi-line entry"
);
for (key, value) in &map {
// Stale: the code no longer asks for it, or it was renamed in en_us only.
let english = en
.get(key)
.unwrap_or_else(|| panic!("{code}: {key} is not in en_us.json"));
// A translation keeps the fields the code fills in.
assert_eq!(
placeholders(value),
placeholders(english),
"{code}: {key} changes the placeholders of the English text"
);
assert!(!value.trim().is_empty(), "{code}: {key} is empty");
}
}
assert!(
seen >= 7,
"found {seen} bundled lang files in {}",
dir.display()
);
}
#[test]
fn every_horizon_key_the_code_names_is_in_en_us() {
fn rust_files(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
for entry in std::fs::read_dir(dir).into_iter().flatten().flatten() {
let path = entry.path();
if path.is_dir() {
rust_files(&path, out);
} else if path.extension().is_some_and(|e| e == "rs") {
out.push(path);
}
}
}
let en: HashMap<String, String> = serde_json::from_str(
&std::fs::read_to_string(bundled_dir().join("en_us.json")).unwrap(),
)
.unwrap();
let crates = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
let mut files = Vec::new();
for krate in [
"horizon-client",
"horizon-client-render",
"horizon-client-core",
] {
rust_files(&crates.join(krate).join("src"), &mut files);
}
assert!(files.len() > 100, "found only {} sources", files.len());
let mut found = 0;
for file in files {
let text = std::fs::read_to_string(&file).unwrap();
for (at, _) in text.match_indices("\"horizon.") {
let rest = &text[at + 1..];
let Some(end) = rest.find('"') else { continue };
let key = &rest[..end];
// A key is `horizon.<group>.<name>`: lower-case letters, digits and `_`
// between the dots. A key built at run time has a brace; a file name
// has one dot; the server addresses that look like keys are listed.
const NOT_KEYS: &[&str] = &["horizon.bunchy7s.dev"];
let key_like = key
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b"_.".contains(&b));
if key.matches('.').count() < 2 || !key_like || NOT_KEYS.contains(&key) {
continue;
}
found += 1;
assert!(
en.contains_key(key),
"{} names {key}, which is not in en_us.json",
file.display()
);
}
}
assert!(found > 50, "found only {found} key names");
}
#[test]
fn layers_selected_language_over_english() {
let base = Mem(HashMap::from([
@@ -213,6 +213,48 @@ impl Registries {
}
}
}
/// Only what [`Self::fill_from_export`] takes from an export, to keep
/// in place of the export itself (a third of its memory).
pub fn from_export(r: &serde_json::Value) -> Self {
let mut out = Self::default();
out.fill_from_export(r);
out
}
/// [`Self::fill_from_export`] from an export already read with
/// [`Self::from_export`]: each field the game left empty, each registry
/// it did not list.
pub fn fill_from_exported(&mut self, e: &Self) {
let fill = |field: &mut Vec<String>, from: &Vec<String>| {
if field.is_empty() {
field.clone_from(from);
}
};
fill(&mut self.effects, &e.effects);
fill(&mut self.enchantments, &e.enchantments);
fill(&mut self.attributes, &e.attributes);
fill(&mut self.damage_types, &e.damage_types);
fill(&mut self.templates, &e.templates);
fill(&mut self.persistent_components, &e.persistent_components);
fill(&mut self.learnable_recipes, &e.learnable_recipes);
for (name, ids) in &e.by_registry {
if !self.by_registry.contains_key(name) {
self.by_registry.insert(name.clone(), ids.clone());
}
}
if self.block_entities.is_empty() {
self.block_entities.clone_from(&e.block_entities);
}
if self.block_tag_members.is_empty() {
self.block_tag_members.clone_from(&e.block_tag_members);
}
if self.clock_time_markers.is_empty() {
self.clock_time_markers.clone_from(&e.clock_time_markers);
}
if self.advancement_criteria.is_empty() {
self.advancement_criteria
.clone_from(&e.advancement_criteria);
}
}
/// The element ids of a registry (sorted).
pub fn ids(&self, registry: &str) -> &[String] {
if let Some(r) = self.by_registry.get(registry) {
@@ -51,6 +51,10 @@ impl InteractionInput {
pub fn press(&mut self, button: Button) {
self.clicks[button as usize] += 1;
}
/// Whether a click waits for the next tick.
pub fn has_clicks(&self) -> bool {
self.clicks.iter().any(|&c| c > 0)
}
pub fn tick(&mut self, world: &mut impl InteractionWorld, attack_down: bool, use_down: bool) {
let [attack, use_item, pick] = std::mem::take(&mut self.clicks);
@@ -483,6 +483,21 @@ impl KeyBinds {
.find(|mapping| self.get(mapping) == name)
.and_then(|mapping| key_code(Self::default_of(mapping)))
}
/// `AbstractContainerScreen.checkHotbarKeyPressed`: the off hand and the
/// hotbar slots, as the controls bind them now. Answers the key the menu
/// acts on (`KeyF` for the off hand, `DigitN` for a slot), so a rebound key
/// swaps instead of reaching the screen.
pub fn container_hotbar_key(&self, key: KeyCode) -> Option<KeyCode> {
let name = key_name(key)?;
if self.get("key.swapOffhand") == name {
return Some(KeyCode::KeyF);
}
MAPPINGS
.iter()
.filter(|m| m.0.starts_with("key.hotbar."))
.find(|m| self.get(m.0) == name)
.and_then(|m| key_code(Self::default_of(m.0)))
}
}
#[cfg(test)]
@@ -519,6 +534,29 @@ mod tests {
assert_eq!(keys.container_mouse_key(MouseButton::Forward, false), None);
}
#[test]
fn container_hotkeys_follow_the_bound_keys() {
let mut keys = KeyBinds::default();
assert_eq!(
keys.container_hotbar_key(KeyCode::KeyF),
Some(KeyCode::KeyF)
);
assert_eq!(
keys.container_hotbar_key(KeyCode::Digit3),
Some(KeyCode::Digit3)
);
assert_eq!(keys.container_hotbar_key(KeyCode::Tab), None);
// The report's key: bound to the off hand, the menu takes it first.
keys.set("key.swapOffhand", "key.keyboard.tab");
assert_eq!(keys.container_hotbar_key(KeyCode::Tab), Some(KeyCode::KeyF));
assert_eq!(keys.container_hotbar_key(KeyCode::KeyF), None);
keys.set("key.hotbar.3", "key.keyboard.t");
assert_eq!(
keys.container_hotbar_key(KeyCode::KeyT),
Some(KeyCode::Digit3)
);
}
#[test]
fn rebinding_translates_to_defaults() {
let mut k = KeyBinds::default();
@@ -207,6 +207,20 @@ impl ToggleKeys {
#[cfg(test)]
mod tests {
/// `KeyMapping.resetToggleKeys` (a new `LocalPlayer`): a sneak and a sprint toggled on in one
/// world are off in the next.
#[test]
fn resetting_the_toggle_keys_puts_every_mapping_up() {
let mut keys = ToggleKeys::default();
keys.key(0, KeyAction::Press, false, true);
keys.key(1, KeyAction::Press, false, true);
assert!(keys.is_down(0) && keys.is_down(1));
for key in &mut keys.0 {
key.reset();
}
assert!(!keys.is_down(0) && !keys.is_down(1));
}
use super::*;
#[test]
fn toggle_flips_on_press_and_ignores_release() {
@@ -549,6 +549,7 @@ pub fn fullscreen_request(
/// it from the `horizon.desktop` entry, macOS from the app bundle). `None` if it can't be read.
pub fn icon() -> Option<winit::window::Icon> {
let mut decoder = png::Decoder::new(std::io::Cursor::new(horizon_theme::ICON_PNG));
decoder.ignore_checksums(true);
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
let mut reader = decoder.read_info().ok()?;
let mut bytes = vec![0; reader.output_buffer_size()?];
+4 -1
View File
@@ -17,6 +17,9 @@ horizon-hdr = { path = "../horizon-hdr" }
horizon-items = { path = "../horizon-items" }
# The narrator option's status and whether the narrator is there (Options.narrator).
horizon-narrator = { path = "../horizon-narrator" }
# OptiFine-format pack files: random entity textures (hud.rs), connected textures
# (connected.rs) and custom item texture rules (cit.rs).
horizon-optifine = { path = "../horizon-optifine" }
horizon-protocol = { path = "../horizon-protocol" }
horizon-shader-api = { path = "../horizon-shader-api" }
horizon-shaders = { path = "../horizon-shaders" }
@@ -70,4 +73,4 @@ workspace = true
[dev-dependencies]
horizon-items = { path = "../horizon-items", features = ["test-support"] }
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out", "msl-out", "spv-out"] }
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out", "glsl-out", "msl-out", "spv-out"] }
@@ -0,0 +1,436 @@
//! How long a big resource pack takes to load and reload, on generated files only (no game
//! resources): a stand-in vanilla root, a server-style zip pack of many namespaces, and a
//! high-resolution pack over vanilla's own textures. Each stage runs a few times; the median
//! is printed as a Markdown table.
//! `cargo run --profile fast -p horizon-client-render --example pack_load_bench [-- --scale 1]`
//! (`.github/workflows/pack-bench.yml` runs it on a branch and on its base, side by side).
use horizon_client_render::{
hud::HudAtlas,
model_library::ParsedModels,
packs::{self, Pack},
sprites::Sprites,
};
use std::{
fs,
path::{Path, PathBuf},
time::Instant,
};
/// A small deterministic generator: the same files on every run and every branch.
struct Noise(u64);
impl Noise {
fn next(&mut self) -> u64 {
self.0 ^= self.0 << 13;
self.0 ^= self.0 >> 7;
self.0 ^= self.0 << 17;
self.0
}
}
/// A `side`x`side` RGBA PNG (`frames` tall when animated) of blotchy pixels, which compress
/// about as well as real textures do.
fn png(noise: &mut Noise, side: u32, frames: u32, alpha: bool) -> Vec<u8> {
let mut out = Vec::new();
{
let mut encoder = png::Encoder::new(&mut out, side, side * frames);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header().unwrap();
let mut pixels = Vec::with_capacity((side * side * frames * 4) as usize);
let mut colour = [0u8; 4];
for i in 0..side * side * frames {
if i % 3 == 0 {
let n = noise.next();
colour = [n as u8, (n >> 8) as u8, (n >> 16) as u8, 255];
if alpha && n >> 40 & 3 == 0 {
colour[3] = 0;
}
}
pixels.extend_from_slice(&colour);
}
writer.write_image_data(&pixels).unwrap();
}
out
}
fn model(noise: &mut Noise, namespace: &str, texture: &str) -> Vec<u8> {
let n = noise.next() % 4;
format!(
r##"{{"parent":"minecraft:block/cube_all","textures":{{"all":"{namespace}:{texture}"}},"elements":[{{"from":[0,0,0],"to":[16,{h},16],"faces":{{"north":{{"texture":"#all","uv":[0,0,16,16]}},"up":{{"texture":"#all"}}}}}}]}}"##,
h = 4 + n * 4
)
.into_bytes()
}
fn write(root: &Path, name: &str, bytes: &[u8]) {
let path = root.join(name);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, bytes).unwrap();
}
/// A deflated zip written by hand (the reader skips CRCs, so they stay zero), at most 65535
/// entries.
#[derive(Default)]
struct ZipOut {
out: Vec<u8>,
central: Vec<u8>,
count: u16,
}
impl ZipOut {
fn put(&mut self, name: &str, body: &[u8]) {
let data = miniz_oxide::deflate::compress_to_vec(body, 6);
let offset = u32::try_from(self.out.len()).unwrap();
let sizes = [data.len() as u32, body.len() as u32];
self.out.extend(0x0403_4b50u32.to_le_bytes());
self.out.extend([20, 0, 0, 0, 8, 0]);
self.out.extend([0; 8]);
sizes.iter().for_each(|s| self.out.extend(s.to_le_bytes()));
self.out.extend((name.len() as u16).to_le_bytes());
self.out.extend([0; 2]);
self.out.extend(name.as_bytes());
self.out.extend(&data);
self.central.extend(0x0201_4b50u32.to_le_bytes());
self.central.extend([20, 0, 20, 0, 0, 0, 8, 0]);
self.central.extend([0; 8]);
sizes
.iter()
.for_each(|s| self.central.extend(s.to_le_bytes()));
self.central.extend((name.len() as u16).to_le_bytes());
self.central.extend([0; 12]);
self.central.extend(offset.to_le_bytes());
self.central.extend(name.as_bytes());
self.count = self.count.checked_add(1).expect("too many zip entries");
}
fn finish(mut self, path: &Path) {
let start = self.out.len() as u32;
self.out.extend(&self.central);
self.out.extend(0x0605_4b50u32.to_le_bytes());
self.out.extend([0; 4]);
self.out.extend(self.count.to_le_bytes());
self.out.extend(self.count.to_le_bytes());
self.out.extend((self.central.len() as u32).to_le_bytes());
self.out.extend(start.to_le_bytes());
self.out.extend([0; 2]);
fs::write(path, self.out).unwrap();
}
}
const META: &[u8] = br#"{"pack":{"description":"bench","min_format":[69,0],"max_format":[999,0]}}"#;
/// The stand-in game resources: vanilla-sized texture folders, models and the stacked JSON
/// files (lang, sounds, fonts, atlases) every reload merges.
fn vanilla(root: &Path, scale: usize, noise: &mut Noise) -> Vec<String> {
let mut blocks = Vec::new();
for i in 0..1100 * scale {
let name = format!("block/b{i}");
let frames = if i % 50 == 0 { 8 } else { 1 };
write(
root,
&format!("assets/minecraft/textures/{name}.png"),
&png(noise, 16, frames, i % 7 == 0),
);
if frames > 1 {
write(
root,
&format!("assets/minecraft/textures/{name}.png.mcmeta"),
br#"{"animation":{"frametime":2}}"#,
);
}
write(
root,
&format!("assets/minecraft/models/block/b{i}.json"),
&model(noise, "minecraft", &name),
);
blocks.push(format!("minecraft:{name}"));
}
for (folder, count, side) in [("item", 1500, 16), ("entity", 900, 64), ("gui", 800, 16)] {
for i in 0..count * scale {
let sub = if folder == "gui" { "sprites/" } else { "" };
write(
root,
&format!(
"assets/minecraft/textures/{folder}/{sub}{}/t{i}.png",
i % 20
),
&png(noise, side, 1, true),
);
}
}
for i in 0..1500 * scale {
write(
root,
&format!("assets/minecraft/models/item/i{i}.json"),
&model(noise, "minecraft", &format!("item/{}/t{i}", i % 20)),
);
}
let lang: String = (0..4000)
.map(|i| format!("\"key.{i}\":\"Value {i}\""))
.collect::<Vec<_>>()
.join(",");
write(
root,
"assets/minecraft/lang/en_us.json",
format!("{{{lang}}}").as_bytes(),
);
write(
root,
"assets/minecraft/sounds.json",
br#"{"a":{"sounds":["a"]}}"#,
);
write(
root,
"assets/minecraft/atlases/blocks.json",
br#"{"sources":[{"type":"directory","source":"block","prefix":"block/"}]}"#,
);
write(root, "pack.mcmeta", META);
blocks
}
/// A server pack the way ItemsAdder-style plugins make them: one zip of many namespaces, each
/// with its own items' textures and models, its sounds and its lang files.
fn server_zip(path: &Path, scale: usize, noise: &mut Noise) {
let mut zip = ZipOut::default();
zip.put("pack.mcmeta", META);
for n in 0..190 {
let ns = format!("plugin{n}");
for i in 0..50 * scale {
let texture = format!("item/{}/t{i}", i % 5);
zip.put(
&format!("assets/{ns}/textures/{texture}.png"),
&png(noise, 32, 1, true),
);
zip.put(
&format!("assets/{ns}/models/item/{}/m{i}.json", i % 5),
&model(noise, &ns, &texture),
);
if i % 10 == 0 {
zip.put(&format!("assets/{ns}/sounds/s{i}.ogg"), &[0; 2048]);
}
}
zip.put(
&format!("assets/{ns}/lang/en_us.json"),
br#"{"item.custom":"Custom"}"#,
);
}
let lang: String = (0..2000)
.map(|i| format!("\"server.{i}\":\"Server {i}\""))
.collect::<Vec<_>>()
.join(",");
zip.put(
"assets/minecraft/lang/en_us.json",
format!("{{{lang}}}").as_bytes(),
);
zip.finish(path);
}
/// A 64x pack over vanilla's own block, item and entity textures, with OptiFine CTM-style
/// tiles beside them: what the big OptiFine packs look like on disk.
fn hd_zip(path: &Path, scale: usize, noise: &mut Noise) {
let mut zip = ZipOut::default();
zip.put("pack.mcmeta", META);
for i in 0..1100 * scale {
zip.put(
&format!("assets/minecraft/textures/block/b{i}.png"),
&png(noise, 64, 1, i % 7 == 0),
);
if i % 4 == 0 {
for tile in 0..12 {
zip.put(
&format!("assets/minecraft/optifine/ctm/b{i}/{tile}.png"),
&png(noise, 64, 1, false),
);
}
zip.put(
&format!("assets/minecraft/optifine/ctm/b{i}/b{i}.properties"),
format!("matchTiles=b{i}\nmethod=ctm\ntiles=0-11\n").as_bytes(),
);
}
}
for i in 0..750 * scale {
zip.put(
&format!("assets/minecraft/textures/item/{}/t{i}.png", i % 20),
&png(noise, 64, 1, true),
);
}
zip.finish(path);
}
fn median(mut ms: Vec<f64>) -> f64 {
ms.sort_by(f64::total_cmp);
ms[ms.len() / 2]
}
/// `f`'s median time over `runs`, with `before` (not timed) ahead of each run.
fn time(runs: usize, mut before: impl FnMut(), mut f: impl FnMut()) -> f64 {
median(
(0..runs)
.map(|_| {
before();
let started = Instant::now();
f();
started.elapsed().as_secs_f64() * 1000.0
})
.collect(),
)
}
fn zip_pack(id: &str, path: PathBuf) -> Pack {
// Read the way `packs::folder` reads a `resourcepacks/` zip.
let dir = path.parent().unwrap().join(format!("{id}-dir"));
fs::create_dir_all(&dir).unwrap();
let at = dir.join(path.file_name().unwrap());
fs::rename(&path, &at).unwrap();
packs::folder(&dir, packs::RESOURCE_FORMAT)
.into_iter()
.next()
.unwrap_or_else(|| panic!("{id} is not a pack"))
}
fn main() {
let args: Vec<String> = std::env::args().collect();
let scale = args
.iter()
.position(|a| a == "--scale")
.and_then(|i| args.get(i + 1))
.map_or(1, |s| s.parse().unwrap());
let runs = 3;
let scratch = std::env::temp_dir().join(format!("horizon-pack-bench-{}", std::process::id()));
let _ = fs::remove_dir_all(&scratch);
fs::create_dir_all(&scratch).unwrap();
let mut noise = Noise(0x9e37_79b9_7f4a_7c15);
let vanilla_root = scratch.join("vanilla");
let blocks = vanilla(&vanilla_root, scale, &mut noise);
server_zip(&scratch.join("server.zip"), scale, &mut noise);
hd_zip(&scratch.join("hd.zip"), scale, &mut noise);
let server = zip_pack("server", scratch.join("server.zip"));
let hd = zip_pack("hd", scratch.join("hd.zip"));
let cache = scratch.join("cache");
let both = [hd.clone(), server.clone()];
let mut rows: Vec<(String, f64)> = Vec::new();
let mut row = |name: &str, ms: f64| {
eprintln!("{name}: {ms:.1} ms");
rows.push((name.to_owned(), ms));
};
let fresh = || {
let _ = fs::remove_dir_all(&cache);
};
row(
"overlay: build, both packs (cold)",
time(runs, fresh, || {
packs::resource_root(&vanilla_root, &both, &cache).unwrap();
}),
);
let root = packs::resource_root(&vanilla_root, &both, &cache).unwrap();
row(
"overlay: reuse, both packs (cache hit)",
time(
runs,
|| {},
|| {
packs::resource_root(&vanilla_root, &both, &cache).unwrap();
},
),
);
// The pack screen: one pack turned off, then on again.
row(
"overlay: toggle the server pack off (new selection)",
time(
runs,
|| {
let only =
packs::resource_root(&vanilla_root, std::slice::from_ref(&hd), &cache).unwrap();
fs::remove_dir_all(&only).unwrap();
},
|| {
packs::resource_root(&vanilla_root, std::slice::from_ref(&hd), &cache).unwrap();
},
),
);
row(
"models: parse every model",
time(
runs,
|| {},
|| {
std::hint::black_box(ParsedModels::load(&root));
},
),
);
let sprite_cache = root.join("horizon-sprites.cache");
// OptiFine connected-texture tiles, inserted by their path as `ConnectedTextures` does.
let tiles: Vec<String> = (0..1100 * scale)
.step_by(4)
.flat_map(|i| (0..12).map(move |t| format!("assets/minecraft/optifine/ctm/b{i}/{t}.png")))
.collect();
let sprites = |cold: bool, prefetch: bool, names: &[String], files: bool| {
time(
runs,
|| {
if cold {
let _ = fs::remove_file(&sprite_cache);
}
},
|| {
let mut sprites = Sprites::with_disk_cache(&root);
if prefetch {
sprites.prefetch(&root, names.iter().map(String::as_str));
}
for name in names {
if files {
sprites.insert_file(&root, name).unwrap();
} else {
sprites.insert(&root, name).unwrap();
}
}
sprites.save_disk_cache();
sprites.finish();
std::hint::black_box(&sprites.pixels);
},
)
};
row(
"block sprites: one by one (no cache)",
sprites(true, false, &blocks, false),
);
row(
"block sprites: prefetched (no cache)",
sprites(true, true, &blocks, false),
);
row(
"block sprites: from the disk cache",
sprites(false, false, &blocks, false),
);
row("CTM tiles: one by one", sprites(false, false, &tiles, true));
row("CTM tiles: prefetched", sprites(false, true, &tiles, true));
row(
"GUI atlas: decode and stitch, both packs",
time(
runs,
|| {},
|| {
if let Err(e) = HudAtlas::build_over(&root, &vanilla_root) {
eprintln!("GUI atlas failed: {e}");
}
},
),
);
row(
"GUI atlas: vanilla alone",
time(
runs,
|| {},
|| {
let _ = HudAtlas::build(&vanilla_root);
},
),
);
let cores = std::thread::available_parallelism().map_or(1, |n| n.get());
println!("| Stage (scale {scale}, {cores} threads, median of {runs}) | ms |");
println!("|---|---:|");
for (name, ms) in &rows {
println!("| {name} | {ms:.0} |");
}
let _ = fs::remove_dir_all(&scratch);
}
+38 -1
View File
@@ -93,6 +93,8 @@ pub struct RenderAssets {
pub model_gallery: Vec<Value>,
pub animations: Vec<crate::sprites::SpriteAnimation>,
pub sprite_layers: std::collections::BTreeMap<String, u32>,
/// OptiFine's emissive block overlays: base sprite and overlay sprite names.
pub emissive: Vec<(String, String)>,
pub translucent_layers: Vec<bool>,
/// Each layer's `MipmapGenerator` settings.
pub sprite_mips: Vec<crate::mipmap::Settings>,
@@ -101,6 +103,8 @@ pub struct RenderAssets {
pub pbr_normals: Vec<u8>,
pub pbr_specular: Vec<u8>,
pub pbr_size: u32,
/// OptiFine connected textures (empty without `optifine/ctm` files).
pub connected: crate::connected::ConnectedTextures,
}
fn invalid(message: &str) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, message)
@@ -240,8 +244,39 @@ impl RenderAssets {
for stage in 0..10 {
sprites.insert(root, &format!("minecraft:block/destroy_stage_{stage}"))?;
}
if draws {
let mut emissive = Vec::new();
let connected = if draws {
crate::native_emissive::load(root, &mut sprites);
// A side's sprite, for rules that connect by tile: the first quad facing it.
let catalog = &models.catalog;
let mut found = Vec::new();
crate::connected::ConnectedTextures::load(
root,
registry,
&mut sprites,
|state, face| {
let side = horizon_assets::model::Direction::ALL[face as usize];
catalog
.models_at(
state,
horizon_core::position::BlockPos::new(0, 0, 0),
&mut found,
)
.ok()?;
found
.iter()
.flat_map(|m| &m.faces)
.find(|f| f.quad.facing == side)
.map(|f| f.layer)
},
)
} else {
Default::default()
};
// After the connected tiles: a tile identical to a block shares its layer, and the
// overlays skip shared layers.
if draws {
emissive = crate::native_emissive::load_optifine(root, &mut sprites);
}
sprites.save_disk_cache();
if draws {
@@ -262,11 +297,13 @@ impl RenderAssets {
model_gallery: models.states,
animations: sprites.animations,
sprite_layers: sprites.layers,
emissive,
translucent_layers: sprites.translucent,
sprite_mips: sprites.mips,
pbr_normals: sprites.normals,
pbr_specular: sprites.specular,
pbr_size: sprites.pbr_size,
connected,
})
}
}
@@ -208,10 +208,26 @@ pub fn list_textures(root: &Path) -> Vec<String> {
out.sort();
out
}
/// The file of a texture identifier.
/// The file of a texture identifier. A Custom Item Textures id (`cit_assets`) names its
/// resource path, wherever the folder: `directory` is not used for it. An id whose path would
/// leave the root (a pack's model may write any) names a file that is never there.
pub fn texture_path(root: &Path, texture: &str, directory: &str) -> PathBuf {
// No file can have a NUL in its name, so this one is never there.
const NOWHERE: &str = "assets/minecraft/textures/horizon/\0refused.png";
if texture.starts_with("horizon_cit:") {
return root.join(
crate::cit_assets::resource(texture, "png")
.as_deref()
.unwrap_or(NOWHERE),
);
}
let (namespace, path) = texture.split_once(':').unwrap_or(("minecraft", texture));
root.join(format!("assets/{namespace}/{directory}/{path}.png"))
let rel = format!("assets/{namespace}/{directory}/{path}.png");
root.join(if horizon_optifine::paths::valid(&rel) {
&rel
} else {
NOWHERE
})
}
/// `SpriteSourceList.list`: every sprite id of the atlas and its origin.
@@ -172,6 +172,7 @@ fn color(v: &Value) -> Option<u32> {
fn colormap(root: &Path, name: &str) -> Result<Vec<u32>> {
let path = root.join(format!("assets/minecraft/textures/colormap/{name}.png"));
let mut decoder = png::Decoder::new(std::io::BufReader::new(fs::File::open(path)?));
decoder.ignore_checksums(true);
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
let mut reader = decoder.read_info()?;
let mut bytes = vec![0; reader.output_buffer_size().ok_or("colormap too large")?];
@@ -0,0 +1,306 @@
//! Bulk block edits: runs of blocks up columns written straight into the
//! resident sections, a section unpacked and packed once however many of its
//! blocks change, instead of a `World::set` per block. Nothing round them is
//! told (as `fill ... strict`); the light of each chunk changed is worked out
//! again whole, off the game thread, rather than block by block, and each
//! section changed is remeshed once.
use crate::chunk::index;
use crate::fast_hash::FxHashMap;
use crate::world::World;
/// A run of one column, resolved: blocks `y0..=y1` of column (x, z) set to
/// `state` where filter `only` (if any) passes the block there.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Run {
pub x: i32,
pub z: i32,
pub y0: i32,
pub y1: i32,
pub state: u32,
pub only: Option<u16>,
}
/// What an edit changed.
#[derive(Debug, Default)]
pub struct Outcome {
/// Blocks changed.
pub changed: u64,
/// Chunks with a change.
pub chunks: usize,
/// Changes of the states `tracked` asked for (block entities, watched
/// blocks): position, previous and new state.
pub tracked: Vec<((i32, i32, i32), u32, u32)>,
}
/// Per state answers, worked out once each.
struct Memo(Vec<u8>);
impl Memo {
#[inline]
fn get(&mut self, state: u32, ask: impl FnOnce(u32) -> bool) -> bool {
let i = state as usize;
if i >= self.0.len() {
self.0.resize(i + 1, 0);
}
if self.0[i] == 0 {
self.0[i] = 1 + u8::from(ask(state));
}
self.0[i] == 2
}
}
impl World {
/// Make `runs` in order, each seeing the changes of those before it.
/// `matches(filter, state)` says whether a filter passes a state, and
/// `tracked(state)` whether its changes are reported; each is asked once
/// per state. Runs in unloaded chunks and the parts of runs outside each
/// chunk's own column (`Chunk::layout`) are left out.
///
/// Unlike `World::set`, this records nothing per block: no
/// `block_entities.state_changed` (a caller adds or removes block
/// entities itself, from `Outcome::tracked`), no `watched_writes` (ask
/// for those states through `tracked`), no `block_log` entry (the chunk
/// goes in `bulk_log` instead, sent whole), no `path_writes` (mobs'
/// paths are not recomputed for these blocks) and no `light_edits` (the
/// chunk and its neighbours are lit again whole, through `dirty_light`).
pub fn edit_runs(
&mut self,
runs: &[Run],
mut matches: impl FnMut(u16, u32) -> bool,
mut tracked: impl FnMut(u32) -> bool,
) -> Outcome {
let mut out = Outcome::default();
// the runs of each chunk, in order, and the chunks in the order they were first named
let mut by_chunk: FxHashMap<(i32, i32), Vec<u32>> = FxHashMap::default();
let mut order = Vec::new();
for (i, r) in runs.iter().enumerate() {
if r.y1 < r.y0 {
continue;
}
let key = (r.x >> 4, r.z >> 4);
by_chunk
.entry(key)
.or_insert_with(|| {
order.push(key);
Vec::new()
})
.push(i as u32);
}
let mut filter_memo: Vec<Memo> = Vec::new();
let mut tracked_memo = Memo(Vec::new());
let mut values: Vec<Option<Box<[u32; 4096]>>> = Vec::new();
let mut changed: Vec<bool> = Vec::new();
for key in order {
let Some(chunk) = self.chunks.get(&key) else {
continue;
};
let (min_y, sections) = (chunk.min_y(), chunk.sections.len());
let max_y = chunk.max_y();
let list = &by_chunk[&key];
changed.clear();
changed.resize(sections, false);
values.clear();
values.resize_with(sections, || None);
let mut any = 0u64;
for &i in list {
let r = runs[i as usize];
let (lx, lz) = (r.x & 15, r.z & 15);
let (y0, y1) = (r.y0.max(min_y), r.y1.min(max_y - 1));
let mut y = y0;
while y <= y1 {
let s = ((y - min_y) >> 4) as usize;
let top = (min_y + (s as i32 + 1) * 16 - 1).min(y1);
let vals = values[s].get_or_insert_with(|| {
let mut v = Box::new([0u32; 4096]);
chunk.sections[s].unpack(&mut v);
v
});
for yy in y..=top {
let at = index(lx, yy, lz);
let old = vals[at];
if old == r.state {
continue;
}
if let Some(f) = r.only {
let f = f as usize;
if filter_memo.len() <= f {
filter_memo.resize_with(f + 1, || Memo(Vec::new()));
}
if !filter_memo[f].get(old, |s| matches(f as u16, s)) {
continue;
}
}
vals[at] = r.state;
changed[s] = true;
any += 1;
if tracked_memo.get(old, &mut tracked)
|| tracked_memo.get(r.state, &mut tracked)
{
out.tracked.push(((r.x, yy, r.z), old, r.state));
}
}
y = top + 1;
}
}
if any == 0 {
continue;
}
out.changed += any;
out.chunks += 1;
let chunk =
std::sync::Arc::make_mut(self.chunks.get_mut(&key).expect("looked up above"));
for s in 0..sections {
if changed[s]
&& let Some(v) = &values[s]
{
chunk.replace_blocks(s, v);
}
}
self.revision = self.revision.wrapping_add(1);
self.modified.insert(key);
if self.block_log.is_some() {
self.bulk_log.insert(key);
}
// remeshed once (with the neighbours whose faces and light it touches), lit again whole
for (s, _) in changed.iter().enumerate().filter(|c| *c.1) {
for ds in -1..=1 {
let sy = s as i32 + ds;
if !(0..sections as i32).contains(&sy) {
continue;
}
for dz in -1..=1 {
for dx in -1..=1 {
self.dirty_sections.insert((key.0 + dx, sy, key.1 + dz));
}
}
}
}
for dz in -1..=1 {
for dx in -1..=1 {
self.dirty_light.insert((key.0 + dx, key.1 + dz));
}
}
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::chunk::Chunk;
use horizon_world::registry::BlockStateId;
use std::sync::Arc;
fn world() -> World {
let mut w = World::new(BlockStateId(0), 0);
for cz in -1..=1 {
for cx in -1..=1 {
w.chunks.insert((cx, cz), Arc::new(Chunk::empty(cx, cz)));
}
}
// stone up to y 63
for z in -16..32 {
for x in -16..32 {
for y in -64..64 {
w.set(x, y, z, BlockStateId(1)).unwrap();
}
}
}
w.dirty_light.clear();
w.dirty_sections.clear();
w.block_log = Some(Vec::new());
w
}
fn run(x: i32, z: i32, y0: i32, y1: i32, state: u32, only: Option<u16>) -> Run {
Run {
x,
z,
y0,
y1,
state,
only,
}
}
#[test]
fn runs_change_their_blocks_in_order() {
let mut w = world();
let runs = [
run(3, 4, 10, 40, 0, None),
run(3, 4, 10, 12, 5, None),
// only where it is air: over the stone, and the three blocks of 5 stay
run(3, 4, 0, 80, 7, Some(0)),
run(-1, 20, -70, 400, 9, None),
];
let out = w.edit_runs(&runs, |_, s| s == 0, |_| false);
assert_eq!(w.block(3, 9, 4).0, 1);
assert_eq!(w.block(3, 11, 4).0, 5);
assert_eq!(w.block(3, 13, 4).0, 7);
assert_eq!(w.block(3, 70, 4).0, 7);
assert_eq!(w.block(3, 81, 4).0, 0);
assert_eq!(w.block(3, 0, 4).0, 1);
assert_eq!(w.block(-1, -64, 20).0, 9);
assert_eq!(w.block(-1, 319, 20).0, 9);
assert_eq!(w.block(0, 30, 20).0, 1);
// 31 dug, 3 set again, the air over them filled (28, and 17 over the stone), the whole of the other column
assert_eq!(out.changed, 31 + 3 + 45 + 384);
assert_eq!(out.chunks, 2);
assert!(w.bulk_log.contains(&(0, 0)) && w.bulk_log.contains(&(-1, 1)));
assert!(w.dirty_light.contains(&(1, 1)) && w.dirty_light.contains(&(-2, 2)));
assert!(w.dirty_sections.contains(&(1, 4, 1)));
// the per-block log is left to the blocks set one at a time
assert!(w.block_log.as_ref().unwrap().is_empty());
}
#[test]
fn unloaded_chunks_and_unchanged_blocks_are_left_out() {
let mut w = world();
let out = w.edit_runs(
&[run(100, 100, 0, 10, 0, None), run(0, 0, 0, 10, 1, None)],
|_, _| true,
|_| false,
);
assert_eq!(out.changed, 0);
assert_eq!(out.chunks, 0);
assert!(w.bulk_log.is_empty() && w.dirty_light.is_empty());
}
#[test]
fn tracked_states_are_reported() {
let mut w = world();
let out = w.edit_runs(&[run(1, 1, 60, 65, 3, None)], |_, _| true, |s| s == 3);
assert_eq!(out.tracked.len(), 6);
assert_eq!(out.tracked[0], ((1, 60, 1), 1, 3));
assert_eq!(out.tracked[5], ((1, 65, 1), 0, 3));
}
#[test]
fn heightmaps_follow_the_edit() {
let mut w = world();
let air = |s: u32| s == 0;
assert_eq!(w.world_surface(5, 5, |s| air(s.0)), 64);
w.edit_runs(&[run(5, 5, 20, 63, 0, None)], |_, _| true, |_| false);
assert_eq!(w.world_surface(5, 5, |s| air(s.0)), 20);
w.edit_runs(&[run(5, 5, 90, 90, 2, None)], |_, _| true, |_| false);
assert_eq!(w.world_surface(5, 5, |s| air(s.0)), 91);
}
#[test]
fn runs_follow_each_chunks_own_layout() {
use crate::chunk::ColumnLayout;
// a data pack's taller dimension: -128..=511
let tall = ColumnLayout::new(-128, 640).unwrap();
let mut w = World::new(BlockStateId(0), 0);
w.chunks
.insert((0, 0), Arc::new(Chunk::with_layout(0, 0, tall)));
let out = w.edit_runs(&[run(2, 3, -500, 900, 4, None)], |_, _| true, |_| false);
assert_eq!(out.changed, 640);
let chunk = &w.chunks[&(0, 0)];
assert_eq!(chunk.block(2, -128, 3).0, 4);
assert_eq!(chunk.block(2, -65, 3).0, 4);
assert_eq!(chunk.block(2, 320, 3).0, 4);
assert_eq!(chunk.block(2, 511, 3).0, 4);
assert!(w.dirty_sections.contains(&(0, 39, 0)));
assert!(!w.dirty_sections.contains(&(0, 40, 0)));
}
}
@@ -23,7 +23,7 @@
//! Which states tick is a table by state id, built by the game from the block
//! registry; whether a slot is made is decided when the slot is promoted at
//! the start of the next tick, from the block state then.
use crate::chunk::block_entity::{BlockEntity, ModContainer, ModFluidTanks};
use crate::chunk::block_entity::{BlockEntity, ModContainer, ModEnergyBuffer, ModFluidTanks};
use crate::fast_hash::{FxHashMap, FxHashSet};
use std::collections::HashMap;
use std::sync::Arc;
@@ -46,6 +46,7 @@ pub enum Ticking {
#[derive(Default)]
pub struct BlockEntities {
map: HashMap<Pos, BlockEntity>,
attachments: HashMap<Pos, horizon_world::attachments::Attachments>,
/// `Level.blockEntityTickers`: positions and the slot each was given.
tickers: Arc<Vec<(Pos, u32)>>,
/// `LevelChunk.tickersInLevel`: the live slot of each ticking position.
@@ -76,6 +77,41 @@ pub struct BlockEntities {
/// The fluid tanks of mod blocks' block entities, by block state id;
/// built with `states`, like the containers.
mod_fluids: FxHashMap<u32, Arc<ModFluidTanks>>,
/// The energy buffers of mod blocks' block entities, by block state id;
/// built with `states`, like the containers.
mod_energy: FxHashMap<u32, Arc<ModEnergyBuffer>>,
/// The positions of the mod block entities (`BlockEntity::Mod`) by chunk,
/// for the renderer: the mesher lists block entities by their template,
/// which leaves out a mod block whose template has none. One taken out
/// (`take`) stays listed until `put` or `remove`.
mods: FxHashMap<(i32, i32), FxHashSet<Pos>>,
/// The positions of the block entities that draw a bounding box (structure and test
/// instance blocks, `shows_box`), so a frame looks at them without scanning the map.
/// Like `mods`, one taken out stays listed until `put` or `remove`.
boxes: FxHashSet<Pos>,
}
/// A structure or test instance block: the block entities whose box the frame may draw.
fn shows_box(entity: &BlockEntity) -> bool {
matches!(
entity,
BlockEntity::StructureBlock(_) | BlockEntity::TestInstanceBlock(_)
)
}
fn chunk_of((x, _, z): Pos) -> (i32, i32) {
(x >> 4, z >> 4)
}
/// Takes `pos` out of its chunk's mod block entity set.
fn forget_mod(mods: &mut FxHashMap<(i32, i32), FxHashSet<Pos>>, pos: Pos) {
let chunk = chunk_of(pos);
if let Some(set) = mods.get_mut(&chunk) {
set.remove(&pos);
if set.is_empty() {
mods.remove(&chunk);
}
}
}
impl std::ops::Deref for BlockEntities {
@@ -172,6 +208,13 @@ impl BlockEntities {
pub fn mod_fluids(&self, state: u32) -> Option<&Arc<ModFluidTanks>> {
self.mod_fluids.get(&state)
}
pub fn set_mod_energy(&mut self, energy: FxHashMap<u32, Arc<ModEnergyBuffer>>) {
self.mod_energy = energy;
}
/// The energy buffer of a mod block's block entity in block state `state`.
pub fn mod_energy(&self, state: u32) -> Option<&Arc<ModEnergyBuffer>> {
self.mod_energy.get(&state)
}
fn next(&mut self) -> u32 {
let n = self.next_slot;
@@ -190,6 +233,65 @@ impl BlockEntities {
self.pending.remove(pos);
}
/// Attachment map for a live block entity.
pub fn attachments_mut(
&mut self,
pos: Pos,
) -> Option<&mut horizon_world::attachments::Attachments> {
if !self.map.contains_key(&pos) && !self.out.contains(&pos) {
return None;
}
Some(self.attachments.entry(pos).or_default())
}
/// Attachment maps in a loaded chunk, for a save snapshot.
pub fn chunk_attachments(
&self,
x: i32,
z: i32,
) -> Vec<(Pos, horizon_world::attachments::Attachments)> {
self.attachments
.iter()
.filter(|(p, _)| p.0 >> 4 == x && p.2 >> 4 == z)
.map(|(p, data)| (*p, data.clone()))
.collect()
}
/// Restore a loaded block entity's attachment map.
pub fn restore_attachments(&mut self, pos: Pos, data: horizon_world::attachments::Attachments) {
if self.map.contains_key(&pos) {
self.attachments.insert(pos, data);
}
}
/// Whether any mod block entity is in the map: until one is, a block change
/// never looks one up (`LevelAccess::has_mod_block_entity`).
pub fn has_mods(&self) -> bool {
!self.mods.is_empty()
}
/// The structure and test instance blocks (a frame draws their boxes), in no order.
pub fn boxes(&self) -> impl Iterator<Item = (&Pos, &BlockEntity)> {
self.boxes
.iter()
.filter_map(|pos| Some((pos, self.map.get(pos)?)))
}
/// Keeps `pos` listed in `boxes` exactly while `entity` shows a box.
fn note_box(&mut self, pos: Pos, entity: Option<&BlockEntity>) {
if entity.is_some_and(shows_box) {
self.boxes.insert(pos);
} else {
self.boxes.remove(&pos);
}
}
/// The mod block entities within `radius` blocks of `x`, `z` (by chunk, so
/// far ones are skipped whole; the caller checks each distance).
pub fn mods_near(&self, x: f64, z: f64, radius: f64) -> impl Iterator<Item = &Pos> {
let far = move |c: i32, at: f64| {
let (lo, hi) = (f64::from(c * 16), f64::from(c * 16 + 16));
at < lo - radius || at > hi + radius
};
self.mods
.iter()
.filter(move |((cx, cz), _)| !far(*cx, x) && !far(*cz, z))
.flat_map(|(_, set)| set)
}
pub fn get_mut(&mut self, pos: &Pos) -> Option<&mut BlockEntity> {
self.map.get_mut(pos)
}
@@ -201,11 +303,33 @@ impl BlockEntities {
}
/// `LevelChunk.setBlockEntity` (+ `addAndRegisterBlockEntity`): a new
/// block entity may get a slot; one replacing another keeps its slot.
/// One of the same type rewrites it in place (a jukebox taking a disc,
/// `/data merge block`), where vanilla keeps the object, so its
/// attachments stay; any other replacement starts them over.
pub fn insert(&mut self, pos: Pos, entity: BlockEntity) -> Option<BlockEntity> {
let new_is_mod = matches!(entity, BlockEntity::Mod(_));
let same_type = |old: &BlockEntity| match (old, &entity) {
(BlockEntity::Mod(old), BlockEntity::Mod(new)) => old.id == new.id,
(old, new) => std::mem::discriminant(old) == std::mem::discriminant(new),
};
if !self.map.get(&pos).is_some_and(same_type) {
self.attachments.remove(&pos);
}
let new_box = shows_box(&entity);
let old = self.map.insert(pos, entity);
if old.is_none() {
self.add(pos);
}
if new_box {
self.boxes.insert(pos);
} else if old.as_ref().is_some_and(shows_box) {
self.boxes.remove(&pos);
}
if new_is_mod {
self.mods.entry(chunk_of(pos)).or_default().insert(pos);
} else if matches!(old, Some(BlockEntity::Mod(_))) {
forget_mod(&mut self.mods, pos);
}
old
}
/// The block entity at `pos`, made by `make` when there is none.
@@ -214,15 +338,29 @@ impl BlockEntities {
pos: Pos,
make: impl FnOnce() -> BlockEntity,
) -> &mut BlockEntity {
if !self.map.contains_key(&pos) {
let absent = !self.map.contains_key(&pos);
if absent {
self.add(pos);
}
self.map.entry(pos).or_insert_with(make)
let entity = self.map.entry(pos).or_insert_with(make);
if absent && matches!(entity, BlockEntity::Mod(_)) {
self.mods.entry(chunk_of(pos)).or_default().insert(pos);
}
if absent && shows_box(entity) {
self.boxes.insert(pos);
}
entity
}
/// `LevelChunk.removeBlockEntity`: the block entity and its slot.
pub fn remove(&mut self, pos: &Pos) -> Option<BlockEntity> {
self.attachments.remove(pos);
self.cancel(pos);
self.map.remove(pos)
let removed = self.map.remove(pos);
if matches!(removed, Some(BlockEntity::Mod(_))) {
forget_mod(&mut self.mods, *pos);
}
self.boxes.remove(pos);
removed
}
/// The block entity at `pos`, out of the map for a while: its slot stays
/// for `put` to bring it back (a tick working on it).
@@ -236,6 +374,10 @@ impl BlockEntities {
/// Brings back what `take` took, in its slot.
pub fn put(&mut self, pos: Pos, entity: BlockEntity) {
self.out.remove(&pos);
if matches!(entity, BlockEntity::Mod(_)) {
self.mods.entry(chunk_of(pos)).or_default().insert(pos);
}
self.note_box(pos, Some(&entity));
if self.map.insert(pos, entity).is_none()
&& !self.live.contains_key(&pos)
&& !self.pending.contains_key(&pos)
@@ -246,18 +388,30 @@ impl BlockEntities {
/// Removes the block entities `keep` refuses, and their slots
/// (`clearAllBlockEntities` of unloaded chunks).
pub fn retain(&mut self, mut keep: impl FnMut(&Pos, &mut BlockEntity) -> bool) {
let (live, pending, freed) = (&mut self.live, &mut self.pending, &mut self.freed);
let (live, pending, freed, attachments) = (
&mut self.live,
&mut self.pending,
&mut self.freed,
&mut self.attachments,
);
let (mods, boxes) = (&mut self.mods, &mut self.boxes);
self.map.retain(|p, e| {
let kept = keep(p, e);
if !kept {
attachments.remove(p);
*freed |= live.remove(p).is_some();
pending.remove(p);
forget_mod(mods, *p);
boxes.remove(p);
}
kept
});
}
pub fn clear(&mut self) {
self.attachments.clear();
self.map.clear();
self.mods.clear();
self.boxes.clear();
self.tickers = Arc::default();
self.live.clear();
self.added.clear();
@@ -274,9 +428,18 @@ impl BlockEntities {
pub fn load_chunk(&mut self, entities: Vec<(Pos, BlockEntity)>) {
let order = chunk_map_order(entities.iter().map(|(p, _)| *p));
for (p, e) in entities {
if self.map.insert(p, e).is_some() {
self.attachments.remove(&p);
let new_is_mod = matches!(e, BlockEntity::Mod(_));
self.note_box(p, Some(&e));
let old = self.map.insert(p, e);
if old.is_some() {
self.replaced.insert(p);
}
if new_is_mod {
self.mods.entry(chunk_of(p)).or_default().insert(p);
} else if matches!(old, Some(BlockEntity::Mod(_))) {
forget_mod(&mut self.mods, p);
}
}
for p in order {
self.add(p);
@@ -369,6 +532,84 @@ mod tests {
.collect()
}
fn near(b: &BlockEntities, x: f64, z: f64, radius: f64) -> Vec<Pos> {
let mut found: Vec<Pos> = b.mods_near(x, z, radius).copied().collect();
found.sort();
found
}
#[test]
fn mod_block_entities_are_listed_by_chunk_until_they_go() {
let mut b = level();
let modded = || BlockEntity::Mod(Box::default());
b.insert((1, 64, 1), modded());
b.insert((200, 64, 1), modded());
b.insert((2, 64, 2), hopper());
b.get_or_insert_with((3, 64, 3), modded);
b.load_chunk(vec![((4, 64, 4), modded()), ((5, 64, 5), hopper())]);
// Only mods, and only chunks within reach.
assert_eq!(
near(&b, 0.0, 0.0, 64.0),
[(1, 64, 1), (3, 64, 3), (4, 64, 4)]
);
assert_eq!(near(&b, 200.0, 0.0, 8.0), [(200, 64, 1)]);
// Taken out for a tick, still listed; removed or replaced, gone.
let taken = b.take(&(1, 64, 1)).unwrap();
assert_eq!(near(&b, 0.0, 0.0, 64.0).len(), 3);
b.put((1, 64, 1), taken);
b.remove(&(3, 64, 3));
b.insert((4, 64, 4), hopper());
assert_eq!(near(&b, 0.0, 0.0, 64.0), [(1, 64, 1)]);
b.retain(|p, _| p.0 != 1);
assert!(near(&b, 0.0, 0.0, 64.0).is_empty());
b.clear();
assert!(near(&b, 200.0, 0.0, 8.0).is_empty());
}
#[test]
fn attachments_follow_block_entity_lifetime() {
use horizon_world::attachments::AttachmentType;
let ty = AttachmentType {
namespace: "test",
name: "value",
default: || 0,
codec: None,
copy_on_death: false,
};
let p = (1, 64, 2);
let mut entities = level();
assert!(entities.attachments_mut(p).is_none());
entities.insert(p, hopper());
entities.attachments_mut(p).unwrap().set(&ty, 4).unwrap();
let held = entities.take(&p).unwrap();
entities.put(p, held);
assert_eq!(entities.attachments_mut(p).unwrap().get(&ty), Ok(4));
entities.insert(p, hopper());
assert_eq!(entities.attachments_mut(p).unwrap().get(&ty), Ok(4));
let machine = |id: &str| {
BlockEntity::Mod(Box::new(
crate::chunk::block_entity::ModBlockEntityFields {
id: id.into(),
..Default::default()
}
.into(),
))
};
entities.insert(p, machine("test:a"));
assert_eq!(entities.attachments_mut(p).unwrap().get(&ty), Ok(0));
entities.attachments_mut(p).unwrap().set(&ty, 4).unwrap();
entities.insert(p, machine("test:a"));
assert_eq!(entities.attachments_mut(p).unwrap().get(&ty), Ok(4));
entities.insert(p, machine("test:b"));
assert_eq!(entities.attachments_mut(p).unwrap().get(&ty), Ok(0));
entities.attachments_mut(p).unwrap().set(&ty, 4).unwrap();
entities.remove(&p);
entities.insert(p, hopper());
assert_eq!(entities.attachments_mut(p).unwrap().get(&ty), Ok(0));
entities.attachments_mut(p).unwrap().set(&ty, 5).unwrap();
entities.retain(|_, _| false);
entities.insert(p, hopper());
assert_eq!(entities.attachments_mut(p).unwrap().get(&ty), Ok(0));
}
#[test]
fn slots_follow_vanilla_lifecycle() {
let mut b = level();
@@ -453,4 +694,43 @@ mod tests {
b.state_changed(p, 2, 1);
assert_eq!(order(&mut b, &s), [q, p]);
}
#[test]
fn boxes_list_exactly_the_structure_and_test_blocks() {
let test_block = || BlockEntity::TestInstanceBlock(Box::default());
let listed = |b: &BlockEntities| {
let mut v: Vec<Pos> = b.boxes().map(|(p, _)| *p).collect();
v.sort();
v
};
let mut b = level();
let (p, q, r) = ((0, 0, 0), (1, 0, 0), (2, 0, 0));
b.insert(p, test_block());
b.insert(q, hopper());
assert_eq!(listed(&b), [p]);
// Replaced by another kind, and back.
b.insert(p, hopper());
assert_eq!(listed(&b), []);
b.insert(q, test_block());
b.get_or_insert_with(r, test_block);
assert_eq!(listed(&b), [q, r]);
// Taken out: not shown until put back; put back as another kind: gone.
let taken = b.take(&q).unwrap();
assert_eq!(listed(&b), [r]);
b.put(q, taken);
assert_eq!(listed(&b), [q, r]);
let taken = b.take(&r).unwrap();
drop(taken);
b.put(r, hopper());
assert_eq!(listed(&b), [q]);
b.remove(&q);
assert_eq!(listed(&b), []);
b.load_chunk(vec![(p, test_block()), (q, hopper())]);
assert_eq!(listed(&b), [p]);
b.retain(|pos, _| *pos != p);
assert_eq!(listed(&b), []);
b.insert(r, test_block());
b.clear();
assert!(b.boxes.is_empty());
}
}
@@ -0,0 +1,630 @@
//! OptiFine custom entity models over the block entities' models (`block_entity_render`'s
//! `MODELS`): `chest.jem`, `shulker_box.jem`, `banner.jem`, `bell.jem`, `head_zombie.jem`... (the
//! "Block entity name" table of OptiFine's `cem_model.txt`, and Entity Model Features' one-file
//! `banner`), found and read as the mobs' are ([`horizon_optifine::cem::files`]). Each entry
//! replaces or joins the boxes of the vanilla part it names and takes its `scale`; animations
//! don't run yet. A `.jem`'s texture paints the models it reshapes instead of vanilla's when the
//! atlas has it.
//!
//! An entry's part is OptiFine's name for it, else the vanilla part's own name. Trapped and
//! ender chests take only their own file, and copper chests (no CEM name) keep vanilla's chest.
//! Lectern and enchanting-table books take theirs, else `book`. Signs, hanging signs and beds are
//! block models here, so their files are not read; copper golem statues have no CEM name.
use crate::block_entity_render::ModelPart;
use horizon_optifine::cem::{
files::{atlas_name, jem_files, load_jem},
model::{Bone, EntityModel},
};
use std::{
collections::{BTreeMap, HashMap, HashSet},
path::Path,
};
/// One model the renderer draws, the CEM names that reshape it, best first, and OptiFine's
/// names for its vanilla parts.
struct Target {
key: &'static str,
/// The vanilla model it starts from: `key` itself, or the one a block the renderer tells
/// apart shares (a trapped chest's is the chest's).
base: &'static str,
names: &'static [&'static str],
parts: &'static [(&'static str, &'static str)],
}
const CHEST: &[(&str, &str)] = &[
("root", "root"),
("base", "bottom"),
("lid", "lid"),
("knob", "lock"),
];
const SHULKER: &[(&str, &str)] = &[("root", "root"), ("base", "base"), ("lid", "lid")];
const BANNER: &[(&str, &str)] = &[("stand", "pole"), ("top", "bar")];
const FLAG: &[(&str, &str)] = &[("slate", "flag")];
const HEAD: &[(&str, &str)] = &[("root", "root"), ("head", "head")];
const DRAGON: &[(&str, &str)] = &[("root", "root"), ("head", "head"), ("jaw", "jaw")];
const PIGLIN: &[(&str, &str)] = &[
("root", "root"),
("head", "head"),
("left_ear", "left_ear"),
("right_ear", "right_ear"),
];
const BELL: &[(&str, &str)] = &[
("root", "root"),
("body", "bell_body"),
("base", "bell_base"),
];
const BOOK: &[(&str, &str)] = &[
("root", "root"),
("cover_right", "right_lid"),
("cover_left", "left_lid"),
("pages_right", "right_pages"),
("pages_left", "left_pages"),
("flipping_page_right", "flip_page1"),
("flipping_page_left", "flip_page2"),
("book_spine", "seam"),
];
const POT_BASE: &[(&str, &str)] = &[("neck", "neck"), ("top", "top"), ("bottom", "bottom")];
const POT_SIDES: &[(&str, &str)] = &[
("front", "front"),
("back", "back"),
("left", "left"),
("right", "right"),
];
const fn model(
key: &'static str,
names: &'static [&'static str],
parts: &'static [(&'static str, &'static str)],
) -> Target {
Target {
key,
base: key,
names,
parts,
}
}
const fn copy(
key: &'static str,
base: &'static str,
names: &'static [&'static str],
parts: &'static [(&'static str, &'static str)],
) -> Target {
Target {
key,
base,
names,
parts,
}
}
/// In order: a copy comes after its base.
const TARGETS: &[Target] = &[
model("chest", &["chest"], CHEST),
model("double_chest_left", &["chest_left"], CHEST),
model("double_chest_right", &["chest_right"], CHEST),
copy("trapped_chest", "chest", &["trapped_chest"], CHEST),
copy(
"trapped_chest_left",
"double_chest_left",
&["trapped_chest_left"],
CHEST,
),
copy(
"trapped_chest_right",
"double_chest_right",
&["trapped_chest_right"],
CHEST,
),
copy("ender_chest", "chest", &["ender_chest"], CHEST),
model("shulker_box", &["shulker_box"], SHULKER),
model("standing_banner", &["banner_base", "banner"], BANNER),
model("wall_banner", &["banner_base", "banner"], BANNER),
model("standing_banner_flag", &["banner_flag", "banner"], FLAG),
model("wall_banner_flag", &["banner_flag", "banner"], FLAG),
model("skeleton_skull", &["head_skeleton"], HEAD),
model("wither_skeleton_skull", &["head_wither_skeleton"], HEAD),
model("zombie_head", &["head_zombie"], HEAD),
model("creeper_head", &["head_creeper"], HEAD),
model("dragon_skull", &["head_dragon"], DRAGON),
model("piglin_head", &["head_piglin"], PIGLIN),
model("player_head", &["head_player"], HEAD),
model("bell", &["bell"], BELL),
model("book", &["enchanting_book", "book"], BOOK),
copy("lectern_book", "book", &["lectern_book", "book"], BOOK),
model("conduit_shell", &["conduit"], &[("base", "shell")]),
model("conduit_cage", &["conduit"], &[("cage", "shell")]),
model("conduit_wind", &["conduit"], &[("wind", "wind")]),
model("conduit_eye", &["conduit"], &[("eye", "eye")]),
model("decorated_pot_base", &["decorated_pot"], POT_BASE),
model("decorated_pot_sides", &["decorated_pot"], POT_SIDES),
];
/// Every model, reshaped or vanilla's, and the atlas names of the `.jem` textures by model.
pub type Reshaped = (
HashMap<&'static str, ModelPart>,
HashMap<&'static str, String>,
);
/// The models the packs under `root` reshape, over `vanilla`'s; None when no block entity has
/// a `.jem` there.
pub fn reshape(root: &Path, vanilla: &HashMap<&'static str, ModelPart>) -> Option<Reshaped> {
let (reshaped, unused) = reshape_all(root, vanilla)?;
for (path, part) in unused {
log::warn!("Horizon: custom entity model {path}: no part {part}");
}
Some(reshaped)
}
/// [`reshape`]'s models, and each file's entries whose part none of the models it reshapes has.
fn reshape_all(
root: &Path,
vanilla: &HashMap<&'static str, ModelPart>,
) -> Option<(Reshaped, Vec<(String, String)>)> {
let files = jem_files(root);
let named = |t: &Target| t.names.iter().any(|n| files.contains_key(*n));
if !TARGETS.iter().any(named) {
return None;
}
let mut models = vanilla.clone();
let mut textures = HashMap::new();
let mut reshaped = HashSet::new();
// Each file parsed once; which of its entries found a part in some model.
let mut parsed: HashMap<&str, Option<EntityModel>> = HashMap::new();
let mut used: BTreeMap<&str, Vec<bool>> = BTreeMap::new();
for t in TARGETS {
let Some(model) = vanilla.get(t.base) else {
continue;
};
let found = t
.names
.iter()
.flat_map(|name| files.get(*name).into_iter().flatten())
.map(String::as_str)
.find(|&path| {
parsed
.entry(path)
.or_insert_with(|| load_jem(root, path))
.is_some()
});
let Some(path) = found else {
// A copy of a reshaped base that has no file of its own stays vanilla.
if t.key != t.base && reshaped.contains(t.base) {
models.insert(t.key, model.clone());
}
continue;
};
let Some(Some(jem)) = parsed.get(path) else {
continue;
};
let mut out = model.clone();
let hits = apply(&mut out, jem, t.parts);
// A file shared by several models (the conduit's, the pot's) paints only the models
// it reshapes: vanilla geometry keeps its own texture's layout.
let reshapes = hits.iter().any(|hit| *hit);
let marks = used
.entry(path)
.or_insert_with(|| vec![false; jem.parts.len()]);
for (mark, hit) in marks.iter_mut().zip(hits) {
*mark |= hit;
}
models.insert(t.key, out);
reshaped.insert(t.key);
if reshapes && let Some(texture) = jem.texture.as_deref().and_then(atlas_name) {
textures.insert(t.key, texture);
}
}
let mut unused = Vec::new();
for (path, marks) in &used {
let Some(Some(jem)) = parsed.get(path) else {
continue;
};
for (p, _) in jem.parts.iter().zip(marks).filter(|(_, hit)| !**hit) {
unused.push(((*path).to_owned(), p.part.clone()));
}
}
Some(((models, textures), unused))
}
/// `jem`'s entries on `model`, OptiFine's part names read through `parts`, else as the vanilla
/// part's own name: a replacing entry clears its part's boxes, and each joins the part as a child
/// `cem#<n>`. Which entries found their part.
fn apply(model: &mut ModelPart, jem: &EntityModel, parts: &[(&str, &str)]) -> Vec<bool> {
let mut count = 0;
let mut hits = Vec::with_capacity(jem.parts.len());
for p in &jem.parts {
let mapped = parts.iter().find(|(of, _)| *of == p.part).map(|&(_, v)| v);
let target = match mapped {
Some(vanilla) if find_mut(model, vanilla).is_some() => vanilla,
_ => p.part.as_str(),
};
let Some(node) = find_mut(model, target) else {
hits.push(false);
continue;
};
if !p.attach {
node.polygons.clear();
}
let (name, mut bone) = part(&p.bone, &mut count);
bone.pose[6..9].fill(p.scale);
node.children.push((name, bone));
hits.push(true);
}
hits
}
/// The model itself for `root`, else its first part named `name`, depth first.
fn find_mut<'a>(model: &'a mut ModelPart, name: &str) -> Option<&'a mut ModelPart> {
fn child<'a>(part: &'a mut ModelPart, name: &str) -> Option<&'a mut ModelPart> {
part.children.iter_mut().find_map(|(n, c)| {
if n.as_str() == name {
Some(c)
} else {
child(c, name)
}
})
}
if name == "root" {
return Some(model);
}
child(model, name)
}
/// A baked bone as a model part, named `cem#<n>`: unique in the model, so no vanilla pose
/// change by name reaches it.
fn part(bone: &Bone, count: &mut usize) -> (String, ModelPart) {
let name = format!("cem#{count}");
*count += 1;
let [x, y, z] = bone.pivot;
let [rx, ry, rz] = bone.rotation;
let out = ModelPart {
pose: [x, y, z, rx, ry, rz, 1.0, 1.0, 1.0],
polygons: bone.quads.iter().map(|q| q.vertices).collect(),
children: bone.children.iter().map(|c| part(c, count)).collect(),
};
(name, out)
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
use std::{collections::BTreeSet, fs};
/// A part with one face and `children`.
pub(crate) fn plane(children: Vec<(&str, ModelPart)>) -> ModelPart {
ModelPart {
pose: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
polygons: vec![[[0.0; 5]; 4]],
children: children
.into_iter()
.map(|(n, c)| (n.to_owned(), c))
.collect(),
}
}
pub(crate) fn chest() -> ModelPart {
let mut root = plane(vec![
("bottom", plane(vec![])),
("lid", plane(vec![])),
("lock", plane(vec![])),
]);
root.polygons.clear();
root
}
pub(crate) const BOX: &str =
r#""boxes": [{"coordinates": [0, 0, 0, 1, 1, 1], "textureOffset": [0, 0]}]"#;
/// A fresh pack folder named for `test`, holding `files` under `assets/minecraft/`.
pub(crate) fn pack(test: &str, files: &[(&str, String)]) -> std::path::PathBuf {
let root =
std::env::temp_dir().join(format!("horizon-be-cem-{test}-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
for (path, text) in files {
let path = root.join("assets/minecraft").join(path);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, text).unwrap();
}
root
}
/// A `.jem` replacing each of `parts` with one box.
fn jem(parts: &[&str]) -> String {
let entries: Vec<_> = parts
.iter()
.map(|p| format!(r#"{{"part": "{p}", {BOX}}}"#))
.collect();
format!(r#"{{"models": [{}]}}"#, entries.join(", "))
}
/// A `.jem` replacing each of `parts` with a box of its own: the `i`th is `i + 1` pixels
/// deep, so a part that took another entry's box shows.
fn jem_sized(parts: &[&str]) -> String {
let entries: Vec<_> = parts
.iter()
.enumerate()
.map(|(i, p)| {
let depth = i + 1;
format!(
r#"{{"part": "{p}", "boxes": [{{"coordinates": [0, 0, 0, 1, 1, {depth}], "textureOffset": [0, 0]}}]}}"#
)
})
.collect();
format!(r#"{{"models": [{}]}}"#, entries.join(", "))
}
/// How deep (z) the custom box under `model`'s part `name` is.
fn depth(model: &ModelPart, name: &str) -> f32 {
let part = model.child(name).unwrap();
let zs = part
.children
.iter()
.flat_map(|(_, c)| c.polygons.iter().flatten().map(|v| v[2]));
let (lo, hi) = zs.fold((f32::MAX, f32::MIN), |(lo, hi), z| (lo.min(z), hi.max(z)));
hi - lo
}
/// Whether `model`'s part `name` was replaced by one custom box: its own face gone, and one
/// child of a box's six faces.
fn replaced(model: &ModelPart, name: &str) -> bool {
let part = model.child(name).unwrap();
match part.children.as_slice() {
[(_, only)] => part.polygons.is_empty() && only.polygons.len() == 6,
_ => false,
}
}
#[test]
fn every_optifine_part_name_lands_on_its_vanilla_part() {
// File, OptiFine's part, the model it reshapes, vanilla's part there.
let cases = [
("chest", "base", "chest", "bottom"),
("chest", "lid", "chest", "lid"),
("chest", "knob", "chest", "lock"),
("chest_left", "base", "double_chest_left", "bottom"),
("chest_left", "lid", "double_chest_left", "lid"),
("chest_left", "knob", "double_chest_left", "lock"),
("chest_right", "base", "double_chest_right", "bottom"),
("chest_right", "lid", "double_chest_right", "lid"),
("chest_right", "knob", "double_chest_right", "lock"),
("trapped_chest", "base", "trapped_chest", "bottom"),
("trapped_chest", "knob", "trapped_chest", "lock"),
("trapped_chest_left", "base", "trapped_chest_left", "bottom"),
("trapped_chest_left", "knob", "trapped_chest_left", "lock"),
(
"trapped_chest_right",
"base",
"trapped_chest_right",
"bottom",
),
("trapped_chest_right", "knob", "trapped_chest_right", "lock"),
("ender_chest", "base", "ender_chest", "bottom"),
("ender_chest", "knob", "ender_chest", "lock"),
("shulker_box", "base", "shulker_box", "base"),
("shulker_box", "lid", "shulker_box", "lid"),
("banner", "stand", "standing_banner", "pole"),
("banner", "top", "standing_banner", "bar"),
("banner", "top", "wall_banner", "bar"),
("banner", "slate", "standing_banner_flag", "flag"),
("banner", "slate", "wall_banner_flag", "flag"),
("head_skeleton", "head", "skeleton_skull", "head"),
(
"head_wither_skeleton",
"head",
"wither_skeleton_skull",
"head",
),
("head_zombie", "head", "zombie_head", "head"),
("head_creeper", "head", "creeper_head", "head"),
("head_dragon", "head", "dragon_skull", "head"),
("head_dragon", "jaw", "dragon_skull", "jaw"),
("head_piglin", "head", "piglin_head", "head"),
("head_piglin", "left_ear", "piglin_head", "left_ear"),
("head_piglin", "right_ear", "piglin_head", "right_ear"),
("head_player", "head", "player_head", "head"),
("bell", "body", "bell", "bell_body"),
("bell", "base", "bell", "bell_base"),
("enchanting_book", "cover_right", "book", "right_lid"),
("enchanting_book", "cover_left", "book", "left_lid"),
("enchanting_book", "pages_right", "book", "right_pages"),
("enchanting_book", "pages_left", "book", "left_pages"),
(
"enchanting_book",
"flipping_page_right",
"book",
"flip_page1",
),
(
"enchanting_book",
"flipping_page_left",
"book",
"flip_page2",
),
("enchanting_book", "book_spine", "book", "seam"),
("lectern_book", "cover_right", "lectern_book", "right_lid"),
("lectern_book", "pages_left", "lectern_book", "left_pages"),
(
"lectern_book",
"flipping_page_right",
"lectern_book",
"flip_page1",
),
("lectern_book", "book_spine", "lectern_book", "seam"),
("conduit", "base", "conduit_shell", "shell"),
("conduit", "cage", "conduit_cage", "shell"),
("conduit", "wind", "conduit_wind", "wind"),
("conduit", "eye", "conduit_eye", "eye"),
("decorated_pot", "neck", "decorated_pot_base", "neck"),
("decorated_pot", "top", "decorated_pot_base", "top"),
("decorated_pot", "bottom", "decorated_pot_base", "bottom"),
("decorated_pot", "front", "decorated_pot_sides", "front"),
("decorated_pot", "back", "decorated_pot_sides", "back"),
("decorated_pot", "left", "decorated_pot_sides", "left"),
("decorated_pot", "right", "decorated_pot_sides", "right"),
];
// One file per name with each of its parts, and a stub of each model holding its parts
// (chest models all of theirs, so a misplaced entry shows).
let mut files: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
let mut trees: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
for (file, part, key, vanilla) in cases {
files.entry(file).or_default().insert(part);
trees.entry(key).or_default().insert(vanilla);
}
for key in ["chest", "double_chest_left", "double_chest_right"] {
trees
.entry(key)
.or_default()
.extend(["bottom", "lid", "lock"]);
}
let files_by_name = files.clone();
let files: Vec<_> = files
.iter()
.map(|(file, parts)| {
let parts: Vec<_> = parts.iter().copied().collect();
(format!("optifine/cem/{file}.jem"), jem_sized(&parts))
})
.collect();
let files: Vec<_> = files.iter().map(|(p, t)| (p.as_str(), t.clone())).collect();
let root = pack("parts", &files);
let vanilla: HashMap<&'static str, ModelPart> = trees
.iter()
.map(|(key, parts)| {
let parts = parts.iter().map(|p| (*p, plane(vec![]))).collect();
(*key, plane(parts))
})
.collect();
let result = reshape_all(&root, &vanilla);
fs::remove_dir_all(&root).unwrap();
let ((models, _), unused) = result.unwrap();
for (file, part, key, vanilla) in cases {
assert!(
replaced(&models[key], vanilla),
"{file}.jem {part}: {key} {vanilla}"
);
// The box is this entry's own: swapped names (left and right, body and base) show.
let own = files_by_name[file].iter().position(|p| *p == part).unwrap() + 1;
assert_eq!(
depth(&models[key], vanilla),
own as f32,
"{file}.jem {part}: {key} {vanilla}"
);
}
assert_eq!(unused, Vec::new());
}
#[test]
fn a_chest_jem_reshapes_the_chest_alone_and_warns_of_entries_without_a_part() {
let root = pack(
"chest",
&[(
"optifine/cem/chest.jem",
format!(
r#"{{"texture": "textures/entity/chest/fancy.png", "models": [
{{"part": "lid", {BOX}}},
{{"part": "knob", "attach": true, "scale": 2, {BOX}}},
{{"part": "bottom", {BOX}}},
{{"part": "tail", {BOX}}}]}}"#
),
)],
);
let vanilla: HashMap<_, _> = [("chest", chest()), ("double_chest_left", chest())].into();
let result = reshape_all(&root, &vanilla);
fs::remove_dir_all(&root).unwrap();
let ((models, textures), unused) = result.unwrap();
let chest = &models["chest"];
// Replaced: the lid's own face goes and the custom box (6 faces) joins it.
assert!(replaced(chest, "lid"));
// `bottom` is no OptiFine name but vanilla's own: it reaches the part as well.
assert!(replaced(chest, "bottom"));
// Attached: the lock keeps its face beside the scaled box.
let lock = chest.child("lock").unwrap();
assert_eq!((lock.polygons.len(), lock.children.len()), (1, 1));
assert_eq!(lock.children[0].1.pose[6..], [2.0, 2.0, 2.0]);
// `tail` names no chest part: the one warning.
assert_eq!(
unused,
[(
"assets/minecraft/optifine/cem/chest.jem".to_owned(),
"tail".to_owned()
)]
);
// The double chest has no file, nor do trapped and ender chests: vanilla's own, and
// only the chest takes the texture.
for key in ["double_chest_left", "trapped_chest", "ender_chest"] {
let lid = models[key].child("lid").unwrap();
assert_eq!((lid.polygons.len(), lid.children.len()), (1, 0), "{key}");
}
assert_eq!(
textures,
HashMap::from([("chest", "entity/chest/fancy".to_owned())])
);
}
#[test]
fn banners_split_one_file_and_books_fall_back() {
let root = pack(
"banner",
&[
("optifine/cem/banner.jem", jem(&["stand", "top", "slate"])),
("emf/cem/enchanting_book.jem", jem(&["cover_left"])),
],
);
let banner = plane(vec![("pole", plane(vec![])), ("bar", plane(vec![]))]);
let wall = plane(vec![("bar", plane(vec![]))]);
let flag = plane(vec![("flag", plane(vec![]))]);
let book = plane(vec![("left_lid", plane(vec![]))]);
let vanilla: HashMap<_, _> = [
("standing_banner", banner),
("wall_banner", wall),
("wall_banner_flag", flag),
("book", book),
]
.into();
let result = reshape_all(&root, &vanilla);
fs::remove_dir_all(&root).unwrap();
let ((models, textures), unused) = result.unwrap();
assert!(replaced(&models["standing_banner"], "pole"));
assert!(replaced(&models["standing_banner"], "bar"));
assert!(replaced(&models["wall_banner"], "bar"));
assert!(replaced(&models["wall_banner_flag"], "flag"));
assert!(replaced(&models["book"], "left_lid"));
// The lectern's book has no file of its own nor `book.jem`: vanilla's.
let lectern = models["lectern_book"].child("left_lid").unwrap();
assert_eq!((lectern.polygons.len(), lectern.children.len()), (1, 0));
assert!(textures.is_empty());
// Each entry found its part in one of the models: no warning.
assert_eq!(unused, Vec::new());
}
#[test]
fn book_jem_reshapes_the_lectern_book_without_its_own_file() {
let root = pack("book", &[("optifine/cem/book.jem", jem(&["cover_left"]))]);
let vanilla: HashMap<_, _> = [("book", plane(vec![("left_lid", plane(vec![]))]))].into();
let result = reshape(&root, &vanilla);
fs::remove_dir_all(&root).unwrap();
let (models, _) = result.unwrap();
assert!(replaced(&models["book"], "left_lid"));
assert!(replaced(&models["lectern_book"], "left_lid"));
}
#[test]
fn a_shared_file_paints_only_the_models_it_reshapes() {
// The conduit's file reshapes its eye alone: the shell, cage and wind keep their
// vanilla geometry and so their own textures.
let file = format!(
r#"{{"texture": "textures/entity/conduit/custom.png", "models": [{{"part": "eye", {BOX}}}]}}"#
);
let root = pack("conduit", &[("optifine/cem/conduit.jem", file)]);
let vanilla: HashMap<_, _> = [
"conduit_shell",
"conduit_cage",
"conduit_wind",
"conduit_eye",
]
.map(|key| {
let part = if key == "conduit_eye" { "eye" } else { "shell" };
(key, plane(vec![(part, plane(vec![]))]))
})
.into();
let result = reshape(&root, &vanilla);
fs::remove_dir_all(&root).unwrap();
let (models, textures) = result.unwrap();
assert!(replaced(&models["conduit_eye"], "eye"));
let painted: BTreeSet<_> = textures.keys().copied().collect();
assert_eq!(painted, BTreeSet::from(["conduit_eye"]));
assert_eq!(textures["conduit_eye"], "entity/conduit/custom");
}
}
@@ -20,7 +20,12 @@ use horizon_world::{
registry::{BlockRegistry, BlockStateId},
};
use serde_json::Value;
use std::{collections::HashMap, f32::consts::PI, path::Path, sync::Mutex};
use std::{
collections::HashMap,
f32::consts::PI,
path::Path,
sync::{Arc, Mutex, PoisonError, RwLock},
};
/// One baked `ModelPart`: initial pose, textured polygons (pixel units,
/// normalized UVs) and named children.
@@ -120,7 +125,12 @@ pub struct Paint {
pub tint: u32,
pub light: u32,
pub lights: (Vec3, Vec3),
/// The texture's OptiFine emissive overlay (`_e`): drawn again over it at full
/// brightness, with the same shading.
pub glow: Option<Rect>,
}
/// Full sky and block light, in [`vertex_light`]'s form.
const FULL_BRIGHT: u32 = 240 | (240 << 8);
/// `ModelPart.render`: translate by pose/16, rotate ZYX, scale; children inherit.
/// `adjust` may change a part's pose by name (`setupAnim`); `skip` hides parts.
pub fn draw_part(
@@ -142,6 +152,7 @@ pub fn draw_part(
tint,
light,
lights,
glow,
} = paint;
for poly in &part.polygons {
let pos = poly.map(|v| m.transform_point3(Vec3::new(v[0], v[1], v[2]) / 16.0));
@@ -155,17 +166,19 @@ pub fn draw_part(
normal
};
let color = pack_color(tint, 255, diffuse(normal, lights));
for (v, position) in poly.iter().zip(pos) {
out.push(Vertex {
normal: [0; 4],
pos: position.to_array(),
uv: [r.u0 + (r.u1 - r.u0) * v[3], r.v0 + (r.v1 - r.v0) * v[4]],
layer: 0,
color,
light,
for (r, light) in std::iter::once((r, light)).chain(glow.map(|g| (g, FULL_BRIGHT))) {
for (v, position) in poly.iter().zip(pos) {
out.push(Vertex {
normal: [0; 4],
pos: position.to_array(),
uv: [r.u0 + (r.u1 - r.u0) * v[3], r.v0 + (r.v1 - r.v0) * v[4]],
layer: 0,
color,
light,
glint_uv: [0.0, 0.0],
});
glint_uv: [0.0, 0.0],
});
}
}
}
for (child_name, child) in &part.children {
@@ -771,6 +784,8 @@ pub struct Scene<'a> {
pub max_y: i32,
pub world: &'a World,
pub sprites: &'a HashMap<String, Rect>,
/// The packs' OptiFine emissive overlays of the sprites.
pub emissive: &'a horizon_optifine::emissive::Emissive,
pub nether: bool,
/// Chest lid openness (`ChestLidController.getOpenNess`) at a position.
pub lid: &'a dyn Fn([i32; 3]) -> f32,
@@ -792,16 +807,102 @@ pub struct Scene<'a> {
/// Empty by default (a dedicated server): no state has a block entity model.
#[derive(Default)]
pub struct BlockEntityRenderer {
models: HashMap<&'static str, ModelPart>,
vanilla: Arc<BlockEntityShapes>,
/// What draws: `vanilla`, or the packs' custom entity models over it.
shapes: RwLock<Arc<BlockEntityShapes>>,
kinds: Vec<Kind>,
// Closed chest geometry: dimension lighting, chest part, horizontal facing.
closed_chests: [[[Vec<Vertex>; 4]; 3]; 2],
/// Item id to its `PROVIDES_POTTERY_PATTERN` side texture (`Sheets.DECORATED_POT_MAPPER`).
pot_patterns: HashMap<String, String>,
/// `Block.shouldRenderFace` for End gateway faces (its occlusion pair cache needs a lock).
visibility: Option<Mutex<FaceVisibility>>,
sign_texts: Mutex<SignTexts>,
}
/// The block-entity models and what is baked from them: vanilla's, or the packs' custom entity
/// models over them ([`crate::block_entity_cem`]).
#[derive(Default)]
pub struct BlockEntityShapes {
models: HashMap<&'static str, ModelPart>,
/// Closed chest geometry by chest model: dimension lighting, horizontal facing.
closed_chests: HashMap<&'static str, [[Vec<Vertex>; 4]; 2]>,
/// A `.jem`'s texture (atlas name) by model, drawn instead of vanilla's.
textures: HashMap<&'static str, String>,
}
/// The chest models, each baked with its lid closed.
const CHEST_MODELS: [&str; 7] = [
"chest",
"double_chest_left",
"double_chest_right",
"trapped_chest",
"trapped_chest_left",
"trapped_chest_right",
"ender_chest",
];
impl BlockEntityShapes {
fn new(
models: HashMap<&'static str, ModelPart>,
textures: HashMap<&'static str, String>,
) -> Self {
let closed_chests = CHEST_MODELS
.iter()
.filter_map(|key| Some((*key, closed_chest(models.get(key)?))))
.collect();
Self {
models,
closed_chests,
textures,
}
}
/// The texture model `key` draws with: its `.jem`'s when the atlas has it, else `vanilla`.
fn texture<'a>(
&'a self,
key: &str,
vanilla: &'a str,
sprites: &HashMap<String, Rect>,
) -> &'a str {
match self.textures.get(key) {
Some(texture) if sprites.contains_key(texture) => texture,
_ => vanilla,
}
}
}
/// A chest model with its lid closed, for each dimension lighting and horizontal facing, its UVs
/// over the whole texture.
fn closed_chest(model: &ModelPart) -> [[Vec<Vertex>; 4]; 2] {
[false, true].map(|nether| {
[0.0f32, 90.0, 180.0, 270.0].map(|y_rot| {
let mut vertices = Vec::new();
draw_part(
model,
"root",
around(
Mat4::from_rotation_y((-y_rot).to_radians()),
Vec3::new(0.5, 0.0, 0.5),
),
&|name, p| {
if name == "lid" || name == "lock" {
p[3] = crate::block_entity_anim::chest_lid_x_rot(0.0);
}
},
Paint {
sprite: Rect {
u0: 0.0,
v0: 0.0,
u1: 1.0,
v1: 1.0,
w: 16,
h: 16,
},
tint: 0xFFFFFF,
light: 0,
lights: diffuse_lights(nether),
glow: None,
},
&mut vertices,
);
vertices
})
})
}
/// Laid-out sign text by position. `AbstractSignRenderer` lays the text out
/// again each frame, glyph by glyph; its vertices only change with what is
/// in the key, so they are kept relative to the block and moved to the camera.
@@ -884,6 +985,8 @@ impl BlockEntityRenderer {
.collect();
let mut renderer = Self::new(models, registry);
renderer.visibility = Some(Mutex::new(visibility));
// The packs' custom entity models (`.jem` files), before anything draws.
renderer.set_shapes(renderer.shapes_for(root));
// Sherd items: provides_pottery_pattern -> decorated_pot_pattern asset id.
let items = match horizon_world::item_defaults::read(root) {
Ok(items) => items,
@@ -910,43 +1013,7 @@ impl BlockEntityRenderer {
Ok(renderer)
}
fn new(models: HashMap<&'static str, ModelPart>, registry: &BlockRegistry) -> Self {
let closed_chests = [false, true].map(|nether| {
["chest", "double_chest_left", "double_chest_right"].map(|name| {
[0.0f32, 90.0, 180.0, 270.0].map(|y_rot| {
let mut vertices = Vec::new();
if let Some(model) = models.get(name) {
draw_part(
model,
"root",
around(
Mat4::from_rotation_y((-y_rot).to_radians()),
Vec3::new(0.5, 0.0, 0.5),
),
&|name, p| {
if name == "lid" || name == "lock" {
p[3] = crate::block_entity_anim::chest_lid_x_rot(0.0);
}
},
Paint {
sprite: Rect {
u0: 0.0,
v0: 0.0,
u1: 1.0,
v1: 1.0,
w: 16,
h: 16,
},
tint: 0xFFFFFF,
light: 0,
lights: diffuse_lights(nether),
},
&mut vertices,
);
}
vertices
})
})
});
let vanilla = Arc::new(BlockEntityShapes::new(models, HashMap::new()));
let kinds = (0..registry.len() as u32)
.map(|id| {
registry.state(BlockStateId(id)).map_or(Kind::None, |s| {
@@ -959,14 +1026,31 @@ impl BlockEntityRenderer {
})
.collect();
Self {
models,
shapes: RwLock::new(vanilla.clone()),
vanilla,
kinds,
closed_chests,
pot_patterns: HashMap::new(),
visibility: None,
sign_texts: Mutex::default(),
}
}
/// The models the packs under `root` give, their `.jem` files over vanilla's, for
/// [`Self::set_shapes`] (a pack reload): vanilla's own, shared, without any.
pub fn shapes_for(&self, root: &Path) -> Arc<BlockEntityShapes> {
match crate::block_entity_cem::reshape(root, &self.vanilla.models) {
Some((models, textures)) => Arc::new(BlockEntityShapes::new(models, textures)),
None => self.vanilla.clone(),
}
}
pub fn set_shapes(&self, shapes: Arc<BlockEntityShapes>) {
*self.shapes.write().unwrap_or_else(PoisonError::into_inner) = shapes;
}
fn shapes(&self) -> Arc<BlockEntityShapes> {
self.shapes
.read()
.unwrap_or_else(PoisonError::into_inner)
.clone()
}
fn kind(&self, state: u32) -> &Kind {
self.kinds.get(state as usize).unwrap_or(&Kind::None)
}
@@ -1260,6 +1344,7 @@ impl BlockEntityRenderer {
world_chests: bool,
out: &mut Vec<Vertex>,
) {
let shapes = self.shapes();
let sprite = |name: &str| scene.sprites.get(name).copied();
let paint = |name: &str, tint: u32, packed: u8| {
let rect = if crumbling {
@@ -1279,6 +1364,9 @@ impl BlockEntityRenderer {
tint,
light: vertex_light(packed),
lights,
glow: (!crumbling)
.then(|| scene.emissive.overlay(name).and_then(sprite))
.flatten(),
})
};
let none = |_: &str, _: &mut [f32; 9]| {};
@@ -1349,12 +1437,34 @@ impl BlockEntityRenderer {
} else {
*texture
};
let model = match part {
ChestPart::Single => "chest",
ChestPart::Left => "double_chest_left",
ChestPart::Right => "double_chest_right",
// Trapped and ender chests have models of their own when a `.jem` reshapes
// them; copper chests have no CEM name and draw vanilla's chest.
let shapes: &BlockEntityShapes =
if matches!(block.as_str(), "chest" | "trapped_chest" | "ender_chest") {
&shapes
} else {
&self.vanilla
};
let (own, chest) = match (block.as_str(), part) {
("trapped_chest", ChestPart::Single) => ("trapped_chest", "chest"),
("trapped_chest", ChestPart::Left) => {
("trapped_chest_left", "double_chest_left")
}
("trapped_chest", ChestPart::Right) => {
("trapped_chest_right", "double_chest_right")
}
("ender_chest", _) => ("ender_chest", "chest"),
(_, ChestPart::Single) => ("chest", "chest"),
(_, ChestPart::Left) => ("double_chest_left", "double_chest_left"),
(_, ChestPart::Right) => ("double_chest_right", "double_chest_right"),
};
let Some(model) = self.models.get(model) else {
let key = if shapes.models.contains_key(own) {
own
} else {
chest
};
let texture = shapes.texture(key, texture, scene.sprites);
let Some(model) = shapes.models.get(key) else {
return;
};
let (open, other) = self.chest_openness(
@@ -1370,19 +1480,25 @@ impl BlockEntityRenderer {
packed = (packed >> 4).max(l >> 4) << 4 | (packed & 15).max(l & 15);
}
if world_chests && open == 0.0 {
if let Some(paint) = paint(texture, 0xFFFFFF, packed) {
let vertices = &self.closed_chests[usize::from(scene.nether)]
[*part as usize][*y_rot as usize / 90];
if let (Some(paint), Some(closed)) = (
paint(texture, 0xFFFFFF, packed),
shapes.closed_chests.get(key),
) {
let vertices = &closed[usize::from(scene.nether)][*y_rot as usize / 90];
let origin = base.w_axis.truncate();
out.extend(vertices.iter().map(|v| Vertex {
pos: (Vec3::from_array(v.pos) + origin).to_array(),
uv: [
paint.sprite.u0 + (paint.sprite.u1 - paint.sprite.u0) * v.uv[0],
paint.sprite.v0 + (paint.sprite.v1 - paint.sprite.v0) * v.uv[1],
],
light: paint.light,
..*v
}));
let passes = std::iter::once((paint.sprite, paint.light))
.chain(paint.glow.map(|g| (g, FULL_BRIGHT)));
for (r, light) in passes {
out.extend(vertices.iter().map(|v| Vertex {
pos: (Vec3::from_array(v.pos) + origin).to_array(),
uv: [
r.u0 + (r.u1 - r.u0) * v.uv[0],
r.v0 + (r.v1 - r.v0) * v.uv[1],
],
light,
..*v
}));
}
}
return;
}
@@ -1403,8 +1519,12 @@ impl BlockEntityRenderer {
}
Kind::Shulker { texture, facing } => {
let (Some(model), Some(paint)) = (
self.models.get("shulker_box"),
paint(texture, 0xFFFFFF, packed),
shapes.models.get("shulker_box"),
paint(
shapes.texture("shulker_box", texture, scene.sprites),
0xFFFFFF,
packed,
),
) else {
return;
};
@@ -1428,12 +1548,13 @@ impl BlockEntityRenderer {
draw_part(model, "root", m, &lid, paint, out);
}
Kind::Banner { color, wall, angle } => {
let (main, flag) = if *wall {
let (main_key, flag_key) = if *wall {
("wall_banner", "wall_banner_flag")
} else {
("standing_banner", "standing_banner_flag")
};
let (Some(main), Some(flag)) = (self.models.get(main), self.models.get(flag))
let (Some(main), Some(flag)) =
(shapes.models.get(main_key), shapes.models.get(flag_key))
else {
return;
};
@@ -1454,8 +1575,13 @@ impl BlockEntityRenderer {
p[3] = (-0.0125 + 0.01 * (2.0 * PI * phase).mcos()) * PI;
}
};
if let Some(paint) = paint("entity/banner/banner_base", 0xFFFFFF, packed) {
let banner_base = "entity/banner/banner_base";
let main_texture = shapes.texture(main_key, banner_base, scene.sprites);
if let Some(paint) = paint(main_texture, 0xFFFFFF, packed) {
draw_part(main, "root", m, &none, paint, out);
}
let flag_texture = shapes.texture(flag_key, banner_base, scene.sprites);
if let Some(paint) = paint(flag_texture, 0xFFFFFF, packed) {
draw_part(flag, "root", m, &wave, paint, out);
}
// Pattern passes must not multiply the crumbling decal.
@@ -1497,9 +1623,12 @@ impl BlockEntityRenderer {
None
};
let has_profile = skin.is_some();
let texture = skin.as_deref().unwrap_or(texture);
// A `.jem` texture stands in for the default skin, not a profile's.
let texture = skin
.as_deref()
.unwrap_or_else(|| shapes.texture(model, texture, scene.sprites));
let (Some(model), Some(paint)) =
(self.models.get(model), paint(texture, 0xFFFFFF, packed))
(shapes.models.get(model), paint(texture, 0xFFFFFF, packed))
else {
return;
};
@@ -1541,8 +1670,12 @@ impl BlockEntityRenderer {
}
Kind::Bell => {
let (Some(model), Some(paint)) = (
self.models.get("bell"),
paint("entity/bell/bell_body", 0xFFFFFF, packed),
shapes.models.get("bell"),
paint(
shapes.texture("bell", "entity/bell/bell_body", scene.sprites),
0xFFFFFF,
packed,
),
) else {
return;
};
@@ -1570,8 +1703,8 @@ impl BlockEntityRenderer {
}
Kind::DecoratedPot { y_rot } => {
let (Some(base_model), Some(sides)) = (
self.models.get("decorated_pot_base"),
self.models.get("decorated_pot_sides"),
shapes.models.get("decorated_pot_base"),
shapes.models.get("decorated_pot_sides"),
) else {
return;
};
@@ -1599,17 +1732,20 @@ impl BlockEntityRenderer {
}
}
let first = out.len();
if let Some(paint) =
paint("entity/decorated_pot/decorated_pot_base", 0xFFFFFF, packed)
{
let pot_base = "entity/decorated_pot/decorated_pot_base";
let pot_base = shapes.texture("decorated_pot_base", pot_base, scene.sprites);
if let Some(paint) = paint(pot_base, 0xFFFFFF, packed) {
draw_part(base_model, "root", m, &none, paint, out);
}
// PotDecorations (back, left, right, front); items without a pattern use the blank side.
// PotDecorations (back, left, right, front); items without a pattern use the blank
// side (a `.jem`'s texture, if any).
let blank = "entity/decorated_pot/decorated_pot_side";
let blank = shapes.texture("decorated_pot_sides", blank, scene.sprites);
for (side, item) in ["back", "left", "right", "front"].iter().zip(&a.sherds) {
let texture = item
.as_ref()
.and_then(|i| self.pot_patterns.get(i))
.map_or("entity/decorated_pot/decorated_pot_side", String::as_str);
.map_or(blank, String::as_str);
if let (Some(part), Some(paint)) =
(sides.child(side), paint(texture, 0xFFFFFF, packed))
{
@@ -1659,11 +1795,14 @@ impl BlockEntityRenderer {
let (ticks, active, hunting, raw_rotation) = (scene.anim)(pos).conduit;
// ConduitRenderer.extractRenderState.
let rotation = (raw_rotation + if active { scene.partial } else { 0.0 }) * -0.0375;
let tex = |name: &str| paint(name, 0xFFFFFF, packed);
let tex = |key: &str, name: &str| {
paint(shapes.texture(key, name, scene.sprites), 0xFFFFFF, packed)
};
if !active {
let (Some(model), Some(paint)) =
(self.models.get("conduit_shell"), tex("entity/conduit/base"))
else {
let (Some(model), Some(paint)) = (
shapes.models.get("conduit_shell"),
tex("conduit_shell", "entity/conduit/base"),
) else {
return;
};
let m = base
@@ -1678,9 +1817,10 @@ impl BlockEntityRenderer {
let hh = (anim_time * 0.1).msin() / 2.0 + 0.5;
let hh = hh * hh + hh;
let lift = Vec3::new(0.5, 0.3 + hh * 0.2, 0.5);
if let (Some(model), Some(paint)) =
(self.models.get("conduit_cage"), tex("entity/conduit/cage"))
{
if let (Some(model), Some(paint)) = (
shapes.models.get("conduit_cage"),
tex("conduit_cage", "entity/conduit/cage"),
) {
let axis = Vec3::new(0.5, 1.0, 0.5).normalize();
let m = base
* Mat4::from_translation(lift)
@@ -1696,7 +1836,9 @@ impl BlockEntityRenderer {
} else {
"entity/conduit/wind"
};
if let (Some(model), Some(paint)) = (self.models.get("conduit_wind"), tex(wind)) {
if let (Some(model), Some(paint)) =
(shapes.models.get("conduit_wind"), tex("conduit_wind", wind))
{
let turn = match phase {
1 => Mat4::from_rotation_x(PI / 2.0),
2 => Mat4::from_rotation_z(PI / 2.0),
@@ -1714,7 +1856,9 @@ impl BlockEntityRenderer {
} else {
"entity/conduit/closed_eye"
};
if let (Some(model), Some(paint)) = (self.models.get("conduit_eye"), tex(eye)) {
if let (Some(model), Some(paint)) =
(shapes.models.get("conduit_eye"), tex("conduit_eye", eye))
{
let m = base
* Mat4::from_translation(lift)
* Mat4::from_scale(Vec3::splat(0.5))
@@ -1725,9 +1869,14 @@ impl BlockEntityRenderer {
}
}
Kind::EnchantingTable => {
let book = "entity/enchantment/enchanting_table_book";
let (Some(model), Some(paint)) = (
self.models.get("book"),
paint("entity/enchantment/enchanting_table_book", 0xFFFFFF, packed),
shapes.models.get("book"),
paint(
shapes.texture("book", book, scene.sprites),
0xFFFFFF,
packed,
),
) else {
return;
};
@@ -1746,9 +1895,16 @@ impl BlockEntityRenderer {
draw_part(model, "root", m, &book_pose(openness, ff1, ff2), paint, out);
}
Kind::Lectern { y_rot } => {
// Its own `.jem`'s book, else the enchanting table's.
let key = if shapes.models.contains_key("lectern_book") {
"lectern_book"
} else {
"book"
};
let book = "entity/enchantment/enchanting_table_book";
let (Some(model), Some(paint)) = (
self.models.get("book"),
paint("entity/enchantment/enchanting_table_book", 0xFFFFFF, packed),
shapes.models.get(key),
paint(shapes.texture(key, book, scene.sprites), 0xFFFFFF, packed),
) else {
return;
};
@@ -1779,7 +1935,7 @@ impl BlockEntityRenderer {
_ => "copper_golem",
};
let (Some(model), Some(paint)) =
(self.models.get(key), paint(texture, 0xFFFFFF, packed))
(shapes.models.get(key), paint(texture, 0xFFFFFF, packed))
else {
return;
};
@@ -2106,6 +2262,7 @@ mod tests {
max_y: 320,
world: &world,
sprites: &base_sprites,
emissive: &Default::default(),
nether: false,
lid: &|_| 0.0,
sign: &|_| None,
@@ -2305,6 +2462,7 @@ mod tests {
tint: 0xFFFFFF,
light: 0,
lights: diffuse_lights(false),
glow: None,
},
&mut out,
);
@@ -2313,6 +2471,55 @@ mod tests {
assert_eq!(out[2].uv, [0.5, 0.5]);
}
/// An OptiFine emissive overlay draws each polygon again with its own sprite at full
/// brightness, shaded alike.
#[test]
fn a_glow_draws_the_part_again_at_full_brightness() {
let part = ModelPart {
pose: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
polygons: vec![[
[0.0, 0.0, 0.0, 0.0, 0.0],
[16.0, 0.0, 0.0, 1.0, 0.0],
[16.0, 0.0, 16.0, 1.0, 1.0],
[0.0, 0.0, 16.0, 0.0, 1.0],
]],
children: vec![],
};
let rect = |u0, v0| Rect {
u0,
v0,
u1: u0 + 0.5,
v1: v0 + 0.5,
w: 1,
h: 1,
};
let mut out = Vec::new();
let light = vertex_light(0x52);
draw_part(
&part,
"root",
Mat4::IDENTITY,
&|_, _| {},
Paint {
sprite: rect(0.0, 0.0),
tint: 0xFFFFFF,
light,
lights: diffuse_lights(false),
glow: Some(rect(0.5, 0.5)),
},
&mut out,
);
assert_eq!(out.len(), 8);
let (base, glow) = out.split_at(4);
assert!(base.iter().all(|v| v.light == light));
assert!(glow.iter().all(|v| v.light == 240 | (240 << 8)));
assert_eq!(glow[2].uv, [1.0, 1.0]);
assert_eq!(base[2].uv, [0.5, 0.5]);
for (b, g) in base.iter().zip(glow) {
assert_eq!((b.pos, b.color), (g.pos, g.color));
}
}
/// 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.
@@ -2341,6 +2548,7 @@ mod tests {
max_y: 320,
world: &world,
sprites: &sprites,
emissive: &Default::default(),
nether: false,
lid: &|_| 0.0,
sign: &|_| None,
@@ -2379,7 +2587,8 @@ mod tests {
#[test]
fn cached_sign_text_matches_and_follows_changes() {
let renderer = BlockEntityRenderer {
models: HashMap::new(),
vanilla: Default::default(),
shapes: Default::default(),
kinds: vec![
Kind::Sign {
hanging: false,
@@ -2392,7 +2601,6 @@ mod tests {
angle: 90.0,
},
],
closed_chests: Default::default(),
pot_patterns: HashMap::new(),
visibility: None,
sign_texts: Mutex::default(),
@@ -2445,6 +2653,7 @@ mod tests {
max_y: 320,
world: &world,
sprites: &sprites,
emissive: &Default::default(),
nether: false,
lid: &|_| 0.0,
sign: &first_sign,
@@ -2553,6 +2762,7 @@ mod tests {
max_y: 320,
world: &world,
sprites: &sprites,
emissive: &Default::default(),
nether: false,
lid: &|_| 0.0,
sign: &|_| None,
@@ -2574,4 +2784,131 @@ mod tests {
"entitySolid culls the pot's back faces"
);
}
#[test]
fn a_chest_jem_reshapes_the_closed_chest_and_paints_its_texture() {
use crate::block_entity_cem::tests::{BOX, chest, pack};
let vanilla = Arc::new(BlockEntityShapes::new(
HashMap::from([("chest", chest())]),
HashMap::new(),
));
let renderer = BlockEntityRenderer {
shapes: RwLock::new(vanilla.clone()),
vanilla,
kinds: Vec::new(),
pot_patterns: HashMap::new(),
visibility: None,
sign_texts: Mutex::default(),
};
// No block entity's `.jem` (a mob's only): vanilla's shapes, not a copy.
let empty = pack("render-none", &[("optifine/cem/zombie.jem", "{}".into())]);
assert!(Arc::ptr_eq(&renderer.shapes_for(&empty), &renderer.vanilla));
let root = pack(
"render-chest",
&[(
"optifine/cem/chest.jem",
format!(
r#"{{"texture": "textures/entity/chest/fancy.png",
"models": [{{"part": "lid", {BOX}}}]}}"#
),
)],
);
renderer.set_shapes(renderer.shapes_for(&root));
let _ = std::fs::remove_dir_all(&root);
let _ = std::fs::remove_dir_all(&empty);
let rect = |u0: f32| Rect {
u0,
v0: 0.0,
u1: u0 + 0.2,
v1: 0.2,
w: 16,
h: 16,
};
let sprites: HashMap<String, Rect> = [
("entity/chest/normal", rect(0.0)),
("entity/chest/ender", rect(0.2)),
("entity/chest/fancy", rect(0.4)),
("entity/chest/copper", rect(0.6)),
]
.map(|(name, r)| (name.to_owned(), r))
.into();
let world = World::new(BlockStateId(0), 1);
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,
emissive: &Default::default(),
nether: false,
lid: &|_| 0.0,
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyphs: 0,
};
// A closed single chest of `block` drawn by `renderer`: positions and UVs.
let draw = |renderer: &BlockEntityRenderer, block: &str, texture: &'static str| {
let kind = Kind::Chest {
block: block.into(),
texture,
part: ChestPart::Single,
y_rot: 0.0,
double_with: None,
};
let mut out = Vec::new();
renderer.render_one(
&scene,
[0; 3],
&kind,
Mat4::IDENTITY,
0xF0,
diffuse_lights(false),
false,
true,
&mut out,
);
out.iter().map(|v| (v.pos, v.uv)).collect::<Vec<_>>()
};
// The chest's three faces become the bottom's, the lock's and the custom lid's six, in
// the `.jem`'s texture. Ender and copper chests have no file of their own: vanilla's
// three faces in their own textures. (Trapped chests: `block_entity_cem`'s tests; their
// texture turns festive in late December.)
for (block, texture, u0, faces) in [
("chest", "entity/chest/normal", 0.4, 8),
("ender_chest", "entity/chest/ender", 0.2, 3),
("copper_chest", "entity/chest/copper", 0.6, 3),
] {
let out = draw(&renderer, block, texture);
assert_eq!(out.len(), faces * 4, "{block}");
assert!(
out.iter().all(|(_, uv)| (u0..=u0 + 0.2).contains(&uv[0])),
"{block}"
);
}
// A copper chest draws exactly what it draws without any pack.
let plain = BlockEntityRenderer {
shapes: RwLock::new(renderer.vanilla.clone()),
vanilla: renderer.vanilla.clone(),
kinds: Vec::new(),
pot_patterns: HashMap::new(),
visibility: None,
sign_texts: Mutex::default(),
};
assert_eq!(
draw(&renderer, "copper_chest", "entity/chest/copper"),
draw(&plain, "copper_chest", "entity/chest/copper")
);
// The atlas has no such texture: vanilla's.
let shapes = renderer.shapes();
assert_eq!(
shapes.texture("chest", "entity/chest/normal", &HashMap::new()),
"entity/chest/normal"
);
}
}
+24 -18
View File
@@ -9,7 +9,7 @@ use crate::{
};
use horizon_world::loot::PackOverlay;
use serde_json::Value;
use std::{collections::HashSet, fs, path::Path};
use std::{collections::HashSet, path::Path};
pub use horizon_client_core::potions::{
BASE_POTION_COLOR, Category, EFFECTS, EffectDef, POTIONS, PotionDef, all_effects,
@@ -158,6 +158,28 @@ pub struct Brewing {
/// Items accepted in the bottle slots (`PotionIngredient.isPotionInput`).
pub inputs: HashSet<String>,
}
/// The files right in `recipe/brewing/`, parsed, by id: taken from the whole
/// recipe folder's read when another table holds it (the start loads them
/// together), else read alone. A file that doesn't parse is left out.
fn brewing_sources(root: &Path) -> Vec<(String, Value)> {
let recipe = root.join("data/minecraft/recipe");
let (folder, prefix) = match crate::json_folder::held(&recipe) {
Some(folder) => (folder, "brewing/"),
None => match crate::json_folder::read(&recipe.join("brewing")) {
Ok(folder) => (folder, ""),
Err(_) => return Vec::new(),
},
};
folder
.files
.iter()
.filter_map(|f| {
let name = f.name.strip_prefix(prefix).filter(|n| !n.contains('/'))?;
let v = serde_json::from_slice::<Value>(f.bytes().ok()?).ok()?;
Some((format!("minecraft:brewing/{name}"), v))
})
.collect()
}
impl Brewing {
pub fn load(root: &Path, items: &Items, overlay: &PackOverlay) -> Self {
// NeoForge's load conditions: a file whose don't hold is silently
@@ -172,23 +194,7 @@ impl Brewing {
let conditions_hold = |v: &Value| {
horizon_world::conditions::conditions_hold(v, &|id: &str| mods.iter().any(|m| m == id))
};
let mut sources: Vec<(String, Value)> =
fs::read_dir(root.join("data/minecraft/recipe/brewing"))
.into_iter()
.flatten()
.flatten()
.filter_map(|e| {
let bytes = fs::read(e.path()).ok()?;
let v = serde_json::from_slice::<Value>(&bytes).ok()?;
Some((
format!(
"minecraft:brewing/{}",
e.file_name().to_string_lossy().strip_suffix(".json")?
),
v,
))
})
.collect();
let mut sources: Vec<(String, Value)> = brewing_sources(root);
// The world's packs' brewing recipes over the game's own: the top
// pack's file for each id replaces it (`SimpleJsonResourceReloadListener`).
for (id, files) in overlay.kind("recipe") {
@@ -419,6 +419,31 @@ pub enum CameraFluid {
PowderSnow,
}
/// `Camera.NearPlane`: the near plane's centre and its half extents toward
/// the camera's left and up, relative to the camera.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct NearPlane {
pub forward: [f64; 3],
pub left: [f64; 3],
pub up: [f64; 3],
}
impl NearPlane {
/// `forward`, `getTopLeft`, `getTopRight`, `getBottomLeft`, `getBottomRight`:
/// the points `Camera.getFluidInCamera` looks for lava and powder snow at.
pub fn points(&self) -> [[f64; 3]; 5] {
let corner = |up: f64, left: f64| -> [f64; 3] {
std::array::from_fn(|i| self.forward[i] + self.up[i] * up + self.left[i] * left)
};
[
self.forward,
corner(1.0, 1.0),
corner(1.0, -1.0),
corner(-1.0, 1.0),
corner(-1.0, -1.0),
]
}
}
/// `Camera`: the eye-height and field-of-view easing and the view placement.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Camera {
@@ -481,6 +506,19 @@ impl Camera {
self.up = self.rotation.transform([0.0, 1.0, 0.0]);
self.left = self.rotation.transform([-1.0, 0.0, 0.0]);
}
/// `Camera.getNearPlane(fov)` for a `width` x `height` window, at the
/// projection's `zNear` of 0.05.
pub fn near_plane(&self, fov: f32, width: f32, height: f32) -> NearPlane {
let z_near = f64::from(0.05f32);
let aspect = f64::from(width) / f64::from(height);
let plane_height = horizon_core::jmath::tan(f64::from(fov * DEG_TO_RAD) / 2.0) * z_near;
let scale = |v: [f32; 3], by: f64| v.map(|c| f64::from(c) * by);
NearPlane {
forward: scale(self.forwards, z_near),
left: scale(self.left, plane_height * aspect),
up: scale(self.up, plane_height),
}
}
/// `Camera.move(forwards, up, right)`.
pub fn move_by(&mut self, forwards: f32, up: f32, right: f32) {
let offset = self.rotation.transform([right, up, -forwards]);
+50 -17
View File
@@ -29,6 +29,10 @@ pub type Signature = horizon_protocol::secure_chat::Signature;
/// How long a message shows before a deletion replaces it
/// (`ChatComponent.deleteMessageOrDelay`: `addedTime + 60`).
const DELETION_DELAY: u64 = 60;
/// The smooth chat extra: the ticks a new line takes to slide in and fade in,
/// and how far left of its place it starts (GUI pixels).
const INTRO_TICKS: f32 = 5.0;
const INTRO_WIDTH: f32 = 12.0;
/// `ChatVisiblity`: the Chat option's three values.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -233,14 +237,10 @@ impl Tag {
/// `ChatComponent.AlphaCalculator.timeBased`: opaque for 180 ticks, then
/// fading out quadratically over 20.
fn time_alpha(ticks: u64, added: u64) -> f32 {
let delta = ticks as i64 - added as i64;
let mut t = delta as f64 / 200.0;
t = 1.0 - t;
t *= 10.0;
t = t.clamp(0.0, 1.0);
t *= t;
t as f32
fn time_alpha(ticks: f32, added: u64) -> f32 {
let mut t = 1.0 - (ticks - added as f32) / 200.0;
t = (t * 10.0).clamp(0.0, 1.0);
t * t
}
#[derive(Default)]
pub struct Chat {
@@ -256,6 +256,11 @@ pub struct Chat {
scroll: usize,
new_since_scroll: bool,
pub options: ChatOptions,
/// The smooth chat extra: a new line slides in from the left while it
/// fades in, instead of appearing whole.
pub smooth: bool,
/// The smooth chat extra: no indicator bar at the left of a line.
pub hide_indicator: bool,
/// Messages printed to stdout as `[CHAT]` (vanilla logs them).
pub log: bool,
/// Messages added since the start, for the control socket's events.
@@ -555,7 +560,7 @@ impl Chat {
}
/// `ChatComponent.extractRenderState` (background mode unless `focused`)
/// on a screen `height` GUI pixels tall at `Hud.tickCount` `ticks`.
pub fn layout(&self, height: i32, ticks: u64, focused: bool) -> Vec<GuiDraw> {
pub fn layout(&self, height: i32, ticks: f32, focused: bool) -> Vec<GuiDraw> {
let total = self.lines.len();
if total == 0 {
return Vec::new();
@@ -569,28 +574,47 @@ impl Chat {
let entry_height = o.line_height();
let bottom_to_text =
(8.0 * (o.line_spacing + 1.0) - 4.0 * o.line_spacing + 0.5).floor() as i32;
// Vanilla's 200-tick fade reads the whole GUI ticks
// (`Gui.getGuiTicks`); only the smooth chat extra eases it per frame.
let fade_ticks = if self.smooth { ticks } else { ticks.floor() };
let alpha_of = |line: &Line| {
if focused {
1.0
} else {
time_alpha(ticks, line.added)
time_alpha(fade_ticks, line.added)
}
};
// The smooth chat extra: how far into its intro a line is, 0 at the
// tick it was added and 1 once it has arrived, as (alpha, offset x).
let intro_of = |line: &Line| {
if focused || !self.smooth {
return (1.0, 0.0);
}
let t = ((ticks - line.added as f32) / INTRO_TICKS).clamp(0.0, 1.0);
let eased = 1.0 - (1.0 - t) * (1.0 - t);
(eased, (eased - 1.0) * INTRO_WIDTH)
};
let visible = total
.saturating_sub(self.scroll)
.min(self.lines_per_page(focused));
let lines: Vec<(usize, &Line, f32)> = (0..visible)
let lines: Vec<(usize, &Line, f32, f32)> = (0..visible)
.rev()
.map(|i| (i, &self.lines[i + self.scroll]))
.map(|(i, line)| (i, line, alpha_of(line)))
.filter(|(_, _, alpha)| *alpha > 1.0e-5)
.map(|(i, line)| {
let (eased, slide) = intro_of(line);
(i, line, alpha_of(line) * eased, slide)
})
.filter(|(_, _, alpha, _)| *alpha > 1.0e-5)
.collect();
let mut out = vec![GuiDraw::Push(vec![
PoseOp::Scale(scale, scale),
PoseOp::Translate(4.0, 0.0),
])];
for &(i, _, alpha) in &lines {
for &(i, _, alpha, slide) in &lines {
let bottom = chat_bottom - i as i32 * entry_height;
if slide != 0.0 {
out.push(GuiDraw::Push(vec![PoseOp::Translate(slide, 0.0)]));
}
out.push(hud_layout::fill(
-4,
bottom - entry_height,
@@ -598,9 +622,15 @@ impl Chat {
bottom,
hud_layout::as8(alpha * background_opacity) << 24,
));
if slide != 0.0 {
out.push(GuiDraw::Pop);
}
}
for &(i, line, alpha) in &lines {
for &(i, line, alpha, slide) in &lines {
let bottom = chat_bottom - i as i32 * entry_height;
if slide != 0.0 {
out.push(GuiDraw::Push(vec![PoseOp::Translate(slide, 0.0)]));
}
let opacity = alpha * text_opacity;
let color = hud_layout::white(opacity);
if color >> 24 != 0 {
@@ -612,7 +642,7 @@ impl Chat {
shadow: true,
});
}
if let Some(tag) = &line.tag {
if let Some(tag) = line.tag.as_ref().filter(|_| !self.hide_indicator) {
let color = hud_layout::as8(opacity) << 24 | tag.color & 0xFF_FFFF;
out.push(hud_layout::fill(
-4,
@@ -622,6 +652,9 @@ impl Chat {
color,
));
}
if slide != 0.0 {
out.push(GuiDraw::Pop);
}
}
if focused {
let count = lines.len() as i32;
@@ -658,7 +691,7 @@ impl Chat {
}
/// Draws [`Chat::layout`] into the HUD batch, and with the chat screen
/// open the restricted prompt above the lines.
pub fn draw(&self, batch: &mut HudBatch, atlas: &HudAtlas, ticks: u64, focused: bool) {
pub fn draw(&self, batch: &mut HudBatch, atlas: &HudAtlas, ticks: f32, focused: bool) {
let draws = self.layout(batch.height as i32, ticks, focused);
hud_layout::paint(batch, atlas, &draws);
if focused && self.abilities.any_restriction() {
+56 -1
View File
@@ -39,6 +39,61 @@ fn add(chat: &mut Chat, font: &Font, content: Text) {
}
}
#[test]
fn smooth_chat_slides_a_line_in_and_can_hide_the_indicator() {
let mut chat = Chat {
smooth: true,
..Chat::default()
};
chat.lines.push(Line {
added: 0,
content: Text::literal("hi"),
tag: Some(std::sync::Arc::new(Tag::system_single_player(
SYSTEM_INDICATOR,
))),
});
let text_alpha = |draws: &[GuiDraw]| {
draws.iter().find_map(|d| match d {
GuiDraw::Text { color, .. } => Some(color >> 24),
_ => None,
})
};
// The slide is the negative translate the line carries while it moves.
let slide = |draws: &[GuiDraw]| {
draws.iter().find_map(|d| match d {
GuiDraw::Push(ops) => ops.iter().find_map(|op| match op {
PoseOp::Translate(x, _) if *x < 0.0 => Some(*x),
_ => None,
}),
_ => None,
})
};
// The tick it arrives: fully faded out, so nothing is drawn.
assert_eq!(text_alpha(&chat.layout(361, 0.0, false)), None);
// Halfway through the intro: left of its place and still faint.
let halfway = chat.layout(361, INTRO_TICKS / 2.0, false);
assert!(slide(&halfway).is_some_and(|x| (-INTRO_WIDTH..0.0).contains(&x)));
let faded = text_alpha(&halfway).expect("the line shows halfway through");
// Settled: in place and as opaque as vanilla's line.
let settled = chat.layout(361, INTRO_TICKS, false);
assert_eq!(slide(&settled), None);
assert!(text_alpha(&settled).expect("settled") > faded);
// The extra off draws the line whole on the tick it arrives, as vanilla.
chat.smooth = false;
let plain = chat.layout(361, 0.0, false);
assert_eq!(slide(&plain), None);
assert_eq!(text_alpha(&plain), text_alpha(&settled));
// The indicator bar is the -4 to -2 fill at the left of the line.
let bar = |draws: &[GuiDraw]| {
draws
.iter()
.any(|d| matches!(d, GuiDraw::Fill { x0: -4, x1: -2, .. }))
};
assert!(bar(&plain));
chat.hide_indicator = true;
assert!(!bar(&chat.layout(361, 0.0, false)));
}
#[test]
fn clicking_seed_copies_the_raw_value_only_inside_the_link() {
let font = font();
@@ -95,7 +150,7 @@ fn click_targets_match_rendered_spans_when_scaled_wrapped_and_scrolled() {
for scroll in [0, 3, 12] {
chat.scroll = scroll;
let mut links = 0;
for draw in chat.layout(361, 0, true) {
for draw in chat.layout(361, 0.0, true) {
if let GuiDraw::Text { text, x, y, .. } = draw {
let mut at = x as f64;
for (c, style) in text.chars() {
+13
View File
@@ -508,6 +508,15 @@ impl Chunk {
}
previous
}
/// Section `s`'s block states replaced whole (a bulk edit): its storage
/// compacted, and the heightmaps built again on their next read.
pub fn replace_blocks(&mut self, s: usize, values: &[u32; 4096]) {
let section = Arc::make_mut(&mut self.sections[s]);
section.blocks = Section::from_blocks(values);
section.compact();
self.surface = OnceLock::new();
self.motion_blocking = OnceLock::new();
}
/// `getHeight(Heightmap.Types.WORLD_SURFACE, x, z)` (`x`, `z` within the
/// chunk): one above the highest block that isn't `air`, or the column's
/// minimum Y.
@@ -635,6 +644,10 @@ impl RolledLoot {
/// Save data loaded with a chunk that Horizon keeps but does not simulate.
#[derive(Clone, Debug, Default)]
pub struct ChunkExtras {
/// Server-owned mod data attached to this chunk.
pub attachments: horizon_world::attachments::Attachments,
/// Attachment maps of the chunk's block entities, on a save snapshot.
pub block_entity_attachments: Vec<((i32, i32, i32), horizon_world::attachments::Attachments)>,
/// Chunk root keys written back as loaded (structures, PostProcessing,
/// InhabitedTime, blending data, ...).
pub root: Compound,
@@ -8,7 +8,13 @@ use horizon_world::{
level::sculk::SculkSpreader,
nbt::{Compound, Tag},
};
use std::{collections::HashMap, sync::Arc};
use std::{
collections::HashMap,
sync::{
Arc,
atomic::{AtomicU64, Ordering},
},
};
/// An entity's UUID as its four ints (`UUIDUtil.CODEC`).
pub type Uuid = [i32; 4];
@@ -94,18 +100,98 @@ pub enum BlockEntity {
/// `TestInstanceBlockEntity` (`test_instance_block.rs`).
TestInstanceBlock(Box<crate::test_instance_block::TestInstanceBlock>),
}
/// A mod's block entity: its type id (`mymod:machine`), its saved
/// A mod's block entity, read and changed through its fields
/// (`ModBlockEntityFields`) as a struct's: `entity.nbt`. Each change goes
/// through `DerefMut` and gives it a new `revision`, so a client tells
/// whether what it kept of it (`BlockEntityDraw::keep`) is still right
/// without comparing the whole entity, and no write can skip that.
pub struct ModBlockEntity {
fields: ModBlockEntityFields,
revision: ModRevision,
}
/// What a `ModBlockEntity` holds: its type id (`mymod:machine`), its saved
/// compound without `id`, `x`, `y`, `z` and `Items`, its item slots
/// (`Items`) when its block has a container (`ModContainer`), and the data
/// slots its menu shows (`ContainerData`: a furnace menu's flame and arrow),
/// which the mod sets and nothing saves.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ModBlockEntity {
pub struct ModBlockEntityFields {
pub id: String,
pub nbt: Compound,
pub items: Vec<Option<ItemStack>>,
pub data: Vec<i32>,
}
/// Which contents a `ModBlockEntity` has had: one per entity made (a clone
/// is another), counted on by each change. Equal revisions, equal contents.
/// Neither saved nor compared.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ModRevision {
born: u64,
changes: u64,
}
static MOD_BLOCK_ENTITIES_MADE: AtomicU64 = AtomicU64::new(0);
impl ModBlockEntity {
pub fn new(fields: ModBlockEntityFields) -> Self {
Self {
fields,
revision: ModRevision {
born: MOD_BLOCK_ENTITIES_MADE.fetch_add(1, Ordering::Relaxed),
changes: 0,
},
}
}
pub fn revision(&self) -> ModRevision {
self.revision
}
/// A client's copy loads the tag the server sent (`loadWithComponents`
/// in `ClientPacketListener.handleBlockEntityData`): `tag`'s fields
/// replace its own of the same names. One it already has as sent is
/// left alone, so a tag sent again unchanged keeps its revision.
pub fn merge_update_tag(&mut self, tag: &Compound) {
for (k, v) in tag {
if self.nbt.get(k) != Some(v) {
self.nbt.insert(k.clone(), v.clone());
}
}
}
}
impl From<ModBlockEntityFields> for ModBlockEntity {
fn from(fields: ModBlockEntityFields) -> Self {
Self::new(fields)
}
}
impl Default for ModBlockEntity {
fn default() -> Self {
Self::new(ModBlockEntityFields::default())
}
}
impl Clone for ModBlockEntity {
fn clone(&self) -> Self {
Self::new(self.fields.clone())
}
}
impl PartialEq for ModBlockEntity {
fn eq(&self, other: &Self) -> bool {
self.fields == other.fields
}
}
impl std::fmt::Debug for ModBlockEntity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.fields.fmt(f)
}
}
impl std::ops::Deref for ModBlockEntity {
type Target = ModBlockEntityFields;
fn deref(&self) -> &ModBlockEntityFields {
&self.fields
}
}
impl std::ops::DerefMut for ModBlockEntity {
fn deref_mut(&mut self) -> &mut ModBlockEntityFields {
self.revision.changes += 1;
&mut self.fields
}
}
/// The container of a mod block's block entity, as a `WorldlyContainer`
/// says it: its slot count, the slots each face reaches
/// (`getSlotsForFace`, by `Direction`: down, up, north, south, west, east),
@@ -135,6 +221,23 @@ pub struct ModFluidTanks {
/// The tanks a drain may take from (`canTakeItemThroughFace`).
pub outputs: Vec<usize>,
}
/// The energy buffer of a mod block's block entity (NeoForge's
/// `SimpleEnergyHandler`): its capacity, the most one insert and one extract
/// moves, and the faces a fill may come through and a drain may leave by.
/// The amount is not here: it lives in the entity's saved compound, under
/// `energy`, so it survives restarts and the block's own tick reads it.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ModEnergyBuffer {
pub capacity: i32,
/// The most one insert moves; 0 takes none.
pub max_insert: i32,
/// The most one extract moves; 0 gives none.
pub max_extract: i32,
/// The faces, by `Direction` number, a fill may come through.
pub insert_faces: [bool; 6],
/// The faces a drain may leave by.
pub extract_faces: [bool; 6],
}
/// The vanilla menus a mod container can open, with the slot count each
/// shows: slot grids, and the furnaces' (input, fuel, result), whose four
/// data slots draw the flame (`data[0]` of `data[1]`) and the arrow
@@ -588,3 +691,64 @@ pub struct CommandBlock {
pub auto: bool,
pub condition_met: bool,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_mod_block_entitys_revision_moves_with_each_change_and_only_then() {
let mut machine = ModBlockEntity::new(ModBlockEntityFields {
id: "mymod:machine".into(),
data: vec![0],
..Default::default()
});
let made = machine.revision();
// Reads leave it.
assert_eq!(machine.data[0] + machine.nbt.len() as i32, 0);
assert_eq!(machine.revision(), made);
// Every way to change a field moves it.
let mut seen = vec![made];
let changes: [fn(&mut ModBlockEntity); 4] = [
|m| m.data[0] = 1,
|m| _ = m.nbt.insert("progress".into(), Tag::Int(3)),
|m| m.items.push(None),
|m| m.id.push('s'),
];
for change in changes {
change(&mut machine);
assert!(!seen.contains(&machine.revision()));
seen.push(machine.revision());
}
// Another entity, or a clone, never shares one, even with equal
// contents; equality is the contents' alone.
let copy = machine.clone();
assert_eq!(copy, machine);
assert!(!seen.contains(&copy.revision()));
let fresh = ModBlockEntity::default();
assert_ne!(fresh.revision(), ModBlockEntity::default().revision());
}
#[test]
fn an_update_tag_sent_again_unchanged_keeps_the_revision() {
let mut machine = ModBlockEntity::default();
let tag = Compound::from([
("progress".into(), Tag::Int(3)),
("fuel".into(), Tag::Int(9)),
]);
machine.merge_update_tag(&tag);
let merged = machine.revision();
machine.merge_update_tag(&tag);
assert_eq!(machine.revision(), merged);
// One field changed: replaced, the other kept, the revision moved.
machine.merge_update_tag(&Compound::from([("progress".into(), Tag::Int(4))]));
assert_ne!(machine.revision(), merged);
assert_eq!(
machine.nbt,
Compound::from([
("progress".into(), Tag::Int(4)),
("fuel".into(), Tag::Int(9)),
])
);
}
}
+849
View File
@@ -0,0 +1,849 @@
//! OptiFine's Custom Item Textures (and CIT Resewn's): the rules of the enabled packs'
//! `optifine/cit/**/*.properties`, read from the overlay resource root. The rules themselves are
//! `horizon_optifine::cit`'s.
pub use horizon_optifine::cit::{Hand, Kind, Rule};
use crate::cit_glint::{Global, StackGlint};
use horizon_client_core::component::name_text;
use horizon_client_core::scoreboard::comp_text;
use horizon_items::item_data::{canon_tag, encode_components};
use horizon_items::items::ItemStack;
use horizon_optifine::cit::{ComponentTest, Source, ValueTest};
use horizon_optifine::{Properties, properties_files_in};
use horizon_world::nbt::{JavaString, Tag, parse_snbt};
use std::borrow::Cow;
use std::collections::HashMap;
use std::fs;
use std::hash::{DefaultHasher, Hash, Hasher};
use std::path::Path;
use std::sync::{Arc, Mutex};
/// Every rule under the root's `optifine/cit/`, `mcpatcher/cit/` and CIT Resewn's
/// `citresewn/cit/` folders, the one chosen first when several match first (`Rule::priority`).
/// The pack's global `cit.properties` is not a rule.
pub fn load_rules(root: &Path) -> Vec<Rule> {
let tops = ["optifine", "mcpatcher", "citresewn"];
let mut rules: Vec<Rule> = properties_files_in(root, &tops, "cit")
.into_iter()
.filter(|path| !path.ends_with("/cit.properties"))
.filter_map(|path| {
let props = read_properties(&root.join(&path))?;
let rule = Rule::parse(&path, &props, |p| root.join(p).is_file());
if rule.is_none() {
log::warn!("custom item textures: {path} is not a rule Horizon can read");
}
rule
})
.collect();
rules.sort_by(Rule::priority);
rules
}
/// A `.properties` file as CIT Resewn reads them (`Properties::parse_equals_first`): UTF-8,
/// else Latin-1 as Java reads them, so a pack saved that way still loads. `None` when it can't
/// be read.
pub fn read_properties(path: &Path) -> Option<Properties> {
let bytes = fs::read(path).ok()?;
let text = String::from_utf8(bytes)
.unwrap_or_else(|e| e.into_bytes().iter().map(|&b| char::from(b)).collect());
Some(Properties::parse_equals_first(&text))
}
/// The rules with an index by item and a cache of what each stack matched.
#[derive(Debug, Default)]
pub struct CitRules {
pub rules: Vec<Rule>,
/// The pack's `cit.properties`.
pub global: Global,
/// Per kind (`Kind as usize`), the rules naming each item, in priority order.
by_item: [HashMap<String, Vec<u32>>; 4],
/// Enchantment rules without `items`, in priority order.
any_item: Vec<u32>,
/// Per rule, per component test: its literal read as SNBT and as a JSON text component.
literals: Vec<Vec<Prepared>>,
selected: Mutex<HashMap<u64, Option<u32>>>,
enchanted: Mutex<HashMap<u64, Vec<u32>>>,
glints: Mutex<HashMap<u64, Arc<StackGlint>>>,
}
#[derive(Debug, Default)]
struct Prepared {
snbt: Option<Tag>,
json: Option<String>,
}
/// Cache entries kept before the cache starts over.
const CACHE_LIMIT: usize = 4096;
impl CitRules {
/// The enabled packs' rules under the overlay resource root.
pub fn load(root: &Path) -> Self {
Self {
global: crate::cit_glint::load_global(root),
..Self::new(load_rules(root))
}
}
/// `rules` in priority order, the winner first.
pub fn new(rules: Vec<Rule>) -> Self {
let mut by_item: [HashMap<String, Vec<u32>>; 4] = Default::default();
let mut any_item = Vec::new();
for (i, rule) in rules.iter().enumerate() {
let i = i as u32;
if rule.kind == Kind::Enchantment && rule.items.is_empty() {
any_item.push(i);
}
for item in &rule.items {
let list = by_item[rule.kind as usize].entry(item.clone()).or_default();
if list.last() != Some(&i) {
list.push(i);
}
}
}
let literals = rules
.iter()
.map(|r| r.components.iter().map(prepare).collect())
.collect();
Self {
rules,
by_item,
any_item,
literals,
..Default::default()
}
}
pub fn is_empty(&self) -> bool {
self.rules.is_empty()
}
/// Leave out the Enchantment rules `keep` refuses (those without a drawable texture): they
/// match no stack, so vanilla's glint and the other rules of their layer stay.
pub fn keep_glints(&mut self, keep: impl Fn(usize) -> bool) {
self.any_item.retain(|&i| keep(i as usize));
let named = &mut self.by_item[Kind::Enchantment as usize];
for list in named.values_mut() {
list.retain(|&i| keep(i as usize));
}
named.retain(|_, list| !list.is_empty());
lock(&self.enchanted).clear();
lock(&self.glints).clear();
}
/// The winning rule of `kind` (Item, Armor or Elytra) for this stack, as an index into `rules`.
pub fn select(
&self,
kind: Kind,
stack: &ItemStack,
hand: Hand,
max_damage: u32,
) -> Option<usize> {
let candidates = self.by_item[kind as usize].get(&*stack.item)?;
let comps = encode_components(stack);
let key = cache_key((0, kind, hand), stack, max_damage, comps.as_ref());
if let Some(hit) = lock(&self.selected).get(&key) {
return hit.map(|i| i as usize);
}
let found = candidates
.iter()
.copied()
.find(|&i| self.matches(i, stack, hand, max_damage, comps.as_ref()));
remember(&self.selected, key, found);
found.map(|i| i as usize)
}
/// Every matching Enchantment rule for the stack drawn in `hand` (`Hand::Main` for one in a
/// GUI), in priority order.
pub fn enchantments(&self, stack: &ItemStack, hand: Hand, max_damage: u32) -> Vec<usize> {
let named = self.by_item[Kind::Enchantment as usize]
.get(&*stack.item)
.map_or(&[][..], Vec::as_slice);
if named.is_empty() && self.any_item.is_empty() {
return Vec::new();
}
let comps = encode_components(stack);
let key = cache_key(
(1, Kind::Enchantment, hand),
stack,
max_damage,
comps.as_ref(),
);
let hit = lock(&self.enchanted).get(&key).cloned();
let found = hit.unwrap_or_else(|| {
let mut all: Vec<u32> = named.iter().chain(&self.any_item).copied().collect();
all.sort_unstable();
all.retain(|&i| self.matches(i, stack, hand, max_damage, comps.as_ref()));
remember(&self.enchanted, key, all.clone());
all
});
found.into_iter().map(|i| i as usize).collect()
}
/// The glint layers this stack draws in `hand` (`Hand::Main` in a GUI) and whether
/// vanilla's still does (`StackGlint`), worked out once per stack and hand; `None` when the
/// pack changes no glint. `foil` is whether the stack draws a glint at all
/// (`ItemStack::has_foil`): one that doesn't gets no layers.
pub fn glint(
&self,
stack: &ItemStack,
hand: Hand,
max_damage: u32,
foil: bool,
) -> Option<Arc<StackGlint>> {
let no_rules =
self.any_item.is_empty() && self.by_item[Kind::Enchantment as usize].is_empty();
// Before the cache, which `foil` is not part of.
if !foil || (no_rules && self.global.use_glint) {
return None;
}
let comps = encode_components(stack);
let key = cache_key(
(2, Kind::Enchantment, hand),
stack,
max_damage,
comps.as_ref(),
);
if let Some(hit) = lock(&self.glints).get(&key) {
return Some(hit.clone());
}
let matched = self.enchantments(stack, hand, max_damage);
let glint = Arc::new(StackGlint::new(
&self.rules,
&matched,
stack.crafting_enchantments(),
&self.global,
));
remember(&self.glints, key, glint.clone());
Some(glint)
}
/// Whether rule `i` matches the stack drawn in `hand`.
fn matches(
&self,
i: u32,
stack: &ItemStack,
hand: Hand,
max_damage: u32,
comps: Option<&Tag>,
) -> bool {
let rule = &self.rules[i as usize];
if rule.hand != Hand::Any && rule.hand != hand {
return false;
}
let count = i32::try_from(stack.count).unwrap_or(i32::MAX);
if rule.stack_size.as_ref().is_some_and(|r| !r.contains(count)) {
return false;
}
// CIT Resewn: only damageable items have damage.
let damage = if max_damage > 0 { stack.damage } else { 0 };
if rule
.damage
.as_ref()
.is_some_and(|d| !d.matches(damage, max_damage))
{
return false;
}
let enchantments = stack.crafting_enchantments();
let listed = |id: &str| rule.enchantments.iter().any(|e| e == id);
if !rule.enchantments.is_empty() && !enchantments.iter().any(|(id, _)| listed(id)) {
return false;
}
if let Some(levels) = &rule.enchantment_levels {
let level_in_range = enchantments.iter().any(|(id, level)| {
(rule.enchantments.is_empty() || listed(id))
&& levels.contains(i32::try_from(*level).unwrap_or(i32::MAX))
});
if !level_in_range {
return false;
}
}
let literals = &self.literals[i as usize];
rule.components
.iter()
.zip(literals)
.all(|(test, lit)| component_test(test, lit, stack, comps) != test.negate)
}
}
fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
m.lock().unwrap_or_else(|e| e.into_inner())
}
fn remember<V>(cache: &Mutex<HashMap<u64, V>>, key: u64, value: V) {
let mut cache = lock(cache);
if cache.len() >= CACHE_LIMIT {
cache.clear();
}
cache.insert(key, value);
}
/// What a cached answer depends on; `query` tells `select`, `enchantments` and `glint` apart.
fn cache_key(query: (u8, Kind, Hand), stack: &ItemStack, max: u32, comps: Option<&Tag>) -> u64 {
let mut h = DefaultHasher::new();
(query.0, query.1 as u8, query.2 as u8, &*stack.item).hash(&mut h);
(stack.count, stack.damage, max, comps).hash(&mut h);
h.finish()
}
fn prepare(test: &ComponentTest) -> Prepared {
let ValueTest::Literal(lit) = &test.value else {
return Prepared::default();
};
let text = lit.text.trim();
let snbt = match parse_snbt(text) {
Ok((tag, read)) if read == text.len() && text.starts_with(['{', '[']) => Some(tag),
_ => None,
};
let json = json_plain(text);
Prepared { snbt, json }
}
/// The plain text of `s` read as a JSON text component.
fn json_plain(s: &str) -> Option<String> {
if !s.starts_with(['{', '[', '"']) {
return None;
}
let value: serde_json::Value = serde_json::from_str(s).ok()?;
Some(comp_text(&value).plain())
}
/// Whether one test passes, before its `negate`.
fn component_test(
test: &ComponentTest,
lit: &Prepared,
stack: &ItemStack,
comps: Option<&Tag>,
) -> bool {
let legacy;
let (root, text_depth) = match &test.source {
Source::Component(id) => {
let depth = match id.as_str() {
"minecraft:custom_name" | "minecraft:item_name" => Some(0),
"minecraft:lore" => Some(1),
_ => None,
};
(comps.and_then(|c| c.get(id)), depth)
}
Source::LegacyEnchantments { stored } => {
let levels = if *stored {
stack.stored_enchantments()
} else {
stack.enchantments()
};
legacy = Tag::List(
levels
.iter()
.map(|(id, lvl)| {
let lvl = i16::try_from(*lvl).unwrap_or(i16::MAX);
Tag::compound([
("id", Tag::String((&**id).into())),
("lvl", Tag::Short(lvl)),
])
})
.collect(),
);
(Some(&legacy), None)
}
};
// Fields the component leaves out when 0, which pre-1.20.5 items always wrote.
let defaulted;
let root = match (&test.source, root, test.path.first()) {
(Source::Component(id), Some(Tag::Compound(map)), Some(field))
if matches!(
(id.as_str(), field.as_str()),
("minecraft:written_book_content", "generation")
| ("minecraft:fireworks", "flight_duration")
) && !map.contains_key(&JavaString::from(field.as_str())) =>
{
let mut map = map.clone();
map.insert(JavaString::from(field.as_str()), Tag::Int(0));
defaulted = Tag::Compound(map);
Some(&defaulted)
}
(_, root, _) => root,
};
let Some(root) = root else {
return matches!(test.value, ValueTest::Exists(false));
};
if let ValueTest::Exists(want) = test.value {
return walk(root, &test.path, 0, &mut |_, _| true) == want;
}
walk(root, &test.path, 0, &mut |tag, depth| {
value_test(&test.value, lit, tag, text_depth == Some(depth))
})
}
/// Visits what `path` reaches from `tag`, true once `visit` is.
fn walk(
tag: &Tag,
path: &[String],
depth: usize,
visit: &mut dyn FnMut(&Tag, usize) -> bool,
) -> bool {
let Some((seg, rest)) = path.split_first() else {
return visit(tag, depth);
};
let mut next = |t: &Tag| walk(t, rest, depth + 1, visit);
let len = match tag {
Tag::Compound(map) if seg == "*" => return map.values().any(&mut next),
Tag::Compound(map) => return map.get(&JavaString::from(seg.as_str())).is_some_and(next),
Tag::List(v) => v.len(),
Tag::ByteArray(v) => v.len(),
Tag::IntArray(v) => v.len(),
Tag::LongArray(v) => v.len(),
_ => return false,
};
let element = |i: usize| match tag {
Tag::List(v) => Cow::Borrowed(&v[i]),
Tag::ByteArray(v) => Cow::Owned(Tag::Byte(v[i] as i8)),
Tag::IntArray(v) => Cow::Owned(Tag::Int(v[i])),
Tag::LongArray(v) => Cow::Owned(Tag::Long(v[i])),
_ => Cow::Owned(Tag::End),
};
if seg == "count" {
return next(&Tag::Int(i32::try_from(len).unwrap_or(i32::MAX)));
}
if seg == "*" {
return (0..len).any(|i| next(&element(i)));
}
seg.parse::<usize>()
.ok()
.filter(|&i| i < len)
.is_some_and(|i| next(&element(i)))
}
fn int_value(tag: &Tag) -> Option<i64> {
match *tag {
Tag::Byte(v) => Some(v.into()),
Tag::Short(v) => Some(v.into()),
Tag::Int(v) => Some(v.into()),
Tag::Long(v) => Some(v),
_ => None,
}
}
/// A number as Java's `String.valueOf` writes it (`3`, `3.0`).
fn decimal(tag: &Tag) -> Option<String> {
let s = match *tag {
Tag::Float(b) => f32::from_bits(b).to_string(),
Tag::Double(b) => f64::from_bits(b).to_string(),
_ => return int_value(tag).map(|v| v.to_string()),
};
Some(if s.contains(['.', 'e', 'N', 'i']) {
s
} else {
s + ".0"
})
}
/// `text` against a string, its JSON text, or the plain text of a text component.
fn text_matches(tag: &Tag, is_text: bool, text: impl Fn(&str) -> bool) -> bool {
match tag {
Tag::String(s) => {
let s = s.to_string_lossy();
text(&s)
|| (is_text && text(&name_text(tag).plain()))
|| json_plain(&s).is_some_and(|p| text(&p))
}
Tag::Compound(_) | Tag::List(_) => is_text && text(&name_text(tag).plain()),
_ => false,
}
}
fn value_test(test: &ValueTest, lit: &Prepared, tag: &Tag, is_text: bool) -> bool {
match test {
ValueTest::Exists(_) => true,
ValueTest::Range(r) => {
int_value(tag).is_some_and(|v| i32::try_from(v).is_ok_and(|v| r.contains(v)))
}
ValueTest::Raw(name) => name.matches(&canon_tag(tag)),
ValueTest::Text(name) => match decimal(tag) {
Some(n) => name.matches(&n),
None => text_matches(tag, is_text, |s| name.matches(s)),
},
ValueTest::Literal(l) => match tag {
Tag::Float(b) => l.text.trim().parse::<f32>().ok() == Some(f32::from_bits(*b)),
Tag::Double(b) => l.float == Some(f64::from_bits(*b)),
Tag::Compound(_) | Tag::List(_)
if lit.snbt.as_ref().is_some_and(|e| contains(e, tag)) =>
{
true
}
_ => match int_value(tag) {
Some(v) => l.int == Some(v),
None => text_matches(tag, is_text, |s| {
s == l.text || lit.json.as_deref() == Some(s)
}),
},
},
}
}
/// `NbtUtils.compareNbt(expected, actual, true)`: every key and element of `expected` found in
/// `actual`.
fn contains(expected: &Tag, actual: &Tag) -> bool {
match (expected, actual) {
(Tag::Compound(e), Tag::Compound(a)) => e
.iter()
.all(|(k, v)| a.get(k).is_some_and(|a| contains(v, a))),
(Tag::List(e), Tag::List(a)) if e.is_empty() => a.is_empty(),
(Tag::List(e), Tag::List(a)) => e.iter().all(|e| a.iter().any(|a| contains(e, a))),
_ => expected == actual,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
/// Rules from (file stem, properties), sorted as `load_rules` sorts them.
fn rules(files: &[(&str, &str)]) -> CitRules {
let mut rules: Vec<Rule> = files
.iter()
.map(|(stem, text)| {
let path = format!("assets/minecraft/optifine/cit/{stem}.properties");
let text = format!("texture=x\n{text}");
Rule::parse(&path, &Properties::parse_equals_first(&text), |_| true).unwrap()
})
.collect();
rules.sort_by(Rule::priority);
CitRules::new(rules)
}
fn stem(cit: &CitRules, i: Option<usize>) -> Option<&str> {
let path = &cit.rules[i?].path;
path.rsplit('/').next()?.strip_suffix(".properties")
}
fn item(cit: &CitRules, stack: ItemStack) -> Option<&str> {
stem(cit, cit.select(Kind::Item, &stack, Hand::Main, 0))
}
fn new(id: &str) -> ItemStack {
ItemStack::new(&format!("minecraft:{id}"), 1)
}
fn string(s: &str) -> Tag {
Tag::String(s.into())
}
fn with(id: &str, component: &str, tag: Tag) -> ItemStack {
let mut stack = new(id);
let component = format!("minecraft:{component}");
stack.data_mut().other.push((component, tag));
stack
}
fn enchanted(id: &str, levels: &[(&str, u32)]) -> ItemStack {
let mut stack = new(id);
let levels = levels
.iter()
.map(|(e, l)| (Arc::from(format!("minecraft:{e}")), *l));
stack.data_mut().enchantments = levels.collect();
stack
}
#[test]
fn heavier_rules_win_and_kinds_stay_apart() {
let cit = rules(&[
("plain", "items=stick"),
("heavy", "items=stick\nweight=2\nstackSize=2-"),
("offhand", "items=stick\nweight=5\nhand=off"),
("armor", "type=armor\nitems=stick"),
("wings", "type=elytra"),
]);
assert_eq!(item(&cit, new("stick")), Some("plain"));
assert_eq!(
item(&cit, ItemStack::new("minecraft:stick", 3)),
Some("heavy")
);
let pick = |kind, id, hand, max| stem(&cit, cit.select(kind, &new(id), hand, max));
assert_eq!(pick(Kind::Item, "stick", Hand::Off, 0), Some("offhand"));
assert_eq!(item(&cit, new("stone")), None);
assert_eq!(item(&cit, new("elytra")), None);
assert_eq!(pick(Kind::Elytra, "elytra", Hand::Main, 432), Some("wings"));
assert_eq!(pick(Kind::Armor, "stick", Hand::Main, 0), Some("armor"));
}
#[test]
fn names_match_by_their_plain_text() {
let cit = rules(&[
("glob", "items=stick\nnbt.display.Name=ipattern:*sword"),
("exact", "items=stone\ncomponents.custom_name=My Sword"),
("json", "items=dirt\nnbt.display.Name=[\"My \",\"Sword\"]"),
]);
let named = |id: &str, name: Tag| {
let mut stack = new(id);
stack.data_mut().custom_name = Some(name);
stack
};
let styled = Tag::compound([("text", string("My Sword")), ("color", string("red"))]);
assert_eq!(item(&cit, named("stick", styled.clone())), Some("glob"));
assert_eq!(item(&cit, named("stick", string("Blade"))), None);
assert_eq!(item(&cit, new("stick")), None);
assert_eq!(item(&cit, named("stone", styled.clone())), Some("exact"));
assert_eq!(item(&cit, named("dirt", styled)), Some("json"));
}
#[test]
fn lore_lines_and_list_counts() {
let cit = rules(&[
(
"any_line",
"items=stick\nnbt.display.Lore.*=ipattern:*legendary*",
),
("two_lines", "items=stone\ncomponents.lore.count=2"),
]);
let bold = Tag::compound([("text", string("A LEGENDARY item")), ("bold", Tag::Byte(1))]);
let lore = Tag::List(vec![string("Common"), bold]);
assert_eq!(
item(&cit, with("stick", "lore", lore.clone())),
Some("any_line")
);
assert_eq!(item(&cit, with("stone", "lore", lore)), Some("two_lines"));
let one = Tag::List(vec![string("A legendary item")]);
assert_eq!(item(&cit, with("stone", "lore", one)), None);
}
#[test]
fn custom_data_existence_and_negation() {
let cit = rules(&[
(
"aote",
"items=stick\nweight=2\nnbt.ExtraAttributes.id=ASPECT_OF_THE_END",
),
(
"not_hyp",
"items=stick\nweight=1\ncomponent.custom_data.ExtraAttributes.id=!HYP",
),
("unnamed", "items=stick\nnbt.display.Name=exists:false"),
]);
let data = |id: &str| {
let attributes =
Tag::compound([("ExtraAttributes", Tag::compound([("id", string(id))]))]);
with("stick", "custom_data", attributes)
};
assert_eq!(item(&cit, data("ASPECT_OF_THE_END")), Some("aote"));
assert_eq!(item(&cit, data("MIDAS")), Some("not_hyp"));
assert_eq!(item(&cit, data("HYP")), Some("unnamed"));
let mut named = data("HYP");
named.data_mut().custom_name = Some(string("Hyperion"));
assert_eq!(item(&cit, named), None);
}
#[test]
fn legacy_enchantment_lists_and_model_data() {
let cit = rules(&[
(
"sharp",
"items=stick\nnbt.Enchantments.*.id=minecraft:sharpness\nnbt.Enchantments.0.lvl=5",
),
("cmd", "items=stone\nnbt.CustomModelData=3"),
(
"cmd_snbt",
"items=dirt\ncomponents.custom_model_data={floats:[3.0f]}",
),
]);
assert_eq!(
item(&cit, enchanted("stick", &[("sharpness", 5)])),
Some("sharp")
);
assert_eq!(item(&cit, enchanted("stick", &[("sharpness", 4)])), None);
let model = |id: &str, f: f32| {
let floats = Tag::List(vec![Tag::Float(f.to_bits()), Tag::Float(7f32.to_bits())]);
with(id, "custom_model_data", Tag::compound([("floats", floats)]))
};
assert_eq!(item(&cit, model("stone", 3.0)), Some("cmd"));
assert_eq!(item(&cit, model("stone", 3.5)), None);
assert_eq!(item(&cit, model("dirt", 3.0)), Some("cmd_snbt"));
assert_eq!(item(&cit, model("dirt", 2.0)), None);
}
#[test]
fn fields_left_out_at_zero_read_as_zero() {
let cit = rules(&[("zero", "items=firework_rocket\nnbt.Fireworks.Flight=0")]);
let mut rocket = new("firework_rocket");
rocket.data_mut().fireworks = Some((0, Vec::new()));
assert_eq!(item(&cit, rocket.clone()), Some("zero"));
rocket.data_mut().fireworks = Some((2, Vec::new()));
assert_eq!(item(&cit, rocket), None);
}
#[test]
fn damage_counts_only_for_damageable_items() {
let cit = rules(&[
("worn", "items=diamond_sword\ndamage=50-100%"),
("new", "items=stick\ndamage=0"),
]);
let damaged = |id: &str, damage: u32, max: u32| {
let mut stack = new(id);
stack.damage = damage;
stem(&cit, cit.select(Kind::Item, &stack, Hand::Main, max))
};
// Half of 1561 is 780.5.
assert_eq!(damaged("diamond_sword", 780, 1561), None);
assert_eq!(damaged("diamond_sword", 781, 1561), Some("worn"));
assert_eq!(damaged("stick", 5, 0), Some("new"));
}
#[test]
fn enchantment_levels_and_rules_for_any_item() {
let cit = rules(&[
(
"sharp3",
"items=stick\nenchantments=sharpness\nenchantmentLevels=3-",
),
("any3", "items=stone\nenchantmentLevels=3-"),
("glint", "type=enchantment\nenchantments=sharpness"),
(
"sword_glint",
"type=enchantment\nitems=diamond_sword\nweight=2",
),
]);
let mixed = [("sharpness", 2), ("unbreaking", 3)];
assert_eq!(item(&cit, enchanted("stick", &mixed)), None);
assert_eq!(
item(&cit, enchanted("stick", &[("sharpness", 4)])),
Some("sharp3")
);
assert_eq!(item(&cit, enchanted("stone", &mixed)), Some("any3"));
let glints = |stack: ItemStack| -> Vec<&str> {
let found = cit.enchantments(&stack, Hand::Main, 0).into_iter();
found.map(|i| stem(&cit, Some(i)).unwrap()).collect()
};
let sharp = [("sharpness", 1)];
assert_eq!(
glints(enchanted("diamond_sword", &sharp)),
["sword_glint", "glint"]
);
assert_eq!(glints(enchanted("stick", &sharp)), ["glint"]);
assert!(glints(new("stick")).is_empty());
// Glints are never an item's texture.
assert_eq!(item(&cit, enchanted("diamond_sword", &sharp)), None);
}
/// `CitRules::glint` in the main hand, with the stack's own `has_foil` as callers pass it.
fn glint(cit: &CitRules, stack: &ItemStack, item_glint: bool) -> Option<Arc<StackGlint>> {
cit.glint(stack, Hand::Main, 0, stack.has_foil(item_glint))
}
/// `stack` with its `enchantment_glint_override` set to `on`.
fn overridden(mut stack: ItemStack, on: bool) -> ItemStack {
let flag = (
"minecraft:enchantment_glint_override".to_owned(),
Tag::Byte(on.into()),
);
stack.data_mut().other.push(flag);
stack
}
#[test]
fn a_stack_glint_reads_the_book_and_the_pack_file() {
let mut cit = rules(&[
("sharp", "type=enchantment\nenchantments=sharpness"),
(
"keep",
"type=enchantment\nlayer=1\nuseGlint=true\nenchantments=mending",
),
]);
// An enchanted book's glint follows its stored enchantments.
let mut book = new("enchanted_book");
book.data_mut().stored_enchantments = vec![
(Arc::from("minecraft:sharpness"), 3),
(Arc::from("minecraft:mending"), 1),
];
let book = glint(&cit, &book, true).unwrap();
let weights: Vec<_> = book.layers.iter().map(|l| l.weight).collect();
assert_eq!(weights, [0.75, 0.25]);
assert!(book.vanilla);
let plain = glint(&cit, &enchanted("stick", &[("sharpness", 1)]), false).unwrap();
assert_eq!(plain.layers.len(), 1);
assert!(!plain.vanilla);
// No enchantment rules: nothing changes, unless the pack turns vanilla's glint off.
let mut bare = rules(&[("stick", "items=stick")]);
assert!(glint(&bare, &overridden(new("stick"), true), false).is_none());
bare.global.use_glint = false;
assert!(
!glint(&bare, &overridden(new("stick"), true), false)
.unwrap()
.vanilla
);
cit.global.use_glint = false;
assert!(
glint(&cit, &overridden(new("stick"), true), false)
.unwrap()
.is_empty()
);
}
#[test]
fn only_a_stack_with_a_glint_gets_layers() {
let cit = rules(&[("any", "type=enchantment")]);
assert!(glint(&cit, &new("stick"), false).is_none());
// The override turns an enchanted stack's glint off, and an unenchanted one's on.
let off = overridden(enchanted("stick", &[("sharpness", 1)]), false);
assert!(glint(&cit, &off, false).is_none());
let on = glint(&cit, &overridden(new("stick"), true), false).unwrap();
let weights: Vec<_> = on.layers.iter().map(|l| l.weight).collect();
assert_eq!(
weights,
[1.0],
"nothing to share: the rule at full strength"
);
// The same stack, asked again without its glint, is not answered from the cache.
assert!(
cit.glint(&overridden(new("stick"), true), Hand::Main, 0, false)
.is_none()
);
}
#[test]
fn a_rule_left_out_of_the_glints_matches_nothing() {
let mut cit = rules(&[
("broken", "type=enchantment\nitems=diamond_sword\nweight=1"),
("shown", "type=enchantment"),
]);
let sword = enchanted("diamond_sword", &[("sharpness", 1)]);
let layers = |cit: &CitRules| -> Vec<String> {
let glint = glint(cit, &sword, false).unwrap();
let stems = glint
.layers
.iter()
.map(|l| stem(cit, Some(l.rule)).unwrap());
stems.map(str::to_owned).collect()
};
// The heavier rule wins the layer, until its texture is missing.
assert_eq!(layers(&cit), ["broken"]);
let broken = (0..cit.rules.len())
.find(|&i| stem(&cit, Some(i)) == Some("broken"))
.unwrap();
cit.keep_glints(|i| i != broken);
assert_eq!(layers(&cit), ["shown"]);
assert_eq!(cit.enchantments(&sword, Hand::Main, 0).len(), 1);
// None left: vanilla's glint stays.
cit.keep_glints(|_| false);
assert!(glint(&cit, &sword, false).is_none());
}
#[test]
fn an_off_hand_layer_draws_only_in_the_off_hand() {
let cit = rules(&[
("left", "type=enchantment\nlayer=1\nhand=off"),
("both", "type=enchantment"),
]);
let sword = enchanted("diamond_sword", &[("sharpness", 1)]);
let layers = |hand| -> Vec<&str> {
let glint = cit.glint(&sword, hand, 0, true).unwrap();
glint
.layers
.iter()
.map(|l| stem(&cit, Some(l.rule)).unwrap())
.collect()
};
assert_eq!(layers(Hand::Off), ["both", "left"]);
// Asked in this order, a cache keyed without the hand would answer as for the off hand.
assert_eq!(layers(Hand::Main), ["both"]);
}
}
@@ -0,0 +1,319 @@
//! Custom Item Textures for worn armour and elytra (`type=armor`, `type=elytra`). An armour rule
//! names its textures after the armour texture files of before 1.21.2
//! (`textures/models/armor/diamond_layer_1.png`): `texture.diamond_layer_1`,
//! `texture.leather_layer_2_overlay`..., with `texture` for any layer it does not name. 26.3's
//! equipment assets name the same layers `entity/equipment/humanoid/diamond` (layer 1: helmet,
//! chestplate, boots) and `entity/equipment/humanoid_leggings/diamond` (layer 2), so the layers
//! are mapped back to the old names here. An elytra rule's `texture` replaces the wings'.
use crate::cit_assets::{rule_sprites, sprite_id};
use horizon_optifine::cit::{Kind, Rule};
use std::collections::BTreeSet;
/// The pre-1.21.2 armour texture name of an equipment asset's layer: `asset` (`minecraft:gold`
/// or `gold`) on the leggings layer (`humanoid_leggings`) or the others (`humanoid`), the dye
/// overlay (`leather_overlay`) or the main texture.
pub fn armor_layer_name(asset: &str, leggings: bool, overlay: bool) -> String {
let path = asset.rsplit_once(':').map_or(asset, |(_, p)| p);
// The equipment assets renamed from their old textures; the rest kept their names.
let old = match path {
"turtle_scute" => "turtle",
other => other,
};
let layer = if leggings { 2 } else { 1 };
let suffix = if overlay { "_overlay" } else { "" };
format!("{old}_layer_{layer}{suffix}")
}
/// The texture (a resource path) `rule` draws on the layer named `layer`: its `texture.<layer>`,
/// else its `texture`.
pub fn armor_texture<'a>(rule: &'a Rule, layer: &str) -> Option<&'a str> {
rule.textures
.iter()
.find(|(name, _)| name == layer)
.map(|(_, path)| path.as_str())
.or(rule.texture.as_deref())
}
/// The atlas name of a CIT texture: its `cit_assets` id, without `minecraft:` as the atlas packs
/// vanilla's sprites.
fn atlas_name(resource: &str) -> Option<String> {
let id = sprite_id(resource)?;
Some(match id.strip_prefix("minecraft:") {
Some(name) => name.to_owned(),
None => id,
})
}
/// The atlas sprite `rule` puts on the layer of equipment asset `asset` (see
/// [`armor_layer_name`]); `None` keeps the vanilla texture.
pub fn layer_sprite(rule: &Rule, asset: &str, leggings: bool, overlay: bool) -> Option<String> {
atlas_name(armor_texture(
rule,
&armor_layer_name(asset, leggings, overlay),
)?)
}
/// The atlas sprite an elytra rule puts on the wings.
pub fn elytra_sprite(rule: &Rule) -> Option<String> {
atlas_name(armor_texture(rule, "elytra")?)
}
/// The atlas sprite the one CIT armour rule `rule` puts on a worn armour layer: the layer
/// `vanilla` (an atlas name, `…_overlay` for the dye overlay) of equipment asset `asset` in
/// equipment layer `kind` (`humanoid_leggings` for the leggings). `None` draws `vanilla`, as does
/// a name the atlas lacks; nothing is allocated without a rule.
pub fn worn_layer_sprite(
rule: Option<&Rule>,
asset: &str,
kind: &str,
vanilla: &str,
) -> Option<String> {
let leggings = kind == "humanoid_leggings";
let overlay = vanilla.ends_with("_overlay");
layer_sprite(rule?, asset, leggings, overlay)
}
/// The CIT elytra rule's wings sprite. On `None`, or a name the atlas lacks, the caller draws the
/// cape, else the equipment asset's own wings.
pub fn wings_sprite(rule: Option<&Rule>) -> Option<String> {
elytra_sprite(rule?)
}
/// What a worn layer draws, looked up with `get` (the atlas): the rule's sprite
/// ([`worn_layer_sprite`]), else `vanilla` when there is no rule or the atlas lacks its texture.
pub fn worn_layer<R>(
rule: Option<&Rule>,
asset: &str,
kind: &str,
vanilla: &str,
get: impl Fn(&str) -> Option<R>,
) -> Option<R> {
worn_layer_sprite(rule, asset, kind, vanilla)
.and_then(|name| get(&name))
.or_else(|| get(vanilla))
}
/// What worn wings draw, looked up with `get`: the rule's sprite, else the cape; `None` leaves
/// them to the equipment asset's own wings.
pub fn wings<R>(
rule: Option<&Rule>,
cape: Option<&str>,
get: impl Fn(&str) -> Option<R>,
) -> Option<R> {
wings_sprite(rule)
.and_then(|name| get(&name))
.or_else(|| cape.and_then(&get))
}
/// Sprite ids the armour and elytra rules draw (see [`rule_sprites`]).
pub fn armor_sprites(rules: &[Rule]) -> BTreeSet<String> {
rule_sprites(rules, |kind| matches!(kind, Kind::Armor | Kind::Elytra))
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_optifine::Properties;
fn rule(path: &str, text: &str) -> Rule {
Rule::parse(path, &Properties::parse_equals_first(text), |_| true).unwrap()
}
#[test]
fn layers_take_their_old_texture_names() {
assert_eq!(
armor_layer_name("minecraft:diamond", false, false),
"diamond_layer_1"
);
assert_eq!(armor_layer_name("diamond", true, false), "diamond_layer_2");
assert_eq!(
armor_layer_name("minecraft:leather", false, true),
"leather_layer_1_overlay"
);
assert_eq!(
armor_layer_name("minecraft:leather", true, true),
"leather_layer_2_overlay"
);
assert_eq!(
armor_layer_name("minecraft:gold", false, false),
"gold_layer_1"
);
assert_eq!(
armor_layer_name("minecraft:chainmail", true, false),
"chainmail_layer_2"
);
assert_eq!(
armor_layer_name("minecraft:turtle_scute", false, false),
"turtle_layer_1"
);
assert_eq!(
armor_layer_name("minecraft:netherite", true, false),
"netherite_layer_2"
);
}
#[test]
fn a_named_layer_wins_over_the_generic_texture() {
let r = rule(
"assets/minecraft/optifine/cit/armor/blue.properties",
"type=armor\nitems=diamond_chestplate diamond_leggings\ntexture=generic\n\
texture.diamond_layer_2=blue_legs\n",
);
let dir = "assets/minecraft/optifine/cit/armor";
assert_eq!(
armor_texture(&r, "diamond_layer_2"),
Some(format!("{dir}/blue_legs.png").as_str())
);
assert_eq!(
armor_texture(&r, "diamond_layer_1"),
Some(format!("{dir}/generic.png").as_str())
);
assert_eq!(
layer_sprite(&r, "minecraft:diamond", true, false).as_deref(),
Some("horizon_cit:minecraft/optifine/cit/armor/blue_legs")
);
assert_eq!(
layer_sprite(&r, "minecraft:diamond", false, false).as_deref(),
Some("horizon_cit:minecraft/optifine/cit/armor/generic")
);
}
#[test]
fn without_a_generic_texture_other_layers_stay_vanilla() {
let r = rule(
"assets/minecraft/optifine/cit/a.properties",
"type=armor\nitems=leather_helmet\ntexture.leather_layer_1=dyed\n",
);
assert_eq!(
layer_sprite(&r, "minecraft:leather", false, false).as_deref(),
Some("horizon_cit:minecraft/optifine/cit/dyed")
);
assert_eq!(layer_sprite(&r, "minecraft:leather", false, true), None);
assert_eq!(layer_sprite(&r, "minecraft:leather", true, false), None);
}
#[test]
fn a_vanilla_texture_path_keeps_its_atlas_name() {
let r = rule(
"assets/minecraft/optifine/cit/wings.properties",
"type=elytra\ntexture=minecraft:textures/entity/wings/dragon.png\n",
);
assert_eq!(elytra_sprite(&r).as_deref(), Some("entity/wings/dragon"));
}
#[test]
fn worn_layers_pick_the_rules_texture_for_their_layer() {
let r = rule(
"assets/minecraft/optifine/cit/dye.properties",
"type=armor\nitems=leather_chestplate leather_leggings\n\
texture.leather_layer_1=body\ntexture.leather_layer_1_overlay=body_dye\n\
texture.leather_layer_2=legs\n",
);
let cit = |p: &str| Some(format!("horizon_cit:minecraft/optifine/cit/{p}"));
let pick = |kind: &str, vanilla: &str| {
worn_layer_sprite(Some(&r), "minecraft:leather", kind, vanilla)
};
let body = "entity/equipment/humanoid/leather";
assert_eq!(pick("humanoid", body), cit("body"));
assert_eq!(
pick("humanoid", "entity/equipment/humanoid/leather_overlay"),
cit("body_dye")
);
assert_eq!(
pick(
"humanoid_leggings",
"entity/equipment/humanoid_leggings/leather"
),
cit("legs")
);
// No `texture.leather_layer_2_overlay` and no `texture`: the overlay stays vanilla.
let legs_dye = "entity/equipment/humanoid_leggings/leather_overlay";
assert_eq!(pick("humanoid_leggings", legs_dye), None);
// No rule draws vanilla's.
assert_eq!(
worn_layer_sprite(None, "minecraft:leather", "humanoid", body),
None
);
}
#[test]
fn wings_take_the_rules_texture() {
let r = rule(
"assets/minecraft/optifine/cit/wings.properties",
"type=elytra\ntexture=wings\n",
);
assert_eq!(
wings_sprite(Some(&r)).as_deref(),
Some("horizon_cit:minecraft/optifine/cit/wings")
);
assert_eq!(wings_sprite(None), None);
}
#[test]
fn only_armor_and_elytra_rules_reach_the_atlas() {
let rules = [
rule(
"assets/minecraft/optifine/cit/sword.properties",
"items=diamond_sword\ntexture=sword\n",
),
rule(
"assets/minecraft/optifine/cit/plate.properties",
"type=armor\nitems=iron_chestplate iron_leggings\ntexture=plate\n\
texture.iron_layer_2=legs\n",
),
rule(
"assets/minecraft/optifine/cit/wings.properties",
"type=elytra\ntexture=wings\n",
),
rule(
"assets/minecraft/optifine/cit/glint.properties",
"type=enchantment\nenchantments=sharpness\ntexture=glint\n",
),
];
let got: Vec<String> = armor_sprites(&rules).into_iter().collect();
assert_eq!(
got,
[
"horizon_cit:minecraft/optifine/cit/legs",
"horizon_cit:minecraft/optifine/cit/plate",
"horizon_cit:minecraft/optifine/cit/wings",
]
);
}
/// An atlas lookup that knows `names`.
fn atlas(names: &[&str]) -> impl Fn(&str) -> Option<String> {
let names: Vec<String> = names.iter().map(|n| (*n).to_owned()).collect();
move |s| names.iter().any(|n| n == s).then(|| s.to_owned())
}
#[test]
fn the_atlas_lookup_falls_back_to_vanilla_and_the_cape() {
let iron = rule(
"assets/minecraft/optifine/cit/iron.properties",
"type=armor\nitems=iron_chestplate\ntexture.iron_layer_1=plate",
);
let plate = "horizon_cit:minecraft/optifine/cit/plate";
let vanilla = "entity/equipment/humanoid/iron";
let layer = |rule: Option<&Rule>, names: &[&str]| {
worn_layer(rule, "minecraft:iron", "humanoid", vanilla, atlas(names))
};
assert_eq!(
layer(Some(&iron), &[plate, vanilla]).as_deref(),
Some(plate)
);
// The rule's texture didn't reach the atlas, or there's no rule: vanilla's layer.
assert_eq!(layer(Some(&iron), &[vanilla]).as_deref(), Some(vanilla));
assert_eq!(layer(None, &[plate, vanilla]).as_deref(), Some(vanilla));
let elytra = rule(
"assets/minecraft/optifine/cit/w.properties",
"type=elytra\ntexture=wings",
);
let w = "horizon_cit:minecraft/optifine/cit/wings";
let wing = |rule: Option<&Rule>, names: &[&str]| wings(rule, Some("cape/x"), atlas(names));
assert_eq!(wing(Some(&elytra), &[w, "cape/x"]).as_deref(), Some(w));
assert_eq!(wing(Some(&elytra), &["cape/x"]).as_deref(), Some("cape/x"));
assert_eq!(wings(None, None, atlas(&[w])), None);
}
}
@@ -0,0 +1,342 @@
//! The ids Custom Item Textures' files take in Horizon's atlas and model library. A CIT rule
//! names its textures and models by resource path, mostly under `optifine/cit/` where vanilla
//! never looks: those take the reserved namespace [`NAMESPACE`], their path the resource path's
//! (`assets/minecraft/optifine/cit/swords/flame.png` is
//! `horizon_cit:minecraft/optifine/cit/swords/flame`). A file already under `textures/` (or
//! `models/`) keeps its vanilla id, so it is not packed twice.
use horizon_assets::model::{CuboidModel, TextureSlot};
use horizon_optifine::cit::{Kind, Rule};
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::Path;
/// The namespace of CIT files outside `textures/` and `models/`.
pub const NAMESPACE: &str = "horizon_cit";
/// `<namespace>:<path>`, if both keep to the identifier rules (lower-case letters, digits,
/// `_.-`, and `/` in the path). CIT Resewn refuses the other paths too.
fn identifier(namespace: &str, path: &str) -> Option<String> {
if namespace.is_empty() || path.is_empty() {
return None;
}
horizon_client_core::identifier::parse_identifier(&format!("{namespace}:{path}"))
}
/// The id of a resource path ending in `.<ext>`: `assets/<ns>/<folder>/<p>` is `<ns>:<p>`, any
/// other `assets/<rest>` is `horizon_cit:<rest>`.
fn id(resource: &str, folder: &str, ext: &str) -> Option<String> {
let rest = resource
.strip_prefix("assets/")?
.strip_suffix(ext)?
.strip_suffix('.')?;
if let Some((namespace, path)) = rest.split_once('/')
&& let Some(path) = path.strip_prefix(folder).and_then(|p| p.strip_prefix('/'))
{
// A pack's own `assets/horizon_cit/` would name another file than its id maps back to.
return (namespace != NAMESPACE)
.then(|| identifier(namespace, path))
.flatten();
}
identifier(NAMESPACE, rest)
}
/// The atlas sprite id of a `.png` resource path.
pub fn sprite_id(resource: &str) -> Option<String> {
id(resource, "textures", "png")
}
/// The model id of a `.json` resource path.
pub fn model_id(resource: &str) -> Option<String> {
id(resource, "models", "json")
}
/// The resource path of a `horizon_cit` id, with the extension `ext`; `None` for other ids,
/// and for one that would leave the resource root (a pack model may write any id).
pub fn resource(id: &str, ext: &str) -> Option<String> {
let rest = id.strip_prefix(NAMESPACE)?.strip_prefix(':')?;
horizon_optifine::paths::valid(rest).then(|| format!("assets/{rest}.{ext}"))
}
/// Sprite ids the rules whose kind passes `keep` draw: their `texture` and `texture.<name>`
/// values. A path no id can name is skipped with a warning.
pub fn rule_sprites(rules: &[Rule], keep: impl Fn(Kind) -> bool) -> BTreeSet<String> {
let mut out = BTreeSet::new();
for rule in rules.iter().filter(|r| keep(r.kind)) {
let named = rule.textures.iter().map(|(_, path)| path);
for path in rule.texture.iter().chain(named) {
match sprite_id(path) {
Some(id) => {
out.insert(id);
}
None => log::warn!(
"custom item textures: {} names {path}, which is not a valid texture id",
rule.path
),
}
}
}
out
}
/// Sprite ids the item rules draw (`Kind::Item`, see [`rule_sprites`]). Textures named by CIT
/// JSON models are [`model_sprites`].
pub fn item_sprites(rules: &[Rule]) -> BTreeSet<String> {
rule_sprites(rules, |kind| kind == Kind::Item)
}
/// The resource path a model value `./x` (or `./../x`, `../x`) names beside the file at `file`,
/// ending in `.<ext>` (added unless the value has it). `None` for other values and for paths
/// that would leave the root.
fn relative(file: &str, value: &str, ext: &str) -> Option<String> {
let mut rest = value
.strip_prefix("./")
.or_else(|| value.starts_with("../").then_some(value))?;
let mut dir = file.rsplit_once('/')?.0;
while let Some(up) = rest.strip_prefix("../") {
dir = dir.rsplit_once('/')?.0;
rest = up;
}
let rest = rest
.strip_suffix(ext)
.and_then(|r| r.strip_suffix('.'))
.unwrap_or(rest);
let path = format!("{dir}/{rest}.{ext}");
horizon_optifine::paths::valid(&path).then_some(path)
}
/// The id a relative value of the model at `file` names, as `CuboidModel::from_json` read it
/// (`./x` is `minecraft:./x` there), with the resource path of the file it names.
fn rebase(file: &str, id: &str, ext: &str) -> Option<(String, String)> {
let path = relative(file, id.strip_prefix("minecraft:")?, ext)?;
let id = if ext == "png" {
sprite_id(&path)
} else {
model_id(&path)
}?;
Some((id, path))
}
/// Parents followed from a rule's model before the rest are left to the library.
const MAX_PARENTS: usize = 16;
/// The CIT JSON models the item rules name (`model`, `model.<name>`) under their [`model_id`]s,
/// with the parents they reach by relative path. Their `./x` texture and parent values become
/// the ids of the files beside them. Only `horizon_cit` models: the others are in the model
/// library already. A file that does not read or parse is left out, and a rule's layer that
/// names it (or a model whose parents reach it) keeps its vanilla model.
pub fn load_models(root: &Path, rules: &[Rule]) -> BTreeMap<String, CuboidModel> {
let mut out = BTreeMap::new();
let mut seen = BTreeSet::new();
for rule in rules.iter().filter(|r| r.kind == Kind::Item) {
let named = rule.models.iter().map(|(_, path)| path);
for path in rule.model.iter().chain(named) {
load_model(root, path, 0, &mut seen, &mut out);
}
}
out
}
fn load_model(
root: &Path,
resource: &str,
depth: usize,
seen: &mut BTreeSet<String>,
out: &mut BTreeMap<String, CuboidModel>,
) {
let Some(id) = model_id(resource) else {
log::warn!("custom item textures: {resource} is not a valid model id");
return;
};
let ours = id
.strip_prefix(NAMESPACE)
.is_some_and(|r| r.starts_with(':'));
if !ours || depth > MAX_PARENTS || !seen.insert(id.clone()) {
return;
}
let model = fs::read_to_string(root.join(resource))
.map_err(|e| e.to_string())
.and_then(|text| CuboidModel::from_json(&text).map_err(|e| e.to_string()));
let mut model = match model {
Ok(model) => model,
Err(e) => {
log::warn!("custom item textures: skipped model {resource}: {e}");
return;
}
};
for slot in model.textures.values_mut() {
if let TextureSlot::Material(m) = slot
&& let Some((id, _)) = rebase(resource, &m.sprite, "png")
{
m.sprite = id;
}
}
let parent = model
.parent
.as_deref()
.and_then(|p| rebase(resource, p, "json"));
if let Some((id, path)) = parent {
model.parent = Some(id);
load_model(root, &path, depth + 1, seen, out);
}
out.insert(id, model);
}
/// The `horizon_cit` sprites `models` name, which the atlas packs beside [`item_sprites`].
pub fn model_sprites(models: &BTreeMap<String, CuboidModel>) -> BTreeSet<String> {
let prefix = format!("{NAMESPACE}:");
models
.values()
.flat_map(|m| m.textures.values())
.filter_map(|slot| match slot {
TextureSlot::Material(m) if m.sprite.starts_with(&prefix) => Some(m.sprite.clone()),
_ => None,
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::atlas_sources::texture_path;
use std::path::Path;
#[test]
fn cit_paths_take_the_reserved_namespace_and_come_back() {
let png = "assets/minecraft/optifine/cit/swords/flame.png";
let id = sprite_id(png).unwrap();
assert_eq!(id, "horizon_cit:minecraft/optifine/cit/swords/flame");
assert_eq!(resource(&id, "png").as_deref(), Some(png));
let json = "assets/minecraft/optifine/cit/swords/flame.json";
let id = model_id(json).unwrap();
assert_eq!(id, "horizon_cit:minecraft/optifine/cit/swords/flame");
assert_eq!(resource(&id, "json").as_deref(), Some(json));
}
#[test]
fn files_under_textures_and_models_keep_their_vanilla_ids() {
let id = sprite_id("assets/minecraft/textures/item/stick.png");
assert_eq!(id.as_deref(), Some("minecraft:item/stick"));
let id = model_id("assets/gems/models/item/ruby.json");
assert_eq!(id.as_deref(), Some("gems:item/ruby"));
// A model id is not a resource of this module's own.
assert_eq!(resource("minecraft:item/stick", "png"), None);
// `textures` as a folder name elsewhere is still a CIT path.
let id = sprite_id("assets/minecraft/optifine/cit/textures/a.png");
assert_eq!(
id.as_deref(),
Some("horizon_cit:minecraft/optifine/cit/textures/a")
);
}
#[test]
fn paths_no_identifier_can_name_are_refused() {
for path in [
"assets/minecraft/optifine/cit/Flame.png",
"assets/minecraft/optifine/cit/fire sword.png",
"assets/minecraft/textures/item/Stick.png",
"assets/minecraft/optifine/cit/a.json",
"optifine/cit/a.png",
] {
assert_eq!(sprite_id(path), None, "{path}");
}
assert_eq!(model_id("assets/minecraft/optifine/cit/A.json"), None);
assert_eq!(
sprite_id("assets//textures/item/x.png"),
None,
"no namespace"
);
}
#[test]
fn the_reserved_namespace_names_only_files_inside_the_root() {
// A pack's own `horizon_cit` namespace would map back to another file.
assert_eq!(sprite_id("assets/horizon_cit/textures/item/x.png"), None);
// A model may write any id: only ones inside the root name a file.
for id in [
"horizon_cit:../../etc/x",
"horizon_cit:a/../../x",
"horizon_cit:a\\..\\x",
] {
assert_eq!(resource(id, "png"), None, "{id}");
// Nor does the texture's file: refused, not read from elsewhere.
let file = texture_path(Path::new("/r"), id, "textures");
assert!(file.starts_with("/r/assets") && !file.to_string_lossy().contains(".."));
}
let escape = texture_path(Path::new("/r"), "minecraft:../../../x", "textures");
assert!(!escape.to_string_lossy().contains(".."), "{escape:?}");
}
#[test]
fn both_id_forms_find_their_file() {
let root = Path::new("/r");
assert_eq!(
texture_path(root, "horizon_cit:minecraft/optifine/cit/a", "textures"),
root.join("assets/minecraft/optifine/cit/a.png")
);
assert_eq!(
texture_path(root, "minecraft:item/stick", "textures"),
root.join("assets/minecraft/textures/item/stick.png")
);
}
#[test]
fn rule_models_load_with_their_relative_textures_and_parents() {
let root = std::env::temp_dir().join(format!("horizon-cit-models-{}", std::process::id()));
let cit = root.join("assets/minecraft/optifine/cit");
fs::create_dir_all(cit.join("swords")).unwrap();
let write = |rel: &str, text: &str| fs::write(cit.join(rel), text).unwrap();
write(
"swords/flame.json",
r#"{"parent":"./../base","textures":{"layer0":"./flame","layer1":"item/stick"}}"#,
);
write(
"base.json",
r#"{"parent":"item/handheld","textures":{"layer2":"./glow.png"}}"#,
);
let props =
horizon_optifine::Properties::parse_equals_first("items=stick\nmodel=./swords/flame");
let rule = Rule::parse("assets/minecraft/optifine/cit/a.properties", &props, |p| {
root.join(p).is_file()
})
.unwrap();
let models = load_models(&root, &[rule]);
let _ = fs::remove_dir_all(&root);
let cit = |p: &str| format!("horizon_cit:minecraft/optifine/cit/{p}");
assert_eq!(
models.keys().cloned().collect::<Vec<_>>(),
[cit("base"), cit("swords/flame")]
);
let flame = &models[&cit("swords/flame")];
assert_eq!(flame.parent.as_deref(), Some(cit("base").as_str()));
assert_eq!(
models[&cit("base")].parent.as_deref(),
Some("minecraft:item/handheld")
);
assert_eq!(
model_sprites(&models).into_iter().collect::<Vec<_>>(),
[cit("glow"), cit("swords/flame")]
);
}
#[test]
fn relative_values_stay_in_the_root() {
let file = "assets/minecraft/optifine/cit/a/m.json";
let at = |v: &str| relative(file, v, "png");
assert_eq!(
at("./x").as_deref(),
Some("assets/minecraft/optifine/cit/a/x.png")
);
assert_eq!(
at("./../x.png").as_deref(),
Some("assets/minecraft/optifine/cit/x.png")
);
assert_eq!(
at("../../x").as_deref(),
Some("assets/minecraft/optifine/x.png")
);
assert_eq!(at("item/x"), None);
assert_eq!(at("./../../../../../x"), None);
assert_eq!(at("./a/../x"), None);
}
}
@@ -0,0 +1,386 @@
//! Custom Item Textures' enchantment glints (`type=enchantment`): which of a stack's matching
//! rules draw, in which order and how strongly, and whether vanilla's glint still draws under
//! them. The pack's `cit.properties` (`Global`) says how layers share an item.
//!
//! Written from OptiFine's `cit.properties` doc and CIT Resewn's (MIT) `TypeEnchantment` of
//! before its 1.21.11 port, which implemented `method`, `cap` and `fade`: the sums and the
//! cycle's fading follow it, except where noted.
pub use horizon_optifine::cit::{Global, Method};
use crate::cit::read_properties;
use horizon_optifine::cit::Rule;
use std::path::Path;
/// The pack's global `cit.properties` under the overlay resource root: the first of
/// `Global::PATHS` that exists, else the defaults.
pub fn load_global(root: &Path) -> Global {
Global::PATHS
.iter()
.find_map(|path| read_properties(&root.join(path)))
.map(|props| Global::parse(&props))
.unwrap_or_default()
}
/// One glint layer a stack draws.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct GlintLayer {
/// The rule, an index into `CitRules::rules`.
pub rule: usize,
/// Its `average` or `layered` share, 1 under `none` and `cycle`.
pub weight: f32,
/// Its `duration`, for `cycle`.
duration: f32,
}
/// What a stack's glint is made of, worked out when the stack changes.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct StackGlint {
/// Bottom first (`layer` ascending).
pub layers: Vec<GlintLayer>,
/// Whether vanilla's glint draws (on a stack that has one) under the layers.
pub vanilla: bool,
/// `fade` when the layers take turns (`method=cycle`).
cycle_fade: Option<f32>,
}
impl StackGlint {
/// The layers among `matched` (the stack's matching enchantment rules, in priority order,
/// as `CitRules::enchantments` gives them) that draw, for a stack with `enchantments`
/// (`ItemStack::crafting_enchantments`).
pub fn new(
rules: &[Rule],
matched: &[usize],
enchantments: &[(impl AsRef<str>, u32)],
global: &Global,
) -> Self {
// The highest priority rule of each layer.
let mut layers: Vec<usize> = Vec::new();
for &i in matched {
let layer = rules[i].glint.layer;
if !layers.iter().any(|&j| rules[j].glint.layer == layer) {
layers.push(i);
}
}
layers.sort_by_key(|&i| rules[i].glint.layer);
let cycle = global.method == Method::Cycle;
// OptiFine's doc: `cap` is for `average` and `layered`, and keeps the topmost layers.
// `none`, which OptiFine lacks, is capped too, as CIT Resewn caps every method (it keeps
// the bottom layers, though, and caps cycles as well).
if let Some(cap) = global.cap.filter(|_| !cycle) {
let cap = usize::try_from(cap).unwrap_or(usize::MAX);
layers = layers.split_off(layers.len().saturating_sub(cap));
}
// CIT Resewn: any layer left asking for vanilla's glint keeps it, and the global
// `useGlint=false` removes it everywhere, as OptiFine's doc says too. `cap=0` leaves no
// custom layer, and vanilla's glint only if the pack's `useGlint` allows it.
let vanilla = global.use_glint
&& (layers.is_empty() || layers.iter().any(|&i| rules[i].glint.use_glint));
// CIT Resewn: shares are against every enchantment of the stack, not only those the
// layers name.
let levels = enchantments.iter().map(|(_, level)| *level as f32);
let total = match global.method {
Method::Average => levels.sum::<f32>(),
Method::Layered => levels.fold(0.0, f32::max),
Method::None | Method::Cycle => 0.0,
};
let shared = matches!(global.method, Method::Average | Method::Layered);
let layers = layers
.into_iter()
.map(|i| {
let rule = &rules[i];
let named = &rule.enchantments;
let weight = if !shared || named.is_empty() {
1.0
} else {
// The highest level among the enchantments the rule names whose level also
// passes its `enchantmentLevels` (CIT Resewn takes the first named one the
// stack has, whatever its level).
let in_range = |level: u32| {
let level = i32::try_from(level).unwrap_or(i32::MAX);
rule.enchantment_levels
.as_ref()
.is_none_or(|r| r.contains(level))
};
let level = enchantments
.iter()
.filter(|(id, level)| {
let id: &str = id.as_ref();
named.iter().any(|n| n == id) && in_range(*level)
})
.map(|(_, level)| *level as f32)
.fold(0.0, f32::max);
if total > 0.0 { level / total } else { 0.0 }
};
GlintLayer {
rule: i,
weight,
duration: rule.glint.duration,
}
})
.collect();
Self {
layers,
vanilla,
cycle_fade: cycle.then_some(global.fade),
}
}
pub fn is_empty(&self) -> bool {
self.layers.is_empty()
}
/// Layer `index`'s intensity `seconds` into the game: its weight, and under `method=cycle`
/// its turn.
pub fn intensity(&self, index: usize, seconds: f64) -> f32 {
let layer = self.layers[index];
match self.cycle_fade {
None => layer.weight,
Some(fade) => cycle_intensity(&self.layers, fade, index, seconds),
}
}
}
/// CIT Resewn's `Loops.statelessFadingLoop`: each layer shows alone for its `duration`, then
/// fades into the next over `fade` seconds, the last into the first. With no time at all
/// (every `duration` and `fade` 0) the bottom layer shows alone.
fn cycle_intensity(layers: &[GlintLayer], fade: f32, index: usize, seconds: f64) -> f32 {
let n = layers.len();
let fade = f64::from(fade);
let period: f64 = layers.iter().map(|l| f64::from(l.duration) + fade).sum();
if n == 1 || period <= 0.0 {
return if index == 0 { 1.0 } else { 0.0 };
}
let mut t = seconds.rem_euclid(period);
for (i, layer) in layers.iter().enumerate() {
let duration = f64::from(layer.duration);
if t < duration {
return if i == index { 1.0 } else { 0.0 };
}
t -= duration;
// Defensive: the last fade also takes anything rounding might leave of the period
// (none seen), ending fully on the first layer.
if t < fade || i + 1 == n {
let into = if fade > 0.0 {
(t / fade).min(1.0) as f32
} else {
1.0
};
return if i == index {
1.0 - into
} else if (i + 1) % n == index {
into
} else {
0.0
};
}
t -= fade;
}
// Not reached, as the last layer returns above; no layer rather than a panic.
0.0
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_optifine::Properties;
const DIR: &str = "assets/minecraft/optifine/cit";
/// An enchantment rule named `name` with `text` after `type` and `texture`.
fn rule(name: &str, text: &str) -> Rule {
let text = format!("type=enchantment\ntexture=glint\n{text}");
let path = format!("{DIR}/{name}.properties");
Rule::parse(&path, &Properties::parse_equals_first(&text), |_| true).unwrap()
}
fn global(text: &str) -> Global {
Global::parse(&Properties::parse_equals_first(text))
}
fn rules_of(layers: &StackGlint) -> Vec<usize> {
layers.layers.iter().map(|l| l.rule).collect()
}
const SWORD: [(&str, u32); 2] = [("minecraft:sharpness", 3), ("minecraft:unbreaking", 1)];
#[test]
fn one_rule_per_layer_bottom_first() {
let rules = [
rule("a", "layer=2"),
rule("b", "layer=0"),
rule("c", "layer=2"),
rule("d", "layer=-1"),
];
// Priority order a, b, c, d: c loses layer 2 to a.
let g = StackGlint::new(&rules, &[0, 1, 2, 3], &SWORD, &Global::default());
assert_eq!(rules_of(&g), [3, 1, 0]);
let g = StackGlint::new(&rules, &[2, 0], &SWORD, &Global::default());
assert_eq!(rules_of(&g), [2]);
}
#[test]
fn average_and_layered_shares() {
let rules = [
rule("sharp", "enchantments=sharpness smite"),
rule("unb", "layer=1\nenchantments=unbreaking"),
rule("any", "layer=2"),
rule("fire", "layer=3\nenchantments=fire_aspect"),
];
let weights = |method: &str| {
let g = StackGlint::new(&rules, &[0, 1, 2, 3], &SWORD, &global(method));
g.layers.iter().map(|l| l.weight).collect::<Vec<_>>()
};
// Sharpness III and Unbreaking I: 3 / 4 and 1 / 4; against the highest, 3 / 3 and 1 / 3.
// A rule naming no enchantment is at full strength, one the stack lacks at none.
assert_eq!(weights("method=average"), [0.75, 0.25, 1.0, 0.0]);
assert_eq!(weights("method=layered"), [1.0, 1.0 / 3.0, 1.0, 0.0]);
assert_eq!(weights("method=cycle"), [1.0; 4]);
assert_eq!(weights("method=none"), [1.0; 4]);
}
#[test]
fn the_level_is_one_the_rules_levels_allow() {
let rules = [rule(
"holy",
"enchantments=sharpness smite\nenchantmentLevels=1-2",
)];
// Sharpness V is out of range, so Smite I sets the share: 1 / 5 of the highest level.
let stack = [("minecraft:sharpness", 5), ("minecraft:smite", 1)];
let g = StackGlint::new(&rules, &[0], &stack, &global("method=layered"));
assert_eq!(g.layers[0].weight, 1.0 / 5.0);
}
#[test]
fn cap_keeps_the_topmost_layers() {
let rules = [
rule("a", "layer=0"),
rule("b", "layer=1"),
rule("c", "layer=2"),
];
let g = StackGlint::new(&rules, &[0, 1, 2], &SWORD, &global("cap=2"));
assert_eq!(rules_of(&g), [1, 2]);
let g = StackGlint::new(&rules, &[0, 1, 2], &SWORD, &global("cap=0"));
assert!(g.is_empty());
let g = StackGlint::new(&rules, &[0, 1, 2], &SWORD, &global("cap=1\nmethod=none"));
assert_eq!(rules_of(&g), [2]);
// Cycles show every layer in turn.
let g = StackGlint::new(&rules, &[0, 1, 2], &SWORD, &global("cap=1\nmethod=cycle"));
assert_eq!(rules_of(&g), [0, 1, 2]);
}
#[test]
fn vanilla_glint() {
let plain = rule("plain", "");
let keep = rule("keep", "layer=1\nuseGlint=true");
let rules = [plain, keep];
let vanilla = |matched: &[usize], text: &str| {
StackGlint::new(&rules, matched, &SWORD, &global(text)).vanilla
};
assert!(vanilla(&[], ""), "no rule: vanilla's glint");
assert!(!vanilla(&[0], ""), "a rule replaces it");
assert!(vanilla(&[0, 1], ""), "unless one keeps it");
assert!(!vanilla(&[], "useGlint=false"));
assert!(!vanilla(&[1], "useGlint=false"), "the pack's word is final");
// Only the layers `cap` keeps count.
let rules = [rule("keep", "useGlint=true"), rule("top", "layer=1")];
let g = StackGlint::new(&rules, &[0, 1], &SWORD, &global("cap=1"));
assert!(!g.vanilla);
let g = StackGlint::new(&rules, &[0, 1], &SWORD, &global("cap=0"));
assert!(g.is_empty() && g.vanilla);
let g = StackGlint::new(&rules, &[0, 1], &SWORD, &global("cap=0\nuseGlint=false"));
assert!(!g.vanilla);
}
#[test]
fn cycles_take_turns_and_fade() {
let rules = [
rule("a", "duration=2"),
rule("b", "layer=1\nduration=1"),
rule("c", "layer=2\nduration=1"),
];
let g = StackGlint::new(
&rules,
&[0, 1, 2],
&SWORD,
&global("method=cycle\nfade=0.5"),
);
// a 0-2, a→b 2-2.5, b 2.5-3.5, b→c 3.5-4, c 4-5, c→a 5-5.5, then again.
let at = |t: f64| [0, 1, 2].map(|i| g.intensity(i, t));
assert_eq!(at(1.0), [1.0, 0.0, 0.0]);
assert_eq!(at(2.125), [0.75, 0.25, 0.0]);
assert_eq!(at(3.0), [0.0, 1.0, 0.0]);
assert_eq!(at(3.75), [0.0, 0.5, 0.5]);
assert_eq!(at(4.5), [0.0, 0.0, 1.0]);
assert_eq!(at(5.25), [0.5, 0.0, 0.5]);
assert_eq!(at(5.5 + 3.0), [0.0, 1.0, 0.0]);
// One layer always shows.
let one = StackGlint::new(&rules, &[1], &SWORD, &global("method=cycle"));
assert_eq!(one.intensity(0, 0.7), 1.0);
}
#[test]
fn a_cycle_always_shows_a_whole_layer() {
let rules = [
rule("a", "duration=0.1"),
rule("b", "layer=1\nduration=0.7"),
rule("c", "layer=2\nduration=0.2"),
];
let g = StackGlint::new(
&rules,
&[0, 1, 2],
&SWORD,
&global("method=cycle\nfade=0.3"),
);
// Every millisecond of the first two seconds, and just under a period, the last fade's
// end. (Rounding has not been seen to leave time past it; the last fade's clamp is
// defensive.)
let period: f64 = [0.1f32, 0.7, 0.2]
.map(|d| f64::from(d) + f64::from(0.3f32))
.iter()
.sum();
let last = f64::from_bits(period.to_bits() - 1);
for t in (0..2000_i32)
.map(|i| f64::from(i) * 0.001)
.chain([last, period + last])
{
let sum: f32 = (0..3).map(|i| g.intensity(i, t)).sum();
assert!((sum - 1.0).abs() < 1e-5, "{sum} at {t}");
}
// No time at all: the bottom layer alone, never NaN.
let still = [rule("a", "duration=0"), rule("b", "layer=1\nduration=0")];
let g = StackGlint::new(&still, &[0, 1], &SWORD, &global("method=cycle\nfade=0"));
for t in [0.0, 0.5, 7.25] {
assert_eq!([0, 1].map(|i| g.intensity(i, t)), [1.0, 0.0]);
}
}
#[test]
fn the_first_global_file_wins() {
let root = std::env::temp_dir().join(format!("horizon-cit-glint-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
assert_eq!(load_global(&root), Global::default());
let write = |path: &str, text: &[u8]| {
let file = root.join(path);
std::fs::create_dir_all(file.parent().unwrap()).unwrap();
std::fs::write(file, text).unwrap();
};
// Saved as Latin-1 (`é` is not UTF-8 alone), in the OptiFine doc's `cit/` folder.
write(
"assets/minecraft/optifine/cit/cit.properties",
b"# caf\xE9\nmethod=cycle",
);
assert_eq!(load_global(&root).method, Method::Cycle);
write("assets/minecraft/mcpatcher/cit.properties", b"method=none");
assert_eq!(load_global(&root).method, Method::Cycle);
write(
"assets/minecraft/optifine/cit.properties",
b"method=layered",
);
assert_eq!(load_global(&root).method, Method::Layered);
write("assets/minecraft/citresewn/cit.properties", b"cap=1");
let g = load_global(&root);
let _ = std::fs::remove_dir_all(&root);
assert_eq!((g.method, g.cap), (Method::Average, Some(1)));
}
}
@@ -199,8 +199,9 @@ where
}
impl SceneCollisions<'_, '_> {
/// Position contexts permit geometric movement probes without inventing a
/// player, inventory, effects or fluid-standing behavior; a lava walker
/// collides as the living entity it is, empty-handed.
/// player, inventory, effects or fluid-standing behavior; a lava walker or
/// a living entity in leather boots collides as the living entity it is,
/// empty-handed.
pub fn resolve(
&mut self,
bounds: Aabb,
@@ -236,7 +237,10 @@ impl SceneCollisions<'_, '_> {
}
fn context(&self, bounds: Aabb) -> CollisionContext<'static> {
let y = bounds.min_position().y;
if !self.subject.is_some_and(|s| s.lava_walker) {
if !self
.subject
.is_some_and(|s| s.lava_walker || s.leather_boots)
{
return CollisionContext::position(y);
}
CollisionContext::for_entity(
File diff suppressed because it is too large. Load diff
@@ -3,8 +3,9 @@
//! from the data-driven `context_int_provider`/`context_float_provider` files.
pub use crate::chunk::block_entity::{
Banner, Beacon, BlockEntity, Brushable, Campfire, ChiseledBookshelf, Crafter, CreakingHeart,
EndGateway, Furnace, FurnaceKind, Jukebox, Lectern, MOD_MENUS, ModBlockEntity, ModContainer,
ModFluidTanks, PotentSulfur, Shelf, Skull, SkullSkin,
EndGateway, Furnace, FurnaceKind, Jukebox, Lectern, MOD_MENUS, ModBlockEntity,
ModBlockEntityFields, ModContainer, ModEnergyBuffer, ModFluidTanks, ModRevision, PotentSulfur,
Shelf, Skull, SkullSkin,
};
use crate::items::ItemStack;
use std::collections::HashMap;
+27 -42
View File
@@ -195,14 +195,6 @@ fn read(path: &str) -> Option<String> {
std::fs::read_to_string(path).ok()
}
fn windows_processor_name(query: &str) -> Option<String> {
query.lines().find_map(|line| {
let (_, value) = line.split_once("REG_SZ")?;
let value = value.trim();
(!value.is_empty()).then(|| value.to_owned())
})
}
/// `PRETTY_NAME` of /etc/os-release and the kernel, or the system's own version string.
fn operating_system() -> String {
let arch = std::env::consts::ARCH;
@@ -220,7 +212,7 @@ fn operating_system() -> String {
} else if cfg!(target_os = "macos") {
command_output("sw_vers", &["-productVersion"]).map(|v| format!("macOS {v}"))
} else if cfg!(windows) {
command_output("cmd", &["/C", "ver"])
horizon_telemetry::system::windows_version()
} else {
None
};
@@ -235,26 +227,11 @@ fn operating_system() -> String {
pub fn cpu() -> String {
let threads = std::thread::available_parallelism().map_or(0, |n| n.get());
let name = if cfg!(target_os = "linux") {
read("/proc/cpuinfo").and_then(|t| {
t.lines()
.find(|l| l.starts_with("model name"))
.and_then(|l| l.split_once(':'))
.map(|(_, v)| v.trim().to_owned())
})
read("/proc/cpuinfo").and_then(|t| horizon_telemetry::system::linux_cpu_name(&t))
} else if cfg!(target_os = "macos") {
command_output("sysctl", &["-n", "machdep.cpu.brand_string"])
} else if cfg!(windows) {
command_output(
"reg",
&[
"query",
r"HKLM\HARDWARE\DESCRIPTION\System\CentralProcessor\0",
"/v",
"ProcessorNameString",
],
)
.and_then(|query| windows_processor_name(&query))
.or_else(|| std::env::var("PROCESSOR_IDENTIFIER").ok())
horizon_telemetry::system::windows_cpu()
} else {
std::env::var("PROCESSOR_IDENTIFIER").ok()
};
@@ -407,6 +384,30 @@ pub fn report_watchdog(home: &Path, side: &'static str, message: String) -> Opti
)
}
/// The report for a game that went over the player's RAM limit (`ram_limit`), written by the
/// watcher thread: it says nothing about where the memory went, so it carries no stack. The player
/// chose this limit, so it is no bug to send (`telemetry::keep_crash`), and leaving that out ends
/// the game seconds sooner: it keeps running, over its limit, until the report is written.
pub fn report_memory_limit(home: &Path, side: &'static str, message: String) {
if REPORTED.swap(true, Ordering::SeqCst) {
return;
}
let report = Report {
time: Local::now(),
description: "RAM limit reached".into(),
error: message,
stack: String::new(),
thread: thread_name(side),
side,
details: system_details(side),
log: crate::logging::recent_lines(),
};
match report.save(home) {
Ok(path) => eprintln!("{SAVED_LINE}{}", path.display()),
Err(e) => eprintln!("Horizon: could not save a crash report: {e}"),
}
}
fn payload_text(payload: &(dyn std::any::Any + Send)) -> String {
payload
.downcast_ref::<&str>()
@@ -657,22 +658,6 @@ pub fn debug_crash_after() -> Option<std::time::Duration> {
mod tests {
use super::*;
#[test]
fn windows_registry_cpu_name_is_read_without_the_registry_columns() {
let query = r#"
HKEY_LOCAL_MACHINE\HARDWARE\DESCRIPTION\System\CentralProcessor\0
ProcessorNameString REG_SZ 13th Gen Intel(R) Core(TM) i7-13700F
"#;
assert_eq!(
windows_processor_name(query).as_deref(),
Some("13th Gen Intel(R) Core(TM) i7-13700F")
);
assert_eq!(
windows_processor_name("ProcessorNameString REG_SZ "),
None
);
}
#[test]
fn a_release_reports_its_built_in_version() {
// release.yml builds with HORIZON_VERSION (docs/versioning.md): `v0.2.3`, wherever it is installed.
+13 -3
View File
@@ -575,7 +575,7 @@ pub struct DataStore {
/// `item_modifier` files, for `/item modify`.
pub item_modifiers: BTreeMap<String, serde_json::Value>,
/// The content tables' raw files from the packs after the built-in one:
/// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `dialog/` and `tags/…/` files, by
/// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `dialog/`, `data_maps/` and `tags/…/` files, by
/// `<kind-dir>/<ns>:<path>`, each id's files in pack order. The tables
/// merge them over what they read from the resource root.
pub files: BTreeMap<String, Vec<Vec<u8>>>,
@@ -584,7 +584,7 @@ pub struct DataStore {
}
/// `<kind-dir>/<ns>:<path>` for a pack file the content tables read:
/// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `trade_set/`,
/// `villager_trade/`, `dialog/` or `tags/<sub>/`, where the id keeps the sub
/// `villager_trade/`, `dialog/`, `data_maps/` or `tags/<sub>/`, where the id keeps the sub
/// path so a nested tag kind stays addressable.
fn content_id(file: &str) -> Option<String> {
let rest = file.strip_prefix("data/")?;
@@ -592,7 +592,7 @@ fn content_id(file: &str) -> Option<String> {
let (kind, rest) = rest.split_once('/')?;
let kind = match kind {
"recipe" | "recipe_filter" | "loot_table" | "advancement" | "trade_set"
| "villager_trade" | "dialog" => kind,
| "villager_trade" | "dialog" | "data_maps" => kind,
// A tag id is `{ns}:{path-under-tags}` (`tags/worldgen/biome/
// test:sub/mytag`), the whole path after the namespace, so nested
// registries keep their directory (`TagLoader`'s id shape).
@@ -623,6 +623,7 @@ impl DataStore {
let builtin = pack.id == "vanilla";
for f in pack.list_kinds(&[
"advancement",
"data_maps",
"dialog",
"function",
"item_modifier",
@@ -882,6 +883,15 @@ pub fn worldgen_overrides(
mod tests {
use super::*;
#[test]
fn data_map_files_are_content_files_keyed_by_map_namespace_and_registry() {
assert_eq!(
content_id("data/mymod/data_maps/item/fuel.json").as_deref(),
Some("data_maps/mymod:item/fuel")
);
assert_eq!(content_id("data/mymod/data_maps/item/fuel.txt"), None);
}
#[test]
fn enabled_packs_override_worldgen_in_order() {
let dir = std::env::temp_dir().join(format!("hz-datapack-worldgen-{}", std::process::id()));
@@ -13,7 +13,8 @@ use std::collections::HashMap;
/// Lights the shaders read: `Globals.dynamic_lights` in gfx.rs, terrain.wgsl and entity.wgsl.
pub const MAX: usize = 16;
/// Camera-relative position and radius; RGB intensity in a second vec4.
/// Camera-relative position and radius; RGB intensity in a second vec4. The shaders read it as a flat
/// `array<vec4<f32>, 32>` (nested arrays need ARB_arrays_of_arrays, absent from GL below 4.3).
pub type Colored = [[f32; 4]; 2];
/// Nearest validated native visual lights; never enters gameplay lighting.
@@ -75,6 +76,12 @@ impl Luminance {
self.0.get(item).copied().unwrap_or(0)
}
/// The HDR tier of an item that glows, as its block's (`mesher::hdr_tier`), 0 for the rest.
pub fn hdr_tier(&self, item: &str) -> u8 {
let name = item.strip_prefix("minecraft:").unwrap_or(item);
crate::mesher::hdr_tier(name, self.of(item)) & !crate::mesher::WHOLE
}
#[cfg(test)]
fn from_pairs(pairs: &[(&str, u8)]) -> Self {
Self(pairs.iter().map(|(k, v)| (k.to_string(), *v)).collect())
@@ -118,6 +125,23 @@ pub fn level_at(pos: [f32; 3], lights: &[[f32; 4]]) -> f32 {
mod tests {
use super::*;
#[test]
fn items_shine_as_their_blocks() {
use crate::mesher::{BRIGHT, INCANDESCENT};
let lum = Luminance::from_pairs(&[
("minecraft:torch", 14),
("minecraft:glowstone", 15),
("minecraft:lava_bucket", 15),
("minecraft:campfire", 15),
]);
assert_eq!(lum.hdr_tier("minecraft:torch"), INCANDESCENT);
assert_eq!(lum.hdr_tier("minecraft:glowstone"), BRIGHT);
assert_eq!(lum.hdr_tier("minecraft:lava_bucket"), INCANDESCENT);
// Items carry no sprite thresholds: only the tier, not "all over".
assert_eq!(lum.hdr_tier("minecraft:campfire"), BRIGHT);
assert_eq!(lum.hdr_tier("minecraft:apple"), 0);
}
#[test]
fn colored_sources_are_camera_relative_and_bounded() {
let lights = select_colored(
+74 -18
View File
@@ -22,12 +22,18 @@ struct Globals {
outline_params: vec4<f32>,
// Horizon Extras' dynamic lights: camera-relative position and level (dynamic_light.rs).
dynamic_lights: array<vec4<f32>, 16>,
colored_lights: array<array<vec4<f32>, 2>, 16>,
colored_lights: array<vec4<f32>, 32>,
colored_count: vec4<u32>,
emissive_layers: array<vec4<u32>, 32>,
emissive_layers: array<vec4<u32>, 128>,
emissive_count: vec4<u32>,
sunrise_fan: array<vec4<f32>, 32>,
filtering: vec4<f32>,
// HDR output's tier windows (hdr.rs `Hdr::windows`): x..w low to very bright,
// [0] where the lift starts, [1] where it is full.
hdr_windows: array<vec4<f32>, 2>,
// HDR lighting: x how strongly the sky lights the world now (`Sky::sky_light_factor`),
// y what the sky and fog get: the same, but none under water.
hdr_light: vec4<f32>,
}
@group(0) @binding(0) var<uniform> globals: Globals;
@group(0) @binding(1) var atlas: texture_2d<f32>;
@@ -58,13 +64,16 @@ struct Out {
@location(9) light_color: vec4<f32>,
@location(10) @interpolate(flat) weather_rect: vec4<f32>,
// The `GLINT_TEXTURING` sample for overlay 7 (item foil), or the model UV
// `ARMOR_ENTITY_GLINT_TEXTURING` scrolls for overlay 36 (armour).
// `ARMOR_ENTITY_GLINT_TEXTURING` scrolls for overlay 36 (armour), or the
// dissolve mask's atlas UV for overlay 38.
@location(11) glint_uv: vec2<f32>,
// 1 drops the quad's back-facing pixels (Vertex::normal w = 1), like a culling render type.
@location(12) @interpolate(flat) cull: u32,
// HDR tier of a light-giving particle (mesher::hdr_tier), from the overlay's
// high bits (particles.rs): fs_hdr_mark marks the frame with it.
@location(13) @interpolate(flat) tier: u32,
// HDR lighting (`hdr_scene`), 0 for what isn't lit.
@location(14) scene: f32,
}
// Shipped light.glsl; normals are already transformed and RGBA8_SNORM packed.
fn entity_light(normal: vec3<f32>, nether: bool) -> f32 {
@@ -97,12 +106,22 @@ fn dynamic_block_light(pos: vec3<f32>) -> f32 {
fn native_colored_light(pos: vec3<f32>) -> vec3<f32> {
var rgb = vec3(0.0);
for (var i = 0u; i < min(globals.colored_count.x, 16u); i++) {
let at = globals.colored_lights[i][0];
let color = globals.colored_lights[i][1];
let at = globals.colored_lights[i * 2u];
let color = globals.colored_lights[i * 2u + 1u];
rgb += color.rgb * max(0.0, 1.0 - distance(pos, at.xyz) / at.w) * color.a;
}
return min(rgb, vec3(1.0));
}
// HDR lighting: how far an entity's light carries it above paper white, 0 to 1, as terrain's.
fn hdr_scene(block: f32, sky: f32) -> f32 {
let b = smoothstep(11.0, 15.0, block);
let s = smoothstep(11.0, 15.0, sky) * globals.hdr_light.x;
return max(b, s);
}
// The frame's alpha for it, between 1 (none) and the light sources' marks below 0.75.
fn hdr_scene_alpha(scene: f32) -> f32 {
return 1.0 - 0.24 * scene;
}
fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
overlay: u32, color: vec4<f32>, light: vec4<u32>, normal: vec4<f32>) -> Out {
var out: Out;
@@ -116,6 +135,7 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
let unlit = overlay == 2u || overlay == 4u || overlay == 6u || overlay == 9u || overlay == 10u
|| overlay == 11u || overlay == 12u;
out.light_color = select(lm, vec4(1.0), unlit);
out.scene = select(hdr_scene(l.x / 16.0, l.y / 16.0), 0.0, unlit);
if globals.colored_count.x > 0u && !unlit {
out.light_color = vec4(min(out.light_color.rgb + native_colored_light(position), vec3(1.0)), out.light_color.a);
}
@@ -252,7 +272,15 @@ fn entity_color(in: Out, front: bool) -> vec4<f32> {
// Each crack's stage sprite origin comes in its light bytes (breaking::stage_light).
uv = crumbling_uv(in.uv, in.wrap, globals.crumbling_sprite.zw, textureDimensions(atlas));
}
var color = textureSample(atlas, atlas_sampler, uv) * select(in.back_color, in.color, front);
let texel = textureSample(atlas, atlas_sampler, uv);
var tint = select(in.back_color, in.color, front);
if in.overlay == 38u {
// RenderPipelines.ENTITY_CUTOUT_DISSOLVE (entity.fsh's DISSOLVE): gone where the vertex
// alpha is below the mask's (its atlas UV in glint_uv), the rest drawn opaque.
if tint.a < textureSampleLevel(atlas, atlas_sampler, in.glint_uv, 0.0).a { discard; }
tint.a = 1.0;
}
var color = texel * tint;
if in.overlay == 9u || in.overlay == 10u {
// rendertype_end_portal: end_sky and PORTAL_LAYERS (15 for portals, 16
// for gateways) layers of end_portal.png projected from the screen.
@@ -315,8 +343,34 @@ fn shadow_color(in: Out) -> vec4<f32> {
// Beams wrap their v in the sprite as fs_entity does: sample where they draw.
var uv = in.uv;
if in.overlay == 6u { uv.y = in.wrap.x + fract(in.uv.y) * in.wrap.y; }
if in.tier == 0u || textureSample(atlas, atlas_sampler, uv).a < 0.1 { discard; }
return vec4(0.0, 0.0, 0.0, 0.7 * f32(4u - min(in.tier, 4u)) / 4.0);
let texel = textureSample(atlas, atlas_sampler, uv);
if texel.a < 0.1 { discard; }
// Fog hides a light source and the light on a surface as it hides everything else.
let fogged = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w)) * globals.fog_color.a;
if in.tier == 0u {
return vec4(0.0, 0.0, 0.0, hdr_scene_alpha(mix(in.scene, globals.hdr_light.y, fogged)));
}
return vec4(0.0, 0.0, 0.0, hdr_mark(in.tier, brightness(texel.rgb * in.color.rgb) * (1.0 - fogged)));
}
fn brightness(c: vec3<f32>) -> f32 {
let l = pow(max(c, vec3(0.0)), vec3(2.2));
return 0.5 * (dot(l, vec3(0.2126, 0.7152, 0.0722)) + max(l.r, max(l.g, l.b)));
}
// HDR output's mark (hdr.wgsl): the frame's alpha below 0.75, a band of 0.175 per
// tier (1 low .. 4 very bright, from 0.525 down to 0) and in it how much the pixel
// lifts, from its own brightness `y` before lighting through the tier's window.
fn hdr_mark(tier: u32, y: f32) -> f32 {
let t = clamp(tier, 1u, 4u) - 1u;
let lift = smoothstep(globals.hdr_windows[0][t], globals.hdr_windows[1][t], y);
return 0.175 * f32(3u - t) + 0.165 * (1.0 - lift);
}
// The hand in HDR output (gfx.rs "HDR hand marks"): where it drew, what the world kept there
// no longer shines; fs_hdr_mark then marks the light sources it holds.
@fragment fn fs_hdr_hand_unmark(in: Out) -> @location(0) vec4<f32> {
if textureSample(atlas, atlas_sampler, in.uv).a < 0.1 { discard; }
return vec4(0.0, 0.0, 0.0, hdr_scene_alpha(in.scene));
}
fn celestial_color(in: Out) -> vec4<f32> {
@@ -334,19 +388,21 @@ fn celestial_color(in: Out) -> vec4<f32> {
}
// The sky's celestial pass (sun, moon, the End sky): fs_celestial's light,
// premultiplied for an additive (one, one) blend, and as alpha how much of it
// the texel adds, which the blend takes off the frame's alpha. HDR output
// (hdr.wgsl) lifts only where that left it low: the sun's and moon's discs, not
// the dark rest of their quads or the dim End sky.
// premultiplied for an additive (one, one) blend. Where a texel adds light, its
// alpha leaves the sky's 1 at HDR output's mark (hdr_mark) for the brightest
// tier, so the sun's and moon's discs shine; the dark rest of their quads and
// the dim End sky stay unmarked.
@fragment fn fs_sky_celestial(in: Out) -> @location(0) vec4<f32> {
let color = celestial_color(in);
let added = color.rgb * color.a;
// The sky under the disc keeps alpha 1 less this: 0 for the brightest tier
// (the sun), terrain.wgsl's mark for the lower ones (the moon). Tier 0 is
// geometry that sets none (the End sky, the End flash): it stays brightest.
let tier = select(4u, min(in.tier, 4u), in.tier > 0u);
let mark = 1.0 - 0.7 * f32(4u - tier) / 4.0;
return vec4(added, mark * smoothstep(0.1, 0.3, max(added.r, max(added.g, added.b))));
// The sun at the brightest tier, the moon at its own (in.tier); geometry that sets
// none (the End sky, the End flash) stays brightest.
let tier = select(in.tier, 4u, in.tier == 0u);
let y = brightness(added);
// Only what's past the tier's window takes the mark: a soft edge keeps the sky's light,
// or it would come out darker than the sky around it.
if y < globals.hdr_windows[1][clamp(tier, 1u, 4u) - 1u] { return vec4(added, 0.0); }
return vec4(added, 1.0 - hdr_mark(tier, y));
}
// RenderPipelines.ENERGY_SWIRL (entity.fsh with EMISSIVE and NO_OVERLAY): the sprite, scrolled by
+96 -12
View File
@@ -111,6 +111,10 @@ pub struct Extras {
pub sort: bool,
/// A vanilla key name: sorts the part under the cursor.
pub sort_key: String,
/// A new chat line slides in from the left while it fades in (`Chat::layout`).
pub chat_animation: bool,
/// The indicator bar at the left of a chat line (on by default, as vanilla).
pub chat_indicator: bool,
/// No AI: every AI feature hidden and off (`horizon_core::no_ai`; the launcher writes it too).
pub no_ai: bool,
/// Agents may control the game the player starts: it opens its control socket for
@@ -152,6 +156,8 @@ impl Default for Extras {
mouse_tweaks_wheel: true,
sort: false,
sort_key: "key.keyboard.unknown".into(),
chat_animation: false,
chat_indicator: true,
no_ai: false,
agent_control: false,
discord_presence: true,
@@ -230,6 +236,8 @@ impl Extras {
"mouseTweaksWheel" => e.mouse_tweaks_wheel = flag(e.mouse_tweaks_wheel),
"sort" => e.sort = flag(e.sort),
"sortKey" if value.starts_with("key.") => e.sort_key = value.to_owned(),
"chatAnimation" => e.chat_animation = flag(e.chat_animation),
"chatIndicator" => e.chat_indicator = flag(e.chat_indicator),
horizon_core::no_ai::KEY => e.no_ai = flag(e.no_ai),
"agentControl" => e.agent_control = flag(e.agent_control),
"discordPresence" => e.discord_presence = flag(e.discord_presence),
@@ -241,7 +249,7 @@ impl Extras {
}
pub fn serialize(&self) -> String {
format!(
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\nsplitscreen:{}\n",
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\nsplitscreen:{}\n",
bool_text(self.fullbright),
bool_text(self.dynamic_lights),
bool_text(self.zoom),
@@ -274,6 +282,8 @@ impl Extras {
bool_text(self.mouse_tweaks_wheel),
bool_text(self.sort),
self.sort_key,
bool_text(self.chat_animation),
bool_text(self.chat_indicator),
horizon_core::no_ai::KEY,
bool_text(self.no_ai),
bool_text(self.agent_control),
@@ -406,6 +416,10 @@ pub enum Item {
MouseTweaksWheel,
Sort,
SortKey,
/// A new chat line slides in and fades in (`Chat::layout`).
ChatAnimation,
/// The coloured bar at the left of a chat line.
ChatIndicator,
/// Not on the Extras page: Horizon Settings shows No AI, its AI Agent page Agent Control.
NoAi,
AgentControl,
@@ -414,29 +428,84 @@ pub enum Item {
/// On the Splitscreen settings page.
Splitscreen,
}
/// The Extras page: headers and items.
/// The Extras page: headers (language keys) and items.
pub fn rows() -> Vec<Result<Item, &'static str>> {
use Item::*;
vec![
Err("Lighting"),
Err("horizon.extras.header.lighting"),
Ok(Fullbright),
Ok(DynamicLights),
Err("Third person"),
Err("horizon.extras.header.third_person"),
Ok(OwnNameTag),
Err("Particles"),
Err("horizon.extras.header.particles"),
Ok(HideBlockParticles),
Err("Inventory"),
Err("horizon.extras.header.inventory"),
Ok(MouseTweaks),
Ok(MouseTweaksWheel),
Ok(Sort),
Ok(SortKey),
Err("Multiplayer"),
Err("horizon.extras.header.chat"),
Ok(ChatAnimation),
Ok(ChatIndicator),
Err("horizon.extras.header.multiplayer"),
Ok(AutoReconnect),
Err("Sound"),
Err("horizon.extras.header.sound"),
Ok(UiSoundReserve),
]
}
impl Item {
/// The language key of the caption (`horizon.*`, assets/horizon/lang).
pub fn caption_key(self) -> &'static str {
match self {
Item::Fullbright => "horizon.extras.fullbright",
Item::DynamicLights => "horizon.extras.dynamic_lights",
Item::OwnNameTag => "horizon.extras.own_name_tag",
Item::HideBlockParticles => "horizon.extras.hide_block_particles",
Item::AutoReconnect => "horizon.extras.auto_reconnect",
Item::UiSoundReserve => "horizon.extras.ui_sound_reserve",
Item::MouseTweaks => "horizon.extras.mouse_tweaks",
Item::MouseTweaksWheel => "horizon.extras.mouse_tweaks_wheel",
Item::Sort => "horizon.extras.sort",
Item::SortKey => "horizon.extras.sort_key",
Item::NoAi => "horizon.extras.no_ai",
Item::AgentControl => "horizon.extras.agent_control",
Item::DiscordPresence => "horizon.extras.discord_presence",
Item::Splitscreen => "horizon.extras.splitscreen",
Item::ChatAnimation => "horizon.extras.chat_animation",
Item::ChatIndicator => "horizon.extras.chat_indicator",
}
}
/// The language key of the description shown as the row's tooltip.
pub fn description_key(self) -> &'static str {
match self {
Item::Fullbright => "horizon.extras.fullbright.tooltip",
Item::DynamicLights => "horizon.extras.dynamic_lights.tooltip",
Item::OwnNameTag => "horizon.extras.own_name_tag.tooltip",
Item::HideBlockParticles => "horizon.extras.hide_block_particles.tooltip",
Item::AutoReconnect => "horizon.extras.auto_reconnect.tooltip",
Item::UiSoundReserve => "horizon.extras.ui_sound_reserve.tooltip",
Item::MouseTweaks => "horizon.extras.mouse_tweaks.tooltip",
Item::MouseTweaksWheel => "horizon.extras.mouse_tweaks_wheel.tooltip",
Item::Sort => "horizon.extras.sort.tooltip",
Item::SortKey => "horizon.extras.sort_key.tooltip",
Item::NoAi => "horizon.extras.no_ai.tooltip",
Item::AgentControl => "horizon.extras.agent_control.tooltip",
Item::DiscordPresence => "horizon.extras.discord_presence.tooltip",
Item::Splitscreen => "horizon.extras.splitscreen.tooltip",
Item::ChatAnimation => "horizon.extras.chat_animation.tooltip",
Item::ChatIndicator => "horizon.extras.chat_indicator.tooltip",
}
}
/// The caption in the current language (English when the key is missing).
pub fn caption_text(self) -> String {
crate::lang::get_or(self.caption_key(), self.caption())
}
/// The description in the current language (English when the key is missing).
pub fn description_text(self) -> String {
crate::lang::get_or(self.description_key(), self.description())
}
/// The English caption: the fallback of [`Item::caption_text`], and the text of
/// `horizon.extras.*` in en_us.json.
pub fn caption(self) -> &'static str {
match self {
Item::Fullbright => "Fullbright",
@@ -449,6 +518,8 @@ impl Item {
Item::MouseTweaksWheel => "Scroll wheel moves items",
Item::Sort => "Sort buttons",
Item::SortKey => "Sort key",
Item::ChatAnimation => "Smooth chat",
Item::ChatIndicator => "Message indicator",
Item::NoAi => "No AI",
Item::AgentControl => "Agent Control",
Item::DiscordPresence => "Discord Rich Presence",
@@ -462,13 +533,15 @@ impl Item {
UiSoundReserve => return None,
MouseTweaksWheel => MouseTweaks,
SortKey => Sort,
ChatIndicator => ChatAnimation,
Fullbright | DynamicLights | OwnNameTag | HideBlockParticles | MouseTweaks | Sort
| AutoReconnect | NoAi | AgentControl | DiscordPresence | Splitscreen => {
| AutoReconnect | NoAi | AgentControl | DiscordPresence | Splitscreen
| ChatAnimation => {
return None;
}
})
}
/// What the row says under its caption.
/// The English description: the fallback of [`Item::description_text`].
pub fn description(self) -> &'static str {
match self {
Item::Fullbright => "Show blocks and entities at full light, even in dark areas.",
@@ -499,6 +572,10 @@ impl Item {
Item::SortKey => {
"Sorts the part under the cursor. Click, then press a key; Escape unbinds it."
}
Item::ChatAnimation => {
"A new chat line slides in from the left while it fades in, instead of appearing whole."
}
Item::ChatIndicator => "The coloured bar at the left of a chat line.",
Item::NoAi => {
"Hides and turns off every AI feature: agent control, horizon-ctl and the MCP servers. The launcher's switch too."
}
@@ -520,6 +597,7 @@ impl Item {
Item::Fullbright => Some(Cost::None),
Item::UiSoundReserve => Some(Cost::None),
Item::DynamicLights => Some(Cost::Low),
Item::ChatAnimation => Some(Cost::None),
Item::OwnNameTag
| Item::HideBlockParticles
| Item::AutoReconnect
@@ -541,6 +619,8 @@ impl Item {
Item::MouseTweaks => e.mouse_tweaks,
Item::MouseTweaksWheel => e.mouse_tweaks_wheel,
Item::Sort => e.sort,
Item::ChatAnimation => e.chat_animation,
Item::ChatIndicator => e.chat_indicator,
Item::NoAi => e.no_ai,
Item::AgentControl => e.agent_control,
Item::DiscordPresence => e.discord_presence,
@@ -553,8 +633,8 @@ impl Item {
match self {
Item::SortKey => crate::keybinds::display_name(&e.sort_key).plain(),
other => match other.switch(e) {
Some(true) => "On".into(),
_ => "Off".into(),
Some(true) => crate::lang::get_or("On", "On"),
_ => crate::lang::get_or("Off", "Off"),
},
}
}
@@ -571,6 +651,8 @@ impl Item {
Item::MouseTweaks => e.mouse_tweaks = !e.mouse_tweaks,
Item::MouseTweaksWheel => e.mouse_tweaks_wheel = !e.mouse_tweaks_wheel,
Item::Sort => e.sort = !e.sort,
Item::ChatAnimation => e.chat_animation = !e.chat_animation,
Item::ChatIndicator => e.chat_indicator = !e.chat_indicator,
Item::NoAi => e.no_ai = !e.no_ai,
Item::AgentControl => e.agent_control = !e.agent_control,
Item::DiscordPresence => e.discord_presence = !e.discord_presence,
@@ -633,6 +715,8 @@ mod tests {
e.mouse_tweaks_wheel = false;
e.sort = true;
e.sort_key = "key.keyboard.r".into();
e.chat_animation = true;
e.chat_indicator = false;
e.discord_presence = false;
assert!(!e.splitscreen);
Item::Splitscreen.cycle(&mut e, false);
+42 -18
View File
@@ -5,6 +5,7 @@
use crate::{
biome::Biomes,
blocks::{BlockFacts, Fluid},
camera::NearPlane,
environment::Sky,
world::World,
};
@@ -135,9 +136,24 @@ fn camera_fluid_height(world: &World, facts: &BlockFacts, b: [i32; 3]) -> Option
};
Some((s.fluid, height))
}
/// `Camera.getFluidInCamera` (near-plane points reduced to the camera and
/// the point just in front of it).
pub fn fog_type(world: &World, facts: &BlockFacts, camera: [f64; 3], forward: [f64; 3]) -> FogType {
/// `Camera.getFluidInCamera`: water at the camera, then lava or powder snow
/// at the near plane's centre and corners.
pub fn fog_type(world: &World, facts: &BlockFacts, camera: [f64; 3], near: &NearPlane) -> FogType {
camera_fog_type(
camera,
near,
|b| camera_fluid_height(world, facts, b),
|b| &*facts.get(world.block(b[0], b[1], b[2])).name == "minecraft:powder_snow",
)
}
/// `fog_type` over a block lookup: the fluid and its camera height, and
/// whether the block is powder snow.
fn camera_fog_type(
camera: [f64; 3],
near: &NearPlane,
fluid: impl Fn([i32; 3]) -> Option<(Fluid, f64)>,
powder_snow: impl Fn([i32; 3]) -> bool,
) -> FogType {
let block = |p: [f64; 3]| {
[
p[0].floor() as i32,
@@ -146,28 +162,19 @@ pub fn fog_type(world: &World, facts: &BlockFacts, camera: [f64; 3], forward: [f
]
};
let b = block(camera);
if let Some((Fluid::Water, h)) = camera_fluid_height(world, facts, b)
if let Some((Fluid::Water, h)) = fluid(b)
&& camera[1] < f64::from(b[1]) + h
{
return FogType::Water;
}
for p in [
[
camera[0] + forward[0] * 0.05,
camera[1] + forward[1] * 0.05,
camera[2] + forward[2] * 0.05,
],
camera,
] {
for point in near.points() {
let p: [f64; 3] = std::array::from_fn(|i| camera[i] + point[i]);
let b = block(p);
match camera_fluid_height(world, facts, b) {
match fluid(b) {
Some((Fluid::Lava, h)) if p[1] <= f64::from(b[1]) + h => return FogType::Lava,
Some((Fluid::Lava, _)) => {}
_ => {
if &*facts.get(world.block(b[0], b[1], b[2])).name == "minecraft:powder_snow" {
return FogType::PowderSnow;
}
}
_ if powder_snow(b) => return FogType::PowderSnow,
_ => {}
}
}
FogType::Atmospheric
@@ -448,6 +455,23 @@ mod tests {
}
}
#[test]
fn lava_fog_starts_when_a_near_plane_corner_dips_under_the_surface() {
// Flowing lava (amount 4) on y 64: the camera sees its surface at 64 + 4/9.
let surface = 64.0 + 4.0 / 9.0;
let fluid = |b: [i32; 3]| (b[1] == 64).then_some((Fluid::Lava, 4.0 / 9.0));
let fog = |y: f64, pitch: f32| {
let mut camera = crate::camera::Camera::default();
camera.set_rotation(0.0, pitch);
let near = camera.near_plane(70.0, 1600.0, 900.0);
camera_fog_type([0.5, y, 0.5], &near, fluid, |_| false)
};
// Looking level, the near plane reaches tan(35 degrees) * 0.05 = 0.035 below the eye.
assert_eq!(fog(surface + 0.02, 0.0), FogType::Lava);
assert_eq!(fog(surface + 0.05, 0.0), FogType::Atmospheric);
// The eye itself is not tested: looking up, the whole plane is above it.
assert_eq!(fog(surface - 0.01, -90.0), FogType::Atmospheric);
}
#[test]
fn boss_world_fog_closes_in() {
// AtmosphericFogEnvironment.setupFog under a createWorldFog boss bar.
let sky = Sky {
+10
View File
@@ -947,6 +947,16 @@ impl Font {
.sum::<f32>()
.ceil()
}
/// `Font.width(FormattedCharSequence)` of a sequence a component built
/// with `FormattedCharSequence.forward`: every character is a glyph, a
/// `§` included (`StringDecomposer.iterate`, not `iterateFormatted`).
pub fn width_drawn(&self, t: &Text) -> f32 {
crate::bidi::visual(t)
.chars()
.map(|(c, s)| self.advance(c, s))
.sum::<f32>()
.ceil()
}
/// `StringSplitter.splitLines`: each line and whether it continues a
/// wrapped one. A line breaks at a `\n`, or once a character overflows
/// `max_width` (never the first visible one) at the last space so far, or
File diff suppressed because it is too large. Load diff
@@ -735,7 +735,7 @@ mod tests {
}
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
pollster::block_on(adapter.request_device(&Default::default())).ok()
}
+368 -19
View File
@@ -4,7 +4,7 @@
//! with pages up to `LARGE_PAGE_BYTES`, and their sorted indices too
//! (`Arena::translucent_indices`): their back-to-front list then stays in one
//! page of each and draws in a few multi-draws.
use std::collections::BTreeMap;
use std::collections::{BTreeMap, VecDeque};
/// Ranges are rounded up to this many vertices to limit fragmentation, and
/// start at multiples of it (`flat` names sections by these blocks).
@@ -100,6 +100,9 @@ impl Page {
/// An arena with this many bytes in free ranges, at least half of its pages,
/// has a page worth emptying (`Arena::sparsest`).
const COMPACT_MIN_FREE: u64 = 128 << 20;
/// A page of at least this many bytes shrinks (`Arena::oversized`) when a page
/// of its ranges in use halves it or is `PAGE_BYTES` smaller.
const SHRINK_MIN_BYTES: u64 = 64 << 20;
/// Terrain vertices in pages of at most `page_limit` vertices (devices cap
/// buffer sizes): a section's vertices sit in one page.
@@ -139,10 +142,10 @@ impl Arena {
Self::with_usage(device, 4, capacity, usage, PAGE_BYTES)
}
/// Translucent quads' vertices, which no shader reads as storage: pages
/// up to `LARGE_PAGE_BYTES`. The section arena's compaction never runs on
/// it (`Renderer::compact_arena`): a page this large has no others to
/// empty into, and the compaction moves sections' `page` ranges, not their
/// `translucent_at`.
/// up to `LARGE_PAGE_BYTES`. A page this large has no others to empty into
/// (`sparsest`): once the view has settled, one far larger than its ranges
/// in use moves into a new page of their size (`oversized`, `open_page`;
/// `Renderer::shrink_translucent`).
pub fn translucent(device: &wgpu::Device, stride: u64, capacity: u64) -> Self {
let usage = wgpu::BufferUsages::VERTEX;
Self::with_usage(device, stride, capacity, usage, LARGE_PAGE_BYTES)
@@ -241,7 +244,7 @@ impl Arena {
})
}
/// The rounded length a request of `vertices` occupies.
fn span(vertices: u64) -> u64 {
pub fn span(vertices: u64) -> u64 {
vertices.max(1).next_multiple_of(GRAIN)
}
/// Bytes of every page's buffer.
@@ -252,6 +255,18 @@ impl Arena {
pub fn page_vertices(&self) -> u64 {
self.page_limit
}
/// `page`'s buffer up to the end of its last range in use, as draws bind it:
/// wgpu clears what a binding covers and nothing wrote yet when the frame is
/// submitted, most of a page just made or grown.
pub fn binding(&self, page: u32) -> wgpu::BufferSlice<'_> {
let p = self.pages[page as usize].as_ref().expect("live arena page");
let end = match p.free.0.last_key_value() {
Some((&start, &len)) if start + len == p.capacity => start,
_ => p.capacity,
};
// A page a draw names holds a range; wgpu refuses an empty slice.
p.buffer.slice(..end.max(GRAIN) * self.stride)
}
pub fn has_page(&self, page: u32) -> bool {
self.pages.get(page as usize).is_some_and(Option::is_some)
}
@@ -266,6 +281,15 @@ impl Arena {
.expect("live arena page")
.buffer
}
/// Bytes of one vertex (or index).
pub fn stride(&self) -> u64 {
self.stride
}
/// Vertices in `page`'s ranges in use (as `span` rounds them).
pub fn used(&self, page: u32) -> u64 {
let page = self.pages[page as usize].as_ref().expect("live arena page");
page.capacity - page.free_vertices()
}
/// A range of `vertices`: (page, first vertex). Grows the newest page
/// (copying its contents on the GPU, in `encoder`) or starts a new one
/// when nothing free is large enough; `None` when no memory is left for
@@ -390,6 +414,49 @@ impl Arena {
.min()
.map(|(_, _, i)| i)
}
/// A page far larger than its ranges in use, and the capacity a page of them
/// gets (`grown`: twice theirs, or `PAGE_BYTES` more; at least the first
/// page's): one of `SHRINK_MIN_BYTES` or more, with ranges in use (an empty
/// one is `trim`'s), that this halves or makes `PAGE_BYTES` smaller. In
/// system memory too (`open_page` makes the new one in video memory). None
/// while a page is closing; the page it leads to is not oversized until its
/// ranges change.
pub fn oversized(&self) -> Option<(u32, u64)> {
if self.closing.is_some() {
return None;
}
let saving = PAGE_BYTES / self.stride;
self.pages.iter().enumerate().find_map(|(i, p)| {
let p = p.as_ref()?;
let used = p.capacity - p.free_vertices();
let capacity = self
.grown(used, 0)
.max(self.initial_capacity)
.next_multiple_of(GRAIN)
.min(self.page_limit);
let smaller = 2 * capacity <= p.capacity || capacity + saving <= p.capacity;
(p.capacity * self.stride >= SHRINK_MIN_BYTES && used > 0 && smaller)
.then_some((i as u32, capacity))
})
}
/// A new, empty page of `capacity` vertices in video memory, made the newest
/// (the one that grows), for what `Renderer::shrink_translucent` moves off an
/// `oversized` page (`alloc_moved`) and for new ranges meanwhile. False, and
/// nothing changes, when video memory has no room for it: in system memory it
/// would slow every frame that draws it.
pub fn open_page(&mut self, device: &wgpu::Device, capacity: u64) -> bool {
let Some((buffer, host)) = self.create(device, capacity, false) else {
return false;
};
let free = FreeList::new(capacity);
self.pages.push(Some(Page {
buffer,
capacity,
free,
host,
}));
true
}
/// Stops placing vertices in `page` until `reopen`.
pub fn close(&mut self, page: u32) {
self.closing = Some(page);
@@ -478,14 +545,17 @@ impl Arena {
old.free.give(previous, capacity - previous);
true
}
/// Reclaim after an upload batch, so replacing a section can reuse its range.
pub fn trim(&mut self) {
/// Reclaim after an upload batch, so replacing a section can reuse its range. Gives back
/// the buffers of the pages it emptied, for a [`Graveyard`].
pub fn trim(&mut self) -> Vec<wgpu::Buffer> {
let mut released = Vec::new();
for page in &mut self.pages {
if let Some(p) = page
&& p.free.0.len() == 1
&& p.free.0.get(&0) == Some(&p.capacity)
if page
.as_ref()
.is_some_and(|p| p.free.0.len() == 1 && p.free.0.get(&0) == Some(&p.capacity))
&& let Some(p) = page.take()
{
*page = None;
released.push(p.buffer);
}
}
if self.closing.is_some_and(|page| !self.has_page(page)) {
@@ -495,13 +565,67 @@ impl Arena {
while self.pages.last().is_some_and(Option::is_none) {
self.pages.pop();
}
released
}
pub fn clear(&mut self) {
self.pages.clear();
/// Drops every page; gives back their buffers, for a [`Graveyard`].
pub fn clear(&mut self) -> Vec<wgpu::Buffer> {
self.closing = None;
self.pages.drain(..).flatten().map(|p| p.buffer).collect()
}
}
/// Frames a released page waits before it is freed: by then the GPU is done with the frames that
/// used it, and the bind groups naming it (flat draws' page tables) are gone, so the freeing
/// thread holds its last reference.
const GRAVE_FRAMES: usize = 4;
/// Buffers of released pages, freed [`GRAVE_FRAMES`] frames later on a thread of their own
/// ([`free_later`]): giving a page of up to 256 MB back to the driver on the render thread took
/// that frame 8-14 ms of CPU (Mesa's lavapipe), when flight or a teleport emptied a page.
pub struct Graveyard<T> {
graves: VecDeque<Vec<T>>,
}
impl<T> Default for Graveyard<T> {
fn default() -> Self {
Self {
graves: VecDeque::new(),
}
}
}
impl<T> Graveyard<T> {
/// Released this frame.
pub fn add(&mut self, items: impl IntoIterator<Item = T>) {
if self.graves.is_empty() {
self.graves.push_back(Vec::new());
}
if let Some(grave) = self.graves.back_mut() {
grave.extend(items);
}
}
/// A frame went by: what was released `GRAVE_FRAMES` frames ago, to free.
pub fn advance(&mut self) -> Vec<T> {
self.graves.push_back(Vec::new());
if self.graves.len() > GRAVE_FRAMES {
self.graves.pop_front().unwrap_or_default()
} else {
Vec::new()
}
}
}
/// Drops `items` on a short-lived thread of its own, as `chunk::free_columns` does for
/// unloaded chunks; should the thread not start, they drop here.
pub fn free_later<T: Send + 'static>(items: Vec<T>) {
if items.is_empty() {
return;
}
let _ = std::thread::Builder::new()
.name("Buffer freeing".into())
.spawn(move || drop(items));
}
/// `create_buffer` with an out-of-memory failure returned instead of raised
/// (raised errors are fatal).
fn try_create(device: &wgpu::Device, size: u64, usage: wgpu::BufferUsages) -> Option<wgpu::Buffer> {
@@ -520,13 +644,31 @@ fn try_create(device: &wgpu::Device, size: u64, usage: wgpu::BufferUsages) -> Op
mod tests {
use super::*;
/// A released page is handed back for freeing `GRAVE_FRAMES` frames later, in order.
#[test]
fn graveyard_frees_after_its_frames() {
let mut graves = Graveyard::default();
graves.add([1, 2]);
let mut freed = Vec::new();
for frame in 0..GRAVE_FRAMES + 2 {
if frame == 1 {
graves.add([3]);
}
freed.push(graves.advance());
}
let mut expected = vec![Vec::new(); GRAVE_FRAMES + 2];
expected[GRAVE_FRAMES - 1] = vec![1, 2];
expected[GRAVE_FRAMES] = vec![3];
assert_eq!(freed, expected);
}
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
device_with(wgpu::Features::empty())
}
/// A device with those of `features` the adapter has.
fn device_with(features: wgpu::Features) -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
eprintln!("arena GPU regression: {}", adapter.get_info().name);
let required_features = adapter.features() & features;
@@ -540,7 +682,7 @@ mod tests {
/// A device with the adapter's own limits, as the renderer opens it: page
/// caps past the default 256 MiB buffer limit show there.
fn device_at_limits() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
required_limits: adapter.limits(),
@@ -678,7 +820,7 @@ mod tests {
/// `translucent_at` along.
#[test]
fn compaction_moves_what_sections_hold_in_the_section_arena() {
use super::super::{SectionGpu, move_off_page};
use super::super::{Part, SectionGpu, move_off_page};
let Some((device, queue)) = device() else {
eprintln!("no GPU adapter: skipped");
return;
@@ -694,6 +836,7 @@ mod tests {
sort: super::super::translucency::SortState::new(Vec::new()),
sorted_indices: None,
flat_id: None,
list_id: None,
fading: None,
leaving: None,
animated_layers: Box::new([]),
@@ -744,14 +887,15 @@ mod tests {
arena.close(0);
let mut encoder = device.create_command_encoder(&Default::default());
let meshes = sections.iter_mut();
let mut budget = u64::MAX;
assert!(move_off_page(
&mut arena,
&mut encoder,
None,
meshes,
0,
split,
u64::MAX
Part::Section { split },
&mut budget
));
queue.submit([encoder.finish()]);
let word =
@@ -784,6 +928,32 @@ mod tests {
}
}
/// Draws bind a large page up to the end of its last range in use, a grain at
/// least: wgpu clears what a binding covers and nothing wrote when the frame is
/// submitted, so a grown page bound whole had its new tail cleared on the GPU.
#[test]
fn large_pages_are_bound_up_to_their_last_range_in_use() {
let Some((device, queue)) = device() else {
eprintln!("no GPU adapter: skipped");
return;
};
let mut arena = Arena::translucent(&device, 24, GRAIN);
let ranges: Vec<_> = (0..3)
.map(|_| alloc(&mut arena, &device, &queue, GRAIN).unwrap())
.collect();
// The third grain grew the page to four.
assert_eq!(arena.buffer(0).size(), 4 * GRAIN * 24);
assert_eq!(arena.binding(0).size(), 3 * GRAIN * 24);
// A free range before the last in use stays bound, none after it; an empty
// page binds a grain (wgpu refuses an empty slice).
arena.release(0, ranges[1].1, GRAIN);
assert_eq!(arena.binding(0).size(), 3 * GRAIN * 24);
arena.release(0, ranges[2].1, GRAIN);
assert_eq!(arena.binding(0).size(), GRAIN * 24);
arena.release(0, ranges[0].1, GRAIN);
assert_eq!(arena.binding(0).size(), GRAIN * 24);
}
#[test]
fn empty_pages_are_reclaimed_without_moving_live_ranges() {
let Some((device, queue)) = device() else {
@@ -916,6 +1086,185 @@ mod tests {
);
}
/// A large page far larger than its ranges in use moves into a new page of their
/// size (`begin_shrink`) over frames, through `move_off_page`: a budget a call,
/// its first range whatever its size, data and all, while new ranges, writes and
/// releases go on around the moves in one encoder, as the renderer records them.
/// The other range of each mesh stays; the emptied page takes no new range until
/// `trim` returns it. Translucent vertices and sorted indices alike.
#[test]
fn a_large_page_moves_into_one_of_the_size_its_ranges_get() {
use super::super::{Part, SectionGpu, begin_shrink, move_off_page};
// Where system memory could take the new page, it must not.
let Some((device, queue)) = device_with(wgpu::Features::MAPPABLE_PRIMARY_BUFFERS) else {
eprintln!("no GPU adapter: skipped");
return;
};
for (mut arena, part) in [
(Arena::translucent(&device, 24, GRAIN), Part::Translucent),
(Arena::translucent_indices(&device, GRAIN), Part::Indices),
] {
let indices = matches!(part, Part::Indices);
let stride = arena.stride;
// 64 quads a mesh: their vertices, or their sorted indices.
let len: u64 = if indices { 64 * 6 } else { 64 * 4 };
let span = len.next_multiple_of(GRAIN);
// Its range here at `at`; the other one names a page that is not there.
let mesh = |at: (u32, u64), solid: u32, translucent: u32| SectionGpu {
page: 0,
start: 0,
translucent_at: if indices { (7, 7) } else { at },
solid,
sides: crate::mesher::Sides::unsorted(solid),
translucent,
sort: super::super::translucency::SortState::new(Vec::new()),
sorted_indices: (translucent > 0).then_some(if indices { at } else { (7, 7) }),
flat_id: None,
list_id: None,
fading: None,
leaving: None,
animated_layers: Box::new([]),
};
let mut uploads = super::super::upload::Uploads::new(&device);
let mut encoder = device.create_command_encoder(&Default::default());
// As the renderer writes: through a belt, in the encoder's order.
let mut write = |encoder: &mut _, buffer: &wgpu::Buffer, at: u64, value: u32| {
uploads.buffer(encoder, buffer, at * stride, &value.to_le_bytes())
};
// Sixteen meshes with their number at both ends of their range, and one
// of solid quads alone (nothing here).
let mut meshes: Vec<(u32, SectionGpu)> = (0..16)
.map(|i| {
let at = arena.alloc(&device, &mut encoder, len).unwrap();
write(&mut encoder, arena.buffer(at.0), at.1, i);
write(&mut encoder, arena.buffer(at.0), at.1 + len - 1, i);
(i, mesh(at, 0, 64))
})
.collect();
meshes.push((99, mesh((0, 0), 64, 0)));
// Grown past `SHRINK_MIN_BYTES` in the same encoder (the writes before
// reach the grown buffer), then mostly freed, as after a teleport.
let big = (64u64 << 20).div_ceil(stride);
let at = arena.alloc(&device, &mut encoder, big).unwrap();
arena.release(at.0, at.1, big);
for (_, s) in meshes.extract_if(.., |(i, _)| ![0, 3, 6, 9, 12, 99].contains(i)) {
let (page, start, n) = part.of(&s).unwrap();
arena.release(page, start, n);
}
// No video memory for the new page: nothing changes, nothing spills.
arena.video_memory_full = true;
let held = meshes.iter_mut().map(|(_, s)| s);
assert_eq!(begin_shrink(&mut arena, &device, held, part), Some(false));
assert_eq!((arena.closing(), arena.pages.len()), (None, 1));
assert!(!arena.spilled);
arena.video_memory_full = false;
let held = meshes.iter_mut().map(|(_, s)| s);
assert_eq!(begin_shrink(&mut arena, &device, held, part), Some(true));
assert_eq!(arena.closing(), Some(0));
// Twice the five meshes' ranges.
assert_eq!(arena.pages[1].as_ref().unwrap().capacity, 10 * span);
// New ranges go to the new page meanwhile.
let upload = arena.alloc(&device, &mut encoder, GRAIN).unwrap();
assert_eq!(upload.0, 1);
write(&mut encoder, arena.buffer(1), upload.1, 77);
// Moves what page 0 holds within `budget`: how many meshes page 1 has then.
let moved = |arena: &mut Arena,
encoder: &mut _,
meshes: &mut [(u32, SectionGpu)],
budget: &mut u64| {
let held = meshes.iter_mut().map(|(_, s)| s);
assert!(move_off_page(arena, encoder, None, held, 0, part, budget));
let on = |s: &SectionGpu| part.of(s).is_some_and(|r| r.0 == 1);
meshes.iter().filter(|(_, s)| on(s)).count()
};
let mut budget = 2 * len * stride;
let on = moved(&mut arena, &mut encoder, &mut meshes, &mut budget);
assert_eq!((on, budget), (2, 0));
// Out of budget, a call still moves its first range.
let on = moved(&mut arena, &mut encoder, &mut meshes, &mut budget);
assert_eq!(on, 3);
// The sorter writes where #0 is now; a mesh still on the old page goes.
let (page, start, _) = part.of(&meshes[0].1).unwrap();
write(&mut encoder, arena.buffer(page), start, 100);
let gone = meshes
.iter()
.position(|(_, s)| part.of(s).is_some_and(|r| r.0 == 0));
let (page, start, n) = part.of(&meshes.remove(gone.unwrap()).1).unwrap();
arena.release(page, start, n);
budget = u64::MAX;
let on = moved(&mut arena, &mut encoder, &mut meshes, &mut budget);
assert_eq!(on, 4);
// Emptied, it stays closed until `trim`.
assert_eq!((arena.used(0), arena.closing()), (0, Some(0)));
uploads.finish(&encoder);
queue.submit([encoder.finish()]);
let word = |page, at: u64| read(&device, &queue, arena.buffer(page), at * stride);
for (i, s) in &meshes {
let Some((page, start, n)) = part.of(s) else {
// Solid quads alone: as it was.
let at = if indices { (7, 7) } else { (0, 0) };
assert_eq!((*i, s.translucent_at, s.sorted_indices), (99, at, None));
continue;
};
assert_eq!(page, 1);
assert_eq!(word(page, start), if *i == 0 { 100 } else { *i });
assert_eq!(word(page, start + n - 1), *i);
// Its other range stays where it was.
assert!(s.translucent_at == (7, 7) || s.sorted_indices == Some((7, 7)));
}
assert_eq!(word(upload.0, upload.1), 77);
arena.trim();
assert!(!arena.has_page(0));
assert_eq!(arena.closing(), None);
assert_eq!(arena.bytes(), 10 * span * stride);
}
}
/// Large pages shrink when a page of their ranges in use (what `grown` makes of
/// them, at least the first page's size) halves them or is `PAGE_BYTES`
/// smaller: at the smallest page that shrinks and at the largest.
#[test]
fn large_pages_shrink_when_that_halves_them_or_saves_a_page() {
let Some((device, _)) = device() else {
eprintln!("no GPU adapter: skipped");
return;
};
let mut arena = Arena::translucent(&device, 24, 1 << 18);
arena.page_limit = LARGE_PAGE_BYTES / 24 / GRAIN * GRAIN;
let (limit, step) = (arena.page_limit, PAGE_BYTES / 24);
// `oversized` looks at the counts only: a small buffer stands in.
let buffer = try_create(&device, 4, wgpu::BufferUsages::VERTEX).unwrap();
let mut shrinks = |capacity: u64, used: u64, host: bool| {
let free = FreeList(BTreeMap::from([(used, capacity - used)]));
let buffer = buffer.clone();
arena.pages = vec![Some(Page {
buffer,
capacity,
free,
host,
})];
arena.oversized().map(|(_, to)| to)
};
let least = (64u64 << 20).div_ceil(24).next_multiple_of(GRAIN);
let quarter = least / 4 / GRAIN * GRAIN;
assert_eq!(shrinks(least, quarter, false), Some(2 * quarter));
assert_eq!(shrinks(least, quarter + GRAIN, false), None);
let used = (limit - 2 * step) / GRAIN * GRAIN;
let target = (used + step).next_multiple_of(GRAIN);
assert_eq!(shrinks(limit, used, false), Some(target));
assert_eq!(shrinks(limit, used + GRAIN, false), None);
// The page it leads to, holding the same, is not oversized.
assert_eq!(shrinks(target, used, false), None);
// Not below the first page's size; smaller pages and empty ones stay.
assert_eq!(shrinks(least, GRAIN, false), Some(1 << 18));
assert_eq!(shrinks(least - GRAIN, GRAIN, false), None);
assert_eq!(shrinks(least, 0, false), None);
// One in system memory goes back to video memory; none while one closes.
assert_eq!(shrinks(least, quarter, true), Some(2 * quarter));
arena.closing = Some(0);
assert_eq!(arena.oversized(), None);
}
#[test]
fn clearing_an_arena_releases_buffers_and_can_allocate_again() {
let Some((device, queue)) = device() else {
@@ -4,7 +4,7 @@ use wgpu::util::DeviceExt;
#[test]
#[ignore = "requires a Vulkan/Metal/DX12 adapter"]
fn crack_samples_stay_aligned_between_stages() {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let instance = crate::gfx::test_gpu::instance();
let adapter =
pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default()))
.unwrap();
+15 -13
View File
@@ -585,23 +585,25 @@ impl Cull {
self.current = Some(self.next);
}
/// After the frame's submission: start reading its results back.
pub fn submitted(&mut self) {
let Some(i) = self.current.take() else {
return;
};
/// The frame's results to read back, started by calling it right after the frame's
/// submission (on the submit thread, when the frame goes there).
pub fn submitted(&mut self) -> Option<impl FnOnce() + Send + 'static> {
let i = self.current.take()?;
let r = &self.readbacks[i];
let state = r.state.clone();
state.store(PENDING, Ordering::Release);
r.buffer
.slice(..r.slots.len() as u64 * 4)
.map_async(wgpu::MapMode::Read, move |result| {
state.store(
if result.is_ok() { MAPPED } else { FREE },
Ordering::Release,
);
});
let (buffer, bytes) = (r.buffer.clone(), r.slots.len() as u64 * 4);
self.next = (i + 1) % READBACKS;
Some(move || {
buffer
.slice(..bytes)
.map_async(wgpu::MapMode::Read, move |result| {
state.store(
if result.is_ok() { MAPPED } else { FREE },
Ordering::Release,
);
});
})
}
fn make_group(
@@ -37,7 +37,7 @@ fn geometry_matches_pre_residency_vertex_snapshots() {
}
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
eprintln!("End sky test adapter: {:?}", adapter.get_info());
pollster::block_on(adapter.request_device(&Default::default())).ok()
@@ -22,6 +22,10 @@ const MATERIALS: usize = 10;
pub const OIT_ENTITY: u32 = 1 << 24;
/// `RenderPipelines.DRAGON_RAYS`: untextured colour, additive with depth writes.
pub const DRAGON_RAYS_OVERLAY: u32 = 37;
/// `RenderPipelines.ENTITY_CUTOUT_DISSOLVE` (the dying dragon): a pixel is dropped where the
/// vertex alpha is below the mask's, whose atlas UV rides in `Vertex::glint_uv`; the rest is
/// drawn opaque. No overlay.
pub const DISSOLVE_OVERLAY: u32 = 38;
/// Tags quads as drawn by a render type vanilla gives an `OitPipelineSet`
/// (`OIT_ENTITY`): improved transparency draws them in its stages.
pub fn mark_oit(quads: &mut [Vertex]) {
@@ -173,7 +173,8 @@ impl EntityOutline {
}
/// Uploads this frame's silhouettes (`LevelRenderer.currentFrameRendersEntityOutline`
/// when there are any) and sizes the targets like the window.
/// when there are any) and sizes the targets like the window. A frame with
/// none gives the targets' video memory back until an entity glows again.
pub(super) fn prepare(
&mut self,
device: &wgpu::Device,
@@ -184,6 +185,7 @@ impl EntityOutline {
) {
self.quads = (silhouettes.len() / 4) as u32;
if self.quads == 0 {
self.targets = None;
return;
}
let vertices = silhouettes.len() as u64;
+106 -6
View File
@@ -42,6 +42,8 @@ struct Plan {
solid_calls: usize,
/// Where the shadow camera's solid and translucent calls begin.
shadow_calls: [usize; 2],
/// Where Metal's opaque pass ends and its cutout pass begins in phase 1's calls.
pass_calls: [usize; 2],
total: u64,
}
const QUADS: u32 = u32::MAX;
@@ -107,6 +109,9 @@ pub struct Flat {
pub calls: Vec<Call>,
pub phase1_calls: usize,
pub solid_calls: usize,
/// Metal's two solid passes over phase 1: the opaque one draws
/// `calls[..pass_calls[0]]`, the cutout one `calls[pass_calls[1]..phase1_calls]`.
pub pass_calls: [usize; 2],
/// Where a shader pack's shadow camera's solid and translucent calls
/// begin (`calls.len()` without them): the camera's translucent calls
/// end at the first.
@@ -216,7 +221,9 @@ fn buffer(
/// apart. A phase 2 range names its draw in phase 2 and its call in phase 2
/// instead of a destination: the call's room holds all of its draws. Draws
/// from `shadow[0]` (solid) and `shadow[1]` (translucent) on are a shader
/// pack's shadow camera's, with calls of their own.
/// pack's shadow camera's, with calls of their own. Phase 1's draws before
/// `passes[0]` and from `passes[1]` on, the two solid passes' on Metal, too.
/// The list starts at index `base` (the GPU-made lists' rooms come first: `gpu_lists`).
#[allow(clippy::too_many_arguments)]
fn plan(
draws: &[[u32; 5]],
@@ -224,17 +231,20 @@ fn plan(
phase1_draws: usize,
solid_draws: usize,
shadow: [usize; 2],
passes: [usize; 2],
align: u64,
calls: &mut Vec<Call>,
ranges: &mut Vec<Range>,
segments: &mut Vec<(Source, u64, u64)>,
base: u64,
) -> Plan {
calls.clear();
ranges.clear();
segments.clear();
let (mut phase1_calls, mut solid_calls) = (None, None);
let mut shadow_calls = [None; 2];
let mut total = 0u64;
let mut pass_calls = [None; 2];
let mut total = base;
let mut by_source: Vec<(Source, Vec<Range>)> = Vec::new();
for (i, (d, &(page, sorted))) in draws.iter().zip(pages).enumerate() {
if i == phase1_draws {
@@ -248,7 +258,13 @@ fn plan(
shadow_calls[k].get_or_insert(calls.len());
}
}
let starts = i == phase1_draws || i == solid_draws || shadow.contains(&i);
for (k, &from) in passes.iter().enumerate() {
if i == from {
pass_calls[k].get_or_insert(calls.len());
}
}
let starts =
i == phase1_draws || i == solid_draws || shadow.contains(&i) || passes.contains(&i);
match calls.last_mut() {
Some(last) if last.page == page && !starts => last.count += d[0],
_ => calls.push(Call {
@@ -283,10 +299,12 @@ fn plan(
ranges.resize(padded as usize, [0; 4]);
}
let solid_calls = solid_calls.unwrap_or(calls.len());
let phase1_calls = phase1_calls.unwrap_or(calls.len()).min(solid_calls);
Plan {
phase1_calls: phase1_calls.unwrap_or(calls.len()).min(solid_calls),
phase1_calls,
solid_calls,
shadow_calls: shadow_calls.map(|c| c.unwrap_or(calls.len())),
pass_calls: pass_calls.map(|c| c.unwrap_or(calls.len()).min(phase1_calls)),
total,
}
}
@@ -438,6 +456,7 @@ impl Flat {
calls: Vec::new(),
phase1_calls: 0,
solid_calls: 0,
pass_calls: [0; 2],
shadow_calls: [0; 2],
min_sections,
range_align: (u64::from(device.limits().min_storage_buffer_offset_alignment) / 16)
@@ -519,9 +538,12 @@ impl Flat {
phase1_draws: usize,
solid_draws: usize,
shadow: [usize; 2],
passes: [usize; 2],
phase2: Option<&wgpu::Buffer>,
origins: impl Iterator<Item = (u32, [f32; 4])>,
sorted_page: impl Fn(u32) -> wgpu::Buffer,
mut lists: Option<&mut super::gpu_lists::GpuLists>,
eye: Option<[f32; 3]>,
timing: Option<&mut super::pack_timing::PassTiming>,
) -> bool {
self.phase2_group = None;
@@ -559,10 +581,13 @@ impl Flat {
bytemuck::cast_slice(&self.origin_data),
);
}
// The GPU-made lists' rooms come first in the index list.
let room = lists.as_mut().map_or(0, |l| l.rooms());
let Plan {
phase1_calls,
solid_calls,
shadow_calls,
pass_calls,
total,
} = plan(
draws,
@@ -570,19 +595,23 @@ impl Flat {
phase1_draws,
solid_draws,
shadow,
passes,
self.range_align,
&mut self.calls,
&mut self.range_data,
&mut self.segments,
room,
);
self.phase1_calls = phase1_calls;
self.solid_calls = solid_calls;
self.shadow_calls = shadow_calls;
self.pass_calls = pass_calls;
if total == 0 || total * 4 > device.limits().max_storage_buffer_binding_size {
self.calls.clear();
self.phase1_calls = 0;
self.solid_calls = 0;
self.shadow_calls = [0; 2];
self.pass_calls = [0; 2];
return false;
}
if self.range_data.len() as u64 > self.range_capacity {
@@ -634,6 +663,9 @@ impl Flat {
bytemuck::cast_slice(&self.phase2_call_data),
);
}
if let Some(lists) = &mut lists {
lists.prepare(device, encoder, uploads, Some(&self.indices), eye);
}
let ranges_at = |first: u64, count: u64| {
wgpu::BindingResource::Buffer(wgpu::BufferBinding {
buffer: &self.ranges,
@@ -703,13 +735,17 @@ impl Flat {
label: Some("Flatten section draws"),
timestamp_writes: timing.and_then(|t| t.compute_writes("Flatten")),
});
if let Some(lists) = &lists {
lists.dispatch(&mut pass);
}
pass.set_pipeline(&self.flatten);
for (group, count) in &groups {
pass.set_bind_group(0, group, &[]);
pass.dispatch_workgroups((*count).min(GROUP_LIMIT), count.div_ceil(GROUP_LIMIT), 1);
}
drop(pass);
for page in self.calls.iter().map(|c| c.page) {
let list_pages = lists.iter().flat_map(|l| l.calls().iter().map(|c| c.page));
for page in self.calls.iter().map(|c| c.page).chain(list_pages) {
let table = self.pages[page as usize]
.as_mut()
.expect("a drawn page has its block table");
@@ -750,6 +786,27 @@ impl Flat {
pass.dispatch_workgroups((*count).min(GROUP_LIMIT), count.div_ceil(GROUP_LIMIT), 1);
}
/// Draws pass `group`'s calls of `lists` (`gpu_lists::Out::Indices`, prepared with
/// this frame's `prepare`) with a flat pipeline set.
pub fn draw_lists<'a>(
&self,
pass: &mut wgpu::RenderPass<'_>,
lists: &super::gpu_lists::GpuLists,
group: u8,
vertices: impl Fn(u32) -> &'a wgpu::Buffer,
) {
if !lists.prepared() {
return;
}
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
for (k, call) in lists.calls().iter().enumerate() {
if call.group == group {
self.bind_page(pass, call.page, &vertices);
lists.draw_indexed(pass, k);
}
}
}
/// The camera's translucent calls.
pub fn translucent_calls(&self) -> &[Call] {
&self.calls[self.solid_calls..self.shadow_calls[0]]
@@ -870,10 +927,12 @@ mod tests {
solid,
solid,
[draws.len(); 2],
[draws.len(), 0],
4,
&mut calls,
&mut ranges,
&mut segments,
0,
);
assert_eq!(total, 6 * 13);
assert_eq!(phase1_calls, solid_calls);
@@ -904,6 +963,40 @@ mod tests {
assert_eq!(ranges.len(), 12);
}
/// Metal's opaque pass draws the lists before `passes[0]`, its cutout
/// pass those from `passes[1]`: each bound starts a call of its own,
/// though its page goes on.
#[test]
fn plan_splits_calls_where_the_solid_passes_end_and_begin() {
let (draws, pages, solid) = sample();
let (mut calls, mut ranges, mut segments) = (Vec::new(), Vec::new(), Vec::new());
let plan = plan(
&draws,
&pages,
solid,
solid,
[draws.len(); 2],
[3, 1],
4,
&mut calls,
&mut ranges,
&mut segments,
0,
);
assert_eq!((plan.pass_calls, plan.solid_calls), ([3, 1], 4));
let call = |page, first, count| Call { page, first, count };
assert_eq!(
calls,
[
call(0, 0, 18),
call(0, 18, 12),
call(1, 30, 6),
call(0, 36, 24),
call(0, 60, 18)
]
);
}
/// A shader pack's shadow draws follow the camera's, in calls of their
/// own even from the same page: solid ones, then translucent ones.
#[test]
@@ -919,10 +1012,12 @@ mod tests {
solid,
solid,
[camera, camera + 2],
[camera, 0],
4,
&mut calls,
&mut ranges,
&mut segments,
0,
);
assert_eq!((plan.solid_calls, plan.shadow_calls), (3, [4, 5]));
let call = |page, first, count| Call { page, first, count };
@@ -946,10 +1041,12 @@ mod tests {
2,
solid,
[draws.len(); 2],
[draws.len(), 0],
4,
&mut calls,
&mut ranges,
&mut segments,
0,
);
assert_eq!(
plan,
@@ -957,6 +1054,7 @@ mod tests {
phase1_calls: 1,
solid_calls: 3,
shadow_calls: [4, 4],
pass_calls: [1, 0],
total: 6 * 13
}
);
@@ -982,7 +1080,7 @@ mod tests {
/// of the draws the test saw, in order, and their calls count them.
#[test]
fn flatten_writes_the_indices_of_the_per_section_draws() {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
eprintln!("no GPU adapter: skipped");
return;
@@ -1061,10 +1159,12 @@ mod tests {
phase1,
solid,
[draws.len(); 2],
[draws.len(), 0],
align,
&mut calls,
&mut ranges,
&mut segments,
0,
);
// Phase 2's draws as the test leaves them: instance counts of 0,
// 1 where it saw the section.
@@ -1,11 +1,572 @@
//! Frame generation: FSR 3.1's frame interpolation will show a frame halfway between each
//! two real ones. So far this holds its optical flow, its interpolation and the motion
//! vectors of what moves on its own; the setting that runs them comes next.
//! Frame generation (a Horizon setting, off by default): FSR 3.1's frame interpolation
//! makes a frame halfway between each two real ones. The world, without the hand and
//! HUD, is kept at the screen's size each frame; optical flow and the interpolation run
//! on it, the hand and HUD are drawn over the made frame as over the real one, and a
//! present thread shows the made frame, then the real one half a real frame later.
//!
//! The renderer draws into frames of its own while this is on; the present thread
//! copies each to the swapchain, which it alone acquires and presents.
// Unused until the renderer runs them.
#[allow(dead_code)]
mod interpolation;
#[allow(dead_code)]
mod motion;
#[allow(dead_code)]
mod optical_flow;
use glam::{DMat4, DVec3, Mat4};
use std::{
sync::{
Arc,
atomic::{AtomicBool, Ordering},
mpsc,
},
thread,
time::{Duration, Instant},
};
/// Frames to draw into: a pair being drawn, a pair waiting and a pair being shown.
const FRAMES: usize = 6;
/// `HORIZON_FRAMEGEN_DEBUG=generated`: show only made frames (AMD's debug view), so a
/// screenshot or an offscreen frame is the made one; `motion`: the same, each made frame
/// showing what moved on its own in blue (its motion vectors in red and green).
pub(super) fn generated_only() -> bool {
debug().is_some()
}
fn debug() -> Option<&'static str> {
static DEBUG: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
DEBUG
.get_or_init(|| {
std::env::var("HORIZON_FRAMEGEN_DEBUG")
.ok()
.filter(|v| v == "generated" || v == "motion")
})
.as_deref()
}
pub struct FrameGen {
pub on: bool,
/// The present thread failed to show a frame (or died): until the renderer has
/// acquired a frame itself, the normal path runs and handles the surface.
pub paused: bool,
gpu: Option<Gpu>,
presenter: Option<Presenter>,
/// When the last real frame was handed over, and the time between real frames.
last: Option<Instant>,
interval: Duration,
/// The last frame's unjittered `view_proj` and camera.
previous: Option<(DMat4, DVec3)>,
/// The last frame's hand vertices and the bob and view they were drawn with.
hand: Vec<crate::mesher::Vertex>,
hand_view: Mat4,
/// Offscreen, the frame the made frame is drawn into.
offscreen_generated: Option<wgpu::Texture>,
/// Motion vectors for what moves on its own, made on the first frame.
motion: Option<motion::ObjectMotion>,
/// Real frames and made ones handed to the screen since `shown_since`, and the share
/// of real frames that had a made frame over the last half second (the F3 screen's),
/// `None` while off.
real: u32,
made: u32,
shown_since: Instant,
pub made_share: Option<f32>,
}
/// What runs on the GPU for one screen size.
struct Gpu {
size: [u32; 2],
flow: optical_flow::OpticalFlow,
interpolation: interpolation::Interpolation,
/// This frame's world at the screen's size, and bindings that copy it or the
/// interpolation's output (`Post::finish_with`).
world: wgpu::TextureView,
world_group: wgpu::BindGroup,
output_group: wgpu::BindGroup,
/// The next interpolation has no previous frame.
reset: bool,
}
/// This frame's camera, for the interpolation.
pub struct Camera {
/// The world's projection this frame (with TAA's jitter) and without the jitter.
pub jittered: Mat4,
pub view_proj: Mat4,
pub depth: wgpu::TextureView,
pub render_size: [u32; 2],
pub jitter: [f32; 2],
/// This frame's unjittered clip space to the previous frame's, `None` without one.
pub reproject: Option<Mat4>,
pub near: f32,
pub far: f32,
pub fov_y: f32,
}
impl FrameGen {
pub fn new() -> Self {
Self {
on: false,
paused: false,
gpu: None,
presenter: None,
last: None,
interval: Duration::from_millis(16),
previous: None,
hand: Vec::new(),
hand_view: Mat4::IDENTITY,
offscreen_generated: None,
motion: None,
real: 0,
made: 0,
shown_since: Instant::now(),
made_share: None,
}
}
/// Ends frame generation's frames and present thread: before the surface is
/// reconfigured or the renderer acquires from it itself.
pub fn stop(&mut self) {
self.presenter = None;
self.made_share = None;
(self.real, self.made) = (0, 0);
self.last = None;
self.previous = None;
self.hand.clear();
if let Some(motion) = &mut self.motion {
motion.reset();
}
if let Some(gpu) = &mut self.gpu {
gpu.reset = true;
}
}
/// The hand for the made frame, appended to `out`: each vertex halfway between where
/// it was on screen last frame and where it is now (the hand swings and bobs on its
/// own, so the world's interpolation can't move it), in this frame's space
/// (`view`: this frame's bob and view). This frame's hand where the two differ in
/// shape (another item, the first frame).
pub fn halfway_hand(
&mut self,
hand: &[crate::mesher::Vertex],
view: Mat4,
out: &mut Vec<crate::mesher::Vertex>,
) {
let same = self.hand.len() == hand.len()
&& self
.hand
.iter()
.zip(hand)
.all(|(a, b)| a.layer == b.layer && a.uv == b.uv);
if same {
let back = view.inverse();
out.extend(self.hand.iter().zip(hand).map(|(before, now)| {
let then = self.hand_view.transform_point3(before.pos.into());
let mid = (then + view.transform_point3(now.pos.into())) * 0.5;
crate::mesher::Vertex {
pos: back.transform_point3(mid).into(),
..*now
}
}));
} else {
out.extend_from_slice(hand);
}
self.hand.clear();
self.hand.extend_from_slice(hand);
self.hand_view = view;
}
/// This frame's entity vertices (relative to `camera`) and the entity ids of their
/// spans (`Frame::entity_motion`), for their own motion vectors; `atlas`: the
/// entities' textures. Before `generate`, every frame.
pub fn track_entities(
&mut self,
device: &wgpu::Device,
atlas: &wgpu::TextureView,
entities: &[crate::mesher::Vertex],
spans: &[(u32, u64)],
camera: DVec3,
) {
self.motion
.get_or_insert_with(|| motion::ObjectMotion::new(device, atlas.clone()))
.track(entities, spans, camera);
}
/// This frame's unjittered clip space to the last frame's, from their `view_proj`s
/// (relative to `camera`); `None` on the first frame.
pub fn camera_motion(&mut self, view_proj: Mat4, camera: DVec3) -> Option<Mat4> {
let current = view_proj.as_dmat4();
let previous = self.previous.replace((current, camera));
previous.map(|(p, pc)| super::world_post::reprojection(p, pc, current, camera).as_mat4())
}
/// Frames to draw the real and the made frame into, from the present thread's
/// (started for this `config`'s surface) or, offscreen, the offscreen target and
/// one of its own. `None` once the present thread failed or died: it is stopped and
/// frame generation `paused`.
pub fn frames(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
surface: Option<&Arc<wgpu::Surface<'static>>>,
offscreen: Option<&wgpu::Texture>,
window: &Arc<winit::window::Window>,
config: &wgpu::SurfaceConfiguration,
) -> Option<(wgpu::Texture, wgpu::Texture)> {
let Some(surface) = surface else {
let real = offscreen.cloned().unwrap_or_else(|| frame(device, config));
if self
.offscreen_generated
.as_ref()
.is_none_or(|t| t.size() != real.size())
{
self.offscreen_generated = Some(frame(device, config));
}
let generated = self.offscreen_generated.clone().expect("made above");
return Some((real, generated));
};
if self.presenter.as_ref().is_some_and(|p| p.config != *config) {
self.presenter = None;
}
let presenter = self.presenter.get_or_insert_with(|| {
Presenter::start(device, queue, surface.clone(), window.clone(), config)
});
// A closed channel: the thread died.
let pair = if presenter.failed() {
None
} else {
presenter.free.recv().ok().zip(presenter.free.recv().ok())
};
if pair.is_none() {
self.stop();
self.paused = true;
}
pair
}
/// The world's target at the screen's size this frame, where `Post::finish` draws.
pub fn world(
&mut self,
device: &wgpu::Device,
post: &super::world_post::Post,
config: &wgpu::SurfaceConfiguration,
) -> wgpu::TextureView {
let size = [config.width, config.height];
if self.gpu.as_ref().is_none_or(|g| g.size != size) {
let world = device
.create_texture(&wgpu::TextureDescriptor {
label: Some("Frame generation world"),
size: wgpu::Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: config.format.remove_srgb_suffix(),
// COPY_SRC: the interpolation keeps it as the previous frame.
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
})
.create_view(&Default::default());
let interpolation = interpolation::Interpolation::new(device, size);
self.gpu = Some(Gpu {
size,
flow: optical_flow::OpticalFlow::new(device, size),
world_group: post.bind(device, &world),
output_group: post.bind(device, interpolation.output()),
interpolation,
world,
reset: true,
});
}
self.gpu.as_ref().expect("made above").world.clone()
}
/// Copy the world (`world`) onto `screen` for the real frame, and draw the made
/// frame's world onto `generated`. Returns false when there is no made frame (the
/// first after a reset): `generated` then holds this frame's world too.
#[allow(clippy::too_many_arguments)]
pub fn generate(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
post: &super::world_post::Post,
screen: &wgpu::TextureView,
generated: &wgpu::TextureView,
camera: &Camera,
) -> bool {
let gpu = self.gpu.as_mut().expect("world() ran this frame");
post.finish_with(encoder, screen, &gpu.world_group, None);
let reset = gpu.reset || camera.reproject.is_none();
let reproject = camera.reproject.unwrap_or(Mat4::IDENTITY);
let object_motion = self
.motion
.as_mut()
.expect("track_entities() ran this frame")
.draw(
device,
queue,
encoder,
&camera.depth,
camera.render_size,
camera.jittered,
camera.view_proj,
reproject * camera.view_proj,
);
gpu.flow.dispatch(device, queue, encoder, &gpu.world, reset);
gpu.interpolation.dispatch(
device,
queue,
encoder,
&interpolation::Inputs {
color: &gpu.world,
depth: &camera.depth,
render_size: camera.render_size,
jitter: camera.jitter,
reproject,
object_motion: &object_motion,
near: camera.near,
far: camera.far,
fov_y: camera.fov_y,
reset,
flow_vectors: gpu.flow.vectors(),
scene_change: gpu.flow.scene_change(),
},
);
gpu.reset = false;
if debug() == Some("motion") {
post.finish_with(encoder, generated, &post.bind(device, &object_motion), None);
return true;
}
if reset {
post.finish_with(encoder, generated, &gpu.world_group, None);
} else {
post.finish_with(encoder, generated, &gpu.output_group, None);
}
!reset
}
/// Hand this frame's pair to the present thread, after the frame's submission: the
/// made frame (when `made`) goes first.
pub fn present(&mut self, real: wgpu::Texture, generated: wgpu::Texture, made: bool) {
let now = Instant::now();
if let Some(last) = self.last {
// A real frame's time, smoothed: the made frame shows for half of it.
let dt = (now - last).min(Duration::from_millis(250));
self.interval = self.interval.mul_f32(0.9) + dt.mul_f32(0.1);
}
self.last = Some(now);
self.real += 1;
self.made += u32::from(made && !generated_only());
if now - self.shown_since >= Duration::from_millis(500) {
self.made_share = Some(self.made as f32 / self.real as f32);
(self.real, self.made, self.shown_since) = (0, 0, now);
}
let Some(send) = self.presenter.as_ref().and_then(|p| p.send.as_ref()) else {
return;
};
let pair = if generated_only() {
Pair {
first: None,
second: generated,
spare: Some(real),
interval: self.interval,
}
} else if made {
Pair {
first: Some(generated),
second: real,
spare: None,
interval: self.interval,
}
} else {
Pair {
first: None,
second: real,
spare: Some(generated),
interval: self.interval,
}
};
let _ = send.send(pair);
}
}
/// A frame to draw into, like the swapchain's.
fn frame(device: &wgpu::Device, config: &wgpu::SurfaceConfiguration) -> wgpu::Texture {
device.create_texture(&wgpu::TextureDescriptor {
label: Some("Frame generation frame"),
size: wgpu::Extent3d {
width: config.width,
height: config.height,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: config.format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC
| wgpu::TextureUsages::COPY_DST,
view_formats: &config.view_formats,
})
}
/// One real frame's frames for the present thread: `first` (the made frame) now,
/// `second` (the real one) half an `interval` later; `spare` goes straight back.
struct Pair {
first: Option<wgpu::Texture>,
second: wgpu::Texture,
spare: Option<wgpu::Texture>,
interval: Duration,
}
/// The thread that copies frames to the swapchain and presents them on time.
struct Presenter {
config: wgpu::SurfaceConfiguration,
send: Option<mpsc::SyncSender<Pair>>,
free: mpsc::Receiver<wgpu::Texture>,
/// The surface gave something other than a frame: the renderer takes it back.
failed: Arc<AtomicBool>,
thread: Option<thread::JoinHandle<()>>,
}
impl Presenter {
fn start(
device: &wgpu::Device,
queue: &wgpu::Queue,
surface: Arc<wgpu::Surface<'static>>,
window: Arc<winit::window::Window>,
config: &wgpu::SurfaceConfiguration,
) -> Self {
// One pair waiting at most: the renderer then waits as for a swapchain image.
let (send, receive) = mpsc::sync_channel::<Pair>(1);
let (give, free) = mpsc::channel();
for _ in 0..FRAMES {
let _ = give.send(frame(device, config));
}
let (device, queue) = (device.clone(), queue.clone());
let vsync = config.present_mode == wgpu::PresentMode::Fifo;
let failed = Arc::new(AtomicBool::new(false));
let failed_here = failed.clone();
let thread = thread::Builder::new()
.name("Frame generation present".into())
.spawn(move || {
let show = |texture: &wgpu::Texture| {
if failed_here.load(Ordering::Relaxed) {
return;
}
// Anything but a frame (outdated, lost, occluded...): the renderer
// stops this thread and acquires itself, handling it as it does
// without frame generation. A suboptimal frame still shows.
let target = match surface.get_current_texture() {
wgpu::CurrentSurfaceTexture::Success(t) => t,
wgpu::CurrentSurfaceTexture::Suboptimal(t) => {
failed_here.store(true, Ordering::Relaxed);
t
}
_ => {
failed_here.store(true, Ordering::Relaxed);
return;
}
};
let mut encoder = device.create_command_encoder(&Default::default());
encoder.copy_texture_to_texture(
texture.as_image_copy(),
target.texture.as_image_copy(),
target.texture.size(),
);
queue.submit([encoder.finish()]);
if vsync {
window.pre_present_notify();
}
queue.present(target);
};
while let Ok(pair) = receive.recv() {
if let Some(first) = pair.first {
let shown = Instant::now();
show(&first);
let _ = give.send(first);
// ponytail: sleep pacing; a spin for the last half millisecond
// if it shows uneven on some systems.
let due = shown + pair.interval / 2;
if let Some(wait) = due.checked_duration_since(Instant::now()) {
thread::sleep(wait);
}
}
show(&pair.second);
let _ = give.send(pair.second);
if let Some(spare) = pair.spare {
let _ = give.send(spare);
}
}
})
.expect("spawn the frame generation present thread");
Self {
config: config.clone(),
send: Some(send),
free,
failed,
thread: Some(thread),
}
}
/// The surface failed it or the thread died (a panic).
fn failed(&self) -> bool {
self.failed.load(Ordering::Relaxed) || self.thread.as_ref().is_none_or(|t| t.is_finished())
}
}
impl Drop for Presenter {
fn drop(&mut self) {
self.send = None;
if let Some(thread) = self.thread.take() {
let _ = thread.join();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mesher::Vertex;
use glam::Vec3;
fn vertex(pos: Vec3) -> Vertex {
Vertex {
pos: pos.into(),
..bytemuck::Zeroable::zeroed()
}
}
/// A swing lands halfway; a hand still on screen while the camera turns stays still;
/// another item shows as it is.
#[test]
fn the_made_frame_s_hand_is_halfway_on_screen() {
let mut fg = FrameGen::new();
let mut out = Vec::new();
fg.halfway_hand(
&[vertex(Vec3::new(0.0, 0.0, -1.0))],
Mat4::IDENTITY,
&mut out,
);
fg.halfway_hand(
&[vertex(Vec3::new(0.4, 0.0, -1.0))],
Mat4::IDENTITY,
&mut out,
);
assert!((Vec3::from(out[1].pos) - Vec3::new(0.2, 0.0, -1.0)).length() < 1e-6);
// The camera turned: the vertex turned with it, the same on screen.
let turned = Mat4::from_rotation_y(0.3);
let on_screen = Vec3::new(0.4, 0.0, -1.0);
let pos = turned.inverse().transform_point3(on_screen);
fg.halfway_hand(&[vertex(pos)], turned, &mut out);
assert!((Vec3::from(out[2].pos) - pos).length() < 1e-6);
let mut other = vertex(Vec3::ZERO);
other.uv = [0.5, 0.5];
fg.halfway_hand(&[other], turned, &mut out);
assert_eq!(out[3].pos, [0.0; 3]);
}
}
@@ -470,7 +470,7 @@ pub(super) mod tests {
/// A device with WebGPU's default limits (what every GPU wgpu runs on has), or `None`
/// without a GPU.
pub fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
pollster::block_on(adapter.request_device(&Default::default())).ok()
}
@@ -0,0 +1,764 @@
//! Phase 1's solid section draws made on the GPU (gpu_lists.wgsl).
//!
//! Each frame the render thread walked every section phase 1 draws through
//! `mesher::Sides::visible_from` to find the runs of its quads that face the eye,
//! and pushed one draw per run. Here it pushes one record per section instead:
//! its row (where its quads start and how `Sides` sorted them, uploaded with its
//! mesh), the call each of its parts draws in and its camera-relative origin. The
//! GPU finds the same runs and writes, per call (one per pass group and arena
//! page), either the runs' indices into the flat draws' index list
//! (`Out::Indices`, drawn with `draw_indexed_indirect`), or one indirect draw per
//! run (`Out::Draws`, drawn with `multi_draw_indexed_indirect_count`). Calls take
//! room for the most their sections may write; the GPU counts what they wrote.
//!
//! Solid draws write depth and are tested against it, so their order within a
//! call does not change the picture.
use crate::mesher::{Part, Sides};
/// Words per row: first vertex, `Sides::gpu_row`, padding.
const ROW: usize = 24;
/// Records each workgroup of `sides_draws` takes.
const GROUP: u32 = 64;
/// Most workgroups along one dimension of a dispatch.
const GROUP_LIMIT: u32 = 65535;
/// What the GPU writes for a frame's calls.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum Out {
/// Indices into the flat draws' list (`flat::Flat`), one indirect draw per call.
Indices,
/// `DrawIndexedIndirectArgs` per run, over the quad index pattern, with the
/// section's instance; per call a count for `multi_draw_indexed_indirect_count`.
Draws,
}
/// A part of a section in a record: `Part` as gpu_lists.wgsl names it.
fn part_code(part: Part) -> u32 {
match part {
Part::All => 0,
Part::Opaque => 1,
Part::Cutout => 2,
}
}
/// One of the frame's calls: what pass `group` draws of arena `page`, from
/// `first` (an index, or a draw) with room for `count`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) struct ListCall {
pub group: u8,
pub page: u32,
pub first: u32,
pub count: u32,
}
pub(super) struct GpuLists {
out: Out,
pipeline: wgpu::ComputePipeline,
layout: wgpu::BindGroupLayout,
/// Per id: its first vertex and `Sides::gpu_row` (`ROW` words).
rows: wgpu::Buffer,
row_capacity: u64,
free_ids: Vec<u32>,
next_id: u32,
/// The frame's sections: id, the call and part of each of its parts, its
/// number of parts plus its instance times 4; then its origin.
records: Vec<[u32; 8]>,
record_buffer: wgpu::Buffer,
record_capacity: u64,
calls: Vec<ListCall>,
/// `calls`' index by page and group in the band of distance the last
/// section listed is in (`u32::MAX`: none yet).
call_of: Vec<u32>,
band: u32,
call_data: Vec<[u32; 5]>,
/// Per call `DrawIndexedIndirectArgs` (`Out::Indices`), or its count of
/// draws first (`Out::Draws`); the GPU adds what it writes to the first word.
call_buffer: wgpu::Buffer,
call_capacity: u64,
view: wgpu::Buffer,
/// `Out::Draws`' draws.
draws: wgpu::Buffer,
draw_capacity: u64,
group: Option<(wgpu::BindGroup, u32)>,
/// The room the frame's calls take (indices, or draws), and the most they may.
room: u64,
cap: u64,
}
fn buffer(
device: &wgpu::Device,
label: &str,
size: u64,
usage: wgpu::BufferUsages,
) -> wgpu::Buffer {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some(label),
size,
// COPY_SRC: rows move to a larger buffer; tests read calls and draws back.
usage: usage | wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
})
}
fn entry(binding: u32, ty: wgpu::BufferBindingType) -> wgpu::BindGroupLayoutEntry {
wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer {
ty,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}
}
impl GpuLists {
pub fn new(device: &wgpu::Device, out: Out) -> Self {
let read = wgpu::BufferBindingType::Storage { read_only: true };
let write = wgpu::BufferBindingType::Storage { read_only: false };
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Section lists"),
entries: &[
entry(0, read),
entry(1, read),
entry(2, wgpu::BufferBindingType::Uniform),
entry(3, write),
entry(4, write),
],
});
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Section lists"),
source: wgpu::ShaderSource::Wgsl(include_str!("gpu_lists.wgsl").into()),
});
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("Section lists"),
layout: Some(
&device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Section lists"),
bind_group_layouts: &[Some(&layout)],
..Default::default()
}),
),
module: &module,
entry_point: Some(match out {
Out::Indices => "flatten_sides",
Out::Draws => "sides_draws",
}),
compilation_options: Default::default(),
cache: None,
});
let storage = wgpu::BufferUsages::STORAGE;
let indirect = wgpu::BufferUsages::INDIRECT | storage;
Self {
out,
pipeline,
layout,
rows: buffer(device, "Section rows", ROW as u64 * 4, storage),
row_capacity: 1,
free_ids: Vec::new(),
next_id: 0,
records: Vec::new(),
record_buffer: buffer(device, "Sections listed", 32, storage),
record_capacity: 1,
calls: Vec::new(),
call_of: Vec::new(),
band: 0,
call_data: Vec::new(),
call_buffer: buffer(device, "Section list calls", 20, indirect),
call_capacity: 1,
view: buffer(
device,
"Section lists' eye",
16,
wgpu::BufferUsages::UNIFORM,
),
draws: buffer(device, "Section list draws", 20, indirect),
draw_capacity: 1,
group: None,
room: 0,
cap: 0,
}
}
/// A mesh uploaded from vertex `start` of its page, its solid quads sorted as
/// `sides` says: its id, whose row is written through `uploads` in `encoder`.
pub fn add(
&mut self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
uploads: &mut super::upload::Uploads,
start: u32,
sides: &Sides,
) -> u32 {
let id = self.free_ids.pop().unwrap_or_else(|| {
self.next_id += 1;
self.next_id - 1
});
self.set(device, encoder, uploads, id, start, sides);
id
}
pub fn remove(&mut self, id: u32) {
self.free_ids.push(id);
}
/// Mesh `id` now starts at vertex `start` (moved to another page).
pub fn set(
&mut self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
uploads: &mut super::upload::Uploads,
id: u32,
start: u32,
sides: &Sides,
) {
let need = u64::from(id) + 1;
if need > self.row_capacity {
let capacity = need.next_power_of_two().max(1024);
let rows = buffer(
device,
"Section rows",
capacity * ROW as u64 * 4,
wgpu::BufferUsages::STORAGE,
);
// Earlier rows: uploads record their copies in `encoder`, so this one follows them.
encoder.copy_buffer_to_buffer(
&self.rows,
0,
&rows,
0,
self.row_capacity * ROW as u64 * 4,
);
self.rows = rows;
self.row_capacity = capacity;
}
let mut row = [0u32; ROW];
row[0] = start;
row[1..23].copy_from_slice(&sides.gpu_row());
uploads.buffer(
encoder,
&self.rows,
u64::from(id) * ROW as u64 * 4,
bytemuck::cast_slice(&row),
);
}
/// Starts the frame's lists.
pub fn clear(&mut self, device: &wgpu::Device) {
self.records.clear();
self.calls.clear();
self.call_of.fill(u32::MAX);
self.band = 0;
self.room = 0;
self.group = None;
// A quarter of a binding: indices share theirs with the CPU's draws.
let binding = device.limits().max_storage_buffer_binding_size;
self.cap = match self.out {
Out::Indices => binding / 16,
Out::Draws => binding / 20 / 4,
};
}
/// Section `id`, in `page`, at `origin` from the camera and `distance`
/// (squared, as bits) from it, instance `instance` of the frame's list:
/// each of `parts` (pass group, part) draws the quads of the part that face
/// the eye. False, listing nothing, when the frame's lists hold all the room
/// they may: the caller draws it.
///
/// Sections come nearest first. Each band of distance gets calls of its own,
/// drawn in that order, as the CPU's lists keep them (`page_runs`): within
/// a call the GPU writes sections in no set order, and cutout quads, which
/// tile GPUs can't hide before shading, then draw mostly front to back.
pub fn list(
&mut self,
id: u32,
page: u32,
distance: u64,
sides: &Sides,
parts: &[(u8, Part)],
origin: [f32; 3],
instance: u32,
) -> bool {
let parts = &parts[..parts.len().min(2)];
let mut counts = [0; 2];
for (count, &(_, part)) in counts.iter_mut().zip(parts) {
*count = match self.out {
Out::Indices => sides.part_quads(part) * 6,
Out::Draws => sides.part_runs(part),
};
}
let room = u64::from(counts[0]) + u64::from(counts[1]);
if self.room + room > self.cap || instance >= 1 << 30 {
return false;
}
self.room += room;
let band = super::page_runs::band(distance) as u32;
if band != self.band {
self.band = band;
self.call_of.fill(u32::MAX);
}
let mut record = [id, 0, 0, parts.len() as u32 | instance << 2, 0, 0, 0, 0];
for (k, &(group, part)) in parts.iter().enumerate() {
let key = page as usize * 2 + usize::from(group.min(1));
if self.call_of.len() <= key {
self.call_of.resize(key + 1, u32::MAX);
}
if self.call_of[key] == u32::MAX {
self.call_of[key] = self.calls.len() as u32;
self.calls.push(ListCall {
group,
page,
first: 0,
count: 0,
});
}
let call = self.call_of[key];
self.calls[call as usize].count += counts[k];
record[1 + k] = call | part_code(part) << 28;
}
for (to, o) in record[4..7].iter_mut().zip(origin) {
*to = o.to_bits();
}
self.records.push(record);
true
}
/// The sections listed this frame.
pub fn listed(&self) -> usize {
self.records.len()
}
/// The frame's calls, with their rooms once laid out (`rooms`).
pub fn calls(&self) -> &[ListCall] {
&self.calls
}
/// Lays the calls' rooms out one after the other from the start: the
/// room they take (indices, or draws).
pub fn rooms(&mut self) -> u64 {
let mut room = 0u64;
for call in &mut self.calls {
call.first = room as u32;
room += u64::from(call.count);
}
room
}
/// Stages the frame's sections, calls and eye (camera-relative; none: every
/// quad faces it), and binds them for `dispatch` writing into `indices`
/// (`Out::Indices`) or into the lists' own draws. After `rooms`.
pub fn prepare(
&mut self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
uploads: &mut super::upload::Uploads,
indices: Option<&wgpu::Buffer>,
eye: Option<[f32; 3]>,
) {
self.group = None;
let records = self.records.len() as u64;
if records == 0 {
return;
}
if records > self.record_capacity {
self.record_capacity = records.next_power_of_two();
self.record_buffer = buffer(
device,
"Sections listed",
self.record_capacity * 32,
wgpu::BufferUsages::STORAGE,
);
}
uploads.buffer(
encoder,
&self.record_buffer,
0,
bytemuck::cast_slice(&self.records),
);
self.call_data.clear();
self.call_data
.extend(self.calls.iter().map(|c| [0, 1, c.first, 0, 0]));
let calls = self.call_data.len() as u64;
let indirect = wgpu::BufferUsages::INDIRECT | wgpu::BufferUsages::STORAGE;
if calls > self.call_capacity {
self.call_capacity = calls.next_power_of_two();
self.call_buffer = buffer(
device,
"Section list calls",
self.call_capacity * 20,
indirect,
);
}
uploads.buffer(
encoder,
&self.call_buffer,
0,
bytemuck::cast_slice(&self.call_data),
);
let room = self
.calls
.last()
.map_or(0, |c| u64::from(c.first + c.count));
if self.out == Out::Draws && room > self.draw_capacity {
self.draw_capacity = room.next_power_of_two();
self.draws = buffer(
device,
"Section list draws",
self.draw_capacity * 20,
indirect,
);
}
let [x, y, z] = eye.unwrap_or_default();
let view = [x, y, z, if eye.is_some() { 1.0 } else { 0.0 }];
uploads.buffer(encoder, &self.view, 0, bytemuck::cast_slice(&view));
let out = match self.out {
Out::Indices => indices.expect("flat draws' index list"),
Out::Draws => &self.draws,
};
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Section lists"),
layout: &self.layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
buffer: &self.record_buffer,
offset: 0,
size: wgpu::BufferSize::new(records * 32),
}),
},
wgpu::BindGroupEntry {
binding: 1,
resource: self.rows.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 2,
resource: self.view.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 3,
resource: self.call_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 4,
resource: out.as_entire_binding(),
},
],
});
self.group = Some((group, records as u32));
}
/// Writes the frame's lists (after `prepare`).
pub fn dispatch(&self, pass: &mut wgpu::ComputePass<'_>) {
let Some((group, records)) = &self.group else {
return;
};
let groups = match self.out {
Out::Indices => *records,
Out::Draws => records.div_ceil(GROUP),
};
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, group, &[]);
pass.dispatch_workgroups(groups.min(GROUP_LIMIT), groups.div_ceil(GROUP_LIMIT), 1);
}
pub fn out(&self) -> Out {
self.out
}
/// Whether `dispatch` writes this frame's lists.
pub fn prepared(&self) -> bool {
self.group.is_some()
}
/// Draws call `k` (`Out::Indices`) with a pipeline, its page's vertices and
/// the index list bound.
pub fn draw_indexed(&self, pass: &mut wgpu::RenderPass<'_>, k: usize) {
pass.draw_indexed_indirect(&self.call_buffer, k as u64 * 20);
}
/// Draws call `k` (`Out::Draws`) with a pipeline, its page's vertices, the
/// quad indices and the frame's instances bound.
pub fn multi_draw(&self, pass: &mut wgpu::RenderPass<'_>, k: usize) {
let call = self.calls[k];
pass.multi_draw_indexed_indirect_count(
&self.draws,
u64::from(call.first) * 20,
&self.call_buffer,
k as u64 * 20,
call.count,
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mesher::{Part, Sides};
use wgpu::util::DeviceExt;
struct Section {
sides: Sides,
origin: [f32; 3],
start: u32,
page: u32,
parts: &'static [(u8, Part)],
}
/// Random sorts: some parts empty, grouped or not, split or not, planes in
/// and out of a section, each listed in one of the three ways it can be.
fn sections(count: usize) -> Vec<Section> {
let mut seed = 0x2545_f491_4f6c_dd1du64;
let mut next = |n: u32| {
seed = seed
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1_442_695_040_888_963_407);
((seed >> 33) % u64::from(n)) as u32
};
(0..count)
.map(|_| {
let mut ends = [0; 14];
let mut end = 0;
for e in &mut ends {
if next(3) > 0 {
end += next(40);
}
*e = end;
}
let planes = std::array::from_fn(|_| next(17) as f32 + next(16) as f32 / 16.0);
Section {
sides: Sides::from_parts(ends, next(2) == 0, next(4) > 0, planes),
origin: [0, 1, 2].map(|_| next(129) as f32 - 64.0),
start: next(10_000) * 4,
page: next(3),
parts: match next(3) {
0 => &[(0, Part::All)],
1 => &[(0, Part::Opaque), (1, Part::Cutout)],
_ => &[(1, Part::All)],
},
}
})
.collect()
}
/// The runs the CPU finds for a section's part, as `Gfx` drew them.
fn cpu_runs(s: &Section, part: Part, eye: Option<[f32; 3]>) -> Vec<(u32, u32)> {
let mut runs = Vec::new();
let draw = |first, quads| runs.push((first, quads));
match eye {
Some(eye) => {
let eye = [0, 1, 2].map(|a| eye[a] - s.origin[a]);
s.sides.visible_from(eye, eye[1], part, draw);
}
None => s.sides.every(part, draw),
}
runs
}
/// Lists `sections` for `out` from each of a few eyes; per eye, the
/// lists, the calls' words and the written words.
fn run(
out: Out,
sections: &[Section],
) -> Option<Vec<(Option<[f32; 3]>, GpuLists, Vec<u32>, Vec<u32>)>> {
let (device, queue) = device()?;
let mut results = Vec::new();
for eye in [
Some([3.5f32, 70.25, -12.0]),
Some([-40.0, -3.0, 50.5]),
None,
] {
let mut lists = GpuLists::new(&device, out);
lists.clear(&device);
let mut rows = vec![0u32; sections.len() * ROW];
for (id, s) in sections.iter().enumerate() {
rows[id * ROW] = s.start;
rows[id * ROW + 1..id * ROW + 23].copy_from_slice(&s.sides.gpu_row());
// Nearest first, over several bands.
let distance = (id as f64 * 2.0).powi(2).to_bits();
let instance = id as u32 + 7;
assert!(lists.list(
id as u32, s.page, distance, &s.sides, s.parts, s.origin, instance
));
}
let room = lists.rooms();
let init = |contents: &[u8], usage| {
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
contents,
usage,
})
};
let storage = wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC;
lists.rows = init(bytemuck::cast_slice(&rows), storage);
let words = match out {
Out::Indices => room,
Out::Draws => room * 5,
};
let indices = init(&vec![0xffu8; words as usize * 4], storage);
let mut encoder = device.create_command_encoder(&Default::default());
let mut uploads = super::super::upload::Uploads::new(&device);
lists.prepare(&device, &mut encoder, &mut uploads, Some(&indices), eye);
uploads.finish(&encoder);
{
let mut pass = encoder.begin_compute_pass(&Default::default());
lists.dispatch(&mut pass);
}
queue.submit([encoder.finish()]);
let written = match out {
Out::Indices => &indices,
Out::Draws => &lists.draws,
};
let read_back = |buffer: &wgpu::Buffer, words: u64| -> Vec<u32> {
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: words * 4,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut encoder = device.create_command_encoder(&Default::default());
encoder.copy_buffer_to_buffer(buffer, 0, &read, 0, words * 4);
queue.submit([encoder.finish()]);
read.slice(..).map_async(wgpu::MapMode::Read, |_| {});
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
bytemuck::cast_slice(&read.slice(..).get_mapped_range().unwrap()).to_vec()
};
let calls = read_back(&lists.call_buffer, lists.calls.len() as u64 * 5);
let words = read_back(written, words);
results.push((eye, lists, calls, words));
}
Some(results)
}
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
eprintln!("no GPU adapter: skipped");
return None;
};
let device = pollster::block_on(adapter.request_device(&Default::default())).ok();
if device.is_none() {
eprintln!("no GPU device: skipped");
}
device
}
/// The call section `i`'s `k`th part draws in, as listed.
fn call_of(lists: &GpuLists, i: usize, k: usize) -> usize {
(lists.records[i][1 + k] & 0x0fff_ffff) as usize
}
/// Each band of distance and page gets a pass's calls of its own, in the
/// order sections come (nearest first), and sections of one share them.
#[test]
fn calls_go_by_band_then_page_nearest_first() {
let Some((device, _)) = device() else {
return;
};
let mut lists = GpuLists::new(&device, Out::Indices);
lists.clear(&device);
let sides = Sides::unsorted(4);
let parts = &[(0, Part::Opaque), (1, Part::Cutout)];
for (page, blocks) in [(1, 10.0), (0, 20.0), (1, 30.0), (1, 100.0), (0, 130.0)] {
let distance = f64::powi(blocks, 2).to_bits();
assert!(lists.list(0, page, distance, &sides, parts, [0.0; 3], 0));
}
let calls: Vec<_> = lists
.calls()
.iter()
.map(|c| (c.group, c.page, c.count))
.collect();
assert_eq!(
calls,
[
(0, 1, 48),
(1, 1, 48),
(0, 0, 24),
(1, 0, 24),
(0, 1, 24),
(1, 1, 24),
(0, 0, 24),
(1, 0, 24)
]
);
}
/// `flatten_sides` writes, in each call's room, the indices of exactly the
/// runs `Sides::visible_from` finds for each listed section's parts (in
/// some order: workgroups take room as they come), and counts them.
#[test]
fn flatten_sides_writes_the_runs_the_cpu_finds() {
let sections = sections(200);
let Some(results) = run(Out::Indices, &sections) else {
return;
};
for (eye, lists, calls, out) in results {
let mut expected = vec![Vec::new(); lists.calls.len()];
for (i, s) in sections.iter().enumerate() {
for (k, &(_, part)) in s.parts.iter().enumerate() {
let call = call_of(&lists, i, k);
for (first, quads) in cpu_runs(s, part, eye) {
for k in 0..quads * 6 {
let corner = [0, 1, 2, 0, 2, 3][(k % 6) as usize];
expected[call].push(s.start + first * 4 + (k / 6) * 4 + corner);
}
}
}
}
for (k, call) in lists.calls.iter().enumerate() {
let count = calls[k * 5] as usize;
assert_eq!(
count,
expected[k].len(),
"call {k}'s index count, eye {eye:?}"
);
assert_eq!(calls[k * 5 + 2], call.first);
let mut got = out[call.first as usize..call.first as usize + count].to_vec();
got.sort_unstable();
expected[k].sort_unstable();
assert_eq!(got, expected[k], "call {k}'s indices, eye {eye:?}");
}
}
}
/// `sides_draws` writes, in each call's room, one draw per run the CPU finds
/// (as the CPU lists them: the run's indices over the quad pattern, from its
/// first vertex, with the section's instance), and counts them; the room
/// holds them all.
#[test]
fn sides_draws_writes_a_draw_per_run_the_cpu_finds() {
let sections = sections(200);
let Some(results) = run(Out::Draws, &sections) else {
return;
};
for (eye, lists, calls, out) in results {
let mut expected = vec![Vec::new(); lists.calls.len()];
for (i, s) in sections.iter().enumerate() {
for (k, &(_, part)) in s.parts.iter().enumerate() {
let call = call_of(&lists, i, k);
for (first, quads) in cpu_runs(s, part, eye) {
expected[call].push([quads * 6, 1, 0, s.start + first * 4, i as u32 + 7]);
}
}
}
for (k, call) in lists.calls.iter().enumerate() {
let count = calls[k * 5] as usize;
assert_eq!(
count,
expected[k].len(),
"call {k}'s draw count, eye {eye:?}"
);
assert!(count as u32 <= call.count, "call {k} wrote past its room");
let first = call.first as usize * 5;
let mut got: Vec<[u32; 5]> = out[first..first + count * 5]
.chunks(5)
.map(|d| d.try_into().unwrap())
.collect();
got.sort_unstable();
expected[k].sort_unstable();
assert_eq!(got, expected[k], "call {k}'s draws, eye {eye:?}");
}
}
}
}
@@ -0,0 +1,216 @@
// Phase 1's solid section draws made on the GPU (gfx/gpu_lists.rs): per section
// listed, the runs of its quads that face the eye, found as
// `mesher::Sides::visible_from` finds them on the CPU, written into the call
// each of its parts draws in (one per pass group and arena page) after taking
// room there: their indices (`flatten_sides`, for the flat draws' index list,
// as `flatten_seen` does for occlusion culling's phase 2), or one indirect draw
// per run (`sides_draws`, for `multi_draw_indexed_indirect_count`). Solid draws
// write depth and are tested against it, so their order within a call does not
// change the picture.
struct View {
// The eye relative to the camera, and 1.0 when there is one (`sides_on`);
// without, every bucket is drawn (`Sides::every`).
eye: vec3<f32>,
has_eye: f32,
}
// Per section listed: its row's id; for each of its parts (up to two) the call
// it draws in, with the part (`PART_*`) in the top bits; its number of parts
// plus 4 times its instance. Then its origin relative to the camera.
struct Record {
words: vec4<u32>,
origin: vec4<f32>,
}
@group(0) @binding(0) var<storage, read> records: array<Record>;
// Per id, `ROW` words: its first vertex in its page, then `Sides::gpu_row`
// (14 ends, 7 planes' bits, flags).
@group(0) @binding(1) var<storage, read> rows: array<u32>;
@group(0) @binding(2) var<uniform> view: View;
// Per call five words: its count (indices, or draws), which starts at 0, then
// for `flatten_sides` the rest of its `DrawIndexedIndirectArgs`; word 2 is
// where its room starts.
@group(0) @binding(3) var<storage, read_write> calls: array<atomic<u32>>;
// Indices (`flatten_sides`) or `DrawIndexedIndirectArgs` (`sides_draws`).
@group(0) @binding(4) var<storage, read_write> out: array<u32>;
const ROW: u32 = 24u;
const LANES: u32 = 64u;
const PART_ALL: u32 = 0u;
const PART_OPAQUE: u32 = 1u;
const PART_CUTOUT: u32 = 2u;
const CALL_MASK: u32 = 0x0fffffffu;
// `sides::MARGIN`.
const MARGIN: f32 = 0.0625;
// Index `k` of a solid draw: quad k / 6's vertices 0 1 2 0 2 3.
fn quad(k: u32) -> u32 {
let c = k % 6u;
return (k / 6u) * 4u + select(select(c - 2u, 0u, c == 3u), c, c < 3u);
}
fn plane(row: u32, bucket: u32) -> f32 {
return bitcast<f32>(rows[row + 15u + bucket]);
}
// `Sides::visible_from`'s `seen`, buckets in mesh order: +X, +Z, other, +Y, -Z, -X, -Y.
fn seen(row: u32, bucket: u32, eye: vec3<f32>) -> bool {
var faces = true;
if view.has_eye != 0.0 {
switch bucket {
case 0u: { faces = eye.x > plane(row, 0u) - MARGIN; }
case 1u: { faces = eye.z > plane(row, 1u) - MARGIN; }
case 3u: { faces = eye.y > plane(row, 3u) - MARGIN; }
case 4u: { faces = eye.z < plane(row, 4u) + MARGIN; }
case 5u: { faces = eye.x < plane(row, 5u) + MARGIN; }
case 6u: { faces = eye.y < plane(row, 6u) + MARGIN; }
default: {}
}
}
return faces;
}
// A part's runs (first quad, quads): at most 7, as a run ends at a part left
// out and there are 14.
struct Runs {
n: u32,
quads: u32,
runs: array<vec2<u32>, 7>,
}
// `Sides::runs` for `part` of the mesh at `row`, seen from `eye` (relative to
// the section): empty parts do not break a run.
fn part_runs(row: u32, part: u32, eye: vec3<f32>) -> Runs {
var r: Runs;
let grouped = (rows[row + 22u] & 1u) != 0u;
let split = (rows[row + 22u] & 2u) != 0u;
var start = 0u;
var open = false;
var first = 0u;
for (var n = 0u; n < 14u; n++) {
let end = rows[row + 1u + n];
if end == start {
continue;
}
var bucket: u32;
var cutout: bool;
if grouped {
bucket = n % 7u;
cutout = n >= 7u;
} else {
bucket = n / 2u;
cutout = n % 2u == 1u;
}
let wanted = part == PART_ALL || !split
|| (part == PART_OPAQUE && !cutout) || (part == PART_CUTOUT && cutout);
let take = wanted && seen(row, bucket, eye);
if take && !open {
open = true;
first = start;
} else if !take && open {
r.runs[r.n] = vec2(first, start - first);
r.quads += start - first;
r.n++;
open = false;
}
start = end;
}
if open {
r.runs[r.n] = vec2(first, start - first);
r.quads += start - first;
r.n++;
}
return r;
}
fn record_index(group: vec3<u32>) -> u32 {
return group.y * 65535u + group.x;
}
// The eye relative to `record`'s section.
fn eye_of(record: Record) -> vec3<f32> {
return view.eye - record.origin.xyz;
}
fn part_word(record: Record, p: u32) -> u32 {
return select(record.words.z, record.words.y, p == 0u);
}
// Lane 0's runs per part, and where each part's indices start.
var<workgroup> found: array<Runs, 2>;
var<workgroup> at: array<u32, 2>;
// One workgroup per record: lane 0 finds the runs and takes room for their
// indices, then every lane writes them.
@compute @workgroup_size(64)
fn flatten_sides(@builtin(workgroup_id) group: vec3<u32>,
@builtin(local_invocation_index) lane: u32) {
let r = record_index(group);
if r >= arrayLength(&records) {
return;
}
let record = records[r];
let row = record.words.x * ROW;
let parts = record.words.w & 3u;
if lane == 0u {
let eye = eye_of(record);
for (var p = 0u; p < parts; p++) {
let word = part_word(record, p);
let runs = part_runs(row, word >> 28u, eye);
let call = (word & CALL_MASK) * 5u;
var begin = 0u;
if runs.quads > 0u {
begin = atomicLoad(&calls[call + 2u]) + atomicAdd(&calls[call], runs.quads * 6u);
}
found[p] = runs;
at[p] = begin;
}
}
workgroupBarrier();
let base = rows[row];
for (var p = 0u; p < parts; p++) {
var to = at[p];
for (var k = 0u; k < found[p].n; k++) {
let run = found[p].runs[k];
let first = base + run.x * 4u;
let count = run.y * 6u;
for (var i = lane; i < count; i += LANES) {
out[to + i] = first + quad(i);
}
to += count;
}
}
}
// One record per invocation: a draw per run, as the CPU lists them
// (`[quads * 6, 1, 0, first vertex, instance]` over the quad index pattern).
@compute @workgroup_size(64)
fn sides_draws(@builtin(workgroup_id) group: vec3<u32>,
@builtin(local_invocation_index) lane: u32) {
let r = record_index(group) * LANES + lane;
if r >= arrayLength(&records) {
return;
}
let record = records[r];
let row = record.words.x * ROW;
let parts = record.words.w & 3u;
let instance = record.words.w >> 2u;
let eye = eye_of(record);
let base = rows[row];
for (var p = 0u; p < parts; p++) {
let word = part_word(record, p);
let runs = part_runs(row, word >> 28u, eye);
if runs.n == 0u {
continue;
}
let call = (word & CALL_MASK) * 5u;
var to = (atomicLoad(&calls[call + 2u]) + atomicAdd(&calls[call], runs.n)) * 5u;
for (var k = 0u; k < runs.n; k++) {
let run = runs.runs[k];
out[to] = run.y * 6u;
out[to + 1u] = 1u;
out[to + 2u] = 0u;
out[to + 3u] = base + run.x * 4u;
out[to + 4u] = instance;
to += 5u;
}
}
}
+54 -10
View File
@@ -61,7 +61,8 @@ struct Targets {
}
/// How one tier of light sources (`mesher::hdr_tier`) shines: `share` of the headroom for
/// its brightest pixels, lifted from brightness `start` (none) to `full` (all of it).
/// its brightest pixels, lifted from brightness `start` (none) to `full` (all of it), the
/// texel's own brightness before lighting.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Tier {
pub share: f32,
@@ -148,6 +149,9 @@ pub struct Hdr {
pub ui: f32,
/// Which pixels of a light source shine (`Masks`).
pub masks: Masks,
/// HDR lighting: off while a shader pack's post or material stage runs, the pack's own, and
/// with `HORIZON_HDR_LIGHTING=0`, to compare.
pub lighting: bool,
/// Calibration's test picture while it runs.
pub calibration: Option<Calibration>,
}
@@ -268,7 +272,7 @@ impl Hdr {
let params = || {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Horizon HDR parameters"),
size: 96,
size: 64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
})
@@ -292,6 +296,7 @@ impl Hdr {
tiers: TIERS,
ui: 1.0,
masks: MASKS,
lighting: true,
calibration: None,
}
}
@@ -319,7 +324,10 @@ impl Hdr {
"Horizon HDR frame",
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
);
let world = texture("Horizon HDR world", wgpu::TextureUsages::COPY_DST);
let world = texture(
"Horizon HDR world",
wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::RENDER_ATTACHMENT,
);
let view = |t: &wgpu::Texture| t.create_view(&Default::default());
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Horizon HDR bindings"),
@@ -437,7 +445,19 @@ impl Hdr {
pass.draw(0..3, 0..1);
}
/// The world as `keep_world` kept it: where the hand's marks go (its alpha, which HDR output
/// reads for it too), and what tints the world rather than covering it.
pub fn hand_light_view(&self) -> Option<wgpu::TextureView> {
let t = self.targets.as_ref()?;
Some(t.world.create_view(&Default::default()))
}
/// Writes the finished frame to the swapchain image `target`.
/// The tiers' windows for the shaders that mark the frame (`Globals::hdr_windows`):
/// where each starts, then where each is full.
pub fn windows(&self) -> [[f32; 4]; 2] {
[self.tiers.map(|t| t.start), self.tiers.map(|t| t.full)]
}
pub fn present(
&self,
queue: &wgpu::Queue,
@@ -510,10 +530,10 @@ impl Hdr {
}
/// hdr.wgsl's `Params`.
fn params(&self, world: bool, pq: bool) -> [f32; 24] {
fn params(&self, world: bool, pq: bool) -> [f32; 16] {
let paper = self.paper_white.max(1.0);
let c = self.calibration;
let mut params = [0.0; 24];
let mut params = [0.0; 16];
params[..5].copy_from_slice(&[
paper / 80.0,
(self.peak / paper).max(1.0),
@@ -523,14 +543,17 @@ impl Hdr {
]);
params[5] = self.black.clamp(0.0, paper);
params[6] = self.ui.clamp(0.25, 1.0);
params[7] = if self.lighting {
scene_gain(self.peak, self.paper_white)
} else {
1.0
};
for (i, t) in self.tiers.iter().enumerate() {
params[8 + i] = t.share;
params[12 + i] = t.start;
params[16 + i] = t.full;
}
params[20] = c.map_or(0.0, |c| c.step as f32);
params[21] = c.map_or(0.0, |c| c.nits);
params[22] = c.map_or(0.0, |c| c.band);
params[12] = c.map_or(0.0, |c| c.step as f32);
params[13] = c.map_or(0.0, |c| c.nits);
params[14] = c.map_or(0.0, |c| c.band);
params
}
@@ -604,6 +627,12 @@ fn logo_mask(device: &wgpu::Device, queue: &wgpu::Queue) -> wgpu::TextureView {
.create_view(&Default::default())
}
/// What the strongest light multiplies a surface by, in HDR lighting: 4, which looked right
/// across biomes; less only where there is little headroom to ease into (`hdr.wgsl`).
fn scene_gain(peak: f32, paper_white: f32) -> f32 {
(peak / paper_white.max(1.0) * 1.75).clamp(1.0, 4.0)
}
#[cfg(test)]
mod tests {
use super::{Output, pick};
@@ -656,3 +685,18 @@ mod tests {
assert_eq!(pick(&caps(&[(F::Bgra8UnormSrgb, S::SRGB)]), None), None);
}
}
#[cfg(test)]
mod lighting_tests {
use super::scene_gain;
#[test]
fn the_lighting_scales_with_the_headroom() {
// Enough headroom: the full strength, whatever the paper white.
assert_eq!(scene_gain(1000.0, 430.0), 4.0);
assert_eq!(scene_gain(1060.0, 203.0), 4.0);
// Little headroom, and none.
assert_eq!(scene_gain(600.0, 400.0), 2.625);
assert_eq!(scene_gain(200.0, 480.0), 1.0);
}
}
@@ -0,0 +1,159 @@
//! The ICC profile of HDR screenshots, for programs that don't read PNG's `cICP`: BT.2020
//! primaries and the PQ curve, with HDR reference white (203 nits) as the profile's white.
//! An ICC profile has no values above white, so those programs show a correct SDR picture
//! with its highlights clipped, where without it they took PQ's values for sRGB's and showed
//! them washed out. Programs that read `cICP` ignore this (PNG's third edition).
/// HDR reference white in nits (ITU-R BT.2408): where the curve reaches the profile's white.
const WHITE: f64 = 203.0;
/// PQ's values from 0 to 1, in the curve's steps.
const STEPS: usize = 4096;
/// BT.2020's primaries, adapted to the profile connection space's D50 (Bradford).
const RED: [f64; 3] = [0.67348, 0.27904, -0.00193];
const GREEN: [f64; 3] = [0.16567, 0.67534, 0.02998];
const BLUE: [f64; 3] = [0.12505, 0.04561, 0.79685];
const D50: [f64; 3] = [0.9642, 1.0, 0.8249];
/// PQ's EOTF (SMPTE ST 2084): a signal from 0 to 1 in nits.
fn pq_nits(signal: f64) -> f64 {
let (m1, m2) = (2610.0 / 16384.0, 2523.0 / 32.0);
let (c1, c2, c3) = (3424.0 / 4096.0, 2413.0 / 128.0, 2392.0 / 128.0);
let p = signal.powf(1.0 / m2);
10_000.0 * ((p - c1).max(0.0) / (c2 - c3 * p)).powf(1.0 / m1)
}
fn s15_fixed16(v: f64) -> [u8; 4] {
((v * 65536.0).round() as i32).to_be_bytes()
}
fn xyz(v: [f64; 3]) -> Vec<u8> {
let mut tag = b"XYZ \0\0\0\0".to_vec();
tag.extend(v.iter().flat_map(|&c| s15_fixed16(c)));
tag
}
fn curve() -> Vec<u8> {
let mut tag = b"curv\0\0\0\0".to_vec();
tag.extend((STEPS as u32).to_be_bytes());
for i in 0..STEPS {
let y = (pq_nits(i as f64 / (STEPS - 1) as f64) / WHITE).min(1.0);
tag.extend(((y * 65535.0).round() as u16).to_be_bytes());
}
tag
}
/// `textDescriptionType` (ICC v2), with its empty Unicode and ScriptCode parts.
fn description(text: &str) -> Vec<u8> {
let mut tag = b"desc\0\0\0\0".to_vec();
tag.extend((text.len() as u32 + 1).to_be_bytes());
tag.extend(text.as_bytes());
tag.extend([0; 1 + 4 + 4 + 2 + 1 + 67]);
tag
}
/// An ICC v2 matrix-shaper display profile.
pub(super) fn profile() -> Vec<u8> {
let trc = curve();
let copyright = b"text\0\0\0\0Public domain\0".to_vec();
let tags: [(&[u8; 4], &[u8]); 9] = [
(b"desc", &description("BT.2020 PQ, 203 nits = white")),
(b"cprt", &copyright),
(b"wtpt", &xyz(D50)),
(b"rXYZ", &xyz(RED)),
(b"gXYZ", &xyz(GREEN)),
(b"bXYZ", &xyz(BLUE)),
(b"rTRC", &trc),
(b"gTRC", &trc),
(b"bTRC", &trc),
];
let data_start = 128 + 4 + 12 * tags.len();
let mut table = (tags.len() as u32).to_be_bytes().to_vec();
let mut data = Vec::new();
let mut trc_at = None;
for (name, tag) in tags {
// The three channels share one curve.
let is_trc = tag == &trc[..];
let at = match trc_at.filter(|_| is_trc) {
Some(at) => at,
None => {
let at = data_start + data.len();
data.extend(tag);
data.resize(data.len().next_multiple_of(4), 0);
at
}
};
if is_trc {
trc_at = Some(at);
}
table.extend(name);
table.extend((at as u32).to_be_bytes());
table.extend((tag.len() as u32).to_be_bytes());
}
let mut out = vec![0; 128];
let size = data_start + data.len();
out[0..4].copy_from_slice(&(size as u32).to_be_bytes());
out[8..12].copy_from_slice(&[2, 0x40, 0, 0]);
out[12..16].copy_from_slice(b"mntr");
out[16..20].copy_from_slice(b"RGB ");
out[20..24].copy_from_slice(b"XYZ ");
out[36..40].copy_from_slice(b"acsp");
// Relative colorimetric, and the connection space's illuminant.
out[64..68].copy_from_slice(&1u32.to_be_bytes());
for (i, &c) in D50.iter().enumerate() {
out[68 + 4 * i..72 + 4 * i].copy_from_slice(&s15_fixed16(c));
}
out.extend(table);
out.extend(data);
out
}
#[cfg(test)]
mod tests {
use super::*;
fn tag<'a>(profile: &'a [u8], name: &[u8; 4]) -> &'a [u8] {
let u32_at = |i: usize| u32::from_be_bytes(profile[i..i + 4].try_into().unwrap()) as usize;
let at = (0..u32_at(128))
.map(|i| 132 + 12 * i)
.find(|&e| &profile[e..e + 4] == name)
.unwrap();
&profile[u32_at(at + 4)..u32_at(at + 4) + u32_at(at + 8)]
}
#[test]
fn the_profile_is_whole() {
let p = profile();
assert_eq!(
u32::from_be_bytes(p[0..4].try_into().unwrap()) as usize,
p.len()
);
assert_eq!(&p[36..40], b"acsp");
assert_eq!(p.len() % 4, 0);
assert_eq!(&tag(&p, b"desc")[0..4], b"desc");
// One curve, shared: the profile stays small.
assert_eq!(tag(&p, b"rTRC").as_ptr(), tag(&p, b"bTRC").as_ptr());
assert!(p.len() < 10_000, "{}", p.len());
}
#[test]
fn the_primaries_add_up_to_white() {
for i in 0..3 {
assert!((RED[i] + GREEN[i] + BLUE[i] - D50[i]).abs() < 0.0001);
}
}
#[test]
fn the_curve_reaches_white_at_reference_white_and_stays() {
let p = profile();
let trc = tag(&p, b"rTRC");
let value = |signal: f64| {
let i = 12 + 2 * (signal * (STEPS - 1) as f64).round() as usize;
u16::from_be_bytes([trc[i], trc[i + 1]])
};
assert_eq!(value(0.0), 0);
// 203 nits in PQ is a signal of 0.5807.
assert!(value(0.57) < 65535);
assert_eq!(value(0.59), 65535);
assert_eq!(value(1.0), 65535);
}
}
@@ -217,7 +217,7 @@ fn long_flash_sequence_keeps_only_active_seeds() {
#[ignore = "requires GPU; run separately with --ignored --exact"]
fn retained_gpu_draw_matches_transient_additive_draw() {
use bytemuck::Zeroable;
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
eprintln!("no GPU adapter: skipped");
return;
@@ -530,7 +530,7 @@ fn white_uv_is_inside_the_white_sprite() {
#[test]
#[ignore = "requires GPU; run separately with --ignored --exact"]
fn bolt_through_the_atlas_white_sprite_is_fully_drawn() {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
eprintln!("no GPU adapter: skipped");
return;
@@ -276,6 +276,10 @@ impl Renderer {
/// once all are in, fades them in over the tiles they replace.
pub fn upload_lods(&mut self, budget: Duration) {
let start = Instant::now();
if self.lod_pending.is_some() {
// Far tiles fill the arenas too: large translucent pages wait for them.
self.shrink_gate.wait(start, super::SHRINK_SETTLE);
}
self.release_left(start);
let faded: Vec<_> = self
.lods
@@ -39,10 +39,12 @@ struct HorizonMaterialOut {
@location(8) @interpolate(flat) face: u32,
@location(9) @interpolate(flat) fluid_quad: u32,
@location(10) @interpolate(flat) emission: f32,
// terrain.wgsl's `emission`, for HDR output's mark.
@location(11) @interpolate(flat) hdr_code: u32,
}
fn horizon_material_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>,
light: vec2<u32>, origin: vec3<f32>) -> HorizonMaterialOut {
let pos = position + origin;
let pos = horizon_material_displace(position + origin, uv, layer, color, light);
var out: HorizonMaterialOut;
out.clip = horizon_terrain_globals.view_proj * vec4(pos, 1.0);
out.uv = uv;
@@ -67,7 +69,9 @@ fn horizon_material_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color
out.fluid_quad = u32(fluid);
// The tier (0 none, 1 low, 2 incandescent, 3 bright, 4 very bright) as how strongly the
// face shines: a redstone torch 7/15, a torch 14/15, glowstone 1.
let tier = 15u - (flags & 15u);
out.hdr_code = 15u - (flags & 15u);
// Codes above 4 are the same tiers, shining all over rather than in their bright parts.
let tier = select(out.hdr_code, out.hdr_code - 4u, out.hdr_code > 4u);
out.emission = select(min(f32(tier) * 7.0 / 15.0, 1.0), 0.0, tier == 0u);
return out;
}
@@ -139,17 +143,51 @@ fn horizon_material_color(in: HorizonMaterialOut, translucent: bool) -> vec4<f32
// Water draws translucent and lava solid, so the pass resolves the fluid kind.
let fluid = select(0u, select(2u, 1u, translucent), in.fluid_quad != 0u);
let normal = horizon_material_normal(in.position);
horizon_material_face_normal = normal;
horizon_surface = vec4<f32>(0.0);
let tangent = horizon_material_tangent(in.position, in.uv, normal);
return material(MaterialInput(albedo, in.tint, in.light, in.lightmap, in.position, in.uv,
translucent, horizon_sprite_tags(in.layer & 0x7fffu),
horizon_sprite_material(in.layer & 0x7fffu), in.clip.xy / frame.resolution,
in.face, fluid, in.emission, normal, tangent)).color;
in.face, fluid, in.emission, normal, tangent,
horizon_terrain_native_colored_light(in.position))).color;
}
@fragment fn horizon_material_fs_solid(in: HorizonMaterialOut) -> @location(0) vec4<f32> {
var<private> horizon_material_face_normal: vec3<f32>;
struct HorizonMaterialSolid {
@location(0) color: vec4<f32>,
// The surface target's data: `set_surface`'s, then the fragment's depth, which the post
// stage's `surface_at` compares with the final depth.
@location(1) surface: vec4<f32>,
}
// What `fs_solid` leaves in the frame's alpha for HDR output, from the sprite before the pack's
// material: the pack decides the colour, Horizon still knows which pixels are light sources.
fn horizon_material_mark(in: HorizonMaterialOut, texel: vec4<f32>) -> f32 {
if in.hdr_code > 0u {
let whole = in.hdr_code > 4u;
let tier = select(in.hdr_code, in.hdr_code - 4u, whole);
let l = in.layer & 0x7fffu;
let t = textureLoad(horizon_terrain_emission_thresholds,
vec2(i32(l % horizon_terrain_SPRITE_SPLIT), i32(l / horizon_terrain_SPRITE_SPLIT)), 0);
let y = horizon_terrain_brightness(texel.rgb);
if whole || y >= t.r {
return horizon_terrain_hdr_mark(tier, y);
}
}
return 1.0;
}
@fragment fn horizon_material_fs_solid(in: HorizonMaterialOut) -> HorizonMaterialSolid {
let color = horizon_material_color(in, false);
let texel = horizon_terrain_sprite(in.uv, in.layer & 0x7fffu);
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
if color.a < 0.5 && (in.layer & 0x8000u) == 0u { discard; }
return horizon_material_fogged(vec4(color.rgb, 1.0), in.spherical, in.cylindrical);
if horizon_surface.w == 0.0 {
set_surface(horizon_material_face_normal, 0.0, 0.0);
}
var out: HorizonMaterialSolid;
let mark = horizon_material_mark(in, texel);
out.color = horizon_material_fogged(vec4(color.rgb, mark), in.spherical, in.cylindrical);
out.surface = vec4(horizon_surface.xyz, in.clip.z);
return out;
}
@fragment fn horizon_material_fs_translucent(in: HorizonMaterialOut) -> @location(0) vec4<f32> {
let color = horizon_material_color(in, true);
@@ -189,6 +227,8 @@ pub struct NativeMaterial {
pub solid: wgpu::RenderPipeline,
pub translucent: wgpu::RenderPipeline,
pub flat: Option<(wgpu::RenderPipeline, wgpu::RenderPipeline)>,
/// The stage calls `set_surface`: its solid pipelines also write the surface target.
pub writes_surface: bool,
}
/// terrain.wgsl's terrain section (its globals, bindings, sampling, lighting and fog), with
@@ -203,7 +243,7 @@ fn vanilla_terrain() -> String {
/// The function `name(layer)`, the value of each sprite layer in `table` and 0 for the rest:
/// `horizon_sprite_tags` (`MaterialInput.tags`) and `horizon_sprite_material`
/// (`MaterialInput.material`).
fn layer_table(name: &str, table: &[(u32, u32)]) -> String {
pub(super) fn layer_table(name: &str, table: &[(u32, u32)]) -> String {
let mut out = format!("fn {name}(layer: u32) -> u32 {{\n switch layer {{\n");
let mut table = table.to_vec();
table.sort_unstable_by_key(|&(layer, value)| (value, layer));
@@ -218,12 +258,47 @@ fn layer_table(name: &str, table: &[(u32, u32)]) -> String {
out + " default: { return 0u; }\n }\n}\n"
}
/// Where a corner of terrain is drawn without a vertex stage.
pub(super) const DISPLACE_VANILLA: &str = "
fn horizon_material_displace(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>,
light: vec2<u32>) -> vec3<f32> {
return pos;
}
";
/// The vertex stage's call, with its inputs from terrain's vertex.
pub(super) const DISPLACE_PACK: &str = "
fn horizon_material_displace(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>,
light: vec2<u32>) -> vec3<f32> {
let flags = u32(round(color.a * 255.0));
let high = flags >> 4u;
let fluid = high >= 8u && (high & 7u) < 6u;
let lit = min(vec2(f32(light.y), f32(light.x)) / 240.0, vec2(1.0));
return vertex(VertexInput(pos, uv, horizon_sprite_material(layer & 0x7fffu), lit,
vec4(color.rgb, 1.0), select(0u, high & 7u, fluid), u32(fluid)));
}
";
/// The surface target (the post stage's `scene_surface`): x the normal (two snorm16 in its
/// bits), y smoothness, z the pack's data, w the fragment's depth.
pub const SURFACE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba32Float;
/// The terrain shader for the pack's material stage `source`, with its sprite `tags` and
/// `materials`.
fn shader_source(source: &str, tags: &[(u32, u32)], materials: &[(u32, u32)]) -> String {
/// `materials`, and the vertex stage in its module when `vertex`.
fn shader_source(
source: &str,
tags: &[(u32, u32)],
materials: &[(u32, u32)],
vertex: bool,
) -> String {
format!(
"{source}\n// Horizon's terrain (terrain.wgsl)\n{}{}{}{ENTRIES}",
"{source}\n// Horizon's terrain (terrain.wgsl)\n{}{}{}{}{ENTRIES}",
vanilla_terrain(),
if vertex {
DISPLACE_PACK
} else {
DISPLACE_VANILLA
},
layer_table("horizon_sprite_tags", tags),
layer_table("horizon_sprite_material", materials)
)
@@ -234,8 +309,9 @@ fn module(
stage: &CheckedStage,
tags: &[(u32, u32)],
materials: &[(u32, u32)],
vertex: bool,
) -> Result<naga::Module, String> {
let text = shader_source(&stage.source, tags, materials);
let text = shader_source(&stage.source, tags, materials, vertex);
naga::front::wgsl::parse_str(&text)
.map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text)))
}
@@ -249,8 +325,10 @@ impl NativeMaterial {
stage: &CheckedStage,
constants: &[(String, f64)],
fog: bool,
vertex: bool,
) -> Result<Self, String> {
let module = module(stage, shape.sprite_tags, shape.sprite_materials)?;
let writes_surface = horizon_shader_api::stages::calls_set_surface(&stage.source);
let module = module(stage, shape.sprite_tags, shape.sprite_materials, vertex)?;
let mut constants = present_constants(&module, constants);
let sprite_names = [
"horizon_terrain_SPRITE_ARRAYS",
@@ -296,6 +374,18 @@ impl NativeMaterial {
constants: &constants,
..Default::default()
};
let solid_targets = [
Some(wgpu::ColorTargetState {
format: shape.format,
blend,
write_mask: wgpu::ColorWrites::ALL,
}),
Some(wgpu::ColorTargetState {
format: SURFACE_FORMAT,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
}),
];
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(&stage.label),
layout: Some(layout),
@@ -321,11 +411,11 @@ impl NativeMaterial {
module: &module,
entry_point: Some(fragment),
compilation_options: options,
targets: &[Some(wgpu::ColorTargetState {
format: shape.format,
blend,
write_mask: wgpu::ColorWrites::ALL,
})],
targets: if fragment == "horizon_material_fs_solid" && writes_surface {
&solid_targets
} else {
&solid_targets[..1]
},
}),
multiview_mask: None,
cache: None,
@@ -370,6 +460,7 @@ impl NativeMaterial {
solid,
translucent,
flat,
writes_surface,
})
}
}
@@ -424,7 +515,7 @@ mod tests {
let tags = [(7, 1), (3, 1), (9, 2)];
let materials = [(5, 1000)];
assert!(layer_table("t", &tags).contains("case 3u, 7u: { return 1u; }"));
let module = module(&stage, &tags, &materials).unwrap();
let module = module(&stage, &tags, &materials, false).unwrap();
let info = naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
@@ -433,7 +524,7 @@ mod tests {
.unwrap_or_else(|e| {
panic!(
"{}",
e.emit_to_string(&shader_source(&stage.source, &tags, &materials))
e.emit_to_string(&shader_source(&stage.source, &tags, &materials, false))
)
});
let entries: Vec<&str> = module
@@ -21,6 +21,8 @@ pub struct NativePack {
pub weather: Option<wgpu::RenderPipeline>,
/// The draw of the shadow map the stages read, if the pack asks for one.
pub shadow: Option<NativeShadow>,
/// `[[particle_lights]]`.
pub particle_lights: Vec<horizon_shader_api::manifest::ParticleLight>,
/// GPU time of each pass, while the renderer asks for it (`pack_timing.rs`).
pub timing: Option<PassTiming>,
}
@@ -48,7 +50,17 @@ impl NativePack {
let shadow = report
.shadow
.as_ref()
.map(|s| NativeShadow::new(device, terrain, &frame, &s.solid_materials))
.map(|s| {
let vertex = report.stages.iter().find(|s| s.kind == StageKind::Vertex);
NativeShadow::new(
device,
terrain,
&frame,
&s.solid_materials,
vertex,
constants,
)
})
.transpose()?;
// The mirror is drawn for a material that reads it, while `[reflection] option` is on.
let option = report.reflection.as_ref().and_then(|r| r.option.as_ref());
@@ -91,6 +103,7 @@ impl NativePack {
.iter()
.find(|s| s.kind == StageKind::Weather)
.map(|s| s.label.clone());
let has_vertex = report.stages.iter().any(|s| s.kind == StageKind::Vertex);
let (post, others): (Vec<_>, Vec<_>) = report
.stages
.into_iter()
@@ -103,7 +116,9 @@ impl NativePack {
let material = others
.iter()
.find(|s| s.kind == StageKind::Material)
.map(|stage| NativeMaterial::new(device, terrain, &frame, stage, constants, fog))
.map(|stage| {
NativeMaterial::new(device, terrain, &frame, stage, constants, fog, has_vertex)
})
.transpose()?;
let post = (!post.is_empty())
.then(|| NativePost::new(device, post, &report.manifest, constants, format, &frame))
@@ -120,6 +135,7 @@ impl NativePack {
labels.extend(post.labels().into_iter().map(str::to_owned));
}
let pack = Self {
particle_lights: report.manifest.particle_lights.clone(),
frame,
sky,
material,
@@ -14,7 +14,7 @@ use super::Frame;
const INTERMEDIATE: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
/// The world's colour and depth views a set of bind groups was made for.
type WorldViews = (wgpu::TextureView, wgpu::TextureView);
type WorldViews = (wgpu::TextureView, wgpu::TextureView, wgpu::TextureView);
/// Where a post pass draws.
#[derive(Clone, Copy, PartialEq)]
@@ -103,6 +103,9 @@ pub struct NativePost {
/// The `frame` every stage of a pack reads (group 3, binding 0), written once a frame, and
/// what the material stage reads besides.
pub struct NativeFrame {
/// The solid terrain's second target, the post stage's `scene_surface`.
surface: wgpu::Texture,
pub surface_view: wgpu::TextureView,
pub(super) buffer: wgpu::Buffer,
/// Group 3 of the sky and material stages.
pub layout: wgpu::BindGroupLayout,
@@ -270,6 +273,8 @@ impl NativeFrame {
label: Some("Native shader pack frame"),
entries: &entries,
});
let surface = surface_target(device, [1, 1]);
let surface_view = surface.create_view(&Default::default());
let opaque_depth = depth_copy(device, [1, 1]);
let opaque_color = color_copy(device, [1, 1], wgpu::TextureFormat::Rgba8Unorm);
let blank = copy_target(
@@ -332,6 +337,8 @@ impl NativeFrame {
buffer,
layout,
group,
surface,
surface_view,
opaque_depth,
opaque_color,
reflection: blank.clone(),
@@ -468,6 +475,14 @@ impl NativeFrame {
self.regroup(device);
}
/// Sizes the surface target: the world's while a material stage writes it, else a blank.
pub fn fit_surface(&mut self, device: &wgpu::Device, size: [u32; 2]) {
if [self.surface.width(), self.surface.height()] != size {
self.surface = surface_target(device, size);
self.surface_view = self.surface.create_view(&Default::default());
}
}
/// Binds `view` as this frame's `reflection` (`None`: the blank). The mirror is drawn
/// before the stages read it.
pub fn set_reflection(&mut self, device: &wgpu::Device, view: Option<&wgpu::TextureView>) {
@@ -573,6 +588,24 @@ pub fn present_constants<'a>(
.collect()
}
/// The solid terrain's surface target of `size`, drawn and read.
fn surface_target(device: &wgpu::Device, size: [u32; 2]) -> wgpu::Texture {
device.create_texture(&wgpu::TextureDescriptor {
label: Some("Native shader pack surface"),
size: wgpu::Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: super::native_material::SURFACE_FORMAT,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
})
}
fn texture_entry(binding: u32, sample_type: wgpu::TextureSampleType) -> wgpu::BindGroupLayoutEntry {
wgpu::BindGroupLayoutEntry {
binding,
@@ -652,6 +685,7 @@ impl NativePost {
texture_entry(8, float),
texture_entry(9, wgpu::TextureSampleType::Depth),
texture_entry(10, float),
texture_entry(11, wgpu::TextureSampleType::Float { filterable: false }),
];
entries.extend(pack_texture_entries(frame.textures.len()));
entries
@@ -757,7 +791,7 @@ impl NativePost {
fn prepare(
&mut self,
device: &wgpu::Device,
world: (&wgpu::TextureView, &wgpu::TextureView),
world: (&wgpu::TextureView, &wgpu::TextureView, &wgpu::TextureView),
size: [u32; 2],
history_reset: bool,
) {
@@ -830,8 +864,11 @@ impl NativePost {
if self
.groups
.as_ref()
.is_some_and(|((color, depth), blank, _)| {
color == world.0 && depth == world.1 && *blank == blank_history
.is_some_and(|((color, depth, surface), blank, _)| {
color == world.0
&& depth == world.1
&& surface == world.2
&& *blank == blank_history
})
{
return;
@@ -893,6 +930,10 @@ impl NativePost {
binding: 9,
resource: wgpu::BindingResource::TextureView(&self.shadow.2),
},
wgpu::BindGroupEntry {
binding: 11,
resource: wgpu::BindingResource::TextureView(world.2),
},
wgpu::BindGroupEntry {
binding: 10,
resource: wgpu::BindingResource::TextureView(&self.shadow.3),
@@ -920,7 +961,11 @@ impl NativePost {
})
})
.collect();
self.groups = Some(((world.0.clone(), world.1.clone()), blank_history, groups));
self.groups = Some((
(world.0.clone(), world.1.clone(), world.2.clone()),
blank_history,
groups,
));
}
/// Runs the passes over the world target `world` (colour, depth) of `size`, the last onto
@@ -929,7 +974,7 @@ impl NativePost {
&mut self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
world: (&wgpu::TextureView, &wgpu::TextureView),
world: (&wgpu::TextureView, &wgpu::TextureView, &wgpu::TextureView),
screen: &wgpu::TextureView,
size: [u32; 2],
history_reset: bool,
@@ -1249,7 +1294,7 @@ mod tests {
}
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
pollster::block_on(adapter.request_device(&Default::default())).ok()
}
@@ -1349,7 +1394,7 @@ mod tests {
post.run(
&device,
&mut encoder,
(&color, &depth),
(&color, &depth, &frame.surface_view),
&view,
[4, 4],
true,
@@ -1456,7 +1501,7 @@ file = \"b.wgsl\"
post.run(
&device,
&mut encoder,
(&color, &depth),
(&color, &depth, &frame.surface_view),
&view,
[4, 4],
history_reset,
@@ -6,11 +6,44 @@
use super::Vertex;
use super::pack_draw::EntitySpan;
use glam::{DVec3, Mat4};
use std::sync::{
OnceLock,
atomic::{AtomicBool, Ordering},
};
mod generated;
pub use generated::{RenderHooks, RenderNeedsEvent, WorldDrawnEvent};
pub use wgpu;
static RENDERERS: OnceLock<Box<[&'static dyn RenderHooks]>> = OnceLock::new();
/// Install trait mod renderers in dispatch order, once per process.
pub fn install(
renderers: Box<[&'static dyn RenderHooks]>,
) -> Result<(), Box<[&'static dyn RenderHooks]>> {
RENDERERS.set(renderers)
}
static MODS_BUILT_IN: AtomicBool = AtomicBool::new(false);
/// The game was built with native mods (the game sets it at start): one of them may draw in
/// [`native_world_drawn`], so the first-person hand keeps its own pass after it.
pub fn set_mods_built_in(on: bool) {
MODS_BUILT_IN.store(on, Ordering::Relaxed);
}
/// Whether [`native_world_drawn`] may draw ([`set_mods_built_in`]).
pub fn world_drawn_hooked() -> bool {
MODS_BUILT_IN.load(Ordering::Relaxed)
|| RENDERERS
.get()
.is_some_and(|renderers| !renderers.is_empty())
}
/// What a mod can draw with once the world is drawn: after the sky, terrain, entities,
/// particles, weather and the world post pass, before the first-person hand, the HUD and the
/// screens. `color` holds the world's picture (copy it out before reading it: it is the target
/// drawn into), `depth` the world's depth, reversed (0 is the sky).
#[non_exhaustive]
pub struct WorldDrawn<'a> {
pub device: &'a wgpu::Device,
pub queue: &'a wgpu::Queue,
@@ -46,6 +79,7 @@ pub struct WorldDrawn<'a> {
/// What the mods need from a frame that the game doesn't make for itself. Each mod sets what it
/// needs and leaves the rest, so several can ask.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct RenderNeeds {
/// Record `Frame::entity_spans` without a shader pack: entity runs get a stable id from
/// their type's name, block entity runs their block state.
@@ -57,13 +91,13 @@ pub struct RenderNeeds {
/// The world is drawn (see [`WorldDrawn`]). Not called on the title screen's panorama.
#[inline(never)]
pub fn native_world_drawn(world: &mut WorldDrawn<'_>) {
let _ = world;
generated::dispatch_world_drawn(RENDERERS.get().map_or(&[], |renderers| renderers), world);
}
/// Asked as a frame is built and drawn: set what your mod needs.
#[inline(never)]
pub fn native_render_needs(needs: &mut RenderNeeds) {
let _ = needs;
generated::dispatch_render_needs(RENDERERS.get().map_or(&[], |renderers| renderers), needs);
}
/// What the mods need this frame ([`native_render_needs`]).
@@ -72,3 +106,34 @@ pub fn render_needs() -> RenderNeeds {
native_render_needs(&mut needs);
needs
}
#[cfg(test)]
mod tests {
use super::*;
struct Depth;
impl RenderHooks for Depth {
fn render_needs(&self, event: &mut RenderNeedsEvent<'_>) {
event.needs.world_depth = true;
}
}
struct Spans;
impl RenderHooks for Spans {
fn render_needs(&self, event: &mut RenderNeedsEvent<'_>) {
event.needs.entity_spans = event.needs.world_depth;
}
}
#[test]
fn needs_accumulate_in_mod_order() {
let mut needs = RenderNeeds::default();
generated::dispatch_render_needs(&[&Depth, &Spans], &mut needs);
assert_eq!(
needs,
RenderNeeds {
world_depth: true,
entity_spans: true
}
);
}
}
@@ -0,0 +1,36 @@
// @generated by cargo run -p xtask -- mod-api; do not edit.
use super::*;
pub trait RenderHooks: Send + Sync {
/// The world is drawn (see [`WorldDrawn`]). Not called on the title screen's panorama.
fn world_drawn(&self, event: &mut WorldDrawnEvent<'_, '_>) {
let _ = event;
}
/// Asked as a frame is built and drawn: set what your mod needs.
///
/// Every mod gets `event.needs` in order and may add, change or remove needs.
fn render_needs(&self, event: &mut RenderNeedsEvent<'_>) {
let _ = event;
}
}
/// What [`RenderHooks::world_drawn`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct WorldDrawnEvent<'a, 'b> {
pub world: &'a mut WorldDrawn<'b>,
}
pub fn dispatch_world_drawn(mods: &[&dyn RenderHooks], world: &mut WorldDrawn<'_>) {
let mut event = WorldDrawnEvent { world };
for mod_hook in mods {
mod_hook.world_drawn(&mut event);
}
}
/// What [`RenderHooks::render_needs`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct RenderNeedsEvent<'a> {
pub needs: &'a mut RenderNeeds,
}
pub fn dispatch_render_needs(mods: &[&dyn RenderHooks], needs: &mut RenderNeeds) {
let mut event = RenderNeedsEvent { needs };
for mod_hook in mods {
mod_hook.render_needs(&mut event);
}
}
@@ -41,17 +41,21 @@ fn horizon_shadow_vertex(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<
}
@vertex fn horizon_shadow_vs(
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>,
@location(2) layer: u32, @location(3) color: vec4<f32>,
@location(2) layer: u32, @location(3) color: vec4<f32>, @location(4) light: vec2<u32>,
@location(5) origin: vec4<f32>) -> HorizonShadowOut {
return horizon_shadow_vertex(position + origin.xyz, horizon_terrain_terrain_uv(uv, origin.w), layer, color);
let atlas = horizon_terrain_terrain_uv(uv, origin.w);
let pos = horizon_material_displace(position + origin.xyz, atlas, layer, color, light);
return horizon_shadow_vertex(pos, atlas, layer, color);
}
@vertex fn horizon_shadow_vs_flat(
@builtin(vertex_index) index: u32,
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>, @location(2) layer: u32,
@location(3) color: vec4<f32>
@location(3) color: vec4<f32>, @location(4) light: vec2<u32>
) -> HorizonShadowOut {
let origin = horizon_terrain_slot_origins[horizon_terrain_block_slots[index >> 8u]];
return horizon_shadow_vertex(position + origin.xyz, horizon_terrain_terrain_uv(uv, origin.w), layer, color);
let atlas = horizon_terrain_terrain_uv(uv, origin.w);
let pos = horizon_material_displace(position + origin.xyz, atlas, layer, color, light);
return horizon_shadow_vertex(pos, atlas, layer, color);
}
@fragment fn horizon_shadow_fs(in: HorizonShadowOut) {
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
@@ -415,12 +419,24 @@ fn shadow_solid(materials: &[(u32, u32)], solid: &[u32]) -> String {
/// The shadow pass's shader: the frame, Horizon's terrain, and the entries, with the layers
/// `solid` of `shadow_solid`.
fn shader_source(solid: &str) -> String {
fn shader_source(solid: &str, vertex: Option<&str>, materials: &[(u32, u32)]) -> String {
let (head, displace) = match vertex {
Some(stage) => (
stage.to_owned()
+ &super::native_material::layer_table("horizon_sprite_material", materials),
super::native_material::DISPLACE_PACK,
),
None => (
format!(
"{}@group({GROUP}) @binding(0) var<uniform> frame: Frame;\n{}",
FrameInputs::WGSL.wgsl(),
horizon_shader_api::stages::SHADOW_HELPERS,
),
super::native_material::DISPLACE_VANILLA,
),
};
format!(
"{}@group({GROUP}) @binding(0) var<uniform> frame: Frame;\n{}\
// Horizon's terrain (terrain.wgsl)\n{}{solid}{ENTRIES}",
FrameInputs::WGSL.wgsl(),
horizon_shader_api::stages::SHADOW_HELPERS,
"{head}// Horizon's terrain (terrain.wgsl)\n{}{solid}{displace}{ENTRIES}",
prefixed(
section(TERRAIN, "struct Globals {", "// Sky (SkyRenderer)"),
PREFIX
@@ -434,14 +450,21 @@ impl NativeShadow {
shape: &TerrainShape<'_>,
frame: &super::native_post::NativeFrame,
solid_materials: &[u32],
vertex: Option<&horizon_shader_api::check::CheckedStage>,
constants: &[(String, f64)],
) -> Result<Self, String> {
let text = shader_source(&shadow_solid(shape.sprite_materials, solid_materials));
let text = shader_source(
&shadow_solid(shape.sprite_materials, solid_materials),
vertex.map(|s| s.source.as_str()),
shape.sprite_materials,
);
let module = naga::front::wgsl::parse_str(&text)
.map_err(|e| format!("shadow pass: {}", e.emit_to_string(&text)))?;
let constants = [
let mut constants = super::native_post::present_constants(&module, constants);
constants.extend([
("horizon_terrain_SPRITE_ARRAYS", shape.sprites[0]),
("horizon_terrain_SPRITE_SPLIT", shape.sprites[1]),
];
]);
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Native shader pack shadow"),
@@ -593,7 +616,7 @@ mod tests {
#[test]
fn the_shadow_shader_validates() {
for materials in [&[][..], &[(3, 7), (4, 8), (9, 7)][..]] {
let text = shader_source(&shadow_solid(materials, &[7]));
let text = shader_source(&shadow_solid(materials, &[7]), None, materials);
let module = naga::front::wgsl::parse_str(&text).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
@@ -211,7 +211,7 @@ mod tests {
/// vanilla's pixels, one that inverts them draws the inverse (and stays opaque).
#[test]
fn a_pack_gets_vanilla_s_colour_and_draws_opaque() {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
@@ -244,7 +244,7 @@ mod tests {
/// Two arrays of 2x2 sprites, one layer split; each layer a colour of its own.
#[test]
fn layers_land_in_their_cells() {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
@@ -0,0 +1,296 @@
//! `centerDepthSmooth`'s input: `depthtex0` at the centre of the screen. A depth texture can
//! only be copied whole, so each frame a one-pixel pass loads that texel into a 1x1 `R32Float`
//! target, copied to one of a few small buffers and read back frames later without waiting for
//! the GPU (as `timing.rs` reads its timestamps); `FrameUniforms` smooths the latest value.
use std::sync::Arc;
use std::sync::atomic::{AtomicU8, Ordering};
const SLOTS: usize = 3;
const FREE: u8 = 0;
/// Copied this frame: mapped once the frame is submitted.
const COPIED: u8 = 1;
const PENDING: u8 = 2;
const MAPPED: u8 = 3;
/// One row of a texture copy, the alignment wgpu asks of `bytes_per_row`.
const ROW: u32 = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
const SHADER: &str = "
@group(0) @binding(0) var depth: texture_depth_2d;
@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
let p = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
return vec4<f32>(p * 2.0 - 1.0, 0.0, 1.0);
}
@fragment fn fs() -> @location(0) vec4<f32> {
let centre = vec2<i32>(textureDimensions(depth) / 2u);
return vec4<f32>(textureLoad(depth, centre, 0), 0.0, 0.0, 1.0);
}
";
struct Slot {
buffer: wgpu::Buffer,
state: Arc<AtomicU8>,
}
pub struct CenterDepth {
slots: Vec<Slot>,
pipeline: wgpu::RenderPipeline,
layout: wgpu::BindGroupLayout,
/// The 1x1 target the centre texel is loaded into.
texel: wgpu::Texture,
texel_view: wgpu::TextureView,
/// The bind group of the depth texture it reads, made again when that texture changes.
group: Option<(wgpu::Texture, wgpu::BindGroup)>,
/// The depth last read back: GL window depth, 0..1.
latest: Option<f32>,
}
impl CenterDepth {
pub fn new(device: &wgpu::Device) -> Self {
let slots = (0..SLOTS)
.map(|_| Slot {
buffer: device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Shader pack centre depth"),
size: u64::from(ROW),
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
}),
state: Arc::new(AtomicU8::new(FREE)),
})
.collect();
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Shader pack centre depth"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Depth,
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
}],
});
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Shader pack centre depth"),
source: wgpu::ShaderSource::Wgsl(SHADER.into()),
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Shader pack centre depth"),
bind_group_layouts: &[Some(&layout)],
immediate_size: 0,
});
let format = wgpu::TextureFormat::R32Float;
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Shader pack centre depth"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &module,
entry_point: Some("vs"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some("fs"),
compilation_options: Default::default(),
targets: &[Some(format.into())],
}),
multiview_mask: None,
cache: None,
});
let texel = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Shader pack centre depth"),
size: wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let texel_view = texel.create_view(&Default::default());
Self {
slots,
pipeline,
layout,
texel,
texel_view,
group: None,
latest: None,
}
}
/// Loads the centre texel of `depth` (a `Depth32Float` texture that can be bound) and
/// copies it into a free slot; with none free (the GPU is behind), this frame is skipped.
pub fn copy(
&mut self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
depth: &wgpu::Texture,
) {
let Some(slot) = self
.slots
.iter()
.find(|s| s.state.load(Ordering::Acquire) == FREE)
else {
return;
};
if self.group.as_ref().is_none_or(|(t, _)| t != depth) {
let view = depth.create_view(&Default::default());
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Shader pack centre depth"),
layout: &self.layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&view),
}],
});
self.group = Some((depth.clone(), group));
}
let Some((_, group)) = &self.group else {
return;
};
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Shader pack centre depth"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &self.texel_view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::WHITE),
store: wgpu::StoreOp::Store,
},
})],
..Default::default()
});
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, group, &[]);
pass.draw(0..3, 0..1);
}
encoder.copy_texture_to_buffer(
self.texel.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &slot.buffer,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(ROW),
rows_per_image: Some(1),
},
},
self.texel.size(),
);
slot.state.store(COPIED, Ordering::Release);
}
/// After the frame's submission: start reading back what it copied.
pub fn submitted(&mut self) {
for slot in &self.slots {
if slot.state.load(Ordering::Acquire) != COPIED {
continue;
}
slot.state.store(PENDING, Ordering::Release);
let state = slot.state.clone();
slot.buffer
.slice(..)
.map_async(wgpu::MapMode::Read, move |result| {
let next = if result.is_ok() { MAPPED } else { FREE };
state.store(next, Ordering::Release);
});
}
}
/// The newest depth read back so far, `None` before the first arrives.
pub fn collect(&mut self, device: &wgpu::Device) -> Option<f32> {
let _ = device.poll(wgpu::PollType::Poll);
for slot in &self.slots {
if slot.state.load(Ordering::Acquire) != MAPPED {
continue;
}
let depth = slot
.buffer
.slice(..4)
.get_mapped_range()
.ok()
.map(|bytes| bytemuck::pod_read_unaligned::<f32>(&bytes[..4]));
slot.buffer.unmap();
slot.state.store(FREE, Ordering::Release);
if let Some(depth) = depth.filter(|d| d.is_finite()) {
self.latest = Some(depth.clamp(0.0, 1.0));
}
}
self.latest
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance =
wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle().with_env());
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
pollster::block_on(adapter.request_device(&Default::default())).ok()
}
/// A depth texture cleared to a known depth comes back as that depth, a frame or more
/// later, without the frame waiting for it.
#[test]
fn the_centre_depth_comes_back() {
let Some((device, queue)) = test_device() else {
return;
};
let depth = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 9,
height: 7,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Depth32Float,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
let view = depth.create_view(&Default::default());
let mut center = CenterDepth::new(&device);
assert_eq!(center.collect(&device), None);
let mut encoder = device.create_command_encoder(&Default::default());
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(0.375),
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
});
center.copy(&device, &mut encoder, &depth);
queue.submit([encoder.finish()]);
center.submitted();
let _ = device.poll(wgpu::PollType::wait_indefinitely());
assert_eq!(center.collect(&device), Some(0.375));
// Its slot is free again: every slot can be used once more.
for _ in 0..SLOTS {
let mut encoder = device.create_command_encoder(&Default::default());
center.copy(&device, &mut encoder, &depth);
queue.submit([encoder.finish()]);
center.submitted();
}
let _ = device.poll(wgpu::PollType::wait_indefinitely());
assert_eq!(center.collect(&device), Some(0.375));
}
}
@@ -319,6 +319,7 @@ impl PackEntities {
entities: &[Vertex],
spans: &[EntitySpan],
drawn: [bool; PROGRAMS],
crack: ([f32; 4], [u32; 2]),
shadow: Casters,
rest: &mut Vec<Vertex>,
) {
@@ -330,7 +331,9 @@ impl PackEntities {
rest.extend_from_slice(&entities[..first.min(entities.len())]);
for (span, range) in runs(entities.len(), spans) {
let run = &entities[range];
let program = program_of(&span).filter(|_| plain(run));
let program = program_of(&span).filter(|&p| {
plain(run) || (p == DESTROY && run.iter().all(|v| matches!(v.layer, 11 | 12)))
});
let pack = program.is_some_and(|p| drawn[p]) && !span.hidden;
let casts = program.is_some_and(|p| shadow.cast(&span, p));
if !pack && !span.hidden {
@@ -341,6 +344,9 @@ impl PackEntities {
};
let start = self.scratch.len() as u32;
self.scratch.extend_from_slice(run);
if program == DESTROY {
project_cracks(&mut self.scratch[start as usize..], crack.0, crack.1);
}
let (entity_id, block_id) = match program {
BLOCK => (0, span.id),
_ => (span.id, 0),
@@ -419,7 +425,8 @@ pub const ENTITY: usize = 0;
pub const BLOCK: usize = 1;
pub const TRANSLUCENT: usize = 2;
pub const PARTICLE: usize = 3;
pub const PROGRAMS: usize = 4;
pub const DESTROY: usize = 4;
pub const PROGRAMS: usize = 5;
/// Which program draws `span`'s run, given which of them (or their fallbacks) the pack has;
/// `None`: Horizon draws it.
@@ -440,6 +447,8 @@ pub fn program_of(span: &EntitySpan) -> Option<usize> {
BLOCK
} else if span.stage == render_stage("PARTICLES") {
PARTICLE
} else if span.stage == render_stage("DESTROY") {
DESTROY
} else {
return None;
})
@@ -461,6 +470,16 @@ pub fn runs(
mod tests {
use super::*;
#[test]
fn destroy_stage_uses_the_pack_only_when_a_program_is_available() {
let destroy = EntitySpan {
stage: render_stage("DESTROY"),
..Default::default()
};
assert!(drawn_by_pack(&destroy, [true; PROGRAMS]).is_some());
assert_eq!(drawn_by_pack(&destroy, [false; PROGRAMS]), None);
}
#[test]
fn runs_split_the_list_between_pack_and_horizon() {
let v = |layer| Vertex {
@@ -483,15 +502,21 @@ mod tests {
let all = [true; PROGRAMS];
assert_eq!(drawn_by_pack(&spans[0], all), Some(ENTITY));
assert_eq!(drawn_by_pack(&spans[1], all), Some(PARTICLE));
assert_eq!(drawn_by_pack(&spans[1], [true, true, true, false]), None);
assert_eq!(drawn_by_pack(&spans[2], [true, false, true, true]), None);
assert_eq!(
drawn_by_pack(&spans[1], [true, true, true, false, true]),
None
);
assert_eq!(
drawn_by_pack(&spans[2], [true, false, true, true, true]),
None
);
assert_eq!(drawn_by_pack(&spans[2], all), Some(BLOCK));
let shadow = EntitySpan {
translucent: true,
..spans[0]
};
assert_eq!(drawn_by_pack(&shadow, all), Some(TRANSLUCENT));
assert_eq!(drawn_by_pack(&span(0, "DESTROY", 0), all), None);
assert_eq!(drawn_by_pack(&span(0, "DESTROY", 0), all), Some(DESTROY));
// A beacon beam's quads stay Horizon's.
assert!(plain(&[v(0), v(0), v(0), v(0), v(1)]));
assert!(!plain(&[v(0), v(0), v(0), v(0), v(6)]));
@@ -523,3 +548,53 @@ mod tests {
assert!(!some.cast(&spans[2], BLOCK) && some.cast(&lit, BLOCK));
}
}
/// Decode the destroy-stage origin stored in the light bytes and project one continuous
/// sprite over each model face. Pack geometry uses ordinary atlas UVs, not Horizon's overlay codes.
fn project_cracks(vertices: &mut [Vertex], sprite: [f32; 4], atlas: [u32; 2]) {
let dims = [atlas[0].max(1) as f32, atlas[1].max(1) as f32];
for quad in vertices.as_chunks_mut::<4>().0 {
let origin = [
(quad[0].light & 65535) as f32 / 65535.0,
(quad[0].light >> 16) as f32 / 65535.0,
];
let origin = [0, 1].map(|i| (origin[i] * dims[i]).round() / dims[i]);
let min = [0, 1]
.map(|i| (quad.iter().map(|v| v.uv[i]).fold(f32::INFINITY, f32::min) + 0.0001).floor());
for v in quad {
v.uv =
[0, 1].map(|i| origin[i] + (v.uv[i] - min[i]).clamp(0.001, 0.999) * sprite[i + 2]);
v.layer = 0;
v.light = 0x0000_f0f0;
}
}
}
#[cfg(test)]
mod crack_projection_tests {
use super::*;
#[test]
fn crack_uvs_keep_a_full_sprite_and_decode_each_stage() {
let mut vertices: Vec<Vertex> = [[-1.0, -1.0], [-1.0, 0.0], [0.0, 0.0], [0.0, -1.0]]
.into_iter()
.map(|uv| Vertex {
uv,
layer: 11,
light: crate::breaking::stage_light([0.25, 0.5]),
..bytemuck::Zeroable::zeroed()
})
.collect();
project_cracks(
&mut vertices,
[0.0, 0.0, 16.0 / 4096.0, 16.0 / 9216.0],
[4096, 9216],
);
assert!(
vertices
.iter()
.all(|v| v.layer == 0 && v.uv[0] >= 0.25 && v.uv[0] < 0.25 + 16.0 / 4096.0)
);
assert!((vertices[2].uv[0] - vertices[0].uv[0]) > 15.0 / 4096.0);
assert!((vertices[1].uv[1] - vertices[0].uv[1]) > 15.0 / 9216.0);
}
}
@@ -408,6 +408,7 @@ pub(super) struct World<'a> {
pub block_ids: &'a [i32],
/// The atlas of the sun and moon's sprites.
pub celestial: Option<wgpu::TextureView>,
pub gui_size: [u32; 2],
/// Set 1 of the clouds' draw: their offset and palette.
pub clouds: Option<&'a wgpu::BindGroupLayout>,
/// The game can hand its rain and snow to the pack.
@@ -510,8 +511,11 @@ pub struct PackPost {
/// with the page buffer it was made for.
block_ids: Option<(wgpu::BindGroupLayout, wgpu::Buffer)>,
page_groups: HashMap<u32, (wgpu::Buffer, wgpu::BindGroup)>,
gui_size: [u32; 2],
/// GPU time of each pass, while the renderer asks for it (`pack_timing.rs`).
pub timing: Option<super::pack_timing::PassTiming>,
/// `depthtex0` at the screen's centre, read back for `centerDepthSmooth`.
center_depth: Option<super::pack_center_depth::CenterDepth>,
/// `shaders.properties`' custom uniforms, after the frame's in `uniforms.values`.
custom: horizon_shaders::Custom,
atlas: Option<wgpu::TextureView>,
@@ -1339,11 +1343,16 @@ fn build_pass(
} else if let Some(&buffer) = p.buffers.get(l) {
let format = formats[buffer as usize];
let mut state = target(format);
if let (Some(t), Some(b)) = (&mut state, p.blend)
&& format
.guaranteed_format_features(features)
.flags
.contains(wgpu::TextureFormatFeatureFlags::BLENDABLE)
if let (Some(t), Some(b)) = (
&mut state,
p.buffer_blends
.iter()
.find(|(n, _)| *n == buffer)
.map_or(p.blend, |(_, b)| *b),
) && format
.guaranteed_format_features(features)
.flags
.contains(wgpu::TextureFormatFeatureFlags::BLENDABLE)
{
t.blend = Some(blend_state(b));
}
@@ -1431,7 +1440,15 @@ fn build_pass(
depth_write_enabled: Some(g.depth_write),
depth_compare: Some(wgpu::CompareFunction::LessEqual),
stencil: Default::default(),
bias: Default::default(),
bias: if p.name == "gbuffers_damagedblock" {
wgpu::DepthBiasState {
constant: -10,
slope_scale: -1.0,
clamp: 0.0,
}
} else {
Default::default()
},
}),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
@@ -1891,8 +1908,15 @@ impl PackPost {
post.block,
post.entities_translucent,
post.particles,
post.damaged_block,
];
let late = [
false,
false,
post.translucent_late,
post.particles_late,
false,
];
let late = [false, false, post.translucent_late, post.particles_late];
let shadow_only = (post.shadow_only_player, post.shadow_only_lit_blocks);
let shadow_programs = [post.shadow_entities, post.shadow_block];
let entity_vertices = (programs.iter().chain(&shadow_programs))
@@ -1908,8 +1932,21 @@ impl PackPost {
cull: false,
});
let entity_passes = programs.map(|p| {
p.zip(entity_geometry.as_ref())
.and_then(|(p, g)| build(p, Some(g)))
p.zip(entity_geometry.as_ref()).and_then(|(p, g)| {
if p.name == "gbuffers_damagedblock" {
let decal = Geometry {
buffers: g.buffers,
table: g.table,
depth_write: false,
atlas: g.atlas.clone(),
ids: g.ids,
cull: false,
};
build(p, Some(&decal))
} else {
build(p, Some(g))
}
})
});
let shadow_entities = shadow_programs.map(|p| {
p.zip(entity_geometry.as_ref())
@@ -2241,7 +2278,9 @@ impl PackPost {
draws,
block_ids,
page_groups: HashMap::new(),
gui_size: world.gui_size,
timing: None,
center_depth: None,
custom,
atlas: world.atlas.as_ref().map(|a| a.0.clone()),
lightmap: world.lightmap,
@@ -2740,6 +2779,13 @@ impl PackPost {
let rp = &mut open.as_mut().expect("open").0;
rp.set_pipeline(pass.render());
rp.set_bind_group(0, group, &[]);
// A sky program that reads the pack's SSBOs (Noble's `gbuffers_textured`, the
// sun's fallback) binds them, as the other passes do.
if pass.buffers
&& let Some(g) = &self.buffers.group
{
rp.set_bind_group(4, g, &[]);
}
sky.geometry.bind(rp, part.textured);
open.as_mut().expect("open").1 = i;
}
@@ -2858,6 +2904,7 @@ impl PackPost {
queue: &wgpu::Queue,
entities: &[super::Vertex],
spans: &[super::pack_draw::EntitySpan],
crumbling: [f32; 4],
rest: &mut Vec<super::Vertex>,
) {
let shadow = self.shadow_casters();
@@ -2874,6 +2921,7 @@ impl PackPost {
entities,
spans,
drawn,
(crumbling, self.gui_size),
shadow,
rest,
);
@@ -2896,6 +2944,13 @@ impl PackPost {
}
}
/// After the frame's submission: the centre depth it copied starts its way back.
pub fn submitted(&mut self) {
if let Some(center) = &mut self.center_depth {
center.submitted();
}
}
/// Whether the pack draws entities or block entities itself.
pub fn has_entities(&self) -> bool {
self.entities.is_some()
@@ -3613,6 +3668,10 @@ impl PackPost {
if let Some(t) = &mut self.timing {
t.begin(device, queue);
}
self.uniforms.center_depth_raw = self
.center_depth
.get_or_insert_with(|| super::pack_center_depth::CenterDepth::new(device))
.collect(device);
self.uniforms.update(frame, inputs, size);
// Until the camera's biome is known, `smooth` waits for the world's values.
if !inputs.biome_known {
@@ -3851,6 +3910,10 @@ impl PackPost {
self.draw_translucent(device, queue, encoder, size, terrain);
}
let targets = self.targets.as_ref().expect("resized");
// depthtex0 as the frame leaves it, for next frames' `centerDepthSmooth`.
if let Some(center) = &mut self.center_depth {
center.copy(device, encoder, &targets.depth_texture);
}
let view = |m: u16, i: usize, alt: bool| {
targets.color[i]
.as_ref()
@@ -3949,7 +4012,7 @@ mod tests {
/// made. Such a panic skips the pass, as a validation error does, and the device goes on.
#[test]
fn a_shader_compiler_panic_skips_the_pass() {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
@@ -51,6 +51,12 @@ pub struct PackInputs {
pub cloud_height: f32,
/// The values native mods hand their packs (`frame.mods`, `native_shader_params`).
pub mod_params: [[f32; 4]; 8],
/// The player's state, for Iris's status uniforms.
pub status: PlayerStatus,
/// The End flash: its intensity, 0..1 (0 outside the End), and its direction from the
/// camera in world space, of length 100 (`endFlashIntensity`, `endFlashPosition`).
pub end_flash: f32,
pub end_flash_direction: Vec3,
}
/// The `cloudHeight` uniform. Vanilla reads `visual/cloud_height` only while the cloud color
@@ -64,6 +70,35 @@ pub fn cloud_height(cloud_color_alpha: f32, height: f32) -> f32 {
}
}
/// The local player's state behind Iris's status uniforms (`currentPlayerHealth`,
/// `isSpectator`, `is_burning`...).
#[derive(Clone, Copy, Debug, Default)]
pub struct PlayerStatus {
/// Survival or adventure: the vitals are -1 in creative and spectator.
pub survival: bool,
pub health: f32,
pub max_health: f32,
pub food: f32,
/// Air supply ticks, of 300.
pub air: f32,
/// Armor points (the `armor` attribute).
pub armor: f32,
pub spectator: bool,
pub riding: bool,
pub feet_in_water: bool,
pub swimming: bool,
pub burning: bool,
pub hurt: bool,
pub invisible: bool,
pub sneaking: bool,
pub sprinting: bool,
}
/// `maxPlayerHunger`, `maxPlayerAir` and `maxPlayerArmor`: Minecraft's own maximums.
const MAX_HUNGER: f32 = 20.0;
const MAX_AIR: f32 = 300.0;
const MAX_ARMOR: f32 = 50.0;
/// One uniform's value: up to 16 floats or 3 integers.
#[derive(Clone, Copy, Debug)]
pub enum Value {
@@ -198,6 +233,26 @@ pub const NAMES: &[&str] = &[
"playerLookVector",
"playerBodyVector",
"firstPersonCamera",
"currentPlayerHealth",
"maxPlayerHealth",
"currentPlayerHunger",
"maxPlayerHunger",
"currentPlayerAir",
"maxPlayerAir",
"currentPlayerArmor",
"maxPlayerArmor",
"isSpectator",
"isRiding",
"feetInWater",
"inSwimmingAnimation",
"is_burning",
"is_hurt",
"is_invisible",
"is_sneaking",
"is_sprinting",
"endFlashIntensity",
"endFlashPosition",
"centerDepthSmooth",
"isRightHanded",
"is_on_ground",
"bedrockLevel",
@@ -220,6 +275,9 @@ pub struct FrameUniforms {
previous: Option<(glam::DVec3, Mat4, Mat4)>,
wetness: f32,
eye_smooth: [f32; 2],
/// `depthtex0` at the screen's centre as last read back, and `centerDepthSmooth`.
pub center_depth_raw: Option<f32>,
center_depth: Option<f32>,
/// The pack's shadow settings (7.1).
pub shadow: Shadow,
}
@@ -235,6 +293,8 @@ impl Default for FrameUniforms {
previous: None,
wetness: 0.0,
eye_smooth: [0.0; 2],
center_depth_raw: None,
center_depth: None,
shadow: Shadow::default(),
}
}
@@ -344,9 +404,9 @@ impl FrameUniforms {
frame.depth_far,
);
let camera = frame.camera.as_vec3();
let first_frame = self.previous.is_none();
let (previous_exact, previous_view, previous_projection) =
self.previous
.unwrap_or((glam::DVec3::ZERO, Mat4::ZERO, Mat4::ZERO));
self.previous.unwrap_or((frame.camera, view, projection));
let previous_camera = previous_exact.as_vec3();
self.previous = Some((frame.camera, view, projection));
// The camera's whole blocks and the rest, exact however far from the origin.
@@ -376,9 +436,22 @@ impl FrameUniforms {
};
self.wetness = smooth(self.wetness, inputs.rain, half_life / 20.0, dt);
let eye = inputs.eye_light.map(|l| (l * 16) as f32);
if first_frame {
self.wetness = inputs.rain;
self.eye_smooth = eye;
}
for (smoothed, target) in self.eye_smooth.iter_mut().zip(eye) {
*smoothed = smooth(*smoothed, target, 10.0 / 20.0, dt);
}
// `centerDepthHalflife`'s default, 1 tick (OptiFine's shaders.txt); the first value
// read back is taken as it is.
if let Some(raw) = self.center_depth_raw {
self.center_depth = Some(match self.center_depth {
Some(v) => smooth(v, raw, 1.0 / 20.0, dt),
None => raw,
});
}
let center_depth = self.center_depth.unwrap_or(1.0);
// Vanilla fades by the stronger of two linear fogs; a pack gets one: the pair (start
// and end together) of the one that is complete nearer (OQ-36). Mixing the nearer start
// of one with the nearer end of the other made fog begin at the camera on clear days.
@@ -424,6 +497,8 @@ impl FrameUniforms {
"eyeAltitude" => Value::f(&[frame.camera.y as f32]),
"upPosition" => v3(to_view(Vec3::new(0.0, 100.0, 0.0))),
"sunPosition" => v3(sun),
"endFlashPosition" => v3(to_view(inputs.end_flash_direction)),
"centerDepthSmooth" => Value::f(&[center_depth]),
"moonPosition" => v3(moon),
"shadowLightPosition" => v3(if day { sun } else { moon }),
"sunAngle" => Value::f(&[sun_angle]),
@@ -483,7 +558,7 @@ impl FrameUniforms {
"isElytraFlying" => Value::i(&[inputs.elytra_flying as i32]),
"cloudHeight" => Value::f(&[inputs.cloud_height]),
// renderStage is NONE outside gbuffers draws.
_ => Value::i(&[0]),
_ => status_value(name, inputs).unwrap_or(Value::i(&[0])),
}
};
self.values.clear();
@@ -529,6 +604,46 @@ impl FrameUniforms {
}
}
/// Iris's player status and dimension uniforms, from `inputs`: the vitals are fractions of
/// their maximum, and -1 (with the maximums of health and air) in creative and spectator.
fn status_value(name: &str, inputs: &PackInputs) -> Option<Value> {
let status = inputs.status;
let vital = |v: f32| {
Value::f(&[if status.survival {
v.clamp(0.0, 1.0)
} else {
-1.0
}])
};
let flag = |b: bool| Value::i(&[i32::from(b)]);
Some(match name {
"currentPlayerHealth" => vital(status.health / status.max_health.max(1.0)),
"maxPlayerHealth" => Value::f(&[if status.survival {
status.max_health
} else {
-1.0
}]),
"currentPlayerHunger" => vital(status.food / MAX_HUNGER),
"maxPlayerHunger" => Value::f(&[MAX_HUNGER]),
"currentPlayerAir" => vital(status.air / MAX_AIR),
"maxPlayerAir" => Value::f(&[if status.survival { MAX_AIR } else { -1.0 }]),
"currentPlayerArmor" => vital(status.armor / MAX_ARMOR),
"maxPlayerArmor" => Value::f(&[MAX_ARMOR]),
"isSpectator" => flag(status.spectator),
"isRiding" => flag(status.riding),
"feetInWater" => flag(status.feet_in_water),
"inSwimmingAnimation" => flag(status.swimming),
"is_burning" => flag(status.burning),
"is_hurt" => flag(status.hurt),
// Spectators count as invisible.
"is_invisible" => flag(status.invisible || status.spectator),
"is_sneaking" => flag(status.sneaking),
"is_sprinting" => flag(status.sprinting),
"endFlashIntensity" => Value::f(&[inputs.end_flash]),
_ => return None,
})
}
/// How many components the uniform `name` has (16 for a matrix).
fn components(name: &str) -> usize {
match name {
@@ -547,7 +662,8 @@ fn components(name: &str) -> usize {
| "moonPosition"
| "shadowLightPosition"
| "fogColor"
| "skyColor" => 3,
| "skyColor"
| "endFlashPosition" => 3,
"eyeBrightness" | "eyeBrightnessSmooth" | "atlasSize" => 2,
n if n.starts_with("gbuffer") || n.starts_with("shadow") && n != "shadowAngle" => 16,
_ => 1,
@@ -576,6 +692,77 @@ mod tests {
assert_eq!(out[60..64], [0; 4], "a value past the block is dropped");
}
/// Iris's status uniforms as its documentation defines them (shaders.properties, "Status").
#[test]
fn status_uniforms_follow_iris() {
let float = |name: &str, inputs: &PackInputs| match status_value(name, inputs) {
Some(Value::F(f)) => f[0],
other => panic!("{name}: {other:?}"),
};
let int = |name: &str, inputs: &PackInputs| match status_value(name, inputs) {
Some(Value::I(i)) => i[0],
other => panic!("{name}: {other:?}"),
};
let mut inputs = PackInputs {
status: PlayerStatus {
survival: true,
health: 15.0,
max_health: 20.0,
food: 10.0,
air: 150.0,
armor: 20.0,
sneaking: true,
..Default::default()
},
..Default::default()
};
// The vitals are fractions of their maximums.
assert_eq!(float("currentPlayerHealth", &inputs), 0.75);
assert_eq!(float("maxPlayerHealth", &inputs), 20.0);
assert_eq!(float("currentPlayerHunger", &inputs), 0.5);
assert_eq!(float("maxPlayerHunger", &inputs), 20.0);
assert_eq!(float("currentPlayerAir", &inputs), 0.5);
assert_eq!(float("maxPlayerAir", &inputs), 300.0);
assert_eq!(float("currentPlayerArmor", &inputs), 0.4);
assert_eq!(float("maxPlayerArmor", &inputs), 50.0);
assert_eq!(int("is_sneaking", &inputs), 1);
assert_eq!(int("is_invisible", &inputs), 0);
// Creative and spectator: -1 for the vitals and the maximums of health and air;
// spectators are invisible.
inputs.status.survival = false;
inputs.status.spectator = true;
for name in [
"currentPlayerHealth",
"maxPlayerHealth",
"currentPlayerHunger",
"currentPlayerAir",
"maxPlayerAir",
"currentPlayerArmor",
] {
assert_eq!(float(name, &inputs), -1.0, "{name}");
}
assert_eq!(float("maxPlayerHunger", &inputs), 20.0);
assert_eq!(float("maxPlayerArmor", &inputs), 50.0);
assert_eq!(int("isSpectator", &inputs), 1);
assert_eq!(int("is_invisible", &inputs), 1);
// Each is provided, not left at zero for the pack.
for name in [
"currentPlayerHealth",
"isSpectator",
"is_sprinting",
"bedrockLevel",
] {
assert!(index(name).is_some(), "{name} is not provided");
}
assert!(
NAMES
.iter()
.filter(|n| status_value(n, &inputs).is_some())
.count()
== 18
);
}
#[test]
fn cloud_height_needs_a_cloud_color() {
assert_eq!(cloud_height(0.8, 192.33), 192.33);
@@ -666,3 +853,147 @@ mod tests {
);
}
}
#[cfg(test)]
mod initial_history_tests {
use super::*;
use crate::hud::HudBatch;
fn float(u: &FrameUniforms, name: &str) -> [f32; 16] {
match u.values[index(name).unwrap()] {
Value::F(v) => v,
_ => panic!("float uniform expected"),
}
}
#[test]
fn first_history_is_current_then_tracks_the_actual_previous_frame() {
let hud = HudBatch::new([800, 450]);
let mut f = frame(&hud);
let mut u = FrameUniforms::default();
f.camera = DVec3::new(1024.25, -3.5, 11.75);
f.yaw = 21.0;
f.pitch = -16.0;
u.update(&f, &PackInputs::default(), [800, 450]);
assert_eq!(
float(&u, "previousCameraPosition"),
float(&u, "cameraPosition")
);
for (prior, current) in [
("gbufferPreviousProjection", "gbufferProjection"),
("gbufferPreviousModelView", "gbufferModelView"),
] {
assert_eq!(float(&u, prior), float(&u, current));
assert!(float(&u, prior).iter().any(|v| *v != 0.0));
}
let camera = float(&u, "cameraPosition");
let view = float(&u, "gbufferModelView");
let projection = float(&u, "gbufferProjection");
f.camera = DVec3::new(-100.0, 20.0, 40.0);
f.yaw = 90.0;
f.pitch = 50.0;
f.fov_degrees = 100.0;
u.update(&f, &PackInputs::default(), [1000, 500]);
assert_eq!(float(&u, "previousCameraPosition"), camera);
assert_eq!(float(&u, "gbufferPreviousModelView"), view);
assert_eq!(float(&u, "gbufferPreviousProjection"), projection);
assert_ne!(
float(&u, "previousCameraPosition"),
float(&u, "cameraPosition")
);
}
#[test]
fn environmental_smoothing_starts_at_the_first_inputs_without_resetting_each_frame() {
let hud = HudBatch::new([800, 450]);
let f = frame(&hud);
let mut u = FrameUniforms::default();
let mut input = PackInputs {
rain: 0.75,
eye_light: [3, 14],
..Default::default()
};
u.update(&f, &input, [800, 450]);
assert_eq!(float(&u, "wetness")[0], 0.75);
match u.values[index("eyeBrightnessSmooth").unwrap()] {
Value::I(v) => assert_eq!(&v[..2], &[48, 224]),
_ => panic!("integer uniform expected"),
}
input.rain = 0.0;
input.eye_light = [0, 0];
u.update(&f, &input, [800, 450]);
assert!(float(&u, "wetness")[0] > 0.0);
assert!(u.eye_smooth.iter().all(|v| *v > 0.0));
}
fn frame(hud: &HudBatch) -> Frame<'_> {
Frame {
eye: glam::DVec3::ZERO,
eye_rotation: [0.0; 2],
first_person: true,
shadow_scene_revision: (0, 0),
shadow_cache: false,
water_plane: None,
weather: &[],
weather_time: crate::weather::Time::new(0, 0.0),
camera: glam::DVec3::new(0.5, 100.0, 0.5),
yaw: 0.0,
pitch: 10.0,
fov_degrees: 85.0,
bob: glam::Mat4::IDENTITY,
spin: glam::Mat4::IDENTITY,
sky: crate::environment::Sky::default(),
distance: 12.0,
detail_distance: 12.0,
depth_far: 1000.0,
underwater: false,
cave_culling: false,
title: true,
sky_blocked: false,
skybox: false,
end_sky: false,
end_sky_sprite: None,
environment_fog: None,
water_fog: [0.0; 3],
fog: None,
dark_disc: false,
outline: Vec::new(),
outline_after_terrain: false,
outline_high_contrast: false,
debug_lines: &[],
debug_lines_on_top: &[],
debug_crosshair: &[],
hud,
glint_alpha: 0.75,
glint_ticks: 0.0,
nether_lighting: false,
entities: &[],
entity_motion: &[],
colored_lights: &[],
emissive_layers: &[],
shadow_only: &[],
entity_spans: &[],
glowing: &[],
dynamic_lights: &[],
clouds: None,
lightning: Default::default(),
additive: &[],
see_through: &[],
celestial: &[],
stars: None,
hand: &[],
hand_fov_degrees: 70.0,
screen: &[],
pack: Default::default(),
border: None,
game_time: 0.0,
shader_game_time: 0.0,
end_portal_sprites: [[0.0; 4]; 2],
crumbling_sprite: [0.0; 4],
ticks: 0,
darkness: (0.0, 0.0),
night_vision: 0.0,
block_flicker: 0.0,
boss_darken: 0.0,
menu_blur: None,
chunk_fade: 0.0,
post_effects: crate::post_chain::Request::none(),
}
}
}
@@ -16,6 +16,8 @@
/// The width of a band, in blocks.
const BAND: f64 = 64.0;
/// Where a run's group sits in its sort key, above band and page.
const GROUP_SHIFT: u32 = 62;
/// One section's draws: `start..start + len` of the list.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -27,6 +29,11 @@ pub(super) struct Run {
pub tag: [u32; 3],
}
/// The band of a section `distance` from the camera (squared, as bits).
pub(super) fn band(distance: u64) -> u64 {
(f64::from_bits(distance).sqrt() / BAND) as u64
}
#[derive(Default)]
pub(super) struct PageRuns {
runs: Vec<Run>,
@@ -41,10 +48,24 @@ impl PageRuns {
/// The draws `start..start + len` of a section `distance` from the
/// camera (squared, as bits) in arena `page`; runs come nearest first.
pub fn push(&mut self, distance: u64, page: u32, start: usize, len: usize, tag: [u32; 3]) {
self.push_in(0, distance, page, start, len, tag);
}
/// As `push`, in `group` (0 to 3): regrouping puts each group's runs
/// before the next group's, then bands and pages within it.
pub fn push_in(
&mut self,
group: u8,
distance: u64,
page: u32,
start: usize,
len: usize,
tag: [u32; 3],
) {
if len > 0 {
let band = (f64::from_bits(distance).sqrt() / BAND) as u64;
let band = band(distance);
self.runs.push(Run {
key: band << 32 | u64::from(page),
key: u64::from(group) << GROUP_SHIFT | band << 32 | u64::from(page),
start: start as u32,
len: len as u32,
tag,
@@ -61,7 +82,8 @@ impl PageRuns {
pages: &mut [(u32, Option<u32>)],
base: usize,
) -> &[Run] {
let page = |r: &Run| r.key as u32;
// Group and page: one of each needs no reordering.
let page = |r: &Run| r.key & (3 << GROUP_SHIFT | u64::from(u32::MAX));
if self.runs.windows(2).all(|w| page(&w[0]) == page(&w[1])) {
return &self.runs;
}
@@ -157,6 +179,24 @@ mod tests {
assert_eq!(order, [0, 1, 2]);
}
/// Metal's two solid passes draw a prefix and a suffix of the list:
/// groups come whole and in order, whatever their distance and page.
#[test]
fn groups_come_before_bands_and_pages() {
let mut grouped = PageRuns::default();
let mut draws: Vec<_> = (0..5).map(|n| [n, 0, 0, 0, 0]).collect();
let mut pages = vec![(0, None); 5];
for (n, (group, distance)) in [(2, 10.0), (0, 300.0), (1, 20.0), (0, 30.0), (2, 5.0)]
.into_iter()
.enumerate()
{
grouped.push_in(group, bits(distance), 0, n, 1, [0; 3]);
}
grouped.regroup(&mut draws, &mut pages, 0);
let order: Vec<_> = draws.iter().map(|d| d[0]).collect();
assert_eq!(order, [3, 1, 2, 0, 4]);
}
#[test]
fn one_page_and_an_offset_list() {
let (mut grouped, mut draws, mut pages) = list(&[(10.0, 3, 2), (90.0, 3, 1)]);
+267 -77
View File
@@ -1,36 +1,64 @@
//! Vanilla 26.3's CubeMapTexture and CubeMap, separate from the pixel-filtered GUI atlas.
use crate::{Result, hud::decode};
use crate::{Result, hud::decode_bytes};
use glam::Mat4;
use std::path::Path;
use std::{
path::Path,
sync::{Arc, Mutex},
};
use wgpu::util::DeviceExt;
/// Native cube order: +X, -X, +Y, -Y, +Z, -Z (CubeMapTexture.SUFFIXES).
const FACE_ORDER: [u8; 6] = [1, 3, 5, 4, 0, 2];
/// Where the cube's faces live in a resource root.
const BACKGROUND: &str = "assets/minecraft/textures/gui/title/background";
/// The title screen's six faces. The cube is on the GPU only while the title
/// screen shows, and its texels are decoded from the faces' files each time it
/// is made: kept decoded, they held 24 MiB through the whole game.
#[derive(Debug)]
pub struct Faces {
pub size: u32,
pub pixels: Vec<u8>,
/// The faces' PNG files in cube order; none for a one-colour cube.
files: Vec<Vec<u8>>,
/// The texel of a one-colour cube.
colour: [u8; 4],
/// The texels decoded while loading, which the first cube takes.
decoded: Mutex<Option<Vec<u8>>>,
}
impl Faces {
pub fn load(root: &Path) -> Self {
Self::try_load(root).unwrap_or_else(|error| {
log::warn!("Horizon: title panorama unavailable ({error})");
Self {
size: 1,
pixels: [96, 110, 111, 255].repeat(6),
}
/// The title artwork of `root` (the packs' overlay) over `defaults` (the
/// game's own resources). `CubeMapTexture` registers each face with the
/// resource manager, which takes the topmost pack that has it: a pack that
/// leaves a face alone keeps vanilla's, and only a cube no layer completes
/// falls back to the grey placeholder that an installation without the
/// imported panorama shows.
pub fn load(root: &Path, defaults: &Path) -> Self {
Self::try_load(root, defaults).unwrap_or_else(|error| {
log::warn!(
"Horizon: no title panorama ({error}); prepare the game again in the launcher \
(or run horizon-prepare panorama)"
);
Self::colour([96, 110, 111, 255])
})
}
fn try_load(root: &Path) -> Result<Self> {
let background = root.join("assets/minecraft/textures/gui/title/background");
/// A one-texel cube of one colour.
pub fn colour(colour: [u8; 4]) -> Self {
Self {
size: 1,
files: Vec::new(),
colour,
decoded: Mutex::new(None),
}
}
fn try_load(root: &Path, defaults: &Path) -> Result<Self> {
let mut size = 0;
let mut pixels = Vec::new();
let mut files = Vec::with_capacity(FACE_ORDER.len());
let mut texels = Vec::new();
for face in FACE_ORDER {
let path = background.join(format!("panorama_{face}.png"));
let (w, h, data) = decode(&path)?;
let (path, file, (w, h, data)) = load_face(root, defaults, face)?;
if w != h || (size != 0 && w != size) {
return Err(format!(
"{}: panorama faces must be equally sized squares",
@@ -41,11 +69,104 @@ impl Faces {
size = w;
// CubeMapTexture copies each original image with flipY=true. Keep every texel.
for row in data.chunks_exact((w * 4) as usize).rev() {
pixels.extend_from_slice(row);
texels.extend_from_slice(row);
}
files.push(file);
}
Ok(Self { size, pixels })
// Decoded once here to check them, and kept for the first cube.
Ok(Self {
size,
files,
colour: [0; 4],
decoded: Mutex::new(Some(texels)),
})
}
/// The cube's texels, layer after layer.
pub fn texels(&self) -> Vec<u8> {
if self.files.is_empty() {
return self.colour.repeat(6);
}
let loaded = self
.decoded
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.take();
loaded
.or_else(|| decode_faces(&self.files).ok().map(|(_, texels)| texels))
.unwrap_or_else(|| vec![0; (self.size * self.size * 4 * 6) as usize])
}
}
/// The faces' files decoded, side by side, into one array of six layers of
/// equal squares.
fn decode_faces(files: &[Vec<u8>]) -> Result<(u32, Vec<u8>)> {
let decoded: Vec<_> = std::thread::scope(|scope| {
let threads: Vec<_> = files
.iter()
.map(|file| scope.spawn(|| decode_bytes(file).map_err(|e| e.to_string())))
.collect();
threads
.into_iter()
.map(|t| t.join().unwrap_or_else(|p| std::panic::resume_unwind(p)))
.collect()
});
let mut size = 0;
let mut texels = Vec::new();
for (face, image) in FACE_ORDER.iter().zip(decoded) {
let (w, h, data) = image?;
if w != h || (size != 0 && w != size) {
return Err(format!(
"panorama_{face}.png: panorama faces must be equally sized squares"
)
.into());
}
if size == 0 {
size = w;
texels.reserve_exact((w * w * 4) as usize * files.len());
}
// CubeMapTexture copies each original image with flipY=true. Keep every texel.
for row in data.chunks_exact((w * 4) as usize).rev() {
texels.extend_from_slice(row);
}
}
Ok((size, texels))
}
/// `panorama_{face}.png` from the first layer that has one, with the path it
/// came from, its file and its decoded image. Reading a face that no layer
/// provides fails naming every path tried, so a report of a missing panorama
/// says where the game looked.
#[allow(clippy::type_complexity)]
fn load_face(
root: &Path,
defaults: &Path,
face: u8,
) -> Result<(std::path::PathBuf, Vec<u8>, (u32, u32, Vec<u8>))> {
let name = format!("panorama_{face}.png");
let mut tried: Vec<(std::path::PathBuf, String)> = Vec::new();
for layer in [root, defaults] {
let path = layer.join(BACKGROUND).join(&name);
// A vanilla-only installation loads one root twice.
if tried.first().is_some_and(|(seen, _)| *seen == path) {
continue;
}
let read = std::fs::read(&path)
.map_err(|e| e.to_string())
.and_then(|file| {
let image = decode_bytes(&file).map_err(|e| e.to_string())?;
Ok((file, image))
});
match read {
Ok((file, image)) => return Ok((path, file, image)),
Err(error) => tried.push((path, error)),
}
}
let tried: Vec<String> = tried
.iter()
.map(|(path, error)| format!("{}: {error}", path.display()))
.collect();
Err(tried.join("; ").into())
}
fn projection(width: u32, height: u32, yaw: f32, pitch: f32) -> Mat4 {
@@ -62,40 +183,20 @@ fn projection(width: u32, height: u32, yaw: f32, pitch: f32) -> Mat4 {
pub(super) struct Panorama {
pipeline: wgpu::RenderPipeline,
group: wgpu::BindGroup,
layout: wgpu::BindGroupLayout,
sampler: wgpu::Sampler,
uniform: wgpu::Buffer,
faces: Arc<Faces>,
/// The cube and its bindings, while the title screen shows.
cube: Option<wgpu::BindGroup>,
}
impl Panorama {
pub(super) fn new(
device: &wgpu::Device,
queue: &wgpu::Queue,
format: wgpu::TextureFormat,
faces: &Faces,
faces: Arc<Faces>,
) -> Self {
let texture = device.create_texture_with_data(
queue,
&wgpu::TextureDescriptor {
label: Some("Title panorama cube"),
size: wgpu::Extent3d {
width: faces.size,
height: faces.size,
depth_or_array_layers: 6,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
},
wgpu::util::TextureDataOrder::LayerMajor,
&faces.pixels,
);
let view = texture.create_view(&wgpu::TextureViewDescriptor {
dimension: Some(wgpu::TextureViewDimension::Cube),
..Default::default()
});
// CubeMapTexture's blur=true metadata: linear minification and magnification, no mips.
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("Title panorama linear filtering"),
@@ -140,24 +241,6 @@ impl Panorama {
},
],
});
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Title panorama resources"),
layout: &layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: uniform.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(&view),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(&sampler),
},
],
});
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Title panorama shader"),
source: wgpu::ShaderSource::Wgsl(include_str!("panorama.wgsl").into()),
@@ -203,13 +286,18 @@ impl Panorama {
});
Self {
pipeline,
group,
layout,
sampler,
uniform,
faces,
cube: None,
}
}
/// Ready a title screen frame: the cube, made if it is not on the GPU.
pub(super) fn prepare(
&self,
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
width: u32,
height: u32,
@@ -221,11 +309,68 @@ impl Panorama {
0,
bytemuck::cast_slice(&projection(width, height, yaw, pitch).to_cols_array()),
);
if self.cube.is_none() {
self.cube = Some(self.cube(device, queue));
}
}
/// Give the cube's video memory back: no frame draws it until the title
/// screen shows again.
pub(super) fn release(&mut self) {
self.cube = None;
}
fn cube(&self, device: &wgpu::Device, queue: &wgpu::Queue) -> wgpu::BindGroup {
let texture = device.create_texture_with_data(
queue,
&wgpu::TextureDescriptor {
label: Some("Title panorama cube"),
size: wgpu::Extent3d {
width: self.faces.size,
height: self.faces.size,
depth_or_array_layers: 6,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
},
wgpu::util::TextureDataOrder::LayerMajor,
&self.faces.texels(),
);
let view = texture.create_view(&wgpu::TextureViewDescriptor {
dimension: Some(wgpu::TextureViewDimension::Cube),
..Default::default()
});
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Title panorama resources"),
layout: &self.layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: self.uniform.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(&view),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(&self.sampler),
},
],
})
}
/// Draw the cube `prepare` made for this frame.
pub(super) fn draw(&self, pass: &mut wgpu::RenderPass<'_>) {
let Some(cube) = &self.cube else {
return;
};
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &self.group, &[]);
pass.set_bind_group(0, cube, &[]);
pass.draw(0..36, 0..1);
}
}
@@ -253,9 +398,18 @@ mod tests {
sync::atomic::{AtomicU32, Ordering},
};
/// One resource root: a pack overlay, or the game's own resources.
struct Fixture(std::path::PathBuf);
impl Fixture {
fn new(size: u32) -> Self {
let fixture = Self::bare();
for face in 0..6 {
fixture.write(face, size, size);
}
fixture
}
/// A root with the faces' folder and none of the faces.
fn bare() -> Self {
static NEXT: AtomicU32 = AtomicU32::new(0);
let root = std::env::temp_dir().join(format!(
"horizon-panorama-{}-{}",
@@ -264,9 +418,6 @@ mod tests {
));
let fixture = Self(root);
fs::create_dir_all(fixture.background()).unwrap();
for face in 0..6 {
fixture.write(face, size, size);
}
fixture
}
fn background(&self) -> std::path::PathBuf {
@@ -309,16 +460,19 @@ mod tests {
#[test]
fn original_resolution_texels_and_vanilla_cube_orientation_are_preserved() {
let f = Fixture::new(1024);
let faces = Faces::try_load(&f.0).unwrap();
let faces = Faces::try_load(&f.0, &f.0).unwrap();
assert_eq!(faces.size, 1024);
assert_eq!(faces.pixels.len(), 6 * 1024 * 1024 * 4);
// The texels decoded while loading, then decoded again for the next cube.
let (loaded, again) = (faces.texels(), faces.texels());
assert_eq!(loaded, again);
assert_eq!(again.len(), 6 * 1024 * 1024 * 4);
// CubeMapTexture's actual layer suffixes, with each source row flipped vertically.
for (layer, source_face) in [1, 3, 5, 4, 0, 2].into_iter().enumerate() {
for y in 0..1024 {
for x in 0..1024 {
let at = ((layer * 1024 + y) * 1024 + x) * 4;
assert_eq!(
faces.pixels[at..at + 4],
again[at..at + 4],
[source_face, (x % 251) as u8, ((1023 - y) % 253) as u8, 255]
);
}
@@ -329,21 +483,57 @@ mod tests {
#[test]
fn placeholders_load_and_incomplete_or_mismatched_cubes_fall_back() {
let f = Fixture::new(1);
assert_eq!(Faces::try_load(&f.0).unwrap().size, 1);
assert_eq!(Faces::try_load(&f.0, &f.0).unwrap().size, 1);
f.write(5, 2, 1);
assert!(
Faces::try_load(&f.0)
Faces::try_load(&f.0, &f.0)
.unwrap_err()
.to_string()
.contains("equally sized squares")
);
let fallback = Faces::load(&f.0);
assert_eq!(fallback.pixels.len(), 24);
let fallback = Faces::load(&f.0, &f.0);
assert_eq!(fallback.texels(), [96, 110, 111, 255].repeat(6));
f.write(5, 2, 2);
assert!(Faces::try_load(&f.0).is_err());
assert!(Faces::try_load(&f.0, &f.0).is_err());
fs::remove_file(f.background().join("panorama_5.png")).unwrap();
assert!(Faces::try_load(&f.0).is_err());
assert_eq!(Faces::load(&f.0).size, 1);
assert!(Faces::try_load(&f.0, &f.0).is_err());
assert_eq!(Faces::load(&f.0, &f.0).size, 1);
}
#[test]
fn a_pack_without_a_face_keeps_vanillas_and_its_own_face_wins() {
let vanilla = Fixture::new(4);
// A pack that replaces one face of the cube: the other five are
// still the game's, as the bottom of the pack stack provides them.
let pack = Fixture::bare();
pack.write(2, 4, 4);
let faces = Faces::try_load(&pack.0, &vanilla.0).unwrap();
assert_eq!(faces.size, 4);
let texels = faces.texels();
assert_eq!(texels.len(), 6 * 4 * 4 * 4);
// Each face's red channel is its index (`face` above), and the cube
// holds them in FACE_ORDER: layer 5 is face 2, the pack's, and layer 4
// is face 0, which only the game's resources have.
let red = |layer: usize, texel: usize| texels[(layer * 16 + texel) * 4];
assert_eq!(red(5, 0), 2);
assert_eq!(red(4, 0), 0);
}
#[test]
fn a_missing_cube_names_every_path_it_tried() {
let vanilla = Fixture::bare();
let pack = Fixture::bare();
let error = Faces::try_load(&pack.0, &vanilla.0)
.unwrap_err()
.to_string();
for root in [&pack, &vanilla] {
let path = root.background().join("panorama_1.png");
assert!(error.contains(&path.display().to_string()), "{error}");
}
// No layer completes the cube: the one-pixel grey placeholder, and the
// window still has a title.
let fallback = Faces::load(&pack.0, &vanilla.0);
assert_eq!((fallback.size, fallback.texels().len()), (1, 24));
}
#[test]
@@ -85,7 +85,7 @@ mod tests {
use super::*;
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
pollster::block_on(adapter.request_device(&Default::default())).ok()
}
@@ -0,0 +1,181 @@
//! Pipelines kept between the renderers built on one device. The start builds
//! three (the boot screen's, the title artwork's, the world's) and a resource
//! reload or a graphics option builds another; each compiled every shader
//! module and pipeline again, though most come out identical (the boot and
//! title renderers' entirely, the world's entity pipelines). On a driver that
//! compiles slowly (a cold shader cache, llvmpipe, a weak CPU) that was seconds
//! of the main thread before the title showed.
//!
//! An object is kept under its descriptor's `Debug` text: wgpu prints the
//! labels, sources, constants, formats and states, and the handles a descriptor
//! names (shader modules, layouts) by their ids, so a descriptor that names a
//! kept handle matches only the same handle. Kept objects stay alive, so their
//! ids are never reused while they are keys.
use std::{
collections::HashMap,
sync::{Mutex, PoisonError},
};
/// One map of kept objects with the generation each was last asked for in.
struct Kept<T> {
map: Mutex<HashMap<String, (T, u32)>>,
}
impl<T> Default for Kept<T> {
fn default() -> Self {
Self {
map: Mutex::new(HashMap::new()),
}
}
}
impl<T: Clone> Kept<T> {
/// The object kept under `key`, else `make`'s, kept. The lock is not
/// held while `make` compiles.
fn get(&self, key: String, generation: u32, make: impl FnOnce() -> T) -> T {
if let Some((object, used)) = self
.map
.lock()
.unwrap_or_else(PoisonError::into_inner)
.get_mut(&key)
{
*used = generation;
return object.clone();
}
let object = make();
self.map
.lock()
.unwrap_or_else(PoisonError::into_inner)
.insert(key, (object.clone(), generation));
object
}
fn keep_only(&self, generation: u32) {
self.map
.lock()
.unwrap_or_else(PoisonError::into_inner)
.retain(|_, (_, used)| *used == generation);
}
fn len(&self) -> usize {
self.map
.lock()
.unwrap_or_else(PoisonError::into_inner)
.len()
}
}
/// The shader modules, layouts and pipelines of the device's renderers.
#[derive(Default)]
pub struct Reuse {
generation: std::sync::atomic::AtomicU32,
shaders: Kept<wgpu::ShaderModule>,
bind_group_layouts: Kept<wgpu::BindGroupLayout>,
pipeline_layouts: Kept<wgpu::PipelineLayout>,
render: Kept<wgpu::RenderPipeline>,
}
impl Reuse {
/// A renderer starts building: what it asks for is kept, the rest is let
/// go by [`Reuse::finish`].
pub fn begin(&self) -> u32 {
self.generation
.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
.wrapping_add(1)
}
/// The renderer of `generation` is built: objects only older renderers
/// used are dropped here (the renderers still alive hold their own).
pub fn finish(&self, generation: u32) {
self.shaders.keep_only(generation);
self.bind_group_layouts.keep_only(generation);
self.pipeline_layouts.keep_only(generation);
self.render.keep_only(generation);
}
/// How many objects are kept.
pub fn len(&self) -> usize {
self.shaders.len()
+ self.bind_group_layouts.len()
+ self.pipeline_layouts.len()
+ self.render.len()
}
fn current(&self) -> u32 {
self.generation.load(std::sync::atomic::Ordering::Relaxed)
}
/// `device`'s `create_*` calls through this cache.
pub fn on<'a>(&'a self, device: &'a wgpu::Device) -> On<'a> {
On {
reuse: self,
device,
generation: self.current(),
}
}
}
/// A device whose shader modules, layouts and render pipelines come from a
/// [`Reuse`] when one with the same descriptor is kept.
pub struct On<'a> {
reuse: &'a Reuse,
device: &'a wgpu::Device,
generation: u32,
}
impl On<'_> {
pub fn create_shader_module(
&self,
descriptor: wgpu::ShaderModuleDescriptor<'_>,
) -> wgpu::ShaderModule {
let key = format!("{descriptor:?}");
self.reuse.shaders.get(key, self.generation, || {
self.device.create_shader_module(descriptor)
})
}
pub fn create_bind_group_layout(
&self,
descriptor: &wgpu::BindGroupLayoutDescriptor<'_>,
) -> wgpu::BindGroupLayout {
let key = format!("{descriptor:?}");
self.reuse.bind_group_layouts.get(key, self.generation, || {
self.device.create_bind_group_layout(descriptor)
})
}
pub fn create_pipeline_layout(
&self,
descriptor: &wgpu::PipelineLayoutDescriptor<'_>,
) -> wgpu::PipelineLayout {
let key = format!("{descriptor:?}");
self.reuse.pipeline_layouts.get(key, self.generation, || {
self.device.create_pipeline_layout(descriptor)
})
}
pub fn create_render_pipeline(
&self,
descriptor: &wgpu::RenderPipelineDescriptor<'_>,
) -> wgpu::RenderPipeline {
let key = format!("{descriptor:?}");
self.reuse.render.get(key, self.generation, || {
self.device.create_render_pipeline(descriptor)
})
}
}
#[cfg(test)]
mod tests {
use super::Kept;
/// A key asked for again gets the kept object without making one; what
/// the latest generation did not ask for is let go when it finishes.
#[test]
fn kept_objects_are_shared_until_a_build_stops_asking() {
let kept = Kept::<u32>::default();
let made = std::cell::Cell::new(0);
let make = |value| {
made.set(made.get() + 1);
value
};
assert_eq!(kept.get("a".into(), 1, || make(10)), 10);
assert_eq!(kept.get("b".into(), 1, || make(20)), 20);
kept.keep_only(1);
assert_eq!(kept.len(), 2);
// The next build asks for "a" only.
assert_eq!(kept.get("a".into(), 2, || make(11)), 10);
kept.keep_only(2);
assert_eq!(kept.len(), 1);
assert_eq!(kept.get("b".into(), 3, || make(21)), 21);
assert_eq!(made.get(), 3);
}
}
@@ -0,0 +1,242 @@
//! The frame's commands finished, submitted and presented on a thread of their own.
//!
//! wgpu records a pass's commands lazily: `CommandEncoder::finish` validates and
//! encodes the whole frame (about 0.1 ms with the submit), work the window's thread
//! would otherwise wait for before building the next frame. The renderer hands the
//! frame's encoder to [`Queue::submit_later`] and goes on; every use of the queue
//! ([`Deref`]) first waits for that frame to be submitted, so writes, copies and
//! submissions keep the order they had on one thread.
use std::ops::Deref;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Mutex, mpsc};
use std::time::{Duration, Instant};
/// Whether frames go to the submit thread, unless `HORIZON_SUBMIT_THREAD` says (`0` off, `1` on):
/// on Linux, where it was measured faster (+32% FPS in a CPU-bound view on Vulkan, #5779). On
/// macOS's Metal it stalled: offscreen at render distance 12, a tenth to a half of the frame rate
/// with 20-180 ms p99 frames, against the same binary without it. Windows is not measured yet.
pub fn wanted() -> bool {
match std::env::var("HORIZON_SUBMIT_THREAD").as_deref() {
Ok("0") => false,
Ok("1") => true,
_ => cfg!(target_os = "linux"),
}
}
/// Work to start right after a frame's submission (a readback's `map_async`).
pub type AfterSubmit = Box<dyn FnOnce() + Send>;
/// A frame for the submit thread: its commands and, in a window, the surface to present.
pub struct Job {
pub encoder: wgpu::CommandEncoder,
pub surface: Option<wgpu::SurfaceTexture>,
pub after: Option<AfterSubmit>,
}
struct Thread {
jobs: Option<mpsc::Sender<Job>>,
handle: Option<std::thread::JoinHandle<()>>,
}
/// What the submit thread sent back, once it is taken in by [`Queue::wait`].
struct Landing {
done: mpsc::Receiver<wgpu::SubmissionIndex>,
landed: Option<wgpu::SubmissionIndex>,
}
/// The device's queue, with frames submitted on the submit thread when it runs.
pub struct Queue {
queue: wgpu::Queue,
thread: Option<Thread>,
/// A frame went to the submit thread and has not been waited for.
pending: AtomicBool,
landing: Mutex<Option<Landing>>,
/// Nanoseconds the window's thread waited for the submit thread since the last
/// [`take_waited`](Self::take_waited): the GPU or the display holding a frame back, not work.
waited: AtomicU64,
}
impl Queue {
/// `threaded`: frames may go to a submit thread (the renderer asks [`wanted`]).
pub fn new(queue: wgpu::Queue, threaded: bool) -> Self {
let mut this = Self {
queue,
thread: None,
pending: AtomicBool::new(false),
landing: Mutex::new(None),
waited: AtomicU64::new(0),
};
if !threaded {
return this;
}
let (jobs, job_rx) = mpsc::channel::<Job>();
let (done_tx, done) = mpsc::channel();
let queue = this.queue.clone();
let spawned = std::thread::Builder::new()
.name("Render submit".into())
.spawn(move || {
for job in job_rx {
let commands = job.encoder.finish();
let submission = queue.submit([commands]);
if let Some(after) = job.after {
after();
}
if let Some(surface) = job.surface {
queue.present(surface);
}
if done_tx.send(submission).is_err() {
break;
}
}
});
match spawned {
Ok(handle) => {
this.thread = Some(Thread {
jobs: Some(jobs),
handle: Some(handle),
});
*this.lock() = Some(Landing { done, landed: None });
}
Err(e) => log::warn!("Horizon renderer: no submit thread ({e}); submitting in place"),
}
this
}
fn lock(&self) -> std::sync::MutexGuard<'_, Option<Landing>> {
self.landing
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// Whether frames can go to the submit thread.
pub fn threaded(&self) -> bool {
self.thread.is_some()
}
/// Finish, submit and present the frame on the submit thread, then run its `after`;
/// given back, with nothing done, when there is none.
pub fn submit_later(&self, job: Job) -> Result<(), Box<Job>> {
let Some(jobs) = self.thread.as_ref().and_then(|t| t.jobs.as_ref()) else {
return Err(Box::new(job));
};
self.wait();
match jobs.send(job) {
Ok(()) => {
self.pending.store(true, Ordering::Release);
Ok(())
}
Err(mpsc::SendError(job)) => Err(Box::new(job)),
}
}
/// Wait until the frame on the submit thread is submitted and presented.
pub fn wait(&self) {
if !self.pending.load(Ordering::Acquire) {
return;
}
let mut landing = self.lock();
if !self.pending.load(Ordering::Acquire) {
return;
}
if let Some(l) = landing.as_mut() {
let started = Instant::now();
// A thread that died (a wgpu panic on it) panics here, as it would have in place.
let submission = l
.done
.recv()
.expect("Horizon renderer: the submit thread stopped");
l.landed = Some(submission);
let nanos = u64::try_from(started.elapsed().as_nanos()).unwrap_or(u64::MAX);
self.waited.fetch_add(nanos, Ordering::Relaxed);
}
self.pending.store(false, Ordering::Release);
}
/// How long the window's thread waited for the submit thread since the last call: in a
/// GPU-bound view, the previous frame's submission and present hold up every use of the
/// queue (uploads in the frame's first half too), and that is waiting, not the frame's work.
pub fn take_waited(&self) -> Duration {
Duration::from_nanos(self.waited.swap(0, Ordering::Relaxed))
}
/// The submission of the frame [`submit_later`](Self::submit_later) took, once it is
/// made; `None` if it was taken already.
pub fn landed(&self) -> Option<wgpu::SubmissionIndex> {
self.wait();
self.lock().as_mut().and_then(|l| l.landed.take())
}
}
impl Deref for Queue {
type Target = wgpu::Queue;
fn deref(&self) -> &wgpu::Queue {
self.wait();
&self.queue
}
}
impl Drop for Queue {
fn drop(&mut self) {
self.wait();
if let Some(thread) = &mut self.thread {
thread.jobs.take();
if let Some(handle) = thread.handle.take() {
let _ = handle.join();
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
pollster::block_on(adapter.request_device(&Default::default())).ok()
}
fn buffer(device: &wgpu::Device, usage: wgpu::BufferUsages) -> wgpu::Buffer {
device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 4,
usage,
mapped_at_creation: false,
})
}
#[test]
fn writes_after_a_frame_on_the_thread_land_after_it() {
let Some((device, queue)) = device() else {
return;
};
let queue = Queue::new(queue, true);
assert!(queue.threaded());
use wgpu::BufferUsages as U;
let source = buffer(&device, U::COPY_SRC | U::COPY_DST);
let copy = buffer(&device, U::COPY_SRC | U::COPY_DST);
let read = buffer(&device, U::MAP_READ | U::COPY_DST);
queue.write_buffer(&source, 0, &[1, 2, 3, 4]);
for frame in 0..3u8 {
// The frame copies `source`; the next frame's write must not reach that copy.
let mut encoder = device.create_command_encoder(&Default::default());
encoder.copy_buffer_to_buffer(&source, 0, &copy, 0, 4);
let job = Job {
encoder,
surface: None,
after: None,
};
assert!(queue.submit_later(job).is_ok());
queue.write_buffer(&source, 0, &[frame + 10; 4]);
assert!(queue.landed().is_some(), "frame {frame} submitted");
let mut encoder = device.create_command_encoder(&Default::default());
encoder.copy_buffer_to_buffer(&copy, 0, &read, 0, 4);
queue.submit([encoder.finish()]);
read.slice(..)
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let expected = if frame == 0 {
[1, 2, 3, 4]
} else {
[frame + 9; 4]
};
assert_eq!(*read.slice(..).get_mapped_range().unwrap(), expected);
read.unmap();
}
}
}
@@ -0,0 +1,14 @@
//! The GPU tests' `wgpu::Instance`.
/// A new instance for one GPU test. On Windows it opens Vulkan and DirectX 12,
/// not OpenGL: WGL instances made and dropped from many test threads at once
/// deadlocked NVIDIA's driver, and the render tests hung most runs (#5238).
/// The game runs there on Vulkan or DirectX 12 unless neither works.
/// `WGPU_BACKEND` and the other `WGPU_*` variables still apply, as in the game.
pub(crate) fn instance() -> wgpu::Instance {
let mut descriptor = wgpu::InstanceDescriptor::new_without_display_handle();
if cfg!(windows) {
descriptor.backends = wgpu::Backends::PRIMARY;
}
wgpu::Instance::new(descriptor.with_env())
}
+92 -3
View File
@@ -30,6 +30,8 @@ const DONE: u8 = 2;
/// Resolved and on its way back.
const PENDING: u8 = 3;
const MAPPED: u8 = 4;
/// Readback failed; stop timing rather than reuse an invalid buffer.
const FAILED: u8 = 5;
struct Slot {
buffer: wgpu::Buffer,
@@ -131,6 +133,14 @@ impl GpuTimer {
})
}
pub fn begin(&mut self, encoder: &mut wgpu::CommandEncoder) {
if self
.slots
.iter()
.any(|slot| slot.state.load(Ordering::Acquire) == FAILED)
{
self.current = None;
return;
}
if self.markers.is_some() {
for slot in 0..SLOTS {
if self.slots[slot].state.load(Ordering::Acquire) == DONE {
@@ -216,7 +226,7 @@ impl GpuTimer {
.slice(..)
.map_async(wgpu::MapMode::Read, move |result| {
state.store(
if result.is_ok() { MAPPED } else { FREE },
if result.is_ok() { MAPPED } else { FAILED },
Ordering::Release,
);
});
@@ -233,13 +243,21 @@ impl GpuTimer {
}
/// GPU milliseconds of frames whose timestamps have arrived, oldest first.
pub fn collect(&mut self, device: &wgpu::Device, out: &mut Vec<f32>) {
let _ = device.poll(wgpu::PollType::Poll);
if device.poll(wgpu::PollType::Poll).is_err() {
return;
}
for i in 0..SLOTS {
let slot = &self.slots[(self.next + i) % SLOTS];
if slot.state.load(Ordering::Acquire) != MAPPED {
continue;
}
if let Ok(data) = slot.buffer.slice(..).get_mapped_range() {
{
let Ok(data) = slot.buffer.slice(..).get_mapped_range() else {
// Device loss can destroy a buffer after its mapping succeeded.
// Unmapping it would turn the failed readback into a panic.
slot.state.store(FAILED, Ordering::Release);
continue;
};
// Read unaligned: the mapping isn't guaranteed to be 8-byte aligned.
let stamps: [u64; 2] = bytemuck::pod_read_unaligned(&data[..16]);
// A start marker that ran late (rare on Metal) leaves the frame untimed.
@@ -285,3 +303,74 @@ pub(super) fn end_pipeline(
cache: None,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn mapped_timer() -> Option<(wgpu::Device, wgpu::Queue, GpuTimer)> {
let mut descriptor = wgpu::InstanceDescriptor::new_without_display_handle();
// WGL can hang while creating a test instance on Windows (#5238).
descriptor.backends = wgpu::Backends::PRIMARY;
let instance = wgpu::Instance::new(descriptor);
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
if !adapter.features().contains(wgpu::Features::TIMESTAMP_QUERY) {
return None;
}
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
required_features: GpuTimer::features(&adapter),
..Default::default()
}))
.unwrap();
let mut timer = GpuTimer::new(&device, &queue).unwrap();
queue.write_buffer(
&timer.slots[0].buffer,
0,
bytemuck::cast_slice(&[10_u64, 20]),
);
queue.submit([]);
timer.resolved.push(0);
timer.submitted(&queue);
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
assert_eq!(timer.slots[0].state.load(Ordering::Acquire), MAPPED);
Some((device, queue, timer))
}
#[test]
fn successful_readback_releases_slot() {
let Some((device, _queue, mut timer)) = mapped_timer() else {
eprintln!("Skipping GPU timer test: no timestamp-capable adapter");
return;
};
let mut samples = Vec::new();
timer.collect(&device, &mut samples);
assert_eq!(
samples,
vec![(10.0 * f64::from(timer.period_ns) / 1e6) as f32]
);
assert_eq!(timer.slots[0].state.load(Ordering::Acquire), FREE);
}
#[test]
fn destroyed_readback_stops_timing_without_unmapping() {
let Some((device, queue, mut timer)) = mapped_timer() else {
eprintln!("Skipping GPU timer test: no timestamp-capable adapter");
return;
};
// Reproduce a buffer destroyed after its successful map callback (#6320).
timer.slots[0].buffer.destroy();
let mut samples = Vec::new();
timer.collect(&device, &mut samples);
assert!(samples.is_empty());
assert_eq!(timer.slots[0].state.load(Ordering::Acquire), FAILED);
// Later frames must not resolve or copy into the failed readback buffer.
let mut encoder = device.create_command_encoder(&Default::default());
timer.begin(&mut encoder);
assert!(timer.current.is_none());
assert!(timer.resolved.is_empty());
queue.submit([encoder.finish()]);
timer.submitted(&queue);
timer.collect(&device, &mut samples);
assert!(samples.is_empty());
}
}
@@ -125,7 +125,7 @@ mod tests {
use super::*;
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
pollster::block_on(adapter.request_device(&Default::default())).ok()
}
@@ -361,7 +361,7 @@ mod tests {
#[test]
#[ignore = "requires a Vulkan/Metal/DX12 adapter"]
fn weather_shader_uvs_match_legacy_bits() {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let instance = crate::gfx::test_gpu::instance();
let adapter =
pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default()))
.unwrap();
@@ -10,6 +10,8 @@
use glam::{DMat4, DVec3, Mat4, Vec2};
/// The least a world is scaled to when memory runs out.
const MIN_SCALE: f32 = 0.25;
/// The TAA history format: 8 bits would band as frames accumulate.
const HISTORY_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
@@ -299,6 +301,16 @@ impl Post {
Mat4::from_translation(jitter.extend(0.0))
}
/// This frame's jitter in rendered pixels, y down (FSR's `jitterOffset`); zero
/// without TAA.
pub fn jitter_pixels(&self) -> [f32; 2] {
let Some(size) = self.size().filter(|_| self.taa_on) else {
return [0.0; 2];
};
let j = self.taa_this_frame.jitter;
[j[0] * size[0] as f32 * 0.5, -j[1] * size[1] as f32 * 0.5]
}
/// 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 anti-aliasing (a shader pack reads it).
@@ -312,12 +324,65 @@ impl Post {
self.target = None;
return None;
}
let (size, output) = self.sizes(window);
let history_size = self.taa_on.then_some(output);
if self
.target
// The old target goes first: a slider drag would otherwise hold the old and the new
// targets together, and the sum is what runs a small GPU out of memory.
loop {
let (size, output) = self.sizes(window);
let history_size = self.taa_on.then_some(output);
if self
.target
.as_ref()
.is_some_and(|t| t.size == size && t.taa.as_ref().map(|h| h.size) == history_size)
{
break;
}
self.target = None;
// Errors raised are fatal, so catch an out-of-memory one and scale down. A texture
// that failed is invalid, so its view fails validation too: catch that as well.
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
let memory = device.push_error_scope(wgpu::ErrorFilter::OutOfMemory);
let target = self.build_target(device, size, history_size);
let error = pollster::block_on(memory.pop()).or(pollster::block_on(validation.pop()));
if error.is_none() {
// New textures: no history yet.
self.taa_this_frame.jitter[2] = 1.0;
self.target = Some(target);
break;
}
drop(target);
if self.scale <= MIN_SCALE {
log::warn!(
"Horizon renderer: no video memory for the scaled world at {size:?} ({}); drawing at the window's size",
error.map_or_else(String::new, |e| e.to_string())
);
self.scale = 1.0;
self.target = None;
return None;
}
let reduced = (self.scale * 0.75).max(MIN_SCALE);
log::warn!(
"Horizon renderer: out of video memory for the scaled world at {size:?}; resolution scale {:.2} -> {reduced:.2} ({})",
self.scale,
error.map_or_else(String::new, |e| e.to_string())
);
self.scale = reduced;
if self.scale == 1.0 && !self.antialias && !self.taa_on && !always {
return None;
}
}
self.target
.as_ref()
.is_none_or(|t| t.size != size || t.taa.as_ref().map(|h| h.size) != history_size)
.map(|t| (t.color.clone(), t.depth.clone()))
}
/// The world's colour and depth targets at `size`, and TAA's history when `history_size`
/// is set.
fn build_target(
&self,
device: &wgpu::Device,
size: [u32; 2],
history_size: Option<[u32; 2]>,
) -> WorldTarget {
{
let make_tex = |label, size: [u32; 2], format, usage| {
device.create_texture(&wgpu::TextureDescriptor {
@@ -393,19 +458,14 @@ impl Post {
show,
})
});
// New textures: no history yet.
self.taa_this_frame.jitter[2] = 1.0;
self.target = Some(WorldTarget {
WorldTarget {
size,
color,
depth,
group,
taa,
});
}
}
self.target
.as_ref()
.map(|t| (t.color.clone(), t.depth.clone()))
}
/// The size of the world's own target, when it has one.
@@ -507,7 +567,12 @@ impl Post {
/// 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 {
pub(super) fn reprojection(
previous: DMat4,
previous_camera: DVec3,
current: DMat4,
camera: DVec3,
) -> DMat4 {
previous * DMat4::from_translation(camera - previous_camera) * current.inverse()
}
@@ -553,8 +618,7 @@ mod tests {
}
fn test_device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance =
wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle().with_env());
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
pollster::block_on(adapter.request_device(&Default::default())).ok()
}
+326 -23
View File
@@ -19,20 +19,28 @@ pub const HDR_MARKER: &str = "horizon-hdr-pending";
pub struct Reported {
pub paper_white: u32,
pub peak: u32,
/// The display's darkest black, in hundredths of a nit.
pub black: u32,
}
static REPORTED: [AtomicU32; 2] = [AtomicU32::new(0), AtomicU32::new(0)];
static REPORTED: [AtomicU32; 3] = [AtomicU32::new(0), AtomicU32::new(0), AtomicU32::new(0)];
/// Sets what the system reports (the renderer, when it opens its surface).
pub fn report(reported: Option<Reported>) {
let (paper_white, peak) = reported.map_or((0, 0), |r| (r.paper_white, r.peak));
let (paper_white, peak, black) =
reported.map_or((0, 0, 0), |r| (r.paper_white, r.peak, r.black));
REPORTED[0].store(paper_white, Ordering::Relaxed);
REPORTED[1].store(peak, Ordering::Relaxed);
REPORTED[2].store(black, Ordering::Relaxed);
}
/// What the system reports, if it does.
pub fn reported() -> Option<Reported> {
let [paper_white, peak] = REPORTED.each_ref().map(|n| n.load(Ordering::Relaxed));
(peak > 0).then_some(Reported { paper_white, peak })
let [paper_white, peak, black] = REPORTED.each_ref().map(|n| n.load(Ordering::Relaxed));
(peak > 0).then_some(Reported {
paper_white,
peak,
black,
})
}
/// Simple-level presets. `Custom` leaves every option as the player set it.
@@ -79,6 +87,47 @@ impl Preset {
}
}
/// Entity occlusion strategy. The synchronous mode preserves the original
/// same-frame CPU test; asynchronous results only hide a mesh when their
/// camera, bounds and occluder evidence still match.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum EntityCullingMode {
#[default]
Off,
Synchronous,
Asynchronous,
}
impl EntityCullingMode {
pub const ALL: [EntityCullingMode; 3] = [
EntityCullingMode::Off,
EntityCullingMode::Synchronous,
EntityCullingMode::Asynchronous,
];
pub fn name(self) -> &'static str {
match self {
Self::Off => "off",
Self::Synchronous => "sync",
Self::Asynchronous => "async",
}
}
pub fn label(self) -> &'static str {
match self {
Self::Off => "Off",
Self::Synchronous => "Synchronous",
Self::Asynchronous => "Asynchronous",
}
}
fn parse(s: &str) -> Self {
match s {
// Keep graphics files written before the mode selector readable.
"true" | "synchronous" | "sync" => Self::Synchronous,
"false" | "off" => Self::Off,
"asynchronous" | "async" => Self::Asynchronous,
_ => Self::default(),
}
}
}
/// How the swapchain presents frames.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum PresentMode {
@@ -246,6 +295,91 @@ impl FarDetail {
}
}
/// Frame generation: a frame made between each two real ones.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum FrameGeneration {
#[default]
Off,
/// AMD FidelityFX Super Resolution 3.1's frame interpolation, ported to WGSL.
Fsr31,
}
impl FrameGeneration {
pub const ALL: [FrameGeneration; 2] = [FrameGeneration::Off, FrameGeneration::Fsr31];
pub fn name(self) -> &'static str {
match self {
FrameGeneration::Off => "off",
FrameGeneration::Fsr31 => "fsr3.1",
}
}
pub fn label(self) -> &'static str {
match self {
FrameGeneration::Off => "Off",
FrameGeneration::Fsr31 => "FSR 3.1",
}
}
fn parse(s: &str) -> Self {
Self::ALL
.into_iter()
.find(|f| f.name() == s)
.unwrap_or_default()
}
}
/// What the section mesher does with leaf faces inside a canopy. Terrain
/// geometry only: entities, particles and block entities are untouched.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum LeafCullingMode {
/// Every leaf face, as vanilla's fancy leaves draw them.
#[default]
Off,
/// Leaves surrounded on all six sides by leaves or full opaque cubes draw
/// in the opaque layer, so their cutouts no longer show the layers behind.
Solid,
/// Like `Solid`, but a leaf with one open side draws opaque too: the
/// exposed shell thins by a layer and the canopy looks chunkier.
SolidAggressive,
/// Drops the faces two leaves share. Faces toward air, cavities, partial
/// models, unknown neighbours and section boundaries stay.
Hollow,
}
impl LeafCullingMode {
pub const ALL: [LeafCullingMode; 4] = [
LeafCullingMode::Off,
LeafCullingMode::Solid,
LeafCullingMode::SolidAggressive,
LeafCullingMode::Hollow,
];
pub fn name(self) -> &'static str {
match self {
LeafCullingMode::Off => "off",
LeafCullingMode::Solid => "solid",
LeafCullingMode::SolidAggressive => "solid_aggressive",
LeafCullingMode::Hollow => "hollow",
}
}
pub fn label(self) -> &'static str {
match self {
LeafCullingMode::Off => "Off",
LeafCullingMode::Solid => "Solid",
LeafCullingMode::SolidAggressive => "Solid Aggressive",
LeafCullingMode::Hollow => "Hollow",
}
}
/// Whether this mode draws interior leaf faces in the solid layer.
pub fn is_interior(self) -> bool {
matches!(
self,
LeafCullingMode::Solid | LeafCullingMode::SolidAggressive
)
}
fn parse(s: &str) -> Self {
Self::ALL
.into_iter()
.find(|mode| mode.name() == s)
.unwrap_or_default()
}
}
/// 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)]
@@ -330,7 +464,7 @@ static TRANSLUCENT_BATCHING_OFFERED: std::sync::atomic::AtomicBool =
/// Whether the device can batch translucent quads (`Graphics::translucent_batching`): set when
/// the renderer starts; not without indirect multi-draw or on OpenGL, which loops over a
/// multi-draw's entries on the CPU, nor where vanilla frames draw flat (they read the section
/// arena's translucent quads).
/// arena's translucent quads). Where it can't, the settings grey its switch out.
pub fn offer_translucent_batching(on: bool) {
TRANSLUCENT_BATCHING_OFFERED.store(on, std::sync::atomic::Ordering::Relaxed);
}
@@ -361,6 +495,46 @@ impl Graphics {
pub fn lod_distance_on(&self, info: &wgpu::AdapterInfo) -> u32 {
lod_distance_capped(self.lod_distance, info)
}
/// Whether the selected packs leave vanilla's block models in place.
/// Horizon's occlusion assumptions (entity and leaf culling) hold only
/// for those: a pack can replace an opaque cube's model or a leaf's.
pub fn vanilla_models(&self, resource_packs: &[String]) -> bool {
resource_packs.iter().all(|id| id == "vanilla")
}
/// The same check including packs overlaid by the connected server.
/// Their ids are namespaced as `server/...`, so an active server pack
/// disables model-dependent occlusion assumptions.
pub fn vanilla_models_with_server_packs<'a>(
&self,
resource_packs: impl IntoIterator<Item = &'a str>,
server_packs: impl IntoIterator<Item = &'a str>,
) -> bool {
resource_packs
.into_iter()
.chain(server_packs)
.all(|id| id == "vanilla")
}
/// The leaf culling the mesher may apply. A pack that can replace leaf
/// models falls back to `Off`, which draws exactly vanilla's leaves.
pub fn leaf_culling_effective(&self, resource_packs: &[String]) -> LeafCullingMode {
if self.vanilla_models(resource_packs) {
self.leaf_culling
} else {
LeafCullingMode::Off
}
}
/// The effective mode when local and server resource packs are active.
pub fn leaf_culling_effective_with_server_packs<'a>(
&self,
resource_packs: impl IntoIterator<Item = &'a str>,
server_packs: impl IntoIterator<Item = &'a str>,
) -> LeafCullingMode {
if self.vanilla_models_with_server_packs(resource_packs, server_packs) {
self.leaf_culling
} else {
LeafCullingMode::Off
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct Graphics {
@@ -371,8 +545,12 @@ pub struct Graphics {
pub far_detail: FarDetail,
/// Skip sections hidden behind closed ones (vanilla's occlusion graph).
pub cave_culling: bool,
/// Skip drawing entities hidden behind opaque terrain.
pub entity_culling: bool,
/// Skip drawing entities hidden behind opaque terrain, synchronously or asynchronously.
pub entity_culling: EntityCullingMode,
/// Terrain leaf faces inside a canopy (see [`LeafCullingMode`]); `Off` draws
/// vanilla's fancy leaves. Ignored with a resource pack that may replace
/// the vanilla leaf models ([`Graphics::leaf_culling_effective`]).
pub leaf_culling: LeafCullingMode,
/// Meshed sections uploaded per frame; 0 uploads all that are ready.
pub chunk_updates: u32,
/// Section mesh bytes uploaded per frame, in KiB; 0 is unlimited.
@@ -402,6 +580,8 @@ pub struct Graphics {
pub upscaling: Upscaling,
/// F2 screenshots at this many times the game's resolution: 1, 2, 4 or 8.
pub screenshot_quality: u32,
/// Frame generation (Horizon setting), off by default.
pub frame_generation: FrameGeneration,
/// HDR output (HDR10, or scRGB), where the display path offers it.
pub hdr: bool,
/// HDR paper white (where SDR white sits), in nits; 0 is Auto: the desktop's SDR
@@ -410,8 +590,9 @@ pub struct Graphics {
/// HDR peak brightness the world's highlights reach, in nits; 0 is Auto:
/// what the system reports for the display ([`Graphics::peak_nits`]).
pub hdr_peak: u32,
/// HDR black level: the display's darkest black, in hundredths of a nit (0 for
/// an OLED's true black). Shadows are lifted onto it, and the HDR metadata says it.
/// HDR black level: the display's darkest black, in hundredths of a nit; 0 is Auto:
/// what the system reports, else true black. Shadows are lifted onto it, and the HDR
/// metadata says it.
pub hdr_black_level: u32,
/// HDR brightness of the HUD and menus, in percent of paper white (25..=100); the
/// first-person hand stays at the world's.
@@ -429,9 +610,11 @@ pub struct Graphics {
/// The graphics API (Windows only; see `GraphicsApi`).
pub api: GraphicsApi,
/// Translucent quads in vertex buffers of their own (`gfx`'s translucent arena), so their
/// back-to-front draws go out in a few calls, for a little more video memory. Off by
/// default for now; read when a renderer is made (start, resource-pack reload), where it is
/// offered (`translucent_batching_offered`).
/// back-to-front draws go out in a few calls, for a little more video memory. On by
/// default (Translucent Batching, Advanced graphics); read when a renderer is made (start,
/// resource-pack reload), where it is offered (`translucent_batching_offered`). Its key is
/// `batchTranslucent`: `translucentBatching`, which every save wrote as false while it was
/// off by default and had no switch, stays ignored.
pub translucent_batching: bool,
/// The shader pack picked in `shaderpacks/` (`shader_pack_list`); empty: none. Its key is
/// not `shaderPack`, which versions before the clean-room rewrite saved and which stays
@@ -445,7 +628,8 @@ impl Default for Graphics {
lod_distance: 0,
far_detail: FarDetail::Full,
cave_culling: true,
entity_culling: false,
entity_culling: EntityCullingMode::Off,
leaf_culling: LeafCullingMode::Off,
chunk_updates: 0,
upload_budget_kib: 0,
texture_budget_mib: 0,
@@ -463,6 +647,7 @@ impl Default for Graphics {
taa: false,
upscaling: Upscaling::Native,
screenshot_quality: 1,
frame_generation: FrameGeneration::Off,
hdr: false,
hdr_paper_white: 0,
hdr_peak: 0,
@@ -472,7 +657,7 @@ impl Default for Graphics {
battery: false,
shadow_cache: false,
api: GraphicsApi::Auto,
translucent_batching: false,
translucent_batching: true,
shader_pack: String::new(),
}
}
@@ -514,7 +699,8 @@ impl Graphics {
}
"farDetail" => g.far_detail = FarDetail::parse(value),
"caveCulling" => g.cave_culling = flag(g.cave_culling),
"entityCulling" => g.entity_culling = flag(g.entity_culling),
"entityCulling" => g.entity_culling = EntityCullingMode::parse(value),
"leafCulling" => g.leaf_culling = LeafCullingMode::parse(value),
"chunkUpdates" => g.chunk_updates = number(g.chunk_updates).min(512),
"uploadBudgetKiB" => g.upload_budget_kib = number(g.upload_budget_kib).min(65536),
"textureBudgetMiB" => {
@@ -543,6 +729,7 @@ impl Graphics {
.filter(|q| SCREENSHOT_QUALITIES.contains(q))
.unwrap_or(1)
}
"frameGeneration" => g.frame_generation = FrameGeneration::parse(value),
"hdr" => g.hdr = flag(g.hdr),
"hdrPaperWhite" => {
g.hdr_paper_white = match number(g.hdr_paper_white) {
@@ -563,7 +750,7 @@ impl Graphics {
"hdrKey" if value.starts_with("key.") => g.hdr_key = value.to_owned(),
"batteryMode" => g.battery = flag(g.battery),
"shadowCache" => g.shadow_cache = flag(g.shadow_cache),
"translucentBatching" => g.translucent_batching = flag(g.translucent_batching),
"batchTranslucent" => g.translucent_batching = flag(g.translucent_batching),
"graphicsApi" => g.api = GraphicsApi::from_name(value).unwrap_or_default(),
"pickedShaderPack" => g.shader_pack = value.to_owned(),
"overlay" => {
@@ -580,12 +767,13 @@ impl Graphics {
}
pub fn serialize(&self) -> String {
format!(
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\ntranslucentBatching:{}\n",
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nleafCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nframeGeneration:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\nbatchTranslucent:{}\n",
self.preset.name(),
self.lod_distance,
self.far_detail.name(),
bool_text(self.cave_culling),
bool_text(self.entity_culling),
self.entity_culling.name(),
self.leaf_culling.name(),
self.chunk_updates,
self.upload_budget_kib,
self.texture_budget_mib,
@@ -610,6 +798,7 @@ impl Graphics {
bool_text(self.taa),
self.upscaling.name(),
self.screenshot_quality,
self.frame_generation.name(),
bool_text(self.hdr),
self.hdr_paper_white,
self.hdr_peak,
@@ -650,6 +839,14 @@ impl Graphics {
n => n,
}
}
/// The black level, in nits: the player's, or on Auto what the system reports, else 0.
pub fn black_level_nits(&self) -> f32 {
let hundredths = match self.hdr_black_level {
0 => reported().map_or(0, |r| r.black),
n => n,
};
hundredths as f32 / 100.0
}
/// Worker threads for chunk generation and meshing.
pub fn worker_threads(&self) -> usize {
if self.workers > 0 {
@@ -741,7 +938,8 @@ mod tests {
lod_distance: 256,
far_detail: FarDetail::Reduced,
cave_culling: false,
entity_culling: true,
entity_culling: EntityCullingMode::Synchronous,
leaf_culling: LeafCullingMode::SolidAggressive,
chunk_updates: 12,
upload_budget_kib: 4096,
texture_budget_mib: 1536,
@@ -759,6 +957,7 @@ mod tests {
taa: true,
upscaling: Upscaling::Balanced,
screenshot_quality: 4,
frame_generation: FrameGeneration::Fsr31,
hdr: true,
hdr_paper_white: 240,
hdr_peak: 1400,
@@ -768,13 +967,32 @@ mod tests {
battery: true,
shadow_cache: true,
api: GraphicsApi::Dx12,
translucent_batching: true,
translucent_batching: false,
shader_pack: "Golden Hour".into(),
};
assert_eq!(Graphics::parse(&g.serialize()), g);
for mode in EntityCullingMode::ALL {
let graphics = Graphics {
entity_culling: mode,
..Graphics::default()
};
assert_eq!(Graphics::parse(&graphics.serialize()), graphics);
}
assert_eq!(
Graphics::parse("entityCulling:true\n").entity_culling,
EntityCullingMode::Synchronous
);
assert_eq!(
Graphics::parse("entityCulling:false\n").entity_culling,
EntityCullingMode::Off
);
assert_eq!(Graphics::parse(""), Graphics::default());
// Translucent Batching is opt-in for now: only the file turns it on.
assert!(!Graphics::default().translucent_batching);
// Translucent Batching is on, in files from before it too; only false turns it off,
// and only under its key: the old one held the false every save wrote while it was
// off by default, not a choice.
assert!(Graphics::default().translucent_batching);
assert!(!Graphics::parse("batchTranslucent:false\n").translucent_batching);
assert!(Graphics::parse("translucentBatching:false\n").translucent_batching);
// 0 is Auto; anything else is a peak the slider reaches.
assert_eq!(Graphics::parse("hdrPeak:0\n").hdr_peak, 0);
assert_eq!(Graphics::parse("hdrPeak:50\n").hdr_peak, 200);
@@ -801,11 +1019,96 @@ mod tests {
Graphics::default()
);
assert_eq!(
Graphics::parse("workers:abc\nsky:maybe\ncullLeaves:true\nentityCulling:maybe\n"),
Graphics::parse(
"workers:abc\nsky:maybe\ncullLeaves:true\nentityCulling:maybe\nleafCulling:maybe\n"
),
Graphics::default()
);
}
#[test]
fn leaf_culling_round_trips_its_stable_names() {
assert_eq!(Graphics::default().leaf_culling, LeafCullingMode::Off);
for mode in LeafCullingMode::ALL {
let g = Graphics {
leaf_culling: mode,
..Graphics::default()
};
assert_eq!(Graphics::parse(&g.serialize()), g, "{mode:?}");
assert_eq!(
Graphics::parse(&format!("leafCulling:{}\n", mode.name())).leaf_culling,
mode
);
}
for text in [
"leafCulling:other\n",
"leafCulling:\n",
"leafCulling:SOLID\n",
] {
assert_eq!(
Graphics::parse(text).leaf_culling,
LeafCullingMode::Off,
"{text:?}"
);
}
}
#[test]
fn presets_keep_the_leaf_culling_choice() {
for mode in LeafCullingMode::ALL {
for preset in Preset::ALL {
let mut s = crate::settings::Settings::default();
s.horizon.leaf_culling = mode;
apply_preset(preset, &mut s);
assert_eq!(s.horizon.leaf_culling, mode, "{preset:?}");
assert_eq!(Graphics::parse(&s.horizon.serialize()), s.horizon);
}
}
}
#[test]
fn custom_packs_turn_leaf_culling_off() {
let g = Graphics {
leaf_culling: LeafCullingMode::Hollow,
..Graphics::default()
};
assert!(g.vanilla_models(&["vanilla".into()]));
assert_eq!(
g.leaf_culling_effective(&["vanilla".into()]),
LeafCullingMode::Hollow
);
assert!(!g.vanilla_models(&["vanilla".into(), "My Pack".into()]));
assert_eq!(
g.leaf_culling_effective(&["vanilla".into(), "My Pack".into()]),
LeafCullingMode::Off
);
assert_eq!(g.leaf_culling_effective(&[]), LeafCullingMode::Hollow);
}
#[test]
fn server_pack_apply_disables_leaf_culling_and_removal_restores_it() {
let g = Graphics {
leaf_culling: LeafCullingMode::Solid,
..Graphics::default()
};
let selected = ["vanilla"];
let applied_server_pack = ["server/0123456789abcdef/model-pack.zip"];
assert_eq!(
g.leaf_culling_effective_with_server_packs(selected, applied_server_pack),
LeafCullingMode::Off,
"an applied server pack can replace block models"
);
assert!(!g.vanilla_models_with_server_packs(selected, applied_server_pack));
assert_eq!(
g.leaf_culling_effective_with_server_packs(selected, []),
LeafCullingMode::Solid,
"removing the server pack restores the selected local mode"
);
assert!(g.vanilla_models_with_server_packs(selected, []));
}
#[test]
fn integrated_gpus_other_than_apple_cap_the_lod_distance() {
let adapter = |name: &str, device_type| wgpu::AdapterInfo {
@@ -15,7 +15,7 @@ pub enum Cost {
Low,
Medium,
High,
/// Turning it on makes frames faster (culling).
/// Turning it on makes frames faster (culling, caching, batching).
Saves,
}
impl Cost {
+351 -38
View File
@@ -2,7 +2,10 @@
//! 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, Upscaling},
graphics::{
EntityCullingMode, FarDetail, FrameGeneration, GraphicsApi, LeafCullingMode, Overlay,
Pacing, PresentMode, Preset, Upscaling,
},
graphics_style::{Cost, STYLE},
hud::{HudAtlas, HudBatch},
lang,
@@ -27,6 +30,7 @@ pub enum H {
FarDetail,
CaveCulling,
EntityCulling,
LeafCulling,
ChunkUpdates,
UploadBudget,
Sky,
@@ -42,6 +46,7 @@ pub enum H {
Antialiasing,
Upscaling,
ScreenshotQuality,
FrameGeneration,
Hdr,
/// Opens `Level::Hdr`.
HdrSettings,
@@ -56,6 +61,7 @@ pub enum H {
BatteryMode,
ShadowCache,
GraphicsApi,
TranslucentBatching,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -187,6 +193,12 @@ pub fn rows(level: Level) -> Vec<Row> {
Medium,
"How far away mobs and items are drawn, as a share of normal.",
),
h(
H::ChunkUpdates,
Low,
"Meshed sections uploaded per frame. Lower is smoother while loading.",
),
Row::Header("Culling"),
h(
H::CaveCulling,
Saves,
@@ -195,12 +207,12 @@ pub fn rows(level: Level) -> Vec<Row> {
h(
H::EntityCulling,
None,
"Hides models behind opaque blocks. Entities update. Off with custom packs.",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.",
),
h(
H::ChunkUpdates,
Low,
"Meshed sections uploaded per frame. Lower is smoother while loading.",
H::LeafCulling,
Saves,
"Draws or drops leaf faces inside canopies. Off keeps vanilla's fancy leaves.",
),
Row::Header("Quality"),
v(
@@ -291,6 +303,11 @@ pub fn rows(level: Level) -> Vec<Row> {
None,
"F2 screenshots at this many times the game's resolution. Higher takes longer.",
),
h(
H::FrameGeneration,
Medium,
"Shows a made-up frame between each two real ones: smoother, a little more lag.",
),
h(
H::HdrSettings,
None,
@@ -341,6 +358,11 @@ pub fn rows(level: Level) -> Vec<Row> {
None,
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.",
),
h(
H::TranslucentBatching,
Saves,
"Water and glass in one buffer: fewer draw calls, a bit more VRAM. Next start.",
),
h(
H::UploadBudget,
Low,
@@ -354,11 +376,13 @@ pub fn rows(level: Level) -> Vec<Row> {
),
],
};
// Only Windows and Linux have more than one graphics API.
// Only Windows and Linux have more than one graphics API, and translucent batching
// (Metal draws flat).
if cfg!(target_os = "macos") {
rows.retain(
|row| !matches!(row, Row::Setting(s) if s.item == Item::Horizon(H::GraphicsApi)),
);
rows.retain(|row| {
!matches!(row, Row::Setting(s)
if matches!(s.item, Item::Horizon(H::GraphicsApi | H::TranslucentBatching)))
});
}
rows
}
@@ -446,6 +470,16 @@ fn description(item: Item, s: &Settings, text: &'static str) -> &'static str {
(Item::Horizon(H::HdrPeak | H::HdrPaperWhite), Setup::Automatic | Setup::Changed) => {
"Set up for your display. Leave on Auto unless it looks wrong."
}
(Item::Horizon(H::LeafCulling), _) => match s.horizon.leaf_culling {
LeafCullingMode::Off => "Every leaf face. Vanilla's fancy leaves.",
LeafCullingMode::Solid => {
"Leaves fully inside a canopy draw solid: fewer see-through layers."
}
LeafCullingMode::SolidAggressive => {
"Leaves with one open side draw solid too: faster, chunkier edges."
}
LeafCullingMode::Hollow => "Drops faces between leaves: faster, but gaps look through.",
},
_ => text,
}
}
@@ -515,7 +549,8 @@ impl H {
},
H::FarDetail => labelled("Far Detail", g.far_detail.label()),
H::CaveCulling => labelled("Cave Culling", on_off(g.cave_culling)),
H::EntityCulling => labelled("Entity Culling", on_off(g.entity_culling)),
H::EntityCulling => labelled("Entity Culling", g.entity_culling.label()),
H::LeafCulling => labelled("Leaf Culling", g.leaf_culling.label()),
H::ChunkUpdates => match g.chunk_updates {
0 => labelled("Chunk Updates", "Unlimited"),
n => labelled("Chunk Updates", lang::tr("%s per frame", &[&n])),
@@ -560,6 +595,7 @@ impl H {
H::ScreenshotQuality => {
labelled("Screenshot Quality", format!("{}x", g.screenshot_quality))
}
H::FrameGeneration => labelled("Frame Generation", g.frame_generation.label()),
H::Hdr => labelled("HDR", on_off(g.hdr)),
H::HdrSettings => Text::literal(lang::get("HDR Settings...")),
H::HdrCalibrate => Text::literal(lang::get(match setup(s) {
@@ -570,31 +606,34 @@ impl H {
H::HdrPaperWhite => match g.hdr_paper_white {
0 => labelled(
"Paper White",
lang::tr("Auto (%s nits)", &[&g.paper_white_nits()]),
lang::tr("Auto (%s)", &[&g.paper_white_nits()]),
),
n => labelled("Paper White", lang::tr("%s nits", &[&n])),
n => labelled("Paper White", n.to_string()),
},
H::HdrPeak => match g.hdr_peak {
0 => labelled(
"Peak Brightness",
lang::tr("Auto (%s nits)", &[&g.peak_nits()]),
),
n => labelled("Peak Brightness", lang::tr("%s nits", &[&n])),
0 => labelled("Peak Brightness", lang::tr("Auto (%s)", &[&g.peak_nits()])),
n => labelled("Peak Brightness", n.to_string()),
},
H::HdrBlackLevel => labelled(
"Black Level",
lang::tr(
"%s nits",
&[&format!("{:.2}", g.hdr_black_level as f32 / 100.0)],
H::HdrBlackLevel => match g.hdr_black_level {
0 => labelled(
"Black Level",
lang::tr("Auto (%s)", &[&format!("{:.2}", g.black_level_nits())]),
),
),
n => labelled("Black Level", format!("{:.2}", n as f32 / 100.0)),
},
H::HdrUiBrightness => labelled("UI Brightness", format!("{}%", g.hdr_ui_brightness)),
H::HdrKey => {
Text::literal("Shortcut: ").append(crate::keybinds::display_name(&g.hdr_key))
Text::translate("Shortcut: %s", &[crate::keybinds::display_name(&g.hdr_key)])
}
H::BatteryMode => labelled("Battery Mode", on_off(g.battery)),
H::ShadowCache => labelled("Shadow Cache", on_off(g.shadow_cache)),
H::GraphicsApi => labelled("Graphics API", g.api.label()),
// Greyed out where the device can't (`Item::blocked`): Off, as it is there,
// while the saved value waits for a device that can.
H::TranslucentBatching => labelled(
"Translucent Batching",
on_off(g.translucent_batching && crate::graphics::translucent_batching_offered()),
),
H::Overlay => labelled(
"Performance Overlay",
match g.overlay {
@@ -648,7 +687,12 @@ impl H {
g.far_detail = step(&[FarDetail::Full, FarDetail::Reduced], g.far_detail, back)
}
H::CaveCulling => g.cave_culling = !g.cave_culling,
H::EntityCulling => g.entity_culling = !g.entity_culling,
H::EntityCulling => {
g.entity_culling = step(&EntityCullingMode::ALL, g.entity_culling, back)
}
H::LeafCulling => {
g.leaf_culling = step(&LeafCullingMode::ALL, g.leaf_culling, back);
}
H::Sky => g.sky = !g.sky,
H::Fog => g.fog = !g.fog,
H::Weather => g.weather = !g.weather,
@@ -678,10 +722,14 @@ impl H {
back,
)
}
H::FrameGeneration => {
g.frame_generation = step(&FrameGeneration::ALL, g.frame_generation, back)
}
H::Hdr => g.hdr = !g.hdr,
H::BatteryMode => g.battery = !g.battery,
H::ShadowCache => g.shadow_cache = !g.shadow_cache,
H::GraphicsApi => g.api = step(GraphicsApi::choices(), g.api, back),
H::TranslucentBatching => g.translucent_batching = !g.translucent_batching,
H::Pacing => {
g.pacing = match g.pacing {
Pacing::Efficient => Pacing::Precise,
@@ -712,7 +760,8 @@ impl Item {
/// the display offers HDR output, whatever `horizon-graphics.txt` says (a copied game
/// folder, a display swapped for an SDR one: the renderer leaves HDR off there, and
/// the saved values wait for an HDR display), and its sliders do something only
/// while it is on.
/// while it is on; Translucent Batching switches only where the device can batch
/// (`graphics::translucent_batching_offered`), its saved value waiting for one that can.
fn blocked(self, s: &Settings) -> Option<&'static str> {
if !self.available() {
return Some("This feature is not yet available in Horizon.");
@@ -728,6 +777,11 @@ impl Item {
| H::HdrPaperWhite
| H::HdrUiBrightness,
) if !s.horizon.hdr => Some("Turn HDR on to use it."),
Item::Horizon(H::TranslucentBatching)
if !crate::graphics::translucent_batching_offered() =>
{
Some("Your GPU or graphics API doesn't support it.")
}
_ => None,
}
}
@@ -736,7 +790,6 @@ impl Item {
let g = &s.horizon;
Some(match self {
Item::Horizon(H::CaveCulling) => g.cave_culling,
Item::Horizon(H::EntityCulling) => g.entity_culling,
Item::Horizon(H::Sky) => g.sky,
Item::Horizon(H::Fog) => g.fog,
Item::Horizon(H::Weather) => g.weather,
@@ -745,6 +798,7 @@ impl Item {
Item::Horizon(H::Hdr) => g.hdr,
Item::Horizon(H::BatteryMode) => g.battery,
Item::Horizon(H::ShadowCache) => g.shadow_cache,
Item::Horizon(H::TranslucentBatching) => g.translucent_batching,
Item::Vanilla(Opt::ViewBobbing) => s.view_bobbing,
Item::Vanilla(Opt::Vsync) => s.vsync,
Item::Vanilla(Opt::Fullscreen) => s.fullscreen,
@@ -804,9 +858,11 @@ impl Item {
| H::Pacing
| H::PresentMode
| H::Workers
| H::FrameGeneration
| H::BatteryMode
| H::ShadowCache
| H::GraphicsApi
| H::TranslucentBatching
) | Item::Vanilla(
Opt::GuiScale
| Opt::Fullscreen
@@ -1039,9 +1095,12 @@ impl GraphicsUi {
let mut label = item.label(s);
if item == Item::Horizon(H::HdrKey) && self.binding.is_some() {
// KeyBindsScreen: "> key <" while it waits.
label = Text::literal("Shortcut: > ")
.append(crate::keybinds::display_name(&s.horizon.hdr_key))
.append(" <");
label = Text::translate(
"Shortcut: %s",
&[Text::literal("> ")
.append(crate::keybinds::display_name(&s.horizon.hdr_key))
.append(" <")],
);
}
let blocked = item.blocked(s);
let live = blocked.is_none();
@@ -1429,6 +1488,12 @@ impl GraphicsUi {
&& let Some(target) = self.focus
{
match target {
// A click focuses the greyed row; Enter there does nothing, as the click didn't.
Target::Item(item @ Item::Horizon(H::TranslucentBatching))
if item.blocked(s).is_some() =>
{
return (Vec::new(), true);
}
Target::Item(item) => {
if item.slider(s).is_some() {
self.can_change_value = !self.can_change_value;
@@ -1826,16 +1891,92 @@ mod tests {
}
#[test]
fn entity_culling_is_opt_in_and_toggles_from_advanced_graphics() {
fn leaf_culling_cycles_modes_in_the_advanced_culling_group() {
let mut s = Settings::default();
let item = Item::Horizon(H::LeafCulling);
assert_eq!(item.switch(&s), None);
assert_eq!(item.label(&s).plain(), "Leaf Culling: Off");
for label in ["Solid", "Solid Aggressive", "Hollow", "Off"] {
item.cycle(&mut s, false, [854, 480]);
assert_eq!(item.label(&s).plain(), format!("Leaf Culling: {label}"));
assert_eq!(
crate::graphics::Graphics::parse(&s.horizon.serialize()).leaf_culling,
s.horizon.leaf_culling
);
}
item.cycle(&mut s, true, [854, 480]);
assert_eq!(s.horizon.leaf_culling, LeafCullingMode::Hollow);
// The three culling settings share one group, below Rendering's chunk
// updates and above Quality.
let rows = rows(Level::Advanced);
let culling = rows
.iter()
.position(|row| matches!(row, Row::Header("Culling")))
.unwrap();
assert!(matches!(
rows.get(culling + 1),
Some(Row::Setting(setting)) if setting.item == Item::Horizon(H::CaveCulling)
));
assert!(matches!(
rows.get(culling + 2),
Some(Row::Setting(setting)) if setting.item == Item::Horizon(H::EntityCulling)
));
assert!(matches!(
rows.get(culling + 3),
Some(Row::Setting(setting)) if setting.item == item
));
assert!(matches!(
rows.get(culling + 4),
Some(Row::Header("Quality"))
));
// The description names what the picked mode does.
for mode in LeafCullingMode::ALL {
s.horizon.leaf_culling = mode;
let text = description(item, &s, "fallback");
assert_ne!(text, "fallback", "{mode:?}");
assert!(text.len() < 80, "{mode:?}: {text}");
}
// A click cycles it and leaves the preset, as the other quality
// settings do.
let mut ui = GraphicsUi::default();
let size = (480.0, 4000.0);
let (widgets, _, _) = ui.layout(Level::Advanced, &s, size.0, size.1);
let control = widgets
.iter()
.find(|w| matches!(w.target, Target::Item(i) if i == item))
.unwrap();
s.horizon.preset = Preset::Balanced;
s.horizon.leaf_culling = LeafCullingMode::Off;
assert_eq!(
ui.click(
Level::Advanced,
&mut s,
(control.x + 1.0, control.y + 1.0),
size,
[854, 480],
false,
false
),
vec![Action::ButtonClick, Action::Changed]
);
assert_eq!(s.horizon.leaf_culling, LeafCullingMode::Solid);
assert_eq!(s.horizon.preset, Preset::Custom);
}
#[test]
fn entity_culling_modes_are_opt_in_and_cycle_from_advanced_graphics() {
let mut s = Settings::default();
let item = Item::Horizon(H::EntityCulling);
assert_eq!(item.switch(&s), Some(false));
assert_eq!(s.horizon.entity_culling, EntityCullingMode::Off);
assert_eq!(item.label(&s).plain(), "Entity Culling: Off");
for enabled in [false, true] {
s.horizon.entity_culling = enabled;
for mode in EntityCullingMode::ALL {
s.horizon.entity_culling = mode;
for preset in Preset::ALL {
crate::graphics::apply_preset(preset, &mut s);
assert_eq!(s.horizon.entity_culling, enabled);
assert_eq!(s.horizon.entity_culling, mode);
}
}
@@ -1847,7 +1988,11 @@ mod tests {
.find(|w| matches!(w.target, Target::Item(i) if i == item))
.unwrap();
let point = (switch.x + 1.0, switch.y + 1.0);
for enabled in [false, true] {
for mode in [
EntityCullingMode::Off,
EntityCullingMode::Synchronous,
EntityCullingMode::Asynchronous,
] {
s.horizon.preset = Preset::Balanced;
assert_eq!(
ui.click(
@@ -1861,10 +2006,70 @@ mod tests {
),
vec![Action::ButtonClick, Action::Changed]
);
assert_eq!(item.switch(&s), Some(enabled));
assert_eq!(s.horizon.entity_culling, mode);
assert_eq!(s.horizon.preset, Preset::Custom);
}
assert_eq!(item.label(&s).plain(), "Entity Culling: On");
assert_eq!(item.label(&s).plain(), "Entity Culling: Asynchronous");
}
/// Translucent Batching: a switch on Advanced (not Simple), on by default, no part of a
/// preset, and greyed out, reading Off, where the device can't batch.
#[test]
fn translucent_batching_is_an_advanced_switch_where_the_device_can() {
use winit::keyboard::KeyCode as K;
let item = Item::Horizon(H::TranslucentBatching);
let has = |level| {
rows(level)
.iter()
.any(|r| matches!(r, Row::Setting(x) if x.item == item))
};
if cfg!(target_os = "macos") {
// Metal draws flat: never offered, so not shown.
assert!(!has(Level::Advanced));
return;
}
assert!(has(Level::Advanced) && !has(Level::Simple));
let mut s = Settings::default();
s.horizon.preset = Preset::Balanced;
assert_eq!(item.switch(&s), Some(true));
let mut ui = GraphicsUi::default();
let size = (480.0, 2000.0);
let (widgets, _, _) = ui.layout(Level::Advanced, &s, size.0, size.1);
let w = widgets
.iter()
.find(|w| w.target == Target::Item(item))
.unwrap();
let point = (w.x + 1.0, w.y + 1.0);
let click = |ui: &mut GraphicsUi, s: &mut Settings| {
ui.click(Level::Advanced, s, point, size, [480, 2000], false, false)
};
// Where the device can't, the saved On reads Off, and neither a click nor Enter
// after it (the click focuses the row) does anything: no sound, no change, no save.
crate::graphics::offer_translucent_batching(false);
assert!(item.blocked(&s).is_some());
assert_eq!(item.label(&s).plain(), "Translucent Batching: Off");
assert!(click(&mut ui, &mut s).is_empty());
let enter = ui.key_modifiers(Level::Advanced, &mut s, K::Enter, false, size, [480, 2000]);
assert!(enter.0.is_empty());
assert!(s.horizon.translucent_batching);
// Where it can, a click switches it and keeps the preset.
crate::graphics::offer_translucent_batching(true);
assert_eq!(item.label(&s).plain(), "Translucent Batching: On");
assert_eq!(
click(&mut ui, &mut s),
[Action::ButtonClick, Action::Changed]
);
assert!(!s.horizon.translucent_batching);
assert_eq!(item.label(&s).plain(), "Translucent Batching: Off");
assert_eq!(s.horizon.preset, Preset::Balanced);
// No preset sets it.
for on in [false, true] {
s.horizon.translucent_batching = on;
for preset in Preset::ALL {
crate::graphics::apply_preset(preset, &mut s);
assert_eq!(s.horizon.translucent_batching, on, "{preset:?}");
}
}
}
#[test]
@@ -2021,4 +2226,112 @@ mod tests {
}
}
}
/// Horizon's graphics captions are English sentences that the language table keys by
/// themselves, so a caption, description or value that is not in `en_us.json` shows in
/// English in every language. Every text the three pages show, with each setting cycled
/// through its values (#1927).
#[test]
fn every_graphics_text_is_in_the_language_table() {
let (_, missed) = lang::scoped(lang::Language::load(&[], "de_de"), || {
for hdr in [false, true] {
let mut s = Settings::default();
s.horizon.hdr = hdr;
s.horizon.taa = true;
for level in [Level::Simple, Level::Advanced, Level::Hdr] {
for _ in 0..8 {
for row in extra_rows(level, &s) {
match row {
Row::Header(name) => {
lang::get(name);
}
Row::Setting(row) => {
row.item.label(&s);
let text = description(row.item, &s, row.text);
lang::get(text);
lang::get(row.cost.label());
if let Some(why) = row.item.blocked(&s) {
lang::get(why);
}
}
}
}
for row in extra_rows(level, &s) {
if let Row::Setting(Setting {
item: Item::Horizon(h),
..
}) = row
{
h.cycle(&mut s, false);
}
}
}
}
}
for button in ["Advanced...", "Simple...", "Done"] {
lang::get(button);
}
});
// `labelled` also looks up values that are already words in the language ("An") or
// numbers with a unit ("12 chunks", "Auto (8)"); those are not keys, and "FSR 3.1" is
// a name.
let missed: Vec<_> = missed
.into_iter()
.filter(|m| !m.starts_with(|c: char| c.is_ascii_digit()) && !m.starts_with("Auto ("))
.filter(|m| !["An", "Aus", "FSR 3.1", "key.keyboard.unknown"].contains(&m.as_str()))
.collect();
assert_eq!(missed, Vec::<String>::new());
}
/// Leaf Culling, Frame Generation and Shadow Cache came with English captions only (#1927).
#[test]
fn newer_graphics_settings_are_translated() {
let mut s = Settings::default();
s.horizon.leaf_culling = LeafCullingMode::Hollow;
s.horizon.entity_culling = EntityCullingMode::Asynchronous;
for (code, leaf, entity, leaf_text) in [
(
"de_de",
"Laub-Culling: Hohl",
"Objekt-Culling: Asynchron",
"Lässt Flächen zwischen Blättern weg: schneller, aber Lücken sind durchsichtig.",
),
(
"pl_pl",
"Pomijanie liści: Puste",
"Pomijanie bytów: Asynchroniczne",
"Pomija ściany między liśćmi: szybciej, ale luki są przezroczyste.",
),
(
"uk_ua",
"Відсікання листя: Порожнє",
"Відсік. сутностей: Асинхронно",
"Прибирає грані між листям: швидше, але прогалини прозорі.",
),
(
"zh_cn",
"树叶剔除: 镂空",
"实体剔除: 异步",
"省略树叶之间的面:更快,但缝隙会透视。",
),
(
"fr_fr",
"Occlusion feuillage: Creux",
"Occlusion des entités: Asynchrone",
"Supprime les faces entre feuilles : plus rapide, mais on voit à travers les trous.",
),
] {
let (shown, _) = lang::scoped(lang::Language::load(&[], code), || {
let item = |h| Item::Horizon(h);
(
item(H::LeafCulling).label(&s).plain(),
item(H::EntityCulling).label(&s).plain(),
lang::get(description(item(H::LeafCulling), &s, "")),
)
});
assert_eq!(
shown,
(leaf.into(), entity.into(), leaf_text.into()),
"{code}"
);
}
}
}
+36 -21
View File
@@ -20,12 +20,11 @@ struct Params {
black: f32,
// The HUD and menus' brightness as a share of paper white.
ui: f32,
// HDR lighting: what the strongest light multiplies a surface by, 1 for none.
scene: f32,
// Per tier, low to very bright (hdr.rs `Tier`): the share of the headroom
// the brightest pixels get, and the brightness where the lift starts and
// where it is full.
// its fully lifted pixels get.
share: vec4<f32>,
start: vec4<f32>,
full: vec4<f32>,
// HDR calibration (hdr_calibration.rs): x its step (0 none, 1 black level,
// 2 peak, 3 paper white), y the slider in nits, z where its controls start,
// a share of the height.
@@ -40,9 +39,11 @@ struct Params {
// near-white spots (glowstone) and leaves the rest (lava, a jack o'lantern's
// face) nearly as it was. Each tier's pixels brighter than its `full` get its
// `share` of the headroom, dimmer ones less down to none at its `start`
// (lava's crust, a lantern's metal), and no channel goes past the peak.
// Brightness is the mean of luminance and the brightest channel, so saturated
// light counts as bright.
// (lava's crust, a lantern's metal), and no channel goes past the peak. That
// brightness is the texel's own, before lighting: the shaders that mark the
// frame (hdr_mark) work it out, so a light source shines the same at any light
// level and on every face. Brightness is the mean of luminance and the
// brightest channel, so saturated light counts as bright.
// Whether `c` still shows the world pixel `w`: the same, or darkened evenly
// across its channels, as the vignette and other screen-wide tints darken it.
@@ -54,7 +55,7 @@ fn shows_world(c: vec3<f32>, w: vec3<f32>) -> bool {
return true;
}
let f = (c.r + c.g + c.b) / max(w.r + w.g + w.b, 1.0 / 255.0);
return f <= 1.01 && f > 0.5 && all(abs(c - f * w) <= 0.03 * w + 1.0 / 255.0);
return f <= 1.01 && f > 0.02 && all(abs(c - f * w) <= 0.03 * w + 1.0 / 255.0);
}
// Linear BT.709 to BT.2020 (ITU-R BT.2087), columns first.
@@ -157,24 +158,38 @@ fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
// 1/255 (Hdr::mark_hand: drawn at the world's brightness), else the HUD or a
// menu, by how much.
let covered = f.a > 0.0;
let ui = select(f.a, 0.0, f.a < 1.5 / 255.0);
let hand = covered && f.a < 1.5 / 255.0;
let ui = select(f.a, 0.0, hand);
// Gamma 2.2, as SDR displays and the compositors' SDR paths decode it.
var light = pow(max(c, vec3(0.0)), vec3(2.2));
let w = textureLoad(world, at, 0);
// The world's alpha is below 0.75 on light sources, the sun and the moon
// (terrain.wgsl, the celestial pipeline): only those shine, by the tier it
// holds, the lower the brighter, and only where nothing was drawn over them
// since: grey text over the white sun still looks like the sun darkened, but
// it raised the frame's alpha.
// (terrain.wgsl, entity.wgsl): only those shine, and only where nothing was
// drawn over them since: grey text over the white sun still looks like the sun
// darkened, but it raised the frame's alpha. Its band of 0.175 is the tier, the
// lower the brighter, and where in the band how much (hdr_mark). The hand
// writes its own alpha there (Hdr::hand_light_view): 1, or a light source it holds.
var lifted = false;
if params.world > 0.5 && params.peak > 1.0 && w.a < 0.75 && !covered
&& shows_world(c, w.rgb) {
// Brightness of the world pixel, before the vignette darkens the frame
// toward its edges: a light source shines the same anywhere on screen.
let wl = pow(max(w.rgb, vec3(0.0)), vec3(2.2));
let y = 0.5 * (dot(wl, vec3(0.2126, 0.7152, 0.0722)) + max(wl.r, max(wl.g, wl.b)));
let tier = u32(clamp(round(4.0 - 4.0 * w.a / 0.7), 1.0, 4.0)) - 1u;
let lift = smoothstep(params.start[tier], params.full[tier], y);
let shows = params.world > 0.5 && params.peak > 1.0
&& (hand || (!covered && shows_world(c, w.rgb)));
// HDR lighting: alpha between 0.76 and 1 is how much of it the surface gets. Vanilla's
// light is albedo times lightmap, so this is the lightmap going past 1. It eases into the
// peak from 60% of it, so bright textures keep their detail.
if shows && w.a >= 0.75 {
let top = max(light.r, max(light.g, light.b));
let gain = 1.0 + (params.scene - 1.0) * (1.0 - w.a) / 0.24;
let v = top * gain;
let knee = max(0.6 * params.peak, 1.0);
var out = v;
if v > knee && params.peak > knee {
out = knee + (params.peak - knee) * (1.0 - exp(-(v - knee) / (params.peak - knee)));
}
light *= select(gain, out / max(top, 1e-4), v > knee);
}
if shows && w.a < 0.75 {
let band = u32(clamp(floor((w.a + 0.005) / 0.175), 0.0, 3.0));
let tier = 3u - band;
let lift = 1.0 - clamp((w.a - 0.175 * f32(band)) / 0.165, 0.0, 1.0);
if lift > 0.0 {
let top = max(light.r, max(light.g, light.b));
let gain = min(1.0 + (params.peak - 1.0) * params.share[tier] * lift, params.peak / max(top, 1e-4));
@@ -18,6 +18,7 @@
//! or Escape leaves them as they were.
use crate::{
hud::{HudAtlas, HudBatch, rgba},
lang,
settings::Settings,
text::Text,
ui,
@@ -48,7 +49,7 @@ pub struct Calibration {
values: [f32; 3],
/// Which values were Auto (peak, paper white): saved as Auto again unless the
/// player moved them.
auto: [Option<f32>; 2],
auto: [Option<f32>; 3],
dragging: bool,
}
@@ -64,14 +65,19 @@ impl Step {
Step::PaperWhite => (10.0, 480.0, 10.0),
}
}
fn name(self) -> &'static str {
/// The English name, which is also its key in the language table.
fn name_key(self) -> &'static str {
match self {
Step::Black => "Black Level",
Step::Peak => "Peak Brightness",
Step::PaperWhite => "Paper White",
}
}
fn hint(self) -> &'static str {
fn name(self) -> String {
lang::get(self.name_key())
}
/// The English hint, which is also its key in the language table.
fn hint_key(self) -> &'static str {
match self {
Step::Black => "Lower it until the logo just disappears into the black.",
Step::Peak => "Raise it until the logo just disappears into the bright square.",
@@ -119,11 +125,12 @@ impl Calibration {
Self {
step: 0,
values: [
g.hdr_black_level as f32 / 100.0,
g.black_level_nits(),
g.peak_nits() as f32,
g.paper_white_nits() as f32,
],
auto: [
(g.hdr_black_level == 0).then_some(g.black_level_nits()),
(g.hdr_peak == 0).then_some(g.peak_nits() as f32),
(g.hdr_paper_white == 0).then_some(g.paper_white_nits() as f32),
],
@@ -139,10 +146,12 @@ impl Calibration {
}
fn label(&self) -> Text {
let v = self.values[self.step];
Text::literal(match self.current() {
Step::Black => format!("Black Level: {v:.2} nits"),
step => format!("{}: {v:.0} nits", step.name()),
})
let step = self.current();
let value = match step {
Step::Black => format!("{v:.2}"),
_ => format!("{v:.0}"),
};
Text::literal(format!("{}: {}", step.name(), value))
}
/// The step, its nits and where the controls start (a share of the height), for
/// HDR output's test picture.
@@ -160,14 +169,12 @@ impl Calibration {
batch.fill(atlas, 0.0, 0.0, w, h, rgba(0, 0, 0, 255));
batch.fill(atlas, 0.0, l.top, w, h - l.top, rgba(24, 24, 24, 255));
let step = self.current();
let title = Text::literal(format!(
"HDR Calibration {}/{}: {}",
self.step + 1,
STEPS.len(),
step.name()
let title = Text::literal(lang::tr(
"HDR Calibration %s/%s: %s",
&[&(self.step + 1), &STEPS.len(), &step.name()],
));
ui::centered_text(batch, atlas, &title, 0.0, l.top + 6.0, w, u32::MAX);
let hint = Text::literal(step.hint());
let hint = Text::literal(lang::get(step.hint_key()));
ui::centered_text(
batch,
atlas,
@@ -192,10 +199,12 @@ impl Calibration {
self.dragging,
);
let last = self.step + 1 == STEPS.len();
let save = lang::get_or("selectWorld.edit.save", "Save");
let next = lang::get("Next");
for (rect, label, active) in [
(l.cancel, "Cancel", true),
(l.back, "Back", self.step > 0),
(l.next, if last { "Save" } else { "Next" }, true),
(l.cancel, lang::get_or("gui.cancel", "Cancel"), true),
(l.back, lang::get_or("gui.back", "Back"), self.step > 0),
(l.next, if last { save } else { next }, true),
] {
let hover = over(cursor, rect) && active;
ui::button(
@@ -272,11 +281,12 @@ impl Calibration {
/// Writes what the player landed on into Horizon's settings.
pub fn save(&self, s: &mut Settings) {
let g = &mut s.horizon;
g.hdr_black_level = (self.values[0] * 100.0).round() as u32;
let [peak, paper_white] = [1, 2].map(|i| match self.auto[i - 1] {
let scale = [100.0, 1.0, 1.0];
let [black, peak, paper_white] = [0, 1, 2].map(|i| match self.auto[i] {
Some(auto) if auto == self.values[i] => 0,
_ => self.values[i].round() as u32,
_ => (self.values[i] * scale[i]).round() as u32,
});
g.hdr_black_level = black;
g.hdr_peak = peak;
g.hdr_paper_white = paper_white;
}
File diff suppressed because it is too large. Load diff
Loaded 100 of 729 files, more files were not shown because too many files have changed in this diff. Show more