Compare commits

...
Author SHA1 Message Date
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
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
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 026f3125f4 Merge commit '51946c9e4585a2e7f2e4af01af893fb6c1cfad0e' into s6179
# Conflicts:
#	crates/horizon-client/src/game/scoreboard.rs
2026-11-02 00:30:38 +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 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
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
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
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
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
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
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
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
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
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
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
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
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
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
Senjin 0df0b4d191 Merge remote-tracking branch 'origin/main' into hdr-polish
# Conflicts:
#	docs/graphics.md
2026-11-01 23:36:32 +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
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
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
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
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
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
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-du6e f8cb56f3a2 Merge branch 'optifine/cit-glint-rules' into optifine/cit-glint 2026-11-01 15:43:19 -06: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
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
some-du6e 5b41c18eed Merge branch 'optifine/cit-glint-rules' into optifine/cit-glint 2026-11-01 15:39:02 -06: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
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
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
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
arismoko 20d6193c00 Merge upstream main (ed102e0592) 2026-11-01 21:24:31 +00: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
some-du6e 711e912310 Merge branch 'optifine/cit-glint-rules' into optifine/cit-glint 2026-11-01 15:07:23 -06: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
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
some-du6e 3c60503ad7 Merge branch 'optifine/cit-glint-rules' into optifine/cit-glint 2026-11-01 14:57:10 -06: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
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
some-du6e 1b0808bbdb Merge branch 'optifine/cit-glint-rules' into optifine/cit-glint 2026-11-01 14:44:23 -06:00
some-du6e c2f5c37f3d Merge branch 'optifine/cit-armor' into optifine/cit-glint-rules 2026-11-01 14:43:53 -06: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
some-du6e d6b801c610 Merge branch 'optifine/cit-items' into optifine/cit-armor 2026-11-01 14:42:42 -06: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
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
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
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
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
some-du6e f8588d4738 Merge branch 'optifine/cit-items' into optifine/cit-armor 2026-11-01 14:20:21 -06: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
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
arismoko 411e7ee665 Merge upstream main (8101f23df4)
# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
2026-11-01 20:02:57 +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
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
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
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
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
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
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
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 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
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
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
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
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
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
some-du6e 9c34aa0193 Merge branch 'optifine/cit-armor' into optifine/cit-glint 2026-11-01 13:06:16 -06: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
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
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
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
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 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
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
arismoko 4290465dbe Mark the mod API's generated files as generated 2026-11-01 18:15:42 +00: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
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
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
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
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
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
levzzz ddb317c771 Merge branch 'pr-5909-new' into mod-api-rework 2026-11-01 20:40:22 +05:00
[email protected]andClaude Opus 5.5 aef97f1e43 Merge upstream/main into staging/unified-hmod
Upstream added --player-dir handling to the startup code in main.rs. With the
library split that code lives in entry.rs, so the change is applied there and
main.rs stays the thin launcher.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:35:47 +01:00
levzzz 3f3bdfa663 Merge branch 'pr-5909' into mod-api-rework 2026-11-01 20:24:21 +05:00
levzzzandClaude Opus 5.5 5482b975fb Merge hr/main into the mod API rework (#5446)
main.rs moved to entry.rs on this branch; carry main's --player-dir
log/crash folder handling over to entry.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 20:18:53 +05:00
[email protected]andClaude Opus 5.5 18a02fe574 Merge native/api-mods (#5446) into staging/unified-hmod
Combined staging branch: the game library split (#5446) and the single mod
file format (#5909) together, as agreed to land in one release. No conflicts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:17:21 +01:00
[email protected]andClaude Opus 5.5 cb11c1d221 Block states: drop a needless borrow clippy 1.99 refuses
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 13:45:12 +01:00
[email protected] 2aa6aa28cd Merge remote-tracking branch 'upstream/main' into staging/unified-hmod 2026-11-01 13:43:44 +01:00
[email protected]andClaude Opus 5.5 df6e0f0547 Mods: keep publishing .hnmod, write the mod file format down
Review on #5909: native versions published as .hmod would reach
launchers that drop their content or refuse them. The registry keeps
.hnmod until launchers and the game read native .hmods.

docs/api/mod-files.md holds every rule of a .hmod/.hnmod in one
place: zip and path rules, top-level entries, the horizon-native.json
schema, what mixins/<id>/ may hold and the size limits.

The registry's Cargo.lock catches up with horizon-mods' png
dependency so its --locked check passes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 13:35:43 +01:00
[email protected]andClaude Opus 5.5 02122e2532 Mods: one .hmod for content and native code (WIP, #5865)
A .hmod's contents decide what it is: content only, native code
(horizon-native.json + mixins/<id>/), or both in one file. Native
bundle reading skips a .hmod's content entries, checks both
manifests name one mod, and refuses mod.wasm with a clear error.
The registry publishes native-mod versions as .hmod and still
accepts .hnmod; .hnmod keeps loading unchanged.

Launcher install, game content loading of native .hmods and the
GitHub install path are not done yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 13:04:52 +01: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
309 changed files with 28927 additions and 2528 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
Generated
+3
View File
@@ -5120,6 +5120,9 @@ name = "xtask"
version = "0.1.0"
dependencies = [
"horizon-theme",
"quote",
"serde_json",
"syn 2.0.119",
]
[[package]]
+24
View File
@@ -91,3 +91,27 @@ licensed under the MIT license:
> 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.
+39
View File
@@ -130,6 +130,26 @@ impl ModelLibrary {
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
@@ -529,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"));
}
}
+106 -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);
@@ -254,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)
@@ -267,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())
@@ -350,6 +408,52 @@ 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"))
@@ -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);
@@ -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.
@@ -84,6 +85,18 @@ pub struct BlockEntities {
/// 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) {
@@ -220,11 +233,53 @@ 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> {
@@ -248,12 +303,28 @@ 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(_))) {
@@ -275,15 +346,20 @@ impl BlockEntities {
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);
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
@@ -301,6 +377,7 @@ impl BlockEntities {
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)
@@ -311,21 +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 mods = &mut self.mods;
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();
@@ -342,7 +428,9 @@ 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 {
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);
@@ -476,6 +564,52 @@ mod tests {
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();
@@ -560,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.
@@ -802,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.
@@ -894,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,
@@ -920,44 +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),
glow: None,
},
&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| {
@@ -970,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)
}
@@ -1271,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 {
@@ -1363,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(
@@ -1384,9 +1480,11 @@ 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();
let passes = std::iter::once((paint.sprite, paint.light))
.chain(paint.glow.map(|g| (g, FULL_BRIGHT)));
@@ -1421,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;
};
@@ -1446,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;
};
@@ -1472,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.
@@ -1515,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;
};
@@ -1559,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;
};
@@ -1588,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;
};
@@ -1617,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))
{
@@ -1677,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
@@ -1696,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)
@@ -1714,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),
@@ -1732,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))
@@ -1743,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;
};
@@ -1764,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;
};
@@ -1797,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;
};
@@ -2449,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,
@@ -2462,7 +2601,6 @@ mod tests {
angle: 90.0,
},
],
closed_chests: Default::default(),
pot_patterns: HashMap::new(),
visibility: None,
sign_texts: Mutex::default(),
@@ -2646,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") {
@@ -644,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,
+206 -19
View File
@@ -4,6 +4,7 @@
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};
@@ -16,7 +17,7 @@ use std::collections::HashMap;
use std::fs;
use std::hash::{DefaultHasher, Hash, Hasher};
use std::path::Path;
use std::sync::Mutex;
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`).
@@ -27,13 +28,8 @@ pub fn load_rules(root: &Path) -> Vec<Rule> {
.into_iter()
.filter(|path| !path.ends_with("/cit.properties"))
.filter_map(|path| {
// Java reads `.properties` as Latin-1: a pack saved that way still loads.
let bytes = fs::read(root.join(&path)).ok()?;
let text = String::from_utf8(bytes)
.unwrap_or_else(|e| e.into_bytes().iter().map(|&b| char::from(b)).collect());
let rule = Rule::parse(&path, &Properties::parse_equals_first(&text), |p| {
root.join(p).is_file()
});
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");
}
@@ -44,10 +40,22 @@ pub fn load_rules(root: &Path) -> Vec<Rule> {
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.
@@ -56,6 +64,7 @@ pub struct CitRules {
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)]
@@ -70,7 +79,10 @@ const CACHE_LIMIT: usize = 4096;
impl CitRules {
/// The enabled packs' rules under the overlay resource root.
pub fn load(root: &Path) -> Self {
Self::new(load_rules(root))
Self {
global: crate::cit_glint::load_global(root),
..Self::new(load_rules(root))
}
}
/// `rules` in priority order, the winner first.
@@ -106,6 +118,19 @@ impl CitRules {
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,
@@ -123,13 +148,14 @@ impl CitRules {
let found = candidates
.iter()
.copied()
.find(|&i| self.matches(i, stack, Some(hand), max_damage, comps.as_ref()));
.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, in priority order (layers are the glint PR's business).
pub fn enchantments(&self, stack: &ItemStack, max_damage: u32) -> Vec<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);
@@ -138,7 +164,7 @@ impl CitRules {
}
let comps = encode_components(stack);
let key = cache_key(
(1, Kind::Enchantment, Hand::Any),
(1, Kind::Enchantment, hand),
stack,
max_damage,
comps.as_ref(),
@@ -147,24 +173,62 @@ impl CitRules {
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, None, max_damage, comps.as_ref()));
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()
}
/// Whether rule `i` matches; `hand` None skips the hand test (enchantment glints).
/// 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: Option<Hand>,
hand: Hand,
max_damage: u32,
comps: Option<&Tag>,
) -> bool {
let rule = &self.rules[i as usize];
if hand.is_some_and(|h| rule.hand != Hand::Any && rule.hand != h) {
if rule.hand != Hand::Any && rule.hand != hand {
return false;
}
let count = i32::try_from(stack.count).unwrap_or(i32::MAX);
@@ -214,7 +278,7 @@ fn remember<V>(cache: &Mutex<HashMap<u64, V>>, key: u64, value: V) {
cache.insert(key, value);
}
/// What a cached answer depends on; `query` tells `select` from `enchantments`.
/// 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);
@@ -646,7 +710,7 @@ mod tests {
);
assert_eq!(item(&cit, enchanted("stone", &mixed)), Some("any3"));
let glints = |stack: ItemStack| -> Vec<&str> {
let found = cit.enchantments(&stack, 0).into_iter();
let found = cit.enchantments(&stack, Hand::Main, 0).into_iter();
found.map(|i| stem(&cit, Some(i)).unwrap()).collect()
};
let sharp = [("sharpness", 1)];
@@ -659,4 +723,127 @@ mod tests {
// 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);
}
}
+184 -6
View File
@@ -5,8 +5,11 @@
//! `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::BTreeSet;
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";
@@ -55,12 +58,11 @@ pub fn resource(id: &str, ext: &str) -> Option<String> {
horizon_optifine::paths::valid(rest).then(|| format!("assets/{rest}.{ext}"))
}
/// Sprite ids the item rules draw: `Kind::Item` rules' `texture` and `texture.<name>` values.
/// (Textures named by CIT JSON models come in a later PR.) A path no id can name is skipped with
/// a warning.
pub fn item_sprites(rules: &[Rule]) -> BTreeSet<String> {
/// 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| r.kind == Kind::Item) {
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) {
@@ -77,6 +79,122 @@ pub fn item_sprites(rules: &[Rule]) -> BTreeSet<String> {
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::*;
@@ -161,4 +279,64 @@ mod tests {
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)));
}
}
+193 -9
View File
@@ -387,7 +387,8 @@ impl ConnectedTextures {
/// What the `face` of the block `state` at `pos` draws, for a quad with sprite `layer`.
/// `neighbour` answers the state at an offset from the block; `biome` its biome, asked only
/// by rules that test one.
/// by rules that test one; `texture` the quad's own texture directions, asked only by rules
/// with `orient=texture`.
pub fn pick<'b>(
&self,
state: u32,
@@ -396,6 +397,7 @@ impl ConnectedTextures {
pos: [i32; 3],
neighbour: impl Fn([i32; 3]) -> u32,
biome: impl Fn() -> Option<&'b str>,
texture: impl Fn() -> Option<Axes>,
mut overlay: impl FnMut(Overlay),
) -> Pick {
let (mut by_state, mut by_layer) = (
@@ -427,9 +429,58 @@ impl ConnectedTextures {
let r = &self.rules[usize::from(i)];
if (block || r.rule.match_blocks.is_empty())
&& (tile || r.rule.match_tiles.is_empty())
&& let Some(pick) =
self.apply(r, state, layer, face, pos, &neighbour, &biome, &mut overlay)
&& let Some(pick) = self.apply(
r,
state,
layer,
face,
pos,
&neighbour,
&biome,
&texture,
&mut overlay,
)
{
// "Tiles output by CTM rules can also be matched by another rule": once, by the
// tile rules naming the picked tile (and this block, if they name blocks).
// A tile on the face's own sprite (`tiles=stone`) is no new tile.
let Pick::Tile(TileLayer::Layer { layer: picked, .. }) = pick else {
return pick;
};
if picked == layer {
return pick;
}
let own = self.list(&self.by_state, state as usize);
// Rules tried on the face's sprite already (those before this one) are not tried
// again: an overlay would draw twice.
let first = self.list(&self.by_layer, layer as usize);
let again = self
.list(&self.by_layer, picked as usize)
.iter()
.map(|&j| (j, &self.rules[usize::from(j)]))
.filter(|&(j, r)| {
j != i
&& !(j < i && first.contains(&j))
&& (r.rule.match_blocks.is_empty() || own.contains(&j))
});
for (_, r) in again {
// Joins by tile still compare the face's own sprite with its neighbours'.
match self.apply(
r,
state,
layer,
face,
pos,
&neighbour,
&biome,
&texture,
&mut overlay,
) {
Some(Pick::Keep) => return pick,
Some(chained) => return chained,
None => {}
}
}
return pick;
}
}
@@ -447,6 +498,7 @@ impl ConnectedTextures {
pos: [i32; 3],
neighbour: &impl Fn([i32; 3]) -> u32,
biome: &impl Fn() -> Option<&'b str>,
texture: &impl Fn() -> Option<Axes>,
overlay: &mut impl FnMut(Overlay),
) -> Option<Pick> {
let rule = &r.rule;
@@ -487,7 +539,12 @@ impl ConnectedTextures {
};
let axes = |face| match (rule.orient, info.axis) {
(Orient::StateAxis, Some(axis)) => Axes::column(face, axis),
// `orient=texture` follows the quad's own UVs; vanilla cubes are what it is used on.
// The quad's own texture directions, whatever face `symmetry` stands it for.
// Not for `overlay`, which Continuity's `orient` leaves out: its 17 tiles always face
// the standard way.
(Orient::Texture, _) if rule.method != Method::Overlay => {
texture().unwrap_or(Axes::cube(face))
}
_ => Axes::cube(face),
};
let mask = || geometry::neighbours(axes(face), face.normal(), rule.inner_seams, joins);
@@ -649,6 +706,10 @@ mod tests {
|name| match name {
"minecraft:block/stone" => Some(7),
"minecraft:block/glass" => Some(3),
// The tile `b0` of a rule second in order (`a_picked_tile_can_be_picked_again`).
"assets/minecraft/optifine/ctm/b0.png" => Some(2000),
// And `a0` of the first rule.
"assets/minecraft/optifine/ctm/a0.png" => Some(1000),
_ => None,
},
)
@@ -671,7 +732,7 @@ mod tests {
blocks: &[([i32; 3], u32)],
) -> Pick {
let at = |o: [i32; 3]| blocks.iter().find(|(p, _)| *p == o).map_or(0, |b| b.1);
ctm.pick(state, layer, face, [0, 64, 0], at, || None, |_| {})
ctm.pick(state, layer, face, [0, 64, 0], at, || None, || None, |_| {})
}
const GLASS: &str = "matchBlocks=glass\nmethod=ctm\ntiles=0-46";
@@ -794,8 +855,26 @@ mod tests {
)]);
let mut tiles = std::collections::HashSet::new();
for x in 0..20 {
let lower = ctm.pick(5, 3, Face::North, [x, 64, 0], |_| 0, || None, |_| {});
let upper = ctm.pick(6, 3, Face::North, [x, 65, 0], |_| 0, || None, |_| {});
let lower = ctm.pick(
5,
3,
Face::North,
[x, 64, 0],
|_| 0,
|| None,
|| None,
|_| {},
);
let upper = ctm.pick(
6,
3,
Face::North,
[x, 65, 0],
|_| 0,
|| None,
|| None,
|_| {},
);
assert_eq!(lower, upper, "{x}");
tiles.insert(format!("{lower:?}"));
}
@@ -809,7 +888,16 @@ mod tests {
"matchBlocks=glass\nmethod=fixed\ntiles=0\nheights=0-63\nbiomes=desert",
)]);
let at = |y, biome: &'static str| {
ctm.pick(1, 3, Face::Up, [0, y, 0], |_| 0, || Some(biome), |_| {})
ctm.pick(
1,
3,
Face::Up,
[0, y, 0],
|_| 0,
|| Some(biome),
|| None,
|_| {},
)
};
assert_eq!(at(10, "minecraft:desert"), tile(1000));
assert_eq!(at(64, "minecraft:desert"), Pick::Keep);
@@ -844,7 +932,7 @@ mod tests {
"glass.properties",
"matchBlocks=glass\nmethod=repeat\nwidth=2\nheight=2\ntiles=0-3",
)]);
let at = |pos| ctm.pick(1, 3, Face::North, pos, |_| 0, || None, |_| {});
let at = |pos| ctm.pick(1, 3, Face::North, pos, |_| 0, || None, || None, |_| {});
assert_eq!(at([0, 64, 0]), tile(1000));
// The north face's right is west; rows run down.
assert_eq!(at([-1, 64, 0]), tile(1001));
@@ -925,6 +1013,7 @@ mod tests {
[0, 64, 0],
at,
|| None,
|| None,
|o| out.push(o.layer),
);
(out, pick)
@@ -962,6 +1051,7 @@ mod tests {
[0, 64, 0],
|o| if o == [0, 0, -1] { 1 } else { 0 },
|| None,
|| None,
|o| {
tint = Some((o.tint, o.translucent));
},
@@ -999,6 +1089,7 @@ mod tests {
[0, 64, 0],
door_north,
|| None,
|| None,
|o| got.push(o),
);
assert_eq!(
@@ -1039,6 +1130,99 @@ mod tests {
assert_eq!((layers, pick), (vec![1001, 3000], Pick::Keep));
}
#[test]
fn a_picked_tile_can_be_picked_again() {
// b (rule 1) gives glass its tile b0, layer 2000; t (rule 0, a tile rule, tried first)
// names that tile, so it draws in b0's place.
let chain = |t: &str| {
world(&[
("b.properties", "matchBlocks=glass\nmethod=fixed\ntiles=b0"),
("t.properties", t),
])
};
let ctm = chain("matchTiles=optifine/ctm/b0.png\nmethod=fixed\ntiles=0");
assert_eq!(pick_in(&ctm, 1, 3, Face::Up, &[]), tile(1000));
// A chained rule keeping the tile (<default>) leaves b's.
let ctm = chain("matchTiles=optifine/ctm/b0.png\nmethod=fixed\ntiles=<default>");
assert_eq!(pick_in(&ctm, 1, 3, Face::Up, &[]), tile(2000));
// One that names another block doesn't chain on glass.
let ctm = chain("matchTiles=optifine/ctm/b0.png\nmatchBlocks=stone\nmethod=fixed\ntiles=0");
assert_eq!(pick_in(&ctm, 1, 3, Face::Up, &[]), tile(2000));
// An overlay tried on the face's own sprite and on the picked tile draws once.
let ctm = chain("matchTiles=glass optifine/ctm/b0.png\nmethod=overlay_fixed\ntiles=0");
let (overlays, pick) = overlays_in(&ctm, 1, Face::Up, &[]);
assert_eq!((overlays, pick), (vec![1000], tile(2000)));
// A rule after the one that picked, naming both the face's sprite and the tile, wasn't
// tried: it chains.
let ctm = world(&[
("a.properties", "matchTiles=glass\nmethod=fixed\ntiles=a0"),
(
"c.properties",
"matchTiles=glass optifine/ctm/a0.png\nmethod=fixed\ntiles=0",
),
]);
assert_eq!(pick_in(&ctm, 1, 3, Face::Up, &[]), tile(2000));
}
#[test]
fn orient_texture_follows_the_quads_uvs() {
let rule = |orient: &str| {
world(&[(
"glass.properties",
&*format!("matchBlocks=glass\nmethod=horizontal\ntiles=0-3\norient={orient}"),
)])
};
// A mirrored north face (a vanilla pane's): its texture's right is east.
let mirrored = || {
Some(Axes {
right: [1, 0, 0],
up: [0, 1, 0],
})
};
let east = |o: [i32; 3]| if o == [1, 0, 0] { 1 } else { 0 };
let at = |ctm: &ConnectedTextures| {
ctm.pick(
1,
3,
Face::North,
[0, 64, 0],
east,
|| None,
mirrored,
|_| {},
)
};
// Glass to the east is to the texture's right: the row's left end.
assert_eq!(at(&rule("texture")), tile(1000));
// By the cube's directions it is to the left: the row's right end.
assert_eq!(at(&rule("none")), tile(1002));
// A plain overlay keeps the standard directions: glass north of a top face is above it
// (tile 15) whatever the quad's texture says.
let overlay = world(&[(
"a.properties",
"matchBlocks=stone\nmethod=overlay\nconnectBlocks=glass\ntiles=0-16\norient=texture",
)]);
let turned = || {
Some(Axes {
right: [0, 0, 1],
up: [1, 0, 0],
})
};
let north = |o: [i32; 3]| if o == [0, 0, -1] { 1 } else { 0 };
let mut got = Vec::new();
overlay.pick(
2,
7,
Face::Up,
[0, 64, 0],
north,
|| None,
turned,
|o| got.push(o.layer),
);
assert_eq!(got, [1015]);
}
#[test]
fn stacked_panes_hide_the_strips_they_share() {
let props = |pairs: &[(&str, &str)]| -> BlockProperties {
+31 -5
View File
@@ -31,6 +31,9 @@ struct Globals {
// 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>;
@@ -69,6 +72,8 @@ struct Out {
// 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 {
@@ -107,6 +112,16 @@ fn native_colored_light(pos: vec3<f32>) -> vec3<f32> {
}
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;
@@ -120,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);
}
@@ -328,8 +344,14 @@ fn shadow_color(in: Out) -> vec4<f32> {
var uv = in.uv;
if in.overlay == 6u { uv.y = in.wrap.x + fract(in.uv.y) * in.wrap.y; }
let texel = textureSample(atlas, atlas_sampler, uv);
if in.tier == 0u || texel.a < 0.1 { discard; }
return vec4(0.0, 0.0, 0.0, hdr_mark(in.tier, brightness(texel.rgb * in.color.rgb)));
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));
@@ -348,7 +370,7 @@ fn hdr_mark(tier: u32, y: f32) -> f32 {
// 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, 1.0);
return vec4(0.0, 0.0, 0.0, hdr_scene_alpha(in.scene));
}
fn celestial_color(in: Out) -> vec4<f32> {
@@ -375,8 +397,12 @@ fn celestial_color(in: Out) -> vec4<f32> {
let added = color.rgb * color.a;
// 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 keep = 1.0 - hdr_mark(select(in.tier, 4u, in.tier == 0u), brightness(added));
return vec4(added, keep * smoothstep(0.1, 0.3, max(added.r, max(added.g, added.b))));
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
+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
+260 -61
View File
@@ -112,6 +112,7 @@ mod cull;
mod end_sky;
mod flat;
mod frame_gen;
mod gpu_lists;
mod hdr;
mod hdr_icc;
mod hdr_stats;
@@ -145,6 +146,7 @@ mod page_runs;
pub mod panorama;
mod readback;
mod resident;
mod reuse;
mod stars;
pub mod submit;
mod sunrise_fan;
@@ -328,6 +330,7 @@ struct Globals {
/// HDR output's tier windows, low to very bright: where the lift starts, where it is
/// full (`hdr::Hdr::windows`); zero without HDR.
hdr_windows: [[f32; 4]; 2],
hdr_light: [f32; 4],
}
pub type SectionKey = (i32, i32, i32);
/// The water mirror's targets: the world as a camera mirrored about the water plane sees
@@ -355,6 +358,8 @@ struct SectionGpu {
sorted_indices: Option<(u32, u64)>,
/// Its id for flat draws (`flat::Flat::add`), when they are on.
flat_id: Option<u32>,
/// Its row for phase 1's lists made on the GPU (`gpu_lists::GpuLists::add`).
list_id: Option<u32>,
/// When it began to fade in over the far terrain it replaces, which
/// stays whole behind it until then: a section new where far tiles are
/// drawn. Meshed again, it stays as it was.
@@ -445,12 +450,19 @@ impl Part {
/// hold on `page` of `arena` (`part`) goes to its other pages in video memory,
/// until the next range would pass `budget` bytes (it keeps what is left; the
/// first range moves whatever its size, so none keeps the page closed), the
/// copies into `encoder` and, in the section arena, the ranges' flat draw ids
/// (`flat`) along. False when the other pages have no room left.
/// copies into `encoder` and, in the section arena, the ranges' flat draw ids and
/// GPU list rows (`ids`) along. False when the other pages have no room left.
/// The flat draws and GPU lists that name a mesh's range, with what writes to them.
type MeshIds<'a> = (
Option<&'a mut flat::Flat>,
Option<&'a mut gpu_lists::GpuLists>,
&'a wgpu::Device,
&'a mut upload::Uploads,
);
fn move_off_page<'a>(
arena: &mut arena::Arena,
encoder: &mut wgpu::CommandEncoder,
mut flat: Option<(&mut flat::Flat, &wgpu::Device, &mut upload::Uploads)>,
mut ids: Option<MeshIds<'_>>,
meshes: impl Iterator<Item = &'a mut SectionGpu>,
page: u32,
part: Part,
@@ -472,9 +484,14 @@ fn move_off_page<'a>(
};
arena.copy(encoder, (page, start), to, vertices);
arena.release(page, start, vertices);
if let (Some((flat, device, uploads)), Some(id)) = (&mut flat, s.flat_id) {
flat.remove(id);
s.flat_id = Some(flat.add(device, encoder, uploads, to.0, to.1, vertices));
if let Some((flat, lists, device, uploads)) = &mut ids {
if let (Some(flat), Some(id)) = (flat, s.flat_id) {
flat.remove(id);
s.flat_id = Some(flat.add(device, encoder, uploads, to.0, to.1, vertices));
}
if let (Some(lists), Some(id)) = (lists, s.list_id) {
lists.set(device, encoder, uploads, id, to.1 as u32, &s.sides);
}
}
part.set(s, to);
*budget = budget.saturating_sub(bytes);
@@ -742,7 +759,8 @@ pub struct Renderer {
/// (F3+4 shows it).
pub lightmap_pixels: [u8; 16 * 16 * 4],
/// Time the last `render` spent blocked on the GPU or the display
/// (acquiring, presenting, offscreen pacing), excluded from CPU time.
/// (acquiring, presenting, offscreen pacing, the submit thread holding the queue),
/// excluded from CPU time.
pub last_wait: std::time::Duration,
/// CPU time of the last `render`, waits excluded.
pub last_encode: std::time::Duration,
@@ -753,6 +771,9 @@ pub struct Renderer {
display_fresh: bool,
window: Arc<Window>,
pub device: wgpu::Device,
/// The shader modules, layouts and pipelines kept for the next renderer on
/// this device ([`reuse`]).
reuse: Arc<reuse::Reuse>,
/// Frames are finished and submitted on its submit thread ([`submit`]).
pub queue: submit::Queue,
/// The last frame went to the submit thread; whether it had no window surface.
@@ -771,6 +792,8 @@ pub struct Renderer {
flat: Option<flat::Flat>,
/// `flat` draws this frame's sections (off: the per-section draws).
flat_on: bool,
/// Phase 1's solid lists made on the GPU, in flat frames (with `flat`).
gpu_lists: Option<gpu_lists::GpuLists>,
/// Occlusion culling of solid section draws, where the GPU's instance
/// counts reach a multi-draw's entries (Vulkan, Metal).
cull: Option<cull::Cull>,
@@ -924,6 +947,8 @@ pub struct Renderer {
/// last asked (`take_dropped_sections`).
sections_dropped: bool,
sorted_indices: arena::Arena,
/// Buffers of released arena pages, freed a few frames later on a thread of their own.
graveyard: arena::Graveyard<wgpu::Buffer>,
translucent_sorter: translucency::Sorter,
translucency_cursor: usize,
/// `DrawIndexedIndirectArgs` for this frame's solid then translucent draws.
@@ -1103,7 +1128,7 @@ const SPRITE_ARRAYS: u32 = 8;
/// array. wgpu's OpenGL backend picks a texture's target from its layer
/// count when it is created: one layer makes a 2D texture and a multiple of
/// six a cube map, neither of which can be viewed as an array.
fn array_layers(count: u32, gl: bool) -> u32 {
pub(crate) fn array_layers(count: u32, gl: bool) -> u32 {
match count {
_ if !gl => count,
0 | 1 => 2,
@@ -1857,7 +1882,10 @@ impl Renderer {
device_lost: renderer.device_lost.clone(),
}
}
None => open_device(&window, offscreen, api, layers).await?,
None => {
let _step = crate::spans::startup::step("renderer.open_device");
open_device(&window, offscreen, api, layers).await?
}
};
// Computed once: the sprite arrays, the shaders' constants and the
// shader pack atlas all cut the sprites by it.
@@ -1937,8 +1965,15 @@ impl Renderer {
if previous.is_none() {
startup_step(format_args!("creating the pipelines"));
}
// A renderer on the same device takes the previous one's shader
// modules, layouts and pipelines where they come out the same.
let reuse = previous.map_or_else(Default::default, |r| r.reuse.clone());
let generation = reuse.begin();
let kept = reuse.on(&device);
let pipelines_step = crate::spans::startup::step("renderer.pipelines_textures");
let terrain_step = crate::spans::startup::step("renderer.terrain_pipelines");
let gpu_timer = timing::GpuTimer::new(&device, &queue);
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
let shader = kept.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Horizon terrain"),
source: wgpu::ShaderSource::Wgsl(include_str!("terrain.wgsl").into()),
});
@@ -1962,7 +1997,7 @@ impl Renderer {
("SPRITE_ARRAYS", f64::from(sprite_arrays)),
("SPRITE_SPLIT", f64::from(split)),
];
let bindings = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
let bindings = kept.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Horizon terrain bindings"),
entries: &[
entry(
@@ -2020,7 +2055,7 @@ impl Renderer {
),
],
});
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
let layout = kept.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Horizon terrain layout"),
bind_group_layouts: &[Some(&bindings)],
..Default::default()
@@ -2056,7 +2091,7 @@ impl Renderer {
depth_write: bool,
depth_test: bool,
blend: Option<wgpu::BlendState>| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
kept.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(label),
layout: Some(layout),
vertex: wgpu::VertexState {
@@ -2204,7 +2239,7 @@ impl Renderer {
false,
Some(alpha_blend),
);
let depth_reset = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
let depth_reset = kept.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Hand depth reset"),
layout: Some(&layout),
vertex: wgpu::VertexState {
@@ -2283,6 +2318,7 @@ impl Renderer {
false,
Some(alpha_blend),
);
drop(terrain_step);
let globals = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Horizon globals"),
size: std::mem::size_of::<Globals>() as u64,
@@ -2594,11 +2630,12 @@ impl Renderer {
&glint_sampler,
)?;
hud.atlas_texture = Some(atlas.clone());
let entity_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
let entity_step = crate::spans::startup::step("renderer.entity_pipelines");
let entity_shader = kept.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Horizon entities"),
source: wgpu::ShaderSource::Wgsl(entity_shader().into()),
});
let entity_bindings = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
let entity_bindings = kept.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Entity bindings"),
entries: &[
entry(
@@ -2749,12 +2786,12 @@ impl Renderer {
},
],
});
let entity_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
let entity_layout = kept.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Entity layout"),
bind_group_layouts: &[Some(&entity_bindings)],
..Default::default()
});
let cloud_bindings = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
let cloud_bindings = kept.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Cloud bindings"),
entries: &[entry(
0,
@@ -2780,7 +2817,7 @@ impl Renderer {
resource: cloud_uniform.as_entire_binding(),
}],
});
let cloud_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
let cloud_layout = kept.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Cloud layout"),
bind_group_layouts: &[Some(&entity_bindings), Some(&cloud_bindings)],
..Default::default()
@@ -2807,7 +2844,7 @@ impl Renderer {
clouds,
fragment: &str,
write_mask| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
kept.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(label),
layout: Some(if clouds {
&cloud_layout
@@ -3069,10 +3106,10 @@ impl Renderer {
multiview_mask: None,
cache: None,
};
let additive_pipeline = device.create_render_pipeline(&additive_descriptor);
let additive_pipeline = kept.create_render_pipeline(&additive_descriptor);
// `RenderPipelines.DRAGON_RAYS` uses DepthStencilState.DEFAULT:
// unlike eyes, a ray hides the faces and rays behind it.
let dragon_rays_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
let dragon_rays_pipeline = kept.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Dragon death rays"),
primitive: wgpu::PrimitiveState {
cull_mode: Some(wgpu::Face::Back),
@@ -3092,7 +3129,7 @@ impl Renderer {
write_mask: wgpu::ColorWrites::ALPHA,
})];
let hdr_mark_lightning_pipeline =
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
kept.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("HDR lightning mark"),
vertex: wgpu::VertexState {
entry_point: Some("vs_lightning"),
@@ -3107,7 +3144,7 @@ impl Renderer {
}),
..additive_descriptor.clone()
});
let lightning_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
let lightning_pipeline = kept.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Lightning"),
vertex: wgpu::VertexState {
entry_point: Some("vs_lightning"),
@@ -3116,7 +3153,7 @@ impl Renderer {
},
..additive_descriptor
});
let celestial_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
let celestial_pipeline = kept.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Celestial bodies"),
layout: Some(&entity_layout),
vertex: wgpu::VertexState {
@@ -3165,6 +3202,7 @@ impl Renderer {
multiview_mask: None,
cache: None,
});
drop(entity_step);
// WorldBorderRenderer: misc/forcefield.png as a MipmappedTexture
// (repeat, linear minification with mipmaps, nearest magnification).
let forcefield_size = forcefield.1;
@@ -3210,7 +3248,7 @@ impl Renderer {
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let border_bindings = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
let border_bindings = kept.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("World border bindings"),
entries: &[
entry(
@@ -3269,19 +3307,19 @@ impl Renderer {
},
],
});
let border_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
let border_layout = kept.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("World border layout"),
bind_group_layouts: &[Some(&border_bindings)],
..Default::default()
});
let border_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
let border_shader = kept.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Horizon world border"),
source: wgpu::ShaderSource::Wgsl(include_str!("border.wgsl").into()),
});
let border_attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2];
// RenderPipelines.WORLD_BORDER: BlendFunction.OVERLAY, no culling,
// depth writes with a polygon offset of 3 towards the camera.
let border_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
let border_pipeline = kept.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("World border"),
layout: Some(&border_layout),
vertex: wgpu::VertexState {
@@ -3334,7 +3372,7 @@ impl Renderer {
multiview_mask: None,
cache: None,
});
let screen_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
let screen_shader = kept.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Horizon screen effects"),
source: wgpu::ShaderSource::Wgsl(include_str!("screen.wgsl").into()),
});
@@ -3373,7 +3411,7 @@ impl Renderer {
constants: &constants,
..Default::default()
};
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
kept.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Screen effects"),
layout: Some(&entity_layout),
vertex: wgpu::VertexState {
@@ -3476,6 +3514,7 @@ impl Renderer {
.get_downlevel_capabilities()
.flags
.contains(wgpu::DownlevelFlags::INDIRECT_EXECUTION);
let gpu_lists = flat.as_ref().map(|_| gpu_lists::GpuLists::new(&device));
let batchable = translucent_batchable(
&adapter.get_info(),
multi_draw,
@@ -3542,6 +3581,14 @@ impl Renderer {
);
let panorama =
panorama::Panorama::new(&device, config.format.remove_srgb_suffix(), panorama_faces);
drop(pipelines_step);
reuse.finish(generation);
if previous.is_some() {
log::info!(
"Horizon renderer: rebuilt on the same device ({} shader modules, layouts and pipelines kept)",
reuse.len()
);
}
if previous.is_none() {
startup_step(format_args!(
"pipelines ready (multi-draw {}, occlusion culling {}, GPU timer {})",
@@ -3572,6 +3619,7 @@ impl Renderer {
last_encode: std::time::Duration::ZERO,
window,
device,
reuse,
queue: submit::Queue::new(queue, submit::wanted()),
deferred: None,
device_lost,
@@ -3581,6 +3629,7 @@ impl Renderer {
solid_cutout,
translucent,
flat_on: flat.is_some(),
gpu_lists,
cull_on: cull.is_some(),
sides_on: true,
cull,
@@ -3686,6 +3735,7 @@ impl Renderer {
translucent_batching,
sections_dropped: false,
sorted_indices,
graveyard: Default::default(),
translucent_sorter: translucency::Sorter::default(),
translucency_cursor: 0,
indirect,
@@ -3979,6 +4029,10 @@ impl Renderer {
pub fn section_count(&self) -> usize {
self.sections.len()
}
/// The running native pack's `[[particle_lights]]`.
pub fn particle_lights(&self) -> Option<&[horizon_shader_api::manifest::ParticleLight]> {
self.native.as_ref().map(|n| n.particle_lights.as_slice())
}
/// Whether one of Horizon's own packs with `[shadow]` draws, whose shadow pass takes the
/// frame's `shadow_only` quads.
pub fn native_shadow(&self) -> bool {
@@ -4122,6 +4176,11 @@ impl Renderer {
} else {
None
};
let sides = if mesh.sides.quads() == solid {
mesh.sides
} else {
Sides::unsorted(solid)
};
// Flat draws name the blocks of its range in `arena`: split, its solid
// quads alone (no frame draws flat then: `translucent_split`).
let flat_id = self.flat.as_mut().filter(|_| main > 0).map(|f| {
@@ -4134,6 +4193,15 @@ impl Renderer {
main,
)
});
let list_id = self.gpu_lists.as_mut().filter(|_| main > 0).map(|l| {
l.add(
&self.device,
encoder,
&mut self.section_uploads,
start as u32,
&sides,
)
});
let stride = std::mem::size_of::<TerrainVertex>() as u64;
let translucent_arena = self.translucent_arena.as_ref().unwrap_or(&self.arena);
for (part, arena, (part_page, first)) in [
@@ -4164,11 +4232,7 @@ impl Renderer {
start,
translucent_at,
solid,
sides: if mesh.sides.quads() == solid {
mesh.sides
} else {
Sides::unsorted(solid)
},
sides,
translucent,
sort: translucency::SortState::new(
mesh.translucent
@@ -4180,6 +4244,7 @@ impl Renderer {
),
sorted_indices,
flat_id,
list_id,
fading: None,
leaving: None,
animated_layers,
@@ -4252,6 +4317,9 @@ impl Renderer {
if let (Some(flat), Some(id)) = (&mut self.flat, s.flat_id) {
flat.remove(id);
}
if let (Some(lists), Some(id)) = (&mut self.gpu_lists, s.list_id) {
lists.remove(id);
}
if let Some((page, start)) = s.sorted_indices {
self.sorted_indices
.release(page, start, u64::from(s.translucent) * 6);
@@ -4294,13 +4362,15 @@ impl Renderer {
.sections
.values_mut()
.chain(self.lods.values_mut().flat_map(|tile| tile.meshes_mut()));
let flat = self
.flat
.as_mut()
.map(|f| (f, &self.device, &mut self.section_uploads));
let ids = Some((
self.flat.as_mut(),
self.gpu_lists.as_mut(),
&self.device,
&mut self.section_uploads,
));
let split = self.translucent_arena.is_some();
let (arena, part) = (&mut self.arena, Part::Section { split });
if !move_off_page(arena, encoder, flat, meshes, page, part, &mut budget) {
if !move_off_page(arena, encoder, ids, meshes, page, part, &mut budget) {
// The other pages have no room after all.
arena.reopen();
}
@@ -4434,14 +4504,15 @@ impl Renderer {
self.lod_pending = None;
self.detail_columns.clear();
self.detail_revision += 1;
self.arena.clear();
// A dimension change drops every page at once: freed later, off this thread.
self.graveyard.add(self.arena.clear());
if let Some(arena) = &mut self.translucent_arena {
arena.clear();
self.graveyard.add(arena.clear());
}
if let Some(flat) = &mut self.flat {
flat.clear();
}
self.sorted_indices.clear();
self.graveyard.add(self.sorted_indices.clear());
self.translucency_cursor = 0;
if layout == self.layout {
self.occlusion.clear();
@@ -4605,6 +4676,11 @@ impl Renderer {
flat.remove(id);
}
}
if let Some(lists) = &mut self.gpu_lists {
for id in released.iter().filter_map(|s| s.list_id) {
lists.remove(id);
}
}
self.sorted_indices
.release_all(released.iter().filter_map(|s| {
let (page, start) = s.sorted_indices?;
@@ -4852,6 +4928,11 @@ impl Renderer {
let world = pack_post::World {
atlas: self.pack_atlas.as_ref().map(|a| (a.view.clone(), a.size())),
lightmap: self.lightmap_view.clone(),
gui_size: self
.hud
.atlas_texture
.as_ref()
.map_or([1, 1], |t| [t.width(), t.height()]),
terrain: self.pack_atlas.as_ref().map(|_| pack_post::Geometry {
buffers: if flat { &buffers[..1] } else { &buffers },
table: self.flat.as_ref().filter(|_| flat).map(|f| &f.draw_layout),
@@ -5324,7 +5405,13 @@ impl Renderer {
}
/// `checks`: the error scopes of a scaled screenshot's frame, checked before its PNG is written.
fn draw_frame(&mut self, frame: &Frame, checks: Option<GpuChecks>) -> Result<bool> {
// Its steps as spans (HORIZON_SPIKES): each lap names the step that just ended.
let mut laps = crate::spans::laps();
let landing = Instant::now();
self.land_frame();
// Waiting for the previous frame (offscreen: at most two on the GPU), not work.
let landed = landing.elapsed();
self.queue.take_waited();
// World frames alone follow the shader pack: the game loads the picked one
// before it draws one. Title frames draw no terrain and keep the layout
// through a pack dropped (Shader Options) or not loaded yet (`Renderer::new`),
@@ -5332,12 +5419,14 @@ impl Renderer {
if !frame.title {
self.follow_translucent_layout();
}
self.arena.trim();
let released = self.arena.trim();
self.graveyard.add(released);
let left = self.compact_arena();
if let Some(arena) = &mut self.translucent_arena {
arena.trim();
self.graveyard.add(arena.trim());
}
self.sorted_indices.trim();
self.graveyard.add(self.sorted_indices.trim());
arena::free_later(self.graveyard.advance());
self.shrink_translucent(left);
if let Some(flat) = &mut self.flat {
flat.trim(|page| self.arena.has_page(page));
@@ -5373,6 +5462,7 @@ impl Renderer {
}
self.surface_unconfigured = false;
}
laps.lap("r_prepare");
// Frame generation: its present thread acquires and presents, this frame draws
// into frames of its own. Not over menus that blur the frame, a shader pack's
// own post passes, HDR or the title screen.
@@ -5388,6 +5478,8 @@ impl Renderer {
if !fg {
self.frame_gen.stop();
}
// Waits for the submit thread since the last frame's present (this frame's uploads).
let queued = self.queue.take_waited();
let acquire = Instant::now();
let fg_frames = fg
.then(|| {
@@ -5409,7 +5501,9 @@ impl Renderer {
} else {
self.surface.as_ref().map(|s| s.get_current_texture())
};
self.last_wait = acquire.elapsed();
self.last_wait = landed + queued + acquire.elapsed();
// Inside the acquire's time already.
self.queue.take_waited();
let (surface_frame, reconfigure) = match acquired {
None => (None, false),
Some(wgpu::CurrentSurfaceTexture::Success(f)) => (Some(f), false),
@@ -5495,6 +5589,7 @@ impl Renderer {
let views = self.views(frame);
let pose = frame.bob * frame.spin;
let view_proj = views.primary().view_proj(pose);
laps.lap("r_acquire");
// TAA runs where the world post pass does, not under a shader pack's own.
let taa = !frame.title
&& self.pack_post.as_ref().is_none_or(|p| p.1.is_none())
@@ -5634,9 +5729,21 @@ impl Renderer {
0.0,
],
hdr_windows: self.hdr.as_ref().map_or([[0.0; 4]; 2], hdr::Hdr::windows),
hdr_light: [
frame.sky.sky_light_factor,
// The underwater overlay: the sky and fog are the water's own colour there.
if frame.screen.iter().any(|q| q.world_tint) {
0.0
} else {
frame.sky.sky_light_factor
},
0.0,
0.0,
],
};
self.uploads
.buffer(&mut encoder, &self.globals, 0, bytemuck::bytes_of(&globals));
laps.lap("r_targets");
// GameRenderer.render3dHud: the hand's projection at hudFov, with the
// bob in its pose rather than the projection (same product).
let hand_projection = Mat4::from_cols_array(&crate::camera::hand_projection(
@@ -5842,6 +5949,7 @@ impl Renderer {
}
self.origins.clear();
let (sections, retiring) = (&self.sections, &self.lod_retiring);
laps.lap("r_cameras");
// `LevelRenderer.extractSectionDrawGroups`: each section's `getVisibility`. Once
// nothing is fading (the usual frame), every section is whole without a lookup.
let fade_ms = fade_millis(frame.chunk_fade);
@@ -5931,10 +6039,40 @@ impl Renderer {
(true, true) => &[(1, Quads::All)],
}
};
// Phase 1's sections can leave finding their facing quads to the GPU
// (`gpu_lists`) in frames drawn flat with nothing but the camera's
// terrain: no mirror, shader pack terrain or shadow, nor tiles.
let use_lists = gpu_lists_on()
&& !pack_terrain
&& shadow_view.is_none()
&& mirror.is_none()
&& visible_lods.is_empty()
&& self.flat_on
&& (self.flat.as_ref()).is_some_and(|f| self.drawn_sections >= f.min_sections);
if let Some(lists) = &mut self.gpu_lists {
lists.clear(&self.device);
}
let mut lists = self.gpu_lists.as_mut().filter(|_| use_lists);
let mut opaque_draws = 0;
for (i, v) in visible.iter().enumerate() {
let s = self.sections.get(v.slot);
let first_phase = cull.as_ref().is_none_or(|c| c.first(v.slot));
if first_phase
&& let (Some(lists), Some(id)) = (&mut lists, s.list_id)
&& lists.list(
id,
s.page,
v.distance,
&s.sides,
parts(fade_code(s, &v.key) != 0),
v.origin,
)
{
if let Some(c) = &mut cull {
c.drawn(i, v.slot, s.sides.quads() * 6);
}
continue;
}
let (draws, pages) = if first_phase {
(&mut self.draws, &mut self.draw_pages)
} else {
@@ -6023,6 +6161,7 @@ impl Renderer {
self.draw_pages.extend_from_slice(&self.phase2_pages);
let culling = cull.is_some();
let solid_draws = self.draws.len() as u32;
laps.lap("r_section_groups");
// LevelRenderer: nearby sections each frame, plus a rotating eighth
// (at least 15) of the visible list. Cached views avoid needless sorts.
self.translucent_draws.clear();
@@ -6220,6 +6359,7 @@ impl Renderer {
(shadow_first + shadow_solid) as usize,
);
let shadow_translucent = self.draws.len() as u32 - shadow_first - shadow_solid;
laps.lap("r_nearby");
// Metal replays a multi-draw as one draw call per entry.
self.section_draw_calls = if self.multi_draw && self.info.backend != wgpu::Backend::Metal {
(0..self.section_draws)
@@ -6305,12 +6445,19 @@ impl Renderer {
&self.queue,
frame.entities,
frame.entity_spans,
frame.crumbling_sprite,
&mut self.pack_entity_rest,
);
entities = &self.pack_entity_rest;
}
// GameRenderer.useImprovedTransparency. A shader pack keeps its own
// path.
let hdr_lighting = self.native.is_none()
&& self.pack_post.as_ref().is_none_or(|p| p.1.is_none())
&& std::env::var_os("HORIZON_HDR_LIGHTING").is_none_or(|v| v != "0");
if let Some(hdr) = &mut self.hdr {
hdr.lighting = hdr_lighting;
}
let oit_on = self.improved_transparency
&& !frame.title
&& self.pack_post.as_ref().is_none_or(|p| p.1.is_none())
@@ -6346,6 +6493,7 @@ impl Renderer {
+ translucent_particle_quads
+ translucent_item_quads
+ oit_entity_quads;
laps.lap("r_draw_lists");
// Where the tail of materials the classic and the improved passes
// both draw ends: the shadows, particles and items count back from it.
let base_quads = world_quads - oit_entity_quads;
@@ -6491,6 +6639,7 @@ impl Renderer {
bytemuck::bytes_of(&uniform),
);
}
laps.lap("r_materials");
// Every upload before the frame's first pass: a pass ends wgpu-core's
// command buffer, so copies between passes would need one more.
if !frame.screen.is_empty() {
@@ -6533,7 +6682,8 @@ impl Renderer {
let flat_frame = match &mut self.flat {
Some(flat)
if self.flat_on
&& !self.draws.is_empty()
&& (!self.draws.is_empty()
|| self.gpu_lists.as_ref().is_some_and(|l| l.listed() > 0))
&& (self.drawn_sections >= flat.min_sections
|| pack_terrain
|| shadow_view.is_some()) =>
@@ -6572,6 +6722,8 @@ impl Renderer {
Some((id, lod::instance(v.origin, v.fade)))
})),
|page| sorted.buffer(page).clone(),
self.gpu_lists.as_mut().filter(|l| l.listed() > 0),
eye.map(|e| e.to_array()),
self.own_timing.as_mut(),
)
})
@@ -6579,7 +6731,8 @@ impl Renderer {
_ => false,
};
if flat_frame {
self.section_draw_calls = self.flat.as_ref().map_or(0, |f| f.shadow_calls[0]);
self.section_draw_calls = self.flat.as_ref().map_or(0, |f| f.shadow_calls[0])
+ self.gpu_lists.as_ref().map_or(0, |l| l.calls().len());
} else if self.multi_draw && !self.draws.is_empty() {
if self.draws.len() as u64 > self.indirect_capacity {
self.indirect_capacity = (self.draws.len() as u64).next_power_of_two();
@@ -6617,6 +6770,7 @@ impl Renderer {
.flatten();
}
let pack_reads_world = pack_post || native_post;
laps.lap("r_uploads");
// Phase 2 of occlusion culling draws over the depth, and a shader pack or TAA reads it.
let keep_depth = pack_reads_world
|| (taa && self.world_post.taa)
@@ -6748,6 +6902,7 @@ impl Renderer {
}
};
let sky_visible = sky_open(frame);
laps.lap("r_world_pass");
// One of Horizon's own packs: this frame's inputs; in a timed frame its sky draws in a
// pass of its own, so that it has a time of its own.
let mut native_sky_pass = false;
@@ -6886,6 +7041,7 @@ impl Renderer {
pass.set_bind_group(0, &self.group, &[]);
sky.draw(&mut pass, &native.frame);
}
laps.lap("r_pack_inputs");
// The water mirror, for one of Horizon's own packs that reads it: the world as a
// camera mirrored about the water plane sees it, into a half-resolution target the
// material stage samples as `reflection`.
@@ -7023,6 +7179,7 @@ impl Renderer {
};
native.frame.fit_surface(&self.device, size);
}
laps.lap("r_mirror");
// One of Horizon's own packs with a material stage: terrain draws with its pipelines,
// which read the pack's frame at group 3.
let material = self
@@ -7176,19 +7333,26 @@ impl Renderer {
// Without a pack, on Metal, the opaque pass, then the cutout one
// (terrain.wgsl's `opaque_quad`), each over phase 1's lists with
// quads for it, and over all of phase 2's.
// Each pass's GPU-made lists by group, too (`flat::Flat::list`).
let [opaque, cutout] = flat.pass_calls;
let passes: &[(&wgpu::RenderPipeline, std::ops::Range<usize>)] = match pack_flat
{
Some(p) => &[(&p.0, 0..flat.phase1_calls)],
type Passes<'p> =
&'p [(&'p wgpu::RenderPipeline, std::ops::Range<usize>, &'p [u8])];
let passes: Passes = match pack_flat {
Some(p) => &[(&p.0, 0..flat.phase1_calls, &[0, 1])],
None if split_solid => &[
(&flat.solid_opaque, 0..opaque),
(&flat.solid_cutout, cutout..flat.phase1_calls),
(&flat.solid_opaque, 0..opaque, &[0]),
(&flat.solid_cutout, cutout..flat.phase1_calls, &[1]),
],
None => &[(&flat.solid, 0..flat.phase1_calls)],
None => &[(&flat.solid, 0..flat.phase1_calls, &[0, 1])],
};
let draw = |pass: &mut wgpu::RenderPass<'_>| {
for (pipeline, calls) in passes {
for (pipeline, calls, groups) in passes {
pass.set_pipeline(pipeline);
if let Some(lists) = &self.gpu_lists {
for &group in *groups {
flat.draw_lists(pass, lists, group, vertices);
}
}
flat.draw(pass, &flat.calls[calls.clone()], vertices);
}
};
@@ -7218,7 +7382,7 @@ impl Renderer {
};
pass.set_bind_group(0, &self.group, &[]);
pack_frame(&mut pass);
for (pipeline, _) in passes {
for (pipeline, ..) in passes {
pass.set_pipeline(pipeline);
flat.draw_phase2(&mut pass, vertices);
}
@@ -8013,6 +8177,7 @@ impl Renderer {
(&view_target, self.config.format.remove_srgb_suffix()),
);
}
laps.lap("r_passes");
// Under `HORIZON_FRAMEGEN_DEBUG=generated` a screenshot is the made frame.
let shown_texture = match &fg_frames {
Some((_, generated)) if fg_made && frame_gen::generated_only() => generated,
@@ -8080,6 +8245,8 @@ impl Renderer {
match self.queue.submit_later(job) {
Ok(()) => {
self.deferred = Some(offscreen);
// The frame's waits for the previous one on the submit thread.
self.last_wait += self.queue.take_waited();
self.last_encode = started.elapsed().saturating_sub(self.last_wait);
self.visible = visible;
self.visible_lods = visible_lods;
@@ -8144,6 +8311,8 @@ impl Renderer {
);
}
}
// The frame's own waits for the submit thread (its queue writes and passes).
self.last_wait += self.queue.take_waited();
let present = Instant::now();
if let Some(surface_frame) = surface_frame {
// On Wayland this ties RedrawRequested to the compositor's frame
@@ -8176,6 +8345,8 @@ impl Renderer {
}
}
self.last_wait += present.elapsed();
// Inside the present's time already.
self.queue.take_waited();
self.last_encode = started.elapsed().saturating_sub(self.last_wait);
let _ = self.started;
self.visible = visible;
@@ -8863,6 +9034,12 @@ fn projection_center(view_proj: Mat4) -> Option<Vec3> {
fn facing_runs(sides: &Sides, eye: Option<Vec3>, origin: [f32; 3], draw: impl FnMut(u32, u32)) {
facing_runs_from(sides, eye, None, origin, Quads::All, draw)
}
/// Whether phase 1's solid lists may be made on the GPU (`flat::Flat::list`);
/// `HORIZON_GPU_LISTS=0` keeps them on the CPU.
fn gpu_lists_on() -> bool {
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| std::env::var("HORIZON_GPU_LISTS").as_deref() != Ok("0"))
}
/// `facing_runs` for the eye and, with `low`, every eye straight below it down to that
/// camera-relative height (the water mirror's eye).
fn facing_runs_from(
@@ -9055,6 +9232,14 @@ mod tests {
assert!(!drawable_size(984, u32::MAX, 32768));
}
#[test]
fn a_reported_black_level_rounds_down() {
use super::hundredths_of_a_nit as h;
assert_eq!(h(0.005), 0);
assert_eq!(h(0.05), 5);
assert_eq!(h(5.0), 100);
assert_eq!(h(f32::NAN), 0);
}
#[test]
fn os_luminances_become_auto_values() {
use super::os_reported;
@@ -9072,14 +9257,16 @@ mod tests {
os_reported(&info(Some(998.4), Some(240.0))),
Some(Reported {
paper_white: 240,
peak: 998
peak: 998,
black: 0
})
);
assert_eq!(
os_reported(&info(Some(120.0), Some(1000.0))),
Some(Reported {
paper_white: 480,
peak: 480
peak: 480,
black: 0
})
);
// White above the calibrated peak (a Samsung QD-OLED's 380, the slider at 456).
@@ -9087,7 +9274,8 @@ mod tests {
os_reported(&info(Some(380.0), Some(456.0))),
Some(Reported {
paper_white: 456,
peak: 456
peak: 456,
black: 0
})
);
assert_eq!(os_reported(&info(Some(1000.0), None)), None);
@@ -9870,6 +10058,15 @@ fn flush_display_log() {
(log.last, log.at) = (line, Some(now));
}
}
/// A reported black level, within the setting's range. Rounded down: OLEDs report a figure
/// just above 0, like KWin's 0.005, which isn't worth lifting the shadows for.
fn hundredths_of_a_nit(nits: f32) -> u32 {
if nits.is_finite() && nits > 0.0 {
((nits * 100.0) as u32).min(100)
} else {
0
}
}
fn log_os_reported(reported: Option<crate::graphics::Reported>) {
log_display(match reported {
Some(r) => format!(
@@ -9892,6 +10089,7 @@ fn os_reported(info: &wgpu::DisplayHdrInfo) -> Option<crate::graphics::Reported>
let paper_white = (white.round() as u32).clamp(80, 480);
Some(crate::graphics::Reported {
paper_white,
black: hundredths_of_a_nit(luminance.min_nits.unwrap_or(0.0)),
// Windows' SDR brightness slider can set white above the reported peak (480 against
// a calibrated 380): the highlights then stay at white instead of dropping below it.
peak: (peak.round() as u32).clamp(200, 10_000).max(paper_white),
@@ -9917,5 +10115,6 @@ fn reported(l: horizon_hdr::wayland::Luminances) -> crate::graphics::Reported {
crate::graphics::Reported {
paper_white: 203,
peak: peak.clamp(200, 10_000),
black: hundredths_of_a_nit(l.target_min * 203.0 / l.reference.max(1.0)),
}
}
+86 -9
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).
@@ -545,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)) {
@@ -562,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> {
@@ -587,6 +644,24 @@ 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())
}
@@ -761,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([]),
@@ -1044,6 +1120,7 @@ mod tests {
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([]),
+42 -2
View File
@@ -223,6 +223,7 @@ fn buffer(
/// from `shadow[0]` (solid) and `shadow[1]` (translucent) on are a shader
/// 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]],
@@ -235,6 +236,7 @@ fn plan(
calls: &mut Vec<Call>,
ranges: &mut Vec<Range>,
segments: &mut Vec<(Source, u64, u64)>,
base: u64,
) -> Plan {
calls.clear();
ranges.clear();
@@ -242,7 +244,7 @@ fn plan(
let (mut phase1_calls, mut solid_calls) = (None, None);
let mut shadow_calls = [None; 2];
let mut pass_calls = [None; 2];
let mut total = 0u64;
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 {
@@ -540,6 +542,8 @@ impl Flat {
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;
@@ -577,6 +581,8 @@ 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,
@@ -594,6 +600,7 @@ impl Flat {
&mut self.calls,
&mut self.range_data,
&mut self.segments,
room,
);
self.phase1_calls = phase1_calls;
self.solid_calls = solid_calls;
@@ -656,6 +663,9 @@ impl Flat {
bytemuck::cast_slice(&self.phase2_call_data),
);
}
if let Some(lists) = &mut lists {
lists.prepare(device, encoder, uploads, &self.indices, eye);
}
let ranges_at = |first: u64, count: u64| {
wgpu::BindingResource::Buffer(wgpu::BufferBinding {
buffer: &self.ranges,
@@ -725,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");
@@ -772,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]]
@@ -897,6 +932,7 @@ mod tests {
&mut calls,
&mut ranges,
&mut segments,
0,
);
assert_eq!(total, 6 * 13);
assert_eq!(phase1_calls, solid_calls);
@@ -945,6 +981,7 @@ mod tests {
&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 };
@@ -980,6 +1017,7 @@ mod tests {
&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 };
@@ -1008,6 +1046,7 @@ mod tests {
&mut calls,
&mut ranges,
&mut segments,
0,
);
assert_eq!(
plan,
@@ -1125,6 +1164,7 @@ mod tests {
&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.
@@ -0,0 +1,639 @@
//! 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 their indices, per call (one per pass
//! group and arena page), into the flat draws' index list, drawn with
//! `draw_indexed_indirect`. Calls take room for all the quads of their sections'
//! parts; the GPU counts the indices it 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;
/// Most workgroups along one dimension of a dispatch.
const GROUP_LIMIT: u32 = 65535;
/// 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
/// index `first` 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 {
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; 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`; the GPU adds what it writes to the count.
call_buffer: wgpu::Buffer,
call_capacity: u64,
view: wgpu::Buffer,
group: Option<(wgpu::BindGroup, u32)>,
/// The indices the frame's calls take, 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 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) -> 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("flatten_sides"),
compilation_options: Default::default(),
cache: None,
});
let storage = wgpu::BufferUsages::STORAGE;
let indirect = wgpu::BufferUsages::INDIRECT | storage;
Self {
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,
),
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 the binding they share with the CPU's draws.
self.cap = device.limits().max_storage_buffer_binding_size / 16;
}
/// Section `id`, in `page`, at `origin` from the camera and `distance`
/// (squared, as bits) from it: 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],
) -> bool {
let parts = &parts[..parts.len().min(2)];
let mut counts = [0; 2];
for (count, &(_, part)) in counts.iter_mut().zip(parts) {
*count = sides.part_quads(part) * 6;
}
let room = u64::from(counts[0]) + u64::from(counts[1]);
if self.room + room > self.cap {
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, 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 of the
/// index list: the indices they take.
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`.
/// After `rooms`.
pub fn prepare(
&mut self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
uploads: &mut super::upload::Uploads,
indices: &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 [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 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: indices.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 = *records;
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, group, &[]);
pass.dispatch_workgroups(groups.min(GROUP_LIMIT), groups.div_ceil(GROUP_LIMIT), 1);
}
/// Whether `dispatch` writes this frame's lists.
pub fn prepared(&self) -> bool {
self.group.is_some()
}
/// Draws call `k` 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);
}
}
#[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` from each of a few eyes; per eye, the lists, the
/// calls' words and the indices written.
fn run(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);
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();
assert!(lists.list(id as u32, s.page, distance, &s.sides, s.parts, s.origin));
}
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 indices = init(&vec![0xffu8; room 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, &indices, eye);
uploads.finish(&encoder);
{
let mut pass = encoder.begin_compute_pass(&Default::default());
lists.dispatch(&mut pass);
}
queue.submit([encoder.finish()]);
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(&indices, room);
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);
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]));
}
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(&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:?}");
}
}
}
}
@@ -0,0 +1,178 @@
// 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, in the flat draws' index list, as `flatten_seen`
// does for occlusion culling's phase 2. 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.
// 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 `DrawIndexedIndirectArgs`: its index count starts at 0, its first
// index is where its room starts.
@group(0) @binding(3) var<storage, read_write> calls: array<atomic<u32>>;
// The flat draws' index list.
@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;
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;
}
}
}
@@ -149,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>,
}
@@ -293,6 +296,7 @@ impl Hdr {
tiers: TIERS,
ui: 1.0,
masks: MASKS,
lighting: true,
calibration: None,
}
}
@@ -539,6 +543,11 @@ 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;
}
@@ -618,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};
@@ -670,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);
}
}
@@ -39,6 +39,8 @@ 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 {
@@ -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;
}
@@ -145,7 +149,8 @@ fn horizon_material_color(in: HorizonMaterialOut, translucent: bool) -> vec4<f32
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;
}
var<private> horizon_material_face_normal: vec3<f32>;
struct HorizonMaterialSolid {
@@ -154,15 +159,33 @@ struct HorizonMaterialSolid {
// 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; }
if horizon_surface.w == 0.0 {
set_surface(horizon_material_face_normal, 0.0, 0.0);
}
var out: HorizonMaterialSolid;
out.color = horizon_material_fogged(vec4(color.rgb, 1.0), in.spherical, in.cylindrical);
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;
}
@@ -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>,
}
@@ -133,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,
@@ -6,7 +6,23 @@
use super::Vertex;
use super::pack_draw::EntitySpan;
use glam::{DVec3, Mat4};
use std::sync::atomic::{AtomicBool, Ordering};
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);
@@ -18,12 +34,16 @@ pub fn set_mods_built_in(on: bool) {
/// 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,
@@ -59,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.
@@ -70,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`]).
@@ -85,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);
}
}
@@ -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,6 +511,7 @@ 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`.
@@ -1341,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));
}
@@ -1433,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 {
@@ -1893,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))
@@ -1910,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())
@@ -2243,6 +2278,7 @@ impl PackPost {
draws,
block_ids,
page_groups: HashMap::new(),
gui_size: world.gui_size,
timing: None,
center_depth: None,
custom,
@@ -2868,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();
@@ -2884,6 +2921,7 @@ impl PackPost {
entities,
spans,
drawn,
(crumbling, self.gui_size),
shadow,
rest,
);
@@ -404,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.
@@ -436,6 +436,10 @@ 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);
}
@@ -849,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(),
}
}
}
@@ -29,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>,
@@ -58,7 +63,7 @@ impl PageRuns {
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: u64::from(group) << GROUP_SHIFT | band << 32 | u64::from(page),
start: start as u32,
@@ -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);
}
}
+15 -1
View File
@@ -7,8 +7,9 @@
//! ([`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, Ordering};
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
@@ -50,6 +51,9 @@ pub struct Queue {
/// 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 {
@@ -60,6 +64,7 @@ impl Queue {
thread: None,
pending: AtomicBool::new(false),
landing: Mutex::new(None),
waited: AtomicU64::new(0),
};
if !threaded {
return this;
@@ -130,15 +135,24 @@ impl Queue {
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> {
@@ -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;
@@ -322,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 {
@@ -403,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.
+23 -6
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.
@@ -582,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.
@@ -830,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 {
+118 -13
View File
@@ -606,24 +606,21 @@ 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::translate("Shortcut: %s", &[crate::keybinds::display_name(&g.hdr_key)])
@@ -2229,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}"
);
}
}
}
+20 -3
View File
@@ -20,6 +20,8 @@ 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
// its fully lifted pixels get.
share: vec4<f32>,
@@ -53,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.
@@ -168,8 +170,23 @@ fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
// 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
&& (hand || (!covered && shows_world(c, w.rgb))) {
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);
@@ -49,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,
}
@@ -125,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),
],
@@ -146,15 +147,11 @@ impl Calibration {
fn label(&self) -> Text {
let v = self.values[self.step];
let step = self.current();
let nits = match step {
let value = match step {
Step::Black => format!("{v:.2}"),
_ => format!("{v:.0}"),
};
Text::literal(format!(
"{}: {}",
step.name(),
lang::tr("%s nits", &[&nits])
))
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.
@@ -284,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;
}
+118 -24
View File
@@ -10,6 +10,7 @@ use std::{
};
use wgpu::util::DeviceExt;
pub mod cit_glint;
mod pack_text;
/// A sprite's atlas region, and its size in GUI pixels: the size of the
@@ -247,16 +248,23 @@ fn wrap_border((name, w, h, data): &PackedImage) -> PackedImage {
}
(name.clone(), bw, bh, out)
}
/// The textures the enabled packs' Custom Item Textures item rules draw, under their
/// `cit_assets` ids, beyond those already packed; with their flags and whether they animate.
/// They are item textures, so an animated strip keeps its first frame. None without
/// `optifine/cit` rules.
/// The textures the enabled packs' Custom Item Textures rules draw, beyond those already packed:
/// item rules' textures and their JSON models', armour and elytra rules' textures, under their
/// `cit_assets` ids, with their flags and whether they animate. An animated strip keeps its
/// first frame; a still texture is packed whole. None without `optifine/cit` rules.
fn cit_sprites(
root: &Path,
packed: &[PackedImage],
) -> Vec<(PackedImage, Option<TextureFlags>, bool)> {
let have: HashSet<&str> = packed.iter().map(|i| i.0.as_str()).collect();
let ids: Vec<String> = crate::cit_assets::item_sprites(&crate::cit::load_rules(root))
let rules = crate::cit::load_rules(root);
let mut ids = crate::cit_assets::item_sprites(&rules);
if !rules.is_empty() {
let models = crate::cit_assets::load_models(root, &rules);
ids.extend(crate::cit_assets::model_sprites(&models));
ids.extend(crate::cit_armor::armor_sprites(&rules));
}
let ids: Vec<String> = ids
.into_iter()
// Vanilla's sprites are packed without their namespace, as `sprite_name` looks them up;
// Horizon's own (`horizon/white`, the run-time slots) are added after, and stay its own.
@@ -664,25 +672,21 @@ impl HudAtlas {
/// packs fill a 4096-wide atlas past what GPUs allow (Faithful 64x needs
/// 32768 rows); vanilla's stitcher fails the reload when its atlases don't
/// fit, and the packs are then turned off (`Minecraft.clearResourcePacksOnError`).
/// A width where the plain shelves (each image after the last) leave too
/// much unused is tried again with each image in the first shelf with
/// room: a pack of thousands of mixed sizes (server packs with custom
/// items) fits where the shelves' ragged ends would not.
fn layout(images: &[PackedImage], max: u32) -> Result<(u32, Vec<(u32, u32)>, u32)> {
let mut width = 4096.min(max);
loop {
let mut placements = Vec::with_capacity(images.len());
let (mut x, mut y, mut row) = (0u32, 0u32, 0u32);
for (_, w, h, _) in images {
if x + w + 1 > width {
x = 0;
y += row + 1;
row = 0;
}
placements.push((x, y));
x += w + 1;
row = row.max(*h);
}
let glyph_region = y + row + 1;
let wide_enough = images.iter().all(|i| i.1 < width);
if wide_enough && glyph_region + GLYPH_ROWS <= max {
return Ok((width, placements, glyph_region));
if wide_enough {
for first_fit in [false, true] {
let (placements, glyph_region) = Self::shelves(images, width, first_fit);
if glyph_region + GLYPH_ROWS <= max {
return Ok((width, placements, glyph_region));
}
}
}
if width >= max {
let texels: u64 = images.iter().map(|i| u64::from(i.1) * u64::from(i.2)).sum();
@@ -695,6 +699,33 @@ impl HudAtlas {
width = (width * 2).min(max);
}
}
/// `layout`'s shelves at one width: the placements and the first row below
/// them. `first_fit` puts an image in the first shelf that is tall enough
/// and has room, instead of only the last one.
fn shelves(images: &[PackedImage], width: u32, first_fit: bool) -> (Vec<(u32, u32)>, u32) {
// Each shelf: its top row, its height (its first image's) and the next free column.
let mut shelves: Vec<(u32, u32, u32)> = Vec::new();
let mut placements = Vec::with_capacity(images.len());
for (_, w, h, _) in images {
let open = if first_fit {
shelves.iter().position(|s| s.1 >= *h && s.2 + w < width)
} else {
shelves
.len()
.checked_sub(1)
.filter(|&i| shelves[i].1 >= *h && shelves[i].2 + w < width)
};
let i = open.unwrap_or_else(|| {
let top = shelves.last().map_or(0, |s| s.0 + s.1 + 1);
shelves.push((top, *h, 0));
shelves.len() - 1
});
placements.push((shelves[i].2, shelves[i].0));
shelves[i].2 += w + 1;
}
let glyph_region = shelves.last().map_or(1, |s| s.0 + s.1 + 1);
(placements, glyph_region)
}
/// Pack the textures under these folders of `textures/`, and for the
/// `full` atlas the generated sprites and the run-time slots too.
fn pack(root: &Path, defaults: &Path, folders: &[&str], full: bool) -> Result<Self> {
@@ -1155,6 +1186,9 @@ pub enum Blend {
/// `RenderPipelines.END_PORTAL` as a GUI fill: the End portal's layers
/// projected from the screen (`HudBatch::end_portal` sets the textures).
EndPortal,
/// An OptiFine CIT glint layer (`cit_glint`), blended as entry `n` of
/// `GlintTextures::blends` says.
CitGlint(u8),
}
/// What a `Blend::EndPortal` fill samples: the atlas rects (u0, v0, width,
/// height) of `end_sky.png` and `end_portal.png`, and the `GameTime` uniform.
@@ -1889,7 +1923,11 @@ pub struct HudRenderer {
depth_pipeline: wgpu::RenderPipeline,
vignette_pipeline: wgpu::RenderPipeline,
end_portal_pipeline: wgpu::RenderPipeline,
/// The layout of `group`, which the CIT glint pipelines share.
layout: wgpu::BindGroupLayout,
group: wgpu::BindGroup,
/// The pack's CIT enchantment glints, when it has any.
cit_glint: Option<cit_glint::CitGlintPass>,
uniform: wgpu::Buffer,
buffer: wgpu::Buffer,
capacity: u64,
@@ -2122,7 +2160,9 @@ impl HudRenderer {
depth_pipeline,
vignette_pipeline,
end_portal_pipeline,
layout,
group,
cit_glint: None,
uniform,
buffer,
capacity,
@@ -2132,6 +2172,18 @@ impl HudRenderer {
zoom: 1.0,
})
}
/// Draw the CIT glint layers of `textures` (the pack's enchantment rules); `None` or no
/// textures draws none.
pub fn set_cit_glints(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
textures: Option<&cit_glint::GlintTextures>,
) {
self.cit_glint = textures.and_then(|t| {
cit_glint::CitGlintPass::new(device, queue, self.format, &self.layout, t)
});
}
/// Draw the GUI's text with `shader` (`HudAtlas::text_shader`), or natively.
pub fn set_text_shader(
&mut self,
@@ -2367,6 +2419,12 @@ impl HudRenderer {
Blend::Depth(_) => &self.depth_pipeline,
Blend::Vignette => &self.vignette_pipeline,
Blend::EndPortal => &self.end_portal_pipeline,
Blend::CitGlint(blend) => {
if let Some(cit) = &self.cit_glint {
cit.draw(&mut pass, *blend, range.clone());
}
continue;
}
};
match &self.pack_text {
Some(pack_text) if *blend == Blend::Alpha => {
@@ -2827,6 +2885,25 @@ mod tests {
assert_eq!(texel_at(&atlas, rect, 1, 1), [10, 200, 30, 255]);
}
#[test]
fn a_cit_models_relative_texture_is_packed() {
let root = cit_root(
"model",
&[
("a.properties", "items=stick\nmodel=sword"),
(
"sword.json",
r#"{"parent":"item/generated","textures":{"layer0":"./blade"}}"#,
),
],
);
let cit = root.join("assets/minecraft/optifine/cit");
write_strip(&cit.join("blade.png"), 2, 2, &[[40, 50, 60, 255]], None);
let atlas = HudAtlas::build(&root).unwrap();
let _ = fs::remove_dir_all(&root);
let rect = atlas.sprites["horizon_cit:minecraft/optifine/cit/blade"];
assert_eq!(texel_at(&atlas, rect, 1, 1), [40, 50, 60, 255]);
}
#[test]
fn a_still_cit_texture_is_packed_whole() {
// Not cut to a square as an unmarked strip would be: armour layers are 2:1.
let root = cit_root("still", &[("a.properties", "items=stick\ntexture=a")]);
@@ -2862,7 +2939,7 @@ mod tests {
assert_eq!(animations[0].1.frame_count(), 3);
}
#[test]
fn a_texture_only_an_armor_rule_names_is_not_packed() {
fn an_armor_rules_texture_is_packed_too() {
let root = cit_root(
"armor",
&[(
@@ -2872,11 +2949,15 @@ mod tests {
);
let cit = root.join("assets/minecraft/optifine/cit");
write_strip(&cit.join("a.png"), 64, 32, &[[1, 2, 3, 255]], None);
// The rule loads; only its kind keeps the texture out.
assert_eq!(crate::cit::load_rules(&root).len(), 1);
let rules = crate::cit::load_rules(&root);
assert_eq!(rules.len(), 1);
let atlas = HudAtlas::build(&root).unwrap();
let _ = fs::remove_dir_all(&root);
assert!(!atlas.sprites.keys().any(|k| k.starts_with("horizon_cit:")));
// Worn armour draws from the same atlas, under the name the layer looks up.
let rect = atlas.sprites["horizon_cit:minecraft/optifine/cit/a"];
assert_eq!(rect.texels((atlas.width, atlas.height)), (64, 32));
let layer = crate::cit_armor::layer_sprite(&rules[0], "minecraft:iron", false, false);
assert!(atlas.sprites.contains_key(&layer.unwrap()));
}
#[test]
fn named_cit_textures_pack_once_and_bad_ones_are_skipped() {
@@ -3104,6 +3185,19 @@ texture=wide",
assert_inside(&images, width, &placements, free);
}
#[test]
fn mixed_sizes_fill_the_ends_of_the_shelves() {
// 15 images 9000 wide leave 7383 of a 16384 shelf unused each; the
// 4000-wide ones (same height) come after them, and on shelves of their
// own (4 to a shelf) the atlas is 19 shelves tall: past 16384 rows.
// Each in a gap, it is 15.
let mut images = sized(15, 9000, 1000);
images.extend(sized(15, 4000, 1000));
let (width, placements, free) = HudAtlas::layout(&images, 16384).unwrap();
assert_eq!(width, 16384);
assert!(free + GLYPH_ROWS <= 16384);
assert_inside(&images, width, &placements, free);
}
#[test]
fn textures_that_cannot_fit_are_an_error_not_a_gpu_panic() {
let error = HudAtlas::layout(&sized(300, 1024, 1024), MAX_ATLAS_SIZE).unwrap_err();
assert!(error.to_string().contains("do not fit a 16384x16384 atlas"));
+29
View File
@@ -41,6 +41,35 @@ struct Out {
if c.a < 0.004 { discard; }
return c;
}
// OptiFine CIT glint layers (hud/cit_glint.rs): every rule's texture in one array, the slot in
// the vertex colour's red and the layer's intensity in its alpha. Masked by the item's texture
// as the vanilla glint is, then blended by the pipeline the rule's `blend` asks for.
struct CitSlot {
rgba: vec4<f32>,
// x: linear filtering (`blur`).
blur: vec4<f32>,
}
struct CitSlots {
slots: array<CitSlot, 256>,
}
@group(1) @binding(0) var cit_tex: texture_2d_array<f32>;
@group(1) @binding(1) var cit_blur: sampler;
@group(1) @binding(2) var cit_nearest: sampler;
@group(1) @binding(3) var<uniform> cit: CitSlots;
@fragment fn fs_cit_glint(in: Out) -> @location(0) vec4<f32> {
let mask = textureSample(atlas, atlas_sampler, in.uv).a;
let index = u32(round(in.color.r * 255.0));
let slot = cit.slots[index];
let blurred = textureSampleLevel(cit_tex, cit_blur, in.glint_uv, index, 0.0);
let sharp = textureSampleLevel(cit_tex, cit_nearest, in.glint_uv, index, 0.0);
if mask < 0.1 { discard; }
// The layer's colour times its intensity (the glint strength option included) in alpha,
// cut where vanilla's glint shader cuts, which CIT Resewn draws through.
var t = select(sharp, blurred, slot.blur.x > 0.5) * slot.rgba;
t.a *= in.color.a;
if t.a < 0.1 { discard; }
return t;
}
@fragment fn fs_crosshair(in: Out) -> @location(0) vec4<f32> {
let c = textureSample(atlas, atlas_sampler, in.uv) * in.color;
if c.a == 0.0 { discard; }
@@ -0,0 +1,563 @@
//! OptiFine CIT enchantment glints on GUI items (`type=enchantment`, see `crate::cit_glint` for
//! which layers a stack draws): every rule's texture in one texture array, each layer an extra
//! pass over the item's quads with its rule's blend. A layer's vertices carry its texture slot
//! (red) and its intensity (alpha) in their colour; its scrolled UV is worked out on the CPU, as
//! vanilla's glint's is. Without enchantment rules nothing here is made.
use super::HudVertex;
use horizon_optifine::cit::{Blend, Factor, Kind, Rule};
use std::path::Path;
/// Slots the shader's table holds; rules past it draw no layer (and match no stack).
pub const MAX_SLOTS: usize = 256;
/// The largest side the shared texture size takes: bigger glints are scaled down to it.
const MAX_SIDE: u32 = 1024;
/// The most the texture array takes, in bytes: past it the shared size is halved.
const MAX_BYTES: u64 = 64 << 20;
/// One slot's constants.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Slot {
/// The rule's `r`, `g`, `b`, `a`.
pub rgba: [f32; 4],
/// Linear filtering (`blur`), else nearest.
pub blur: bool,
/// An index into `GlintTextures::blends`.
pub blend: u8,
}
/// The enchantment rules' textures, resized to one size.
#[derive(Debug, Default)]
pub struct GlintTextures {
pub width: u32,
pub height: u32,
/// One `width` x `height` RGBA image per slot, slot after slot. Kept after the upload: every
/// split-screen player's renderer builds its own array from it.
pub pixels: Vec<u8>,
/// Per rule (an index into `CitRules::rules`): its slot; `None` for other kinds of rules,
/// textures that did not load and rules past `MAX_SLOTS`.
pub slot_of: Vec<Option<u8>>,
pub slots: Vec<Slot>,
/// The blends the slots use, once each: the HUD makes a pipeline per entry.
pub blends: Vec<Option<Blend>>,
}
impl GlintTextures {
/// The textures of `rules` under the overlay resource root.
pub fn load(root: &Path, rules: &[Rule]) -> Self {
let mut images = Vec::new();
let mut past = 0;
for (i, rule) in rules.iter().enumerate() {
let Some(path) = rule.texture.as_deref() else {
continue;
};
if rule.kind != Kind::Enchantment {
continue;
}
if images.len() == MAX_SLOTS {
past += 1;
continue;
}
match super::decode(&root.join(path)) {
Ok(image) => images.push((i, image)),
Err(e) => log::warn!("custom item textures: glint {path} left out: {e}"),
}
}
if past > 0 {
log::warn!(
"custom item textures: {past} glint rules past the first {MAX_SLOTS} left out"
);
}
Self::new(rules, images, MAX_BYTES)
}
/// `images`: (rule, (width, height, RGBA)), in at most `budget` bytes.
fn new(rules: &[Rule], images: Vec<(usize, (u32, u32, Vec<u8>))>, budget: u64) -> Self {
let sizes = images.iter().map(|(_, (w, h, _))| (*w, *h));
let Some((width, height)) = shared_size(sizes, images.len(), budget) else {
return Self::default();
};
let mut out = Self {
width,
height,
slot_of: vec![None; rules.len()],
..Self::default()
};
for (rule, (w, h, rgba)) in images {
let glint = &rules[rule].glint;
out.pixels
.extend(resize(&rgba, (w, h), (width, height), glint.blur));
let blend = match out.blends.iter().position(|b| *b == glint.blend) {
Some(i) => i,
None => {
out.blends.push(glint.blend);
out.blends.len() - 1
}
};
out.slot_of[rule] = Some(out.slots.len() as u8);
out.slots.push(Slot {
rgba: glint.rgba,
blur: glint.blur,
blend: blend as u8,
});
}
out
}
pub fn is_empty(&self) -> bool {
self.slots.is_empty()
}
}
/// The size every slot takes: the largest width and height of `sizes`, each at most
/// `MAX_SIDE`, then halved until `layers` RGBA images of it fit in `budget` bytes.
fn shared_size(
sizes: impl Iterator<Item = (u32, u32)>,
layers: usize,
budget: u64,
) -> Option<(u32, u32)> {
let (mut w, mut h) = sizes.fold(None, |max: Option<(u32, u32)>, (w, h)| {
let (w, h) = (w.clamp(1, MAX_SIDE), h.clamp(1, MAX_SIDE));
Some(max.map_or((w, h), |(mw, mh)| (mw.max(w), mh.max(h))))
})?;
let bytes = |w: u32, h: u32| u64::from(w) * u64::from(h) * 4 * layers as u64;
while bytes(w, h) > budget && (w > 1 || h > 1) {
(w, h) = ((w / 2).max(1), (h / 2).max(1));
}
Some((w, h))
}
/// `rgba` (`from` texels) scaled to `to`: bilinearly for a `blur` glint, as the GPU would have
/// filtered the texture at its own size, else to the nearest texel. Both wrap at the edges, as
/// the glint repeats over the item.
fn resize(rgba: &[u8], from: (u32, u32), to: (u32, u32), smooth: bool) -> Vec<u8> {
if from == to {
return rgba.to_vec();
}
let ((w, h), (to_w, to_h)) = (from, to);
let texel = |x: i64, y: i64| {
let (x, y) = (x.rem_euclid(i64::from(w)), y.rem_euclid(i64::from(h)));
let at = (y as usize * w as usize + x as usize) * 4;
&rgba[at..at + 4]
};
// Where texel `i` of `n` samples the source's `size` texels, in source texels.
let centre = |i: u32, n: u32, size: u32| (f64::from(i) + 0.5) * f64::from(size) / f64::from(n);
let mut out = Vec::with_capacity(to_w as usize * to_h as usize * 4);
for y in 0..to_h {
let sy = centre(y, to_h, h);
for x in 0..to_w {
let sx = centre(x, to_w, w);
if !smooth {
let pick = |i: u32, n: u32, size: u32| {
(u64::from(i) * u64::from(size) / u64::from(n)) as i64
};
out.extend_from_slice(texel(pick(x, to_w, w), pick(y, to_h, h)));
continue;
}
let (fx, fy) = (sx - 0.5, sy - 0.5);
let (x0, y0) = (fx.floor() as i64, fy.floor() as i64);
let (tx, ty) = (fx - fx.floor(), fy - fy.floor());
let [a, b, c, d] = [
texel(x0, y0),
texel(x0 + 1, y0),
texel(x0, y0 + 1),
texel(x0 + 1, y0 + 1),
];
let lerp =
|p: &[u8], q: &[u8], k: usize| f64::from(p[k]) * (1.0 - tx) + f64::from(q[k]) * tx;
out.extend((0..4).map(|k| {
let (top, bottom) = (lerp(a, b, k), lerp(c, d, k));
(top * (1.0 - ty) + bottom * ty).round() as u8
}));
}
}
out
}
/// CIT Resewn's glint texturing for one layer: `translation(-x, y)`, `rotateZ(rotation)`, then
/// `scale`, where `x` and `y` run through vanilla's 110000 and 30000 periods of the clock
/// `millis * speed` (vanilla's own glint runs at `millis * glintSpeed * 8`). `rotation` is in
/// degrees, as OptiFine's doc has it.
pub fn layer_uv(model: [f32; 2], millis: f32, speed: f32, rotation: f32, scale: f32) -> [f32; 2] {
let clock = f64::from(millis) * f64::from(speed);
let x = (clock % 110_000.0 / 110_000.0) as f32;
let y = (clock % 30_000.0 / 30_000.0) as f32;
let (sin, cos) = rotation.to_radians().sin_cos();
let (u, v) = (model[0] * scale, model[1] * scale);
[u * cos - v * sin - x, u * sin + v * cos + y]
}
/// The colour of a layer's vertices: its slot in red, its intensity in alpha.
pub fn vertex_color(slot: u8, intensity: f32) -> u32 {
let alpha = (intensity.clamp(0.0, 1.0) * 255.0).round() as u8;
super::rgba(slot, 0, 0, alpha)
}
fn factor(f: Factor) -> wgpu::BlendFactor {
use wgpu::BlendFactor as W;
match f {
Factor::Zero => W::Zero,
Factor::One => W::One,
Factor::SrcColor => W::Src,
Factor::OneMinusSrcColor => W::OneMinusSrc,
Factor::SrcAlpha => W::SrcAlpha,
Factor::OneMinusSrcAlpha => W::OneMinusSrcAlpha,
Factor::DstAlpha => W::DstAlpha,
Factor::OneMinusDstAlpha => W::OneMinusDstAlpha,
Factor::DstColor => W::Dst,
Factor::OneMinusDstColor => W::OneMinusDst,
Factor::SrcAlphaSaturate => W::SrcAlphaSaturated,
}
}
/// `glBlendFuncSeparate` with the rule's factors; `replace` draws unblended.
pub fn blend_state(blend: Option<Blend>) -> wgpu::BlendState {
let Some(b) = blend else {
return wgpu::BlendState::REPLACE;
};
let component = |src, dst| wgpu::BlendComponent {
src_factor: factor(src),
dst_factor: factor(dst),
operation: wgpu::BlendOperation::Add,
};
wgpu::BlendState {
color: component(b.src, b.dst),
alpha: component(b.src_alpha, b.dst_alpha),
}
}
/// The GPU side: the texture array and slot table (bind group 1 of `fs_cit_glint`), and one
/// pipeline per blend.
pub(super) struct CitGlintPass {
group: wgpu::BindGroup,
pipelines: Vec<wgpu::RenderPipeline>,
}
impl CitGlintPass {
/// `None` without textures. `hud` is the HUD's bind group layout (group 0).
pub(super) fn new(
device: &wgpu::Device,
queue: &wgpu::Queue,
format: wgpu::TextureFormat,
hud: &wgpu::BindGroupLayout,
textures: &GlintTextures,
) -> Option<Self> {
use wgpu::util::DeviceExt;
if textures.is_empty() {
return None;
}
// wgpu's OpenGL backend makes 1 layer a 2D texture and 6n a cube map
// (`gfx::array_layers`): pad with empty layers, within the GPU's limit. Slots past the
// layers kept sample the last one.
let gl = device.adapter_info().backend == wgpu::Backend::Gl;
let max = device.limits().max_texture_array_layers.max(1);
let slots = textures.slots.len() as u32;
let mut kept = slots.min(max);
while kept > 1 && crate::gfx::array_layers(kept, gl) > max {
kept -= 1;
}
if kept < slots {
log::warn!("custom item textures: the GPU holds {kept} of {slots} glint textures");
}
let layers = crate::gfx::array_layers(kept, gl).min(max);
let layer_bytes = textures.width as usize * textures.height as usize * 4;
let mut data = std::borrow::Cow::Borrowed(&textures.pixels[..]);
if layers != slots {
let mut padded = textures.pixels[..layer_bytes * kept as usize].to_vec();
padded.resize(layer_bytes * layers as usize, 0);
data = std::borrow::Cow::Owned(padded);
}
let texture = device.create_texture_with_data(
queue,
&wgpu::TextureDescriptor {
label: Some("CIT glints"),
size: wgpu::Extent3d {
width: textures.width,
height: textures.height,
depth_or_array_layers: layers,
},
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,
&data,
);
let view = texture.create_view(&wgpu::TextureViewDescriptor {
dimension: Some(wgpu::TextureViewDimension::D2Array),
..Default::default()
});
let sampler = |label, filter| {
device.create_sampler(&wgpu::SamplerDescriptor {
label: Some(label),
address_mode_u: wgpu::AddressMode::Repeat,
address_mode_v: wgpu::AddressMode::Repeat,
mag_filter: filter,
min_filter: filter,
..Default::default()
})
};
let linear = sampler("CIT glint blur", wgpu::FilterMode::Linear);
let nearest = sampler("CIT glint", wgpu::FilterMode::Nearest);
// hud.wgsl's `CitSlot`: rgba, then blur in x.
let mut table = vec![[0.0f32; 8]; MAX_SLOTS];
for (row, slot) in table.iter_mut().zip(&textures.slots) {
row[..4].copy_from_slice(&slot.rgba);
row[4] = if slot.blur { 1.0 } else { 0.0 };
}
let table = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("CIT glint slots"),
contents: bytemuck::cast_slice(&table),
usage: wgpu::BufferUsages::UNIFORM,
});
let fragment = wgpu::ShaderStages::FRAGMENT;
let filtering = wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering);
let entry = |binding, ty| wgpu::BindGroupLayoutEntry {
binding,
visibility: fragment,
ty,
count: None,
};
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("CIT glint bindings"),
entries: &[
entry(
0,
wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2Array,
multisampled: false,
},
),
entry(1, filtering),
entry(2, filtering),
entry(
3,
wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
),
],
});
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("CIT glint resources"),
layout: &layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&view),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(&linear),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(&nearest),
},
wgpu::BindGroupEntry {
binding: 3,
resource: table.as_entire_binding(),
},
],
});
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Horizon HUD (CIT glints)"),
source: wgpu::ShaderSource::Wgsl(include_str!("../hud.wgsl").into()),
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("CIT glint layout"),
bind_group_layouts: &[Some(hud), Some(&layout)],
..Default::default()
});
let attributes =
wgpu::vertex_attr_array![0=>Float32x2,1=>Float32x2,2=>Unorm8x4,3=>Float32,4=>Float32x2];
let pipelines = textures
.blends
.iter()
.map(|&blend| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("CIT glint"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_main"),
compilation_options: Default::default(),
buffers: &[Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<HudVertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &attributes,
})],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_cit_glint"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format,
blend: Some(blend_state(blend)),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
})
})
.collect();
Some(Self { group, pipelines })
}
/// Draw `vertices` with blend `blend` (an index into `GlintTextures::blends`).
pub(super) fn draw(
&self,
pass: &mut wgpu::RenderPass<'_>,
blend: u8,
vertices: std::ops::Range<u32>,
) {
if let Some(pipeline) = self.pipelines.get(usize::from(blend)) {
pass.set_pipeline(pipeline);
pass.set_bind_group(1, &self.group, &[]);
pass.draw(vertices, 0..1);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_optifine::Properties;
use horizon_optifine::cit::parse_blend;
fn rule(name: &str, text: &str) -> Rule {
let path = format!("assets/minecraft/optifine/cit/{name}.properties");
let text = format!("texture=glint\n{text}");
Rule::parse(&path, &Properties::parse_equals_first(&text), |_| true).unwrap()
}
#[test]
fn slots_share_one_size_and_their_blends() {
let rules = [
rule("a", "type=enchantment\nr=0.5"),
rule("item", "items=stick"),
rule("b", "type=enchantment\nblend=multiply\nblur=false"),
rule("c", "type=enchantment"),
];
// A 1x1 red glint and a 2x2 one: the red texel fills 2x2.
let red = vec![255, 0, 0, 255];
let quad: Vec<u8> = (0..16).collect();
let t = GlintTextures::new(
&rules,
vec![
(0, (1, 1, red)),
(2, (2, 2, quad.clone())),
(3, (2, 2, quad.clone())),
],
MAX_BYTES,
);
assert_eq!((t.width, t.height), (2, 2));
assert_eq!(t.slot_of, [Some(0), None, Some(1), Some(2)]);
assert_eq!(&t.pixels[..16], [255, 0, 0, 255].repeat(4));
assert_eq!(&t.pixels[16..32], &quad[..]);
assert_eq!(
t.blends,
[
parse_blend("add").unwrap(),
parse_blend("multiply").unwrap()
]
);
let blends: Vec<_> = t.slots.iter().map(|s| s.blend).collect();
assert_eq!(blends, [0, 1, 0]);
assert_eq!(t.slots[0].rgba, [0.5, 1.0, 1.0, 1.0]);
assert!(!t.slots[1].blur && t.slots[2].blur);
assert!(GlintTextures::new(&rules, Vec::new(), MAX_BYTES).is_empty());
}
#[test]
fn big_glints_are_scaled_down_to_the_largest_side() {
let rules = [rule("wide", "type=enchantment\nblur=false")];
// 2048 x 1, texel x holding x in red and green: every other texel is kept.
let wide: Vec<u8> = (0..2048u32)
.flat_map(|x| [(x % 256) as u8, (x / 256) as u8, 0, 255])
.collect();
let t = GlintTextures::new(&rules, vec![(0, (2048, 1, wide))], MAX_BYTES);
assert_eq!((t.width, t.height), (1024, 1));
for x in [0u32, 1, 300, 1023] {
let at = x as usize * 4;
let source = 2 * x;
let want = [(source % 256) as u8, (source / 256) as u8, 0, 255];
assert_eq!(t.pixels[at..at + 4], want, "texel {x}");
}
}
#[test]
fn the_array_is_halved_until_it_fits() {
let sizes = |n: usize| std::iter::repeat_n((1024, 1024), n);
// 16 layers of 1024 x 1024 RGBA are 64 MiB; a 17th halves both sides.
assert_eq!(shared_size(sizes(16), 16, 64 << 20), Some((1024, 1024)));
assert_eq!(shared_size(sizes(17), 17, 64 << 20), Some((512, 512)));
// The largest of each side, never past MAX_SIDE.
let mixed = [(16, 4096), (64, 32)].into_iter();
assert_eq!(shared_size(mixed, 2, 64 << 20), Some((64, 1024)));
assert_eq!(shared_size(std::iter::empty(), 0, 64 << 20), None);
}
#[test]
fn blurred_glints_grow_smoothly_and_sharp_ones_by_texel() {
// Black then white, doubled: the wrapping blend of the two, or each texel twice.
let row = [0, 0, 0, 255, 255, 255, 255, 255];
let red = |rgba: Vec<u8>| rgba.chunks(4).map(|t| t[0]).collect::<Vec<_>>();
assert_eq!(red(resize(&row, (2, 1), (4, 1), true)), [64, 64, 191, 191]);
assert_eq!(red(resize(&row, (2, 1), (4, 1), false)), [0, 0, 255, 255]);
}
#[test]
fn a_layer_vertex_carries_its_slot_and_intensity() {
// Unorm8x4 reads the colour's bytes in memory order: r, g, b, a.
assert_eq!(vertex_color(3, 0.5).to_le_bytes(), [3, 0, 0, 128]);
assert_eq!(vertex_color(200, 2.0).to_le_bytes(), [200, 0, 0, 255]);
assert_eq!(vertex_color(0, -1.0).to_le_bytes(), [0, 0, 0, 0]);
}
#[test]
fn a_still_layer_is_scaled_and_turned() {
let close =
|a: [f32; 2], b: [f32; 2]| (a[0] - b[0]).abs() < 1e-5 && (a[1] - b[1]).abs() < 1e-5;
// No speed: no scroll whatever the clock.
assert!(close(
layer_uv([0.25, 0.5], 12_345.0, 0.0, 0.0, 8.0),
[2.0, 4.0]
));
// A quarter turn takes (1, 0) to (0, 1).
assert!(close(layer_uv([1.0, 0.0], 0.0, 0.0, 90.0, 1.0), [0.0, 1.0]));
// 1500 ms at speed 2: 3000 / 110000 back, 3000 / 30000 up.
assert!(close(
layer_uv([0.0, 0.0], 1500.0, 2.0, 0.0, 8.0),
[-3.0 / 110.0, 0.1]
));
}
#[test]
fn named_blends_take_their_gl_factors() {
let multiply = blend_state(parse_blend("multiply").unwrap());
assert_eq!(multiply.color.src_factor, wgpu::BlendFactor::Dst);
assert_eq!(
multiply.color.dst_factor,
wgpu::BlendFactor::OneMinusSrcAlpha
);
// The alpha factors default to GL_ZERO, GL_ONE: the target's alpha stays.
assert_eq!(multiply.alpha.src_factor, wgpu::BlendFactor::Zero);
assert_eq!(multiply.alpha.dst_factor, wgpu::BlendFactor::One);
assert_eq!(
blend_state(parse_blend("replace").unwrap()),
wgpu::BlendState::REPLACE
);
}
}
@@ -0,0 +1,154 @@
//! A data folder's files read from the disk once while a load holds them.
//! Several tables are built from the same folder (the recipes: the crafting
//! tables, the stations, the recipe book, brewing), and each used to list and
//! read every file again: on a hard disk or under a virus scanner, each open
//! costs. The first reader lists and reads the folder; later readers share
//! those bytes for as long as one of them holds the [`Folder`], then it is
//! gone (nothing is kept past the load).
use std::{
fs, io,
path::{Path, PathBuf},
sync::{Arc, Mutex, Weak},
};
/// One file: its path inside the folder without `.json` (`a/b`, as an id has
/// it), the file, and its bytes or the error reading it gave.
pub struct File {
pub name: String,
pub path: PathBuf,
bytes: Result<Box<[u8]>, (io::ErrorKind, String)>,
}
impl File {
pub fn bytes(&self) -> io::Result<&[u8]> {
self.bytes
.as_deref()
.map_err(|(kind, message)| io::Error::new(*kind, message.clone()))
}
}
/// Every `.json` file under a folder, its subfolders' too, in name order.
pub struct Folder {
pub files: Vec<File>,
}
static OPEN: Mutex<Vec<(PathBuf, Weak<Folder>)>> = Mutex::new(Vec::new());
fn find(open: &[(PathBuf, Weak<Folder>)], dir: &Path) -> Option<Arc<Folder>> {
open.iter()
.find(|(d, _)| d == dir)
.and_then(|(_, f)| f.upgrade())
}
/// `dir`'s files if another reader holds them, without reading anything.
pub fn held(dir: &Path) -> Option<Arc<Folder>> {
find(&OPEN.lock().unwrap_or_else(|e| e.into_inner()), dir)
}
/// The `.json` files under `dir`: shared with any other reader holding them,
/// else listed and read now. An error when `dir` itself can't be listed.
pub fn read(dir: &Path) -> io::Result<Arc<Folder>> {
if let Some(folder) = held(dir) {
return Ok(folder);
}
let files = list(dir)?
.into_iter()
.map(|(name, path)| {
let bytes = fs::read(&path)
.map(Vec::into_boxed_slice)
.map_err(|e| (e.kind(), e.to_string()));
File { name, path, bytes }
})
.collect();
let folder = Arc::new(Folder { files });
let mut open = OPEN.lock().unwrap_or_else(|e| e.into_inner());
// Another reader may have read it meanwhile: share one copy.
if let Some(theirs) = find(&open, dir) {
return Ok(theirs);
}
open.retain(|(_, f)| f.strong_count() > 0);
open.push((dir.to_owned(), Arc::downgrade(&folder)));
Ok(folder)
}
/// The `.json` files under `dir` and its subfolders, in name order: each
/// one's path inside the folder without the extension (`a/b`) and its file.
/// An error when `dir` itself can't be listed; a subfolder or an entry that
/// can't be is warned and skipped, as vanilla's `PathPackResources` keeps
/// what it found.
pub fn list(dir: &Path) -> io::Result<Vec<(String, PathBuf)>> {
let mut files = Vec::new();
let mut stack = vec![fs::read_dir(dir)?];
while let Some(entries) = stack.pop() {
for entry in entries {
let entry = match entry {
Ok(entry) => entry,
Err(e) => {
log::warn!("Horizon: failed to list an entry of {}: {e}", dir.display());
continue;
}
};
let path = entry.path();
if path.is_dir() {
match fs::read_dir(&path) {
Ok(list) => stack.push(list),
Err(e) => log::warn!("Horizon: failed to list {}: {e}", path.display()),
}
} else if path.extension().is_some_and(|e| e == "json") {
files.push(path);
}
}
}
let mut named: Vec<(String, PathBuf)> = files
.into_iter()
.map(|path| {
let inside = path.strip_prefix(dir).unwrap_or(&path).with_extension("");
let name: Vec<_> = inside.iter().map(|c| c.to_string_lossy()).collect();
(name.join("/"), path)
})
.collect();
// Vanilla lists resources by id, in a map keyed by it
// (`FallbackResourceManager`): `crushing` before `crushing/iron`, where
// a path's components put the subfolder's file first.
named.sort_by(|a, b| a.0.cmp(&b.0));
Ok(named)
}
#[cfg(test)]
mod tests {
use super::*;
fn folder(tag: &str) -> PathBuf {
let dir =
std::env::temp_dir().join(format!("horizon-json-folder-{tag}-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(dir.join("brewing")).unwrap();
fs::write(dir.join("stick.json"), b"{\"a\":1}").unwrap();
fs::write(dir.join("brewing/awkward.json"), b"{}").unwrap();
fs::write(dir.join("notes.txt"), b"not json").unwrap();
dir
}
#[test]
fn readers_share_the_bytes_while_one_holds_them_and_read_again_after() {
let dir = folder("share");
let first = read(&dir).unwrap();
let names: Vec<_> = first.files.iter().map(|f| f.name.as_str()).collect();
assert_eq!(names, ["brewing/awkward", "stick"]);
assert_eq!(first.files[1].bytes().unwrap(), b"{\"a\":1}");
// Held: a change on the disk isn't seen, the copy read is shared.
fs::write(dir.join("stick.json"), b"{\"a\":2}").unwrap();
let second = read(&dir).unwrap();
assert!(Arc::ptr_eq(&first, &second));
drop((first, second));
// Let go: the next reader reads the folder again.
assert_eq!(read(&dir).unwrap().files[1].bytes().unwrap(), b"{\"a\":2}");
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_folder_that_cannot_be_listed_is_an_error() {
let dir =
std::env::temp_dir().join(format!("horizon-json-folder-none-{}", std::process::id()));
assert!(read(&dir).is_err());
}
}
+4
View File
@@ -11,6 +11,7 @@ pub mod biome;
pub mod block_edit;
pub mod block_entities;
pub mod block_entity_anim;
pub mod block_entity_cem;
pub mod block_entity_render;
pub mod border_render;
pub mod breaking;
@@ -20,7 +21,9 @@ pub mod chat;
pub mod chunk;
pub mod chunk_map;
pub mod cit;
pub mod cit_armor;
pub mod cit_assets;
pub mod cit_glint;
pub mod clouds;
pub mod collisions;
pub mod command_block;
@@ -46,6 +49,7 @@ pub mod hud_overlays;
pub mod inventory_effects;
pub mod java_text;
pub mod jigsaw_block;
pub mod json_folder;
pub mod level_loading;
pub mod light;
pub mod lod;
+145 -45
View File
@@ -12,7 +12,7 @@ use crate::{
assets::{Assets, RenderAssets},
biome::Biomes,
blocks::{BlockFacts, RenderShape},
chunk::{Chunk, MIN_Y},
chunk::{Chunk, Light, MIN_Y},
graphics::LeafCullingMode,
models::catalog::BakedFace,
};
@@ -20,7 +20,7 @@ use horizon_assets::model::Direction as ModelDirection;
use horizon_core::{
biome::BiomeZoom, geometry::Direction, position::BlockPos, random::position_seed,
};
use horizon_optifine::ctm::connect::Face as CtmFace;
use horizon_optifine::ctm::connect::{Axes as CtmAxes, Face as CtmFace};
use horizon_world::{
block_render::FaceVisibility,
fluid::{FluidKind, FluidState},
@@ -283,7 +283,7 @@ impl SectionMesh {
pub const BOUNDS_PAD: f32 = 0.5;
/// Retain geometry capacity after an upload, but none of its old contents.
pub(crate) fn clear(&mut self) {
pub fn clear(&mut self) {
self.solid.clear();
self.translucent.clear();
self.block_entities.clear();
@@ -505,6 +505,10 @@ pub struct MeshFacts {
}
pub struct MeshTables {
info: Vec<Info>,
/// `Info::solid` by state, packed apart: the opacity mask of a section
/// reads 8,000 states, and a few bytes each stay in cache where `Info`
/// does not.
solid: Vec<bool>,
pub visibility: FaceVisibility,
water_still: u16,
water_flow: u16,
@@ -673,6 +677,7 @@ impl MeshTables {
});
}
Ok(Self {
solid: info.iter().map(|i| i.solid).collect(),
info,
visibility,
water_still: layer("minecraft:block/water_still")?,
@@ -842,43 +847,66 @@ impl Padded {
}
fn fill(&mut self, region: &[[Option<&Chunk>; 3]; 3], sy: i32, own: &mut [u32; 4096]) {
self.blocks.resize(P * P * P, 0);
self.blocks.fill(0);
self.light.resize(P * P * P, 0);
let (blocks, light) = (&mut self.blocks, &mut self.light);
let base_y = region[1][1].map_or(MIN_Y, Chunk::min_y) + sy * 16;
// The section itself is unpacked in one pass; the border cells of the
// neighbouring sections are read one by one.
// The section itself is unpacked in one pass and copied row by row;
// the border cells of the neighbouring sections are read one by one,
// each run of them from a section found once.
own.fill(0);
let center = region[1][1].map(|c| &c.sections[sy as usize]);
if let Some(section) = center {
section.unpack(own);
}
// The x runs of a padded row by the chunk column they fall in.
const RUNS: [(usize, i32, i32); 3] = [(0, -B, 0), (1, 0, 16), (2, 16, 16 + B)];
for y in -B..16 + B {
let wy = base_y + y;
for z in -B..16 + B {
for x in -B..16 + B {
let chunk = region[((z + 16) >> 4) as usize][((x + 16) >> 4) as usize];
let i = pi(x, y, z);
if let Some(c) = chunk {
blocks[i] =
if (0..16).contains(&x) && (0..16).contains(&y) && (0..16).contains(&z)
{
own[(y << 8 | z << 4 | x) as usize]
} else {
c.get(x & 15, wy, z & 15)
};
light[i] = c.light(x & 15, wy, z & 15);
} else if let Some(c) = region[1][1] {
// A neighbour not loaded continues this chunk's edge
// column. As lit air, the outermost chunks' water drew
// a wall toward it (fluids show both sides), a light
// sheet in the water where far terrain carries on.
// Vanilla draws no chunk without its neighbours.
let (ex, ez) = (x.clamp(0, 15), z.clamp(0, 15));
blocks[i] = c.get(ex, wy, ez);
light[i] = c.light(ex, wy, ez);
let row = pi(-B, y, z);
let rz = ((z + 16) >> 4) as usize;
for (rx, x0, x1) in RUNS {
let (start, end) = (row + (x0 + B) as usize, row + (x1 + B) as usize);
let (blocks, light) = (&mut blocks[start..end], &mut light[start..end]);
let Some(c) = region[rz][rx] else {
match region[1][1] {
// A neighbour not loaded continues this chunk's edge
// column. As lit air, the outermost chunks' water drew
// a wall toward it (fluids show both sides), a light
// sheet in the water where far terrain carries on.
// Vanilla draws no chunk without its neighbours.
Some(c) => {
let ez = z.clamp(0, 15);
for (k, x) in (x0..x1).enumerate() {
let ex = x.clamp(0, 15);
blocks[k] = c.get(ex, wy, ez);
light[k] = c.light(ex, wy, ez);
}
}
None => {
blocks.fill(0);
light.fill(0xF0);
}
}
continue;
};
// `Chunk::get` and `Chunk::light` with the section found once.
let s = (wy - c.min_y()) >> 4;
let Some(section) = c.sections.get(s as usize).filter(|_| s >= 0) else {
blocks.fill(0);
light.fill(if s < 0 { 0 } else { 0xF0 });
continue;
};
let at = crate::chunk::index(x0, wy, z);
if rx == 1 && rz == 1 && (0..16).contains(&y) {
let own_row = (y << 8 | z << 4) as usize;
blocks.copy_from_slice(&own[own_row..own_row + 16]);
} else {
light[i] = 0xF0;
section.blocks.get_run(at, blocks);
}
match &section.light {
Light::Uniform(v) => light.fill(*v),
Light::Full(all) => light.copy_from_slice(&all[at..at + light.len()]),
}
}
}
@@ -889,11 +917,33 @@ impl Padded {
/// geometry still owned by the renderer, and total about 150 KiB per worker
/// (the leaf mask adds 8,000 cells, depths and a queue).
#[derive(Default)]
pub(crate) struct MeshScratch {
pub struct MeshScratch {
padded: Padded,
own: Option<Box<[u32; 4096]>>,
visibility: Vec<usize>,
leaf: leaf_culling::LeafMask,
/// Per padded row (`pi(-B, y, z) / P`), bit `x + B` set where the block
/// is `isSolidRender`.
opaque: Vec<u32>,
}
/// Fills `rows` with the padded section's opacity, one bit per block (`MeshScratch::opaque`).
fn opaque_rows(pad: &Padded, solid: &[bool], rows: &mut Vec<u32>) {
rows.clear();
rows.extend(pad.blocks.as_chunks::<P>().0.iter().map(|row| {
row.iter().enumerate().fold(0u32, |bits, (x, &id)| {
bits | u32::from(solid[id as usize]) << x
})
}));
}
/// Whether the block at `(x, y, z)` (section-local) and its six neighbours
/// are all `isSolidRender`: none of its faces can show.
#[inline]
fn hidden(rows: &[u32], x: i32, y: i32, z: i32) -> bool {
let row = |y: i32, z: i32| rows[((y + B) as usize) * P + (z + B) as usize];
let r = row(y, z);
let around =
r & r << 1 & r >> 1 & row(y - 1, z) & row(y + 1, z) & row(y, z - 1) & row(y, z + 1);
around >> (x + B) & 1 != 0
}
#[cfg(test)]
mod leaf_culling_tests;
@@ -990,6 +1040,12 @@ enum Resolver {
/// layer plus the blend radius around it, and the blended colours per column.
struct Layer {
zoomed: Vec<u16>,
/// Each resolver's colour of every cell of `zoomed`, filled on first use:
/// a cell is in the blend square of up to `(2r+1)^2` columns.
cells: [Vec<u32>; 4],
/// `BiomeZoom::corner_jitter` of the quart cells `zoomed` reaches, by
/// their offset from the first: each is shared by up to 16 cells of the layer.
jitter: Vec<Option<[[f64; 3]; 8]>>,
colors: [[u32; 256]; 4],
}
/// `ClientLevel.calculateBlockTint` behind a per-layer `BlockTintCache`: each
@@ -1041,6 +1097,16 @@ impl<'a> Tints<'a> {
[x, y, z]: [i32; 3],
) -> u16 {
let q = zoom.quart_at_block(BlockPos::new(cx * 16 + x, base_y + y, cz * 16 + z));
Self::quart_biome(region, plains, [cx, cz], q)
}
/// The biome of quart cell `q` in the loaded chunks around chunk `(cx, cz)`, plains where
/// there is none.
fn quart_biome(
region: &[[Option<&Chunk>; 3]; 3],
plains: u16,
[cx, cz]: [i32; 2],
q: [i32; 3],
) -> u16 {
let (rx, rz) = ((q[0] >> 2) - cx + 1, (q[2] >> 2) - cz + 1);
if (0..3).contains(&rx) && (0..3).contains(&rz) {
region[rz as usize][rx as usize].map_or(plains, |c| {
@@ -1057,6 +1123,8 @@ impl<'a> Tints<'a> {
let layer = self.layers[y as usize].get_or_insert_with(|| {
Box::new(Layer {
zoomed: vec![NO_BIOME; dim * dim],
cells: Default::default(),
jitter: Vec::new(),
colors: [[UNSET; 256]; 4],
})
});
@@ -1065,21 +1133,45 @@ impl<'a> Tints<'a> {
if cached != UNSET {
return cached;
}
let [cx, cz, _] = self.origin;
let (bx, bz) = (cx * 16, cz * 16);
let [cx, cz, base_y] = self.origin;
let (bx, by, bz) = (cx * 16, base_y + y, cz * 16);
let (mut red, mut green, mut blue) = (0, 0, 0);
let cells = &mut layer.cells[resolver as usize];
if cells.is_empty() {
cells.resize(dim * dim, UNSET);
}
for dz in -r..=r {
for dx in -r..=r {
let cell = ((z + dz + r) as usize) * dim + (x + dx + r) as usize;
let known = cells[cell];
if known != UNSET {
red += known >> 16 & 255;
green += known >> 8 & 255;
blue += known & 255;
continue;
}
let mut biome = layer.zoomed[cell];
if biome == NO_BIOME {
biome = Self::biome_id(
&self.zoom,
self.region,
self.plains,
self.origin,
[x + dx, y, z + dz],
);
// BiomeManager.getBiome over the loaded chunks (plains
// where there is none): `quart_at_block`, its cell's
// corner jitter mixed once per layer.
let block = BlockPos::new(bx + x + dx, by, bz + z + dz);
let base = [block.x, block.y, block.z].map(|v| v.wrapping_sub(2) >> 2);
let first = [bx - r, bz - r].map(|v| v.wrapping_sub(2) >> 2);
let span = dim / 4 + 2;
let at = (base[2] - first[1]) as usize * span + (base[0] - first[0]) as usize;
if layer.jitter.is_empty() {
layer.jitter.resize(span * span, None);
}
let jitter =
layer.jitter[at].get_or_insert_with(|| self.zoom.corner_jitter(base));
let corner = BiomeZoom::select_corner(block, jitter);
let q = [
base[0].wrapping_add((corner >> 2 & 1) as i32),
base[1].wrapping_add((corner >> 1 & 1) as i32),
base[2].wrapping_add((corner & 1) as i32),
];
biome = Self::quart_biome(self.region, self.plains, [cx, cz], q);
layer.zoomed[cell] = biome;
}
let c = match resolver {
@@ -1091,6 +1183,7 @@ impl<'a> Tints<'a> {
Resolver::Dry => self.biomes.get(biome).dry_foliage,
Resolver::Water => self.biomes.get(biome).water,
};
cells[cell] = c;
red += c >> 16 & 255;
green += c >> 8 & 255;
blue += c & 255;
@@ -1144,7 +1237,9 @@ impl Mesher<'_> {
&mut MeshScratch::default(),
)
}
pub(crate) fn mesh_reusing(
/// The meshing workers' path: `out`'s buffers and `scratch` are reused
/// rather than allocated per section.
pub fn mesh_reusing(
&mut self,
region: &[[Option<&Chunk>; 3]; 3],
sy: i32,
@@ -1161,6 +1256,8 @@ impl Mesher<'_> {
scratch.padded.fill(region, sy, own);
let pad = &scratch.padded;
out.visibility = visibility_reusing(pad, &self.tables.info, &mut scratch.visibility);
opaque_rows(pad, &self.tables.solid, &mut scratch.opaque);
let opaque = &scratch.opaque;
let base_y = center.min_y() + sy * 16;
if self.leaf_culling.is_interior() {
scratch
@@ -1193,12 +1290,7 @@ impl Mesher<'_> {
out.lights
.push(([center.x * 16 + x, base_y + y, center.z * 16 + z], id));
}
if state.solid
&& Direction::ALL.iter().all(|d| {
let [dx, dy, dz] = d.normal();
info[pad.blocks[pi(x + dx, y + dy, z + dz)] as usize].solid
})
{
if hidden(opaque, x, y, z) {
continue;
}
if state.fluid.kind() != FluidKind::Empty {
@@ -1377,6 +1469,14 @@ impl Mesher<'_> {
[world.x, world.y, world.z],
|[dx, dy, dz]| pad.blocks[pi(pos[0] + dx, pos[1] + dy, pos[2] + dz)],
|| Some(tints.biome(pos[0], pos[1], pos[2])),
// A pane's tiles are drawn with the cube's UVs (below), so that is its texture.
|| {
if state.pane {
Some(CtmAxes::cube(CtmFace::ALL[q.facing as usize]))
} else {
CtmAxes::texture(&q.positions, &q.uv)
}
},
|overlay| {
if let Some(slot) = overlays.get_mut(count) {
*slot = Some(overlay);
+158 -31
View File
@@ -103,6 +103,22 @@ impl Sides {
}
}
/// A sort with these parts' `ends` and `planes`, for tests.
#[cfg(test)]
pub(crate) fn from_parts(
ends: [u32; 14],
grouped: bool,
split: bool,
planes: [f32; 7],
) -> Self {
Self {
ends,
grouped,
split,
planes,
}
}
/// The number of solid quads the buckets hold.
pub fn quads(&self) -> u32 {
self.ends[13]
@@ -128,6 +144,26 @@ impl Sides {
self.runs(seen, part, draw);
}
/// How many quads `part` draws facing every way (`every`).
pub fn part_quads(&self, part: Part) -> u32 {
let mut quads = 0;
self.every(part, |_, count| quads += count);
quads
}
/// The facts `visible_from` reads, for the GPU to find the same runs
/// (gpu_lists.wgsl): the parts' ends, the planes' bits,
/// then 1 when grouped plus 2 when split.
pub fn gpu_row(&self) -> [u32; 22] {
let mut row = [0; 22];
row[..14].copy_from_slice(&self.ends);
for (to, plane) in row[14..21].iter_mut().zip(self.planes) {
*to = plane.to_bits();
}
row[21] = u32::from(self.grouped) | u32::from(self.split) << 1;
row
}
/// The runs of `part`'s quads facing every way.
pub fn every(&self, part: Part, draw: impl FnMut(u32, u32)) {
self.runs(|_| true, part, draw);
@@ -210,6 +246,29 @@ pub fn sort(solid: &mut Vec<TerrainVertex>) -> Sides {
/// `sort`, with all the opaque parts first when `grouped`.
fn sort_parts(solid: &mut Vec<TerrainVertex>, grouped: bool) -> Sides {
SORT_SCRATCH.with_borrow_mut(|scratch| sort_with(solid, grouped, scratch))
}
/// `sort`'s buffers, kept by each meshing thread: a section's quads went
/// through fresh vectors, and the sorted copy replaced the mesh's own
/// (recycled) buffer.
#[derive(Default)]
struct SortScratch {
slots: Vec<u8>,
keys: Vec<u64>,
sorted: Vec<[TerrainVertex; 4]>,
}
thread_local! {
static SORT_SCRATCH: std::cell::RefCell<SortScratch> = Default::default();
}
/// `f32::total_cmp`'s order as an unsigned integer's.
fn total_order_key(v: f32) -> u32 {
let bits = v.to_bits() as i32;
(bits ^ (((bits >> 31) as u32) >> 1) as i32) as u32 ^ 0x8000_0000
}
fn sort_with(solid: &mut Vec<TerrainVertex>, grouped: bool, scratch: &mut SortScratch) -> Sides {
let (quads, rest) = solid.as_chunks::<4>();
debug_assert!(rest.is_empty(), "solid vertices come in quads");
let cutout = |q: &[TerrainVertex; 4]| q[0].layer & OPAQUE_LAYER == 0;
@@ -218,15 +277,14 @@ fn sort_parts(solid: &mut Vec<TerrainVertex>, grouped: bool) -> Sides {
true => b + 7 * usize::from(cutout),
false => b * 2 + usize::from(cutout),
};
let slots: Vec<u8> = quads
.iter()
.map(|q| position(bucket(q), cutout(q)) as u8)
.collect();
let slots = &mut scratch.slots;
slots.clear();
slots.extend(quads.iter().map(|q| position(bucket(q), cutout(q)) as u8));
let has_cutout = quads.iter().any(cutout);
let mut counts = [0u32; 7];
let mut slot_counts = [0u32; 14];
let mut planes = [0f32; 7];
for (quad, &part) in quads.iter().zip(&slots) {
for (quad, &part) in quads.iter().zip(slots.iter()) {
slot_counts[usize::from(part)] += 1;
let b = slot(grouped, usize::from(part)).0;
if let Bucket::Side(axis, positive) = BUCKETS[b] {
@@ -246,33 +304,34 @@ fn sort_parts(solid: &mut Vec<TerrainVertex>, grouped: bool) -> Sides {
*end = total;
}
if has_cutout || counts.iter().filter(|&&c| c > 0).count() > 1 {
let mut next: [u32; 14] = std::array::from_fn(|s| slot_ends[s] - slot_counts[s]);
let mut sorted = vec![quads[0]; quads.len()];
for (quad, &part) in quads.iter().zip(&slots) {
let at = &mut next[usize::from(part)];
sorted[*at as usize] = *quad;
*at += 1;
}
if has_cutout {
let mut start = 0;
for (s, &end) in slot_ends.iter().enumerate() {
let end = end as usize;
let slice = &mut sorted[start..end];
if let Bucket::Side(axis, positive) = BUCKETS[slot(grouped, s).0] {
slice.sort_by(|a, b| {
let a = a[0].pos[axis];
let b = b[0].pos[axis];
if positive {
b.total_cmp(&a)
} else {
a.total_cmp(&b)
// One integer key per quad: its part, then (cutout meshes, side
// buckets) its plane from the outside in, then its index, which keeps
// the order of equal quads as a stable sort does.
let keys = &mut scratch.keys;
keys.clear();
keys.extend(
quads
.iter()
.zip(slots.iter())
.enumerate()
.map(|(i, (quad, &part))| {
let plane = match BUCKETS[slot(grouped, usize::from(part)).0] {
Bucket::Side(axis, positive) if has_cutout => {
let key = total_order_key(quad[0].pos[axis]);
if positive { !key } else { key }
}
});
}
start = end;
}
}
*solid = sorted.into_flattened();
_ => 0,
};
u64::from(part) << 56 | u64::from(plane) << 24 | i as u64
}),
);
debug_assert!(quads.len() < 1 << 24);
keys.sort_unstable();
let sorted = &mut scratch.sorted;
sorted.clear();
sorted.extend(keys.iter().map(|&k| quads[(k & 0xFF_FFFF) as usize]));
solid.clear();
solid.extend_from_slice(sorted.as_flattened());
}
Sides {
ends: slot_ends,
@@ -286,6 +345,74 @@ fn sort_parts(solid: &mut Vec<TerrainVertex>, grouped: bool) -> Sides {
mod tests {
use super::*;
/// The sort as it was before the integer keys: quads moved to their
/// parts in order, then a stable sort by plane in each side bucket.
fn reference(solid: &[TerrainVertex], grouped: bool) -> Vec<TerrainVertex> {
let quads = solid.as_chunks::<4>().0;
let has_cutout = quads.iter().any(|q| q[0].layer & OPAQUE_LAYER == 0);
let mut sorted = Vec::new();
for n in 0..14 {
let (b, cutout) = slot(grouped, n);
let mut part: Vec<[TerrainVertex; 4]> = quads
.iter()
.filter(|q| bucket(q) == b && (q[0].layer & OPAQUE_LAYER == 0) == cutout)
.copied()
.collect();
if let (Bucket::Side(axis, positive), true) = (BUCKETS[b], has_cutout) {
part.sort_by(|a, b| {
let (a, b) = (a[0].pos[axis], b[0].pos[axis]);
if positive {
b.total_cmp(&a)
} else {
a.total_cmp(&b)
}
});
}
sorted.extend(part);
}
sorted.into_flattened()
}
#[test]
fn integer_keys_sort_as_the_stable_sort_did() {
let mut state = 0x2545_F491_4F6C_DD1Du64;
let mut next = move || {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
state
};
for round in 0..100 {
let grouped = round % 2 == 1;
let mut solid = Vec::new();
for _ in 0..(next() % 300) {
let origin = [0, 1, 2].map(|_| (next() % 34) as f32 / 2.0 - 0.5);
// Some negative and signed-zero planes too.
let origin = if next() % 9 == 0 {
origin.map(|v| -v)
} else {
origin
};
let layer = if round % 3 == 0 {
OPAQUE_LAYER
} else {
(next() % 2) as u16 * OPAQUE_LAYER
};
let faces = cube(origin, layer);
let face = (next() % 6) as usize;
solid.extend_from_slice(&faces[face * 4..face * 4 + 4]);
}
let expected = reference(&solid, grouped);
let sides = sort_parts(&mut solid, grouped);
assert_eq!(
bytemuck::cast_slice::<_, u8>(&solid),
bytemuck::cast_slice::<_, u8>(&expected),
"round {round}"
);
assert_eq!(sides.quads() as usize * 4, solid.len());
}
}
fn quad(corners: [[f32; 3]; 4], layer: u16) -> [TerrainVertex; 4] {
corners.map(|p| TerrainVertex::new(p, [0.0; 2], layer, 0, 0))
}
@@ -28,10 +28,18 @@ pub fn load(root: &Path, sprites: &mut Sprites) {
}
}
/// A connected-texture tile's sprite name without `.png`: tiles outside `textures/` are named by
/// their resource path (`Sprites::insert_file`, which only they use), block sprites by an id.
fn tile_file(name: &str) -> Option<&str> {
name.strip_prefix("assets/")?;
name.strip_suffix(".png")
}
/// OptiFine's emissive overlays of the block sprites already loaded (what the terrain draws;
/// items draw from the HUD atlas): for each
/// `<ns>:block/<name>` whose `<name><suffix>.png` sits beside it, the overlay is loaded too and
/// paired with it, first by name, at most [`MAX`] - [`NATIVE`] (native mods keep their share of
/// `<ns>:block/<name>`, or connected-texture tile `assets/<ns>/.../<name>.png`, whose
/// `<name><suffix>.png` sits beside it, the overlay is loaded too and paired with it, blocks
/// first, then tiles, each by name, at most [`MAX`] - [`NATIVE`] (native mods keep their share of
/// the table). A pack without such files adds nothing.
pub fn load_optifine(root: &Path, sprites: &mut Sprites) -> Vec<(String, String)> {
let suffix = horizon_optifine::emissive::suffix(root);
@@ -41,21 +49,34 @@ pub fn load_optifine(root: &Path, sprites: &mut Sprites) -> Vec<(String, String)
for layer in sprites.layers.values() {
*sharing.entry(*layer).or_default() += 1;
}
let bases: Vec<String> = sprites
let mut bases: Vec<String> = sprites
.layers
.iter()
.filter(|(name, layer)| {
sharing[layer] == 1
&& name
&& (name
.split_once(':')
.is_some_and(|(_, p)| p.starts_with("block/") && !p.ends_with(&suffix))
|| tile_file(name).is_some_and(|stem| !stem.ends_with(&suffix)))
})
.map(|(name, _)| name.clone())
.collect();
// Blocks before tiles: a pack's many tiles must not crowd vanilla blocks' overlays out.
bases.sort_by_key(|name| (tile_file(name).is_some(), name.clone()));
let mut pairs = Vec::new();
for base in bases {
let overlay = format!("{base}{suffix}");
let file = crate::atlas_sources::texture_path(root, &overlay, "textures");
// A tile is named by its file, a block sprite by its id.
let (overlay, file) = match tile_file(&base) {
Some(stem) => {
let overlay = format!("{stem}{suffix}.png");
(overlay.clone(), root.join(overlay))
}
None => {
let overlay = format!("{base}{suffix}");
let file = crate::atlas_sources::texture_path(root, &overlay, "textures");
(overlay, file)
}
};
if !std::fs::metadata(&file).is_ok_and(|m| m.is_file()) {
continue;
}
@@ -71,7 +92,12 @@ pub fn load_optifine(root: &Path, sprites: &mut Sprites) -> Vec<(String, String)
continue;
}
// A file that doesn't load would glow as the missing texture: no pair instead.
match sprites.try_insert(root, &overlay) {
let loaded = if overlay.starts_with("assets/") {
sprites.insert_file(root, &overlay)
} else {
sprites.try_insert(root, &overlay)
};
match loaded {
Ok(_) => pairs.push((base, overlay)),
Err(e) => log::warn!("Horizon: emissive overlay {overlay} skipped: {e}"),
}
@@ -192,13 +218,40 @@ mod tests {
let props = root.join("assets/minecraft/optifine/emissive.properties");
std::fs::create_dir_all(props.parent().unwrap()).unwrap();
std::fs::write(&props, "suffix.emissive=_glow").unwrap();
// Connected-texture tiles pair with the overlay beside them.
let tiles = root.join("assets/minecraft/optifine/ctm/lamp");
png(&tiles.join("0.png"), 230);
png(&tiles.join("0_glow.png"), 240);
png(&tiles.join("1.png"), 250);
// A tile outside `ctm/` (`tiles=~/glass/2`) is a tile all the same.
png(&root.join("assets/minecraft/optifine/glass/2.png"), 245);
png(
&root.join("assets/minecraft/optifine/glass/2_glow.png"),
246,
);
for tile in ["ctm/lamp/0", "ctm/lamp/1", "glass/2"] {
sprites
.insert_file(&root, &format!("assets/minecraft/optifine/{tile}.png"))
.unwrap();
}
let pairs = load_optifine(&root, &mut sprites);
assert_eq!(
pairs,
[(
"minecraft:block/deepslate".into(),
"minecraft:block/deepslate_glow".into()
)]
// Blocks first, then tiles.
[
(
"minecraft:block/deepslate".into(),
"minecraft:block/deepslate_glow".into()
),
(
"assets/minecraft/optifine/ctm/lamp/0.png".into(),
"assets/minecraft/optifine/ctm/lamp/0_glow.png".into()
),
(
"assets/minecraft/optifine/glass/2.png".into(),
"assets/minecraft/optifine/glass/2_glow.png".into()
),
]
);
std::fs::remove_dir_all(&root).unwrap();
}
@@ -5345,6 +5345,61 @@ mod tests {
assert!(ui.resource_error.is_none());
}
/// The texts of an options page, as laid out in `code`'s language, and the keys it lacked.
fn page_texts(code: &str, pages: &[Page]) -> (Vec<String>, Vec<String>) {
let s = Settings::default();
lang::scoped(lang::Language::load(&[], code), || {
let mut texts = Vec::new();
for page in pages {
let mut ui = OptionsUi::default();
ui.show(*page);
for w in ui.layout(&s, 480.0, 4000.0).0 {
match w.kind {
WKind::Button { label: t, .. }
| WKind::Slider { label: t, .. }
| WKind::Text { text: t, .. } => texts.push(t.plain()),
_ => {}
}
}
}
texts
})
}
/// Horizon's own options pages said "Splitscreen...", "Hide block-break particles" and
/// "Hotbar: Previous Slot" in every language (#1927).
#[test]
fn horizon_option_pages_follow_the_language() {
let (texts, missed) = page_texts("de_de", &[Page::Horizon, Page::Extras, Page::PadBinds]);
for want in [
"Splitscreen …",
"Partikel",
"Blockpartikel weg: Aus",
"Weicher Chat: Aus",
"Nachrichtenmarkierung: An",
"Kanäle für UI-Töne: Aus",
"Schnellzugriffsleiste: Vorheriger Platz",
"Schnellzugriffsleiste: Nächster Platz",
] {
assert!(texts.iter().any(|t| t == want), "{want:?} not in {texts:?}");
}
// Every key these pages ask for is in the table; vanilla's own keys (`options.*`,
// `key.*`: dotted ids without spaces) come from the prepared resources, which this
// language does not have.
let missed: Vec<_> = missed
.into_iter()
.filter(|k| k.starts_with("horizon.") || k.contains(' ') || !k.contains('.'))
.collect();
assert_eq!(missed, Vec::<String>::new());
let (texts, _) = page_texts("pl_pl", &[Page::Extras, Page::PadBinds]);
for want in [
"Ukryj cząstki bloków: Wył.",
"Pasek szybkiego wyboru: poprzednie pole",
] {
assert!(texts.iter().any(|t| t == want), "{want:?} not in {texts:?}");
}
}
#[test]
fn classic_mods_page_toggles_mods_and_keeps_dev_and_invalid_entries_inactive() {
let atlas = atlas();
@@ -4093,6 +4093,47 @@ impl ParticleEngine {
}
}
/// The particles that `entries` name, as lights: where each is this frame, its entry's colour
/// as RGB and reach, and its alpha as the intensity.
pub fn lights(
&self,
entries: &[horizon_shader_api::manifest::ParticleLight],
partial: f32,
) -> Vec<([f64; 3], u32, f32, f32)> {
let shining: Vec<(ParticleType, u32, f32)> = entries
.iter()
.flat_map(|e| {
let [r, g, b] = e.color.map(|c| (c.clamp(0.0, 1.0) * 255.0) as u32);
e.particles.iter().filter_map(move |name| {
let name = if name.contains(':') {
name.clone()
} else {
format!("minecraft:{name}")
};
Some((
ParticleType::from_name(&name)?,
r << 16 | g << 8 | b,
e.reach,
))
})
})
.collect();
if shining.is_empty() {
return Vec::new();
}
let f = f64::from(partial);
self.particles
.iter()
.filter(|p| p.rgba[3] > 0.0)
.filter_map(|p| {
let (_, rgb, reach) = shining.iter().find(|(ty, ..)| *ty == p.ty)?;
let at =
std::array::from_fn(|i| p.previous[i] + (p.position[i] - p.previous[i]) * f);
Some((at, *rgb, *reach, p.rgba[3].clamp(0.0, 1.0)))
})
.collect()
}
/// Camera-facing quads (`QuadParticleRenderState.renderRotatedQuad`).
pub fn render(
&self,
@@ -133,6 +133,21 @@ pub fn text(out: &mut Vec<Draw>, text: Text, x: i32, y: i32, color: i32, shadow:
});
}
}
/// `graphics.text` on text the caller already styled, drawn as it is: a `§`
/// in it stays a character, as `FormattedCharSequence.forward`
/// (`StringDecomposer.iterate`, not `iterateFormatted`) draws it. An edit box
/// builds its own sequence that way, so a `§` in its value shows.
pub fn text_as_typed(out: &mut Vec<Draw>, text: Text, x: i32, y: i32, color: i32, shadow: bool) {
if (color as u32) >> 24 != 0 {
out.push(Draw::Text {
text,
x,
y,
color,
shadow,
});
}
}
/// `StringDecomposer.iterateFormatted`: the drawn characters of `contents`,
/// with their index in it and their style; legacy `§` codes restyle what
@@ -186,6 +201,11 @@ impl Measure<'_> {
pub fn width(&self, t: &Text) -> i32 {
self.0.width_text(&t.resolve_legacy()) as i32
}
/// `Font.width(FormattedCharSequence)` of a sequence drawn as it is: a
/// `§` in the text counts as the glyph it is drawn as.
pub fn width_drawn(&self, t: &Text) -> i32 {
self.0.width_drawn(t) as i32
}
pub fn width_str(&self, s: &str) -> i32 {
self.0.width(s) as i32
}
@@ -864,8 +884,8 @@ impl TextField {
displayed.clone()
};
let t = styled(half, self.display);
let w = m.width(&t);
text(out, t, draw_x, ty, color, self.text_shadow);
let w = m.width_drawn(&t);
text_as_typed(out, t, draw_x, ty, color, self.text_shadow);
draw_x += w + 1;
}
let insert = self.cursor < len || len >= self.max_length;
@@ -878,7 +898,7 @@ impl TextField {
}
if !displayed.is_empty() && on_screen && (rel_cursor as usize) < shown_len {
let t = styled(sub(&displayed, rel_cursor as usize, shown_len), self.cursor);
text(out, t, draw_x, ty, color, self.text_shadow);
text_as_typed(out, t, draw_x, ty, color, self.text_shadow);
}
if let Some(hint) = &self.hint
&& displayed.is_empty()
+198
View File
@@ -46,6 +46,30 @@ pub fn time<T>(name: &'static str, f: impl FnOnce() -> T) -> T {
SPANS.with(|s| s.borrow_mut().push(Span { name, ms, cpu_ms }));
out
}
/// Consecutive steps of one function timed as spans: each [`Laps::lap`] records the time since
/// the previous one (or since [`laps`]) under its name. Nothing is read while spans are off.
pub struct Laps(Option<(Instant, Option<Duration>)>);
/// Start timing the steps of a function (see [`Laps`]).
#[inline]
pub fn laps() -> Laps {
Laps(enabled().then(|| (Instant::now(), thread_cpu())))
}
impl Laps {
/// Record the step that just ended under `name`, and start the next.
#[inline]
pub fn lap(&mut self, name: &'static str) {
let Some((started, cpu)) = &mut self.0 else {
return;
};
let (now, now_cpu) = (Instant::now(), thread_cpu());
let ms = (now - *started).as_secs_f32() * 1000.0;
let cpu_ms = now_cpu
.zip(*cpu)
.map_or(ms, |(end, start)| (end - start).as_secs_f32() * 1000.0);
SPANS.with(|s| s.borrow_mut().push(Span { name, ms, cpu_ms }));
(*started, *cpu) = (now, now_cpu);
}
}
/// The CPU time this thread has used, where the platform tells it: a tick
/// with more wall time than CPU time waited (for a lock, or for a core on a
/// busy machine).
@@ -92,6 +116,161 @@ pub fn describe(spans: &[Span]) -> String {
list.join(", ")
}
/// The start's timeline: `HORIZON_STARTUP_TRACE=FILE` writes every named step of
/// the start (resource tables, the renderer, the title, a world's open, a
/// server's join, a dimension change) on whichever thread ran it, one Chrome
/// trace event per line (`tools/startup_trace.py` reads it; wrapped in `[...]`,
/// Perfetto opens it). Times are microseconds since the process started. Off,
/// a step costs one relaxed load.
pub mod startup {
use std::{
io::Write as _,
sync::{
Mutex, OnceLock,
atomic::{AtomicBool, AtomicU32, Ordering},
},
time::Instant,
};
static ON: AtomicBool = AtomicBool::new(false);
static SINK: OnceLock<Option<Mutex<std::fs::File>>> = OnceLock::new();
static ORIGIN: OnceLock<Instant> = OnceLock::new();
static NEXT_THREAD: AtomicU32 = AtomicU32::new(1);
thread_local! {
/// This thread's trace id, given (with its name) on its first event.
static THREAD: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
}
/// Opens the trace named by `HORIZON_STARTUP_TRACE`, timed from `origin`
/// (the process's start). Call once, first thing.
pub fn init(origin: Instant) {
ORIGIN.get_or_init(|| origin);
let sink = SINK.get_or_init(|| {
let path = std::env::var_os("HORIZON_STARTUP_TRACE")?;
std::fs::File::create(&path).ok().map(Mutex::new)
});
ON.store(sink.is_some(), Ordering::Relaxed);
}
/// Whether a trace is being written.
#[inline]
pub fn on() -> bool {
ON.load(Ordering::Relaxed)
}
fn micros(at: Instant) -> u128 {
ORIGIN.get().map_or(0, |origin| {
at.saturating_duration_since(*origin).as_micros()
})
}
fn write(event: std::fmt::Arguments) {
let Some(Some(sink)) = SINK.get() else {
return;
};
let tid = THREAD.with(|id| {
if id.get() == 0 {
id.set(NEXT_THREAD.fetch_add(1, Ordering::Relaxed));
let name = std::thread::current()
.name()
.unwrap_or("unnamed")
.replace(['"', '\\'], "_");
let mut file = sink.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let _ = writeln!(
file,
r#"{{"ph":"M","name":"thread_name","pid":1,"tid":{},"args":{{"name":"{name}"}}}}"#,
id.get()
);
}
id.get()
});
let mut file = sink
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let _ = writeln!(file, r#"{{"pid":1,"tid":{tid},{event}}}"#);
}
/// A step running until it is dropped.
#[must_use = "the step ends when this is dropped"]
pub struct Step {
name: &'static str,
started: Instant,
}
impl Drop for Step {
fn drop(&mut self) {
let end = Instant::now();
write(format_args!(
r#""ph":"X","name":"{}","ts":{},"dur":{}"#,
self.name,
micros(self.started),
end.saturating_duration_since(self.started).as_micros()
));
}
}
/// Starts a step named `name` (a literal: no quotes or backslashes).
#[inline]
pub fn step(name: &'static str) -> Option<Step> {
on().then(|| Step {
name,
started: Instant::now(),
})
}
/// Runs `f` as a step named `name`.
#[inline]
pub fn time<T>(name: &'static str, f: impl FnOnce() -> T) -> T {
let _step = step(name);
f()
}
/// A moment of the start: the title drawn, the world in view.
#[inline]
pub fn mark(name: &'static str) {
if on() {
write(format_args!(
r#""ph":"i","s":"g","name":"{name}","ts":{}"#,
micros(Instant::now())
));
}
}
/// The first time `name` happens, as a mark. `once` is the caller's flag.
#[inline]
pub fn mark_once(once: &AtomicBool, name: &'static str) {
if on() && !once.swap(true, Ordering::Relaxed) {
mark(name);
}
}
/// Set while a world opens, a server is joined or the dimension changes,
/// until the view first fills ([`view_filled`]).
static FILLING: AtomicBool = AtomicBool::new(false);
/// A world starts loading: the next full view is marked.
pub fn arm(name: &'static str) {
if on() {
mark(name);
FILLING.store(true, Ordering::Relaxed);
}
}
/// Called each frame in a world: marks `world.view_full` the first frame
/// after [`arm`] where nothing waits to load, generate, light, mesh or
/// upload and the resident columns cover perf_bench's share of the view
/// (0.8 pi r^2: corners are never loaded).
#[inline]
pub fn view_filled(resident: usize, pending: usize, radius: u32) {
if FILLING.load(Ordering::Relaxed)
&& pending == 0
&& resident as f64 >= 0.8 * std::f64::consts::PI * f64::from(radius * radius)
&& FILLING.swap(false, Ordering::Relaxed)
{
mark("world.view_full");
}
}
}
#[cfg(test)]
mod tests {
use super::Span;
@@ -138,6 +317,25 @@ mod tests {
assert!(super::take().is_empty());
}
/// Laps record the steps between them while spans are on, and nothing while off.
#[test]
fn laps_time_consecutive_steps() {
let _ = super::take();
let mut off = super::laps();
off.lap("unseen");
super::enable(true);
let mut laps = super::laps();
std::thread::sleep(std::time::Duration::from_millis(2));
laps.lap("first");
laps.lap("second");
super::enable(false);
off.lap("still unseen");
let spans = super::take();
let names: Vec<_> = spans.iter().map(|s| s.name).collect();
assert_eq!(names, ["first", "second"]);
assert!(spans[0].ms >= 2.0);
}
/// The thread's CPU clock moves with work done on it, not with time
/// spent asleep.
#[cfg(any(unix, all(windows, target_arch = "x86_64")))]
+182
View File
@@ -413,6 +413,119 @@ impl Sprites {
};
self.register(root, resource, decoded)
}
/// The tiles of the texture `name` (`ns:path`) cut into a `columns` x `rows` grid, row by
/// row from the top left: a Fusion connecting texture's sheet. Each tile is its own layer
/// (named `fusion/<ns>/<path>#<tile>`); a fully transparent one is `None`, as Fusion skips
/// empty tiles. Tiles are square, as wide as a column. An animated sheet's frames are whole
/// grids one below the other, or with `per_tile` each cell is a column of its tile's frames.
/// The sheet's own layer, if models already show it, shows its first tile from then on.
pub fn insert_tiles(
&mut self,
root: &Path,
name: &str,
(columns, rows): (u32, u32),
per_tile: bool,
) -> Result<Vec<Option<u32>>> {
let path = crate::atlas_sources::texture_path(root, name, "textures");
let metadata = match fs::read_to_string(path.with_extension("png.mcmeta")) {
Ok(text) => Some(serde_json::from_str::<
BTreeMap<String, Box<serde_json::value::RawValue>>,
>(&text)?),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
Err(error) => return Err(error.into()),
};
let animation = metadata
.as_ref()
.and_then(|m| m.get("animation"))
.map(|v| v.get().to_owned());
// The sheet's `texture` section is each tile's.
let texture = texture_section(metadata.as_ref());
let strategy = texture
.as_ref()
.and_then(|t| t["mipmap_strategy"].as_str())
.and_then(crate::mipmap::Strategy::parse)
.unwrap_or_default();
let alpha_cutoff_bias = texture
.as_ref()
.and_then(|t| t["alpha_cutoff_bias"].as_f64())
.unwrap_or(0.0) as f32;
let (width, height, pixels) = read_rgba(&path)?;
let side = width / columns.max(1);
// How far down the next frame of a tile is, and the next row of tiles.
let (frame_step, row_step) = match per_tile {
true => (side, height / rows.max(1)),
false => (rows * side, side),
};
let frames = match per_tile {
true => row_step / side.max(1),
false => height / frame_step.max(1),
};
let whole = match per_tile {
true => height % rows.max(1) == 0 && row_step % side.max(1) == 0,
false => height % frame_step.max(1) == 0,
};
if side == 0 || frames == 0 || width % columns.max(1) != 0 || !whole {
return Err(format!("{name} isn't a {columns} x {rows} grid of square tiles").into());
}
let tiles = (0..columns * rows)
.map(|tile| {
let frames: Vec<Vec<u8>> = (0..frames)
.map(|f| {
let (x, y) = (
tile % columns * side,
tile / columns * row_step + f * frame_step,
);
(y..y + side)
.flat_map(|row| {
let start = ((row * width + x) * 4) as usize;
pixels[start..start + side as usize * 4].iter().copied()
})
.collect()
})
.collect();
let alphas = || frames.iter().flat_map(|f| f.iter().skip(3).step_by(4));
if alphas().all(|&a| a == 0) {
return Ok(None);
}
let decoded = crate::sprite_cache::Decoded {
side,
animation: animation.clone().filter(|_| frames.len() > 1),
strategy,
alpha_cutoff_bias,
has_transparent: alphas().any(|&a| a == 0),
opaque: alphas().all(|&a| a == 255),
translucent: alphas().any(|&a| a != 0 && a != 255),
frames,
};
let tile_name = format!("fusion/{}#{tile}", name.replace(':', "/"));
self.register(root, &tile_name, decoded).map(Some)
})
.collect::<Result<Vec<_>>>()?;
// Where Fusion doesn't draw the sheet (items, other models), the lone tile rather than
// the whole sheet squeezed square, whose size would set every layer's.
if let (Some(&own), Some(&first)) = (self.layers.get(name), tiles.iter().flatten().next()) {
self.show_as(own, first);
}
Ok(tiles)
}
/// Layer `layer` shows what `source` does, and is no longer the layer of its old pixels.
fn show_as(&mut self, layer: u32, source: u32) {
self.still.retain(|_, l| *l != layer);
let (l, s) = (layer as usize, source as usize);
self.native[l] = self.native[s].clone();
self.opaque[l] = self.opaque[s];
self.translucent[l] = self.translucent[s];
self.mips[l] = self.mips[s];
self.pbr[l] = self.pbr[s].clone();
self.animations.retain(|a| a.layer != layer);
if let Some(animation) = self.animations.iter().find(|a| a.layer == source) {
let animation = SpriteAnimation {
layer,
..animation.clone()
};
self.animations.push(animation);
}
}
/// Read and decode one texture and its `.mcmeta`.
fn decode(root: &Path, name: &str) -> Result<crate::sprite_cache::Decoded> {
if !name.contains(':') {
@@ -762,6 +875,75 @@ mod tests {
fs::remove_dir_all(&root).unwrap();
}
#[test]
fn tile_sheets_cut_into_a_layer_per_tile() {
let root =
std::env::temp_dir().join(format!("horizon-sprite-tiles-{}", std::process::id()));
let file = root.join("assets/minecraft/textures/block/sheet.png");
fs::create_dir_all(file.parent().unwrap()).unwrap();
// 3 columns of 2 x 2 tiles over 4 rows of pixels: the pixel at (x, y) has red x, green
// y; the middle column is transparent, and the top-right tile half see-through.
let (w, h) = (6u32, 4u32);
let pixels: Vec<u8> = (0..w * h)
.flat_map(|i| {
let (x, y) = (i % w, i / w);
let alpha = match x / 2 {
1 => 0,
2 if y < 2 => 128,
_ => 255,
};
[x as u8, y as u8, 0, alpha]
})
.collect();
let mut encoder = png::Encoder::new(File::create(&file).unwrap(), w, h);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
encoder
.write_header()
.unwrap()
.write_image_data(&pixels)
.unwrap();
// Red and green of each pixel in a layer's frame.
let texels = |sprites: &Sprites, layer: u32, frame: usize| -> Vec<(u8, u8)> {
sprites.native[layer as usize].1[frame]
.chunks(4)
.map(|p| (p[0], p[1]))
.collect()
};
let mut sprites = Sprites::default();
let name = "minecraft:block/sheet";
let own = sprites.insert(&root, name).unwrap();
// Still, as a 3 x 2 grid: the empty middle column draws nothing.
let tiles = sprites.insert_tiles(&root, name, (3, 2), false).unwrap();
// The sheet's own layer now shows the top-left tile, at a tile's size.
assert_eq!(texels(&sprites, own, 0), [(0, 0), (1, 0), (0, 1), (1, 1)]);
assert_eq!(
tiles.iter().map(Option::is_some).collect::<Vec<_>>(),
[true, false, true, true, false, true]
);
let bottom_left = tiles[3].unwrap();
assert_eq!(
texels(&sprites, bottom_left, 0),
[(0, 2), (1, 2), (0, 3), (1, 3)]
);
assert!(sprites.translucent[tiles[2].unwrap() as usize]);
assert!(sprites.opaque[bottom_left as usize]);
// Animated, as one row of tiles: two frames, the grid's top and then its bottom half;
// per tile, each cell is the column of a tile's frames, which here is the same.
fs::write(file.with_extension("png.mcmeta"), r#"{"animation":{}}"#).unwrap();
for per_tile in [false, true] {
let mut sprites = Sprites::default();
let tiles = sprites.insert_tiles(&root, name, (3, 1), per_tile).unwrap();
let first = tiles[0].unwrap();
assert_eq!(texels(&sprites, first, 0), [(0, 0), (1, 0), (0, 1), (1, 1)]);
assert_eq!(texels(&sprites, first, 1), [(0, 2), (1, 2), (0, 3), (1, 3)]);
assert_eq!(sprites.animations.len(), 2, "two non-empty tiles");
}
// Not a grid of square tiles.
assert!(sprites.insert_tiles(&root, name, (4, 1), false).is_err());
assert!(sprites.insert_tiles(&root, name, (3, 3), false).is_err());
fs::remove_dir_all(&root).unwrap();
}
#[test]
fn custom_item_texture_ids_load_with_their_animation() {
let root = std::env::temp_dir().join(format!("horizon-sprite-cit-{}", std::process::id()));
let file = root.join("assets/minecraft/optifine/cit/a.png");
@@ -1131,6 +1131,13 @@ impl Streaming {
world
.block_entities
.load_chunk(std::mem::take(&mut chunk.block_entities));
if let Some(extras) = &chunk.saved {
for (pos, attachments) in &extras.block_entity_attachments {
world
.block_entities
.restore_attachments(*pos, attachments.clone());
}
}
world
.block_names
.extend(std::mem::take(&mut chunk.block_names));
+12 -21
View File
@@ -20,9 +20,6 @@ const MIN_SECONDS: f32 = 60.0;
const CRASH_DETAILS: [&str; 3] = ["Horizon Version", "Graphics Adapter", "Render Settings"];
static STARTED: OnceLock<Instant> = OnceLock::new();
/// When the menu bundle became ready, so the time to the title screen measures
/// the menu and not the world bundle loaded behind it.
static UI_READY_MS: AtomicU32 = AtomicU32::new(u32::MAX);
static CORE_READY_MS: AtomicU32 = AtomicU32::new(u32::MAX);
static REST_READY_MS: AtomicU32 = AtomicU32::new(u32::MAX);
/// The GPU, once the renderer has its adapter: name, maker, kind, driver, backend, its own memory.
@@ -46,14 +43,6 @@ pub fn mark_start() {
STARTED.get_or_init(Instant::now);
}
/// The menu bundle is ready: the title screen can draw. The first writer wins.
pub fn mark_ui_ready() {
if let Some(started) = STARTED.get() {
let ms = started.elapsed().as_millis() as u32;
let _ = UI_READY_MS.compare_exchange(u32::MAX, ms, Ordering::Relaxed, Ordering::Relaxed);
}
}
pub fn mark_core_ready() {
mark_ready(&CORE_READY_MS);
}
@@ -241,17 +230,18 @@ impl Session {
pub fn title_shown(&mut self) {
if !self.title_measured {
self.title_measured = true;
let ui_ms = UI_READY_MS.load(Ordering::Relaxed);
self.to_title_ms = if ui_ms != u32::MAX {
Some(ui_ms)
} else {
STARTED.get().map(|s| s.elapsed().as_millis() as u32)
};
crate::spans::startup::mark("title.shown");
// The frame itself: the menu bundle can be ready seconds before
// the renderer that draws it (pipelines compiling on a slow driver).
self.to_title_ms = STARTED.get().map(|s| s.elapsed().as_millis() as u32);
}
}
/// A world starts loading.
pub fn world_opening(&mut self) {
if self.opening.is_none() {
crate::spans::startup::arm("world.opening");
}
self.opening.get_or_insert_with(Instant::now);
}
@@ -262,10 +252,11 @@ impl Session {
/// A frame drawn while playing (not in menus or loading).
pub fn frame(&mut self, ms: f32) {
if let Some(opening) = self.opening.take()
&& self.to_world_ms.is_none()
{
self.to_world_ms = Some(opening.elapsed().as_millis() as u32);
if let Some(opening) = self.opening.take() {
crate::spans::startup::mark("world.first_frame");
if self.to_world_ms.is_none() {
self.to_world_ms = Some(opening.elapsed().as_millis() as u32);
}
}
self.frames.record(ms);
self.played_ms += ms;
+38 -4
View File
@@ -37,6 +37,9 @@ struct Globals {
// 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 sprites: texture_2d_array<f32>;
@@ -171,6 +174,8 @@ struct TerrainOut {
// How the face shines in HDR output (mesher.rs's `EMISSION_BITS`): 0 not
// at all, 1 to 4 the tier with its bright parts, 5 to 8 the tier all over.
@location(5) @interpolate(flat) emission: u32,
// HDR lighting: block and sky light, as levels 0 to 15.
@location(11) scene: vec2<f32>,
@location(6) @interpolate(flat) fade: u32,
// A distant tile's tile-relative position, for its fade (`faded`).
@location(7) local: vec3<f32>,
@@ -361,6 +366,22 @@ fn left_out() -> TerrainOut {
return terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
origin.w);
}
// HDR lighting: how far a surface's light carries it above paper white, 0 to 1. Only the
// strongest light does, so dark stays as dark as in SDR; sky light by how the face meets the sun.
fn hdr_scene(block: f32, sky: f32, facing: f32) -> f32 {
let b = smoothstep(11.0, 15.0, block);
let s = smoothstep(11.0, 15.0, sky) * globals.hdr_light.x * facing;
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;
}
// The face's normal, from the fragment's derivatives, for how it faces the sun.
var<private> hdr_normal: vec3<f32>;
fn hdr_facing() -> f32 {
return mix(0.45, 1.0, max(dot(hdr_normal, globals.sun.xyz), 0.0));
}
fn terrain_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>,
light: vec2<u32>, origin: vec3<f32>, w: f32) -> TerrainOut {
let pos = position + origin;
@@ -384,6 +405,7 @@ fn terrain_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f3
}
let block = max(f32(light.y), dynamic_block_light(pos));
out.color = vec4(color.rgb, 1.0) * sample_lightmap(f32(light.x) / 16.0, block / 16.0);
out.scene = vec2(block / 16.0, f32(light.x) / 16.0);
if globals.colored_count.x > 0u {
out.color = vec4(min(out.color.rgb + color.rgb * native_colored_light(pos), vec3(1.0)), out.color.a);
}
@@ -426,6 +448,7 @@ fn hdr_mark(tier: u32, y: f32) -> f32 {
}
// `fs_solid` for the opaque pass (`opaque_quad`): no fade, no cutout, no discard.
@fragment fn fs_opaque(in: TerrainOut) -> @location(0) vec4<f32> {
hdr_normal = normalize(cross(dpdy(in.local), dpdx(in.local)));
let texel = sprite(in.uv, in.layer & 0x7fffu);
var color = texel * in.color;
color = mix(globals.fog_color * vec4(1.0, 1.0, 1.0, color.a), color, in.visibility);
@@ -440,6 +463,7 @@ fn hdr_mark(tier: u32, y: f32) -> f32 {
fn solid_color(in: TerrainOut) -> vec4<f32> {
let dx = dpdx(in.local);
let dy = dpdy(in.local);
hdr_normal = normalize(cross(dy, dx));
if faded(in.fade, in.local, dx, dy) { discard; }
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
let texel = sprite(in.uv, in.layer & 0x7fffu);
@@ -456,15 +480,24 @@ fn solid_finish(in: TerrainOut, texel: vec4<f32>, color: vec4<f32>) -> vec4<f32>
// texel's own brightness, so its light level and face shading don't change
// it. Only pixels as bright as their sprite's threshold may: its bright
// parts, or all of it. Nothing else reads the frame's alpha.
var emissive = 1.0;
// Fog turns a far surface into the fog's colour: it takes the fog's light with it, or a
// fully fogged mountain would stand out from the fog behind it.
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;
var emissive = hdr_scene_alpha(mix(hdr_scene(in.scene.x, in.scene.y, hdr_facing()),
globals.hdr_light.y, fogged));
if in.emission > 0u {
// A light source's own face shines as one or stays as drawn, without the lighting.
emissive = 1.0;
let whole = in.emission > 4u;
let tier = select(in.emission, in.emission - 4u, whole);
let l = in.layer & 0x7fffu;
let t = textureLoad(emission_thresholds, vec2(i32(l % SPRITE_SPLIT), i32(l / SPRITE_SPLIT)), 0);
let y = brightness(texel.rgb);
if y >= select(t.r, t.g, whole) {
emissive = hdr_mark(tier, y);
// All over means all over: the sprite's range is one frame's, and an animated sprite's
// other frames dip below it. Fog hides a light source as it hides everything else.
if whole || y >= t.r {
emissive = hdr_mark(tier, y * (1.0 - fogged));
}
}
if in.emissive_layer != 0xffffffffu {
@@ -612,7 +645,8 @@ fn sky_along_ray(d: vec3<f32>) -> vec3<f32> {
return color;
}
@fragment fn fs_sky(in: SkyOut) -> @location(0) vec4<f32> {
return vec4(sky_along_ray(view_ray(in.ndc)), 1.0);
// `hdr_scene_alpha`, spelled out: this part of the file also goes into shader packs' skies.
return vec4(sky_along_ray(view_ray(in.ndc)), 1.0 - 0.24 * globals.hdr_light.y);
}
// The first-person hand's depth clear inside the world pass: `vs_sky`'s triangle lies at the
// far plane, written over the whole depth target with no colour.
+107 -8
View File
@@ -384,19 +384,42 @@ pub fn scrolling_text_at(
y: f32,
now_ms: i64,
color: u32,
) {
// `acceptScrollingWithDefaultCenter`: `centerX = (left + right) / 2`.
let center_x = left + ((right - left) / 2.0).floor();
scrolling_text_around_at(
batch, atlas, text, center_x, left, right, top, bottom, y, now_ms, color,
);
}
/// [`scrolling_text_at`] around an explicit `center_x`
/// (`ActiveTextCollector.acceptScrolling`, which `SpriteIconButton.TextAndIcon`
/// calls with the button's middle): a label that fits is centred on `center_x`
/// moved as far as it must to stay between `left` and `right`
/// (`Mth.clamp(centerX, left + lineWidth / 2, right - lineWidth / 2)`), so
/// beside an icon it ends where the icon's room begins. A wider one is drawn
/// from the left and slid inside the box.
#[allow(clippy::too_many_arguments)]
pub fn scrolling_text_around_at(
batch: &mut HudBatch,
atlas: &HudAtlas,
text: &Text,
center_x: f32,
left: f32,
right: f32,
top: f32,
bottom: f32,
y: f32,
now_ms: i64,
color: u32,
) {
let text = text.resolved();
let width = atlas.font.width_text(&text);
let room = right - left;
if width <= room {
batch.text_styled(
atlas,
&text,
centered_left(left, room, width),
y,
color,
true,
);
let half = (width / 2.0).floor();
let text_x = center_x.max(left + half).min(right - half);
batch.text_styled(atlas, &text, text_x - half, y, color, true);
return;
}
// Scissored to the widget: what does not fit is read as the label
@@ -440,6 +463,37 @@ pub fn scrolling_text(
);
}
/// `MenuTabBar.MenuTabButton.renderLabel` for the tab at `x, y`: its label
/// inside the tab less 1 px on each side, from the tab's top when selected
/// and 3 px lower when not (`acceptScrollingWithDefaultCenter`, so one wider
/// than the tab slides instead of running into the next tab). White, gray
/// for an inactive tab (`WithInactiveMessage`).
pub fn menu_tab_label(
batch: &mut HudBatch,
atlas: &HudAtlas,
label: &Text,
(x, y, width, height): (f32, f32, f32, f32),
selected: bool,
active: bool,
now_ms: i64,
) {
let top = y + if selected { 0.0 } else { 3.0 };
let bottom = y + height;
scrolling_text_at(
batch,
atlas,
label,
x + 1.0,
x + width - 1.0,
top,
bottom,
// defaultScrollingHelper: `(top + bottom - lineHeight) / 2 + 1`.
((top + bottom - 9.0) / 2.0).floor() + 1.0,
now_ms,
if active { u32::MAX } else { 0xFFA0_A0A0 },
);
}
/// Button (AbstractButton): nine-slice sprite plus the centred label.
pub fn button(
batch: &mut HudBatch,
@@ -1642,6 +1696,51 @@ mod tests {
);
}
/// `MenuTabButton.renderLabel` on a 24 px tab: centred on the tab, its
/// text top at `(0 + 24 - 9) / 2 + 1 = 8` when selected and at
/// `(3 + 24 - 9) / 2 + 1 = 10` when not; white on an active tab, selected
/// or not, `0xA0A0A0` on an inactive one (`WithInactiveMessage`). The
/// Statistics tabs drew every label at 8 and an active unselected one gray.
#[test]
fn a_tab_label_sits_lower_on_an_unselected_tab_and_is_gray_only_when_inactive() {
let atlas = atlas();
let label = Text::literal("Items");
let half = (atlas.font.width_text(&label.resolved()) / 2.0).floor();
// A 124 px tab at x = 100: `(left + right) / 2 = (101 + 223) / 2 = 162`.
let left = 162.0 - half;
for (selected, active, y, color) in [
(true, true, 8.0, u32::MAX),
(false, true, 10.0, u32::MAX),
(false, false, 10.0, 0xFFA0_A0A0),
] {
let mut tab = HudBatch::new([640, 480]);
menu_tab_label(
&mut tab,
&atlas,
&label,
(100.0, 0.0, 124.0, 24.0),
selected,
active,
0,
);
let mut want = HudBatch::new([640, 480]);
want.text_styled(&atlas, &label.resolved(), left, y, color, true);
let glyphs = |b: &HudBatch| -> Vec<([u32; 2], u32)> {
b.vertices
.iter()
.filter(|v| v.text == 1)
.map(|v| ([v.pos[0].to_bits(), v.pos[1].to_bits()], v.color))
.collect()
};
assert!(!glyphs(&want).is_empty());
assert_eq!(
glyphs(&tab),
glyphs(&want),
"selected {selected}, active {active}"
);
}
}
#[test]
fn slider_track_is_outlined_only_while_dragged() {
let (track, handle) = slider_sprites(true, true, false);
@@ -52,6 +52,8 @@ impl DirtySections {
}
}
pub struct World {
/// Server-owned mod data attached to this dimension.
pub attachments: horizon_world::attachments::Attachments,
/// The level's build range and skylight (its dimension type); its
/// chunks hold `shape.column()`.
pub shape: LevelShape,
@@ -200,6 +202,7 @@ impl World {
loaded_entities: Vec::new(),
loaded_chunks: Vec::new(),
modified: FxHashSet::default(),
attachments: Default::default(),
block_entities: Default::default(),
chest_openers: FxHashMap::default(),
block_names: FxHashMap::default(),
@@ -95,6 +95,7 @@
"UI Brightness": "GUI-Helligkeit",
"Battery Mode": "Akkumodus",
"Graphics API": "Grafik-API",
"Translucent Batching": "Transparenz-Batching",
"Performance Overlay": "Leistungsanzeige",
"Full Detail Distance": "Detailweite",
"On": "An",
@@ -142,14 +143,12 @@
"This feature is not yet available in Horizon.": "Diese Funktion ist in Horizon noch nicht verfügbar.",
"Your display or system doesn't offer HDR output.": "Dein Bildschirm oder System unterstützt keine HDR-Ausgabe.",
"Turn HDR on to use it.": "Schalte HDR ein, um es zu nutzen.",
"Your GPU or graphics API doesn't support it.": "Deine GPU oder Grafik-API unterstützt das nicht.",
"%s chunks": "%s Chunks",
"%s per frame": "%s pro Bild",
"%s MiB/frame": "%s MiB/Bild",
"%s KiB/frame": "%s KiB/Bild",
"%s nits": "%s Nits",
"Auto (%s)": "Auto (%s)",
"Auto (%s nits)": "Auto (%s Nits)",
"%.2f nits": "%.2f Nits",
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "Gelände mit vollen Details. Die LOD-Entfernung reicht weiter.",
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Grobes fernes Gelände in lokalen Welten. Integrierte GPUs: max. 128.",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Reduziert: fernes Gelände gröber, schneller auf dieser GPU.",
@@ -167,7 +166,6 @@
"How far mobs, crops and machines keep working. CPU time.": "Wie weit Kreaturen, Pflanzen und Maschinen weiterarbeiten. Kostet CPU-Zeit.",
"How far away mobs and items are drawn, as a share of normal.": "Zeichenweite für Kreaturen und Gegenstände, anteilig zur Norm.",
"Skips terrain hidden behind closed sections. Off costs a lot.": "Überspringt Gelände hinter geschlossenen Abschnitten. Aus kostet viel.",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Versteckt Modelle hinter Blöcken. Bei eigenen Paketen ausschalten.",
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Mesh-Abschnitte pro Bild. Weniger: flüssigeres Laden.",
"Soft shadows in corners. Costs chunk building time only.": "Weiche Schatten in Ecken. Kostet nur Zeit beim Chunk-Aufbau.",
"Smaller copies of block textures for distant terrain: less shimmer.": "Kleinere Textur-Kopien für fernes Gelände: weniger Flimmern.",
@@ -201,6 +199,7 @@
"60 FPS cap (30 in menus or unfocused), half the workers: less power.": "60 FPS (30 im Menü/ohne Fokus), halb so viele Worker: spart Strom.",
"Threads that generate, light and mesh chunks. Next start.": "Threads für Erzeugung, Licht und Mesh der Chunks. Nach Neustart.",
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan, sonst DirectX 12 (Windows) oder OpenGL (Linux). Nach Neustart.",
"Water and glass in one buffer: fewer draw calls, a bit more VRAM. Next start.": "Wasser und Glas in einem Puffer: weniger Draw-Calls, etwas mehr VRAM. Nach Neustart.",
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Pro Bild an die GPU gesendete Mesh-Daten. Weniger ist flüssiger beim Laden.",
"Frame rate and frame times over the game, with a graph.": "Bildrate und Bildzeiten über dem Spiel, mit Diagramm.",
"horizon.multiplayer.finding_friend": "Die Welt deines Freundes wird gesucht …",
@@ -289,5 +288,53 @@
"horizon.multiplayer.not_signed_in": "Dieser Server läuft im Online-Modus. Melde dich im Horizon-Launcher mit deinem Microsoft-Konto an, um ihm beizutreten.",
"horizon.multiplayer.invalid_address": "Ungültige Serveradresse: %s",
"horizon.multiplayer.connected_friend_direct": "Direkt mit deinem Freund verbunden …",
"horizon.multiplayer.connected_friend_relay": "Über das Relay mit deinem Freund verbunden …"
"horizon.multiplayer.connected_friend_relay": "Über das Relay mit deinem Freund verbunden …",
"Culling": "Culling",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Versteckt Modelle hinter Blöcken. Prüfung im Hintergrund; Objekte laufen weiter.",
"Leaf Culling": "Laub-Culling",
"Every leaf face. Vanilla's fancy leaves.": "Jede Blattfläche, wie Minecraft sie bei schönen Blättern zeichnet.",
"Distant Plant Dither": "Pflanzen-Dithering",
"Keeps fine plants in high-res packs from fading far away.": "Verhindert, dass feine Pflanzen in hochauflösenden Paketen in der Ferne verblassen.",
"Screenshot Quality": "Screenshot-Qualität",
"F2 screenshots at this many times the game's resolution. Higher takes longer.": "F2-Screenshots in diesem Vielfachen der Spielauflösung. Höher dauert länger.",
"Frame Generation": "Bildgenerierung",
"Shows a made-up frame between each two real ones: smoother, a little more lag.": "Zeigt zwischen zwei echten Bildern ein errechnetes: flüssiger, etwas mehr Verzögerung.",
"Shadow Cache": "Schatten-Cache",
"Reuses the pack shadow map when sun, view and world match. Off by default.": "Verwendet die Schattenkarte des Pakets erneut, wenn Sonne, Blick und Welt gleich bleiben. Standardmäßig aus.",
"Synchronous": "Synchron",
"Asynchronous": "Asynchron",
"Solid": "Massiv",
"Solid Aggressive": "Massiv, aggressiv",
"Hollow": "Hohl",
"Leaves fully inside a canopy draw solid: fewer see-through layers.": "Blätter mitten im Blätterdach werden massiv gezeichnet: weniger durchsichtige Schichten.",
"Leaves with one open side draw solid too: faster, chunkier edges.": "Auch Blätter mit einer offenen Seite werden massiv gezeichnet: schneller, gröbere Kanten.",
"Drops faces between leaves: faster, but gaps look through.": "Lässt Flächen zwischen Blättern weg: schneller, aber Lücken sind durchsichtig.",
"Hotbar: Previous Slot": "Schnellzugriffsleiste: Vorheriger Platz",
"Hotbar: Next Slot": "Schnellzugriffsleiste: Nächster Platz",
"horizon.agent.connected.one": "Verbunden: 1 Agentenverbindung",
"horizon.agent.connected.many": "Verbunden: %s Agentenverbindungen",
"horizon.agent.connected.one_ago": "Verbunden: 1 Agentenverbindung, letzter Befehl vor %s s",
"horizon.agent.connected.many_ago": "Verbunden: %s Agentenverbindungen, letzter Befehl vor %s s",
"horizon.agent.waiting": "Wartet auf einen Agenten.",
"horizon.agent.off": "Aus: Agenten können dieses Spiel nicht erreichen.",
"horizon.agent.session_opened": "Für diese Sitzung von dem Programm geöffnet, das das Spiel gestartet hat.",
"horizon.agent.socket": "Socket: %s (Verbindungsdaten kopieren für den vollständigen Pfad)",
"horizon.agent.local_only": "Nur lokal: Agenten auf diesem Computer, nie über das Netzwerk.",
"horizon.extras.chat_animation": "Weicher Chat",
"horizon.extras.chat_animation.tooltip": "Eine neue Chatzeile gleitet von links herein und blendet dabei ein, statt sofort ganz zu erscheinen.",
"horizon.extras.chat_indicator": "Nachrichtenmarkierung",
"horizon.extras.chat_indicator.tooltip": "Der farbige Balken links neben einer Chatzeile.",
"horizon.extras.header.chat": "Chat",
"horizon.extras.header.particles": "Partikel",
"horizon.extras.header.sound": "Ton",
"horizon.extras.hide_block_particles": "Blockpartikel weg",
"horizon.extras.hide_block_particles.tooltip": "Blendet die Trümmerpartikel aus, die aus einem zerstörten Block sprühen und von dem abbröckeln, den du abbaust. Andere Partikel bleiben.",
"horizon.extras.splitscreen": "Splitscreen",
"horizon.extras.splitscreen.tooltip": "In einer Welt kann ein weiterer Controller als neuer Spieler beitreten: A drücken, wenn gefragt, dann Profil und Spielort wählen.",
"horizon.extras.ui_sound_reserve": "Kanäle für UI-Töne",
"horizon.extras.ui_sound_reserve.tooltip": "Weltgeräusche lassen 16 der 247 Tonkanäle frei, damit eine Flut davon (eine große Explosion) Menü-Klicks nicht verstummen lässt.",
"horizon.pages.open.split_setup": "Splitscreen …",
"horizon.pages.split_settings": "Splitscreen-Einstellungen",
"horizon.pages.split_setup": "Splitscreen",
"horizon.pages.split_setup.tooltip": "Spieler an diesem Computer: Controller können deiner Welt beitreten."
}
@@ -152,10 +152,7 @@
"%s per frame": "%s per frame",
"%s MiB/frame": "%s MiB/frame",
"%s KiB/frame": "%s KiB/frame",
"%s nits": "%s nits",
"Auto (%s)": "Auto (%s)",
"Auto (%s nits)": "Auto (%s nits)",
"%.2f nits": "%.2f nits",
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "Full-detail terrain range. LOD Distance can extend local worlds farther.",
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Reduced draws far terrain coarser: faster on this integrated GPU.",
@@ -173,7 +170,6 @@
"How far mobs, crops and machines keep working. CPU time.": "How far mobs, crops and machines keep working. CPU time.",
"How far away mobs and items are drawn, as a share of normal.": "How far away mobs and items are drawn, as a share of normal.",
"Skips terrain hidden behind closed sections. Off costs a lot.": "Skips terrain hidden behind closed sections. Off costs a lot.",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Hides models behind opaque blocks. Entities update. Off with custom packs.",
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Meshed sections uploaded per frame. Lower is smoother while loading.",
"Soft shadows in corners. Costs chunk building time only.": "Soft shadows in corners. Costs chunk building time only.",
"Smaller copies of block textures for distant terrain: less shimmer.": "Smaller copies of block textures for distant terrain: less shimmer.",
@@ -309,5 +305,36 @@
"horizon.osk.close": "Close",
"horizon.extras.chat_animation.tooltip": "A new chat line slides in from the left while it fades in, instead of appearing whole.",
"horizon.extras.chat_indicator.tooltip": "The coloured bar at the left of a chat line.",
"horizon.extras.splitscreen.tooltip": "In a world, another controller can join as a new player: press A when it asks, then pick a profile and where to play."
"horizon.extras.splitscreen.tooltip": "In a world, another controller can join as a new player: press A when it asks, then pick a profile and where to play.",
"Culling": "Culling",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Hides models behind opaque blocks. Async checks off-thread; entities tick.",
"Leaf Culling": "Leaf Culling",
"Every leaf face. Vanilla's fancy leaves.": "Every leaf face. Vanilla's fancy leaves.",
"Distant Plant Dither": "Distant Plant Dither",
"Keeps fine plants in high-res packs from fading far away.": "Keeps fine plants in high-res packs from fading far away.",
"Screenshot Quality": "Screenshot Quality",
"F2 screenshots at this many times the game's resolution. Higher takes longer.": "F2 screenshots at this many times the game's resolution. Higher takes longer.",
"Frame Generation": "Frame Generation",
"Shows a made-up frame between each two real ones: smoother, a little more lag.": "Shows a made-up frame between each two real ones: smoother, a little more lag.",
"Shadow Cache": "Shadow Cache",
"Reuses the pack shadow map when sun, view and world match. Off by default.": "Reuses the pack shadow map when sun, view and world match. Off by default.",
"Synchronous": "Synchronous",
"Asynchronous": "Asynchronous",
"Solid": "Solid",
"Solid Aggressive": "Solid Aggressive",
"Hollow": "Hollow",
"Leaves fully inside a canopy draw solid: fewer see-through layers.": "Leaves fully inside a canopy draw solid: fewer see-through layers.",
"Leaves with one open side draw solid too: faster, chunkier edges.": "Leaves with one open side draw solid too: faster, chunkier edges.",
"Drops faces between leaves: faster, but gaps look through.": "Drops faces between leaves: faster, but gaps look through.",
"Hotbar: Previous Slot": "Hotbar: Previous Slot",
"Hotbar: Next Slot": "Hotbar: Next Slot",
"horizon.agent.connected.one": "Connected: 1 agent connection",
"horizon.agent.connected.many": "Connected: %s agent connections",
"horizon.agent.connected.one_ago": "Connected: 1 agent connection, last command %s s ago",
"horizon.agent.connected.many_ago": "Connected: %s agent connections, last command %s s ago",
"horizon.agent.waiting": "Waiting for an agent.",
"horizon.agent.off": "Off: agents can't reach this game.",
"horizon.agent.session_opened": "Opened for this session by the program that started the game.",
"horizon.agent.socket": "Socket: %s (copy the details for its full path)",
"horizon.agent.local_only": "Local only: agents on this computer, never the network."
}
@@ -140,10 +140,7 @@
"%s per frame": "%s por fotograma",
"%s MiB/frame": "%s MiB/fotograma",
"%s KiB/frame": "%s KiB/fotograma",
"%s nits": "%s nits",
"Auto (%s)": "Automático (%s)",
"Auto (%s nits)": "Automático (%s nits)",
"%.2f nits": "%.2f nits",
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "Alcance del terreno con todo el detalle. La distancia LOD puede extender más los mundos locales.",
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Terreno lejano de baja resolución en mundos locales. Las GPU integradas se limitan a 128 chunks.",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Reducido dibuja el terreno lejano con menos detalle: más rápido en esta GPU integrada.",
@@ -161,7 +158,6 @@
"How far mobs, crops and machines keep working. CPU time.": "Hasta dónde siguen funcionando mobs, cultivos y máquinas. Tiempo de CPU.",
"How far away mobs and items are drawn, as a share of normal.": "A qué distancia se dibujan mobs y objetos, como proporción de lo normal.",
"Skips terrain hidden behind closed sections. Off costs a lot.": "Omite el terreno oculto tras secciones cerradas. Desactivarlo cuesta mucho.",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Oculta modelos tras bloques opacos. Las entidades se actualizan. Desactivado con paquetes personalizados.",
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Secciones malladas subidas por fotograma. Menos es más fluido durante la carga.",
"Soft shadows in corners. Costs chunk building time only.": "Sombras suaves en las esquinas. Solo cuesta tiempo de construcción de chunks.",
"Smaller copies of block textures for distant terrain: less shimmer.": "Copias más pequeñas de las texturas de bloques para el terreno lejano: menos parpadeo.",
@@ -95,6 +95,7 @@
"UI Brightness": "Luminosité UI",
"Battery Mode": "Mode batterie",
"Graphics API": "API graphique",
"Translucent Batching": "Regroupement translucide",
"Performance Overlay": "Infos de perfs",
"Full Detail Distance": "Dist. détail",
"On": "Oui",
@@ -142,14 +143,12 @@
"This feature is not yet available in Horizon.": "Cette fonction n'est pas encore disponible dans Horizon.",
"Your display or system doesn't offer HDR output.": "Votre écran ou votre système ne propose pas de sortie HDR.",
"Turn HDR on to use it.": "Activez le HDR pour l'utiliser.",
"Your GPU or graphics API doesn't support it.": "Votre GPU ou votre API graphique ne le prend pas en charge.",
"%s chunks": "%s chunks",
"%s per frame": "%s par image",
"%s MiB/frame": "%s MiB/image",
"%s KiB/frame": "%s KiB/image",
"%s nits": "%s nits",
"Auto (%s)": "Auto (%s)",
"Auto (%s nits)": "Auto (%s nits)",
"%.2f nits": "%.2f nits",
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "Terrain en détail complet. La distance LOD va plus loin.",
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Terrain lointain simplifié, mondes locaux. GPU intégrés : 128 max.",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Réduit : terrain lointain simplifié, plus rapide sur ce GPU.",
@@ -167,7 +166,6 @@
"How far mobs, crops and machines keep working. CPU time.": "Portée d'activité des créatures, cultures, machines. Coût : CPU.",
"How far away mobs and items are drawn, as a share of normal.": "Distance des créatures et objets, en part de la normale.",
"Skips terrain hidden behind closed sections. Off costs a lot.": "Ignore le terrain caché. Désactiver coûte cher.",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Masque les modèles derrière les blocs. Éteindre avec packs.",
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Sections maillées par image. Moins : chargement plus fluide.",
"Soft shadows in corners. Costs chunk building time only.": "Ombres douces dans les coins. Coût : construction des chunks.",
"Smaller copies of block textures for distant terrain: less shimmer.": "Textures réduites au loin : moins de scintillement.",
@@ -201,6 +199,7 @@
"60 FPS cap (30 in menus or unfocused), half the workers: less power.": "60 FPS max (30 en menu/sans focus), moitié de threads : économie.",
"Threads that generate, light and mesh chunks. Next start.": "Threads de génération, lumière et maillage. Au redémarrage.",
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan, sinon DirectX 12 (Windows) ou OpenGL (Linux). Au redémarrage.",
"Water and glass in one buffer: fewer draw calls, a bit more VRAM. Next start.": "Eau et verre dans un seul tampon : moins d'appels de dessin, un peu plus de VRAM. Au redémarrage.",
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Données de maillage vers le GPU par image. Moins : plus fluide.",
"Frame rate and frame times over the game, with a graph.": "Fréquence et temps d'image par-dessus le jeu, avec un graphique.",
"horizon.multiplayer.finding_friend": "Recherche du monde de votre ami...",
@@ -289,5 +288,53 @@
"horizon.multiplayer.not_signed_in": "Ce serveur est en mode en ligne. Connectez-vous avec votre compte Microsoft dans le lanceur Horizon pour le rejoindre.",
"horizon.multiplayer.invalid_address": "Adresse de serveur invalide : %s",
"horizon.multiplayer.connected_friend_direct": "Connecté directement à votre ami...",
"horizon.multiplayer.connected_friend_relay": "Connecté à votre ami via le relais..."
"horizon.multiplayer.connected_friend_relay": "Connecté à votre ami via le relais...",
"Culling": "Occlusion",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Masque les modèles derrière les blocs opaques. Vérification en arrière-plan ; les entités continuent.",
"Leaf Culling": "Occlusion feuillage",
"Every leaf face. Vanilla's fancy leaves.": "Toutes les faces de feuillage, comme les feuilles détaillées de Minecraft.",
"Distant Plant Dither": "Tramage des plantes",
"Keeps fine plants in high-res packs from fading far away.": "Empêche les plantes fines des packs haute résolution de s'estomper au loin.",
"Screenshot Quality": "Qualité des captures",
"F2 screenshots at this many times the game's resolution. Higher takes longer.": "Captures F2 à ce multiple de la résolution du jeu. Plus c'est haut, plus c'est long.",
"Frame Generation": "Génération d'images",
"Shows a made-up frame between each two real ones: smoother, a little more lag.": "Insère une image calculée entre deux vraies : plus fluide, un peu plus de latence.",
"Shadow Cache": "Cache des ombres",
"Reuses the pack shadow map when sun, view and world match. Off by default.": "Réutilise la carte d'ombres du pack si soleil, vue et monde sont identiques. Désactivé par défaut.",
"Synchronous": "Synchrone",
"Asynchronous": "Asynchrone",
"Solid": "Opaque",
"Solid Aggressive": "Opaque agressif",
"Hollow": "Creux",
"Leaves fully inside a canopy draw solid: fewer see-through layers.": "Les feuilles au cœur d'un feuillage sont opaques : moins de couches transparentes.",
"Leaves with one open side draw solid too: faster, chunkier edges.": "Les feuilles avec un côté ouvert sont aussi opaques : plus rapide, bords plus grossiers.",
"Drops faces between leaves: faster, but gaps look through.": "Supprime les faces entre feuilles : plus rapide, mais on voit à travers les trous.",
"Hotbar: Previous Slot": "Barre d'action : case précédente",
"Hotbar: Next Slot": "Barre d'action : case suivante",
"horizon.agent.connected.one": "Connecté : 1 connexion d'agent",
"horizon.agent.connected.many": "Connecté : %s connexions d'agent",
"horizon.agent.connected.one_ago": "Connecté : 1 connexion d'agent, dernière commande il y a %s s",
"horizon.agent.connected.many_ago": "Connecté : %s connexions d'agent, dernière commande il y a %s s",
"horizon.agent.waiting": "En attente d'un agent.",
"horizon.agent.off": "Désactivé : les agents ne peuvent pas joindre ce jeu.",
"horizon.agent.session_opened": "Ouvert pour cette session par le programme qui a lancé le jeu.",
"horizon.agent.socket": "Socket : %s (copiez les détails pour le chemin complet)",
"horizon.agent.local_only": "Local uniquement : agents sur cet ordinateur, jamais via le réseau.",
"horizon.extras.chat_animation": "Chat fluide",
"horizon.extras.chat_animation.tooltip": "Une nouvelle ligne de chat glisse depuis la gauche en apparaissant progressivement, au lieu d'apparaître d'un coup.",
"horizon.extras.chat_indicator": "Marqueur de message",
"horizon.extras.chat_indicator.tooltip": "La barre colorée à gauche d'une ligne de chat.",
"horizon.extras.header.chat": "Chat",
"horizon.extras.header.particles": "Particules",
"horizon.extras.header.sound": "Son",
"horizon.extras.hide_block_particles": "Masquer particules",
"horizon.extras.hide_block_particles.tooltip": "Masque les débris qui jaillissent d'un bloc cassé et s'effritent de celui que vous minez. Les autres particules restent.",
"horizon.extras.splitscreen": "Écran partagé",
"horizon.extras.splitscreen.tooltip": "Dans un monde, une autre manette peut rejoindre en tant que nouveau joueur : appuyez sur A quand c'est demandé, puis choisissez un profil et où jouer.",
"horizon.extras.ui_sound_reserve": "Canaux pour sons UI",
"horizon.extras.ui_sound_reserve.tooltip": "Les sons du monde laissent 16 des 247 canaux sonores libres, pour qu'un déluge de sons (une grosse explosion) ne couvre pas les clics de menu.",
"horizon.pages.open.split_setup": "Écran partagé...",
"horizon.pages.split_settings": "Options de l'écran partagé",
"horizon.pages.split_setup": "Écran partagé",
"horizon.pages.split_setup.tooltip": "Joueurs sur cet ordinateur : des manettes peuvent rejoindre votre monde."
}
@@ -95,6 +95,7 @@
"UI Brightness": "Jasność interfejsu",
"Battery Mode": "Tryb baterii",
"Graphics API": "API grafiki",
"Translucent Batching": "Grupowanie półprzezroczystych",
"Performance Overlay": "Nakładka wydajności",
"Full Detail Distance": "Zasięg detali",
"On": "Wł.",
@@ -142,14 +143,12 @@
"This feature is not yet available in Horizon.": "Ta funkcja nie jest jeszcze dostępna w Horizon.",
"Your display or system doesn't offer HDR output.": "Twój wyświetlacz lub system nie obsługuje wyjścia HDR.",
"Turn HDR on to use it.": "Włącz HDR, aby tego użyć.",
"Your GPU or graphics API doesn't support it.": "Twoje GPU lub API graficzne tego nie obsługuje.",
"%s chunks": "%s chunków",
"%s per frame": "%s na klatkę",
"%s MiB/frame": "%s MiB/klatka",
"%s KiB/frame": "%s KiB/klatka",
"%s nits": "%s nitów",
"Auto (%s)": "Auto (%s)",
"Auto (%s nits)": "Auto (%s nitów)",
"%.2f nits": "%.2f nitów",
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "Zasięg terenu w pełnych szczegółach. Zasięg LOD sięga dalej.",
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Uproszczony odległy teren w światach lokalnych. Zintegr. GPU: do 128.",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Ograniczone: odległy teren rysowany prościej, szybciej na tym GPU.",
@@ -167,7 +166,6 @@
"How far mobs, crops and machines keep working. CPU time.": "Jak daleko stworzenia, uprawy i maszyny nadal działają. Czas procesora.",
"How far away mobs and items are drawn, as a share of normal.": "Odległość rysowania stworzeń i przedmiotów, jako ułamek normy.",
"Skips terrain hidden behind closed sections. Off costs a lot.": "Pomija teren ukryty za zamkniętymi sekcjami. Wyłączone dużo kosztuje.",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Ukrywa modele za pełnymi blokami. Wył. przy własnych paczkach.",
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Liczba sekcji siatki wysyłanych na klatkę. Mniej to płynniej podczas ładowania.",
"Soft shadows in corners. Costs chunk building time only.": "Miękkie cienie w narożnikach. Kosztuje tylko czas budowania chunków.",
"Smaller copies of block textures for distant terrain: less shimmer.": "Mniejsze kopie tekstur bloków dla odległego terenu: mniej migotania.",
@@ -201,6 +199,7 @@
"60 FPS cap (30 in menus or unfocused), half the workers: less power.": "Limit 60 FPS (30 w menu/bez fokusu), połowa wątków: mniej energii.",
"Threads that generate, light and mesh chunks. Next start.": "Wątki generujące, oświetlające i meshujące chunki. Po restarcie.",
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan, awaryjnie DirectX 12 (Windows) / OpenGL (Linux). Po restarcie.",
"Water and glass in one buffer: fewer draw calls, a bit more VRAM. Next start.": "Woda i szkło w jednym buforze: mniej wywołań rysowania, trochę więcej VRAM. Po restarcie.",
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Dane siatki wysyłane do GPU na klatkę. Mniej to płynniej podczas ładowania.",
"Frame rate and frame times over the game, with a graph.": "Liczba klatek i czasy klatek nad grą, z wykresem.",
"horizon.multiplayer.finding_friend": "Szukanie świata znajomego…",
@@ -289,5 +288,53 @@
"horizon.osk.delete": "Usuń",
"horizon.osk.type": "Wpisz",
"horizon.osk.symbols": "Symbole",
"horizon.osk.close": "Zamknij"
"horizon.osk.close": "Zamknij",
"Culling": "Pomijanie",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Ukrywa modele za pełnymi blokami. Sprawdzanie w tle; byty nadal się aktualizują.",
"Leaf Culling": "Pomijanie liści",
"Every leaf face. Vanilla's fancy leaves.": "Każda ściana liści, tak jak rysuje je Minecraft.",
"Distant Plant Dither": "Dithering roślin",
"Keeps fine plants in high-res packs from fading far away.": "Zapobiega zanikaniu drobnych roślin w paczkach wysokiej rozdzielczości w oddali.",
"Screenshot Quality": "Jakość zrzutów ekranu",
"F2 screenshots at this many times the game's resolution. Higher takes longer.": "Zrzuty F2 w tylokrotności rozdzielczości gry. Więcej trwa dłużej.",
"Frame Generation": "Generowanie klatek",
"Shows a made-up frame between each two real ones: smoother, a little more lag.": "Pokazuje wygenerowaną klatkę między każdymi dwiema prawdziwymi: płynniej, trochę większe opóźnienie.",
"Shadow Cache": "Pamięć cieni",
"Reuses the pack shadow map when sun, view and world match. Off by default.": "Używa ponownie mapy cieni paczki, gdy słońce, widok i świat się zgadzają. Domyślnie wyłączone.",
"Synchronous": "Synchroniczne",
"Asynchronous": "Asynchroniczne",
"Solid": "Pełne",
"Solid Aggressive": "Pełne, agresywnie",
"Hollow": "Puste",
"Leaves fully inside a canopy draw solid: fewer see-through layers.": "Liście w środku korony rysują się jako pełne: mniej przezroczystych warstw.",
"Leaves with one open side draw solid too: faster, chunkier edges.": "Liście z jedną otwartą stroną też rysują się jako pełne: szybciej, grubsze krawędzie.",
"Drops faces between leaves: faster, but gaps look through.": "Pomija ściany między liśćmi: szybciej, ale luki są przezroczyste.",
"Hotbar: Previous Slot": "Pasek szybkiego wyboru: poprzednie pole",
"Hotbar: Next Slot": "Pasek szybkiego wyboru: następne pole",
"horizon.agent.connected.one": "Połączono: 1 połączenie agenta",
"horizon.agent.connected.many": "Połączenia agenta: %s",
"horizon.agent.connected.one_ago": "Połączono: 1 połączenie agenta, ostatnie polecenie %s s temu",
"horizon.agent.connected.many_ago": "Połączenia agenta: %s, ostatnie polecenie %s s temu",
"horizon.agent.waiting": "Czeka na agenta.",
"horizon.agent.off": "Wyłączone: agenci nie mają dostępu do tej gry.",
"horizon.agent.session_opened": "Otwarte na tę sesję przez program, który uruchomił grę.",
"horizon.agent.socket": "Gniazdo: %s (skopiuj dane, aby poznać pełną ścieżkę)",
"horizon.agent.local_only": "Tylko lokalnie: agenci na tym komputerze, nigdy przez sieć.",
"horizon.extras.chat_animation": "Płynny czat",
"horizon.extras.chat_animation.tooltip": "Nowa linia czatu wsuwa się z lewej i pojawia stopniowo, zamiast wyświetlać się od razu w całości.",
"horizon.extras.chat_indicator": "Znacznik wiadomości",
"horizon.extras.chat_indicator.tooltip": "Kolorowy pasek po lewej stronie linii czatu.",
"horizon.extras.header.chat": "Czat",
"horizon.extras.header.particles": "Cząsteczki",
"horizon.extras.header.sound": "Dźwięk",
"horizon.extras.hide_block_particles": "Ukryj cząstki bloków",
"horizon.extras.hide_block_particles.tooltip": "Ukrywa cząsteczki odłamków, które tryskają ze zniszczonego bloku i kruszą się z tego, który kopiesz. Inne cząsteczki zostają.",
"horizon.extras.splitscreen": "Podzielony ekran",
"horizon.extras.splitscreen.tooltip": "W świecie może dołączyć kolejny kontroler jako nowy gracz: naciśnij A, gdy zapyta, potem wybierz profil i miejsce gry.",
"horizon.extras.ui_sound_reserve": "Kanały dla dźwięków UI",
"horizon.extras.ui_sound_reserve.tooltip": "Dźwięki świata zostawiają wolnych 16 z 247 kanałów, by ich nawał (duży wybuch) nie wyciszył kliknięć w menu.",
"horizon.pages.open.split_setup": "Podzielony ekran…",
"horizon.pages.split_settings": "Ustawienia podzielonego ekranu",
"horizon.pages.split_setup": "Podzielony ekran",
"horizon.pages.split_setup.tooltip": "Gracze na tym komputerze: kontrolery mogą dołączyć do twojego świata."
}
@@ -139,10 +139,7 @@
"%s per frame": "%s за кадр",
"%s MiB/frame": "%s МиБ/кадр",
"%s KiB/frame": "%s КиБ/кадр",
"%s nits": "%s нит",
"Auto (%s)": "Авто (%s)",
"Auto (%s nits)": "Авто (%s нит)",
"%.2f nits": "%.2f нит",
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "Дальность прорисованного рельефа в деталях. Дистанция LOD может продлить её для локальных миров.",
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Грубый дальний рельеф в локальных мирах. На встроенной графике — не дальше 128 чанков.",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "«Снижено» рисует дальний рельеф грубее: на этой встроенной видеокарте быстрее.",
@@ -160,7 +157,6 @@
"How far mobs, crops and machines keep working. CPU time.": "Как далеко мобы, посевы и механизмы продолжают работать. Нагрузка на процессор.",
"How far away mobs and items are drawn, as a share of normal.": "С какой дистанции рисуются мобы и предметы, в долях от обычной.",
"Skips terrain hidden behind closed sections. Off costs a lot.": "Пропускает рельеф, скрытый за закрытыми секциями. Выключено — дорого.",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Прячет модели за непрозрачными блоками. Сущности тикают. Выключать со своими наборами.",
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Секций меша за кадр. Меньше — плавнее во время загрузки.",
"Soft shadows in corners. Costs chunk building time only.": "Мягкие тени по углам. Замедляет только построение чанков.",
"Smaller copies of block textures for distant terrain: less shimmer.": "Уменьшенные копии текстур блоков для дали: меньше мерцания.",
@@ -140,10 +140,7 @@
"%s per frame": "Kare başına %s",
"%s MiB/frame": "Kare başına %s MiB",
"%s KiB/frame": "Kare başına %s KiB",
"%s nits": "%s nit",
"Auto (%s)": "Otomatik (%s)",
"Auto (%s nits)": "Otomatik (%s nit)",
"%.2f nits": "%.2f nit",
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "Arazinin tam ayrıntıyla görüntülendiği mesafe. LOD Mesafesi, yerel dünyalarda bu mesafeyi daha da artırabilir.",
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Yerel dünyalarda uzaktaki arazileri daha düşük ayrıntıyla görüntüler. Tümleşik GPU'larda sınır 128 parçadır.",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Düşük ayarı, uzak arazileri daha düşük ayrıntıyla görüntüler. Bu tümleşik GPU'da daha yüksek performans sağlar.",
@@ -161,7 +158,6 @@
"How far mobs, crops and machines keep working. CPU time.": "Yaratıkların, ekinlerin ve makinelerin ne kadar uzaktan çalışmaya devam edeceğini belirler. İşlemci gücü kullanır.",
"How far away mobs and items are drawn, as a share of normal.": "Yaratıkların ve eşyaların ne kadar uzaktan görüntüleneceğini, normal mesafeye oranla belirler.",
"Skips terrain hidden behind closed sections. Off costs a lot.": "Kapalı alanların arkasında kalan arazilerin çizilmesini önler. Kapalı olması ciddi performans kaybına neden olur.",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Opak blokların arkasında kalan modelleri gizler. Varlıkların güncellemeleri devam eder. Özel paketler kullanırken kapatın.",
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Her karede GPU'ya yüklenen oluşturulmuş bölüm sayısı. Daha düşük değerler yükleme sırasında daha akıcı bir deneyim sağlar.",
"Soft shadows in corners. Costs chunk building time only.": "Köşelerde yumuşak gölgeler oluşturur. Yalnızca parça oluşturma süresini etkiler.",
"Smaller copies of block textures for distant terrain: less shimmer.": "Uzak arazilerde blok dokularının daha küçük kopyalarını kullanır. Titreşimli görüntüyü azaltır.",
@@ -95,6 +95,7 @@
"UI Brightness": "Яскравість UI",
"Battery Mode": "Режим батареї",
"Graphics API": "API графіки",
"Translucent Batching": "Групування напівпрозорого",
"Performance Overlay": "Накладка швидкодії",
"Full Detail Distance": "Дальність деталей",
"On": "Увімк.",
@@ -142,14 +143,12 @@
"This feature is not yet available in Horizon.": "Ця функція ще недоступна в Horizon.",
"Your display or system doesn't offer HDR output.": "Ваш дисплей або система не підтримує вивід HDR.",
"Turn HDR on to use it.": "Увімкніть HDR, щоб користуватися цим.",
"Your GPU or graphics API doesn't support it.": "Ваш GPU або графічний API цього не підтримує.",
"%s chunks": "%s чанків",
"%s per frame": "%s на кадр",
"%s MiB/frame": "%s MiB/кадр",
"%s KiB/frame": "%s KiB/кадр",
"%s nits": "%s ніт",
"Auto (%s)": "Авто (%s)",
"Auto (%s nits)": "Авто (%s ніт)",
"%.2f nits": "%.2f ніт",
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "Дальність повних деталей. Дальність LOD сягає далі.",
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Спрощена далека місцевість локальних світів. Інтегр. GPU: до 128.",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Знижена: далека місцевість простіша, швидше на цьому GPU.",
@@ -167,7 +166,6 @@
"How far mobs, crops and machines keep working. CPU time.": "Як далеко працюють моби, посіви й механізми. Витрачає CPU.",
"How far away mobs and items are drawn, as a share of normal.": "Відстань малювання мобів і предметів, у частці від норми.",
"Skips terrain hidden behind closed sections. Off costs a lot.": "Пропускає приховану місцевість. Вимкнення коштує дорого.",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Ховає моделі за блоками. Вимкніть із власними пакетами.",
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Секцій сітки за кадр. Менше — плавніше під час завантаження.",
"Soft shadows in corners. Costs chunk building time only.": "М’які тіні в кутах. Витрачає лише час на побудову чанків.",
"Smaller copies of block textures for distant terrain: less shimmer.": "Менші копії текстур блоків для віддаленої місцевості: менше мерехтіння.",
@@ -201,6 +199,7 @@
"60 FPS cap (30 in menus or unfocused), half the workers: less power.": "60 FPS (30 у меню/без фокуса), половина потоків: економія.",
"Threads that generate, light and mesh chunks. Next start.": "Потоки генерації, світла й сітки чанків. Після перезапуску.",
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan, інакше DirectX 12 (Windows)/OpenGL (Linux). Після рестарту.",
"Water and glass in one buffer: fewer draw calls, a bit more VRAM. Next start.": "Вода і скло в одному буфері: менше викликів малювання, трохи більше VRAM. Після перезапуску.",
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Дані сітки до GPU за кадр. Менше — плавніше завантаження.",
"Frame rate and frame times over the game, with a graph.": "Частота та час кадрів поверх гри, з графіком.",
"horizon.multiplayer.finding_friend": "Шукаємо світ друга…",
@@ -289,5 +288,53 @@
"horizon.osk.delete": "Видалити",
"horizon.osk.type": "Ввести",
"horizon.osk.symbols": "Символи",
"horizon.osk.close": "Закрити"
"horizon.osk.close": "Закрити",
"Culling": "Відсікання",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Ховає моделі за непрозорими блоками. Перевірка у фоні; сутності оновлюються.",
"Leaf Culling": "Відсікання листя",
"Every leaf face. Vanilla's fancy leaves.": "Кожна грань листя, як це робить Minecraft у гарній графіці.",
"Distant Plant Dither": "Дизеринг рослин",
"Keeps fine plants in high-res packs from fading far away.": "Не дає дрібним рослинам у пакетах високої роздільності зникати здалеку.",
"Screenshot Quality": "Якість знімків екрана",
"F2 screenshots at this many times the game's resolution. Higher takes longer.": "Знімки F2 у стільки разів більшій роздільності, ніж гра. Більше значить довше.",
"Frame Generation": "Генерація кадрів",
"Shows a made-up frame between each two real ones: smoother, a little more lag.": "Показує згенерований кадр між кожними двома справжніми: плавніше, трохи більша затримка.",
"Shadow Cache": "Кеш тіней",
"Reuses the pack shadow map when sun, view and world match. Off by default.": "Повторно використовує карту тіней пакета, коли сонце, вигляд і світ збігаються. Типово вимкнено.",
"Synchronous": "Синхронно",
"Asynchronous": "Асинхронно",
"Solid": "Суцільне",
"Solid Aggressive": "Суцільне, агресивно",
"Hollow": "Порожнє",
"Leaves fully inside a canopy draw solid: fewer see-through layers.": "Листя всередині крони малюється суцільним: менше прозорих шарів.",
"Leaves with one open side draw solid too: faster, chunkier edges.": "Листя з одним відкритим боком теж малюється суцільним: швидше, грубіші краї.",
"Drops faces between leaves: faster, but gaps look through.": "Прибирає грані між листям: швидше, але прогалини прозорі.",
"Hotbar: Previous Slot": "Панель швидкого доступу: попередній слот",
"Hotbar: Next Slot": "Панель швидкого доступу: наступний слот",
"horizon.agent.connected.one": "Підключено: 1 з’єднання агента",
"horizon.agent.connected.many": "Підключено з’єднань агента: %s",
"horizon.agent.connected.one_ago": "Підключено: 1 з’єднання агента, остання команда %s с тому",
"horizon.agent.connected.many_ago": "Підключено з’єднань агента: %s, остання команда %s с тому",
"horizon.agent.waiting": "Очікування агента.",
"horizon.agent.off": "Вимкнено: агенти не можуть дістатися цієї гри.",
"horizon.agent.session_opened": "Відкрито на цей сеанс програмою, що запустила гру.",
"horizon.agent.socket": "Сокет: %s (скопіюйте дані, щоб побачити повний шлях)",
"horizon.agent.local_only": "Лише локально: агенти на цьому комп’ютері, ніколи через мережу.",
"horizon.extras.chat_animation": "Плавний чат",
"horizon.extras.chat_animation.tooltip": "Новий рядок чату виїжджає зліва, поступово з’являючись, замість того щоб з’явитися одразу.",
"horizon.extras.chat_indicator": "Позначка повідомлення",
"horizon.extras.chat_indicator.tooltip": "Кольорова смужка ліворуч від рядка чату.",
"horizon.extras.header.chat": "Чат",
"horizon.extras.header.particles": "Частинки",
"horizon.extras.header.sound": "Звук",
"horizon.extras.hide_block_particles": "Без частинок блоків",
"horizon.extras.hide_block_particles.tooltip": "Ховає частинки уламків, що розлітаються зі зламаного блока та осипаються з того, який ви ламаєте. Інші частинки залишаються.",
"horizon.extras.splitscreen": "Розділений екран",
"horizon.extras.splitscreen.tooltip": "У світі може долучитися ще один геймпад як новий гравець: натисніть A, коли запитає, потім виберіть профіль і де грати.",
"horizon.extras.ui_sound_reserve": "Канали для звуків UI",
"horizon.extras.ui_sound_reserve.tooltip": "Звуки у світі залишають вільними 16 із 247 звукових каналів, щоб їхній потік (великий вибух) не заглушив кліки в меню.",
"horizon.pages.open.split_setup": "Розділений екран…",
"horizon.pages.split_settings": "Налаштування розділеного екрана",
"horizon.pages.split_setup": "Розділений екран",
"horizon.pages.split_setup.tooltip": "Гравці на цьому комп’ютері: геймпади можуть долучитися до вашого світу."
}
@@ -89,6 +89,7 @@
"UI Brightness": "界面亮度",
"Battery Mode": "电池模式",
"Graphics API": "图形 API",
"Translucent Batching": "半透明批处理",
"Performance Overlay": "性能叠加层",
"Full Detail Distance": "全细节距离",
"On": "开",
@@ -136,14 +137,12 @@
"This feature is not yet available in Horizon.": "此功能在 Horizon 中尚不可用。",
"Your display or system doesn't offer HDR output.": "你的显示器或系统不支持 HDR 输出。",
"Turn HDR on to use it.": "开启 HDR 后才能使用。",
"Your GPU or graphics API doesn't support it.": "你的 GPU 或图形 API 不支持此功能。",
"%s chunks": "%s 个区块",
"%s per frame": "每帧 %s",
"%s MiB/frame": "每帧 %s MiB",
"%s KiB/frame": "每帧 %s KiB",
"%s nits": "%s 尼特",
"Auto (%s)": "自动(%s)",
"Auto (%s nits)": "自动(%s 尼特)",
"%.2f nits": "%.2f 尼特",
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "完整细节的地形范围。LOD 距离可以让本地世界延伸得更远。",
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "本地世界的远景粗略地形。集成显卡最多 128 个区块。",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "“精简”把远景画得更粗略:在这块集成显卡上更快。",
@@ -161,7 +160,6 @@
"How far mobs, crops and machines keep working. CPU time.": "生物、农作物和机器持续工作的距离。占用 CPU 时间。",
"How far away mobs and items are drawn, as a share of normal.": "生物和物品的绘制距离,按正常值的比例。",
"Skips terrain hidden behind closed sections. Off costs a lot.": "跳过被封闭区块遮挡的地形。关闭的开销很大。",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "隐藏不透明方块后面的模型。实体仍会更新。自定义资源包时应关闭。",
"Meshed sections uploaded per frame. Lower is smoother while loading.": "每帧上传的网格化区块数。越低加载时越流畅。",
"Soft shadows in corners. Costs chunk building time only.": "角落里的柔和阴影。只占用区块构建时间。",
"Smaller copies of block textures for distant terrain: less shimmer.": "为远处地形生成更小的方块纹理副本:减少闪烁。",
@@ -195,6 +193,7 @@
"60 FPS cap (30 in menus or unfocused), half the workers: less power.": "限制 60 FPS(菜单或失焦时 30),工作线程减半:更省电。",
"Threads that generate, light and mesh chunks. Next start.": "负责生成、光照与网格化区块的线程数。下次启动生效。",
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "优先 Vulkan,失败时用 DirectX 12(Windows)或 OpenGL(Linux)。下次启动生效。",
"Water and glass in one buffer: fewer draw calls, a bit more VRAM. Next start.": "水和玻璃放在同一个缓冲区:绘制调用更少,显存略多。下次启动生效。",
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "每帧发送给 GPU 的网格数据。越低加载时越流畅。",
"Frame rate and frame times over the game, with a graph.": "游戏运行时的帧率与帧时间,附带图表。",
"horizon.multiplayer.finding_friend": "正在查找好友的世界…",
@@ -306,5 +305,36 @@
"horizon.osk.close": "关闭",
"horizon.extras.chat_animation.tooltip": "新聊天行从左侧滑入并淡入,而不是整行直接出现。",
"horizon.extras.chat_indicator.tooltip": "聊天行左侧的彩色竖条。",
"horizon.extras.splitscreen.tooltip": "在世界中,另一个手柄可以以新玩家身份加入:提示时按 A,然后选择档案和游玩位置。"
"horizon.extras.splitscreen.tooltip": "在世界中,另一个手柄可以以新玩家身份加入:提示时按 A,然后选择档案和游玩位置。",
"Culling": "剔除",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "隐藏不透明方块后面的模型。检测在后台线程进行,实体仍会更新。",
"Leaf Culling": "树叶剔除",
"Every leaf face. Vanilla's fancy leaves.": "绘制每个树叶面,与原版的华丽树叶一致。",
"Distant Plant Dither": "远处植物抖动",
"Keeps fine plants in high-res packs from fading far away.": "避免高分辨率资源包中的细小植物在远处逐渐消失。",
"Screenshot Quality": "截图质量",
"F2 screenshots at this many times the game's resolution. Higher takes longer.": "F2 截图的分辨率为游戏分辨率的这个倍数。越高耗时越长。",
"Frame Generation": "帧生成",
"Shows a made-up frame between each two real ones: smoother, a little more lag.": "在每两个真实帧之间插入一个生成帧:更流畅,延迟略增。",
"Shadow Cache": "阴影缓存",
"Reuses the pack shadow map when sun, view and world match. Off by default.": "太阳、视角和世界不变时重复使用资源包的阴影贴图。默认关闭。",
"Synchronous": "同步",
"Asynchronous": "异步",
"Solid": "实心",
"Solid Aggressive": "激进实心",
"Hollow": "镂空",
"Leaves fully inside a canopy draw solid: fewer see-through layers.": "树冠内部的树叶绘制为实心:透明层更少。",
"Leaves with one open side draw solid too: faster, chunkier edges.": "只有一面外露的树叶也绘制为实心:更快,边缘更粗糙。",
"Drops faces between leaves: faster, but gaps look through.": "省略树叶之间的面:更快,但缝隙会透视。",
"Hotbar: Previous Slot": "快捷栏:上一个栏位",
"Hotbar: Next Slot": "快捷栏:下一个栏位",
"horizon.agent.connected.one": "已连接:1 个代理连接",
"horizon.agent.connected.many": "已连接:%s 个代理连接",
"horizon.agent.connected.one_ago": "已连接:1 个代理连接,上次命令在 %s 秒前",
"horizon.agent.connected.many_ago": "已连接:%s 个代理连接,上次命令在 %s 秒前",
"horizon.agent.waiting": "正在等待代理。",
"horizon.agent.off": "关闭:代理无法访问此游戏。",
"horizon.agent.session_opened": "由启动游戏的程序为本次会话开启。",
"horizon.agent.socket": "套接字:%s(复制详细信息以获取完整路径)",
"horizon.agent.local_only": "仅限本机:此电脑上的代理可用,网络不可用。"
}
@@ -5,13 +5,16 @@
//!
//! cargo run --release -p horizon-client --example chunk_load_bench -- \
//! .local/horizon-client/26.3 [--seed 1234] [--center CX CZ] [--radius 8]
//! [--threads N] [--rounds 3]
//! [--threads N] [--rounds 3] [--mesh-threads N] [--json out.json]
//!
//! The default centre is the village of `tools/perf_bench.py`. The generation
//! figure is wall time with N workers (the game's own pool); the mesh figure is
//! this thread's CPU time in `Mesher::mesh`, the best of `--rounds`, so other
//! work on the machine changes it less. The checksum of the meshes shows that an
//! A/B pair of builds made the same ones.
//! this thread's CPU time in the workers' path (`Mesher::mesh_reusing` with a
//! recycled mesh and scratch, then `sort_solid`), each section's best of
//! `--rounds`, so other work on the machine changes it less; then the wall time
//! of `--mesh-threads` threads meshing them all at once. The checksum of the
//! meshes (vertices and visibility) shows that an A/B pair of builds made the
//! same ones.
extern crate horizon_client_lib as horizon_client;
use horizon_client::{
assets::Assets,
@@ -20,7 +23,7 @@ use horizon_client::{
chunk::HEIGHT,
graphics::LeafCullingMode,
light::LightTables,
mesher::{MeshTables, Mesher, Region, borrow},
mesher::{MeshScratch, MeshTables, Mesher, Region, SectionMesh, TerrainVertex, borrow},
models,
spans::thread_cpu,
streaming::{Streaming, WorkerContext},
@@ -32,7 +35,10 @@ use horizon_world::registry::BlockStateId;
use std::{
hash::{DefaultHasher, Hash, Hasher},
path::PathBuf,
sync::Arc,
sync::{
Arc,
atomic::{AtomicUsize, Ordering},
},
thread,
time::{Duration, Instant},
};
@@ -131,43 +137,176 @@ fn main() -> Result<(), Error> {
})
.flat_map(|region| (0..HEIGHT / 16).map(move |sy| (region.clone(), sy)))
.collect();
let mut best = Duration::MAX;
let (mut vertices, mut checksum) = (0, 0);
for _ in 0..rounds.max(1) {
let mut mesher = Mesher {
nether_shading: false,
shader_pack_lighting: false,
zoom: BiomeZoom::new(obfuscate_seed(seed)),
blend_radius: 2,
smooth_lighting: true,
cutout_leaves: true,
translucent_layers: &[],
leaf_culling: LeafCullingMode::Off,
assets: &context.assets,
render: &context.render,
tables: &context.tables,
biomes: &context.biomes,
visibility: context.tables.visibility.clone(),
};
vertices = 0;
let mut hash = DefaultHasher::new();
let mut spent = Duration::ZERO;
for (region, sy) in &regions {
let started = thread_cpu().ok_or("no thread CPU clock here")?;
let mesh = mesher.mesh(&borrow(region), *sy);
spent += thread_cpu().ok_or("no thread CPU clock here")? - started;
vertices += mesh.solid.len() + mesh.translucent.len();
bytemuck::cast_slice::<_, u8>(&mesh.solid).hash(&mut hash);
bytemuck::cast_slice::<_, u8>(&mesh.translucent).hash(&mut hash);
}
checksum = hash.finish();
best = best.min(spent);
}
let one = mesh_rounds(&context, seed, &regions, rounds)?;
let mut sorted = one.per_section.clone();
sorted.sort_unstable();
let pct = |p: f64| {
let i = ((sorted.len() as f64 - 1.0) * p).round() as usize;
sorted.get(i).map_or(0.0, |d| d.as_secs_f64() * 1e6)
};
let total: Duration = one.per_section.iter().sum();
let filled: Vec<Duration> = one
.per_section
.iter()
.zip(&one.vertices_per_section)
.filter(|&(_, &v)| v > 0)
.map(|(d, _)| *d)
.collect();
let filled_total: Duration = filled.iter().sum();
let bytes = one.vertices * std::mem::size_of::<TerrainVertex>();
println!(
"meshed {} sections ({vertices} vertices, checksum {checksum:016x}) in {:.1} ms of CPU ({:.1} us per section)",
"meshed {} sections ({} with geometry, {} vertices, {:.1} MB of vertices, checksum {:016x}) in {:.1} ms of CPU ({:.1} us per section)",
regions.len(),
best.as_secs_f64() * 1e3,
best.as_secs_f64() * 1e6 / regions.len().max(1) as f64
filled.len(),
one.vertices,
bytes as f64 / 1e6,
one.checksum,
total.as_secs_f64() * 1e3,
total.as_secs_f64() * 1e6 / regions.len().max(1) as f64
);
println!(
"per section (best of {rounds} rounds, us): p50 {:.1} p90 {:.1} p99 {:.1} max {:.1}; with geometry: mean {:.1}; {:.0} sections/s per core",
pct(0.5),
pct(0.9),
pct(0.99),
pct(1.0),
filled_total.as_secs_f64() * 1e6 / filled.len().max(1) as f64,
regions.len() as f64 / total.as_secs_f64().max(1e-9),
);
// Every worker meshing at once: the throughput a world being explored gets,
// memory bandwidth and the allocator included.
let mesh_threads: usize = flag(&args, "--mesh-threads", threads);
let parallel = mesh_parallel(&context, seed, &regions, mesh_threads)?;
println!(
"{mesh_threads} threads meshed {} sections in {:.1} ms of wall time: {:.0} sections/s",
regions.len(),
parallel.as_secs_f64() * 1e3,
regions.len() as f64 / parallel.as_secs_f64().max(1e-9)
);
if let Some(path) = args
.iter()
.position(|a| a == "--json")
.and_then(|i| args.get(i + 1))
{
let json = format!(
concat!(
"{{\"sections\":{},\"filled\":{},\"vertices\":{},\"vertex_bytes\":{},",
"\"checksum\":\"{:016x}\",\"cpu_ms\":{:.3},\"p50_us\":{:.2},\"p90_us\":{:.2},",
"\"p99_us\":{:.2},\"max_us\":{:.2},\"filled_mean_us\":{:.2},",
"\"mesh_threads\":{},\"parallel_per_s\":{:.1}}}\n"
),
regions.len(),
filled.len(),
one.vertices,
bytes,
one.checksum,
total.as_secs_f64() * 1e3,
pct(0.5),
pct(0.9),
pct(0.99),
pct(1.0),
filled_total.as_secs_f64() * 1e6 / filled.len().max(1) as f64,
mesh_threads,
regions.len() as f64 / parallel.as_secs_f64().max(1e-9),
);
std::fs::write(path, json)?;
}
Ok(())
}
fn mesher<'a>(context: &'a WorkerContext, seed: i64) -> Mesher<'a> {
Mesher {
nether_shading: false,
shader_pack_lighting: false,
zoom: BiomeZoom::new(obfuscate_seed(seed)),
blend_radius: 2,
smooth_lighting: true,
cutout_leaves: true,
translucent_layers: &[],
leaf_culling: LeafCullingMode::Off,
assets: &context.assets,
render: &context.render,
tables: &context.tables,
biomes: &context.biomes,
visibility: context.tables.visibility.clone(),
}
}
struct Rounds {
/// Each section's CPU time, the least of the rounds.
per_section: Vec<Duration>,
vertices_per_section: Vec<usize>,
vertices: usize,
checksum: u64,
}
/// Meshes every section `rounds` times on this thread the way a worker does:
/// one mesher (its face cache kept), one scratch, the mesh buffers recycled,
/// and the solid quads sorted by side.
fn mesh_rounds(
context: &WorkerContext,
seed: i64,
regions: &[(Region, i32)],
rounds: usize,
) -> Result<Rounds, Error> {
let mut mesher = mesher(context, seed);
let mut scratch = MeshScratch::default();
let mut recycled = SectionMesh::default();
let mut per_section = vec![Duration::MAX; regions.len()];
let mut vertices_per_section = vec![0; regions.len()];
let mut checksum = 0;
for _ in 0..rounds.max(1) {
let mut hash = DefaultHasher::new();
for (i, (region, sy)) in regions.iter().enumerate() {
let started = thread_cpu().ok_or("no thread CPU clock here")?;
let mut mesh = mesher.mesh_reusing(&borrow(region), *sy, recycled, &mut scratch);
mesh.sort_solid();
let spent = thread_cpu().ok_or("no thread CPU clock here")? - started;
per_section[i] = per_section[i].min(spent);
vertices_per_section[i] = mesh.solid.len() + mesh.translucent.len();
bytemuck::cast_slice::<_, u8>(&mesh.solid).hash(&mut hash);
bytemuck::cast_slice::<_, u8>(&mesh.translucent).hash(&mut hash);
mesh.visibility.hash(&mut hash);
recycled = mesh;
}
checksum = hash.finish();
}
let vertices = vertices_per_section.iter().sum();
Ok(Rounds {
per_section,
vertices_per_section,
vertices,
checksum,
})
}
/// Wall time for `threads` workers to mesh every section once between them.
fn mesh_parallel(
context: &WorkerContext,
seed: i64,
regions: &[(Region, i32)],
threads: usize,
) -> Result<Duration, Error> {
let next = AtomicUsize::new(0);
let started = Instant::now();
thread::scope(|scope| {
for _ in 0..threads.max(1) {
scope.spawn(|| {
let mut mesher = mesher(context, seed);
let mut scratch = MeshScratch::default();
let mut recycled = SectionMesh::default();
loop {
let i = next.fetch_add(1, Ordering::Relaxed);
let Some((region, sy)) = regions.get(i) else {
break;
};
let mut mesh =
mesher.mesh_reusing(&borrow(region), *sy, recycled, &mut scratch);
mesh.sort_solid();
recycled = mesh;
}
});
}
});
Ok(started.elapsed())
}
@@ -536,6 +536,9 @@ pub fn main() -> Result<()> {
let flying = op["value"].as_bool().unwrap();
world.player.may_fly = op["may_fly"].as_bool().unwrap_or(flying);
world.player.flying = flying;
if let Some(speed) = op.get("speed") {
world.player.flying_speed = single(speed);
}
}
"trigger" => world.player.jump_trigger_time = op["value"].as_i64().unwrap() as i32,
"delay" => world.player.no_jump_delay = op["value"].as_i64().unwrap() as i32,
+13 -5
View File
@@ -403,6 +403,18 @@ pub fn clip_block_stop(
collision: &BlockCollisionRegistry<'_>,
from: Vec3,
movement: Vec3,
) -> Option<(Vec3, BlockPos, [i32; 3])> {
let (location, block, normal) = clip_block_hit(world, facts, collision, from, movement)?;
Some((back_off(location, movement), block, normal))
}
/// The clip of `clip_block_stop` before the back-off: the hit location (what a
/// `BlockHitResult` carries), the block and the normal of the face.
pub fn clip_block_hit(
world: &World,
facts: &BlockFacts,
collision: &BlockCollisionRegistry<'_>,
from: Vec3,
movement: Vec3,
) -> Option<(Vec3, BlockPos, [i32; 3])> {
let to = Vec3::new(
from.x + movement.x,
@@ -426,11 +438,7 @@ pub fn clip_block_stop(
})
.ok()
.flatten()?;
Some((
back_off(hit.location, movement),
hit.block,
hit.direction.normal(),
))
Some((hit.location, hit.block, hit.direction.normal()))
}
/// `AbstractArrow.onHitBlock`'s back-off: vanilla scales `(double) 0.05f` by
/// `Math.signum` per axis, whose value at zero is zero (not Rust's -1.0).
@@ -214,6 +214,9 @@ pub const COMMANDS: &[Cmd] = &[
mcp: true, local: true),
cmd!("status", Session, "status", "game, renderer and streaming state (the scripting socket's original summary)",
"Also what F3 shows: the overlay flags, the renderer, chunk streaming and memory.", ["horizon-ctl status"], &[], mcp: true, local: false),
cmd!("hello", Session, "hello", "the control protocol's version, the game's release and the command groups it serves",
"Replies protocol (MAJOR.MINOR.PATCH of the control protocol, docs/api/stability.md), release (the game's version, the string MCP serverInfo carries too) and features (the command groups the game answers, so a script can test for one before calling it).",
["horizon-ctl hello"], &[], mcp: true, local: false),
cmd!("worlds", Session, "worlds", "the saved worlds, newest first: folder, name, mode, last played, version",
"folder is what open, world-rename, world-backup and world-delete take.", ["horizon-ctl worlds"], &[], mcp: true, local: false),
cmd!("world-rename", Session, "world-rename FOLDER NAME", "rename a world, as Edit World does (its folder stays)",
@@ -444,7 +447,7 @@ pub const COMMANDS: &[Cmd] = &[
"Add it to an MCP client as a stdio server: command horizon-ctl, args [\"mcp\"] (with --socket PATH first if needed). The screenshot tool returns the image.",
["horizon-ctl mcp"], &[], mcp: false, local: true),
cmd!("bench", Scripting, "bench SECONDS", "reset frame stats, wait, reply with the summary", "", ["horizon-ctl bench 10"], &[p("seconds", Ty::Num, "how long to measure")], mcp: true, local: false),
cmd!("stats", Scripting, "stats [reset]", "frame-time summary since the last reset", "", ["horizon-ctl stats"], &[opt("reset", Ty::Bool, "start counting again")], mcp: true, local: false),
cmd!("stats", Scripting, "stats [reset] [raw]", "frame-time summary since the last reset", "", ["horizon-ctl stats"], &[opt("reset", Ty::Bool, "start counting again"), opt("raw", Ty::Bool, "every frame time too (frame_ms, cpu_ms, gpu_ms)")], mcp: true, local: false),
cmd!("gpu-passes", Scripting, "gpu-passes [on|off]", "time Horizon's own render passes on the GPU, shown in F3 and in stats and bench", "", ["horizon-ctl gpu-passes on"], &[opt("mode", Ty::Enum(&["on", "off"]), "on or off; without it, only reports")], mcp: false, local: false),
cmd!("fly", Scripting, "fly X1 Y1 Z1 X2 Y2 Z2 SECONDS [PITCH [raw]]", "fly the camera along a line, then reply with the frame summary",
"The reply's tick_ms and tick_ms_max are the world's smoothed tick time over the flight (the server thread's when single player runs on one). With raw, the reply also lists every frame time in milliseconds (frame_ms).",
+11 -11
View File
@@ -10,17 +10,17 @@ pub mod block_contact;
pub use horizon_client_render::{
assets, atlas_sources, audio, auth, ban_notice, bidi, biome, block_edit, block_entities,
block_entity_anim, block_entity_render, border_render, breaking, brewing, camera, chat, chunk,
chunk_map, cit, cit_assets, clouds, collisions, command_block, containers, crash, datapack,
dynamic_light, effects, environment, extras, fast_hash, fog, font, gfx, graphics,
graphics_style, graphics_ui, hdr_calibration, hud, hud_layout, hud_overlays, inventory_effects,
java_text, jigsaw_block, level_loading, light, lod, logging, menu_blur, mesher, minimap,
mipmap, mod_list, model_library, models, multiline_edit, native_emissive, options_file,
options_fixes, options_ui, overlay, pack_screen, packs, par, particles, poi_sections,
post_chain, potent_sulfur, preedit, providers, screen_draw, screen_effects, sculk,
section_mask, settings, shader_options, shader_pack_list, signs, skins, sound_engine,
sound_events, sound_preview, spans, split_layout, split_profiles, sprites, streaming,
structure_block, tab_list, telemetry, test_block, test_instance_block, testgen, title_edition,
trims, ui, vault, weather, widgets, world, worldborder, zip,
chunk_map, cit, cit_armor, cit_assets, cit_glint, clouds, collisions, command_block,
containers, crash, datapack, dynamic_light, effects, environment, extras, fast_hash, fog, font,
gfx, graphics, graphics_style, graphics_ui, hdr_calibration, hud, hud_layout, hud_overlays,
inventory_effects, java_text, jigsaw_block, json_folder, level_loading, light, lod, logging,
menu_blur, mesher, minimap, mipmap, mod_list, model_library, models, multiline_edit,
native_emissive, options_file, options_fixes, options_ui, overlay, pack_screen, packs, par,
particles, poi_sections, post_chain, potent_sulfur, preedit, providers, screen_draw,
screen_effects, sculk, section_mask, settings, shader_options, shader_pack_list, signs, skins,
sound_engine, sound_events, sound_preview, spans, split_layout, split_profiles, sprites,
streaming, structure_block, tab_list, telemetry, test_block, test_instance_block, testgen,
title_edition, trims, ui, vault, weather, widgets, world, worldborder, zip,
};
pub mod attack_strength;
pub mod attribute_updates;
+55 -48
View File
@@ -14,11 +14,34 @@ use horizon_world::nbt::Tag;
use serde_json::Value;
use std::{
collections::{HashMap, HashSet},
fs,
path::{Path, PathBuf},
path::Path,
sync::Arc,
};
/// A recipe ingredient of a mod's own kind: the entry named a type in a
/// namespace outside `minecraft:`, and the loader kept it for this call.
/// The kind and the JSON the file wrote are here; fill `items` with the
/// item ids the ingredient matches (item ids only: components are not
/// checked here; a match-time hook follows separately). Duplicates are
/// removed keeping first-seen order. The hook may be called more than
/// once per recipe; the answer must be the same. An unclaimed kind
/// leaves the list empty and the recipe fails, as an unknown kind does.
/// API @API:ingredient@.
#[inline(never)]
pub fn native_ingredient(
kind: &str,
data: &serde_json::Map<String, Value>,
items: &mut Vec<String>,
) {
let _ = (kind, data, items);
}
/// Keep first-seen order, drop the rest.
fn dedup_keep_order(items: &mut Vec<String>) {
let mut seen = std::collections::HashSet::new();
items.retain(|i| seen.insert(i.clone()));
}
/// A set of accepted items (expanded from an item, list or tag), and the
/// items in its `HolderSet` order (`Ingredient.display`'s `TagSlotDisplay`).
#[derive(Clone, Debug)]
@@ -107,6 +130,14 @@ impl Ingredient {
.cloned()
.collect()
}
// A mod's own kind: the hook answers with the item list it
// matches; a bare type name is minecraft's own, not a mod's.
k if k.contains(':') && !k.starts_with("minecraft:") => {
let mut list = Vec::new();
native_ingredient(k, o, &mut list);
dedup_keep_order(&mut list);
return (!list.is_empty()).then(|| Self::of(list));
}
_ => return None,
};
(!kept.is_empty()).then(|| Self::of(kept))
@@ -1176,48 +1207,6 @@ pub fn recipe_result(v: &Value) -> Option<ItemStack> {
let template = Template::parse(v)?;
template.item.is_some().then(|| template.create())
}
/// The `.json` files under a `recipe` folder, its subfolders' too, in id
/// order: each one's path inside the folder without the extension (`a/b`, as a
/// recipe id has it) and its file. An error when `dir` itself can't be read; a
/// subfolder or an entry that can't be listed is warned and skipped, as
/// vanilla's `PathPackResources` keeps what it found.
pub(crate) fn recipe_files(dir: &Path) -> Result<Vec<(String, PathBuf)>> {
let mut files = Vec::new();
let mut stack = vec![fs::read_dir(dir)?];
while let Some(entries) = stack.pop() {
for entry in entries {
let entry = match entry {
Ok(entry) => entry,
Err(e) => {
log::warn!("Horizon: failed to list an entry of {}: {e}", dir.display());
continue;
}
};
let path = entry.path();
if path.is_dir() {
match fs::read_dir(&path) {
Ok(list) => stack.push(list),
Err(e) => log::warn!("Horizon: failed to list {}: {e}", path.display()),
}
} else if path.extension().is_some_and(|e| e == "json") {
files.push(path);
}
}
}
let mut named: Vec<(String, PathBuf)> = files
.into_iter()
.map(|path| {
let inside = path.strip_prefix(dir).unwrap_or(&path).with_extension("");
let name: Vec<_> = inside.iter().map(|c| c.to_string_lossy()).collect();
(name.join("/"), path)
})
.collect();
// Vanilla lists resources by id, in a map keyed by it
// (`FallbackResourceManager`): `crushing` before `crushing/iron`, where
// a path's components put the subfolder's file first.
named.sort_by(|a, b| a.0.cmp(&b.0));
Ok(named)
}
/// The type of a recipe the game doesn't craft itself: one outside `minecraft`.
/// A recipe's whole id from its name in the tables: vanilla's are kept bare
/// (`oak_planks`), everyone else's whole (`mymod:crusher/iron`).
@@ -1340,14 +1329,14 @@ impl Recipes {
}
mods
};
for (name, p) in recipe_files(&root.join("data/minecraft/recipe"))? {
sources.push((name, fs::read(&p)?));
for file in &crate::json_folder::read(&root.join("data/minecraft/recipe"))?.files {
sources.push((file.name.clone(), file.bytes()?.to_vec()));
}
for namespace in crate::mod_content::namespaces(root) {
let dir = root.join(format!("data/{namespace}/recipe"));
if dir.is_dir() {
for (name, p) in recipe_files(&dir)? {
sources.push((format!("{namespace}:{name}"), fs::read(&p)?));
for file in &crate::json_folder::read(&dir)?.files {
sources.push((format!("{namespace}:{}", file.name), file.bytes()?.to_vec()));
}
}
}
@@ -1855,6 +1844,7 @@ impl Recipes {
mod tests {
use super::*;
use crate::banner::BannerLayer;
use std::fs;
#[test]
fn result_count_is_bounded_like_vanilla() {
@@ -1913,6 +1903,23 @@ mod tests {
/// NeoForge's standard ingredient shapes resolve to their flat item
/// lists; a kind the loader doesn't know still fails the file.
#[test]
fn an_unclaimed_ingredient_kind_fails_and_dedup_keeps_order() {
let items = Items::for_tests(HashMap::new());
let json = |s: &str| serde_json::from_str::<Value>(s).unwrap();
assert!(
Ingredient::parse(&json(r#"{"type":"mymod:aspect","aspect":"fire"}"#), &items,)
.is_none()
);
assert!(
Ingredient::parse(&json(r#"{"type":"minecraft:future_shape"}"#), &items,).is_none()
);
assert!(Ingredient::parse(&json(r#"{"type":"aspect"}"#), &items,).is_none());
let mut list = vec!["b".to_string(), "a".to_string(), "b".to_string()];
dedup_keep_order(&mut list);
assert_eq!(list, vec!["b".to_string(), "a".to_string()]);
}
#[test]
fn neoforge_ingredient_shapes_parse() {
let mut tags = HashMap::new();
+69
View File
@@ -2343,6 +2343,20 @@ pub fn fireball_to_nbt(
if let Some(power) = power {
put(&mut tag, "ExplosionPower", Tag::Byte(power as i8));
}
// Fireball.addAdditionalSaveData: the item it is drawn as, one of the loaded
// item or else a fire charge (`setItem` refuses an empty stack and keeps one).
if matches!(
p.kind,
ProjectileKind::LargeFireball { .. } | ProjectileKind::SmallFireball
) {
let mut item = tag
.get("Item")
.and_then(item_from_nbt)
.filter(|i| i.count > 0 && &*i.item != "minecraft:air")
.unwrap_or_else(|| crate::items::ItemStack::new("minecraft:fire_charge", 1));
item.count = 1;
put(&mut tag, "Item", item_to_nbt(&item));
}
Some(tag)
}
@@ -4673,6 +4687,61 @@ mod fireball_nbt_tests {
}
}
/// `Fireball.addAdditionalSaveData` / `readAdditionalSaveData`: both fireballs save
/// `Item`, a fire charge by default, and a loaded one stays, as one item.
#[test]
fn a_fireball_saves_its_item() {
let item_of = |tag: &Tag| {
let item = tag.get("Item").and_then(item_from_nbt).unwrap();
(item.item.to_string(), item.count)
};
for id in ["minecraft:fireball", "minecraft:small_fireball"] {
let plain = Tag::compound([
("id", Tag::String(id.into())),
("Pos", doubles(&[0.0, 0.0, 0.0])),
]);
let p = fireball_from_nbt(&plain, None).unwrap();
let back = fireball_to_nbt(&p, [1, 2, 3, 4], None, Some(&plain)).unwrap();
assert_eq!(
item_of(&back),
("minecraft:fire_charge".to_owned(), 1),
"{id}"
);
// A loaded item stays, as a single one.
let mut loaded = plain.clone();
put(
&mut loaded,
"Item",
item_to_nbt(&crate::items::ItemStack::new("minecraft:diamond", 5)),
);
let p = fireball_from_nbt(&loaded, None).unwrap();
let back = fireball_to_nbt(&p, [1, 2, 3, 4], None, Some(&loaded)).unwrap();
assert_eq!(item_of(&back), ("minecraft:diamond".to_owned(), 1), "{id}");
// An empty one falls back to the fire charge.
let mut empty = plain.clone();
put(
&mut empty,
"Item",
item_to_nbt(&crate::items::ItemStack::new("minecraft:air", 1)),
);
let p = fireball_from_nbt(&empty, None).unwrap();
let back = fireball_to_nbt(&p, [1, 2, 3, 4], None, Some(&empty)).unwrap();
assert_eq!(
item_of(&back),
("minecraft:fire_charge".to_owned(), 1),
"{id}"
);
}
// The wind charges are not `Fireball`s: they save no item.
let wind = Tag::compound([
("id", Tag::String("minecraft:wind_charge".into())),
("Pos", doubles(&[0.0, 0.0, 0.0])),
]);
let p = fireball_from_nbt(&wind, None).unwrap();
let back = fireball_to_nbt(&p, [1, 2, 3, 4], None, Some(&wind)).unwrap();
assert!(back.get("Item").is_none());
}
#[test]
fn thrown_item_projectiles_round_trip_their_flight_item_and_owner() {
let player = Some([5, 6, 7, 8]);
+36 -1
View File
@@ -3,8 +3,18 @@ use crate::{crash, game, logging, ram_limit};
/// Start the game. The executable supplies the allocator and retains the mod API marker.
pub fn main() {
main_with(Default::default());
}
/// Start the game with trait mods in dispatch order.
pub fn main_with(mods: crate::mod_api::Mods) {
// First: a trait injection into anything below (logging, the crash handler) calls its mod.
if crate::mod_api::MODS.set(mods).is_err() {
log::warn!("Trait mods already installed; ignoring the second list");
}
horizon_threads::configure_allocator();
horizon_client::telemetry::mark_start();
horizon_client::spans::startup::init(std::time::Instant::now());
let args: Vec<String> = std::env::args().skip(1).collect();
let game_args = args.iter().take_while(|a| *a != "--dedicated");
let help = game_args
@@ -45,8 +55,33 @@ pub fn main() {
crash::install(home.clone(), side);
ram_limit::start(&home, side);
}
let renderers = crate::mod_api::mods()
.iter()
.filter_map(|m| m.render())
.collect();
if crate::gfx::native_render::install(renderers).is_err() {
log::warn!("Trait renderers already installed; keeping the first list");
}
if horizon_world::native_load_hooks::install(
horizon_world::native_load_hooks::NativeLoadHooks {
loot_table_load: |id, table| {
crate::mod_api::dispatch_loot_table_load(crate::mod_api::mods(), id, table);
},
modify_default_components: |item, components| {
crate::mod_api::dispatch_modify_default_components(
crate::mod_api::mods(),
item,
components,
);
},
},
)
.is_err()
{
log::warn!("Native data-load hooks already installed; keeping the first callbacks");
}
raise_open_file_limit();
if let Err(error) = game::run() {
if let Err(error) = game::run_with() {
// A bad command line is the caller's mistake, not a crash: no report.
let usage = error.is::<game::UsageError>();
log::error!("Horizon: {error}");
+13 -6
View File
@@ -541,6 +541,8 @@ pub struct HandSway {
pub y_bob: f32,
pub x_bob_o: f32,
pub y_bob_o: f32,
/// The yaw `tick_wrapped` has followed so far, unwrapped like vanilla's.
y_rot: f32,
}
impl HandSway {
/// `LocalPlayer.applyInput` with the player's `getXRot` and `getYRot`.
@@ -548,9 +550,14 @@ impl HandSway {
self.y_bob_o = self.y_bob;
self.x_bob_o = self.x_bob;
self.x_bob += (x_rot - self.x_bob) * 0.5;
// Vanilla's yaw runs on without wrapping, so `yRot - yBob` is the short way round. Ours
// is kept in -180..180 (a boat turns its rider through it), so take the short way here.
self.y_bob += wrap_degrees(y_rot - self.y_bob) * 0.5;
self.y_bob += (y_rot - self.y_bob) * 0.5;
}
/// `tick` for a yaw kept in -180..180 (a boat turns its rider through the wrap). Vanilla's
/// yaw runs on without wrapping, so `yRot - yBob` is always the short way round; this
/// follows the wrapped yaw by its short steps to give `tick` the same unwrapped yaw.
pub fn tick_wrapped(&mut self, x_rot: f32, y_rot: f32) {
self.y_rot += wrap_degrees(y_rot - self.y_rot);
self.tick(x_rot, self.y_rot);
}
/// The degrees the hands turn about X, then Y, before either is drawn:
/// `(viewXRot - xBob) * 0.1` and `(viewYRot - yBob) * 0.1`, the bobs
@@ -1406,12 +1413,12 @@ mod action_result_tests {
#[test]
fn hand_sway_takes_the_short_way_over_the_yaw_wrap() {
let mut sway = HandSway::default();
sway.tick(0.0, 179.0);
sway.tick_wrapped(0.0, 179.0);
for _ in 0..20 {
sway.tick(0.0, 179.0);
sway.tick_wrapped(0.0, 179.0);
}
// The view is still at 179; one tick later it reads -179 (2 degrees on).
sway.tick(0.0, -179.0);
sway.tick_wrapped(0.0, -179.0);
let [_, y] = sway.rotation(1.0, 0.0, -179.0);
assert!(
y.abs() < 0.5,
+121 -18
View File
@@ -6,7 +6,6 @@ use crate::{
items::{ItemStack, Items},
providers::Providers,
};
use std::sync::Arc;
/// The furnace methods that read the recipes.
pub trait FurnaceCooking {
@@ -22,15 +21,15 @@ pub trait FurnaceCooking {
/// (`Slot.setByPlayer`; partial takes and merges keep the stack).
fn input_clicked(&mut self, recipes: &Recipes, before: Option<ItemStack>);
/// One server tick. Returns `Some(lit)` when the LIT block state must change.
fn tick(&mut self, recipes: &Recipes, items: &Items, providers: &Providers) -> Option<bool>;
}
fn remainder(item: &str) -> Option<&'static str> {
match item {
"minecraft:lava_bucket" | "minecraft:water_bucket" | "minecraft:milk_bucket" => {
Some("minecraft:bucket")
}
_ => None,
}
/// `drops` collects what the furnace lets fall at its position
/// (`Containers.dropItemStack`): the remainder of a fuel when more of it is left.
fn tick(
&mut self,
recipes: &Recipes,
items: &Items,
providers: &Providers,
drops: &mut Vec<ItemStack>,
) -> Option<bool>;
}
impl FurnaceCooking for Furnace {
fn set_input(&mut self, recipes: &Recipes, stack: Option<ItemStack>) {
@@ -59,7 +58,13 @@ impl FurnaceCooking for Furnace {
self.set_input(recipes, after);
}
}
fn tick(&mut self, recipes: &Recipes, items: &Items, providers: &Providers) -> Option<bool> {
fn tick(
&mut self,
recipes: &Recipes,
items: &Items,
providers: &Providers,
drops: &mut Vec<ItemStack>,
) -> Option<bool> {
let was_lit = self.lit_remaining > 0;
let mut lit = false;
if self.lit_remaining > 0 {
@@ -101,16 +106,19 @@ impl FurnaceCooking for Furnace {
(ratio * self.cooking_total as f32).ceil() as i32;
}
if time > 0 {
// AbstractFurnaceBlockEntity.consumeFuel: the fuel's crafting
// remainder takes the slot once the stack is used up, else
// it is dropped.
if let Some(f) = self.items[1].as_mut() {
let item = f.item.clone();
let remainder = items
.get(&f.item)
.and_then(|i| i.crafting_remainder.as_deref())
.map(|r| ItemStack::new(r, 1));
f.count -= 1;
if f.count == 0 {
self.items[1] = remainder(&item).map(|r| ItemStack {
item: Arc::from(r),
count: 1,
damage: 0,
extra: None,
});
self.items[1] = remainder;
} else if let Some(remainder) = remainder {
drops.push(remainder);
}
}
lit = true;
@@ -171,3 +179,98 @@ fn total_time(time: u32, speed: f32) -> i32 {
time as i32
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{containers::FurnaceKind, items::Items};
use horizon_world::loot::PackOverlay;
use std::{collections::HashMap, fs};
/// A furnace with `fuel` x `count` burning `test:ore` into `test:ingot`, over a
/// data root holding that one recipe and an item table with two fuels: `test:coal`
/// (no remainder) and `test:cell` (a content mod's item that leaves `test:husk`).
fn burn(fuel: &str, count: u32) -> (Furnace, Vec<ItemStack>) {
let root = std::env::temp_dir().join(format!(
"horizon-furnace-remainder-{}-{}-{count}",
std::process::id(),
fuel.replace(':', "-")
));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join("data/minecraft/recipe")).unwrap();
fs::write(
root.join("data/minecraft/recipe/test_ingot.json"),
br#"{"type":"minecraft:smelting","ingredient":"test:ore","result":{"id":"test:ingot"}}"#,
)
.unwrap();
fs::create_dir_all(root.join("data/minecraft/loot_table")).unwrap();
let fuel_component =
r#"{"minecraft:cooking_fuel": {"burn_time": 200, "speed_multiplier": 1.0}}"#;
fs::write(
root.join("items.json"),
format!(
r#"{{"test:ore": {{}}, "test:ingot": {{}}, "test:coal": {fuel_component}, "test:cell": {fuel_component}, "test:husk": {{}}}}"#
),
)
.unwrap();
fs::write(
root.join(horizon_items::items::MOD_CRAFT_REMAINDERS),
r#"{"test:cell": "test:husk"}"#,
)
.unwrap();
let items = Items::catalog(&root, &PackOverlay::NONE).unwrap();
let recipes = Recipes::load(&root, &items, &PackOverlay::NONE).unwrap();
let providers = Providers::load(&root);
let mut furnace = Furnace {
kind: FurnaceKind::Furnace,
items: [
Some(ItemStack::new("test:ore", 1)),
Some(ItemStack::new(fuel, count)),
None,
],
lit_remaining: 0,
lit_total: 0,
cooking_timer: 0,
cooking_total: 0,
speed: 1.0,
recipes_used: HashMap::new(),
};
let mut drops = Vec::new();
let lit = furnace.tick(&recipes, &items, &providers, &mut drops);
let _ = fs::remove_dir_all(&root);
assert_eq!(lit, Some(true), "the fuel lit the furnace");
(furnace, drops)
}
fn fuel_slot(furnace: &Furnace) -> Option<(&str, u32)> {
furnace.items[1].as_ref().map(|s| (&*s.item, s.count))
}
/// `AbstractFurnaceBlockEntity.consumeFuel`: the last of a fuel with a remainder
/// leaves it in the fuel slot.
#[test]
fn the_last_fuel_leaves_its_remainder_in_the_slot() {
let (furnace, drops) = burn("test:cell", 1);
assert_eq!(fuel_slot(&furnace), Some(("test:husk", 1)));
assert!(drops.is_empty());
}
/// With more of the fuel left, the remainder is dropped at the furnace.
#[test]
fn a_fuel_still_in_the_stack_drops_its_remainder() {
let (furnace, drops) = burn("test:cell", 3);
assert_eq!(fuel_slot(&furnace), Some(("test:cell", 2)));
assert_eq!(drops, vec![ItemStack::new("test:husk", 1)]);
}
/// A fuel without a remainder leaves nothing.
#[test]
fn a_fuel_without_a_remainder_leaves_nothing() {
let (furnace, drops) = burn("test:coal", 1);
assert_eq!(fuel_slot(&furnace), None);
assert!(drops.is_empty());
let (furnace, drops) = burn("test:coal", 2);
assert_eq!(fuel_slot(&furnace), Some(("test:coal", 1)));
assert!(drops.is_empty());
}
}
File diff suppressed because it is too large. Load diff
+139 -28
View File
@@ -86,34 +86,21 @@ impl Game<'_> {
let server = self
.control
.as_ref()
.filter(|c| !c.path().as_os_str().is_empty());
let mut lines = vec![match server {
Some(s) if s.clients() > 0 => {
let ago = self
.agent_last
.map(|t| format!(", last command {} s ago", t.elapsed().as_secs()))
.unwrap_or_default();
format!(
"Connected: {} agent connection{}{ago}",
s.clients(),
if s.clients() == 1 { "" } else { "s" }
)
}
Some(_) => "Waiting for an agent.".into(),
None => "Off: agents can't reach this game.".into(),
}];
if let Some(s) = server {
if self.options.control.is_some() && !self.agent_control_on {
lines.push("Opened for this session by the program that started the game.".into());
}
// The full path is too wide for the page: Copy Connection Details has it.
let name = s.path().file_name().unwrap_or_default().to_string_lossy();
lines.push(format!(
"Socket: {name} (copy the details for its full path)"
));
}
lines.push("Local only: agents on this computer, never the network.".into());
lines
.filter(|c| !c.path().as_os_str().is_empty())
.map(|s| Socket {
clients: s.clients(),
name: s
.path()
.file_name()
.unwrap_or_default()
.to_string_lossy()
.into_owned(),
});
status_lines(
server,
self.agent_last.map(|t| t.elapsed().as_secs()),
self.options.control.is_some() && !self.agent_control_on,
)
}
/// What Copy Connection Details puts on the clipboard: the socket, a horizon-ctl line to try,
@@ -138,3 +125,127 @@ impl Game<'_> {
)
}
}
/// The control socket as the AI Agent page tells of it.
struct Socket {
/// Agents connected now.
clients: usize,
/// The socket's file name; the full path is too wide for the page (Copy Connection Details
/// has it).
name: String,
}
/// The AI Agent page's lines in the selected language: the state, then where agents connect.
/// `last_command` is the seconds since an agent's last command, `session_only` whether the
/// program that started the game opened the socket and the player's switch did not.
fn status_lines(
server: Option<Socket>,
last_command: Option<u64>,
session_only: bool,
) -> Vec<String> {
use horizon_client::lang::{get, tr};
let mut lines = vec![match &server {
Some(s) if s.clients > 0 => {
let (one, many) = match last_command {
Some(_) => ("one_ago", "many_ago"),
None => ("one", "many"),
};
let key = format!(
"horizon.agent.connected.{}",
if s.clients == 1 { one } else { many }
);
let mut args: Vec<&dyn std::fmt::Display> = Vec::new();
if s.clients != 1 {
args.push(&s.clients);
}
if let Some(ago) = &last_command {
args.push(ago);
}
tr(&key, &args)
}
Some(_) => get("horizon.agent.waiting"),
None => get("horizon.agent.off"),
}];
if let Some(s) = &server {
if session_only {
lines.push(get("horizon.agent.session_opened"));
}
lines.push(tr("horizon.agent.socket", &[&s.name]));
}
lines.push(get("horizon.agent.local_only"));
lines
}
#[cfg(test)]
mod tests {
use super::{Socket, status_lines};
use horizon_client::lang::{Language, scoped};
fn lines(code: &str, clients: usize, ago: Option<u64>, session_only: bool) -> Vec<String> {
let socket = Socket {
clients,
name: "ctl.sock".into(),
};
let (lines, missed) = scoped(Language::load(&[], code), || {
status_lines(Some(socket), ago, session_only)
});
assert!(missed.is_empty(), "{code}: {missed:?}");
lines
}
/// The AI Agent page told its state in English in every language (#1927).
#[test]
fn agent_page_lines_follow_the_language() {
assert_eq!(
lines("de_de", 2, Some(5), false),
[
"Verbunden: 2 Agentenverbindungen, letzter Befehl vor 5 s",
"Socket: ctl.sock (Verbindungsdaten kopieren für den vollständigen Pfad)",
"Nur lokal: Agenten auf diesem Computer, nie über das Netzwerk.",
]
);
assert_eq!(
lines("pl_pl", 1, None, true),
[
"Połączono: 1 połączenie agenta",
"Otwarte na tę sesję przez program, który uruchomił grę.",
"Gniazdo: ctl.sock (skopiuj dane, aby poznać pełną ścieżkę)",
"Tylko lokalnie: agenci na tym komputerze, nigdy przez sieć.",
]
);
let (off, missed) = scoped(Language::load(&[], "fr_fr"), || {
status_lines(None, None, false)
});
assert!(missed.is_empty(), "{missed:?}");
assert_eq!(
off,
[
"Désactivé : les agents ne peuvent pas joindre ce jeu.",
"Local uniquement : agents sur cet ordinateur, jamais via le réseau.",
]
);
}
/// English keeps the sentences it always had: the count, its plural, the time since the last
/// command, and the wait for a first agent.
#[test]
fn agent_page_lines_in_english() {
assert_eq!(
lines("en_us", 2, Some(5), false)[0],
"Connected: 2 agent connections, last command 5 s ago"
);
assert_eq!(
lines("en_us", 1, None, false)[0],
"Connected: 1 agent connection"
);
assert_eq!(
lines("en_us", 1, Some(0), false)[0],
"Connected: 1 agent connection, last command 0 s ago"
);
assert_eq!(
lines("en_us", 3, None, false)[0],
"Connected: 3 agent connections"
);
assert_eq!(lines("en_us", 0, None, true)[0], "Waiting for an agent.");
}
}
+36 -11
View File
@@ -4,6 +4,8 @@
//! the default pose, the hit wobble and humanoid armor layers.
use super::{Game, InteractTarget};
use horizon_client::{
cit::{Hand, Kind, Rule},
cit_armor,
entities::ArmorStand,
items::ItemStack,
mesher::Vertex,
@@ -154,6 +156,27 @@ impl ArmorStandAssets {
}
}
impl Game<'_> {
/// OptiFine CIT: the winning armour or elytra rule (`kind`) of the worn `stack`. No lookup
/// without CIT rules.
pub(super) fn cit_worn_rule(&self, kind: Kind, stack: &ItemStack) -> Option<&Rule> {
let rules = &self.pack.item_renderer.cit.as_ref()?.rules;
let item_max = self.items().get(&stack.item).map_or(0, |i| i.max_damage);
horizon_client::item_render::cit::worn_rule(rules, kind, stack, item_max)
.and_then(|i| rules.rules.get(i))
}
/// The atlas rect of an armour layer `texture` of equipment asset `asset`, or of the one CIT
/// armour rule `cit` puts there when the atlas has it.
fn armor_layer_rect(
&self,
cit: Option<&Rule>,
asset: &str,
kind: &str,
texture: &str,
) -> Option<[f32; 4]> {
let sprites = &self.gui.atlas.sprites;
let r = cit_armor::worn_layer(cit, asset, kind, texture, |s| sprites.get(s))?;
Some([r.u0, r.v0, r.u1, r.v1])
}
/// `HumanoidArmorLayer` for the player: (model, texture rect, colour,
/// overlay) of each worn piece, head to feet.
pub(super) fn player_armor_layers(&self) -> Vec<(&Part, [f32; 4], u32, u32, bool)> {
@@ -185,7 +208,7 @@ impl Game<'_> {
)
}
/// The layers of the armour items `worn` (with their stacks when known,
/// for trims), head to feet.
/// for trims and CIT armour rules), head to feet.
fn armor_layers(
&self,
worn: [Option<(&str, Option<&ItemStack>)>; 4],
@@ -240,18 +263,13 @@ impl Game<'_> {
};
// Every layer of the asset, the leather overlay too, then the
// trim layer over the armour.
let cit = stack.and_then(|s| self.cit_worn_rule(Kind::Armor, s));
for (i, (texture, undyed)) in layers.iter().enumerate() {
let Some(r) = self.gui.atlas.sprites.get(texture.as_str()) else {
let Some(uv) = self.armor_layer_rect(cit, &asset, kind, texture) else {
continue;
};
let color = layer_color(*undyed, stack);
out.push((
part,
[r.u0, r.v0, r.u1, r.v1],
color,
if i == 0 { overlay } else { 0 },
false,
));
out.push((part, uv, color, if i == 0 { overlay } else { 0 }, false));
// The layer's OptiFine emissive overlay, full bright without the glint.
if let Some(g) = emissive
.overlay(texture)
@@ -999,8 +1017,9 @@ impl Game<'_> {
apply_pose(&mut layer_poses, &a.pose);
// EquipmentLayerRenderer: every layer of the asset, the leather overlay
// too; the shader glint goes on the first layer only (renderShaderGlint).
let cit = self.cit_worn_rule(Kind::Armor, stack);
for (i, (texture, undyed)) in layers.iter().enumerate() {
let Some(uv) = uv_of(texture) else {
let Some(uv) = self.armor_layer_rect(cit, &asset, kind, texture) else {
continue;
};
let color = layer_color(*undyed, Some(stack));
@@ -1049,6 +1068,7 @@ impl Game<'_> {
// never crouches or glides, so its wings rest folded.
self.wings_geometry(
a.equipment[4].as_ref().map(|s| &*s.item),
a.equipment[4].as_ref(),
None,
a.small,
base,
@@ -1296,7 +1316,12 @@ impl Game<'_> {
oz / 16.0,
));
let seed = a.id as i32 + if left { 1 } else { 2 };
let state = self.resolve_item(stack, context, Some(&owner), seed);
// OptiFine CIT `hand=off` rules: slot 1 is the off hand.
let state = if left {
self.resolve_item_in_hand(stack, context, Some(&owner), seed, Hand::Off)
} else {
self.resolve_item(stack, context, Some(&owner), seed)
};
self.submit_item_world(&state, pose, light, out);
}
// `CustomHeadLayer`: a worn skull draws the skull model.
@@ -218,11 +218,17 @@ impl Game<'_> {
let Some(BlockEntity::Furnace(f)) = self.world.block_entities.get_mut(&pos) else {
return Ok(());
};
let Some(lit) = f.tick(
let mut drops = Vec::new();
let lit = f.tick(
world_recipes!(self),
world_items!(self),
self.res.providers(),
) else {
&mut drops,
);
for stack in drops {
self.campfire_drop(pos, stack);
}
let Some(lit) = lit else {
return Ok(());
};
let state = self.world.block(pos.0, pos.1, pos.2);
+78 -72
View File
@@ -738,84 +738,90 @@ impl Game<'_> {
// A server player's boat is driven by its client (lan_vehicles.rs).
let driven = self.guest_vehicles();
// EntityTickEvent.Post comes once AbstractBoat.tick is over, after the
// passes below (lava, ejection, bubbles, contacts): the ticked boats,
// in order, kept only while a mod listens (no list without mods).
let mut ticked = Vec::new();
for i in 0..self.boats.len() {
// A mod's hook may have removed boats.
if i >= self.boats.len() {
break;
}
let p = self.boats[i].position;
if Some(self.boats[i].id) == self.riding
|| driven.contains(&self.boats[i].id)
|| !ticking(p.x.floor() as i32, p.z.floor() as i32)
{
// Its rider's client moves it, but the lead still ticks.
if ticking(p.x.floor() as i32, p.z.floor() as i32) && self.remote.is_none() {
let mut b = self.boats[i].clone();
self.tick_boat_leash(&mut b);
self.boats[i] = b;
}
continue;
}
let id = self.boats[i].id;
let kind = if round.listening() {
self.boats[i].entity_type()
} else {
String::new()
};
if !self.native_tick_pre_one(&mut round, id, &kind) {
continue;
}
// The hook may have removed boats.
let Some(i) = Some(i)
.filter(|&i| self.boats.get(i).is_some_and(|b| b.id == id))
.or_else(|| self.boats.iter().position(|b| b.id == id))
else {
continue;
};
let b = self.boats[i].clone();
self.boats[i] = self.tick_boat(b, false);
if round.listening() {
ticked.push((id, kind));
}
}
// AbstractBoat.tick's applyEffectsFromBlocks, on the server for every
// boat, the client-driven ones too.
// passes of `boat_server_passes` (lava, ejection, bubbles, contacts),
// and before the next boat ticks, as `ServerLevel.tickNonPassenger`.
// A pass (lava breaks a boat) or a mod's hook may remove boats: the next one is found
// after this one by its id, so none is skipped or ticked twice.
let mut i = 0;
while i < self.boats.len() {
let p = self.boats[i].position;
let skip = Some(self.boats[i].id) == self.riding
|| round.canceled(self.boats[i].id)
|| !ticking(p.x.floor() as i32, p.z.floor() as i32);
if skip || !self.boat_lava_tick(i) {
i += 1;
let id = self.boats[i].id;
'boat: {
let p = self.boats[i].position;
if Some(id) == self.riding
|| driven.contains(&id)
|| !ticking(p.x.floor() as i32, p.z.floor() as i32)
{
// Its rider's client moves it, but the lead still ticks.
if ticking(p.x.floor() as i32, p.z.floor() as i32) && self.remote.is_none() {
let mut b = self.boats[i].clone();
self.tick_boat_leash(&mut b);
self.boats[i] = b;
}
self.boat_server_passes(id, &driven, &ticking, &round);
break 'boat;
}
let kind = if round.listening() {
self.boats[i].entity_type()
} else {
String::new()
};
if !self.native_tick_pre_one(&mut round, id, &kind) {
self.boat_server_passes(id, &driven, &ticking, &round);
break 'boat;
}
// The hook may have removed boats.
let Some(i) = Some(i)
.filter(|&i| self.boats.get(i).is_some_and(|b| b.id == id))
.or_else(|| self.boats.iter().position(|b| b.id == id))
else {
break 'boat;
};
let b = self.boats[i].clone();
self.boats[i] = self.tick_boat(b, false);
self.boat_server_passes(id, &driven, &ticking, &round);
self.native_tick_post_one(&round, id, &kind);
}
i = match self.boats.get(i) {
Some(b) if b.id == id => i + 1,
_ => self
.boats
.iter()
.position(|b| b.id == id)
.map_or(i, |k| k + 1),
};
}
// The rest of AbstractBoat.tick on the server, for every boat: the
// passengers of one under water too long get out, and it picks up or
// pushes what it touches.
for i in 0..self.boats.len() {
let p = self.boats[i].position;
if !ticking(p.x.floor() as i32, p.z.floor() as i32) || round.canceled(self.boats[i].id)
{
continue;
}
// `AbstractBoat.tick` counts submersion on the server even when
// its controlling client owns the boat's movement.
if driven.contains(&self.boats[i].id) {
self.refresh_boat_status(i);
}
if self.boats[i].out_of_control >= 60.0 {
self.eject_boat_riders(i);
}
self.boat_bubble_tick(i);
self.boat_contacts(i);
}
/// The rest of `AbstractBoat.tick` on the server, for every boat, the
/// client-driven ones too: `applyEffectsFromBlocks` (lava breaks a boat
/// at once), the passengers of one under water too long get out, and it
/// picks up or pushes what it touches.
fn boat_server_passes(
&mut self,
id: u32,
driven: &[u32],
ticking: &impl Fn(i32, i32) -> bool,
round: &super::native_ticks::TickRound,
) {
let Some(i) = self.boats.iter().position(|b| b.id == id) else {
return;
};
let p = self.boats[i].position;
if !ticking(p.x.floor() as i32, p.z.floor() as i32) || round.canceled(id) {
return;
}
for (id, kind) in ticked {
self.native_tick_post_one(&round, id, &kind);
if Some(id) != self.riding && self.boat_lava_tick(i) {
return;
}
// `AbstractBoat.tick` counts submersion on the server even when
// its controlling client owns the boat's movement.
if driven.contains(&id) {
self.refresh_boat_status(i);
}
if self.boats[i].out_of_control >= 60.0 {
self.eject_boat_riders(i);
}
self.boat_bubble_tick(i);
self.boat_contacts(i);
}
/// `Entity.canSimulateMovement`: for a boat (`AUTHORITATIVE_SIDE`) a
/// server leaves the boat its player drives to that player's client, and a
+1 -1
View File
@@ -258,7 +258,7 @@ impl Game<'_> {
self.remote_close_container();
}
self.book = None;
self.capture(true);
self.capture(self.screen == super::Screen::Playing && !self.screen_over_playing());
}
/// `BookEditScreen.saveChanges` and `BookSignScreen`'s Sign and Close:
/// trailing empty pages dropped, the book in hand updated here and, on a
+1 -1
View File
@@ -171,7 +171,7 @@ impl Game<'_> {
/// `Containers.dropItemStack(level, x, y, z, stack)` at the block's
/// integer position.
fn campfire_drop(&mut self, pos: Pos, stack: ItemStack) {
pub(super) fn campfire_drop(&mut self, pos: Pos, stack: ItemStack) {
let at = BlockPos::new(pos.0, pos.1, pos.2);
for piece in
horizon_world::level::removal::drop_item_stack(&mut self.world.random, at, stack.count)
+2
View File
@@ -574,6 +574,8 @@ impl Game<'_> {
/// last time, when this method can restore it (a command draft only for
/// /), else with its prefix.
pub(super) fn open_chat_method(&mut self, prefix: &str) {
// handleKeybinds: the number keys select before key.chat and key.command.
self.select_queued_hotbar();
let command = prefix == "/";
let draft = self
.ui
@@ -67,6 +67,8 @@ pub(super) enum Target {
Tnt(u32),
/// A falling block, by its entity id (`minecraft:falling_block`).
Falling(u32),
/// A firework rocket, by its entity id (`minecraft:firework_rocket`).
Rocket(u32),
/// An ominous item spawner, by its entity id (`minecraft:ominous_item_spawner`).
Spawner(u32),
}
@@ -144,6 +146,7 @@ fn non_player_entity_id(t: Target) -> Option<i32> {
| Target::Dragon(id)
| Target::Tnt(id)
| Target::Falling(id)
| Target::Rocket(id)
| Target::Spawner(id)) = t
else {
return None;
@@ -535,6 +538,11 @@ impl Game<'_> {
.iter()
.find(|f| f.id == id)
.map(|f| [f.position.x, f.position.y, f.position.z]),
Target::Rocket(id) => self
.rockets
.iter()
.find(|r| r.id == id)
.map(|r| [r.position.x, r.position.y, r.position.z]),
Target::Spawner(id) => self
.ominous_spawners
.iter()
@@ -588,6 +596,7 @@ impl Game<'_> {
Target::Dragon(_) => "minecraft:ender_dragon".into(),
Target::Tnt(_) => "minecraft:tnt".into(),
Target::Falling(_) => "minecraft:falling_block".into(),
Target::Rocket(_) => "minecraft:firework_rocket".into(),
Target::Spawner(_) => "minecraft:ominous_item_spawner".into(),
}
}
@@ -880,6 +889,7 @@ impl Game<'_> {
}
candidates.extend(self.tnts.iter().map(|t| Target::Tnt(t.id)));
candidates.extend(self.falling.iter().map(|f| Target::Falling(f.id)));
candidates.extend(self.rockets.iter().map(|r| Target::Rocket(r.id)));
candidates.extend(self.ominous_spawners.iter().map(|s| Target::Spawner(s.id)));
candidates
}
@@ -914,6 +924,7 @@ impl Game<'_> {
Target::Dragon(id),
Target::Tnt(id),
Target::Falling(id),
Target::Rocket(id),
Target::Spawner(id),
]
.into_iter()
@@ -938,6 +949,7 @@ impl Game<'_> {
Target::Dragon(id) => self.dragon.as_ref().is_some_and(|d| d.id == id),
Target::Tnt(id) => self.tnts.iter().any(|t| t.id == id),
Target::Falling(id) => self.falling.iter().any(|f| f.id == id),
Target::Rocket(id) => self.rockets.iter().any(|r| r.id == id),
Target::Spawner(id) => self.ominous_spawners.iter().any(|s| s.id == id),
}
}
@@ -1576,6 +1588,12 @@ impl Game<'_> {
f.previous = v;
}
}
Target::Rocket(id) => {
if let Some(r) = self.rockets.iter_mut().find(|r| r.id == id) {
r.position = v;
r.previous = v;
}
}
Target::Spawner(id) => {
if let Some(s) = self.ominous_spawners.iter_mut().find(|s| s.id == id) {
s.position = [v.x, v.y, v.z];
@@ -2080,6 +2098,7 @@ impl Game<'_> {
Target::Dragon(id) => self.kill_dragon(id),
Target::Tnt(id) => self.tnts.retain(|t| t.id != id),
Target::Falling(id) => self.falling.retain(|f| f.id != id),
Target::Rocket(id) => self.rockets.retain(|r| r.id != id),
Target::Spawner(id) => self.ominous_spawners.retain(|s| s.id != id),
}
// The orbs marked for removal are gone (Entity.kill removes them).
@@ -2981,6 +3000,7 @@ impl Game<'_> {
}
/// `MinecraftServer.setDifficulty(difficulty, true)`.
pub(super) fn set_difficulty_forced(&mut self, i: u8) {
let from = self.ui.difficulty;
self.ui.difficulty = i;
// The level data holds the world's difficulty: it survives the
// reopen, so it follows the command.
@@ -2999,6 +3019,10 @@ impl Game<'_> {
locked: self.level.difficulty_locked,
}));
}
if from != i {
let (from, to) = difficulty_names(from, i);
self.native_difficulty_changed(from, to);
}
}
fn cmd_spawnpoint(&mut self, ctx: &Context) -> R<i32> {
let targets = if ctx.get("targets").is_some() {
@@ -3725,6 +3749,30 @@ pub(super) fn block_input_matches(
.all(|(k, v)| s.properties.get(k) == Some(v))
}
/// The names `native_difficulty_changed` hears for a change from `from` to
/// `to`: the four difficulties of `options.difficulty.*`, the index kept in
/// range as the command and the options keep it.
fn difficulty_names(from: u8, to: u8) -> (&'static str, &'static str) {
(
DIFFICULTIES[usize::from(from) & 3],
DIFFICULTIES[usize::from(to) & 3],
)
}
#[cfg(test)]
mod difficulty_tests {
use super::difficulty_names;
#[test]
fn a_difficulty_change_names_both_ends() {
assert_eq!(difficulty_names(0, 3), ("peaceful", "hard"));
assert_eq!(difficulty_names(2, 1), ("normal", "easy"));
// The four difficulties are the only indexes the command and the
// options keep; an index outside them still names one.
assert_eq!(difficulty_names(7, 4), ("hard", "peaceful"));
}
}
#[cfg(test)]
mod summon_tests {
#[test]
@@ -3756,6 +3804,7 @@ mod look_at_tests {
Target::Dragon(7),
Target::Tnt(7),
Target::Falling(7),
Target::Rocket(7),
Target::Spawner(7),
] {
assert_eq!(non_player_entity_id(t), Some(7), "{t:?}");
@@ -68,7 +68,12 @@ impl Game<'_> {
.flatten()
}
fn entity_nbt_in_level(&mut self, t: Target) -> Option<Tag> {
let player_uuid = self.level.player_uuid;
// The acting player's own UUID: a guest's turn (the window's player on
// the server thread) keeps the world owner's in `level`.
let player_uuid = self
.lan_acting_uuid
.map(horizon_client::lan::playerdata::uuid_ints)
.or(self.level.player_uuid);
Some(match t {
Target::Guest(_) => {
return self
@@ -212,6 +217,11 @@ impl Game<'_> {
);
crate::game::persist::with_raw(tag, raw)
}
Target::Rocket(id) => {
let uuid = self.uuid_for(id);
let r = self.rockets.iter().find(|r| r.id == id)?;
r.to_nbt(uuid, player_uuid, self.persist.raw.get(&id))
}
Target::Spawner(id) => {
let s = self.ominous_spawners.iter().find(|s| s.id == id)?;
let [x, y, z] = s.position;
@@ -842,6 +852,8 @@ impl Game<'_> {
Some(Target::Tnt(id))
} else if self.falling.iter().any(|f| f.id == id) {
Some(Target::Falling(id))
} else if self.rockets.iter().any(|r| r.id == id) {
Some(Target::Rocket(id))
} else if self.ominous_spawners.iter().any(|s| s.id == id) {
Some(Target::Spawner(id))
} else {
+48 -3
View File
@@ -10,7 +10,27 @@ use std::{
};
use winit::{event::MouseButton, keyboard::KeyCode};
/// The control protocol's version (`docs/api/stability.md`): before 1.0 an
/// addition makes the next patch and a breaking change the next line.
const PROTOCOL: &str = "0.1.0";
/// The command groups the game serves, as `help` chains them: what `hello` reports as its features.
const FEATURES: &[&str] = &[
"game", "station", "blocks", "lan", "friends", "render", "sculk", "agent", "ui", "options",
"events", "saves", "pad",
];
/// `hello`: the protocol the socket speaks, the game's release and the command groups it serves.
fn hello() -> Value {
json!({
"protocol": PROTOCOL,
"release": horizon_core::release::VERSION,
"features": FEATURES,
})
}
const HELP: &[&str] = &[
"hello the control protocol's version, the game's release and the command groups it serves",
"status game, player, streaming and renderer state",
"simulation authoritative projectiles in the owner's level",
"server-hold on|off hold simulation and player packets at a control-fixture barrier",
@@ -36,7 +56,7 @@ const HELP: &[&str] = &[
"type TEXT type into the open chat box (key enter tap sends)",
"chat-log [N] the newest N chat messages (default 10)",
"options [PAGE] open the options (video sound controls keybinds mouse chat accessibility language font packs mods graphics graphics-advanced graphics-hdr)",
"stats [reset] frame-time summary since the last reset",
"stats [reset] [raw] frame-time summary since the last reset (raw: every frame time too)",
"narration the narrator's status and the last messages it was asked to say",
"bench SECONDS reset stats, wait, then reply with the summary",
"fly X1 Y1 Z1 X2 Y2 Z2 SECONDS [PITCH [raw]] fly the camera along a line at constant speed, then reply with the frame summary (raw: every frame time too)",
@@ -355,6 +375,7 @@ impl Game<'_> {
Ok(Some(json!({ "ok": true, "sides": r.section_sides() })))
}
"sculk" => Ok(Some(self.sculk_command(&words)?)),
"hello" => Ok(Some(hello())),
"status" => Ok(Some(self.control_status())),
"stall" => {
let millis: u64 = arg(1)?.parse()?;
@@ -859,8 +880,13 @@ impl Game<'_> {
})))
}
"stats" => {
let summary = self.summary_with_passes();
if words.get(1) == Some(&"reset") {
let mut summary = self.summary_with_passes();
if words[1..].contains(&"raw") {
summary["frame_ms"] = self.frame_stats.frame_millis().into();
summary["cpu_ms"] = self.frame_stats.cpu_millis().into();
summary["gpu_ms"] = self.frame_stats.gpu_millis().into();
}
if words[1..].contains(&"reset") {
self.frame_stats.reset();
}
Ok(Some(summary))
@@ -1403,6 +1429,25 @@ fn key_code(name: &str) -> Option<KeyCode> {
mod tests {
use super::*;
#[test]
fn hello_names_the_protocol_the_release_and_the_command_groups() {
let reply = hello();
assert_eq!(reply["release"], horizon_core::release::VERSION);
let protocol = reply["protocol"].as_str().unwrap();
let parts: Vec<_> = protocol.split('.').collect();
assert!(
parts.len() == 3 && parts.iter().all(|p| p.parse::<u32>().is_ok()),
"{protocol}"
);
let features = reply["features"].as_array().unwrap();
assert!(!features.is_empty());
let mut names: Vec<_> = features.iter().filter_map(Value::as_str).collect();
assert_eq!(names.len(), features.len());
names.sort_unstable();
names.dedup();
assert_eq!(names.len(), features.len(), "a group is named twice");
}
#[test]
fn taps_and_ups_always_release_their_key() {
for line in ["key shift tap", "key shift up", "key Shift up"] {
+46 -80
View File
@@ -21,6 +21,7 @@ use horizon_client::{
farm::Climate,
spawning::{self, BiomeFacts},
},
mod_api::NativeLootDrops,
particles::{ParticleOptions, ParticleType},
projectiles::{Hit, Projectile, ProjectileKind, ProjectileWorld},
text::Text,
@@ -487,14 +488,15 @@ impl Game<'_> {
}
}
/// `LivingEntity.hurtArmor` -> `LivingEntity.doHurtEquipment`, from the hit
/// `MobEvent::ArmorHurt` reported: every worn piece that wears when its
/// wearer is hurt loses `max(1, damage / 4)` durability, Unbreaking
/// applied, and at its maximum the slot empties and the equipment-break
/// event goes out (`LivingEntity.entityEventForEquipmentBreak`: 49 to 52
/// for the armour, 65 for the body). A fire hit leaves a fire-resistant
/// piece alone (`ItemStack.canBeHurtBy`).
/// `MobEvent::ArmorHurt` reported. Only `Horse` overrides `hurtArmor` for a
/// mob, and its override asks for the BODY slot alone, so the body piece is
/// the only one this wears: it loses `max(1, damage / 4)` durability,
/// Unbreaking applied, and at its maximum the slot empties and the break
/// event goes out (`LivingEntity.entityEventForEquipmentBreak`: 65 for the
/// body — 49 to 52 are the four armour slots, which only a player's own
/// pieces reach). A fire hit leaves a fire-resistant piece alone
/// (`ItemStack.canBeHurtBy`).
fn hurt_mob_armor(&mut self, id: u32, damage: f32, kind: horizon_client::mobs::DamageKind) {
use horizon_client::mobs::drops;
use horizon_world::player_inventory::EquipmentSlot;
if damage <= 0.0 {
return;
@@ -503,7 +505,6 @@ impl Game<'_> {
return;
};
let at = [m.position.x, m.position.y, m.position.z];
let body_armour = drops::head_is_body(m.species());
let amount = ((damage / 4.0) as u32).max(1);
let name = damage_type(kind, false);
let fire = self
@@ -513,9 +514,15 @@ impl Game<'_> {
.id(name)
.is_some_and(|d| self.motion.health.damage.in_tag(d, "minecraft:is_fire"));
// A piece's maximum, or none when it may not lose durability at all.
// `doHurtEquipment` starts from the item's `equippable` component
// (`Equippable equippable = item.get(DataComponents.EQUIPPABLE)`), so a
// damageable item with no such component — a sword put in a horse's body
// slot by `/item replace` — never wears; `equip_damage_on_hurt` alone
// cannot say that, since it defaults to true when the component is absent.
fn wearable(me: &Game<'_>, stack: &ItemStack, fire: bool, name: &str) -> Option<u32> {
let info = me.res.items().get(&stack.item)?;
if !info.equip_damage_on_hurt
if info.equip_slot.is_none()
|| !info.equip_damage_on_hurt
|| info.max_damage == 0
|| !horizon_client::stations::is_damageable(me.res.items(), stack)
|| (fire && me.res.items().resists_fire(stack, name))
@@ -524,66 +531,39 @@ impl Game<'_> {
}
Some(info.max_damage)
}
let mut broke: Vec<i8> = Vec::new();
// The four armour slots, then the body slot (`extra.head`).
let event_of = horizon_client::mobs::item_break::equipment_break_event;
for (index, slot) in [
(0usize, EquipmentSlot::Head),
(1, EquipmentSlot::Chest),
(2, EquipmentSlot::Legs),
(3, EquipmentSlot::Feet),
] {
let Some(m) = self.mobs.iter().find(|m| m.id == id) else {
return;
};
let Some(stack) = m.extra.armor[index].clone() else {
continue;
};
let Some(max) = wearable(self, &stack, fire, name) else {
continue;
};
let loss = self.durability_loss(&stack, amount);
if loss == 0 {
continue;
}
let lost = stack.damage + loss;
let Some(body) = self
.mobs
.iter()
.find(|m| m.id == id)
.and_then(|m| m.item_by_slot(EquipmentSlot::Body))
else {
return;
};
let Some(max) = wearable(self, &body, fire, name) else {
return;
};
let loss = self.durability_loss(&body, amount);
if loss == 0 {
return;
}
let lost = body.damage + loss;
let broke = {
let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) else {
return;
};
if lost >= max {
m.extra.armor[index] = None;
broke.push(event_of(slot));
} else if let Some(worn) = m.extra.armor[index].as_mut() {
worn.damage = lost;
m.extra.head = None;
m.extra.body_damage = 0;
true
} else {
m.extra.body_damage = lost as i32;
false
}
}
if body_armour {
let body = self
.mobs
.iter()
.find(|m| m.id == id)
.and_then(|m| m.item_by_slot(EquipmentSlot::Body));
if let Some(body) = body
&& let Some(max) = wearable(self, &body, fire, name)
{
let loss = self.durability_loss(&body, amount);
if loss > 0 {
let lost = body.damage + loss;
let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) else {
return;
};
if lost >= max {
m.extra.head = None;
m.extra.body_damage = 0;
broke.push(event_of(EquipmentSlot::Body));
} else {
m.extra.body_damage = lost as i32;
}
}
}
}
for event in broke {
};
if broke {
// LivingEntity.onEquippedItemBroken through the level's broadcast.
let event =
horizon_client::mobs::item_break::equipment_break_event(EquipmentSlot::Body);
self.lan_entity_event(id as i32, event, at);
}
}
@@ -1498,7 +1478,7 @@ impl Game<'_> {
}
let kind = super::native_hooks::mob_kind(m);
let table = drops::death_loot_table(m, death.mob_drops).filter(|t| loot.has_table(t));
let context = super::native_hooks::NativeLootDrops {
let context = NativeLootDrops {
table: table.as_deref().unwrap_or_default(),
dimension: self.dim.id(),
origin: [m.position.x, m.position.y, m.position.z],
@@ -6475,14 +6455,7 @@ impl Game<'_> {
// ShulkerBulletRenderer: the tumbling bullet model, then a larger
// translucent copy of it (colour 0x26FFFFFF); always 15 block light.
if matches!(a.kind, ProjectileKind::ShulkerBullet { .. }) {
static BULLET: std::sync::OnceLock<Option<horizon_client::mobs::Part>> =
std::sync::OnceLock::new();
let model = BULLET.get_or_init(|| {
horizon_client::mobs::load_model(
&self.options.resources,
"minecraft:shulker_bullet#main",
)
});
let model = &self.entity_models.shulker_bullet;
if let (Some(model), Some(r)) =
(model, atlas.sprites.get("entity/shulker/spark").copied())
{
@@ -6534,14 +6507,7 @@ impl Game<'_> {
}
// WitherSkullRenderer: the skull model facing its flight.
if let Some(dangerous) = super::wither::skull_kind(a) {
static SKULL: std::sync::OnceLock<Option<horizon_client::mobs::Part>> =
std::sync::OnceLock::new();
let model = SKULL.get_or_init(|| {
horizon_client::mobs::load_model(
&self.options.resources,
"minecraft:wither_skull#main",
)
});
let model = &self.entity_models.wither_skull;
let tex = if dangerous {
"entity/wither/wither_invulnerable"
} else {
+43 -2
View File
@@ -257,6 +257,16 @@ fn forwarding_secret(online: bool) -> Result<Option<Vec<u8>>> {
Ok(Some(secret))
}
/// `MinecraftServer.runServer`'s overload warning for a server `behind` its
/// schedule, in milliseconds and in ticks of `tick`.
fn overload_warning(behind: Duration, tick: Duration) -> String {
format!(
"Can't keep up! Is the server overloaded? Running {}ms or {} ticks behind",
behind.as_millis(),
behind.as_nanos() / tick.as_nanos().max(1)
)
}
/// Log4j's console pattern: `[HH:mm:ss] [thread/LEVEL]: message`.
pub(super) fn log(level: &str, message: &str) {
log_from("Server thread", level, message);
@@ -502,6 +512,13 @@ const LEGACY_PRESET_NAMES: [(&str, horizon_client::save::WorldPreset); 2] = [
/// server.properties' line for `server.toml`'s `max_view_distance` (horizon-server writes it).
const FAR_PROPERTY: &str = "horizon-max-view-distance";
/// A dedicated server logs a tick slower than this, in milliseconds, with
/// its spans, unless `HORIZON_SPIKES` names another threshold: five ticks'
/// worth, a stall players feel as rubber-banding.
pub(super) const SLOW_TICK_MS: f32 = 250.0;
/// `MinecraftServer.OVERLOADED_WARNING_INTERVAL_NANOS` plus 100 ticks: the
/// "Can't keep up!" warning comes at most this often.
const OVERLOAD_WARNING_INTERVAL: Duration = Duration::from_secs(15);
/// server.properties' line for `server.toml`'s `admin_feedback_to_ops` (horizon-server writes it).
const ADMIN_FEEDBACK_PROPERTY: &str = "horizon-admin-feedback-to-ops";
@@ -618,6 +635,8 @@ struct ServerState {
/// View and simulation distance, and the player limit, for the report.
view: (u32, u32),
max_players: usize,
/// The mutable `gamemode` server property (`DedicatedServer.gameMode`).
game_mode: i32,
}
/// The dedicated-only commands' replies vanilla sends with `broadcast`
@@ -985,6 +1004,7 @@ impl Game<'_> {
broadcast_console: props.broadcast_console_to_ops,
broadcast_rcon: props.broadcast_rcon_to_ops,
op_level: props.op_permission_level,
game_mode: props.game_mode,
report: None,
view: (
props.view_distance.clamp(2, 32) as u32,
@@ -1000,6 +1020,7 @@ impl Game<'_> {
self.fns.permission = Some(props.function_permission_level.clamp(0, 4) as u8);
let watchdog = Watchdog::start(props.max_tick_time);
let mut braked = Instant::now();
let mut overload_warned: Option<Instant> = None;
while !state.stop {
let tick_start = Instant::now();
if let Some(watchdog) = &watchdog {
@@ -1089,7 +1110,17 @@ impl Game<'_> {
far.brake(mspt, host.hub.config.view_distance);
}
state.ticked(Instant::now());
self.wait_for_next_server_tick(&mut next, paused);
// MinecraftServer.runServer: a server more than a second's ticks
// behind skips them and says so, at most every 15 seconds.
if let Some(behind) = self.wait_for_next_server_tick(&mut next, paused)
&& overload_warned.is_none_or(|at| at.elapsed() >= OVERLOAD_WARNING_INTERVAL)
{
overload_warned = Some(Instant::now());
log(
"WARN",
&overload_warning(behind, self.clock.tick_duration()),
);
}
}
if let Some(watchdog) = &watchdog {
watchdog.running.store(false, Ordering::Relaxed);
@@ -1975,7 +2006,7 @@ fn png_size(bytes: &[u8]) -> Option<(u32, u32)> {
#[cfg(test)]
mod tests {
use super::{capture_log, log, log_from, panic_message};
use super::{capture_log, log, log_from, overload_warning, panic_message};
use std::{
fs,
io::{self, BufRead, BufReader, Read, Write},
@@ -1983,6 +2014,16 @@ mod tests {
time::{SystemTime, UNIX_EPOCH},
};
/// Vanilla's words, with how far behind the server is in milliseconds and in whole ticks.
#[test]
fn the_overload_warning_reads_as_vanilla_s() {
let tick = std::time::Duration::from_millis(50);
assert_eq!(
overload_warning(std::time::Duration::from_millis(13_027), tick),
"Can't keep up! Is the server overloaded? Running 13027ms or 260 ticks behind"
);
}
/// A `panic!("literal")` carries a `&str`, a formatted one a `String`; anything else is
/// named as unknown, so the crash line is never empty.
#[test]
@@ -3,7 +3,7 @@
//! `AllowlistService`, `BanlistService`, `IpBanlistService` and `OperatorService` on the running server's lists. Players are found as
//! `MinecraftPlayerListService.getUser` finds them, each change is logged as `JsonRpcLogger` logs it, and the
//! players a change shuts out are disconnected. The supervisor answers with the lists, from their files.
use super::{ServerState, log};
use super::{GAME_MODES, ServerState, log};
use crate::game::Game;
use horizon_client::{
command::vanilla::DIFFICULTIES,
@@ -70,6 +70,25 @@ impl Game<'_> {
self.store_property("motd", value.to_owned());
return json!({ "value": value });
}
"serversettings/game_mode" => {
return json!({ "value": GAME_MODES[state.game_mode as usize] });
}
"serversettings/game_mode/set" => {
let Some(value) = body["value"].as_str() else {
return json!({ "error": "game mode must be a string" });
};
let Some(index) = GAME_MODES.iter().position(|name| *name == value) else {
return json!({ "error": format!("unknown game mode {value:?}") });
};
rpc(format!(
"Update game mode from '{}' to '{}'",
GAME_MODES[state.game_mode as usize], value
));
self.store_property("gamemode", value.to_owned());
state.game_mode = index as i32;
self.set_default_game_mode(state.game_mode);
return json!({ "value": GAME_MODES[state.game_mode as usize] });
}
"serversettings/difficulty" => {
return json!({ "value": DIFFICULTIES[self.level.difficulty_id() as usize] });
}
+2 -4
View File
@@ -13,6 +13,8 @@ use horizon_client::{
particles::{ParticleOptions, ParticleType},
projectiles::{Projectile, ProjectileKind},
};
// Its old home, for mixin code that names it here.
pub(super) use horizon_client::mod_api::NativeDispensed;
use horizon_core::{
geometry::{Aabb, Direction, Vec3},
position::BlockPos,
@@ -99,10 +101,6 @@ fn shrink(mut stack: ItemStack) -> Option<ItemStack> {
/// A dispenser's answer: the slot's new content and the level event played.
type Dispensed = (Option<ItemStack>, i32);
/// A dispenser hook's answer: the slot's remainder (None empties it) and the
/// level event to play, `CLICK` (1000), `FAIL` (1001) or 0 for none.
pub(super) type NativeDispensed = (Option<ItemStack>, i32);
/// None keeps vanilla's behaviour; an explicit empty remainder consumes the stack.
fn mod_dispensed(answer: Option<NativeDispensed>) -> Option<Dispensed> {
answer.map(|(left, event)| (left.filter(|stack| stack.count > 0), event))
+6 -3
View File
@@ -1814,8 +1814,9 @@ impl Game<'_> {
{
y -= 1.0;
}
let id = self.take_entity_id();
self.breath_clouds.push(Breath {
id: 0,
id,
at: [x, y.floor() + 1.0, z],
radius: 5.0,
age: 0,
@@ -2291,8 +2292,9 @@ impl Game<'_> {
);
// assignDirectionalMovement: the direction times the acceleration power 0.1.
let velocity = Vec3::new(v.x * 0.1, v.y * 0.1, v.z * 0.1);
let id = self.take_entity_id();
self.dragon_fireballs.push(DragonFireball {
id: 0,
id,
position: at,
previous: at,
velocity,
@@ -2338,8 +2340,9 @@ impl Game<'_> {
))
});
if hit_block || hit_player || b.age > 600 {
let id = self.take_entity_id();
self.breath_clouds.push(Breath {
id: 0,
id,
at: [next.x, next.y, next.z],
radius: 3.0,
age: 0,
@@ -89,7 +89,17 @@ impl Game<'_> {
/// `ItemStack.hurtAndBreak` for the player's `stack`: the wear Unbreaking
/// leaves, and `ItemDurabilityTrigger` when some is left (`applyDamage`).
pub(super) fn wear(&mut self, stack: &ItemStack, amount: u32) -> u32 {
let loss = self.durability_loss(stack, amount);
let mut loss = self.durability_loss(stack, amount);
// The mods hear a server player's wear once Unbreaking has had its say,
// and may change it or stop it.
if loss > 0
&& let (Some(uuid), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone())
{
let mut points = loss as i32;
let mut cancel = false;
self.native_item_damaged(uuid, &name, &stack.item, &mut points, &mut cancel);
loss = if cancel { 0 } else { points.max(0) as u32 };
}
if loss > 0 {
self.trigger_item_durability(stack, stack.damage + loss);
}
+5 -3
View File
@@ -1302,8 +1302,10 @@ impl Game<'_> {
target
};
let survive = self.world.random.next_int_bound(5).unwrap_or(0) > 0;
self.next_entity += 1;
let id = self.next_entity;
self.end.eyes.push(EyeOfEnder {
id: 0,
id,
position: start,
previous: start,
velocity: glam::DVec3::ZERO,
@@ -1571,7 +1573,7 @@ impl Game<'_> {
// A gateway keeps the item in its level (teleportSameDimension
// moves it): it doesn't leave, so no native_entity_leave_level.
self.dropped.remove(i);
self.forget_entity(id);
self.detach_entity(id);
tag
} else if let Some(i) = self.mobs.iter().position(|m| m.id == id) {
let mut tag = self.mob_tree_nbt(i);
@@ -1594,7 +1596,7 @@ impl Game<'_> {
}
self.mobs.retain(|m| !gone.contains(&m.id));
for g in gone {
self.forget_entity(g);
self.detach_entity(g);
}
tag
} else if let Some(i) = self.creatures.projectiles.iter().position(|p| p.id == id) {
+308
View File
@@ -0,0 +1,308 @@
//! `Entity`: what every kind in the game's entity lists answers to, so a system
//! can treat them alike and only write down where vanilla's kinds differ.
//!
//! Default methods follow vanilla's `Entity`; a kind overrides one only where
//! its vanilla class does. [`EntityHandle`] names one entity by list and index,
//! so a system can walk handles and reach each entity after other borrows of
//! `Game` end. The walk allocates nothing and dispatch is static: hot paths pay
//! no vtable. A new kind is one line in `entity_lists!` and one `impl Entity`.
use super::{FallingBlock, Game};
use super::{
boats::Boat, fireworks::Rocket, minecarts::Minecart, tnt::PrimedTnt, trident::ThrownTrident,
};
use horizon_client::entities::{ArmorStand, ExperienceOrb, ItemEntity};
use horizon_client::mobs::Mob;
use horizon_client::projectiles::Projectile;
use horizon_core::geometry::{Aabb, Direction, Vec3};
use horizon_core::position::BlockPos;
use horizon_world::{level::data::PushReaction, piston_push};
pub(super) trait Entity {
fn position(&self) -> Vec3;
fn position_mut(&mut self) -> &mut Vec3;
fn velocity_mut(&mut self) -> &mut Vec3;
fn bounds(&self) -> Aabb;
fn on_ground(&self) -> bool {
false
}
/// `Entity.maxUpStep`.
fn get_step_height(&self) -> f32 {
0.0
}
fn is_alive(&self) -> bool {
true
}
/// `Entity.isSupportedBy`.
fn is_supported_by(&self, _pos: BlockPos) -> bool {
false
}
/// `Entity.getPistonPushReaction`.
fn get_piston_push_reaction(&self) -> PushReaction {
PushReaction::PushPull
}
/// `Entity.move(MoverType.PISTON, …)` once the movement is resolved.
fn move_by_piston(&mut self, movement: Vec3) {
let position = self.position_mut();
*position = position.add_vector(movement);
}
/// `PistonMovingBlockEntity.causeBounce`: a slime block sets the velocity
/// along the movement axis.
fn piston_slime_bounce(&mut self, movement: Direction) {
let velocity = self.velocity_mut();
*velocity = piston_push::bounce_velocity(movement, *velocity);
}
}
/// The fields every kind stores under the same names.
macro_rules! stored_motion {
() => {
fn position(&self) -> Vec3 {
self.position
}
fn position_mut(&mut self) -> &mut Vec3 {
&mut self.position
}
fn velocity_mut(&mut self) -> &mut Vec3 {
&mut self.velocity
}
};
}
/// The game's lists of entities, in the order systems walk them. Hands them to
/// `$then`, so one list drives the handles and every typed dispatch.
macro_rules! entity_lists {
($then:ident, $($args:tt)*) => {
$crate::game::entity::$then! {
($($args)*)
Mob: mobs,
Item: dropped,
Boat: boats,
Cart: carts,
ArmorStand: armor_stands,
Tnt: tnts,
Falling: falling,
Orb: orbs,
Projectile: creatures.projectiles,
Rocket: rockets,
Trident: tridents,
}
};
}
pub(crate) use entity_lists;
macro_rules! define_entity_handles {
(() $($kind:ident: $($list:ident).+,)*) => {
/// One entity, by list and index.
#[derive(Clone, Copy, Hash, PartialEq, Eq)]
pub(super) enum EntityHandle {
$($kind(usize),)*
}
impl Game<'_> {
/// Every entity's handle: lengths read now, nothing allocated, and
/// no borrow of `Game` held, so the caller can change it mid-walk.
pub(super) fn entity_handles(&self) -> impl Iterator<Item = EntityHandle> + use<> {
std::iter::empty()
$(.chain((0..self$(.$list)+.len()).map(EntityHandle::$kind)))*
}
}
};
}
pub(crate) use define_entity_handles;
entity_lists!(define_entity_handles,);
macro_rules! entity_borrow {
(shared, $place:expr) => {
&$place
};
(exclusive, $place:expr) => {
&mut $place
};
}
pub(crate) use entity_borrow;
macro_rules! match_entity {
(($borrow:ident, $entity:ident, $game:expr, $handle:expr, $body:expr)
$($kind:ident: $($list:ident).+,)*) => {
match $handle {
$($crate::game::entity::EntityHandle::$kind(i) => {
let $entity = $crate::game::entity::entity_borrow!($borrow, $game$(.$list)+[i]);
$body
})*
}
};
}
pub(crate) use match_entity;
/// Runs `$body` with `$entity` bound to the handle's entity, as its own type:
/// one match, each arm compiled for its kind, so nothing goes through a vtable.
macro_rules! with_entity {
($game:expr, $handle:expr, $entity:ident => $body:expr) => {
$crate::game::entity::entity_lists!(match_entity, shared, $entity, $game, $handle, $body)
};
}
pub(super) use with_entity;
/// [`with_entity`], with the entity mutable.
macro_rules! with_entity_mut {
($game:expr, $handle:expr, $entity:ident => $body:expr) => {
$crate::game::entity::entity_lists!(match_entity, exclusive, $entity, $game, $handle, $body)
};
}
pub(super) use with_entity_mut;
impl Entity for Mob {
stored_motion!();
fn bounds(&self) -> Aabb {
Mob::bounds(self)
}
fn on_ground(&self) -> bool {
self.on_ground
}
fn get_step_height(&self) -> f32 {
self.kind.step_height as f32
}
fn is_alive(&self) -> bool {
!self.removed && self.alive()
}
fn is_supported_by(&self, pos: BlockPos) -> bool {
self.supporting == Some([pos.x, pos.y, pos.z])
}
}
impl Entity for ItemEntity {
stored_motion!();
fn bounds(&self) -> Aabb {
ItemEntity::bounds(self)
}
fn on_ground(&self) -> bool {
self.on_ground
}
fn move_by_piston(&mut self, movement: Vec3) {
self.position = self.position.add_vector(movement);
// Entity.move(MoverType.PISTON): onGround only if the push was down.
if movement.y != 0.0 {
self.on_ground = movement.y < 0.0;
}
}
fn piston_slime_bounce(&mut self, movement: Direction) {
ItemEntity::piston_slime_bounce(self, movement);
}
}
impl Entity for ExperienceOrb {
stored_motion!();
fn bounds(&self) -> Aabb {
ExperienceOrb::bounds(self)
}
fn on_ground(&self) -> bool {
self.on_ground
}
fn move_by_piston(&mut self, movement: Vec3) {
self.position = self.position.add_vector(movement);
if movement.y != 0.0 {
self.on_ground = movement.y < 0.0;
}
}
fn piston_slime_bounce(&mut self, movement: Direction) {
self.velocity = piston_push::bounce_velocity(movement, self.velocity);
self.on_ground = false;
}
}
impl Entity for Boat {
stored_motion!();
fn bounds(&self) -> Aabb {
Boat::bounds(self)
}
fn on_ground(&self) -> bool {
self.on_ground
}
}
impl Entity for Minecart {
stored_motion!();
fn bounds(&self) -> Aabb {
Minecart::bounds(self)
}
fn on_ground(&self) -> bool {
self.on_ground
}
}
impl Entity for ArmorStand {
stored_motion!();
fn bounds(&self) -> Aabb {
ArmorStand::bounds(self)
}
fn on_ground(&self) -> bool {
self.on_ground
}
/// `ArmorStand.getPistonPushReaction`: a marker is ignored.
fn get_piston_push_reaction(&self) -> PushReaction {
if self.marker {
PushReaction::IgnoreEntity
} else {
PushReaction::PushPull
}
}
}
impl Entity for PrimedTnt {
stored_motion!();
fn bounds(&self) -> Aabb {
PrimedTnt::bounds(self)
}
fn on_ground(&self) -> bool {
self.on_ground
}
fn piston_slime_bounce(&mut self, movement: Direction) {
self.velocity = piston_push::bounce_velocity(movement, self.velocity);
// Leave the ground so the next tick does not apply the on-ground
// Y * -0.5 friction that cancels the launch.
self.on_ground = false;
}
}
impl Entity for FallingBlock {
stored_motion!();
fn bounds(&self) -> Aabb {
horizon_client::falling_block::bounds(self.position)
}
}
impl Entity for Projectile {
stored_motion!();
fn bounds(&self) -> Aabb {
Projectile::bounds(self)
}
fn move_by_piston(&mut self, movement: Vec3) {
self.position = self.position.add_vector(movement);
self.needs_sync = true;
}
fn piston_slime_bounce(&mut self, movement: Direction) {
self.velocity = piston_push::bounce_velocity(movement, self.velocity);
self.needs_sync = true;
}
}
impl Entity for Rocket {
stored_motion!();
fn bounds(&self) -> Aabb {
Rocket::bounds(self)
}
}
impl Entity for ThrownTrident {
stored_motion!();
fn bounds(&self) -> Aabb {
let p = self.position;
Aabb::new(
Vec3::new(p.x - 0.25, p.y, p.z - 0.25),
Vec3::new(p.x + 0.25, p.y + 0.5, p.z + 0.25),
)
}
}
+1 -5
View File
@@ -28,11 +28,7 @@ impl Game<'_> {
pub(super) fn insert_fangs(&mut self, id: u32, at: [f64; 3], yaw: f32, delay: i32, owner: u32) {
if self.fangs.model.is_none() {
self.fangs.model = horizon_client::mobs::load_model(
&self.options.resources,
"minecraft:evoker_fangs#main",
)
.map(Arc::new);
self.fangs.model = self.entity_models.evoker_fangs.clone();
}
self.fangs.list.push(Fangs {
id,
+5 -2
View File
@@ -29,7 +29,8 @@ enum RocketHit {
}
pub(super) struct Rocket {
/// The entity id a server tracks it by (0 until it is tracked).
/// Its entity id: from the entity counter on the server, the server's on
/// a client.
pub id: u32,
pub(super) position: Vec3,
pub(super) previous: Vec3,
@@ -223,8 +224,10 @@ impl Game<'_> {
let lifetime = 10 * (1 + i32::from(flight))
+ r.next_int_bound(6).unwrap_or(0)
+ r.next_int_bound(7).unwrap_or(0);
// An id from the entity counter at once, so a command can name it.
self.next_entity += 1;
Rocket {
id: 0,
id: self.next_entity,
position: at,
previous: at,
velocity,
+29
View File
@@ -98,6 +98,22 @@ impl Game<'_> {
if &*stack.item != "minecraft:fishing_rod" {
return false;
}
// The mods hear a server player's cast or reel-in first.
if self.player.is_some()
&& let (Some(uuid), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone())
{
let state = match &self.hook {
None => 0,
Some(h) if matches!(h.state, State::Hooked(_)) => 4,
Some(h) if h.nibble > 0 => 2,
Some(_) => 3,
};
let mut cancel = false;
self.native_player_fish(uuid, &name, state, &mut cancel);
if cancel {
return true;
}
}
let Some(p) = self.player.as_ref() else {
return false;
};
@@ -419,6 +435,19 @@ impl Game<'_> {
}
}
} else {
// The mods hear a server player's fish bite, and may call it
// off: the wait starts over.
if let (Some(uuid), Some(name)) =
(self.lan_acting_uuid, self.lan_acting_name.clone())
{
let mut cancel = false;
self.native_player_fish(uuid, &name, 1, &mut cancel);
if cancel {
h.time_until_lured = 0;
h.time_until_hooked = 0;
return;
}
}
let pitch =
1.0 + (self.world.random.next_float() - self.world.random.next_float()) * 0.4;
self.audio.play(
+98 -9
View File
@@ -205,6 +205,94 @@ impl Game<'_> {
self.attack_ticks = 0;
self.hand_strength.reset();
}
/// `Minecraft.tick`'s `pick`, then `handleKeybinds`' number keys: the
/// tick's clicks act on what the item held before the keys picked (a
/// spear's 4.5 blocks), with the item the keys select (attribute
/// swapping a spear's reach onto a mace).
pub(super) fn pick_tick_targets(&mut self) {
self.tick_hit = Some(self.pick());
self.tick_mob = None;
let mob = match self.active_attack_range() {
Some(range) => self.attack_range_pick(&range),
None => None,
};
self.tick_mob = Some(mob.unwrap_or_else(|| self.mob_hit()));
self.select_queued_hotbar();
}
/// `handleKeybinds`' number keys (`keyHotbarSlots[i].consumeClick`).
pub(super) fn select_queued_hotbar(&mut self) {
for slot in std::mem::take(&mut self.hotbar_presses) {
self.selected = slot;
}
}
/// The tick's clicks are done: later picks follow the crosshair again.
pub(super) fn release_tick_targets(&mut self) {
self.tick_hit = None;
self.tick_mob = None;
}
/// `Minecraft.startAttack` on an entity: a main-hand item with an
/// `attack_range` attacks only a hit inside it.
pub(super) fn held_range_allows(&self, distance: f64) -> bool {
let range = self.held().and_then(|stack| {
self.pack
.item_renderer
.models
.components(&horizon_client::item_render::stack_view(stack))
.get("minecraft:attack_range")
.cloned()
});
range.is_none_or(|r| attack_range_contains(&r, distance, self.creative))
}
/// `LocalPlayer.raycastHitResult`'s item (`getActiveItem`: the one in
/// use, else the main hand's) `attack_range` component.
fn active_attack_range(&self) -> Option<serde_json::Value> {
let active = self
.using
.and_then(|(slot, _)| self.inventory.get(slot).and_then(Option::as_ref))
.or_else(|| self.held())?;
self.pack
.item_renderer
.models
.components(&horizon_client::item_render::stack_view(active))
.get("minecraft:attack_range")
.cloned()
}
/// `AttackRange.getClosesetHit` against the mobs `mob_hit` can pick
/// (`EntitySelector.CAN_BE_PICKED`), as `raycastHitResult` reads it: the
/// nearest mob hit within the item's reach; no mob when the clip met a
/// block within the block reach (`getHitEntitiesAlong`'s block result,
/// kept by `filterHitResult`); `None` on a miss, where the plain pick
/// decides.
fn attack_range_pick(&self, range: &serde_json::Value) -> Option<Option<(usize, f64)>> {
let eye = v3(self.eye_position()?);
let look = v3(super::view_vector(self.yaw, self.pitch));
let reach = attack_reach(range, self.creative);
let camera = self.remote_camera_id();
let boxes: Vec<(usize, Aabb)> = (self.mobs.iter().enumerate())
.filter(|(_, m)| !m.removed && Some(m.id) != self.riding && Some(m.id) != camera)
.map(|(i, m)| (i, m.bounds()))
.collect();
let clip = |from: Vec3, to: Vec3| {
let length = from.distance_squared(to).sqrt();
let d = to.subtract(from).scale(1.0 / length);
(length > 0.0)
.then(|| self.clip_ray([from.x, from.y, from.z], [d.x, d.y, d.z], length))
.flatten()
.map(|hit| Vec3::new(hit.point[0], hit.point[1], hit.point[2]))
};
let movement = self.known_player_movement();
let nearest = super::spear::hits_in_reach(&boxes, eye, look, movement, reach, clip)
.into_iter()
.map(|(i, at)| (i, eye.distance_squared(at).sqrt()))
.reduce(|a, b| if b.1 < a.1 { b } else { a });
if nearest.is_some() {
return Some(nearest);
}
let to = eye.add_vector(look.scale(f64::from(reach.1) + movement.dot(look).max(0.0)));
clip(eye, to)
.is_some_and(|block| eye.distance_squared(block) <= self.block_reach.powi(2))
.then_some(None)
}
/// The attack and use part of `Minecraft.tick` for the local player:
/// attack clicks since the last tick and the held attack button.
pub(super) fn tick_local_hand(&mut self) -> Result<()> {
@@ -216,11 +304,10 @@ impl Game<'_> {
// (scripts under --control hold buttons without grabbing it).
let down = self.left;
let ticks = self.clicks.destroying.ticks;
// Minecraft.hitResult is picked once per frame: a creative break
// does not move the use that follows it past the broken block.
if use_clicks > 0 && attack_clicks > 0 {
self.tick_hit = Some(self.pick());
}
// Minecraft.hitResult is picked once per tick, before the number
// keys: a creative break does not move the use that follows it
// past the broken block.
self.pick_tick_targets();
let mut world = GameHands {
game: self,
error: None,
@@ -229,7 +316,7 @@ impl Game<'_> {
let error = world.error.take();
self.clicks = clicks;
if let Some(e) = error {
self.tick_hit = None;
self.release_tick_targets();
return Err(e);
}
// Use clicks follow the attack clicks within the tick, against
@@ -245,7 +332,7 @@ impl Game<'_> {
break;
}
}
self.tick_hit = None;
self.release_tick_targets();
used?;
// A tick of breaking: the hit sound every fourth tick and the
// cracking particles.
@@ -267,8 +354,10 @@ impl Game<'_> {
}
}
} else {
// Minecraft.tick: a screen keeps the attack from starting.
// Minecraft.tick: a screen keeps the attack from starting, and
// opening it released the keys (`KeyMapping.releaseAll`).
self.clicks.miss_time = 10000;
self.hotbar_presses.clear();
}
let d = &self.clicks.destroying;
self.mining = d.active.then_some(Mining {
@@ -385,7 +474,7 @@ impl Game<'_> {
pub(super) fn tick_hand(&mut self) {
// LocalPlayer.applyInput (the camera is the player's own).
if self.player.is_some() {
self.sway.tick(self.pitch, self.yaw);
self.sway.tick_wrapped(self.pitch, self.yaw);
}
self.swing.tick();
let main = self.inventory[self.selected].clone();
+11 -4
View File
@@ -17,6 +17,7 @@ use super::{
world_loading::{WorldLoader, WorldRequest},
};
use horizon_client::level_loading::{STATUS_RADIUS, Stage};
use horizon_client::spans::startup;
use horizon_client::{
Result,
audio::Audio,
@@ -685,9 +686,11 @@ impl Game<'_> {
self.native_server_about_to_start(false);
self.native_lifecycle_open_started();
// `load_world`, with the world `serve` started preparing.
self.join_world_rest()?;
startup::time("server.wait_rest", || self.join_world_rest())?;
self.perf.world_opening();
let open_step = startup::step("server.open_world");
self.finish_open_world(opening.world?.finish()?)?;
drop(open_step);
if let Some(sync) = opening.sync {
sync.adopt(&self.pack_files());
}
@@ -751,7 +754,9 @@ impl Game<'_> {
guest_commands: false,
force_game_mode: false,
};
self.lan_host(settings, net, config, None)?;
startup::time("server.lan_host", || {
self.lan_host(settings, net, config, None)
})?;
let commands = self.allow_commands();
let pipe = {
let host = self
@@ -781,7 +786,7 @@ impl Game<'_> {
view.start(Stage::LoadInitialChunks, 0);
view.finish(Stage::LoadInitialChunks);
let _ = ready.send(Ok((pipe, self.level.name.clone())));
self.settle_spawn()
startup::time("server.settle_spawn", || self.settle_spawn())
}
/// `MinecraftServer.stopServer`: the players are saved and sent away,
@@ -1141,7 +1146,9 @@ fn serve(
world,
sync: preload_sync_data(&options.resources),
};
let mut server = match server_game(res, pack, options, settings) {
let mut server = match startup::time("server.game_new", || {
server_game(res, pack, options, settings)
}) {
Ok(server) => server,
Err(e) => {
// The world's session lock is let go before the failure is told.
Loaded 100 of 309 files, more files were not shown because too many files have changed in this diff. Show more