Compare commits

...
Author SHA1 Message Date
Ludovic Marie c1f4a7c196 Merge pull request #5882 from horizon-rust/launcher-win-account-flaky
🤖 Agent: fixes the Windows-only account test flakes that blocked launcher 0.2.33 (per-store account lock). Verified: windows-latest green twice on this head (runs 41300356019 push, 41300386770 dry run: 352 passed), Mac mini account tests + clippy clean.
2026-11-01 12:36:29 +01:00
Ludovic Marie 9543d2a686 Merge pull request #5891 from horizon-rust/fix/prediction-log-where
🤖 Agent: pr_ready --quick green (1674 horizon-client tests, clippy, fmt); log format test updated; diagnostic only (log-line fields).
2026-11-01 12:16:22 +01:00
levzzz 07b7ea30d0 Merge pull request #5739 from Stink-O/native/push-and-flying-speed
feat(mods): native mods can push the player and set their flying speed
2026-11-01 16:06:22 +05:00
levzzz 4d75ff55f9 Merge pull request #5617 from Sylviromi/screen-key-input
Native mods: screen_key_pressed and screen_char_typed, keys over the open screen (API next)
2026-11-01 16:06:18 +05:00
Ludovic MarieandClaude Opus 5.5 8f2e53a966 Launcher: give each memory store its own account lock
Every unit test shared the process-wide account operations lock. On Windows a connection
to the tests' closed port (127.0.0.1:9) takes about 2 s to be refused, and tests that hold
the lock meanwhile made another test's status read answer "busy" after its 3 s wait
(Retry instead of SignInRequired), or starved a 5 s lock_within behind blocked waiters.

The in-process lock now comes from SecretStore::operations: the system keychain keeps the
one process-wide lock (one keychain per computer), a MemoryStore has its own, as its
secrets are its own. The launcher uses a single store, so nothing changes for players.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 12:05:37 +01:00
Ludovic MarieandClaude Opus 5.5 787a59fd85 Anticheat prediction: a log line says the tick's keys, where it ended and the replay's velocity
So a flag can be found again in the world and played there (#5879: water
flags that don't reproduce in the harness).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 12:05:04 +01:00
Ludovic Marie f4cd2e97a9 Merge pull request #5757 from Teiwazik/fix/cutout-mips-keep-coverage
🤖 Agent: Shader series stacked in order (#5590 → … → #5757), each head clippy/test/fmt-clean on the Mac mini, the union checked in game with an all-keys pack and without a pack (vanilla). Merging once CI and parity are green.
2026-11-01 12:05:03 +01:00
Ludovic Marie 393e3f0034 Merge pull request #5830 from Teiwazik/shader/post-targets
🤖 Agent: Shader series stacked in order (#5590 → … → #5757), each head clippy/test/fmt-clean on the Mac mini, the union checked in game with an all-keys pack and without a pack (vanilla). Merging once CI and parity are green.
2026-11-01 12:04:58 +01:00
Ludovic Marie db42d44415 Merge pull request #5819 from Teiwazik/shader/labpbr
🤖 Agent: Shader series stacked in order (#5590 → … → #5757), each head clippy/test/fmt-clean on the Mac mini, the union checked in game with an all-keys pack and without a pack (vanilla). Merging once CI and parity are green.
2026-11-01 12:00:34 +01:00
Ludovic Marie bfa86ce98d Merge pull request #5753 from Teiwazik/shader/lights
🤖 Agent: Shader series stacked in order (#5590 → … → #5757), each head clippy/test/fmt-clean on the Mac mini, the union checked in game with an all-keys pack and without a pack (vanilla). Merging once CI and parity are green.
2026-11-01 12:00:29 +01:00
Ludovic Marie 91c086865a Merge pull request #5871 from horizon-rust/fix/release-green-window
🤖 Agent: One-number change in tools/release_train.py (10 → 50 parity-main runs looked back); its 25 unit tests pass. Unblocks the Nightly train.
2026-11-01 12:00:25 +01:00
Ludovic Marie 719a7d97ed Merge pull request #5681 from Teiwazik/shader/textures
🤖 Agent: Shader series stacked in order (#5590 → … → #5757), each head clippy/test/fmt-clean on the Mac mini, the union checked in game with an all-keys pack and without a pack (vanilla). Merging once CI and parity are green.
2026-11-01 12:00:22 +01:00
Ludovic Marie 6ed842f35b Merge pull request #5663 from Teiwazik/shader/weather
🤖 Agent: Shader series stacked in order (#5590 → … → #5757), each head clippy/test/fmt-clean on the Mac mini, the union checked in game with an all-keys pack and without a pack (vanilla). Merging once CI and parity are green.
2026-11-01 12:00:18 +01:00
Ludovic Marie 5bd8da2db5 Merge pull request #5652 from Teiwazik/shader/pack-pages
🤖 Agent: Shader series stacked in order (#5590 → … → #5757), each head clippy/test/fmt-clean on the Mac mini, the union checked in game with an all-keys pack and without a pack (vanilla). Merging once CI and parity are green.
2026-11-01 12:00:14 +01:00
Ludovic Marie 66bf45924c Merge pull request #5658 from Teiwazik/shader/sky-flags
🤖 Agent: Shader series stacked in order (#5590 → … → #5757), each head clippy/test/fmt-clean on the Mac mini, the union checked in game with an all-keys pack and without a pack (vanilla). Merging once CI and parity are green.
2026-11-01 12:00:11 +01:00
Ludovic Marie ad3d1ac77d Merge pull request #5824 from Teiwazik/shader/material-fog
🤖 Agent: Shader series stacked in order (#5590 → … → #5757), each head clippy/test/fmt-clean on the Mac mini, the union checked in game with an all-keys pack and without a pack (vanilla). Merging once CI and parity are green.
2026-11-01 12:00:07 +01:00
Ludovic Marie 411202c6bb Merge pull request #5814 from Teiwazik/shader/texture-budget
🤖 Agent: Shader series stacked in order (#5590 → … → #5757), each head clippy/test/fmt-clean on the Mac mini, the union checked in game with an all-keys pack and without a pack (vanilla). Merging once CI and parity are green.
2026-11-01 12:00:03 +01:00
Ludovic Marie 46716474ef Merge pull request #5827 from Teiwazik/shader/stage-files
🤖 Agent: Shader series stacked in order (#5590 → … → #5757), each head clippy/test/fmt-clean on the Mac mini, the union checked in game with an all-keys pack and without a pack (vanilla). Merging once CI and parity are green.
2026-11-01 11:59:59 +01:00
Ludovic Marie 4fbe3bf2dd Merge pull request #5619 from Teiwazik/shader/s9-materials
🤖 Agent: Shader series stacked in order (#5590 → … → #5757), each head clippy/test/fmt-clean on the Mac mini, the union checked in game with an all-keys pack and without a pack (vanilla). Merging once CI and parity are green.
2026-11-01 11:59:55 +01:00
Ludovic Marie 294097d298 Merge pull request #5590 from Teiwazik/shader/s8-shadow-entities
🤖 Agent: Shader series stacked in order (#5590 → … → #5757), each head clippy/test/fmt-clean on the Mac mini, the union checked in game with an all-keys pack and without a pack (vanilla). Merging once CI and parity are green.
2026-11-01 11:59:51 +01:00
Ludovic MarieandClaude Opus 5.5 7a6ff6cc32 Release train: look back 50 parity-main runs, not 10
Every merge cancels the parity-main run before it. In a busy hour of merges, the
last finished run fell out of the 10-run window, green_main_head found nothing,
and no Nightly went out for 14 h (2026-11-01) although parity-main had passed
on bd8c53bfa.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:57:49 +01:00
levzzz af669895a8 Merge pull request #5792 from CocofireHD/feature/mod-particle-types
Native mods: a mod's own particle types
2026-11-01 15:50:47 +05:00
levzzz 863b9034e7 Merge pull request #5780 from lukehinojosa/feat/native-lead-drops
Native mods: lead drops through native_loot_drops
2026-11-01 15:50:43 +05:00
levzzz 9203ebc1b8 Merge remote-tracking branch 'hr/main' into pr-5739 2026-11-01 15:48:10 +05:00
levzzz 2f077e899b Merge remote-tracking branch 'hr/main' into pr-5617 2026-11-01 15:48:10 +05:00
Ludovic MarieandClaude Opus 5.5 eecf134cec Merge the shader series up to #5830 into #5757
The PRs before this one in the series, merged in order with their conflicts resolved,
so this branch merges cleanly once #5830 has.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:42:56 +01:00
Ludovic MarieandClaude Opus 5.5 606df956bf Merge the shader series up to #5819 into #5830
The PRs before this one in the series, merged in order with their conflicts resolved,
so this branch merges cleanly once #5819 has.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:42:49 +01:00
Ludovic MarieandClaude Opus 5.5 e08bc408a3 Merge the shader series up to #5753 into #5819
The PRs before this one in the series, merged in order with their conflicts resolved,
so this branch merges cleanly once #5753 has.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:42:44 +01:00
Ludovic MarieandClaude Opus 5.5 83966d3fb3 Merge the shader series up to #5681 into #5753
The PRs before this one in the series, merged in order with their conflicts resolved,
so this branch merges cleanly once #5681 has.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:42:40 +01:00
Ludovic MarieandClaude Opus 5.5 5e967a2730 Merge the shader series up to #5663 into #5681
The PRs before this one in the series, merged in order with their conflicts resolved,
so this branch merges cleanly once #5663 has.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:42:32 +01:00
Ludovic MarieandClaude Opus 5.5 13eeb8147b Merge the shader series up to #5652 into #5663
The PRs before this one in the series, merged in order with their conflicts resolved,
so this branch merges cleanly once #5652 has.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:42:25 +01:00
Ludovic MarieandClaude Opus 5.5 76e7861108 Merge the shader series up to #5658 into #5652
The PRs before this one in the series, merged in order with their conflicts resolved,
so this branch merges cleanly once #5658 has.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:42:20 +01:00
levzzz bb03a6b2e0 Merge pull request #5748 from lukehinojosa/feat/native-give-experience
Native mods: read experience orbs, give experience and levels
2026-11-01 15:42:19 +05:00
Ludovic MarieandClaude Opus 5.5 6d2f22af83 Merge the shader series up to #5824 into #5658
The PRs before this one in the series, merged in order with their conflicts resolved,
so this branch merges cleanly once #5824 has.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:42:13 +01:00
Ludovic MarieandClaude Opus 5.5 ea73dab8ce Merge the shader series up to #5814 into #5824
The PRs before this one in the series, merged in order with their conflicts resolved,
so this branch merges cleanly once #5814 has.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:42:08 +01:00
Ludovic MarieandClaude Opus 5.5 76154d843f Merge the shader series up to #5827 into #5814
The PRs before this one in the series, merged in order with their conflicts resolved,
so this branch merges cleanly once #5827 has.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:42:00 +01:00
Ludovic MarieandClaude Opus 5.5 aa125d76a7 Merge the shader series up to #5619 into #5827
The PRs before this one in the series, merged in order with their conflicts resolved,
so this branch merges cleanly once #5619 has.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:41:53 +01:00
Ludovic MarieandClaude Opus 5.5 2f5862e0e3 Merge the shader series up to #5590 into #5619
The PRs before this one in the series, merged in order with their conflicts resolved,
so this branch merges cleanly once #5590 has.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:41:48 +01:00
Ludovic MarieandClaude Opus 5.5 df761058da Merge main into #5590
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:41:43 +01:00
Ludovic Marie d7a41d6ff3 Merge pull request #5856 from horizon-rust/fix/prediction-join-freeze
🤖 Agent: pr_ready --quick all green (1671 horizon-client tests, clippy, fmt); new replay test + 16 existing pass; anticheat_e2e --check prediction: honest client never flagged, cheating bot flagged 13x and sent back 12x.
2026-11-01 11:41:06 +01:00
levzzz 08e734236d Merge remote-tracking branch 'hr/main' into pr-5780 2026-11-01 15:38:54 +05:00
levzzz 3e32651f45 Merge pull request #5763 from lukehinojosa/feat/native-interaction-drops
Native mods: player-use drops through native_loot_drops
2026-11-01 15:37:55 +05:00
levzzz 85072be4af Merge pull request #5815 from Detimes/feat-5682-console-native-commands
Native mods: a registered command typed on the dedicated server's console (part of #5682)
2026-11-01 15:37:51 +05:00
levzzz dcbfa2e3ed Merge remote-tracking branch 'hr/main' into pr-5748
# Conflicts:
#	crates/horizon-client/src/game/native_server.rs
#	docs/native-mods.md
2026-11-01 15:32:28 +05:00
levzzz 59744ec017 Merge pull request #5661 from lukehinojosa/feat/native-give-stack
Native mods: give a whole stack to any player (native_give_stack)
2026-11-01 15:31:38 +05:00
levzzz 4afb288b43 Merge pull request #5770 from d3xc0/native-hooks
Native mods: read redstone signal, light, weather, time, world height and stack size
2026-11-01 15:31:34 +05:00
levzzz 02197e6053 Merge remote-tracking branch 'hr/main' into pr-5815
# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
#	docs/native-mods.md
2026-11-01 15:28:07 +05:00
levzzzandClaude Opus 5.5 3892152091 Lead killers: each level prunes only its own corpses
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:27:58 +05:00
levzzz 51f26d0576 Merge pull request #5825 from Detimes/feat-player-command-hook
Native mods: any command a player types, cancellable (native_player_command, part of #5816)
2026-11-01 15:27:46 +05:00
Ludovic MarieandClaude Opus 5.5 0626d6d505 Anticheat prediction: a client still loading the level isn't flagged for not falling
Until the client says it loaded the level (at join and after a respawn), the
ticks it ends with no move are excused: it is on its loading screen, where it
doesn't move, even once the server stops waiting for it after 60 ticks. Its
first move counts as any other.

Fixes #5849.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:27:33 +01:00
Ludovic Marie d8dc15794c Merge pull request #5847 from horizon-rust/perf/startup-sp2
🤖 Agent: playhorizon 4 CPUs, 6 alternated starts: Done 2.067 → 1.881 s median (6/6), core tables 0.70 → 0.52 s, idle RSS same; pr_ready tests 1702 + content roots pass, fmt/clippy/Python clean on the Mac.
2026-11-01 11:23:54 +01:00
Ludovic Marie de552471f6 Merge pull request #5848 from horizon-rust/fix/main-build-attributes-after-first-batch
Server: a joining player's attributes wait for its first chunk batch (dedicated_server flake)
2026-11-01 11:23:09 +01:00
Ludovic MarieandClaude Opus 5.5 d10b514182 Server: a joining player's attributes wait for its first chunk batch
parity-main fails on dedicated_server now and then: "+play-start
minecraft:update_attributes". #5597 held the join tick's dirty attribute
instances for one tick, because vanilla syncs them on the next tick, after
the first chunk batch. But this server sends no batch before placed_at + 2,
and only once the player's chunks are ready: on a loaded runner the first
batch came after that next tick, so the attributes went out before it.
They now wait until a batch has been sent (unacknowledged > 0, or the
client already acknowledged one), which keeps vanilla's order however long
the chunks take.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:22:24 +01:00
levzzz f9187fe58b Merge pull request #5707 from ErikKalikas/mod-block-entity-keep
Native mods: keep a mod block entity's geometry while it doesn't change
2026-11-01 15:21:38 +05:00
levzzzandClaude Opus 5.5 d3fb451a44 Flying speed: the server's movement replay uses it; docs: client pushes and the anticheat, false inside a player's hook
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:14:50 +05:00
levzzzandClaude Opus 5.5 8560cb7287 Creative search: a number key the hovered slot took types nothing; scroll hook doc: discrete scrolling, scroll-at
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:14:14 +05:00
levzzzandClaude Opus 5.5 5b4333059f Mod particle types: an API fragment instead of a hand-bumped number; say vanilla clients aren't sent them
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:13:43 +05:00
levzzz 5c0ee1ef23 Merge #5763's branch (fixed) into the lead drops 2026-11-01 15:12:40 +05:00
levzzzandClaude Opus 5.5 a9c6d86b39 Spill: a use whose loot came out empty still reaches the hook and is drained
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:12:34 +05:00
levzzzandClaude Opus 5.5 4e40cd0eb4 Experience docs: no level-up sound for levels, when a give can be refused, merged orbs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:12:26 +05:00
levzzzandClaude Opus 5.5 079292b669 Give stack: reach no other player inside a player's hook
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:12:17 +05:00
levzzzandClaude Opus 5.5 e7fedb9bc3 World reads: no light in unloaded chunks, no weather outside the overworld, 26.3's clock name
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:12:05 +05:00
levzzzandClaude Opus 5.5 723299c6ab Console mod commands: look the line up trimmed, as the console reads it
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:11:40 +05:00
levzzzandClaude Opus 5.5 2a1b8a321a Player command hook: hear quiet command batches, name it canceled, catalog keys
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:11:31 +05:00
levzzzandClaude Opus 5.5 c3af108912 Clear kept mod block entity geometry with the world; say what keep() freezes
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:10:58 +05:00
levzzz 4ef0afc99c Merge remote-tracking branch 'hr/main' into pr-5707
# Conflicts:
#	crates/horizon-client/src/game/native_block_entity_draw.rs
2026-11-01 15:10:58 +05:00
levzzz 648a65259d Merge remote-tracking branch 'hr/main' into pr-5739 2026-11-01 15:10:25 +05:00
levzzz bbc31dd472 Merge remote-tracking branch 'hr/main' into pr-5617 2026-11-01 15:10:24 +05:00
levzzz e0f5ad643b Merge remote-tracking branch 'hr/main' into pr-5792 2026-11-01 15:10:24 +05:00
levzzz 6d744151ac Merge remote-tracking branch 'hr/main' into pr-5763 2026-11-01 15:10:24 +05:00
levzzz 50069df898 Merge remote-tracking branch 'hr/main' into pr-5748 2026-11-01 15:10:24 +05:00
levzzz a69a65d099 Merge remote-tracking branch 'hr/main' into pr-5661 2026-11-01 15:10:24 +05:00
levzzz 7239b38b1d Merge remote-tracking branch 'hr/main' into pr-5770 2026-11-01 15:10:24 +05:00
levzzz 2b7549c556 Merge remote-tracking branch 'hr/main' into pr-5815 2026-11-01 15:10:23 +05:00
levzzz 9802a6dccb Merge remote-tracking branch 'hr/main' into pr-5825 2026-11-01 15:10:23 +05:00
levzzz bd8c53bfa4 Merge pull request #5616 from Sylviromi/screen-mouse-scrolled
Native mods: screen_mouse_scrolled, the wheel over the open screen (API next)
2026-11-01 15:09:48 +05:00
levzzz afac2f98eb Merge pull request #5729 from Stink-O/native/clock
feat(mods): native mods can read the world's clock
2026-11-01 15:09:42 +05:00
levzzz aa85e03ee2 Merge pull request #5687 from ErikKalikas/mod-block-entity-draw-item-helpers
Native mods: draw items in mod block entities, read their block state's properties
2026-11-01 15:09:35 +05:00
CocofireHD 42aec3c9f6 Native mods: keep mod particles off vanilla clients, pack their sprites 2026-11-01 10:46:01 +01:00
TeiwazikandClaude Opus 5.5 4ee84d5fa6 Shader API: [[post]] output and scale, a pack's own targets
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 10:38:07 +01:00
Ludovic MarieandClaude Opus 5.5 bef4061052 Block facts: validate the JSON beside the decode
The gameplay facts load parses its 56 MB file twice: a validation pass that
only keeps the JSON errors, then the typed decode. The validation now runs
on a scoped thread while this one decodes, and is joined before the load
returns, so its error still wins. With a single worker (`parallel` false)
the two passes run one after the other as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 10:33:35 +01:00
885a33e930 Native mods: the world options boxes type a key's characters only
The window now hands a key's characters to the character hook instead of
the key, so `world_options_key` must not read the missing text as a
backspace: only a key that carries text types, and a `None` stays the
control socket's `\b`.

The character hook fires after the key's screen effects, as GLFW's
separate char callback (`KeyboardHandler.charTyped`) comes after its key
callback, and its characters survive a cancelled press, as NeoForge's
`CharacterTyped.Pre` comes after a cancelled `KeyPressed.Pre`.

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
Co-Authored-By: Claude Code <[email protected]>
2026-11-01 12:31:27 +03:00
TeiwazikandClaude Opus 5.5 a8b2fe2969 Shader API: a stage's files, WGSL only that stage reads
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 10:27:10 +01:00
SylviromiandClaude Opus 5.5 f75040061d Native mods: a key's characters reach the screen after the key has acted
GLFW's char callback comes after its key callback, so the character
hook now fires once the screen has acted on the key, on the screen open
then: a key that closes the screen reports no character, and one that
moves the focus types into the box it moved to. Options keeps its text
on the key, as its key path routes it between boxes and key binds.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 12:26:44 +03:00
levzzz f77b1391eb Merge pull request #5649 from reservedbytes/feat/native-item-use-over-time
feat(mods): native mods use items over time (duration, animation, can eat, start and finish)
2026-11-01 14:26:32 +05:00
TeiwazikandClaude Opus 5.5 4bb3e393d9 Shader API: [material] fog, for a pack that fogs its surfaces itself
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 10:23:34 +01:00
levzzz 3477f0d8ce Merge pull request #5692 from reservedbytes/feat/content-entity-types-save-summon
feat(mods): content mods' thrown entity types are saved, summoned and listed
2026-11-01 14:19:27 +05:00
levzzz b6bf49c1a9 Merge pull request #5809 from Xmilon/native/item-decorations
feat(mods): custom item icon decorations
2026-11-01 14:16:31 +05:00
levzzz b0358127e6 Merge pull request #5806 from Xmilon/native/client-resource-reload
feat(mods): notify clients after resource-pack reloads
2026-11-01 14:16:25 +05:00
Detimes 64c3a68a71 Native mods: any command a player types, cancellable (native_player_command) 2026-11-01 09:15:18 +00:00
levzzz 4e25d33e58 Merge pull request #5802 from Xmilon/native/5797-render-hand
feat(mods): cancellable first-person hand rendering
2026-11-01 14:15:16 +05:00
TeiwazikandClaude Opus 5.5 e7aa7cb796 Shader API: the material stage reads resource packs' LabPBR maps
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 10:08:39 +01:00
levzzzandClaude Opus 5.5 b91ef70301 fix(mods): a use's client countdowns follow its started duration
LivingEntityUseItemEvent.Start may change a use's duration, which
start_using_item keeps in use_duration (vanilla's useItemRemaining). The
first person pose asked getUseDuration again each frame for the remaining
ticks, and a server's client counted a food's crumbs from its consumable
time: both now count from use_duration, as getUseItemRemainingTicks does.
A use the server's flags start on the client takes getUseDuration, as
onSyncedDataUpdated does.

The fragment carries the catalog's neoforge, side and cancellable keys,
which the stamp tool now copies.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:06:29 +05:00
levzzzandClaude Opus 5.5 69722dfc5e Merge main into feat/native-item-use-over-time
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:04:09 +05:00
levzzzandClaude Opus 5.5 a77659a199 fix(mods): mod entity rows appended as text; the summon test compiles
The content root appended each mod type's entity-types.json row by parsing
and rewriting the table, which needed serde_json's float_roundtrip to keep
a zombie's movement_speed to the bit. Cargo unifies that feature over every
crate in the game build (worldgen JSON included), while horizon-world's
parity probes, built alone, would parse without it. The rows are now
appended as raw text copied from the template's row, so no vanilla float
is reparsed and the feature goes.

The save test compared p.kind by value, then borrowed p: ProjectileKind
isn't Copy.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:03:48 +05:00
levzzz ac68376123 Merge remote-tracking branch 'hr/main' into pr-5692 2026-11-01 14:02:39 +05:00
Detimes 678eecf01c Hook catalog fragment: name without the native_ prefix (#5682) 2026-11-01 09:02:34 +00:00
levzzzandClaude Opus 5.5 ccc8d4a9ae Merge main into native/item-decorations; the batch takes GUI units
The stub moves next to native_item_bar: #5804's block highlight took the
spot after native_tick. The docs said the batch uses screen coordinates;
it takes the same GUI units as x and y (draw_slot_item fills there too).
They now also list the other screens that draw item icons.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:02:16 +05:00
levzzzandClaude Opus 5.5 402bb0be5e Merge main into native/5797-render-hand; the hook only hides
The stub moves next to native_camera_shake: #5804's block highlight took
the spot after native_tick. Its docs no longer say mods can draw their own
hand geometry: the hook passes no vertex buffer, pose or light.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:02:00 +05:00
levzzzandClaude Opus 5.5 f7dc1bd0d5 Merge main into native/client-resource-reload
The hook stub moves next to native_frame: #5804's block highlight took
the spot after native_tick.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:01:40 +05:00
Stink-OandClaude Opus 5.5 7096f3fe39 fix(mods): save a player's flying speed and document the motion helpers
Vanilla saves Abilities.flyingSpeed with the player, so a speed a server
mod set now goes into abilities.flySpeed and comes back on the next join;
a file's own speed is read. Every abilities packet comes from one
Guest::abilities, so the join, a game mode change, a respawn and a level
change all carry it (tested with the save round trip). The fragment is
renamed to native_motion for both helpers, FLYING_MAX says why 50 and what
the movement checks do with a fast non-owner, the guest comment names
the server mod, the fork wording goes, and docs/native-mods.md gets a section.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 09:00:42 +00:00
levzzz a1fa1ac0a6 Merge pull request #5804 from Xmilon/native/block-highlight
feat(mods): cancellable targeted block highlights
2026-11-01 14:00:40 +05:00
levzzz e79b7e80d8 Merge pull request #5713 from reservedbytes/feat/mod-menu-types
feat(mods): mod menu types with their own slot count and data slots
2026-11-01 14:00:36 +05:00
levzzz 7ac08d8634 Merge pull request #5677 from arismoko/fix/native-tell-acting-player
Fix native chat to the acting player inside hooks
2026-11-01 14:00:31 +05:00
levzzz 9adc39ae7a Merge pull request #5796 from Xmilon/native/5672-cancelled-click-focus
fix(mods): cancelled screen clicks preserve navigation focus
2026-11-01 14:00:26 +05:00
Sylviromi 9d255bbdf0 Merge branch 'screen-mouse-scrolled' into screen-key-input
# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
2026-11-01 11:58:47 +03:00
Stink-OandClaude Opus 5.5 d0135bf354 fix(mods): native_clock with then_some (clippy unnecessary_lazy_evaluations)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 08:57:42 +00:00
SylviromiandClaude Opus 5.5 adb8f735ec Native mods: a cancelled key still types the characters left, and the socket's backspace stays a key
A key press a mod cancelled hands the characters no mod cancelled to the
text box, as vanilla's separate charTyped does; the control socket's
type \b no longer offers its backspace sentinel as two characters, and
its cancelled tap sends the release the docs promise. The key and
character hooks' docs name the control socket and say a key that closes
the screen reports its character there first.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:56:13 +03:00
Sylviromi e69ed14ade Merge remote-tracking branch 'origin/main' into screen-mouse-scrolled 2026-11-01 11:49:37 +03:00
Detimes 7176677496 Native mods: a registered command typed on the dedicated server's console (#5682)
native_perform_console_command hears a command a mod registered when it is typed on the
dedicated server's console (level 4, no player), before vanilla's dispatcher; the answer goes
to the console through native_feedback. The single player owner's console is not covered.
2026-11-01 08:45:06 +00:00
TeiwazikandClaude Opus 5.5 5445cf8a52 Graphics setting: textureBudgetMiB, the block textures' video memory
The block sprite array was held to a fixed 256 MiB, which samples a
high-resolution pack down to 128 px. The default stays that.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 09:36:57 +01:00
Ludovic Marie b4e123280d Merge pull request #5810 from horizon-rust/fix/main-build-random-tick-push-up
random_tick: the probe skips push_entities_up (parity-main green again)
2026-11-01 09:33:02 +01:00
Ludovic MarieandClaude Opus 5.5 48af02d0a7 random_tick: a block's push_entities_up lifts nobody in the probe's cases
Since #5100, turn_to_base_block (a dirt path or farmland turning to dirt)
emits a push_entities_up block event, which the block-update probe printed
as "X push_entities_up". Vanilla's Block.pushEntitiesUp only moves the
entities in the new block's box, and these cases have none, so the oracle
logs nothing: the dirt_path case differed and parity-main failed. The probe
now skips it, as it skips the other events that need someone there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 09:32:19 +01:00
[email protected]andCodex c411dcd917 feat(mods): append custom item icon decorations
Co-Authored-By: Codex <[email protected]>
2026-11-01 09:25:36 +01:00
[email protected]andCodex b87b52e3f3 feat(mods): notify clients after successful resource reloads
Co-Authored-By: Codex <[email protected]>
2026-11-01 09:23:36 +01:00
[email protected]andCodex f4d6ebab0c feat(mods): allow cancelling targeted block highlights
Co-Authored-By: Codex <[email protected]>
2026-11-01 09:21:41 +01:00
Ludovic Marie 7e5e3d1ada Merge pull request #5799 from horizon-rust/perf/ticks-fx-hash
🤖 Agent: playhorizon A/B, 20 bots, 3 rounds: tick_blocks 5.35→5.06 ms (−5.5 %), MSPT 25.91→25.46. Tick sets only looked up (keys sorted where listed). fmt + clippy -D warnings + horizon-core tests pass (Mac mini); 955 render+core tests pass (playhorizon); its 2 Python failures are its VSCREEN env.
2026-11-01 09:19:12 +01:00
[email protected]andCodex f9e03cd32b feat(mods): add cancellable first-person hand rendering
Co-Authored-By: Codex <[email protected]>
2026-11-01 09:19:01 +01:00
[email protected]andCodex 47a342e6c9 fix(mods): keep cancelled screen clicks from moving navigation focus
Co-Authored-By: Codex <[email protected]>
2026-11-01 09:14:33 +01:00
Stink-OandClaude Opus 5.5 09a73636d0 fix(mods): native_clock gives the clock as the world keeps it
Renamed from native_get_clock (no other helper has a get_ prefix). Both fields are i64 ticks as
Level.getGameTime and getDayTime return them, without the clamp to 0: the day time counts on past 24000
and /time set can make it negative, and the docs say so. Documented as the shown world's clock, for
client mods.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 08:07:17 +00:00
Stink-O 8b9556cc9d Merge main into #5739 2026-11-01 08:00:13 +00:00
Stink-O 54335a438d Merge main into #5729 2026-11-01 08:00:13 +00:00
Ludovic Marie 5edb339a76 Merge pull request #5793 from horizon-rust/perf/chunk-encode-palette-ids
🤖 Agent: playhorizon A/B, 20 bots, 3 rounds: tick_lan 4.22→3.96 ms (−6 %), MSPT 25.65→25.32. Same bytes (new test vs from_values). fmt + clippy -D warnings pass (Mac mini); workspace tests 3982 pass (playhorizon); the 2 Python failures there are its VSCREEN env, they pass on the Mac mini.
2026-11-01 08:55:26 +01:00
CocofireHDandDeepSeek Harness a7bda85c03 Native mods: a mod's own particle types
A native mod can now send a particle of its own type, drawn by every client
from the sprite set the mod's pack ships, instead of borrowing a vanilla one.

The type is the mod's namespaced name in ParticleOptions::named ("mymod:spark");
the client builds it as vanilla's SimpleParticleType with a sprite set: a
plain billboard, no tint, 20 to 40 ticks, on the engine's own random. The set
comes from assets/<namespace>/particles/<name>.json, the file vanilla's
SimpleParticleType reads; its textures resolve as particle/<texture> in the
atlas, the same rule vanilla's own particle JSONs use.

On the wire a mod particle is Horizon's own extension: the sentinel id
MOD_PARTICLE_ID (-1, never a vanilla registry id) followed by the string,
which vanilla's Particle codec has no room for. A client that does not know
the mod ignores it like any unknown particle, and native_send_particles
refuses an unnamespaced name.

native_send_particles keeps its signature; a mod builds the options itself,
so no hook point moved. NATIVE_HOOKS_API is 22 for the new capability and
docs/native-mods.md says what is supported.

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-11-01 08:52:10 +01:00
Ludovic MarieandClaude Opus 5.5 d16d57b197 Scheduled ticks: FxHash for the per-tick position sets
LevelTicks and ChunkTicks hash a (type, position) key at every schedule,
poll and dedup check, and the chunk map at every lookup, with std's SipHash.
They are only looked up, never iterated in order (the keys of an area are
sorted), so they now use the Fx hasher, which moves from
horizon-client-render to horizon-core (re-exported under the old path).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 08:35:53 +01:00
Ludovic Marie 78043f09a9 Merge pull request #5787 from horizon-rust/fix/client-input-auto-jump
🤖 Agent: #5786. Auto-jump now in ServerboundPlayerInputPacket as vanilla's keyPresses. Staircase walk with autoJump on, same server: 75 prediction flags with main's client, 0 with this one. pr_ready --quick all green on 7a8a97dd5.
2026-11-01 08:33:08 +01:00
Ludovic MarieandClaude Opus 5.5 17410e0123 Client: the input packet carries the auto-jump, as vanilla's does
LocalPlayer.sendChanges sends input.keyPresses, which an auto-jump's makeJump
sets to jump for that tick. Horizon sent the keys as held, so with Auto-Jump on
the server saw jumps its keys didn't allow: the anticheat's prediction flagged
every auto-jump and the 0.4 a tick it left (75 flags on a staircase walk, none
with this change). The official server's log has the same lines.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 08:32:09 +01:00
Ludovic Marie 07f5e6164d Merge pull request #5781 from horizon-rust/fix/anticheat-prediction-one-flag-per-slip
🤖 Agent: #5725. One prediction flag per slip across instead of 10-20 (87% of live lines continue an earlier slip). Strict cheat tests unchanged; new tests for a missed push (counts once) and a kept-up 0.05 speed lie (35 of 40 ticks count). pr_ready --quick all green on 7a8a97dd5.
2026-11-01 08:14:18 +01:00
lukehinojosaandClaude Opus 5.5 cd12172a8e Native mods: lead drops through native_loot_drops
Leads dropped through break_leash without telling native_loot_drops,
so a mod couldn't see or change the lead a player gets back, nor the
one a leashed mob drops when a player kills it.

Untying a lead, cutting it with shears (on the mob or its fence
knot) or re-leashing a mob from another holder now passes it through
the hook as a use (break_leash_used: the leashed mob, the acting player).
A mob a player kills while leashed records its killer (lead_killers);
the corpse drops its lead on its next tick as vanilla does
(Leashable.tickLeash), as a call of its own naming that killer. A lead
that snaps or loses its holder names no player and drops as before.

The API number comes from the lead_drops fragment.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:12:10 -05:00
Ludovic MarieandClaude Opus 5.5 82888ca49d Prediction: a slip across counts once, not on every tick its velocity lasts
87% of the official server's prediction flags (6,002 of 6,900 lines on
2026-11-01) continue a divergence that started on an earlier tick: once the
replay's velocity is off (a hit it missed), the offset dies out by the air drag
(0.91) or ground friction each tick, and each of those ticks flagged again.

The first tick off after ten that agreed now has its follow-up tick try the
velocity the client was left with (across only, at most 0.5), as another
candidate. A single slip then counts once; a blink leaves the velocity alone; a
tick that agrees only thanks to it never becomes the setback point. Not up and
down: the vertical drag (0.98) would let a slow fly pass for a slip.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 08:04:40 +01:00
TeiwazikandClaude Opus 5.5 6bb88d2149 Merge main: reference and formatting
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 08:01:18 +01:00
Teiwazik 1720b747dd Merge remote-tracking branch 'origin/main' into shader/textures
# Conflicts:
#	crates/horizon-client-render/src/gfx/native_pack.rs
#	crates/horizon-client-render/src/gfx/native_post.rs
#	crates/horizon-client-render/src/gfx/native_sky.rs
#	crates/horizon-shader-api/src/check.rs
#	docs/api/shaders.md
#	launcher/Cargo.lock
2026-11-01 08:01:09 +01:00
TeiwazikandClaude Opus 5.5 d3e42f3761 Merge main: reference and formatting
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 07:59:11 +01:00
Teiwazik 2c1aa246ac Merge remote-tracking branch 'origin/main' into shader/weather
# Conflicts:
#	crates/horizon-client-render/src/gfx/native_pack.rs
#	crates/horizon-shader-api/src/stages.rs
#	docs/api/shaders-reference.md
#	docs/api/shaders.md
2026-11-01 07:59:02 +01:00
Teiwazik 2b8e602c5b Merge remote-tracking branch 'origin/main' into shader/sky-flags
# Conflicts:
#	crates/horizon-client-render/src/gfx/native_pack.rs
#	docs/api/shaders.md
2026-11-01 07:55:45 +01:00
Ludovic Marie 7a8a97dd5f Merge pull request #5776 from horizon-rust/fix/server-thread-limit-crash
🤖 Agent: #5775. Official server crash 05:15Z (spawn worker WouldBlock, no save). Worker spawn failure non-fatal; connection threads capped at max_players+64. New flood test fails without the cap, passes with it; pr_ready --quick green apart from one ENOSPC test failure that passes alone.
2026-11-01 07:55:38 +01:00
Ludovic MarieandClaude Opus 5.5 76ae6b7763 Server: a refused thread no longer crashes it; connection threads are capped
A dedicated server crashed on the official server (2026-11-01 05:15Z) when the
system refused a new thread: Streaming::new's .expect("spawn worker") panicked
and the process died without saving. Worker spawn failures are now logged and
the pool runs with the workers that started.

The server starts a thread per accepted socket (vanilla's Netty uses a few for
all): past max_players + 64 live connection threads, a new socket is closed at
once, so a flood of silent sockets can't use up a container's thread limit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 07:54:08 +01:00
Teiwazik 669cc4d8fa Merge remote-tracking branch 'origin/main' into shader/pack-pages
# Conflicts:
#	docs/api/shaders.md
2026-11-01 07:53:01 +01:00
Ludovic MarieandClaude Opus 5.5 6d035f2fa1 Chunk sending: encode a section from its stored palette ids
A section's first encoding unpacked its 4,096 block states, then built a
palette from them again (Paletted::from_values). For a list palette the
stored ids are now renumbered in first-seen order instead: the same palette,
indices and bytes, without the values. Global palettes keep the old path.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 07:52:40 +01:00
Ludovic Marie 04029578c5 Merge pull request #5774 from horizon-rust/perf/mob-tick-no-copy
🤖 Agent: playhorizon A/B, 20 bots, 3 rounds: tick_creature_entities 4.95→4.32 ms (−13 %), MSPT 26.59→25.78 (−3 %). pr_ready --quick pass (1655 horizon-client tests, 511 Python). Tick order and RNG unchanged; 34 harnesses in CI.
2026-11-01 07:48:35 +01:00
Teiwazik c958240f31 Merge remote-tracking branch 'origin/main' into shader/s9-materials 2026-11-01 07:45:57 +01:00
Teiwazik cf281cc812 Merge remote-tracking branch 'origin/main' into shader/s8b-player-shadow 2026-11-01 07:38:39 +01:00
Arthur Black bca8c9a0b8 Native mods: read light, weather, time of day, world height and stack sizes 2026-10-31 23:28:28 -07:00
Arthur Black 14dcadde70 Native mods: read the redstone signal reaching a block (native_neighbor_signal) 2026-10-31 23:28:28 -07:00
Ludovic Marie 26747867df Merge pull request #5714 from TiggerTakkyTTT/fix/native-mod-list
🤖 Agent: Fixes #5690 (no Build button with only GitHub mods); verify + launcher green; approved as code owner. Queued.
2026-11-01 07:17:47 +01:00
Ludovic MarieandClaude Opus 5.5 ab376df4e0 Mob ticks: no copy of every mob each tick; a fast hasher for the per-mob sets
tick_creature_entities takes the mob list, ticks it, and appended it back
into the emptied list: a new buffer and a copy of every Mob each tick (and
the old buffer freed). With no mob added meanwhile the list now goes back as
it is. The dead-mob set and the guest bindings, looked up per mob each tick
and never iterated, use FxHash instead of SipHash.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 07:13:44 +01:00
Ludovic Marie 87e5ca82de Merge pull request #5767 from horizon-rust/fix/anticheat-prediction-item-use
🤖 Agent: #5725 step 2. Guest item use (eating, bow, shield) now slows the replay as it slows the client. Vanilla 26.3 client walking/sprinting while drawing a bow 3 min: 503 prediction flags → 2 (0.045). pr_ready --quick all green on 3bc251ea5.
2026-11-01 07:11:40 +01:00
Ludovic Marie c624e9c1d8 Merge pull request #5765 from horizon-rust/feat/server-low-memory
🤖 Agent: [engine] low_memory (off by default) sets MIMALLOC_ALLOW_THP=0 for the game. Measured playhorizon 20 bots: idle RSS −60 MB, −190 MB at 20 players, MSPT +1.2 % (5 rounds). pr_ready --quick pass (98 horizon-server tests incl. the new env test, 511 Python); server reference regenerated.
2026-11-01 07:04:41 +01:00
Ludovic MarieandClaude Opus 5.5 16559f6668 Anticheat prediction: play a guest's item use as its client does
An item in use (food, a bow, a shield) slows the client to its
use_effects speed (0.2 for most) and stops its sprint. The replay plays a
guest on the server's copy of its body, which takes the item use from
Game.sync_player_body, and that runs only for the window's own player: a
guest's copy never had one. So the replay walked a guest who was eating
or drawing a bow at full speed. That put it 0.8 of a walk (0.173 blocks a
tick) or of a sprint (0.224) off, every tick of it. 0.173 was the official
server's most common prediction flag.

guest_replay now sets the copy's item use speed and its no-sprint flag
from the item the guest is using, as it already did for the glider. A
vanilla 26.3 client sprinting and walking while drawing a bow for 3
minutes: 503 flags before, 2 after (0.045 blocks).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 07:02:51 +01:00
Ludovic MarieandClaude Opus 5.5 8e692458eb horizon-server: [engine] low_memory turns transparent huge pages off
mimalloc backs the game's memory with transparent huge pages where Linux
allows them; many are only partly used. low_memory = true starts the game
with MIMALLOC_ALLOW_THP=0: 60 MB less idle and 190 MB less at 20 players,
for ~1.2 % more tick time (playhorizon, 20 bots, 5 rounds). Off by default.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 07:01:01 +01:00
lukehinojosaandClaude Opus 5.5 aa805243b6 Native mods: player-use drops through native_loot_drops
native_loot_drops heard block breaks and mob deaths only, so a mod
couldn't tell, or change, what a player's shears, brush or hit
dropped, nor who caused it. An auto-pickup or a global loot modifier
needs those too.

The player-use drops now pass through it, with the acting player
named and the new NativeLootDrops::used set: shears on a sheep, a
mooshroom, a snow golem or a bogged; a brush on an armadillo or a
suspicious block; shears carving a pumpkin; picking sweet berries or
cave vines; and a player's hit taking an item frame or its item. One
helper, native_use_drops, builds the context. The berry and cave vine
drops are popped inside the block behaviour, so their run of spill
spawns is tagged and retold when the spill flushes (retell_runs),
keeping each stack's spot and the random draws when no mod changes it.

The API number comes from the use_drops fragment.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 01:49:44 -04:00
reservedbytes d81a16f409 feat(mods): content mods' thrown entity types are saved, summoned and listed 2026-11-01 04:33:54 -01:00
Teiwazik cb1f775f0c Merge remote-tracking branch 'origin/main' into fix/cutout-mips-keep-coverage 2026-11-01 06:33:00 +01:00
Ludovic Marie 3bc251ea5a Merge pull request #5751 from horizon-rust/perf/light-views-on-workers
🤖 Agent: playhorizon A/B, 20 bots, 3 rounds: streaming 4.53→3.46 ms (−24 %), MSPT median 26.59→26.37. pr_ready --quick pass (831 render tests, 511 Python); forced_chunks/generation_saves/streaming_meshes pass. Light output unchanged; 117 harnesses in CI.
2026-11-01 06:32:03 +01:00
TeiwazikandClaude Opus 5.5 450feba95d Shader API: [[lights]], the colour of block light by the block giving it off
Block light has a brightness but no colour, so a pack can't make a soul torch blue. Section meshes now
list their light sources, and a 3D grid around the camera holds the colour reaching each cell, read
through block_light_color. Block light keeps its own brightness, which walls stop, so the grid only
tints it and needs no occlusion of its own.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 06:24:52 +01:00
Ludovic Marie 9cf948a102 Merge pull request #5750 from horizon-rust/fix/main-build-throwable-no-gravity
Thrown projectiles keep NoGravity: save_roundtrip passes again
2026-11-01 06:23:19 +01:00
Ludovic MarieandClaude Opus 5.5 2c935d8d91 Thrown projectiles keep NoGravity: save_roundtrip passes again
Since #5641 a saved snowball, egg or ender pearl loads as a live projectile,
but NoGravity was dropped: the scene's floating snowball and pearl fell into
the floor and were gone before the save. Entity.getGravity is 0 with
NoGravity; the projectile now reads it, honours it and writes it back.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 06:22:38 +01:00
lukehinojosaandClaude Opus 5.5 e2b0df56bd Native mods: read experience orbs, give experience and levels
A mod could cancel an experience orb in native_entity_join_level but
not read its value first, nor give experience to anyone. An
auto-pickup or a machine that pays out experience needs both.

native_experience_orbs(dimension, min, max) reads the orbs in a box,
as native_item_entities does for items. native_give_experience(uuid,
points) gives points as an orb a player touches does: Mending repairs
first (repairPlayerItems), then giveExperiencePoints with the level-up
sound. It reaches any player from any server hook, through as_guest
for a guest, and refuses one swapped out by another player's hook.
native_give_levels(uuid, levels) adds levels or, negative, takes them
off (giveExperienceLevels), as the anvil and enchanting table charge.
Both go through one dispatch, native_as_player.

The API number comes from the give_experience fragment.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 01:14:57 -04:00
Ludovic Marie cbfae59314 Merge pull request #5702 from rdbtCVS/perf/frame-cpu
Cull off-screen dropped items; fix index crash after translucent sections
2026-11-01 05:57:28 +01:00
Ludovic Marie 3dba8448f6 Merge pull request #5726 from horizon-rust/feat/anticheat-prediction-detail
🤖 Agent: log-only detail for prediction lines (#5725 step 1); unit tests for the worst tick's offset/state; vanilla client on a dedicated server logs the new detail; pr_ready --quick all green on 21a12582c.
2026-11-01 05:57:01 +01:00
Ludovic Marie d1e40035ef Merge pull request #5476 from Teiwazik/shader/s7-shadow-translucent
🤖 Agent: requeued after the LAND-STOP: holds #5461's main merge; tests + clippy pass.
2026-11-01 05:55:50 +01:00
Ludovic Marie 6ae81c6449 Merge pull request #5461 from Teiwazik/shader/s6-shadow-solid
🤖 Agent: requeued after the LAND-STOP: main merged (Frame.mods first, shadow fields after it, reference regenerated); shader-api + render native tests + clippy pass.
2026-11-01 05:55:46 +01:00
Ludovic Marie 46a177c046 Merge main and #5461's main merge into #5476
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	docs/api/shaders.md
2026-11-01 05:53:46 +01:00
Ludovic Marie 205d4c32c7 Merge main into #5461 (after the native shader params, #5411)
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client-render/src/gfx/native_post.rs
#	crates/horizon-shader-api/src/inputs.rs
#	docs/api/shaders-reference.md
#	docs/api/shaders.md
2026-11-01 05:52:57 +01:00
Ludovic Marie 8a4ea81699 Merge pull request #5413 from levzzz5154/launcher/gpui-fast
🤖 Agent: launcher on GPUI Fast (Apache-2.0; fixes the NVIDIA/blade crash #3812); macOS text fixed (font-kit) and checked against main by screenshot; launcher tests + clippy pass on macOS; approved as code owner.
2026-11-01 05:51:27 +01:00
Ludovic MarieandClaude Opus 5.5 ef2b5d25fa Launcher: GPUI Fast's macOS text system needs font-kit
Without the feature gpui-fast-platform has no font source on macOS: the
launcher drew no text at all and half its home page was blank. With it,
the home page matches main's GPUI build.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 05:48:28 +01:00
Ludovic MarieandClaude Opus 5.5 eb6d6c3616 Merge main into #5413
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 05:47:41 +01:00
Ludovic MarieandClaude Opus 5.5 6fa9bef54f Anticheat prediction: log what the worst tick did
A prediction line said only how far off a move was. On the official server
it logs about 15,000 lines an hour, from ordinary players (mostly 0.1 to
0.2 blocks) and cheaters (1 to 10 blocks) alike, and nothing in a line
tells a glide, water, a ladder or a lag from a cheat, so the check can't
be trusted to act.

Each line now ends with its worst tick as the replay played it: where the
client said it went less where the replay ended (x, y, z), how far the
client said it moved, and what the replayed body was doing (ground or air,
water, gliding, sprinting, sneaking, swimming, flying, riding). Only
flagged ticks pay for the text; the state is a few bits per played tick.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 05:47:06 +01:00
Ludovic MarieandClaude Opus 5.5 f8ee6221ff Merge main into #5413
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 05:42:46 +01:00
Ludovic Marie 81b0cd084e Merge pull request #5484 from guilhermejansen/fix/macos-updater-keychain
🤖 Agent: macOS updater keeps retired bundles until unused (fixes #5427 Keychain 100002); main merged; launcher 433 tests incl. 7 mac_update + clippy pass on macOS; approved as code owner.
2026-11-01 05:42:36 +01:00
Ludovic Marie 37fd2a4722 Merge pull request #5458 from xFedeT/feat/debug-world
🤖 Agent: DebugLevelSource grid + isDebug gates (rdbtCVS approved); main merged (random-tick masks, superflat structures); clippy + debugworld/save tests pass.
2026-11-01 05:39:54 +01:00
Ludovic MarieandClaude Opus 5.5 c539244947 Merge main into #5484
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 05:39:40 +01:00
Ludovic Marie 7baaa4423a Merge pull request #5412 from Vaspyyy/feat/native-camera-shake
🤖 Agent: native_camera_shake: identity without a mod, scaled by Distortion Effects, bounded; holds #5411. pr_ready --quick on chain-5412 (holds #5406 #5409-#5412, main f99f8feaa): fmt, clippy -D warnings, 2493 tests, 511 Python tests pass.
2026-11-01 05:37:55 +01:00
Ludovic Marie acc1fdceb5 Merge main into #5458
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/game.rs
#	crates/horizon-client/src/game/world_loading.rs
2026-11-01 05:37:29 +01:00
Ludovic Marie 31fccc920b Merge pull request #5409 from Vaspyyy/feat/native-falling-blocks
🤖 Agent: native_spawn_falling_block: server-only, bounded speed, 1024 cap, loaded chunk; holds #5406. pr_ready --quick on chain-5412 (holds #5406 #5409-#5412, main f99f8feaa): fmt, clippy -D warnings, 2493 tests, 511 Python tests pass.
2026-11-01 05:36:35 +01:00
ErikKalikas a5937ef682 Merge remote-tracking branch 'upstream/main' into mod-block-entity-draw-item-helpers
# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
2026-11-01 06:34:10 +02:00
Ludovic MarieandClaude Opus 5.5 68c5daa3ae Merge main and #5461's main merge into #5476
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 05:33:13 +01:00
Ludovic MarieandClaude Opus 5.5 e554c66ae2 Merge main into #5461
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 05:33:13 +01:00
Ludovic Marie 3099586bbc Merge pull request #5447 from Qwerner88880/hooks-ingredients
🤖 Agent: NeoForge ingredient shapes (compound = union, intersection, difference); rdbtCVS's points fixed; HashSet-order test made order-free; holds #5439.
2026-11-01 05:32:46 +01:00
Ludovic Marie a1a4fd46be Merge pull request #5439 from Qwerner88880/hooks-conditions
🤖 Agent: neoforge:conditions gate on every recipe loader; rdbtCVS approved, arismoko checked in game; main merged; conditions tests + clippy pass.
2026-11-01 05:32:43 +01:00
Stink-OandClaude Opus 5.5 8f257f4672 feat(mods): native mods can push the player and set their flying speed
`native_push_player([x, y, z])` on a client, added to the local player's velocity as an explosion's knockback is (the window's player moves itself, as vanilla's client does), and `native_set_flying_speed(player, speed)` for server mods, a player's creative flying speed kept as vanilla keeps its abilities (a respawn or a game mode change keeps it). A test for the push. Ported from Stink-O/horizon 63ea5c582 and 848a0686c.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 04:32:08 +00:00
Ludovic MarieandClaude Opus 5.5 b7ce7c00da crafting test: a tag's items have no fixed order
The test tag is a HashSet, so the compound's union came out in either order.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 05:31:41 +01:00
Stink-OandClaude Opus 5.5 9dc99a5147 feat(mods): native mods can read the world's clock
`native_get_clock()` gives a client mod the world's game time and time of day in ticks (none on the title screen), as `Level.getGameTime` and `getDayTime`: for anything timed to the day, the sun or the world's own clock. Ported from Stink-O/horizon's `game.get-clock`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 04:31:32 +00:00
Ludovic MarieandClaude Opus 5.5 ae0ed11caf Streaming: build the light views of proto chunks on the workers
A light job for a column of the last ring reads its neighbours past the
area as views of their decorated proto chunks. The server thread built
those views itself while it scheduled light (~8 columns per tick at 20
players). The proto chunk is now checked out to a worker, like a save,
which builds the view; the light job is scheduled once it is back.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 05:29:03 +01:00
Ludovic Marie 05f240102a Merge pull request #5480 from Vaspyyy/fix/native-summon-orbs-passengers
🤖 Agent: native_summon root ids: orbs (canceled → None), end crystals and cushions; passengers placed by /summon's place helper; clippy clean.
2026-11-01 05:24:58 +01:00
Ludovic MarieandClaude Opus 5.5 1488ba172e Merge main and #5439's main merge into #5447
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 05:24:08 +01:00
Ludovic Marie 88e4e2b847 Merge main into #5439
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	docs/native-mods.md
2026-11-01 05:24:07 +01:00
Ludovic Marie b6134f4841 Merge pull request #5414 from sirrayi/fix/fox-trust
🤖 Agent: merged main. Fox trust list, save/load, breeding and egg-on-fox trust (rdbtCVS's fix, approved). pr_ready --quick on a union of #5414 #5418 #5433 #5441 #5442 #5450 merged into main f99f8feaa: fmt, clippy -D warnings, 2403 client/render tests, 510 Python tests pass.
2026-11-01 05:23:27 +01:00
Ludovic Marie ba8be81ecb Merge pull request #5450 from reservedbytes/fix/native-hurt-server-canceled-notes
🤖 Agent: merged main. Canceled hits forget their attackers on every damage path (PvP, /damage, thorns, dragon, blast, potion); test. pr_ready --quick on a union of #5414 #5418 #5433 #5441 #5442 #5450 merged into main f99f8feaa: fmt, clippy -D warnings, 2403 client/render tests, 510 Python tests pass.
2026-11-01 05:23:23 +01:00
Ludovic Marie 9267c6870b Merge pull request #5442 from ezejde/fix/head-profile-texture-patch
🤖 Agent: merged main. ResolvableProfile texture patch drawn for heads, read from the active resource root, reloaded on pack change. pr_ready --quick on a union of #5414 #5418 #5433 #5441 #5442 #5450 merged into main f99f8feaa: fmt, clippy -D warnings, 2403 client/render tests, 510 Python tests pass.
2026-11-01 05:23:19 +01:00
Ludovic Marie 50f47f3acd Merge pull request #5441 from xFedeT/fix/rabbit-raids-carrots
🤖 Agent: merged main. Rabbit.RaidGardenGoal + MoreCarrotTicks; customServerAiStep's nextInt(3) checked in bytecode (same order). pr_ready --quick on a union of #5414 #5418 #5433 #5441 #5442 #5450 merged into main f99f8feaa: fmt, clippy -D warnings, 2403 client/render tests, 510 Python tests pass.
2026-11-01 05:23:15 +01:00
Ludovic MarieandClaude Opus 5.5 5fc86a7384 native_summon: end crystals and cushions keep their root ids too
The fallback for roots no command Target stands for checked only orbs after
the canceled-orb fix; end crystals and cushions also take an id at once
(rdbtCVS's re-review).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 05:23:07 +01:00
Ludovic MarieandClaude Opus 5.5 f5a7175f27 Merge main into #5480
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 05:21:40 +01:00
Ludovic Marie e91a4ba7c0 Merge #5411 (queued ahead of it) into #5412
The native-mods PRs each add a section to docs/native-mods.md at the same
place; holding the one ahead lets the series merge in one pass.

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

# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
#	docs/native-mods.md
2026-11-01 05:20:51 +01:00
Ludovic MarieandClaude Opus 5.5 6fbec036d7 Merge main into #5412
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 05:20:51 +01:00
Ludovic Marie b36c7f9da5 Merge #5410 (queued ahead of it) into #5411
The native-mods PRs each add a section to docs/native-mods.md at the same
place; holding the one ahead lets the series merge in one pass.

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

# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
#	docs/native-mods.md
2026-11-01 05:20:50 +01:00
Ludovic MarieandClaude Opus 5.5 aff0fec800 Merge main into #5411
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 05:20:50 +01:00
Ludovic Marie 8a50d1c414 Merge #5409 (queued ahead of it) into #5410
The native-mods PRs each add a section to docs/native-mods.md at the same
place; holding the one ahead lets the series merge in one pass.

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

# Conflicts:
#	docs/native-mods.md
2026-11-01 05:20:49 +01:00
Ludovic MarieandClaude Opus 5.5 e600371fc9 Merge main into #5410
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 05:20:49 +01:00
Ludovic MarieandClaude Opus 5.5 59d8117f64 Merge main into #5409
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 05:20:48 +01:00
Ludovic Marie 21a12582cf Merge pull request #5433 from k3yh/fix/nether-water-evaporate
🤖 Agent: merged main. BucketItem.emptyContents in the Nether: sendParticles(LARGE_SMOKE, corner, 8, 1,1,1, 0, ALTERNATIVE) now reaches clients; ALTERNATIVE spread added. pr_ready --quick on a union of #5414 #5418 #5433 #5441 #5442 #5450 merged into main f99f8feaa: fmt, clippy -D warnings, 2403 client/render tests, 510 Python tests pass.
2026-11-01 05:20:34 +01:00
Ludovic Marie aef5704b1e Merge pull request #5418 from xFedeT/feat/spawner-minecart
🤖 Agent: merged main. MinecartSpawner: BaseSpawner ticks at the cart, saved/loaded, drops and picks as a minecart, entity event broadcast. pr_ready --quick on a union of #5414 #5418 #5433 #5441 #5442 #5450 merged into main f99f8feaa: fmt, clippy -D warnings, 2403 client/render tests, 510 Python tests pass.
2026-11-01 05:20:30 +01:00
Ludovic Marie 8bf92ca0ae Merge pull request #5715 from horizon-rust/perf/lan-attribute-ids
🤖 Agent: playhorizon A/B, 20 bots, 3 rounds: lan_entities 1.13→0.92 ms (−19 %), tick_lan 4.52→4.27 ms. pr_ready --quick pass (1635 horizon-client tests, 511 Python). attribute_updates harness PASS; same ids, bases and modifiers sent.
2026-11-01 05:20:26 +01:00
ErikKalikasandClaude Opus 5.5 63bc913369 Native mods: name ItemStackRenderState.submit, what a campfire draws its items with
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 06:12:44 +02:00
TiggerTakkyTTTandCodex eb46a6d619 fix(launcher): unify installed native mods and expose Build
Include GitHub and local native bundles in both installed-mod views, retaining source/update actions and the existing build confirmation.

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-01 11:09:00 +07:00
Teiwazik 6018159ff1 Merge remote-tracking branch 'origin/main' into fix/cutout-mips-keep-coverage
# Conflicts:
#	crates/horizon-client-render/src/graphics.rs
2026-11-01 05:08:57 +01:00
ErikKalikasandClaude Opus 5.5 7cb008681f Native mods: native_draw_item takes an owner, as a shelf's items have
ShelfRenderer passes its block entity as the ItemOwner (at the block's
centre, turned by its facing), so a compass on it points to its target;
without one it always points the same way. The client's copy of a block
entity has no item slots: the docs say to send the stacks drawn in the
update tag.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 05:51:54 +02:00
rdbtCVSandClaude Opus 5.5 139ef2f801 Cull off-screen dropped items; restore quad indices after sorted pages
dropped_item_geometry resolved and meshed every item within 64 blocks,
even behind the camera. Apply EntityRenderer.shouldRender's frustum test
(box grown by 0.5) first, as mobs already do: ~0.2 ms -> ~0.01 ms per
frame with a few dozen items around.

draw_sections left a translucent sorted-index page bound, so the cloud
draw after it indexed past its end and wgpu aborted on the first frame.
Rebind the shared quad indices when a sorted page was used.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 03:47:09 +00:00
reservedbytes 9a47759df3 feat(mods): mod menu types with their own slot count and data slots 2026-11-01 02:44:59 -01:00
ErikKalikasandClaude Opus 5.5 6e96e6f975 Native mods: keep a mod block entity's geometry while it doesn't change
draw.keep() in native_block_entity_submit keeps what that call drew,
relative to the block as a sign's text is kept, and the block is drawn the
same again without calling the mod until its state, its block entity, its
light or the resource packs change. Idle machines then cost a copy moved to
the camera, not a call into the mod, each frame.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 05:42:29 +02:00
Ludovic Marie c51dcda011 Merge pull request #5691 from horizon-rust/fix/lunge-motion-skipped-tick
lunge_motion: name a server tick that ran without a move, retry the harness once (#5656)
2026-11-01 04:30:59 +01:00
Ludovic MarieandClaude Opus 5.5 9228c44123 lunge_motion: name a server tick that ran without a move, and retry the harness once (#5656)
#5656's falling motion (-0.3015 against vanilla's -0.2277) is a tick of
gravity more, not a change in the game: main, and the exact tree CI
failed on (#5502's head merged into 76dc5e3bd), pass every time on a
quiet machine. On a loaded one the server catches up with two ticks for
one of the jump's moves (seen: "the server ran 2 ticks for move 7"), and
the server's copy of the player falls in the tick with no move. Before
the jab that changes the motion the jab sends.

LocatorBarClient's arc now records an error when that happens up to the
jab, so the failure says why, and lunge_motion joins known_flaky so the
runner gives it its one retry.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 04:30:20 +01:00
Ludovic Marie 521614fc7f Merge pull request #5689 from horizon-rust/fix/launcher-github-build-hint
🤖 Agent: Message text only, in 11 lang files that all parse; the launcher CI job runs the i18n tests. Merging once it's green.
2026-11-01 04:23:20 +01:00
Ludovic MarieandClaude Opus 5.5 a50005adb4 Launcher: point the GitHub-install message at the Mods page's Build
The Build button moved from Settings > Native mods to the Mods page, but the
message after installing a mod from GitHub still sent players to the old
setting (reported on Discord, 2026-11-01). Updated in English and the ten
translations.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 04:22:22 +01:00
Ludovic Marie fbd0ca7f11 Merge pull request #5688 from horizon-rust/automation/native-hook-stamp
🤖 Agent: The stamp commit from the native hook stamp workflow (the bot can't open PRs). Merging once CI is green.
2026-11-01 04:18:28 +01:00
Ludovic MarieandClaude Opus 5.5 bc1d4d1516 Entity tracking: a mob's movement speed looked up once per tick
mob_attributes asked the attribute catalog for movement speed (by name)
and recomputed its base value up to three times per mob and tick, on
top of the registry id built with format! per attribute (Attr::id, the
previous commit). The speed instance, the baby and the sprinting
modifiers now share one lookup.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 04:12:45 +01:00
Ludovic MarieandClaude Opus 5.5 75a9116efb Attributes: static registry ids, not format! per mob and tick
The type's default of an attribute (base_value, read by mob movement and
every tick's entity tracking) and the LAN attribute sync built the id
"minecraft:<name>" with format! on each call: for a server's 2000 mobs that
is several allocations per mob per tick. Attr::id returns the id as a
static string.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 04:10:17 +01:00
Teiwazik 177e6be16c Merge remote-tracking branch 'origin/main' into shader/s9-materials
# Conflicts:
#	crates/horizon-client/src/game/native_shaders.rs
2026-11-01 04:07:32 +01:00
TeiwazikandClaude Opus 5.5 af1325e6a5 Shader API: [[textures]], PNGs every stage of a pack reads
Noise, lookup tables and the like had to be computed in WGSL. A pack can now ship up to eight PNGs,
which the check decodes and every stage reads as <id> and <id>_sampler.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 03:55:37 +01:00
Claude 88784b6910 Merge remote-tracking branch 'upstream/main' into fix/native-summon-orbs-passengers
# Conflicts:
#	crates/horizon-client/src/game/native_entities.rs
2026-11-01 02:50:55 +00:00
Claude 64aa0b4473 Merge remote-tracking branch 'upstream/main' into feat/native-camera-shake
# Conflicts:
#	docs/native-mods.md
2026-11-01 02:50:26 +00:00
Claude a62c609529 Merge remote-tracking branch 'upstream/main' into feat/native-shader-params
# Conflicts:
#	docs/native-mods.md
2026-11-01 02:50:25 +00:00
Claude c6a5386e9f Merge remote-tracking branch 'upstream/main' into feat/native-local-sound
# Conflicts:
#	docs/native-mods.md
2026-11-01 02:50:24 +00:00
Claude a8b7cdc12e Merge remote-tracking branch 'upstream/main' into feat/native-falling-blocks
# Conflicts:
#	crates/horizon-client/src/game.rs
#	docs/native-mods.md
2026-11-01 02:50:23 +00:00
ErikKalikasandClaude Opus 5.5 dcec52b07e Native mods: native_draw_item and native_block_state_property for drawing block entities
An item stack drawn under a block entity's pose as a campfire's, and a block
state's property (its facing) read without a world lookup. Helper-only, with a
fragment of its own.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 04:40:50 +02:00
Ludovic Marie b6ac0602d7 Merge pull request #5670 from horizon-rust/fix/sprite-split-adapter-limit
🤖 Agent: unit tests for the split choice and resplit; HORIZON_SPRITE_SPLIT=512 renders the same world as the default on the Mac mini; pr_ready --quick green. Merging after verify + parity.
2026-11-01 03:39:17 +01:00
github-actions[bot] 593e4af363 Stamp native hook API fragments 2026-11-01 02:35:29 +00:00
Ludovic Marie 929f53ad3e Merge pull request #5578 from Sylviromi/screen-mouse-button
🤖 Agent: merger STOP was a conflict with main after #5571 landed; main merged (clean). clippy -D warnings, the native hook tests (55) and rustfmt pass on it; the rest was proven in union3 with #5571.
2026-11-01 03:35:07 +01:00
Ludovic MarieandClaude Opus 5.5 475af9674a Renderer: cut block sprites by the GPU's texture array limit
A GPU with 512 layers per texture array refused to start ("GPU supports 512
texture layers; 1291 required") although terrain binds 8 sprite arrays. The
split is now the smallest of SPRITE_SPLIT, HORIZON_SPRITE_SPLIT and the
adapter's max_texture_array_layers (one less on OpenGL when a full array would
be padded past the limit), computed once when the renderer is made and used
by the sprite arrays, the shader constants and the shader pack atlas. Mip
chains prepared before the adapter was known are cut again for it. Startup
only fails when the sprites need more than 8 arrays of that split.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 03:35:06 +01:00
arismoko e8d5b826b0 Fix native chat to acting players in hooks 2026-10-31 21:31:21 -05:00
Ludovic Marie 9be37e6877 Merge remote-tracking branch 'origin/main' into n5578 2026-11-01 03:30:51 +01:00
Ludovic Marie ae4e28774f Merge pull request #5569 from v3nn7/fix/mount-offhand-steering
🤖 Agent: vanilla's Pig/Strider getControllingPassenger use player.isHolding, which checks both hands; the server path (lan_vehicles → tick_mount_riding) shares this check. Main merged; its test passes. union3 = main ba8b5b414 + #5516 #5535 #5566 #5571 #5578 #5573 #5569: pr_ready --quick passes (python 511, rustfmt, workspace tests 2856, content root); clippy -D warnings clean after the #5573 borrow fix.
2026-11-01 03:29:27 +01:00
Ludovic Marie 678c9b7073 Merge pull request #5573 from some-du6e/feat/native-modify-default-components
🤖 Agent: main merged (item-default and loot free hooks both kept), clippy needless borrow fixed. union3 = main ba8b5b414 + #5516 #5535 #5566 #5571 #5578 #5573 #5569: pr_ready --quick passes (python 511, rustfmt, workspace tests 2856, content root); clippy -D warnings clean after the #5573 borrow fix.
2026-11-01 03:29:23 +01:00
Ludovic Marie 63e9b9207a Merge pull request #5571 from Sylviromi/screen-render-post
🤖 Agent: main merged (the hook fires, then main's split-screen HUD). union3 = main ba8b5b414 + #5516 #5535 #5566 #5571 #5578 #5573 #5569: pr_ready --quick passes (python 511, rustfmt, workspace tests 2856, content root); clippy -D warnings clean after the #5573 borrow fix.
2026-11-01 03:28:15 +01:00
Ludovic Marie 8978c7c35e Merge pull request #5566 from Qwerner88880/hooks-enchant
🤖 Agent: main merged (both doc sections kept). union3 = main ba8b5b414 + #5516 #5535 #5566 #5571 #5578 #5573 #5569: pr_ready --quick passes (python 511, rustfmt, workspace tests 2856, content root); clippy -D warnings clean after the #5573 borrow fix.
2026-11-01 03:28:11 +01:00
Ludovic Marie c7095cc9e5 Merge pull request #5535 from mariuslozo-boop/fix/horse-inventory-close
🤖 Agent: main merged (the level check before main's lan_mount_valid). union3 = main ba8b5b414 + #5516 #5535 #5566 #5571 #5578 #5573 #5569: pr_ready --quick passes (python 511, rustfmt, workspace tests 2856, content root); clippy -D warnings clean after the #5573 borrow fix.
2026-11-01 03:28:07 +01:00
Ludovic Marie 78547c3b61 Merge pull request #5516 from sirrayi/mods/placement-state
🤖 Agent: main merged (both sides' level hooks kept). union3 = main ba8b5b414 + #5516 #5535 #5566 #5571 #5578 #5573 #5569: pr_ready --quick passes (python 511, rustfmt, workspace tests 2856, content root); clippy -D warnings clean after the #5573 borrow fix.
2026-11-01 03:28:03 +01:00
Teiwazik 1781a7bd3e Merge remote-tracking branch 'origin/main' into shader/pack-pages 2026-11-01 03:26:37 +01:00
Ludovic MarieandClaude Opus 5.5 db0ec1edd4 content_identity: no needless borrow (clippy)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 03:23:26 +01:00
TeiwazikandClaude Opus 5.5 f11233f125 Shader API: solid_materials work without a material stage
A pack with [shadow] and only [[post]] can now name the ids its shadow pass reads, and the test of
solid ids checks which layers lose their cutouts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 03:20:08 +01:00
Teiwazik ddadcb2203 Merge branch 'shader/s8b-player-shadow' into shader/s9-materials 2026-11-01 03:19:32 +01:00
Ludovic Marie 1734ca3982 Merge pull request #5445 from navinor/fix/armor-stand-lerp
🤖 Agent: Review points from some-du6e fixed and confirmed (seat_own_stands ordering, the cancel() test, port-ledger citations); local lan_e2e proof posted by the author's agent; CI green on 6f941a5e9. Queued for merge.
2026-11-01 03:19:11 +01:00
Ludovic Marie 8c936ae640 Merge remote-tracking branch 'origin/main' into n5569 2026-11-01 03:19:02 +01:00
Ludovic MarieandClaude Opus 5.5 c4cbf36967 Merge main into #5573 (item-default and loot hooks both kept)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 03:18:00 +01:00
Ludovic Marie ddeecbad3b Merge remote-tracking branch 'origin/main' into n5578 2026-11-01 03:17:38 +01:00
Ludovic Marie 6ac25bbc02 Merge remote-tracking branch 'origin/main' into n5571 2026-11-01 03:17:38 +01:00
Ludovic Marie d2ad79e4c1 Merge remote-tracking branch 'origin/main' into n5566 2026-11-01 03:17:38 +01:00
Ludovic Marie 072ad5d00a Merge remote-tracking branch 'origin/main' into n5535 2026-11-01 03:17:38 +01:00
Ludovic Marie 57ae3494b8 Merge remote-tracking branch 'origin/main' into n5516 2026-11-01 03:17:38 +01:00
Ludovic MarieandClaude Opus 5.5 c30ebd6aaf Merge main into #5516 (both sides' level hooks kept)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 03:17:37 +01:00
Ludovic Marie ba8b5b4146 Merge pull request #4250 from martipops/feature/launcher-auth-status
🤖 Agent: launcher account status, profile heads, refresh and targeted sign-in (Microsoft only); rdbtCVS's points fixed earlier; main merged here (lang union, both sides' account tests, a string main dropped removed). Proven: union of main 956e35469 with 19 PRs, launcher clippy + tests green (the i18n tests after the string fix), game clippy/tests green.
2026-11-01 03:16:32 +01:00
Ludovic Marie 22a3c3c55b Merge branch 'n5571' into n5578 2026-11-01 03:16:20 +01:00
Ludovic MarieandClaude Opus 5.5 29761e3803 Merge main into #5571 (the hook, then main's split-screen HUD)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 03:16:20 +01:00
Ludovic MarieandClaude Opus 5.5 cd39ec5fdc Merge main into #5566 (both hook sections kept)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 03:16:04 +01:00
Ludovic Marie 5a8414ddad Merge pull request #5561 from some-du6e/mods/loot-table-hooks
🤖 Agent: native loot table and drop hooks. Fixed here: NativeLootDrops.player only while the hurt memory lasts (dropFromLootTable's playerKilled). Proven: union of main 956e35469 with 19 PRs had this PR's code (clippy, workspace + content-root tests green); the author's later commits are docs, a test and rustfmt, and CI verify passes on this head.
2026-11-01 03:15:19 +01:00
Ludovic MarieandClaude Opus 5.5 9c1bd32c77 Merge main into #5535 (the level check before main's lan_mount_valid)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 03:14:53 +01:00
Ludovic Marie 42ffde8cd2 Merge remote-tracking branch 'origin/main' into n5573 2026-11-01 03:14:33 +01:00
TeiwazikandClaude Opus 5.5 753c565883 Shader API: the blob stays where a pack has no sun to cast with
Also names in the docs that entities the camera culls don't cast.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 03:14:15 +01:00
Ludovic Marie af2edf2e86 Merge pull request #5558 from Qwerner88880/hooks-consume
🤖 Agent: native consume effect kinds; fixed here: an empty namespace is minecraft's. Proven on a union of main 956e35469 with 19 open PRs including this head: pr_ready --quick green for it (clippy -D warnings incl. the server build, workspace tests, content-root tests, Python tests). CI parity covers the harnesses.
2026-11-01 03:13:54 +01:00
Ludovic Marie 735f4371c5 Merge pull request #5536 from Xmilon/native/block-touch-hooks
🤖 Agent: native block contact / landing hooks; fixed here: no item/projectile sweep without mod blocks (hot path), vanilla stepOn-then-inside order, getOnPosLegacy. Proven on a union of main 956e35469 with 19 open PRs including this head: pr_ready --quick green for it (clippy -D warnings incl. the server build, workspace tests, content-root tests, Python tests). CI parity covers the harnesses.
2026-11-01 03:13:50 +01:00
Ludovic Marie 213a16d88a Merge pull request #5527 from sirrayi/mods/bone-meal-on-mod-blocks
🤖 Agent: native bone meal hooks; fixed here: Some(false) keeps the meal as growCrop does, None leaves the template's. Proven on a union of main 956e35469 with 19 open PRs including this head: pr_ready --quick green for it (clippy -D warnings incl. the server build, workspace tests, content-root tests, Python tests). CI parity covers the harnesses.
2026-11-01 03:13:47 +01:00
Ludovic MarieandClaude Opus 5.5 553c11fd2f Launcher lang: drop the string main's redesign no longer uses
"Microsoft account · signed in" came back with this branch's tables in the
main merge; the code lost it in 86149f7da (i18n tests).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 03:13:12 +01:00
Ludovic Marie 09d3345c9b Merge pull request #5514 from kacpherek/fix/knot-and-orbs
🤖 Agent: fence knot clicks and orb smoothing; fixed here: the orb draws its correction step (previous kept), knot hit only within entity range; main merged. Proven on a union of main 956e35469 with 19 open PRs including this head: pr_ready --quick green for it (clippy -D warnings incl. the server build, workspace tests, content-root tests, Python tests). CI parity covers the harnesses.
2026-11-01 03:10:58 +01:00
Ludovic Marie 0a8def17fa Merge pull request #5654 from horizon-rust/sweepF/5521
🤖 Agent: #5521 by rdbtCVS finished (main merged: serialize keeps screenshotQuality and main's shadowCache). Screenshot Quality defaults to 1x, so at 1x and 100% scale F2 saves the window's frame as before; the scaled frame draws offscreen inside OOM/validation error scopes. Proven on a union of main 956e35469 with 19 open PRs including this head: pr_ready --quick green (clippy -D warnings, workspace tests).
2026-11-01 03:08:38 +01:00
Ludovic Marie 6253329644 Merge pull request #5562 from ErikKalikas/mod-block-entity-draw
🤖 Agent: native block entity renderers; main merged here. Proven on a union of main 956e35469 with 19 open PRs including this head: pr_ready --quick green for it (clippy -D warnings incl. the server build, workspace tests, content-root tests, Python tests). CI parity covers the harnesses.
2026-11-01 03:08:32 +01:00
Ludovic Marie f28c2b348c Merge pull request #5574 from some-du6e/optifine/properties
🤖 Agent: OptiFine .properties reader (new crate, no dependents); fixed here: no .. paths, linear glob, no link loops; skip-release. Proven on a union of main 956e35469 with 19 open PRs including this head: pr_ready --quick green for it (clippy -D warnings incl. the server build, workspace tests, content-root tests, Python tests). CI parity covers the harnesses.
2026-11-01 03:06:11 +01:00
Ludovic Marie 707340c312 Merge pull request #5570 from some-du6e/perf/faster-prepare
🤖 Agent: parallel horizon-prepare (same output digest, release check covers both lane layouts); fixed here: the extraction test on case-insensitive disks. Proven on a union of main 956e35469 with 19 open PRs including this head: pr_ready --quick green for it (clippy -D warnings incl. the server build, workspace tests, content-root tests, Python tests). CI parity covers the harnesses.
2026-11-01 03:06:08 +01:00
Ludovic Marie 98642e8b22 Merge pull request #5512 from PzmuV1517/mods/can-survive
🤖 Agent: native can_survive / may_place_on hooks; fixed here: a piston head's neighbour change asks can_survive. Proven on a union of main 956e35469 with 19 open PRs including this head: pr_ready --quick green for it (clippy -D warnings incl. the server build, workspace tests, content-root tests, Python tests). CI parity covers the harnesses.
2026-11-01 03:05:36 +01:00
Ludovic Marie f752cb0841 Merge pull request #5503 from navinor/feat/mod-advancement-triggers
🤖 Agent: native advancement criteria triggers (+ the author's on_fire fix); main merged here. Proven on a union of main 956e35469 with 19 open PRs including this head: pr_ready --quick green for it (clippy -D warnings incl. the server build, workspace tests, content-root tests, Python tests). CI parity covers the harnesses.
2026-11-01 03:05:32 +01:00
Ludovic Marie 9a4faa20d7 Merge pull request #5502 from sirrayi/mods/biome-modifiers
🤖 Agent: biome modifiers place mod features; fixed here: unread modifier types and unknown names are skipped with a warning instead of dropping every content mod. Proven on a union of main 956e35469 with 19 open PRs including this head: pr_ready --quick green for it (clippy -D warnings incl. the server build, workspace tests, content-root tests, Python tests). CI parity covers the harnesses.
2026-11-01 03:05:27 +01:00
Ludovic Marie f90fb824b9 Merge pull request #3825 from martipops/split/09-windows
🤖 Agent: splitscreen part 9 (on #3801, testing only); main carried up the stack. Proven on a union of main 956e35469 with 19 open PRs including this head: pr_ready --quick green for it (clippy -D warnings incl. the server build, workspace tests, content-root tests, Python tests). CI parity covers the harnesses.
2026-11-01 03:05:22 +01:00
Ludovic Marie 0fefb3744c Merge pull request #3801 from martipops/split/08-join-with-controller
🤖 Agent: splitscreen part 8 (on #3794, testing only); main carried up the stack. Proven on a union of main 956e35469 with 19 open PRs including this head: pr_ready --quick green for it (clippy -D warnings incl. the server build, workspace tests, content-root tests, Python tests). CI parity covers the harnesses.
2026-11-01 03:05:18 +01:00
Ludovic Marie 3e34340e75 Merge pull request #3794 from martipops/split/07-controllers
🤖 Agent: splitscreen part 7 (on #3777, testing only); main carried up the stack. Proven on a union of main 956e35469 with 19 open PRs including this head: pr_ready --quick green for it (clippy -D warnings incl. the server build, workspace tests, content-root tests, Python tests). CI parity covers the harnesses.
2026-11-01 03:05:14 +01:00
Ludovic Marie 84ccfa68de Merge pull request #3777 from martipops/split/06-two-players
🤖 Agent: splitscreen part 6 (HORIZON_SPLIT testing only, off by default); main merged here (main's headless loop runs before the split's event loop). Proven on a union of main 956e35469 with 19 open PRs including this head: pr_ready --quick green for it (clippy -D warnings incl. the server build, workspace tests, content-root tests, Python tests). CI parity covers the harnesses.
2026-11-01 03:05:09 +01:00
TeiwazikandClaude Opus 5.5 000eb647aa Shader API: the [weather] stage colours rain and snow streaks
A native pack had no way to light or tint weather, so vanilla's streaks stood out against a pack's
own lighting. Horizon still places and animates the streaks; the pack only returns their colour.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 03:03:24 +01:00
reservedbytes e26bd0fe58 feat(mods): native mods use items over time (duration, animation, can eat, start and finish) 2026-11-01 01:03:17 -01:00
some-du6eandClaude Opus 5.5 c2e44bda19 Native mods: the content identity hashes the changed defaults
origin/main's identity hashes mod components' defaults so peers that
disagree are told apart; it read items.json raw, so a native mod's change
slipped past it. It reads them through item_defaults::read now. The read
and hook docs mention the stack-size range.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:01:14 -06:00
lukehinojosaandClaude Opus 5.5 b126de85e3 Native mods: give a whole stack to any player (native_give_stack)
Server mods could only give plain items to the acting player
(native_give_items). An auto-pickup or a machine's output needs to hand
over the stack it has, with its damage, name and enchantments, from any
server hook. native_give_stack(uuid, stack) places it as Inventory.add
does and returns what did not fit.

Game::give's slot loop moves into inventory_add, shared by both, so
Inventory.add stays one port. The API number comes from the
give_stack fragment.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:00:38 -04:00
some-du6eandClaude Opus 5.5 9fb61eafa6 Native loot hooks: rustfmt the hookability test
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:59:46 -06:00
Ludovic Marie 690a1a4512 Merge pull request #5641 from reservedbytes/fix/save-thrown-projectiles
🤖 Agent: verified by Ludovic's AI agent (Claude). I read the diff. pr_ready --quick passed on main c4a65832e with this PR merged with 16 others from the same sweep (fmt, clippy -D warnings, horizon-client, -core and -render tests, Python tests).
2026-11-01 02:59:31 +01:00
some-du6e 2413dabfae Merge remote-tracking branch 'origin/main' into mods/loot-table-hooks
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
2026-10-31 19:58:43 -06:00
Ludovic Marie 6647659a3b Merge pull request #5542 from kacpherek/fix/remote-ride-gone-vehicle
🤖 Agent: stop riding a remote vehicle that is gone (Entity.baseTick); approved; verify green. Proven on a union of main f99f8feaa with 37 open PRs including this head: pr_ready --quick green for it (fmt, clippy -D warnings incl. the server build, 3077 workspace tests, 20 content-root tests, launcher clippy + 340 tests, 510 Python tests; the one failure is #5570's own new test, not in this PR). CI parity covers the harnesses.
2026-11-01 02:57:14 +01:00
Ludovic Marie 321af52ea4 Merge pull request #5640 from rdbtCVS/fix/armor-stand-disabled-slots-and-lead-owner
🤖 Agent: verified by Ludovic's AI agent (Claude). I read the diff. pr_ready --quick passed on main c4a65832e with this PR merged with 16 others from the same sweep (fmt, clippy -D warnings, horizon-client, -core and -render tests, Python tests).
2026-11-01 02:56:36 +01:00
Ludovic Marie 504f90a09a Merge pull request #5639 from rdbtCVS/fix/mob-equipment-itemstack
🤖 Agent: verified by Ludovic's AI agent (Claude). I read the diff. pr_ready --quick passed on main c4a65832e with this PR merged with 16 others from the same sweep (fmt, clippy -D warnings, horizon-client, -core and -render tests, Python tests).
2026-11-01 02:56:33 +01:00
Ludovic Marie b3f954f038 Merge pull request #5636 from mariuslozo-boop/fix/inventory-revert-update
🤖 Agent: verified by Ludovic's AI agent (Claude). I read the diff. pr_ready --quick passed on main c4a65832e with this PR merged with 16 others from the same sweep (fmt, clippy -D warnings, horizon-client, -core and -render tests, Python tests).
2026-11-01 02:56:29 +01:00
Ludovic Marie c4fd435788 Merge pull request #5635 from rdbtCVS/fix/autosave-saves-players
🤖 Agent: verified by Ludovic's AI agent (Claude). I read the diff. pr_ready --quick passed on main c4a65832e with this PR merged with 16 others from the same sweep (fmt, clippy -D warnings, horizon-client, -core and -render tests, Python tests).
2026-11-01 02:56:25 +01:00
Ludovic Marie 039d514f4f Merge pull request #5634 from rdbtCVS/fix/removed-entity-drops-contents
🤖 Agent: verified by Ludovic's AI agent (Claude). I read the diff. pr_ready --quick passed on main c4a65832e with this PR merged with 16 others from the same sweep (fmt, clippy -D warnings, horizon-client, -core and -render tests, Python tests).
2026-11-01 02:56:22 +01:00
Ludovic Marie 8df8d78559 Merge pull request #5633 from rdbtCVS/fix/menu-still-valid-on-click
🤖 Agent: verified by Ludovic's AI agent (Claude). I read the diff. pr_ready --quick passed on main c4a65832e with this PR merged with 16 others from the same sweep (fmt, clippy -D warnings, horizon-client, -core and -render tests, Python tests). Stacked on #5630, tested together.
2026-11-01 02:56:18 +01:00
Ludovic Marie 231b549303 Merge pull request #5632 from rdbtCVS/fix/open-menu-closes-previous
🤖 Agent: verified by Ludovic's AI agent (Claude). I read the diff. pr_ready --quick passed on main c4a65832e with this PR merged with 16 others from the same sweep (fmt, clippy -D warnings, horizon-client, -core and -render tests, Python tests). Stacked on #5630, tested together.
2026-11-01 02:56:15 +01:00
Ludovic Marie daac34040f Merge pull request #5630 from rdbtCVS/fix/close-menus-on-leave
🤖 Agent: verified by Ludovic's AI agent (Claude). I read the diff. pr_ready --quick passed on main c4a65832e with this PR merged with 16 others from the same sweep (fmt, clippy -D warnings, horizon-client, -core and -render tests, Python tests).
2026-11-01 02:56:11 +01:00
Ludovic Marie bd9f2f2683 Merge pull request #5628 from JustKarkat/fix/held-block-item-culling
🤖 Agent: verified by Ludovic's AI agent (Claude). I read the diff. pr_ready --quick passed on main c4a65832e with this PR merged with 16 others from the same sweep (fmt, clippy -D warnings, horizon-client, -core and -render tests, Python tests).
2026-11-01 02:56:07 +01:00
Ludovic Marie ab49db6a46 Merge pull request #5626 from Teiwazik/shader/pack-settings
🤖 Agent: verified by Ludovic's AI agent (Claude). I read the diff. pr_ready --quick passed on main c4a65832e with this PR merged with 16 others from the same sweep (fmt, clippy -D warnings, horizon-client, -core and -render tests, Python tests).
2026-11-01 02:56:02 +01:00
Ludovic Marie 2b7b1711c9 Merge pull request #5605 from Xmilon/native/image-tooltips
🤖 Agent: verified by Ludovic's AI agent (Claude). I read the diff. pr_ready --quick passed on main c4a65832e with this PR merged with 16 others from the same sweep (fmt, clippy -D warnings, horizon-client, -core and -render tests, Python tests). The image follows the text and the bundle component, the rule Horizon's bundle tooltip already uses; moving both under the title, as vanilla's index 1, is a separate parity fix.
2026-11-01 02:55:59 +01:00
Ludovic Marie 483a51e9f0 Merge pull request #5538 from xFedeT/fix/tadpole-brain
🤖 Agent: tadpole AbstractFish goals and TadpoleAi brain, mob_ai scenarios; approved; verify green. Proven on a union of main f99f8feaa with 37 open PRs including this head: pr_ready --quick green for it (fmt, clippy -D warnings incl. the server build, 3077 workspace tests, 20 content-root tests, launcher clippy + 340 tests, 510 Python tests; the one failure is #5570's own new test, not in this PR). CI parity covers the harnesses.
2026-11-01 02:55:55 +01:00
Ludovic Marie 904e161bf1 Merge pull request #5519 from elythepreciouscat/perf/leaf-cutout
🤖 Agent: fancy leaves sorted front to back on the mesh workers (no render-thread cost); approved; verify green. Proven on a union of main f99f8feaa with 37 open PRs including this head: pr_ready --quick green for it (fmt, clippy -D warnings incl. the server build, 3077 workspace tests, 20 content-root tests, launcher clippy + 340 tests, 510 Python tests; the one failure is #5570's own new test, not in this PR). CI parity covers the harnesses.
2026-11-01 02:55:50 +01:00
TeiwazikandClaude Opus 5.5 fbf8c46b8e Shader API: [sky] clouds, sun and stars, for a sky that draws its own
A pack's sky stage only replaced the dome: Minecraft's clouds, sun and stars still drew over it, so a
pack with its own had two of each. Each key defaults to true, so existing packs don't change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 02:54:22 +01:00
some-du6eandClaude Opus 5.5 bb3d830ed9 Native loot hooks: the drops doc lists a mob's inventories and what comes before its table
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:53:38 -06:00
some-du6e e6d8501486 Merge remote-tracking branch 'origin/main' into feat/native-modify-default-components 2026-10-31 19:49:47 -06:00
some-du6eandClaude Opus 5.5 885988f9d1 Native loot hooks: name every non-item death drop; say which tables a mob's drops come from
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:49:23 -06:00
Ludovic MarieandClaude Opus 5.5 df8aa42d9b Merge main into #5521 (serialize: screenshotQuality and main's shadowCache)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:48:52 +01:00
Ludovic MarieandClaude Opus 5.5 737633786c Merge #5571's main merge into #5578
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:48:33 +01:00
Ludovic MarieandClaude Opus 5.5 cfa8e57125 Merge main into #5571 (both new tests)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:48:08 +01:00
Ludovic MarieandClaude Opus 5.5 c652b50920 Merge main into #5562 (module list)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:48:08 +01:00
Ludovic MarieandClaude Opus 5.5 003f47c465 Merge main into #5535 (both new lan_e2e checks kept whole)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:47:57 +01:00
Ludovic MarieandClaude Opus 5.5 2c9b07d6d1 Merge main into #5514 (ExperienceOrb::tick's new argument kept)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:47:12 +01:00
Ludovic MarieandClaude Opus 5.5 08b3aca04c rustfmt's module order
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:47:04 +01:00
Ludovic MarieandClaude Opus 5.5 175635b82b Merge main into #5503 (native_criteria next to main's native_components; both doc sections)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:46:57 +01:00
some-du6eandClaude Opus 5.5 96b7a3332f Native mods: keep the stack size in range for every reader
The clamp moves from the item table into item_defaults::read, after the
hook, so loot and item models read the same 1 to 99 (a negative size too).
The durability tooltip no longer underflows when a mod lowers max_damage
below a saved stack's damage (getDamageValue clamps to the max).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:46:50 -06:00
Ludovic MarieandClaude Opus 5.5 f9839553c7 Merge main into #4250 (lang tables: both sides' strings; account tests: both)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:46:38 +01:00
Ludovic Marie db4acf3105 Merge branch 'ss3801' into ss3825 2026-11-01 02:43:29 +01:00
Ludovic Marie a72a10a288 Merge branch 'ss3794' into ss3801 2026-11-01 02:43:28 +01:00
Ludovic Marie d5aefe39e2 Merge branch 'ss3777' into ss3794 2026-11-01 02:43:27 +01:00
Ludovic MarieandClaude Opus 5.5 96e310baa6 Merge main into #3777 (main's headless loop before the split's event loop)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:43:19 +01:00
Aaron Wang 6f941a5e92 Merge branch 'main' into fix/armor-stand-lerp 2026-10-31 21:42:57 -04:00
Ludovic Marie 8788e9438e Merge branch 'pr/F5570new' into fix5570 2026-11-01 02:41:57 +01:00
TeiwazikandClaude Opus 5.5 622786d903 Shader packs: [[pages]], the layout of a Horizon pack's options page
A pack with dozens of options needs them in categories. The first page is the one the button opens,
"page:<id>" opens another, "" leaves a slot empty and columns = 1 gives full-width buttons. Every page
has to be reached from the first and every option has to be on a page, or the check refuses the pack.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 02:41:23 +01:00
Ludovic MarieandClaude Opus 5.5 fd5f849c42 horizon-prepare test: ask the disk whether Tag.json and tag.json are one file
The skip compared Paths, which compare case-sensitively, so it never fired
and the extraction test failed on macOS's case-insensitive file system (the
later member, tag.json, wins there). It now checks whether a case-folded
path exists.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:40:52 +01:00
some-du6eandClaude Opus 5.5 d0013cfc31 Native loot hooks: document the hurt memory behind the killing player; scan the loot hook from its doc comment
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:40:30 -06:00
some-du6eandClaude Opus 5.5 c6a450499b horizon-prepare: HORIZON_PREPARE_JOBS caps each step's threads
Each copying, hashing or compiling step spreads over that many threads; the lanes run theirs side by side, so it is no total limit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:38:53 -06:00
Aaron Wang 1c6d6935d4 lan_e2e objects: a stand that falls, so the guest follows its moves
The objects check summoned its stands on the floor, where a guest that
never moved a remote stand still matched the host. This one starts 3
blocks up and turned to 200 degrees, so where it lands and its yaw are
compared between host and guest.

Written with Claude Opus 5.5 in OpenCode.
2026-10-31 21:38:02 -04:00
some-du6eandClaude Opus 5.5 2a3429819e Native mods: keep a changed stack size to vanilla's 1 to 99
A mod setting minecraft:max_stack_size to 0 made merchant.rs clamp(1, 0)
panic on the server thread. Items now keeps it to ExtraCodecs.intRange(1, 99),
and the docs say what else a malformed value does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:34:52 -06:00
some-du6eandClaude Opus 5.5 0ed5f554b3 Native loot hooks: name what the drops hook doesn't hear; check the loot hook's attributes
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:34:39 -06:00
rdbtCVSandClaude Sonnet 5.5 c1348a3526 Armor stand DisabledSlots and another player's lead are locked
ArmorStand.interactAt ignored `DisabledSlots` (not even read or saved), so
locked stands could be emptied and filled. The stand now keeps the bitmask:
bit `filterFlag` disables a slot (an empty hand then takes from the main
hand), `+8` stops taking from it and `+16` putting into it
(`isDisabled`, `swapItem`), and the save writes it back.

A lead tied to another player was untied by anyone who clicked the mob;
only the player it is tied to unties it (`getLeashHolder() == player`).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 01:31:01 +00:00
Ludovic Marie 998049b7dd Merge branch 'pr/F5561new' into fix5561 2026-11-01 02:30:58 +01:00
Ludovic MarieandClaude Opus 5.5 8d3e175d49 Native loot drops: the killing player only while the hurt memory lasts
LivingEntity.dropFromLootTable supplies LAST_DAMAGE_PLAYER only when
playerKilled (lastHurtByPlayerMemoryTime > 0), so NativeLootDrops.player is
None for a mob that dies to the environment after the memory ran out
(arismoko's review). Vanilla's own loot is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:30:06 +01:00
Ludovic MarieandClaude Opus 5.5 58f3f290e6 Block contact hooks: no sweep without mod blocks, vanilla's order and on-pos
- swept_inside returns at once when no loaded block is outside minecraft:,
  so a server's items and projectiles no longer sweep their blocks every tick
  with no content mod (rdbtCVS's hot-path note).
- An item's step_on comes before its inside contacts, as
  Entity.applyEffectsFromBlocks calls stepOn before checkInsideBlocks.
- A mob's step_on (and the magma floor, turtle egg checks with it) reads
  getOnPosLegacy through block_on_legacy, so a mob over the edge of its
  supporting block still steps on it (arismoko's review).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:28:31 +01:00
rdbtCVSandClaude Sonnet 5.5 d4941b1155 Mobs keep the whole stack in each equipment slot
Mob equipment was an item id plus enchantments and a trim, so a picked-up
sword or armour piece lost its damage, name and components, a hand took one
item of a stack, a worn piece always compared as fresh, and death or
replacement dropped it repaired and unnamed.

`Gear.stacks` (the main hand keeps `main_hand_stack`) now holds the stack of
a slot that is more than one plain item. `set_item_stack` stores it,
`stored_stack`/`item_stack` read it back, replacing the item forgets it.
`equipItemIfPossible` takes the whole stack into a hand (one into an armour
slot) and drops the replaced stack as it was; `canReplaceCurrentItem` sees
the worn piece's damage and name; death drops the stack itself
(`Drop::Stack`), with the rolled damage when the drop is not preserved; the
save writes and reads it.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 01:27:41 +00:00
Ludovic MarieandClaude Opus 5.5 064313363c Biome modifiers: skip what can't be read, with a warning
A modifier of a type not read yet (add_spawns, add_carvers, a mod's own
serializer) or naming a biome, feature or tag the root doesn't have (another
mod's ids, #c: convention tags) dropped every content mod. Real mods ship
those beside their feature modifiers, so that modifier is now skipped with a
warning naming the mod and file, as NeoForge skips an unresolvable holder
set (reservedbytes's review). A malformed modifier of a read type (unknown
step) still stops the build. The docs say #tag in features reads vanilla's
feature tags only for now (arismoko's review).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:26:32 +01:00
some-du6eandClaude Opus 5.5 9148cb676f Native mods: the default-components docs name every reader and thread
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:25:37 -06:00
some-du6eandClaude Opus 5.5 33efc55c5b Native loot hooks: keep a replaced table's random_sequence; test the loot hook stays hookable
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:24:36 -06:00
Ludovic MarieandClaude Opus 5.5 2c93d81d0c lan_e2e mount_other_level: horse and camel in the Nether by default
The whole matrix (7 mounts x 2 levels x 2 players + in_nether) takes about 10
minutes, and the nightly LAN suite already runs ~2,400 s of its 3,000 s
timeout (rdbtCVS's review). The default keeps the regression signal;
LAN_E2E_MOUNTS=all and LAN_E2E_LEVELS=all run everything.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:24:15 +01:00
Ludovic MarieandClaude Opus 5.5 934df75a12 Orbs draw their correction step; a knot is hit only within entity range
- Entity.commonTick sets the old position before the interpolation step, so
  the frame lerps from the pre-step position: previous keeps it now, where
  ExperienceOrb::tick had overwritten it and the orb still hopped (rdbtCVS).
- The knot is an entity, so a click counts as one on it only within the
  entity interaction range (filterHitResult), not the fence's block range
  (Joansitoh).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:23:26 +01:00
Ludovic MarieandClaude Opus 5.5 cb9e4996d3 OptiFine reader: paths stay in the root, linear globs, no link loops
rdbtCVS's review points 1-3:
- resolve returns None for a path with a .., . or empty segment, so a pack
  can never name a file outside the overlay root (vanilla refuses them).
- glob backtracks only to the last star: O(pattern x name), where every
  star tried every split before (a player-chosen item name, per frame).
- properties_files reads each real folder once and at most 16 deep, so a
  folder pack linking to its own parent no longer recurses to PATH_MAX.
Tests for each.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:22:01 +01:00
Ludovic MarieandClaude Opus 5.5 8d98852911 Screen mouse hooks: buttons past 4 are the platform's numbers
winit's MouseButton::Other(n) is the raw X11/evdev number on Linux, not GLFW's
index, so the hook docs and the fragment say a button past 4 is
platform-defined (rdbtCVS's review).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:20:32 +01:00
rdbtCVSandClaude Sonnet 5.5 ca88f2e2e5 The dedicated server's autosave and save-all save every player
The player-file save in `save_game_metadata` only ran for a dedicated-mode
integrated server (the fixture), so a real `--dedicated` server (the
`DedicatedServer` role) skipped it on autosave and `save-all`: players were
only written when they left or the server stopped. It now runs for every
server role, as PlayerList.saveAll does.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 01:19:35 +00:00
Ludovic MarieandClaude Opus 5.5 fef6f8080a Consume effects: an empty namespace is minecraft's
Identifier.bySeparator reads ":clear_all_effects" as minecraft:clear_all_effects
(Consumable.CODEC decodes both), so it is vanilla's clear, not a mod kind for
the native hook (arismoko's review). Test added.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:19:25 +01:00
reservedbytes db4208923e fix(saves): thrown snowballs, eggs and ender pearls are saved with their chunk 2026-11-01 00:18:57 -01:00
rdbtCVSandClaude Sonnet 5.5 1e3758f6c1 A removed vehicle empties its container and drops its stand-in
A chest boat or chest/hopper minecart open in a menu keeps its slots in a
stand-in under a placeholder key. Removing the vehicle (broken, killed,
exploded, a rider's death, a portal) dropped the vehicle's stale items and
left the stand-in with a full copy a stale menu could still take from.
`release_vehicle_container` writes the menu's live slots back to the vehicle
first (so they are what drops or travels), then removes the stand-in, before
each removal.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 01:18:10 +00:00
Ludovic MarieandClaude Opus 5.5 124cd2ac33 Dispenser placement: getRotation is -90 facing up or down
DirectionalPlaceContext.getRotation is get2DDataValue() * 90, so a vertical
facing gives -90, not toYRot's 270. Both name East for vanilla's blocks
(from_y_rot, rotation segments), but the placement hook shows the raw value
(arismoko's review on #5516). Armor stands and boats keep toYRot.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:17:18 +01:00
rdbtCVSandClaude Sonnet 5.5 f79e3b7e60 Check a menu's stillValid before every guest click
A menu was only validated once per tick, after the packet phase, so a click
could still land on a vehicle that was gone, a mount that died or left reach,
a lectern out of reach or a station block that was replaced. The click
packets (container click, button, slot state, rename, beacon, trade select,
bundle select, place recipe) now run one shared check first: the open menu,
whichever kind, must still be valid, else it closes like ContainerClose and
the click lands on nothing. The per-tick checks use the same predicates.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 01:16:19 +00:00
rdbtCVSandClaude Sonnet 5.5 a65385ff3e Close a leaving guest's open menu before the player is saved
Leaving the server (a disconnect, a LAN close, a dedicated stop) kept the
guest's cursor stack, trade payment, grid and station inputs in the menu, a
trade unwritten on the villager and a vehicle's stand-in in the level.
`guest_close_all` runs the same close code as ContainerClose for every kind
of menu, in the guest's own level, and runs from `HostEvent::Left` and
`save_leaving_guests` (LAN close, dedicated stop, integrated stop) before the
playerdata is written.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 01:15:36 +00:00
Ludovic MarieandClaude Opus 5.5 06584db009 Bone meal hook: a mod's no keeps the meal, as vanilla's growCrop does
native_is_valid_bonemeal_target answers Option<bool>: Some(false) refuses
the meal even where the block's template takes it (BoneMealItem.growCrop
returns false when isValidBonemealTarget is false), None leaves the
template's BonemealableBlock (arismoko's review). The client prediction
falls through to growWaterPlant on a refusal, as vanilla's useOn does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:15:16 +01:00
rdbtCVSandClaude Sonnet 5.5 ff6ef169c3 Every guest menu open closes the previous menu first
Each opener closed a different subset of the menus the guest could have
open (the trading screen left a mount or crafting state behind, a vehicle's
stand-in stayed, the old window id still took clicks). They now all start
with `guest_close_all`, as `ServerPlayer.openMenu` closes the open menu:
containers, vehicles, mounts, merchants, stations, lecterns, the crafting
table and native menus.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 01:13:35 +00:00
Aaron Wang ae2088d2ab Merge remote-tracking branch 'origin/main' into fix/armor-stand-lerp 2026-10-31 21:13:12 -04:00
Aaron Wang 642ed5b09b A stand riding the player is seated after the player's move
position_riders ran before the player moved and before previous_position
was saved, so a name hologram on the local player was drawn a full tick
behind it. Vanilla ticks the player (tickNonPassenger) before its
passengers (positionRider), so seat_own_stands now runs after the move,
from the previous_position just saved to the moved position.

The snap test now walks toward x 5 when the far sync comes and checks no
step back toward it is left, so it fails if a snap stops clearing the
handler. The ledger citations move off the first doc lines they were
read as ports from.

Written with Claude Opus 5.5 in OpenCode.
2026-10-31 21:13:11 -04:00
rdbtCVSandClaude Sonnet 5.5 322d45fda5 Close a leaving guest's open menu before the player is saved
Leaving the server (a disconnect, a LAN close, a dedicated stop) kept the
guest's cursor stack, trade payment, grid and station inputs in the menu, a
trade unwritten on the villager and a vehicle's stand-in in the level.
`guest_close_all` runs the same close code as ContainerClose for every kind
of menu, in the guest's own level, and runs from `HostEvent::Left` and
`save_leaving_guests` (LAN close, dedicated stop, integrated stop) before the
playerdata is written.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 01:12:39 +00:00
rdbtCVSandClaude Sonnet 5.5 7a19762534 Close a leaving guest's open menu before the player is saved
Leaving the server (a disconnect, a LAN close, a dedicated stop) kept the
guest's cursor stack, trade payment, grid and station inputs in the menu, a
trade unwritten on the villager and a vehicle's stand-in in the level.
`guest_close_all` runs the same close code as ContainerClose for every kind
of menu, in the guest's own level, and runs from `HostEvent::Left` and
`save_leaving_guests` (LAN close, dedicated stop, integrated stop) before the
playerdata is written.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 01:11:47 +00:00
Ludovic MarieandClaude Opus 5.5 4eb27f4216 Piston head: a neighbour change asks can_survive, so a mod's answer counts
PistonHeadBlock.neighborChanged calls state.canSurvive; the direct
piston_head_can_survive call skipped native_can_survive (arismoko's review).
can_survive keeps the piston rule as its default.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:11:18 +01:00
some-du6eandClaude Opus 5.5 39e9a8e0d1 horizon-prepare: a stamp without outputs never matches; say what HORIZON_PREPARE_JOBS limits
stamp_matches compared a missing outputs key with the digest of a missing output as None == None. The docs and comments said HORIZON_PREPARE_JOBS is how many things run at once; it is how many threads copy and hash, the lanes all run at once unless it is 1, and it counts logical processors. With the split there are five lanes. java.rs's memory figure now says what it compared.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:08:43 -06:00
JustKarkatandJustKarkat's AI agent 58a141917f Item quads: cull back faces like vanilla's item render types
A held two-sided model carries a quad per direction (a calibrated sculk
sensor's tendrils and amethyst planes, big dripleaf, block/cross), and the
item render types vanilla picks in ItemBlockRenderTypes -- solidBlockSheet
(entitySolid), cutoutBlockSheet (entityCutout), translucentItemSheet
(entityTranslucentCull) -- all keep the composite state's CULL. Horizon
culled only quads whose sprite has translucent pixels, so held blocks drew
both sides: the coplanar pairs striped (#4856, #4855) and a hay block's far
faces bled through the near ones (#4801).

Two-sidedness in vanilla is geometry, not culling: ItemModelGenerator's
bakeExtrudedSprite bakes a South and a North quad per layer, which
horizon-assets already mirrors.

Co-Authored-By: JustKarkat's AI agent (ZCode) <[email protected]>
2026-10-31 20:08:25 -05:00
MariuslozoandClaude Opus 5.5 7ca3d98901 Single player: the owner's leave saves the file it loads; .dat_old fallback
The owner loads the file level.dat names, but when it left (save_guest,
PlayerList.remove) only its own file was written; the named one waited for
the world's save that follows (stop_integrated: free_player_beds, the
chunks, then save_game_metadata). A game stopped or failing in between
left the named file older than the own one, and the owner then loaded the
older state. `playerdata::save_owner` writes the named file
(`Level::write_player`, also the player half of `Level::write`) before the
own one, so the file the owner loads is never the older.

`playerdata::load` (PlayerDataStorage.load) now falls back to the player's
`.dat_old`, as vanilla does: `write_dat` moves the old file aside before it
renames the new one in, so a save cut off there leaves only the
`.dat_old`, which the named file's read already used.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:07:22 +01:00
MariuslozoandClaude Opus 5.5 3e75d6484b Port map: load_owner's row keeps the load and datafix gap; no stale citation
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:07:22 +01:00
MariuslozoandClaude Opus 5.5 be7de6230c Single player: the owner loads the player file level.dat names (#5351)
PlayerList.loadPlayerData gives the single-player owner the file level.dat
names (`singleplayer_uuid`), not their own. Since #5069 the integrated
server saves the owner to both files, `players/data/<singleplayer_uuid>.dat`
(offline `Player`) and `<owner uuid>.dat`, but loaded the owner's own file
first. A build that saves the owner to the named file alone (0.2.93 and the
stable releases before #5069) leaves the own file behind, so after
switching back the owner got an older inventory and position, and the next
save wrote them over the newer ones.

`Level::read` now says whether its player came from the named file, and
`playerdata::load_owner` applies it before the owner's own file; the own
file stays the fallback when the named file is missing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:07:22 +01:00
Ludovic MarieandClaude Opus 5.5 28bd35e286 Merge main into #5414
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:04:42 +01:00
Ludovic MarieandClaude Opus 5.5 f8fa3c9996 Merge main into #5441
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:04:41 +01:00
Ludovic MarieandClaude Opus 5.5 e33808a648 Merge main into #5450
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:04:16 +01:00
Ludovic MarieandClaude Opus 5.5 9c77c753a5 Merge main into #5442
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:04:15 +01:00
Ludovic MarieandClaude Opus 5.5 8b9d3235e1 Merge main into #5433
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:04:12 +01:00
Ludovic MarieandClaude Opus 5.5 7fcd32faa1 Merge main into #5418
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:04:11 +01:00
some-du6eandClaude Opus 5.5 5b16c46f5d horizon-prepare: the release check's repeat run compares the manifest too; four JVMs with the split
The repeat run's file check skipped horizon-resources.json, so a manifest the repeat rewrote differently passed. The comments that said at most three JVMs run at once now say four with the second server JVM.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:58:51 -06:00
TeiwazikandClaude Opus 5.5 5cc2d09d5c Shader packs: a Shader Options page for a Horizon pack's [[options]]
A Horizon pack declares options, but they stayed at their defaults: the player had no way to set them.
The picked pack now gets the same Shader Options... button as a shaders/ pack, and its values live in
shaderpacks/<pack>.txt, the file that kind already uses. A saved value counts as a change to the pack,
so it reloads with it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 01:58:23 +01:00
Aaron Wang a484e2b491 The player predicate sees a burning player on fire
player_entity left on_fire false, so a criterion's player predicate with
flags.is_on_fire true never matched, a mod's trigger (the reviewer's
case) or a vanilla one. It is Entity.isOnFire on a server now:
remainingFireTicks above 0 (players aren't fire immune).

Written with Claude Opus 5.5 in OpenCode.
2026-10-31 20:56:58 -04:00
TeiwazikandClaude Opus 5.5 bbbb18595f Shader API: [[materials]], an id by sprite, and shadows cut whole
A pack treats hundreds of kinds of surface differently, where [[tags]] hold 32 sets. Each sprite takes
the id of the first [[materials]] entry that names it, as MaterialInput.material, through the same
generated switch as the tags. [shadow] solid_materials draws those ids into the map without their
cutouts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 01:43:03 +01:00
[email protected]andGPT-6 4497abec98 feat(mods): support image tooltip components
Co-Authored-By: GPT-6 <[email protected]>
2026-11-01 01:27:20 +01:00
TeiwazikandClaude Opus 5.5 11cd3f220d Shader API: the first-person player casts into the shadow map
The local player's model isn't drawn in first person, so a native pack's shadow pass gets it on its
own through the frame's shadow_only quads. The round blob under entities is left out while a pack
draws a shadow map, since the sun's shadow is already there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 01:16:22 +01:00
Sylviromi dd72fb8277 Merge branch 'screen-mouse-scrolled' into screen-key-input 2026-11-01 03:04:24 +03:00
Sylviromi c90161511b Merge branch 'screen-mouse-button' into screen-mouse-scrolled 2026-11-01 03:04:24 +03:00
Sylviromi 068b434a3d Merge branch 'screen-render-post' into screen-mouse-button 2026-11-01 03:04:24 +03:00
SylviromiandClaude Code ccb1474a03 Native mods: a server's trading window gives the screen view its title
remote_open_merchant left the menu title unset, so NativeScreenView.title was
None on that window although the docs promise the server's title.

Co-Authored-By: Claude Code <[email protected]>
2026-11-01 03:04:23 +03:00
7a6468bd8f Native mods: the screen hears of a key and of each typed character first
Adds screen_key_pressed_pre and screen_char_typed_pre (ScreenEvent.KeyPressed.Pre
and CharacterTyped.Pre) on the open screen, fired from the window's key event,
the control socket's key and type commands, and the input method's commit.

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
Co-Authored-By: Claude Code <[email protected]>
2026-11-01 03:02:51 +03:00
ErikKalikasandClaude Opus 5.5 600e7bec77 Native mods: test that infinite bounds skip a frustum that culls everything
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:02:38 +02:00
TeiwazikandClaude Opus 5.5 43207a5a3d Shader API: entities cast into the shadow map
The renderer's opaque entity quads now go into shadow_map through the shadow camera, with their
texture's cutout. They draw after the translucent map is copied, since where one casts the solid
map is the darker and decides. Unlit overlays, as entity.wgsl lists them, cast nothing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 00:57:54 +01:00
ErikKalikasandClaude Opus 5.5 4ad692a07b Native mods: leave native_draw_item and native_block_state_property for a follow-up
Keeps this pull request near the size limit; the block state stays in the
hooks, whose signatures can't change once merged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 01:56:04 +02:00
SylviromiandDeepSeek b1d6819737 Native mods: the screen hears of the wheel before it scrolls
`native_screen_mouse_scrolled_pre` (`ScreenEvent.MouseScrolled.Pre`) hands a
mod the open screen, the wheel's movement in x and y as the game's scroll
handler already scales it (vanilla's doubles, the sign only when discrete
scrolling is on, a touchpad's pixels over 30) and GLFW's modifier bits, plus
`canceled`.

It fires in the `MouseWheel` arm once `scaled` is defined, before the dialog,
title, hotbar, bundle and every screen scroll, so a cancelled turn scrolls
nothing and leaves nothing half-applied; the hotbar reads the wheel only
while playing, which the hook's gate excludes. The pad's and the keyboard
focus's made-up wheel is not the mouse and fires nothing.

The hook doc names the screens that never fire, as the mouse button hooks
do, and the fragment is pending, so this is "API next".

Co-Authored-By: DeepSeek <[email protected]>
2026-11-01 02:51:37 +03:00
Teiwazik 3ab39ea984 Merge remote-tracking branch 'origin/main' into shader/s8-shadow-entities 2026-11-01 00:50:18 +01:00
ErikKalikasandClaude Opus 5.5 97c8e9ed15 Native mods: the block state in the block entity hooks, items and state properties for drawing
Both hooks get the block's state, as a renderer reads
blockEntity.getBlockState(); native_block_state_property reads its facing
without a world lookup, and native_draw_item draws an item stack under the
pose stack as a campfire does (resolve_item, submit_item_world).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 01:46:43 +02:00
TeiwazikandClaude Opus 5.5 6a47c9eab5 Docs: shadow_color carries the vertex colour's shading
The colour is the mesher's vertex colour, so it includes the face's directional shade and ambient occlusion as well as the tint. The docs now say so.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 00:38:31 +01:00
SylviromiandClaude Opus 5.5 4685ab06e2 Native mods: fragment names the renamed gate
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:35:45 +03:00
SylviromiandClaude Opus 5.5 7c42d32723 Native mods: the screen-hook gate is a helper, not a hook point
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:35:34 +03:00
some-du6eandClaude Opus 5.5 42cfa789b9 Native mods: document the default-components hook, share the catalog scan
docs/native-mods.md gets an "Items' default components" section beside the
render hook points: the target path, both sides, threads, vanilla clients,
creative search. The catalog test reads free-function hooks with one helper.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:34:11 -06:00
some-du6eandClaude Opus 5.5 9cecb86ec2 horizon-prepare: review fixes for the parallel preparation
- A failed exporter compile fails every lane at once instead of being
  retried by each lane that waits for it.
- The gameplay facts exporter streams to a .partial file and moves it into
  place: a run stopped halfway no longer leaves a short file the next run
  would keep.
- pool::map takes no per-thread state (a jar clone costs two Arc counts);
  pool::lanes and map are tested at 1 job and several. With 1 job the first
  failing lane stops the rest, as documented now.
- The lanes' log and stage messages go through relay(), which a test now
  checks; block behaviour reuses export_on; the stamp inputs are worked out
  once; the fifth lane exists only when it splits; dead checks are gone.
- check_prepare_exporters.py also prepares with 2 jobs (the default path
  below 6 cores, 5 JVMs) and repeats that run, which must start no JVM and
  change no file.
- The docs say what HORIZON_PREPARE_JOBS does, that the split is not yet
  timed on 6 cores, and the split's measured ~400 MB.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:33:51 -06:00
ErikKalikasandClaude Opus 5.5 ee34e1982a Native mods: cull mod block entities by the bounds their mod gives (getRenderBoundingBox)
native_block_entity_render_bounding_box lets a mod widen the box its block
entity is culled with (NeoForge's getRenderBoundingBox), or make it
infinite, so a tall part stays drawn while its block is out of view. The
view distance check is strict, as Vec3.closerThan.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 01:33:44 +02:00
SylviromiandClaude Opus 5.5 5f87b9e60d Native mods: mouse hook docs, which screens fire
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:33:16 +03:00
MariuslozoandClaude Opus 5.5 bc13a0403f lan_e2e mount_other_level: the player in the Nether, and no clicks on the last mount
The other way round from a force-loaded chunk: the player and a horse in the
Nether while the other player keeps the Overworld ticking; the Overworld's pass
must leave the menu alone (in_nether, --server-thread). The player lands in
creative and is put back on the platform once it stands, so a cave below the
spot doesn't drop it out of reach.

A killed mount lies at the spot for 20 ticks (LivingEntity.tickDeath), and the
next one, summoned there 10 ticks later, took the clicks meant for it on a
slow run: wait until the client no longer sees it. LAN_E2E_LEVELS takes a
comma list (the_nether, the_end, in_nether), as LAN_E2E_MOUNTS does; the
force-load is removed even when the wait after adding it fails.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 00:27:22 +01:00
SylviromiandDeepSeek e39e0088cb Native mods: mouse button press and release on the open screen
`native_screen_mouse_button_pressed_pre` and
`native_screen_mouse_button_released_pre` (`ScreenEvent.MouseButtonPressed.Pre`
and `.MouseButtonReleased.Pre`) hand a mod the open screen, the button's GLFW
index (0 left, 1 right, 2 middle, 3 back, 4 forward, `Other(n)` = n) and
GLFW's modifier bits (shift 1, control 2, alt 4, super 8), plus `canceled`
for a press the screen never sees.

They fire first in `handle_window_event`'s closure, before the screen or the
container hotkey, sort and header buttons act, so a cancelled press leaves
nothing half-applied and a cancelled release keeps a drag held. The pad's and
the keyboard focus's made-up clicks are not the mouse and fire nothing.

The gate and the view two more input hook families need are here: the pure
`glfw_modifiers` and `glfw_button`, `native_screen_open` (a screen of the
game's over `Screen::Playing`, no connect or load) and `screen_view_now`,
which builds only the `HudBatch` its rect needs. The hook docs and the
fragment name the screens that never fire: the title screen, the connect and
loading screens, the prompts, books and sign editors drawn over gameplay, and
any screen while a world loads or saves.

The fragment is pending, so this is "API next".

Co-Authored-By: DeepSeek <[email protected]>
2026-11-01 02:25:08 +03:00
some-du6eandClaude Opus 5.5 d253b46872 OptiFine packs: a shared reader for their .properties, matchers and paths
New crate horizon-optifine, the layer every OptiFine-format feature (CTM,
CIT, random and emissive entities, CEM, sky, colours) reads its files
through: Java .properties with escapes and continuations, name matchers
(pattern:, ipattern:, regex:, iregex:), range lists with parenthesised
negatives, id lists with ! exclusions, block lists with states, OptiFine
path resolution (~/, ./, namespace:) and the optifine/ and mcpatcher/
folders of every namespace. Written from OptiFine's public format docs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:24:52 -06:00
some-du6eandClaude Opus 5.5 619b5fe709 Native mods: change the default components of any item (API next)
A mod could not change a default component of an item it didn't add (a
stack size, a food, a tool rule), as NeoForge's ModifyDefaultComponentsEvent
lets it. native_modify_default_components hands a mod each item's defaults
in items.json's form as they are read, to set or remove components.

Every reader of items.json (the item table, loot, enchanting, item models,
banners, pot sherds, rarity in chat) now goes through
horizon_world::item_defaults::read, so a change reaches all of them, and
stacks are saved and sent against the changed defaults like vanilla's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:21:27 -06:00
TeiwazikandClaude Opus 5.5 89be6259da Docs: the dither option's row has three cells
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 00:16:39 +01:00
Teiwazik b10619ee4f Merge branch 'shader/s6-shadow-solid' into shader/s7-shadow-translucent
# Conflicts:
#	crates/horizon-client-render/src/gfx/native_shadow.rs
2026-11-01 00:16:37 +01:00
TeiwazikandClaude Opus 5.5 d5316caaaf Shader API: review fixes for [shadow]
The depth range now reaches past the build height below the camera and shadow_position clamps z,
so a point past the far end stays lit, the blank map included. The light's box culls the native
lists instead of a section radius that lost casters at low sun. The native camera's snap is
euclidean, the resolution is clamped once, and the distortion and the camera each live in one
place. The generated reference lists the shadow helpers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 00:14:36 +01:00
huiandClaude Opus 5.5 36b0d2670f Native mods: enchant_effect honours requirements and names the mob hit
The first cut told an entry's kind before its requirements were checked, so a
failed requirement still fired, and gave the hook no way to know which mob was
hit. The kind is now told after the check, with the mob's id. A bare type name
is minecraft's own, so ignite runs as minecraft:ignite instead of reaching the
hook. The docs now say what's told: attacker on victim, a player's weapon on a
mob.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 00:13:18 +01:00
some-du6eandClaude Opus 5.5 5bc2501d7f Native loot hooks: clippy's auto-deref
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:10:58 -06:00
some-du6eandClaude Opus 5.5 c3e5e7866c horizon-prepare: block behaviour on a second server JVM with 6+ cores
A fresh preparation on a 6-core laptop finished its other JVM lanes by ~17 s,
then ran the server lane alone for ~20 s more: its last export, the world
generator's block behaviour (11.5 s there), sat behind everything else on
that JVM. From 6 cores (pool::jobs, so HORIZON_PREPARE_JOBS too) it and the
structure blocks run on a fifth lane, in their own server JVM. It pays a
second Bootstrap and ~400 MB of peak memory, so it stays off below 6 cores:
forced on this 3-core box it took 44.9 s against 40.5 s.

Same output (digest unchanged either way). check_prepare_exporters.py now
runs its batched preparation that way and expects 6 JVM launches; it passes
(23,204 files).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:07:12 -06:00
some-du6eandClaude Opus 5.5 75047c93d9 horizon-prepare: cut its memory peak from 1.47 GB to 1.06 GB
Measured on the pinned 26.3 jars, sampling every process's RSS: the peak came
at the end, from the server worker alone (1.25 GB). Its live heap stayed under
~250 MB; the rest was garbage the serial collector promoted and kept, mostly
from two exporters:

- The collision exporter wrote, and the worker cached, 62 MB of verification
  inputs (printf'd, ~4 s) that only the parity harness reads. Inside the
  prepare worker it now skips them; standalone runs still write them.
- The gameplay facts exporter built its 56 MB file as a Gson tree, then a
  String, then bytes. It now streams each state to the file; the bytes are
  the same (re-serializing the parsed file with Gson.toJson gives them back).

The worker now peaks at ~480 MB. In the Rust process, every thread reading a
jar parsed its member list again (~10 MB each, eight at once); they now
share one parse through clones of the archive that read at their own offsets.
Its heap peak halved (96 to 51 MB).

check_prepare_exporters.py passes (23,054 files, 5 JVM launches); the output
digest is unchanged. "Movement and attributes" takes 12-13 s instead of 16-18 s.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:07:10 -06:00
Teiwazik b13a22fdcb Merge remote-tracking branch 'origin/main' into shader/s6-shadow-solid 2026-11-01 00:06:32 +01:00
huiandZCode AI agent (GLM-5.3) f074c2d4bd Native mods: an enchantment's effect of a mod's own kind
An enchantment's effects entry that names an effect.type in a namespace
outside minecraft: used to be silently skipped at the firing point. It
now reaches native_enchant_effect(enchantment, kind, data, level) — the
post_attack components first, where the weapon's holder is the acting
player — and the game's own damage, sounds, effects and drops are open
to the hook. An unclaimed kind does nothing.

Co-Authored-By: ZCode AI agent (GLM-5.3)
2026-11-01 00:06:13 +01:00
some-du6eandClaude Opus 5.5 a9d6eaf3b3 horizon-prepare: serial collector for its JVMs; time steps after compiling
The serial collector is as fast as the parallel one for these JVMs (fresh run
63-66 s either way, pinned 26.3 jars) and keeps the three that run at once to
1.25 GB together instead of 2 GB. An export's time no longer includes waiting
for javac. A compile error still names the export it stopped.

tools/check_prepare_exporters.py passes: 23054 resource files match between two
isolated runs and a batched one, with 5 JVM launches and no compiler.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:05:57 -06:00
some-du6eandClaude Opus 5.5 a935fe0bf2 horizon-prepare: run its JVMs as batch jobs (C1 only, ParallelGC)
The exporters, the data generator and javac are short batch JVMs, three of them
running at once. The C2 compiler's background work and G1's concurrent threads
cost them more CPU than those win back. With -XX:TieredStopAtLevel=1
-XX:+UseParallelGC (javac gets them through -J), a fresh preparation from the
pinned 26.3 jars took 67 s instead of 102-105 s on the same machine. The output
digest (tools/prepare_output.py) is unchanged. java.rs is not part of the
exporter bundle key, so cached bundles stay valid.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:05:57 -06:00
some-du6eandClaude Opus 5.5 78d211e0a4 horizon-prepare: run independent steps at once, skip unchanged exporters
Fresh preparation ran everything one after another. The steps now run on four
lanes: the server exporter JVM (same order as before, since its exporters share
state), the client exporter JVM then the command registries', the data generator
then the creative inventory's, and the Rust-only copying. At most three JVMs run
at once, still five JVMs in total. A worker stops when its lane is done. The two
exporter sides compile at once. Asset extraction, structure templates, worldgen
bundles, server library unpacking, client library imports, inventories and pin
checks run on several threads. Every pinned file is still hashed in full.

Repeat runs always started the server JVM for the block behaviour and
structure-block exporters, which had no skip check. They now keep a stamp.json
in .local (exporter bundle key, server jar, preparer sources, output sha256), so
an unchanged repeat run starts no JVM. The prepared files are unchanged.

Every step logs how long it took. HORIZON_PREPARE_JOBS=1 runs it all serially.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:05:56 -06:00
MariuslozoandClaude Sonnet 5.5 276b77a0f0 lan_e2e: mount_other_level covers every mount, the End, getting off and reopening (#5331)
The check now opens a tamed horse, camel, llama, trader llama, nautilus
(owned), chested donkey and mule by shift + right click and by the
inventory key while riding, and again by shift + right click after getting
off, on the host and on the guest, with a chunk force-loaded in the Nether
and in the End. Every way is reported on its own and must still be open 20
ticks later.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RodDbPWCByh96WKcbiEQpB
2026-11-01 00:05:40 +01:00
some-du6eandClaude Opus 5.5 8226fb0b51 Native loot hooks: a mob's drops name its position as the origin
LivingEntity.dropFromLootTable's ORIGIN is the mob's position, not the point
its drops spawn from.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:04:52 -06:00
SylviromiandClaude Opus 5.5 dc6b189768 Native mods: the screen view's rect is the drawn window; menu None when unknown
The rect comes from the same container image the game draws, so the
merchant and the beacon report their wider, shifted window. An unknown menu
type is None rather than an empty id, and slot_count is checked against
slots() in debug builds.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:02:41 +03:00
v3nn7andCodex 3cdf2466ae Test steering items in both hands
Co-Authored-By: Codex <[email protected]>
2026-10-31 23:59:48 +01:00
v3nn7andCodex 88c6720079 Allow steering mounts with offhand item
Co-Authored-By: Codex <[email protected]>
2026-10-31 23:59:42 +01:00
some-du6eandClaude Opus 5.5 0c987c9f63 Native loot hooks: round 3 fixes
A server names no player of its own as a mob's killer, and the drops
context's fields aren't flagged as never read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:56:21 -06:00
SylviromiandDeepSeek dfcc1eb4dc Native mods: the screen hook's payload, and a view that does not allocate
`native_screen_render_post` (`ScreenEvent.Render.Post`) hands the mod the
mouse in GUI pixels and the frame's partial tick besides the screen view,
as the event's `mouseX`, `mouseY` and `partialTick` do, so a mod can draw a
hover highlight.

The view also carries `menu`, the vanilla menu type of the menu the server
opened (`minecraft:generic_9x3`, `minecraft:furnace`, a mod's own id), and
None for a window the client opened. Every container, station and mod
window draws on the inventory screen, so `id`'s doc says they all report
`inventory` and are told apart by `menu`, `title` and `slots`.

The view is built without allocating: slots are counted the way `Game::slots`
lists them (`slot_count`, with `station_slot_count`, `mount_slot_count` and
`native_screen_slot_count` beside their list versions) instead of being
collected into a `Vec` every frame, and the id comes from the screen
`hud_layers` drew rather than from a second argument beside `self.screen`.

The hook and the fragment say which screens never fire it: the title screen,
which returns before its point, and the prompts, books and sign editors
drawn over gameplay after it, which keep `Screen::Playing`, are skipped, and
draw over what the hook drew. Vanilla fires Render.Post for them; that gap
is now written down.

With no mod installed the body stays `let _ = (...)`: the frame is vanilla's.

Co-Authored-By: DeepSeek <[email protected]>
2026-11-01 01:55:58 +03:00
rdbtCVSandClaude Opus 5.5 6ebfcd0ded Spawner minecart: /summon loads its spawner; review fixups
- /summon minecraft:spawner_minecart with no NBT runs BaseSpawner.load on an
  empty compound, as SummonCommand.createEntity does, so SpawnPotentials is
  WeightedList.of(new SpawnData()) rather than empty.
- The save test passes no raw tag and changes the delay after loading, so it
  checks the cart's own save arm.
- The cart model test covers spawner_minecart#main.
- Port ledger: MinecartSpawner's tick, save, load and display state are ported.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:55:46 +00:00
rdbtCVSandClaude Opus 5.5 0ddaee3da8 Native hooks: a villager bonemeals a mod block only when it is a crop
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:54:24 +00:00
rdbtCVSandClaude Opus 5.5 75c6194615 lan_e2e: realign the riding entry of the check list
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:51:18 +00:00
huiandZCode AI agent (GLM-5.3) 1742791286 Native mods: a consumable's effect of a mod's own kind
The loader dropped an on_consume_effects entry whose type the game
doesn't know silently. It now keeps the kind and the JSON the file
wrote, and the game hands them to native_consume_effect when the item
is consumed, on the server thread, for the acting player. The game's
own effects, sounds, teleports and drops are open to the hook; an
unclaimed kind does nothing, as an unknown kind on any loader.

A bare type name without a namespace is minecraft's own — apply_effects
is minecraft:apply_effects — so only a namespaced kind outside
minecraft's becomes Native; an unknown minecraft: kind is dropped. The
hook sees the item's own consumable component, not a patch on the stack.

Co-Authored-By: ZCode AI agent (GLM-5.3)
2026-10-31 23:50:37 +01:00
some-du6eandClaude Opus 5.5 7f9f86a7ab Native loot hooks: round 2 fixes
The wall-table test's registry has every block's default, a kill by a guest
who left names no player, and the load hook's docs say two loads can overlap.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:50:04 -06:00
huiandZCode AI agent (GLM-5.3) 1841925457 Ingredient shapes: compound is the union, intersection the all-of; ingredient_type key; fluid dropped
NeoForge's CompoundIngredient matches if any child does — the union of
their items, not the intersection (that is IntersectionIngredient).
The ingredient_type key is neoforge:ingredient_type, type only as the
older packs' spelling; children is the field name, ingredients as the
older spelling. neoforge:fluid is not among NeoForge's item ingredient
types (its fluid ingredients are a separate registry for fluid stacks,
not items), so it is dropped until a use case arrives with a source.

Review: rdbtCVS's agent in the pull request.

Co-Authored-By: ZCode AI agent (GLM-5.3)
2026-10-31 23:49:30 +01:00
Claude 4a361d034b native_summon: a root a mod kept out has no id
spawn_loaded_entities hands out the id before native_entity_joins runs,
and a cancelled orb doesn't give it back, so the fallback returned an id
no entity holds. Take it only when an orb holds that id (root_id), and
test both cases.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XnU6QT61YPiPzVNysmVHsH
2026-10-31 22:48:42 +00:00
some-du6eandClaude Opus 5.5 603fda280e Native loot hooks: review fixes
Empty stacks a mod leaves don't drop, the load hook runs outside the
cache's lock, a mob's context names the table that rolled (one helper with
drops::die), the wall-variant table lookup has a test, and the docs say
which breaks the drops hook hears.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:44:37 -06:00
some-du6eandClaude Opus 5.5 f749bbeef1 Native mods: change vanilla loot tables and drops
native_loot_table_load (horizon_world::loot) hears each loot table's JSON as
it loads, after the data packs' (LootTableLoadEvent, LootTableEvents.MODIFY),
and native_loot_drops hears a broken block's or a dead mob's items with what
dropped them (global loot modifiers, MODIFY_DROPS).

Closes #5522

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:44:37 -06:00
ElyTheCatandCodex 8f785872f8 Remove redundant render upload diff
Keep the PR focused on worker-side side sorting.

Co-Authored-By: Codex <[email protected]>
2026-11-01 02:14:05 +03:30
SylviromiandDeepSeek 3e5b8b3ecc Native mods: draw over the open screen
Adds `screen_render_post`, the point of NeoForge's `ScreenEvent.Render.Post`: once a frame, after the
open screen has drawn itself and its tooltips, a native mod may draw over it. It is handed a
`NativeScreenView`: the screen's id (one per `Screen` variant, from `Game::screen_id`), the open
menu's title (borrowed from the menu, not copied: no per-frame allocation), the container panel in GUI
pixels or the whole window, and the menu's slot count.

The firing site is the end of `hud_layers`'s screen match, where every screen but Win and Options is
drawn. Those two keep their early returns: folding them into the match would draw the HUD and the
prompt path behind them, so the hook fires on their own path, after their drawing, instead. It does
not fire while playing with no screen, and it is a `&self` hook on the window only, so a mod can
read and draw but not change the screen.

With no mod installed the body is `let _ = (...)`: the output stays vanilla's.

The hook is API next (`tools/native-hook-fragments/screen_render_post.json`); `native-hooks.json` and
`NATIVE_HOOKS_API` are untouched.

Co-Authored-By: DeepSeek <[email protected]>
2026-11-01 01:38:39 +03:00
rdbtCVSandClaude Opus 5.5 de0c665692 Scaled screenshots: halve the size when video memory runs out, not crash
An 8x screenshot at 200% Resolution Scale with the video memory already full
crashed: the depth buffer's allocation failed, and its view raised a
validation error that only an out-of-memory scope was watching. The target
and depth buffer, and the whole scaled frame, now run inside out-of-memory
and validation scopes; a failure writes no PNG and tries again next frame at
half the size, down to the window's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:37:16 +00:00
ErikKalikas 30350feba8 Merge remote-tracking branch 'upstream/main' into mod-block-entity-draw
# Conflicts:
#	crates/horizon-client-render/src/hud.rs
#	crates/horizon-client/src/game.rs
2026-11-01 00:36:06 +02:00
ElyTheCatandCodex 31456cb738 Move leaf draw ordering into mesh workers
Sort solid cutout quads while the worker-side side buckets are built, so rendering uploads the canonical worker result without a render-thread permutation or duplicate allocation.

Co-Authored-By: Codex <[email protected]>
2026-11-01 01:56:35 +03:30
Kacpherek 0bf4904595 Stop riding a remote vehicle that is gone
A rider kept its vehicle id after the vehicle left the level. A respawn into
another level keeps the id when KEEP_ENTITY_DATA is set, and a removed
nautilus or minecart leaves it behind. remote_ride then kept the player in
the riding pose with no vehicle under it: it did not move, sneaking did
nothing, and the seat stayed until a vehicle packet named it again.

Vanilla Entity.baseTick stops riding a vehicle that is removed
(isPassenger() && getVehicle().isRemoved() -> stopRiding(), Entity.java).
Do the same in remote_ride when no minecart, boat, cushion or mob has the
id, as tick_riding already does on the local path (boats.rs).
2026-10-31 23:25:37 +01:00
Federico TaglianiandClaude Sonnet 5.5 b5d2db2e60 Tadpole: AbstractFish goals and swim, TadpoleAi brain
A tadpole is an AbstractFish with a brain: it gets panic 1.25, avoid-player and
the fish random swim goals, SmoothSwimmingMoveControl(85, 10, 0.02, 0.1, true),
SmoothSwimmingLookControl(10), water-bound navigation, and the TadpoleAi brain
(panic, look at player, follow temptation, swim/walk/stay-in-water gate).
Its travelInWater is AbstractFish's (0.01 speed, 0.9 drag, -0.005 gravity).

Adds mob_ai parity scenarios tadpole_idle, tadpole_tempted, tadpole_panic.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 23:24:16 +01:00
[email protected]andGPT-6 69c0f83d85 docs: clarify landing hooks preserve block-specific sounds
Co-Authored-By: GPT-6 <[email protected]>
2026-10-31 23:20:13 +01:00
[email protected]andGPT-6 e1c1669993 feat(mods): add server-side block contact hooks
Adds step-on, inside and fall-on callbacks with entity and block context; landing damage is adjustable before sounds and hurt.

Co-Authored-By: GPT-6 <[email protected]>
2026-10-31 23:19:26 +01:00
sirrayiandDeepSeek Harness 86165f5754 Native mods: the placement fragment's summary is one JSON string
The hardening edit put a raw newline inside the fragment's `doc_summary`, which
JSON reads as a control character: `catalog_matches_hook_points` failed to
parse it. The line break is the `\n` escape the other fragments use.

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-10-31 22:15:22 +00:00
MariuslozoandClaude Opus 5.5 8b5a93cbd0 Keep a horse inventory open while another level ticks (#5331)
tick_lan_mount_menus checked every open mount menu on every level pass,
not only in the player's own level. When a second level ticks (a player
or a force-loaded chunk in the Nether, a portal ticket), its pass found
no such mob and closed the menu the tick it opened. Like the station,
crafting and merchant ticks, it now skips players not in the level in
place (ServerPlayer.tick checks stillValid in the player's level).

lan_e2e mount_other_level force-loads a Nether chunk and checks that the
host's and the guest's horse inventories stay open, opened with shift +
right click and with the inventory key while riding.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 23:10:52 +01:00
sirrayiandDeepSeek Harness 2aec9b416e Native mods: the placement hook's test covers a dispenser too
The issue asks the hook to run for every placer that goes through vanilla's
path, not only the player. `the_level_chooses_the_state_a_block_is_placed_in`
now drives a dispenser's `DirectionalPlaceContext` as `dispense_shulker_box`
builds it — `directional_can_place`, then `place_block` — and asserts the
furnace lands in the hook's state, with the hook handed the dispenser's own
click (its yaw, the front block it replaces).

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-10-31 22:09:58 +00:00
sirrayiandDeepSeek Harness 6c494834f5 Native mods: a placement answer must be the placed block's own state
Review point on #5516: an answer naming another block would place a block the
item isn't bound to, and skip that block's `setPlacedBy` and sound. Vanilla's
`getStateForPlacement` sets properties on the block's own `defaultBlockState`,
so the hook's answer is taken only for a state of the block being placed;
another block's, like an id past the state table, leaves vanilla's state.

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-10-31 22:07:45 +00:00
sirrayiandDeepSeek Harness 0be8029be8 Native mods: bone meal on a mod block
A mod block took no bone meal: `BoneMealItem.growCrop` looked the block's
`BonemealableBlock` up in the port's classes and stopped. The level now
answers vanilla's three questions for it, ahead of the `BonemealableBlock` a
content template would give it, so a mod block can refuse, roll and grow:

- `is_valid_bonemeal_target` — `isValidBonemealTarget`. Asked on both sides,
  as vanilla asks it, so a client predicts the use with the mod's answer.
- `is_bonemeal_success` — `isBonemealSuccess`, drawing from
  `native_level_random` so a mod's draws stay in vanilla's order.
- `perform_bonemeal` — `performBonemeal`, with the level's ticks.

The hooks are `native_is_valid_bonemeal_target`, `native_is_bonemeal_success`
and `native_perform_bonemeal` (a fragment, no API number taken), plus
`native_level_random` for a world hook, the stream `native_level_random
(dimension)` hands a server hook.

One insertion point serves every placer: `Sim::grow_crop` is where the
player's `BoneMealItem.useOn`, a dispenser's `BoneMealItem.growCrop` and a
villager's bone meal meet, and the meal is used up and level event 1505 fired
by the callers as before, so a mod block gets vanilla's particles and sound.
The client's copy of the level now forwards the one read-only question
through `DryLevel`, and a mod block's growth particles stay inside it, as
vanilla's `GROWER` type does, where the port knows no type for it.

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-10-31 22:05:15 +00:00
ElyTheCatandCodex 095b522455 Optimize alpha-tested leaf rendering order
Reorder solid cutout quads front to back during GPU upload while preserving the canonical section mesh and shared index batching. Include the before and after leaf-scene captures used for validation.

Co-Authored-By: Codex <[email protected]>
2026-11-01 01:33:08 +03:30
rdbtCVSandClaude Opus 5.5 02caa53a27 Scaled F2 screenshots: game resolution times a Screenshot Quality option
F2 now saves the frame at the game's resolution (Resolution Scale above
100%) times a new Video Settings option, Screenshot Quality (1x, 2x, 4x,
8x; 1x by default). The frame is drawn once into its own offscreen
target, with the HUD zoomed to the same layout, and capped to the GPU's
texture and buffer limits; if the large targets can't be allocated the
window's frame is saved as before.

Fixes #3030

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:01:44 +00:00
rdbtCVSandClaude Opus 5.5 2f7dd09e90 Windows import check: uiautomationcore.dll ships with Windows (GPUI Fast's accesskit_windows imports it)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:58:38 +00:00
rdbtCVSandClaude Opus 5.5 4e61981299 Merge #3801's branch (with main) into #3825
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:51:17 +00:00
Kacpherek 67c4b573ad Smooth an experience orb's server corrections over 3 ticks
ExperienceOrb.createInterpolationHandler returns a LinearInterpolationHandler, so a client spreads each server position correction over 3 ticks (doInterpolate in Entity.commonTick) and the orb's own move carries the target along (applyPredictedMovement). The remote path put the orb at the new position at once, so it jumped on every correction; the comment claimed vanilla's NO_OP handler.
2026-10-31 22:45:25 +01:00
Kacpherek 6d2682cf87 Take leads back or snip them only when the click hits the fence knot
LeashFenceKnotEntity.interact runs only when the ray meets the knot (its box from recalculateBoundingBox, 0.375 wide, y + 0.375 to y + 0.875) before the post. A click elsewhere on the fence is FenceBlock.useWithoutItem or LeadItem.useOn, which only ties the player's own mobs (LeadItem.bindPlayerMobs). interact_knot ran the take-back and shears branches for any fence click, so clicking the top of a post with stone took a tied horse's lead instead of placing the stone, and shears snipped every lead.
2026-10-31 22:45:25 +01:00
rdbtCVSandClaude Opus 5.5 72e89aca4a Splitscreen: say Linux's gilrs matches a reconnected pad by model too
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:44:53 +00:00
rdbtCVSandClaude Opus 5.5 19c46b3fde Biome modifier ore test: the configured feature in 26.3's flat form
The prepared root holds features without vanilla's old "config" wrapper and
with a block state as an id, so the zinc ore never decoded and the
content-root test failed over real data (CI skips it without .local/).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:43:01 +00:00
rdbtCVS 500c13a0bf Merge branch 'pr-3794' into pr-3801 2026-10-31 21:42:23 +00:00
sirrayiandDeepSeek Harness 09a67d8c48 Native mods: a mod block chooses the state it is placed in
`getStateForPlacement` had no way in for a mod: a block could only be placed
in its default state. `LevelAccess::before_state_for_placement` now asks the
level, once per block whose placement state is computed, with vanilla's
`BlockPlaceContext` (the clicked face, the placer's rotation, the clicked
position, whether the clicked block is being replaced, inside, the item in
hand) and the fluid of the position the block would go in, so a block can
face the player, sit on the clicked face or come waterlogged. The answer is
`Some(Some(state))` to place that state, `Some(None)` to refuse the placement
as vanilla's `null` does, and empty to let the block's own placement run.

`native_state_for_placement` is the hook a native bundle answers with, and
`native_state_with_property` builds the state from the block's default state
and one of its properties (a content block's declared ones included).

`Ctx`, the resolved context, is now `PlacementContext`, public and documented
as what a mod block is handed. Both placers that go through vanilla's path
reach it: the player's `BlockItem.place` and a dispenser's
`DirectionalPlaceContext` (both `Sim::state_for_placement`), and the answer
still takes its survival and entity obstruction checks.

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-10-31 21:41:50 +00:00
rdbtCVSandClaude Opus 5.5 6d1237c04a Merge main into #5476
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:39:49 +00:00
rdbtCVS 746bdf8a96 Merge remote-tracking branch 'origin/main' into pr-3794 2026-10-31 21:39:39 +00:00
rdbtCVS 288ba8ca10 Merge branch 'pr-3777' into pr-3794 2026-10-31 21:39:34 +00:00
rdbtCVSandClaude Opus 5.5 f7bdb7449e Merge main into #3777
Main's handlers take a loop handle (EventLoopCtl) and about_to_wait's body is Game::idle: exit_on_error takes the handle, the split guard stays in idle, and run_event_loop stops the narrator after the split's loop.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:34:01 +00:00
levzzz eaf3bf704d Launcher: the AppImage path test runs on Unix only
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 39696d7a74)
2026-11-01 02:33:31 +05:00
TeiwazikandClaude Opus 5.5 11843ac18b Mipmaps: put the cutout dither behind a graphics option, off by default
The dither changes vanilla's look for every cutout from 64 texels up, so it now runs only under
Distant Plant Dither (ditherCutoutMips in horizon-graphics.txt). Turning it on or off reloads the
textures, since mips are made when they load. Decoded textures don't hold mips, so the sprite cache
keeps its version.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-10-31 22:30:41 +01:00
Aaron Wang e103983b5d Native mods fire their own advancement criteria triggers
An advancement of a content mod or a data pack can name a trigger of
the mod's (mymod:pipe_connected); the tree keeps it, but only the game's
own events called Game::trigger, so nothing could grant it.

native_trigger_criterion(uuid, trigger, test) is vanilla's
SimpleCriterionTrigger.trigger for one player: each open criterion of
that trigger goes to the mod's test, then to its player predicate, and
those that pass are granted and awarded as the game's own are. The test
reads the criterion's conditions and can check a vanilla item predicate
in them. It reaches the acting player inside their hook, and any player,
in their own level (for_player), where no player is swapped in;
minecraft: triggers are refused.

Fixes #5481.

Written with Claude Opus 5.5 in OpenCode.
2026-10-31 17:25:54 -04:00
levzzzandClaude Opus 5.5 6f0839a15e Launcher: the macOS and Windows tests build on gpui-fast
Keystroke::parse takes a 'static str; the app comes from gpui_platform::application(), and
AsyncApp::update returns nothing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:16:39 +05:00
PzmuV1517 b1f3afcd9a Native mods: a mod block decides if it can stay where it is
native_can_survive answers BlockState.canSurvive before the block does: as it is placed (BlockItem.canPlace)
and from the shape update of a block whose template checks it, which breaks it with its drops.
native_may_place_on answers VegetationBlock.mayPlaceOn, the blocks a mod plant can stand on, which its
template's canSurvive asks. Both through LevelAccess, on authoritative servers, as native_update_shape is.
2026-10-31 23:14:07 +02:00
rdbtCVSandClaude Opus 5.5 3332137ad2 Merge main into #5412
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:12:15 +00:00
rdbtCVSandClaude Opus 5.5 5a2f191b22 fix(skins): a head's texture patch only names a file inside the packs
The profile's texture id comes from a server's NBT; Identifier's characters and
no '.', '..' or empty segment keep it from reading a PNG outside the resource root.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:10:46 +00:00
rdbtCVSandClaude Opus 5.5 29f5ee49ee Merge main into #5411
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:10:22 +00:00
rdbtCVSandClaude Opus 5.5 b42b510cfb Merge main into #4250
The ten lang tables conflicted where both sides added strings: main's table as the
base, plus this branch's account strings, without the "Microsoft account · signed in"
this branch removed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:08:52 +00:00
rdbtCVSandClaude Opus 5.5 68b5f9e525 native_play_local_sound: check the event with sound_id, as native_play_sound does
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:03:25 +00:00
rdbtCVSandClaude Opus 5.5 53cfb35095 Merge main into #5441
Both sides added an Extra field and its NBT round trip (rabbit MoreCarrotTicks here,
ocelot Trusting on main); kept both.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:00:23 +00:00
rdbtCVSandClaude Opus 5.5 5c3017a630 fix(mobs): a fox egg's trust goes to the baby made on a fox, not to an egg used on a block
Fox.onOffspringSpawnedFromEgg's only caller is Mob.checkAndHandleImportantInteractions
(a fox egg used on an adult fox), so the trust moves from use_spawn_egg to
spawn_egg_offspring. The port map's Fox.clearTrusted and readAdditionalSaveData rows
now say what is ported.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:59:05 +00:00
rdbtCVSandClaude Opus 5.5 27fb1b40c6 Merge main into #5410
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:57:32 +00:00
rdbtCVSandClaude Opus 5.5 82bd9f6f37 Merge main into #5403
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:56:22 +00:00
levzzz 2034a32866 Merge remote-tracking branch 'origin/launcher/gpui-fast' into launcher/gpui-fast 2026-11-01 01:55:57 +05:00
levzzzandClaude Opus 5.5 124904afa4 Docs: the launcher reference lists completions
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 01:54:47 +05:00
Claude f2d940e4c8 Biome modifiers: test one biome id and a list of biomes
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NLEqWLKW2LHgtLer35pT8m
2026-10-31 20:54:36 +00:00
levzzz 26a4cdc5c5 Merge remote-tracking branch 'hr/main' into launcher/gpui-fast 2026-11-01 01:53:36 +05:00
rdbtCVSandClaude Opus 5.5 a844d55815 Merge main into #5409
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:52:06 +00:00
Guilherme Jansen 4f1ffce47d fix(launcher): preserve running macOS app bundles during updates 2026-10-31 17:49:40 -03:00
rdbtCVSandClaude Opus 5.5 e05059a562 Merge main into #5418
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:49:11 +00:00
rdbtCVSandClaude Opus 5.5 88590156f0 Merge main into #5461
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:46:21 +00:00
rdbtCVS f97c2cd499 Merge main into #5413 2026-10-31 20:45:41 +00:00
Claude 1fedaa1c5a Content mods: place mod features in biomes (biome modifiers)
A content mod's placed features now generate in vanilla's biomes through
NeoForge's biome modifiers (data/<ns>/neoforge/biome_modifier/, and
forge/biome_modifier/ for older mods): add_features, remove_features and
none. When the content root builds they rewrite the biomes' feature
lists, an added feature at the end of its step as NeoForge adds it; the
generator's FeatureSorter then orders them as vanilla does. Biomes no
modifier names keep their text, and a root with no modifiers keeps
vanilla's bundle. An unknown biome, tag, feature, step or type fails the
build with the mod and file named.

Part of #4953, closes #5431.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NLEqWLKW2LHgtLer35pT8m
2026-10-31 20:45:38 +00:00
Claude 8a6eaac999 native_summon: return experience orbs' ids, and place passengers without a Pos
Follow-up to #5408. A summoned orb spawned but native_summon returned None,
since no command Target lookup covers orbs: return the stack's first
spawned id. Passengers without Pos were not placed and got lost: share
/summon's recursive place helper (place_summoned), which also supplies
the Fire and Rotation defaults.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XnU6QT61YPiPzVNysmVHsH
2026-10-31 20:39:58 +00:00
huiandZCode AI agent (GLM-5.3) 42e5a2fb8e Load conditions: an unknown kind nested in the tree leaves the file out
known() now checks the whole subtree, not only the immediate type:
not(not(unknown)) and or(true, not(unknown)) both failed to exclude
the file, because the outer combinator was a known kind and only the
direct child was checked. Both now fail closed, with tests.

Review: Aria's agent swarm (Dyna) in the pull request.

Co-Authored-By: ZCode AI agent (GLM-5.3)
2026-10-31 21:39:32 +01:00
huiandZCode AI agent (GLM-5.3) bb0e421674 Recipes parse NeoForge's standard ingredient shapes
An ingredient that is one of NeoForge's standard object shapes now
resolves to the flat item list it matches, where the string parser
dropped the recipe file whole: neoforge:compound and
neoforge:intersection keep the items every sub-ingredient names,
neoforge:difference the base's without the subtracted's, and
neoforge:fluid a fluid's item form, its full bucket (a water or lava
tag names the same still fluid). A kind outside these still fails the
file - a mod's own ingredient types come with their own hook, not the
loader.

Co-Authored-By: ZCode AI agent (GLM-5.3)
2026-10-31 21:39:32 +01:00
TeiwazikandClaude Opus 5.5 d36550e82c Shader API: translucent terrain in the shadow pass, and its colour
Translucent terrain is drawn over a copy of the solid shadow map, so a pack can tell a point under
water or stained glass from one in shadow: shadow_map_translucent holds that depth, and shadow_color
the texture colour times the tint of the nearest such surface, with alpha 1 on a fluid.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-10-31 21:32:52 +01:00
Aaron Wang 7bb5aec013 Measure a far teleport from where the entity is headed
setValuesFromPositionPacket measures tooBigToInterpolate from
PositionMoveRotation.of(entity), which is getClientPositionAndRotation:
the interpolation target while one runs. handleEntityPositionSync
measures from the entity's position. MovePacket keeps the two apart.

The mob and armor stand branches of move_entity now share stepped_move.
A mob put somewhere at once also resets its previous yaw and pitch
(snapTo's setOldPosAndRot), so it doesn't spin for a frame, and every
spawn starts with a fresh interpolation handler, a stand's included.

Written with Claude Opus 5.5 in OpenCode.
2026-10-31 16:25:51 -04:00
huiandZCode AI agent (GLM-5.3) 3379d00ae0 Load conditions: mod IDs, not namespaces; unknown fails closed; the overlay replaces before the gate
mod_loaded now asks which mods are loaded (the mod-content root's new
horizon-mod-ids.json marker), not which namespaces they own — a mod that
also owns a namespace doesn't make that namespace another mod. An
unknown condition kind fails the file through not and or as well, not
just on its own: `not` of an unknown kind is not a pass, and an `or`
over one doesn't short-circuit past it. And the stations, the recipe
book and brewing apply the world's packs first and gate the merged
file, as the crafting loader already did: a false-conditioned pack file
leaves the lower recipe gone too, not still active underneath.

Review: Aria's agent swarm (Dyna) in the pull request.

Co-Authored-By: ZCode AI agent (GLM-5.3)
2026-10-31 21:22:30 +01:00
Aaron Wang 34ff18cdad Armor stands turn between ticks and snap on far moves, as living entities
An armor stand is a LivingEntity, so a vanilla client moves it with the
SteppedInterpolationHandler over its update interval (3 ticks), position
and yaw together, and ArmorStand.tickHeadTurn has the body follow yRot.
Horizon eased stands with the generic three-step lerp and set the yaw at
once from each packet. Stands now share the mobs' stepped handler:
ArmorStand::interpolate takes its step, keeps the yaw continuous and
saves yBodyRotO, and the renderer and the F3+B hitbox turn the body by
Mth.rotLerp(partialTick, yBodyRotO, yBodyRot).

A move of more than 64 blocks now snaps (tooBigToInterpolate), with
nothing left to blend from (Entity.snapTo). TeleportEntity snaps too, as
setValuesFromPositionPacket does; before, only EntityPositionSync did,
so a far teleported mob also slid there over three ticks.

Written with Claude Opus 5.5 in OpenCode.
2026-10-31 16:06:36 -04:00
TeiwazikandClaude Opus 5.5 acc8c21f4c Shader API: [shadow], a shadow map of the solid terrain
A pack asks for a shadow map in its manifest and the renderer draws the solid terrain the light
sees into it each frame, from the shadow camera's lists shaders/ packs already use. The material
and post stages read it as shadow_map with shadow_sampler and the prelude's shadow_position, and
the frame gains light_direction, has_shadow, shadow_view_proj, shadow_distance, shadow_distortion,
shadow_resolution and shadow_center_texel. A pack without [shadow] gets a 1x1 map that lights
everything. Translucent terrain and entities don't cast yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-10-31 21:03:05 +01:00
Federico TaglianiandClaude Sonnet 5.5 e2b71fa692 The Debug world preset: a grid of every block state
DebugLevelSource: barriers at y 60 and the block states in registry order on
a grid at y 70, one every other block. A Debug world's blocks never change
(Level.setBlock fails; setblock, fill and clone report it), nothing ticks
(scheduled, random, natural spawning), the login packet says isDebug, and
level-type minecraft:debug_all_block_states creates it.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 21:02:03 +01:00
Aaron Wang ccc34ef964 Draw armor stands between ticks, so a falling one moves smoothly
EntityRenderer draws an entity at Mth.lerp(partialTick, xo, x), but armor
stands were drawn at their tick position, so a falling stand jumped once
a tick (20 times a second) while boats, TNT and mobs slid. ArmorStand now
keeps its previous position: ArmorStand::interpolate saves it before each
interpolation step on a server's client, a stand riding a mob or the
player takes it from its vehicle's previous seat (as the mob riders do,
so it stays right while the level is frozen), and the in-frame tick
saves it too. The body, its light, the culling box, the name tag, the
flames and the F3+B hitbox all use the blended position.

Fixes #4416

Written with Claude Opus 5.5 in OpenCode.
2026-10-31 15:39:13 -04:00
reservedbytes 742e4dc206 fix(mods): a canceled hit forgets its attackers on every damage path 2026-10-31 18:37:46 -01:00
Claude 6b5780b686 Merge remote-tracking branch 'upstream/main' into feat/native-camera-shake 2026-10-31 19:33:10 +00:00
Claude f58fe06685 Native hooks: finite keeps its own doc comment 2026-10-31 19:33:10 +00:00
Federico TaglianiandClaude Sonnet 5.5 b1e4937c9a A rabbit eats ripe carrots
Rabbit.RaidGardenGoal: a MoveToBlockGoal (0.7, 16) to carrots at their maximum
age on #supports_crops ground, which takes one age off them (or the crop when
it had none) and leaves the rabbit full for 40 ticks (moreCarrotTicks, saved
as MoreCarrotTicks, running down by 0 to 2 a tick).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 20:31:50 +01:00
sirrayiandDeepSeek Harness 7170948484 test(probe): the mob probe feeds without a love cause
`feed_animal` takes the player who feeds an animal into love since this
branch; the probe passes none, so `--examples` builds again.

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-10-31 19:24:24 +00:00
MartipopsandClaude Opus 5.5 242065a84d Merge upstream main into feature/launcher-auth-status
Main's #5397 translated the same 17 strings into ja, pt-br and tr that dd722ee7a did, with
other wording. Git merges both without a conflict, which left each of those tables with 17
duplicate keys. Main's wording is kept and this branch's dropped, so the tables differ from
main only by this branch's 26 account strings and the key it removed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JLHp82d7UpxGuCncnLpJsq
2026-10-31 15:21:49 -04:00
rdbtCVSandClaude Opus 5.5 a209fab13e Native hooks: finite keeps its own doc comment
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:21:31 +00:00
Claude 1ec53c7a3f Merge remote-tracking branch 'upstream/main' into feat/native-falling-blocks
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-10-31 19:21:14 +00:00
sirrayiandDeepSeek Harness e8a93aa87e fix(mobs): a fox's baby trusts the players who bred it
`Fox.FoxBreedGoal.breed` hands the breeding to the baby as trust, which needs
the player who fed each parent into love: the animals kept the love counter
but not its cause. `Animal.LoveCause` is now kept (`Extra.love_cause`, fed
from the player who hands over the food, saved and read), and
`fox::trust_breeders` gives the baby both players, once each.

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-10-31 19:19:52 +00:00
sirrayiandDeepSeek Harness c5f11d1eb6 fix(mobs): foxes trust the players their list names
`Fox.getTrustedEntities` and `Fox.addTrustedEntity` were modelled as "no
trusted players yet": the save wrote an empty `Trusted` list, nothing read it
back, and the avoid-player goal had no trust test, so a fox fled the player
who spawned it and nobody could become trusted (two rows of #3512).

- `Extra.trusted` holds the two `DATA_TRUSTED_ID` slots; `fox::trusted_entities`,
  `add_trusted_entity` (first free slot, then replacing the second),
  `clear_trusted` and `trusts` are the entity's own methods by name.
- The save writes the real list (`Fox.TRUSTED_LIST_CODEC`: int arrays) and
  `mob_from_nbt` clears and rebuilds it, as `Fox.readAdditionalSaveData` does.
- `AVOID_PLAYERS` no longer flees a player the fox trusts, which is what the
  row meant by a bred fox still running from you; `AvoidEntity::player_when`
  takes the avoider so the predicate can ask. A fox spawned from a spawn egg
  trusts the player who used it (`Fox.onOffspringSpawnedFromEgg`).

Tested without prepared game data:
`mobs::fox::trust_tests::a_fox_trusts_two_players_and_the_third_replaces_the_second`.

Still missing from the row: the breeding hand-off (needs a saved love cause,
which the mobs do not keep) and `DefendTrustedTargetGoal`; both are in the
note of the map rows.

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-10-31 19:18:19 +00:00
huiandZCode AI agent (GLM-5.3) 617466720a Recipes answer NeoForge's load conditions
A data file with neoforge:conditions loads only when every condition
holds, and is silently absent otherwise, as NeoForge's ICondition leaves
it out of the manager. The gate covers the recipe loaders - crafting's
three kinds and the mods' own types, the stations, brewing, and the
recipe book - and the packs' overlays over them; mod_loaded asks which
mods' data is in the root (minecraft, always, and the content mods
that are on), and neoforge:true, not, and and or combine. A condition
the game does not know leaves the file out: a future shape fails
closed. Ported modpacks already carry such recipes verbatim (the carry
step keeps them), so cross-mod recipes stop loading blind.

Co-Authored-By: ZCode AI agent (GLM-5.3)
2026-10-31 20:16:20 +01:00
k3yhandClaude Opus 5.5 ad85e99600 Smoke where a bucket's water evaporates in the Nether (#5288)
BucketItem.emptyContents ends an evaporation with ServerLevel.sendParticles
(LARGE_SMOKE, the block's corner, 8, spread 1, speed 0, ALTERNATIVE).
Horizon played the hiss but sent no smoke for a player's bucket, and the
dispenser drew its own smoke one block too wide (nextDouble * 2 - 1 around
the corner instead of nextDouble from it) and sent none to other players.

evaporation_smoke does both: the window's puffs and the packet, now with
its randomization type (QueuedParticle.randomization). A client receiving
LevelParticles now honors ALTERNATIVE and ALTERNATIVE_WITH_SPEED, as
ClientPacketListener.handleParticleEvent does: positions spread over
[0, dist), speeds kept or scaled by nextDouble.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 14:10:43 -05:00
Claude d356420e76 Merge main into feat/native-shader-params
Resolves the conflicts with the water reflection fields: Frame.mods now
follows reflection_resolution at offset 528.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XnU6QRonhDhVY6koHuncJx
2026-10-31 19:10:34 +00:00
levzzz 120a5f41e3 Merge remote-tracking branch 'hr/main' into launcher/gpui-fast 2026-11-01 00:04:08 +05:00
levzzz fefa788a34 Merge remote-tracking branch 'hr/main' into launcher/gpui-fast
# Conflicts:
#	launcher/Cargo.lock
2026-10-31 23:58:48 +05:00
TeiwazikandClaude Opus 5.5 03829bb88e Mipmaps: dither a high-resolution cutout's small levels
Grass blades a few texels wide at 2048 average to about the same alpha everywhere by 64, and no scale of that alpha keeps the coverage, so the alpha test passes the whole quad or none of it. From 64 texels up, each level below the base takes the mean alpha through an ordered dither, about as many texels in as the base has.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-10-31 19:58:40 +01:00
Federico TaglianiandClaude Sonnet 5.5 cb12c27a6e MinecartSpawner: a spawner minecart ticks its BaseSpawner where it stands
A new cart kind (spawner_minecart, made by /summon: there is no item) that
shows a spawner block, drops and is picked as a plain minecart, and saves and
loads the BaseSpawner fields. Its serverTick is the spawner block's, with
broadcastEvent as an entity event on the cart.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 19:55:19 +01:00
levzzzandClaude Opus 5.5 e36ed13712 Launcher: GPUI Fast instead of GPUI
longbridge/gpui-fast: Zed's GPUI with retained views and cached scroll layers.
The API changes are mechanical: focus and blur take the App, the application
comes from gpui-fast-platform (wgpu on Linux), menus say whether they're
disabled, shadows whether they're inset. oo7 follows to 0.6.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 23:55:12 +05:00
RansomandClaude Opus 5.5 46598a2bc5 Native mods: native_play_local_sound plays a client mod's sound, later
A client helper that plays a sound event, vanilla's or the mod's own
from its assets' sounds.json, for this player only and up to a minute
later (SoundEngine.playDelayed), so a mod can time a blast's sound to
its shock wave. Audio's distance delay now goes through the same
play_after.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XnU6QSxLfzUbudHhcvZtgy
2026-10-31 18:50:02 +00:00
RansomandClaude Opus 5.5 de92159b98 Native mods: native_spawn_falling_block throws a block from a server mod
A server mod's helper that throws a falling block at a velocity: it
flies, lands and places itself as sand does, or drops as an item, and
can hurt what it lands on as an anvil does. For explosion debris and
collapsing builds. Refused on a client, for another dimension, air, an
unloaded chunk, values that aren't finite, or past 1024 falling blocks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XnU6QSxLfzUbudHhcvZtgy
2026-10-31 18:50:02 +00:00
RansomandClaude Opus 5.5 39755f90ff Native mods and shader packs: native_shader_params hands a mod's pack eight values
A client hook asked once a frame for eight vec4s that every stage of a
Horizon shader pack reads as frame.mods, so a native mod can tell the
pack it bundles where and when something happened (an explosion's
centre and age for a cloud ray marched in a post pass). Values that
aren't finite read as 0; all are 0 when no mod sets them. The
reference is regenerated.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XnU6QSxLfzUbudHhcvZtgy
2026-10-31 18:50:02 +00:00
RansomandClaude Opus 5.5 4983adec8b Native mods: native_camera_shake lets a client mod shake the camera
A client hook asked each frame for turns in degrees and an offset in
blocks, which add up across mods. Horizon scales the sum by Distortion
Effects (0% turns it off), bounds it to 10 degrees and half a block on
each axis, and multiplies it into the view after the hurt tilt and view
bobbing. It moves what the player sees, not where they aim.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XnU6QSxLfzUbudHhcvZtgy
2026-10-31 18:33:03 +00:00
MartipopsandClaude Opus 5.5 dd722ee7a7 Launcher: main's new strings in Japanese, Brazilian Portuguese and Turkish; tr gets the accounts'
Main's 31c1212bf added 17 strings to seven tables but not to ja, pt-br or the new tr, which
fails the every-language test on main. They are translated here as in the PR to main that
fixes it, so the lines are the same. tr.json also gets this branch's 26 account strings and
drops "Microsoft account · signed in", as ja and pt-br did.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JLHp82d7UpxGuCncnLpJsq
2026-10-31 14:23:00 -04:00
Claude 71955880b7 Draw a head profile's texture override instead of its skin
A ResolvableProfile can carry a `texture` id (PlayerSkin.Patch body, a
ClientAsset.ResourceTexture). Vanilla draws a player head with that
resource-pack texture instead of the downloaded or default skin; Horizon
ignored it and drew the profile's default skin. Placed heads and head
items now read the patch, loading non-vanilla namespaces' textures from
the resource packs into a skin slot.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01P1ZTfB3AfwKgGnv7UFKYVD
2026-10-31 18:22:56 +00:00
Ludovic Marie ee20fbd1a9 Merge branch 't3794' into t3801 2026-10-31 19:17:11 +01:00
Ludovic Marie 696c5709e7 Merge branch 't3777' into t3794 2026-10-31 19:17:06 +01:00
Martipops 80bd8b2aa2 Merge upstream main into feature/launcher-auth-status
# Conflicts:
#	launcher/lang/de.json
#	launcher/lang/es.json
#	launcher/lang/fr.json
#	launcher/lang/pl.json
#	launcher/lang/ru.json
#	launcher/lang/uk.json
#	launcher/lang/zh.json
2026-10-31 14:17:01 -04:00
Ludovic Marie f0761a306d Merge remote-tracking branch 'origin/main' into t3777
# Conflicts:
#	crates/horizon-client/src/game/integrated.rs
2026-10-31 19:13:05 +01:00
MartipopsandClaude Opus 5.5 695574db09 Launcher accounts: a refresh saves to the accounts file it is given; pt-BR and Japanese strings
refresh_account and renew_for_game_with take the accounts.json a renewed profile is saved to.
The commands pass the launcher's own (Accounts::path()); the tests pass one that is never
there, so they no longer read or write the developer's launcher data. A new test checks that
a refresh saves the services' newer profile and leaves an unchanged one unwritten.

Main added pt-br.json and ja.json after this branch's strings: both get its 26 account
strings, and lose "Microsoft account · signed in", which the code no longer asks for (the
other tables dropped it already).

With main merged: profile_heads' test uses Accent::VIOLET (main made Accent a hue), and
docs/launcher-commands.md is regenerated for main's `completions` command, which main had
not written into it (the reference test failed on main too).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JLHp82d7UpxGuCncnLpJsq
2026-10-31 14:08:21 -04:00
Martipops 8bbf67da6f Merge upstream main into feature/launcher-auth-status 2026-10-31 13:55:01 -04:00
ErikKalikasandClaude Opus 5.5 91ed167997 Native mods: keep the block entity draw helpers for mods (dead_code)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:50:44 +02:00
ErikKalikas a8a17b12d0 Merge remote-tracking branch 'upstream/main' into mod-block-entity-draw 2026-10-31 19:42:38 +02:00
ErikKalikasandClaude Opus 5.5 4d9dbdb565 Native mods: draw mod block entities on the client (BlockEntityRenderer.submit)
native_block_entity_submit runs once a frame for each mod block entity in
view, as vanilla's block entity renderers do (64 blocks, frustum, chunk
fade-in), with a pose stack and helpers that add to the entity batch:
quads, model-element boxes baked as FaceBakery does, and block models
(submitBlockModel). Content mods' textures/entity join the atlas.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:42:38 +02:00
Ludovic Marie abb630c564 Merge main into #3825
# Conflicts:
#	crates/horizon-client/src/game/integrated.rs
2026-10-31 18:41:25 +01:00
Ludovic MarieandClaude Opus 5.5 802a3fa668 Merge #3794's branch; a model match only where ids change and one player waits with it; P1 with controllers off takes no pad
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:37:57 +01:00
Ludovic MarieandClaude Opus 5.5 95122e4ff8 Merge #3777's branch; a pressed pad skips players with controllers off; say splitscreen has no rumble yet
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:29:40 +01:00
Ludovic MarieandClaude Opus 5.5 da45330894 Merge main; splitscreen seats follow the owner's Allow Cheats, a failed join leaves P1 playing
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:26:05 +01:00
Ludovic MarieandClaude Opus 5.5 c7b8559015 Launcher accounts: status workers wait for the lock; a refresh saves the renewed profile
rdbtCVS's two review points on #4250:
- The launcher's own status workers (off the UI thread, showing "Waiting for
  account access…") take the lock without the 3 s bound, so a worker queued
  behind another account's renewal no longer shows a false "Could not check
  sign-in". account list and MCP keep the bounded wait.
- refresh_account saves a renewed profile (new name or skin) to accounts.json
  as launch() does, and the UI reloads accounts and profile heads when a
  status arrives and the saved list changed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:24:16 +01:00
Ludovic MarieandClaude Opus 5.5 d876fa1212 Merge main into #3825; splitscreen windows: the review's three fixes
- audio.rs: main's shared-output fields (the device tracker's, f80c0360d).
- A guest in Window 1 leaves its F11 to P1: the game's window follows P1's option.
- disable() shows Window 1 again and says so (window1_shown), so a later
  split into Window 2 hides it again.
- Clicks, key presses and the wheel from a window the focused player isn't
  in no longer reach that player; releases still pass, so no key sticks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:11:32 +01:00
Ludovic Marie d291364f56 Merge main into #4250 2026-10-31 18:08:06 +01:00
rdbtCVSandClaude Opus 5.5 9ec5a13f6b Merge origin/main into launcher auth status
launcher/lang/*.json: keep both sides' new strings (main's text where a key is in both).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:15:13 +00:00
rdbtCVSandClaude Opus 5.5 35f41e670c Merge origin/main into split/09-windows
Kept PadRouter's state_of with main's stick_axes, both Flags fields, and filled
main's audio device-tracking fields in Audio::share.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:09:57 +00:00
rdbtCVSandClaude Opus 5.5 d309aed786 Merge origin/main into split/07-controllers
Conflicts: integrated.rs keeps both Flags fields (split_players, load);
pad/hardware.rs keeps the PadRouter split and moves main's stick_axes
mapping into state_of. A shared Audio gets main's device-tracking fields
and leaves reopening the output to the game's own Audio.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:06:04 +00:00
rdbtCVSandClaude Opus 5.5 59e106cf25 Merge origin/main into split/06-two-players
main's audio device tracking (#default_seen, devices_seen, device_ticks) added fields
the shared Audio built by share() now fills.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:05:26 +00:00
martipops 9e3aaa3400 Merge branch 'split/08-join-with-controller' into split/09-windows
# Conflicts:
#	crates/horizon-client/src/game/options.rs
2026-10-30 19:04:33 -04:00
martipops 1e13f55868 Merge branch 'split/07-controllers' into split/08-join-with-controller 2026-10-30 19:03:22 -04:00
martipops 3779a7c5d0 Merge branch 'split/06-two-players' into split/07-controllers 2026-10-30 19:03:22 -04:00
martipopsandClaude Opus 5.5 c6661cfe72 Merge upstream main into split/06-two-players
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 19:03:22 -04:00
martipopsandClaude Opus 5.5 626de8923b Merge upstream main into launcher auth status
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:59:08 -04:00
martipops cd48c77ff2 Merge branch 'split/08-join-with-controller' into split/09-windows 2026-10-30 18:37:16 -04:00
martipops c7dbf8f290 Merge branch 'split/07-controllers' into split/08-join-with-controller 2026-10-30 18:37:16 -04:00
martipops 858e8b3ba8 Merge branch 'split/06-two-players' into split/07-controllers 2026-10-30 18:37:16 -04:00
martipops a2fcdeef3b Merge branch 'split/02-audio' into split/06-two-players 2026-10-30 18:37:16 -04:00
martipopsandClaude Opus 5.5 a12892258f Merge upstream main into split/06-two-players
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:36:45 -04:00
martipopsandClaude Opus 5.5 dbb23c1eca Splitscreen windows: don't retry a window that failed to open, pace on a shown one
- A window that failed to open was tried again every frame, each time
  re-laying out every player. Its players now play in Window 1 and the
  window is forgotten.
- Vsync and the frame pacing's refresh rate follow the first shown
  window, not a hidden Window 1; Window 1's visibility is kept in a
  field, so Wayland (which can't report it) isn't told every frame.
- Only free windows remember where they were: a monitor's rect made a
  later free window with its number open monitor-sized.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:19:50 -04:00
martipops fd4b82f92d Merge branch 'split/08-join-with-controller' into split/09-windows 2026-10-30 18:18:16 -04:00
martipopsandClaude Opus 5.5 2d9d1f2264 Splitscreen: keep a lone P1's controller output between frames
route_pads released P1's pad hardware every frame, so with P1 alone the
reader lent for P1's turn lost its rumble and DualSense link a frame
after they started. It now releases only when nothing is lent.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:17:39 -04:00
martipopsandClaude Opus 5.5 2317bebacf Merge split/07-controllers into split/08-join-with-controller
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:17:39 -04:00
martipopsandClaude Opus 5.5 aad4fd03f4 Splitscreen: hold back a trigger press that gave a controller its player
A controller first pressed with a trigger got a player, and the held
trigger then attacked or used: only buttons were held back.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:15:30 -04:00
martipops a7802c004c Merge branch 'split/06-two-players' into split/07-controllers 2026-10-30 18:14:30 -04:00
martipops 3d94aa69d1 Merge branch 'split/02-audio' into split/06-two-players 2026-10-30 18:14:20 -04:00
martipopsandClaude Opus 5.5 7215229f71 Splitscreen: let a seat's name in only through memory
A LAN player who logged in under a registered seat's name (P2) passed
the integrated server's gate and the full-server check and held the
owner's permissions given to that seat. The gate now admits a seat's
name only from the memory connection (MEMORY_ADDRESS, port 0).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:14:20 -04:00
martipopsandClaude Opus 5.5 9945e72c81 Merge upstream main into launcher auth status
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 17:39:33 -04:00
martipopsandClaude Opus 5.5 1af012a921 Launcher auth: let a status read give up behind a long operation
A refresh or a launch check holds the account lock across network calls.
Status reads (account list, the account page) now wait at most 3 s for
it, then say another operation is running, instead of waiting out a
network timeout. Writers still wait.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 17:38:37 -04:00
martipopsandClaude Opus 5.5 134b6f1a42 Merge upstream main into launcher auth status
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 17:02:13 -04:00
martipopsandClaude Opus 5.5 af5ac52ace Launcher auth: answer review nits
Translate the account status messages, print a failed `account refresh`
once, fetch a profile head again a minute after its skin download failed,
and check every account's sign-in at most once a minute on page switches
(an account added meanwhile at once), so switching pages no longer reads
the keychain each time.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 16:57:48 -04:00
martipopsandClaude Opus 5.5 dc6d372db8 Let a running game renew its session through the launcher CLI
`account session WHO` renews an account and hands the running game a new
read-once session file, or the public status when it needs a browser
sign-in or a retry. `account sign-in --for WHO` runs the UUID-bound
re-sign-in from the command line. The launcher passes its own path to the
game as HORIZON_LAUNCHER_EXE. `account session` is kept out of MCP: its
file holds a token.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 04:01:40 -04:00
0dab1469ac Show profile heads, auth progress, and targeted re-sign-in in the launcher
Fetch public skin heads on workers with a bounded, URL-keyed cache and UUID default fallback, report each account operation's actual phase, and bind Sign in again to the account's UUID so a different Microsoft account is rejected before any credentials are stored.

Co-Authored-By: Codex <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 03:34:27 -04:00
martipopsandCodex 3fab32c08a Give launcher account states semantic colors and icons
Show ready, refreshable, and sign-in-required states using the theme's success, warning, and danger colors, retaining icons and full labels.

Co-Authored-By: Codex <[email protected]>
2026-10-30 02:58:31 -04:00
martipopsandCodex 501c9abbfe Handle completed sign-in only once while polling the launcher
Compare sign-in states directly instead of comparing a state to an Option's debug output, which continually restarted every account's status checks.

Co-Authored-By: Codex <[email protected]>
2026-10-30 02:49:01 -04:00
martipopsandCodex f0a06742ea Show account status and refresh actions in the launcher
Inspect and renew due sessions on workers, expose Refresh and Sign in again actions, and share the operation through CLI and MCP for future launcher-to-game integration.

Co-Authored-By: Codex <[email protected]>
2026-10-30 02:32:45 -04:00
martipopsandCodex 6e1ac28831 Add launcher-owned account status and forced session renewal
Keep authentication state credential-free, remember rejected refresh credentials, preserve rotated credentials after downstream failures, and serialize refresh with sign-in, sign-out and launch.

Co-Authored-By: Codex <[email protected]>
2026-10-30 02:32:45 -04:00
martipopsandClaude Opus 5.5 f96acabfdc Splitscreen: the alone check in device_event is no surfaces, as in window_event
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 23:46:46 -04:00
martipops 1a7e3da3c4 Merge branch 'split/07-controllers' into split/08-join-with-controller 2026-10-29 23:43:33 -04:00
martipops 5596fd89f4 Merge branch 'split/06-two-players' into split/07-controllers 2026-10-29 23:43:33 -04:00
martipops f6bd4918d7 Merge branch 'split/08-join-with-controller' into split/09-windows 2026-10-29 23:43:33 -04:00
martipops 38bda0fc71 Merge branch 'split/02-audio' into split/06-two-players 2026-10-29 23:42:35 -04:00
martipopsandClaude Opus 5.5 126d270448 Splitscreen: alone, device events go straight to P1 as window events do
Review: device_event turned P1's GUI scale on for every mouse motion even with no other
player; it now hands the event to P1 directly while there is no compositor.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 23:42:35 -04:00
martipops 92ba57a02e Merge branch 'split/07-controllers' into split/08-join-with-controller 2026-10-29 23:07:35 -04:00
martipops c350caa74f Merge branch 'split/06-two-players' into split/07-controllers 2026-10-29 23:07:35 -04:00
martipops 91290d7d0a Merge branch 'split/08-join-with-controller' into split/09-windows 2026-10-29 23:07:35 -04:00
martipops 0286833cb7 Merge remote-tracking branch 'upstream/main' into split/06-two-players
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-10-29 23:07:34 -04:00
martipops 8a8ee31ea2 Merge branch 'split/07-controllers' into split/08-join-with-controller 2026-10-29 21:39:41 -04:00
martipops c9e0a240a4 Merge branch 'split/08-join-with-controller' into split/09-windows 2026-10-29 21:39:41 -04:00
martipops 69bc9623a1 Merge branch 'split/06-two-players' into split/07-controllers 2026-10-29 21:39:40 -04:00
martipopsandClaude Opus 5.5 dc772c4601 Merge upstream main into part 6
Upstream moved the title actions into title_action (the split guest's Quit guard goes there),
added the server's online mode beside our memory-seat limit bypass, the audio output's device
name (a shared output carries it), and dropped code mods' key hooks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 21:38:16 -04:00
martipops 950e0aeac2 Merge branch 'split/07-controllers' into split/08-join-with-controller 2026-10-29 13:39:25 -04:00
martipops ec514fd94b Merge branch 'split/08-join-with-controller' into split/09-windows 2026-10-29 13:39:25 -04:00
martipops 664c82127f Merge branch 'split/06-two-players' into split/07-controllers 2026-10-29 13:39:25 -04:00
martipops 90f2db9114 Merge remote-tracking branch 'upstream/main' into split/06-two-players
# Conflicts:
#	crates/horizon-client-render/src/gfx.rs
2026-10-29 13:39:15 -04:00
martipopsandClaude Opus 5.5 685548932b Splitscreen: players in windows of their own, or filling a monitor
Part 9 of the splitscreen series (#3541), on #3801. Each player plays in a window: Window 1
(the game's), Window 2, 3... opened for the players put there, or a borderless window filling
a monitor (over the menu bar on macOS, the system's borderless fullscreen elsewhere). Players
in one window share it. Closing a player's own window drops its players; the game's window
hides while nobody plays in it; titles say who plays where; a window reopens where it was.
Another player's F11 toggles its own window. HORIZON_SPLIT_WINDOWS (testing, until the Setup
screen) says each seat's window: `1,1,2`, or `m2` for monitor 2.

Live: tools/splitscreen_e2e.py adds `windows`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 13:01:59 -04:00
martipopsandClaude Opus 5.5 2edc3b57a4 Splitscreen: join with a controller; a lost controller's player waits
Part 8 of the splitscreen series (#3541), on #3794. While splitscreen watches the controllers
(HORIZON_SPLIT for now; the switch comes later), one reader serves every player, alone too:
the first controller used is P1's (its rumble and DualSense output lent to P1's turn). In P1's
world another controller pressing a button is offered to join on every view: A joins (as
P<seat>), Y drives P1 when P1 has none, B says no, 10 s and it goes. A controller that
disconnects leaves its player dimmed and asked: the same controller back takes it again, A on
another takes over, B lets it leave. Each view gets a short badge (who, and why: "Keyboard
and mouse" when it takes them).

Live: tools/splitscreen_e2e.py adds `alone` (watching, nobody joins).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 12:32:57 -04:00
martipops bd6ead73c3 Merge branch 'split/06-two-players' into split/07-controllers 2026-10-29 12:24:01 -04:00
martipopsandClaude Opus 5.5 557a675d5d splitscreen_e2e: replies through a file, so Windows doesn't hang on launch
On Windows the game horizon-ctl launch starts inherits horizon-ctl's stdout pipe, which then
never closes while the game runs: subprocess.run waited forever. No pkill there either.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 12:22:32 -04:00
martipops 408ca59a65 Merge branch 'split/06-two-players' into split/07-controllers 2026-10-29 11:56:09 -04:00
martipopsandClaude Opus 5.5 c911e9060d splitscreen_e2e: find horizon-ctl.exe on Windows
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 11:56:01 -04:00
martipopsandClaude Opus 5.5 9b19ab5b04 Splitscreen: each controller drives its own player
Part 7 of the splitscreen series (#3541), on #3777. While several play, one controller reader
(PadRouter) serves every player's game: a controller nobody has goes, when pressed, to the
first player without one (P1 first); its first press is not passed on; a controller that
disconnects leaves its player without one. Each player's glyphs follow its controller.
Alone, P1 reads the controllers itself, exactly as without splitscreen.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 11:50:52 -04:00
martipopsandClaude Opus 5.5 70a9fd6f6a Splitscreen: a second player in the same window (HORIZON_SPLIT=N)
Part 6 of the splitscreen series (#3541), on parts 1 (compositor), 2 (audio), 4 (pointer
ownership) and 5 (layout). With HORIZON_SPLIT=N (testing only, no switch yet), N-1 more
players join P1's world through memory as P2, P3..., each its own Game in this process,
rendered offscreen and composited into P1's window. Keyboard and mouse go to the view
clicked last. A player who quits or disconnects drops out; alone again, P1 draws on its
window as before. Guests never open the controllers (pad.feed); routing comes next.

Live: tools/splitscreen_e2e.py (join, four).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 11:34:36 -04:00
martipops 563d85f957 Merge remote-tracking branch 'origin/split/05-viewports' into split/06-two-players 2026-10-29 11:25:55 -04:00
martipops ce0983fecc Merge remote-tracking branch 'origin/split/04-pointer-ownership' into split/06-two-players 2026-10-29 11:25:55 -04:00
martipops b3e5cb2fa6 Merge remote-tracking branch 'origin/split/02-audio' into split/06-two-players 2026-10-29 11:25:55 -04:00
335 changed files with 27647 additions and 4611 deletions

No files matched your search

Generated
+11
View File
@@ -1334,6 +1334,9 @@ dependencies = [
"log",
"miniz_oxide 0.8.9",
"naga",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"open",
"png",
"pollster",
@@ -1506,6 +1509,13 @@ dependencies = [
"windows 0.62.2",
]
[[package]]
name = "horizon-optifine"
version = "0.1.0"
dependencies = [
"regex",
]
[[package]]
name = "horizon-port-pack"
version = "0.1.0"
@@ -1609,6 +1619,7 @@ version = "0.1.0"
dependencies = [
"bytemuck",
"naga",
"png",
"serde",
"serde_json",
"sha2",
+1
View File
@@ -30,6 +30,7 @@ members = [
"crates/cargo-horizon-mod",
"crates/horizon-port-pack",
"crates/horizon-shaders",
"crates/horizon-optifine",
"crates/horizon-shader-api",
"crates/horizon-release-sign",
"xtask",
@@ -96,6 +96,11 @@ pub struct RenderAssets {
pub translucent_layers: Vec<bool>,
/// Each layer's `MipmapGenerator` settings.
pub sprite_mips: Vec<crate::mipmap::Settings>,
/// The resource packs' LabPBR maps (normals, specular), layer by layer at `pbr_size`:
/// empty when they ship none.
pub pbr_normals: Vec<u8>,
pub pbr_specular: Vec<u8>,
pub pbr_size: u32,
}
fn invalid(message: &str) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, message)
@@ -259,6 +264,9 @@ impl RenderAssets {
sprite_layers: sprites.layers,
translucent_layers: sprites.translucent,
sprite_mips: sprites.mips,
pbr_normals: sprites.normals,
pbr_specular: sprites.specular,
pbr_size: sprites.pbr_size,
})
}
}
+12 -2
View File
@@ -1226,6 +1226,17 @@ impl Audio {
self.play(event, Some(at), volume, pitch);
return;
}
self.play_after(
event,
at,
volume,
pitch,
(distance_sqr.sqrt() / 40.0 * 20.0) as i32,
);
}
/// `play` at a world position, starting `ticks` sound-engine ticks (20 a
/// second) from now: `SoundEngine.playDelayed`.
pub fn play_after(&mut self, event: &str, at: [f64; 3], volume: f32, pitch: f32, ticks: i32) {
let seed = self.random.next_long();
if let Some(capture) = self.capture.as_mut() {
capture.push((event.to_owned(), at, volume, pitch));
@@ -1239,8 +1250,7 @@ impl Audio {
let instance =
SoundInstance::at(&event, source, volume, pitch, LegacyRandom::new(seed), at);
let id = self.engine.add(instance, false);
self.engine
.play_delayed(id, (distance_sqr.sqrt() / 40.0 * 20.0) as i32);
self.engine.play_delayed(id, ticks);
}
/// `play` for this client only: a sound a packet's handler plays
/// (`playLocalSound`), which the guests play from that packet themselves.
@@ -885,25 +885,26 @@ impl BlockEntityRenderer {
let mut renderer = Self::new(models, registry);
renderer.visibility = Some(Mutex::new(visibility));
// Sherd items: provides_pottery_pattern -> decorated_pot_pattern asset id.
if let Ok(bytes) = std::fs::read(root.join("items.json")) {
let items: Value = serde_json::from_slice(&bytes)?;
for (item, components) in items.as_object().into_iter().flatten() {
let Some(pattern) = components["minecraft:provides_pottery_pattern"].as_str()
else {
continue;
};
let name = pattern.strip_prefix("minecraft:").unwrap_or(pattern);
let file = root.join(format!("data/minecraft/decorated_pot_pattern/{name}.json"));
let Ok(def) = std::fs::read(file) else {
continue;
};
let def: Value = serde_json::from_slice(&def)?;
if let Some(asset) = def["asset_id"].as_str() {
let asset = asset.strip_prefix("minecraft:").unwrap_or(asset);
renderer
.pot_patterns
.insert(item.clone(), format!("entity/decorated_pot/{asset}"));
}
let items = match horizon_world::item_defaults::read(root) {
Ok(items) => items,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Default::default(),
Err(e) => return Err(e.into()),
};
for (item, components) in &items {
let Some(pattern) = components["minecraft:provides_pottery_pattern"].as_str() else {
continue;
};
let name = pattern.strip_prefix("minecraft:").unwrap_or(pattern);
let file = root.join(format!("data/minecraft/decorated_pot_pattern/{name}.json"));
let Ok(def) = std::fs::read(file) else {
continue;
};
let def: Value = serde_json::from_slice(&def)?;
if let Some(asset) = def["asset_id"].as_str() {
let asset = asset.strip_prefix("minecraft:").unwrap_or(asset);
renderer
.pot_patterns
.insert(item.clone(), format!("entity/decorated_pot/{asset}"));
}
}
Ok(renderer)
@@ -160,6 +160,18 @@ pub struct Brewing {
}
impl Brewing {
pub fn load(root: &Path, items: &Items, overlay: &PackOverlay) -> Self {
// NeoForge's load conditions: a file whose don't hold is silently
// absent. Vanilla's brewing recipes carry none; a pack's can.
let mods: Vec<String> = {
let mut mods = horizon_items::content_namespaces::mod_ids(root);
if !mods.iter().any(|m| m == "minecraft") {
mods.push("minecraft".to_owned());
}
mods
};
let conditions_hold = |v: &Value| {
horizon_world::conditions::conditions_hold(v, &|id: &str| mods.iter().any(|m| m == id))
};
let mut sources: Vec<(String, Value)> =
fs::read_dir(root.join("data/minecraft/recipe/brewing"))
.into_iter()
@@ -191,6 +203,9 @@ impl Brewing {
None => sources.push((id.to_owned(), v)),
}
}
// The gate answers the merged file, as the crafting loader's does:
// a false-conditioned pack file leaves the lower one gone too.
sources.retain(|(_, v)| conditions_hold(v));
sources.sort_by(|a, b| a.0.cmp(&b.0));
let mut recipes = Vec::new();
for (id, v) in sources {
@@ -233,6 +233,9 @@ pub struct SkullSkin {
/// `ResolvableProfile.nameOrId` when the profile is `Dynamic` (a name or
/// an id only): the key `skins::Resolver` resolves it by.
pub dynamic: Option<NameOrId>,
/// `PlayerSkin.Patch.body`: the profile's `texture`, a resource pack
/// texture drawn instead of any skin.
pub texture: Option<String>,
pub fallback: &'static str,
}
/// `Either<String, UUID>`: the key a `Dynamic` `ResolvableProfile` resolves
@@ -9,6 +9,59 @@ pub use crate::chunk::block_entity::{
use crate::items::ItemStack;
use std::collections::HashMap;
/// a content mod's menu type (`data/<id>/horizon/menu/`, vanilla's
/// `MenuType`): its container slots, its data slots (`ContainerData`) and the
/// slots players only take from.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ModMenuType {
pub id: &'static str,
pub slots: usize,
pub data_slots: usize,
pub results: Vec<usize>,
}
static MOD_MENU_TYPES: std::sync::OnceLock<Vec<ModMenuType>> = std::sync::OnceLock::new();
/// registers the content mods' menu types, `(id, slots, data slots,
/// results)`, a list set once per process (false when it already was).
pub fn register_mod_menu_types(
menus: impl IntoIterator<Item = (String, usize, usize, Vec<usize>)>,
) -> bool {
if MOD_MENU_TYPES.get().is_some() {
return false;
}
let menus = menus
.into_iter()
.map(|(id, slots, data_slots, results)| ModMenuType {
id: Box::leak(id.into_boxed_str()),
slots,
data_slots,
results,
});
MOD_MENU_TYPES.set(menus.collect()).is_ok()
}
/// the content mods' menu types, in registry order.
pub fn mod_menu_types() -> &'static [ModMenuType] {
MOD_MENU_TYPES.get().map_or(&[], Vec::as_slice)
}
/// the mod menu type named `id`, kept out of line: a vanilla menu never
/// looks one up.
#[cold]
#[inline(never)]
pub fn mod_menu_type(id: &str) -> Option<&'static ModMenuType> {
mod_menu_types().iter().find(|m| m.id == id)
}
/// whether a client of `brand` can be sent menu type `menu`: a vanilla
/// client reads the type through `ByteBufCodecs.registry(Registries.MENU)`,
/// and an id it lacks (`IdMap.byIdOrThrow`) disconnects it, so it is sent
/// vanilla's only. A Horizon client has the server's (the content identity).
pub fn menu_reaches(brand: &str, menu: &str) -> bool {
brand == "horizon" || menu.starts_with("minecraft:")
}
impl FurnaceKind {
pub fn recipe_type(self) -> &'static str {
match self {
@@ -196,6 +249,7 @@ impl SkullSkin {
Self {
download,
dynamic,
texture: crate::skins::profile_texture_nbt(profile),
fallback,
}
}
@@ -610,6 +664,15 @@ mod tests {
use super::*;
use horizon_core::random::{LegacyRandom, RandomSource};
/// a client that isn't Horizon's is sent vanilla's menu types only.
#[test]
fn a_mod_menu_type_reaches_horizon_clients_only() {
assert!(menu_reaches("horizon", "ruby:press"));
assert!(!menu_reaches("vanilla", "ruby:press"));
assert!(!menu_reaches("fabric", "ruby:press"));
assert!(menu_reaches("vanilla", "minecraft:generic_9x3"));
}
#[test]
fn campfire_slots_take_food_cook_it_and_cool_down() {
let mut c = Campfire::default();
+3 -85
View File
@@ -1,85 +1,3 @@
//! A fast, non-cryptographic hasher (the Fx hash used by rustc) for hot maps
//! keyed by small integers, such as chunk coordinates. Iteration order of
//! such maps carries no meaning: the default hasher's is random anyway.
use std::hash::{BuildHasherDefault, Hasher};
const SEED: u64 = 0xf135_7aea_2e62_a9c5;
#[derive(Clone, Copy, Default)]
pub struct FxHasher {
hash: u64,
}
impl FxHasher {
#[inline]
fn add(&mut self, word: u64) {
self.hash = self.hash.wrapping_add(word).wrapping_mul(SEED);
}
}
impl Hasher for FxHasher {
#[inline]
fn write(&mut self, bytes: &[u8]) {
let (words, rest) = bytes.as_chunks::<8>();
for word in words {
self.add(u64::from_le_bytes(*word));
}
if !rest.is_empty() {
let mut word = [0u8; 8];
word[..rest.len()].copy_from_slice(rest);
self.add(u64::from_le_bytes(word));
}
}
#[inline]
fn write_u8(&mut self, i: u8) {
self.add(u64::from(i));
}
#[inline]
fn write_u16(&mut self, i: u16) {
self.add(u64::from(i));
}
#[inline]
fn write_u32(&mut self, i: u32) {
self.add(u64::from(i));
}
#[inline]
fn write_u64(&mut self, i: u64) {
self.add(i);
}
#[inline]
fn write_usize(&mut self, i: usize) {
self.add(i as u64);
}
#[inline]
fn write_i32(&mut self, i: i32) {
self.add(u64::from(i as u32));
}
#[inline]
fn finish(&self) -> u64 {
// The multiply leaves the low bits weakest; hash tables use them.
self.hash.rotate_left(26)
}
}
pub type FxBuild = BuildHasherDefault<FxHasher>;
pub type FxHashMap<K, V> = std::collections::HashMap<K, V, FxBuild>;
pub type FxHashSet<K> = std::collections::HashSet<K, FxBuild>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn nearby_chunks_spread_over_buckets() {
let mut map: FxHashMap<(i32, i32), i32> = FxHashMap::default();
for x in -40..40 {
for z in -40..40 {
map.insert((x, z), x * z);
}
}
assert_eq!(map.len(), 6400);
assert_eq!(map[&(-7, 13)], -91);
let mut low = std::collections::HashSet::new();
for x in 0..64 {
low.insert(std::hash::BuildHasher::hash_one(&FxBuild::default(), (x, 0)) & 63);
}
assert!(low.len() > 32, "low bits vary: {}", low.len());
}
}
//! The Fx hasher for hot maps keyed by small integers: kept in horizon-core
//! (the scheduled ticks use it too), and reachable here under its old path.
pub use horizon_core::fast_hash::*;
+581 -72
View File
@@ -88,12 +88,14 @@ mod frame_gen;
mod hdr;
mod hdr_stats;
pub use hdr::Calibration as HdrCalibration;
pub mod block_light;
pub mod lightning;
mod lod;
mod native_material;
mod native_pack;
mod native_post;
pub mod native_render;
mod native_shadow;
mod native_sky;
mod occlusion;
mod oit;
@@ -411,6 +413,9 @@ pub struct Frame<'a> {
pub additive: &'a [Vertex],
/// Name tags over everything, without depth (`TEXT_SEE_THROUGH`).
pub see_through: &'a [Vertex],
/// Opaque entity quads only a native pack's shadow pass draws (the local player in first
/// person).
pub shadow_only: &'a [Vertex],
/// Camera-relative sun and moon, or the animated End flash (additive,
/// behind everything and after the resident End sky box).
pub celestial: &'a [Vertex],
@@ -480,6 +485,9 @@ pub struct ScreenshotRequest {
/// `GameRenderer.takeAutoScreenshot`: centre-crop the frame to a square
/// and average it down to this size. `None` saves the frame as drawn.
pub size: Option<[u32; 2]>,
/// F2's: drawn at the game's resolution (Resolution Scale above 100%) times
/// `Renderer::screenshot_quality`, not the window's.
pub scaled: bool,
}
pub struct Renderer {
instance: wgpu::Instance,
@@ -494,6 +502,9 @@ pub struct Renderer {
offscreen: Option<wgpu::Texture>,
/// Offscreen submissions still on the GPU: at most two frames run ahead.
in_flight: std::collections::VecDeque<wgpu::SubmissionIndex>,
/// Splitscreen: the caller waits for the GPU once every player has submitted
/// (`wait_in_flight`), so one player's work overlaps the GPU drawing another's.
pub paced_elsewhere: bool,
/// Which steps of the first frame on this device still go to the log
/// (see `startup_step`).
first_frame: FirstFrame,
@@ -592,6 +603,8 @@ pub struct Renderer {
screen_buffer: wgpu::Buffer,
screen_capacity: u64,
entity_group: wgpu::BindGroup,
/// The layout of `entity_group`, for the native shadow pass.
entity_bindings: wgpu::BindGroupLayout,
/// The entity resources with the hand's projection (`hand_globals`).
hand_group: wgpu::BindGroup,
hand_globals: wgpu::Buffer,
@@ -600,10 +613,14 @@ pub struct Renderer {
entity_batch: EntityBatch,
group: wgpu::BindGroup,
globals: wgpu::Buffer,
/// The resource packs' LabPBR maps (normals, specular), for native shader packs.
pbr_maps: Option<[wgpu::Texture; 2]>,
lightmap: wgpu::Texture,
/// The block sprites, [`SPRITE_SPLIT`] layers per array (the first is
/// The block sprites, `sprite_split` layers per array (the first is
/// `sprites` in terrain.wgsl, the others `sprites_1`...).
sprite_textures: Vec<wgpu::Texture>,
/// Block sprite layers per array on this device ([`layer_split`]).
sprite_split: u32,
/// A 1-layer array bound where a pack needs fewer arrays.
sprite_unused: wgpu::TextureView,
mip_levels: u32,
@@ -690,6 +707,9 @@ pub struct Renderer {
section_uploaded: crate::fast_hash::FxHashMap<SectionKey, std::time::Instant>,
/// Changes whenever `block_entities` does (unique across renderers).
block_entity_revision: u64,
/// The light sources of each section and their revision, for `block_light`.
light_blocks: crate::fast_hash::FxHashMap<SectionKey, Vec<([i32; 3], u32)>>,
light_revision: u64,
/// VisGraph face connectivity per meshed section (cave culling).
occlusion: occlusion::Occlusion,
/// The column `occlusion`, `sections` and `detail_columns` count in: the
@@ -751,6 +771,12 @@ pub struct Renderer {
pub screenshot: Option<ScreenshotRequest>,
/// Outcome of the last screenshot: frame size, or the error.
pub screenshot_result: Option<std::result::Result<[u32; 2], String>>,
/// Screenshot Quality (Horizon setting): a `scaled` screenshot's frame is this many
/// times the game's resolution (1, 2, 4 or 8).
pub screenshot_quality: u32,
/// How many times the pending scaled screenshot ran out of video memory: each
/// halves its size, down to the window's.
screenshot_halvings: u32,
/// Brightness option (lightmap gamma), 0..1.
pub gamma: f32,
/// Optional full-light lightmap (Horizon Extras).
@@ -765,18 +791,45 @@ pub struct Renderer {
}
/// Every layer's mip levels, layer major (`first` is the first layer's
/// index into `settings`), made by `MipmapGenerator`.
/// Block sprite layers per texture array: the smallest
/// `max_texture_array_layers` of the GPUs Horizon runs on. Mods add sprites
/// past it into more arrays (terrain.wgsl's `sprite`).
/// Block sprite layers per texture array at most: the
/// `max_texture_array_layers` of most GPUs. Mods add sprites past it into more
/// arrays (terrain.wgsl's `sprite`); a GPU with fewer layers per array gets a
/// smaller split ([`layer_split`]).
pub(crate) const SPRITE_SPLIT: u32 = 2048;
/// [`SPRITE_SPLIT`], or a smaller split from `HORIZON_SPRITE_SPLIT` (dev
/// only: vanilla's sprites drawn from several arrays must look the same).
fn sprite_split() -> u32 {
/// A smaller split from `HORIZON_SPRITE_SPLIT` (dev only: vanilla's sprites
/// drawn from several arrays must look the same).
fn requested_split() -> Option<u32> {
std::env::var("HORIZON_SPRITE_SPLIT")
.ok()
.and_then(|v| v.parse().ok())
.filter(|&n| (1..=SPRITE_SPLIT).contains(&n))
.unwrap_or(SPRITE_SPLIT)
}
/// The split the CPU mip chains are cut by before the adapter is known.
fn sprite_split() -> u32 {
requested_split().unwrap_or(SPRITE_SPLIT)
}
/// The block sprite layers per texture array on an adapter that holds
/// `max_layers` layers per array: [`SPRITE_SPLIT`], `requested`
/// (`HORIZON_SPRITE_SPLIT`) or `max_layers`, whichever is smallest, with a full
/// array still fitting once [`array_layers`] pads it for OpenGL. Errors when
/// `layers` sprites need more than the [`SPRITE_ARRAYS`] arrays terrain.wgsl binds.
fn layer_split(
max_layers: u32,
gl: bool,
requested: Option<u32>,
layers: u32,
) -> std::result::Result<u32, String> {
let mut split = requested.unwrap_or(SPRITE_SPLIT).min(max_layers).max(1);
if split > 1 && array_layers(split, gl) > max_layers {
split -= 1;
}
if layers > split * SPRITE_ARRAYS {
return Err(format!(
"GPU supports {max_layers} texture layers per array; {layers} block textures need \
more than the {SPRITE_ARRAYS} arrays of {split} Horizon draws from"
));
}
Ok(split)
}
/// Sprite arrays terrain.wgsl binds (`sprites`, `sprites_1`...).
const SPRITE_ARRAYS: u32 = 8;
@@ -879,7 +932,40 @@ fn terrain_group(
/// construction so the temporary mip chains are released after GPU upload.
pub struct PreparedTextures {
sprites: Vec<(Vec<u8>, u32)>,
/// The sprite layers per array `sprites` is cut by.
split: u32,
forcefield: (Vec<u8>, u32),
/// The LabPBR maps' mip chains (normals, specular), their levels, layers and size.
pbr: Option<(Vec<u8>, Vec<u8>, u32, u32, u32)>,
}
/// Every level of `layers` square RGBA layers of `size`, each the mean of four texels of the
/// one above, layer major: the LabPBR maps' mips (their channels are data, not colour).
fn box_mip_chain(pixels: &[u8], size: u32, layers: u32) -> Vec<u8> {
let layer_bytes = (size * size * 4) as usize;
let mut out = Vec::new();
for layer in pixels.chunks_exact(layer_bytes).take(layers as usize) {
let mut level = layer.to_vec();
let mut side = size as usize;
out.extend_from_slice(&level);
while side > 1 {
let half = side / 2;
let mut next = Vec::with_capacity(half * half * 4);
for y in 0..half {
for x in 0..half {
for c in 0..4 {
let at = |dx: usize, dy: usize| {
u32::from(level[((2 * y + dy) * side + 2 * x + dx) * 4 + c])
};
next.push(((at(0, 0) + at(1, 0) + at(0, 1) + at(1, 1) + 2) / 4) as u8);
}
}
}
out.extend_from_slice(&next);
level = next;
side = half;
}
}
out
}
impl PreparedTextures {
pub fn new(
@@ -910,9 +996,49 @@ impl PreparedTextures {
mip_chain(&pixels[start..], size, settings, first, count)
})
.collect(),
split,
forcefield: mip_chain(forcefield.0, forcefield.1, &[], 0, 1),
pbr: None,
}
}
/// With the resource packs' LabPBR `normals` and `specular` maps (`layers` layers of
/// `size`, as `RenderAssets` holds them; nothing when empty).
pub fn with_pbr(mut self, normals: &[u8], specular: &[u8], size: u32, layers: u32) -> Self {
if !normals.is_empty() {
self.pbr = Some((
box_mip_chain(normals, size, layers),
box_mip_chain(specular, size, layers),
size.trailing_zeros() + 1,
layers,
size,
));
}
self
}
/// The same mip chains cut by `split` layers per array: chains made before
/// the adapter was known, for a GPU with fewer layers per array.
fn resplit(mut self, layers: u32, split: u32) -> Self {
if self.split == split || layers <= split.min(self.split) {
self.split = split;
return self;
}
let levels = self.sprites[0].1;
let all: Vec<u8> = self
.sprites
.drain(..)
.flat_map(|(bytes, _)| bytes)
.collect();
// Every layer's mip levels are contiguous (layer major).
let layer_bytes = all.len() / layers as usize;
self.sprites = all
.chunks(layer_bytes * split as usize)
.map(|chunk| (chunk.to_vec(), levels))
.collect();
self.split = split;
self
}
}
fn mip_chain(
@@ -1081,16 +1207,13 @@ fn describe_adapter(adapter: &wgpu::Adapter) {
}
/// Whether an adapter's texture arrays hold the block textures Horizon puts
/// in one array.
fn check_layers(limits: &wgpu::Limits, layers: u32) -> Result<()> {
let needed = layers.min(sprite_split());
if limits.max_texture_array_layers < needed {
return Err(format!(
"GPU supports {} texture layers; {needed} required",
limits.max_texture_array_layers
)
.into());
}
Ok(())
fn check_layers(adapter: &wgpu::Adapter, layers: u32) -> Result<u32> {
Ok(layer_split(
adapter.limits().max_texture_array_layers,
adapter.get_info().backend == wgpu::Backend::Gl,
requested_split(),
layers,
)?)
}
/// Whether the swapchain can take a window of `width` x `height`: each side from 1 to the
/// device's `max_texture_dimension_2d` (`max`), else `Surface::configure` panics. 0 is a
@@ -1261,7 +1384,7 @@ async fn open_backend(
describe_adapter(&adapter);
let info = adapter.get_info();
let limits = adapter.limits();
if let Err(e) = check_layers(&limits, layers) {
if let Err(e) = check_layers(&adapter, layers) {
return Err(NoDevice::Failed(format!("{name}: {e}")));
}
// Mappable primary buffers: section pages that no longer fit in
@@ -1425,14 +1548,6 @@ impl Renderer {
previous: Option<&Self>,
api: GraphicsApi,
) -> Result<Self> {
let split = sprite_split();
if layers > split * SPRITE_ARRAYS {
return Err(format!(
"{layers} block textures; Horizon draws at most {}",
split * SPRITE_ARRAYS
)
.into());
}
let Opened {
instance,
surface,
@@ -1446,7 +1561,7 @@ impl Renderer {
// window.
Some(renderer) => {
describe_adapter(&renderer.adapter);
check_layers(&renderer.adapter.limits(), layers)?;
check_layers(&renderer.adapter, layers)?;
Opened {
surface: if offscreen {
None
@@ -1470,6 +1585,12 @@ impl Renderer {
}
None => open_device(&window, offscreen, api, layers).await?,
};
// Computed once: the sprite arrays, the shaders' constants and the
// shader pack atlas all cut the sprites by it.
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();
crate::graphics::suggest_reduced_far_detail(crate::graphics::lod_capped(&info));
let size = window.inner_size();
@@ -1847,11 +1968,53 @@ impl Renderer {
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let prepared = prepared.unwrap_or_else(|| {
PreparedTextures::new(sprite_pixels, sprite_size, layers, sprite_mips, forcefield)
});
let prepared = match prepared {
Some(prepared) => prepared.resplit(layers, split),
None => PreparedTextures::with_split(
sprite_pixels,
sprite_size,
layers,
sprite_mips,
forcefield,
split,
),
};
let mut mip_levels = 1;
let gl = adapter.get_info().backend == wgpu::Backend::Gl;
// The resource packs' LabPBR maps, for native shader packs: one array each, cut to
// the layers the GPU has (`block_normal_map` gives the default past them).
let pbr_maps = prepared
.pbr
.map(|(normals, specular, levels, layers, size)| {
let kept = layers.min(device.limits().max_texture_array_layers);
let count = array_layers(kept, gl);
[("LabPBR normals", normals), ("LabPBR specular", specular)].map(
|(label, mut data)| {
// The extra layers are empty.
let layer_bytes = data.len() / layers as usize;
data.resize(layer_bytes * count as usize, 0);
device.create_texture_with_data(
&queue,
&wgpu::TextureDescriptor {
label: Some(label),
size: wgpu::Extent3d {
width: size,
height: size,
depth_or_array_layers: count,
},
mip_level_count: levels,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
},
wgpu::util::TextureDataOrder::LayerMajor,
&data,
)
},
)
});
let sprite_textures: Vec<wgpu::Texture> = prepared
.sprites
.into_iter()
@@ -3018,6 +3181,7 @@ impl Renderer {
spare_surface: std::sync::Mutex::new(None),
offscreen,
in_flight: std::collections::VecDeque::new(),
paced_elsewhere: false,
first_frame: if previous.is_none() {
FirstFrame::Acquire
} else {
@@ -3088,6 +3252,7 @@ impl Renderer {
screen_buffer,
screen_capacity,
entity_group,
entity_bindings,
hand_group,
hand_globals,
entity_buffer,
@@ -3095,6 +3260,7 @@ impl Renderer {
entity_batch: EntityBatch::default(),
group,
globals,
pbr_maps,
lightmap,
sprite_textures,
sprite_unused,
@@ -3102,6 +3268,7 @@ impl Renderer {
mipmaps: mip_levels - 1,
terrain_layout: bindings,
terrain_sprites: sprite_constants.map(|(_, v)| v),
sprite_split: split,
sprite_sampler,
texture_filtering: 0,
max_anisotropy: 1,
@@ -3151,6 +3318,8 @@ impl Renderer {
block_entities: Default::default(),
section_uploaded: Default::default(),
block_entity_revision: next_block_entity_revision(),
light_blocks: Default::default(),
light_revision: 0,
occlusion: occlusion::Occlusion::new(crate::chunk::MIN_Y, crate::chunk::HEIGHT / 16),
layout: crate::chunk::ColumnLayout::VANILLA,
world_post,
@@ -3181,6 +3350,8 @@ impl Renderer {
started: Instant::now(),
screenshot: None,
screenshot_result: None,
screenshot_quality: 1,
screenshot_halvings: 0,
gamma: 0.5,
improved_transparency: false,
oit: None,
@@ -3397,6 +3568,11 @@ impl Renderer {
pub fn section_count(&self) -> usize {
self.sections.len()
}
/// Whether one of Horizon's own packs with `[shadow]` draws, whose shadow pass takes the
/// frame's `shadow_only` quads.
pub fn native_shadow(&self) -> bool {
self.native.as_ref().is_some_and(|n| n.shadow.is_some())
}
/// What a shader pack's shadow pass draws of the entities this frame: (entities or block
/// entities other than the player, so those Horizon's culling hides from the camera are
/// kept for it; the local player, so its model is built in first person too). (false,
@@ -3429,6 +3605,15 @@ impl Renderer {
if changed {
self.block_entity_revision = next_block_entity_revision();
}
let lights_changed = if mesh.lights.is_empty() {
self.light_blocks.remove(&key).is_some()
} else {
let old = self.light_blocks.insert(key, mesh.lights.clone());
old.as_deref() != Some(&mesh.lights[..])
};
if lights_changed {
self.light_revision += 1;
}
if !self.lod_retiring.is_empty() {
self.lod_retiring.remove(&key);
}
@@ -3552,7 +3737,8 @@ impl Renderer {
/// origins bound: one indirect multi-draw, or a loop without it.
/// Consecutive draws in one arena page share a vertex buffer binding,
/// keeping the list's order (translucent sections go back to front).
/// `indirect` holds the list's draws from `base`.
/// `indirect` holds the list's draws from `base`. Leaves the quad
/// indices bound, as the draws after it expect.
fn draw_sections(
&self,
pass: &mut wgpu::RenderPass<'_>,
@@ -3582,6 +3768,12 @@ impl Renderer {
}
i += run;
}
if pages[first as usize..end]
.iter()
.any(|(_, sorted)| sorted.is_some())
{
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
}
}
fn release_section(&mut self, key: SectionKey) {
if let Some(s) = self.sections.remove(key) {
@@ -3801,6 +3993,9 @@ impl Renderer {
for sy in 0..self.layout.sections() as i32 {
let key = (x, sy, z);
block_entities |= self.block_entities.remove(&key).is_some();
if self.light_blocks.remove(&key).is_some() {
self.light_revision += 1;
}
self.section_uploaded.remove(&key);
if retiring {
// Distant tiles still leave its column out: drawn until they don't.
@@ -4100,9 +4295,11 @@ impl Renderer {
horizon_shader_api::Report,
Vec<(String, f64)>,
Vec<(u32, u32)>,
Vec<(u32, u32)>,
block_light::LightTable,
)>,
) -> std::result::Result<Vec<String>, String> {
let Some((report, constants, sprite_tags)) = pack else {
let Some((report, constants, sprite_tags, sprite_materials, lights)) = pack else {
self.native = None;
return Ok(Vec::new());
};
@@ -4112,9 +4309,19 @@ impl Renderer {
sprites: self.terrain_sprites,
format: self.config.format.remove_srgb_suffix(),
sprite_tags: &sprite_tags,
sprite_materials: &sprite_materials,
entities: &self.entity_bindings,
weather_groups: [&self.weather.entity_layout, &self.weather.time_layout],
pbr: self.pbr_maps.as_ref(),
};
let (pack, labels) =
native_pack::NativePack::new(&self.device, &terrain, report, &constants)?;
let (pack, labels) = native_pack::NativePack::new(
&self.device,
&self.queue,
&terrain,
report,
&constants,
lights,
)?;
self.native = pack.draws().then_some(pack);
if self.native.is_some() {
self.pack_post = None;
@@ -4164,7 +4371,7 @@ impl Renderer {
&self.device,
&mut encoder,
&self.sprite_textures,
sprite_split(),
self.sprite_split,
layers,
self.sprite_textures[0].width(),
);
@@ -4387,7 +4594,11 @@ impl Renderer {
}
}
pub fn render(&mut self, frame: &Frame) -> Result<bool> {
let rendered = self.draw_frame(frame);
let scaled = self.screenshot_size();
let rendered = match scaled {
Some(size) => self.draw_scaled_frame(frame, size),
None => self.draw_frame(frame, None),
};
// A frame that was not drawn still submits the section copies and
// atlas patches (glyphs, skins, maps), which the caller already took.
if !matches!(rendered, Ok(true)) && !frame.hud.patches.is_empty() {
@@ -4410,9 +4621,74 @@ impl Renderer {
} else if let Some(copies) = self.take_section_copies() {
self.queue.submit([copies]);
}
rendered
// A scaled screenshot's frame never reached the window.
rendered.map(|drawn| drawn && scaled.is_none())
}
fn draw_frame(&mut self, frame: &Frame) -> Result<bool> {
/// The frame size of the pending `scaled` screenshot when it is larger than the
/// window: the window's times the Resolution Scale (when above 100%) and
/// `screenshot_quality`, cut down to what one texture and one readback buffer hold,
/// and halved for each time it ran out of video memory.
fn screenshot_size(&self) -> Option<[u32; 2]> {
let request = self.screenshot.as_ref()?;
if !request.scaled || request.size.is_some() || self.suspended {
return None;
}
let window = [self.config.width, self.config.height];
let limits = self.device.limits();
let factor = scaled_screenshot_factor(
window,
self.world_post.scale,
self.screenshot_quality,
limits.max_texture_dimension_2d,
limits.max_buffer_size,
) / 2f32.powi(self.screenshot_halvings as i32);
(factor > 1.0).then(|| window.map(|side| (side as f32 * factor) as u32))
}
/// Draws one frame at `size` into a texture of its own, for the pending screenshot:
/// every pass sizes itself by `config`, which says `size` for this frame, the HUD
/// keeps its layout at a larger scale, and the world is drawn at `size` (a Resolution
/// Scale below 100% stays as low). The window keeps its last frame.
fn draw_scaled_frame(&mut self, frame: &Frame, size: [u32; 2]) -> Result<bool> {
let window = [self.config.width, self.config.height];
let mut config = self.config.clone();
[config.width, config.height] = size;
config.usage |= wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC;
// Out of video memory (an invalid texture's view is a validation error):
// tried again at half the size next frame; the window keeps its last one.
let checks = GpuChecks::push(&self.device);
let target = Self::offscreen_target(&self.device, &config);
let depth = Self::depth(&self.device, &config);
if let Some(e) = checks.pop() {
log::warn!(
"Horizon renderer: no memory for a {}x{} screenshot ({e}); trying half the size",
size[0],
size[1]
);
self.screenshot_halvings += 1;
return Ok(false);
}
let scale = self.world_post.scale;
self.world_post.scale = scale / scale.max(1.0);
self.hud.zoom = size[0] as f32 / window[0] as f32;
[self.config.width, self.config.height] = size;
let surface = self.surface.take();
// HDR presents to the swapchain; this frame goes to a file.
let hdr = self.hdr.take();
let offscreen = self.offscreen.replace(target);
let window_depth = std::mem::replace(&mut self.depth, depth);
// The frame's own targets at `size` can run out of memory too.
let drawn = self.draw_frame(frame, Some(GpuChecks::push(&self.device)));
self.depth = window_depth;
self.offscreen = offscreen;
self.hdr = hdr;
self.surface = surface;
[self.config.width, self.config.height] = window;
self.hud.zoom = 1.0;
self.world_post.scale = scale;
drawn
}
/// `checks`: the error scopes of a scaled screenshot's frame, checked before its PNG is written.
fn draw_frame(&mut self, frame: &Frame, checks: Option<GpuChecks>) -> Result<bool> {
self.arena.trim();
self.compact_arena();
self.sorted_indices.trim();
@@ -4767,7 +5043,13 @@ impl Renderer {
.as_ref()
.and_then(|p| p.1.as_ref())
.filter(|_| !frame.title)
.and_then(|p| p.shadow_view(frame));
.and_then(|p| p.shadow_view(frame))
.or_else(|| {
// One of Horizon's own packs with `[shadow]` uses the same lists.
let native = self.native.as_ref().filter(|_| !frame.title)?;
native.shadow.as_ref()?;
native.frame.shadow_view(frame)
});
let mut shadow_visible = std::mem::take(&mut self.shadow_visible);
shadow_visible.clear();
if let Some(v) = &shadow_view {
@@ -5308,6 +5590,7 @@ impl Renderer {
frame.see_through,
oit_on,
);
self.entity_batch.append_shadow_only(frame.shadow_only);
let weather_quads = frame.weather.len() as u32 / 4;
let world_quads = entity_quads
+ text_quads
@@ -5322,7 +5605,8 @@ impl Renderer {
// Where the tail of materials the classic and the improved passes
// both draw ends: the shadows, particles and items count back from it.
let base_quads = world_quads - oit_entity_quads;
let cloud_quads = if let Some(input) = &frame.clouds {
let native_clouds = self.native.as_ref().is_none_or(|n| n.vanilla_clouds);
let cloud_quads = if let Some(input) = frame.clouds.as_ref().filter(|_| native_clouds) {
let (rebuilt, uniform) =
crate::spans::time("clouds", || self.cloud_mesh.prepare(input));
let vertices = self.cloud_mesh.vertices.len() as u64;
@@ -5364,6 +5648,7 @@ impl Renderer {
let hand_quads = (frame.hand.len() / 4) as u32;
let additive_quads = (frame.additive.len() / 4) as u32 - self.entity_batch.moved_additive;
let see_through_quads = (frame.see_through.len() / 4) as u32;
let shadow_only_quads = (frame.shadow_only.len() / 4) as u32;
let entity_vertices = all_entities.len() as u64;
if entity_vertices > self.entity_capacity {
self.entity_capacity = entity_vertices.next_power_of_two();
@@ -5409,15 +5694,16 @@ impl Renderer {
bytemuck::cast_slice(&frame.celestial[..celestial_vertices]),
);
}
let max_quads = (world_quads + additive_quads + hand_quads + see_through_quads)
.max(cloud_quads)
.max(if frame.lightning.bolts.is_empty() {
0
} else {
lightning::QUADS
})
.max(weather_quads)
.max(frame.glowing.len() as u32 / 4);
let max_quads =
(world_quads + additive_quads + hand_quads + see_through_quads + shadow_only_quads)
.max(cloud_quads)
.max(if frame.lightning.bolts.is_empty() {
0
} else {
lightning::QUADS
})
.max(weather_quads)
.max(frame.glowing.len() as u32 / 4);
if max_quads > self.index_quads {
self.index_quads = max_quads.next_power_of_two();
self.indices = Self::quad_indices(&self.device, self.index_quads);
@@ -5702,12 +5988,97 @@ impl Renderer {
.filter(|_| scaled.is_some())
.unwrap_or([self.config.width, self.config.height]);
native.frame.write(&self.queue, frame, size);
native.frame.block_light.update(
&self.queue,
frame.camera,
&self.light_blocks,
self.light_revision,
);
if let Some(t) = &mut native.timing {
t.begin(&self.device, &self.queue);
native_sky_pass =
native.sky.is_some() && t.recording() && sky_visible && frame.skybox;
}
}
// One of Horizon's own packs with `[shadow]`: the solid terrain the light sees, from the
// shadow camera's lists, into the pack's shadow map (a pack without one gets its 1×1 map
// cleared to "far" once).
if !frame.title
&& let Some(native) = &mut self.native
{
native.frame.shadow_map.clear_blank(&mut encoder);
}
if !frame.title
&& shadow_view.is_some()
&& let Some(native) = &self.native
&& let Some(shadow) = &native.shadow
{
let mut pass = native
.frame
.shadow_map
.begin(&mut encoder, "Native shader pack shadow");
pass.set_bind_group(0, &self.group, &[]);
pass.set_bind_group(horizon_shader_api::stages::GROUP, &shadow.group, &[]);
if flat_frame
&& let Some(flat) = &self.flat
&& let Some(pipeline) = &shadow.flat
{
pass.set_pipeline(pipeline);
let [first, translucent] = flat.shadow_calls;
flat.draw(&mut pass, &flat.calls[first..translucent], |p| {
self.arena.buffer(p)
});
} else if !flat_frame {
pass.set_pipeline(&shadow.solid);
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
pass.set_vertex_buffer(1, self.instances.slice(..));
self.draw_sections(&mut pass, &self.indirect, 0, shadow_first, shadow_solid);
}
drop(pass);
let mut pass = native
.frame
.shadow_map
.begin_translucent(&mut encoder, "Native shader pack translucent shadow");
pass.set_bind_group(0, &self.group, &[]);
pass.set_bind_group(horizon_shader_api::stages::GROUP, &shadow.group, &[]);
if flat_frame
&& let Some(flat) = &self.flat
&& let Some(pipeline) = &shadow.flat_translucent
{
pass.set_pipeline(pipeline);
let [_, translucent] = flat.shadow_calls;
flat.draw(&mut pass, &flat.calls[translucent..], |p| {
self.arena.buffer(p)
});
} else if !flat_frame {
pass.set_pipeline(&shadow.translucent);
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
pass.set_vertex_buffer(1, self.instances.slice(..));
self.draw_sections(
&mut pass,
&self.indirect,
0,
shadow_first + shadow_solid,
shadow_translucent,
);
}
drop(pass);
if entity_quads > 0 || shadow_only_quads > 0 {
let mut pass = native
.frame
.shadow_map
.begin_entities(&mut encoder, "Native shader pack entity shadow");
pass.set_pipeline(&shadow.entity);
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_bind_group(horizon_shader_api::stages::GROUP, &shadow.group, &[]);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
pass.draw_indexed(0..entity_quads * 6, 0, 0..1);
// At the end of the buffer (`EntityBatch::append_shadow_only`).
let first = entity_vertices as u32 - shadow_only_quads * 4;
pass.draw_indexed(0..shadow_only_quads * 6, first as i32, 0..1);
}
}
if native_sky_pass
&& let Some(native) = &mut self.native
&& let Some(sky) = &native.sky
@@ -5940,12 +6311,24 @@ impl Renderer {
self.end_sky.draw(&mut pass);
}
if celestial_vertices > 0 && !pack_draws_celestial {
// The Overworld's bodies start with the sun's quad
// (`Game::celestial_geometry`): a pack sky drawing its own skips it.
let own_sun = !frame.end_sky
&& self
.native
.as_ref()
.is_some_and(|n| n.sky.is_some() && !n.vanilla_sun);
let first = if own_sun { 6 } else { 0 };
pass.set_vertex_buffer(0, self.celestial_buffer.slice(..));
pass.draw_indexed(0..(celestial_vertices as u32 / 4) * 6, 0, 0..1);
pass.draw_indexed(first..(celestial_vertices as u32 / 4) * 6, 0, 0..1);
}
pass.set_bind_group(0, &self.group, &[]);
}
if !frame.title && frame.stars.is_some() && !pack_draws_sky {
let own_stars = self
.native
.as_ref()
.is_some_and(|n| n.sky.is_some() && !n.vanilla_stars);
if !frame.title && frame.stars.is_some() && !pack_draws_sky && !own_stars {
self.stars.draw(&mut pass);
pass.set_bind_group(0, &self.group, &[]);
}
@@ -6429,7 +6812,13 @@ impl Renderer {
// terrain and clouds, so the water behind the rain is drawn first.
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
if !pack_draws_weather && !oit_on {
self.weather.draw(&mut pass, &self.entity_group);
let native_weather = self
.native
.as_ref()
.filter(|_| !frame.title)
.and_then(|n| Some((n.weather.as_ref()?, &n.frame.group)));
self.weather
.draw(&mut pass, &self.entity_group, native_weather);
pass.set_bind_group(0, &self.group, &[]);
}
// LevelRenderer: the world border after translucent terrain and weather.
@@ -6832,21 +7221,34 @@ impl Renderer {
if let Some(Some(timer)) = Self::pass_timer(&mut self.pack_post, &mut self.native) {
timer.submitted(&self.queue);
}
let failed = checks.and_then(GpuChecks::pop);
if let Some((request, buffer)) = readback {
if let Some(hdr_buffer) = hdr_readback {
let hdr_path = hdr_screenshot_path(&request.path);
match self.write_hdr_screenshot(&hdr_path, &hdr_buffer) {
Ok(()) => log::info!("Saved HDR screenshot to {}", hdr_path.display()),
Err(e) => log::warn!(
"Horizon renderer: HDR screenshot {}: {e}",
hdr_path.display()
),
// Out of memory on the way: the PNG would be black; taken again at half the size.
if let Some(e) = failed {
log::warn!(
"Horizon renderer: a {}x{} screenshot failed ({e}); trying half the size",
self.config.width,
self.config.height
);
self.screenshot_halvings += 1;
self.screenshot = Some(request);
} else {
self.screenshot_halvings = 0;
if let Some(hdr_buffer) = hdr_readback {
let hdr_path = hdr_screenshot_path(&request.path);
match self.write_hdr_screenshot(&hdr_path, &hdr_buffer) {
Ok(()) => log::info!("Saved HDR screenshot to {}", hdr_path.display()),
Err(e) => log::warn!(
"Horizon renderer: HDR screenshot {}: {e}",
hdr_path.display()
),
}
}
self.screenshot_result = Some(
self.write_screenshot(&request, &buffer)
.map_err(|e| e.to_string()),
);
}
self.screenshot_result = Some(
self.write_screenshot(&request, &buffer)
.map_err(|e| e.to_string()),
);
}
let present = Instant::now();
if let Some(surface_frame) = surface_frame {
@@ -6870,14 +7272,9 @@ impl Renderer {
self.configure_surface();
}
} else {
// No swapchain paces offscreen frames: keep two on the GPU at most.
self.in_flight.push_back(submission);
while self.in_flight.len() > 2 {
let index = self.in_flight.pop_front();
let _ = self.device.poll(wgpu::PollType::Wait {
submission_index: index,
timeout: Some(std::time::Duration::from_secs(5)),
});
if !self.paced_elsewhere {
self.wait_in_flight();
}
}
self.last_wait += present.elapsed();
@@ -6889,6 +7286,41 @@ impl Renderer {
Ok(true)
}
}
/// Out-of-memory and validation error scopes, popped together: the first error either caught.
struct GpuChecks {
memory: wgpu::ErrorScopeGuard,
validation: wgpu::ErrorScopeGuard,
}
impl GpuChecks {
fn push(device: &wgpu::Device) -> Self {
let memory = device.push_error_scope(wgpu::ErrorFilter::OutOfMemory);
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
Self { memory, validation }
}
fn pop(self) -> Option<wgpu::Error> {
let validation = pollster::block_on(self.validation.pop());
let memory = pollster::block_on(self.memory.pop());
memory.or(validation)
}
}
/// How many times the window's size a scaled screenshot is: the Resolution Scale
/// `world_scale` when above 1, times `quality`, as far as a side fits in
/// `max_side` pixels and the readback in `max_buffer` bytes.
fn scaled_screenshot_factor(
[width, height]: [u32; 2],
world_scale: f32,
quality: u32,
max_side: u32,
max_buffer: u64,
) -> f32 {
let wanted = world_scale.max(1.0) * quality.max(1) as f32;
let (w, h) = (f64::from(width.max(1)), f64::from(height.max(1)));
let side = f64::from(max_side) / w.max(h);
// padded_row(w * f) * h * f <= (4 * w * f + 256) * h * f <= max_buffer.
let (a, b) = (4.0 * w * h, 256.0 * h);
let buffer = ((b * b + 4.0 * a * max_buffer as f64).sqrt() - b) / (2.0 * a);
wanted.min(side.min(buffer) as f32)
}
/// Bytes per row of a `width`-pixel frame read back at 4 bytes a pixel.
fn padded_row(width: u32) -> u32 {
(width * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT)
@@ -6925,6 +7357,16 @@ pub(crate) fn copy_frame(
buffer
}
impl Renderer {
/// No swapchain paces offscreen frames: keep two on the GPU at most.
pub fn wait_in_flight(&mut self) {
while self.in_flight.len() > 2 {
let index = self.in_flight.pop_front();
let _ = self.device.poll(wgpu::PollType::Wait {
submission_index: index,
timeout: Some(std::time::Duration::from_secs(5)),
});
}
}
/// Take a screenshot request: queue the frame's copy, or report why the
/// frame cannot be read back.
fn take_readback(
@@ -7297,6 +7739,30 @@ mod tests {
);
}
#[test]
fn scaled_screenshots_follow_resolution_scale_and_quality_within_limits() {
use super::scaled_screenshot_factor as factor;
let big = u64::MAX / 2;
// The window's size at 100% and 1x; Resolution Scale below 100% stays the window's.
assert_eq!(factor([1920, 1080], 1.0, 1, 16384, big), 1.0);
assert_eq!(factor([1920, 1080], 0.5, 1, 16384, big), 1.0);
// The game's resolution, times the quality.
assert_eq!(factor([1920, 1080], 2.0, 1, 16384, big), 2.0);
assert_eq!(factor([1920, 1080], 1.5, 4, 16384, big), 6.0);
assert_eq!(factor([1920, 1080], 0.5, 8, 16384, big), 8.0);
// Cut to the largest texture side: 3840 * 4.266 = 16384.
let f = factor([3840, 2160], 1.0, 8, 16384, big);
assert!(
(3840.0 * f) as u32 <= 16384 && (3840.0 * f) as u32 > 16300,
"{f}"
);
// Cut to the largest buffer: 256 MiB holds about 3.8x of 1080p's readback.
let max = 256 << 20;
let f = factor([1920, 1080], 1.0, 8, 16384, max);
let [w, h] = [1920, 1080].map(|s| (s as f32 * f) as u32);
assert!(u64::from(super::padded_row(w)) * u64::from(h) <= max, "{f}");
assert!(f > 3.7, "{f}");
}
#[test]
fn drawable_size_skips_minimized_and_oversized_windows() {
use super::drawable_size;
assert!(drawable_size(1280, 720, 8192));
@@ -7905,9 +8371,52 @@ mod sprite_upload_tests {
.collect();
assert_eq!(actual, expected.0);
assert_eq!(prepared.forcefield, mip_chain(&border, size, &[], 0, 1));
// Chains cut before the adapter was known, cut again for it.
for to in [1, 2, 3, 5, 8] {
let direct = PreparedTextures::with_split(
&pixels,
size,
layers,
&settings,
(&border, size),
to,
);
let resplit = PreparedTextures::with_split(
&pixels,
size,
layers,
&settings,
(&border, size),
split,
)
.resplit(layers, to);
assert_eq!(resplit.split, to);
assert_eq!(resplit.sprites, direct.sprites, "{split} -> {to}");
}
}
}
#[test]
fn sprite_split_follows_the_adapters_layer_limit() {
// The Discord report: 512 layers per array, 1291 block textures (3 arrays).
assert_eq!(layer_split(512, false, None, 1291), Ok(512));
assert_eq!(layer_split(512, true, None, 1291), Ok(512));
assert_eq!(layer_split(2048, false, None, 1291), Ok(SPRITE_SPLIT));
assert_eq!(layer_split(8192, false, None, 1291), Ok(SPRITE_SPLIT));
// HORIZON_SPRITE_SPLIT only lowers it.
assert_eq!(layer_split(2048, false, Some(512), 1291), Ok(512));
assert_eq!(layer_split(256, false, Some(512), 1291), Ok(256));
// OpenGL pads a multiple of 6 by a layer: a full array must still fit.
assert_eq!(layer_split(600, true, None, 1291), Ok(599));
assert_eq!(layer_split(600, false, None, 1291), Ok(600));
assert_eq!(array_layers(599, true), 599);
// Past the arrays terrain.wgsl binds.
assert_eq!(layer_split(256, false, None, 256 * SPRITE_ARRAYS), Ok(256));
let err = layer_split(128, false, None, 1291).unwrap_err();
assert!(err.contains("128 texture layers per array"), "{err}");
assert!(layer_split(512, false, None, 512 * SPRITE_ARRAYS + 1).is_err());
}
#[test]
fn mip_chain_allocates_exactly_its_size() {
for size in [16, 64] {
@@ -0,0 +1,224 @@
//! The colour of block light for Horizon's shader packs (`[[lights]]`, docs/api/shaders.md):
//! a grid of cells around the camera, each holding the colour of the light sources that
//! reach it, averaged by how near each is, which the material stage reads through the
//! prelude's `block_light_color`. Block light keeps its own brightness (the light engine's,
//! which walls stop): the grid only tints it, so it needs no occlusion of its own.
//!
//! The grid is rebuilt on the CPU when the camera moves on to another 16-block step or the
//! light sources change, from the sources the section meshes list (`SectionMesh::lights`).
use crate::fast_hash::FxHashMap;
use glam::{DVec3, Vec3};
use super::SectionKey;
/// Cells along each side of the grid.
pub const SIZE: u32 = 64;
/// The side of a cell, in blocks: the grid spans 128 blocks.
pub const CELL: f32 = 2.0;
/// The grid moves in steps of this many blocks.
const STEP: i32 = 16;
/// The most sources one rebuild splats, the nearest first (a lava lake lists thousands).
const MAX_SOURCES: usize = 2048;
/// `[[lights]]` colours go up to 4; the cells keep them over 4 in 8 bits.
const COLOR_RANGE: f32 = 4.0;
/// The light a block state gives off: its `[[lights]]` colour and its level, 1 to 15.
pub type LightTable = FxHashMap<u32, ([f32; 3], u8)>;
pub struct BlockLight {
texture: wgpu::Texture,
pub view: wgpu::TextureView,
/// `block_light_grid`: the grid's corner, camera-relative, and the side of a cell.
pub grid: wgpu::Buffer,
table: LightTable,
/// The world corner and the sources' revision the texture was last built for.
built: Option<([i32; 3], u64)>,
}
impl BlockLight {
pub fn new(device: &wgpu::Device, table: LightTable) -> Self {
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Native shader pack block light colours"),
size: wgpu::Extent3d {
width: SIZE,
height: SIZE,
depth_or_array_layers: SIZE,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D3,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
let grid = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Native shader pack block light grid"),
size: 16,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
Self {
view: texture.create_view(&Default::default()),
texture,
grid,
table,
built: None,
}
}
/// Places the grid around `camera`, rebuilding it when it moved or `revision` (of
/// `sections`' sources) changed.
pub fn update(
&mut self,
queue: &wgpu::Queue,
camera: DVec3,
sections: &FxHashMap<SectionKey, Vec<([i32; 3], u32)>>,
revision: u64,
) {
let half = (SIZE as f32 * CELL / 2.0) as f64;
let origin = ((camera - half) / f64::from(STEP))
.floor()
.as_ivec3()
.to_array()
.map(|v| v * STEP);
let corner = (glam::IVec3::from(origin).as_dvec3() - camera).as_vec3();
queue.write_buffer(
&self.grid,
0,
bytemuck::cast_slice(&[corner.x, corner.y, corner.z, CELL]),
);
if self.built == Some((origin, revision)) {
return;
}
self.built = Some((origin, revision));
let cells = build(&self.table, origin, camera, sections);
queue.write_texture(
self.texture.as_image_copy(),
&cells,
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(SIZE * 4),
rows_per_image: Some(SIZE),
},
self.texture.size(),
);
}
}
/// The cells of the grid with its corner at `origin`: RGBA8, x fastest, then y, then z.
fn build(
table: &LightTable,
origin: [i32; 3],
camera: DVec3,
sections: &FxHashMap<SectionKey, Vec<([i32; 3], u32)>>,
) -> Vec<u8> {
let n = SIZE as usize;
let mut cells = vec![0u8; n * n * n * 4];
if table.is_empty() {
return cells;
}
let span = SIZE as i32 * CELL as i32;
// Sections the grid or the light reaching into it (15 blocks) touches.
let lo = origin.map(|v| (v - 15).div_euclid(16));
let hi = origin.map(|v| (v + span + 15).div_euclid(16));
let mut sources: Vec<(Vec3, [f32; 3], f32, f64)> = Vec::new();
for (key, lights) in sections {
let key = [key.0, key.1 + crate::chunk::MIN_Y.div_euclid(16), key.2];
if (0..3).any(|i| key[i] < lo[i] || key[i] > hi[i]) {
continue;
}
for &(pos, state) in lights {
let Some(&(color, level)) = table.get(&state) else {
continue;
};
let center = glam::IVec3::from(pos).as_dvec3() + 0.5;
let local = (center - glam::IVec3::from(origin).as_dvec3()).as_vec3();
sources.push((
local,
color,
f32::from(level),
center.distance_squared(camera),
));
}
}
if sources.len() > MAX_SOURCES {
sources.select_nth_unstable_by(MAX_SOURCES, |a, b| a.3.total_cmp(&b.3));
sources.truncate(MAX_SOURCES);
}
let mut sum = vec![[0.0f32; 4]; n * n * n];
for (at, color, reach, _) in sources {
let first = ((at - reach) / CELL).floor().max(Vec3::ZERO).as_ivec3();
let last = ((at + reach) / CELL)
.ceil()
.min(Vec3::splat(SIZE as f32 - 1.0))
.as_ivec3();
for z in first.z..=last.z {
for y in first.y..=last.y {
for x in first.x..=last.x {
let cell = (Vec3::new(x as f32, y as f32, z as f32) + 0.5) * CELL;
let d = cell.distance(at);
if d >= reach {
continue;
}
// As block light falls off: strongest at the source, gone at its level.
let w = (1.0 - d / reach).powi(2);
let s = &mut sum[x as usize + n * (y as usize + n * z as usize)];
s[0] += color[0] * w;
s[1] += color[1] * w;
s[2] += color[2] * w;
s[3] += w;
}
}
}
}
for (cell, s) in cells.as_chunks_mut::<4>().0.iter_mut().zip(&sum) {
if s[3] <= 0.0 {
continue;
}
let byte = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
for i in 0..3 {
cell[i] = byte(s[i] / s[3] / COLOR_RANGE);
}
cell[3] = byte(s[3]);
}
cells
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_source_tints_the_cells_it_reaches_with_its_colour() {
let mut table = LightTable::default();
table.insert(7, ([2.0, 1.0, 0.0], 14));
let mut sections = FxHashMap::default();
// A torch at world (8, 64, 8): section y = (64 - MIN_Y) / 16.
let sy = (64 - crate::chunk::MIN_Y) / 16;
sections.insert((0, sy, 0), vec![([8, 64, 8], 7)]);
let camera = DVec3::new(8.0, 64.0, 8.0);
let origin = [-64, 0, -64];
let cells = build(&table, origin, camera, &sections);
let n = SIZE as usize;
let at = |p: [i32; 3]| {
let c = p
.iter()
.zip(origin)
.map(|(v, o)| ((v - o) as f32 / CELL) as usize)
.collect::<Vec<_>>();
let i = (c[0] + n * (c[1] + n * c[2])) * 4;
[cells[i], cells[i + 1], cells[i + 2], cells[i + 3]]
};
let near = at([8, 64, 8]);
// The colour over 4, whatever the weight: (2, 1, 0) / 4.
assert_eq!(&near[..3], &[128, 64, 0]);
assert!(near[3] > 150);
assert_eq!(at([8, 64, 40]), [0; 4], "past its level, nothing");
assert!(
build(&LightTable::default(), origin, camera, &sections)
.iter()
.all(|&b| b == 0)
);
}
}
@@ -79,6 +79,11 @@ pub(super) struct EntityBatch {
}
impl EntityBatch {
/// After `update`: quads only a native pack's shadow pass draws, at the buffer's end.
pub(super) fn append_shadow_only(&mut self, shadow_only: &[Vertex]) {
self.combined.extend_from_slice(shadow_only);
}
pub(super) fn update(
&mut self,
entities: &[Vertex],
@@ -18,6 +18,14 @@ const PREFIX: &str = "horizon_terrain_";
/// The entries around the pack's `material`: vanilla's vertex work without the lightmap
/// product, then the material, the alpha test and fog as `fs_solid` and `fs_translucent` do.
const ENTRIES: &str = "
// The manifest's `[material] fog`: false when the pack fogs its surfaces itself.
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);
}
return color;
}
struct HorizonMaterialOut {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
@@ -79,26 +87,52 @@ fn horizon_material_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color
return horizon_material_vertex(position, horizon_terrain_terrain_uv(uv, origin.w), layer,
color, light, origin.xyz);
}
// The prelude's `block_normal_map` and `block_specular_map`: the resource packs' LabPBR maps
// in the pack's frame group (3), one array each, a blank 1x1 without them.
@group(3) @binding(12) var horizon_pbr_normals: texture_2d_array<f32>;
@group(3) @binding(13) var horizon_pbr_specular: texture_2d_array<f32>;
fn horizon_block_pbr_present() -> bool {
return textureDimensions(horizon_pbr_normals).x > 1u;
}
fn horizon_block_pbr_size() -> vec2<f32> {
return vec2<f32>(textureDimensions(horizon_pbr_normals).xy);
}
fn horizon_block_pbr(uv: vec2<f32>, layer: u32, lod: f32, map: u32) -> vec4<f32> {
if !horizon_block_pbr_present() || layer >= textureNumLayers(horizon_pbr_normals) {
return select(vec4<f32>(0.5, 0.5, 1.0, 1.0), vec4<f32>(0.0), map == 1u);
}
let s = horizon_terrain_sprite_sampler;
if map == 0u {
return textureSampleLevel(horizon_pbr_normals, s, uv, i32(layer), lod);
}
return textureSampleLevel(horizon_pbr_specular, s, uv, i32(layer), lod);
}
fn horizon_material_color(in: HorizonMaterialOut, translucent: bool) -> vec4<f32> {
let albedo = horizon_terrain_sprite(in.uv, in.layer & 0x7fffu);
horizon_block_layer = in.layer & 0x7fffu;
// The mip level of this point in the maps: their own texels, as the sampler would pick.
let texels = in.uv * horizon_block_pbr_size();
let footprint = max(dot(dpdx(texels), dpdx(texels)), dot(dpdy(texels), dpdy(texels)));
horizon_block_lod = max(0.5 * log2(max(footprint, 1e-8)), 0.0);
// Water draws translucent and lava solid, so the pass resolves the fluid kind.
let fluid = select(0u, select(2u, 1u, translucent), in.fluid_quad != 0u);
let normal = horizon_material_normal(in.position);
let tangent = horizon_material_tangent(in.position, in.uv, normal);
return material(MaterialInput(albedo, in.tint, in.light, in.lightmap, in.position, in.uv,
translucent, horizon_sprite_tags(in.layer & 0x7fffu), in.clip.xy / frame.resolution,
translucent, horizon_sprite_tags(in.layer & 0x7fffu),
horizon_sprite_material(in.layer & 0x7fffu), in.clip.xy / frame.resolution,
in.face, fluid, in.emission, normal, tangent)).color;
}
@fragment fn horizon_material_fs_solid(in: HorizonMaterialOut) -> @location(0) vec4<f32> {
let color = horizon_material_color(in, false);
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
if color.a < 0.5 && (in.layer & 0x8000u) == 0u { discard; }
return horizon_terrain_apply_fog(vec4(color.rgb, 1.0), in.spherical, in.cylindrical);
return horizon_material_fogged(vec4(color.rgb, 1.0), in.spherical, in.cylindrical);
}
@fragment fn horizon_material_fs_translucent(in: HorizonMaterialOut) -> @location(0) vec4<f32> {
let color = horizon_material_color(in, true);
if color.a < 0.01 { discard; }
return horizon_terrain_apply_fog(color, in.spherical, in.cylindrical);
return horizon_material_fogged(color, in.spherical, in.cylindrical);
}
";
@@ -115,6 +149,16 @@ pub struct TerrainShape<'a> {
/// The pack's tags of each tagged sprite layer, as `(layer, bits)`
/// (`Manifest::sprite_tags`).
pub sprite_tags: &'a [(u32, u32)],
/// The pack's material id of each sprite layer with one, as `(layer, id)`
/// (`Manifest::sprite_materials`).
pub sprite_materials: &'a [(u32, u32)],
/// The entities' group 0 (their atlas), for the shadow pass's entity draw.
pub entities: &'a wgpu::BindGroupLayout,
/// The weather's groups 0 and 1 (the entities' atlas, the fall clock), for the weather
/// stage.
pub weather_groups: [&'a wgpu::BindGroupLayout; 2],
/// The resource packs' LabPBR maps (normals, specular), when they ship any.
pub pbr: Option<&'a [wgpu::Texture; 2]>,
}
/// The pack's terrain pipelines: solid (with the cutout test) and translucent, for the
@@ -134,12 +178,14 @@ fn vanilla_terrain() -> String {
)
}
/// `horizon_sprite_tags(layer)`: the bits of `MaterialInput.tags` of each sprite layer.
fn sprite_tags(tags: &[(u32, u32)]) -> String {
let mut out = String::from("fn horizon_sprite_tags(layer: u32) -> u32 {\n switch layer {\n");
let mut tags = tags.to_vec();
tags.sort_unstable_by_key(|&(layer, bits)| (bits, layer));
for group in tags.chunk_by(|a, b| a.1 == b.1) {
/// The function `name(layer)`, the value of each sprite layer in `table` and 0 for the rest:
/// `horizon_sprite_tags` (`MaterialInput.tags`) and `horizon_sprite_material`
/// (`MaterialInput.material`).
fn layer_table(name: &str, table: &[(u32, u32)]) -> String {
let mut out = format!("fn {name}(layer: u32) -> u32 {{\n switch layer {{\n");
let mut table = table.to_vec();
table.sort_unstable_by_key(|&(layer, value)| (value, layer));
for group in table.chunk_by(|a, b| a.1 == b.1) {
let layers: Vec<String> = group.iter().map(|(layer, _)| format!("{layer}u")).collect();
out += &format!(
" case {}: {{ return {}u; }}\n",
@@ -150,18 +196,24 @@ fn sprite_tags(tags: &[(u32, u32)]) -> String {
out + " default: { return 0u; }\n }\n}\n"
}
/// The terrain shader for the pack's material stage `source`, with its sprite `tags`.
fn shader_source(source: &str, tags: &[(u32, u32)]) -> String {
/// The terrain shader for the pack's material stage `source`, with its sprite `tags` and
/// `materials`.
fn shader_source(source: &str, tags: &[(u32, u32)], materials: &[(u32, u32)]) -> String {
format!(
"{source}\n// Horizon's terrain (terrain.wgsl)\n{}{}{ENTRIES}",
"{source}\n// Horizon's terrain (terrain.wgsl)\n{}{}{}{ENTRIES}",
vanilla_terrain(),
sprite_tags(tags)
layer_table("horizon_sprite_tags", tags),
layer_table("horizon_sprite_material", materials)
)
}
/// The material's naga module, its errors in the stage's file.
fn module(stage: &CheckedStage, tags: &[(u32, u32)]) -> Result<naga::Module, String> {
let text = shader_source(&stage.source, tags);
fn module(
stage: &CheckedStage,
tags: &[(u32, u32)],
materials: &[(u32, u32)],
) -> Result<naga::Module, String> {
let text = shader_source(&stage.source, tags, materials);
naga::front::wgsl::parse_str(&text)
.map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text)))
}
@@ -174,14 +226,16 @@ impl NativeMaterial {
frame: &NativeFrame,
stage: &CheckedStage,
constants: &[(String, f64)],
fog: bool,
) -> Result<Self, String> {
let module = module(stage, shape.sprite_tags)?;
let module = module(stage, shape.sprite_tags, shape.sprite_materials)?;
let mut constants = present_constants(&module, constants);
let sprite_names = [
"horizon_terrain_SPRITE_ARRAYS",
"horizon_terrain_SPRITE_SPLIT",
];
constants.extend(sprite_names.into_iter().zip(shape.sprites));
constants.push(("horizon_material_fog", f64::from(u8::from(fog))));
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some(&stage.label),
@@ -346,14 +400,20 @@ mod tests {
return Surface(apply_fog(sprite(lit)) + (behind + frame_of) * 0.0);\n}\n";
let stage = stage(own);
let tags = [(7, 1), (3, 1), (9, 2)];
assert!(sprite_tags(&tags).contains("case 3u, 7u: { return 1u; }"));
let module = module(&stage, &tags).unwrap();
let materials = [(5, 1000)];
assert!(layer_table("t", &tags).contains("case 3u, 7u: { return 1u; }"));
let module = module(&stage, &tags, &materials).unwrap();
let info = naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&shader_source(&stage.source, &tags))));
.unwrap_or_else(|e| {
panic!(
"{}",
e.emit_to_string(&shader_source(&stage.source, &tags, &materials))
)
});
let entries: Vec<&str> = module
.entry_points
.iter()
@@ -5,6 +5,7 @@ use horizon_shader_api::{Report, StageKind};
use super::native_material::{NativeMaterial, TerrainShape};
use super::native_post::{NativeFrame, NativePost};
use super::native_shadow::NativeShadow;
use super::native_sky::NativeSky;
use super::pack_timing::PassTiming;
@@ -13,6 +14,13 @@ pub struct NativePack {
pub sky: Option<NativeSky>,
pub material: Option<NativeMaterial>,
pub post: Option<NativePost>,
/// With a sky stage, what of Minecraft's sky still draws over it (`[sky]`).
pub vanilla_clouds: bool,
pub vanilla_sun: bool,
pub vanilla_stars: bool,
pub weather: Option<wgpu::RenderPipeline>,
/// The draw of the shadow map the stages read, if the pack asks for one.
pub shadow: Option<NativeShadow>,
/// GPU time of each pass, while the renderer asks for it (`pack_timing.rs`).
pub timing: Option<PassTiming>,
}
@@ -24,12 +32,24 @@ impl NativePack {
/// refused.
pub fn new(
device: &wgpu::Device,
queue: &wgpu::Queue,
terrain: &TerrainShape<'_>,
report: Report,
constants: &[(String, f64)],
lights: super::block_light::LightTable,
) -> Result<(Self, Vec<String>), String> {
let (globals, format) = (terrain.globals, terrain.format);
let mut frame = NativeFrame::new(device);
let textures = super::native_post::pack_textures(device, queue, &report);
let settings = report
.shadow
.as_ref()
.map(|settings| super::native_shadow::fitted(device, settings));
let mut frame = NativeFrame::new(device, settings.as_ref(), textures, lights, terrain.pbr);
let shadow = report
.shadow
.as_ref()
.map(|s| NativeShadow::new(device, terrain, &frame, &s.solid_materials))
.transpose()?;
// The mirror is drawn for a material that reads it, while `[reflection] option` is on.
let option = report.reflection.as_ref().and_then(|r| r.option.as_ref());
frame.reflects = report
@@ -43,6 +63,34 @@ impl NativePack {
.find(|(k, _)| k == id)
.is_none_or(|(_, v)| *v != 0.0)
});
let fog = report.material_fog;
let keeps = |key: fn(&horizon_shader_api::Stage) -> Option<bool>| {
report.sky.as_ref().and_then(key).unwrap_or(true)
};
let vanilla_clouds = keeps(|s| s.clouds);
let vanilla_sun = keeps(|s| s.sun);
let vanilla_stars = keeps(|s| s.stars);
let weather = report
.stages
.iter()
.find(|s| s.kind == StageKind::Weather)
.map(|stage| {
super::weather::native_pipeline(
device,
format,
terrain.weather_groups[0],
terrain.weather_groups[1],
&frame,
stage,
constants,
)
})
.transpose()?;
let weather_label = report
.stages
.iter()
.find(|s| s.kind == StageKind::Weather)
.map(|s| s.label.clone());
let (post, others): (Vec<_>, Vec<_>) = report
.stages
.into_iter()
@@ -55,10 +103,10 @@ impl NativePack {
let material = others
.iter()
.find(|s| s.kind == StageKind::Material)
.map(|stage| NativeMaterial::new(device, terrain, &frame, stage, constants))
.map(|stage| NativeMaterial::new(device, terrain, &frame, stage, constants, fog))
.transpose()?;
let post = (!post.is_empty())
.then(|| NativePost::new(device, post, constants, format, &frame))
.then(|| NativePost::new(device, post, &report.manifest, constants, format, &frame))
.transpose()?;
let mut labels: Vec<String> = sky.iter().map(|s| s.label.clone()).collect();
labels.extend(
@@ -67,6 +115,7 @@ impl NativePack {
.filter(|s| material.is_some() && s.kind == StageKind::Material)
.map(|s| s.label.clone()),
);
labels.extend(weather_label);
if let Some(post) = &post {
labels.extend(post.labels().into_iter().map(str::to_owned));
}
@@ -75,6 +124,11 @@ impl NativePack {
sky,
material,
post,
vanilla_clouds,
vanilla_sun,
vanilla_stars,
weather,
shadow,
timing: None,
};
Ok((pack, labels))
@@ -82,6 +136,9 @@ impl NativePack {
/// Whether the pack draws anything the game runs today.
pub fn draws(&self) -> bool {
self.sky.is_some() || self.material.is_some() || self.post.is_some()
self.sky.is_some()
|| self.material.is_some()
|| self.weather.is_some()
|| self.post.is_some()
}
}
@@ -16,14 +16,72 @@ const INTERMEDIATE: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
/// The world's colour and depth views a set of bind groups was made for.
type WorldViews = (wgpu::TextureView, wgpu::TextureView);
/// Where a post pass draws.
#[derive(Clone, Copy, PartialEq)]
enum Writes {
/// One of the chain's two targets.
Chain(usize),
/// A pack target (`[[post]] output`).
Target(usize),
/// The screen: the last pass.
Screen,
}
/// A post pass's target, and the chain's picture it reads as `previous` (the world's for
/// none).
#[derive(Clone, Copy)]
struct PassPlan {
writes: Writes,
reads: Option<usize>,
}
/// The passes' plan for `outputs` (each pass's `[[post]] output`, by target index): the chain's
/// passes write its targets in turn, each reading what the one before wrote; a pass with an
/// output writes that and reads the chain's picture, which goes on past it.
fn plan(outputs: &[Option<usize>]) -> Vec<PassPlan> {
let mut current = None;
outputs
.iter()
.enumerate()
.map(|(i, output)| {
let reads = current;
let writes = match output {
_ if i + 1 == outputs.len() => Writes::Screen,
Some(t) => Writes::Target(*t),
None => {
let slot = usize::from(current == Some(0));
current = Some(slot);
Writes::Chain(slot)
}
};
PassPlan { writes, reads }
})
.collect()
}
/// A pack target's size: `scale` of the world's `size`, at least a texel.
fn scaled(size: [u32; 2], scale: f32) -> [u32; 2] {
size.map(|s| ((s as f32 * scale).ceil() as u32).max(1))
}
pub struct NativePost {
passes: Vec<(String, wgpu::RenderPipeline)>,
layout: wgpu::BindGroupLayout,
/// The pack's textures.
textures: Vec<(wgpu::TextureView, wgpu::Sampler)>,
frame: wgpu::Buffer,
linear: wgpu::Sampler,
nearest: wgpu::Sampler,
/// Two targets of the world's size, written in turn (none with a single pass).
targets: Option<([u32; 2], [wgpu::TextureView; 2])>,
/// The frame's shadow map, its comparison sampler (bindings 6 and 7), the translucent map
/// and its colour (bindings 9 and 10).
shadow: (
wgpu::TextureView,
wgpu::Sampler,
wgpu::TextureView,
wgpu::TextureView,
),
/// Last frame's input to the last pass (`history`, binding 8), made with the targets,
/// and a black 1×1 in its place before then.
history: Option<wgpu::Texture>,
@@ -32,6 +90,12 @@ pub struct NativePost {
groups: Option<(WorldViews, bool, Vec<wgpu::BindGroup>)>,
/// Whether `history` contains the last frame's input to the last pass.
history_valid: bool,
/// What each pass writes and reads (`[[post]] output`).
plan: Vec<PassPlan>,
/// The pack's targets' scales (`Manifest::post_targets`), and their views for the world's
/// size.
scales: Vec<f32>,
named: Option<([u32; 2], Vec<wgpu::TextureView>)>,
/// The screen's colour format, the world target's too.
format: wgpu::TextureFormat,
}
@@ -42,6 +106,10 @@ pub struct NativeFrame {
pub(super) buffer: wgpu::Buffer,
/// Group 3 of the sky and material stages.
pub layout: wgpu::BindGroupLayout,
/// The pack's `[[textures]]` and their samplers, bound for every stage.
pub textures: Vec<(wgpu::TextureView, wgpu::Sampler)>,
/// The colour of block light around the camera (`[[lights]]`), bindings 9 and 10.
pub block_light: super::block_light::BlockLight,
pub group: wgpu::BindGroup,
/// The world's depth before translucent terrain (the material stage's `opaque_depth`,
/// binding 6), copied each frame.
@@ -49,11 +117,17 @@ pub struct NativeFrame {
/// The world's colour before translucent terrain (`opaque_color`, binding 11), copied
/// with it; 1×1 while the world draws straight to the window.
pub opaque_color: wgpu::Texture,
/// The resource packs' LabPBR maps (bindings 12 and 13: normals, specular), a blank 1x1
/// without them.
pbr: [wgpu::TextureView; 2],
/// The material stage's `reflection` (binding 1): this frame's water mirror, else
/// `blank`; and its sampler (binding 2).
reflection: wgpu::TextureView,
blank: wgpu::TextureView,
reflection_sampler: wgpu::Sampler,
/// The shadow map the stages read, and the camera that draws it when the pack asks for one.
pub shadow_map: super::native_shadow::ShadowMap,
shadow_camera: Option<horizon_shaders::Shadow>,
/// Whether the pack's material reads `reflection` (`NativePack::new`): else no mirror
/// is drawn and `has_reflection` stays 0.
pub reflects: bool,
@@ -140,22 +214,61 @@ fn eased(value: f32, target: f32, half_life: f32, dt: f32) -> f32 {
}
impl NativeFrame {
pub fn new(device: &wgpu::Device) -> Self {
/// The frame of a pack with the `[shadow]` settings `shadow`, if any, and the
/// block light colours `lights`.
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]>,
) -> Self {
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Native shader pack frame"),
size: size_of::<FrameInputs>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut entries = vec![
frame_entry(wgpu::ShaderStages::VERTEX_FRAGMENT),
texture_entry(1, wgpu::TextureSampleType::Float { filterable: true }),
sampler_entry(2),
texture_entry(3, wgpu::TextureSampleType::Depth),
comparison_entry(4),
texture_entry(6, wgpu::TextureSampleType::Depth),
texture_entry(7, wgpu::TextureSampleType::Depth),
texture_entry(8, wgpu::TextureSampleType::Float { filterable: true }),
texture_entry(11, wgpu::TextureSampleType::Float { filterable: false }),
pbr_entry(12),
pbr_entry(13),
];
let block_light_entries = [
wgpu::BindGroupLayoutEntry {
binding: 9,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D3,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 10,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
];
entries.extend(block_light_entries);
entries.extend(pack_texture_entries(textures.len()));
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Native shader pack frame"),
entries: &[
frame_entry(wgpu::ShaderStages::VERTEX_FRAGMENT),
texture_entry(1, wgpu::TextureSampleType::Float { filterable: true }),
sampler_entry(2),
texture_entry(6, wgpu::TextureSampleType::Depth),
texture_entry(11, wgpu::TextureSampleType::Float { filterable: false }),
],
entries: &entries,
});
let opaque_depth = depth_copy(device, [1, 1]);
let opaque_color = color_copy(device, [1, 1], wgpu::TextureFormat::Rgba8Unorm);
@@ -172,15 +285,50 @@ impl NativeFrame {
min_filter: wgpu::FilterMode::Linear,
..Default::default()
});
let shadow_map = super::native_shadow::ShadowMap::new(device, shadow);
let block_light = super::block_light::BlockLight::new(device, lights);
let array_view = |texture: &wgpu::Texture| {
texture.create_view(&wgpu::TextureViewDescriptor {
dimension: Some(wgpu::TextureViewDimension::D2Array),
..Default::default()
})
};
let pbr = match pbr {
Some(maps) => maps.each_ref().map(array_view),
None => {
// Two layers: one would make a 2D texture on the OpenGL backend.
let blank = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Native shader pack LabPBR (none)"),
size: wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 2,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
[array_view(&blank), array_view(&blank)]
}
};
let group = Self::group(
device,
&layout,
&buffer,
(&blank, &reflection_sampler),
&opaque_depth,
&opaque_color,
&shadow_map,
(&opaque_depth, &opaque_color),
&textures,
&block_light,
&pbr,
);
Self {
textures,
block_light,
pbr,
buffer,
layout,
group,
@@ -189,6 +337,8 @@ impl NativeFrame {
reflection: blank.clone(),
blank,
reflection_sampler,
shadow_map,
shadow_camera: shadow.map(super::native_shadow::camera),
reflects: false,
last: None,
eye: [f32::NAN; 4],
@@ -199,43 +349,93 @@ impl NativeFrame {
}
}
/// What the shadow camera sees this frame, for the renderer's shadow lists.
pub fn shadow_view(&self, frame: &Frame) -> Option<super::pack_post::ShadowView> {
let camera = self.shadow_camera.as_ref()?;
Some(super::pack_post::ShadowView {
cull: super::pack_uniforms::shadow_culling(frame, camera),
light: super::pack_uniforms::shadow_light(frame, camera),
// The light's box culls: on the ground it reaches further than `distance` along
// the sun's azimuth while the sun is low.
distance: None,
unculled: None,
})
}
fn group(
device: &wgpu::Device,
layout: &wgpu::BindGroupLayout,
buffer: &wgpu::Buffer,
(reflection, sampler): (&wgpu::TextureView, &wgpu::Sampler),
opaque_depth: &wgpu::Texture,
opaque_color: &wgpu::Texture,
shadow: &super::native_shadow::ShadowMap,
(opaque_depth, opaque_color): (&wgpu::Texture, &wgpu::Texture),
textures: &[(wgpu::TextureView, wgpu::Sampler)],
block_light: &super::block_light::BlockLight,
pbr: &[wgpu::TextureView; 2],
) -> wgpu::BindGroup {
let opaque_depth = opaque_depth.create_view(&Default::default());
let opaque_color = opaque_color.create_view(&Default::default());
let mut entries = vec![
wgpu::BindGroupEntry {
binding: 0,
resource: buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(reflection),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(sampler),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::TextureView(&shadow.view),
},
wgpu::BindGroupEntry {
binding: 4,
resource: wgpu::BindingResource::Sampler(&shadow.sampler),
},
wgpu::BindGroupEntry {
binding: 7,
resource: wgpu::BindingResource::TextureView(&shadow.translucent_view),
},
wgpu::BindGroupEntry {
binding: 8,
resource: wgpu::BindingResource::TextureView(&shadow.color_view),
},
wgpu::BindGroupEntry {
binding: 6,
resource: wgpu::BindingResource::TextureView(&opaque_depth),
},
wgpu::BindGroupEntry {
binding: 11,
resource: wgpu::BindingResource::TextureView(&opaque_color),
},
wgpu::BindGroupEntry {
binding: 12,
resource: wgpu::BindingResource::TextureView(&pbr[0]),
},
wgpu::BindGroupEntry {
binding: 13,
resource: wgpu::BindingResource::TextureView(&pbr[1]),
},
];
entries.extend([
wgpu::BindGroupEntry {
binding: 9,
resource: wgpu::BindingResource::TextureView(&block_light.view),
},
wgpu::BindGroupEntry {
binding: 10,
resource: block_light.grid.as_entire_binding(),
},
]);
entries.extend(pack_texture_group_entries(textures));
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Native shader pack frame"),
layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(reflection),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(sampler),
},
wgpu::BindGroupEntry {
binding: 6,
resource: wgpu::BindingResource::TextureView(
&opaque_depth.create_view(&Default::default()),
),
},
wgpu::BindGroupEntry {
binding: 11,
resource: wgpu::BindingResource::TextureView(
&opaque_color.create_view(&Default::default()),
),
},
],
entries: &entries,
})
}
@@ -284,8 +484,11 @@ impl NativeFrame {
&self.layout,
&self.buffer,
(&self.reflection, &self.reflection_sampler),
&self.opaque_depth,
&self.opaque_color,
&self.shadow_map,
(&self.opaque_depth, &self.opaque_color),
&self.textures,
&self.block_light,
&self.pbr,
);
}
@@ -295,7 +498,13 @@ impl NativeFrame {
let now = std::time::Instant::now();
let since = self.wetness.1.map_or(0.0, |t| (now - t).as_secs_f32());
self.wetness = (wetness(self.wetness.0, frame.pack.rain, since), Some(now));
let mut inputs = frame_inputs(frame, size, seconds, self.frames);
let mut inputs = frame_inputs(
frame,
size,
seconds,
self.frames,
self.shadow_camera.as_ref(),
);
inputs.wetness = self.wetness.0;
if !self.reflects {
inputs.has_reflection = 0;
@@ -377,6 +586,29 @@ fn texture_entry(binding: u32, sample_type: wgpu::TextureSampleType) -> wgpu::Bi
}
}
/// A LabPBR map's array, read by the material entries (`native_material.rs`).
fn pbr_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2Array,
multisampled: false,
},
count: None,
}
}
fn comparison_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Comparison),
count: None,
}
}
fn sampler_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
wgpu::BindGroupLayoutEntry {
binding,
@@ -392,22 +624,43 @@ impl NativePost {
pub fn new(
device: &wgpu::Device,
stages: Vec<CheckedStage>,
manifest: &horizon_shader_api::Manifest,
constants: &[(String, f64)],
format: wgpu::TextureFormat,
frame: &NativeFrame,
) -> Result<Self, String> {
let targets = manifest.post_targets();
let outputs: Vec<Option<usize>> = manifest
.post
.iter()
.map(|p| {
let name = p.output.as_deref()?;
targets.iter().position(|t| t.0 == name)
})
.collect();
let plan = plan(&outputs);
let float = wgpu::TextureSampleType::Float { filterable: true };
let mut entries = vec![
frame_entry(wgpu::ShaderStages::FRAGMENT),
texture_entry(1, float),
texture_entry(2, wgpu::TextureSampleType::Depth),
texture_entry(3, float),
sampler_entry(4),
sampler_entry(5),
texture_entry(6, wgpu::TextureSampleType::Depth),
comparison_entry(7),
texture_entry(8, float),
texture_entry(9, wgpu::TextureSampleType::Depth),
texture_entry(10, float),
];
entries.extend(pack_texture_entries(frame.textures.len()));
entries
.extend((0..targets.len() as u32).map(|j| {
texture_entry(horizon_shader_api::stages::POST_TARGET_BINDING + j, float)
}));
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Native shader pack post"),
entries: &[
frame_entry(wgpu::ShaderStages::FRAGMENT),
texture_entry(1, float),
texture_entry(2, wgpu::TextureSampleType::Depth),
texture_entry(3, float),
sampler_entry(4),
sampler_entry(5),
texture_entry(8, float),
],
entries: &entries,
});
let mut groups: [Option<&wgpu::BindGroupLayout>; GROUP as usize + 1] = Default::default();
groups[GROUP as usize] = Some(&layout);
@@ -446,7 +699,11 @@ impl NativePost {
entry_point: Some("horizon_fs_post"),
compilation_options: options,
targets: &[Some(wgpu::ColorTargetState {
format: if i + 1 == count { format } else { INTERMEDIATE },
format: if plan[i].writes == Writes::Screen {
format
} else {
INTERMEDIATE
},
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
@@ -470,7 +727,14 @@ impl NativePost {
Ok(Self {
passes,
layout,
textures: frame.textures.clone(),
frame: frame.buffer.clone(),
shadow: (
frame.shadow_map.view.clone(),
frame.shadow_map.sampler.clone(),
frame.shadow_map.translucent_view.clone(),
frame.shadow_map.color_view.clone(),
),
linear: sampler("Native shader pack linear", wgpu::FilterMode::Linear),
nearest: sampler("Native shader pack nearest", wgpu::FilterMode::Nearest),
targets: None,
@@ -478,6 +742,9 @@ impl NativePost {
history_blank: history_target(device, "Native shader pack history blank", [1, 1]),
groups: None,
history_valid: false,
scales: targets.iter().map(|t| t.1).collect(),
named: None,
plan,
format,
})
}
@@ -494,7 +761,39 @@ impl NativePost {
size: [u32; 2],
history_reset: bool,
) {
if self.passes.len() > 1 && self.targets.as_ref().is_none_or(|t| t.0 != size) {
if !self.scales.is_empty() && self.named.as_ref().is_none_or(|n| n.0 != size) {
let views = self
.scales
.iter()
.map(|&scale| {
let size = scaled(size, scale);
device
.create_texture(&wgpu::TextureDescriptor {
label: Some("Native shader pack target"),
size: wgpu::Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: INTERMEDIATE,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
})
.create_view(&Default::default())
})
.collect();
self.named = Some((size, views));
self.groups = None;
}
let chain = self
.plan
.iter()
.any(|p| matches!(p.writes, Writes::Chain(_)));
if chain && self.targets.as_ref().is_none_or(|t| t.0 != size) {
let target = |label| {
device
.create_texture(&wgpu::TextureDescriptor {
@@ -545,46 +844,79 @@ impl NativePost {
.expect("history exists after a post stage has more than one pass")
}
.create_view(&Default::default());
let blank = self.history_blank.create_view(&Default::default());
let groups = (0..self.passes.len())
.map(|i| {
// Pass i reads what pass i - 1 wrote: target (i - 1) % 2, the world for the first.
let previous = match (&self.targets, i) {
(Some((_, t)), 1..) => &t[(i - 1) % 2],
// The chain's picture so far: the world's before its first pass.
let previous = match (&self.targets, self.plan[i].reads) {
(Some((_, t)), Some(slot)) => &t[slot],
_ => world.0,
};
let mut entries = vec![
wgpu::BindGroupEntry {
binding: 0,
resource: self.frame.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(world.0),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::TextureView(world.1),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::TextureView(previous),
},
wgpu::BindGroupEntry {
binding: 4,
resource: wgpu::BindingResource::Sampler(&self.linear),
},
wgpu::BindGroupEntry {
binding: 5,
resource: wgpu::BindingResource::Sampler(&self.nearest),
},
wgpu::BindGroupEntry {
binding: 6,
resource: wgpu::BindingResource::TextureView(&self.shadow.0),
},
wgpu::BindGroupEntry {
binding: 7,
resource: wgpu::BindingResource::Sampler(&self.shadow.1),
},
wgpu::BindGroupEntry {
binding: 8,
resource: wgpu::BindingResource::TextureView(&history),
},
wgpu::BindGroupEntry {
binding: 9,
resource: wgpu::BindingResource::TextureView(&self.shadow.2),
},
wgpu::BindGroupEntry {
binding: 10,
resource: wgpu::BindingResource::TextureView(&self.shadow.3),
},
];
entries.extend(pack_texture_group_entries(&self.textures));
// The pack's targets, a blank one in place of the one this pass writes.
if let Some((_, views)) = &self.named {
entries.extend(views.iter().enumerate().map(|(j, view)| {
let view = if self.plan[i].writes == Writes::Target(j) {
&blank
} else {
view
};
wgpu::BindGroupEntry {
binding: horizon_shader_api::stages::POST_TARGET_BINDING + j as u32,
resource: wgpu::BindingResource::TextureView(view),
}
}));
}
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Native shader pack post"),
layout: &self.layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: self.frame.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(world.0),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::TextureView(world.1),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::TextureView(previous),
},
wgpu::BindGroupEntry {
binding: 4,
resource: wgpu::BindingResource::Sampler(&self.linear),
},
wgpu::BindGroupEntry {
binding: 5,
resource: wgpu::BindingResource::Sampler(&self.nearest),
},
wgpu::BindGroupEntry {
binding: 8,
resource: wgpu::BindingResource::TextureView(&history),
},
],
entries: &entries,
})
})
.collect();
@@ -611,8 +943,9 @@ impl NativePost {
return;
};
for (i, ((label, pipeline), group)) in self.passes.iter().zip(groups).enumerate() {
let view = match &self.targets {
Some((_, t)) if i + 1 < self.passes.len() => &t[i % 2],
let view = match (self.plan[i].writes, &self.targets, &self.named) {
(Writes::Chain(slot), Some((_, t)), _) => &t[slot],
(Writes::Target(j), _, Some((_, n))) => &n[j],
_ => screen,
};
let attachments = [Some(wgpu::RenderPassColorAttachment {
@@ -634,14 +967,17 @@ impl NativePost {
pass.set_bind_group(GROUP, group, &[]);
pass.draw(0..3, 0..1);
}
// The last pass's input becomes next frame's `history`, once that pass has read this
// frame's.
let count = self.passes.len();
if count > 1
// The chain's last picture becomes next frame's `history`, once every pass has read
// this frame's.
let before_last = &self.plan[..self.plan.len() - 1];
if let Some(Writes::Chain(slot)) = before_last
.iter()
.rfind(|p| matches!(p.writes, Writes::Chain(_)))
.map(|p| p.writes)
&& let (Some(history), Some((_, targets))) = (&self.history, &self.targets)
{
encoder.copy_texture_to_texture(
targets[(count - 2) % 2].texture().as_image_copy(),
targets[slot].texture().as_image_copy(),
history.as_image_copy(),
history.size(),
);
@@ -676,7 +1012,13 @@ fn wetness(wetness: f32, rain: f32, dt: f32) -> f32 {
}
/// The frame as packs read it (docs/api/shaders-reference.md), from the renderer's `Frame`.
pub fn frame_inputs(frame: &Frame, size: [u32; 2], seconds: f32, index: u32) -> FrameInputs {
pub fn frame_inputs(
frame: &Frame,
size: [u32; 2],
seconds: f32,
index: u32,
shadow: Option<&horizon_shaders::Shadow>,
) -> FrameInputs {
let aspect = size[0] as f32 / size[1].max(1) as f32;
let view = crate::camera::view_matrix(frame.yaw, frame.pitch);
let projection = super::world_projection(frame.fov_degrees, aspect, frame.depth_far)
@@ -748,11 +1090,147 @@ pub fn frame_inputs(frame: &Frame, size: [u32; 2], seconds: f32, index: u32) ->
water_plane: frame.water_plane.unwrap_or(0.0),
has_reflection: u32::from(frame.water_plane.is_some()),
reflection_resolution: reflection_size(size).map(|s| s as f32),
mods: pack.mod_params,
light_direction: match shadow {
Some(s) => super::pack_uniforms::shadow_light(frame, s),
None => Vec3::Y,
}
.to_array(),
has_shadow: u32::from(shadow.is_some()),
shadow_view_proj: m(shadow.map_or(Mat4::IDENTITY, |s| {
super::pack_uniforms::shadow_culling(frame, s)
})),
shadow_distance: shadow.map_or(0.0, |s| s.distance),
shadow_distortion: shadow.map_or(0.0, |s| {
super::native_shadow::distortion(s.distance, s.resolution)
}),
shadow_resolution: shadow.map_or(0.0, |s| s.resolution as f32),
shadow_center_texel: shadow.map_or(0.0, |s| {
let k = super::native_shadow::distortion(s.distance, s.resolution);
2.0 * s.distance / s.resolution.max(1) as f32 * (1.0 - k)
}),
}
}
/// The pack's `[[textures]]` (`check::Image`s), uploaded with samplers as the manifest says.
pub fn pack_textures(
device: &wgpu::Device,
queue: &wgpu::Queue,
report: &horizon_shader_api::Report,
) -> Vec<(wgpu::TextureView, wgpu::Sampler)> {
report
.textures
.iter()
.map(|(t, image)| {
let texture = wgpu::util::DeviceExt::create_texture_with_data(
device,
queue,
&wgpu::TextureDescriptor {
label: Some(&t.id),
size: wgpu::Extent3d {
width: image.width,
height: image.height,
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: &[],
},
wgpu::util::TextureDataOrder::LayerMajor,
&image.rgba,
);
let filter = if t.linear {
wgpu::FilterMode::Linear
} else {
wgpu::FilterMode::Nearest
};
let address = if t.repeat {
wgpu::AddressMode::Repeat
} else {
wgpu::AddressMode::ClampToEdge
};
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some(&t.id),
address_mode_u: address,
address_mode_v: address,
mag_filter: filter,
min_filter: filter,
..Default::default()
});
(texture.create_view(&Default::default()), sampler)
})
.collect()
}
/// Layout entries of `count` pack textures and their samplers.
pub(super) fn pack_texture_entries(count: usize) -> Vec<wgpu::BindGroupLayoutEntry> {
use horizon_shader_api::stages::{TEXTURE_BINDING, TEXTURE_SAMPLER_BINDING};
(0..count as u32)
.flat_map(|i| {
[
wgpu::BindGroupLayoutEntry {
binding: TEXTURE_BINDING + i,
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,
},
wgpu::BindGroupLayoutEntry {
binding: TEXTURE_SAMPLER_BINDING + i,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
]
})
.collect()
}
/// Group entries of the pack's textures, as `pack_texture_entries` lays them out.
pub(super) fn pack_texture_group_entries(
textures: &[(wgpu::TextureView, wgpu::Sampler)],
) -> Vec<wgpu::BindGroupEntry<'_>> {
use horizon_shader_api::stages::{TEXTURE_BINDING, TEXTURE_SAMPLER_BINDING};
textures
.iter()
.enumerate()
.flat_map(|(i, (view, sampler))| {
[
wgpu::BindGroupEntry {
binding: TEXTURE_BINDING + i as u32,
resource: wgpu::BindingResource::TextureView(view),
},
wgpu::BindGroupEntry {
binding: TEXTURE_SAMPLER_BINDING + i as u32,
resource: wgpu::BindingResource::Sampler(sampler),
},
]
})
.collect()
}
#[cfg(test)]
mod tests {
#[test]
fn a_pass_with_an_output_leaves_the_chain_as_it_was() {
use super::{Writes, plan};
// Bloom into a target, a pass of the chain, a second target, the picture.
let p = plan(&[Some(0), None, Some(1), None, None]);
let writes: Vec<_> = p.iter().map(|p| p.writes).collect();
assert!(writes[0] == Writes::Target(0));
assert!(writes[1] == Writes::Chain(0));
assert!(writes[2] == Writes::Target(1));
assert!(writes[3] == Writes::Chain(1));
assert!(writes[4] == Writes::Screen);
let reads: Vec<_> = p.iter().map(|p| p.reads).collect();
assert_eq!(reads, [None, None, Some(0), Some(0), Some(1)]);
}
#[test]
fn eased_values_start_at_the_target_and_halve_the_distance() {
use super::eased;
@@ -814,7 +1292,7 @@ mod tests {
.into();
let report = check(&Pack::from_files(files).unwrap()).unwrap();
let format = wgpu::TextureFormat::Rgba8Unorm;
let frame = NativeFrame::new(&device);
let frame = NativeFrame::new(&device, None, Vec::new(), Default::default(), None);
queue.write_buffer(
&frame.buffer,
0,
@@ -823,6 +1301,7 @@ mod tests {
let mut post = NativePost::new(
&device,
report.stages,
&report.manifest,
&[("gain".into(), 1.0)],
format,
&frame,
@@ -933,8 +1412,16 @@ file = \"b.wgsl\"
.into();
let report = check(&Pack::from_files(files).unwrap()).unwrap();
let format = wgpu::TextureFormat::Rgba8Unorm;
let frame = NativeFrame::new(&device);
let mut post = NativePost::new(&device, report.stages, &[], format, &frame).unwrap();
let frame = NativeFrame::new(&device, None, Vec::new(), Default::default(), None);
let mut post = NativePost::new(
&device,
report.stages,
&report.manifest,
&[],
format,
&frame,
)
.unwrap();
let usage = wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING;
let color = texture(&device, format, usage).create_view(&Default::default());
let depth = texture(&device, wgpu::TextureFormat::Depth32Float, usage)
@@ -0,0 +1,668 @@
//! The shadow map of Horizon's own shader packs (`[shadow]`, docs/shaders.md): each frame the
//! solid terrain and entities the light (the sun by day, the moon by night) sees are drawn into a square depth
//! map, which the material and post stages read as `shadow_map`; translucent terrain goes over a
//! copy of it, `shadow_map_translucent`, with its colour in `shadow_color`. The shadow camera is the one
//! `shaders/` packs use (`pack_uniforms::shadow_camera`), so the renderer's shadow visibility and
//! draw lists (`ShadowView`) serve both; the map's texels are distorted toward the camera as the
//! prelude's `shadow_position` undoes.
use horizon_shader_api::{Frame as FrameInputs, manifest::Shadow as Settings, stages::GROUP};
use horizon_shaders::Shadow;
use super::native_material::TerrainShape;
use super::wgsl_prefix::{TERRAIN, prefixed, section};
const PREFIX: &str = "horizon_terrain_";
/// The shadow pass's entries: terrain's vertices through the shadow camera, distorted, and the
/// cutout test (leaves' and plants' holes let the light through).
const ENTRIES: &str = "
struct HorizonShadowOut {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
@location(1) @interpolate(flat) layer: u32,
@location(2) tint: vec3<f32>,
@location(3) @interpolate(flat) fluid: u32,
}
// `color` is the vertex colour: the tint, and the fluid flag in its alpha's high nibble
// (mesher.rs's `fluid_face`).
fn horizon_shadow_vertex(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>)
-> HorizonShadowOut {
let clip = frame.shadow_view_proj * vec4(pos, 1.0);
let xy = shadow_distort(clip.xy);
var out: HorizonShadowOut;
out.clip = vec4(xy, clamp(clip.z, 0.0, 1.0), 1.0);
out.uv = uv;
out.layer = layer;
out.tint = color.rgb;
let high = u32(round(color.a * 255.0)) >> 4u;
out.fluid = u32(high >= 8u && (high & 7u) < 6u);
return out;
}
@vertex fn horizon_shadow_vs(
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>,
@location(2) layer: u32, @location(3) color: vec4<f32>,
@location(5) origin: vec4<f32>) -> HorizonShadowOut {
return horizon_shadow_vertex(position + origin.xyz, horizon_terrain_terrain_uv(uv, origin.w), layer, color);
}
@vertex fn horizon_shadow_vs_flat(
@builtin(vertex_index) index: u32,
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>, @location(2) layer: u32,
@location(3) color: vec4<f32>
) -> HorizonShadowOut {
let origin = horizon_terrain_slot_origins[horizon_terrain_block_slots[index >> 8u]];
return horizon_shadow_vertex(position + origin.xyz, horizon_terrain_terrain_uv(uv, origin.w), layer, color);
}
@fragment fn horizon_shadow_fs(in: HorizonShadowOut) {
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
let alpha = horizon_terrain_sprite(in.uv, in.layer & 0x7fffu).a;
if alpha < 0.5 && (in.layer & 0x8000u) == 0u && !horizon_shadow_solid(in.layer & 0x7fffu) { discard; }
}
// Translucent terrain: its colour where it is the nearest thing the light passes through;
// alpha 1 marks a fluid, other surfaces keep their alpha below it.
@fragment fn horizon_shadow_fs_translucent(in: HorizonShadowOut) -> @location(0) vec4<f32> {
let color = horizon_terrain_sprite(in.uv, in.layer & 0x7fffu);
if color.a < 0.01 { discard; }
return vec4(color.rgb * in.tint, select(min(color.a, 0.99), 1.0, in.fluid != 0u));
}
";
/// The entity draw of the shadow pass: the renderer's opaque entity quads (`EntityBatch`'s first
/// material) through the shadow camera, with their texture's cutout. The sky's bodies, clouds,
/// beacon beams and breaking cracks share that buffer but are unlit (entity.wgsl's overlays) and
/// cast nothing. A quad's layer holds the overlay in its low 16 bits.
const ENTITY_ENTRIES: &str = "
@group(0) @binding(1) var horizon_atlas: texture_2d<f32>;
@group(0) @binding(2) var horizon_atlas_sampler: sampler;
struct HorizonEntityShadowOut {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
@location(1) alpha: f32,
}
@vertex fn horizon_entity_shadow_vs(@location(0) position: vec3<f32>,
@location(1) uv: vec2<f32>, @location(2) layer: u32, @location(3) color: vec4<f32>)
-> HorizonEntityShadowOut {
let clip = frame.shadow_view_proj * vec4(position, 1.0);
let xy = shadow_distort(clip.xy);
let overlay = layer & 0xffffu;
// As entity.wgsl's `unlit`.
let unlit = overlay == 2u || overlay == 4u || overlay == 6u
|| (overlay >= 9u && overlay <= 12u);
var out: HorizonEntityShadowOut;
// Unlit geometry lands outside the clip volume.
out.clip = select(vec4(xy, clamp(clip.z, 0.0, 1.0), 1.0), vec4(2.0, 2.0, 2.0, 1.0), unlit);
out.uv = uv;
out.alpha = color.a;
return out;
}
@fragment fn horizon_entity_shadow_fs(in: HorizonEntityShadowOut) {
let alpha = textureSampleLevel(horizon_atlas, horizon_atlas_sampler, in.uv, 0.0).a;
if alpha * in.alpha < 0.5 { discard; }
}
";
/// `shadow_color`'s format.
const SHADOW_COLOR: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
/// The shader pack's shadow camera for `settings`: orthographic over `distance` each side,
/// staying put while the camera walks a `SNAP`-block cell. The map is distorted about its
/// centre, so a centre that moves with the camera, even by a texel, resamples every shadow but
/// the nearest each frame and they shimmer; a still one maps the world to the same texels until
/// the next cell. Its depth starts 256 blocks further toward the light than `shaders/` packs'
/// (the projection's near plane is at `-near`), so the ground over a deep cave still casts its
/// shadow, and ends past the build height and the slant across the map below the camera.
pub fn camera(settings: &Settings) -> Shadow {
Shadow {
resolution: settings.resolution,
distance: settings.distance,
render_mul: 1.0,
interval: SNAP,
euclidean_snap: true,
near: 256.0,
far: super::pack_uniforms::SHADOW_EYE + 2.0 * settings.distance + 384.0,
..Shadow::default()
}
}
/// `settings` with the resolution `device` can make.
pub fn fitted(device: &wgpu::Device, settings: &Settings) -> Settings {
Settings {
resolution: settings
.resolution
.min(device.limits().max_texture_dimension_2d),
..settings.clone()
}
}
/// The cell the shadow camera keeps to, in blocks along the world's axes: the player stays
/// within 7 blocks of the centre (half the cell on each axis), where the map is still over
/// three times as fine as at its edge.
const SNAP: f32 = 8.0;
/// The ground one shadow texel covers right under the camera, in blocks: a 32nd, half a texture
/// pixel, so shadow edges near the player stay as sharp as the blocks casting them.
const CENTRE_TEXEL: f32 = 1.0 / 32.0;
/// How strongly the map is distorted toward its centre (`Frame::shadow_distortion`).
/// `xy / (|xy| k + 1 - k)` magnifies the centre by 1 / (1 - k); an undistorted texel covers
/// 2 distance / resolution blocks, so k is what brings that down to `CENTRE_TEXEL`. None when
/// the map is already that fine; at most 0.95, past which the edge of the map gets too few texels.
pub fn distortion(distance: f32, resolution: u32) -> f32 {
let texel = 2.0 * distance / resolution.max(1) as f32;
(1.0 - CENTRE_TEXEL / texel).clamp(0.0, 0.95)
}
/// The shadow map: a square depth map of the pack's resolution, or a 1×1 one (cleared to "far",
/// so everything is lit) for a pack without `[shadow]`; the same with translucent terrain drawn
/// in, and that terrain's colour.
pub struct ShadowMap {
depth: wgpu::Texture,
pub view: wgpu::TextureView,
translucent: wgpu::Texture,
pub translucent_view: wgpu::TextureView,
pub color_view: wgpu::TextureView,
pub sampler: wgpu::Sampler,
/// The 1×1 map still holds the zeroes it was made with.
blank_uncleared: bool,
}
impl ShadowMap {
pub fn new(device: &wgpu::Device, settings: Option<&Settings>) -> Self {
let size = settings.map_or(1, |s| s.resolution);
let texture = |label, format, usage| {
device.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
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: wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT
| usage,
view_formats: &[],
})
};
let depth = texture(
"Native shader pack shadow map",
wgpu::TextureFormat::Depth32Float,
wgpu::TextureUsages::COPY_SRC,
);
let translucent = texture(
"Native shader pack translucent shadow map",
wgpu::TextureFormat::Depth32Float,
wgpu::TextureUsages::COPY_DST,
);
let color = texture(
"Native shader pack shadow colour",
SHADOW_COLOR,
wgpu::TextureUsages::empty(),
);
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("Native shader pack shadow comparison"),
compare: Some(wgpu::CompareFunction::LessEqual),
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
..Default::default()
});
Self {
view: depth.create_view(&Default::default()),
depth,
translucent_view: translucent.create_view(&Default::default()),
translucent,
color_view: color.create_view(&Default::default()),
sampler,
blank_uncleared: settings.is_none(),
}
}
/// A depth pass into the map that clears it to "far".
pub fn begin<'e>(
&self,
encoder: &'e mut wgpu::CommandEncoder,
label: &str,
) -> wgpu::RenderPass<'e> {
self.depth_pass(encoder, label, wgpu::LoadOp::Clear(1.0))
}
/// A depth pass that adds to the map. Entities draw here, after the translucent map was
/// copied: where one casts, the solid map is the darker and decides.
pub fn begin_entities<'e>(
&self,
encoder: &'e mut wgpu::CommandEncoder,
label: &str,
) -> wgpu::RenderPass<'e> {
self.depth_pass(encoder, label, wgpu::LoadOp::Load)
}
fn depth_pass<'e>(
&self,
encoder: &'e mut wgpu::CommandEncoder,
label: &str,
load: wgpu::LoadOp<f32>,
) -> wgpu::RenderPass<'e> {
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(label),
color_attachments: &[],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &self.view,
depth_ops: Some(wgpu::Operations {
load,
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
})
}
/// The translucent pass: the solid terrain's map copied into the translucent one, which it
/// goes on drawing into, and the colour cleared to white (nothing to pass through).
pub fn begin_translucent<'e>(
&self,
encoder: &'e mut wgpu::CommandEncoder,
label: &str,
) -> wgpu::RenderPass<'e> {
encoder.copy_texture_to_texture(
self.depth.as_image_copy(),
self.translucent.as_image_copy(),
self.depth.size(),
);
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(label),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &self.color_view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::WHITE),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &self.translucent_view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
})
}
/// Clears the 1×1 map of a pack without `[shadow]` once.
pub fn clear_blank(&mut self, encoder: &mut wgpu::CommandEncoder) {
if std::mem::take(&mut self.blank_uncleared) {
drop(self.begin(encoder, "Native shader pack shadow map clear"));
drop(self.begin_translucent(encoder, "Native shader pack shadow colour clear"));
}
}
}
/// The pipelines that draw terrain into the shadow map, for the renderer's instanced draws and,
/// where they run, its flat ones, and the frame they read (group 3, the uniform alone: the
/// stages' own group 3 also holds the map being drawn).
pub struct NativeShadow {
pub solid: wgpu::RenderPipeline,
pub flat: Option<wgpu::RenderPipeline>,
/// The translucent terrain's draws into the translucent map and the colour, instanced and
/// flat.
pub translucent: wgpu::RenderPipeline,
pub flat_translucent: Option<wgpu::RenderPipeline>,
/// The entities' draw, with the entities' group 0 (`ENTITY_ENTRIES`).
pub entity: wgpu::RenderPipeline,
pub group: wgpu::BindGroup,
}
/// The entity draw's shader.
fn entity_source() -> String {
format!(
"{}@group({GROUP}) @binding(0) var<uniform> frame: Frame;\n{}{ENTITY_ENTRIES}",
FrameInputs::WGSL.wgsl(),
horizon_shader_api::stages::SHADOW_HELPERS,
)
}
/// The shadow pass's entity pipeline: the entities' vertices and group 0, the frame at group 3.
fn entity_pipeline(
device: &wgpu::Device,
entities: &wgpu::BindGroupLayout,
frame_layout: &wgpu::BindGroupLayout,
) -> wgpu::RenderPipeline {
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Native shader pack entity shadow"),
source: wgpu::ShaderSource::Wgsl(entity_source().into()),
});
let mut groups: [Option<&wgpu::BindGroupLayout>; GROUP as usize + 1] = Default::default();
groups[0] = Some(entities);
groups[GROUP as usize] = Some(frame_layout);
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Native shader pack entity shadow"),
bind_group_layouts: &groups,
..Default::default()
});
let attributes =
wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x2, 2 => Uint32, 3 => Unorm8x4];
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Native shader pack entity shadow"),
layout: Some(&layout),
vertex: wgpu::VertexState {
module: &module,
entry_point: Some("horizon_entity_shadow_vs"),
compilation_options: Default::default(),
buffers: &[Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<super::Vertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &attributes,
})],
},
// Both sides, as the terrain: thin parts (ears, item quads) cast from either.
primitive: wgpu::PrimitiveState {
cull_mode: None,
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(true),
depth_compare: Some(wgpu::CompareFunction::LessEqual),
stencil: Default::default(),
bias: wgpu::DepthBiasState {
constant: 0,
slope_scale: 1.5,
clamp: 0.0,
},
}),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some("horizon_entity_shadow_fs"),
compilation_options: Default::default(),
targets: &[],
}),
multiview_mask: None,
cache: None,
})
}
/// `horizon_shadow_solid(layer)`: whether the sprite layer's id, out of `materials`
/// (`(layer, id)`), is one of the pack's `solid` ones.
fn shadow_solid(materials: &[(u32, u32)], solid: &[u32]) -> String {
let layers: Vec<String> = materials
.iter()
.filter(|(_, id)| solid.contains(id))
.map(|(layer, _)| format!("{layer}u"))
.collect();
let cases = if layers.is_empty() {
String::new()
} else {
format!(" case {}: {{ return true; }}\n", layers.join(", "))
};
format!(
"fn horizon_shadow_solid(layer: u32) -> bool {{\n switch layer {{\n{cases} \
default: {{ return false; }}\n }}\n}}\n"
)
}
/// The shadow pass's shader: the frame, Horizon's terrain, and the entries, with the layers
/// `solid` of `shadow_solid`.
fn shader_source(solid: &str) -> String {
format!(
"{}@group({GROUP}) @binding(0) var<uniform> frame: Frame;\n{}\
// Horizon's terrain (terrain.wgsl)\n{}{solid}{ENTRIES}",
FrameInputs::WGSL.wgsl(),
horizon_shader_api::stages::SHADOW_HELPERS,
prefixed(
section(TERRAIN, "struct Globals {", "// Sky (SkyRenderer)"),
PREFIX
)
)
}
impl NativeShadow {
pub fn new(
device: &wgpu::Device,
shape: &TerrainShape<'_>,
frame: &super::native_post::NativeFrame,
solid_materials: &[u32],
) -> Result<Self, String> {
let text = shader_source(&shadow_solid(shape.sprite_materials, solid_materials));
let module = naga::front::wgsl::parse_str(&text)
.map_err(|e| format!("shadow pass: {}", e.emit_to_string(&text)))?;
let constants = [
("horizon_terrain_SPRITE_ARRAYS", shape.sprites[0]),
("horizon_terrain_SPRITE_SPLIT", shape.sprites[1]),
];
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Native shader pack shadow"),
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
});
let frame_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Native shader pack shadow frame"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: wgpu::BufferSize::new(size_of::<FrameInputs>() as u64),
},
count: None,
}],
});
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Native shader pack shadow frame"),
layout: &frame_layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: frame.buffer.as_entire_binding(),
}],
});
let layout = |flat: Option<&wgpu::BindGroupLayout>| {
let mut groups: [Option<&wgpu::BindGroupLayout>; GROUP as usize + 1] =
Default::default();
groups[0] = Some(shape.globals);
groups[1] = flat;
groups[GROUP as usize] = Some(&frame_layout);
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Native shader pack shadow"),
bind_group_layouts: &groups,
..Default::default()
})
};
let instance_attributes = wgpu::vertex_attr_array![5=>Float32x4];
let vertices = wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<super::TerrainVertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &super::TERRAIN_ATTRIBUTES,
};
let instances = wgpu::VertexBufferLayout {
array_stride: 16,
step_mode: wgpu::VertexStepMode::Instance,
attributes: &instance_attributes,
};
let color_target = [Some(wgpu::ColorTargetState {
format: SHADOW_COLOR,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})];
let pipeline = |layout: &wgpu::PipelineLayout,
vertex: &str,
buffers: &[Option<wgpu::VertexBufferLayout<'_>>],
translucent: bool| {
let options = wgpu::PipelineCompilationOptions {
constants: &constants,
..Default::default()
};
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Native shader pack shadow"),
layout: Some(layout),
vertex: wgpu::VertexState {
module: &module,
entry_point: Some(vertex),
compilation_options: options.clone(),
buffers,
},
// Both sides: the quads that face the light are drawn (`facing_runs`), and
// plants' crossed quads cast from either side.
primitive: wgpu::PrimitiveState {
cull_mode: None,
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(true),
depth_compare: Some(wgpu::CompareFunction::LessEqual),
stencil: Default::default(),
bias: wgpu::DepthBiasState {
constant: 0,
slope_scale: 1.5,
clamp: 0.0,
},
}),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some(if translucent {
"horizon_shadow_fs_translucent"
} else {
"horizon_shadow_fs"
}),
compilation_options: options,
targets: if translucent { &color_target } else { &[] },
}),
multiview_mask: None,
cache: None,
})
};
let instanced = layout(None);
let solid = pipeline(
&instanced,
"horizon_shadow_vs",
&[Some(vertices.clone()), Some(instances.clone())],
false,
);
let translucent = pipeline(
&instanced,
"horizon_shadow_vs",
&[Some(vertices.clone()), Some(instances)],
true,
);
let flat_layout = shape.flat.map(|group| layout(Some(group)));
let flat_pipeline = |translucent| {
flat_layout.as_ref().map(|layout| {
pipeline(
layout,
"horizon_shadow_vs_flat",
&[Some(vertices.clone())],
translucent,
)
})
};
let flat = flat_pipeline(false);
let flat_translucent = flat_pipeline(true);
let entity = entity_pipeline(device, shape.entities, &frame_layout);
if let Some(e) = pollster::block_on(scope.pop()) {
return Err(format!("shadow pass: {e}"));
}
Ok(Self {
solid,
flat,
translucent,
flat_translucent,
entity,
group,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_shadow_shader_validates() {
for materials in [&[][..], &[(3, 7), (4, 8), (9, 7)][..]] {
let text = shader_source(&shadow_solid(materials, &[7]));
let module = naga::front::wgsl::parse_str(&text).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&text)));
}
}
#[test]
fn only_the_layers_of_solid_ids_lose_their_cutouts() {
let materials = [(3, 7), (4, 8), (9, 7)];
assert!(shadow_solid(&materials, &[7]).contains("case 3u, 9u: { return true; }"));
assert!(!shadow_solid(&materials, &[]).contains("case"));
assert!(!shadow_solid(&materials, &[5]).contains("case"));
}
#[test]
fn the_entity_shader_validates() {
let text = entity_source();
let module = naga::front::wgsl::parse_str(&text).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&text)));
}
#[test]
fn what_the_camera_stands_on_is_inside_the_shadow_cameras_depth() {
let settings = Settings {
resolution: 2048,
distance: 128.0,
solid_materials: Vec::new(),
};
let shadow = camera(&settings);
let (view, projection) = super::super::pack_uniforms::shadow_camera(
glam::Vec3::new(0.3, 0.9, 0.0).normalize(),
glam::DVec3::new(-2064.5, 246.0, -4304.5),
&shadow,
);
// Up to the build height's span below a player flying high, as well as above them.
for y in [64.0, 0.0, -64.0, -200.0, -384.0] {
let clip = projection * view * glam::Vec4::new(0.0, y, 0.0, 1.0);
assert!(clip.is_finite(), "{clip}");
assert!((-1.0..=1.0).contains(&clip.z), "{clip}");
}
}
#[test]
fn the_centre_stays_near_the_player_on_both_sides_of_zero() {
let shadow = camera(&Settings {
resolution: 2048,
distance: 128.0,
solid_materials: Vec::new(),
});
let light = glam::Vec3::Y;
for x in [5.5, -5.5, -2064.5, 2064.5] {
let camera = glam::DVec3::new(x, 64.0, x);
let (view, _) = super::super::pack_uniforms::shadow_camera(light, camera, &shadow);
let centre = view.inverse().w_axis.truncate();
assert!(centre.x.abs() <= SNAP / 2.0, "{x}: {centre}");
assert!(centre.z.abs() <= SNAP / 2.0, "{x}: {centre}");
}
}
#[test]
fn the_distortion_brings_the_centre_texel_to_a_32nd_of_a_block() {
// 2048 texels over 256 blocks: an 8th of a block each, magnified 4 times.
assert!((distortion(128.0, 2048) - 0.75).abs() < 1e-6);
// Already that fine: no distortion.
assert_eq!(distortion(32.0, 2048), 0.0);
assert_eq!(distortion(10_000.0, 1024), 0.95);
}
}
@@ -252,7 +252,7 @@ mod tests {
resource: globals_buffer.as_entire_binding(),
}],
});
let frame = NativeFrame::new(&device);
let frame = NativeFrame::new(&device, None, Vec::new(), Default::default(), None);
queue.write_buffer(
&frame.buffer,
0,
@@ -35,6 +35,8 @@ pub struct PackInputs {
/// `heldItemId`, `heldItemId2` (item.properties, 5.3) and `heldBlockLightValue(2)`.
pub held_items: [i32; 2],
pub held_light: [i32; 2],
/// The values native mods hand their packs (`frame.mods`, `native_shader_params`).
pub mod_params: [[f32; 4]; 8],
}
/// One uniform's value: up to 16 floats or 3 integers.
@@ -245,7 +247,7 @@ pub fn shadow_culling(frame: &Frame, shadow: &Shadow) -> Mat4 {
/// How far toward the light the shadow projection starts, in blocks: part of the projection,
/// not of `shadowModelView` (Iris's matrices, measured).
const SHADOW_EYE: f32 = 100.0;
pub const SHADOW_EYE: f32 = 100.0;
/// The shadow camera as Iris places it (7.1; measured from the outside): its view (player
/// space to shadow view space) and GL projection. The view undoes `sunPathRotation`, turns the
@@ -264,7 +266,12 @@ pub fn shadow_camera(light: Vec3, camera: DVec3, shadow: &Shadow) -> (Mat4, Mat4
* DMat4::from_rotation_x(tilt);
let step = f64::from(shadow.interval);
let origin = if step > 0.0 {
DVec3::splat(step / 2.0) - camera % step
let remainder = if shadow.euclidean_snap {
camera.rem_euclid(DVec3::splat(step))
} else {
camera % step
};
DVec3::splat(step / 2.0) - remainder
} else {
DVec3::ZERO
};
+169 -2
View File
@@ -3,6 +3,10 @@ use crate::weather::{Time, Vertex};
pub(super) struct Weather {
pipeline: wgpu::RenderPipeline,
/// The layouts of groups 0 (the entities') and 1 (the fall clock), for a native pack's
/// weather pipeline.
pub entity_layout: wgpu::BindGroupLayout,
pub time_layout: wgpu::BindGroupLayout,
group: wgpu::BindGroup,
uniform: wgpu::Buffer,
vertices: wgpu::Buffer,
@@ -84,6 +88,8 @@ impl Weather {
});
Self {
pipeline,
entity_layout: entities.clone(),
time_layout: bindings,
group,
uniform,
vertices: Self::buffer(device, 4),
@@ -138,11 +144,24 @@ impl Weather {
pass.set_vertex_buffer(0, self.vertices.slice(..));
pass.draw_indexed(0..self.count / 4 * 6, 0, 0..1);
}
pub fn draw(&self, pass: &mut wgpu::RenderPass<'_>, entities: &wgpu::BindGroup) {
/// Draws the streaks, through a native pack's weather stage when `native` is given (its
/// pipeline and the pack's frame group).
pub fn draw(
&self,
pass: &mut wgpu::RenderPass<'_>,
entities: &wgpu::BindGroup,
native: Option<(&wgpu::RenderPipeline, &wgpu::BindGroup)>,
) {
if self.count == 0 {
return;
}
pass.set_pipeline(&self.pipeline);
match native {
Some((pipeline, frame)) => {
pass.set_pipeline(pipeline);
pass.set_bind_group(horizon_shader_api::stages::GROUP, frame, &[]);
}
None => pass.set_pipeline(&self.pipeline),
}
pass.set_bind_group(0, entities, &[]);
pass.set_bind_group(1, &self.group, &[]);
pass.set_vertex_buffer(0, self.vertices.slice(..));
@@ -163,8 +182,156 @@ fn depth_stencil() -> wgpu::DepthStencilState {
}
}
/// The entries around a native pack's `weather`: Horizon's weather vertex, then the texture
/// as `fs_weather` reads it, handed to the pack with the light and the position.
const NATIVE_ENTRIES: &str = "
struct HorizonWeatherOut {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
@location(1) color: vec4<f32>,
@location(2) @interpolate(flat) rect: vec4<f32>,
@location(3) light: vec2<f32>,
@location(4) position: vec3<f32>,
@location(5) @interpolate(flat) snow: u32,
}
@vertex fn horizon_weather_vs(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
@location(2) rect: vec4<f32>, @location(3) animation: vec4<f32>,
@location(4) bases: vec4<u32>, @location(5) color: vec4<f32>,
@location(6) light: vec4<u32>, @location(7) snow: u32) -> HorizonWeatherOut {
let phase = horizon_entity_weather_phase(animation, bases, snow,
horizon_entity_weather_time.values);
let v = horizon_entity_entity_vertex(position, uv + phase, 0u, color, light, vec4(0.0));
var out: HorizonWeatherOut;
out.clip = v.clip;
out.uv = v.uv;
out.color = v.color;
out.rect = rect;
// entity_vertex's light: block in y, sky in x, 0..240.
out.light = min(vec2(f32(light.y), f32(light.x)) / 240.0, vec2(1.0));
out.position = position;
out.snow = snow;
return out;
}
@fragment fn horizon_weather_fs(in: HorizonWeatherOut) -> @location(0) vec4<f32> {
let texel = horizon_entity_weather_texel(in.uv, in.rect,
textureDimensions(horizon_entity_atlas));
let color = textureLoad(horizon_entity_atlas, texel, 0) * in.color;
if color.a < 0.1 { discard; }
return weather(WeatherInput(color, in.light, in.position, in.snow != 0u));
}
";
/// The shader of a native pack's weather stage `source`.
fn native_source(source: &str) -> String {
format!(
"{source}\n// Horizon's weather (entity.wgsl)\n{}{NATIVE_ENTRIES}",
super::wgsl_prefix::prefixed(&super::entity_shader(), "horizon_entity_")
)
}
/// A native pack's weather pipeline: its `stage` (with the option values `constants`), then
/// entity.wgsl with every name prefixed, then the entries above. Group 0 is the entities',
/// 1 the fall clock (`time_layout`), 3 the pack's frame.
pub(super) fn native_pipeline(
device: &wgpu::Device,
format: wgpu::TextureFormat,
entities: &wgpu::BindGroupLayout,
time_layout: &wgpu::BindGroupLayout,
frame: &super::native_post::NativeFrame,
stage: &horizon_shader_api::check::CheckedStage,
constants: &[(String, f64)],
) -> Result<wgpu::RenderPipeline, String> {
use horizon_shader_api::stages::GROUP;
let text = native_source(&stage.source);
let module = naga::front::wgsl::parse_str(&text)
.map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text)))?;
let present = super::native_post::present_constants(&module, constants);
let mut groups: [Option<&wgpu::BindGroupLayout>; GROUP as usize + 1] = Default::default();
groups[0] = Some(entities);
groups[1] = Some(time_layout);
groups[GROUP as usize] = Some(&frame.layout);
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Native shader pack weather"),
bind_group_layouts: &groups,
..Default::default()
});
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some(&stage.label),
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
});
let options = wgpu::PipelineCompilationOptions {
constants: &present,
..Default::default()
};
let attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Float32x4,
3=>Float32x4,4=>Uint32x4,5=>Unorm8x4,6=>Uint8x4,7=>Uint32];
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Native shader pack weather"),
layout: Some(&layout),
vertex: wgpu::VertexState {
module: &module,
entry_point: Some("horizon_weather_vs"),
compilation_options: options.clone(),
buffers: &[Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<Vertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &attributes,
})],
},
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
..Default::default()
},
depth_stencil: Some(depth_stencil()),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some("horizon_weather_fs"),
compilation_options: options,
targets: &[Some(wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState {
color: wgpu::BlendState::ALPHA_BLENDING.color,
// The frame's alpha stays as the surfaces left it (entities mark it 0).
alpha: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::Zero,
dst_factor: wgpu::BlendFactor::One,
operation: wgpu::BlendOperation::Add,
},
}),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
});
if let Some(e) = pollster::block_on(scope.pop()) {
return Err(format!("{}: {e}", stage.file));
}
Ok(pipeline)
}
#[cfg(test)]
mod tests {
use super::native_source;
#[test]
fn a_native_weather_stage_validates() {
use horizon_shader_api::stages::StageKind;
let source = StageKind::Weather.prelude()
+ "fn weather(input: WeatherInput) -> vec4<f32> {\n \
return input.color * vec4(input.light, input.position.x, f32(input.snow));\n}\n";
let text = native_source(&source);
let module = naga::front::wgsl::parse_str(&text)
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&text)));
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&text)));
}
use crate::weather::{Parameters, Time};
use wgpu::util::DeviceExt;
+42 -1
View File
@@ -313,6 +313,8 @@ pub fn reduced_far_detail_suggested() -> bool {
/// The name the options screen waits on to bind `Graphics::hdr_key`.
pub const HDR_KEY: &str = "horizon.hdrSettings";
/// The Screenshot Quality choices, times the game's resolution.
pub const SCREENSHOT_QUALITIES: [u32; 4] = [1, 2, 4, 8];
static HDR_OFFERED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
/// Whether the display path offers HDR output (HDR10 or scRGB): set when the renderer
/// opens its surface; the settings open HDR's screen only then.
@@ -362,10 +364,16 @@ pub struct Graphics {
pub chunk_updates: u32,
/// Section mesh bytes uploaded per frame, in KiB; 0 is unlimited.
pub upload_budget_kib: u32,
/// The block textures' video memory (their top mip level), in MiB; 0 leaves it to
/// Horizon (`sprites::DEFAULT_TEXTURE_BUDGET`). Applies when resources next load.
pub texture_budget_mib: u32,
pub sky: bool,
pub fog: bool,
pub weather: bool,
pub animated_textures: bool,
/// Dithers the small mip levels of cutouts from 64 texels up, so a high-resolution pack's
/// fine plants keep their coverage in the distance; off is vanilla's look.
pub dither_cutout_mips: bool,
pub present_mode: PresentMode,
pub pacing: Pacing,
/// Chunk worker threads; 0 uses every core but one. Applies at the next start.
@@ -379,6 +387,8 @@ pub struct Graphics {
pub taa: bool,
/// TAA upscaling; anything but `Native` comes with `taa` on.
pub upscaling: Upscaling,
/// F2 screenshots at this many times the game's resolution: 1, 2, 4 or 8.
pub screenshot_quality: u32,
/// HDR output (HDR10, or scRGB), where the display path offers it.
pub hdr: bool,
/// HDR paper white (where SDR white sits), in nits; 0 is Auto: the desktop's SDR
@@ -420,10 +430,12 @@ impl Default for Graphics {
entity_culling: false,
chunk_updates: 0,
upload_budget_kib: 0,
texture_budget_mib: 0,
sky: true,
fog: true,
weather: true,
animated_textures: true,
dither_cutout_mips: false,
present_mode: PresentMode::Auto,
pacing: Pacing::Efficient,
workers: 0,
@@ -432,6 +444,7 @@ impl Default for Graphics {
fxaa: false,
taa: false,
upscaling: Upscaling::Native,
screenshot_quality: 1,
hdr: false,
hdr_paper_white: 0,
hdr_peak: 0,
@@ -485,10 +498,14 @@ impl Graphics {
"entityCulling" => g.entity_culling = flag(g.entity_culling),
"chunkUpdates" => g.chunk_updates = number(g.chunk_updates).min(512),
"uploadBudgetKiB" => g.upload_budget_kib = number(g.upload_budget_kib).min(65536),
"textureBudgetMiB" => {
g.texture_budget_mib = number(g.texture_budget_mib).min(16384);
}
"sky" => g.sky = flag(g.sky),
"fog" => g.fog = flag(g.fog),
"weather" => g.weather = flag(g.weather),
"animatedTextures" => g.animated_textures = flag(g.animated_textures),
"ditherCutoutMips" => g.dither_cutout_mips = flag(g.dither_cutout_mips),
"presentMode" => g.present_mode = PresentMode::parse(value),
"framePacing" => {
g.pacing = if value == "precise" {
@@ -502,6 +519,11 @@ impl Graphics {
"fxaa" => g.fxaa = flag(g.fxaa),
"taa" => g.taa = flag(g.taa),
"upscaling" => g.upscaling = Upscaling::parse(value),
"screenshotQuality" => {
g.screenshot_quality = Some(number(0))
.filter(|q| SCREENSHOT_QUALITIES.contains(q))
.unwrap_or(1)
}
"hdr" => g.hdr = flag(g.hdr),
"hdrPaperWhite" => {
g.hdr_paper_white = match number(g.hdr_paper_white) {
@@ -538,7 +560,7 @@ impl Graphics {
}
pub fn serialize(&self) -> String {
format!(
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\n",
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\n",
self.preset.name(),
self.lod_distance,
self.far_detail.name(),
@@ -546,10 +568,12 @@ impl Graphics {
bool_text(self.entity_culling),
self.chunk_updates,
self.upload_budget_kib,
self.texture_budget_mib,
bool_text(self.sky),
bool_text(self.fog),
bool_text(self.weather),
bool_text(self.animated_textures),
bool_text(self.dither_cutout_mips),
self.present_mode.name(),
match self.pacing {
Pacing::Efficient => "efficient",
@@ -565,6 +589,7 @@ impl Graphics {
bool_text(self.fxaa),
bool_text(self.taa),
self.upscaling.name(),
self.screenshot_quality,
bool_text(self.hdr),
self.hdr_paper_white,
self.hdr_peak,
@@ -698,10 +723,12 @@ mod tests {
entity_culling: true,
chunk_updates: 12,
upload_budget_kib: 4096,
texture_budget_mib: 1536,
sky: false,
fog: false,
weather: false,
animated_textures: false,
dither_cutout_mips: true,
present_mode: PresentMode::Mailbox,
pacing: Pacing::Precise,
workers: 6,
@@ -710,6 +737,7 @@ mod tests {
fxaa: true,
taa: true,
upscaling: Upscaling::Balanced,
screenshot_quality: 4,
hdr: true,
hdr_paper_white: 240,
hdr_peak: 1400,
@@ -728,6 +756,19 @@ mod tests {
assert_eq!(Graphics::parse("hdrPeak:50\n").hdr_peak, 200);
assert_eq!(Graphics::parse("hdrPaperWhite:0\n").hdr_paper_white, 0);
assert_eq!(Graphics::parse("hdrPaperWhite:5\n").hdr_paper_white, 10);
// Only the button's choices.
assert_eq!(
Graphics::parse("screenshotQuality:8\n").screenshot_quality,
8
);
assert_eq!(
Graphics::parse("screenshotQuality:3\n").screenshot_quality,
1
);
assert_eq!(
Graphics::parse("screenshotQuality:16\n").screenshot_quality,
1
);
// The old menus' Simple / Advanced choice is ignored, and so is the
// shader pack older versions saved.
assert_eq!(Graphics::parse("level:advanced\n"), Graphics::default());
@@ -33,6 +33,7 @@ pub enum H {
Fog,
Weather,
AnimatedTextures,
DitherCutoutMips,
PresentMode,
Pacing,
Workers,
@@ -40,6 +41,7 @@ pub enum H {
ResolutionScale,
Antialiasing,
Upscaling,
ScreenshotQuality,
Hdr,
/// Opens `Level::Hdr`.
HdrSettings,
@@ -260,6 +262,11 @@ pub fn rows(level: Level) -> Vec<Row> {
Low,
"Animates water, lava, fire and other moving textures.",
),
h(
H::DitherCutoutMips,
Low,
"Keeps fine plants in high-res packs from fading far away.",
),
Row::Header("Display"),
fov,
fps,
@@ -279,6 +286,11 @@ pub fn rows(level: Level) -> Vec<Row> {
High,
"Renders the world smaller and rebuilds it with TAA. Faster, softer.",
),
h(
H::ScreenshotQuality,
None,
"F2 screenshots at this many times the game's resolution. Higher takes longer.",
),
h(
H::HdrSettings,
None,
@@ -519,6 +531,7 @@ impl H {
H::Fog => labelled("Fog", on_off(g.fog)),
H::Weather => labelled("Weather", on_off(g.weather)),
H::AnimatedTextures => labelled("Animated Textures", on_off(g.animated_textures)),
H::DitherCutoutMips => labelled("Distant Plant Dither", on_off(g.dither_cutout_mips)),
H::PresentMode => labelled("Present Mode", g.present_mode.label()),
H::Pacing => labelled(
"Frame Pacing",
@@ -544,6 +557,9 @@ impl H {
},
),
H::Upscaling => labelled("Upscaling", g.upscaling.label()),
H::ScreenshotQuality => {
labelled("Screenshot Quality", format!("{}x", g.screenshot_quality))
}
H::Hdr => labelled("HDR", on_off(g.hdr)),
H::HdrSettings => Text::literal(lang::get("HDR Settings...")),
H::HdrCalibrate => Text::literal(lang::get(match setup(s) {
@@ -637,6 +653,7 @@ impl H {
H::Fog => g.fog = !g.fog,
H::Weather => g.weather = !g.weather,
H::AnimatedTextures => g.animated_textures = !g.animated_textures,
H::DitherCutoutMips => g.dither_cutout_mips = !g.dither_cutout_mips,
H::PresentMode => g.present_mode = step(&PresentMode::ALL, g.present_mode, back),
// Off, FXAA, TAA.
H::Antialiasing => {
@@ -654,6 +671,13 @@ impl H {
(g.fxaa, g.taa) = (false, true);
}
}
H::ScreenshotQuality => {
g.screenshot_quality = step(
&crate::graphics::SCREENSHOT_QUALITIES,
g.screenshot_quality,
back,
)
}
H::Hdr => g.hdr = !g.hdr,
H::BatteryMode => g.battery = !g.battery,
H::ShadowCache => g.shadow_cache = !g.shadow_cache,
@@ -717,6 +741,7 @@ impl Item {
Item::Horizon(H::Fog) => g.fog,
Item::Horizon(H::Weather) => g.weather,
Item::Horizon(H::AnimatedTextures) => g.animated_textures,
Item::Horizon(H::DitherCutoutMips) => g.dither_cutout_mips,
Item::Horizon(H::Hdr) => g.hdr,
Item::Horizon(H::BatteryMode) => g.battery,
Item::Horizon(H::ShadowCache) => g.shadow_cache,
@@ -775,6 +800,7 @@ impl Item {
Item::Horizon(
H::Preset
| H::Overlay
| H::ScreenshotQuality
| H::Pacing
| H::PresentMode
| H::Workers
@@ -1743,6 +1769,21 @@ mod tests {
assert_eq!(s.horizon.preset, Preset::Ultra);
}
#[test]
fn screenshot_quality_cycles_1_2_4_8_and_keeps_the_preset() {
let mut s = Settings::default();
Item::Horizon(H::Preset).cycle(&mut s, false, [854, 480]);
let mut seen = Vec::new();
for _ in 0..4 {
Item::Horizon(H::ScreenshotQuality).cycle(&mut s, false, [854, 480]);
seen.push(s.horizon.screenshot_quality);
}
assert_eq!(seen, [2, 4, 8, 1]);
Item::Horizon(H::ScreenshotQuality).cycle(&mut s, true, [854, 480]);
assert_eq!(s.horizon.screenshot_quality, 8);
assert_eq!(s.horizon.preset, Preset::Performance);
}
#[test]
fn upload_budget_slider_steps() {
let mut s = Settings::default();
+40 -9
View File
@@ -528,20 +528,33 @@ impl HudAtlas {
for folder in folders {
walk(&textures.join(folder), &mut files);
}
// Content mods' equipment textures, which their equipment assets name as
// `<ns>:<path>`, and their mob effects' icons, `<ns>:mob_effect/<path>`.
// Content mods' entity textures: their equipment assets name them as
// `<ns>:<path>`, and native mods draw their block entities with them
// (`native_draw_quad`: `<ns>:entity/...`); and their mob effects'
// icons, `<ns>:mob_effect/<path>`.
let namespaces = horizon_items::content_namespaces::namespaces(root);
for (folder, textures) in [("entity", "entity/equipment"), ("mob_effect", "mob_effect")] {
for folder in ["entity", "mob_effect"] {
if folders.contains(&folder) {
for namespace in &namespaces {
walk(
&root.join(format!("assets/{namespace}/textures/{textures}")),
&root.join(format!("assets/{namespace}/textures/{folder}")),
&mut files,
);
}
}
}
let assets = root.join("assets");
// A native mod's particle type names its textures `<ns>:particle/<path>`
// and needn't ship content, so every namespace's folder is packed.
if folders.contains(&"particle")
&& let Ok(namespaces) = fs::read_dir(&assets)
{
for ns in namespaces.flatten() {
if ns.file_name() != "minecraft" {
walk(&ns.path().join("textures/particle"), &mut files);
}
}
}
// Content mods' GUI textures, for their screens: `<namespace>:gui/...`.
if full && let Ok(namespaces) = fs::read_dir(&assets) {
for ns in namespaces.flatten() {
@@ -565,6 +578,12 @@ impl HudAtlas {
format!("{namespace}:{rest}")
}
};
// LabPBR's maps beside a texture are the world's (sprites.rs), not images to draw.
if let Some(base) = name.strip_suffix("_n").or_else(|| name.strip_suffix("_s"))
&& textures.join(format!("{base}.png")).is_file()
{
return None;
}
let image = decode(path)
.map_err(|e| log::warn!("skipped GUI texture {}: {e}", path.display()))
.ok()?;
@@ -1604,6 +1623,9 @@ pub struct HudRenderer {
atlas_view: wgpu::TextureView,
/// The resource pack's text shader, when it has one that works.
pack_text: Option<pack_text::PackText>,
/// Target pixels per window pixel: above 1 for a screenshot drawn larger than the
/// window (`Renderer::screenshot_quality`).
pub zoom: f32,
}
impl HudRenderer {
pub fn new(
@@ -1833,6 +1855,7 @@ impl HudRenderer {
format,
atlas_view: view.clone(),
pack_text: None,
zoom: 1.0,
})
}
/// Draw the GUI's text with `shader` (`HudAtlas::text_shader`), or natively.
@@ -1968,14 +1991,14 @@ impl HudRenderer {
[
size[0] as f32,
size[1] as f32,
batch.scale,
batch.scale * self.zoom,
batch.glint_alpha,
],
batch.end_portal,
)),
);
if let Some(pack_text) = &self.pack_text {
pack_text.prepare(uploads, encoder, batch, size);
pack_text.prepare(uploads, encoder, batch, size, batch.scale * self.zoom);
}
}
/// Draw the batch `prepare` staged.
@@ -2048,7 +2071,8 @@ impl HudRenderer {
});
pass.set_bind_group(0, &self.group, &[]);
pass.set_vertex_buffer(0, self.buffer.slice(..));
if let Some([x, y, w, h]) = clip {
if let Some(clip) = clip {
let [x, y, w, h] = clip.map(|v| (v as f32 * self.zoom) as u32);
let (x, y) = (x.min(size[0]), y.min(size[1]));
let (w, h) = (w.min(size[0] - x), h.min(size[1] - y));
if w == 0 || h == 0 {
@@ -2186,7 +2210,7 @@ mod tests {
atlas.pixels[at..at + 4].try_into().unwrap()
}
#[test]
fn content_mod_equipment_textures_pack_under_their_namespace() {
fn content_mod_entity_textures_pack_under_their_namespace() {
let root = std::env::temp_dir().join(format!("horizon-atlas-equip-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
let equipment = root.join("assets/gems/textures/entity/equipment/humanoid");
@@ -2206,10 +2230,17 @@ mod tests {
br#"["gems"]"#,
)
.unwrap();
// A block entity's texture, for a native mod's renderer.
let machine = root.join("assets/gems/textures/entity/machine");
fs::create_dir_all(&machine).unwrap();
write_strip(&machine.join("cog.png"), 32, 32, &[[0, 0, 200, 255]], None);
let atlas = HudAtlas::build(&root).unwrap();
let rect = atlas.sprites["gems:entity/equipment/humanoid/ruby"];
assert_eq!(texel_at(&atlas, rect, 0, 0), [200, 0, 0, 255]);
// Only equipment: the mods' other textures reach the atlas through atlas definitions.
let rect = atlas.sprites["gems:entity/machine/cog"];
assert_eq!(rect.texels((atlas.width, atlas.height)), (32, 32));
assert_eq!(texel_at(&atlas, rect, 0, 0), [0, 0, 200, 255]);
// Only entity textures: the mods' others reach the atlas through atlas definitions.
assert!(!atlas.sprites.contains_key("gems:item/ruby"));
fs::remove_dir_all(&root).unwrap();
}
@@ -216,8 +216,9 @@ impl PackText {
encoder: &mut wgpu::CommandEncoder,
batch: &HudBatch,
size: [u32; 2],
scale: f32,
) {
uploads.buffer(encoder, &self.uniform, 0, &uniforms(batch, size));
uploads.buffer(encoder, &self.uniform, 0, &uniforms(batch, size, scale));
}
/// Draw `range` (whole glyph quads) with the pack's shader; the caller sets its own
@@ -233,7 +234,8 @@ impl PackText {
/// `GlintAlpha`, `GameTime`, `ScreenSize`), `Projection` (`GuiRenderer`'s orthographic GUI
/// projection, 1000 to 11000 deep, in GL's depth range), `DynamicTransforms` (the GUI's model view
/// moving 11000 back, colour modulator white) and an empty `Fog`.
fn uniforms(batch: &HudBatch, size: [u32; 2]) -> Vec<u8> {
/// `scale`: target pixels per GUI pixel (`batch.scale`, times `HudRenderer::zoom`).
fn uniforms(batch: &HudBatch, size: [u32; 2], scale: f32) -> Vec<u8> {
let mut data = vec![0f32; 256];
let globals = 0;
data[globals + 3] = batch.glint_alpha;
@@ -241,7 +243,7 @@ fn uniforms(batch: &HudBatch, size: [u32; 2]) -> Vec<u8> {
data[globals + 8] = size[0] as f32;
data[globals + 9] = size[1] as f32;
// Matrix4f.setOrtho(0, width, height, 0, near, far) with the GUI's scale, columns.
let (w, h) = (size[0] as f32 / batch.scale, size[1] as f32 / batch.scale);
let (w, h) = (size[0] as f32 / scale, size[1] as f32 / scale);
let (near, far) = (1000.0f32, 11000.0f32);
let projection = 64;
data[projection] = 2.0 / w;
@@ -287,7 +289,8 @@ mod tests {
fn the_gui_projection_maps_gui_pixels_to_the_screen() {
let mut batch = HudBatch::new([1920, 1080]);
batch.scale = 4.0;
let data: Vec<f32> = bytemuck::cast_slice(&uniforms(&batch, [1920, 1080])).to_vec();
let data: Vec<f32> =
bytemuck::cast_slice(&uniforms(&batch, [1920, 1080], batch.scale)).to_vec();
let column = |matrix: usize, c: usize| &data[matrix + 4 * c..matrix + 4 * c + 4];
let apply = |matrix: usize, v: [f32; 4]| -> [f32; 4] {
std::array::from_fn(|r| (0..4).map(|c| column(matrix, c)[r] * v[c]).sum())
@@ -231,6 +231,9 @@ pub struct SectionMesh {
/// each frame by `block_entity_render` like vanilla's per-section
/// `SectionCompiler.Results.blockEntities`.
pub block_entities: Vec<([i32; 3], u32)>,
/// Blocks that give off light (world position, state id), for the colour of block light
/// Horizon's shader packs read (`gfx::block_light`).
pub lights: Vec<([i32; 3], u32)>,
/// Sprite layers emitted while building this mesh. Kept distinct before
/// upload so the renderer does not rescan every terrain vertex.
pub sprite_layers: Vec<u16>,
@@ -248,6 +251,7 @@ impl Default for SectionMesh {
translucent: Vec::new(),
visibility: ALL_VISIBLE,
block_entities: Vec::new(),
lights: Vec::new(),
sprite_layers: Vec::new(),
bounds: None,
keep: false,
@@ -1124,6 +1128,10 @@ impl Mesher<'_> {
out.block_entities
.push(([center.x * 16 + x, base_y + y, center.z * 16 + z], id));
}
if state.emission > 0 {
out.lights
.push(([center.x * 16 + x, base_y + y, center.z * 16 + z], id));
}
if state.solid
&& Direction::ALL.iter().all(|d| {
let [dx, dy, dz] = d.normal();
@@ -13,9 +13,10 @@
//!
//! The buckets lie one after another in the mesh, in an order chosen so that
//! the buckets an eye can see are usually one or two runs of quads: one draw
//! each. Inside a bucket quads keep their model order, so coplanar quads
//! facing one way (an overlay on its base) still draw in that order.
use super::TerrainVertex;
//! each. Coplanar quads facing one way keep their model order, so an overlay
//! on its base still draws in that order. Solid cutout quads are otherwise
//! ordered from the outside toward the section interior to reduce overdraw.
use super::{OPAQUE_LAYER, TerrainVertex};
/// The buckets in mesh order: +X, +Z, other, +Y, -Z, -X, -Y. Seen from
/// above (+Y, the commonest case), each pair of horizontal sides with a
@@ -138,11 +139,14 @@ fn bucket(quad: &[TerrainVertex; 4]) -> usize {
OTHER
}
/// Reorders `solid`'s quads by bucket, keeping their order inside each.
/// Reorders `solid`'s quads by bucket. Solid cutout quads are also ordered
/// front to back within each axis-facing bucket; stable sorting preserves the
/// mesher's order for coplanar overlays.
pub fn sort(solid: &mut Vec<TerrainVertex>) -> Sides {
let (quads, rest) = solid.as_chunks::<4>();
debug_assert!(rest.is_empty(), "solid vertices come in quads");
let buckets: Vec<u8> = quads.iter().map(|q| bucket(q) as u8).collect();
let has_cutout = quads.iter().any(|q| q[0].layer & OPAQUE_LAYER == 0);
let mut counts = [0u32; 7];
let mut planes = [0f32; 7];
for (quad, &b) in quads.iter().zip(&buckets) {
@@ -163,7 +167,7 @@ pub fn sort(solid: &mut Vec<TerrainVertex>) -> Sides {
total += count;
*end = total;
}
if counts.iter().filter(|&&c| c > 0).count() > 1 {
if has_cutout || counts.iter().filter(|&&c| c > 0).count() > 1 {
let mut next: [u32; 7] = std::array::from_fn(|b| ends[b] - counts[b]);
let mut sorted = vec![quads[0]; quads.len()];
for (quad, &b) in quads.iter().zip(&buckets) {
@@ -171,6 +175,25 @@ pub fn sort(solid: &mut Vec<TerrainVertex>) -> Sides {
sorted[*slot as usize] = *quad;
*slot += 1;
}
if has_cutout {
let mut start = 0;
for (i, bucket) in BUCKETS.iter().enumerate() {
let end = ends[i] as usize;
let slice = &mut sorted[start..end];
if let Bucket::Side(axis, positive) = *bucket {
slice.sort_by(|a, b| {
let a = a[0].pos[axis];
let b = b[0].pos[axis];
if positive {
b.total_cmp(&a)
} else {
a.total_cmp(&b)
}
});
}
start = end;
}
}
*solid = sorted.into_flattened();
}
Sides { ends, planes }
@@ -368,4 +391,46 @@ mod tests {
before.iter().map(|v| v.layer).collect::<Vec<_>>()
);
}
#[test]
fn front_to_back_sort_orders_positive_faces_and_keeps_ties_stable() {
let mut solid: Vec<_> = [(1.0, 10), (3.0, 30), (3.0, 31)]
.into_iter()
.flat_map(|(x, layer)| {
quad(
[[x, 0.0, 0.0], [x, 1.0, 0.0], [x, 1.0, 1.0], [x, 0.0, 1.0]],
layer,
)
})
.collect();
sort(&mut solid);
let layers: Vec<u16> = solid
.as_chunks::<4>()
.0
.iter()
.map(|q| q[0].layer)
.collect();
assert_eq!(layers, [30, 31, 10]);
}
#[test]
fn front_to_back_sort_orders_negative_faces_toward_the_eye() {
let mut solid: Vec<_> = [(3.0, 30), (1.0, 10), (2.0, 20)]
.into_iter()
.flat_map(|(x, layer)| {
quad(
[[x, 0.0, 0.0], [x, 0.0, 1.0], [x, 1.0, 1.0], [x, 1.0, 0.0]],
layer,
)
})
.collect();
sort(&mut solid);
let layers: Vec<u16> = solid
.as_chunks::<4>()
.0
.iter()
.map(|q| q[0].layer)
.collect();
assert_eq!(layers, [10, 20, 30]);
}
}
+123
View File
@@ -38,6 +38,21 @@ pub struct Settings {
pub alpha_cutoff_bias: f32,
pub has_transparent: bool,
pub item: bool,
/// Whether a high-resolution cutout's small levels are dithered ([`dither_cutout_levels`]).
pub dither_cutouts: bool,
}
/// The `ditherCutoutMips` setting, for textures loaded from now on.
static DITHER_CUTOUTS: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
/// Sets `ditherCutoutMips`; true if that changes it, when the textures must load again.
pub fn set_dither_cutouts(on: bool) -> bool {
DITHER_CUTOUTS.swap(on, std::sync::atomic::Ordering::Relaxed) != on
}
/// The `ditherCutoutMips` setting.
pub fn dither_cutouts() -> bool {
DITHER_CUTOUTS.load(std::sync::atomic::Ordering::Relaxed)
}
/// A square or rectangular RGBA8 image, rows top to bottom.
@@ -362,5 +377,113 @@ fn generate_native(mut base: Image, levels: u32, settings: Settings) -> Vec<Imag
}
out.push(data);
}
if settings.dither_cutouts && cutout && out[0].width >= DITHERED_CUTOUT_SIZE {
dither_cutout_levels(&mut out);
}
out
}
/// From this size a cutout texture's mips can be dithered ([`dither_cutout_levels`]).
const DITHERED_CUTOUT_SIZE: u32 = 64;
/// A high-resolution cutout's detail is finer than its small mips: grass blades a few
/// texels wide at 2048 average to about the same alpha everywhere at 64, and no scale of
/// that alpha can keep the coverage, so the alpha test passes the whole quad or none of it.
/// Each level below the base takes the base's mean alpha through an ordered dither instead:
/// its texels are in or out, about as many in as the base has.
fn dither_cutout_levels(levels: &mut [Image]) {
const BAYER: [[f32; 4]; 4] = [
[0.0, 8.0, 2.0, 10.0],
[12.0, 4.0, 14.0, 6.0],
[3.0, 11.0, 1.0, 9.0],
[15.0, 7.0, 13.0, 5.0],
];
let (mut w, mut h) = (levels[0].width as usize, levels[0].height as usize);
let mut alpha: Vec<f32> = levels[0]
.pixels
.iter()
.map(|p| f32::from(p[3]) / 255.0)
.collect();
for level in &mut levels[1..] {
let (nw, nh) = (level.width as usize, level.height as usize);
let mut next = vec![0.0; nw * nh];
for y in 0..nh {
for x in 0..nw {
let at = |dx: usize, dy: usize| {
alpha[(y * 2 + dy).min(h - 1) * w + (x * 2 + dx).min(w - 1)]
};
let mean = (at(0, 0) + at(1, 0) + at(0, 1) + at(1, 1)) / 4.0;
next[y * nw + x] = mean;
let threshold = (BAYER[y % 4][x % 4] + 0.5) / 16.0;
level.pixels[y * nw + x][3] = if mean > threshold { 255 } else { 0 };
}
}
alpha = next;
(w, h) = (nw, nh);
}
}
#[cfg(test)]
mod dither_tests {
use super::*;
#[test]
fn the_dither_only_runs_when_asked_for_and_from_its_size() {
let cutout = |side: u32| Image {
width: side,
height: side,
pixels: (0..side * side)
.map(|i| {
[
90,
140,
40,
if (i % side).is_multiple_of(4) { 255 } else { 0 },
]
})
.collect(),
};
let settings = |dither_cutouts| Settings {
has_transparent: true,
dither_cutouts,
..Settings::default()
};
assert_ne!(
generate(cutout(128), 4, settings(false)),
generate(cutout(128), 4, settings(true))
);
assert_eq!(
generate(cutout(32), 4, settings(false)),
generate(cutout(32), 4, settings(true))
);
}
#[test]
fn a_high_resolution_cutouts_fine_detail_keeps_its_coverage_in_small_mips() {
// Blades one texel wide, one column in four: too fine for the mips to hold.
let side = 128u32;
let pixels = (0..side * side)
.map(|i| {
[
90,
140,
40,
if (i % side).is_multiple_of(4) { 255 } else { 0 },
]
})
.collect();
let base = Image {
width: side,
height: side,
pixels,
};
let settings = Settings {
has_transparent: true,
dither_cutouts: true,
..Settings::default()
};
let mips = generate(base, 4, settings);
for level in &mips[3..] {
let passing = level.pixels.iter().filter(|p| p[3] >= 128).count() as f32;
let coverage = passing / level.pixels.len() as f32;
assert!((0.15..0.35).contains(&coverage), "{coverage}");
}
}
}
+238 -18
View File
@@ -1236,6 +1236,9 @@ pub enum Page {
NativeMod(usize),
/// Horizon's shader packs, either kind (`shader_pack_list`).
ShaderPacks,
/// The options of `s.shader_packs[.0]`, a Horizon pack: its `[[pages]]` entry `.1`, the
/// first the one the button opens.
NativePackOptions(usize, u8),
/// The picked `shaders/` pack's options (`shader_options`), one of its screens at a time.
ShaderOptions,
/// Horizon's graphics settings (`graphics_ui`).
@@ -1282,6 +1285,12 @@ enum Act {
OpenShaderPackFolder,
/// Open the picked pack's Shader Options page.
ShaderOptions,
/// Open the options page of the Horizon pack `s.shader_packs[.0]`.
NativePackOptions(usize),
/// Option `.1` of that pack: its next value.
NativePackOption(usize, usize),
/// Every option of that pack back to its default.
NativePackReset(usize),
/// Shader Options: the next value of an option, a screen of the pack, the next profile,
/// every option back to its default.
ShaderOption(String),
@@ -1411,6 +1420,9 @@ pub enum Event {
OpenShaderOptions,
/// The Shader Options page closed with changes: save them and load the pack again.
ShaderOptions,
/// The options of `shader_packs[.0]`, a Horizon pack, changed: save them (the pack reloads
/// with them).
NativePackOptions(usize),
/// Native mod `.0`'s page opened: the game asks the mod for its labels
/// again (they were made with the list, maybe in another language).
NativeSettings(usize),
@@ -1627,18 +1639,12 @@ fn horizon_page_tooltip(page: Page) -> Option<&'static str> {
}
/// The open screen of the picked pack: its title, then its items two to a row (one when the
/// pack asks for a single column).
fn shader_option_rows(s: &Settings) -> Vec<Row> {
let Some(options) = &s.shader_options else {
return vec![Row::Header("horizon.shader_options.loading")];
};
let columns = options
.screens
.screen(options.screen())
.map_or(2, |(_, c)| c);
let blank = || Widget {
/// A list's slot with nothing in it: text takes no clicks.
fn blank_slot(w: f32) -> Widget {
Widget {
x: 0.0,
y: 0.0,
w: 150.0,
w,
h: 20.0,
kind: WKind::Text {
text: Text::literal(""),
@@ -1647,7 +1653,55 @@ fn shader_option_rows(s: &Settings) -> Vec<Row> {
},
list: false,
row: 0,
}
}
/// Page `at` of the options of the Horizon pack `s.shader_packs[i]`: the pack's own layout,
/// else every option two a row.
fn native_pack_option_rows(s: &Settings, i: usize, at: u8) -> Vec<Row> {
use horizon_shader_api::PageItem;
let Some(pack) = s.shader_packs.get(i) else {
return Vec::new();
};
let count = pack.options.len().min(pack.values.len());
let (columns, items) = match pack.pages.get(usize::from(at)) {
Some(page) => (usize::from(page.columns), page.items.clone()),
None => (2, (0..count).map(PageItem::Option).collect()),
};
let width = if columns == 1 { 310.0 } else { 150.0 };
let widget = |item: &PageItem| match *item {
PageItem::Option(o) if o < count => {
let option = &pack.options[o];
let value = crate::shader_pack_list::show_value(&option.kind, pack.values[o]);
let label = Text::literal(format!("{}: {value}", option.name));
button(0.0, 0.0, width, label, Act::NativePackOption(i, o))
}
PageItem::Page(p) if p < pack.pages.len() => {
let label = Text::literal(format!("{}...", pack.pages[p].name));
let page = Page::NativePackOptions(i, p as u8);
button(0.0, 0.0, width, label, Act::Push(page))
}
_ => blank_slot(width),
};
let mut rows = Vec::new();
if at == 0 {
rows.push(Row::Note(pack.title.clone()));
}
rows.extend(
items
.chunks(columns)
.map(|row| Row::Buttons(widget(&row[0]), row.get(1).map(widget))),
);
rows
}
fn shader_option_rows(s: &Settings) -> Vec<Row> {
let Some(options) = &s.shader_options else {
return vec![Row::Header("horizon.shader_options.loading")];
};
let columns = options
.screens
.screen(options.screen())
.map_or(2, |(_, c)| c);
let blank = || blank_slot(150.0);
let width = if columns == 1 { 310.0 } else { 150.0 };
let widget = |item: &horizon_shaders::ScreenItem| -> Option<Widget> {
use horizon_shaders::ScreenItem;
@@ -1712,6 +1766,26 @@ enum Row {
/// A widget's tooltip (`setTooltip`), unwrapped: drawn while hovered, and
/// its narration's hint.
fn tooltip_text(s: &Settings, wd: &Widget) -> Option<Text> {
let pack_text = match &wd.kind {
WKind::Button {
act: Act::NativePackOption(pack, option),
..
} => Some(&s.shader_packs.get(*pack)?.options.get(*option)?.description),
WKind::Button {
act: Act::Push(Page::NativePackOptions(pack, at)),
..
} => Some(
&s.shader_packs
.get(*pack)?
.pages
.get(usize::from(*at))?
.description,
),
_ => None,
};
if let Some(text) = pack_text {
return (!text.is_empty()).then(|| Text::literal(text));
}
let WKind::Button { act, .. } = &wd.kind else {
let WKind::Slider { opt, .. } = &wd.kind else {
return None;
@@ -2055,6 +2129,14 @@ impl OptionsUi {
_ => None,
}
}
/// The name of a Horizon pack's options page past its first, as the page's title.
fn pack_page_title(s: &Settings, page: Page) -> Option<Text> {
let Page::NativePackOptions(pack, at @ 1..) = page else {
return None;
};
let page = s.shader_packs.get(pack)?.pages.get(usize::from(at))?;
Some(Text::literal(&page.name))
}
fn title(page: Page) -> &'static str {
match page {
Page::Main => "options.title",
@@ -2073,7 +2155,7 @@ impl OptionsUi {
Page::Mods => "horizon.pages.mods",
Page::NativeMod(_) => "Mod Settings",
Page::ShaderPacks => "horizon.pages.shader_packs",
Page::ShaderOptions => "horizon.pages.shader_options",
Page::ShaderOptions | Page::NativePackOptions(..) => "horizon.pages.shader_options",
Page::Graphics | Page::GraphicsAdvanced | Page::GraphicsHdr | Page::HdrCalibration => {
"options.videoTitle"
}
@@ -2502,13 +2584,19 @@ impl OptionsUi {
if s.shader_packs.is_empty() {
return vec![Row::Header("horizon.shader_packs.empty")];
}
let options = s.shader_packs.iter().any(|p| {
p.file == s.horizon.shader_pack
&& p.kind == crate::shader_pack_list::Kind::Classic
});
let options = options.then(|| {
let picked = s
.shader_packs
.iter()
.position(|p| p.file == s.horizon.shader_pack);
let options = picked.and_then(|i| {
let pack = &s.shader_packs[i];
let act = match pack.kind {
crate::shader_pack_list::Kind::Classic => Act::ShaderOptions,
_ if pack.options.is_empty() => return None,
crate::shader_pack_list::Kind::Horizon => Act::NativePackOptions(i),
};
let label = tr("horizon.shader_options.open");
Row::Buttons(button(0.0, 0.0, 310.0, label, Act::ShaderOptions), None)
Some(Row::Buttons(button(0.0, 0.0, 310.0, label, act), None))
});
options
.into_iter()
@@ -2526,6 +2614,7 @@ impl OptionsUi {
.collect()
}
Page::ShaderOptions => shader_option_rows(s),
Page::NativePackOptions(i, at) => native_pack_option_rows(s, i, at),
_ => {
let _ = s;
Vec::new()
@@ -2977,7 +3066,8 @@ impl OptionsUi {
w,
h: 9.0,
kind: WKind::Text {
text: tr(Self::title(page)),
text: Self::pack_page_title(s, page)
.unwrap_or_else(|| tr(Self::title(page))),
color: u32::MAX,
centered: true,
},
@@ -3062,6 +3152,9 @@ impl OptionsUi {
Page::Mods => Some(("horizon.mods.open_folder", Act::OpenModFolder)),
Page::ShaderPacks => Some(("pack.openFolder", Act::OpenShaderPackFolder)),
Page::ShaderOptions => Some(("controls.reset", Act::ShaderReset)),
Page::NativePackOptions(i, _) => {
Some(("controls.reset", Act::NativePackReset(i)))
}
_ => None,
};
if let Some((label, act)) = folder {
@@ -3722,6 +3815,25 @@ impl OptionsUi {
options.cycle(&name, true);
events.push(Event::ButtonClick);
}
let native = widgets.iter().find_map(|wd| match wd.kind {
WKind::Button {
active: true,
act: Act::NativePackOption(pack, option),
..
} if ui::hovered((x, y), wd.x, wd.y, wd.w, wd.h)
&& !(wd.list && (y < top || y >= bottom)) =>
{
Some((pack, option))
}
_ => None,
});
if let Some((pack, option)) = native
&& let Some(entry) = s.shader_packs.get_mut(pack)
&& entry.step_option(option, true, false)
{
events.push(Event::ButtonClick);
events.push(Event::NativePackOptions(pack));
}
return events;
}
if ui::scrollbar_thumb(top, bottom, content, self.scroll).is_some()
@@ -3838,6 +3950,22 @@ impl OptionsUi {
options.reset();
}
}
Act::NativePackOptions(i) => {
self.push(Page::NativePackOptions(i, 0));
}
Act::NativePackOption(pack, option) => {
if let Some(entry) = s.shader_packs.get_mut(pack)
&& entry.step_option(option, false, self.shift)
{
events.push(Event::NativePackOptions(pack));
}
}
Act::NativePackReset(pack) => {
if let Some(entry) = s.shader_packs.get_mut(pack) {
entry.reset_options();
events.push(Event::NativePackOptions(pack));
}
}
Act::Credits => events.push(Event::Credits),
Act::CopyAgentDetails => events.push(Event::CopyAgentDetails),
Act::None => {}
@@ -4745,6 +4873,22 @@ impl OptionsUi {
options.reset();
}
}
Act::NativePackOptions(i) => {
self.push(Page::NativePackOptions(i, 0));
}
Act::NativePackOption(pack, option) => {
if let Some(entry) = s.shader_packs.get_mut(pack)
&& entry.step_option(option, false, shift)
{
events.push(Event::NativePackOptions(pack));
}
}
Act::NativePackReset(pack) => {
if let Some(entry) = s.shader_packs.get_mut(pack) {
entry.reset_options();
events.push(Event::NativePackOptions(pack));
}
}
Act::Credits => events.push(Event::Credits),
Act::CopyAgentDetails => events.push(Event::CopyAgentDetails),
Act::None => {}
@@ -5032,6 +5176,82 @@ mod tests {
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap()
}
#[test]
fn a_horizon_packs_options_step_both_ways_from_the_picked_packs_button() {
let atlas = atlas();
let size = (960.0, 540.0);
let mut s = Settings::default();
s.horizon.shader_pack = "p".into();
s.shader_packs = vec![crate::shader_pack_list::Entry {
file: "p".into(),
title: "P".into(),
kind: crate::shader_pack_list::Kind::Horizon,
options: vec![horizon_shader_api::PackOption {
id: "a".into(),
name: "A".into(),
description: String::new(),
kind: horizon_shader_api::OptionKind::Float {
default: 1.0,
min: 0.0,
max: 2.0,
step: 0.5,
},
}],
values: vec![1.0],
pages: vec![
horizon_shader_api::OptionPage {
id: "main".into(),
name: "P".into(),
description: String::new(),
columns: 2,
items: vec![horizon_shader_api::PageItem::Page(1)],
},
horizon_shader_api::OptionPage {
id: "more".into(),
name: "More".into(),
description: String::new(),
columns: 1,
items: vec![horizon_shader_api::PageItem::Option(0)],
},
],
}];
let mut ui = OptionsUi::default();
ui.show(Page::ShaderPacks);
let find = |ui: &mut OptionsUi, s: &Settings, want: &Act| {
let (widgets, ..) = ui.layout(s, size.0, size.1);
let w = widgets
.into_iter()
.find(|w| matches!(&w.kind, WKind::Button { act, .. } if act == want))
.expect("button");
(w.x + w.w / 2.0, w.y + w.h / 2.0)
};
let (x, y) = find(&mut ui, &s, &Act::NativePackOptions(0));
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false);
assert_eq!(ui.page(), Page::NativePackOptions(0, 0));
// The option is on the pack's second page, behind a button on its first.
let (x, y) = find(&mut ui, &s, &Act::Push(Page::NativePackOptions(0, 1)));
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false);
assert_eq!(ui.page(), Page::NativePackOptions(0, 1));
let (x, y) = find(&mut ui, &s, &Act::NativePackOption(0, 0));
let events = ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false);
assert!(events.contains(&Event::NativePackOptions(0)));
assert_eq!(s.shader_packs[0].values, [1.5]);
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, true);
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, true);
assert_eq!(s.shader_packs[0].values, [0.5]);
// Shift-click: the option's default.
ui.shift = true;
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false);
ui.shift = false;
assert_eq!(s.shader_packs[0].values, [1.0]);
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false);
let (x, y) = find(&mut ui, &s, &Act::NativePackReset(0));
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false);
assert_eq!(s.shader_packs[0].values, [1.0]);
ui.back(&mut s);
assert_eq!(ui.page(), Page::NativePackOptions(0, 0));
}
#[test]
fn resource_reload_error_blocks_options_until_dismissed() {
use winit::keyboard::KeyCode;
+137 -1
View File
@@ -47,6 +47,9 @@ pub struct ParticleOptions {
/// `GeyserParticleOptions.waterBlocks` and `GeyserBaseParticleOptions`'
/// `waterBlocks` and `burstImpulseBase` (0 where the type has none).
pub geyser: Option<(i32, f32)>,
/// A native mod's own particle type, by its namespaced name (`mymod:spark`):
/// `SimpleParticleType` plus the provider `particles/<name>.json` names.
pub mod_particle: Option<String>,
}
impl ParticleOptions {
pub fn simple(ty: ParticleType) -> Self {
@@ -59,6 +62,15 @@ impl ParticleOptions {
trail: None,
item: None,
geyser: None,
mod_particle: None,
}
}
/// A native mod's `SimpleParticleType`: the sprite set of
/// `assets/<namespace>/particles/<path>.json`, drawn as a plain billboard.
pub fn named(name: impl Into<String>) -> Self {
Self {
mod_particle: Some(name.into()),
..Self::simple(ParticleType::Smoke)
}
}
/// `GeyserParticleOptions(type, waterBlocks)`.
@@ -1055,6 +1067,8 @@ pub struct ParticleEngine {
/// it on the translucent layer (a texel with 0 < alpha < 255).
atlas: HashMap<String, (Rect, bool)>,
sets: HashMap<ParticleType, u32>,
/// A native mod's sprite sets, by namespaced particle name.
mod_sets: HashMap<String, u32>,
set_sprites: Vec<Vec<u32>>,
/// `TrackingEmitter`s: particle type, entity feet position, width, height, ticks left.
emitters: Vec<(ParticleOptions, [f64; 3], f64, f64, u32)>,
@@ -1092,6 +1106,7 @@ impl ParticleEngine {
rects: Vec::new(),
atlas: HashMap::new(),
sets: HashMap::new(),
mod_sets: HashMap::new(),
set_sprites: Vec::new(),
emitters: Vec::new(),
starters: Vec::new(),
@@ -1123,12 +1138,24 @@ impl ParticleEngine {
.map(|name| self.atlas.get(name).map(|&(rect, _)| rect))
.collect();
self.sets.clear();
self.mod_sets.clear();
self.set_sprites.clear();
self.item_sprites.clear();
self.load_sprites(root);
}
/// The sprite sets of every `particles/*.json` under `root`.
/// The sprite sets of every `particles/*.json` under `root`: vanilla's own
/// and a native mod's (`assets/<namespace>/particles/<name>.json`).
fn load_sprites(&mut self, root: &Path) {
let assets = root.join("assets");
if let Ok(namespaces) = std::fs::read_dir(&assets) {
for namespace in namespaces.flatten() {
let ns = namespace.file_name().to_string_lossy().into_owned();
if ns == "minecraft" || !namespace.path().is_dir() {
continue;
}
self.load_mod_sprites(&ns, &namespace.path().join("particles"));
}
}
let dir = root.join("assets/minecraft/particles");
for &ty in ParticleType::ALL {
let Ok(bytes) = std::fs::read(dir.join(format!("{}.json", ty.name()))) else {
@@ -1154,6 +1181,56 @@ impl ParticleEngine {
}
}
}
/// A namespace's particle JSONs that vanilla has no type for: each becomes
/// one sprite set under `<ns>:<file name>`.
fn load_mod_sprites(&mut self, ns: &str, dir: &Path) {
let Ok(files) = std::fs::read_dir(dir) else {
return;
};
for file in files.flatten() {
let path = file.path();
if path.extension().is_none_or(|e| e != "json") {
continue;
}
let Some(stem) = path.file_stem().map(|s| s.to_string_lossy().into_owned()) else {
continue;
};
let key = format!("{ns}:{stem}");
if ParticleType::from_name(&key).is_some() {
continue;
}
let Ok(bytes) = std::fs::read(&path) else {
continue;
};
let Ok(json) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
continue;
};
let sprites: Vec<u32> = json["textures"]
.as_array()
.map(|a| {
a.iter()
.filter_map(|t| t.as_str())
.map(|t| {
// A texture is `<ns>:<path>` (bare means
// `minecraft:`), and the atlas names a sprite
// `<ns>:particle/<path>` — `minecraft`'s own
// without its namespace.
let name = match t.split_once(':') {
Some(("minecraft", path)) => format!("particle/{path}"),
Some((ns, path)) => format!("{ns}:particle/{path}"),
None => format!("particle/{t}"),
};
self.intern(&name)
})
.collect()
})
.unwrap_or_default();
if !sprites.is_empty() {
self.mod_sets.insert(key, self.set_sprites.len() as u32);
self.set_sprites.push(sprites);
}
}
}
fn intern(&mut self, name: &str) -> u32 {
if let Some(i) = self.names.iter().position(|n| n == name) {
return i as u32;
@@ -1626,6 +1703,23 @@ impl ParticleEngine {
) -> Option<Particle> {
use ParticleType as T;
let ty = o.ty;
// A native mod's SimpleParticleType: vanilla's TextureSheetParticle with
// the sprite set its particles/<name>.json names, no tint, 20..40 ticks.
if let Some(name) = &o.mod_particle {
let set = *self.mod_sets.get(name)?;
let mut r = LegacyRandom::new(seed);
let mut q = Particle::moving(&mut r, ParticleType::Smoke, p, v);
q.ty = ParticleType::Smoke;
q.gravity = 0.0;
q.rgba = [1.0; 4];
q.lifetime = 20 + r.next_int_bound(20).unwrap_or(0);
q.set = Some(set);
let sprites = &self.set_sprites[set as usize];
q.sprite = Sprite::Index(
sprites[r.next_int_bound(sprites.len() as i32).unwrap_or(0) as usize],
);
return Some(q);
}
let set = self.set_of(ty);
let mut local = LegacyRandom::new(seed);
let r = &mut local;
@@ -4222,6 +4316,48 @@ const CREEPER: [[f64; 2]; 12] = [
mod tests {
use super::*;
#[test]
fn a_mod_particles_json_becomes_its_sprite_set() {
let dir =
std::env::temp_dir().join(format!("horizon-mod-particles-{}", std::process::id()));
let particles = dir.join("assets/mymod/particles");
std::fs::create_dir_all(&particles).unwrap();
std::fs::write(
particles.join("spark.json"),
br#"{"textures":["mymod:spark/1","mymod:spark/2"]}"#,
)
.unwrap();
assert!(particles.join("spark.json").exists());
let mut engine = ParticleEngine::new(&dir, &HashMap::new(), &HashSet::new());
let set = *engine
.mod_sets
.get("mymod:spark")
.expect("the set is loaded");
assert_eq!(engine.set_sprites[set as usize].len(), 2);
// The named option spawns a billboard from that set, not a vanilla type.
let made = engine
.make(
&ParticleOptions::named("mymod:spark"),
[0.0; 3],
[0.0; 3],
7,
)
.expect("a mod particle is made");
assert_eq!(made.set, Some(set));
// A name no pack knows draws nothing, like a missing vanilla sprite set.
assert!(
engine
.make(
&ParticleOptions::named("mymod:missing"),
[0.0; 3],
[0.0; 3],
7
)
.is_none()
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn egg_teleport_unpacks_offsets_and_preserves_seeded_draw_order() {
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
@@ -332,6 +332,7 @@ impl Settings {
.parent()
.map(crate::graphics::Graphics::load)
.unwrap_or_default();
crate::mipmap::set_dither_cutouts(horizon.dither_cutout_mips);
let controller = path
.parent()
.map(crate::gamepad::PadSettings::load)
@@ -2,7 +2,9 @@
//! Settings > Shader Packs lists them: Horizon's own (docs/shaders.md) and those in the
//! `shaders/` format, side by side.
use std::path::Path;
use std::path::{Path, PathBuf};
use horizon_shader_api::{OptionKind, OptionPage, PackOption};
pub const DIR: &str = "shaderpacks";
@@ -14,13 +16,48 @@ pub enum Kind {
Classic,
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[derive(Clone, Debug, PartialEq)]
pub struct Entry {
/// The folder's or file's name in `shaderpacks`: what the pick saves.
pub file: String,
/// The pack's own name for Horizon's packs, else `file`.
pub title: String,
pub kind: Kind,
/// A Horizon pack's `[[options]]`, and the value of each the player set (`values_path`).
pub options: Vec<PackOption>,
pub values: Vec<f64>,
/// Its options page's `[[pages]]`; empty for one page of every option.
pub pages: Vec<OptionPage>,
}
impl Entry {
/// Saves the player's option values, for the pack in the folder `dir`.
pub fn save_values(&self, dir: &Path) {
let path = dir.join(&self.file);
if let Err(e) = save_values(&path, &self.options, &self.values) {
log::warn!("shader pack options of {}: {e}", self.file);
}
}
/// The option `i` one value on (`back`: one before), or at its default (`reset`). Whether
/// the pack has that option.
pub fn step_option(&mut self, i: usize, back: bool, reset: bool) -> bool {
let (Some(o), Some(v)) = (self.options.get(i), self.values.get_mut(i)) else {
return false;
};
*v = if reset {
o.kind.default_constant()
} else {
step_value(&o.kind, *v, back)
};
true
}
/// Every option back to its default.
pub fn reset_options(&mut self) {
let defaults = self.options.iter().map(|o| o.kind.default_constant());
self.values = defaults.collect();
}
}
/// The packs in `dir`, Horizon's first, each kind by title. A missing folder has none.
@@ -53,11 +90,18 @@ fn entry(path: &Path) -> Option<Entry> {
.ok()
.map(|p| p.manifest)
};
let title = manifest.map_or_else(|| file.clone(), |m| m.name);
let (title, options, pages) = manifest.map_or_else(
|| (file.clone(), Vec::new(), Vec::new()),
|m| (m.name, m.options, m.pages),
);
let values = load_values(path, &options);
return Some(Entry {
file,
title,
kind: Kind::Horizon,
options,
values,
pages,
});
}
let zip = path.is_file()
@@ -68,9 +112,128 @@ fn entry(path: &Path) -> Option<Entry> {
title: file.clone(),
file,
kind: Kind::Classic,
options: Vec::new(),
values: Vec::new(),
pages: Vec::new(),
})
}
/// Where the option values of the Horizon pack at `pack` are kept: `<pack>.txt` beside it,
/// one `id=value` a line.
pub fn values_path(pack: &Path) -> PathBuf {
let mut path = pack.as_os_str().to_owned();
path.push(".txt");
PathBuf::from(path)
}
/// The value of each of `options` saved for the pack at `pack`, else its default; a value
/// out of the option's range is brought into it.
pub fn load_values(pack: &Path, options: &[PackOption]) -> Vec<f64> {
let text = std::fs::read_to_string(values_path(pack)).unwrap_or_default();
let saved: std::collections::HashMap<&str, f64> = text
.lines()
.filter_map(|l| l.split_once('='))
.filter_map(|(k, v)| Some((k.trim(), v.trim().parse().ok()?)))
.collect();
options
.iter()
.map(|o| {
saved
.get(o.id.as_str())
.map_or_else(|| o.kind.default_constant(), |&v| clamp(&o.kind, v))
})
.collect()
}
/// Saves `values` of `options`; values at their defaults are left out, and with none left
/// the file goes.
fn save_values(pack: &Path, options: &[PackOption], values: &[f64]) -> std::io::Result<()> {
let text: String = options
.iter()
.zip(values)
.filter(|(o, v)| o.kind.default_constant() != **v)
.map(|(o, v)| format!("{}={v}\n", o.id))
.collect();
let path = values_path(pack);
if text.is_empty() {
match std::fs::remove_file(&path) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e),
_ => Ok(()),
}
} else {
std::fs::write(path, text)
}
}
fn clamp(kind: &OptionKind, v: f64) -> f64 {
match kind {
OptionKind::Bool { .. } => f64::from(u8::from(v != 0.0)),
OptionKind::Float { min, max, .. } => v.clamp(f64::from(*min), f64::from(*max)),
OptionKind::Int { min, max, .. } => v.round().clamp(f64::from(*min), f64::from(*max)),
OptionKind::Choice { values, .. } => v.round().clamp(0.0, values.len() as f64 - 1.0),
}
}
/// The option's next value (`back`: the one before), wrapping around at the ends.
fn step_value(kind: &OptionKind, v: f64, back: bool) -> f64 {
let dir = if back { -1.0 } else { 1.0 };
match kind {
OptionKind::Bool { .. } => f64::from(u8::from(v == 0.0)),
OptionKind::Float { min, max, step, .. } => {
let (min, max, step) = (f64::from(*min), f64::from(*max), f64::from(*step));
if !back && v >= max - step * 0.01 {
min
} else if back && v <= min + step * 0.01 {
max
} else {
// On the grid of steps from the minimum, so float error never piles up.
let n = ((v - min) / step).round() + dir;
(min + n * step).clamp(min, max)
}
}
OptionKind::Int { min, max, .. } => {
let (min, max) = (f64::from(*min), f64::from(*max));
let n = v.round() + dir;
if n > max {
min
} else if n < min {
max
} else {
n
}
}
OptionKind::Choice { values, .. } => {
let n = values.len() as f64;
(v.round() + dir).rem_euclid(n)
}
}
}
/// The value as the settings page shows it.
pub fn show_value(kind: &OptionKind, v: f64) -> String {
match kind {
OptionKind::Bool { .. } => crate::lang::get(if v != 0.0 {
"options.on"
} else {
"options.off"
}),
OptionKind::Float { step, .. } => {
let digits = if *step >= 1.0 {
0
} else if *step >= 0.1 {
1
} else {
2
};
format!("{v:.digits$}")
}
OptionKind::Int { .. } => format!("{v:.0}"),
OptionKind::Choice { values, .. } => {
values.get(v.round() as usize).cloned().unwrap_or_default()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -106,4 +269,59 @@ mod tests {
assert!(scan(&d.join("missing")).is_empty());
std::fs::remove_dir_all(d).unwrap();
}
fn option(id: &str, kind: OptionKind) -> PackOption {
PackOption {
id: id.into(),
name: id.into(),
description: String::new(),
kind,
}
}
#[test]
fn values_step_wrap_and_round_trip() {
let float = OptionKind::Float {
default: 1.0,
min: 0.0,
max: 2.0,
step: 0.5,
};
assert_eq!(step_value(&float, 1.0, false), 1.5);
assert_eq!(step_value(&float, 2.0, false), 0.0);
assert_eq!(step_value(&float, 0.0, true), 2.0);
assert_eq!(show_value(&float, 1.5), "1.5");
let choice = OptionKind::Choice {
values: vec!["a".into(), "b".into(), "c".into()],
default: 0,
};
assert_eq!(step_value(&choice, 2.0, false), 0.0);
assert_eq!(step_value(&choice, 0.0, true), 2.0);
assert_eq!(show_value(&choice, 1.0), "b");
let int = OptionKind::Int {
default: 0,
min: -1,
max: 1,
};
assert_eq!(step_value(&int, 1.0, false), -1.0);
let on = OptionKind::Bool { default: true };
assert_eq!(step_value(&on, 1.0, false), 0.0);
let dir =
std::env::temp_dir().join(format!("horizon-shader-values-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let pack = dir.join("p.hshader");
let options = [option("a", float), option("b", choice)];
// Only what differs from the defaults is written; out of range comes back clamped.
save_values(&pack, &options, &[1.0, 2.0]).unwrap();
let text = std::fs::read_to_string(values_path(&pack)).unwrap();
assert_eq!(text, "b=2\n");
std::fs::write(values_path(&pack), "a=9\nb=1\nzzz=4\n").unwrap();
assert_eq!(load_values(&pack, &options), [2.0, 1.0]);
save_values(&pack, &options, &[1.0, 0.0]).unwrap();
assert!(!values_path(&pack).exists());
assert_eq!(load_values(&pack, &options), [1.0, 0.0]);
std::fs::remove_dir_all(&dir).unwrap();
}
}
+141
View File
@@ -158,6 +158,12 @@ pub fn profile_skin_url_nbt(profile: &horizon_world::nbt::Tag) -> Option<String>
unpack(&value, Signature::Unsigned, &TEXTURE_URIS).skin
}
/// `ResolvableProfile.skinPatch().body()` of a skull's `profile` NBT: the
/// `texture` id that replaces its skin.
pub fn profile_texture_nbt(profile: &horizon_world::nbt::Tag) -> Option<String> {
nbt_text(profile.get("texture"))
}
/// The profile's uuid as u128: `id` as an int array (block entity NBT) or a
/// uuid string (older and component forms). Used for `DefaultPlayerSkin.get`
/// when a head's skin is not downloaded yet.
@@ -467,6 +473,11 @@ pub fn head_skin(
downloads: &mut Downloader,
atlas: impl Fn(&str) -> bool,
) -> String {
if let Some(texture) = profile.get("texture").and_then(|t| t.as_str())
&& let Some(sprite) = patch_skin(texture, downloads, &atlas)
{
return sprite;
}
let p = parse_profile(profile);
let property = p.textures.or_else(|| {
let key = p.dynamic.as_ref()?;
@@ -495,6 +506,68 @@ pub fn head_skin(
crate::tab_list::default_skin(p.id).to_owned()
}
/// `PlayerSkin.Patch.apply` for a head: the sprite of a profile's `texture`
/// override (`ClientAsset.ResourceTexture`, a resource pack's
/// `<namespace>:textures/<path>.png`), which replaces the downloaded or
/// default skin. Vanilla textures are in the atlas already; another
/// namespace's loads into a skin slot, and `None` answers until it is there
/// (or when the packs lack it).
pub fn patch_skin(
texture: &str,
downloads: &mut Downloader,
atlas: impl Fn(&str) -> bool,
) -> Option<String> {
let (namespace, path) = texture.split_once(':').unwrap_or(("minecraft", texture));
if !valid_texture_id(namespace, path) {
return None;
}
if namespace == "minecraft" && atlas(path) {
return Some(path.to_owned());
}
let location = format!("patch/{namespace}/{path}");
if atlas(&location) {
downloads.drawn(&location);
return Some(location);
}
downloads.resource(&location, namespace, path);
None
}
/// `Identifier`'s characters (`isValidNamespace`, `isValidPath`), and no
/// `.`, `..` or empty segment (`FileUtil.decomposePath`, as the packs read
/// it): a server's `texture` never names a file outside the packs.
fn valid_texture_id(namespace: &str, path: &str) -> bool {
let segment = |s: &str| !s.is_empty() && s != "." && s != "..";
!namespace.is_empty()
&& namespace
.chars()
.all(|c| matches!(c, 'a'..='z' | '0'..='9' | '_' | '-' | '.'))
&& path
.chars()
.all(|c| matches!(c, 'a'..='z' | '0'..='9' | '_' | '-' | '.' | '/'))
&& segment(namespace)
&& path.split('/').all(segment)
}
/// A `width`x`height` RGBA image as the 64x64 skin it maps onto: each texel
/// the nearest of the image's at the same normalised UV.
fn resample_64(width: u32, height: u32, rgba: &[u8]) -> Option<Vec<u8>> {
let (w, h) = (width as usize, height as usize);
if w == 0 || h == 0 || rgba.len() < w * h * 4 {
return None;
}
let mut out = vec![0u8; 64 * 64 * 4];
for y in 0..64 {
for x in 0..64 {
let (sx, sy) = (x * w / 64, y * h / 64);
let s = (sy * w + sx) * 4;
let d = (y * 64 + x) * 4;
out[d..d + 4].copy_from_slice(&rgba[s..s + 4]);
}
}
Some(out)
}
/// `MinecraftProfileTexture.getHash`: the base name of the URL's path.
pub fn texture_hash(url: &str) -> Option<String> {
let rest = url.split_once("://")?.1;
@@ -732,6 +805,10 @@ pub struct Downloader {
image_order: std::collections::VecDeque<String>,
/// Skins and capes drawn from the atlas since `take_drawn`.
drawn: std::collections::HashSet<String>,
/// The resource packs' root, where a profile's `texture` patch is read.
resource_root: std::path::PathBuf,
/// How each `patch/` texture of the resource packs loaded, by location.
resources: std::collections::HashMap<String, Download>,
}
/// `images` is bounded only so a long session on a busy server does not grow
/// without end; the disk cache holds everything it drops.
@@ -749,8 +826,48 @@ impl Downloader {
images: Default::default(),
image_order: Default::default(),
drawn: Default::default(),
resource_root: Default::default(),
resources: Default::default(),
}
}
/// The resource packs changed (or were first loaded) at `root`: patch
/// textures load again from there.
pub fn set_resource_root(&mut self, root: std::path::PathBuf) {
for location in std::mem::take(&mut self.resources).into_keys() {
self.images.remove(&location);
self.image_order.retain(|l| *l != location);
}
self.resource_root = root;
}
/// `ClientAsset.ResourceTexture`: loads `<namespace>:textures/<path>.png`
/// from the resource packs into a skin slot as `location`, once. A
/// texture of another size is sampled as the model's normalised UVs would
/// sample it (nearest texel) into the 64x64 slot.
fn resource(&mut self, location: &str, namespace: &str, path: &str) {
match self.resources.get(location) {
Some(Download::Failed) => return,
Some(_) => {
self.again(location);
return;
}
None => {}
}
let file = self
.resource_root
.join(format!("assets/{namespace}/textures/{path}.png"));
let image = std::fs::read(file)
.ok()
.and_then(|b| crate::hud::decode_bytes(&b).ok())
.and_then(|(w, h, rgba)| resample_64(w, h, &rgba));
let state = match image {
Some(rgba) => {
self.queue(location.to_owned(), rgba);
Download::Ready
}
None => Download::Failed,
};
self.resources.insert(location.to_owned(), state);
}
/// How the download of `url` stands, starting it the first time.
pub fn state(&mut self, kind: Kind, url: &str) -> Download {
while let Ok((key, image)) = self.done.try_recv() {
@@ -824,6 +941,15 @@ impl Downloader {
if self.queued.contains(location) {
return true;
}
if location.starts_with("patch/") {
let Some(image) = self.images.get(location).cloned() else {
// Dropped from memory: read from the packs again.
self.resources.remove(location);
return false;
};
self.queue(location.to_owned(), image);
return true;
}
let (digest, process): (_, fn(u32, u32, &[u8]) -> Option<Vec<u8>>) =
if let Some(digest) = location.strip_prefix("skins/") {
(digest, process_legacy_skin)
@@ -960,6 +1086,21 @@ pub fn sha1(data: &[u8]) -> [u8; 20] {
#[cfg(test)]
mod tests {
/// A profile's `texture` names a pack texture, never a file outside the
/// packs: bad characters and `..` segments draw nothing.
#[test]
fn a_texture_patch_stays_inside_the_packs() {
use super::valid_texture_id;
assert!(valid_texture_id("ethereal", "entity/plush_blank"));
assert!(valid_texture_id("minecraft", "entity/player/wide/steve"));
assert!(!valid_texture_id("ethereal", "../../../secret"));
assert!(!valid_texture_id("..", "entity/x"));
assert!(!valid_texture_id("ethereal", "entity//x"));
assert!(!valid_texture_id("Ethereal", "entity/x"));
assert!(!valid_texture_id("ethereal", "entity\\x"));
assert!(!valid_texture_id("", "entity/x"));
}
/// A pack reload swaps the atlas: its skin slots are blank, so a forgotten
/// table holds nothing and hands out slots from the start again, even for
/// skins drawn in the last frame.
@@ -25,6 +25,30 @@ const ROWS: [&[u32]; MAX_PLAYERS - 4] = [
&[4, 4, 4, 4],
];
/// Where a window is.
#[derive(Clone, Debug, PartialEq)]
pub enum Placement {
/// A normal window: its corner on the desktop and its size in pixels (`None`: a new one,
/// where the system opens it).
Free(Option<([i32; 2], [u32; 2])>),
/// Borderless over the monitor of this name.
Fill(String),
}
/// How far window `number` opens right and down of the centre, in logical pixels: Window 2
/// centred, each next one a step further, as systems cascade new windows (back to the centre
/// after six), so new windows never open exactly on one another.
pub fn cascade(number: u8) -> f64 {
f64::from(number.saturating_sub(2) % 6) * 32.0
}
/// A window: its number (1 is the game's own) and where it is.
#[derive(Clone, Debug, PartialEq)]
pub struct Window {
pub number: u8,
pub placement: Placement,
}
/// `order` made a whole order of `n` players: those it names (once, if there are), then the
/// others, P1 first.
pub fn ordered(order: &[usize], n: usize) -> Vec<usize> {
+244 -41
View File
@@ -85,18 +85,93 @@ pub struct Sprites {
/// Each layer's frames at the texture's own resolution (square), and the
/// frame shown first.
native: Vec<(u32, Arc<Vec<Vec<u8>>>, usize)>,
/// Still textures by their pixels and mip settings: mods copy vanilla's
/// textures under their own names, and one layer serves them all (the
/// GPU's array may hold as few as 2048).
still: std::collections::HashMap<(Vec<u8>, u32, [u32; 2], [bool; 4]), u32>,
/// Each layer's LabPBR maps (`_n` normals, `_s` specular) beside its texture: their first
/// frame at their own resolution (square).
pbr: Vec<[Option<(u32, Vec<u8>)>; 2]>,
/// The layers' normal and specular maps at `pbr_size`, layer after layer, set by
/// [`Sprites::finish`]: empty when no texture has either.
pub normals: Vec<u8>,
pub specular: Vec<u8>,
pub pbr_size: u32,
/// Still textures by their pixels, their LabPBR maps and mip settings: mods copy vanilla's
/// textures under their own names, and one layer serves them all (the GPU's array may
/// hold as few as 2048).
still: std::collections::HashMap<(Vec<u8>, u64, u32, [u32; 2], [bool; 4]), u32>,
/// Decoded textures kept on disk between launches ([`Sprites::with_disk_cache`]).
disk: Option<crate::sprite_cache::SpriteCache>,
}
/// Edge length of vanilla's block textures: the smallest array layer.
pub const SPRITE_SIZE: u32 = 16;
/// Largest sprite array (all layers of the top mip level): resource packs
/// with bigger textures are sampled down to fit.
const MAX_ARRAY_BYTES: u64 = 256 << 20;
/// The largest sprite array (all layers of the top mip level) when the settings leave it to
/// Horizon: resource packs with bigger textures are sampled down to fit.
pub const DEFAULT_TEXTURE_BUDGET: u64 = 256 << 20;
/// The budget in bytes the next resource load keeps to (`set_texture_budget`).
static TEXTURE_BUDGET: std::sync::atomic::AtomicU64 =
std::sync::atomic::AtomicU64::new(DEFAULT_TEXTURE_BUDGET);
/// The block textures' budget from the `textureBudgetMiB` setting (0 for the default), for
/// resources loaded from now on.
pub fn set_texture_budget(mib: u32) {
let bytes = match mib {
0 => DEFAULT_TEXTURE_BUDGET,
mib => u64::from(mib) << 20,
};
TEXTURE_BUDGET.store(bytes, std::sync::atomic::Ordering::Relaxed);
}
/// Whether resource loads read the resource packs' LabPBR maps (`set_labpbr_maps`).
static LABPBR_MAPS: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
/// Whether resource loads from now on read the resource packs' LabPBR maps beside their block
/// textures. Only a native shader pack's material stage reads them, so without one they cost
/// neither loading time nor video memory.
pub fn set_labpbr_maps(on: bool) {
LABPBR_MAPS.store(on, std::sync::atomic::Ordering::Relaxed);
}
/// Whether resource loads read the LabPBR maps (`set_labpbr_maps`).
pub fn labpbr_maps() -> bool {
LABPBR_MAPS.load(std::sync::atomic::Ordering::Relaxed)
}
/// Largest array of each LabPBR map, which only a native shader pack reads.
const MAX_MAP_BYTES: u64 = DEFAULT_TEXTURE_BUDGET / 4;
/// Fewer layers than any game has (vanilla's are about 1300): what a map is kept at while
/// loading assumes at least these many share the array.
const LAYERS_AT_LEAST: u64 = 1024;
/// A layer's normal map where its texture has none: flat, unoccluded, at full height.
pub const FLAT_NORMAL: [u8; 4] = [128, 128, 255, 255];
/// A layer's specular map where its texture has none: rough, not metal, not glowing.
pub const NO_SPECULAR: [u8; 4] = [0, 0, 0, 0];
/// The largest power-of-two side from `start` down (at least `SPRITE_SIZE`) at which `layers`
/// square RGBA layers take at most `bytes`.
fn side_within(start: u32, layers: u64, bytes: u64) -> u32 {
let mut size = start.max(SPRITE_SIZE).next_power_of_two();
while size > SPRITE_SIZE && layers * u64::from(size) * u64::from(size) * 4 > bytes {
size /= 2;
}
size
}
/// A LabPBR map's square `frame` of `side` averaged down by whole powers of two to at most
/// `max`, channel by channel: they are data, not colour.
fn shrink_map(frame: Vec<u8>, side: u32, max: u32) -> (u32, Vec<u8>) {
if side <= max || !side.is_power_of_two() || !max.is_power_of_two() {
return (side, frame);
}
let (out_side, f, side) = (max as usize, (side / max) as usize, side as usize);
let mut out = Vec::with_capacity(out_side * out_side * 4);
for y in 0..out_side {
for x in 0..out_side {
let mut sum = [0u32; 4];
for dy in 0..f {
let row = ((y * f + dy) * side + x * f) * 4;
for texel in frame[row..row + f * 4].as_chunks::<4>().0 {
for (s, &v) in sum.iter_mut().zip(texel) {
*s += u32::from(v);
}
}
}
let n = (f * f) as u32;
out.extend(sum.map(|s| ((s + n / 2) / n) as u8));
}
}
(max, out)
}
/// Nearest-resample a square RGBA frame to another square size.
pub(crate) fn resample(frame: &[u8], from: u32, to: u32) -> Vec<u8> {
if from == to {
@@ -156,7 +231,7 @@ impl Sprites {
}
/// Lay every layer out at one size: the largest texture's (resource packs
/// may ship 32x, 64x... textures, like vanilla's stitched atlas), rounded
/// up to a power of two and limited to [`MAX_ARRAY_BYTES`]. Smaller
/// up to a power of two and limited to the texture budget (`set_texture_budget`). Smaller
/// textures are scaled up by whole pixels, so UVs stay per layer.
pub fn finish(&mut self) {
self.lay_out(true);
@@ -170,13 +245,18 @@ impl Sprites {
let layers = self.native.len().max(1) as u64;
let largest = self.native.iter().map(|n| n.0).max().unwrap_or(SPRITE_SIZE);
self.minimum_size = self.native.iter().map(|n| n.0).min().unwrap_or(SPRITE_SIZE);
let mut size = largest.max(SPRITE_SIZE).next_power_of_two();
while size > SPRITE_SIZE && layers * u64::from(size * size) * 4 > MAX_ARRAY_BYTES {
size /= 2;
let budget = TEXTURE_BUDGET.load(std::sync::atomic::Ordering::Relaxed);
let size = side_within(largest, layers, budget);
if pixels {
log::info!(
"Horizon: block sprites: {layers} layers at {size} px (largest {largest} px, budget {} MiB)",
budget >> 20
);
}
if !pixels {
self.pixels = Vec::new();
self.animations = Vec::new();
self.pbr = Vec::new();
self.size = size;
return;
}
@@ -192,6 +272,30 @@ impl Sprites {
animation.frames = resized[animation.layer as usize].clone();
}
self.size = size;
self.normals.clear();
self.specular.clear();
let largest_map = self.pbr.iter().flatten().flatten().map(|m| m.0).max();
self.pbr_size = largest_map.map_or(SPRITE_SIZE, |l| {
side_within(l.min(size), layers, MAX_MAP_BYTES)
});
if largest_map.is_some() {
let pbr_size = self.pbr_size;
log::info!("Horizon: LabPBR maps at {pbr_size} px, {layers} layers");
for maps in std::mem::take(&mut self.pbr) {
for (map, (out, none)) in maps.into_iter().zip([
(&mut self.normals, FLAT_NORMAL),
(&mut self.specular, NO_SPECULAR),
]) {
match map {
Some((from, pixels)) => {
let (from, pixels) = shrink_map(pixels, from, pbr_size);
out.extend(resample(&pixels, from, pbr_size));
}
None => out.extend(none.repeat((pbr_size * pbr_size) as usize)),
}
}
}
}
}
/// The layer of a texture, added on first use. A texture that is missing
/// or does not load (a resource pack's filter or a broken file) becomes
@@ -222,6 +326,7 @@ impl Sprites {
.collect();
let layer = u32::try_from(self.native.len())?;
self.opaque.push(true);
self.pbr.push([None, None]);
self.translucent.push(false);
self.mips.push(crate::mipmap::Settings::default());
self.layers.insert(name.to_owned(), layer);
@@ -242,7 +347,7 @@ impl Sprites {
decoded
}
};
self.register(name, decoded)
self.register(root, name, decoded)
}
/// Read and decode one texture and its `.mcmeta`.
fn decode(root: &Path, name: &str) -> Result<crate::sprite_cache::Decoded> {
@@ -263,40 +368,19 @@ impl Sprites {
.as_deref()
.map(AnimationMetadata::from_json)
.transpose()?;
let mut decoder = png::Decoder::new(BufReader::new(File::open(path)?));
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
let mut reader = decoder.read_info()?;
let mut bytes = vec![
0;
reader
.output_buffer_size()
.ok_or("sprite exceeds decoder limits")?
];
let info = reader.next_frame(&mut bytes)?;
let (png_width, png_height, pixels) = read_rgba(&path)?;
let (width, height) = if let Some(animation) = &animation {
let [width, height] =
animation.frame_size(i32::try_from(info.width)?, i32::try_from(info.height)?);
animation.frame_size(i32::try_from(png_width)?, i32::try_from(png_height)?);
(u32::try_from(width)?, u32::try_from(height)?)
} else {
(info.width, info.height)
(png_width, png_height)
};
if width == 0 || height == 0 || info.width % width != 0 || info.height % height != 0 {
if width == 0 || height == 0 || png_width % width != 0 || png_height % height != 0 {
return Err(format!("sprite {name} has invalid frame dimensions").into());
}
let channels = info.color_type.samples();
let mut pixels = Vec::with_capacity(info.width as usize * info.height as usize * 4);
for pixel in bytes[..info.buffer_size()].chunks_exact(channels) {
let rgba = match info.color_type {
png::ColorType::Rgba => [pixel[0], pixel[1], pixel[2], pixel[3]],
png::ColorType::Rgb => [pixel[0], pixel[1], pixel[2], 255],
png::ColorType::Grayscale => [pixel[0], pixel[0], pixel[0], 255],
png::ColorType::GrayscaleAlpha => [pixel[0], pixel[0], pixel[0], pixel[1]],
_ => return Err("unsupported expanded sprite format".into()),
};
pixels.extend(rgba);
}
let columns = info.width / width;
let count = columns * (info.height / height);
let columns = png_width / width;
let count = columns * (png_height / height);
// Layers are square; a frame of another shape is nearest-resampled to
// its longer side so UVs (normalized per layer) are unchanged.
let side = width.max(height);
@@ -309,7 +393,7 @@ impl Sprites {
let source_row = y + row * height / side;
for column in 0..side {
let offset =
((source_row * info.width + x + column * width / side) * 4) as usize;
((source_row * png_width + x + column * width / side) * 4) as usize;
frame.extend_from_slice(&pixels[offset..offset + 4]);
}
}
@@ -339,7 +423,12 @@ impl Sprites {
})
}
/// Give a decoded texture its layer (or the one an identical still has).
fn register(&mut self, name: &str, decoded: crate::sprite_cache::Decoded) -> Result<u32> {
fn register(
&mut self,
root: &Path,
name: &str,
decoded: crate::sprite_cache::Decoded,
) -> Result<u32> {
let crate::sprite_cache::Decoded {
side,
frames,
@@ -356,6 +445,13 @@ impl Sprites {
.transpose()?;
let count = frames.len() as u32;
let layer = u32::try_from(self.native.len())?;
let pbr = match name.split_once(':') {
Some((namespace, path)) if labpbr_maps() => {
let path = root.join(format!("assets/{namespace}/textures/{path}.png"));
["_n", "_s"].map(|suffix| pbr_map(&path, suffix, count))
}
_ => [None, None],
};
let settings = crate::mipmap::Settings {
native_size: side,
strategy,
@@ -364,10 +460,15 @@ impl Sprites {
item: name
.split_once(':')
.is_some_and(|(_, p)| p.starts_with("item/")),
dither_cutouts: crate::mipmap::dither_cutouts(),
};
let still = (animation.is_none() && frames.len() == 1).then(|| {
use std::hash::{Hash, Hasher};
let mut maps = std::collections::hash_map::DefaultHasher::new();
pbr.hash(&mut maps);
(
frames[0].clone(),
maps.finish(),
side,
[
settings.strategy as u32,
@@ -401,12 +502,66 @@ impl Sprites {
self.mips.push(settings);
self.opaque.push(opaque);
self.translucent.push(translucent);
self.pbr.push(pbr);
self.layers.insert(name.to_owned(), layer);
self.native.push((side, frames, initial));
Ok(layer)
}
}
/// A PNG's width, height and pixels as RGBA.
fn read_rgba(path: &Path) -> Result<(u32, u32, Vec<u8>)> {
let mut decoder = png::Decoder::new(BufReader::new(File::open(path)?));
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
let mut reader = decoder.read_info()?;
let mut bytes = vec![
0;
reader
.output_buffer_size()
.ok_or("sprite exceeds decoder limits")?
];
let info = reader.next_frame(&mut bytes)?;
let channels = info.color_type.samples();
let mut pixels = Vec::with_capacity(info.width as usize * info.height as usize * 4);
for pixel in bytes[..info.buffer_size()].chunks_exact(channels) {
let rgba = match info.color_type {
png::ColorType::Rgba => [pixel[0], pixel[1], pixel[2], pixel[3]],
png::ColorType::Rgb => [pixel[0], pixel[1], pixel[2], 255],
png::ColorType::Grayscale => [pixel[0], pixel[0], pixel[0], 255],
png::ColorType::GrayscaleAlpha => [pixel[0], pixel[0], pixel[0], pixel[1]],
_ => return Err("unsupported expanded sprite format".into()),
};
pixels.extend(rgba);
}
Ok((info.width, info.height, pixels))
}
/// The LabPBR map with `suffix` beside the texture at `path`, whose frames lie in a column of
/// `count`: its first frame, square, no larger than the maps' array allows any game's
/// textures (a 512x pack's maps would otherwise fill the memory while loading).
fn pbr_map(path: &Path, suffix: &str, count: u32) -> Option<(u32, Vec<u8>)> {
let stem = path.file_stem()?.to_str()?;
let map = path.with_file_name(format!("{stem}{suffix}.png"));
let (map_width, map_height, pixels) = read_rgba(&map).ok()?;
let (w, h) = (map_width, map_height / count.max(1));
if w == 0 || h == 0 {
return None;
}
let side = w.max(h);
let mut frame = Vec::with_capacity((side * side * 4) as usize);
for row in 0..side {
for column in 0..side {
let offset = (((row * h / side) * map_width + column * w / side) * 4) as usize;
frame.extend_from_slice(&pixels[offset..offset + 4]);
}
}
Some(shrink_map(
frame,
side,
side_within(side, LAYERS_AT_LEAST, MAX_MAP_BYTES),
))
}
/// SpriteContents: the `.mcmeta` `texture` section (TextureMetadataSection's
/// mipmap strategy and alpha cutoff bias).
fn texture_section(
@@ -452,6 +607,54 @@ pub(crate) fn sprite_mipmap_limit(requested: u32, size: u32, minimum: u32, avail
mod tests {
use super::*;
#[test]
fn the_texture_budget_picks_the_largest_side_that_fits() {
// 1300 layers: vanilla's count, about.
assert_eq!(side_within(512, 1300, DEFAULT_TEXTURE_BUDGET), 128);
assert_eq!(side_within(512, 1300, 1536 << 20), 512);
assert_eq!(side_within(64, 1300, 1536 << 20), 64);
// Never under vanilla's size, whatever the budget.
assert_eq!(side_within(512, 1300, 1), SPRITE_SIZE);
}
#[test]
fn a_labpbr_map_shrinks_by_plain_means_and_a_layer_without_one_gets_the_default() {
// Alpha is data here: it does not weigh the other channels.
let frame = [
[200, 100, 0, 255],
[0, 0, 0, 0],
[200, 100, 0, 255],
[0, 0, 0, 0],
]
.concat();
assert_eq!(shrink_map(frame, 2, 1), (1, vec![100, 50, 0, 128]));
let mut sprites = Sprites {
native: vec![
(SPRITE_SIZE, Arc::new(vec![vec![0; 16 * 16 * 4]]), 0),
(SPRITE_SIZE, Arc::new(vec![vec![0; 16 * 16 * 4]]), 0),
],
pbr: vec![
[Some((16, [9, 8, 7, 6].repeat(16 * 16))), None],
[None, None],
],
..Default::default()
};
sprites.finish();
assert_eq!(sprites.pbr_size, 16);
assert_eq!(sprites.normals[..4], [9, 8, 7, 6]);
assert_eq!(sprites.normals[16 * 16 * 4..][..4], FLAT_NORMAL);
assert_eq!(sprites.specular[..4], NO_SPECULAR);
assert_eq!(sprites.specular.len(), 2 * 16 * 16 * 4);
}
#[test]
fn textures_without_labpbr_maps_keep_none() {
let mut sprites = Sprites {
native: vec![(SPRITE_SIZE, Arc::new(vec![vec![0; 16 * 16 * 4]]), 0)],
pbr: vec![[None, None]],
..Default::default()
};
sprites.finish();
assert!(sprites.normals.is_empty() && sprites.specular.is_empty());
}
#[test]
fn mipmap_option_keeps_real_16px_reductions() {
for levels in 0..=4 {
assert_eq!(sprite_mipmap_limit(levels, 32, 16, 6), levels + 1);
+68 -22
View File
@@ -215,6 +215,8 @@ enum Job {
/// Proto chunks checked out to be saved (`ChunkGenerator::save_protos`),
/// off the main thread like vanilla's IOWorker.
Save(Vec<((i32, i32), Proto)>),
/// A proto chunk checked out for its light view (`light_region`).
LightView(i32, i32, Proto),
/// Meshes the renderer is done with (`Streaming::free`): their memory
/// is given back here, not in the frame.
Free(Vec<((i32, i32, i32), SectionMesh)>),
@@ -235,6 +237,8 @@ pub enum Done {
Lit(i32, i32, Vec<crate::chunk::Light>, u64),
Meshed(i32, i32, i32, SectionMesh, u64),
Saved(Vec<((i32, i32), Proto)>),
/// A proto chunk back from `Job::LightView`, with its view.
LightView(i32, i32, Proto, Option<Chunk>),
}
struct Queued {
priority: i64,
@@ -279,6 +283,8 @@ struct Shared {
/// Workers that have freed their buffers ([`IDLE_RELEASE`]) and taken no
/// job since: the last of them gives the freed memory back to the OS.
released: AtomicUsize,
/// The workers that started.
workers: AtomicUsize,
}
#[derive(Default)]
struct MeshPool {
@@ -352,7 +358,6 @@ pub struct Streaming {
/// Columns generated and saved as they were left behind: new when they
/// next arrive, though read from the save (`World::loaded_chunks`).
dropped_new: FxHashSet<(i32, i32)>,
light_view: Arc<dyn Fn(&Proto) -> Option<Chunk> + Send + Sync>,
non_air_sections: Arc<dyn Fn(&Proto) -> Option<SectionMask> + Send + Sync>,
threads: Vec<thread::JoinHandle<()>>,
order: u64,
@@ -377,6 +382,8 @@ pub struct Streaming {
decorating_finals: HashSet<(i32, i32)>,
/// Save jobs in flight.
saving: usize,
/// Proto chunks checked out to a worker for their light view.
viewing: FxHashSet<(i32, i32)>,
/// Vanilla's decoration order around the player, and the centre and
/// radius it was last ranked for.
decoration_order: DecorationOrder,
@@ -618,8 +625,8 @@ impl Streaming {
done: Mailbox::default(),
meshes: Mutex::new(MeshPool::default()),
released: AtomicUsize::new(0),
workers: AtomicUsize::new(0),
});
let workers = threads.max(1);
let light_tables = context.light.clone();
let saves: Arc<dyn Saves> = generator.clone();
let threads = (0..threads.max(1))
@@ -673,7 +680,7 @@ impl Streaming {
// runs short: once every worker has freed its own,
// hand them back.
if shared.released.fetch_add(1, Ordering::Relaxed) + 1
== workers
== shared.workers.load(Ordering::Relaxed)
{
// No worker needs retained mesh capacity now.
// Drop it outside the pool lock before asking
@@ -732,6 +739,10 @@ impl Streaming {
generator.save_protos(&refs);
Done::Saved(protos)
}
Job::LightView(x, z, proto) => {
let view = generator.light_view(&proto);
Done::LightView(x, z, proto, view)
}
Job::Free(meshes) => {
for (_, mesh) in meshes {
let rejected = shared.meshes.lock().unwrap().put(mesh);
@@ -780,20 +791,22 @@ impl Streaming {
shared.done.send(done);
}
})
.expect("spawn worker")
})
.collect();
let light_view = {
let generator = generator.clone();
Arc::new(move |p: &Proto| generator.light_view(p))
};
// A thread the system refuses (a container's process limit reached) leaves the pool a
// worker short: a server that panicked here went down with its players' moves unsaved.
.filter_map(|spawned| {
spawned
.inspect_err(|e| log::error!("Horizon: chunk worker not started: {e}"))
.ok()
})
.collect::<Vec<_>>();
shared.workers.store(threads.len(), Ordering::Relaxed);
let non_air_sections = {
let generator = generator.clone();
Arc::new(move |p: &Proto| generator.non_air_sections(p))
};
Self {
shared,
light_view,
non_air_sections,
threads,
order: 0,
@@ -808,6 +821,7 @@ impl Streaming {
decorating: 0,
decorating_finals: HashSet::new(),
saving: 0,
viewing: Default::default(),
decoration_order: DecorationOrder::default(),
order_area: None,
queue_basis: Default::default(),
@@ -1092,28 +1106,42 @@ impl Streaming {
/// The 3x3 a light job reads: the finished columns, and for a neighbour
/// that will not finish (past the area), its decorated proto chunk, as
/// vanilla lights its last FULL ring against neighbours at FEATURES.
/// A proto neighbour without its view yet is checked out to a worker,
/// which builds the view off the server thread (`Job::LightView`); the
/// region is asked for again once it is back.
fn light_region(&mut self, world: &World, x: i32, z: i32) -> Option<Box<Region>> {
let mut region: Region = Default::default();
let mut complete = true;
for dz in 0..3 {
for dx in 0..3 {
let n = (x + dx as i32 - 1, z + dz as i32 - 1);
region[dz][dx] = Some(match world.chunks.get(&n) {
Some(chunk) => chunk.clone(),
region[dz][dx] = match world.chunks.get(&n) {
Some(chunk) => Some(chunk.clone()),
None if !self.in_range(n.0, n.1, 1) && self.decorated.contains(&n) => {
if let Some(view) = self.light_views.get(&n) {
view.clone()
Some(view.clone())
} else {
let proto = self.protos.get(&n)?.as_ref()?;
let view = Arc::new((self.light_view)(proto)?);
self.light_views.insert(n, view.clone());
view
let proto = self.protos.get_mut(&n)?.take()?;
self.viewing.insert(n);
self.submit(Some((x, z)), 0, Job::LightView(n.0, n.1, proto));
complete = false;
None
}
}
None => return None,
});
};
}
}
Some(Box::new(region))
complete.then(|| Box::new(region))
}
/// A proto chunk back from its light view: the view waits in
/// `light_views` for the light jobs that asked for it.
fn receive_light_view(&mut self, x: i32, z: i32, proto: Proto, view: Option<Chunk>) {
self.viewing.remove(&(x, z));
self.protos.insert((x, z), Some(proto));
if let Some(view) = view {
self.light_views.insert((x, z), Arc::new(view));
}
}
/// Whether `light_region` would find all nine columns: the same walk
/// without building the snapshot, so the scheduler can rank the ready
@@ -1201,6 +1229,7 @@ impl Streaming {
pub fn busy(&self) -> bool {
!(self.generating.is_empty()
&& self.decorating == 0
&& self.viewing.is_empty()
&& self.finishing.is_empty()
&& self.lighting.is_empty()
&& self.meshing.is_empty()
@@ -1264,6 +1293,7 @@ impl Streaming {
}
Done::Generated(chunk) => self.receive_generated(chunk, world),
Done::Saved(protos) => self.receive_saved(protos),
Done::LightView(x, z, proto, view) => self.receive_light_view(x, z, proto, view),
Done::Lit(x, z, sections, seq) => {
if self.lighting.get(&(x, z)) != Some(&seq) {
continue;
@@ -1822,7 +1852,11 @@ impl Streaming {
return;
}
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
while self.decorating > 0 || !self.finishing.is_empty() || self.saving > 0 {
while self.decorating > 0
|| !self.finishing.is_empty()
|| self.saving > 0
|| !self.viewing.is_empty()
{
let left = deadline.saturating_duration_since(std::time::Instant::now());
match self.shared.done.recv_timeout(left) {
Some(Done::Decorated(x, z, region, finals, writes)) => {
@@ -1830,6 +1864,9 @@ impl Streaming {
}
Some(Done::Generated(chunk)) => self.receive_generated(chunk, world),
Some(Done::Saved(protos)) => self.receive_saved(protos),
Some(Done::LightView(x, z, proto, view)) => {
self.receive_light_view(x, z, proto, view)
}
Some(other) => self.deferred.push(other),
None => {
log::warn!("Horizon: chunk generation did not finish before saving");
@@ -1860,13 +1897,18 @@ impl Streaming {
}
Done::Generated(chunk) => self.receive_generated(chunk, world),
Done::Saved(protos) => self.receive_saved(protos),
Done::LightView(x, z, proto, view) => self.receive_light_view(x, z, proto, view),
other => self.deferred.push(other),
}
if started.elapsed() >= std::time::Duration::from_millis(2) {
return false;
}
}
if self.decorating > 0 || !self.finishing.is_empty() || self.saving > 0 {
if self.decorating > 0
|| !self.finishing.is_empty()
|| self.saving > 0
|| !self.viewing.is_empty()
{
return false;
}
let written: Vec<_> = self.written.drain().collect();
@@ -1940,7 +1982,11 @@ impl Streaming {
counts[1] += 1;
} else {
counts[2] += 1;
if self.light_region(world, key.0, key.1).is_some() {
if self.light_region(world, key.0, key.1).is_some()
|| (-1..=1).any(|dz| {
(-1..=1).any(|dx| self.viewing.contains(&(key.0 + dx, key.1 + dz)))
})
{
world.dirty_light.insert(key);
}
}
@@ -98,6 +98,25 @@ thread_local! {
static TARGETS: RefCell<(Vec<Target>, Vec<Target>, u64)> = const { RefCell::new((Vec::new(), Vec::new(), 0)) };
}
/// Splitscreen: one player's buttons and focus targets, kept aside while another player's game
/// runs on this thread ([`take`] and [`put`] around each turn).
#[derive(Default)]
pub struct Saved(
(Vec<Widget>, Vec<Widget>, u64),
(Vec<Target>, Vec<Target>, u64),
);
/// This thread's buttons and targets, leaving none.
pub fn take() -> Saved {
Saved(WIDGETS.take(), TARGETS.take())
}
/// Bring back what [`take`] kept.
pub fn put(saved: Saved) {
WIDGETS.set(saved.0);
TARGETS.set(saved.1);
}
/// An interactive rectangle (GUI pixels) a controller can focus and click.
pub fn target(rect: [f32; 4]) {
target_as(rect, Kind::Button);
+7
View File
@@ -83,6 +83,13 @@ open = { version = "5.4.5", features = ["shellexecute-on-windows"] }
# on the PATH.
wgpu = { version = "30.0.1", default-features = false, features = ["dx12"] }
[target.'cfg(target_os = "macos")'.dependencies]
# Splitscreen: a window filling a monitor goes over its menu bar (game/split/windows.rs
# `above_menu_bar`); all already in the tree through winit and wgpu.
objc2 = "0.6"
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSResponder", "NSWindow", "NSScreen"] }
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSString"] }
[target.'cfg(not(target_os = "macos"))'.dependencies]
# OpenGL (WGL on Windows, EGL on Linux), the last fallback, for GPUs with no Vulkan or
# DirectX 12 driver (`graphicsApi`, graphics.rs).
+9
View File
@@ -5,6 +5,15 @@ fn main() {
let dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default();
let ico = format!("{dir}/../../packaging/icons/horizon.ico");
println!("cargo:rerun-if-changed={ico}");
// The game's main thread gets 8 MB of stack on Windows as on macOS and Linux (Windows gives 1
// MB): splitscreen builds a joining player's `Game`, ~67 KB, deep in the event loop.
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") {
let arg = match std::env::var("CARGO_CFG_TARGET_ENV").as_deref() {
Ok("msvc") => "/STACK:8388608",
_ => "-Wl,--stack,8388608",
};
println!("cargo:rustc-link-arg-bin=horizon-client={arg}");
}
#[cfg(windows)]
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") {
let mut resource = winresource::WindowsResource::new();
@@ -291,6 +291,8 @@ pub fn main() -> horizon_client::Result<()> {
for d in items {
let _ = match d {
drops::Drop::Item(s) => writeln!(out, "{}", line(&s)),
// The fixture's mobs keep no whole stacks.
drops::Drop::Stack(s) => writeln!(out, "drop {} {}", s.item, s.count),
drops::Drop::CarriedBlock(state) => {
let stacks = enderman::carried_block_drops(
&block_items,
@@ -1315,7 +1315,7 @@ pub fn main() -> horizon_client::Result<()> {
continue;
}
if let Some(Some((name, volume, pitch))) =
horizon_client::mobs::ai::feed_animal(&mut mobs[i], &mut level_random)
horizon_client::mobs::ai::feed_animal(&mut mobs[i], &mut level_random, None)
{
events.push(MobEvent::Sound(name, [0.0; 3], volume, pitch));
}
@@ -47,7 +47,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
))?;
let parse_ms = parse_started.elapsed().as_secs_f64() * 1000.0;
let facts_started = Instant::now();
let (facts, gameplay) = BlockFacts::load_with_projection(root, assets.registry, &json)?;
let (facts, gameplay) =
BlockFacts::load_with_projection(root, assets.registry, &json, true)?;
let facts_ms = facts_started.elapsed().as_secs_f64() * 1000.0;
let behaviours_started = Instant::now();
let behaviours = Behaviours::load_with_gameplay(assets.registry, root, &json, &gameplay)?;
+9
View File
@@ -1071,6 +1071,15 @@ impl Ticker<'_, '_, '_, '_> {
}
effects.advance(step);
let name = &*self.scene.facts.get(state).name;
crate::block_contact::emit(crate::block_contact::Event::Inside(
crate::block_contact::Contact {
entity: self.p.id as i32,
kind: self.p.kind.id(),
block: name,
state,
position: p,
},
));
if matches!(name, "minecraft:fire" | "minecraft:soul_fire") {
// BaseFireBlock.entityInside (its inFire hurt does nothing to arrows).
effects.apply(Effect::ClearFreeze);
+1 -3
View File
@@ -92,9 +92,7 @@ impl Banners {
});
}
banners.no_item_required = read_tag(root, "no_item_required", 0);
if let Some(raw) = fs::read(root.join("items.json")).ok().and_then(|b| {
serde_json::from_slice::<std::collections::BTreeMap<String, Value>>(&b).ok()
}) {
if let Ok(raw) = horizon_world::item_defaults::read(root) {
for (item, c) in raw {
let id: Arc<str> = Arc::from(item.as_str());
if let Some(dye) = c["minecraft:dye"].as_str() {
+201
View File
@@ -0,0 +1,201 @@
//! Synchronous mod-block contacts from the server's simulation code.
use horizon_core::position::BlockPos;
use horizon_world::registry::BlockStateId;
use std::{
cell::Cell,
sync::{
OnceLock,
atomic::{AtomicBool, Ordering},
},
};
#[derive(Clone, Copy, Debug)]
pub struct Contact<'a> {
pub entity: i32,
pub kind: &'a str,
pub block: &'a str,
pub state: BlockStateId,
pub position: BlockPos,
}
#[derive(Clone, Copy, Debug)]
pub enum Event<'a> {
StepOn(Contact<'a>),
Inside(Contact<'a>),
FallOn(Contact<'a>, f64),
}
pub static HOOK: OnceLock<fn(&str, Event<'_>, &mut f32)> = OnceLock::new();
/// Whether the loaded blocks hold one outside `minecraft:` (a content mod's).
/// Without one no contact can dispatch, so the item and projectile sweeps
/// skip their per-tick work: every item and thrown thing on a server would
/// otherwise sweep its blocks each tick for nothing.
static MOD_BLOCKS: AtomicBool = AtomicBool::new(false);
/// Called when the block tables load.
pub fn set_mod_blocks(any: bool) {
MOD_BLOCKS.store(any, Ordering::Relaxed);
}
thread_local! {
static LEVEL: Cell<Option<&'static str>> = const { Cell::new(None) };
}
/// Scope authority to this thread and restore it even on an error or panic.
/// Nested guest actions may temporarily enter a different dimension.
pub fn in_level<R>(dimension: Option<&'static str>, f: impl FnOnce() -> R) -> R {
struct Restore(Option<&'static str>);
impl Drop for Restore {
fn drop(&mut self) {
LEVEL.set(self.0);
}
}
let _restore = Restore(LEVEL.replace(dimension));
f()
}
pub fn emit(event: Event<'_>) {
dispatch(event, &mut 0.0);
}
/// Damage is the calculated fall damage, before hurt handling and fall sounds.
pub fn fall(contact: Contact<'_>, distance: f64, mut damage: f32) -> f32 {
dispatch(Event::FallOn(contact, distance), &mut damage);
damage
}
fn dispatch(event: Event<'_>, damage: &mut f32) {
let contact = match event {
Event::StepOn(c) | Event::Inside(c) | Event::FallOn(c, _) => c,
};
if contact.block.starts_with("minecraft:") {
return;
}
if let Some(dimension) = LEVEL.get()
&& let Some(hook) = HOOK.get()
{
let original = *damage;
hook(dimension, event, damage);
if !damage.is_finite() {
*damage = original;
}
*damage = damage.max(0.0);
}
}
/// Reuse the entity block sweep for item and projectile ticks without an effect collector.
pub fn swept_inside<'a>(
entity: i32,
kind: &str,
from: horizon_core::geometry::Vec3,
to: horizon_core::geometry::Vec3,
bounds: horizon_core::geometry::Aabb,
mut block: impl FnMut(BlockPos) -> (BlockStateId, &'a str),
) {
if !MOD_BLOCKS.load(Ordering::Relaxed) || LEVEL.get().is_none() || HOOK.get().is_none() {
return;
}
let bounds = bounds.deflate(horizon_core::geometry::Vec3::new(1e-5, 1e-5, 1e-5));
let mut visited = std::collections::HashSet::new();
let _ =
horizon_core::geometry::sweep_intersected_blocks(from, to, bounds, 1_000_000, |p, step| {
if step >= 16 {
return false;
}
if visited.insert(p) {
let (state, name) = block(p);
emit(Event::Inside(Contact {
entity,
kind,
block: name,
state,
position: p,
}));
}
true
});
}
#[cfg(test)]
mod tests {
use super::*;
thread_local! {
static SEEN: Cell<usize> = const { Cell::new(0) };
}
fn hook(dimension: &str, event: Event<'_>, damage: &mut f32) {
assert_eq!(dimension, "minecraft:the_nether");
let c = match event {
Event::FallOn(c, distance) => {
assert_eq!(distance, 12.0);
*damage = match *damage {
99.0 => f32::NAN,
98.0 => -1.0,
_ => *damage * 0.2,
};
c
}
Event::Inside(c) | Event::StepOn(c) => c,
};
assert_eq!(
(c.entity, c.kind, c.state, c.position),
(
17,
"minecraft:zombie",
BlockStateId(9),
BlockPos::new(1, 2, 3)
)
);
SEEN.set(SEEN.get() + 1);
}
#[test]
fn contacts_require_server_scope_and_mod_blocks_and_restore_nested_levels() {
HOOK.set(hook).unwrap();
let c = Contact {
entity: 17,
kind: "minecraft:zombie",
block: "test:hay",
state: BlockStateId(9),
position: BlockPos::new(1, 2, 3),
};
assert_eq!(fall(c, 12.0, 10.0), 10.0);
in_level(Some("minecraft:the_nether"), || {
emit(Event::StepOn(c));
emit(Event::Inside(c));
assert_eq!(fall(c, 12.0, 10.0), 2.0);
in_level(None, || assert_eq!(fall(c, 12.0, 10.0), 10.0));
assert_eq!(fall(c, 12.0, 0.0), 0.0);
emit(Event::Inside(Contact {
block: "minecraft:hay_block",
..c
}));
});
assert_eq!(fall(c, 12.0, 10.0), 10.0);
assert_eq!(SEEN.get(), 4);
in_level(Some("minecraft:the_nether"), || {
assert_eq!(fall(c, 12.0, 99.0), 99.0);
assert_eq!(fall(c, 12.0, 98.0), 0.0);
use horizon_core::geometry::{Aabb, Vec3};
let to = Vec3::new(2.5, 2.0, 3.5);
let bounds = Aabb::new(Vec3::new(2.25, 2.0, 3.25), Vec3::new(2.75, 2.5, 3.75));
let block = |p| {
(
BlockStateId(9),
if p == c.position {
"test:hay"
} else {
"minecraft:air"
},
)
};
set_mod_blocks(true);
swept_inside(17, c.kind, Vec3::new(0.5, 2.0, 3.5), to, bounds, block);
assert_eq!(SEEN.get(), 7, "a block crossed mid-move is notified once");
let at = Vec3::new(1.5, 2.0, 3.5);
let bounds = bounds.move_by(Vec3::new(-1.0, 0.0, 0.0));
swept_inside(17, c.kind, at, at, bounds, block);
assert_eq!(SEEN.get(), 8, "stationary overlap still notifies");
});
let _ =
std::panic::catch_unwind(|| in_level(Some("minecraft:the_nether"), || panic!("test")));
assert_eq!(fall(c, 12.0, 10.0), 10.0);
}
}
+36 -9
View File
@@ -1,6 +1,7 @@
//! Identity of the ordered block-state registry used by chunk packets, and of
//! the item, mob effect and entity type registries once content mods add to
//! them (item stacks, effect and entity packets carry their network ids).
//! the item, mob effect, entity type and menu registries once content mods
//! add to them (item stacks, effect, entity and open screen packets carry
//! their network ids).
use horizon_protocol::registry::Builtin;
use serde::{Deserialize, Serialize, de::IgnoredAny};
use sha2::{Digest, Sha256};
@@ -77,14 +78,13 @@ pub fn identity(root: &Path) -> Result<[u8; 32], IdentityError> {
if !components.is_empty() {
hash = hash_entries(hash, "horizon:nbt_components/v1", &components);
// Defaults are not sent in a stack patch. Both peers must therefore
// agree on their values as well as the registered wire IDs.
let bytes = std::fs::read(root.join("items.json")).map_err(IdentityError::Read)?;
let items: BTreeMap<String, BTreeMap<String, serde_json::Value>> =
serde_json::from_slice(&bytes).map_err(IdentityError::Json)?;
// agree on their values as well as the registered wire IDs, native
// mods' changes included.
let items = horizon_world::item_defaults::read(root).map_err(IdentityError::Read)?;
let mut defaults = Vec::new();
for (item, components) in items {
for (name, value) in components {
if horizon_protocol::item::custom::id(&name).is_some() {
for (name, value) in components.as_object().into_iter().flatten() {
if horizon_protocol::item::custom::id(name).is_some() {
defaults.push((defaults.len() as u64, format!("{item}\0{name}\0{value}")));
}
}
@@ -109,10 +109,11 @@ fn hash_facts(bytes: &[u8]) -> Result<[u8; 32], IdentityError> {
}
/// The built-in registries whose network ids content mods extend besides blocks.
const REGISTRIES: [&str; 3] = [
const REGISTRIES: [&str; 4] = [
"minecraft:item",
"minecraft:mob_effect",
"minecraft:entity_type",
"minecraft:menu",
];
fn parse_registries(bytes: &[u8]) -> Result<Builtin, IdentityError> {
@@ -181,6 +182,32 @@ mod tests {
);
}
/// a mod menu type in the report changes the identity, so a client
/// without it is refused before it could be sent an id it lacks.
#[test]
fn a_mod_menu_type_joins_the_identity() {
let root = |name: &str, extra: &str| {
let root = std::env::temp_dir().join(format!(
"horizon-content-identity-menu-{name}-{}",
std::process::id()
));
std::fs::create_dir_all(&root).unwrap();
let facts = br#"{"states":[["minecraft:stone",false,false,"Block{minecraft:stone}",{"id":"minecraft:stone"}]]}"#;
std::fs::write(root.join("facts.json"), facts).unwrap();
let report = format!(
r#"{{"minecraft:menu":{{"entries":{{"minecraft:generic_9x1":{{"protocol_id":0}}{extra}}}}}}}"#
);
std::fs::write(root.join("registries.json"), report).unwrap();
let hash = identity(&root).unwrap();
std::fs::remove_dir_all(&root).unwrap();
hash
};
let vanilla = root("vanilla", "");
let press = root("press", r#","ruby:press":{"protocol_id":25}"#);
assert_ne!(press, vanilla);
assert_ne!(press, root("mill", r#","ruby:mill":{"protocol_id":25}"#));
}
#[test]
fn a_root_without_mod_entries_keeps_its_block_identity() {
let root = std::env::temp_dir().join(format!(
+235
View File
@@ -44,10 +44,73 @@ impl Ingredient {
match v {
Value::String(s) => add(s),
Value::Array(a) => a.iter().filter_map(Value::as_str).for_each(&mut add),
Value::Object(o) => return Self::parse_object(o, items),
_ => return None,
}
(!list.is_empty()).then(|| Self::of(list))
}
/// NeoForge's ingredient types (`NeoForgeRegistries.Keys.INGREDIENT_TYPES`),
/// the standard shapes mods' recipes carry, each resolved to the flat item
/// list it matches. A kind outside these still fails the file: a mod's
/// own ingredient types come with their own hook, not the loader.
fn parse_object(o: &serde_json::Map<String, Value>, items: &Items) -> Option<Self> {
// Current NeoForge marks a custom ingredient with
// `neoforge:ingredient_type`; `type` is the older packs' key.
let kind = o
.get("neoforge:ingredient_type")
.or_else(|| o.get("type"))?
.as_str()?;
let children = || {
o.get("children")
.or_else(|| o.get("ingredients"))
.and_then(Value::as_array)
};
let kept = match kind {
// `CompoundIngredient`: any of its children matching the stack
// is enough — the union of their items.
"neoforge:compound" => {
let subs: Vec<Self> = children()?
.iter()
.map(|s| Self::parse(s, items))
.collect::<Option<_>>()?;
let mut seen = Vec::new();
for sub in &subs {
for item in &sub.1 {
if !seen.contains(item) {
seen.push(item.clone());
}
}
}
seen
}
// `IntersectionIngredient`: every child must match the same
// stack — the items all of them name.
"neoforge:intersection" => {
let subs: Vec<Self> = children()?
.iter()
.map(|s| Self::parse(s, items))
.collect::<Option<_>>()?;
subs.first()?
.1
.iter()
.filter(|i| subs[1..].iter().all(|s| s.0.contains(*i)))
.cloned()
.collect()
}
// The base's items without the subtracted's.
"neoforge:difference" => {
let base = Self::parse(o.get("base")?, items)?;
let gone = Self::parse(o.get("subtracted")?, items)?;
base.1
.iter()
.filter(|i| !gone.0.contains(*i))
.cloned()
.collect()
}
_ => return None,
};
(!kept.is_empty()).then(|| Self::of(kept))
}
pub fn test(&self, stack: &ItemStack) -> bool {
self.0.contains(&*stack.item)
}
@@ -1252,6 +1315,18 @@ impl Recipes {
let mut meta = HashMap::new();
// Vanilla's recipes by bare name, then content mods' as `<ns>:<name>`.
let mut sources: Vec<(String, Vec<u8>)> = Vec::new();
// NeoForge's load conditions: `mod_loaded` asks which mods' data is
// in this root (`minecraft`, always, and the content mods').
// `mod_loaded` asks which mods are loaded, not which namespaces
// they own: a mod that also owns a namespace doesn't make that
// namespace another mod.
let mods: Vec<String> = {
let mut mods = horizon_items::content_namespaces::mod_ids(root);
if !mods.iter().any(|m| m == "minecraft") {
mods.push("minecraft".to_owned());
}
mods
};
for (name, p) in recipe_files(&root.join("data/minecraft/recipe"))? {
sources.push((name, fs::read(&p)?));
}
@@ -1291,6 +1366,13 @@ impl Recipes {
continue;
}
};
// A file whose load conditions don't hold is silently absent,
// as NeoForge's ICondition leaves it out of the manager.
if !horizon_world::conditions::conditions_hold(&v, &|id: &str| {
mods.iter().any(|m| m == id)
}) {
continue;
}
if let Some(kind) = custom_type(&v) {
let id = if name.contains(':') {
name
@@ -1759,6 +1841,99 @@ mod tests {
}
}
/// A mod's recipe whose ingredient is one of NeoForge's standard shapes
/// loads and carries the resolved items, where the plain string parser
/// used to drop the file whole.
#[test]
fn a_neoforge_ingredient_recipe_loads() {
let root =
std::env::temp_dir().join(format!("horizon-neo-ingredient-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join("data/minecraft/recipe")).unwrap();
fs::create_dir_all(root.join("data/mymod/recipe")).unwrap();
fs::write(
root.join(horizon_items::content_namespaces::MARKER),
br#"["mymod"]"#,
)
.unwrap();
let recipe = r##"{
"type": "minecraft:crafting_shapeless",
"ingredients": [
{"neoforge:ingredient_type": "neoforge:compound",
"children": ["minecraft:oak_planks", "minecraft:birch_planks"]},
{"type": "neoforge:difference",
"base": ["minecraft:oak_planks", "minecraft:birch_planks"],
"subtracted": "minecraft:birch_planks"}
],
"result": {"id": "minecraft:stick", "count": 4}
}"##;
fs::write(root.join("data/mymod/recipe/treated.json"), recipe).unwrap();
let items = Items::for_tests(HashMap::new());
let recipes = Recipes::load(&root, &items, &PackOverlay::NONE).unwrap();
assert_eq!(recipes.crafting.len(), 1);
assert_eq!(recipes.crafting[0].0, "mymod:treated");
let _ = fs::remove_dir_all(&root);
}
/// NeoForge's standard ingredient shapes resolve to their flat item
/// lists; a kind the loader doesn't know still fails the file.
#[test]
fn neoforge_ingredient_shapes_parse() {
let mut tags = HashMap::new();
tags.insert(
"minecraft:planks".to_owned(),
["minecraft:oak_planks", "minecraft:birch_planks"]
.iter()
.map(|s| s.to_string())
.collect::<HashSet<_>>(),
);
let items = Items::for_tests(tags);
let json = |s: &str| serde_json::from_str::<Value>(s).unwrap();
// Compound (`CompoundIngredient`): any child matching is enough —
// the union of their items, a tag beside an item gives both.
let union = Ingredient::parse(
&json(r##"{"neoforge:ingredient_type":"neoforge:compound","children":["#minecraft:planks","minecraft:stone"]}"##),
&items,
)
.unwrap();
// The tag's items come from a HashSet: their order is not fixed.
let mut all = union.1.clone();
all.sort();
assert_eq!(
all,
[
"minecraft:birch_planks",
"minecraft:oak_planks",
"minecraft:stone"
]
);
// Intersection: every child must match — only the items all name.
let both = Ingredient::parse(
&json(r##"{"type":"neoforge:intersection","children":["#minecraft:planks","minecraft:oak_planks"]}"##),
&items,
)
.unwrap();
assert_eq!(both.1, ["minecraft:oak_planks"]);
// Difference: the base without the subtracted.
let no_oak = Ingredient::parse(
&json(r##"{"type":"neoforge:difference","base":"#minecraft:planks","subtracted":"minecraft:oak_planks"}"##),
&items,
)
.unwrap();
assert_eq!(no_oak.1, ["minecraft:birch_planks"]);
// An empty intersection and an unknown kind fail the file, as today.
let empty = Ingredient::parse(
&json(
r#"{"type":"neoforge:intersection","children":["minecraft:oak_planks","minecraft:birch_planks"]}"#,
),
&items,
);
assert!(empty.is_none());
let unknown =
Ingredient::parse(&json(r#"{"type":"mymod:aspect","aspect":"fire"}"#), &items);
assert!(unknown.is_none());
}
fn ingredient(items: &[&str]) -> Ingredient {
Ingredient::of(items.iter().map(|s| (*s).to_owned()).collect())
}
@@ -1928,6 +2103,66 @@ mod tests {
assert_eq!((&*made.item, made.count), ("minecraft:stick", 1));
let _ = fs::remove_dir_all(&root);
}
/// A recipe whose `neoforge:conditions` hold loads, one whose don't is
/// silently absent, from the root's own files and a pack's alike.
#[test]
fn load_conditions_gate_recipes() {
let root = std::env::temp_dir().join(format!("horizon-conditions-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join("data/minecraft/recipe")).unwrap();
fs::write(
root.join(horizon_items::content_namespaces::MARKER),
br#"["mymod"]"#,
)
.unwrap();
fs::write(
root.join(horizon_items::content_namespaces::MOD_IDS_MARKER),
br#"["mymod"]"#,
)
.unwrap();
let file = |conditions: &str, tag: &str| {
format!(
r#"{{"type":"mymod:crusher","tag":"{tag}","neoforge:conditions":{conditions}}}"#
)
.into_bytes()
};
// `mymod` is in the root, `ic2` is not.
fs::write(
root.join("data/minecraft/recipe/yes.json"),
file(r#"[{"type":"neoforge:mod_loaded","modid":"mymod"}]"#, "yes"),
)
.unwrap();
fs::write(
root.join("data/minecraft/recipe/no.json"),
file(r#"[{"type":"neoforge:mod_loaded","modid":"ic2"}]"#, "no"),
)
.unwrap();
fs::write(
root.join("data/minecraft/recipe/inverted.json"),
file(
r#"[{"type":"neoforge:not","value":{"type":"neoforge:mod_loaded","modid":"ic2"}}]"#,
"inverted",
),
)
.unwrap();
let items = Items::for_tests(HashMap::new());
let recipes = Recipes::load(&root, &items, &PackOverlay::NONE).unwrap();
let ids = &recipes.custom["mymod:crusher"];
assert_eq!(
ids.iter().map(|r| r.id.as_str()).collect::<Vec<_>>(),
["minecraft:inverted", "minecraft:yes"]
);
// A pack's gated file answers the same gate.
let packs = [(
"recipe/minecraft:pack_yes".to_owned(),
vec![file(r#"[{"type":"neoforge:true"}]"#, "pack_yes")],
)];
let recipes = Recipes::load(&root, &items, &PackOverlay { files: &packs }).unwrap();
let ids = &recipes.custom["mymod:crusher"];
assert!(ids.iter().any(|r| r.id == "minecraft:pack_yes"));
let _ = fs::remove_dir_all(&root);
}
#[test]
fn cook_finds_the_first_recipe_the_scan_finds() {
let recipes = cooking_book();
+131
View File
@@ -0,0 +1,131 @@
//! `DebugLevelSource`: the `minecraft:debug` generator of the Debug world preset,
//! a grid of every block state floating over a layer of barriers.
use crate::{
chunk::{Chunk, LevelShape},
streaming::ChunkGenerator,
};
use horizon_world::{nbt::Tag, registry::BlockRegistry};
/// `DebugLevelSource.HEIGHT`: the layer the block states stand in.
pub const HEIGHT: i32 = 70;
/// `DebugLevelSource.BARRIER_HEIGHT`.
pub const BARRIER_HEIGHT: i32 = 60;
/// The debug world's generator: every block state in registry order
/// (`ALL_BLOCKS`) on a square grid, one every other block, at `HEIGHT`.
pub struct DebugTerrain {
shape: LevelShape,
biome: u16,
barrier: u32,
/// `ALL_BLOCKS.size()`: block states are numbered in that order, so the
/// state at an index is the state with that id.
states: u32,
}
impl DebugTerrain {
/// The generator of a world whose overworld is `minecraft:debug`.
pub fn from_settings(
settings: &Tag,
registry: &BlockRegistry,
biomes: &crate::biome::Biomes,
shape: LevelShape,
) -> Option<Self> {
let generator = settings
.get("dimensions")?
.get("minecraft:overworld")?
.get("generator")?;
if crate::save::text(generator.get("type"))?.trim_start_matches("minecraft:") != "debug" {
return None;
}
Some(Self {
shape,
// `FixedBiomeSource(plains)`.
biome: biomes.id("minecraft:plains"),
barrier: registry.default_state("minecraft:barrier")?.0,
states: u32::try_from(registry.len()).ok()?,
})
}
/// `DebugLevelSource.getBlockStateFor`: the state id at a block column
/// (air, id 0, where the grid has none).
pub fn state_for(&self, x: i32, z: i32) -> u32 {
grid_state(self.states, x, z)
}
}
/// `DebugLevelSource.GRID_WIDTH` and `GRID_HEIGHT` for `count` block states:
/// `Mth.ceil(Mth.sqrt(size))` and `ceil(size / width)`.
fn grid(count: u32) -> (i32, i32) {
let width = (count as f32).sqrt().ceil() as i32;
let height = (count as f32 / width as f32).ceil() as i32;
(width, height)
}
/// `getBlockStateFor(worldX, worldZ)` as a state id.
fn grid_state(count: u32, mut x: i32, mut z: i32) -> u32 {
let (width, height) = grid(count);
if x > 0 && z > 0 && x % 2 != 0 && z % 2 != 0 {
x /= 2;
z /= 2;
if x <= width && z <= height {
let index = (x * width + z).unsigned_abs();
if index < count {
return index;
}
}
}
0
}
impl ChunkGenerator for DebugTerrain {
type Worker = ();
fn worker(&self) {}
/// `DebugLevelSource.buildTerrain` builds nothing; `applyBiomeDecoration`
/// writes the barriers and the grid.
fn proto(&self, _: &mut (), cx: i32, cz: i32) -> crate::streaming::Proto {
let mut chunk = Chunk::with_layout(cx, cz, self.shape.column());
for s in &mut chunk.sections {
std::sync::Arc::make_mut(s).biomes = horizon_core::paletted::PalettedContainer::new(
crate::chunk::BIOME_STRATEGY,
u32::from(self.biome),
);
}
for z in 0..16 {
for x in 0..16 {
chunk.set(x, BARRIER_HEIGHT, z, self.barrier);
let state = self.state_for(cx * 16 + x, cz * 16 + z);
if state != 0 {
chunk.set(x, HEIGHT, z, state);
}
}
}
for s in &mut chunk.sections {
std::sync::Arc::make_mut(s).compact();
}
Box::new(chunk)
}
/// `spawnOriginalMobs` is empty.
fn finish(&self, proto: crate::streaming::Proto) -> Chunk {
*proto.downcast::<Chunk>().expect("debug terrain proto")
}
}
#[cfg(test)]
mod tests {
use super::*;
/// `getBlockStateFor`: only odd, positive columns hold a state, in the order
/// `x / 2 * width + z / 2`, and nothing past the last state.
#[test]
fn the_grid_holds_a_state_every_other_block() {
// 1000 states: a 32 wide grid, 32 high (ceil(1000 / 32) = 32).
assert_eq!(grid(1000), (32, 32));
assert_eq!(grid(1), (1, 1));
assert_eq!(grid(26_000), (162, 161));
// Even, zero and negative columns are air.
for (x, z) in [(2, 1), (1, 2), (0, 1), (1, 0), (-1, 1), (1, -1)] {
assert_eq!(grid_state(1000, x, z), 0, "{x} {z}");
}
// (1, 1) is index 0 (air), (3, 5) is 1 * 32 + 2 = 34.
assert_eq!(grid_state(1000, 1, 1), 0);
assert_eq!(grid_state(1000, 3, 5), 34);
// x = 2 * 32 + 1 maps to x / 2 = 32: index 32 * 32 + z / 2, past the 1000 states.
assert_eq!(grid_state(1000, 65, 1), 0);
assert_eq!(grid_state(1000, 61, 3), 30 * 32 + 1);
}
}
+143
View File
@@ -306,6 +306,37 @@ impl ItemEntity {
&mut self.velocity,
);
}
if !no_physics {
// Entity.applyEffectsFromBlocks: stepOn, then checkInsideBlocks.
if self.on_ground {
let p = BlockPos::containing(self.position.add_vector(Vec3::new(
0.0,
-f64::from(0.2_f32),
0.0,
)));
let state = world.block(p.x, p.y, p.z);
crate::block_contact::emit(crate::block_contact::Event::StepOn(
crate::block_contact::Contact {
entity: self.id as i32,
kind: "minecraft:item",
block: &facts.get(state).name,
state,
position: p,
},
));
}
crate::block_contact::swept_inside(
self.id as i32,
"minecraft:item",
self.previous,
self.position,
self.bounds(),
|p| {
let state = world.block(p.x, p.y, p.z);
(state, &facts.get(state).name)
},
);
}
if self.velocity.subtract(old_velocity).length_squared() > 0.01 {
self.needs_sync = true;
}
@@ -1018,9 +1049,15 @@ pub struct ArmorStand {
pub health: f32,
pub id: u32,
pub position: Vec3,
/// `Entity.xo`, `yo`, `zo`: the position at the start of the tick, which
/// the renderer blends toward `position` by the partial tick.
pub previous: Vec3,
pub velocity: Vec3,
pub on_ground: bool,
/// `yRot`, which the body follows (`ArmorStand.tickHeadTurn`: `yBodyRot`).
pub yaw: f32,
/// `yBodyRotO`: the body's yaw at the start of the tick.
pub previous_yaw: f32,
/// mainhand, offhand, feet, legs, chest, head (EquipmentSlot order).
pub equipment: [Option<crate::items::ItemStack>; 6],
pub small: bool,
@@ -1041,6 +1078,9 @@ pub struct ArmorStand {
/// rides, which seats it every tick (`positionRider`), as NPC plugins hang
/// a second name over a player's head.
pub vehicle: Option<u32>,
/// `ArmorStand.disabledSlots`: bit `filterFlag` locks a slot, bit
/// `filterFlag + 8` takes from it and bit `filterFlag + 16` puts into it.
pub disabled_slots: i32,
}
impl ArmorStand {
pub const SLOTS: [&'static str; 6] = ["mainhand", "offhand", "feet", "legs", "chest", "head"];
@@ -1056,15 +1096,50 @@ impl ArmorStand {
[-1.0, 0.0, -1.0],
[1.0, 0.0, 1.0],
];
/// Where the renderer draws the stand `alpha` of the way through the tick
/// (`EntityRenderer`'s `Mth.lerp(partialTick, xo, x)` and so on).
pub fn render_position(&self, alpha: f64) -> Vec3 {
self.previous.lerp(self.position, alpha)
}
/// The body's yaw `alpha` of the way through the tick
/// (`LivingEntityRenderer.solveBodyRot`: `Mth.rotLerp(partialTick,
/// yBodyRotO, yBodyRot)`).
pub fn render_yaw(&self, alpha: f32) -> f32 {
self.previous_yaw + alpha * crate::camera::wrap_degrees(self.yaw - self.previous_yaw)
}
/// A server's stand on its client, each tick:
/// `setOldPosAndRot` (`ClientLevel.tickNonPassenger`), then the step of
/// its `SteppedInterpolationHandler` (`step`: the position and yaw it is
/// put at, when one is due). The yaw
/// turns the short way and stays continuous, so a frame between ticks
/// never spins it.
pub fn interpolate(&mut self, step: Option<(Vec3, f32)>) {
self.previous = self.position;
self.previous_yaw = self.yaw;
if let Some((at, yaw)) = step {
self.position = at;
self.yaw += crate::camera::wrap_degrees(yaw - self.yaw);
}
}
/// Put there at once, with nothing to blend from
/// (`Entity.snapTo`).
pub fn snap(&mut self, at: Vec3, yaw: f32) {
self.position = at;
self.previous = at;
self.yaw = yaw;
self.previous_yaw = yaw;
}
pub fn new(id: u32, position: Vec3, yaw: f32) -> Self {
Self {
fire_ticks: 0,
health: 20.0,
id,
position,
previous: position,
velocity: Vec3::ZERO,
on_ground: true,
yaw,
previous_yaw: yaw,
equipment: Default::default(),
small: false,
show_arms: false,
@@ -1076,8 +1151,29 @@ impl ArmorStand {
last_hit: i64::MIN / 2,
pose: Self::DEFAULT_POSE,
vehicle: None,
disabled_slots: 0,
}
}
/// `EquipmentSlot.getFilterFlag` of an `equipment` index (main hand, off
/// hand, feet, legs, chest, head).
fn filter_flag(slot: usize) -> u32 {
[0, 5, 1, 2, 3, 4][slot]
}
/// `ArmorStand.isDisabled`: the slot is locked, or it is a hand and the
/// stand has no arms.
pub fn is_disabled(&self, slot: usize) -> bool {
self.disabled_slots & (1 << Self::filter_flag(slot)) != 0 || (slot < 2 && !self.show_arms)
}
/// The `swapItem` locks: taking from a filled slot or putting into an
/// empty one can each be disabled.
pub fn swap_locked(&self, slot: usize) -> bool {
let offset = if self.equipment[slot].is_some() {
8
} else {
16
};
self.disabled_slots & (1 << (Self::filter_flag(slot) + offset)) != 0
}
pub fn environment_tick(
&mut self,
world: &World,
@@ -1367,6 +1463,53 @@ mod fire_environment_tests {
}
}
#[cfg(test)]
mod armor_stand_render_tests {
use super::*;
/// A falling stand is drawn between last tick's position and this one's,
/// so it slides down every frame instead of jumping once a tick (#4416).
#[test]
fn a_falling_stand_is_drawn_between_its_ticks() {
let mut stand = ArmorStand::new(1, Vec3::new(0.5, 70.0, 0.5), 0.0);
// A new stand has no move behind it.
assert_eq!(stand.render_position(0.5), stand.position);
let fall = |s: &ArmorStand| Some((Vec3::new(0.5, s.position.y - 1.0, 0.5), 0.0));
stand.interpolate(fall(&stand));
assert_eq!(stand.render_position(0.0).y, 70.0);
assert_eq!(stand.render_position(0.25).y, 69.75);
assert_eq!(stand.render_position(1.0).y, 69.0);
stand.interpolate(fall(&stand));
assert_eq!(stand.render_position(0.5).y, 68.5);
// Landed: no step is due, so it stays put, and so does every frame.
stand.interpolate(None);
assert_eq!(stand.render_position(0.5).y, 68.0);
}
/// A turning stand's body turns between ticks too, the short way round.
#[test]
fn a_turning_stand_is_drawn_between_its_ticks() {
let mut stand = ArmorStand::new(1, Vec3::ZERO, 170.0);
assert_eq!(stand.render_yaw(0.5), 170.0);
// To -170 is 20 degrees on through 180, not 340 back through 0.
stand.interpolate(Some((Vec3::ZERO, -170.0)));
assert_eq!(stand.render_yaw(0.0), 170.0);
assert_eq!(stand.render_yaw(0.5), 180.0);
assert_eq!(stand.render_yaw(1.0), 190.0);
stand.interpolate(None);
assert_eq!(stand.render_yaw(0.5), 190.0);
}
/// A snap (a move too far to interpolate) leaves nothing to blend from.
#[test]
fn a_snapped_stand_is_drawn_where_it_landed() {
let mut stand = ArmorStand::new(1, Vec3::new(0.5, 70.0, 0.5), 0.0);
stand.interpolate(Some((Vec3::new(0.5, 69.0, 0.5), 0.0)));
stand.snap(Vec3::new(200.5, 64.0, 0.5), 90.0);
assert_eq!(stand.render_position(0.5), Vec3::new(200.5, 64.0, 0.5));
assert_eq!(stand.render_yaw(0.5), 90.0);
}
}
#[cfg(test)]
mod merge_tests {
use super::*;
+342 -14
View File
@@ -511,6 +511,10 @@ pub fn mob_to_nbt(
// Animal.addAdditionalSaveData: the love ticks left, whatever the save had.
if is_animal(id) {
map.insert("InLove".into(), Tag::Int(m.extra.in_love.max(0)));
// Animal.addAdditionalSaveData: the player who fed it.
if let Some(cause) = m.extra.love_cause {
map.insert("LoveCause".into(), Tag::IntArray(cause.to_vec()));
}
}
let species = m.species();
// MushroomCow saves its variant under `Type` (below), not `variant`.
@@ -538,7 +542,15 @@ pub fn mob_to_nbt(
"minecraft:fox" => {
let kind = if m.extra.variant == 1 { "snow" } else { "red" };
map.insert("Type".into(), string(kind));
map.entry("Trusted".into()).or_insert(Tag::List(Vec::new()));
// The trusted UUIDs (`Fox.TRUSTED_LIST_CODEC`: a list of int arrays).
map.insert(
"Trusted".into(),
Tag::List(
crate::mobs::fox::trusted_entities(m)
.map(|uuid| Tag::IntArray(uuid.to_vec()))
.collect(),
),
);
for (key, bit) in [
("Sleeping", crate::mobs::fox::SLEEPING),
("Sitting", crate::mobs::fox::SITTING),
@@ -575,7 +587,10 @@ pub fn mob_to_nbt(
// Rabbit.addAdditionalSaveData (the legacy id; 99 is the killer bunny).
"minecraft:rabbit" => {
map.insert("RabbitType".into(), Tag::Int(i32::from(m.extra.variant)));
map.entry("MoreCarrotTicks".into()).or_insert(Tag::Int(0));
map.insert(
"MoreCarrotTicks".into(),
Tag::Int(m.extra.more_carrot_ticks),
);
}
"minecraft:goat" => {
map.insert(
@@ -1141,6 +1156,25 @@ pub fn mob_to_nbt(
equipment.insert(slot.into(), crate::save::item_to_nbt(&stack));
continue;
}
// A worn piece or held item that kept its damage, name or
// components is saved with them.
if slot != "mainhand"
&& item.is_some()
&& let Some(stack) = crate::mobs::finalize::stored_stack(
m,
[
crate::mobs::finalize::MAINHAND,
crate::mobs::finalize::OFFHAND,
crate::mobs::finalize::HEAD,
crate::mobs::finalize::CHEST,
crate::mobs::finalize::LEGS,
crate::mobs::finalize::FEET,
][index],
)
{
equipment.insert(slot.into(), crate::save::item_to_nbt(&stack));
continue;
}
// Enchanted or trimmed gear is saved with its components.
if let (Some(id), Some(g)) = (item, crate::mobs::gear::gear(m))
&& (!g.slots[index].is_empty() || g.trims[index].is_some())
@@ -1405,6 +1439,10 @@ pub fn mob_from_nbt(
crate::mobs::gear::set(&mut m, index, stack.enchantments().to_vec());
let trim = stack.data().and_then(|d| d.trim.clone());
crate::mobs::gear::set_trim(&mut m, index, trim);
// The damage, name and components stay with the slot.
if index != crate::mobs::gear::MAIN {
crate::mobs::gear::set_stack(&mut m, index, stack);
}
}
}
} else {
@@ -1463,6 +1501,8 @@ pub fn mob_from_nbt(
}
if is_animal(&m.kind.id) {
m.extra.in_love = number(tag.get("InLove")).unwrap_or(0) as i32;
// Animal.readAdditionalSaveData: the player who fed it into love.
m.extra.love_cause = crate::save::uuid_from_tag(tag.get("LoveCause"));
}
m.name_visible = flag(tag, "CustomNameVisible");
m.no_ai = flag(tag, "NoAI");
@@ -1592,6 +1632,17 @@ pub fn mob_from_nbt(
match m.species() {
crate::mobs::Species::Fox => {
m.extra.variant = u8::from(text(tag.get("Type")).as_deref() == Some("snow"));
// Fox.readAdditionalSaveData: clearTrusted, then addTrustedEntity for
// each of "Trusted".
crate::mobs::fox::clear_trusted(&mut m);
if let Some(Tag::List(trusted)) = tag.get("Trusted") {
for uuid in trusted
.iter()
.filter_map(|t| crate::save::uuid_from_tag(Some(t)))
{
crate::mobs::fox::add_trusted_entity(&mut m, uuid);
}
}
// Fox.readAdditionalSaveData: the sleeping, sitting and crouching flags.
for (key, bit) in [
("Sleeping", crate::mobs::fox::SLEEPING),
@@ -1633,6 +1684,10 @@ pub fn mob_from_nbt(
}
_ => {}
}
// `Rabbit.readAdditionalSaveData`.
if m.species() == crate::mobs::Species::Rabbit {
m.extra.more_carrot_ticks = number(tag.get("MoreCarrotTicks")).unwrap_or(0) as i32;
}
// `Ocelot.readAdditionalSaveData`.
if m.species() == crate::mobs::Species::Ocelot {
m.extra.trusting = flag(tag, "Trusting");
@@ -2155,6 +2210,7 @@ pub fn arrow_from_nbt(
p.velocity = motion(tag);
[p.yaw, p.pitch] = rotation(tag);
p.left_owner = flag(tag, "LeftOwner");
p.owner_uuid = owner;
let a = p.arrow.as_mut()?;
a.life = number(tag.get("life")).unwrap_or(0) as i16 as i32;
a.shake = (number(tag.get("shake")).unwrap_or(0) as i8 as u8).into();
@@ -2198,26 +2254,121 @@ pub fn fireball_to_nbt(
"acceleration_power",
Tag::Double(p.acceleration.to_bits()),
);
// Projectile: LeftOwner only when set; HasBeenShot once it has ticked
// (a fireball saved before its first tick keeps what it was loaded with).
if let Tag::Compound(map) = &mut tag {
map.remove(&"LeftOwner".into());
}
if p.left_owner {
put(&mut tag, "LeftOwner", Tag::Byte(1));
}
if p.age > 0 || tag.get("HasBeenShot").is_none() {
put(&mut tag, "HasBeenShot", Tag::Byte(i8::from(p.age > 0)));
}
put_projectile(&mut tag, p, owner);
if let Some(power) = power {
put(&mut tag, "ExplosionPower", Tag::Byte(power as i8));
}
Some(tag)
}
/// `Projectile.addAdditionalSaveData`: Owner, LeftOwner only when set, and
/// HasBeenShot once it has ticked (one saved before its first tick keeps
/// what it was loaded with).
fn put_projectile(tag: &mut Tag, p: &Projectile, owner: Option<[i32; 4]>) {
if let Tag::Compound(map) = tag {
map.remove(&"LeftOwner".into());
}
if p.left_owner {
put(tag, "LeftOwner", Tag::Byte(1));
}
if p.age > 0 || tag.get("HasBeenShot").is_none() {
put(tag, "HasBeenShot", Tag::Byte(i8::from(p.age > 0)));
}
if let Some(owner) = owner {
put(&mut tag, "Owner", Tag::IntArray(owner.to_vec()));
put(tag, "Owner", Tag::IntArray(owner.to_vec()));
}
}
/// The thrown item projectiles Horizon saves: each entity type's id is also
/// the item it saves when none was loaded (`getDefaultItem`).
const THROWABLES: [(ProjectileKind, &str); 3] = [
(ProjectileKind::Snowball, "minecraft:snowball"),
(ProjectileKind::Egg, "minecraft:egg"),
(ProjectileKind::EnderPearl, "minecraft:ender_pearl"),
];
fn throwable_item(kind: &ProjectileKind) -> Option<&'static str> {
if let ProjectileKind::Thrown(t) = kind {
return Some(t.item);
}
THROWABLES
.iter()
.find(|(k, _)| k == kind)
.map(|&(_, id)| id)
}
/// The thrown kind an entity id names: vanilla's, or a content mod's type.
pub fn throwable_kind(id: &str) -> Option<ProjectileKind> {
match THROWABLES.iter().find(|(_, i)| *i == id) {
Some((kind, _)) => Some(kind.clone()),
None => crate::projectiles::thrown_type(id).map(ProjectileKind::Thrown),
}
}
/// Whether its chunk saves it: the kinds `arrow_to_nbt`, `fireball_to_nbt`
/// and [`throwable_to_nbt`] write.
pub fn saved_with_chunk(p: &Projectile) -> bool {
matches!(
p.kind,
ProjectileKind::LargeFireball { .. } | ProjectileKind::SmallFireball
) || (p.arrow.is_some() && matches!(p.kind, ProjectileKind::Arrow { .. }))
|| throwable_item(&p.kind).is_some()
}
/// `ThrowableItemProjectile.addAdditionalSaveData`: `Projectile`'s keys and
/// the `Item` it flies as.
pub fn throwable_to_nbt(
p: &Projectile,
uuid: [i32; 4],
owner: Option<[i32; 4]>,
raw: Option<&Tag>,
) -> Option<Tag> {
let item = throwable_item(&p.kind)?;
let base = Base {
id: p.kind.id(),
uuid,
position: p.position,
motion: p.velocity,
rotation: [p.yaw, p.pitch],
on_ground: false,
fall_distance: 0.0,
};
let mut tag = base.tag(raw);
put_projectile(&mut tag, p, owner);
// Entity.saveWithoutId writes NoGravity only when set.
if p.no_gravity {
put(&mut tag, "NoGravity", Tag::Byte(1));
}
// the loaded item stays (a blue egg's, a renamed one's).
if tag.get("Item").is_none() {
put(
&mut tag,
"Item",
item_to_nbt(&crate::items::ItemStack::new(item, 1)),
);
}
Some(tag)
}
/// `ThrowableItemProjectile.readAdditionalSaveData` for the kinds
/// [`throwable_to_nbt`] writes; the owner is the player when it is theirs.
pub fn throwable_from_nbt(tag: &Tag, player: Option<[i32; 4]>) -> Option<Projectile> {
let kind = throwable_kind(&kind(tag)?)?;
let owner = owner_uuid(tag);
let from_player = player.is_some() && owner == player;
let mut p = Projectile::new(kind, position(tag)?, from_player.then_some(Who::Player));
p.velocity = motion(tag);
[p.yaw, p.pitch] = rotation(tag);
p.left_owner = flag(tag, "LeftOwner");
p.owner_uuid = owner;
p.no_gravity = flag(tag, "NoGravity");
Some(p)
}
/// `Projectile`'s `Owner`.
pub fn owner_uuid(tag: &Tag) -> Option<[i32; 4]> {
uuid_from_tag(tag.get("Owner"))
}
pub fn fireball_from_nbt(tag: &Tag, player: Option<[i32; 4]>) -> Option<Projectile> {
let kind = match kind(tag)?.as_str() {
"minecraft:fireball" => ProjectileKind::LargeFireball {
@@ -2234,6 +2385,7 @@ pub fn fireball_from_nbt(tag: &Tag, player: Option<[i32; 4]>) -> Option<Projecti
p.velocity = motion(tag);
[p.yaw, p.pitch] = rotation(tag);
p.left_owner = flag(tag, "LeftOwner");
p.owner_uuid = owner;
p.acceleration = float(tag.get("acceleration_power")).unwrap_or(0.1);
Some(p)
}
@@ -2498,6 +2650,7 @@ pub fn armor_stand_to_nbt(a: &ArmorStand, uuid: [i32; 4], raw: Option<&Tag>) ->
} else {
put(&mut tag, "equipment", Tag::compound(equipment));
}
put(&mut tag, "DisabledSlots", Tag::Int(a.disabled_slots));
for (key, value) in [
("Small", a.small),
("ShowArms", a.show_arms),
@@ -2572,6 +2725,7 @@ pub fn armor_stand_from_nbt(tag: &Tag, id: u32) -> Option<ArmorStand> {
}
a.small = flag(tag, "Small");
a.show_arms = flag(tag, "ShowArms");
a.disabled_slots = number(tag.get("DisabledSlots")).unwrap_or(0) as i32;
a.no_base_plate = flag(tag, "NoBasePlate");
a.invisible = flag(tag, "Invisible");
a.marker = flag(tag, "Marker");
@@ -3519,6 +3673,34 @@ mod sheep_nbt_tests {
assert_eq!((back.extra.color, back.extra.sheared), (14, true));
}
/// `Rabbit.addAdditionalSaveData` and `readAdditionalSaveData`: `MoreCarrotTicks`.
#[test]
fn a_rabbit_keeps_how_full_of_carrots_it_is() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("items.json").exists() {
return;
}
let data = crate::mobs::MobTypes::load(&root).unwrap();
let types: HashMap<String, Arc<MobType>> = data
.types
.iter()
.map(|(k, v)| (k.clone(), Arc::new(v.clone())))
.collect();
let mut m = Mob::new(
1,
Arc::new(data.types["minecraft:rabbit"].clone()),
Vec3::new(0.0, 4.0, 0.0),
0.0,
);
m.extra.more_carrot_ticks = 33;
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None);
assert_eq!(number(tag.get("MoreCarrotTicks")), Some(33));
let back = mob_from_nbt(&tag, &types, 2, None).unwrap();
assert_eq!(back.extra.more_carrot_ticks, 33);
}
/// `Ocelot.addAdditionalSaveData` and `readAdditionalSaveData`: `Trusting`.
#[test]
fn an_ocelot_keeps_its_trust() {
@@ -4084,6 +4266,85 @@ mod fireball_nbt_tests {
assert_eq!(fireball_from_nbt(&tag, None).unwrap().kind, kind);
}
}
#[test]
fn thrown_item_projectiles_round_trip_their_flight_item_and_owner() {
let player = Some([5, 6, 7, 8]);
for (kind, id) in [
(ProjectileKind::Snowball, "minecraft:snowball"),
(ProjectileKind::Egg, "minecraft:egg"),
(ProjectileKind::EnderPearl, "minecraft:ender_pearl"),
] {
let mut p =
Projectile::new(kind.clone(), Vec3::new(1.5, 70.0, -2.5), Some(Who::Player));
(p.velocity, p.left_owner, p.age) = (Vec3::new(0.3, -0.1, 1.2), true, 4);
[p.yaw, p.pitch] = [30.0, -10.0];
let tag = throwable_to_nbt(&p, [1, 2, 3, 4], player, None).unwrap();
assert_eq!(super::kind(&tag).as_deref(), Some(id));
// ThrowableItemProjectile: the item it flies as, by its default.
let item = tag.get("Item").and_then(item_from_nbt).unwrap();
assert_eq!((&*item.item, item.count), (id, 1));
assert_eq!(number(tag.get("HasBeenShot")), Some(1));
assert_eq!(uuid_from_tag(tag.get("Owner")), player);
let back = throwable_from_nbt(&tag, player).unwrap();
assert_eq!((back.kind, back.owner), (kind, Some(Who::Player)));
assert_eq!((back.position, back.velocity), (p.position, p.velocity));
assert_eq!(
([back.yaw, back.pitch], back.left_owner),
([30.0, -10.0], true)
);
// an owner who is not here keeps their UUID through a moved resave.
let mut away = throwable_from_nbt(&tag, Some([9; 4])).unwrap();
assert_eq!((away.owner, away.owner_uuid), (None, player));
away.position.y += 3.0;
let resaved = throwable_to_nbt(&away, [1; 4], away.owner_uuid, None).unwrap();
assert_eq!(uuid_from_tag(resaved.get("Owner")), player);
}
// the chunk saves arrows, fireballs and these, and so takes them with it.
let kinds = [
(ProjectileKind::SmallFireball, true),
(ProjectileKind::Snowball, true),
(ProjectileKind::WindCharge, false),
(ProjectileKind::Trident, false),
];
for (kind, saved) in kinds {
let p = Projectile::new(kind, Vec3::ZERO, None);
assert_eq!(saved_with_chunk(&p), saved, "{:?}", p.kind);
}
// a loaded item stays, and a new projectile leaves LeftOwner out.
let raw = Tag::compound([
("id", Tag::String("minecraft:egg".into())),
("Pos", doubles(&[0.0, 64.0, 0.0])),
(
"Item",
item_to_nbt(&crate::items::ItemStack::new("minecraft:blue_egg", 1)),
),
]);
let egg = throwable_from_nbt(&raw, None).unwrap();
let tag = throwable_to_nbt(&egg, [1; 4], None, Some(&raw)).unwrap();
let item = tag.get("Item").and_then(item_from_nbt).unwrap();
assert_eq!(&*item.item, "minecraft:blue_egg");
assert!(tag.get("LeftOwner").is_none() && tag.get("Owner").is_none());
assert_eq!(number(tag.get("HasBeenShot")), Some(0));
let wind = Projectile::new(ProjectileKind::WindCharge, Vec3::ZERO, None);
assert!(throwable_to_nbt(&wind, [1; 4], None, None).is_none());
// NoGravity is loaded and saved again (a summoned pearl that floats).
let floating = Tag::compound([
("id", Tag::String("minecraft:ender_pearl".into())),
("Pos", doubles(&[0.0, 64.0, 0.0])),
("NoGravity", Tag::Byte(1)),
]);
let pearl = throwable_from_nbt(&floating, None).unwrap();
assert!(pearl.no_gravity);
let tag = throwable_to_nbt(&pearl, [1; 4], None, None).unwrap();
assert_eq!(number(tag.get("NoGravity")), Some(1));
assert!(
throwable_to_nbt(&egg, [1; 4], None, None)
.unwrap()
.get("NoGravity")
.is_none()
);
}
}
#[cfg(test)]
@@ -4136,6 +4397,43 @@ mod neutral_anger_tests {
mod held_item_name_tests {
use super::*;
#[test]
fn a_worn_piece_keeps_its_damage_and_name_through_the_save() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("items.json").exists() {
return;
}
let data = crate::mobs::MobTypes::load(&root).unwrap();
let types: HashMap<_, _> = data
.types
.into_iter()
.map(|(id, kind)| (id, Arc::new(kind)))
.collect();
let mut mob = Mob::new(1, Arc::clone(&types["minecraft:zombie"]), Vec3::ZERO, 0.0);
let mut helmet = crate::items::ItemStack::new("minecraft:iron_helmet", 1);
helmet.damage = 40;
helmet.data_mut().custom_name = Some(horizon_world::nbt::Tag::String("Hat".into()));
crate::mobs::finalize::set_item_stack(&mut mob, crate::mobs::finalize::HEAD, helmet);
let raw = mob_to_nbt(&mob, [1, 2, 3, 4], None, None);
let loaded = mob_from_nbt(&raw, &types, 2, None).unwrap();
let worn = crate::mobs::finalize::item_stack(&loaded, crate::mobs::finalize::HEAD).unwrap();
assert_eq!(worn.damage, 40);
assert_eq!(worn.custom_name().as_deref(), Some("Hat"));
// Replacing the item forgets the old stack.
let mut loaded = loaded;
crate::mobs::finalize::set_item(
&mut loaded,
crate::mobs::finalize::HEAD,
"minecraft:iron_helmet",
);
let fresh =
crate::mobs::finalize::item_stack(&loaded, crate::mobs::finalize::HEAD).unwrap();
assert_eq!(fresh.damage, 0);
assert!(fresh.custom_name().is_none());
}
#[test]
fn replacing_a_named_item_does_not_restore_its_old_name_on_save() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
@@ -4184,3 +4482,33 @@ mod held_item_name_tests {
);
}
}
#[cfg(test)]
mod mod_thrown_nbt_tests {
use super::*;
#[test]
fn a_mod_thrown_type_saves_its_id_and_item_and_loads_back() {
crate::projectiles::register_test_types();
let bolt = crate::projectiles::thrown_type("ruby:bolt").unwrap();
let mut p = Projectile::new(
ProjectileKind::Thrown(bolt),
Vec3::new(1.0, 80.0, 2.0),
None,
);
p.velocity = Vec3::new(0.0, 0.5, 1.0);
assert!(saved_with_chunk(&p));
let tag = throwable_to_nbt(&p, [1; 4], None, None).unwrap();
assert_eq!(super::kind(&tag).as_deref(), Some("ruby:bolt"));
let item = tag.get("Item").and_then(item_from_nbt).unwrap();
assert_eq!(&*item.item, "ruby:gem");
let back = throwable_from_nbt(&tag, None).unwrap();
assert_eq!((&back.kind, back.velocity), (&p.kind, p.velocity));
// NoGravity comes back too, as for vanilla's (a summoned bolt that floats).
p.no_gravity = true;
let tag = throwable_to_nbt(&p, [1; 4], None, None).unwrap();
assert!(throwable_from_nbt(&tag, None).unwrap().no_gravity);
// a type no mod declares now is not a thrown kind.
assert!(throwable_kind("gone:bolt").is_none());
}
}
File diff suppressed because it is too large. Load diff
@@ -457,6 +457,8 @@ impl Game<'_> {
EntityView {
kind: "minecraft:player".into(),
position: [p.position.x, p.position.y, p.position.z],
// Entity.isOnFire on a server: remainingFireTicks (players aren't fire immune).
on_fire: p.fire_ticks > 0,
sneaking: p.pose == horizon_client::pose::Pose::Crouching,
// Input.shift: the held key, as `BlockItem.place`'s sneaking reads it.
input_sneak: self.keys.contains(&winit::keyboard::KeyCode::ShiftLeft),
+48 -9
View File
@@ -522,7 +522,9 @@ impl Game<'_> {
let slot = match &held {
Some(h) => {
let slot = self.equipment_slot(h);
if slot < 2 && !a.show_arms {
// ArmorStand.interactAt: a disabled slot, or a hand without
// arms, is FAIL.
if a.is_disabled(slot) {
return true;
}
slot
@@ -550,6 +552,18 @@ impl Game<'_> {
}
}
};
// An empty hand takes from the main hand when the clicked slot is
// disabled (`isDisabled(clickedSlot) ? slot : clickedSlot`).
let slot = if held.is_none() && a.is_disabled(slot) {
0
} else {
slot
};
// ArmorStand.swapItem: taking from a filled slot or putting into an
// empty one can be disabled; the click then passes.
if a.swap_locked(slot) {
return false;
}
let current = self.armor_stands[i].equipment[slot].clone();
if held.is_none() && current.is_none() {
return false;
@@ -722,6 +736,8 @@ impl Game<'_> {
// ServerLevel.tick: only entities in entity-ticking chunks tick.
let ticking = self.entity_ticking_area();
for (i, a) in self.armor_stands.iter_mut().enumerate() {
a.previous = a.position;
a.previous_yaw = a.yaw;
if !ticking(a.position.x.floor() as i32, a.position.z.floor() as i32) {
continue;
}
@@ -785,11 +801,10 @@ impl Game<'_> {
let dragon = a.equipment[5]
.as_ref()
.is_some_and(|i| &*i.item == "minecraft:dragon_head");
let position = super::entity_culling::at(a.position);
if !self.standing_entity_in_view(
camera,
position,
position,
super::entity_culling::at(a.previous),
super::entity_culling::at(a.position),
alpha,
width,
height,
@@ -798,7 +813,9 @@ impl Game<'_> {
) {
continue;
}
let p = glam::DVec3::new(a.position.x, a.position.y, a.position.z) - camera;
// EntityRenderer: drawn between last tick's position and this one's.
let at = a.render_position(alpha);
let p = super::entity_culling::at(at) - camera;
// setupRotations: 180 - body yaw, plus the wobble for 5 ticks after a hit.
let wiggle = (self.world.game_time - a.last_hit) as f32 + alpha as f32;
let wobble = if wiggle < 5.0 {
@@ -807,13 +824,15 @@ impl Game<'_> {
0.0
};
let base = glam::Mat4::from_translation(p.as_vec3())
* glam::Mat4::from_rotation_y((180.0 - a.yaw + wobble).to_radians())
* glam::Mat4::from_rotation_y(
(180.0 - a.render_yaw(alpha as f32) + wobble).to_radians(),
)
* glam::Mat4::from_scale(glam::Vec3::new(-1.0, -1.0, 1.0))
* glam::Mat4::from_translation(glam::Vec3::new(0.0, -1.501, 0.0));
let light = self.world.light(
a.position.x.floor() as i32,
(a.position.y + 0.5).floor() as i32,
a.position.z.floor() as i32,
at.x.floor() as i32,
(at.y + 0.5).floor() as i32,
at.z.floor() as i32,
);
let packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
self.armor_stand_vertices(a, base, packed, out);
@@ -1322,6 +1341,26 @@ mod swap_tests {
ItemStack::new(item, count)
}
#[test]
fn disabled_slots_lock_by_filter_flag() {
let mut a = ArmorStand::new(1, horizon_core::geometry::Vec3::ZERO, 0.0);
// Hands need arms.
assert!(a.is_disabled(0) && a.is_disabled(1));
a.show_arms = true;
assert!(!a.is_disabled(0) && !a.is_disabled(1));
// Bit 4 locks the head (equipment index 5), bit 5 the off hand (1).
a.disabled_slots = (1 << 4) | (1 << 5);
assert!(a.is_disabled(5) && a.is_disabled(1));
assert!(!a.is_disabled(4) && !a.is_disabled(0));
// Taking (+8) from a filled slot and putting (+16) into an empty one.
a.disabled_slots = (1 << (3 + 8)) | (1 << (1 + 16));
assert!(!a.swap_locked(4), "an empty chest slot is not take-locked");
a.equipment[4] = Some(stack("minecraft:iron_chestplate", 1));
assert!(a.swap_locked(4));
assert!(a.swap_locked(2), "an empty feet slot is put-locked");
a.equipment[2] = Some(stack("minecraft:iron_boots", 1));
assert!(!a.swap_locked(2));
}
#[test]
fn creative_swap_gives_the_worn_piece_to_the_hand() {
let swap = swap_with_equipment_slot(
&stack("minecraft:diamond_chestplate", 1),
@@ -778,6 +778,15 @@ impl Game<'_> {
return None;
};
let skin = skull.skin.as_ref()?;
// PlayerSkin.Patch: a `texture` override replaces the profile's skin.
if let Some(texture) = &skin.texture
&& let Some(sprite) =
skins::patch_skin(texture, &mut self.skin_downloads.borrow_mut(), |location| {
self.gui.atlas.sprites.contains_key(location)
})
{
return Some(sprite);
}
// The texture a packed or resolved `textures` property fetches: the
// download of a static profile, or the one the services answer for a
// `Dynamic` (name- or id-only) profile.
+2
View File
@@ -1308,6 +1308,8 @@ impl Game<'_> {
let destroyed = creative || b.damage > 40.0;
if destroyed {
self.eject_boat_mobs(i, true);
let id = self.boats[i].id;
self.release_vehicle_container(id);
let b = self.boats.remove(i);
let at = [b.position.x, b.position.y, b.position.z];
// AbstractBoat.remove: a destroyed boat drops its lead.
+18 -5
View File
@@ -71,7 +71,7 @@ impl Game<'_> {
}
let at = hit.pos.map(|v| f64::from(v) + 0.5);
self.audio.play(sound, Some(at), 1.0, 1.0);
if brushable && self.brush_block(hit.pos, hit.face) {
if brushable && self.brush_block(hit.pos, hit.face, slot) {
self.damage_held(1);
}
}
@@ -113,7 +113,7 @@ impl Game<'_> {
}
/// `BrushableBlockEntity.brush`: whether the block changed (the brush wears).
fn brush_block(&mut self, pos: [i32; 3], face: [i32; 3]) -> bool {
fn brush_block(&mut self, pos: [i32; 3], face: [i32; 3], slot: usize) -> bool {
let time = self.world.game_time;
let mut brush = self.brushing.remove(&pos).unwrap_or_default();
brush.hit_face.get_or_insert(face);
@@ -141,7 +141,7 @@ impl Game<'_> {
let before = completion_state(brush.count);
brush.count += 1;
if brush.count >= 10 {
self.brushing_completed(pos, brush.hit_face);
self.brushing_completed(pos, brush.hit_face, slot);
return true;
}
self.schedule_brushable_tick(pos);
@@ -191,7 +191,7 @@ impl Game<'_> {
/// `BrushableBlockEntity.brushingCompleted`: the item drops out of the
/// brushed face and the block turns into its sand or gravel.
fn brushing_completed(&mut self, pos: [i32; 3], face: Option<[i32; 3]>) {
fn brushing_completed(&mut self, pos: [i32; 3], face: Option<[i32; 3]>, slot: usize) {
let state = self.world.block(pos[0], pos[1], pos[2]);
let turns_into = match &*self.res.facts().get(state).name {
"minecraft:suspicious_sand" => "minecraft:sand",
@@ -212,7 +212,20 @@ impl Game<'_> {
f64::from(pos[1] + face[1]) + 0.625,
f64::from(pos[2] + face[2]) + 0.5,
];
self.spawn_item(item, at, [0.0; 3], 0);
let mut drops = vec![item];
let tool = self.stack_in(slot).cloned();
let origin = pos.map(|c| f64::from(c) + 0.5);
self.native_use_drops(
"",
origin,
Some((pos, state)),
None,
tool.as_ref(),
&mut drops,
);
for item in drops {
self.spawn_item(item, at, [0.0; 3], 0);
}
}
// Level event 3008 for every player, then the block change.
self.level_event_at(3008, pos, state.0 as i32);
+4
View File
@@ -435,6 +435,9 @@ impl Game<'_> {
]
.map(String::from),
);
// content mods' thrown types.
let thrown = horizon_client::projectiles::thrown_types().iter();
summonable.extend(thrown.map(|t| t.id.to_owned()));
summonable.sort();
summonable.dedup();
reg.summonable = summonable;
@@ -1432,6 +1435,7 @@ impl Game<'_> {
renderer.screenshot = Some(horizon_client::gfx::ScreenshotRequest {
path: path.clone(),
size: None,
scaled: true,
});
self.ui.screenshot_message = Some(path);
}
+59 -12
View File
@@ -80,6 +80,9 @@ pub(super) struct CombatTracker {
pending: Vec<(String, (u32, Text), Option<Text>)>,
/// `pending` for a direct entity with no causing entity: only its name.
pending_direct: Vec<(String, Text)>,
/// The lengths of `pending` and `pending_direct` before the first
/// attacker noted for the hurt being dealt now (`forget_noted`).
noted_since: Option<(usize, usize)>,
/// `LivingEntity.lastHurtByMob` and its timestamp (the kill credit).
last_hurt_by: Option<((u32, Text), i64)>,
/// The mob the player last hurt (`getLastHurtMob`) and when.
@@ -145,6 +148,23 @@ pub(super) struct CombatTracker {
/// end of the last one).
fall_before: f64,
}
impl CombatTracker {
fn mark(&mut self) {
let marks = (self.pending.len(), self.pending_direct.len());
self.noted_since.get_or_insert(marks);
}
/// The player's hurt is over: when a native mod canceled it, the
/// attackers noted for it go, as no combat entry takes them and the next
/// hit of the same damage type must not be credited to them.
pub(super) fn forget_noted(&mut self, canceled: bool) {
if let Some((attackers, directs)) = self.noted_since.take()
&& canceled
{
self.pending.truncate(attackers);
self.pending_direct.truncate(directs);
}
}
}
impl Game<'_> {
/// `LivingEntity.makeDrownParticles`: eight bubbles around the entity,
/// moving with it.
@@ -197,18 +217,6 @@ impl Game<'_> {
self.note_named_attacker(damage_type, mob, name);
}
}
/// How many attackers wait for the damage they caused, for
/// [`Game::drop_pending_since`].
pub(super) fn pending_marks(&self) -> (usize, usize) {
(self.combat.pending.len(), self.combat.pending_direct.len())
}
/// Forgets the attackers noted since `marks`: their hit didn't land (a
/// native mod canceled it), so no combat entry takes them, and the next
/// hit of the same damage type must not be credited to them.
pub(super) fn drop_pending_since(&mut self, marks: (usize, usize)) {
self.combat.pending.truncate(marks.0);
self.combat.pending_direct.truncate(marks.1);
}
/// `note_attacker` for an entity that is not one of `self.mobs` (the
/// ender dragon).
pub(super) fn note_named_attacker(&mut self, damage_type: &str, id: u32, name: Text) {
@@ -223,6 +231,7 @@ impl Game<'_> {
name: Text,
item: Option<Text>,
) {
self.combat.mark();
self.combat
.pending
.push((damage_type.to_owned(), (id, name), item));
@@ -231,6 +240,7 @@ impl Game<'_> {
/// nothing caused it (a splash potion nobody threw): the death message
/// names it instead (`getLocalizedDeathMessage`). It is no kill credit.
pub(super) fn note_direct_entity(&mut self, damage_type: &str, name: Text) {
self.combat.mark();
self.combat
.pending_direct
.push((damage_type.to_owned(), name));
@@ -314,6 +324,7 @@ impl Game<'_> {
}
self.combat.pending.clear();
self.combat.pending_direct.clear();
self.combat.noted_since = None;
self.combat.projectile_direct = None;
self.combat.projectile_attacker = None;
self.combat.fall_before = self.player.as_ref().map_or(0.0, |p| p.fall_distance);
@@ -741,7 +752,43 @@ mod held_item_tests {
assert!(!has_custom_name(&gone));
}
}
#[cfg(test)]
mod noted_tests {
use super::CombatTracker;
use horizon_client::text::Text;
/// A canceled hit forgets what was noted for it, also before its hurt
/// started (a PvP blow, `/damage`), and nothing a landed hit noted.
#[test]
fn a_canceled_hit_forgets_only_its_own_attackers() {
let mut c = CombatTracker::default();
let arrow = |c: &mut CombatTracker, id| {
c.mark();
c.pending.push((
"minecraft:arrow".into(),
(id, Text::literal("skeleton")),
None,
));
};
arrow(&mut c, 1);
c.forget_noted(false);
arrow(&mut c, 2);
c.mark();
c.pending_direct
.push(("minecraft:arrow".into(), Text::literal("arrow")));
c.forget_noted(true);
assert_eq!(c.pending.iter().map(|p| p.1.0).collect::<Vec<_>>(), [1]);
assert!(c.pending_direct.is_empty());
// A hit noted and landed, then one noted and canceled: the landed
// one keeps its attacker.
c.forget_noted(false);
arrow(&mut c, 3);
c.forget_noted(false);
arrow(&mut c, 4);
c.forget_noted(true);
assert_eq!(c.pending.iter().map(|p| p.1.0).collect::<Vec<_>>(), [1, 3]);
}
}
#[cfg(test)]
mod stuck_tests {
use super::Stuck;
+56 -27
View File
@@ -3,6 +3,7 @@
mod admin;
pub(super) mod compute;
mod data;
pub(super) use data::place_summoned;
mod effects;
pub(super) mod execute;
pub(super) mod extra;
@@ -76,6 +77,11 @@ pub(in crate::game) struct OtherEntity {
pub(super) fn err(key: &str, args: &[&dyn std::fmt::Display]) -> CommandError {
CommandError::tr(key, args)
}
/// `EntityType.getDescriptionId`: `entity.<namespace>.<path>`.
fn entity_name_key(id: &str) -> String {
let (namespace, path) = id.split_once(':').unwrap_or(("minecraft", id));
format!("entity.{namespace}.{}", path.replace('/', "."))
}
fn texts(args: &[Text]) -> Vec<Text> {
args.to_vec()
}
@@ -1227,21 +1233,11 @@ impl Game<'_> {
static RARITY: std::sync::OnceLock<std::collections::HashMap<String, String>> =
std::sync::OnceLock::new();
let map = RARITY.get_or_init(|| {
let v: serde_json::Value = serde_json::from_slice(
&std::fs::read(self.options.resources.join("items.json")).unwrap_or_default(),
)
.unwrap_or_default();
v.as_object()
.map(|o| {
o.iter()
.filter_map(|(k, c)| {
c["minecraft:rarity"]
.as_str()
.map(|r| (k.clone(), r.to_owned()))
})
.collect()
})
horizon_world::item_defaults::read(&self.options.resources)
.unwrap_or_default()
.into_iter()
.filter_map(|(k, c)| c["minecraft:rarity"].as_str().map(|r| (k, r.to_owned())))
.collect()
});
map.get(item).cloned().unwrap_or_else(|| "common".into())
}
@@ -1951,11 +1947,15 @@ impl Game<'_> {
Target::Boat(id) => {
// AbstractBoat.remove: a killed boat drops its lead.
if let Some(i) = self.boats.iter().position(|b| b.id == id) {
self.release_vehicle_container(id);
let b = self.boats.remove(i);
self.boat_removed(&b);
}
}
Target::Cart(id) => self.carts.retain(|c| c.id != id),
Target::Cart(id) => {
self.release_vehicle_container(id);
self.carts.retain(|c| c.id != id);
}
Target::Other(id) => self.others.retain(|o| o.id != id),
Target::Crystal(id) => self.kill_crystal(id),
Target::Dragon(id) => self.kill_dragon(id),
@@ -2019,18 +2019,18 @@ impl Game<'_> {
&& self
.res
.mob_types()
.get(&format!("minecraft:{short}"))
.get(&kind)
.is_some_and(|t| t.category == "monster")
{
return Err(err("commands.summon.failed.peaceful", &[]));
}
let before = self.spawned_mark();
let name = match self.summon_with_nbt(&format!("minecraft:{short}"), pos, &nbt)? {
let name = match self.summon_with_nbt(&kind, pos, &nbt)? {
Some(target) => self.target_name(target),
// Spawned as a kind no command target stands for (primed
// TNT, falling blocks, projectiles): its type's name.
None if self.spawned_mark() != before => {
Text::translate(&format!("entity.minecraft.{short}"), &[])
Text::translate(&entity_name_key(&kind), &[])
}
None => return Err(err("commands.summon.failed", &[])),
};
@@ -2038,17 +2038,12 @@ impl Game<'_> {
return Ok(1);
}
let name;
if let Some(t) = self
.res
.mob_types()
.get(&format!("minecraft:{short}"))
.cloned()
{
if let Some(t) = self.res.mob_types().get(&kind).cloned() {
if self.ui.difficulty == 0 && t.category == "monster" {
return Err(err("commands.summon.failed.peaceful", &[]));
}
self.spawn_mob_spawned(&short, pos);
name = Text::translate(&format!("entity.minecraft.{short}"), &[]);
name = Text::translate(&entity_name_key(&kind), &[]);
} else {
match short.as_str() {
"item" => {
@@ -2106,11 +2101,19 @@ impl Game<'_> {
let cart_kind = super::minecarts::CartKind::from_id(&kind)
.unwrap_or(super::minecarts::CartKind::Rideable);
self.next_entity += 1;
let cart = super::minecarts::Minecart::new(
let mut cart = super::minecarts::Minecart::new(
self.next_entity,
cart_kind,
Vec3::new(pos[0], pos[1], pos[2]),
);
// SummonCommand.createEntity loads even an empty compound, so a
// spawner minecart's BaseSpawner.load runs: SpawnPotentials becomes
// WeightedList.of(new SpawnData()).
if cart.spawner.is_some() {
use horizon_client::mobs::spawner::{Spawner, SpawnerExt};
let empty = horizon_world::nbt::Tag::Compound(Default::default());
cart.spawner = Some(Box::new(Spawner::load(&empty)));
}
self.carts.push(cart);
name = Text::translate(&format!("entity.minecraft.{k}"), &[]);
}
@@ -2122,7 +2125,7 @@ impl Game<'_> {
// Spawned as a kind no command target stands for
// (primed TNT, falling blocks, projectiles).
None if self.spawned_mark() != before => {
Text::translate(&format!("entity.minecraft.{short}"), &[])
Text::translate(&entity_name_key(&kind), &[])
}
None => return Err(err("commands.summon.failed", &[])),
};
@@ -2236,6 +2239,10 @@ impl Game<'_> {
return Ok(0);
};
let state = self.block_state(&b)?;
// SetBlockCommand.setBlock: `ServerLevel.isDebug` fails it.
if self.world_debug {
return Err(err("commands.setblock.failed", &[]));
}
let current = self.world.block(pos[0], pos[1], pos[2]);
if ctx.has("keep") && current != self.world.air {
return Err(err("commands.setblock.failed", &[]));
@@ -2438,6 +2445,10 @@ impl Game<'_> {
if area > i64::from(limit) {
return Err(err("commands.fill.toobig", &[&limit, &area]));
}
// FillCommand.fillBlocks: `ServerLevel.isDebug` fails it.
if self.world_debug {
return Err(err("commands.fill.failed", &[]));
}
let state = self.block_state(&block)?;
let air = self.world.air;
let (hollow, outline, destroy, strict, keep) = (
@@ -2518,6 +2529,10 @@ impl Game<'_> {
return Err(err("argument.pos.unloaded", &[]));
}
}
// CloneCommands.clone: the destination level being a Debug world fails it.
if self.world_debug {
return Err(err("commands.clone.failed", &[]));
}
let filter = match ctx.get("filter") {
Some(Arg::Block(f)) => Some(f.clone()),
_ => None,
@@ -3554,6 +3569,20 @@ fn split_top_level(s: &str, sep: char) -> Vec<&str> {
out
}
#[cfg(test)]
mod summon_tests {
#[test]
fn a_summoned_entity_is_named_from_its_own_namespace() {
use super::entity_name_key;
assert_eq!(
entity_name_key("minecraft:snowball"),
"entity.minecraft.snowball"
);
assert_eq!(entity_name_key("ruby:bolt"), "entity.ruby.bolt");
assert_eq!(entity_name_key("ruby:spell/bolt"), "entity.ruby.spell.bolt");
}
}
#[cfg(test)]
mod look_at_tests {
use super::{Target, non_player_entity_id};
+31 -30
View File
@@ -28,6 +28,36 @@ fn set_key(tag: &mut Tag, key: &str, value: Tag) {
c.insert(JavaString::from(key), value);
}
}
/// `EntityType.loadEntityRecursive` snaps every entity of the stack,
/// passengers included, to the position (`/summon`, `native_summon`).
pub(in crate::game) fn place_summoned(tag: &mut Tag, pos: [f64; 3]) {
let p = pos.map(|v| Tag::Double(v.to_bits()));
set_key(tag, "Pos", Tag::List(p.to_vec()));
// Entity.load: an absent Fire reads as 0 (until the entity moves).
if tag.get("Fire").is_none() {
set_key(tag, "Fire", Tag::Short(0));
}
if tag.get("Rotation").is_none() {
set_key(
tag,
"Rotation",
Tag::List(vec![
Tag::Float(0.0f32.to_bits()),
Tag::Float(0.0f32.to_bits()),
]),
);
}
if let Tag::Compound(c) = tag
&& let Some(Tag::List(riders)) = c.get_mut(&"Passengers".into())
{
for r in riders {
if matches!(r, Tag::Compound(_)) {
place_summoned(r, pos);
}
}
}
}
impl Game<'_> {
// ---- entity data ---------------------------------------------------
@@ -762,36 +792,7 @@ impl Game<'_> {
return Err(err("argument.nbt.expected.compound", &[]));
}
set_key(&mut tag, "id", Tag::String(kind.into()));
// EntityType.loadEntityRecursive snaps every entity of the stack,
// passengers included, to the position.
fn place(tag: &mut Tag, pos: [f64; 3]) {
let p = pos.map(|v| Tag::Double(v.to_bits()));
set_key(tag, "Pos", Tag::List(p.to_vec()));
// Entity.load: an absent Fire reads as 0 (until the entity moves).
if tag.get("Fire").is_none() {
set_key(tag, "Fire", Tag::Short(0));
}
if tag.get("Rotation").is_none() {
set_key(
tag,
"Rotation",
Tag::List(vec![
Tag::Float(0.0f32.to_bits()),
Tag::Float(0.0f32.to_bits()),
]),
);
}
if let Tag::Compound(c) = tag
&& let Some(Tag::List(riders)) = c.get_mut(&"Passengers".into())
{
for r in riders {
if matches!(r, Tag::Compound(_)) {
place(r, pos);
}
}
}
}
place(&mut tag, pos);
place_summoned(&mut tag, pos);
let from = self.mobs.len();
let canceled = self.persist.joins_canceled;
let target = self.spawn_entity_from_nbt(tag);
@@ -186,10 +186,13 @@ impl Game<'_> {
_ => Some(None),
};
self.for_player(target, |g| {
if g.spectator() {
return false;
}
if let Some(name) = named {
g.note_direct_entity(&ty, name);
}
!g.spectator() && g.hurt_player_from(amount, from, &ty, attacker)
g.hurt_player_from(amount, from, &ty, attacker)
})
.unwrap_or(false)
}
+98 -42
View File
@@ -150,6 +150,69 @@ impl Game<'_> {
let _ = request.reply.send(reply.to_string());
}
/// Text typed at the open screen's text box by the control socket's
/// `type`: its `\b` is a backspace sentinel for the boxes that have always
/// taken one, not the characters `\` and `b`. `Err` when no text box of
/// the screen takes it.
pub(super) fn type_into_screen(&mut self, text: &str) -> Result<()> {
if text == "\\b" {
match self.screen {
Screen::WorldOptions => {
self.world_options_text(None);
return Ok(());
}
Screen::Inventory if self.mod_browser_typing() => {
self.mod_browser_text(None, true);
return Ok(());
}
_ => {}
}
}
self.type_chars_into_screen(text)
}
/// Characters the open screen's focused text box takes: the control
/// socket's `type` and a key press's surviving characters (`charTyped`).
/// Nothing here is a key — the caller drops control characters. `Err` when
/// no text box of the screen takes them.
pub(super) fn type_chars_into_screen(&mut self, text: &str) -> Result<()> {
if self.book.is_some() {
self.book_type(text);
return Ok(());
}
if self.sign_edit.is_some() {
self.sign_input(None, Some(text));
return Ok(());
}
match self.screen {
Screen::Chat => self.chat_type(text),
Screen::CommandBlock => self.command_block_type(text),
Screen::BlockEditor => self.block_editor_type(text),
Screen::DebugOptions => self.debug_options_type(text),
Screen::Options => self.ui.options.type_text(&self.gui.atlas, text),
// The port, or the game rules' search and number boxes.
Screen::WorldOptions => self.world_options_text(Some(text)),
Screen::Inventory if self.mod_browser_typing() => {
self.mod_browser_text(Some(text), false)
}
Screen::Inventory if self.recipe_search_focused() => {
self.recipe_text(Some(text), false)
}
// The anvil's rename box, while it has an input to name.
Screen::Inventory if self.station_typing() => self.station_text(Some(text), false),
Screen::Creative if self.creative_screen.tab().search => {
// As typed: replaces a selected query, keeps the length limit.
self.creative_search_edit(Some(text))
}
_ => {
return Err(
"type needs the chat screen (key t tap), a sign or book, the language search, the creative search, the anvil or World Options".into(),
);
}
}
Ok(())
}
fn control_command(
&mut self,
line: &str,
@@ -371,6 +434,17 @@ impl Game<'_> {
}
// Native mods see the key as the keyboard hook does.
let action = arg(2)?;
// ScreenEvent.KeyPressed.Pre: the open screen hears of the
// press before it acts on it, as the window's key event hands
// it over. A cancelled press goes no further — not to the
// screens, not to `native_key` — and its release still comes.
if action != "up" && self.screen_key_pressed(physical, action == "repeat") {
if action == "tap" {
self.physical_keys.remove(&physical);
self.native_key_event(physical, false, false);
}
return ok();
}
self.native_key_event(physical, action != "up", action == "repeat");
if action == "tap" {
self.native_key_event(physical, false, false);
@@ -644,6 +718,7 @@ impl Game<'_> {
renderer.screenshot = Some(horizon_client::gfx::ScreenshotRequest {
path: path.trim().into(),
size: None,
scaled: false,
});
self.pending_screenshot = Some(reply.clone());
Ok(None)
@@ -674,48 +749,20 @@ impl Game<'_> {
self.require_world()?;
// Typed keys: the open screen's narration hears them.
self.narration.keyboard_action();
if self.book.is_some() {
self.book_type(text);
return ok();
}
if self.sign_edit.is_some() {
self.sign_input(None, Some(text));
return ok();
}
match self.screen {
Screen::Chat => self.chat_type(text),
Screen::CommandBlock => self.command_block_type(text),
Screen::BlockEditor => self.block_editor_type(text),
Screen::DebugOptions => self.debug_options_type(text),
Screen::Options => self.ui.options.type_text(&self.gui.atlas, text),
// The port, or the game rules' search and number boxes.
Screen::WorldOptions if text == "\\b" => self.world_options_text(None),
Screen::WorldOptions => self.world_options_text(Some(text)),
Screen::Inventory if self.mod_browser_typing() => {
self.mod_browser_text((text != "\\b").then_some(text), text == "\\b")
}
Screen::Inventory if self.recipe_search_focused() => {
self.recipe_text(Some(text), false)
}
// The anvil's rename box.
Screen::Inventory
if self
.station
.as_ref()
.is_some_and(|s| s.kind == super::stations::Kind::Anvil) =>
{
self.station_text(Some(text), false)
}
Screen::Creative if self.creative_screen.tab().search => {
// As typed: replaces a selected query, keeps the length limit.
self.creative_search_edit(Some(text))
}
_ => {
return Err(
"type needs the chat screen (key t tap), a sign or book, the language search, the creative search, the anvil or World Options".into(),
);
}
}
// ScreenEvent.CharacterTyped.Pre: the open screen hears of
// each character before a text box takes it, as the window's
// key event fires it. A cancelled character is dropped, so
// `type` types the survivors only.
// `\b` is the socket's backspace sentinel, not two characters.
let typed = if text == "\\b" {
text.to_owned()
} else {
let Some(typed) = self.screen_text_typed(text) else {
return ok();
};
typed
};
self.type_into_screen(&typed)?;
ok()
}
"options" => {
@@ -895,6 +942,15 @@ impl Game<'_> {
);
ok()
}
// A splitscreen player leaves splitscreen; only P1's game ends the process.
"quit" if self.options.split_guest => {
if self.remote.is_some() {
self.leave_server(None)?;
} else if let Some(title) = self.title.as_mut() {
title.escape();
}
ok()
}
"quit" => {
self.begin_closing_save(true)?;
ok()
+195 -36
View File
@@ -89,6 +89,12 @@ pub(super) struct ProjWorld<'a, 'w> {
pub targets: Vec<(Who, Aabb)>,
}
impl ProjectileWorld for ProjWorld<'_, '_> {
fn block_contacts(&self, entity: i32, kind: &str, from: Vec3, to: Vec3, bounds: Aabb) {
horizon_client::block_contact::swept_inside(entity, kind, from, to, bounds, |p| {
let state = self.w.world.block(p.x, p.y, p.z);
(state, &self.w.facts.get(state).name)
});
}
fn clip(&self, from: Vec3, to: Vec3) -> Option<(Vec3, [i32; 3], [i32; 3])> {
let mut scene = SceneCollisions {
world: self.w.world,
@@ -1060,7 +1066,14 @@ impl Game<'_> {
bindings.retain(|id, _| live.contains(id));
host.mob_players = bindings;
}
self.mobs.append(&mut mobs);
// The mobs added during the tick (into the emptied `self.mobs`) come
// first, as `append` put them; with none, the ticked list goes back
// as it is instead of being copied into a new one, every mob a tick.
if self.mobs.is_empty() {
self.mobs = mobs;
} else {
self.mobs.append(&mut mobs);
}
self.died = died;
// ... and entity event 60 goes out as it is: the poof.
for (id, at, width, height) in gone {
@@ -1201,7 +1214,16 @@ impl Game<'_> {
self.villager_murdered(m, slain_by_player, killer.flatten());
let pos = [m.position.x, m.position.y + 0.5, m.position.z];
let killer_entity = self.killer_loot_entity(killer.flatten());
self.drop_death_loot(&mut dying, by_player, killer_entity, consumed, pos);
// LAST_DAMAGE_PLAYER only while the player-hurt memory lasts
// (LivingEntity.dropFromLootTable's playerKilled): a mob that dies to
// the environment long after a player's hit names no player.
let player = killer
.filter(|_| m.ai.last_hurt_by_player > 0)
.and_then(|conn| self.killer_uuid(conn));
if let Some(player) = player.filter(|_| m.extra.leash.is_some()) {
self.lead_killers.insert(m.id, (player, self.dim));
}
self.drop_death_loot(&mut dying, player, by_player, killer_entity, consumed, pos);
self.create_wither_rose(m);
if m.species() == Species::CopperGolem {
// CopperGolem.dropEquipment: the preserved equipment too.
@@ -1318,9 +1340,26 @@ impl Game<'_> {
}
own(self)
}
/// The UUID of the player `last_hurt_by_player` keeps: the guest on
/// connection `conn` (None once they left), else the player in place
/// (None on a server, which has none of its own).
fn killer_uuid(&mut self, conn: Option<u32>) -> Option<u128> {
match conn {
// During that guest's own turn, the hub is out of `lan`.
Some(conn) if self.lan_acting != Some(conn) => {
Some(self.lan.as_ref()?.hub.guest(conn)?.uuid)
}
Some(_) => Some(self.native_acting_id()),
None if self.lan_acting.is_none() && self.player.is_some() => {
Some(self.native_acting_id())
}
None => None,
}
}
fn drop_death_loot(
&mut self,
m: &mut Mob,
player_uuid: Option<u128>,
by_player: bool,
killer: horizon_world::loot::LootEntity,
consumed: bool,
@@ -1403,22 +1442,49 @@ impl Game<'_> {
}
}
let raider = horizon_client::mobs::raider::is_raider(m.species());
// The item drops come before the carried block and the orbs, so the
// mods get them all at once and they still spawn in that order.
let mut stacks = Vec::new();
let mut rest = Vec::new();
for d in items {
match d {
drops::Drop::Item(stack) => {
let item = if raider && stack.item == horizon_client::mobs::raider::BANNER {
drops::Drop::Item(stack) => stacks.push(
if raider && stack.item == horizon_client::mobs::raider::BANNER {
horizon_client::mobs::raider::ominous_banner()
} else {
from_loot(&stack)
};
// Every death drop is its own ItemEntity, and the drop
// entity's own random (not the level's) gives it the
// constructor's horizontal push (its motion otherwise
// stays [0, 0.2, 0] and the drops land stacked at the
// mob's centre, without disturbing the level random that
// the orbs' merge ids and block ticks read next).
self.spawn_item(item, pos, dispenser::new_item_motion(), 10);
}
},
),
drops::Drop::Stack(stack) => stacks.push(stack),
other => rest.push(other),
}
}
let kind = super::native_hooks::mob_kind(m);
let table = drops::death_loot_table(m, death.mob_drops).filter(|t| loot.has_table(t));
let context = super::native_hooks::NativeLootDrops {
table: table.as_deref().unwrap_or_default(),
dimension: self.dim.id(),
origin: [m.position.x, m.position.y, m.position.z],
block: None,
entity: Some((m.id, &kind)),
player: player_uuid,
tool: None,
explosion: None,
used: false,
};
self.native_loot_drops(&context, &mut stacks);
stacks.retain(|stack| stack.count > 0 && &*stack.item != "minecraft:air");
for item in stacks {
// Every death drop is its own ItemEntity, and the drop entity's
// own random (not the level's) gives it the constructor's
// horizontal push (its motion otherwise stays [0, 0.2, 0] and
// the drops land stacked at the mob's centre, without disturbing
// the level random that the orbs' merge ids and block ticks read
// next).
self.spawn_item(item, pos, dispenser::new_item_motion(), 10);
}
for d in rest {
match d {
drops::Drop::CarriedBlock(state) => {
// Enderman.dropCustomDeathLoot: spawnAtLocation for each
// of the carried block's silk-touch drops.
@@ -1434,7 +1500,7 @@ impl Game<'_> {
}
}
drops::Drop::Orb(value) if !consumed => self.spawn_orbs(pos, value as u32),
drops::Drop::Orb(_) => {}
drops::Drop::Item(_) | drops::Drop::Stack(_) | drops::Drop::Orb(_) => {}
}
}
}
@@ -1599,6 +1665,15 @@ impl Game<'_> {
self.next_entity += 1;
p.id = self.next_entity;
}
// Projectile.setOwner: the acting player's or the mob's UUID, which
// its save keeps even once the owner is gone.
if p.owner_uuid.is_none() {
p.owner_uuid = match p.owner {
Some(Who::Player) => Some(self.acting_player_uuid()),
Some(Who::Mob(id)) => Some(self.uuid_for(id)),
None => None,
};
}
self.creatures.projectiles.push(p);
}
pub(super) fn handle_mob_events(&mut self, events: Vec<MobEvent>) -> Result<()> {
@@ -2081,6 +2156,27 @@ impl Game<'_> {
// `Level.setBlockAndUpdate`: neighbour and shape updates, so a crop
// above trampled farmland breaks and drops.
MobEvent::SetBlock { pos, state } => self.level_set(pos, state)?,
MobEvent::CropRaided {
pos,
state,
old,
mob,
} => {
self.level_set_with_flags(
pos,
state,
horizon_world::level::flags::UPDATE_CLIENTS,
)?;
if let Some(old) = old {
self.post_block_game_event(
"minecraft:block_change",
pos,
Some(Who::Mob(mob)),
None,
);
self.level_event_at(2001, pos, old.0 as i32);
}
}
MobEvent::CrackTurtleEgg { pos } => {
let state = self.world.block(pos[0], pos[1], pos[2]);
let eggs = self
@@ -2218,13 +2314,14 @@ impl Game<'_> {
self.destroy_particles(pos, old);
}
if drop {
let drops = world_items!(self).block_drops_in(
let mut drops = world_items!(self).block_drops_in(
self.pack.assets.registry,
old,
None,
&mut self.world.random,
Some((&mut self.rand_sequences, self.level.seed)),
);
self.native_block_drops(pos, old, None, None, None, &mut drops);
for d in drops {
self.pop_resource(pos, d);
}
@@ -2587,7 +2684,7 @@ impl Game<'_> {
&mut self,
feet: [f64; 3],
conn: Option<u32>,
bindings: &std::collections::HashMap<u32, u32>,
bindings: &horizon_client::fast_hash::FxHashMap<u32, u32>,
) {
// A client of someone else's world has the server's mobs only to show.
if self.remote.is_some() || !self.ui.rules.bool("forgive_dead_players") {
@@ -2735,6 +2832,15 @@ impl Game<'_> {
if p.species() == Species::Fox {
child.extra.main_hand = None;
}
// Fox.FoxBreedGoal.breed: the baby trusts the players who fed its
// parents into love.
if p.species() == Species::Fox {
horizon_client::mobs::fox::trust_breeders(
child,
p.extra.love_cause,
q.as_ref().and_then(|q| q.extra.love_cause),
);
}
// Goat.getBreedOffspring: GoatAi.initMemories, then the baby
// screams if the parent a coin toss picks does, or on a 2% roll.
if p.species() == Species::Goat {
@@ -2966,6 +3072,7 @@ impl Game<'_> {
let rocket = std::mem::take(&mut self.combat.rocket_hit);
// ServerPlayer.canHarmPlayer refused another player's projectile.
if self.creative || self.combat.refuse_player_hit {
self.combat.forget_noted(false);
return false;
}
// A projectile entity dealt it: `#is_projectile`, or a firework
@@ -2999,7 +3106,6 @@ impl Game<'_> {
}),
};
// The causing entity for the death message.
let marks = self.pending_marks();
let owner = self.combat.projectile_owner.take();
if let Some(a) = attacker.or(owner) {
self.note_attacker(damage_type, a);
@@ -3067,9 +3173,7 @@ impl Game<'_> {
// LivingEntity.hurtServer: ENTITY_HURT_PLAYER, unless it returned
// early (invulnerable, or inside the hurt cooldown).
// A hit a native mod canceled records no damage (hurtServer never ran).
if outcome.canceled {
self.drop_pending_since(marks);
}
self.combat.forget_noted(outcome.canceled);
if outcome.hurt || outcome.sound {
let source = attacker
.or(owner)
@@ -3152,14 +3256,21 @@ impl Game<'_> {
self.give_player_effect("minecraft:wither", 40, 0);
}
pub(super) fn hurt_player_by(&mut self, damage_type: &str, amount: f32) {
if self.creative {
return;
}
if let Some(player) = self.player.as_mut()
let mut canceled = false;
if !self.creative
&& let Some(player) = self.player.as_mut()
&& let Some(id) = self.motion.health.damage.id(damage_type)
&& let Ok(o) = player.hurt_blocking(
&self.motion.health,
self.world.health_rules,
id,
amount,
None,
)
{
let _ = player.hurt(&self.motion.health, self.world.health_rules, id, amount);
canceled = o.canceled;
}
self.combat.forget_noted(canceled);
}
/// `Player.aiStep` touching arrows in its box grown by (1, 0.5, 1):
/// `AbstractArrow.playerTouch`, then `Player.take`'s pickup sound at the
@@ -3411,9 +3522,10 @@ impl Game<'_> {
} = hit
&& let Some(j) = wind_charge_hit(id)
{
let (owner, guest) = (p.owner, p.guest);
let (owner, owner_uuid, guest) = (p.owner, p.owner_uuid, p.guest);
let look = self.owner_look(owner, guest);
super::deflect::aim_deflect(&mut projectiles[j], look, owner, guest);
let wind = &mut projectiles[j];
super::deflect::aim_deflect(wind, look, owner, owner_uuid, guest);
}
}
let mut idx = 0;
@@ -4783,6 +4895,9 @@ impl Game<'_> {
let feet = [m.position.x, m.position.y, m.position.z];
let mut handled = true;
let mut drops: Vec<(String, u32)> = Vec::new();
// The shearing table rolled, for the mods (none for a mooshroom).
let mut drops_table = String::new();
let mob = (m.id, super::native_hooks::mob_kind(m));
// `Sheep.shear`'s drops add a spread on top of the ItemEntity's own
// motion; `MushroomCow.shear`'s do not.
let mut wool = false;
@@ -4872,8 +4987,9 @@ impl Game<'_> {
Species::Sheep if item == "minecraft:shears" && !m.extra.sheared && !m.is_baby() => {
m.extra.sheared = true;
let color = horizon_client::mobs::render::DYE_NAMES[m.extra.color as usize & 15];
drops_table = format!("minecraft:shearing/sheep/{color}");
let stacks = world_items!(self).loot_table(
&format!("minecraft:shearing/sheep/{color}"),
&drops_table,
false,
None,
&mut self.world.random,
@@ -5258,7 +5374,7 @@ impl Game<'_> {
{
// Animal.mobInteract: breed adults, speed up babies, then
// playEatingSound (silent unless the species overrides it).
match feed_animal(m, &mut self.world.random) {
match feed_animal(m, &mut self.world.random, Some(acting)) {
Some(sound) => {
consume = true;
eat = sound;
@@ -5312,6 +5428,7 @@ impl Game<'_> {
spread,
speed: 0.2,
count: count as i32,
randomization: 0,
});
}
// PlayerInteractTrigger: the item used on the mob, once the interaction took.
@@ -5373,8 +5490,22 @@ impl Game<'_> {
self.trigger_item_entity("minecraft:player_sheared_equipment", &stack, &target);
self.spawn_item(stack, [center[0], center[1] + 0.5, center[2]], [0.0; 3], 10);
}
for (item, count) in drops {
for _ in 0..count {
let mut drops: Vec<ItemStack> = drops
.into_iter()
.map(|(item, count)| ItemStack::new(&item, count))
.collect();
if !drops.is_empty() {
self.native_use_drops(
&drops_table,
feet,
None,
Some((mob.0, &mob.1)),
held.as_ref(),
&mut drops,
);
}
for stack in drops {
for _ in 0..stack.count {
// `spawnAtLocation(stack, 1.0)`: an ItemEntity a block above
// the feet that starts with the constructor's own motion, and
// `setDefaultPickUpDelay`. Sheep.shear adds its spread on top;
@@ -5392,10 +5523,8 @@ impl Game<'_> {
}
self.spawn_item(
ItemStack {
item: item.as_str().into(),
count: 1,
damage: 0,
extra: None,
..stack.clone()
},
[feet[0], feet[1] + 1.0, feet[2]],
v,
@@ -5427,10 +5556,21 @@ impl Game<'_> {
_ => return false,
};
let at = m.position;
let mob = (m.id, super::native_hooks::mob_kind(m));
let tool = self.held().cloned();
self.audio.play(sound, Some([at.x, at.y, at.z]), 1.0, 1.0);
self.mobs[index].extra.sheared = true;
for stack in self.sequence_loot(table, table, tool.as_ref()) {
let mut stacks = self.sequence_loot(table, table, tool.as_ref());
let origin = [at.x, at.y, at.z];
self.native_use_drops(
table,
origin,
None,
Some((mob.0, &mob.1)),
tool.as_ref(),
&mut stacks,
);
for stack in stacks {
self.spawn_at_location(at, height, stack);
}
let ctx = self.who_context(Some(horizon_client::mobs::Who::Player), None);
@@ -5453,7 +5593,17 @@ impl Game<'_> {
let feet = [at.x, at.y, at.z];
let tool = self.held().cloned();
let table = "minecraft:brush/armadillo";
for d in self.sequence_loot(table, table, tool.as_ref()) {
let mut stacks = self.sequence_loot(table, table, tool.as_ref());
let kind = super::native_hooks::mob_kind(&self.mobs[index]);
self.native_use_drops(
table,
feet,
None,
Some((id, &kind)),
tool.as_ref(),
&mut stacks,
);
for d in stacks {
self.spawn_at_location(at, 0.0, d);
}
self.audio
@@ -5577,6 +5727,15 @@ impl Game<'_> {
if let Some(child) = self.mobs.iter_mut().find(|m| m.id == id) {
horizon_client::mobs::finalize::spawn_egg_offspring(child, species, variant, color);
}
// `Mob.checkAndHandleImportantInteractions` then calls
// `onOffspringSpawnedFromEgg`: `Fox`'s makes the baby trust the player
// who used the egg (a fox egg used on a block trusts nobody).
if species == Species::Fox {
let spawner = self.acting_player_uuid();
if let Some(child) = self.mobs.iter_mut().find(|m| m.id == id) {
horizon_client::mobs::fox::add_trusted_entity(child, spawner);
}
}
self.trigger_item_entity("minecraft:player_interacted_with_entity", &stack, &target);
self.consume_held();
true
+2 -4
View File
@@ -89,10 +89,8 @@ impl Game<'_> {
}
if stack.data().is_some_and(|d| !d.charged.is_empty()) {
self.shoot_crossbow();
} else if (self.projectile_slot(CROSSBOW).is_some() || self.creative)
&& self.using.is_none()
{
self.using = Some((self.held_slot(), 0));
} else if self.projectile_slot(CROSSBOW).is_some() || self.creative {
self.start_using_item();
}
true
}
+7 -2
View File
@@ -976,8 +976,13 @@ impl Game<'_> {
(_, true) => ((0.25, 0.9875), 0.9875),
_ => ((0.5, 1.975), 1.7775),
};
let p = a.position;
g.hitbox([p.x, p.y, p.z], size, eye, true, (0.0, a.yaw));
g.hitbox(
at(a.previous, a.position),
size,
eye,
true,
(0.0, a.render_yaw(alpha)),
);
}
for b in &self.boats {
let h = super::boats::HEIGHT as f32;
+5 -1
View File
@@ -1583,7 +1583,11 @@ impl Game<'_> {
_ => {
// The game's dispatcher, captured for the console.
self.chat_capture = Some(Vec::new());
self.as_console(|g| g.run_command(line));
// A command a native mod registered goes to the mods first,
// looked up by the same first word the match above read.
if !self.perform_native_console_command(line.trim()) {
self.as_console(|g| g.run_command(line));
}
for text in self.chat_capture.take().unwrap_or_default() {
self.console_reply(text);
}
+9 -8
View File
@@ -34,14 +34,14 @@ pub(super) fn aim_deflect(
p: &mut Projectile,
look: Option<Vec3>,
owner: Option<Who>,
owner_uuid: Option<[i32; 4]>,
guest: Option<u32>,
) {
if let Some(look) = look {
p.velocity = look;
p.needs_sync = true;
}
p.owner = owner;
p.guest = guest;
(p.owner, p.owner_uuid, p.guest) = (owner, owner_uuid, guest);
p.acceleration = 0.1;
}
@@ -196,14 +196,14 @@ impl Game<'_> {
/// `Projectile.deflect(AIM_DEFLECT, player, player, true)`.
pub(super) fn deflect(&mut self, i: usize) {
let dir = view_vector(self.yaw, self.pitch);
let guest = self.lan_acting;
let (guest, owner_uuid) = (self.lan_acting, Some(self.acting_player_uuid()));
let p = &mut self.creatures.projectiles[i];
p.velocity = Vec3::new(dir[0], dir[1], dir[2]);
p.yaw = dir[0].atan2(dir[2]).to_degrees() as f32;
p.pitch = dir[1].atan2(dir[0].hypot(dir[2])).to_degrees() as f32;
p.owner = Some(Who::Player);
// On a server, the guest who struck it (their projectile now).
p.guest = guest;
(p.guest, p.owner_uuid) = (guest, owner_uuid);
// Not into the one who turned it on its way out.
p.left_owner = false;
// `AbstractHurtingProjectile.onDeflection` by an attack, and the
@@ -274,9 +274,9 @@ mod tests {
fn a_redirected_wind_charge_flies_along_the_new_owners_look() {
let mut p = wind_charge();
let look = Vec3::new(0.0, -1.0, 0.0);
aim_deflect(&mut p, Some(look), Some(Who::Player), Some(3));
aim_deflect(&mut p, Some(look), Some(Who::Player), Some([4; 4]), Some(3));
assert_eq!(p.velocity, look);
assert_eq!(p.owner, Some(Who::Player));
assert_eq!((p.owner, p.owner_uuid), (Some(Who::Player), Some([4; 4])));
assert_eq!(p.guest, Some(3));
assert_eq!(p.acceleration, 0.1);
assert!(p.needs_sync);
@@ -285,9 +285,10 @@ mod tests {
#[test]
fn with_no_owner_here_it_keeps_its_movement_but_changes_hands() {
let mut p = wind_charge();
aim_deflect(&mut p, None, None, None);
p.owner_uuid = Some([7; 4]);
aim_deflect(&mut p, None, None, None, None);
assert_eq!(p.velocity, Vec3::new(0.0, 1.5, 0.0));
assert_eq!(p.owner, None);
assert_eq!((p.owner, p.owner_uuid), (None, None));
assert_eq!(p.acceleration, 0.1);
assert!(!p.needs_sync);
}
@@ -5,6 +5,10 @@ use horizon_client::discord_presence::{self as presence, Dimension, Play, Status
impl Game<'_> {
/// Each frame: only a change reaches the presence thread.
pub(super) fn update_discord_presence(&self) {
// One Discord status per computer: P1's, not a splitscreen player's.
if self.options.split_guest {
return;
}
presence::set(self.discord_status());
}
fn discord_status(&self) -> Status {
+12 -18
View File
@@ -67,6 +67,15 @@ fn y_rot(d: Direction) -> f32 {
_ => 270.0,
}
}
/// `DirectionalPlaceContext.getRotation`: `get2DDataValue() * 90`, so a
/// vertical direction (`data2d = -1`) gives -90 where `toYRot` gives 270: the
/// same horizontal facing, but the raw value a mod's placement hook reads.
fn place_rotation(d: Direction) -> f32 {
match d {
Direction::Up | Direction::Down => -90.0,
_ => y_rot(d),
}
}
fn block_box(p: Pos) -> Aabb {
Aabb::new(
Vec3::new(f64::from(p.0), f64::from(p.1), f64::from(p.2)),
@@ -507,7 +516,7 @@ impl Game<'_> {
/// Drop one item as DefaultDispenseItemBehavior does. For a dispenser hook:
/// return this remainder in its answer with `CLICK`; the caller plays the
/// answer's event and smoke once. A client, another dimension or an unloaded
/// chunk keeps the input stack unchanged. API @API:dispenser_behaviour@.
/// chunk keeps the input stack unchanged. API 67.
#[allow(dead_code)]
pub(super) fn native_dispense_default(
&mut self,
@@ -1252,22 +1261,7 @@ impl Game<'_> {
BucketContent::Fluid(FluidKind::Water) | BucketContent::MobWater
)
{
// The water evaporates in a cloud of smoke.
let c = Self::center(target);
for _ in 0..8 {
let r = self.particles.random();
let p = [
f64::from(target.0) + (r.next_double() * 2.0 - 1.0),
f64::from(target.1) + (r.next_double() * 2.0 - 1.0),
f64::from(target.2) + (r.next_double() * 2.0 - 1.0),
];
let _ = c;
self.particles.add(
&ParticleOptions::simple(ParticleType::LargeSmoke),
p,
[0.0; 3],
);
}
self.evaporation_smoke([target.0, target.1, target.2]);
}
if let Some(kind) = mob {
// MobBucketItem.checkExtraContent: the mob (from the bucket, so
@@ -1581,7 +1575,7 @@ impl Game<'_> {
f64::from(front.2) + 0.5,
),
inside: false,
yaw: y_rot(facing),
yaw: place_rotation(facing),
pitch: 0.0,
sneaking: false,
item: stack.item.to_string(),
+6 -2
View File
@@ -2436,13 +2436,17 @@ impl Game<'_> {
}
let (health, rules) = (&self.motion.health, self.world.health_rules);
let id = health.damage.id("minecraft:mob_attack");
let mut canceled = false;
if let Some(p) = self.player.as_mut() {
push_seen_with_hurt(p, push, |p| {
if let Some(id) = id.filter(|_| hurts) {
let _ = p.hurt(health, rules, id, 5.0);
if let Some(id) = id.filter(|_| hurts)
&& let Ok(o) = p.hurt_blocking(health, rules, id, 5.0, None)
{
canceled = o.canceled;
}
});
}
self.combat.forget_noted(canceled);
}
/// Damage from the dragon, which is its causing entity
/// (`DamageSources.mobAttack(dragon)`, `indirectMagic(cloud, dragon)`): the
@@ -175,6 +175,7 @@ impl Game<'_> {
};
let Some(m) = self.mobs.get(mob) else { return };
let kind = m.kind.id.clone();
let victim = m.id;
let ctx = EffectContext {
this: Some(&kind),
attacker: Some("minecraft:player"),
@@ -187,6 +188,7 @@ impl Game<'_> {
// ignite (fire aspect) and apply_mob_effect (bane of arthropods).
let mut ignite = 0.0f32;
let mut mob_effects = Vec::new();
let mut hook_calls: Vec<(String, String, Value, u32)> = Vec::new();
for (id, level) in held.enchantments() {
let Some(def) = world_stations!(self).enchantments.get(id) else {
continue;
@@ -197,14 +199,17 @@ impl Game<'_> {
.flatten()
{
let e = &effect["effect"];
let kind_of = e["type"].as_str();
if effect["enchanted"] != "attacker"
|| effect["affected"] != "victim"
|| !matches!(
kind_of,
Some("minecraft:ignite" | "minecraft:apply_mob_effect")
)
{
if effect["enchanted"] != "attacker" || effect["affected"] != "victim" {
continue;
}
// A bare type name is minecraft's own, as a bare item id is.
let kind_of = match e["type"].as_str() {
Some(k) if k.contains(':') => k.to_owned(),
Some(k) => format!("minecraft:{k}"),
None => continue,
};
let own = !kind_of.starts_with("minecraft:");
if !own && !matches!(&*kind_of, "minecraft:ignite" | "minecraft:apply_mob_effect") {
continue;
}
let mut random = self.world.random.clone();
@@ -218,7 +223,11 @@ impl Game<'_> {
if !ok {
continue;
}
if kind_of == Some("minecraft:ignite") {
// A mod's own kind: its hook answers, after this loop (the
// stations borrow is held here).
if own {
hook_calls.push((id.to_string(), kind_of, e.clone(), *level));
} else if kind_of == "minecraft:ignite" {
ignite =
ignite.max(horizon_client::enchant::level_value(&e["duration"], *level));
} else {
@@ -235,6 +244,9 @@ impl Game<'_> {
for (effect, level) in mob_effects {
self.apply_mob_effect(mob, &effect, level);
}
for (id, kind_of, data, level) in hook_calls {
self.native_enchant_effect(&id, &kind_of, &data, level, victim);
}
self.post_attack_bursts(&held, &kind);
}
/// `EnchantmentHelper.doPostPiercingAttackEffects`: the
+1 -3
View File
@@ -392,9 +392,7 @@ impl Game<'_> {
// EnderEyeItem.use: startUsingItem (getUseDuration 0, so the use
// lasts until the button lets go), then the eye is thrown towards
// the nearest stronghold if there is one (CONSUME without).
if self.using.is_none() {
self.using = Some((self.held_slot(), 0));
}
self.start_using_item();
self.throw_ender_eye();
return Ok(true);
};
@@ -17,6 +17,9 @@ impl Game<'_> {
// Spans tell the pack's passes which entities are hidden; without them, nothing changes.
self.pack_shadow_casters = match (&self.renderer, &self.pack_ids) {
(Some(r), Some(_)) => r.pack_shadow_casters(),
// A native pack's shadow pass has no spans to hide entities with: it only needs
// the first-person model.
(Some(r), None) => (false, r.native_shadow()),
_ => (false, false),
};
let at = camera.to_array().map(|v| v.floor() as i32);
@@ -17,13 +17,14 @@ const WOODS: [&str; 11] = [
"pale_oak",
"test_wood",
];
const CARTS: [&str; 6] = [
const CARTS: [&str; 7] = [
"minecart",
"chest_minecart",
"hopper_minecart",
"furnace_minecart",
"tnt_minecart",
"command_block_minecart",
"spawner_minecart",
];
fn fixture() -> Value {
@@ -178,7 +179,7 @@ fn entity_model_batches_preserve_parts_missing_layers_and_load_failures() {
let mut all = fixture();
fs::write(&file, serde_json::to_vec(&all).unwrap()).unwrap();
assert_equivalent(&root.0);
assert_eq!(optimized(&root.0).len(), 42);
assert_eq!(optimized(&root.0).len(), 43);
all.as_object_mut()
.unwrap()
.remove("minecraft:armor_stand_small#main");
@@ -187,7 +188,7 @@ fn entity_model_batches_preserve_parts_missing_layers_and_load_failures() {
.remove("minecraft:boat/oak#main");
fs::write(&file, serde_json::to_vec(&all).unwrap()).unwrap();
assert_equivalent(&root.0);
assert_eq!(optimized(&root.0).len(), 40);
assert_eq!(optimized(&root.0).len(), 41);
let document = EntityModelDocument::load(&root.0).unwrap();
let mut first = document.model("minecraft:armor_stand#main").unwrap();
first.children.clear();
+3 -1
View File
@@ -434,6 +434,7 @@ impl Game<'_> {
pushed = (!p.health.sync_velocity).then_some(push);
}
}
self.combat.forget_noted(outcome.canceled);
if raised {
self.after_block(outcome, None, false);
}
@@ -941,7 +942,7 @@ impl Game<'_> {
// tables' survives_explosion and explosion_decay draw from their
// random sequence.
let decay = (interaction == BlockInteraction::DestroyWithDecay).then_some(radius);
let drops = world_items!(self).block_drops_exploded(
let mut drops = world_items!(self).block_drops_exploded(
self.pack.assets.registry,
state,
None,
@@ -951,6 +952,7 @@ impl Game<'_> {
decay,
false,
);
self.native_block_drops(pos, state, None, None, Some(radius), &mut drops);
for mut stack in drops {
if &*stack.item == "minecraft:air" || stack.count == 0 {
continue;
@@ -490,6 +490,7 @@ impl Game<'_> {
spread,
speed,
count: count as i32,
randomization: 0,
});
}
/// `FishingHook.tick`.
+2 -2
View File
@@ -156,6 +156,7 @@ impl Game<'_> {
};
let tilt = self.settings.file.double("damageTiltStrength");
glam::Mat4::from_cols_array(&bob_matrix(&state, tilt, self.settings.view_bobbing).m)
* self.native_shake_matrix(partial)
}
/// `AbstractClientPlayer.tick` and `aiStep` (`ClientAvatarState.tick`,
/// `updateBob`), `rideTick`'s `resetBob` and `LocalPlayer.move`'s walked
@@ -183,8 +184,7 @@ impl Game<'_> {
}
/// `SpyglassItem.use`: start scoping.
pub(super) fn use_spyglass(&mut self) {
if self.using.is_none() {
self.using = Some((self.held_slot(), 0));
if self.start_using_item() {
let at = self.eye_position();
self.audio.play("minecraft:item.spyglass.use", at, 1.0, 1.0);
}
+18 -4
View File
@@ -300,19 +300,33 @@ impl Game<'_> {
let s = f.step().map(f64::from);
let c = center(f);
let at = [c[0] + s[0] * 0.15, c[1], c[2] + s[2] * 0.15];
let mut drops = Vec::new();
if with_frame {
let frame = ItemStack {
drops.push(ItemStack {
item: Arc::from(f.kind()),
count: 1,
damage: 0,
extra: None,
};
self.spawn_item(frame, at, [0.0; 3], 10);
});
}
if let Some(item) = f.item.clone()
&& self.world.random.next_float() < f.drop_chance
{
self.spawn_item(item, at, [0.0; 3], 10);
drops.push(item);
}
if by_player && !drops.is_empty() {
let tool = self.held().cloned();
self.native_use_drops(
"",
c,
None,
Some((f.id, f.kind())),
tool.as_ref(),
&mut drops,
);
}
for stack in drops {
self.spawn_item(stack, at, [0.0; 3], 10);
}
}
/// `ServerExplosion.hurtEntities` for item frames: a frame within the blast
+7
View File
@@ -94,6 +94,13 @@ impl Game<'_> {
pub(super) fn ime_commit(&mut self, text: &str) {
preedit::set_preedit(None);
let text: String = text.chars().filter(|c| !c.is_control()).collect();
// ScreenEvent.CharacterTyped.Pre: the input method's committed text
// reaches a text box by characters, as a key's own text does, so the
// open screen hears of each of them first and a cancelled one never
// reaches the box.
let Some(text) = self.screen_text_typed(&text) else {
return;
};
if text.is_empty() || !self.text_field_focused() {
return;
}
+88 -6
View File
@@ -60,6 +60,9 @@ struct Flags {
entity_distance: AtomicU32,
/// `MinecraftServer.isCurrentlySaving`: the server is autosaving.
saving: AtomicBool,
/// Splitscreen players joined through memory (offline uuids): let in although the world is
/// not open to the LAN, and the game no longer pauses while they play.
split_players: Mutex<Vec<u128>>,
/// The generators of the levels the server has run, by dimension: what
/// F3's chunk generation entry asks `ServerChunkCache.getGenerator`.
terrain: Mutex<
@@ -120,7 +123,42 @@ impl Integrated {
}
/// `IntegratedServer.tickServer` skips the world while the game is paused.
pub fn set_paused(&self, paused: bool) {
self.flags.paused.store(paused, Ordering::Relaxed);
let split = !self.flags.split_players.lock().unwrap().is_empty();
self.flags.paused.store(paused && !split, Ordering::Relaxed);
}
/// Splitscreen: `name` left; the game pauses again once every such player has. The owner's
/// permissions it was given go with it (a LAN player could otherwise join under that name).
pub fn leave_split(&self, name: &str) {
let uuid = horizon_protocol::crypto::offline_uuid(name);
self.flags
.split_players
.lock()
.unwrap()
.retain(|&u| u != uuid);
self.call(move |g| {
if let Some(host) = g.lan.as_mut() {
host.hub.ops.remove(&uuid);
}
});
}
/// Splitscreen: a connection through memory for another player on this machine, who gets
/// the owner's permissions. `None` once the server has stopped.
pub fn join_split(&self, name: &str) -> Option<horizon_protocol::connection::Pipe> {
let uuid = horizon_protocol::crypto::offline_uuid(name);
self.flags.split_players.lock().unwrap().push(uuid);
let pipe = self
.call(move |g| {
let host = g.lan.as_mut()?;
let owner = host.owner.as_ref()?.uuid;
let level = host.hub.ops.get(&owner).copied().unwrap_or(0);
host.hub.ops.insert(uuid, level);
host.hub.lan.connect_local().ok()
})
.flatten();
if pipe.is_none() {
self.leave_split(name);
}
pipe
}
/// The window's render and simulation distances and its Entity Distance
/// option, for the server to follow.
@@ -679,14 +717,25 @@ impl Game<'_> {
};
let owner = launch.owner;
let published = flags.clone();
let local_players = flags.clone();
let net = NetConfig {
// No network until it is opened to the LAN; the memory connection
// is not compressed (`Connection.isMemoryConnection`).
listen: false,
online: horizon_client::lan::OnlineMode::lan(),
gate: Some(Arc::new(move |_, uuid, _| {
(uuid != owner && published.published.load(Ordering::Relaxed) == 0)
.then(|| translatable("multiplayer.disconnect.not_whitelisted", &[]))
// Registered local seats share this computer; the vanilla LAN capacity still
// applies to other connections when the world is published.
bypasses_limit: Some(Arc::new(move |uuid| {
local_players.split_players.lock().unwrap().contains(&uuid)
})),
gate: Some(Arc::new(move |address, uuid, _| {
refused(
uuid == owner,
published.published.load(Ordering::Relaxed) != 0,
published.split_players.lock().unwrap().contains(&uuid),
address == horizon_client::lan::conn::MEMORY_ADDRESS,
)
.then(|| translatable("multiplayer.disconnect.not_whitelisted", &[]))
})),
levels: self.level_shapes,
owner_profile: launch.owner_profile.clone(),
@@ -1033,12 +1082,17 @@ impl Game<'_> {
/// `IntegratedServer.setWorldAllowCommands`: the owner is an operator
/// while the world allows commands; everyone's level is sent again.
pub(super) fn owner_allow_commands(&mut self, allow: bool) {
let Some(owner) = self.serving.as_ref().map(|s| s.owner) else {
let Some((owner, flags)) = self.serving.as_ref().map(|s| (s.owner, s.flags.clone())) else {
return;
};
let published = self.published_port().is_some();
if let Some(host) = self.lan.as_mut() {
host.hub.ops.insert(owner, if allow { 4 } else { 0 });
let level = if allow { 4 } else { 0 };
host.hub.ops.insert(owner, level);
// Splitscreen seats have the owner's permissions (`join_split`): they follow it.
for &seat in flags.split_players.lock().unwrap().iter() {
host.hub.ops.insert(seat, level);
}
if published {
host.hub.config.guest_commands = allow && host.settings.guest_commands;
}
@@ -1144,10 +1198,38 @@ fn server_game(
Game::new(res, pack, gui, options, settings, audio, None)
}
/// Who the integrated server turns away: anyone but the owner until it is opened to the LAN,
/// except a splitscreen seat. A seat's name is let in only through memory, so a LAN player
/// logging in under it never gets its place or the owner's permissions it holds.
fn refused(owner: bool, published: bool, split_seat: bool, memory: bool) -> bool {
if split_seat {
!memory
} else {
!owner && !published
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_splitscreen_seat_is_let_in_only_through_memory() {
// (owner, published, seat, memory)
assert!(!refused(false, false, true, true), "a local seat joins");
assert!(
refused(false, true, true, false),
"a LAN player under a seat's name"
);
assert!(refused(false, false, true, false));
assert!(
!refused(false, true, false, false),
"anyone once open to the LAN"
);
assert!(refused(false, false, false, false), "nobody else before");
assert!(!refused(true, false, false, true), "the owner");
}
#[test]
fn shutdown_request_does_not_join_a_server_still_saving() {
let flags = Arc::new(Flags::default());
+19 -5
View File
@@ -268,10 +268,13 @@ impl Game<'_> {
&& self.stack_in(slot) == Some(stack)
{
let view = item_render::stack_view(stack);
let duration = use_duration(&self.pack.item_renderer.models.components(&view));
let mut duration = use_duration(&self.pack.item_renderer.models.components(&view));
self.native_use_duration(stack, &mut duration);
owner.using_this_item = true;
owner.use_duration = duration;
owner.use_item_remaining_ticks = duration - ticks as i32;
// getUseItemRemainingTicks: the duration the use started with,
// as LivingEntityUseItemEvent.Start left it (`start_using_item`).
owner.use_item_remaining_ticks = self.use_duration - ticks as i32;
owner.crossbow_charge_duration = Self::charge_ticks(stack).max(1) as i32;
}
owner.fishing_cast_with_this_item = stack
@@ -407,22 +410,33 @@ impl Game<'_> {
p.y + (q.y - p.y) * alpha,
p.z + (q.z - p.z) * alpha,
];
(item, at, alpha as f32)
(item, at, alpha as f32, true)
});
// ItemPickupParticle: the render state extracted at partial tick 1.
let taken = self.pickups.live.iter().filter_map(|p| match &p.taken {
super::pickups::Taken::Item(item) => {
Some((&**item, super::pickups::position(p, alpha), 1.0))
Some((&**item, super::pickups::position(p, alpha), 1.0, false))
}
_ => None,
});
for (item, at, partial) in live.chain(taken) {
for (item, at, partial, entity) in live.chain(taken) {
let entity_start = out.len();
let q = item.position;
let pos = glam::DVec3::from_array(at) - camera;
if pos.length_squared() > 64.0 * 64.0 {
continue;
}
// EntityRenderer.shouldRender: the item's 0.25 box, grown by 0.5,
// in the view frustum. Checked before its model is resolved.
if entity && let Some(view) = &self.view_frustum {
let feet = (glam::DVec3::new(q.x, q.y, q.z) - camera).as_vec3();
let half = glam::Vec3::new(0.125 + 0.5, 0.5, 0.125 + 0.5);
let lo = feet - half;
let hi = feet + glam::Vec3::new(half.x, 0.25 + 0.5, half.z);
if !view.intersects(lo, hi) {
continue;
}
}
// `extractItemGroupRenderState`: GROUND, seeded by the entity id.
let owner = owner_at([q.x, q.y, q.z], 0.0, "minecraft:item");
let state = self.resolve_item(
+218
View File
@@ -0,0 +1,218 @@
//! `LivingEntity.startUsingItem` and the use over time of items vanilla's
//! `Item` subclasses don't know: what `Item.getUseDuration` answers, a
//! native mod's own item counted down to its `finishUsingItem`, and the hand
//! pose of a `getUseAnimation` (`native_use_duration`, `native_use_animation`,
//! `native_living_entity_use_item_start`, `native_living_entity_use_item_finish`).
use super::Game;
use horizon_client::{items::ItemStack, spear::Kinetic};
use horizon_world::consumable::UseAnimation;
impl Game<'_> {
/// `LivingEntity.startUsingItem` for the acting player's hand: nothing
/// when a use runs already or the hand is empty. The use lasts
/// `getUseDuration` ticks, which mods may change or refuse
/// (`LivingEntityUseItemEvent.Start`), on both sides as in vanilla. True
/// when the use started.
pub(super) fn start_using_item(&mut self) -> bool {
let Some(stack) = self.held().cloned().filter(|_| self.using.is_none()) else {
return false;
};
let mut duration = self.use_duration(&stack);
let mut canceled = false;
let hand = self.hand;
self.native_living_entity_use_item_start(&stack, None, hand, &mut duration, &mut canceled);
if canceled {
return false;
}
self.using = Some((self.held_slot(), 0));
self.use_duration = duration;
true
}
/// `ItemStack.getUseDuration`: a consumable's time, 72000 for what is
/// held until let go, the fixed times of brushes, bundles, spyglasses and
/// goat horns, 0 for the rest, then a mod's answer.
pub(super) fn use_duration(&self, stack: &ItemStack) -> i32 {
let info = self.items().get(&stack.item);
let mut duration = match &*stack.item {
"minecraft:bow" | "minecraft:crossbow" | "minecraft:trident" => 72_000,
"minecraft:spyglass" => 1200,
"minecraft:brush" => 200,
_ if info.is_some_and(|i| i.bundle) => 200,
_ if let Some(i) = self.res.items().instrument(stack) => i.use_ticks(),
_ => match info {
Some(i) if let Some(c) = &i.consumable => c.ticks(),
Some(i) if i.blocks_attacks.is_some() => 72_000,
_ if Kinetic::of(&stack.item).is_some() => 72_000,
_ => 0,
},
};
self.native_use_duration(stack, &mut duration);
duration
}
/// Whether `stack` is a mod's item that only a mod uses over time: no
/// `consumable`, `blocks_attacks` or kinetic weapon of vanilla's own, and
/// a `native_use_duration` above 0. Its use is its own `Item.use`.
pub(super) fn mod_use(&self, stack: &ItemStack) -> bool {
!stack.item.starts_with("minecraft:")
&& self
.items()
.get(&stack.item)
.is_some_and(|i| i.consumable.is_none() && i.blocks_attacks.is_none())
&& Kinetic::of(&stack.item).is_none()
&& self.use_duration(stack) > 0
}
/// A right click with a `mod_use` item: `startUsingItem` (CONSUME).
pub(super) fn start_mod_use(&mut self, stack: &ItemStack) -> bool {
if !self.mod_use(stack) {
return false;
}
self.start_using_item();
true
}
/// `LivingEntity.updateUsingItem` for a `mod_use` item used `ticks` so far:
/// `completeUsingItem` once its duration ran out.
pub(super) fn tick_mod_use(&mut self, slot: usize, ticks: u32, stack: &ItemStack) {
if let Some(ticks) = count_down(ticks, self.use_duration) {
self.using = Some((slot, ticks));
return;
}
self.using = None;
self.send_use_item_complete();
// in ItemStack.finishUsingItem, Item's own gives the stack back.
self.apply_use_cooldown(stack);
self.use_item_finished(stack);
}
/// `LivingEntityUseItemEvent.Finish` after `finishUsingItem` of `stack`
/// (no ticks left): the hand holds what a mod made the result.
pub(super) fn use_item_finished(&mut self, stack: &ItemStack) {
let mut result = self.held().cloned();
let before = result.clone();
let hand = self.hand;
self.native_living_entity_use_item_finish(stack, None, hand, 0, &mut result);
if result != before {
*self.held_mut() = result.filter(|s| s.count > 0);
}
}
/// `Player.canEat(canAlwaysEat)` for `stack`, food that is eaten only
/// when hungry, then a mod's answer.
pub(super) fn can_eat(&self, stack: &ItemStack, can_always_eat: bool) -> bool {
let mut can_eat = can_always_eat
|| self.creative
|| self
.player
.as_ref()
.is_some_and(|p| p.health.food.food < 20);
self.native_can_eat(stack, &mut can_eat);
can_eat
}
}
/// `Consumable.startConsuming`: a time of 0 or less is no use, the item is
/// consumed at once (`onConsume`).
pub(super) fn consumes_at_once(consume_ticks: i32) -> bool {
consume_ticks <= 0
}
/// `updateUsingItem`'s `--useItemRemaining == 0` for a use `ticks` old of
/// `duration` ticks: the ticks used after this one, none on the tick it runs
/// out. A time of 0 or less, or one a mod cut below what was used, never
/// does: the use lasts until let go.
pub(super) fn count_down(ticks: u32, duration: i32) -> Option<u32> {
let ticks = ticks + 1;
(i64::from(duration) - i64::from(ticks) != 0).then_some(ticks)
}
/// The first person pose of a hand using an item of `animation`
/// (`ItemInHandRenderer.renderArmWithItem`'s switch), for an item of no
/// pose of its own.
pub(super) fn use_pose(
animation: UseAnimation,
remaining: f32,
duration: f32,
partial: f32,
) -> horizon_client::first_person::ItemPose {
use horizon_client::first_person::ItemPose;
let ticks_used = duration - (remaining - partial + 1.0);
match animation {
UseAnimation::Eat | UseAnimation::Drink => ItemPose::Eat {
remaining,
duration,
partial,
},
UseAnimation::Bow => ItemPose::Bow { ticks_used },
UseAnimation::Trident => ItemPose::Trident { ticks_used },
UseAnimation::Brush => ItemPose::Brush { remaining, partial },
_ => ItemPose::Using,
}
}
#[cfg(test)]
mod tests {
use super::{consumes_at_once, count_down, use_pose};
use horizon_client::first_person::ItemPose;
use horizon_world::consumable::UseAnimation;
#[test]
fn a_use_runs_out_on_its_last_tick() {
// a 32 tick food: ticks 0 to 30 go on, the 32nd tick finishes it.
assert_eq!(count_down(0, 32), Some(1));
assert_eq!(count_down(30, 32), Some(31));
assert_eq!(count_down(31, 32), None);
// a one tick use finishes on its first tick.
assert_eq!(count_down(0, 1), None);
// no time, or one below what was used, waits for the release.
assert_eq!(count_down(0, 0), Some(1));
assert_eq!(count_down(10, 4), Some(11));
assert_eq!(count_down(5, 72_000), Some(6));
}
#[test]
fn a_started_use_finishes_after_its_duration() {
// start at 0 ticks, count down each tick, finish when it runs out.
for duration in [1, 16, 32, 40] {
let (mut ticks, mut ticked) = (0, 0);
while let Some(next) = count_down(ticks, duration) {
ticks = next;
ticked += 1;
}
assert_eq!(ticked + 1, duration);
}
}
#[test]
fn a_food_of_no_time_is_eaten_at_once() {
assert!(consumes_at_once(0));
assert!(consumes_at_once(-3));
assert!(!consumes_at_once(1));
assert!(!consumes_at_once(32));
}
#[test]
fn a_use_animation_picks_the_hand_pose() {
let pose = |animation| use_pose(animation, 12.0, 32.0, 0.5);
assert!(matches!(
pose(UseAnimation::Drink),
ItemPose::Eat { remaining, duration, partial }
if (remaining, duration, partial) == (12.0, 32.0, 0.5)
));
// ticks used: 32 - (12 - 0.5 + 1).
assert!(
matches!(pose(UseAnimation::Bow), ItemPose::Bow { ticks_used } if ticks_used == 19.5)
);
assert!(matches!(
pose(UseAnimation::Trident),
ItemPose::Trident { ticks_used } if ticks_used == 19.5
));
assert!(matches!(
pose(UseAnimation::Brush),
ItemPose::Brush { remaining, partial } if (remaining, partial) == (12.0, 0.5)
));
for animation in [
UseAnimation::None,
UseAnimation::Block,
UseAnimation::Spyglass,
] {
assert!(matches!(pose(animation), ItemPose::Using));
}
}
}
+44 -36
View File
@@ -171,7 +171,8 @@ pub(super) struct LanHost {
/// see; the players' hurt time by entity id.
mob_state: HashMap<super::Dim, HashMap<u32, (i32, bool, Option<i32>)>>,
/// The guest each mob sees as its player, when not the host (game/lan_mobs.rs).
pub mob_players: HashMap<u32, u32>,
/// Looked up per mob each tick: a fast hasher (never iterated).
pub mob_players: horizon_client::fast_hash::FxHashMap<u32, u32>,
/// An integrated server's owner (game/integrated.rs).
pub owner: Option<Owner>,
/// The players' locator bars, one waypoint manager per level by
@@ -1403,6 +1404,7 @@ impl Game<'_> {
let host_entity = self.next_entity as i32;
let mut hub = Hub::new(lan, sync, encoder, config);
hub.flat_world = self.world_flat;
hub.debug_world = self.world_debug;
let port = hub.lan.port;
self.world.block_log = Some(Vec::new());
self.world.light_log = Some(Default::default());
@@ -1420,7 +1422,7 @@ impl Game<'_> {
play: HashMap::new(),
broken: Vec::new(),
mob_state: HashMap::new(),
mob_players: HashMap::new(),
mob_players: Default::default(),
owner: None,
waypoints: Default::default(),
waypoint_dead: Default::default(),
@@ -1482,6 +1484,25 @@ impl Game<'_> {
/// `PlayerList.removeAll`: every guest leaves in turn, the vehicle it
/// alone rides with it (`PlayerList.remove`), and is saved.
pub(super) fn save_leaving_guests(&mut self, host: &mut LanHost) {
// Every open menu closes first, in its guest's level
// (`ServerPlayer.disconnect` -> `doCloseContainer`), so the cursor,
// the trade and the inputs are back in the file that is saved.
let back = self.dim;
let open: Vec<(u32, super::Dim)> = host
.hub
.guests
.iter()
.filter(|g| g.placed)
.map(|g| (g.conn, super::Dim::from_id(&g.dimension)))
.collect();
for (conn, dim) in open {
if self.enter_level(Some(host), dim).is_ok() {
self.guest_close_all(host, conn);
}
}
if let Err(e) = self.enter_level(Some(host), back) {
log::error!("could not reopen {}: {e}", back.id());
}
for i in 0..host.hub.guests.len() {
let mut g = host.hub.guests.remove(i);
self.take_parent_vehicle(host, &mut g);
@@ -1490,15 +1511,13 @@ impl Game<'_> {
}
}
/// Save a guest when they leave or the world closes (after
/// `take_parent_vehicle`): an integrated server's owner with the world
/// (`save_game`), anyone else in their file.
/// `take_parent_vehicle`, `PlayerList.save`): an integrated server's owner
/// into the level (`/data`) and both its files (`playerdata::save_owner`),
/// anyone else into their own file.
pub(super) fn save_guest(&mut self, host: &LanHost, guest: &horizon_client::lan::guest::Guest) {
if host.owner.as_ref().is_some_and(|o| o.uuid == guest.uuid) && guest.placed {
// The world's own player state rides in the level (`/data`).
playerdata::store(guest, &mut self.level, None);
}
if guest.placed {
// PlayerList.save/remove: every player saves to their own file.
if guest.placed && host.owner.as_ref().is_some_and(|o| o.uuid == guest.uuid) {
playerdata::save_owner(guest, &mut self.level, &self.world_root);
} else if guest.placed {
playerdata::save(guest, &self.world_root);
}
self.save_guest_records(host, guest);
@@ -1815,7 +1834,7 @@ impl Game<'_> {
fn lan_entities(
&self,
host_entity: Option<i32>,
mob_players: &HashMap<u32, u32>,
mob_players: &horizon_client::fast_hash::FxHashMap<u32, u32>,
hub: &mut Hub,
out: &mut States,
) {
@@ -1979,15 +1998,17 @@ impl Game<'_> {
}
}
HostEvent::Left(mut guest) => {
// The container they had open loses an opener.
if let Some(pos) = guest.menu.as_ref().and_then(|m| m.pos)
&& !guest.spectator()
{
// In the level they were in.
// The menu they had open closes, in the level they were in
// (`ServerPlayer.disconnect` -> `doCloseContainer`).
if guest.placed {
let back = self.dim;
let theirs = super::Dim::from_id(&guest.dimension);
if self.enter_level(Some(&mut host), theirs).is_ok() {
self.container_openers_changed(pos, false);
let conn = guest.conn;
let at = host.hub.guests.len();
host.hub.guests.push(*guest);
self.guest_close_all(&mut host, conn);
*guest = host.hub.guests.remove(at);
}
if let Err(e) = self.enter_level(Some(&mut host), back) {
log::error!("could not reopen {}: {e}", back.id());
@@ -2352,28 +2373,15 @@ impl Game<'_> {
let force = host.hub.config.force_game_mode;
let root = self.world_root.clone();
let in_place = host.hub.config.dimension.clone();
let mut owner = host.owner.as_mut();
let owner = host.owner.as_mut();
let Some(g) = host.hub.guest_mut(conn) else {
return;
};
let is_owner = owner.as_ref().is_some_and(|o| o.uuid == g.uuid);
// PlayerDataStorage.load: the player's own file first; the world's
// own player state is the fallback for a first join.
let known = if is_owner && playerdata::load(g, &root) {
true
} else {
let saved = if is_owner {
owner.as_mut().and_then(|o| o.saved.take())
} else {
None
};
match saved {
Some(level) => {
playerdata::apply(g, &level);
level.position.is_some()
}
None => playerdata::load(g, &root),
}
// PlayerList.loadPlayerData: the owner gets the world's player file,
// everyone else their own.
let known = match owner.filter(|o| o.uuid == g.uuid) {
Some(o) => playerdata::load_owner(g, &root, o.saved.take().as_deref()),
None => playerdata::load(g, &root),
};
if !known || force {
g.game_mode = default_mode;
+161 -14
View File
@@ -42,6 +42,7 @@ pub(super) struct GuestPlay {
mining: Option<Mining>,
destroy_delay: u32,
using: Option<(usize, u32)>,
use_duration: i32,
use_delay: u32,
/// The shield's ItemCooldowns entry: an axe disabled it for this many ticks.
pub(super) shield_cooldown: u32,
@@ -172,6 +173,7 @@ pub(super) struct GuestSwap {
mining: Option<Mining>,
destroy_delay: u32,
using: Option<(usize, u32)>,
use_duration: i32,
use_delay: u32,
pub(super) shield_cooldown: u32,
/// The guest's item cooldowns (`Game::cooldowns` for its turn).
@@ -295,7 +297,9 @@ impl Game<'_> {
f: impl FnOnce(&mut Self) -> R,
) -> Option<R> {
let swap = self.enter_guest(host, conn)?;
let result = f(self);
let dimension =
(self.serving.is_some() || self.options.dedicated.is_some()).then_some(self.dim.id());
let result = horizon_client::block_contact::in_level(dimension, || f(self));
self.leave_guest(host, swap);
Some(result)
}
@@ -410,6 +414,7 @@ impl Game<'_> {
motion.sprinting = g.sprinting;
motion.may_fly = g.creative() || g.game_mode == SPECTATOR;
motion.flying = g.flying;
motion.flying_speed = g.flying_speed;
// FireworkRocketItem.use boosts only a gliding player.
motion.fall_flying = g.fall_flying;
motion.invulnerable = motion.may_fly;
@@ -464,6 +469,7 @@ impl Game<'_> {
let mining = std::mem::replace(&mut self.mining, play.mining.take());
let destroy_delay = std::mem::replace(&mut self.destroy_delay, play.destroy_delay);
let using = std::mem::replace(&mut self.using, play.using.take());
let use_duration = std::mem::replace(&mut self.use_duration, play.use_duration);
let use_delay = std::mem::replace(&mut self.use_delay, play.use_delay);
let shield_cooldown = std::mem::replace(&mut self.shield_cooldown, play.shield_cooldown);
let cooldowns = std::mem::replace(&mut self.cooldowns, std::mem::take(&mut play.cooldowns));
@@ -577,6 +583,7 @@ impl Game<'_> {
mining,
destroy_delay,
using,
use_duration,
use_delay,
shield_cooldown,
cooldowns,
@@ -652,6 +659,7 @@ impl Game<'_> {
mining,
destroy_delay,
using,
use_duration,
use_delay,
shield_cooldown,
cooldowns,
@@ -769,6 +777,7 @@ impl Game<'_> {
play.mining = std::mem::replace(&mut self.mining, mining);
play.destroy_delay = std::mem::replace(&mut self.destroy_delay, destroy_delay);
play.using = std::mem::replace(&mut self.using, using);
play.use_duration = std::mem::replace(&mut self.use_duration, use_duration);
play.use_delay = std::mem::replace(&mut self.use_delay, use_delay);
play.shield_cooldown = std::mem::replace(&mut self.shield_cooldown, shield_cooldown);
play.cooldowns = std::mem::replace(&mut self.cooldowns, cooldowns);
@@ -1181,9 +1190,14 @@ impl Game<'_> {
);
return false;
}
// A mod menu type only for a client that has it: as
// `ServerPlayer.openMenu` with no menu, nothing opens.
let brand = host.hub.guest(conn).map_or("", |g| g.brand.as_str());
if !horizon_client::containers::menu_reaches(brand, menu) {
return false;
}
// ServerPlayer.openMenu closes the menu that was open.
self.guest_container_closed(host, conn);
self.lan_station_closed(host, conn, None);
self.guest_close_all(host, conn);
let hidden = self.native_hidden_slots(pos, items.len());
host.hub
.open_container(conn, menu, title, pos, slots, items);
@@ -1231,8 +1245,7 @@ impl Game<'_> {
return false;
};
// ServerPlayer.openMenu closes the menu that was open.
self.guest_container_closed(host, conn);
self.lan_station_closed(host, conn, None);
self.guest_close_all(host, conn);
host.hub
.open_container(conn, menu, title, pos, slots, items);
true
@@ -1253,6 +1266,38 @@ impl Game<'_> {
}
}
/// Closes whatever menu the guest has open, the way each `ContainerClose`
/// does (`ServerPlayer.doCloseContainer`, `AbstractContainerMenu.removed`):
/// the container's lid, a vehicle's stand-in (its items go back to the
/// vehicle), the trade (written back to the villager), the crafting table,
/// station and mount menus, and the cursor stack back to the inventory.
/// Nothing is sent to the client: this is for a guest who is leaving or
/// whose server is closing. The guest must be in `host.hub.guests` and the
/// level they are in must be the one in place.
pub(super) fn guest_close_all(&mut self, host: &mut LanHost, conn: u32) {
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
return;
};
if let Some((window, slots)) = host.hub.guests[gi]
.menu
.as_ref()
.map(|m| (m.window, m.slots))
{
self.guest_container_closed(host, conn);
if let Slots::Block(key) = slots
&& key.1 == super::minecarts::CART_KEY_Y
{
self.close_guest_vehicle_menu(host, conn, key);
}
host.hub.container_closed(conn, window);
}
self.lan_merchant_closed(host, conn, None);
self.lan_crafting_closed(host, conn, None);
self.lan_station_closed(host, conn, None);
self.lan_mount_closed(host, conn, None);
self.guest_carried_back(host, gi);
}
/// `ContainerEntity`'s `Container.stopOpen` when a vehicle menu closes
/// (`AbstractChestBoat.stopOpen` / `MinecartChest.stopOpen`): the menu's
/// slots go back to the vehicle, the shared placeholder leaves
@@ -1297,15 +1342,7 @@ impl Game<'_> {
.guests
.iter()
.filter(|g| host.hub.here(g))
.filter(|g| {
g.menu.as_ref().is_some_and(|m| {
(m.slots.valid(&self.world) && !m.slots.still_valid(&self.world, g, m.pos))
|| m.pos.is_some_and(|k| {
k.1 == super::minecarts::CART_KEY_Y
&& !self.chest_vehicle_still_valid(k.2, g)
})
})
})
.filter(|g| !self.container_menu_valid(g))
.map(|g| {
let vehicle = g.menu.as_ref().and_then(|m| match m.slots {
Slots::Block(key) if key.1 == super::minecarts::CART_KEY_Y => Some(key),
@@ -1326,6 +1363,78 @@ impl Game<'_> {
}
}
/// `stillValid` of the guest's open container: its blocks are still there
/// and in reach, or its vehicle exists and is in reach. True with none open.
fn container_menu_valid(&self, g: &horizon_client::lan::guest::Guest) -> bool {
g.menu.as_ref().is_none_or(|m| {
!(m.slots.valid(&self.world) && !m.slots.still_valid(&self.world, g, m.pos))
&& !m.pos.is_some_and(|k| {
k.1 == super::minecarts::CART_KEY_Y && !self.chest_vehicle_still_valid(k.2, g)
})
})
}
/// `AbstractContainerMenu.stillValid` of whatever menu the guest has open
/// (a container or vehicle, a trade, the crafting table, a station, a
/// lectern, a mount's inventory). True when none is open.
fn guest_menu_still_valid(&self, host: &LanHost, conn: u32) -> bool {
let Some(g) = host.hub.guest(conn) else {
return true;
};
if !self.container_menu_valid(g) {
return false;
}
let Some(p) = host.play.get(&conn) else {
return true;
};
p.crafting
.table
.as_ref()
.is_none_or(|t| self.lan_table_valid(g, t.3))
&& p.station
.as_ref()
.is_none_or(|s| self.lan_station_valid(g, s))
&& p.lectern
.as_ref()
.is_none_or(|l| self.lan_lectern_valid(g, l.key))
&& p.merchant
.as_ref()
.and_then(|m| m.merchant.mob)
.is_none_or(|id| self.lan_merchant_valid(g, id))
&& p.mount_window
.as_ref()
.is_none_or(|m| self.lan_mount_valid(g, m.mob))
}
/// The check before a menu click: a menu that is no longer valid closes
/// (returning what it held, as `ContainerClose` does) and the client's
/// window with it, so the click lands on nothing
/// (`ServerGamePacketListenerImpl.handleContainerClick` and the others
/// ask `stillValid` first).
fn guest_validate_menu(&mut self, host: &mut LanHost, conn: u32) {
if self.guest_menu_still_valid(host, conn) {
return;
}
let mut windows: Vec<i32> = host
.hub
.guest(conn)
.and_then(|g| g.menu.as_ref().map(|m| m.window))
.into_iter()
.collect();
if let Some(p) = host.play.get(&conn) {
windows.extend(p.crafting.table.as_ref().map(|t| t.0));
windows.extend(p.station.as_ref().map(|s| s.window));
windows.extend(p.lectern.as_ref().map(|l| l.window));
windows.extend(p.merchant.as_ref().map(|m| m.window));
windows.extend(p.mount_window.as_ref().map(|m| m.window));
}
self.guest_close_all(host, conn);
for container in windows {
host.hub
.send_to(conn, &C::ContainerClose(play::ContainerClose { container }));
}
}
/// `ContainerEntity.isChestVehicleStillValid`: the chest minecart or chest
/// boat is still there and within the guest's entity interaction range
/// plus 4 of its box.
@@ -1437,6 +1546,19 @@ impl Game<'_> {
{
return;
}
if matches!(
packet,
S::ContainerClick(_)
| S::ContainerButtonClick(_)
| S::ContainerSlotStateChanged(_)
| S::RenameItem(_)
| S::SetBeacon(_)
| S::SelectTrade(_)
| S::SelectBundleItem(_)
| S::PlaceRecipe(_)
) {
self.guest_validate_menu(host, conn);
}
let spectator = host.hub.guests[gi].spectator();
match packet {
S::PlayerAction(a) => match a.action {
@@ -2641,6 +2763,30 @@ impl Game<'_> {
command: &str,
signed: Option<Vec<(String, horizon_client::lan::hub::chat::ChatMessage)>>,
) {
// The mods hear every command first, and may stop it.
let mut canceled = false;
let mut replies = Vec::new();
if let Some((uuid, name)) = host.hub.guest(conn).map(|g| (g.uuid, g.name.clone())) {
self.native_player_command(
uuid,
&name,
command.trim_start_matches('/'),
&mut canceled,
&mut replies,
);
}
for line in replies {
host.hub.send_to(
conn,
&C::SystemChat(play::SystemChat {
content: horizon_client::lan::data::component_nbt(&line.component()),
overlay: false,
}),
);
}
if canceled {
return;
}
if self.guest_message_command(host, conn, command, signed.as_ref()) {
return;
}
@@ -2977,6 +3123,7 @@ impl Game<'_> {
// restartClientLoadTimerAfterRespawn.
g.dead = false;
g.load_timeout = horizon_client::lan::guest::CLIENT_LOADED_TIMEOUT;
g.loading = true;
g.death_time = 0;
g.health = 20.0;
g.food = 20;
@@ -116,20 +116,22 @@ impl Game<'_> {
/// `/attribute` changed.
pub(super) fn mob_attributes(&self, m: &Mob, out: &mut Vec<AttributeSnapshot>) {
let kind = m.kind.id.as_str();
if let Some(a) = self.synced_attribute(kind, "minecraft:movement_speed") {
instance(out, a, finalize::base_value(m, Attr::MovementSpeed));
// Movement speed is synced for nearly every mob, and read up to three
// times below: looked up once per mob and tick.
let speed = self
.synced_attribute(kind, Attr::MovementSpeed.id())
.map(|a| (a, finalize::base_value(m, Attr::MovementSpeed)));
if let Some((a, base)) = speed {
instance(out, a, base);
}
if let Some(s) = m.extra.attributes.as_deref() {
for (attr, v) in &s.bases {
if let Some(a) = self.synced_attribute(kind, &format!("minecraft:{}", attr.name()))
{
if let Some(a) = self.synced_attribute(kind, attr.id()) {
instance(out, a, *v).base = *v;
}
}
for x in &s.modifiers {
if let Some(a) =
self.synced_attribute(kind, &format!("minecraft:{}", x.attr.name()))
{
if let Some(a) = self.synced_attribute(kind, x.attr.id()) {
add_modifier(
out,
a,
@@ -162,18 +164,17 @@ impl Game<'_> {
if rolled
&& !jump_base
&& m.extra.vector[0] > 0.0
&& let Some(a) = self.synced_attribute(kind, "minecraft:jump_strength")
&& let Some(a) = self.synced_attribute(kind, Attr::JumpStrength.id())
{
instance(out, a, m.extra.vector[0]).base = m.extra.vector[0];
}
if let (Some(a), Some((amount, Op::AddMultipliedBase))) = (
self.synced_attribute(kind, "minecraft:movement_speed"),
finalize::transient(m, Attr::MovementSpeed),
) {
if let (Some((a, base)), Some((amount, Op::AddMultipliedBase))) =
(speed, finalize::transient(m, Attr::MovementSpeed))
{
add_modifier(
out,
a,
finalize::base_value(m, Attr::MovementSpeed),
base,
AttributeModifier {
id: "minecraft:baby".into(),
amount,
@@ -182,14 +183,11 @@ impl Game<'_> {
);
}
// LivingEntity.setSprinting: SPEED_MODIFIER_SPRINTING (a dashing cat or ocelot).
if let (true, Some(a)) = (
m.extra.sprinting,
self.synced_attribute(kind, "minecraft:movement_speed"),
) {
if let (true, Some((a, base))) = (m.extra.sprinting, speed) {
add_modifier(
out,
a,
finalize::base_value(m, Attr::MovementSpeed),
base,
AttributeModifier {
id: "minecraft:sprinting".into(),
amount: f64::from(0.3f32),
@@ -21,7 +21,7 @@ pub(super) struct GuestCrafting {
/// The inventory menu; its grid lives in the hub's guest.
inventory: Option<Box<Menu>>,
/// The open crafting table: its window, state id, menu and block.
table: Option<(i32, i32, Box<Menu>, [i32; 3])>,
pub(super) table: Option<(i32, i32, Box<Menu>, [i32; 3])>,
/// The open furnace's or brewing stand's window and menu (`AbstractFurnaceMenu`,
/// `BrewingStandMenu`); its slots live in the block entity and the hub's
/// container menu keeps the client in sync.
@@ -436,10 +436,8 @@ impl Game<'_> {
/// The guest opened a crafting table (`CraftingTableBlock.getMenuProvider`).
pub(super) fn lan_open_crafting(&mut self, host: &mut LanHost, conn: u32, pos: [i32; 3]) {
self.lan_crafting_closed(host, conn, None);
self.lan_station_closed(host, conn, None);
// The container it replaces loses its opener.
self.guest_container_closed(host, conn);
// ServerPlayer.openMenu closes the menu that was open.
self.guest_close_all(host, conn);
let title = horizon_protocol::text::translatable("container.crafting", &[]);
let Some(window) = host.hub.open_window(conn, "minecraft:crafting", title) else {
return;
@@ -509,7 +507,7 @@ impl Game<'_> {
/// `CraftingMenu.stillValid`: the block is still a crafting table and the
/// guest is within reach of it.
fn lan_table_valid(&self, g: &Guest, pos: [i32; 3]) -> bool {
pub(super) fn lan_table_valid(&self, g: &Guest, pos: [i32; 3]) -> bool {
let name = &self
.res
.facts()
@@ -15,8 +15,8 @@ use horizon_protocol::packets::play::{self, Clientbound as C};
/// A guest's open mount menu.
pub(super) struct GuestMount {
window: i32,
mob: u32,
pub(super) window: i32,
pub(super) mob: u32,
columns: usize,
state: i32,
/// The menu's slots and the carried stack the client was last sent.
@@ -31,10 +31,8 @@ impl Game<'_> {
return;
};
let columns = super::mount_menu::mount_columns(&self.mobs[index]);
self.lan_mount_closed(host, conn, None);
self.lan_station_closed(host, conn, None);
self.lan_crafting_closed(host, conn, None);
self.guest_container_closed(host, conn);
// ServerPlayer.openMenu closes the menu that was open.
self.guest_close_all(host, conn);
let Some(window) = host.hub.open_mount_window(conn, columns as i32, mob as i32) else {
return;
};
@@ -182,10 +180,28 @@ impl Game<'_> {
closed
}
/// `AbstractMountInventoryMenu.stillValid`: the mount is alive and within
/// the entity interaction range (3) plus 4 of the guest's eyes.
pub(super) fn lan_mount_valid(&self, g: &horizon_client::lan::guest::Guest, mob: u32) -> bool {
let eye = [g.position[0], g.position[1] + g.eye_height(), g.position[2]];
self.mobs.iter().find(|m| m.id == mob).is_some_and(|m| {
let (w, h) = (f64::from(m.width()) / 2.0, f64::from(m.height()));
let p = [m.position.x, m.position.y, m.position.z];
let lo = [p[0] - w, p[1], p[2] - w];
let hi = [p[0] + w, p[1] + h, p[2] + w];
let d: f64 = (0..3)
.map(|k| (eye[k] - eye[k].clamp(lo[k], hi[k])).powi(2))
.sum();
m.alive() && d < 7.0 * 7.0
})
}
/// Each tick: a mount menu whose mount died or is out of reach closes
/// (`AbstractMountInventoryMenu.stillValid`: alive, within the entity
/// interaction range plus 4 of the eyes), and the others follow the
/// mount's and the inventory's slots.
/// mount's and the inventory's slots. Only the level the player is in
/// checks its menu (`ServerPlayer.tick`): another level ticking has
/// none of its mobs (#5331).
pub(super) fn tick_lan_mount_menus(&mut self, host: &mut LanHost) {
let open: Vec<(u32, u32, i32)> = host
.play
@@ -196,19 +212,10 @@ impl Game<'_> {
let Some(g) = host.hub.guests.iter().find(|g| g.conn == conn) else {
continue;
};
let eye = [g.position[0], g.position[1] + g.eye_height(), g.position[2]];
let valid = self.mobs.iter().find(|m| m.id == mob).is_some_and(|m| {
let (w, h) = (f64::from(m.width()) / 2.0, f64::from(m.height()));
let p = [m.position.x, m.position.y, m.position.z];
let lo = [p[0] - w, p[1], p[2] - w];
let hi = [p[0] + w, p[1] + h, p[2] + w];
let d: f64 = (0..3)
.map(|k| (eye[k] - eye[k].clamp(lo[k], hi[k])).powi(2))
.sum();
// Player.ENTITY_INTERACTION_RANGE 3, plus 4.
m.alive() && d < 7.0 * 7.0
});
if valid {
if !host.hub.here(g) {
continue;
}
if self.lan_mount_valid(g, mob) {
self.lan_send_mount(host, conn);
} else {
self.lan_mount_closed(host, conn, Some(window));
+117 -15
View File
@@ -6,7 +6,7 @@ use horizon_client::lan::hub::Hub;
use horizon_client::particles::{ParticleOptions, ParticleType};
use horizon_core::{geometry::Vec3, random::RandomSource};
use horizon_protocol::{
entity_data::{Particle, ParticleData},
entity_data::{MOD_PARTICLE_ID, Particle, ParticleData},
packets::play::{self, Clientbound as C},
};
use horizon_world::registry::BlockStateId;
@@ -20,12 +20,15 @@ pub(super) struct QueuedParticle {
pub spread: [f64; 3],
pub speed: f64,
pub count: i32,
/// The packet's `RandomizationType` (0 DEFAULT, 1 ALTERNATIVE).
pub randomization: i32,
}
fn send_native_nearby(
host: &LanHost,
dimension: &str,
packet: Vec<u8>,
horizon_only: bool,
within_range: impl Fn([f64; 3]) -> bool,
) {
for guest in host
@@ -34,10 +37,47 @@ fn send_native_nearby(
.iter()
.filter(|g| g.placed && g.dimension == dimension && within_range(g.position))
{
// A vanilla client cannot decode a native mod's particle id; see
// `mod_particle_only_for_horizon`.
if horizon_only && guest.brand != "horizon" {
continue;
}
guest.sender.send(packet.clone());
}
}
/// A native mod's particle travels as `MOD_PARTICLE_ID` (-1) plus its name.
/// Vanilla reads the id with `IdMap.byIdOrThrow`, which throws on -1 and
/// drops the connection, so such a particle may only go to Horizon clients.
fn mod_particle_only_for_horizon(particle: &Particle) -> bool {
matches!(particle.data, Some(ParticleData::Mod(_)))
}
/// One of a packet's `count` particles (`handleParticleEvent`): its position
/// and speed. DEFAULT (0) draws both from a Gaussian; ALTERNATIVE (1) and
/// ALTERNATIVE_WITH_SPEED (2) spread over `[0, dist)`, the first keeping
/// the speeds as they are, the second scaling each by `nextDouble`.
fn spread(
r: &mut impl RandomSource,
randomization: i32,
pos: [f64; 3],
dist: [f64; 3],
speed: [f64; 3],
) -> ([f64; 3], [f64; 3]) {
if matches!(randomization, 1 | 2) {
let at = [0, 1, 2].map(|k| pos[k] + r.next_double() * dist[k]);
let v = if randomization == 2 {
[0, 1, 2].map(|k| speed[k] * r.next_double())
} else {
speed
};
(at, v)
} else {
let at = [0, 1, 2].map(|k| pos[k] + r.next_gaussian() * dist[k]);
(at, [0, 1, 2].map(|k| r.next_gaussian() * speed[k]))
}
}
/// An ARGB colour as the options' RGBA.
fn rgba(argb: i32) -> [f32; 4] {
let c = |shift: i32| ((argb >> shift) & 255) as f32 / 255.0;
@@ -125,6 +165,7 @@ impl Game<'_> {
else {
return;
};
let horizon_only = mod_particle_only_for_horizon(&particle);
let packet = C::LevelParticles(play::LevelParticles {
particle,
override_limiter: false,
@@ -136,6 +177,9 @@ impl Game<'_> {
randomization: 0,
});
for g in host.hub.guests.iter().filter(|g| host.hub.here(g)) {
if horizon_only && g.brand != "horizon" {
continue;
}
// ServerLevel compares the player's block centre, strictly within 32.
let distance: f64 = (0..3)
.map(|k| (g.position[k].floor() + 0.5 - at[k]).powi(2))
@@ -225,6 +269,13 @@ impl Game<'_> {
options: &ParticleOptions,
state: Option<BlockStateId>,
) -> Option<Particle> {
// A native mod's own type: no registry id, so the sentinel and the name.
if let Some(name) = &options.mod_particle {
return Some(Particle {
id: MOD_PARTICLE_ID,
data: Some(ParticleData::Mod(name.clone())),
});
}
let name = format!("minecraft:{}", options.ty.name());
let id = hub.registry_id("minecraft:particle_type", &name)?;
use ParticleType as T;
@@ -265,6 +316,11 @@ impl Game<'_> {
p: &Particle,
at: [f64; 3],
) -> Option<ParticleOptions> {
if p.id == MOD_PARTICLE_ID
&& let Some(ParticleData::Mod(name)) = &p.data
{
return Some(ParticleOptions::named(name.clone()));
}
let name = self
.remote
.as_ref()?
@@ -334,17 +390,7 @@ impl Game<'_> {
return;
}
for _ in 0..p.count {
let r = self.particles.random();
let at = [
p.pos[0] + r.next_gaussian() * d[0],
p.pos[1] + r.next_gaussian() * d[1],
p.pos[2] + r.next_gaussian() * d[2],
];
let v = [
r.next_gaussian() * s[0],
r.next_gaussian() * s[1],
r.next_gaussian() * s[2],
];
let (at, v) = spread(self.particles.random(), p.randomization, p.pos, d, s);
add(&mut self.particles, at, v);
}
}
@@ -409,6 +455,7 @@ impl Game<'_> {
spread,
speed,
count,
randomization,
} = request;
let Some(particle) = self.network_particle(&options, state) else {
return;
@@ -416,6 +463,7 @@ impl Game<'_> {
let Some(host) = self.lan.as_ref() else {
return;
};
let horizon_only = mod_particle_only_for_horizon(&particle);
let body = C::LevelParticles(play::LevelParticles {
particle,
override_limiter: false,
@@ -424,12 +472,12 @@ impl Game<'_> {
dist: spread.map(|d| d as f32),
max_speed: [speed as f32; 3],
count,
randomization: 0,
randomization,
})
.encode();
// BlockPos.closerToCenterThan(at, 32): from the centre of the
// player's block.
send_native_nearby(host, dimension, body, |position| {
send_native_nearby(host, dimension, body, horizon_only, |position| {
let d: f64 = (0..3)
.map(|k| (position[k].floor() + 0.5 - at[k]).powi(2))
.sum();
@@ -462,13 +510,40 @@ impl Game<'_> {
)
.encode();
let range = f64::from(sound.volume.max(1.0)) * 16.0;
send_native_nearby(host, sound.dimension, packet, |position| {
send_native_nearby(host, sound.dimension, packet, false, |position| {
let distance: f64 = (0..3).map(|k| (position[k] - sound.at[k]).powi(2)).sum();
distance <= range * range
});
}
}
/// `BucketItem.emptyContents` where water evaporates: `sendParticles(
/// LARGE_SMOKE, x, y, z, 8, 1, 1, 1, 0, ALTERNATIVE)` from the block's
/// corner, so each puff lands inside the block. The window draws its own.
pub(super) fn evaporation_smoke(&mut self, pos: [i32; 3]) {
let options = ParticleOptions::simple(ParticleType::LargeSmoke);
let at = pos.map(f64::from);
for _ in 0..8 {
let r = self.particles.random();
let p = [
at[0] + r.next_double(),
at[1] + r.next_double(),
at[2] + r.next_double(),
];
self.particles.add(&options, p, [0.0; 3]);
}
self.lan_send_particles(QueuedParticle {
dimension: self.dim.id(),
options,
state: None,
at,
spread: [1.0; 3],
speed: 0.0,
count: 8,
randomization: 1,
});
}
/// The particles guests' turns put off.
pub(super) fn flush_lan_particles(&mut self) {
for request in std::mem::take(&mut self.creatures.lan_particles) {
@@ -616,3 +691,30 @@ impl Game<'_> {
}
}
}
#[cfg(test)]
mod tests {
use super::spread;
use horizon_core::random::LegacyRandom;
/// The Nether's evaporation smoke (ALTERNATIVE, dist 1, speed 0) lands
/// inside the block from its corner and stands still; DEFAULT spreads
/// both ways.
#[test]
fn alternative_particles_spread_from_the_corner_over_dist() {
let mut r = LegacyRandom::new(1);
let mut below = false;
for _ in 0..200 {
let (at, v) = spread(&mut r, 1, [3.0, 64.0, -7.0], [1.0; 3], [0.0; 3]);
assert!((3.0..4.0).contains(&at[0]), "{at:?}");
assert!((64.0..65.0).contains(&at[1]), "{at:?}");
assert!((-7.0..-6.0).contains(&at[2]), "{at:?}");
assert_eq!(v, [0.0; 3]);
let (_, v) = spread(&mut r, 2, [0.0; 3], [0.0; 3], [0.5, 0.0, 2.0]);
assert!((0.0..0.5).contains(&v[0]) && v[1] == 0.0 && (0.0..2.0).contains(&v[2]));
let (at, _) = spread(&mut r, 0, [0.0; 3], [1.0; 3], [0.0; 3]);
below |= at.iter().any(|&c| c < 0.0);
}
assert!(below, "DEFAULT is a Gaussian around the position");
}
}
+4 -5
View File
@@ -548,11 +548,10 @@ impl Game<'_> {
let (xd, zd) = sweep.knockback;
let attacker = self.player_attacker(host, sweep.attacker);
let land = |g: &mut Self| {
let hurt = g.mob_hits_player(sweep.damage, sweep.from, "minecraft:player_attack");
if hurt {
if let Some((id, name)) = &attacker {
g.note_named_attacker("minecraft:player_attack", *id, name.clone());
}
if let Some((id, name)) = &attacker {
g.note_named_attacker("minecraft:player_attack", *id, name.clone());
}
if g.mob_hits_player(sweep.damage, sweep.from, "minecraft:player_attack") {
g.knock_back_player(0.4, xd, zd);
}
};
+12 -1
View File
@@ -112,13 +112,24 @@ impl Game<'_> {
if !integrated && (!host.settings.guest_commands || !allowed) {
return false;
}
let Some(permission) = host.hub.guest(conn).map(|g| g.permission) else {
let Some((permission, uuid, name)) = host
.hub
.guest(conn)
.map(|g| (g.permission, g.uuid, g.name.clone()))
else {
return false;
};
let command = line.trim_start_matches('/');
if !self.signable_arguments(permission, command).is_empty() {
return false;
}
// The mods hear it as they hear a typed command; the batch is quiet,
// so their replies go nowhere.
let mut canceled = false;
self.native_player_command(uuid, &name, command, &mut canceled, &mut Vec::new());
if canceled {
return false;
}
self.run_guest_command(GuestCommand {
conn,
command: command.to_owned(),
+15 -4
View File
@@ -1205,8 +1205,14 @@ impl Game<'_> {
None => {
let mut handled = false;
rules.edit(|b| handled = b.key(key, ctrl, false));
if !handled && types_text {
self.world_options_text(event.text.as_deref());
// Only a key with text types: a `None` text is a
// backspace to `world_options_text`, which is the
// control socket's `\b`, not a key's.
if !handled
&& types_text
&& let Some(text) = event.text.as_deref()
{
self.world_options_text(Some(text));
}
}
}
@@ -1312,8 +1318,13 @@ impl Game<'_> {
o.port.shortcut(shortcut, &mut self.clipboard);
return;
}
if !o.port.key(key, ctrl, false) && types_text {
self.world_options_text(event.text.as_deref());
// Only a key with text types (a `None` is a backspace to
// `world_options_text`, the control socket's `\b`).
if !o.port.key(key, ctrl, false)
&& types_text
&& let Some(text) = event.text.as_deref()
{
self.world_options_text(Some(text));
}
return;
}
+35 -21
View File
@@ -20,7 +20,7 @@ use horizon_protocol::packets::play::{self, Clientbound as C};
/// A guest's open workstation menu.
pub(super) struct GuestStation {
window: i32,
pub(super) window: i32,
state: i32,
kind: StationKind,
pos: [i32; 3],
@@ -34,9 +34,9 @@ pub(super) struct GuestStation {
/// A guest's open lectern: `LecternMenu` over the block entity, made on the
/// menu engine for each click (`Game::lectern_menu`).
pub(super) struct GuestLectern {
window: i32,
pub(super) window: i32,
state: i32,
key: (i32, i32, i32),
pub(super) key: (i32, i32, i32),
/// The book and page the client was last sent (`broadcastChanges`).
sent: Option<(Option<ItemStack>, i32)>,
}
@@ -69,10 +69,8 @@ impl Game<'_> {
let Some((menu_type, kind, _)) = menu_of(station.kind) else {
return false;
};
self.lan_station_closed(host, conn, None);
self.lan_crafting_closed(host, conn, None);
// The container it replaces loses its opener.
self.guest_container_closed(host, conn);
// ServerPlayer.openMenu closes the menu that was open.
self.guest_close_all(host, conn);
let title = horizon_protocol::text::translatable(station.kind.title_key(), &[]);
let Some(window) = host.hub.open_window(conn, menu_type, title) else {
return true;
@@ -151,9 +149,8 @@ impl Game<'_> {
/// `LecternBlock.openScreen` for a guest: `minecraft:lectern` over the
/// block entity, with its book and page.
pub(super) fn lan_open_lectern(&mut self, host: &mut LanHost, conn: u32, key: (i32, i32, i32)) {
self.lan_station_closed(host, conn, None);
self.lan_crafting_closed(host, conn, None);
self.guest_container_closed(host, conn);
// ServerPlayer.openMenu closes the menu that was open.
self.guest_close_all(host, conn);
let title = horizon_protocol::text::translatable("container.lectern", &[]);
let Some(window) = host.hub.open_window(conn, "minecraft:lectern", title) else {
return;
@@ -644,10 +641,7 @@ impl Game<'_> {
let Some(gs) = host.play.get(&conn).and_then(|p| p.station.as_ref()) else {
continue;
};
let [x, y, z] = gs.pos;
let name = &self.res.facts().get(self.world.block(x, y, z)).name;
let block = StationKind::from_block(name) == Some(gs.kind);
if !(block && g.within_block_range(gs.pos, 4.0)) {
if !self.lan_station_valid(g, gs) {
let window = gs.window;
self.lan_station_closed(host, conn, Some(window));
host.hub.send_to(
@@ -669,6 +663,32 @@ impl Game<'_> {
}
impl Game<'_> {
/// A station menu's `stillValid`: its block is still that station and the
/// guest is within `isWithinBlockInteractionRange(pos, 4)`.
pub(super) fn lan_station_valid(
&self,
g: &horizon_client::lan::guest::Guest,
gs: &GuestStation,
) -> bool {
let [x, y, z] = gs.pos;
let name = &self.res.facts().get(self.world.block(x, y, z)).name;
StationKind::from_block(name) == Some(gs.kind) && g.within_block_range(gs.pos, 4.0)
}
/// `LecternMenu.stillValid` (`Container.stillValidBlockEntity`): the block
/// entity still holds a book and the guest is within the interaction
/// range and its buffer.
pub(super) fn lan_lectern_valid(
&self,
g: &horizon_client::lan::guest::Guest,
key: (i32, i32, i32),
) -> bool {
matches!(
self.world.block_entities.get(&key),
Some(BlockEntity::Lectern(l)) if l.book.is_some()
) && g.within_block_range([key.0, key.1, key.2], 4.0)
}
/// The guests' lecterns each tick: `stillValid` (the block entity, its
/// book, the reach) closes them, and `broadcastChanges` sends a book or
/// page another player changed.
@@ -685,13 +705,7 @@ impl Game<'_> {
if !host.hub.here(g) {
continue;
}
let has_book = matches!(
self.world.block_entities.get(&key),
Some(BlockEntity::Lectern(l)) if l.book.is_some()
);
// Container.stillValidBlockEntity: the interaction range and its buffer.
let near = g.within_block_range([key.0, key.1, key.2], 4.0);
if has_book && near {
if self.lan_lectern_valid(g, key) {
self.lan_send_lectern(host, conn, false);
} else {
self.lan_station_closed(host, conn, Some(window));
+28 -20
View File
@@ -57,9 +57,8 @@ impl Game<'_> {
state.session.update_special_prices();
}
let title = self.merchant_display_name(&state);
// The container or workstation the trading screen replaces is closed.
self.guest_container_closed(host, conn);
self.lan_station_closed(host, conn, None);
// ServerPlayer.openMenu closes the menu that was open.
self.guest_close_all(host, conn);
let Some(window) = host.hub.open_merchant_window(conn, title) else {
self.merchant_closed(&mut state);
return;
@@ -350,28 +349,37 @@ impl Game<'_> {
self.merchant_closed(&mut gm.merchant);
true
}
/// `Merchant.stillValid` for a trader: it is alive and the guest is within
/// the entity interaction range plus 4 of its box.
pub(super) fn lan_merchant_valid(
&self,
g: &horizon_client::lan::guest::Guest,
id: u32,
) -> bool {
self.mobs
.iter()
.find(|m| m.id == id)
.is_some_and(|m| m.alive() && guest_within_entity_range(g, &m.bounds(), 4.0))
}
/// `Villager.customServerAiStep` for the guests' trading villagers: the
/// TRADE reputation event, then the prices follow.
pub(super) fn lan_merchant_tick(&mut self, host: &mut LanHost) {
// `AbstractVillager.stillValid`: the trade screen closes once the trader
// is gone or dead, or the guest is more than 4 blocks past its reach.
let far: Vec<(u32, i32)> =
host.play
.iter()
.filter_map(|(conn, p)| {
let gm = p.merchant.as_ref()?;
let id = gm.merchant.mob?;
let g = host
.hub
.guests
.iter()
.find(|g| g.conn == *conn && host.hub.here(g))?;
let valid = self.mobs.iter().find(|m| m.id == id).is_some_and(|m| {
m.alive() && guest_within_entity_range(g, &m.bounds(), 4.0)
});
(!valid).then_some((*conn, gm.window))
})
.collect();
let far: Vec<(u32, i32)> = host
.play
.iter()
.filter_map(|(conn, p)| {
let gm = p.merchant.as_ref()?;
let id = gm.merchant.mob?;
let g = host
.hub
.guests
.iter()
.find(|g| g.conn == *conn && host.hub.here(g))?;
(!self.lan_merchant_valid(g, id)).then_some((*conn, gm.window))
})
.collect();
for (conn, window) in far {
if self.lan_merchant_closed(host, conn, Some(window)) {
host.hub.send_to(
+83 -11
View File
@@ -127,6 +127,14 @@ impl Game<'_> {
}
/// `Leashable.dropLeash` / `removeLeash`: the lead falls as an item.
pub(super) fn break_leash(&mut self, id: u32, drop: bool, sound: bool) {
self.drop_leash(id, drop, sound, false);
}
/// `break_leash` by the acting player's use (untying it, shears).
fn break_leash_used(&mut self, id: u32, drop: bool) {
self.drop_leash(id, drop, false, true);
}
fn drop_leash(&mut self, id: u32, drop: bool, sound: bool, used: bool) {
let killer = self.lead_killers.remove(&id).map(|(killer, _)| killer);
let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) else {
return;
};
@@ -136,16 +144,54 @@ impl Game<'_> {
// Leashable.tickLeash gates a dying entity's lead drop on entity_drops.
let drop = drop && (m.alive() || self.ui.rules.bool("entity_drops"));
let at = [m.position.x, m.position.y, m.position.z];
let kind = super::native_hooks::mob_kind(m);
if sound {
self.lead_sound("break", at);
}
if drop {
if !drop {
return;
}
// The mods' say when a player is behind it: the user, else the
// killer of a mob that died leashed.
let player = if used {
Some(self.native_acting_id())
} else {
killer
};
let Some(player) = player else {
self.drop_lead_item(at);
return;
};
let mut drops = vec![ItemStack::new("minecraft:lead", 1)];
let context = super::native_hooks::NativeLootDrops {
table: "",
dimension: self.dim.id(),
origin: at,
block: None,
entity: Some((id, &kind)),
player: Some(player),
tool: None,
explosion: None,
used,
};
self.native_loot_drops(&context, &mut drops);
for stack in drops {
if stack.count > 0 && &*stack.item != "minecraft:air" {
let v = super::dispenser::new_item_motion();
self.spawn_item(stack, at, v, 10);
}
}
}
/// Holders read from a save become real once the entity is found; a
/// holder that never shows up drops the lead after 100 ticks.
pub(super) fn tick_leads(&mut self) {
// A corpse gone without dropping its lead takes its killer along.
// Only this level's: the others' mobs aren't in place.
if !self.lead_killers.is_empty() {
let (mobs, dim) = (&self.mobs, self.dim);
self.lead_killers
.retain(|id, (_, at)| *at != dim || mobs.iter().any(|m| m.id == *id && !m.removed));
}
let player_uuid = self.level.player_uuid.unwrap_or([0, 0, 0, 1]);
for i in 0..self.mobs.len() {
let Some(pending) = self.mobs[i].extra.leash_pending else {
@@ -222,7 +268,7 @@ impl Game<'_> {
if item == "minecraft:shears" {
let mut dropped = false;
if self.mobs[index].extra.leash.is_some() {
self.break_leash(id, true, false);
self.break_leash_used(id, true);
dropped = true;
}
let others: Vec<u32> = self
@@ -232,7 +278,7 @@ impl Game<'_> {
.map(|m| m.id)
.collect();
for o in others {
self.break_leash(o, true, false);
self.break_leash_used(o, true);
dropped = true;
}
let boats = self.boats_on(Holder::Mob(id));
@@ -246,9 +292,16 @@ impl Game<'_> {
return true;
}
}
if self.mobs[index].extra.leash == Some(Holder::Player) {
// Mob.checkAndHandleImportantInteractions: only the player the lead
// is tied to unties it (`getLeashHolder() == player`).
if self.mobs[index].extra.leash == Some(Holder::Player)
&& self.mobs[index]
.extra
.leash_player
.is_none_or(|p| p == self.acting_player_uuid())
{
let creative = self.creative;
self.break_leash(id, !creative, false);
self.break_leash_used(id, !creative);
self.lead_game_event("minecraft:entity_interact", at);
self.lead_sound("untied", at);
return true;
@@ -257,7 +310,7 @@ impl Game<'_> {
let center = self.player_center();
if self.can_attach_lead(index, center) {
if self.mobs[index].extra.leash.is_some() {
self.break_leash(id, true, false);
self.break_leash_used(id, true);
}
let holder = self.acting_player_uuid();
self.mobs[index].extra.leash = Some(Holder::Player);
@@ -301,15 +354,34 @@ impl Game<'_> {
.filter(|&i| self.mobs[i].extra.leash == Some(Holder::Knot(knot_pos)))
.collect();
let existing_boats = self.boats_on(Holder::Knot(knot_pos));
// `LeashFenceKnotEntity.interact` runs only when the ray meets the knot
// before the post: the knot's box (`recalculateBoundingBox`, 0.375 wide,
// y + 0.375 to y + 0.875) is wider than the post, so a hit point on the
// post inside that box went through the knot first. Any other click on
// the fence is `FenceBlock.useWithoutItem` / `LeadItem.useOn`, which only
// ties the player's own mobs (`LeadItem.bindPlayerMobs`).
let knot_hit = !(existing.is_empty() && existing_boats.is_empty()) && {
let box_ = leash::knot_state(knot_pos);
let half = box_.width / 2.0 + 1.0e-6;
(hit.point[0] - box_.position.x).abs() <= half
&& (hit.point[2] - box_.position.z).abs() <= half
&& hit.point[1] >= box_.position.y - 1.0e-6
&& hit.point[1] <= box_.position.y + box_.height + 1.0e-6
// The knot is an entity: a hit on it counts only within the
// entity interaction range (`filterHitResult`), shorter than
// the block range the fence was hit at.
&& self.eye_position().is_some_and(|eye| {
let d = [0, 1, 2].map(|i| hit.point[i] - eye[i]);
d[0] * d[0] + d[1] * d[1] + d[2] * d[2] <= self.entity_reach * self.entity_reach
})
};
let held_item = self.held().map(|s| s.item.to_string());
let sneaking = self.keys.contains(&winit::keyboard::KeyCode::ShiftLeft);
// Shears snip every lead on the knot.
if held_item.as_deref() == Some("minecraft:shears")
&& !(existing.is_empty() && existing_boats.is_empty())
{
if knot_hit && held_item.as_deref() == Some("minecraft:shears") {
for i in existing {
let id = self.mobs[i].id;
self.break_leash(id, true, false);
self.break_leash_used(id, true);
}
self.drop_boat_leashes(&existing_boats);
self.audio
@@ -335,7 +407,7 @@ impl Game<'_> {
attached = true;
}
}
if !attached && !sneaking {
if !attached && !sneaking && knot_hit {
let player = self.player_center();
let holder = self.acting_player_uuid();
for i in existing {
@@ -82,6 +82,13 @@ impl Game<'_> {
}
}
None if &*facts.get(state).name == "minecraft:water" => (p, count * 3, 3.0, 1.0, false),
// A mod block the level answered bone meal for: the port knows no
// `BonemealableBlock.Type` for it, and vanilla's `GROWER` is the
// one a block growing itself has, so the sparks stay inside it
// rather than spreading over its neighbours.
None if !facts.get(state).name.starts_with("minecraft:") => {
(p, count, 0.5, self.block_shape_top(p), true)
}
None => return,
};
self.spawn_particles(at, count, xz, height, floating, ParticleType::HappyVillager);
+17 -12
View File
@@ -411,18 +411,23 @@ impl Game<'_> {
}
self.update_dedicated_streaming();
self.update_world();
result = if paused {
self.tick_lan(true);
Ok(())
} else if k == 0 {
self.tick()
} else {
let running = running.unwrap_or_else(|| self.clock.world_runs());
if running {
self.tick_weather_effects();
}
self.tick_world(running)
};
result = horizon_client::block_contact::in_level(
(self.serving.is_some() || self.options.dedicated.is_some()).then_some(dim.id()),
|| {
if paused {
self.tick_lan(true);
Ok(())
} else if k == 0 {
self.tick()
} else {
let running = running.unwrap_or_else(|| self.clock.world_runs());
if running {
self.tick_weather_effects();
}
self.tick_world(running)
}
},
);
if result.is_err() {
break;
}
+1 -1
View File
@@ -47,7 +47,7 @@ impl Game<'_> {
pub(super) fn tick_thunder(&mut self) {
// isRaining (rain level > 0.2) and isThundering (> 0.9, sky light, no ceiling).
let (rain, thunder) = self.world.weather.levels(1.0);
if rain <= 0.2 || thunder <= 0.9 || self.dim != super::Dim::Overworld {
if rain <= 0.2 || thunder <= 0.9 || self.dim != super::Dim::Overworld || self.world_debug {
return;
}
let mut scratch = std::mem::take(&mut self.spawn_scratch);
+136 -2
View File
@@ -33,6 +33,9 @@ pub(super) enum CartKind {
/// `MinecartCommandBlock`: its `BaseCommandBlock` is `Minecart::command`,
/// run by a powered activator rail (`game/command_minecarts.rs`).
CommandBlock,
/// `MinecartSpawner`: a `BaseSpawner` that ticks where the cart is
/// (`Minecart::spawner`). Only `/summon` makes one: there is no item.
Spawner,
}
impl CartKind {
pub fn from_id(id: &str) -> Option<Self> {
@@ -43,6 +46,7 @@ impl CartKind {
"furnace_minecart" => Self::Furnace,
"tnt_minecart" => Self::Tnt,
"command_block_minecart" => Self::CommandBlock,
"spawner_minecart" => Self::Spawner,
_ => return None,
})
}
@@ -54,6 +58,15 @@ impl CartKind {
Self::Furnace => "minecraft:furnace_minecart",
Self::Tnt => "minecraft:tnt_minecart",
Self::CommandBlock => "minecraft:command_block_minecart",
Self::Spawner => "minecraft:spawner_minecart",
}
}
/// `AbstractMinecart.getDropItem` and `getPickResult`: the item a cart drops and
/// is picked as (a spawner minecart's is a plain minecart: there is no item of its own).
pub fn item(self) -> &'static str {
match self {
Self::Spawner => "minecraft:minecart",
kind => kind.id(),
}
}
pub(super) fn slots(self) -> usize {
@@ -70,7 +83,7 @@ impl CartKind {
/// an empty cart.
pub fn interact_passes(self, sneaking: bool, ridden: bool, game_master: bool) -> bool {
match self {
Self::Tnt => true,
Self::Tnt | Self::Spawner => true,
Self::CommandBlock => !game_master,
Self::Rideable => sneaking || ridden,
Self::Chest | Self::Hopper | Self::Furnace => false,
@@ -119,6 +132,9 @@ pub(super) struct Minecart {
/// An activator rail asked for the command during this tick; it runs once
/// the cart is back in the level (`command_minecarts.rs`).
pub command_due: bool,
/// Spawner minecarts: the cart's `BaseSpawner` (`Delay`, `SpawnData`,
/// `SpawnPotentials` and the rest as a spawner block saves them).
pub spawner: Option<Box<horizon_client::mobs::spawner::Spawner>>,
/// The synched `DATA_ID_COMMAND_NAME` and `DATA_ID_LAST_OUTPUT`, which
/// follow the command block only at `onUpdated`.
pub synced_command: String,
@@ -255,6 +271,8 @@ impl Minecart {
tick_count: 0,
last_activated: 0,
command_due: false,
// A fresh `BaseSpawner`, as `MinecartSpawner`'s constructor makes.
spawner: (kind == CartKind::Spawner).then(Default::default),
synced_command: String::new(),
synced_output: String::new(),
}
@@ -293,6 +311,7 @@ impl CartModels {
CartKind::Furnace,
CartKind::Tnt,
CartKind::CommandBlock,
CartKind::Spawner,
];
let models = kinds
.into_iter()
@@ -906,6 +925,18 @@ impl Game<'_> {
}
}
}
// MinecartSpawner.tick: `BaseSpawner.serverTick` at the cart's block.
CartKind::Spawner => {
if let Some(mut spawner) = c.spawner.take() {
let key = (
c.position.x.floor() as i32,
c.position.y.floor() as i32,
c.position.z.floor() as i32,
);
let _ = self.server_tick_spawner_of(key, &mut spawner, Some(c.id));
c.spawner = Some(spawner);
}
}
_ => {}
}
true
@@ -963,6 +994,7 @@ impl Game<'_> {
if let Some(i) = self.carts.iter().position(|x| x.id == c.id) {
self.eject_cart_mobs(i);
}
self.release_vehicle_container(c.id);
self.carts.retain(|x| x.id != c.id);
// ExplosionInteraction.TNT: the tnt_explosion_drop_decay rule.
let interaction = self.destroy_type("tnt_explosion_drop_decay");
@@ -1226,6 +1258,7 @@ impl Game<'_> {
}
} else {
self.eject_cart_mobs(i);
self.release_vehicle_container(id);
self.carts.remove(i);
}
}
@@ -1331,6 +1364,7 @@ impl Game<'_> {
self.push_from_cart(i);
} else {
self.eject_cart_mobs(i);
self.release_vehicle_container(c.id);
self.carts.remove(i);
self.riding = None;
}
@@ -1539,6 +1573,8 @@ impl Game<'_> {
// client opens the editor, `open_cart_command_block`), anyone else
// PASSes to the held item. SUCCESS swings on the client only.
CartKind::CommandBlock => return self.can_use_game_master_blocks(),
// MinecartSpawner has no interact of its own (`Entity.interact` passes).
CartKind::Spawner => return false,
}
self.swing_arm();
true
@@ -1577,6 +1613,19 @@ impl Game<'_> {
self.store_cart_items(key);
self.world.block_entities.remove(&key);
}
/// A chest or hopper minecart or a chest boat is about to be removed
/// (`Containers.dropContents` / `AbstractChestBoat.remove`): what an open
/// menu edited goes back to the vehicle, so it is what drops or travels
/// with it, and the menu's stand-in leaves the level. A menu that is
/// still open over it then has nothing left to take.
pub(super) fn release_vehicle_container(&mut self, id: u32) {
let key = (i32::MAX - 15, CART_KEY_Y, id as i32);
self.store_cart_items(key);
self.world.block_entities.remove(&key);
if self.container == Some(key) {
self.container = None;
}
}
/// The menu of a chest or hopper minecart or a chest boat works on the
/// vehicle's own items (`ContainerEntity`): the placeholder under `key`
/// that the menu edits is copied back to the vehicle.
@@ -1644,6 +1693,8 @@ impl Game<'_> {
}
if burning && self.carts[i].kind == CartKind::Tnt {
self.eject_cart_mobs(i);
let id = self.carts[i].id;
self.release_vehicle_container(id);
let c = self.carts.remove(i);
self.explode_cart(&c, arrow_velocity.length_squared());
return;
@@ -1669,6 +1720,10 @@ impl Game<'_> {
if !tnt_prime {
self.eject_cart_mobs(i);
}
let id = self.carts[i].id;
if !tnt_prime {
self.release_vehicle_container(id);
}
let mut c = self.carts.remove(i);
if c.kind == CartKind::Tnt && (fire || speed >= f64::from(0.01f32)) {
// MinecartTNT.destroy: prime instead of breaking.
@@ -1685,7 +1740,7 @@ impl Game<'_> {
self.spawn_item(s.clone(), at, [0.0, 0.1, 0.0], 10);
}
if !creative {
self.spawn_item(ItemStack::new(c.kind.id(), 1), at, [0.0, 0.1, 0.0], 10);
self.spawn_item(ItemStack::new(c.kind.item(), 1), at, [0.0, 0.1, 0.0], 10);
}
}
}
@@ -1805,6 +1860,8 @@ impl Game<'_> {
),
CartKind::Tnt => registry.default_state("minecraft:tnt"),
CartKind::CommandBlock => registry.default_state("minecraft:command_block"),
// MinecartSpawner.getDefaultDisplayBlockState.
CartKind::Spawner => registry.default_state("minecraft:spawner"),
}
}
/// `AbstractMinecartRenderer` (old render): on the rail between the
@@ -2027,6 +2084,16 @@ impl Minecart {
}
}
}
// BaseSpawner.save: the delays, ranges, SpawnData and SpawnPotentials.
CartKind::Spawner => {
if let Some(spawner) = &self.spawner {
for (key, value) in
horizon_client::mobs::spawner::SpawnerExt::save(&**spawner)
{
put(key, value);
}
}
}
CartKind::Rideable => {}
}
// A loaded loot table goes back while the items are still its roll;
@@ -2071,6 +2138,12 @@ impl Minecart {
c.synced_command.clone_from(&c.command.command);
c.synced_output = c.command.last_output_string();
}
// MinecartSpawner.readAdditionalSaveData: BaseSpawner.load.
if kind == CartKind::Spawner {
c.spawner = Some(Box::new(horizon_client::mobs::spawner::SpawnerExt::load(
tag,
)));
}
Some(c)
}
}
@@ -2103,6 +2176,67 @@ mod tests {
assert_eq!(display_offset(CartKind::Rideable), 6.0);
}
/// `MinecartSpawner`: its `BaseSpawner` is saved and loaded with the cart (the delay, the
/// ranges and the `SpawnData`), it drops and is picked as a plain minecart, and it shows a
/// spawner block.
#[test]
fn a_spawner_cart_keeps_its_spawner_and_drops_a_minecart() {
use horizon_world::nbt::Tag;
let saved = Tag::compound([
("id", Tag::String("minecraft:spawner_minecart".into())),
(
"Pos",
Tag::List(
[0.5f64, 40.5, 0.5]
.map(|v| Tag::Double(v.to_bits()))
.to_vec(),
),
),
("Delay", Tag::Short(37)),
("MaxSpawnDelay", Tag::Short(120)),
("SpawnRange", Tag::Short(2)),
(
"SpawnData",
Tag::compound([(
"entity",
Tag::compound([("id", Tag::String("minecraft:zombie".into()))]),
)]),
),
]);
let mut cart = Minecart::from_nbt(&saved, 1, CartKind::Spawner).unwrap();
let spawner = cart.spawner.as_mut().expect("a spawner cart has a spawner");
assert_eq!(
(
spawner.spawn_delay,
spawner.max_spawn_delay,
spawner.spawn_range
),
(37, 120, 2)
);
// No raw tag to copy from: what comes out is the cart's own save.
spawner.spawn_delay = 41;
let out = cart.to_nbt([0; 4], None);
assert_eq!(out.get("Delay"), Some(&Tag::Short(41)));
assert_eq!(out.get("MaxSpawnDelay"), Some(&Tag::Short(120)));
assert_eq!(
out.get("SpawnData")
.and_then(|d| d.get("entity"))
.and_then(|e| e.get("id")),
Some(&Tag::String("minecraft:zombie".into()))
);
assert_eq!(CartKind::Spawner.item(), "minecraft:minecart");
assert_eq!(
CartKind::from_id("minecraft:spawner_minecart"),
Some(CartKind::Spawner)
);
// A summoned one has a spawner of its own.
assert!(
Minecart::new(2, CartKind::Spawner, Vec3::ZERO)
.spawner
.is_some()
);
}
#[test]
fn a_cow_boards_an_empty_cart_and_an_iron_golem_does_not() {
assert!(pickup_instead_of_push(Species::Cow, false, false));
+21 -27
View File
@@ -14,10 +14,7 @@ use horizon_client::{
mobs::{
Mob, Species,
equip::{self, Facts, ItemTags, Slot},
finalize::{
self, set_drop_chance, set_item, set_slot_enchantments, slot_enchantments,
stored_drop_chance,
},
finalize::{self, set_drop_chance, set_item_stack, stored_drop_chance},
},
};
use horizon_core::{geometry::Vec3, random::RandomSource};
@@ -92,27 +89,29 @@ impl Game<'_> {
if !replace {
return None;
}
// shortcut: a mob's equipment is one item per slot here, so a hand
// takes one item of a stack where vanilla takes the whole stack;
// upgrade with a per-slot count in `Extra`.
let taken = 1;
// `EquipmentSlot.limit`: an armour slot takes one item, a hand the
// whole stack.
let taken = if slot.is_armor() {
1
} else {
stack.count.max(1)
};
let m = &mut self.mobs[index];
let old = finalize::item(m, slot.index());
let old = finalize::item_stack(m, slot.index());
let chance = stored_drop_chance(m, slot.index());
let old_enchantments = slot_enchantments(m, slot.index());
// `DropChances.byEquipment` against the roll the equip makes.
let drop_old = old.is_some() && (m.random.next_float() - 0.1).max(0.0) < chance;
set_item(m, slot.index(), item);
// The stack's enchantments come with it: a Sharpness V sword stays sharp.
set_slot_enchantments(m, slot.index(), stack.enchantments().to_vec());
// The whole stack comes with it: its enchantments (a Sharpness V sword
// stays sharp), damage, name and components.
let mut equipped = stack.clone();
equipped.count = taken;
set_item_stack(m, slot.index(), equipped);
// `setItemSlotAndDropWhenKilled`, then `persistenceRequired`.
set_drop_chance(m, slot.index(), 2.0);
m.persistent = true;
let at = [m.position.x, m.position.y, m.position.z];
if drop_old && let Some(old) = old {
let mut dropped = ItemStack::new(&old, 1);
dropped.set_crafting_enchantments(old_enchantments);
self.spawn_item(dropped, at, super::dispenser::new_item_motion(), 10);
self.spawn_item(old, at, super::dispenser::new_item_motion(), 10);
}
Some(taken)
}
@@ -120,9 +119,10 @@ impl Game<'_> {
/// `Mob.canReplaceCurrentItem` for what `m` wears or holds in `slot`.
fn can_replace(&self, index: usize, stack: &ItemStack, slot: Slot) -> bool {
let m = &self.mobs[index];
let Some(current) = finalize::item(m, slot.index()) else {
let Some(worn_stack) = finalize::item_stack(m, slot.index()) else {
return true;
};
let current = worn_stack.item.clone();
let species = m.species();
// `Drowned.canReplaceCurrentItem` never parts with a nautilus shell.
if species == Species::Drowned && &*current == "minecraft:nautilus_shell" {
@@ -140,21 +140,15 @@ impl Game<'_> {
// Binding's, the only enchantment that carries one.
prevents_armor_change: stack.enchantment_level("minecraft:binding_curse") > 0,
};
let worn_enchantments = slot_enchantments(m, slot.index());
let (armor, toughness, attack) = self.attributes(m, &current, slot);
// shortcut: what a mob already wears keeps no damage value or name here
// (`Extra` records neither), so a worn piece always compares as fresh;
// upgrade with a stack per equipment slot.
let worn = Facts {
armor,
armor_toughness: toughness,
attack_damage: attack,
enchantments: worn_enchantments.len(),
damage: 0,
custom_name: false,
prevents_armor_change: worn_enchantments
.iter()
.any(|(id, level)| &**id == "minecraft:binding_curse" && *level > 0),
enchantments: worn_stack.enchantments().len(),
damage: worn_stack.damage as i32,
custom_name: worn_stack.custom_name().is_some(),
prevents_armor_change: worn_stack.enchantment_level("minecraft:binding_curse") > 0,
};
let preferred = equip::preferred_weapon_tag(species).map(|tag| {
(
+21
View File
@@ -103,12 +103,14 @@ pub(super) fn content_root(
}
register_mob_effects(&mods);
register_entity_types(&mods);
register_menu_types(&mods);
out
}
Err(e) => {
log::warn!("Horizon: content mods not loaded: {e}");
register_mob_effects(&[]);
register_entity_types(&[]);
register_menu_types(&[]);
root.to_owned()
}
}
@@ -161,6 +163,25 @@ fn register_entity_types(mods: &[horizon_client::mod_content::ContentMod]) {
}
}
/// the mods' menu types, as their entity types: set once, from the startup
/// root.
fn register_menu_types(mods: &[horizon_client::mod_content::ContentMod]) {
use horizon_client::containers;
let menus = horizon_client::mod_content::menu_types(mods);
let set = containers::register_mod_menu_types(menus.iter().map(|m| {
let results = m.results.iter().map(|&r| usize::from(r)).collect();
(m.id.clone(), m.slots.into(), m.data_slots.into(), results)
}));
if !set
&& containers::mod_menu_types()
.iter()
.map(|m| m.id)
.ne(menus.iter().map(|m| m.id.as_str()))
{
log::warn!("Horizon: the mods' menu types changed; restart the game to use them");
}
}
/// What the launcher writes beside a game it built with native mods: their ids under `mods`.
const BUILD_INFO: &str = "native-mods.json";
@@ -319,6 +319,16 @@ impl Game<'_> {
v.extend((0..open.columns * 3).map(|i| CHEST + i));
Some(v)
}
/// How many slots `mount_slots` lists, counted without building it
/// (`Game::slot_count` asks every frame).
pub(super) fn mount_slot_count(&self) -> Option<usize> {
let open = self
.mount_menu
.open
.as_ref()
.filter(|_| self.mount_active())?;
Some(36 + usize::from(open.show_saddle) + usize::from(open.show_armor) + open.columns * 3)
}
pub(super) fn mount_slot_position(&self, i: usize) -> Option<(f32, f32)> {
let open = self
.mount_menu
+25 -6
View File
@@ -17,6 +17,15 @@ use horizon_core::{
random::RandomSource,
};
fn steering_item(species: Species, main: Option<&str>, offhand: Option<&str>) -> bool {
let item = match species {
Species::Pig => "minecraft:carrot_on_a_stick",
Species::Strider => "minecraft:warped_fungus_on_a_stick",
_ => return false,
};
main == Some(item) || offhand == Some(item)
}
/// `passengerAttachments` minus the player's 0.6 vehicle attachment.
fn seat(s: Species) -> f64 {
let attach = match s {
@@ -367,12 +376,11 @@ impl Game<'_> {
let strafe = f32::from(u8::from(keys.left)) - f32::from(u8::from(keys.right));
let forward = f32::from(u8::from(keys.forward)) - f32::from(u8::from(keys.backward));
// LocalPlayer.aiStep: the riding jump charges while space is held.
let held = self.held().map(|s| s.item.clone());
let steer = match self.mobs[i].species() {
Species::Pig => held.as_deref() == Some("minecraft:carrot_on_a_stick"),
Species::Strider => held.as_deref() == Some("minecraft:warped_fungus_on_a_stick"),
_ => false,
};
let steer = steering_item(
self.mobs[i].species(),
self.held().map(|s| s.item.as_ref()),
self.offhand.as_ref().map(|s| s.item.as_ref()),
);
let jump = self.charge_mount_jump(i, keys);
let m = &mut self.mobs[i];
if jump > 0.0 {
@@ -839,6 +847,17 @@ mod tests {
use super::*;
use std::collections::HashMap;
#[test]
fn steering_accepts_the_correct_item_in_either_hand() {
let carrot = "minecraft:carrot_on_a_stick";
let fungus = "minecraft:warped_fungus_on_a_stick";
assert!(steering_item(Species::Pig, Some(carrot), None));
assert!(steering_item(Species::Pig, None, Some(carrot)));
assert!(steering_item(Species::Strider, None, Some(fungus)));
assert!(!steering_item(Species::Pig, Some(fungus), None));
assert!(!steering_item(Species::Horse, Some(carrot), Some(fungus)));
}
/// A lava sea up to y 30 with full blocks placed in it; air above.
struct Sea {
blocks: HashMap<[i32; 3], ()>,
Loaded 100 of 335 files, more files were not shown because too many files have changed in this diff. Show more