Compare commits

...
Author SHA1 Message Date
Ludovic MarieandClaude Opus 5.5 85bfd116a7 Server: the newcomer doesn't read its own join line, as in vanilla
PlayerList.placeNewPlayer calls broadcastSystemMessage before players.add,
so the joining player isn't in the list yet and never gets its own
"joined the game". #4634 sent it to the newcomer too, which the
dedicated_server harness (the real 26.3 server's packets) shows as an
extra system_chat at play start. Back to the others only, at the same
point as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 04:02:20 +01:00
levzzz 3dc5b432fe Merge pull request #4907 from levzzz5154/parity/K4-exact-components
Hash exact component predicates in container and bundle_contents predicates
2026-10-31 07:41:12 +05:00
levzzz d454cd53fb Merge pull request #4909 from levzzz5154/parity/B3-oit-entities
Improved transparency: entity render types with an OIT pipeline set
2026-10-31 07:38:47 +05:00
rdbt deff92036a Merge pull request #4551 from foudretbr/fox-night-screech
Foxes screech at night when no player is near
2026-10-31 02:34:55 +00:00
rdbt 230d0d5911 Merge pull request #4643 from Joansitoh/fix/arrow-pickup-animation
Arrows fly into whoever picks them up on servers and the server thread
2026-10-31 02:34:51 +00:00
rdbt fcd9f1b401 Merge pull request #4661 from DenisHumen/fix/bundle-wheel-in-stations
Bundles: the wheel selects in a crafting table's and a station's menu
2026-10-31 02:34:47 +00:00
rdbt 68f554e7dc Merge pull request #4546 from MSedgeMC/chore/ledger-stale-rows
Retarget ledger rows that are already ported
2026-10-31 02:34:43 +00:00
rdbt ee637b2567 Merge pull request #4857 from mulareal/fix-death-sound-4570
death: play the player death sound only once on fatal damage
2026-10-31 02:34:39 +00:00
rdbt cc6bfe2bc3 Merge pull request #4696 from WooCaash/fix/4684-bucket-empty-sound
Play empty-bucket sounds on the predicting client
2026-10-31 02:34:35 +00:00
rdbt 57b2aa470b Merge pull request #4866 from Iam-Sanja/fix/dedicated-playerdata-overwrite
fix: prevent dedicated world saves from overwriting player data
2026-10-31 02:34:31 +00:00
rdbt c8ad6a8840 Merge pull request #4879 from xFedeT/fix/server-level-dat-fallback
A dedicated server takes level.dat_old when level.dat does not read
2026-10-31 02:34:27 +00:00
rdbt 57064f15d0 Merge pull request #4662 from WooCaash/fix/4509-cushion-swim-pose
Sit up on a cushion after swimming
2026-10-31 02:34:23 +00:00
rdbt 21f460c630 Merge pull request #4659 from WooCaash/fix/4548-sulfur-cube-fuse-anim
Sync SulfurCube MAX_FUSE for the client fuse swell
2026-10-31 02:34:19 +00:00
rdbt d05e95409d Merge pull request #4730 from WooCaash/fix/4714-server-day-night
Fix inverted day/night on multiplayer servers
2026-10-31 02:34:15 +00:00
rdbt f4852b0660 Merge pull request #4512 from xFedeT/fix/random-stand-parity
RandomStandGoal parity: named as vanilla's, herd scenarios for the equines
2026-10-31 02:34:09 +00:00
rdbt 9304a5387e Merge pull request #4876 from josegil1909/fix/4821-horse-footstep-sounds
fix(client): play horse footstep sounds while riding
2026-10-31 02:34:04 +00:00
rdbt 6b25a75238 Merge pull request #4895 from horizon-rust/fix/villager-show-trades-offers
Roll a villager's offers when it shows trades
2026-10-31 02:34:00 +00:00
levzzzandClaude Sonnet 5.5 36e376591a Merge hr/main into parity/K4-exact-components
Replaces the name-based COMPONENT_BOOLS heuristic with the per-component codec shapes.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 07:31:26 +05:00
rdbt 926875ace4 Merge pull request #4710 from WooCaash/fix/4694-dedicated-peaceful-difficulty
Apply server.properties difficulty on dedicated startup
2026-10-31 02:30:07 +00:00
levzzzandClaude Sonnet 5.5 1b4ef1de92 Improved transparency: entity render types with an OIT pipeline set
Translucent, emissive, eyes and breeze wind entity quads, lightning and the
additive dragon geometry go through the OIT stages when the option is on.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 07:29:37 +05:00
levzzzandClaude Sonnet 5.5 91e1f8a8cd Hash exact component predicates nested in container and bundle_contents predicates
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 07:27:05 +05:00
rdbt 575c9b20df Merge pull request #4892 from sirrayi/fix/golem-block-pattern
Golems: express the golem patterns with the shared BlockPattern finder
2026-10-31 02:25:13 +00:00
rdbt e65680f925 Merge pull request #4761 from arismoko/tidy-native-particle-queue
Tidy the native particle queue
2026-10-31 02:24:52 +00:00
rdbt e24e645156 Merge pull request #4864 from sirrayi/fix/redstone-piston-retract
Redstone: a retracting piston no longer breaks the dust on top of it
2026-10-31 02:24:48 +00:00
rdbt d5ff486d31 Merge pull request #4660 from DenisHumen/fix/looting-mob-drops
Looting on the killer's weapon counts on a server's mobs
2026-10-31 02:24:43 +00:00
rdbt b477ff2689 Merge pull request #4885 from ErikKalikas/launcher-native-build-offline
Launcher: keep native mods' commit dates so a kept build works offline
2026-10-31 02:23:54 +00:00
rdbt 8a65204b3a Merge pull request #4884 from xFedeT/fix/hash-container-predicate
Hash a container or bundle_contents predicate whose item predicate has exact components
2026-10-31 02:23:50 +00:00
rdbt 8115a3a4b1 Merge pull request #4779 from WooCaash/fix/4343-controller-right-stick-look
Fix 8BitDo Ultimate 2 right-stick look on Linux
2026-10-31 02:23:46 +00:00
rdbt 65bc3d80b5 Merge pull request #4837 from mulareal/fix-slime-attack-sound-4569
mobs: play slime and magma cube attack sound only when hurtServer succeeds
2026-10-31 02:23:09 +00:00
rdbtCVSandClaude Opus 5.5 0a6d8aa90a Dedicated startup: a hardcore world stays hard
MinecraftServer.setDifficulty forces HARD for a hardcore world, so
difficulty=peaceful in server.properties must not make one peaceful.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 02:22:36 +00:00
rdbt 497e13808f Merge pull request #4634 from cryingtoilet/fix/4536-own-join-message
Send a joining player its own join message (#4536)
2026-10-31 02:21:45 +00:00
rdbt 90ca4e0491 Merge pull request #4697 from horizon-rust/fix/floating-unloaded-chunks
Floating kick: a chunk not loaded yet is not air
2026-10-31 02:21:30 +00:00
rdbt 23d5461fe0 Merge pull request #4748 from sirrayi/lod-sampler-cache
LOD: skip density work for tiles wholly inside visited chunks
2026-10-31 02:21:26 +00:00
rdbt 65688ae0f0 Merge pull request #4744 from apeirs/fluid-spread-no-clones
Fluids: spread without cloning shapes or locking per step
2026-10-31 02:21:22 +00:00
rdbt 8b718c5d5e Merge pull request #4841 from horizon-rust/fix/native-build-no-console
Launcher: native builds open no console window on Windows
2026-10-31 02:20:47 +00:00
rdbt c5d7bae5fe Merge pull request #4608 from k3yh/fix/vulkan-acquire-fence-fifo-only
Windows Vulkan: wait for the acquire fence only with VSync (gfx-rs/wgpu#10714)
2026-10-31 02:20:43 +00:00
rdbt b296f7e2ea Merge pull request #4875 from reservedbytes/tooling/pr-ready-rust-version
fix(tools): pr_ready says when local rust is older than ci's
2026-10-31 02:20:39 +00:00
rdbt 0f4db0c569 Merge pull request #4641 from WooCaash/fix/4565-enderman-boat-sit
Sit an enderman that rides a boat
2026-10-31 02:20:36 +00:00
rdbt f541e240fb Merge pull request #4819 from Qwerner88880/fix-item-command-guest-slots
/item reaches a connected player's slots
2026-10-31 02:20:32 +00:00
levzzz 0ba58d9229 Merge pull request #4898 from levzzz5154/parity/ime-preedit
IME: show composition text under the caret and place the candidate window
2026-10-31 07:20:30 +05:00
rdbt 450d8c35dc Merge pull request #4692 from doodlyyousuf/fix/4421-boat-ice-slide
Boats slide on ice (getGroundFriction)
2026-10-31 02:20:28 +00:00
levzzz e280d96cf2 Merge pull request #4900 from levzzz5154/parity/T5-gametest-dims
Game tests run in any dimension
2026-10-31 07:20:20 +05:00
levzzz 241684332d Merge pull request #4897 from levzzz5154/parity/B2-oit-features
Improved transparency: route features, particles, weather, world border and flat draws
2026-10-31 07:19:59 +05:00
levzzz 59ec871592 Merge pull request #4901 from levzzz5154/parity/place-entities
/place structure and /place jigsaw: spawn the entities the pieces carry
2026-10-31 07:18:13 +05:00
rdbt cbf9b8d5ab Merge pull request #4762 from josegil1909/fix/es-sponsor-chip-overflow
fix(launcher): shorten Spanish sponsor discount label to prevent chip overflow
2026-10-31 02:17:44 +00:00
rdbt 00643bca10 Merge pull request #4769 from josegil1909/fix/launcher-telemetry-notice-layout
fix(launcher): constrain telemetry notice width in settings to prevent overflow
2026-10-31 02:17:39 +00:00
rdbt 7ac8e3a30a Merge pull request #4606 from apeirs/auto-upload-time-cap
Chunk uploads: a 2 ms cap per frame when the player set no budget
2026-10-31 02:17:35 +00:00
rdbt 1fdcf6b385 Merge pull request #4630 from WooCaash/fix/4550-tnt-slime-bounce
Bounce primed TNT when a slime piston hits it
2026-10-31 02:17:31 +00:00
rdbt 6d669b0a59 Merge pull request #4695 from WooCaash/fix/4681-strip-sound-once
Play axe strip sounds only once for the actor
2026-10-31 02:17:27 +00:00
rdbt 93b9775439 Merge pull request #4709 from WooCaash/fix/4508-slime-honey-item-texture
Cull back faces on translucent slime/honey items
2026-10-31 02:16:57 +00:00
rdbt c723e0894c Merge pull request #4701 from WooCaash/fix/4688-cherry-grove-lod-tint
Keep cherry leaf pink in far LOD terrain
2026-10-31 02:16:53 +00:00
rdbt 6f1e0a2463 Merge pull request #4734 from WooCaash/fix/4190-dragon-crystal-rays
Clear end crystal revival beams when dragon respawn ends
2026-10-31 02:16:49 +00:00
rdbt 08191e2f40 Merge pull request #4640 from cryingtoilet/fix/4562-activator-rail-eject
Place a rider ejected by an activator rail beside the cart (#4562)
2026-10-31 02:16:45 +00:00
rdbt 439e0ee259 Merge pull request #4771 from reservedbytes/fix/starvation-recorded-once
fix(combat): starvation is recorded once
2026-10-31 02:16:41 +00:00
rdbt a18e7d5d1f Merge pull request #4874 from DmytroChup/fix-4777
Apply equipment attribute modifiers from the off hand
2026-10-31 02:16:37 +00:00
Ludovic Marie ec27a1e0b0 Merge pull request #4890 from horizon-rust/fix/decode-limits
🤖 Agent: unit tests: a 32768-byte payload (play and configuration) and a 32501-char suggestion are refused, the limits pass; 8 emoji pass a 16-unit limit and fail 15. 3 server harnesses pass; pr_ready --quick green.
2026-10-31 03:14:35 +01:00
Ludovic Marie 1d4b04ceb3 Merge pull request #4888 from horizon-rust/fix/container-click-slot-range
🤖 Agent: proved with tools/server_container_click_e2e.py on a dedicated server: a THROW click on a chest's slot 63 no longer throws inventory slot 9 (it did on main); lan_bot a_click_past_the_menu_is_ignored fails on main's containers.rs and passes here. 3 server harnesses and lan_e2e chest/containers/large_chest/shulker_menu/clicks pass; menu-transfer fails the same on main (its stale count takes the armour ids 500-504).
2026-10-31 03:14:32 +01:00
rdbtCVS 07c8bdbc0b Merge main 2026-10-31 02:14:21 +00:00
rdbtCVS adfda4bd26 Merge main 2026-10-31 02:14:20 +00:00
rdbtCVS 502a3026aa Merge main 2026-10-31 02:14:18 +00:00
rdbtCVS 9d9fe2298e Merge main 2026-10-31 02:14:16 +00:00
levzzz 3839d80076 Merge remote-tracking branch 'hr/main' into parity/ime-preedit 2026-10-31 07:11:54 +05:00
levzzz 26752debe1 Merge remote-tracking branch 'hr/main' into parity/place-entities 2026-10-31 07:11:54 +05:00
levzzz 65d68266e2 Merge remote-tracking branch 'hr/main' into parity/B2-oit-features 2026-10-31 07:11:46 +05:00
levzzz c418e970be Merge remote-tracking branch 'hr/main' into parity/T5-gametest-dims 2026-10-31 07:11:26 +05:00
rdbt 8930bb3faf Merge pull request #4902 from horizon-rust/fix/streaming-section-mask-build
Fix main's build: meshing candidates carry a SectionMask
2026-10-31 02:11:22 +00:00
rdbtCVSandClaude Opus 5.5 cd9867da5c Fix main's build: meshing candidates carry a SectionMask
#3961 typed the candidates' sections as u32 while the level-shape work made
a column's dirty sections a SectionMask; SectionMask now orders so
nearest_first can sort them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 02:05:11 +00:00
levzzzandClaude Sonnet 5.5 1ee73d821d Spawn the entities /place structure and /place jigsaw's pieces carry
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 07:04:39 +05:00
levzzz 7680fee325 Merge remote-tracking branch 'hr/main' into parity/B2-oit-features 2026-10-31 07:04:12 +05:00
levzzzandClaude Sonnet 5.5 860dbf51e1 Fix main's build: streaming candidates hold a SectionMask
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 07:04:05 +05:00
levzzzandClaude Sonnet 5.5 3ef3272ee2 Game tests run in any dimension: per-dimension grids, level swapped in for placement, levels with tests keep ticking
Also fixes the streaming candidate list type (SectionMask) that broke main.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 07:03:33 +05:00
levzzzandClaude Sonnet 5.5 9cc33bdda8 Fix main's build: streaming candidates hold a SectionMask
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 07:02:31 +05:00
levzzz 2d621405c6 Merge remote-tracking branch 'hr/main' into parity/ime-preedit 2026-10-31 07:00:51 +05:00
levzzzandClaude Sonnet 5.5 a0b0a5ba50 Show the input method's composition under the caret and place its candidate window there
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 07:00:51 +05:00
levzzzandClaude Sonnet 5.5 5fbb18a139 Improved transparency: route features, particles, weather, border and flat draws
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 07:00:12 +05:00
levzzz 1fdd195790 Merge pull request #4527 from kacpherek/fix/screens-batch
Screens, HUD and chat parity: 29 fixes (toasts, chat trust, recipe book, bundles, world list, sounds)
2026-10-31 06:59:04 +05:00
levzzz 95fe89ca0d Merge pull request #4886 from levzzz5154/parity/T4-test-highlight
/test pos: the highlight packet and the client's block highlight
2026-10-31 06:57:48 +05:00
rdbtCVSandClaude Sonnet 5.5 cfb61d623a Roll a villager's offers when it shows trades
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 01:57:38 +00:00
levzzz 711f099ac8 Merge pull request #4342 from MSedgeMC/fix/4309-place-structure
Wire /place structure to the live level
2026-10-31 06:56:49 +05:00
sirrayiandDeepSeek Harness 4f43f49cf7 Redstone: a retracting piston no longer breaks the dust on top of it
PistonBaseBlock.triggerEvent writes the retracting base as a `moving_piston`
and still updates the neighbours around it, so the dust above the base is asked
whether it can survive. The exported support facts of a moving block carry no
block entity and call every face unsupported, while vanilla reads that block
through PistonMovingBlockEntity.getCollisionShape, whose shape holds the
extended base; the dust dropped and was removed (#4728).

Both dust support checks now read the block through Sim::support_state, the
indirection #/5782df1c0 added for the same class of mistake on a lever: the
survive check uses sturdy(below, Up) and updateShape's down arm passes the
position instead of the neighbour's raw state.

Generated with DeepSeek Harness (deepseek-v4.1-flash)

Co-Authored-By: DeepSeek Harness <[email protected]>
2026-10-31 01:56:00 +00:00
rdbt ad7b45d3b6 Merge pull request #3961 from WhyFovius/perf/chunk-streaming-priority
Streaming: light and mesh jobs bounded in flight, nearest first
2026-10-31 01:54:27 +00:00
rdbt 35fbfb00dd Merge pull request #3987 from senjinthedragon/hdr-shortcut
HDR: a shortcut key to its settings, unbound by default
2026-10-31 01:54:21 +00:00
rdbt 9ca4d10841 Merge pull request #4177 from Domanffe/feat/game-ru-strings
Graphics screens: their text through the language table
2026-10-31 01:54:15 +00:00
rdbt 9e2c0dfde2 Merge pull request #4148 from Domanffe/fix/launcher-language-row
Launcher: the language row is a dropdown, not a segment per language
2026-10-31 01:54:11 +00:00
rdbt 7b52aa1f85 Merge pull request #4175 from DUzzL/fix/issue-4174-online-servers-default
Check Minecraft accounts for new launcher servers by default
2026-10-31 01:54:06 +00:00
rdbt 05a7663aab Merge pull request #4062 from senjinthedragon/hdr-blocks-2
HDR: light sources tiered from a look at each in game
2026-10-31 01:54:02 +00:00
rdbt 2f82051fb4 Merge pull request #4060 from senjinthedragon/hdr-moon-2
HDR: the moon shines at the low tier, not the sun's
2026-10-31 01:53:57 +00:00
rdbt f1b2d7b86f Merge pull request #4061 from senjinthedragon/hdr-eyes-2
HDR: mobs' glowing eyes shine like glowstone
2026-10-31 01:53:53 +00:00
rdbt 223a97787d Merge pull request #4415 from SlaVKsVolks/contribute/horizon-ledger-jar-20261030
Port ledger: support builds with JAR-only inputs
2026-10-31 01:53:48 +00:00
rdbt e5e27a7aac Merge pull request #4426 from SlaVKsVolks/contribute/horizon-benchmark-conditions-20261030
Benchmark: reject comparisons with incompatible conditions
2026-10-31 01:53:43 +00:00
Ludovic MarieandClaude Opus 5.5 a65989354d Packets past vanilla's decoder limits disconnect
What vanilla's codecs refuse, so its decoder disconnects the player:
- a custom payload over 32767 bytes (DiscardedPayload), in configuration
  and in play;
- a command suggestion request over 32500 characters (it was ignored);
- a string longer than its limit in Java's UTF-16 units: a character
  outside the BMP counts twice (Utf8String.read), where Horizon counted
  Unicode characters.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 02:51:11 +01:00
levzzzandClaude Sonnet 5.5 83c79c4d4f Add restore_held_keys to the capture test's fixture
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 06:49:42 +05:00
levzzz 5b372e2f35 Merge pull request #4883 from levzzz5154/parity/B2-oit
Improved transparency: water mask and cloud passes
2026-10-31 06:49:15 +05:00
levzzzandClaude Sonnet 5.5 e3eb634eba /test pos: ClientboundGameTestHighlightPosPacket and the client's block highlight
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 06:47:28 +05:00
levzzzandClaude Sonnet 5.5 c00e5801aa Merge hr/main into fix/screens-batch
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 06:46:50 +05:00
rdbt b1f7c78d54 Merge pull request #4306 from MSedgeMC/fix/sheared-undercoat
Keep the sheep undercoat when the sheep is sheared
2026-10-31 01:46:39 +00:00
rdbt f9676a54ce Merge pull request #4197 from kacpherek/fix/gameplay-world-scale
Scale attribute: the view-blocking check follows the player's scale
2026-10-31 01:46:31 +00:00
rdbt b9f5a45c62 Merge pull request #4268 from DUzzL/fix/issue-4254-scaffolding-sneak-descend
Let a sneaking player descend through scaffolding on the server
2026-10-31 01:46:22 +00:00
rdbt b357010174 Merge pull request #4137 from DUzzL/fix/issue-4127-armor-stand-passenger
Seat armour-stand name holograms on the player they ride
2026-10-31 01:46:14 +00:00
levzzz 9b611a200b Merge remote-tracking branch 'hr/main' into fix/pr-4342 2026-10-31 06:44:26 +05:00
levzzz 2fc88ea632 Merge pull request #4881 from levzzz5154/parity/T3-test-env
Game tests: test environments
2026-10-31 06:44:15 +05:00
levzzz 10a4ec4e2d Merge pull request #4347 from DUzzL/fix/issue-4338-ime-commit
Accept IME text composition in the game's text fields
2026-10-31 06:44:11 +05:00
levzzzandClaude Sonnet 5.5 5ddb4828b5 Fix clippy collapsible ifs; open the Optimize test's world editor before its data goes stale
Edit is disabled for a world that needs file fixing (LevelSummary.canEdit),
so the test now opens the edit screen first.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 06:43:10 +05:00
ErikKalikasandClaude Opus 5.5 9bb2c8818d Launcher: keep commit dates so a native build of split-pin mods works offline
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 03:42:53 +02:00
levzzz 40f8bd80da Merge pull request #4588 from phexuss/fix/smithing-armor-stand-preview
Show the armour stand preview in the smithing table
2026-10-31 06:42:14 +05:00
levzzz e44f25cae1 Merge pull request #4581 from phexuss/fix/shoulder-parrots
Draw the parrots on the player's shoulders
2026-10-31 06:42:06 +05:00
sirrayi 65d2b5e8fe Merge remote-tracking branch 'origin/main' into lod-sampler-cache-v2 2026-10-31 01:42:04 +00:00
rdbt c620da0f19 Merge pull request #4265 from DenisHumen/fix/parrot-stands-when-hurt
A hit stands a sitting tame parrot up
2026-10-31 01:39:59 +00:00
rdbt e1093d6800 Merge pull request #4311 from Teiwazik/feat/pack-folder-watch
Resource pack screen: find packs added to the folder while it is open
2026-10-31 01:39:53 +00:00
rdbt 148b33f41d Merge pull request #4109 from Benflex1/fix/4080-inventory-effect-tooltip-order
Draw item tooltips over inventory effect panels
2026-10-31 01:39:47 +00:00
rdbt b80768cd4a Merge pull request #4184 from ASOwnerYT/fix/tamed-wolf-owner
Tamed pets no longer attack the owner who hits them
2026-10-31 01:39:40 +00:00
levzzz c8a8bf357b Merge hr/main into parity/B2-oit 2026-10-31 06:39:25 +05:00
levzzz d914ceeb53 Merge pull request #4850 from levzzz5154/parity/B1-oit
Improved transparency: order-independent terrain pass
2026-10-31 06:39:16 +05:00
Federico TaglianiandClaude Sonnet 5.5 b213e5346b Hash a container or bundle_contents predicate whose item predicate has exact components
The nested components travel as NBT, whose Codec.BOOL values are bytes. The
bool fields of the component codecs are named, so the predicate hashes as
vanilla's HashOps does; a byte under any other name still leaves it unhashed.
Hashes printed by vanilla 26.3 for four cases (glint override true and false,
damage and a custom name with bold, a food with can_always_eat).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 02:39:05 +01:00
Ludovic Marie 2509598c13 Merge pull request #4877 from horizon-rust/fix/client-loaded-gate
🤖 Agent: proved with tools/server_client_loaded_e2e.py on a dedicated server: a client that never sends PlayerLoaded stays put for 1.5 s and moves after the 60-tick timeout, a loaded one moves at once (fails on main). 7 server harnesses and lan_e2e respawn/spawned/portals/digging/deathmining pass; pr_ready --quick green.
2026-10-31 02:38:53 +01:00
rdbtCVSandClaude Opus 5.5 f0d20beff8 Merge main into fix/sheared-undercoat
Keep main's jeb_ wool colours; only SheepWoolLayer stops on a sheared
sheep, SheepWoolUndercoatLayer still draws.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 01:37:05 +00:00
levzzzandClaude Sonnet 5.5 934136e813 Merge hr/main into the /place structure branch
Main added set_block_entity and entities to LevelFeatureWorld; drop the
duplicate impl and initialise the new field. Keep both corpus groups.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 06:36:49 +05:00
levzzz b2bf03c749 Merge hr/main 2026-10-31 06:36:30 +05:00
levzzz 22b8af021c Merge pull request #4878 from levzzz5154/parity/T3-test-cmds
/test verify, locate, pos, create
2026-10-31 06:36:26 +05:00
rdbtCVSandClaude Opus 5.5 111081b1bc Merge main into fix/gameplay-world-scale
Carry the scale into main's near-plane view-blocking check
(LevelExtractor.getViewBlockingState: 0.1F * getScale()).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 01:33:28 +00:00
levzzz 1d383e73b3 Merge remote-tracking branch 'hr/main' into fix/pr-4347 2026-10-31 06:33:28 +05:00
levzzzandClaude Sonnet 5.5 bb674debd6 Merge hr/main; smithing preview holds every non-armour item in the left hand
SmithingScreen.updateArmorStandPreview's switch falls to the left hand for a
null slot and for every slot but head, chest, legs and feet (main hand, off
hand, body, saddle), not nothing.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 06:33:06 +05:00
levzzzandClaude Sonnet 5.5 32b24a9e8e Merge hr/main into fix/screens-batch
Drops the portal loading reason, the shallow-angle sprint check and the
incompatible-server ping, which main ports already; keeps the Reconfiguring
screen on main's level loading, and the held-key restore beside main's
offscreen grab.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 06:32:51 +05:00
levzzzandClaude Sonnet 5.5 ee837e88be Merge parity/B1-oit into parity/B2-oit
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 06:32:39 +05:00
rdbtCVSandClaude Opus 5.5 470fb25a89 Merge main into fix/issue-4254-scaffolding-sneak-descend
Resolve the conflict with main's changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 01:31:44 +00:00
rdbtCVSandClaude Opus 5.5 40fee24b86 Merge main into fix/issue-4127-armor-stand-passenger
Resolve the conflict with main's changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 01:31:41 +00:00
levzzzandClaude Sonnet 5.5 29c318b541 Translate the OIT shader with terrain.wgsl in the HLSL test
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 06:30:48 +05:00
levzzzandClaude Sonnet 5.5 a8f57b1997 Merge hr/main; sync shoulder parrots through DATA_SHOULDER_PARROT_LEFT/RIGHT
The layer read the local player's saved compounds, so other players' parrots
never drew and a server-thread window had none. The server now syncs the
OptionalInt variant (Player ids 19 and 20), clients read it for every player,
and Parrot.Variant.byId clamps out-of-range ids instead of using the first.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 06:30:09 +05:00
Ludovic MarieandClaude Opus 5.5 94e2666542 lan_bot: a click past a chest's menu is ignored
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 02:29:58 +01:00
Ludovic MarieandClaude Opus 5.5 2541cba005 Container clicks past the menu are ignored, as in vanilla
AbstractContainerMenu.isValidSlotIndex: a click on a slot past the open
menu does nothing. The hub mapped it to the inventory past its 36 slots
(a chest's slot 63 reached the main inventory's slot 9), so a click
could throw or take items outside the menu. The bundle selection
(setSelectedBundleItemIndex) has the same range now.

The packets' codecs: an unknown click type reads as PICKUP (ByIdMap,
OutOfBoundsStrategy.ZERO), and a bundle index below -1 disconnects
(ServerboundSelectBundleItemPacket's constructor).

horizon-bots gains --click, and tools/server_container_click_e2e.py
checks a click on a chest's slot 63 on a dedicated server.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 02:29:58 +01:00
sirrayiandDeepSeek Harness 9c54d6b294 Golems: express the golem patterns with the shared BlockPattern finder
Vanilla has one matcher, BlockPattern.find, for the golem and wither structures;
Horizon had two, so orientation and matching-order fixes had to be made twice
(#4430, one such case was #3929).

The four golem patterns are BlockPattern constants beside the wither's now, and
find_pattern is gone. The search order, orientation set and cell mapping are
unchanged: the tests keep the orientations they had and add the matching order
and the base pattern's wildcard head.

Generated with DeepSeek Harness (deepseek-v4.1-flash)

Co-Authored-By: DeepSeek Harness <[email protected]>
2026-10-31 01:29:46 +00:00
levzzz 6eac540995 Merge pull request #4862 from levzzz5154/parity/K3-hash
Hash block predicates with nbt or partial predicates and books with filtered text
2026-10-31 06:29:01 +05:00
rdbtCVSandClaude Opus 5.5 93892c4049 Merge main into fix/parrot-stands-when-hurt
Keep both test sets in mobs/tame.rs (main's wolf shake tests, the sitting tests here).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 01:28:47 +00:00
levzzz 067b5877d2 Merge hr/main 2026-10-31 06:28:19 +05:00
levzzz 52890d2f8f Merge hr/main 2026-10-31 06:28:05 +05:00
levzzz cbc12f96bf Merge pull request #4873 from levzzz5154/parity/T3-test-verify
Game tests: entity removal, force-loaded chunks
2026-10-31 06:28:01 +05:00
Federico TaglianiandClaude Sonnet 5.5 b1c5484243 Optimize world: level.dat_old stands in for a level.dat that does not read
OptimizeWorldScreen.create reads the level data through
getUnfixedDataTagWithFallback too.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 02:27:20 +01:00
rdbtCVSandClaude Opus 5.5 59dc4a6f14 Merge main into feat/pack-folder-watch
Resolve the conflict with main's changes in game.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 01:26:56 +00:00
rdbtCVSandClaude Opus 5.5 f1ed2f36e6 Merge main into fix/4080-inventory-effect-tooltip-order
Resolve the conflict with main's changes in game.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 01:26:52 +00:00
reservedbytes 1028b8f19c fix(tools): pr_ready says when local rust is older than ci's 2026-10-31 00:26:29 -01:00
Dmytro ChuprynaandClaude Opus 5.5 4cc2631bc0 Apply equipment attribute modifiers from the off hand
Include offhand in apply_equipment and treat EquipmentSlotGroup.HAND as both hands in equipment_modifier_applies, matching vanilla 26.3.

Fixes #4777

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 02:26:25 +01:00
Federico TaglianiandClaude Sonnet 5.5 4e8bdad871 The dedicated server takes level.dat_old when level.dat does not read
LevelStorageAccess.getUnfixedDataTagWithFallback, which the server's Main
calls, falls back to level.dat_old and restores it in place
(restoreLevelDataFromOld). Horizon refused to open such a world unless it
was opened from the world list, which a dedicated server does not have. The
client keeps asking on RecoverWorldDataScreen, as WorldOpenFlows does.
Port map: collectIssues, restoreLevelDataFromOld and readLevelSummary were
still listed as missing after #4219.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 02:25:28 +01:00
rdbtCVSandClaude Opus 5.5 f77d6da3bd Merge main into fix/tamed-wolf-owner
Keep both new mob_ai cases (wolf_shake/wolf_rain from main, wolf_owner_hurt here).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 01:24:36 +00:00
rdbt 46a3761a41 Merge pull request #4240 from kacpherek/fix/trouble-portals-carts
Portal trips keep their destination running, and minecarts follow the track steps a server sends
2026-10-31 01:23:28 +00:00
rdbt 8b4aa65bbd Merge pull request #1760 from horizon-rust/perf/structure-start-convoy
World generation: workers build free structure starts instead of queueing
2026-10-31 01:23:23 +00:00
Jose Gil 8676990b18 fix(client): play horse footstep sounds while riding 2026-10-30 21:23:17 -04:00
levzzz 6f899414a7 Merge hr/main into parity/B1-oit 2026-10-31 06:23:17 +05:00
Jose Gil e3bcd0fdba fix(client): return true in remote_start_held_use for goat horn 2026-10-30 21:23:14 -04:00
levzzz a52f904ff5 Merge hr/main 2026-10-31 06:22:24 +05:00
levzzz f4d91f2fbb Merge hr/main into parity/T3-test-verify 2026-10-31 06:22:01 +05:00
levzzzandClaude Sonnet 5.5 cf8cab121c Game tests: test environments (game rules, weather, clock time, difficulty, functions, all_of)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 06:22:01 +05:00
levzzz 7bcd15e22d Merge remote-tracking branch 'hr/main' into parity/K3-hash 2026-10-31 06:21:45 +05:00
levzzzandClaude Sonnet 5.5 c17233b793 /test verify, locate, pos and create
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 06:19:39 +05:00
Ludovic MarieandClaude Opus 5.5 c644282b7b lan_bot: the hub tests' guests have loaded their level
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 02:19:26 +01:00
Ludovic MarieandClaude Opus 5.5 704dff73bd Ignore a player's moves and actions until their client has loaded
ServerGamePacketListenerImpl.hasClientLoaded: until the client sends
ServerboundPlayerLoadedPacket, or 60 ticks pass (60 again after a
respawn), and while the player is dead, vanilla ignores their moves,
vehicle moves, block actions, item use, attacks, interactions, spectator
actions and player commands, and PlayerInput doesn't change crouching.
The server only skipped a rotation-only move before the first position,
and never re-armed the wait after a respawn.

horizon-bots gains --no-loaded, and tools/server_client_loaded_e2e.py
checks a dedicated server ignores such a client for its first 3 seconds.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 02:19:26 +01:00
rdbt 2fb0d5b493 Merge pull request #4870 from horizon-rust/fix/main-goat-horn-return
Fix main's build: the goat horn's held use returns true
2026-10-31 01:18:57 +00:00
sirrayiandomp b8511b4888 LOD: test the all-cached sampler path
Extract the cache fast path and check it returns exactly each cell's
Column::reduced, including negative chunk coordinates and a partial miss.

Co-Authored-By: omp (DeepSeek V4.1 Flash) <[email protected]>
2026-10-31 01:15:11 +00:00
levzzzandClaude Sonnet 5.5 88fd421269 Game tests: remove entities in a cleared test area and on success, force-load the test's chunks
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 06:14:10 +05:00
Cosmic ce1500dbbb Merge pull request #4835 from horizon-rust/fix/pack-path-audit
Resource packs: zip entries never leave the overlay folder
2026-10-30 21:13:30 -04:00
rdbt 3811132bd2 Merge pull request #4261 from Teiwazik/fix/overlay-vanilla-stamps
Pack overlay: stamp vanilla's files by their metadata on Windows
2026-10-31 01:11:17 +00:00
rdbt 4c6d49a02f Merge pull request #4030 from d3grando/fix-3061-pad
Controller toggle-sneak comes back after a screen closes
2026-10-31 01:11:12 +00:00
Ludovic MarieandClaude Opus 5.5 4f17e42f93 Fix main's build: the goat horn's held use returns true
#4085 made remote_start_held_use return whether the use starts, and the
goat horn's arm (from the goat horn PR, merged beside it) still ended in
`return;`. The horn starts its own use (remote_use_instrument), as the
trident and the shield do, so it returns true like them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 02:10:37 +01:00
levzzz ddd3d4d0e3 Merge pull request #4861 from levzzz5154/parity/E2-post-chain-shaders
Post chains run the resources' own GLSL, with texture and depth inputs
2026-10-31 06:07:59 +05:00
rdbt 12e5483917 Merge pull request #4218 from DenisHumen/fix/server-service-windows
Servers: "Run in the background and at login" works on Windows without admin rights
2026-10-31 01:04:07 +00:00
levzzzandClaude Sonnet 5.5 d7ff063035 Hash block predicates with nbt or partial predicates, and books with filtered text, as vanilla does
BlockPredicate.nbt (NbtPredicate flattened to its SNBT string), the partial
DataComponentPredicate map, and Filterable.filtered of writable and written
book pages and titles now reach HashedPatchMap.create. StringTagVisitor moves
to horizon-world so the protocol can use it.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 06:03:34 +05:00
sirrayi c597f06d65 Merge remote-tracking branch 'origin/main' into lod-sampler-cache-v2 2026-10-31 01:03:00 +00:00
rdbt c904d2fa1c Merge pull request #4152 from DUzzL/fix/issue-4147-same-click-block-replace
Run a same-click attack before the use in single player
2026-10-31 01:02:45 +00:00
rdbt 094a083540 Merge pull request #4231 from DenisHumen/fix/creative-armor-swap
Creative armour swap gives the worn piece back to the hand
2026-10-31 01:02:41 +00:00
rdbt 5c188c619b Merge pull request #4154 from spidercraft/far-terrain-nether-respawn
Far terrain: the dimension the player is in, not the server's level in place
2026-10-31 01:02:37 +00:00
rdbt cce03e9367 Merge pull request #4085 from DenisHumen/fix/bow-no-arrows
Bows and crossbows: no use animation with nothing to shoot
2026-10-31 01:02:32 +00:00
rdbt c08119ea7a Merge pull request #4139 from DUzzL/fix/issue-4133-toggle-sprint-alt-tab
Keep latched toggle sprint and sneak across a short focus loss
2026-10-31 01:02:28 +00:00
levzzz 088390b4fe Merge remote-tracking branch 'hr/main' into parity/E2-post-chain-shaders 2026-10-31 06:00:47 +05:00
Ludovic Marie 4f5c8307e8 Merge pull request #4852 from horizon-rust/fix/creative-slot-validation
🤖 Agent: proved with tools/server_creative_slot_e2e.py on a dedicated server: slots 1..45 within the stack size are kept; slot 0, slot 46, a sword ×2 and pearls ×17 are ignored; a stack of 100 disconnects (the same run fails on main). pr_ready --quick green; dedicated_server, server_entities, server_player_damage pass.
2026-10-31 01:58:46 +01:00
levzzzandClaude Sonnet 5.5 b3516f66e2 Post chains run the resources' own GLSL: texture and depth inputs, translated with naga
PostChain/PostPass over any shader the active resource root provides
(vanilla's post shaders and packs' own), translated GLSL to naga at run
time and bound as vanilla binds them (SamplerInfo, Globals, the pass's
uniform blocks, a sampler per input). Targets keep GL's rows. The WGSL
stand-ins are the fallback when a pass does not translate.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 05:56:47 +05:00
rdbt db83571428 Merge pull request #4597 from xFedeT/fix/move-through-village-map
Port map: MoveThroughVillageGoal is ported for zombies
2026-10-31 00:54:38 +00:00
levzzz c58580ead2 Merge pull request #4853 from levzzz5154/parity/T2-test-retries
Game tests: retries and flaky tests
2026-10-31 05:54:15 +05:00
mularealandAntigravity 1aae74b8b1 death: play the player death sound only once on fatal damage
Fixes #4570

Co-Authored-By: Antigravity <[email protected]>
2026-10-31 01:53:07 +01:00
levzzz 270431ec55 Merge pull request #4851 from levzzz5154/fix/win-disk-space-unsafe
Fix the Windows build: free disk space without unsafe code
2026-10-31 05:53:00 +05:00
rdbt 6c9ab657fd Merge pull request #4481 from xFedeT/fix/parrot-follow-mob-goal
Parrots follow other mobs and hover near them (FollowMobGoal)
2026-10-31 00:52:36 +00:00
rdbt 4851a7ab4d Merge pull request #4542 from xFedeT/fix/piglin-back-up
Crossbow piglins back away from a target within 5 blocks (BackUpIfTooClose)
2026-10-31 00:51:59 +00:00
levzzz 7570dcff24 Merge pull request #4843 from levzzz5154/parity/I-aniso
Texture Filtering (RGSS, Anisotropic) and Max Anisotropy on terrain
2026-10-31 05:47:13 +05:00
rdbt 9e2440253f Merge pull request #4357 from xFedeT/fix/spawn-egg-peaceful-tooltip
Spawn egg tooltip: the red "Disabled in Peaceful" line for a monster's egg
2026-10-31 00:46:55 +00:00
levzzz ee4108fdaf Merge remote-tracking branch 'hr/main' into parity/T2-test-retries 2026-10-31 05:46:07 +05:00
levzzzandClaude Sonnet 5.5 5d41303466 Game tests: retries and flaky tests
RetryOptions (/test run .. numberOfTimes untilFailed), ReportGameListener's attempt and success counters, GameTestRunner.rerunTest and runScheduledRerunTests, max_attempts / required_successes.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 05:46:06 +05:00
levzzzandClaude Sonnet 5.5 0e515dbfd3 Improved transparency: water mask and cloud passes
LevelRenderer.executeOitWaterMask and CloudRenderer.renderOit.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 05:45:56 +05:00
levzzz 5747b52d0c Merge pull request #4845 from levzzz5154/parity/T2-error-marker
Test instance error markers: filled box and billboard text
2026-10-31 05:45:26 +05:00
levzzzandClaude Opus 5.5 bc105393f0 Fix the Windows build: read free disk space without unsafe code
#4732 called GetDiskFreeSpaceExW through an unsafe extern block in
save.rs, but the client lib is #![forbid(unsafe_code)], so every
Windows build failed. Linux CI never compiles the cfg(windows) branch.
fs4::available_space answers the same question safely on every
platform, so the same code now runs on Linux CI too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 05:44:09 +05:00
levzzz e1c4d5dc88 Merge remote-tracking branch 'hr/main' into parity/T2-error-marker 2026-10-31 05:39:02 +05:00
levzzzandClaude Sonnet 5.5 49b4dbec3d Test instance error markers: filled translucent box and billboard text
TestInstanceRenderer.submitErrorMarker: Gizmos.cuboid with a fill (not a stroke) and Gizmos.billboardText at scale 0.16, always on top.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 05:39:01 +05:00
levzzzandClaude Sonnet 5.5 2e1da6db52 Texture Filtering (None, RGSS, Anisotropic) and Max Anisotropy on terrain
Port LevelRenderer's chunk layer sampler (linear, clamped, anisotropy when
Anisotropic) with texture_sampling.glsl's sampleNearest and sampleRGSS in
terrain.wgsl. None keeps the nearest sampler and plain sampling. The two Video
rows are enabled; Max Anisotropy greys out unless Anisotropic is chosen.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 05:38:43 +05:00
Ludovic Marie 0820541751 Merge pull request #4796 from horizon-rust/fix/dedicated-op-ip-bans
🤖 Agent: proven on the Mac mini: server_ops_e2e (Horizon client as op, levels 2/3/4), dedicated_server, server_ops_feedback, chat_screen, command_parse, command_exec; pr_ready --quick green. Stacked on #4795: merge after it.
2026-10-31 01:37:41 +01:00
Iam-Sanja cd53a3c0dd fix: prevent dedicated world saves from overwriting player data 2026-10-31 00:34:54 +00:00
levzzzandClaude Sonnet 5.5 9fd99d5a4d Improved transparency: the order-independent terrain pass
GameRenderer.useImprovedTransparency gates LevelRenderer.executeOit for the translucent terrain: depth bounds, transmittance and accumulate stages against the opaque depth, executeDepthBoundsCull, and the composite. Off: unchanged.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 05:34:32 +05:00
Ludovic MarieandClaude Opus 5.5 4ecf521195 Launcher: native builds open no console window on Windows
cargo, horizon-mixins and rustc are console programs and the launcher
has no console to share, so a native build showed an empty cmd window
for as long as it ran (#4770). Toolchain::command now starts them
through process::command, with CREATE_NO_WINDOW like the launcher's
other background programs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 01:34:02 +01:00
mularealandAntigravity f554aa5a6c mobs: play slime and magma cube attack sound only when hurtServer succeeds
Fixes #4569

Co-Authored-By: Antigravity <[email protected]>
2026-10-31 01:30:51 +01:00
Ludovic MarieandClaude Opus 5.5 11eecf5a6a Resource packs: zip entries never leave the overlay folder
A pack's zip entry named with `..` parts was joined to the overlay folder as
it was and written there, so a pack (a server's too) could put a file outside
it. Entries with empty, `.` or `..` parts, `:` or NUL are now left out, and
overlay directories are one plain folder, as vanilla's DIR_VALIDATOR has it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 01:29:01 +01:00
Ludovic MarieandClaude Opus 5.5 f472da81d1 Dedicated server: /ban-ip, /pardon-ip and /banlist for operators
#4680 let operators run /op, /deop, /ban, /pardon and /whitelist in game;
the IP bans and the ban list were still console-only, so an op typing them
got "Unknown or incomplete command" while the client's tree offered them.
BanIpCommands (an address or an online player's, its players kicked with
ip_banned), PardonIpCommand and BanListCommands (told to the source only),
at admins' level (3) as in vanilla, on the dedicated tree only.

tools/server_ops_e2e.py: a Horizon game as an op at ops.json level 2, 3 and
4 types /tp, /gamemode, /op, /deop, /whitelist, /ban-ip, /banlist,
/pardon-ip, /kick, /ban and /pardon on a dedicated server with bots as
targets; levels 3 and 4 run them all, level 2 only /tp and /gamemode.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 01:20:26 +01:00
huiandClaude Opus 5.5 d65cd8af19 /item reaches a connected player's slots
slots_named only had slots for the player in place, so from the console
every player slot was "not found". It now goes through for_player.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 01:04:22 +01:00
sirrayiandomp 15dce73eb4 LOD: skip density work for tiles wholly inside visited chunks
VanillaLodSampler::grid evaluated the whole density lattice first and only
then checked its per-cell cache, so a tile covered by visited chunks paid for
every sample and discarded all of them. Check the cache first and return the
reduced columns directly when every cell is covered.

Co-Authored-By: omp (DeepSeek V4.1 Flash) <[email protected]>
2026-10-31 00:01:33 +00:00
Ludovic MarieandClaude Opus 5.5 9f4f39addc Creative-slot packets follow vanilla's rules on the server
handleSetCreativeModeSlot takes slots 1 to 45 only (slot 0, the crafting
result, and anything past the inventory menu are ignored) and a stack no
bigger than its own stack size. Its codec takes counts of 1 to 99: more
fails the packet and disconnects the player. The server took slot 0 and
any count. The drop path (slot < 0) now uses the same rule.

horizon-bots gains --give SLOT:ID:COUNT,... and
tools/server_creative_slot_e2e.py checks it on a dedicated server.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 00:54:28 +01:00
Federico TaglianiandClaude Sonnet 5.5 b57d440412 Spawn egg tooltip: any item with entity_data, and an id without its namespace
TypedEntityData.addToTooltip hangs on the entity_data component, not on spawn
eggs. SulfurCube is allowed in Peaceful in vanilla (no notAllowedInPeaceful), so
the egg correctly shows no line.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 00:50:28 +01:00
Federico TaglianiandClaude Sonnet 5.5 253b1f8780 Spawn egg tooltip: the red "Disabled in Peaceful" line in Peaceful
TypedEntityData.addToTooltip shows it under an egg whose entity type is not
allowed in Peaceful (a monster); Horizon showed only the op-only warning.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 00:49:25 +01:00
Federico TaglianiandClaude Sonnet 5.5 d671006122 FollowMob matches vanilla tick for tick: goal name, update interval, mob_ai scenarios
tools/test_mob_ai_parity.py gets parrot_cow_0..2 and parrot_cow_far: the
parrot and the cow match vanilla's FollowMobGoal for 300 ticks (position,
goals, look control, path and random draws).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 00:49:06 +01:00
Federico TaglianiandClaude Sonnet 5.5 bddb7c34be Parrots follow other mobs and hover near them (FollowMobGoal)
register_parrot lacked the priority 3 FollowMobGoal(1.0, 3.0, 7.0): a parrot
flies after the first visible mob of another kind within 7 blocks and keeps
about 3 blocks from it. lan_e2e check parrot_follows_mob.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 00:48:49 +01:00
Jan PresandOpenCode 23bceb746d Send a joining player its own join message
PlayerList.placeNewPlayer adds the player to the list and then calls
broadcastSystemMessage, so the newcomer reads "X joined the game" at
the top of its chat. Horizon broadcast the line with broadcast_except,
which skipped exactly that player; the message also went out before the
player was placed in the list.

Place the player first and send the line to every placed guest after the
initial packets, matching placeNewPlayer's order.

Fixes #4536

Co-Authored-By: OpenCode (DeepSeek V4.1 Flash) <[email protected]>
2026-10-30 23:48:36 +00:00
Federico TaglianiandClaude Sonnet 5.5 0c66d4678d RandomStandGoal: named as vanilla's, mob_ai herd scenarios for the equines
The row said horses rear with the angry sound and donkeys and mules never
get the goal. They do (the ambient sound, baby variants included); what
was missing was a scenario. horse, donkey, mule, zombie_horse and
skeleton_horse herds (8 each, 1200 ticks) and single equines now match
vanilla; the goal's name was 'RandomStand', vanilla's list says
'RandomStandGoal'.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 00:48:25 +01:00
Federico TaglianiandClaude Sonnet 5.5 9ad0a3abf1 Crossbow piglins back away from a target within 5 blocks (BackUpIfTooClose)
Refs #3521

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 00:47:45 +01:00
Federico TaglianiandClaude Sonnet 5.5 90e269dc36 Port map: MoveThroughVillageGoal is ported for zombies
Refs #3521

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 00:47:20 +01:00
Łukasz KopańskiandCursor 0bfa991471 Fix 8BitDo Ultimate 2 right-stick look on Linux.
gilrs' bundled SDL DB only has a Windows row for this pad; on Linux/BT the
right stick sits on ABS_Z/ABS_RX, so Horizon never turned the camera. Inject
the missing SDL mappings and fall back by vendor/product when no row matches.

Fixes #4343

Co-Authored-By: Cursor <[email protected]>
2026-10-31 00:33:40 +01:00
Jose Gil f15483df83 fix(launcher): constrain telemetry notice width in settings to prevent overflow 2026-10-30 19:21:32 -04:00
Jose Gil d72c67d81a fix(launcher): shorten Spanish sponsor discount label to prevent chip overflow 2026-10-30 19:15:06 -04:00
arismoko 45f155e801 Tidy native particle queue 2026-10-30 18:00:35 -05:00
apeirsandClaude Opus 5.5 bcb2087d2f Fluids: spread without cloning shapes or locking per step
FlowingFluid.canPassThroughWall runs for every step of a spread's slope
search. It cloned up to four VoxelShapes per call (the shared block and
empty shapes among them: five atomic reference counts each, on cache lines
every generating thread also touches) and took the global occlusion cache's
mutex once or twice. The shapes are now borrowed and compared by identity
(is_block, a new is_empty_identity), and the cache is an RwLock. The
spread's SpreadContext caches are flat arrays around the origin instead of
two HashMaps. Same comparisons, same results; only the cost changes.

Fixes #4743

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 23:57:23 +01:00
Łukasz KopańskiandCursor 9bab876eb4 Clear portal end crystal revival beams when respawn ends
Portal crystals on the exit portal were left out of reset_spike_crystals,
so their beam targets could stay set after a successful revive or abort.

Co-Authored-By: Cursor <[email protected]>
2026-10-30 23:41:12 +01:00
reservedbytes 2f41aedb39 fix(combat): starvation is recorded once 2026-10-30 21:41:04 -01:00
Łukasz KopańskiandCursor 230ae976e1 Format SetTime clock picker for rustfmt
Co-Authored-By: Cursor <[email protected]>
2026-10-30 23:34:26 +01:00
Łukasz KopańskiandCursor e5f3507585 Fix server day/night by applying only the level's world clock
SetTime packets can update the End clock alone; falling back to the first
clock made overworld clients take game_time as day_time, so the sky looked
inverted while the server still refused beds during day.

Co-Authored-By: Cursor <[email protected]>
2026-10-30 23:27:41 +01:00
foudretbrandClaude Opus 5.5 c094313726 Foxes screech at night when no player is near (#4533)
Fox.getAmbientSound: when it isn't bright outside, one time in ten, an
awake fox with no non-spectator player within 16 blocks of its box picks
entity.fox.screech. Fox.playAmbientSound plays it at volume 2; anything
else goes through Mob.playAmbientSound, which draws getAmbientSound again
and plays its pick through makeSound.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 22:46:07 +01:00
Łukasz KopańskiandCursor 703f9ec127 Apply server.properties difficulty after load_world.
forceDifficulty only set ui.difficulty; health_rules (despawn/spawn) and
level.dat still followed the world's saved difficulty, so peaceful in
server.properties left slimes alive until /difficulty peaceful.

Co-Authored-By: Cursor <composer>
2026-10-30 22:45:34 +01:00
Łukasz KopańskiandCursor f935ff68a5 Cull back faces on translucent dropped items.
ITEM_TRANSLUCENT draws without depth writes, so both sides of a slime/honey
shell were stacking partial alpha and looking opaque. Glass stayed fine
because most of its texels discard at alpha 0.

Co-Authored-By: Cursor <composer>
2026-10-30 22:42:48 +01:00
Łukasz KopańskiandCursor d9d8f5e2e8 rustfmt LOD leaf tint match arm.
Co-Authored-By: Cursor <composer>
2026-10-30 22:36:24 +01:00
Łukasz KopańskiandCursor 5168e4db30 cargo fmt
Co-Authored-By: Cursor <[email protected]>
2026-10-30 22:30:43 +01:00
Łukasz KopańskiandCursor 5e2e5b4291 Drop unrelated item_render WIP from the fmt commit.
Co-Authored-By: Cursor <[email protected]>
2026-10-30 22:30:19 +01:00
Łukasz KopańskiandCursor 05d810136c cargo fmt
Co-Authored-By: Cursor <[email protected]>
2026-10-30 22:29:54 +01:00
Łukasz KopańskiandCursor 8036e0de7f Keep cherry leaf pink in far LOD terrain.
LOD applied the biome foliage tint to every *_leaves block. Cherry (and
other pre-coloured leaves) already carry their colour in the texture, so
that tint turned cherry groves yellow at a distance (#4688).

Co-Authored-By: Cursor <[email protected]>
2026-10-30 22:26:31 +01:00
Łukasz KopańskiandCursor 874c4d1cdb Play empty-bucket sounds on the predicting client.
BucketItem.emptyContents uses playSound(player): the client must hear the
empty (or nether evaporate) sound from its DryLevel prediction, and the
server must leave that guest out of the broadcast (#4684).

Co-Authored-By: Cursor <[email protected]>
2026-10-30 22:24:22 +01:00
Łukasz KopańskiandCursor 88a19fab33 Play axe strip sounds only once for the actor.
The client already predicts strip/scrape/till in predict_item_on_block;
Level.playSound(player) must leave that guest out of the broadcast so the
same sound is not heard again from lan_broadcast_effects (#4681).

Co-Authored-By: Cursor <[email protected]>
2026-10-30 22:22:31 +01:00
Muhammad Asif 7fb5b6f214 Give boats ice friction from the hull's collision slab
AbstractBoat.getGroundFriction averages the friction of blocks whose
collision shape meets a thin box just under the hull. Horizon only
counted full_collision or legacy_solid cells, so ice (and other
non-full solids) left the boat InAir at 0.9 instead of OnLand.

Fixes #4421
2026-10-30 21:19:14 +00:00
Ludovic MarieandClaude Opus 5.5 0d0e9d4213 Floating kick: a chunk not loaded yet is not air around the player
#4622's noBlocksAround read every block through World::block, which gives
air for a chunk the server has not loaded. A survival player's first move
after joining a dedicated server can come before the spawn chunks are
generated: the move counted as floating and nothing cleared it, so the
player was kicked 80 ticks later ("Flying is not enabled on this server").
Vanilla loads the chunks it reads and sees the ground. cursor_death and
pearl_death failed about every other run on it (#4526's parity run).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 22:15:48 +01:00
DenisHumenandClaude Opus 5.5 8b23993cfb lan_e2e looting: register the check, count picked-up drops
The check was missing from the dispatch table (KeyError), and the guest
picks the porkchops up before they are counted: count the ground and
both players' inventories.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 23:04:38 +02:00
Ludovic Marie fd16400b91 Merge remote-tracking branch 'origin/main' into HEAD 2026-10-30 21:51:28 +01:00
Ludovic Marie a62fa7375d Merge remote-tracking branch 'origin/main' into HEAD 2026-10-30 21:45:33 +01:00
DenisHumenandClaude Opus 5.5 23200373f6 Bundles: the wheel selects in a crafting table's and a station's menu
The client offered the wheel to a bundle in the player's inventory only
in the inventory and chest-like menus, and the server read the slot of
ServerboundSelectBundleItemPacket as the inventory menu's. In a crafting
table, an enchanting table or another station the bundle never scrolled
(#4436). Now the inventory part of any menu takes the wheel, and the
server maps a crafting table's or station's slot onto the guest's
inventory.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 22:39:09 +02:00
Łukasz KopańskiandCursor b6c585706f Sit up on a cushion after swimming.
A cushion seat clips the feet into the support block, so pose fitting
rejects standing and would keep a prior swim pose. Force standing for
passengers when that happens, and use the passenger pose path on the
server while a guest rides.

Co-Authored-By: Cursor <[email protected]>
2026-10-30 21:38:40 +01:00
DenisHumenandClaude Opus 5.5 daa6d3b1ca Looting on the killer's weapon counts on a server's mobs
A mob's death runs in the server's tick, after the swing that killed it,
so drop_death_loot read the killer's weapon from the server's own player
(self.held()), who holds nothing. On the server thread every player is
a guest, so Looting never added to a mob's drops (#4496). The killer is
now the guest who struck the last blow, with their hands and armour as
EnchantmentHelper.getEnchantmentLevel reads them. lan_e2e gets a
`looting` check.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 22:38:15 +02:00
Łukasz KopańskiandCursor 468dd3c24b Sync SulfurCube MAX_FUSE so clients show the fuse swell.
Guests never received entity data id 20, so a primed cube jumped
straight to the boom with no scale animation. Push/apply the fuse and
tick it down on the client for the renderer.

Co-Authored-By: Cursor <[email protected]>
2026-10-30 21:35:23 +01:00
Łukasz KopańskiandCursor 9b6a9a6b55 Sort anim.rs imports for rustfmt.
Co-Authored-By: Cursor <[email protected]>
2026-10-30 21:19:50 +01:00
Łukasz KopańskiandCursor 0d9dcfd247 rustfmt the seated-enderman anim test.
Co-Authored-By: Cursor <[email protected]>
2026-10-30 21:09:39 +01:00
JoansitohandClaude Opus 5.5 3f38f88bee Arrows fly into whoever picks them up on servers and the server thread (#4486)
ClientPacketListener.handleTakeItemEntity adds an ItemPickupParticle for any
taken entity and plays the pickup sound where it was. A server's client only
did so for items and orbs: a taken arrow or trident vanished at once. It now
flies to its collector and sounds at the arrow, as on a LAN guest.

horizon-ctl's status counts the pickup particles by kind (`pickups`), and
lan_e2e's `arrow_pickup` check has each player pick up an arrow and both
clients draw it flying.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 21:04:30 +01:00
Łukasz KopańskiandCursor 88e36f9220 Retrigger verify after a flaky Mojang lookup in horizon-server lists.
Co-Authored-By: Cursor <[email protected]>
2026-10-30 21:03:40 +01:00
Łukasz KopańskiandCursor 355b5f099e Sit an enderman that rides a boat.
humanoid_pub ignored isPassenger, so EndermanModel never got the
humanoid riding pose before its limb clamp.

Co-Authored-By: Cursor <[email protected]>
2026-10-30 21:03:09 +01:00
Jan PresandOpenCode d049c7a175 Place a rider ejected by an activator rail beside the cart
AbstractMinecart.activateMinecart ejects its passengers on a powered
activator rail, and getDismountLocationForPassenger puts them on the
nearest block. Horizon cleared the ride but left the player at the seat
position, so the cart appeared to keep its rider.

tick_cart_riding now runs the same dismount placement when the tick
cleared the ride, for the window's player and for a server player
(each body goes through this function).

Fixes #4562

Co-Authored-By: OpenCode (DeepSeek V4.1 Flash) <[email protected]>
2026-10-30 19:58:11 +00:00
Łukasz KopańskiandCursor 0c1b92ce82 Fix TNT bounce unit test attributes after a bad insert.
Co-Authored-By: Cursor <[email protected]>
2026-10-30 20:49:22 +01:00
Łukasz KopańskiandCursor d2839ff264 Bounce primed TNT when a slime piston hits it.
Fixes #4550: moveCollidedEntities' slime causeBounce skipped PrimedTnt,
so TNT sitting on slime did not launch when a piston pushed the block.

Co-Authored-By: Cursor <[email protected]>
2026-10-30 20:46:52 +01:00
apeirsandClaude Opus 5.5 24e36962aa Chunk uploads: a 2 ms cap per frame when the player set no budget
With Chunk Updates and Upload Budget at 0 (the defaults), update_world
uploaded every mesh finished that frame, so a burst of finished sections
landed in one frame. Without the player's budgets, a burst now goes through
the queued path and uploads stop once they took 2 ms of the frame (at least
one per frame), nearest sections first; a frame's batch of 8 or fewer still
uploads at once without queueing. The player's own budgets work as before.

Fixes #4605

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 20:19:47 +01:00
k3yhandClaude Opus 5.5 da4ded1d7c Windows Vulkan: wait for the acquire fence only with VSync (gfx-rs/wgpu#10714)
wgpu-hal 30 waits on Windows for a fence that vkAcquireNextImageKHR
signals, in every present mode. It was added for FIFO, where AMD and
NVIDIA drivers present through DXGI and stuttered without it
(gfx-rs/wgpu#8310). With VSync off (Immediate, Mailbox) the driver
signals that fence only once the GPU has finished the previous frame, so
get_current_texture held the CPU for the whole GPU frame and the GPU sat
idle while the next one was recorded: 72% busy with a shader pack on an
RX 7900 XT. Apply the upstream fix (gfx-rs/wgpu#10714, not in a release
yet), which creates the fence only for Fifo and FifoRelaxed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 14:19:27 -05:00
Claude d63d89bb86 Show the armour stand preview in the smithing table
SmithingScreen.updateArmorStandPreview puts the result on a stand without
its base plate and with its arms (armour in its slot, anything else in the
off hand), drawn by extractEntityInInventory beside the slots. The world's
armour stand drawing is split out per stand so the screen can reuse it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Rx5M6NS763D2WVnb2HmcTH
2026-10-30 18:46:36 +00:00
Claude bec89a8927 Draw the parrots on the player's shoulders
A parrot that lands on its owner is kept in ShoulderEntityLeft/Right, but
nothing drew it, so it seemed to vanish. Port ParrotOnShoulderLayer and
AbstractClientPlayer.getParrotVariantOnShoulder: the parrot model in
ParrotModel.Pose.ON_SHOULDER with the player's walk animation, age and
head turn, 0.4 to the side and 1.5 (1.3 crouching) up the player's model,
on the player in third person and in the inventory preview.

Part of #3518.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Rx5M6NS763D2WVnb2HmcTH
2026-10-30 18:26:58 +00:00
MSedgeMC 0d31254815 Retarget ledger rows that are already ported
The port ledger is where AGENTS.md sends every contributor before parity
work, and thirteen rows still said missing for behaviour that is on main:
the spectral arrow's post-hurt effects and trail, the axolotl's bucket
hand-back and rehydration, the allay's note block memory and vibration
listener, the pillager's raid banner, and three splash potion methods.

Each now points at the function that ports it, verified against the 26.3
jar and the tree. Three pillager rows were transcribed from a branch that
is unreachable in vanilla: a white banner is a BannerItem, so pickUpItem
sends it to Raider.pickUpItem before wantsItem is ever consulted.
2026-10-30 23:12:22 +05:30
Kacpherek d848b1e05d Put guests in a changed default game mode when it is forced
Only a join or the world-options force switch applied the forced mode, so
/defaultgamemode or the default-mode button left guests in the old mode.
IntegratedServer.setWorldGameType and DefaultGameModeCommands.setMode call
updateGameModeBasedOnPermission / enforceGameTypeForPlayers, which sets every
online guest without game master permission to the new default.
2026-10-30 18:15:59 +01:00
Kacpherek c727c26612 Show the smithing table's onboarding tooltips on empty slots
Only the upgrade error tooltip existed, and it was one unwrapped line.
SmithingScreen.extractOnboardingTooltips shows Add Smithing Template over the
empty template slot, and the template's base or additions slot description
over an empty base or addition slot; every one is split at 115 pixels
(font.split(component, 115)).
2026-10-30 18:15:59 +01:00
Kacpherek 9e6453c457 Mark servers of another protocol as incompatible in the list
Every server that answered the ping showed ping bars and a player count.
ServerSelectionList.OnlineServerEntry sets State.INCOMPATIBLE when the
status protocol differs from the client's: the red version name replaces the
player count, the icon is server_list/incompatible, its tooltip reads
multiplayer.status.incompatible, and the player list stays on the name.
2026-10-30 18:15:59 +01:00
Kacpherek af84c3a547 Make Home and End in a book page move to the line's edges
Home and End jumped to the start and end of the whole page. In vanilla's
MultilineTextField.keyPressed they seek getCursorLineView().beginIndex and
endIndex, the visual line holding the caret; only Ctrl+Home and Ctrl+End go
to the page's ends.
2026-10-30 18:15:59 +01:00
Kacpherek 04f73f422a Re-read the held keys when the game takes the pointer back
A movement key pressed or kept down behind a screen did not count once the
screen closed, so the walk stopped until the key was pressed again.
MouseHandler.grabMouse calls KeyMapping.setAll off macOS
(InputQuirks.RESTORE_KEY_STATE_AFTER_MOUSE_GRAB), which sets every mapping
from the keys physically down, except sneak and sprint while they toggle.
2026-10-30 18:15:59 +01:00
Kacpherek 7886e3f5d7 Close the sign editor when the player walks out of reach
The editor only closed when its sign was removed (and only on a server's
client). AbstractSignEditScreen.tick closes it whenever isValid fails, which
also covers SignBlockEntity.playerIsTooFarAwayToEdit: eyes further than the
block interaction range plus 4 blocks. Both paths now run the same check.
2026-10-30 18:15:59 +01:00
Kacpherek e8e251df67 Grey out empty Items and Mobs tabs on the Statistics screen
An Items or Mobs tab with no rows could still be selected and showed an
empty list. StatsScreen.setTabActiveStateAndTooltip makes such a tab
inactive (grey label, no click) and gives it the gui.stats.none_found
tooltip (StatsScreen.java).
2026-10-30 18:15:59 +01:00
Kacpherek 1fb244e0a5 Show Take Book on a lectern only to players who may build
An adventure or spectator player saw Take Book on every lectern. Vanilla's
LecternScreen.createMenuControls adds Done and Take Book only when
player.mayBuild() holds, and otherwise falls back to BookViewScreen's single
wide Done button (LecternScreen.java).
2026-10-30 18:15:59 +01:00
Kacpherek 0bc5e0de9c World list: disable and mark worlds that cannot be played
Play stayed enabled for a world locked by another instance or from an incompatible series, Edit and Re-Create stayed enabled for a world whose files need upgrading, and the hover icon was always the join arrow. LevelSummary (isDisabled, primaryActionActive, canEdit, canRecreate, primaryActionMessage) and WorldSelectionList.WorldListEntry (canInteract, extractContent) grey those buttons out, show Upgrade and Play, ignore a double click or icon click on a disabled world, draw the error or marked join sprite with its tooltip, and tooltip the buttons of a world that needs its files fixed. The info line names an incompatible version in red and shows a backup world's version in italics, red for a downgrade.
2026-10-30 18:15:59 +01:00
Kacpherek 6557c0604c Re-Create keeps the world's data packs and alerts on an unreadable world
Re-Create World took the name, seed and options but none of the world's data packs, and a world whose level.dat would not read did nothing when the button was pressed. WorldSelectionList.WorldListEntry.recreateWorld (WorldSelectionList.java) copies the world's datapacks into the new screen's pack folder (CreateWorldScreen.createTempDataPackDirFromExistingWorld) and keeps its enabled packs, and on failure shows an AlertScreen with selectWorld.recreate.error.title and .text over the list.
2026-10-30 18:15:59 +01:00
Kacpherek f3a05ae1a3 Reopen the sound output when its device is lost or the default changes
Unplugging headphones or switching the system output left Horizon silent until restart: the stream's error callback only logged. SoundEngine.shouldChangeDevice (SoundEngine.java) reloads the engine when the open device disconnects, when the system default changes with no device chosen in the options, or when the chosen device comes back. Audio::tick now watches the stream's DeviceNotAvailable and looks at the default output once a second, then stops all sounds and opens the output again, as SoundEngine.reload does.
2026-10-30 18:15:59 +01:00
Kacpherek 6f0c7b1441 Send language, chat colors and particles in the client information
Settings::client_information filled only chat visibility, skin layers, main hand and server listing; the language stayed en_us, chat colours on and particles at All whatever the options said. Options.buildPlayerInformation (Options.java) sends languageCode, chatColors and particles as set.
2026-10-30 18:15:59 +01:00
Kacpherek bb1007fae9 Show the Amplified help tooltip on the world type button
Create New World's World tab showed no tooltip for Amplified. Vanilla (CreateWorldScreen.WorldTab, WorldCreationUiState.WorldTypeEntry.isAmplified) gives the type button the generator.minecraft.amplified.info tooltip whenever the selected preset is Amplified.
2026-10-30 18:15:59 +01:00
Kacpherek d5a8d23be6 Creative screen: switch to the survival inventory when creative ends
The creative inventory stayed open after the player left creative mode
(/gamemode survival, or a server's game mode change), so items could
still be taken from it. CreativeModeInventoryScreen.containerTick opens
an InventoryScreen once the player no longer hasInfiniteMaterials.
2026-10-30 18:15:59 +01:00
Kacpherek b5dace7edd Recipe book: dragging across the search box selects text
A press in the recipe book's search box placed the caret, but moving the
mouse with the button held did nothing. RecipeBookComponent.mouseDragged
forwards the drag to the search box while it is focused, and
EditBox.onDrag moves the selection end to the pointer.
2026-10-30 18:15:59 +01:00
Kacpherek e39901dc6f Recipe book: a narrow window's open book disables the slots under it
With the book open in a window under 379 px wide it covers the menu, yet
the slots under it could still be hovered and clicked, and an outside
click dropped the carried stack. AbstractRecipeBookScreen.isHovering is
false while the book is visible and widthTooNarrow, and its mouseClicked
returns true for every click in that state instead of passing it to
AbstractContainerScreen.
2026-10-30 18:15:59 +01:00
Kacpherek 963f465dc6 Recipe book: close after placing a recipe in a narrow window
In a window narrower than 379 px the book is not offset next to the menu,
and Horizon kept it open over the menu after a recipe was placed.
RecipeBookComponent.mouseClicked calls setVisible(false) after a placed
recipe when isOffsetNextToMainGUI is false, which stores the closed state
and sends the settings. A refused click (tryPlaceRecipe false) leaves it
open.
2026-10-30 18:15:59 +01:00
Kacpherek 0ada161e69 Container screens: a bundle's tooltip shows while carrying a stack
Hovering a bundle with a stack on the cursor showed no tooltip at all.
AbstractContainerScreen.extractTooltip shows the hovered item's tooltip
with a carried stack when its tooltip image says
showTooltipWithItemInHand, which ClientBundleTooltip does. Applied to
the inventory, container, station, trading, beacon and creative screens.
2026-10-30 18:15:59 +01:00
Kacpherek ca5315dc0d Bundle tooltip: show the selected item's name above the grid
The bundle tooltip drew the grid and the bar but not the box with the
selected item's name above it. ClientBundleTooltip.extractSelectedItemTooltip
(ClientBundleTooltip.java) draws the stack's styled hover name in a
tooltip of its own, centred on the component less 12, 15 above its top,
placed by DefaultTooltipPositioner (+12, -12, kept on screen).
2026-10-30 18:15:59 +01:00
Kacpherek 536fe04942 Container screens: draw the slot highlight sprites
The hovered slot got a flat 50% white square over its item. Vanilla draws
the 24x24 container/slot_highlight_back sprite behind the item and
slot_highlight_front over it, at the slot minus 4 (AbstractContainerScreen
.extractSlotHighlightBack and extractSlotHighlightFront), and skips slots
whose isHighlightable is false (the crafter's NonInteractiveResultSlot).
Done for the inventory, container, station, trading, beacon and creative
screens; the quick-craft drag preview keeps its 0x80ffffff fill as in
AbstractContainerScreen.extractSlot.
2026-10-30 18:15:59 +01:00
Kacpherek 14b5b031d3 Music Toast: the Now Playing toast with its music notes
The Music Toast option did nothing: the music manager queued show and
hide requests that nothing read, and no toast existed. ToastManager
makes a NowPlayingToast under Pause and Pause and Toast
(initializeMusicToast, setMusicToastDisplayState), shows it when a song
starts and hides it when music stops (showNowPlayingToast,
hideNowPlayingToast); NowPlayingToast.update times it out after five
seconds of Notification Time, extractToast draws the nine-slice
background, the music notes tinted by ColorLerper MUSIC_NOTE
(tickMusicNotes) and the song's language key in light grey. It slides
in from the left over every screen but the pause screen, which draws it
itself while music is audible (PauseScreen.rendersNowPlayingToast).
2026-10-30 18:15:58 +01:00
Kacpherek 397e947fac Advancement toast: wrap a long title and fade the label into it
A long advancement title stayed on one line at the toast's right edge.
AdvancementToast.extractRenderState (AdvancementToast.java) splits the
title at 125 pixels; with more than one line it shows the frame label
centred and fading out until 1.5 s of full visibility, then the title
lines centred and fading in over 0.3 s.
2026-10-30 18:15:58 +01:00
Kacpherek db2c7b799d Chat: grey Not Secure bar, Modified tag and Only Show Secure Chat
Player chat lines never carried a tag, so the grey bar for an unsigned
message and the Modified bar and icon for a server-edited one were
missing, and Only Show Secure Chat did nothing. ChatTrustLevel.evaluate
(ChatTrustLevel.java) calls a message not secure when it has no
signature or is older than seven minutes, modified when the signed
content is not in the line or the replacement uses another font, and
createTag turns that into a GuiMessageTag. ChatListener.showMessageToPlayer
drops not secure lines with the option on, and the player's own message
on their own server is always secure. ChatComponent shows the tag text
over the bar and the icon, and the icon on the hovered message.
2026-10-30 18:15:58 +01:00
Kacpherek bc005e8ac2 Title splash: sometimes show the player's name as NAME IS YOU
The title screen only ever drew a list entry. SplashManager.getSplash
(SplashManager.java), outside the three special dates, rolls
nextInt(size) == 42 and then shows the logged-in user's name in
upper case followed by " IS YOU" instead of a list entry.
2026-10-30 18:15:58 +01:00
Kacpherek be75260bc0 Show the Reconfiguring screen while a server reconfigures the client
The client kept drawing the old world while a proxy or server sent it back to
configuration. ClientPacketListener.handleConfigurationStart flushes the held
chat and clearClientLevel opens ServerReconfigScreen: connect.reconfiguring
over the panorama with a Disconnect button that only becomes active after
600 ticks (DISCONNECT_TIME); the layout is a vertical LinearLayout with cells
padded by 10. handleLogin replaces it with the loading screen.
2026-10-30 18:15:58 +01:00
Kacpherek f5f968df5c Nudge the player out of a block it stands inside
A player whose body corner sat in a solid block (a block placed or pushed
onto the player) was left there. LocalPlayer.aiStep calls
moveTowardsClosestSpace at the four corners (0.35 of the width out) unless
the player is a spectator or a passenger: a corner in a suffocating block
sets a 0.1 speed on the axis of the nearest side that is free (suffocatesAt,
LocalPlayer.java).
2026-10-30 18:15:58 +01:00
Kacpherek fd4c3e01e9 Keep sprinting along a wall you push into at a shallow angle
The local player's minor_horizontal_collision was a stub returning false,
so any bump into a wall stopped the sprint. LocalPlayer.
isHorizontalCollisionMinor compares the move the wall allowed with the
direction of xxa/zza turned by the yaw, and counts it minor when the angle
is under 0.13962634 radians (8 degrees); Player.shouldStopRunSprinting
ignores a minor collision.
2026-10-30 18:15:58 +01:00
Kacpherek bd19371acf Delay far firework sounds by their distance
Firework bursts played their blast and twinkle at once, however far they
were. ClientLevel.playSound with distanceDelay, as FireworkParticles passes
it, plays a sound beyond 10 blocks of the camera later, by sqrt(distance) /
40 seconds (SoundManager.playDelayed); drips stay immediate.
2026-10-30 18:15:58 +01:00
Kacpherek 4f3a68c450 Show the portal loading screen on a server's dimension change
A remote respawn always drew the panorama loading screen. Vanilla's
ClientPacketListener.handleRespawn picks a LevelLoadingScreen.Reason with
determineLevelLoadingReason (not after a death; the Nether or the End on
either side gives NETHER_PORTAL or END_PORTAL) and
LevelLoadingScreen.extractBackground draws the portal sprite or the End
backdrop for those instead of the blurred panorama.
2026-10-30 18:15:58 +01:00
SlaVKsVolksandOpenAI Codex GPT-6.1 Sol 320778a3d2 fix(bench): reject incomparable benchmark reports
Check the documented comparison conditions before printing tables, and report missing or conflicting settings without producing a misleading comparison.

Co-Authored-By: OpenAI Codex GPT-6.1 Sol <[email protected]>
2026-10-30 11:12:46 -03:00
SlaVKsVolksandOpenAI Codex GPT-6.1 Sol b46c30af80 fix(port-ledger): support jar-only citation checks
Resolve declared and inherited fields from bytecode when source files are absent, while retaining source-based citation checks.

Co-Authored-By: OpenAI Codex GPT-6.1 Sol <[email protected]>
2026-10-30 10:53:44 -03:00
DUzzL e2dbf8a03f Enable the input method in World Options only while a text box has focus 2026-10-30 12:11:18 +01:00
DUzzL 21bdf4f9a3 Accept committed IME text in the focused text field
Allow the window's input method only while a text box has focus and route
WindowEvent::Ime(Commit) to the same sinks as typed text. Preedit drawing and
the candidate window position are not handled yet.
2026-10-30 12:02:53 +01:00
MSedgeMC dec8429c3d Wire /place structure to the live level
PlaceCommand.placeStructure generates a start for the position's chunk with
a constant-true biome predicate, checks that every chunk of the start's box
is loaded, and runs placeInChunk over that box in the live level. Horizon
implemented /place template, /place feature and /place jigsaw, and the
structure arm validated the id and then always failed with
command.failed, which is the "unexpected error" the report shows.

The arm now generates the start through the existing structure machinery
with every biome valid (the command's own predicate, not worldgen's), reuses
place_in_chunk over the live-level feature world, and reports vanilla's
success and failure messages. LevelFeatureWorld also stores a template's
block-entity data now, so a placed chest keeps its loot table instead of
losing it.
2026-10-30 16:29:35 +05:30
TeiwazikandClaude Opus 5.5 5455bc98f4 Resource pack screen: find packs added to the folder while it is open
PackSelectionScreen watches resourcepacks/ and lists the packs again when its
contents change. Horizon listed them once, when the screen opened: a pack
copied in after needed the screen closed and opened again. The folder's
entries (name, size, write time) are now compared once a second while the
resource pack screen is open; on a change the screen is made again over the
new list, keeping the selection made on it so far. Create World's data packs,
on the same page, are left alone.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NbhTrA2NFEcA5CdtUTS1eH
2026-10-30 10:29:11 +01:00
MSedgeMC dd0a1e4972 Keep the sheep undercoat when the sheep is sheared
Vanilla's two wool layers disagree about shearing: SheepWoolLayer returns
early on a sheared sheep, while SheepWoolUndercoatLayer has no sheared
test at all (its guard is invisibility, jeb_ or a non-white colour, and
adult). Horizon gated both pushes behind one !sheared arm, so a sheared
dyed sheep drew nothing at all.

The sheared guard now wraps only the wool push; the undercoat keeps its
existing conditions and is pushed first, as before. Unsheared output is
unchanged, and the test asserts (model, texture, colour) for the sheared
dyed, sheared white, baby and unsheared cases.
2026-10-30 14:48:48 +05:30
TeiwazikandClaude Opus 5.5 525d7366eb Pack overlay: the read flag has no use on Unix
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NbhTrA2NFEcA5CdtUTS1eH
2026-10-30 09:44:43 +01:00
DUzzL 9ca7f51d05 Sneaking players descend through scaffolding on the server (#4254) 2026-10-30 08:55:14 +01:00
DenisHumenandClaude Opus 5.5 e17aabe322 A hit stands a sitting tame parrot up
Parrot.hurtServer calls setOrderedToSit(false) before
LivingEntity.hurtServer, as Wolf.hurtServer does. Horizon only did it
for wolves, and only after the damage landed, so a sitting parrot kept
sitting when hit, and a wolf hit inside its hurt frames kept sitting.
Both now stand up in accepts_damage, which runs where vanilla's override
does (after isInvulnerableTo, before the hurt frames).

Part of #3512 (row Parrot.hurtServer).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 09:52:54 +02:00
TeiwazikandClaude Opus 5.5 b91fb97b1c Pack overlay: stamp vanilla's files by their metadata on Windows
Without a change timestamp, Windows checked every file of a cached overlay by
its bytes, vanilla's included: tens of thousands of opens, each scanned by the
antivirus, took minutes on every resource reload. Vanilla's files now keep to
their size and write and creation times, as on Unix; the packs' own files are
still read. The marker version moves to 6.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NbhTrA2NFEcA5CdtUTS1eH
2026-10-30 08:31:09 +01:00
Kacpherek 64b7e511b1 Play the minecart steps a server sends along the track
Horizon ignored ClientboundMoveMinecartPacket, which a server sends in place of move packets for minecarts on NewMinecartBehavior (the minecart_improvements experiment), so such a cart and a rider in it stayed where they were added until a teleport. Decode the packet's steps (MinecartStep: position, movement, yRot, xRot, weight) and play them as NewMinecartBehavior.lerpClientPositionAndRotation does: queued steps become the current ones over 3 ticks by their weights (getCurrentLerpStep), and the cart takes the position, movement and yaw they give at the end of each tick. A teleport puts such a cart there at once (its interpolation handler is NO_OP). The cart's pitch still comes from its rails.

Fixes #3796
2026-10-30 08:15:21 +01:00
Kacpherek d0c5f220bc Keep the destination of a portal trip running with a portal ticket
Horizon only lit the exit chunks long enough to land an entity that went through a portal, then nothing in that dimension ticked while no player was in it, so an item or mob stayed frozen there and never came back through. Vanilla places TicketType.PORTAL (300 ticks, radius 3, level 30 of both kinds, keeps the dimension active) on the exit chunk when the entity arrives (Entity.placePortalTicket via TeleportTransition.PLACE_PORTAL_TICKET and NetherPortalBlock.getExitPortal, EndPortalBlock.getPortalDestination). Add the ticket to the level's chunk tickets, tick the dimension while one is held and stream its area.

Fixes #3292
2026-10-30 08:11:41 +01:00
DenisHumenandClaude Opus 5.5 dc3d702e01 Creative armour swap gives the worn piece back to the hand
Equippable.swapWithEquipmentSlot: in creative a single item swaps with the
worn piece too (the hand gets copyAndClear of the worn stack, the slot a
copy), and from a larger stack the worn piece goes to the inventory. The
game kept the new piece in the hand and deleted the worn one, so the held
item never changed and the hand never played its swap animation.

Fixes #4150

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 09:08:00 +02:00
DenisHumenandClaude Opus 5.5 e3e2404a1f horizon-server: install the Windows logon task without admin rights
`service install` (the launcher's "Run in the background and at login"
switch) ran `schtasks /Create /SC ONLOGON`. That trigger fires at every
user's logon, and Windows lets only an administrator create it, so the
switch failed with "Access is denied" for a normal user.

Create the task from XML instead, with the logon trigger and the
principal naming the current user (LeastPrivilege, interactive token),
which needs no elevation. The task also drops schtasks' defaults that
would stop a server: the 72-hour run limit and not starting on battery,
and Task Scheduler restarts the supervisor a minute after it fails.

Fixes #3733

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 08:41:08 +02:00
d3grandoandClaude Opus 5.5 3e536cb541 Merge origin/main into fix-3061-pad
game/pad.rs: main dropped the unused mouse::Rotation import; this branch's keymapping::SNEAK import stays.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 13:15:22 +07:00
Kacpherek 85b5ff7294 Scale the view-blocking probe height with the player's scale
The block overlay (the dark stretched texture over the screen) probes eight points around the eye, 0.1 apart in height. Vanilla multiplies that by LivingEntity.getScale (LevelExtractor.getViewBlockingStateAndPos). Horizon used a fixed 0.1, so a player of scale 0.01, whose eye is 0.0162 above the feet, probed the ground block and got its overlay over the whole screen.

Part of #3809
2026-10-30 06:34:55 +01:00
Alexander KrylovandHermes Agent 58cf8aa10c Graphics screens: their text through the language table
The graphics screens' captions, values, headers, titles, cost tags, blocked
reasons and descriptions were English display text handed to the widgets, so
no key ever reached i18n. The text is the key now: labelled() translates a
caption and its value, the description is translated before it wraps, and the
headers, titles and buttons go through lang::get where they are drawn.
Numbers in values became templates the table can hold.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-30 07:28:14 +03:00
DUzzL 6f654f9180 Launcher: keep --check-accounts as a hidden no-op for old scripts 2026-10-30 04:54:15 +01:00
DUzzL b636b37e9a Launcher: new servers check Minecraft accounts by default
The create screen no longer offers the offline choice; the server's
settings page still turns account checks off, and the CLI takes --offline.
2026-10-30 04:46:04 +01:00
DUzzL e8c32f6610 Run single-player use clicks after attack clicks within a tick
A place and a break in one click now replace the targeted block as in
vanilla's handleKeybinds: attack first, then use against the hit result
picked before the break. Fixes #4147.
2026-10-30 03:13:48 +01:00
spiderandClaude Opus 5.5 d87ead957e Far terrain: the dimension the player is in, not the server's level in place
The window built its far terrain from whichever level the server thread
had in place when it asked. Dying in the Nether and respawning in the
Overworld could catch the Nether there, and Nether far terrain stood around
the Overworld until the world was reopened. Levels::lod_source_in gives the
player's dimension's, in place or parked.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 03:12:23 +01:00
Alexander KrylovandHermes Agent 56402b95f3 Launcher: the language row is a dropdown, not a segment per language
Nine segments are wider than the row, which left its sentence a character
wide and wrapping one letter per line. The row shows the choice and a
menu now, and the sentence stays only while the language follows the
system's.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-30 05:06:21 +03:00
asownerytandClaude Opus 5.5 7037d586ca Tamed pets no longer turn on the owner who hits them
Vanilla's TamableAnimal.canAttack refuses the pet's owner (isOwnedBy), so
HurtByTargetGoal never starts when the owner hits their wolf, cat or
parrot, and no target or anger follows. Horizon's player check
(Mob::spares_players) only knew the player-built iron golem, so a tamed
wolf hunted its owner after one hit (#4119). It now spares a
TamableAnimal's owner too, in every place that check is made.

NonTameRandomTargetGoal also checks isTame before its random interval
roll: the wolf's and cat's untamed-only target goals now gate before the
draw, so a tamed pet's random sequence follows vanilla.

mob_ai harness: a new `owned` flag (tamed by the scenario's player) and
the wolf_owner_hurt scenario.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 15:05:11 +13:00
DUzzL 3acfebb2fe Document the armor stand seat offset 2026-10-30 02:41:31 +01:00
DUzzL 77738c6356 Keep latched sneak and sprint across a short focus loss
Focus loss cleared the key set, which also held the keys synthesized from the sneak/sprint toggles. A quick alt-tab opens no pause screen, so nothing restored them. Re-sync both toggles after the clear. Fixes #4133.
2026-10-30 02:38:18 +01:00
DUzzL fc26a5f40a Seat armor stands that ride a mob or the player (frozen name tag)
A server's name tag hologram rides the player as an armor stand. The client
ignored SetPassengers for armor stands, so the stand stayed where it spawned
while the real name tag followed the player.
2026-10-30 02:31:19 +01:00
Benflex 7fe6f090e1 Draw item tooltips over inventory effect panels
The survival and creative inventories drew the active-effect panels
after the item tooltip, so the panel backgrounds covered the tooltip
text. Draw the panels with the rest of the screen and keep only the
hovered effect's tooltip for the end, after the item and slot tooltips.

Fixes #4080.
2026-10-30 01:43:11 +01:00
DenisHumenandClaude Opus 5.5 6297ea09f6 Bows and crossbows: no use animation with nothing to shoot
BowItem.use and CrossbowItem.use (uncharged) FAIL without a projectile
unless the player has infinite materials, and Minecraft.startUseItem only
calls itemUsed for a SUCCESS, trying the other hand otherwise. The client's
remote_use_item treated the bow as used anyway, so the hand dipped as if the
draw started and stopped.

remote_start_held_use now reports a failed bow or uncharged crossbow use
(a charged crossbow still succeeds, it shoots), and remote_use_item passes
the turn on.

Fixes #4079

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 01:48:20 +02:00
SenjinandClaude Opus 5.5 ef61ac7c6c HDR: light sources tiered from a look at each in game
Beacon, end rod, furnace and redstone lamp shine bright; the froglights,
shroomlight and every copper bulb low; the respawn anchor incandescent at
every charge. Sea pickles, brown mushrooms, the dragon egg and the end
portal frame only glow faintly, as glow lichen, and stay as in SDR.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 23:44:15 +01:00
SenjinandClaude Opus 5.5 0a72d58c25 HDR: mobs' glowing eyes shine like glowstone
The eyes render type's layers (spiders', endermen's and phantoms' eyes,
the breeze's, the creaking's, the copper golem's) carry the bright tier
in the overlay's high bits, as light-giving particles do, so the HDR mark
pass lifts them: they glow to frighten, so they shine above a torch. The
warden's fading layers and the dragon's additive eyes are left as they
are.

Part of #1441.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 23:42:13 +01:00
SenjinandClaude Opus 5.5 decdf49937 HDR: the moon shines at the low tier, not the sun's
The sun and the moon marked the frame alike, so the moon sat in the
sun's tier, whose lift window only its brightest pixels reach: it hardly
lifted at all. Each body's quads now carry their tier in the overlay's
high bits (as particles do), and fs_sky_celestial writes that tier's
mark: the sun's as before, the moon's low.

Part of #1441.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 23:38:38 +01:00
d3grandoandClaude Opus 5.5 a8b5151ce5 Controller toggle-sneak comes back after a screen closes
The pad's toggle-sneak flipped sneaking with a hold-mode Shift press or
release, so KeyMapping.releaseAll dropped it when a screen opened and
restoreToggleStatesOnScreenClosed had nothing to restore. ToggleKeys now
has a pad toggle: it flips the sneak mapping as ToggleKeyMapping.setDown
does with its option on, and while it holds sneaking the mapping toggles
(released and remembered by a screen, restored when it closes, a shift
release behind the screen ignored), as vanilla's toggle crouch does.

Part of #3061.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 05:34:50 +07:00
SenjinandClaude Opus 5.5 7b98a22fe1 HDR shortcut: Done goes back to the game, not the pause menu
The key opens Graphics: HDR straight from the game, so closing it returns
there and takes the mouse again (options_to_game, like
options_to_world_options for Chat Settings).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 22:35:10 +01:00
SenjinandClaude Opus 5.5 0a8ac2e89c HDR: a shortcut key to its settings, unbound by default
Graphics: HDR gets a Shortcut row, bound like the sort key on Extras: click it,
press a key or mouse button (Escape unbinds). The key lives in
horizon-graphics.txt as hdrKey, never in options.txt, which stays vanilla's.
Pressed in the game where the display offers HDR, it opens Graphics: HDR
straight away, and Done goes back to the game: tune paper white and peak while
looking at the world.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 22:35:10 +01:00
unknownandOpenCode Agent 89b737d88a Streaming: light and mesh jobs bounded in flight, nearest first
The light and mesh stages of Streaming::update submitted every ready
column's jobs at once, in hash order. At a wide render distance the
queue - and lighting, and meshing, and each job's 3x3 region snapshot -
grew with the number of loaded columns, requeue re-ranked that whole
heap under the queue lock each time the player crossed a chunk border,
and the workers ground through jobs whose columns were long gone,
their results dropped only after the work was done (#3629: at render
distance 1000 the backlog survived lowering the distance, and chunks
around the player waited behind it).

Both stages now hand the workers only as much as they can chew, chosen
from everything that is ready each update, nearest first: what stays
dirty is asked for again, with the current centre, as capacity frees -
the way vanilla's chunk task queues hold bounded per-worker work and
follow the current ticket levels. Generation (workers * 3) and
decoration (workers * 2) already had such bounds.

Fixes #3629

Co-Authored-By: OpenCode Agent (GLM) <[email protected]>
2026-10-30 00:24:28 +03:00
Ludovic Marie 67591edf8f Merge remote-tracking branch 'origin/main' into perf/structure-start-convoy 2026-10-25 21:31:03 +01:00
Ludovic Marie e2ca936487 Merge main 2026-10-25 14:21:48 +01:00
Ludovic MarieandClaude Opus 5.5 4eb85dbdf9 World generation: workers build free structure starts instead of queueing
ChunkGenerator.createReferences asks for the structure starts of the 17x17
chunks around each chunk. Every worker asked for them in the same order and
waited on the chunk's work lock while another worker built its starts, so at
a cold start (a new world, the first structures of a seed) the workers queued
one behind the other: in a fresh generator with 12 workers on the Mac mini,
39% of the generation time was spent blocked in that lock (sampled).

references() now builds the starts no other worker is building first, and
waits for the rest only then; the work locks' map is sharded by chunk, as
the start cache already is, because every worker now asks for the locks of
the whole neighbourhood. Starts and the order of the references are the same:
each chunk's starts are still built once and kept by the first builder.
Blocked time in the same run: 39% -> 1.4% of generation samples.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 06:11:58 +01:00
231 changed files with 14279 additions and 1756 deletions

No files matched your search

Generated
+11
View File
@@ -830,6 +830,16 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fs4"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e72ed92b67c146290f88e9c89d60ca163ea417a446f61ffd7b72df3e7f1dfd5"
dependencies = [
"rustix 1.1.5",
"windows-sys 0.61.2",
]
[[package]]
name = "futures-core"
version = "0.3.34"
@@ -1117,6 +1127,7 @@ dependencies = [
"bytemuck",
"chrono",
"flate2",
"fs4",
"gilrs",
"glam",
"horizon-anticheat",
+4 -1
View File
@@ -53,7 +53,10 @@ large_enum_variant = "allow"
# UHD 620/630) crashes on it with an access violation (#746). It also fixes the
# Metal backend's lookup of the CoreGraphics HDR colour space names, which handed
# CoreGraphics a symbol's address as the string and crashed HDR output on macOS.
# Drop this and the folder once a wgpu release has both fixes.
# And gfx-rs/wgpu#10714: on Windows, Vulkan waits for the acquire fence only in
# FIFO (VSync), where it was meant to pace frames; with VSync off it made every
# frame wait for the GPU to finish the one before (#4604).
# Drop this and the folder once a wgpu release has these fixes.
[patch.crates-io]
wgpu-hal = { path = "third_party/wgpu-hal" }
+3 -2
View File
@@ -3,8 +3,9 @@
## wgpu-hal
`third_party/wgpu-hal` is [wgpu-hal](https://github.com/gfx-rs/wgpu) 30.0.1 by the gfx-rs
developers, with one upstream fix applied
([gfx-rs/wgpu#10124](https://github.com/gfx-rs/wgpu/pull/10124)). It is licensed under MIT or
developers, with upstream fixes applied
([gfx-rs/wgpu#10124](https://github.com/gfx-rs/wgpu/pull/10124),
[gfx-rs/wgpu#10714](https://github.com/gfx-rs/wgpu/pull/10714)). It is licensed under MIT or
Apache-2.0; both licenses are in that folder (`LICENSE.MIT`, `LICENSE.APACHE`).
## Pixelify Sans
+88 -1
View File
@@ -63,6 +63,8 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
--attack NAME attack the player NAME twice a second, from wherever the bot is (a reach test);
the report gives each bot's last health
--attack-after S start attacking S seconds after joining; default 5
--no-loaded never say the level is loaded (ServerboundPlayerLoadedPacket): a vanilla server
ignores the player's moves and actions for its first 3 seconds
--no-read stop reading from the server once spawned (a client that lets the
server's replies pile up)
--join-every SECONDS default 1
@@ -75,6 +77,10 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
--height H blocks above spawn the bots fly at (walk, explore, idle); default 24
--ground claim the ground in every move (with --height 0 on a flat world: walking
on it; higher, or over water: a no-fall or jesus cheat, to test an anticheat)
--give SLOT:ID:COUNT,... creative-slot packets sent once joined (inventory menu slot, item
registry id, count), to test what a server takes
--click X,Y,Z:SLOT:BUTTON:MODE 3 s after joining, open the block at X,Y,Z (a chest), then a second later
click SLOT of its window (ServerboundContainerClickPacket), to test what a server takes
--timer F cheat, to test an anticheat: tick F times as fast as vanilla's client (1.5: 30 Hz)
--report FILE a JSON summary at the end (- prints it after the per-second lines)
";
@@ -133,8 +139,13 @@ struct Options {
attack_after: f64,
flood: usize,
no_read: bool,
no_loaded: bool,
height: f64,
ground: bool,
/// ServerboundSetCreativeModeSlotPackets to send once joined: (slot, item id, count).
give: Vec<(i16, i32, i32)>,
/// --click: the block to open, then the slot, button and click type in its window.
click: Option<([i32; 3], i16, i8, i32)>,
name: String,
/// The `minecraft:brand` to send, if any.
brand: Option<String>,
@@ -158,8 +169,11 @@ fn options() -> Result<Options, String> {
attack_after: 5.0,
flood: 100,
no_read: false,
no_loaded: false,
height: 24.0,
ground: false,
give: Vec::new(),
click: None,
name: "Bot".into(),
report: None,
brand: None,
@@ -175,6 +189,10 @@ fn options() -> Result<Options, String> {
o.no_read = true;
continue;
}
if arg == "--no-loaded" {
o.no_loaded = true;
continue;
}
if arg == "--ground" {
o.ground = true;
continue;
@@ -202,6 +220,32 @@ fn options() -> Result<Options, String> {
"--name" => o.name = value,
"--report" => o.report = Some(value),
"--brand" => o.brand = Some(value),
"--give" => {
for part in value.split(',') {
let n: Vec<&str> = part.split(':').collect();
let parsed = match n[..] {
[s, i, c] => s.parse().ok().zip(i.parse().ok()).zip(c.parse().ok()),
_ => None,
};
let ((slot, item), count) = parsed.ok_or_else(|| number("SLOT:ID:COUNT"))?;
o.give.push((slot, item, count));
}
}
"--click" => {
let bad = || number("X,Y,Z:SLOT:BUTTON:MODE");
let n: Vec<&str> = value.split(':').collect();
let [at, slot, button, mode] = n[..] else {
return Err(bad());
};
let at: Vec<i32> = at
.split(',')
.map(str::parse)
.collect::<Result<_, _>>()
.map_err(|_| bad())?;
let at = <[i32; 3]>::try_from(at).map_err(|_| bad())?;
let parse = |s: &str| s.parse::<i32>().map_err(|_| bad());
o.click = Some((at, parse(slot)? as i16, parse(button)? as i8, parse(mode)?));
}
"--height" => o.height = value.parse().map_err(|_| number("blocks"))?,
"--timer" => {
o.timer = value.parse().map_err(|_| number("a speed, like 1.5"))?;
@@ -716,7 +760,9 @@ fn run_bot(
bot.joined_after_ms
.store(asked.elapsed().as_millis() as u64, Ordering::SeqCst);
let send = |body: Vec<u8>| lock(&writer).send(&body).is_ok();
send(raw(sb::PLAYER_LOADED, |_| {}));
if !o.no_loaded {
send(raw(sb::PLAYER_LOADED, |_| {}));
}
bot.deaf.store(o.no_read, Ordering::Relaxed);
// What the costly floods need in hand (creative: the server takes the bot's word for it).
let tool = match behaviour {
@@ -724,6 +770,15 @@ fn run_bot(
Behaviour::FloodBook => Some(Stack::new(WRITABLE_BOOK, 1)),
_ => None,
};
for &(slot, item, count) in &o.give {
send(
play::Serverbound::SetCreativeModeSlot(play::SetCreativeModeSlot {
slot,
item: Some(Stack::new(item, count)),
})
.encode(),
);
}
if tool.is_some() {
send(
play::Serverbound::SetCreativeModeSlot(play::SetCreativeModeSlot {
@@ -848,6 +903,38 @@ fn run_bot(
}
}
}
if let Some((at, slot, button, input)) = o.click {
// Ticks of 50 ms: the block 3 s after joining, the click a second later.
if tick == 60 {
sequence += 1;
send(
play::Serverbound::UseItemOn(play::UseItemOn {
hand: 0,
pos: at,
face: 1,
cursor: [0.5, 1.0, 0.5],
inside: false,
world_border: false,
sequence,
})
.encode(),
);
} else if tick == 80 {
// The first window a player opens is 1.
send(
play::Serverbound::ContainerClick(play::ContainerClick {
container: 1,
state: 0,
slot,
button,
input,
changed: Vec::new(),
carried: None,
})
.encode(),
);
}
}
// The costly floods go out wherever the bot is, chunks or not (--no-read gets none).
for i in 0..o.flood {
sequence += 1;
+2 -173
View File
@@ -3,179 +3,8 @@
//! number formatting.
use crate::text::{self, Style, Text};
use horizon_world::nbt::Tag;
/// `Double.toString`.
pub fn java_double(v: f64) -> String {
java_float_text(v, &format!("{v}"), &format!("{v:e}"))
}
/// `Float.toString`.
pub fn java_float(v: f32) -> String {
java_float_text(f64::from(v), &format!("{v}"), &format!("{v:e}"))
}
fn java_float_text(v: f64, plain: &str, sci: &str) -> String {
if v.is_nan() {
return "NaN".into();
}
if v.is_infinite() {
return if v > 0.0 { "Infinity" } else { "-Infinity" }.into();
}
if v == 0.0 {
return if v.is_sign_negative() { "-0.0" } else { "0.0" }.into();
}
let a = v.abs();
if (1.0e-3..1.0e7).contains(&a) {
if plain.contains('.') {
plain.to_owned()
} else {
format!("{plain}.0")
}
} else {
let (mantissa, exponent) = sci.split_once('e').unwrap_or((sci, "0"));
let mantissa = if mantissa.contains('.') {
mantissa.to_owned()
} else {
format!("{mantissa}.0")
};
format!("{mantissa}E{exponent}")
}
}
/// `SnbtGrammar.escapeControlCharacters`.
fn escape_control(c: char) -> Option<&'static str> {
Some(match c {
'\u{8}' => "b",
'\u{c}' => "f",
'\n' => "n",
'\r' => "r",
'\t' => "t",
_ => return None,
})
}
/// `StringTag.quoteAndEscape`.
pub fn quote_and_escape(input: &str) -> String {
let mut out = String::from(" ");
let mut quote = '\0';
for c in input.chars() {
if c == '\\' {
out.push_str("\\\\");
} else if c != '"' && c != '\'' {
match escape_control(c) {
Some(e) => {
out.push('\\');
out.push_str(e);
}
None => out.push(c),
}
} else {
if quote == '\0' {
quote = if c == '"' { '\'' } else { '"' };
}
if quote == c {
out.push('\\');
}
out.push(c);
}
}
if quote == '\0' {
quote = '"';
}
out.replace_range(0..1, &quote.to_string());
out.push(quote);
out
}
fn unquoted_key(key: &str) -> bool {
if key.eq_ignore_ascii_case("true") || key.eq_ignore_ascii_case("false") {
return false;
}
let mut chars = key.chars();
let first_ok = chars
.next()
.is_some_and(|c| c.is_ascii_alphabetic() || c == '.' || c == '_');
// `[A-Za-z._]+[A-Za-z0-9._+-]*`
first_ok && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '+' | '-'))
}
fn string_of(t: &horizon_world::nbt::JavaString) -> String {
t.to_string_lossy()
}
/// `Tag.toString` (StringTagVisitor).
pub fn to_snbt(tag: &Tag) -> String {
let mut out = String::new();
visit(tag, &mut out);
out
}
fn visit(tag: &Tag, out: &mut String) {
match tag {
Tag::End => out.push_str("END"),
Tag::Byte(v) => out.push_str(&format!("{v}b")),
Tag::Short(v) => out.push_str(&format!("{v}s")),
Tag::Int(v) => out.push_str(&v.to_string()),
Tag::Long(v) => out.push_str(&format!("{v}L")),
Tag::Float(v) => {
out.push_str(&java_float(f32::from_bits(*v)));
out.push('f');
}
Tag::Double(v) => {
out.push_str(&java_double(f64::from_bits(*v)));
out.push('d');
}
Tag::String(s) => out.push_str(&quote_and_escape(&string_of(s))),
Tag::ByteArray(a) => {
out.push_str("[B;");
out.push_str(
&a.iter()
.map(|v| format!("{}B", *v as i8))
.collect::<Vec<_>>()
.join(","),
);
out.push(']');
}
Tag::IntArray(a) => {
out.push_str("[I;");
out.push_str(&a.iter().map(i32::to_string).collect::<Vec<_>>().join(","));
out.push(']');
}
Tag::LongArray(a) => {
out.push_str("[L;");
out.push_str(
&a.iter()
.map(|v| format!("{v}L"))
.collect::<Vec<_>>()
.join(","),
);
out.push(']');
}
Tag::List(l) => {
out.push('[');
for (i, t) in l.iter().enumerate() {
if i > 0 {
out.push(',');
}
visit(t, out);
}
out.push(']');
}
Tag::Compound(c) => {
out.push('{');
let mut entries: Vec<(String, &Tag)> =
c.iter().map(|(k, v)| (string_of(k), v)).collect();
entries.sort_by(|a, b| a.0.cmp(&b.0));
for (i, (k, v)) in entries.iter().enumerate() {
if i > 0 {
out.push(',');
}
if unquoted_key(k) {
out.push_str(k);
} else {
out.push_str(&quote_and_escape(k));
}
out.push(':');
visit(v, out);
}
out.push('}');
}
}
}
use horizon_world::snbt_text::string_of;
pub use horizon_world::snbt_text::{java_double, java_float, quote_and_escape, to_snbt};
// ---- the coloured form -----------------------------------------------------
@@ -567,6 +567,34 @@ fn length(v: [f32; 2]) -> f32 {
(v[0] * v[0] + v[1] * v[1]).sqrt()
}
/// 8BitDo Ultimate 2 Wireless (`2dc8:6012`). gilrs' bundled SDL DB only has a
/// Windows row for it; on Linux/Bluetooth the right stick is on ABS_Z/ABS_RX
/// (`LeftZ`/`RightStickX`) until an SDL mapping matches.
pub const BITDO_ULTIMATE2_VID_PID: (u16, u16) = (0x2dc8, 0x6012);
/// Stick axes after correcting pads whose right stick gilrs still exposes on
/// `LeftZ`/`RightStickX` (no matching SDL row for this device version).
/// `sdl_mapped` is true when gilrs already applied an SDL gamecontroller row.
pub fn stick_axes(
left: [f32; 2],
right: [f32; 2],
left_z: f32,
vendor: Option<u16>,
product: Option<u16>,
sdl_mapped: bool,
) -> ([f32; 2], [f32; 2]) {
if !sdl_mapped
&& vendor == Some(BITDO_ULTIMATE2_VID_PID.0)
&& product == Some(BITDO_ULTIMATE2_VID_PID.1)
{
// Default map: RightStickX is the stick's Y, LeftZ its X; RightStickY
// is the right trigger and must not drive look.
(left, [left_z, right[0]])
} else {
(left, right)
}
}
/// A radial deadzone: inside it the stick is centred; beyond it the rest of
/// the travel is rescaled to 0..1 in the same direction, so diagonals aren't
/// clipped and the first movement past the deadzone is small.
@@ -1178,6 +1206,38 @@ mod tests {
assert_eq!(odd, PadSettings::default());
}
#[test]
fn ultimate2_right_stick_on_z_without_sdl() {
let (left, right) = stick_axes(
[0.2, 0.3],
[0.7, -0.9],
-0.5,
Some(BITDO_ULTIMATE2_VID_PID.0),
Some(BITDO_ULTIMATE2_VID_PID.1),
false,
);
assert_eq!(left, [0.2, 0.3]);
assert_eq!(right, [-0.5, 0.7], "X from LeftZ, Y from RightStickX");
let kept = stick_axes(
[0.2, 0.3],
[0.7, -0.9],
-0.5,
Some(BITDO_ULTIMATE2_VID_PID.0),
Some(BITDO_ULTIMATE2_VID_PID.1),
true,
);
assert_eq!(kept, ([0.2, 0.3], [0.7, -0.9]), "SDL row already remapped");
let other = stick_axes(
[0.0, 0.0],
[1.0, 0.0],
0.5,
Some(0x045e),
Some(0x028e),
false,
);
assert_eq!(other, ([0.0, 0.0], [1.0, 0.0]));
}
#[test]
fn radial_deadzone_rescales() {
assert_eq!(radial([0.1, 0.05], 0.15), [0.0, 0.0]);
+89 -4
View File
@@ -10,6 +10,10 @@ pub struct KeyState {
released_by_screen_when_down: bool,
toggle: Option<bool>,
keyboard: bool,
/// Held down by a controller's toggle (Horizon's pad toggle-sneak, which
/// vanilla has no option for): until it lets go, the mapping toggles as
/// if its vanilla option were on, so a screen releases and restores it.
pad_toggled: bool,
}
impl KeyState {
@@ -20,6 +24,7 @@ impl KeyState {
released_by_screen_when_down: false,
toggle: None,
keyboard: true,
pad_toggled: false,
}
}
/// A `ToggleKeyMapping`; `keyboard` is whether the bound key is a keyboard key.
@@ -43,16 +48,28 @@ impl KeyState {
true
}
}
/// The mapping toggles: its option is on, or a pad toggled it down.
fn needs_toggle(&self, option: bool) -> bool {
option || self.pad_toggled
}
/// `setDown`; `needs_toggle` is the mapping's option value.
pub fn set_down(&mut self, down: bool, needs_toggle: bool) {
if self.toggle.is_some() && needs_toggle {
if self.toggle.is_some() && self.needs_toggle(needs_toggle) {
if down {
self.is_down = !self.is_down;
// Toggled back up: the pad's toggle is over.
self.pad_toggled &= self.is_down;
}
} else {
self.is_down = down;
}
}
/// A controller's toggle button: flips the mapping like `setDown(true)`
/// with its option on, whatever the option says.
pub fn pad_toggle(&mut self) {
self.is_down = !self.is_down;
self.pad_toggled = self.is_down;
}
/// `shouldSetOnIngameFocus` for a keyboard-bound, bound key.
pub fn should_set_on_ingame_focus(&self, needs_toggle: bool) -> bool {
self.keyboard && (self.toggle.is_none() || !needs_toggle)
@@ -61,7 +78,8 @@ impl KeyState {
pub fn release(&mut self, needs_toggle: bool) {
if self.toggle.is_some() {
// ToggleKeyMapping.release does not clear pending clicks.
if needs_toggle && self.is_down || self.released_by_screen_when_down {
if self.needs_toggle(needs_toggle) && self.is_down || self.released_by_screen_when_down
{
self.released_by_screen_when_down = true;
}
self.reset();
@@ -70,7 +88,8 @@ impl KeyState {
self.set_down(false, needs_toggle);
}
}
/// `ToggleKeyMapping.reset`.
/// `ToggleKeyMapping.reset`. A pad toggle stays on record, so the screen
/// that released it restores it.
pub fn reset(&mut self) {
self.is_down = false;
}
@@ -83,7 +102,10 @@ impl KeyState {
let Some(restore) = self.toggle else {
return false;
};
let result = restore && needs_toggle && self.keyboard && self.released_by_screen_when_down;
let result = restore
&& self.needs_toggle(needs_toggle)
&& self.keyboard
&& self.released_by_screen_when_down;
self.released_by_screen_when_down = false;
result
}
@@ -138,6 +160,15 @@ impl ToggleKeys {
_ => {}
}
}
/// A controller's toggle button bound to `slot` (the pad's toggle-sneak):
/// a press with no screen open flips the mapping and clicks it, as a
/// key press does with the mapping's toggle option on.
pub fn pad_toggle(&mut self, slot: usize, screen_open: bool) {
if !screen_open {
self.0[slot].pad_toggle();
self.0[slot].click();
}
}
/// `MouseHandler.onButton` for a button bound to `slot`: only without a
/// screen or overlay.
pub fn button(&mut self, slot: usize, pressed: bool, screen_open: bool, needs_toggle: bool) {
@@ -167,6 +198,9 @@ impl ToggleKeys {
pub fn respawn(&mut self) {
for key in &mut self.0 {
key.reset();
// As with the vanilla option, the death screen closing after
// this still restores a toggle it released.
key.pad_toggled &= key.released_by_screen_when_down;
}
}
}
@@ -197,6 +231,57 @@ mod tests {
keys.respawn();
assert!(!keys.is_down(SPRINT));
}
/// #3061: the pad's toggle-sneak (vanilla's toggle crouch off) comes
/// back after a screen, as the vanilla option's toggle does; a
/// keyboard shift-click in the inventory does not drop it.
#[test]
fn pad_toggle_is_restored_after_a_screen() {
let off = [false; 4];
let mut keys = ToggleKeys::default();
keys.pad_toggle(SNEAK, false);
assert!(keys.is_down(SNEAK));
keys.screen_opened(off);
assert!(!keys.is_down(SNEAK));
keys.key(SNEAK, KeyAction::Press, true, false);
keys.key(SNEAK, KeyAction::Release, true, false);
keys.screen_closed(off);
assert!(keys.is_down(SNEAK), "the pad's toggle is restored");
// Toggled back up, it stays up through the next screen.
keys.pad_toggle(SNEAK, false);
assert!(!keys.is_down(SNEAK));
keys.screen_opened(off);
keys.screen_closed(off);
assert!(!keys.is_down(SNEAK));
// A held key is not restored, as before.
keys.key(SNEAK, KeyAction::Press, false, false);
keys.screen_opened(off);
keys.key(SNEAK, KeyAction::Release, true, false);
keys.screen_closed(off);
assert!(!keys.is_down(SNEAK));
}
/// After the pad's toggle is over (toggled up, or reset by a respawn
/// with no screen to restore it), the shift key holds again.
#[test]
fn pad_toggle_ends_with_its_release() {
let off = [false; 4];
let mut keys = ToggleKeys::default();
keys.pad_toggle(SNEAK, false);
keys.respawn();
assert!(!keys.is_down(SNEAK));
keys.key(SNEAK, KeyAction::Press, false, false);
keys.key(SNEAK, KeyAction::Release, false, false);
assert!(!keys.is_down(SNEAK), "held, not toggled");
// The death screen releases it and closes after the respawn: the
// toggle comes back, as with the vanilla option.
keys.pad_toggle(SNEAK, false);
keys.screen_opened(off);
keys.respawn();
keys.screen_closed(off);
assert!(keys.is_down(SNEAK));
// A press with a screen open does nothing.
keys.pad_toggle(SNEAK, true);
assert!(keys.is_down(SNEAK));
}
#[test]
fn screen_release_restores_toggled_state() {
let mut k = KeyState::toggle(true, true);
+1 -1
View File
@@ -32,7 +32,7 @@ flate2 = { version = "1.1.10", default-features = false, features = ["zlib-rs",
glam = "0.30"
log = "0.4"
miniz_oxide = "0.8"
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out"] }
naga = { version = "30.0.1", features = ["wgsl-in", "glsl-in", "hlsl-out"] }
png = "0.18"
pollster = "0.4"
serde = { version = "1", features = ["derive"] }
+136 -9
View File
@@ -47,6 +47,12 @@ struct Mixer {
listener_yaw: f32,
listener_pitch: f32,
limiter_peak: f32,
/// The output device the stream was opened on (`Library.currentDeviceName`).
#[cfg(feature = "audio")]
device_name: String,
/// The stream reported its device gone (`Library.isCurrentDeviceDisconnected`).
#[cfg(feature = "audio")]
device_lost: bool,
}
/// A sound the player could have heard (`SoundEventListener.onPlaySound`):
/// what the subtitle overlay needs.
@@ -167,6 +173,15 @@ pub struct Audio {
pub stopped: std::collections::BTreeMap<String, u32>,
/// The volume sliders' `SoundPreviewHandler`.
preview: crate::sound_preview::SoundPreview,
/// `SoundEngine.lastSeenDevices`: the system default output last seen.
#[cfg(feature = "audio")]
default_seen: Option<String>,
/// The other half of `lastSeenDevices`: every output device last seen.
#[cfg(feature = "audio")]
devices_seen: Vec<String>,
/// Ticks since the output devices were last looked at (`DeviceTracker.tick`).
#[cfg(feature = "audio")]
device_ticks: u32,
}
fn decode(path: &Path) -> Option<(Vec<f32>, u32)> {
use symphonia::core::{
@@ -506,19 +521,30 @@ fn open_device(
use cpal::SampleFormat as F;
let config = device.default_output_config()?;
let stream = match config.sample_format() {
F::F32 => build_stream::<f32>(device, &config, mixer),
F::F64 => build_stream::<f64>(device, &config, mixer),
F::I16 => build_stream::<i16>(device, &config, mixer),
F::I32 => build_stream::<i32>(device, &config, mixer),
F::I8 => build_stream::<i8>(device, &config, mixer),
F::U16 => build_stream::<u16>(device, &config, mixer),
F::U32 => build_stream::<u32>(device, &config, mixer),
F::U8 => build_stream::<u8>(device, &config, mixer),
F::F32 => build_stream::<f32>(device, &config, mixer.clone()),
F::F64 => build_stream::<f64>(device, &config, mixer.clone()),
F::I16 => build_stream::<i16>(device, &config, mixer.clone()),
F::I32 => build_stream::<i32>(device, &config, mixer.clone()),
F::I8 => build_stream::<i8>(device, &config, mixer.clone()),
F::U16 => build_stream::<u16>(device, &config, mixer.clone()),
F::U32 => build_stream::<u32>(device, &config, mixer.clone()),
F::U8 => build_stream::<u8>(device, &config, mixer.clone()),
other => Err(format!("unsupported sample format {other}").into()),
}?;
stream.play()?;
if let Ok(mut m) = mixer.lock() {
m.device_name = device.name().unwrap_or_default();
m.device_lost = false;
}
Ok((stream, config))
}
/// The name of the system's default output device.
#[cfg(feature = "audio")]
fn default_output_name() -> Option<String> {
cpal::default_host()
.default_output_device()
.and_then(|d| d.name().ok())
}
#[cfg(feature = "audio")]
fn build_stream<T: cpal::SizedSample + cpal::FromSample<f32>>(
device: &cpal::Device,
@@ -528,6 +554,7 @@ fn build_stream<T: cpal::SizedSample + cpal::FromSample<f32>>(
let rate = config.sample_rate().0;
let channels = config.channels() as usize;
let mut scratch = Vec::new();
let on_error = mixer.clone();
let stream = device.build_output_stream(
&config.config(),
move |data: &mut [T], _| {
@@ -541,7 +568,14 @@ fn build_stream<T: cpal::SizedSample + cpal::FromSample<f32>>(
*out = T::from_sample(*s);
}
},
|e| log::warn!("Horizon: audio stream error: {e}"),
move |e| {
log::warn!("Horizon: audio stream error: {e}");
if matches!(e, cpal::StreamError::DeviceNotAvailable)
&& let Ok(mut m) = on_error.lock()
{
m.device_lost = true;
}
},
None,
)?;
Ok(stream)
@@ -637,6 +671,12 @@ impl Audio {
heard: Default::default(),
stopped: Default::default(),
preview: Default::default(),
#[cfg(feature = "audio")]
default_seen: if live { default_output_name() } else { None },
#[cfg(feature = "audio")]
devices_seen: if live { output_devices() } else { Vec::new() },
#[cfg(feature = "audio")]
device_ticks: 0,
}
}
#[cfg(not(feature = "audio"))]
@@ -764,8 +804,64 @@ impl Audio {
}
self.engine.listening = self.record_heard;
}
/// `SoundEngine.shouldChangeDevice`: the open device was lost, the system
/// default changed (with no device chosen in the options), or the chosen
/// device came back; the output is then opened again. The devices are looked
/// at once a second.
#[cfg(feature = "audio")]
fn should_change_device(&mut self) -> bool {
let (current, lost) = match self.backend.mixer.lock() {
Ok(mut m) => (m.device_name.clone(), std::mem::take(&mut m.device_lost)),
Err(_) => return false,
};
if lost {
log::info!("Horizon: audio device was lost");
self.default_seen = default_output_name();
self.devices_seen = output_devices();
return true;
}
self.device_ticks += 1;
if self._stream.is_none() || self.device_ticks < 20 {
return false;
}
self.device_ticks = 0;
let default = default_output_name();
let devices = output_devices();
// Only a change in the device lists is looked at, as in vanilla: a
// preferred device that is listed but cannot open is not retried.
if default == self.default_seen && devices == self.devices_seen {
return false;
}
self.default_seen = default.clone();
let wanted = self.device_name.clone();
let available = devices.contains(&wanted);
self.devices_seen = devices;
if wanted.is_empty() {
let changed = default.as_deref().is_some_and(|d| d != current);
if changed {
log::info!("Horizon: system default audio device has changed");
}
return changed;
}
let changed = current != wanted && available;
if changed {
log::info!("Horizon: preferred audio device has become available");
}
changed
}
/// `SoundManager.tick(paused)`, once per client tick.
pub fn tick(&mut self, paused: bool, ctx: &TickContext) {
#[cfg(feature = "audio")]
{
if self.should_change_device() {
// SoundEngine.reload: stop every sound, open the output again.
self.stop_all();
let device = self.device_name.clone();
if let Err(e) = self.set_device(&device) {
log::warn!("Horizon: could not reopen the audio output: {e}");
}
}
}
self.sync_volumes();
self.engine.tick(paused, ctx, &mut self.backend);
}
@@ -801,6 +897,37 @@ impl Audio {
let seed = self.random.next_long();
self.play_seeded(event, at, volume, pitch, seed);
}
/// `ClientLevel.playLocalSound` with `distanceDelay`: a sound more than 10
/// blocks from the camera starts `sqrt(distance) / 40` seconds later.
pub fn play_distance_delayed(
&mut self,
event: &str,
at: [f64; 3],
volume: f32,
pitch: f32,
camera: [f64; 3],
) {
let distance_sqr: f64 = (0..3).map(|i| (at[i] - camera[i]).powi(2)).sum();
if distance_sqr <= 100.0 {
self.play(event, Some(at), volume, pitch);
return;
}
let seed = self.random.next_long();
if let Some(capture) = self.capture.as_mut() {
capture.push((event.to_owned(), at, volume, pitch));
}
*self.heard.entry(event.to_owned()).or_default() += 1;
self.sync_volumes();
let event = crate::sound_events::normalize(event);
let source = self
.source_override
.unwrap_or_else(|| crate::settings::sound_source(&event));
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);
}
/// `play` for this client only: a sound a packet's handler plays
/// (`playLocalSound`), which the guests play from that packet themselves.
pub fn play_unshared(&mut self, event: &str, at: Option<[f64; 3]>, volume: f32, pitch: f32) {
@@ -24,6 +24,9 @@ struct Out {
return out;
}
@fragment fn fs_border(in: Out) -> @location(0) vec4<f32> {
return border_color(in);
}
fn border_color(in: Out) -> vec4<f32> {
let color = textureSample(forcefield, forcefield_sampler, in.uv);
if color.a == 0.0 {
discard;
+161 -18
View File
@@ -21,6 +21,8 @@ const COMMAND_HISTORY_FILE: &str = "command_history.txt";
const MAX_PERSISTED_COMMAND_HISTORY: usize = 50;
/// GuiMessageTag.systemSinglePlayer's indicator colour.
pub const SYSTEM_INDICATOR: u32 = 0xD0D0D0;
/// `GuiMessageTag.CHAT_MODIFIED_INDICATOR_COLOR`.
const MODIFIED_INDICATOR: u32 = 0x60_6060;
/// A player message's signature (MessageSignature), which names it to a
/// server's deletion.
pub type Signature = horizon_protocol::secure_chat::Signature;
@@ -169,7 +171,7 @@ impl ChatOptions {
struct Message {
added: u64,
content: Text,
indicator: Option<u32>,
tag: Option<std::sync::Arc<Tag>>,
source: ChatSource,
/// `GuiMessage.signature`: a signed player message's, for deletion.
signature: Option<Box<Signature>>,
@@ -179,7 +181,46 @@ struct Message {
struct Line {
added: u64,
content: Text,
indicator: Option<u32>,
tag: Option<std::sync::Arc<Tag>>,
}
/// `GuiMessageTag`: the bar at the left of a message's lines, the tooltip
/// over it, and for a modified message the icon after each line.
#[derive(Clone, Debug, PartialEq)]
pub struct Tag {
pub color: u32,
/// `GuiMessageTag.Icon.CHAT_MODIFIED`.
pub icon: bool,
pub text: Text,
}
impl Tag {
/// `GuiMessageTag.systemSinglePlayer`, in `color`.
pub fn system_single_player(color: u32) -> Self {
Self {
color,
icon: false,
text: Text::translate("chat.tag.system_single_player", &[]),
}
}
/// `GuiMessageTag.chatNotSecure`.
pub fn not_secure() -> Self {
Self {
color: SYSTEM_INDICATOR,
icon: false,
text: Text::translate("chat.tag.not_secure", &[]),
}
}
/// `GuiMessageTag.chatModified`: the tooltip adds the original content in grey.
pub fn modified(original_content: &str) -> Self {
Self {
color: MODIFIED_INDICATOR,
icon: true,
text: Text::empty()
.append(Text::translate("chat.tag.modified", &[]))
.append(Text::literal("\n"))
.append(Text::literal(original_content).with_color(text::GRAY)),
}
}
}
/// `ChatComponent.AlphaCalculator.timeBased`: opaque for 180 ticks, then
@@ -236,7 +277,8 @@ impl Chat {
ticks: u64,
focused: bool,
) {
self.add_message(&atlas.font, content, indicator, source, ticks, focused);
let tag = indicator.map(|color| std::sync::Arc::new(Tag::system_single_player(color)));
self.add_message(&atlas.font, content, tag, source, ticks, focused);
}
/// `add` of a player message with its signature, which a server's
/// deletion names it by (`GuiMessage.signature`).
@@ -245,11 +287,13 @@ impl Chat {
atlas: &HudAtlas,
content: Text,
signature: Option<Signature>,
tag: Option<Tag>,
ticks: u64,
focused: bool,
) {
self.add_signed_message(&atlas.font, content, signature, ticks, focused);
self.add_player_message(&atlas.font, content, signature, tag, ticks, focused);
}
#[cfg(test)]
fn add_signed_message(
&mut self,
font: &crate::font::Font,
@@ -257,13 +301,25 @@ impl Chat {
signature: Option<Signature>,
ticks: u64,
focused: bool,
) {
self.add_player_message(font, content, signature, None, ticks, focused);
}
/// `ChatComponent.addPlayerMessage`: the tag the message's trust level made.
fn add_player_message(
&mut self,
font: &crate::font::Font,
content: Text,
signature: Option<Signature>,
tag: Option<Tag>,
ticks: u64,
focused: bool,
) {
self.push_message(
font,
Message {
added: ticks,
content,
indicator: None,
tag: tag.map(std::sync::Arc::new),
source: ChatSource::Player,
signature: signature.map(Box::new),
},
@@ -274,7 +330,7 @@ impl Chat {
&mut self,
font: &crate::font::Font,
content: Text,
indicator: Option<u32>,
tag: Option<std::sync::Arc<Tag>>,
source: ChatSource,
ticks: u64,
focused: bool,
@@ -284,7 +340,7 @@ impl Chat {
Message {
added: ticks,
content,
indicator,
tag,
source,
signature: None,
},
@@ -307,7 +363,13 @@ impl Chat {
self.received += 1;
}
fn add_lines(&mut self, font: &crate::font::Font, message: &Message, focused: bool) {
let width = self.max_width();
// GuiMessage.splitLines: room for the tag's icon (its width, 4 before and 2 after).
let icon = if message.tag.as_ref().is_some_and(|tag| tag.icon) {
9 + 4 + 2
} else {
0
};
let width = self.max_width() - icon;
let content = self.displayed(&message.content);
for line in font.wrap_components(&content, width) {
if focused && self.scroll > 0 {
@@ -319,7 +381,7 @@ impl Chat {
Line {
added: message.added,
content: line,
indicator: message.indicator,
tag: message.tag.clone(),
},
);
}
@@ -416,7 +478,9 @@ impl Chat {
*message = Message {
added: message.added,
content: Self::deleted_marker(),
indicator: Some(SYSTEM_INDICATOR),
tag: Some(std::sync::Arc::new(Tag::system_single_player(
SYSTEM_INDICATOR,
))),
source: ChatSource::Server,
signature: None,
};
@@ -540,8 +604,8 @@ impl Chat {
shadow: true,
});
}
if let Some(indicator) = line.indicator {
let color = hud_layout::as8(opacity) << 24 | indicator & 0xFF_FFFF;
if let Some(tag) = &line.tag {
let color = hud_layout::as8(opacity) << 24 | tag.color & 0xFF_FFFF;
out.push(hud_layout::fill(
-4,
bottom - entry_height,
@@ -702,12 +766,91 @@ impl Chat {
}
None
}
/// Whether the cursor is on a line's indicator bar while chat is focused
/// (`DrawingFocusedGraphicsAccess.handleTag`), whose tooltip is the
/// message tag's text.
pub fn indicator_at(&self, height: i32, x: f64, y: f64) -> bool {
self.line_at(height, x, y)
.is_some_and(|(line, x)| line.indicator.is_some() && (-4.0..-2.0).contains(&x))
/// `DrawingFocusedGraphicsAccess.handleTag` and `handleTagIcon` with the
/// cursor at GUI position `(x, y)` while chat is focused: the tooltip of
/// the tag under it (its bar, or its icon) and the icons to draw, those
/// under the cursor and those of the message the cursor is over.
pub fn focused_tags(
&self,
font: &crate::font::Font,
height: i32,
x: f64,
y: f64,
) -> (Option<Text>, Vec<GuiDraw>) {
let o = &self.options;
let scale = o.scale as f32;
let chat_bottom = hud_layout::floor((height - 40) as f32 / scale);
let entry_height = o.line_height();
let bottom_to_text =
(8.0 * (o.line_spacing + 1.0) - 4.0 * o.line_spacing + 0.5).floor() as i32;
let (mouse_x, mouse_y) = (x / f64::from(scale) - 4.0, y / f64::from(scale));
let inside = |left: i32, top: i32, right: i32, bottom: i32| {
mouse_x >= f64::from(left)
&& mouse_x < f64::from(right)
&& mouse_y >= f64::from(top)
&& mouse_y < f64::from(bottom)
};
let visible = self
.lines
.len()
.saturating_sub(self.scroll)
.min(self.lines_per_page(true));
let mut tooltip = None;
let mut icons = Vec::new();
let mut hovered_message = false;
for i in (0..visible).rev() {
let index = i + self.scroll;
let line = &self.lines[index];
let bottom = chat_bottom - i as i32 * entry_height;
let top = bottom - entry_height;
let width = font.width_text(&line.content).ceil() as i32;
hovered_message |= inside(0, top, width, bottom);
// GuiMessage.Line.endOfEntry: the next newer line is another message's.
let end_of_entry = index == 0
|| match (&line.tag, &self.lines[index - 1].tag) {
(Some(a), Some(b)) => !std::sync::Arc::ptr_eq(a, b),
(None, None) => false,
_ => true,
};
let force_icon = end_of_entry && hovered_message;
if end_of_entry {
hovered_message = false;
}
let Some(tag) = &line.tag else { continue };
if inside(-4, top, -2, bottom) {
tooltip = Some(tag.text.clone());
}
if tag.icon {
let (left, text_bottom) = (width + 4, bottom - bottom_to_text + 9);
let icon_top = text_bottom - 9 - 1;
let over = inside(left, icon_top, left + 9, text_bottom);
if over {
tooltip = Some(tag.text.clone());
}
if force_icon || over {
icons.push(GuiDraw::Sprite {
sprite: "icon/chat_modified".into(),
x: left,
y: icon_top,
w: 9,
h: 9,
region: None,
color: hud_layout::WHITE,
blend: hud_layout::Blend::Textured,
});
}
}
}
if icons.is_empty() {
return (tooltip, icons);
}
let mut draws = vec![GuiDraw::Push(vec![
PoseOp::Scale(scale, scale),
PoseOp::Translate(4.0, 0.0),
])];
draws.extend(icons);
draws.push(GuiDraw::Pop);
(tooltip, draws)
}
/// Plain text of the newest messages (for tests and the control socket).
pub fn recent_lines(&self, n: usize) -> Vec<String> {
@@ -33,7 +33,7 @@ fn add(chat: &mut Chat, font: &Font, content: Text) {
Line {
added: 0,
content,
indicator: None,
tag: None,
},
);
}
@@ -557,7 +557,7 @@ fn a_deleted_message_shows_60_ticks_then_becomes_the_marker() {
assert_eq!(lines(&chat), ["<Alex> hi".to_owned(), marker.clone()]);
assert!(chat.deletions.is_empty());
let line = &chat.lines[1];
assert_eq!(line.indicator, Some(SYSTEM_INDICATOR));
assert_eq!(line.tag.as_ref().map(|t| t.color), Some(SYSTEM_INDICATOR));
assert_eq!(line.added, 100, "the marker keeps the message's time");
assert!(
line.content
@@ -223,6 +223,35 @@ pub fn install(dir: &Path, world: &Path, selected: &[String]) -> Result<(), Stri
let file = world.join("datapacks.json");
std::fs::write(&file, json.to_string()).map_err(|e| err(&file, e))
}
/// Re-Create World's packs from a saved world: a new folder holding a copy of
/// its `datapacks` (`CreateWorldScreen.createTempDataPackDirFromExistingWorld`; `None` when the
/// world has none), and the selection its `datapacks.json` makes, lowest
/// priority first (`WorldOpenFlows.recreateWorldData` keeps the world's `DataConfiguration`).
/// The built-in pack is selected unless the world turned it off.
pub fn for_recreate(world: &Path) -> (Option<PathBuf>, Vec<String>) {
let packs = discover(world);
let mut selected = enabled_ids(world, &packs);
let vanilla_off = std::fs::read(world.join("datapacks.json"))
.ok()
.and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok())
.and_then(|v| v["disabled"].as_array().cloned())
.is_some_and(|a| a.iter().any(|id| id.as_str() == Some("vanilla")));
if !vanilla_off {
selected.insert(0, "vanilla".into());
}
if packs.is_empty() {
return (None, selected);
}
let dir = temp_folder().ok().and_then(|dir| {
if copy(&world.join("datapacks"), &dir).is_ok() {
Some(dir)
} else {
let _ = std::fs::remove_dir_all(&dir);
None
}
});
(dir, selected)
}
/// A file, or a folder and everything in it.
fn copy(from: &Path, to: &Path) -> Result<(), String> {
let err = |e: std::io::Error| format!("{} -> {}: {e}", from.display(), to.display());
+24 -4
View File
@@ -23,6 +23,7 @@ struct Globals {
// Horizon Extras' dynamic lights: camera-relative position and level (dynamic_light.rs).
dynamic_lights: array<vec4<f32>, 16>,
sunrise_fan: array<vec4<f32>, 32>,
filtering: vec4<f32>,
}
@group(0) @binding(0) var<uniform> globals: Globals;
@group(0) @binding(1) var atlas: texture_2d<f32>;
@@ -131,7 +132,7 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
@location(5) normal: vec4<f32>, @location(6) glint_uv: vec2<f32>) -> Out {
var out = entity_vertex(position, uv, overlay & 0xffffu, color, light, normal);
out.glint_uv = glint_uv;
out.tier = overlay >> 16u;
out.tier = (overlay >> 16u) & 0xffu;
return out;
}
// Retained bolt-local geometry, translated using the same f64 camera
@@ -224,6 +225,9 @@ fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>,
}
// CRUMBLING_UV_END
@fragment fn fs_entity(in: Out, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
return entity_color(in, front);
}
fn entity_color(in: Out, front: bool) -> vec4<f32> {
var uv = in.uv;
if in.overlay == 6u { uv.y = in.wrap.x + fract(in.uv.y) * in.wrap.y; }
if in.overlay == 11u || in.overlay == 12u {
@@ -271,6 +275,9 @@ fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>,
// rendertype_entity_shadow.fsh: the shadow texture times the vertex colour,
// then fog, with no alpha cutout, so a faint shadow in dim light still draws.
@fragment fn fs_entity_shadow(in: Out) -> @location(0) vec4<f32> {
return shadow_color(in);
}
fn shadow_color(in: Out) -> vec4<f32> {
var color = textureSample(atlas, atlas_sampler, in.uv) * in.color;
color *= in.light_color;
let f = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
@@ -306,21 +313,31 @@ fn celestial_color(in: Out) -> vec4<f32> {
@fragment fn fs_sky_celestial(in: Out) -> @location(0) vec4<f32> {
let color = celestial_color(in);
let added = color.rgb * color.a;
return vec4(added, smoothstep(0.1, 0.3, max(added.r, max(added.g, added.b))));
// The sky under the disc keeps alpha 1 less this: 0 for the brightest tier
// (the sun), terrain.wgsl's mark for the lower ones (the moon).
let mark = 1.0 - 0.7 * f32(4u - min(in.tier, 4u)) / 4.0;
return vec4(added, mark * smoothstep(0.1, 0.3, max(added.r, max(added.g, added.b))));
}
// RenderPipelines.ENERGY_SWIRL (entity.fsh with EMISSIVE and NO_OVERLAY): the sprite, scrolled by
// the UV and repeated within itself, times the layer colour 0xFF808080, unlit; the pipeline adds
// it to the frame. The light bytes carry the sprite's origin and the colour bytes its size, both
// as breaking::stage_light packs them.
@fragment fn fs_energy_swirl(in: Out) -> @location(0) vec4<f32> {
fn energy_swirl_base(in: Out) -> vec4<f32> {
let bytes = round(in.color * 255.0);
let size = vec2(bytes.x + bytes.y * 256.0, bytes.z + bytes.w * 256.0) / 65535.0;
let color = textureSample(atlas, atlas_sampler, crumbling_uv(in.uv, in.wrap, size, textureDimensions(atlas)));
if color.a < 0.1 { discard; }
return vec4(color.rgb * (128.0 / 255.0), color.a);
}
fn energy_swirl_fog(in: Out) -> f32 {
let f = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w));
return vec4(mix(color.rgb * (128.0 / 255.0), globals.fog_color.rgb, f * globals.fog_color.a), color.a);
return f * globals.fog_color.a;
}
@fragment fn fs_energy_swirl(in: Out) -> @location(0) vec4<f32> {
let color = energy_swirl_base(in);
return vec4(mix(color.rgb, globals.fog_color.rgb, energy_swirl_fog(in)), color.a);
}
// RenderPipelines.WATER_MASK: depth only (the pipeline masks the colour off).
@@ -449,6 +466,9 @@ struct WeatherTime { values: vec4<f32> }
return out;
}
@fragment fn fs_weather(in: Out) -> @location(0) vec4<f32> {
return weather_color(in);
}
fn weather_color(in: Out) -> vec4<f32> {
// Repeat within this sprite, never across the atlas. The atlas uses nearest
// filtering and no mipmaps, so fractional UVs have no seam derivatives.
let texel = weather_texel(in.uv, in.weather_rect, textureDimensions(atlas));
+384 -59
View File
@@ -40,7 +40,7 @@ mod entity_outline;
mod weather;
use entity_batch::EntityBatch;
pub use entity_batch::{
ENERGY_SWIRL_OVERLAY, SHADOW_OVERLAY, TEXT_POLYGON_OFFSET_OVERLAY,
ENERGY_SWIRL_OVERLAY, OIT_ENTITY, SHADOW_OVERLAY, TEXT_POLYGON_OFFSET_OVERLAY,
TRANSLUCENT_GLINT_ITEM_OVERLAY, TRANSLUCENT_ITEM_OVERLAY, TRANSLUCENT_PARTICLE_OVERLAY,
WATER_MASK_OVERLAY,
};
@@ -84,6 +84,8 @@ mod native_pack;
mod native_post;
mod native_sky;
mod occlusion;
mod oit;
mod oit_features;
mod pack_atlas;
pub mod pack_draw;
mod pack_entities;
@@ -224,6 +226,8 @@ struct Globals {
dynamic_lights: [[f32; 4]; crate::dynamic_light::MAX],
/// The sunrise fan's triangles as the sky shader tests them (`sunrise_fan::normals`).
sunrise_fan: [[f32; 4]; 2 * sunrise_fan::TRIANGLES],
/// x: `Options.textureFiltering` (0 none, 1 RGSS, 2 anisotropic); y: the sprite arrays' width.
filtering: [f32; 4],
}
pub type SectionKey = (i32, i32, i32);
struct SectionGpu {
@@ -542,6 +546,10 @@ pub struct Renderer {
/// terrain.wgsl's `SPRITE_ARRAYS` and `SPRITE_SPLIT`, for a shader pack's terrain.
terrain_sprites: [f64; 2],
sprite_sampler: wgpu::Sampler,
/// `Options.textureFiltering`: 0 none, 1 RGSS, 2 anisotropic.
texture_filtering: u32,
/// The anisotropic sampler's clamp (`Options.maxAnisotropyValue`); 1 unless anisotropic.
max_anisotropy: u16,
lightmap_view: wgpu::TextureView,
lightmap_sampler: wgpu::Sampler,
animations: animate::SpriteAnimator,
@@ -672,6 +680,10 @@ pub struct Renderer {
pub gamma: f32,
/// Optional full-light lightmap (Horizon Extras).
pub fullbright: bool,
/// The Improved transparency option (`GameRenderer.useImprovedTransparency`).
pub improved_transparency: bool,
/// Its passes and targets, made when first drawn with.
oit: Option<oit::Oit>,
/// Advance animated block textures (Horizon setting; vanilla always does).
pub animated_textures: bool,
present_modes: Vec<wgpu::PresentMode>,
@@ -723,6 +735,36 @@ fn sprite_views(
})
.collect()
}
/// The terrain sprites' sampler for a Texture Filtering mode. None keeps the nearest sampler;
/// RGSS and anisotropic take LevelRenderer.chunkLayerSampler's linear one (clamped to the edge),
/// with `anisotropy` samples when it is above 1 (wgpu then needs a linear mipmap filter too).
fn sprite_sampler_descriptor(filtering: u32, anisotropy: u16) -> wgpu::SamplerDescriptor<'static> {
if filtering == 0 {
return wgpu::SamplerDescriptor {
label: Some("Block sprites"),
address_mode_u: wgpu::AddressMode::Repeat,
address_mode_v: wgpu::AddressMode::Repeat,
mag_filter: wgpu::FilterMode::Nearest,
min_filter: wgpu::FilterMode::Nearest,
mipmap_filter: wgpu::MipmapFilterMode::Nearest,
..Default::default()
};
}
wgpu::SamplerDescriptor {
label: Some("Block sprites (filtered)"),
address_mode_u: wgpu::AddressMode::ClampToEdge,
address_mode_v: wgpu::AddressMode::ClampToEdge,
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
mipmap_filter: wgpu::MipmapFilterMode::Linear,
anisotropy_clamp: anisotropy,
..Default::default()
}
}
/// `Options.maxAnisotropyValue` for a `maxAnisotropyBit`: `1 << bit`, at most wgpu's 16.
fn sampler_anisotropy(bit: i32) -> u16 {
1u16 << bit.clamp(0, 4)
}
/// terrain.wgsl's group 0.
#[allow(clippy::too_many_arguments)]
fn terrain_group(
@@ -1119,6 +1161,7 @@ async fn open_backend(
// stand-in neither clamps nor drops NaN.
let features = timing::GpuTimer::features(&adapter)
| cull::Cull::features(&adapter)
| oit::features(&adapter)
| (adapter.features()
& (wgpu::Features::INDIRECT_FIRST_INSTANCE
| wgpu::Features::MAPPABLE_PRIMARY_BUFFERS
@@ -1701,15 +1744,7 @@ impl Renderer {
..Default::default()
});
let sprite_views = sprite_views(&sprite_textures, &sprite_unused, None);
let sprite_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("Block sprites"),
address_mode_u: wgpu::AddressMode::Repeat,
address_mode_v: wgpu::AddressMode::Repeat,
mag_filter: wgpu::FilterMode::Nearest,
min_filter: wgpu::FilterMode::Nearest,
mipmap_filter: wgpu::MipmapFilterMode::Nearest,
..Default::default()
});
let sprite_sampler = device.create_sampler(&sprite_sampler_descriptor(0, 1));
let lightmap = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Lightmap"),
size: wgpu::Extent3d {
@@ -2728,7 +2763,7 @@ impl Renderer {
}
cull
});
let depth = Self::depth(&device, &config, cull.as_ref());
let depth = Self::depth(&device, &config);
let world_post = world_post::Post::new(&device, config.format.remove_srgb_suffix());
let uploads = upload::Uploads::new(&device);
let section_uploads = upload::Uploads::with_chunk(&device, SECTION_UPLOAD_CHUNK);
@@ -2840,6 +2875,8 @@ impl Renderer {
terrain_layout: bindings,
terrain_sprites: sprite_constants.map(|(_, v)| v),
sprite_sampler,
texture_filtering: 0,
max_anisotropy: 1,
lightmap_view,
lightmap_sampler,
animations,
@@ -2914,6 +2951,8 @@ impl Renderer {
screenshot: None,
screenshot_result: None,
gamma: 0.5,
improved_transparency: false,
oit: None,
fullbright: false,
animated_textures: true,
present_modes,
@@ -2998,13 +3037,8 @@ impl Renderer {
self.surface = Some(surface);
Ok(())
}
/// The world's depth, which occlusion culling's test may read.
fn depth(
device: &wgpu::Device,
config: &wgpu::SurfaceConfiguration,
cull: Option<&cull::Cull>,
) -> wgpu::TextureView {
let read = cull.is_some_and(|c| c.depth_test() == cull::DepthTest::InShader);
/// The world's depth, which occlusion culling's test and post effects may read.
fn depth(device: &wgpu::Device, config: &wgpu::SurfaceConfiguration) -> wgpu::TextureView {
device
.create_texture(&wgpu::TextureDescriptor {
label: Some("Horizon depth"),
@@ -3017,11 +3051,11 @@ impl Renderer {
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Depth32Float,
usage: if read {
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING
} else {
wgpu::TextureUsages::RENDER_ATTACHMENT
},
// TEXTURE_BINDING: a post effect may read the depth buffer.
// COPY_SRC: improved transparency copies it.
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
})
.create_view(&Default::default())
@@ -3085,7 +3119,7 @@ impl Renderer {
} else {
self.offscreen = Some(Self::offscreen_target(&self.device, &self.config));
}
self.depth = Self::depth(&self.device, &self.config, self.cull.as_ref());
self.depth = Self::depth(&self.device, &self.config);
}
/// Whether sections are drawn as one index list per frame (`flat`).
pub fn flat_draws(&self) -> bool {
@@ -3805,6 +3839,44 @@ impl Renderer {
pub fn shader_pack_dimension(&self) -> Option<i32> {
self.pack_post.as_ref().map(|p| p.0)
}
/// The Texture Filtering and Max Anisotropy options (`filtering`: 0 none, 1 RGSS, 2
/// anisotropic; `bit`: `maxAnisotropyBit`). None is the nearest sampler terrain always used;
/// the others take LevelRenderer's chunk layer sampler (linear, clamped, `bit` anisotropy
/// when anisotropic) and terrain.wgsl's sampleNearest / sampleRGSS.
pub fn set_texture_filtering(&mut self, filtering: i32, bit: i32) {
let filtering = filtering.clamp(0, 2) as u32;
let anisotropy = if filtering == 2 {
sampler_anisotropy(bit)
} else {
1
};
if filtering == self.texture_filtering && anisotropy == self.max_anisotropy {
return;
}
self.texture_filtering = filtering;
self.max_anisotropy = anisotropy;
self.sprite_sampler = self
.device
.create_sampler(&sprite_sampler_descriptor(filtering, anisotropy));
self.rebuild_terrain_group();
}
fn rebuild_terrain_group(&mut self) {
let views = sprite_views(
&self.sprite_textures,
&self.sprite_unused,
Some(self.mipmaps + 1),
);
self.group = terrain_group(
&self.device,
"Horizon terrain resources",
&self.terrain_layout,
&self.globals,
&views,
&self.sprite_sampler,
&self.lightmap_view,
&self.lightmap_sampler,
);
}
/// The Mipmap Levels option: block textures sample at most `levels`
/// reduced copies (0: none).
pub fn set_mipmap_levels(&mut self, levels: u32) {
@@ -3818,17 +3890,7 @@ impl Renderer {
return;
}
self.mipmaps = levels;
let views = sprite_views(&self.sprite_textures, &self.sprite_unused, Some(levels + 1));
self.group = terrain_group(
&self.device,
"Horizon terrain resources",
&self.terrain_layout,
&self.globals,
&views,
&self.sprite_sampler,
&self.lightmap_view,
&self.lightmap_sampler,
);
self.rebuild_terrain_group();
}
/// The views `render` draws `frame` from.
pub fn views(&self, frame: &Frame) -> ViewSet {
@@ -4148,6 +4210,12 @@ impl Renderer {
lights
},
sunrise_fan: sunrise_fan::normals(frame.sky.sunrise_color[3], sun),
filtering: [
self.texture_filtering as f32,
self.sprite_textures[0].width() as f32,
0.0,
0.0,
],
};
self.uploads
.buffer(&mut encoder, &self.globals, 0, bytemuck::bytes_of(&globals));
@@ -4700,6 +4768,13 @@ impl Renderer {
);
entities = &self.pack_entity_rest;
}
// GameRenderer.useImprovedTransparency. A shader pack keeps its own
// path.
let oit_on = self.improved_transparency
&& !frame.title
&& self.pack_post.as_ref().is_none_or(|p| p.1.is_none())
&& self.native.is_none()
&& oit::supported(&self.device);
let [
entity_quads,
text_quads,
@@ -4710,9 +4785,14 @@ impl Renderer {
shadow_quads,
translucent_particle_quads,
translucent_item_quads,
] = self
.entity_batch
.update(entities, frame.additive, frame.hand, frame.see_through);
oit_entity_quads,
] = self.entity_batch.update(
entities,
frame.additive,
frame.hand,
frame.see_through,
oit_on,
);
let weather_quads = frame.weather.len() as u32 / 4;
let world_quads = entity_quads
+ text_quads
@@ -4722,7 +4802,11 @@ impl Renderer {
+ swirl_quads
+ shadow_quads
+ translucent_particle_quads
+ translucent_item_quads;
+ translucent_item_quads
+ oit_entity_quads;
// 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 (rebuilt, uniform) =
crate::spans::time("clouds", || self.cloud_mesh.prepare(input));
@@ -4972,7 +5056,7 @@ impl Renderer {
}
let pack_reads_world = pack_post || native_post;
// Phase 2 of occlusion culling draws over the depth, and a shader pack or TAA reads it.
let keep_depth = pack_reads_world || (taa && self.world_post.taa);
let keep_depth = pack_reads_world || (taa && self.world_post.taa) || oit_on;
// The pack's program reads the vertices this frame's draws bind.
let pack_draws_terrain = pack_terrain && flat_frame == self.pack_terrain_flat;
// The shadow camera's lists, drawn by the pack's shadow programs.
@@ -5025,6 +5109,46 @@ impl Renderer {
if culling && let Some(cull) = &mut self.cull {
cull.bind_depth(&self.device, world_depth);
}
if oit_on {
let size = self
.world_post
.size()
.filter(|_| scaled.is_some())
.unwrap_or([self.config.width, self.config.height]);
let (device, layout, sprites) =
(&self.device, &self.terrain_layout, self.terrain_sprites);
let clouds = [0, 1].map(|i| self.cloud_pipeline.get_bind_group_layout(i));
let format = self.config.format.remove_srgb_suffix();
let flat = self
.flat
.as_ref()
.map(|f| f.translucent.get_bind_group_layout(1));
let features = [
self.entity_pipeline.get_bind_group_layout(0),
self.weather.pipeline().get_bind_group_layout(1),
self.border_pipeline.get_bind_group_layout(0),
];
self.oit
.get_or_insert_with(|| {
oit::Oit::new(
device,
layout,
flat.as_ref(),
[&features[0], &features[1], &features[2]],
[&clouds[0], &clouds[1]],
sprites,
format,
)
})
.prepare(
&self.device,
&self.queue,
size,
&views.primary().projection,
[water_mask_quads > 0, cloud_quads > 0],
);
}
let oit = self.oit.as_ref().filter(|_| oit_on);
let cull = self.cull.as_ref().filter(|_| culling && solid_draws > 0);
let clear = if frame.title || (frame.end_sky && !frame.underwater && sky_open(frame)) {
wgpu::Color::BLACK
@@ -5262,29 +5386,29 @@ impl Renderer {
}
pass.set_bind_group(0, &self.group, &[]);
}
if text_quads > 0 {
if text_quads > 0 && !oit_on {
pass.set_pipeline(&self.text_polygon_offset_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.draw_indexed(0..text_quads * 6, (entity_quads * 4) as i32, 0..1);
pass.set_bind_group(0, &self.group, &[]);
}
if translucent_item_quads > 0 {
if translucent_item_quads > 0 && !oit_on {
pass.set_pipeline(&self.translucent_item_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.draw_indexed(
0..translucent_item_quads * 6,
((world_quads - translucent_item_quads) * 4) as i32,
((base_quads - translucent_item_quads) * 4) as i32,
0..1,
);
pass.set_bind_group(0, &self.group, &[]);
}
if !pack_draws_weather {
if !pack_draws_weather && !oit_on {
self.weather.draw(&mut pass, &self.entity_group);
}
pass.set_bind_group(0, &self.group, &[]);
if crumbling_quads > 0 {
if crumbling_quads > 0 && !oit_on {
pass.set_pipeline(&self.crumbling_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
@@ -5295,13 +5419,13 @@ impl Renderer {
);
pass.set_bind_group(0, &self.group, &[]);
}
if shadow_quads > 0 {
if shadow_quads > 0 && !oit_on {
pass.set_pipeline(&self.shadow_blob_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.draw_indexed(
0..shadow_quads * 6,
((world_quads
((base_quads
- translucent_item_quads
- translucent_particle_quads
- shadow_quads)
@@ -5312,7 +5436,7 @@ impl Renderer {
}
// BoatRenderer's water patch (RenderTypes.waterMask): depth only,
// before translucent terrain, so no water shows inside the boat.
if water_mask_quads > 0 {
if water_mask_quads > 0 && !oit_on {
pass.set_pipeline(&self.water_mask_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
@@ -5324,13 +5448,13 @@ impl Renderer {
);
pass.set_bind_group(0, &self.group, &[]);
}
if !frame.lightning.bolts.is_empty() {
if !frame.lightning.bolts.is_empty() && !oit_on {
pass.set_pipeline(&self.lightning_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
self.lightning.draw(&mut pass, &frame.lightning);
pass.set_bind_group(0, &self.group, &[]);
}
if swirl_quads > 0 {
if swirl_quads > 0 && !oit_on {
pass.set_pipeline(&self.energy_swirl_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
@@ -5346,7 +5470,7 @@ impl Renderer {
);
pass.set_bind_group(0, &self.group, &[]);
}
if additive_quads > 0 {
if additive_quads > 0 && !oit_on {
pass.set_pipeline(&self.additive_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
@@ -5369,6 +5493,159 @@ impl Renderer {
match self.flat.as_ref().filter(|_| flat_frame) {
// The pack draws translucent terrain after its deferred programs.
_ if pack_draws_water => {}
// LevelRenderer.executeOit in place of the classic blend; what
// follows draws over its composite in a pass of its own.
flat if let Some(oit) = oit => {
drop(pass);
// LevelRenderer.executeOitWaterMask and CloudRenderer.renderOit.
let mask = |pass: &mut wgpu::RenderPass<'_>| {
pass.set_pipeline(&self.water_mask_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.draw_indexed(
0..water_mask_quads * 6,
((entity_quads
+ text_quads
+ crumbling_quads
+ translucent_crumbling_quads)
* 4) as i32,
0..1,
);
};
let clouds =
|pass: &mut wgpu::RenderPass<'_>, pipeline: &wgpu::RenderPipeline| {
pass.set_pipeline(pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_bind_group(1, &self.cloud_group, &[]);
pass.set_index_buffer(
self.indices.slice(..),
wgpu::IndexFormat::Uint32,
);
pass.set_vertex_buffer(0, self.cloud_buffer.slice(..));
pass.draw_indexed(0..cloud_quads * 6, 0, 0..1);
};
// "Features, World Border, Weather": what follows the opaque
// world in the classic pass, one stage at a time.
let features = |pass: &mut wgpu::RenderPass<'_>, stage: oit::Stage| {
let f = oit.features();
let s = stage as usize;
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
// Quads of a material, `first` quads into the buffer.
let quads = |pass: &mut wgpu::RenderPass<'_>,
pipeline: &wgpu::RenderPipeline,
count: u32,
first: u32| {
if count > 0 {
pass.set_pipeline(pipeline);
pass.draw_indexed(0..count * 6, (first * 4) as i32, 0..1);
}
};
quads(pass, &f.text[s], text_quads, entity_quads);
quads(
pass,
&f.crumbling[s],
crumbling_quads,
entity_quads + text_quads,
);
quads(
pass,
&f.crumbling[s],
translucent_crumbling_quads,
entity_quads + text_quads + crumbling_quads,
);
quads(
pass,
&f.swirl[s],
swirl_quads,
entity_quads
+ text_quads
+ crumbling_quads
+ translucent_crumbling_quads
+ water_mask_quads,
);
quads(
pass,
&f.shadow[s],
shadow_quads,
base_quads
- translucent_item_quads
- translucent_particle_quads
- shadow_quads,
);
quads(
pass,
&f.entity[s],
translucent_particle_quads,
base_quads - translucent_item_quads - translucent_particle_quads,
);
quads(
pass,
&f.entity[s],
translucent_item_quads,
base_quads - translucent_item_quads,
);
// Translucent, emissive, eyes and breeze wind models:
// the same fragment, their light and shading are in
// the vertices.
quads(pass, &f.entity[s], oit_entity_quads, base_quads);
if !frame.lightning.bolts.is_empty() {
pass.set_pipeline(&f.lightning[s]);
self.lightning.draw(pass, &frame.lightning);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
}
quads(pass, &f.additive[s], additive_quads, world_quads);
self.weather
.draw_with(pass, &f.weather[s], &self.entity_group);
if let Some(b) = frame.border.as_ref().filter(|_| !frame.title) {
pass.set_pipeline(&f.border[s]);
pass.set_bind_group(0, &self.border_group, &[]);
pass.set_vertex_buffer(0, self.border_vertices.slice(..));
for &side in &b.sides {
let first = side as u32 * 6;
pass.draw_indexed(first..first + 6, 0, 0..1);
}
}
};
let extras = oit::Extras {
water_mask: (water_mask_quads > 0).then_some(&mask as _),
clouds: (cloud_quads > 0).then_some(&clouds as _),
features: Some(&features),
};
oit.run(
&mut encoder,
world_view,
world_depth,
&|pass, stage| {
pass.set_bind_group(0, &self.group, &[]);
// Flat section draws take the flat pipelines.
if let Some((flat, pipeline)) = flat.zip(oit.flat(stage)) {
pass.set_pipeline(pipeline);
flat.draw(pass, flat.translucent_calls(), |p| self.arena.buffer(p));
return;
}
pass.set_pipeline(oit.terrain(stage));
pass.set_index_buffer(
self.indices.slice(..),
wgpu::IndexFormat::Uint32,
);
pass.set_vertex_buffer(1, self.instances.slice(..));
self.draw_sections(
pass,
&self.indirect,
0,
solid_draws,
translucent_draws,
);
},
&extras,
);
pass = world_pass_after_culling(&mut encoder, world_view, world_depth, true);
pass.set_bind_group(0, &self.group, &[]);
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
}
Some(flat) => {
pass.set_pipeline(pack_flat.map_or(&flat.translucent, |p| &p.1));
flat.draw(&mut pass, flat.translucent_calls(), |p| {
@@ -5394,15 +5671,14 @@ impl Renderer {
// translucent particle layer draws after translucent terrain —
// the quads write depth, so before it they would hide the glass
// or water behind them.
if translucent_particle_quads > 0 {
if translucent_particle_quads > 0 && !oit_on {
pass.set_pipeline(&self.entity_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.draw_indexed(
0..translucent_particle_quads * 6,
((world_quads - translucent_item_quads - translucent_particle_quads) * 4)
as i32,
((base_quads - translucent_item_quads - translucent_particle_quads) * 4) as i32,
0..1,
);
pass.set_bind_group(0, &self.group, &[]);
@@ -5414,7 +5690,7 @@ impl Renderer {
}
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
// LevelRenderer: the world border after translucent terrain and weather.
if let Some(b) = frame.border.as_ref().filter(|_| !frame.title) {
if let Some(b) = frame.border.as_ref().filter(|_| !frame.title && !oit_on) {
pass.set_pipeline(&self.border_pipeline);
pass.set_bind_group(0, &self.border_group, &[]);
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
@@ -5425,7 +5701,7 @@ impl Renderer {
}
pass.set_bind_group(0, &self.group, &[]);
}
if translucent_crumbling_quads > 0 {
if translucent_crumbling_quads > 0 && !oit_on {
pass.set_pipeline(&self.crumbling_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
@@ -5435,7 +5711,7 @@ impl Renderer {
0..1,
);
}
if cloud_quads > 0 && !pack_draws_clouds {
if cloud_quads > 0 && !pack_draws_clouds && !oit_on {
pass.set_pipeline(&self.cloud_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_bind_group(1, &self.cloud_group, &[]);
@@ -5633,6 +5909,8 @@ impl Renderer {
&target_texture,
&view_target,
[self.config.width, self.config.height],
// The scaled world pass drew its own depth buffer.
scaled.is_none().then_some(&self.depth),
&frame.post_effects,
);
}
@@ -6225,9 +6503,9 @@ mod tests {
.flat_map(|(layer, id)| quad(layer, id))
.collect();
let mut batch = super::EntityBatch::default();
let counts = batch.update(&entities, &quad(3, 5), &quad(0, 6), &quad(0, 8));
let counts = batch.update(&entities, &quad(3, 5), &quad(0, 6), &quad(0, 8), false);
let vertices = &batch.combined;
assert_eq!(counts, [4, 1, 1, 1, 0, 0, 1, 1, 0]);
assert_eq!(counts, [4, 1, 1, 1, 0, 0, 1, 1, 0, 0]);
assert_eq!(
vertices
.as_chunks::<4>()
@@ -6299,8 +6577,31 @@ mod tests {
&mut files,
);
assert!(files.len() >= 10, "{files:?}");
// Improved transparency's files are appended to terrain.wgsl and entity.wgsl.
let mut sources: Vec<(std::path::PathBuf, String)> = Vec::new();
for path in files {
let text = std::fs::read_to_string(&path).unwrap();
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if name == "oit.wgsl" {
// The shared maths: it needs the file it is appended to.
continue;
} else if name == "oit_world.wgsl" {
let base = include_str!("terrain.wgsl").to_owned() + include_str!("oit.wgsl");
sources.push((path, base + &text));
} else if name == "oit_features.wgsl" {
let base = super::entity_shader() + include_str!("oit.wgsl");
sources.push((path, base + &text));
} else if name == "oit_border.wgsl" {
let base = include_str!("border.wgsl").to_owned() + include_str!("oit.wgsl");
sources.push((path, base + &text));
} else if name == "oit_clouds.wgsl" {
let base = super::entity_shader() + include_str!("oit.wgsl");
sources.push((path, base + &text));
} else {
sources.push((path, text));
}
}
for (path, text) in sources {
// entity.wgsl comes after the overlay's constants (entity_shader).
let module = naga::front::wgsl::parse_str(&text)
.or_else(|_| {
@@ -6419,6 +6720,30 @@ mod tests {
);
}
#[test]
fn texture_filtering_samplers() {
use super::{sampler_anisotropy, sprite_sampler_descriptor};
// None is the sampler terrain always used.
let none = sprite_sampler_descriptor(0, 1);
assert_eq!(none.mag_filter, wgpu::FilterMode::Nearest);
assert_eq!(none.min_filter, wgpu::FilterMode::Nearest);
assert_eq!(none.address_mode_u, wgpu::AddressMode::Repeat);
assert_eq!(none.anisotropy_clamp, 1);
// LevelRenderer.chunkLayerSampler: linear both ways, clamped.
for (mode, aniso) in [(1, 1), (2, 8)] {
let d = sprite_sampler_descriptor(mode, aniso);
assert_eq!(d.mag_filter, wgpu::FilterMode::Linear);
assert_eq!(d.min_filter, wgpu::FilterMode::Linear);
assert_eq!(d.mipmap_filter, wgpu::MipmapFilterMode::Linear);
assert_eq!(d.address_mode_v, wgpu::AddressMode::ClampToEdge);
assert_eq!(d.anisotropy_clamp, aniso);
}
assert_eq!(
[1, 2, 3].map(sampler_anisotropy),
[2, 4, 8],
"Options.maxAnisotropyValue is 1 << bit"
);
}
#[test]
fn shader_globals_match_the_rust_layout() {
// terrain.wgsl and entity.wgsl bind the same buffer: same fields, same order.
let fields = |source: &str| -> Vec<String> {
@@ -491,10 +491,6 @@ impl Cull {
}
}
pub fn depth_test(&self) -> DepthTest {
self.depth_test
}
/// In the world pass after phase 1 (`Early`): draw the tested boxes
/// against its depth. Leaves the pass's pipeline and group 0 changed.
pub fn test(&self, pass: &mut wgpu::RenderPass<'_>) {
@@ -9,7 +9,17 @@ use super::Vertex;
/// `RenderPipelines.ENTITY_SHADOW`), translucent-layer particles (15, drawn
/// after translucent terrain: `SubmitNodeCollection.submitQuadParticleGroup`)
/// and translucent item quads (32, drawn without depth writes).
const MATERIALS: usize = 9;
///
/// With improved transparency on, quads tagged `OIT_ENTITY` are a tenth
/// material at the end: the render types vanilla gives an `OitPipelineSet`
/// (translucent, emissive, eyes and breeze wind entity models). Off, the tag is
/// ignored and they stay in the first one, in their order.
const MATERIALS: usize = 10;
/// A bit of `Vertex::layer`, above the overlay (low 16 bits) and the HDR tier
/// (bits 16 to 23): the quad's render type has an `OitPipelineSet`
/// (`RenderTypes.entityTranslucent`, `entityTranslucentEmissive`, `eyes`,
/// `breezeWind`). Only the improved transparency path reads it.
pub const OIT_ENTITY: u32 = 1 << 24;
/// The overlay of depth-only quads that hide water (`RenderTypes.waterMask`).
pub const WATER_MASK_OVERLAY: u32 = 13;
/// The overlay of energy swirl quads (`RenderTypes.energySwirl`: the charged creeper's and the
@@ -30,7 +40,10 @@ pub const TRANSLUCENT_GLINT_ITEM_OVERLAY: u32 = 33;
pub const TEXT_POLYGON_OFFSET_OVERLAY: u32 = 35;
/// The material of a quad by its overlay (`MATERIALS` order).
fn material(layer: u32) -> usize {
fn material(layer: u32, oit: bool) -> usize {
if oit && layer & OIT_ENTITY != 0 {
return 9;
}
match layer {
TEXT_POLYGON_OFFSET_OVERLAY => 1,
11 => 2,
@@ -60,6 +73,7 @@ impl EntityBatch {
additive: &[Vertex],
hand: &[Vertex],
see_through: &[Vertex],
oit: bool,
) -> [u32; MATERIALS] {
let quads = entities.as_chunks::<4>().0;
for runs in &mut self.runs {
@@ -68,10 +82,10 @@ impl EntityBatch {
let mut counts = [0usize; MATERIALS];
let mut start = 0;
while start < quads.len() {
let kind = material(quads[start][0].layer);
let kind = material(quads[start][0].layer, oit);
let end = quads[start + 1..]
.iter()
.position(|quad| material(quad[0].layer) != kind)
.position(|quad| material(quad[0].layer, oit) != kind)
.map_or(quads.len(), |n| start + 1 + n);
self.runs[kind].push((start, end - start));
counts[kind] += end - start;
@@ -156,13 +170,46 @@ mod tests {
hand: &[Vertex],
) {
let (expected, counts) = baseline(entities, additive, hand);
assert_eq!(batch.update(entities, additive, hand, &[]), counts);
assert_eq!(batch.update(entities, additive, hand, &[], false), counts);
assert_eq!(
bytemuck::cast_slice::<_, u8>(&batch.combined),
bytemuck::cast_slice::<_, u8>(&expected)
);
}
#[test]
fn oit_entity_quads_are_a_last_material_only_when_on() {
let quad = |layer: u32, x: f32| {
(0..4).map(move |_| Vertex {
pos: [x, 0.0, 0.0],
layer,
..geometry(1, false)[0]
})
};
// Plain, tagged, plain, shadow, tagged with a hurt overlay.
let entities: Vec<Vertex> = [
(0, 0.0),
(OIT_ENTITY | 3 << 16, 1.0),
(1, 2.0),
(SHADOW_OVERLAY, 3.0),
(OIT_ENTITY | 1, 4.0),
]
.into_iter()
.flat_map(|(layer, x)| quad(layer, x))
.collect();
let mut batch = EntityBatch::default();
// Off: the tag changes nothing, the quads keep their place.
let counts = batch.update(&entities, &[], &[], &[], false);
assert_eq!(counts, [4, 0, 0, 0, 0, 0, 1, 0, 0, 0]);
let xs: Vec<f32> = batch.combined.iter().step_by(4).map(|v| v.pos[0]).collect();
assert_eq!(xs, [0.0, 1.0, 2.0, 4.0, 3.0]);
// On: the tagged quads come last, in order.
let counts = batch.update(&entities, &[], &[], &[], true);
assert_eq!(counts, [2, 0, 0, 0, 0, 0, 1, 0, 0, 2]);
let xs: Vec<f32> = batch.combined.iter().step_by(4).map(|v| v.pos[0]).collect();
assert_eq!(xs, [0.0, 2.0, 3.0, 1.0, 4.0]);
}
#[test]
fn stable_material_order_tails_and_reuse() {
let mut batch = EntityBatch::default();
@@ -227,7 +274,7 @@ mod tests {
equivalent(&mut batch, &entities, &additive, &hand);
for _ in 0..50 {
black_box(baseline(black_box(&entities), &additive, &hand));
black_box(batch.update(black_box(&entities), &additive, &hand, &[]));
black_box(batch.update(black_box(&entities), &additive, &hand, &[], false));
black_box(&batch.combined);
}
let mut before = Vec::new();
@@ -240,7 +287,7 @@ mod tests {
before.push(start.elapsed().as_nanos() / 100);
let start = Instant::now();
for _ in 0..100 {
black_box(batch.update(black_box(&entities), &additive, &hand, &[]));
black_box(batch.update(black_box(&entities), &additive, &hand, &[], false));
black_box(&batch.combined);
}
after.push(start.elapsed().as_nanos() / 100);
+968
View File
@@ -0,0 +1,968 @@
//! Improved transparency: vanilla 26.3's order-independent transparency
//! (`LevelRenderer.executeOit`). The translucent layer is drawn three times
//! against the opaque world's depth, without writing it, into targets the
//! next stage reads (`oit.wgsl` says what each holds), then a full-screen pass
//! composites the sum. With the option off none of this exists and the world
//! draws as before.
use super::oit_features::Features;
use glam::Mat4;
/// The depth bounds' clear: `LevelRenderer.DEPTH_BOUNDS_CLEAR_COLOR`.
const BOUNDS_CLEAR: wgpu::Color = wgpu::Color {
r: -(f32::MAX as f64),
g: 0.0,
b: 0.0,
a: 0.0,
};
const BOUNDS_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba32Float;
const COEFFICIENT_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
/// The shader's bind group: `terrain.wgsl` holds groups 0 and 1.
const GROUP: u32 = 2;
/// `OitStage`.
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Stage {
DepthBounds,
Transmittance,
Accumulate,
}
/// What the device must support: max blending on 32-bit float targets.
pub fn features(adapter: &wgpu::Adapter) -> wgpu::Features {
adapter.features() & wgpu::Features::FLOAT32_BLENDABLE
}
pub fn supported(device: &wgpu::Device) -> bool {
device
.features()
.contains(wgpu::Features::FLOAT32_BLENDABLE)
}
/// What a stage's pipelines write: `OIT_*_SNIPPET`'s colour targets.
pub(super) fn stage_targets(stage: Stage) -> Vec<Option<wgpu::ColorTargetState>> {
let blend = |operation| wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::One,
dst_factor: wgpu::BlendFactor::One,
operation,
};
let target = |format, operation| {
Some(wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState {
color: blend(operation),
alpha: blend(operation),
}),
write_mask: wgpu::ColorWrites::ALL,
})
};
let add = wgpu::BlendOperation::Add;
match stage {
Stage::DepthBounds => vec![target(BOUNDS_FORMAT, wgpu::BlendOperation::Max)],
Stage::Transmittance => vec![
target(COEFFICIENT_FORMAT, add),
target(COEFFICIENT_FORMAT, add),
],
Stage::Accumulate => vec![target(COEFFICIENT_FORMAT, add)],
}
}
/// `ProjMat[2][2]` and `ProjMat[3][2]`: a device depth `d` is `y / (d + x)`
/// blocks along the view axis (`oit_linear_depth`).
fn projection_terms(projection: &Mat4) -> [f32; 2] {
[projection.z_axis.z, projection.w_axis.z]
}
struct Targets {
size: [u32; 2],
bounds: wgpu::Texture,
culled: wgpu::Texture,
views: Views,
/// The uniform alone, with the culled bounds, with those and the
/// coefficients, for the cull with the unculled bounds, and the composite.
alone: wgpu::BindGroup,
sampled: wgpu::BindGroup,
accumulate: wgpu::BindGroup,
cull: wgpu::BindGroup,
composite: wgpu::BindGroup,
/// The depth the terrain's stages test against while a water mask is
/// drawn (a copy of the world's, plus the masks), and the clouds' own.
terrain_depth: Option<Depth>,
cloud_depth: Option<Depth>,
}
struct Depth {
texture: wgpu::Texture,
view: wgpu::TextureView,
}
impl Depth {
fn new(device: &wgpu::Device, label: &str, size: [u32; 2]) -> Self {
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
size: extent(size),
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Depth32Float,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
Self {
view: texture.create_view(&Default::default()),
texture,
}
}
}
fn extent(size: [u32; 2]) -> wgpu::Extent3d {
wgpu::Extent3d {
width: size[0].max(1),
height: size[1].max(1),
depth_or_array_layers: 1,
}
}
/// What the translucent layer's other drawers add to a frame: each draws with
/// a pipeline per stage where it has one.
#[derive(Default)]
pub struct Extras<'a> {
/// `executeOitWaterMask`: the boats' water patches, depth only.
pub water_mask: Option<&'a dyn Fn(&mut wgpu::RenderPass<'_>)>,
/// `CloudRenderer.renderOit`: draws the clouds with the pipeline given.
pub clouds: Option<&'a dyn Fn(&mut wgpu::RenderPass<'_>, &wgpu::RenderPipeline)>,
/// "Features, World Border, Weather": draws what the entity, weather and
/// border shaders put after the opaque world, with `Oit::features`'s
/// pipelines of the stage.
pub features: Option<&'a dyn Fn(&mut wgpu::RenderPass<'_>, Stage)>,
}
struct Views {
bounds: wgpu::TextureView,
culled: wgpu::TextureView,
coefficients: [wgpu::TextureView; 2],
accumulated: wgpu::TextureView,
}
pub struct Oit {
uniform: wgpu::Buffer,
/// Layouts of the groups at `GROUP`: the uniform; with the bounds; with the
/// coefficients; and with the accumulation too.
layouts: [wgpu::BindGroupLayout; 4],
/// One per `Stage`.
terrain: [wgpu::RenderPipeline; 3],
/// The terrain's flat section draws (`Flat`), where the device has them.
flat: Option<[wgpu::RenderPipeline; 3]>,
features: Features,
clouds: [wgpu::RenderPipeline; 3],
cull: wgpu::RenderPipeline,
composite: wgpu::RenderPipeline,
targets: Option<Targets>,
}
impl Oit {
/// `terrain`: the terrain bind group layout that `vs_terrain` reads,
/// `flat` the flat draws' second group, `features` the entity, weather
/// and world border layouts,
/// `clouds` the cloud pipeline's two, `sprites` the terrain's override
/// constants, `format` the world target's.
pub fn new(
device: &wgpu::Device,
terrain: &wgpu::BindGroupLayout,
flat: Option<&wgpu::BindGroupLayout>,
features: [&wgpu::BindGroupLayout; 3],
clouds: [&wgpu::BindGroupLayout; 2],
sprites: [f64; 2],
format: wgpu::TextureFormat,
) -> Self {
let instance = wgpu::vertex_attr_array![5=>Float32x4];
let buffers = [
Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<super::TerrainVertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &super::TERRAIN_ATTRIBUTES,
}),
Some(wgpu::VertexBufferLayout {
array_stride: 16,
step_mode: wgpu::VertexStepMode::Instance,
attributes: &instance,
}),
];
let buffers = &buffers[..];
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Horizon improved transparency"),
source: wgpu::ShaderSource::Wgsl(
(include_str!("../terrain.wgsl").to_owned()
+ include_str!("../oit.wgsl")
+ include_str!("../oit_world.wgsl"))
.into(),
),
});
let uniform = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Improved transparency"),
size: 16,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let entry = |binding: u32, ty| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::FRAGMENT,
ty,
count: None,
};
let texture = |binding| {
entry(
binding,
wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: false },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
)
};
let params = entry(
0,
wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
);
let layout = |label, entries: &[wgpu::BindGroupLayoutEntry]| {
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some(label),
entries,
})
};
let layouts = [
layout("Improved transparency", &[params]),
layout("Improved transparency bounds", &[params, texture(1)]),
layout(
"Improved transparency coefficients",
&[params, texture(1), texture(2), texture(3)],
),
layout(
"Improved transparency composite",
&[params, texture(1), texture(2), texture(3), texture(4)],
),
];
let pipeline_layout = |first: Option<&wgpu::BindGroupLayout>, group: usize| {
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Improved transparency"),
bind_group_layouts: &[first, None, Some(&layouts[group])],
..Default::default()
})
};
let constants = [("SPRITE_ARRAYS", sprites[0]), ("SPRITE_SPLIT", sprites[1])];
let add = wgpu::BlendOperation::Add;
let depth = |write| {
Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(write),
depth_compare: Some(wgpu::CompareFunction::GreaterEqual),
stencil: Default::default(),
bias: Default::default(),
})
};
let pipeline = |label,
layout: &wgpu::PipelineLayout,
vertex: &str,
fragment: &str,
buffers: &[Option<wgpu::VertexBufferLayout<'_>>],
targets: &[Option<wgpu::ColorTargetState>],
depth_stencil| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(label),
layout: Some(layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some(vertex),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &constants,
..Default::default()
},
buffers,
},
primitive: wgpu::PrimitiveState {
cull_mode: vertex.starts_with("vs_terrain").then_some(wgpu::Face::Back),
..Default::default()
},
depth_stencil,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some(fragment),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &constants,
..Default::default()
},
targets,
}),
multiview_mask: None,
cache: None,
})
};
let terrain_pipeline = |label, fragment, stage| {
pipeline(
label,
&pipeline_layout(Some(terrain), stage as usize),
"vs_terrain",
fragment,
buffers,
&stage_targets(stage),
depth(false),
)
};
let terrain_pipelines = [
terrain_pipeline("OIT depth bounds", "fs_oit_bounds", Stage::DepthBounds),
terrain_pipeline(
"OIT transmittance",
"fs_oit_transmittance",
Stage::Transmittance,
),
terrain_pipeline("OIT accumulate", "fs_oit_accumulate", Stage::Accumulate),
];
// The same fragments over `vs_terrain_flat`: one vertex buffer, and
// the draw table at group 1.
let flat_pipelines = flat.map(|draw| {
let flat_buffers = &buffers[..1];
[
(
"OIT depth bounds (flat)",
"fs_oit_bounds",
Stage::DepthBounds,
),
(
"OIT transmittance (flat)",
"fs_oit_transmittance",
Stage::Transmittance,
),
(
"OIT accumulate (flat)",
"fs_oit_accumulate",
Stage::Accumulate,
),
]
.map(|(label, fragment, stage)| {
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Improved transparency flat terrain"),
bind_group_layouts: &[
Some(terrain),
Some(draw),
Some(&layouts[stage as usize]),
],
..Default::default()
});
pipeline(
label,
&layout,
"vs_terrain_flat",
fragment,
flat_buffers,
&stage_targets(stage),
depth(false),
)
})
});
let screen = |label, fragment, group, targets: &[_], depth_stencil| {
pipeline(
label,
&pipeline_layout(None, group),
"vs_sky",
fragment,
&[],
targets,
depth_stencil,
)
};
let replace = |format| {
Some(wgpu::ColorTargetState {
format,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})
};
let cull = screen(
"OIT cull depth bounds",
"fs_oit_cull",
1,
&[replace(BOUNDS_FORMAT)],
depth(true),
);
// `RenderPipelines.OIT_COMPOSITE`: premultiplied alpha over the world,
// the nearest see-through depth written.
let premultiplied = wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::One,
dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
operation: add,
};
let composite = screen(
"OIT composite",
"fs_oit_composite",
3,
&[Some(wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState {
color: premultiplied,
alpha: premultiplied,
}),
write_mask: wgpu::ColorWrites::ALL,
})],
Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(true),
depth_compare: Some(wgpu::CompareFunction::Always),
stencil: Default::default(),
bias: Default::default(),
}),
);
// The clouds: entity.wgsl's vertex shader and vertex format, with the
// stages' fragments.
let cloud_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Horizon improved transparency clouds"),
source: wgpu::ShaderSource::Wgsl(
(super::entity_shader()
+ include_str!("../oit.wgsl")
+ include_str!("../oit_clouds.wgsl"))
.into(),
),
});
let cloud_attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint8x4,5=>Snorm8x4,6=>Float32x2];
let cloud_buffers = [Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<super::Vertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &cloud_attributes,
})];
let cloud_pipeline = |label, fragment, stage: Stage| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(label),
layout: Some(
&device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Improved transparency clouds"),
bind_group_layouts: &[
Some(clouds[0]),
Some(clouds[1]),
Some(&layouts[stage as usize]),
],
..Default::default()
}),
),
vertex: wgpu::VertexState {
module: &cloud_shader,
entry_point: Some("vs_cloud"),
compilation_options: Default::default(),
buffers: &cloud_buffers,
},
primitive: wgpu::PrimitiveState {
cull_mode: Some(wgpu::Face::Back),
..Default::default()
},
// `OIT_CLOUDS`: only the bounds stage writes the cloud depth,
// so the later stages keep the nearest face of each cloud.
depth_stencil: depth(stage == Stage::DepthBounds),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &cloud_shader,
entry_point: Some(fragment),
compilation_options: Default::default(),
targets: &stage_targets(stage),
}),
multiview_mask: None,
cache: None,
})
};
let cloud_pipelines = [
cloud_pipeline(
"OIT cloud depth bounds",
"fs_oit_cloud_bounds",
Stage::DepthBounds,
),
cloud_pipeline(
"OIT cloud transmittance",
"fs_oit_cloud_transmittance",
Stage::Transmittance,
),
cloud_pipeline(
"OIT cloud accumulate",
"fs_oit_cloud_accumulate",
Stage::Accumulate,
),
];
let features = Features::new(device, &layouts, features[0], features[1], features[2]);
Self {
uniform,
layouts,
terrain: terrain_pipelines,
flat: flat_pipelines,
features,
clouds: cloud_pipelines,
cull,
composite,
targets: None,
}
}
/// The pipeline the terrain's translucent layer draws with in `stage`.
pub fn terrain(&self, stage: Stage) -> &wgpu::RenderPipeline {
&self.terrain[stage as usize]
}
/// The pipeline the terrain's translucent layer draws with in `stage`
/// as flat section draws, where they run.
pub fn flat(&self, stage: Stage) -> Option<&wgpu::RenderPipeline> {
self.flat.as_ref().map(|f| &f[stage as usize])
}
pub fn features(&self) -> &Features {
&self.features
}
/// Sizes the targets to the world's and writes this frame's projection.
pub fn prepare(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
size: [u32; 2],
projection: &Mat4,
wants: [bool; 2],
) {
queue.write_buffer(
&self.uniform,
0,
bytemuck::cast_slice(&[projection_terms(projection), [0.0; 2]].concat()),
);
let [mask, clouds] = wants;
if let Some(t) = self.targets.as_mut().filter(|t| t.size == size) {
if mask && t.terrain_depth.is_none() {
t.terrain_depth = Some(Depth::new(device, "OIT terrain depth", size));
}
if clouds && t.cloud_depth.is_none() {
t.cloud_depth = Some(Depth::new(device, "OIT cloud depth", size));
}
return;
}
let make = |label, format, usage| {
device.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
size: extent(size),
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage,
view_formats: &[],
})
};
let attachment =
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING;
let bounds = make(
"OIT depth bounds",
BOUNDS_FORMAT,
attachment | wgpu::TextureUsages::COPY_SRC,
);
let culled = make(
"OIT culled depth bounds",
BOUNDS_FORMAT,
attachment | wgpu::TextureUsages::COPY_DST,
);
let coefficients =
[0, 1].map(|_| make("OIT transmittance", COEFFICIENT_FORMAT, attachment));
let accumulated = make("OIT accumulate", COEFFICIENT_FORMAT, attachment);
let views = Views {
bounds: bounds.create_view(&Default::default()),
culled: culled.create_view(&Default::default()),
coefficients: coefficients.map(|t| t.create_view(&Default::default())),
accumulated: accumulated.create_view(&Default::default()),
};
let group = |layout: &wgpu::BindGroupLayout, views: &[&wgpu::TextureView]| {
let mut entries = vec![wgpu::BindGroupEntry {
binding: 0,
resource: self.uniform.as_entire_binding(),
}];
entries.extend(views.iter().enumerate().map(|(i, v)| wgpu::BindGroupEntry {
binding: i as u32 + 1,
resource: wgpu::BindingResource::TextureView(v),
}));
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Improved transparency"),
layout,
entries: &entries,
})
};
let [c0, c1] = &views.coefficients;
self.targets = Some(Targets {
size,
alone: group(&self.layouts[0], &[]),
sampled: group(&self.layouts[1], &[&views.culled]),
accumulate: group(&self.layouts[2], &[&views.culled, c0, c1]),
cull: group(&self.layouts[1], &[&views.bounds]),
composite: group(
&self.layouts[3],
&[&views.bounds, c0, c1, &views.accumulated],
),
terrain_depth: mask.then(|| Depth::new(device, "OIT terrain depth", size)),
cloud_depth: clouds.then(|| Depth::new(device, "OIT cloud depth", size)),
bounds,
culled,
views,
});
}
/// `LevelRenderer.executeOit`: `draw` records the terrain's translucent
/// layer into each stage's pass with that stage's pipeline, `extras` the
/// water masks and clouds, then the composite goes over `color`. `depth`
/// is the opaque world's: the stages only read it; the cull and the
/// composite write it.
pub fn run(
&self,
encoder: &mut wgpu::CommandEncoder,
color: &wgpu::TextureView,
depth: &wgpu::TextureView,
draw: &dyn Fn(&mut wgpu::RenderPass<'_>, Stage),
extras: &Extras<'_>,
) {
let Some(t) = &self.targets else { return };
let attachment = |view, load| {
Some(wgpu::RenderPassColorAttachment {
view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load,
store: wgpu::StoreOp::Store,
},
})
};
let clear = wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT);
let load = wgpu::LoadOp::Load;
let read_only = |view| {
Some(wgpu::RenderPassDepthStencilAttachment {
view,
depth_ops: None,
stencil_ops: None,
})
};
let water_mask = extras.water_mask.zip(t.terrain_depth.as_ref());
let cloud_depth = extras.clouds.zip(t.cloud_depth.as_ref());
if let Some((mask, _)) = water_mask {
self.execute_water_mask(encoder, t, color, depth, mask);
}
let terrain_depth = water_mask.map_or(depth, |(_, d)| &d.view);
let [c0, c1] = &t.views.coefficients;
let culled = &t.views.culled;
for (stage, label, group, first, then) in [
(
Stage::DepthBounds,
"OIT depth bounds",
&t.alone,
vec![attachment(
&t.views.bounds,
wgpu::LoadOp::Clear(BOUNDS_CLEAR),
)],
// The clouds' bounds join the culled ones.
vec![attachment(culled, load)],
),
(
Stage::Transmittance,
"OIT transmittance",
&t.sampled,
vec![attachment(c0, clear), attachment(c1, clear)],
vec![attachment(c0, load), attachment(c1, load)],
),
(
Stage::Accumulate,
"OIT accumulate",
&t.accumulate,
vec![attachment(&t.views.accumulated, clear)],
vec![attachment(&t.views.accumulated, load)],
),
] {
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(label),
color_attachments: &first,
depth_stencil_attachment: read_only(terrain_depth),
..Default::default()
});
pass.set_bind_group(GROUP, group, &[]);
draw(&mut pass, stage);
}
if let Some(features) = extras.features {
// Over the world's own depth, even where a water mask patched
// the terrain's: the masks only hide water.
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(label),
color_attachments: &[attachment(
match stage {
Stage::DepthBounds => &t.views.bounds,
Stage::Transmittance => c0,
Stage::Accumulate => &t.views.accumulated,
},
load,
)]
.into_iter()
.chain((stage == Stage::Transmittance).then(|| attachment(c1, load)))
.collect::<Vec<_>>(),
depth_stencil_attachment: read_only(depth),
..Default::default()
});
pass.set_bind_group(GROUP, group, &[]);
features(&mut pass, stage);
}
if stage == Stage::DepthBounds {
self.execute_depth_bounds_cull(encoder, t, depth);
}
if let Some((clouds, cloud_depth)) = cloud_depth {
self.render_clouds(
encoder,
t,
stage,
depth,
(label, group, &then),
(clouds, cloud_depth),
);
}
if stage == Stage::DepthBounds
&& let Some((mask, _)) = water_mask
{
self.execute_water_mask(encoder, t, color, depth, mask);
}
}
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("OIT composite"),
color_attachments: &[attachment(color, wgpu::LoadOp::Load)],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: depth,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
});
pass.set_pipeline(&self.composite);
pass.set_bind_group(GROUP, &t.composite, &[]);
pass.draw(0..3, 0..1);
}
/// `CloudRenderer.renderOit` for one stage. The bounds stage first copies
/// the world's depth (after the cull) and draws into it, so the later
/// stages, which only read it, keep each pixel's nearest cloud face.
fn render_clouds(
&self,
encoder: &mut wgpu::CommandEncoder,
t: &Targets,
stage: Stage,
depth: &wgpu::TextureView,
(label, group, targets): (
&str,
&wgpu::BindGroup,
&[Option<wgpu::RenderPassColorAttachment<'_>>],
),
(draw, cloud_depth): (
&dyn Fn(&mut wgpu::RenderPass<'_>, &wgpu::RenderPipeline),
&Depth,
),
) {
if stage == Stage::DepthBounds {
encoder.copy_texture_to_texture(
depth.texture().as_image_copy(),
cloud_depth.texture.as_image_copy(),
extent(t.size),
);
}
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(label),
color_attachments: targets,
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &cloud_depth.view,
depth_ops: (stage == Stage::DepthBounds).then_some(wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
});
pass.set_bind_group(GROUP, group, &[]);
draw(&mut pass, &self.clouds[stage as usize]);
}
/// `LevelRenderer.executeOitWaterMask`: the terrain's depth is the world's
/// again, with the boats' water patches on top.
fn execute_water_mask(
&self,
encoder: &mut wgpu::CommandEncoder,
t: &Targets,
color: &wgpu::TextureView,
depth: &wgpu::TextureView,
mask: &dyn Fn(&mut wgpu::RenderPass<'_>),
) {
let Some(terrain) = &t.terrain_depth else {
return;
};
encoder.copy_texture_to_texture(
depth.texture().as_image_copy(),
terrain.texture.as_image_copy(),
extent(t.size),
);
// The mask pipeline writes depth only but has a colour target.
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("OIT water mask"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: color,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &terrain.view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
});
mask(&mut pass);
}
/// `LevelRenderer.executeDepthBoundsCull`: the bounds are copied, then where
/// the nearest fully opaque see-through fragment is in front of the world,
/// the far bound stops there and its depth replaces the world's.
fn execute_depth_bounds_cull(
&self,
encoder: &mut wgpu::CommandEncoder,
t: &Targets,
depth: &wgpu::TextureView,
) {
encoder.copy_texture_to_texture(
t.bounds.as_image_copy(),
t.culled.as_image_copy(),
wgpu::Extent3d {
width: t.size[0].max(1),
height: t.size[1].max(1),
depth_or_array_layers: 1,
},
);
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("OIT cull depth bounds"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &t.views.culled,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: depth,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
});
pass.set_pipeline(&self.cull);
pass.set_bind_group(GROUP, &t.cull, &[]);
pass.draw(0..3, 0..1);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn validate(source: &str, entries: &[&str]) {
let module = naga::front::wgsl::parse_str(source).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap();
for entry in entries {
assert!(
module.entry_points.iter().any(|e| e.name == *entry),
"{entry}"
);
}
}
#[test]
fn the_terrain_shader_validates() {
validate(
&(include_str!("../terrain.wgsl").to_owned()
+ include_str!("../oit.wgsl")
+ include_str!("../oit_world.wgsl")),
&[
"fs_oit_bounds",
"fs_oit_transmittance",
"fs_oit_accumulate",
"fs_oit_cull",
"fs_oit_composite",
],
);
}
#[test]
fn the_cloud_shader_validates() {
validate(
&(super::super::entity_shader()
+ include_str!("../oit.wgsl")
+ include_str!("../oit_clouds.wgsl")),
&[
"fs_oit_cloud_bounds",
"fs_oit_cloud_transmittance",
"fs_oit_cloud_accumulate",
],
);
}
#[test]
fn the_feature_shaders_validate() {
let stages = |name: &str| {
["bounds", "transmittance", "accumulate"].map(|s| format!("fs_oit_{name}_{s}"))
};
let entity = ["entity", "shadow", "weather", "swirl"]
.map(stages)
.concat();
validate(
&(super::super::entity_shader()
+ include_str!("../oit.wgsl")
+ include_str!("../oit_features.wgsl")),
&entity.iter().map(String::as_str).collect::<Vec<_>>(),
);
let border = stages("border");
validate(
&(include_str!("../border.wgsl").to_owned()
+ include_str!("../oit.wgsl")
+ include_str!("../oit_border.wgsl")),
&border.iter().map(String::as_str).collect::<Vec<_>>(),
);
}
#[test]
fn a_device_depth_gives_the_distance_along_the_view_axis() {
// The world's reversed-Z projection: near plane at depth 1.
let projection = super::super::world_projection(70.0, 16.0 / 9.0, 512.0);
let [x, y] = projection_terms(&projection);
for distance in [0.1_f32, 1.0, 7.5, 64.0, 500.0] {
let clip = projection * glam::Vec4::new(0.0, 0.0, -distance, 1.0);
let device = clip.z / clip.w;
let linear = y / (device + x);
assert!(
(linear - distance).abs() < distance * 2e-3,
"{distance}: {linear}"
);
}
}
}
@@ -0,0 +1,248 @@
//! Improved transparency for what is drawn after the opaque world besides the
//! terrain and the clouds: `LevelRenderer.executeOit`'s "Features, World
//! Border, Weather" pass. Each kind keeps its classic vertex shader, vertex
//! format and depth state (minus the depth writes, as every `OIT_*` pipeline),
//! with the three stages' fragments (`oit_features.wgsl`, `oit_border.wgsl`).
use super::oit::{Stage, stage_targets};
/// One pipeline per stage.
type Set = [wgpu::RenderPipeline; 3];
pub struct Features {
/// `OIT_ENTITY`/`OIT_ITEM`: translucent items and particles.
pub entity: Set,
/// `OIT_TEXT_POLYGON_OFFSET`.
pub text: Set,
/// `OIT_CRUMBLING`.
pub crumbling: Set,
/// `OIT_ENTITY_SHADOW`.
pub shadow: Set,
/// `OIT_ENERGY_SWIRL`.
pub swirl: Set,
/// `OIT_LIGHTNING`: the retained bolts.
pub lightning: Set,
/// `OIT_DRAGON_RAYS`: the additive geometry after the bolts.
pub additive: Set,
/// `OIT_WEATHER`.
pub weather: Set,
/// `OIT_WORLD_BORDER`.
pub border: Set,
}
/// What a kind differs in.
struct Kind<'a> {
label: &'a str,
/// The entry points' common prefix: `fs_oit_<name>_<stage>`.
name: &'a str,
vertex: &'a str,
cull: Option<wgpu::Face>,
bias: wgpu::DepthBiasState,
}
const PLAIN: wgpu::DepthBiasState = wgpu::DepthBiasState {
constant: 0,
slope_scale: 0.0,
clamp: 0.0,
};
impl Features {
/// `layouts`: `Oit`'s stage groups (at group 2); `entity`, `weather` and
/// `border` the bind group layouts of the classic pipelines.
pub fn new(
device: &wgpu::Device,
layouts: &[wgpu::BindGroupLayout; 4],
entity: &wgpu::BindGroupLayout,
weather: &wgpu::BindGroupLayout,
border: &wgpu::BindGroupLayout,
) -> Self {
let entity_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Horizon improved transparency features"),
source: wgpu::ShaderSource::Wgsl(
(super::entity_shader()
+ include_str!("../oit.wgsl")
+ include_str!("../oit_features.wgsl"))
.into(),
),
});
let border_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Horizon improved transparency world border"),
source: wgpu::ShaderSource::Wgsl(
(include_str!("../border.wgsl").to_owned()
+ include_str!("../oit.wgsl")
+ include_str!("../oit_border.wgsl"))
.into(),
),
});
let entity_attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint8x4,5=>Snorm8x4,6=>Float32x2];
let weather_attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Float32x4,
3=>Float32x4,4=>Uint32x4,5=>Unorm8x4,6=>Uint8x4,7=>Uint32];
let border_attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2];
let entity_buffers = [Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<super::Vertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &entity_attributes,
})];
let weather_buffers = [Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<crate::weather::Vertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &weather_attributes,
})];
let border_buffers = [Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<crate::border_render::BorderVertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &border_attributes,
})];
let lightning_attributes = wgpu::vertex_attr_array![7=>Float32x3];
let lightning_buffers = [
entity_buffers[0].clone(),
Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<[f32; 3]>() as u64,
step_mode: wgpu::VertexStepMode::Instance,
attributes: &lightning_attributes,
}),
];
let set = |module: &wgpu::ShaderModule,
groups: [Option<&wgpu::BindGroupLayout>; 2],
buffers: &[Option<wgpu::VertexBufferLayout<'_>>],
kind: Kind<'_>| {
[Stage::DepthBounds, Stage::Transmittance, Stage::Accumulate].map(|stage| {
let suffix = ["bounds", "transmittance", "accumulate"][stage as usize];
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Improved transparency features"),
bind_group_layouts: &[groups[0], groups[1], Some(&layouts[stage as usize])],
..Default::default()
});
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(&format!("OIT {} {suffix}", kind.label)),
layout: Some(&layout),
vertex: wgpu::VertexState {
module,
entry_point: Some(kind.vertex),
compilation_options: Default::default(),
buffers,
},
primitive: wgpu::PrimitiveState {
cull_mode: kind.cull,
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(false),
depth_compare: Some(wgpu::CompareFunction::GreaterEqual),
stencil: Default::default(),
bias: kind.bias,
}),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module,
entry_point: Some(&format!("fs_oit_{}_{suffix}", kind.name)),
compilation_options: Default::default(),
targets: &stage_targets(stage),
}),
multiview_mask: None,
cache: None,
})
})
};
let entities = [Some(entity), None];
let kind = |label, name, cull, bias| Kind {
label,
name,
vertex: "vs_entity",
cull,
bias,
};
// The classic pipelines' biases: text and cracks (10, 1), shadows
// (128 ulps and a slope, over the block tops they lie on).
let offset = wgpu::DepthBiasState {
constant: 10,
slope_scale: 1.0,
clamp: 0.0,
};
let shadow_bias = wgpu::DepthBiasState {
constant: 128,
slope_scale: 0.015,
clamp: 0.0,
};
Self {
entity: set(
&entity_module,
entities,
&entity_buffers,
kind("entities", "entity", None, PLAIN),
),
text: set(
&entity_module,
entities,
&entity_buffers,
kind("text", "entity", None, offset),
),
crumbling: set(
&entity_module,
entities,
&entity_buffers,
kind("breaking cracks", "entity", Some(wgpu::Face::Back), offset),
),
shadow: set(
&entity_module,
entities,
&entity_buffers,
kind("entity shadows", "shadow", None, shadow_bias),
),
swirl: set(
&entity_module,
entities,
&entity_buffers,
kind("energy swirls", "swirl", None, PLAIN),
),
lightning: set(
&entity_module,
entities,
&lightning_buffers,
Kind {
label: "lightning",
name: "lightning",
vertex: "vs_lightning",
cull: None,
bias: PLAIN,
},
),
additive: set(
&entity_module,
entities,
&entity_buffers,
kind("additive entities", "lightning", None, PLAIN),
),
weather: set(
&entity_module,
[Some(entity), Some(weather)],
&weather_buffers,
Kind {
label: "weather",
name: "weather",
vertex: "vs_weather",
cull: None,
bias: PLAIN,
},
),
// `OIT_WORLD_BORDER`: a polygon offset of 3 towards the camera.
border: set(
&border_module,
[Some(border), None],
&border_buffers,
Kind {
label: "world border",
name: "border",
vertex: "vs_border",
cull: None,
bias: wgpu::DepthBiasState {
constant: 3,
slope_scale: 3.0,
clamp: 0.0,
},
},
),
}
}
}
@@ -349,7 +349,10 @@ impl PackEntities {
bytes[0..4].copy_from_slice(&(span.stage as i32).to_le_bytes());
bytes[4..8].copy_from_slice(&entity_id.to_le_bytes());
bytes[8..12].copy_from_slice(&block_id.to_le_bytes());
for (i, c) in entity_color(run[0].layer).iter().enumerate() {
for (i, c) in entity_color(run[0].layer & !super::entity_batch::OIT_ENTITY)
.iter()
.enumerate()
{
bytes[16 + 4 * i..20 + 4 * i].copy_from_slice(&c.to_le_bytes());
}
self.uniforms.extend_from_slice(&bytes);
@@ -408,9 +411,12 @@ pub(super) fn quad_indices(device: &wgpu::Device, quads: u32) -> wgpu::Buffer {
/// celestial body, cloud, beam, End portal, crack or energy swirl (overlay codes Horizon's entity
/// shader draws in its own way; entity.wgsl).
fn plain(run: &[Vertex]) -> bool {
run.iter()
.step_by(4)
.all(|v| !matches!(v.layer, 2 | 4 | 6 | 9 | 10 | 11 | 12 | 34))
run.iter().step_by(4).all(|v| {
!matches!(
v.layer & !super::entity_batch::OIT_ENTITY,
2 | 4 | 6 | 9 | 10 | 11 | 12 | 34
)
})
}
/// The programs of entity runs, in the order they draw.
@@ -124,6 +124,26 @@ impl Weather {
uploads.buffer(encoder, &self.vertices, 0, bytemuck::cast_slice(vertices));
uploads.buffer(encoder, &self.uniform, 0, bytemuck::bytes_of(&time));
}
/// The classic pipeline, for its layouts.
pub fn pipeline(&self) -> &wgpu::RenderPipeline {
&self.pipeline
}
/// `draw` with another pipeline of the same layouts and vertex format.
pub fn draw_with(
&self,
pass: &mut wgpu::RenderPass<'_>,
pipeline: &wgpu::RenderPipeline,
entities: &wgpu::BindGroup,
) {
if self.count == 0 {
return;
}
pass.set_pipeline(pipeline);
pass.set_bind_group(0, entities, &[]);
pass.set_bind_group(1, &self.group, &[]);
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) {
if self.count == 0 {
return;
@@ -344,7 +344,7 @@ impl Post {
"Scaled world depth",
size,
wgpu::TextureFormat::Depth32Float,
sampled,
sampled | wgpu::TextureUsages::COPY_SRC,
)
.create_view(&Default::default());
let group = self.bind(device, &color);
+17 -1
View File
@@ -311,6 +311,8 @@ pub fn reduced_far_detail_suggested() -> bool {
REDUCED_SUGGESTED.load(std::sync::atomic::Ordering::Relaxed)
}
/// The name the options screen waits on to bind `Graphics::hdr_key`.
pub const HDR_KEY: &str = "horizon.hdrSettings";
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.
@@ -391,6 +393,8 @@ pub struct Graphics {
/// HDR brightness of the HUD and menus, in percent of paper white (25..=100); the
/// first-person hand stays at the world's.
pub hdr_ui_brightness: u32,
/// A vanilla key name (`HDR_KEY`): opens HDR's settings from the game. Unbound by default.
pub hdr_key: String,
/// Battery mode: at most 60 frames per second in a world and 30 in menus
/// or behind other windows, and half the worker threads. Off by default;
/// it changes when frames are drawn, never what they show or the ticks.
@@ -429,6 +433,7 @@ impl Default for Graphics {
hdr_peak: 0,
hdr_black_level: 0,
hdr_ui_brightness: 100,
hdr_key: "key.keyboard.unknown".into(),
battery: false,
api: GraphicsApi::Auto,
shader_pack: String::new(),
@@ -507,6 +512,7 @@ impl Graphics {
"hdrUiBrightness" => {
g.hdr_ui_brightness = number(g.hdr_ui_brightness).clamp(25, 100)
}
"hdrKey" if value.starts_with("key.") => g.hdr_key = value.to_owned(),
"batteryMode" => g.battery = flag(g.battery),
"graphicsApi" => g.api = GraphicsApi::from_name(value).unwrap_or_default(),
"pickedShaderPack" => g.shader_pack = value.to_owned(),
@@ -524,7 +530,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:{}\nbatteryMode:{}\ngraphicsApi:{}\npickedShaderPack:{}\n",
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\ngraphicsApi:{}\npickedShaderPack:{}\n",
self.preset.name(),
self.lod_distance,
self.far_detail.name(),
@@ -556,11 +562,20 @@ impl Graphics {
self.hdr_peak,
self.hdr_black_level,
self.hdr_ui_brightness,
self.hdr_key,
bool_text(self.battery),
self.api.name(),
self.shader_pack,
)
}
/// Binds Horizon's graphics key `name` (`HDR_KEY`); false for another name.
pub fn set_key(&mut self, name: &str, value: String) -> bool {
match name {
HDR_KEY => self.hdr_key = value,
_ => return false,
}
true
}
pub fn save(&self, dir: &Path) {
let _ = fs::write(dir.join(FILE), self.serialize());
}
@@ -691,6 +706,7 @@ mod tests {
hdr_peak: 1400,
hdr_black_level: 12,
hdr_ui_brightness: 60,
hdr_key: "key.keyboard.h".into(),
battery: true,
api: GraphicsApi::Dx12,
shader_pack: "Golden Hour".into(),
@@ -3,6 +3,7 @@
//! lay out rows and handle input; a menu theme replaces this module.
use crate::{
hud::{HudAtlas, HudBatch},
lang,
text::Text,
ui,
};
@@ -156,10 +157,11 @@ impl Style {
cost: Cost,
text: &str,
) {
let tag = Text::literal(cost.label());
let tag = Text::literal(lang::get(cost.label()));
batch.text_styled(atlas, &tag, x, y, self.cost_colors[cost as usize], true);
let indent = atlas.font.width_text(&tag) + 4.0;
let lines = wrap(atlas, text, self.text_width(batch.width), indent);
let text = lang::get(text);
let lines = wrap(atlas, &text, self.text_width(batch.width), indent);
for (i, line) in lines.iter().take(2).enumerate() {
let lx = if i == 0 { x + indent } else { x };
batch.text_styled(
+61 -20
View File
@@ -5,6 +5,7 @@ use crate::{
graphics::{FarDetail, GraphicsApi, Overlay, Pacing, PresentMode, Preset, Upscaling},
graphics_style::{Cost, STYLE},
hud::{HudAtlas, HudBatch},
lang,
settings::{Opt, Settings},
text::Text,
ui,
@@ -43,6 +44,8 @@ pub enum H {
/// Opens `Level::Hdr`.
HdrSettings,
HdrBlackLevel,
/// Binds `Graphics::hdr_key`.
HdrKey,
HdrPeak,
HdrPaperWhite,
HdrUiBrightness,
@@ -153,6 +156,11 @@ pub fn rows(level: Level) -> Vec<Row> {
None,
"How bright the HUD and menus are, against paper white.",
),
h(
H::HdrKey,
None,
"A key that opens this screen from the game. Not bound at first.",
),
],
Level::Advanced => vec![
Row::Header("Rendering"),
@@ -354,11 +362,14 @@ fn extra_rows(level: Level, settings: &Settings) -> Vec<Row> {
rows
}
fn on_off(on: bool) -> &'static str {
if on { "On" } else { "Off" }
fn on_off(on: bool) -> String {
lang::get(if on { "On" } else { "Off" })
}
/// A control's caption and its value, both through the language table: Horizon's captions are
/// English sentences, and some of the values ("Off", "12 chunks") are words too.
fn labelled(caption: &str, value: impl std::fmt::Display) -> Text {
Text::literal(format!("{caption}: {value}"))
let value = value.to_string();
Text::literal(format!("{}: {}", lang::get(caption), lang::get(&value)))
}
impl H {
@@ -396,19 +407,21 @@ impl H {
H::Preset => labelled("Preset", g.preset.label()),
H::LodDistance => match g.lod_distance {
0 => labelled("LOD Distance", "Off"),
n => labelled("LOD Distance", format!("{n} chunks")),
n => labelled("LOD Distance", lang::tr("%s chunks", &[&n])),
},
H::FarDetail => labelled("Far Detail", g.far_detail.label()),
H::CaveCulling => labelled("Cave Culling", on_off(g.cave_culling)),
H::EntityCulling => labelled("Entity Culling", on_off(g.entity_culling)),
H::ChunkUpdates => match g.chunk_updates {
0 => labelled("Chunk Updates", "Unlimited"),
n => labelled("Chunk Updates", format!("{n} per frame")),
n => labelled("Chunk Updates", lang::tr("%s per frame", &[&n])),
},
H::UploadBudget => match g.upload_budget_kib {
0 => labelled("Upload Budget", "Unlimited"),
k if k >= 1024 => labelled("Upload Budget", format!("{} MiB/frame", k / 1024)),
k => labelled("Upload Budget", format!("{k} KiB/frame")),
k if k >= 1024 => {
labelled("Upload Budget", lang::tr("%s MiB/frame", &[&(k / 1024)]))
}
k => labelled("Upload Budget", lang::tr("%s KiB/frame", &[&k])),
},
H::Sky => labelled("Sky", on_off(g.sky)),
H::Fog => labelled("Fog", on_off(g.fog)),
@@ -423,7 +436,10 @@ impl H {
},
),
H::Workers => match g.workers {
0 => labelled("Worker Threads", format!("Auto ({})", g.worker_threads())),
0 => labelled(
"Worker Threads",
lang::tr("Auto (%s)", &[&g.worker_threads()]),
),
n => labelled("Worker Threads", n),
},
H::ResolutionScale => labelled("Resolution Scale", format!("{}%", g.resolution_scale)),
@@ -437,23 +453,32 @@ impl H {
),
H::Upscaling => labelled("Upscaling", g.upscaling.label()),
H::Hdr => labelled("HDR", on_off(g.hdr)),
H::HdrSettings => Text::literal("HDR Settings..."),
H::HdrSettings => Text::literal(lang::get("HDR Settings...")),
H::HdrPaperWhite => match g.hdr_paper_white {
0 => labelled(
"Paper White",
format!("Auto ({} nits)", g.paper_white_nits()),
lang::tr("Auto (%s nits)", &[&g.paper_white_nits()]),
),
n => labelled("Paper White", format!("{n} nits")),
n => labelled("Paper White", lang::tr("%s nits", &[&n])),
},
H::HdrPeak => match g.hdr_peak {
0 => labelled("Peak Brightness", format!("Auto ({} nits)", g.peak_nits())),
n => labelled("Peak Brightness", format!("{n} nits")),
0 => labelled(
"Peak Brightness",
lang::tr("Auto (%s nits)", &[&g.peak_nits()]),
),
n => labelled("Peak Brightness", lang::tr("%s nits", &[&n])),
},
H::HdrBlackLevel => labelled(
"Black Level",
format!("{:.2} nits", g.hdr_black_level as f32 / 100.0),
lang::tr(
"%s nits",
&[&format!("{:.2}", g.hdr_black_level as f32 / 100.0)],
),
),
H::HdrUiBrightness => labelled("UI Brightness", format!("{}%", g.hdr_ui_brightness)),
H::HdrKey => {
Text::literal("Shortcut: ").append(crate::keybinds::display_name(&g.hdr_key))
}
H::BatteryMode => labelled("Battery Mode", on_off(g.battery)),
H::GraphicsApi => labelled("Graphics API", g.api.label()),
H::Overlay => labelled(
@@ -618,7 +643,7 @@ impl Item {
match self {
Item::Vanilla(Opt::RenderDistance) if s.horizon.lod_distance > 0 => labelled(
"Full Detail Distance",
format!("{} chunks", s.render_distance),
lang::tr("%s chunks", &[&s.render_distance]),
),
Item::Vanilla(o) => o.label(s),
Item::Horizon(h) => h.label(s),
@@ -686,6 +711,8 @@ pub enum Action {
OpenAdvanced,
OpenSimple,
OpenHdr,
/// Wait for a key or mouse button to bind to this name.
BindKey(&'static str),
Done,
}
@@ -708,6 +735,8 @@ pub struct GraphicsUi {
/// The list's scroll amount and its held scrollbar (`AbstractScrollArea`).
scroll: ui::Scroll,
dragging: Option<Item>,
/// The key binding the options screen is waiting on (`Action::BindKey`).
pub binding: Option<&'static str>,
}
impl GraphicsUi {
@@ -818,7 +847,7 @@ impl GraphicsUi {
let x = STYLE.row_x(w);
for (y, name) in headers {
if shown(y, STYLE.header_height) {
STYLE.header(batch, atlas, &Text::literal(name), x, y);
STYLE.header(batch, atlas, &Text::literal(lang::get(name)), x, y);
}
}
let descriptions: Vec<(Cost, &'static str)> = extra_rows(level, s)
@@ -840,11 +869,17 @@ impl GraphicsUi {
if !shown(wd.y, 20.0) {
continue;
}
let label = item.label(s);
let mut label = item.label(s);
if item == Item::Horizon(H::HdrKey) && self.binding.is_some() {
// KeyBindsScreen: "> key <" while it waits.
label = Text::literal("Shortcut: > ")
.append(crate::keybinds::display_name(&s.horizon.hdr_key))
.append(" <");
}
let blocked = item.blocked(s);
let live = blocked.is_none();
if hover && let Some(why) = blocked {
tooltip = Some(atlas.font.split(&Text::literal(why), 170.0));
tooltip = Some(atlas.font.split(&Text::literal(lang::get(why)), 170.0));
}
match item.slider(s) {
Some(value) => STYLE.slider(
@@ -885,7 +920,7 @@ impl GraphicsUi {
batch,
atlas,
(wd.x, wd.y, wd.w),
&Text::literal(label),
&Text::literal(lang::get(label)),
hover,
true,
);
@@ -907,7 +942,7 @@ impl GraphicsUi {
Level::Advanced => "Graphics: Advanced",
Level::Hdr => "Graphics: HDR",
};
STYLE.title(batch, atlas, &Text::literal(title));
STYLE.title(batch, atlas, &Text::literal(lang::get(title)));
if let Some(lines) = tooltip {
ui::tooltip(batch, atlas, &lines, cursor);
}
@@ -965,6 +1000,12 @@ impl GraphicsUi {
Target::Item(Item::Horizon(H::HdrSettings)) => {
vec![Action::ButtonClick, Action::OpenHdr]
}
Target::Item(Item::Horizon(H::HdrKey)) => {
vec![
Action::ButtonClick,
Action::BindKey(crate::graphics::HDR_KEY),
]
}
Target::Item(item) => {
if item.slider(s).is_some() {
self.dragging = Some(item);
+2
View File
@@ -62,7 +62,9 @@ pub mod par;
pub mod particles;
pub mod poi_sections;
pub mod post_chain;
pub mod post_glsl;
pub mod potent_sulfur;
pub mod preedit;
pub mod providers;
pub mod screen_draw;
pub mod screen_effects;
@@ -130,6 +130,12 @@ impl Mesher {
let tint = match name.strip_prefix("minecraft:").unwrap_or(name) {
"spruce_leaves" => Tint::Constant(0x61_9961),
"birch_leaves" => Tint::Constant(0x80_A755),
// Cherry (and other pre-coloured) leaves: the texture is
// already pink; biome foliage tint turns a grove yellow (#4688).
"cherry_leaves"
| "azalea_leaves"
| "flowering_azalea_leaves"
| "pale_oak_leaves" => Tint::None,
"leaf_litter" => Tint::Dry,
_ if leaves => Tint::Foliage,
_ => Tint::Grass,
+19 -4
View File
@@ -139,8 +139,12 @@ mod vertex_tests {
assert_eq!(hdr_tier("red_candle", 3), LOW);
assert_eq!(hdr_tier("glow_lichen", 7), 0);
assert_eq!(hdr_tier("stone", 0), 0);
assert_eq!(hdr_tier("ochre_froglight", 15), LOW);
assert_eq!(hdr_tier("respawn_anchor", 3), INCANDESCENT);
assert_eq!(hdr_tier("waxed_copper_bulb", 15), LOW);
assert_eq!(hdr_tier("sea_pickle", 15), 0);
// Unlisted ones by their light level.
assert_eq!(hdr_tier("ochre_froglight", 15), INCANDESCENT);
assert_eq!(hdr_tier("vault", 12), INCANDESCENT);
assert_eq!(hdr_tier("sculk_sensor", 1), LOW);
}
}
@@ -702,14 +706,25 @@ pub fn hdr_tier(name: &str, level: u8) -> u8 {
match name {
_ if level == 0 => 0,
"glowstone" | "jack_o_lantern" | "campfire" | "soul_campfire" | "soul_lantern"
| "soul_torch" | "soul_wall_torch" | "soul_fire" => BRIGHT,
| "soul_torch" | "soul_wall_torch" | "soul_fire" | "beacon" | "end_rod" | "furnace"
| "redstone_lamp" => BRIGHT,
"torch" | "wall_torch" | "lantern" | "lava" | "crying_obsidian" | "fire"
| "sea_lantern" => INCANDESCENT,
"redstone_torch" | "redstone_wall_torch" | "magma_block" => LOW,
// Glowing at every charge, not only the brighter ones.
"respawn_anchor" => INCANDESCENT,
"redstone_torch"
| "redstone_wall_torch"
| "magma_block"
| "ochre_froglight"
| "verdant_froglight"
| "pearlescent_froglight"
| "shroomlight" => LOW,
// Its flame is a particle.
_ if name.ends_with("candle") || name.ends_with("candle_cake") => LOW,
// One tier at every oxidation stage, waxed or not.
_ if name.ends_with("copper_bulb") => LOW,
// A faint glow in the dark, as in SDR.
"glow_lichen" => 0,
"glow_lichen" | "sea_pickle" | "brown_mushroom" | "dragon_egg" | "end_portal_frame" => 0,
_ if level >= 10 => INCANDESCENT,
_ => LOW,
}
@@ -579,6 +579,9 @@ impl MultiLineEdit {
if show_cursor && !insert_cursor && self.within(cursor_y + shift, cursor_y + shift + LINE) {
batch.text(atlas, "_", cursor_x, cursor_y, cursor_color, true);
}
if self.focused {
crate::preedit::report_caret(cursor_x, cursor_y, 10.0);
}
if self.has_selection() {
let (sel_begin, sel_end) = self.selected();
let draw_x = inner_left;
+166
View File
@@ -0,0 +1,166 @@
// Improved transparency (the options screen's order-independent transparency),
// appended to terrain.wgsl. Vanilla 26.3 draws its translucent layer three
// times: once to find each pixel's nearest and furthest see-through depth (the
// depth bounds), once to add each fragment's absorbance into a small wavelet
// transform over that range (the transmittance), and once to weight every
// fragment's colour by what the transform says lies in front of it (the
// accumulation). A last full-screen pass puts the sum over the opaque world.
//
// Eight coefficients in two RGBA16F targets: [0] is the mean absorbance, [1]
// the widest wavelet, [2..4] the next level, [4..8] the finest, one per depth
// bin pair.
struct OitParams {
// x, y: the projection's [2][2] and [3][2], which turn a device depth into
// a distance along the view axis: y / (depth + x).
projection: vec4<f32>,
}
@group(2) @binding(0) var<uniform> oit: OitParams;
@group(2) @binding(1) var oit_bounds: texture_2d<f32>;
@group(2) @binding(2) var oit_coeff0: texture_2d<f32>;
@group(2) @binding(3) var oit_coeff1: texture_2d<f32>;
@group(2) @binding(4) var oit_accumulated: texture_2d<f32>;
const OIT_FULLY_OPAQUE_ALPHA: f32 = 0.99;
const OIT_FULLY_OPAQUE_TRANSMITTANCE: f32 = 0.02;
const OIT_BINS: i32 = 8;
const OIT_LOW_PRECISION_BINS: f32 = 2.0;
const OIT_HIGH_PRECISION_RANGE: f32 = 10.0;
const OIT_ALPHA_CUTOUT: f32 = 0.1;
fn oit_linear_depth(device_depth: f32) -> f32 {
return oit.projection.y / (device_depth + oit.projection.x);
}
fn oit_absorbance(transmittance: f32) -> f32 {
return clamp(-log(max(transmittance, 0.0001)), 0.0, 4.0);
}
fn oit_transmittance(absorbance: f32) -> f32 {
return clamp(exp(-absorbance), 0.0001, 1.0);
}
// Where a fragment sits in its pixel's depth range, 0 to 1. The last
// OIT_LOW_PRECISION_BINS bins stretch over whatever is past the first ten
// blocks, so a thin pane near the eye keeps its resolution in a deep range.
fn oit_normalize_depth(device_depth: f32, pixel: vec2<f32>) -> f32 {
let bounds = textureLoad(oit_bounds, vec2<i32>(pixel), 0);
let nearest = -bounds.r;
let furthest = bounds.g;
let linear = oit_linear_depth(device_depth);
let range = max(furthest - nearest, 0.0001);
let within = clamp(linear - nearest, 0.0, range);
let fine_share = (f32(OIT_BINS) - OIT_LOW_PRECISION_BINS) / f32(OIT_BINS);
let fine_range = fine_share * range;
if fine_range <= OIT_HIGH_PRECISION_RANGE {
return within / range;
}
if within <= OIT_HIGH_PRECISION_RANGE {
return within / OIT_HIGH_PRECISION_RANGE * fine_share;
}
return fine_share + (within - OIT_HIGH_PRECISION_RANGE)
/ (range - OIT_HIGH_PRECISION_RANGE) * (1.0 - fine_share);
}
struct OitCoefficients {
@location(0) first: vec4<f32>,
@location(1) second: vec4<f32>,
}
// One fragment's contribution to the transform (summed by additive blending).
fn oit_add_transmittance(transmittance: f32, frag_depth: f32, pixel: vec2<f32>) -> OitCoefficients {
var c: array<f32, 8>;
let absorbance = oit_absorbance(transmittance);
let original = oit_normalize_depth(frag_depth, pixel);
let depth = original * f32(OIT_BINS - 1) / f32(OIT_BINS);
c[0] = absorbance * (1.0 - depth);
// The wavelet tree's leaves start at index 8.
var tree = i32(floor(original * f32(OIT_BINS - 1))) + OIT_BINS;
for (var level = 0; level <= 2; level++) {
let power = 2 - level;
let parent = tree >> 1u;
let index_at_parent = f32(parent & ((1 << u32(power)) - 1));
let right = tree & 1;
let side = 1 - (right << 1u);
let width = exp2(-f32(power));
let height = exp2(f32(power) * 0.5);
let offset = depth - width * index_at_parent;
c[parent] = (f32(right) * width + f32(side) * offset) * height * absorbance;
tree = parent;
}
return OitCoefficients(vec4(c[0], c[1], c[2], c[3]), vec4(c[4], c[5], c[6], c[7]));
}
// The absorbance in front of `original` (a normalized depth), without the
// fragment's own contribution `current` that the transform already holds.
fn oit_sample_absorbance(coefficients: array<f32, 8>, original: f32, current: f32) -> f32 {
var c = coefficients;
var average = c[0];
if average == 0.0 {
return 0.0;
}
let in_bins = original * f32(OIT_BINS - 1);
let depth = in_bins / f32(OIT_BINS);
average -= current * (1.0 - depth);
// B: the bin the fragment is in; A: the one before it.
var tree_b = clamp(i32(floor(in_bins)), 0, OIT_BINS - 1);
let sample_a_too = tree_b >= 1;
var tree_a = select(tree_b, tree_b - 1, sample_a_too);
tree_b += OIT_BINS;
tree_a += OIT_BINS;
var sample_b = average;
var sample_a = select(0.0, average, sample_a_too);
for (var level = 0; level <= 2; level++) {
let power = 2 - level;
let width = exp2(-f32(power));
let height = exp2(f32(power) * 0.5);
let parent_b = tree_b >> 1u;
let right_b = tree_b & 1;
let side_b = 1 - (right_b << 1u);
let index_at_parent = f32(parent_b & ((1 << u32(power)) - 1));
let offset = depth - width * index_at_parent;
let own = (f32(right_b) * width + f32(side_b) * offset) * height * current;
let difference_b = c[parent_b] - own;
sample_b -= height * difference_b * f32(side_b);
tree_b = parent_b;
if sample_a_too {
let parent_a = tree_a >> 1u;
let difference_a = select(c[parent_a], difference_b, parent_a == parent_b);
let side_a = 1 - ((tree_a & 1) << 1u);
sample_a -= height * difference_a * f32(side_a);
tree_a = parent_a;
}
}
let blend = select(fract(in_bins), 1.0, in_bins >= f32(OIT_BINS));
return mix(sample_a, sample_b, blend);
}
fn oit_sample_transmittance(pixel: vec2<i32>, depth: f32, current: f32) -> f32 {
let a = textureLoad(oit_coeff0, pixel, 0);
let b = textureLoad(oit_coeff1, pixel, 0);
let c = array<f32, 8>(a.x, a.y, a.z, a.w, b.x, b.y, b.z, b.w);
return oit_transmittance(oit_sample_absorbance(c, depth, oit_absorbance(current)));
}
// The three stages' ends for a pipeline whose fragment colour is computed as
// it is without improved transparency (vanilla's `executeAlphaOnlyPhase` and
// `sampleColorForAccumulation`). `color` is the classic result, fogged:
// weighting a fogged colour equals fogging the weighted one, since the fog
// is a mix and vanilla premultiplies the fog colour by the same alpha.
fn oit_bounds_fragment(alpha: f32, clip: vec4<f32>) -> vec4<f32> {
if alpha < 0.01 { discard; }
let linear = oit_linear_depth(clip.z);
let opaque = select(0.0, clip.z, alpha > OIT_FULLY_OPAQUE_ALPHA);
return vec4(-linear, linear, clip.z, opaque);
}
fn oit_transmittance_fragment(alpha: f32, clip: vec4<f32>) -> OitCoefficients {
return oit_add_transmittance(1.0 - alpha, clip.z, clip.xy);
}
fn oit_accumulate_fragment(color: vec4<f32>, clip: vec4<f32>) -> vec4<f32> {
let seen = oit_sample_transmittance(vec2<i32>(clip.xy),
oit_normalize_depth(clip.z, clip.xy), 1.0 - color.a);
return vec4(color.rgb * color.a, color.a) * seen;
}
// `OIT_ADDITIVE`: no coverage and nothing in the transmittance. The colour
// is not fogged yet: the fog colour is premultiplied by the alpha, which is
// zero, so the fog fades it out instead of tinting it.
fn oit_accumulate_additive(color: vec4<f32>, fog: f32, clip: vec4<f32>) -> vec4<f32> {
let seen = oit_sample_transmittance(vec2<i32>(clip.xy),
oit_normalize_depth(clip.z, clip.xy), 1.0);
return vec4(color.rgb * color.a * (1.0 - fog), 0.0) * seen;
}
@@ -0,0 +1,13 @@
// Improved transparency's stages for the world border (`OIT_WORLD_BORDER`:
// `world_border.fsh` with `OIT_ADDITIVE`), appended to border.wgsl after
// oit.wgsl.
@fragment fn fs_oit_border_bounds(in: Out) -> @location(0) vec4<f32> {
return oit_bounds_fragment(border_color(in).a, in.clip);
}
@fragment fn fs_oit_border_transmittance(in: Out) -> OitCoefficients {
discard;
return OitCoefficients(vec4(0.0), vec4(0.0));
}
@fragment fn fs_oit_border_accumulate(in: Out) -> @location(0) vec4<f32> {
return oit_accumulate_additive(border_color(in), 0.0, in.clip);
}
@@ -0,0 +1,28 @@
// Improved transparency's cloud stages (`clouds.fsh` under OIT_*), appended to
// entity.wgsl after oit.wgsl. The clouds' vertex shader and draw are the
// classic ones; only the fragment colour goes through the three stages.
// The cloud's colour; it fades out towards the cloud fog end everywhere but in
// the depth bounds stage, which sees the unfaded alpha.
fn oit_cloud_color(in: Out, front: bool, fade: bool) -> vec4<f32> {
let color = textureSample(atlas, atlas_sampler, in.uv) * select(in.back_color, in.color, front);
let faded = 1.0 - linear_fog(in.spherical, 0.0, globals.fog_ends.y);
return vec4(color.rgb, color.a * select(1.0, faded, fade));
}
@fragment fn fs_oit_cloud_bounds(in: Out, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
let color = oit_cloud_color(in, front, false);
if color.a < 0.01 { discard; }
let linear = oit_linear_depth(in.clip.z);
let opaque = select(0.0, in.clip.z, color.a > OIT_FULLY_OPAQUE_ALPHA);
return vec4(-linear, linear, in.clip.z, opaque);
}
@fragment fn fs_oit_cloud_transmittance(in: Out, @builtin(front_facing) front: bool) -> OitCoefficients {
let color = oit_cloud_color(in, front, true);
return oit_add_transmittance(1.0 - color.a, in.clip.z, in.clip.xy);
}
@fragment fn fs_oit_cloud_accumulate(in: Out, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
let color = oit_cloud_color(in, front, true);
let seen = oit_sample_transmittance(vec2<i32>(in.clip.xy),
oit_normalize_depth(in.clip.z, in.clip.xy), 1.0 - color.a);
return vec4(color.rgb * color.a, color.a) * seen;
}
@@ -0,0 +1,61 @@
// Improved transparency's stages for what the entity shader draws after the
// opaque world (`LevelRenderer.executeOit`'s "Features, World Border,
// Weather" pass), appended to entity.wgsl after oit.wgsl. The colour of each
// is the classic fragment's, so the shaders cannot drift apart.
// Items, particles, text, breaking cracks (`RenderPipelines.OIT_ENTITY` and
// the others on entity.fsh).
@fragment fn fs_oit_entity_bounds(in: Out, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
return oit_bounds_fragment(entity_color(in, front).a, in.clip);
}
@fragment fn fs_oit_entity_transmittance(in: Out, @builtin(front_facing) front: bool) -> OitCoefficients {
return oit_transmittance_fragment(entity_color(in, front).a, in.clip);
}
@fragment fn fs_oit_entity_accumulate(in: Out, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
return oit_accumulate_fragment(entity_color(in, front), in.clip);
}
// `OIT_ENTITY_SHADOW`.
@fragment fn fs_oit_shadow_bounds(in: Out) -> @location(0) vec4<f32> {
return oit_bounds_fragment(shadow_color(in).a, in.clip);
}
@fragment fn fs_oit_shadow_transmittance(in: Out) -> OitCoefficients {
return oit_transmittance_fragment(shadow_color(in).a, in.clip);
}
@fragment fn fs_oit_shadow_accumulate(in: Out) -> @location(0) vec4<f32> {
return oit_accumulate_fragment(shadow_color(in), in.clip);
}
// `OIT_WEATHER` (the particle shader).
@fragment fn fs_oit_weather_bounds(in: Out) -> @location(0) vec4<f32> {
return oit_bounds_fragment(weather_color(in).a, in.clip);
}
@fragment fn fs_oit_weather_transmittance(in: Out) -> OitCoefficients {
return oit_transmittance_fragment(weather_color(in).a, in.clip);
}
@fragment fn fs_oit_weather_accumulate(in: Out) -> @location(0) vec4<f32> {
return oit_accumulate_fragment(weather_color(in), in.clip);
}
// `OIT_ENERGY_SWIRL`: entity.fsh with `OIT_ADDITIVE`, which caps the alpha.
@fragment fn fs_oit_swirl_bounds(in: Out) -> @location(0) vec4<f32> {
return oit_bounds_fragment(min(0.99, energy_swirl_base(in).a), in.clip);
}
@fragment fn fs_oit_swirl_transmittance(in: Out) -> OitCoefficients {
discard;
return OitCoefficients(vec4(0.0), vec4(0.0));
}
@fragment fn fs_oit_swirl_accumulate(in: Out) -> @location(0) vec4<f32> {
let color = energy_swirl_base(in);
return oit_accumulate_additive(vec4(color.rgb, min(0.99, color.a)), energy_swirl_fog(in), in.clip);
}
// `OIT_LIGHTNING` and `OIT_DRAGON_RAYS`: additive (`OIT_ADDITIVE`), the
// classic fragment's colour (fs_celestial) with the alpha capped.
@fragment fn fs_oit_lightning_bounds(in: Out) -> @location(0) vec4<f32> {
return oit_bounds_fragment(min(0.99, celestial_color(in).a), in.clip);
}
@fragment fn fs_oit_lightning_transmittance(in: Out) -> OitCoefficients {
discard;
return OitCoefficients(vec4(0.0), vec4(0.0));
}
@fragment fn fs_oit_lightning_accumulate(in: Out) -> @location(0) vec4<f32> {
let color = celestial_color(in);
return oit_accumulate_additive(vec4(color.rgb, min(0.99, color.a)), 0.0, in.clip);
}
@@ -0,0 +1,57 @@
// Improved transparency's terrain stages and its screen passes (oit.wgsl
// has the shared maths), appended to terrain.wgsl after oit.wgsl.
// The terrain's translucent layer, once per stage.
fn oit_terrain_color(in: TerrainOut) -> vec4<f32> {
var color = sprite(in.uv, in.layer & 0x7fffu) * in.color;
color = mix(globals.fog_color * vec4(1.0, 1.0, 1.0, color.a), color, in.visibility);
if color.a < OIT_ALPHA_CUTOUT { discard; }
return color;
}
// Stage 1: (-nearest, furthest, nearest device depth, nearest opaque device
// depth), combined by max blending.
@fragment fn fs_oit_bounds(in: TerrainOut) -> @location(0) vec4<f32> {
let color = oit_terrain_color(in);
let linear = oit_linear_depth(in.clip.z);
let opaque = select(0.0, in.clip.z, color.a > OIT_FULLY_OPAQUE_ALPHA);
return vec4(-linear, linear, in.clip.z, opaque);
}
// Stage 2.
@fragment fn fs_oit_transmittance(in: TerrainOut) -> OitCoefficients {
let color = oit_terrain_color(in);
return oit_add_transmittance(1.0 - color.a, in.clip.z, in.clip.xy);
}
// Stage 3: premultiplied colour, weighted by the transmittance in front.
@fragment fn fs_oit_accumulate(in: TerrainOut) -> @location(0) vec4<f32> {
let color = oit_terrain_color(in);
let seen = oit_sample_transmittance(vec2<i32>(in.clip.xy),
oit_normalize_depth(in.clip.z, in.clip.xy), 1.0 - color.a);
let weighted = vec4(color.rgb * color.a, color.a) * seen;
let f = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w));
// The fog colour is premultiplied by the same weight.
let rgb = mix(weighted.rgb, globals.fog_color.rgb * weighted.a, f * globals.fog_color.a);
return vec4(rgb, weighted.a);
}
struct OitFragment {
@location(0) color: vec4<f32>,
@builtin(frag_depth) depth: f32,
}
// After the bounds stage: a fully opaque fragment hides what is behind it, so
// the far bound stops there and its depth goes into the depth buffer.
@fragment fn fs_oit_cull(@builtin(position) pos: vec4<f32>) -> OitFragment {
let b = textureLoad(oit_bounds, vec2<i32>(pos.xy), 0);
let opaque = oit_linear_depth(b.a);
return OitFragment(vec4(b.r, min(b.g, opaque), b.b, b.a), b.a);
}
// The sum over the world, renormalized to the coverage the transform gives.
@fragment fn fs_oit_composite(@builtin(position) pos: vec4<f32>) -> OitFragment {
let p = vec2<i32>(pos.xy);
let accumulated = textureLoad(oit_accumulated, p, 0);
let seen = oit_sample_transmittance(p, 100000.0, 1.0);
let coverage = 1.0 - select(seen, 0.0, seen < OIT_FULLY_OPAQUE_TRANSMITTANCE);
// Additive surfaces add light but no coverage.
if coverage < 0.00001 && dot(accumulated.rgb, vec3(1.0)) < 0.00001 { discard; }
let scale = select(1.0, coverage / accumulated.a, accumulated.a > 0.00001);
return OitFragment(vec4(accumulated.rgb * scale, coverage), textureLoad(oit_bounds, p, 0).b);
}
+58 -10
View File
@@ -141,9 +141,6 @@ impl Opt {
| Opt::ChunkFade
| Opt::ImprovedTransparency
| Opt::ChunkBuilder
// Saved, but the terrain sampler has no RGSS or anisotropic filtering yet.
| Opt::TextureFiltering
| Opt::MaxAnisotropy
// Saved, but the mouse doesn't read it yet.
| Opt::CtrlClickEmulatesRightClick
)
@@ -1352,6 +1349,9 @@ pub struct OptionsUi {
pub resource_error: Option<String>,
/// When the pack screen's search box took the focus (its cursor blink).
pack_focus: Instant,
/// The selection carried over from a pack screen made again over a changed pack list
/// (`update_pack_infos`) differs from the settings'.
pack_reselected: bool,
pub in_world: bool,
/// Scroll the selected language into the middle on the next layout.
center_language: bool,
@@ -1387,6 +1387,7 @@ impl Default for OptionsUi {
data_pack_stamp: (Vec::new(), Instant::now()),
resource_error: None,
pack_focus: Instant::now(),
pack_reselected: false,
in_world: false,
center_language: false,
graphics: Default::default(),
@@ -1418,6 +1419,10 @@ fn option_widget(s: &Settings, opt: Opt, x: f32, y: f32, w: f32) -> Widget {
if !opt.available() {
return inactive(button(x, y, w, opt.label(s), Act::Cycle(opt)));
}
// VideoSettingsScreen.tick: Max Anisotropy only works with Anisotropic filtering.
if opt == Opt::MaxAnisotropy && s.file.int("textureFiltering") != 2 {
return inactive(button(x, y, w, opt.label(s), Act::Cycle(opt)));
}
match opt.kind(s) {
Kind::Slider(v) => Widget {
x,
@@ -1472,6 +1477,23 @@ impl OptionsUi {
self.settings_search.focused
&& matches!(self.page(), Page::Video | Page::Controls | Page::KeyBinds)
}
/// Whether the resource pack screen is open (not Create World's data packs on the same
/// page).
pub fn resource_packs_open(&self) -> bool {
self.page() == Page::Packs && self.data_pack_dir.is_none()
}
/// The packs in `resourcepacks/` changed while the pack screen is open
/// (`PackSelectionScreen`'s directory watcher): the screen is made again over
/// `infos`, keeping the selection made on it so far.
pub fn update_pack_infos(&mut self, infos: Vec<crate::pack_screen::PackInfo>) {
if let Some(p) = self.pack_screen.take()
&& p.changed
{
self.pack_selection = p.commit();
self.pack_reselected = true;
}
self.pack_infos = infos;
}
pub fn page(&self) -> Page {
*self.stack.last().unwrap_or(&Page::Main)
}
@@ -2588,6 +2610,7 @@ impl OptionsUi {
return;
}
if let Some(level) = Self::graphics_level(self.page()) {
self.graphics.binding = self.selected_key;
self.graphics
.draw(level, s, batch, atlas, cursor, self.in_world);
return;
@@ -2847,6 +2870,15 @@ impl OptionsUi {
}
if let Some(level) = Self::graphics_level(self.page()) {
use crate::graphics_ui::Action;
// Waiting on a key: a mouse button binds, as on Key Binds.
if self.selected_key.is_some() && !drag {
let button = if right {
winit::event::MouseButton::Right
} else {
winit::event::MouseButton::Left
};
return self.bind_mouse(s, button);
}
let mut events = Vec::new();
for action in self
.graphics
@@ -2858,6 +2890,7 @@ impl OptionsUi {
Action::SliderDrag => events.push(Event::SliderDrag),
Action::OpenAdvanced => self.push(Page::GraphicsAdvanced),
Action::OpenHdr => self.push(Page::GraphicsHdr),
Action::BindKey(name) => self.selected_key = Some(name),
Action::OpenSimple => {
if self.stack.len() >= 2
&& self.stack[self.stack.len() - 2] == Page::Graphics
@@ -3226,7 +3259,7 @@ impl OptionsUi {
} else {
"resourcePack.title"
};
self.pack_screen = Some(crate::pack_screen::PackScreen::new(
let mut screen = crate::pack_screen::PackScreen::new(
&m,
crate::text::Text::translate(title, &[]),
w,
@@ -3234,7 +3267,9 @@ impl OptionsUi {
self.in_world,
self.pack_infos.clone(),
&self.pack_selection,
));
);
screen.changed = std::mem::take(&mut self.pack_reselected);
self.pack_screen = Some(screen);
}
}
self.pack_screen.as_mut().expect("made above")
@@ -3348,7 +3383,7 @@ impl OptionsUi {
keys.insert(key.clone(), value);
return vec![Event::Changed];
}
if !s.extras.set_key(name, value.clone()) {
if !s.extras.set_key(name, value.clone()) && !s.horizon.set_key(name, value.clone()) {
s.keys.set(name, &value);
}
vec![Event::Changed]
@@ -4024,9 +4059,21 @@ mod tests {
assert_eq!(s.file.int("maxAnisotropyBit"), bit, "{preset}");
assert_eq!(s.file.int("textureFiltering"), filtering, "{preset}");
}
// Nothing samples the terrain atlas with them yet, so the buttons stay greyed out.
assert!(!Opt::TextureFiltering.available());
assert!(!Opt::MaxAnisotropy.available());
assert!(Opt::TextureFiltering.available());
assert!(Opt::MaxAnisotropy.available());
// The anisotropy slider only works with Anisotropic filtering (VideoSettingsScreen.tick).
let grey = |s: &Settings| {
matches!(
option_widget(s, Opt::MaxAnisotropy, 0.0, 0.0, 150.0).kind,
WKind::Button { active: false, .. }
)
};
s.file
.set("textureFiltering", crate::options_file::V::Int(1));
assert!(grey(&s));
s.file
.set("textureFiltering", crate::options_file::V::Int(2));
assert!(!grey(&s));
}
#[test]
@@ -4928,7 +4975,7 @@ mod tests {
MouseButton::Back,
MouseButton::Forward,
] {
for name in [SORT_KEY, "key.jump"] {
for name in [SORT_KEY, crate::graphics::HDR_KEY, "key.jump"] {
let mut ui = OptionsUi::default();
let mut s = Settings::default();
ui.selected_key = Some(name);
@@ -4937,6 +4984,7 @@ mod tests {
let value = keybinds::mouse_name(button);
match name {
SORT_KEY => assert_eq!(s.extras.sort_key, value),
crate::graphics::HDR_KEY => assert_eq!(s.horizon.hdr_key, value),
_ => assert_eq!(s.keys.get(name), value),
}
assert_eq!(Extras::parse(&s.extras.serialize()), s.extras);
+76 -1
View File
@@ -414,6 +414,13 @@ fn overlays_of(meta: &serde_json::Value) -> Vec<String> {
.flatten()
.filter(|e| overlay_applies(e))
.filter_map(|e| e["directory"].as_str().map(str::to_owned))
// OverlayMetadataSection's DIR_VALIDATOR: one folder of the pack, `[-_a-zA-Z0-9.]+`.
.filter(|d| {
stays_inside(d)
&& !d.contains('/')
&& d.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"-_.".contains(&b))
})
.collect()
}
fn filter_of(meta: &serde_json::Value) -> Vec<Filter> {
@@ -539,7 +546,7 @@ fn pack_files(pack: &Pack) -> io::Result<BTreeMap<String, Provided>> {
let zip = Zip::open(z)?;
for root in &roots {
let prefix = format!("{root}assets/");
for name in zip.names() {
for name in zip.names().filter(|name| stays_inside(name)) {
if let Some(rest) = name.strip_prefix(&prefix) {
out.insert(format!("assets/{rest}"), (None, name.to_owned()));
}
@@ -549,6 +556,15 @@ fn pack_files(pack: &Pack) -> io::Result<BTreeMap<String, Provided>> {
}
Ok(out)
}
/// Whether a zip entry's name, joined to the overlay folder, stays in it: `/`-separated parts
/// (zip.rs turns `\` into `/`), none empty, `.` or `..`, and no `:` (a drive or a stream on
/// Windows). A pack, a server's too, may name entries anything.
fn stays_inside(name: &str) -> bool {
!name.contains([':', '\0'])
&& name
.split('/')
.all(|part| !part.is_empty() && part != "." && part != "..")
}
#[cfg(unix)]
fn link(target: &Path, at: &Path) -> io::Result<()> {
// Relative sources are relative to the process, not the overlay directory.
@@ -1422,6 +1438,56 @@ mod tests {
let _ = fs::remove_dir_all(base);
}
#[test]
fn zip_entries_never_leave_the_overlay() {
use std::io::Write as _;
let base = std::env::temp_dir().join(format!("horizon-pack-escape-{}", std::process::id()));
let _ = fs::remove_dir_all(&base);
let vanilla = base.join("vanilla");
fs::create_dir_all(vanilla.join("assets/minecraft/textures/block")).unwrap();
fs::write(
vanilla.join("assets/minecraft/textures/block/stone.png"),
b"vanilla",
)
.unwrap();
fs::create_dir_all(base.join("packs")).unwrap();
let mut zip = zip::ZipWriter::new(fs::File::create(base.join("packs/evil.zip")).unwrap());
for (name, data) in [
(
"pack.mcmeta",
&br#"{"pack":{"description":"d","pack_format":97}}"#[..],
),
("assets/minecraft/textures/block/stone.png", b"pack"),
("assets/../../escaped.txt", b"out"),
("assets/minecraft/../../../escaped_lang.json", b"{}"),
("assets\\..\\..\\escaped_backslash.txt", b"out"),
("assets/./minecraft/dot.txt", b"in"),
] {
zip.start_file(name, zip::write::SimpleFileOptions::default())
.unwrap();
zip.write_all(data).unwrap();
}
zip.finish().unwrap();
let packs = discover(&vanilla, &base.join("packs"));
assert_eq!(packs.len(), 1);
let out = base.join("work/overlay");
build_overlay(&vanilla, &packs, &out).unwrap();
assert_eq!(
fs::read(out.join("assets/minecraft/textures/block/stone.png")).unwrap(),
b"pack"
);
for escaped in [
"escaped.txt",
"escaped_lang.json",
"escaped_backslash.txt",
"work/escaped.txt",
"work/escaped_lang.json",
"work/escaped_backslash.txt",
] {
assert!(!base.join(escaped).exists(), "{escaped} was written");
}
let _ = fs::remove_dir_all(base);
}
#[test]
fn built_in_and_folder_packs_give_their_pack_png() {
use std::io::Write as _;
let base = std::env::temp_dir().join(format!("horizon-pack-icons-{}", std::process::id()));
@@ -1469,6 +1535,15 @@ mod tests {
{"min_format":97,"max_format":[97,5],"directory":"also"},
{"formats":50,"directory":"no"}]}}"#);
assert_eq!(overlays_of(&meta), ["new", "also"]);
// Only one plain folder of the pack, as vanilla's DIR_VALIDATOR has it.
let meta = v(r#"{"overlays":{"entries":[
{"formats":97,"directory":".."},
{"formats":97,"directory":"../outside"},
{"formats":97,"directory":"/etc"},
{"formats":97,"directory":"a/b"},
{"formats":97,"directory":"C:"},
{"formats":97,"directory":"ok_1.21-x"}]}}"#);
assert_eq!(overlays_of(&meta), ["ok_1.21-x"]);
let base = std::env::temp_dir().join(format!("horizon-packs-ov-{}", std::process::id()));
let _ = fs::remove_dir_all(&base);
let vanilla = base.join("vanilla");
+28 -12
View File
@@ -9,7 +9,9 @@ use std::{collections::BTreeSet, fs, io, path::Path, path::PathBuf};
// Bump when overlay/merge semantics change. Markers are disposable cache data.
// Version 4 mirrored every untouched directory file by file on Windows.
// Version 3 could omit untouched vanilla directories on Windows (#1649, #1688).
const VERSION: u32 = 5;
// Version 5 read every vanilla file to stamp it on Windows.
// Version 6 stamps vanilla's files by their metadata on Windows too.
const VERSION: u32 = 6;
#[derive(Default, Serialize, Deserialize)]
pub(super) struct Dependencies {
@@ -18,13 +20,16 @@ pub(super) struct Dependencies {
failed: bool,
#[serde(skip)]
tracking: bool,
/// The vanilla root (absolute): its files are stamped without reading them.
#[serde(skip)]
vanilla: PathBuf,
}
impl Dependencies {
fn record(&mut self, path: &Path, directory: bool) {
if !self.tracking {
return;
}
match dependency_signature(path, directory) {
match dependency_signature(path, directory, &self.vanilla) {
Ok(signature) => self.entries.push((path.into(), directory, signature)),
Err(_) => self.failed = true,
}
@@ -38,7 +43,7 @@ impl Dependencies {
fn valid(&self) -> bool {
!self.failed
&& self.entries.iter().all(|(path, directory, signature)| {
dependency_signature(path, *directory).is_ok_and(|s| s == *signature)
dependency_signature(path, *directory, &self.vanilla).is_ok_and(|s| s == *signature)
})
}
}
@@ -72,13 +77,16 @@ fn content(hash: &mut Sha256, path: &Path) -> io::Result<()> {
}
Ok(())
}
fn stamp(hash: &mut Sha256, path: &Path) -> io::Result<()> {
/// `path`'s stamp; `read` lets platforms without a change timestamp hash a file's bytes.
fn stamp(hash: &mut Sha256, path: &Path, read: bool) -> io::Result<()> {
let meta = fs::metadata(path)?;
hash.update(meta.len().to_le_bytes());
field(hash, format!("{:?}", meta.modified()?).as_bytes());
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
// The change time stands in for the bytes here.
let _ = read;
hash.update(meta.dev().to_le_bytes());
hash.update(meta.ino().to_le_bytes());
hash.update(meta.ctime().to_le_bytes());
@@ -87,8 +95,11 @@ fn stamp(hash: &mut Sha256, path: &Path) -> io::Result<()> {
#[cfg(not(unix))]
{
// Portable metadata lacks a change timestamp. Preserve content-based
// validation for materialized files on those platforms.
if meta.is_file() {
// validation for the packs' materialized files on those platforms;
// vanilla's (the launcher's, tens of thousands) keep to the size and
// the write and creation times: reading each costs minutes on Windows,
// where every open is scanned by the antivirus.
if read && meta.is_file() {
return content(hash, path);
}
if let Ok(created) = meta.created() {
@@ -104,17 +115,18 @@ fn entries(path: &Path) -> io::Result<Vec<PathBuf>> {
entries.sort();
Ok(entries)
}
fn dependency_signature(path: &Path, directory: bool) -> io::Result<String> {
fn dependency_signature(path: &Path, directory: bool, vanilla: &Path) -> io::Result<String> {
let mut hash = Sha256::new();
let read = !(path.starts_with(vanilla) || absolute(path).is_ok_and(|p| p.starts_with(vanilla)));
match fs::metadata(path) {
Err(e) if e.kind() == io::ErrorKind::NotFound => field(&mut hash, b"missing"),
Err(e) => return Err(e),
Ok(meta) if directory && meta.is_dir() => {
field(&mut hash, b"directory");
stamp(&mut hash, path)?;
stamp(&mut hash, path, read)?;
}
Ok(_) if directory => field(&mut hash, b"not-directory"),
Ok(_) => stamp(&mut hash, path)?,
Ok(_) => stamp(&mut hash, path, read)?,
}
Ok(finish(hash))
}
@@ -190,7 +202,7 @@ fn tree_inner(
if pack.is_none() {
return content(hash, path);
}
stamp(hash, path)?;
stamp(hash, path, true)?;
}
}
Ok(())
@@ -283,11 +295,14 @@ pub(super) fn build(vanilla: &Path, packs: &[Pack], out: &Path) -> io::Result<()
let signature = pack_signature(packs);
if let Ok(signature) = &signature
&& let Ok(bytes) = fs::read(&marker_path)
&& let Ok(marker) = serde_json::from_slice::<Marker>(&bytes)
&& let Ok(mut marker) = serde_json::from_slice::<Marker>(&bytes)
&& marker.version == VERSION
&& marker.vanilla == vanilla_key
&& marker.packs == *signature
&& marker.dependencies.valid()
&& {
marker.dependencies.vanilla = vanilla_key.clone();
marker.dependencies.valid()
}
&& out.is_dir()
{
return Ok(());
@@ -300,6 +315,7 @@ pub(super) fn build(vanilla: &Path, packs: &[Pack], out: &Path) -> io::Result<()
}
let mut dependencies = Dependencies {
tracking: signature.is_ok(),
vanilla: vanilla_key.clone(),
..Dependencies::default()
};
build_overlay_uncached(vanilla, packs, out, &mut dependencies)?;
@@ -1064,8 +1064,9 @@ pub struct ParticleEngine {
pub particle_level: u8,
/// FireworkParticles.Starter instances.
starters: Vec<Starter>,
/// Local sounds requested by particles (dripstone drips): (event, position, volume, pitch).
pub sounds: Vec<(&'static str, [f64; 3], f32, f32)>,
/// Local sounds requested by particles (dripstone drips, firework bursts):
/// (event, position, volume, pitch, distanceDelay).
pub sounds: Vec<(&'static str, [f64; 3], f32, f32, bool)>,
/// How many of each type were made, for `horizon-ctl status`.
made: std::collections::BTreeMap<&'static str, u64>,
/// The item particles' sprites (`BreakingItemParticle`'s slime ball, snowball
@@ -3059,7 +3060,7 @@ impl ParticleEngine {
(false, false) => "minecraft:entity.firework_rocket.blast",
};
let pitch = 0.95 + self.random.next_float() * 0.1;
self.sounds.push((sound, s.at, 20.0, pitch));
self.sounds.push((sound, s.at, 20.0, pitch, true));
}
if s.life % 2 == 0 && ((s.life / 2) as usize) < s.explosions.len() {
let e = s.explosions[(s.life / 2) as usize].clone();
@@ -3110,7 +3111,7 @@ impl ParticleEngine {
"minecraft:entity.firework_rocket.twinkle"
};
let pitch = 0.9 + self.random.next_float() * 0.15;
self.sounds.push((sound, s.at, 20.0, pitch));
self.sounds.push((sound, s.at, 20.0, pitch, true));
}
continue;
}
@@ -3305,7 +3306,7 @@ impl ParticleEngine {
}
if let Some(sound) = sound {
let volume = p.random.next_float() * 0.7 + 0.3;
self.sounds.push((sound, p.position, volume, 1.0));
self.sounds.push((sound, p.position, volume, 1.0, false));
}
}
_ => {}
File diff suppressed because it is too large. Load diff
@@ -1,8 +1,9 @@
// The built-in stand-ins for the vanilla post shaders a `post_effect` chain can
// name (`core/screenquad`, `post/rotscale`, `post/invert`, `post/blit`,
// `post/bits`, `post/color_convolve`, `post/box_blur`, `post/spiderclip`).
// Coordinates stay in the chain's own convention (v up from the bottom edge) and
// are flipped only where a texture is read.
// Targets keep GL's rows (row 0 is the bottom edge): the passes draw with clip y
// negated (`vs_quad_gl`, `vs_rotscale`) and read their textures as they are;
// `vs_quad` with `fs_blit` is the flip between the frame and a chain's `main`.
// OutSize, InSize | BlurSize, menu blur radius.
@group(0) @binding(0) var<uniform> info: array<vec4<f32>, 2>;
@@ -27,10 +28,10 @@ fn fcf(i: u32) -> f32 {
return fs_cfg[i / 4u][i % 4u];
}
fn read_in(uv: vec2<f32>) -> vec4<f32> {
return textureSampleLevel(in_tex, in_smp, vec2(uv.x, 1.0 - uv.y), 0.0);
return textureSampleLevel(in_tex, in_smp, uv, 0.0);
}
fn read_blur(uv: vec2<f32>) -> vec4<f32> {
return textureSampleLevel(blur_tex, blur_smp, vec2(uv.x, 1.0 - uv.y), 0.0);
return textureSampleLevel(blur_tex, blur_smp, uv, 0.0);
}
fn corner(i: u32) -> vec2<f32> {
@@ -46,10 +47,19 @@ fn corner(i: u32) -> vec2<f32> {
return out;
}
@vertex fn vs_quad_gl(@builtin(vertex_index) i: u32) -> Out {
let p = corner(i);
var out: Out;
out.clip = vec4(p.x * 2.0 - 1.0, 1.0 - p.y * 2.0, 0.0, 1.0);
out.uv = p;
out.scaled = p;
return out;
}
@vertex fn vs_rotscale(@builtin(vertex_index) i: u32) -> Out {
let p = corner(i);
var out: Out;
out.clip = vec4(p * 2.0 - 1.0, 0.0, 1.0);
out.clip = vec4(p.x * 2.0 - 1.0, 1.0 - p.y * 2.0, 0.0, 1.0);
out.uv = p;
let radians = vcf(4u) * 0.0174532925;
let c = cos(radians);
@@ -0,0 +1,316 @@
//! Vanilla's post-processing GLSL (`#version 330` with `layout(std140)` uniform blocks
//! and `uniform sampler2D`s, Vulkan-style `gl_VertexIndex`) turned into naga modules at
//! run time, from whichever resource root is active, the way Horizon's shader packs turn
//! their GLSL into naga (`horizon-shaders`' translator, which handles the compatibility
//! dialect; the post shaders need only the block and sampler binding rewrite below).
//!
//! `ShaderManager`/`GlslPreprocessor` read `assets/<ns>/shaders/<path>.{vsh,fsh}` and
//! resolve `#include <ns:path>` to `assets/<ns>/shaders/include/<path>`.
//!
//! The rewrite gives every uniform block a binding of bind group 0, shared by both
//! stages, and splits each `uniform sampler2D NameSampler;` into a texture and a sampler
//! object at the next two bindings, `NameSampler` becoming the combined
//! `sampler2D(texture, sampler)` again where it is used. The vertex stage's `main` is
//! wrapped so that clip y is negated: targets keep GL's rows (row 0 is the bottom edge),
//! so `texCoord` reads the texel it would in vanilla.
use naga::ShaderStage;
use naga::front::glsl::{Frontend, Options};
use naga::valid::{Capabilities, ValidationFlags, Validator};
use std::path::Path;
/// One program, ready for wgpu.
pub struct Program {
pub vertex: naga::Module,
pub fragment: naga::Module,
/// Every uniform block either stage declares, with its binding.
pub blocks: Vec<(String, u32)>,
/// Every sampler (`NameSampler`): the texture's binding; the sampler object is the next.
pub samplers: Vec<(String, u32)>,
}
fn is_ident(c: u8) -> bool {
c.is_ascii_alphanumeric() || c == b'_'
}
/// The source with its comments blanked (same length, so offsets stay valid).
fn blank_comments(src: &str) -> String {
let b = src.as_bytes();
let mut out = b.to_vec();
let mut i = 0;
while i < b.len() {
if b[i] == b'/' && b.get(i + 1) == Some(&b'/') {
while i < b.len() && b[i] != b'\n' {
out[i] = b' ';
i += 1;
}
} else if b[i] == b'/' && b.get(i + 1) == Some(&b'*') {
while i < b.len() && !(b[i] == b'*' && b.get(i + 1) == Some(&b'/')) {
if b[i] != b'\n' {
out[i] = b' ';
}
i += 1;
}
for slot in out.iter_mut().take((i + 2).min(b.len())).skip(i) {
*slot = b' ';
}
i += 2;
} else {
i += 1;
}
}
String::from_utf8(out).unwrap_or_default()
}
/// `GlslPreprocessor`'s `#include <ns:path>`.
fn read_source(root: &Path, id: &str, ext: &str) -> Result<String, String> {
let (ns, path) = id.split_once(':').unwrap_or(("minecraft", id));
let file = root
.join("assets")
.join(ns)
.join("shaders")
.join(format!("{path}.{ext}"));
std::fs::read_to_string(&file).map_err(|e| format!("{}: {e}", file.display()))
}
fn include(root: &Path, src: &str, depth: u32) -> Result<String, String> {
if depth > 8 {
return Err("#include nested too deep".into());
}
let mut out = String::new();
for line in src.lines() {
let t = line.trim_start();
if let Some(rest) = t.strip_prefix("#include") {
let name = rest
.trim()
.trim_start_matches(['<', '"'])
.trim_end_matches(['>', '"'])
.trim();
let (ns, path) = name.split_once(':').unwrap_or(("minecraft", name));
let file = root
.join("assets")
.join(ns)
.join("shaders")
.join("include")
.join(path);
let text = std::fs::read_to_string(&file)
.map_err(|e| format!("#include <{name}>: {}: {e}", file.display()))?;
out.push_str(&include(root, &text, depth + 1)?);
} else if t.starts_with("#version") || t.starts_with("#extension") {
// Re-added below, for the translator.
out.push('\n');
continue;
} else {
out.push_str(line);
}
out.push('\n');
}
Ok(out)
}
/// Every `uniform` declaration of a source: (start of the declaration including a
/// `layout(...)` before it, end of the type or of the whole `;`, type, name, is a sampler).
fn declarations(src: &str) -> Vec<(usize, usize, String, String, bool)> {
let plain = blank_comments(src);
let b = plain.as_bytes();
let mut out = Vec::new();
let mut at = 0;
while let Some(found) = plain[at..].find("uniform") {
let start = at + found;
at = start + 7;
if (start > 0 && is_ident(b[start - 1])) || b.get(start + 7).is_some_and(|c| is_ident(*c)) {
continue;
}
let word = |from: usize| -> Option<(usize, usize)> {
let mut s = from;
while s < b.len() && b[s].is_ascii_whitespace() {
s += 1;
}
let mut e = s;
while e < b.len() && is_ident(b[e]) {
e += 1;
}
(e > s).then_some((s, e))
};
let Some((ts, te)) = word(start + 7) else {
continue;
};
let ty = plain[ts..te].to_owned();
// The `layout(...)` right before `uniform`, if any.
let before = plain[..start].trim_end();
let decl_start = before
.ends_with(')')
.then(|| before.rfind("layout"))
.flatten()
.filter(|l| {
before[*l + 6..].trim_start().starts_with('(') && !before[*l..].contains(';')
})
.unwrap_or(start);
if ty.starts_with("sampler") {
let Some((ns, ne)) = word(te) else { continue };
let end = plain[ne..].find(';').map_or(ne, |p| ne + p + 1);
out.push((decl_start, end, ty, plain[ns..ne].to_owned(), true));
} else {
let mut s = te;
while s < b.len() && b[s].is_ascii_whitespace() {
s += 1;
}
if b.get(s) == Some(&b'{') {
out.push((decl_start, te, String::new(), ty, false));
}
}
}
out
}
/// The source with each declaration given its binding from the shared tables.
fn rewrite(src: &str, blocks: &[(String, u32)], samplers: &[(String, u32)]) -> String {
let mut out = String::new();
let mut pos = 0;
for (start, end, ty, name, sampler) in declarations(src) {
out.push_str(&src[pos..start]);
pos = end;
if sampler {
let tex = samplers.iter().find(|(n, _)| *n == name).map_or(0, |s| s.1);
let kind = if ty == "sampler2D" { "texture2D" } else { &ty };
out.push_str(&format!(
"layout(set = 0, binding = {tex}) uniform {kind} {name}_hz_tex; \
layout(set = 0, binding = {}) uniform sampler {name}_hz_smp;\n\
#define {name} sampler2D({name}_hz_tex, {name}_hz_smp)\n",
tex + 1
));
} else {
let binding = blocks.iter().find(|(n, _)| *n == name).map_or(0, |s| s.1);
out.push_str(&format!(
"layout(std140, set = 0, binding = {binding}) uniform {name}"
));
}
}
out.push_str(&src[pos..]);
out
}
/// Replaces the word `main` of `void main(` with `hz_main`.
fn wrap_main(src: &str) -> Result<String, String> {
let plain = blank_comments(src);
let mut at = 0;
while let Some(found) = plain[at..].find("main") {
let p = at + found;
at = p + 4;
let b = plain.as_bytes();
if (p > 0 && is_ident(b[p - 1])) || b.get(p + 4).is_some_and(|c| is_ident(*c)) {
continue;
}
if plain[..p].trim_end().ends_with("void") && plain[p + 4..].trim_start().starts_with('(') {
let mut out = String::with_capacity(src.len() + 80);
out.push_str(&src[..p]);
out.push_str("hz_main");
out.push_str(&src[p + 4..]);
out.push_str("\nvoid main() { hz_main(); gl_Position.y = -gl_Position.y; }\n");
return Ok(out);
}
}
Err("no main".into())
}
fn parse(stage: ShaderStage, text: &str) -> Result<naga::Module, String> {
let module = Frontend::default()
.parse(&Options::from(stage), text)
.map_err(|e| {
e.errors
.iter()
.map(|e| e.kind.to_string())
.collect::<Vec<_>>()
.join("; ")
})?;
Validator::new(ValidationFlags::all(), Capabilities::empty())
.validate(&module)
.map_err(|e| format!("{:?}", e.into_inner()))?;
Ok(module)
}
/// Translates the vertex and fragment shader a pass names (`minecraft:post/invert`).
pub fn translate(root: &Path, vertex: &str, fragment: &str) -> Result<Program, String> {
let vs = include(root, &read_source(root, vertex, "vsh")?, 0)?;
let fs = include(root, &read_source(root, fragment, "fsh")?, 0)?;
// Blocks and samplers are shared by name across the stages, so both agree on a binding.
let mut blocks: Vec<(String, u32)> = Vec::new();
let mut names: Vec<String> = Vec::new();
for src in [&vs, &fs] {
for (_, _, _, name, sampler) in declarations(src) {
if sampler {
if !names.contains(&name) {
names.push(name);
}
} else if !blocks.iter().any(|(n, _)| *n == name) {
blocks.push((name, blocks.len() as u32));
}
}
}
let first = blocks.len() as u32;
let samplers: Vec<(String, u32)> = names.into_iter().zip((first..).step_by(2)).collect();
let vs = wrap_main(&rewrite(&vs, &blocks, &samplers))?;
let fs = rewrite(&fs, &blocks, &samplers);
let vertex_module = parse(ShaderStage::Vertex, &format!("#version 450\n{vs}"))
.map_err(|e| format!("{vertex}: {e}"))?;
let fragment_module = parse(ShaderStage::Fragment, &format!("#version 450\n{fs}"))
.map_err(|e| format!("{fragment}: {e}"))?;
Ok(Program {
vertex: vertex_module,
fragment: fragment_module,
blocks,
samplers,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn root() -> Option<std::path::PathBuf> {
let dir = Path::new(env!("CARGO_MANIFEST_DIR"))
.ancestors()
.nth(2)?
.join(".local/horizon-client/26.3");
dir.join("assets/minecraft/shaders/post/blit.fsh")
.exists()
.then_some(dir)
}
#[test]
fn vanilla_post_shaders_translate() {
let Some(root) = root() else { return };
let vs = ["minecraft:core/screenquad", "minecraft:post/rotscale"];
let fs = [
"bits",
"blit",
"box_blur",
"color_convolve",
"entity_outline_box_blur",
"entity_sobel",
"invert",
"spiderclip",
];
for f in fs {
for v in vs {
let name = format!("minecraft:post/{f}");
let p = translate(&root, v, &name);
// rotscale's outputs only match spiderclip, but each stage translates alone.
if let Err(e) = &p {
panic!("{v} + {name}: {e}");
}
}
}
}
#[test]
fn sampler_and_block_bindings() {
let src = "layout(std140) uniform A {\n float x;\n};\nuniform sampler2D InSampler; // c\nvoid main(){}\n";
let d = declarations(src);
assert_eq!(d.len(), 2);
let out = rewrite(src, &[("A".into(), 0)], &[("InSampler".into(), 1)]);
assert!(out.contains("layout(std140, set = 0, binding = 0) uniform A {"));
assert!(out.contains("binding = 1) uniform texture2D InSampler_hz_tex;"));
assert!(out.contains("binding = 2) uniform sampler InSampler_hz_smp;"));
assert!(wrap_main("void main() {}").unwrap().contains("hz_main"));
}
}
+304
View File
@@ -0,0 +1,304 @@
//! Input method composition text (`PreeditEvent`, `IMEPreeditOverlay`,
//! `TextInputManager`): the text an input method is still composing, shown
//! under the focused text field's caret, and the area the system's candidate
//! window is placed at.
//!
//! Vanilla hands each `PreeditEvent` to the focused widget, which keeps an
//! overlay and reports its caret every frame. Horizon draws its fields from
//! many places, so the event is kept here and every focused field calls
//! [`report_caret`] where it draws its caret; the game draws the overlay last
//! with [`draw_overlay`] and applies [`take_text_input_area`] to the window.
use crate::{
hud::{HudAtlas, HudBatch},
text::{Style, Text},
ui::nine_slice,
};
use std::{sync::Mutex, time::Instant};
/// `PreeditEvent`: the composition, its caret and the blocks of text the
/// input method set apart (the one being converted is `focused_block`).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PreeditEvent {
pub full_text: String,
/// Byte offset of the caret in `full_text` (vanilla's UTF-16 offset).
pub caret_position: usize,
pub blocks: Vec<String>,
pub focused_block: usize,
}
impl PreeditEvent {
/// `PreeditEvent.fromSdlTextEditing`: `selection_start` and
/// `selection_length` are in code points; a negative start puts the caret
/// at the end. Empty text is no composition.
pub fn from_sdl_text_editing(
text: Option<&str>,
selection_start: i32,
selection_length: i32,
) -> Option<Self> {
let text = text.filter(|t| !t.is_empty())?;
let total = text.chars().count();
let caret_codepoint = if selection_start < 0 {
total
} else {
(selection_start as usize).min(total)
};
let caret_char = offset_by_codepoints(text, caret_codepoint);
let single = |caret| Self {
full_text: text.to_owned(),
caret_position: caret,
blocks: vec![text.to_owned()],
focused_block: 0,
};
if selection_length <= 0 {
return Some(single(caret_char));
}
let end_codepoint = (caret_codepoint + selection_length as usize).min(total);
let end_char = offset_by_codepoints(text, end_codepoint);
if end_char <= caret_char {
return Some(single(caret_char));
}
let mut blocks = Vec::new();
let mut focused_block = 0;
if caret_char > 0 {
blocks.push(text[..caret_char].to_owned());
focused_block = 1;
}
blocks.push(text[caret_char..end_char].to_owned());
if end_char < text.len() {
blocks.push(text[end_char..].to_owned());
}
Some(Self {
full_text: text.to_owned(),
caret_position: caret_char,
blocks,
focused_block,
})
}
/// winit's `Ime::Preedit`: `cursor` is the byte range of the part being
/// converted, `None` when the input method hides its cursor. SDL's
/// `TEXTEDITING` says the same in code points.
pub fn from_winit(text: &str, cursor: Option<(usize, usize)>) -> Option<Self> {
let (start, length) = match cursor {
Some((from, to)) => {
let from = from.min(text.len());
let to = to.clamp(from, text.len());
let start = text.get(..from).map_or(-1, |s| s.chars().count() as i32);
let length = text.get(from..to).map_or(0, |s| s.chars().count() as i32);
(start, length)
}
None => (-1, 0),
};
Self::from_sdl_text_editing(Some(text), start, length)
}
/// `PreeditEvent.toFormattedText`.
pub fn to_formatted_text(&self, focused_style: &Style) -> Text {
if self.blocks.len() == 1 {
return Text::literal(self.blocks[0].clone()).with_style(focused_style);
}
let mut result = Text::empty();
for (i, block) in self.blocks.iter().enumerate() {
let mut part = Text::literal(block.clone());
if i == self.focused_block {
part = part.with_style(focused_style);
}
result.push(part);
}
result
}
}
/// `String.offsetByCodePoints(0, n)`: the byte offset after `n` characters.
fn offset_by_codepoints(text: &str, n: usize) -> usize {
text.char_indices().nth(n).map_or(text.len(), |(i, _)| i)
}
/// The caret a field reported this frame, in GUI pixels.
#[derive(Clone, Copy, Debug, PartialEq)]
struct Caret {
x: f32,
y: f32,
input_height: f32,
}
#[derive(Default)]
struct State {
/// `KeyboardHandler.lastPreeditEvent`, with when the overlay was created.
preedit: Option<(PreeditEvent, Instant)>,
caret: Option<Caret>,
area: Option<[f32; 4]>,
}
static STATE: Mutex<State> = Mutex::new(State {
preedit: None,
caret: None,
area: None,
});
fn state() -> std::sync::MutexGuard<'static, State> {
STATE.lock().unwrap_or_else(|e| e.into_inner())
}
/// `KeyboardHandler.textEditing`: the input method's composition changed
/// (`None`: it ended). A new `IMEPreeditOverlay` restarts the caret blink.
pub fn set_preedit(event: Option<PreeditEvent>) {
let mut s = state();
s.preedit = event.map(|e| (e, Instant::now()));
}
/// Whether text is being composed.
pub fn composing() -> bool {
state().preedit.is_some()
}
/// What the focused field's `extractRenderState` ends with: its caret is at
/// (`x`, `y`) in GUI pixels and `input_height` tall (a line plus one).
/// Without a composition that is the area for the candidate window; with one
/// it is where the overlay goes ([`draw_overlay`]).
pub fn report_caret(x: f32, y: f32, input_height: f32) {
state().caret = Some(Caret { x, y, input_height });
}
/// `IMEPreeditOverlay.extractRenderState` for the caret reported this frame,
/// drawn on top of everything in `batch`. Without a composition the caret is
/// the text input area (`TextInputManager.setTextInputArea`).
pub fn draw_overlay(batch: &mut HudBatch, atlas: &HudAtlas) {
let mut s = state();
let Some(caret) = s.caret.take() else {
return;
};
let Some((event, init)) = s.preedit.as_ref() else {
s.area = Some([
caret.x,
caret.y,
caret.x + 1.0,
caret.y + caret.input_height,
]);
return;
};
let font = &atlas.font;
let focused = Style {
underlined: Some(true),
..Style::default()
};
let black = 0xFF00_0000;
let text = event.to_formatted_text(&focused).with_color(black);
let width = font.width_text(&text);
let caret_pos = font.width(&event.full_text[..event.caret_position]);
let blink_visible = (init.elapsed().as_millis() / 300).is_multiple_of(2);
let line = 9.0;
let (gui_w, gui_h) = (batch.width, batch.height);
let mut left = caret.x;
let mut right = left + width;
if right > gui_w {
left = gui_w - width;
right = left + width;
}
let input_bottom = caret.y + caret.input_height;
let mut bottom = input_bottom + 4.0 + line;
if bottom > gui_h {
bottom = caret.y - 4.0 - line;
}
let top = bottom - line;
s.area = Some([left, top, right, bottom]);
drop(s);
nine_slice(
batch,
atlas,
"gui/sprites/widget/preedit",
left - 5.0,
top - 5.0,
right - left + 10.0,
bottom - top + 10.0,
u32::MAX,
);
batch.text_styled(atlas, &text, left, top, black, false);
if blink_visible {
batch.fill(atlas, left + caret_pos, top - 1.0, 1.0, 11.0, black);
}
}
/// The text input area (`x0, y0, x1, y1` in GUI pixels) set since the last
/// call, for `Window.set_ime_cursor_area`.
pub fn take_text_input_area() -> Option<[f32; 4]> {
state().area.take()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_text_is_no_composition() {
assert_eq!(PreeditEvent::from_sdl_text_editing(None, 0, 0), None);
assert_eq!(PreeditEvent::from_sdl_text_editing(Some(""), 0, 0), None);
}
#[test]
fn a_negative_start_puts_the_caret_at_the_end() {
let e = PreeditEvent::from_sdl_text_editing(Some("abc"), -1, 0).unwrap();
assert_eq!(
(e.caret_position, e.blocks, e.focused_block),
(3, vec!["abc".into()], 0)
);
}
#[test]
fn the_caret_counts_code_points() {
// Two code points before the caret, the second outside the BMP.
let e = PreeditEvent::from_sdl_text_editing(Some("a\u{1F600}b"), 2, 0).unwrap();
assert_eq!(e.caret_position, "a\u{1F600}".len());
let past = PreeditEvent::from_sdl_text_editing(Some("ab"), 9, 0).unwrap();
assert_eq!(past.caret_position, 2);
}
#[test]
fn a_selection_splits_the_text_into_blocks() {
let e = PreeditEvent::from_sdl_text_editing(Some("abcdef"), 2, 2).unwrap();
assert_eq!(e.blocks, ["ab", "cd", "ef"]);
assert_eq!((e.focused_block, e.caret_position), (1, 2));
let first = PreeditEvent::from_sdl_text_editing(Some("abcdef"), 0, 3).unwrap();
assert_eq!(first.blocks, ["abc", "def"]);
assert_eq!(first.focused_block, 0);
let last = PreeditEvent::from_sdl_text_editing(Some("abcdef"), 3, 99).unwrap();
assert_eq!(last.blocks, ["abc", "def"]);
assert_eq!(last.focused_block, 1);
}
#[test]
fn winit_byte_ranges_become_code_points() {
let text = "\u{3042}\u{3044}\u{3046}";
// Converting the middle character (bytes 3..6).
let e = PreeditEvent::from_winit(text, Some((3, 6))).unwrap();
assert_eq!(e.blocks, ["\u{3042}", "\u{3044}", "\u{3046}"]);
assert_eq!(e.focused_block, 1);
// A hidden cursor sits at the end.
let hidden = PreeditEvent::from_winit(text, None).unwrap();
assert_eq!(hidden.caret_position, text.len());
assert_eq!(hidden.blocks.len(), 1);
}
#[test]
fn only_the_focused_block_is_underlined() {
let focused = Style {
underlined: Some(true),
..Style::default()
};
let e = PreeditEvent::from_sdl_text_editing(Some("abcdef"), 2, 2).unwrap();
let styled: Vec<(char, bool)> = e
.to_formatted_text(&focused)
.chars()
.map(|(c, s)| (c, s.underlined == Some(true)))
.collect();
let underlined: String = styled.iter().filter(|x| x.1).map(|x| x.0).collect();
assert_eq!(underlined, "cd");
assert_eq!(styled.len(), 6);
let one = PreeditEvent::from_sdl_text_editing(Some("ab"), 0, 0).unwrap();
assert!(
one.to_formatted_text(&focused)
.chars()
.all(|(_, s)| s.underlined == Some(true))
);
}
}
@@ -5,7 +5,7 @@
//! dimension type allows.
/// Section indexes of one column, 0 being the lowest.
#[derive(Clone, Copy, Default, PartialEq, Eq, Hash)]
#[derive(Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct SectionMask([u64; 4]);
impl SectionMask {
@@ -497,10 +497,13 @@ impl Settings {
/// `Options.createInformation`: what the server learns about this player.
pub fn client_information(&self, view_distance: i8) -> ClientInformation {
ClientInformation {
language: self.language.clone(),
chat_visibility: self.chat_visibility as i32,
chat_colors: self.chat_colors,
model_customisation: self.file.model_customisation(),
main_hand: i32::from(!self.main_hand_left),
allows_listing: self.file.bool("allowServerListing"),
particle_status: i32::from(self.particles),
..ClientInformation::new(view_distance)
}
}
+162 -5
View File
@@ -958,6 +958,21 @@ impl Streaming {
}
Some(Box::new(region))
}
/// Whether `light_region` would find all nine columns: the same walk
/// without building the snapshot, so the scheduler can rank the ready
/// columns before any region is cloned. A proto neighbour whose light
/// view turns out empty is skipped when its region is built, and asked
/// for again, as `light_region` returning `None` always was.
fn light_region_ready(&self, world: &World, x: i32, z: i32) -> bool {
light_region_ready(
world,
|x, z| self.in_range(x, z, 1),
&self.decorated,
&self.protos,
x,
z,
)
}
fn region(world: &World, x: i32, z: i32) -> Option<Box<Region>> {
let mut region: Region = Default::default();
for dz in 0..3 {
@@ -1357,13 +1372,29 @@ impl Streaming {
}
}
self.schedule_generation(world, workers);
// Lighting once all neighbours exist.
let light: Vec<_> = world.dirty_light.iter().copied().collect();
for (x, z) in light {
// Lighting once all neighbours exist, nearest first and only as
// much of it as the workers can chew. Vanilla's light engine also
// hands a bounded queue to its workers and reschedules what did
// not fit by distance; submitted whole, the light jobs of every
// unlit column at once — each carrying a 3x3 region snapshot —
// grew with the render distance and outlived the columns they
// were queued for (#3629). What stays in `dirty_light` is asked
// for again, from the current centre, as capacity frees.
let mut light: Vec<(i64, i32, i32)> = Vec::new();
let mut gone: Vec<(i32, i32)> = Vec::new();
for &(x, z) in world.dirty_light.iter() {
if !world.chunks.contains_key(&(x, z)) {
world.dirty_light.remove(&(x, z));
continue;
gone.push((x, z));
} else if self.light_region_ready(world, x, z) {
light.push((self.distance(x, z), x, z));
}
}
for key in gone {
world.dirty_light.remove(&key);
}
let room = (self.threads.len() * 4).saturating_sub(self.lighting.len());
nearest_first(&mut light, room);
for (_, x, z) in light {
let Some(region) = self.light_region(world, x, z) else {
continue;
};
@@ -1409,6 +1440,7 @@ impl Streaming {
}
// Column by column: the checks below hold for all of a column's
// sections, and columns waiting for light cost one look per frame.
let mut candidates: Vec<(i64, i32, i32, SectionMask)> = Vec::new();
let columns: Vec<_> = world.dirty_sections.columns().collect();
for ((x, z), sections) in columns {
if !world.chunks.contains_key(&(x, z)) {
@@ -1437,6 +1469,18 @@ impl Streaming {
if !ready {
continue;
}
candidates.push((self.distance(x, z), x, z, sections));
}
// Nearest first, and only as many sections as the workers can chew.
// Every ready column used to be submitted at once, so at a wide
// render distance the queue — and `meshing`, and each job's 3x3
// region snapshot — grew with the number of loaded columns and the
// jobs went stale long before a worker reached them (#3629). What
// stays dirty is asked for again, from the current centre, as
// capacity frees; `requeue` then has a small queue to keep close.
let room = (self.threads.len() * 32).saturating_sub(self.meshing.len());
nearest_first(&mut candidates, room);
for (_, x, z, sections) in candidates {
let Some(region) = Self::region(world, x, z) else {
continue;
};
@@ -2051,6 +2095,46 @@ fn rank_again(queue: &mut BinaryHeap<Queued>, distance: impl Fn(i32, i32) -> i64
}
*queue = BinaryHeap::from(jobs);
}
/// Whether the 3x3 a light job for (x, z) reads is all there: every
/// neighbour a finished column, or a decorated proto chunk past the area
/// (the last FULL ring lights against neighbours at FEATURES, as
/// `Streaming::light_region` reads them).
fn light_region_ready(
world: &World,
in_range: impl Fn(i32, i32) -> bool,
decorated: &FxHashSet<(i32, i32)>,
protos: &FxHashMap<(i32, i32), Option<Proto>>,
x: i32,
z: i32,
) -> bool {
for dz in 0..3 {
for dx in 0..3 {
let n = (x + dx - 1, z + dz - 1);
if world.chunks.contains_key(&n)
|| (!in_range(n.0, n.1)
&& decorated.contains(&n)
&& matches!(protos.get(&n), Some(Some(_))))
{
continue;
}
return false;
}
}
true
}
/// The nearest `room` of the `candidates`, ordered: the light and mesh
/// stages submit bounded work, chosen from everything that is ready each
/// `update`, so what runs is always the nearest of what is left — chosen
/// again, from the current centre, as capacity frees, the way vanilla's
/// chunk task queues follow the current ticket levels. The selection is
/// O(n); the sort orders only what is submitted.
fn nearest_first<T: Ord>(candidates: &mut Vec<T>, room: usize) {
if candidates.len() > room {
candidates.select_nth_unstable(room);
candidates.truncate(room);
}
candidates.sort_unstable();
}
#[cfg(test)]
mod tests {
use super::*;
@@ -2140,6 +2224,79 @@ mod tests {
assert_eq!(queue.pop().and_then(|q| q.at), Some((20, 0)));
}
/// The light and mesh stages submit bounded work, the nearest first:
/// what exceeds the room stays dirty, to be chosen again — from the
/// current centre — as capacity frees. Submitted whole, the queue once
/// held every ready column's jobs and grew with the render distance
/// (#3629); ties break by position, so the choice is deterministic.
#[test]
fn bounded_submission_takes_the_nearest_first() {
let candidates: Vec<(i64, i32, i32, u32)> = (0..40i32)
.map(|i| {
let (x, z) = (i / 8 - 2, i % 8 - 3);
(
i64::from((x - 3).pow(2) + (z - 2).pow(2)),
x,
z,
1 << (i % 24),
)
})
.collect();
let mut expected = candidates.clone();
expected.sort_unstable();
let mut none = candidates.clone();
nearest_first(&mut none, 0);
assert!(none.is_empty());
let mut nearest = candidates;
nearest_first(&mut nearest, 5);
assert_eq!(nearest, expected[..5]);
let mut past = expected.clone();
nearest_first(&mut past, expected.len() + 10);
assert_eq!(past, expected);
}
/// What the scheduler counts as ready to light: every neighbour a
/// finished column, or a decorated proto chunk past the area — the
/// walk `light_region` makes without its snapshot.
#[test]
fn light_readiness_follows_the_region_a_light_job_reads() {
let mut world = World::new(horizon_world::registry::BlockStateId(0), 0);
let chunk = Arc::new(Chunk::empty(0, 0));
let nine = (-1..=1).flat_map(|dz| (-1..=1).map(move |dx| (dx, dz)));
for key in nine {
world.chunks.insert(key, chunk.clone());
}
// The kept area is the cross around (0, 0); the corners of the
// 3x3 light reads lie past it.
let in_range = |x: i32, z: i32| x.abs() + z.abs() <= 1;
let none = FxHashSet::<(i32, i32)>::default();
let empty = FxHashMap::<(i32, i32), Option<Proto>>::default();
assert!(light_region_ready(&world, in_range, &none, &empty, 0, 0));
// A corner is missing, and nothing stands in for it yet.
world.chunks.remove(&(1, 1));
assert!(!light_region_ready(&world, in_range, &none, &empty, 0, 0));
// A decorated, checked-in proto chunk past the area lights against.
let decorated: FxHashSet<(i32, i32)> = [(1, 1)].into_iter().collect();
let mut protos: FxHashMap<(i32, i32), Option<Proto>> = Default::default();
protos.insert((1, 1), Some(Box::new(()) as Proto));
assert!(light_region_ready(
&world, in_range, &decorated, &protos, 0, 0
));
// The proto is checked out to a job: it cannot be read.
protos.insert((1, 1), None);
assert!(!light_region_ready(
&world, in_range, &decorated, &protos, 0, 0
));
// A neighbour inside the area waits for the chunk itself.
world.chunks.remove(&(1, 0));
let mut inside = decorated.clone();
inside.insert((1, 0));
protos.insert((1, 0), Some(Box::new(()) as Proto));
assert!(!light_region_ready(
&world, in_range, &inside, &protos, 0, 0
));
}
#[test]
fn view_distance_is_vanillas_shape() {
// ChunkTrackingView.isInViewDistance at render distance 12: a column
+80 -7
View File
@@ -27,6 +27,9 @@ struct Globals {
// The sunrise fan's 16 triangles (gfx/sunrise_fan.rs): xyz the apex weight's normal, w the
// triangle's volume with the camera; then each rim vertex's normal against the apex.
sunrise_fan: array<vec4<f32>, 32>,
// x: Options.textureFiltering (0 none, 1 RGSS, 2 anisotropic), y: the sprite arrays' width
// in texels (vanilla's TextureSize).
filtering: vec4<f32>,
}
@group(0) @binding(0) var<uniform> globals: Globals;
@group(0) @binding(1) var sprites: texture_2d_array<f32>;
@@ -45,14 +48,12 @@ struct Globals {
// which then samples exactly as before.
override SPRITE_ARRAYS: u32 = 1u;
override SPRITE_SPLIT: u32 = 2048u;
fn sprite(uv: vec2<f32>, layer: u32) -> vec4<f32> {
if SPRITE_ARRAYS == 1u {
return textureSample(sprites, sprite_sampler, uv, i32(layer));
}
// The array is chosen per fragment: sample with the derivatives taken here.
let dx = dpdx(uv);
let dy = dpdy(uv);
// A sprite sampled with the given gradients, from whichever array holds its layer.
fn sprite_grad(uv: vec2<f32>, layer: u32, dx: vec2<f32>, dy: vec2<f32>) -> vec4<f32> {
let l = i32(layer % SPRITE_SPLIT);
if SPRITE_ARRAYS == 1u {
return textureSampleGrad(sprites, sprite_sampler, uv, l, dx, dy);
}
switch layer / SPRITE_SPLIT {
case 0u: { return textureSampleGrad(sprites, sprite_sampler, uv, l, dx, dy); }
case 1u: { return textureSampleGrad(sprites_1, sprite_sampler, uv, l, dx, dy); }
@@ -64,6 +65,78 @@ fn sprite(uv: vec2<f32>, layer: u32) -> vec4<f32> {
default: { return textureSampleGrad(sprites_7, sprite_sampler, uv, l, dx, dy); }
}
}
// The same at an explicit mip level.
fn sprite_level(uv: vec2<f32>, layer: u32, lod: f32) -> vec4<f32> {
let l = i32(layer % SPRITE_SPLIT);
if SPRITE_ARRAYS == 1u {
return textureSampleLevel(sprites, sprite_sampler, uv, l, lod);
}
switch layer / SPRITE_SPLIT {
case 0u: { return textureSampleLevel(sprites, sprite_sampler, uv, l, lod); }
case 1u: { return textureSampleLevel(sprites_1, sprite_sampler, uv, l, lod); }
case 2u: { return textureSampleLevel(sprites_2, sprite_sampler, uv, l, lod); }
case 3u: { return textureSampleLevel(sprites_3, sprite_sampler, uv, l, lod); }
case 4u: { return textureSampleLevel(sprites_4, sprite_sampler, uv, l, lod); }
case 5u: { return textureSampleLevel(sprites_5, sprite_sampler, uv, l, lod); }
case 6u: { return textureSampleLevel(sprites_6, sprite_sampler, uv, l, lod); }
default: { return textureSampleLevel(sprites_7, sprite_sampler, uv, l, lod); }
}
}
// texture_sampling.glsl's sampleNearest: the sprite's texels stay square pixels, blending only
// across the one screen pixel where two meet, so a linear (and anisotropic) sampler keeps them crisp.
fn sample_nearest(uv: vec2<f32>, layer: u32, pixel: vec2<f32>, du: vec2<f32>, dv: vec2<f32>,
texel_screen: vec2<f32>) -> vec4<f32> {
let texel_coords = uv / pixel;
let center = round(texel_coords) - 0.5;
var offset = texel_coords - center;
offset = (offset - 0.5) * pixel / max(texel_screen, vec2(1e-12)) + 0.5;
offset = clamp(offset, vec2(0.0), vec2(1.0));
return sprite_grad((center + offset) * pixel, layer, du, dv);
}
// sampleRGSS: four rotated-grid samples of the two nearest mip levels, faded in as the texels
// shrink below two screen pixels, over sample_nearest.
fn sample_rgss(uv: vec2<f32>, layer: u32, pixel: vec2<f32>, du: vec2<f32>, dv: vec2<f32>,
texel_screen: vec2<f32>) -> vec4<f32> {
let max_texel = max(texel_screen.x, texel_screen.y);
let min_pixel = min(pixel.x, pixel.y);
let blend = smoothstep(min_pixel, min_pixel * 2.0, max_texel);
let du_len = length(du);
let dv_len = length(dv);
let effective = sqrt(min(du_len, dv_len) * max(du_len, dv_len));
let exact = max(0.0, log2(effective / min_pixel));
let low = floor(exact);
let mip_blend = fract(exact);
var offsets = array<vec2<f32>, 4>(
vec2(0.125, 0.375), vec2(-0.125, -0.375), vec2(0.375, -0.125), vec2(-0.375, 0.125));
var sum_low = vec4(0.0);
var sum_high = vec4(0.0);
for (var i = 0; i < 4; i++) {
let p = uv + offsets[i] * pixel;
sum_low += sprite_level(p, layer, low);
sum_high += sprite_level(p, layer, low + 1.0);
}
let rgss = mix(sum_low * 0.25, sum_high * 0.25, mip_blend);
return mix(sample_nearest(uv, layer, pixel, du, dv, texel_screen), rgss, blend);
}
fn sprite(uv: vec2<f32>, layer: u32) -> vec4<f32> {
let mode = u32(globals.filtering.x);
if mode != 0u {
// Texture Filtering Anisotropic or RGSS: terrain.fsh's two sampling paths.
let du = dpdx(uv);
let dv = dpdy(uv);
let pixel = vec2(1.0 / globals.filtering.y);
let texel_screen = sqrt(du * du + dv * dv);
if mode == 1u {
return sample_rgss(uv, layer, pixel, du, dv, texel_screen);
}
return sample_nearest(uv, layer, pixel, du, dv, texel_screen);
}
if SPRITE_ARRAYS == 1u {
return textureSample(sprites, sprite_sampler, uv, i32(layer));
}
// The array is chosen per fragment: sample with the derivatives taken here.
return sprite_grad(uv, layer, dpdx(uv), dpdy(uv));
}
struct TerrainOut {
@builtin(position) clip: vec4<f32>,
+3
View File
@@ -1289,6 +1289,9 @@ impl EditBox {
batch.text(atlas, "_", cursor_x, ty, color, true);
}
}
if self.focused {
crate::preedit::report_caret(cursor_x, ty, 10.0);
}
}
/// Character index under GUI x (mouse click).
pub fn index_at(&self, atlas: &HudAtlas, x: f32) -> usize {
+2 -1
View File
@@ -64,10 +64,11 @@ horizon-dualsense = { path = "../horizon-dualsense", optional = true }
# HTTPS to the Minecraft services (online-mode session join).
ureq = { version = "2", default-features = false, features = ["tls"] }
unicode-bidi = "0.3.18"
# `LevelStorageAccess.estimateDiskSpace`: usable disk space, without unsafe code on Windows.
fs4 = { version = "1.1.0", default-features = false, features = ["sync"] }
[target.'cfg(unix)'.dependencies]
# The server thread's CPU time per tick (`HORIZON_SPIKES`, spans.rs).
# `LevelStorageAccess.estimateDiskSpace` (statvfs) too.
rustix = { version = "1", default-features = false, features = ["std", "time", "process", "fs"] }
[target.'cfg(windows)'.dependencies]
@@ -63,5 +63,137 @@
"horizon.multiplayer.cancel_reconnecting": "Cancel Reconnecting",
"horizon.multiplayer.join_friend": "Join a Friend",
"horizon.multiplayer.invite_code": "Invite Code",
"horizon.multiplayer.relay": "Relay (optional)"
"horizon.multiplayer.relay": "Relay (optional)",
"Preset": "Preset",
"LOD Distance": "LOD Distance",
"Far Detail": "Far Detail",
"Cave Culling": "Cave Culling",
"Entity Culling": "Entity Culling",
"Chunk Updates": "Chunk Updates",
"Upload Budget": "Upload Budget",
"Sky": "Sky",
"Fog": "Fog",
"Weather": "Weather",
"Animated Textures": "Animated Textures",
"Present Mode": "Present Mode",
"Frame Pacing": "Frame Pacing",
"Worker Threads": "Worker Threads",
"Resolution Scale": "Resolution Scale",
"Anti-aliasing": "Anti-aliasing",
"Upscaling": "Upscaling",
"HDR Settings...": "HDR Settings...",
"Paper White": "Paper White",
"Peak Brightness": "Peak Brightness",
"Black Level": "Black Level",
"UI Brightness": "UI Brightness",
"Battery Mode": "Battery Mode",
"Graphics API": "Graphics API",
"Performance Overlay": "Performance Overlay",
"Full Detail Distance": "Full Detail Distance",
"On": "On",
"Off": "Off",
"Unlimited": "Unlimited",
"Auto": "Auto",
"Custom": "Custom",
"Performance": "Performance",
"Balanced": "Balanced",
"Quality": "Quality",
"Ultra": "Ultra",
"Ultra Performance": "Ultra Performance",
"Native": "Native",
"Full": "Full",
"Reduced": "Reduced",
"Efficient": "Efficient",
"Precise": "Precise",
"Auto (VSync)": "Auto (VSync)",
"FIFO": "FIFO",
"Mailbox": "Mailbox",
"Immediate": "Immediate",
"Vulkan": "Vulkan",
"DirectX 12": "DirectX 12",
"OpenGL": "OpenGL",
"FPS and Graph": "FPS and Graph",
"FPS": "FPS",
"TAA": "TAA",
"FXAA": "FXAA",
"No cost": "No cost",
"Low cost": "Low cost",
"Medium cost": "Medium cost",
"High cost": "High cost",
"Saves time": "Saves time",
"Rendering": "Rendering",
"Display": "Display",
"Preferences": "Preferences",
"Overlay": "Overlay",
"HDR": "HDR",
"Graphics": "Graphics",
"Graphics: Advanced": "Graphics: Advanced",
"Graphics: HDR": "Graphics: HDR",
"Advanced...": "Advanced...",
"Simple...": "Simple...",
"Done": "Done",
"This feature is not yet available in Horizon.": "This feature is not yet available in Horizon.",
"Your display or system doesn't offer HDR output.": "Your display or system doesn't offer HDR output.",
"Turn HDR on to use it.": "Turn HDR on to use it.",
"%s chunks": "%s chunks",
"%s per frame": "%s per frame",
"%s MiB/frame": "%s MiB/frame",
"%s KiB/frame": "%s KiB/frame",
"%s nits": "%s nits",
"Auto (%s)": "Auto (%s)",
"Auto (%s nits)": "Auto (%s nits)",
"%.2f nits": "%.2f nits",
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "Full-detail terrain range. LOD Distance can extend local worlds farther.",
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Reduced draws far terrain coarser: faster on this integrated GPU.",
"Reduced draws far terrain coarser, for GPUs it slows down.": "Reduced draws far terrain coarser, for GPUs it slows down.",
"Field of view. Wider shows more terrain at once.": "Field of view. Wider shows more terrain at once.",
"Caps frames per second; a cap saves power and heat.": "Caps frames per second; a cap saves power and heat.",
"Waits for the display's refresh: no tearing, a little latency.": "Waits for the display's refresh: no tearing, a little latency.",
"Size of menus and the HUD.": "Size of menus and the HUD.",
"Sets the options below together. Changing one makes it Custom.": "Sets the options below together. Changing one makes it Custom.",
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Lava, fire and sunlight shine brighter than white. Needs an HDR display.",
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Your display's darkest black. 0 for OLED; LCDs glow a little.",
"How bright highlights get. Auto: the peak your system reports.": "How bright highlights get. Auto: the peak your system reports.",
"How bright white paper and menus look. Auto: your desktop's white.": "How bright white paper and menus look. Auto: your desktop's white.",
"How bright the HUD and menus are, against paper white.": "How bright the HUD and menus are, against paper white.",
"How far mobs, crops and machines keep working. CPU time.": "How far mobs, crops and machines keep working. CPU time.",
"How far away mobs and items are drawn, as a share of normal.": "How far away mobs and items are drawn, as a share of normal.",
"Skips terrain hidden behind closed sections. Off costs a lot.": "Skips terrain hidden behind closed sections. Off costs a lot.",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Hides models behind opaque blocks. Entities update. Off with custom packs.",
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Meshed sections uploaded per frame. Lower is smoother while loading.",
"Soft shadows in corners. Costs chunk building time only.": "Soft shadows in corners. Costs chunk building time only.",
"Smaller copies of block textures for distant terrain: less shimmer.": "Smaller copies of block textures for distant terrain: less shimmer.",
"Blends grass and water colours across biomes when building chunks.": "Blends grass and water colours across biomes when building chunks.",
"Cloud layer: off, flat (fast) or 3D (fancy).": "Cloud layer: off, flat (fast) or 3D (fancy).",
"How far clouds reach, in chunks.": "How far clouds reach, in chunks.",
"Draws the sky, sun, moon and stars.": "Draws the sky, sun, moon and stars.",
"Distance fog. Off shows the edge of the loaded world.": "Distance fog. Off shows the edge of the loaded world.",
"Draws rain and snow.": "Draws rain and snow.",
"How far rain and snow fall around you.": "How far rain and snow fall around you.",
"How many particles effects spawn.": "How many particles effects spawn.",
"Round shadows under mobs and items.": "Round shadows under mobs and items.",
"Leaves with see-through gaps. Off draws them solid.": "Leaves with see-through gaps. Off draws them solid.",
"Sorts water and glass more exactly behind each other.": "Sorts water and glass more exactly behind each other.",
"Which chunk rebuilds wait for the frame: none, nearby or yours.": "Which chunk rebuilds wait for the frame: none, nearby or yours.",
"Animates water, lava, fire and other moving textures.": "Animates water, lava, fire and other moving textures.",
"World pixels per screen pixel. Below 100% is faster on the GPU.": "World pixels per screen pixel. Below 100% is faster on the GPU.",
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.",
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Renders the world smaller and rebuilds it with TAA. Faster, softer.",
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "HDR output on an HDR display: lava, fire and sunlight shine brighter.",
"How frames reach the screen while VSync is off.": "How frames reach the screen while VSync is off.",
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Efficient sleeps until the next frame; Precise spins, like vanilla.",
"Fills the screen (borderless).": "Fills the screen (borderless).",
"Brightens dark places.": "Brightens dark places.",
"Lowers the frame rate when minimised, or idle too.": "Lowers the frame rate when minimised, or idle too.",
"Sways the view while walking.": "Sways the view while walking.",
"Fades newly loaded chunks in.": "Fades newly loaded chunks in.",
"Darkens the screen's edges.": "Darkens the screen's edges.",
"Shows when the world saves.": "Shows when the world saves.",
"Shows attack cooldown.": "Shows attack cooldown.",
"60 FPS cap (30 in menus or unfocused), half the workers: less power.": "60 FPS cap (30 in menus or unfocused), half the workers: less power.",
"Threads that generate, light and mesh chunks. Next start.": "Threads that generate, light and mesh chunks. Next start.",
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.",
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Mesh data sent to the GPU per frame. Lower is smoother while loading.",
"Frame rate and frame times over the game, with a graph.": "Frame rate and frame times over the game, with a graph."
}
@@ -63,5 +63,137 @@
"horizon.multiplayer.cancel_reconnecting": "Отменить переподключение",
"horizon.multiplayer.join_friend": "Играть с другом",
"horizon.multiplayer.invite_code": "Код приглашения",
"horizon.multiplayer.relay": "Ретранслятор (необязательно)"
"horizon.multiplayer.relay": "Ретранслятор (необязательно)",
"Preset": "Пресет",
"LOD Distance": "Дистанция LOD",
"Far Detail": "Детализация вдали",
"Cave Culling": "Отсечение пещер",
"Entity Culling": "Отсечение сущностей",
"Chunk Updates": "Обновления чанков",
"Upload Budget": "Бюджет выгрузки",
"Sky": "Небо",
"Fog": "Туман",
"Weather": "Погода",
"Animated Textures": "Анимированные текстуры",
"Present Mode": "Режим вывода",
"Frame Pacing": "Ритм кадров",
"Worker Threads": "Рабочие потоки",
"Resolution Scale": "Масштаб разрешения",
"Anti-aliasing": "Сглаживание",
"Upscaling": "Апскейл",
"HDR Settings...": "Настройки HDR…",
"Paper White": "Белая точка",
"Peak Brightness": "Пиковая яркость",
"Black Level": "Уровень чёрного",
"UI Brightness": "Яркость интерфейса",
"Battery Mode": "Режим экономии",
"Graphics API": "Графический API",
"Performance Overlay": "Оверлей производительности",
"Full Detail Distance": "Дальность полной детализации",
"On": "Вкл.",
"Off": "Выкл.",
"Unlimited": "Без ограничения",
"Auto": "Авто",
"Custom": "Свои",
"Performance": "Производительность",
"Balanced": "Сбалансированный",
"Quality": "Качество",
"Ultra": "Ультра",
"Ultra Performance": "Максимальная производительность",
"Native": "Нативный",
"Full": "Полная",
"Reduced": "Снижена",
"Efficient": "Экономный",
"Precise": "Точный",
"Auto (VSync)": "Авто (верт. синхр.)",
"FIFO": "FIFO",
"Mailbox": "Mailbox",
"Immediate": "Immediate",
"Vulkan": "Vulkan",
"DirectX 12": "DirectX 12",
"OpenGL": "OpenGL",
"FPS and Graph": "FPS и график",
"FPS": "FPS",
"TAA": "TAA",
"FXAA": "FXAA",
"No cost": "Без затрат",
"Low cost": "Мало затрат",
"Medium cost": "Средние затраты",
"High cost": "Много затрат",
"Saves time": "Экономит время",
"Rendering": "Рендеринг",
"Display": "Изображение",
"Preferences": "Предпочтения",
"Overlay": "Оверлей",
"HDR": "HDR",
"Graphics": "Графика",
"Graphics: Advanced": "Графика: подробно",
"Graphics: HDR": "Графика: HDR",
"Advanced...": "Подробно…",
"Simple...": "Просто…",
"Done": "Готово",
"This feature is not yet available in Horizon.": "Эта функция в Horizon пока недоступна.",
"Your display or system doesn't offer HDR output.": "Ваш экран или система не поддерживает HDR-вывод.",
"Turn HDR on to use it.": "Включите HDR, чтобы пользоваться этим.",
"%s chunks": "%s чанков",
"%s per frame": "%s за кадр",
"%s MiB/frame": "%s МиБ/кадр",
"%s KiB/frame": "%s КиБ/кадр",
"%s nits": "%s нит",
"Auto (%s)": "Авто (%s)",
"Auto (%s nits)": "Авто (%s нит)",
"%.2f nits": "%.2f нит",
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "Дальность прорисованного рельефа в деталях. Дистанция LOD может продлить её для локальных миров.",
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Грубый дальний рельеф в локальных мирах. На встроенной графике — не дальше 128 чанков.",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "«Снижено» рисует дальний рельеф грубее: на этой встроенной видеокарте быстрее.",
"Reduced draws far terrain coarser, for GPUs it slows down.": "«Снижено» рисует дальний рельеф грубее — для карт, которым он мешает.",
"Field of view. Wider shows more terrain at once.": "Угол обзора. Шире — больше рельефа видно сразу.",
"Caps frames per second; a cap saves power and heat.": "Ограничивает кадры в секунду: предел экономит энергию и снижает нагрев.",
"Waits for the display's refresh: no tearing, a little latency.": "Ждёт обновления экрана: без разрывов, с небольшой задержкой.",
"Size of menus and the HUD.": "Размер меню и интерфейса.",
"Sets the options below together. Changing one makes it Custom.": "Задаёт настройки ниже разом. Изменишь одну — станет «Свои».",
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Лава, огонь и солнце светятся ярче белого. Нужен HDR-экран.",
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Самый тёмный чёрный вашего экрана. 0 для OLED; ЖК-панели светятся немного.",
"How bright highlights get. Auto: the peak your system reports.": "Насколько яркими будут блики. Авто: пик, который сообщает система.",
"How bright white paper and menus look. Auto: your desktop's white.": "Насколько ярким выглядит белый и меню. Авто: белый цвет рабочего стола.",
"How bright the HUD and menus are, against paper white.": "Яркость интерфейса и меню относительно белой точки.",
"How far mobs, crops and machines keep working. CPU time.": "Как далеко мобы, посевы и механизмы продолжают работать. Нагрузка на процессор.",
"How far away mobs and items are drawn, as a share of normal.": "С какой дистанции рисуются мобы и предметы, в долях от обычной.",
"Skips terrain hidden behind closed sections. Off costs a lot.": "Пропускает рельеф, скрытый за закрытыми секциями. Выключено — дорого.",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Прячет модели за непрозрачными блоками. Сущности тикают. Выключать со своими наборами.",
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Секций меша за кадр. Меньше — плавнее во время загрузки.",
"Soft shadows in corners. Costs chunk building time only.": "Мягкие тени по углам. Замедляет только построение чанков.",
"Smaller copies of block textures for distant terrain: less shimmer.": "Уменьшенные копии текстур блоков для дали: меньше мерцания.",
"Blends grass and water colours across biomes when building chunks.": "Смешивает цвета травы и воды на границах биомов при построении чанков.",
"Cloud layer: off, flat (fast) or 3D (fancy).": "Слой облаков: выключен, плоский (быстро) или 3D (красиво).",
"How far clouds reach, in chunks.": "Как далеко простираются облака, в чанках.",
"Draws the sky, sun, moon and stars.": "Рисует небо, солнце, луну и звёзды.",
"Distance fog. Off shows the edge of the loaded world.": "Туман по дальности. Выключенный виден край прогруженного мира.",
"Draws rain and snow.": "Рисует дождь и снег.",
"How far rain and snow fall around you.": "Как далеко вокруг вас идёт дождь и снег.",
"How many particles effects spawn.": "Сколько частиц создают эффекты.",
"Round shadows under mobs and items.": "Круглые тени под мобами и предметами.",
"Leaves with see-through gaps. Off draws them solid.": "Листва с просветами. Выключено — рисуется сплошной.",
"Sorts water and glass more exactly behind each other.": "Точнее сортирует воду и стекло друг за другом.",
"Which chunk rebuilds wait for the frame: none, nearby or yours.": "Какие перестройки чанков ждут кадр: никакие, ближние или ваши.",
"Animates water, lava, fire and other moving textures.": "Анимирует воду, лаву, огонь и другие движущиеся текстуры.",
"World pixels per screen pixel. Below 100% is faster on the GPU.": "Пикселей мира на пиксель экрана. Ниже 100% — быстрее для видеокарты.",
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Сглаживает рваные края. FXAA: мягче. TAA: глаже всего, но движущееся может шлейфить.",
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Рисует мир мельче и восстанавливает его через TAA. Быстрее, но мягче.",
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "HDR-вывод на HDR-экране: лава, огонь и солнце светятся ярче.",
"How frames reach the screen while VSync is off.": "Как кадры попадают на экран, когда вертикальная синхронизация выключена.",
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "«Экономный» спит до следующего кадра; «Точный» крутится, как в ванили.",
"Fills the screen (borderless).": "Занимает весь экран (без рамки).",
"Brightens dark places.": "Осветляет тёмные места.",
"Lowers the frame rate when minimised, or idle too.": "Снижает частоту кадров в свёрнутом окне, а также в простое.",
"Sways the view while walking.": "Покачивает вид при ходьбе.",
"Fades newly loaded chunks in.": "Плавно проявляет только что загруженные чанки.",
"Darkens the screen's edges.": "Затемняет края экрана.",
"Shows when the world saves.": "Показывает, когда мир сохраняется.",
"Shows attack cooldown.": "Показывает перезарядку удара.",
"60 FPS cap (30 in menus or unfocused), half the workers: less power.": "Предел 60 FPS (30 в меню и без фокуса) и половина потоков: меньше энергии.",
"Threads that generate, light and mesh chunks. Next start.": "Потоки, которые генерируют, освещают и строят чанки. Со следующего запуска.",
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan, при сбое — DirectX 12 (Windows) или OpenGL (Linux). Со следующего запуска.",
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Данных меша в видеокарту за кадр. Меньше — плавнее во время загрузки.",
"Frame rate and frame times over the game, with a graph.": "Частота кадров и их время поверх игры, с графиком."
}
@@ -599,6 +599,7 @@ fn print_heart(
fn player_damage(e: &MobEvent) -> f32 {
match e {
MobEvent::AttackPlayer(amount, _)
| MobEvent::CubeHurtPlayer { amount, .. }
| MobEvent::AttackPlayerWithEffect { amount, .. }
| MobEvent::DamagePlayer { amount, .. } => *amount,
MobEvent::SonicBoom {
@@ -616,6 +617,7 @@ fn player_hurt(player: Option<&PlayerView>, e: &MobEvent) -> bool {
};
match e {
MobEvent::AttackPlayer(amount, _)
| MobEvent::CubeHurtPlayer { amount, .. }
| MobEvent::AttackPlayerWithEffect { amount, .. }
| MobEvent::DamagePlayer { amount, .. } => *amount > 0.0,
MobEvent::SonicBoom {
@@ -1027,6 +1029,10 @@ pub fn main() -> horizon_client::Result<()> {
"inlove" => m.extra.in_love = 600,
"nectar" => horizon_client::mobs::bee::set_has_nectar(&mut m, true),
"tame" => m.extra.tame = true,
"owned" => {
horizon_client::mobs::tame::set_tame(&mut m, true);
m.extra.owner = Some(PLAYER_UUID);
}
x if x.starts_with("age=") => m.extra.age = x[4..].parse().unwrap(),
x if x.starts_with("strength=") => m.extra.variant2 = x[9..].parse().unwrap(),
x if x.starts_with("hand=") => {
@@ -220,7 +220,7 @@ pub fn main() -> Result<()> {
}
writeln!(out)?;
}
for (name, p, volume, pitch) in std::mem::take(&mut engine.sounds) {
for (name, p, volume, pitch, _) in std::mem::take(&mut engine.sounds) {
writeln!(
out,
"~ {name} {} {} {} {} {}",
+1 -1
View File
@@ -536,7 +536,7 @@ fn add_visible(engine: &mut ParticleEngine, ty: T, p: [f64; 3], v: [f64; 3]) {
engine.add_with(&ParticleOptions::simple(ty), p, v, false, true);
}
fn sound(engine: &mut ParticleEngine, name: &'static str, p: [f64; 3], volume: f32, pitch: f32) {
engine.sounds.push((name, p, volume, pitch));
engine.sounds.push((name, p, volume, pitch, false));
}
fn redstone(engine: &mut ParticleEngine, p: [f64; 3]) {
engine.add(&ParticleOptions::dust([1.0, 0.0, 0.0], 1.0), p, [0.0; 3]);
+20 -1
View File
@@ -1,6 +1,6 @@
//! `BlockPattern`: a grid of block predicates found in the world in any of
//! its 24 orientations (upright, upside down, lying flat, turned sideways).
//! The wither is built from these.
//! The wither and the golems are built from these.
use horizon_core::geometry::Direction;
/// A grid of predicate keys: `aisles[z][y]` is a row whose chars are the `x`
@@ -19,6 +19,25 @@ pub const WITHER_FULL: BlockPattern = BlockPattern {
pub const WITHER_BASE: BlockPattern = BlockPattern {
aisles: &[&[" ", "###", "~#~"]],
};
/// `CarvedPumpkinBlock.getOrCreateSnowGolemFull`: '^' a carved pumpkin or
/// jack o'lantern, '#' a snow block.
pub const SNOW_GOLEM_FULL: BlockPattern = BlockPattern {
aisles: &[&["^", "#", "#"]],
};
/// `CarvedPumpkinBlock.getOrCreateSnowGolemBase`: the same column with any
/// block where the head will go.
pub const SNOW_GOLEM_BASE: BlockPattern = BlockPattern {
aisles: &[&[" ", "#", "#"]],
};
/// `CarvedPumpkinBlock.getOrCreateIronGolemFull`: '^' the head, '#' an iron
/// block, '~' air beside the arms and legs.
pub const IRON_GOLEM_FULL: BlockPattern = BlockPattern {
aisles: &[&["~^~", "###", "~#~"]],
};
/// `CarvedPumpkinBlock.getOrCreateIronGolemBase`.
pub const IRON_GOLEM_BASE: BlockPattern = BlockPattern {
aisles: &[&["~ ~", "###", "~#~"]],
};
/// `BlockPattern.BlockPatternMatch`: where a pattern was found and how it is turned.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct PatternMatch {
+5
View File
@@ -882,6 +882,10 @@ pub struct ArmorStand {
/// `DATA_HEAD_POSE` to `DATA_RIGHT_LEG_POSE`: head, body, left arm, right
/// arm, left leg, right leg, in degrees (`POSE_KEYS` order).
pub pose: [[f32; 3]; 6],
/// `Entity.vehicle` on a server's client (`SetPassengers`): the mob it
/// rides, which seats it every tick (`positionRider`), as NPC plugins hang
/// a second name over a player's head.
pub vehicle: Option<u32>,
}
impl ArmorStand {
pub const SLOTS: [&'static str; 6] = ["mainhand", "offhand", "feet", "legs", "chest", "head"];
@@ -916,6 +920,7 @@ impl ArmorStand {
name_visible: false,
last_hit: i64::MIN / 2,
pose: Self::DEFAULT_POSE,
vehicle: None,
}
}
pub fn environment_tick(
+393 -111
View File
@@ -119,6 +119,7 @@ mod glowing;
mod golems;
mod growth;
mod hand;
mod ime;
mod initial_spawn;
mod integrated;
mod interactions;
@@ -247,6 +248,8 @@ mod subtitle_hud;
mod sulfur_cubes;
mod test_block_edit;
mod test_blocks;
mod test_entities;
mod test_environments;
mod test_instance_edit;
mod test_instances;
mod text_displays;
@@ -344,6 +347,12 @@ use winit::{
window::{CursorGrabMode, Window, WindowId},
};
/// The time a frame spends uploading chunk meshes when the player set no
/// Chunk Updates or Upload Budget: a burst past it waits for the next frames.
const AUTO_UPLOAD_TIME: Duration = Duration::from_millis(2);
/// A frame's finished meshes up to this many upload at once, unqueued.
const AUTO_UPLOADS_AT_ONCE: usize = 8;
#[derive(Clone)]
struct Options {
resources: PathBuf,
@@ -787,15 +796,7 @@ struct GameLevel<'w> {
client_side: bool,
/// Only the integrated or dedicated server dispatches native observations.
native_authoritative: bool,
native_particles: &'w mut Vec<(
&'static str,
horizon_client::particles::ParticleOptions,
Option<BlockStateId>,
[f64; 3],
[f64; 3],
f64,
i32,
)>,
native_particles: &'w mut Vec<lan_particles::QueuedParticle>,
}
struct PlayerCollision {
y: f64,
@@ -1666,8 +1667,8 @@ struct DragView {
fn equipment_modifier_applies(group: &str, slot: &str) -> bool {
group == slot
|| group == "any"
|| (group == "armor" && slot != "mainhand")
|| (group == "hand" && slot == "mainhand")
|| (group == "armor" && slot != "mainhand" && slot != "offhand")
|| (group == "hand" && (slot == "mainhand" || slot == "offhand"))
}
/// Ctrl+Q reads the physical keyboard, independently of sprint key mappings.
/// A chunk whose ticking future can complete (`ChunkMap.prepareTickingChunk`):
@@ -1723,6 +1724,12 @@ struct Game<'a> {
pending_entry: Option<PendingWorldEntry>,
world_loader: Option<world_loading::WorldLoader>,
pending_pack_reload: bool,
/// When `resourcepacks/` was last looked at for the open pack screen, and what it held
/// (`watch_pack_folder`).
pack_folder: Option<(
std::time::Instant,
Vec<(String, u64, Option<std::time::SystemTime>)>,
)>,
/// A pack reload staged on a worker (`LoadingOverlay`): the frame draws
/// its progress until `drive_pack_reload` applies it.
pack_reload: Option<packs::PackReload>,
@@ -1823,6 +1830,10 @@ struct Game<'a> {
/// This view's corner in its shared window; cursor warps include the offset.
view_at: [u32; 2],
focused: bool,
/// Whether the window currently accepts input method events.
ime_allowed: bool,
/// The candidate window's area last given to the window, in pixels.
ime_area: Option<[i32; 4]>,
/// When the window lost focus, while it is still without
/// (`Minecraft.lastActiveTime`: pausing waits 500 ms).
focus_lost: Option<Instant>,
@@ -1896,6 +1907,11 @@ struct Game<'a> {
session_lock: Option<horizon_client::save::SessionLock>,
audio: Audio,
music: horizon_client::music::MusicManager,
/// `ToastManager.nowPlayingToast`: there while Music Toast is not Never.
now_playing: Option<horizon_client::toasts::NowPlayingToast>,
/// The Music Toast option the toast was last set up for.
music_toast_state: u8,
music_notes: horizon_client::toasts::MusicNotes,
ambience: horizon_client::ambience::Ambience,
/// The sound engine was told the game paused (pauseAllExcept).
sound_paused: bool,
@@ -2016,6 +2032,8 @@ struct Game<'a> {
/// the attack strength tickers the hand reads.
clicks: horizon_client::first_person::Clicks,
attack_clicks: u32,
/// Use clicks since the last tick; `handleKeybinds` consumes them after the attack clicks.
use_clicks: u32,
hand_strength: horizon_client::attack_strength::AttackStrength,
settings: Settings,
tutorial: tutorial::Tutorial,
@@ -2246,6 +2264,8 @@ struct Game<'a> {
world_options: Option<lan_screen::WorldOptions>,
/// Chat Settings opened from RestrictionsScreen: Done returns to World Options.
options_to_world_options: bool,
/// HDR's settings opened with their key from the game: Done returns to the game.
options_to_game: bool,
command_block_screen: Option<command_blocks::CommandBlockScreen>,
block_editor: Option<block_editor::BlockEditScreen>,
/// `StructureTemplateManager.structureRepository`: the templates in memory
@@ -2253,6 +2273,8 @@ struct Game<'a> {
structure_templates: HashMap<String, Option<structure_blocks::TemplateEntry>>,
/// The game tests read so far, by id (the test instance registry).
test_defs: std::cell::RefCell<HashMap<String, Option<test_instances::TestDef>>>,
/// `GameTestBlockHighlightRenderer.markers`: block, text, removal time.
test_highlights: HashMap<(i32, i32, i32), (String, std::time::Instant)>,
/// `GameTestTicker.SINGLETON`: the game tests running on this server.
game_tests: game_tests::GameTests,
/// KeyboardHandler's debug key state (game/debug_keys.rs), the physical
@@ -2694,6 +2716,66 @@ impl Game<'_> {
.flatten()
.any(|s| immune.is_some_and(|t| t.contains(&*s.item)))
}
/// `LocalPlayer.moveTowardsClosestSpace` at the body's four corners
/// (aiStep, unless a spectator or a passenger): a corner inside a
/// suffocating block sets a 0.1 speed towards the nearest side that is
/// free, on that side's axis; a later corner overwrites an earlier one.
/// Suffocating is a full collision cube that is also a full render cube,
/// as `in_wall` has it.
fn closest_space_nudge(&self) -> [Option<f64>; 2] {
use horizon_core::geometry::Axis;
let mut out = [None; 2];
let Some(p) = self.player.as_ref() else {
return out;
};
if p.spectator || self.riding.is_some() {
return out;
}
let body = p.bounds();
let (min_y, max_y) = (body.min(Axis::Y) + 1.0e-7, body.max(Axis::Y) - 1.0e-7);
let suffocates_at = |x: i32, z: i32| {
((min_y.floor() as i32 - 1)..=(max_y.ceil() as i32 + 1)).any(|y| {
f64::from(y) < max_y && f64::from(y + 1) > min_y && {
let f = self.res.facts().get(self.world.block(x, y, z));
f.full_collision && f.solid_render
}
})
};
let side = f64::from(p.dimensions.width) * 0.35;
let (px, pz) = (p.position.x, p.position.z);
for (x, z) in [
(px - side, pz + side),
(px - side, pz - side),
(px + side, pz - side),
(px + side, pz + side),
] {
let (bx, bz) = (x.floor() as i32, z.floor() as i32);
if !suffocates_at(bx, bz) {
continue;
}
let (xd, zd) = (x - f64::from(bx), z - f64::from(bz));
// WEST, EAST, NORTH, SOUTH: (axis, step, distance to that edge).
let mut closest = 1.797_693_134_862_315_7e308; // Double.MAX_VALUE
let mut chosen = None;
for (axis, step, distance) in
[(0, -1, xd), (0, 1, 1.0 - xd), (1, -1, zd), (1, 1, 1.0 - zd)]
{
let (nx, nz) = if axis == 0 {
(bx + step, bz)
} else {
(bx, bz + step)
};
if distance < closest && !suffocates_at(nx, nz) {
closest = distance;
chosen = Some((axis, step));
}
}
if let Some((axis, step)) = chosen {
out[axis] = Some(0.1 * f64::from(step));
}
}
out
}
/// What the window decides for its player's body every tick, in frame and
/// on a server alike: the look (Player.travel steers sprint-swimming by
/// the pitch), Auto Jump (LocalPlayer.sendPosition), the slowdown of an
@@ -2725,9 +2807,11 @@ impl Game<'_> {
.is_some_and(|s| &*s.item == "minecraft:leather_boots");
let freeze_immune = self.wears_freeze_immune();
let burn_immune = self.wears_burn_from_stepping_immunity();
let nudge = self.closest_space_nudge();
let Some(player) = self.player.as_mut() else {
return;
};
player.nudge = nudge;
player.yaw = self.yaw;
player.pitch = self.pitch;
player.auto_jump_enabled = self.settings.auto_jump;
@@ -3558,9 +3642,9 @@ impl Game<'_> {
self.tick_chunks.recycle_added(added);
}
/// `DistanceManager`'s tickets of the level in place, from every player in
/// it (the local player, LAN guests, a server's players) and its forced
/// chunks. A game with neither a server nor a player (the headless
/// fixtures) ticks everywhere.
/// it (the local player, LAN guests, a server's players), its forced
/// chunks and its portal tickets. A game with neither a server nor a
/// player (the headless fixtures) ticks everywhere.
fn update_chunk_tickets(&mut self) {
let mut players: Vec<(i32, i32)> = self.lan.as_ref().map_or_else(Vec::new, |h| {
h.hub
@@ -3581,6 +3665,8 @@ impl Game<'_> {
horizon_client::ticket_levels::Sources {
players,
forced: self.streaming.forced.iter().copied().collect(),
// Filled from the `PORTAL` tickets by `update`.
portal: Vec::new(),
view_distance: self.settings.render_distance.clamp(2, 32),
simulation_distance: self.settings.simulation_distance.max(2),
}
@@ -3607,6 +3693,8 @@ impl Game<'_> {
/// ServerLevel.tick block phases: scheduled ticks, random ticks in the
/// chunks the tickets simulate, block events and moving pistons.
fn tick_blocks(&mut self) -> Result<()> {
// ServerChunkCache.tick: the tickets that time out.
self.tickets.tick_portal();
self.sync_tick_chunks();
self.start_ticking_chunks();
let levels = self.tickets.levels();
@@ -5987,6 +6075,14 @@ impl Game<'_> {
self.tick_tutorial();
if self.screen == Screen::Creative {
self.refresh_creative_tabs();
// CreativeModeInventoryScreen.containerTick: a player without
// infinite materials gets the survival inventory instead.
if !self.creative {
if let Some(c) = self.carried.take() {
let _ = self.give(c);
}
self.screen = Screen::Inventory;
}
}
if self.remote.is_some() {
return self.tick_remote();
@@ -6392,6 +6488,7 @@ impl Game<'_> {
self.share_spawn_conditions();
self.update_chunk_tickets();
horizon_client::spans::time("tick_effects", || self.tick_effects(running));
self.tick_sign_editor();
// Player.tick's ItemCooldowns.tick for the window's player (a
// server's players count theirs down in `tick_guests`).
self.shield_cooldown = self.shield_cooldown.saturating_sub(1);
@@ -6648,6 +6745,31 @@ impl Game<'_> {
self.screen = Screen::Paused;
}
}
/// `KeyMapping.setAll`: every key physically held is held again, as the
/// mapping it is bound to, so a key pressed or kept down behind a screen
/// counts once the game has the keyboard back. A sneak or sprint that
/// toggles keeps its toggle state (`shouldSetOnIngameFocus`).
fn restore_held_keys(&mut self) {
let held: Vec<KeyCode> = self.physical_keys.iter().copied().collect();
let toggles = [self.settings.toggle_crouch, self.settings.toggle_sprint];
for key in held {
for mapped in self.settings.keys.translate_all(key) {
match mapped {
Ok(slot_key @ (KeyCode::ShiftLeft | KeyCode::ControlLeft)) => {
let slot = usize::from(slot_key == KeyCode::ControlLeft);
if !toggles[slot] {
self.toggles.0[slot].set_down(true, false);
self.sync_toggle(slot);
}
}
Ok(other) => {
self.keys.insert(other);
}
Err(_) => {}
}
}
}
}
/// `MouseHandler.grabMouse` / `releaseMouse`. Both put the pointer at
/// the window's centre, so a screen opened from gameplay (inventory,
/// chest, pause, chat, sign, book, death) starts with the cursor in the
@@ -6683,6 +6805,11 @@ impl Game<'_> {
self.captured = false;
return;
}
if capture && !self.captured && !cfg!(target_os = "macos") {
// MouseHandler.grabMouse: KeyMapping.setAll unless
// InputQuirks.RESTORE_KEY_STATE_AFTER_MOUSE_GRAB is off (macOS).
self.restore_held_keys();
}
let Some(window) = &self.window else { return };
let size = self.renderer.as_ref().map_or_else(
|| window.inner_size().into(),
@@ -6978,6 +7105,7 @@ impl Game<'_> {
self.draw_toasts(&mut batch, false);
self.draw_tutorial_toasts(&mut batch, false);
self.draw_pack_toasts(&mut batch, false);
self.draw_music_toast(&mut batch, false);
return batch;
}
let atlas_ptr: *const HudAtlas = &self.gui.atlas;
@@ -6986,11 +7114,13 @@ impl Game<'_> {
// download while the connection screen shows.
if self.remote_overlay(&mut batch) {
self.draw_pack_toasts(&mut batch, false);
self.draw_music_toast(&mut batch, false);
return batch;
}
if let Some(reason) = self.loading_reason() {
self.draw_level_loading(&mut batch, reason);
self.draw_pack_toasts(&mut batch, false);
self.draw_music_toast(&mut batch, false);
return batch;
}
// Hud.extractRenderState: F1 (keyToggleGui) hides camera overlays, the
@@ -7243,6 +7373,7 @@ impl Game<'_> {
self.draw_toasts(&mut batch, !show_hud);
self.draw_tutorial_toasts(&mut batch, !show_hud);
self.draw_pack_toasts(&mut batch, !show_hud);
self.draw_music_toast(&mut batch, !show_hud);
if self.server_packs.prompt.is_none() {
self.remote_hud(&mut batch);
self.draw_sign_editor(&mut batch);
@@ -7404,6 +7535,9 @@ impl Game<'_> {
}
}
fn slot_at(&self, batch: &HudBatch, x: f32, y: f32) -> Option<usize> {
if self.book_covers_menu(batch.width) {
return None;
}
let (ox, oy) = self.inventory_origin(batch);
self.slots().into_iter().find(|&i| {
let (sx, sy) = self.slot_position(i);
@@ -7412,6 +7546,40 @@ impl Game<'_> {
x >= ox + sx - 1.0 && x < ox + sx + 17.0 && y >= oy + sy - 1.0 && y < oy + sy + 17.0
})
}
/// `AbstractContainerScreen.extractSlotHighlightBack` and
/// `AbstractContainerScreen.extractSlotHighlightFront`: the 24 x 24
/// `container/slot_highlight_back` sprite behind the slot at `x`, `y` (its
/// 16 x 16 item box), the `_front` one over its item.
fn draw_slot_highlight(&self, batch: &mut HudBatch, front: bool, x: f32, y: f32) {
let sprite = if front {
"gui/sprites/container/slot_highlight_front"
} else {
"gui/sprites/container/slot_highlight_back"
};
horizon_client::ui::blit_sprite(
batch,
&self.gui.atlas,
sprite,
x - 4.0,
y - 4.0,
24.0,
24.0,
u32::MAX,
);
}
/// The highlight of the slot under the cursor, for the screens whose
/// slots all sit in `slot_at`'s grid. `Slot.isHighlightable` is false
/// for the crafter's result (`NonInteractiveResultSlot`).
fn draw_hovered_slot_highlight(&self, batch: &mut HudBatch, front: bool, ox: f32, oy: f32) {
let Some(slot) = self.slot_at(batch, self.cursor.0, self.cursor.1) else {
return;
};
if slot == 309 && matches!(self.container_kind(), Some(BlockEntity::Crafter(_))) {
return;
}
let (sx, sy) = self.slot_position(slot);
self.draw_slot_highlight(batch, front, ox + sx, oy + sy);
}
fn slot_mut(&mut self, i: usize) -> &mut Option<ItemStack> {
if i >= mount_menu::SADDLE {
return self.mount_slot_mut(i);
@@ -7645,25 +7813,22 @@ impl Game<'_> {
if self.container.is_none() && self.craft_size != 3 && !self.mount_active() {
self.draw_inventory_player(batch, ox, oy);
}
self.draw_hovered_slot_highlight(batch, false, ox, oy);
self.draw_inventory_slots(batch, ox, oy);
self.draw_hovered_slot_highlight(batch, true, ox, oy);
self.draw_sort_buttons(batch, ox, oy);
self.draw_recipe_book(batch);
if let Some(slot) = self.slot_at(batch, self.cursor.0, self.cursor.1) {
let (sx, sy) = self.slot_position(slot);
batch.fill(
&self.gui.atlas,
ox + sx,
oy + sy,
16.0,
16.0,
rgba(255, 255, 255, 128),
);
}
let effect_tip = if self.container.is_none() && self.craft_size == 2 && !self.mount_active()
{
self.draw_inventory_effects(batch, (ox, oy), 176)
} else {
None
};
self.draw_carried(batch);
self.draw_slot_tooltip(batch);
self.draw_native_screen_tooltip(batch);
if self.container.is_none() && self.craft_size == 2 && !self.mount_active() {
self.draw_inventory_effects(batch, (ox, oy), 176);
if let Some(lines) = effect_tip {
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
}
}
/// `AbstractContainerScreen` keeps `isQuickCrafting` and the drag it
@@ -9130,6 +9295,7 @@ impl Game<'_> {
let mut see_through = std::mem::take(&mut self.see_through_vertices);
see_through.clear();
self.name_tag_geometry(camera, alpha, &mut out, &mut see_through);
self.test_instance_marker_geometry(camera, &mut out, &mut see_through);
self.text_display_geometry(camera, alpha, &mut out, &mut see_through);
self.see_through_vertices = see_through;
// Entity shadows (EntityRenderer.extractShadow).
@@ -9644,7 +9810,15 @@ impl Game<'_> {
// the offset is the one from the player to the particle.
let origin = from.unwrap_or(at);
let spread = [at[0] - origin[0], 0.0, at[2] - origin[2]];
self.lan_send_particles(self.dim.id(), &arc, None, at, spread, 0.0, 0);
self.lan_send_particles(lan_particles::QueuedParticle {
dimension: self.dim.id(),
options: arc,
state: None,
at,
spread,
speed: 0.0,
count: 0,
});
}
/// `Player.attack`'s sounds when a vehicle takes the hit (it always does,
/// and can't be hit critically): knockback when sprinting at full
@@ -9873,15 +10047,15 @@ impl Game<'_> {
(actual * 0.5) as u32,
);
// The players a server serves are sent them too.
self.lan_send_particles(
self.dim.id(),
&indicator,
None,
[feet[0], feet[1] + height * 0.5, feet[2]],
[0.1, 0.0, 0.1],
0.2,
(actual * 0.5) as u32 as i32,
);
self.lan_send_particles(lan_particles::QueuedParticle {
dimension: self.dim.id(),
options: indicator,
state: None,
at: [feet[0], feet[1] + height * 0.5, feet[2]],
spread: [0.1, 0.0, 0.1],
speed: 0.2,
count: (actual * 0.5) as u32 as i32,
});
}
self.last_hurt_by_player
.insert(self.mobs[index].id, self.lan_acting);
@@ -10000,6 +10174,13 @@ impl Game<'_> {
let Some(r) = atlas.sprites.get(name).copied() else {
return;
};
// How brightly HDR output lets the body shine (mesher::hdr_tier):
// the moon at the low tier, not the sun's.
let tier = if moon {
horizon_client::mesher::LOW
} else {
horizon_client::mesher::VERY_BRIGHT
};
let m = frame(angle);
let corners = [(-size, -size), (size, -size), (size, size), (-size, size)];
// buildCelestialQuad (sun) vs buildMoonPhases: the moon's UVs run
@@ -10015,7 +10196,7 @@ impl Game<'_> {
normal: [0; 4],
pos: p.to_array(),
uv,
layer: 2,
layer: 2 | u32::from(tier) << 16,
color: body_color,
light: 0,
@@ -10131,10 +10312,13 @@ impl Game<'_> {
if self.window.is_some() {
self.perf.world_opening();
if self.title.is_none() {
self.title = Some(Title::new(
std::env::current_dir()?.join("saves"),
&self.options.resources,
));
self.title = Some(
Title::new(
std::env::current_dir()?.join("saves"),
&self.options.resources,
)
.with_user(|| self.options.player_name()),
);
}
self.world_loader = Some(world_loading::WorldLoader::start(
request,
@@ -10642,10 +10826,13 @@ impl Game<'_> {
if let Some(r) = self.renderer.as_mut() {
r.clear_sections(self.world.layout());
}
self.title = Some(Title::new(
std::env::current_dir()?.join("saves"),
&self.options.resources,
));
self.title = Some(
Title::new(
std::env::current_dir()?.join("saves"),
&self.options.resources,
)
.with_user(|| self.options.player_name()),
);
if let Some(title) = &mut self.title {
title.last_server = self.settings.file.string("lastServer");
title.skip_multiplayer_warning = self.settings.file.bool("skipMultiplayerWarning");
@@ -10698,6 +10885,7 @@ impl Game<'_> {
.map(|(s, n)| (s.as_ref(), n))
.collect();
equipped.push((self.inventory[self.selected].as_ref(), "mainhand"));
equipped.push((self.offhand.as_ref(), "offhand"));
for (stack, slot) in equipped {
let Some(info) = stack.and_then(|s| world_items!(self).get(&s.item)) else {
continue;
@@ -11070,16 +11258,19 @@ impl Game<'_> {
ItemStack::new("minecraft:hanging_roots", 1),
);
}
self.audio.play(
&sound,
Some([
f64::from(pos[0]) + 0.5,
f64::from(pos[1]) + 0.5,
f64::from(pos[2]) + 0.5,
]),
1.0,
1.0,
);
// `Level.playSound(player, ...)`: the actor's client already played
// the strip/scrape/till sound in `predict_item_on_block` (#4681).
let at = [
f64::from(pos[0]) + 0.5,
f64::from(pos[1]) + 0.5,
f64::from(pos[2]) + 0.5,
];
if self.lan_acting.is_some() {
self.audio.play_unshared(&sound, Some(at), 1.0, 1.0);
self.guest_player_sounds.push((sound.clone(), at, 1.0, 1.0));
} else {
self.audio.play(&sound, Some(at), 1.0, 1.0);
}
// ItemStack.useOn: a use that acted counts as one of the item.
self.stats.add("used", &stack.item, 1);
self.damage_held(damage);
@@ -11297,6 +11488,24 @@ impl Game<'_> {
if !emptied {
return Ok(UseResult::Fail);
}
// `BucketItem.emptyContents` uses `playSound(player, …)`: the
// actor's client already played the empty / evaporate sound from
// its DryLevel prediction (#4684).
if self.lan_acting.is_some()
&& let Some(capture) = self.audio.capture.as_mut()
{
let want = sound.trim_start_matches("minecraft:");
let mut keep = Vec::with_capacity(capture.len());
for entry in capture.drain(..) {
let name = entry.0.trim_start_matches("minecraft:");
if name == want || name == "block.fire.extinguish" {
self.guest_player_sounds.push(entry);
} else {
keep.push(entry);
}
}
*capture = keep;
}
if let Some(kind) = mob {
if let Some(id) = self.spawn_mob_at(kind, (target[0], target[1], target[2]), true) {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
@@ -12527,29 +12736,17 @@ impl Game<'_> {
20.0,
0.0625,
);
let trash = Self::creative_trash_hovered(batch, self.cursor);
let (tx, ty) = Creative::TRASH_POSITION;
if trash {
self.draw_slot_highlight(batch, false, ox + tx, oy + ty);
}
self.draw_hovered_slot_highlight(batch, false, ox, oy);
self.draw_inventory_slots(batch, ox, oy);
if Self::creative_trash_hovered(batch, self.cursor) {
let (x, y) = Creative::TRASH_POSITION;
batch.fill(
&self.gui.atlas,
ox + x,
oy + y,
16.0,
16.0,
rgba(255, 255, 255, 128),
);
}
if let Some(slot) = self.slot_at(batch, self.cursor.0, self.cursor.1) {
let (x, y) = self.slot_position(slot);
batch.fill(
&self.gui.atlas,
ox + x,
oy + y,
16.0,
16.0,
rgba(255, 255, 255, 128),
);
if trash {
self.draw_slot_highlight(batch, true, ox + tx, oy + ty);
}
self.draw_hovered_slot_highlight(batch, true, ox, oy);
} else {
let items = self.creative_screen.items(&self.pack.names);
let first = self.creative_screen.first_row(&self.pack.names);
@@ -12559,13 +12756,16 @@ impl Game<'_> {
continue;
};
let (x, y) = (ox + 9.0 + c as f32 * 18.0, oy + 18.0 + r as f32 * 18.0);
self.draw_item(batch, stack, x, y);
if self.cursor.0 >= x
let over = self.cursor.0 >= x
&& self.cursor.0 < x + 16.0
&& self.cursor.1 >= y
&& self.cursor.1 < y + 16.0
{
batch.fill(&self.gui.atlas, x, y, 16.0, 16.0, rgba(255, 255, 255, 128));
&& self.cursor.1 < y + 16.0;
if over {
self.draw_slot_highlight(batch, false, x, y);
}
self.draw_item(batch, stack, x, y);
if over {
self.draw_slot_highlight(batch, true, x, y);
hovered = Some((stack.clone(), true));
}
}
@@ -12573,13 +12773,16 @@ impl Game<'_> {
let drag = self.drag_view();
for c in 0..9 {
let (x, y) = (ox + 9.0 + c as f32 * 18.0, oy + 112.0);
self.draw_slot_item(batch, drag.as_ref(), c, &self.inventory[c], x, y);
if self.cursor.0 >= x
let over = self.cursor.0 >= x
&& self.cursor.0 < x + 16.0
&& self.cursor.1 >= y
&& self.cursor.1 < y + 16.0
{
batch.fill(&self.gui.atlas, x, y, 16.0, 16.0, rgba(255, 255, 255, 128));
&& self.cursor.1 < y + 16.0;
if over {
self.draw_slot_highlight(batch, false, x, y);
}
self.draw_slot_item(batch, drag.as_ref(), c, &self.inventory[c], x, y);
if over {
self.draw_slot_highlight(batch, true, x, y);
hovered = self.inventory[c].clone().map(|s| (s, false));
}
}
@@ -12596,6 +12799,7 @@ impl Game<'_> {
oy + Creative::SCROLL_Y + self.creative_screen.scroller_offset(),
);
}
let effect_tip = self.draw_inventory_effects(batch, (ox, oy), Creative::WIDTH as i32);
for t in &tabs {
let (tx, ty) = Creative::tab_position(t);
if self.cursor.0 >= ox + tx
@@ -12618,9 +12822,9 @@ impl Game<'_> {
// `inventory.binSlot`, not an English literal.
let destroy = self.translate("inventory.binSlot", "Destroy Item");
self.tooltip(batch, &destroy, self.cursor.0, self.cursor.1);
} else if self.carried.is_none()
&& let Some(slot) = self.slot_at(batch, self.cursor.0, self.cursor.1)
} else if let Some(slot) = self.slot_at(batch, self.cursor.0, self.cursor.1)
&& let Some(stack) = self.slot_view(slot)
&& self.tooltip_with_carried(&stack)
{
let tabs = self.creative_tooltip_tabs(&stack, false);
self.draw_stack_tooltip_with(
@@ -12632,14 +12836,16 @@ impl Game<'_> {
tabs,
);
}
} else if self.carried.is_none()
&& let Some((item, grid)) = hovered
} else if let Some((item, grid)) = hovered
&& self.tooltip_with_carried(&item)
{
let tabs = self.creative_tooltip_tabs(&item, grid);
// A `CustomCreativeSlot`: TooltipFlag.asCreative.
self.draw_stack_tooltip_with(batch, &item, grid, self.cursor.0, self.cursor.1, tabs);
}
self.draw_inventory_effects(batch, (ox, oy), Creative::WIDTH as i32);
if let Some(lines) = effect_tip {
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
}
}
fn creative_trash_hovered(batch: &HudBatch, cursor: (f32, f32)) -> bool {
let (ox, oy) = Self::creative_origin(batch);
@@ -12845,7 +13051,11 @@ impl Game<'_> {
self.save_warden_tracker();
self.sync_level();
self.store_owner();
self.level.write(&self.world_root)?;
if self.options.dedicated.is_some() {
self.level.write_without_player(&self.world_root)?;
} else {
self.level.write(&self.world_root)?;
}
self.save_world_state();
self.message = Some(("Saved the game".into(), Instant::now()));
Ok(())
@@ -13187,6 +13397,7 @@ impl Game<'_> {
Vec::new()
};
let mut hud = horizon_client::spans::time("hud", || self.hud(size));
self.flush_ime(&mut hud);
// LevelLoadingScreen (Reason.OTHER) draws the title's panorama.
let title = self.title.is_some()
|| self.loading_reason() == Some(horizon_client::level_loading::Reason::Other)
@@ -13279,7 +13490,12 @@ impl Game<'_> {
let screen = if title {
Vec::new()
} else {
self.screen_quads(alpha as f32, size)
let scale = self
.player
.as_mut()
.and_then(|p| p.attribute("minecraft:scale").ok())
.unwrap_or(1.0) as f32;
self.screen_quads(alpha as f32, size, scale)
};
if !title {
self.end_geometry(camera, &mut entities);
@@ -13421,6 +13637,21 @@ impl Game<'_> {
effects: &requested_post_effects,
root: &self.options.resources,
menu_blur_radius: self.settings.menu_blur as f32,
// GlobalSettingsUniform.update.
globals: horizon_client::post_chain::Globals {
camera_block_pos: [
camera.x.floor() as i32,
camera.y.floor() as i32,
camera.z.floor() as i32,
],
camera_offset: [
(camera.x.floor() - camera.x) as f32,
(camera.y.floor() - camera.y) as f32,
(camera.z.floor() - camera.z) as f32,
],
game_time: ((self.world.game_time % 24000) as f32 + alpha as f32) / 24000.0,
..Default::default()
},
},
chunk_fade: self.settings.chunk_fade,
};
@@ -13582,7 +13813,7 @@ impl Game<'_> {
}
return Ok(());
}
if self.sort_key_press(key) {
if self.sort_key_press(key) || self.hdr_key_press(key) {
return Ok(());
}
if self.remote.is_some() && self.remote_key(key)? {
@@ -13782,10 +14013,10 @@ impl Game<'_> {
.button(3, pressed, false, self.settings.toggle_use);
self.right = self.toggles.is_down(3);
// handleKeybinds consumes use clicks without acting while an
// item is in use.
// item is in use. Minecraft.tick runs them after the attack
// clicks, so a place and a break in one tick replace the block.
if pressed && self.using.is_none() {
self.use_with_hand()?;
self.use_delay = 4;
self.use_clicks += 1;
}
}
MouseButton::Middle if pressed => {
@@ -13870,8 +14101,9 @@ impl Game<'_> {
return;
}
self.lod_source_attempted = true;
let dim = self.dim;
let source = match &self.integrated {
Some(server) => server.call(|game| game.streaming.lod_source()),
Some(server) => server.call(move |game| game.lod_source_in(dim)).flatten(),
None => Some(self.streaming.lod_source()),
};
if let Some(source) = source {
@@ -13964,7 +14196,8 @@ impl Game<'_> {
let g = &self.settings.horizon;
let budgeted = g.chunk_updates > 0 || g.upload_budget_kib > 0;
// The meshes, once copied, are freed by a worker (`Streaming::free`).
if !budgeted && self.pending_uploads.is_empty() {
if !budgeted && self.pending_uploads.is_empty() && uploads.len() <= AUTO_UPLOADS_AT_ONCE
{
horizon_client::spans::time("upload", || {
for (key, mesh) in &uploads {
renderer.upload(*key, mesh);
@@ -14003,7 +14236,15 @@ impl Game<'_> {
(0, 0)
};
let (mut count, mut bytes) = (0, 0u64);
// Without the player's budgets, uploads stop once they took
// AUTO_UPLOAD_TIME of this frame (at least one goes): a burst of
// finished meshes spreads over frames, nearest first.
let auto = !budgeted;
let started = Instant::now();
while let Some((_, mesh)) = pending.last() {
if auto && count > 0 && started.elapsed() >= AUTO_UPLOAD_TIME {
break;
}
let size = ((mesh.solid.len() + mesh.translucent.len())
* std::mem::size_of::<horizon_client::mesher::TerrainVertex>())
as u64;
@@ -14106,6 +14347,8 @@ impl ApplicationHandler for Game<'_> {
}
}
self.window = Some(window);
self.ime_allowed = false;
self.ime_area = None;
self.apply_settings();
self.previous = Instant::now();
}
@@ -14319,6 +14562,14 @@ impl ApplicationHandler for Game<'_> {
}
self.begin_closing_save(true)?;
}
// Input method composition: the committed text types like a key's text.
WindowEvent::Ime(winit::event::Ime::Commit(text)) => self.ime_commit(&text),
WindowEvent::Ime(winit::event::Ime::Preedit(text, cursor)) => {
self.ime_preedit(&text, cursor)
}
WindowEvent::Ime(winit::event::Ime::Disabled) => {
horizon_client::preedit::set_preedit(None)
}
WindowEvent::Resized(size) => {
if let Some(r) = &mut self.renderer {
r.resize(size.width, size.height);
@@ -14330,6 +14581,11 @@ impl ApplicationHandler for Game<'_> {
self.focus_lost = (!focused).then(Instant::now);
if !focused && self.control.is_none() {
self.keys.clear();
// Latched sneak/sprint live in `toggles`, not in the
// physical keys: put them back so a short alt-tab
// (no pause screen) does not drop them.
self.sync_toggle(0);
self.sync_toggle(1);
self.left = false;
self.right = false;
self.capture(false);
@@ -14360,6 +14616,7 @@ impl ApplicationHandler for Game<'_> {
}
if self.screen == Screen::Inventory {
self.station_drag();
self.recipe_book_drag();
}
if matches!(self.screen, Screen::Inventory | Screen::Creative) {
self.inventory_drag_move();
@@ -14585,7 +14842,7 @@ impl ApplicationHandler for Game<'_> {
// Neither edge of a bound button is an ordinary slot click.
return Ok(());
}
if pressed && self.sort_mouse_press(button) {
if pressed && (self.sort_mouse_press(button) || self.hdr_mouse_press(button)) {
return Ok(());
}
match self.screen {
@@ -14642,6 +14899,7 @@ impl ApplicationHandler for Game<'_> {
Screen::Inventory => {
self.inventory_mouse_release(button == MouseButton::Right);
self.station_release();
self.recipe_book_release();
}
Screen::Chat if pressed && button == MouseButton::Left => self.chat_click(),
Screen::Creative if pressed => {
@@ -14951,6 +15209,7 @@ impl ApplicationHandler for Game<'_> {
}
WindowEvent::RedrawRequested => {
self.observe_fullscreen();
self.sync_ime();
let now = Instant::now();
let frame_cpu = self
.options
@@ -15318,6 +15577,7 @@ impl<'a> Game<'a> {
pending_entry: None,
world_loader: None,
pending_pack_reload: false,
pack_folder: None,
pack_reload: None,
server_packs: Default::default(),
link_prompt: None,
@@ -15389,6 +15649,8 @@ impl<'a> Game<'a> {
keyboard_mouse: true,
view_at: [0, 0],
focused: true,
ime_allowed: false,
ime_area: None,
focus_lost: None,
modifiers: winit::keyboard::ModifiersState::empty(),
fullscreen: horizon_client::window::FullscreenState::new(false),
@@ -15437,6 +15699,10 @@ impl<'a> Game<'a> {
);
music
},
// ToastManager.initializeMusicToast: Pause and Pause and Toast make the toast.
now_playing: (settings.file.int("musicToast").clamp(0, 2) != 0).then(Default::default),
music_toast_state: settings.file.int("musicToast").clamp(0, 2) as u8,
music_notes: Default::default(),
ambience: Default::default(),
sound_paused: false,
cart_sounds: HashSet::new(),
@@ -15504,6 +15770,7 @@ impl<'a> Game<'a> {
hands_replaced: [false; 2],
clicks: Default::default(),
attack_clicks: 0,
use_clicks: 0,
hand_strength: Default::default(),
settings,
tutorial,
@@ -15642,10 +15909,12 @@ impl<'a> Game<'a> {
serving: None,
world_options: None,
options_to_world_options: false,
options_to_game: false,
command_block_screen: None,
block_editor: None,
structure_templates: HashMap::new(),
test_defs: std::cell::RefCell::new(HashMap::new()),
test_highlights: HashMap::new(),
game_tests: game_tests::GameTests::default(),
friends: None,
debug_keys: Default::default(),
@@ -15830,10 +16099,13 @@ impl Game<'_> {
}
self.perf.world_opening();
if self.title.is_none() {
self.title = Some(Title::new(
std::env::current_dir()?.join("saves"),
&self.options.resources,
));
self.title = Some(
Title::new(
std::env::current_dir()?.join("saves"),
&self.options.resources,
)
.with_user(|| self.options.player_name()),
);
}
self.pending_entry = Some(entry);
log::info!("Horizon: world entry waiting for world resources");
@@ -16735,10 +17007,13 @@ fn start_game<'a>(
} else {
// Start after the window is handed over, so --world uses the
// same responsive initialization path as the title screen.
game.title = Some(Title::new(
std::env::current_dir()?.join("saves"),
&game.options.resources,
));
game.title = Some(
Title::new(
std::env::current_dir()?.join("saves"),
&game.options.resources,
)
.with_user(|| game.options.player_name()),
);
game.pending_entry = Some(PendingWorldEntry::Local {
folder,
name: None,
@@ -16753,7 +17028,8 @@ fn start_game<'a>(
let mut title = Title::new(
std::env::current_dir()?.join("saves"),
&game.options.resources,
);
)
.with_user(|| game.options.player_name());
title.last_server = game.settings.file.string("lastServer");
title.skip_multiplayer_warning = game.settings.file.bool("skipMultiplayerWarning");
// Gui.addInitialScreens: the accessibility onboarding comes first.
@@ -17516,7 +17792,7 @@ mod tests {
use super::*;
use horizon_client::container_screen::{Button, Clicks, Release, Target};
/// Held items apply their main-hand modifiers to the player (#4118).
/// Held items apply their main-hand and off-hand modifiers to the player (#4118, #4777).
#[test]
fn equipment_modifiers_follow_their_slot_group() {
assert!(equipment_modifier_applies("mainhand", "mainhand"));
@@ -17526,6 +17802,12 @@ mod tests {
assert!(!equipment_modifier_applies("mainhand", "head"));
assert!(equipment_modifier_applies("armor", "chest"));
assert!(!equipment_modifier_applies("hand", "feet"));
assert!(equipment_modifier_applies("offhand", "offhand"));
assert!(equipment_modifier_applies("hand", "offhand"));
assert!(equipment_modifier_applies("any", "offhand"));
assert!(!equipment_modifier_applies("armor", "offhand"));
assert!(!equipment_modifier_applies("mainhand", "offhand"));
assert!(!equipment_modifier_applies("offhand", "mainhand"));
}
use horizon_client::keymapping::{KeyAction, SPRINT, ToggleKeys};
+35 -11
View File
@@ -1711,19 +1711,43 @@ impl Game<'_> {
let atlas = &self.gui.atlas;
batch.sprite(atlas, "gui/sprites/toast/advancement", x, y);
let (key, color) = match frame {
Frame::Task => ("advancements.toast.task", 0xFF00_FFFF),
Frame::Goal => ("advancements.toast.goal", 0xFF00_FFFF),
Frame::Task => ("advancements.toast.task", 0xFFFF_FF00),
Frame::Goal => ("advancements.toast.goal", 0xFFFF_FF00),
Frame::Challenge => ("advancements.toast.challenge", 0xFFFF_88FF),
};
batch.text_styled(
atlas,
&Text::translate(key, &[]),
x + 30.0,
y + 7.0,
color,
false,
);
batch.text_styled(atlas, &title, x + 30.0, y + 18.0, u32::MAX, false);
// AdvancementToast.extractRenderState: a title of one line sits under
// the frame's label; a longer one is wrapped at 125, the label fading
// out first (to 1.5 s) and the lines fading in after it over 0.3 s.
let lines = atlas.font.split(&title.resolve_legacy(), 125.0);
let label = Text::translate(key, &[]);
let rgb = color & 0x00FF_FFFF;
if let [line] = lines.as_slice() {
batch.text_styled(atlas, &label, x + 30.0, y + 7.0, color, false);
batch.text_styled(atlas, line, x + 30.0, y + 18.0, u32::MAX, false);
} else if fully_for < 1500 {
let fade = ((1500 - fully_for) as f32 / 300.0).clamp(0.0, 1.0);
let alpha = (fade * 255.0).floor() as u32;
if alpha != 0 {
batch.text_styled(atlas, &label, x + 30.0, y + 11.0, alpha << 24 | rgb, false);
}
} else {
let fade = ((fully_for - 1500) as f32 / 300.0).clamp(0.0, 1.0);
let alpha = (fade * 252.0).floor() as u32;
if alpha != 0 {
let mut line_y = y + (16 - lines.len() as i32 * 9 / 2) as f32;
for line in &lines {
batch.text_styled(
atlas,
line,
x + 30.0,
line_y,
alpha << 24 | 0x00FF_FFFF,
false,
);
line_y += 9.0;
}
}
}
self.draw_item(batch, &ItemStack::new(&icon, 1), x + 8.0, y + 8.0);
}
}
+323 -153
View File
@@ -726,12 +726,6 @@ impl Game<'_> {
alpha: f64,
out: &mut Vec<Vertex>,
) {
let assets = &self.armor_stand_assets;
let sprites = &self.gui.atlas.sprites;
let uv_of = |name: &str| sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
let Some(wood) = uv_of("entity/armorstand/armorstand") else {
return;
};
for a in &self.armor_stands {
let entity_start = out.len();
// ArmorStand.shouldRenderAtSqrDistance: 256 times the box's mean
@@ -764,9 +758,6 @@ impl Game<'_> {
) {
continue;
}
let Some(model) = (if a.small { &assets.small } else { &assets.main }) else {
continue;
};
let p = glam::DVec3::new(a.position.x, a.position.y, a.position.z) - 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;
@@ -779,143 +770,220 @@ impl Game<'_> {
* glam::Mat4::from_rotation_y((180.0 - a.yaw + 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 mut poses = Poses::new(model);
// ArmorStandModel.setupAnim: the stand's pose and part visibility.
apply_pose(&mut poses, &a.pose);
// Item layers pose from the hidden arms too (`visible` only skips drawing).
let posed = poses.clone();
let mut hide = |name: &str| {
if poses.has(name) {
let pose = poses.get(name);
pose[6] = 0.0;
pose[7] = 0.0;
pose[8] = 0.0;
}
};
if !a.show_arms {
hide("left_arm");
hide("right_arm");
}
if a.no_base_plate {
hide("base_plate");
}
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,
);
let packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
// LivingEntityRenderer.submit: an invisible stand has no body render type, but its
// layers (held items, custom head, armour) are submitted all the same.
if !a.invisible {
self.armor_stand_vertices(a, base, packed, out);
self.cull_entity_vertices(out, entity_start);
}
}
/// `ArmorStandRenderer.submit` for stand `a` under `base` (its
/// `setupRotations`): the body, the hand and head items, the armour and
/// the wings, at packed light `packed`.
fn armor_stand_vertices(
&self,
a: &ArmorStand,
base: glam::Mat4,
packed: u32,
out: &mut Vec<Vertex>,
) {
let assets = &self.armor_stand_assets;
let sprites = &self.gui.atlas.sprites;
let uv_of = |name: &str| sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
let Some(wood) = uv_of("entity/armorstand/armorstand") else {
return;
};
let Some(model) = (if a.small { &assets.small } else { &assets.main }) else {
return;
};
let mut poses = Poses::new(model);
// ArmorStandModel.setupAnim: the stand's pose and part visibility.
apply_pose(&mut poses, &a.pose);
// Item layers pose from the hidden arms too (`visible` only skips drawing).
let posed = poses.clone();
let mut hide = |name: &str| {
if poses.has(name) {
let pose = poses.get(name);
pose[6] = 0.0;
pose[7] = 0.0;
pose[8] = 0.0;
}
};
if !a.show_arms {
hide("left_arm");
hide("right_arm");
}
if a.no_base_plate {
hide("base_plate");
}
// LivingEntityRenderer.submit: an invisible stand has no body render type, but its
// layers (held items, custom head, armour) are submitted all the same.
if !a.invisible {
horizon_client::mobs::render::draw(
model,
base,
&poses,
wood,
0xFFFF_FFFF,
packed,
0,
out,
);
}
self.armor_stand_items(a, model, base, &posed, packed, out);
// HumanoidArmorLayer: head, chest, legs, feet.
for (slot, layer) in [
(5usize, "helmet"),
(4, "chestplate"),
(3, "leggings"),
(2, "boots"),
] {
let Some(stack) = &a.equipment[slot] else {
continue;
};
let Some(asset) = self
.res
.items()
.get(&stack.item)
.and_then(|i| i.equip_asset.clone())
else {
continue;
};
let kind = if layer == "leggings" {
"humanoid_leggings"
} else {
"humanoid"
};
let Some(layers) = assets.equipment.get(&asset).and_then(|k| k.get(kind)) else {
continue;
};
let Some(part) = assets.layers.get(&(layer, a.small)) else {
continue;
};
// ArmorStandRenderer's HumanoidArmorLayer: the armour glint
// when `stack.hasFoil()`, as on a living entity.
let item_glint = self
.res
.items()
.enchantments
.props
.get(&stack.item)
.is_some_and(|p| p.glint);
let overlay = if stack.has_foil(item_glint) {
horizon_client::mobs::render::ARMOR_GLINT
} else {
0
};
// ArmorStandArmorModel.setupAnim: the same pose as the stand.
let mut layer_poses = Poses::new(part);
apply_pose(&mut layer_poses, &a.pose);
// EquipmentLayerRenderer: every layer of the asset, the leather overlay
// too; the shader glint goes on the first layer only (renderShaderGlint).
for (i, (texture, undyed)) in layers.iter().enumerate() {
let Some(uv) = uv_of(texture) else {
continue;
};
let color = layer_color(*undyed, Some(stack));
horizon_client::mobs::render::draw(
model,
part,
base,
&poses,
wood,
0xFFFF_FFFF,
&layer_poses,
uv,
color,
packed,
0,
if i == 0 { overlay } else { 0 },
out,
);
}
self.armor_stand_items(a, model, base, &posed, packed, out);
// HumanoidArmorLayer: head, chest, legs, feet.
for (slot, layer) in [
(5usize, "helmet"),
(4, "chestplate"),
(3, "leggings"),
(2, "boots"),
] {
let Some(stack) = &a.equipment[slot] else {
continue;
};
let Some(asset) = self
.res
.items()
.get(&stack.item)
.and_then(|i| i.equip_asset.clone())
else {
continue;
};
let kind = if layer == "leggings" {
"humanoid_leggings"
} else {
"humanoid"
};
let Some(layers) = assets.equipment.get(&asset).and_then(|k| k.get(kind)) else {
continue;
};
let Some(part) = assets.layers.get(&(layer, a.small)) else {
continue;
};
// ArmorStandRenderer's HumanoidArmorLayer: the armour glint
// when `stack.hasFoil()`, as on a living entity.
let item_glint = self
.res
.items()
.enchantments
.props
.get(&stack.item)
.is_some_and(|p| p.glint);
let overlay = if stack.has_foil(item_glint) {
horizon_client::mobs::render::ARMOR_GLINT
} else {
0
};
// ArmorStandArmorModel.setupAnim: the same pose as the stand.
let mut layer_poses = Poses::new(part);
apply_pose(&mut layer_poses, &a.pose);
// EquipmentLayerRenderer: every layer of the asset, the leather overlay
// too; the shader glint goes on the first layer only (renderShaderGlint).
for (i, (texture, undyed)) in layers.iter().enumerate() {
let Some(uv) = uv_of(texture) else {
continue;
};
let color = layer_color(*undyed, Some(stack));
horizon_client::mobs::render::draw(
part,
base,
&layer_poses,
uv,
color,
packed,
if i == 0 { overlay } else { 0 },
out,
);
}
// EquipmentLayerRenderer: the trim layer over the armour.
if let Some(trim) = assets
.trim_texture(&asset, kind, stack)
.and_then(|t| uv_of(&t.sprite()))
{
horizon_client::mobs::render::draw(
part,
base,
&layer_poses,
trim,
0xFFFF_FFFF,
packed,
overlay,
out,
);
}
// EquipmentLayerRenderer: the trim layer over the armour.
if let Some(trim) = assets
.trim_texture(&asset, kind, stack)
.and_then(|t| uv_of(&t.sprite()))
{
horizon_client::mobs::render::draw(
part,
base,
&layer_poses,
trim,
0xFFFF_FFFF,
packed,
overlay,
out,
);
}
// WingsLayer: ArmorStandRenderer adds it after the armour layers. A stand
// never crouches or glides, so its wings rest folded.
self.wings_geometry(
a.equipment[4].as_ref().map(|s| &*s.item),
None,
a.small,
base,
horizon_client::mobs::ElytraAnimation::RESTING,
false,
packed,
self.wings_overlay(a.equipment[4].as_ref()),
out,
);
self.cull_entity_vertices(out, entity_start);
}
// WingsLayer: ArmorStandRenderer adds it after the armour layers. A stand
// never crouches or glides, so its wings rest folded.
self.wings_geometry(
a.equipment[4].as_ref().map(|s| &*s.item),
None,
a.small,
base,
horizon_client::mobs::ElytraAnimation::RESTING,
false,
packed,
self.wings_overlay(a.equipment[4].as_ref()),
out,
);
}
/// `SmithingScreen`'s armour stand: `updateArmorStandPreview` puts the
/// result on a stand without its base plate and with its arms, turned to
/// 210 degrees, and `extractEntityInInventory` draws it at scale 25 in
/// the box (x0, y0) to (x1, y1), moved up 1 and tilted by `ARMOR_STAND_ANGLE`.
pub(super) fn draw_smithing_armor_stand(
&self,
batch: &mut horizon_client::hud::HudBatch,
result: Option<&ItemStack>,
[x0, y0, x1, y1]: [f32; 4],
) {
let mut stand = ArmorStand::new(0, Vec3::ZERO, 210.0);
stand.no_base_plate = true;
stand.show_arms = true;
if let Some(stack) = result {
let equip = self
.res
.items()
.get(&stack.item)
.and_then(|i| i.equip_slot.clone());
stand.equipment[preview_slot(equip.as_deref())] = Some(stack.clone());
}
// LivingEntityRenderer: setupRotations (180 - body yaw), the flip and
// the 1.501 drop, at full light.
let base = glam::Mat4::from_rotation_y((180.0 - stand.yaw).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 mut verts = Vec::new();
self.armor_stand_vertices(&stand, base, 240 | 240 << 8, &mut verts);
// ARMOR_STAND_ANGLE: rotationXYZ(0.43633232, 0, PI).
let gui =
glam::Mat4::from_translation(glam::Vec3::new((x0 + x1) / 2.0, (y0 + y1) / 2.0, 0.0))
* glam::Mat4::from_scale(glam::Vec3::new(25.0, 25.0, -25.0))
* glam::Mat4::from_translation(glam::Vec3::new(0.0, 1.0, 0.0))
* glam::Mat4::from_rotation_x(0.436_332_32)
* glam::Mat4::from_rotation_z(std::f32::consts::PI);
let quads =
super::perspective::player_preview_quads(verts, gui, None, &self.gui.atlas.sprites);
let start = batch.vertices.len();
for (_, p, uv, color) in quads {
batch.quad_3d(p, uv, color);
}
batch.depth_since(start, [x0, y0, x1, y1]);
}
}
/// The stand's slot (`ArmorStand::SLOTS` order) for an item whose
/// `equippable` slot is `equip` (`SmithingScreen.updateArmorStandPreview`):
/// worn in a head, chest, legs or feet slot, else (no `equippable`, or any
/// other slot: main hand, off hand, body, saddle) held in the left hand.
fn preview_slot(equip: Option<&str>) -> usize {
match equip {
Some("feet") => 2,
Some("legs") => 3,
Some("chest") => 4,
Some("head") => 5,
_ => 1,
}
}
/// The `Pose` rotations on the parts of an armor stand model or its armor
@@ -954,6 +1022,52 @@ fn overlaps(a: Aabb, b: Aabb) -> bool {
);
a0.x < b1.x && b0.x < a1.x && a0.y < b1.y && b0.y < a1.y && a0.z < b1.z && b0.z < a1.z
}
/// What `Equippable.swapWithEquipmentSlot` leaves once it has passed its
/// checks: the piece worn in the slot, the hand, and the old piece bound for
/// the inventory.
#[derive(Debug, PartialEq)]
struct ArmorSwap {
worn: ItemStack,
hand: Option<ItemStack>,
to_inventory: Option<ItemStack>,
}
/// `Equippable.swapWithEquipmentSlot` after its checks. A single item swaps
/// with the worn piece: the hand gets the worn piece (`copyAndClear`), or
/// keeps the item in creative when nothing was worn; creative wears a copy
/// (`copy` instead of `copyAndClear`). From a larger stack one item is worn
/// (`consumeAndReturn`, which takes none in creative) and the worn piece goes
/// to the inventory.
fn swap_with_equipment_slot(
stack: &ItemStack,
current: Option<ItemStack>,
creative: bool,
) -> ArmorSwap {
let one = ItemStack {
count: 1,
..stack.clone()
};
if stack.count <= 1 {
let hand = match current {
Some(old) => Some(old),
None if creative => Some(stack.clone()),
None => None,
};
return ArmorSwap {
worn: one,
hand,
to_inventory: None,
};
}
let mut held = stack.clone();
if !creative {
held.count -= 1;
}
ArmorSwap {
worn: one,
hand: Some(held),
to_inventory: current,
}
}
impl Game<'_> {
/// `Equippable.swapWithEquipmentSlot`: right-clicking armor swaps it into
/// its slot (the worn piece comes back to the hand).
@@ -979,28 +1093,19 @@ impl Game<'_> {
}) {
return true;
}
let equipped = ItemStack {
count: 1,
..stack.clone()
};
self.armor[index] = Some(equipped.clone());
let swap = swap_with_equipment_slot(stack, current, self.creative);
self.armor[index] = Some(swap.worn.clone());
// Player.setItemSlot: onEquipItem plays the equip sound.
self.player_equipped(slot, Some(&equipped));
if !self.creative {
let mut held = stack.clone();
held.count -= 1;
if held.count == 0 {
// The worn piece comes back to the hand.
*self.held_mut() = current;
} else {
*self.held_mut() = Some(held);
if let Some(old) = current
&& let Some(rest) = self.give(old)
&& let Some(eye) = self.eye_position()
{
self.spawn_item(rest, eye, [0.0; 3], 40);
}
}
self.player_equipped(slot, Some(&swap.worn));
*self.held_mut() = swap.hand;
// Inventory.add, else Player.drop (in creative a full inventory
// takes it anyway: hasInfiniteMaterials empties the stack).
if let Some(old) = swap.to_inventory
&& let Some(rest) = self.give(old)
&& !self.creative
&& let Some(eye) = self.eye_position()
{
self.spawn_item(rest, eye, [0.0; 3], 40);
}
self.apply_equipment();
self.swing_arm();
@@ -1163,6 +1268,52 @@ fn broken_armor_stand_equipment(a: ArmorStand) -> ([i32; 3], Vec<ItemStack>) {
(pos, drops)
}
#[cfg(test)]
mod swap_tests {
use super::*;
fn stack(item: &str, count: u32) -> ItemStack {
ItemStack::new(item, count)
}
#[test]
fn creative_swap_gives_the_worn_piece_to_the_hand() {
let swap = swap_with_equipment_slot(
&stack("minecraft:diamond_chestplate", 1),
Some(stack("minecraft:iron_chestplate", 1)),
true,
);
assert_eq!(swap.worn, stack("minecraft:diamond_chestplate", 1));
assert_eq!(swap.hand, Some(stack("minecraft:iron_chestplate", 1)));
assert_eq!(swap.to_inventory, None);
}
#[test]
fn creative_equip_with_nothing_worn_keeps_the_hand() {
let swap = swap_with_equipment_slot(&stack("minecraft:diamond_helmet", 1), None, true);
assert_eq!(swap.worn, stack("minecraft:diamond_helmet", 1));
assert_eq!(swap.hand, Some(stack("minecraft:diamond_helmet", 1)));
}
#[test]
fn survival_swap_gives_the_worn_piece_or_empties_the_hand() {
let worn = Some(stack("minecraft:iron_boots", 1));
let swap = swap_with_equipment_slot(&stack("minecraft:diamond_boots", 1), worn, false);
assert_eq!(swap.hand, Some(stack("minecraft:iron_boots", 1)));
let swap = swap_with_equipment_slot(&stack("minecraft:diamond_boots", 1), None, false);
assert_eq!(swap.hand, None);
}
#[test]
fn a_larger_stack_wears_one_and_sends_the_old_piece_to_the_inventory() {
// Armour stacks past one only with a max_stack_size component.
let worn = Some(stack("minecraft:leather_helmet", 1));
let swap =
swap_with_equipment_slot(&stack("minecraft:golden_helmet", 3), worn.clone(), false);
assert_eq!(swap.worn, stack("minecraft:golden_helmet", 1));
assert_eq!(swap.hand, Some(stack("minecraft:golden_helmet", 2)));
assert_eq!(swap.to_inventory, worn);
let swap =
swap_with_equipment_slot(&stack("minecraft:golden_helmet", 3), worn.clone(), true);
assert_eq!(swap.hand, Some(stack("minecraft:golden_helmet", 3)));
assert_eq!(swap.to_inventory, worn);
}
}
#[cfg(test)]
mod burn_drop_tests {
use super::*;
#[test]
@@ -1194,6 +1345,25 @@ mod burn_drop_tests {
#[cfg(test)]
mod layer_color_tests {
use super::*;
#[test]
fn the_smithing_preview_wears_armour_and_holds_the_rest_in_its_off_hand() {
assert_eq!(preview_slot(Some("head")), 5);
assert_eq!(preview_slot(Some("chest")), 4);
assert_eq!(preview_slot(Some("legs")), 3);
assert_eq!(preview_slot(Some("feet")), 2);
// A netherite sword has no equippable slot; a saddle or a
// main-hand item falls to the same default branch.
for other in [
None,
Some("mainhand"),
Some("offhand"),
Some("body"),
Some("saddle"),
] {
assert_eq!(preview_slot(other), 1);
}
}
#[test]
fn a_dyed_stack_overrides_the_undyed_leather_colour() {
let mut dyed = ItemStack::new("minecraft:leather_helmet", 1);
+5 -9
View File
@@ -562,27 +562,23 @@ impl Game<'_> {
"gui/sprites/container/beacon/cancel",
);
let slots: Vec<usize> = (0..36).chain([INPUT]).collect();
self.draw_hovered_slot_highlight(batch, false, ox, oy);
for i in slots {
let (sx, sy) = self.slot_position(i);
if let Some(stack) = self.slot_mut(i).clone() {
self.draw_item(batch, &stack, ox + sx, oy + sy);
}
}
self.draw_hovered_slot_highlight(batch, true, ox, oy);
let mut stack_tip = None;
if let Some(slot) = self.slot_at(batch, self.cursor.0, self.cursor.1) {
let (sx, sy) = self.slot_position(slot);
batch.fill(
&self.gui.atlas,
ox + sx,
oy + sy,
16.0,
16.0,
rgba(255, 255, 255, 128),
);
stack_tip = self.slot_mut(slot).clone();
}
if self.carried.is_some() {
self.draw_carried(batch);
if let Some(stack) = stack_tip.filter(|s| self.tooltip_with_carried(s)) {
self.draw_stack_tooltip(batch, &stack, self.cursor.0, self.cursor.1);
}
} else if let Some(stack) = stack_tip {
self.draw_stack_tooltip(batch, &stack, self.cursor.0, self.cursor.1);
} else if let Some(key) = tooltip {
+62 -20
View File
@@ -16,9 +16,11 @@ use horizon_client::{
},
};
use horizon_core::{
geometry::{Aabb, Vec3},
geometry::{Aabb, ShapeOp, Vec3, VoxelShape},
position::BlockPos,
random::RandomSource,
};
use horizon_world::collision_context::CollisionContext;
use std::{collections::HashMap, f32::consts::PI, sync::Arc};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -339,14 +341,37 @@ impl Game<'_> {
if in_water {
return Status::InWater;
}
// getGroundFriction over the blocks just below the hull (corners skipped).
let y0 = (lo.y - 0.001).floor() as i32;
let (x0, x1, z0, z1) = (
lo.x.floor() as i32 - 1,
hi.x.ceil() as i32 + 1,
lo.z.floor() as i32 - 1,
hi.z.ceil() as i32 + 1,
let friction = self.boat_ground_friction(bb);
if friction > 0.0 {
b.land_friction = friction;
Status::OnLand
} else {
Status::InAir
}
}
/// `AbstractBoat.getGroundFriction`: average friction of the blocks whose
/// collision shape meets a thin slab just under the hull (lily pads skipped).
fn boat_ground_friction(&self, bb: Aabb) -> f32 {
let (lo, hi) = (bb.min_position(), bb.max_position());
let ground = Aabb::new(
Vec3::new(lo.x, lo.y - 0.001, lo.z),
Vec3::new(hi.x, lo.y, hi.z),
);
let (g_lo, g_hi) = (ground.min_position(), ground.max_position());
let (x0, x1, y0, y1, z0, z1) = (
g_lo.x.floor() as i32 - 1,
g_hi.x.ceil() as i32 + 1,
g_lo.y.floor() as i32 - 1,
g_hi.y.ceil() as i32 + 1,
g_lo.z.floor() as i32 - 1,
g_hi.z.ceil() as i32 + 1,
);
let slab = VoxelShape::from_box(ground).unwrap_or_else(|_| VoxelShape::empty());
let mut scene = SceneCollisions {
world: &self.world,
binding: self.collision,
subject: None,
};
let (mut friction, mut count) = (0.0f32, 0);
for x in x0..x1 {
for z in z0..z1 {
@@ -354,22 +379,39 @@ impl Game<'_> {
if edges == 2 {
continue;
}
let inside = f64::from(x) < hi.x
&& f64::from(x + 1) > lo.x
&& f64::from(z) < hi.z
&& f64::from(z + 1) > lo.z;
let f = self.res.facts().get(self.world.block(x, y0, z));
if inside && (f.full_collision || f.legacy_solid) && !f.has_class("LilyPadBlock") {
friction += f.friction;
count += 1;
for y in y0..y1 {
if edges == 1 && (y == y0 || y == y1 - 1) {
continue;
}
let state = self.world.block(x, y, z);
let f = self.res.facts().get(state);
if f.name.as_ref() == "minecraft:air" || f.has_class("LilyPadBlock") {
continue;
}
let Ok(shape) = self.collision.collision_shape_with_context(
&mut scene,
state,
BlockPos::new(x, y, z),
&CollisionContext::empty(false),
) else {
continue;
};
let world_shape =
shape.moved(Vec3::new(f64::from(x), f64::from(y), f64::from(z)));
if world_shape
.join_is_not_empty(&slab, ShapeOp::AND)
.unwrap_or(false)
{
friction += f.friction;
count += 1;
}
}
}
}
if count > 0 && friction > 0.0 {
b.land_friction = friction / count as f32;
Status::OnLand
if count > 0 {
friction / count as f32
} else {
Status::InAir
0.0
}
}
/// `AbstractBoat.tick` for one boat (`controlled` when the player rides it).
+29 -2
View File
@@ -378,6 +378,11 @@ impl Game<'_> {
Some(KeyCode::PageDown) => self.turn_page(true),
Some(KeyCode::ArrowUp) => self.book_move_line(-1),
Some(KeyCode::ArrowDown) => self.book_move_line(1),
// MultilineTextField.keyPressed: Home and End seek the caret's
// line; with Ctrl they go to the page's start and end.
Some(edge @ (KeyCode::Home | KeyCode::End)) if !self.has_control_down() => {
self.book_line_edge(edge == KeyCode::End)
}
Some(KeyCode::Enter | KeyCode::NumpadEnter) => self.page_insert("\n"),
Some(key) if self.book_page_key(key) => {}
_ => {
@@ -542,6 +547,26 @@ impl Game<'_> {
page_edit.set_cursor(pos, shift);
}
}
/// Home and End: the start or end of the visual line holding the caret
/// (`getCursorLineView`'s beginIndex and endIndex).
fn book_line_edge(&mut self, end: bool) {
let shift = self.has_shift_down();
let (text, cursor) = match self.book.as_ref() {
Some(BookScreen::Edit {
pages,
page,
page_edit,
..
}) => (pages[*page].clone(), page_edit.cursor),
_ => return,
};
let rows = self.page_rows(&text);
let (_, line) = self.caret_on(&text, &rows, cursor);
let (start, stop) = rows[line];
if let Some(BookScreen::Edit { page_edit, .. }) = self.book.as_mut() {
page_edit.set_cursor(if end { stop } else { start }, shift);
}
}
/// Wrapped rows of a page as character ranges, the same breaks as `font.split`.
fn page_rows(&self, page: &str) -> Vec<(usize, usize)> {
let font = &self.gui.atlas.font;
@@ -1075,7 +1100,7 @@ impl Game<'_> {
self.turn_page(true);
} else if page > 0 && inside(left + 43.0, 159.0, 23.0, 13.0) {
self.turn_page(false);
} else if let Some(key) = lectern {
} else if let Some(key) = lectern.filter(|_| self.may_build()) {
if inside(w / 2.0 - 100.0, 196.0, 98.0, 20.0) {
click(self);
self.close_book();
@@ -1330,7 +1355,9 @@ impl Game<'_> {
}
}
page_buttons(batch, *page > 0, *page + 1 < pages.len());
if lectern.is_some() {
// LecternScreen.createMenuControls: Done and Take Book only with
// build permission, else BookViewScreen's wide Done.
if lectern.is_some() && self.may_build() {
button(batch, w / 2.0 - 100.0, 98.0, &tr("gui.done", "Done"), true);
button(
batch,
+8 -5
View File
@@ -26,12 +26,15 @@ pub(super) fn takes_bundle_wheel(screen: Screen) -> bool {
}
impl Game<'_> {
/// A slot a bundle can be selected in: the player's inventory, the off hand and a
/// container's own slots. A server only knows these in the inventory and chest-like menus.
/// A slot a bundle can be selected in: the player's inventory and off hand in any
/// menu (#4436: a crafting table's, an enchanting table's...), and a container's own
/// slots. A server only knows those in chest-like menus.
fn bundle_slot(&mut self, slot: usize) -> bool {
(slot < 36 || slot == 504 || (300..500).contains(&slot))
&& (self.remote.is_none() || self.remote_storage_open())
&& (slot < 36 || slot == 504 || self.slots().contains(&slot))
slot < 36
|| slot == 504
|| ((300..500).contains(&slot)
&& (self.remote.is_none() || self.remote_storage_open())
&& self.slots().contains(&slot))
}
/// `BundleMouseActions.onMouseScrolled`: the wheel over a bundle moves the selection
+9 -4
View File
@@ -1129,6 +1129,12 @@ impl Game<'_> {
let height = batch.height as i32;
let (cx, cy) = (f64::from(self.cursor.0), f64::from(self.cursor.1));
let wrap = (batch.width as i32 / 2).max(200);
// handleTagIcon: the chat-modified icons over the cursor or its message.
let (tag_tooltip, tag_icons) =
self.ui
.chat
.focused_tags(&self.gui.atlas.font, height, cx, cy);
horizon_client::hud_layout::paint(batch, &self.gui.atlas, &tag_icons);
if let Some(style) = self.ui.chat.style_at(&self.gui.atlas.font, height, cx, cy) {
// DrawingFocusedGraphicsAccess.handleMessage: the pointing hand over
// insertion text, which ChatScreen only asks for while Shift is held.
@@ -1156,10 +1162,9 @@ impl Game<'_> {
let lines = Self::entity_tooltip_lines(entity);
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
}
} else if self.ui.chat.indicator_at(height, cx, cy) {
// GuiMessageTag.systemSinglePlayer's text, split at 210.
let tag = Text::translate("chat.tag.system_single_player", &[]);
let lines = self.wrapped_rows(&tag, 210);
} else if let Some(tag) = &tag_tooltip {
// The message tag's text, split at 210.
let lines = self.wrapped_rows(tag, 210);
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
}
}
-3
View File
@@ -224,9 +224,6 @@ impl Game<'_> {
};
(d.name.clone(), *amount)
}
MotionEvent::Health(HealthEvent::StarvationDamage(amount)) => {
("minecraft:starve".to_owned(), *amount)
}
_ => continue,
};
let attacker = self
+9 -2
View File
@@ -14,6 +14,7 @@ pub(super) mod pregen;
mod profiles;
mod raid;
mod terrain_cmds;
mod test_locate;
mod test_runs;
mod text_commands;
mod world_cmds;
@@ -971,6 +972,8 @@ impl Game<'_> {
id if id.starts_with("posteffect.") => self.cmd_posteffect(id, ctx),
"kick" => self.cmd_kick(ctx),
"op" | "deop" | "ban" | "pardon" => self.cmd_profiles(id, ctx),
"ban-ip" | "pardon-ip" => self.cmd_ip_bans(id, ctx),
id if id.starts_with("banlist") => self.cmd_banlist(id),
id if id.starts_with("whitelist.") => self.cmd_whitelist(id, ctx),
"fetchprofile" => self.cmd_fetchprofile(ctx),
"publish" => self.cmd_publish(ctx),
@@ -2798,6 +2801,12 @@ impl Game<'_> {
&[name(i)],
)));
}
self.set_difficulty_forced(i);
self.feedback(Text::translate("commands.difficulty.success", &[name(i)]));
Ok(0)
}
/// `MinecraftServer.setDifficulty(difficulty, true)`.
pub(super) fn set_difficulty_forced(&mut self, i: u8) {
self.ui.difficulty = i;
// The level data holds the world's difficulty: it survives the
// reopen, so it follows the command.
@@ -2816,8 +2825,6 @@ impl Game<'_> {
locked: self.level.difficulty_locked,
}));
}
self.feedback(Text::translate("commands.difficulty.success", &[name(i)]));
Ok(0)
}
fn cmd_spawnpoint(&mut self, ctx: &Context) -> R<i32> {
let targets = if ctx.get("targets").is_some() {
@@ -674,36 +674,21 @@ impl Game<'_> {
// ---- /test ----------------------------------------------------------
/// `TestCommand`: running, resetting and clearing tests is
/// `test_runs`; `verify`, `create`, `locate` and `pos` are not ported and
/// answer as in a world without test instance blocks.
/// `test_runs`; `locate`, `pos` and `create` are `test_locate`.
pub(super) fn cmd_test(&mut self, id: &str, ctx: &Context) -> R<i32> {
match id {
"test.run" | "test.run.found" | "test.clear" | "test.stop" => {
self.cmd_test_runs(id, ctx)
}
// verify, create: not ported.
"test.verify" | "test.create" => Err(err("command.failed", &[])),
"test.locate" => {
// CommandSourceStack.sendSystemMessage: no feedback rule.
if !self.command_source.is_some_and(|s| s.silent) {
self.system_message(Text::translate("commands.test.locate.started", &[]));
}
Err(err("commands.test.error.no_test_instances", &[]))
}
_ => {
// TestCommand.showPos: the block the player looks at (10 blocks).
self.source_player()?;
let p = self.test_pick_block();
Err(err(
"commands.test.error.no_test_containing_pos",
&[&p[0], &p[1], &p[2]],
))
}
"test.verify" => self.test_verify(ctx),
"test.create" => self.test_create(ctx),
"test.locate" => self.test_locate(ctx),
_ => self.test_show_pos(ctx),
}
}
/// `Entity.pick(10, 1, false)`: the first non-air block on the view ray,
/// else the block at the ray's end.
fn test_pick_block(&self) -> [i32; 3] {
pub(super) fn test_pick_block(&self) -> [i32; 3] {
use horizon_core::{
geometry::{Vec3, traverse_blocks},
position::BlockPos,
@@ -24,6 +24,8 @@ pub(in crate::game) enum Slot {
Stand(u32, usize),
Frame(u32),
Block([i32; 3], usize),
/// A slot of another player (`for_player`): reached with their state in place.
Guest(u32, Box<Slot>),
}
/// Whose slots a command addresses.
#[derive(Clone, Debug, PartialEq)]
@@ -73,6 +75,16 @@ impl Game<'_> {
}
/// The slots a name (or wildcard group) addresses on an owner.
pub(in crate::game) fn slots_named(&mut self, owner: &Owner, name: &str) -> Vec<Slot> {
if let Owner::Entity(Target::Guest(conn)) = *owner {
let slots = self.for_player(Target::Guest(conn), |g| {
g.slots_named(&Owner::Entity(Target::Player), name)
});
return slots
.unwrap_or_default()
.into_iter()
.map(|s| Slot::Guest(conn, Box::new(s)))
.collect();
}
let mut out = Vec::new();
let mut add = |s: Slot| out.push(s);
match owner {
@@ -157,8 +169,11 @@ impl Game<'_> {
}
out
}
pub(in crate::game) fn slot_get(&self, s: &Slot) -> Option<ItemStack> {
pub(in crate::game) fn slot_get(&mut self, s: &Slot) -> Option<ItemStack> {
match s {
Slot::Guest(conn, slot) => self
.for_player(Target::Guest(*conn), |g| g.slot_get(slot))
.flatten(),
Slot::Inv(i) => self.inventory.get(*i).cloned().flatten(),
Slot::Armor(i) => self.armor.get(*i).cloned().flatten(),
Slot::Offhand => self.offhand.clone(),
@@ -201,6 +216,9 @@ impl Game<'_> {
/// Put a stack (or nothing) in a slot; false when the slot cannot take it.
pub(in crate::game) fn slot_set(&mut self, s: &Slot, stack: Option<ItemStack>) -> bool {
match s {
Slot::Guest(conn, slot) => self
.for_player(Target::Guest(*conn), |g| g.slot_set(slot, stack))
.unwrap_or(false),
Slot::Inv(i) => match self.inventory.get_mut(*i) {
Some(slot) => {
*slot = stack;
@@ -603,7 +621,7 @@ impl Game<'_> {
}
/// `ItemStack.getMaxStackSize`: the max_stack_size component, else the
/// item's.
pub(super) fn stack_max_size(&self, s: &ItemStack) -> u32 {
pub(in crate::game) fn stack_max_size(&self, s: &ItemStack) -> u32 {
if let Some(Tag::Int(n)) = s.data().and_then(|d| d.other("minecraft:max_stack_size")) {
return (*n).clamp(1, 99) as u32;
}
@@ -156,9 +156,12 @@ impl Game<'_> {
max_depth,
position: pos,
};
let placed = self.with_level(|b, level, ticks| {
let (placed, entities) = self.with_level(|b, level, ticks| {
b.place_jigsaw(level, ticks, pos, false, || terrain.jigsaw_pieces(&query))
});
for entity in entities {
self.spawn_entity_from_nbt(Tag::Compound(entity));
}
self.process_level_events()
.map_err(|_| err("command.failed", &[]))?;
if !placed {
@@ -182,9 +185,37 @@ impl Game<'_> {
{
return Err(err("commands.place.structure.invalid", &[&structure]));
}
self.place_pos(ctx, "pos")?;
// Structure.generate is not wired to the live level.
Err(err("command.failed", &[]))
let pos = self.place_pos(ctx, "pos")?;
// PlaceCommand.placeStructure: Structure.generate for the
// position's chunk with its `biome -> true` predicate, then
// placeInChunk for every chunk of the start's box.
let terrain = self
.end
.locator()
.ok_or_else(|| err("command.failed", &[]))?;
let start = terrain
.structure_start(&structure, pos[0] >> 4, pos[2] >> 4)
.ok_or_else(|| err("commands.place.structure.failed", &[]))?;
let (min, max) = (start.bounding_box.min, start.bounding_box.max);
self.place_check_loaded([min[0] >> 4, min[2] >> 4], [max[0] >> 4, max[2] >> 4])?;
let (placed, entities) =
self.with_level(|b, level, ticks| b.place_structure(level, ticks, &start));
for entity in entities {
self.spawn_entity_from_nbt(Tag::Compound(entity));
}
if !placed {
return Err(err("commands.place.structure.failed", &[]));
}
self.feedback(Text::translate(
"commands.place.structure.success",
&[
Text::literal(structure),
num(pos[0]),
num(pos[1]),
num(pos[2]),
],
));
Ok(1)
}
}
}
@@ -1,5 +1,6 @@
//! `/op`, `/deop`, `/ban`, `/pardon` and `/whitelist` (OpCommand,
//! DeOpCommands, BanPlayerCommands, PardonCommand, WhitelistCommand):
//! `/op`, `/deop`, `/ban`, `/pardon`, `/ban-ip`, `/pardon-ip`, `/banlist` and
//! `/whitelist` (OpCommand, DeOpCommands, BanPlayerCommands, PardonCommand,
//! BanIpCommands, PardonIpCommand, BanListCommands, WhitelistCommand):
//! commands of a dedicated server, run on its lists by an operator in game
//! (the console has its own handlers in game/dedicated.rs).
use super::{Arg, Context, Game, R, Target, err};
@@ -183,6 +184,147 @@ impl Game<'_> {
Ok(count)
}
/// `BanIpCommands.banIpOrName` and `PardonIpCommand.unban`: an address,
/// or for `/ban-ip` an online player's.
pub(super) fn cmd_ip_bans(&mut self, id: &str, ctx: &Context) -> R<i32> {
let lists = self.dedicated_lists()?;
let lit = |s: &str| Text::literal(s.to_owned());
let target = match ctx.get("target") {
Some(Arg::Str(t)) => t.clone(),
_ => return Err(err("command.unknown.argument", &[])),
};
let is_ip = target.parse::<std::net::IpAddr>().is_ok();
if id == "pardon-ip" {
if !is_ip {
return Err(err("commands.pardonip.invalid", &[]));
}
let removed = {
let mut l = lists.lock().unwrap();
l.ip_bans.get(&target).is_some() && l.ip_bans.remove(&target)
};
if !removed {
return Err(err("commands.pardonip.failed", &[]));
}
self.feedback(Text::translate(
"commands.pardonip.success",
&[lit(&target)],
));
return Ok(1);
}
// PlayerList.getPlayerByName, then ServerPlayer.getIpAddress.
let address = |name: &str| {
let hub = &self.lan.as_ref()?.hub;
hub.guests
.iter()
.find(|g| g.name.eq_ignore_ascii_case(name))
.map(|g| g.address.ip().to_string())
};
let ip = if is_ip {
Some(target)
} else {
address(&target)
};
let Some(ip) = ip else {
return Err(err("commands.banip.invalid", &[]));
};
if lists.lock().unwrap().ip_bans.get(&ip).is_some() {
return Err(err("commands.banip.failed", &[]));
}
let players: Vec<(u128, String)> = self
.lan
.as_ref()
.map(|h| {
h.hub
.guests
.iter()
.filter(|g| g.address.ip().to_string() == ip)
.map(|g| (g.uuid, g.name.clone()))
.collect()
})
.unwrap_or_default();
let reason = match ctx.get("reason") {
Some(Arg::Str(raw)) => {
let raw = raw.clone();
Some(self.expand_selectors(&raw).plain().to_string())
}
_ => None,
};
let source = self.source_text_name();
lists.lock().unwrap().ip_bans.add(BanEntry::new(
ip.clone(),
Some(source),
None,
reason.clone(),
));
self.feedback(Text::translate(
"commands.banip.success",
&[lit(&ip), Self::reason_message(reason.as_deref())],
));
if !players.is_empty() {
let names: Vec<&str> = players.iter().map(|(_, n)| n.as_str()).collect();
self.feedback(Text::translate(
"commands.banip.info",
&[lit(&players.len().to_string()), lit(&names.join(", "))],
));
}
for (uuid, _) in &players {
self.kick_uuid(*uuid, translatable("multiplayer.disconnect.ip_banned", &[]));
}
Ok(players.len() as i32)
}
/// `BanListCommands.showList`: the players' bans then the addresses'
/// (`banlist`), or one of them; told to the source only.
pub(super) fn cmd_banlist(&mut self, id: &str) -> R<i32> {
let lists = self.dedicated_lists()?;
let lit = |s: &str| Text::literal(s.to_owned());
let entries: Vec<(String, String, Option<String>)> = {
let l = lists.lock().unwrap();
let players = l
.bans
.entries()
.into_iter()
.map(|b| (b.user.name.clone(), b.source.clone(), b.reason.clone()));
let ips = l
.ip_bans
.entries()
.into_iter()
.map(|b| (b.user.clone(), b.source.clone(), b.reason.clone()));
match id {
"banlist.ips" => ips.collect(),
"banlist.players" => players.collect(),
_ => players.chain(ips).collect(),
}
};
if entries.is_empty() {
self.feedback_unbroadcast(Text::translate("commands.banlist.none", &[]));
return Ok(0);
}
self.feedback_unbroadcast(Text::translate(
"commands.banlist.list",
&[lit(&entries.len().to_string())],
));
for (name, source, reason) in &entries {
self.feedback_unbroadcast(Text::translate(
"commands.banlist.entry",
&[
lit(name),
lit(source),
Self::reason_message(reason.as_deref()),
],
));
}
Ok(entries.len() as i32)
}
/// `BanListEntry.getReasonMessage`.
fn reason_message(reason: Option<&str>) -> Text {
reason.map_or_else(
|| Text::translate("multiplayer.disconnect.banned.reason.default", &[]),
|r| Text::literal(r.to_owned()),
)
}
/// `WhitelistCommand`: `on`, `off`, `list`, `reload`, `add` and `remove`.
pub(super) fn cmd_whitelist(&mut self, id: &str, ctx: &Context) -> R<i32> {
let lists = self.dedicated_lists()?;
@@ -0,0 +1,353 @@
//! `TestCommand`'s `locate`, `pos` and `create`.
use super::{Arg, Context, Game, R, err};
use crate::game::test_instances::TestDef;
use horizon_client::{
containers::BlockEntity,
test_instance_block::{Status, TestInstanceBlock},
text::{self, ClickEvent, Style, Text},
};
use horizon_protocol::packets::play::{self, Clientbound as C};
use horizon_world::worldgen::structure::Rotation;
type Pos = (i32, i32, i32);
/// `Rotation.rotate(Direction.NORTH)` as (dx, dz) and the `toYRot` of the
/// opposite direction.
fn north_turned(rotation: Rotation) -> ((i32, i32), i32) {
match rotation {
Rotation::None => ((0, -1), 0),
Rotation::Clockwise90 => ((1, 0), 90),
Rotation::Clockwise180 => ((0, 1), 180),
Rotation::Counterclockwise90 => ((-1, 0), 270),
}
}
/// `String.format("/execute in %s run tp @s %d %d %d %d 0", ..)` of `locate`.
fn locate_command(dimension: &str, at: Pos, y_rot: i32) -> String {
format!(
"/execute in {dimension} run tp @s {} {} {} {y_rot} 0",
at.0, at.1, at.2
)
}
/// `Mth.floor(Mth.sqrt(dx * dx + dz * dz))` over ints.
fn horizontal_distance(dx: i32, dz: i32) -> i32 {
((dx.wrapping_mul(dx).wrapping_add(dz.wrapping_mul(dz))) as f32)
.sqrt()
.floor() as i32
}
/// The text of a clickable bracketed position.
fn link(inner: Text, color: u32, command: String) -> Text {
Text::literal("[")
.append(inner)
.append("]")
.with_style(&Style {
click: Some(ClickEvent::SuggestCommand(command)),
hover: Some(Box::new(Text::translate("chat.coordinates.tooltip", &[]))),
..Style::color(color)
})
}
fn path_of(dimension: &str) -> &str {
dimension.split_once(':').map_or(dimension, |(_, p)| p)
}
impl Game<'_> {
/// `TestCommand.locate`.
pub(super) fn test_locate(&mut self, ctx: &Context) -> R<i32> {
let selected: Vec<String> = match ctx.get("tests") {
Some(Arg::Str(pattern)) => horizon_client::command::args::select_resources(
self.ui.registries.ids("minecraft:test_instance"),
pattern,
)
.into_iter()
.cloned()
.collect(),
_ => Vec::new(),
};
// CommandSourceStack.sendSystemMessage: no feedback rule.
if !self.command_source.is_some_and(|s| s.silent) {
self.system_message(Text::translate("commands.test.locate.started", &[]));
}
let center = self.test_source_block();
let source_dim = self.dim;
// findSelectedTestPositionsAcrossDimensions: every level's test
// blocks within 250 blocks whose test was selected.
let mut found: Vec<(Pos, String, Rotation)> = Vec::new();
if !selected.is_empty() {
for dim in self.loaded_dims() {
let back = self.enter_command_level(dim);
for pos in self.find_test_instance_blocks(center, 250) {
let key = self.test_instance_at(pos).and_then(|b| b.data.test.clone());
let key = key.map(|k| {
if k.contains(':') {
k
} else {
format!("minecraft:{k}")
}
});
if key.is_some_and(|k| selected.contains(&k)) {
found.push((pos, dim.id().to_owned(), self.test_rotation(pos)));
}
}
if let Some(back) = back {
self.enter_command_level(back);
}
}
}
let mut dimensions: Vec<String> = Vec::new();
for (pos, dimension, rotation) in &found {
if !dimensions.contains(dimension) {
dimensions.push(dimension.clone());
}
let ((dx, dz), y_rot) = north_turned(*rotation);
let tp = (pos.0 + dx * 3, pos.1, pos.2 + dz * 3);
let command = locate_command(dimension, tp, y_rot);
let coordinates = link(
Text::tr("chat.coordinates", &[&pos.0, &pos.1, &pos.2])
.append(format!(" ->[{}]", path_of(dimension))),
text::GREEN,
command,
);
let distance = if *dimension == source_dim.id() {
horizontal_distance(center.0 - pos.0, center.2 - pos.2).to_string()
} else {
"N/A".to_owned()
};
self.feedback_unbroadcast(Text::translate(
"commands.test.locate.found",
&[coordinates, Text::literal(distance)],
));
}
let count = found.len() as i32;
if count == 0 {
return Err(err("commands.test.error.no_test_instances", &[]));
}
self.output_player_coordinates(&dimensions);
self.feedback(Text::translate(
"commands.test.locate.done",
&[Text::literal(count.to_string())],
));
Ok(count)
}
/// `outputPlayerCoordinates`.
fn output_player_coordinates(&mut self, test_dimensions: &[String]) {
let Some(player) = self.player.as_ref() else {
return;
};
let dimension = self.dim.id().to_owned();
// isPlayerInCurrentTestDimension
if test_dimensions.len() <= 1
&& test_dimensions.contains(&dimension)
&& test_dimensions.iter().any(|d| d == "minecraft:overworld")
{
return;
}
let at = [
player.position.x.floor() as i32,
player.position.y.floor() as i32,
player.position.z.floor() as i32,
];
let command = format!(
"/execute in {dimension} run tp @s {} {} {} {} {}",
at[0],
at[1],
at[2],
self.yaw.floor() as i32,
self.pitch.floor() as i32
);
let coordinates = link(
Text::translate(
"test.player.coordinates",
&[
Text::literal(at[0].to_string()),
Text::literal(at[1].to_string()),
Text::literal(at[2].to_string()),
Text::literal(path_of(&dimension).to_owned()),
],
),
text::GOLD,
command,
);
self.feedback_unbroadcast(coordinates);
}
/// `TestCommand.showPos`, but for the highlight the client draws
/// (`ClientboundGameTestHighlightPosPacket`).
pub(super) fn test_show_pos(&mut self, ctx: &Context) -> R<i32> {
self.source_player()?;
let var = match ctx.get("var") {
Some(Arg::Str(v)) => v.clone(),
_ => "pos".to_owned(),
};
let target = self.test_pick_block();
let target = (target[0], target[1], target[2]);
let containing = |game: &Self, radius: i32| {
game.find_test_instance_blocks(target, radius)
.into_iter()
.find(|&p| {
game.test_structure_box(p).is_some_and(|b| {
(0..3).all(|i| {
let t = [target.0, target.1, target.2][i];
t >= b.min[i] && t <= b.max[i]
})
})
})
};
let found = containing(self, 15).or_else(|| containing(self, 250));
let Some(block) = found else {
return Err(err(
"commands.test.error.no_test_containing_pos",
&[&target.0, &target.1, &target.2],
));
};
let Some(origin) = self.test_structure_box(block).map(|b| b.min) else {
return Err(err("commands.test.error.test_instance_not_found", &[]));
};
let relative = [
target.0 - origin[0],
target.1 - origin[1],
target.2 - origin[2],
];
let description = format!("{}, {}, {}", relative[0], relative[1], relative[2]);
let coords = Text::translate(
"commands.test.coordinates",
&relative.map(|v| Text::literal(v.to_string())),
)
.with_style(&Style {
bold: Some(true),
click: Some(ClickEvent::CopyToClipboard(format!(
"final BlockPos {var} = new BlockPos({description});"
))),
hover: Some(Box::new(Text::translate(
"commands.test.coordinates.copy",
&[],
))),
..Style::color(text::GREEN)
});
let name = self.test_name(block).plain();
self.feedback_unbroadcast(Text::translate(
"commands.test.relative_position",
&[Text::literal(name), coords],
));
// `player.connection.send(new ClientboundGameTestHighlightPosPacket(..))`.
if self.lan_acting.is_some() {
self.guest_packets
.push(C::GameTestHighlightPos(play::GameTestHighlightPos {
absolute: [target.0, target.1, target.2],
relative,
}));
} else {
self.highlight_test_pos([target.0, target.1, target.2], relative);
}
Ok(1)
}
/// `TestCommand.createNewStructure`.
pub(super) fn test_create(&mut self, ctx: &Context) -> R<i32> {
let int = |name: &str, default: i32| match ctx.get(name) {
Some(Arg::Int(v)) => *v,
_ => default,
};
let Some(Arg::Resource(id)) = ctx.get("id") else {
return Ok(0);
};
let key = if id.contains(':') {
id.clone()
} else {
format!("minecraft:{id}")
};
// `create <id> <width>` is a cube.
let width = int("width", 5);
let size = if ctx.get("height").is_some() {
[width, int("height", 5), int("depth", 5)]
} else {
[width; 3]
};
if size.iter().any(|&v| v > 48) {
return Err(err("commands.test.error.too_large", &[&48]));
}
let at = self.test_position_around();
self.create_empty_test(&key, at, size);
let padding = self.test_holder_padding(at);
let low =
TestInstanceBlock::structure_pos_of([at.0 + padding, at.1 + padding, at.2 + padding]);
let bedrock = self.pack.assets.registry.default_state("minecraft:bedrock");
if bedrock.is_some() {
for x in low[0]..low[0] + size[0] {
for z in low[2]..low[2] + size[2] {
self.set_test_block_state(
(x, low[1], z),
"minecraft:bedrock",
horizon_world::level::flags::UPDATE_ALL,
);
}
}
}
let name = self.test_name(at);
self.feedback(Text::translate("commands.test.create.success", &[name]));
Ok(1)
}
}
impl Game<'_> {
/// `StructureUtils.createNewEmptyTest`.
fn create_empty_test(&mut self, key: &str, at: Pos, size: [i32; 3]) {
let padding = self.test_def(key).map_or(0, |d: TestDef| d.padding);
let corner =
TestInstanceBlock::structure_pos_of([at.0 + padding, at.1 + padding, at.2 + padding]);
// getStructureBoundingBox(getStructurePos(testPos), size, NONE)
let area = horizon_world::worldgen::structure::BoundingBox::new(
corner,
[0, 1, 2].map(|i| corner[i] + size[i] - 1),
);
self.clear_test_space(&area);
self.set_test_block_state(
at,
"minecraft:test_instance_block",
horizon_world::level::flags::UPDATE_ALL,
);
if self.test_instance_at(at).is_none() {
self.world.block_entities.insert(
at,
BlockEntity::TestInstanceBlock(Box::<TestInstanceBlock>::default()),
);
}
if let Some(block) = self.test_instance_mut(at) {
block.data.test = Some(key.to_owned());
block.data.size = size;
block.data.rotation = Rotation::None;
block.data.ignore_entities = false;
block.data.status = Status::Cleared;
block.data.error_message = None;
}
self.mark_block_entity_updated(at);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn locate_teleports_three_blocks_in_front_of_the_rotated_north_face() {
// Rotation.rotate(Direction.NORTH) and the toYRot of its opposite.
assert_eq!(north_turned(Rotation::None), ((0, -1), 0));
assert_eq!(north_turned(Rotation::Clockwise90), ((1, 0), 90));
assert_eq!(north_turned(Rotation::Clockwise180), ((0, 1), 180));
assert_eq!(north_turned(Rotation::Counterclockwise90), ((-1, 0), 270));
assert_eq!(
locate_command("minecraft:the_nether", (1, 64, -3), 90),
"/execute in minecraft:the_nether run tp @s 1 64 -3 90 0"
);
}
#[test]
fn the_distance_is_the_floored_horizontal_one() {
assert_eq!(horizontal_distance(3, 4), 5);
assert_eq!(horizontal_distance(-1, 1), 1);
assert_eq!(path_of("minecraft:overworld"), "overworld");
}
}
@@ -3,14 +3,15 @@
//! `runthat`, `runfailed`, `resetclosest`/`these`/`that`,
//! `clearthat`/`these`/`all` and `stop`, with `TestFinder`'s searches.
//!
//! Not done: `verify`, `locate`, `pos`, `create`, retries (`numberOfTimes`,
//! `untilFailed`), tests of another dimension than the level in place.
//! Retries are `RetryOptions(numberOfTimes, untilFailed)`.
use super::{Arg, Context, Game, R, err};
use crate::game::{
game_tests::{Grid, Info},
Dim,
game_tests::{Grid, Info, Retry, RunnerOptions, verify_batches},
test_instances::TestDef,
};
use horizon_client::text::{self, Text};
use horizon_world::worldgen::structure::BoundingBox;
use horizon_world::worldgen::structure::Rotation;
type Pos = (i32, i32, i32);
@@ -49,7 +50,7 @@ impl Game<'_> {
/// `StructureUtils.findTestBlocks`: the test instance blocks within
/// `radius` of `center` (the POI search is a sphere).
fn find_test_instance_blocks(&self, center: Pos, radius: i32) -> Vec<Pos> {
pub(super) fn find_test_instance_blocks(&self, center: Pos, radius: i32) -> Vec<Pos> {
let limit = i64::from(radius) * i64::from(radius);
let mut found: Vec<Pos> = self
.world
@@ -72,7 +73,7 @@ impl Game<'_> {
}
/// The block of the source's position (`BlockPos.containing`).
fn test_source_block(&self) -> Pos {
pub(super) fn test_source_block(&self) -> Pos {
let p = self.source_position();
(
p[0].floor() as i32,
@@ -167,7 +168,7 @@ impl Game<'_> {
}
/// `createGameTestInfo`: the test of a test instance block.
fn test_info_at(&mut self, pos: Pos) -> Option<Info> {
fn test_info_at(&mut self, pos: Pos, retry: Retry) -> Option<Info> {
let key = self.test_instance_at(pos).and_then(|b| b.data.test.clone());
if self.test_instance_at(pos).is_none() {
self.test_failure(Text::translate(
@@ -195,45 +196,74 @@ impl Game<'_> {
}
let rotation = self.test_rotation(pos);
let id = self.game_tests.next_id();
Some(Info::new(id, key, def, Some(pos), self.dim, rotation))
Some(Info::new(id, key, def, Some(pos), self.dim, rotation).with_retry(retry))
}
/// `toGameTestInfo`: a test without a block, in the level in place.
fn test_info_for(&mut self, key: String, def: TestDef, steps: i32) -> R<Option<Info>> {
fn test_info_for(
&mut self,
key: String,
def: TestDef,
steps: i32,
retry: Retry,
) -> R<Option<Info>> {
if !self.verify_test_structure(&def.structure) {
return Ok(None);
}
if def.dimension != self.dim.id() {
// `server.getLevel(dimension)`: the server's levels; a client-only
// game (deprecated in-frame mode) has the one it is in.
let dim = match def.dimension.as_str() {
"minecraft:overworld" | "minecraft:the_nether" | "minecraft:the_end" => {
Some(Dim::from_id(&def.dimension))
}
_ => None,
}
.filter(|d| *d == self.dim || !self.options.execution_role.is_client());
let Some(dim) = dim else {
self.test_failure(Text::literal(format!(
"Could not resolve level for dimension: {}",
def.dimension
)));
return Ok(None);
}
};
let rotation = rotation_for_steps(steps).ok_or_else(|| err("command.failed", &[]))?;
let id = self.game_tests.next_id();
Ok(Some(Info::new(id, key, def, None, self.dim, rotation)))
Ok(Some(
Info::new(id, key, def, None, dim, rotation).with_retry(retry),
))
}
/// The column `createTestPositionAround` starts from: the source's.
pub(super) fn test_around(&self) -> (i32, i32) {
let p = self.source_position();
(p[0].floor() as i32, p[2].floor() as i32)
}
/// `createTestPositionAround`: three blocks south of the source, on the
/// surface of the level.
fn test_position_around(&self) -> Pos {
let p = self.source_position();
let (x, z) = (p[0].floor() as i32, p[2].floor() as i32);
/// surface of the level in place (`playerAndTestInfo` loads the chunk of
/// the source's column in that level first).
pub(in crate::game) fn test_position_in_level(&mut self, (x, z): (i32, i32)) -> Pos {
self.load_test_chunks(&BoundingBox::new([x, 0, z], [x, 0, z]));
let facts = self.res.facts();
let surface = self.world.world_surface(x, z + 3, |s| facts.is_air(s));
let surface = self.world.world_surface(x, z, |s| facts.is_air(s));
(x, surface, z + 3)
}
/// `createTestPositionAround` in the level in place.
pub(super) fn test_position_around(&mut self) -> Pos {
let around = self.test_around();
self.test_position_in_level(around)
}
/// `TestCommand.run`.
fn run_tests(&mut self, finder: &Finder, steps: i32, per_row: i32) -> R<i32> {
fn run_tests(&mut self, finder: &Finder, retry: Retry, steps: i32, per_row: i32) -> R<i32> {
self.clear_game_tests();
let mut infos = Vec::new();
for pos in self.finder_positions(finder) {
infos.extend(self.test_info_at(pos));
infos.extend(self.test_info_at(pos, retry));
}
for (key, def) in self.finder_tests(finder) {
infos.extend(self.test_info_for(key, def, steps)?);
infos.extend(self.test_info_for(key, def, steps, retry)?);
}
if infos.is_empty() {
self.feedback_unbroadcast(Text::translate("commands.test.no_tests", &[]));
@@ -244,17 +274,45 @@ impl Game<'_> {
"commands.test.run.running",
&[Text::literal(infos.len().to_string())],
));
let grid = Grid::new(self.test_position_around(), per_row);
let grid = Grid::new(self.test_around(), per_row);
self.track_and_start_runner(infos, false, Some(grid));
Ok(1)
}
/// `TestCommand.verify`.
pub(super) fn test_verify(&mut self, ctx: &Context) -> R<i32> {
self.clear_game_tests();
let tests = match ctx.get("tests") {
Some(Arg::Str(pattern)) => horizon_client::command::args::select_resources(
self.ui.registries.ids("minecraft:test_instance"),
pattern,
)
.into_iter()
.cloned()
.collect(),
_ => Vec::new(),
};
let finder = Finder::Selection { tests, copies: 1 };
let mut infos = Vec::new();
for pos in self.finder_positions(&finder) {
infos.extend(self.test_info_at(pos, Retry::none()));
}
for (key, def) in self.finder_tests(&finder) {
infos.extend(self.test_info_for(key, def, 0, Retry::none())?);
}
self.game_tests.failed.clear();
let batches = verify_batches(&infos, || self.game_tests.next_id());
let grid = Grid::new(self.test_around(), 10).clearing();
self.track_and_start_batches(batches, RunnerOptions::verify(grid));
Ok(1)
}
/// `TestCommand.reset`.
fn reset_tests(&mut self, finder: &Finder) -> R<i32> {
self.clear_game_tests();
let mut count = 0;
for pos in self.finder_positions(finder) {
if self.test_info_at(pos).is_some() {
if self.test_info_at(pos, Retry::none()).is_some() {
// resetGameTestInfo: `TestInstanceBlockEntity.resetTest`.
self.reset_test(pos);
count += 1;
@@ -302,6 +360,12 @@ impl Game<'_> {
Some(Arg::Int(v)) => Some(*v),
_ => None,
};
// runWithRetryOptions: `RetryOptions(numberOfTimes, untilFailed)`, else
// no retries.
let retry = match int("numberOfTimes") {
Some(n) => Retry::new(n, matches!(ctx.get("untilFailed"), Some(Arg::Bool(true)))),
None => Retry::none(),
};
let has = |word: &str| ctx.literals.iter().any(|l| l == word);
let looks = has("runthat") || has("resetthat") || has("clearthat");
if looks {
@@ -331,6 +395,11 @@ impl Game<'_> {
let finder = Finder::Selection { tests, copies };
self.run_tests(
&finder,
if has("runmultiple") {
Retry::none()
} else {
retry
},
int("rotationSteps").unwrap_or(0),
int("testsPerRow").unwrap_or(8),
)
@@ -348,6 +417,7 @@ impl Game<'_> {
};
self.run_tests(
&finder,
retry,
int("rotationSteps").unwrap_or(0),
int("testsPerRow").unwrap_or(8),
)
@@ -1116,6 +1116,8 @@ impl Game<'_> {
status["maps"] = json!(self.maps_status());
// The particles made so far, by type.
status["particles"] = json!(self.particles.made());
// The pickup particles made so far, by what was taken.
status["pickups"] = json!(self.pickups.made());
// The world border this side knows of.
status["border"] = json!({ "center": self.border.center(), "size": self.border.size() });
// The scoreboard sidebar the HUD shows.
+75 -20
View File
@@ -68,17 +68,8 @@ pub(super) struct Creatures {
pub shoulder_time: std::collections::HashMap<[i32; 4], i64>,
/// The cat spawner's countdown (`CatSpawner.nextTick`).
pub cat_tick: i32,
/// Particles from a guest's turn, waiting for it to end
/// (`lan_send_particles`: options, centre, spread, speed, count).
pub lan_particles: Vec<(
&'static str,
horizon_client::particles::ParticleOptions,
Option<horizon_world::registry::BlockStateId>,
[f64; 3],
[f64; 3],
f64,
i32,
)>,
/// Particles from a guest's turn, waiting for it to end.
pub lan_particles: Vec<super::lan_particles::QueuedParticle>,
}
pub(super) struct ProjWorld<'a, 'w> {
pub w: &'a MobWorld<'w>,
@@ -987,7 +978,8 @@ impl Game<'_> {
self.raider_died(m, slain_by_player);
self.villager_murdered(m, slain_by_player, killer.flatten());
let pos = [m.position.x, m.position.y + 0.5, m.position.z];
self.drop_death_loot(&mut dying, by_player, consumed, pos);
let killer_entity = self.killer_loot_entity(killer.flatten());
self.drop_death_loot(&mut dying, by_player, killer_entity, consumed, pos);
self.create_wither_rose(m);
if m.species() == Species::CopperGolem {
// CopperGolem.dropEquipment: the preserved equipment too.
@@ -1069,7 +1061,47 @@ impl Game<'_> {
/// `dropAllDeathLoot` through `mobs::drops`: the entity loot table from the
/// world's random sequence, equipment by drop chance, species extras and
/// the experience orbs, spawned in order.
fn drop_death_loot(&mut self, m: &mut Mob, by_player: bool, consumed: bool, pos: [f64; 3]) {
/// The killing player as a loot entity: their worn and held items, read
/// from the guest who struck the last blow when a server's player did
/// (on the server thread that is every player, the window's too), so
/// Looting on their weapon counts.
fn killer_loot_entity(&mut self, guest: Option<u32>) -> horizon_world::loot::LootEntity {
let own = |g: &Self| {
let mut e = horizon_world::loot::LootEntity::new("minecraft:player");
let slots = [
("mainhand", g.stack_in(g.selected)),
("offhand", g.stack_in(super::shield::OFFHAND_SLOT)),
("head", g.armor[0].as_ref()),
("chest", g.armor[1].as_ref()),
("legs", g.armor[2].as_ref()),
("feet", g.armor[3].as_ref()),
];
for (slot, stack) in slots {
if let Some(stack) = stack {
e.equipment.push((slot.into(), to_loot(stack)));
}
}
e
};
if let Some(conn) = guest.filter(|_| self.lan_acting.is_none())
&& let Some(mut host) = self.lan.take()
{
let entity = self.as_guest(&mut host, conn, |g| own(g));
self.lan = Some(host);
if let Some(entity) = entity {
return entity;
}
}
own(self)
}
fn drop_death_loot(
&mut self,
m: &mut Mob,
by_player: bool,
killer: horizon_world::loot::LootEntity,
consumed: bool,
pos: [f64; 3],
) {
use horizon_client::mobs::{DamageKind, Who, drops};
use horizon_world::loot::{LootDamage, LootEntity};
let Some(loot) = horizon_client::structure_blocks::loot_data(
@@ -1078,13 +1110,7 @@ impl Game<'_> {
) else {
return;
};
let player = || {
let mut e = LootEntity::new("minecraft:player");
if let Some(held) = self.held() {
e.equipment.push(("mainhand".into(), to_loot(held)));
}
e
};
let player = || killer.clone();
let damage = m.last_damage.map(|(d, _)| {
let source = match d.attacker {
Some(Who::Player) => Some(player()),
@@ -1554,6 +1580,30 @@ impl Game<'_> {
} => {
self.mob_hits_mob(target, amount, source);
}
MobEvent::CubeHurtPlayer { cube, amount, from } => {
// AbstractCubeMob.dealDamage: the attack sound (and its
// two pitch draws, made even for a silent cube, whose
// Entity.playSound is then skipped) only follows a hit
// that landed.
if self.hurt_player_from(
amount,
Some(from),
"minecraft:mob_attack",
Some(Some(cube)),
) && let Some(m) = self.mobs.iter_mut().find(|m| m.id == cube)
{
let pitch = (m.random.next_float() - m.random.next_float()) * 0.2 + 1.0;
let at = m.center();
if !m.silent {
self.handle_mob_events(vec![MobEvent::Sound(
"entity.slime.attack",
at,
1.0,
pitch,
)])?;
}
}
}
MobEvent::CubeHurtMob {
cube,
target,
@@ -2054,6 +2104,11 @@ impl Game<'_> {
MobEvent::CureZombieVillager { mob } => self.cure_zombie_villager(mob),
MobEvent::Reinforcements { mob, target } => self.call_reinforcements(mob, target),
MobEvent::VillagerMeet { mob, other } => self.villager_meet(mob, other),
MobEvent::VillagerOffers { mob } => {
if let Some(i) = self.mobs.iter().position(|m| m.id == mob) {
self.ensure_offers(i);
}
}
MobEvent::VillagerBirth {
parent,
partner,
+13 -1
View File
@@ -708,8 +708,20 @@ impl Game<'_> {
// The single-player host's state is not used: players have their own files.
self.level.position = None;
// DedicatedServer.initServer: worldData.setGameType; forceDifficulty.
// `load_world` restored difficulty from level.dat; server.properties wins
// (vanilla's forceDifficulty), including health_rules used for despawn (#4694).
self.set_default_game_mode(game_mode);
self.ui.difficulty = props.difficulty as u8;
// MinecraftServer.setDifficulty: a hardcore world is always hard.
self.ui.difficulty = if self.level.hardcore {
3
} else {
props.difficulty as u8
};
self.level.set_difficulty(self.ui.difficulty);
self.sync_rules();
if self.ui.difficulty == 0 {
self.mobs.retain(|m| !m.hostile());
}
// DedicatedServer.initServer: a legacy `announce-player-achievements`
// sets the show_advancement_messages rule once, as the file is read.
if let Some(announce) = props.announce_player_achievements {
@@ -934,6 +934,14 @@ impl Game<'_> {
Ok(())
}
/// Whether the open dialog has a text input.
pub(super) fn dialog_has_text_field(&self) -> bool {
self.dialogs
.open
.as_ref()
.is_some_and(|open| open.fields.iter().any(Option::is_some))
}
/// `type TEXT` with a dialog open: into the focused text input, or the
/// first one.
pub(super) fn dialog_type(&mut self, text: &str) -> bool {
+4
View File
@@ -3344,6 +3344,9 @@ impl Game<'_> {
/// The END stage: the portal crystals blow up (no block damage) and a
/// new dragon takes over.
fn finish_respawn(&mut self) {
// Portal crystals are not on spikes; clear their revival beams before
// the spike reset and before they are removed (vanilla resetEndCrystals).
self.respawn_beams(None);
self.reset_spike_crystals();
self.fight.respawn = None;
let list = std::mem::take(&mut self.fight.respawn_crystals);
@@ -3398,6 +3401,7 @@ impl Game<'_> {
/// `abortRespawnSequence`.
fn abort_respawn(&mut self) {
self.fight.respawn = None;
self.respawn_beams(None);
self.fight.respawn_crystals.clear();
self.reset_spike_crystals();
let _ = self.activate_exit_portal();
+6 -6
View File
@@ -476,15 +476,15 @@ impl Game<'_> {
self.particles
.send_particles(&options, at, spread, speed, count);
// The players a server serves are sent them too.
self.lan_send_particles(
self.dim.id(),
&options,
None,
self.lan_send_particles(super::lan_particles::QueuedParticle {
dimension: self.dim.id(),
options,
state: None,
at,
spread,
speed,
count as i32,
);
count: count as i32,
});
}
/// `FishingHook.tick`.
pub(super) fn tick_fishing(&mut self) {
File diff suppressed because it is too large. Load diff
+101 -88
View File
@@ -4,6 +4,9 @@
//! golem. Players placing the pumpkin and dispensers share it.
use super::Game;
use horizon_client::Result;
use horizon_client::block_pattern::{
IRON_GOLEM_BASE, IRON_GOLEM_FULL, SNOW_GOLEM_BASE, SNOW_GOLEM_FULL,
};
impl Game<'_> {
fn golem_block(&self, p: [i32; 3]) -> &str {
@@ -17,12 +20,12 @@ impl Game<'_> {
)
}
/// The blocks of the golem completed at `head` and its spawn base
/// (`BlockPattern.find`), snow golems first: the snow column ("^", "#",
/// "#") spawns at its bottom, the iron T ("~^~", "###", "~#~") under its
/// head. The blocks come column by column, top first, as
/// `clearPatternBlocks` visits them. With `base`, the head's cell takes
/// any block, as in the base patterns (" ", "#", "#") and ("~ ~", "###",
/// "~#~") that `canSpawnGolem` checks before the head is there.
/// (`BlockPattern.find`), snow golems first: the snow column
/// (`SNOW_GOLEM_FULL`: "^", "#", "#") spawns at its bottom, the iron T
/// (`IRON_GOLEM_FULL`: "~^~", "###", "~#~") under its head. The blocks
/// come column by column, top first, as `clearPatternBlocks` visits them.
/// With `base`, `SNOW_GOLEM_BASE` and `IRON_GOLEM_BASE` take any block
/// where the head will go, as `canSpawnGolem` checks before it is there.
fn find_golem(
&self,
head: [i32; 3],
@@ -30,24 +33,24 @@ impl Game<'_> {
) -> Option<(&'static str, Vec<[i32; 3]>, [i32; 3])> {
let air = |p: [i32; 3]| self.res.facts().is_air(self.world.block(p[0], p[1], p[2]));
let snow = |c: char, p: [i32; 3]| match c {
'^' => base || self.is_golem_head(p),
'^' => self.is_golem_head(p),
_ => self.golem_block(p) == "minecraft:snow_block",
};
let iron = |c: char, p: [i32; 3]| match c {
'^' => base || self.is_golem_head(p),
'^' => self.is_golem_head(p),
'#' => self.golem_block(p) == "minecraft:iron_block",
_ => air(p),
};
if let Some(cell) = find_pattern(head, &["^", "#", "#"], snow) {
let blocks = (0..3).map(|y| cell(0, y)).collect();
return Some(("minecraft:snow_golem", blocks, cell(0, 2)));
let (snow_pattern, iron_pattern) = if base {
(SNOW_GOLEM_BASE, IRON_GOLEM_BASE)
} else {
(SNOW_GOLEM_FULL, IRON_GOLEM_FULL)
};
if let Some(m) = snow_pattern.find(head, snow) {
return Some(("minecraft:snow_golem", m.front_cells(), m.block(0, 2, 0)));
}
let cell = find_pattern(head, &["~^~", "###", "~#~"], iron)?;
let blocks = (0..3)
.flat_map(|x| (0..3).map(move |y| (x, y)))
.map(|(x, y)| cell(x, y))
.collect();
Some(("minecraft:iron_golem", blocks, cell(1, 2)))
let m = iron_pattern.find(head, iron)?;
Some(("minecraft:iron_golem", m.front_cells(), m.block(1, 2, 0)))
}
/// `CarvedPumpkinBlock.canSpawnGolem`: a snow or iron golem base, or a
/// copper block (`#minecraft:copper`) below.
@@ -254,78 +257,24 @@ impl Game<'_> {
}
}
/// `BlockPattern.find` for a one-aisle pattern (`rows`, top first): the
/// pattern's corner at every block from `pos` to `pos + size - 1` (x fastest,
/// then y, then z), turned every way (`forwards`, then `up`, in `Direction`
/// order, never along each other), until every cell passes `test`. The match
/// gives the block of column x, row y (`BlockPatternMatch.getBlock(x, y, 0)`).
fn find_pattern(
pos: [i32; 3],
rows: &[&str],
test: impl Fn(char, [i32; 3]) -> bool,
) -> Option<impl Fn(i32, i32) -> [i32; 3]> {
// Direction.values(): down, up, north, south, west, east.
const DIRECTIONS: [[i32; 3]; 6] = [
[0, -1, 0],
[0, 1, 0],
[0, 0, -1],
[0, 0, 1],
[-1, 0, 0],
[1, 0, 0],
];
let size = rows.len().max(rows[0].len()) as i32;
let corners =
(0..size).flat_map(|z| (0..size).flat_map(move |y| (0..size).map(move |x| [x, y, z])));
for d in corners {
let origin = [pos[0] + d[0], pos[1] + d[1], pos[2] + d[2]];
for forwards in DIRECTIONS {
for up in DIRECTIONS {
if up == forwards || up == forwards.map(|v| -v) {
continue;
}
// translateAndRotate: right is forwards × up, rows go down `up`.
let right = [
forwards[1] * up[2] - forwards[2] * up[1],
forwards[2] * up[0] - forwards[0] * up[2],
forwards[0] * up[1] - forwards[1] * up[0],
];
let cell =
move |x: i32, y: i32| [0, 1, 2].map(|i| origin[i] - up[i] * y + right[i] * x);
let matches = rows.iter().enumerate().all(|(y, row)| {
row.chars()
.enumerate()
.all(|(x, c)| test(c, cell(x as i32, y as i32)))
});
if matches {
return Some(cell);
}
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::find_pattern;
use horizon_client::block_pattern::{
BlockPattern, IRON_GOLEM_BASE, IRON_GOLEM_FULL, SNOW_GOLEM_BASE, SNOW_GOLEM_FULL,
};
use std::collections::HashMap;
const SNOW: [&str; 3] = ["^", "#", "#"];
const IRON: [&str; 3] = ["~^~", "###", "~#~"];
/// `find_pattern` over `blocks` (anything else is air): the cells of the
/// match, column by column, top first.
fn find(rows: &[&str], head: [i32; 3], blocks: &[([i32; 3], char)]) -> Option<Vec<[i32; 3]>> {
/// `BlockPattern.find` over `blocks` (anything else is air): the cells of
/// the match, column by column, top first.
fn find(
pattern: BlockPattern,
head: [i32; 3],
blocks: &[([i32; 3], char)],
) -> Option<Vec<[i32; 3]>> {
let world: HashMap<_, _> = blocks.iter().copied().collect();
let at = |p: [i32; 3]| world.get(&p).copied().unwrap_or('~');
let cell = find_pattern(head, rows, |c, p| at(p) == c)?;
let width = rows[0].len() as i32;
Some(
(0..width)
.flat_map(|x| (0..rows.len() as i32).map(move |y| (x, y)))
.map(|(x, y)| cell(x, y))
.collect(),
)
let m = pattern.find(head, |c, p| at(p) == c)?;
Some(m.front_cells())
}
#[test]
@@ -342,7 +291,7 @@ mod tests {
let at = |n: i32| [0, 1, 2].map(|i| head[i] + step[i] * n);
let blocks = [(at(0), '^'), (at(1), '#'), (at(2), '#')];
assert_eq!(
find(&SNOW, head, &blocks),
find(SNOW_GOLEM_FULL, head, &blocks),
Some(vec![at(0), at(1), at(2)]),
"snow blocks going {step:?} from the head"
);
@@ -353,8 +302,11 @@ mod tests {
fn a_bent_or_short_snow_column_is_no_golem() {
let head = [0, 0, 0];
let bent = [(head, '^'), ([0, -1, 0], '#'), ([1, -1, 0], '#')];
assert_eq!(find(&SNOW, head, &bent), None);
assert_eq!(find(&SNOW, head, &[(head, '^'), ([0, -1, 0], '#')]), None);
assert_eq!(find(SNOW_GOLEM_FULL, head, &bent), None);
assert_eq!(
find(SNOW_GOLEM_FULL, head, &[(head, '^'), ([0, -1, 0], '#')]),
None
);
}
#[test]
@@ -368,15 +320,76 @@ mod tests {
([1, 0, 1], '#'),
([2, 0, 0], '#'),
];
let cells = find(&IRON, head, &blocks).expect("a lying iron golem");
let cells = find(IRON_GOLEM_FULL, head, &blocks).expect("a lying iron golem");
// Column 1, rows 0 to 2: the head, the body and the legs.
assert_eq!(&cells[3..6], &[[0, 0, 0], [1, 0, 0], [2, 0, 0]]);
let mut arms_out = blocks.to_vec();
arms_out.push(([2, 0, 1], '#'));
assert_eq!(
find(&IRON, head, &arms_out),
find(IRON_GOLEM_FULL, head, &arms_out),
None,
"a block beside the legs"
);
}
#[test]
fn the_base_patterns_ignore_the_head() {
// canSpawnGolem checks the base patterns before the head is there: the
// head's cell (' ' in the pattern) takes any block.
let snow = [([0, -1, 0], '#'), ([0, -2, 0], '#')];
assert_eq!(
find(SNOW_GOLEM_BASE, [0, 0, 0], &snow),
Some(vec![[0, 0, 0], [0, -1, 0], [0, -2, 0]])
);
assert_eq!(find(SNOW_GOLEM_FULL, [0, 0, 0], &snow), None);
let iron = [
([-1, 0, 0], '#'),
([0, 0, 0], '#'),
([1, 0, 0], '#'),
([0, -1, 0], '#'),
];
assert!(find(IRON_GOLEM_BASE, [0, 1, 0], &iron).is_some());
assert_eq!(find(IRON_GOLEM_FULL, [0, 1, 0], &iron), None);
}
#[test]
fn the_first_corner_of_the_search_cube_wins() {
// There is no snow at the head, so the corners x fastest, then y, then
// z decide: the column one step along +x is the one taken.
let column = |c: [i32; 3]| {
[
(c, '^'),
([c[0], c[1] - 1, c[2]], '#'),
([c[0], c[1] - 2, c[2]], '#'),
]
};
let blocks: Vec<_> = [column([1, 0, 0]), column([0, 1, 0]), column([0, 0, 1])].concat();
assert_eq!(
find(SNOW_GOLEM_FULL, [0, 0, 0], &blocks),
Some(vec![[1, 0, 0], [1, -1, 0], [1, -2, 0]])
);
}
#[test]
fn forwards_then_up_in_direction_order_picks_the_column() {
// One head with a snow block below it and along both z ways. Every
// `up` is tried for one `forwards` before the next forwards, in
// Direction order and never along `forwards`: down's first usable up
// is north, so the +z column is the match, not the column below (up,
// under forwards north) nor the one along -z (south, later).
let head = [0, 0, 0];
let blocks = [
(head, '^'),
([0, -1, 0], '#'),
([0, -2, 0], '#'),
([0, 0, 1], '#'),
([0, 0, 2], '#'),
([0, 0, -1], '#'),
([0, 0, -2], '#'),
];
assert_eq!(
find(SNOW_GOLEM_FULL, head, &blocks),
Some(vec![[0, 0, 0], [0, 0, 1], [0, 0, 2]])
);
}
}
+23
View File
@@ -175,6 +175,7 @@ impl Game<'_> {
});
self.clicks.destroying = destroying;
self.attack_clicks = 0;
self.use_clicks = 0;
self.request_remote_mining_stop();
self.mining = None;
}
@@ -203,12 +204,18 @@ impl Game<'_> {
/// attack clicks since the last tick and the held attack button.
pub(super) fn tick_local_hand(&mut self) -> Result<()> {
let attack_clicks = std::mem::take(&mut self.attack_clicks);
let use_clicks = std::mem::take(&mut self.use_clicks);
if self.screen == Screen::Playing && self.title.is_none() && self.sleeping.is_none() {
let mut clicks = std::mem::take(&mut self.clicks);
// keyAttack.isDown(); playing implies the grabbed mouse here
// (scripts under --control hold buttons without grabbing it).
let down = self.left;
let ticks = self.clicks.destroying.ticks;
// Minecraft.hitResult is picked once per frame: a creative break
// does not move the use that follows it past the broken block.
if use_clicks > 0 && attack_clicks > 0 {
self.tick_hit = Some(self.pick());
}
let mut world = GameHands {
game: self,
error: None,
@@ -217,8 +224,24 @@ impl Game<'_> {
let error = world.error.take();
self.clicks = clicks;
if let Some(e) = error {
self.tick_hit = None;
return Err(e);
}
// Use clicks follow the attack clicks within the tick, against
// the block the attack may just have broken.
let mut used = Ok(());
for _ in 0..use_clicks {
if self.using.is_some() {
break;
}
used = self.use_with_hand();
self.use_delay = 4;
if used.is_err() {
break;
}
}
self.tick_hit = None;
used?;
// A tick of breaking: the hit sound every fourth tick and the
// cracking particles.
let d = &self.clicks.destroying;
+122
View File
@@ -0,0 +1,122 @@
//! Input method (IME) text: committed composition goes to the focused text
//! field through the same sinks the keyboard and `horizon-ctl type` use.
//! The window only asks the OS for an input method while such a field has
//! focus, so movement keys keep arriving as plain key events.
use super::{Game, Screen, books::BookScreen};
use horizon_client::{
hud::HudBatch,
preedit::{self, PreeditEvent},
};
impl Game<'_> {
/// Whether a text box has focus right now (the fields `ime_commit` can fill).
pub(super) fn text_field_focused(&mut self) -> bool {
if self.dialog_open() {
return self.dialog_has_text_field();
}
if let Some(title) = self.title.as_mut() {
return title.focused_field().is_some();
}
if self.sign_edit.is_some() || matches!(self.book, Some(BookScreen::Edit { .. })) {
return true;
}
match self.screen {
Screen::Chat | Screen::CommandBlock => true,
Screen::WorldOptions => self.world_options_typing(),
Screen::Creative => self.creative_screen.tab().search,
Screen::Inventory => {
self.mod_browser_typing() || self.recipe_search_focused() || self.station_typing()
}
_ => false,
}
}
/// Allow the input method only while a text box has focus.
pub(super) fn sync_ime(&mut self) {
let want = self.text_field_focused();
if want != self.ime_allowed
&& let Some(window) = &self.window
{
window.set_ime_allowed(want);
self.ime_allowed = want;
// The field lost the focus (or a new one took it): the composition
// and the candidate window's place belong to the old one.
self.ime_area = None;
if !want {
preedit::set_preedit(None);
}
}
}
/// `WindowEvent::Ime(Preedit)`: `KeyboardHandler.textEditing`. The text
/// still being composed is shown under the focused field's caret.
pub(super) fn ime_preedit(&mut self, text: &str, cursor: Option<(usize, usize)>) {
preedit::set_preedit(
self.text_field_focused()
.then(|| PreeditEvent::from_winit(text, cursor))
.flatten(),
);
}
/// With the frame's GUI built: draw the composition over it and move the
/// input method's candidate window to the caret
/// (`TextInputManager.setTextInputArea`, in window pixels).
pub(super) fn flush_ime(&mut self, hud: &mut HudBatch) {
preedit::draw_overlay(hud, &self.gui.atlas);
let Some([x0, y0, x1, y1]) = preedit::take_text_input_area() else {
return;
};
let scale = hud.scale;
let x = (x0 * scale).round() as i32;
let y = (y0 * scale).round() as i32;
let width = (((x1 - x0) * scale).round() as i32).max(1);
let height = (((y1 - y0) * scale).round() as i32).max(1);
let area = [x, y, width, height];
if self.ime_area == Some(area) {
return;
}
if let Some(window) = &self.window
&& self.ime_allowed
{
window.set_ime_cursor_area(
winit::dpi::PhysicalPosition::new(x, y),
winit::dpi::PhysicalSize::new(width as u32, height as u32),
);
self.ime_area = Some(area);
}
}
/// Text the input method committed (`WindowEvent::Ime(Commit)`).
pub(super) fn ime_commit(&mut self, text: &str) {
preedit::set_preedit(None);
let text: String = text.chars().filter(|c| !c.is_control()).collect();
if text.is_empty() || !self.text_field_focused() {
return;
}
if self.dialog_type(&text) {
return;
}
if self.title.is_some() {
self.title_edit(Some(&text), false);
} else if self.book.is_some() {
self.book_input(None, Some(&text));
} else if self.sign_edit.is_some() {
self.sign_input(None, Some(&text));
} else {
match self.screen {
Screen::Chat => self.chat_type(&text),
Screen::CommandBlock => self.command_block_type(&text),
Screen::WorldOptions => self.world_options_text(Some(&text)),
Screen::Creative => self.creative_search_edit(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)
}
Screen::Inventory => self.station_text(Some(&text), false),
_ => {}
}
}
}
}
@@ -7,7 +7,7 @@ use horizon_client::{
jigsaw_block::{self, JigsawBlock, JointType},
};
use horizon_protocol::packets::play;
use horizon_world::worldgen::generator::JigsawQuery;
use horizon_world::{nbt::Tag, worldgen::generator::JigsawQuery};
type Pos = (i32, i32, i32);
@@ -128,11 +128,14 @@ impl Game<'_> {
position,
};
let keep = p.keep_jigsaws;
self.with_level(|b, level, ticks| {
let (_, entities) = self.with_level(|b, level, ticks| {
b.place_jigsaw(level, ticks, position, keep, || {
terrain.jigsaw_pieces(&query)
})
});
for entity in entities {
self.spawn_entity_from_nbt(Tag::Compound(entity));
}
let _ = self.process_level_events();
}
}
+63 -25
View File
@@ -2,7 +2,7 @@
//! server threads and the play-state hub, feeds it the world's entities each
//! tick and turns what the guests do into gameplay. A world that is not open
//! costs one `Option` check per tick.
use super::{Game, shield::OFFHAND_SLOT};
use super::{Game, shield::OFFHAND_SLOT, shoulders::shoulder_variants};
use horizon_client::{
Result,
items::ItemStack,
@@ -635,12 +635,26 @@ fn mob_meta(
));
}
}
// AbstractCubeMob.ID_SIZE.
// AbstractCubeMob.ID_SIZE; SulfurCube also syncs FROM_BUCKET and
// MAX_FUSE (clients tick the fuse for the swell / white flash).
"minecraft:slime" | "minecraft:magma_cube" | "minecraft:sulfur_cube" => {
if e.size > 1 {
meta.push((18, serializer::INT, Value::Int(e.size)));
}
defaults.push((18, serializer::INT, Value::Int(1)));
if kind == "minecraft:sulfur_cube" {
let from_bucket = e.scratch[3] != 0;
if from_bucket {
meta.push((19, serializer::BOOLEAN, Value::Bool(true)));
}
defaults.push((19, serializer::BOOLEAN, Value::Bool(false)));
// SulfurCube.MAX_FUSE: set when primed; 0 means unprimed.
let fuse = horizon_client::mobs::sulfur_cube::fuse(m);
if fuse >= 0 {
meta.push((20, serializer::INT, Value::Int(fuse)));
}
defaults.push((20, serializer::INT, Value::Int(0)));
}
}
// Creeper.DATA_SWELL_DIR, DATA_IS_POWERED and DATA_IS_IGNITED: the
// guest swells the creeper from them. The direction always goes in,
@@ -1020,6 +1034,28 @@ pub const HELP: &[&str] = &[
"lan status the hosting state and the guests",
];
/// With the game mode forced, the guests online take the default mode
/// (setting a mode does nothing when it is already theirs). A world open to
/// LAN is an integrated server, which leaves out the players who have game
/// master permission; a dedicated server does not.
fn enforce_guest_game_mode(host: &mut LanHost) {
if !host.hub.config.force_game_mode {
return;
}
let mode = host.hub.config.default_game_mode;
let spares_operators = !host.hub.config.dedicated || host.hub.config.integrated;
let stale: Vec<u32> = host
.hub
.guests
.iter()
.filter(|g| g.game_mode != mode && !(spares_operators && g.permission >= 2))
.map(|g| g.conn)
.collect();
for conn in stale {
host.hub.set_game_mode(conn, mode);
}
}
impl Game<'_> {
/// `horizon-ctl lan ...`.
pub(super) fn lan_control(&mut self, words: &[&str]) -> Result<Option<serde_json::Value>> {
@@ -1452,6 +1488,9 @@ impl Game<'_> {
self.level.creative = mode == 1;
if let Some(host) = self.lan.as_mut() {
host.hub.config.default_game_mode = mode;
// `setWorldGameType` and `DefaultGameModeCommands.setMode`: a
// world that forces its mode puts the guests online in it.
enforce_guest_game_mode(host);
}
}
@@ -1469,25 +1508,7 @@ impl Game<'_> {
host.settings.force_game_mode = force;
host.hub.config.guest_commands = effective;
host.hub.config.force_game_mode = force;
// MinecraftServer.enforceGameTypeForPlayers: the guests online
// take the default mode (`setGameMode` does nothing when it is
// already theirs). A world open to LAN is an IntegratedServer,
// whose `updateGameModeBasedOnPermission` leaves out the players
// who have game master permission; a dedicated server does not.
if force {
let mode = host.hub.config.default_game_mode;
let spares_operators = !host.hub.config.dedicated || host.hub.config.integrated;
let stale: Vec<u32> = host
.hub
.guests
.iter()
.filter(|g| g.game_mode != mode && !(spares_operators && g.permission >= 2))
.map(|g| g.conn)
.collect();
for conn in stale {
host.hub.set_game_mode(conn, mode);
}
}
enforce_guest_game_mode(host);
host.hub.resend_commands();
}
}
@@ -1532,6 +1553,7 @@ impl Game<'_> {
living_flags: living_flags(self.using)
| if p.auto_spin_attack_ticks > 0 { 4 } else { 0 },
stuck: (self.combat.stuck.arrows, self.combat.stuck.stingers),
shoulders: shoulder_variants(self.level.player_extra.as_ref()),
bed: self.sleeping,
body: BodyFlags {
gliding: p.fall_flying,
@@ -2218,19 +2240,27 @@ impl Game<'_> {
let players: Vec<_> = host_player
.into_iter()
.chain(guests.filter_map(|g| g.motion.as_deref().map(|m| (g.entity_id, g.position, m))))
.map(|(id, at, p)| (id, at, p.health.hurt_animation_time, p.hurt_log.last()))
.map(|(id, at, p)| {
(
id,
at,
p.health.hurt_animation_time,
p.health.dead_or_dying(),
p.hurt_log.last(),
)
})
.collect();
for (id, at, hurt, record) in players {
for (id, at, hurt, dead, record) in players {
let old = host
.mob_state
.get(&self.dim)
.and_then(|s| s.get(&(id as u32)));
if hurt > old.map_or(0, |o| o.0) {
if hurt > old.map_or(0, |o| o.0) && !dead {
// Starving keeps no record: any type flashes the same.
let kind = record.map_or("minecraft:generic", |r| r.source.as_str());
host.hub.damage_event(id, kind, None, None, at);
}
seen.insert(id as u32, (hurt, false, None));
seen.insert(id as u32, (hurt, dead, None));
}
host.mob_state.insert(self.dim, seen);
// Level.broadcastEntityEvent(player, 2) of a spear's stab.
@@ -2280,6 +2310,13 @@ impl Game<'_> {
// then ticks without a player.
.map(|chunk| (chunk, 1)),
)
// A portal ticket keeps the area around the exit loaded and
// ticking (its entity-ticking chunks and their neighbours).
.chain(
self.portal_ticket_chunks(self.dim)
.into_iter()
.map(|chunk| (chunk, 1)),
)
.collect();
}
@@ -2363,6 +2400,7 @@ impl Game<'_> {
m.extra.synced_pose = if body.swimming || body.crawling { 3 } else { 0 };
m.extra.auto_spin_attack = body.spin_attack;
(m.extra.arrow_count, m.extra.stinger_count) = g.stuck;
m.extra.shoulder_parrots = g.shoulders;
m.velocity = g
.motion
.as_ref()
+72 -12
View File
@@ -1506,13 +1506,7 @@ impl Game<'_> {
let Some(stack) = self.item_from_network(host, &p.item) else {
return;
};
if stack.count
> self
.res
.items()
.get(&stack.item)
.map_or(64, |i| i.max_stack)
{
if !self.creative_stack_valid(host, gi, &stack) {
return;
}
let play = host.play.entry(conn).or_default();
@@ -1527,11 +1521,18 @@ impl Game<'_> {
self.as_guest(host, conn, |g| g.player_drop(stack));
}
S::SetCreativeModeSlot(p) => {
// Slot 0 is the crafting result: not a slot to set.
let g = &mut host.hub.guests[gi];
if !g.creative() || !(0..46).contains(&p.slot) {
if !g.creative() || !(1..46).contains(&p.slot) {
return;
}
let item = self.item_from_network(host, &p.item);
if item
.as_ref()
.is_some_and(|s| !self.creative_stack_valid(host, gi, s))
{
return;
}
let slot = p.slot as usize;
let g = &mut host.hub.guests[gi];
let old = g.menu_slot(slot);
@@ -1595,7 +1596,14 @@ impl Game<'_> {
self.guest_carried_back(host, gi);
}
S::SelectBundleItem(p) => {
if !spectator && let Ok(slot) = usize::try_from(p.slot) {
// ServerGamePacketListenerImpl.handleBundleItemSelectedPacket: the slot
// is the open menu's (#4436).
let own = Self::lan_crafting_table_own_slots(host, conn)
.or_else(|| Self::lan_station_own_slots(host, conn));
let slot = usize::try_from(p.slot)
.ok()
.and_then(|slot| bundle_inventory_slot(slot, own));
if !spectator && let Some(slot) = slot {
host.hub
.select_bundle_item(conn, slot, p.index, &mut self.world);
}
@@ -1875,6 +1883,23 @@ impl Game<'_> {
}
}
/// What ServerboundSetCreativeModeSlotPacket asks of a stack. Its codec
/// (`ItemStack.validatedStreamCodec`) encodes the stack with `ItemStack.CODEC`, whose count is
/// 1 to 99: more fails the packet and disconnects the player, as vanilla's decoder error does.
/// handleSetCreativeModeSlot's `validData` then ignores a stack beyond its own stack size.
fn creative_stack_valid(&self, host: &mut LanHost, gi: usize, s: &ItemStack) -> bool {
if s.count > 99 {
let g = &host.hub.guests[gi];
let conn = g.conn;
let reason = format!("Invalid packet: count {} is not in 1..99", s.count);
eprintln!("{} sent an invalid creative stack: {reason}", g.name);
host.hub
.kick(conn, horizon_protocol::text::literal(&reason));
return false;
}
s.count <= self.stack_max_size(s)
}
fn item_from_network(
&self,
host: &LanHost,
@@ -2501,7 +2526,9 @@ impl Game<'_> {
return;
}
let g = &mut host.hub.guests[i];
// restartClientLoadTimerAfterRespawn.
g.dead = false;
g.load_timeout = horizon_client::lan::guest::CLIENT_LOADED_TIMEOUT;
g.death_time = 0;
g.health = 20.0;
g.food = 20;
@@ -3059,9 +3086,13 @@ impl Game<'_> {
// LivingEntity.aiStep: a Riptide spin counts down on the server's
// copy too, or it would stay in the spin pose (eye 0.4) for good.
// LivingEntity.tick: a changed scale rebuilds the box. Player.tick
// ends with updatePlayerPose: a swimmer, or a player crawling
// under a block, takes the short box its moves are checked with.
let _ = w.server_player_pose_tick();
// ends with updatePlayerPose — or, for a passenger, the rideTick
// path that clears a swim (#4509 / #3308).
let _ = if riding {
w.passenger_pose_tick()
} else {
w.server_player_pose_tick()
};
let mut events = std::mem::take(&mut w.player.events);
// LayeredCauldronBlock.handleEntityOnFireInside along those moves.
for event in &events {
@@ -3804,6 +3835,7 @@ impl Game<'_> {
self.combat.stuck.tick();
}
for g in &mut host.hub.guests {
g.shoulders = super::shoulders::shoulder_variants(g.saved.as_ref());
g.stuck = match self.guest_combat.get_mut(&g.conn) {
Some(combat) => {
if !paused {
@@ -3954,3 +3986,31 @@ mod tests {
);
}
}
/// A `ServerboundSelectBundleItemPacket` slot as the hub's `select_bundle_item` reads it.
/// With a crafting table or a station open (`own` slots of its own first), its
/// inventory part is the inventory menu's main inventory and hotbar from slot 9, in
/// the same order; its own slots hold no bundle the hub can reach.
fn bundle_inventory_slot(slot: usize, own: Option<usize>) -> Option<usize> {
match own {
Some(own) => slot.checked_sub(own).filter(|s| *s < 36).map(|s| s + 9),
None => Some(slot),
}
}
#[cfg(test)]
mod bundle_slot_tests {
use super::bundle_inventory_slot;
/// A crafting table (10 slots of its own) and an enchanting table (2): the first
/// inventory slot and the last hotbar slot map to 9 and 44; their own slots to none.
#[test]
fn station_menus_map_to_the_inventory_menu() {
assert_eq!(bundle_inventory_slot(10, Some(10)), Some(9));
assert_eq!(bundle_inventory_slot(45, Some(10)), Some(44));
assert_eq!(bundle_inventory_slot(2, Some(2)), Some(9));
assert_eq!(bundle_inventory_slot(5, Some(10)), None);
assert_eq!(bundle_inventory_slot(46, Some(10)), None);
assert_eq!(bundle_inventory_slot(45, None), Some(45));
}
}
@@ -583,6 +583,13 @@ impl Game<'_> {
/// (`window`; None closes the table whichever it is, and 0 the inventory
/// menu) gives its grid back to the inventory, what doesn't fit is
/// dropped. False when no such window is open.
/// The open crafting table's own slots (result and grid) before the
/// player's inventory, if the guest has one open.
pub(super) fn lan_crafting_table_own_slots(host: &LanHost, conn: u32) -> Option<usize> {
let (_, _, menu, _) = host.play.get(&conn)?.crafting.table.as_ref()?;
Some(menu.slot_count() - 36)
}
pub(super) fn lan_crafting_closed(
&mut self,
host: &mut LanHost,
@@ -95,6 +95,7 @@ pub(super) fn acts_on_player(e: &MobEvent) -> bool {
matches!(
e,
MobEvent::AttackPlayer(..)
| MobEvent::CubeHurtPlayer { .. }
| MobEvent::AttackPlayerWithEffect { .. }
| MobEvent::StingPlayer { .. }
| MobEvent::DamagePlayer { .. }
+25 -25
View File
@@ -11,6 +11,17 @@ use horizon_protocol::{
};
use horizon_world::registry::BlockStateId;
/// A particle request awaiting delivery to players in its level.
pub(super) struct QueuedParticle {
pub dimension: &'static str,
pub options: ParticleOptions,
pub state: Option<BlockStateId>,
pub at: [f64; 3],
pub spread: [f64; 3],
pub speed: f64,
pub count: i32,
}
/// An ARGB colour as the options' RGBA.
fn rgba(argb: i32) -> [f32; 4] {
let c = |shift: i32| ((argb >> shift) & 255) as f32 / 255.0;
@@ -367,32 +378,23 @@ impl Game<'_> {
/// the players (a guest's turn through `as_guest`, a host's hit on a
/// guest) the packet waits for the turn to end so that it reaches every
/// player, the guest too.
#[allow(clippy::too_many_arguments)]
pub(super) fn lan_send_particles(
&mut self,
dimension: &'static str,
options: &ParticleOptions,
state: Option<BlockStateId>,
at: [f64; 3],
spread: [f64; 3],
speed: f64,
count: i32,
) {
pub(super) fn lan_send_particles(&mut self, request: QueuedParticle) {
if self.lan.is_none() {
if self.serving.is_some() || self.options.dedicated.is_some() {
self.creatures.lan_particles.push((
dimension,
options.clone(),
state,
at,
spread,
speed,
count,
));
self.creatures.lan_particles.push(request);
}
return;
}
let Some(particle) = self.network_particle(options, state) else {
let QueuedParticle {
dimension,
options,
state,
at,
spread,
speed,
count,
} = request;
let Some(particle) = self.network_particle(&options, state) else {
return;
};
let Some(host) = self.lan.as_ref() else {
@@ -428,10 +430,8 @@ impl Game<'_> {
/// The particles guests' turns put off.
pub(super) fn flush_lan_particles(&mut self) {
for (dimension, options, state, at, spread, speed, count) in
std::mem::take(&mut self.creatures.lan_particles)
{
self.lan_send_particles(dimension, &options, state, at, spread, speed, count);
for request in std::mem::take(&mut self.creatures.lan_particles) {
self.lan_send_particles(request);
}
}
@@ -989,6 +989,13 @@ impl Game<'_> {
self.world_options_text(event.text.as_deref());
}
}
/// Whether a World Options text box has focus (the port or a game rule box).
pub(super) fn world_options_typing(&self) -> bool {
self.world_options.as_ref().is_some_and(|o| {
o.rules.as_ref().is_some_and(|r| r.typing())
|| (o.port.focused && o.view == View::Options)
})
}
/// Digits typed into the port field (the keyboard, or a controller's
/// on-screen keyboard), and its backspace.
pub(super) fn world_options_text(&mut self, text: Option<&str>) {
@@ -141,6 +141,13 @@ impl Game<'_> {
true
}
/// How many slots of its own the guest's open station menu has before the
/// player's inventory (`slot_count - 36`), if one is open.
pub(super) fn lan_station_own_slots(host: &LanHost, conn: u32) -> Option<usize> {
let gs = host.play.get(&conn)?.station.as_ref()?;
Some(gs.menu.slot_count() - 36)
}
/// `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)) {
@@ -270,7 +270,7 @@ impl Game<'_> {
return;
}
let g = &host.hub.guests[gi];
if g.awaiting_teleport.is_some() || !g.loaded {
if g.awaiting_teleport.is_some() || !g.client_loaded() {
return;
}
let owner = host.hub.singleplayer_owner == Some(g.uuid);
+37
View File
@@ -132,6 +132,13 @@ impl Levels {
}
/// The levels that are not in place, by dimension.
/// The forced chunks of every level not in place, released.
pub(super) fn release_parked_forced_chunks(&mut self) {
for level in self.parked.values_mut() {
level.streaming.forced.clear();
}
}
pub(super) fn parked_dims(&self) -> Vec<Dim> {
self.parked.keys().copied().collect()
}
@@ -325,6 +332,19 @@ impl Game<'_> {
}
}
/// Far terrain for a client in `dim`: the level in place may be another
/// one, the server ticking its levels in turn.
pub(super) fn lod_source_in(
&self,
dim: Dim,
) -> Option<Arc<dyn horizon_client::streaming::LodSource>> {
if dim == self.dim {
return Some(self.streaming.lod_source());
}
let parked = self.levels.parked.get(&dim)?;
Some(parked.streaming.lod_source())
}
/// Put `dim`'s level in place for a command whose source is there
/// (`execute in`, `execute at` a player in another level), opening it if
/// nobody is in it; returns the level to put back afterwards. Only a
@@ -503,10 +523,15 @@ impl Game<'_> {
guests.iter().any(|g| g.dimension == d.id())
|| self.player_bed_levels().any(|b| b == *d)
|| forced
// A game test runs in its own level, players or not.
|| self.game_tests.has_tests_in(*d)
|| self.pregen_level() == Some(*d)
// A pending portal arrival keeps its destination level
// running so it can land and tick (the portal ticket).
|| !self.arrival_chunks(*d).is_empty()
// A portal ticket keeps its dimension active
// (`TicketType.shouldKeepDimensionActive`).
|| !self.portal_ticket_chunks(*d).is_empty()
})
.collect();
if dims.is_empty() {
@@ -515,6 +540,18 @@ impl Game<'_> {
dims
}
/// The chunks holding a portal ticket in `dim`'s level (`placePortalTicket`).
pub(super) fn portal_ticket_chunks(&self, dim: Dim) -> Vec<(i32, i32)> {
if dim == self.dim {
self.tickets.portal_chunks().collect()
} else {
self.levels
.parked
.get(&dim)
.map_or_else(Vec::new, |p| p.tickets.portal_chunks().collect())
}
}
/// Count the parked levels' empty ticks, and save and close the ones
/// (other than the Overworld) nobody has been in for a while.
pub(super) fn close_idle_levels(&mut self) -> Result<()> {
@@ -960,6 +960,12 @@ impl Game<'_> {
self.carts.remove(i);
self.riding = None;
}
// AbstractMinecart.activateMinecart: a powered activator rail ejects
// the rider, and getDismountLocationForPassenger places it beside the
// cart instead of leaving it at the seat.
if alive && self.riding.is_none() {
self.dismount_cart(i);
}
true
}
/// `getDismountLocationForPassenger`: beside the cart (across its facing), else on top.
+96
View File
@@ -20,6 +20,102 @@ impl Game<'_> {
let loading = self.title.is_none() && self.player.is_none() && self.win.is_none();
self.music
.tick(music.as_ref(), volume, loading, &mut self.audio);
// Options.musicToast's listener: ToastManager.setMusicToastDisplayState.
let state = self.settings.file.int("musicToast").clamp(0, 2) as u8;
if state != self.music_toast_state {
self.music_toast_state = state;
self.set_music_toast_state(state);
}
// MusicManager's showNowPlayingToast and hideNowPlayingToast calls.
let multiplier = f64::from(self.settings.notification_display_time);
for show in std::mem::take(&mut self.music.toast_requests) {
if let Some(toast) = &mut self.now_playing {
if show {
toast.show(multiplier);
} else {
toast.hide();
}
}
}
}
/// `Options.getFinalSoundSourceVolume(SoundSource.MUSIC)`.
fn final_music_volume(&self) -> f32 {
self.settings.master_volume * self.settings.volumes[1]
}
/// `ToastManager.setMusicToastDisplayState`: Pause and Pause and Toast make
/// the toast (the second one shows it at once while music is audible),
/// Never drops it.
fn set_music_toast_state(&mut self, state: u8) {
self.now_playing = match state {
0 => None,
_ => {
let mut toast = horizon_client::toasts::NowPlayingToast::default();
if state == 2 && self.final_music_volume() > 0.0 {
toast.show(f64::from(self.settings.notification_display_time));
}
Some(toast)
}
};
}
/// `NowPlayingToast.getNowPlayingString` of the song the music manager
/// plays: its file's short language key, slashes as dots.
fn now_playing_song(&self) -> Option<String> {
let id = self.music.current_instance()?;
let location = self
.audio
.engine
.instance(id)?
.sound
.as_ref()?
.location
.as_str();
let (namespace, path) = location.split_once(':').unwrap_or(("minecraft", location));
// Identifier.toShortLanguageKey.
let key = if namespace == "minecraft" {
path.to_owned()
} else {
format!("{namespace}.{path}")
}
.replace('/', ".");
Some(horizon_client::lang::get_or(&key, &key))
}
/// `ToastManager.update` (it plays no toast sound for this toast) and
/// `extractRenderState` for the Now Playing toast, which slides in at the
/// top left over every screen but the pause screen, and
/// `PauseScreen.extractRenderState` with its `tick`, which draws the song
/// name on the pause menu under the Pause and Pause and Toast settings
/// while music is audible. `hidden` (F1) keeps the toast
/// timing out without drawing it.
pub(super) fn draw_music_toast(
&mut self,
batch: &mut horizon_client::hud::HudBatch,
hidden: bool,
) {
let song = self.now_playing_song();
let Some(toast) = self.now_playing.as_mut() else {
return;
};
toast.update(song.is_some(), &mut self.music_notes);
let Some(song) = song else {
return;
};
if self.screen == super::Screen::Paused {
if self.pause_menu && self.final_music_volume() > 0.0 {
self.music_notes.tick();
horizon_client::toasts::draw_now_playing(
&self.gui.atlas,
batch,
0.0,
&song,
self.music_notes.color,
);
}
} else if !hidden
&& self.music_toast_state == 2
&& let Some(toast) = &self.now_playing
{
toast.draw(&self.gui.atlas, batch, &song, &self.music_notes);
}
}
/// `Minecraft.tick` -> `SoundManager.tick(pause)`, after the music, with
/// `pauseAllExcept(MUSIC, UI)` when the game pauses and `resume` when it
+36 -5
View File
@@ -12,6 +12,9 @@ use horizon_client::{
scoreboard as sb, text::Text,
};
/// The world size of one text pixel in a name tag: `Matrix4f.scale(0.025F, -0.025F, 0.025F)`.
pub(super) const NAME_TAG_UNIT: f32 = 0.025;
impl Game<'_> {
/// A named name tag names the mob under the crosshair.
pub(super) fn name_mob(&mut self, index: usize) -> bool {
@@ -324,16 +327,43 @@ impl Game<'_> {
if tag.discrete {
self.mark_translucent_entity(out.len(), "minecraft:name_tag");
let light = vertex_light(tag.light);
patches.extend(self.name_tag_quads(o, &tag.lines, FADED, true, 0, light, out));
patches.extend(self.name_tag_quads(
o,
&tag.lines,
FADED,
true,
0,
light,
NAME_TAG_UNIT,
out,
));
continue;
}
// LightCoordsUtil.lightCoordsWithEmission(lightCoords, 2).
let lit = (tag.light >> 4).max(2) << 4 | (tag.light & 15).max(2);
self.mark_entity(out.len(), "minecraft:name_tag");
let light = vertex_light(lit);
patches.extend(self.name_tag_quads(o, &tag.lines, 0xFF, false, 0, light, out));
patches.extend(self.name_tag_quads(
o,
&tag.lines,
0xFF,
false,
0,
light,
NAME_TAG_UNIT,
out,
));
// RenderPipelines.TEXT_SEE_THROUGH: no depth test, no lightmap.
patches.extend(self.name_tag_quads(o, &tag.lines, FADED, true, 2, 0, see_through));
patches.extend(self.name_tag_quads(
o,
&tag.lines,
FADED,
true,
2,
0,
NAME_TAG_UNIT,
see_through,
));
}
self.map_patches.extend(patches);
}
@@ -342,7 +372,7 @@ impl Game<'_> {
/// (in `text_layer` like the glyphs, a hair behind) when `background`.
/// Returns the glyph pixels the atlas still needs.
#[allow(clippy::too_many_arguments)]
fn name_tag_quads(
pub(super) fn name_tag_quads(
&self,
o: glam::DVec3,
lines: &[(Text, f64)],
@@ -350,6 +380,7 @@ impl Game<'_> {
background: bool,
text_layer: u32,
light: u32,
unit: f32,
out: &mut Vec<Vertex>,
) -> Vec<horizon_client::font::Patch> {
// Options.getBackgroundOpacity(0.25) black, as ARGB.color.
@@ -369,7 +400,7 @@ impl Game<'_> {
let mut batch = HudBatch::new([1, 1]);
for (text, lift) in lines {
let o = (o + glam::DVec3::new(0.0, *lift, 0.0)).as_vec3();
let (r, u) = (right.as_vec3() * 0.025, up.as_vec3() * 0.025);
let (r, u) = (right.as_vec3() * unit, up.as_vec3() * unit);
// Text space: x right, y down (scale 0.025, -0.025).
let at = |x: f32, y: f32| o + r * x - u * y;
let y0 = if text.plain() == "deadmau5" {
+11 -3
View File
@@ -807,7 +807,6 @@ impl Game<'_> {
}
}
/// Helpers for native mods, kept as stable as the hook points.
/// Server-level helpers for native mods.
#[allow(dead_code)]
impl GameLevel<'_> {
@@ -855,11 +854,20 @@ impl GameLevel<'_> {
LevelDimension::End => "minecraft:the_end",
};
self.native_particles
.push((dimension, options.clone(), state, at, spread, speed, count));
.push(super::lan_particles::QueuedParticle {
dimension,
options: options.clone(),
state,
at,
spread,
speed,
count,
});
true
}
}
/// Helpers for native mods, kept as stable as the hook points.
// Native mods use these; nothing in Horizon does.
#[allow(dead_code)]
impl Game<'_> {
@@ -1743,7 +1751,7 @@ mod tests {
.split("/// The hook points. Each does nothing in Horizon.")
.nth(1)
.unwrap()
.split("/// Helpers for native mods")
.split("/// Server-level helpers for native mods.")
.next()
.unwrap();
let level_section = source
@@ -373,6 +373,9 @@ impl Game<'_> {
max_section,
);
if let Some(found) = found {
// NetherPortalBlock.getExitPortal: the ticket goes on the chunk
// of the portal found.
self.place_portal_ticket((found.x >> 4, found.z >> 4));
let state = self.world.block(found.x, found.y, found.z);
let x_axis = self.res.behaviours().portal_axis(state).unwrap_or(true);
return Some(largest_rectangle_around(
@@ -390,8 +393,28 @@ impl Game<'_> {
b.create_portal(level, ticks, approximate, x_axis, &border, logical)
});
let _ = self.process_level_events();
if let Some(exit) = created {
// A portal built for the entity: the ticket goes on the chunk it
// arrives in (`TeleportTransition.PLACE_PORTAL_TICKET`).
let (at, _) = self.arrival(plan, exit);
self.place_portal_ticket(Self::chunk_of(at));
}
created
}
/// `ChunkPos.containing`: the chunk a position is in.
pub(super) fn chunk_of(at: Vec3) -> (i32, i32) {
((at.x.floor() as i32) >> 4, (at.z.floor() as i32) >> 4)
}
/// `Entity.placePortalTicket`: a `TicketType.PORTAL` ticket of radius 3
/// on `chunk` in the level in place, which keeps the area running for 300
/// ticks with no player in it (the entity that just came through ticks
/// there, and goes back through the portal when its cooldown ends).
/// Only a server holds levels with tickets of their own.
pub(super) fn place_portal_ticket(&mut self, chunk: (i32, i32)) {
if !self.options.execution_role.is_client() {
self.tickets.add_portal(chunk);
}
}
/// `NetherPortalBlock.createDimensionTransition`: the arrival position
/// and the yaw turned by.
pub(super) fn arrival(&self, plan: &Plan, exit: FoundRectangle) -> (Vec3, f32) {
@@ -1038,6 +1061,8 @@ impl Game<'_> {
let (at, motion, [yaw, pitch]) = if plan.end {
// Relative.DELTA: the motion is kept.
let at = self.end_portal_exit(&plan);
// EndPortalBlock.getPortalDestination: PLACE_PORTAL_TICKET.
self.place_portal_ticket(Self::chunk_of(at));
let into_end = plan.dim == Dim::End;
let turn =
end_portal_rotation(into_end, [yaw, pitch], self.level.world_spawn_angle);
+52 -1
View File
@@ -106,13 +106,55 @@ impl Game<'_> {
self.keys.clear();
self.screen = Screen::Options;
self.options_from_title = from_title;
self.options_to_game = false;
let ui = &mut self.ui.options;
ui.open_at(!from_title && self.title.is_none(), page);
let root = self.options.resources.clone();
ui.languages = lang::available(&[&root]);
self.refresh_pack_lists();
}
/// While the resource pack screen is open, `resourcepacks/` is looked at once a second
/// and the list found again when its packs changed (`PackSelectionScreen.tick`'s
/// watcher).
fn watch_pack_folder(&mut self) {
if !self.ui.options.resource_packs_open() {
self.pack_folder = None;
return;
}
let now = std::time::Instant::now();
if self
.pack_folder
.as_ref()
.is_some_and(|(at, _)| now.duration_since(*at).as_secs_f32() < 1.0)
{
return;
}
let signature: Vec<(String, u64, Option<std::time::SystemTime>)> =
std::fs::read_dir(super::packs::packs_dir())
.into_iter()
.flatten()
.flatten()
.map(|e| {
let meta = e.metadata().ok();
(
e.file_name().to_string_lossy().into_owned(),
meta.as_ref().map_or(0, |m| m.len()),
meta.and_then(|m| m.modified().ok()),
)
})
.collect();
let changed = self
.pack_folder
.as_ref()
.is_some_and(|(_, seen)| *seen != signature);
self.pack_folder = Some((now, signature));
if changed {
let infos = super::packs::pack_infos(&self.vanilla_root());
self.ui.options.update_pack_infos(infos);
}
}
pub(super) fn draw_options(&mut self, batch: &mut HudBatch) {
self.watch_pack_folder();
let mut ui = std::mem::take(&mut self.ui.options);
ui.draw(&self.settings, batch, &self.gui.atlas, self.cursor);
self.ui.options = ui;
@@ -234,13 +276,17 @@ impl Game<'_> {
Event::Close if self.ui.options.resource_error.is_some() => {}
Event::Close => {
self.settings.save(&Self::options_path());
let to_game = std::mem::take(&mut self.options_to_game);
self.screen = if std::mem::take(&mut self.options_to_world_options) {
Screen::WorldOptions
} else if self.options_from_title {
} else if self.options_from_title || to_game {
Screen::Playing
} else {
Screen::Paused
};
if to_game {
self.capture(true);
}
self.dragging = false;
}
Event::Language(code) => {
@@ -327,9 +373,14 @@ impl Game<'_> {
if let Some(r) = self.renderer.as_mut() {
r.gamma = s.gamma;
r.fullbright = s.extras.fullbright;
r.improved_transparency = s.improved_transparency;
r.set_present_mode(s.vsync, s.horizon.present_mode);
r.animated_textures = s.horizon.animated_textures;
r.set_mipmap_levels(s.mipmap_levels.max(0) as u32);
r.set_texture_filtering(
s.file.int("textureFiltering"),
s.file.int("maxAnisotropyBit"),
);
r.set_world_post(
s.horizon.resolution_scale as f32 / 100.0,
s.horizon.fxaa,
+10 -5
View File
@@ -8,6 +8,7 @@ use horizon_client::{
Result,
gamepad::{self, Button, Edges, GlyphStyle, HealthWatch, PadState, Rumble},
keybinds::{KeyBinds, key_code, mouse_button},
keymapping::SNEAK,
};
use serde_json::{Value, json};
use std::time::Instant;
@@ -213,12 +214,16 @@ impl Game<'_> {
"key.sneak"
if self.settings.controller.toggle_sneak && !self.settings.toggle_crouch =>
{
// A stick click flips sneaking instead of holding it.
if down {
let sneaking = self.keys.contains(&KeyCode::ShiftLeft);
self.mapped_key(KeyCode::ShiftLeft, !sneaking, false)?;
// A stick click flips sneaking instead of holding it, as
// vanilla's toggle crouch does: a screen opening releases it
// and closing restores it (ToggleKeyMapping).
if !down {
return Ok(());
}
Ok(())
let screen_open = self.screen != Screen::Playing || self.title.is_some();
self.toggles.pad_toggle(SNEAK, screen_open);
self.sync_toggle(SNEAK);
self.key_pressed(KeyCode::ShiftLeft)
}
_ => {
let default = KeyBinds::default_of(action);
+25 -3
View File
@@ -57,6 +57,16 @@ fn is_dualsense(pad: &gilrs::Gamepad) -> bool {
(Some(vendor), Some(product)) if horizon_dualsense::is_dualsense(vendor, product))
}
/// SDL rows gilrs' bundled DB is missing (or only ships for Windows). Newer
/// than the crate's copy; `GilrsBuilder::add_mappings` prefers these. The
/// Ultimate 2's Linux/BT rows put the right stick on a2/a3 — without them
/// look never moves (#4343).
const EXTRA_SDL_MAPPINGS: &str = concat!(
"03000000c82d00001260000011010000,8BitDo Ultimate 2,a:b0,b:b1,back:b10,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b6,leftstick:b13,lefttrigger:a5,leftx:a0,lefty:a1,paddle1:b17,paddle2:b16,paddle3:b2,paddle4:b5,rightshoulder:b7,rightstick:b14,righttrigger:a4,rightx:a2,righty:a3,start:b11,x:b3,y:b4,platform:Linux,\n",
"05000000c82d00001260000001000000,8BitDo Ultimate 2,a:b0,b:b1,back:b10,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b6,leftstick:b13,lefttrigger:a5,leftx:a0,lefty:a1,paddle1:b17,paddle2:b16,paddle3:b2,paddle4:b5,rightshoulder:b7,rightstick:b14,righttrigger:a4,rightx:a2,righty:a3,start:b11,x:b3,y:b4,platform:Linux,\n",
"03000000c82d00000b31000014010000,8BitDo Ultimate 2,a:b0,b:b1,back:b6,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b8,leftshoulder:b4,leftstick:b9,lefttrigger:a2,leftx:a0,lefty:a1,rightshoulder:b5,rightstick:b10,righttrigger:a5,rightx:a3,righty:a4,start:b7,x:b2,y:b3,platform:Linux,\n",
);
impl Pads {
/// Open gilrs on first use. A platform without it (or a Linux without
/// udev) leaves controllers off; the game runs as before.
@@ -65,7 +75,10 @@ impl Pads {
return;
}
self.devices.started = true;
self.devices.gilrs = match gilrs::Gilrs::new() {
self.devices.gilrs = match gilrs::GilrsBuilder::new()
.add_mappings(EXTRA_SDL_MAPPINGS)
.build()
{
Ok(g) => {
for (_, pad) in g.gamepads() {
log::info!("Controller connected: {}", pad.name());
@@ -132,8 +145,17 @@ impl Pads {
}
let axis = |a| pad.value(a);
use gilrs::Axis as A;
state.left = [axis(A::LeftStickX), axis(A::LeftStickY)];
state.right = [axis(A::RightStickX), axis(A::RightStickY)];
let sdl_mapped = pad.mapping_source() == gilrs::MappingSource::SdlMappings;
let (left, right) = gamepad::stick_axes(
[axis(A::LeftStickX), axis(A::LeftStickY)],
[axis(A::RightStickX), axis(A::RightStickY)],
axis(A::LeftZ),
pad.vendor_id(),
pad.product_id(),
sdl_mapped,
);
state.left = left;
state.right = right;
// Triggers arrive as analog buttons on most mappings, as plain
// buttons on some.
let trigger = |b: gilrs::Button| {
@@ -146,6 +146,33 @@ impl Game<'_> {
}
}
}
// Armor stands riding a mob or the local player (a server's name tag
// hologram): seated like the displays above, so the name follows its
// vehicle instead of staying where the stand spawned. The stand's own
// vehicle attachment is the default 0, so its feet sit on the seat.
for i in 0..self.armor_stands.len() {
let Some(vehicle) = self.armor_stands[i].vehicle else {
continue;
};
let at = if Some(vehicle) == me {
let Some(at) = own_seat else { continue };
at
} else {
let Some(&j) = index.get(&vehicle) else {
continue;
};
let vehicle = &self.mobs[j];
riders::seat(
riders::passenger_attachment(vehicle),
0.0,
vehicle.position,
vehicle.yaw,
)
};
let stand = &mut self.armor_stands[i];
stand.position = at;
stand.velocity = Vec3::ZERO;
}
}
/// `Entity.getPassengerRidingPosition` of a vehicle: where the feet of a
Loaded 100 of 231 files, more files were not shown because too many files have changed in this diff. Show more