Compare commits

..
Author SHA1 Message Date
Kacper a5dbd2c5c1 Merge pull request #15240 from yahya-nusseibeh/fix/neoforge-version-path
Port-pack: only a plain NeoForge version makes the loader jar's path
2026-11-12 03:32:40 +01:00
Kacper 4c4de96fa5 Merge pull request #14760 from MSedgeMC/fix/walk-target-scores
A stroll scores its ground the way the jar does
2026-11-12 03:31:51 +01:00
Kacper c19aaf98e5 Merge pull request #14060 from MSedgeMC/fix/jigsaw-priority-tooltip
The jigsaw's priority boxes explain themselves on hover
2026-11-12 03:31:18 +01:00
Kacper 30a7b86bc8 Merge pull request #15296 from rdbtCVS/fix/deep-end-boat-void
Prevent deeply fallen boats from crashing dedicated servers
2026-11-12 03:18:14 +01:00
Kacper 0e0f95efa6 Merge pull request #15281 from Akirabane/fix/lan-accept-teleport-twice
LAN: a second accept of the same teleport id disconnects the guest, as vanilla does
2026-11-12 03:17:57 +01:00
Kacper 483497ef1c Merge pull request #14695 from ilguerro/fix/cave-air-is-air
Count cave air as air for open-water fishing and the flying kick
2026-11-12 03:17:40 +01:00
Kacper f7ba023dc6 Merge pull request #15435 from Akirabane/refactor/move-model-parts
Move the model parts out of mobs.rs into mobs/model.rs
2026-11-12 03:16:54 +01:00
Kacper cad46c5ff5 Merge pull request #15414 from mentallyunstable-lab/docs/layers-exceptions-15408
docs(layers): regenerate today's exceptions from tools/layers.json
2026-11-12 03:16:29 +01:00
Kacper 654bed3a7f Merge pull request #15248 from yahya-nusseibeh/fix/port-pack-licences
Port-pack: licence text stays one comment line, and families match whole words
2026-11-12 03:16:17 +01:00
Kacper 6154ce2282 Merge pull request #15160 from MSedgeMC/fix/brightness-gate
Level: getMaxLocalRawBrightness answers 15 outside the +-30,000,000 gate
2026-11-12 03:15:55 +01:00
Kacper 5e331b277a Merge pull request #15101 from angel2rider/tools/fixtures-ask-for-work-dir
tools: the command fixtures ask for their work directory instead of crashing
2026-11-12 03:15:33 +01:00
Kacper 96712da3cb Merge pull request #15091 from angel2rider/tools/port-ledger-json-after-query
tools: port_ledger.py accepts --json after the query's name, as its own help says
2026-11-12 03:15:22 +01:00
Kacper 755ba0b9a5 Merge pull request #15071 from MSedgeMC/fix/batch11-c
A tamed cat stops fleeing players
2026-11-12 03:15:11 +01:00
Kacper 29223492fc Merge pull request #15278 from DUzzL/fix/15270-alt-space
Swallow SC_KEYMENU so Alt+Space does not open the Windows system menu
2026-11-12 03:15:01 +01:00
Kacper d5f9e06dac Merge pull request #14902 from MSedgeMC/fix/chat-scroll-keeps-place
Changing the Chat option keeps the reader's place
2026-11-12 03:14:36 +01:00
Kacper 26d4f3f273 Merge pull request #14160 from ilguerro/fix/item-void-floor
Discard dropped items below each dimension's own void floor
2026-11-12 03:13:32 +01:00
Kacper fc71cbfeed Merge pull request #13755 from MSedgeMC/fix/follow-invisible
Follow-mob goals ignore invisible mobs
2026-11-12 03:13:02 +01:00
Kacper 0d55f3075b Merge pull request #15190 from MSedgeMC/fix/chunk-empty-sections
Chunk storage: a chunk without a sections list loads as an empty chunk
2026-11-12 02:54:55 +01:00
Kacper 24b778c3b4 Merge pull request #14173 from DUzzL/fix/14071-full-inventory-pickup
Sync a guest's refilled stack after a predicted drop
2026-11-12 02:45:28 +01:00
Kacper e1895229b8 Merge pull request #14499 from vnsmoreira/fix/explosion-player-xp
A player's blast pops the experience of what it breaks
2026-11-12 02:38:08 +01:00
Kacper cf79b811ac Merge pull request #13752 from DUzzL/fix/13665-crafted-stat
Award the crafted statistic for crafting-table and inventory-grid takes
2026-11-12 02:37:45 +01:00
Kacper 2b86defaa7 Merge pull request #13749 from MSedgeMC/fix/equippable-damage-on-hurt
Armor damage_on_hurt comes from the stack's equippable component
2026-11-12 02:37:35 +01:00
rdbt 0da40f990f Merge pull request #15493 from rdbtCVS/perf/mob-islands-to-main
Mobs: land the parallel mob islands on main (#15056 merged into the stack base)
2026-11-12 01:32:29 +00:00
Kacper d63ae80019 Merge pull request #15330 from Akirabane/fix/snow-golem-stroll-probability
A snow golem strolls with vanilla's rare probability, 1.0000001E-5
2026-11-12 02:26:31 +01:00
Kacper cd5c829460 Merge pull request #15318 from Akirabane/fix/new-world-folder-name
Create New World: the folder name is found as vanilla's findResultFolder finds it
2026-11-12 02:26:20 +01:00
Kacper 14ff11b96b Merge pull request #14651 from senjinthedragon/boat-passenger-water
Mobs: a mob sitting in a boat on water is not in the water, so a drowned or zombie in a boat burns in daylight
2026-11-12 02:25:43 +01:00
rdbtCVS 12bbd34fe5 Merge remote-tracking branch 'origin/main' into land-islands 2026-11-12 01:24:55 +00:00
Kacper 4a015bfb7c Merge pull request #15244 from yahya-nusseibeh/fix/servers-dat-lock
Client: hold the servers.dat lock on every save, each process with its own temp file
2026-11-12 02:24:25 +01:00
Kacper aaad7c57c1 Merge pull request #15237 from yahya-nusseibeh/fix/windows-console-close
Server: on Windows, wait for the clean stop when the console closes
2026-11-12 02:24:09 +01:00
Kacpherek 82a17c59ae Merge commit 'aab654d4e8' into unstick/14695
# Conflicts:
#	crates/horizon-client/src/lan/hub_tests.rs
2026-11-12 02:15:37 +01:00
Kacper 646722c466 Merge pull request #15290 from DUzzL/fix/15275-broken-stat
Count the broken statistic for every equipment break
2026-11-12 02:14:29 +01:00
Kacper 02f9ee4629 Merge pull request #14431 from Mayonesu0/up-13175-forced-chunk-ring
Rank a forced chunk's ring around it so it ticks on the first tick (#13175)
2026-11-12 02:14:03 +01:00
Kacper 902c147f8e Merge pull request #15231 from yahya-nusseibeh/fix/friends-try-every-endpoint
Friends: try every endpoint that answers before giving up on a direct join
2026-11-12 02:13:39 +01:00
Kacper 92166e6236 Merge pull request #14914 from vermingov/fix/axolotl-variant-14907
An axolotl with an unknown Variant id is lucy, not blue (#14907)
2026-11-12 02:13:22 +01:00
Kacpherek eb6536a832 Merge commit 'aab654d4e8' into unstick/14499
# Conflicts:
#	crates/horizon-client/src/game/explosion.rs
2026-11-12 02:13:10 +01:00
Kacper cf26b92d45 Merge pull request #11548 from socksprox/items/destroy-progress
Move the mining speed formula into horizon-items
2026-11-12 02:13:04 +01:00
Kacpherek 687caa60c4 Merge commit 'aab654d4e8' into unstick/13755
# Conflicts:
#	crates/horizon-client/src/mobs.rs
2026-11-12 02:08:55 +01:00
Kacpherek 9503016e93 Merge commit 'aab654d4e8' into unstick/13752
# Conflicts:
#	crates/horizon-client/src/menu/place/transfer_all_tests.rs
2026-11-12 02:07:56 +01:00
Kacpherek cce82d001c Merge commit 'aab654d4e8' into unstick/13749
# Conflicts:
#	crates/horizon-client/src/item_sync.rs
2026-11-12 02:06:57 +01:00
Kacpherek f9d2eddb83 Merge commit 'aab654d4e8' into unstick/15190
# Conflicts:
#	crates/horizon-world/src/chunk.rs
2026-11-12 02:06:21 +01:00
Kacpherek d66d48d9a3 Merge commit 'aab654d4e8' into unstick/15278
# Conflicts:
#	crates/horizon-threads/src/lib.rs
2026-11-12 02:05:59 +01:00
Kacper aab654d4e8 Merge pull request #15314 from Akirabane/docs/port-map-game-012-fixed-gaps
port map: four rows of game-012.tsv now say what live goat and fox spawns do
2026-11-12 02:00:27 +01:00
Kacper b7eeb79a33 Merge pull request #15051 from ilguerro/fix/beacon-payment-order-tooltips
Beacon: pay emerald before diamond and tooltip every button
2026-11-12 02:00:15 +01:00
Kacper db7b03ffda Merge pull request #14571 from voidsipp/fix/11318-end-gateway-cooldowns
Use entity-specific End gateway cooldowns
2026-11-12 02:00:00 +01:00
Kacper e27a62210c Merge pull request #14373 from Joansitoh/fix/14368-spawnable-pos
Check only the spawnable bounds for /spawnpoint and /setworldspawn
2026-11-12 01:59:50 +01:00
Kacper 06e9cfe6da Merge pull request #14010 from ilguerro/fix/hoglin-pacified-breeding
Keep mobs/ai.rs from growing: the hoglin check moves to piglin.rs
2026-11-12 01:58:54 +01:00
Kacper b6941b78f2 Merge branch 'main' into fix/snow-golem-stroll-probability 2026-11-12 01:55:53 +01:00
Kacper d64883cef3 Merge branch 'main' into fix/lan-accept-teleport-twice 2026-11-12 01:55:43 +01:00
Kacper 7ab3a23f95 Merge branch 'main' into fix/new-world-folder-name 2026-11-12 01:55:34 +01:00
Kacper 07050a34bd Merge pull request #15420 from yahya-nusseibeh/fix/launcher-data-link-above-root
Launcher: follow links above the data folder when moving it
2026-11-12 01:48:21 +01:00
Kacper 9054341b05 Merge pull request #15417 from AlfaRojo/fix/fishing-hook-harness-peaceful
fishing_hook_entities: run the servers in peaceful
2026-11-12 01:48:02 +01:00
Kacper 30db6aa7fd Merge pull request #14473 from voidsipp/split/11469-damage-source-foundation
Preserve explicit explosion damage sources
2026-11-12 01:46:47 +01:00
Kacper 663e7457ab Merge pull request #14260 from ilguerro/fix/void-floor-per-dimension
Start the void 64 blocks below each level's own floor
2026-11-12 01:45:31 +01:00
Kacper 2b375bf38c Merge pull request #15421 from vnsmoreira/packs/core-provided-blocks
Core shaders: blocks and samplers a pipeline provides, and blocks on one line
2026-11-12 01:36:40 +01:00
Kacper 172cec105a Merge pull request #15201 from DUzzL/fix/15134-flame-particles
Particles face the frame's camera, not the player's yaw and pitch
2026-11-12 01:36:24 +01:00
rdbt 5b28083ef3 Merge pull request #15481 from rdbtCVS/server/engine-parallel-tick
Server: [engine] parallel_tick turns the parallel tick on from server.toml
2026-11-12 00:36:18 +00:00
Kacper 3072b90817 Merge pull request #14885 from vermingov/fix/fireball-power-byte-14883
A fireball narrows a loaded ExplosionPower to a byte (#14883)
2026-11-12 01:36:07 +01:00
rdbt 02c7045ff6 Merge pull request #15056 from rdbtCVS/perf/mob-islands-finish
Mobs: parallel mob islands with ordered RNG and full-state verification (draft)
2026-11-12 00:36:06 +00:00
Kacper 4c29ce57c7 Merge pull request #13820 from RINNECODER/up12/ctm-chain-and-pass
OptiFine CTM: a picked tile chains to the rules naming it, and draws in its face's pass
2026-11-12 01:35:49 +01:00
rdbt 291b2c2b03 Merge pull request #14552 from rdbtCVS/perf/mob-loop-group
Mobs: the mob loop runs as a group of mobs, still on one thread (parallel mob tick, draft)
2026-11-12 00:35:30 +00:00
rdbt 9fa96cab6f Merge pull request #14531 from rdbtCVS/perf/ordered-draws
Random: one LegacyRandom stream drawn in list order from several threads (parallel mob tick, draft)
2026-11-12 00:34:44 +00:00
rdbt 892c64cc69 Merge pull request #14529 from rdbtCVS/perf/item-moves-ahead
Parallel tick: the dropped items' own moves worked out ahead on helpers
2026-11-12 00:34:14 +00:00
rdbtCVSandClaude Sonnet 5.5 6fad4fb984 Server: [engine] parallel_tick in server.toml turns the parallel tick on
A host with a fixed startup command (a game panel running `./horizon-server run`) can't set
HORIZON_PARALLEL_TICK. `[engine] parallel_tick = true` makes horizon-server start the game with the
variable set to 1, the way `low_memory` sets MIMALLOC_ALLOW_THP. Off leaves the variable alone, so one
already in the environment still applies.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 00:30:19 +00:00
Kacper 32ed49b6d8 Merge pull request #15191 from MSedgeMC/fix/disabledlabel
A disabled button's label is grey
2026-11-12 01:24:41 +01:00
Kacper 35992267b9 Merge pull request #15150 from MSedgeMC/fix/jigsaw-local-edit
fix: a jigsaw edited in single player is applied
2026-11-12 01:24:24 +01:00
Kacper 95d025f4c2 Merge pull request #14566 from DUzzL/fix/14461-firework-dispenser-rotation
Turn a shot firework rocket to its motion like Projectile.shoot
2026-11-12 01:24:07 +01:00
Kacper 40b9d160c9 Merge pull request #13747 from MSedgeMC/fix/loom-dye-component
A loom reads a dye stack's dye component
2026-11-12 01:23:50 +01:00
Kacper 984872a0dd Merge pull request #15313 from AlfaRojo/fix/driven-mount-client-hurt
A driven horse takes no fall damage on its rider's client
2026-11-12 01:20:14 +01:00
Kacper 4d00052e33 Merge pull request #14179 from voduff/frames-projectile-hits
Projectiles hit item frames: the item comes out, then the frame breaks
2026-11-12 01:19:48 +01:00
rdbtCVSandClaude Sonnet 5.5 51b9a663bd Mobs: island tests wrap their random in EntityRandom after main's owner types
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 00:18:59 +00:00
rdbtCVS 76557beb69 Merge branch 'w-loop' into w-isl 2026-11-12 00:17:37 +00:00
rdbtCVSandClaude Sonnet 5.5 bf955abf30 Mobs: lend the level random to a group as a plain LegacyRandom after main's LevelRandom wrapper
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 00:16:49 +00:00
rdbtCVS 2d2ebb8fa5 Merge remote-tracking branch 'fork/perf/ordered-draws' into w-loop 2026-11-12 00:14:26 +00:00
rdbtCVSandClaude Sonnet 5.5 4aec48949f Merge perf/item-moves-ahead into perf/mob-loop-group
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 00:14:26 +00:00
rdbtCVSandClaude Sonnet 5.5 929928d3e6 Merge main into perf/ordered-draws
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 00:13:26 +00:00
Kacper a792e58186 Merge branch 'main' into perf/item-moves-ahead 2026-11-12 01:09:23 +01:00
Kacper cc15269fb6 Merge branch 'main' into fix/deep-end-boat-void 2026-11-12 01:07:37 +01:00
Kacper 1177119180 Merge branch 'main' into fix/15275-broken-stat 2026-11-12 01:07:28 +01:00
Kacper 2bfb755693 Merge branch 'main' into fix/beacon-payment-order-tooltips 2026-11-12 01:06:06 +01:00
Kacper 3f287372f4 Merge branch 'main' into boat-passenger-water 2026-11-12 01:03:40 +01:00
Kacper d3e0272348 Merge branch 'main' into fix/11318-end-gateway-cooldowns 2026-11-12 01:03:18 +01:00
Kacper 818e0a229e Merge branch 'main' into split/11469-damage-source-foundation 2026-11-12 01:02:42 +01:00
Kacper 09fa6403c5 Merge branch 'main' into up-13175-forced-chunk-ring 2026-11-12 01:02:33 +01:00
Kacper cad3d55f76 Merge branch 'main' into fix/14368-spawnable-pos 2026-11-12 01:02:24 +01:00
Kacper 0d85f3f65f Merge branch 'main' into fix/void-floor-per-dimension 2026-11-12 01:01:57 +01:00
Kacper 39046b18e8 Merge branch 'main' into fix/item-void-floor 2026-11-12 01:01:48 +01:00
Kacper 46d26691cf Merge branch 'main' into fix/explosion-player-xp 2026-11-12 01:00:07 +01:00
Kacper 713fe9176d Merge branch 'main' into packs/core-provided-blocks 2026-11-12 01:00:07 +01:00
Kacper 66319c12e6 Merge branch 'main' into fix/launcher-data-link-above-root 2026-11-12 00:59:58 +01:00
Kacper 31b0e68219 Merge branch 'main' into fix/windows-console-close 2026-11-12 00:59:42 +01:00
Kacper 8bf7dd6dfe Merge branch 'main' into fix/15134-flame-particles 2026-11-12 00:59:34 +01:00
Kacper 5814b1cb5f Merge branch 'main' into fix/friends-try-every-endpoint 2026-11-12 00:59:25 +01:00
Kacper 59cd7697be Merge branch 'main' into fix/jigsaw-local-edit 2026-11-12 00:59:21 +01:00
Kacper 063632d5f5 Merge branch 'main' into fix/axolotl-variant-14907 2026-11-12 00:59:12 +01:00
Kacper 8424af6894 Merge branch 'main' into fix/fireball-power-byte-14883 2026-11-12 00:59:04 +01:00
Kacper a0d2e7d935 Merge branch 'main' into fix/14071-full-inventory-pickup 2026-11-12 00:58:52 +01:00
Kacper 7e779ceb8b Merge branch 'main' into fix/loom-dye-component 2026-11-12 00:58:28 +01:00
Kacper 5481445402 Merge branch 'main' into up12/ctm-chain-and-pass 2026-11-12 00:58:12 +01:00
Kacper 2c62ea5aaa Merge branch 'main' into items/destroy-progress 2026-11-12 00:58:10 +01:00
Kacper 0bd5635d74 Merge branch 'main' into fix/hoglin-pacified-breeding 2026-11-12 00:58:04 +01:00
Kacper 53ef669745 Merge branch 'main' into fix/14461-firework-dispenser-rotation 2026-11-12 00:57:55 +01:00
Kacper 44d13ad5d4 Merge branch 'main' into fix/disabledlabel 2026-11-12 00:57:23 +01:00
Kacper f66e1a8d91 Merge branch 'main' into fix/servers-dat-lock 2026-11-12 00:57:15 +01:00
Kacper 634df8744a Merge branch 'main' into fix/fishing-hook-harness-peaceful 2026-11-12 00:57:06 +01:00
Kacper e8b23d06ed Merge branch 'main' into fix/jigsaw-priority-tooltip 2026-11-12 00:56:34 +01:00
Kacper c1894da34b Merge branch 'main' into fix/walk-target-scores 2026-11-12 00:56:08 +01:00
Kacper 0482839c1b Merge branch 'main' into fix/neoforge-version-path 2026-11-12 00:55:35 +01:00
rdbtCVSandClaude Sonnet 5.5 97f41ca90f Mobs: keep a tick serial while a mob holds a teleporting consumable
A fox that finishes chorus fruit teleports within half the item's
diameter on each axis (item data) and then falls to the first ground
below, so no margin bounds its destination. Islands::plan now returns
None, in production and verify, while any mob holds such an item in
either hand or is using one; it checks the item, not the species.

Adds a destination-herd regression and a plan() case, and documents the
fallback and the audit of other self-moves in docs/mob-islands.md.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 23:55:27 +00:00
Kacper 09fdaa392a Merge branch 'main' into fix/batch11-c 2026-11-12 00:55:01 +01:00
Kacper 6e5270105b Merge branch 'main' into tools/port-ledger-json-after-query 2026-11-12 00:54:52 +01:00
Kacper 7c9db3e706 Merge branch 'main' into tools/fixtures-ask-for-work-dir 2026-11-12 00:54:44 +01:00
Kacper e8b01b7282 Merge branch 'main' into fix/port-pack-licences 2026-11-12 00:54:27 +01:00
Kacper f22a814607 Merge branch 'main' into refactor/move-model-parts 2026-11-12 00:54:19 +01:00
Kacper b14027d065 Merge branch 'main' into fix/brightness-gate 2026-11-12 00:53:53 +01:00
Kacper 5133e20195 Merge branch 'main' into docs/layers-exceptions-15408 2026-11-12 00:53:44 +01:00
rdbt 1511bd7a23 Merge pull request #15474 from horizon-rust/fix/ci-core-shader-test-locations
Fix failing core_shader vertex-index test
2026-11-11 23:32:26 +00:00
AkirabaneandClaude Sonnet 5.5 3e75afe983 port_map: the goat and fox rows of game-012.tsv name the callers that exist
Goat.finalizeSpawn, GoatAi.initMemories and Fox.populateDefaultEquipmentSlots
said that Ctx::finalize and Ctx::populate call goat_spawn and
fox::populate_default_equipment, and that goat_spawn calls goat_init_memories.
They do not: Ctx::finalize and Ctx::populate have their own copies of the same
draws, and goat_spawn draws the two cooldowns itself.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 00:27:54 +01:00
Akirabane 29df2938b1 Merge origin/main into docs/port-map-game-012-fixed-gaps 2026-11-12 00:27:11 +01:00
rdbtCVSandClaude Sonnet 5.5 87949f0a24 Fix core_shader vertex-index test: match the fragment shader's locations
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 23:27:05 +00:00
rdbtCVSandClaude Sonnet 5.5 c5e63f9f06 Merge main into perf/item-moves-ahead
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 23:25:42 +00:00
rdbt 09de8c7bbb Merge pull request #15466 from rdbtCVS/fix/ci-main-core-tests
Fix the two failing horizon-client-core tests on main
2026-11-11 23:08:15 +00:00
rdbtCVSandClaude Sonnet 5.5 bc6dbfe589 Fix the two failing horizon-client-core tests on main
- th_th.json (game and lang_review copy): drop the HDR peak hint en_us
  no longer has, as b36e25ab16 did for Ukrainian;
  bundled_files_are_served_and_have_no_stale_or_repeated_keys failed on it.
- booleans_are_read_as_bytes: the sign side in the has_glowing_text case had
  no messages, so it now loads empty (4787069c5b); give it four lines.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 23:02:37 +00:00
Kacpherek 0808d1f189 Merge commit '3298a6dcd5' into unstick/14179
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
2026-11-11 23:59:55 +01:00
Kacper 5a4d8c11e6 Merge branch 'main' into fix/deep-end-boat-void 2026-11-11 23:58:23 +01:00
Kacpherek 219d6b6b4e Keep mobs.rs from growing: the client_side note sits on its field's line 2026-11-11 23:57:12 +01:00
Kacpherek 251c800717 Close the chat option change test in chat_tests 2026-11-11 23:53:01 +01:00
Kacper 02b3da3eec Merge branch 'main' into fix/explosion-player-xp 2026-11-11 23:52:32 +01:00
Kacpherek 4676650694 Merge commit '3298a6dcd5' into unstick/15313 2026-11-11 23:50:19 +01:00
Kacpherek 660288e50b Merge commit '3298a6dcd5' into unstick/14902 2026-11-11 23:50:18 +01:00
Kacper 53ffc05840 Merge branch 'main' into packs/core-provided-blocks 2026-11-11 23:50:04 +01:00
Kacper 5b275cd324 Merge branch 'main' into fix/launcher-data-link-above-root 2026-11-11 23:49:56 +01:00
Kacper 05c0a3942f Merge branch 'main' into fix/servers-dat-lock 2026-11-11 23:49:35 +01:00
Kacper fa34419e4b Merge branch 'main' into fix/fishing-hook-harness-peaceful 2026-11-11 23:49:32 +01:00
Kacper a640a5a91a Merge branch 'main' into fix/windows-console-close 2026-11-11 23:49:26 +01:00
Kacper 7475a27b7b Merge branch 'main' into fix/friends-try-every-endpoint 2026-11-11 23:49:17 +01:00
Kacper 721a16e17b Merge branch 'main' into fix/15134-flame-particles 2026-11-11 23:49:09 +01:00
Kacper 20c3b24dab Merge branch 'main' into fix/disabledlabel 2026-11-11 23:49:00 +01:00
Kacper 8d60a65c6c Merge branch 'main' into fix/jigsaw-priority-tooltip 2026-11-11 23:48:59 +01:00
Kacper 72d6a5b383 Merge branch 'main' into fix/jigsaw-local-edit 2026-11-11 23:48:51 +01:00
Kacper b1104143ef Merge branch 'main' into fix/14461-firework-dispenser-rotation 2026-11-11 23:48:42 +01:00
Kacper 1c98b67b44 Merge branch 'main' into fix/14071-full-inventory-pickup 2026-11-11 23:48:33 +01:00
Kacper 1299432e7a Merge branch 'main' into fix/walk-target-scores 2026-11-11 23:48:32 +01:00
Kacper 271bace8e1 Merge branch 'main' into fix/hoglin-pacified-breeding 2026-11-11 23:48:24 +01:00
Kacper dec1f7bd51 Merge branch 'main' into up12/ctm-chain-and-pass 2026-11-11 23:48:06 +01:00
Kacper 3943570b53 Merge branch 'main' into fix/neoforge-version-path 2026-11-11 23:47:58 +01:00
Kacper a5b58a242b Merge branch 'main' into fix/loom-dye-component 2026-11-11 23:47:56 +01:00
Kacper 6494dc92e0 Merge branch 'main' into items/destroy-progress 2026-11-11 23:47:39 +01:00
Kacper 7cae9ff3c4 Merge branch 'main' into fix/batch11-c 2026-11-11 23:47:08 +01:00
Kacper d9d0c7ad13 Merge branch 'main' into tools/port-ledger-json-after-query 2026-11-11 23:47:00 +01:00
Kacper d60a13d0d3 Merge branch 'main' into tools/fixtures-ask-for-work-dir 2026-11-11 23:46:51 +01:00
Kacper 38e2c2522a Merge branch 'main' into fix/port-pack-licences 2026-11-11 23:46:35 +01:00
Kacper 8c46ceea46 Merge branch 'main' into refactor/move-model-parts 2026-11-11 23:46:26 +01:00
Kacper 0f209ac875 Merge branch 'main' into fix/brightness-gate 2026-11-11 23:46:00 +01:00
Kacper de7ceb78d7 Merge branch 'main' into docs/layers-exceptions-15408 2026-11-11 23:45:51 +01:00
rdbt 3298a6dcd5 Merge pull request #15462 from rdbtCVS/fix/upstream-ci-test-api-drift
Fix compilation after entity RNG and network data refactors
2026-11-11 22:41:17 +00:00
rdbtCVSandCodex gpt-6.1-sol efa58613aa Fix compilation after RNG and network data refactors
Update stale test call sites and the player RNG field to the current APIs without changing seeds, draws or assertions.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-11 22:34:32 +00:00
rdbt 2ddce27f40 Merge pull request #14927 from vermingov/fix/horse-variant-wrap-14926
A horse's unknown coat or markings id wraps (#14926)
2026-11-11 22:27:15 +00:00
rdbt 48cef8f361 Merge pull request #14444 from voidsipp/split/11454-offer-packets
Keep merchant offer packets tied to price refreshes
2026-11-11 22:26:34 +00:00
rdbt ec56b95628 Merge pull request #14412 from voidsipp/fix/11589-guest-owned-rng
Give player bodies their own random streams
2026-11-11 22:26:27 +00:00
rdbt 8d44c49cd3 Merge pull request #14536 from ilguerro/fix/saved-legacy-structures
Load saved mineshafts, fortresses and strongholds instead of regenerating them
2026-11-11 22:24:14 +00:00
rdbt 1d1d79ebc1 Merge pull request #14701 from ilguerro/fix/template-loading
Load structure templates faster and preserve numeric NBT fields
2026-11-11 22:24:09 +00:00
rdbt f7453ff16f Merge pull request #15167 from ilguerro/perf/worldgen-surface
Surface stage: sample each ore vein's densities over its own rows, when first read
2026-11-11 22:24:05 +00:00
Kacper 28f0c9802e Merge branch 'main' into fix/driven-mount-client-hurt 2026-11-11 23:23:37 +01:00
Kacper e1a97b5783 Merge branch 'main' into fix/15275-broken-stat 2026-11-11 23:23:28 +01:00
rdbt cb5de34db6 Merge pull request #15252 from rdbtCVS/perf/zlib-level-2-50-player
Reduce packet compression cost and benchmark 50-player traffic
2026-11-11 22:22:37 +00:00
rdbt 9f72537d21 Merge pull request #15243 from yahya-nusseibeh/fix/ci-pin-actions
CI: pin the third-party Rust actions to reviewed commits
2026-11-11 22:22:32 +00:00
rdbt 06f24ae718 Merge pull request #15007 from darkliro/perf/intel-raptor-lake-vulkan
gfx: allow indirect multi-draw on Raptor Lake with Mesa >= 26.0.8
2026-11-11 22:22:27 +00:00
rdbt fa68c60740 Merge pull request #15004 from sunrise-org/dims/portal-scales
Portals and execute in scale coordinates by the dimension types (#11282, 6/9)
2026-11-11 22:22:23 +00:00
rdbt 04d109da79 Merge pull request #14979 from lucasmts59/fix/pack-text-world
A resource pack's core/text draws world text (text displays, name tags)
2026-11-11 22:22:18 +00:00
rdbt 936e9e0cba Merge pull request #14934 from Detimes/perf-recording-box
Perf: skip recording's block test for a viewer no changed block can reach
2026-11-11 22:22:13 +00:00
rdbt 373c49af50 Merge pull request #14933 from Detimes/perf-biome-resend
Perf: encode resent biomes once per set of columns
2026-11-11 22:22:08 +00:00
Kacper 1ec82f9a9f Merge branch 'main' into fix/beacon-payment-order-tooltips 2026-11-11 23:22:05 +01:00
rdbt 45c080011c Merge pull request #14815 from senjinthedragon/dynamic-resolution
Dynamic Resolution: the world's resolution follows a target frame rate
2026-11-11 22:22:02 +00:00
rdbt 0fa6d4d68e Merge pull request #14795 from alexcatdad/nix-module
Nix: services.horizon-server, a NixOS module for a Horizon server
2026-11-11 22:21:57 +00:00
rdbt 6e344a9fd6 Merge pull request #14744 from NongTham/lang/thai-game-client
Game: review and complete Thai translations (th_th.json)
2026-11-11 22:21:52 +00:00
rdbt 1d3ed915df Merge pull request #14711 from YourPancakes/perf/parse-packet-header-once
perf(net): parse packet frame headers once
2026-11-11 22:21:48 +00:00
rdbt f5ccf953c4 Merge pull request #14506 from rdbtCVS/perf/parallel-tick-helpers
Parallel tick: helper threads for mob prep, random tick masks and mob states, behind HORIZON_PARALLEL_TICK
2026-11-11 22:21:41 +00:00
rdbt 0179d86295 Merge pull request #14502 from horizon-rust/ci/forks-verify-on-github
Forks' pull requests run verify on GitHub's runners, never on the desktop's
2026-11-11 22:21:36 +00:00
rdbt 5b03edb56e Merge pull request #14455 from rdbtCVS/perf/anticheat-log-thread
Anticheat: write the flag log on a thread of its own, joined on drop
2026-11-11 22:21:25 +00:00
Kacper d62d4787ab Merge branch 'main' into fix/chat-scroll-keeps-place 2026-11-11 23:21:20 +01:00
rdbt 7e2abf5fc4 Merge pull request #14452 from horizon-rust/split/pages-fields
Pages v2: text fields, and a page in the title screen's Friends icon slot
2026-11-11 22:21:19 +00:00
rdbt 17273e22ba Merge pull request #14361 from NongTham/lang/thai-launcher
Launcher: review and complete Thai translations (th.json)
2026-11-11 22:21:06 +00:00
rdbt 81d944457c Merge pull request #14356 from Evan-lhm/rt/acceleration-structures
Ray tracing: the terrain's acceleration structures, near the camera only (#13780 step 2)
2026-11-11 22:21:01 +00:00
rdbt f2b210b1f1 Merge pull request #14331 from horizon-rust/split/cosmetics-settings
Cosmetics settings leave the engine's Extras (spec #14091 step 5a)
2026-11-11 22:20:56 +00:00
rdbt d77c945717 Merge pull request #14283 from NikitaMGrimm/typed-randoms-1a
The level's random and a mob's own random have types of their own
2026-11-11 22:20:43 +00:00
rdbt b4ce8a563b Merge pull request #13942 from horizon-rust/layers/chat-guard-hook
Chat filter: the hub asks an engine hook (layer plan M6)
2026-11-11 22:20:06 +00:00
rdbt c9df2c9f93 Merge pull request #13930 from horizon-rust/layers/extras-reconnect
Auto-reconnect's game glue moves to game/extras/reconnect.rs (layer plan M5)
2026-11-11 22:20:01 +00:00
rdbt 62a8e1f9fa Merge pull request #13730 from NikitaMGrimm/shader-api-linear-blend
Shader API 0.2: packs that ask for linear blending are drawn, glass and water blend as light
2026-11-11 22:19:54 +00:00
Kacper c0cfcda90e Merge branch 'main' into boat-passenger-water 2026-11-11 23:19:33 +01:00
Kacper 68d5cd7114 Merge branch 'main' into fix/11318-end-gateway-cooldowns 2026-11-11 23:18:51 +01:00
Kacper 37d6df3219 Merge branch 'main' into split/11469-damage-source-foundation 2026-11-11 23:18:16 +01:00
Kacper 59b2aed66c Merge branch 'main' into up-13175-forced-chunk-ring 2026-11-11 23:17:56 +01:00
Kacper cb8761a18c Merge branch 'main' into fix/14368-spawnable-pos 2026-11-11 23:17:38 +01:00
rdbt 6b734b015b Merge pull request #14451 from itarqos5/test/vanilla-redstone-rules-2
Tests: 25 more vanilla redstone, fluid and block rules (stacked on #14237)
2026-11-11 22:17:24 +00:00
rdbt bd4cf5bf17 Merge pull request #11585 from voidsipp/fix/11337-special-attack-followup
Preserve exhaustion and sprint slowdown on special entity hits
2026-11-11 22:17:14 +00:00
Kacper f5dbdcadba Merge branch 'main' into fix/void-floor-per-dimension 2026-11-11 23:17:13 +01:00
rdbt ebcccff515 Merge pull request #13580 from TheApple05/fix/13174-wayland-file-drop
Fix confirmed resource-pack drag-and-drop imports
2026-11-11 22:17:09 +00:00
Kacper d361eaf394 Merge branch 'main' into frames-projectile-hits 2026-11-11 23:17:03 +01:00
rdbt bae784326f Merge pull request #13913 from iseaprojects/fix/11316-custom-durability
Honor custom MAX_DAMAGE on normally non-durable items
2026-11-11 22:17:03 +00:00
rdbt bea5186176 Merge pull request #14296 from ilguerro/fix/enderman-block-and-stare
fix: endermen keep carried blocks out of entities, stare once per 400 ticks and reach blocks through outlines
2026-11-11 22:16:57 +00:00
Kacper fe4d3a2e94 Merge branch 'main' into fix/item-void-floor 2026-11-11 23:16:54 +01:00
rdbt f9a650f3ac Merge pull request #14319 from DylanGonzaloFerreyra/fix-split-memory-queue
A splitscreen player's first view no longer disconnects it
2026-11-11 22:16:52 +00:00
rdbt e3ba3ae992 Merge pull request #14417 from Ragbie/fix/rocket-fall-distance
Firework rockets keep their fall distance
2026-11-11 22:16:45 +00:00
rdbt 5a7076f3ea Merge pull request #14425 from Mayonesu0/up-13541-portal-armor-attrs
Keep the player's attribute modifiers through a portal respawn
2026-11-11 22:16:40 +00:00
rdbt e1eb285cc7 Merge pull request #14442 from Mik00000/fix/spear-reach-pvp
fix(client): synchronize look rotation and update pvp hud for spear stab (fixes #14092)
2026-11-11 22:16:35 +00:00
rdbt 786b48499c Merge pull request #14458 from Solenopsisbot/t3/fix-pregen-square-crash
fix: bound pregen save backlog
2026-11-11 22:16:30 +00:00
rdbt f782dd4326 Merge pull request #14477 from voduff/fix/14424-legacy-chunk-entities
A chunk saved before 1.17 loads the entities it keeps
2026-11-11 22:16:25 +00:00
rdbt 7955d4c512 Merge pull request #14505 from foudretbr/fix/area-effect-cloud-motion-persistence
fix(game): preserve area effect cloud Motion across chunk save and reload
2026-11-11 22:16:20 +00:00
rdbt 6858deccf1 Merge pull request #14547 from ilguerro/fix/sign-editor-holder
signs keep one editor at a time and only open on plain text
2026-11-11 22:16:12 +00:00
rdbt d16d9b6d15 Merge pull request #14559 from MSedgeMC/fix/death-screen-links
The death screen's cause behaves as a link
2026-11-11 22:16:07 +00:00
rdbt 855f1f9043 Merge pull request #14567 from fbikat/launcher-uninstall-moved-data
Launcher: uninstall deletes the data of a moved data location
2026-11-11 22:16:00 +00:00
rdbt 16b7843619 Merge pull request #14581 from Mayonesu0/up-14478-painting-pitch
Keep a painting's saved pitch (Rotation[1]) through save and load
2026-11-11 22:15:55 +00:00
rdbt 9c306d6f5b Merge pull request #14588 from Mayonesu0/up-14481-absorption-roundtrip
Keep a loaded mob's AbsorptionAmount as saved (negative or above the maximum)
2026-11-11 22:15:48 +00:00
rdbt 2a095d62d5 Merge pull request #14605 from DUzzL/fix/14466-fireball-rotation
Turn fireballs towards their movement every tick
2026-11-11 22:15:39 +00:00
rdbt 60ffd2a342 Merge pull request #14609 from Mayonesu0/up-12788-hoglin-anger
Compare a broadcast player target by distance in the hoglin brain
2026-11-11 22:15:35 +00:00
rdbt 449ebe1366 Merge pull request #14630 from MSedgeMC/fix/shield-block-stat
port: a blocked hit counts the blocking item as used
2026-11-11 22:15:30 +00:00
rdbt 55a7086663 Merge pull request #14655 from J5Casey/fix/viewer-transfer-worn
Recipe viewers: a transfer takes worn, enchanted and renamed items
2026-11-11 22:15:25 +00:00
rdbt 51094095b2 Merge pull request #14666 from martipops/agent/osk-wishlist
On-screen keyboard: Done finishes books and signs, chat suggestions by pad, hints wrap
2026-11-11 22:15:20 +00:00
rdbt b3b496a449 Merge pull request #14669 from JustKarkat/fix/player-head-resource-pack-texture
fix: player heads keep their custom skins with resource packs
2026-11-11 22:15:15 +00:00
rdbt b7db4654ee Merge pull request #14709 from Skr1zzzz/integrated-crash-save
Single player: a tick that panics or fails still saves the world
2026-11-11 22:15:08 +00:00
rdbt af4217724b Merge pull request #14716 from Skr1zzzz/removal-passengers
Client: removing entities seats passengers off only the vehicles among them
2026-11-11 22:15:03 +00:00
rdbt 27f8cff837 Merge pull request #14729 from ilguerro/fix/pet-sitting-pose
Pets sit only where vanilla lets them, and a cat's chest pose reaches every player
2026-11-11 22:14:57 +00:00
Akirabane 1b5be38573 Merge remote-tracking branch 'origin/main' into local/M1-move-model-parts 2026-11-11 23:14:52 +01:00
rdbt 1ebd5f32ab Merge pull request #14733 from ilguerro/fix/powder-snow-cauldron-extinguish
Hiss when powder snow or a cauldron puts a burning player out, and leave the block outside the world border
2026-11-11 22:14:52 +00:00
rdbt 51ca66993a Merge pull request #14739 from foudretbr/fix/consumable-component-use
fix(game): resolve dynamic consumable component on item stack (#14634)
2026-11-11 22:14:47 +00:00
rdbt 3af468a074 Merge pull request #14746 from MSedgeMC/fix/dripstone-drip-face
A fluid drip hangs on the fluid block's own face, and a flowing fluid drips
2026-11-11 22:14:42 +00:00
rdbt f0ccc014ad Merge pull request #14774 from MSedgeMC/fix/breathe-under-water
The air supply the jar gives each type
2026-11-11 22:14:37 +00:00
rdbt de5b2f94db Merge pull request #14813 from foudretbr/fix/armor-stand-saved-pitch
fix(mobs): load and retain armor stand pitch from entity Rotation (#14799)
2026-11-11 22:14:24 +00:00
rdbt 8edbc6760d Merge pull request #14827 from Mik00000/fix/flint-steel-rapid-use
fix(client): allow rapid flint and steel usage when holding offhand item (fixes #14773)
2026-11-11 22:14:17 +00:00
rdbt d47d019d27 Merge pull request #14843 from vermingov/fix/trident-negative-life-14834
A trident keeps a negative life and shake across a reload (#14834)
2026-11-11 22:14:12 +00:00
rdbt 5c03db3283 Merge pull request #14864 from DUzzL/fix/14824-spit-bullet-nbt
Keep Air, Fire and fall_distance on llama spit and shulker bullet saves
2026-11-11 22:14:05 +00:00
rdbt 33f8d2f80c Merge pull request #14869 from vermingov/fix/item-age-short-14867
A dropped item narrows a loaded Age and PickupDelay to a short (#14867)
2026-11-11 22:13:58 +00:00
rdbt 5d0aeb8197 Merge pull request #14954 from Web-mc1/fix/add-padding-bits-zero
Guard add_padding against bits==0 and bits>=64
2026-11-11 22:13:54 +00:00
rdbt 91a474ae11 Merge pull request #14962 from MSedgeMC/fix/fox-alert-visibility
An invisible player far away does not keep a fox alert
2026-11-11 22:13:49 +00:00
rdbt 827b18454f Merge pull request #14974 from vermingov/fix/falling-block-flags-14971
A falling block's saved flags are read as bytes (#14971)
2026-11-11 22:13:43 +00:00
rdbt 6edd7b88a9 Merge pull request #14976 from vermingov/fix/gateway-exact-teleport-14975
An end gateway's ExactTeleport is read as a byte (#14975)
2026-11-11 22:13:39 +00:00
rdbt 44316071c3 Merge pull request #14978 from vermingov/fix/block-entity-flag-byte-14977
A block entity's booleans are read as bytes (#14977)
2026-11-11 22:13:34 +00:00
rdbt d2cf911a6d Merge pull request #14988 from DUzzL/fix/14896-itemframe-neg-rotation
Keep the sign of a loaded item frame ItemRotation
2026-11-11 22:13:29 +00:00
rdbt 2b4656e70f Merge pull request #14993 from DUzzL/fix/14895-thrown-item-count
Force thrown item stack count to 1 like ThrowableItemProjectile.setItem
2026-11-11 22:13:24 +00:00
rdbt 548a41afa2 Merge pull request #14995 from MSedgeMC/fix/lectern-book-block-change
port: a lectern's book state change posts block_change
2026-11-11 22:13:19 +00:00
rdbt f15d4a5845 Merge pull request #14998 from Ragbie/fix/piston-arrow-bubble-column
Pushed arrows and client shulker bullets take the bubble column push
2026-11-11 22:13:14 +00:00
rdbt be7e745e48 Merge pull request #15016 from DUzzL/fix/14942-vehicle-powder-snow
Count powder snow freeze ticks on boats and minecarts
2026-11-11 22:13:08 +00:00
rdbt b67388bf60 Merge pull request #15026 from DUzzL/fix/14964-orb-air
Keep an experience orb's Air through load and save
2026-11-11 22:13:02 +00:00
rdbt f738f99adb Merge pull request #15043 from DUzzL/fix/15020-windcharge-air-fire
Keep Air and Fire on wind charges when saving and loading
2026-11-11 22:12:57 +00:00
rdbt b94b07ff5e Merge pull request #15044 from DUzzL/fix/15022-crystal-fire
An end crystal's first tick clears a loaded Fire
2026-11-11 22:12:52 +00:00
Akirabane 2106e9b21e Merge remote-tracking branch 'origin/main' into local/R13-snow-golem-stroll 2026-11-11 23:12:46 +01:00
rdbt ae0c0273f5 Merge pull request #15050 from DUzzL/fix/15047-portpack-placeholder
port-pack: accept a lone placeholder mod id template
2026-11-11 22:12:46 +00:00
rdbt ff0dbe7864 Merge pull request #15064 from ilguerro/fix/zombie-fire-aspect-rules
Zombies burn you only with a bare hand, on the difficulty's chance
2026-11-11 22:12:41 +00:00
rdbt e4042d5a2e Merge pull request #15066 from MSedgeMC/fix/reset-keys-active
The Controls Reset All button is inactive until a binding changes
2026-11-11 22:12:36 +00:00
rdbt 9fe0fd9cc7 Merge pull request #15073 from KBroneZ/claude/up-7843
A client plays with the server's block and item tags
2026-11-11 22:12:30 +00:00
rdbt 8ae679f4e1 Merge pull request #15105 from vnsmoreira/packs/s00-arrays
Core shaders: an array sized by its initializer only translates (#9386)
2026-11-11 22:12:24 +00:00
rdbt c493a84388 Merge pull request #15114 from angel2rider/docs/agents-map-lang-coverage
docs: the agents map says what lang_coverage.py --summary prints
2026-11-11 22:12:15 +00:00
rdbt d1255f85ab Merge pull request #15124 from MSedgeMC/fix/crafter-slot-state-mark
port: a crafter's slot state write marks the chunk
2026-11-11 22:12:10 +00:00
rdbt f176986623 Merge pull request #15129 from qaesen/fix-plugin-resource-packs-8570
FIX(hud): enable full compatibility for plugin resource packs (Nexo, ItemsAdder, BetterModel) (fixes #8570)
2026-11-11 22:12:05 +00:00
rdbt f0832b0998 Merge pull request #15145 from angel2rider/fix/14539-portal-cooldown
Entity.saveWithoutId writes the live PortalCooldown, not the loaded one
2026-11-11 22:11:59 +00:00
rdbt 55477b7c27 Merge pull request #15146 from MSedgeMC/fix/end-gateway-age-marks
port: an end gateway's ageing marks the chunk
2026-11-11 22:11:54 +00:00
rdbt 947c085c0f Merge pull request #15148 from MSedgeMC/fix/chat-delay-slider
The Chat Delay control is a working slider
2026-11-11 22:11:49 +00:00
rdbt 9b49d846f0 Merge pull request #15162 from MSedgeMC/fix/server-icon-1024
A server icon is kept up to 1024x1024, not only 64x64
2026-11-11 22:11:44 +00:00
Akirabane b1cdc91bf4 Merge remote-tracking branch 'origin/main' into local/R09-world-folder-name 2026-11-11 23:11:43 +01:00
Akirabane e7cdaa120a Merge remote-tracking branch 'origin/main' into local/PM4-game-012 2026-11-11 23:10:40 +01:00
Akirabane 58a9cbddca Merge remote-tracking branch 'origin/main' into local/R16-accept-teleport 2026-11-11 23:09:47 +01:00
rdbt e4822fe906 Merge pull request #15169 from MSedgeMC/fix/tooltip-attribute-stack
Tooltip attribute modifiers come from the stack
2026-11-11 22:08:49 +00:00
rdbt 24a2bf3900 Merge pull request #15184 from MSedgeMC/fix/region-damaged
Region: a chunk the file cannot deliver is absent, not an error
2026-11-11 22:08:45 +00:00
rdbt b2a2758162 Merge pull request #15187 from MSedgeMC/fix/chunk-default-pos
Chunk storage: a chunk without xPos/zPos is the chunk at 0, 0
2026-11-11 22:08:39 +00:00
rdbt 6757c5ed35 Merge pull request #15188 from MSedgeMC/fix/backdrop
A text backdrop frames the glyphs by one pixel
2026-11-11 22:08:35 +00:00
rdbt f57714de73 Merge pull request #15189 from angel2rider/fix/7899-ssbo-binary-init
Shader packs: a buffer object can start with the bytes of a file
2026-11-11 22:08:30 +00:00
rdbt 7b02095700 Merge pull request #15193 from DUzzL/fix/15180-enderman-place
Enderman: check carried block survival when placing; play stare sound from synced data
2026-11-11 22:08:25 +00:00
rdbt 78249e7c9e Merge pull request #15195 from MSedgeMC/fix/scrollcursor
The pack list's scrollbar shows its hover and drag cursor
2026-11-11 22:08:20 +00:00
rdbt 1e10e533cb Merge pull request #15219 from yahya-nusseibeh/fix/legacy-trim-preview-bounds
Bound legacy smithing-trim previews from old servers
2026-11-11 22:08:16 +00:00
rdbt a2d3ac8b64 Merge pull request #15226 from yahya-nusseibeh/fix/protocol-plain-text-cap
Protocol text: a disconnect reason can't expand past 1 MiB
2026-11-11 22:08:11 +00:00
rdbt 8e26a88f53 Merge pull request #15232 from yahya-nusseibeh/fix/supervisor-queued-work
Supervisor: apply queued restores and updates before every start
2026-11-11 22:08:07 +00:00
rdbt 6b704ba6f3 Merge pull request #15233 from yahya-nusseibeh/fix/server-toml-atomic
Server: write server.toml atomically under one lock, and keep the world if a restore fails
2026-11-11 22:08:02 +00:00
rdbt f8f2571c0b Merge pull request #15236 from yahya-nusseibeh/fix/launcher-accounts-atomic
Launcher: save accounts.json atomically and merge what a launch renewed
2026-11-11 22:07:57 +00:00
rdbt 9c08a53ebf Merge pull request #15242 from yahya-nusseibeh/fix/forged-log-lines
Server: players can't forge the game's log lines or flood the log
2026-11-11 22:07:53 +00:00
rdbt 083321a7fd Merge pull request #15251 from yahya-nusseibeh/fix/realms-download-files
Client: private Realms temp files, a bounded pax record, and no leaked boot-screen font
2026-11-11 22:07:45 +00:00
rdbt bbe8dfd73f Merge pull request #15254 from yahya-nusseibeh/fix/server-backup-settings-edges
Server: backup and settings edge cases
2026-11-11 22:07:41 +00:00
rdbt 0b4c9b627e Merge pull request #15353 from Akirabane/fix/login-query-payload-cap
Login: a custom-query payload over 1 MiB is refused, as vanilla refuses it
2026-11-11 22:07:36 +00:00
rdbt 1a42c8bf10 Merge pull request #15357 from Akirabane/fix/waypoint-icon-saved
A player's /waypoint modify icon is saved with the player, as vanilla saves it
2026-11-11 22:07:31 +00:00
rdbt b2ae481dee Merge pull request #15362 from DylanGonzaloFerreyra/split-play-hud
Move the boss bar, scoreboard and team packets into play/hud.rs
2026-11-11 22:07:26 +00:00
rdbt 6f3ec8b315 Merge pull request #15368 from AlfaRojo/fix/decorated-pot-normals
The decorated pot keeps its face normals and still culls
2026-11-11 22:07:21 +00:00
rdbt f7c43dd03b Merge pull request #15202 from loicpiller/feature/9432-realms-availability
Realms: the availability check vanilla makes before use (1/2)
2026-11-11 22:07:16 +00:00
rdbt 8ba548a33d Merge pull request #15205 from Akirabane/tooling/5879-water-e2e-cases
anticheat_e2e: prediction-water plays kelp, a waterfall, a changing current and waterlogged blocks
2026-11-11 22:07:11 +00:00
rdbt 07257993e6 Merge pull request #15214 from Akirabane/fix/12358-route-no-window
Finding the gateway on Windows opens no console window
2026-11-11 22:07:06 +00:00
rdbt bda383e91b Merge pull request #15271 from rdbtCVS/fix/far-chunk-gaps
Fix missing chunks and stalled retries beyond the server view distance
2026-11-11 22:07:01 +00:00
rdbt b78c1e633a Merge pull request #15348 from kacpherek/tooling/pr-ready-quick
pr_ready: run the harnesses only where CI's parity job would
2026-11-11 22:06:57 +00:00
rdbt 117db61043 Merge pull request #15422 from kacpherek/fix/14887-naga-subgroup-2d
Shader packs: allow subgroup ids in 2-D compute workgroups
2026-11-11 22:06:51 +00:00
rdbt b32ba7fa20 Merge pull request #15423 from kacpherek/fix/14886-naga-quad-swap
Shader packs: translate the subgroupQuadSwap functions
2026-11-11 22:06:46 +00:00
rdbt 95a330832d Merge pull request #15185 from MSedgeMC/fix/conversion-absorption
A converting mob keeps its golden hearts
2026-11-11 22:06:36 +00:00
rdbt eb0b785ec8 Merge pull request #15186 from MSedgeMC/port_map/zombie-drowning-rows
port map: the drowning conversion has rows
2026-11-11 22:06:31 +00:00
rdbt 0742928943 Merge pull request #15447 from kacpherek/fix/uk-ua-stale-key
main is red: a stale Ukrainian string and a sulfur cube harness group
2026-11-11 22:06:23 +00:00
Ludovic Marie 692a4d8059 Merge pull request #15413 from horizon-rust/server-tick-untimed-spans
🤖 Agent: timing spans around every untimed call of a level's pass (about 200 ms of the SMP's 250-330 ms overworld pass had none, #15349); spans read nothing while off, and no behaviour changes.
2026-11-11 22:42:03 +01:00
Ludovic Marie ceff58d796 Merge pull request #15409 from horizon-rust/fix/watchdog-server-stack
🤖 Agent: the watchdog now reads the stuck server thread's stack (a SIGURG handler installed only when a tick hangs, 5 s wait, else the report says why), so the next SMP freeze (#15349) names the function it was in; nothing changes until a tick hangs.
2026-11-11 22:41:59 +01:00
Kacpherek 31915393c1 command_exec: the sulfur cube size group shrinks its cubes before killing them
tests/test_command_exec_group_cleanup.py fails on main since #13823: the new 'sulfur cube size attributes' group kills its cubes after its last tick without shrinking them, so a size 2 cube would split inside the next group. Same shrink as the other sulfur cube groups; both commands are setup commands, so no record changes.
2026-11-11 22:32:22 +01:00
Kacpherek b36e25ab16 Ukrainian: drop the HDR peak hint en_us no longer has
en_us dropped "Your display's reported peak is often too high: calibrate to set it up." in 1c93067327; #14706 added its Ukrainian text back today, so lang::tests::bundled_files_are_served_and_have_no_stale_or_repeated_keys fails on main and on every pull request tested against it.
2026-11-11 22:32:10 +01:00
Kacper e86000f6eb Merge pull request #15309 from Akirabane/docs/port-map-game-003-fixed-gaps
port map: two rows of game-003.tsv no longer list gaps that are fixed
2026-11-11 22:29:58 +01:00
Kacpherek 56d067048f Merge commit '6cc36c6f3a' into unstick/15296
# Conflicts:
#	crates/horizon-client/src/game/boats/below_world.rs
2026-11-11 22:29:50 +01:00
Kacper 1bd446f250 Merge pull request #15306 from Akirabane/docs/port-map-game2-001-fixed-gaps
port map: three rows of game2-001.tsv no longer list gaps that are fixed
2026-11-11 22:29:46 +01:00
Kacper 08be24761f Merge pull request #15223 from yahya-nusseibeh/fix/control-one-line-requests
horizon-server: a management kick reason can't inject control requests
2026-11-11 22:29:22 +01:00
Kacpherek 175c1a69a6 Merge commit '6cc36c6f3a' into unstick/14813
# Conflicts:
#	crates/horizon-client/src/entity_nbt.rs
2026-11-11 22:28:43 +01:00
Kacper ccbb5f85bc Merge pull request #15052 from DUzzL/fix/15048-rabbit-yaw
Rabbit: start the move control in MOVE_TO towards the origin like vanilla
2026-11-11 22:28:30 +01:00
Kacper e705c9175d Merge pull request #15036 from DUzzL/fix/15017-dispense-position
Fix dispense position float order to match vanilla
2026-11-11 22:28:11 +01:00
Kacpherek fa5f2c393e Merge commit '6cc36c6f3a' into unstick/14927
# Conflicts:
#	crates/horizon-client/src/entity_nbt.rs
2026-11-11 22:27:46 +01:00
Kacper 2d6af5f5ea Merge pull request #14917 from Detimes/perf-block-event-queue
Perf: the block-event queue pops and dedupes in O(1)
2026-11-11 22:27:44 +01:00
Kacpherek d53c507d5e Merge commit '6cc36c6f3a' into unstick/15026
# Conflicts:
#	crates/horizon-client/src/entity_nbt.rs
2026-11-11 22:24:59 +01:00
Kacpherek b5c00e8040 Merge commit '6cc36c6f3a' into unstick/14914
# Conflicts:
#	crates/horizon-client/src/entity_nbt.rs
2026-11-11 22:24:24 +01:00
Kacpherek 23ae7d42c2 Merge commit '6cc36c6f3a' into unstick/14885
# Conflicts:
#	crates/horizon-client/src/entity_nbt.rs
2026-11-11 22:24:15 +01:00
Kacper 6cc36c6f3a Merge pull request #15229 from yahya-nusseibeh/fix/management-connection-cap
Cap management and API connections, and give a request one deadline
2026-11-11 22:20:45 +01:00
Kacper 60f0ab264d Merge pull request #15055 from ilguerro/fix/pot-comparator-signal
A comparator beside a decorated pot reads its item
2026-11-11 22:20:28 +01:00
Kacper 29f46b1320 Merge pull request #15045 from DUzzL/fix/14941-camel-lastposetick
Keep a camel's saved LastPoseTick an absolute game time while it is not ticking
2026-11-11 22:20:11 +01:00
Kacper 7a756c4b47 Merge pull request #14756 from ilguerro/perf/worldgen-noise
Worldgen: the range choice picks its values without a branch per block
2026-11-11 22:19:41 +01:00
Kacper b3e977c932 Merge pull request #15305 from Akirabane/fix/lan-keep-alive-pending-id
LAN: a keep-alive answer that is not the pending one disconnects the guest, as vanilla does
2026-11-11 22:18:55 +01:00
Kacper 7b3c73f790 Merge pull request #15298 from Akirabane/fix/lan-owner-name-taken
LAN: a login with the host's own name is refused while the host is online, as vanilla does
2026-11-11 22:18:38 +01:00
Kacper d740d99388 Merge pull request #15279 from Akirabane/fix/fov-walking-speed-from-server
The field of view uses the walking speed the server sends in the abilities packet
2026-11-11 22:18:21 +01:00
Kacper 915337910a Merge pull request #15130 from vnsmoreira/packs/polytone-limit-join
Polytone: a type's limit and exclusion_radius count only particles in the world
2026-11-11 22:18:04 +01:00
Kacper cf1f44ac13 Merge pull request #15035 from KBroneZ/claude/up-8688
Preview the anvil with a server's data-pack enchantments
2026-11-11 22:17:46 +01:00
Kacper 8ea9f4a20f Merge pull request #14944 from Detimes/perf-held-item
Perf: borrow the held item in tick_guests instead of cloning the stack
2026-11-11 22:17:19 +01:00
Kacper 94233f3ce1 Merge pull request #14882 from vermingov/fix/fire-short-14879
A mob and an armor stand narrow a loaded Fire to a short (#14879)
2026-11-11 22:16:23 +01:00
Kacper 8778015009 Merge pull request #14877 from vermingov/fix/mob-hurt-time-short-14876
A mob narrows a loaded HurtTime and DeathTime to a short (#14876)
2026-11-11 22:16:06 +01:00
Kacper 114e8cad52 Merge pull request #14787 from DUzzL/fix/14754-leash-knot-nosave
Skip leash knots when saving chunk entities
2026-11-11 22:15:49 +01:00
Kacper c63152903d Merge pull request #14777 from foudretbr/fix/drowned-husk-conversion-and-cure-timer
fix(mobs): align drowned and husk conversion ticks and cure random with vanilla (#14770)
2026-11-11 22:15:32 +01:00
Kacper 35b1b3358d Merge pull request #14686 from Mayonesu0/up-14542-armor-stand-first-tick-attrs
A loaded armor stand saves the movement_speed its first tick makes
2026-11-11 22:15:15 +01:00
Kacper 33859e433d Merge pull request #14636 from NikitaMGrimm/parity/ominous-spawner-loaded-rotation
An ominous item spawner keeps the rotation, OnGround and fall_distance it was loaded with
2026-11-11 22:14:58 +01:00
Kacper b5bba8a620 Merge pull request #14591 from DUzzL/fix/14465-item-frame-facing
Wrap item frame and painting facing bytes like vanilla's Direction codecs
2026-11-11 22:14:41 +01:00
Kacper ca7044d165 Merge pull request #14560 from Ragbie/refactor/entity-nbt-orb
Move the experience orb NBT out of entity_nbt.rs
2026-11-11 22:14:24 +01:00
Kacper 93beed5df7 Merge pull request #14183 from DUzzL/fix/14157-sign-four-lines
Load a sign side empty unless messages has exactly 4 lines
2026-11-11 22:14:06 +01:00
Kacper 7c61110eee Merge pull request #14162 from RLC248/fix/mace-smash-motion-at-hit
A mace smash on a player sends the attacker's motion after the hit
2026-11-11 22:13:49 +01:00
Kacper edd751003e Merge pull request #14147 from MSedgeMC/fix/sheep-colour-codec
An unknown saved sheep colour reads white
2026-11-11 22:13:32 +01:00
Kacper 571e833b48 Merge pull request #13823 from NikitaMGrimm/parity/sulfur-cube-bucket-health
A sulfur cube saves its size's max_health and keeps its size's speed after a reload
2026-11-11 22:13:15 +01:00
Kacpherek 1aa3ad9441 Merge commit 'a5df8273bb' into unstick/14179
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
#	crates/horizon-client/src/game/frames.rs
2026-11-11 22:06:31 +01:00
Kacpherek fc67c07589 Merge commit 'a5df8273bb' into unstick/14571
# Conflicts:
#	crates/horizon-client/src/game/entity.rs
2026-11-11 22:04:59 +01:00
Vinícius Moreira a4266531f2 Merge commit 'c8e1eb66199f696d1a6218fbbaeef1863ce5cc00' into fix/explosion-player-xp
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-11 17:57:09 -03:00
Kacper bb6b34d530 Merge branch 'main' into fix/15275-broken-stat 2026-11-11 21:51:13 +01:00
Kacper be2f80a4cf Merge branch 'main' into fix/beacon-payment-order-tooltips 2026-11-11 21:50:39 +01:00
Kacper 12e1d88d71 Merge branch 'main' into fix/chat-scroll-keeps-place 2026-11-11 21:50:20 +01:00
Kacper 1a9bf76f69 Merge branch 'main' into fix/item-void-floor 2026-11-11 21:49:44 +01:00
Kacper c8e1eb6619 Merge pull request #15286 from GIGABAIT93/ci/prepare-lock-link
CI vanilla data: horizon-prepare's lock file is no prepared output
2026-11-11 21:48:30 +01:00
Kacper 31578f0509 Merge pull request #15208 from KBroneZ/claude/up-15164
Random tick chunks in position order, not chunk load order
2026-11-11 21:48:18 +01:00
Kacper 12ddb73e82 Merge pull request #15172 from angel2rider/tools/tools-a-flag-is-not-a-directory-or-a-folder
tools: a flag is not a directory or a folder
2026-11-11 21:48:10 +01:00
yahya-nusseibeh 715402eb80 Merge remote-tracking branch 'upstream/main' into fix/port-pack-licences 2026-11-11 22:43:21 +02:00
yahya-nusseibehandClaude Opus 5.5 18e26a3e4d Port pack: count a family with its version run on as that family
The whole-word match turned GPLv3, LGPLv3, AGPLv3 and MPLv2 into custom: their token is
gplv3, not gpl. A token now matches a family when it is the family's name followed by
nothing, or by an optional v and digits. Tests for those spellings.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 22:43:18 +02:00
Kacpherek 95756af921 Merge commit 'a5df8273bb' into unstick/15313
# Conflicts:
#	crates/horizon-client/src/mobs.rs
2026-11-11 21:42:52 +01:00
Kacpherek 72e4d61b57 Merge commit 'a5df8273bb' into unstick/14651
# Conflicts:
#	crates/horizon-client/src/mobs/living_tests.rs
2026-11-11 21:42:15 +01:00
Kacper d0f5bccf60 Merge pull request #13737 from RINNECODER/up10/boat-table-trig
Boats: Mth's sine table for steering, dismounts and minecart pushes; lastYd for water entry
2026-11-11 21:42:00 +01:00
Kacpherek 6604778b58 Merge commit 'a5df8273bb' into unstick/14431
# Conflicts:
#	crates/horizon-client-render/src/streaming.rs
2026-11-11 21:39:33 +01:00
Kacpherek ce25b504c8 Merge commit 'a5df8273bb' into unstick/14260
# Conflicts:
#	crates/horizon-client/src/projectiles.rs
2026-11-11 21:36:40 +01:00
Kacpherek 5a281cec36 Merge commit 'a5df8273bb' into unstick/14473
# Conflicts:
#	crates/horizon-client/src/game/explosion.rs
2026-11-11 21:36:25 +01:00
Kacper a5df8273bb Merge pull request #15358 from ilguerro/fix/spider-hard-effect
Spiders spawned at runtime get their Hard-difficulty effect
2026-11-11 21:34:16 +01:00
Kacper a0bede5135 Merge pull request #15297 from bct8925/adreno-vulkan
Adreno on Vulkan: pack the shaders' indexed tables, and test occlusion in the shader
2026-11-11 21:34:08 +01:00
Kacper 470578dd2e Merge pull request #15163 from MSedgeMC/fix/bundle-tooltip-lines
An empty bundle's tooltip is as tall as its description
2026-11-11 21:34:00 +01:00
Kacper a5fce8fada Merge pull request #15153 from tarzinio/fix-edit-queue-cap
Cap the occlusion edit queue when cave culling is off
2026-11-11 21:33:53 +01:00
Kacper f9a645f1ec Merge pull request #15125 from MSedgeMC/fix/potion-douse-source
port: a doused campfire's block_change carries the potion's owner
2026-11-11 21:33:45 +01:00
Kacper a93fe71b74 Merge pull request #15118 from MSedgeMC/ledger/rows-3
port_map: SelectWorldScreen's button states are all modelled
2026-11-11 21:33:35 +01:00
Akirabane 33a70e3fa4 Merge remote-tracking branch 'origin/main' into local/R13-snow-golem-stroll 2026-11-11 21:33:28 +01:00
Kacper 203baeb0bd Merge pull request #15107 from angel2rider/tools/velocity-probe-protocol
tools: the velocity probe reads the protocol it announces, instead of writing 777 by hand
2026-11-11 21:33:26 +01:00
Akirabane f5ea4bde3a Merge remote-tracking branch 'origin/main' into local/R09-world-folder-name 2026-11-11 21:33:25 +01:00
Akirabane 251976843a Merge remote-tracking branch 'origin/main' into local/PM4-game-012 2026-11-11 21:33:23 +01:00
Akirabane 8e17b81335 Merge remote-tracking branch 'origin/main' into local/R16-accept-teleport 2026-11-11 21:33:21 +01:00
Kacper 307029382a Merge pull request #15106 from vnsmoreira/packs/s00-vertex-index
Core shaders: a pack's gl_VertexIndex reads the glue's vertex number
2026-11-11 21:33:19 +01:00
Kacper 098012518a Merge pull request #15077 from ilguerro/fix/zombie-powder-snow-sunburn
Keep a zombie in powder snow from catching fire in daylight
2026-11-11 21:33:11 +01:00
Kacper a21d37c81d Merge pull request #15028 from sunrise-org/dims/render-facts
Sky, shading and ambient light follow the dimension type (#11282, 7/9)
2026-11-11 21:33:03 +01:00
Kacper 2c1584e88c Merge pull request #14708 from Akirabane/structure-mobs-preload-13857
Server: the mob data generation finalizes with is read when the world opens
2026-11-11 21:32:32 +01:00
Kacper c7d7576e77 Merge pull request #14706 from GIGABAIT93/l10n/uk-nightly-1110
Ukrainian: the 96 strings uk_ua.json lacked (radial menu, HDR, fuel)
2026-11-11 21:32:25 +01:00
Kacper 37bee174e9 Merge pull request #14406 from rdbtCVS/perf/heightmap-columns
Heightmaps: a write marks its column, found again alone on its next read (split of #13644, part 1)
2026-11-11 21:31:24 +01:00
rdbtCVSandGPT 6.1 Sol 9ac80b72b7 Move command position validation out of oversized dispatcher
Address the maintainer request on PR #14373 by relocating the loaded and spawnable block-position helpers into a sibling module. Keep both implementations and their game-module visibility unchanged.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-11 20:31:23 +00:00
Kacper 34afc9aced Merge pull request #14389 from vnsmoreira/fix/comparator-setblock-output
A comparator placed with OutputSignal by /setblock keeps it
2026-11-11 21:31:14 +01:00
Kacper 1f31436c29 Merge pull request #14345 from MSedgeMC/fix/command-block-edit-mark
Editing a command block marks its chunk
2026-11-11 21:31:05 +01:00
Kacper 99d9a0b931 Merge pull request #14022 from MSedgeMC/fix/weapon-disable-blocking-component
Disable-blocking seconds come from a stack's weapon component
2026-11-11 21:30:09 +01:00
Kacper 3eeeb544f0 Merge pull request #13836 from NikitaMGrimm/projectile-uuid-draws
A projectile draws its UUID and its shot from its own random, as vanilla does
2026-11-11 21:30:02 +01:00
Kacper d5157fe7d6 Merge pull request #13832 from RINNECODER/up13/vehicle-below-world
Boats and minecarts more than 64 blocks under the world are discarded
2026-11-11 21:29:54 +01:00
AkirabaneandClaude Sonnet 5.5 a8d3ffaf2e Move the model parts out of mobs.rs into mobs/model.rs
Polygon, ModelNormals and Part move byte for byte; only visibility changes
(the private members the sibling modules reach become pub(super)), and mobs.rs
declares the module and re-exports Part and Polygon. A port map row follows the
move. mobs.rs goes from 4931 to 4720 lines without test modules, so a change
to Seen has room.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 21:23:03 +01:00
Kacper f740fdae36 Merge pull request #14678 from horizon-rust/split/external-pipeline-hook
Renderer: the ExternalPipeline hook for shader packs (layer plan M6, step a)
2026-11-11 21:18:41 +01:00
Kacper 2a9ba00903 Merge pull request #15204 from senjinthedragon/oit-crack-size
Cracks keep their size with Improved Transparency and a pack's own destroy stages
2026-11-11 21:12:58 +01:00
Kacper 32d6154943 Merge pull request #15178 from angel2rider/tools/tests-the-two-ci-scripts-that-had-no-test
tests: the two CI scripts that had no test
2026-11-11 21:12:51 +01:00
Kacper 33e30ec6fc Merge pull request #15174 from angel2rider/tools/tools-roadmap-py-s-help-says-the-python-tests-ru
tools: roadmap.py's help says the Python tests run its checks, not CI
2026-11-11 21:12:43 +01:00
Kacper cb89d995c6 Merge pull request #15155 from MSedgeMC/fix/slider-resize-cursor
A dragged slider shows the horizontal resize cursor
2026-11-11 21:12:36 +01:00
Kacper 8a6cf18c34 Merge pull request #15119 from MSedgeMC/port_map/campfire-marked-updated
port_map: CampfireBlockEntity.markUpdated is covered on both paths
2026-11-11 21:12:27 +01:00
Kacper c49a0ebf5b Merge pull request #15117 from MSedgeMC/ledger/rows-2
port_map: the Language page draws the accuracy warning
2026-11-11 21:12:19 +01:00
Kacper 69b930572d Merge pull request #15116 from MSedgeMC/ledger/rows-1
port_map: the Direct Connection screen's Join button state and initial focus
2026-11-11 21:12:12 +01:00
Kacper b891b50a74 Merge pull request #15094 from angel2rider/tools/generate-lan-command-order-help
tools: generate_lan_command_order.py prints usage instead of running the generator on --help
2026-11-11 21:12:04 +01:00
Kacper d8441062e9 Merge pull request #15089 from angel2rider/tools/lang-coverage-show-keys
tools: lang_coverage.py no longer drops --show-keys when --summary is given
2026-11-11 21:11:56 +01:00
Kacper 014dab78fc Merge pull request #15087 from angel2rider/tools/anvil-dump-help
tools: anvil_dump.py asks for its file instead of crashing on --help, and gets a test
2026-11-11 21:11:49 +01:00
Kacper b99d24ae9e Merge pull request #15076 from senjinthedragon/pass-timing-names
Shader pack timing: passes of one name count as their sum, compute passes apart
2026-11-11 21:11:41 +01:00
Kacper e5ba661931 Merge pull request #15067 from MSedgeMC/fix/preset-slider-floor
The graphics preset slider's stops follow vanilla's floor mapping
2026-11-11 21:11:34 +01:00
Kacper 9a1340b14a Merge pull request #14803 from shawshanker18/fix/tests-sibling-imports
tests: import sibling test modules by their own name
2026-11-11 21:11:27 +01:00
Kacper 0fc29c6adb Merge pull request #13839 from NikitaMGrimm/hdr-shaders-pack-no-lift
HDR output: a shaders/ pack's alpha isn't lifted as light sources
2026-11-11 21:11:20 +01:00
Kacper 501446443c Merge pull request #12156 from RINNECODER/up2/metal-sprite-arrays
Block sprites: on Metal the arrays grow past 512 layers when the sprites need it
2026-11-11 21:10:42 +01:00
LucasandClaude Opus 5.5 884fc3f5b1 World text: the core_text tests in their own file
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 21:01:54 +01:00
Kacpherek ba299deb2d Shader packs: translate subgroupQuadSwap Horizontal, Vertical and Diagonal
naga's GLSL frontend did not know the GL_KHR_shader_subgroup_quad swaps, so any program using
them (FidelityFX SPD in several packs) failed with Unknown function. They map to naga's QuadSwap gather.
2026-11-11 21:00:56 +01:00
Kacpherek dfd289adfc Shader packs: allow subgroup ids in 2-D compute workgroups
naga rejected gl_SubgroupID and gl_SubgroupInvocationID when the workgroup had more than one
dimension, a WGSL rule GLSL does not have, so pack compute passes with a 2-D workgroup were skipped.
2026-11-11 20:58:47 +01:00
Kacpherek da035b00ec Merge main into pr-14962
# Conflicts:
#	tools/port_map/game-012.tsv
2026-11-11 20:56:24 +01:00
Kacpherek f2b07381ab Merge main into pr-13755
# Conflicts:
#	crates/horizon-client/src/mobs.rs
2026-11-11 20:55:08 +01:00
Vinícius MoreiraandClaude Opus 5.5 bee546c085 Core shaders: blocks and samplers a pipeline provides, and blocks on one line
A pack's core shader could read only vanilla's blocks and samplers. A
pipeline that gives it more (the improved-transparency stages' depth bounds
and coefficients for core/text, Polytone's blocks) translates it with
translate_core_with and the names it provides; CoreProgram::other lists
them in binding order, at other_binding(i). A uniform block declared on one
line is bound like one over several lines, where it came out unbound and
the shader was refused. An unprovided one is named as the pack declared it.

Part of #11486

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 16:53:50 -03:00
Kacpherek 4e03ad84b9 Merge main into pr-15055
# Conflicts:
#	crates/horizon-client/src/game/machines.rs
2026-11-11 20:53:28 +01:00
Kacper 084b20d5cf Merge pull request #15367 from AlfaRojo/fix/recipe-search-tooltip-only
Recipe book search without a colon matches tooltip lines only
2026-11-11 20:47:54 +01:00
Kacper 78b9b261e2 Merge pull request #15366 from AlfaRojo/fix/jukebox-placed-no-block-change
A jukebox placed with a record posts no block_change
2026-11-11 20:47:34 +01:00
Kacper fed1c51351 Merge pull request #15356 from ilguerro/fix/magma-cube-armor
Magma cubes take the damage their size's armor absorbs
2026-11-11 20:47:15 +01:00
Kacper 3dcdffb682 Merge pull request #15354 from ilguerro/fix/farm-milk-pitch-shear-event
Fix milk pitch and shear vibrations for farm animals
2026-11-11 20:46:55 +01:00
Kacper 0ac3ddb034 Merge pull request #15350 from masa-masa-0314/refactor/friends-list-updates
Friends: separate the existing list update handler
2026-11-11 20:46:35 +01:00
Kacper d54f748d38 Merge pull request #15311 from Akirabane/refactor/remove-seen-player-rep
mobs: remove Seen.player_rep, a field written for every villager and never read
2026-11-11 20:46:16 +01:00
Kacper 4deb873ce6 Merge pull request #15282 from Akirabane/fix/fall-location-trapdoors
A fall from a trapdoor says ladder, as vanilla's FallLocation does
2026-11-11 20:45:56 +01:00
Kacpherek 969effa7e0 Merge main into fix/far-chunk-gaps 2026-11-11 20:45:19 +01:00
Kacpherek 80392718df Merge main into fix/15134-flame-particles 2026-11-11 20:45:10 +01:00
Kacpherek 122a7650ad Merge main into fix/disabledlabel 2026-11-11 20:45:03 +01:00
Kacpherek c4c5649ce9 Merge main into fix/chunk-empty-sections 2026-11-11 20:44:56 +01:00
Kacpherek da7c9d05b3 Merge main into fix/brightness-gate 2026-11-11 20:44:48 +01:00
Kacpherek 938c04c32e Merge main into fix/jigsaw-local-edit 2026-11-11 20:44:34 +01:00
Kacpherek ebcd7beedb Merge main into tools/fixtures-ask-for-work-dir 2026-11-11 20:44:19 +01:00
Kacpherek 1e45ee6ed3 Merge main into tools/port-ledger-json-after-query 2026-11-11 20:44:12 +01:00
Kacpherek 8bf3aa3982 Merge main into fix/batch11-c 2026-11-11 20:44:04 +01:00
Kacpherek 258ff26bd6 Merge main into fix/15048-rabbit-yaw 2026-11-11 20:43:58 +01:00
Kacpherek f85370184c Merge main into fix/15047-portpack-placeholder 2026-11-11 20:43:50 +01:00
Kacpherek b58389b53c Merge main into fix/14941-camel-lastposetick 2026-11-11 20:43:43 +01:00
Kacpherek 1ae7d9053c Merge main into fix/15022-crystal-fire 2026-11-11 20:43:36 +01:00
Kacpherek bd20630159 Merge main into fix/15017-dispense-position 2026-11-11 20:43:28 +01:00
Kacpherek d6dd30c7ce Merge main into fix/14964-orb-air 2026-11-11 20:43:21 +01:00
Kacpherek 7c723354eb Merge main into fix/walk-target-scores 2026-11-11 20:43:06 +01:00
Kacpherek c9675c1ba5 Merge main into fix/14461-firework-dispenser-rotation 2026-11-11 20:42:52 +01:00
Kacpherek debbbf6585 Merge main into fix/jigsaw-priority-tooltip 2026-11-11 20:42:16 +01:00
Kacpherek 8215f3afef Merge main into shader-api-linear-blend 2026-11-11 20:41:54 +01:00
yahya-nusseibeh a0118a4bfc Merge upstream main into fix/server-backup-settings-edges 2026-11-11 21:33:01 +02:00
yahya-nusseibeh 61bd1fcfe4 Merge upstream main into fix/realms-download-files 2026-11-11 21:32:58 +02:00
yahya-nusseibeh 9e701221fd Merge upstream main into fix/servers-dat-lock 2026-11-11 21:32:53 +02:00
yahya-nusseibeh 02b49472ef Merge upstream main into fix/ci-pin-actions 2026-11-11 21:32:50 +02:00
yahya-nusseibeh d77c870a98 Merge upstream main into fix/forged-log-lines 2026-11-11 21:32:47 +02:00
yahya-nusseibeh 5a76ab5f51 Merge upstream main into fix/neoforge-version-path 2026-11-11 21:32:42 +02:00
yahya-nusseibeh ef218f6ced Merge upstream main into fix/windows-console-close 2026-11-11 21:32:37 +02:00
yahya-nusseibeh 3fdf92d7f4 Merge upstream main into fix/launcher-accounts-atomic 2026-11-11 21:32:35 +02:00
yahya-nusseibeh 1d37d845bd Merge upstream main into fix/server-toml-atomic 2026-11-11 21:32:32 +02:00
yahya-nusseibeh 8e4d76ef04 Merge upstream main into fix/friends-try-every-endpoint 2026-11-11 21:32:29 +02:00
yahya-nusseibeh 9df2ce4dc1 Merge upstream main into fix/management-connection-cap 2026-11-11 21:32:23 +02:00
yahya-nusseibeh 6f509879fd Merge upstream main into fix/protocol-plain-text-cap 2026-11-11 21:32:21 +02:00
yahya-nusseibeh 8246b4ac62 Merge upstream main into fix/control-one-line-requests 2026-11-11 21:32:18 +02:00
yahya-nusseibeh 1fbb508e06 Merge upstream main into fix/legacy-trim-preview-bounds 2026-11-11 21:32:10 +02:00
yahya-nusseibehandClaude Opus 5.5 a8f543b1cc Launcher: follow links above the data folder when moving it
Moving the data folder was refused when any folder above a data root was a symbolic link (a
~/.local/share on another disk, Fedora Atomic's /home). Only the roots are copied, so only the
root itself may not be a link.

Split out of #15247 at review.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 21:31:44 +02:00
lucasmts59andClaude Opus 5.5 6cd362ab78 Run a resource pack's core/text on world text
Vanilla 26.3 draws text displays, name tags and their backgrounds with
core/text (TEXT_POLYGON_OFFSET, and TEXT_SEE_THROUGH with
IS_SEE_THROUGH), so a pack's text shader reaches them, not only the
GUI's. The Globals and fog blocks the pack shaders read are made once a
frame for core/rendertype_lines, core/sky and core/text.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 20:25:30 +01:00
lucasmts59andClaude Opus 5.5 39f876471f Core shaders: 26.3's #include, varying locations and valued defines
Pastes an #include every time, as shaderc does (vanilla's includes carry
guards), and a #moj_import once, as 26.2's GlslPreprocessor did; a
shader may paste at most 1024 files and grow to 1 MiB, so includes that
multiply are refused instead of exhausting memory. Keeps a 26.3
shader's own varying locations on both stages, even where another
branch gives the same name another one, so branch-local varyings no
longer push a program past OpenGL's 16. Takes NAME VALUE defines, as
RenderPipeline.Builder.withShaderDefine(name, int).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 20:25:30 +01:00
Kevin RamirezandClaude Opus 5.5 ad8dd2f57e fishing_hook_entities: run the servers in peaceful
The flat world lies below y 40 and seed 12345 has slime chunks within
the view distance, so either server may spawn a slime
(Slime.checkSlimeSpawnRules) during the minute the steps take. On
Horizon one did: it pushed the boat off the bobber's line before the
cast and killed Alice, so the boat was never hooked. Fishing hooks do
not depend on the difficulty; peaceful keeps every monster out.

Fixes #15323

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 13:25:20 -06:00
Kacper 8235bf1380 Merge pull request #15308 from Akirabane/docs/port-map-game2-004-fixed-gaps
port map: three rows of game2-004.tsv no longer list gaps that are fixed
2026-11-11 20:22:32 +01:00
Kacper 8561fae7bb Merge pull request #15295 from Akirabane/fix/ladder-slide-flying-client
A flying client body does not stop sliding down a ladder by sneaking, as LocalPlayer does
2026-11-11 20:22:13 +01:00
Kacper 7c4bda6333 Merge pull request #15283 from masa-masa-0314/fix/realms-reset-generate-click
Realms: make Generate New World respond to mouse clicks
2026-11-11 20:21:53 +01:00
Kacper 2c7fe19a83 Merge pull request #15265 from Akirabane/fix/item-modify-list-operation
/item modify: a list operation that cannot be done leaves the list unchanged
2026-11-11 20:21:31 +01:00
Kacper f21b8ed58e Merge pull request #15238 from yahya-nusseibeh/fix/portmap-bounds
Friends: bound router answers, and keep a bad one from crashing or freezing the game
2026-11-11 20:21:11 +01:00
Kacper 904f1ddb00 Merge pull request #15059 from ilguerro/fix/goat-ram-feeding-shield
Goats stop ramming while fed or bred, and a shield halves a ram's push
2026-11-11 20:20:50 +01:00
Kacper b8d68e1848 Merge pull request #15049 from KBroneZ/claude/up-15039
A player's copper chest plays its own hinge sound
2026-11-11 20:20:29 +01:00
Kacper 3192c16c2c Merge pull request #14781 from Raymon82777/ios/threads-task-info
horizon-threads: resident memory on iOS through Mach task_info
2026-11-11 20:20:09 +01:00
Kacper e3dbea7830 Merge pull request #14639 from MSedgeMC/fix/trader-defend-spectator
A trader llama ignores a spectator attacker
2026-11-11 20:19:46 +01:00
Kacper 1b1fc9e4dd Merge pull request #14638 from MSedgeMC/fix/vex-dead-owner-target
A vex ignores a dead owner target
2026-11-11 20:19:26 +01:00
Kacper d8b9d1aee7 Merge pull request #14637 from NikitaMGrimm/parity/noai-tadpole-ages
A tadpole with NoAI still ages and grows into a frog
2026-11-11 20:19:06 +01:00
Kacper 93dc4ef3d1 Merge pull request #14165 from iseaprojects/fix/11374-raids-before-spawns
Tick raids before natural and custom spawning
2026-11-11 20:18:45 +01:00
Kacper f0b31a7bbe Merge pull request #13759 from DUzzL/fix/13732-unbreakable-tools
Unbreakable stacks take no tool wear
2026-11-11 20:18:24 +01:00
Kacper 6f3d762140 Merge pull request #13750 from MSedgeMC/fix/guide-equippable-swappable
A pad guide reads a stack's equippable slot and swappable flag
2026-11-11 20:17:53 +01:00
Kacper e142656a86 Merge pull request #13726 from MSedgeMC/fix/editbox-utf16-length
An edit box's maximum length counts UTF-16 code units, as Java's does
2026-11-11 20:17:17 +01:00
Kacper 0e7273581f Merge pull request #12488 from MSedgeMC/fix/breed-partner-panic
A breeding animal stops when its partner panics
2026-11-11 20:16:57 +01:00
mentallyunstable-labandClaude Opus 5.5 018ca6a76e docs(layers): regenerate today's exceptions from tools/layers.json
The table said 47 exceptions at d5504cd328 and still listed edges that
are gone (the horizon-client-input row; -theme, -bench, -telemetry and
-mod-api-gen for the render crate). Rebuild it from the 30 entries in
'allowed' at a89f338 and point at tools/check_layers.py for the live
count.

Closes #15408

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 21:10:55 +02:00
DetimesandClaude Opus 5.5 e317cb70da Merge upstream/main into perf-block-event-queue
Resolves the conflict with main so verify and parity can run again.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 19:09:44 +00:00
yahya-nusseibeh 8bbb380e2a Merge remote-tracking branch 'upstream/main' into fix/supervisor-queued-work 2026-11-11 21:09:28 +02:00
yahya-nusseibehandClaude Opus 5.5 3ffd798109 Keep switch()'s doc comment above switch
The queued-work change split it so apply_queued took the first half.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 21:08:35 +02:00
Kevin Ramirez 242fba5b5a Merge upstream main for CI (play_ping_tests fix) 2026-11-11 13:07:53 -06:00
DetimesandClaude Opus 5.5 03decca8b2 Merge upstream/main into perf-recording-box
Resolves the conflict with main so verify and parity can run again.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 19:07:44 +00:00
Horizon agentandClaude Opus 5.5 99e14c6d02 Name the untimed parts of a server level's pass in tick spans
The official SMP's slow-tick lines spend ~200 ms of a 300 ms overworld
pass outside every named span. Spans now wrap each untimed call of the
level loop (enter_level, the mods' level hooks, streaming, update_world),
of tick/tick_world and of update_world, and level_tick wraps the world
tick, so the line names where that time goes.

Refs #15349

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 20:02:43 +01:00
Evrim CicekandClaude Opus 5.5 d2a9fcd3f3 Merge upstream/main into fix/rocket-fall-distance
new_rocket now takes the rocket's own random (upstream); the rocket's splash and move helpers stay before it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 21:45:06 +03:00
Kacper a89f338f75 Merge pull request #14668 from readerkid/fix/io-pool-kick-test
Make the io_pool kick test say which packet is which, not which goes first
2026-11-11 19:45:00 +01:00
Kacper 40aa0acf24 Merge pull request #14657 from Ragbie/tests/world-border-scratch-world
levels world border tests start from a new scratch world, so they pass on a second run
2026-11-11 19:44:39 +01:00
Kacper 29afd2d73a Merge pull request #14216 from voduff/hidden-effect-promotion
Mobs: a promoted weaker Absorption clamps the amount to its maximum
2026-11-11 19:44:17 +01:00
Kacper 4062bda9c8 Merge pull request #14212 from iseaprojects/fix/11380-cart-pushable-by
Filter a moving cart's pushes by its teams' collision rules
2026-11-11 19:43:57 +01:00
Horizon agentandClaude Opus 5.5 62a2f69feb Merge main into the ExternalPipeline hook
gfx.rs: keep both imports (external's shadow helpers and main's section_fade).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 19:40:18 +01:00
Ludovic Marie 937cadf658 Merge branch 'main' into split/pages-fields 2026-11-11 19:38:59 +01:00
Ludovic Marie 3e2b1d6d19 Merge branch 'main' into split/cosmetics-settings 2026-11-11 19:38:56 +01:00
Ludovic Marie dc8f30b7e8 Merge branch 'main' into layers/chat-guard-hook 2026-11-11 19:38:53 +01:00
Ludovic Marie e5dedb5239 Merge branch 'main' into layers/extras-reconnect 2026-11-11 19:38:51 +01:00
Horizon agentandClaude Opus 5.5 18ceb494e9 Server watchdog: put the server thread's stack in the crash report
ServerWatchdog's report only had the last 50 log lines, so a tick stuck
for 60 s on the official server (#15349) never said where. The watchdog now
interrupts the server thread with a signal (Linux, Android, Apple) whose
handler captures that thread's backtrace, and the report carries it as its
stack, as vanilla's report carries the server thread's stack. A thread that
does not answer in 5 s, or a system without the signal, is named in the
report's details instead.

Refs #15349

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 19:35:19 +01:00
AkirabaneandClaude Sonnet 5.5 356c6c1cb3 Merge origin/main into local/R04-fall-location-trapdoors
Keep both the trapdoor fall_location_of and its test module and main's
mob-hit impl block in game/combat.rs.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 19:31:48 +01:00
Kevin RamirezandClaude Opus 5.5 e664d20ec8 A jukebox placed with a record posts no block_change
JukeboxBlock.setPlacedBy sets has_record with level.setBlock alone;
only JukeboxBlockEntity.notifyItemChangedInJukebox posts the
block_change game event. Since #14900 set_has_record posted it for the
placement too, and use_item_on failed 79 cases on main.

Fixes #15320

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 12:30:54 -06:00
Vinícius Moreira 5a52b1c13c Merge commit 'a85af177a8' into packs/polytone-limit-join 2026-11-11 15:27:30 -03:00
Kevin RamirezandClaude Opus 5.5 b745f39a11 The decorated pot keeps its face normals and still culls
#11899 left the shade of a world block entity to the entity shader,
which reads Vertex::normal, but the decorated pot's arm still
overwrote every normal with CULL_BACK (a baked-colour quad) to cull
the back faces of its flat top and bottom: the pot had neither a shade
nor a normal. mesher::cull_back keeps a shaded quad's normal with
SHADED_CULL_W (shade and cull, as the bat does) and gives CULL_BACK to
a baked one. The harness accepts SHADED_CULL_W as a shaded normal.

Fixes #15322

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 12:26:40 -06:00
Kacper 69ba5a7b63 Merge pull request #15239 from yahya-nusseibeh/fix/port-pack-panics
Port-pack: report odd worldgen JSON instead of panicking, and end a panicking modpack step
2026-11-11 19:20:37 +01:00
Kacper 2e2218eced Merge pull request #15176 from angel2rider/tools/tools-port-ledger-py-reads-the-pinned-version-fr
tools: port_ledger.py reads the pinned version from the lock file
2026-11-11 19:20:18 +01:00
Kacper f4e5eb5bcd Merge pull request #15109 from angel2rider/tools/port-modules-say-how-they-are-used
tools: the port modules say how they are used, and the port report finds every version site
2026-11-11 19:19:58 +01:00
Kacper 49667c9f3b Merge pull request #15097 from angel2rider/tools/git-version-check
tools: say git 2.38 or newer is required, instead of printing git's usage
2026-11-11 19:19:38 +01:00
Kacper 37050a4985 Merge pull request #14704 from Hiuoy/layers/get-the-game-hook
Layer split: the "no prepared files" screen gets playhorizon.dev through a hook
2026-11-11 19:19:17 +01:00
Kacper 7d76c6bb80 Merge pull request #14582 from voduff/fix/14577-preedit-test-lock
The preedit tests take turns with the composition state
2026-11-11 19:18:58 +01:00
Kacper a1cd2db8eb Merge pull request #14562 from MSedgeMC/fix/social-search-caret
A click in the social screen's search box places its caret
2026-11-11 19:18:38 +01:00
Kacper c2157fb2db Merge pull request #14420 from voduff/fix/14266-thread-loot
A test's world-pack loot tables stay on its own thread
2026-11-11 19:18:18 +01:00
Kacper fb03ea2f84 Merge pull request #14141 from MSedgeMC/fix/dragon-sitting-damage
A perched dragon keeps its saved damage through a reload
2026-11-11 19:17:59 +01:00
Kacper 92705c55fe Merge pull request #13754 from v0ntr3n/fix/remote-fishing-rod-cast
Show other players' fishing rods as cast while their bobber is out
2026-11-11 19:17:39 +01:00
AkirabaneandClaude Sonnet 5.5 871dc11a41 A player's /waypoint modify icon is saved as locator_bar_icon, as vanilla does
Waypoint.Icon is written to the player's tag when it has data (a style
other than the default, or a colour) and read back when nothing is held
in memory. A tag without the key gives the default icon.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 18:57:42 +01:00
rdbt a85af177a8 Merge pull request #15347 from kacpherek/docs/vanilla-then-better
docs: vanilla first, then better; drop the license rules; time rules for agents
2026-11-11 17:56:38 +00:00
masa-masa-0314andZCode 7961f052d6 refactor(friends): separate the existing list update handler
Move list state, foreground scheduling and the single background updater
into a private list_updates module. Retain the public social paths and
move the original state-mapping test without changing its assertions.

This is R1 after merged #15182, reapplied independently to main at
b66b2f8372. Preserve shared state, lock scopes,
request order and existing cache, failure and timing behavior.

Refs #15183

Co-Authored-By: ZCode <[email protected]>
2026-11-12 01:45:34 +08:00
AkirabaneandClaude Sonnet 5.5 654cefca28 Login custom queries: refuse a payload over 1048576 bytes, as vanilla does
ClientboundCustomQueryPacket and ServerboundCustomQueryAnswerPacket read what is
left of the packet as the payload and throw "Payload may not be larger than
1048576 bytes" past MAX_PAYLOAD_SIZE. Horizon read it with Rest, of any size.
RestN<N> is Rest with that limit; both packets use it.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 18:45:01 +01:00
Dylan FerreyraandClaude Opus 5.5 4b430232ed Move the boss bar, scoreboard and team packets into play/hud.rs
BossAction, BossEvent, NumberFormat, SetDisplayObjective, ResetScore,
SetScore, SetObjective, TeamParameters and SetPlayerTeam, with their
codecs and the boss bar's two flag helpers, move byte for byte into
packets/play/hud.rs. play.rs re-exports the nine public items, so every
`packets::play::…` path still resolves.

The port map follows: the two rest-012 rows that name boss_flags and
SetPlayerTeam::write point at the new file.

Part of #13063.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 14:43:17 -03:00
rdbt 663ed839ff Merge pull request #14384 from Cx188/fix/14209-remove-manual-vanilla-parity
ci: remove the obsolete manual vanilla-parity job
2026-11-11 17:40:18 +00:00
rdbt 41e2db604b Merge pull request #15324 from NikitaMGrimm/ci/quarantine-never-green
Parity: six harnesses that never passed run by name only
2026-11-11 17:40:08 +00:00
rdbt 7d5427bb4f Merge pull request #15085 from angel2rider/ci/run-cargo-with-locked
ci: run cargo with --locked so a stale Cargo.lock fails instead of being rewritten
2026-11-11 17:39:36 +00:00
rdbt cbc97b2529 Merge pull request #15325 from NikitaMGrimm/ci/audit-first-failure
Nightly audit: a harness that fails in its first audit gets an issue too
2026-11-11 17:39:07 +00:00
rdbt 86a8472693 Merge pull request #15327 from NikitaMGrimm/ci/parity-main-bisect-budget
parity-main: the bisect stops on a time budget and the next run goes on from there
2026-11-11 17:38:54 +00:00
rdbt b621a878d3 Merge pull request #14087 from horizon-rust/fix/hoglin-repeat-attack
A hoglin hits from its fight activity, 41 ticks apart
2026-11-11 17:38:17 +00:00
rdbt 0d8f2ef270 Merge pull request #14088 from horizon-rust/fix/zombie-villager-random-profession
command_exec reads the zombie villagers' profession only where it is not random
2026-11-11 17:38:09 +00:00
rdbt e68a3c6974 Merge pull request #14219 from NikitaMGrimm/fix/command-exec-group-isolation
command_exec removes what each group leaves alive before the next group
2026-11-11 17:38:00 +00:00
rdbt 5bf9ac1931 Merge pull request #14086 from horizon-rust/fix/allay-pickup-cooldown
An allay stays on its pickup cooldown at 0; /item replace triggers inventory_changed
2026-11-11 17:37:52 +00:00
rdbt 8b3db9eec1 Merge pull request #14579 from Mayonesu0/up-12830-last-hurt-by-mob-load
A saved last_hurt_by_mob is read back and ends as in vanilla
2026-11-11 17:37:44 +00:00
rdbt 9c1245513c Merge pull request #15346 from horizon-rust/fix/parity-main-preserve-failures
Preserve parity failure logs before diagnosis
2026-11-11 17:37:12 +00:00
rdbt 4a3d5daf95 Merge pull request #15337 from horizon-rust/fix/ci-ubuntu-dns-7b5e12e1
ci: evaluate Ubuntu 24.04 for hosted checks
2026-11-11 17:36:13 +00:00
rdbtCVSandCodex GPT-6.1 Terra 199c56fd1a Preserve parity failure logs before bisect
Co-Authored-By: Codex GPT-6.1 Terra <[email protected]>
2026-11-11 17:29:52 +00:00
Kacpherek deb0b34fcb pr_ready: run the harnesses only where CI's parity job would
CI leaves most pull requests' harnesses to parity-main and runs them on the pull request only when it changes a harness, an oracle, a probe or the runner (parity-scope in ci.yml). pr_ready ran every affected harness locally whenever the vanilla data was there: about 80 harness-minutes per pull request by the repository's own numbers. It now follows CI's scope and lists the others; --harnesses runs them anyway.
2026-11-11 18:16:52 +01:00
Kacpherek b0211887f2 docs: tell agents where time goes and what they can skip
AGENTS.md gets a short "Don't lose time" section from measured agent sessions and CI: check for duplicates first, find code with the map, cargo check while working, no polling loops, one test per changed behaviour, don't hunt unrelated red checks, finish when a rule is unclear. CONTRIBUTING asks for a harness only where vanilla has something to compare.
2026-11-11 18:14:48 +01:00
Kacpherek 99f82f18a9 docs: drop the license rules the removed CONTRIBUTING section left in three docs
docs/cleanroom, docs/native-hooks-neoforge and docs/how-horizon-is-built linked to the removed section and repeated its rules. The history of code removed for its license stays in how-horizon-is-built.
2026-11-11 18:14:07 +01:00
Kevin RamirezandClaude Opus 5.5 2a8da2bd76 Recipe book search without a colon matches tooltip lines only
FullTextSearchTree.searchPlainText searches the plain-text tree (the
results' tooltip lines) alone; the id path is only searched for a
namespace:path query. #14910 also matched the item id path, so
recipe_book_view failed on main ("light_blue_candle" found recipes).

Fixes #15321

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 11:10:49 -06:00
Kacpherek e5177563e9 docs: drop the per-pull-request license rules from CONTRIBUTING and the template
The license section and the template's "Other projects' code" block are removed by a Horizon Team decision: agents lost time on them and misread them.
2026-11-11 17:58:09 +01:00
Kacpherek 531a071845 docs: parity first, then improve on vanilla where players gain from it
CONTRIBUTING now says Horizon works in two stages: first it plays exactly like vanilla, then it improves on it. Performance work keeps compatibility with vanilla clients, servers and saves and the gameplay players rely on, but need not copy vanilla's internal order, random calls or slow paths. Pull requests say what they no longer reproduce exactly and how it was checked.
2026-11-11 17:54:24 +01:00
rdbtCVSandCodex GPT-6.1 Sol 49cdc775d8 Keep hosted verification and launcher routing aligned
Allow the supported pinned Ubuntu 24.04 fallback in the runner-policy check while preserving the shared pool-routing and privilege guards.

Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-11 16:54:00 +00:00
rdbt b66b2f8372 Merge pull request #15331 from NikitaMGrimm/ci/quarantine-regressed
Parity: command_exec and four regressed harnesses run by name only until their fix
2026-11-11 16:53:13 +00:00
rdbt e14b35cecb Merge pull request #14390 from voduff/map-moved-fns-2
port map: rows follow remove_entity and the minecart save fns
2026-11-11 16:47:27 +00:00
rdbtCVSandCodex GPT-6.1 Sol 4e71c65c5c ci: test Ubuntu 24.04 for hosted verification DNS failures
Refs #15336. Pin the GitHub-hosted verify fallback for a controlled CI run; preserve the existing self-hosted routing and checks.

Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-11 16:46:17 +00:00
rdbtandCodex GPT-6.1 Sol 9a8c769601 Merge pull request #15291 from KBroneZ/claude/up-feature-parse
Report missing inline-feature fields against the complete flat feature map, matching the vanilla decoder.

Validation: 84/84 strict vanilla records, the feature-codec unit test, and all 5 Pong compatibility tests passed locally; required verify passed on f55a82aa12.

Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-11 16:39:20 +00:00
rdbtCVSandCodex GPT-6.1 Sol f8a833cc41 Merge main to pick up the corrected Pong test field
Keep the gate change unchanged and include main #15250, which carries the validated Guest.record test correction.

Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-11 16:37:21 +00:00
rdbt 5a58f445a6 Merge pull request #15250 from KBroneZ/claude/up-interaction-data
Print an Interaction's width before its interaction record
2026-11-11 16:34:36 +00:00
rdbtCVSandCodex GPT-6.1 Sol ddd66cd75f Follow the movement-record relocation in the existing Pong test
Match the already validated fix in #14219, #14088, #15250 and #15291: Guest.record holds the prediction record directly after main #14005.

Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-11 16:26:41 +00:00
rdbtCVSandCodex GPT-6.1 Sol 12315601fd Keep the two repaired harnesses in the automatic parity gate
The requested bulk merge includes #15250 and #15291. Keep interaction_data and inline_feature_parse selected so those fixes retain automatic coverage.

Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-11 16:21:59 +00:00
rdbt fe0dc37392 Merge pull request #14859 from NikitaMGrimm/fix/wrapper-work-dirs
frame_map_writes and wolf_anger_writes run in work folders of their own
2026-11-11 16:18:51 +00:00
AkirabaneandClaude Sonnet 5.5 f19afb61ad Merge origin/main into local/R16-accept-teleport
The conflict was the AcceptTeleportation arm in lan/hub.rs: the handler stays in
hub/accept_teleport.rs, which takes main's change of the guest's prediction
record (g.record instead of g.anticheat.record).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 17:14:23 +01:00
rdbt 8f3adbae40 Merge pull request #15264 from Akirabane/fix/rcon-utf16-units
RCON: cut a command's output at 4096 UTF-16 units, as vanilla does
2026-11-11 16:11:05 +00:00
rdbtCVSandCodex GPT-6.1 Sol 678ed9eb24 Merge main into map-moved-fns-2 and retain the PlayerEntry note
Resolve the port-ledger conflict by retaining the explicit social_screen.rs::Entry description. The other path corrections are already on main.

Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-11 16:11:04 +00:00
rdbt 61444edbef Merge pull request #15257 from yahya-nusseibeh/fix/telemetry-bounds
Telemetry: refuse an impossible official player count
2026-11-11 16:10:57 +00:00
rdbt d38111c32a Merge pull request #15255 from yahya-nusseibeh/fix/server-connection-limits
Server: per-address RCON places, write timeouts for event streams, digest token compare
2026-11-11 16:10:49 +00:00
rdbt 0e87353bd8 Merge pull request #15246 from yahya-nusseibeh/fix/friends-relay-hardening
Friends and relay: bounded handshakes, endpoints, STUN and UPnP input
2026-11-11 16:10:42 +00:00
rdbt 74039da8e1 Merge pull request #15228 from yahya-nusseibeh/fix/relay-budgets
Relay: keep real codes joinable and registrations bounded per site
2026-11-11 16:10:34 +00:00
rdbt 5d71cad052 Merge pull request #15227 from yahya-nusseibeh/fix/command-intern-cap
Commands: a server's registry names can't leak without bound
2026-11-11 16:10:27 +00:00
rdbt 0bdf9ff9d8 Merge pull request #15224 from yahya-nusseibeh/fix/config-key-whitespace
horizon-server: a space in a config key can't get past the API's deny-list
2026-11-11 16:10:20 +00:00
rdbt e94ffb5b10 Merge pull request #15222 from yahya-nusseibeh/fix/backup-save-on-always
horizon-server: a backup always turns saving back on
2026-11-11 16:10:14 +00:00
rdbt 0065a8925a Merge pull request #14632 from MSedgeMC/port_map/game2-009-only2
port map: the game2-009 rows whose gaps are gone
2026-11-11 16:10:06 +00:00
rdbt 91f26d5aa2 Merge pull request #14606 from MSedgeMC/port_map/game-005-spawn-egg-paths
port map: the spawn egg block-entity rows point at spawn_eggs.rs
2026-11-11 16:09:58 +00:00
rdbt d0706e0059 Merge pull request #15272 from Akirabane/fix/identifier-namespace-dot-dot
Identifier: the namespace .. is refused, as vanilla's isValidNamespace does
2026-11-11 16:09:35 +00:00
rdbt 5c0a3b5ecf Merge pull request #15256 from yahya-nusseibeh/fix/server-list-writes-checked
Server: list edits report a failed save, server.properties is written atomically, plain params
2026-11-11 16:09:25 +00:00
rdbt fb0127a07f Merge pull request #15234 from yahya-nusseibeh/fix/service-system-sudo-user
Server: a --system unit runs as the account that ran sudo, never root
2026-11-11 16:09:15 +00:00
rdbt 9aa200334a Merge pull request #15225 from yahya-nusseibeh/fix/management-secret-os-random
Server files: the management secret comes from OS randomness, not the seed's stream
2026-11-11 16:09:04 +00:00
rdbt 7c0b174bbf Merge pull request #15083 from senjinthedragon/terrain-one-draw
Shader packs: one program's solid and cutout terrain draw in one pass
2026-11-11 16:08:54 +00:00
rdbt b52cee2760 Merge pull request #14575 from MSedgeMC/ledger/moved-citations-2
port map: finish the moved-module citations from the week's splits
2026-11-11 16:08:43 +00:00
rdbtCVSandCodex GPT-6.1 Sol 82d4e2faa9 Follow the guest movement record in the existing Pong test
Compatibility fix for main pull request #14005: the record moved out of anticheat onto Guest. Preserve the existing tick_end assertion.

Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-11 16:06:33 +00:00
rdbtCVSandCodex GPT-6.1 Sol fa6140c555 Follow the guest movement record in the existing Pong test
Compatibility fix for main pull request #14005: the record moved out of anticheat onto Guest. Preserve the existing tick_end assertion.

Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-11 16:06:33 +00:00
rdbtCVSandCodex GPT-6.1 Sol 9945e0cb21 Follow the guest movement record in the existing Pong test
Compatibility fix for main pull request #14005: the record moved out of anticheat onto Guest. Preserve the existing tick_end assertion.

Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-11 16:01:02 +00:00
rdbtCVSandCodex GPT-6.1 Sol f55a82aa12 Follow the guest movement record in the existing Pong test
Compatibility fix for main pull request #14005: the record moved out of anticheat onto Guest. Preserve the existing tick_end assertion.

Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-11 16:01:02 +00:00
rdbtCVSandCodex GPT-6.1 Sol f72c7f644d Follow the guest movement record in the existing Pong test
Compatibility fix for main pull request #14005: the record moved out of anticheat onto Guest. Preserve the existing tick_end assertion.

Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-11 16:01:02 +00:00
rdbtCVSandCodex GPT-6.1 Sol 15ed0df804 Follow the guest movement record in the existing Pong test
Compatibility fix for main pull request #14005: the record moved out of anticheat onto Guest. Preserve the existing tick_end assertion.

Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-11 16:01:01 +00:00
Nikita Maximilian GrimmandClaude Fable 5.2 1b2c3b4ae2 parity: command_exec runs by name only while a few of its commands differ in every run
Part of #15316.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-11 16:59:56 +01:00
Nikita Maximilian GrimmandClaude Fable 5.2 9d87f01363 parity: four harnesses that regressed on main run by name only until their fix
use_item_on (#15320), recipe_book_view (#15321), block_entity_render
(#15322) and fishing_hook_entities (#15323) passed at main's last green
parity-main (fdc87aecd, 2026-11-07) and fail now whatever a pull request
changes. Each has an issue naming the merge, or the range, that broke it.

Part of #15316.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-11 16:59:55 +01:00
Nikita Maximilian GrimmandClaude Fable 5.2 e10f3459c8 nightly audit: the new issue's wording covers a harness an earlier audit left out
Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-11 16:59:11 +01:00
Nikita Maximilian GrimmandClaude Fable 5.2 2b43458b4a parity-main: the search's budget starts after HEAD and the base ran; a comment that failed is tried again
Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-11 16:59:10 +01:00
rdbtCVSandCodex GPT-6.1 Sol 9d97041ffb Follow the guest movement record in the existing Pong test
Compatibility fix for main pull request #14005: the record moved out of anticheat onto Guest. Preserve the existing tick_end assertion.

Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-11 15:53:37 +00:00
Nikita Maximilian GrimmandClaude Fable 5.2 05e964cd8f docs: wrap the audit line
Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-11 16:51:19 +01:00
Nikita Maximilian GrimmandClaude Fable 5.2 5a56eb3ae1 nightly audit: a harness that fails in its first audit gets an issue too
An issue was opened only for a harness that passed in the previous audit,
so one merged while its pull request's parity job failed or was skipped
never got one: nine such harnesses failed on main unreported (#15158).

Part of #15316.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-11 16:51:13 +01:00
Nikita Maximilian GrimmandClaude Fable 5.2 38ce5cfa4e parity-main: the bisect stops on a time budget and the next run goes on from there
Seven runs between 2026-11-09 and 11-11 searched up to 1,500 merges until
the job's 240 minutes ended, named no merge, and the next run started again
from nothing. The search now has --budget minutes (90), then says between
which merges each harness broke. What it learnt is kept in
.local/parity-runner/bisect.json while the base is the same, so the next
run narrows it further, and a merge already named gets no second comment.

Part of #15316.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-11 16:50:21 +01:00
rdbt 2be5079ae1 Merge pull request #14237 from itarqos5/test/vanilla-redstone-rules
Tests: vanilla's piston, tripwire hook and observer rules, each pinned (8 tests)
2026-11-11 15:48:35 +00:00
rdbt 1d5957fb9d Merge pull request #14222 from NikitaMGrimm/parity-process-tree
Harnesses take what they started with them: a dead harness or runner no longer leaves games running
2026-11-11 15:47:56 +00:00
rdbt 9c03f504a2 Merge pull request #14375 from DylanGonzaloFerreyra/split-play-advancements
Move the advancement packets out of play.rs into play/advancements.rs
2026-11-11 15:47:36 +00:00
Nikita Maximilian GrimmandClaude Fable 5.2 fc78a29fee parity: eight harnesses that never passed run by name only
They merged while their pull request's parity job failed or was skipped and
fail on main whatever a change touches (#15158), so every pull request that
selects them fails and parity-main cannot pass. They still run when named and
in the nightly audit, and come off the list with their fix.

Part of #15316.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-11 16:47:23 +01:00
rdbtCVSandCodex GPT-6.1 Sol d05a66c6f2 Merge current main before command fixture validation
Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-11 15:46:51 +00:00
rdbtCVSandCodex GPT-6.1 Sol 461c878b6a Clarify the Rust cooldown field reference
Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-11 15:46:51 +00:00
rdbt 216aca09f6 Merge pull request #15182 from masa-masa-0314/refactor/friends-social-model
Friends: separate existing account-social models and parsing
2026-11-11 15:46:25 +00:00
rdbt ff11e449d3 Merge pull request #15147 from MSedgeMC/fix/wheel-sensitivity-log
The mouse wheel sensitivity slider is logarithmic
2026-11-11 15:46:20 +00:00
rdbtCVSandCodex GPT-6.1 Sol 31c5e22e7f Merge current main into parity fix #15291
Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-11 15:45:17 +00:00
rdbt 687f3f6d3f Merge pull request #14397 from MSedgeMC/fix/port-focus
The LAN port box is focusable while LAN is off
2026-11-11 15:45:16 +00:00
rdbt 6e5784e4fa Merge pull request #14464 from Solenopsisbot/t3/fix-saved-present-mode
Apply saved present mode before first gameplay frame
2026-11-11 15:44:39 +00:00
rdbtCVSandCodex GPT-6.1 Sol e653778213 Merge current main into parity fix #15250
Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-11 15:44:35 +00:00
rdbtCVS 82134c2755 Merge main and preserve both command comparison test suites
Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>

# Conflicts:
#	tests/test_command_exec_comparison.py
2026-11-11 15:44:10 +00:00
rdbtCVSandCodex GPT-6.1 Sol cd4e6696fa Merge current main before attacker reference validation
Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-11 15:43:42 +00:00
rdbtCVSandCodex GPT-6.1 Sol 06560cdd7d Keep deferred-hit timer guidance beside its ordering helper
Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-11 15:43:16 +00:00
rdbtCVSandCodex GPT-6.1 Sol 1420b5bd29 Merge current main before hoglin regression validation
Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-11 15:37:42 +00:00
rdbtCVSandCodex GPT-6.1 Sol cef4066d7e Keep the allay pickup predicate in its feature module
Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-11 15:36:55 +00:00
Kevin RamirezandClaude Opus 5.5 d9eab08f7f A driven horse takes no fall damage on its rider's client
Entity.hurt only calls hurtServer on a server level. The rider's client
ticks the horse it drives with the server's mob code, so a fall, lava or
suffocation hurt and killed the client's copy: it lay dead on the ground
while the server's horse lived, and could be ridden again (#15294).

Fixes #15294

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 09:34:53 -06:00
rdbt 77e510c3ab Merge pull request #14005 from horizon-rust/layers/movement-record
Movement prediction: the client-tick record moves into the engine (layer plan M6)
2026-11-11 15:34:21 +00:00
YourPancakes-dev d404575740 test(render): use the worker-count fix from main
Drop the duplicate test change now that upstream handles hybrid Linux CPUs.
2026-11-11 18:33:13 +03:00
AkirabaneandClaude Sonnet 5.5 c6f61bbcc4 port_map: the goat and fox spawn rows of game-012.tsv name the live spawn path
Goat.finalizeSpawn, GoatAi.initMemories and Fox.populateDefaultEquipmentSlots
are reached by live spawns; Fox.finalizeSpawn keeps the one gap that is left.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 16:29:41 +01:00
rdbtCVSandCodex GPT-6.1 Sol 20cd32f3a4 Gate allay item pickup on cooldown memory presence
Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-11 15:29:38 +00:00
rdbtCVSandCodex GPT-6.1 Sol f0fb237cc2 Merge current main before validating allay cooldowns
Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-11 15:29:22 +00:00
rdbtandCodex GPT-6.1 Sol eebd726751 Merge pull request #14085 from horizon-rust/fix/invulnerable-time-attribute-order
Canonicalize root entity attribute-list order while preserving all values, styles and other record fields.

Validation: 9 focused comparison tests and 105/105 strict vanilla parity records pass; current-head required verify is green.

Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-11 15:28:36 +00:00
rdbt 3acf30529b Merge pull request #15079 from angel2rider/docs/prerequisites
docs: the prerequisites say Python 3.11+ and git 2.38+
2026-11-11 15:28:23 +00:00
rdbt 197fa1e294 Merge pull request #14683 from NikitaMGrimm/hdr-pass-rules-blend
docs: HDR pass rules, tone-weighted blending is for linear light and sharpening eases off above white
2026-11-11 15:28:18 +00:00
rdbtandCodex GPT-6.1 Sol cd0b263747 Merge pull request #15092 from MSedgeMC/fix/worker-count-hybrid-test
Correct the automatic-worker assertion for machines with fewer performance cores.

Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-11 15:26:31 +00:00
rdbtCVSandCodex GPT-6.1 Sol 1fce5ccdbf Shrink the new sulfur size fixture before its cleanup
Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-11 15:24:48 +00:00
rdbtCVSandCodex GPT-6.1 Sol 244b166781 Merge main into command fixture isolation
Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-11 15:24:32 +00:00
rdbtCVSandCodex GPT-6.1 Sol f2b247edda Preserve attacker field ordering and wrapping timestamps
Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-11 15:23:09 +00:00
rdbtCVSandCodex GPT-6.1 Sol 7baaa34948 Count down deferred hurt timers only after a reset
Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-11 15:22:20 +00:00
rdbtCVSandCodex GPT-6.1 Sol 517889c893 Merge main into attacker reference loading fix
Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-11 15:22:20 +00:00
rdbtCVSandCodex GPT-6.1 Sol 95fe2c81f2 Merge main into hoglin attack fix
Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-11 15:21:30 +00:00
rdbtCVSandCodex GPT-6.1 Sol 6d783a4116 Cover full integer values in saved allay cooldowns
Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-11 15:21:02 +00:00
rdbtCVS 9f32ef4233 Merge main into allay cooldown fix
Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>

# Conflicts:
#	crates/horizon-client/src/mobs/allay.rs
2026-11-11 15:20:29 +00:00
AkirabaneandClaude Sonnet 5.5 a77b71a4ed mobs: remove Seen.player_rep, which nothing reads
Mob::seen wrote a villager's reputation of the player into every Seen it
built, and no code reads the field (git grep finds only its definition and
that write; the trading code reads the gossip itself). Remove the field and
the lookup. The `player` parameter of Mob::seen is now unused and is named
_player: it has many callers, and a later change can drop it from them.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 16:12:02 +01:00
AkirabaneandClaude Sonnet 5.5 67063cfdcc port_map: the two death-message rows of game-003.tsv no longer list gaps
CombatTracker.getMessageForAssistedFall and DamageSource.getLocalizedDeathMessage
are ported with their .item forms; the rows now say what the code does.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 16:03:27 +01:00
AkirabaneandClaude Sonnet 5.5 9d1b4c1b34 port_map: three rows of game2-004.tsv no longer list gaps
Piglin.holdInOffHand, PiglinAi.holdInOffhand and Drowned.rangedAttackUncertainty
are ported as vanilla's bytecode reads; the rows now say what the code does.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 16:02:34 +01:00
KBroneZandClaude Opus 5.5 48f231af5d test: pin that random-tick chunks come out in position order
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 17:02:18 +02:00
AkirabaneandClaude Sonnet 5.5 60c4d1d4f4 port_map: three rows of game2-001.tsv no longer list gaps
Wolf.getLeashOffset, ArmorStand.brokenByAnything and HangingEntity.canCoexist
are ported as vanilla's bytecode reads; the rows now say what the code does.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 16:00:58 +01:00
rdbtCVSandCodex gpt-6.1-sol 70865d77e8 Fix deeply fallen boat loading and dedicated-server void crashes
Match vanilla saved boat bounds and void removal, and make chunk and collision coordinate arithmetic safe at integer boundaries.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-11 14:58:17 +00:00
Riccardo Guerriero d526f569a0 Merge origin/main into fix/beacon-payment-order-tooltips 2026-11-11 15:57:04 +01:00
Riccardo Guerriero 957ba89eac Merge origin/main into fix/item-void-floor 2026-11-11 15:57:04 +01:00
Riccardo Guerriero cf6ff9c340 Merge origin/main into fix/goat-ram-feeding-shield 2026-11-11 15:57:04 +01:00
yahya-nusseibehandClaude Opus 5.5 83a152c98a Keep this change apart from the other open fixes' lines
Moves a new test (or a doc comment) so the branch merges cleanly with the other open pull
requests that touch the same file, in any order.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 16:46:08 +02:00
bct8925andClaude Opus 5.5 a09d036f50 Adreno on Vulkan: pack the shaders' indexed tables, and test occlusion in the shader
Qualcomm's Adreno Vulkan driver (Adreno 740, build 0676.41 of 2023-08-14) fails to link a
shader that indexes a constant array by vertex or pixel, which naga makes a private array, and
wgpu reports the failed pipeline as a lost device: the renderer never starts. The occlusion
test's box corners (cull.wgsl, gpu_occlusion.wgsl), the panorama cube (panorama.wgsl) and the
fading tiles' Bayer matrix (terrain.wgsl) are packed into u32 words read through a switch
instead, with tests that the packed tables are the old ones.

With the early depth test, occlusion culling on that driver now and then left sections out for
a frame (the world flashing black or see-through as a block broke); Adreno on Vulkan tests the
boxes in the shader, as Metal and DirectX 12 do.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 09:39:27 -05:00
levzzz f23176af0f Merge pull request #15285 from levzzz5154/policy/read-restricted-source
Allow reading source under restrictive licenses
2026-11-11 19:36:18 +05:00
DUzzL c4146a25af Count the broken statistic for every equipment break
ServerPlayer.onEquippedItemBroken awards Stats.ITEM_BROKEN whatever the
slot. Award it in player_equipment_broke instead of at two call sites, so
worn armour, shields and enchantment-driven breaks count too.
2026-11-11 15:33:49 +01:00
yahya-nusseibehandClaude Opus 5.5 482c381f6b Client: private Realms temp files, a bounded pax record, and no leaked boot-screen font
- Realms downloads and uploads used predictable names in the shared temp folder and
  File::create, which follows a planted link or reuses a planted file. They now use an
  unguessable name and a new owner-only file (create_new, 0600).
- A pax record whose length was shorter than its own prefix ("1 x") sliced backwards and
  panicked the Realms download worker; it is now refused.
- The Windows boot-screen painter restored its fonts in the wrong order, leaving one selected so
  DeleteObject failed: one HFONT leaked per repaint. It now restores the DC's own font.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 16:31:51 +02:00
KBroneZandClaude Opus 5.5 909d7a2134 fix: a missing inline feature field names the whole map, as vanilla does
In 26.3 Feature.DIRECT_CODEC dispatches on `type` straight to the type's
own map codec: there is no `config` wrapper, so `config` is just another
unread key and a missing field's DFU error names the whole map. The
command codec still fell back to a `config` sub-map, so
`{type:"minecraft:simple_block",config:{}}` reported `MapLike[{}]`. The
inline_feature_parse parity harness (#15158) now matches 84/84 records.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 16:27:39 +02:00
GIGABAIT93andClaude Opus 5.5 95ff1c5769 CI vanilla data: horizon-prepare's lock file is no prepared output
vanilla-data.sh moves whatever a preparation adds to .local/ into
shared-local/prepared/<key> and links it back. Since the preparer locks
.local/ (.horizon-prepare.lock), that took the lock file too: .local/'s
lock became a link into one preparation. Once that preparation is
pruned (four kept, two runners share them) the link dangles, the
`.local/*` glob that drops old links skips dotfiles, and the next
preparation fails: ".local/.horizon-prepare.lock: No such file or
directory".

The lock now stays a plain file: it is left out of the entries, a link
to it is removed, and old .entries that list it are skipped.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 16:25:46 +02:00
levzzzandOpenAI GPT-6.1 7ca851a78d Allow reading source under restrictive licenses
Co-Authored-By: OpenAI GPT-6.1 <[email protected]>
2026-11-11 19:24:37 +05:00
masa-masa-0314andZCode 916764cc31 fix(realms): route reset tile mouse clicks through existing actions
Forward coordinate-hit Reset World tiles to actions 70-75 so Generate New
World reaches the existing CreateWorldRequest flow. Keep active tasks from
accepting either coordinate clicks or direct tile actions.

Follow Minecraft 26.3 RealmsResetWorldScreen.init/FrameButton and
RealmsCreateWorldFlow.createWorld. Extend the existing regression with
actual mouse coordinates, bounds, creation metadata and running-task gates.

Fixes #15273

Co-Authored-By: ZCode (new-provider/gpt-6.1-sol) <[email protected]>
2026-11-11 22:15:46 +08:00
yahya-nusseibehandClaude Opus 5.5 c59deaa19b Port-pack: only a plain NeoForge version makes the loader jar's path
A modpack's NeoForge version (modrinth.index.json dependencies.neoforge, CurseForge, modlist)
went unchecked into loader_jar's file name and URL. A version such as
"21.1.1/neoforge-21.1.1-universal.jar#/../../../../<dir>/x" made fetch create every directory
on that path and rename a real NeoForge jar over <dir>/x-universal.jar, with no checksum
(the .sha1 lookup got the jar and failed).

loader_jar now takes only [A-Za-z0-9._+-]{1,64} without "..", and the loader jar is downloaded
only with a 40-hex SHA-1 from Maven.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 16:06:13 +02:00
yahya-nusseibehandClaude Opus 5.5 f46f2dbc98 Rate-limit refused quiet commands without a new LanHost field
game/lan.rs is over 3000 lines and may not grow (docs/layers.md#file-size): a refused payload is
now logged at most once a second, kept in lan_quiet.rs, instead of once per connection through a
LanHost field.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 16:06:08 +02:00
yahya-nusseibehandClaude Opus 5.5 b62be5565d A router read that times out at the deadline is "too slow" on every platform
On Linux a socket read timeout is reported as WouldBlock, so a read cut short by the last part of
the deadline came back as that error instead of TimedOut (seen in CI's
a_router_trickling_its_answer_runs_out_of_time). Both kinds now end the request as TimedOut.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 16:06:03 +02:00
AkirabaneandClaude Sonnet 5.5 fb6b8c54e2 A snow golem strolls with the probability vanilla gives it
SnowGolem.registerGoals builds its WaterAvoidingRandomStrollGoal with the
three-argument constructor and 1.0000001E-5F, not the default 0.001 that
Stroll::avoiding_water uses.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 15:00:03 +01:00
AkirabaneandClaude Sonnet 5.5 16e97dca16 Motion: a flying client player keeps sliding down a ladder when it sneaks
LocalPlayer.isSuppressingSlidingDownLadder is !abilities.flying &&
super.isSuppressingSlidingDownLadder(), with LivingEntity's being
isShiftKeyDown() and ServerPlayer not overriding it. Horizon answered the
sneak for every body. A client body (client_side) that is flying no longer
suppresses the slide; a server body still looks at the sneak alone.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 14:52:33 +01:00
AkirabaneandClaude Sonnet 5.5 d1a243289c LAN: a second accept of the same teleport id disconnects
ServerGamePacketListenerImpl.handleAcceptTeleportPacket: an accept of the last
teleport id sent with no teleport awaited is invalid player movement. The
handler moves into hub/accept_teleport.rs so hub.rs does not grow.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 14:47:49 +01:00
982c71f54d Spiders spawned at runtime get their Hard-difficulty effect
Spider.finalizeSpawn (Spider.java:160-172) rolls
nextFloat() < 0.1F * getSpecialMultiplier() on HARD, then nextInt(5) for
Speed, Strength, Regeneration or Invisibility, and applies it for good
(MobEffectInstance(effect, -1)), for every spawn reason. Only the chunk
generation port did that; the runtime finalizer (summon, spawn eggs,
spawners, natural spawning) had no spider arm. Add one, share the
setRandomEffect mapping (Spider.java:207-217) between both paths, and pin
the odds, duration, effect set and the CaveSpider exception with a test.

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 14:47:10 +01:00
DUzzL eb18b83652 Swallow SC_KEYMENU so Alt+Space does not open the Windows system menu 2026-11-11 14:46:32 +01:00
AkirabaneandClaude Sonnet 5.5 268553de31 Fall location: a trapdoor counts as a ladder, as vanilla does
FallLocation.blockToFallLocation returns LADDER for Blocks.LADDER or a
block in #minecraft:trapdoors; Horizon mapped only the ladder by name, so a
fall off a climbed trapdoor said other_climbable. Move the name match into
fall_location_of and give it whether the block is in the trapdoors tag.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 14:41:26 +01:00
AkirabaneandClaude Opus 5.5 c4889805f3 Create New World: name the folder as findResultFolder does
WorldCreationUiState.findResultFolder trims the name (String.trim), takes the translated
selectWorld.newWorld when nothing is left, and asks FileUtil.findAvailableName for a free folder:
sanitizeName turns only SharedConstants.ILLEGAL_FILE_CHARACTERS and . / " into _, a name Windows
reserves is wrapped in underscores, a name ending in " (n)" counts on from n, the name is cut to
255 characters with its count, and a count that can't be parsed falls back to "World".

Horizon turned every character but letters, digits, space, - and _ into _ (Bob's world! became
Bob_s world_), named a blank world's folder World, and counted every taken name up from 1. The
rules move out of title.rs, a big file, into title/world_folder.rs with their test.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 14:40:07 +01:00
AkirabaneandClaude Sonnet 5.5 af65ddae12 LAN: only the pending keep-alive answer counts
ServerCommonPacketListenerImpl.handleKeepAlive accepts an answer whose id is
the pending challenge; any other answer disconnects the player with
disconnect.timeout unless they are the singleplayer owner.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 14:38:56 +01:00
rdbtCVSandGPT 6.1 Sol 8f27aaeb01 test: retain summoned zombie villager profession domain coverage
Replace only the two unseeded equality probes with deterministic built-in profession-domain checks. Keep saved VillagerData and all finalized reads exact, with regression tests for the corpus and strict feedback/callback comparison.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-11 13:35:33 +00:00
rdbtCVSandGPT 6.1 Sol 3e75c0f077 Constrain attribute canonicalization to root entity data
Keep quoted text, nested custom lists, modifier order and every styled
separator exact. Cover values, duplicate entries, modifier IDs and actual
state differences with regression tests; preserve the contributor commit.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-11 13:33:38 +00:00
rdbtCVSandCodex gpt-6.1-sol 23d4652362 Fix gaps and stalled retries in saved far-chunk sending
Send lit resident far columns with complete light layers, scan past blocked entries within the existing budget, and retain missing chunks for retry without player movement.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-11 13:33:14 +00:00
AkirabaneandClaude Sonnet 5.5 765ff1ed80 LAN: a login with the owner's name is refused while the owner is online
IntegratedPlayerList.canPlayerLogin returns multiplayer.disconnect.name_taken
when the joining name is the singleplayer owner's (ignoring case) and a player
of that name is online.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 14:31:35 +01:00
AkirabaneandClaude Sonnet 5.5 a64e9c8037 Identifier: refuse the namespace ".." as vanilla does
Identifier.isValidNamespace returns false for the namespace "..", and
otherwise accepts a-z, 0-9, '_', '-' and '.'. from_parts only checked the
characters, so Identifier::parse("..:x") succeeded.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 14:28:47 +01:00
AkirabaneandClaude Opus 5.5 6b2d1cd585 Field of view: divide by the walking speed of the server's abilities packet
ClientPacketListener.handlePlayerAbilities sets the local player's walking speed from the packet
(Abilities.setWalkingSpeed), and AbstractClientPlayer.getFieldOfViewModifier divides the movement
speed by it. Horizon kept the packet but always divided by 0.1, so a server that sends another
walking speed got a different field of view (walking speed 0.2 with a movement speed of 0.2: 1.5
instead of 1.0).

The abilities code moves out of game/remote.rs, a big file, into game/remote_abilities.rs, with the
walking speed the field of view now reads (default 0.1 before any packet) and its test.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 14:28:04 +01:00
AkirabaneandClaude Sonnet 5.5 b50a91cb67 /item modify: a list operation that cannot be done leaves the list unchanged
ListOperation.Append, Insert and ReplaceSection give the old list back when
the result would pass the limit (256 for lore) or the offset is past the end
of the list. The command cut every result to 256 entries and moved an offset
past the end to the end instead.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 14:27:24 +01:00
rdbtCVSandCodex GPT-6.1 57e1e16ca2 Reduce packet compression level and measure the tradeoff
Co-Authored-By: Codex GPT-6.1 <[email protected]>
2026-11-11 13:23:59 +00:00
rdbtCVSandGPT 6.1 Sol b52d83f71e fix(ui): wrap and anchor focused jigsaw priority tooltips
Match Tooltip.splitTooltip, WidgetTooltipHolder and BelowOrAboveWidgetTooltipPositioner. Keep real hover distinct from the hidden keyboard/controller pointer and test priority label/value/packet mapping.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-11 13:23:09 +00:00
levzzz 1bec6d447a Merge pull request #15211 from horizon-rust/docs/owners-council
Docs: two org owners (P28), council code owners and security managers
2026-11-11 18:23:06 +05:00
b23dd07fa9 Magma cubes take the damage their size's armor absorbs
MagmaCube.setSize (MagmaCube.java:60-66) writes size * 3 into
Attributes.ARMOR; LivingEntity.getArmorValue (LivingEntity.java:1883-1885)
reads that base back. Mob armor now reads the set attribute base instead of
only the type's armor default, so a summoned size-4 cube keeps its 12 armor.

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 14:22:11 +01:00
AkirabaneandClaude Sonnet 5.5 d08c04020c RCON: cut a command's output at 4096 UTF-16 units, as vanilla does
RconClient.sendCmdResponse cuts the Java string with substring at 4096
UTF-16 units, so a character outside the BMP can straddle a cut, and
String.getBytes(UTF_8) writes each lone half as '?'. Horizon counted
characters instead. Split the output on UTF-16 units and write a lone
surrogate half as '?'.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 14:20:49 +01:00
KBroneZandClaude Opus 5.5 d83b716d13 fix: print an Interaction's width before its interaction record
`width` and `interaction` share a HashMap bucket at every table size, and
keys sharing a bucket iterate in insertion order. java_hash_order ranked
neither, so they fell back to key order and `interaction` came first,
while Interaction.addAdditionalSaveData writes width, height, attack,
interaction and response in that order. The interaction_data parity
harness (#15158) now matches 120/120 records.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 15:16:58 +02:00
DUzzL 7aef48e484 Tick restored wind-charge fire and sync HasVisualFire 2026-11-11 14:14:52 +01:00
rdbtCVSandGPT 6.1 Sol 72c066552e Merge main into hoglin feeding fix, preserving the extracted predicate
Keep main's takes_food extraction and canAgeUp baby check, and apply the contributor's pacified-hoglin breeding gate in ai/feeding.rs. Normalize two test citations to the vanilla method names.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-11 13:09:38 +00:00
rdbtCVSandGPT 6.1 Sol dc35fac460 Fix weapon component formatting and extend regression coverage
Cover prototype inheritance, zero-default replacement, custom weapons, empty stacks, and persisted component removal without changing the contributor's gameplay fix.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-11 13:05:59 +00:00
yahya-nusseibehandClaude Opus 5.5 3f254035ec Port-pack: licence text stays one comment line, and families match whole words
- A jar's licence text (and file name) went into a `#` comment of the carried mod's
  horizon-mod.toml as it was: a line break ended the comment and wrote keys of its own. Control
  characters are now replaced with spaces.
- license_kind matched families by substring, so "limited" read as MIT (permissive), "template"
  as MPL (copyleft) and "Discord" as ISC. It now matches whole words of the text.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 15:01:06 +02:00
yahya-nusseibehandClaude Opus 5.5 8ded4de3fe Telemetry: refuse an impossible official player count
The official server's players.online (any u64 from a plain status ping) went into the history
as i64: above i64::MAX it wrapped negative, and near it SUM() overflowed, failing
/v1/live/history?source=official until the rows were pruned. A count above a million is now
treated as no answer: that minute stays empty.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 14:59:39 +02:00
yahya-nusseibehandClaude Opus 5.5 db69868ebb Friends and relay: bounded handshakes, endpoints, STUN and UPnP input
- Host: the inbound handshake slot is an RAII guard, so a failed spawn can't leak one of
  MAX_HANDSHAKES until the direct port takes nothing.
- Sessions authenticate at most 16 direct-race streams, each under one 6 s deadline instead of a
  per-read timeout a peer could drip past.
- Invite decode takes at most MAX_ENDPOINTS (8), as encode writes: a crafted code can't make the
  guest dial thousands of addresses.
- A STUN attempt has one deadline, so datagrams arriving faster than the read timeout can't keep
  the query, and the host's setup, going forever.
- A UPnP description's controlURL, URLBase or serviceType with control characters or spaces is
  ignored instead of reaching a request line or header.
- The guest's direct-race listener handles each inbound stream on its own thread with a deadline.
- Relay: the connection count is given back by a guard, and attach tokens compare in constant time.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 14:45:28 +02:00
yahya-nusseibehandClaude Opus 5.5 adb97c68d7 Server: list edits report a failed save, server.properties is written atomically, plain params
- UserList add/remove only log a failed save, so with a read-only or full disk the CLI and the
  stopped server's API said "Banned X" or "Made X a server operator" though the file still said
  otherwise. Each change is now saved and checked (saved()).
- ServerProperties::store wrote server.properties in place, default mode: a crash at start left a
  short file read as defaults (a new management secret, settings dropped), readable by other
  accounts. It now writes a new file (0600 when new, an existing file's mode kept) renamed over.
- system_message/kick translatable parameters are plain text, as vanilla's Message takes them,
  not components tellraw would resolve with the console's rights.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 14:42:44 +02:00
AkirabaneandClaude Sonnet 5.5 f7224c29ee Finding the gateway on Windows opens no console window
`portmap::default_gateway` runs `route print` on Windows. A program with no
console of its own, such as the launcher, got a console window for it each
time. The command now starts with CREATE_NO_WINDOW, as the other crates'
commands do.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 13:42:12 +01:00
yahya-nusseibehandClaude Opus 5.5 978f37d092 Server: backup and settings edge cases
- Backup zips (which hold server.toml and server.properties with the API token, RCON password
  and management secret) are created owner-only.
- Pruning automatic backups deletes only `<stamp>-auto.zip`, not a backup labelled by hand
  "nightly-auto".
- backups.every no longer panics on a wide last character ("6é") or overflows on a huge number.
- A Discord rate-limit header of "NaN" no longer ends the Discord thread.
- world.name may not be ".", nor backups, logs or .horizon, which pointed the world at the server
  folder or the supervisor's own folders.
- init stops instead of replacing a whitelist.json or ops.json it could not read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 14:39:48 +02:00
rdbtCVSandGPT 6.1 Sol 5644d16a61 Fix held-stack break handling when processed wear is zero
Follow ItemStack.hurtAndBreak and keep custom-durable stacks at their damage limit intact when effective wear is zero. Extend the survival mining regression and disambiguate a Rust field from a vanilla ledger citation.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-11 12:38:30 +00:00
yahya-nusseibehandClaude Opus 5.5 e0d880754e Server: per-address RCON places, write timeouts for event streams, digest token compare
- One address could hold all 16 RCON places idle and keep the operator's own client out; each
  address now gets at most 4.
- Management notifications went to every client from one thread with blocking writes: a client
  that stopped reading stalled them for everyone. Management sockets get a 10 s write timeout
  and a client whose write fails is closed.
- The event stream (SSE) gets a write timeout too, so a client that stops reading doesn't pin
  its thread forever.
- same_token compares SHA-256 digests, so its time doesn't show the token's length.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 14:36:44 +02:00
Horizon agentandClaude Opus 5.5 e415db6e71 Docs: two org owners (P28), council code owners (P35) and security managers (P36)
The council's polls are applied: rdbtCVS is the second organisation owner,
the council team holds CODEOWNERS and the security-manager role. The pages
that said Ludovic alone held these powers now say who does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 13:35:38 +01:00
KBroneZandClaude Opus 5.5 87d97ec860 test: keep the random tick order test beside block_ticks (game.rs may not grow)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 14:27:42 +02:00
yahya-nusseibehandClaude Opus 5.5 d3da93ba30 CI: pin the third-party Rust actions to reviewed commits
dtolnay/rust-toolchain@stable (a branch) and Swatinem/rust-cache@v2 (a movable tag) run inside
the jobs that build the archives players download, before the upload Nightly signs: whoever
could move those refs could change released binaries. Both are pinned to full commit SHAs
(rust-toolchain's stable branch head and rust-cache v2.9.3), with toolchain: stable passed
explicitly since rust-toolchain reads the toolchain from its ref otherwise.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 14:22:40 +02:00
yahya-nusseibehandClaude Opus 5.5 545b8f9e8d Client: hold the servers.dat lock on every save, each process with its own temp file
servers::lock() keeps a ping storing an icon and a save of the list from undoing each other,
but adding or editing an entry on the screen, and the gameplay saves of a server's resource
pack choice and Code of Conduct, loaded and saved without it: a favicon saved meanwhile could
drop the edit, or the edit the icon. Every writer also used the same servers.dat_tmp, so
overlapping writes could rename an interleaved file into place, which then loads as an empty
list and is saved as one.

Those three now hold the lock (the screen releases it before reloading the list), and the
temporary file is named per process.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 14:19:49 +02:00
KBroneZandClaude Opus 5.5 7117330d80 fix: random tick chunks in position order, not chunk load order
tick_blocks built the random-tick list from the world map's keys, an
FxHashMap filled as the streaming workers finish, so two runs of one world
random-ticked their chunks in different orders and the level random's draws
landed on different blocks. Game::random_tick_chunks now sorts the list by
chunk position. Vanilla walks its simulation tracker's fastutil table, whose
slot order follows that table's history; position order is deterministic.

Fixes #15164

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 14:19:06 +02:00
rdbtCVSandGPT 6.1 Sol 906b9143f3 Merge main into layers/chat-guard-hook
Resolve startup registration and layer-exception conflicts while preserving both branches.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-11 12:17:22 +00:00
AkirabaneandClaude Opus 5.5 77303a4d58 anticheat_e2e: prediction-water plays kelp, a waterfall, a changing current and waterlogged blocks
#5879 lists these as the unknown blocks around the water flags of the official server. water_play now
builds each one beside the player (the server only fills loaded chunks), checks through the client that
the blocks are there as built, and plays in them: sprint-swimming down and up a kelp forest and holding
jump at its surface; jump held at a waterfall's foot, walking into it across the water it spreads,
sinking down it from the top, and standing at its foot while it stops and starts again; standing and
walking in a channel whose source goes and comes back; standing on waterlogged slabs, walking into
waterlogged fences and up waterlogged stairs.

The check's client renders offscreen when HORIZON_E2E_HEADLESS is set, as agent_control_e2e.py does,
so a run on a desktop opens no window that takes the focus.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 13:17:06 +01:00
yahya-nusseibehandClaude Opus 5.5 7da7209026 Server: players can't forge the game's log lines or flood the log
- The supervisor matched "Stopping server" and "Done (" anywhere in a stdout line, so chat
  saying "/INFO]: Stopping server" told management clients the server was stopping and hid the
  real notice. It now looks at the message after the first "/INFO]: " only.
- A quiet-commands payload's mod id went unescaped into the dedicated server's stdout and log:
  a newline in it forged whole lines. decode now refuses control characters, DEL and the
  section sign in the mod id and every line, as a chat command must pass.
- An empty batch (3 bytes) was queued and logged as a summary line, before any permission check,
  hundreds of times a tick; it is now ignored, a batch with no room is counted on the player's
  last waiting one, and a refused payload is logged once per connection.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 14:14:04 +02:00
SenjinandClaude Opus 5.5 57ffe914c0 Cracks keep their size with Improved Transparency and a pack's own destroy stages
With Improved Transparency the cracks draw vanilla's destroy_oit_stage_* sprites, but the
crumbling shaders took their size from destroy_stage_0. A resource pack that replaces only the
plain stages at a higher resolution made the crack window larger than its sprite, so it showed
neighbouring atlas sprites (#11164). crumbling_sprite now reads the set the cracks draw.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 13:03:54 +01:00
yahya-nusseibehandClaude Opus 5.5 c72e912d73 Port-pack: report odd worldgen JSON instead of panicking, and end a panicking modpack step
serde_json's index panics on a Map missing the key and on a non-object Value: a density
function "spline" without its "spline" field, a template pool whose elements aren't objects, or
a structure whose horizon:piece_limited isn't an object panicked the port. In the launcher that
killed pack_job's thread before PackDone, so the Modpacks page stayed busy until a restart.

Those reads now use get/get_mut and skip what isn't there, and pack_job turns a panic in a step
into an error.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 13:55:10 +02:00
rdbtCVSandGPT 6.1 Sol 4cb7f5a348 Merge main into vehicle-below-world, preserving the moved cart lookup
Keep main's minecart index/display extraction and expose cart_at to the below-world discard helper. Vehicle discard behavior is unchanged.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-11 11:53:09 +00:00
yahya-nusseibehandClaude Opus 5.5 9a384a124b Friends: bound router answers, and keep a bad one from crashing or freezing the game
- dechunk sliced the device's chunk size into a from_utf8_lossy string: a chunk ending inside a
  character panicked the sharing thread, under the reach lock on renewal, and the poisoned lock
  then crashed the game at Host::stop. It now stops at such a chunk, and stop() and the renewal
  loop take the lock even when poisoned.
- A router's HTTP answer has one 10 s deadline (each read was limited, not the answer), SSDP
  discovery tries at most 8 gateways, and the mapping is renewed outside the reach lock, so
  stopping sharing no longer waits on a slow device.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 13:50:34 +02:00
yahya-nusseibehandClaude Opus 5.5 c2e172a8a3 Server: on Windows, wait for the clean stop when the console closes
For CTRL_CLOSE_EVENT, CTRL_LOGOFF_EVENT and CTRL_SHUTDOWN_EVENT, Windows ends the process as
soon as the console handler returns, so the supervisor died right after counting the request
and never stopped its game. The game runs in its own process group and gets no event: it kept
the world and the port, unsupervised, until it was killed without a save.

The handler now waits, within what Windows allows (4 s for a close, 15 s for a logoff or
shutdown), until the supervisor reports with signals::stopped() that its game has stopped.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 13:49:20 +02:00
yahya-nusseibehandClaude Opus 5.5 ccf20df11d Launcher: save accounts.json atomically and merge what a launch renewed
Play loads accounts.json, asks the services about each account (several requests, 20 s
timeouts), then saved that whole old copy without the lock: a sign-in finished meanwhile was
dropped (its secrets orphaned in the keychain) and a sign-out came back. save_to also wrote in
place, so a crash or full disk mid-write left a file read as no accounts.

save_to now writes a new file and renames it over the old one, and the launch paths save with
save_renewed: under the accounts' lock, the renewed names and skins go into accounts.json as it
is now, keeping accounts added or removed since.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 13:47:42 +02:00
Loïc PILLERandClaude Fable 5.2 bbdbf1a285 Realms: the availability check vanilla makes before use (#9432)
RealmsAvailability: GET mco/client/compatible, then GET mco/available for
a compatible client, once per account and shared by every caller. Nothing
consumes it yet: the Realms screen's gate and its refusal screens follow.

- realms/client.rs: client_compatible, has_parental_consent, and
  RealmsError::message (vanilla's errorMessage, split out of text()).
- realms/availability.rs: Availability, Pending, AvailabilityCache, get,
  current. Non-blocking; only an unexpected error is checked again.
- docs/realms-availability.md: how a screen consumes it (#14986).
- realms.rs: the module line, and a stale ResetKind comment made one line
  so the file does not grow.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-11 12:39:13 +01:00
yahya-nusseibehandClaude Opus 5.5 63f703ccb8 Server: a --system unit runs as the account that ran sudo, never root
`sudo horizon-server service install --system`, as the help says to run it, wrote `User=root`:
sudo resets USER to root, so the network-facing server (game port, RCON, management API, panel)
ran as root and left root-owned files in the operator's folder. The unit now runs as SUDO_USER,
else USER, and refuses to be written for root (from a root shell).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 13:36:49 +02:00
yahya-nusseibehandClaude Opus 5.5 06d1d236bb Server: write server.toml atomically under one lock, and keep the world if a restore fails
- server.toml was read, changed and written in place with no lock by management clients, the
  API, the CLI and sync_back: two overlapping writes dropped one change, and a crash after the
  truncate left an empty file, which reads as every default and was then written into
  server.properties. Writers in the process now hold one lock, and server.toml and
  server.properties are written to a temporary file renamed over the old one.
- A whole-world restore whose last rename failed returned with the world moved aside and no
  world in its place, and the game then made a new one. The old world is now moved back.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 13:34:33 +02:00
yahya-nusseibehandClaude Opus 5.5 c8370400c6 Supervisor: apply queued restores and updates before every start
- A restore or update asked for while the game was stopped (or while it was already
  stopping) was only consumed on Exit::Restart, so the game started on the current world and
  the request waited for some later, unrelated restart, and a stuck update kept every later
  one refused. Both are now applied at the head of the loop, before every start.
- An update timeout is clamped to a day (API, supervisor) and refused past it by the CLI:
  Instant + a huge Duration panicked the supervisor after it had switched releases.
- A repeated stop from the API or the management protocol no longer kills the game while it
  saves; only a second signal from a person does, as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 13:31:24 +02:00
DUzzL 9147b0893b Particles face the frame's camera, not the player's own yaw and pitch 2026-11-11 12:29:42 +01:00
yahya-nusseibehandClaude Opus 5.5 004628230c Friends: try every endpoint that answers before giving up on a direct join
join_direct gave the handshake only to the first endpoint that answered the hello, and returned
its error with `?`, so the invite's other endpoints and its relay were never tried. Invites
carry the host's LAN addresses on the default port, and on the guest's own network another
Horizon host at the same private address (a housemate's game) can answer first: a valid code
failed as "The code doesn't match".

Each answering endpoint now gets the handshake in turn until one takes the code. When none
does, the relay is tried as before; without a relay, the wrong code (else the first failure)
is reported.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 13:27:42 +02:00
rdbtCVSandCodex GPT-6.1 Sol 4d8fbac921 Keep non-default mob locomotion serial until acceleration is bounded
Initial velocity does not bound movement after MoveControl reads movement and flight attributes. Conservatively exclude non-default locomotion and movement effects, reject non-finite velocity, and cover a stationary fast cow crossing into a distant herd in production and verify.

Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-11 11:27:41 +00:00
YourPancakes-dev 4e43f7a14f test(render): honor the worker policy on hybrid Linux CPUs 2026-11-11 14:23:50 +03:00
TheGT 75cfb4cc28 Merge remote-tracking branch 'origin/main' into tools/fixtures-ask-for-work-dir 2026-11-11 11:23:39 +00:00
yahya-nusseibehandClaude Opus 5.5 289aba5aa5 Cap management and API connections, and give a request one deadline
The management server spawned a thread for every connection with no cap, and read the
upgrade request before authentication with only a 10 s timeout per read, so a byte every few
seconds held a connection forever, and a 1 MiB Content-Length was allocated up front. An
unauthenticated client could open thousands.

http::read_request now has one deadline for the whole request (REQUEST_DEADLINE, 10 s), grows
the body as it arrives, and both the management listener and http::serve hold at most
MAX_OPEN (64) connections at once through a Slot given back on drop; past it a new connection
is closed unanswered.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 13:22:14 +02:00
TheGT dd60b729ee Merge remote-tracking branch 'origin/main' into tools/port-ledger-json-after-query 2026-11-11 11:22:13 +00:00
MSedgeMC aa8769a5ac port_map: AbstractScrollArea.extractScrollbar's cursor requests 2026-11-11 11:17:57 +00:00
MSedgeMC 84c2f81616 test: the pack scrollbar asks for the hand, then the resize cursor
Over the bar the cursor is PointingHand; with the bar held it is ResizeNs.
2026-11-11 11:17:57 +00:00
yahya-nusseibehandClaude Opus 5.5 e559d499fb Relay: keep real codes joinable and registrations bounded per site
- The relay-wide unknown-code budget now refuses only joins whose code turns out unknown:
  a handful of /24s or /48s could keep it empty and every guest with a real code got
  RateLimited. Per-address and per-site budgets are still checked before the lookup.
- Behind the PROXY protocol, a connection from outside RELAY_PROXY_FROM is closed before
  anything is read, as the docs say, instead of holding a slot for first_line.
- Registrations get a per-site budget (IPv4 /24, IPv6 /48; 30 a minute, burst 10) on top of
  the per-/64 one, and ended nameplates expire oldest first from a queue instead of a scan of
  the whole map under the global lock on every registration.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 13:17:55 +02:00
MSedgeMC 90562aede1 fix: the pack list's scrollbar asks for its cursor
AbstractScrollArea.extractScrollbar requests POINTING_HAND over a scrollable
bar and RESIZE_NS while it is held. The pack screen drew its scroller without
asking, so the pointer stayed an arrow over both lists. The draw now requests
the hand or the resize cursor from the held state it already tracks.
2026-11-11 11:17:37 +00:00
MSedgeMC 651abefe2b port_map: WithInactiveMessage's grey label 2026-11-11 11:14:29 +00:00
MSedgeMC 8858d0c7e0 test: a disabled button's label is grey
The label's Draw::Text colour is 0xFFA0A0A0 when the button is inactive.
2026-11-11 11:14:29 +00:00
MSedgeMC df589fa039 test: a chunk without sections loads as an empty chunk
Without the change `ChunkData::new` answers
`Err(InvalidData, "chunk is missing sections")`.
2026-11-11 11:05:38 +00:00
MSedgeMC 2c8c04518d fix: a chunk without a sections list loads as a chunk with no sections
`SerializableChunkData.parse` reads the section list with
`getListOrEmpty("sections")`, so a chunk that does not carry one is a chunk with
no sections rather than an unreadable one. `ChunkData::new` refused it, so an
empty or hand-written chunk could not be opened; it now keeps an empty list in
the tag, which is what the writer would put there and what the accessors below
look for.
2026-11-11 11:05:37 +00:00
DUzzL 54ef9e095a Enderman: require the carried block to survive when placing it; play the stare sound from synced data 2026-11-11 12:02:27 +01:00
MSedgeMC f852a2d582 port_map: the drowning conversion has rows
`Zombie.isUnderWaterConverting` and the `ConversionTracker` it drives had no
row, nor did the two husk overrides that send the conversion to a zombie; the
behaviour is `undead.rs::custom_server_ai_step`, and the row cites it for
each method the jar declares there.

Names read from `javap -p` on 26.3: `Zombie.isUnderWaterConverting`,
`Zombie.convertsInWater`, `Zombie.convertsToWhenDrowning`,
`Zombie.getConversionSound`, `ConversionTracker.tick`, `Husk.convertsInWater`,
`Husk.convertsToWhenDrowning`, `Husk.getConversionSound` and
`ZombieVillager.convertsInWater` (false in the jar, so it is not part of the
arm).

`map-check`: 165 rows, no problems.
2026-11-11 10:44:44 +00:00
MSedgeMC 7f625eeb61 test: a chunk without a position loads at the origin
Without the change `ChunkData::new` answers
`Err(InvalidData, "chunk is missing xPos")` for the first case.
2026-11-11 10:44:35 +00:00
MSedgeMC bc01321fa4 fix: a chunk without xPos/zPos is the chunk at 0, 0
`SerializableChunkData.parse` builds the position with `getIntOr("xPos", 0)`
and `getIntOr("zPos", 0)`, so a chunk that does not carry either tag is the
chunk at 0, 0 and `Tag.asInt` accepts the narrower integer tags as well as
`Int`. `ChunkData::new` refused the whole chunk, so an edited or hand-written
chunk without them could not be opened at all.
2026-11-11 10:44:35 +00:00
MSedgeMC 226d535e77 fix: a disabled button's label is grey
AbstractWidget.WithInactiveMessage.getMessage returns
ComponentUtils.mergeStyles(message, Style.EMPTY.withColor(-6250336)) while the
widget is inactive, so a disabled button's message is drawn grey (0xFFA0A0A0).
screen_draw::button drew the same white label in both states; it now colours
the label when inactive.
2026-11-11 10:35:57 +00:00
YourPancakes-dev b0ba94929e test(net): cover malformed packet frame headers
Make the connection retry test independent of external addresses and add a reproducible release benchmark.
2026-11-11 13:35:52 +03:00
MSedgeMC 4c68279066 port_map: Font.PreparedTextBuilder.markBackground's glyph box 2026-11-11 10:35:27 +00:00
MSedgeMC 424af5fc17 test: the backdrop frames the glyphs by one pixel
The fill is (x - 1, y - 1) to (x + w, y + 9): one above and left, the advance
right, nine down.
2026-11-11 10:35:27 +00:00
TheGTandOpenCode 7d2ecff35c Shader packs: a buffer object can start with the bytes of a file
The documented `bufferObject.<n> = <size> <file>` form was refused:
the loader read the value as a size and nothing else, so a pack with
`bufferObject.2 = 16 data/seed.bin` lost its buffer with the note
"not a buffer object".

The loader now reads that form: the file's bytes come from the pack,
relative to `shaders`, and the buffer starts with them. A file that is
not there, that leaves the pack, or that is bigger than the buffer it
fills is refused with a note, as the other pack properties are.

Co-Authored-By: OpenCode <[email protected]>
2026-11-11 10:35:09 +00:00
MSedgeMC e4920df1c1 fix: a text backdrop frames the glyphs by one pixel
Font.PreparedTextBuilder.markBackground reaches one pixel left of the first
glyph and one above it, the glyph advance to the right and nine pixels down.
Horizon drew two pixels on every side (right and bottom included), so every
HUD title, action bar, boss bar and selected-item backdrop was a couple of
pixels too large. The fill now matches the glyph box.
2026-11-11 10:35:07 +00:00
MSedgeMC 47312851b0 test: a damaged or cut-off chunk reads as absent
Without the change the four length cases fail with `Err(InvalidData, "invalid
chunk payload length")` and the cut-off case with `Err(UnexpectedEof, "failed
to fill whole buffer")`.
2026-11-11 10:25:19 +00:00
MSedgeMC f48c714adc fix: a chunk the region file cannot deliver is absent, not an error
`RegionFile.getChunkDataInputStream` answers `null` whenever the file cannot
hand over the chunk: a header cut off by the end of the file ("Chunk header is
truncated"), a declared length of 0 ("allocated, but stream is missing"), a
length past the chunk's sectors ("stream is truncated") or a payload shorter
than its own header. Horizon returned an `Err` for all four, so a reader that
walks a region gave up on the whole file instead of skipping the chunk.
2026-11-11 10:25:19 +00:00
MSedgeMC 6be3db13d7 port_map: the convertCommon row says which path copies absorption
The gap note said absorption is never copied; the SINGLE path now does.
2026-11-11 10:25:11 +00:00
MSedgeMC e3e8b4c973 test: a zombie's absorption crosses into its drowned
A zombie at 3.0 absorption converts through `single` into a drowned: the new
mob reads 3.0. Without the copy the new mob has 0.0, the effect having been
copied but not the field the effect filled.
2026-11-11 10:25:11 +00:00
MSedgeMC f3946c6bbb fix: a converting mob keeps its absorption
`ConversionType.convertCommon` copies the effects, then
`to.setAbsorptionAmount(from.getAbsorptionAmount())` - the golden hearts an
absorption effect left on the mob are a plain field, and the effect copy does
not rebuild them.

`conversion::single` copied the effects but never the field, so a zombie with
three absorption hearts that turned into a drowned came out with none.
2026-11-11 10:16:19 +00:00
masa-masa-0314andZCode 08095e81cb refactor(friends): separate account-social models and parsing
Move the existing DTOs and response parsing into a private model module,
retaining public social paths and the existing parsing tests. Keep wire
and presence helpers visible only inside the social module tree.

Document the small pure-move preparation sequence, including a mandatory
manager module and a vanilla audit gate after all structural steps.
No cache, scheduling, action or UI behavior changes are included.

Fixes #15179

Co-Authored-By: ZCode <[email protected]>
2026-11-11 18:16:07 +08:00
rdbt b880384691 Merge pull request #15161 from horizon-rust/fix/parity-revert-13953 2026-11-11 10:00:11 +00:00
TheGTandOpenCode 3993570cb3 tests: the two CI scripts that had no test
tools/ci/legacy_releases.py writes the pinned SHA-256s of the releases
published before signing began, and tools/ci/resolve_reviewer_ids.py
fills in the numeric GitHub IDs the comment commands decide by. Twelve
of their thirteen siblings under tools/ci/ have a test; these two did
not, although both are pure functions over what GitHub returns.

Co-Authored-By: OpenCode <[email protected]>
2026-11-11 09:58:06 +00:00
12498072b0 Fix milk pitch and shear vibrations for farm animals
Milking a cow, goat or mooshroom plays at pitch 1.0 with no level-random draw, as AbstractCow.mobInteract, Goat.mobInteract and MushroomCow.mobInteract do.

Shearing a sheep or mooshroom by hand now posts GameEvent.SHEAR with the player, as Sheep.mobInteract and MushroomCow.mobInteract do.

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 10:57:36 +01:00
TheGTandOpenCode 5ee4540940 tools: port_ledger.py reads the pinned version from the lock file
Its two version lookups fell back to a literal "26.3" when the lock
file was missing or had no version, so a version bump that touched
only research-lock.json left the ledger looking in the wrong folder.
tools/lab.py already reads the pinned version from there; the
fallback now uses it.

Co-Authored-By: OpenCode <[email protected]>
2026-11-11 09:56:38 +00:00
TheGTandOpenCode 8a1de3ebdb tools: roadmap.py's help says the Python tests run its checks, not CI
Its help marked "build --check" and "check --offline" as (CI), but no
workflow runs them: CI runs tests/test_roadmap.py, which checks the same
two things (the generated files match the toml, and the file keeps its
rules). The help pointed contributors at a command CI never runs.

Co-Authored-By: OpenCode <[email protected]>
2026-11-11 09:55:08 +00:00
TheGTandOpenCode e690aec116 tools: a flag is not a directory or a folder
Three tools took their first argument as a path to write into or
download into, so "--help" created ./--help: the pad glyphs wrote
14 PNGs into it, and fetch_dxc.py downloaded Microsoft's 22 MB archive
into it. runner_pause.py, a watcher the service runs with no
arguments, started watching whatever it was given and then crashed on
a folder that was not there yet.

They print their usage and exit instead, and the shared dev-tool test
covers the two that write.

Co-Authored-By: OpenCode <[email protected]>
2026-11-11 09:53:34 +00:00
MSedgeMC 9823bf6c48 port_map: Tooltip attribute modifiers come from the stack 2026-11-11 09:52:00 +00:00
MSedgeMC 47f735a888 test: Tooltip attribute modifiers come from the stack 2026-11-11 09:52:00 +00:00
MSedgeMC 30ae132596 fix: Tooltip attribute modifiers come from the stack 2026-11-11 09:52:00 +00:00
Ludovic Marie 77a34310b0 Merge pull request #15159 from horizon-rust/governance/codeowners-council
🤖 Agent: CODEOWNERS moves from @ludovic111 to @horizon-rust/council, as the council decided (P35); the team exists with the six members and write access; reviewer tests pass. Paths guarded by /nightly are unchanged.
2026-11-11 10:39:41 +01:00
rdbtCVSandCodex GPT-6.1 Sol e592fc3af0 Keep owned mobs serial until owner teleport reach is bounded
Cover real wolf, cat and parrot teleports between separated herds in production and verify. Document conservative reach maintenance and implementation/performance acceptance requirements.

Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-11 09:31:56 +00:00
rdbtCVSandGPT 6.1 Sol ddb70f7a42 gfx: limit Raptor Lake exemption to tested Mesa 26.0.8
Keep older stable and prerelease Mesa drivers on the conservative first_instance workaround. Exercise the release boundary, distribution suffix, unknown versions, earlier Intel families and backend/vendor guards.

Review fix by rdbtCVS's AI agent (OpenCode, GPT 6.1 Sol).

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-11 10:28:33 +01:00
darkliroandAntigravity 505c938cad gfx: allow indirect multi-draw on Raptor Lake with Mesa >= 26
Allow indirect multi-draw and GPU culling on Intel Gen12 Raptor Lake
adapters when running on Mesa 26.0+. Tested on Intel Iris Xe Graphics
(RPL-U, device 0xa7a1) under Mesa 26.0.8: verified zero terrain flicker
or attribute corruption while cutting frame times by 28%.

Older Mesa versions and earlier Intel generations (Ice Lake, Tiger Lake,
Rocket Lake, Alder Lake) retain the workaround.

Co-Authored-By: Antigravity <[email protected]>
2026-11-11 10:28:33 +01:00
umcekoandClaude Opus 5.5 adbb0b4578 Merge main into dims/portal-scales
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 12:25:08 +03:00
umcekoandClaude Opus 5.5 0cec8f447b Merge main into dims/render-facts
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 12:25:08 +03:00
MSedgeMC 2aeaeb2495 test: the +-30,000,000 gate answers 15, inside answers the raw brightness
`TestLevel` gains a `sky_light` override so the two answers differ (0 inside,
15 outside); without the change both read 0.
2026-11-11 09:24:13 +00:00
MSedgeMC 782331b499 fix: full daylight outside the +-30,000,000 gate in getMaxLocalRawBrightness
`LevelReader.getMaxLocalRawBrightness(pos)` answers 15 when the position is
outside `x >= -30000000 && z >= -30000000 && x < 30000000 && z < 30000000`,
because there is no chunk to ask there; inside it is `getRawBrightness(pos,
skyDarken)`. Horizon always asked the level, so past the gate it returned
whatever the (unloaded, dark) level answered: saplings and mushrooms never grow
and nothing that reads the brightness sees daylight.
2026-11-11 09:24:13 +00:00
Horizon agentandClaude Opus 5.5 7627df18b0 CODEOWNERS: the council team reviews release paths (P35)
The council decided (P35, 2026-11-10) that code-owner reviews move from the
founder to a GitHub team of the six council members. The team
horizon-rust/council exists and has write access, which code owners need.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 10:17:07 +01:00
MSedgeMC e559e8646f port_map: ClientBundleTooltip's empty description height 2026-11-11 09:11:14 +00:00
MSedgeMC c48801cb57 test: an empty bundle's height follows its description lines
Three wrapped lines reserve 3 * 9 + 13 + 8 pixels and put the bar at 3 * 9 +
4, not the fixed two-line height.
2026-11-11 09:11:14 +00:00
MSedgeMC 200a255241 fix: an empty bundle's tooltip is as tall as its description
ClientBundleTooltip.getEmptyBundleBackgroundHeight is
getEmptyBundleDescriptionTextHeight(font) + 13 + 8, and that height is
font.split(BUNDLE_EMPTY_DESCRIPTION, 96).size() * 9: the description's wrapped
line count at one decimal. Horizon hardcoded two lines (18), so a translation
whose description wraps to three lines drew the progress bar and the box's
bottom two pixels too high.

tooltip_layout and bundle_tooltip take the line count; the tooltip's probe and
draw compute it from the same font and 96 pixel width the description is drawn
at. SmithingScreen's slot icons moved to game/smithing_icons.rs so the
3,000-line stations.rs does not grow.
2026-11-11 09:10:51 +00:00
MSedgeMC 0f6a5b1b58 port_map: ServerData.validateIcon's 1024 limit 2026-11-11 09:06:59 +00:00
MSedgeMC e9c6b85aa8 test: an icon up to 1024 square is kept
64, 128 and 1024 pixel PNGs are kept (128 and 1024 would fail the old
64x64-only gate); 1025 and a non-PNG are not.
2026-11-11 09:06:59 +00:00
MSedgeMC c01e5ea7bd fix: a server icon is kept up to 1024x1024, not only 64x64
ServerData.validateIcon keeps any PNG whose width and height are at most 1024
(MAX_ICON_SIZE); FaviconTexture.upload still needs exactly 64x64 to draw one.
Horizon used icon_pixels (64x64 only) as the gate too, so a server whose
favicon was, say, 128x128 had its icon dropped from servers.dat and never
saved on join. valid_icon now answers the save question, and icon_pixels keeps
answering the draw one.
2026-11-11 09:06:37 +00:00
Horizon agent ee66246131 Merge remote-tracking branch 'origin/main' into fix/parity-revert-13953
# Conflicts:
#	tools/parity_hosts.json
2026-11-11 10:06:20 +01:00
Horizon agentandClaude Opus 5.5 766a5d4c87 Parity: container_screen's oracle skips the first release; stand_lodestone is named-only
container_screen: the oracle makes its screens with Unsafe.allocateInstance,
which skips AbstractContainerScreen's constructor and so its
`skipNextRelease = true`. #13723 made Horizon's Clicks start that way,
as the real screen does, and the oracle then disagreed on every case
whose first event is a release (373 cases). The oracle now sets the
field the constructor would.

stand_lodestone needs --work-dir, a frozen Java run (--reference-dir)
and a built game (--binary), which an automatic run never passes, so it
failed in 0 s on every runner. It joins the other capture-preserving
harnesses in parity_hosts.json's named_only: it runs when named, and
in the nightly audit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 10:06:01 +01:00
MSedgeMC b29c0cc050 port_map: AbstractSliderButton.handleCursor's resize cursor 2026-11-11 09:05:21 +00:00
MSedgeMC a10e37401c test: a dragged slider asks for the horizontal resize cursor
Not hovered asks nothing; dragged resizes; hovered at rest points; inactive is
not allowed.
2026-11-11 09:05:21 +00:00
MSedgeMC 8943e13768 fix: a dragged slider shows the horizontal resize cursor
AbstractSliderButton.handleCursor asks for RESIZE_EW while dragging,
POINTING_HAND when hovered and NOT_ALLOWED when inactive. Horizon's
widget_cursor only knew the hand and the forbidden sign, so the shell's
sliders (their held state already known in RowView) never asked for the resize
cursor. widgets::slider_cursor adds it, the shell's slider rows call it, and
the client maps Cursor::ResizeEw to EwResize.
2026-11-11 09:05:00 +00:00
rdbtCVSandGPT 6.1 Sol 0f1921c196 Make special-attack parity runnable without a prebuilt test artifact
Build and select the gameplay library test executable for the automatic parity runner; retain the explicit artifact override. Cover argument-free invocation and Cargo artifact selection.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-11 09:01:00 +00:00
tarzinio eb56315c76 Cap the occlusion edit queue when cave culling is off
Fixes #15152
2026-11-11 09:50:20 +01:00
MSedgeMC 5add9e31c0 refactor: the wheel module declares itself after mod shell_pages
The #[path] mod wheel block sat right after pub use Opt, the anchor the
widgets PR uses. It now sits after mod shell_pages, and its test moved into
the module's own #[cfg(test)] mod tests instead of appending to
options_ui_graphics_tests.rs, so the three siblings touch disjoint lines. No
behaviour changes.
2026-11-11 08:45:48 +00:00
MSedgeMC d77a09e09f refactor: the preset module declares itself by mod graphics_tests
The #[path] mod preset block sat before use crate::{, the same anchor the
widgets and wheel PRs use. It and its pub(crate) use now sit just above the
test modules at the end of the file, and the preset test moved into the
module's own #[cfg(test)] mod tests, so it no longer appends to
options_ui_graphics_tests.rs either. No behaviour changes.
2026-11-11 08:38:35 +00:00
MSedgeMC 4256dec408 port_map: handleSetJigsawBlock applies locally too
The guest path marks the block entity; the integrated game applies the
same packet through the editor's Done.
2026-11-11 08:33:44 +00:00
MSedgeMC 959c3b6180 test: a single-player jigsaw edit applies and marks the chunk
`a_local_jigsaw_edit_applies_and_marks_the_chunk` sends a
`SetJigsawBlock` through the editor's Done path on an integrated game:
the block entity takes the new name and priorities and its chunk is
marked. Without the local application the name stays the default.
2026-11-11 08:33:44 +00:00
MSedgeMC e00864ba59 fix: a jigsaw edited in single player is applied
`ServerboundSetJigsawBlockPacket` is handled on the server
(`handleSetJigsawBlock`), which writes the block entity and marks it. The
editor's Done only builds that packet, and `Game::send` drops it when
there is no remote (the integrated server is this game), so a jigsaw edit
in single player changed nothing.

`block_editor_outcome` now applies the packet locally when `remote` is
none, through the same `apply_set_jigsaw_block` a guest's edit uses.
2026-11-11 08:33:23 +00:00
MSedgeMC 12782bd26c port_map: Options.chatDelay's message and slider 2026-11-11 08:29:08 +00:00
MSedgeMC 28c51ac694 test: the Chat Delay row says its delay
Default reads None; 2.0 and 6.0 seconds read the delay message with one
decimal; back to zero reads None. The slider test from the previous commit
still pins the tenths.
2026-11-11 08:29:08 +00:00
MSedgeMC 3df28380e8 fix: the Chat Delay row shows its delay
Options.chatDelay's message is options.chat.delay_none at zero and
options.chat.delay with the delay to one decimal otherwise (both label and
value_text). This folds the message half of #15068 into this PR, so one change
covers the control: the IntRange(0, 60) slider from the previous commits and
the message here.

For merge hygiene the new values module sits above mod shell_pages (not after
pub use Opt) and the ChatDelay arms lead their matches (not after the wheel
sensitivity arm), so the wheel-sensitivity PR's hunks do not touch these lines.
2026-11-11 08:29:08 +00:00
rdbtCVSandCodex GPT-6.1 Terra 9062522b9d Merge updated mob groundwork into parallel islands
Co-Authored-By: Codex GPT-6.1 Terra <[email protected]>
2026-11-11 08:23:32 +00:00
rdbtCVSandCodex GPT-6.1 Terra fa0e2b01cd Merge updated item and helper work into mob groundwork
Co-Authored-By: Codex GPT-6.1 Terra <[email protected]>
2026-11-11 08:22:25 +00:00
rdbtCVSandCodex GPT-6.1 Terra f663c83f98 Merge updated parallel tick helpers into item moves
Co-Authored-By: Codex GPT-6.1 Terra <[email protected]>
2026-11-11 08:20:28 +00:00
rdbtCVSandCodex GPT-6.1 Terra 4740691c04 Merge origin/main into parallel tick helpers
Co-Authored-By: Codex GPT-6.1 Terra <[email protected]>
2026-11-11 08:18:54 +00:00
MSedgeMC 00c3584214 port_map: OptionInstance.IntRangeBase.toSliderValue and the Chat Delay tenths 2026-11-11 08:12:38 +00:00
MSedgeMC f288262d66 test: the Chat Delay slider runs from 0 to 6 seconds in tenths
0.0, 3.0 and 6.0 sit at 0, 1/2 and 1 of the track (a stored 6.5 clamps), the
slider has 60 steps, and set_slider stores 0.0, 3.0 and 6.0.
2026-11-11 08:12:38 +00:00
MSedgeMC 5a75c90dbc fix: the Chat Delay control is a working slider
Options.chatDelay is IntRange(0, 60) xmap(v -> v / 10.0, v -> v * 10, true),
a slider from 0 to 6 seconds in tenths: toSliderValue is (tenths / 60) and
fromSliderValue is floor(map(v, 0, 1, 0, 61)) / 10. Horizon had no kind or
set_slider arm for it, so the Accessibility row drew a button whose click
changed nothing. The value labels (generic, on_off, chat_px) moved to
options_ui_values.rs with the mapping so options_ui.rs does not grow.
2026-11-11 08:12:38 +00:00
MSedgeMC 8918f22af2 port_map: TheEndGatewayBlockEntity.portalTick marks the chunk
The server ticker's age change is a `setChanged`.
2026-11-11 08:10:12 +00:00
MSedgeMC c8f98e4a8c test: an ageing end gateway marks the chunk
`a_gateway_that_ages_marks_the_chunk` ticks a gateway one server tick: the
age reads 1 and its chunk is in `world.modified`. Without the mark the
chunk stays clean.
2026-11-11 08:10:05 +00:00
MSedgeMC 1e12fc40bc fix: an end gateway's ageing marks the chunk
`TheEndGatewayBlockEntity.portalTick` ages the block entity and ends with
`setChanged`, so the age the beam and the chunk save read is saved.

Horizon's `tick_end_gateway` copied the server age onto the block entity
but never marked the chunk.
2026-11-11 08:09:43 +00:00
MSedgeMC 0779ef7099 port_map: Options.mouseWheelSensitivity's logarithmic slider 2026-11-11 08:07:20 +00:00
MSedgeMC f6850ed4a5 test: the mouse wheel sensitivity slider steps by 10^(i/100)
The default 1.0 sits two thirds along, 0.01 and 10.0 at the ends, a third of
the way is 0.1, and the value setter goes through the same mapping.
2026-11-11 08:07:20 +00:00
MSedgeMC ec1ab4c1a9 fix: the mouse wheel sensitivity slider is logarithmic
Options.mouseWheelSensitivity is an IntRange(-200, 100) through
logMouse/unlogMouse: the value is 10^(i/100), so the slider's handle sits at
(i + 200) / 300 and a drag picks the step by floor(map(v, 0, 1, -200, 101)).
Horizon used a straight line from 0.01 to 10.0, which put the default 1.0 at
0.099 of the track and made every drag pick a different sensitivity.
2026-11-11 08:07:16 +00:00
TheGTandOpenCode 26c04d14c3 Entity.saveWithoutId writes the live PortalCooldown, not the loaded one
A mob, an item and a vehicle kept the PortalCooldown they were loaded
with, so their countdown never showed in a save or in /data get entity.
The portal state ticks the countdown down every tick, as
Entity.processPortalCooldown does; the save now takes it from there, as
the projectile writer already did.

Fixes #14539

Co-Authored-By: OpenCode <[email protected]>
2026-11-11 07:59:37 +00:00
foudretbr 853ec54bd3 fix(mobs): load and retain armor stand pitch from entity Rotation (#14799)
Read the stand's pitch component alongside yaw during NBT deserialization and preserve both components across save roundtrips.
2026-11-11 08:42:18 +01:00
TheGT 78ca3e6736 Merge remote-tracking branch 'origin/main' into tools/generate-lan-command-order-help 2026-11-11 07:37:31 +00:00
TheGT ee8d4c4a72 Merge remote-tracking branch 'origin/main' into tools/port-ledger-json-after-query 2026-11-11 07:36:06 +00:00
TheGT 1d59235bc6 Merge remote-tracking branch 'origin/main' into tools/anvil-dump-help 2026-11-11 07:34:40 +00:00
TheGT ffd67ee750 Merge remote-tracking branch 'origin/main' into ci/run-cargo-with-locked 2026-11-11 07:33:14 +00:00
TheGT ef207822ad Merge remote-tracking branch 'origin/main' into docs/prerequisites 2026-11-11 07:31:49 +00:00
TheGT 7faa273f45 Merge remote-tracking branch 'origin/main' into tools/lang-coverage-show-keys
# Conflicts:
#	tools/lang_coverage.py
2026-11-11 07:30:11 +00:00
Vinícius MoreiraandClaude Opus 5.5 a6deb3e7de Polytone: exclusion_radius sees only particles already in the world
Measured on the mod: two particles of a type with exclusion_radius 2,
spawned a block apart in the same tick, both show, with or without
physics; one born inside a block (dropped) keeps no clear one from
spawning beside it. Horizon also counted the pending particles of the
tick, so the second was refused. The check now looks only at those that
joined; a later tick's spawn near them is still excluded.

Part of #11486

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 04:06:29 -03:00
Riccardo GuerrieroandClaude Fable 5.2 4fcb17840a Surface stage: sample each ore vein's densities over its own rows, when first read
The surface stage bound three whole-chunk density volumes for the ore veins
(copper's vein toggle, the shared richness, iron's) before its walk, although
each vein only reads the rows of its own y range and the richness is read in
about a quarter of the chunks.

A vein's rule now binds its densities with the rows it can read: the buffer
covers those rows widened to whole 16-block world rows, and it is sampled at
the first read. Other bindings (carvers, plain bind_density), and a volume
whose steps are not 1, are sampled whole when bound, as before. The per-block
read uses plain fields of the buffer, so the hot path keeps no copy of the
volume on the stack.

Same sampler, same positions inside the rows, same values: chunk dumps of
4 seeds x Overworld/Nether/End, terrain and decorated, are identical to main.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-11 07:57:13 +01:00
qaesen 63976a33d7 hud: add progressive downscaling fallback for oversized server packs (fixes #8570) 2026-11-11 07:48:51 +01:00
Vinícius MoreiraandClaude Opus 5.5 aed6fcaad9 Polytone: a particle that never joins takes no place under its type's limit
Measured on the mod: with limit 1, one particle with physics born inside a
block and one clear of it in the same tick, the clear one shows. Horizon
counted the pending one, which is dropped only when it joins, so the clear
one was refused and nothing showed. A pending particle with physics now
counts only once it joins: the limit is checked again at the join, after
the drop.

Part of #11486

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 03:43:40 -03:00
MSedgeMC f09967622c port_map: AbstractThrownPotion.douseFire carries the owner's source
The campfire's block-change event is posted from the potion's owner.
2026-11-11 05:19:53 +00:00
MSedgeMC f561a0f625 test: a doused campfire posts block_change from the potion owner
`a_doused_campfire_posts_block_change_from_the_potion_owner` puts a lit
campfire at (2, 4, 3) and douses it with a mob owner: the block reads
unlit and the posted events hold
`("minecraft:block_change", [2.5, 4.5, 3.5], Some(Who::Mob(7)))`.
With no source passed the event's source is None.
2026-11-11 05:19:53 +00:00
MSedgeMC d0321de02b fix: a doused campfire's block_change carries the potion's owner
`AbstractThrownPotion.douseFire` passes `getOwner()` to
`CampfireBlock.douse(entity, level, pos, state)`, which calls
`level.gameEvent(entity, GameEvent.BLOCK_CHANGE, pos)`: the sculk sensor
hears the campfire go out from the potion's owner (possibly no owner at
all).

Horizon's `potion_hit_block`/`douse_fire` posted the event with no
source; the candle path stays sourceless, as vanilla's
`AbstractCandleBlock.extinguish(null, ...)` is.
2026-11-11 05:19:53 +00:00
readerkid 9fc6f9d529 Make the io_pool kick test say which packet is which, not which goes first
The packet queued with `served.send` and the one the probe sends from
`Session::tick` are framed by different paths: `Conn::serve` takes the first
from the outbox, `Conn::sweep` frames the second straight into `out`. Whichever
the worker thread reaches first is written first, and the sweep is on a timer,
so a CI run took the probe's packet (id 33) before the queued one and the test's
two `assert_eq!`s failed. The order is common, but not something the pool
promises.

The test now takes both packets and tells them apart, then checks what it is
about: the kick goes after the queued packet, `kicked` is reported, the
connection closes, and nothing follows the kick. The kick is still sent only
after both packets arrived, so that part stays ordered.

Fixes #14660

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
2026-11-10 21:14:32 -08:00
MSedgeMC 1a2d9072d6 port_map: CrafterBlockEntity.setSlotState marks the chunk
The menu's slot state write-back lands on the block entity and marks it.
2026-11-11 05:03:37 +00:00
MSedgeMC e01e03f4db test: a slot toggled in the menu marks the crafter's chunk
`a_slot_toggled_in_the_menu_lands_on_the_crafter` opens a crafter with
slot 3 disabled, clicks it with an empty hand and checks the block
entity: the slot is enabled and the chunk is marked. Without the mark the
click leaves `world.modified` empty.
2026-11-11 05:03:37 +00:00
MSedgeMC efd000579d fix: a crafter's slot state write marks the chunk
`CrafterBlockEntity.setSlotState` ends with `setChanged`, which is what
saves a toggled slot. Horizon applied the menu's slot states to the block
entity on every click (`menu_click`'s write-back) but never marked the
chunk, so a disabled slot could be lost on save.

The write-back is now `Game::apply_crafter_slot_states`: it copies the
menu's flags, and when one changed calls `container_changed` (the
container-marking path, which also re-reads comparators).
2026-11-11 05:03:37 +00:00
MSedgeMC 0340e90e89 port_map: CampfireBlockEntity.markUpdated is covered on both paths
Placing food marks the block entity (chunk modified, clients synced) and
a moved cooking timer marks it in the tick; the row kept the old gap.
2026-11-11 05:02:08 +00:00
MSedgeMC f4c4b3c8bd port_map: SelectWorldScreen's button states are modelled 2026-11-11 05:00:30 +00:00
MSedgeMC 126a017432 port_map: the Language page draws the accuracy warning 2026-11-11 05:00:28 +00:00
MSedgeMC cf44da6903 port_map: DirectJoinServerScreen's Join button state and initial focus 2026-11-11 05:00:26 +00:00
MSedgeMC 9c99f95069 Merge remote-tracking branch 'upstream/main' into fix/reset-keys-active 2026-11-11 04:54:10 +00:00
TheGTandOpenCode f2708c690f docs: the agents map says what lang_coverage.py --summary prints
Co-Authored-By: OpenCode <[email protected]>
2026-11-11 04:52:56 +00:00
yahya-nusseibehandClaude Opus 5.5 62f7e8ef03 Bound legacy smithing-trim previews from old servers
A 1.20.2-1.21.4 server's smithing-trim display (or trim recipe) is expanded into one
SmithingTrim per pattern, each with its own copy of the base and material displays. Those
can themselves be trim displays, so a few hundred bytes asked for billions of nodes.

Refuse a preview whose patterns x (base + material weight) exceeds MAX_TRIM_PREVIEW (4096),
counted with the new SlotDisplay::weight, both in SlotDisplay::read (1.21.2-1.21.4) and in
the 1.21.1 trim recipe translation; the 1.21.1 path also builds each display once.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 06:51:11 +02:00
Vinícius MoreiraandClaude Opus 5.5 f5bad28164 Comparator: each doc comment above its own function
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 01:47:37 -03:00
TheGTandOpenCode 45bfbc8a5f tools: the port modules say how they are used, and the port report finds every version site
Co-Authored-By: OpenCode <[email protected]>
2026-11-11 04:45:59 +00:00
yahya-nusseibehandClaude Opus 5.5 7145f30254 Commands: a server's registry names can't leak without bound
Every Commands packet becomes a dispatcher, and every resource-style
argument the vanilla tree doesn't match goes through intern(), which
Box::leaks its registry name for the life of the game. The name is the
server's, up to 32767 bytes and unchecked, so a server sending trees of
new names packet after packet leaked megabytes each time until the
client ran out of memory.

intern() now keeps only resource-location names of at most 256 bytes,
and at most 1024 of them; anything else is kept as "" (no suggestions
for that argument).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 06:45:22 +02:00
TheGTandOpenCode 071a5d1d3a tools: the velocity probe reads the protocol it announces, instead of writing 777 by hand
Co-Authored-By: OpenCode <[email protected]>
2026-11-11 04:42:45 +00:00
yahya-nusseibehandClaude Opus 5.5 b689e1dfa2 Protocol text: a disconnect reason can't expand past 1 MiB
text::plain flattens a server's Disconnect component into one String,
and format_java copies an argument once per %1$s in the server's own
`fallback` template. A fallback of 16 000 "%1$s" (64 KB) whose argument
is the same construct, nested three levels (well inside the depth-32
guard), asked for about 4e12 bytes; the allocation failed and the client
aborted instead of showing the reason. The login phase's json_text, a
second copy of the same expansion, had no depth limit either.

plain, format_java and json_text now stop at MAX_TEXT (1 MiB), cut at a
character boundary (push_capped). A disconnect screen needs a few
hundred bytes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 06:40:51 +02:00
Vinícius MoreiraandClaude Opus 5.5 8204825b4e Core shaders: an array sized by its initializer only translates (#9386)
naga's GLSL front end refuses `const float[] w = float[](...)` and
`float w[] = ...`, which vanilla's GL compiler sizes from the initializer.
The size is written in, in the declarator and the constructor. Brackets
are matched in one pass and the declarator is looked for at most 256 bytes
back, so a long line stays linear.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 01:37:49 -03:00
Vinícius MoreiraandClaude Opus 5.5 680e91bdf9 Core shaders: a helper taking the sampler it reads translates (#9386)
naga's GLSL front end refuses sampler2D function parameters that vanilla's
GL compiler takes. Each such function is copied once per set of samplers
its callers pass, the copy reading those samplers by name: overloads keep
their own bodies, nested calls are rewritten inside out, prototypes become
the copies' prototypes, and copies go after their samplers' declarations
(lucasmts59's finding in review).

Pack text is untrusted. Brackets are matched in one pass and the scans for
calls have a budget, so no text freezes a load. A sampler parameter with
no name, edits that would overlap, or anything else the rewrite can't
follow leave the whole shader as the pack wrote it, for naga to refuse.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 01:37:33 -03:00
TheGTandOpenCode 8e4e46fdd6 tools: the command fixtures ask for their work directory instead of crashing
Co-Authored-By: OpenCode <[email protected]>
2026-11-11 04:36:10 +00:00
TheGTandOpenCode e43c0f75dc tools: say git 2.38 or newer is required, instead of printing git's usage
Co-Authored-By: OpenCode <[email protected]>
2026-11-11 04:32:53 +00:00
yahya-nusseibehandClaude Opus 5.5 7d84ad3777 Server files: the management secret comes from OS randomness, not the seed's stream
SystemEntropy is splitmix64 seeded from 8 bytes. On a first start with
no secret and no level-seed (both defaults), the 40 characters of
management-server-secret were outputs 1-40 of that stream and the world
seed was output 41; splitmix64's mixer is a bijection, so the world
seed (which ops read with /seed, and seed crackers recover from
terrain) gave back the state and every secret character. The secret is
kept when the owner later enables the management server. On Windows the
seed fell back to the clock, so the secret depended only on the start
time.

Secrets (the management secret, the TLS keystore password) now come
from OsEntropy, every draw straight from getrandom with rejection
sampling, as vanilla's SecureRandom. SystemEntropy itself is seeded
from getrandom too, the clock only if that fails.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 06:32:18 +02:00
TheGTandOpenCode 6b855fe0c7 tools: generate_lan_command_order.py prints usage instead of running the generator on --help
Co-Authored-By: OpenCode <[email protected]>
2026-11-11 04:29:43 +00:00
MSedgeMC 14bc8fbebe graphics: the automatic worker count is not always one per available processor 2026-11-11 04:29:25 +00:00
yahya-nusseibehandClaude Opus 5.5 56a25648ac horizon-server: a space in a config key can't get past the API's deny-list
PATCH /api/v1/config checked the raw JSON key with changeable_remotely,
which doesn't trim it, then applied "{key}={value}" through
parse_change, which does. A key " engine.path" (or "\tapi.allow_public",
" discord.webhook", " properties.enable-rcon") passed the deny-list and
was written to server.toml: at the next restart the API or panel opened
to the internet, chat went to another webhook, RCON came on, or the
supervisor ran another program.

changeable_remotely now refuses any key with whitespace or control
characters in it, so what it checks is what gets applied.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 06:28:23 +02:00
TheGTandOpenCode 9351cd381f tools: port_ledger.py accepts --json after the query's name, as its own help says
Co-Authored-By: OpenCode <[email protected]>
2026-11-11 04:26:33 +00:00
yahya-nusseibehandClaude Opus 5.5 a2864d9edd horizon-server: a management kick reason can't inject control requests
players/kick put the management client's reason, newlines included,
into "console kick <name> <reason>", which Control::ask writes as one
line to the game's control endpoint. The endpoint reads one request per
line, so a reason of "x\nconsole op Mallory" ran a second request: any
console command, or any other control verb, which the management
protocol never offers.

The kick reason now drops control characters, and Control::ask itself
refuses a request with \n or \r before sending anything, so every caller
is covered (the API's /command also refuses \r now, as it did \n).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 06:26:10 +02:00
yahya-nusseibehandClaude Opus 5.5 5323f98c42 horizon-server: a backup always turns saving back on
A backup turned autosave off, then flushed. If the flush failed (the
control connection's 30 s reply timeout on a big world or a slow disk),
the ? returned before `console save-on` was sent, and the game kept
running with autosave off until the next backup that succeeded;
scheduled backups take this path, so a crash in that window lost
everything played since.

Once save-off has been asked, saving is now always turned back on: over
the same connection after a good backup, and over a fresh one when the
flush failed (a reply that timed out leaves the old one out of step).
The flush gets its own 300 s reply timeout.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 06:23:41 +02:00
TheGTandOpenCode 4ffda2343e tools: lang_coverage.py no longer drops --show-keys when --summary is given
Co-Authored-By: OpenCode <[email protected]>
2026-11-11 04:23:14 +00:00
TheGTandOpenCode dcddf81a20 tools: anvil_dump.py asks for its file instead of crashing on --help, and gets a test
Co-Authored-By: OpenCode <[email protected]>
2026-11-11 04:20:00 +00:00
TheGTandOpenCode 70a011535b ci: run cargo with --locked so a stale Cargo.lock fails instead of being rewritten
Co-Authored-By: OpenCode <[email protected]>
2026-11-11 04:16:07 +00:00
rdbtCVSandGPT 6.1 Sol 724b70e606 Merge repaired main to refresh item-frame verification
The current-head verify run 42944105269 used the old synthetic merge with the inherited CEM shadowing error. Main now includes the shared repair from PR #15038. Preserve both the contributor change and signed-byte regression fixup; no duplicated repair code.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-11 04:11:52 +00:00
Vinícius MoreiraandClaude Opus 5.5 c0998126c0 Core shaders: a pack's gl_VertexIndex reads the glue's vertex number
26.3 packs write gl_VertexIndex where older ones wrote gl_VertexID: both
mean vanilla's vertex number, four per quad. Only gl_VertexID read the
glue's expression, so on the text pipeline (six vertices per glyph) a
26.3 pack read the raw index. Both now read it; the glue's own code above
the pack's keeps the real index.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 01:09:48 -03:00
SenjinandClaude Opus 5.5 9b006dcab6 Shader packs: one program's solid and cutout terrain draw in one pass
A pack whose one terrain program (gbuffers_terrain) draws both solid and
cutout terrain ran it twice over the solid list, each pass moving the other
part's quads off-screen: every solid-terrain vertex was shaded twice (0.6 ms a frame measured with a path-traced pack on an RTX 2080).

Now the game also gives the loader terrain's inputs for one pass
(GameInputs::terrain_one_pass, GbuffersInputs::parts): the vertex stage
works out its quad's part from the sprite layer, as before, and hands it to
the fragment stage as a flat varying, which applies that part's alpha test.
alphaTestRef becomes a per-part value in both stages (0 for solid, 0.5 for
cutout, as Iris 1.11.7 did in #9307's probes), and alphaTest.gbuffers_terrain
still replaces both parts' tests. When both tests are the same, nothing
changes in the shader. Packs with gbuffers_terrain_solid or _cutout keep
their two passes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 05:09:25 +01:00
TheGTandOpenCode minimax-m3.1-flash-free 172965a9a0 docs: the prerequisites say Python 3.11+ and git 2.38+
Co-Authored-By: OpenCode minimax-m3.1-flash-free <[email protected]>
2026-11-11 04:06:30 +00:00
a54d9f825a Keep a zombie in powder snow from catching fire in daylight
Mob.isSunBurnTick (Mob.java:500-506) counts isInPowderSnow and wasInPowderSnow
as not burnable, so a mob standing in powder snow never ignites; Horizon only
tested water and rain, so a zombie in powder snow ignited in daylight and
PowderSnowBlock.entityInside (PowderSnowBlock.java:88-99) then destroyed the
block under it with mobGriefing and put it out, leaving it to burn freely.
The scan at burn time is isInPowderSnow; m.in_powder_snow, the scan baseTick
made before the mob travelled, is wasInPowderSnow.

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 05:05:20 +01:00
SenjinandClaude Opus 5.5 d990e4f79c Shader pack timing: passes of one name count as their sum, compute passes apart
HORIZON_SHADER_PACK_TIMING pushed every pass's time into the window of its name, so two passes of
one name in a frame (a program drawn over several render passes, or deferred2.csh beside
deferred2.fsh) shared a window and its median was the time of neither. A frame's passes of one
name now add up to one sample, and a compute pass is timed as "<name> (compute)".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 05:00:09 +01:00
MSedgeMC d7107e8f1b Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-11 03:53:41 +00:00
MSedgeMC 72e3ec1924 Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-11 03:53:38 +00:00
MSedgeMC 4caa4a90c3 Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-11 03:53:31 +00:00
MSedgeMC 8d47c940e6 Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-11 03:53:22 +00:00
MSedgeMC 0d92a3d997 Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-11 03:53:19 +00:00
MSedgeMC 7d6b37b8e6 Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-11 03:53:12 +00:00
MSedgeMC 451fb4ebd9 Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-11 03:53:09 +00:00
MSedgeMC 6895c1dcc1 Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-11 03:53:07 +00:00
MSedgeMC 278dee9871 Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-11 03:53:02 +00:00
MSedgeMC 08eed8f8a7 Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-11 03:53:00 +00:00
MSedgeMC 7a8364a0f7 Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-11 03:52:53 +00:00
MSedgeMC 8eb87f05ff Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-11 03:52:50 +00:00
MSedgeMC c5406b0bf0 Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-11 03:52:45 +00:00
0ce71c6934 Zombies burn you only with a bare hand, on the difficulty's chance
`Zombie.doHurtTarget` (Zombie.java:315-325) rolls `effectiveDifficulty * 0.3`
for a burn of `2 * (int)effectiveDifficulty` seconds only when
`super.doHurtTarget` landed, the main hand is empty and the zombie is on fire,
and every species that extends `Zombie` inherits it: a burning husk, drowned,
zombie villager and zombified piglin now burn too, while a zombie holding a
weapon and a blow a shield took whole no longer do. The chance is drawn in
`do_hurt_target` where vanilla draws it, in the mob's own tick, and the game
lights the burn only for the blow it rides.

The turtle egg's break sound also draws its pitch from the level random
(Zombie.java:553-555): `0.9 + level.getRandom().nextFloat() * 0.2`.

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 04:42:19 +01:00
KBroneZ e7499779c8 Merge remote-tracking branch 'upstream/main' into claude/up-8688 2026-11-11 05:33:30 +02:00
KBroneZ f211a2ee96 Merge remote-tracking branch 'upstream/main' into claude/up-15039 2026-11-11 05:33:28 +02:00
KBroneZ c1503f7ada Merge remote-tracking branch 'upstream/main' into claude/up-7843 2026-11-11 05:33:13 +02:00
SenjinandClaude Opus 5.5 2f3a3e302d Dynamic Resolution: a window gathers timed frames until it has enough, so a slow scene still steps down
A window cleared its GPU timings when its time was up, then wanted eight: at 10 FPS (five frames in
500 ms) or with timestamps arriving in gaps, every window started over and the scale never came
down in the scenes that need it most (review on #14815).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 04:29:54 +01:00
Nikita Maximilian GrimmandClaude Fable 5.2 17acc701da Merge remote-tracking branch 'origin/main' into typed-randoms-1a
Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-11 04:26:01 +01:00
Nikita Maximilian GrimmandClaude Fable 5.2 4f76f9b35d Merge remote-tracking branch 'origin/main' into projectile-uuid-draws
Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-11 04:25:26 +01:00
Nikita Maximilian GrimmandClaude Fable 5.2 619e45830a Merge remote-tracking branch 'origin/main' into parity/noai-tadpole-ages
Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-11 04:24:07 +01:00
Nikita Maximilian GrimmandClaude Fable 5.2 bd8ae9f1ed Merge remote-tracking branch 'origin/main' into parity/ominous-spawner-loaded-rotation
Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-11 04:23:26 +01:00
MSedgeMC c96f20ac6c port_map: map CatAvoidEntityGoal continue 2026-11-11 03:23:17 +00:00
MSedgeMC fb6707ec92 test: a tamed cat stops fleeing players 2026-11-11 03:23:17 +00:00
MSedgeMC dfc7fdd786 fix: a tamed cat stops fleeing players 2026-11-11 03:23:02 +00:00
Nikita Maximilian GrimmandClaude Fable 5.2 95848131df Merge remote-tracking branch 'origin/main' into parity/sulfur-cube-bucket-health
Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-11 04:21:24 +01:00
Vinícius Moreira 541bc4b519 Merge remote-tracking branch 'upstream/main' into fix/explosion-player-xp 2026-11-11 00:03:46 -03:00
Vinícius MoreiraandClaude Opus 5.5 f0a379cbc1 Merge main into fix/comparator-setblock-output: main compiles again (#15038)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 23:57:09 -03:00
Evan-lhm 3c17bd7960 Merge remote-tracking branch 'origin/main' into rt/acceleration-structures
# Conflicts:
#	crates/horizon-client/src/mobs/cem.rs
2026-11-11 03:45:10 +01:00
Nikita Maximilian Grimm 41a9e808e1 Merge remote-tracking branch 'origin/main' into hdr-shaders-pack-no-lift 2026-11-11 03:40:32 +01:00
Nikita Maximilian Grimm bdef01a46c Merge remote-tracking branch 'origin/main' into shader-api-linear-blend 2026-11-11 03:39:56 +01:00
rdbt 04a26210e3 Merge pull request #15038 from horizon-rust/fix/main-ci-after-bulk-merge
Fix CEM compilation and scaled mob culling bounds
2026-11-11 02:39:18 +00:00
rdbtCVSandGPT 6.1 Sol a005b741f2 Narrow loaded frame rotation before taking its remainder
Match TagValueInput.getByteOr for wider numeric and fractional tags, and cover signed frame rotation through NBT round trips for both frame kinds.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-11 02:08:40 +00:00
f6af16d3ef Goats stop ramming while fed or bred, and a shield halves a ram's push
GoatAi.initRamActivity wants TEMPTING_PLAYER and BREED_TARGET absent, and
GoatAi.updateActivity picks RAM before IDLE, so a goat being fed or bred only
follows; a player who starts feeding it during the prepare also ends the
activity (PrepareRamNearestTarget.stop starts the cooldown).

RamTarget.tick runs applyItemBlocking a second time for its mobAttack push and
scales it by blockingFactor (0.5 when the victim's raised item blocked part of
the hit). The push now names the damage the hit dealt so the host can do that,
and blockingFactor * speedFactor keeps vanilla's float-then-double order.

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 03:03:46 +01:00
MSedgeMC 90187f9942 port_map: KeyBindsScreen's Reset All active state 2026-11-11 02:01:31 +00:00
MSedgeMC 57bf54407a test: Reset All's active state follows the bindings
The default map has nothing to reset; moving one binding turns it on, setting
it back or reset_all turns it off.
2026-11-11 02:01:31 +00:00
MSedgeMC 5666c93e13 fix: Reset All is greyed out until a binding changes
KeyBindsScreen.extractRenderState walks the mappings and sets
resetButton.active from KeyMapping.isDefault, so the button is inactive until
some binding differs from its default. Horizon drew it active and reset every
mapping on any click.
2026-11-11 02:01:31 +00:00
rdbtCVSandGPT 6.1 Sol 4f699a47f4 fix: keep lectern cursor tests out of production builds
Restore the cursor test module's cfg(test) guard displaced by the new lectern regression test. Cite the actual pinned LecternBlock.resetBookState method instead of nonexistent setHasBook.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-11 01:45:20 +00:00
Vinícius MoreiraandClaude Opus 5.5 b5c4b2f84b Wrap the zombie owner's summon
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 22:43:52 -03:00
Vinícius MoreiraandClaude Opus 5.5 75ef2801f5 A nested blast drops the TNT owner; the zombie owner stays and leaves no drops
A native explosion inside a TNT blast is Level.explode(null, ...): it
now takes blast_owner, blast_guest and blast_owner_player aside with
the rest of the blast's state. The group keeps its zombie persistent
and clears the zombie's drops.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 22:43:46 -03:00
MSedgeMC e6eeff4d10 port_map: SliderableEnum.fromSliderValue's floor mapping 2026-11-11 01:41:38 +00:00
MSedgeMC 811ea89472 test: the graphics preset slider's four stops
0.24, 0.25, 0.49, 0.5, 0.74, 0.75 and 1.0 pick Fast/Fancy/Fabulous/Custom by
the floor mapping, and the value setter moves the preset through them.
2026-11-11 01:41:38 +00:00
MSedgeMC 5f51a346c8 fix: the graphics preset slider uses vanilla's floor stops
OptionInstance.SliderableEnum.fromSliderValue maps a slider position by
floor(v * values.size()), with 1.0 just below, and clamps to the last value:
the four GraphicsPreset values change at 0.25, 0.5 and 0.75. Horizon rounded
v * 3, so dragging to 0.2 picked Fancy instead of Fast and 0.8 Fabulous
instead of Custom.
2026-11-11 01:41:38 +00:00
KBroneZandClaude Opus 5.5 a1bc8fc608 fix: a client plays with the server's block and item tags
The client kept only the dialog tags of ClientboundUpdateTagsPacket and
read block and item tags from the game's own files, so a data pack's or
plugin's tags (climbable, mineable, ...) never reached a player on a
server. Dynamic now keeps every registry's tags; a server session builds
the game's Items with the server's block and item tags in place of its
own (by name, through the built-in registries), and Game::items() reads
it. The play-state packet after /reload replaces them the same way
(ClientPacketListener.handleUpdateTags).

The server tables (the synced enchantments from #9301 and now the items)
move to game/remote_tables.rs, so remote.rs shrinks.

Closes #7843

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 03:39:00 +02:00
Vinícius MoreiraandClaude Opus 5.5 699cf1101f A mob-owned TNT's blast pops no block experience
PrimedTnt.getOwner names the mob a saved owner UUID points at, so
dropExperience (the indirect source is a player) is false. Keep
whether the owner is a player beside blast_owner (a chain reaction
hands a mob owner on as a mob), and compare only whether orbs came
out, since they may merge.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 22:33:19 -03:00
MSedgeMC 59a74c49ac port map: the citations the later splits left behind (feeding, step, section_fade, remote_removal, minecarts/nbt, Fox.SleepGoal) 2026-11-11 01:18:03 +00:00
DUzzL ee581f9834 Rabbit: start the move control in MOVE_TO towards (0, 0, 0) like the constructor's setSpeedModifier(0.0) 2026-11-11 02:12:23 +01:00
MSedgeMC 22f000349e Merge branch 'fix/breathe-under-water' of https://github.com/MSedgeMC/horizon into fix/breathe-under-water 2026-11-11 01:10:17 +00:00
MSedgeMC 8101d02e21 Merge remote-tracking branch 'upstream/main' into fix/breathe-under-water
# Conflicts:
#	crates/horizon-client/src/mobs/ai.rs
2026-11-11 01:10:11 +00:00
MSedgeMC 4919ef98dd Restore the weapon component test the merge dropped 2026-11-11 01:07:56 +00:00
MSedgeMC e723d9ea15 Merge main: keep weapon_damage beside weapon_disable_blocking 2026-11-11 01:07:39 +00:00
MSedgeMC 07af125581 Merge main; keep both sides' tests and module declarations 2026-11-11 01:02:53 +00:00
DUzzL 822fbc2af9 port-pack: read a lone placeholder mod id template instead of failing with a TOML error 2026-11-11 02:00:40 +01:00
Vinícius MoreiraandClaude Opus 5.5 9c31a83fed A comparator placed with OutputSignal by /setblock keeps it
BlockInput.place loads the tag into the comparator's block entity, and
ComparatorBlockEntity.loadAdditional reads getIntOr(OutputSignal, 0).
Horizon keeps a comparator's output beside the block entities and only
/data wrote it; /setblock and /fill now load it through the same
load_comparator_output.

Fixes #13870

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 21:59:37 -03:00
Vinícius MoreiraandClaude Opus 5.5 5e5258f410 A player's blast pops the experience of what it breaks
Block.onExplosionHit passes dropExperience = the explosion's indirect
source is a player (a primed TNT's owner) to spawnAfterBreak, so ores,
sculk and spawners blasted by a player's TNT sample and pop their
experience while blocks drop. Horizon popped none.

Part of #11330

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 21:59:33 -03:00
MSedgeMC a899c56c45 Merge main; keep both sides' rows 2026-11-11 00:59:17 +00:00
MSedgeMC cff772e57a Merge main; keep both sides' rows 2026-11-11 00:59:15 +00:00
MSedgeMC 1165a5c119 Merge main; keep both sides' rows 2026-11-11 00:59:13 +00:00
KBroneZandClaude Opus 5.5 c25d5e6d71 fix: a player's copper chest plays its own hinge sound
container_edge_sound sent every block named *chest* to block.chest.open
and close. Vanilla's ChestBlockEntity opener counter plays the block's
getOpenChestSound/getCloseChestSound, which a copper chest takes from
CopperChestBlock.getHingeSound by its weather state. The copper golem's
transport path already had that mapping; it moves to
transport_items::chest_hinge_sound and both paths use it.

Closes #15039

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 02:53:12 +02:00
MSedgeMC d9672a3587 Merge remote-tracking branch 'upstream/main' into fix/jigsaw-priority-tooltip
# Conflicts:
#	tools/port_map/client2-016.tsv
2026-11-11 00:48:02 +00:00
MSedgeMC e9198b0004 Merge main: the air block stays in ai/air.rs 2026-11-11 00:45:17 +00:00
MSedgeMC 59d9424230 Merge upstream/main into fix/command-block-edit-mark 2026-11-11 00:42:21 +00:00
MSedgeMC fe8bffe676 Merge remote-tracking branch 'upstream/main' into fix/vex-dead-owner-target
# Conflicts:
#	tools/port_map/game2-003.tsv
2026-11-11 00:41:48 +00:00
MSedgeMC f5541f7c45 Merge upstream/main into fix/social-search-caret 2026-11-11 00:41:23 +00:00
Evan-lhmandClaude Opus 5.5 bf26fb0d64 cem.rs compiles on main again (the same change as #15042)
The list of layer keys shadowed the own-file map, so main stopped compiling after #14658.
Identical to #15042, so the next merge of main takes it without a conflict.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 01:40:10 +01:00
Evan-lhm e1255c4465 Merge remote-tracking branch 'origin/main' into rt/acceleration-structures 2026-11-11 01:29:58 +01:00
DUzzL 2a94e35372 End crystal tick clears a loaded Fire (Entity.applyEffectsFromBlocks, fire immune) 2026-11-11 01:21:07 +01:00
DUzzL 0e9f555a5b Keep a camel's saved LastPoseTick an absolute game time while it is not ticking 2026-11-11 01:20:10 +01:00
Nikita Maximilian Grimm 8b16965108 Merge remote-tracking branch 'origin/main' into shader-api-linear-blend
# Conflicts:
#	crates/horizon-client/src/game/native_shaders.rs
#	docs/api/shaders.md
2026-11-11 01:17:01 +01:00
DUzzL f306365eb8 Keep Air and Fire on wind charges and breeze wind charges when saving and loading 2026-11-11 01:12:27 +01:00
rdbtCVSandCodex f37bcae92c Scale culling margins with the mob model root
Include the root model scale in the appendage margin so animated elder guardian vertices remain inside the precheck bounds. The existing prepared-model coverage test remains unchanged.

Co-Authored-By: Codex (gpt-6.1-sol) <[email protected]>
2026-11-10 23:55:39 +00:00
Nikita Maximilian GrimmandClaude Opus 5.5 6e036e8918 Merge origin/main into projectile-uuid-draws
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 00:52:29 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 68a4fb2261 Merge origin/main into typed-randoms-1a
mobs.rs: main (#14857) moved the yaw draw out of living_random into
Mob::with_random. Main's version stays, returning the random as an
EntityRandom as before.

mobs/zombie_villager.rs: main split the test fixture and added tests
that hand Mob::with_random a plain LegacyRandom. Main's side stays; the
fixture seeds the mob's random with set_seed, as it did here, and the
new tests wrap theirs with EntityRandom::own.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 00:51:31 +01:00
DUzzL 27b18c07f0 Merge remote-tracking branch 'upstream/main' into rv/r14566 2026-11-11 00:45:19 +01:00
rdbtCVSandCodex 04b53013c8 Fix CEM own-model lookup shadowing
Keep the own-file map available when selecting the layers covered by a model.

Co-Authored-By: Codex (gpt-6.1-sol) <[email protected]>
2026-11-10 23:45:10 +00:00
Nikita Maximilian Grimm 41e78cbe12 Merge remote-tracking branch 'origin/main' into hdr-shaders-pack-no-lift
# Conflicts:
#	docs/graphics.md
2026-11-11 00:40:08 +01:00
rdbt 5070174aef Merge pull request #14707 from masa-masa-0314/docs/friends-overlay-part2-spec
Docs: propose the Friends overlay Part 2 plan
2026-11-10 23:31:27 +00:00
rdbt 5841fdc1e7 Merge pull request #14658 from readerkid/fix/cem-armour-layers
Map CEM's armour models to the layers 26.3 draws, and the rabbit's feet
2026-11-10 23:31:23 +00:00
rdbt 2093fb4d0d Merge pull request #14627 from MrCloudy2/fix/piston-cactus-vehicles
A cactus pricks a boat or a minecart a piston pushes
2026-11-10 23:31:19 +00:00
rdbt 4e84f9e7fe Merge pull request #14623 from MrCloudy2/fix/projectile-hits-tnt
A projectile hits primed TNT
2026-11-10 23:31:14 +00:00
rdbt 6d45e61cdc Merge pull request #14621 from MrCloudy2/fix/wind-bursts-all-push
Every wind burst of a tick pushes an entity that ticks after the projectiles
2026-11-10 23:31:10 +00:00
rdbt 960c03a4a1 Merge pull request #14619 from MrCloudy2/fix/projectile-fluid-current
A flying projectile is pushed by the water and lava it touches
2026-11-10 23:31:06 +00:00
rdbt 7618cdca8e Merge pull request #14533 from MrCloudy2/fix/geyser-through-scaffolding
A geyser goes through scaffolding seen from below
2026-11-10 23:30:59 +00:00
rdbt 31c02b8f59 Merge pull request #14513 from Detimes/gap-line-of-sight
Native hooks: line of sight between entities and a clear-sight block ray
2026-11-10 23:30:55 +00:00
rdbt 67c6dfbca9 Merge pull request #14512 from Detimes/gap-riding-getters
Native hooks: read the vehicle an entity rides and its passengers
2026-11-10 23:30:51 +00:00
rdbt 7ef8abd367 Merge pull request #14510 from Detimes/gap-server-props-read
Native hooks: read the dedicated server's online mode, port and proxy forwarding
2026-11-10 23:30:47 +00:00
rdbt 63f20e8f30 Merge pull request #14498 from MrCloudy2/fix/geyser-launches-projectiles
A geyser launches projectiles, boats and minecarts
2026-11-10 23:30:42 +00:00
rdbt f25fdf5828 Merge pull request #14490 from MSedgeMC/fix/wither-side-head-los
A wither's side head does not pick targets through a wall
2026-11-10 23:30:38 +00:00
rdbt 87ef5ed8d9 Merge pull request #14484 from MrCloudy2/fix/wind-charge-hurts-vehicles
A wind charge's hit damages a boat or a minecart
2026-11-10 23:30:33 +00:00
rdbt 31e1d6aedf Merge pull request #14462 from ilguerro/fix/spilled-items-pop-up
Items spilled from a destroyed shulker box or bundle pop upward again
2026-11-10 23:30:29 +00:00
rdbt cc4067a168 Merge pull request #14441 from voduff/fix/12038-cube-body-item-replace
A sulfur cube's body takes a swallowable block through /item replace
2026-11-10 23:30:25 +00:00
umcekoandClaude Opus 5.5 a97cefe7f7 Merge main into dims/render-facts
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 02:30:21 +03:00
rdbt f7c9520898 Merge pull request #14437 from J5Casey/fix/synced-recipe-display-numbers
Recipe book numbers a server's synced recipes as the server does
2026-11-10 23:30:21 +00:00
umcekoandClaude Opus 5.5 94279ded10 Merge main into dims/portal-scales
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 02:30:18 +03:00
rdbt 417728114c Merge pull request #14430 from horizon-rust/split/crate-lang
Crates ship their own lang files: horizon_platform::lang (layer plan M5)
2026-11-10 23:30:17 +00:00
rdbt 1f2fd92867 Merge pull request #14402 from vnsmoreira/packs/polytone-physics-spawn
Polytone: a particle with physics born inside a block never joins
2026-11-10 23:30:13 +00:00
rdbt b955f83e5b Merge pull request #14401 from DUzzL/fix/14382-jmath-pow-nan
Fix jmath::pow NaN special cases to match Java Math.pow
2026-11-10 23:30:09 +00:00
rdbt 916dd0cebb Merge pull request #14332 from yahya-nusseibeh/fix/registry-rusqlite-0.41
Mod registry: bundle SQLite 3.54 through rusqlite 0.41
2026-11-10 23:30:04 +00:00
rdbt b4977c3700 Merge pull request #14277 from Detimes/gap-mob-death-message
Native hooks: living_death_message (mob death message)
2026-11-10 23:30:00 +00:00
rdbt 8cbfc508ae Merge pull request #14271 from Detimes/gap-entity-lookup
Native hooks: read-only entity lookup by UUID and entity data in any level
2026-11-10 23:29:55 +00:00
rdbt 1843b792ff Merge pull request #14270 from Mik00000/perf/lighting-propagation-batch
perf(light): reuse scratch queues during incremental relight and preserve lazy section loading
2026-11-10 23:29:51 +00:00
rdbt 8b6513f833 Merge pull request #14021 from MSedgeMC/fix/weapon-damage-component
Weapon wear comes from a stack's weapon component
2026-11-10 23:29:47 +00:00
rdbt 3895a99900 Merge pull request #13988 from MSedgeMC/fix/banner-layer-codec
A banner with an invalid layer loads no patterns
2026-11-10 23:29:42 +00:00
rdbt 70f3965c0a Merge pull request #13761 from TheMrZZ/fix/h194-item-count-predicate-2
Match item count equality against the actual stack
2026-11-10 23:29:38 +00:00
rdbt e51f313b02 Merge pull request #11559 from voidsipp/fix/11329-villager-reputation-events
Use cached villager murder witnesses
2026-11-10 23:29:34 +00:00
rdbt 339bda5ed2 Merge pull request #11536 from voidsipp/fix/11336-potion-contents
Expire custom potion contents on tipped arrows
2026-11-10 23:29:29 +00:00
rdbt 0cc2963ccf Merge pull request #11484 from voidsipp/fix/11318-portal-cooldowns
Keep entity-specific Nether portal cooldowns
2026-11-10 23:29:25 +00:00
rdbtCVSandOpenCode GPT 6.1 Sol ad43388bb5 Merge additive helper-test dependency from mob-loop-group
Includes unchanged test-only cherry-pick of 41a579d428 as 5cc88b517b. Preserve original implementation, contributor credits, and ordered-RNG dependency history.

Co-Authored-By: OpenCode GPT 6.1 Sol <[email protected]>
2026-11-10 23:28:33 +00:00
rdbt 1b27a37702 Merge pull request #15009 from DUzzL/fix/14956-powder-snow-order
Mobs: tick freezing after the move and block effects
2026-11-10 23:26:54 +00:00
rdbt d4d833f199 Merge pull request #14945 from CocofireHD/fix/14857-zombie-villager-profession
A zombie villager's profession is the constructor's draw from its own random (#14857)
2026-11-10 23:26:50 +00:00
rdbt 1101314498 Merge pull request #14775 from NongTham/fix/14664-bed-explosion-flying-kick
test: allow-flight in bed_explosion parity harness (fixes #14664)
2026-11-10 23:26:46 +00:00
rdbt 68b4c07179 Merge pull request #14549 from vivescene/docs/agents-review-when-asked
AGENTS.md: review pull requests only when asked, and submit a decision
2026-11-10 23:26:42 +00:00
rdbt 395789e107 Merge pull request #14509 from Detimes/gap-map-colors-read
Native hooks: read a map's colours (native_map_colors)
2026-11-10 23:26:37 +00:00
rdbt 9223dc0e30 Merge pull request #14446 from Mayonesu0/up-12937-dispenser-equip
Dispensers equip server players and respect armor stand slot locks
2026-11-10 23:26:33 +00:00
rdbt d9fe05af0d Merge pull request #14439 from horizon-rust/split/friends-title-icon
Title screen's Friends icon is inactive without an invite-code resolver (spec #14167 step 4)
2026-11-10 23:26:29 +00:00
rdbt f02798aed5 Merge pull request #14130 from MSedgeMC/port_map/destroy-progress-and-daylight
port map: the destroy-progress stage and the daylight ticker rows are ports
2026-11-10 23:26:25 +00:00
rdbt 900933aae2 Merge pull request #13762 from TheMrZZ/fix/h211-shulker-drop-lock-2
Preserve external locks on destroyed shulker boxes
2026-11-10 23:26:21 +00:00
rdbt 2ef31a7618 Merge pull request #15014 from darkliro/perf/linux-hybrid-cores
threads: detect performance cores on Linux hybrid CPUs
2026-11-10 23:26:03 +00:00
rdbt cbf533a8ae Merge pull request #14360 from NikitaMGrimm/frame-data-count
/data on an item frame detaches its old map when only the stack's count changes
2026-11-10 23:25:53 +00:00
rdbt ccc233474d Merge pull request #13753 from glitchsnake/fix/12935-sculk-particle-resume-reload
sculk: resume vibration particle on reload (fixes #12935)
2026-11-10 23:25:25 +00:00
MSedgeMC 02ae7bfa26 Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-10 23:21:20 +00:00
MSedgeMC 0ba1641e9c Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-10 23:21:18 +00:00
MSedgeMC 7bf8f4b226 Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-10 23:21:16 +00:00
MSedgeMC a06a1853e2 Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-10 23:21:13 +00:00
rdbt 1b16aaecbf Merge pull request #15027 from NikitaMGrimm/fix/hold-fullbright-replay
Main compiles again: hold_fullbright's replay check and the firework tests' new_rocket calls
2026-11-10 23:20:47 +00:00
DUzzL ba8b8cda4a Merge upstream/main into fix/14466-fireball-rotation 2026-11-11 00:19:23 +01:00
KBroneZandClaude Opus 5.5 15dc5e301c test: keep the remote anvil test beside anvil.rs, stations.rs not growing
stations.rs is over the 3000-line limit and may not grow
(docs/layers.md#file-size): the test module hangs off stations/anvil.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 01:18:36 +02:00
b972efd9fa test: prove item work-ahead collisions, RNG and native fallback
Retain the 307-item corpus and add real boat and border cases, independently expected RNG advances, the environment gate and off/on/off comparison. Reject count, age and pickup-delay mutations independently and retain actual merge invalidation. Exercise 300 nonmerging opaque-component stacks in each native contact mode.

Repair the existing escape test's baseline chunk fixture and promote vanilla's 0.98F drag to double in its expected value; no production gameplay change.

Co-Authored-By: Kacper <[email protected]>
Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-10 23:17:06 +00:00
MSedgeMC d9cf87a5a8 fix: the lectern's book change posts unconditionally, as resetBookState does
`LecternBlock.resetBookState` calls `setBlockAndUpdate` and then posts
`gameEvent(BLOCK_CHANGE, Context.of(entity, state))` with no condition on the
state having changed; `placeBook` hands the placing entity to it and
`resetBookState`'s other caller passes none. Horizon's helper only posted when
the state differed, so the gate is gone: the post follows the property write.
2026-11-10 23:13:27 +00:00
MSedgeMC c635024f69 Merge remote-tracking branch 'upstream/main' into fix/lectern-book-block-change
# Conflicts:
#	crates/horizon-client/src/game/books.rs
2026-11-10 23:13:27 +00:00
8a4ca5e7de Merge helper verification into dependent item moves
Preserve helper test commit 41a579d428 and the item phase's original history and authors.

Co-Authored-By: Kacper <[email protected]>
Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-10 23:08:41 +00:00
Nikita Maximilian GrimmandClaude Fable 5.2 2160d3e4d1 The firework event tests pass new_rocket its random
#14603 gave new_rocket the rocket's own random as a fourth argument; the
tests added by 188817342 still called it with three, so the client's tests
stopped compiling on main.

Refs #15025

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-11 00:08:01 +01:00
MSedgeMC 2760a81580 Merge remote-tracking branch 'upstream/main' into fix/fox-alert-visibility
# Conflicts:
#	crates/horizon-client/src/mobs/fox.rs
#	tools/port_map/game-012.tsv
2026-11-10 23:03:54 +00:00
umcekoandClaude Sonnet 5.5 1ab052ca3c Portals and execute in scale coordinates by the dimension types (#11282, 6/9)
Vanilla scales coordinates between levels by DimensionType.getTeleportationScale
(NetherPortalBlock.getExitPortal, CommandSourceStack.withLevel), which reads each
dimension type's coordinate_scale. portal_plan, start_server_trip and /execute in
now take the two types' scales instead of hand-written 8 and 1/8.

/execute in looks the level up through known_level, which names the levels the
server runs.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 02:03:52 +03:00
Nikita Maximilian Grimm 4536ec51ca Merge remote-tracking branch 'origin/main' into fix/hold-fullbright-replay 2026-11-11 00:01:35 +01:00
DUzzL 67e0a30ea8 Merge remote-tracking branch 'upstream/main' into rv/r14566 2026-11-10 23:59:43 +01:00
DUzzL 02d449ced2 Merge upstream/main into fix/14461-firework-dispenser-rotation 2026-11-10 23:59:29 +01:00
Evan-lhmandClaude Opus 5.5 e8e1971422 Ray queries: the quad index buffer moves to ray_query.rs (gfx.rs may not grow)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 23:58:55 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 b02f367216 Merge origin/main into projectile-uuid-draws
dispenser.rs: main (#14603) moved the firework rocket out of the match
into launch_angled_rocket, which makes the rocket's own random. This
branch only renamed the random argument in that arm, so main's version
stays and the arm goes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 23:58:45 +01:00
DUzzL 29bfd0e362 Fix dispense position float order to match vanilla 2026-11-10 23:57:50 +01:00
umcekoandClaude Sonnet 5.5 d07042696f Sky, shading and ambient light follow the dimension type (#11282, 7/9)
A datapack dimension (`data/<ns>/dimension/`) gets its own `DimensionType`, and
vanilla's renderer reads the skybox, cardinal light, ambient light and
attributes from it, not from the level's key. The client's draw choices now
read the level's dimension type facts the same way.

- Cardinal light picks the diffuse lights and the section shading; the type's
  `has_skylight` picks the section sky light.
- The skybox picks the sun, moon, stars and clouds, the End sky and its flashes,
  the sun shadows and the shader sky type; the ambient light picks the entity
  shadow darkening.
- The day timeline, `water_evaporates` and `piglins_zombify` pick the firefly
  bush and creaking effects, the dripstone particle and the piglin conversion
  shake; the minimap reads the level shape's ceiling.
- `Resources::environment_of` picks the environment table by dimension type id.

For the Overworld, the Nether and the End every site returns what it did before.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 01:57:34 +03:00
Nikita Maximilian GrimmandClaude Opus 5.5 20ea6e500e Merge origin/main into typed-randoms-1a
tnt.rs: main wrapped the silent bottle test in on_big_stack. Main's
side stays, and the test seeds the level's random with set_seed, as it
did here.

Tests main added since assign a plain LegacyRandom to World.random or
Mob.random (breezes, bred_draws, fireworks_events_tests, mounts,
turtle). They wrap it with LevelRandom::for_level or EntityRandom::own.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 23:56:27 +01:00
rdbtCVS 5cc88b517b test: cross parallel tick helper thresholds with real resources
Compare deferred and in-place metadata for 2,085 varied mobs, including pack-selected effect particles and switch-off vector reuse. Exercise live climbing, snapshot order and nearest-player invalidation for 293 mobs. Build 132 random-tick section masks ahead and retain exact event, state and random comparisons through edits and reloads.

Co-Authored-By: Kacper <[email protected]>
Co-Authored-By: GPT 6.1 Sol <[email protected]>
(cherry picked from commit 41a579d428)
2026-11-10 22:55:55 +00:00
Evan-lhmandClaude Opus 5.5 4591f41aeb Merge origin/main into rt/acceleration-structures
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 23:55:00 +01:00
Nikita Maximilian GrimmandClaude Fable 5.2 72d1238876 hold_fullbright asks the replay whether it plays
#14335 read Game::studio_replay, which the layer split had moved to
Game::replay before it merged, so main stopped compiling.

Fixes #15025

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-10 23:51:58 +01:00
rdbt 214469a4bf Merge pull request #14737 from ilguerro/fix/sitting-cat-blocks-comparator
Silence a chest's comparator while a cat sits on it
2026-11-10 22:51:41 +00:00
Nikita Maximilian Grimm 370af10365 Merge remote-tracking branch 'origin/main' into parity/noai-tadpole-ages
# Conflicts:
#	crates/horizon-client/src/mobs/ai.rs
#	crates/horizon-client/src/mobs/aquatic.rs
2026-11-10 23:51:39 +01:00
MSedgeMC 761650fe14 Merge remote-tracking branch 'upstream/main' into fix/breathe-under-water
# Conflicts:
#	crates/horizon-client/src/mobs/ai.rs
#	tools/port_map/game2-003.tsv
2026-11-10 22:51:20 +00:00
rdbt b2bfa61fb1 Merge pull request #14987 from DUzzL/fix/14856-shulker-armor
A shulker keeps its armor attribute once its lid has moved
2026-11-10 22:49:57 +00:00
rdbt 5f4dbb058b Merge pull request #14969 from vermingov/fix/villager-food-level-14967
A villager narrows a loaded FoodLevel to a byte (#14967)
2026-11-10 22:49:50 +00:00
rdbt 292dfe14e1 Merge pull request #14955 from DUzzL/fix/14832-zombie-villager-cure
A zombie villager starts its cure when it loads a ConversionTime
2026-11-10 22:49:45 +00:00
rdbt 506624b951 Merge pull request #14951 from Web-mc1/fix/biome-get-empty
Fix biome table panic when empty
2026-11-10 22:49:39 +00:00
rdbt e7fb465789 Merge pull request #14935 from TheMrZZ/slot-sources/holder-tag-codec
Slot sources: holder and tag codec, and slot-source tags in DataStore
2026-11-10 22:49:33 +00:00
rdbt 31af1ed8ad Merge pull request #14932 from Detimes/perf-replay-excuse
Perf: the replay excuse tests the distance before the entity kind
2026-11-10 22:49:28 +00:00
rdbt af3a8d235b Merge pull request #14928 from MSedgeMC/fix/crafter-recipe-book
port: a crafter's grid counts for the recipe book
2026-11-10 22:49:22 +00:00
rdbt 3c564f0e2b Merge pull request #14910 from oloion/fix/custom-crafting-recipes-book
client: show custom server crafting recipes in recipe book
2026-11-10 22:49:17 +00:00
rdbt 45c4e0d744 Merge pull request #14900 from MSedgeMC/fix/jukebox-block-change
port: a disc coming or going posts block_change
2026-11-10 22:49:12 +00:00
rdbt 802056c402 Merge pull request #14892 from NikitaMGrimm/shader-api-primaries-key
Shader API 0.2: [frame] primaries, and the rule for new names
2026-11-10 22:49:06 +00:00
rdbt fc604a958a Merge pull request #14785 from MSedgeMC/fix/merchant-scroller-cursor
The merchant's trade scroller shows its cursor
2026-11-10 22:49:01 +00:00
rdbt 8f98a65be5 Merge pull request #14776 from NongTham/fix/14715-modifier-key-handling
input: clear modifiers on focus loss and heal alt and super keys (fixes #14715)
2026-11-10 22:48:56 +00:00
rdbt 75dc9db341 Merge pull request #14772 from foudretbr/fix/attack-speed-attribute-modifiers
fix(game): evaluate attack_speed attribute modifiers for player attack cooldown (#14721)
2026-11-10 22:48:51 +00:00
rdbt eb161b9c99 Merge pull request #14745 from MSedgeMC/fix/feed-never-baby
A mob that cannot be a baby does not take the food that grows it
2026-11-10 22:48:44 +00:00
rdbt 7687b2a619 Merge pull request #14742 from foudretbr/fix/modifier-key-debug-shortcuts-focus-loss
fix(input): reset debug keys modifier state on window focus loss (#14715)
2026-11-10 22:48:39 +00:00
rdbt 1d50e158b9 Merge pull request #14723 from ilguerro/fix/firework-silent-explode-stat
Firework rockets: honour Silent, post EXPLODE, count block launches
2026-11-10 22:48:33 +00:00
rdbt 58b5d93f8f Merge pull request #14710 from glitchsnake/fix/14467-squid-noai-motion
mobs: apply squid sinking velocity on land under NoAI (fixes #14467)
2026-11-10 22:48:28 +00:00
rdbt 9951ef958d Merge pull request #14705 from Skr1zzzz/falling-model-cache
Falling blocks: cache each model's vertices instead of rebuilding them every frame
2026-11-10 22:48:22 +00:00
rdbt 8cb5c02d4b Merge pull request #14703 from ilguerro/fix/horses-do-not-climb
Stop horses, llamas, camels and flying monsters climbing vines and ladders
2026-11-10 22:48:17 +00:00
rdbt b75ffd9554 Merge pull request #14699 from NikitaMGrimm/hdr-pq-dither
HDR10 output: the last pass dithers its 10 bits, so a clear sky doesn't band
2026-11-10 22:48:12 +00:00
rdbt c7552a32e5 Merge pull request #14697 from ilguerro/fix/phantoms-glass-roof
Spawn phantoms under a glass roof
2026-11-10 22:48:07 +00:00
rdbt ff03932fb0 Merge pull request #14693 from ilguerro/fix/turtle-frog-lay-events
Post block_place when turtles and frogs lay eggs, and stop bred turtles bursting hearts
2026-11-10 22:48:02 +00:00
rdbt 6a007e8e02 Merge pull request #14691 from ilguerro/fix/campfire-candle-douse
Sneaking keeps the held food off campfires and candles answer pick-block
2026-11-10 22:47:57 +00:00
rdbt a4b71ca8ba Merge pull request #14689 from ilguerro/fix/shulker-teleport-vibration
Post a shulker teleport's game event for sculk sensors
2026-11-10 22:47:52 +00:00
rdbt 1386a7fd1f Merge pull request #14677 from YourPancakes/perf/skip-full-cube-shape-allocation
perf(world): avoid full-cube allocation in collision checks
2026-11-10 22:47:47 +00:00
rdbt 5f029bb260 Merge pull request #14672 from YourPancakes/perf/cache-flat-compute-bind-groups
perf(render): cache flat compute bind groups
2026-11-10 22:47:42 +00:00
KBroneZandClaude Opus 5.5 57b2669f0a fix: preview the anvil with a server's data-pack enchantments
On a server whose data pack adds an enchantment, the anvil showed the red
cross for a book of it: the client's result prediction read the world's
own enchantment table, which lacks the entry. AnvilMenu.createResult on
the client reads the synced registry, so the station previews and the menu
engine now read Game::enchantments(), the server's table in a server
session and the world's otherwise.

Closes #8688

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 00:47:40 +02:00
rdbt 23a2b65c29 Merge pull request #14667 from vnsmoreira/fix/dispensed-item-random
A dispensed item draws its constructor's motion from its own random
2026-11-10 22:47:37 +00:00
MSedgeMC 2e8cc55201 Merge remote-tracking branch 'upstream/main' into fix/walk-target-scores
# Conflicts:
#	crates/horizon-client/src/mobs/ai.rs
2026-11-10 22:47:33 +00:00
rdbt 62165d135d Merge pull request #14665 from YourPancakes/perf/sky-octagon-half-planes
perf(render): evaluate sky octagon with half-planes
2026-11-10 22:47:31 +00:00
rdbt 2732d737a1 Merge pull request #14653 from Ragbie/tests/pad-parity-actions
pad::parity_tests: the pad_press helper records the press, so four tests pass again
2026-11-10 22:47:25 +00:00
rdbt a1807b93a1 Merge pull request #14629 from Ragbie/tests/tnt-silence-stack
TNT silence tests run on an 8 MiB stack, so they don't overflow on Windows
2026-11-10 22:47:18 +00:00
rdbt 53c0b0694b Merge pull request #14602 from ilguerro/fix/sniffer-dig-vibration-silence
Keep animal_brain.rs at main's line count
2026-11-10 22:47:13 +00:00
rdbt cc3e92cadd Merge pull request #14600 from ilguerro/fix/bred-wolf-voice
Move the bred offspring's draws and their test out of creatures.rs
2026-11-10 22:47:08 +00:00
rdbt 27e0787a23 Merge pull request #14592 from NikitaMGrimm/parity/bucket-mob-from-bucket
A mob from a bucket is not persistent, a sulfur cube keeps its bucket's age, and tadpoles never despawn
2026-11-10 22:47:02 +00:00
rdbt f312c30454 Merge pull request #14585 from foudretbr/fix/cat-default-variant-black
fix(mobs): default cat variant to black when omitted
2026-11-10 22:46:57 +00:00
rdbt a79db77ac1 Merge pull request #14573 from MSedgeMC/ledger/moved-citations
port map: point citations at the modules they moved to
2026-11-10 22:46:52 +00:00
rdbt 5fde1275b5 Merge pull request #14556 from ilguerro/fix/wall-hanging-sign-click-command
Run a waxed wall hanging sign's click command instead of chaining a sign
2026-11-10 22:46:46 +00:00
rdbt 7ebdfbc3ee Merge pull request #14508 from Ragbie/tests/mob-landing-neighbourhood
mob_landing_support_tests load the chunks a pig needs to tick
2026-11-10 22:46:41 +00:00
rdbt 9e4e43a69b Merge pull request #14496 from horizon-rust/fix/service-images-path-crates
Telemetry and mod registry images copy every crate their lockfile builds
2026-11-10 22:46:35 +00:00
rdbt 78f286e657 Merge pull request #14492 from Ragbie/fix/landing-vibrations
Primed TNT and falling blocks make a vibration when they land
2026-11-10 22:46:28 +00:00
rdbt 71e8f7c8cd Merge pull request #14489 from MSedgeMC/fix/wololo-visibility
An invisible blue sheep is not wololoed from five blocks
2026-11-10 22:46:23 +00:00
rdbt 91429d906a Merge pull request #14488 from MSedgeMC/fix/sulfur-tempt-visibility
A sulfur cube's tempt range shrinks for an invisible player
2026-11-10 22:46:17 +00:00
rdbt 846e34c2eb Merge pull request #14486 from vnsmoreira/fix/golden-horse-armor
A horse draws its golden armour
2026-11-10 22:46:10 +00:00
rdbt a084ad2f35 Merge pull request #14468 from Solenopsisbot/fix/high-contrast-pack-hang
Fix startup hang with High Contrast resource pack
2026-11-10 22:46:05 +00:00
rdbt 10588c9b4c Merge pull request #14448 from ilguerro/fix/frame-vibrations
Let sculk hear item frame changes as vanilla does
2026-11-10 22:46:00 +00:00
rdbt 8ad2bee66a Merge pull request #14434 from MSedgeMC/fix/written-book-resolve
port: a written book resolves its selectors and scores when it is opened
2026-11-10 22:45:54 +00:00
rdbt 3e8dbbc2df Merge pull request #14423 from YourPancakes/perf/skip-swim-below-block-reads
perf(mobs): skip redundant swim block reads
2026-11-10 22:45:49 +00:00
rdbt bbe1ec5372 Merge pull request #14415 from YourPancakes/perf/villager-item-sensing-index
perf(mobs): reuse item cells for villager sensing
2026-11-10 22:45:43 +00:00
rdbtCVSandOpenCode GPT 6.1 Sol 316e0ee2b0 Mobs: join island workers and propagate their original panic payload
Retain every scoped worker handle, release the first-lane guard, join all workers, then resume the first original worker payload. Extend the production-path injected spawn/setup/mid-turn regression to assert exact payloads as well as timeout-bounded release.

Co-Authored-By: OpenCode GPT 6.1 Sol <[email protected]>
2026-11-10 22:45:42 +00:00
rdbt 1f6cf55e1c Merge pull request #14413 from MSedgeMC/fix/shulker-neighbor-updates
port: a shulker box lid updates its neighbours as it moves
2026-11-10 22:45:38 +00:00
rdbtCVSandOpenCode GPT 6.1 Sol e725b49629 Mobs: prove startup release and merge snapshots, with serial raid fallback
Inject later-spawn and worker-setup failure through the production island runner; keep first and pending guards inside the scope callback. Compare independent cloned Gaussian/long outcomes and final group snapshots. Keep raid-member ticks serial in both production and verify so the serial diagnostic copy cannot mutate shared hurt ordering. Cover one-CPU fallback and preserve the original ticked check.

Co-Authored-By: OpenCode GPT 6.1 Sol <[email protected]>
2026-11-10 22:42:35 +00:00
MSedgeMC 9ce725cca5 fix: the social screen's click routing names the class that dispatches it
`SocialInteractionsScreen` declares no `mouseClicked` at all: its clicks come
from `ContainerEventHandler.mouseClicked`, the inherited child dispatch, which
reaches the search box's `EditBox.onClick` before the tabs, the rows' buttons
and Done. The body and the port map had attributed that to an override on the
screen.

`social_screen::click_kind` now answers which child the click belongs to -
`Click::Search` for the box, `Click::Action` for the list - and `Game::social_click`
performs it. The test drives `click_kind`: both corners of the box are the
box's, the pixels left of and just below it are nobody's, and Done's centre is
the list's `Action::Done`. Without the search branch the two inside corners
read `None`.

The caret move itself still needs the GUI atlas (`EditBox::click`), so it sits
in `Game::social_click` and is not asserted: every `Game` test in this crate
needs prepared resources and is `#[ignore]`d.
2026-11-10 22:40:35 +00:00
DUzzL fd0574f5cf Keep an experience orb's Air through load and save 2026-11-10 23:39:58 +01:00
Nikita Maximilian Grimm f9c3e85868 Merge remote-tracking branch 'origin/main' into parity/ominous-spawner-loaded-rotation
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-10 23:36:02 +01:00
41a579d428 test: cross parallel tick helper thresholds with real resources
Compare deferred and in-place metadata for 2,085 varied mobs, including pack-selected effect particles and switch-off vector reuse. Exercise live climbing, snapshot order and nearest-player invalidation for 293 mobs. Build 132 random-tick section masks ahead and retain exact event, state and random comparisons through edits and reloads.

Co-Authored-By: Kacper <[email protected]>
Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-10 22:32:11 +00:00
Nikita Maximilian Grimm ad3c08df6e Merge remote-tracking branch 'origin/main' into parity/noai-tadpole-ages
# Conflicts:
#	crates/horizon-client/src/mobs/ai.rs
2026-11-10 23:29:39 +01:00
Nikita Maximilian Grimm b555275591 Merge remote-tracking branch 'origin/main' into parity/sulfur-cube-bucket-health
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-10 23:27:21 +01:00
rdbtCVSandOpenCode GPT 6.1 Sol 753df1c63a Mobs: load planner test chunks through ChunkMap's mutation API
Resource-backed islands tests now compile and execute: 12 passed, including real grouped-versus-serial and verify paths. Scoped seed regressions: 3 passed; ordered RNG: 10 passed, benchmark ignored.

Co-Authored-By: OpenCode GPT 6.1 Sol <[email protected]>
2026-11-10 22:25:55 +00:00
rdbtCVSandOpenCode GPT 6.1 Sol a47aa92bcd Merge additive ordered-reseed dependency from mob-loop-group
Includes 0b21fcbf60, the unchanged cherry-pick of 9298d7961c. Preserve the original islands implementation and both contributors' history.

Co-Authored-By: OpenCode GPT 6.1 Sol <[email protected]>
2026-11-10 22:16:32 +00:00
Mik00000 f790048622 fix(client): leave TNT ignition sound and consumption to server on remote flint usage 2026-11-11 00:12:01 +02:00
rdbtCVS 0b21fcbf60 Random: preserve the ordered lane and stream when reseeding
Treat LegacyRandom::set_seed as an ordered mutation: acquire the shared stream before replacing its LCG state and clearing the Gaussian cache, without discarding the gate. This also publishes a reseed-only item's state through end_item and keeps finish_lane and LaneDone working.

Add timeout-bounded multi-lane regressions for reseeding before/after draws, reseed-only items, cross-lane Gaussian state and panic cleanup, plus serial cache-reset coverage. The regression rejects the original gate loss, omitted synchronization and retained Gaussian cache.

Co-Authored-By: Kacper <[email protected]>
Co-Authored-By: OpenCode GPT 6.1 Sol <[email protected]>
(cherry picked from commit 9298d7961c)
2026-11-10 22:08:34 +00:00
cda60d91ae Mobs: bound island lanes and verify scoped seed ordering
Cap waiting lanes at available hardware, release all unstarted lanes on spawn failure, reject invalid planner inputs, and verify full outputs including pending pushes and Gaussian state. Order new mob RNG allocations with level draws; record/replay diagnostic seeds without resetting the global counter or pinning other threads. Add resource-backed planning, visibility, moved-rider and actual verify coverage.

Co-Authored-By: Kacper <[email protected]>
Co-Authored-By: OpenCode GPT 6.1 Sol <[email protected]>
2026-11-10 22:08:15 +00:00
DUzzL 3beead5ef0 Count powder snow freeze ticks on boats and minecarts
InsideBlockEffectType.FREEZE raises Entity.ticksFrozen for every entity
box touching powder snow and Entity.saveWithoutId writes TicksFrozen
while positive. Boats and minecarts never tracked it.
2026-11-10 23:05:08 +01:00
d9ed797708 Heightmaps: clone stale bits before concurrently refreshed heights
A snapshot could copy an old cached height and then a stale bit another reader had just cleared, retaining the old height as clean. Acquire the stale-bit snapshot first: marked columns rescan, and cleared bits publish the refreshed height. A concurrent Chunk snapshot test covers both heightmaps and fails with the original ordering.

Co-Authored-By: Kacper <[email protected]>

Co-Authored-By: OpenCode GPT 6.1 Sol <[email protected]>
2026-11-10 22:03:40 +00:00
darkliroandAntigravity fb9b1d3542 threads: detect performance cores on Linux hybrid CPUs
Count performance cores on Linux systems with asymmetric core frequencies
(such as Intel Alder Lake, Raptor Lake, Meteor Lake and ARM big.LITTLE)
via sysfs cpufreq, bringing parity with the Apple silicon performance core
heuristics from #11294.

Co-Authored-By: Antigravity <[email protected]>
2026-11-10 23:00:02 +01:00
9298d7961c Random: preserve the ordered lane and stream when reseeding
Treat LegacyRandom::set_seed as an ordered mutation: acquire the shared stream before replacing its LCG state and clearing the Gaussian cache, without discarding the gate. This also publishes a reseed-only item's state through end_item and keeps finish_lane and LaneDone working.

Add timeout-bounded multi-lane regressions for reseeding before/after draws, reseed-only items, cross-lane Gaussian state and panic cleanup, plus serial cache-reset coverage. The regression rejects the original gate loss, omitted synchronization and retained Gaussian cache.

Co-Authored-By: Kacper <[email protected]>
Co-Authored-By: OpenCode GPT 6.1 Sol <[email protected]>
2026-11-10 21:59:04 +00:00
MSedgeMC 4d4aec1c7a fix: one rejected banner layer clears the saved list
`BannerPatternLayers.CODEC` is `Layer.CODEC.listOf()` and
`BannerBlockEntity.loadAdditional` resolves it with `ValueInput.read` plus
`Optional.orElse(BannerPatternLayers.EMPTY)`, so a single layer the codec
rejects leaves no layers at all. The branch kept the valid ones with a
`filter_map`, which is the partial behaviour the body says it fixed.

The list now reads all or nothing, and the test asserts the empty answer for a
mixed list, a missing field, and that a list of valid layers still reads back
(and round-trips), so an always-empty result cannot pass it.
2026-11-10 21:58:02 +00:00
foudretbr 1ee6ac1ed9 style(client): rustfmt consumable test 2026-11-10 22:56:02 +01:00
DUzzL a8a5843f59 Mobs: tick freezing after the move and block effects, like LivingEntity.aiStep 2026-11-10 22:40:06 +01:00
c45072ca0e A comparator beside a decorated pot reads its item
DecoratedPotBlock.getAnalogOutputSignal goes through
AbstractContainerMenu.getRedstoneSignalFromBlockEntity ->
getRedstoneSignalFromContainer, but Horizon keeps a pot's one slot in the
chunk's saved block entity rather than world.block_entities, so the
container signal found no container and read 0. The comparator path now
builds the pot's ContainerSingleItem view from the saved tag (the same one
hopper insertion already used) and reads the slot through it, so a pot
with items powers a comparator and a hopper insertion updates it.

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 22:31:22 +01:00
c4ec5dfd57 Beacon: pay emerald before diamond and tooltip every button
`BeaconScreen.extractBackground` draws the payment row as netherite (x+20),
emerald (x+41), diamond (x+41+22), gold (x+42+44) and iron (x+42+66); the row
no longer follows the items' value. `BeaconPowerButton.setEffect` gives every
power button its effect's tooltip, and `BeaconUpgradePowerButton`'s
`createEffectDescription` appends " II". `BeaconSpriteScreenButton`'s tooltip
is its label (`CommonComponents.GUI_DONE` / `GUI_CANCEL`), hovered whether or
not the button is active.

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 22:27:10 +01:00
foudretbr 407bc867f4 test(client): build consumable test registries without field reassignment 2026-11-10 22:22:18 +01:00
MSedgeMC c3f9c44e15 port_map: LecternBlock.setHasBook posts block_change
The place and reset rows now record the event and its source.
2026-11-10 20:43:54 +00:00
CocofireHDandDeepSeek V4.1 Flash 63a2feba79 A zombie villager's profession is the constructor's draw from its own random (#14857)
`ZombieVillager.defineSynchedData` seeds `DATA_VILLAGER_DATA` from
`initializeZombieVillagerData(this.random)`: one `nextInt(15)` on the entity's
own random, made inside `Entity`'s constructor. Horizon built a synthetic
`LegacyRandom` out of the mob's random state and id instead, so the profession
never matched vanilla's and the mob's own random never advanced.

The draw also has to sit in the right place in the stream. `Entity.<init>`
makes the UUID's two draws and then calls `defineSynchedData` last, while the
yaw `living_random` used to take belongs to `LivingEntity`'s constructor, after
it. So `living_random` now returns the random straight after the UUID, and the
two draws it left behind move to `zombie_villager::constructor_draws`, which
`Mob::with_random` calls in vanilla's order. Nothing moves for other kinds:
those draws are constants, so their streams are unchanged.

Two draws vanilla never makes are gone as well. `ZombieVillager.finalizeSpawn`
only picks the villager type and never rolls a profession, and neither does the
spawn path. `readAdditionalSaveData` redraws only for a finalized save with no
`VillagerData`, which `read_nbt` now matches.

Verified with the issue's repro (`parity_fuzz.py --replay`, seed 998): vanilla
`butcher` against Horizon `nitwit` before, both `butcher` after. The replay's
other difference, `Motion[1]` -0.0784 against 0.0, is pre-existing on
`origin/main` and untouched here. `seed_sync`, `zombie_cure_sound` and
`world_spawn#1`-`#4`/`world_spawn_sync` pass.

`mobs.rs` is past the 3000-line limit, so it gains no lines: the helper lives in
`zombie_villager.rs` and `living_random` loses the yaw line it used to take.

Co-Authored-By: DeepSeek V4.1 Flash (OpenCode) <[email protected]>

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-10 21:38:36 +01:00
Evrim CicekandClaude Opus 5.5 1110d526f2 A server's client leaves the piston path's bubble column particles to the server
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 23:36:20 +03:00
DUzzL dc226c105c Force thrown item stack count to 1 like ThrowableItemProjectile.setItem 2026-11-10 21:22:45 +01:00
MSedgeMC 976dd1cc06 test: a lectern book removal posts block_change
`a_lectern_book_removal_posts_block_change` puts a lectern with a book
and clears `has_book` through the property helper: the block reads
`has_book=false` and the posted events hold
`("minecraft:block_change", [2.5, 4.5, 3.5], None)`. Without the post the
list is empty.
2026-11-10 20:20:52 +00:00
MSedgeMC e1e8ea3822 fix: a lectern's book state change posts block_change
`LecternBlock.setHasBook` (through `placeBook` and `resetBookState`)
writes `has_book`, then calls `level.gameEvent(entity, GameEvent.BLOCK_CHANGE,
pos, Context.of(newState))`; the property drives the lectern's comparator
output and the item it drops.

Horizon's `set_lectern_property` wrote the state with flag 3 and posted
no game event, so a book put on or taken off a lectern was silent to
sculk sensors.

The placement path passes the placing player, a removal passes no source,
as vanilla's callers do.
2026-11-10 20:20:37 +00:00
DUzzL f2a4074c91 Use Mth.atan2 and the float RAD_TO_DEG factor for fireball rotation 2026-11-10 21:17:47 +01:00
Evrim CicekandClaude Opus 5.5 f3a6fbbdbf Pushed arrows and client shulker bullets take the bubble column push (#14981)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 23:17:20 +03:00
Verm c793eeaf45 Keep a sign's has_glowing_text on Codec.BOOL's whole-number test
SignText reads has_glowing_text through Codec.BOOL, and
NbtOps.getBooleanValue is doubleValue() != 0, so an int 256 is true
there, unlike getBooleanOr's byte test. It has its own read now.
2026-11-10 19:51:44 +00:00
DUzzL b228e6bef7 A shulker keeps its armor attribute once its lid has moved 2026-11-10 20:51:41 +01:00
Verm cc76626978 A block entity's booleans are read as bytes (#14977)
getBooleanOr tests a number's byte value, so an int 256 is false. The
flag read every block entity's canonical form goes through tested the
whole number.
2026-11-10 19:47:25 +00:00
Verm ee3e2699ca An end gateway's ExactTeleport is read as a byte (#14975)
TheEndGatewayBlockEntity reads it with getBooleanOr: a number is true
when its byte value is not 0, so an int 256 is false. The decoder tested
the whole number; it now uses flag.
2026-11-10 19:42:15 +00:00
Verm 9c702e54fe A falling block's saved flags are read as bytes (#14971)
FallingBlockEntity reads HurtEntities, CancelDrop and DropItem with
getBooleanOr: a number is true when its byte value is not 0, so an int
256 is false. The loaders tested the whole number; they now use flag
and flag_or.
2026-11-10 19:34:15 +00:00
Verm 6a246dd81d A villager narrows a loaded FoodLevel to a byte (#14967)
Villager.readAdditionalSaveData reads it with getByteOr, which narrows
any numeric tag (IntTag.byteValue is (byte)(value & 255)): FoodLevel:300
loads as 44. The loader kept the raw number.
2026-11-10 19:20:30 +00:00
Verm b6a0f352eb Keep a trident's saved life apart from its Loyalty ground time
AbstractArrow.life is the saved despawn count; inGroundTime is not
saved and starts at 0 on load. The trident used one counter for both,
so a loaded negative life held back the Loyalty return (in_ground_time
> 4). life is now its own field, loaded and saved as it is, counted
where tickDespawn counts it (not for a Loyalty trident that can be
picked up) and reset by startFalling; in_ground_time starts at 0.
2026-11-10 19:08:56 +00:00
Web-mc1 a0b9b67f4a Guard add_padding against bits==0 and bits>=64 2026-11-11 07:52:59 +13:00
Web-mc1 4c49ec3523 Fix biome table panic when empty 2026-11-11 07:47:48 +13:00
foudretbr 0d242f4125 fix(game): keep the registry-resolved consumable over a raw wire re-decode
from_network writes the wire patch's consumable through the real registries;
consumable() then decoded the raw bytes again without them, dropping registered
effects and replacing a registered sound with the prototype's. Decode the raw
bytes only when no resolved component exists. The test now uses a registered
sound that differs from the honey bottle's and a registered effect.
2026-11-10 19:46:42 +01:00
MSedgeMC 67aab17c4e port_map: map FoxBehaviorGoal.alertable visibility range 2026-11-10 18:41:46 +00:00
MSedgeMC 4d0178d549 test: an invisible player far away is not alerting 2026-11-10 18:41:46 +00:00
MSedgeMC 1e13e7a7e2 fix: scale a fox's alert range by visibility 2026-11-10 18:41:31 +00:00
DUzzL e61aa0e8b0 Keep the sign of a loaded ItemRotation like ItemFrame.setRotation 2026-11-10 19:34:35 +01:00
DetimesandClaude Opus 5.5 fd4d917b90 Biome resend: collect the watching guests once, so hub.rs shrinks
resend_biomes filtered the guests twice (for the bodies and for sending);
it now collects the guests that watch once and zips them with their bodies.
Same receivers in the same order, and hub.rs, over the 3000-line limit,
no longer grows.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 18:32:46 +00:00
Oloion 2e6f3cd716 client: show custom server crafting recipes in recipe book (fixes #14608) 2026-11-10 21:32:19 +03:00
DetimesandClaude Opus 5.5 14ac9b88e5 Merge upstream/main into perf-block-event-queue
Brings the branch up to date and lays out the new code and tests as
rustfmt wants them, which the verify job checks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 18:26:50 +00:00
DetimesandClaude Opus 5.5 8ef9fdfb0c Merge upstream/main into perf-biome-resend
Brings the branch up to date and lays out the new code and tests as
rustfmt wants them, which the verify job checks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 18:22:39 +00:00
DetimesandClaude Opus 5.5 94a37b8562 Merge upstream/main into perf-recording-box
Brings the branch up to date and lays out the new code and tests as
rustfmt wants them, which the verify job checks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 18:19:53 +00:00
Mayonesu0andClaude Sonnet 5.5 9528d81b2f The last_hurt_by_mob corpus gains a pig naming its own UUID
Adds a fourth NoAI pig (self) summoned with last_hurt_by_mob set to its
own UUID, read at the same points as echo, so vanilla 26.3 shows that
EntityReference.getEntity resolves the holder too and baseTick ends the
record after 100 ticks.

Part of #12830

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 19:17:45 +01:00
Mayonesu0andClaude Sonnet 5.5 c9b853dda2 Add a save/reload parity harness for a painting's saved pitch
Summons paintings with several Rotation pitches on vanilla's dedicated
server and Horizon's, saves and reloads the world, and compares
/data get entity ... Rotation, facing and variant before and after.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 19:17:31 +01:00
DetimesandClaude Opus 5.5 289e90d636 Merge upstream/main into perf-replay-excuse
Brings the branch up to date and lays out the new code and tests as
rustfmt wants them, which the verify job checks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 18:16:08 +00:00
Verm b5906ff6d5 A trident narrows a loaded Fire to a short as well
Entity.load reads Fire with getShortOr; the trident's loader now
narrows it as the arrow's does.
2026-11-10 18:15:34 +00:00
Mayonesu0andClaude Opus 5.5 3275cd1602 Dispensers pick players in getEntitiesOfClass order, loaded or not
Players are entities of the level from placeNewPlayer/respawn on, so a
guest whose client is still loading is equipped too, and guests and the
local player sort with the mobs and stands: entity sections first
(EntitySectionStorage.forEachAccessibleNonEmptySection, SectionPos.asLong
order), then join order by entity id.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 19:15:33 +01:00
Mayonesu0andClaude Sonnet 5.5 a100957b30 Dispensers equip server players and respect armor stand slot locks
EquipmentDispenseItemBehavior picks the first living entity in front that
canEquipWithDispenser, players included; ArmorStand.canUseSlot rejects locked
slots and hands without arms. Fixes #12937

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 19:15:33 +01:00
Verm e8d176ca8d A dropped item narrows a loaded Fire to a short as well
Entity.load reads Fire with getShortOr; the item's loader now reads it
through the same floor-and-narrow read as Health, Age and PickupDelay.
2026-11-10 18:11:19 +00:00
Verm 452d4be9dc A server player's saved Fire is narrowed to a short too
Entity.load reads Fire with getShortOr for every entity. The player's
loader read it, Air and TicksFrozen through one unnarrowed read; Fire
is now narrowed, the other two stay ints as vanilla reads them.
2026-11-10 18:05:35 +00:00
DetimesandClaude Opus 5.5 c08c3e765c Test: held_attack_item returns the item the old clone returned
Checks the borrowed lookup against the clone-and-flatten expression it
replaces, over an empty slot, filled slots, slots past the end and an
empty inventory.

Refs #14939

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 18:01:58 +00:00
masa-masa-0314andZCode 3dcdbf8947 docs: ground Friends overlay split plan in 26.3 source review
Replace the open-ended UI scope with eight reviewable work packages and
separate backend, toast, face and host-adapter steps. Record vanilla
entry/lifecycle requirements, old-port defects, evidence limits and the
maintainer decisions required before implementation.

Keep the account overlay distinct from the approved host/share migration;
leave the old implementation PR and isolated merge branch untouched.

Co-Authored-By: ZCode <[email protected]>
2026-11-11 02:01:45 +08:00
DetimesandClaude Opus 5.5 d386833f8b Perf: borrow the held item in tick_guests instead of cloning the stack
tick_guests cloned the whole ItemStack of every guest's selected slot
every tick, only to compare its item with play.attack_item. The new
held_attack_item helper borrows the item, and the Arc is cloned only
when the held item changed. Same result, no clone on the common path.

Refs #14939

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 18:01:39 +00:00
martipopsandClaude Opus 5.5 0f157e4f3a On-screen keyboard: the hint after a wrapped one starts its own line
A hint broken over several lines could have the next hint packed onto its
last line ("...the way you want it to   RB Symbols"), reading as part of
it. The lines of a broken hint are now its own.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 12:57:04 -05:00
DUzzL 5da9065a8d Start the cure when a zombie villager loads a ConversionTime
ZombieVillager.readAdditionalSaveData calls startConverting, so a saved cure swaps weakness for strength on load.
2026-11-10 18:56:44 +01:00
Verm eb8de82c63 A horse saves back the Variant it was loaded with
Horse.addAdditionalSaveData writes typeVariant as it is, so Variant:9
reads back 9 although it shows coat 2. The save now keeps the loaded
int while the horse still shows that coat and those markings, and
packs the two ids once its look changed.
2026-11-10 17:49:18 +00:00
MSedgeMC a04de39fea port_map: CrafterBlockEntity.fillStackedContents is a port
The crafter's nine slots are the menu's MAIN container and count for the
recipe book's craftable check.
2026-11-10 17:45:24 +00:00
MSedgeMC 76d95dbcc9 test: a crafter's grid is stacked for the recipe book
`a_crafters_grid_counts_for_the_recipe_book` opens a crafter menu, puts
four oak planks in its first slot and asks the stacked contents whether
an oak-plank ingredient can be crafted. Without the crafter arm the same
call fails.
2026-11-10 17:45:13 +00:00
MSedgeMC b9ad2da17e fix: a crafter's grid counts for the recipe book
`CrafterBlockEntity.fillStackedContents` hands the crafter's nine slots
to the recipe book's stacked contents (`accountSimpleStack` each), so an
open crafter's grid counts towards the craftable check.

Horizon's `Menu::stacked_contents` read the player's inventory, the mod
sources and the `GRID` container, but a crafter's slots are its `MAIN`
container (`CrafterMenu.addSlots`): with a crafter open the book saw an
empty grid and marked nothing craftable.
2026-11-10 17:45:13 +00:00
DetimesandClaude Opus 5.5 394455b62e Test: the section reach box never rejects a viewer with a near block
Random viewers and sections (20,000 cases, with an empty section checked
too), and every single block around 60 random viewers including the edge
of the reach, assert that reaches() keeps any viewer for which near()
holds. The sections-with-hits count and the blocks-near count guard
against a test that proves nothing.

Refs #14923

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 17:42:39 +00:00
DetimesandClaude Opus 5.5 79e5720a76 Perf: skip recording's per-viewer block test when no changed block is in reach
broadcast_block_changes tested every changed block of a section against
every recording viewer, although only a block inside the viewer's `near`
reach is ever recorded. bounds() takes the section's box once, and
reaches() rejects a viewer whose reach misses it, using the same
expressions as near() so the reject is conservative. note_changes() keeps
the per-block loop and its order, and moves the loop out of hub.rs.

Refs #14923

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 17:41:53 +00:00
Verm 6d91dd0c76 A horse's unknown coat or markings id wraps (#14926)
Horse.getVariant is Variant.byId(typeVariant & 255) and getMarkings
Markings.byId((typeVariant & 0xFF00) >> 8); both maps are
OutOfBoundsStrategy.WRAP. The loader clamped them, so Variant:9 was
dark brown where vanilla's horse is chestnut. The saved Variant still
comes from the two ids, not the loaded number.
2026-11-10 17:41:47 +00:00
DetimesandClaude Haiku 5.5 a5fe3eea40 Test that shared resent biome bodies match the old packets
bodies() encodes one body per set of columns. The test builds the packet
each receiver got before (cloned columns, encoded alone) for three
receivers sharing five columns, a receiver with a subset in another
order, and one with none, and compares the bytes.

Refs #14925

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
2026-11-10 17:41:40 +00:00
DetimesandClaude Haiku 5.5 d9146d02b5 Encode resent biomes once per set of columns
resend_biomes cloned each receiver's ChunkBiomeData and encoded the
ChunksBiomes packet again for every guest. Receivers that have the same
columns now share one encoded body, built by the new lan::biome_resend
module; hub.rs gets smaller. Each guest still gets the same bytes in
the same column order, and the fake-player filter is unchanged.

Refs #14925

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
2026-11-10 17:41:31 +00:00
DetimesandClaude Haiku 5.5 fab1e1a3e6 Test: entity_near matches the scan it replaced
Differential test over 2000 seeded random inputs: entity sets of up to 40
states (all four kinds, own and other ids), guest positions and slacks,
with entities placed on or next to every edge of the close box, checked
against a verbatim copy of the old scan. Plus an inclusive-edge test and
the own/item/orb exclusions. The inputs are multiples of 1/8 so the box
edges are exact in f64. No world or game resources needed; cargo not run.

Refs #14920

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
2026-11-10 17:41:19 +00:00
DetimesandClaude Haiku 5.5 f6d8444698 Perf: test the distance before the kind in the replay's entity excuse
excused() ran a string compare on each entity's kind before the box test,
for every guest every tick. Move the box test first (it is a pure float
check) and move the entity scan into entity_near, so the excuse can be
checked against the old scan. The boolean result is unchanged: the
predicates are pure and any() is order-independent.

Refs #14920

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
2026-11-10 17:40:37 +00:00
Verm fca27e4c63 An axolotl with an unknown Variant id is lucy, not blue (#14907)
Axolotl.Variant.BY_ID is OutOfBoundsStrategy.ZERO: an id outside 0..4
is the first variant, lucy. The loader shared the parrot's arm, whose
CLAMP is right for the parrot but made Variant:7 the rare blue axolotl.
2026-11-10 17:33:46 +00:00
Shashanksaraswat123939 918e56994c ci: re-run checks (verify was cancelled when #14802 closed) 2026-11-10 21:33:19 +04:00
MSedgeMC b5a64cf86b port_map: JukeboxBlockEntity.notifyItemChangedInJukebox is a port
A disc coming or going sets `has_record` and posts `block_change` at the
box; the row kept the old gap.
2026-11-10 17:24:58 +00:00
MSedgeMC 0966aa0d3f test: a disc change posts block_change
`a_disc_change_posts_block_change` places a jukebox, records the posted
game events and calls `set_has_record`: the box posts
`minecraft:block_change` at its centre with no source. Without the post
the recorded list is empty.
2026-11-10 17:15:39 +00:00
Nikita Maximilian GrimmandClaude Fable 5.2 9188926fff Shader API 0.2: [frame] primaries, and the rule for new names
A 0.2 pack can't show a colour outside sRGB today: the contract says every
colour is in BT.709 primaries, forever. This adds the key that will let a
pack ask for more, before 0.2 is on by default and packs are written against
that contract.

- `[frame] primaries = "bt709"` (the default) or `"bt2020"`. The check takes
  `"bt2020"` only with `blend = "linear"`, since a change of primaries is only
  right in linear light. The game refuses a `"bt2020"` pack for now, as it
  refuses `blend = "linear"`.
- `Frame` gains `primaries` and `luminance` (that space's luminance weights)
  after `black_level`. The renderer writes 0 and BT.709's weights. The
  uploaded buffer grows by 16 bytes; 0.1 declares the same prefix as before.
- The reference's "Room to grow" says a later release adds a name to a
  stage's prelude only under a manifest key the pack sets or in a new stage.
  `tests/names.rs` holds each 0.2 prelude to its list of names
  (`tests/names-0.2/`), and shows why: a pack's own `fn depth_at` in a post
  stage is refused as a redefinition, its own `fn to_bt709` passes.

No pack's picture changes: a pack without the key, a `"bt709"` pack and 0.1
packs draw as before.

Part of #14584 (step 1) and #12989.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-10 18:13:34 +01:00
Verm c1aba1d3f4 Test the item's Age and PickupDelay sentinels through load and save
-32768 (never ages) and 32767 (never counts down), given as ints, and
the ints that narrow onto them, load as the sentinels the tick tests
for and save as the shorts vanilla writes.
2026-11-10 17:12:37 +00:00
MSedgeMC b920f8a432 fix: a disc coming or going posts block_change
`JukeboxBlockEntity.notifyItemChangedInJukebox` sets the `has_record`
block state with update flag 2 and then calls
`level.gameEvent(..., GameEvent.BLOCK_CHANGE, pos)`: sculk sensors hear a
disc enter or leave a jukebox. Horizon's `set_has_record` changed the
state but posted no game event.
2026-11-10 17:09:19 +00:00
Verm f61130508b A fireball narrows a loaded ExplosionPower to a byte (#14883)
LargeFireball.readAdditionalSaveData reads it with getByteOr, which
narrows any numeric tag (IntTag.byteValue is (byte)(value & 255)):
ExplosionPower:300 loads as 44. The loader kept the raw number, so
such a fireball exploded with power 300 while its save showed 44b.
2026-11-10 17:04:53 +00:00
Florian ErnstandClaude Opus 5.5 16fb5cfed0 Slot sources: holder and tag codec, and slot-source tags in DataStore
SlotSources.CODEC admits a registered holder (`ns:id`), a holder list and a
`#tag` holder set wherever a slot source is accepted, and validates the
`group`, `limit_slots`, `filtered`, `contents` and `slot_range` shapes
(including a `slot_range` source context) against the loaded registry.

DataStore loads `tags/slot_source` in the same pass as the definitions, in
pack order with `replace`. `slot_source_tag` resolves a tag as TagLoader
does: first-seen member order, nested tags, required members and cycles
rejected. The command registry publishes the tags that resolve.

Part 1 of 3 for #13091 (PR #13951 is the last part).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 18:04:33 +01:00
TheMrZZandCodex gpt-6.1-sol ef9f8e86d7 Keep item-count and shulker harness registrations merge-compatible
Share only isolated-group/filter metadata; each PR keeps its own corpus and gameplay change. Preserve hopper-pot exact selection and all existing groups.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-10 18:04:14 +01:00
TheMrZZandCodex gpt-6.1-sol b91c2d4965 Keep item-count and shulker harness registrations merge-compatible
Share only isolated-group/filter metadata; each PR keeps its own corpus and gameplay change. Preserve hopper-pot exact selection and all existing groups.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-10 18:04:12 +01:00
TheMrZZandCodex gpt-6.1-sol 1cf4775de9 Merge main into PR13762 to pick up landed CI fixes
Preserve maintainer merge history; include deterministic git-slim temporary repository maintenance settings from #14090.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-10 18:02:06 +01:00
TheMrZZandCodex gpt-6.1-sol bdf670abdf Merge main into PR13761 to pick up landed CI fixes
Preserve maintainer merge history; include deterministic git-slim temporary repository maintenance settings from #14090.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-10 18:02:03 +01:00
Verm 32621f2275 A mob and an armor stand narrow a loaded Fire to a short (#14879)
Entity.load reads Fire with getShortOr, which narrows any numeric tag
(IntTag.shortValue is (short)(value & 65535)): Fire:40000 loads as
-25536. The arrow, the other projectiles and the boat already narrowed
it; the mob's and the armor stand's loaders kept the raw number.
2026-11-10 16:57:36 +00:00
DUzzL 4e7219047b Wrap previous fireball angles before easing towards the movement 2026-11-10 17:53:31 +01:00
Verm b6172e204f A mob narrows a loaded HurtTime and DeathTime to a short (#14876)
LivingEntity.readAdditionalSaveData reads both with getShortOr, which
narrows any numeric tag (IntTag.shortValue is (short)(value & 65535)):
HurtTime:40000 loads as -25536 and never counts down. The loader kept
the raw number, so such a mob counted down from 40000 and was drawn
hurt all the while.
2026-11-10 16:50:37 +00:00
DetimesandClaude b642094bb6 Test: block-event queue matches the old Vec behaviour
Replays pushes, duplicates, pops with re-queues during a run, deferred
events put back after the run, and retains against the old Vec model
(contains + push, remove(0)) and checks queue contents and pop order
after every step. Also checks 5000 distinct events pop in queue order
and a duplicate is dropped. No game resources needed.

Refs #14873

Co-Authored-By: Claude <[email protected]>
2026-11-10 16:41:27 +00:00
DetimesandClaude e2cb60d768 Perf: block-event queue is O(1) per event, not O(E^2) per tick
run_block_events and client_drain_block_events popped with Vec::remove(0),
which shifts the whole tail per event, and block_event deduped with a
linear contains. The queue is now a VecDeque (pop_front) with an
FxHashSet mirror for the dedupe. BlockEventData derives Hash.

All queue writes go through Ticks::queue_block_event, pop_block_event
and retain_block_events so the set cannot drift. Queue order, dedupe
results and the re-queue and reschedule paths are unchanged.

Refs #14873

Co-Authored-By: Claude <[email protected]>
2026-11-10 16:41:21 +00:00
MSedgeMC 7ab5bfd238 port_map: ChatComponent.refreshTrimmedMessages keeps the scroll 2026-11-10 16:40:49 +00:00
MSedgeMC e347c6f99f test: a Chat option change keeps the reader's place
Sixty lines, the reader three lines back with a new message since: a Chat
Visibility change leaves both, a rescale resets both.
2026-11-10 16:40:45 +00:00
Verm 5d5e2712ad A dropped item floors a float Age or PickupDelay before narrowing it
getShortOr floors a float or double tag before the short narrowing. The
item's loader did that for Health only; Age and PickupDelay now go
through the same read.
2026-11-10 16:40:39 +00:00
MSedgeMC f03a2ffe21 fix: changing the Chat option keeps the reader's place
ChatComponent.setVisibleMessageFilter rewraps through refreshTrimmedMessages,
which leaves chatScrollbarPos alone; only rescaleChat resets it first
(resetChatScroll). Horizon's rewrap always reset the scroll and
newMessageSinceScroll, so changing Chat Visibility threw a scrolled-back
reader to the bottom.
2026-11-10 16:40:25 +00:00
DUzzL 34a9b0144c Share the projectile flag and entity field load to keep entity_nbt.rs from growing 2026-11-10 17:32:07 +01:00
Verm 4a42d89978 A dropped item narrows a loaded Age and PickupDelay to a short (#14867)
ItemEntity.readAdditionalSaveData reads both with getShortOr, which
narrows any numeric tag (IntTag.shortValue is (short)(value & 65535)):
Age:40000 loads as -25536. The loader narrowed Health this way but kept
Age and PickupDelay as the raw number, so such an item was past 6000
and despawned on its first tick.
2026-11-10 16:31:18 +00:00
foudretbr 76178bacf0 fix(game): resolve dynamic consumable component on item stack (#14634)
Read the minecraft:consumable component from saved components or the wire patch on an ItemStack rather than only checking the prototype definition. This ensures custom consumable items properly start eating, tick use duration, emit consume particles and sound, and complete without desynchronizing movement.
2026-11-10 17:26:04 +01:00
Nikita Maximilian GrimmandClaude Fable 5.2 8df65734b7 frame_map_writes and wolf_anger_writes run in work folders of their own
Both wrapped the command_exec driver without --work-dir, so they shared
.local/command-exec-parity with each other and with command_exec, and two
running at once read each other's scripts and worlds.

Fixes #14855

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-10 17:20:39 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 1b5800c3ab command_exec cleanup keeps the End's dragon; bird flap sensors seeds each bird itself
The End's own dragon flies above the cleanup's floor at times, and killing it started vanilla's
death sequence. bird flap sensors ran on item honey bounce's seed sync, 36 groups earlier, and
failed in more than half of the runs once the cleanup ran between them (0 of 9 without it): each
bird now gets its own sync, with entity ids clear of the world's loaded entities.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 17:08:46 +01:00
martipopsandClaude Opus 5.5 3e4dce841c On-screen keyboard: a wrapped hint keeps its button with its first word
Breaking a too-wide hint at its spaces could leave the button's print
alone on a line ("B", then the label under it). The print now stays with
the hint's first word, and that line is drawn smaller if it is still
wider than the panel.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 10:58:53 -05:00
Mayonesu0andClaude Sonnet 5.5 bd11bddc68 Add a save/reload parity harness for a loaded mob's AbsorptionAmount
Summons pigs with several AbsorptionAmount values on vanilla's dedicated
server and Horizon's, saves and reloads the world, and compares
/data get entity ... AbsorptionAmount before and after the reload.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 16:47:51 +01:00
JustKarkat 55e719d3b7 fix: skins::unpack keeps each profile texture independently
unpack() rejected a profile's textures all-or-nothing: any texture URL
not on textures.minecraft.net dropped every texture, so a head's skin
was lost whenever another texture (cape, elytra) named a disallowed URL.

Vanilla's SkinManager judges each texture per URL: disallowed ones are
omitted and the rest are kept. unpack() now skips an invalid texture
instead of rejecting the profile, so custom player heads keep their skin
on servers whose profiles carry extra textures with foreign URLs (e.g.
hub.fadecloud.com).

The head-profile cache test expected the all-or-nothing behaviour; its
cape assertion now matches vanilla's per-URL semantics, and a regression
test covers a mixed valid-skin + disallowed-cape profile.

Refs #5376
2026-11-10 09:47:46 -06:00
Verm 1f230b80a9 A trident keeps a negative life and shake across a reload (#14834)
AbstractArrow reads life as the saved short, negative too, and shakeTime
as the saved byte & 255. The arrow already did; the trident's own copy
kept both unsigned, so a loaded life:-1s or shake:-1b saved back as 0.
Both are signed on the trident now, read as the arrow reads them, with
vanilla's 'if (shakeTime > 0) --shakeTime' and pickup at shakeTime <= 0.
2026-11-10 15:31:39 +00:00
Mayonesu0andClaude Sonnet 5.5 2a67bd988c A saved last_hurt_by_mob naming the mob itself resolves like any other
EntityReference.getEntity looks the UUID up in the level and returns any
matching LivingEntity, the holder included, so LivingEntity.baseTick ends
such a record after 100 ticks. resolve_last_hurt_by_refs dropped a
reference equal to the mob's own UUID, leaving it unresolved and saved for
ever. Remove that exclusion and cover it with a test.

Part of #12830

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 16:30:47 +01:00
Nikita Maximilian Grimm 66ead25e8d Merge remote-tracking branch 'origin/main' into fix/command-exec-group-isolation 2026-11-10 16:21:50 +01:00
DUzzL 4bdf605756 Keep Air, Fire and fall_distance on llama spit and shulker bullet saves 2026-11-10 16:21:40 +01:00
readerkid 327b811914 Map CEM's armour models to the layers 26.3 draws, and the rabbit's feet
CEM names a humanoid's armour with two models, `<mob>_outer_armor` and
`<mob>_inner_armor`, but 26.3 draws it on four layers of the model: the helmet,
the chestplate, the boots and the thinner leggings. A CEM name gave one layer
key, so no armour file reached any of them, and the 24 armour layers of the
zombie family's baby models were unreachable.

An armour model now reshapes every layer it covers: the outer one the helmet,
the chestplate and the boots, the inner one the leggings. Layers a model does
not have are dropped by the existing check against vanilla's layers, so no list
of mobs has to track the game, and a baby keeps its own file through the suffix
rule.

The rabbit's `left_foot`/`right_foot` mapped to `left_hind_foot`/
`right_hind_foot`, which 26.3 no longer has: the haunch carries the box under an
empty hind leg, so the foot is the haunch and the thigh the leg.

`mobs::cem::tests::names_reach_horizons_models` (ignored: needs the prepared
resources) passes with no gaps.

Fixes #8426

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
2026-11-10 07:16:11 -08:00
martipopsandClaude Opus 5.5 4e3e68b758 On-screen keyboard: a hint wider than the panel breaks at its spaces
A single hint wider than the whole panel (a long translation) ran past it:
it now breaks at its spaces onto lines of its own, and only a line that is
one word wider than the panel is drawn smaller, to fit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 09:58:09 -05:00
Mik00000 a98cde40f8 fix(client): allow rapid flint and steel usage when holding offhand item (fixes #14773) 2026-11-10 16:47:30 +02:00
Horizon agentandClaude Opus 5.5 938dcd673c Revert #13953: entity ticking waits for chunks two columns away
This reverts merge 5248e83f28 (#13953).

Since it merged (2026-11-09 15:51Z), entities in the command_exec
fixture world stop ticking: they no longer fall, die, despawn or pick
items up, and command_exec fails 159/184 (it passes at the merge's
parent, and fails at the merge, both run here). Every PR whose change
selects command_exec has failed parity since.

Vanilla's gate is right, but Horizon does not keep the ring around a
forceloaded or ticketed chunk resident the way vanilla's ticket levels
do (a FORCED ticket gives its neighbours FULL), so the residency check
holds back chunks vanilla ticks. #13852 needs that ring modelled first.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 14:02:07 +01:00
SenjinandClaude Opus 5.5 4afaf25272 Dynamic Resolution: the world's resolution follows a target frame rate
Off by default. Resolution Scale becomes the floor, and the scale rises toward 100% while the
GPU keeps Target FPS (30 to 240 in steps of 5, 60 by default), falling back when frames take
longer. A verdict is the median GPU time of a window of frames; a step up waits until the
frame would still fit if its cost grew with the pixels. Frames of a world still loading don't
count.

Each change starts TAA's and a shader pack's history over, so under a shader pack it moves
gently (3 s windows, 10% steps, 8 s between changes) and without one quickly (0.5 s, 5%, 1 s).
F3, the performance overlay (`res 85%`) and horizon-ctl's status show the scale; changes log at
debug level.

GPU Culling's Auto moves with it out of game.rs into game/frame_autotune.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 13:47:38 +01:00
Shashanksaraswat123939 ffdd56a5e0 tests: import sibling test modules by their own name
test_item_model_property and test_port_ledger imported their helpers as
tests.test_world_input_capture and tests.test_class_index, while every
other test imports siblings by module name (unittest discover puts tests/
on sys.path). The tests. form resolves to any installed top-level tests
package first, so both modules failed to import wherever one exists.
2026-11-10 16:40:16 +04:00
NongTham e1848e08a4 input: clear modifiers on focus loss and heal alt and super keys (fixes #14715) 2026-11-10 19:10:27 +07:00
MSedgeMC 445b1bbd4c The iron golem keeps its air through its own decreaseAirSupply
The tag is right and the behaviour was wrong for the wrong reason: an iron
golem is not in can_breathe_under_water, and it stays under water without a
bubble because IronGolem.decreaseAirSupply hands back the air it is given
(`iload_1; ireturn`). Taking the golem out of the tag alone left nothing to
hold its air, so it drowned after 320 ticks where vanilla never hurts it.

The drown path now asks keeps_its_air beside can_breathe_under_water: a golem
under water stays in baseTick's drowning branch, as a type missing from the
tag does, but its consume is 0, so its air stays at 300, it never reaches the
-20 that hurts and it never refills either — exactly the three things the
override decides.

The test states both halves: the shipped data has no iron_golem in the tag (a
copper golem, which is in it, keeps its air through the tag and has no
decreaseAirSupply override), and 320 ticks with its eyes in water leave the
golem at (0.0, 300) where the same mob without the arm is (2.0, 0).
2026-11-10 12:02:55 +00:00
foudretbr 3661bfd7c2 fix(game): evaluate attack_speed attribute modifiers for player attack cooldown (#14721) 2026-11-10 12:55:27 +01:00
foudretbr fc3ac6a620 fix(mobs): align drowned and husk conversion ticks and cure random with vanilla (#14770) 2026-11-10 12:52:35 +01:00
MSedgeMC a08205b8ec port_map: MerchantScreen.extractScroller is ported 2026-11-10 11:44:30 +00:00
MSedgeMC 41fdaa3f0c test: the merchant scroller's thumb asks for its cursor
Eight offers put the thumb at the track's top; a hand over it, the vertical
resize while dragged, nothing left of the thumb or past its bottom edge, and
the disabled scroller of seven offers asks for nothing.
2026-11-10 11:44:30 +00:00
MSedgeMC 2398317072 fix: the merchant's trade scroller shows its cursor
MerchantScreen.extractScroller requests CursorTypes.POINTING_HAND over the
scroller's thumb, or RESIZE_NS while it is dragged. Horizon drew the thumb
but asked for no cursor, so the pointer stayed an arrow over it.
2026-11-10 11:44:30 +00:00
DUzzL 800b42b719 Skip leash knots when saving chunk entities (EntityType.noSave) 2026-11-10 12:33:41 +01:00
MSedgeMC 669e1a162a Move takes_food into mobs/ai/feeding.rs so ai.rs may not grow 2026-11-10 11:29:46 +00:00
Ray McDonaldandClaude Opus 5.5 c46a72196e horizon-threads: resident memory on iOS through Mach task_info
libc declares proc_pidinfo/proc_taskinfo for macOS only, so horizon-threads
didn't compile for aarch64-apple-ios. iOS reads MACH_TASK_BASIC_INFO's
resident size instead.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 06:12:10 -05:00
NongTham d645b72e8b test: allow-flight in bed_explosion parity harness (fixes #14664) 2026-11-10 18:05:15 +07:00
MSedgeMC db693c8f36 port_map: the underwater breathing names its arm
canBreatheUnderWater had no row in any of the three classes; the tag half and
the two overrides are air.rs::can_breathe_under_water.
2026-11-10 11:03:00 +00:00
MSedgeMC f39838d4b2 Test who drowns and who breathes
ai::air::tick_air on a mob with its eyes in water for 320 ticks: a guardian
takes 0.0 where an iron golem takes the drown's 2.0, an adult happy ghast 2.0
where its baby takes 0.0, and a sulfur cube 2.0 without a block in its body and
0.0 with one. Without the tag correction and the two overrides the iron golem
reads 0.0.
2026-11-10 11:00:22 +00:00
MSedgeMC 33a0458cf2 Only the jar's types breathe under water
LivingEntity.canBreatheUnderWater is the can_breathe_under_water tag, and
Horizon's copy of it carried an iron golem that the tag does not list: an iron
golem standing in water never lost its air and never drowned. Two class
overrides were missing with it — HappyGhast.canBreatheUnderWater answers true
for a baby, and SulfurCube.canBreatheUnderWater true while it has a block in
its body — so both drowned where vanilla lets them breathe.

The tag's list loses the iron golem, and the drown predicate now asks
can_breathe_under_water, which adds the baby happy ghast and the sulfur cube
with a body. The air-supply block moves out of ai.rs into mobs/ai/air.rs, which
takes 60 lines of code out of ai.rs.
2026-11-10 11:00:22 +00:00
MSedgeMC d46bea7851 port_map: the stroll's score names its port
The mushroom cow's row said its mycelium rule lived only in the spawn check;
walk_target_value carries it now, and the enderman, the pillager and the
warden have their flat 0 beside the other walk-target rows.
2026-11-10 10:53:43 +00:00
MSedgeMC a25182804a Test the walk-target scores of four classes
walk_target_value at three positions that differ only in the block below: a
cow scores grass 10.0 and stone and mycelium by the light, a mushroom cow
mycelium 10.0 and grass by the light (never 10), a zombie the negated light,
and an enderman, a pillager and a warden 0.0 on all three. Without the two
rules the mushroom cow reads 0.5 for its mycelium, the light term, and the
three flat classes read the zombie's score.
2026-11-10 10:50:20 +00:00
MSedgeMC dd178e638f A stroll scores its ground the way the jar does
PathfinderMob.getWalkTargetValue is what a random stroll ranks its ten
candidate positions by, and three of its class overrides had no port:
Enderman.getWalkTargetValue, Pillager's and Warden's answer 0 wherever the
position is, so their strolls pick one of the ten at random, while Horizon
scored them by the negated light and sent them to the dark. A mushroom cow's
override replaces Animal's outright — mycelium 10, everything else by the
light — so grass is worth nothing to it, while Horizon gave it the cow's grass
10 and nothing for its own mycelium.

walk_target_value moves out of ai.rs into mobs/ai/walk_target.rs with the two
rules, and ai.rs loses 75 lines of code to the move.
2026-11-10 10:50:20 +00:00
Loki 3bc4e3035c Merge remote-tracking branch 'origin/main' into fix/12935-sculk-particle-resume-reload 2026-11-10 19:47:20 +09:00
Riccardo GuerrieroandClaude Fable 5.2 9ee7b165cd Worldgen: the range choice picks its values without a branch per block
RangeChoiceSampler::sample_volume kept or replaced each value with a
conditional the compiler turned into one branch per value. Final density
runs it over a chunk's whole 16x384x16 volume (the noodle caves), where the
choice changes often, so the branch was mispredicted: about 4.5% of proto
generation time at one thread.

The select now runs in its own function over plain slices, on the values'
bits, which the compiler vectorizes. Each value is the one vanilla's
conditional store leaves; a test checks it bit for bit against a scalar
reference, with NaN bounds, NaN selectors and signed zeros.

Co-Authored-By: Claude Fable 5.2 <[email protected]>

Claude-Session: https://claude.ai/code/session_01WZzq8MWgQD4Rbxknozw5LZ
2026-11-10 11:47:04 +01:00
MSedgeMC ea435acad4 port_map: the stalactite tick rows name the client drip
PointedDripstoneBlock.animateTick, getFluidAboveStalactite and canFillCauldron
were unlisted on the client side: the drip they decide is
animate_tick.rs::dripstone_drip, and the 26.3 filter reads the fluid's type.
2026-11-10 10:27:46 +00:00
MSedgeMC 71eb66aa68 test: a waterlogged slab and its fluid drip in the tick zoo
The half of `trySpawnDripParticles` that decides where a fluid drop lands had
no case: every fluid the zoo put a block over was air or stone, both full
cubes, so the branch that reads the fluid block's own face was never taken.

Two cases put a waterlogged bottom slab over a dry one - a fluid block with a
full down face and a dry half-height block under it, the arrangement the branch
exists for - at seeds 0 and 1057. The corpus goes from 2,188 cases and 462,718
lines to 2,190 and 462,960; the 2,188 cases keep their own seeds and print the
same 462,718 lines as before, byte for byte, on both sides.

Negative run, against the same corpus: the face back to `full_collision` makes
only these two cases differ, 78 lines against 67 at seed 0 and 81 against 76 at
seed 1057, both short of the dripping_water particle the client adds on the
slab's top face.
2026-11-10 10:27:46 +00:00
MSedgeMC 92ce70e08a fix: the dripstone reads the fluid above its root, not a source
Two reads of the fluid a drip comes from were taken from the wrong accessor.

`ClientLevel.doAnimateTick` hands `trySpawnDripParticles` the fluid block's own
face, `state.isFaceSturdy(this, pos, Direction.DOWN)`, and its collision shape,
`state.getCollisionShape(this, pos).max(Y)`. Horizon passed `full_collision`,
the "collision shape is a full cube" fact, for the face: a waterlogged bottom
slab has a full down face and is not a full cube, so with the dry slab under it
the client put a dripping_water particle on that slab's top and Horizon put
none (78 lines against 67 at seed 0, 81 against 76 at seed 1057). The facts
already carry `isFaceSturdy(.., SupportType.FULL)` by direction ordinal, so the
down face is bit 0.

`PointedDripstoneBlock.canFillCauldron(Fluid)` is `fluid == Fluids.LAVA ||
fluid == Fluids.WATER`: the fluid above the root by its type. Horizon also
required it to still be a source (`fluid_level >= 8 && !fluid_falling`), which
drops the flowing fluid a stalactite drips in vanilla.
2026-11-10 10:27:46 +00:00
foudretbr 15183261d9 fix(input): reset debug keys modifier state on window focus loss (#14715) 2026-11-10 11:25:52 +01:00
MSedgeMC adbc523e5c port_map: the canAgeUp feeding branch
AgeableMob.canAgeUp to ai.rs::can_age_up, and Animal.mobInteract's food
branches to ai.rs::takes_food: an adult at age 0 that canFallInLove, else
canAgeUp.
2026-11-10 10:20:16 +00:00
MSedgeMC 7f5b3462df test: feeding a frog at a baby's age leaves the food in the hand
A frog and a cow, both at Age -24000, through takes_food and feed_animal: the
frog refuses the food and keeps the age, the cow takes it and grows a tenth.

Without the fix the frog takes it: took_food=true ate=true age=-21600, where
the test wants false, false and -24000. The cow reads the same either way
(age -21600).
2026-11-10 10:20:13 +00:00
MSedgeMC c106b315ad fix: a mob that cannot be a baby does not take the food that grows it
Animal.mobInteract's growing branch is `canAgeUp()`, which is
`isBaby() && !isAgeLocked()`, not `getAge() < 0`. ai::takes_food still
decided that branch from the raw age, so a frog spawned with Age at
getBabyStartAge (AgeableMob.finalizeSpawn's group baby is not gated on
canBeABaby), a camel husk (AbstractHorse's group spawn is 20 percent), or a
slime, magma cube, parrot or zombie nautilus with a negative age took the
food: it ate the item, played the eating sound and grew a tenth towards 0,
where vanilla refuses the click.

takes_food now asks can_age_up(m), the port of canAgeUp, the same predicate
the rest of the feeding code already used.
2026-11-10 10:20:06 +00:00
NongTham 2d9d67e048 Game: review and complete Thai translations (th_th.json)
- Translate 96 missing strings including Controller Radial Menu, HDR, Shaders, and Recipes
- Reach 100% coverage (1,151/1,151 keys) with zero placeholders changed and zero empty values
- Record all 1,151 keys as reviewed in tools/lang_review/game/th_th.json
2026-11-10 17:16:54 +07:00
MSedgeMC c3121e9a5a Rebuild the weapon component read on current main without dropping attribute_modifiers 2026-11-10 09:59:59 +00:00
NongTham b689c295dc Launcher: review and complete Thai translations (th.json)
- Translate remaining English error and status strings
- Polish phrasing across Home, Settings, Mods, and Servers
- Record all 1,240 keys as reviewed in tools/lang_review/launcher/th.json
2026-11-10 16:40:27 +07:00
1d9040c05a Silence a chest's comparator while a cat sits on it
ChestBlock.isChestBlockedAt is a redstone conductor above the chest or a
cat sitting in the block above it (ChestBlock.java:318-319, the cat test
at :327-338). getAnalogOutputSignal (:346-348) reads getContainer(..,
false), whose combine(.., false) (:279) uses that predicate, so a blocked
chest combines to nothing and the comparator reads 0.

The comparator's container view (machines::Containers::view) only checked
the conductor; the cat test lived on the menu path alone. Hand the level's
mobs to the view and share one isCatSittingOnChest helper with the menu
path (chest_blocked_at). A hopper's getContainer(.., true) still ignores
the block, so hoppers and menus behave as before.

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>

Claude-Session: https://claude.ai/code/session_01WZzq8MxShENtiMj2DMbm5G
2026-11-10 09:19:23 +01:00
a77e5ec7f3 Hiss when powder snow or a cauldron puts a burning player out, and leave the block outside the world border
Entity.applyEffectsFromBlocks plays playEntityOnFireExtinguishedSound once a move
left the entity no longer on fire (Entity.java:992-1003, 1047-1061), which powder
snow and water cauldrons cause through InsideBlockEffectType.EXTINGUISH; the
server's copy of the player now plays it after the block changes. The same two
blocks gate their block change on mayInteract (PowderSnowBlock.java:85-98,
LayeredCauldronBlock.java:73-79), which for a player is ServerPlayer.mayInteract
(ServerPlayer.java:2108-2110) and ServerLevel.mayInteract (ServerLevel.java:874-876):
outside the world border or under spawn protection the powder snow stays and the
cauldron keeps its level, while the fire still goes out and the hiss still plays.

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>

Claude-Session: https://claude.ai/code/session_01WZzq8MxShENtiMj2DMbm5G
2026-11-10 09:18:22 +01:00
ebe873fb5e Pets sit only where vanilla lets them, and a cat's chest pose reaches every player
`TamableAnimal.isOrderedToSit` is the saved flag an interaction flips;
`isInSittingPose` is bit 1 of `DATA_FLAGS_ID`, which only
`SitWhenOrderedToGoal.start` (once `canUse` allows it: not in water, on the
ground) and a cat's `CatSitOnBlockGoal`/`CatLieOnBlockGoal` write, and which
the models, `ChestBlock` and the metadata read. A wolf fed a bone while it
swims is no longer drawn sitting.

A cat's chest/bed pose now goes out in that bit (the metadata carried the
order instead), so the other players of a LAN world see it sitting, and
`readAdditionalSaveData` sets the pose from the saved order as
`TamableAnimal:81-82` does.

`RunAroundLikeCrazyGoal.canUse` takes its spot from
`DefaultRandomPos.getPos(horse, 5, 4)` -- ten attempts past the build-height,
home, stable-destination and malus filters, the best walk-target value -- and
`start` walks to it, instead of a raw offset into a hill or over a pond.
`Cat.registerGoals` strolls out of water one tick in 100000
(`1.0000001E-5F`), not one in 1000.

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>

Claude-Session: https://claude.ai/code/session_01WZzq8MxShENtiMj2DMbm5G
2026-11-10 09:18:19 +01:00
Skr1zzzzandClaude Opus 5.5 a35b729ccf Keep game.rs from growing: the falling model cache takes the start position
`falling_model_vertices` works out the model seed from the block's starting
position itself, so its call in the falling-block loop fits on one line and
game.rs ends up shorter than on main (docs/layers.md#file-size).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 11:14:38 +03:00
Skr1zzzzandClaude Opus 5.5 aef2b895df Client: removing entities seats passengers off only the vehicles among them
`remove_entities` called `remote_set_passengers(id, &[])` for every removed
id, and each call walks every tracked entity (`seat_passengers`), the mobs,
the displays and the armor stands. With thousands of falling blocks removed
a tick, that was most of the packet's cost, for entities that carry nobody.

The packet's vehicles are now collected once, before the per-id cleanup:
the removed ids that some passenger, mob, display, armor stand, interaction,
controlled boat or the player's own ride names as its vehicle. Only those
get the call; every other per-id step keeps its order.

8,000 falling blocks, RemoveEntities of 256 ids (debug test build):
51.3 -> 1.3 ms.

Part of #13007

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 10:58:04 +03:00
LokiandAntigravity 27cded80d5 mobs: apply squid sinking velocity on land under NoAI (fixes #14467)
Co-Authored-By: Antigravity <[email protected]>
2026-11-10 15:36:11 +09:00
Skr1zzzzandClaude Opus 5.5 cbe92651a1 Single player: a tick that panics or fails still saves the world
Vanilla's MinecraftServer.runServer catches what a tick throws and still calls
stopServer in its finally, so an integrated server saves its players and levels
after a crash. Horizon's dedicated server already does this (stop_after_crash);
single player's server thread did not: a failed tick returned before
stop_integrated, and a panic unwound the thread, so everything since the last
autosave was lost, for the host and for every guest.

The integrated tick loop now catches a panic or a failed tick, stops through
stop_after_crash (the guest whose turn was interrupted keeps its last file) and
stop_integrated, and then returns the original error.

Part of #14394

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 09:28:07 +03:00
YourPancakes d51f612f54 perf(net): parse packet frame headers once 2026-11-10 09:24:54 +03:00
AkirabaneandClaude Opus 5.5 9c0c8ed4f4 Server: the mob data generation finalizes with is read when the world opens
The first mob a fresh chunk generates (spawnOriginalMobs) read the structure mob
finalizer's data on the server's tick: every mob type, the biome tags, the spawn
registries, the enchantments and the item tags, from the prepared resources.
Where those files are slow to read, that one read stalled the server for 6 to
7 s, a minute after players first walked into new terrain (#13857).

The terrain now starts that read on a thread of its own when its level loads
(every dimension but the End, whose chunks generate no mobs). The cell stays a
OnceLock: a mob that needs the data before the read ends waits for it, and the
data is read once.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 07:23:29 +01:00
masa-masa-0314andZCode 64e74bffd2 docs: propose friends overlay Part 2 spec
Co-Authored-By: ZCode <[email protected]>
2026-11-10 14:19:11 +08:00
GIGABAIT93andClaude Opus 5.5 5a452cc97f Ukrainian: the 96 strings uk_ua.json lacked (radial menu, HDR, fuel)
The controller's radial menu and its page, the HDR options' tooltips and
the recipe viewer's fuel tooltip. Placeholders and edge spaces match
en_us.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 08:06:12 +02:00
HiuoyandClaude f7c7e37999 Layer split: the "no prepared files" screen gets its page through a hook
A new get_the_game hook (horizon-hooks, re-exported by horizon-platform)
answers with the page to open and the screen's texts as translation keys.
horizon-official-servers answers it with Horizon's launcher text and
horizon_addresses::WEBSITE. With no hook, the engine screen and
missing_resources say how to prepare the files and offer only Quit.
The screen's strings are now translation keys. The no_resources.rs entry
leaves tools/addresses.json's allowed list.

Closes #14479

Co-Authored-By: Claude <[email protected]>
2026-11-10 09:59:36 +04:00
Nikita Maximilian GrimmandClaude Fable 5.2 db26868e4f HDR10 output: the last pass dithers its 10 bits
The float frame is smooth, but the last pass rounded it to the HDR10
swapchain's 10 bits as it was. Around 90 nits one PQ code is a 1% step
in light, and a clear noon sky is two or three codes tall, so it showed
as wide flat bands with hard edges (#12989's audit, F3).

The pass into the HDR10 swapchain now adds triangular noise of one code
either way to the PQ signal, each channel's own, as the last thing
before the write: over the world and the HUD alike, after the black
level lift. A gradient shows as grain one code deep and its mean is the
light meant.

- The noise is a fixed function of the pixel (PCG's 3D integer hash):
  no frame counter, state or texture, so a still picture stays still
  and HORIZON_STILL needs nothing.
- Black stays black: within a code of 0 or of the top the noise shrinks
  to what fits, so a signal of 0 is written as 0, one of 1 as 1023,
  nothing leaves the range, and near-black isn't lifted.
  In that last code the rounded mean leans to the end (a third of a
  code from it shows as an eighth): the accepted price of a true 0.
- A pipeline override constant (`pq_dither`) turns it on for the HDR10
  swapchain's pipeline alone. scRGB (a float swapchain), SDR, the SDR
  screenshot, recordings and the screenshot's HDR10 copy (`-hdr.png`)
  are as before, byte for byte; calibration's test pictures keep their
  exact codes.

Refs #12989

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-10 06:45:33 +01:00
5cfbc7e3ea Stop horses, llamas, camels and flying monsters climbing vines and ladders
AbstractHorse.onClimbable (horses, donkeys, mules, the undead horses, llamas,
trader llamas, camels, camel husks), Ghast.onClimbable, Phantom.onClimbable
and HappyGhast.onClimbable all return false, so LivingEntity never takes its
climbable branch for them: no 0.15 clamp and fall reset in handleOnClimbable,
no 0.2 climb against a wall in handleRelativeFrictionAndCalculateMovement, no
fall reset for a ridden mount in handleMoveVehicle, and isPushable keeps them
pushable in vines. Mob::on_climbable now answers that false, which every
caller (travel, pushEntities, the vehicle fall reset) already reads.

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>

Claude-Session: https://claude.ai/code/session_01WZzq8MxShENtiMj2DMbm5G
2026-11-10 06:36:40 +01:00
f7ff9d2410 Load structure templates faster and preserve numeric NBT fields
Stream template blocks from decompressed NBT instead of building the full
tag tree. Preserve list unwrapping, duplicate keys, limits and block order.

Follow StructureTemplate.load/loadPalette and ListTag.addAndUnwrap in
vanilla 26.3. Read numeric positions and state indices through the same
NumericTag conversions as CompoundTag.getIntOr and ListTag.getDoubleOr.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Codex (gpt-6.1-sol) <[email protected]>
Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
2026-11-10 06:35:48 +01:00
Mayonesu0andClaude Sonnet 5.5 37bbe0bdea A loaded armor stand saves the movement_speed its first tick makes
armor_stand_to_nbt kept a saved `attributes: []` over the stand's live
speed_instance (or_insert), so a stand loaded with an empty list never saved
the movement_speed instance LivingEntity.aiStep -> removeFrost creates.
Append it to the saved list once the stand has ticked, keeping the other
saved instances. The mob corpus gains a stand summoned with `attributes:[]`.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 06:26:14 +01:00
3f1f2fd3a8 Spawn phantoms under a glass roof
PhantomSpawner.tick asks Level.canSeeSky, which is "sky light 15 here"
(BlockAndLightGetter.canSeeSky), not a scan for a full collision cube:
glass and stained glass keep the sky light and spawn phantoms, while
stone, water and a slab roof do not. The advancement location predicate
read the same hand-rolled scan and now reads the sky light too.

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>

Claude-Session: https://claude.ai/code/session_01WZzq8MxShENtiMj2DMbm5G
2026-11-10 05:50:01 +01:00
Nikita Maximilian GrimmandClaude Fable 5.2 c8d9a748ac docs: HDR pass rules, tone-weighted blending is for linear light and sharpening eases off above white
Rule 3 told every pass that keeps a history to blend tone-weighted. Measured on Horizon's
own TAA, which accumulates the float frame's gamma, that raised the sun scene's largest
per-pixel standard deviation from 11 to 27 PQ code values. The rule now goes by the encoding
a pass accumulates: tone-weighted for linear light (a 0.2 pack's post buffers, or the frame
under linear storage), as stored for vanilla's gamma.

Rule 2 named the sharpening's limit above 1 as a fault to fix. Measured, sharpening the sun's
edge as the rest raises its flicker under upscaling by 55 to 69% for 1 to 6% more detail, so
the limit stays and the rule says why.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-10 05:22:27 +01:00
YourPancakes ab7d9f4c50 perf(world): avoid full-cube allocation in collision checks 2026-11-10 07:18:19 +03:00
Horizon agentandClaude Opus 5.5 8642db5c99 Renderer: the ExternalPipeline hook, with the shadow request and its culling in gfx/external.rs
Step a of #14035 (layer plan M6). An external shader pipeline implements
ExternalPipeline; the renderer calls begin_frame and shadow_request on world
frames, resize and reset_history, and draws vanilla's way where it doesn't
answer. Nothing sets one yet: each point is one Option test.

ShadowView moves from pack_post (re-exported there) and ShadowCull,
shadow_box, shadow_runs and shadow_casters from gfx.rs, unchanged: the
engine's side of the shadow frame point.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 05:17:39 +01:00
YourPancakesandChatGPT 4f1b40d9ca fix(render): keep binding cache out of oversized gfx.rs
Co-Authored-By: ChatGPT (GPT-6) <[email protected]>
2026-11-10 07:12:21 +03:00
4b8f61ed0e Count cave air as air for open-water fishing and the flying kick
FishingHook.getOpenWaterTypeForBlock tests !state.isAir()
(FishingHook.java:416), and isAir() covers air, cave_air and void_air;
ServerGamePacketListenerImpl.noBlocksAround filters on the same predicate
(ServerGamePacketListenerImpl.java:543). open_water_type now reads
BlockFacts::is_air, and no_blocks_in reads the hub's state table, so a hook
under a mineshaft's air counts as open water (treasure can be caught) and a
survival guest hovering there is kicked for flying after 80 ticks.

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>

Claude-Session: https://claude.ai/code/session_01WZzq8MxShENtiMj2DMbm5G
2026-11-10 04:58:50 +01:00
YourPancakes 81a8d04a1f perf(render): cache flat compute bind groups 2026-11-10 06:55:35 +03:00
martipopsandClaude Opus 5.5 309da9f821 On-screen keyboard: rustfmt the hint wrap's test
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 22:38:05 -05:00
Vinícius MoreiraandClaude Opus 5.5 e1a6c5e363 A dispensed item draws its constructor's motion from its own random
DefaultDispenseItemBehavior.spawnItem builds the item with the
five-argument ItemEntity constructor, which draws a default motion (two
nextDouble) from the item's own random before spawnItem sets the thrown
one. Horizon passed the thrown motion straight in and skipped those draws,
so every later draw from the item's random, starting with
moveTowardsClosestSpace's escape speed, came two doubles early.
spawn_item_thrown makes the draws; the dispenser, the vault and the trial
spawner use it.

Fixes #14537

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 00:30:20 -03:00
YourPancakes 89733192ca perf(render): evaluate sky octagon with half-planes 2026-11-10 06:26:52 +03:00
b973a2ed28 Post block_place when turtles and frogs lay eggs, and stop bred turtles bursting hearts
Turtle.TurtleLayEggGoal sets the egg then posts GameEvent.BLOCK_PLACE, and its lay-egg pitch comes from level.getRandom(); TryLaySpawnOnFluidNearLand posts the same event for frogspawn. Turtle.TurtleBreedGoal.breed overrides Animal.breed, so Animal.finalizeSpawnChildFromBreeding's entity event 18 never runs for a bred turtle (Frog.spawnChildFromBreeding still calls it).

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>

Claude-Session: https://claude.ai/code/session_01WZzq8MxShENtiMj2DMbm5G
2026-11-10 04:13:00 +01:00
Evrim CicekandClaude Opus 5.5 8392c05178 levels world border tests start from a new scratch world (#14654)
They opened HORIZON_HOME/h260 without removing it, so a second run found
the border the first had saved and its first worldborder set changed
nothing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 06:00:05 +03:00
J5CaseyandClaude Opus 5.5 d81eb7ffe9 Recipe viewers: a transfer takes worn, enchanted and renamed items
A recipe viewer's transfer (JEI's "+", Menu::transfer_recipe) counted and
moved only the stacks vanilla's recipe book may use
(Inventory.isUsableForCrafting: not damaged, enchanted or renamed). A
recipe with a tool as an ingredient, such as a mod rod made from a worn
fishing rod, then moved nothing, though crafting it by hand works and JEI
moves such items. The viewer's transfer now counts and moves every stack
of the item; the recipe book's placement keeps vanilla's rule.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01KtNXhvTSpY15Rjzwuqh8Nf
2026-11-09 20:58:32 -06:00
Evrim CicekandClaude Opus 5.5 03a6255967 pad::parity_tests: pad_press records the press as pad_button does (#14648)
Since the radial menu, Pad::holds_action reads the actions pad_button
records for a pressed button; the helper called pad_action directly, so
four tests read a held action as not held.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 05:53:11 +03:00
6539061751 Sneaking keeps the held food off campfires and candles answer pick-block
ServerPlayerGameMode.useItemOn skips the block's useItemOn while sneaking
with something in either hand, so CampfireBlock.useItemOn never places the
food; the item's own use (eating) runs instead. The block use it does
consume triggers ITEM_USED_ON_BLOCK, which the campfire placement now fires
too. CampfireBlock.douse and AbstractCandleBlock.extinguish post
BLOCK_CHANGE after the extinguish sound, so water on a lit campfire or a
lit candle is heard by sculk sensors, and CandleCakeBlock.getCloneItemStack
answers a cake for the plain candle cake as well as the dyed ones.

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>

Claude-Session: https://claude.ai/code/session_01WZzq8MxShENtiMj2DMbm5G
2026-11-10 03:52:09 +01:00
martipopsandClaude Opus 5.5 922c047e22 On-screen keyboard: the hints wrap, and the panel grows to hold them
The hint line under the keys had a fixed 30 dp strip, so a longer line (RT
Complete over chat, a translation, the pack's wider letters) ran past the
panel or had to shrink. The hints now wrap onto as many lines as they need
within the panel's width, in order, and the panel grows to fit them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 21:46:50 -05:00
MSedgeMC 44b65a8226 port_map: map TraderLlamaDefendWanderingTraderGoal.canUse player validity 2026-11-10 01:57:41 +00:00
MSedgeMC 3dfc15e206 test: a trader llama does not defend against a spectator 2026-11-10 01:57:41 +00:00
MSedgeMC f55124aaf7 fix: a trader llama ignores a spectator attacker 2026-11-10 01:57:22 +00:00
MSedgeMC 63e9728c2e port_map: map VexCopyOwnerTargetGoal.canUse target validity 2026-11-10 01:56:57 +00:00
MSedgeMC 3955c94576 test: a vex does not copy a dead owner target 2026-11-10 01:56:57 +00:00
MSedgeMC 63a4c6f103 fix: a vex ignores a dead owner target 2026-11-10 01:56:39 +00:00
martipopsandClaude Opus 5.5 175998d817 On-screen keyboard: Done finishes, a New Line key, and chat's suggestions by pad
The user's list for the controller's keyboard:
- Done (and Menu) on a book and quill's page or a sign was Enter, so it
  made a new line. It is now the screen's own Done (the book saves and
  closes, the sign closes with its text); a New Line key beside it makes
  the line there.
- Over chat, RT is Tab (CommandSuggestions.keyPressed): it fills in the
  selected suggestion and again the next, and the right stick picks the
  one before or after (Up and Down), so commands complete without closing
  the keyboard. The hint line shows RT Complete there.
- A key's label shrinks to fit when it is wider than its key (a long
  translation, New Line in the pack's font).

pad_menu_e2e: osk_book_done, osk_sign_done, osk_chat_suggestions.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:52:32 -05:00
DetimesandClaude Haiku 5.5 7059358371 Catalog test: accept living_death_message as a free function
living_death_message is a free function in horizon_client_lib::living_death,
so the test needs a set for that module like hunger, landing and chat.

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
2026-11-10 01:52:12 +00:00
Senjin 1f7d2adcbe Mobs: a mob sitting in a boat on water is not in the water, so a drowned or zombie in a boat burns in daylight
AbstractBoat.modifyPassengerFluidInteractionBox for mobs: a boat that isn't underwater keeps only the part of its passenger's box above the boat's top, and all of it when the boat reaches the passenger's head. Players had it (motion/fluids.rs); mobs measured their whole box, so a passenger's legs below the surface made it count as in the water: it never burned in daylight, the flow pushed it and boarding splashed.
2026-11-10 02:49:35 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 fa19462873 An ominous item spawner's loaded yaw wraps at 360 too
Entity.load ends with setRot, which takes the yaw and the pitch % 360.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 02:47:46 +01:00
MSedgeMC aa25cc6cb3 port_map: the game2-009 rows whose gaps are gone
Six rows still described behaviour the code has, and two of them cited
`Game::use_spawn_egg` in `creatures.rs`, where it no longer lives (it is
in `creatures/spawn_eggs.rs`):

- `SpawnEggItem.spawnMob`: the spawn path posts
  `minecraft:entity_place` at the spawn position (the dispenser path does
  too), so sculk sensors hear the egg; the path is now cited.
- `PotionContents.getAllEffects`: `brewing::contents_effects` is the
  potion's effects plus the stack's `custom_effects`, and
  `finish_drinking` applies them.
- `PiercingWeapon.makeHitSound`: the jab's sound plays at the target
  through `audio.play`, shared with the LAN guests.
- `TooltipDisplay.withHidden`: the loot table's
  `minecraft:toggle_tooltips` is applied in `loot.rs::Context::run`, next
  to the `/item modify` path.
- `SlotDisplay.Composite.resolve`: `Book::stacks` flattens a composite
  into its members' stacks.
2026-11-10 01:35:55 +00:00
MSedgeMC a445b69634 port_map: BlocksAttacks.hurtBlockingItem has the ITEM_USED award
The row said the statistic was never awarded; `after_block` awards it
before the blocked-damage check now.
2026-11-10 01:33:27 +00:00
Nikita Maximilian GrimmandClaude Opus 5.5 e65ca98d9d A tadpole with NoAI still ages and grows into a frog
Tadpole.aiStep counts the age up, and NoAI only skips serverAiStep, so a
NoAI tadpole ages, shows its age lock's particles and grows up like any
other. The NoAI branch of the mob tick now runs the aquatic aiStep parts
that NoAI doesn't skip from one place in aquatic.rs.

Fixes #14615

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 02:33:24 +01:00
MSedgeMC 5b0d6e7047 test: a shielded hit counts the shield as used
`a_blocked_hit_counts_the_shield_as_used` holds a shield in the off hand
with the use action running and calls `after_block` twice: a hit it takes
whole (5.0 blocked) takes the `used` count from 0 to 1, and a hit it
blocks nothing of takes it to 2. Without the award both stay 0.
2026-11-10 01:33:05 +00:00
MSedgeMC 2d7f975701 fix: a blocked hit counts the blocking item as used
`BlocksAttacks.hurtBlockingItem` awards `Stats.ITEM_USED` for the item
before it applies the damage function, and `LivingEntity.hurtServer`
calls it before its `damageBlocked > 0` branch: every hit taken while
using a blocking item counts it, even one it blocks nothing of.

Horizon's `after_block` applied the damage and broke the item at its max
damage but never awarded the statistic. It now does, on the
authoritative side (`remote.is_none()`, vanilla's `!level.isClientSide`),
before the early return for a hit that blocked nothing.
2026-11-10 01:32:40 +00:00
Evrim CicekandClaude Opus 5.5 d05c54b48e TNT silence tests run on an 8 MiB stack
Fixes #14628: the five source_silence_game tests overflowed the 2 MiB
test thread in a Windows debug build (4 MiB is enough) and took the whole
horizon-client lib run down.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 04:29:15 +03:00
9f9d226294 Post a shulker teleport's game event for sculk sensors
Shulker.teleportSomewhere calls level.gameEvent(GameEvent.TELEPORT,
oldPos, Context.of(this)) after setPos and before level event 2016. Emit
the matching MobEvent at the old block's centre so sculk sensors and
wardens hear the teleport.

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>

Claude-Session: https://claude.ai/code/session_01WZzq8MxShENtiMj2DMbm5G
2026-11-10 02:27:58 +01:00
MrCloudy2andClaude Opus 5.5 0a9ceaedb2 Every wind burst of a tick pushes an entity that ticks after the projectiles
apply_late_wind_pushes gave each entity only the first push held for it.

Harness: tools/test_wind_bursts_all_push_parity.py.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 02:12:50 +01:00
MrCloudy2andClaude Opus 5.5 d64e5172b6 A flying projectile is pushed by the water and lava it touches
Entity.baseTick's updateFluidInteraction applies the fluids' currents to a
thrown or hurting projectile (Entity.isPushedByFluid); only arrows had it.

Harness: tools/test_projectile_fluid_current_parity.py.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 02:12:49 +01:00
MrCloudy2andClaude Opus 5.5 faa8141c2f A cactus pricks a boat or a minecart a piston pushes
moveEntityByPiston's applyEffectsFromBlocks reaches CactusBlock.entityInside,
even for a push that moves the vehicle nowhere. The pricked vehicles are
hurt once the tick's pushes are over, so the targets found first stay valid.

Harness: tools/test_piston_cactus_vehicles_parity.py.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 02:12:42 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 4976814641 An ominous item spawner keeps the rotation, OnGround and fall_distance it was loaded with
Entity.load reads Rotation (its pitch clamped to -90..90 after % 360),
OnGround and fall_distance, and a spawner never moves or turns, so its
save writes them back. Horizon saved 0 for all three.

Fixes #14572

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 02:10:54 +01:00
MrCloudy2andClaude Opus 5.5 2d5e7839c6 A projectile hits primed TNT (PrimedTnt.isPickable)
Primed TNT joins the boats and minecarts in the list of objects every
projectile, the trident included, can strike: a wind charge bursts on it, a
snowball breaks on it, an arrow or a trident turns back.

Harness: tools/test_projectile_hits_tnt_parity.py.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 02:10:37 +01:00
MSedgeMC c06d152243 port_map: the spawn egg block-entity rows point at spawn_eggs.rs
`Game::apply_spawn_egg_entity_data` moved from `creatures.rs` to
`creatures/spawn_eggs.rs`, so `EntityType.appendCustomEntityStackConfig`
and `EntityType.updateCustomEntityTag` cited a function that is not
there. The leash row's gap is gone too: the tick checks the leashed
mob's life and its player holder's life and spectator state, so a dead
or spectating holder drops the lead.
2026-11-10 01:08:52 +00:00
Detimes 7a47e7a21f Merge remote-tracking branch 'upstream/main' into gap-line-of-sight 2026-11-10 01:06:25 +00:00
188817342b Firework rockets: honour Silent, post EXPLODE, count block launches
`FireworkRocketEntity.explode` posts `GameEvent.EXPLODE` from `getOwner()` at
the rocket before the damage, and the entity event passes `!isSilent()` to
`createFireworks`, which gates only the blast and the twinkle of
`FireworkParticles.Starter`, so a silent rocket keeps its sparks and flash.
A rocket launched from a block face also counts as a use, as
`ItemStack.useOn` does for `FireworkRocketItem.useOn`'s SUCCESS.

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>

Claude-Session: https://claude.ai/code/session_01WZzq8MxShENtiMj2DMbm5G
2026-11-10 02:05:57 +01:00
Detimes 56c7214447 Merge remote-tracking branch 'upstream/main' into gap-riding-getters 2026-11-10 00:54:38 +00:00
Mayonesu0andClaude Sonnet 5.5 ce5491d573 Keep a loaded mob's AbsorptionAmount as saved, negative or above the maximum
LivingEntity.readAdditionalSaveData stores AbsorptionAmount through
internalSetAbsorptionAmount, which does not clamp; only setAbsorptionAmount
(the Absorption effect, the damage path) clamps to [0, max_absorption].
mob_from_nbt clamped it to at least 0.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 01:51:00 +01:00
Skr1zzzzandClaude Opus 5.5 c1d32a0f93 Falling blocks: cache each model's vertices instead of rebuilding them every frame
The falling-block loop rebuilt every block's model vertices each frame: the
model lookup, two sprite hash lookups per face, shading and uv mapping. They
depend only on the block state and the model variant picked, so they are now
kept in model space per state and variant; each frame copies them, applies the
same translation as before and sets the block's light. The output is the same,
bit for bit.

8,100 falling sand blocks (fast profile, Windows 11, Ryzen 5 7500F, RTX 3070 Ti):
151 -> 247 fps, CPU 6.6 -> 4.0 ms per frame.

Part of #13007

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 03:50:36 +03:00
Nikita Maximilian GrimmandClaude Opus 5.5 861914b486 Merge origin/main into parity/bucket-mob-from-bucket
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 01:46:34 +01:00
foudretbr d1ca042ab8 fix(mobs): default cat variant to black when omitted (#14540) 2026-11-10 01:45:52 +01:00
Detimes 1e66c75589 Merge remote-tracking branch 'upstream/main' into gap-server-props-read 2026-11-10 00:40:40 +00:00
DUzzL 2f0e97de09 Turn fireballs towards their movement every tick
AbstractHurtingProjectile.tick calls ProjectileUtil.rotateTowardsMovement(this, 0.2F); the fireball kinds skipped it and kept their creation rotation.
2026-11-10 01:34:26 +01:00
Mayonesu0andClaude Sonnet 5.5 d5adb5fc37 Keep a painting's saved pitch (Rotation[1]) through save and load
Entity.load sets xRot from Rotation; Painting.readAdditionalSaveData's
setDirection only writes the yaw, so Entity.saveWithoutId writes the
loaded pitch back. painting_to_nbt wrote a fixed 0.0. It now reads the
pitch from the retained compound with setXRot's rules (non-finite
ignored, xRot % 360 clamped to +-90).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 01:33:54 +01:00
voduffandClaude Opus 5.5 7373b8cf73 The preedit tests take turns with the composition state (#14577)
preedit's state is one static for the process, and three tests change it while
cargo runs them in parallel: one's reset or should_suppress_key could land in
another, which failed at random in CI. They now hold a test-only lock.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 00:29:33 +00:00
alexcatdadandClaude Opus 5.5 5895774d7e Nix: services.horizon-server, a NixOS module for a Horizon server
The module runs `horizon-server run` as its own user in /var/lib/horizon-server, in a hardened unit
that stops it with SIGTERM (the world saves first) and turns the update check off, since Nix picks the
release. The first start writes server.toml with `horizon-server init` from `init`; every start then
sets `port`, `resources` and `settings` with `horizon-server config set`, which checks them and keeps
the owner's other edits. `openFirewall` opens the port.

It ships no game files and fetches none. The game files are either a folder the owner prepared
(`resources`, given as a string so they never enter the store), or `prepare`: horizon-prepare run before
the server on a server jar and a Minecraft folder the owner put on the machine, in a unit with no network.

nix/tests/server.nix checks it in a VM without any of Mojang's files. Fixes #13841.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 02:28:51 +02:00
Mayonesu0andClaude Sonnet 5.5 68ad338029 A saved last_hurt_by_mob is read back and ends as in vanilla
LivingEntity.readAdditionalSaveData reads last_hurt_by_mob as a lazy
EntityReference (the UUID) and sets lastHurtByMobTimestamp to
tickCount - ticks_since_last_hurt_by_mob. getLastHurtByMob only answers once
the UUID resolves to a living entity in the level, and baseTick ends the
record when that entity is dead or more than 100 ticks old.

- Mob.ai keeps the bare UUID (last_hurt_by_ref) until the game resolves it
  into last_hurt_by (a mob, or the player) before the mobs' baseTicks.
- baseTick (mobs/hurt_by.rs) ends a resolved record as vanilla does; a
  reference naming nothing stays, keeps aging, and is saved as it is.
- setLastHurtByMob(who/null) clears an unresolved reference.
- The tests' world loads the 2-column neighbourhood entities need to tick.
- The corpus gains a pig naming an entity that arrives later.

Part of #12830

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 01:28:46 +01:00
Detimes bd6bf2422b Merge remote-tracking branch 'upstream/main' into gap-mob-death-message 2026-11-10 00:25:43 +00:00
MSedgeMC f888d26173 port map: finish the moved-module citations, including the render and remote splits 2026-11-10 00:23:20 +00:00
Evrim CicekandClaude Opus 5.5 827ff272ab A rocket's first tick doesn't halve its fall in lava
Entity.isInLava is false while firstTick holds; the rocket cleared its
first-tick flag before the lava check. The `rocket fall distance` group
gains a rocket summoned inside lava.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 03:22:22 +03:00
MSedgeMC 72879fc35b port map: point citations at the modules they moved to
The game, entity_nbt, stations, creatures, minecarts, particles and remote modules were split this week; twenty rows
still named the file a function left.
2026-11-10 00:17:52 +00:00
voidsip 38b2b41791 Name the arrow expiry harness for its actual coverage 2026-11-09 18:12:25 -06:00
fbiandClaude Sonnet 5.5 134eb9f0a6 Launcher: uninstall deletes the data of a moved data location
`uninstall --remove-data` only deleted backend::data_dir() when the folder was
called `horizon-launcher`, a check made for HORIZON_LAUNCHER_HOME ("may point
anywhere"). Once the player has moved the data (Settings > Data folder), the
data folder is the location's launcher root, which the move always names
`<folder>/launcher`, so the check failed for every moved install: nothing was
deleted, and the build folder, removed inside the same `if`, stayed too.

A moved location's launcher folder is now deleted when it is the configured one
and has the name the move gives it; its build folder when it is `<folder>/build`
or inside the launcher folder. The game folder is never touched, and a location
file edited by hand to name another folder is still refused. Under
HORIZON_LAUNCHER_HOME the old name check stays.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 00:09:32 +00:00
DUzzL f2b0c69f72 Wrap item frame and painting facing bytes like Direction.from3DDataValue/from2DDataValue 2026-11-10 01:04:45 +01:00
voidsip fc83f106f2 Encode gateway fixture positions as NBT double bits 2026-11-09 18:04:41 -06:00
Riccardo GuerrieroandClaude Opus 5.5 5f40c762f6 Move the bred offspring's draws and their test out of creatures.rs
creatures.rs is over the 3000-line limit (docs/layers.md#file-size): the
coat coin, the wolf pup's voice and the test of both move to
game/creatures/bred_draws.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 01:03:44 +01:00
46988764a5 Bred wolf pups get a random voice, and pups take their coat coin from the parent
Wolf.getBreedOffspring picks the pup's voice with
WolfSoundVariants.pickRandomSoundVariant from the breeding parent's random,
after the collar colour mix; Horizon left every bred pup on the classic
voice. The cow, pig, chicken, fox, cat and wolf getBreedOffspring also draw
the coat coin (which parent's variant the baby gets) from the parent's own
random, where Horizon spent a level draw for every species but the
mooshroom.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Codex (gpt-6.1-sol) <[email protected]>
Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
2026-11-10 01:03:38 +01:00
valuttaandClaude Opus 5.5 2289b7a732 AGENTS.md: how an agent reviews, after rdbtCVS's review prompt
Asked by a maintainer to review open pull requests, an agent takes them
one at a time, oldest first, skipping drafts, its person's own and ones
already reviewed. It checks function, regressions, tests and scope,
reports only points with a file:line, each marked by severity, and
submits Approve or Request changes, never a plain comment. Based on the
review prompt rdbtCVS shared.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 00:57:43 +01:00
MSedgeMC c9cf9cb18b port_map: SocialInteractionsScreen.init and mouseClicked 2026-11-09 23:57:24 +00:00
MSedgeMC 33754abe21 test: a click in the social search box is the box's own
The box's rectangle and the pixels just outside it; the list starts below, so
nothing else can claim the click.
2026-11-09 23:57:24 +00:00
MSedgeMC 5a67941d3f fix: a click in the social screen's search box places its caret
SocialInteractionsScreen.mouseClicked gives super.mouseClicked the widgets
first, so the search EditBox's onClick runs on a click inside it and puts the
caret under the pointer. Horizon's social click only knew the tabs, the rows
and Done; clicking the search box moved nothing.
2026-11-09 23:57:24 +00:00
MSedgeMC 4471b929a3 port_map: DeathScreen's cause links and hover 2026-11-09 23:57:15 +00:00
MSedgeMC 89e9f69c8c test: a death cause's link is the glyph under the pointer
The cause line is four 6-pixel glyphs centred in 427; the last two carry an
open_url click event, and only the pointer inside a glyph of the line finds
it.
2026-11-09 23:57:15 +00:00
MSedgeMC 80ae207954 fix: the death screen's cause behaves as a link
DeathScreen.mouseClicked runs a ClickableStyleFinder over the cause line and
opens an open_url click event through clickUrlAction; its render visits the
line with HoveredTextEffects.TOOLTIP_AND_CURSOR. Horizon drew the cause with
text_styled and ignored clicks and hover on it.
2026-11-09 23:57:15 +00:00
69a156f587 Mobs: the mob loop in islands on several threads (HORIZON_PARALLEL_TICK)
With the parallel tick on, tick_creature_entities splits its mobs into
islands: a mob's reach for the tick (its move, a teleport, what it reads
around itself, the village data, the mobs it is linked to by id) meets only
mobs of its own island. Islands are packed into one group per thread; each
group runs its mobs' turns in list order (creatures/mob_group.rs), draws the
level random from a lane of one OrderedDraws, and reads a mob of another
group by id as the serial loop would show it (Board/Foreign). The results
merge back in list order. HORIZON_PARALLEL_TICK=verify also runs the serial
loop on copies and logs a tick that differs.

Each turn still decides whether its mob is loaded and ticking, as the serial
loop does, so the plan gives every mob a ticking mob's reach. The loop stays
serial with native mods, on a sampled tick, under HORIZON_TEST_SEEDS, with
fewer than 256 ticking mobs, with a wither or warden, or when one island
holds three quarters of the ticking mobs. A group's thread holds its lane's
LaneDone, so a panic in one group lets the others finish and reaches the
caller.

Tests: the plan's islands and packing, and an end-to-end run of three herds
in three groups against the serial loop (mobs, level random, death marks).

From kacpherek's #13644 (mob_islands.rs); for #14457.

Co-Authored-By: Kacper <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 23:55:41 +00:00
voidsip d1d35697a1 Clarify the gateway phase fixture validation boundary 2026-11-09 17:52:57 -06:00
voduff d68f241a0e Merge remote-tracking branch 'origin/main' into fix/12038-cube-body-item-replace 2026-11-09 23:52:09 +00:00
voidsip 6a9154801f Check real gateway cooldown dispatch before and after movement 2026-11-09 17:51:55 -06:00
valuttaandClaude Opus 5.5 176f5b3475 AGENTS.md: review pull requests only when asked, post only findings
An agent asked for some reviews swept about 300 open pull requests, most
reviews saying it found nothing. Agents now review a pull request only
when its author or a maintainer asks for it, post only findings someone
can act on as a real review, and skip reviews that found nothing.
Maintainers' own agents may run wider passes when the maintainer asks.

Closes #14545.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 00:48:26 +01:00
MSedgeMC 34d39e0da0 Rebuild the weapon wear read on current main 2026-11-09 23:48:14 +00:00
Evrim CicekandClaude Opus 5.5 6d0d16d48b Move the experience orb NBT out of entity_nbt.rs
Part of #14043: a pure move to entity_nbt/orb.rs, so the orb's
fall_distance (#14440) lands in the moved file.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 02:47:49 +03:00
MSedgeMC 7b73baf589 Rebuild the weapon component read on current main 2026-11-09 23:45:27 +00:00
Nikita Maximilian GrimmandClaude Opus 5.5 a45aa615ef A mob from a bucket is not persistent, a sulfur cube keeps its bucket's age, and tadpoles never despawn
MobBucketItem.spawn loads the bucket's entity data and sets fromBucket,
never PersistenceRequired. SulfurCube.loadFromBucketTag reads its own
lowercase age keys, and Tadpole.fromBucket is always true, so
requiresCustomPersistence keeps every tadpole.

Fixes #14538

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 00:42:55 +01:00
foudretbr ded3d99728 fix(game): streamline dragon geometry and snap targets to respect file size limit 2026-11-10 00:26:23 +01:00
DUzzL 72c06622be Turn a shot firework rocket to its motion like Projectile.shoot 2026-11-10 00:25:30 +01:00
72fe5b13ba Mobs: the mob loop's body moves into a group of mobs, still one thread
The per-mob turn of tick_creatures (its world view, brain, movement, events,
riders, deaths and pushes) moves into creatures/mob_group.rs as Group::tick, on
a Group that owns the mobs it ticks, their snapshot grid, pushes, events and the
level random. tick_creatures builds one group of every mob and runs it in list
order, so nothing changes: whether a mob is loaded and ticking, and the
per-chunk difficulty, are still decided at the mob's own turn. The parallel tick
(#14457) will run several groups. creatures.rs shrinks by 177 lines.

From kacpherek's #13644.

Co-Authored-By: Kacper <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 23:24:28 +00:00
MrCloudy2andClaude Fable 5.2 c7ca421167 A geyser goes through scaffolding seen from below
PotentSulfurBlockEntity reads its column with
CollisionContext.positionContext(the height under it); the shapes were read
with no context, so scaffolding kept its full top and stopped the geyser.

Harness: tools/test_geyser_scaffolding_parity.py.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-10 00:22:30 +01:00
aaf5aa6fcf Mobs: what the parallel mob tick's groups read, behind no switch
MobSlot lets the mob loop's helpers (apply_brain_events, riders, a school's
recruits) work on a group's borrowed mobs as on the list's own; MobWorld.foreign
answers a lookup by id of a mob in another group (ForeignSeen); PendingPushes::
append puts one group's pushes after another's, each mob's in order; and a
SeenGrid of part of a list can answer as the whole list's grid would
(answering_as). Nothing sets them yet: every caller passes the list's own mobs
and foreign: None. For #14457.

From kacpherek's #13644; the grid follows main's scan threshold.

Co-Authored-By: Kacper <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 23:20:15 +00:00
eaa027179b Random: a lane that panics is marked finished, so the others don't wait on it
LaneDone, held by a lane's thread while it runs, marks the lane finished when
dropped, also while a panic unwinds: the other lanes run to their end instead of
waiting forever for its items, and the panic reaches the caller when it joins
them. Review finding 1 on #13644; the test times out without it.

Co-Authored-By: Kacper <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 23:20:15 +00:00
bbaa73ec43 Random: one LegacyRandom stream drawn in list order from several threads
OrderedDraws shares a LegacyRandom among lanes that each run their part of a list
in list order: an item's first draw waits until every other lane has passed it,
so every draw comes out as one thread would make it. A copy of a gated random
draws on its own. For the parallel mob tick (#14457).

Taken from kacpherek's #13644.

Co-Authored-By: Kacper <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 23:20:15 +00:00
d3c8eb00ea Parallel tick: the dropped items' own moves worked out ahead on helpers
While the parallel tick is on, each ticking item's move (ItemEntity.tick up to its
merge) is worked out on a copy on the helper threads before the item loop, and
put in place at its turn. The move reads only the item, the blocks, the obstacle
boxes and the border, none of which the loop changes; an item inside a block
escapes at its own random's speed, which the copy carries. A merge before its turn
that changed its count, age or pickup delay sends it back to the move at its turn,
as do native mods and a mod listening to the round. HORIZON_PARALLEL_TICK=verify
makes each move again at its turn on a copy and logs a difference.

The item's tick reads ItemTables (the blocks, collisions, motion tables, border)
so helpers run the same code the turn runs; ObstacleGrid moves to
entities/obstacles.rs and gains a query with the caller's scratch. A test ticks
307 items (escaping blocks, in water, falling, merging) both ways and checks the
items and the level's random come out the same.

From kacpherek's #13644, redone on main's item tick.

Co-Authored-By: Kacper <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 23:20:11 +00:00
Riccardo GuerrieroandClaude Opus 5.5 c1540bd026 Keep animal_brain.rs at main's line count
The file is over 3000 lines and may not grow.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 00:17:15 +01:00
8683a6b570 Post sniffer digging vibrations and mute the right sniffer sounds
Sniffer.emitDiggingParticles posts GameEvent.ENTITY_ACTION at the head block every ten ticks for the whole dig, outside the dust window. Only sounds that go through Entity.playSound are silenced: the state transitions and the seed drop, not playLocalSound's searching sound or the block's hit sound. A bred pair's egg takes its push from ItemEntity's own random.

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>

Claude-Session: https://claude.ai/code/session_01WZzq8MxShENtiMj2DMbm5G
2026-11-10 00:14:57 +01:00
DetimesandClaude Haiku 5.5 d88c0d9011 Adapt living death message test impls to the generated hook shape
The generated Hooks trait takes the event only: entity, kind and message are
its fields, so the test impls take one argument.

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
2026-11-09 23:11:58 +00:00
SolenopsisbotandCodex 62ed49d4a4 fix: satisfy game file size check
Co-Authored-By: Codex <[email protected]>
2026-11-10 10:05:52 +11:00
DetimesandClaude Haiku 5.5 9f861b1624 Use contains_key in entity lookup test (clippy unnecessary_get_then_check)
Co-Authored-By: Claude Haiku 5.5 <[email protected]>
2026-11-09 23:04:34 +00:00
foudretbr d42021f1d2 fix(game): preserve area effect cloud Motion across chunk save and reload (#14482) 2026-11-10 00:02:31 +01:00
Evrim Cicek bdf33dd2a3 Merge remote-tracking branch 'upstream/main' into tests/mob-landing-neighbourhood 2026-11-10 02:01:58 +03:00
MrCloudy2andClaude Fable 5.2 016630efda Keep creatures.rs from growing: the vehicle re-tick lives in wind.rs
The file-size rule (docs/layers.md#file-size). hurt_vehicle moves with it.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-10 00:01:01 +01:00
Mayonesu0andClaude Opus 5.5 2b3062609c Compare a broadcast player target by distance in the hoglin brain
HoglinAi.setAttackTargetIfCloserThanCurrent compares the current and the
broadcast target by position (BehaviorUtils.getTargetNearestMe), players
included. The flush kept the current target whenever either side was
Who::Player, since Who::Player is each mob's own player. The broadcast now
carries the hoglin that sent it, and the flush places the new target as
that hoglin's player and the current one as the receiver's (host, or the
LAN guest in mob_players). A receiver that takes another mob's player is
bound to that player.

Part of #12788.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 23:59:53 +01:00
Horizon agentandClaude Opus 5.5 9b8c6dc0b0 Forks' pull requests run verify on GitHub's runners, never on the desktop's
A fork's workflow run sees no repository variable, so SELF_HOSTED_CI can't move it: a fork's pull
request whose number ends in 0 or 1 always asked for the desktop's horizon-checks runners, even with
SELF_HOSTED_CI=light (#14131 waited there for hours). With the desktop off, its required verify
would wait a day, fail, and wait again on a re-run, so the pull request could never merge. verify,
launcher, mod-registry and telemetry-server now pick our checks runners only for this repository's
own branches; a test pins it for every pull_request workflow.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 23:59:07 +01:00
MrCloudy2andClaude Fable 5.2 3a953f4f9a A geyser launches projectiles, boats and minecarts
PotentSulfurBlockEntity.LAUNCH_ENTITY_TICKER pushes every entity in the
column; only mobs, items and primed TNT were.

Harness: tools/test_geyser_launch_parity.py.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-09 23:55:52 +01:00
Horizon agentandClaude Opus 5.5 91b743b549 Telemetry and mod registry images copy every crate their lockfile builds
Railway's builds of both services fail since horizon-telemetry gained horizon-addresses (and the
platform crates) and horizon-mods gained horizon-release-sign: the Dockerfiles copy a fixed list of
crates, so cargo can't find the new path dependencies. The old deployments keep running, so nothing
looked broken. Copy every path crate of each service's own Cargo.lock, watch them in railway.json,
and test both lists against the lockfiles so the next new dependency fails verify instead.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 23:54:45 +01:00
Horizon agentandClaude Opus 5.5 1ff33fd853 Merge origin/main into split/cosmetics-settings
extras.rs: main dropped DiscordPresence, this branch dropped the cosmetics
rows: both removals kept. layers.json: main dropped client-lib -> theme,
this branch dropped client-render -> cosmetics: both removals kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 23:51:45 +01:00
Evrim CicekandClaude Opus 5.5 59923087d5 mob_landing_support_tests load the 5x5 chunks entities need to tick
Fixes #14493: since #13953 an entity ticks only once every chunk within 2
columns is loaded; with only chunk (0, 0) the pig never fell.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 01:51:39 +03:00
MSedgeMC 763b6f6c8c port_map: map WitherBoss side-head target sight 2026-11-09 22:49:37 +00:00
MSedgeMC 88c287eb7e test: a wither's side head does not pick through a wall 2026-11-09 22:49:37 +00:00
MSedgeMC 94f499bdbe fix: wither side heads need sight of a target 2026-11-09 22:49:37 +00:00
MSedgeMC f943e82857 port_map: map EvokerWololoSpellGoal.canUse visibility range 2026-11-09 22:49:30 +00:00
MSedgeMC 6efab1f890 test: an invisible blue sheep is not wololoed from five blocks 2026-11-09 22:49:30 +00:00
MSedgeMC 34b5ab04b0 fix: scale the evoker's wololo range by visibility 2026-11-09 22:49:30 +00:00
MSedgeMC a76ea00dde port_map: map SulfurCubeTemptGoal.canUse visibility range 2026-11-09 22:49:22 +00:00
MSedgeMC d7c7e06634 test: an invisible player tempts a sulfur cube only within two blocks 2026-11-09 22:49:22 +00:00
MSedgeMC 7f8a855d78 fix: scale a sulfur cube's tempt range by visibility 2026-11-09 22:49:22 +00:00
c9a064bbc5 Random: a lane that panics is marked finished, so the others don't wait on it
LaneDone, held by a lane's thread while it runs, marks the lane finished when
dropped, also while a panic unwinds: the other lanes run to their end instead of
waiting forever for its items, and the panic reaches the caller when it joins
them. Review finding 1 on #13644; the test times out without it.

Co-Authored-By: Kacper <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 22:48:09 +00:00
3831c9df9f Random: one LegacyRandom stream drawn in list order from several threads
OrderedDraws shares a LegacyRandom among lanes that each run their part of a list
in list order: an item's first draw waits until every other lane has passed it,
so every draw comes out as one thread would make it. A copy of a gated random
draws on its own. For the parallel mob tick (#14457).

Taken from kacpherek's #13644.

Co-Authored-By: Kacper <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 22:46:51 +00:00
voidsip 8f96b0b6bb Format the level-up sequence setup 2026-11-09 16:46:37 -06:00
voduff 3365857605 Merge remote-tracking branch 'origin/main' into fix/14424-legacy-chunk-entities 2026-11-09 22:36:25 +00:00
Evrim Cicek 5f68b5da25 Merge remote-tracking branch 'upstream/main' into fix/landing-vibrations 2026-11-10 01:36:24 +03:00
voidsip c98211b51b Send refreshed merchant offers after trade level-ups 2026-11-09 16:35:29 -06:00
voidsip 21023c478e Cover merchant offer packets at the level-two boundary 2026-11-09 16:35:28 -06:00
SolenopsisbotandCodex 71709ecb3f fix: keep save backpressure out of oversized module
Co-Authored-By: Codex <[email protected]>
2026-11-10 09:34:42 +11:00
itarqos5andClaude Sonnet 5.5 7cc8eae1d1 Tests: leaves count their distance from a log and decay only at seven
`LeavesBlock.updateDistance`: DISTANCE is one more than the smallest of the six face neighbours', a
`#minecraft:logs` block counting 0, another leaf its own DISTANCE and anything else 7, spreading one
step per scheduled tick. A row of nine leaves reads 1..6 then the cap and returns to the cap when the
log goes; logs, stripped wood and nether stems count while planks, stone, fences and leaves at the cap
do not; the six sides count and the diagonals do not. Only a non-persistent leaf at 7 is removed by a
random tick (`decaying`). The existing test covered a single leaf at 7 and one log beside a leaf.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 22:29:05 +00:00
MrCloudy2andClaude Fable 5.2 ca01155bce A wind charge passes a vehicle an earlier charge of the tick broke
Vanilla ticks entities one after the other, so a charge never meets a boat or
minecart that an earlier charge already broke. The tick's hits are all found
first here, so such a charge ticks again without the broken vehicle.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-09 23:28:23 +01:00
Evrim CicekandClaude Opus 5.5 2740471e60 A falling block a piston pushes keeps Entity.move's fall bookkeeping
A piston's push is Entity.move(MoverType.PISTON): the drop adds to the
falling block's fall, and landing runs fallOn and posts HIT_GROUND in
that step. Review finding on #14440.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 01:28:02 +03:00
3f63aa1f5c Run a waxed wall hanging sign's click command instead of chaining a sign
WallHangingSignBlock.shouldTryToChainAnotherHangingSign lets the hanging sign
in hand chain onto the sign only when the side the player faces cannot execute
its click commands (SignBlockEntity.canExecuteClickCommands), so a waxed sign
with a run_command hands the click to SignBlock.useWithoutItem, which runs it
and returns SUCCESS_SERVER.

The client now answers that for the wall case as it already did for the
ceiling one (chains_hanging_sign: isHittingEditableSide by the sign's FACING
axis), so the click runs the command, plays the waxed-sign failure and places
nothing; the world sim's unconditional Pass for the wall edge is gone.

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>

Claude-Session: https://claude.ai/code/session_01WZzq8MxShENtiMj2DMbm5G
2026-11-09 23:26:38 +01:00
itarqos5andClaude Sonnet 5.5 5885feff36 Tests: a stair takes its corner shape from the stairs beside it
`StairBlock.getStairsShape`, recomputed by `updateShape` when a horizontal neighbour changes: a
same-half stair on the facing side, turned across, makes an OUTER corner (LEFT when it faces the
subject's counter-clockwise), else one behind it makes an INNER corner; either is refused when the
block across the corner is a stair like the subject (`canTakeShape`), and the outer test is first.
Twelve arrangements: both handednesses, same axis, other half, a non-stair, both neighbours at once,
and both refusals.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 22:26:10 +00:00
Solenopsisbot a151569d72 Merge remote-tracking branch 'origin/main' into t3/fix-high-contrast-pack-hang 2026-11-10 09:23:34 +11:00
Vinícius MoreiraandClaude Opus 5.5 bad4b0ef9a A horse draws its golden armour
The horse armour layer took its texture from the item id
(golden_horse_armor -> horse_body/golden), but the golden armour's
equippable asset_id is minecraft:gold, whose horse_body texture is
entity/equipment/horse_body/gold. The layer found no texture and drew
nothing. horse_armor_asset maps each horse armour to its equipment
asset; a test checks every vanilla horse armour against items.json and
equipment/*.json.

Fixes #13826

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:23:28 -03:00
SolenopsisbotandCodex 9482ae7472 Apply saved present mode before gameplay
Co-Authored-By: Codex <[email protected]>
2026-11-10 09:20:12 +11:00
voduffandClaude Opus 5.5 3e6842009d A chunk saved before 1.17 loads the entities it keeps (#14424)
SerializableChunkData.postLoadChunk adds a full chunk's own entities list
(ServerLevel.addLegacyChunkEntities): a chunk last saved before entities had
files of their own keeps them there. Horizon read only entities/, so an old
world's minecarts and mobs were gone. A re-save writes no such list and the
entities go to entities/, as in vanilla.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 22:19:35 +00:00
2b01dbcd5e signs keep one editor at a time and only open on plain text
SignBlock.openTextEdit gives the sign to one player (`playerWhoMayEdit`,
kept on the block entity like vanilla's SignBlockEntity, not on the
connection): another player's click opens nothing, their write is dropped
(`SignBlockEntity.updateSignText`) and their dye, ink sac or honeycomb is
refused without being used (`SignBlock.useItemOn`). The write releases the
sign, and a sign tick drops a holder who walked out of the block
interaction range plus four blocks or left the world
(`SignBlockEntity.tick`, `playerIsTooFarAwayToEdit`).

The editor also opens only on a side whose lines are plain text
(`SignText.hasEditableText`): on the click and after placement
(`SignBlock.setPlacedBy`), so a translatable or score line is no longer
replaced by typing into it.

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>

Claude-Session: https://claude.ai/code/session_01WZzq8MxShENtiMj2DMbm5G
2026-11-09 23:19:11 +01:00
SolenopsisbotandCodex 3a22ba32c2 Fix texture metadata parsing during pack startup
Co-Authored-By: Codex <[email protected]>
2026-11-10 09:17:35 +11:00
voidsip ca8cc5a43b Prove nested native damage cannot inherit an outer credited source 2026-11-09 16:17:25 -06:00
0xRinne 92125370de Merge branch 'main' into up2/metal-sprite-arrays 2026-11-09 16:16:54 -06:00
SolenopsisbotandCodex dbef6e3e14 fix: bound asynchronous chunk save backlog
Co-Authored-By: Codex <[email protected]>
2026-11-10 09:15:19 +11:00
5f61de11d9 Parallel tick: mob entity data and attributes from plain tables, on helpers
mob_meta reads SyncData instead of the hub (SyncData::effect_meta, with main's
shown_color; Hub::effect_meta calls it), and the attribute instances read
AttrSources (the catalog, the items, /attribute's maps) instead of Game. While
the parallel tick is on, the mobs' entity data and attributes are built after
the pass on the pool and put in place (game/lan/mob_states.rs); with it off
they are filled in place as before, with no new Vecs. What is read in order
(idle state, lead, equipment network ids) stays in the pass. A test checks the
states put in place equal those built in place.

From kacpherek's #13644.

Co-Authored-By: Kacper <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 22:14:03 +00:00
1ef6e26dfa Parallel tick: mob prep and random tick masks on the helper threads
Before the mob loop, each mob's climbing check, snapshot entry and nearest
player read only that mob, the blocks and the players, so they are mapped on the
pool from 256 mobs (creatures/prep.rs, out of creatures.rs). A mob that moved
before its turn is asked its nearest player again where it stands. The masks the
random ticks read are built on the pool before the random ticks; the draws and
the ticks stay serial. The random tick test runs a fourth path with its masks
built ahead and checks it gives vanilla's scan's events and random values.

From kacpherek's #13644.

Co-Authored-By: Kacper <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 22:14:03 +00:00
2c9aa0ac86 Parallel tick: one pool of helper threads, behind HORIZON_PARALLEL_TICK
horizon_client::parallel: one rayon pool made once (at most 8 threads, the server
thread takes parts too), par_map that gives every part's results in the items'
order, and the HORIZON_PARALLEL_TICK switch (off by default; 1 or verify turn it
on). With the switch off every phase runs its serial code on the server thread.
Goal is Send + Sync, so mobs can be read from helper threads.

From kacpherek's #13644.

Co-Authored-By: Kacper <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 22:14:03 +00:00
605f1ead0c Anticheat: join the log's writer thread when the anticheat drops
The writer thread was detached, so the last lines handed to it could be lost when
the server stopped. LogWriter now closes the channel and joins the thread on drop.
The thread writes the lines waiting together and flushes once none is left.
Tests: every line reaches the file in order by the drop, and dropping the writer
waits for a thread that is behind.

Co-Authored-By: Kacper <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 22:13:43 +00:00
MrCloudy2andClaude Fable 5.2 eedbb1717c A wind charge's hit damages a boat or a minecart
AbstractWindCharge.onHitEntity hurts what it hit by one point before the
burst; a vehicle takes it through VehicleEntity.hurtServer and breaks on the
fifth hit of a tick. wind_charge_damage only looked among the mobs.

Harness: tools/test_wind_charge_vehicle_parity.py.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-09 23:11:56 +01:00
Horizon agentandClaude Opus 5.5 92dab6cafc Pages v2: text fields, and a page in the title screen's Friends icon slot
horizon_hooks::pages gains Row::Field (Row::field(label, text).max(n)),
vanilla's EditBox on a page drawn as its own screen, with Act::Edit (each
change, before any other act or redraw reaches the page) and Act::Submit
(Enter); Place::Title, the title screen's Friends icon slot, unchanged in
place and look; Page::back, the label of the button that leaves such a
page; and pages::connect, for a Title page to join a server.

The icon opens the first Title page as multiplayer::Screen::Page, a form
laid out as Join a Friend (multiplayer/page.rs); its fields go through the
title screen's caret like every field of its own (focus, typing, caret
keys, selection, copy, paste, the max length), Tab walks fields then
buttons. Without a Title page the icon opens Join a Friend while a
resolver is registered, else it stays inactive. The options shell skips
fields. title.rs loses lines.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 23:11:27 +01:00
itarqos5andClaude Sonnet 5.5 3adf1bbe97 Tests: a door half also reads the power beside its other half
`DoorBlock.neighborChanged` is `hasNeighborSignal(pos) || hasNeighborSignal(the other half)`. Power
written raw beside one half, then a block update that reaches only the other half, opens it and the
half that copies it. The existing door test could not see this term: the half with power beside it
always saw it itself, so dropping the other-half check left it green.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 22:07:58 +00:00
itarqos5andClaude Sonnet 5.5 7fad4b662d Tests: the hopper test now tells a quasi-connected hopper from a plain one
The diagonal cases put power where it sends the hopper no block update, so the hopper never looked
and the case passed for any implementation: mutating the port to be quasi-connected left the test
green. Each such case now pokes the hopper with a block on a side no case uses, as the piston and
dispenser tests do, and the same mutation turns it red.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 22:07:58 +00:00
Evrim CicekandClaude Opus 5.5 fad8e4e555 Primed TNT and falling blocks post hit_ground when they land
Part of #14440: PrimedTnt and FallingBlockEntity move with Entity.move and
keep its checkFallDamage, which posts HIT_GROUND after the block's fallOn
when they land after a fall (a piston's push too, for TNT). Extends the
`entity landing sensors` command-exec group.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 01:06:08 +03:00
itarqos5andClaude Sonnet 5.5 2139b4464a Tests: a fence connects by vanilla's rules
`FenceBlock.connectsTo` is `!isExceptionForConnection(state) && faceSolid || sameFence || gate`.
Eighteen neighbours: any full face joins (stone, dirt, glass) except leaves, barriers, pumpkins and
melons; torches and air do not; another fence joins only within its own family (wooden against
nether brick does not); a gate joins only when its facing lies across the line.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 22:03:11 +00:00
Horizon agent dbbe0e6bf5 Merge origin/main (brings #14090's test_git_slim fix) 2026-11-09 22:59:17 +01:00
Mik00000 c37356308c fix(client): synchronize look rotation and update pvp hud for spear stab (fixes #14092) 2026-11-09 23:53:33 +02:00
5a78eee6ca Items spilled from a destroyed shulker box or bundle pop upward again
`ItemUtils.onContainerDestroyed` makes each stack a destroyed item held
with `new ItemEntity(level, x, y, z, stack)`, whose motion is 0.2 up and
0.2 less 0.1 sideways from the *item's* own random
(ItemEntity.java:61-66). The spill drew three doubles from the level
random instead, so the stacks barely rose and the level's stream
advanced three times per stack. Route it through `spawn_item_own`,
which already draws the four-argument constructor's motion (#13938).

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>

Claude-Session: https://claude.ai/code/session_01WZzq8MxShENtiMj2DMbm5G
2026-11-09 22:49:34 +01:00
Evrim CicekandClaude Opus 5.5 7d0ab4d38a post_landing_event takes its state from landing_state
The block a landing names (mainSupportingBlockPos, else getOnPosLegacy)
is worked out in one place for items, armor stands and rockets.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 00:48:03 +03:00
Horizon agentandClaude Opus 5.5 e11233f2dc The title screen's Friends icon is inactive without an invite-code resolver (spec #14167 step 4)
Join a Friend opens from vanilla's Friends icon slot, unchanged in place
and size at every GUI scale; it is now drawn inactive, and takes no click,
key or controller press, when no Hooks::tunnel resolver is registered
(net::joins_friends), as the pause menu's icon is without a sharing page.
horizon-official registers horizon-friends' resolver, so players of the
official game see no change; the plain executable, which refuses every
friend: address, no longer opens a screen that cannot join.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 22:45:31 +01:00
J5CaseyandClaude Opus 5.5 941e45b1ba Recipe book: number a server's synced recipes as the server does (#14435)
A client sent the server's recipes (horizon:recipe_content) numbered its
recipe book displays from its own files, while the server numbers them over
its recipe set, a world's data pack recipes included. Each pack recipe moved
the server's numbers past the client's, and the server's book entries, put
over the book by number, replaced other recipes with server:N entries.

The display numbers now come from the synced recipe set itself
(RecipeIndex::load_from_sources), for the book, its toasts and the
RecipeBookAdd toasts; without synced recipes nothing changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01KtNXhvTSpY15Rjzwuqh8Nf
2026-11-09 15:45:03 -06:00
voduffandClaude Opus 5.5 916064d1ac A sulfur cube's body takes a swallowable block through /item replace
SulfurCube.getEquipmentSlotForItem answers BODY for every
#sulfur_cube_swallowable item, so the body's SlotAccess takes it. Horizon
checked only the item's own equippable slot and refused every block.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 21:42:45 +00:00
MSedgeMC 4ef09a8e0d port_map: the written book resolution rows are ports
`resolveForItem`, `resolve`, `markResolved`, `resolvePage` and
`isPageTooLarge` cite `WrittenBook::resolve` and `resolve_held_book`.
The spawn egg rows cited `Game::use_spawn_egg` in `creatures.rs`, where
it no longer is: the function lives in `creatures/spawn_eggs.rs`.
2026-11-09 21:39:30 +00:00
MSedgeMC d9cf8f497c test: a written book's pages resolve and keep their resolved flag
The resolve test rewrites a selector page and a score page (a plain page
is left alone), marks the book resolved and then leaves a resolved book
alone; the over-long test keeps the raw pages past 32767 characters and
still marks the book resolved; the codec test round-trips `resolved`.
2026-11-09 21:39:30 +00:00
MSedgeMC a321aa6c73 fix: a written book resolves its selectors and scores when it is opened
`WrittenBookContent.resolveForItem` runs in `ServerPlayer.openItemGui`:
`resolve` replaces every page's selectors and scores with the text they
name and marks the book resolved, and a page whose resolved text passes
`resolvePage`'s 32767 characters cancels the resolution (the book is only
marked resolved).

Horizon kept the page components (`WrittenBook.page_tags`) but never
resolved them and had no resolved flag, so a page holding a selector
showed its raw component text and a book could not be told as resolved.

`WrittenBook` gains `resolved` (read and written by the component codec),
`WrittenBook::resolve` ports the resolution, and `Game::resolve_held_book`
resolves the book in the acting hand when `use_book` opens it (a LAN
guest's copy is the mirrored inventory, so its slot is sent back).
`set_written_book_pages` was already applied in `loot.rs`.
2026-11-09 21:39:30 +00:00
Vinícius MoreiraandClaude Opus 5.5 2490c57d3e Polytone: the drop happens when the particle joins, not at its first tick
Dropping a particle with physics at its first tick still drew it for one
frame, so a falling leaf flickered with the newest wide copy through it.
The check now runs when a pending particle joins the engine list, and
set_polytone moves unchanged to particles/polytone.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:32:03 -03:00
voidsip e624d71b21 Remove a needless borrow in the arrow fixture 2026-11-09 15:31:36 -06:00
Mayonesu0andClaude Sonnet 5.5 ac2a95ab1d Keep streaming.rs from growing: the forced ring's ranking basis lives in forced_ring (#13175)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 22:31:34 +01:00
Mayonesu0andClaude Sonnet 5.5 faef0581d1 Test that queued jobs follow a forced chunk (#13175)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 22:31:34 +01:00
Mayonesu0andClaude Sonnet 5.5 4df6ddc3f3 Rank a forced chunk's ring around it (#13175)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 22:31:34 +01:00
Horizon agentandClaude Opus 5.5 a190dbbc0d Crates ship their own lang files: horizon_hooks::lang (layer plan M5)
horizon_hooks::lang::register(&'static [(code, json)]), re-exported as
horizon_platform::lang. The game's entry installs the registered files
into horizon_client::lang; Language::load reads them in the horizon
namespace right after the engine's bundled file for the same language,
before packs, and the engine's file wins on a key both have. Regional
variants share one alias table with the bundled files (fr_ca -> fr_fr,
de_ch -> de_de without ß). Nothing registered: languages load as before.

tools/lang_coverage.py reports crates/*/assets/lang beside the game's
folder; a crate's review record is tools/lang_review/<crate>/, and
--mark-reviewed puts each key in the folder that holds it. lang.rs's
file checks run on every crate folder, and a crate key the engine also
has fails them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 22:30:16 +01:00
YourPancakes f779b500ec perf(mobs): skip redundant swim block reads 2026-11-10 00:16:52 +03:00
Evrim Cicek 34d493fb45 Merge remote-tracking branch 'upstream/main' into fix/rocket-fall-distance 2026-11-10 00:08:45 +03:00
Horizon agent b4a5943911 Merge origin/main into shader-api-linear-blend 2026-11-09 22:07:36 +01:00
YourPancakes 4ed92f7176 perf(mobs): reuse item cells for villager sensing 2026-11-10 00:06:46 +03:00
MSedgeMC 6e8edf7ad2 port_map: ShulkerBoxBlockEntity.doNeighborUpdates is a port
The row said the four lid-step calls were counted and dropped;
`Behaviours::shulker_neighbor_updates` now runs them, and `tick_shulkers`
calls it once per counted update.
2026-11-09 21:04:49 +00:00
MSedgeMC deb5860d9e test: a shulker lid reaches all six neighbours
`a_shulker_lid_updates_the_shapes_and_redstone_of_its_neighbours`
observes the level as `shulker_neighbor_updates` runs: each of the six
neighbours gets one `neighborChanged` from the shulker's block, with its
own state as the changed state. Without the call the level sees none.
2026-11-09 21:04:49 +00:00
MSedgeMC 6dc2d97de4 fix: a shulker box lid updates its neighbours as it moves
`ShulkerBoxBlockEntity.tick` calls `doNeighborUpdates` when its lid
starts or finishes opening or closing: the box's collision shape moved,
so the neighbours' shapes are updated (flag 3) and their
`neighborChanged` runs. Horizon's render tick counted those four calls
(`ShulkerTick.neighbor_updates`) and the game dropped them, so redstone
beside a moving shulker box never saw the update.

`Behaviours::shulker_neighbor_updates` ports the two calls, and
`tick_shulkers` runs it once per counted update. The box's collision
shape for the same ticks already reaches the world through
`shulker_collision_state`.
2026-11-09 21:04:49 +00:00
Dylan FerreyraandClaude Opus 5.5 86f629b61e Say that the queue limit is for network clients only
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:01:33 -03:00
Mayonesu0andClaude Sonnet 5.5 dc10ffbf02 Keep the player's attribute modifiers through a respawn packet
ClientPacketListener.handleRespawn gives the new LocalPlayer
AttributeMap.assignAllValues of the old one when the packet keeps the
attribute modifiers (every level change), assignBaseValues after a death.
The new player here started empty, so the worn armour's modifiers and any
custom one were gone after an End or Nether portal until re-equipped.

Fixes #13541

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 21:57:24 +01:00
voduffandClaude Opus 5.5 b0074207ca A test's world-pack loot tables stay on its own thread (#14266)
The loot data is one slot per process. A test that installed a world pack's
armor_stand table warmed that slot, and every other test's stand break rolled
it too. load_thread_loot_data keeps such a test's loot data on its own thread,
ahead of the slot; a game never sets it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:56:22 +00:00
8b7a7d5cfe Anticheat: write the flag log on a thread of its own
Every flag wrote its log line from the tick thread, piece by piece. With 40 load-test bots
(which fail the movement prediction every tick: 76,000 lines in 3.5 minutes) that was
lan_guests 7.7 ms a tick; a lagging player's flood costs the same. The lines now go, in order,
to a writer thread: lan_guests 1.9 ms on the same run.

Co-Authored-By: Kacper <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:51:59 +00:00
Evrim CicekandClaude Opus 5.5 e96b2ff99a Firework rockets keep their fall distance
#14318: a rocket's Entity.fallDistance builds up as it flies down and is
saved and loaded. Entity.baseTick's fluid step resets it in water (and
splashes on entry, and lets currents push the rocket); lava halves it.
Entity.move's long-move ray resets it past a fall-resetting block or water,
checkFallDamage adds the drop and posts HIT_GROUND on landing, and a
cobweb or powder snow makes it stuck. Adds the `rocket fall distance`
command-exec group.

Items now splash with their motion before the water's current pushes it,
as Entity.updateFluidInteraction does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 23:48:48 +03:00
DUzzL 920e317f53 Make jmath::pow return NaN like Java for NaN exponent and |x|==1 with infinite y 2026-11-09 21:47:25 +01:00
Vinícius MoreiraandClaude Opus 5.5 9286b28e24 Polytone: a particle with physics born inside a block goes at once
A custom particle with has_physics whose box starts inside a block's
collision is dropped before its first tick, so its emitters never run.
Falling leaves start 0.05 under their leaf block, and a pack's leaf with a
0.08 hitbox reaches into it: in Java such a leaf never falls, while here
every one fell, and a leaf that emits a resting copy of itself each tick
left trails of them under every tree until the frame rate collapsed.

Compared in game against Java 26.3 with Polytone, with particles of its
own: a box overlapping a block by 0.01 goes, 0.01 clear of it lives, and
a particle without physics lives either way; the emitters do run every
tick. A test covers the three cases.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:45:54 -03:00
MSedgeMC 58abbd0e4a port_map: WorldOptionsScreen.updatePortControlsState is ported 2026-11-09 20:45:32 +00:00
MSedgeMC e6bc09eeb9 test: the LAN-off port box is read-only but focusable
The caret still moves and Backspace changes nothing while LAN is off; turning
LAN on makes the same key edit the port.
2026-11-09 20:45:32 +00:00
MSedgeMC 3ea0ce07a2 fix: the LAN port box is focusable while LAN is off
WorldOptionsScreen.updatePortControlsState makes the port box read-only
without LAN (setEditable) but leaves it focusable: clicking it selects and
copies the port. Horizon dropped the focus instead, so with LAN off the port
could not even be selected or copied.
2026-11-09 20:45:32 +00:00
voidsip df4b2599b4 Load the portal fixture's entity-ticking neighborhood 2026-11-09 14:43:57 -06:00
Riccardo Guerriero 8aea9cb52b Merge remote-tracking branch 'origin/main' into fix/frame-vibrations 2026-11-09 21:40:02 +01:00
voduffandClaude Opus 5.5 07a82eccf3 port map: rows follow remove_entity and the minecart save fns; PlayerEntry is a class row
remove_entity moved to game/remote_removal.rs, Minecart::to_nbt/from_nbt to
game/minecarts/nbt.rs. A class row takes home, not port.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:37:56 +00:00
fd7b18c787 Heightmaps: test that a write finds its own column again
A write two below the height asks about no block on the next read; a write at or
above it is found again down that column alone, and the other columns keep theirs.

Co-Authored-By: Kacper <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:36:39 +00:00
Cx188andCodex 0bdc77efb6 Remove obsolete manual vanilla parity job (#14209)
Use the existing nightly and reusable parity runners instead of a second
manual inventory. Keep the JFR checks and describe their JDK fixtures.
All 84 removed harness commands remain in parity discovery.

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-09 23:31:54 +03:00
5c7ea99917 Heightmaps: a write marks its column, found again alone on its next read
A block written at or above a column's height (grass spreading, a crop, a mob breaking a
block) used to drop the whole chunk's map, and the next spawn attempt there scanned all 256
columns again. The write now marks that column; its next read finds its height down that
column only. The heights are those a rebuild gives. Natural spawning on the bench: 4.8 ms a
tick to 4.0.

Co-Authored-By: Kacper <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:26:42 +00:00
itarqos5andClaude Sonnet 5.5 5f234b1b99 Tests: a note block cycles through 25 notes and follows its power
`NoteBlock.useWithoutItem` cycles NOTE through its 25 values and wraps on the 25th click, and POWERED
follows the neighbour signal on and off.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 20:24:45 +00:00
itarqos5andClaude Sonnet 5.5 f284d61b06 Tests: a note block takes its instrument from the block below or a head above
`NoteBlock.setInstrument`, run when it is placed: a mob head above decides the instrument
(`worksAboveNoteBlock`), else the block below does, except that a head below gives harp and a
block with no instrument is harp too. Twenty-four cases from `Blocks.java`'s `.instrument(...)`
registrations, the seven head types included.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 20:24:44 +00:00
itarqos5andClaude Sonnet 5.5 09de0cc427 Tests: sand falls through what is free and rests on what is not
`FallingBlock.isFree` is `isAir || FIRE tag || liquid || canBeReplaced`: sand falls through air, both
kinds of fire, any liquid and the blocks flagged replaceable (grass, vines, dead bushes, snow) and
rests on everything else, torches, carpets, slabs, saplings and cobwebs included.

Each block stands on what it needs: placing the sand above is a neighbour update, and a carpet,
sapling or lily pad with nothing to stand on is removed by it, which frees the sand for a reason
that is not the rule under test. (A first draft missed this, and read as Horizon letting sand fall
through carpet; the exported flag and vanilla agree that carpet is not replaceable.)

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 20:24:43 +00:00
Dylan FerreyraandClaude Opus 5.5 3914d39e80 Move the advancement packets out of play.rs into play/advancements.rs
AdvancementDisplay, Advancement, UpdateAdvancements and
SeenAdvancements, with their codecs, move byte for byte into
packets/play/advancements.rs. play.rs re-exports the four public items,
so every `packets::play::…` path still resolves.

The port map follows: the two rows that name UpdateAdvancements::read
and SeenAdvancements::write point at the new file.

Part of #13063.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:23:15 -03:00
JoansitohandClaude Sonnet 5.5 644d598685 Check only the spawnable bounds for /spawnpoint and /setworldspawn
Vanilla resolves the position with BlockPosArgument.getSpawnablePos: no
loaded-chunk or build-height check, only Level.isInSpawnableBounds, else
argument.pos.outofbounds.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 21:21:36 +01:00
voidsip afd4df1cd9 Use entity-specific End gateway cooldowns 2026-11-09 14:20:42 -06:00
voidsip 8aaa338f08 Separate Nether portal cooldown handling from gateway changes 2026-11-09 14:20:39 -06:00
itarqos5andClaude Sonnet 5.5 3606b9ea41 Tests: lava flowing down onto water makes stone and stays lava
`LavaFluid.spreadTo`: lava spreading down into water turns the water's block to stone. Water below a
lava source does not harden the source itself, since `LiquidBlock.shouldSpreadLiquid` skips the side
below; the existing test only covered water beside lava.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 20:19:44 +00:00
itarqos5andClaude Sonnet 5.5 de91e0b39d Tests: lava spreads three blocks on a floor in the overworld
`LavaFluid.getDropOff` is 2 outside the Nether (1 in an ultrawarm dimension): lava loses two levels a
block and reaches only three, at levels 2, 4 and 6. Run long enough for its 30-tick steps, including
the random slowdown of a rising level, so it checks the extent and not the timing.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 20:19:43 +00:00
itarqos5andClaude Sonnet 5.5 2ffd4f31c2 Tests: water advances one block every five ticks
`WaterFluid.getTickDelay` is 5: a source's first spread is five ticks after it is placed and each
flowing block schedules the next five on, so the water reaches distance d on tick 5 * d, none
sooner, out to its seven blocks and never the eighth. The existing test checked only where the water
ended up, not when.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 20:19:42 +00:00
voidsip 79b62aaaea Keep witness visibility inputs on seen villagers 2026-11-09 14:17:44 -06:00
itarqos5andClaude Sonnet 5.5 41b4268ace Tests: a comparator reads the output vanilla gives each state-based block
`getAnalogOutputSignal` of the blocks whose output is a function of their own state, read through a
comparator as in play: a beehive's honey level, a cake's `(7 - bites) * 2`, a candle cake's full 14,
a composter's level, an end portal frame's 15 with an eye, a lava cauldron's 3, a water or powder
snow cauldron's level, a respawn anchor's `floor(charges / 4 * 15)` (0, 3, 7, 11, 15) and a lit
copper bulb's 15. Sixty-four state rows, each reporting its own mismatch.

The comparator is placed first: it computes only when told a neighbour changed, and a block that
has an output tells it when it is placed. (Two earlier drafts got this wrong, and one misnamed the
end portal frame's property: it is `eye`, not `has_eye`.)

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 20:17:17 +00:00
Detimes 02b3b03618 Merge remote-tracking branch 'upstream/main' into gap-line-of-sight 2026-11-09 20:17:13 +00:00
DetimesandClaude Haiku 5.5 0acd52bcb5 Work in progress
Co-Authored-By: Claude Haiku 5.5 <[email protected]>
2026-11-09 20:17:11 +00:00
Detimes 02f00f9b83 Merge remote-tracking branch 'upstream/main' into gap-riding-getters 2026-11-09 20:17:08 +00:00
Detimes 7d75faab81 Merge remote-tracking branch 'upstream/main' into gap-server-props-read 2026-11-09 20:17:05 +00:00
Detimes 0eddebf75e Merge remote-tracking branch 'upstream/main' into gap-map-colors-read 2026-11-09 20:17:04 +00:00
DetimesandClaude Haiku 5.5 baab01b031 Work in progress
Co-Authored-By: Claude Haiku 5.5 <[email protected]>
2026-11-09 20:17:03 +00:00
Detimes 034c4b6e5c Merge remote-tracking branch 'upstream/main' into gap-mob-death-message 2026-11-09 20:16:59 +00:00
voidsip 6d6dd49139 Send merchant offers only when prices refresh 2026-11-09 14:16:58 -06:00
Detimes 566027307e Merge remote-tracking branch 'upstream/main' into gap-entity-lookup 2026-11-09 20:16:57 +00:00
voidsip 37249efd34 Check explicit blast context survives a nested native blast 2026-11-09 14:15:06 -06:00
Horizon agent 98498da9aa Merge origin/main (brings #14090's test_git_slim fix) 2026-11-09 21:14:49 +01:00
Horizon agent 536c0f938b Merge branch 'split/cosmetics-fx' into split/cosmetics-settings 2026-11-09 21:13:54 +01:00
voidsip a2b63b3e3b Keep explicit damage source state across nested native explosions 2026-11-09 14:13:34 -06:00
voidsip e8dbc42da7 Use the oracle memory constructor entity-list type 2026-11-09 14:13:14 -06:00
voidsip 1f3f7cebae Separate explicit explosion damage causes from blast ownership 2026-11-09 14:11:42 -06:00
voidsip adfc70d2f3 Split custom arrow contents and expiration from potion impacts 2026-11-09 14:10:51 -06:00
voidsip 9da6aa25e5 Split cached villager murder witnesses from hurt attribution 2026-11-09 14:10:25 -06:00
voidsip 66013b7277 Give remote player bodies their own random stream 2026-11-09 14:09:48 -06:00
voidsip acf9008e49 Merge commit '38174db59bfd5bf90027bd727f894b141c9e72dc' into split/11536-preserve-live
# Conflicts:
#	.github/workflows/ci.yml
2026-11-09 14:06:12 -06:00
voidsip 7e32b7d0da Merge main before splitting villager reputation fixes 2026-11-09 14:06:08 -06:00
Nikita Maximilian GrimmandClaude Opus 5.5 c14ab8d543 Merge remote-tracking branch 'origin/main' into frame-data-count
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 21:05:56 +01:00
voidsip cd9441f90e Merge commit '38174db59bfd5bf90027bd727f894b141c9e72dc' into split/11484-review 2026-11-09 14:05:52 -06:00
Horizon agent 316bef4484 Merge origin/main (brings #14090's test_git_slim fix) 2026-11-09 21:04:19 +01:00
RINNECODERandClaude Opus 5.5 d5bd80fcf7 Block sprites: the Metal warning lives in surface_setup, as gfx.rs may not grow
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 14:04:18 -06:00
Nikita Maximilian Grimm 021bbb8225 Merge commit '716988614' into parity-process-tree 2026-11-09 20:59:17 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 4da0ea5197 parity: start sccache's server outside the harness jobs on Windows
A server started by a harness's build joined that harness's job (sccache
doesn't ask to break away) and ended with it, failing other harnesses'
in-flight compiles (os error 10054). The runner now starts it before any
build, in a job of its own, with no idle timeout.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:58:50 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 ac296614af /data on an item frame detaches its old map when only the stack's count changes
ItemFrame.readAdditionalSaveData compares the Item stack as read with
ItemStack.matches, count included, and removes the framed map on a
mismatch; only setItem then keeps one item. apply_entity_nbt compared
against item_frame_from_nbt's stack, already cut to one, so a count-only
change kept the old map's marker. It now compares the stack as read.

Fixes #14178

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:58:37 +01:00
Horizon agent fd85857547 Merge origin/main into fix/editbox-utf16-length 2026-11-09 20:57:20 +01:00
itarqos5andClaude Sonnet 5.5 a203a7255b Tests: a dispenser and a dropper trigger on any side and through the block above
`DispenserBlock.neighborChanged`: TRIGGERED is `hasNeighborSignal(pos) || hasNeighborSignal(pos.above())`,
so power on any of its six sides, its front included (a piston skips that one), or beside the block
above it, triggers it, and losing the power un-triggers it. A dropper inherits all of it.

A source that is not beside it sends no block update, so each change is followed by a poke: a block
placed and removed beside it.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 19:56:35 +00:00
itarqos5andClaude Sonnet 5.5 6260f30e4e Tests: redstone wire climbs and descends only where nothing conductive cuts it
`RedStoneWireBlock.getConnectingSide`: a wire climbs onto a block beside it only if nothing
conductive sits on top of the wire itself, and steps down to a wire below a gap only if the block
beside it is not conductive. The wire is placed by an item so its connections are computed, and
with none at all it is drawn as a cross, which tells a real connection from a cut one.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 19:56:34 +00:00
itarqos5andClaude Sonnet 5.5 cbbb38b654 Tests: redstone wire connects to each neighbour by that block's own rule
`RedStoneWireBlock` connects through each neighbour's `shouldRedstoneWireConnectTo`: any signal
source from any side, a repeater only along its axis, an observer only when it faces the wire. And
`getConnectionState` draws a wire whose only connection is on one axis straight, so the opposite
side connects too, where one with no connection stays a dot. Twelve neighbours.

(A first draft expected the opposite side to stay unconnected, and was wrong.)

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 19:56:33 +00:00
itarqos5andClaude Sonnet 5.5 f4ddbb4327 Tests: a piston is powered by its neighbours but its front, and by quasi-connectivity
`PistonBaseBlock.getNeighborSignal` skips the side the piston pushes towards, then also takes what
powers the block above it: that block's neighbours in every direction but down, the pushing side
and the block two above included. Twelve placements of a redstone block around a piston facing
east: the asymmetric front exclusion and the upward-only quasi-connectivity among them.

A source that is not beside the piston sends it no block update, so it takes effect only when
something beside it changes (BUD, the reason quasi-connectivity is a quirk and not a power
source). Each case pokes the piston with a block beside it. A first draft did not, and expected
the piston to extend at once; vanilla does not either.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 19:56:32 +00:00
itarqos5andClaude Sonnet 5.5 29968525ac Tests: trapdoors and fence gates open with power beside them
`TrapDoorBlock` and `FenceGateBlock` take POWERED and OPEN from `hasNeighborSignal` of their own
position and give both up with it. Oak and iron trapdoors, oak and bamboo fence gates.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 19:54:53 +00:00
RINNECODER f5df9ca25a Merge main into #12156 2026-11-09 13:54:52 -06:00
itarqos5andClaude Sonnet 5.5 7cb62d72e4 Tests: a door opens with power beside either half and closes without it
`DoorBlock.neighborChanged` powers a door from a signal at either of its halves; the half with the
power beside it runs the check and the other copies the state through `updateShape`. Power beside
each half in turn, for wooden, iron and bamboo doors.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 19:54:52 +00:00
itarqos5andClaude Sonnet 5.5 a912cb9c47 Tests: a hopper is disabled by power on any of its six sides and nothing else
`HopperBlock.checkPoweredState` sets ENABLED to `!hasNeighborSignal`: power at any of its six sides
switches it off at once and removing it switches it back on, while power diagonally above does
nothing, since a hopper has no quasi-connectivity. Also checked at placement (`onPlace`).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 19:54:50 +00:00
itarqos5andClaude Sonnet 5.5 fe962aa5f6 Tests: a redstone lamp lights at once and goes dark exactly four ticks after its power
`RedstoneLampBlock` lights the moment it is powered and loses its light on a tick scheduled four
ticks after the power goes: lit through the third tick, dark at the fourth, and power that returns
in time cancels it. The existing lever test only checked that it was dark after five.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 19:54:49 +00:00
itarqos5andClaude Sonnet 5.5 7c4cdcea29 Tests: a powered rail passes power eight rails along in both directions
`PoweredRailBlock.findPoweredRailSignal` gives up at `searchDepth >= 8`: a rail takes power from a
powered neighbour up to eight rails away along the line, both ways, and not from the ninth.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 19:54:47 +00:00
itarqos5andClaude Sonnet 5.5 9972a26864 Tests: a repeater is locked only by a powered diode pointing into its side
`RepeaterBlock.isLocked` is `getAlternateSignal > 0` with `sideInputDiodesOnly`: a diode beside it
locks it only when it is powered and outputs into it. An unpowered repeater or comparator there does
not, and neither does a redstone block, which does feed a comparator's side input.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 19:54:46 +00:00
itarqos5andClaude Sonnet 5.5 6e73d47b54 Tests: a torch toggled eight times in sixty ticks burns out and relights after 160
`RedstoneTorchBlock`: turning off adds a toggle; eight inside the last 60 ticks burn the torch out
and `isToggledTooFrequently` keeps it dark until the toggles age, with a restart tick scheduled 160
ticks out. Seven quick cycles leave it working, the eighth burns it out, and eight spread over 96
ticks never do.

The lever is clicked, not rewritten: `LeverBlock.pull` is what updates the neighbours of the block it
is attached to, so a plain setBlock of a powered lever never reaches the torch above it. (A first
draft rewrote it, saw the torch never toggle, and was wrong about why.)

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 19:54:45 +00:00
Evan-lhm 4802e85471 Merge branch 'split/gfx-section-fade' into rt/acceleration-structures 2026-11-09 20:53:11 +01:00
MSedgeMC 9f6b9e408d port_map: the command block edit marks the chunk 2026-11-09 19:51:59 +00:00
MSedgeMC a81a3151c3 test: an edited command block dirties its chunk 2026-11-09 19:51:59 +00:00
MSedgeMC bc9c686b25 fix: editing a command block marks its chunk 2026-11-09 19:51:59 +00:00
Evan-lhm 218816fbf6 Merge remote-tracking branch 'origin/main' into rt/acceleration-structures 2026-11-09 20:49:40 +01:00
itarqos5andClaude Sonnet 5.5 a5b8c780bf Tests: a target block holds its output 20 ticks for an arrow and 8 otherwise
`TargetBlock.updateRedstoneOutput` schedules the reset 20 ticks out for an arrow and 8 for anything
else, and a hit while the output is held changes nothing (`hasScheduledTick`), the new strength
included.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 19:45:00 +00:00
itarqos5andClaude Sonnet 5.5 1159d8f197 Tests: a target block gives vanilla's strength for where a projectile hit
`TargetBlock.getRedstoneStrength` is `max(1, ceil(15 * clamp((0.5 - distance) / 0.5, 0, 1)))` from
the hit's distance to the face's centre. The expected values are vanilla's formula run in Java for
51 hits on three faces, double rounding included: the same offset gives 12 on one face and 13 on
another, because frac(6.6) and frac(5.6) are not the same double.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 19:45:00 +00:00
itarqos5andClaude Sonnet 5.5 0817fddb83 Tests: a copper bulb flips its light only when it becomes powered
`CopperBulbBlock.checkAndFlip` flips LIT only on a rising edge of power and lets POWERED follow the
signal either way, so the light alternates on every power-up and ignores the power-downs. Five
variants (plain, exposed, weathered, oxidized, waxed), and one placed beside power, where onPlace
runs the same check.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 19:45:00 +00:00
Evrim CicekandClaude Opus 5.5 c1bbcf1535 Move the firework rocket NBT out of entity_nbt.rs
Part of #14043: a pure move to entity_nbt/rocket.rs, so the rocket's
fall_distance (#14318) lands in the moved file.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 22:38:02 +03:00
Horizon agentandClaude Opus 5.5 398bec3d6e Move the cosmetics settings out of the engine's Extras (spec #14091 step 5a)
The six cosmetics settings (my cosmetics, in single player, other players'
cosmetics, hide others' trails and auras, Horizon logos, show me as Horizon)
leave horizon-client-render's Extras for game/cosmetics/settings.rs:

- their horizon-extras.txt lines go through a settings Store (same keys, so
  players and the launcher's Wardrobe keep their choice; others' line still
  written only once chosen), always registered so a game without cosmetics
  keeps the Wardrobe's lines;
- their rows are a Place::Extras page section, shown while cosmetics are
  offered, in the same Cosmetics group at the end of the Extras page;
- the values live in the per-seat Seat, and the owner flag is read there
  instead of being copied into Extras each frame.

horizon-client-render no longer depends on horizon-cosmetics: the allowed
entry "horizon-client-render -> horizon-cosmetics" is removed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:37:10 +01:00
yahya-nusseibehandClaude Opus 5.5 5328ec28b5 Mod registry: bundle SQLite 3.54 through rusqlite 0.41
The registry's own workspace still resolved rusqlite 0.37 ->
libsqlite3-sys 0.35, which bundles a SQLite older than 3.54
(RUSTSEC-2026-0459: uninitialised page-cache memory from a crafted
database opened with a "-wal" file of another page size). The registry
only opens its own database, so this isn't reachable as deployed, but
it is the same upgrade the telemetry server and the root workspace
already made: rusqlite 0.41 resolves libsqlite3-sys 0.39.1, and
fallible_uint keeps the u64/usize conversions working as before (an
error past i64::MAX).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-09 21:36:55 +02:00
itarqos5andClaude Sonnet 5.5 3c11bce138 Tests: a comparator compares and subtracts by vanilla's rules
`ComparatorBlock.calculateOutputSignal`: no input is no output; a side input above the input
silences it; otherwise COMPARE passes the input through and SUBTRACT outputs `input - side`.
`shouldTurnOn` agrees except that equal inputs turn COMPARE on and SUBTRACT off. Nine cases, read
from the wire in front of the comparator. Signal strengths come from lever-and-wire lines (a wire
gives up one level a block).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 19:36:28 +00:00
itarqos5andClaude Sonnet 5.5 8057b16916 Tests: buttons stay pressed for their own ticks and a second click does not extend it
`ButtonBlock.press` powers the button and schedules a tick `ticksToStayPressed` out. Stone buttons
stay pressed 20 ticks and every wooden one 30 (oak, bamboo and cherry pinned). A button that is
already pressed answers a second click with nothing: the timer is not extended.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 19:36:25 +00:00
Nikita Maximilian GrimmandClaude Opus 5.5 f11565b4ae Merge main into #13839; graphics.md's HDR paragraph is main's (with #14129) plus this PR's sentence
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:34:12 +01:00
Dylan FerreyraandClaude Opus 5.5 3750bd11b1 A splitscreen player's first view no longer disconnects it
A memory connection's outbox closed past MAX_PENDING (96 MiB), as a
socket's does. A memory connection gets every pending chunk at once
(PlayerChunkSender.collectChunksToSend), so a player joining where the
terrain is already loaded can queue more than that in one burst, and
the server closed its connection with no reason: "<name> could not
stay / End of stream".

The limit is for clients that read slowly over the network; those keep
it.

Fixes #12849.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:28:39 -03:00
Horizon agent 07c4f9364b Merge origin/main into fix/11316-custom-durability 2026-11-09 20:28:10 +01:00
Evan-lhmandClaude Opus 5.5 fb6b997e15 Resident::iter in every build: ray_scene walks the sections when the camera changes column
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:17:19 +01:00
Evan-lhmandClaude Opus 5.5 73b311e9d1 Ray tracing: the terrain's acceleration structures, near the camera only (#13780 step 2)
Continues #1180. With ray queries on (`HORIZON_RAY_TRACING=1` in a horizon-extras
build), `ray_scene` keeps one BLAS per section's solid quads within 8 chunks of
the camera (`HORIZON_RT_CHUNKS`), compacted once built, and a TLAS of them, built
again only when a section's mesh changes or the camera enters another chunk
column (instances sit relative to that column). Nothing traces them yet.

The arena keeps its compact layout: a section about to get a BLAS has its solid
quads written out as vertices by a compute pass (`rt_unpack.wgsl`, appended to
terrain.wgsl to use its `section_corner`) into a reused buffer.

The off path is main's: `without_ray_queries_there_is_no_scene` opens a device
without ray queries and checks there is no scene and no extra buffer usage. The
renderer's side is three one-line calls in gfx.rs (`ray_mark`, `ray_clear`,
`ray_prepare`), the methods living in ray_scene.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:12:34 +01:00
Riccardo GuerrieroandClaude Opus 5.5 72f206a80c fix: endermen keep carried blocks out of entities, stare once per 400 ticks and reach blocks through outlines
- EndermanLeaveBlockGoal.canPlaceBlock: level.getEntities(this.enderman, cube)
  must be empty, so the player (not a spectator), dropped items, armor
  stands, boats, projectiles, frames and every other entity block the cube,
  and the enderman's own box no longer does.
- Enderman.playStareSound: once per 400 ticks (lastStareSound), stamped
  but silent for a Silent enderman, at getEyeY (2.55 up). It plays when
  DATA_CREEPY changes while DATA_STARED_AT holds (onSyncedDataUpdated, value
  by value in id order), so the end of a stare's anger plays it too.
- Enderman.setTarget stamps targetChangeTime for every target and zeroes it
  on none, which customServerAiStep's daylight teleport reads.
- EndermanTakeBlockGoal.tick clips with ClipContext.Block.OUTLINE, the
  shapes' random offset included: a flower or torch now hides a block.

The block random offset (BlockBehaviour offsetType) moves to
StateFacts::offset_at, shared by the mesher and the mob clip.

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

Claude-Session: https://claude.ai/code/session_01WZzq8MxShENtiMj2DMbm5G
2026-11-09 20:09:10 +01:00
DetimesandClaude Haiku 5.5 de6cc998df Native hooks: read the vehicle an entity rides and its passengers
Adds the read-only helpers native_vehicle_of(entity) and
native_passengers_of(entity) for server mods (vanilla Entity.getVehicle
and getPassengers, first = controlling passenger). Helper-only fragment
riding_getters.json; API number is assigned when the fragment is stamped.

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
2026-11-09 19:00:46 +00:00
DetimesandClaude Haiku 5.5 03b7d245b5 Native hooks: living_death_message (mob death message) (work in progress)
Co-Authored-By: Claude Haiku 5.5 <[email protected]>
2026-11-09 18:59:43 +00:00
RINNECODERandClaude Opus 5.5 790d1be209 Block sprites: warn when Metal arrays grow past 512 layers, with the layer count and GPU
So a texture-corruption report from a big pack can be tied to the lifted cap at once (#12155).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 12:57:33 -06:00
DetimesandClaude Haiku 5.5 a6204f1f3a Native hooks: read-only entity lookup by UUID and entity data in any level (work in progress)
Co-Authored-By: Claude Haiku 5.5 <[email protected]>
2026-11-09 18:57:08 +00:00
Horizon agent f3fdefa0ec Merge remote-tracking branch 'origin/main' into rpn4-14179
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
2026-11-09 19:56:12 +01:00
Mik00000 c2dd2d935d perf(light): reuse scratch queues during incremental relight and preserve lazy section loading
- Reuse thread-local scratch queues (`RelightScratch`) across BFS light propagation passes, avoiding reallocation during incremental relighting.
- Pass borrowed slices `&[Pos]` to `relight_layer` to avoid redundant `Vec<Pos>` heap allocations.
- Cache consecutive vertical column lookups in `lt_sources` to avoid redundant map lookups.
- Preserve lazy loading of sections in `Region`, only loading and cloning sections that are actually read or written.
- Add `relight_single_block_bench` and update `relight_bench` to verify timings for both single-block and large batch edits.
2026-11-09 20:55:36 +02:00
Horizon agent 073e8db61c Merge remote-tracking branch 'origin/main' into rpn4-14216
# Conflicts:
#	crates/horizon-client/src/mobs/ai.rs
2026-11-09 19:55:31 +01:00
Horizon agentandClaude Opus 5.5 4787069c5b Sign sides: keep the first 4 valid lines, as SignText's codec does
Run against the 26.3 SignBlockEntity: an entry that fails is dropped
(ListCodec's partial) and Util.fixedSize keeps the first 4 lines of a
longer list with the colour and glow; only fewer than 4 valid lines load
the empty side.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:54:19 +01:00
Horizon agentandClaude Opus 5.5 fa250c3c93 Sheep colour: read Color as Codec.BYTE does (byteValue)
DyeColor.LEGACY_ID_CODEC is Codec.BYTE: an int tag of 259 reads as byte 3
(light blue), not white.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:53:53 +01:00
DetimesandClaude Haiku 5.5 9f04ad5318 Native hooks: read the dedicated server's online mode, port and proxy forwarding
Mods can read whether a dedicated server runs in online mode, the port it
listens on and whether it takes its players from a Velocity proxy
(forwarding.secret). The values come from the live network config and
listening socket, so they match what the server does. None off a dedicated
server and before it listens. The forwarding secret is never returned.

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
2026-11-09 18:47:11 +00:00
Ludovic Marie 1e4f136720 Merge pull request #14078 from horizon-rust/layers/extras-pvp-reads
🤖 Agent: every read of settings.extras.pvp outside the PvP module now goes through one accessor in game/extras/pvp.rs (pvp_legacy_look, pvp_mutes_sound, pvp_hides_particle and so on), same values at the same places, so nothing new runs per frame.
2026-11-09 19:40:06 +01:00
Ludovic Marie a8aca2ba12 Merge pull request #13941 from horizon-rust/layers/extras-pvp
🤖 Agent: pure move of game/pvp.rs to game/extras/pvp.rs (git sees a rename), stacked on #13930; only super:: paths and visibility change, docs/pvp.md follows the path.
2026-11-09 19:39:45 +01:00
itarqos5andClaude Sonnet 5.5 41d1c3adfe Tests: an observer ignores changes on the sides it does not face
`ObserverBlock.updateShape` reacts only when the direction to the changed neighbour is its
FACING. A change behind it, beside it, above it or below it starts no pulse.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 18:34:25 +00:00
itarqos5andClaude Sonnet 5.5 df7437d02b Tests: an observer pulses from two to four ticks after its faced side changes
`ObserverBlock.updateShape` schedules a tick two out when the block it faces changes; that tick
turns it on and schedules another two out, which turns it off. A lamp behind it is lit by the
pulse (`getSignal` answers 15 only for its facing); lamps in front of it, beside it and above it
are not.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 18:34:25 +00:00
itarqos5andClaude Sonnet 5.5 622eda982a Tests: tripwire hooks attach from two to forty-one blocks apart
`TripWireHookBlock.calculateState` scans `for i in 1..42` along the hook's facing for a hook
facing back and ends with `attached &= receiverPos > 1`: hooks attach 2 to 41 blocks apart and
never side by side or 42 apart. Both hooks are placed the way a player places them, because
vanilla runs the calculation from `setPlacedBy`, not from `setBlock`.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 18:34:25 +00:00
itarqos5andClaude Sonnet 5.5 cdc8772455 Tests: a piston refuses the blocks that have a block entity
`PistonBaseBlock.isPushable` ends in `!state.hasBlockEntity()`, so a chest, furnace, barrel or
hopper ahead of a powered piston keeps it retracted and stays where it is.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 18:34:25 +00:00
itarqos5andClaude Sonnet 5.5 d289ee5de2 Tests: a piston pushes a retracted piston and refuses an extended one
`PistonBaseBlock.isPushable` answers false for a piston with EXTENDED set, so a push that meets
it fails and the pusher stays retracted, while a retracted one is pushed along. Both kinds.

The extended piston is held extended by a power source of its own, out of the pusher's reach:
unpowered it retracts on the tick, and that retraction updates the pusher, which then pushes it
(vanilla does the same; the first draft of this test asserted the wrong end state).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 18:34:25 +00:00
itarqos5andClaude Sonnet 5.5 3a5bc7ef94 Tests: glazed terracotta is pushed in line and never dragged sideways
26.3 gives `PushReaction.PUSH` to glazed terracotta alone, and `PistonBaseBlock.isPushable`
answers `direction == connectionDirection` for it: a slime block beside it cannot drag it, where
it drags an ordinary block, and in line with the push it moves like any other.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 18:34:25 +00:00
itarqos5andClaude Sonnet 5.5 7725516a3e Tests: slime and honey each drag their own kind and never each other
`PistonStructureResolver.canStickToEachOther`: a slime block drags slime and a honey block drags
honey, but a slime block and a honey block never stick to each other. Four pairings, each with
the block beside the one the piston faces either dragged or left where it was.

Built in open air: a slime block on stone drags the floor along too (vanilla's as well), which
would make the check about something else.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 18:34:25 +00:00
itarqos5andClaude Sonnet 5.5 577c82ffd3 Tests: a piston moves twelve blocks and refuses a thirteenth
`PistonStructureResolver.addBlockLine` refuses once `toPush` would hold
more than twelve blocks, and then the whole push fails: the piston stays
retracted, it does not move twelve of them. Pins 11 and 12 moving, with
the head where the first block stood, and 13 moving none.

The port matched vanilla; the ledger lists `PistonStructureResolver.resolve`
among the ported methods no parity harness covers.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 18:34:25 +00:00
itarqos5andClaude Sonnet 5.5 401373bc22 Tests: the moving-piston tests of level/tests.rs get a file of their own
The five tests from `pistons_pop_fragile_blocks_and_refuse_obsidian` to
`replacing_a_moving_piston_leaves_its_extended_base` move, unchanged, to
level/tests/pistons.rs, beside the barrel and trigger suites that were split
out the same way. They are the piston tests that stood in the middle of a
4176-line file; the piston rules the next commits pin go in the same place,
so one file holds them. This takes 191 lines out of tests.rs.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 18:34:25 +00:00
AppaandCodex GPT-6 125308948a Merge branch 'main' of https://github.com/horizon-rust/horizon into fix/13174-wayland-file-drop
Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-09 10:30:25 -08:00
Horizon agentandClaude Opus 5.5 272fcfa34c mace.rs: pass the borrowed stack as it is (clippy needless_borrow)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:27:53 +01:00
Horizon agent 9ec09e4714 Merge remote-tracking branch 'origin/layers/moderation-durations' into layers/chat-guard-hook
# Conflicts:
#	crates/horizon-client/src/game/chat_filter.rs
2026-11-09 19:26:44 +01:00
Horizon agent 1324281e8b Merge remote-tracking branch 'rv3n/14141' into rv3-fix-14141 2026-11-09 19:21:20 +01:00
Horizon agentandClaude Opus 5.5 997007191a Banner layers: leave out only the layer the codec rejects
TagValueInput.read returns the codec error's partial value, and DFU's list
codec keeps the valid elements as that partial (checked against
datafixerupper 10.0.21): vanilla keeps the other layers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:21:03 +01:00
voduffandClaude Opus 5.5 c93edbe286 Mobs: a promoted weaker Absorption clamps the amount to its maximum (#11384)
LivingEntity.onEffectUpdated(effect, true) runs when a hidden effect
instance is promoted, and its refreshDirtyAttributes clamps absorption
to the new max_absorption. Horizon clamped only when Absorption was
gone, so Absorption II's 8 survived the promotion of a hidden
Absorption I (max 4). tick_effects now clamps whenever the tick changed
max_absorption.

mob_absorption_max_corpus gains a promote case (494/494; main: 493/494).

Part of #11384

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:19:10 +00:00
Nikita Maximilian Grimm 2f1ad94bf7 Merge remote-tracking branch 'origin/main' into parity-process-tree 2026-11-09 19:12:43 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 a0f6aeafce pr_ready: the Windows check reaper is a daemon and holds its own process
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:10:00 +01:00
Horizon agentandClaude Opus 5.5 0d6c974c99 Merge main into #13839; graphics.md's HDR paragraph is main's plus this PR's sentence
Main rewrote the UI Brightness part of the HDR paragraph; this PR only added
the sentence that a shaders/ pack's picture is not lifted. Main's paragraph is
kept with that sentence inserted where the PR put it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:09:06 +01:00
iseaprojectsandopencode 91f580a2a2 Filter a moving cart's pushes by its teams' collision rules
`OldMinecartBehavior.pushAndPickupEntities` asks
`EntitySelector.pushableBy(this.minecart)` for what a moving rideable cart
pushes, so the cart's own team's collision rule and the target's both decide;
Horizon passed `None` for the cart's team on mobs and never filtered carts.
A still or unrideable cart keeps vanilla's else branch, which pushes every
pushable minecart with no team filter.

The per-candidate answer comes from `Game::cart_push_allowed` beside the
other team lookups; `pickup_from_cart` gets the cart's team from the same
`team_with_owner`. Two ignored tests on the real server game fixture cover
every collision rule over cart-to-cart pushes, the stationary branch, and a
cow the cart may not board.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-09 14:07:58 -04:00
Nikita Maximilian Grimm 2ec731aadf Merge remote-tracking branch 'origin/main' into fix/command-exec-group-isolation 2026-11-09 19:06:02 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 de08483605 process_tree: a Windows check's exit ends what it left; close a job Popen never used
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:03:35 +01:00
Nikita Maximilian Grimm 57e9e2b751 Merge origin/main into typed-randoms-1a
# Conflicts:
#	crates/horizon-client/src/game/persist.rs
2026-11-09 19:02:51 +01:00
MSedgeMC 9790d9d81c Merge remote-tracking branch 'upstream/main' into fix/weapon-damage-component 2026-11-09 18:02:49 +00:00
MSedgeMC 6a31935f68 Merge upstream/main 2026-11-09 18:00:32 +00:00
MSedgeMC 81b88d62be Merge upstream/main 2026-11-09 18:00:17 +00:00
MSedgeMC 4694211e8d Merge remote-tracking branch 'upstream/main' into fix/weapon-disable-blocking-component 2026-11-09 17:59:40 +00:00
Nikita Maximilian GrimmandClaude Opus 5.5 ace3563955 command_exec cleanup reaches the other levels in Horizon; groups shrink the cubes they kill last
A dying cube mob is out of every selector's reach and splits 20 ticks later, inside the next group, so
the three groups that end by killing their size-2+ sulfur cubes shrink them first. Horizon resolves an
@s of another level only from that level, so each level's cleanup runs in it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:59:32 +01:00
MSedgeMC ae0ab270ff Merge remote-tracking branch 'upstream/main' into fix/weapon-damage-component 2026-11-09 17:59:18 +00:00
Horizon agentandClaude Opus 5.5 28214f02a7 Move the perched-dragon test into the module that has dragon()
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:59:09 +01:00
Horizon agentandClaude Opus 5.5 7b74e2243b Merge main into #13726; ui.rs keeps both new edit box tests
insert_text auto-merged main's read-only guard with this PR's UTF-16 room;
main's read-only test and this PR's UTF-16 test were appended at the same
place, both kept whole.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:58:46 +01:00
MSedgeMC 0ff4404c9e Merge remote-tracking branch 'upstream/main' into fix/weapon-disable-blocking-component 2026-11-09 17:54:04 +00:00
Horizon agentandClaude Opus 5.5 5bb1789373 Name the test's Tag: dragon.rs's test module has no import of it
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:54:00 +01:00
MSedgeMC e865160f46 Merge remote-tracking branch 'upstream/main' into fix/weapon-damage-component 2026-11-09 17:53:42 +00:00
DUzzL b50a445987 Load a sign side empty unless messages has exactly 4 lines 2026-11-09 18:50:32 +01:00
MSedgeMC 16d4b2c348 fix: read the sheep colour through the saved tag
`Option::and_then` gives the closure the `&Tag`, not the option the save
helpers take; pass it through `Some` so the branch compiles.
2026-11-09 17:49:57 +00:00
Nikita Maximilian GrimmandClaude Opus 5.5 4c593f0ca7 Harnesses take what they started with them: games, servers and builds
A harness that exited or crashed left the rest of its process group running, and on Windows a parity.py or
pr_ready.py that died left its harnesses and their games running too (taskkill /T can't follow a dead parent).
tools/process_tree.py starts each harness in its own Job Object on Windows (KILL_ON_JOB_CLOSE, started suspended
so nothing escapes before it joins) and, on POSIX, kills the rest of its session's group once it exits, before
it is reaped. pr_ready.py runs its Windows checks the same way.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:49:37 +01:00
voduffandClaude Opus 5.5 7837d91790 Projectiles hit item frames: the item comes out, then the frame breaks (#11383)
Frames are pickable (BlockAttachedEntity.isPickable), so a projectile
meets them and ItemFrame.hurtServer runs: a filled frame gives its item
up on any non-explosion hit, an empty one breaks, a fixed one refuses
unless the shooter is creative (and a refused arrow is turned back).
Horizon left frames out of every projectile's targets.

projectile_targets/vehicle_targets move to creatures/projectile_targets.rs
and list frames; attack_frame's body becomes hit_frame, shared with the
new projectile_hurts_frame, which arrows, tridents and hurt_vehicle's
callers reach. New harness test_frame_projectile_parity.py (27/27;
main: 22/27).

Part of #11383

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:49:21 +00:00
DUzzL 53b7a6768d Record the predicted drop as sent so a refilling pickup syncs to a guest 2026-11-09 18:47:18 +01:00
Riccardo GuerrieroandClaude Opus 5.5 9d025d7e3c Start the void 64 blocks below each level's own floor
Entity.checkBelowWorld acts below Level.getMinY() - 64. Horizon used the
chunks' column bottom, which is widened to the Overworld's -64 in every
level, so in the Nether and the End (min Y 0) players and mobs only took
void damage, and arrows, thrown potions and other projectiles were only
removed, below -128 instead of -64; a guest's death there was not named
"fell out of the world". World::below_world_y gives the level's own floor
minus 64, and an entity exactly on it stays, as vanilla's strict < keeps it.

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

Claude-Session: https://claude.ai/code/session_01WZzq8MxShENtiMj2DMbm5G
2026-11-09 18:47:16 +01:00
iseaprojectsandopencode d413223251 Tick raids before natural and custom spawning
`ServerLevel.tick` runs `raids.tick` before `getChunkSource().tick`, so a
wave that lands in a tick joins before that tick's natural and custom
spawns and takes the lower entity ids. `tick_creatures` did the opposite:
natural spawning, then the custom spawners, then the raids.

The three phases move into `Game::tick_spawners`, in vanilla's order, each
with its own span; the LAN sync bracket still covers exactly them. A new
ignored test builds the real server game fixture, records spans and asserts
the phase order.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-09 13:41:39 -04:00
Nikita Maximilian GrimmandClaude Opus 5.5 ac90e00807 command_exec group cleanup test: the zombie burned in daylight, it was not unloaded
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:37:25 +01:00
RLC248 d702f7cf60 A mace smash sends the attacker's motion after the hit
MaceItem.hurtEnemy resets the attacker's fall and sets its motion, and the
server sends that motion to the attacker in a ClientboundSetEntityMotionPacket
right after the hit. A LAN guest's PvP mace smash now sends the motion the
hit gave it, as it already did for mob hits. The server_knockback harness no
longer lists pvp-mace as a known difference.

Fixes #8246
2026-11-09 18:31:54 +01:00
rdbtCVSandClaude Sonnet 5.5 c7a360b9bb Merge main into fix/h194-item-count-predicate-2
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 17:28:42 +00:00
rdbtCVSandClaude Sonnet 5.5 1a4e885e2c Merge main into layers/chat-guard-hook
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 17:26:02 +00:00
MSedgeMC f6a6dcc007 fix: drop needless borrows in the weapon disable paths 2026-11-09 17:24:58 +00:00
MSedgeMC 3df13bccbd port_map: the sheep's colour id follows the codec's out-of-bounds strategy
`tools/port_map/game-012.tsv` records `Sheep.readAdditionalSaveData`; its
`Color` read now goes through `mobs/farm.rs::saved_color`, which gives white
for an id the legacy codec does not know.
2026-11-09 17:23:00 +00:00
MSedgeMC 851627a847 test: an unknown sheep colour reads white
`mob_from_nbt` is what `Game::load_mob` goes through. A sheep with
`Color: 99b` loads with `extra.color == 0` (white), not the wrapped 3.

Without the codec bound the sheep loads light blue: `left: 3`, `right: 0`.
2026-11-09 17:23:00 +00:00
MSedgeMC 0a36da7779 fix: an unknown saved sheep colour reads white
`Sheep.readAdditionalSaveData` reads `Color` with
`DyeColor.LEGACY_ID_CODEC` and `orElse(DEFAULT_COLOR)`. The codec's id map is
`ByIdMap.continuous(..., OutOfBoundsStrategy.ZERO)`, so an id outside 0..=15
reads as the first colour, white.

Horizon masked the id with `& 15`: a saved `Color: 99b` became light blue (3)
instead of white, and a negative id became a wrapped colour the sheep never
had.

The read now accepts only 0..=15 and gives white for anything else, in
`mobs/farm.rs::saved_color` beside the sheep's other colour code.
2026-11-09 17:23:00 +00:00
MSedgeMC 8f2b122754 port_map: the dragon's sitting damage is read back
`tools/port_map/game2-001.tsv` recorded `EnderDragon.readAdditionalSaveData`
with a gap: `sitting_damage_received` was not read. The row now says
`Dragon::from_nbt` reads it with `getFloatOr`, 0 when absent.
2026-11-09 17:22:03 +00:00
MSedgeMC cbd47615bc test: a perched dragon's stored damage survives the chunk save
`Dragon::to_nbt`/`Dragon::from_nbt` are the pair the dragon's chunk entity
uses. A perched dragon with `sitting_damage = 12.5` saves
`sitting_damage_received: 12.5f` and `from_nbt` gives 12.5 back. A compound
with no such key loads 0.0.

Without the read the reloaded dragon has 0.0: `left: 0.0`, `right: 12.5`.
2026-11-09 17:22:03 +00:00
MSedgeMC 04822910b5 fix: a perched dragon keeps its saved damage through a reload
`EnderDragon.addAdditionalSaveData` writes `sitting_damage_received` (the
damage a perched dragon has taken since it sat down, which makes it take off
at a quarter of its health), and `readAdditionalSaveData` reads it back with
`getFloatOr("sitting_damage_received", 0)`.

Horizon wrote the field in `Dragon::to_nbt` but `Dragon::from_nbt` ignored it:
a perched dragon saved after some damage but before taking off reloaded with
`sitting_damage` 0 and had to be hit for the whole quarter again.

`Dragon::from_nbt` now reads the float, 0 when absent, as vanilla does. The
health read is folded into one line so the over-3000-line file does not grow.
2026-11-09 17:22:03 +00:00
MSedgeMC 9e68efc05b port_map: the destroy-progress stage and the daylight ticker rows are ports
`guest_dig_feedback` sends `(int)(progress * 10)`, the stage
`incrementDestroyProgress` computes, and `tick_daylight_detector` gates on
the dimension type's sky light, so the End is excluded like the Nether.
Both rows still described the old gaps.
2026-11-09 17:19:46 +00:00
945c667281 Discard dropped items below each dimension's own void floor
Entity.checkBelowWorld discards at getY() < Level.getMinY() - 64, so the Nether and the End (minY 0) lose an item at y = -64, not the Overworld's -128. Use the level's minimum y in ItemEntity.tick and the item tick, and keep an item at exactly the threshold, as vanilla's < does.

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>

Claude-Session: https://claude.ai/code/session_01WZzq8MxShENtiMj2DMbm5G
2026-11-09 18:10:14 +01:00
rdbtCVSandClaude Sonnet 5.5 b8ceaa3ed4 Merge main into fix/h211-shulker-drop-lock-2
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 17:09:21 +00:00
Nikita Maximilian Grimm 070902e9f5 Merge remote-tracking branch 'origin/main' into fix/command-exec-group-isolation 2026-11-09 18:00:50 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 6c4df147a7 command_exec removes what each group leaves alive before the next group
Groups share one world, so an entity a group left stayed visible to later groups' unbounded selectors.
The bell group's persistent zombie burned in daylight and died at a random tick in each engine, which
made the entities group's PersistenceRequired check flaky. The harness now kills, between two groups,
the non-player entities above the groups' floor (y 100 in the overworld, 128 elsewhere), shrinking
cube mobs first and killing twice so drops go too. World-generated entities below stay out of it.
The wolf anger clock diagnostic continues the group before it and is named in CONTINUES_PREVIOUS.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:59:59 +01:00
Horizon agentandClaude Opus 5.5 3f044a9aca The command harness records only what a command's own broadcastChanges announced
run_command checks InventoryChangeTrigger after every command; vanilla's
/loot give calls no broadcastChanges, so its Diamonds! comes on the
player's next tick, outside the command's record (inline_loot_reference).
/item replace on a player marks what its own check announced
(cmds.broadcast_announced); the harness records those and leaves the
rest for the tick.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:57:19 +01:00
MSedgeMC 8d7267f7f7 port_map: JigsawBlockEditScreen lives in jigsaw_edit.rs 2026-11-09 16:51:30 +00:00
Nikita Maximilian Grimm bdc0092803 Merge origin/main into typed-randoms-1a 2026-11-09 17:50:32 +01:00
rdbtCVSandClaude Sonnet 5.5 c3834bb29d Merge main into fix/11316-custom-durability
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 16:48:09 +00:00
Horizon agentandClaude Opus 5.5 e749b17104 mob_attacks_mob and the hit catch-up move to combat.rs: creatures.rs may not grow
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:44:46 +01:00
Horizon agent 9e82a7acd1 Merge remote-tracking branch 'origin/main' into fix-11585 2026-11-09 17:30:30 +01:00
Horizon agentandClaude Opus 5.5 1340e54376 cargo fmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:23:08 +01:00
MSedgeMC 0eb2196f99 The jigsaw's priority boxes explain themselves on hover
JigsawBlockEditScreen sets a tooltip on its selection and placement priority
boxes, explaining what each number does. Horizon's jigsaw editor drew both
boxes without one. The block editor now takes an optional tooltip per box and
draws it over the screen, and the jigsaw gives its two priority boxes theirs.
2026-11-09 16:21:58 +00:00
Horizon agentandClaude Opus 5.5 2b579a4067 items: a durable prototype drops an explicit zero damage when saved
PatchedDataComponentMap keeps only values that differ from the prototype:
a diamond pickaxe already has DAMAGE 0, so the zero components_tag now keeps
for a stack-declared max_damage is dropped again by the prototype-aware save.
A stick, whose prototype has no DAMAGE, keeps it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:11:40 +01:00
Horizon agentandClaude Opus 5.5 3925efc44e adapter_requested_split: the same rule in the lines it had
gfx.rs is over 3000 lines and may not grow (tools/check_file_size.py).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:10:40 +01:00
Horizon agentandClaude Opus 5.5 4eee03985c The engine reads PvP & HUD's options through game/extras/pvp.rs (layer plan M5)
Every read of settings.extras.pvp outside the PvP module and its HUD
editor (glint, attack indicator, legacy look and rules, sound and
particle filters, shield and totem size, fire height, clear fluids,
chat search and copy) now calls one accessor in game/extras/pvp.rs.
Same values, read at the same places: each accessor becomes one engine
knob or hook when PvP & HUD leaves the engine.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:10:24 +01:00
Horizon agentandClaude Opus 5.5 fb7b0f4ca3 mobs.rs and goals.rs don't grow: two helpers move out
totem_death_effects goes to mobs/living.rs (LivingEntity.checkTotemDeathProtection), and
RandomPos.moveUpToAboveSolid to mobs/random_pos.rs. Pure moves; no behaviour change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:03:47 +01:00
Horizon agentandClaude Opus 5.5 1414d6350f Chat filter: the hub asks an engine hook, horizon-client-lib no longer names horizon-chat-filter
Layer plan M6. horizon-server-files gets chat_guard (the ChatGuard trait, Place, Action, Mute, and an
install/load registry). horizon-chat-filter implements the trait and its loader; entry installs it
at startup. Behaviour unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:03:42 +01:00
Horizon-agentandClaude Opus 5.5 fe35609011 Tests from main build their FrameRequest with linear_blend
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:01:52 +00:00
MSedgeMC f8599e2685 Disable-blocking seconds come from a stack's weapon component
LivingEntity.getSecondsToDisableBlocking reads
Weapon.disableBlockingForSeconds. Horizon read the prototype's value, so a
patched weapon disabled a shield for 0 seconds instead of its own.

Test: a diamond sword with disable_blocking_for_seconds 1.5 reports 1.5.
2026-11-09 16:00:10 +00:00
MSedgeMC 02a8a61de4 Weapon wear comes from a stack's weapon component
ItemStack.postHurtEnemy uses Weapon.itemDamagePerAttack from the stack's
component. Horizon read the item prototype's weapon damage, so a patched
weapon wore 0 or the prototype's amount instead of its own.

Test: a diamond sword with item_damage_per_attack 3 wears 3 per hit.
2026-11-09 16:00:01 +00:00
Nikita Maximilian GrimmandClaude Opus 5.5 ef090d751b Merge origin/main into typed-randoms-1a
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:56:42 +01:00
Horizon agentandClaude Opus 5.5 26afbb999c Merge #13745's formatting into #13747
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:53:52 +01:00
Horizon agentandClaude Opus 5.5 d906242b0e Format the pattern tag walk
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:53:45 +01:00
Horizon-agentandClaude Opus 5.5 43851ba7e7 Frame policy moves out of gfx.rs into gfx/frame_policy.rs
frame_format, frame_configuration, overlay_format and horizon_tone_curve
move unchanged beside their tests (frame_policy_tests.rs), so gfx.rs does
not grow against main with linear blending in.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:50:31 +00:00
Horizon-agent 0317c86476 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-09 15:49:41 +00:00
Horizon agentandClaude Opus 5.5 c80f0361be Projectiles: put shoot_own's doc comment back on shoot_own
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:49:12 +01:00
Horizon agentandClaude Opus 5.5 c08d2b301e Merge origin/main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:49:12 +01:00
Horizon agentandClaude Opus 5.5 dbf99fe456 Merge #13748 into #13749
Both add an equippable reader beside `equippable` in item_sync.rs: with
#13748 merged in, either lands first without a conflict. The new test's
doc comment now describes it, and the glider test keeps its own.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:40:24 +01:00
Horizon agentandClaude Opus 5.5 7842fca4da Keep the glider test's doc on it; cover a removed equippable
The new test went between the glider test's doc comment and its fn.
Also check that a removed component plays no equip sound, as the
description says.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:39:57 +01:00
Horizon agentandClaude Opus 5.5 ef01959fb1 Merge #13745 (with #13744) into #13747
The three loom PRs change banner.rs side by side: with the others merged
in, any of them can land first without a conflict. All their tests kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:39:39 +01:00
Horizon agentandClaude Opus 5.5 ec2cd929ab Merge #13744 into #13745; read nested pattern tags
Both PRs change the loom's component reads in banner.rs: merging #13744
here lets either land first without a conflict (both tests kept).

provides_banner_patterns names `#minecraft:pattern_item/<name>`, a tag in
a subfolder, and an id may omit its namespace: load every tag under
tags/banner_pattern and namespace bare ids (Identifier.parse), with
test cases for both.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:39:06 +01:00
Horizon agentandClaude Opus 5.5 6f79c55c2a Keep game.rs at its size: leave the drop check in place there
game.rs is over the 3000-line limit (docs/layers.md#file-size); the two forms are the same check.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:36:42 +01:00
Horizon agent 529cd743a0 Merge remote-tracking branch 'origin/main' into fix-11548
# Conflicts:
#	crates/horizon-client/src/game/hand.rs
2026-11-09 16:35:26 +01:00
MSedgeMC 6a68afa6cc A banner with an invalid layer loads no patterns
BannerPatternLayers.CODEC reads its layer list all or nothing: one layer the
codec rejects leaves the empty layer list. Horizon skipped only the bad layer,
so a banner kept the layers before it.
2026-11-09 15:32:06 +00:00
Horizon agent 3c5c7d6077 Merge remote-tracking branch 'origin/main' into fix-11484
# Conflicts:
#	crates/horizon-client/src/game/nether_portal.rs
2026-11-09 16:31:19 +01:00
Horizon agentandClaude Opus 5.5 f3b08a0988 Merge main into #12156
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:27:21 +01:00
Horizon agentandClaude Opus 5.5 e364221b4b Resource-pack drops: no winit fork
The game workspace pinned winit to an unreleased backport in a personal
fork (crmne/winit) for native Wayland file drops. Every player build would
then depend on a repository outside winit's own, which the project doesn't
take. The drop import itself works wherever winit already delivers
DroppedFile (Windows, macOS, X11, XWayland); native Wayland follows once a
winit 0.30 release carries rust-windowing/winit#4729.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:24:59 +01:00
Horizon agent b23b5a810f Merge main into resource-pack drops 2026-11-09 16:24:58 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 89faf5b548 Merge origin/main into typed-randoms-1a
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:24:23 +01:00
Horizon agentandClaude Opus 5.5 5171e9c09c Breed: a panicking partner is refused and stops the goal
The snapshot carried the panic state but Breed never read it:
getFreePartner skips a panicking partner and canContinueToUse ends the
goal when the partner panics. The test only skipped on CI (no prepared
resources), so it looked green without the check.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:23:17 +01:00
Horizon agent 871fb3a448 Merge main into the breed panic fix 2026-11-09 16:23:09 +01:00
Horizon agent a1d48cf2c0 Merge remote-tracking branch 'origin/main' into fix-11585 2026-11-09 16:21:54 +01:00
Horizon agent d93a9a4be4 Merge remote-tracking branch 'origin/main' into fix-11536 2026-11-09 16:21:50 +01:00
Horizon agentandClaude Opus 5.5 2b6aed3dc7 Leave the other harnesses' work folders as on main
A run- folder per invocation under .local/<harness>/ is never removed by runner-gc (it only
looks one level down), and these four harnesses aren't this fix's. Main's harness fixes own them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:21:48 +01:00
Horizon agentandClaude Opus 5.5 a9f390ee3b Leave the file-size check's CI change to #13882
The same fix (compare with the main the pull request lands on) is open as #13882. A change to
.github/ also keeps a fork's runs from being approved automatically, so this branch got no parity.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:14:41 +01:00
Horizon agentandClaude Opus 5.5 53d1d825ee Leave the file-size check's CI change to #13882
The same fix (compare with the main the pull request lands on) is open as #13882. A change to
.github/ also keeps a fork's runs from being approved automatically, so this branch got no parity.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:14:35 +01:00
Horizon agent 956eb692ea Merge remote-tracking branch 'origin/main' into fix-11585 2026-11-09 16:14:20 +01:00
Horizon agent 6db95ffc1e Merge remote-tracking branch 'origin/main' into fix-11536 2026-11-09 16:14:15 +01:00
iseaprojectsandopencode 0f6227abba Honor custom MAX_DAMAGE on normally non-durable items
`ItemStack.isDamageableItem` asks for the MAX_DAMAGE, DAMAGE and
absence of UNBREAKABLE components, not for the item prototype's own
durability. A stick given `minecraft:max_damage` and `minecraft:damage`
is therefore damageable and wears while mining, but Horizon only looked
at the prototype `max_damage` in `mining_damage_capacity` and in the
attack wear path, so such stacks never took durability damage and an
explicit `damage: 0` was dropped on save.

- `durability::stack_max_damage` mirrors `getMaxDamage` for any stack:
  MAX_DAMAGE present, DAMAGE present (prototype, stack value, or an
  explicit zero), no UNBREAKABLE.
- `mining_damage_capacity` and `damage_held_in_slot` both use it.
- `components_tag` keeps an explicit `damage: 0` when the stack carries
  its own `max_damage`; durable prototypes still drop the zero, matching
  vanilla's `fromPatch` sanitizing.

The capacity logic lives in its own module so `items.rs` (already past
the 3000-line limit) does not grow; the thin method there delegates.

Completes the deferred part of #11316: custom durability on non-durable
item types. Covered by unit tests, the `mining_component_tests` game
regression, and 74 stack-mining parity cases against vanilla 26.3.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-09 11:12:01 -04:00
Horizon agentandClaude Opus 5.5 5ae9f06129 cargo fmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:06:26 +01:00
Horizon agentandClaude Opus 5.5 13061c109f lan_bot: the record is the guest's
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:41:16 +01:00
Horizon agentandClaude Opus 5.5 40eb5c7fd7 Record: the teleport's restart reads the guest's record
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:40:35 +01:00
Horizon agentandClaude Opus 5.5 04a8acc5a3 Movement prediction: the client-tick record moves from horizon-anticheat into the engine's replay
Layer plan M6, step toward dropping horizon-client-lib -> horizon-anticheat. The record
(horizon_anticheat::prediction) is what the engine's movement replay (lan::prediction) plays again:
it moves to lan/prediction/record.rs unchanged. The guest holds it and the client's sprint
(was Player.record / Player.sprinting). Behaviour unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:32:06 +01:00
Horizon agentandClaude Opus 5.5 5a589fb0f0 The PvP & HUD options' game glue moves to game/extras/pvp.rs (layer plan M5)
A pure move under game/extras/, where each Horizon extra's game glue
gathers: paths and visibility only (pub(in crate::game)). game.rs
loses its mod line.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:21:29 +01:00
Horizon agentandClaude Opus 5.5 a9ce60dde5 Auto-reconnect's game glue moves to game/extras/reconnect.rs (layer plan M5)
A pure move: the connection's start, join and end, the countdown and the
retry leave game/extras.rs for a child module of their own, so the extra
leaves the engine in one move once the seam lands.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:16:18 +01:00
MSedgeMC e632dda2b7 cargo fmt 2026-11-09 14:07:35 +00:00
MSedgeMC d22f313e9e Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-09 14:00:02 +00:00
Nikita Maximilian GrimmandClaude Opus 5.5 7c94c21638 The level's random and a mob's own random have types of their own
EntityRandom (Entity.random) and LevelRandom (Level.random) wrap a
LegacyRandom in horizon_core::random, each with one named constructor
(EntityRandom::own, LevelRandom::for_level) and its own positional type.
World.random is a LevelRandom and Mob.random an EntityRandom, with
entity_random, living_random, Mob::with_random and
mob_from_nbt_with_random. Both deref to LegacyRandom while the rest
migrates (#13813). No draw changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 14:28:22 +01:00
Horizon agentandClaude Opus 5.5 add5b203c6 A hoglin hits from its fight activity, 41 ticks apart
HoglinAi.updateActivity runs after the brain, so a hoglin given an attack
target enters the fight activity at the end of that tick and its
MeleeAttack hits from the next one: the goal now acts only in the fight
activity (brain_step runs after the goals). MeleeAttack.create(40) sets
ATTACK_COOLING_DOWN for 40 brain ticks and MemorySlot.tick erases it on
the 41st, so hits come 41 ticks apart (16 for a baby).

A hit one mob's tick deals another is handled after the mob loop, after
the target's own tick; vanilla deals it inside the attacker's tick, so a
target later in the list counts HurtTime and its damage cooldown down
once more that tick. The pig hit by a hoglin read HurtTime 10 on the tick
of the hit; vanilla's reads 9.

hoglin_repeat_attack reports its rand- rows instead of comparing them:
each hit is attack / 2 + level.getRandom().nextInt(attack), and
Level.random is RandomSource.create(); two vanilla runs disagree on the
pigs' Health.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 14:05:50 +01:00
MSedgeMC e957a4bd85 Keep mobs.rs and goals.rs under the file-size limit
Mob::seen moves to mobs/seen.rs and the FollowMob test to mobs/invisible_follow_tests.rs, so neither file over
3000 lines grows: 6858 -> 6777 and 3740 -> 3738.
2026-11-09 13:05:29 +00:00
Horizon agentandClaude Opus 5.5 a93975ceb5 An allay stays on its pickup cooldown at 0; /item replace triggers inventory_changed
CountDownCooldownTicks keeps ITEM_PICKUP_COOLDOWN_TICKS while it counts
down and erases it the tick after it reached 0 (stop), and
Allay.isOnPickupCooldown is the memory's presence: an allay loaded with
{value:30} saves {value:0} on its 31st tick, then nothing. ints[3] now
holds the value plus one (allay::pickup_cooldown).

EntityItemAccessor calls containerMenu.broadcastChanges for a player
whose slots /item replace changed, so InventoryChangeTrigger (here the
Diamonds! announcement) comes before the command's message. The command
harness now announces what a command earned before its feedback, as a
guest's command does (announce_guest_advancements); it left the
announcement for the next tick, outside the command's record.

allay_pickup_cooldown reports its rand- rows instead of comparing them:
the natural allay's flight starts at its first item scan
(Sensor.randomlyDelayStart with the allay's own random, which is
RandomSource.create()), and two vanilla runs of the script disagree on
those 30 rows.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 14:05:01 +01:00
Horizon agentandClaude Opus 5.5 3ef15455e3 command_exec reads the zombie villagers' random profession only where it is not random
ZombieVillager.defineSynchedData picks the profession with
VILLAGER_PROFESSION.getRandom(this.random), and Entity.random is
RandomSource.create(): the first two subjects' profession:none check
passed 1 time in 15 on either side, so 'zombie villager summoned data'
matched 9/10 from run to run. The third subject's profession comes from
its save and stays checked.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 13:49:15 +01:00
Horizon agentandClaude Opus 5.5 cef8682971 Drop the item count fixture nothing runs
Its name matches no harness pattern; test_command_exec_parity.py --only
"item count predicate" already compares the group with vanilla.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 13:44:27 +01:00
Horizon agentandClaude Opus 5.5 67d694dd9c shulker_lock_drop runs by name only
It needs --work-dir and --reference-dir, which an automatic parity run
doesn't pass, so without the named_only entry it fails at startup on
every pull request that selects it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 13:43:10 +01:00
Horizon agentandClaude Opus 5.5 7b25b8ed80 Sculk reload particle: interpolate in doubles as vanilla does
VibrationSystem.Ticker.tryReloadVibrationParticle computes alpha as a double.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 13:35:18 +01:00
Florian Ernst c2e4f28c3f Start CI again: the previous run stayed queued with no jobs 2026-11-09 13:25:01 +01:00
Florian Ernst 8e26753cda Start CI again: the previous run stayed queued with no jobs 2026-11-09 13:24:59 +01:00
Horizon agentandClaude Opus 5.5 ec08a90d43 invulnerable_time compares a mob's attributes list unordered
AttributeMap.pack lists the instances in the iteration order of an
Object2ObjectOpenHashMap keyed by Holder.Reference, which keeps
Object.hashCode: an identity hash. The same script run under another
main class saves the pig's list in another order, so the oracle's
order is the JVM's, not the game's. All 11 differences on main were
that order. The driver gets unordered_attributes(), which sorts the
entries of every attributes list in a record and keeps their text and
styles; the harness compares through it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 13:22:31 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 32c375e0ae HDR output: a shaders/ pack's alpha isn't lifted as light-source marks
With HDR on and a shaders/ (OptiFine/Iris format) pack running, the last
pass read the alpha the pack's final program leaves in the frame as
Horizon's light-source marks and lifted those pixels above paper white.
A pack that leaves alpha below 0.75 had most of its picture brightened as
if it were torches or lava, and HDR Lighting turned that on and off.

Horizon doesn't light the world under such a pack (HDR lighting is
already off there), so the lift is now off too: the pack is shown at
paper white, as a 0.2 pack is. The decision moves into a small pure
function in gfx/output.rs with a unit test. HDR off doesn't change.

Fixes #13807.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 13:11:02 +01:00
RINNECODERandClaude Opus 5.5 e31eeeb2bc Boats and minecarts more than 64 blocks under the world are discarded
Entity.checkBelowWorld runs in AbstractBoat.tick (through Entity.baseTick,
after the fire) and in AbstractMinecart.tick (after its hurt and damage wear
off), before either moves: under the level's floor minus 64, onBelowWorld
discards it. Horizon had no such check for vehicles, so a boat or minecart
that fell out of the world kept falling for ever (#8024 measured one at
y -243 where vanilla had removed it by tick 33).

The discard ejects the mob riders where they are and spills a chest boat's or
container minecart's slots (AbstractChestBoat.remove,
AbstractMinecartContainer.remove), with no ENTITY_DIE and no vehicle item.
The logic lives in game/boats/below_world.rs; minecarts.rs keeps its size.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 05:41:46 -06:00
voidsip edfe18495e Keep reputation damage and visibility helpers in focused modules 2026-11-09 05:35:03 -06:00
voidsip 8ede2ff197 Keep potion sound regression in its own module 2026-11-09 05:22:24 -06:00
voidsip f1696089ef Merge commit 'b25a050be818408360d7cf97956350212dda0686' into fix/11329-villager-reputation-events
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
#	crates/horizon-client/src/game/native_hooks.rs
2026-11-09 05:20:13 -06:00
voidsip a4925a7954 Merge commit 'b25a050be818408360d7cf97956350212dda0686' into fix/11336-potion-contents 2026-11-09 05:19:15 -06:00
voidsip 085b4109e3 Merge commit '3690aa6119b9235be7e3b79680c156da8979479b' into fix/11336-potion-contents 2026-11-09 05:19:05 -06:00
voidsip 4a6942b8e9 Merge commit '3690aa6119b9235be7e3b79680c156da8979479b' into fix/11329-villager-reputation-events 2026-11-09 05:18:51 -06:00
Nikita Maximilian GrimmandClaude Opus 5.5 08d02c752f Format the shot calls that lost their random argument
A projectile added keeps an earlier UUID for its id (a client copy's from the
spawn packet) through or_insert again, so the client copy needs no reset.

Part of #12561

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 12:13:18 +01:00
Nikita Maximilian Grimm 31edfa94fb Merge remote-tracking branch 'origin/main' into parity/sulfur-cube-bucket-health 2026-11-09 12:07:49 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 3a35efafa8 Keep a loaded sulfur cube's saved Health in its own load
entity_nbt.rs may not grow (over 3000 lines), so the sulfur cube's own load
re-reads its saved Health after sizing it instead of the living load
skipping the Health Boost clamp: the result is the same, the clamp to the
unsized maximum no longer sticks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 11:57:18 +01:00
RINNECODERandClaude Opus 5.5 89f90f0b65 OptiFine CTM: a tile draws in the pass of the face it replaces
CTM picks a texture, not a render layer: OptiFine draws a tile in the
block's layer. Horizon took the pass from the tile's own sprite, so one
of Conquest Reforged's six random grass blade tiles (7 partly clear
texels of 1024) put its overhang plane in the translucent pass. That
pass has no alpha test, so the plane's clear rectangle wrote depth
(depthtex0 had it, depthtex1 not), and Bliss fogged it: faint
rectangles against the sky. A tile now takes the face's pass; its own
texels still decide whether it skips the alpha test.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 04:50:07 -06:00
RINNECODERandClaude Opus 5.5 f965ae85f3 OptiFine CTM: a picked tile chains to the rules naming it, not to its layer's
Identical still sprites share one layer. Chaining looked the picked tile's
rules up by that layer, so a tile chained to every rule naming a tile with
the same pixels. Conquest's grass top repeats 16 blank placeholders that
random rules beside them replace (matchTiles=./N.png); dozens of other
folders' placeholders are blank too, and their rules, sorting first, drew
their own tiles on the grass. A rule's tile now chains to the rules naming
that tile, and a tile file (not a model's sprite) no longer matches faces by
layer.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 04:50:07 -06:00
Nikita Maximilian GrimmandClaude Opus 5.5 916b481669 Size a loaded sulfur cube before its Health Boost clamp
The living load refreshed a Health Boost holder's maximum and clamped its
Health before sulfur_cube::from_nbt sized it, so a size-3 cube saved with
Health 12 under Health Boost dropped to the constructor size's 8. Vanilla
sizes the cube (AbstractCubeMob.readAdditionalSaveData) before
LivingEntity's setHealth; its own load now sets the maximum.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 11:44:42 +01:00
Nikita Maximilian Grimm 8db4b7bc14 Merge remote-tracking branch 'origin/main' into projectile-uuid-draws 2026-11-09 11:37:10 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 b5fd8221fc Keep a loaded projectile's UUID through Projectile::own_uuid
The loads take the own random's UUID through one helper, so persist.rs and
creatures.rs keep their size. A client copy drops its own random's UUID: the
spawn packet's, kept before it is added, wins.

Part of #12561

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 11:37:08 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 2ba5206ba9 A sulfur cube saves its size's max_health and movement_speed
SulfurCube.setSize sets the max_health (4 per size) and movement_speed
(0.2 + 0.1 * size) attribute bases, so every load, spawn, split and bucket
release leaves both instances to save. Horizon's set_size wrote the cached
values only: the save held no max_health, and from the first tick a
movement_speed of the type's 0.7, which a reloaded cube then moved at.
set_size now records the bases (the spawn path did that alone before), the
load applies saved bases after the size as LivingEntity does, and Health
Boost changes refresh a sulfur cube's maximum like any model-owned one.

Fixes #13801

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 11:26:12 +01:00
Riccardo GuerrieroandClaude Opus 5.5 983d9a3235 Keep mobs/ai.rs from growing: the hoglin check moves to piglin.rs
ai.rs is over the 3000-line limit (docs/layers.md#file-size), so the
Hoglin.canFallInLove check and its explanation move to
piglin::refuses_love; takes_food keeps one line for it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 11:08:18 +01:00
Florian ErnstandClaude Opus 5.5 1efd1c20c9 Keep game.rs from growing: Pending::record_from reads name and lock
The file-size check (docs/layers.md#file-size) does not let a file over
3000 lines grow. Breaking a block entity and dropping its resources both
read the custom name and lock from the world, so a helper does it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 10:52:54 +01:00
Florian ErnstandClaude Opus 5.5 d8c00ad64e Keep commands.rs from growing: count matcher and tests in their own files
The file-size check (docs/layers.md#file-size) does not let a file over
3000 lines grow. The count bounds matcher moves to commands/item_count.rs
and the ignored test module to game/item_count_predicate_tests.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 10:52:21 +01:00
MSedgeMC 87c473666a Do not load items the selection test does not read 2026-11-09 09:03:32 +00:00
MSedgeMC 1c9f8a4359 Keep the tag type in the test 2026-11-09 09:03:30 +00:00
Florian ErnstandClaude Opus 5.5 e2f472508d Preserve external locks on destroyed shulker boxes
Squashed and rebased onto main from #11627.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 10:01:28 +01:00
Florian ErnstandClaude Opus 5.5 38060a67ba Match item count equality against the actual stack
Squashed and rebased onto main from #11658.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 10:01:28 +01:00
DUzzL a48be025f1 Skip item wear for unbreakable stacks 2026-11-09 09:59:02 +01:00
MSedgeMC d0850614ba Follow mob goals ignore invisible mobs 2026-11-09 08:50:42 +00:00
MSedgeMC f4d10e7099 Import the Tag type the loom component read uses 2026-11-09 08:48:45 +00:00
MSedgeMC 1b029a08f6 Import the Tag type the loom component read uses 2026-11-09 08:48:43 +00:00
MSedgeMC 8a5211f260 Import the Tag type the loom component read uses 2026-11-09 08:48:41 +00:00
LokiandAntigravity 6c199da5f6 sculk: resume vibration particle on reload (fixes #12935)
Co-Authored-By: Antigravity <[email protected]>
2026-11-09 17:46:05 +09:00
MSedgeMC f65d12322e A pad guide reads a stack's equippable slot and swappable flag
The controller guide offers Equip only when Equippable.swapWithEquipmentSlot
applies: the stack's slot is an armour slot and swappable is true. Horizon
read the item prototype for both.

Test: a helmet patched swappable:0b no longer offers Equip, and its patched
slot is used.
2026-11-09 08:43:41 +00:00
MSedgeMC 0831968204 Armor damage_on_hurt comes from the stack's equippable component
LivingEntity.doHurtEquipment skips a piece whose Equippable.damageOnHurt is
false. Horizon read the prototype's flag for both the player's armor and a
mob's, so a stack patched to false still wore.

Test: a golden helmet patched with damage_on_hurt:0b does not wear, and a
removed equippable component does not wear.
2026-11-09 08:43:33 +00:00
MSedgeMC 1e8745416d Equip sounds come from the stack's equippable component
Player and mount equipping read the item prototype's equip_sound. A stack's
own minecraft:equippable replaces the component, so its equip_sound (or the
generic default when omitted) must win.

Test: a golden helmet with a slot-only equippable component uses the generic
sound, not the gold one; a patch naming a sound uses that sound.
2026-11-09 08:43:26 +00:00
MSedgeMC 9fc9722501 A loom reads a dye stack's dye component
LoomMenu.isDyeItem needs the LOOM_DYES tag and a present DYE component, and
the result uses that component's colour. Horizon read the prototype's dye,
so a patched or removed dye kept its item default.

Test: white dye patched red colours red; white dye with !minecraft:dye is not
a loom dye.
2026-11-09 08:43:19 +00:00
MSedgeMC adfd8e43c8 A loom offers the patterns a stack's component names
LoomMenu.getSelectablePatterns reads the stack's PROVIDES_BANNER_PATTERNS
HolderSet. Horizon read the item prototype's list, so a patched pattern item
offered the wrong patterns.

Test: a globe banner pattern patched to creeper offers creeper, and a removed
component offers none.
2026-11-09 08:43:11 +00:00
MSedgeMC f7267efa1b A loom takes a pattern item whose provides component was removed
LoomMenu.isPatternItem needs the LOOM_PATTERNS item tag and a present
PROVIDES_BANNER_PATTERNS component. Horizon accepted any tagged item whose
prototype provided patterns, even after !minecraft:provides_banner_patterns.

Test: a globe banner pattern with the component removed is not a pattern
item.
2026-11-09 08:43:04 +00:00
Tran Anh Van c4a108b5c3 Keep original f64 PI constant in rod_hand_position 2026-11-09 15:36:22 +07:00
Tran Anh Van 4628acd092 Restore original PI constant type in rod_hand_position 2026-11-09 15:29:29 +07:00
RINNECODERandClaude Opus 5.5 65b8c76509 Boats: Mth's sine table for steering and dismounts, and lastYd for water entry
Boat steering, a boat rider's dismount vector and a minecart rider's push
used the platform's sine and cosine, where vanilla uses `Mth`'s 65,536
entry table, so a boat paddled at most yaws drifted from vanilla's path
(about 1e-4 of the push per tick). They now go through
`horizon_core::geometry::trig`, as `AbstractBoat.controlBoat`,
`Entity.getCollisionHorizontalEscapeVector` (reusing the mounts port)
and `Entity.getInputVector` do. The dismount also uses vanilla's float
products: the boat's width times `Mth.SQRT_OF_TWO`, the 1.0E-5F margin and
the player's 0.6F width.

`getWaterLevelAbove` scans from the hull's top up to
`ceil(maxY - lastYd)`, where `lastYd` is the boat's vertical velocity at
its last `checkFallDamage` (before collisions zero it); Horizon scanned
only the hull top's own row, so a boat falling fast into deep water could
pick another surface height.

Fixes #11381

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 02:26:58 -06:00
Tran Anh Van 71cf748116 Show other players' fishing rods as cast while their bobber is out
When drawing held items for non-local entities in held_item_geometry,
set fishing_cast_with_this_item from the remote hooks' active owner
state and the holding hand (with the main hand taking precedence when
both hold fishing rods), matching FishingRodCast and FishingHook's
client owner tracking.
2026-11-09 15:24:37 +07:00
MSedgeMC 86d2c0d5cd Edit box test expectations count the maximum in UTF-16 units
The two native text field replacement tests pinned the old character count:
a maxLength of 4 kept four code points and one of 3 kept three. EditBox's
maxLength is Java String.length(), so the emoji takes two of them: 4 units
keep "a😀b" and 3 keep "a😀", which is what vanilla's setValue truncation
leaves.
2026-11-09 08:20:36 +00:00
DUzzL d288db6b6e Award the crafted statistic for crafting-table and inventory-grid takes 2026-11-09 09:18:02 +01:00
MSedgeMC 9523592a93 An edit box's maximum length counts UTF-16 code units, as Java's does
EditBox.maxLength caps Java `String.length()`: UTF-16 code units, so a
character outside the BMP takes two of them. Horizon measured the limit in
Rust chars (code points) in both edit box ports, so a chat box with a maximum
of 256 accepted 256 emoji where vanilla accepts 128, and a pasted emoji could
take a place the next character should have been refused.

`ui::EditBox` (the screens' field) and `screen_draw::TextField` (the chat box
and the native screen API's field) now measure the maximum in UTF-16 units and
keep whole characters, which is the prefix vanilla's insertText keeps after
its half-surrogate guard drops the split one.
2026-11-09 08:03:43 +00:00
Nikita Maximilian GrimmandClaude Opus 5.5 e8327d7baa Merge origin/main into shader-api-linear-blend
Main's output owner (#13233) decides each renderer's frame in gfx/output.rs.
Whether the frame holds linear light is now part of that decision:
`FrameRequest::linear_blend` carries the picked 0.2 pack's `[frame] blend`, and
`resolve_frame` sets `FrameConfiguration::linear_light` where the frame is the
float one. The renderer reads it from its output instead of a field of its own,
so a pack that changes its blend asks for another frame and the game makes the
renderer again through main's frame check, without a separate outcome key.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 08:35:46 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 51ff5618de Shader API 0.2: fade-in and custom skies on a frame of linear light
Under a 0.2 pack with `[frame] blend = "linear"`, two draws still mixed or wrote
vanilla's gamma values into the linear frame:

- A section fading in mixed the pack's material result, linear light, toward
  the gamma fog colour, so new chunks faded in too bright and unlike "gamma".
  The fade now mixes in gamma, as the fog does.
- A resource pack's OptiFine custom sky layers, drawn when the pack has no sky
  stage, wrote gamma, so that sky looked washed out. They now store their
  colour decoded. Multiply and overlay layers convert their factor so the
  product stays what it is under "gamma". Add and subtract add light, as
  "linear" means to; dodge, burn and screen come out close to their gamma
  results.

Under "gamma", and without a 0.2 pack, nothing changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 07:51:46 +01:00
21f90219c6 worldgen: load saved mineshafts, fortresses and strongholds
Read these structure pieces from saved starts instead of regenerating their
layouts and losing placed chest or spawner state. Follow StructureStart.loadStaticStart,
PiecesContainer.load and the MineshaftPieces, NetherFortressPieces and
StrongholdPieces tag constructors in vanilla 26.3.

Keep whole-start regeneration when a required piece cannot decode. Preserve
stronghold Source flags and vanilla numeric and optional entrance defaults.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Codex (gpt-6.1-sol) <[email protected]>
Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
2026-11-09 07:23:50 +01:00
ede2ada183 Make a pacified hoglin refuse breeding food
A hoglin within reach of a #hoglin_repellents block keeps its PACIFIED
memory for 200 ticks, and Hoglin.canFallInLove is false while it is set.
Animal.mobInteract then refuses the crimson fungus: nothing is eaten, no
love mode, no baby. Its baby branch asks canAgeUp instead, so a pacified
baby still grows up.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
2026-11-09 07:23:48 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 732dfb1cb0 Render examples: pass the pack's frame blend to the renderer
The examples that make a renderer of their own were still passing a bool where
`Renderer::new` now takes the picked pack's `[frame] blend`; they pass `None`,
no pack, so they draw as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 06:50:10 +01:00
AppaandOpenAI Codex 2d6f393adb Require an empty scratch directory for pack-drop integration test
Guard before creating fixtures because Cargo runs unit tests from their package directory. Validate the integration test by invoking its built executable directly from an isolated game directory.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-08 21:38:50 -08:00
AppaandOpenAI Codex 713f1bd2b1 Implement confirmed resource-pack drops in startup and game options
Fixes #11698. Route resource-pack drops through the vanilla confirmation, copy ZIPs and folders without overwriting existing packs, and refresh the available list while preserving pending selections. Keep Create World's data-pack route and cover the UI, copy safety, and both event callbacks.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-08 21:29:12 -08:00
Nikita Maximilian GrimmandClaude Opus 5.5 a6a7b85f9e Shader API 0.2: draw packs that ask for linear blending
A 0.2 pack with `[frame] blend = "linear"` was refused: the game showed the
"isn't served yet" message and drew without it. Now it draws. Its float frame
holds linear light, so its glass, water and smoke blend as light does, while
solid surfaces look as under "gamma".

The pack's material, sky and weather results are stored as they are. Horizon's
own draws into the frame (entities, particles, clouds, weather, the sky, stars,
sun and moon, the world border, outlines and the debug lines, world warp and
the visibility buffer) decode their colour at the end of their shaders. The
clear colour is decoded too. Block breaking keeps darkening as vanilla's, and
the material's fog still mixes in gamma. What a stage reads (scene_color,
opaque_color, the water mirror) is copied instead of decoded, so [post],
[tonemap] and everything after the tonemap (the hand, camera overlays, HUD,
screenshots, HDR output) see the same values under either blend.

Where the device can't draw a float frame, the pack draws on the 8-bit frame
in gamma instead of being refused. Resource packs' core sky, stars and line
shaders write gamma, so under "linear" Horizon's own draw in their place.

Changing a pack's blend now makes the frame again, like changing its API line.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 05:53:21 +01:00
voidsip 69cb922604 Keep special attack helpers in focused modules 2026-11-08 22:48:45 -06:00
voidsip ee9bae684c Merge commit '3690aa6119b9235be7e3b79680c156da8979479b' into fix/11337-special-attack-followup 2026-11-08 22:46:55 -06:00
Nikita Maximilian GrimmandClaude Opus 5.5 412e1a72c9 A projectile shoots with its own random after its UUID's draws
Vanilla's Entity makes its UUID from the first two draws of its own random
(Mth.createInsecureUUID), and Projectile.shoot spreads the shot with that same
random, from which an arrow keeps drawing (AbstractArrow.setBaseDamageFromMob,
its later rolls). Horizon shot dispensed, crossbow and mob projectiles with a
fresh random that never drew the UUID, player throws with the level's random,
gave an arrow's later draws a random of their own, and gave projectiles a
clock-seeded UUID when first asked.

An arrow now makes its own random in Projectile::new and draws its UUID first;
another kind makes it at the shot (shoot_own), the same stream since nothing
draws between. Every shot goes through shoot_own, the arrow keeps the random,
and the skeleton's base damage comes from it. add_projectile keeps the UUID; a
player's trident takes its shot's. Loaded and summoned projectiles take their
own random's UUID unless the tag has one.

The seed sync proof reads a dispensed arrow's and snowball's UUID and Motion
and a summoned arrow's UUID: 25/30 on main, 30/30 with this.

Part of #12561

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 05:21:24 +01:00
AppaandOpenAI Codex da4e938c99 Fix native Wayland file-drop delivery with winit backport
Use the winit 0.30.13 backport from rust-windowing/winit#4729, pinned to its reviewed revision. Document the Apache-2.0 dependency and removal condition.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-08 20:04:15 -08:00
voidsip 57a3d9bb51 Keep special attack fixtures isolated and deterministic 2026-11-08 21:04:10 -06:00
voidsip 42b04e0238 Update reputation damage and guest fixtures for main 2026-11-08 20:23:13 -06:00
voidsip b37b703f1c Run portal cooldown proof in a scratch game directory 2026-11-08 19:19:22 -06:00
voidsip 94cad99398 Merge commit 'f858ee752f1c0e76b6d200ec846645532536aded' into fix/11336-potion-contents 2026-11-08 18:41:12 -06:00
voidsip b2d8cfa7f1 Merge commit 'f858ee752f1c0e76b6d200ec846645532536aded' into fix/11337-special-attack-followup 2026-11-08 18:38:54 -06:00
voidsip 366d731251 Merge main and preserve special attack follow-up behavior 2026-11-08 18:38:53 -06:00
voidsip d7dc5628e2 Merge commit 'f858ee752f1c0e76b6d200ec846645532536aded' into fix/11329-villager-reputation-events 2026-11-08 18:38:26 -06:00
voidsip 8b45d15486 Merge commit 'f858ee752f1c0e76b6d200ec846645532536aded' into fix/11318-portal-cooldowns 2026-11-08 18:38:26 -06:00
voidsip e8f93ffc67 Merge main into villager reputation fix 2026-11-08 18:38:25 -06:00
voidsip 2e0f86f312 Merge main and preserve entity portal cooldown phases 2026-11-08 18:38:25 -06:00
voidsip 167caf28d0 Merge commit '976346aff5bfa09c6d8e364c347bdf1791618a98' into fix/11336-potion-contents 2026-11-08 18:13:04 -06:00
Riccardo GuerrieroandClaude Opus 5.5 2485e58daa Merge origin/main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:50:33 +01:00
MSedgeMC 555ab73579 Merge remote-tracking branch 'upstream/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/mobs/goals.rs
2026-11-08 15:19:47 +00:00
Horizon agentandClaude Opus 5.5 f4c1e560e8 Drop the .analysis scratch files
They are a local analysis's inputs and outputs, not part of the fix.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 13:28:19 +01:00
MSedgeMC fcfec5fe13 A breeding animal stops when its partner panics
`BreedGoal.getFreePartner` drops a panicking partner and
`BreedGoal.canContinueToUse` requires `!partner.isPanicking()`. Horizon's
`Breed` neither snapshotted the panic state nor tested it, so a pair kept
breeding after one of them started panicking.
2026-11-08 12:26:50 +00:00
voidsip 36bad2c06e Give parity harnesses fresh default capture directories 2026-11-07 20:35:20 -06:00
voidsip 7c7bca2e7b Update potion fixture for silent impact argument 2026-11-07 20:35:19 -06:00
voidsip 58552b8cb9 Merge commit 'bc6016d19d4dd6f11f6b954fc3b121546f069ffb' into fix/11336-potion-contents 2026-11-07 20:25:35 -06:00
RINNECODERandClaude Opus 5.5 61127ad12d Block sprites: on Metal the arrays grow past 512 layers when the sprites need it
Metal keeps 512 layers per sprite array, so the 8 arrays terrain.wgsl binds
hold at most 4096 block textures and a set with more (10,271) was refused
at startup. The split now grows to fit the arrays (up to the GPU's limit
per array) only when the textures need it; up to 4096 it stays 512, as
before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:26:09 -06:00
b064c12aca Let sculk hear item frame changes
Post BLOCK_CHANGE at the frame from ItemFrame.interact, hurtServer and
dropItem, after the changes and drops vanilla performs first. Preserve
BlockAttachedEntity.kill ordering and discard unsupported frames without
a spurious ENTITY_DIE.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Codex (gpt-6.1-sol) <[email protected]>
Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
2026-11-07 21:18:13 +01:00
Horizon agentandClaude Opus 5.5 959a89ef67 Merge origin/main into #11559
/damage: keeps this branch's villager reputation flush after a hurt and
main's enderman teleport settle (they act on different mobs).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:51:59 +01:00
Horizon agentandClaude Opus 5.5 c3a16de36d Merge origin/main into #11585
dispense_fixture.rs: chains this branch's special_attack fixture op and
main's wind_burst op.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:51:14 +01:00
Horizon agentandClaude Opus 5.5 1cf0e13b61 Merge origin/main into fix/11329-villager-reputation-events
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 13:05:20 +01:00
Horizon agentandClaude Opus 5.5 37541674a9 Merge origin/main into fix/11336-potion-contents
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 13:04:25 +01:00
Horizon agentandClaude Opus 5.5 18e0854d00 Merge main into fix/11329-villager-reputation-events
Main split projectile_hit into a wrapper and projectile_hit_inner: the
reputation flush now runs in the wrapper after the inner hit. Trident and
wind charge hits keep both the reputation flush and main's absorbed-damage
and post-attack calls.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:09:28 +01:00
Horizon agentandClaude Opus 5.5 04914f8c33 Merge main into fix/11336-potion-contents
Keep both sides of the potions.rs test modules; the contents fixture passes
the impact motion that main's splash now takes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:07:05 +01:00
Horizon agentandClaude Opus 5.5 c2a2fe370f Skip entities outside a gateway before the per-id lookups
entity_portal_delay walks every entity list; it ran for every item, mob,
boat, cart and pearl in the End each tick.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:01:48 +01:00
voidsip 65d35f3261 Initialize configuration history for reputation test guests 2026-11-06 22:02:27 -06:00
voidsip b4dea2a473 Merge commit '77936bf4bee3ec7d641f54b97a3da09f9b72e213' into fix/11329-villager-reputation-events 2026-11-06 21:52:56 -06:00
voidsip bfa20f0413 Initialize upstream native command fields in reputation fixture 2026-11-06 21:31:46 -06:00
voidsip 05c7788a8f Merge commit '41944ed91474c6af28572d2e2273b6b2bd0bcf1e' into fix/11329-villager-reputation-events 2026-11-06 21:22:00 -06:00
voidsip fd7dfc59df Guard special attack tails with original damage admission 2026-11-06 21:07:44 -06:00
voidsip 35ab895667 Validate villager reputation with isolated game and vanilla fixtures 2026-11-06 19:47:29 -06:00
socksproxandClaude Opus 5.5 507006a08d Move the mining speed formula into horizon-items
Player.getDestroySpeed, Player.hasCorrectToolForDrops and
BlockBehaviour.getDestroyProgress move from horizon-client's gameplay into
horizon_items::mining, taking plain inputs, so frontends that cannot link
horizon-client mine by the same rules. destroy_progress in game/hand.rs
gathers the player's modifiers and calls it, with the native_break_speed
hook still where getDestroySpeed ends; break_block_as_player uses the same
harvest check. Same f32 operations in the same order: no behaviour change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 02:24:09 +01:00
voidsip 178a2bf8b4 Correct isolated potion fixture admission and default invocation 2026-11-06 18:36:13 -06:00
voidsip ec468766cd Assert accepted attack outcomes before comparing follow-up costs 2026-11-06 17:57:23 -06:00
voidsip 673a2515e8 Add stock-compatible accepted-hurt regression fixture 2026-11-06 17:55:03 -06:00
voidsip 93d57cf58c Validate potion components with isolated vanilla methods 2026-11-06 17:52:39 -06:00
voidsip 4f935ae226 Use the standard prepared resource path in the focused harness 2026-11-06 17:28:28 -06:00
voidsip 8eba763b33 Keep legacy cooldown fixture type explicit 2026-11-06 17:14:40 -06:00
voidsip 85029c85e5 Verify portal counters without starting a server 2026-11-06 17:14:13 -06:00
voidsip e08699893a Merge current main into villager reputation fix 2026-11-06 16:15:57 -06:00
voidsip 7e176a3b9e Preserve upstream cart helper spacing 2026-11-06 16:14:29 -06:00
voidsip 9dee3221a3 Merge current main into special attack follow-up fix 2026-11-06 16:14:12 -06:00
voidsip ab335d9ebb Preserve accepted special-target attack exhaustion and slowdown 2026-11-06 16:13:47 -06:00
voidsip b714d73e46 Deliver villager reputation from accepted damage sources 2026-11-06 16:13:29 -06:00
voidsip 72cd6e42ee Merge commit '9d11d0c710705ee0901c2186d78641048a6660b0' into fix/11336-potion-contents 2026-11-06 16:13:16 -06:00
voidsip 9baf83e129 Fix thrown potion stack components and arrow expiration 2026-11-06 16:13:02 -06:00
voidsip 2d04344265 Merge commit '9d11d0c710705ee0901c2186d78641048a6660b0' into fix/11318-portal-cooldowns 2026-11-06 16:12:27 -06:00
voidsip 226e365523 Use entity-specific portal cooldowns and preserve live counters 2026-11-06 16:12:12 -06:00
voidsip bcc1264dff Share identity-aware merchant price refresh helpers 2026-11-06 15:33:49 -06:00
927 changed files with 60155 additions and 9704 deletions

No files matched your search

+12 -12
View File
@@ -1,16 +1,16 @@
# Changes to these files wait for Ludovic's review once branch protection requires code owners' reviews
# Changes to these files wait for a council member's review (council poll P35, 2026-11-10) once branch protection requires code owners' reviews
# (tools/ci/protect_pipeline.sh, docs/security.md). They are what builds, signs and ships releases, the code that
# checks downloads before installing them (backend.rs downloads, installs and updates the launcher itself; links.json
# says where from; the Cargo.toml files pick the crates that do it), and this file. Everything else merges as soon as
# `verify` passes.
/.github/ @ludovic111
/tools/ci/ @ludovic111
/tools/release_train.py @ludovic111
/crates/horizon-release-sign/ @ludovic111
/crates/horizon-server/src/update.rs @ludovic111
/crates/horizon-server/Cargo.toml @ludovic111
/launcher/src/signed.rs @ludovic111
/launcher/src/backend.rs @ludovic111
/launcher/links.json @ludovic111
/launcher/Cargo.toml @ludovic111
/packaging/ @ludovic111
/.github/ @horizon-rust/council
/tools/ci/ @horizon-rust/council
/tools/release_train.py @horizon-rust/council
/crates/horizon-release-sign/ @horizon-rust/council
/crates/horizon-server/src/update.rs @horizon-rust/council
/crates/horizon-server/Cargo.toml @horizon-rust/council
/launcher/src/signed.rs @horizon-rust/council
/launcher/src/backend.rs @horizon-rust/council
/launcher/links.json @horizon-rust/council
/launcher/Cargo.toml @horizon-rust/council
/packaging/ @horizon-rust/council
-7
View File
@@ -32,13 +32,6 @@
<!-- The vanilla classes and methods this follows (tools/rea_vanilla.py), and the harness that checks it.
"Horizon-only" for a feature vanilla doesn't have; "none" for tooling or docs. -->
## Other projects' code
<!-- "none", or for each project you read or copied from: its link at the commit, its license (only MIT,
BSD-2-Clause, BSD-3-Clause, Apache-2.0, ISC, zlib, 0BSD, MIT-0, Unlicense or CC0-1.0), and what you took.
CONTRIBUTING.md "Only original code". Shader packs: also the sections of docs/cleanroom/shader-packs-spec.md,
the documentation pages and the packs you used. -->
## Self-check
<!-- Paste the block `python3 tools/pr_ready.py` prints (`--base upstream/main` from a fork). -->
+2 -1
View File
@@ -36,8 +36,9 @@ jobs:
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable, 2026-11
with:
toolchain: stable
targets: ${{ matrix.target }}
- name: The aarch64 Linux linker
if: matrix.target == 'aarch64-unknown-linux-gnu'
+26 -372
View File
@@ -29,18 +29,20 @@ jobs:
# One job, not one per check: each job takes a runner from the organisation's shared pool, and with every pull request
# and every merge to main starting this workflow, three jobs a run left the pool's queue tens of minutes deep.
#
# Where (docs/ci-runners.md): our checks runners (label horizon-checks: the desktop's, an isolated user, so a fork's
# pull request runs there too) take the pull requests whose number ends in 0 or 1, about what their three runners
# can do; GitHub's runners take the others and main's pushes (which also save the Rust cache GitHub's pull request
# runs read), so both pools work at once. The repository variable SELF_HOSTED_CI moves them: `all` every job to
# ours, `light` (the desktop paused) or `off` every job to GitHub's. On ours the build folder persists
# Where (docs/ci-runners.md): our checks runners (label horizon-checks: the desktop's) take this repository's own
# pull requests whose number ends in 0 or 1, about what their three runners can do; GitHub's runners take the others,
# forks' pull requests and main's pushes (which also save the Rust cache GitHub's pull request runs read), so both
# pools work at once. The repository variable SELF_HOSTED_CI moves them: `all` every job to ours, `light` (the
# desktop paused) or `off` every job to GitHub's. A fork's pull request sees no repository variable, so no switch
# could move it off a machine that is off: it always runs on GitHub's. On ours the build folder persists
# (CARGO_TARGET_DIR, outside the checkout), so the apt packages, Python and the Rust cache steps are GitHub's only.
verify:
runs-on: >-
${{ vars.SELF_HOSTED_CI != 'off' && vars.SELF_HOSTED_CI != 'light' && (vars.SELF_HOSTED_CI == 'all' ||
github.event_name == 'pull_request' && (endsWith(format('{0}', github.event.pull_request.number), '0') ||
github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository &&
(endsWith(format('{0}', github.event.pull_request.number), '0') ||
endsWith(format('{0}', github.event.pull_request.number), '1'))) &&
fromJSON('["self-hosted", "linux", "horizon-checks"]') || 'ubuntu-latest' }}
fromJSON('["self-hosted", "linux", "horizon-checks"]') || 'ubuntu-24.04' }}
timeout-minutes: 45
steps:
- uses: actions/checkout@v7
@@ -110,7 +112,7 @@ jobs:
fi
rustup toolchain install stable --profile minimal --component rustfmt --component clippy
# Dependencies and the workspace's own build, saved from main's runs (pull requests read main's cache).
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
if: steps.rust.outputs.required != 'false' && runner.environment == 'github-hosted'
with:
shared-key: verify
@@ -119,31 +121,32 @@ jobs:
if: steps.rust.outputs.required != 'false'
run: |
cargo +stable fmt --all -- --check
cargo +stable clippy --workspace --all-targets -- -D warnings
cargo +stable clippy --workspace --all-targets --locked -- -D warnings
# The server build (released as horizon-engine): the game without sound or controllers.
cargo +stable clippy -p horizon-client --all-targets --no-default-features -- -D warnings
cargo +stable clippy -p horizon-client --all-targets --locked --no-default-features -- -D warnings
# Libraries, binaries and integration tests, then doc tests: not the ~275 examples, which clippy above already
# type-checks and the parity job builds and runs (they were most of this step's build and link time).
# cargo test, not nextest: nextest's process per test reloads each binary's shared tables for every test, and on
# cargo test --locked, not nextest: nextest's process per test reloads each binary's shared tables for every test, and on
# these 4-core runners it was no faster (#4978).
- name: Test Rust core and voxel kernel correctness
if: steps.rust.outputs.required != 'false'
run: |
cargo +stable test --workspace --lib --bins --tests
cargo +stable test --locked --workspace --lib --bins --tests
# The full content-root tests (#[ignore]d: minutes each on a laptop). The same build as above; they need the
# prepared vanilla data, so without it they pass at once. Keep the target selection identical to avoid
# recompiling the workspace with a different dependency graph.
cargo +stable test --workspace --lib --bins --tests mod_content::tests:: -- --ignored
cargo +stable test --workspace --doc
cargo +stable test --locked --workspace --lib --bins --tests mod_content::tests:: -- --ignored
cargo +stable test --locked --workspace --doc
# The launcher is its own workspace (launcher/Cargo.toml), so verify's cargo runs never build it. A pull request that
# changes it or a crate it builds is checked here, and so is every push to main: two pull requests can each pass
# alone and break it together (#2059 and the registry ratings did, 2026-10-26).
launcher:
runs-on: >-
${{ vars.SELF_HOSTED_CI != 'off' && vars.SELF_HOSTED_CI != 'light' && (vars.SELF_HOSTED_CI == 'all' ||
github.event_name == 'pull_request' && (endsWith(format('{0}', github.event.pull_request.number), '0') ||
github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository &&
(endsWith(format('{0}', github.event.pull_request.number), '0') ||
endsWith(format('{0}', github.event.pull_request.number), '1'))) &&
fromJSON('["self-hosted", "linux", "horizon-checks"]') || 'ubuntu-latest' }}
fromJSON('["self-hosted", "linux", "horizon-checks"]') || 'ubuntu-24.04' }}
timeout-minutes: 45
steps:
- uses: actions/checkout@v7
@@ -166,7 +169,7 @@ jobs:
libx11-xcb-dev libfontconfig-dev libvulkan-dev libasound2-dev libudev-dev
fi
rustup toolchain install stable --profile minimal --component rustfmt --component clippy
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
if: steps.launcher.outputs.required != 'false' && runner.environment == 'github-hosted'
with:
workspaces: launcher
@@ -199,13 +202,14 @@ jobs:
run: |
$cores = (Get-CimInstance Win32_Processor | Measure-Object -Property NumberOfLogicalProcessors -Sum).Sum
"CARGO_BUILD_JOBS=$cores" >> $env:GITHUB_ENV
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable, 2026-11
with:
toolchain: stable
targets: aarch64-pc-windows-msvc
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
shared-key: windows-arm64-game
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
workspaces: launcher
shared-key: windows-arm64-launcher
@@ -313,358 +317,8 @@ jobs:
head: ${{ github.event.pull_request.head.sha }}
full: ${{ contains(github.event.pull_request.labels.*.name, 'full-parity') }}
frame_identity: ${{ contains(github.event.pull_request.labels.*.name, 'frame-identity') }}
# Horizon never downloads the game. These jobs need a self-hosted Linux runner
# (label horizon-game) holding the maintainer's own copy: the official launcher's
# game directory in HORIZON_MINECRAFT_DIR and the pinned server jar in
# HORIZON_SERVER_JAR. Start them by hand: gh workflow run checks --ref <branch>.
vanilla-parity:
if: github.event_name == 'workflow_dispatch'
runs-on: [self-hosted, linux, horizon-game]
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: '3.12'
- name: Install Rust toolchain
run: rustup toolchain install stable --profile minimal
- uses: actions/setup-java@v6
with:
distribution: temurin
java-version: '25'
- name: Compare Rust foundations with pinned vanilla client and server bytecode
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_rust_parity.py --prepare
- name: Check Rust world-data interoperability with pinned vanilla client and server
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_world_parity.py
- name: Compare scheduled ticks and saved tick data with pinned vanilla client and server
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_tick_parity.py
- name: Compare simulation geometry and cached block collision shapes
env:
RUSTUP_TOOLCHAIN: stable
run: |
python tools/test_geometry_parity.py
python tools/test_collision_parity.py
- name: Compare noise samplers and density kernels with pinned vanilla client and server
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_noise_parity.py
- name: Compare compiled density samplers with pinned vanilla client and server
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_density_parity.py
- name: Compare world-generation JSON codecs with pinned vanilla client and server
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_worldgen_parity.py
- name: Compare heightmap predicates and chunk mutations with pinned vanilla client and server
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_heightmap_parity.py
- name: Compare pre-light chunk status transitions and block writes
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_chunk_stage_parity.py
- name: Compare feature placement into resident neighboring chunks
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_feature_region_parity.py
- name: Compare nested feature selectors and sequences
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_feature_graph_parity.py
- name: Compare named feature holders and placed-feature tags
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_feature_catalog_parity.py
- name: Compare configured fill-layer block placement
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_fill_layer_parity.py
- name: Compare replacement-rule codecs and evaluation
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_rule_test_parity.py
- name: Compare simple-block placement and feature composition
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_simple_block_parity.py
- name: Compare block-column sampling and obstruction
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_block_column_parity.py
- name: Compare vertical pillars and cap feature placement
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_pillar_parity.py
- name: Compare exactly-one-neighbor feature spreading
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_neighbor_spread_parity.py
- name: Compare block-pile support and placement
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_block_pile_parity.py
- name: Compare disk replacement and postprocessing
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_disk_parity.py
- name: Compare block-blob support search and geometry
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_block_blob_parity.py
- name: Compare spring placement and fluid tick scheduling
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_spring_parity.py
- name: Compare vine attachment priority and placement
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_vines_parity.py
- name: Compare underwater magma floor scans and occlusion
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_underwater_magma_parity.py
- name: Compare blue-ice seeding and growth attempts
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_blue_ice_parity.py
- name: Compare spike geometry and discontinuous roots
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_spike_parity.py
- name: Compare End podium construction and destruction requests
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_end_podium_parity.py
- name: Rust End gateway parity
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_end_gateway_parity.py
- name: Compare bonus chest placement and conditional loot seeding
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_bonus_chest_parity.py
- name: Compare recursive chorus generation and connection refreshes
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_chorus_plant_parity.py
- name: Compare small speleothem patches and waterlogged points
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_speleothem_parity.py
- name: Compare replacement blob search and Manhattan shells
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_replace_blobs_parity.py
- name: Compare bamboo stalks, podzol patches and leafy caps
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_bamboo_parity.py
- name: Compare Nether delta rims and shifted contents
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_delta_parity.py
- name: Compare biome freezing and snow eligibility
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_biome_weather_parity.py
- name: Compare frozen top layers and snowy ground updates
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_snow_freeze_parity.py
- name: Compare generation-stage tick storage and routing
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_generation_tick_parity.py
- name: Compare below-zero upgrade metadata codecs
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_retrogen_data_parity.py
- name: Compare below-zero upgrade lifecycle and block transformations
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_retrogen_chunk_parity.py
- name: Compare chunk-status metadata, ordering and codecs
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_chunk_status_parity.py
- name: Compare proto-chunk block-entity storage and coordinates
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_block_entity_storage_parity.py
- name: Compare ordered single-block replacement features
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_replace_block_parity.py
- name: Compare scattered ore placement and air-exposure checks
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_scattered_ore_parity.py
- name: Compare resident bulk-section access and raw writes
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_bulk_section_parity.py
- name: Compare biome feature ordering and cycle detection
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_feature_sort_parity.py
- name: Compare world-generation random wrappers and seeding
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_worldgen_random_parity.py
- name: Compare biome filtering and top-level feature context
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_biome_placement_parity.py
- name: Compare biome decoration feature scheduling and seeding
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_decoration_parity.py
- name: Compare decoration against resident biome and terrain sections
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_decoration_region_parity.py
- name: Compare block-state provider choices and rotation
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_state_provider_parity.py
- name: Compare contained fluids and height queries with pinned vanilla client and server
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_fluid_parity.py
- name: Compare entity obstruction with pinned vanilla client and server
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_entity_obstruction_parity.py
- name: Compare block solidity and replaceability with pinned vanilla client and server
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_block_properties_parity.py
- name: Compare face slicing and block support with pinned vanilla client and server
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_face_support_parity.py
- name: Compare cached block support with pinned vanilla client and server
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_block_support_parity.py
- name: Compare dynamic block support with pinned vanilla client and server
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_dynamic_support_parity.py
- name: Compare moving-piston collision and interpolation with pinned vanilla
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_piston_shape_parity.py
- name: Compare supported block survival rules with pinned vanilla
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_block_survival_parity.py
- name: Compare noise-stage chunk filling with pinned vanilla client and server
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_terrain_parity.py
- name: Compare structure terrain-adaptation density with pinned vanilla client and server
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_structure_density_parity.py
- name: Compare seeded material bands and surface queries with pinned vanilla client and server
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_material_surface_parity.py
- name: Compare material condition codecs and rule evaluation with pinned vanilla client and server
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_material_condition_parity.py
- name: Compare biome temperatures and cache behavior with pinned vanilla client and server
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_biome_temperature_parity.py
- name: Compare badlands and frozen-ocean extensions with pinned vanilla
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_material_extension_parity.py
- name: Compare bundled material-rule libraries with pinned vanilla client and server
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_material_rule_library_parity.py
- name: Compare fixed, checkerboard and End biome sources with pinned vanilla
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_biome_source_parity.py
- name: Compare seeded cave paths and failure state with pinned vanilla
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_cave_carver_parity.py
- name: Compare seeded canyon paths and width factors with pinned vanilla
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_canyon_carver_parity.py
- name: Compare initial block-writing feature algorithms with pinned vanilla
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_basic_feature_parity.py
- name: Compare feature placement chains with pinned vanilla
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_feature_placement_parity.py
- name: Compare feature placement primitives with pinned vanilla
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_placement_parity.py
- name: Compare anchored height providers with pinned vanilla
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_height_provider_parity.py
- name: Compare scalar value providers with pinned vanilla
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_value_provider_parity.py
- name: Compare cave and canyon geometric primitives with pinned vanilla
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_carving_geometry_parity.py
- name: Compare carving-mask traversal and callback mutations with pinned vanilla
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_carving_mask_parity.py
- name: Compare registry-bound biome-source codecs with pinned vanilla
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_biome_source_codec_parity.py
- name: Compare all four biome-source kinds in chunk generation with pinned vanilla
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_biome_generation_parity.py
- name: Compare chunk biome filling and failure state with pinned vanilla
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_biome_chunk_parity.py
- name: Compare biome zoom and seed obfuscation with pinned vanilla
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_biome_zoom_parity.py
- name: Compare surface-stage chunks with pinned vanilla client and server
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_material_stage_parity.py
- name: Compare block-state JSON codecs with pinned vanilla client and server
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_block_state_codec_parity.py
- name: Compare vertical anchors with pinned vanilla client and server
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_anchor_parity.py
- name: Compare float logarithms at domain and rounding boundaries
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_float_log_parity.py
# JFR analysis uses JDK-generated fixture recordings on the self-hosted Linux runner.
# Start a manual checks dispatch with: gh workflow run checks --ref <branch>.
jfr-analysis:
if: github.event_name == 'workflow_dispatch'
runs-on: [self-hosted, linux, horizon-game]
+6 -3
View File
@@ -50,7 +50,9 @@ jobs:
ref: ${{ inputs.ref }}
persist-credentials: false
clean: true
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable, 2026-11
with:
toolchain: stable
- run: bash tools/ci/runner-env.sh
env:
HORIZON_NO_SCCACHE: ${{ inputs.trusted && '1' || '' }}
@@ -156,10 +158,11 @@ jobs:
# may end up in a release. target/ is rebuilt from scratch; .local/ is relinked by vanilla-data.sh, which
# keeps its preparations outside the checkout.
clean: true
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable, 2026-11
with:
toolchain: stable
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
if: runner.environment == 'github-hosted' && !matrix.musl
with:
# A release restores only what a release saved: an untrusted build has its own key and saves nothing.
+3 -2
View File
@@ -173,10 +173,11 @@ jobs:
# Always from a clean checkout, the Mac mini's runner too: its work folder is shared with pull requests'
# parity jobs, and nothing they leave may end up in a launcher release (target/ is rebuilt from scratch).
clean: true
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable, 2026-11
with:
toolchain: stable
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
if: runner.environment == 'github-hosted'
with:
workspaces: launcher
+7 -3
View File
@@ -20,8 +20,12 @@ concurrency:
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
check:
# Our checks runners (label horizon-checks, docs/ci-runners.md); SELF_HOSTED_CI=light or off: GitHub's.
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && vars.SELF_HOSTED_CI != 'light' && fromJSON('["self-hosted", "linux", "horizon-checks"]') || 'ubuntu-latest' }}
# Our checks runners (label horizon-checks, docs/ci-runners.md); SELF_HOSTED_CI=light or off: GitHub's. A fork's
# pull request sees no repository variable, so it always runs on GitHub's (it would wait for the desktop when off).
runs-on: >-
${{ vars.SELF_HOSTED_CI != 'off' && vars.SELF_HOSTED_CI != 'light' &&
(github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) &&
fromJSON('["self-hosted", "linux", "horizon-checks"]') || 'ubuntu-latest' }}
timeout-minutes: 20
defaults:
run:
@@ -30,7 +34,7 @@ jobs:
- uses: actions/checkout@v7
- run: rustup toolchain install stable --profile minimal --component rustfmt --component clippy
# Ours keep their build folder between jobs.
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
if: runner.environment == 'github-hosted'
with:
workspaces: crates/horizon-mod-registry
+3 -1
View File
@@ -55,8 +55,10 @@ jobs:
persist-credentials: false
# Keeps .local/ (parity state, probe builds, the previous audit) and target/.
clean: false
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable, 2026-11
if: steps.when.outputs.run == 'true'
with:
toolchain: stable
- name: Tools from the runner
if: steps.when.outputs.run == 'true'
run: bash tools/ci/runner-env.sh
+1 -1
View File
@@ -38,7 +38,7 @@ jobs:
sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
fi
rustup toolchain install stable --profile minimal
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
if: runner.environment == 'github-hosted'
with:
shared-key: pack-bench
+15 -10
View File
@@ -37,7 +37,9 @@ jobs:
persist-credentials: false
# Keeps .local/ (parity state, probe builds, oracle results) and target/ between runs.
clean: false
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable, 2026-11
with:
toolchain: stable
- name: Tools from the runner
run: bash tools/ci/runner-env.sh
- name: Vanilla data, and resources prepared by this preparer
@@ -58,6 +60,18 @@ jobs:
echo "Merges since ${BASE_SHA}:"
git log --first-parent --format=' %h %s' "$BASE_SHA..HEAD"
python3 tools/parity.py --changed "$BASE_SHA" --timeout 2400
- name: Preserve the initial failure logs
if: steps.harnesses.outcome == 'failure'
run: |
rm -rf .local/parity-main-logs && cp -R .local/parity-runner/logs .local/parity-main-logs 2>/dev/null || true
- name: Upload the initial failure logs
if: steps.harnesses.outcome == 'failure'
uses: actions/upload-artifact@v7
with:
name: parity-main-logs
path: .local/parity-main-logs/
if-no-files-found: warn
retention-days: 7
- name: A flake, or the merge that broke them
if: steps.harnesses.outcome == 'failure'
env:
@@ -65,13 +79,4 @@ jobs:
PARITY_HOSTS: ''
BASE_SHA: ${{ steps.base.outputs.sha }}
run: |
rm -rf .local/parity-main-logs && cp -R .local/parity-runner/logs .local/parity-main-logs 2>/dev/null || true
python3 tools/parity_main.py bisect "$BASE_SHA" --comment
- name: Logs of the failed harnesses
if: steps.harnesses.outcome == 'failure'
uses: actions/upload-artifact@v7
with:
name: parity-main-logs
path: .local/parity-main-logs/
if-no-files-found: ignore
retention-days: 7
+3 -1
View File
@@ -71,7 +71,9 @@ jobs:
echo "::warning::head ${HEAD_SHA:0:9} conflicts with main ${main:0:9}: tested alone, without main's fixes"
echo "Tested: head ${HEAD_SHA:0:9} alone (it conflicts with main ${main:0:9})" | tee -a "$GITHUB_STEP_SUMMARY"
fi
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable, 2026-11
with:
toolchain: stable
- name: Tools from the runner
run: bash tools/ci/runner-env.sh
- name: Vanilla data, and resources prepared by this preparer
+7 -3
View File
@@ -20,8 +20,12 @@ concurrency:
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
check:
# Our checks runners (label horizon-checks, docs/ci-runners.md); SELF_HOSTED_CI=light or off: GitHub's.
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && vars.SELF_HOSTED_CI != 'light' && fromJSON('["self-hosted", "linux", "horizon-checks"]') || 'ubuntu-latest' }}
# Our checks runners (label horizon-checks, docs/ci-runners.md); SELF_HOSTED_CI=light or off: GitHub's. A fork's
# pull request sees no repository variable, so it always runs on GitHub's (it would wait for the desktop when off).
runs-on: >-
${{ vars.SELF_HOSTED_CI != 'off' && vars.SELF_HOSTED_CI != 'light' &&
(github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) &&
fromJSON('["self-hosted", "linux", "horizon-checks"]') || 'ubuntu-latest' }}
timeout-minutes: 20
defaults:
run:
@@ -30,7 +34,7 @@ jobs:
- uses: actions/checkout@v7
- run: rustup toolchain install stable --profile minimal --component rustfmt --component clippy
# Ours keep their build folder between jobs.
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
if: runner.environment == 'github-hosted'
with:
workspaces: crates/horizon-telemetry-server
+35 -7
View File
@@ -1,7 +1,9 @@
# Horizon: a map for agents
Minecraft: Java Edition 26.3 rewritten in Rust (wgpu). Vanilla is the specification wherever it has the
feature. Read [CONTRIBUTING.md](CONTRIBUTING.md) once; this page tells you where things are.
feature; performance work may differ inside as long as it stays compatible with vanilla
([CONTRIBUTING.md](CONTRIBUTING.md#when-it-doesnt-have-to-be-11)). Read [CONTRIBUTING.md](CONTRIBUTING.md) once;
this page tells you where things are.
Feature → files → harness for the most-changed areas: [docs/architecture.md](docs/architecture.md).
## Rules that bite
@@ -35,7 +37,6 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
- Small PRs (< ~500 lines). Labels: `feature` (players get something new), `skip-release` (they see no
change), none for a fix. Agents never add `release-now`: the maintainer decides it. Merges ship in batches of 5, or after
6 hours ([docs/release-flow.md](docs/release-flow.md)).
- Contributors first: `gh pr list --state open --search <area>` before you start; never duplicate theirs.
- **No hardcoded user-facing strings.** Keep UI translation-ready from the start. In the launcher (`launcher/`), wrap visible text with `tr("...")` or `trf("... {arg} ...", &[("arg", &val)])` from `crate::i18n` instead of raw string literals or `format!`. In the game, use translation keys (`Text::tr`, `lang::get`), never hardcoded English strings.
- **Claim before you start**: comment `/claim` on the issue (no issue yet: open one, then claim it). An assigned
issue is taken, and so is one labelled `in progress` (claimed on Discord): pick another. `/unclaim` when you stop;
@@ -47,6 +48,29 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
- **Render passes keep HDR's light.** A pass that reads, filters or redraws the world's picture (upscaling,
anti-aliasing, sharpening, frame generation, post effects, captures) follows [docs/hdr-passes.md](docs/hdr-passes.md).
## Don't lose time
Where agent time went, measured on sessions and CI in November 2026: reading code, compiling `horizon-client`,
waiting in loops, and work someone else had already done. Tests were a small part.
- **Check nobody else is on it.** One in three pull requests closed unmerged was a duplicate. Before you write
code, look at the issue's linked pull requests and run
`gh pr list --state open --search "<issue number, function or file name>"`; then `/claim`.
- **Find code with the map, not by reading.** Start from the tables below and
`python3 tools/port_ledger.py where Class.method`. Read the function you change and its callers, not whole
files.
- **Compile cheaply while you work.** `cargo check -p <crate>` after an edit. Clippy, fmt and tests run once, at
the end, through `pr_ready.py`. `cargo clippy -p horizon-client` and `cargo build -p horizon-client` are the
slowest commands here: never after every edit, and build the game only when you need to run it.
- **Don't wait in loops.** Start a long command in the background and carry on, or wait for it once. No
`sleep` or `until grep` polling of builds, harnesses or CI.
- **One test per changed behaviour.** None for a refactor or a file move, none that reads source text, and no
harness where vanilla has nothing to compare (Horizon's own features, performance work).
- **A red check isn't always yours.** When `verify` fails in a test your change can't reach, say so in the pull
request and move on; don't hunt it.
- **Unsure about a rule? Finish the work.** Take the reading that lets you finish, and say in the pull request
which one you took. Don't stop, and don't change direction halfway.
## Crates
Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-server` are separate workspaces.
@@ -96,6 +120,7 @@ reads `README.md`, `launcher/links.json`, `docs/api/`, `roadmap/roadmap.json` an
- Use independent expected values or a vanilla oracle; do not derive the answer from the code under test.
- Test outcomes, not only calls to stubs. Use mocks to isolate behavior, not as the behavior itself.
- Skip tests of trivial constants, derives and getters, or behavior already covered by another test.
- One test per changed behaviour is enough. A refactor or a move that changes no behaviour gets no new test.
- Avoid pinning implementation details; a harmless refactor should not break a behavior test.
- Make slow tests faster without removing cases or weakening their coverage.
- Never delete parity harnesses or tests that pin vanilla behaviour: they are the spec.
@@ -103,8 +128,8 @@ reads `README.md`, `launcher/links.json`, `docs/api/`, `roadmap/roadmap.json` an
## Build and test fast
```sh
python3 tools/pr_ready.py # the self-check: fmt, clippy -D warnings, touched crates' tests, harnesses; paste its block in the PR
python3 tools/pr_ready.py --quick # same without harnesses
python3 tools/pr_ready.py # the self-check: fmt, clippy -D warnings, touched crates' tests; paste its block in the PR
python3 tools/pr_ready.py --harnesses # also the affected harnesses (by default only when you change a harness, oracle, probe or the runner, as in CI)
cargo test -p horizon-client -p horizon-client-lib --lib --bins --tests # skips the examples (pr_ready does this); `--doc` separately
cargo test -p horizon-client-lib --lib mod_content::tests:: -- --ignored # the slow content-root tests, #[ignore]d; pr_ready runs them when mod content, assets, blocks or items change
cargo build --profile fast -p horizon-client # optimized, quick to link: use it to play or run lan_e2e
@@ -113,7 +138,8 @@ python3 tools/parity.py --changed-files paths.txt --list # which harnesses cov
python3 tools/parity.py mob_ai sculk # named harnesses
python3 tools/lan_e2e.py --check chest # two real games over LAN (server-thread host)
python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
python3 tools/lang_coverage.py --summary # the keys each language still misses, and its English leftovers
python3 tools/lang_coverage.py --summary # one line per language: how many keys it carries and what it misses
python3 tools/lang_coverage.py --summary --show-keys # the same, then the keys behind the counts
```
- Harnesses and the game need the vanilla data in `.local/` (`minecraft/`, `horizon-client/26.3`, `datagen/`,
@@ -152,7 +178,8 @@ python3 tools/lang_coverage.py --summary # the keys each language still misses
- A harness runs the oracle against the real 26.3 jars and the Rust probe on the same inputs, then compares
their JSON. Oracle runs are cached by `tools/parity_shim.py`; `--fresh` reruns them.
- Mirror vanilla's `Math` calls with `horizon_core::jmath`, and vanilla's RNG with `horizon_core::random`:
random draws must happen in vanilla's order.
random draws must happen in vanilla's order. Performance work may break that order where no player can rely
on it; the pull request says so ([CONTRIBUTING.md](CONTRIBUTING.md#when-it-doesnt-have-to-be-11)).
## horizon-client: where things live
@@ -215,7 +242,8 @@ player does (#1514). A window's `Game` never ticks a world itself: the old in-fr
Open `[server-thread]` issues list the gaps left.
Single player, LAN guests and dedicated servers all run the server code, so a fix there serves all three.
Test the server thread when you touch player-facing gameplay:
When you change how the server and its clients talk (packets, tracking, guests, the server thread itself), check
it there. Other gameplay fixes are covered by their own harness and CI's parity job:
```sh
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T # server thread
+30 -37
View File
@@ -1,7 +1,8 @@
# Contributing to Horizon
Thanks for helping. Horizon is Minecraft: Java Edition 26.3 rewritten in Rust, and the one rule that shapes
everything is that **it plays exactly like vanilla**. This page is the short version of how we work; questions are
everything is that **it plays like vanilla**: first exactly like it, then better where players gain from it
([vanilla first, then better](#vanilla-first-then-better)). This page is the short version of how we work; questions are
welcome on [Discord](https://discord.gg/playhorizon).
This repository holds the game and the launcher, plus the tools, tests and docs that build, check and release them.
@@ -23,7 +24,7 @@ here.
## Build it
- **Prerequisites:** Rust (stable), Python 3.10+, JDK 25, and on Linux the ALSA and udev headers
- **Prerequisites:** Rust (stable), Python 3.11+, JDK 25, git 2.38 or newer, and on Linux the ALSA and udev headers
(`libasound2-dev libudev-dev`).
- **Resources:** Horizon never downloads the game; it prepares its resources from your own copy of Minecraft 26.3, as
described in [Build from source](README.md#build-from-source).
@@ -36,7 +37,10 @@ here.
- **Launcher:** its own Cargo workspace in `launcher/` (`cd launcher && cargo run`; on NixOS, `nix-shell` first).
## Vanilla first
## Vanilla first, then better
Horizon gets there in two stages. First it plays exactly like vanilla; that is what parity work is for. Then it
improves on vanilla where players gain from it, mostly speed: FPS, TPS, loading, memory.
Wherever vanilla has a feature, vanilla is the specification, down to the order of random calls. "Parity" here means
Horizon gives the same results as the pinned 26.3 client and server for the same inputs, checked by a **harness**: a
@@ -49,8 +53,10 @@ and compares their outputs.
- **One version: 26.3.** Parity work targets 26.3 until its parity is good, redstone first. Don't open pull requests
that move Horizon to a newer Minecraft version for now; to join servers on a newer version, put a Via proxy
(ViaVersion, ViaProxy) in front of them.
- **Gameplay changes come with a harness**, new or extended, so the next change can't silently break them.
[The parity workflow](docs/parity-workflow.md) shows how harnesses run; copy a nearby one.
- **Ports of vanilla behaviour come with a harness**, new or extended, so the next change can't silently break
them. [The parity workflow](docs/parity-workflow.md) shows how harnesses run; copy a nearby one. Where vanilla
has nothing to compare (Horizon's own features, performance work, refactors), no harness is needed: one Rust
test of the changed behaviour, or the numbers before and after.
- **Horizon's own features** (the launcher, the new menus) follow [the design system](docs/design-system.md) and are
off by default wherever they would change the vanilla look.
- **Three layers: engine, platform, Horizon.**
@@ -67,40 +73,26 @@ and compares their outputs.
The pre-push hook checks the full history reachable from the branches and tags being pushed. Run
`python3 tools/check_public_tree.py --history` to audit the history of all local refs.
## Only original code, or code whose license allows it
### When it doesn't have to be 1:1
Horizon is MIT. Its code is written from Minecraft 26.3 itself and from public documentation. Since 30 October 2026
another project's source may also be read and used, **only when that project's license allows it**:
Parity is about what players and the vanilla game see, not about copying vanilla's internals. Performance work (a
multithreaded server tick, rendering, chunk loading, memory) doesn't have to keep vanilla's internal order or its
exact random calls. It has to work together with normal Minecraft:
- **Allowed:** MIT, MIT-0, BSD-2-Clause, BSD-3-Clause, 0BSD, ISC, zlib, Apache-2.0, Unlicense, and the CC0-1.0
public-domain dedication. That code may be read, copied or adapted.
- **Off limits, even to read:** GPL, LGPL (Iris), AGPL, MPL, EPL, Polyform (Sodium), the Creative Commons licenses
(CC BY, CC BY-SA, CC BY-NC and the others; CC0 is a public-domain dedication, not one of them), custom or
"source available" licenses, all rights reserved (OptiFine), and any project with no license or an unclear one.
Don't read, copy, translate or paraphrase their source, and don't send code an AI tool wrote from it. Being able
to download a mod or a pack does not give you the right to read its files.
- **Java's own library is GPL too.** OpenJDK's source (`java.lang.Math`, `StrictMath`, `FdLibm`, `HashMap`, the
sorts, `Double.toString`, HotSpot's intrinsics...) is GPL-2.0, so it is off limits like the rest. Reproduce Java's
behaviour from its API documentation (the Javadoc) and from what a JVM returns, or from a permissive original such
as Sun's fdlibm C sources (see [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)); see #6741.
- **Check the license yourself.** Read the LICENSE file at the commit you use and the header of each file you take:
some repositories mix licenses, and a file under another license is off limits. A license that changed: the one at
your commit counts. In doubt, don't: ask in #contributors first.
- **Say it in the pull request:** the project, a link to the commit, its license, and what you read or copied. A
pull request that used another project's source without saying so is closed.
- **Copied or adapted code** keeps its copyright line and license notice at the top of the file, and gets an entry
in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). Apache-2.0 code also keeps its NOTICE text and says what
was changed.
User documentation and file formats are fine to read, and packs and mods are fine to run and observe, whatever their
license. Shader packs are a clean-room rewrite of the pack format, produced from a written description of it (see
#1561): the [specification and its provenance log](docs/cleanroom/README.md) are public. They carry no foreign
source, and the rules above still apply to anything you read while working on them. A shader-pack pull request
names its sources: the sections of that specification, the documentation pages and the packs it ran.
- **Vanilla clients and servers still work with it.** A vanilla 26.3 client plays on a Horizon server, Horizon joins
vanilla servers, and saves and worlds open in both, as before.
- **The game still plays the same.** Farms, redstone, mobs and drops work the way players know from vanilla. A
difference may only show where no player can rely on it, such as which of two mobs ticks first or exactly which
random number a mob gets.
- **Vanilla's slow paths aren't copied.** When vanilla spends a lot of FPS or TPS on something that can reach the
same result faster, Horizon does it faster. Copying a performance problem isn't parity.
- **Say what changes.** The pull request names the vanilla behaviour it no longer reproduces exactly, why players
won't notice it or why it's worth it, and how it was checked: numbers before and after, and the harnesses that
still pass. A change players could notice, such as a different tick order on a busy server, goes behind a setting.
## Vendor libraries and Java mods
- **Upscalers and latency tools.** AMD FSR (MIT) may go in the repository, like any code under an allowed license.
- **Upscalers and latency tools.** AMD FSR (MIT) may go in the repository.
NVIDIA's DLSS, Reflex and any other library under an NVIDIA license may not: they ship only as an optional native mod
that downloads NVIDIA's files itself, on the player's machine. No NVIDIA binaries, headers or SDK files in the
repository.
@@ -117,9 +109,10 @@ python3 tools/pr_ready.py --base upstream/main # or origin/main if you work on
```
It looks at what your branch changes and runs what matters: rustfmt, clippy with warnings denied, the tests of the
crates you touched (and the launcher's), generated files, `Cargo.lock`, the Python tests, and the vanilla harnesses
your change can affect. Without the vanilla data on your machine it lists those harnesses instead; CI runs them.
`--quick` skips the harnesses. It exits non-zero when something fails.
crates you touched (and the launcher's), generated files, `Cargo.lock` and the Python tests. Like CI, it runs the
vanilla harnesses only when your branch changes a harness, an oracle, a probe or the runner; otherwise it lists the
ones your change can affect, and main's parity run checks them after the merge. `--harnesses` runs them on your
machine anyway (it needs the vanilla data). It exits non-zero when something fails.
For the game workspace, Clippy checks all targets of the touched crates and their transitive workspace dependents,
including examples. The dependency scope includes optional, platform, build and development dependencies. Tests run
Generated
+47 -2
View File
@@ -706,6 +706,25 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-queue"
version = "0.3.14"
@@ -926,6 +945,12 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]]
name = "either"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
[[package]]
name = "embedded-io"
version = "0.4.0"
@@ -1523,7 +1548,6 @@ dependencies = [
"horizon-anticheat",
"horizon-assets",
"horizon-bench",
"horizon-chat-filter",
"horizon-client-core",
"horizon-client-input",
"horizon-client-render",
@@ -1554,6 +1578,7 @@ dependencies = [
"open",
"png",
"pollster",
"rayon",
"rustix 1.1.5",
"serde",
"serde_json",
@@ -1584,7 +1609,6 @@ dependencies = [
"horizon-client-input",
"horizon-command",
"horizon-core",
"horizon-cosmetics",
"horizon-hdr",
"horizon-hooks",
"horizon-items",
@@ -1947,6 +1971,7 @@ name = "horizon-server-files"
version = "0.1.0"
dependencies = [
"base64 0.22.1",
"getrandom 0.3.4",
"horizon-core",
"horizon-protocol",
"horizon-world",
@@ -3664,6 +3689,26 @@ dependencies = [
"objc2-quartz-core 0.3.2",
]
[[package]]
name = "rayon"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
]
[[package]]
name = "rc2"
version = "0.8.1"
+1 -1
View File
@@ -530,7 +530,7 @@ unable to act, and never puts an account that is tied to a person into anyone el
| Account | Today | Can it be shared? | Plan |
| --- | --- | --- | --- |
| **GitHub organisation** (horizon-rust) | One owner (the founder) | Yes: an organisation can have several owners | **Step 1:** a second owner from the council, chosen by a 2/3 poll in the council's first week, with two-factor authentication on. **Step 2:** a `council` team; CODEOWNERS reviews and the release tools' owner powers (`paused`, `/nightly remove`) move from the founder to that team |
| **GitHub organisation** (horizon-rust) | Two owners since 2026-11-11: the founder and rdbtCVS (P28); a `council` team holds CODEOWNERS (P35) and is the security managers (P36) | Yes: an organisation can have several owners | **Step 1:** a second owner from the council, chosen by a 2/3 poll in the council's first week, with two-factor authentication on. **Step 2:** a `council` team; CODEOWNERS reviews and the release tools' owner powers (`paused`, `/nightly remove`) move from the founder to that team |
| **Discord server** | Owned by the founder | Partly: a server has exactly one owner; powers can be given through roles | **Step 1:** the 🏛️ Council role gets moderation and ban permissions; Kira applies the outcomes of passed polls, so nobody needs Manage Roles; two-factor authentication becomes required for moderation. Ownership itself stays with the founder until a legal entity exists |
| **Kira** (bot application, Railway service) | The founder's Discord developer account and Railway | The bot: yes (a Discord developer team). Railway: adding members may need a paid plan | **Step 1:** a Discord developer team with two council members. Railway members once the council decides on money |
| **Official server** (hosting panel) | The founder's account | Yes: panel sub-users | **Step 1:** sub-users for the Server and SMP lead and for one council member |
+1 -1
View File
@@ -109,7 +109,7 @@ horizon-ctl break -1 65 -3 # mines it; replies with
## Build from source
Needs Rust 1.90+, Python 3.10+, JDK 25, and on Linux `libasound2-dev libudev-dev`. `horizon-prepare` builds the game's resources from **your own copy** of Java Edition 26.3 (checked against `research-lock.json`) and never downloads it:
Needs Rust 1.90+, Python 3.11+, JDK 25, and on Linux `libasound2-dev libudev-dev`. `horizon-prepare` builds the game's resources from **your own copy** of Java Edition 26.3 (checked against `research-lock.json`) and never downloads it:
1. **Client:** play 26.3 once in the official launcher. Horizon reads `~/.minecraft` (or `--minecraft-dir` / `HORIZON_MINECRAFT_DIR`).
2. **Server jar:** the 26.3 server jar (`downloads.server.url` in `versions/26.3/26.3.json`), passed once with `--server-jar` or `HORIZON_SERVER_JAR`.
+26
View File
@@ -358,3 +358,29 @@ state of Starling's `crates/horizon-client-render/src/touch.rs` at the same comm
`crates/horizon-client-render/src/android_runtime.rs` and `android_runtime/tests/` are adapted from the
input part of Starling's `crates/horizon-client-render/src/android_runtime.rs` at the same commit.
## PCG3D hash
`hash3` in `crates/horizon-client-render/src/hdr.wgsl`, the noise HDR10 output's dither is made from, is the
`pcg3d` hash of Mark Jarzynski and Marc Olano ("Hash Functions for GPU Rendering", Journal of Computer Graphics
Techniques 9(3), 2020), from <https://github.com/markjarzynski/PCG3D>. Copyright 2020 Mark Jarzynski & Marc Olano,
licensed under MIT:
```
Copyright 2020 Mark Jarzynski & Marc Olano
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.
```
+4
View File
@@ -23,6 +23,10 @@ pub const WITHDRAWN_PARTNER_SERVER: &str = "afri-network.co.za";
/// (the telemetry server's status checks).
pub const OFFICIAL_SERVER_STATUS: &str = "play.playhorizon.dev:24454";
/// Horizon's website, where the launcher is downloaded: the game started without its prepared
/// files sends the player there.
pub const WEBSITE: &str = "https://playhorizon.dev";
/// The cosmetics service: cosmetics, emotes, "Show me as a Horizon player"
/// (`HORIZON_COSMETICS_API` overrides it).
pub const COSMETICS_API: &str = "https://cosmetics.playhorizon.dev";
+2 -12
View File
@@ -21,7 +21,6 @@ use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
pub mod combat;
pub mod prediction;
pub mod timer;
/// What one check's violation level leads to.
@@ -162,8 +161,8 @@ checks! {
/// Prediction: each move played again from the client's own inputs with vanilla's movement code,
/// and compared (speed, step, fly, jesus...). Each player's client ticks are recorded
/// (`prediction::Record`), with a ping a tick to place what the server changes, and played again
/// (horizon-client's `lan::prediction`). A move that lands farther than `tolerance` from the
/// (horizon-client's `lan::prediction::record`), with a ping a tick to place what the server changes,
/// and played again (`lan::prediction`). A move that lands farther than `tolerance` from the
/// replay flags, by how far it lands (one `tolerance` off weighs 1, at most 5 a tick), and the
/// setback puts the player back where its moves last agreed. A tick near what the replay can't
/// play yet (another entity, a block change or abilities the client hadn't seen, a moving piston,
@@ -279,15 +278,6 @@ pub struct Player {
pub timer: timer::Balance,
/// The server tick and height where the ground last held the player.
pub ground: Option<(u64, f64)>,
/// The client ticks the prediction plays again (only while it runs).
pub record: Option<Box<prediction::Record>>,
/// Sprinting as the client's own commands left it (ServerboundPlayerCommandPacket's
/// START_SPRINTING and STOP_SPRINTING: LocalPlayer.wasSprinting), which the record's ticks take.
/// Not the server's player: a hit with extra knockback stops its sprint
/// (Player.causeExtraKnockback), the client's own `Player.attack` doesn't (a mob's `hurtClient`
/// is false), and once the server's flag reaches it LocalPlayer.aiStep starts the sprint again
/// while the sprint key is held, with nothing to say: wasSprinting never changed.
pub sprinting: bool,
/// The attacks of the client tick being heard.
pub attacks: combat::Attacks,
}
+1 -1
View File
@@ -7,7 +7,7 @@ rust-version.workspace = true
description = "The dedicated server's chat filter: its [chat_filter] settings, the slur list's matcher and the mutes, with no game state (docs/chat-filter.md)"
[dependencies]
# The moderation durations, shared with `/tempban`.
# The hook the hub asks (`chat_guard`), and the moderation durations.
horizon-server-files = { path = "../horizon-server-files" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+136 -36
View File
@@ -1,8 +1,8 @@
//! The dedicated server's chat filter (docs/chat-filter.md), without the game: `server.toml`'s
//! `[chat_filter]`, the matcher for the slur list (`data/slurs.txt`, or the owner's `words_file`), and
//! the escalation (a refused message, then a mute, then a temporary ban), with the mutes kept in a file
//! so that they outlive a restart. The server's hub (`horizon_client::lan`) asks it before it relays a
//! player's text and carries out what it decides.
//! so that they outlive a restart. The server's hub (`horizon_client::lan`) asks it, through the engine's
//! `horizon_server_files::chat_guard` hook, before it relays a player's text, and carries out what it decides.
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
@@ -10,6 +10,8 @@ use std::path::{Path, PathBuf};
mod normalise;
use horizon_server_files::chat_guard::{self, ChatGuard, Loaded, Place};
pub use horizon_server_files::chat_guard::{Action, Mute};
pub use horizon_server_files::duration::{
DurationUnit, duration_parts, duration_text, now_millis, parse_duration,
};
@@ -221,49 +223,59 @@ fn fits(entry: &[Run], runs: &[Run], whole: bool) -> bool {
.all(|(e, r)| e.letter == r.letter && r.count >= e.count)
}
/// What a hit leads to.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Action {
/// The text is refused, nothing more.
Refused,
/// Refused, and the player can't chat until then (epoch milliseconds).
Muted { until: i64 },
/// Refused, and the player is banned until then.
Banned { until: i64 },
/// A mute as the mutes file keeps it.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
struct SavedMute {
uuid: String,
name: String,
until: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
by: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
reason: Option<String>,
}
impl Action {
pub fn name(self) -> &'static str {
match self {
Action::Refused => "refused",
Action::Muted { .. } => "muted",
Action::Banned { .. } => "banned",
impl From<SavedMute> for Mute {
fn from(m: SavedMute) -> Self {
let SavedMute {
uuid,
name,
until,
by,
reason,
} = m;
Mute {
uuid,
name,
until,
by,
reason,
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Mute {
pub uuid: String,
pub name: String,
/// Epoch milliseconds; `None`: until `/unmute`.
pub until: Option<i64>,
/// The operator who muted them (`/mute`, `/tempmute`); `None`: the filter.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub by: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
impl Mute {
pub fn active(&self, now: i64) -> bool {
self.until.is_none_or(|until| until > now)
impl From<Mute> for SavedMute {
fn from(m: Mute) -> Self {
let Mute {
uuid,
name,
until,
by,
reason,
} = m;
SavedMute {
uuid,
name,
until,
by,
reason,
}
}
}
#[derive(Default, Serialize, Deserialize)]
struct MutesFile {
mutes: Vec<Mute>,
mutes: Vec<SavedMute>,
}
/// One player's hits: how many, and when the last one stops counting.
@@ -293,7 +305,7 @@ impl Guard {
{
for m in saved.mutes {
if let Some(uuid) = parse_uuid(&m.uuid) {
mutes.insert(uuid, m);
mutes.insert(uuid, m.into());
}
}
}
@@ -431,7 +443,7 @@ impl Guard {
};
let now = now_millis();
self.mutes.retain(|_, m| m.active(now));
let mut mutes: Vec<Mute> = self.mutes.values().cloned().collect();
let mut mutes: Vec<SavedMute> = self.mutes.values().cloned().map(Into::into).collect();
mutes.sort_by(|a, b| a.name.cmp(&b.name));
if let Ok(text) = serde_json::to_string_pretty(&MutesFile { mutes }) {
let tmp = file.with_extension("json.tmp");
@@ -442,6 +454,94 @@ impl Guard {
}
}
impl ChatGuard for Guard {
fn matches(&self, text: &str) -> bool {
self.filter.matches(text)
}
fn muted(&self, uuid: u128, now: i64) -> Option<&Mute> {
Guard::muted(self, uuid, now)
}
fn mutes(&self, now: i64) -> Vec<&Mute> {
Guard::mutes(self, now)
}
fn mute(
&mut self,
uuid: u128,
name: &str,
until: Option<i64>,
by: &str,
reason: Option<String>,
) {
Guard::mute(self, uuid, name, until, by, reason);
}
fn strike(&mut self, uuid: u128, name: &str, now: i64) -> (u32, Action) {
Guard::strike(self, uuid, name, now)
}
fn unmute(&mut self, name: &str) -> Option<String> {
Guard::unmute(self, name)
}
fn log(
&mut self,
now: i64,
name: &str,
uuid: u128,
place: Place,
count: u32,
action: Action,
text: &str,
) -> Result<(), String> {
let path = &self.config.log;
if path.is_empty() {
return Ok(());
}
let line = log_line(now, name, uuid, place.name(), count, action, text);
append_log(Path::new(path), &line).map_err(|e| format!("{path}: {e}"))
}
}
/// `server.toml`'s `[chat_filter]` for the dedicated server ([`chat_guard::install`] it at startup): on
/// by default, off (with an error logged) when the section is wrong. The mutes are kept in `dir`.
pub fn load(server_toml: &str, dir: &Path) -> Loaded {
let mut loaded = Loaded::default();
let config = match Config::from_server_toml(server_toml) {
Ok(config) => config,
Err(e) => {
loaded.log.push((
"ERROR",
format!("server.toml: {e}; the chat filter stays off"),
));
return loaded;
}
};
if !config.enabled {
return loaded;
}
let (guard, problem) = Guard::load(config, dir);
if let Some(problem) = problem {
loaded.log.push(("WARN", problem));
}
loaded.log.push((
"INFO",
format!(
"Chat filter on: {} words (server.toml [chat_filter]; enabled = false turns it off)",
guard.filter.len()
),
));
loaded.guard = Some(Box::new(guard));
loaded
}
/// Gives the dedicated server this chat filter.
pub fn install() {
chat_guard::install(load);
}
/// One line of `logs/chat-filter.log`, without its newline.
pub fn log_line(
now: i64,
@@ -51,8 +51,14 @@ fn float(tag: Option<&Tag>) -> Option<f32> {
other => int(Some(other))? as f32,
})
}
/// `TagValueInput.getBooleanOr`: a number is true when its byte value is not 0.
fn flag(tag: Option<&Tag>) -> Option<bool> {
Some(int(tag)? != 0)
Some(int(tag)? as i8 != 0)
}
/// `Codec.BOOL` through `NbtOps.getBooleanValue`: a number is true when it
/// is not 0 as a whole (`doubleValue() != 0`), unlike `getBooleanOr`.
fn codec_flag(tag: Option<&Tag>) -> Option<bool> {
Some(float(tag)? != 0.0)
}
fn boolean(v: bool) -> Tag {
Tag::Byte(i8::from(v))
@@ -271,15 +277,31 @@ fn int_array(tag: Option<&Tag>) -> Option<&Vec<i32>> {
fn is_pos(tag: &Tag) -> bool {
matches!(tag, Tag::IntArray(v) if v.len() == 3)
}
/// The default `SignText`: four empty lines, black, not glowing.
fn empty_sign_side() -> Tag {
Tag::compound([
("messages", Tag::List(vec![string(""); 4])),
("color", string("black")),
("has_glowing_text", boolean(false)),
])
}
/// The loaded `SignText` of one side, or the empty text.
pub fn sign_side(tag: Option<&Tag>) -> Tag {
let empty = || string("");
let mut messages: Vec<Tag> = match tag.and_then(|t| t.get("messages")) {
Some(Tag::List(list)) => list.iter().filter_map(component).collect(),
_ => Vec::new(),
// `SignText.DIRECT_CODEC`: `messages` is a component list (an entry that
// fails is dropped, `ListCodec`'s partial result) cut to 4 lines by
// `Util.fixedSize`. Fewer than 4 valid lines fail the side, and
// `SignBlockEntity.loadAdditional` then loads an empty one; more keep the
// first 4 with the side's colour and glow.
let lines: Option<Vec<Tag>> = match tag.and_then(|t| t.get("messages")) {
Some(Tag::List(list)) => {
let lines: Vec<Tag> = list.iter().filter_map(component).take(4).collect();
(lines.len() == 4).then_some(lines)
}
_ => None,
};
let Some(messages) = lines else {
return empty_sign_side();
};
messages.resize_with(4, empty);
messages.truncate(4);
let mut out = vec![
("messages", Tag::List(messages)),
(
@@ -292,7 +314,7 @@ pub fn sign_side(tag: Option<&Tag>) -> Tag {
(
"has_glowing_text",
boolean(
tag.and_then(|t| flag(t.get("has_glowing_text")))
tag.and_then(|t| codec_flag(t.get("has_glowing_text")))
.unwrap_or(false),
),
),
@@ -852,6 +874,32 @@ mod tests {
);
}
/// `getBooleanOr` tests a number's byte value: an int 256 is false and
/// 257 true, for a sign's wax and a piston's flags alike.
#[test]
fn booleans_are_read_as_bytes() {
let sign = |waxed: &str| {
canon(&format!(
"{{id:\"minecraft:sign\",x:1,y:2,z:3,is_waxed:{waxed}}}"
))
.get("is_waxed")
.cloned()
};
assert_eq!(sign("1b"), Some(Tag::Byte(1)));
assert_eq!(sign("256"), Some(Tag::Byte(0)));
assert_eq!(sign("257"), Some(Tag::Byte(1)));
// `SignText` reads `has_glowing_text` through `Codec.BOOL`, which tests
// the whole number: 256 is true there.
let glowing = canon(
r#"{id:"minecraft:sign",x:1,y:2,z:3,front_text:{messages:["","","",""],has_glowing_text:256}}"#,
);
let front = glowing.get("front_text").unwrap();
assert_eq!(front.get("has_glowing_text"), Some(&Tag::Byte(1)));
let piston = canon(r#"{id:"minecraft:piston",x:1,y:2,z:3,extending:256,source:513}"#);
assert_eq!(piston.get("extending"), Some(&Tag::Byte(0)));
assert_eq!(piston.get("source"), Some(&Tag::Byte(1)));
}
#[test]
fn every_type_has_defaults() {
for id in TYPES.iter().chain(NEWEST.iter()) {
@@ -957,6 +1005,32 @@ mod tests {
assert_eq!(structure.get("showboundingbox"), Some(&Tag::Byte(1)));
}
#[test]
fn sign_side_needs_exactly_four_messages() {
let ok = snbt("{messages:[\"a\",\"\",\"\",\"\"],color:\"red\",has_glowing_text:1b}");
let side = sign_side(Some(&ok));
assert_eq!(side.get("color"), Some(&string("red")));
// A fifth line, or a bad entry among five, leaves the first 4 valid
// lines with the colour (checked against the 26.3 codec).
for kept in [
"{messages:[\"a\",\"\",\"\",\"\",\"e\"],color:\"red\",has_glowing_text:1b}",
"{messages:[\"a\",1,\"\",\"\",\"\"],color:\"red\",has_glowing_text:1b}",
] {
let side = sign_side(Some(&snbt(kept)));
assert_eq!(side.get("messages"), ok.get("messages"), "{kept}");
assert_eq!(side.get("color"), Some(&string("red")), "{kept}");
}
for bad in [
"{color:\"red\",has_glowing_text:1b}",
"{messages:[\"a\",\"b\"],color:\"red\",has_glowing_text:1b}",
"{messages:[\"a\",\"\",\"\",1],color:\"red\",has_glowing_text:1b}",
] {
let side = sign_side(Some(&snbt(bad)));
assert_eq!(side, sign_side(None), "{bad}");
assert_eq!(side.get("color"), Some(&string("black")));
}
}
#[test]
fn canonical_is_idempotent() {
for id in TYPES.iter().chain(NEWEST.iter()) {
+172 -37
View File
@@ -74,11 +74,70 @@ pub trait ResourceSource {
vec!["minecraft".into()]
}
}
/// The bundled file a language reads, and whether it drops `ß`: a regional variant reads its
/// language's file (Swiss German writes "ss" for "ß", as vanilla's de_ch does).
fn bundled_code(code: &str) -> (&str, bool) {
match code {
"ko" => ("ko_kr", false),
"hr" => ("hr_hr", false),
"de_at" => ("de_de", false),
"de_ch" => ("de_de", true),
"fr_ca" | "fr_ch" => ("fr_fr", false),
"es_ar" | "es_cl" | "es_ec" | "es_mx" | "es_uy" | "es_ve" => ("es_es", false),
code => (code, false),
}
}
/// Lang files crates above the engine ship (`horizon_hooks::lang`), each a list of
/// (code, JSON) pairs. The game's entry installs them once, before any language loads.
static CRATE_FILES: std::sync::OnceLock<&'static [&'static [(&'static str, &'static str)]]> =
std::sync::OnceLock::new();
/// Installs the crates' lang files (`horizon_hooks::lang::engine::all()`). Only the first call
/// counts.
pub fn install_crate_files(files: &'static [&'static [(&'static str, &'static str)]]) {
let _ = CRATE_FILES.set(files);
}
/// The crates' entries for language `code` (through [`bundled_code`]'s variants) that the
/// engine's own `horizon` file for it doesn't have: on a key both have, the engine wins.
fn read_crate_files(files: &[&[(&str, &str)]], code: &str, map: &mut HashMap<String, String>) {
if files.is_empty() {
return;
}
let (file, swiss) = bundled_code(code);
let mut own = HashMap::new();
if let Some(bytes) = Bundled.read(&format!("assets/horizon/lang/{code}.json")) {
read_lang_file(&bytes, &mut own);
}
for (c, json) in files.iter().flat_map(|f| f.iter()) {
if *c != file {
continue;
}
let mut theirs = HashMap::new();
if swiss {
read_lang_file(json.replace('ß', "ss").as_bytes(), &mut theirs);
} else {
read_lang_file(json.as_bytes(), &mut theirs);
}
for (key, value) in theirs {
if !own.contains_key(&key) {
map.insert(key, value);
}
}
}
}
/// Horizon's own strings, compiled in so they need no prepared resources.
struct Bundled;
impl ResourceSource for Bundled {
fn read(&self, path: &str) -> Option<Vec<u8>> {
let body = match path {
let code = path
.strip_prefix("assets/horizon/lang/")?
.strip_suffix(".json")?;
let (file, swiss) = bundled_code(code);
let path = format!("assets/horizon/lang/{file}.json");
let body = match path.as_str() {
"assets/horizon/lang/en_us.json" => {
include_str!("../../horizon-client/assets/horizon/lang/en_us.json")
}
@@ -103,10 +162,10 @@ impl ResourceSource for Bundled {
"assets/horizon/lang/tr_tr.json" => {
include_str!("../../horizon-client/assets/horizon/lang/tr_tr.json")
}
"assets/horizon/lang/ko_kr.json" | "assets/horizon/lang/ko.json" => {
"assets/horizon/lang/ko_kr.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ko_kr.json")
}
"assets/horizon/lang/hr_hr.json" | "assets/horizon/lang/hr.json" => {
"assets/horizon/lang/hr_hr.json" => {
include_str!("../../horizon-client/assets/horizon/lang/hr_hr.json")
}
"assets/horizon/lang/pt_pt.json" => {
@@ -331,30 +390,20 @@ impl ResourceSource for Bundled {
"assets/horizon/lang/vec_it.json" => {
include_str!("../../horizon-client/assets/horizon/lang/vec_it.json")
}
"assets/horizon/lang/de_de.json" | "assets/horizon/lang/de_at.json" => {
"assets/horizon/lang/de_de.json" => {
include_str!("../../horizon-client/assets/horizon/lang/de_de.json")
}
// Swiss German writes "ss" for "ß", as vanilla's de_ch does.
"assets/horizon/lang/de_ch.json" => {
let de = include_str!("../../horizon-client/assets/horizon/lang/de_de.json");
return Some(de.replace('ß', "ss").into_bytes());
}
"assets/horizon/lang/fr_fr.json"
| "assets/horizon/lang/fr_ca.json"
| "assets/horizon/lang/fr_ch.json" => {
"assets/horizon/lang/fr_fr.json" => {
include_str!("../../horizon-client/assets/horizon/lang/fr_fr.json")
}
"assets/horizon/lang/es_es.json"
| "assets/horizon/lang/es_ar.json"
| "assets/horizon/lang/es_cl.json"
| "assets/horizon/lang/es_ec.json"
| "assets/horizon/lang/es_mx.json"
| "assets/horizon/lang/es_uy.json"
| "assets/horizon/lang/es_ve.json" => {
"assets/horizon/lang/es_es.json" => {
include_str!("../../horizon-client/assets/horizon/lang/es_es.json")
}
_ => return None,
};
if swiss {
return Some(body.replace('ß', "ss").into_bytes());
}
Some(body.as_bytes().to_vec())
}
fn namespaces(&self) -> Vec<String> {
@@ -438,6 +487,16 @@ impl Language {
/// from every namespace and every source in priority order (later sources
/// override).
pub fn load(sources: &[&dyn ResourceSource], code: &str) -> Self {
let crate_files = CRATE_FILES.get().copied().unwrap_or_default();
Self::load_with(sources, code, crate_files)
}
/// [`Language::load`] with these crates' lang files.
fn load_with(
sources: &[&dyn ResourceSource],
code: &str,
crate_files: &[&[(&str, &str)]],
) -> Self {
let mut map = HashMap::new();
let mut codes = vec![DEFAULT];
if code != DEFAULT {
@@ -453,12 +512,14 @@ impl Language {
sources.iter().flat_map(|s| s.namespaces()).collect();
for c in codes {
for namespace in &namespaces {
for source in sources {
let Some(bytes) = source.read(&format!("assets/{namespace}/lang/{c}.json"))
else {
continue;
};
read_lang_file(&bytes, &mut map);
for (i, source) in sources.iter().enumerate() {
if let Some(bytes) = source.read(&format!("assets/{namespace}/lang/{c}.json")) {
read_lang_file(&bytes, &mut map);
}
// The crates' text right after the engine's own (`Bundled`, the first source).
if i == 0 && namespace == "horizon" {
read_crate_files(crate_files, c, &mut map);
}
}
}
}
@@ -688,6 +749,43 @@ mod tests {
);
}
#[test]
fn a_crates_text_loads_after_the_engines_and_before_packs() {
static CRATE: &[(&str, &str)] = &[
(
"en_us",
r#"{"test.crate.hello": "Hello", "horizon.osk.close": "Shut", "test.crate.packed": "Crate"}"#,
),
(
"de_de",
r#"{"test.crate.hello": "Grüß dich", "horizon.osk.close": "Zu"}"#,
),
("fr_fr", r#"{"test.crate.hello": "Bonjour"}"#),
];
let pack = Mem(HashMap::from([(
"assets/horizon/lang/en_us.json".to_owned(),
br#"{"test.crate.packed": "Pack"}"#.to_vec(),
)]));
let load = |code| Language::load_with(&[&pack], code, &[CRATE]);
let en = load("en_us");
assert_eq!(en.get("test.crate.hello"), Some("Hello"));
// The engine's own file wins on a key both have; a pack wins over both.
assert_eq!(en.get("horizon.osk.close"), Some("Close"));
assert_eq!(en.get("test.crate.packed"), Some("Pack"));
let de = load("de_de");
assert_eq!(de.get("test.crate.hello"), Some("Grüß dich"));
assert_eq!(de.get("horizon.osk.close"), Some("Schließen"));
// Regional variants read their language's file, as the engine's do.
assert_eq!(load("de_ch").get("test.crate.hello"), Some("Grüss dich"));
assert_eq!(load("fr_ca").get("test.crate.hello"), Some("Bonjour"));
// A language the crate lacks falls back to its English.
assert_eq!(load("pl_pl").get("test.crate.hello"), Some("Hello"));
// Without crate files, nothing changes.
let plain = Language::load_with(&[&pack], "de_de", &[]);
assert_eq!(plain.get("test.crate.hello"), None);
assert_eq!(plain.len() + 1, de.len());
}
#[test]
fn a_scoped_language_is_the_calling_threads_and_reports_what_it_lacks() {
let empty = Mem(HashMap::new());
@@ -739,6 +837,20 @@ mod tests {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../horizon-client/assets/horizon/lang")
}
/// The crates whose own lang files the game loads (`crates/<name>/assets/lang`,
/// registered through `horizon_hooks::lang`), with that folder.
fn crate_lang_dirs() -> Vec<(String, std::path::PathBuf)> {
let crates = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
let mut dirs: Vec<_> = std::fs::read_dir(&crates)
.unwrap()
.flatten()
.map(|e| (e.file_name().into_string().unwrap(), e.path()))
.map(|(name, path)| (name, path.join("assets/lang")))
.filter(|(_, dir)| dir.join("en_us.json").is_file())
.collect();
dirs.sort();
dirs
}
/// The keys of a flat bundled file in file order, duplicates included (a map
/// would keep only the last of two equal keys). One `"key": "value"` per line.
fn file_keys(body: &str) -> Vec<String> {
@@ -776,11 +888,33 @@ mod tests {
#[test]
fn bundled_files_are_served_and_have_no_stale_or_repeated_keys() {
let dir = bundled_dir();
let engine = check_lang_dir(&bundled_dir(), true);
assert!(
engine.1 >= 7,
"found {} bundled lang files in {}",
engine.1,
bundled_dir().display()
);
// A crate's files pass the same checks. Its keys are its own: on a key the engine's
// files also have, the engine's text would win and hide the crate's.
for (name, dir) in crate_lang_dirs() {
let (en, _) = check_lang_dir(&dir, false);
for key in en.keys() {
assert!(
!engine.0.contains_key(key),
"{name}: {key} is also the engine's"
);
}
}
}
/// The checks every lang folder passes: flat maps, no repeated key, no key en_us lacks, the
/// same placeholders as English, no empty text. Its en_us and how many files it has.
fn check_lang_dir(dir: &std::path::Path, bundled: bool) -> (HashMap<String, String>, usize) {
let en_body = std::fs::read_to_string(dir.join("en_us.json")).unwrap();
let en: HashMap<String, String> = serde_json::from_str(&en_body).unwrap();
let mut seen = 0;
for entry in std::fs::read_dir(&dir).unwrap().flatten() {
for entry in std::fs::read_dir(dir).unwrap().flatten() {
let name = entry.file_name().into_string().unwrap();
let Some(code) = name.strip_suffix(".json") else {
continue;
@@ -789,7 +923,7 @@ mod tests {
// A file that Bundled::read does not name is never loaded.
let path = format!("assets/horizon/lang/{name}");
assert!(
Bundled.read(&path).is_some(),
!bundled || Bundled.read(&path).is_some(),
"{name} is not served by Bundled"
);
let body = std::fs::read_to_string(entry.path()).unwrap();
@@ -815,11 +949,7 @@ mod tests {
assert!(!value.trim().is_empty(), "{code}: {key} is empty");
}
}
assert!(
seen >= 7,
"found {seen} bundled lang files in {}",
dir.display()
);
(en, seen)
}
#[test]
@@ -834,10 +964,10 @@ mod tests {
}
}
}
let en: HashMap<String, String> = serde_json::from_str(
&std::fs::read_to_string(bundled_dir().join("en_us.json")).unwrap(),
)
.unwrap();
let english = |dir: &std::path::Path| -> HashMap<String, String> {
serde_json::from_str(&std::fs::read_to_string(dir.join("en_us.json")).unwrap()).unwrap()
};
let mut en = english(&bundled_dir());
let crates = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
let mut files = Vec::new();
for krate in [
@@ -847,6 +977,11 @@ mod tests {
] {
rust_files(&crates.join(krate).join("src"), &mut files);
}
// A crate that ships lang files names its keys in its own sources.
for (name, dir) in crate_lang_dirs() {
en.extend(english(&dir));
rust_files(&crates.join(name).join("src"), &mut files);
}
assert!(files.len() > 100, "found only {} sources", files.len());
let mut found = 0;
for file in files {
+25
View File
@@ -320,7 +320,12 @@ pub fn java_hash_order(
"FallFlying",
"sleeping_pos",
"Brain",
"last_hurt_by_player",
"last_hurt_by_player_memory_time",
"last_hurt_by_mob",
"ticks_since_last_hurt_by_mob",
"equipment",
"locator_bar_icon",
// ArmorStand.
"Invisible",
"Small",
@@ -337,6 +342,12 @@ pub fn java_hash_order(
"immovable",
"description",
"hide_description",
// Interaction.addAdditionalSaveData.
"width",
"height",
"attack",
"interaction",
"response",
];
// BlockEntity.saveMetadata writes the position after the entity's data.
const AFTER: &[&str] = &["x", "y", "z"];
@@ -436,6 +447,20 @@ mod tests {
);
}
#[test]
fn interaction_compound_orders_width_before_interaction_in_shared_bucket() {
// `width` and `interaction` share a bucket at every table size;
// `Interaction.addAdditionalSaveData` writes `width` first.
let tag = parse_snbt(
r#"{Air:300s,Fire:0s,Invulnerable:0b,Motion:[0.0d,0.0d,0.0d],OnGround:0b,PortalCooldown:0,Pos:[4.5d,100.0d,4.5d],Rotation:[0.0f,0.0f],UUID:[I;66,0,0,1],fall_distance:0.0d,height:1.25f,interaction:{player:[I;1,2,3,4],timestamp:9L},response:0b,width:1.0f}"#,
)
.unwrap()
.0;
assert_eq!(
pretty(&tag).plain(),
"{Motion: [0.0d, 0.0d, 0.0d], Invulnerable: 0b, fall_distance: 0.0d, Air: 300s, OnGround: 0b, PortalCooldown: 0, Rotation: [0.0f, 0.0f], Pos: [4.5d, 100.0d, 4.5d], response: 0b, Fire: 0s, width: 1.0f, interaction: {player: [I; 1, 2, 3, 4], timestamp: 9L}, UUID: [I; 66, 0, 0, 1], height: 1.25f}",
);
}
#[test]
fn compact_form_matches_vanilla() {
assert_eq!(
rt("{b:1b,a:\"x\",c:[1,2],d:1.5f,e:2d,f:3L,g:4s}"),
@@ -356,6 +356,12 @@ impl KeyBinds {
pub fn is_default(&self, name: &str) -> bool {
self.get(name) == Self::default_of(name)
}
/// `KeyBindsScreen.extractRenderState`: Reset All is active only while a
/// mapping differs from its default (`KeyMapping.isDefault`, the bound key
/// against `defaultKey`).
pub fn changed_from_defaults(&self) -> bool {
self.keys.keys().any(|name| !self.is_default(name))
}
pub fn set(&mut self, name: &str, key: &str) {
if let Some((n, _, _)) = all_mappings().find(|m| m.0 == name) {
self.keys.insert(n, key.to_owned());
@@ -645,6 +651,22 @@ mod tests {
assert_eq!(k.translate_all(KeyCode::Digit9), [Ok(KeyCode::Digit9)]);
assert_eq!(k.translate_all(KeyCode::F3), [Ok(KeyCode::F3)]);
}
/// `KeyBindsScreen.extractRenderState`: Reset All is active only while a
/// binding differs from its default.
#[test]
fn reset_all_is_active_only_after_a_binding_moves() {
let mut keys = KeyBinds::default();
assert!(!keys.changed_from_defaults());
keys.set("key.attack", "key.mouse.right");
assert!(keys.changed_from_defaults());
// Setting a mapping back to its own default leaves nothing to reset.
keys.set("key.attack", KeyBinds::default_of("key.attack"));
assert!(!keys.changed_from_defaults());
keys.set("key.jump", "key.keyboard.z");
keys.reset_all();
assert!(!keys.changed_from_defaults());
}
#[test]
fn display_names() {
assert_eq!(display_name("key.keyboard.w").plain(), "W");
+3 -2
View File
@@ -27,6 +27,9 @@ horizon-mcp = { path = "../horizon-mcp" }
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
horizon-optifine = { path = "../horizon-optifine" }
horizon-threads = { path = "../horizon-threads" }
# One pool of helper threads for the server tick's parallel phases (`parallel.rs`): threads made
# once, not for every phase of every tick.
rayon = "1.10"
horizon-hdr = { path = "../horizon-hdr" }
horizon-world = { path = "../horizon-world" }
horizon-protocol = { path = "../horizon-protocol" }
@@ -36,8 +39,6 @@ horizon-server-files = { path = "../horizon-server-files" }
getrandom = "0.3"
# The dedicated server's anticheat, off unless server.toml's [anticheat] turns it on (docs/anticheat.md).
horizon-anticheat = { path = "../horizon-anticheat" }
# The dedicated server's chat filter: [chat_filter], the slur list, mutes (docs/chat-filter.md).
horizon-chat-filter = { path = "../horizon-chat-filter" }
# Shader packs: reading them and turning their GLSL into naga modules.
horizon-shaders = { path = "../horizon-shaders" }
# Horizon's own shader packs: the format, the stages and the check (docs/shaders.md).
-1
View File
@@ -9,7 +9,6 @@ description = "The client's world data, rendering and screens: chunks, meshing,
[dependencies]
# The settings stores of the crates above the engine (docs/layers.md, "Platform registries").
horizon-hooks = { path = "../horizon-hooks" }
horizon-cosmetics = { path = "../horizon-cosmetics" }
# The GUI atlas's pixels, kept compressed once uploaded (hud.rs `pack_pixels`).
lz4_flex = { version = "0.14", default-features = false, features = ["std", "safe-encode", "safe-decode", "checked-decode"] }
horizon-assets = { path = "../horizon-assets" }
+61 -1
View File
@@ -7,7 +7,7 @@ use serde_json::Value;
use std::{
collections::{BTreeMap, HashMap},
path::Path,
sync::Arc,
sync::{Arc, OnceLock},
};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -398,7 +398,37 @@ impl Biomes {
pub fn contains(&self, name: &str) -> bool {
self.index.contains_key(name)
}
/// The biome at `id`, clamped to the last entry when `id` is past the
/// table (a stale column from an older registry keeps its id).
///
/// When the table is empty (a resource pack with no biome files), returns
/// a shared default plains biome instead of panicking on `list.len() - 1`.
pub fn get(&self, id: u16) -> &BiomeInfo {
if self.list.is_empty() {
static DEFAULT: OnceLock<BiomeInfo> = OnceLock::new();
return DEFAULT.get_or_init(|| BiomeInfo {
spawns: HashMap::new(),
spawn_costs: HashMap::new(),
creature_probability: 0.0,
increased_fire_burnout: false,
name: "minecraft:plains".to_owned(),
temperature: 0.8,
frozen: false,
downfall: 0.4,
precipitation: false,
grass: 0x7f_c0_55,
modifier: GrassModifier::None,
foliage: 0x6a_ab_37,
dry_foliage: 0x79_75_54,
water: 0x3f_76_e4,
sky: None,
fog: None,
water_fog: 0x3f_76_e4,
ambient_particles: Vec::new(),
features: Vec::new(),
attributes: Default::default(),
});
}
&self.list[usize::from(id).min(self.list.len() - 1)]
}
/// The name of biome `id`, plains (as for an unknown name) when the id is
@@ -795,4 +825,34 @@ mod blend_tests {
}
assert!(at(-2.0) > 0.0 && at(-2.0) < 0.5);
}
/// An empty biome table (a resource pack with no biome files) must not
/// panic on `get`: before the fix, `list.len() - 1` underflowed.
#[test]
fn get_on_empty_biome_table_returns_default_instead_of_panicking() {
use std::sync::Arc;
let empty = Biomes {
builtin: Arc::new(Vec::new()),
index: HashMap::new(),
list: Vec::new(),
info_noise: SimplexNoise::with_skipped_offsets(&mut LegacyRandom::new(2345), true),
colormaps: Arc::new(Colormaps {
grass: Vec::new(),
foliage: Vec::new(),
dry: Vec::new(),
}),
packs: BTreeMap::new(),
};
// Any id returns the shared default plains biome, no panic.
let b = empty.get(0);
assert_eq!(b.name, "minecraft:plains");
let b = empty.get(65535);
assert_eq!(b.name, "minecraft:plains");
// Colours and temperature come from the default.
assert_eq!(b.temperature, 0.8);
assert_eq!(b.grass, 0x7f_c0_55);
assert_eq!(b.foliage, 0x6a_ab_37);
assert_eq!(b.water, 0x3f_76_e4);
}
}
@@ -92,6 +92,9 @@ pub struct BlockEntities {
/// 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>,
/// The signs a player's editor is on (`SignBlockEntity.playerWhoMayEdit`
/// set), so the level's tick checks those alone instead of every sign.
sign_editors: FxHashSet<Pos>,
asleep: FxHashSet<Pos>,
}
@@ -312,6 +315,44 @@ impl BlockEntities {
.filter(move |((cx, cz), _)| !far(*cx, x) && !far(*cz, z))
.flat_map(|(_, set)| set)
}
/// `SignBlockEntity.playerWhoMayEdit` of the sign at `pos`.
pub fn sign_editor(&self, pos: &Pos) -> Option<u128> {
match self.map.get(pos) {
Some(BlockEntity::Sign(sign)) => sign.allowed_editor,
_ => None,
}
}
/// `SignBlockEntity.setAllowedPlayerEditor`: the one player whose editor
/// is on the sign at `pos`, or `None` for nobody's.
pub fn set_sign_editor(&mut self, pos: Pos, who: Option<u128>) {
let Some(BlockEntity::Sign(sign)) = self.map.get_mut(&pos) else {
return;
};
sign.allowed_editor = who;
if who.is_some() {
self.sign_editors.insert(pos);
} else {
self.sign_editors.remove(&pos);
}
}
/// The positions of the signs a player's editor is on, in no order.
pub fn sign_editors(&self) -> impl Iterator<Item = &Pos> {
self.sign_editors.iter()
}
/// Drops `pos` from the signs an editor is on, for a sign that is gone or
/// no longer holds one. `set_sign_editor` keeps the list itself.
pub fn forget_sign_editor(&mut self, pos: Pos) {
self.sign_editors.remove(&pos);
}
/// Keeps `pos` listed in `sign_editors` exactly while `entity` holds an
/// editor, as `note_box` does for the boxes.
fn note_sign_editor(&mut self, pos: Pos, entity: &BlockEntity) {
if matches!(entity, BlockEntity::Sign(sign) if sign.allowed_editor.is_some()) {
self.sign_editors.insert(pos);
} else {
self.sign_editors.remove(&pos);
}
}
/// `LevelChunk.getBlockEntity`: the block entity at `pos`, if any.
pub fn get(&self, pos: &Pos) -> Option<&BlockEntity> {
self.map.get(pos)
@@ -351,6 +392,7 @@ impl BlockEntities {
self.attachments.remove(&pos);
}
let new_box = shows_box(&entity);
self.note_sign_editor(pos, &entity);
self.asleep.remove(&pos);
let old = self.map.insert(pos, entity);
if old.is_none() {
@@ -398,6 +440,7 @@ impl BlockEntities {
forget_mod(&mut self.mods, *pos);
}
self.boxes.remove(pos);
self.sign_editors.remove(pos);
removed
}
/// The block entity at `pos`, out of the map for a while: its slot stays
@@ -418,6 +461,7 @@ impl BlockEntities {
self.mods.entry(chunk_of(pos)).or_default().insert(pos);
}
self.note_box(pos, Some(&entity));
self.note_sign_editor(pos, &entity);
if self.map.insert(pos, entity).is_none()
&& !self.live.contains_key(&pos)
&& !self.pending.contains_key(&pos)
@@ -445,6 +489,7 @@ impl BlockEntities {
&mut self.attachments,
);
let (mods, boxes) = (&mut self.mods, &mut self.boxes);
let sign_editors = &mut self.sign_editors;
self.asleep.clear();
self.map.retain(|p, e| {
let kept = keep(p, e);
@@ -454,6 +499,7 @@ impl BlockEntities {
pending.remove(p);
forget_mod(mods, *p);
boxes.remove(p);
sign_editors.remove(p);
}
kept
});
@@ -463,6 +509,7 @@ impl BlockEntities {
self.map.clear();
self.mods.clear();
self.boxes.clear();
self.sign_editors.clear();
self.asleep.clear();
self.tickers = Arc::default();
self.live.clear();
@@ -484,6 +531,7 @@ impl BlockEntities {
self.asleep.remove(&p);
let new_is_mod = matches!(e, BlockEntity::Mod(_));
self.note_box(p, Some(&e));
self.note_sign_editor(p, &e);
let old = self.map.insert(p, e);
if old.is_some() {
self.replaced.insert(p);
@@ -15,7 +15,7 @@ use crate::{
font::{Font, Glyph},
gfx::mark_oit,
hud::Rect,
mesher::{CULL_BACK, Vertex},
mesher::{CULL_BACK, Vertex, cull_back},
models::load_visibility,
signs::{self, SignData, SignText},
text::{Style, Text},
@@ -3093,9 +3093,9 @@ impl BlockEntityRenderer {
}
}
// `DecoratedPotRenderer` submits every part with `entitySolid`, which culls
// back faces: the flat `top` and `bottom` planes show one side each.
// back faces: the flat `top` and `bottom` planes show one side each (#15322).
for vertex in &mut out[first..] {
vertex.normal = CULL_BACK;
vertex.normal = cull_back(vertex.normal);
}
}
Kind::EndPortal => {
@@ -4760,9 +4760,12 @@ mod tests {
28,
"the pot submits all of vanilla's quads"
);
// A part the shader shades keeps its normal (#15322): `w` alone says cull.
assert!(
verts.iter().all(|v| v.normal == CULL_BACK),
"entitySolid culls the pot's back faces"
verts
.iter()
.all(|v| v.normal[3] == crate::mesher::SHADED_CULL_W && v.normal[..3] != [0; 3]),
"entitySolid culls the pot's back faces and shades it by its normals"
);
}
+1 -1
View File
@@ -24,7 +24,7 @@ struct Out {
return out;
}
@fragment fn fs_border(in: Out) -> @location(0) vec4<f32> {
return border_color(in);
return frame_stored(border_color(in));
}
fn border_color(in: Out) -> vec4<f32> {
let color = textureSample(forcefield, forcefield_sampler, in.uv);
+100
View File
@@ -84,6 +84,71 @@ pub fn contents_effects(stack: &ItemStack) -> Vec<(&'static str, i32, i32)> {
)
.collect()
}
/// `PotionContents.getAllEffects`, retaining each custom instance's flags.
pub fn contents_instances(stack: &ItemStack) -> Vec<crate::effects::EffectInstance> {
use horizon_client_core::nbt_value::{number, text};
use horizon_world::nbt::Tag;
let mut effects: Vec<_> = stack
.potion()
.and_then(potion)
.map_or(&[][..], |p| p.effects)
.iter()
.map(|(id, duration, amp)| crate::effects::EffectInstance::new(id, *duration, *amp))
.collect();
if let Some((_, Tag::List(custom))) = stack
.data()
.and_then(|d| d.potion_extra.iter().find(|(k, _)| k == "custom_effects"))
{
for e in custom {
let Some(def) = text(e.get("id")).and_then(|id| effect(&id)) else {
continue;
};
let mut instance = crate::effects::EffectInstance::new(
def.id,
number(e.get("duration")).unwrap_or(0) as i32,
number(e.get("amplifier")).unwrap_or(0).clamp(0, 255) as i32,
);
instance.ambient = number(e.get("ambient")).unwrap_or(0) != 0;
instance.visible = number(e.get("show_particles")) != Some(0);
effects.push(instance);
}
}
effects
}
/// `PotionContents.getColor`: an explicit colour wins; otherwise mix only
/// visible base and custom effects, with their amplifier weights.
pub fn contents_color(stack: &ItemStack) -> u32 {
use horizon_client_core::nbt_value::number;
if let Some(color) = stack
.data()
.and_then(|d| d.potion_extra.iter().find(|(k, _)| k == "custom_color"))
.and_then(|(_, v)| number(Some(v)))
{
return color as u32;
}
0xFF00_0000
| horizon_client_core::potions::effects_color(
contents_instances(stack)
.iter()
.filter(|e| e.visible)
.map(|e| (e.id, e.amplifier)),
)
}
/// `PotionContents.equals(EMPTY)`: colour and custom name also make contents
/// nonempty, even when there is no registered potion or custom effect.
pub fn contents_empty(stack: &ItemStack) -> bool {
use horizon_world::nbt::Tag;
stack.potion().is_none()
&& stack.data().is_none_or(|d| {
d.potion_extra
.iter()
.all(|(key, value)| match key.as_str() {
"custom_effects" => matches!(value, Tag::List(list) if list.is_empty()),
"custom_color" | "custom_name" => false,
_ => true,
})
})
}
/// Roman amplifier suffix (`potion.potency.N`) and duration lines of a potion.
pub fn tooltip(
potion: Option<&str>,
@@ -706,4 +771,39 @@ mod tests {
let lines = effect_lines([("minecraft:resistance", 15, 0)], 1.0, &translate);
assert_eq!(lines[0].0, "resistance");
}
#[test]
fn custom_contents_color_and_empty_semantics() {
use horizon_world::nbt::Tag;
let mut stack = ItemStack::new("minecraft:tipped_arrow", 1);
assert!(contents_empty(&stack));
stack
.data_mut()
.potion_extra
.push(("custom_effects".into(), Tag::List(Vec::new())));
assert!(contents_empty(&stack));
stack.data_mut().potion_extra = vec![(
"custom_effects".into(),
Tag::List(vec![
Tag::compound([
("id", Tag::String("minecraft:glowing".into())),
("amplifier", Tag::Int(1)),
]),
Tag::compound([
("id", Tag::String("minecraft:poison".into())),
("show_particles", Tag::Byte(0)),
]),
]),
)];
assert!(!contents_empty(&stack));
// Glowing's colour; hidden poison contributes no weight.
assert_eq!(contents_color(&stack), 0xFF94_A061);
stack
.data_mut()
.potion_extra
.push(("custom_color".into(), Tag::Int(123456)));
assert_eq!(contents_color(&stack), 123456);
stack.data_mut().potion_extra = vec![("custom_name".into(), Tag::String("test".into()))];
assert!(!contents_empty(&stack));
assert_eq!(contents_color(&stack), 0xFF38_5DC6);
}
}
+12 -5
View File
@@ -568,6 +568,8 @@ impl Chat {
pub fn set_qol(&mut self, atlas: &HudAtlas, qol: ChatQol) {
if self.qol != qol {
self.qol = qol;
self.scroll = 0;
self.new_since_scroll = false;
self.rewrap(&atlas.font);
}
}
@@ -593,20 +595,25 @@ impl Chat {
.filter(|q| !q.trim().is_empty());
if self.search != query {
self.search = query;
self.scroll = 0;
self.new_since_scroll = false;
self.rewrap(font);
}
}
pub fn searching(&self) -> bool {
self.search.is_some()
}
/// Re-wrap after a width or scale change (ChatComponent.rescaleChat),
/// keeping only the messages the Chat option shows.
/// Re-wrap after a width or scale change (`ChatComponent.rescaleChat`):
/// `resetChatScroll`, then the messages the Chat option shows.
pub fn rescale(&mut self, atlas: &HudAtlas) {
self.rewrap(&atlas.font);
}
fn rewrap(&mut self, font: &crate::font::Font) {
self.scroll = 0;
self.new_since_scroll = false;
self.rewrap(&atlas.font);
}
/// `ChatComponent.refreshTrimmedMessages`: the lines are laid out again
/// where the reader was (`chatScrollbarPos` survives a filter change);
/// `rescale` resets the scroll itself, as `rescaleChat` does.
fn rewrap(&mut self, font: &crate::font::Font) {
self.lines.clear();
let mut messages = std::mem::take(&mut self.messages);
for m in messages.iter_mut().rev() {
@@ -1067,6 +1067,34 @@ fn a_server_filter_mask_hides_or_masks_the_message() {
);
}
/// `ChatComponent.refreshTrimmedMessages`: a filter change lays the lines out
/// again but keeps `chatScrollbarPos` and `newMessageSinceScroll`; only
/// `rescaleChat` resets them, after `resetChatScroll`.
#[test]
fn a_chat_option_change_keeps_the_readers_place_but_a_rescale_does_not() {
let font = font();
let mut chat = Chat::default();
for i in 0..60 {
chat.add_message(
&font,
Text::literal(format!("line {i}")),
None,
ChatSource::Server,
0,
false,
);
}
chat.scroll = 3;
chat.new_since_scroll = true;
chat.set_abilities(&font, ChatAbilities::new(ChatVisibility::System));
assert_eq!(chat.scroll, 3);
assert!(chat.new_since_scroll);
let atlas = crate::hud::HudAtlas::build(Path::new("/nonexistent/horizon-test-assets")).unwrap();
chat.rescale(&atlas);
assert_eq!(chat.scroll, 0);
assert!(!chat.new_since_scroll);
}
/// `SuggestionsList.extractRenderState`: the list's rectangle asks for the
/// pointing hand; outside it the cursor stays the arrow.
#[test]
+184 -21
View File
@@ -4,6 +4,7 @@
use horizon_core::paletted::{PalettedContainer, Strategy};
use horizon_world::nbt::{Compound, Tag};
use horizon_world::registry::BlockStateId;
use std::sync::atomic::{AtomicI16, AtomicU64, Ordering};
use std::sync::{Arc, OnceLock};
pub mod block_entity;
@@ -195,7 +196,7 @@ impl ColumnLayout {
}
/// The index of the section holding block `y`, if the column holds it.
pub fn section_index(self, y: i32) -> Option<usize> {
usize::try_from((y - self.min_y) >> 4)
usize::try_from((i64::from(y) - i64::from(self.min_y)) >> 4)
.ok()
.filter(|&s| s < self.sections)
}
@@ -457,6 +458,44 @@ impl Light {
}
}
}
/// One of a chunk's heightmaps (`Chunk::surface`): the heights, built whole on the first read,
/// and the columns a write may have moved since, each found again on its next read. Reads share
/// the chunk (helper threads read the world): a stale column is found the same by any of them.
#[derive(Default)]
struct Heights {
heights: OnceLock<Box<[AtomicI16; 256]>>,
stale: [AtomicU64; 4],
}
impl Heights {
/// `Heightmap.update`: a write two or more below column `i`'s height can't move it; any
/// other marks the column.
fn write(&mut self, i: usize, y: i32) {
let Some(heights) = self.heights.get_mut() else {
return;
};
let stale = self.stale[i >> 6].get_mut();
if *stale & 1 << (i & 63) == 0 && y >= i32::from(*heights[i].get_mut()) - 1 {
*stale |= 1 << (i & 63);
}
}
}
impl Clone for Heights {
fn clone(&self) -> Self {
let copy = Self::default();
// A reader can refresh a height and then clear its stale bit while we clone. Copy the
// bits first: a stale snapshot will rescan; a clean one acquires the refreshed height.
// Copying heights first could keep an old height with a newly cleared bit.
for (to, from) in copy.stale.iter().zip(&self.stale) {
to.store(from.load(Ordering::Acquire), Ordering::Relaxed);
}
if let Some(heights) = self.heights.get() {
let _ = copy.heights.set(Box::new(std::array::from_fn(|i| {
AtomicI16::new(heights[i].load(Ordering::Relaxed))
})));
}
copy
}
}
#[inline]
fn column(x: i32, z: i32) -> usize {
((z & 15) << 4 | (x & 15)) as usize
@@ -574,10 +613,10 @@ pub struct Chunk {
pub sections: Vec<Arc<Section>>,
/// `Heightmap.Types.WORLD_SURFACE` per column (`z << 4 | x`): one above
/// the highest non-air block, or the column's minimum Y. Built on the first read; a
/// `set` that can move a column's surface drops it.
surface: OnceLock<Box<[i16; 256]>>,
/// `set` that can move a column's surface marks that column, found again on its next read.
surface: Heights,
/// `Heightmap.Types.MOTION_BLOCKING` per column, kept like `surface`.
motion_blocking: OnceLock<Box<[i16; 256]>>,
motion_blocking: Heights,
pub lit: bool,
/// Light read from a save with `isLightOn`: no light job is needed.
pub light_loaded: bool,
@@ -618,8 +657,8 @@ impl Chunk {
z,
min_y: layout.min_y(),
sections: (0..layout.sections()).map(|_| empty.clone()).collect(),
surface: OnceLock::new(),
motion_blocking: OnceLock::new(),
surface: Heights::default(),
motion_blocking: Heights::default(),
lit: false,
light_loaded: false,
fresh: false,
@@ -661,7 +700,7 @@ impl Chunk {
/// The index of the section holding block `y`, if the column holds it.
#[inline]
pub fn section_index(&self, y: i32) -> Option<usize> {
usize::try_from((y - self.min_y) >> 4)
usize::try_from((i64::from(y) - i64::from(self.min_y)) >> 4)
.ok()
.filter(|&s| s < self.sections.len())
}
@@ -698,12 +737,7 @@ impl Chunk {
// move it. Any other change rebuilds that map on the next read.
if previous != v {
for map in [&mut self.surface, &mut self.motion_blocking] {
if map
.get()
.is_some_and(|heights| y >= i32::from(heights[column(x, z)]) - 1)
{
*map = OnceLock::new();
}
map.write(column(x, z), y);
}
}
previous
@@ -714,24 +748,55 @@ impl Chunk {
let section = Arc::make_mut(&mut self.sections[s]);
section.blocks = Section::from_blocks(values);
section.compact();
self.surface = OnceLock::new();
self.motion_blocking = OnceLock::new();
self.surface = Heights::default();
self.motion_blocking = Heights::default();
}
/// `getHeight(Heightmap.Types.WORLD_SURFACE, x, z)` (`x`, `z` within the
/// chunk): one above the highest block that isn't `air`, or the column's
/// minimum Y.
/// Sections must change only through `set` once this has been read.
pub fn world_surface(&self, x: i32, z: i32, air: impl Fn(u32) -> bool) -> i32 {
let surface = self.surface.get_or_init(|| self.prime(|v| !air(v)));
i32::from(surface[column(x, z)])
self.height(&self.surface, x, z, |v| !air(v))
}
/// `getHeight(Heightmap.Types.MOTION_BLOCKING, x, z)` (`x`, `z` within
/// the chunk): one above the highest state where `blocks` (a motion
/// blocking block or a fluid), or the column's minimum Y. Kept as
/// `world_surface` is.
pub fn motion_blocking(&self, x: i32, z: i32, blocks: impl Fn(u32) -> bool) -> i32 {
let heights = self.motion_blocking.get_or_init(|| self.prime(blocks));
i32::from(heights[column(x, z)])
self.height(&self.motion_blocking, x, z, blocks)
}
/// One column's height in `map`: the whole map built on its first read, a column a write
/// marked found again down that column alone.
fn height(&self, map: &Heights, x: i32, z: i32, counts: impl Fn(u32) -> bool) -> i32 {
let heights = map.heights.get_or_init(|| {
let built = self.prime(&counts);
Box::new(std::array::from_fn(|i| AtomicI16::new(built[i])))
});
let i = column(x, z);
let (word, bit) = (i >> 6, 1u64 << (i & 63));
if map.stale[word].load(Ordering::Acquire) & bit != 0 {
let height = self.column_height(i, &counts);
heights[i].store(height, Ordering::Relaxed);
map.stale[word].fetch_and(!bit, Ordering::Release);
return i32::from(height);
}
i32::from(heights[i].load(Ordering::Relaxed))
}
/// [`Self::prime`] for column `i` alone.
fn column_height(&self, i: usize, counts: impl Fn(u32) -> bool) -> i16 {
for (s, section) in self.sections.iter().enumerate().rev() {
if section.blocks.uniform().is_some_and(|v| !counts(v)) {
continue;
}
let base = self.min_y + s as i32 * 16;
if let Some(dy) = (0..16)
.rev()
.find(|&dy| counts(section.blocks.get(dy << 8 | i)))
{
return (base + dy as i32 + 1) as i16;
}
}
self.min_y as i16
}
/// `Heightmap.primeHeightmaps`: each section from the top, skipping
/// those of one state that doesn't count, until every column is found.
@@ -797,7 +862,7 @@ impl Chunk {
+ self.sections.capacity() * std::mem::size_of::<Arc<Section>>();
census.heightmap_bytes += [&self.surface, &self.motion_blocking]
.iter()
.filter(|map| map.get().is_some())
.filter(|map| map.heights.get().is_some())
.count()
* std::mem::size_of::<[i16; 256]>();
for section in &self.sections {
@@ -919,6 +984,24 @@ pub struct ChunkExtras {
mod tests {
use super::*;
#[test]
fn extreme_y_queries_are_air_and_writes_leave_the_column_unchanged() {
for layout in [ColumnLayout::VANILLA, ColumnLayout::new(0, 256).unwrap()] {
let mut chunk = Chunk::with_layout(0, 0, layout);
chunk.set(0, layout.min_y(), 0, 7);
for y in [i32::MIN, i32::MIN + 63, -2_146_769_275, i32::MAX] {
assert_eq!(layout.section_index(y), None);
assert_eq!(chunk.section_index(y), None);
assert_eq!(chunk.get(0, y, 0), 0);
assert_eq!(
chunk.light(0, y, 0),
if y < layout.min_y() { 0 } else { 0xf0 }
);
chunk.set(0, y, 0, 9);
}
assert_eq!(chunk.get(0, layout.min_y(), 0), 7);
}
}
#[test]
fn each_light_form_keeps_every_value_and_run() {
// Sky varies alone, block varies alone, both, neither.
let cases: [(fn(usize) -> u8, &str); 4] = [
@@ -1020,9 +1103,89 @@ mod tests {
let before = chunk.world_surface(3, 3, is_air);
chunk.set(3, MIN_Y + HEIGHT, 3, stone);
chunk.set(3, before - 1, 3, chunk.get(3, before - 1, 3));
assert!(chunk.surface.get().is_some());
assert!(chunk.surface.heights.get().is_some());
assert_eq!(chunk.world_surface(3, 3, |_| unreachable!()), before);
}
/// A write two below a column's height can't move it, so the next read asks about no block;
/// a write at or above it (placing on top, clearing the top block) is found again on the next
/// read by looking down that column alone, and the other columns keep their heights.
#[test]
fn a_write_finds_its_own_column_again() {
// Each column holds its own state (16 + i), so a read that looks at another column's
// blocks shows up as a state that isn't air or this column's.
let state = |i: usize| 16 + i as u32;
let mut chunk = Chunk::empty(0, 0);
for i in 0..256 {
for y in MIN_Y..MIN_Y + 20 + (i as i32 % 7) {
chunk.set(i as i32 & 15, y, i as i32 >> 4, state(i));
}
}
let seen = std::cell::RefCell::new(Vec::new());
let surface = |c: &Chunk, i: usize| {
c.world_surface(i as i32 & 15, i as i32 >> 4, |v| {
seen.borrow_mut().push(v);
v == 0
})
};
let heights: Vec<i32> = (0..256).map(|i| surface(&chunk, i)).collect();
assert_eq!(heights[0], MIN_Y + 20);
assert_eq!(heights[6], MIN_Y + 26);
seen.borrow_mut().clear();
let (column, x, z) = (0x35, 5, 3);
let top = heights[column];
// Two below the height: the column keeps its map entry.
chunk.set(x, top - 2, z, 0);
assert_eq!(surface(&chunk, column), top);
assert!(seen.borrow().is_empty());
chunk.set(x, top - 2, z, state(column));
// At the height: placed on top, found again down this column only.
chunk.set(x, top, z, state(column));
assert_eq!(surface(&chunk, column), top + 1);
assert!(!seen.borrow().is_empty());
assert!(seen.borrow().iter().all(|&v| v == 0 || v == state(column)));
seen.borrow_mut().clear();
assert_eq!(surface(&chunk, column), top + 1);
for i in (0..256).filter(|&i| i != column) {
assert_eq!(surface(&chunk, i), heights[i]);
}
assert!(
seen.borrow().is_empty(),
"a read found again an untouched column"
);
// One below the height: the top block cleared, the height drops by one.
chunk.set(x, top, z, 0);
assert_eq!(surface(&chunk, column), top);
assert!(seen.borrow().iter().all(|&v| v == 0 || v == state(column)));
}
/// Chunk snapshots can be cloned while helper threads refresh a stale height. No snapshot
/// may retain the old height with its stale bit cleared, for either map.
#[test]
fn cloning_during_a_height_refresh_keeps_the_new_height() {
for _ in 0..4096 {
let mut chunk = Chunk::empty(0, 0);
chunk.set(0, MIN_Y + 4, 0, 1);
assert_eq!(chunk.world_surface(0, 0, |v| v == 0), MIN_Y + 5);
assert_eq!(chunk.motion_blocking(0, 0, |v| v != 0), MIN_Y + 5);
chunk.set(0, MIN_Y + 5, 0, 1);
let ready = std::sync::Barrier::new(2);
std::thread::scope(|scope| {
scope.spawn(|| {
ready.wait();
assert_eq!(chunk.world_surface(0, 0, |v| v == 0), MIN_Y + 6);
assert_eq!(chunk.motion_blocking(0, 0, |v| v != 0), MIN_Y + 6);
});
ready.wait();
for _ in 0..4 {
let snapshot = chunk.clone();
assert_eq!(snapshot.world_surface(0, 0, |v| v == 0), MIN_Y + 6);
assert_eq!(snapshot.motion_blocking(0, 0, |v| v != 0), MIN_Y + 6);
}
});
}
}
/// MOTION_BLOCKING ignores states that don't count (air, grass) and
/// follows writes as the surface map does, both maps kept at once.
#[test]
@@ -513,6 +513,10 @@ pub struct SignData {
pub waxed: bool,
/// `allow_op_features`: the sign's click events may run commands.
pub allow_op_features: bool,
/// `playerWhoMayEdit`: the one player whose editor is on this sign, as a
/// UUID. Every other player's click, write and applicator is refused
/// while it is set. Not saved (vanilla keeps it out of the NBT).
pub allowed_editor: Option<u128>,
}
/// `VibrationInfo`.
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -539,6 +543,7 @@ pub struct Data {
pub current: Option<Info>,
pub travel: i32,
pub selector: Selector,
pub reload_particle: bool,
}
/// Which sculk block entity this is.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+11 -2
View File
@@ -419,8 +419,10 @@ impl SceneCollisions<'_, '_> {
let hi = BlockPos::containing(Vec3::new(max.x + 1.0e-7, max.y + 1.0e-7, max.z + 1.0e-7))
.offset(1, 1, 1);
let size = |a: i32, b: i32| i64::from(b) - i64::from(a) + 1;
let cells = size(lo.x, hi.x) * size(lo.y, hi.y) * size(lo.z, hi.z);
if lo.x > hi.x || lo.y > hi.y || lo.z > hi.z || cells > 4096 {
let cells = [size(lo.x, hi.x), size(lo.y, hi.y), size(lo.z, hi.z)]
.into_iter()
.try_fold(1i64, |n, size| n.checked_mul(size));
if lo.x > hi.x || lo.y > hi.y || lo.z > hi.z || cells.is_none_or(|n| n > 4096) {
return false;
}
// A column and section at a time. Most boxes lie in a few sections whose palette says that no
@@ -1111,6 +1113,13 @@ mod section_tests {
binding: &binding,
subject: None,
};
// The fast path must decline an enormous volume without overflowing
// its size calculation or beginning to walk it.
let enormous = Aabb::new(
Vec3::new(-1.0e9, -1.0e9, -1.0e9),
Vec3::new(1.0e9, 1.0e9, 1.0e9),
);
assert!(!scene.cells_without_collision(enormous));
// Under the fence: its section's outer cells may hold a shape reaching in.
let under = Aabb::new(Vec3::new(5.4, 31.6, 5.4), Vec3::new(5.6, 31.8, 5.6));
assert!(!scene.cells_without_collision(under.expand_towards(Vec3::new(0.0, 0.1, 0.0))));
+85 -14
View File
@@ -56,6 +56,8 @@ pub struct Overlay {
struct LoadedRule {
rule: Rule,
tiles: Vec<TileLayer>,
/// By tile: the rules whose `matchTiles` name it (a range of `lists`), which chain on it.
chain: Vec<(u32, u32)>,
overlay: OverlayJoin,
}
@@ -188,6 +190,7 @@ impl ConnectedTextures {
Ok(tiles) => Some(LoadedRule {
rule,
tiles,
chain: Vec::new(),
overlay: OverlayJoin::default(),
}),
Err(e) => {
@@ -292,10 +295,23 @@ impl ConnectedTextures {
let mut by_layer: Vec<Vec<u16>> = Vec::new();
// Block rules by the block each entry names, so a state tests only its own.
let mut by_block: HashMap<&str, Vec<u16>> = HashMap::new();
// Tile rules by the sprite or tile file each names, for chaining on a picked tile.
let mut by_name: HashMap<String, Vec<u16>> = HashMap::new();
for (i, r) in out.rules.iter().enumerate() {
let i = i as u16;
for tile in &r.rule.match_tiles {
if let Some(l) = layer(&sprite_name(tile)) {
let name = sprite_name(tile);
let list = by_name.entry(name.clone()).or_default();
if list.last() != Some(&i) {
list.push(i);
}
// A tile file (`./1.png`) is no model's sprite: only chaining reaches it, by
// name. By layer it would match every face whose sprite has the same pixels
// (identical stills share a layer: Conquest's blank placeholders, dozens).
if !name.contains(':') {
continue;
}
if let Some(l) = layer(&name) {
let l = l as usize;
if by_layer.len() <= l {
by_layer.resize(l + 1, Vec::new());
@@ -316,6 +332,26 @@ impl ConnectedTextures {
let range = push_list(&mut out.lists, &list);
out.by_layer.push(range);
}
let by_name: HashMap<String, (u32, u32)> = by_name
.into_iter()
.map(|(name, list)| (name, push_list(&mut out.lists, &list)))
.collect();
// Set at the end: `by_block` borrows the rules' names.
let chains: Vec<Vec<(u32, u32)>> = out
.rules
.iter()
.map(|r| {
r.rule
.tiles
.iter()
.map(|t| match t {
Tile::Texture(path) => by_name.get(&sprite_name(path)).copied(),
_ => None,
})
.map(Option::unwrap_or_default)
.collect()
})
.collect();
let mut list = Vec::new();
for state in states {
let Some((name, props)) = state else {
@@ -355,6 +391,9 @@ impl ConnectedTextures {
let range = push_list(&mut out.lists, &list);
out.by_state.push(range);
}
for (r, chain) in out.rules.iter_mut().zip(chains) {
r.chain = chain;
}
out
}
@@ -392,9 +431,11 @@ impl ConnectedTextures {
}
fn list(&self, ranges: &[(u32, u32)], i: usize) -> &[u16] {
ranges.get(i).map_or(&[], |&(start, len)| {
&self.lists[start as usize..(start + len) as usize]
})
ranges.get(i).map_or(&[], |&range| self.range(range))
}
fn range(&self, (start, len): (u32, u32)) -> &[u16] {
&self.lists[start as usize..(start + len) as usize]
}
/// What the `face` of the block `state` at `pos` draws, for a quad with sprite `layer`.
@@ -441,7 +482,7 @@ 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(
&& let Some((pick, index)) = self.apply(
r,
state,
layer,
@@ -454,7 +495,7 @@ impl ConnectedTextures {
)
{
// "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).
// tile rules naming the picked tile by name (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;
@@ -467,7 +508,7 @@ impl ConnectedTextures {
// 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)
.range(r.chain.get(index).copied().unwrap_or_default())
.iter()
.map(|&j| (j, &self.rules[usize::from(j)]))
.filter(|&(j, r)| {
@@ -488,8 +529,8 @@ impl ConnectedTextures {
&texture,
&mut overlay,
) {
Some(Pick::Keep) => return pick,
Some(chained) => return chained,
Some((Pick::Keep, _)) => return pick,
Some((chained, _)) => return chained,
None => {}
}
}
@@ -512,7 +553,7 @@ impl ConnectedTextures {
biome: &impl Fn() -> Option<&'b str>,
texture: &impl Fn() -> Option<Axes>,
overlay: &mut impl FnMut(Overlay),
) -> Option<Pick> {
) -> Option<(Pick, usize)> {
let rule = &r.rule;
// `Rule::applies_at`, with the biome looked up only for rules that test one. Rules with
// a custom name are not loaded.
@@ -602,7 +643,7 @@ impl ConnectedTextures {
return if quarters.contains(&TileLayer::Skip) {
None
} else {
Some(Pick::Quarters(quarters))
Some((Pick::Quarters(quarters), 0))
};
}
}
@@ -617,7 +658,7 @@ impl ConnectedTextures {
return None;
}
if !joins([0, 1, 0]) {
return Some(Pick::Keep);
return Some((Pick::Keep, 0));
}
0
}
@@ -644,8 +685,8 @@ impl ConnectedTextures {
}
match tile(index) {
TileLayer::Skip => None,
TileLayer::Default => Some(Pick::Keep),
t => Some(Pick::Tile(t)),
TileLayer::Default => Some((Pick::Keep, index)),
t => Some((Pick::Tile(t), index)),
}
}
}
@@ -675,6 +716,7 @@ mod tests {
.enumerate()
.map(|(n, rule)| LoadedRule {
overlay: OverlayJoin::default(),
chain: Vec::new(),
tiles: rule
.tiles
.iter()
@@ -722,6 +764,10 @@ mod tests {
"assets/minecraft/optifine/ctm/b0.png" => Some(2000),
// And `a0` of the first rule.
"assets/minecraft/optifine/ctm/a0.png" => Some(1000),
// Two blank placeholders with the same pixels, so one layer: the tile `grass/1`
// of the third rule (`chaining_follows_the_tile_not_its_pixels`).
"assets/minecraft/optifine/ctm/grass/1.png"
| "assets/minecraft/optifine/ctm/aaa/1.png" => Some(3000),
_ => None,
},
)
@@ -1176,6 +1222,31 @@ mod tests {
assert_eq!(pick_in(&ctm, 1, 3, Face::Up, &[]), tile(2000));
}
#[test]
fn chaining_follows_the_tile_not_its_pixels() {
// Conquest's grass top: a placeholder tile that a rule beside it replaces. Another
// folder's placeholder has the same pixels, hence the same layer, and its rule sorts
// first; it must not draw on the grass.
let ctm = world(&[
(
"grass/top.properties",
"matchTiles=glass\nmethod=fixed\ntiles=1",
),
(
"grass/random_1.properties",
"matchTiles=./1.png\nmethod=fixed\ntiles=17\nfaces=top",
),
(
"aaa/random_1.properties",
"matchTiles=./1.png\nmethod=fixed\ntiles=5\nfaces=top",
),
]);
// Rules in path order: aaa/random_1 (1000), grass/random_1 (2000), grass/top (3000).
assert_eq!(pick_in(&ctm, 1, 3, Face::Up, &[]), tile(2000));
// A face whose own sprite has a placeholder's pixels is no placeholder.
assert_eq!(pick_in(&ctm, 2, 3000, Face::Up, &[]), Pick::Keep);
}
#[test]
fn orient_texture_follows_the_quads_uvs() {
let rule = |orient: &str| {
+324 -44
View File
@@ -4,11 +4,13 @@
//!
//! The pack's GLSL (26.3's `#include` or the older `#moj_import`, with explicit locations or
//! without) is written again as the Vulkan-flavoured GLSL 450 naga reads:
//! - GLSL vanilla's GL compiler takes and naga doesn't is rewritten first ([`glsl_fixups`]: a
//! helper taking the `sampler2D` it reads, an array sized by its initializer only);
//! - includes are inlined, each file once (as `GlslPreprocessor` imported them), from `include`;
//! - vanilla's uniform blocks get the bindings of [`BLOCKS`], and each of [`SAMPLERS`] is split
//! into a texture and a sampler object, combined again where the sampler is named;
//! - the vertex inputs become globals the caller's [`Glue`] fills before the pack's `main`
//! (renamed `hz_main`) runs, and `gl_VertexID` reads the glue's expression;
//! (renamed `hz_main`) runs, and `gl_VertexID` and `gl_VertexIndex` read the glue's expression;
//! - varyings get locations by name across the stages, the fragment output location 0.
use std::collections::HashSet;
@@ -18,6 +20,8 @@ use naga::ShaderStage;
use naga::front::glsl::{Frontend, Options};
use naga::valid::{Capabilities, ValidationFlags, Validator};
mod glsl_fixups;
/// Vanilla's uniform blocks (`shaders/include/*.glsl`) and their bindings in set 0.
pub const BLOCKS: [(&str, u32); 4] = [
("Globals", 0),
@@ -43,7 +47,7 @@ pub struct Glue<'a> {
pub setup: &'a str,
/// Statements after it, on `gl_Position`.
pub finish: &'a str,
/// The expression `gl_VertexID` reads.
/// The expression the pack's `gl_VertexID` and `gl_VertexIndex` read.
pub vertex_id: &'a str,
}
@@ -52,6 +56,15 @@ pub struct Glue<'a> {
pub struct CoreProgram {
pub vertex: naga::Module,
pub fragment: naga::Module,
/// The blocks and samplers that are none of vanilla's, in binding order: the `i`th is at
/// [`other_binding`]`(i)` (a sampler's object one after its texture; a block leaves that
/// binding unused).
pub other: Vec<String>,
}
/// The binding of the `index`th block or sampler of [`CoreProgram::other`].
pub fn other_binding(index: usize) -> u32 {
OTHER_BINDINGS + 2 * index as u32
}
/// The shaders of one pipeline (`text.vsh`, `text.fsh`) with vanilla's `defines` for it.
@@ -62,19 +75,39 @@ pub fn translate_core(
defines: &[&str],
include: &dyn Fn(&str) -> Option<String>,
glue: &Glue,
) -> Result<CoreProgram, String> {
translate_core_with(vertex, fragment, defines, include, glue, &|_| false)
}
/// [`translate_core`] for a pipeline that also gives the blocks and samplers `provided` says
/// yes to, at their [`CoreProgram::other`] bindings; the others are still an error. `provided`
/// answers a block's name and a sampler's alike: the caller knows which kind each one is.
pub fn translate_core_with(
vertex: &str,
fragment: &str,
defines: &[&str],
include: &dyn Fn(&str) -> Option<String>,
glue: &Glue,
provided: &dyn Fn(&str) -> bool,
) -> Result<CoreProgram, String> {
const CORE: &str = "minecraft:shaders/core/";
let vertex_text = strip_comments(&expand(vertex, CORE, include)?);
let fragment_text = strip_comments(&expand(fragment, CORE, include)?);
let vertex_text = glsl_fixups::fixups(&strip_comments(&expand(vertex, CORE, include)?));
let fragment_text = glsl_fixups::fixups(&strip_comments(&expand(fragment, CORE, include)?));
let mut names = Names::default();
let vertex_code = rewrite(&vertex_text, ShaderStage::Vertex, glue, &mut names)
.map_err(|e| format!("vertex shader: {e}"))?;
let fragment_code = rewrite(&fragment_text, ShaderStage::Fragment, glue, &mut names)
.map_err(|e| format!("fragment shader: {e}"))?;
let vertex = compile(ShaderStage::Vertex, &vertex_code, defines)
let vertex = compile(ShaderStage::Vertex, &vertex_code, defines, &names, provided)
.map_err(|e| format!("vertex shader: {e}"))?;
let fragment = compile(ShaderStage::Fragment, &fragment_code, defines)
.map_err(|e| format!("fragment shader: {e}"))?;
let fragment = compile(
ShaderStage::Fragment,
&fragment_code,
defines,
&names,
provided,
)
.map_err(|e| format!("fragment shader: {e}"))?;
let given = locations(&vertex, false);
if let Some(missing) = locations(&fragment, true)
.into_iter()
@@ -84,11 +117,21 @@ pub fn translate_core(
"the fragment shader reads location {missing}, which the vertex shader doesn't write"
));
}
Ok(CoreProgram { vertex, fragment })
Ok(CoreProgram {
vertex,
fragment,
other: names.other,
})
}
/// A bound on a pack's includes, which are pasted every time.
const MAX_PASTES: u32 = 1024;
const MAX_EXPANDED: usize = 1 << 20;
/// The file with its includes inlined (`#include <ns:path>`, `#include "relative"` and the same
/// with `#moj_import`), each file once, `#version` and `#extension` lines dropped.
/// with `#moj_import`), `#version` and `#extension` lines dropped. An `#include` is pasted every
/// time, as vanilla's shaderc does (the files' `#ifndef` guards keep one copy); a `#moj_import`
/// once.
fn expand(
text: &str,
dir: &str,
@@ -99,6 +142,7 @@ fn expand(
dir: &str,
include: &dyn Fn(&str) -> Option<String>,
seen: &mut HashSet<String>,
pastes: &mut u32,
depth: u32,
out: &mut String,
) -> Result<(), String> {
@@ -109,15 +153,19 @@ fn expand(
let directive = line.trim_start().strip_prefix('#').map(str::trim_start);
let target = directive.and_then(|d| {
d.strip_prefix("include")
.or_else(|| d.strip_prefix("moj_import"))
.map(str::trim)
.map(|t| (t.trim(), false))
.or_else(|| d.strip_prefix("moj_import").map(|t| (t.trim(), true)))
});
if let Some(target) = target {
if let Some((target, once)) = target {
let id = include_id(target, dir).ok_or_else(|| format!("bad include {target}"))?;
if seen.insert(id.clone()) {
if seen.insert(id.clone()) || !once {
*pastes += 1;
if *pastes > MAX_PASTES {
return Err(format!("includes paste more than {MAX_PASTES} files"));
}
let file = include(&id).ok_or_else(|| format!("{id} not found"))?;
let parent = &id[..id.rfind('/').map_or(id.len(), |i| i + 1)];
walk(&file, parent, include, seen, depth + 1, out)?;
walk(&file, parent, include, seen, pastes, depth + 1, out)?;
}
out.push('\n');
} else if directive
@@ -128,11 +176,16 @@ fn expand(
out.push_str(line);
out.push('\n');
}
if out.len() > MAX_EXPANDED {
return Err(format!(
"includes grow the shader past {MAX_EXPANDED} bytes"
));
}
}
Ok(())
}
let mut out = String::new();
walk(text, dir, include, &mut HashSet::new(), 0, &mut out)?;
walk(text, dir, include, &mut HashSet::new(), &mut 0, 0, &mut out)?;
Ok(out)
}
@@ -289,14 +342,16 @@ fn block_name(line: &str) -> Option<&str> {
let rest = line[at + "uniform".len()..].trim();
let name_end = rest.find(|c: char| !is_ident(c)).unwrap_or(rest.len());
let (name, tail) = rest.split_at(name_end);
(!name.is_empty() && matches!(tail.trim(), "" | "{")).then_some(name)
// `{` alone, or the whole block on the line (`uniform Day { float daylight; };`).
(!name.is_empty() && (tail.trim().is_empty() || tail.trim_start().starts_with('{')))
.then_some(name)
}
/// What the two stages share: the vertex stage's outputs in location order, and the names of
/// What the two stages share: the vertex stage's outputs and their locations, and the names of
/// the blocks and samplers of other pipelines in binding order.
#[derive(Default)]
struct Names {
varyings: Vec<String>,
varyings: Vec<(String, u32)>,
other: Vec<String>,
}
@@ -304,8 +359,8 @@ impl Names {
fn location(&self, varying: &str) -> Option<u32> {
self.varyings
.iter()
.position(|v| v == varying)
.map(|i| i as u32)
.find(|(v, _)| v == varying)
.map(|(_, location)| *location)
}
/// A block or sampler of another pipeline: its binding (two for a sampler).
@@ -317,7 +372,7 @@ impl Names {
self.other.len() - 1
}
};
OTHER_BINDINGS + 2 * index as u32
other_binding(index)
}
}
@@ -337,7 +392,19 @@ fn rewrite(
writeln!(out, "{ty} {name};").unwrap();
}
}
// Where the pack's own code starts: the glue above it is left as it is.
let pack_start = out.len();
let mut outputs: Vec<&str> = Vec::new();
let explicit: Vec<u32> = if vertex {
text.lines()
.flat_map(declaration_lines)
.filter_map(declaration)
.filter(|d| d.storage == "out")
.filter_map(|d| d.location)
.collect()
} else {
Vec::new()
};
for source_line in text.lines() {
for line in declaration_lines(source_line) {
if let Some(name) = block_name(line) {
@@ -417,10 +484,21 @@ fn rewrite(
}
"out" if vertex => {
// One location each: naga takes scalars and vectors only.
let location = names.location(decl.name).unwrap_or_else(|| {
names.varyings.push(decl.name.to_owned());
names.varyings.len() as u32 - 1
});
let location = decl
.location
.or_else(|| names.location(decl.name))
.unwrap_or_else(|| {
(0..)
.find(|l| {
!explicit.contains(l)
&& names.varyings.iter().all(|(_, v)| v != l)
})
.unwrap_or_default()
});
let varying = (decl.name.to_owned(), location);
if !names.varyings.contains(&varying) {
names.varyings.push(varying);
}
writeln!(
out,
"layout(location = {location}) {qualifiers} out {} {};",
@@ -429,9 +507,12 @@ fn rewrite(
.unwrap();
}
"in" => {
let location = names.location(decl.name).ok_or_else(|| {
format!("input {} isn't written by the vertex shader", decl.name)
})?;
let location = decl
.location
.or_else(|| names.location(decl.name))
.ok_or_else(|| {
format!("input {} isn't written by the vertex shader", decl.name)
})?;
writeln!(
out,
"layout(location = {location}) {qualifiers} in {} {};",
@@ -462,11 +543,18 @@ fn rewrite(
if !vertex {
return Ok(out);
}
let mut code = replace_ident(
&rename_main(&out),
"gl_VertexID",
&format!("({})", glue.vertex_id),
// The pack's `gl_VertexID` and 26.3's `gl_VertexIndex` both mean vanilla's vertex number
// (four per quad, an `int`): both read the glue's expression, which for a pipeline that
// draws six vertices per quad turns its own `gl_VertexIndex` back into that number. Only the
// pack's code is rewritten, so the glue's expression keeps reading the real index.
let vertex_id = format!("({})", glue.vertex_id);
let pack = replace_ident(
&rename_main(&out[pack_start..]),
"gl_VertexIndex",
&vertex_id,
);
let pack = replace_ident(&pack, "gl_VertexID", &vertex_id);
let mut code = format!("{}{pack}", &out[..pack_start]);
write!(
code,
"void main() {{\n{}\nhz_main();\n{}\n}}\n",
@@ -498,10 +586,17 @@ fn rename_main(text: &str) -> String {
}
/// Parses and validates one stage; errors name the line of the rewritten code.
fn compile(stage: ShaderStage, code: &str, defines: &[&str]) -> Result<naga::Module, String> {
fn compile(
stage: ShaderStage,
code: &str,
defines: &[&str],
names: &Names,
provided: &dyn Fn(&str) -> bool,
) -> Result<naga::Module, String> {
let mut options = Options::from(stage);
for define in defines {
options.defines.insert((*define).to_owned(), String::new());
let (name, value) = define.split_once(' ').unwrap_or((define, ""));
options.defines.insert(name.to_owned(), value.to_owned());
}
let module = Frontend::default().parse(&options, code).map_err(|e| {
e.errors
@@ -529,16 +624,16 @@ fn compile(stage: ShaderStage, code: &str, defines: &[&str]) -> Result<naga::Mod
})?;
let entry = info.get_entry_point(0);
for (handle, global) in module.global_variables.iter() {
let other = global
.binding
.as_ref()
.is_some_and(|b| b.binding >= OTHER_BINDINGS);
if other && !entry[handle].is_empty() {
let name = global.name.as_deref().unwrap_or("?");
let name = name
.strip_prefix("hz_tex_")
.or_else(|| name.strip_prefix("hz_smp_"))
.unwrap_or(name);
let Some(binding) = global.binding.as_ref().map(|b| b.binding) else {
continue;
};
if binding >= OTHER_BINDINGS && !entry[handle].is_empty() {
// The name it was declared with (a block's instance may have none).
let index = ((binding - OTHER_BINDINGS) / 2) as usize;
let name = names.other.get(index).map_or("?", String::as_str);
if provided(name) {
continue;
}
return Err(format!("{name} is not a block or sampler of this pipeline"));
}
}
@@ -633,6 +728,44 @@ mod tests {
assert_eq!(program.fragment.entry_points.len(), 1);
}
/// A 26.3 pack's `gl_VertexIndex` means vanilla's vertex number, four per quad. The text
/// pipeline draws six per glyph, so the pack's code must read the glue's `hz_vertex_id()`
/// (as `gl_VertexID` does), never the raw index.
#[test]
fn a_packs_gl_vertex_index_reads_the_glues_vertex_number() {
let vsh = "#version 330\n#include <minecraft:dynamictransforms.glsl>\n#include <minecraft:projection.glsl>\n\
layout(location = 0) in vec3 Position;\nlayout(location = 1) in vec4 Color;\nlayout(location = 2) in vec2 UV0;\n\
layout(location = 2) out vec4 vertexColor;\nlayout(location = 3) out vec2 texCoord0;\n\
void main() {\n int corner = gl_VertexIndex % 4;\n\
gl_Position = ProjMat * ModelViewMat * vec4(Position + vec3(float(corner), 0.0, 0.0), 1.0);\n\
vertexColor = Color;\n texCoord0 = UV0;\n}\n";
let program = translate_core(vsh, VANILLA_FSH, &["IS_GUI"], &include, &GLUE).unwrap();
let module = &program.vertex;
let named = |name: &str| {
module
.functions
.iter()
.find(|(_, f)| f.name.as_deref() == Some(name))
.unwrap_or_else(|| panic!("no {name}"))
};
let (vertex_id, _) = named("hz_vertex_id");
let (_, pack_main) = named("hz_main");
fn calls(block: &naga::Block, target: naga::Handle<naga::Function>) -> bool {
block.iter().any(|statement| match statement {
naga::Statement::Call { function, .. } => *function == target,
naga::Statement::Block(inner) => calls(inner, target),
naga::Statement::If { accept, reject, .. } => {
calls(accept, target) || calls(reject, target)
}
naga::Statement::Loop {
body, continuing, ..
} => calls(body, target) || calls(continuing, target),
_ => false,
})
}
assert!(calls(&pack_main.body, vertex_id));
}
/// A 26.2-style pack shader as a HUD pack writes it: `#moj_import`, no locations, a flat
/// varying, `gl_VertexID`, `texelFetch` and `textureSize` on the glyph atlas, `GameTime`.
#[test]
@@ -666,6 +799,96 @@ mod tests {
assert_eq!(read, [0, 2]);
}
#[test]
fn explicit_varying_locations_are_kept() {
let vsh = "#version 330\nin vec3 Position;\n\
#ifdef IS_GUI\nlayout(location = 4) out float gui;\n#else\nlayout(location = 4) out float world;\n#endif\n\
layout(location = 13) out vec4 a;\nout float b;\n\
void main() { gl_Position = vec4(Position, 1.0); a = vec4(1.0); b = 1.0;\n\
#ifdef IS_GUI\ngui = 1.0;\n#else\nworld = 1.0;\n#endif\n}\n";
let fsh = "#version 330\n#ifdef IS_GUI\nlayout(location = 4) in float gui;\n#endif\n\
layout(location = 13) in vec4 a;\nin float b;\nout vec4 fragColor;\n\
void main() { fragColor = a * b;\n#ifdef IS_GUI\nfragColor *= gui;\n#endif\n}\n";
let program = translate_core(vsh, fsh, &["IS_GUI"], &include, &GLUE).unwrap();
let mut read = locations(&program.fragment, true);
read.sort_unstable();
assert_eq!(read, [0, 4, 13]);
}
#[test]
fn an_include_is_pasted_every_time_and_a_moj_import_once() {
let file = |id: &str| {
(id == "minecraft:shaders/include/g.glsl")
.then(|| "#ifndef G\n#define G\nconst float g = 1.0;\n#endif\n".to_owned())
};
let twice =
"#ifdef A\n#include <minecraft:g.glsl>\n#else\n#include <minecraft:g.glsl>\n#endif\n";
let expanded = expand(twice, "minecraft:shaders/core/", &file).unwrap();
assert_eq!(expanded.matches("const float g").count(), 2);
let imported = expand(
&twice.replace("#include", "#moj_import"),
"minecraft:shaders/core/",
&file,
)
.unwrap();
assert_eq!(imported.matches("const float g").count(), 1);
}
#[test]
fn a_location_another_branch_gave_the_same_name_isnt_reused() {
let vsh = "#version 330\nin vec3 Position;\n\
#ifdef IS_GUI\nlayout(location = 0) out vec4 vertexColor;\n\
#else\nlayout(location = 1) out vec4 vertexColor;\nlayout(location = 0) out vec4 worldOther;\n#endif\n\
void main() { gl_Position = vec4(Position, 1.0); vertexColor = vec4(1.0);\n\
#ifndef IS_GUI\nworldOther = vec4(0.5);\n#endif\n}\n";
let fsh = "#version 330\n\
#ifdef IS_GUI\nlayout(location = 0) in vec4 vertexColor;\n\
#else\nlayout(location = 1) in vec4 vertexColor;\nlayout(location = 0) in vec4 worldOther;\n#endif\n\
out vec4 fragColor;\nvoid main() { fragColor = vertexColor;\n\
#ifndef IS_GUI\nfragColor *= worldOther;\n#endif\n}\n";
let world = translate_core(vsh, fsh, &[], &include, &GLUE).unwrap();
let mut written = locations(&world.vertex, false);
written.sort_unstable();
assert_eq!(written, [0, 1]);
let mut read = locations(&world.fragment, true);
read.sort_unstable();
assert_eq!(read, [0, 1]);
let gui = translate_core(vsh, fsh, &["IS_GUI"], &include, &GLUE).unwrap();
assert_eq!(locations(&gui.vertex, false), [0]);
}
#[test]
fn includes_that_multiply_are_refused_once_over_budget() {
let file = |id: &str| {
let level: u32 = id
.strip_prefix("minecraft:shaders/include/l")?
.strip_suffix(".glsl")?
.parse()
.ok()?;
let next = format!("#include <minecraft:l{}.glsl>\n", level + 1).repeat(4);
Some(format!(
"#ifndef L{level}\n#define L{level}\n{}#endif\n",
if level < 15 { next.as_str() } else { "" }
))
};
let error = expand(
"#include <minecraft:l0.glsl>\n",
"minecraft:shaders/core/",
&file,
)
.unwrap_err();
assert!(error.contains("paste more than"), "{error}");
}
#[test]
fn a_define_with_a_value_is_defined_to_it() {
let vsh = "#version 330\nin vec3 Position;\n\
void main() { gl_Position = vec4(Position, float(COUNT)); }\n";
let fsh =
"#version 330\nout vec4 fragColor;\nvoid main() { fragColor = vec4(COUNT / 4); }\n";
assert!(translate_core(vsh, fsh, &["COUNT 8"], &include, &GLUE).is_ok());
}
#[test]
fn minified_varying_declarations_are_rewritten() {
let vsh = "#version 330\nout float sphericalVertexDistance;out float cylindricalVertexDistance;\
@@ -686,6 +909,63 @@ mod tests {
translate_core(vsh, fsh, &[], &include, &GLUE).unwrap();
}
/// A block declared on one line, as packs write a one-float uniform, is bound like one
/// over several lines; the blocks and samplers the caller provides are accepted, in binding
/// order, and one it doesn't provide is not.
#[test]
fn a_block_on_one_line_is_bound_and_provided_blocks_are_accepted() {
let vsh = "#version 330\nlayout(std140) uniform Day { float daylight; };\nout vec4 vertexColor;\n\
void main() { vertexColor = vec4(daylight); gl_Position = vec4(0.0); }\n";
let fsh = "#version 330\nuniform sampler2D Bounds;\nin vec4 vertexColor;\nout vec4 fragColor;\n\
void main() { fragColor = vertexColor * texture(Bounds, vec2(0.5)); }\n";
let both = |name: &str| name == "Day" || name == "Bounds";
let program = translate_core_with(vsh, fsh, &[], &include, &GLUE, &both).unwrap();
assert_eq!(program.other, ["Day", "Bounds"]);
// The bindings the caller gives them at.
let bindings = |module: &naga::Module| -> Vec<(Option<String>, u32)> {
module
.global_variables
.iter()
.filter_map(|(_, g)| Some((g.name.clone(), g.binding.as_ref()?.binding)))
.collect()
};
assert!(
bindings(&program.vertex)
.iter()
.any(|(_, b)| *b == other_binding(0)),
"{:?}",
bindings(&program.vertex)
);
let fragment = bindings(&program.fragment);
assert!(
fragment.contains(&(Some("hz_tex_Bounds".into()), other_binding(1))),
"{fragment:?}"
);
assert!(
fragment.contains(&(Some("hz_smp_Bounds".into()), other_binding(1) + 1)),
"{fragment:?}"
);
let error = translate_core(vsh, fsh, &[], &include, &GLUE).unwrap_err();
assert!(error.contains("Day is not a block or sampler"), "{error}");
let only_day = |name: &str| name == "Day";
let error = translate_core_with(vsh, fsh, &[], &include, &GLUE, &only_day).unwrap_err();
assert!(
error.contains("Bounds is not a block or sampler"),
"{error}"
);
}
/// Only a uniform block's declaration names a block, written over several lines or on one.
#[test]
fn block_names_are_read_from_block_declarations_only() {
assert_eq!(block_name("layout(std140) uniform Day {"), Some("Day"));
assert_eq!(block_name("uniform Day { float daylight; };"), Some("Day"));
assert_eq!(block_name("uniform Foo{"), Some("Foo"));
assert_eq!(block_name("uniform Globals"), Some("Globals"));
assert_eq!(block_name("uniform float x;"), None);
assert_eq!(block_name("uniform sampler2D Sampler0;"), None);
}
#[test]
fn what_horizon_does_not_provide_is_an_error() {
let fsh = "#version 330\nin vec4 vertexColor;\nout vec4 fragColor;\nvoid main() { fragColor = vertexColor; }\n";
@@ -0,0 +1,603 @@
//! GLSL that vanilla's GL compiler takes and naga's front end doesn't (#9386), rewritten into
//! what both take:
//! - a function with `sampler2D` parameters is copied once per set of samplers its callers pass
//! (all of them a shader's global samplers), the copy reading those samplers by name;
//! - an unsized array (`const float[] w = float[](...)`, `float w[] = ...`) takes the size its
//! initializer gives.
/// The longest shader rewritten (vanilla's, includes expanded, are a few KiB).
const MAX_LEN: usize = 64 * 1024;
/// The bytes the rewrite may scan for calls (the whole text per call it rewrites): thousands of
/// calls leave the text as it was rather than freeze a load.
const SCAN_BUDGET: usize = 64 << 20;
/// `text` with the rewrites, where they apply; anything they can't follow is left as it was.
pub(super) fn fixups(text: &str) -> String {
if text.len() > MAX_LEN {
return text.to_owned();
}
size_unsized_arrays(&specialize_sampler_params(text))
}
fn is_ident(c: char) -> bool {
c.is_ascii_alphanumeric() || c == '_'
}
/// A name a sampler can have (`sampler2D ;` gives none, `t[2]` isn't one).
fn is_name(s: &str) -> bool {
s.chars().all(is_ident) && s.starts_with(|c: char| !c.is_ascii_digit()) && s != "sampler2D"
}
/// The identifier that ends right before byte `end` (whitespace skipped), and where it starts.
fn ident_before(text: &str, end: usize) -> Option<(usize, &str)> {
let trimmed = text[..end].trim_end();
let start = trimmed
.char_indices()
.rev()
.take_while(|(_, c)| is_ident(*c))
.last()?
.0;
let ident = &trimmed[start..];
(!ident.is_empty() && !ident.starts_with(|c: char| c.is_ascii_digit()))
.then_some((start, ident))
}
/// For each byte of `text`, the byte of the bracket closing the one opening there (counting
/// that kind of bracket only), or `None`: one pass, so a scan asking for many stays linear.
fn closings(text: &str) -> Vec<Option<usize>> {
let mut out = vec![None; text.len()];
let mut open: [Vec<usize>; 3] = Default::default();
for (i, b) in text.bytes().enumerate() {
let (kind, opens) = match b {
b'(' => (0, true),
b')' => (0, false),
b'[' => (1, true),
b']' => (1, false),
b'{' => (2, true),
b'}' => (2, false),
_ => continue,
};
if opens {
open[kind].push(i);
} else if let Some(at) = open[kind].pop() {
out[at] = Some(i);
}
}
out
}
/// The comma-separated parts of `list` at its own nesting level.
fn split_top(list: &str) -> Vec<&str> {
let mut parts = Vec::new();
let (mut depth, mut start) = (0i32, 0);
for (i, c) in list.char_indices() {
match c {
'(' | '[' | '{' => depth += 1,
')' | ']' | '}' => depth -= 1,
',' if depth == 0 => {
parts.push(&list[start..i]);
start = i + 1;
}
_ => {}
}
}
if !list[start..].trim().is_empty() || !parts.is_empty() {
parts.push(&list[start..]);
}
parts
}
/// A function at the file's top level whose parameters include a `sampler2D`.
struct SamplerFn {
name: String,
/// The header from its return type to its `{` (or `;` for a prototype), and the body after.
start: usize,
end: usize,
params: Vec<String>,
/// The indices of the `sampler2D` parameters and their names.
samplers: Vec<(usize, String)>,
/// `None` for a prototype.
body: Option<String>,
return_type: String,
}
fn sampler_functions(text: &str) -> Vec<SamplerFn> {
let closing = closings(text);
let mut out = Vec::new();
let mut depth = 0i32;
let bytes = text.as_bytes();
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'{' => depth += 1,
b'}' => depth -= 1,
b'(' if depth == 0 => {
if let (Some((name_at, name)), Some(close)) = (ident_before(text, i), closing[i])
&& let Some((type_at, return_type)) = ident_before(text, name_at)
{
let params: Vec<String> = split_top(&text[i + 1..close])
.into_iter()
.map(|p| p.trim().to_owned())
.collect();
let samplers: Vec<(usize, String)> = params
.iter()
.enumerate()
.filter(|(_, p)| p.split_whitespace().any(|w| w == "sampler2D"))
.map(|(n, p)| (n, p.split_whitespace().last().unwrap_or("").to_owned()))
.collect();
// One without a name is left for naga to refuse.
if !samplers.iter().all(|(_, name)| is_name(name)) {
i = close + 1;
continue;
}
let after = text[close + 1..].trim_start();
let next = close + 1 + (text[close + 1..].len() - after.len());
if !samplers.is_empty() && (after.starts_with('{') || after.starts_with(';')) {
let (end, body) = if after.starts_with('{') {
let Some(body_end) = closing[next] else {
break;
};
(body_end + 1, Some(text[next..=body_end].to_owned()))
} else {
(next + 1, None)
};
out.push(SamplerFn {
name: name.to_owned(),
start: type_at,
end,
params,
samplers,
body,
return_type: return_type.to_owned(),
});
i = end;
continue;
}
}
// Any other header: no other one sits in its parentheses.
if let Some(close) = closing[i] {
i = close + 1;
continue;
}
}
_ => {}
}
i += 1;
}
out
}
/// The calls to `name` outside `skip` (its own header): each call's span and arguments. The
/// bytes read are added to `scanned`.
fn calls<'a>(
scanned: &std::cell::Cell<usize>,
text: &'a str,
name: &str,
skip: &[(usize, usize)],
) -> Vec<(usize, usize, Vec<&'a str>)> {
scanned.set(scanned.get() + text.len());
let closing = closings(text);
let mut out = Vec::new();
let mut from = 0;
while let Some(found) = text[from..].find(name) {
let at = from + found;
from = at + name.len();
let before = text[..at].chars().next_back();
let rest = &text[at + name.len()..];
let open = at + name.len() + (rest.len() - rest.trim_start().len());
if before.is_some_and(is_ident)
|| !text[open..].starts_with('(')
|| skip.iter().any(|(s, e)| (*s..*e).contains(&at))
{
continue;
}
let Some(close) = closing[open] else {
continue;
};
scanned.set(scanned.get() + close - open);
out.push((at, close + 1, split_top(&text[open + 1..close])));
}
out
}
/// Every `from` standing as an identifier on its own, but not a member after a `.` (a
/// parameter `s` leaves the swizzle `c.s` alone).
fn replace_ident(text: &str, from: &str, to: &str) -> String {
if from.is_empty() {
return text.to_owned();
}
let mut out = String::with_capacity(text.len());
let mut done = 0;
while let Some(found) = text[done..].find(from) {
let at = done + found;
// Read in the whole text: a match right after another (`ss`) is part of it.
let before = text[..at].trim_end().chars().next_back();
let joined = text[..at].chars().next_back().is_some_and(is_ident);
let after = text[at + from.len()..].chars().next();
out.push_str(&text[done..at]);
if joined || before == Some('.') || after.is_some_and(is_ident) {
out.push_str(from);
} else {
out.push_str(to);
}
done = at + from.len();
}
out.push_str(&text[done..]);
out
}
/// A body with its sampler parameters reading `chosen`, all at once: a parameter named like
/// another's sampler (`mix2(sampler2D Sampler0, sampler2D Sampler1)` called with them swapped)
/// still reads its own.
fn read_samplers(body: &str, params: &[(usize, String)], chosen: &[String]) -> String {
let mut body = body.to_owned();
for (n, (_, param)) in params.iter().enumerate() {
body = replace_ident(&body, param, &format!("hz_sampler_param_{n}"));
}
for (n, sampler) in chosen.iter().enumerate().take(params.len()) {
body = replace_ident(&body, &format!("hz_sampler_param_{n}"), sampler);
}
body
}
/// The byte after `uniform sampler2D <name>;`, where the shader declares that sampler.
fn sampler_declaration_end(text: &str, name: &str) -> Option<usize> {
let mut from = 0;
while let Some(found) = text[from..].find("sampler2D") {
let at = from + found;
from = at + "sampler2D".len();
let before = text[..at].trim_end();
if !before.ends_with("uniform")
|| before[..before.len() - 7]
.chars()
.next_back()
.is_some_and(is_ident)
{
continue;
}
let rest = &text[from..];
let ident = rest.trim_start();
let len = ident.chars().take_while(|c| is_ident(*c)).count();
if &ident[..len] != name {
continue;
}
let after = ident[len..].trim_start();
if after.starts_with(';') {
return Some(text.len() - after.len() + 1);
}
}
None
}
/// The samplers a call passes for `f`'s `sampler2D` parameters, in order.
fn chosen_samplers(f: &SamplerFn, args: &[&str]) -> Vec<String> {
f.samplers
.iter()
.filter_map(|(n, _)| args.get(*n).map(|a| a.trim().to_owned()))
.collect()
}
/// One pass per function with `sampler2D` parameters, until none is left or one can't be
/// followed (a caller passes something that is no sampler's name).
fn specialize_sampler_params(original: &str) -> String {
let mut text = original.to_owned();
// Bytes scanned for calls so far, against [`SCAN_BUDGET`].
let scanned = std::cell::Cell::new(0usize);
let spent = || scanned.get() > SCAN_BUDGET;
for _ in 0..16 {
let functions = sampler_functions(&text);
// From the outside in: no call inside another function still to be copied.
let inside_another = |f: &SamplerFn, at: usize| {
functions
.iter()
.any(|g| g.name != f.name && g.body.is_some() && (g.start..g.end).contains(&at))
};
let spans_of = |f: &SamplerFn| -> Vec<(usize, usize)> {
functions
.iter()
.filter(|g| g.name == f.name)
.map(|g| (g.start, g.end))
.collect()
};
let Some(f) = functions.iter().find(|f| {
f.body.is_some()
&& !spent()
&& calls(&scanned, &text, &f.name, &spans_of(f))
.iter()
.all(|(at, ..)| !inside_another(f, *at))
}) else {
if spent() {
break;
}
// None left to copy.
return text;
};
let spans = spans_of(f);
let found = calls(&scanned, &text, &f.name, &spans);
// Each call's samplers must be plain names (a global's, or a later pass's parameter).
let mut variants: Vec<Vec<String>> = Vec::new();
let mut call_starts: Vec<usize> = Vec::new();
let mut followable = true;
for (at, _, args) in &found {
let chosen = chosen_samplers(f, args);
if chosen.len() != f.samplers.len() || !chosen.iter().all(|a| is_name(a)) {
followable = false;
break;
}
call_starts.push(*at);
if !variants.contains(&chosen) {
variants.push(chosen);
}
if variants.len() > 64 {
followable = false;
break;
}
}
// Overloads (`tap(sampler2D, vec2)` and `tap(sampler2D, vec3)`) each keep their own
// body; a call picks its samplers by position, so they must sit at the same places.
let same_places = functions.iter().filter(|g| g.name == f.name).all(|g| {
g.samplers
.iter()
.map(|s| s.0)
.eq(f.samplers.iter().map(|s| s.0))
});
if !followable || !same_places || spent() {
break;
}
// A body may call another overload of itself with its own sampler (vanilla's
// `sampleNearest` calls the six-argument one): once its sampler parameters read a
// sampler, those calls name that copy too, and their sets join the variants.
let rewrite_own_calls = |body: &str| -> Option<(String, Vec<Vec<String>>)> {
let mut body = body.to_owned();
let mut found = Vec::new();
let mut starts: Vec<usize> = calls(&scanned, &body, &f.name, &[])
.iter()
.map(|c| c.0)
.collect();
starts.sort_unstable_by(|a, b| b.cmp(a));
for start in starts {
if spent() {
return None;
}
let (end, chosen, with) = {
let (_, end, args) = calls(&scanned, &body, &f.name, &[])
.into_iter()
.find(|c| c.0 == start)?;
let chosen = chosen_samplers(f, &args);
let rest: Vec<&str> = args
.iter()
.enumerate()
.filter(|(n, _)| !f.samplers.iter().any(|(s, _)| s == n))
.map(|(_, a)| a.trim())
.collect();
let with = format!("{}_hz_{}({})", f.name, chosen.join("_"), rest.join(", "));
(end, chosen, with)
};
if chosen.len() != f.samplers.len() || !chosen.iter().all(|a| is_name(a)) {
return None;
}
found.push(chosen);
body.replace_range(start..end, &with);
}
Some((body, found))
};
let specialize = |g: &SamplerFn, chosen: &[String]| {
let body = read_samplers(g.body.as_deref().unwrap_or_default(), &g.samplers, chosen);
rewrite_own_calls(&body)
};
let same: Vec<&SamplerFn> = functions.iter().filter(|g| g.name == f.name).collect();
let mut next = 0;
while followable && next < variants.len() {
let chosen = variants[next].clone();
for g in same.iter().filter(|g| g.body.is_some()) {
match specialize(g, &chosen) {
Some((_, inner)) => {
for v in inner {
if !variants.contains(&v) {
variants.push(v);
}
}
}
None => followable = false,
}
}
next += 1;
followable &= variants.len() <= 64 && !spent();
}
if !followable {
break;
}
// One copy of an overload per set of samplers its callers pass, keeping its other
// parameters; a prototype becomes the copies' prototypes.
let signature = |g: &SamplerFn, chosen: &[String]| {
let params: Vec<&str> = g
.params
.iter()
.enumerate()
.filter(|(n, _)| !g.samplers.iter().any(|(s, _)| s == n))
.map(|(_, p)| p.as_str())
.collect();
format!(
"{} {}_hz_{}({})",
g.return_type,
g.name,
chosen.join("_"),
params.join(", ")
)
};
let copies_of = |g: &SamplerFn| -> String {
variants
.iter()
.map(|chosen| match &g.body {
Some(_) => {
let body = specialize(g, chosen).map(|(b, _)| b).unwrap_or_default();
format!("{} {body}\n", signature(g, chosen))
}
None => format!("{};\n", signature(g, chosen)),
})
.collect()
};
// Rebuild from the back: the calls, then the function's own spans (each prototype and
// definition becomes its copies). A call nested in another's arguments starts after it,
// so it is rewritten first, and the call around it is read again from the text as it is
// then. A copy reads its samplers by name, so definitions above their samplers'
// declaration (vanilla's `sample_lightmap.glsl` is included above `uniform sampler2D
// Sampler2;`) move after it, together and in their order, as long as no call comes first.
let first_call = call_starts.iter().copied().min().unwrap_or(usize::MAX);
let declared = variants
.iter()
.flatten()
.filter_map(|s| sampler_declaration_end(&text, s))
.max();
let mut edits: Vec<(usize, Option<(usize, String)>)> =
call_starts.into_iter().map(|at| (at, None)).collect();
let (mut moved, mut move_to) = (String::new(), None);
for g in &same {
match (
&g.body,
declared.filter(|&end| end > g.start && end <= first_call),
) {
(Some(_), Some(end)) => {
edits.push((g.start, Some((g.end, String::new()))));
moved.push_str(&copies_of(g));
move_to = Some(end);
}
_ => edits.push((g.start, Some((g.end, copies_of(g))))),
}
}
if let Some(end) = move_to {
edits.push((end, Some((end, format!("\n{moved}")))));
}
// Pack text is untrusted: edits that overlap, or off the text, leave it as it was.
let mut spans: Vec<(usize, usize)> = edits
.iter()
.filter_map(|(s, e)| e.as_ref().map(|(end, _)| (*s, *end)))
.collect();
spans.sort_unstable();
if spans.windows(2).any(|w| w[1].0 < w[0].1) {
break;
}
edits.sort_by_key(|(start, _)| std::cmp::Reverse(*start));
let mut sound = true;
for (start, edit) in edits {
let (end, with) = match edit {
Some(span) => span,
None => {
let found = calls(&scanned, &text, &f.name, &[])
.into_iter()
.find(|c| c.0 == start);
let Some((_, end, args)) = found else {
continue;
};
let rest: Vec<&str> = args
.iter()
.enumerate()
.filter(|(n, _)| !f.samplers.iter().any(|(s, _)| s == n))
.map(|(_, a)| a.trim())
.collect();
let copy = format!("{}_hz_{}", f.name, chosen_samplers(f, &args).join("_"));
(end, format!("{copy}({})", rest.join(", ")))
}
};
if start > end || text.get(start..end).is_none() || spent() {
sound = false;
break;
}
text.replace_range(start..end, &with);
}
if !sound {
break;
}
}
// One that couldn't be followed: the shader as written, never half rewritten.
original.to_owned()
}
/// Each array declared without a size that an initializer sizes: `T[] name = T[](a, b)` and
/// `T name[] = T[](a, b)` (or `T[2](...)`) are written with the size.
fn size_unsized_arrays(text: &str) -> String {
let mut text = text.to_owned();
let mut closing = closings(&text);
// Each array sized reads the text's brackets again: a few hundred are plenty.
let mut left = 256;
let mut from = 0;
while let Some(found) = text[from..].find('=') {
let eq = from + found;
from = eq + 1;
// `==`, `<=`, `>=`, `!=`, `+=` ... are no initializers.
let prev = text[..eq].chars().next_back();
let next = text[eq + 1..].chars().next();
if matches!(
prev,
Some('=' | '<' | '>' | '!' | '+' | '-' | '*' | '/' | '%' | '&' | '|' | '^')
) || next == Some('=')
{
continue;
}
// The constructor: `T [ n? ] (`.
let rest = &text[eq + 1..];
let ty_start = eq + 1 + (rest.len() - rest.trim_start().len());
let ty_len = text[ty_start..]
.chars()
.take_while(|c| is_ident(*c))
.count();
if ty_len == 0 {
continue;
}
let after_ty = &text[ty_start + ty_len..];
let bracket = ty_start + ty_len + (after_ty.len() - after_ty.trim_start().len());
if !text[bracket..].starts_with('[') {
continue;
}
let Some(bracket_end) = closing[bracket] else {
continue;
};
let after_bracket = &text[bracket_end + 1..];
let paren = bracket_end + 1 + (after_bracket.len() - after_bracket.trim_start().len());
if !text[paren..].starts_with('(') {
continue;
}
let Some(paren_end) = closing[paren] else {
continue;
};
// The declarator: `name []` or `T [] name`, between the statement's start and `=`.
// (Looked for on the line, at most 256 bytes back, so a long line stays linear.)
let mut back = eq.saturating_sub(256);
while !text.is_char_boundary(back) {
back += 1;
}
let statement_start = text[back..eq]
.rfind([';', '{', '}', '\n'])
.map_or(back, |i| back + i + 1);
let declarator = &text[statement_start..eq];
let Some(empty) = declarator.rfind('[') else {
continue;
};
let inside = &declarator[empty + 1..];
let Some(close) = inside.find(']') else {
continue;
};
if !inside[..close].trim().is_empty() {
continue;
}
let size = split_top(&text[paren + 1..paren_end]).len().to_string();
// Sized in the constructor too, where it isn't.
if text[bracket + 1..bracket_end].trim().is_empty() {
text.replace_range(bracket + 1..bracket_end, &size);
}
let at = statement_start + empty + 1;
text.replace_range(at..at + close, &size);
// The declarator is before `=`, so `=` moved by what its size changed.
from = eq + 1 + size.len() - close;
closing = closings(&text);
left -= 1;
if left == 0 {
break;
}
}
text
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,291 @@
//! The rewrites on GLSL a pack writes, with the text each gives written out by hand.
use super::*;
#[test]
fn a_sampler_parameter_becomes_a_copy_per_sampler_passed() {
let text = "vec4 blur(sampler2D tex, vec2 uv);\n\
vec4 blur(sampler2D tex, vec2 uv) { return texture(tex, uv) + texture(tex, uv * 2.0); }\n\
void main() { vec4 a = blur(Sampler0, uv); vec4 b = blur( Sampler1 , uv + 1.0); vec4 c = blur(Sampler0, uv); }\n";
let out = specialize_sampler_params(text);
assert!(!out.contains("sampler2D"), "{out}");
assert!(out.contains(
"vec4 blur_hz_Sampler0(vec2 uv) { return texture(Sampler0, uv) + texture(Sampler0, uv * 2.0); }"
));
assert!(out.contains("vec4 blur_hz_Sampler1(vec2 uv)"));
assert!(out.contains("blur_hz_Sampler0(uv)"));
assert!(out.contains("blur_hz_Sampler1(uv + 1.0)"));
// One copy per sampler, not per call (and its prototype, from the prototype above).
assert_eq!(out.matches("vec4 blur_hz_Sampler0(vec2 uv) {").count(), 1);
assert_eq!(out.matches("vec4 blur_hz_Sampler0(vec2 uv);").count(), 1);
}
#[test]
fn a_sampler_passed_on_through_another_function_is_followed() {
let text = "vec4 inner(sampler2D t, vec2 uv) { return texture(t, uv); }\n\
vec4 outer(vec2 uv, sampler2D s) { return inner(s, uv); }\n\
void main() { vec4 a = outer(uv, Sampler2); }\n";
let out = specialize_sampler_params(text);
assert!(!out.contains("sampler2D"), "{out}");
assert!(out.contains("outer_hz_Sampler2(uv)"), "{out}");
assert!(out.contains("inner_hz_Sampler2(uv)"), "{out}");
}
/// A call in another call's arguments is rewritten before the call around it, which is
/// then read again: neither edit lands on the other's old text.
#[test]
fn a_call_nested_in_another_calls_arguments_is_rewritten_inside_out() {
let text = "vec2 tap(sampler2D s, vec2 uv) { return texture(s, uv).xy; }\n\
void main() { vec2 a = tap(Sampler0, tap(Sampler0, uv));\n\
vec2 b = tap( Sampler1 , tap( Sampler0 , uv ) ); }\n";
let out = specialize_sampler_params(text);
assert!(!out.contains("sampler2D") && !out.contains("tap("), "{out}");
assert!(
out.contains("vec2 a = tap_hz_Sampler0(tap_hz_Sampler0(uv));"),
"{out}"
);
assert!(
out.contains("vec2 b = tap_hz_Sampler1(tap_hz_Sampler0(uv)); }"),
"{out}"
);
assert!(out.contains("vec2 tap_hz_Sampler0(vec2 uv) { return texture(Sampler0, uv).xy; }"));
assert!(out.contains("vec2 tap_hz_Sampler1(vec2 uv) { return texture(Sampler1, uv).xy; }"));
}
/// Overloads that differ past their sampler each keep their own body in their copies.
#[test]
fn overloads_with_a_sampler_each_keep_their_own_body() {
let text = "vec4 tap(sampler2D s, vec2 p) { return texture(s, p); }\n\
vec4 tap(sampler2D s, vec3 p) { return texture(s, p.xy / p.z); }\n\
void main() { vec4 a = tap(Sampler0, uv); vec4 b = tap(Sampler0, vec3(uv, 2.0)); }\n";
let out = specialize_sampler_params(text);
assert!(!out.contains("sampler2D"), "{out}");
assert!(
out.contains("vec4 tap_hz_Sampler0(vec2 p) { return texture(Sampler0, p); }"),
"{out}"
);
assert!(
out.contains("vec4 tap_hz_Sampler0(vec3 p) { return texture(Sampler0, p.xy / p.z); }"),
"{out}"
);
assert_eq!(out.matches("vec4 tap_hz_Sampler0(").count(), 2, "{out}");
assert!(out.contains("vec4 a = tap_hz_Sampler0(uv);"), "{out}");
assert!(
out.contains("vec4 b = tap_hz_Sampler0(vec3(uv, 2.0));"),
"{out}"
);
}
/// A copy reads its samplers by name, so it goes after a sampler declared below the helper
/// (vanilla's world `text.vsh` includes `sample_lightmap.glsl` above `Sampler2`).
#[test]
fn a_copy_goes_after_its_samplers_declaration() {
let text = "vec4 tap(sampler2D s, vec2 uv) { return texture(s, uv); }\n\
uniform sampler2D Sampler2;\n\
void main() { vec4 c = tap(Sampler2, vec2(0.5)); }\n";
let out = specialize_sampler_params(text);
let declared = out
.find("uniform sampler2D Sampler2;")
.expect("declaration kept");
let copy = out
.find("vec4 tap_hz_Sampler2(vec2 uv)")
.expect("copy written");
let call = out
.find("tap_hz_Sampler2(vec2(0.5))")
.expect("call rewritten");
assert!(declared < copy && copy < call, "{out}");
assert_eq!(out.matches("tap_hz_Sampler2(vec2 uv)").count(), 1, "{out}");
assert!(!out.contains("tap(sampler2D"), "{out}");
}
/// A pack's text is untrusted: a sampler declared inside the helper itself (and text that
/// isn't ASCII) gives edits that would overlap, so the shader is left as it is for the
/// translator to refuse: never a panic, never a half-rewritten shader.
#[test]
fn a_sampler_declared_inside_the_helper_never_panics() {
let text = "vec4 tap(sampler2D s, vec2 uv) { é uniform sampler2D Sampler0; x return texture(s, uv); ééé }\n\
void main() { fragColor = tap(Sampler0, vec2(0.0)); }\n";
let out = specialize_sampler_params(text);
assert_eq!(out, text);
}
/// Vanilla's `texture_sampling.glsl` shape: one overload calls the other with its own
/// sampler. That call is rewritten too, and both copies come after the sampler's declaration
/// in their own order, the called one first.
#[test]
fn an_overload_passing_its_sampler_to_another_is_followed() {
let text = "vec4 sampleNearest(sampler2D source, vec2 uv, vec2 pixelSize, vec2 du, vec2 dv, vec2 texelSize) { return texture(source, uv + du + dv); }\n\
vec4 sampleNearest(sampler2D source, vec2 uv, vec2 pixelSize) { return sampleNearest(source, uv, pixelSize, dFdx(uv), dFdy(uv), pixelSize); }\n\
uniform sampler2D Sampler0;\n\
void main() { vec4 c = sampleNearest(Sampler0, uv, px); }\n";
let out = specialize_sampler_params(text);
assert!(
!out.contains("sampleNearest(") && !out.contains("sampler2D source"),
"{out}"
);
let declared = out
.find("uniform sampler2D Sampler0;")
.expect("declaration");
let six = out
.find("vec4 sampleNearest_hz_Sampler0(vec2 uv, vec2 pixelSize, vec2 du, vec2 dv, vec2 texelSize)")
.expect("six-argument copy");
let three = out
.find("vec4 sampleNearest_hz_Sampler0(vec2 uv, vec2 pixelSize) { return sampleNearest_hz_Sampler0(uv, pixelSize, dFdx(uv), dFdy(uv), pixelSize); }")
.expect("three-argument copy calling the six-argument one");
let call = out
.find("vec4 c = sampleNearest_hz_Sampler0(uv, px);")
.expect("call");
assert!(declared < six && six < three && three < call, "{out}");
}
/// A prototype above `main` with the definition below it becomes the copies' prototypes, so
/// the call still has a declaration above it.
#[test]
fn a_prototype_becomes_the_copies_prototypes() {
let text = "uniform sampler2D Sampler0;\n\
vec4 tap(sampler2D s, vec2 uv);\n\
void main() { vec4 c = tap(Sampler0, uv); }\n\
vec4 tap(sampler2D s, vec2 uv) { return texture(s, uv); }\n";
let out = specialize_sampler_params(text);
let prototype = out
.find("vec4 tap_hz_Sampler0(vec2 uv);")
.expect("prototype copy");
let call = out.find("tap_hz_Sampler0(uv); }").expect("call");
let definition = out
.find("vec4 tap_hz_Sampler0(vec2 uv) { return texture(Sampler0, uv); }")
.expect("definition copy");
assert!(prototype < call && call < definition, "{out}");
}
/// Parameters named like the samplers they are passed, swapped, still read their own; and a
/// parameter `s` leaves the swizzle `c.s` alone.
#[test]
fn sampler_parameters_are_replaced_at_once_and_not_as_members() {
let text = "uniform sampler2D Sampler0;\nuniform sampler2D Sampler1;\n\
vec4 mix2(sampler2D Sampler0, sampler2D Sampler1, vec2 uv) { return texture(Sampler0, uv) - texture(Sampler1, uv); }\n\
vec4 swz(sampler2D s, vec2 uv) { vec4 c = texture(s, uv); return vec4(c.s, c.t, 0.0, 1.0); }\n\
void main() { vec4 a = mix2(Sampler1, Sampler0, uv) + swz(Sampler0, uv); }\n";
let out = specialize_sampler_params(text);
assert!(
out.contains("{ return texture(Sampler1, uv) - texture(Sampler0, uv); }"),
"{out}"
);
assert!(
out.contains("{ vec4 c = texture(Sampler0, uv); return vec4(c.s, c.t, 0.0, 1.0); }"),
"{out}"
);
}
#[test]
fn a_parameter_matched_twice_in_one_identifier_stays_whole() {
assert_eq!(replace_ident("c.ss + ss * s", "s", "t"), "c.ss + ss * t");
assert_eq!(replace_ident("brightness(s)", "s", "t"), "brightness(t)");
}
/// `fsh` translated beside a vertex shader that passes its position on.
fn translate(fsh: &str) -> Result<crate::core_shader::CoreProgram, String> {
let vsh = "#version 330\nin vec3 Position;\nout vec2 texCoord0;\n\
void main() {\n gl_Position = vec4(Position, 1.0);\n texCoord0 = Position.xy;\n}\n";
let glue = crate::core_shader::Glue {
inputs: &[("Position", "vec3")],
prelude: "layout(location = 0) in vec3 hz_position;",
setup: "Position = hz_position;",
finish: "",
vertex_id: "int(gl_VertexIndex)",
};
crate::core_shader::translate_core(vsh, fsh, &[], &|_| None, &glue)
}
/// #9386 end to end: naga refuses a helper taking the sampler it reads, as GLSL vanilla's GL
/// compiler takes it; the pack's shader translates, the helper a copy reading `Sampler0`.
#[test]
fn a_helper_taking_its_sampler_translates() {
let fsh = "#version 330\nuniform sampler2D Sampler0;\nin vec2 texCoord0;\nout vec4 fragColor;\n\
vec4 tap(sampler2D tex, vec2 at, float w) { return texture(tex, at) * w; }\n\
void main() {\n fragColor = tap(Sampler0, texCoord0, 0.5) + tap(Sampler0, texCoord0 + 0.01, 0.5);\n}\n";
let program = translate(fsh).unwrap();
let names: Vec<_> = program
.fragment
.functions
.iter()
.filter_map(|(_, f)| f.name.as_deref())
.collect();
assert!(names.contains(&"tap_hz_Sampler0"), "{names:?}");
assert!(!names.contains(&"tap"), "{names:?}");
}
/// A sampler parameter with no name used to hand `replace_ident` an empty name to replace,
/// which it found at the same place forever, growing the text until the game ran out of memory.
#[test]
fn a_sampler_parameter_without_a_name_is_left_as_it_was() {
let text = "uniform sampler2D Sampler0;\n\
vec4 tap(sampler2D ;, vec2 uv) { return texture(Sampler0, uv); }\n\
void main() { vec4 c = tap(Sampler0, uv); }\n";
assert_eq!(fixups(text), text);
assert_eq!(replace_ident("abc", "", "x"), "abc");
}
/// Each call rewritten reads the whole text again: thousands of them stop at the scan budget,
/// the shader left as it was (never half rewritten) instead of freezing the load.
#[test]
fn thousands_of_calls_are_left_as_they_were() {
let shader = format!(
"uniform sampler2D S;\nvec4 t(sampler2D s) {{ return texture(s, vec2(0.0)); }}\n\
void main() {{ vec4 c; {}}}\n",
"c = t(S);".repeat(5000)
);
assert_eq!(fixups(&shader), shader);
// A few of them are rewritten.
let few = shader.replace(&"c = t(S);".repeat(5000), &"c = t(S);".repeat(3));
assert_eq!(
fixups(&few).matches("c = t_hz_S(").count(),
3,
"{}",
fixups(&few)
);
}
#[test]
fn unsized_arrays_take_their_initializers_size() {
let text = "const float[] w = float[](0.25, 0.5, 0.25);\n\
const vec2 offsets[] = vec2[](vec2(1.0, 0.0), vec2(0.0, 1.0));\n\
float k[] = float[4](1.0, 2.0, 3.0, 4.0);\n\
bool same = a == b;\n";
let out = size_unsized_arrays(text);
assert!(
out.contains("const float[3] w = float[3](0.25, 0.5, 0.25);"),
"{out}"
);
assert!(
out.contains("const vec2 offsets[2] = vec2[2](vec2(1.0, 0.0), vec2(0.0, 1.0));"),
"{out}"
);
assert!(
out.contains("float k[4] = float[4](1.0, 2.0, 3.0, 4.0);"),
"{out}"
);
assert!(out.contains("bool same = a == b;"));
}
#[test]
fn a_wide_character_after_a_sized_array_never_panics() {
// Ten values make the size two digits, which moves the `=` two bytes right: the scan
// went on from where it used to be, inside the `é`.
let text = "float w[]\u{e9}= float[](0.,1.,2.,3.,4.,5.,6.,7.,8.,9.);\n";
assert_eq!(
size_unsized_arrays(text),
"float w[10]\u{e9}= float[10](0.,1.,2.,3.,4.,5.,6.,7.,8.,9.);\n"
);
}
/// #9386 end to end: naga refuses an array sized by its initializer only.
#[test]
fn an_array_sized_by_its_initializer_translates() {
let fsh = "#version 330\nuniform sampler2D Sampler0;\nin vec2 texCoord0;\nout vec4 fragColor;\n\
const float[] weights = float[](0.25, 0.5, 0.25);\n\
void main() {\n vec4 sum = vec4(0.0);\n\
for (int i = 0; i < 3; i++) {\n sum += texture(Sampler0, texCoord0 + float(i) * 0.01) * weights[i];\n }\n\
fragColor = sum;\n}\n";
let program = translate(fsh);
assert!(program.is_ok(), "{:?}", program.err());
}
+19 -6
View File
@@ -439,20 +439,33 @@ fn deliver(home: &Path, report: Report) -> Option<PathBuf> {
}
}
/// The report for a hung server tick, written by the watchdog thread
/// (`ServerWatchdog`): the stuck thread's stack is not available to Rust, so
/// the report carries none.
pub fn report_watchdog(home: &Path, side: &'static str, message: String) -> Option<PathBuf> {
/// The report for a hung server tick, written by the watchdog thread (`ServerWatchdog`): the
/// stack is the server thread's, read from the watchdog (`horizon_threads::stack_dump`), as
/// vanilla's report carries the server thread's stack; without it, the details say why.
pub fn report_watchdog(
home: &Path,
side: &'static str,
message: String,
server_stack: Result<String, horizon_threads::stack_dump::NoStack>,
) -> Option<PathBuf> {
let mut details = system_details(side);
let stack = match server_stack {
Ok(stack) => stack_lines(&stack, false),
Err(why) => {
details.push(("Server thread stack".into(), why.to_string()));
String::new()
}
};
deliver(
home,
Report {
time: Local::now(),
description: "Watching Server".into(),
error: format!("{message} (Watching Server)"),
stack: String::new(),
stack,
thread: thread_name(side),
side,
details: system_details(side),
details,
log: crate::logging::recent_lines(),
},
)
@@ -1,6 +1,7 @@
//! Data packs (`<world>/datapacks`: folders and zips with `pack.mcmeta`) and
//! the parts of them commands use: `.mcfunction` functions (with macros and
//! line continuation), function tags and predicates.
mod slot_sources;
use crate::zip::Zip;
use horizon_world::{
nbt::Tag,
@@ -663,6 +664,8 @@ pub struct DataStore {
pub item_modifiers: BTreeMap<String, serde_json::Value>,
/// `slot_source` definitions, evaluated against a command's live accessor.
pub slot_sources: BTreeMap<String, serde_json::Value>,
/// Slot-source tags belong to the same loaded snapshot as their definitions.
pub slot_source_tags: BTreeMap<String, Vec<(String, bool)>>,
/// The content tables' raw files from the packs after the built-in one:
/// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `dialog/`, `damage_type/`, `data_maps/`, `enchantment/` and `tags/…/` files, by
/// `<kind-dir>/<ns>:<path>`, each id's files in pack order. The tables
@@ -759,6 +762,16 @@ impl DataStore {
&& let Ok(v) = serde_json::from_slice(&bytes)
{
s.item_modifiers.insert(id, v);
} else if let Some(id) = resource_id(&f, "tags/slot_source", ".json")
&& let Some(bytes) = pack.read(&f)
&& let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes)
{
let entries = tag_entries(&v);
let slot = s.slot_source_tags.entry(id).or_default();
if v["replace"].as_bool() == Some(true) {
slot.clear();
}
slot.extend(entries);
} else if let Some(id) = resource_id(&f, "slot_source", ".json")
&& let Some(bytes) = pack.read(&f)
&& let Ok(v) = serde_json::from_slice(&bytes)
@@ -0,0 +1,86 @@
//! Slot-source holder-set tags share DataStore's pack ordering and snapshot.
use super::DataStore;
use std::collections::BTreeSet;
impl DataStore {
/// TagLoader's required-member validation and first-seen membership order.
pub fn slot_source_tag(&self, id: &str) -> Result<Vec<String>, String> {
fn visit(
store: &DataStore,
id: &str,
active: &mut BTreeSet<String>,
) -> Result<Vec<String>, String> {
if !active.insert(id.to_owned()) {
return Err(format!("Recursive slot-source tag #{id}"));
}
let result = (|| {
let entries = store
.slot_source_tags
.get(id)
.ok_or_else(|| format!("Unknown slot-source tag #{id}"))?;
let mut out = Vec::new();
for (entry, required) in entries {
let values = if let Some(tag) = entry.strip_prefix('#') {
visit(store, tag, active)
} else if store.slot_sources.contains_key(entry) {
Ok(vec![entry.clone()])
} else {
Err(format!("Unknown slot source {entry}"))
};
match values {
Ok(values) => {
for value in values {
if !out.contains(&value) {
out.push(value);
}
}
}
Err(error) if *required => return Err(error),
Err(_) => {}
}
}
Ok(out)
})();
active.remove(id);
result
}
visit(self, id, &mut BTreeSet::new())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn holder_set_tags_keep_order_required_members_and_snapshot_authority() {
let mut store = DataStore::default();
for name in ["t:a", "t:b"] {
store.slot_sources.insert(
name.into(),
serde_json::json!({"type":"slot_range","slots":"container.0"}),
);
}
store.slot_source_tags.insert(
"t:child".into(),
vec![("t:b".into(), true), ("t:a".into(), true)],
);
store.slot_source_tags.insert(
"t:all".into(),
vec![
("t:a".into(), true),
("#t:child".into(), true),
("t:absent".into(), false),
],
);
assert_eq!(store.slot_source_tag("t:all").unwrap(), ["t:a", "t:b"]);
store
.slot_source_tags
.get_mut("t:all")
.unwrap()
.push(("t:absent".into(), true));
assert!(store.slot_source_tag("t:all").is_err());
store
.slot_source_tags
.insert("t:cycle".into(), vec![("#t:cycle".into(), true)]);
assert!(store.slot_source_tag("t:cycle").is_err());
}
}
@@ -0,0 +1,241 @@
//! Dynamic Resolution: the world's resolution scale follows the GPU's frame time toward a target
//! frame rate, between the Resolution Scale setting (the floor) and 100%.
//!
//! The setting is the lowest scale the player accepts; where the GPU has room for more, the
//! scale rises toward 100% a step at a time, and falls back toward the floor where frames take
//! longer than the target allows. A verdict is the median GPU time of a window of frames, which
//! a few slow frames (a chunk load) don't move, and a step up is taken only when the frame would
//! still fit if its cost grew with the pixels: most of a frame doesn't, so it errs toward
//! staying.
//!
//! Each change has a price: new render targets, and history that starts over (TAA, and a
//! shader pack's own: its denoised light, reflections and accumulation). So a shader pack gets
//! the gentle pace (long windows, big steps, a long rest after each change): walking from a
//! heavy scene to a light one changes the scale once, not every few frames. Without a pack the
//! quick pace follows the scene within a second.
//!
//! Nothing here draws or reads the GPU: the caller feeds it the frames and applies the scale it
//! answers.
/// How fast the scale follows the frame.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Pace {
/// A shader pack: its history is expensive to start over.
Gentle,
/// Horizon's own renderer.
Quick,
}
impl Pace {
/// Milliseconds of frames in one verdict.
fn window_ms(self) -> f64 {
match self {
Pace::Gentle => 3_000.0,
Pace::Quick => 500.0,
}
}
/// Milliseconds of frames after a change before the next verdict counts, while the new
/// targets and the restarted history settle.
fn settle_ms(self) -> f64 {
match self {
Pace::Gentle => 8_000.0,
Pace::Quick => 1_000.0,
}
}
/// Percentage points per change.
fn step(self) -> u32 {
match self {
Pace::Gentle => 10,
Pace::Quick => 5,
}
}
}
/// The frame is too slow when its GPU time passes the target's frame time by this share.
const DOWN_ABOVE: f32 = 1.05;
/// A step up is taken when the frame, its cost grown with the pixels, stays under this share.
const UP_BELOW: f32 = 0.95;
/// Timed frames a window needs for a verdict (a GPU without timestamps gives none).
const MIN_TIMED: usize = 8;
/// The scale in use and what it has measured since the last verdict.
#[derive(Clone, Debug, Default)]
pub struct DynamicResolution {
gpu: Vec<f32>,
window_ms: f64,
since_change_ms: f64,
/// The scale in use, in percent; `None` until the first change (the floor).
scale: Option<u32>,
}
impl DynamicResolution {
pub fn new() -> Self {
Self::default()
}
/// Back to the floor, with nothing measured (turned off, or the world changed).
pub fn reset(&mut self) {
*self = Self::new();
}
/// The scale in use, in percent, for a Resolution Scale of `floor`: never below it, never
/// above 100% unless the floor itself is (then it is the floor).
pub fn scale(&self, floor: u32) -> u32 {
self.scale.unwrap_or(floor).clamp(floor, floor.max(100))
}
/// One frame: its GPU time where its timestamps have arrived, and its whole time, in
/// milliseconds. Answers the new scale, in percent, when this frame completed a window that
/// changed it.
pub fn observe(
&mut self,
gpu_ms: Option<f32>,
frame_ms: f32,
floor: u32,
target_fps: u32,
pace: Pace,
) -> Option<u32> {
self.window_ms += f64::from(frame_ms);
self.since_change_ms += f64::from(frame_ms);
if let Some(gpu) = gpu_ms {
self.gpu.push(gpu);
}
// A window lasts its time and holds enough timed frames: a slow scene (or timestamps that
// arrive with gaps) keeps gathering past the time rather than starting over with too few,
// which would never judge the scenes that most need a lower scale. A GPU that can't be
// timed gathers nothing and so leaves the scale alone.
if self.window_ms < pace.window_ms() || self.gpu.len() < MIN_TIMED {
return None;
}
self.window_ms = 0.0;
let timed = std::mem::take(&mut self.gpu);
// At or above 100% there is nothing to vary; a change still settling isn't judged yet.
if floor >= 100 || self.since_change_ms < pace.settle_ms() {
return None;
}
let gpu = median(timed);
let target = 1000.0 / target_fps.max(1) as f32;
let now = self.scale(floor);
let next = if gpu > target * DOWN_ABOVE && now > floor {
now.saturating_sub(pace.step()).max(floor)
} else if now < 100 {
let up = (now + pace.step()).min(100);
let grown = (up as f32 / now as f32).powi(2);
if gpu * grown < target * UP_BELOW {
up
} else {
now
}
} else {
now
};
if next == now {
return None;
}
self.scale = Some(next);
self.since_change_ms = 0.0;
Some(next)
}
}
fn median(mut values: Vec<f32>) -> f32 {
values.sort_by(f32::total_cmp);
values[values.len() / 2]
}
#[cfg(test)]
mod tests {
use super::*;
/// `ms` milliseconds of frames, each `gpu` long on the GPU and `frame` long in all; the last
/// answer.
fn run(
d: &mut DynamicResolution,
ms: f64,
gpu: f32,
frame: f32,
floor: u32,
pace: Pace,
) -> Option<u32> {
let mut answer = None;
let mut t = 0.0;
while t < ms {
if let Some(scale) = d.observe(Some(gpu), frame, floor, 60, pace) {
answer = Some(scale);
}
t += f64::from(frame);
}
answer
}
#[test]
fn it_starts_at_the_floor_and_climbs_while_there_is_room() {
let mut d = DynamicResolution::new();
assert_eq!(d.scale(70), 70);
// 8 ms of a 16.7 ms budget: room to grow, one step per window and settle.
assert_eq!(run(&mut d, 1_200.0, 8.0, 8.0, 70, Pace::Quick), Some(75));
assert_eq!(run(&mut d, 30_000.0, 8.0, 8.0, 70, Pace::Quick), Some(100));
assert_eq!(d.scale(70), 100);
}
#[test]
fn a_slow_frame_steps_down_but_never_below_the_floor() {
let mut d = DynamicResolution::new();
run(&mut d, 30_000.0, 8.0, 8.0, 70, Pace::Quick);
assert_eq!(d.scale(70), 100);
// 25 ms against 16.7: down a step at a time, to the floor and no further.
run(&mut d, 60_000.0, 25.0, 25.0, 70, Pace::Quick);
assert_eq!(d.scale(70), 70);
}
#[test]
fn a_slow_scene_still_steps_down() {
let mut d = DynamicResolution::new();
run(&mut d, 30_000.0, 8.0, 8.0, 70, Pace::Quick);
assert_eq!(d.scale(70), 100);
// 10 FPS: five frames in a 500 ms window, fewer than a verdict needs; the window keeps
// gathering until it has them.
run(&mut d, 60_000.0, 100.0, 100.0, 70, Pace::Quick);
assert_eq!(d.scale(70), 70);
}
#[test]
fn a_frame_near_the_target_stays_put() {
let mut d = DynamicResolution::new();
// 15 ms: under the target, but a step up (75 -> 80%) would make it 17 ms.
assert_eq!(run(&mut d, 60_000.0, 15.0, 16.0, 75, Pace::Quick), None);
assert_eq!(d.scale(75), 75);
}
#[test]
fn the_gentle_pace_takes_big_steps_and_long_rests() {
let mut d = DynamicResolution::new();
// The first window (3 s) can't count yet: a change rests 8 s, and the first counts
// from the start.
assert_eq!(run(&mut d, 7_000.0, 8.0, 8.0, 70, Pace::Gentle), None);
assert_eq!(run(&mut d, 3_000.0, 8.0, 8.0, 70, Pace::Gentle), Some(80));
// Then nothing for the 8 s rest.
assert_eq!(run(&mut d, 7_000.0, 8.0, 8.0, 70, Pace::Gentle), None);
assert_eq!(d.scale(70), 80);
}
#[test]
fn nothing_changes_at_or_above_100_or_without_gpu_times() {
let mut d = DynamicResolution::new();
assert_eq!(run(&mut d, 30_000.0, 30.0, 30.0, 100, Pace::Quick), None);
assert_eq!(d.scale(120), 120);
let mut d = DynamicResolution::new();
for _ in 0..1_000 {
assert_eq!(d.observe(None, 8.0, 70, 60, Pace::Quick), None);
}
assert_eq!(d.scale(70), 70);
}
#[test]
fn reset_goes_back_to_the_floor() {
let mut d = DynamicResolution::new();
run(&mut d, 30_000.0, 8.0, 8.0, 70, Pace::Quick);
d.reset();
assert_eq!(d.scale(70), 70);
}
}
+15 -8
View File
@@ -155,7 +155,8 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
var back = c;
// Normal w: 0 keeps the baked colour, +-127 shades by the normal, and a small positive 1
// keeps the baked colour but culls back faces (entitySolid models such as the decorated pot).
// -126/127 shades like -127 and also culls (entityCutoutCull models such as the bat).
// -126/127 shades like -127 and also culls (entityCutoutCull models such as the bat, and the
// entitySolid decorated pot).
out.cull = select(0u, 1u, (normal.w > 0.0 && normal.w < 0.5) || (normal.w < 0.0 && normal.w > -0.996));
if abs(normal.w) >= 0.5 {
c = vec4(c.rgb * entity_light(normal.xyz, globals.time.y != 0.0), c.a);
@@ -293,10 +294,15 @@ fn entity_written_color(in: Out, color: vec4<f32>) -> vec4<f32> {
if in.overlay == 4u { mark = hdr_scene_alpha(globals.hdr_light.y); }
return vec4(hdr_written(color.rgb, mark, globals.hdr_settings, globals.hdr_shares), color.a);
}
return color;
if FRAME_LINEAR && (in.overlay == 11u || in.overlay == 12u) {
// The cracks scale what is under them by twice their colour (CRUMBLING_BLEND). Vanilla's
// product is in gamma, so under light it is (2c)^2.2: they give half of that.
return vec4(pow(2.0 * max(color.rgb, vec3(0.0)), vec3(2.2)) * 0.5, color.a);
}
return frame_stored(color);
}
@fragment fn fs_entity_unmarked(in: Out, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
return entity_color(in, front);
return frame_stored(entity_color(in, front));
}
fn entity_color(in: Out, front: bool) -> vec4<f32> {
var uv = in.uv;
@@ -354,7 +360,7 @@ fn entity_color(in: Out, front: bool) -> vec4<f32> {
// rendertype_entity_shadow.fsh: the shadow texture times the vertex colour,
// then fog, with no alpha cutout, so a faint shadow in dim light still draws.
@fragment fn fs_entity_shadow(in: Out) -> @location(0) vec4<f32> {
return shadow_color(in);
return frame_stored(shadow_color(in));
}
fn shadow_color(in: Out) -> vec4<f32> {
var color = textureSample(atlas, atlas_sampler, in.uv) * in.color;
@@ -447,7 +453,7 @@ fn celestial_color(in: Out) -> vec4<f32> {
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
return vec4(hdr_written(color.rgb, hdr_entity_mark(in), globals.hdr_settings, globals.hdr_shares), color.a);
}
return color;
return frame_stored(color);
}
// The sky's celestial pass (sun, moon, the End sky): fs_celestial's light,
@@ -468,7 +474,8 @@ fn celestial_color(in: Out) -> vec4<f32> {
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
return vec4(hdr_written(added, select(1.0, hdr_mark(tier, y), full), globals.hdr_settings, globals.hdr_shares), color.a);
}
if HDR_WRITE_LIGHT { return vec4(added, color.a); }
// Premultiplied after the decode: the light the texel adds, at its coverage.
if HDR_WRITE_LIGHT { return vec4(frame_stored(color).rgb * color.a, color.a); }
if !full { return vec4(added, 0.0); }
return vec4(added, 1.0 - hdr_mark(tier, y));
}
@@ -491,7 +498,7 @@ fn energy_swirl_fog(in: Out) -> f32 {
}
@fragment fn fs_energy_swirl(in: Out) -> @location(0) vec4<f32> {
let color = energy_swirl_base(in);
return vec4(mix(color.rgb, globals.fog_color.rgb, energy_swirl_fog(in)), color.a);
return frame_stored(vec4(mix(color.rgb, globals.fog_color.rgb, energy_swirl_fog(in)), color.a));
}
// RenderPipelines.WATER_MASK: depth only (the pipeline masks the colour off).
@@ -620,7 +627,7 @@ struct WeatherTime { values: vec4<f32> }
return out;
}
@fragment fn fs_weather(in: Out) -> @location(0) vec4<f32> {
return weather_color(in);
return frame_stored(weather_color(in));
}
fn weather_color(in: Out) -> vec4<f32> {
// Repeat within this sprite, never across the atlas. The atlas uses nearest
+9 -215
View File
@@ -166,24 +166,6 @@ pub struct Extras {
/// built-in track and leaving the off ones out (`musicPage`, off by
/// default: vanilla plays exactly as before).
pub music_page: bool,
/// Cosmetics (`horizon_cosmetics`, off unless enabled), the lines `horizon_cosmetics::prefs`
/// shares with the launcher's Wardrobe: Show my cosmetics (the account's, sent to the service;
/// on by default), how much of other players' to show (no line until chosen:
/// `effective_others`), and whether their trails and auras are hidden (off by default).
pub cosmetics_mine: bool,
pub cosmetics_others: Option<horizon_cosmetics::prefs::Others>,
pub cosmetics_hide_fx: bool,
/// My cosmetics in single player too (`cosmeticsSolo`, off by default): the window's own server
/// names the player by their offline profile, which the service doesn't know, so their own
/// cosmetics and name tag style show only with this on.
pub cosmetics_solo: bool,
/// Whether the signed-in account owns a cosmetic, as the service said (never saved; the game
/// sets it): with no choice made, others' cosmetics show only then.
pub cosmetics_owner: bool,
/// Horizon's mark before the names of other Horizon players (name tags, player list), and
/// whether the service may say this player plays Horizon: both on by default.
pub horizon_marks: bool,
pub horizon_listed: bool,
/// 3D skin layers (`threeDSkinLayers`, Options → Skin Customization): each
/// painted texel of a player's outer layer drawn as a slab
/// (`horizon_client::mobs::skin_layers`).
@@ -238,13 +220,6 @@ impl Default for Extras {
wheel_cycles_settings: false,
music_off: Vec::new(),
music_page: false,
cosmetics_mine: true,
cosmetics_others: None,
cosmetics_hide_fx: false,
cosmetics_solo: false,
cosmetics_owner: false,
horizon_marks: true,
horizon_listed: true,
three_d_skin_layers: false,
pvp: Default::default(),
}
@@ -254,11 +229,6 @@ fn bool_text(v: bool) -> &'static str {
if v { "true" } else { "false" }
}
impl Extras {
/// How much of other players' cosmetics shows: the player's choice, else all of it for an
/// account that owns a cosmetic and none for any other (a vanilla game).
pub fn effective_others(&self) -> horizon_cosmetics::prefs::Others {
horizon_cosmetics::prefs::Others::effective(self.cosmetics_others, self.cosmetics_owner)
}
pub fn load(dir: &Path) -> Self {
fs::read_to_string(dir.join(FILE))
.map(|t| Self::parse(&t))
@@ -353,15 +323,6 @@ impl Extras {
.collect()
}
"musicPage" => e.music_page = flag(e.music_page),
"cosmeticsMine" => e.cosmetics_mine = flag(e.cosmetics_mine),
"cosmeticsOthers" => {
e.cosmetics_others =
horizon_cosmetics::prefs::Others::parse(value).or(e.cosmetics_others)
}
"cosmeticsHideFx" => e.cosmetics_hide_fx = flag(e.cosmetics_hide_fx),
"cosmeticsSolo" => e.cosmetics_solo = flag(e.cosmetics_solo),
horizon_cosmetics::prefs::MARKS => e.horizon_marks = flag(e.horizon_marks),
horizon_cosmetics::prefs::LISTED => e.horizon_listed = flag(e.horizon_listed),
SKIN_LAYERS_KEY => e.three_d_skin_layers = flag(e.three_d_skin_layers),
key => {
e.pvp.parse_line(key, value);
@@ -372,7 +333,7 @@ impl Extras {
}
pub fn serialize(&self) -> String {
let mut text = format!(
"packBrowser:{}\npregen:{}\nfullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nlodZoomDetail:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\nreproKeys:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\nwheelCyclesListSettings:{}\nmusicOff:{}\nmusicPage:{}\ncosmeticsMine:{}\n{}cosmeticsHideFx:{}\ncosmeticsSolo:{}\n{}:{}\n{}:{}\n{}:{}\n",
"packBrowser:{}\npregen:{}\nfullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nlodZoomDetail:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\nreproKeys:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\nwheelCyclesListSettings:{}\nmusicOff:{}\nmusicPage:{}\n{}:{}\n",
bool_text(self.pack_browser),
bool_text(self.pregen),
bool_text(self.fullbright),
@@ -421,16 +382,6 @@ impl Extras {
bool_text(self.wheel_cycles_settings),
self.music_off.join(","),
bool_text(self.music_page),
bool_text(self.cosmetics_mine),
// Written once the player chose.
self.cosmetics_others
.map_or(String::new(), |o| format!("cosmeticsOthers:{}\n", o.name())),
bool_text(self.cosmetics_hide_fx),
bool_text(self.cosmetics_solo),
horizon_cosmetics::prefs::MARKS,
bool_text(self.horizon_marks),
horizon_cosmetics::prefs::LISTED,
bool_text(self.horizon_listed),
SKIN_LAYERS_KEY,
bool_text(self.three_d_skin_layers),
);
@@ -616,13 +567,6 @@ pub enum Item {
/// The Select Resource Packs screen's Add button (Modrinth).
PackBrowser,
Pregen,
/// Shown only while cosmetics are enabled (`horizon_cosmetics::enabled`).
CosmeticsMine,
CosmeticsOthers,
CosmeticsHideFx,
CosmeticsSolo,
HorizonMarks,
HorizonMe,
/// On the PvP & HUD page (`pvp::rows`).
Pvp(crate::pvp::Opt),
}
@@ -630,8 +574,10 @@ pub enum Item {
pub fn rows() -> Vec<Result<Item, &'static str>> {
use Item::*;
// Fullbright, Dynamic lights and Hide block-break particles change the picture: they
// are on the graphics screen's Quality page (`graphics_ui::rows`), not here.
let mut rows = vec![
// are on the graphics screen's Quality page (`graphics_ui::rows`), not here. The Cosmetics
// group comes last, from the cosmetics' own section (`pages::Place::Extras`) while they are
// offered.
vec![
Err("horizon.extras.header.third_person"),
Ok(OwnNameTag),
Err("horizon.extras.header.inventory"),
@@ -652,19 +598,7 @@ pub fn rows() -> Vec<Result<Item, &'static str>> {
Err("horizon.extras.header.menus"),
Ok(WheelCyclesSettings),
Ok(PackBrowser),
];
if horizon_cosmetics::enabled() {
rows.extend([
Err("horizon.extras.header.cosmetics"),
Ok(CosmeticsMine),
Ok(CosmeticsSolo),
Ok(CosmeticsOthers),
Ok(CosmeticsHideFx),
Ok(HorizonMarks),
Ok(HorizonMe),
]);
}
rows
]
}
impl Item {
/// The language key of the caption (`horizon.*`, assets/horizon/lang).
@@ -690,12 +624,6 @@ impl Item {
Item::WheelCyclesSettings => "horizon.extras.wheel_cycles_settings",
Item::PackBrowser => "horizon.extras.pack_browser",
Item::Pregen => "horizon.extras.pregen",
Item::CosmeticsMine => "horizon.extras.cosmetics_mine",
Item::CosmeticsOthers => "horizon.extras.cosmetics_others",
Item::CosmeticsHideFx => "horizon.extras.cosmetics_hide_fx",
Item::CosmeticsSolo => "horizon.extras.cosmetics_solo",
Item::HorizonMarks => "horizon.extras.horizon_marks",
Item::HorizonMe => "horizon.extras.horizon_me",
Item::Pvp(o) => o.caption_key(),
}
}
@@ -722,12 +650,6 @@ impl Item {
Item::WheelCyclesSettings => "horizon.extras.wheel_cycles_list_settings.tooltip",
Item::PackBrowser => "horizon.extras.pack_browser.tooltip",
Item::Pregen => "horizon.extras.pregen.tooltip",
Item::CosmeticsMine => "horizon.extras.cosmetics_mine.tooltip",
Item::CosmeticsOthers => "horizon.extras.cosmetics_others.tooltip",
Item::CosmeticsHideFx => "horizon.extras.cosmetics_hide_fx.tooltip",
Item::CosmeticsSolo => "horizon.extras.cosmetics_solo.tooltip",
Item::HorizonMarks => "horizon.extras.horizon_marks.tooltip",
Item::HorizonMe => "horizon.extras.horizon_me.tooltip",
Item::Pvp(o) => o.description_key(),
}
}
@@ -763,12 +685,6 @@ impl Item {
Item::WheelCyclesSettings => "Scroll wheel changes settings",
Item::PackBrowser => "Resource pack browser",
Item::Pregen => "Chunk pregeneration",
Item::CosmeticsMine => "My cosmetics",
Item::CosmeticsOthers => "Other players' cosmetics",
Item::CosmeticsHideFx => "Hide other players' trails and auras",
Item::CosmeticsSolo => "In single player",
Item::HorizonMarks => "Horizon logos",
Item::HorizonMe => "Show me as Horizon",
Item::Pvp(o) => o.caption(),
}
}
@@ -783,8 +699,7 @@ impl Item {
Pvp(o) => return o.parent().map(Pvp),
Fullbright | DynamicLights | OwnNameTag | HideBlockParticles | MouseTweaks | Sort
| AutoReconnect | NoAi | AgentControl | ReproKeys | Splitscreen | ChatAnimation
| WheelCyclesSettings | MusicPage | PackBrowser | CosmeticsMine | CosmeticsOthers
| CosmeticsHideFx | CosmeticsSolo | HorizonMarks | HorizonMe | Pregen => {
| WheelCyclesSettings | MusicPage | PackBrowser | Pregen => {
return None;
}
})
@@ -842,24 +757,6 @@ impl Item {
Item::ReproKeys => {
"F3+R starts or stops a repro recording and F3+E writes its bundle. Off by default: a recording copies the world."
}
Item::CosmeticsMine => {
"Hidden: every Horizon player sees you without your cosmetics, you included. The same switch as the launcher's Wardrobe."
}
Item::CosmeticsOthers => {
"What other Horizon players wear: everything, only their name tag styles, or nothing. Hidden unless you own cosmetics, until you choose. Hidden, the game never asks what they wear."
}
Item::CosmeticsHideFx => {
"Other players' trails and auras stay hidden; their other cosmetics still show. Off by default."
}
Item::CosmeticsSolo => {
"Show your cosmetics and your name tag's badge in your single-player worlds too (your name tag with Own name tag). Off: only on servers."
}
Item::HorizonMarks => {
"Show the Horizon logo next to Horizon players: a small logo before the names of other players who use Horizon, above their heads and in the player list. Only Horizon players see it: servers receive nothing."
}
Item::HorizonMe => {
"Show me as a Horizon player: other Horizon players see the logo by your name. On, the service answers anyone who asks about you that you played Horizon in the last 3 hours; off, it never reports you."
}
Item::Pvp(o) => o.description(),
Item::WheelCyclesSettings => {
"The scroll wheel over a button in the settings changes its value, as in vanilla. Off, the wheel only scrolls the list."
@@ -886,12 +783,6 @@ impl Item {
| Item::Splitscreen
| Item::WheelCyclesSettings
| Item::PackBrowser => Some(Cost::None),
Item::CosmeticsMine
| Item::CosmeticsOthers
| Item::CosmeticsHideFx
| Item::CosmeticsSolo
| Item::HorizonMarks
| Item::HorizonMe => Some(Cost::Low),
_ => None,
}
}
@@ -917,10 +808,6 @@ impl Item {
Item::WheelCyclesSettings => e.wheel_cycles_settings,
Item::PackBrowser => e.pack_browser,
Item::Pregen => e.pregen,
Item::CosmeticsHideFx => e.cosmetics_hide_fx,
Item::CosmeticsSolo => e.cosmetics_solo,
Item::HorizonMarks => e.horizon_marks,
Item::HorizonMe => e.horizon_listed,
Item::Pvp(o) => return o.switch(&e.pvp),
_ => return None,
})
@@ -930,30 +817,14 @@ impl Item {
match self {
Item::SortKey => crate::keybinds::display_name(&e.sort_key).plain(),
Item::Pvp(o) if o.switch(&e.pvp).is_none() => o.value(&e.pvp).unwrap_or_default(),
Item::CosmeticsMine => match e.cosmetics_mine {
true => crate::lang::get_or("horizon.extras.cosmetics.shown", "Shown"),
false => crate::lang::get_or("horizon.extras.cosmetics.hidden", "Hidden"),
},
Item::CosmeticsOthers => {
use horizon_cosmetics::prefs::Others;
match e.effective_others() {
Others::Shown => crate::lang::get_or("horizon.extras.cosmetics.shown", "Shown"),
Others::NameTags => {
crate::lang::get_or("horizon.extras.cosmetics.name_tags", "Name tags only")
}
Others::Hidden => {
crate::lang::get_or("horizon.extras.cosmetics.hidden", "Hidden")
}
}
}
other => match other.switch(e) {
Some(true) => crate::lang::get_or("On", "On"),
_ => crate::lang::get_or("Off", "Off"),
},
}
}
/// A click (`back` for the right button: it turns the three-way cosmetics button the other
/// way; every other item left is a switch, so both flip it). The sort key binds instead.
/// A click (`back` for the right button: a PvP & HUD choice steps back; a switch flips
/// either way). The sort key binds instead.
pub fn cycle(self, e: &mut Extras, back: bool) {
match self {
Item::Fullbright => e.fullbright = !e.fullbright,
@@ -975,16 +846,6 @@ impl Item {
Item::WheelCyclesSettings => e.wheel_cycles_settings = !e.wheel_cycles_settings,
Item::PackBrowser => e.pack_browser = !e.pack_browser,
Item::Pregen => e.pregen = !e.pregen,
Item::CosmeticsMine => e.cosmetics_mine = !e.cosmetics_mine,
Item::CosmeticsOthers => {
// From what the button shows; the result is the player's choice from now on.
let o = e.effective_others();
e.cosmetics_others = Some(if back { o.next().next() } else { o.next() });
}
Item::CosmeticsHideFx => e.cosmetics_hide_fx = !e.cosmetics_hide_fx,
Item::CosmeticsSolo => e.cosmetics_solo = !e.cosmetics_solo,
Item::HorizonMarks => e.horizon_marks = !e.horizon_marks,
Item::HorizonMe => e.horizon_listed = !e.horizon_listed,
Item::Pvp(o) => o.cycle(&mut e.pvp, back),
Item::SortKey => {}
}
@@ -1003,73 +864,6 @@ impl Item {
mod tests {
use super::*;
#[test]
fn other_players_cosmetics_cycle_three_ways_and_read_the_old_switch() {
use horizon_cosmetics::prefs::Others;
let mut e = Extras {
cosmetics_owner: true,
..Extras::default()
};
assert_eq!(
(e.effective_others(), e.cosmetics_hide_fx, e.cosmetics_mine),
(Others::Shown, false, true)
);
Item::CosmeticsOthers.cycle(&mut e, false);
assert_eq!(e.cosmetics_others, Some(Others::NameTags));
Item::CosmeticsOthers.cycle(&mut e, false);
assert_eq!(e.cosmetics_others, Some(Others::Hidden));
// The right button goes back.
Item::CosmeticsOthers.cycle(&mut e, true);
assert_eq!(e.cosmetics_others, Some(Others::NameTags));
// Ownership is the service's, never written.
assert_eq!(
Extras::parse(&e.serialize()),
Extras {
cosmetics_owner: false,
..e.clone()
}
);
// The launcher's Wardrobe writes the same lines.
assert_eq!(
horizon_cosmetics::prefs::Prefs::parse(&e.serialize()).others,
Some(Others::NameTags)
);
// Before this, the line was on or off.
assert_eq!(
Extras::parse("cosmeticsOthers:false").cosmetics_others,
Some(Others::Hidden)
);
}
#[test]
fn others_cosmetics_are_off_for_a_player_who_owns_none_until_chosen() {
use horizon_cosmetics::prefs::Others;
let mut e = Extras::default();
// Nothing chosen, nothing owned: the button shows Hidden and no line is written.
assert_eq!(e.effective_others(), Others::Hidden);
assert_eq!(
Item::CosmeticsOthers.value(&e),
crate::lang::get_or("horizon.extras.cosmetics.hidden", "Hidden")
);
assert!(!e.serialize().contains("cosmeticsOthers"));
// The service says the account owns one: shown, still not a choice.
e.cosmetics_owner = true;
assert_eq!(e.effective_others(), Others::Shown);
assert!(!e.serialize().contains("cosmeticsOthers"));
// A non-owner pressing the button goes on from Hidden, and that choice is kept.
e.cosmetics_owner = false;
Item::CosmeticsOthers.cycle(&mut e, false);
assert_eq!(e.cosmetics_others, Some(Others::Shown));
let saved = Extras::parse(&e.serialize());
assert_eq!(saved.effective_others(), Others::Shown);
// An owner who chose Hidden keeps it.
let hidden = Extras {
cosmetics_owner: true,
..Extras::parse("cosmeticsOthers:hidden\n")
};
assert_eq!(hidden.effective_others(), Others::Hidden);
}
#[test]
fn a_saved_wheel_setting_from_before_the_default_changed_is_dropped() {
assert!(!Extras::parse("wheelCyclesSettings:true\n").wheel_cycles_settings);
+2 -19
View File
@@ -206,14 +206,13 @@ impl Screen for ExtrasScreen {
fn note_lines(&self) -> usize {
NOTE_LINES
}
/// The rows read the extras, the mods, whether cosmetics are offered and the sections' rows.
/// The rows read the extras, the mods and the sections' rows.
fn stamp(&self, s: &Settings) -> u64 {
let mut added = Vec::new();
for (_, section) in sections() {
section.rows(&mut added);
}
let cosmetics = horizon_cosmetics::enabled();
shell::stamp_of(&(&s.extras, &s.mods, cosmetics, added))
shell::stamp_of(&(&s.extras, &s.mods, added))
}
fn sheets(&self, s: &Settings) -> Vec<Sheet<(), ExtraRow>> {
let mut groups = vec![Group {
@@ -321,22 +320,6 @@ mod tests {
assert_ne!(s.extras.mouse_tweaks_wheel, before);
}
#[test]
fn the_three_way_cosmetics_row_turns_both_ways() {
use horizon_cosmetics::prefs::Others;
let mut s = Settings::default();
let mut screen = ExtrasScreen;
let row = ExtraRow::Item(Item::CosmeticsOthers);
// Not chosen, nothing owned: it shows Hidden, and forward wraps to Shown.
assert_eq!(s.extras.effective_others(), Others::Hidden);
screen.press(&mut s, row, false);
assert_eq!(s.extras.cosmetics_others, Some(Others::Shown));
screen.press(&mut s, row, true);
assert_eq!(s.extras.cosmetics_others, Some(Others::Hidden));
screen.press(&mut s, row, true);
assert_eq!(s.extras.cosmetics_others, Some(Others::NameTags));
}
/// The sort key binds instead of cycling; the wheel leaves it be.
#[test]
fn the_key_row_waits_for_a_key() {
+148 -431
View File
@@ -47,6 +47,10 @@ fn terrain_shader() -> String {
include_str!("hdr_light.wgsl").to_owned() + include_str!("terrain.wgsl")
}
fn border_shader() -> String {
include_str!("hdr_light.wgsl").to_owned() + include_str!("border.wgsl")
}
/// The static block-entity vertex entry point shares entity shading but lives
/// in its own source file and pipeline.
fn static_entity_shader() -> String {
@@ -75,6 +79,7 @@ pub use entity_batch::{
WATER_MASK_OVERLAY, WORLD_TEXT_OVERLAY, WORLD_TEXT_POLYGON_OFFSET_OVERLAY, is_native_terrain,
mark_oit,
};
use external::{ShadowCull, shadow_box, shadow_casters, shadow_runs};
use section_fade::{
CHUNK_VISIBILITY_THRESHOLD, FadeInputs, chunk_visibility, fade_millis, section_instance,
section_visibility,
@@ -190,10 +195,12 @@ pub mod capture;
pub mod compositor;
pub(crate) mod core_lines;
pub(crate) mod core_sky;
pub(crate) mod core_text;
mod cull;
mod dx12_compiler;
mod end_sky;
pub mod entity_skin;
pub mod external;
mod flat;
mod frame_gen;
mod gpu_culling;
@@ -210,7 +217,12 @@ pub use screenshot_save::Saved as SavedScreenshot;
pub mod block_light;
mod custom_sky;
mod displace;
mod frame_policy;
pub mod lightning;
mod linear_blend;
#[cfg(test)]
use frame_policy::frame_format;
use frame_policy::{frame_configuration, horizon_tone_curve, overlay_format};
mod lod;
mod native_material;
mod native_pack;
@@ -1125,6 +1137,8 @@ pub struct Renderer {
/// Sections upload their solid quads in the compact layout (`mesher::packed`): the solid
/// passes read the arena themselves (`flat`) and no shader pack reads it as vertices.
packed_quads: bool,
/// The terrain's acceleration structures, with ray tracing on (`ray_scene`).
ray_scene: Option<surface_setup::ray_query::ray_scene::RayScene>,
/// The compact layout is allowed (`HORIZON_PACKED_QUADS`, `set_packed_layout`).
packed_on: bool,
/// The visibility buffer for flat frames' solid terrain (`visbuffer`,
@@ -1164,6 +1178,7 @@ pub struct Renderer {
lines_secondary: wgpu::RenderPipeline,
/// A resource pack's `core/rendertype_lines`, which draws the block outline in their place.
core_lines: Option<core_lines::CoreLines>,
core_text: Option<core_text::CoreText>,
/// The packs' `core/sky` and `core/stars`, drawn in place of the native sky and stars.
core_sky: Option<core_sky::CoreSky>,
/// Gizmo lines: opaque ones (`RenderTypes.lines`, which writes depth),
@@ -1369,8 +1384,10 @@ pub struct Renderer {
/// Per-frame scratch, kept to avoid allocating every frame.
visible: Vec<resident::Visible>,
visible_lods: Vec<lod::Visible>,
/// The sections a shader pack's shadow camera sees (`pack_post::ShadowView`).
/// The sections a shader pack's shadow camera sees (`external::ShadowView`).
shadow_visible: Vec<resident::Visible>,
/// The external shader pipeline world frames call (`external`); `None` draws vanilla's way.
external: Option<Box<dyn external::ExternalPipeline>>,
translucent_draws: Vec<(u64, usize, [u32; 5], (u32, Option<u32>))>,
origins: Vec<[f32; 4]>,
/// `DrawIndexedIndirectArgs` words: index count, instance count, first
@@ -1496,7 +1513,7 @@ pub struct Renderer {
shadow_seen_scratch: crate::fast_hash::FxHashSet<SectionKey>,
/// Shadow translucent section draws, retained between frames.
shadow_translucent_scratch: Vec<(u64, [u32; 5], (u32, Option<u32>))>,
/// Lava's sprite layers, which a pack's solid terrain draws (`pack_atlas::terrain_parts`).
/// Lava's sprite layers, which a pack's solid terrain draws (`pack_atlas::game_terrain`).
pack_lava_layers: Vec<u16>,
pub hud: HudRenderer,
/// Vanilla's menu background blur, made on first use.
@@ -1577,9 +1594,9 @@ fn requested_split() -> Option<u32> {
.and_then(|v| v.parse().ok())
.filter(|&n| (1..=SPRITE_SPLIT).contains(&n))
}
fn adapter_requested_split(info: &wgpu::AdapterInfo, requested: Option<u32>) -> Option<u32> {
// Metal can fail to sample large mipmapped array textures despite advertising the layer limit.
requested.or_else(|| (info.backend == wgpu::Backend::Metal).then_some(512))
fn adapter_requested_split(info: &wgpu::AdapterInfo, asked: Option<u32>, n: u32) -> Option<u32> {
// Metal can fail on big mipmapped arrays (#8471): 512 layers each, more only for n > 8 x 512.
asked.or((info.backend == wgpu::Backend::Metal).then(|| n.div_ceil(SPRITE_ARRAYS).max(512)))
}
/// The split the CPU mip chains are cut by before the adapter is known.
fn sprite_split() -> u32 {
@@ -2108,7 +2125,7 @@ fn check_layers(adapter: &wgpu::Adapter, layers: u32) -> Result<u32> {
Ok(layer_split(
adapter.limits().max_texture_array_layers,
info.backend == wgpu::Backend::Gl,
adapter_requested_split(&info, requested_split()),
adapter_requested_split(&info, requested_split(), layers),
layers,
)?)
}
@@ -2170,74 +2187,6 @@ fn draws_frame(format: wgpu::TextureFormatFeatures) -> bool {
/// survive from the world to the tonemap and the HDR pass.
const LINEAR_FRAME: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
/// What a renderer's frame is drawn in: `LINEAR_FRAME` under the switch where the device draws
/// it, for a 0.2 pack, or for the layered HDR frame where the surface offers HDR output
/// (`linear`: `(a 0.2 pack picked, the layered HDR frame, the device draws it)`); else
/// `WIDE_FRAME` when it is made with HDR on and Smooth Gradients (`wide_frame`,
/// `output::resolve_frame`), the surface offering HDR output (`hdr_offered`) and the device
/// drawing it (`draws_wide_frame`): an 8-bit frame's steps band in dark gradients, and HDR
/// shows them. Else the swapchain's 8-bit format, as without HDR.
fn frame_format(
wide_frame: bool,
hdr_offered: bool,
device_draws_it: bool,
swapchain: wgpu::TextureFormat,
linear: (bool, bool, bool),
) -> wgpu::TextureFormat {
if linear.2 && (linear.0 || linear.1 && hdr_offered) {
LINEAR_FRAME
} else if wide_frame && hdr_offered && device_draws_it {
WIDE_FRAME
} else {
swapchain.remove_srgb_suffix()
}
}
/// The frame's format, and whether it is the layered HDR frame (HUD coverage): for HDR on
/// under the switch (`hdr_float`), with no pack or a 0.2 one, where the device keeps the HUD
/// on a layer of its own.
fn frame_configuration(
wide_frame: bool,
device: (bool, bool, bool, bool),
swapchain: wgpu::TextureFormat,
linear_pack: bool,
hdr_float: bool,
) -> (wgpu::TextureFormat, bool) {
let (hdr_offered, wide_ok, linear_ok, coverage_ok) = device;
let layered = hdr_float && hdr_offered && linear_ok && coverage_ok;
(
frame_format(
wide_frame,
hdr_offered,
wide_ok,
swapchain,
(linear_pack, layered, linear_ok),
),
layered,
)
}
/// What the hand, the camera overlays and the HUD draw on: the frame, except for a 0.2 pack's
/// float frame without the HUD's layer, which is SDR: the pack's picture finishes on the
/// swapchain's 8-bit format first, so they clip and blend per draw as on vanilla's frame.
fn overlay_format(
frame: wgpu::TextureFormat,
layered: bool,
swapchain: wgpu::TextureFormat,
) -> wgpu::TextureFormat {
if frame == LINEAR_FRAME && !layered {
swapchain.remove_srgb_suffix()
} else {
frame
}
}
/// Whether Horizon's tone curve (`Hdr::tonemap`) runs on this frame: on the layered frame's
/// world, unless a 0.2 pack's `[tonemap]` already mapped it. One tone mapping, never two.
fn horizon_tone_curve(layered: bool, world: bool, pack_tonemap: bool) -> bool {
layered && world && !pack_tonemap
}
/// Opens a device on the first of `backend_order`'s backends that gives one.
/// Offscreen rendering needs no surface, so it can use a GPU the window's
/// display cannot present from (a headless compositor).
@@ -2823,10 +2772,8 @@ impl Renderer {
// Computed once: the sprite arrays, the shaders' constants and the
// shader pack atlas all cut the sprites by it.
let split = check_layers(&adapter, layers)?;
if split < sprite_split() {
log::info!("Horizon: {split} block textures per array (the GPU's limit)");
}
let info = adapter.get_info();
surface_setup::log_sprite_split(&info, split, layers);
crate::graphics::suggest_reduced_far_detail(crate::graphics::lod_capped(&info));
let size = window.inner_size();
let max_side = device.limits().max_texture_dimension_2d;
@@ -2909,6 +2856,7 @@ impl Renderer {
let frame = output::resolve_frame(settings, request, output_caps, display, config.format);
let (frame_format, hud_coverage, overlay_format) =
(frame.format, frame.hud_layer, frame.overlay_format);
let linear_light = frame.linear_light;
if previous.is_none() {
startup_step(format_args!("creating the pipelines"));
}
@@ -3064,9 +3012,10 @@ impl Renderer {
blend: Option<wgpu::BlendState>,
hud: Option<bool>| {
let mut constants = constants.to_vec();
constants.push((
"HDR_WRITE_LIGHT",
f64::from(u8::from(frame_format == LINEAR_FRAME)),
// The debug crosshair draws with the HUD, in gamma.
constants.extend(linear_blend::frame_constants(
frame_format,
linear_light && hud.is_none(),
));
let desc = wgpu::RenderPipelineDescriptor {
label: Some(label),
@@ -3879,8 +3828,12 @@ impl Renderer {
},
)],
});
let weather =
weather::Weather::new(&device, frame_format, &entity_bindings, &entity_shader);
let weather = weather::Weather::new(
&device,
(frame_format, linear_light),
&entity_bindings,
&entity_shader,
);
let atlas_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("Atlas"),
..Default::default()
@@ -4046,6 +3999,7 @@ impl Renderer {
];
#[allow(clippy::too_many_arguments)]
let entity_pipeline_in = |format,
linear,
label,
cull_mode,
depth_write,
@@ -4084,10 +4038,7 @@ impl Renderer {
module: &entity_shader,
entry_point: Some(fragment),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &[(
"HDR_WRITE_LIGHT",
f64::from(u8::from(frame_format == LINEAR_FRAME)),
)],
constants: &linear_blend::frame_constants(frame_format, linear),
..Default::default()
},
targets: &[Some(wgpu::ColorTargetState {
@@ -4105,6 +4056,7 @@ impl Renderer {
|label, cull_mode, depth_write, compare, bias, blend, clouds, fragment, write_mask| {
entity_pipeline_in(
frame_format,
linear_light,
label,
cull_mode,
depth_write,
@@ -4244,8 +4196,12 @@ impl Renderer {
"fs_entity_unmarked",
wgpu::ColorWrites::ALL,
);
let world_text_pipeline =
world_text::pipeline(&device, Some(&entity_layout), &entity_shader, frame_format);
let world_text_pipeline = world_text::pipeline(
&device,
Some(&entity_layout),
&entity_shader,
(frame_format, linear_light),
);
// RenderPipelines.ENTITY_SOLID: a surface of an entity that is coplanar with
// terrain wins the tie in vanilla (the boat's floor over the trapdoor on the
// ground it is stopped by, #5950). Terrain and entities are separate draw
@@ -4268,10 +4224,12 @@ impl Renderer {
alpha_blend,
false,
);
// The hand as `entity_pipeline` draws it, in the overlays' format.
let overlay_hand = (overlay_format != frame_format).then(|| {
// The hand as `entity_pipeline` draws it, in the overlays' format and gamma: it draws
// after a 0.2 pack's tonemap.
let overlay_hand = (overlay_format != frame_format || linear_light).then(|| {
entity_pipeline_in(
overlay_format,
false,
"First-person hand",
None,
true,
@@ -4305,7 +4263,10 @@ impl Renderer {
fragment: Some(wgpu::FragmentState {
module: &static_entity_shader,
entry_point: Some("fs_entity_unmarked"),
compilation_options: Default::default(),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &linear_blend::frame_constants(frame_format, linear_light),
..Default::default()
},
targets: &[Some(wgpu::ColorTargetState {
format: frame_format,
// fs_entity's discard handles binary cutout alpha. Any
@@ -4412,10 +4373,7 @@ impl Renderer {
module: &entity_shader,
entry_point: Some("fs_celestial"),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &[(
"HDR_WRITE_LIGHT",
f64::from(u8::from(frame_format == LINEAR_FRAME)),
)],
constants: &linear_blend::frame_constants(frame_format, linear_light),
..Default::default()
},
targets: &[Some(wgpu::ColorTargetState {
@@ -4510,10 +4468,7 @@ impl Renderer {
module: &entity_shader,
entry_point: Some("fs_sky_celestial"),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &[(
"HDR_WRITE_LIGHT",
f64::from(u8::from(frame_format == LINEAR_FRAME)),
)],
constants: &linear_blend::frame_constants(frame_format, linear_light),
..Default::default()
},
targets: &[Some(wgpu::ColorTargetState {
@@ -4652,7 +4607,7 @@ impl Renderer {
});
let border_shader = kept.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Horizon world border"),
source: wgpu::ShaderSource::Wgsl(include_str!("border.wgsl").into()),
source: wgpu::ShaderSource::Wgsl(border_shader().into()),
});
let border_attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2];
// RenderPipelines.WORLD_BORDER: BlendFunction.OVERLAY, no culling,
@@ -4689,7 +4644,10 @@ impl Renderer {
fragment: Some(wgpu::FragmentState {
module: &border_shader,
entry_point: Some("fs_border"),
compilation_options: Default::default(),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &linear_blend::frame_constants(frame_format, linear_light),
..Default::default()
},
targets: &[Some(wgpu::ColorTargetState {
format: frame_format,
blend: Some(wgpu::BlendState {
@@ -4915,6 +4873,7 @@ impl Renderer {
entity: entity_bindings.clone(),
static_entity: static_entity_bindings.clone(),
format: frame_format,
linear: linear_light,
sprite_constants,
hdr_output: hdr_output.is_some(),
});
@@ -4925,7 +4884,13 @@ impl Renderer {
log::info!(
"Horizon: solid terrain through a visibility buffer (HORIZON_VISBUFFER)"
);
visbuffer::Visbuffer::new(&device, &bindings, f, frame_format, &sprite_constants)
visbuffer::Visbuffer::new(
&device,
&bindings,
f,
(frame_format, linear_light),
&sprite_constants,
)
});
let multi_draw = device
.features()
@@ -5006,8 +4971,8 @@ impl Renderer {
let world_post = world_post::Post::new(&device, frame_format);
let uploads = upload::Uploads::new(&device);
let section_uploads = upload::Uploads::with_chunk(&device, SECTION_UPLOAD_CHUNK);
let stars = stars::Stars::new(&device, frame_format);
let custom_sky = custom_sky::CustomSky::new(&device, frame_format);
let stars = stars::Stars::new(&device, (frame_format, linear_light));
let custom_sky = custom_sky::CustomSky::new(&device, (frame_format, linear_light));
let entity_outline = entity_outline::EntityOutline::new(
&device,
(&entity_shader, &entity_layout, &entity_vertex),
@@ -5035,7 +5000,8 @@ impl Renderer {
on_off(gpu_timer.is_some()),
));
}
let world_primitives = world_primitives::WorldPrimitives::new(&device, frame_format);
let world_primitives =
world_primitives::WorldPrimitives::new(&device, (frame_format, linear_light));
let screenshot_saves = screenshot_save::Saves::new(&device);
Ok(Self {
display: None,
@@ -5059,6 +5025,7 @@ impl Renderer {
last_wait: std::time::Duration::ZERO,
last_encode: std::time::Duration::ZERO,
window,
ray_scene: surface_setup::ray_query::ray_scene::RayScene::new(&device),
device,
reuse,
queue: submit::Queue::new(queue, submit::wanted()),
@@ -5093,6 +5060,7 @@ impl Renderer {
lines,
lines_secondary,
core_lines: None,
core_text: None,
core_sky: None,
debug_lines_opaque,
debug_lines,
@@ -5242,6 +5210,7 @@ impl Renderer {
solid_runs: page_runs::PageRuns::default(),
phase2_runs: page_runs::PageRuns::default(),
shadow_runs: page_runs::PageRuns::default(),
external: None,
block_entities: Default::default(),
block_entity_section_revisions: Default::default(),
block_entity_membership_revisions: Default::default(),
@@ -5439,6 +5408,8 @@ impl Renderer {
/// outline, or the built-in one.
pub fn set_lines_shader(&mut self, program: Option<&crate::core_shader::CoreProgram>) {
let format = self.output.frame.format;
// A pack's GLSL writes vanilla's gamma: a frame of linear light keeps the built-in one.
let program = program.filter(|_| !self.output.frame.linear_light);
self.core_lines =
program.and_then(|program| core_lines::CoreLines::new(&self.device, format, program));
}
@@ -5449,6 +5420,9 @@ impl Renderer {
disc: Option<&crate::core_shader::CoreProgram>,
stars: Option<&crate::core_shader::CoreProgram>,
) {
// As `set_lines_shader`: not on a frame of linear light.
let keep = !self.output.frame.linear_light;
let (disc, stars) = (disc.filter(|_| keep), stars.filter(|_| keep));
self.core_sky = core_sky::CoreSky::new(&self.device, self.output.frame.format, disc, stars);
}
/// LevelRenderer.submitBlockOutline: the buffer holds `edges` per stroke, the black
@@ -5474,17 +5448,7 @@ impl Renderer {
pass.draw(0..4, first..first + edges);
}
fn quad_indices(device: &wgpu::Device, quads: u32) -> wgpu::Buffer {
let indices: Vec<u32> = (0..quads)
.flat_map(|q| {
let b = q * 4;
[b, b + 1, b + 2, b, b + 2, b + 3]
})
.collect();
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Quad indices"),
contents: bytemuck::cast_slice(&indices),
usage: wgpu::BufferUsages::INDEX,
})
surface_setup::ray_query::quad_indices(device, quads)
}
fn offscreen_target(
device: &wgpu::Device,
@@ -5625,6 +5589,9 @@ impl Renderer {
self.offscreen = Some(Self::offscreen_target(&self.device, &self.config));
}
self.depth = Self::depth(&self.device, &self.config);
if let Some(pipeline) = &mut self.external {
pipeline.resize([width, height]);
}
}
/// Whether sections are drawn as one index list per frame (`flat`).
pub fn flat_draws(&self) -> bool {
@@ -5960,6 +5927,7 @@ impl Renderer {
section: Option<SectionGpu>,
fading: Option<Instant>,
) {
self.ray_mark(key);
let section = section.map(|section| SectionGpu { fading, ..section });
self.set_detail(key, section.is_some() || mesh.empty);
if let Some(mut section) = section {
@@ -6648,6 +6616,7 @@ impl Renderer {
self.newest_lod_fade = None;
self.block_entity_revision = next_block_entity_revision();
self.sections.clear();
self.ray_clear();
// (a hold on land that is gone ends; the arenas' clearing below takes what waited)
self.hold.clear();
self.lods.clear();
@@ -7261,12 +7230,19 @@ impl Renderer {
}
Ok(report)
}
/// Sets the external shader pipeline world frames call (`external`), or none.
pub fn set_external_pipeline(&mut self, pipeline: Option<Box<dyn external::ExternalPipeline>>) {
self.external = pipeline;
}
/// Makes the running shader pack, of either kind, forget what it kept from earlier frames:
/// the next frame draws as a freshly loaded pack's first. Nothing to do without a pack.
pub fn reset_shader_pack_history(&mut self) {
// A frame still on the submit thread uses the targets and readbacks replaced
// here.
self.land_frame();
if let Some(pipeline) = &mut self.external {
pipeline.reset_history();
}
if let Some((_, Some(post))) = &mut self.pack_post {
post.reset_history(&self.device);
}
@@ -7498,6 +7474,7 @@ impl Renderer {
flat: self.flat.as_ref().map(|f| &f.draw_layout),
sprites: self.terrain_sprites,
format: self.output.frame.format,
linear: self.output.frame.linear_light,
sprite_tags: &sprite_tags,
sprite_materials: &sprite_materials,
entities: &self.entity_bindings,
@@ -7540,10 +7517,6 @@ impl Renderer {
}
});
horizon_shaders::GameInputs {
terrain_parts: terrain
.as_ref()
.map(|t| pack_atlas::terrain_parts(t, &self.pack_lava_layers)),
terrain,
terrain_geometry,
entity_geometry: Some(pack_entities::geometry_inputs()),
sky: Some(pack_sky::inputs()),
@@ -7553,6 +7526,7 @@ impl Renderer {
weather: Some(pack_weather::inputs()),
lines: Some(pack_lines::inputs()),
hand: Some(pack_hand::inputs()),
..pack_atlas::game_terrain(terrain, &self.pack_lava_layers)
}
}
@@ -8603,6 +8577,13 @@ impl Renderer {
// A shader pack's shadow pass draws the sections in its camera's box, all of them (the
// occlusion graph follows the eye, not the light), within shadowDistanceRenderMul; for
// an Iris-format pack, only those whose shadow can fall into the camera's view.
let state = external::FrameState {
frame,
output: &output,
};
if let Some(pipeline) = self.external.as_mut().filter(|_| !frame.title) {
pipeline.begin_frame(&state);
}
let shadow_view = self
.pack_post
.as_ref()
@@ -8614,6 +8595,12 @@ impl Renderer {
let native = self.native.as_ref().filter(|_| !frame.title)?;
native.shadow.as_ref()?;
native.frame.shadow_view(frame)
})
.or_else(|| {
self.external
.as_ref()
.filter(|_| !frame.title)?
.shadow_request(&state)
});
// `LevelRenderer.extractSectionDrawGroups`' fade-in time, for the instances.
let fade_ms = fade_millis(frame.chunk_fade);
@@ -8859,6 +8846,7 @@ impl Renderer {
mapped_at_creation: false,
});
}
self.ray_prepare(&mut encoder, camera, frame.title);
self.origins.clear();
let (sections, retiring) = (&self.sections, &self.lod_retiring);
laps.lap("r_cameras");
@@ -9405,6 +9393,8 @@ impl Renderer {
&self.entity_atlas,
)
};
let pack = self.pack_blocks(frame, fog, globals.fog_ends);
self.write_core_text(&pack, views.primary(), pose, jitter, fog_color);
let outline_edges = frame.outline.len().min(MAX_OUTLINE_EDGES as usize);
if outline_edges > 0 {
// The same per-line colour and pixel width layout as the gizmo lines. A high
@@ -9438,20 +9428,14 @@ impl Renderer {
self.uploads
.buffer(&mut encoder, &self.outline, 0, bytemuck::cast_slice(lines));
if let Some(core) = &self.core_lines {
let size = [self.config.width, self.config.height];
let block = crate::post_chain::Globals {
glint_alpha: frame.glint_alpha,
..frame.post_effects.globals
};
let [sky_end, clouds_end, ..] = globals.fog_ends;
let data = core_lines::uniforms(
block.bytes(size, frame.post_effects.menu_blur_radius as i32),
pack.globals,
views.primary(),
pose,
jitter,
globals.outline_params[3],
fog_color,
[fog[0], fog[1], fog[2], fog[3], sky_end, clouds_end],
pack.fog,
);
self.uploads.buffer(&mut encoder, core.uniform(), 0, &data);
}
@@ -9461,14 +9445,7 @@ impl Renderer {
if let Some(core) = &self.core_sky
&& !frame.title
{
let size = [self.config.width, self.config.height];
let block = crate::post_chain::Globals {
glint_alpha: frame.glint_alpha,
..frame.post_effects.globals
}
.bytes(size, frame.post_effects.menu_blur_radius as i32);
let [sky_end, clouds_end, ..] = globals.fog_ends;
let fog = [fog[0], fog[1], fog[2], fog[3], sky_end, clouds_end];
let (block, fog) = (pack.globals, pack.fog);
let view = views.primary();
// Far enough for the disc's rim, 512 out: the sky draws without a depth test.
let fov = (2.0 * view.tan_half_fov.atan()).to_degrees();
@@ -10188,6 +10165,7 @@ impl Renderer {
a: 1.0,
}
};
let clear = linear_blend::stored_clear(clear, self.output.frame.linear_light);
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
@@ -11026,7 +11004,7 @@ impl Renderer {
}
if text_quads > 0 && !oit_on {
pass.set_pipeline(warped.map_or(&self.text_polygon_offset_pipeline, |w| &w.text));
pass.set_bind_group(0, &self.entity_group, &[]);
self.bind_world_text(&mut pass, warped.is_none(), false);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.draw_indexed(0..text_quads * 6, (entity_quads * 4) as i32, 0..1);
pass.set_bind_group(0, &self.group, &[]);
@@ -11584,7 +11562,7 @@ impl Renderer {
// LevelRenderer.executeSeeThrough, after the rest of the world.
if see_through_quads > 0 {
pass.set_pipeline(&self.see_through_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
self.bind_world_text(&mut pass, true, true);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.draw_indexed(
0..see_through_quads * 6,
@@ -11903,13 +11881,12 @@ impl Renderer {
{
self.readback.present(&self.device, &mut encoder, image);
}
// A 0.2 pack is shown at paper white: HDR output lifts nothing of its world.
let lifts_world = !frame.title
&& self.output.frame.format != LINEAR_FRAME
&& self
.native
.as_ref()
.is_none_or(|n| n.frame.api == horizon_shader_api::Api::V0_1);
let lifts_world = output::lifts_world(
frame.title,
self.output.frame.format == LINEAR_FRAME,
self.shader_pack_kind() == Some("iris"),
self.native.as_ref().map(|n| n.frame.api),
);
match (&self.hdr, &surface_frame) {
(Some(hdr), Some(f)) => hdr.present(
&self.queue,
@@ -12745,61 +12722,6 @@ impl Cull for Frusta {
self.0.contains(min, max) || self.1.is_some_and(|f| f.contains(min, max))
}
}
/// A shader pack's shadow camera's box, and with `casters` only what can shade the camera's view
/// (`shadow_casters`): a box must meet all of them.
#[derive(Clone, Copy, Debug)]
pub struct ShadowCull {
pub light: Frustum,
pub casters: Option<[Frustum; 2]>,
/// `shadowDistance × shadowDistanceRenderMul`: only what lies within this many blocks of the
/// camera along each axis, a cube around it. Our probe's Iris 1.11.7 maps keep the sections
/// whose box, grown by `SHADOW_CASTER_MARGIN` as for the casters, meets that cube (#8464):
/// out to its corners, and a section 1 block past its side but not one 1.5 blocks past.
pub reach: Option<f32>,
}
impl ShadowCull {
/// Whether the camera-relative box `min..max`, grown by the margin, meets (`whole`: lies
/// within) `reach`'s cube.
fn within_reach(&self, min: Vec3, max: Vec3, whole: bool) -> bool {
let (min, max) = (min - SHADOW_CASTER_MARGIN, max + SHADOW_CASTER_MARGIN);
self.reach.is_none_or(|d| {
if whole {
min.cmpge(Vec3::splat(-d)).all() && max.cmple(Vec3::splat(d)).all()
} else {
max.cmpge(Vec3::splat(-d)).all() && min.cmple(Vec3::splat(d)).all()
}
})
}
}
/// How far past a section's 16 blocks the shadow pass looks for what can shade the view: our
/// probes' Iris 1.11.7 maps keep the sections whose box, grown by this much on every side, meets
/// `shadow_casters`' shape. Over 76 scenes at fields of view 30 to 110, Iris keeps every section
/// that needs up to 1.1224 blocks and drops every one that needs 1.1252 or more (#8464): one
/// growth, whatever the field of view, of 9/8 (a fit, not Iris's code). Without it the edges of
/// the wedge lose a column of terrain.
const SHADOW_CASTER_MARGIN: f32 = 1.125;
impl Cull for ShadowCull {
fn intersects(&self, min: Vec3, max: Vec3) -> bool {
let (grown_min, grown_max) = (min - SHADOW_CASTER_MARGIN, max + SHADOW_CASTER_MARGIN);
self.within_reach(min, max, false)
&& self.light.intersects(min, max)
&& self
.casters
.is_none_or(|c| c.iter().all(|f| f.intersects(grown_min, grown_max)))
}
fn contains(&self, min: Vec3, max: Vec3) -> bool {
let (grown_min, grown_max) = (min - SHADOW_CASTER_MARGIN, max + SHADOW_CASTER_MARGIN);
self.within_reach(min, max, true)
&& self.light.contains(min, max)
&& self
.casters
.is_none_or(|c| c.iter().all(|f| f.contains(grown_min, grown_max)))
}
/// Our probe's Iris 1.11.7 maps fit testing the casters' whole sections (a guess that matched).
fn tight(&self) -> bool {
self.casters.is_none()
}
}
/// The block entities of `sections` whose box, a block wider on every side, comes within the
/// 64 blocks block entities are drawn to (`Renderer::visible_block_entities_in_frustum`).
fn block_entities_within_reach<'a>(
@@ -12822,17 +12744,6 @@ fn block_entities_within_reach<'a>(
})
.flatten()
}
/// The shadow camera's box that the shadow pass draws terrain in: the sides of `view.cull`, and
/// its near and far planes only with `depth_planes`.
fn shadow_box(view: &pack_post::ShadowView) -> Frustum {
let mut planes = frustum(view.cull);
if !view.depth_planes {
// `frustum`'s faces at depth 0 and 1 take in everything.
planes[4] = Vec4::W;
planes[5] = Vec4::W;
}
Frustum { planes }
}
/// Camera-relative clip planes (`Frustum`).
#[derive(Clone, Copy, Debug)]
pub struct Frustum {
@@ -12956,21 +12867,6 @@ fn projection_center(view_proj: Mat4) -> Option<Vec3> {
let b = DVec3::new(rows[0].w, rows[1].w, rows[2].w);
Some((a.inverse() * -b).as_vec3())
}
/// The runs of a section's solid quads the shadow pass draws: every face within the unculled
/// distance, or everywhere for `every_face`; else the quads that can face the light.
fn shadow_runs(
sides: &Sides,
view: &pack_post::ShadowView,
origin: [f32; 3],
draw: impl FnMut(u32, u32),
) {
let unculled = view.unculled.is_some_and(|d| {
let o = Vec3::from(origin) + 8.0;
o.x.abs().max(o.z.abs()) <= d + 8.0
});
let toward = (!unculled && !view.every_face).then_some(view.light * 1.0e5);
facing_runs(sides, toward, origin, draw);
}
/// Calls `draw(first, quads)` for each run of a mesh's solid quads that can
/// face `eye` (`projection_center`), the mesh at the camera-relative
@@ -12997,68 +12893,6 @@ fn facing_runs_from(
None => sides.every(part, draw),
}
}
/// The camera's view `view_proj` (wgpu depth, reversed or not) stretched without end toward
/// `light` (a direction): the boxes whose shadow, cast away from the light, can fall into the
/// view. Its planes are the view's faces turned toward the light, and through each edge between
/// such a face and one turned away, the plane along the light; unused ones take in everything.
fn shadow_casters(view_proj: Mat4, light: Vec3) -> [Frustum; 2] {
let faces = frustum(view_proj);
let toward = faces.map(|p| p.truncate().dot(light) >= 0.0);
let inverse = view_proj.inverse();
// Corner (x, y, z) of clip space's box; `frustum`'s faces at x -1 and 1 are 0 and 1, at
// y -1 and 1 are 2 and 3, at depth 0 and 1 are 4 and 5.
let corner = |c: [usize; 3]| {
inverse.project_point3(Vec3::new(
c[0] as f32 * 2.0 - 1.0,
c[1] as f32 * 2.0 - 1.0,
c[2] as f32,
))
};
let corners: Vec<[usize; 3]> = (0..8).map(|i| [i & 1, (i >> 1) & 1, i >> 2]).collect();
let centre = corners.iter().map(|&c| corner(c)).sum::<Vec3>() / 8.0;
let mut planes = [Vec4::W; 12];
let mut n = 0;
for (k, &face) in faces.iter().enumerate() {
if toward[k] {
planes[n] = face;
n += 1;
}
}
for a in &corners {
for axis in 0..3 {
if a[axis] == 1 {
continue;
}
let mut b = *a;
b[axis] = 1;
// The edge from a to b lies on the faces of the two other axes.
let [f, g] = [(axis + 1) % 3, (axis + 2) % 3].map(|o| o * 2 + a[o]);
if toward[f] == toward[g] {
continue;
}
let (p, q) = (corner(*a), corner(b));
let normal = (q - p).cross(light);
if normal.length_squared() < 1e-12 {
continue;
}
let normal = normal.normalize();
let side = if normal.dot(centre - p) < 0.0 {
-1.0
} else {
1.0
};
// At most six such edges with at most four faces toward the light: 12 planes.
if let Some(plane) = planes.get_mut(n) {
*plane = (normal * side).extend(-normal.dot(p) * side);
n += 1;
}
}
}
let [a, b] = [0, 6].map(|i| Frustum {
planes: planes[i..i + 6].try_into().expect("six planes"),
});
[a, b]
}
fn taa_frustum(view_proj: Mat4, rendered_size: [u32; 2]) -> Frustum {
// TAA shifts clip x/y by at most half a rendered pixel times clip w.
// Widen x/y before extracting the planes: this includes every such shifted
@@ -13082,143 +12916,11 @@ fn frustum(m: Mat4) -> [Vec4; 6] {
planes.map(|p| p / p.truncate().length())
}
#[cfg(test)]
mod frame_policy_tests;
#[cfg(test)]
mod tests {
#[test]
fn float_frames_never_schedule_light_mark_passes() {
use super::{LINEAR_FRAME, WIDE_FRAME, light_marks};
use wgpu::TextureFormat::{Bgra8Unorm, Rgb10a2Unorm, Rgba8Unorm};
for hdr in [false, true] {
assert!(!light_marks(LINEAR_FRAME, hdr));
for format in [WIDE_FRAME, Bgra8Unorm, Rgba8Unorm, Rgb10a2Unorm] {
assert_eq!(light_marks(format, hdr), hdr);
}
}
}
#[test]
fn frame_policy_keeps_compatibility_frames_and_requires_layering_for_float_hdr() {
use super::{LINEAR_FRAME, WIDE_FRAME, frame_configuration};
use wgpu::TextureFormat::{Bgra8Unorm, Bgra8UnormSrgb};
for bits in 0..128 {
let hdr = bits & 1 != 0;
let offered = bits & 2 != 0;
let wide_ok = bits & 4 != 0;
let float_ok = bits & 8 != 0;
let coverage_ok = bits & 16 != 0;
let switch = bits & 32 != 0;
let smooth = bits & 64 != 0;
for pack in ["none", "0.1", "0.2", "shaders"] {
let float_hdr = matches!(pack, "none" | "0.2");
let expected = if switch && float_ok && float_hdr && hdr && offered && coverage_ok {
(LINEAR_FRAME, true)
} else if switch && float_ok && pack == "0.2" {
(LINEAR_FRAME, false)
} else if hdr && smooth && offered && wide_ok {
(WIDE_FRAME, false)
} else {
(Bgra8Unorm, false)
};
assert_eq!(
frame_configuration(
hdr && smooth,
(offered, wide_ok, float_ok, coverage_ok),
Bgra8UnormSrgb,
switch && pack == "0.2",
switch && hdr && float_hdr,
),
expected,
"bits {bits}, pack {pack}"
);
}
}
}
#[test]
fn float_hdr_falls_back_for_each_missing_hud_layer_requirement() {
use super::{LINEAR_FRAME, WIDE_FRAME, frame_configuration, hud_coverage};
use wgpu::{DownlevelFlags as D, TextureFormat as F, TextureFormatFeatureFlags as Fl};
use wgpu::{TextureFormatFeatures, TextureUsages as U};
let usages = U::RENDER_ATTACHMENT | U::TEXTURE_BINDING;
for (attachments, flags, allowed_usages, format_flags) in [
(1, D::INDEPENDENT_BLEND, usages, Fl::BLENDABLE),
(2, D::empty(), usages, Fl::BLENDABLE),
(2, D::INDEPENDENT_BLEND, U::TEXTURE_BINDING, Fl::BLENDABLE),
(2, D::INDEPENDENT_BLEND, U::RENDER_ATTACHMENT, Fl::BLENDABLE),
(2, D::INDEPENDENT_BLEND, usages, Fl::empty()),
] {
let coverage = hud_coverage::supported(
attachments,
flags,
TextureFormatFeatures {
allowed_usages,
flags: format_flags,
},
);
let device = (true, true, true, coverage);
assert_eq!(
frame_configuration(true, device, F::Bgra8Unorm, false, true),
(WIDE_FRAME, false)
);
assert_eq!(
frame_configuration(false, device, F::Bgra8Unorm, false, true),
(F::Bgra8Unorm, false)
);
assert_eq!(
frame_configuration(true, device, F::Bgra8Unorm, true, false),
(LINEAR_FRAME, false)
);
// A 0.2 pack keeps its float frame, without the layer: SDR.
assert_eq!(
frame_configuration(true, device, F::Bgra8Unorm, true, true),
(LINEAR_FRAME, false)
);
}
}
#[test]
fn a_0_2_pack_without_the_hud_layer_finishes_on_the_swapchains_8_bits() {
use super::{LINEAR_FRAME, WIDE_FRAME, overlay_format};
use wgpu::TextureFormat::{Bgra8Unorm, Bgra8UnormSrgb, Rgba8Unorm};
assert_eq!(
overlay_format(LINEAR_FRAME, false, Bgra8UnormSrgb),
Bgra8Unorm
);
assert_eq!(overlay_format(LINEAR_FRAME, false, Rgba8Unorm), Rgba8Unorm);
// Everything else draws its overlays on its frame: the layered frame's light, the
// 16-bit frame, the 8-bit frame.
for (frame, layered) in [
(LINEAR_FRAME, true),
(WIDE_FRAME, false),
(Bgra8Unorm, false),
] {
assert_eq!(overlay_format(frame, layered, Bgra8UnormSrgb), frame);
}
}
#[test]
fn one_tone_curve_runs_on_the_layered_world() {
use super::horizon_tone_curve;
// No pack, or a 0.2 pack without `[tonemap]`: Horizon's curve.
assert!(horizon_tone_curve(true, true, false));
// A pack's `[tonemap]` mapped it already; never both.
assert!(!horizon_tone_curve(true, true, true));
// No world (the title screen), or no layered frame (SDR, the compatibility frames).
assert!(!horizon_tone_curve(true, false, false));
assert!(!horizon_tone_curve(false, true, false));
}
#[test]
fn only_a_no_pack_layered_world_applies_hdr_gain() {
use super::no_pack_hdr_gain;
assert!(no_pack_hdr_gain(true, false, true));
assert!(!no_pack_hdr_gain(false, false, true));
assert!(!no_pack_hdr_gain(true, true, true));
assert!(!no_pack_hdr_gain(true, false, false));
}
#[test]
fn taa_culling_covers_jittered_view_edges_but_still_rejects_behind_the_camera() {
use super::*;
@@ -13991,10 +13693,15 @@ mod tests {
// The shared maths: it needs the file it is appended to. GPU culling's
// occlusion test is appended to gpu_culling.wgsl, on Vulkan alone.
continue;
} else if name == "rt_unpack.wgsl" {
// Appended to terrain.wgsl (`ray_scene`'s `Unpacker`), on ray tracing devices.
continue;
} else if name == "terrain.wgsl"
|| name == "entity.wgsl"
|| name == "stars.wgsl"
|| name == "custom_sky.wgsl"
|| name == "border.wgsl"
|| name == "world_primitives.wgsl"
{
let base = include_str!("hdr_light.wgsl").to_owned() + &text;
sources.push((path, crate::overlay::wgsl_constants() + &base));
@@ -14012,7 +13719,7 @@ mod tests {
// Appended to terrain.wgsl (gfx/visbuffer.rs).
sources.push((path, super::terrain_shader() + &text));
} else if name == "oit_border.wgsl" {
let base = include_str!("border.wgsl").to_owned() + include_str!("oit.wgsl");
let base = super::border_shader() + include_str!("oit.wgsl");
sources.push((path, base + &text));
} else if name == "oit_clouds.wgsl" {
let base = super::entity_shader() + include_str!("oit.wgsl");
@@ -15068,11 +14775,21 @@ mod sprite_upload_tests {
limit_bucket: None,
};
info.backend = wgpu::Backend::Metal;
assert_eq!(adapter_requested_split(&info, None), Some(512));
assert_eq!(adapter_requested_split(&info, Some(1024)), Some(1024));
assert_eq!(adapter_requested_split(&info, None, 1200), Some(512));
assert_eq!(
adapter_requested_split(&info, None, 512 * SPRITE_ARRAYS),
Some(512)
);
assert_eq!(adapter_requested_split(&info, Some(1024), 1200), Some(1024));
// More sprites than the arrays hold at 512: the arrays grow, up to the GPU's layers.
let split = adapter_requested_split(&info, None, 10_271);
assert_eq!(split, Some(1284));
assert_eq!(layer_split(2048, false, split, 10_271), Ok(1284));
let split = adapter_requested_split(&info, None, 2048 * SPRITE_ARRAYS + 1);
assert!(layer_split(2048, false, split, 2048 * SPRITE_ARRAYS + 1).is_err());
info.backend = wgpu::Backend::Vulkan;
assert_eq!(adapter_requested_split(&info, None), None);
assert_eq!(adapter_requested_split(&info, None, 10_271), None);
}
#[test]
@@ -15357,7 +15074,7 @@ mod shadow_face_tests {
}
/// The layers (faces) of the quads the shadow pass draws of `solid`.
fn drawn(view: &pack_post::ShadowView, origin: [f32; 3]) -> Vec<u16> {
fn drawn(view: &external::ShadowView, origin: [f32; 3]) -> Vec<u16> {
let mut solid = block([7.0, 7.0, 7.0]);
let sides = crate::mesher::sort_tinted(&mut solid, &mut Vec::new());
let mut layers = Vec::new();
@@ -15370,8 +15087,8 @@ mod shadow_face_tests {
layers
}
fn view(every_face: bool) -> pack_post::ShadowView {
pack_post::ShadowView {
fn view(every_face: bool) -> external::ShadowView {
external::ShadowView {
cull: Mat4::IDENTITY,
light: Vec3::Y,
distance: None,
@@ -15585,7 +15302,7 @@ mod shadow_caster_tests {
..horizon_shaders::Shadow::default()
};
let camera = DVec3::new(13.5, y + 1.62, -13.5);
let view = pack_post::ShadowView {
let view = external::ShadowView {
cull: pack_uniforms::shadow_culling_at(Vec3::Y, camera, &shadow),
light: Vec3::Y,
distance: None,
@@ -136,6 +136,8 @@ impl Arena {
} else {
wgpu::BufferUsages::VERTEX
};
// BLAS builds read sections' vertices here (`ray_scene`).
let usage = usage | super::surface_setup::ray_query::blas_input(device);
Self::with_usage(device, stride, capacity, usage, PAGE_BYTES)
}
/// Sorted translucent indices; `storage` when compute passes read them.
@@ -0,0 +1,42 @@
//! A bind group's validity depends on the identity of its buffers and the
//! bound slice, not on the bytes written into those buffers.
pub(super) struct BindingKey<B, const N: usize> {
pub first: u64,
pub count: u64,
pub buffers: [B; N],
}
impl<B: PartialEq, const N: usize> BindingKey<B, N> {
pub fn matches(&self, first: u64, count: u64, buffers: [&B; N]) -> bool {
self.first == first
&& self.count == count
&& self
.buffers
.iter()
.zip(buffers)
.all(|(old, new)| old == new)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn changes_to_any_resource_or_range_invalidate_the_binding() {
let key = BindingKey {
first: 16,
count: 8,
buffers: [1u64, 2, 3, 4],
};
assert!(key.matches(16, 8, [&1, &2, &3, &4]));
assert!(!key.matches(0, 8, [&1, &2, &3, &4]));
assert!(!key.matches(16, 9, [&1, &2, &3, &4]));
for i in 0..4 {
let mut resources = [1u64, 2, 3, 4];
resources[i] += 100;
assert!(!key.matches(16, 8, resources.each_ref()));
}
}
}
@@ -0,0 +1,315 @@
//! A resource pack's `core/text` on world text, as vanilla 26.3's `TEXT_POLYGON_OFFSET` and
//! `TEXT_SEE_THROUGH` draw text displays, name tags and their backgrounds.
use crate::core_shader::{CoreProgram, Glue};
use std::sync::Arc;
/// The entity vertices' light bytes are sky then block; vanilla's `UV2` is block then sky.
pub(crate) const WORLD_GLUE: Glue = Glue {
inputs: &[
("Position", "vec3"),
("Color", "vec4"),
("UV0", "vec2"),
("UV2", "ivec2"),
],
prelude: "layout(location = 0) in vec3 hz_pos;\n\
layout(location = 1) in vec2 hz_uv;\n\
layout(location = 3) in vec4 hz_color;\n\
layout(location = 4) in uvec4 hz_light;",
setup: "Position = hz_pos; Color = hz_color; UV0 = hz_uv; UV2 = ivec2(hz_light.y, hz_light.x);",
finish: "",
vertex_id: "int(gl_VertexIndex)",
};
pub(crate) const SEE_THROUGH_GLUE: Glue = Glue {
inputs: &[("Position", "vec3"), ("Color", "vec4"), ("UV0", "vec2")],
prelude: "layout(location = 0) in vec3 hz_pos;\n\
layout(location = 1) in vec2 hz_uv;\n\
layout(location = 3) in vec4 hz_color;",
setup: "Position = hz_pos; Color = hz_color; UV0 = hz_uv;",
finish: "",
vertex_id: "int(gl_VertexIndex)",
};
#[derive(Default)]
pub struct TextShaders {
pub gui: Option<Arc<CoreProgram>>,
pub world: Option<Arc<CoreProgram>>,
pub see_through: Option<Arc<CoreProgram>>,
}
#[derive(Clone, Copy)]
pub(super) struct PackBlocks {
pub(super) globals: [u8; 48],
pub(super) fog: [f32; 6],
}
impl super::Renderer {
pub fn set_world_text_shaders(&mut self, shaders: &TextShaders) {
self.core_text = CoreText::new(
&self.device,
self.output.frame.format,
shaders.world.as_deref(),
shaders.see_through.as_deref(),
&self.entity_atlas,
(&self.lightmap_view, &self.lightmap_sampler),
);
}
pub(super) fn pack_blocks(
&self,
frame: &super::Frame,
fog: [f32; 4],
ends: [f32; 4],
) -> PackBlocks {
let globals = crate::post_chain::Globals {
glint_alpha: frame.glint_alpha,
..frame.post_effects.globals
};
let size = [self.config.width, self.config.height];
PackBlocks {
globals: globals.bytes(size, frame.post_effects.menu_blur_radius as i32),
fog: [fog[0], fog[1], fog[2], fog[3], ends[0], ends[1]],
}
}
pub(super) fn write_core_text(
&self,
pack: &PackBlocks,
view: &super::View,
pose: glam::Mat4,
jitter: glam::Mat4,
fog_color: [f32; 4],
) {
let Some(core) = &self.core_text else {
return;
};
let data =
super::core_lines::uniforms(pack.globals, view, pose, jitter, 1.0, fog_color, pack.fog);
self.queue.write_buffer(&core.uniform, 0, &data);
}
pub(super) fn bind_world_text(
&self,
pass: &mut wgpu::RenderPass<'_>,
allowed: bool,
see_through: bool,
) {
let pack = self
.core_text
.as_ref()
.filter(|_| allowed)
.and_then(|core| {
let pipeline = if see_through {
core.see_through.as_ref()
} else {
core.polygon_offset.as_ref()
};
Some((pipeline?, &core.group))
});
match pack {
Some((pipeline, group)) => {
pass.set_pipeline(pipeline);
pass.set_bind_group(0, group, &[]);
}
None => pass.set_bind_group(0, &self.entity_group, &[]),
}
}
}
const BLOCK_OFFSETS: [u64; 4] = [0, 256, 512, 768];
const BLOCK_SIZES: [u64; 4] = [48, 64, 160, 48];
pub(super) struct CoreText {
pub(super) polygon_offset: Option<wgpu::RenderPipeline>,
pub(super) see_through: Option<wgpu::RenderPipeline>,
pub(super) group: wgpu::BindGroup,
uniform: wgpu::Buffer,
}
impl CoreText {
pub(super) fn new(
device: &wgpu::Device,
format: wgpu::TextureFormat,
world: Option<&CoreProgram>,
see_through: Option<&CoreProgram>,
atlas: &wgpu::TextureView,
lightmap: (&wgpu::TextureView, &wgpu::Sampler),
) -> Option<Self> {
if world.is_none() && see_through.is_none() {
return None;
}
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
let internal = device.push_error_scope(wgpu::ErrorFilter::Internal);
let buffer = |binding| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: wgpu::BufferSize::new(BLOCK_SIZES[binding as usize]),
},
count: None,
};
let texture = |binding| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
};
let sampler = |binding| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
};
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Resource pack world text"),
entries: &[
buffer(0),
buffer(1),
buffer(2),
buffer(3),
texture(4),
sampler(5),
texture(8),
sampler(9),
],
});
let uniform = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Resource pack world text uniforms"),
size: 1024,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let glyphs = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("Resource pack world text glyphs"),
..Default::default()
});
let mut resources: Vec<wgpu::BindGroupEntry> = (0..4)
.map(|i| wgpu::BindGroupEntry {
binding: i as u32,
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
buffer: &uniform,
offset: BLOCK_OFFSETS[i],
size: wgpu::BufferSize::new(BLOCK_SIZES[i]),
}),
})
.collect();
resources.extend([
wgpu::BindGroupEntry {
binding: 4,
resource: wgpu::BindingResource::TextureView(atlas),
},
wgpu::BindGroupEntry {
binding: 5,
resource: wgpu::BindingResource::Sampler(&glyphs),
},
wgpu::BindGroupEntry {
binding: 8,
resource: wgpu::BindingResource::TextureView(lightmap.0),
},
wgpu::BindGroupEntry {
binding: 9,
resource: wgpu::BindingResource::Sampler(lightmap.1),
},
]);
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Resource pack world text"),
layout: &layout,
entries: &resources,
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Resource pack world text"),
bind_group_layouts: &[Some(&layout)],
..Default::default()
});
let attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint8x4,5=>Snorm8x4,6=>Float32x2];
let buffers = [Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<crate::mesher::Vertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &attributes,
})];
let pipeline = |label, program: &CoreProgram, depth: wgpu::DepthStencilState| {
let module = |module: &naga::Module| {
device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some(label),
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module.clone())),
})
};
let (vertex, fragment) = (module(&program.vertex), module(&program.fragment));
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(label),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &vertex,
entry_point: Some("main"),
compilation_options: Default::default(),
buffers: &buffers,
},
primitive: wgpu::PrimitiveState {
cull_mode: Some(wgpu::Face::Back),
..Default::default()
},
depth_stencil: Some(depth),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &fragment,
entry_point: Some("main"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
})
};
let depth = |write, compare, bias| wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(write),
depth_compare: Some(compare),
stencil: Default::default(),
bias,
};
let offset = wgpu::DepthBiasState {
constant: 10,
slope_scale: 1.0,
clamp: 0.0,
};
let polygon_offset = world.map(|program| {
pipeline(
"Resource pack polygon offset text",
program,
depth(true, wgpu::CompareFunction::GreaterEqual, offset),
)
});
let see_through = see_through.map(|program| {
pipeline(
"Resource pack see-through text",
program,
depth(false, wgpu::CompareFunction::Always, Default::default()),
)
});
let internal = pollster::block_on(internal.pop());
if let Some(e) = internal.or(pollster::block_on(validation.pop())) {
log::warn!("the resource pack's world text shader is not used: {e}");
return None;
}
Some(Self {
polygon_offset,
see_through,
group,
uniform,
})
}
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,201 @@
use super::*;
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
pollster::block_on(adapter.request_device(&Default::default())).ok()
}
#[test]
fn a_pack_text_shader_reads_the_light_as_vanilla_does() {
let Some((device, queue)) = device() else {
eprintln!("no GPU: skipping");
return;
};
let vsh = "#version 330\n#extension GL_ARB_separate_shader_objects : require\n\
layout(location = 0) in vec3 Position;\nlayout(location = 1) in vec4 Color;\n\
layout(location = 2) in vec2 UV0;\nlayout(location = 3) in ivec2 UV2;\n\
layout(location = 0) flat out ivec2 light;\n\
void main() { gl_Position = vec4(Position, 1.0); light = UV2; }\n";
let fsh = "#version 330\n#extension GL_ARB_separate_shader_objects : require\n\
layout(location = 0) flat in ivec2 light;\nlayout(location = 0) out vec4 fragColor;\n\
void main() { fragColor = vec4(vec2(light) / 255.0, 0.0, 1.0); }\n";
let program =
crate::core_shader::translate_core(vsh, fsh, &[], &|_| None, &WORLD_GLUE).unwrap();
const SIZE: u32 = 64;
let texture = |format, usage| {
device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: SIZE,
height: SIZE,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage,
view_formats: &[],
})
};
let binding = wgpu::TextureUsages::TEXTURE_BINDING;
let atlas = texture(wgpu::TextureFormat::Rgba8Unorm, binding).create_view(&Default::default());
let sampler = device.create_sampler(&Default::default());
let text = CoreText::new(
&device,
wgpu::TextureFormat::Rgba8Unorm,
Some(&program),
None,
&atlas,
(&atlas, &sampler),
)
.expect("the pack's pipeline");
let target = wgpu::TextureUsages::RENDER_ATTACHMENT;
let color = texture(
wgpu::TextureFormat::Rgba8Unorm,
target | wgpu::TextureUsages::COPY_SRC,
);
let depth = texture(wgpu::TextureFormat::Depth32Float, target);
let light = 0x30 | (0xC0 << 8);
let corner = |x: f32, y: f32| crate::mesher::Vertex {
pos: [x, y, 0.5],
uv: [0.0; 2],
layer: 0,
color: u32::MAX,
light,
normal: [0; 4],
glint_uv: [0.0; 2],
};
let quad = [
corner(-1.0, -1.0),
corner(1.0, -1.0),
corner(1.0, 1.0),
corner(-1.0, -1.0),
corner(1.0, 1.0),
corner(-1.0, 1.0),
];
let vertices = wgpu::util::DeviceExt::create_buffer_init(
&device,
&wgpu::util::BufferInitDescriptor {
label: None,
contents: bytemuck::cast_slice(&quad),
usage: wgpu::BufferUsages::VERTEX,
},
);
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: u64::from(SIZE * SIZE * 4),
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut encoder = device.create_command_encoder(&Default::default());
{
let color_view = color.create_view(&Default::default());
let depth_view = depth.create_view(&Default::default());
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: None,
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &color_view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &depth_view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(0.0),
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
});
pass.set_pipeline(text.polygon_offset.as_ref().unwrap());
pass.set_bind_group(0, &text.group, &[]);
pass.set_vertex_buffer(0, vertices.slice(..));
pass.draw(0..6, 0..1);
}
encoder.copy_texture_to_buffer(
color.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &read,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(SIZE * 4),
rows_per_image: Some(SIZE),
},
},
color.size(),
);
queue.submit([encoder.finish()]);
read.slice(..)
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let pixels = read.slice(..).get_mapped_range().unwrap().to_vec();
let middle = ((SIZE / 2 * SIZE + SIZE / 2) * 4) as usize;
assert_eq!(
&pixels[middle..middle + 4],
&[0xC0, 0x30, 0, 0xFF],
"UV2 is (block, sky)"
);
}
#[test]
#[ignore = "requires prepared resources in HORIZON_TEST_RESOURCES or .local/horizon-client/26.3, and a GPU"]
fn vanillas_text_shader_makes_both_world_text_pipelines() {
let root = std::env::var_os("HORIZON_TEST_RESOURCES")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| {
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION)
});
let read = |path: &str| std::fs::read_to_string(root.join(path)).ok();
let vertex = read("assets/minecraft/shaders/core/text.vsh").expect("vanilla's text.vsh");
let fragment = read("assets/minecraft/shaders/core/text.fsh").expect("vanilla's text.fsh");
let include = |id: &str| {
let (namespace, path) = id.split_once(':')?;
read(&format!("assets/{namespace}/{path}"))
};
let translate = |defines: &[&str], glue: &Glue| {
crate::core_shader::translate_core(&vertex, &fragment, defines, &include, glue)
.expect("vanilla's text shader translates")
};
let world = translate(&[], &WORLD_GLUE);
let see_through = translate(&["IS_SEE_THROUGH"], &SEE_THROUGH_GLUE);
let (device, _) = device().expect("a GPU");
let texture = || {
device
.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 4,
height: 4,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
})
.create_view(&Default::default())
};
let (atlas, lightmap) = (texture(), texture());
let sampler = device.create_sampler(&Default::default());
let text = CoreText::new(
&device,
wgpu::TextureFormat::Rgba8Unorm,
Some(&world),
Some(&see_through),
&atlas,
(&lightmap, &sampler),
)
.expect("the pack's world text pipelines");
assert!(text.polygon_offset.is_some() && text.see_through.is_some());
}
+48 -5
View File
@@ -37,7 +37,9 @@
//! are drawn in a pass of their own between the phases, which reads phase
//! 1's depth as a texture and makes the same comparison in the shader: the
//! fragment's depth against the stored one, greater or equal passing. The
//! same fragments pass, so the same sections are seen.
//! same fragments pass, so the same sections are seen. Adreno's Vulkan driver
//! takes that path too: with the early test it now and then leaves sections
//! out for a frame.
use std::num::NonZeroU64;
use std::sync::{
Arc,
@@ -220,17 +222,42 @@ const RESULTS_USAGE: wgpu::BufferUsages =
const READBACK_USAGE: wgpu::BufferUsages =
wgpu::BufferUsages::MAP_READ.union(wgpu::BufferUsages::COPY_DST);
/// The first `n` values of a packed lookup in WGSL `source` (`fn name`, as cull.wgsl's
/// `box_triangle_corner`): four bits each, eight a word, its words in the order of its cases.
#[cfg(test)]
pub(super) fn packed_lookup(source: &str, name: &str, n: usize) -> Vec<u32> {
let body = &source[source.find(&format!("fn {name}(")).expect("the lookup")..];
let body = &body[..body.find("return").expect("its return")];
let words: Vec<u32> = body
.split(|c: char| !c.is_ascii_alphanumeric())
.filter_map(|t| t.strip_prefix("0x")?.strip_suffix('u'))
.map(|hex| u32::from_str_radix(hex, 16).expect("a hex word"))
.collect();
(0..n)
.map(|v| (words[v / 8] >> (v % 8 * 4)) & 0xf)
.collect()
}
/// Qualcomm's PCI vendor id, which wgpu reports for Adreno GPUs.
const ADRENO: u32 = 0x5143;
impl Cull {
/// The device feature culling needs, where the adapter has it.
pub fn features(adapter: &wgpu::Adapter) -> wgpu::Features {
adapter.features() & wgpu::Features::SHADER_EARLY_DEPTH_TEST
}
/// Where the device can run the test, if it can: Vulkan with early
/// depth tests, or Metal and DirectX 12 with the test in the shader, all
/// drawing an indirect multi-draw's entries with the instance counts the
/// GPU wrote, with fragment shaders that write storage.
/// depth tests (Adreno's in the shader), or Metal and DirectX 12 with the
/// test in the shader, all drawing an indirect multi-draw's entries with
/// the instance counts the GPU wrote, with fragment shaders that write
/// storage.
pub fn supported(adapter: &wgpu::Adapter, device: &wgpu::Device) -> Option<DepthTest> {
let depth_test = match adapter.get_info().backend {
let info = adapter.get_info();
let depth_test = match info.backend {
// Adreno's Vulkan driver (the AYANEO Pocket DS's Adreno 740, build 0676.41) now and
// then leaves sections out with the early test, the world flashing black or
// see-through as a block breaks; the test in the shader, as on Metal, holds.
wgpu::Backend::Vulkan if info.vendor == ADRENO => DepthTest::InShader,
wgpu::Backend::Vulkan | wgpu::Backend::Gl
if device
.features()
@@ -772,4 +799,20 @@ mod tests {
.unwrap_or_else(|e| panic!("{depth_test:?}: {e:?}"));
}
}
/// The box's 12 triangles, as cull.wgsl and gpu_occlusion.wgsl draw them: two per face,
/// counter-clockwise seen from outside, corners by bit (x 1, y 2, z 4).
#[test]
fn the_packed_box_corners_are_the_box_triangles() {
let triangles = [
0, 6, 2, 0, 4, 6, 1, 3, 7, 1, 7, 5, 0, 1, 5, 0, 5, 4, 2, 7, 3, 2, 6, 7, 0, 3, 1, 0, 2,
3, 4, 5, 7, 4, 7, 6,
];
for source in [
include_str!("cull.wgsl"),
include_str!("gpu_occlusion.wgsl"),
] {
assert_eq!(packed_lookup(source, "box_triangle_corner", 36), triangles);
}
}
}
+16 -6
View File
@@ -34,11 +34,21 @@ struct Test {
@group(0) @binding(5) var<uniform> params: Params;
// The corners (bit 0: x, bit 1: y, bit 2: z) of the box's 12 triangles,
// counter-clockwise seen from outside.
const CORNERS = array<u32, 36>(
0u, 6u, 2u, 0u, 4u, 6u, 1u, 3u, 7u, 1u, 7u, 5u, 0u, 1u, 5u, 0u, 5u, 4u,
2u, 7u, 3u, 2u, 6u, 7u, 0u, 3u, 1u, 0u, 2u, 3u, 4u, 5u, 7u, 4u, 7u, 6u,
);
// counter-clockwise seen from outside: 0 6 2, 0 4 6, 1 3 7, 1 7 5, 0 1 5, 0 5 4,
// 2 7 3, 2 6 7, 0 3 1, 0 2 3, 4 5 7, 4 7 6.
// Packed four bits each, eight a word, read through a switch: a constant array indexed by the
// vertex would be a private array, which Adreno's Vulkan compiler (build 0676.41) fails to link.
fn box_triangle_corner(v: u32) -> u32 {
var word: u32;
switch v / 8u {
case 0u: { word = 0x31640260u; }
case 1u: { word = 0x05105717u; }
case 2u: { word = 0x76237245u; }
case 3u: { word = 0x54320130u; }
default: { word = 0x00006747u; }
}
return (word >> (v % 8u * 4u)) & 0xfu;
}
struct Box {
@builtin(position) position: vec4<f32>,
@@ -95,7 +105,7 @@ fn vs_box(@builtin(vertex_index) v: u32, @builtin(instance_index) t: u32) -> Box
out.position = select(vec4(0.0, 0.0, 0.0, 1.0), vec4(p, 1.0, 1.0), v < 3u);
return out;
}
out.position = globals.view_proj * vec4(corner(origin, low, high, CORNERS[v]), 1.0);
out.position = globals.view_proj * vec4(corner(origin, low, high, box_triangle_corner(v)), 1.0);
return out;
}
@@ -174,7 +174,12 @@ fn mesh() -> Vec<Vertex> {
}
impl CustomSky {
pub(super) fn new(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self {
/// The layers' pipelines for a frame of `format`, holding linear light where `linear`
/// (`linear_blend`).
pub(super) fn new(
device: &wgpu::Device,
(format, linear): (wgpu::TextureFormat, bool),
) -> Self {
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Custom sky bindings"),
entries: &[
@@ -261,10 +266,7 @@ impl CustomSky {
"fs_custom_sky"
}),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &[(
"HDR_WRITE_LIGHT",
f64::from(u8::from(format == super::LINEAR_FRAME)),
)],
constants: &super::linear_blend::frame_constants(format, linear),
..Default::default()
},
targets: &[Some(wgpu::ColorTargetState {
@@ -617,7 +619,7 @@ mod tests {
label: None,
source: wgpu::ShaderSource::Wgsl(source.into()),
});
let sky = CustomSky::new(&device, wgpu::TextureFormat::Rgba16Float);
let sky = CustomSky::new(&device, (wgpu::TextureFormat::Rgba16Float, false));
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: None,
bind_group_layouts: &[Some(&sky.layout)],
@@ -661,19 +663,37 @@ mod tests {
let view = target.create_view(&Default::default());
// Independent scalar expectation: a sky mark of 206/255 gives this gamma gain.
let hdr_gain = (1.0f32 + 3.0 * (1.0 - 206.0 / 255.0) / 0.24).powf(1.0 / 2.2);
for (mode_index, mode, debug, written) in MODES
// A frame of linear light (`FRAME_LINEAR`, `[frame] blend = "linear"`): the layer stored
// decoded, products kept exact.
let linear_modes = [
Blend::Replace,
Blend::Multiply,
Blend::Overlay,
Blend::Alpha,
]
.map(|m| {
(
MODES.iter().position(|&n| n == m).unwrap(),
m,
false,
false,
true,
)
});
for (mode_index, mode, debug, written, linear) in MODES
.iter()
.copied()
.enumerate()
.map(|(i, m)| (i, m, false, true))
.chain([(5, Blend::Screen, true, true)])
.map(|(i, m)| (i, m, false, true, false))
.chain([(5, Blend::Screen, true, true, false)])
.chain(
MODES
.iter()
.copied()
.enumerate()
.map(|(i, m)| (i, m, false, false)),
.map(|(i, m)| (i, m, false, false, false)),
)
.chain(linear_modes)
{
let gain = if written { hdr_gain } else { 1.0 };
let uniform = Uniform {
@@ -717,7 +737,10 @@ mod tests {
module: &shader,
entry_point: Some(entry),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &[("HDR_WRITE_LIGHT", 1.0)],
constants: &[
("HDR_WRITE_LIGHT", 1.0),
("FRAME_LINEAR", f64::from(u8::from(linear))),
],
..Default::default()
},
targets: &[Some(wgpu::ColorTargetState {
@@ -824,7 +847,17 @@ mod tests {
(alpha - coverage).abs() < 0.001,
"{mode:?}: alpha {alpha}, expected {coverage}"
);
let expected = if debug {
let decode = |v: f32| v.powf(2.2);
let expected = if linear {
// The sky under it is linear light, 2.0; the layer is gamma.
match mode {
Blend::Replace => decode(t),
Blend::Multiply => d * decode(1.0 + (t - 1.0) * k),
Blend::Overlay => d * decode(2.0 * (0.5 + (t - 0.5) * k)),
Blend::Alpha => decode(t) * k + d * (1.0 - k),
_ => unreachable!(),
}
} else if debug {
let grey = (0.2126f32 * 0.8f32.powf(2.2)
+ 0.7152 * 0.4f32.powf(2.2)
+ 0.0722 * 0.2f32.powf(2.2))
@@ -845,11 +878,11 @@ mod tests {
};
assert!(
(got - expected).abs() < 0.004,
"{mode:?} debug {debug}: {got} expected {expected}"
"{mode:?} debug {debug} linear {linear}: {got} expected {expected}"
);
}
eprintln!(
"HDR custom sky GPU ran all nine modes with and without gain, plus debug Screen, checking RGB and coverage"
"HDR custom sky GPU ran all nine modes with and without gain, plus debug Screen and four on linear light, checking RGB and coverage"
);
}
}
@@ -56,16 +56,25 @@ fn custom_sky_color(in: Out) -> vec4<f32> {
(layer.mode == ADD || layer.mode == SUBTRACT || layer.mode == REPLACE || layer.mode == ALPHA) {
color = hdr_written(color, 1.0 - 0.24 * layer.hdr_light.y, layer.hdr_settings, layer.hdr_shares);
}
// On a frame of linear light (`FRAME_LINEAR`) the layer's colour is decoded as it is stored.
switch layer.mode {
// dst * src: white leaves the sky alone.
case MULTIPLY: { return vec4(mix(vec3(1.0), t.rgb, k), 1.0); }
// 2 * dst * src: grey leaves it alone.
case OVERLAY: { return vec4(mix(vec3(0.5), t.rgb, k), 1.0); }
case REPLACE: { return vec4(color, 1.0); }
// mix(dst, src, alpha).
case ALPHA: { return vec4(color, k); }
// add, subtract, dodge, burn, screen: the layer premultiplied, black leaves it alone.
default: { return vec4(color * k, 1.0); }
// dst * src: white leaves the sky alone. Decoded, the product stays exact:
// (dst * src)^2.2 = dst^2.2 * src^2.2.
case MULTIPLY: { return frame_stored(vec4(mix(vec3(1.0), t.rgb, k), 1.0)); }
// 2 * dst * src: grey leaves it alone. Decoded as entity.wgsl's crumbling, (2 src)^2.2 / 2,
// so it stays exact too.
case OVERLAY: {
let factor = mix(vec3(0.5), t.rgb, k);
if FRAME_LINEAR { return vec4(pow(2.0 * factor, vec3(2.2)) * 0.5, 1.0); }
return vec4(factor, 1.0);
}
case REPLACE: { return frame_stored(vec4(color, 1.0)); }
// mix(dst, src, alpha): alpha is coverage, kept.
case ALPHA: { return frame_stored(vec4(color, k)); }
// add, subtract, dodge, burn, screen: the layer premultiplied, black leaves it alone. On
// linear light add and subtract add and take away light, as `"linear"` means to; dodge,
// burn and screen come out near their gamma results, not exactly.
default: { return vec4(frame_stored(vec4(color, 1.0)).rgb * k, 1.0); }
}
}
@@ -0,0 +1,214 @@
//! The hook an external shader pipeline fills (#14035, layer plan M6). A shader pack's runtime
//! (a platform crate, later) implements [`ExternalPipeline`]; the renderer calls it at fixed
//! points of a world frame and draws vanilla's way wherever it doesn't answer. With none set
//! (`Renderer::set_external_pipeline`), each point is one `Option` test.
//!
//! Workspace-internal: the trait names the engine's frame, its mesh format and its pass order,
//! has one implementer in this workspace, carries no stability promise and is never exposed to
//! mods. A mesher or pass change updates its implementer in the same PR. HDR output
//! (`output.rs`, `frame_policy`) is not an implementer: it stays the one output stage after the
//! frame, and a pipeline draws into the frame it gives.
//!
//! Shadows are an engine frame point, on request: a pipeline that wants a shadow pass answers
//! [`ShadowView`], the engine culls the sections from it ([`ShadowCull`]) and hands back the
//! terrain seen from the light. Vanilla's path never asks, so it never computes shadow
//! visibility.
use glam::{Mat4, Vec3, Vec4};
use super::{Cull, EffectiveOutput, Frame, Frustum, Sides, facing_runs, frustum};
/// What a world frame gives an external pipeline at its points: the frame's own values, and
/// the display's from the renderer's one output snapshot (`EffectiveOutput`: whether HDR is on,
/// paper white, peak), never worked out again, so each split-screen window keeps its own.
pub struct FrameState<'a> {
pub frame: &'a Frame<'a>,
pub output: &'a EffectiveOutput,
}
/// An external shader pipeline, called by the renderer at fixed points of a world frame (not
/// title frames). Every method has a default that leaves vanilla's way in place.
pub trait ExternalPipeline: Send {
/// A world frame starts, before culling.
fn begin_frame(&mut self, _state: &FrameState<'_>) {}
/// The shadow pass this frame wants; `None` draws none.
fn shadow_request(&self, _state: &FrameState<'_>) -> Option<ShadowView> {
None
}
/// The window's drawable size changed.
fn resize(&mut self, _size: [u32; 2]) {}
/// Forget what was kept from earlier frames: the next is drawn as a fresh pipeline's first.
fn reset_history(&mut self) {}
}
/// What the shadow camera sees of the terrain, for the game's draw lists: its view and
/// projection in wgpu's clip space, the direction toward the light, and how far from the camera
/// terrain is drawn (`shadowDistanceRenderMul`).
pub struct ShadowView {
pub cull: glam::Mat4,
pub light: glam::Vec3,
pub distance: Option<f32>,
/// Draw everything, every face, within this many blocks (`shadow.culling=false|reversed`):
/// packs that voxelize the world in their shadow pass need all of it.
pub unculled: Option<f32>,
/// Every face of the drawn sections, not only those that can face the light. Iris 1.11.7's
/// shadow map holds the faces turned away from the light too (#9033): under a hill at low sun
/// a cave's ceiling, which faces away, is often all that shadows the cave.
pub every_face: bool,
/// Only the sections whose shadow can fall into the camera's view: those the camera's
/// frustum, stretched toward the light, takes in (`shadow_casters`). Our probes' Iris 1.11.7
/// maps fit keeping only those with `shadow.culling` unset (#8464): under a noon sun, only
/// the terrain above the ground the camera sees; with a low sun ahead, none behind the camera.
pub view_casters: bool,
/// Whether the projection's near and far planes in `cull` drop sections too. Our probes'
/// Iris 1.11.7 maps hold the terrain at every depth we put it along the light (from 535 blocks
/// beyond the shadow camera to about 120 before it), whatever `shadowNearPlane` and
/// `shadowFarPlane` the pack sets, or none (#10403): only its sides cull.
pub depth_planes: bool,
}
/// A shader pack's shadow camera's box, and with `casters` only what can shade the camera's view
/// (`shadow_casters`): a box must meet all of them.
#[derive(Clone, Copy, Debug)]
pub struct ShadowCull {
pub light: Frustum,
pub casters: Option<[Frustum; 2]>,
/// `shadowDistance × shadowDistanceRenderMul`: only what lies within this many blocks of the
/// camera along each axis, a cube around it. Our probe's Iris 1.11.7 maps keep the sections
/// whose box, grown by `SHADOW_CASTER_MARGIN` as for the casters, meets that cube (#8464):
/// out to its corners, and a section 1 block past its side but not one 1.5 blocks past.
pub reach: Option<f32>,
}
impl ShadowCull {
/// Whether the camera-relative box `min..max`, grown by the margin, meets (`whole`: lies
/// within) `reach`'s cube.
fn within_reach(&self, min: Vec3, max: Vec3, whole: bool) -> bool {
let (min, max) = (min - SHADOW_CASTER_MARGIN, max + SHADOW_CASTER_MARGIN);
self.reach.is_none_or(|d| {
if whole {
min.cmpge(Vec3::splat(-d)).all() && max.cmple(Vec3::splat(d)).all()
} else {
max.cmpge(Vec3::splat(-d)).all() && min.cmple(Vec3::splat(d)).all()
}
})
}
}
/// How far past a section's 16 blocks the shadow pass looks for what can shade the view: our
/// probes' Iris 1.11.7 maps keep the sections whose box, grown by this much on every side, meets
/// `shadow_casters`' shape. Over 76 scenes at fields of view 30 to 110, Iris keeps every section
/// that needs up to 1.1224 blocks and drops every one that needs 1.1252 or more (#8464): one
/// growth, whatever the field of view, of 9/8 (a fit, not Iris's code). Without it the edges of
/// the wedge lose a column of terrain.
const SHADOW_CASTER_MARGIN: f32 = 1.125;
impl Cull for ShadowCull {
fn intersects(&self, min: Vec3, max: Vec3) -> bool {
let (grown_min, grown_max) = (min - SHADOW_CASTER_MARGIN, max + SHADOW_CASTER_MARGIN);
self.within_reach(min, max, false)
&& self.light.intersects(min, max)
&& self
.casters
.is_none_or(|c| c.iter().all(|f| f.intersects(grown_min, grown_max)))
}
fn contains(&self, min: Vec3, max: Vec3) -> bool {
let (grown_min, grown_max) = (min - SHADOW_CASTER_MARGIN, max + SHADOW_CASTER_MARGIN);
self.within_reach(min, max, true)
&& self.light.contains(min, max)
&& self
.casters
.is_none_or(|c| c.iter().all(|f| f.contains(grown_min, grown_max)))
}
/// Our probe's Iris 1.11.7 maps fit testing the casters' whole sections (a guess that matched).
fn tight(&self) -> bool {
self.casters.is_none()
}
}
/// The shadow camera's box that the shadow pass draws terrain in: the sides of `view.cull`, and
/// its near and far planes only with `depth_planes`.
pub(super) fn shadow_box(view: &ShadowView) -> Frustum {
let mut planes = frustum(view.cull);
if !view.depth_planes {
// `frustum`'s faces at depth 0 and 1 take in everything.
planes[4] = Vec4::W;
planes[5] = Vec4::W;
}
Frustum { planes }
}
/// The runs of a section's solid quads the shadow pass draws: every face within the unculled
/// distance, or everywhere for `every_face`; else the quads that can face the light.
pub(super) fn shadow_runs(
sides: &Sides,
view: &ShadowView,
origin: [f32; 3],
draw: impl FnMut(u32, u32),
) {
let unculled = view.unculled.is_some_and(|d| {
let o = Vec3::from(origin) + 8.0;
o.x.abs().max(o.z.abs()) <= d + 8.0
});
let toward = (!unculled && !view.every_face).then_some(view.light * 1.0e5);
facing_runs(sides, toward, origin, draw);
}
/// The camera's view `view_proj` (wgpu depth, reversed or not) stretched without end toward
/// `light` (a direction): the boxes whose shadow, cast away from the light, can fall into the
/// view. Its planes are the view's faces turned toward the light, and through each edge between
/// such a face and one turned away, the plane along the light; unused ones take in everything.
pub(super) fn shadow_casters(view_proj: Mat4, light: Vec3) -> [Frustum; 2] {
let faces = frustum(view_proj);
let toward = faces.map(|p| p.truncate().dot(light) >= 0.0);
let inverse = view_proj.inverse();
// Corner (x, y, z) of clip space's box; `frustum`'s faces at x -1 and 1 are 0 and 1, at
// y -1 and 1 are 2 and 3, at depth 0 and 1 are 4 and 5.
let corner = |c: [usize; 3]| {
inverse.project_point3(Vec3::new(
c[0] as f32 * 2.0 - 1.0,
c[1] as f32 * 2.0 - 1.0,
c[2] as f32,
))
};
let corners: Vec<[usize; 3]> = (0..8).map(|i| [i & 1, (i >> 1) & 1, i >> 2]).collect();
let centre = corners.iter().map(|&c| corner(c)).sum::<Vec3>() / 8.0;
let mut planes = [Vec4::W; 12];
let mut n = 0;
for (k, &face) in faces.iter().enumerate() {
if toward[k] {
planes[n] = face;
n += 1;
}
}
for a in &corners {
for axis in 0..3 {
if a[axis] == 1 {
continue;
}
let mut b = *a;
b[axis] = 1;
// The edge from a to b lies on the faces of the two other axes.
let [f, g] = [(axis + 1) % 3, (axis + 2) % 3].map(|o| o * 2 + a[o]);
if toward[f] == toward[g] {
continue;
}
let (p, q) = (corner(*a), corner(b));
let normal = (q - p).cross(light);
if normal.length_squared() < 1e-12 {
continue;
}
let normal = normal.normalize();
let side = if normal.dot(centre - p) < 0.0 {
-1.0
} else {
1.0
};
// At most six such edges with at most four faces toward the light: 12 planes.
if let Some(plane) = planes.get_mut(n) {
*plane = (normal * side).extend(-normal.dot(p) * side);
n += 1;
}
}
}
let [a, b] = [0, 6].map(|i| Frustum {
planes: planes[i..i + 6].try_into().expect("six planes"),
});
[a, b]
}
+223 -69
View File
@@ -19,7 +19,10 @@
//! start, and the run is drawn indirectly with the count the copy reached.
use super::SolidPipeline;
use super::arena::GRAIN;
use smallvec::SmallVec;
#[path = "binding_cache.rs"]
mod binding_cache;
use binding_cache::BindingKey;
/// One section draw for `flatten.wgsl`: index count, first sorted index (or
/// `QUADS` for a solid draw; phase 2: its entry in phase 2's draws), base
@@ -85,6 +88,11 @@ pub struct Call {
pub count: u32,
}
struct CachedGroup<const N: usize> {
key: BindingKey<wgpu::Buffer, N>,
group: wgpu::BindGroup,
}
pub struct Flat {
/// The solid draws in one pass (`fs_solid`), where the GPU doesn't hide pixels
/// by tiles (`split_solid` in `Renderer::draw_frame`: elsewhere than Metal).
@@ -109,8 +117,9 @@ pub struct Flat {
phase2_call_data: Vec<[u32; 5]>,
/// GPU culling's phase 2 uses indirect draws; flat draws without culling do not.
indirect_execution: bool,
/// The copy of phase 2's draws, and its ranges.
phase2_group: Option<(wgpu::BindGroup, u32)>,
/// Bindings reused while the range and all GPU buffers are unchanged.
phase2_group: Option<CachedGroup<4>>,
groups: Vec<CachedGroup<3>>,
/// The layout of a page's bind group 1 (shader packs' terrain pipelines share it).
pub(super) draw_layout: wgpu::BindGroupLayout,
blocks_per_page: u64,
@@ -193,34 +202,70 @@ pub fn is_adreno_dx12(info: &wgpu::AdapterInfo) -> bool {
info.backend == wgpu::Backend::Dx12 && matches!(info.vendor, 0x4d4f_4351 | 0x5143)
}
/// A released Mesa version from its driver info, allowing distribution suffixes.
/// Development and release-candidate builds do not establish a stable release's safety.
fn parse_mesa_version(driver_info: &str) -> Option<(u32, u32, u32)> {
let version = driver_info
.strip_prefix("Mesa ")?
.split_whitespace()
.next()?;
let (version, suffix) = version.split_once('-').unwrap_or((version, ""));
if suffix.starts_with("devel") || suffix.starts_with("rc") {
return None;
}
let mut parts = version.split('.');
let version = (
parts.next()?.parse().ok()?,
parts.next()?.parse().ok()?,
parts.next()?.parse().ok()?,
);
parts.next().is_none().then_some(version)
}
/// Intel Gen11 (Ice Lake) and Gen12 (Tiger Lake, Rocket Lake, Alder Lake,
/// Raptor Lake) ANV Vulkan drivers have broken `first_instance` for
/// Raptor Lake) ANV Vulkan drivers had broken `first_instance` for
/// instance-step vertex attributes (#3529, #4599), causing terrain sections to
/// be drawn with another section's origin and flicker.
///
/// PR #15007 tested Raptor Lake (RPL-U, 0xa7a1) on Mesa 26.0.8 without terrain
/// flicker. Allow indirect multi-draw and GPU culling for Raptor Lake from that
/// release; earlier or unidentified drivers keep the workaround.
pub fn is_broken_intel_vulkan(info: &wgpu::AdapterInfo) -> bool {
info.backend == wgpu::Backend::Vulkan
&& info.vendor == 0x8086
&& (matches!(
info.device,
0x8a50..=0x8a71 // Gen11: Ice Lake
| 0x9a40..=0x9a7f // Gen12: Tiger Lake
| 0x4c80..=0x4c9a // Gen12: Rocket Lake
| 0x4600..=0x46ff // Gen12: Alder Lake (incl. 0x46a8 in #4599)
| 0xa700..=0xa7ff // Gen12: Raptor Lake
) || [
"ICL",
"Ice Lake",
"TGL",
"Tiger Lake",
"RKL",
"Rocket Lake",
"ADL",
"Alder Lake",
"RPL",
"Raptor Lake",
]
.iter()
.any(|tag| info.name.contains(tag)))
if info.backend != wgpu::Backend::Vulkan || info.vendor != 0x8086 {
return false;
}
let is_raptor_lake = matches!(info.device, 0xa700..=0xa7ff)
|| info.name.contains("RPL")
|| info.name.contains("Raptor Lake");
if is_raptor_lake
&& parse_mesa_version(&info.driver_info).is_some_and(|version| version >= (26, 0, 8))
{
return false;
}
matches!(
info.device,
0x8a50..=0x8a71 // Gen11: Ice Lake
| 0x9a40..=0x9a7f // Gen12: Tiger Lake
| 0x4c80..=0x4c9a // Gen12: Rocket Lake
| 0x4600..=0x46ff // Gen12: Alder Lake (incl. 0x46a8 in #4599)
| 0xa700..=0xa7ff // Gen12: Raptor Lake
) || [
"ICL",
"Ice Lake",
"TGL",
"Tiger Lake",
"RKL",
"Rocket Lake",
"ADL",
"Alder Lake",
"RPL",
"Raptor Lake",
]
.iter()
.any(|tag| info.name.contains(tag))
}
fn supported(adapter: &wgpu::Adapter, device: &wgpu::Device) -> bool {
@@ -515,6 +560,7 @@ impl Flat {
phase2_call_data: Vec::new(),
indirect_execution,
phase2_group: None,
groups: Vec::new(),
draw_layout,
blocks_per_page: page_vertices.div_ceil(GRAIN),
translucent_blocks_per_page: page_vertices.div_ceil(GRAIN),
@@ -626,6 +672,8 @@ impl Flat {
}
pub fn clear(&mut self) {
self.pages.clear();
self.groups.clear();
self.phase2_group = None;
self.free_ids.clear();
self.next_id = 0;
}
@@ -665,7 +713,6 @@ impl Flat {
sorted_page: impl Fn(u32) -> wgpu::Buffer,
timing: Option<&mut super::pack_timing::PassTiming>,
) -> bool {
self.phase2_group = None;
let phase2 = phase2.filter(|_| self.indirect_execution);
let phase1_draws = if phase2.is_some() {
phase1_draws
@@ -729,6 +776,9 @@ impl Flat {
self.shadow_calls = shadow_calls;
self.pass_calls = pass_calls;
if total == 0 || total * 4 > device.limits().max_storage_buffer_binding_size {
// Do not retain bindings for a frame that cannot use flat draws.
self.phase2_group = None;
self.groups.clear();
self.calls.clear();
self.phase1_calls = 0;
self.solid_calls = 0;
@@ -802,39 +852,63 @@ impl Flat {
phase2,
self.segments.iter().find(|(s, ..)| *s == Source::Seen),
) {
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Flatten phase 2 section draws"),
layout: &self.seen_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: ranges_at(first, count),
let buffers = [&self.ranges, &self.indices, phase2, &self.phase2_calls];
if !self
.phase2_group
.as_ref()
.is_some_and(|cached| cached.key.matches(first, count, buffers))
{
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Flatten phase 2 section draws"),
layout: &self.seen_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: ranges_at(first, count),
},
wgpu::BindGroupEntry {
binding: 2,
resource: self.indices.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 3,
resource: phase2.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 4,
resource: self.phase2_calls.as_entire_binding(),
},
],
});
self.phase2_group = Some(CachedGroup {
key: BindingKey {
first,
count,
buffers: buffers.map(|b| b.clone()),
},
wgpu::BindGroupEntry {
binding: 2,
resource: self.indices.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 3,
resource: phase2.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 4,
resource: self.phase2_calls.as_entire_binding(),
},
],
});
self.phase2_group = Some((group, count as u32));
group,
});
}
} else {
self.phase2_group = None;
}
let groups: SmallVec<[(wgpu::BindGroup, u32); 8]> = self
.segments
.iter()
.filter_map(|&(source, first, count)| {
let sorted = match source {
Source::Quads => None,
Source::Seen => return None,
Source::Sorted(page) => Some(sorted_page(page)),
};
let mut groups = 0;
for &(source, first, count) in &self.segments {
let sorted = match source {
Source::Quads => None,
Source::Seen => continue,
Source::Sorted(page) => Some(sorted_page(page)),
};
let buffers = [
&self.ranges,
sorted.as_ref().unwrap_or(&self.empty),
&self.indices,
];
if self
.groups
.get(groups)
.is_none_or(|cached| !cached.key.matches(first, count, buffers))
{
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Flatten section draws"),
layout: &self.compute_layout,
@@ -845,7 +919,7 @@ impl Flat {
},
wgpu::BindGroupEntry {
binding: 1,
resource: sorted.as_ref().unwrap_or(&self.empty).as_entire_binding(),
resource: buffers[1].as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 2,
@@ -853,17 +927,32 @@ impl Flat {
},
],
});
Some((group, count as u32))
})
.collect();
let cached = CachedGroup {
key: BindingKey {
first,
count,
buffers: buffers.map(|b| b.clone()),
},
group,
};
if groups < self.groups.len() {
self.groups[groups] = cached;
} else {
self.groups.push(cached);
}
}
groups += 1;
}
self.groups.truncate(groups);
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("Flatten section draws"),
timestamp_writes: timing.and_then(|t| t.compute_writes("Flatten")),
});
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);
for cached in &self.groups {
let count = cached.key.count as u32;
pass.set_bind_group(0, &cached.group, &[]);
pass.dispatch_workgroups(count.min(GROUP_LIMIT), count.div_ceil(GROUP_LIMIT), 1);
}
drop(pass);
true
@@ -919,16 +1008,17 @@ impl Flat {
encoder: &mut wgpu::CommandEncoder,
timing: Option<&mut super::pack_timing::PassTiming>,
) {
let Some((group, count)) = &self.phase2_group else {
let Some(cached) = &self.phase2_group else {
return;
};
let count = cached.key.count as u32;
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("Flatten phase 2 section draws"),
timestamp_writes: timing.and_then(|t| t.compute_writes("Flatten phase 2")),
});
pass.set_pipeline(&self.flatten_seen);
pass.set_bind_group(0, group, &[]);
pass.dispatch_workgroups((*count).min(GROUP_LIMIT), count.div_ceil(GROUP_LIMIT), 1);
pass.set_bind_group(0, &cached.group, &[]);
pass.dispatch_workgroups(count.min(GROUP_LIMIT), count.div_ceil(GROUP_LIMIT), 1);
}
/// The camera's translucent calls.
@@ -1080,6 +1170,70 @@ mod tests {
assert!(!is_adreno_dx12(&info));
}
#[test]
fn intel_vulkan_detection_preserves_workaround_on_older_mesa_and_allows_modern_raptor_lake() {
let mut info = wgpu::AdapterInfo {
name: "Intel(R) Iris(R) Xe Graphics (RPL-U)".into(),
vendor: 0x8086,
device: 0xa7a1,
device_type: wgpu::DeviceType::IntegratedGpu,
device_pci_bus_id: String::new(),
driver: "Intel open-source Mesa driver".into(),
driver_info: "Mesa 26.0.8-1ubuntu0.3".into(),
backend: wgpu::Backend::Vulkan,
subgroup_min_size: 0,
subgroup_max_size: 0,
transient_saves_memory: None,
limit_bucket: None,
};
for (driver_info, broken) in [
("Mesa 26.0.8-1ubuntu0.3", false),
("Mesa 26.0.8", false),
("Mesa 26.1.0", false),
("Mesa 27.0.0", false),
("Mesa 25.2.0", true),
("Mesa 26.0.0", true),
("Mesa 26.0.7", true),
("Mesa 26.0.8-devel", true),
("Mesa 27.0.0-rc1", true),
("Mesa 27.0.0-devel (git-abcdef)", true),
("Mesa 26", true),
("Mesa 26.0.8.1", true),
("Mesa unknown", true),
("26.0.8", true),
("", true),
] {
info.driver_info = driver_info.into();
assert_eq!(is_broken_intel_vulkan(&info), broken, "{driver_info}");
}
// PCI identification and the name fallback both allow the tested family.
info.driver_info = "Mesa 26.0.8".into();
info.device = 0;
assert!(!is_broken_intel_vulkan(&info));
info.name = "Intel Graphics".into();
info.device = 0xa7a1;
assert!(!is_broken_intel_vulkan(&info));
// Earlier families keep their workaround even with a modern Mesa release.
for (device, name) in [
(0x8a52, "Intel Ice Lake"),
(0x9a49, "Intel Tiger Lake"),
(0x4c8a, "Intel Rocket Lake"),
(0x46a8, "Intel Alder Lake"),
] {
info.device = device;
info.name = name.into();
assert!(is_broken_intel_vulkan(&info), "{name}");
}
// A Mesa-looking version cannot turn on this workaround for other backends/vendors.
info.backend = wgpu::Backend::Dx12;
assert!(!is_broken_intel_vulkan(&info));
info.backend = wgpu::Backend::Vulkan;
info.vendor = 0x10de;
assert!(!is_broken_intel_vulkan(&info));
}
/// Draws as `render` writes them: solid (page, quads at a base vertex)
/// first, then translucent (page, sorted page, count, first sorted index,
/// base vertex).
@@ -0,0 +1,72 @@
//! The frame policy: which frame a renderer draws, what the overlays draw on and whether
//! Horizon's tone curve runs (tests in `frame_policy_tests.rs`).
use super::{LINEAR_FRAME, WIDE_FRAME};
/// What a renderer's frame is drawn in: `LINEAR_FRAME` under the switch where the device draws
/// it, for a 0.2 pack, or for the layered HDR frame where the surface offers HDR output
/// (`linear`: `(a 0.2 pack picked, the layered HDR frame, the device draws it)`); else
/// `WIDE_FRAME` when it is made with HDR on and Smooth Gradients (`wide_frame`,
/// `output::resolve_frame`), the surface offering HDR output (`hdr_offered`) and the device
/// drawing it (`draws_wide_frame`): an 8-bit frame's steps band in dark gradients, and HDR
/// shows them. Else the swapchain's 8-bit format, as without HDR.
pub(super) fn frame_format(
wide_frame: bool,
hdr_offered: bool,
device_draws_it: bool,
swapchain: wgpu::TextureFormat,
linear: (bool, bool, bool),
) -> wgpu::TextureFormat {
if linear.2 && (linear.0 || linear.1 && hdr_offered) {
LINEAR_FRAME
} else if wide_frame && hdr_offered && device_draws_it {
WIDE_FRAME
} else {
swapchain.remove_srgb_suffix()
}
}
/// The frame's format, and whether it is the layered HDR frame (HUD coverage): for HDR on
/// under the switch (`hdr_float`), with no pack or a 0.2 one, where the device keeps the HUD
/// on a layer of its own.
pub(super) fn frame_configuration(
wide_frame: bool,
device: (bool, bool, bool, bool),
swapchain: wgpu::TextureFormat,
linear_pack: bool,
hdr_float: bool,
) -> (wgpu::TextureFormat, bool) {
let (hdr_offered, wide_ok, linear_ok, coverage_ok) = device;
let layered = hdr_float && hdr_offered && linear_ok && coverage_ok;
(
frame_format(
wide_frame,
hdr_offered,
wide_ok,
swapchain,
(linear_pack, layered, linear_ok),
),
layered,
)
}
/// What the hand, the camera overlays and the HUD draw on: the frame, except for a 0.2 pack's
/// float frame without the HUD's layer, which is SDR: the pack's picture finishes on the
/// swapchain's 8-bit format first, so they clip and blend per draw as on vanilla's frame.
pub(super) fn overlay_format(
frame: wgpu::TextureFormat,
layered: bool,
swapchain: wgpu::TextureFormat,
) -> wgpu::TextureFormat {
if frame == LINEAR_FRAME && !layered {
swapchain.remove_srgb_suffix()
} else {
frame
}
}
/// Whether Horizon's tone curve (`Hdr::tonemap`) runs on this frame: on the layered frame's
/// world, unless a 0.2 pack's `[tonemap]` already mapped it. One tone mapping, never two.
pub(super) fn horizon_tone_curve(layered: bool, world: bool, pack_tonemap: bool) -> bool {
layered && world && !pack_tonemap
}
@@ -0,0 +1,137 @@
//! The frame policy: which frame a renderer draws (`frame_configuration`), and what follows
//! from the frame chosen.
#[test]
fn float_frames_never_schedule_light_mark_passes() {
use super::{LINEAR_FRAME, WIDE_FRAME, light_marks};
use wgpu::TextureFormat::{Bgra8Unorm, Rgb10a2Unorm, Rgba8Unorm};
for hdr in [false, true] {
assert!(!light_marks(LINEAR_FRAME, hdr));
for format in [WIDE_FRAME, Bgra8Unorm, Rgba8Unorm, Rgb10a2Unorm] {
assert_eq!(light_marks(format, hdr), hdr);
}
}
}
#[test]
fn frame_policy_keeps_compatibility_frames_and_requires_layering_for_float_hdr() {
use super::{LINEAR_FRAME, WIDE_FRAME, frame_configuration};
use wgpu::TextureFormat::{Bgra8Unorm, Bgra8UnormSrgb};
for bits in 0..128 {
let hdr = bits & 1 != 0;
let offered = bits & 2 != 0;
let wide_ok = bits & 4 != 0;
let float_ok = bits & 8 != 0;
let coverage_ok = bits & 16 != 0;
let switch = bits & 32 != 0;
let smooth = bits & 64 != 0;
for pack in ["none", "0.1", "0.2", "shaders"] {
let float_hdr = matches!(pack, "none" | "0.2");
let expected = if switch && float_ok && float_hdr && hdr && offered && coverage_ok {
(LINEAR_FRAME, true)
} else if switch && float_ok && pack == "0.2" {
(LINEAR_FRAME, false)
} else if hdr && smooth && offered && wide_ok {
(WIDE_FRAME, false)
} else {
(Bgra8Unorm, false)
};
assert_eq!(
frame_configuration(
hdr && smooth,
(offered, wide_ok, float_ok, coverage_ok),
Bgra8UnormSrgb,
switch && pack == "0.2",
switch && hdr && float_hdr,
),
expected,
"bits {bits}, pack {pack}"
);
}
}
}
#[test]
fn float_hdr_falls_back_for_each_missing_hud_layer_requirement() {
use super::{LINEAR_FRAME, WIDE_FRAME, frame_configuration, hud_coverage};
use wgpu::{DownlevelFlags as D, TextureFormat as F, TextureFormatFeatureFlags as Fl};
use wgpu::{TextureFormatFeatures, TextureUsages as U};
let usages = U::RENDER_ATTACHMENT | U::TEXTURE_BINDING;
for (attachments, flags, allowed_usages, format_flags) in [
(1, D::INDEPENDENT_BLEND, usages, Fl::BLENDABLE),
(2, D::empty(), usages, Fl::BLENDABLE),
(2, D::INDEPENDENT_BLEND, U::TEXTURE_BINDING, Fl::BLENDABLE),
(2, D::INDEPENDENT_BLEND, U::RENDER_ATTACHMENT, Fl::BLENDABLE),
(2, D::INDEPENDENT_BLEND, usages, Fl::empty()),
] {
let coverage = hud_coverage::supported(
attachments,
flags,
TextureFormatFeatures {
allowed_usages,
flags: format_flags,
},
);
let device = (true, true, true, coverage);
assert_eq!(
frame_configuration(true, device, F::Bgra8Unorm, false, true),
(WIDE_FRAME, false)
);
assert_eq!(
frame_configuration(false, device, F::Bgra8Unorm, false, true),
(F::Bgra8Unorm, false)
);
assert_eq!(
frame_configuration(true, device, F::Bgra8Unorm, true, false),
(LINEAR_FRAME, false)
);
// A 0.2 pack keeps its float frame, without the layer: SDR.
assert_eq!(
frame_configuration(true, device, F::Bgra8Unorm, true, true),
(LINEAR_FRAME, false)
);
}
}
#[test]
fn a_0_2_pack_without_the_hud_layer_finishes_on_the_swapchains_8_bits() {
use super::{LINEAR_FRAME, WIDE_FRAME, overlay_format};
use wgpu::TextureFormat::{Bgra8Unorm, Bgra8UnormSrgb, Rgba8Unorm};
assert_eq!(
overlay_format(LINEAR_FRAME, false, Bgra8UnormSrgb),
Bgra8Unorm
);
assert_eq!(overlay_format(LINEAR_FRAME, false, Rgba8Unorm), Rgba8Unorm);
// Everything else draws its overlays on its frame: the layered frame's light, the
// 16-bit frame, the 8-bit frame.
for (frame, layered) in [
(LINEAR_FRAME, true),
(WIDE_FRAME, false),
(Bgra8Unorm, false),
] {
assert_eq!(overlay_format(frame, layered, Bgra8UnormSrgb), frame);
}
}
#[test]
fn one_tone_curve_runs_on_the_layered_world() {
use super::horizon_tone_curve;
// No pack, or a 0.2 pack without `[tonemap]`: Horizon's curve.
assert!(horizon_tone_curve(true, true, false));
// A pack's `[tonemap]` mapped it already; never both.
assert!(!horizon_tone_curve(true, true, true));
// No world (the title screen), or no layered frame (SDR, the compatibility frames).
assert!(!horizon_tone_curve(true, false, false));
assert!(!horizon_tone_curve(false, true, false));
}
#[test]
fn only_a_no_pack_layered_world_applies_hdr_gain() {
use super::no_pack_hdr_gain;
assert!(no_pack_hdr_gain(true, false, true));
assert!(!no_pack_hdr_gain(false, false, true));
assert!(!no_pack_hdr_gain(true, true, true));
assert!(!no_pack_hdr_gain(true, false, false));
}
@@ -9,11 +9,20 @@
@group(0) @binding(12) var<storage, read> box_sections: array<Section>;
// The corners (bit 0: x, bit 1: y, bit 2: z) of the box's 12 triangles,
// counter-clockwise seen from outside (cull.wgsl's).
const CORNERS = array<u32, 36>(
0u, 6u, 2u, 0u, 4u, 6u, 1u, 3u, 7u, 1u, 7u, 5u, 0u, 1u, 5u, 0u, 5u, 4u,
2u, 7u, 3u, 2u, 6u, 7u, 0u, 3u, 1u, 0u, 2u, 3u, 4u, 5u, 7u, 4u, 7u, 6u,
);
// counter-clockwise seen from outside (cull.wgsl's `box_triangle_corner`).
// Packed four bits each, eight a word, read through a switch: a constant array indexed by the
// vertex would be a private array, which Adreno's Vulkan compiler (build 0676.41) fails to link.
fn box_triangle_corner(v: u32) -> u32 {
var word: u32;
switch v / 8u {
case 0u: { word = 0x31640260u; }
case 1u: { word = 0x05105717u; }
case 2u: { word = 0x76237245u; }
case 3u: { word = 0x54320130u; }
default: { word = 0x00006747u; }
}
return (word >> (v % 8u * 4u)) & 0xfu;
}
struct BoxOut {
@builtin(position) position: vec4<f32>,
@@ -26,7 +35,7 @@ fn vs_box(@builtin(vertex_index) v: u32, @builtin(instance_index) t: u32) -> Box
let o = bitcast<vec3<f32>>(box_origins[id].xyz);
var out: BoxOut;
out.id = id;
out.position = params.view_proj * vec4(box_corner(o, box_sections[id].a.z, CORNERS[v]), 1.0);
out.position = params.view_proj * vec4(box_corner(o, box_sections[id].a.z, box_triangle_corner(v)), 1.0);
return out;
}
+43 -20
View File
@@ -169,7 +169,8 @@ pub struct Hdr {
hand_mark: wgpu::RenderPipeline,
params: wgpu::Buffer,
/// The same pass drawing HDR10 for a screenshot (`capture`), with its own parameters,
/// so it can differ from the swapchain's in one frame.
/// so it can differ from the swapchain's in one frame, and without the swapchain's dither:
/// a screenshot keeps the exact codes.
capture_pipeline: wgpu::RenderPipeline,
capture_params: wgpu::Buffer,
pub output: Output,
@@ -286,8 +287,9 @@ impl Hdr {
entries: &entries,
})
};
// One full-screen pass of hdr.wgsl's `entry` into `format`, reading `layout`.
let pass = |label, layout: &wgpu::BindGroupLayout, entry, format| {
// One full-screen pass of hdr.wgsl's `entry` into `format`, reading `layout`; `dither`
// for the last pass into the HDR10 swapchain (`pq_dither`).
let pass = |label, layout: &wgpu::BindGroupLayout, entry, format, dither: bool| {
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some(label),
bind_group_layouts: &[Some(layout)],
@@ -308,7 +310,10 @@ impl Hdr {
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some(entry),
compilation_options: Default::default(),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &[("pq_dither", f64::from(u8::from(dither)))],
..Default::default()
},
targets: &[Some(wgpu::ColorTargetState {
format,
blend: None,
@@ -321,7 +326,8 @@ impl Hdr {
};
let layout_bindings: &[u32] = if coverage { &[0, 3, 4, 5] } else { &[0, 1, 3] };
let display = layout("Horizon HDR bindings", layout_bindings, true);
let pipeline_for = |format| pass("Horizon HDR", &display, entry_point, format);
let pipeline_for =
|format, dither| pass("Horizon HDR", &display, entry_point, format, dither);
let params = || {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Horizon HDR parameters"),
@@ -333,7 +339,7 @@ impl Hdr {
let layered = coverage.then(|| {
let with = |label, textures: &[u32], params, entry, format| {
let layout = layout(label, textures, params);
let pipeline = pass(label, &layout, entry, format);
let pipeline = pass(label, &layout, entry, format, false);
(layout, pipeline)
};
let hud = super::hud_coverage::HUD_FORMAT;
@@ -395,8 +401,8 @@ impl Hdr {
);
Self {
logo: logo_mask(device, queue),
pipeline: pipeline_for(output.format()),
capture_pipeline: pipeline_for(Output::Pq.format()),
pipeline: pipeline_for(output.format(), output == Output::Pq),
capture_pipeline: pipeline_for(Output::Pq.format(), false),
layout: display,
clear_alpha,
hand_mark,
@@ -1709,19 +1715,32 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
super::super::hud_coverage::clear(&mut encoder, &hdr.coverage_view().unwrap());
hdr.begin(&queue, true);
hdr.present(&queue, &mut encoder, &target, true, None);
let got = texel(&device, &queue, encoder, &target);
for c in &got[..3] {
let c = f64::from(*c);
let ok = match output {
// 10-bit codes, a step either way for rounding.
Output::Pq => (c - 1023.0 * pq(want)).abs() <= 1.0,
// 80 nits a unit, in half floats.
Output::ScRgb => (c * 80.0 - want).abs() <= 2e-3 * want + 1e-4,
// HDR10's exact codes are a screenshot's copy: the swapchain's own get a
// code of dither either way.
let exact = (output == Output::Pq)
.then(|| hdr.capture(&device, &queue, &mut encoder, true).unwrap());
let presented = texel(&device, &queue, encoder, &target);
let exact = exact.map(|copy| {
let encoder = device.create_command_encoder(&Default::default());
texel(&device, &queue, encoder, &copy)
});
for (got, codes) in [(Some(presented), 2.0), (exact, 1.0)] {
let Some(got) = got else {
continue;
};
assert!(
ok,
"{output:?} black {black}, light {light}: {got:?}, not {want} nits"
);
for c in &got[..3] {
let c = f64::from(*c);
let ok = match output {
// 10-bit codes, a step either way for rounding.
Output::Pq => (c - 1023.0 * pq(want)).abs() <= codes,
// 80 nits a unit, in half floats.
Output::ScRgb => (c * 80.0 - want).abs() <= 2e-3 * want + 1e-4,
};
assert!(
ok,
"{output:?} black {black}, light {light}: {got:?}, not {want} nits"
);
}
}
}
}
@@ -2078,3 +2097,7 @@ mod lighting_tests {
#[cfg(test)]
#[path = "hdr_written_tests.rs"]
mod written_tests;
#[cfg(test)]
#[path = "hdr_dither_tests.rs"]
mod dither_tests;
@@ -0,0 +1,313 @@
//! HDR10's dither (`hdr.wgsl`, `dithered`): the last pass into the 10-bit swapchain, and what
//! must stay without it.
use super::{
Hdr, Output,
tests::{fill, gpu},
};
use wgpu::TextureFormat as F;
const SIZE: [u32; 2] = [64, 1024];
/// The ramp: grey rising by one half-float step every `ROWS` rows from `FIRST / 2048`. That is
/// three PQ codes down the picture around 91 nits: as slow as a clear sky, and as fine as the
/// float frame holds.
const FIRST: u32 = 1434;
const ROWS: u32 = 52;
/// SMPTE ST 2084: nits as a 10-bit PQ code, before rounding.
fn code(nits: f64) -> f64 {
let y = (nits / 10_000.0).powf(0.1593017578125);
1023.0 * ((0.8359375 + 18.8515625 * y) / (1.0 + 18.6875 * y)).powf(78.84375)
}
/// The ramp's row `y` as a PQ code, at the 200-nit paper white `Hdr` starts with.
fn ramp_code(y: u32) -> f64 {
code(200.0 * (f64::from(FIRST + y / ROWS) / 2048.0).powf(2.2))
}
fn ramp(device: &wgpu::Device, encoder: &mut wgpu::CommandEncoder, frame: &wgpu::Texture) {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Wgsl(
format!(
r#"
@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {{
return vec4(vec2(f32((i << 1u) & 2u), f32(i & 2u)) * 2.0 - 1.0, 0.0, 1.0);
}}
@fragment fn fs(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {{
return vec4(vec3(f32({FIRST}u + u32(pos.y) / {ROWS}u) / 2048.0), 1.0);
}}
"#
)
.into(),
),
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: None,
layout: None,
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs"),
compilation_options: Default::default(),
targets: &[Some(frame.format().into())],
}),
multiview_mask: None,
cache: None,
});
let view = frame.create_view(&Default::default());
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
color_attachments: &[Some(super::super::hud_coverage::attachment(&view))],
..Default::default()
});
pass.set_pipeline(&pipeline);
pass.draw(0..3, 0..1);
}
/// What `hdr` writes for the float frame `paint` fills, with no HUD over it: its last pass into
/// a swapchain image of its output (`Hdr::present`), or a screenshot's HDR10 copy
/// (`Hdr::capture`). Every pixel's bytes, row by row.
fn shown(
(device, queue): &(wgpu::Device, wgpu::Queue),
hdr: &mut Hdr,
screenshot: bool,
paint: impl Fn(&mut wgpu::CommandEncoder, &wgpu::Texture),
) -> Vec<u8> {
let frame = hdr.frame(device, SIZE);
let mut encoder = device.create_command_encoder(&Default::default());
paint(&mut encoder, &frame);
hdr.keep_world(&mut encoder, None);
super::super::hud_coverage::clear(&mut encoder, &hdr.coverage_view().unwrap());
let image = if screenshot {
hdr.capture(device, queue, &mut encoder, true).unwrap()
} else {
let image = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: frame.size(),
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: hdr.output.format(),
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
hdr.present(queue, &mut encoder, &image, true, None);
image
};
let row = SIZE[0] * image.format().block_copy_size(None).unwrap();
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: u64::from(row * SIZE[1]),
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
encoder.copy_texture_to_buffer(
image.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &buffer,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(row),
rows_per_image: None,
},
},
image.size(),
);
queue.submit([encoder.finish()]);
buffer.map_async(wgpu::MapMode::Read, .., |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
buffer.get_mapped_range(..).unwrap().to_vec()
}
fn float_hdr((device, queue): &(wgpu::Device, wgpu::Queue), output: Output) -> Hdr {
Hdr::with_debug(device, queue, F::Rgba16Float, output, true, None)
}
/// An HDR10 picture's 10-bit codes, red, green and blue.
fn codes(bytes: &[u8]) -> Vec<[u32; 3]> {
let pixels = bytes.as_chunks::<4>().0.iter();
pixels
.map(|p| [0, 10, 20].map(|shift| (u32::from_le_bytes(*p) >> shift) & 0x3ff))
.collect()
}
/// Down every column of a channel: the longest run of rows with one code, and the runs' mean
/// length.
fn runs(codes: &[[u32; 3]], channel: usize) -> (u32, f64) {
let (mut longest, mut count) = (0, 0);
for x in 0..SIZE[0] {
let mut run = 0;
for y in 0..SIZE[1] {
let at = |y: u32| codes[(y * SIZE[0] + x) as usize][channel];
if y > 0 && at(y) != at(y - 1) {
(longest, count, run) = (longest.max(run), count + 1, 0);
}
run += 1;
}
(longest, count) = (longest.max(run), count + 1);
}
(longest, f64::from(SIZE[0] * SIZE[1]) / f64::from(count))
}
/// Whether every row of a picture is one value from side to side: no noise in it.
fn rows_are_flat(bytes: &[u8]) -> bool {
let pixel = bytes.len() / (SIZE[0] * SIZE[1]) as usize;
bytes
.chunks(pixel * SIZE[0] as usize)
.all(|row| row.chunks(pixel).all(|p| p == &row[..pixel]))
}
#[test]
fn a_slow_gradient_gets_grain_instead_of_bands_on_hdr10() {
let Some(gpu) = gpu() else {
return;
};
let ramp = |encoder: &mut wgpu::CommandEncoder, frame: &wgpu::Texture| {
ramp(&gpu.0, encoder, frame);
};
let mut hdr = float_hdr(&gpu, Output::Pq);
// The ramp is what the test says: three codes, top to bottom.
let span = ramp_code(SIZE[1] - 1) - ramp_code(0);
assert!((2.9..3.2).contains(&span), "{span}");
// Without dither, as a screenshot's copy stays: each row its nearest code, in bands hundreds
// of rows tall. A GPU need not round at exactly half a code: an NVIDIA one rounds up from
// 0.55 here, which shows as 0.05 of a code less, so the bounds below leave a quarter code.
let plain = codes(&shown(&gpu, &mut hdr, true, ramp));
let dithered = codes(&shown(&gpu, &mut hdr, false, ramp));
let want = |i: usize| ramp_code(i as u32 / SIZE[0]);
for channel in 0..3 {
for (i, p) in plain.iter().enumerate() {
let off = (f64::from(p[channel]) - want(i)).abs();
assert!(
off <= 0.75,
"plain {channel} at {i}: {p:?}, not {}",
want(i)
);
}
let (longest, _) = runs(&plain, channel);
assert!(longest >= 250, "plain {channel}: bands of {longest} rows");
// With it: each pixel within the noise's one code of its light (and half a code of
// rounding), no column holds a code for long, and a run is a few rows on average.
for (i, d) in dithered.iter().enumerate() {
let off = (f64::from(d[channel]) - want(i)).abs();
assert!(
off <= 1.75,
"dithered {channel} at {i}: {d:?}, not {}",
want(i)
);
}
let (longest, mean) = runs(&dithered, channel);
eprintln!(
"HDR10 dither GPU, channel {channel}: longest run {longest} rows, mean {mean:.2}"
);
assert!(longest <= 64, "dithered {channel}: a run of {longest} rows");
assert!(mean < 4.0, "dithered {channel}: runs of {mean} rows");
// The mean of each 64-row block is the light meant.
let mut worst = 0.0_f64;
for block in 0..SIZE[1] / 64 {
let rows = (block * 64 * SIZE[0]) as usize..((block + 1) * 64 * SIZE[0]) as usize;
let n = rows.len() as f64;
let got = dithered[rows.clone()]
.iter()
.map(|d| f64::from(d[channel]))
.sum::<f64>()
/ n;
let meant = rows.map(want).sum::<f64>() / n;
worst = worst.max((got - meant).abs());
assert!(
(got - meant).abs() < 0.25,
"block {block} of {channel}: {got}, not {meant}"
);
}
eprintln!("HDR10 dither GPU, channel {channel}: block means within {worst:.3} of a code");
}
}
#[test]
fn hdr10s_dither_keeps_both_ends_exact_and_leans_to_them_in_their_last_code() {
let Some(gpu) = gpu() else {
return;
};
let mut hdr = float_hdr(&gpu, Output::Pq);
let grey = |g: f64| {
move |encoder: &mut wgpu::CommandEncoder, frame: &wgpu::Texture| {
fill(encoder, frame, [g, g, g, 1.0]);
}
};
// How far the picture's mean code is from `end`.
let mean_from = |picture: &[[u32; 3]], end: f64| {
let sum: f64 = picture.iter().flatten().map(|c| f64::from(*c)).sum();
(sum / (picture.len() * 3) as f64 - end).abs()
};
// Within a code of an end the noise shrinks to the distance to it. A third of a code from
// the end, the signal plus that noise passes half a code only where the noise is over half
// its range, an eighth of a triangle: so the picture's mean is an eighth of a code from the
// end, not a third. That lean to the end, in the last code alone, is the price of keeping
// the end itself exact. Bound: a GPU that rounds a tenth of a code off the half (as above)
// shows between 0.02 and 0.32 of a code, always short of the third.
let leans = |mean: f64| (0.015..0.325).contains(&mean);
// Black gets no noise: every pixel stays off.
let black = codes(&shown(&gpu, &mut hdr, false, grey(0.0)));
assert!(black.iter().all(|p| *p == [0; 3]));
// A third of a code above black (this grey is a half float): 0 or 1, never lifted past it.
let dim = 0.00038433074951171875;
let meant = code(200.0 * f64::powf(dim, 2.2));
assert!((meant - 1.0 / 3.0).abs() < 0.001, "{meant}");
let near_black = codes(&shown(&gpu, &mut hdr, false, grey(dim)));
assert!(near_black.iter().flatten().all(|c| *c <= 1));
let mean = mean_from(&near_black, 0.0);
eprintln!("HDR10 dither GPU: a third of a code above black shows as {mean:.3}");
assert!(leans(mean), "{mean} above black");
// The top of the range, PQ's 10 000 nits, stays the top: nothing wraps or dims. Light past
// the peak is held at it, so the Peak setting is the signal here.
hdr.peak = 10_000.0;
let top = codes(&shown(&gpu, &mut hdr, false, grey(20.0)));
assert!(top.iter().all(|p| *p == [1023; 3]));
// A third of a code under the top, mirrored: 1022 or 1023.
let (mut under, mut over) = (9000.0, 10_000.0);
for _ in 0..60 {
let middle = (under + over) / 2.0;
if code(middle) < 1023.0 - 1.0 / 3.0 {
under = middle;
} else {
over = middle;
}
}
hdr.peak = under as f32;
let meant = code(f64::from(hdr.peak));
assert!((1023.0 - meant - 1.0 / 3.0).abs() < 0.001, "{meant}");
let near_top = codes(&shown(&gpu, &mut hdr, false, grey(20.0)));
assert!(near_top.iter().flatten().all(|c| *c >= 1022));
let mean = mean_from(&near_top, 1023.0);
eprintln!("HDR10 dither GPU: a third of a code under the top shows as {mean:.3} under");
assert!(leans(mean), "{mean} under the top");
}
#[test]
fn scrgb_and_screenshots_get_no_dither() {
let Some(gpu) = gpu() else {
return;
};
let ramp = |encoder: &mut wgpu::CommandEncoder, frame: &wgpu::Texture| {
ramp(&gpu.0, encoder, frame);
};
let (mut hdr10, mut scrgb) = (float_hdr(&gpu, Output::Pq), float_hdr(&gpu, Output::ScRgb));
// scRGB's swapchain is float: every row is one value from side to side.
assert!(rows_are_flat(&shown(&gpu, &mut scrgb, false, ramp)));
// A screenshot's HDR10 copy is the same bytes whichever swapchain is presented, the
// dithered one included, and has no noise in it.
let screenshot = shown(&gpu, &mut hdr10, true, ramp);
assert!(rows_are_flat(&screenshot));
assert_eq!(screenshot, shown(&gpu, &mut scrgb, true, ramp));
// The swapchain's own picture differs from it: the dither is on there.
assert_ne!(screenshot, shown(&gpu, &mut hdr10, false, ramp));
}
@@ -465,7 +465,7 @@ impl<'a> Draw<'a> {
}
fn material(&mut self, api: horizon_shader_api::Api) {
let source = super::super::native_material::coverage_test_shader(api);
let source = super::super::native_material::coverage_test_shader((api, false));
self.shader = self
.device
.create_shader_module(wgpu::ShaderModuleDescriptor {
@@ -478,7 +478,7 @@ impl<'a> Draw<'a> {
Vec::new(),
Default::default(),
None,
api,
(api, false),
);
self.queue.write_buffer(
&frame.buffer,
@@ -149,6 +149,12 @@ fn composed() -> Vec<(&'static str, Vec<&'static str>, String)> {
vec!["terrain.wgsl", "visbuffer.wgsl"],
super::terrain_shader() + include_str!("../visbuffer.wgsl"),
),
// Ray tracing's unpacking of compact sections (gfx/ray_scene.rs's `Unpacker`).
(
"ray tracing unpack",
vec!["terrain.wgsl", "rt_unpack.wgsl"],
super::surface_setup::ray_query::ray_scene::unpack_shader(),
),
// A native mod's world warp (gfx/world_warp.rs): its copies with the plainest warp.
(
"world warp terrain",
@@ -164,10 +170,15 @@ fn composed() -> Vec<(&'static str, Vec<&'static str>, String)> {
vec!["entity.wgsl", "static_entity.wgsl", "world_warp.wgsl"],
super::world_warp::entity_shader(super::world_warp::IDENTITY).unwrap(),
),
(
"world primitives",
vec!["world_primitives.wgsl"],
super::world_primitives::shader(),
),
(
"improved transparency world border",
vec!["border.wgsl", "oit.wgsl", "oit_border.wgsl"],
include_str!("../border.wgsl").to_owned()
super::border_shader()
+ include_str!("../oit.wgsl")
+ include_str!("../oit_border.wgsl"),
),
@@ -0,0 +1,51 @@
//! A shader API 0.2 pack's `[frame] blend = "linear"` (docs/design/hdr-linear-light.md, How the
//! frame stores the picture): its float frame holds linear light instead of vanilla's gamma, so
//! glass, water and smoke blend as light does. The pack's material, sky and weather stages store
//! their results as they are. Horizon's own draws into the frame decode theirs at the end of
//! their shaders (hdr_light.wgsl's `frame_stored`), so a solid surface looks as under `"gamma"`.
//! What the pack reads of the frame (`scene_color`, `opaque_color`, the water mirror) is copied
//! as it is instead of decoded. The tonemap encodes the picture into gamma again, so the hand,
//! the camera overlays, the HUD, the screenshots and HDR output see the same either way.
/// Whether a frame of `format` stores linear light for a 0.2 pack that asks for it (`linear`,
/// `FrameRequest::linear_blend`): only the float frame. Where the device can't draw that, the
/// 0.2 pack runs on the 8-bit frame, which keeps vanilla's gamma, as `"gamma"` does: eight bits
/// of linear light would band in the darks.
pub(super) fn stores_linear(linear: bool, format: wgpu::TextureFormat) -> bool {
linear && format == super::LINEAR_FRAME
}
/// The constants of a pipeline of Horizon's that draws into the world's frame of `format`
/// (hdr_light.wgsl): light written, not marked, on the float frame; and decoded where the
/// frame holds linear light (`linear`).
pub(super) fn frame_constants(
format: wgpu::TextureFormat,
linear: bool,
) -> [(&'static str, f64); 2] {
[
(
"HDR_WRITE_LIGHT",
f64::from(u8::from(format == super::LINEAR_FRAME)),
),
("FRAME_LINEAR", f64::from(u8::from(linear))),
]
}
/// The world's clear colour, given in vanilla's gamma, as a frame that holds linear light
/// (`linear`) stores it.
pub(super) fn stored_clear(color: wgpu::Color, linear: bool) -> wgpu::Color {
if !linear {
return color;
}
let decode = |v: f64| v.max(0.0).powf(2.2);
wgpu::Color {
r: decode(color.r),
g: decode(color.g),
b: decode(color.b),
a: color.a,
}
}
#[cfg(test)]
#[path = "linear_blend_tests.rs"]
mod tests;
@@ -0,0 +1,413 @@
use super::super::{
LINEAR_FRAME,
native_post::{Decoder, INTERMEDIATE, line_helpers},
};
use super::{frame_constants, stored_clear};
use horizon_shader_api::Api;
use wgpu::util::DeviceExt;
/// One full-screen draw into the frame: `vec4(rgb, alpha)` from Horizon's own shaders, which
/// give vanilla's gamma (`frame_stored`), or a 0.2 pack's, which gives linear light
/// (`horizon_line_out`).
#[derive(Clone, Copy)]
enum Writer {
Horizon,
Pack,
}
const SOURCE: &str = "
// The colour comes in a buffer: naga 30 rejects this fragment with the colour made of
// overrides (`ExpressionAlreadyInScope` once they are processed).
@group(0) @binding(0) var<uniform> test_color: vec4<f32>;
override TEST_PACK: bool = false;
@vertex fn test_vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
return vec4<f32>(vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u)) * 2.0 - 1.0, 0.0, 1.0);
}
@fragment fn test_fs() -> @location(0) vec4<f32> {
let c = test_color;
if TEST_PACK {
return vec4<f32>(horizon_line_out(c.rgb), c.a);
}
return frame_stored(c);
}
";
fn gpu() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
pollster::block_on(adapter.request_device(&Default::default())).ok()
}
/// What a 0.2 stage reads of a 1x1 frame (`scene_color`): the world cleared to `clear`, in
/// vanilla's gamma, then `draws` alpha-blended over it, then the production decoding copy.
fn stage_input(
(device, queue): &(wgpu::Device, wgpu::Queue),
linear: bool,
clear: [f64; 3],
draws: &[(Writer, [f32; 4])],
) -> [f32; 4] {
let texture = |format, usage| {
device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | usage,
view_formats: &[],
})
};
let frame = texture(LINEAR_FRAME, wgpu::TextureUsages::TEXTURE_BINDING);
let input = texture(INTERMEDIATE, wgpu::TextureUsages::COPY_SRC);
let frame_view = frame.create_view(&Default::default());
let source =
include_str!("../hdr_light.wgsl").to_owned() + line_helpers(Api::V0_2, linear) + SOURCE;
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Wgsl(source.into()),
});
let mut encoder = device.create_command_encoder(&Default::default());
let [r, g, b] = clear;
let clear = stored_clear(wgpu::Color { r, g, b, a: 1.0 }, linear);
let mut load = wgpu::LoadOp::Clear(clear);
for &(writer, color) in draws {
let mut constants = frame_constants(LINEAR_FRAME, linear).to_vec();
constants.push((
"TEST_PACK",
f64::from(u8::from(matches!(writer, Writer::Pack))),
));
let options = wgpu::PipelineCompilationOptions {
constants: &constants,
..Default::default()
};
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: None,
layout: None,
vertex: wgpu::VertexState {
module: &module,
entry_point: Some("test_vs"),
compilation_options: options.clone(),
buffers: &[],
},
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some("test_fs"),
compilation_options: options,
targets: &[Some(wgpu::ColorTargetState {
format: LINEAR_FRAME,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
multiview_mask: None,
cache: None,
});
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &frame_view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load,
store: wgpu::StoreOp::Store,
},
})],
..Default::default()
});
let color = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
contents: bytemuck::cast_slice(&color),
usage: wgpu::BufferUsages::UNIFORM,
});
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout: &pipeline.get_bind_group_layout(0),
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: color.as_entire_binding(),
}],
});
pass.set_pipeline(&pipeline);
pass.set_bind_group(0, &group, &[]);
pass.draw(0..3, 0..1);
load = wgpu::LoadOp::Load;
}
if let wgpu::LoadOp::Clear(_) = load {
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &frame_view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load,
store: wgpu::StoreOp::Store,
},
})],
..Default::default()
});
}
Decoder::new(device, linear).run(
device,
&mut encoder,
&frame_view,
&input.create_view(&Default::default()),
);
texel((device, queue), encoder, &input)
}
/// The first texel of the half-float `texture` once `encoder` ran.
fn texel(
(device, queue): (&wgpu::Device, &wgpu::Queue),
mut encoder: wgpu::CommandEncoder,
texture: &wgpu::Texture,
) -> [f32; 4] {
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 256,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
encoder.copy_texture_to_buffer(
texture.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &buffer,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(256),
rows_per_image: None,
},
},
wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
);
queue.submit([encoder.finish()]);
buffer.map_async(wgpu::MapMode::Read, .., |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let bytes = buffer.get_mapped_range(..).unwrap();
std::array::from_fn(|i| half::f16::from_le_bytes([bytes[2 * i], bytes[2 * i + 1]]).to_f32())
}
fn assert_near(got: [f32; 4], want: [f32; 3], what: &str) {
for i in 0..3 {
// Half floats, and a gamma frame's half float decoded, hold about three digits.
assert!(
(got[i] - want[i]).abs() < 0.003 * want[i].max(1.0),
"{what}: {got:?}, expected {want:?}"
);
}
assert_eq!(got[3], 1.0, "{what}: {got:?}");
}
// The background is gamma (0.8, 0.2, 0.5), linear (0.6121, 0.0290, 0.2176). Horizon's glass
// gives gamma (0.1, 0.9, 0.4), the pack's linear (0.0, 0.5, 1.0); both cover a quarter. Under
// "gamma" the frame mixes gamma, a stage reads `decode(0.25 * src + 0.75 * bg)`: Horizon's
// (0.3556, 0.1156, 0.1944), the pack's, encoded first, (0.3250, 0.0887, 0.3556). Under
// "linear" it mixes light, `0.25 * decode(src) + 0.75 * decode(bg)`: Horizon's
// (0.4606, 0.2200, 0.1965), the pack's (0.4590, 0.1467, 0.4132).
#[test]
fn translucent_draws_blend_in_the_light_the_frame_holds() {
let Some(gpu) = gpu() else {
eprintln!("linear blend GPU unavailable");
return;
};
let background = [0.8, 0.2, 0.5];
for (linear, horizon, pack) in [
(false, [0.3556, 0.1156, 0.1944], [0.3250, 0.0887, 0.3556]),
(true, [0.4606, 0.2200, 0.1965], [0.4590, 0.1467, 0.4132]),
] {
let glass = [(Writer::Horizon, [0.1, 0.9, 0.4, 0.25])];
let got = stage_input(&gpu, linear, background, &glass);
assert_near(got, horizon, &format!("Horizon's glass, linear {linear}"));
let glass = [(Writer::Pack, [0.0, 0.5, 1.0, 0.25])];
let got = stage_input(&gpu, linear, background, &glass);
assert_near(got, pack, &format!("the pack's glass, linear {linear}"));
}
}
// What a stage reads of solid surfaces is the same light under either blend: Horizon's gamma
// (0.3, 0.7, 1.5) decodes to (0.0707, 0.4563, 2.4401), the pack's light (0.25, 0.5, 2.0) and
// the clear colour, gamma (0.8, 0.2, 0.5), to (0.6121, 0.0290, 0.2176).
#[test]
fn stages_read_the_same_light_under_either_blend() {
let Some(gpu) = gpu() else {
eprintln!("linear blend GPU unavailable");
return;
};
let clear = [0.8, 0.2, 0.5];
let cases = [
(
vec![(Writer::Horizon, [0.3, 0.7, 1.5, 1.0])],
[0.0707, 0.4563, 2.4401],
),
(
vec![(Writer::Pack, [0.25, 0.5, 2.0, 1.0])],
[0.25, 0.5, 2.0],
),
(vec![], [0.6121, 0.0290, 0.2176]),
];
for (draws, want) in cases {
let [gamma, linear] = [false, true].map(|linear| stage_input(&gpu, linear, clear, &draws));
assert_near(gamma, want, "gamma");
assert_near(linear, want, "linear");
for i in 0..3 {
assert!((gamma[i] - linear[i]).abs() < 0.003 * want[i].max(1.0));
}
}
}
// A section fading in mixes the pack's material result toward the fog colour in vanilla's
// gamma, as the fog does. Fog gamma 0.8, the pack's light 0.2 (gamma 0.2^(1/2.2) = 0.4812), half
// visible: under "linear" the frame stores (0.5 * 0.8 + 0.5 * 0.4812)^2.2 = 0.3757, not the
// 0.5 a mix in light gives; under "gamma" the result is already gamma, 0.5 * 0.8 + 0.5 * 0.2.
#[test]
fn a_fading_section_mixes_toward_the_fog_in_gamma() {
let Some((device, queue)) = gpu() else {
eprintln!("linear blend GPU unavailable");
return;
};
let globals = super::super::Globals {
fog_color: [0.8; 4],
// The pack's material result, as `horizon_material_color` gives it.
sky_color: [0.2, 0.2, 0.2, 1.0],
..bytemuck::Zeroable::zeroed()
};
let globals = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
contents: bytemuck::bytes_of(&globals),
usage: wgpu::BufferUsages::UNIFORM,
});
for (linear, want) in [(false, 0.5), (true, 0.3757)] {
let source = super::super::native_material::coverage_test_shader((Api::V0_2, linear))
+ "
@fragment fn test_faded() -> @location(0) vec4<f32> {
var o: HorizonMaterialOut;
// Half visible (`horizon_material_vertex`), past 32 blocks.
o.hdr_code = 32768u << 16u;
o.spherical = 40.0;
return horizon_material_faded(o, horizon_terrain_globals.sky_color);
}
";
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Wgsl(source.into()),
});
let constants = frame_constants(LINEAR_FRAME, linear)
.map(|(name, value)| (format!("horizon_terrain_{name}"), value));
let constants: Vec<(&str, f64)> = constants.iter().map(|(n, v)| (n.as_str(), *v)).collect();
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: None,
layout: None,
vertex: wgpu::VertexState {
module: &module,
entry_point: Some("test_vertex"),
compilation_options: Default::default(),
buffers: &[],
},
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some("test_faded"),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &constants,
..Default::default()
},
targets: &[Some(LINEAR_FRAME.into())],
}),
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
multiview_mask: None,
cache: None,
});
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout: &pipeline.get_bind_group_layout(0),
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: globals.as_entire_binding(),
}],
});
let frame = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: LINEAR_FRAME,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let mut encoder = device.create_command_encoder(&Default::default());
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &frame.create_view(&Default::default()),
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})],
..Default::default()
});
pass.set_pipeline(&pipeline);
pass.set_bind_group(0, &group, &[]);
pass.draw(0..3, 0..1);
}
let got = texel((&device, &queue), encoder, &frame);
assert!(
(got[0] - want).abs() < 0.002,
"linear {linear}: {got:?}, expected {want}"
);
assert_eq!(got[3], 1.0, "alpha kept");
}
}
// The frame O1 resolves (`output::resolve_frame`) holds linear light only for a 0.2 pack that
// asks for it, on the float frame. On a device without the float frame the pack draws anyway,
// in gamma. A blend changed is another frame, so the game makes the renderer again
// (`Renderer::frame_follows`).
#[test]
fn only_a_float_frame_holds_linear_light() {
use super::super::output::{FrameRequest, resolve_frame, tests::window};
let settings = crate::graphics::Graphics::default();
for float_frame in [false, true] {
for (linear_pack, linear_blend) in [(false, false), (true, false), (true, true)] {
let (mut caps, reading) = window(None, None);
caps.float_frame = float_frame;
let request = FrameRequest {
linear_pack,
linear_blend,
hdr_float: false,
};
let frame = resolve_frame(
&settings,
request,
caps,
reading,
wgpu::TextureFormat::Bgra8UnormSrgb,
);
assert_eq!(frame.format == LINEAR_FRAME, float_frame && linear_pack);
assert_eq!(
frame.linear_light,
float_frame && linear_blend,
"float {float_frame}, linear {linear_blend}"
);
}
}
}
@@ -22,7 +22,9 @@ const ENTRIES: &str = "
override horizon_material_fog: bool = true;
fn horizon_material_fogged(color: vec4<f32>, spherical: f32, cylindrical: f32) -> vec4<f32> {
if horizon_material_fog {
return horizon_terrain_apply_fog(color, spherical, cylindrical);
// In vanilla's gamma, as vanilla mixes it: on a frame of linear light, encoded for it.
let gamma = horizon_terrain_frame_gamma(color);
return horizon_terrain_frame_stored(horizon_terrain_apply_fog(gamma, spherical, cylindrical));
}
return color;
}
@@ -61,11 +63,14 @@ fn horizon_material_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color
}
// terrain.wgsl's fade-in (terrain.vsh's chunkVisibility): whole within 16 blocks, the section's
// own visibility from 32 out, toward the fog colour, alpha kept. The distance blend runs here on
// the interpolated distance, as vanilla's interpolated `visibility`, not once per triangle.
// the interpolated distance, as vanilla's interpolated `visibility`, not once per triangle. In
// vanilla's gamma, as the fog: on a frame of linear light, encoded for it.
fn horizon_material_faded(in: HorizonMaterialOut, color: vec4<f32>) -> vec4<f32> {
let instance = 1.0 - f32(in.hdr_code >> 16u) / 65535.0;
let visibility = mix(1.0, instance, clamp((in.spherical - 16.0) / 16.0, 0.0, 1.0));
return vec4(mix(horizon_terrain_globals.fog_color.rgb, color.rgb, visibility), color.a);
let gamma = horizon_terrain_frame_gamma(color);
let faded = mix(horizon_terrain_globals.fog_color.rgb, gamma.rgb, visibility);
return horizon_terrain_frame_stored(vec4(faded, color.a));
}
// `light`: sky, block, 16 per level.
fn horizon_material_out(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>,
@@ -277,8 +282,9 @@ pub struct TerrainShape<'a> {
pub flat: Option<&'a wgpu::BindGroupLayout>,
/// terrain.wgsl's `SPRITE_ARRAYS` and `SPRITE_SPLIT`.
pub sprites: [f64; 2],
/// The world target's colour format.
/// The world target's colour format, and whether it holds linear light (`linear_blend`).
pub format: wgpu::TextureFormat,
pub linear: bool,
/// The pack's tags of each tagged sprite layer, as `(layer, bits)`
/// (`Manifest::sprite_tags`).
pub sprite_tags: &'a [(u32, u32)],
@@ -369,12 +375,12 @@ fn shader_source(
tags: &[(u32, u32)],
materials: &[(u32, u32)],
(vertex, displaced): (bool, bool),
api: horizon_shader_api::Api,
(api, linear): (horizon_shader_api::Api, bool),
) -> String {
format!(
"{source}\n// Horizon's terrain (terrain.wgsl)\n{}{}{}{}{}{ENTRIES}{}{}",
vanilla_terrain(),
super::native_post::line_helpers(api),
super::native_post::line_helpers(api, linear),
if vertex {
DISPLACE_PACK
} else {
@@ -398,9 +404,9 @@ fn module(
materials: &[(u32, u32)],
vertex: bool,
displaced: bool,
api: horizon_shader_api::Api,
line: (horizon_shader_api::Api, bool),
) -> Result<naga::Module, String> {
let text = shader_source(&stage.source, tags, materials, (vertex, displaced), api);
let text = shader_source(&stage.source, tags, materials, (vertex, displaced), line);
naga::front::wgsl::parse_str(&text)
.map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text)))
}
@@ -424,7 +430,7 @@ impl NativeMaterial {
shape.sprite_materials,
vertex,
displaced.is_some(),
frame.api,
(frame.api, frame.linear),
)?;
let mut constants = present_constants(&module, constants);
let sprite_names = [
@@ -433,10 +439,10 @@ impl NativeMaterial {
];
constants.extend(sprite_names.into_iter().zip(shape.sprites));
constants.push(("horizon_material_fog", f64::from(u8::from(fog))));
constants.push((
"horizon_terrain_HDR_WRITE_LIGHT",
f64::from(u8::from(shape.format == super::LINEAR_FRAME)),
));
let [(_, write_light), (_, linear)] =
super::linear_blend::frame_constants(shape.format, frame.linear);
constants.push(("horizon_terrain_HDR_WRITE_LIGHT", write_light));
constants.push(("horizon_terrain_FRAME_LINEAR", linear));
constants.push((
"horizon_displace_distance",
displaced.map_or(0.0, |d| f64::from(d.distance)),
@@ -586,7 +592,9 @@ impl NativeMaterial {
}
#[cfg(test)]
pub(super) fn coverage_test_shader(api: horizon_shader_api::Api) -> String {
/// A material pack's terrain shader on shader API line `api`, its frame holding linear light
/// where `linear`, with a full-screen `test_vertex`.
pub(super) fn coverage_test_shader((api, linear): (horizon_shader_api::Api, bool)) -> String {
use horizon_shader_api::{MANIFEST, Pack, StageKind, check};
let manifest = format!(
"schema = 1\nid = \"coverage\"\nname = \"Coverage\"\nversion = \"1\"\napi = \"{api}\"\n[material]\nfile = \"material.wgsl\"\n[displacement]\n"
@@ -601,7 +609,7 @@ pub(super) fn coverage_test_shader(api: horizon_shader_api::Api) -> String {
.iter()
.find(|s| s.kind == StageKind::Material)
.unwrap();
shader_source(&stage.source, &[], &[], (false, true), api)
shader_source(&stage.source, &[], &[], (false, true), (api, linear))
+ r#"
override TEST_TIER: u32 = 0u;
override TEST_OVERLAY: u32 = 0u;
@@ -687,7 +695,7 @@ mod tests {
let tags = [(7, 1), (3, 1), (9, 2)];
let materials = [(5, 1000)];
assert!(layer_table("t", &tags).contains("case 3u, 7u: { return 1u; }"));
let module = module(&stage, &tags, &materials, false, false, Api::V0_1).unwrap();
let module = module(&stage, &tags, &materials, false, false, (Api::V0_1, false)).unwrap();
let info = naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
@@ -701,7 +709,7 @@ mod tests {
&tags,
&materials,
(false, false),
Api::V0_1
(Api::V0_1, false)
))
)
});
@@ -743,7 +751,7 @@ mod tests {
.into();
let report = check(&Pack::from_files(files).unwrap()).unwrap();
let stage = report.stages.iter().find(|s| s.kind == StageKind::Material);
let module = module(stage.unwrap(), &[], &[], true, false, Api::V0_2).unwrap();
let module = module(stage.unwrap(), &[], &[], true, false, (Api::V0_2, false)).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
@@ -766,14 +774,20 @@ mod tests {
let mut report = check(&Pack::from_files(files).unwrap()).unwrap();
let stage = report.stages.remove(0);
let materials = [(5, 1000)];
let module = module(&stage, &[], &materials, false, true, Api::V0_1).unwrap();
let module = module(&stage, &[], &materials, false, true, (Api::V0_1, false)).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap_or_else(|e| {
let text = shader_source(&stage.source, &[], &materials, (false, true), Api::V0_1);
let text = shader_source(
&stage.source,
&[],
&materials,
(false, true),
(Api::V0_1, false),
);
panic!("{}", e.emit_to_string(&text))
});
for entry in [
@@ -830,7 +844,7 @@ mod tests {
let report = check(&Pack::from_files(files).unwrap()).unwrap();
let source = report.stages[0].source.clone()
+ horizon_shader_api::stages::PBR_STUB
+ super::super::native_post::line_helpers(api)
+ super::super::native_post::line_helpers(api, false)
+ entry;
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
@@ -129,7 +129,7 @@ impl NativePack {
textures,
lights,
terrain.pbr,
api,
(api, terrain.linear && api == horizon_shader_api::Api::V0_2),
);
// `[displacement]`, unless its option is off; nothing to displace without heights.
let displacement = report
@@ -361,6 +361,7 @@ mod tests {
flat: None,
sprites: [1.0, 2048.0],
format: wgpu::TextureFormat::Rgba8Unorm,
linear: false,
sprite_tags: &[],
sprite_materials: &[],
entities: &empty,
@@ -8,7 +8,7 @@
use glam::{Mat4, Vec3};
use horizon_shader_api::{
Api, Frame as FrameInputs, StageKind, check::CheckedStage, stages::GROUP,
Api, Frame as FrameInputs, Primaries, StageKind, check::CheckedStage, stages::GROUP,
};
use super::Frame;
@@ -16,43 +16,57 @@ use super::Frame;
pub(super) const INTERMEDIATE: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
/// What the renderer's own entries call where a stage's colours cross into and out of it: as
/// they are under 0.1; under 0.2 vanilla's 2.2 power, as the prelude's `horizon_decode` and
/// `horizon_encode` (the shadow pass has no prelude, so these stand alone).
pub fn line_helpers(api: Api) -> &'static str {
match api {
Api::V0_1 => {
/// they are under 0.1; under 0.2 vanilla's 2.2 power in, as the prelude's `horizon_decode`, and
/// out as the frame stores them: encoded, as `horizon_encode`, or where the frame holds linear
/// light (`linear`, `[frame] blend = "linear"`) as they are. The shadow pass has no prelude, so
/// these stand alone.
pub fn line_helpers(api: Api, linear: bool) -> &'static str {
match (api, linear) {
(Api::V0_1, _) => {
"fn horizon_line_in(c: vec3<f32>) -> vec3<f32> { return c; }
fn horizon_line_out(c: vec3<f32>) -> vec3<f32> { return c; }
"
}
Api::V0_2 => {
(Api::V0_2, false) => {
"fn horizon_line_in(c: vec3<f32>) -> vec3<f32> {
return pow(select(vec3<f32>(0.0), c, c > vec3<f32>(0.0)), vec3<f32>(2.2));
}
fn horizon_line_out(c: vec3<f32>) -> vec3<f32> {
return pow(select(vec3<f32>(0.0), c, c > vec3<f32>(0.0)), vec3<f32>(1.0 / 2.2));
}
"
}
(Api::V0_2, true) => {
"fn horizon_line_in(c: vec3<f32>) -> vec3<f32> {
return pow(select(vec3<f32>(0.0), c, c > vec3<f32>(0.0)), vec3<f32>(2.2));
}
fn horizon_line_out(c: vec3<f32>) -> vec3<f32> { return c; }
"
}
}
}
/// 0.2's decoding copy: a picture in the frame's gamma into linear light in `INTERMEDIATE`, of
/// the same size, alpha 1 (`scene_color`, `opaque_color`, the mirror).
/// 0.2's decoding copy: a picture of the frame into linear light in `INTERMEDIATE`, of the same
/// size, alpha 1 (`scene_color`, `opaque_color`, the mirror). A frame of vanilla's gamma is
/// decoded; one that holds linear light (`linear`) is copied as it is.
#[derive(Clone)]
pub struct Decoder(wgpu::RenderPipeline);
impl Decoder {
fn new(device: &wgpu::Device) -> Self {
let source = line_helpers(Api::V0_2).to_owned()
+ "@group(0) @binding(0) var horizon_from: texture_2d<f32>;
@vertex fn horizon_vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
pub(super) fn new(device: &wgpu::Device, linear: bool) -> Self {
let read = if linear { "c" } else { "horizon_line_in(c)" };
let source = line_helpers(Api::V0_2, false).to_owned()
+ &format!(
"@group(0) @binding(0) var horizon_from: texture_2d<f32>;
@vertex fn horizon_vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {{
return vec4<f32>(vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u)) * 2.0 - 1.0, 0.0, 1.0);
}
@fragment fn horizon_fs(@builtin(position) p: vec4<f32>) -> @location(0) vec4<f32> {
return vec4<f32>(horizon_line_in(textureLoad(horizon_from, vec2<i32>(p.xy), 0).rgb), 1.0);
}
";
}}
@fragment fn horizon_fs(@builtin(position) p: vec4<f32>) -> @location(0) vec4<f32> {{
let c = textureLoad(horizon_from, vec2<i32>(p.xy), 0).rgb;
return vec4<f32>({read}, 1.0);
}}
"
);
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Shader API 0.2 decode"),
source: wgpu::ShaderSource::Wgsl(source.into()),
@@ -353,9 +367,11 @@ pub struct NativeFrame {
frames: u32,
/// `frame.wetness`, and when it was last written.
wetness: (f32, Option<std::time::Instant>),
/// The shader API line of the pack, and under 0.2 the decoding copy and the mirror it
/// decoded (`linear_mirror`).
/// The shader API line of the pack, whether the world's frame holds linear light for it
/// (`[frame] blend = "linear"`), and under 0.2 the decoding copy and the mirror it decoded
/// (`linear_mirror`).
pub api: Api,
pub linear: bool,
pub decoder: Option<Decoder>,
mirror: Option<wgpu::TextureView>,
}
@@ -433,15 +449,16 @@ fn eased(value: f32, target: f32, half_life: f32, dt: f32) -> f32 {
}
impl NativeFrame {
/// The frame of a pack with the `[shadow]` settings `shadow`, if any, and the
/// block light colours `lights`.
/// The frame of a pack of the shader API line `api` with the `[shadow]` settings `shadow`, if
/// any, and the block light colours `lights`, onto a world's frame that holds linear light
/// when `linear` (a 0.2 pack's).
pub fn new(
device: &wgpu::Device,
shadow: Option<&horizon_shader_api::manifest::Shadow>,
textures: Vec<(wgpu::TextureView, wgpu::Sampler)>,
lights: super::block_light::LightTable,
pbr: Option<&[wgpu::Texture; 2]>,
api: Api,
(api, linear): (Api, bool),
) -> Self {
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Native shader pack frame"),
@@ -565,7 +582,8 @@ impl NativeFrame {
shadow_camera: shadow.map(super::native_shadow::camera),
reflects: false,
api,
decoder: (api == Api::V0_2).then(|| Decoder::new(device)),
linear,
decoder: (api == Api::V0_2).then(|| Decoder::new(device, linear)),
mirror: None,
last: None,
eye: [f32::NAN; 4],
@@ -1596,7 +1614,11 @@ pub fn frame_inputs(
paper_white: 80.0,
peak: 80.0,
black_level: 0.0,
padding: [0; 3],
// No pack is served another space yet (`[frame] primaries`).
primaries: Primaries::Bt709 as u32,
luminance_gap: [0; 2],
luminance: Primaries::Bt709.luminance(),
padding: 0,
}
}
@@ -1755,7 +1777,7 @@ mod tests {
Vec::new(),
Default::default(),
None,
Api::V0_1,
(Api::V0_1, false),
);
frame.pack.still_ticks = Some(47_811);
frame.pack.eye_light = [0, 15];
@@ -1805,7 +1827,7 @@ mod tests {
Vec::new(),
Default::default(),
None,
Api::V0_1,
(Api::V0_1, false),
)
};
let (mut used, mut fresh) = (new(), new());
@@ -1871,7 +1893,7 @@ file = \"c.wgsl\"
Vec::new(),
Default::default(),
None,
Api::V0_1,
(Api::V0_1, false),
);
let mut post = NativePost::new(
&device,
@@ -1984,7 +2006,7 @@ file = \"c.wgsl\"
Vec::new(),
Default::default(),
None,
Api::V0_1,
(Api::V0_1, false),
);
queue.write_buffer(
&frame.buffer,
@@ -2112,7 +2134,7 @@ file = \"b.wgsl\"
Vec::new(),
Default::default(),
None,
Api::V0_1,
(Api::V0_1, false),
);
let mut post = NativePost::new(
&device,
@@ -2232,7 +2254,7 @@ file = \"b.wgsl\"
Vec::new(),
Default::default(),
None,
Api::V0_1,
(Api::V0_1, false),
);
let mut post = NativePost::new(
&device,
@@ -2567,7 +2589,7 @@ file = \"b.wgsl\"
Vec::new(),
Default::default(),
None,
Api::V0_2,
(Api::V0_2, false),
);
let inputs = FrameInputs {
headroom: 1.0,
@@ -2651,7 +2673,7 @@ file = \"b.wgsl\"
Vec::new(),
Default::default(),
None,
Api::V0_2,
(Api::V0_2, false),
);
let fields = |i: &FrameInputs| (i.hdr, i.headroom, i.paper_white, i.peak, i.black_level);
assert_eq!(
@@ -2721,7 +2743,7 @@ file = \"b.wgsl\"
Vec::new(),
Default::default(),
None,
Api::V0_2,
(Api::V0_2, false),
);
let inputs = FrameInputs {
hdr: u32::from(hdr),
@@ -516,7 +516,7 @@ fn shader_source(
+ section(TERRAIN, "struct Globals {", "// Sky (SkyRenderer)")),
PREFIX
),
super::native_post::line_helpers(api)
super::native_post::line_helpers(api, false)
)
}
@@ -39,12 +39,13 @@ fn vanilla_sky() -> String {
prefixed(&text, PREFIX)
}
/// The sky shader for the pack's sky stage `source`.
fn shader_source(source: &str, api: horizon_shader_api::Api) -> String {
/// The sky shader for the pack's sky stage `source`, of the line `api`, onto a frame that holds
/// linear light when `linear`.
fn shader_source(source: &str, (api, linear): (horizon_shader_api::Api, bool)) -> String {
format!(
"{source}\n// Horizon's sky (terrain.wgsl)\n{}{}{PACK_ENTRY}",
vanilla_sky(),
super::native_post::line_helpers(api)
super::native_post::line_helpers(api, linear)
)
}
@@ -102,7 +103,7 @@ fn build(
frame: &NativeFrame,
stage: &CheckedStage,
) -> Result<(naga::Module, wgpu::PipelineLayout), String> {
let text = shader_source(&stage.source, frame.api);
let text = shader_source(&stage.source, (frame.api, frame.linear));
let module = naga::front::wgsl::parse_str(&text)
.map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text)))?;
let mut groups: [Option<&wgpu::BindGroupLayout>; GROUP as usize + 1] = Default::default();
@@ -198,7 +199,7 @@ mod tests {
// A pack may use the renderer's names for its own things.
let own = "override gain: f32;\nconst PI: f32 = 3.0;\nfn view_ray() -> f32 { return PI; }\n\
fn sky(input: SkyInput) -> vec4<f32> { return input.color * gain * view_ray(); }\n";
let text = shader_source(&stage(own).source, horizon_shader_api::Api::V0_1);
let text = shader_source(&stage(own).source, (horizon_shader_api::Api::V0_1, false));
let module = naga::front::wgsl::parse_str(&text).unwrap_or_else(|e| {
panic!("{}", e.emit_to_string(&text));
});
@@ -261,7 +262,7 @@ mod tests {
Vec::new(),
Default::default(),
None,
horizon_shader_api::Api::V0_1,
(horizon_shader_api::Api::V0_1, false),
);
queue.write_buffer(
&frame.buffer,
@@ -42,6 +42,8 @@ const DISTANT_SQR: f64 = 3600.0;
const SECTION_DIAGONAL: f64 = 28.0;
/// In `Graph::rays`: the line from this corner stays in reached sections.
const SEES: u8 = u8::MAX;
/// Edits kept before a full resend.
const EDIT_CAP: usize = 1 << 16;
/// VisGraph connectivity per column, one entry per section (bottom first).
/// A missing column or section connects every face.
@@ -611,6 +613,8 @@ pub struct Occlusion {
queue: VecDeque<u32>,
/// Changes whenever `contains` may answer differently (`generation`).
generation: u64,
/// Set when edits were dropped; the next search resends every column.
resync: bool,
}
impl Occlusion {
@@ -631,6 +635,7 @@ impl Occlusion {
vis: Vec::new(),
queue: VecDeque::new(),
generation: next_generation(),
resync: false,
}
}
pub fn set(&mut self, key: SectionKey, vis: u64) {
@@ -639,13 +644,35 @@ impl Occlusion {
}
}
fn changed(&mut self, key: SectionKey, vis: u64) {
self.edits.push(Edit::Set(key, vis));
self.dirty = true;
if self.edits.len() >= EDIT_CAP || self.changed.len() >= EDIT_CAP {
// nobody drains these with cave culling off
self.resync = true;
self.edits.clear();
self.changed.clear();
self.changed_late.clear();
}
if self.resync {
return;
}
self.edits.push(Edit::Set(key, vis));
self.changed.push(key);
if self.running.is_some() {
self.changed_late.push(key);
}
}
fn resync_edits(&mut self) {
self.resync = false;
self.edits.clear();
self.edits.push(Edit::Clear);
for (&(x, z), column) in &self.columns.map {
for (y, &vis) in column.iter().enumerate() {
if vis != ALL_VISIBLE {
self.edits.push(Edit::Set((x, y as i32, z), vis));
}
}
}
}
/// Every section of the column `x`, `z` goes (sees through every face).
pub fn remove_column(&mut self, x: i32, z: i32) {
let Some(column) = self.columns.map.remove(&(x, z)) else {
@@ -674,6 +701,7 @@ impl Occlusion {
self.columns.map.clear();
self.edits.clear();
self.edits.push(Edit::Clear);
self.resync = false;
self.dirty = true;
// Another world: the old graph says nothing about it. Without one, every
// section was visible already, and stays so.
@@ -769,6 +797,9 @@ impl Occlusion {
let from = self.columns.section_of(camera);
self.dirty = false;
self.searched = Some((from, radius));
if self.resync {
self.resync_edits();
}
let graph = std::mem::take(&mut self.spare);
if let Some(worker) = &self.worker {
let job = Job {
@@ -1793,4 +1824,36 @@ mod tests {
assert_ne!(answers(&o, around, spread + radius), walled.1);
assert_ne!(o.generation(), walled.0, "the graph went");
}
#[test]
fn edit_queue_stays_bounded_without_searches() {
let mut o = Occlusion::new(0, 4);
let total = (1usize << 16) + 5000;
for i in 0..total {
o.set(((i % 700) as i32, 1, (i / 700) as i32), 1 + (i as u64 % 5));
}
assert!(o.edits.len() <= 1 << 16, "{} edits queued", o.edits.len());
assert!(
o.changed.len() <= 1 << 16,
"{} changed queued",
o.changed.len()
);
let mut fresh = Occlusion::new(0, 4);
for (&(x, z), column) in &o.columns.map {
for (y, &vis) in column.iter().enumerate() {
fresh.set((x, y as i32, z), vis);
}
}
let camera = at((0, 1, 0));
for _ in 0..2 {
o.settle(camera, 6);
fresh.settle(camera, 6);
}
assert_eq!(
reached(&o),
reached(&fresh),
"the worker saw the same world"
);
}
}
+1 -1
View File
@@ -972,7 +972,7 @@ mod tests {
);
let border = stages("border");
validate(
&(include_str!("../border.wgsl").to_owned()
&(super::super::border_shader()
+ include_str!("../oit.wgsl")
+ include_str!("../oit_border.wgsl")),
&border.iter().map(String::as_str).collect::<Vec<_>>(),
@@ -69,7 +69,7 @@ impl Features {
let border_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Horizon improved transparency world border"),
source: wgpu::ShaderSource::Wgsl(
(include_str!("../border.wgsl").to_owned()
(super::border_shader()
+ include_str!("../oit.wgsl")
+ include_str!("../oit_border.wgsl"))
.into(),
@@ -77,6 +77,9 @@ impl DisplayReading {
pub struct FrameRequest {
/// A shader API 0.2 pack is picked under `HORIZON_HDR_FRAME=float`: its frame is float.
pub linear_pack: bool,
/// That pack's `[frame] blend` is `"linear"`: its frame holds linear light where it is
/// float (`linear_blend`).
pub linear_blend: bool,
/// Under `HORIZON_HDR_FRAME=float`, with no pack available or a 0.2 one: the frame is
/// the layered float one wherever HDR output goes.
pub hdr_float: bool,
@@ -89,6 +92,8 @@ pub struct FrameConfiguration {
pub(crate) overlay_format: wgpu::TextureFormat,
/// The layered float HDR frame: an 8-bit HUD target and an R8 coverage target beside it.
pub(crate) hud_layer: bool,
/// The frame holds linear light, not vanilla's gamma (`linear_blend::stores_linear`).
pub(crate) linear_light: bool,
}
/// The display a 0.2 pack's `Frame` describes (docs/api/shaders-reference-0.2.md).
@@ -166,6 +171,7 @@ impl EffectiveOutput {
format: wgpu::TextureFormat::Bgra8Unorm,
overlay_format: wgpu::TextureFormat::Bgra8Unorm,
hud_layer: false,
linear_light: false,
},
nits: [203.0, 1000.0, 0.0],
ui_brightness: 1.0,
@@ -259,6 +265,7 @@ pub(crate) fn resolve_frame(
format,
overlay_format: super::overlay_format(format, hud_layer, swapchain),
hud_layer,
linear_light: super::linear_blend::stores_linear(request.linear_blend, format),
}
}
@@ -295,6 +302,22 @@ pub(crate) fn resolve_output(
}
}
/// Whether HDR output lifts the world's light-source marks above paper white. Not on the title
/// screen or the float frame, and not under a pack that lights its world itself: a 0.2 pack is
/// shown at paper white, and a `shaders/` pack's `final` pass leaves its own alpha in the
/// frame, which isn't marks (HDR lighting is off under it too).
pub(crate) fn lifts_world(
title: bool,
float_frame: bool,
shaders_pack: bool,
native_api: Option<horizon_shader_api::Api>,
) -> bool {
!title
&& !float_frame
&& !shaders_pack
&& native_api.is_none_or(|api| api == horizon_shader_api::Api::V0_1)
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
@@ -337,6 +360,29 @@ pub(crate) mod tests {
resolve_output(settings, caps, reading, frame, true)
}
/// HDR output lifts light-source marks only on the 8/16-bit frame's world with no pack or
/// a 0.1 pack: a `shaders/` pack's alpha isn't marks, and a 0.2 pack is shown at paper white.
#[test]
fn only_horizons_own_gamma_world_is_lifted() {
use horizon_shader_api::Api::{V0_1, V0_2};
// (title, float frame, `shaders/` pack, Horizon pack) -> lifts
let cases = [
((false, false, false, None), true),
((false, false, false, Some(V0_1)), true),
((false, false, true, None), false),
((false, false, false, Some(V0_2)), false),
((false, true, false, None), false),
((true, false, false, None), false),
];
for ((title, float, shaders, api), lifts) in cases {
assert_eq!(
lifts_world(title, float, shaders, api),
lifts,
"title {title}, float {float}, shaders/ pack {shaders}, pack {api:?}"
);
}
}
/// Every mode against every offer, system report, pack and device: HDR output only where
/// the mode asks for it and the destination offers it, and the frame each combination
/// draws in, as `Renderer::new` picked it before the snapshot.
@@ -344,10 +390,12 @@ pub(crate) mod tests {
fn the_resolver_picks_one_output_and_frame_for_each_combination() {
let no_pack = FrameRequest {
linear_pack: false,
linear_blend: false,
hdr_float: true,
};
let pack_0_2 = FrameRequest {
linear_pack: true,
linear_blend: false,
hdr_float: true,
};
// A 0.1 pack, or none without the float switch; a refused, missing or disabled
@@ -460,6 +508,7 @@ pub(crate) mod tests {
};
let request = FrameRequest {
linear_pack: false,
linear_blend: false,
hdr_float: true,
};
let on = |driver: Option<&str>| {
@@ -702,6 +751,7 @@ pub(crate) mod tests {
};
let no_pack = FrameRequest {
linear_pack: false,
linear_blend: false,
hdr_float: true,
};
let (caps, reading) = window(Some(Output::Pq), Some(true));
@@ -460,6 +460,7 @@ mod tests {
const NO_PACK: FrameRequest = FrameRequest {
linear_pack: false,
linear_blend: false,
hdr_float: true,
};
fn on() -> Graphics {
@@ -666,34 +666,52 @@ const FACE: &str = " vec3 hz_p0 = uintBitsToFloat(hz_cp0);\n \
}\n \
}\n";
/// `terrain` (`PackAtlas::terrain_inputs`) drawing only solid terrain, then only cutout terrain:
/// each leaves the other part's quads outside the clip volume. Solid: the quads over opaque
/// texels and opaque leaves (`mesher::OPAQUE_LAYER`), as vanilla picks `ChunkSectionLayer.SOLID`
/// per quad (`FaceBakery.computeMaterialTransparency`, `SectionCompiler.compile`), and lava's
/// faces (sprite layers `lava`, its still and flowing sprites), which Iris 1.11.7 drew with
/// `gbuffers_terrain_solid` in our probes, with and without `separateAo`.
/// GLSL: whether a terrain vertex's quad is solid terrain rather than cutout. Solid: the quads
/// over opaque texels and opaque leaves (`mesher::OPAQUE_LAYER`), as vanilla picks
/// `ChunkSectionLayer.SOLID` per quad (`FaceBakery.computeMaterialTransparency`,
/// `SectionCompiler.compile`), and lava's faces (sprite layers `lava`, its still and flowing
/// sprites), which Iris 1.11.7 drew with `gbuffers_terrain_solid` in our probes, with and
/// without `separateAo`.
///
/// The test reads only the vertex's sprite layer, which all four vertices of a quad share: a
/// quad lands whole in one part. The colour's alpha can't decide it, as with `separateAo` it
/// holds each vertex's own ambient occlusion.
pub fn terrain_parts(
terrain: &horizon_shaders::GbuffersInputs,
lava: &[u16],
) -> [horizon_shaders::GbuffersInputs; 2] {
fn solid(lava: &[u16]) -> String {
let opaque = crate::mesher::OPAQUE_LAYER;
let lava: String = lava
.iter()
.map(|l| format!(" || hz_v_layer == {l}u"))
.collect();
let part = |solid: bool| {
format!("(hz_v_layer & {opaque}u) != 0u{lava}")
}
/// The game's `terrain` inputs (`PackAtlas::terrain_inputs`) for a pack: whole; drawing only
/// solid terrain, then only cutout terrain (`solid`), each leaving the other part's quads outside
/// the clip volume; and drawing both in one pass, each quad with its part's alpha test.
pub fn game_terrain(
terrain: Option<horizon_shaders::GbuffersInputs>,
lava: &[u16],
) -> horizon_shaders::GameInputs {
let solid = solid(lava);
let part = |terrain: &horizon_shaders::GbuffersInputs, part: bool| {
let mut inputs = terrain.clone();
inputs.after.push_str(&format!(
" bool hz_solid = (hz_v_layer & {opaque}u) != 0u{lava};\n \
if (hz_solid != {solid}) gl_Position = vec4(2.0, 2.0, 2.0, 1.0);\n"
" if (({solid}) != {part}) gl_Position = vec4(2.0, 2.0, 2.0, 1.0);\n"
));
inputs
};
[part(true), part(false)]
horizon_shaders::GameInputs {
terrain_parts: terrain.as_ref().map(|t| [part(t, true), part(t, false)]),
terrain_one_pass: terrain.as_ref().map(|t| horizon_shaders::GbuffersInputs {
parts: Some(horizon_shaders::Parts {
solid: solid.clone(),
cutout_alpha_test: None,
}),
..t.clone()
}),
terrain,
..Default::default()
}
}
impl PackAtlas {
@@ -852,6 +870,7 @@ impl PackAtlas {
.collect(),
after: String::new(),
alpha_test: None,
parts: None,
item_id_declared: false,
far_depth: false,
paged_maps: false,
@@ -125,6 +125,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
op: ">",
reference: 0.1,
}),
parts: None,
item_id_declared: false,
far_depth: false,
// Entities, items and block entities read the shared atlas: its own maps (paged_maps.rs).
@@ -192,6 +193,7 @@ pub fn cloud_inputs() -> horizon_shaders::GbuffersInputs {
.collect(),
after: String::new(),
alpha_test: None,
parts: None,
item_id_declared: false,
far_depth: false,
paged_maps: false,
@@ -5,6 +5,7 @@
use std::collections::BTreeMap;
use horizon_shaders::{BufferObject, CustomImage, Format, Kind};
use wgpu::util::DeviceExt;
pub(super) struct PackImage {
pub(super) def: CustomImage,
@@ -468,19 +469,33 @@ impl Buffers {
.iter()
.map(|(binding, o)| {
let least = self.minimums.get(binding).copied().unwrap_or(0);
let wanted = buffer_size(*o, size, least);
let wanted = buffer_size(o, size, least);
if wanted > largest {
log::warn!(
"shader pack: bufferObject.{binding} wants {wanted} bytes, this device \
binds at most {largest}"
);
}
device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Shader pack buffer"),
size: wanted.min(largest),
usage: wgpu::BufferUsages::STORAGE,
mapped_at_creation: false,
})
let size = wanted.min(largest);
// A buffer the pack initializes from a file starts with the file's bytes, the
// rest of it zero.
match o.initial_data() {
Some(data) => {
let mut contents = data.to_vec();
contents.resize(size as usize, 0);
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Shader pack buffer"),
contents: &contents,
usage: wgpu::BufferUsages::STORAGE,
})
}
None => device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Shader pack buffer"),
size,
usage: wgpu::BufferUsages::STORAGE,
mapped_at_creation: false,
}),
}
})
.collect();
let entries: Vec<_> = self
@@ -509,7 +524,7 @@ impl Buffers {
/// The bytes a storage buffer is made with for a render size of `render`: what the pack
/// declares, or more when a shader binds it as a larger block (`least`), which wgpu needs where
/// OpenGL binds the smaller buffer; a multiple of 4.
fn buffer_size(object: BufferObject, render: [u32; 2], least: u64) -> u64 {
fn buffer_size(object: &BufferObject, render: [u32; 2], least: u64) -> u64 {
object.size(render).max(least).next_multiple_of(4)
}
@@ -612,18 +627,18 @@ mod tests {
/// bound as a 48-byte block failed wgpu's validation, #7178); a larger one keeps its size.
#[test]
fn a_buffer_is_at_least_its_shaders_block() {
assert_eq!(buffer_size(BufferObject::Bytes(40), [1, 1], 48), 48);
assert_eq!(buffer_size(&BufferObject::Bytes(40), [1, 1], 48), 48);
assert_eq!(
buffer_size(BufferObject::Bytes(40980), [1, 1], 40992),
buffer_size(&BufferObject::Bytes(40980), [1, 1], 40992),
40992
);
assert_eq!(buffer_size(BufferObject::Bytes(100), [1, 1], 48), 100);
assert_eq!(buffer_size(BufferObject::Bytes(41), [1, 1], 0), 44);
assert_eq!(buffer_size(&BufferObject::Bytes(100), [1, 1], 48), 100);
assert_eq!(buffer_size(&BufferObject::Bytes(41), [1, 1], 0), 44);
let per_pixel = BufferObject::PerPixel {
bytes: 4,
scale: [0.5, 0.5],
};
assert_eq!(buffer_size(per_pixel, [10, 10], 64), 100);
assert_eq!(buffer_size(per_pixel, [4, 4], 64), 64);
assert_eq!(buffer_size(&per_pixel, [10, 10], 64), 100);
assert_eq!(buffer_size(&per_pixel, [4, 4], 64), 64);
}
}
@@ -50,6 +50,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
.collect(),
after,
alpha_test: None,
parts: None,
item_id_declared: false,
far_depth: false,
paged_maps: false,
@@ -1201,31 +1201,7 @@ pub(super) type TerrainDraw<'a> = dyn FnMut(
Option<(u32, &wgpu::BindGroupLayout)>,
) + 'a;
/// What the shadow camera sees of the terrain, for the game's draw lists: its view and
/// projection in wgpu's clip space, the direction toward the light, and how far from the camera
/// terrain is drawn (`shadowDistanceRenderMul`).
pub(super) struct ShadowView {
pub cull: glam::Mat4,
pub light: glam::Vec3,
pub distance: Option<f32>,
/// Draw everything, every face, within this many blocks (`shadow.culling=false|reversed`):
/// packs that voxelize the world in their shadow pass need all of it.
pub unculled: Option<f32>,
/// Every face of the drawn sections, not only those that can face the light. Iris 1.11.7's
/// shadow map holds the faces turned away from the light too (#9033): under a hill at low sun
/// a cave's ceiling, which faces away, is often all that shadows the cave.
pub every_face: bool,
/// Only the sections whose shadow can fall into the camera's view: those the camera's
/// frustum, stretched toward the light, takes in (`shadow_casters`). Our probes' Iris 1.11.7
/// maps fit keeping only those with `shadow.culling` unset (#8464): under a noon sun, only
/// the terrain above the ground the camera sees; with a low sun ahead, none behind the camera.
pub view_casters: bool,
/// Whether the projection's near and far planes in `cull` drop sections too. Our probes'
/// Iris 1.11.7 maps hold the terrain at every depth we put it along the light (from 535 blocks
/// beyond the shadow camera to about 120 before it), whatever `shadowNearPlane` and
/// `shadowFarPlane` the pack sets, or none (#10403): only its sides cull.
pub depth_planes: bool,
}
pub(super) use super::external::ShadowView;
/// An arena page's key, native vertices and optional per-quad source records.
pub(super) type TerrainPage = (u32, wgpu::Buffer, Option<wgpu::Buffer>);
@@ -2964,17 +2940,13 @@ impl PackPost {
p.name
));
}
match (&terrain, &mut terrain_cutout) {
match (&terrain, &terrain_cutout) {
(Some(t), None) => notes.push(format!("terrain draws with {}", t.name)),
(t, Some(c)) => {
if let Some(t) = t {
notes.push(format!("solid terrain draws with {}", t.name));
}
notes.push(format!("cutout terrain draws with {}", c.name));
// Timed (and labelled) apart from the solid part when it runs the same program.
if t.as_ref().is_some_and(|t| t.name == c.name) {
c.name.push_str(" (cutout)");
}
}
(None, None) => {}
}
@@ -3835,7 +3807,7 @@ impl PackPost {
timestamp_writes: self
.timing
.as_mut()
.and_then(|t| t.compute_writes(&pass.name)),
.and_then(|t| t.compute_writes(&format!("{} (compute)", pass.name))),
});
cp.set_pipeline(&pipeline);
cp.set_bind_group(0, &group, &[]);
@@ -3911,8 +3883,8 @@ impl PackPost {
}
/// Terrain (or, `translucent`, translucent terrain) with the pack's program; `draw` issues
/// the game's draws. Solid terrain in two parts has its cutout part drawn second, from the
/// same draws.
/// the game's draws. Solid terrain in two parts (a pack's `gbuffers_terrain_solid` and
/// `_cutout`) has its cutout part drawn second, from the same draws.
fn draw_terrain(
&mut self,
device: &wgpu::Device,
@@ -322,7 +322,7 @@ fn check(sampler: &str, stage: Stage, scene: Scene, expected: [u8; 4]) {
let hand = matches!(scene, Scene::Terrain { hand: true, .. });
let water = matches!(scene, Scene::Terrain { water: true, .. } | Scene::WaterOnly);
let inputs=horizon_shaders::GameInputs {
terrain:terrain.then(|| horizon_shaders::GbuffersInputs { declarations:"layout(location=0)in vec3 fixture_position;\n".into(),body:"hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);hz_MultiTexCoord0=vec4(0.0);hz_MultiTexCoord1=vec4(240.0);hz_MultiTexCoord2=vec4(240.0);hz_Normal=vec3(0.0,0.0,1.0);\n".into(),attributes:vec![],after:String::new(),alpha_test:None,item_id_declared:false,far_depth:false,paged_maps:false,water_mask:false,water_patches:false }),
terrain:terrain.then(|| horizon_shaders::GbuffersInputs { declarations:"layout(location=0)in vec3 fixture_position;\n".into(),body:"hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);hz_MultiTexCoord0=vec4(0.0);hz_MultiTexCoord1=vec4(240.0);hz_MultiTexCoord2=vec4(240.0);hz_Normal=vec3(0.0,0.0,1.0);\n".into(),attributes:vec![],after:String::new(),alpha_test:None,parts:None,item_id_declared:false,far_depth:false,paged_maps:false,water_mask:false,water_patches:false }),
hand:hand.then(crate::gfx::pack_hand::inputs),..Default::default()
};
let mut post = gpu.load(
@@ -717,6 +717,7 @@ fn shadow_depth_map_reads(
attributes: vec![],
after: String::new(),
alpha_test: None,
parts: None,
item_id_declared: false,
far_depth: false,
paged_maps: false,
@@ -48,6 +48,7 @@ fn pack(outputs: u32) -> Post {
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
let inputs = horizon_shaders::GameInputs {
terrain: Some(horizon_shaders::GbuffersInputs {
parts: None,
item_id_declared: false,
far_depth: false,
paged_maps: false,
@@ -44,6 +44,7 @@ fn pack() -> Post {
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
let inputs = horizon_shaders::GameInputs {
terrain: Some(horizon_shaders::GbuffersInputs {
parts: None,
item_id_declared: false,
far_depth: false,
paged_maps: false,
@@ -89,14 +89,7 @@ fn strips_of(
.into(),
..Default::default()
};
let inputs = horizon_shaders::GameInputs {
terrain_parts: Some(super::super::pack_atlas::terrain_parts(
&terrain,
&[LAVA, LAVA + 1],
)),
terrain: Some(terrain),
..Default::default()
};
let inputs = super::super::pack_atlas::game_terrain(Some(terrain), &[LAVA, LAVA + 1]);
const ATTRS: [wgpu::VertexAttribute; 3] =
wgpu::vertex_attr_array![0=>Float32x3,2=>Uint32,3=>Unorm8x4];
let layouts = [Some(wgpu::VertexBufferLayout {
@@ -346,3 +339,64 @@ fn alpha_test_ref_is_0_for_solid_terrain_and_0_5_for_cutout() {
assert_eq!(got, [Some(RED), Some(YELLOW), Some(RED)], "{names:?}");
}
}
/// `alphaTest.gbuffers_terrain` replaces both parts' tests when one program draws them, as it
/// did when they drew in two passes: over `GREATER 0.25`, alpha 0.3 keeps cutout terrain too
/// and 0.2 loses solid terrain too, and `alphaTestRef` reads 0.25 in both.
#[test]
fn a_packs_terrain_alpha_test_applies_to_both_parts() {
let properties = || {
(
"shaders.properties".to_owned(),
"alphaTest.gbuffers_terrain=GREATER 0.25\n".to_owned(),
)
};
for (alpha, kept) in [(0.3, true), (0.2, false)] {
let mut files = program("gbuffers_terrain", RED, alpha).to_vec();
files.push(properties());
let Some((got, _)) = strips_of(files, [255; 4]) else {
return;
};
assert_eq!(got, [kept.then_some(RED); 3], "alpha {alpha}");
}
let files = vec![
(
"gbuffers_terrain.vsh".to_owned(),
"#version 120\nvoid main(){gl_Position=vec4(gl_Vertex.xy,0.0,1.0);}".to_owned(),
),
(
"gbuffers_terrain.fsh".to_owned(),
"#version 120\nuniform float alphaTestRef;\n/* DRAWBUFFERS:0 */\n\
void main(){gl_FragData[0]=vec4(1.0,alphaTestRef*4.0,0.0,1.0);}"
.to_owned(),
),
properties(),
];
let Some((got, _)) = strips_of(files, [255; 4]) else {
return;
};
assert_eq!(got, [Some([255, 255, 0]); 3]);
}
/// The vertex stage reads its own quad's `alphaTestRef` too: 0 for solid, 0.5 for cutout.
#[test]
fn the_vertex_stage_reads_its_parts_alpha_test_ref() {
let files = vec![
(
"gbuffers_terrain.vsh".to_owned(),
"#version 120\nuniform float alphaTestRef;\nvarying float ref;\n\
void main(){ref=alphaTestRef;gl_Position=vec4(gl_Vertex.xy,0.0,1.0);}"
.to_owned(),
),
(
"gbuffers_terrain.fsh".to_owned(),
"#version 120\nvarying float ref;\n/* DRAWBUFFERS:0 */\n\
void main(){gl_FragData[0]=vec4(1.0,ref*2.0,0.0,1.0);}"
.to_owned(),
),
];
let Some((got, _)) = strips_of(files, [255; 4]) else {
return;
};
assert_eq!(got, [Some(RED), Some([255, 255, 0]), Some(RED)]);
}
@@ -102,6 +102,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
// depth. Its fragments read their own depth, as in Iris.
after: String::new(),
alpha_test: None,
parts: None,
item_id_declared: false,
far_depth: true,
paged_maps: false,
@@ -132,6 +133,7 @@ pub fn celestial_inputs() -> horizon_shaders::GbuffersInputs {
],
after: String::new(),
alpha_test: None,
parts: None,
item_id_declared: false,
far_depth: true,
paged_maps: false,
@@ -151,7 +151,9 @@ impl PassTiming {
Some(stamps(&self.queries, i))
}
/// Timestamp writes for the compute pass `name`, in a timed frame.
/// Timestamp writes for the compute pass `name`, in a timed frame. Shader packs' compute
/// passes come as `<program> (compute)`, apart from the fragment program of the same name
/// (`deferred2.csh` beside `deferred2.fsh`), whose time theirs would otherwise add to.
pub fn compute_writes(&mut self, name: &str) -> Option<wgpu::ComputePassTimestampWrites<'_>> {
let i = self.push(name)?;
Some(compute_stamps(&self.queries, i))
@@ -236,23 +238,7 @@ impl PassTiming {
drop(data);
self.read.unmap();
let times = pass_times(&stamps, self.period_ns);
for (name, ms) in self.names.iter().zip(times) {
let Some(ms) = ms.filter(|_| !name.is_empty()) else {
continue;
};
let i = match self.samples.iter().position(|s| s.0 == *name) {
Some(i) => i,
None => {
self.samples.push((name.clone(), VecDeque::new()));
self.samples.len() - 1
}
};
let window = &mut self.samples[i].1;
if window.len() == WINDOW {
window.pop_front();
}
window.push_back(ms);
}
record(&mut self.samples, self.names.iter().zip(times));
self.reports += 1;
if self.log && self.reports.is_multiple_of(REPORT) {
let medians = self.medians();
@@ -283,6 +269,40 @@ impl PassTiming {
}
}
/// Adds one timed frame's pass times to `samples`, each pass's window of its last `WINDOW`
/// frames. Passes of one name in a frame (a program drawn over several render passes) count as
/// one sample, their sum: as separate samples they shared one window, and its median was the
/// time of neither. Unnamed and unstamped passes are left out.
fn record<'a>(
samples: &mut Vec<(String, VecDeque<f32>)>,
frame: impl Iterator<Item = (&'a String, Option<f32>)>,
) {
let mut sums: Vec<(&str, f32)> = Vec::new();
for (name, ms) in frame {
let Some(ms) = ms.filter(|_| !name.is_empty()) else {
continue;
};
match sums.iter_mut().find(|s| s.0 == name) {
Some(s) => s.1 += ms,
None => sums.push((name, ms)),
}
}
for (name, ms) in sums {
let i = match samples.iter().position(|s| s.0 == name) {
Some(i) => i,
None => {
samples.push((name.to_owned(), VecDeque::new()));
samples.len() - 1
}
};
let window = &mut samples[i].1;
if window.len() == WINDOW {
window.pop_front();
}
window.push_back(ms);
}
}
/// Pass `i`'s beginning and end timestamps.
fn stamps(queries: &wgpu::QuerySet, i: u32) -> wgpu::RenderPassTimestampWrites<'_> {
wgpu::RenderPassTimestampWrites {
@@ -323,7 +343,21 @@ fn pass_times(stamps: &[u64], period_ns: f32) -> Vec<Option<f32>> {
#[cfg(test)]
mod tests {
use super::pass_times;
use super::{pass_times, record};
use std::collections::VecDeque;
#[test]
fn passes_of_one_name_count_as_their_sum() {
let names = ["terrain", "sky", "terrain", "", "water"].map(String::from);
let times = [Some(1.0), Some(2.0), Some(3.0), Some(9.0), None];
let mut samples: Vec<(String, VecDeque<f32>)> = Vec::new();
record(&mut samples, names.iter().zip(times));
let got: Vec<(&str, Vec<f32>)> = samples
.iter()
.map(|(n, w)| (n.as_str(), w.iter().copied().collect()))
.collect();
assert_eq!(got, [("terrain", vec![4.0]), ("sky", vec![2.0])]);
}
#[test]
fn overlapping_passes_cost_from_the_previous_end() {
@@ -44,6 +44,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
op: ">",
reference: 0.1,
}),
parts: None,
item_id_declared: false,
far_depth: false,
paged_maps: false,
@@ -399,6 +399,20 @@ impl Rotation {
#[cfg(test)]
mod tests {
use super::*;
/// CubeMap's cube: its corners' indices, six faces of two triangles.
#[test]
fn the_packed_cube_corners_are_cubemaps_indices() {
let indices = [
4, 7, 6, 4, 6, 5, 5, 6, 2, 5, 2, 1, 1, 2, 3, 1, 3, 0, 0, 3, 7, 0, 7, 4, 7, 3, 2, 7, 2,
6, 0, 4, 5, 0, 5, 1,
];
let source = include_str!("panorama.wgsl");
assert_eq!(
super::super::cull::packed_lookup(source, "cube_corner", 36),
indices
);
}
use std::{
fs,
sync::atomic::{AtomicU32, Ordering},
@@ -8,19 +8,27 @@ struct Out {
@builtin(position) clip: vec4<f32>,
@location(0) direction: vec3<f32>,
}
// The cube's corner of each of its 36 vertices (CubeMap's indices): 4 7 6, 4 6 5, 5 6 2,
// 5 2 1, 1 2 3, 1 3 0, 0 3 7, 0 7 4, 7 3 2, 7 2 6, 0 4 5, 0 5 1.
// Packed four bits each, eight a word, read through a switch: a constant array indexed by the
// vertex would be a private array, which Adreno's Vulkan compiler (build 0676.41) fails to link.
fn cube_corner(v: u32) -> u32 {
var word: u32;
switch v / 8u {
case 0u: { word = 0x65564674u; }
case 1u: { word = 0x13211252u; }
case 2u: { word = 0x47073003u; }
case 3u: { word = 0x40627237u; }
default: { word = 0x00001505u; }
}
return (word >> (v % 8u * 4u)) & 0xfu;
}
@vertex fn vs_panorama(@builtin(vertex_index) index: u32) -> Out {
let corners = array<vec3<f32>, 8>(
vec3(-1.0, -1.0, -1.0), vec3(1.0, -1.0, -1.0),
vec3(1.0, 1.0, -1.0), vec3(-1.0, 1.0, -1.0),
vec3(-1.0, -1.0, 1.0), vec3(1.0, -1.0, 1.0),
vec3(1.0, 1.0, 1.0), vec3(-1.0, 1.0, 1.0),
);
let indices = array<u32, 36>(
4, 7, 6, 4, 6, 5, 5, 6, 2, 5, 2, 1,
1, 2, 3, 1, 3, 0, 0, 3, 7, 0, 7, 4,
7, 3, 2, 7, 2, 6, 0, 4, 5, 0, 5, 1,
);
let position = corners[indices[index]];
// CubeMap's corners: -1 or 1 on each axis, x 1 at corners 1, 2, 5 and 6, y at 2, 3, 6
// and 7, z at 4 to 7.
let c = cube_corner(index);
let high = vec3((c ^ (c >> 1u)) & 1u, c & 2u, c & 4u) != vec3(0u);
let position = select(vec3(-1.0), vec3(1.0), high);
var out: Out;
out.clip = projection.view_proj * vec4(position, 1.0);
out.direction = position;
@@ -3,9 +3,45 @@
//! draws with it yet. Without the variable, and in a build without `horizon-extras` (a Horizon
//! extra, docs/layers.md), the device is opened exactly as before.
/// The terrain's acceleration structures (step 2).
#[path = "ray_scene.rs"]
pub(in crate::gfx) mod ray_scene;
/// The ray query feature.
const RAY_QUERY: wgpu::Features = wgpu::Features::EXPERIMENTAL_RAY_QUERY;
/// Whether `device` was opened with ray queries.
pub(in crate::gfx) fn on(device: &wgpu::Device) -> bool {
device.features().contains(RAY_QUERY)
}
/// What buffers that BLAS builds read need on top of their own usage: nothing without ray
/// queries, so the off path's buffers are main's.
pub(in crate::gfx) fn blas_input(device: &wgpu::Device) -> wgpu::BufferUsages {
if on(device) {
wgpu::BufferUsages::BLAS_INPUT
} else {
wgpu::BufferUsages::empty()
}
}
/// The index buffer of `quads` quads (triangles 0 1 2 and 0 2 3), which the terrain draws
/// and BLAS builds both read.
pub(in crate::gfx) fn quad_indices(device: &wgpu::Device, quads: u32) -> wgpu::Buffer {
use wgpu::util::DeviceExt;
let indices: Vec<u32> = (0..quads)
.flat_map(|q| {
let b = q * 4;
[b, b + 1, b + 2, b, b + 2, b + 3]
})
.collect();
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Quad indices"),
contents: bytemuck::cast_slice(&indices),
usage: wgpu::BufferUsages::INDEX | blas_input(device),
})
}
/// Whether the player asked for ray tracing (the device needs the feature from its creation).
fn requested() -> bool {
asked(std::env::var("HORIZON_RAY_TRACING").ok().as_deref())
@@ -0,0 +1,538 @@
//! The terrain as acceleration structures, with ray tracing on (#13780 step 2, after the #1180
//! spike; `ray_query`): one BLAS per section's solid quads within `reach` chunks of the camera,
//! compacted once built, and one TLAS of them, built again only when a BLAS changes or the camera
//! enters another chunk column (its instances sit relative to that column). Nothing traces them
//! yet. Without ray queries on the device there is no scene and nothing here runs.
//!
//! The section arena keeps its compact layout: a section about to get a BLAS has its solid quads
//! written out as vertices first (`Unpacker`, `rt_unpack.wgsl`), as the solid draws expand them.
use glam::DVec3;
use crate::fast_hash::FxHashMap;
use crate::gfx::{Renderer, SectionGpu, SectionKey, arena::Arena, resident::Resident};
use crate::mesher::TerrainVertex;
/// BLAS builds per frame at most: a world that just loaded builds over a few frames.
const BUILDS_PER_FRAME: usize = 512;
/// Chunks around the camera the TLAS spans, unless `HORIZON_RT_CHUNKS` says otherwise. The
/// #1180 spike's TLAS over every section cost 2-7 ms of CPU a frame at render distance 32.
const REACH: i32 = 8;
/// The renderer's side: the calls gfx.rs makes, each one line there.
impl Renderer {
/// Section `key` got a new mesh, or lost it.
pub(in crate::gfx) fn ray_mark(&mut self, key: SectionKey) {
if let Some(rt) = &mut self.ray_scene {
rt.mark(key);
}
}
/// Every section is gone (a new world, a new dimension).
pub(in crate::gfx) fn ray_clear(&mut self) {
if let Some(rt) = &mut self.ray_scene {
rt.clear();
}
}
/// The frame's BLAS builds and TLAS, recorded in `encoder` before the world pass.
pub(in crate::gfx) fn ray_prepare(
&mut self,
encoder: &mut wgpu::CommandEncoder,
camera: DVec3,
title: bool,
) {
if let Some(rt) = &mut self.ray_scene
&& !title
{
crate::spans::time("ray scene", || {
rt.prepare(
&self.device,
&self.queue,
encoder,
&self.sections,
&self.arena,
&self.indices,
camera,
)
});
}
}
}
/// The terrain's acceleration structures.
pub(in crate::gfx) struct RayScene {
/// Each section's BLAS, while it has solid quads within reach.
blas: FxHashMap<SectionKey, wgpu::Blas>,
/// Sections meshed again or emptied since their BLAS was built.
dirty: Vec<SectionKey>,
tlas: wgpu::Tlas,
/// The camera's chunk column when the TLAS was built: its instances are placed relative to
/// that column's corner, so moving inside it needs no new TLAS.
anchor: Option<(i32, i32)>,
/// Chunks around the anchor the TLAS spans.
reach: i32,
/// BLAS built this frame, to measure for compaction once submitted.
to_compact: Vec<(SectionKey, wgpu::Blas)>,
/// BLAS whose compacted size the GPU is measuring.
compacting: Vec<(SectionKey, wgpu::Blas)>,
/// Sections in the arena's compact layout, written out as vertices for their BLAS.
unpacker: Unpacker,
}
impl RayScene {
/// The scene, when the device has ray queries (`ray_query`).
pub(in crate::gfx) fn new(device: &wgpu::Device) -> Option<Self> {
if !super::on(device) {
return None;
}
let reach = std::env::var("HORIZON_RT_CHUNKS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(REACH);
Some(Self {
blas: FxHashMap::default(),
dirty: Vec::new(),
tlas: create_tlas(device, 1024),
anchor: None,
reach,
to_compact: Vec::new(),
compacting: Vec::new(),
unpacker: Unpacker::new(device, 1 << 20),
})
}
fn mark(&mut self, key: SectionKey) {
self.dirty.push(key);
}
fn clear(&mut self) {
self.blas.clear();
self.dirty.clear();
self.to_compact.clear();
self.compacting.clear();
self.anchor = None;
}
/// Compacts BLAS built on earlier frames: built with room to spare, a compacted copy (made
/// once the GPU has measured it) takes about a quarter. Whether a BLAS was replaced.
fn compact(&mut self, queue: &wgpu::Queue) -> bool {
let mut changed = false;
let blas = &mut self.blas;
self.compacting.retain(|(key, b)| {
if blas.get(key) != Some(b) {
return false;
}
if !b.ready_for_compaction() {
return true;
}
blas.insert(*key, queue.compact_blas(b));
changed = true;
false
});
// Built and submitted last frame: the GPU can now measure them.
for (key, b) in std::mem::take(&mut self.to_compact) {
if self.blas.get(&key) == Some(&b) {
b.prepare_compaction_async(|_| {});
self.compacting.push((key, b));
}
}
changed
}
/// Builds the BLAS of sections marked since the last frame (at most `BUILDS_PER_FRAME`, and
/// as many as the unpacker has room for), and the TLAS again when a BLAS changed or the
/// camera entered another chunk column.
#[allow(clippy::too_many_arguments)]
fn prepare(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
sections: &Resident<SectionGpu>,
arena: &Arena,
indices: &wgpu::Buffer,
camera: DVec3,
) {
let column = crate::gfx::resident::camera_column(camera);
// Only sections within `reach` get a BLAS (at render distance 32 all of them took 1.3 GB
// of video memory); those past `reach + 2` lose theirs, so walking along a chunk border
// doesn't build the same ones over and over.
let keep = self.reach + 2;
let mut changed = self.compact(queue);
if self.anchor != Some(column) {
let before = self.blas.len();
self.blas.retain(|k, _| within(k, column, keep));
changed |= self.blas.len() != before;
for (key, s, _) in sections.iter() {
if within(&key, column, self.reach) && s.solid > 0 && !self.blas.contains_key(&key)
{
self.dirty.push(key);
}
}
}
let take = self.dirty.len().min(BUILDS_PER_FRAME);
let mut builds: Vec<Build<'_>> = Vec::new();
let mut deferred = Vec::new();
// Vertices the unpacker writes this frame.
let mut unpacked = 0;
for key in self.dirty.drain(..take) {
if builds.iter().any(|b| b.key == key) {
continue;
}
match sections
.find(key)
.filter(|s| s.solid > 0 && within(&key, column, self.reach))
{
Some(s) => {
let at = if s.packed {
let vertices = u64::from(s.solid) * 4;
if unpacked + vertices > self.unpacker.capacity {
if unpacked > 0 {
// The rest waits for the next frame's room.
deferred.push(key);
continue;
}
self.unpacker.grow(device, vertices);
}
unpacked += vertices;
Some(unpacked - vertices)
} else {
None
};
let (size, blas) = new_blas(device, s.solid);
builds.push(Build {
key,
s,
size,
blas,
unpacked: at,
});
}
None => changed |= self.blas.remove(&key).is_some(),
}
}
self.dirty.extend(deferred);
if builds.is_empty() && !changed && self.anchor == Some(column) {
return;
}
self.unpacker.run(device, encoder, arena, &builds);
for b in &builds {
self.blas.insert(b.key, b.blas.clone());
self.to_compact.push((b.key, b.blas.clone()));
}
let near = |k: &SectionKey| within(k, column, self.reach);
let count = self.blas.keys().filter(|k| near(k)).count();
if count > self.tlas.get().len() {
self.tlas = create_tlas(device, (count as u32).next_power_of_two());
}
let min_y = sections.min_y();
let mut n = 0;
for (key, blas) in self.blas.iter().filter(|(k, _)| near(k)) {
let o = instance_origin(*key, min_y, column);
self.tlas[n] = Some(wgpu::TlasInstance::new(
blas,
[
1.0, 0.0, 0.0, o[0], 0.0, 1.0, 0.0, o[1], 0.0, 0.0, 1.0, o[2],
],
0,
0xff,
));
n += 1;
}
for i in n..self.tlas.get().len() {
if self.tlas[i].is_none() {
break;
}
self.tlas[i] = None;
}
let entries = build_entries(&builds, arena, &self.unpacker, indices);
encoder.build_acceleration_structures(entries.iter(), std::iter::once(&self.tlas));
self.anchor = Some(column);
}
}
/// Whether section `key` is within `reach` chunks of column `column`, along x and z.
fn within(key: &SectionKey, column: (i32, i32), reach: i32) -> bool {
(key.0 - column.0).abs() <= reach && (key.2 - column.1).abs() <= reach
}
/// A BLAS to build this frame.
struct Build<'a> {
key: SectionKey,
s: &'a SectionGpu,
size: wgpu::BlasTriangleGeometrySizeDescriptor,
blas: wgpu::Blas,
/// Its first vertex in the unpacker's buffer, for a section in the compact layout.
unpacked: Option<u64>,
}
impl Build<'_> {
/// The buffer holding its solid quads as vertices, and its first vertex there.
fn vertices<'b>(&self, arena: &'b Arena, unpacker: &'b Unpacker) -> (&'b wgpu::Buffer, u64) {
match self.unpacked {
Some(at) => (&unpacker.out, at),
None => (arena.buffer(self.s.page), self.s.start),
}
}
}
/// `rt_unpack.wgsl`'s `RtJob`.
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct RtJob {
base: u32,
quads: u32,
out: u32,
pad: u32,
}
/// Writes the solid quads of sections in the arena's compact layout out as vertices, for their
/// BLAS (BLAS builds read vertices; the records are only for the draws to expand). The buffer
/// is reused every frame: a frame takes only the sections it has room for.
struct Unpacker {
pipeline: wgpu::ComputePipeline,
out: wgpu::Buffer,
/// Vertices `out` holds.
capacity: u64,
}
impl Unpacker {
fn new(device: &wgpu::Device, capacity: u64) -> Self {
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Ray tracing: unpack sections"),
source: wgpu::ShaderSource::Wgsl(unpack_shader().into()),
});
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("Ray tracing: unpack sections"),
layout: None,
module: &module,
entry_point: Some("cs_rt_unpack"),
compilation_options: Default::default(),
cache: None,
});
Self {
pipeline,
out: unpacked_buffer(device, capacity),
capacity,
}
}
/// Room for at least `vertices` (one section larger than the buffer).
fn grow(&mut self, device: &wgpu::Device, vertices: u64) {
self.capacity = vertices.next_power_of_two();
self.out = unpacked_buffer(device, self.capacity);
}
/// Writes out the builds' sections that are in the compact layout, one dispatch per arena
/// page.
fn run(
&self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
arena: &Arena,
builds: &[Build<'_>],
) {
let mut pages: Vec<u32> = builds
.iter()
.filter(|b| b.unpacked.is_some())
.map(|b| b.s.page)
.collect();
pages.sort_unstable();
pages.dedup();
if pages.is_empty() {
return;
}
let empty = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout: &self.pipeline.get_bind_group_layout(0),
entries: &[],
});
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("Ray tracing: unpack sections"),
timestamp_writes: None,
});
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &empty, &[]);
for page in pages {
let jobs: Vec<RtJob> = builds
.iter()
.filter_map(|b| {
let at = b.unpacked.filter(|_| b.s.page == page)?;
Some(RtJob {
base: b.s.solid_base(),
quads: b.s.solid,
out: at as u32,
pad: 0,
})
})
.collect();
let jobs_buffer = wgpu::util::DeviceExt::create_buffer_init(
device,
&wgpu::util::BufferInitDescriptor {
label: Some("Ray tracing: unpack jobs"),
contents: bytemuck::cast_slice(&jobs),
usage: wgpu::BufferUsages::STORAGE,
},
);
let arena_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout: &self.pipeline.get_bind_group_layout(1),
entries: &[wgpu::BindGroupEntry {
binding: 19,
resource: arena.buffer(page).as_entire_binding(),
}],
});
let out_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout: &self.pipeline.get_bind_group_layout(2),
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: jobs_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: self.out.as_entire_binding(),
},
],
});
let most = jobs.iter().map(|j| j.quads * 4).max().unwrap_or(0);
pass.set_bind_group(1, &arena_group, &[]);
pass.set_bind_group(2, &out_group, &[]);
pass.dispatch_workgroups(most.div_ceil(64), jobs.len() as u32, 1);
}
}
}
/// terrain.wgsl with the unpacking entry point after it, which reads sections as its solid
/// draws do (`section_corner`).
pub(in crate::gfx) fn unpack_shader() -> String {
crate::gfx::terrain_shader() + include_str!("../rt_unpack.wgsl")
}
fn unpacked_buffer(device: &wgpu::Device, vertices: u64) -> wgpu::Buffer {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Ray tracing: unpacked sections"),
size: vertices * std::mem::size_of::<TerrainVertex>() as u64,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::BLAS_INPUT,
mapped_at_creation: false,
})
}
fn create_tlas(device: &wgpu::Device, capacity: u32) -> wgpu::Tlas {
device.create_tlas(&wgpu::CreateTlasDescriptor {
label: Some("Terrain sections"),
max_instances: capacity,
flags: wgpu::AccelerationStructureFlags::PREFER_FAST_TRACE,
update_mode: wgpu::AccelerationStructureUpdateMode::Build,
})
}
/// A new BLAS for `solid` quads, and its size.
fn new_blas(
device: &wgpu::Device,
solid: u32,
) -> (wgpu::BlasTriangleGeometrySizeDescriptor, wgpu::Blas) {
let size = wgpu::BlasTriangleGeometrySizeDescriptor {
vertex_format: wgpu::VertexFormat::Float32x3,
vertex_count: solid * 4,
index_format: Some(wgpu::IndexFormat::Uint32),
index_count: Some(solid * 6),
flags: wgpu::AccelerationStructureGeometryFlags::OPAQUE,
};
let blas = device.create_blas(
&wgpu::CreateBlasDescriptor {
label: None,
flags: wgpu::AccelerationStructureFlags::PREFER_FAST_TRACE
| wgpu::AccelerationStructureFlags::ALLOW_COMPACTION,
update_mode: wgpu::AccelerationStructureUpdateMode::Build,
},
wgpu::BlasGeometrySizeDescriptors::Triangles {
descriptors: vec![size.clone()],
},
);
(size, blas)
}
/// Build entries reading each section's solid vertices (`pos` is `TerrainVertex`'s first field)
/// with the shared quad indices.
fn build_entries<'a>(
builds: &'a [Build<'a>],
arena: &'a Arena,
unpacker: &'a Unpacker,
indices: &'a wgpu::Buffer,
) -> Vec<wgpu::BlasBuildEntry<'a>> {
let stride = std::mem::size_of::<TerrainVertex>() as u64;
builds
.iter()
.map(|b| {
let (source, first) = b.vertices(arena, unpacker);
wgpu::BlasBuildEntry {
blas: &b.blas,
geometry: wgpu::BlasGeometries::TriangleGeometries(vec![
wgpu::BlasTriangleGeometry {
size: &b.size,
vertex_buffer: source,
first_vertex: first as u32,
vertex_stride: stride,
index_buffer: Some(indices),
first_index: Some(0),
transform_buffer: None,
transform_buffer_offset: None,
},
]),
}
})
.collect()
}
/// Where section `key` sits relative to the corner of chunk column `anchor`.
fn instance_origin(key: SectionKey, min_y: i32, anchor: (i32, i32)) -> [f32; 3] {
[
((key.0 - anchor.0) * 16) as f32,
(min_y + key.1 * 16) as f32,
((key.2 - anchor.1) * 16) as f32,
]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn instances_sit_relative_to_the_anchor_column() {
// Section 5 of column (3, -2), the world starting at y = -64, seen from column (2, -2).
assert_eq!(instance_origin((3, 5, -2), -64, (2, -2)), [16.0, 16.0, 0.0]);
assert_eq!(instance_origin((2, 0, -2), -64, (2, -2)), [0.0, -64.0, 0.0]);
}
#[test]
fn the_unpack_shader_validates_with_terrain() {
let source = unpack_shader();
let module = naga::front::wgsl::parse_str(&source).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap();
}
/// The off path is main's: a device opened without ray queries (every device without
/// `HORIZON_RAY_TRACING`, and every plain build) gets no scene, and its buffers no usage.
#[test]
fn without_ray_queries_there_is_no_scene() {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let Ok((device, _)) = pollster::block_on(adapter.request_device(&Default::default()))
else {
return;
};
assert!(!super::super::on(&device));
assert!(RayScene::new(&device).is_none());
assert!(super::super::blas_input(&device).is_empty());
}
}
@@ -92,7 +92,6 @@ impl<T> Resident<T> {
self.items.iter_mut().map(|(_, v)| v)
}
/// Every section's key, value and bounds.
#[cfg(any(test, debug_assertions))]
pub fn iter(&self) -> impl Iterator<Item = (SectionKey, &T, Option<[Vec3; 2]>)> {
let items = self.items.iter().zip(&self.bounds);
items.map(|((key, value), bounds)| (*key, value, *bounds))
@@ -60,7 +60,11 @@ pub(super) struct Stars {
}
impl Stars {
pub(super) fn new(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self {
/// The stars onto a frame of `format`, holding linear light when `linear`.
pub(super) fn new(
device: &wgpu::Device,
(format, linear): (wgpu::TextureFormat, bool),
) -> Self {
let triangles = star_triangles();
let vertices = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Stars vertex buffer"),
@@ -135,10 +139,7 @@ impl Stars {
module: &shader,
entry_point: Some("fs_stars"),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &[(
"HDR_WRITE_LIGHT",
f64::from(u8::from(format == super::LINEAR_FRAME)),
)],
constants: &super::linear_blend::frame_constants(format, linear),
..Default::default()
},
targets: &[Some(wgpu::ColorTargetState {
@@ -22,6 +22,6 @@ struct Stars {
if HDR_WRITE_LIGHT && stars.hdr_light.w != 0.0 {
return vec4(hdr_written(stars.color.rgb * b, 0.175, stars.hdr_settings, stars.hdr_shares), b);
}
if HDR_WRITE_LIGHT { return vec4(stars.color.rgb * b, b); }
if HDR_WRITE_LIGHT { return vec4(frame_stored(stars.color).rgb * b, b); }
return vec4(stars.color.rgb * b, 0.175);
}
@@ -7,7 +7,7 @@ use super::{
};
// Ray tracing on request, which the device needs from its creation (gfx.rs may not grow).
#[path = "ray_query.rs"]
mod ray_query;
pub(super) mod ray_query;
use std::sync::Arc;
use winit::window::Window;
@@ -221,6 +221,18 @@ pub(super) async fn open_adapter(
configured,
})
}
/// Logs a sprite split below the CPU's (the GPU's limit), and warns when Metal's arrays grew
/// past 512 layers (#12155), so a big pack's texture-corruption report ties to it (#8471).
pub(super) fn log_sprite_split(info: &wgpu::AdapterInfo, split: u32, layers: u32) {
if info.backend == wgpu::Backend::Metal && split > 512 {
log::warn!(
"Horizon: {split}-layer Metal sprite arrays for {layers} on {}",
info.name
);
} else if split < super::sprite_split() {
log::info!("Horizon: {split} block textures per array (the GPU's limit)");
}
}
#[cfg(test)]
mod tests {
use super::{AdapterChoice, pick_adapter};
@@ -57,7 +57,7 @@ impl Visbuffer {
device: &wgpu::Device,
terrain_layout: &wgpu::BindGroupLayout,
flat: &Flat,
color: wgpu::TextureFormat,
(color, linear): (wgpu::TextureFormat, bool),
constants: &[(&str, f64)],
) -> Self {
let flat_layout = &flat.draw_layout;
@@ -65,13 +65,8 @@ impl Visbuffer {
let resolve_constants: Vec<(&str, f64)> = constants
.iter()
.copied()
.chain([
("VIS_BLOCKS_PER_PAGE", blocks_per_page as f64),
(
"HDR_WRITE_LIGHT",
f64::from(u8::from(color == super::LINEAR_FRAME)),
),
])
.chain([("VIS_BLOCKS_PER_PAGE", blocks_per_page as f64)])
.chain(super::linear_blend::frame_constants(color, linear))
.collect();
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Horizon visibility buffer"),
Loaded 100 of 927 files, more files were not shown because too many files have changed in this diff. Show more