Compare commits

..
Author SHA1 Message Date
Horizon agentandClaude Opus 5.5 42ee9ad644 CI: run verify in the merge queue (merge_group)
The council decided to enable GitHub's merge queue on main (poll P43, decision D17). Main requires
`verify`, and no workflow listened to merge_group, so every queued pull request would have waited forever.

- ci.yml triggers on merge_group (checks_requested). verify stays unconditional and scopes the queue's
  commit against merge_group.base_sha/head_sha.
- Queue runs always use GitHub's runners: the commit can hold a fork's code, which job-started.sh
  refuses on ours, so SELF_HOSTED_CI=all must not send them there.
- Each entry gets its own concurrency group (merge_group.head_ref), never cancelled by the workflow.
- launcher and parity are skipped in the queue; main's push and parity-main still run them.
- docs/release-flow.md: what runs in the queue and the owner's steps to turn it on.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 10:43:04 +01:00
Kacper a21f8a3947 Merge pull request #14047 from MrCloudy2/fix/wind-charge-no-deflect-ticks
A player's wind charge can't be turned in its first five ticks
2026-11-12 10:36:19 +01:00
Kacper f0811d3363 Merge pull request #14598 from ilguerro/perf/faster-client-lib-tests
Load the prepared test resources once per test process
2026-11-12 09:50:51 +01:00
Kacper c543cb8689 Merge pull request #14596 from Ragbie/fix/orb-fall-distance
Experience orbs keep their fall distance and make a vibration when they land
2026-11-12 09:50:34 +01:00
Kacper 4b65ec8a0d Merge pull request #13578 from NikitaMGrimm/hdr-one-peak
HDR float frame: one peak, the world mastered for 1000 nits
2026-11-12 09:49:46 +01:00
Kacper bb8a15db1e Merge pull request #15359 from Akirabane/fix/guest-invalid-entity-attack-kick
A guest who attacks an item, an orb, themselves or an arrow is disconnected, as handleAttack does
2026-11-12 09:39:18 +01:00
Kacper 555b885429 Merge pull request #14860 from vermingov/fix/cloud-fall-distance-14853
An area effect cloud keeps the fall_distance it was loaded with (#14853)
2026-11-12 09:38:57 +01:00
Kacper 722e63500a Merge pull request #14848 from vermingov/fix/trident-fall-distance-14846
A trident keeps the fall_distance it was loaded with (#14846)
2026-11-12 09:38:40 +01:00
Kacper f1708966d5 Merge pull request #14612 from Solenopsisbot/t3/nameplate-distance-attributes
Client: honor synced nameplate distances
2026-11-12 09:38:23 +01:00
Kacper 7b45eb9614 Merge pull request #13962 from horizon-rust/split/telemetry-probes
horizon-system: the process probes leave horizon-bench (layer plan M5)
2026-11-12 09:33:41 +01:00
Kacper 911aa6ddc6 Merge pull request #14904 from vermingov/fix/collar-colour-14897
A wolf's or cat's CollarColor outside 0..15 reads white (#14897)
2026-11-12 09:27:07 +01:00
Kacper 5816e84e56 Merge pull request #14822 from DUzzL/fix/14800-arrow-air
An arrow loads and keeps its saved Air
2026-11-12 09:26:50 +01:00
Kacper 1cd6c70899 Merge pull request #14771 from zenith600/launcher/version-file-without-game
Launcher: a folder with 26.3's version file but not its game says to play 26.3 once
2026-11-12 09:26:32 +01:00
Kacper eb084c5152 Merge pull request #15058 from ilguerro/fix/fox-village-shade-defend-trusted
Keep foxes out of the shade in villages and defending the players they trust
2026-11-12 09:20:15 +01:00
Kacper aa3483bf5a Merge branch 'main' into perf/faster-client-lib-tests 2026-11-12 09:15:37 +01:00
Kacper 23678db4f7 Merge branch 'main' into fix/orb-fall-distance 2026-11-12 09:15:28 +01:00
Kacper a97725d63c Merge branch 'main' into fix/wind-charge-no-deflect-ticks 2026-11-12 09:15:09 +01:00
Kacper 0ce3cf8624 Merge branch 'main' into hdr-one-peak 2026-11-12 09:14:54 +01:00
Kacper aa548b0a41 Merge branch 'main' into fix/guest-invalid-entity-attack-kick 2026-11-12 09:04:15 +01:00
Kacper cd4e73e270 Merge branch 'main' into fix/collar-colour-14897 2026-11-12 09:04:06 +01:00
Kacper d946b838fe Merge branch 'main' into fix/cloud-fall-distance-14853 2026-11-12 09:03:57 +01:00
Kacper b2d30693b8 Merge branch 'main' into fix/trident-fall-distance-14846 2026-11-12 09:03:48 +01:00
Kacper 8b13f108ef Merge branch 'main' into fix/14800-arrow-air 2026-11-12 09:03:38 +01:00
Kacper a15a3ea50c Merge branch 'main' into launcher/version-file-without-game 2026-11-12 09:03:29 +01:00
Kacper 5ddbaaab62 Merge branch 'main' into t3/nameplate-distance-attributes 2026-11-12 09:03:19 +01:00
Kacper 6c34352b0f Merge pull request #14931 from Detimes/perf-equipment-cache
Perf: convert a guest's equipment only when a stack changed
2026-11-12 08:52:49 +01:00
Kacper 52540d4ff2 Merge pull request #13830 from RINNECODER/up13/camera-armor-stand
Spectator camera: a server's camera on an armor stand holds the view
2026-11-12 08:52:32 +01:00
Kacper f671c935c2 Merge pull request #11190 from Ragbie/fix/tnt-minecart-explosion-power
TNT minecarts keep explosion_power and explosion_speed_factor, and blow with them
2026-11-12 08:52:14 +01:00
Kacper 11e85c0411 Merge pull request #14784 from MSedgeMC/fix/port-error-tooltip
The LAN port box says why its number is wrong
2026-11-12 08:51:57 +01:00
Kacpherek 2dabcba7dc Merge main into fix/fox-village-shade-defend-trusted 2026-11-12 08:44:32 +01:00
Kacper c94cdb0c76 Merge pull request #14131 from MSedgeMC/fix/component-can_eat
Food and consumable components come from the stack
2026-11-12 08:41:33 +01:00
Kacper 8182a33000 Merge pull request #14943 from vermingov/fix/shulker-attach-face-14940
A shulker's AttachFace is narrowed to a byte before the face is picked (#14940)
2026-11-12 08:30:45 +01:00
Kacper dd90805814 Merge branch 'main' into fix/tnt-minecart-explosion-power 2026-11-12 08:30:16 +01:00
Kacper 0c1a839a02 Merge pull request #15192 from MSedgeMC/fix/ibeam
A hovered text field shows the I-beam cursor
2026-11-12 06:49:20 +01:00
Kacper 00f230d640 Merge pull request #13843 from v0ntr3n/fix/thunder-trap-random-streams
Use Level.randValue for thunder strike columns and trap horse RNG for scatter
2026-11-12 06:48:52 +01:00
Kacper 535211eb7e Merge pull request #13966 from horizon-rust/fix/warden-silent-unmount-wither-easy
Wardens: silent ones make no sound, forced riders get off to dig; withers' idle volleys wait for Normal
2026-11-12 06:47:41 +01:00
Kacper d4c59f8c7a Merge pull request #15021 from DUzzL/fix/15013-launcher-ipv4-fallback
launcher: fall back to IPv4 when IPv6 to Microsoft sign-in is dead
2026-11-12 06:34:08 +01:00
Kacper 82f042de20 Merge branch 'main' into split/telemetry-probes 2026-11-12 06:27:32 +01:00
Kacper c351efbab6 Merge branch 'main' into fix/warden-silent-unmount-wither-easy 2026-11-12 06:27:20 +01:00
Kacper 3054963dec Merge pull request #15289 from DUzzL/fix/15276-set-custom-data
Remove empty custom_data after set_custom_data item modifier
2026-11-12 06:27:02 +01:00
Kacper 25a8fab320 Merge branch 'main' into fix/ibeam 2026-11-12 06:26:03 +01:00
Kacper 194c5c2c6c Merge pull request #15217 from GIGABAIT93/docs/index-updated
docs: the index's `updated`, and the fields a reader may rely on (#10562)
2026-11-12 06:25:38 +01:00
Kacper bab03f04db Merge pull request #15170 from MSedgeMC/fix/loot-set-enchantments
Set Enchantments replaces the levels
2026-11-12 06:25:24 +01:00
Kacper 9a71098242 Merge pull request #15061 from DUzzL/fix/15057-egg-breed
Thrown eggs hatch the chicken variant of their item
2026-11-12 06:25:11 +01:00
Kacper 6d76e89de2 Merge pull request #15053 from ilguerro/fix/fox-eat-effects-panda-pickup
Give a fox that eats its food its effects, and credit a panda's pickup
2026-11-12 06:24:20 +01:00
Kacper 609981c352 Merge pull request #14740 from TiggerTakkyTTT/fix/wind-burst-ender-dragon
Fix Wind Burst knockback for the Ender Dragon
2026-11-12 06:24:05 +01:00
Kacper 3aeb206813 Merge pull request #14587 from MSedgeMC/fix/skeleton-never-freezes
A skeleton and a loaded sulfur cube never freeze
2026-11-12 06:23:52 +01:00
Kacper 9bd670d3d3 Merge pull request #14574 from voidsipp/split/11535-sulfur-portal-compile-fix-20261109
Preserve portal blocks in sulfur cube explosions
2026-11-12 06:23:39 +01:00
Kacper 63bd6c914d Merge pull request #14561 from ilguerro/fix/lectern-book-state
Lecterns take a placed book into has_book, gate the comparator on it and post block_change
2026-11-12 06:23:26 +01:00
Kacper ea787a658c Merge pull request #14553 from ilguerro/fix/adventure-sign-place-on
Place a can_place_on block against a sign in adventure mode
2026-11-12 06:23:14 +01:00
Kacper 14e27946ca Merge pull request #15410 from Web-mc1/fix/item-set-count-overflow
Guard item count arithmetic against i32 overflow
2026-11-12 06:20:31 +01:00
Kacper 9639526d6b Merge pull request #15360 from Web-mc1/fix/pvp-held-count-overflow
Guard held item count against u32 overflow
2026-11-12 06:20:18 +01:00
Kacper d9b9996a4d Merge pull request #14850 from DUzzL/fix/14806-fire-reset
A mob's negative Fire is reset to 0 on its first tick
2026-11-12 04:54:06 +01:00
Kacper a71f9d3bd3 Merge pull request #11507 from voidsipp/fix/shield-blocking-11338
Fix built-in shield piercing, use statistics and durability
2026-11-12 04:53:39 +01:00
Kacpherek 91e8f91fee Skeleton trap test: the horse's random is an EntityRandom 2026-11-12 04:52:33 +01:00
Kacpherek c6544a2311 Foxes do not flee wolves and polar bears while defending
Vanilla gates both avoidance goals on !isDefending(); a defending fox now goes on to the melee attack.
2026-11-12 04:49:41 +01:00
Kacpherek 63ad77a1da Send the drink game event for a fox's drinkable food
A consumable with the DRINK animation posts minecraft:drink instead of minecraft:eat, and the mob game-event forwarding now passes minecraft:drink on.
2026-11-12 04:48:53 +01:00
rdbtCVSandClaude Sonnet 5.5 440a7f0144 Format the held-count fold with rustfmt
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 03:47:32 +00:00
rdbtCVSandClaude Sonnet 5.5 96bb424a7f Format saturating item count arithmetic
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 03:47:18 +00:00
Kacpherek 5cdcbf9812 Accept libtest's singular banner for a one-test parity run 2026-11-12 04:47:15 +01:00
Kacpherek 3cc15c8ba0 Keep enchantments that set_enchantments does not name
Only the supplied enchantments are set, as vanilla does; the others on the stack stay.
2026-11-12 04:44:31 +01:00
Kacpherek e983ba70a5 Limit the never-freeze override to Skeleton; the bogged, parched and wither skeleton freeze 2026-11-12 04:39:52 +01:00
Kacpherek 1a5bea076d Set a placed lectern's has_book before the item's block_state is applied 2026-11-12 04:35:28 +01:00
Kacpherek 672b1cae9a Test that an empty set_custom_data merge adds no custom_data component 2026-11-12 04:33:33 +01:00
Kacpherek 9a2135d7b2 Decide whether a sign consumes the click before its commands run 2026-11-12 04:32:31 +01:00
Kacpherek beb77a48e0 Wrap the invalid-port tooltip to 170 pixels like vanilla 2026-11-12 04:31:33 +01:00
Kacper 2abe71ca92 Merge pull request #14589 from MSedgeMC/fix/drowned-fluid-push
A swimming drowned is not pushed by the current
2026-11-12 04:29:17 +01:00
Kacpherek d7a25b8aec Merge commit '571ef142c5' into unstick/14574
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
#	crates/horizon-client/src/game/explosion.rs
#	crates/horizon-client/src/game/nether_portal.rs
2026-11-12 04:28:26 +01:00
Detimes c6683ed9a2 Merge branch 'perf-equipment-cache' of https://github.com/Detimes/horizon into perf-equipment-cache 2026-11-12 03:21:14 +00:00
DetimesandClaude Opus 5.5 0a484606c9 Merge upstream/main into perf-equipment-cache
Keeps both new tests at the end of hub_tests.rs (main's teleport accept test and the equipment cache test).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 03:21:12 +00:00
Kacpherek 98961ccd14 Merge commit '571ef142c5' into unstick/15061
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
#	crates/horizon-client/src/projectiles.rs
2026-11-12 04:16:58 +01:00
Kacper f787306fce Merge pull request #15457 from svetch/fix/26.3-seventh-republish
Prepare: accept Mojang's seventh 26.3 republish (9f67e10e, index e99f9973)
2026-11-12 04:16:20 +01:00
Kacper d8bf54d1e6 Merge pull request #14494 from vnsmoreira/fix/explosion-creaking-heart-xp
A player's blast awards a natural creaking heart's experience
2026-11-12 04:16:11 +01:00
Kacper f165e7fa29 Merge pull request #15419 from yahya-nusseibeh/fix/launcher-sign-in-bounds
Launcher: bound the sign-in services' interval and lifetimes
2026-11-12 04:16:01 +01:00
Kacper 9d73f6a26a Merge pull request #15151 from MSedgeMC/fix/chiseled-shelf-can-take
fix: a chiseled bookshelf only gives a book to a hopper with room
2026-11-12 04:15:48 +01:00
Kacpherek c2bead90c4 Workflow files as on main: this pull request changes the item count only 2026-11-12 04:14:31 +01:00
Kacpherek e470dac100 Merge remote-tracking branch 'origin/main' into takeover/15410 2026-11-12 04:14:20 +01:00
Kacper 4a60f12112 Merge pull request #13630 from MSedgeMC/fix/goat-long-jump
A goat keeps its long-jump cooldown in its chunk save
2026-11-12 04:09:39 +01:00
Kacper 192be7194f Merge pull request #15120 from MSedgeMC/port_map/farmland-is-near-water
port_map: FarmlandBlock.isNearWater is the water fluid search
2026-11-12 04:01:45 +01:00
Kacper 5f3caf9be4 Merge pull request #13881 from Skr1zzzz/split-renderer
Move building and adopting the renderer out of game.rs into game/render_settings.rs
2026-11-12 04:00:55 +01:00
Kacper 571ef142c5 Merge pull request #15477 from Ragbie/fix/stack-dump-windows-unused
horizon-threads: stack_dump's Unix-only code no longer fails clippy on Windows
2026-11-12 03:48:06 +01:00
Kacper 21845f7949 Merge pull request #14376 from Joansitoh/fix/14371-rod-neighbours
Lightning rod: a strike updates the front block's neighbours without redstone experiments
2026-11-12 03:46:31 +01:00
Kacper 27a4dc65c9 Merge pull request #14365 from oloion/perf/range-dispatch-binary-search
assets: avoid heap allocation in item model range dispatch binary search
2026-11-12 03:46:19 +01:00
Kacper deaab60ba9 Merge pull request #15418 from yahya-nusseibeh/fix/protocol-inflate-growth
Protocol: grow the inflate buffer instead of reserving the declared size
2026-11-12 03:38:58 +01:00
Kacper c2c3cea8e3 Merge pull request #15478 from Bl4ckspell7/refactor/gfx-census-module
Renderer: residency and memory figures move out of gfx.rs into gfx/census.rs
2026-11-12 03:33:14 +01:00
Kacper a5dbd2c5c1 Merge pull request #15240 from yahya-nusseibeh/fix/neoforge-version-path
Port-pack: only a plain NeoForge version makes the loader jar's path
2026-11-12 03:32:40 +01:00
Kacper 4c4de96fa5 Merge pull request #14760 from MSedgeMC/fix/walk-target-scores
A stroll scores its ground the way the jar does
2026-11-12 03:31:51 +01:00
Kacper c19aaf98e5 Merge pull request #14060 from MSedgeMC/fix/jigsaw-priority-tooltip
The jigsaw's priority boxes explain themselves on hover
2026-11-12 03:31:18 +01:00
Kacper 30a7b86bc8 Merge pull request #15296 from rdbtCVS/fix/deep-end-boat-void
Prevent deeply fallen boats from crashing dedicated servers
2026-11-12 03:18:14 +01:00
Kacper 0e0f95efa6 Merge pull request #15281 from Akirabane/fix/lan-accept-teleport-twice
LAN: a second accept of the same teleport id disconnects the guest, as vanilla does
2026-11-12 03:17:57 +01:00
Kacper 483497ef1c Merge pull request #14695 from ilguerro/fix/cave-air-is-air
Count cave air as air for open-water fishing and the flying kick
2026-11-12 03:17:40 +01:00
Kacper f7ba023dc6 Merge pull request #15435 from Akirabane/refactor/move-model-parts
Move the model parts out of mobs.rs into mobs/model.rs
2026-11-12 03:16:54 +01:00
Kacper cad46c5ff5 Merge pull request #15414 from mentallyunstable-lab/docs/layers-exceptions-15408
docs(layers): regenerate today's exceptions from tools/layers.json
2026-11-12 03:16:29 +01:00
Kacper 654bed3a7f Merge pull request #15248 from yahya-nusseibeh/fix/port-pack-licences
Port-pack: licence text stays one comment line, and families match whole words
2026-11-12 03:16:17 +01:00
Kacper 6154ce2282 Merge pull request #15160 from MSedgeMC/fix/brightness-gate
Level: getMaxLocalRawBrightness answers 15 outside the +-30,000,000 gate
2026-11-12 03:15:55 +01:00
Kacper 5e331b277a Merge pull request #15101 from angel2rider/tools/fixtures-ask-for-work-dir
tools: the command fixtures ask for their work directory instead of crashing
2026-11-12 03:15:33 +01:00
Kacper 96712da3cb Merge pull request #15091 from angel2rider/tools/port-ledger-json-after-query
tools: port_ledger.py accepts --json after the query's name, as its own help says
2026-11-12 03:15:22 +01:00
Kacper 755ba0b9a5 Merge pull request #15071 from MSedgeMC/fix/batch11-c
A tamed cat stops fleeing players
2026-11-12 03:15:11 +01:00
Kacper 29223492fc Merge pull request #15278 from DUzzL/fix/15270-alt-space
Swallow SC_KEYMENU so Alt+Space does not open the Windows system menu
2026-11-12 03:15:01 +01:00
Kacper d5f9e06dac Merge pull request #14902 from MSedgeMC/fix/chat-scroll-keeps-place
Changing the Chat option keeps the reader's place
2026-11-12 03:14:36 +01:00
Kacper 26d4f3f273 Merge pull request #14160 from ilguerro/fix/item-void-floor
Discard dropped items below each dimension's own void floor
2026-11-12 03:13:32 +01:00
Kacper fc71cbfeed Merge pull request #13755 from MSedgeMC/fix/follow-invisible
Follow-mob goals ignore invisible mobs
2026-11-12 03:13:02 +01:00
Kacpherek fcf7c70c83 Merge commit '24b778c3b4' into unstick/14131
# Conflicts:
#	crates/horizon-client/src/item_sync.rs
2026-11-12 02:58:34 +01:00
Kacpherek be34bdfd3c Merge commit '24b778c3b4' into unstick/14589
# Conflicts:
#	crates/horizon-client/src/mobs/living_tests.rs
2026-11-12 02:57:18 +01:00
Kacper 09d748f19c Merge branch 'main' into fix/shulker-attach-face-14940 2026-11-12 02:55:48 +01:00
Kacper 0d55f3075b Merge pull request #15190 from MSedgeMC/fix/chunk-empty-sections
Chunk storage: a chunk without a sections list loads as an empty chunk
2026-11-12 02:54:55 +01:00
Kacpherek e72b69caca Merge commit '24b778c3b4' into unstick/14494
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-12 02:54:54 +01:00
Kacpherek 166e09f55e Merge commit '24b778c3b4' into unstick/14587
# Conflicts:
#	tools/port_map/game-006.tsv
2026-11-12 02:52:49 +01:00
Kacper 24b778c3b4 Merge pull request #14173 from DUzzL/fix/14071-full-inventory-pickup
Sync a guest's refilled stack after a predicted drop
2026-11-12 02:45:28 +01:00
Kacper e1895229b8 Merge pull request #14499 from vnsmoreira/fix/explosion-player-xp
A player's blast pops the experience of what it breaks
2026-11-12 02:38:08 +01:00
Kacper cf79b811ac Merge pull request #13752 from DUzzL/fix/13665-crafted-stat
Award the crafted statistic for crafting-table and inventory-grid takes
2026-11-12 02:37:45 +01:00
Kacper 2b86defaa7 Merge pull request #13749 from MSedgeMC/fix/equippable-damage-on-hurt
Armor damage_on_hurt comes from the stack's equippable component
2026-11-12 02:37:35 +01:00
rdbt 0da40f990f Merge pull request #15493 from rdbtCVS/perf/mob-islands-to-main
Mobs: land the parallel mob islands on main (#15056 merged into the stack base)
2026-11-12 01:32:29 +00:00
Kacper d63ae80019 Merge pull request #15330 from Akirabane/fix/snow-golem-stroll-probability
A snow golem strolls with vanilla's rare probability, 1.0000001E-5
2026-11-12 02:26:31 +01:00
Kacper cd5c829460 Merge pull request #15318 from Akirabane/fix/new-world-folder-name
Create New World: the folder name is found as vanilla's findResultFolder finds it
2026-11-12 02:26:20 +01:00
Kacper 14ff11b96b Merge pull request #14651 from senjinthedragon/boat-passenger-water
Mobs: a mob sitting in a boat on water is not in the water, so a drowned or zombie in a boat burns in daylight
2026-11-12 02:25:43 +01:00
rdbtCVS 12bbd34fe5 Merge remote-tracking branch 'origin/main' into land-islands 2026-11-12 01:24:55 +00:00
Kacper 4a015bfb7c Merge pull request #15244 from yahya-nusseibeh/fix/servers-dat-lock
Client: hold the servers.dat lock on every save, each process with its own temp file
2026-11-12 02:24:25 +01:00
Kacper aaad7c57c1 Merge pull request #15237 from yahya-nusseibeh/fix/windows-console-close
Server: on Windows, wait for the clean stop when the console closes
2026-11-12 02:24:09 +01:00
Kacpherek 61c43e5109 Merge commit 'aab654d4e8' into unstick/11507
# Conflicts:
#	crates/horizon-client/src/game/dispense_fixture.rs
#	crates/horizon-client/src/game/shield.rs
2026-11-12 02:19:15 +01:00
Kacpherek 10721b288e Merge commit 'aab654d4e8' into unstick/14848
# Conflicts:
#	crates/horizon-client/src/entity_nbt/trident.rs
#	crates/horizon-client/src/game/trident.rs
2026-11-12 02:17:22 +01:00
Kacpherek be9a3c000f Merge commit 'aab654d4e8' into unstick/14822
# Conflicts:
#	crates/horizon-client/src/entity_nbt/projectile_fields.rs
2026-11-12 02:16:25 +01:00
Kacpherek 82a17c59ae Merge commit 'aab654d4e8' into unstick/14695
# Conflicts:
#	crates/horizon-client/src/lan/hub_tests.rs
2026-11-12 02:15:37 +01:00
Kacper 646722c466 Merge pull request #15290 from DUzzL/fix/15275-broken-stat
Count the broken statistic for every equipment break
2026-11-12 02:14:29 +01:00
Kacper 02f9ee4629 Merge pull request #14431 from Mayonesu0/up-13175-forced-chunk-ring
Rank a forced chunk's ring around it so it ticks on the first tick (#13175)
2026-11-12 02:14:03 +01:00
Kacper 902c147f8e Merge pull request #15231 from yahya-nusseibeh/fix/friends-try-every-endpoint
Friends: try every endpoint that answers before giving up on a direct join
2026-11-12 02:13:39 +01:00
Kacper 92166e6236 Merge pull request #14914 from vermingov/fix/axolotl-variant-14907
An axolotl with an unknown Variant id is lucy, not blue (#14907)
2026-11-12 02:13:22 +01:00
Kacpherek eb6536a832 Merge commit 'aab654d4e8' into unstick/14499
# Conflicts:
#	crates/horizon-client/src/game/explosion.rs
2026-11-12 02:13:10 +01:00
Kacper cf26b92d45 Merge pull request #11548 from socksprox/items/destroy-progress
Move the mining speed formula into horizon-items
2026-11-12 02:13:04 +01:00
Kacpherek bb9d4c2467 Merge commit 'aab654d4e8' into unstick/14860
# Conflicts:
#	crates/horizon-client/src/entity_nbt.rs
2026-11-12 02:13:03 +01:00
Kacpherek d9d89df9d8 Merge commit 'aab654d4e8' into unstick/14494
# Conflicts:
#	crates/horizon-client/src/game/explosion.rs
2026-11-12 02:12:20 +01:00
Kacpherek 5a5d9f780c Merge commit 'aab654d4e8' into unstick/14131
# Conflicts:
#	crates/horizon-client/src/game/using_item.rs
2026-11-12 02:10:09 +01:00
Kacpherek 687caa60c4 Merge commit 'aab654d4e8' into unstick/13755
# Conflicts:
#	crates/horizon-client/src/mobs.rs
2026-11-12 02:08:55 +01:00
Kacpherek 9503016e93 Merge commit 'aab654d4e8' into unstick/13752
# Conflicts:
#	crates/horizon-client/src/menu/place/transfer_all_tests.rs
2026-11-12 02:07:56 +01:00
Kacpherek 344bcdec9a Merge commit 'aab654d4e8' into unstick/15021
# Conflicts:
#	launcher/src/account/tests.rs
2026-11-12 02:07:33 +01:00
Kacpherek cce82d001c Merge commit 'aab654d4e8' into unstick/13749
# Conflicts:
#	crates/horizon-client/src/item_sync.rs
2026-11-12 02:06:57 +01:00
Kacpherek f9d2eddb83 Merge commit 'aab654d4e8' into unstick/15190
# Conflicts:
#	crates/horizon-world/src/chunk.rs
2026-11-12 02:06:21 +01:00
Kacpherek d66d48d9a3 Merge commit 'aab654d4e8' into unstick/15278
# Conflicts:
#	crates/horizon-threads/src/lib.rs
2026-11-12 02:05:59 +01:00
Kacpherek 18415b2fbd Merge commit 'aab654d4e8' into unstick/11190
# Conflicts:
#	crates/horizon-client/src/game/minecarts/nbt.rs
2026-11-12 02:05:06 +01:00
Kacper aab654d4e8 Merge pull request #15314 from Akirabane/docs/port-map-game-012-fixed-gaps
port map: four rows of game-012.tsv now say what live goat and fox spawns do
2026-11-12 02:00:27 +01:00
Kacper b7eeb79a33 Merge pull request #15051 from ilguerro/fix/beacon-payment-order-tooltips
Beacon: pay emerald before diamond and tooltip every button
2026-11-12 02:00:15 +01:00
Kacper db7b03ffda Merge pull request #14571 from voidsipp/fix/11318-end-gateway-cooldowns
Use entity-specific End gateway cooldowns
2026-11-12 02:00:00 +01:00
Kacper e27a62210c Merge pull request #14373 from Joansitoh/fix/14368-spawnable-pos
Check only the spawnable bounds for /spawnpoint and /setworldspawn
2026-11-12 01:59:50 +01:00
Kacper 06e9cfe6da Merge pull request #14010 from ilguerro/fix/hoglin-pacified-breeding
Keep mobs/ai.rs from growing: the hoglin check moves to piglin.rs
2026-11-12 01:58:54 +01:00
Kacper b6941b78f2 Merge branch 'main' into fix/snow-golem-stroll-probability 2026-11-12 01:55:53 +01:00
Kacper d64883cef3 Merge branch 'main' into fix/lan-accept-teleport-twice 2026-11-12 01:55:43 +01:00
Kacper 7ab3a23f95 Merge branch 'main' into fix/new-world-folder-name 2026-11-12 01:55:34 +01:00
Kacper 07050a34bd Merge pull request #15420 from yahya-nusseibeh/fix/launcher-data-link-above-root
Launcher: follow links above the data folder when moving it
2026-11-12 01:48:21 +01:00
Kacper 9054341b05 Merge pull request #15417 from AlfaRojo/fix/fishing-hook-harness-peaceful
fishing_hook_entities: run the servers in peaceful
2026-11-12 01:48:02 +01:00
Kacper 30db6aa7fd Merge pull request #14473 from voidsipp/split/11469-damage-source-foundation
Preserve explicit explosion damage sources
2026-11-12 01:46:47 +01:00
Kacper 663e7457ab Merge pull request #14260 from ilguerro/fix/void-floor-per-dimension
Start the void 64 blocks below each level's own floor
2026-11-12 01:45:31 +01:00
Kacper 2b375bf38c Merge pull request #15421 from vnsmoreira/packs/core-provided-blocks
Core shaders: blocks and samplers a pipeline provides, and blocks on one line
2026-11-12 01:36:40 +01:00
Kacper 172cec105a Merge pull request #15201 from DUzzL/fix/15134-flame-particles
Particles face the frame's camera, not the player's yaw and pitch
2026-11-12 01:36:24 +01:00
Kacpherek 45f36d84e1 Keep reset_unlit_fire before control.rs's test module 2026-11-12 01:36:18 +01:00
rdbt 5b28083ef3 Merge pull request #15481 from rdbtCVS/server/engine-parallel-tick
Server: [engine] parallel_tick turns the parallel tick on from server.toml
2026-11-12 00:36:18 +00:00
Kacper 3072b90817 Merge pull request #14885 from vermingov/fix/fireball-power-byte-14883
A fireball narrows a loaded ExplosionPower to a byte (#14883)
2026-11-12 01:36:07 +01:00
Solenopsisbot 8368def6fa Merge remote-tracking branch 'origin/main' into t3/nameplate-distance-attributes
# Conflicts:
#	crates/horizon-client/src/entities.rs
2026-11-12 11:35:51 +11:00
Kacper 4c29ce57c7 Merge pull request #13820 from RINNECODER/up12/ctm-chain-and-pass
OptiFine CTM: a picked tile chains to the rules naming it, and draws in its face's pass
2026-11-12 01:35:49 +01:00
rdbt 291b2c2b03 Merge pull request #14552 from rdbtCVS/perf/mob-loop-group
Mobs: the mob loop runs as a group of mobs, still on one thread (parallel mob tick, draft)
2026-11-12 00:35:30 +00:00
Kacpherek 10004fcd77 Merge remote-tracking branch 'origin/main' into unstick/14850 2026-11-12 01:35:00 +01:00
rdbt 9fa96cab6f Merge pull request #14531 from rdbtCVS/perf/ordered-draws
Random: one LegacyRandom stream drawn in list order from several threads (parallel mob tick, draft)
2026-11-12 00:34:44 +00:00
rdbt 892c64cc69 Merge pull request #14529 from rdbtCVS/perf/item-moves-ahead
Parallel tick: the dropped items' own moves worked out ahead on helpers
2026-11-12 00:34:14 +00:00
Joansitoh f8e59c3e3b Merge upstream main into fix/14371-rod-neighbours 2026-11-12 01:31:16 +01:00
rdbtCVSandClaude Sonnet 5.5 6fad4fb984 Server: [engine] parallel_tick in server.toml turns the parallel tick on
A host with a fixed startup command (a game panel running `./horizon-server run`) can't set
HORIZON_PARALLEL_TICK. `[engine] parallel_tick = true` makes horizon-server start the game with the
variable set to 1, the way `low_memory` sets MIMALLOC_ALLOW_THP. Off leaves the variable alone, so one
already in the environment still applies.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 00:30:19 +00:00
Kacper 32ed49b6d8 Merge pull request #15191 from MSedgeMC/fix/disabledlabel
A disabled button's label is grey
2026-11-12 01:24:41 +01:00
Kacper 35992267b9 Merge pull request #15150 from MSedgeMC/fix/jigsaw-local-edit
fix: a jigsaw edited in single player is applied
2026-11-12 01:24:24 +01:00
Kacper 95d025f4c2 Merge pull request #14566 from DUzzL/fix/14461-firework-dispenser-rotation
Turn a shot firework rocket to its motion like Projectile.shoot
2026-11-12 01:24:07 +01:00
Kacper 40b9d160c9 Merge pull request #13747 from MSedgeMC/fix/loom-dye-component
A loom reads a dye stack's dye component
2026-11-12 01:23:50 +01:00
Kacper 984872a0dd Merge pull request #15313 from AlfaRojo/fix/driven-mount-client-hurt
A driven horse takes no fall damage on its rider's client
2026-11-12 01:20:14 +01:00
Kacper 4d00052e33 Merge pull request #14179 from voduff/frames-projectile-hits
Projectiles hit item frames: the item comes out, then the frame breaks
2026-11-12 01:19:48 +01:00
Kacper 94077f9e66 Merge branch 'main' into launcher/version-file-without-game 2026-11-12 01:15:59 +01:00
Kacper 1dcb48e1ec Merge branch 'main' into fix/26.3-seventh-republish 2026-11-12 01:12:39 +01:00
Kacper 80570541d0 Merge branch 'main' into fix/goat-long-jump 2026-11-12 01:11:14 +01:00
Kacper 475ea2f6a7 Merge branch 'main' into fix/thunder-trap-random-streams 2026-11-12 01:11:06 +01:00
Kacper cc15269fb6 Merge branch 'main' into fix/deep-end-boat-void 2026-11-12 01:07:37 +01:00
Kacper 1177119180 Merge branch 'main' into fix/15275-broken-stat 2026-11-12 01:07:28 +01:00
Kacper b0918fb06c Merge branch 'main' into fix/loot-set-enchantments 2026-11-12 01:07:18 +01:00
Kacper 702b9e5a6e Merge branch 'main' into docs/index-updated 2026-11-12 01:07:03 +01:00
Kacper 0bdbffccf7 Merge branch 'main' into fix/fox-village-shade-defend-trusted 2026-11-12 01:06:24 +01:00
Kacper e3d11137f6 Merge branch 'main' into fix/fox-eat-effects-panda-pickup 2026-11-12 01:06:15 +01:00
Kacper 2bfb755693 Merge branch 'main' into fix/beacon-payment-order-tooltips 2026-11-12 01:06:06 +01:00
Kacper 783c053601 Merge branch 'main' into fix/15276-set-custom-data 2026-11-12 01:05:54 +01:00
Kacper 1416eb94b9 Merge branch 'main' into fix/pvp-held-count-overflow 2026-11-12 01:05:46 +01:00
Kacper 76a0ded489 Merge branch 'main' into fix/item-set-count-overflow 2026-11-12 01:05:37 +01:00
Kacper 44699b4c51 Merge branch 'main' into fix/protocol-inflate-growth 2026-11-12 01:05:19 +01:00
Kacper 22b2093962 Merge branch 'main' into fix/14806-fire-reset 2026-11-12 01:05:11 +01:00
Kacper 7a67cce201 Merge branch 'main' into t3/nameplate-distance-attributes 2026-11-12 01:04:46 +01:00
Kacper 11bda3ecc3 Merge branch 'main' into fix/port-error-tooltip 2026-11-12 01:04:43 +01:00
Kacper 7a725b44e8 Merge branch 'main' into fix/wind-burst-ender-dragon 2026-11-12 01:04:06 +01:00
Kacper c9fbcbc9c0 Merge branch 'main' into fix/orb-fall-distance 2026-11-12 01:03:41 +01:00
Kacper 3f287372f4 Merge branch 'main' into boat-passenger-water 2026-11-12 01:03:40 +01:00
Kacper d3e0272348 Merge branch 'main' into fix/11318-end-gateway-cooldowns 2026-11-12 01:03:18 +01:00
Kacper e3d38ae5bd Merge branch 'main' into fix/lectern-book-state 2026-11-12 01:03:08 +01:00
Kacper 58cdf1b551 Merge branch 'main' into fix/adventure-sign-place-on 2026-11-12 01:02:51 +01:00
Kacper 818e0a229e Merge branch 'main' into split/11469-damage-source-foundation 2026-11-12 01:02:42 +01:00
Kacper 09fa6403c5 Merge branch 'main' into up-13175-forced-chunk-ring 2026-11-12 01:02:33 +01:00
Kacper cad3d55f76 Merge branch 'main' into fix/14368-spawnable-pos 2026-11-12 01:02:24 +01:00
Kacper 0c03fe844b Merge branch 'main' into perf-equipment-cache 2026-11-12 01:02:19 +01:00
Kacper 0d85f3f65f Merge branch 'main' into fix/void-floor-per-dimension 2026-11-12 01:01:57 +01:00
Kacper ede16abf28 Merge branch 'main' into fix/guest-invalid-entity-attack-kick 2026-11-12 01:01:56 +01:00
Kacper 39046b18e8 Merge branch 'main' into fix/item-void-floor 2026-11-12 01:01:48 +01:00
Kacper 02b5729038 Merge branch 'main' into fix/wind-charge-no-deflect-ticks 2026-11-12 01:00:51 +01:00
Kacper 370854a657 Merge branch 'main' into fix/explosion-creaking-heart-xp 2026-11-12 01:00:16 +01:00
Kacper 46d26691cf Merge branch 'main' into fix/explosion-player-xp 2026-11-12 01:00:07 +01:00
Kacper 713fe9176d Merge branch 'main' into packs/core-provided-blocks 2026-11-12 01:00:07 +01:00
Kacper 2e23fd1dd1 Merge branch 'main' into perf/faster-client-lib-tests 2026-11-12 00:59:58 +01:00
Kacper 66319c12e6 Merge branch 'main' into fix/launcher-data-link-above-root 2026-11-12 00:59:58 +01:00
Kacper 31b0e68219 Merge branch 'main' into fix/windows-console-close 2026-11-12 00:59:42 +01:00
Kacper 8bf7dd6dfe Merge branch 'main' into fix/15134-flame-particles 2026-11-12 00:59:34 +01:00
Kacper 5814b1cb5f Merge branch 'main' into fix/friends-try-every-endpoint 2026-11-12 00:59:25 +01:00
Kacper 59cd7697be Merge branch 'main' into fix/jigsaw-local-edit 2026-11-12 00:59:21 +01:00
Kacper 063632d5f5 Merge branch 'main' into fix/axolotl-variant-14907 2026-11-12 00:59:12 +01:00
Kacper 8424af6894 Merge branch 'main' into fix/fireball-power-byte-14883 2026-11-12 00:59:04 +01:00
Kacper a0d2e7d935 Merge branch 'main' into fix/14071-full-inventory-pickup 2026-11-12 00:58:52 +01:00
Kacper 90827741ae Merge branch 'main' into up13/camera-armor-stand 2026-11-12 00:58:39 +01:00
Kacper 7e779ceb8b Merge branch 'main' into fix/loom-dye-component 2026-11-12 00:58:28 +01:00
Kacper 5481445402 Merge branch 'main' into up12/ctm-chain-and-pass 2026-11-12 00:58:12 +01:00
Kacper 2c62ea5aaa Merge branch 'main' into items/destroy-progress 2026-11-12 00:58:10 +01:00
Kacper 0bd5635d74 Merge branch 'main' into fix/hoglin-pacified-breeding 2026-11-12 00:58:04 +01:00
Kacper 53ef669745 Merge branch 'main' into fix/14461-firework-dispenser-rotation 2026-11-12 00:57:55 +01:00
Kacper 44d13ad5d4 Merge branch 'main' into fix/disabledlabel 2026-11-12 00:57:23 +01:00
Kacper f66e1a8d91 Merge branch 'main' into fix/servers-dat-lock 2026-11-12 00:57:15 +01:00
Bl4ckspellandClaude Opus 5.5 83703c28b2 refactor(gfx): move residency and memory figures into gfx/census.rs
gfx.rs is over the 3000-line limit and may not grow, and the 4x terrain MSAA work adds to it. The impl block from resident_sections to cpu_census moves unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 00:57:12 +01:00
Kacper 634df8744a Merge branch 'main' into fix/fishing-hook-harness-peaceful 2026-11-12 00:57:06 +01:00
Kacper e8b23d06ed Merge branch 'main' into fix/jigsaw-priority-tooltip 2026-11-12 00:56:34 +01:00
Kacper c1894da34b Merge branch 'main' into fix/walk-target-scores 2026-11-12 00:56:08 +01:00
Kacper 1b016ba22e Merge branch 'main' into fix/chiseled-shelf-can-take 2026-11-12 00:55:44 +01:00
Kacper 0482839c1b Merge branch 'main' into fix/neoforge-version-path 2026-11-12 00:55:35 +01:00
Kacper 023cef202f Merge branch 'main' into fix/launcher-sign-in-bounds 2026-11-12 00:55:27 +01:00
Kacper fdc97df507 Merge branch 'main' into split-renderer 2026-11-12 00:55:09 +01:00
Kacper 09fdaa392a Merge branch 'main' into fix/batch11-c 2026-11-12 00:55:01 +01:00
Kacper 6e5270105b Merge branch 'main' into tools/port-ledger-json-after-query 2026-11-12 00:54:52 +01:00
Kacper 7c9db3e706 Merge branch 'main' into tools/fixtures-ask-for-work-dir 2026-11-12 00:54:44 +01:00
Kacper e8b01b7282 Merge branch 'main' into fix/port-pack-licences 2026-11-12 00:54:27 +01:00
Kacper f22a814607 Merge branch 'main' into refactor/move-model-parts 2026-11-12 00:54:19 +01:00
Kacper fd7c423476 Merge branch 'main' into perf/range-dispatch-binary-search 2026-11-12 00:54:10 +01:00
Kacper 731699144c Merge branch 'main' into port_map/farmland-is-near-water 2026-11-12 00:54:01 +01:00
Kacper b14027d065 Merge branch 'main' into fix/brightness-gate 2026-11-12 00:53:53 +01:00
Kacper 5133e20195 Merge branch 'main' into docs/layers-exceptions-15408 2026-11-12 00:53:44 +01:00
Evrim CicekandClaude Opus 5.5 1fc67d4d10 stack_dump: the Unix-only code is unused on Windows without failing clippy
after_handler is only called on the signal path (Linux, Android, Apple),
and the tests' busy thread only serves the test that reads another
thread's stack there: on Windows clippy -D warnings stopped in
horizon-threads, and every build warned about after_handler. The function
takes the same cfg_attr(.., allow(dead_code)) as Watched::thread, and the
test helpers and their imports the test's cfg.

Fixes #15476.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 02:45:58 +03:00
Your Name 59f63a251a Merge remote-tracking branch 'origin/main' into fix/pvp-held-count-overflow 2026-11-12 12:40:48 +13:00
Your Name e745db772e Fix held item count overflow: use saturating_add; drop unrelated CI changes 2026-11-12 12:40:14 +13:00
Evrim Cicek 7573ae6a3e Merge remote-tracking branch 'upstream/main' into fix/orb-fall-distance
# Conflicts:
#	crates/horizon-client/src/entity_nbt.rs
#	crates/horizon-client/src/game/commands.rs
#	crates/horizon-client/src/game/loaded_arrow_shot_tests.rs
#	crates/horizon-client/src/game/remote.rs
#	tools/command_exec_corpus.py
2026-11-12 02:28:46 +03:00
AkirabaneandClaude Sonnet 5.5 3e75afe983 port_map: the goat and fox rows of game-012.tsv name the callers that exist
Goat.finalizeSpawn, GoatAi.initMemories and Fox.populateDefaultEquipmentSlots
said that Ctx::finalize and Ctx::populate call goat_spawn and
fox::populate_default_equipment, and that goat_spawn calls goat_init_memories.
They do not: Ctx::finalize and Ctx::populate have their own copies of the same
draws, and goat_spawn draws the two cooldowns itself.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 00:27:54 +01:00
Akirabane 29df2938b1 Merge origin/main into docs/port-map-game-012-fixed-gaps 2026-11-12 00:27:11 +01:00
Kacpherek 0808d1f189 Merge commit '3298a6dcd5' into unstick/14179
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
2026-11-11 23:59:55 +01:00
Kacper 014462436f Merge branch 'main' into docs/index-updated 2026-11-11 23:59:38 +01:00
Kacper 7dffe8983b Merge branch 'main' into fix/15276-set-custom-data 2026-11-11 23:58:31 +01:00
Kacper 5a4d8c11e6 Merge branch 'main' into fix/deep-end-boat-void 2026-11-11 23:58:23 +01:00
Kacper 789be0131d Merge branch 'main' into fix/item-set-count-overflow 2026-11-11 23:58:07 +01:00
Kacper 54ea398de4 Merge branch 'main' into fix/protocol-inflate-growth 2026-11-11 23:57:50 +01:00
Kacpherek 219d6b6b4e Keep mobs.rs from growing: the client_side note sits on its field's line 2026-11-11 23:57:12 +01:00
Kacper 0c07b18c76 Merge branch 'main' into perf-equipment-cache 2026-11-11 23:54:41 +01:00
Kacpherek 71d150b028 Read the redstone experiments flag from the enabled features in the lightning rod test 2026-11-11 23:54:20 +01:00
Kacper f8cf08849e Merge branch 'main' into fix/guest-invalid-entity-attack-kick 2026-11-11 23:54:18 +01:00
Kacper d78f353156 Merge branch 'main' into fix/wind-charge-no-deflect-ticks 2026-11-11 23:53:15 +01:00
Kacpherek 251c800717 Close the chat option change test in chat_tests 2026-11-11 23:53:01 +01:00
Kacper 6c58b77fed Merge branch 'main' into fix/explosion-creaking-heart-xp 2026-11-11 23:52:41 +01:00
Kacper 02b3da3eec Merge branch 'main' into fix/explosion-player-xp 2026-11-11 23:52:32 +01:00
Kacper 0bd65042f2 Merge branch 'main' into perf/faster-client-lib-tests 2026-11-11 23:52:23 +01:00
Kacpherek 245d97a312 Close the drowned current test in living_tests 2026-11-11 23:51:47 +01:00
Kacpherek eb1fbb151d Merge commit '3298a6dcd5' into unstick/14943
# Conflicts:
#	crates/horizon-client/src/entity_nbt.rs
2026-11-11 23:50:58 +01:00
Kacpherek 4676650694 Merge commit '3298a6dcd5' into unstick/15313 2026-11-11 23:50:19 +01:00
Kacpherek 9aae4a6671 Merge commit '3298a6dcd5' into unstick/14376 2026-11-11 23:50:18 +01:00
Kacpherek 660288e50b Merge commit '3298a6dcd5' into unstick/14902 2026-11-11 23:50:18 +01:00
Kacpherek 2497c21f48 Merge commit '3298a6dcd5' into unstick/14589 2026-11-11 23:50:17 +01:00
Kacpherek 5cbded2bd9 Merge commit '3298a6dcd5' into unstick/14904
# Conflicts:
#	crates/horizon-client/src/entity_nbt.rs
2026-11-11 23:50:15 +01:00
Kacper 53ffc05840 Merge branch 'main' into packs/core-provided-blocks 2026-11-11 23:50:04 +01:00
Kacper 5b275cd324 Merge branch 'main' into fix/launcher-data-link-above-root 2026-11-11 23:49:56 +01:00
Kacper 05c0a3942f Merge branch 'main' into fix/servers-dat-lock 2026-11-11 23:49:35 +01:00
Kacper fa34419e4b Merge branch 'main' into fix/fishing-hook-harness-peaceful 2026-11-11 23:49:32 +01:00
Kacper a640a5a91a Merge branch 'main' into fix/windows-console-close 2026-11-11 23:49:26 +01:00
Kacper 7475a27b7b Merge branch 'main' into fix/friends-try-every-endpoint 2026-11-11 23:49:17 +01:00
Kacper 721a16e17b Merge branch 'main' into fix/15134-flame-particles 2026-11-11 23:49:09 +01:00
Kacper 20c3b24dab Merge branch 'main' into fix/disabledlabel 2026-11-11 23:49:00 +01:00
Kacper 8d60a65c6c Merge branch 'main' into fix/jigsaw-priority-tooltip 2026-11-11 23:48:59 +01:00
Kacper 72d6a5b383 Merge branch 'main' into fix/jigsaw-local-edit 2026-11-11 23:48:51 +01:00
Kacper b1104143ef Merge branch 'main' into fix/14461-firework-dispenser-rotation 2026-11-11 23:48:42 +01:00
Kacper 1c98b67b44 Merge branch 'main' into fix/14071-full-inventory-pickup 2026-11-11 23:48:33 +01:00
Kacper 1299432e7a Merge branch 'main' into fix/walk-target-scores 2026-11-11 23:48:32 +01:00
Kacper 271bace8e1 Merge branch 'main' into fix/hoglin-pacified-breeding 2026-11-11 23:48:24 +01:00
Kacper d07604cd8e Merge branch 'main' into up13/camera-armor-stand 2026-11-11 23:48:15 +01:00
Kacper 195dceeb94 Merge branch 'main' into fix/chiseled-shelf-can-take 2026-11-11 23:48:07 +01:00
Kacper dec1f7bd51 Merge branch 'main' into up12/ctm-chain-and-pass 2026-11-11 23:48:06 +01:00
Kacper 3943570b53 Merge branch 'main' into fix/neoforge-version-path 2026-11-11 23:47:58 +01:00
Kacper a5b58a242b Merge branch 'main' into fix/loom-dye-component 2026-11-11 23:47:56 +01:00
Kacper 8dc176f4e6 Merge branch 'main' into fix/launcher-sign-in-bounds 2026-11-11 23:47:50 +01:00
Kacper 6494dc92e0 Merge branch 'main' into items/destroy-progress 2026-11-11 23:47:39 +01:00
Kacper 13fe0905ba Merge branch 'main' into split-renderer 2026-11-11 23:47:32 +01:00
Kacper 7cae9ff3c4 Merge branch 'main' into fix/batch11-c 2026-11-11 23:47:08 +01:00
Kacper d9d0c7ad13 Merge branch 'main' into tools/port-ledger-json-after-query 2026-11-11 23:47:00 +01:00
Kacper d60a13d0d3 Merge branch 'main' into tools/fixtures-ask-for-work-dir 2026-11-11 23:46:51 +01:00
Kacper 38e2c2522a Merge branch 'main' into fix/port-pack-licences 2026-11-11 23:46:35 +01:00
Kacper 8c46ceea46 Merge branch 'main' into refactor/move-model-parts 2026-11-11 23:46:26 +01:00
Kacper 7d7fa98a44 Merge branch 'main' into perf/range-dispatch-binary-search 2026-11-11 23:46:17 +01:00
Kacper fd2f881a32 Merge branch 'main' into port_map/farmland-is-near-water 2026-11-11 23:46:08 +01:00
Kacper 0f209ac875 Merge branch 'main' into fix/brightness-gate 2026-11-11 23:46:00 +01:00
Kacper de7ceb78d7 Merge branch 'main' into docs/layers-exceptions-15408 2026-11-11 23:45:51 +01:00
SolenopsisbotandCodex c3325b01f3 Client: honor synced nameplate distances
Co-Authored-By: Codex (GPT-6.1 Sol) <[email protected]>
2026-11-12 09:45:10 +11:00
Kacpherek 59dbb4703d Merge commit '8d44c49cd3' into unstick/14561
# Conflicts:
#	crates/horizon-client/src/game/books.rs
#	crates/horizon-client/src/game/use_hand.rs
2026-11-11 23:31:04 +01:00
Kacpherek fe78b81e88 Merge commit '8d44c49cd3' into unstick/14740
# Conflicts:
#	crates/horizon-client/src/game/dragon.rs
2026-11-11 23:27:19 +01:00
Kacper 28f0c9802e Merge branch 'main' into fix/driven-mount-client-hurt 2026-11-11 23:23:37 +01:00
Kacper e1a97b5783 Merge branch 'main' into fix/15275-broken-stat 2026-11-11 23:23:28 +01:00
Kacper 4e86da4a7a Merge branch 'main' into fix/loot-set-enchantments 2026-11-11 23:23:18 +01:00
Kacper 98df3549d2 Merge branch 'main' into fix/fox-village-shade-defend-trusted 2026-11-11 23:22:23 +01:00
Kacper 7d256e1766 Merge branch 'main' into fix/fox-eat-effects-panda-pickup 2026-11-11 23:22:14 +01:00
Kacper 1ec82f9a9f Merge branch 'main' into fix/beacon-payment-order-tooltips 2026-11-11 23:22:05 +01:00
Kacper d62d4787ab Merge branch 'main' into fix/chat-scroll-keeps-place 2026-11-11 23:21:20 +01:00
Kacper 012f70508a Merge branch 'main' into fix/14806-fire-reset 2026-11-11 23:21:02 +01:00
Kacper 40a5a99ca8 Merge branch 'main' into fix/port-error-tooltip 2026-11-11 23:20:26 +01:00
Kacper c0cfcda90e Merge branch 'main' into boat-passenger-water 2026-11-11 23:19:33 +01:00
Kacper 4a90452d73 Merge branch 'main' into fix/drowned-fluid-push 2026-11-11 23:19:17 +01:00
Kacper 68d5cd7114 Merge branch 'main' into fix/11318-end-gateway-cooldowns 2026-11-11 23:18:51 +01:00
Kacper b1fb8a4048 Merge branch 'main' into fix/adventure-sign-place-on 2026-11-11 23:18:25 +01:00
Kacper 37d6df3219 Merge branch 'main' into split/11469-damage-source-foundation 2026-11-11 23:18:16 +01:00
Kacper 59b2aed66c Merge branch 'main' into up-13175-forced-chunk-ring 2026-11-11 23:17:56 +01:00
Kacper b9abf293aa Merge branch 'main' into fix/14371-rod-neighbours 2026-11-11 23:17:47 +01:00
Kacper cb8761a18c Merge branch 'main' into fix/14368-spawnable-pos 2026-11-11 23:17:38 +01:00
Kacper f5dbdcadba Merge branch 'main' into fix/void-floor-per-dimension 2026-11-11 23:17:13 +01:00
Kacper d361eaf394 Merge branch 'main' into frames-projectile-hits 2026-11-11 23:17:03 +01:00
Kacper fe4d3a2e94 Merge branch 'main' into fix/item-void-floor 2026-11-11 23:16:54 +01:00
Akirabane 1b5be38573 Merge remote-tracking branch 'origin/main' into local/M1-move-model-parts 2026-11-11 23:14:52 +01:00
Akirabane 54909bf033 Merge remote-tracking branch 'origin/main' into local/W3-02-invalid-attack-kick 2026-11-11 23:13:48 +01:00
Akirabane 2106e9b21e Merge remote-tracking branch 'origin/main' into local/R13-snow-golem-stroll 2026-11-11 23:12:46 +01:00
Román BenjáminandClaude Opus 5.5 f5a3990fbf Prepare: accept Mojang's seventh 26.3 republish (9f67e10e, index e99f9973)
Fixes #15456.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 23:12:02 +01:00
Akirabane b1cdc91bf4 Merge remote-tracking branch 'origin/main' into local/R09-world-folder-name 2026-11-11 23:11:43 +01:00
Akirabane e7cdaa120a Merge remote-tracking branch 'origin/main' into local/PM4-game-012 2026-11-11 23:10:40 +01:00
Akirabane 58a9cbddca Merge remote-tracking branch 'origin/main' into local/R16-accept-teleport 2026-11-11 23:09:47 +01:00
Kacpherek 56d067048f Merge commit '6cc36c6f3a' into unstick/15296
# Conflicts:
#	crates/horizon-client/src/game/boats/below_world.rs
2026-11-11 22:29:50 +01:00
Kacpherek 68b16245aa Merge commit '6cc36c6f3a' into unstick/14904
# Conflicts:
#	crates/horizon-client/src/entity_nbt.rs
2026-11-11 22:27:17 +01:00
Kacpherek 32a43ad647 Merge commit '6cc36c6f3a' into unstick/14943
# Conflicts:
#	crates/horizon-client/src/entity_nbt.rs
2026-11-11 22:25:32 +01:00
Kacpherek b5c00e8040 Merge commit '6cc36c6f3a' into unstick/14914
# Conflicts:
#	crates/horizon-client/src/entity_nbt.rs
2026-11-11 22:24:24 +01:00
Kacpherek 23ae7d42c2 Merge commit '6cc36c6f3a' into unstick/14885
# Conflicts:
#	crates/horizon-client/src/entity_nbt.rs
2026-11-11 22:24:15 +01:00
Kacpherek 46179fd42e Pack screen: no I-beam on the filter while the Add-pack browser covers it 2026-11-11 22:23:34 +01:00
Kacpherek 1aa3ad9441 Merge commit 'a5df8273bb' into unstick/14179
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
#	crates/horizon-client/src/game/frames.rs
2026-11-11 22:06:31 +01:00
Kacpherek fc67c07589 Merge commit 'a5df8273bb' into unstick/14571
# Conflicts:
#	crates/horizon-client/src/game/entity.rs
2026-11-11 22:04:59 +01:00
Vinícius Moreira 190aa00a63 Merge commit 'c8e1eb66199f696d1a6218fbbaeef1863ce5cc00' into fix/explosion-creaking-heart-xp
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-11 17:57:10 -03:00
Vinícius Moreira a4266531f2 Merge commit 'c8e1eb66199f696d1a6218fbbaeef1863ce5cc00' into fix/explosion-player-xp
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-11 17:57:09 -03:00
Kacper bb6b34d530 Merge branch 'main' into fix/15275-broken-stat 2026-11-11 21:51:13 +01:00
Kacper b2258c35e7 Merge branch 'main' into fix/loot-set-enchantments 2026-11-11 21:51:09 +01:00
Kacper 90fed51ab1 Merge branch 'main' into fix/fox-village-shade-defend-trusted 2026-11-11 21:50:47 +01:00
Kacper 5de4e1ee74 Merge branch 'main' into fix/fox-eat-effects-panda-pickup 2026-11-11 21:50:43 +01:00
Kacper be2f80a4cf Merge branch 'main' into fix/beacon-payment-order-tooltips 2026-11-11 21:50:39 +01:00
Kacper 12e1d88d71 Merge branch 'main' into fix/chat-scroll-keeps-place 2026-11-11 21:50:20 +01:00
Kacper 6b1f808b13 Merge branch 'main' into fix/14806-fire-reset 2026-11-11 21:50:12 +01:00
Kacper 81336ce7a9 Merge branch 'main' into fix/wind-burst-ender-dragon 2026-11-11 21:50:02 +01:00
Kacper 47e22facbb Merge branch 'main' into fix/drowned-fluid-push 2026-11-11 21:49:56 +01:00
Kacper f2a97a7329 Merge branch 'main' into fix/skeleton-never-freezes 2026-11-11 21:49:52 +01:00
Kacper 1d2b14b01d Merge branch 'main' into fix/14371-rod-neighbours 2026-11-11 21:49:49 +01:00
Kacper 1a9bf76f69 Merge branch 'main' into fix/item-void-floor 2026-11-11 21:49:44 +01:00
yahya-nusseibeh 715402eb80 Merge remote-tracking branch 'upstream/main' into fix/port-pack-licences 2026-11-11 22:43:21 +02:00
yahya-nusseibehandClaude Opus 5.5 18e26a3e4d Port pack: count a family with its version run on as that family
The whole-word match turned GPLv3, LGPLv3, AGPLv3 and MPLv2 into custom: their token is
gplv3, not gpl. A token now matches a family when it is the family's name followed by
nothing, or by an optional v and digits. Tests for those spellings.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 22:43:18 +02:00
Kacpherek 95756af921 Merge commit 'a5df8273bb' into unstick/15313
# Conflicts:
#	crates/horizon-client/src/mobs.rs
2026-11-11 21:42:52 +01:00
Kacpherek 72e4d61b57 Merge commit 'a5df8273bb' into unstick/14651
# Conflicts:
#	crates/horizon-client/src/mobs/living_tests.rs
2026-11-11 21:42:15 +01:00
Kacpherek 4c2306d61d Merge commit 'a5df8273bb' into unstick/14561
# Conflicts:
#	crates/horizon-client/src/game.rs
#	crates/horizon-client/src/game/books.rs
2026-11-11 21:39:53 +01:00
Kacpherek ff08319e96 Merge commit 'a5df8273bb' into unstick/15192
# Conflicts:
#	crates/horizon-client-render/src/widgets.rs
2026-11-11 21:39:52 +01:00
Kacpherek 6604778b58 Merge commit 'a5df8273bb' into unstick/14431
# Conflicts:
#	crates/horizon-client-render/src/streaming.rs
2026-11-11 21:39:33 +01:00
Kacpherek 194ddaa041 Merge commit 'a5df8273bb' into unstick/14553
# Conflicts:
#	crates/horizon-client/src/game/use_hand.rs
2026-11-11 21:39:07 +01:00
Kacpherek 289674508b Merge commit 'a5df8273bb' into unstick/14784
# Conflicts:
#	tools/port_map/client2-015.tsv
2026-11-11 21:36:54 +01:00
Kacpherek ce25b504c8 Merge commit 'a5df8273bb' into unstick/14260
# Conflicts:
#	crates/horizon-client/src/projectiles.rs
2026-11-11 21:36:40 +01:00
Kacpherek 5a281cec36 Merge commit 'a5df8273bb' into unstick/14473
# Conflicts:
#	crates/horizon-client/src/game/explosion.rs
2026-11-11 21:36:25 +01:00
Akirabane f2ea985b8d Merge remote-tracking branch 'origin/main' into local/W3-02-invalid-attack-kick 2026-11-11 21:33:47 +01:00
Akirabane 33a70e3fa4 Merge remote-tracking branch 'origin/main' into local/R13-snow-golem-stroll 2026-11-11 21:33:28 +01:00
Akirabane f5ea4bde3a Merge remote-tracking branch 'origin/main' into local/R09-world-folder-name 2026-11-11 21:33:25 +01:00
Akirabane 251976843a Merge remote-tracking branch 'origin/main' into local/PM4-game-012 2026-11-11 21:33:23 +01:00
Akirabane 8e17b81335 Merge remote-tracking branch 'origin/main' into local/R16-accept-teleport 2026-11-11 21:33:21 +01:00
rdbtCVSandGPT 6.1 Sol 1bcc5ec3f9 assets: preserve vanilla range-dispatch binary-search hits
Address kacpherek's request on PR #14365: remove the post-hit tie scan while keeping slice-based search and the short-list upper bound. Extend the existing regression test with 16-entry duplicate and signed-zero lists; expected indices were observed on the JVM and the pinned vanilla method was checked with javap.

Validation: cargo check -p horizon-assets --locked; cargo test -p horizon-assets --locked (53 passed); cargo fmt -p horizon-assets -- --check; cargo clippy -p horizon-assets --all-targets --locked -- -D warnings.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-11 20:31:36 +00:00
rdbtCVSandGPT 6.1 Sol 9ac80b72b7 Move command position validation out of oversized dispatcher
Address the maintainer request on PR #14373 by relocating the loaded and spawnable block-position helpers into a sibling module. Keep both implementations and their game-module visibility unchanged.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-11 20:31:23 +00:00
rdbtCVSandGPT 6.1 Sol 1c5f7c2d3c Merge main into fix/thunder-trap-random-streams
Address the reviewer request by retaining main's invulnerable_time field rename alongside the PR's RNG changes.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-11 20:23:21 +00:00
AkirabaneandClaude Sonnet 5.5 a8d3ffaf2e Move the model parts out of mobs.rs into mobs/model.rs
Polygon, ModelNormals and Part move byte for byte; only visibility changes
(the private members the sibling modules reach become pub(super)), and mobs.rs
declares the module and re-exports Part and Polygon. A port map row follows the
move. mobs.rs goes from 4931 to 4720 lines without test modules, so a change
to Seen has room.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 21:23:03 +01:00
AkirabaneandClaude Opus 5.5 e7922700f0 Check that the guest's attack on the pig hurts it
The attack-checks test only asserted that attacking a pig does not
disconnect the guest. It now also asserts that the pig lost health,
so a normal attack on a mob is shown to still land.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 21:21:23 +01:00
DetimesandClaude Opus 5.5 7ad2c3b0bc Merge upstream/main into perf-equipment-cache
Brings the branch up to date with main for review.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 20:11:47 +00:00
Kacpherek f2b07381ab Merge main into pr-13755
# Conflicts:
#	crates/horizon-client/src/mobs.rs
2026-11-11 20:55:08 +01:00
Kacpherek 983bd56fb5 Merge main into pr-14598
# Conflicts:
#	crates/horizon-client/src/lan/prediction.rs
2026-11-11 20:54:52 +01:00
Vinícius MoreiraandClaude Opus 5.5 bee546c085 Core shaders: blocks and samplers a pipeline provides, and blocks on one line
A pack's core shader could read only vanilla's blocks and samplers. A
pipeline that gives it more (the improved-transparency stages' depth bounds
and coefficients for core/text, Polytone's blocks) translates it with
translate_core_with and the names it provides; CoreProgram::other lists
them in binding order, at other_binding(i). A uniform block declared on one
line is bound like one over several lines, where it came out unbound and
the shader was refused. An unprovided one is named as the pack declared it.

Part of #11486

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 16:53:50 -03:00
Kacpherek 80392718df Merge main into fix/15134-flame-particles 2026-11-11 20:45:10 +01:00
Kacpherek 122a7650ad Merge main into fix/disabledlabel 2026-11-11 20:45:03 +01:00
Kacpherek c4c5649ce9 Merge main into fix/chunk-empty-sections 2026-11-11 20:44:56 +01:00
Kacpherek da7c9d05b3 Merge main into fix/brightness-gate 2026-11-11 20:44:48 +01:00
Kacpherek b61cff5625 Merge main into fix/chiseled-shelf-can-take 2026-11-11 20:44:41 +01:00
Kacpherek 938c04c32e Merge main into fix/jigsaw-local-edit 2026-11-11 20:44:34 +01:00
Kacpherek 37f6d169d7 Merge main into port_map/farmland-is-near-water 2026-11-11 20:44:26 +01:00
Kacpherek ebcd7beedb Merge main into tools/fixtures-ask-for-work-dir 2026-11-11 20:44:19 +01:00
Kacpherek 1e45ee6ed3 Merge main into tools/port-ledger-json-after-query 2026-11-11 20:44:12 +01:00
Kacpherek 8bf3aa3982 Merge main into fix/batch11-c 2026-11-11 20:44:04 +01:00
Kacpherek 7b98f70c85 Merge main into fix/15013-launcher-ipv4-fallback 2026-11-11 20:43:13 +01:00
Kacpherek 7c723354eb Merge main into fix/walk-target-scores 2026-11-11 20:43:06 +01:00
Kacpherek c9675c1ba5 Merge main into fix/14461-firework-dispenser-rotation 2026-11-11 20:42:52 +01:00
Kacpherek debbbf6585 Merge main into fix/jigsaw-priority-tooltip 2026-11-11 20:42:16 +01:00
Kacpherek ac4204f395 Merge main into split-renderer 2026-11-11 20:42:09 +01:00
Kacpherek 7714b1d37a Merge main into fix/goat-long-jump 2026-11-11 20:41:46 +01:00
Kacpherek b045dddfa1 Merge main into fix/tnt-minecart-explosion-power 2026-11-11 20:41:30 +01:00
yahya-nusseibeh 9e701221fd Merge upstream main into fix/servers-dat-lock 2026-11-11 21:32:53 +02:00
yahya-nusseibeh 5a76ab5f51 Merge upstream main into fix/neoforge-version-path 2026-11-11 21:32:42 +02:00
yahya-nusseibeh ef218f6ced Merge upstream main into fix/windows-console-close 2026-11-11 21:32:37 +02:00
yahya-nusseibeh 8e4d76ef04 Merge upstream main into fix/friends-try-every-endpoint 2026-11-11 21:32:29 +02:00
yahya-nusseibehandClaude Opus 5.5 a8f543b1cc Launcher: follow links above the data folder when moving it
Moving the data folder was refused when any folder above a data root was a symbolic link (a
~/.local/share on another disk, Fedora Atomic's /home). Only the roots are copied, so only the
root itself may not be a link.

Split out of #15247 at review.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 21:31:44 +02:00
yahya-nusseibehandClaude Opus 5.5 5f61926401 Launcher: bound the sign-in services' interval and lifetimes
The device-code interval, its expiry and the Minecraft token lifetime came from the services
unchecked; Instant + a huge interval panicked the sign-in thread and left the window on the
code. The interval is clamped to 1-60 s (slow-down too), the device code's expiry to an hour
and the token lifetime to a week, all added with saturating_add.

Split out of #15247 at review.

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

Fixes #15323

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 13:25:20 -06:00
yahya-nusseibehandClaude Opus 5.5 98b40e8e5e protocol: grow the inflate buffer instead of reserving the declared size
A compressed frame declares its uncompressed size up front, and the inflater reserved that
much at once: a few bytes declaring 8 MiB made the reader allocate 8 MiB per frame. The
buffer now grows as output arrives (capped by DEFLATE's ~1032:1 ratio), so a lying frame
is refused after a small allocation and a real large body still inflates whole.

Split out of #15249 at review.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 21:16:47 +02:00
mentallyunstable-labandClaude Opus 5.5 018ca6a76e docs(layers): regenerate today's exceptions from tools/layers.json
The table said 47 exceptions at d5504cd328 and still listed edges that
are gone (the horizon-client-input row; -theme, -bench, -telemetry and
-mod-api-gen for the render crate). Rebuild it from the 30 entries in
'allowed' at a89f338 and point at tools/check_layers.py for the live
count.

Closes #15408

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 21:10:55 +02:00
Kevin Ramirez 242fba5b5a Merge upstream main for CI (play_ping_tests fix) 2026-11-11 13:07:53 -06:00
Web-mc1 c93c23248a Guard item count arithmetic against i32 overflow 2026-11-12 07:45:27 +13:00
Web-mc1 fbfa055eff Guard item count arithmetic against i32 overflow 2026-11-12 07:40:58 +13:00
Web-mc1 b610bc7618 Guard held item count against u32 overflow 2026-11-12 07:15:10 +13:00
Web-mc1 b082e400c6 Guard held item count against u32 overflow 2026-11-12 07:09:38 +13:00
AkirabaneandClaude Opus 5.5 9a267cf4bf Disconnect a player who attacks an item, an orb, themselves or an arrow
ServerGamePacketListenerImpl.handleAttack disconnects a player whose
attack names an ItemEntity, an ExperienceOrb, the player itself or an
AbstractArrow that is not isAttackable, with
multiplayer.disconnect.invalid_entity_attacked, after the world border,
attack range and PIERCING_WEAPON checks. A hosted or dedicated Horizon
server let the attack end without a word.

handleAttack's checks before Player.attack move to
game/lan_actions/attack_checks.rs: the border and range checks
guest_attack already did, then that test. An arrow's isAttackable reads
#minecraft:redirectable_projectile from the tags the players were sent,
so a data pack that adds arrows to it keeps them attackable.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 18:56:15 +01:00
AkirabaneandClaude Sonnet 5.5 f19afb61ad Merge origin/main into local/R16-accept-teleport
The conflict was the AcceptTeleportation arm in lan/hub.rs: the handler stays in
hub/accept_teleport.rs, which takes main's change of the guest's prediction
record (g.record instead of g.anticheat.record).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 17:14:23 +01:00
Kevin RamirezandClaude Opus 5.5 d9eab08f7f A driven horse takes no fall damage on its rider's client
Entity.hurt only calls hurtServer on a server level. The rider's client
ticks the horse it drives with the server's mob code, so a fall, lava or
suffocation hurt and killed the client's copy: it lay dead on the ground
while the server's horse lived, and could be ridden again (#15294).

Fixes #15294

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 09:34:53 -06:00
AkirabaneandClaude Sonnet 5.5 c6f61bbcc4 port_map: the goat and fox spawn rows of game-012.tsv name the live spawn path
Goat.finalizeSpawn, GoatAi.initMemories and Fox.populateDefaultEquipmentSlots
are reached by live spawns; Fox.finalizeSpawn keeps the one gap that is left.

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

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-11 14:58:17 +00:00
Riccardo Guerriero 09b8b5b94b Merge origin/main into perf/faster-client-lib-tests 2026-11-11 15:57:05 +01:00
Riccardo Guerriero d526f569a0 Merge origin/main into fix/beacon-payment-order-tooltips 2026-11-11 15:57:04 +01:00
Riccardo Guerriero 957ba89eac Merge origin/main into fix/item-void-floor 2026-11-11 15:57:04 +01:00
DUzzL c4146a25af Count the broken statistic for every equipment break
ServerPlayer.onEquippedItemBroken awards Stats.ITEM_BROKEN whatever the
slot. Award it in player_equipment_broke instead of at two call sites, so
worn armour, shields and enchantment-driven breaks count too.
2026-11-11 15:33:49 +01:00
GIGABAIT93andClaude Opus 5.5 dc064e4bb1 docs: broken_on picks a version, it isn't only shown
A reader skips a version broken on the player's Horizon commit for an
older one that builds; only downloads, stars and updated are for show and
sort. None of the four is checked against a downloaded file.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 14:40:07 +01:00
DUzzL 64fc971ebc Honor an egg's explicit chicken variant removal before the item default 2026-11-11 14:39:38 +01:00
rdbtCVSandGPT 6.1 Sol b52d83f71e fix(ui): wrap and anchor focused jigsaw priority tooltips
Match Tooltip.splitTooltip, WidgetTooltipHolder and BelowOrAboveWidgetTooltipPositioner. Keep real hover distinct from the hidden keyboard/controller pointer and test priority label/value/packet mapping.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-11 13:23:09 +00:00
GIGABAIT93andClaude Opus 5.5 c6e83d5f09 docs: the index's updated, and the fields a reader may rely on
`projects[].updated` is the date of a project's newest version, set by the
registry's publish job (horizon-rust/horizon-mods#683). While the index is
preview, a reader may rely on every field but the publish job's four
(`downloads`, `stars`, `updated`, `broken_on`), which may be absent at any
time. Point (3) of #10562.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 15:10:46 +02:00
rdbtCVSandGPT 6.1 Sol 72c066552e Merge main into hoglin feeding fix, preserving the extracted predicate
Keep main's takes_food extraction and canAgeUp baby check, and apply the contributor's pacified-hoglin breeding gate in ai/feeding.rs. Normalize two test citations to the vanilla method names.

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 14:19:49 +02:00
rdbtCVSandGPT 6.1 Sol 8c893c55f0 Merge main into split-renderer to pick up deterministic CI fixture
Preserve the contributor's renderer extraction unchanged while including main's fix for the Git maintenance race in test_git_slim. The diff against main remains the same four moved methods and port-map citation.

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 13:27:42 +02:00
TheGT 75cfb4cc28 Merge remote-tracking branch 'origin/main' into tools/fixtures-ask-for-work-dir 2026-11-11 11:23:39 +00:00
TheGT dd60b729ee Merge remote-tracking branch 'origin/main' into tools/port-ledger-json-after-query 2026-11-11 11:22:13 +00:00
MSedgeMC 651abefe2b port_map: WithInactiveMessage's grey label 2026-11-11 11:14:29 +00:00
MSedgeMC 8858d0c7e0 test: a disabled button's label is grey
The label's Draw::Text colour is 0xFFA0A0A0 when the button is inactive.
2026-11-11 11:14:29 +00:00
MSedgeMC df589fa039 test: a chunk without sections loads as an empty chunk
Without the change `ChunkData::new` answers
`Err(InvalidData, "chunk is missing sections")`.
2026-11-11 11:05:38 +00:00
MSedgeMC 2c8c04518d fix: a chunk without a sections list loads as a chunk with no sections
`SerializableChunkData.parse` reads the section list with
`getListOrEmpty("sections")`, so a chunk that does not carry one is a chunk with
no sections rather than an unreadable one. `ChunkData::new` refused it, so an
empty or hand-written chunk could not be opened; it now keeps an empty list in
the tag, which is what the writer would put there and what the accessors below
look for.
2026-11-11 11:05:37 +00:00
MSedgeMC 08e01dc3da port_map: EditBox.handleCursor's I-beam 2026-11-11 10:37:18 +00:00
MSedgeMC c289d208e6 test: a hovered text field asks for the I-beam cursor
Not hovered asks nothing; hovered asks Ibeam; a read-only one asks NotAllowed.
2026-11-11 10:37:18 +00:00
MSedgeMC 84d6e3d97a fix: a hovered text field shows the I-beam cursor
EditBox.handleCursor requests CursorTypes.IBEAM while hovered and editable, and
NOT_ALLOWED when it takes no text. Horizon's cursor set had no I-beam, and the
pack screen's search boxes and the settings shell's search never asked for one.
widgets::text_cursor adds it, the two screens call it where their fields are
drawn, and the client maps Cursor::Ibeam to CursorIcon::Text.
2026-11-11 10:36:57 +00:00
MSedgeMC 226d535e77 fix: a disabled button's label is grey
AbstractWidget.WithInactiveMessage.getMessage returns
ComponentUtils.mergeStyles(message, Style.EMPTY.withColor(-6250336)) while the
widget is inactive, so a disabled button's message is drawn grey (0xFFA0A0A0).
screen_draw::button drew the same white label in both states; it now colours
the label when inactive.
2026-11-11 10:35:57 +00:00
MSedgeMC 7f0f9337f3 port_map: Set Enchantments replaces the levels 2026-11-11 09:52:12 +00:00
MSedgeMC 7d011d64aa test: Set Enchantments replaces the levels 2026-11-11 09:52:12 +00:00
MSedgeMC 4ea481cb2f fix: Set Enchantments replaces the levels 2026-11-11 09:52:12 +00:00
MSedgeMC 2aeaeb2495 test: the +-30,000,000 gate answers 15, inside answers the raw brightness
`TestLevel` gains a `sky_light` override so the two answers differ (0 inside,
15 outside); without the change both read 0.
2026-11-11 09:24:13 +00:00
MSedgeMC 782331b499 fix: full daylight outside the +-30,000,000 gate in getMaxLocalRawBrightness
`LevelReader.getMaxLocalRawBrightness(pos)` answers 15 when the position is
outside `x >= -30000000 && z >= -30000000 && x < 30000000 && z < 30000000`,
because there is no chunk to ask there; inside it is `getRawBrightness(pos,
skyDarken)`. Horizon always asked the level, so past the gate it returned
whatever the (unloaded, dark) level answered: saplings and mushrooms never grow
and nothing that reads the brightness sees daylight.
2026-11-11 09:24:13 +00:00
MSedgeMC 30e4e488b2 port_map: ChiseledBookShelfBlockEntity.canTakeItem asks the target's room
The hopper's path applies the `hasAnyMatching` rule through
`can_take_into`.
2026-11-11 08:49:01 +00:00
rdbtCVSandGPT 6.1 Sol b2095cc1ca Merge current main into TNT minecart explosion fix
Preserve the contributor's TNT power change and the minecart extraction already on main. Git resolved both touched files without manual conflict edits. The net change against main is three files (+82/-2).

Validation: rustfmt, public-tree, layer/file-size checks and three command-fixture Python tests pass. The focused Rust test attempt failed in sccache before compiling Horizon; vanilla resources and the port ledger are unavailable, so gameplay proof remains outstanding.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-11 08:42:36 +00:00
MSedgeMC e2ee8ad7d9 test: a full hopper cannot take a book from a chiseled shelf
`a_full_hopper_cannot_take_a_book_from_a_chiseled_shelf` asks the rule
with a hopper full of stone (false) and an empty one (true). Without the
shelf rule both answer true.
2026-11-11 08:40:34 +00:00
MSedgeMC c59a4d6ffd fix: a chiseled bookshelf only gives a book to a hopper with room
`ChiseledBookShelfBlockEntity.canTakeItem` is
`target.hasAnyMatching(other -> other.isEmpty() || (same item and
components && other.count + stack.count <= target.getMaxStackSize(other)))`:
a hopper may take a book only when it has a free slot or a matching stack
with room.

Horizon's `Containers::can_take` had no shelf rule, so a full hopper
still pulled a book out (and put it back). The hopper's path now asks
`can_take_into`, which applies the shelf rule before the existing checks.
2026-11-11 08:40:13 +00:00
MSedgeMC 4256dec408 port_map: handleSetJigsawBlock applies locally too
The guest path marks the block entity; the integrated game applies the
same packet through the editor's Done.
2026-11-11 08:33:44 +00:00
MSedgeMC 959c3b6180 test: a single-player jigsaw edit applies and marks the chunk
`a_local_jigsaw_edit_applies_and_marks_the_chunk` sends a
`SetJigsawBlock` through the editor's Done path on an integrated game:
the block entity takes the new name and priorities and its chunk is
marked. Without the local application the name stays the default.
2026-11-11 08:33:44 +00:00
MSedgeMC e00864ba59 fix: a jigsaw edited in single player is applied
`ServerboundSetJigsawBlockPacket` is handled on the server
(`handleSetJigsawBlock`), which writes the block entity and marks it. The
editor's Done only builds that packet, and `Game::send` drops it when
there is no remote (the integrated server is this game), so a jigsaw edit
in single player changed nothing.

`block_editor_outcome` now applies the packet locally when `remote` is
none, through the same `apply_set_jigsaw_block` a guest's edit uses.
2026-11-11 08:33:23 +00:00
TiggerTakkyTTTandCodex 866057db34 Merge upstream/main into fix/wind-burst-ender-dragon
Co-Authored-By: Codex <[email protected]>
2026-11-11 15:30:28 +07:00
TheGT ee8d4c4a72 Merge remote-tracking branch 'origin/main' into tools/port-ledger-json-after-query 2026-11-11 07:36:06 +00:00
MSedgeMC fb05c1acfc port_map: FarmlandBlock.isNearWater is the water fluid search
Vanilla's predicate is `state.getFluidState().is(FluidTags.WATER)`, which
the inline 9x2x9 search already matches; the row's `canBeHydrated` gap
was wrong.
2026-11-11 05:02:45 +00:00
TheGTandOpenCode 8e4e46fdd6 tools: the command fixtures ask for their work directory instead of crashing
Co-Authored-By: OpenCode <[email protected]>
2026-11-11 04:36:10 +00:00
TheGTandOpenCode 9351cd381f tools: port_ledger.py accepts --json after the query's name, as its own help says
Co-Authored-By: OpenCode <[email protected]>
2026-11-11 04:26:33 +00:00
MSedgeMC 72e3ec1924 Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-11 03:53:38 +00:00
MSedgeMC a2cdad2a10 Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-11 03:53:17 +00:00
MSedgeMC 53e8715d83 Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-11 03:53:14 +00:00
MSedgeMC 278dee9871 Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-11 03:53:02 +00:00
MSedgeMC 7a8364a0f7 Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-11 03:52:53 +00:00
MSedgeMC c96f20ac6c port_map: map CatAvoidEntityGoal continue 2026-11-11 03:23:17 +00:00
MSedgeMC fb6707ec92 test: a tamed cat stops fleeing players 2026-11-11 03:23:17 +00:00
MSedgeMC dfc7fdd786 fix: a tamed cat stops fleeing players 2026-11-11 03:23:02 +00:00
Vinícius Moreira 306dca0a21 Merge remote-tracking branch 'upstream/main' into fix/explosion-creaking-heart-xp 2026-11-11 00:03:46 -03:00
Vinícius Moreira 541bc4b519 Merge remote-tracking branch 'upstream/main' into fix/explosion-player-xp 2026-11-11 00:03:46 -03:00
DUzzL 6352b7468c Thrown eggs hatch the chicken variant of their item
ThrownEgg.onHit sets the chicken's variant from the item's CHICKEN_VARIANT component (egg temperate, blue_egg cold, brown_egg warm); a thrown or dispensed egg now carries it.
2026-11-11 03:37:51 +01:00
Vinícius MoreiraandClaude Opus 5.5 26d32147ea Wrap the zombie owner's summon
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 22:43:53 -03:00
Vinícius MoreiraandClaude Opus 5.5 b5c4b2f84b Wrap the zombie owner's summon
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 22:43:52 -03:00
Vinícius MoreiraandClaude Opus 5.5 0b2ed50a68 A nested blast drops the TNT owner; the zombie owner stays and leaves no drops
A native explosion inside a TNT blast is Level.explode(null, ...): it
now takes blast_owner, blast_guest and blast_owner_player aside with
the rest of the blast's state. The group keeps its zombie persistent
and clears the zombie's drops.

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 22:33:19 -03:00
Vinícius MoreiraandClaude Opus 5.5 386d0560a1 A mob-owned TNT awards no creaking heart experience; block_drops off pops none
PrimedTnt.getOwner names the mob a saved owner UUID points at, so its
blast's indirect source is no player and tryAwardExperience awards
nothing. Keep whether the owner is a player beside blast_owner (a chain
reaction hands a mob owner on as a mob). Block.popExperience draws the
amount, then pops nothing with block_drops off.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 22:32:56 -03:00
f90e164b4b Give a fox that eats its food its effects, and credit a panda's pickup
`Fox.aiStep` finishes the item in the mouth with `ItemStack.finishUsingItem`
(`Consumable.onConsume`), which ran only the chorus fruit's teleport here: run
the whole consume in vanilla's order — `emitParticlesAndSounds` (the
consumable's sound, and the four draws a crumb of `spawnItemParticles` costs,
though `Level.addParticle` is empty on a server), `FoodProperties.onConsume`'s
own sound, the `on_consume_effects` through `LivingEntity.addEffect` (so a
golden apple's regeneration and absorption land) and `GameEvent.EAT` at the
fox's feet with the fox as its source.

`Panda.pickUpItem` calls `onItemPickup` before it takes the item, so
`CriteriaTriggers.THROWN_ITEM_PICKED_UP_BY_ENTITY` fires for whoever threw it,
as it already did for the fox (`LivingEntity.onItemPickup`).

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 02:32:32 +01:00
MSedgeMC 49873c3371 Merge main; keep both sides' tests and module declarations 2026-11-11 01:03:02 +00:00
MSedgeMC 359388eea7 Merge main; keep both sides' tests and module declarations 2026-11-11 01:03:00 +00:00
MSedgeMC 07af125581 Merge main; keep both sides' tests and module declarations 2026-11-11 01:02:53 +00:00
Vinícius MoreiraandClaude Opus 5.5 4d6923514e A player's blast awards a natural creaking heart's experience
CreakingHeartBlock.onExplosionHit awards the heart's experience
(tryAwardExperience) when the explosion's indirect source is a player, a
primed TNT's owner, unless that player is in creative or spectator
(preventsBlockDrops). Horizon always broke the heart as if no player
caused it.

Part of #11330

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

Part of #11330

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 21:59:33 -03:00
MSedgeMC d9672a3587 Merge remote-tracking branch 'upstream/main' into fix/jigsaw-priority-tooltip
# Conflicts:
#	tools/port_map/client2-016.tsv
2026-11-11 00:48:02 +00:00
TiggerTakkyTTTandCodex 15e02ce312 fix(cem): preserve layer ownership map
Avoid shadowing the ownership map with the layer-key list so the latest main branch compiles after the CEM armour-layer merge.

Co-Authored-By: Codex <[email protected]>
2026-11-11 07:00:19 +07:00
TiggerTakkyTTT 1c15680e9a Merge remote-tracking branch 'upstream/main' into fix/wind-burst-ender-dragon 2026-11-11 06:57:15 +07:00
rdbtCVSandGPT 6.1 Sol 7cdd21f24d Merge repaired main into PR #15021 to refresh CI
By rdbtCVS's AI agent (OpenCode, GPT 6.1 Sol). Preserve the contributor and maintainer history while including main's merged #15027 repair for the inherited studio_replay and rocket RNG compilation failures. No PR-specific code changes.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-10 23:52:38 +00:00
DUzzL 27b18c07f0 Merge remote-tracking branch 'upstream/main' into rv/r14566 2026-11-11 00:45:19 +01:00
TiggerTakkyTTTandCodex 61d8dea2a7 fix(combat): scale dragon wind burst knockback
Apply the burst damage-calculator multiplier to the Ender Dragon impulse before native knockback hooks, and cover the behavior with a regression test.

Co-Authored-By: Codex <[email protected]>
2026-11-11 06:39:34 +07:00
MSedgeMC a06a1853e2 Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-10 23:21:13 +00:00
rdbtCVSandGPT 6.1 Sol 0afcabe0ec test(launcher): exercise sign-in builder in IPv4 fallback test
Use the production timeout builder rather than an unrelated test agent, and
cover IPv4-first multi-address and IPv6-only resolver ordering.

Fixup by rdbtCVS's AI agent (OpenCode, GPT 6.1 Sol), not by rdbtCVS personally.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-10 23:07:38 +00:00
DUzzL 67e0a30ea8 Merge remote-tracking branch 'upstream/main' into rv/r14566 2026-11-10 23:59:43 +01:00
DUzzL 02d449ced2 Merge upstream/main into fix/14461-firework-dispenser-rotation 2026-11-10 23:59:29 +01:00
MSedgeMC 2e8cc55201 Merge remote-tracking branch 'upstream/main' into fix/walk-target-scores
# Conflicts:
#	crates/horizon-client/src/mobs/ai.rs
2026-11-10 22:47:33 +00:00
DUzzL d13e35bb8f launcher: fall back to IPv4 when IPv6 to Microsoft is dead
Interleave the address families of resolved sign-in hosts and bound the
connect time, so a broken IPv6 route no longer fails sign-in with
'Could not reach Microsoft'. Fixes #15013.
2026-11-10 23:27:51 +01:00
e24554895d Keep foxes out of the shade in villages and defending the players they trust
Fox.SeekShelterGoal.canUse no longer looks for shade when the fox stands in a
village (ServerLevel.isVillage: within one section of a village POI), so a
village fox keeps strolling in the sun as vanilla's does.

Fox.DefendTrustedTargetGoal is now registered at target priority 3: the fox
takes whoever last hurt a trusted entity as its target, plays FOX_AGGRO at its
feet, sets DEFENDING (the growl and the avoid/stroll guards were dead code) and
drops the flag through Fox.setTarget(null) when the goal stops.

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

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 22:27:10 +01:00
Verm 7d37a291c2 A shulker's AttachFace is narrowed to a byte before the face is picked (#14940)
Shulker reads AttachFace with Direction.LEGACY_ID_CODEC: a byte through
from3DDataValue (abs(data % 6)). The loader took the remainder of the
whole number, so an int 258 was face 0 where vanilla's byte 2 is face 2.
The shulker bullet's Dir already narrowed first.
2026-11-10 18:02:13 +00:00
DetimesandClaude Haiku 5.5 d2bc41cd4e Test: equipment conversion repeats only for changed stacks
Compares the cached equipment with a direct conversion of every equipped
slot over static ticks, after a count change and after a slot is cleared.

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
2026-11-10 17:42:31 +00:00
DetimesandClaude Haiku 5.5 61ffa23382 Perf: cache converted equipment per guest
Held, offhand and armor stacks were converted to network stacks for every
guest on every tick. Each guest now keeps the converted stack of each
equipment slot together with the ItemStack it came from, and converts
again only when that stack differs by full equality. The /reload path
clears the cache, since conversions depend on the registries.

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
2026-11-10 17:42:28 +00:00
Verm fca27e4c63 An axolotl with an unknown Variant id is lucy, not blue (#14907)
Axolotl.Variant.BY_ID is OutOfBoundsStrategy.ZERO: an id outside 0..4
is the first variant, lucy. The loader shared the parrot's arm, whose
CLAMP is right for the parrot but made Variant:7 the rare blue axolotl.
2026-11-10 17:33:46 +00:00
Verm c44aa01ebb A wolf's or cat's CollarColor outside 0..15 reads white (#14897)
Wolf and Cat read CollarColor with DyeColor.LEGACY_ID_CODEC: a byte
through DyeColor.byId, whose out-of-range answer is the first colour
(OutOfBoundsStrategy.ZERO). The loader masked the id with & 15, so 17b
was orange and -1b black where vanilla's collar is white.
2026-11-10 17:26:10 +00:00
Verm f61130508b A fireball narrows a loaded ExplosionPower to a byte (#14883)
LargeFireball.readAdditionalSaveData reads it with getByteOr, which
narrows any numeric tag (IntTag.byteValue is (byte)(value & 255)):
ExplosionPower:300 loads as 44. The loader kept the raw number, so
such a fireball exploded with power 300 while its save showed 44b.
2026-11-10 17:04:53 +00:00
MSedgeMC 7ab5bfd238 port_map: ChatComponent.refreshTrimmedMessages keeps the scroll 2026-11-10 16:40:49 +00:00
MSedgeMC e347c6f99f test: a Chat option change keeps the reader's place
Sixty lines, the reader three lines back with a new message since: a Chat
Visibility change leaves both, a rescale resets both.
2026-11-10 16:40:45 +00:00
MSedgeMC f03a2ffe21 fix: changing the Chat option keeps the reader's place
ChatComponent.setVisibleMessageFilter rewraps through refreshTrimmedMessages,
which leaves chatScrollbarPos alone; only rescaleChat resets it first
(resetChatScroll). Horizon's rewrap always reset the scroll and
newMessageSinceScroll, so changing Chat Visibility threw a scrolled-back
reader to the bottom.
2026-11-10 16:40:25 +00:00
Verm 935faca944 An area effect cloud keeps the fall_distance it was loaded with (#14853)
AreaEffectCloud extends Entity and never moves, so Entity.saveWithoutId
writes the fall_distance Entity.load read. entity_base_tag, which the
cloud's save uses, took OnGround from the loaded compound but wrote
fall_distance 0.0; it now takes that from the compound too.
2026-11-10 16:18:38 +00:00
Verm 70ee24e9ef A trident keeps the fall_distance it was loaded with (#14846)
Entity.saveWithoutId writes fall_distance from fallDistance and
Entity.load reads it back. AbstractArrow.tick never calls Entity.move,
so a trident keeps the loaded value until water resets it. The trident's
save wrote 0.0 and its load ignored the key; it now reads, keeps and
saves it as the arrow does, and water resets it in the ticks that reach
Entity.baseTick. Lava's halving is not modelled for a trident.
2026-11-10 15:58:46 +00:00
DUzzL 267c3838b6 Reset a negative mob Fire to 0 on its first tick 2026-11-10 16:06:42 +01:00
DUzzL dfbdf94740 An arrow loads and keeps its saved Air 2026-11-10 15:10:23 +01:00
Oloion afb34764ec assets: eliminate heap allocations in RangeSelectItemModel binary search 2026-11-10 17:00:17 +03:00
MSedgeMC 479276f66d port_map: WorldOptionsScreen.setPortError is ported 2026-11-10 11:44:22 +00:00
MSedgeMC 17cdf49055 test: a rejected port says why in its tooltip
Above 65535, below 1024, not a number and negative all read the invalid-port
reason; 1024, 25565 and 65535 keep the Port Number label, and an empty box
takes the suggestion.
2026-11-10 11:44:22 +00:00
MSedgeMC b49f625199 fix: the LAN port box says why its number is wrong
WorldOptionsScreen.setPortError swaps the port box's tooltip from
PORT_INFO_TEXT to INVALID_PORT when tryParsePort rejects the value. Horizon
always showed Port Number, so a red box never said what was wrong.
2026-11-10 11:44:22 +00:00
MSedgeMC c1fad2fb18 port_map: the loaded cube's freeze immunity
SulfurCube.canFreeze had no row; it is the cube's arm of freezing.rs::can_freeze.
2026-11-10 11:28:57 +00:00
MSedgeMC 9b087a4560 A sulfur cube holding a block does not freeze
SulfurCube.canFreeze answers `!hasBodyItem() && super`, so a cube carrying a
block in its body takes no frozen ticks from powder snow and no freeze damage,
while one without a body freezes like the cube mobs do. can_freeze answered
the tag and the skeleton's refusal and nothing else, so a loaded cube shook and
took the 1 a fully frozen mob takes.

The test puts a sulfur cube with a stone body at 0 and at 140 frozen ticks: it
stays at 0 with no damage and melts to 138, where the cube without a body gains
a tick (1) and takes the 1 at 140. Without the arm the loaded cube reads (1,
0.0) like the empty one.
2026-11-10 11:17:11 +00:00
zenith600andClaude Opus 5.5 4d3d579464 launcher: say when a folder has the version file but not the game (#14286)
A folder where the official launcher wrote versions/26.3/26.3.json but not
26.3.jar (a version picked but never played, or a download that stopped)
said "no Minecraft 26.3 client jar", the same as a folder that never had
26.3. It now says the version file is there and to play 26.3 once in that
launcher. A wrong jar and a folder without the version file keep their
messages.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 16:29:39 +05:30
MSedgeMC d46bea7851 port_map: the stroll's score names its port
The mushroom cow's row said its mycelium rule lived only in the spawn check;
walk_target_value carries it now, and the enderman, the pillager and the
warden have their flat 0 beside the other walk-target rows.
2026-11-10 10:53:43 +00:00
MSedgeMC a25182804a Test the walk-target scores of four classes
walk_target_value at three positions that differ only in the block below: a
cow scores grass 10.0 and stone and mycelium by the light, a mushroom cow
mycelium 10.0 and grass by the light (never 10), a zombie the negated light,
and an enderman, a pillager and a warden 0.0 on all three. Without the two
rules the mushroom cow reads 0.5 for its mycelium, the light term, and the
three flat classes read the zombie's score.
2026-11-10 10:50:20 +00:00
MSedgeMC dd178e638f A stroll scores its ground the way the jar does
PathfinderMob.getWalkTargetValue is what a random stroll ranks its ten
candidate positions by, and three of its class overrides had no port:
Enderman.getWalkTargetValue, Pillager's and Warden's answer 0 wherever the
position is, so their strolls pick one of the ten at random, while Horizon
scored them by the negated light and sent them to the dark. A mushroom cow's
override replaces Animal's outright — mycelium 10, everything else by the
light — so grass is worth nothing to it, while Horizon gave it the cow's grass
10 and nothing for its own mycelium.

walk_target_value moves out of ai.rs into mobs/ai/walk_target.rs with the two
rules, and ai.rs loses 75 lines of code to the move.
2026-11-10 10:50:20 +00:00
TiggerTakkyTTTandCodex 01731db683 fix(combat): make Wind Burst affect the Ender Dragon
Fixes #14724.

Co-Authored-By: Codex <[email protected]>
2026-11-10 17:03:22 +07:00
MSedgeMC 7b9fca2a49 Move the freezing helpers into mobs/ai/freezing.rs
ai.rs is over the 3000-line limit and may not grow, so in_powder_snow and can_freeze move out; the skeleton rule stays
the same.
2026-11-10 09:51:34 +00:00
4b8f61ed0e Count cave air as air for open-water fishing and the flying kick
FishingHook.getOpenWaterTypeForBlock tests !state.isAir()
(FishingHook.java:416), and isAir() covers air, cave_air and void_air;
ServerGamePacketListenerImpl.noBlocksAround filters on the same predicate
(ServerGamePacketListenerImpl.java:543). open_water_type now reads
BlockFacts::is_air, and no_blocks_in reads the hub's state table, so a hook
under a mineshaft's air counts as open water (treasure can be caught) and a
survival guest hovering there is kicked for flying after 80 ticks.

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

Claude-Session: https://claude.ai/code/session_01WZzq8MxShENtiMj2DMbm5G
2026-11-10 04:58:50 +01:00
Senjin 1f7d2adcbe Mobs: a mob sitting in a boat on water is not in the water, so a drowned or zombie in a boat burns in daylight
AbstractBoat.modifyPassengerFluidInteractionBox for mobs: a boat that isn't underwater keeps only the part of its passenger's box above the boat's top, and all of it when the boat reaches the passenger's head. Players had it (motion/fluids.rs); mobs measured their whole box, so a passenger's legs below the surface made it count as in the water: it never burned in daylight, the flow pushed it and boarding splashed.
2026-11-10 02:49:35 +01:00
Riccardo GuerrieroandClaude Fable 5.1 1258ec339a Load the prepared test resources once per test process
horizon-client-lib's unit tests spent 71% of their time in the 708 mobs::
tests and 7% in lan::prediction, and nearly all of that was each test
reading the whole prepared root again in a debug build: Assets (block
registry, tags), BlockFacts, MotionResources, MobData, MobTypes, Biomes,
Items and the collision registry, about 7 s a call under load. A test
calling the fixture 16 times paid 16 times (157 s).

The tests only read those tables and build their own World over them, so
mobs/world.rs keeps the vanilla root's bundle (TestResources) in a OnceLock
and run_test_world builds the per-test World on it as before. A mod root
in a test's scratch folder is still loaded fresh each call, since the same
path can hold different content between tests. test_fixtures::vanilla()
does the same for Assets, RenderAssets, collisions and MotionResources;
lan::prediction's steps_in uses it, and motion.rs and entities.rs can
follow.

Same tests, same cases, same assertions; nothing new is #[ignore]d.
cargo test -p horizon-client-lib --lib on an 8-core laptop at 8 threads:
388 s -> 99 s wall (three interleaved runs each), 3061 s -> 770 s summed.

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

Claude-Session: https://claude.ai/code/session_01WZzq8MxShENtiMj2DMbm5G
2026-11-10 01:58:51 +01:00
MSedgeMC 7cbe3d49b0 port_map: the drowned's fluid push names its arm
Drowned.isPushedByFluid had no row; it is the drowned arm of
living.rs::Mob::pushed_by_fluid now.
2026-11-10 00:50:48 +00:00
MSedgeMC 83122825e5 Test the drowned that a current leaves alone
Mob::pushed_by_fluid, the predicate both push sites of
LivingEntity.updateFluidInteraction read: a drowned with its swimming flag set
answers false, the same drowned with the flag cleared answers true, the water
mobs that never take a push (a cod, a squid, a turtle, a nautilus) answer
false either way, and a zombie answers true. Without the drowned arm the
swimming one answers true too.
2026-11-10 00:50:42 +00:00
MSedgeMC c3af4fadeb A swimming drowned is not pushed by the current
Drowned.isPushedByFluid answers `!isSwimming()`: a drowned swimming after its
target holds its line in a stream, while one walking the bottom is carried
along like any other mob. Horizon answered the plain LivingEntity rule for
every drowned, so a drowned chasing a player through flowing water was swept
off its path, and the current was applied even while its own move control was
driving it.

pushed_by_fluid now answers the drowned with its swimming flag, which
Drowned.updateSwimming keeps on the mob (`extra.flag`) and the clients read
from the shared flags, and the rest of the table stays as it was.
2026-11-10 00:50:42 +00:00
MSedgeMC fbf4ddd296 port_map: the freeze immunity names its port
canFreeze had no row in either place: LivingEntity's tag-and-wearables half
and the Skeleton override are both ai.rs::can_freeze.
2026-11-10 00:47:14 +00:00
MSedgeMC a8c16eb5db Test the skeleton that powder snow leaves alone
LivingEntity.aiStep's freezing, through freeze_tick, in a world with a powder
snow block at the mob's feet: a skeleton, a stray, a bogged, a parched and a
wither skeleton gain no frozen tick (0) where a zombie gains one (1), and one
of them put at 140 frozen ticks melts to 138 with no damage where the zombie
takes its 1. Without the skeleton arm the skeleton freezes like the zombie.
2026-11-10 00:47:14 +00:00
MSedgeMC ce66fd40bd A skeleton never freezes
Skeleton.canFreeze answers false, so powder snow builds no frozen ticks on a
skeleton and never deals it the 1 a fully frozen mob takes, and a skeleton
already frozen melts out of it at two ticks a tick. The stray, the bogged, the
parched and the wither skeleton inherit the same false. Horizon only asked the
freeze_immune_entity_types tag — which lists the stray, the polar bear, the
snow golem and the wither — and the worn freeze_immune_wearables, so a plain
skeleton standing in powder snow froze like a zombie.

can_freeze now answers the five skeletons before the tag, which is where
vanilla puts the class override.
2026-11-10 00:47:13 +00:00
Evrim CicekandClaude Opus 5.5 22e92450e4 ExperienceOrb::new builds a fresh orb
The summon and remote-spawn sites build their orb with it, so the new
fall_distance field doesn't grow commands.rs and remote.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 03:37:25 +03:00
Evrim CicekandClaude Opus 5.5 ee1dfabaa6 An orb's first tick doesn't halve its fall in lava
Entity.isInLava is false while firstTick holds, so Entity.baseTick halves
the fall only from the second tick. Review finding on #14440; the
`orb fall distance` group gains an orb summoned inside lava.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 03:22:21 +03:00
Evrim CicekandClaude Opus 5.5 5e1eab0e05 Experience orbs keep their fall distance and post hit_ground on landing
Part of #14440: an orb's Entity.fallDistance builds up as it falls and is
saved and loaded (and taken by /data merge). Entity.baseTick's fluid step
resets it in water and halves it in lava; Entity.move's long-move ray
resets it past a fall-resetting block or water, and checkFallDamage adds
the drop and, landing after a fall, posts HIT_GROUND. Adds the
`orb fall distance` command-exec group.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 03:07:01 +03:00
voidsip fc83f106f2 Encode gateway fixture positions as NBT double bits 2026-11-09 18:04:41 -06:00
voidsip 7f8e095514 Keep ordinary explosion reference tests on the default calculator 2026-11-09 18:04:34 -06:00
voidsip d1d35697a1 Clarify the gateway phase fixture validation boundary 2026-11-09 17:52:57 -06:00
voidsip 6a9154801f Check real gateway cooldown dispatch before and after movement 2026-11-09 17:51:55 -06:00
DUzzL 72c06622be Turn a shot firework rocket to its motion like Projectile.shoot 2026-11-10 00:25:30 +01:00
793565a33f Lecterns take a placed book into has_book, gate the comparator on it and post block_change
`LecternBlock.getStateForPlacement` sets HAS_BOOK from the item's
`block_entity_data.Book` when the placer may use game-master blocks, so a
lectern placed from a book-carrying item draws its book.

`LecternBlock.getAnalogOutputSignal` reads the state's HAS_BOOK before asking
`LecternBlockEntity.getRedstoneSignal`, so `/setblock ... lectern[has_book=true]`
with no book now reads 14 instead of 0, and `has_book=false` reads 0.

`LecternBlock.resetBookState` sets POWERED and HAS_BOOK in one write, then posts
`minecraft:block_change` (the player who put the book on, none when
`LecternBlockEntity.onBookItemRemove` takes it off) and updates below, so a
sculk sensor hears both.

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

Claude-Session: https://claude.ai/code/session_01WZzq8MxShENtiMj2DMbm5G
2026-11-09 23:49:04 +01:00
voidsip 4bed23dc4c Leave portal placement helpers in their existing module 2026-11-09 16:29:08 -06:00
b820331574 Place a can_place_on block against a sign in adventure mode
SignBlock.useWithoutItem answers PASS when the player may not build and
the sign is not waxed and has no click action to run (SignBlock.java:139-146),
so ServerPlayerGameMode.useItemOn goes on to the item phase
(ServerPlayerGameMode.java:411-427) and ItemStack.useOn's canPlaceOn test
lets a `can_place_on` block place against the sign (ItemStack.java:358-362).
Horizon's sim's sign arm answered SUCCESS, which `use_hand` consumed before
the item was used. `sign_consumes_click` now asks the sign's block entity
the question vanilla asks it, and use_hand only ends the click when it says
a waxed sign, a click-command side or a player who may build takes it.

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

Claude-Session: https://claude.ai/code/session_01WZzq8MxShENtiMj2DMbm5G
2026-11-09 23:20:28 +01:00
voidsip ca8cc5a43b Prove nested native damage cannot inherit an outer credited source 2026-11-09 16:17:25 -06:00
Mayonesu0andClaude Sonnet 5.5 ac2a95ab1d Keep streaming.rs from growing: the forced ring's ranking basis lives in forced_ring (#13175)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 22:31:34 +01:00
Mayonesu0andClaude Sonnet 5.5 faef0581d1 Test that queued jobs follow a forced chunk (#13175)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 22:31:34 +01:00
Mayonesu0andClaude Sonnet 5.5 4df6ddc3f3 Rank a forced chunk's ring around it (#13175)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 22:31:34 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 9f1fb2c77c HDR tone curve test: the scene's peak itself may come back one half-float step off
S's gamma rounds up in half float, to light just above S that the curve holds at S. Whether that comes back to the same half float depends on the GPU's rounding: on an Apple M4 Pro (Metal) it lands one step lower, so the exact check at S failed there. Every stored value below S still comes back bit-identical.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 21:45:09 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 46fe2c7f3d Merge main into #13578; graphics.md's HDR paragraph is main's plus this PR's sentences
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 21:28:40 +01:00
JoansitohandClaude Sonnet 5.5 f23a03f6c7 Lightning rod: update the front block's neighbours without redstone experiments
Fixes #14371

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

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 21:21:36 +01:00
voidsip afd4df1cd9 Use entity-specific End gateway cooldowns 2026-11-09 14:20:42 -06:00
voidsip fa7424ca31 Protect sulfur cube blasts while portal cooldown is positive 2026-11-09 14:15:30 -06:00
voidsip 37249efd34 Check explicit blast context survives a nested native blast 2026-11-09 14:15:06 -06:00
Horizon agent 98498da9aa Merge origin/main (brings #14090's test_git_slim fix) 2026-11-09 21:14:49 +01:00
voidsip a2b63b3e3b Keep explicit damage source state across nested native explosions 2026-11-09 14:13:34 -06:00
voidsip 1f3f7cebae Separate explicit explosion damage causes from blast ownership 2026-11-09 14:11:42 -06:00
Horizon agent 316bef4484 Merge origin/main (brings #14090's test_git_slim fix) 2026-11-09 21:04:19 +01:00
Ludovic Marie 4cea8c2ee6 Merge remote-tracking branch 'origin/main' into split/telemetry-probes 2026-11-09 21:00:24 +01:00
Ludovic Marie 0694db7dcb Merge pull request #14152 from horizon-rust/split/telemetry-scrub
🤖 Agent: scrub.rs moved unchanged to horizon-system; horizon-telemetry re-exports it, so the launcher and the ingest server keep their paths.
2026-11-09 20:00:36 +01:00
Horizon agent f3fdefa0ec Merge remote-tracking branch 'origin/main' into rpn4-14179
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
2026-11-09 19:56:12 +01:00
Horizon agentandClaude Opus 5.5 b21058f361 deflect tests: the turned wind charge is past its no-deflect ticks
The two redirect tests built a fresh wind charge, which this PR now (as
WindCharge.deflect) refuses for its first five ticks; their fixture clears
no_deflect_ticks, and a new test checks a fresh one keeps its course and owner.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:51:31 +01:00
Horizon agent 58f80819d9 Merge remote-tracking branch 'rv3n/14131' into rv3-comp 2026-11-09 19:21:19 +01:00
MSedgeMC f606efbf36 Merge remote-tracking branch 'upstream/main' into fix/component-can_eat 2026-11-09 18:03:15 +00:00
MSedgeMC e8397cd8eb Merge remote-tracking branch 'upstream/main' into fix/component-can_eat 2026-11-09 17:55:10 +00:00
Evrim Cicek dd2d560901 Merge branch 'refactor/minecarts-split' into fix/tnt-minecart-explosion-power 2026-11-09 20:51:09 +03:00
voduffandClaude Opus 5.5 7837d91790 Projectiles hit item frames: the item comes out, then the frame breaks (#11383)
Frames are pickable (BlockAttachedEntity.isPickable), so a projectile
meets them and ItemFrame.hurtServer runs: a filled frame gives its item
up on any non-explosion hit, an empty one breaks, a fixed one refuses
unless the shooter is creative (and a refused arrow is turned back).
Horizon left frames out of every projectile's targets.

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

Part of #11383

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

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

Claude-Session: https://claude.ai/code/session_01WZzq8MxShENtiMj2DMbm5G
2026-11-09 18:47:16 +01:00
Horizon agentandClaude Opus 5.5 a03902b64d Use the stack's whole consumable component, finish the merged series
The eight component branches (#14131-#14138) each edited the same spots and
three of them left an unclosed block in finish_consuming. Merged here:

- item_sync::stack_consumable builds the component a use reads once: the
  stack's own when it has one, with Consumable.CODEC's defaults for the
  fields it leaves out (1.6 s, eat, entity.generic.eat, particles), else the
  item's. finish_consuming, the per-tick particles and sounds and the
  instant-consume check use it.
- Consumable.canConsume's food check asks the stack's food component.
- A direct sound reads SoundEvent.DIRECT_CODEC's `sound_id`, and every
  ItemUseAnimation name maps (trident, crossbow and spyglass were eat).
- The glider test's doc comment is back on its test.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:42:09 +01:00
Horizon agent 30637c0820 Merge remote-tracking branch 'rv3/14136' into comp
# Conflicts:
#	crates/horizon-client/src/game/consume.rs
#	crates/horizon-client/src/item_sync.rs
2026-11-09 18:37:24 +01:00
Horizon agent b63d7491d2 Merge remote-tracking branch 'rv3/14135' into comp
# Conflicts:
#	crates/horizon-client/src/game/consume.rs
#	crates/horizon-client/src/item_sync.rs
2026-11-09 18:37:21 +01:00
Horizon agent e64e477126 Merge remote-tracking branch 'rv3/14133' into comp
# Conflicts:
#	crates/horizon-client/src/item_sync.rs
2026-11-09 18:37:14 +01:00
Horizon agent dc94adc898 Merge remote-tracking branch 'rv3/14134' into comp
# Conflicts:
#	crates/horizon-client/src/item_sync.rs
2026-11-09 18:37:05 +01:00
Horizon agent f914bcd4a9 Merge remote-tracking branch 'rv3/14138' into comp
# Conflicts:
#	crates/horizon-client/src/item_sync.rs
2026-11-09 18:36:53 +01:00
Horizon agent 0a6d0dd257 Merge remote-tracking branch 'rv3/14137' into comp
# Conflicts:
#	crates/horizon-client/src/item_sync.rs
2026-11-09 18:36:51 +01:00
Horizon agent c719e4ac0e Merge remote-tracking branch 'rv3/14132' into comp
# Conflicts:
#	crates/horizon-client/src/item_sync.rs
2026-11-09 18:36:50 +01:00
Nikita Maximilian Grimm 7049d0e811 Merge remote-tracking branch 'origin/main' into hdr-one-peak
# Conflicts:
#	docs/graphics.md
2026-11-09 18:34:01 +01:00
rdbtCVSandClaude Sonnet 5.5 78e094e614 Merge main into fix/tnt-minecart-explosion-power
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 17:29:33 +00:00
rdbtCVSandClaude Sonnet 5.5 f81f6de46b cargo fmt
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 17:23:28 +00:00
MSedgeMC ff42a99d98 port_map: Dispensable comes from the stack 2026-11-09 17:20:59 +00:00
MSedgeMC aced4bb6b9 test: Dispensable comes from the stack 2026-11-09 17:20:59 +00:00
MSedgeMC 97a323b499 fix: Dispensable comes from the stack 2026-11-09 17:20:59 +00:00
MSedgeMC 227333eacd port_map: Use remainder comes from the stack 2026-11-09 17:20:53 +00:00
MSedgeMC 9367ed7710 test: Use remainder comes from the stack 2026-11-09 17:20:53 +00:00
MSedgeMC e15589bbbb fix: Use remainder comes from the stack 2026-11-09 17:20:53 +00:00
MSedgeMC 84fe5b4f12 port_map: Consumable particles come from the stack 2026-11-09 17:20:47 +00:00
MSedgeMC 7ee566f6b7 test: Consumable particles come from the stack 2026-11-09 17:20:47 +00:00
MSedgeMC 3774ca6edd fix: Consumable particles come from the stack 2026-11-09 17:20:47 +00:00
MSedgeMC c73321876c port_map: Consumable sound comes from the stack 2026-11-09 17:20:41 +00:00
MSedgeMC 19d083fa26 test: Consumable sound comes from the stack 2026-11-09 17:20:41 +00:00
MSedgeMC 8d35e7fc0a fix: Consumable sound comes from the stack 2026-11-09 17:20:41 +00:00
MSedgeMC 46f0546de6 port_map: Consumable animation comes from the stack 2026-11-09 17:20:13 +00:00
MSedgeMC 7c173c30b1 test: Consumable animation comes from the stack 2026-11-09 17:20:13 +00:00
MSedgeMC 715a969071 fix: Consumable animation comes from the stack 2026-11-09 17:20:13 +00:00
MSedgeMC 5097c53509 port_map: Consumable seconds come from the stack 2026-11-09 17:20:08 +00:00
MSedgeMC a89477a4b9 test: Consumable seconds come from the stack 2026-11-09 17:20:08 +00:00
MSedgeMC 430b6dc4c5 fix: Consumable seconds come from the stack 2026-11-09 17:20:07 +00:00
MSedgeMC e591b36a8d port_map: Food can_always_eat comes from the stack 2026-11-09 17:20:01 +00:00
MSedgeMC 701eaa6f9c test: Food can_always_eat comes from the stack 2026-11-09 17:20:01 +00:00
MSedgeMC 018a51c27f fix: Food can_always_eat comes from the stack 2026-11-09 17:20:01 +00:00
MSedgeMC 4db980e5de port_map: Food nutrition and saturation come from the stack 2026-11-09 17:19:53 +00:00
MSedgeMC d40ab7df1a test: Food nutrition and saturation come from the stack 2026-11-09 17:19:53 +00:00
MSedgeMC 54578041bc fix: Food nutrition and saturation come from the stack 2026-11-09 17:19:52 +00:00
945c667281 Discard dropped items below each dimension's own void floor
Entity.checkBelowWorld discards at getY() < Level.getMinY() - 64, so the Nether and the End (minY 0) lose an item at y = -64, not the Overworld's -128. Use the level's minimum y in ItemEntity.tick and the item tick, and keep an item at exactly the threshold, as vanilla's < does.

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

Claude-Session: https://claude.ai/code/session_01WZzq8MxShENtiMj2DMbm5G
2026-11-09 18:10:14 +01:00
Horizon agentandClaude Opus 5.5 bdec76846e Merge main: keep main's blocking_item_max_damage
main's 3b34446f84 added the same helper with the fuller ItemStack.isDamageableItem rule (an
explicit DAMAGE component on a non-damageable prototype); this branch keeps that one and its own
ItemDamageFunction.apply.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:08:04 +01:00
Horizon agentandClaude Opus 5.5 93ebb80b92 Carry blocking_item_used through the staged equipment hurt
main's health/equipment.rs (3cdf31772f) runs a hurt in stages and copies the block results
between them; it now copies blocking_item_used too, so a staged hit reports the shield use as
the one-shot hurt does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:58:51 +01:00
MSedgeMC 8d7267f7f7 port_map: JigsawBlockEditScreen lives in jigsaw_edit.rs 2026-11-09 16:51:30 +00:00
Evrim Cicek 345041401e Merge branch 'refactor/minecarts-split' into fix/tnt-minecart-explosion-power
# Conflicts:
#	crates/horizon-client/src/game/minecarts.rs
2026-11-09 19:36:49 +03:00
MSedgeMC 0eb2196f99 The jigsaw's priority boxes explain themselves on hover
JigsawBlockEditScreen sets a tooltip on its selection and placement priority
boxes, explaining what each number does. Horizon's jigsaw editor drew both
boxes without one. The block editor now takes an optional tooltip per box and
draws it over the screen, and the jigsaw gives its two priority boxes theirs.
2026-11-09 16:21:58 +00:00
Nikita Maximilian Grimm 18c03d23d8 Merge remote-tracking branch 'fork/hdr-one-peak' into hdr-one-peak 2026-11-09 17:14:50 +01:00
Horizon agent dceee849cb Merge remote-tracking branch 'origin/main' into r-13578
# Conflicts:
#	crates/horizon-client-render/src/gfx/native_post.rs
2026-11-09 17:08:54 +01:00
Horizon agentandClaude Opus 5.5 10b318cd6b Merge main into split-renderer
main moved more of game.rs out (#13877, #13878, #13880...): the conflict is only the
neighbours of the moved functions; game.rs takes main and drops the four functions
this branch moves to game/render_settings.rs, unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:08:14 +00:00
Nikita Maximilian GrimmandClaude Opus 5.5 79cfecbdb9 HDR float frame: main's encoding-switch test tunes a tier's gain, as one peak has no share
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:05:29 +01:00
MrCloudy2andClaude Opus 5.5 298d3f12e9 A player's wind charge can't be turned in its first five ticks
WindCharge.deflect refuses while noDeflectTicks (5, one less after each tick,
not saved) lasts: a projectile hitting a young wind charge doesn't redirect it
(Projectile.onRedirectProjectile), and a player's blow goes on as an attack
that hurts nothing. A breeze's wind charge has no such wait.
Harness: tools/test_wind_charge_no_deflect_ticks_parity.py.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:02:39 +01:00
Horizon agentandClaude Opus 5.5 73a745339e Carry an arrow's piercing in a combat flag so creatures.rs doesn't grow
creatures.rs is over 3000 lines (docs/layers.md#file-size). The arrow's hit on the player goes
through shield.rs's arrow_hits_player, which sets combat.piercing_hit; hurt_player_from takes it
as it takes rocket_hit and hands it to player_blocking. Same behaviour as hurt_player_with_pierce.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:00:58 +01:00
Horizon agent 70287510ab Merge remote-tracking branch 'origin/main' into fix-11507 2026-11-09 16:59:46 +01:00
Horizon agentandClaude Opus 5.5 26afbb999c Merge #13745's formatting into #13747
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:53:52 +01:00
Horizon agentandClaude Opus 5.5 d906242b0e Format the pattern tag walk
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:53:45 +01:00
Horizon agentandClaude Opus 5.5 dbf99fe456 Merge #13748 into #13749
Both add an equippable reader beside `equippable` in item_sync.rs: with
#13748 merged in, either lands first without a conflict. The new test's
doc comment now describes it, and the glider test keeps its own.

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:36:42 +01:00
Horizon agent 529cd743a0 Merge remote-tracking branch 'origin/main' into fix-11548
# Conflicts:
#	crates/horizon-client/src/game/hand.rs
2026-11-09 16:35:26 +01:00
Horizon agentandClaude Opus 5.5 3877ae52d8 Merge origin/main into the branch
The verify job compared big files against this branch's old base, so main's own growth of options_ui.rs since then failed the size check.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:35:03 +01:00
Nikita Maximilian Grimm 8e46f3971d Merge remote-tracking branch 'origin/main' into hdr-one-peak
# Conflicts:
#	crates/horizon-client-render/src/gfx/native_post.rs
2026-11-09 16:30:19 +01:00
Horizon agentandClaude Opus 5.5 ebe1a5d52b Wardens: silent ones make no sound, forced riders get off to dig; withers' idle volleys wait for Normal
- A Silent:1b warden played its emerge, dig, roar, sniff, tendril, attack and sonic boom sounds:
  both warden sound helpers now check `silent`, as Entity.playSound does (#8835).
- ForceUnmount was stubbed out: a warden put on a vehicle with /ride or a summon's Passengers
  never got off, so it could never dig. It now runs vanilla's unRide (passengers ejected, the warden
  set down where its vehicle lets it off) and stops in the tick it starts (#8833).
- WitherBoss.customServerAiStep counts the side heads' idle updates only on Normal and Hard: on Easy
  a wither fired its random blue skulls all the same (#8840).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:17:45 +01:00
Horizon agent 61a0362859 Merge remote-tracking branch 'origin/main' into fix-11507 2026-11-09 16:14:13 +01:00
MSedgeMC 166073d4bc Re-run checks: the previous run failed in an unrelated fixture / harness 2026-11-09 14:52:23 +00:00
Horizon agentandClaude Opus 5.5 92742bef1b horizon-system: the scrubber leaves horizon-telemetry (layer plan M5)
scrub.rs moves unchanged from horizon-telemetry to horizon-system (engine);
horizon-telemetry re-exports it at horizon_telemetry::scrub for the launcher.
The repro export (agent control) reads it there, so gameplay no longer uses
horizon-telemetry for it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:36:00 +01:00
Horizon agentandClaude Opus 5.5 bb29b70b32 Cargo.lock: horizon-system for horizon-client and horizon-optifine
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:23:16 +01:00
Horizon agentandClaude Opus 5.5 3ad8a18a7b horizon-system: the process probes leave horizon-bench (layer plan M5)
rss_bytes, cpu_seconds and load_average move to horizon-system (engine);
horizon-bench re-exports them for the launcher. horizon-system's cpu() and
ram_bytes() become public and replace horizon-bench's identical copies.
The dedicated server's performance report and cem_expr_bench read them
there, so gameplay no longer uses horizon-bench for them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:14:20 +01:00
MSedgeMC e632dda2b7 cargo fmt 2026-11-09 14:07:35 +00:00
MSedgeMC d22f313e9e Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-09 14:00:02 +00:00
Evrim Cicek 16237bb2b7 Merge remote-tracking branch 'upstream/main' into fix/tnt-minecart-explosion-power
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-09 16:47:50 +03:00
Skr1zzzzandClaude Opus 5.5 71e2401edc Move building and adopting the renderer out of game.rs into game/render_settings.rs
Part of #11857. new_renderer, adopt_renderer, forget_atlas_slots and all_sections_changed join game/render_settings.rs. Methods that were private in game.rs become
pub(super), the same reach.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:29:00 +03:00
MSedgeMC e957a4bd85 Keep mobs.rs and goals.rs under the file-size limit
Mob::seen moves to mobs/seen.rs and the FollowMob test to mobs/invisible_follow_tests.rs, so neither file over
3000 lines grows: 6858 -> 6777 and 3740 -> 3738.
2026-11-09 13:05:29 +00:00
RINNECODERandClaude Opus 5.5 6c5f62af35 Spectator camera: a server's camera on an armor stand holds the view
ClientPacketListener.handleSetCamera takes any entity the level holds, but
remote_set_camera only looked among the mobs, so a camera on an armor stand
(the usual target of a server's cutscene or menu scene) was dropped: the
view stayed on the player, who kept moving and turning. The stand now holds
the camera like a mob does: the view looks out of its eyes along its yaw,
the player sends no moves, the crosshair skips the stand and first person
does not draw it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 05:25:24 -06:00
Tran Anh Van d029364bd4 Use Level.randValue for thunder strike columns and trap horse RNG for scatter
ServerLevel.tickThunder picks its strike column via Level.getBlockRandomPos(minX, 0, minZ, 15), which advances Level.randValue (Ticks::block_random_pos) and extracts bits 2..=5 for X and 10..=13 for Z instead of drawing from Level.random.

SkeletonTrapGoal.tick draws each additional trap horse's horizontal push (two triangle(0.0, 1.1485) draws) from the original trap horse's own random after creating and mounting its skeleton rider, rather than drawing from Level.random before rider creation.
2026-11-09 18:19:33 +07:00
RINNECODERandClaude Opus 5.5 89f90f0b65 OptiFine CTM: a tile draws in the pass of the face it replaces
CTM picks a texture, not a render layer: OptiFine draws a tile in the
block's layer. Horizon took the pass from the tile's own sprite, so one
of Conquest Reforged's six random grass blade tiles (7 partly clear
texels of 1024) put its overhang plane in the translucent pass. That
pass has no alpha test, so the plane's clear rectangle wrote depth
(depthtex0 had it, depthtex1 not), and Bliss fogged it: faint
rectangles against the sky. A tile now takes the face's pass; its own
texels still decide whether it skips the alpha test.

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 11:08:18 +01:00
MSedgeMC 87c473666a Do not load items the selection test does not read 2026-11-09 09:03:32 +00:00
MSedgeMC 1c9f8a4359 Keep the tag type in the test 2026-11-09 09:03:30 +00:00
MSedgeMC d0850614ba Follow mob goals ignore invisible mobs 2026-11-09 08:50:42 +00:00
MSedgeMC f4d10e7099 Import the Tag type the loom component read uses 2026-11-09 08:48:45 +00:00
MSedgeMC 1b029a08f6 Import the Tag type the loom component read uses 2026-11-09 08:48:43 +00:00
MSedgeMC 8a5211f260 Import the Tag type the loom component read uses 2026-11-09 08:48:41 +00:00
MSedgeMC 0831968204 Armor damage_on_hurt comes from the stack's equippable component
LivingEntity.doHurtEquipment skips a piece whose Equippable.damageOnHurt is
false. Horizon read the prototype's flag for both the player's armor and a
mob's, so a stack patched to false still wore.

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

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

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

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

Test: a globe banner pattern with the component removed is not a pattern
item.
2026-11-09 08:43:04 +00:00
DUzzL d288db6b6e Award the crafted statistic for crafting-table and inventory-grid takes 2026-11-09 09:18:02 +01:00
MSedgeMC 35fc116bd3 fix: drop the goat's old call and pass the memory value to number 2026-11-09 06:41:45 +00:00
ede2ada183 Make a pacified hoglin refuse breeding food
A hoglin within reach of a #hoglin_repellents block keeps its PACIFIED
memory for 200 ticks, and Hoglin.canFallInLove is false while it is set.
Animal.mobInteract then refuses the crimson fungus: nothing is eaten, no
love mode, no baby. Its baby branch asks canAgeUp instead, so a pacified
baby still grows up.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
2026-11-09 07:23:48 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 cc100b4470 Merge origin/main into hdr-one-peak
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 07:20:47 +01:00
MSedgeMC 2a95475e8f refactor: keep the goat's save code in goat.rs
`entity_nbt.rs` is over the 3000-line limit and may not grow (docs/layers.md
#file-size), so the goat's save pair moves into `mobs/goat.rs`: the screaming
and horn fields `Goat.addAdditionalSaveData` writes plus the long jump
cooldown memory, and their read side. `entity_nbt` keeps a one-line call.
2026-11-09 05:52:47 +00:00
MSedgeMC 47894e831f port_map: a goat's long-jump cooldown is saved and read
`Goat.addAdditionalSaveData`'s row in `tools/port_map/game-012.tsv` names the
three own fields; it now also names the `GoatAi` `LONG_JUMP_COOLDOWN_TICKS`
brain memory that the save covers.
2026-11-09 05:18:42 +00:00
MSedgeMC 5e735bd030 test: a goat's long-jump cooldown through the chunk save
`mob_to_nbt`/`mob_from_nbt` are the pair `Game::mob_tree_nbt` and
`Game::load_mob` go through. A goat with 57 ticks of long-jump cooldown saves
`Brain.memories."minecraft:long_jump_cooling_down"` as `{value: 57}` and the
reloaded goat's `frog::jump_cooldown` is 57 again. A goat with no cooldown
writes no memory, even over a compound that still names one.

Without the write the save has no memory (left `None`, right `Some(Compound({"value": Int(57)}))`), and the
reloaded goat has no cooldown (left `None`, right `Some(57)`).
2026-11-09 05:18:42 +00:00
MSedgeMC da4591bc88 fix: a goat keeps its long-jump cooldown in its chunk save
`GoatAi` sets `LONG_JUMP_COOLDOWN_TICKS` when a goat long jumps, and
`CountDownCooldownTicks` counts it down; `Brain.Packed` saves every brain
memory with `LivingEntity.addAdditionalSaveData` and reads it back.

Horizon kept the cooldown in the goat's `extra.memories` (the shared
`frog::jump_cooldown` the long jump goal asks) and wrote no `Brain`, so a goat
that had just long jumped unloaded with its chunk and came back ready to jump
again. The save now writes `minecraft:long_jump_cooling_down` as the integer
memory it is and the load puts it back in the goat's memories.
2026-11-09 05:18:42 +00:00
Nikita Maximilian GrimmandClaude Opus 5.5 dbcbe4fe87 HDR one peak docs: the identity holds where Peak reaches 1000/203 Paper White
The design note and graphics.md said a display at 1000 nits or more shows
the scene as written. That holds at a paper white of 203; in general the
curve is the identity where Peak Brightness is at least 1000/203 times
Paper White, so say that.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 05:05:05 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 8423eabc57 HDR float frame: pin one peak's identity at the settings' extremes
Paper White now runs from 10 to 480 nits and Peak up to 10 000 (#12881): the tone curve
test checks that the world shows exactly as written wherever the display's peak reaches
the scene's, at 480/10 000 and 10/200 nits too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 04:30:47 +01:00
Nikita Maximilian Grimm fd46aa03c5 Merge remote-tracking branch 'origin/main' into hdr-one-peak 2026-11-09 04:29:46 +01:00
Nikita Maximilian Grimm 87f2c1314e Merge branch 'hdr-step5-gates' into hdr-one-peak 2026-11-09 04:12:22 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 4c2a2c42d2 HDR float frame: one peak, the world mastered for 1000 nits
On the float frame the world's light no longer depends on the display. Light sources
multiply their brightest pixels by fixed gains in multiples of paper white (1.6, 2.4,
2.8 and 4 from faint glowers to the sun), and HDR lighting's strongest light by 3. The
tone curve alone fits that scene to the display: light above the scene's peak (1000/203
of paper white, a 1000-nit display at BT.2408's 203-nit white) is held at it, keeping
its hue, then BT.2390's EETF maps the scene's peak onto the display's.

For players: on a 1000-nit display the world shows exactly as written; a 1500-nit or
brighter one shows the same picture, the sun included, instead of a brighter sun and
lava; a dimmer one keeps its midtones and rolls the highlights off below its peak.
Before, the gains scaled with the display's headroom and the curve then compressed
that light again from a peak it derived from the same headroom, so the look changed
with the Peak setting.

Light Horizon doesn't lift (HDR Lighting off, the title, a 0.2 pack's own) has no
known peak, so there the curve keeps the source that follows the display, as the 0.2
contract describes. HDR off, HDR Lighting off, the HUD, the black lift, the SDR
screenshot's shoulder and the 16-bit or 8-bit compatibility frame (HDR's default until
step 5, which changes the look once) are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 03:26:04 +01:00
Evrim Cicek 4c246e24f2 Merge remote-tracking branch 'upstream/main' into fix/tnt-minecart-explosion-power
# Conflicts:
#	crates/horizon-client/src/game/minecarts.rs
2026-11-08 20:02:17 +03:00
Evrim Cicek 3ff75a8ecb Merge remote-tracking branch 'upstream/main' into fix/tnt-minecart-explosion-power 2026-11-08 02:38:26 +03:00
Horizon agentandClaude Opus 5.5 6ca76d9e9c Merge origin/main into #11507
dispense_fixture.rs: chains this branch's shield_hit fixture op and
main's wind_burst op.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:50:23 +01:00
Evrim Cicek c58a3eef6f Merge remote-tracking branch 'origin/fix/tnt-fuse-any-number' into fix/tnt-minecart-explosion-power
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-07 16:54:08 +03:00
Evrim Cicek ca57097ae8 Merge remote-tracking branch 'upstream/main' into fix/tnt-minecart-explosion-power
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-07 16:53:53 +03:00
socksproxandClaude Opus 5.5 507006a08d Move the mining speed formula into horizon-items
Player.getDestroySpeed, Player.hasCorrectToolForDrops and
BlockBehaviour.getDestroyProgress move from horizon-client's gameplay into
horizon_items::mining, taking plain inputs, so frontends that cannot link
horizon-client mine by the same rules. destroy_progress in game/hand.rs
gathers the player's modifiers and calls it, with the native_break_speed
hook still where getDestroySpeed ends; break_block_as_player uses the same
harvest check. Same f32 operations in the same order: no behaviour change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 02:24:09 +01:00
voidsip b98cb1a611 Give each shield fixture run a fresh game directory 2026-11-06 18:07:05 -06:00
Evrim Cicek b7eb967776 Merge remote-tracking branch 'upstream/main' into fix/tnt-minecart-explosion-power
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-07 02:30:21 +03:00
voidsip c74851a64b Initialize starvation blocking state and bound fixture startup 2026-11-06 17:25:24 -06:00
voidsip 6b6a9577c7 Merge current upstream for shield blocking validation 2026-11-06 16:13:01 -06:00
voidsip 316f9680d5 Fix built-in shield blocking inputs and item wear 2026-11-06 16:12:41 -06:00
Evrim CicekandClaude Opus 5.5 5a60e2252b TNT minecarts keep explosion_power and explosion_speed_factor, and blow with them
MinecartTNT reads explosion_power and explosion_speed_factor with
getFloatOr (4 and 1), clamped to 0..128, writes each only off its
default, and explode() blows with explosionPowerBase +
explosionSpeedFactor * nextDouble() * 1.5 * min(speed, 5). Horizon blew
every cart at 4 + 1.5 * speed * r and kept whatever the loaded compound
had for the fields, unclamped.

Command harness group "tnt minecart explosion power": 13/18 -> 18/18.

Fixes #11167

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 17:28:56 +03:00
290 changed files with 11422 additions and 1990 deletions

No files matched your search

+32 -18
View File
@@ -1,5 +1,5 @@
name: checks
# verify: every pull request and every push to main, on GitHub's runners (the repository is public). It is the
# verify: every pull request, every merge queue entry and every push to main (runners: see the job). It is the
# check main requires, so it never has a condition: a skipped job counts as passed, and every run of this workflow
# must report a real verify (a pull request that changes no Rust finishes it in seconds instead).
# parity: the vanilla harnesses a pull request's changes can affect, on the Mac mini's runner, which holds the
@@ -7,11 +7,15 @@ name: checks
# request that changes a harness, oracle, probe or the runner: the rest are checked on main by parity-main. A fork's pull
# request runs it only when a maintainer adds the run-parity label (.github/workflows/run-parity.yml), so labels
# never start this workflow. See docs/release-flow.md.
# merge_group: the merge queue's commit (main's tip, the pull requests ahead in the queue and this one) runs verify
# before it lands, on GitHub's runners; parity and launcher wait for main's push (docs/release-flow.md#merge-queue).
on:
push:
branches: [main]
pull_request:
types: [opened, synchronize, reopened]
merge_group:
types: [checks_requested]
workflow_dispatch:
permissions:
contents: read
@@ -19,7 +23,8 @@ concurrency:
# A new push to a pull request replaces its running checks. On main, the running check finishes and only the
# newest push waits behind it: GitHub cancels a waiting run when a newer one joins the group. A burst of merges
# queued ~100 main runs on 2026-11-09; the release follows the newest finished main run (docs/release-flow.md).
group: checks-${{ github.event.pull_request.number || (github.event_name == 'push' && 'main') || github.sha }}
# Each merge queue entry has a group of its own (its temporary branch), and nothing cancels it: the queue does.
group: checks-${{ github.event.pull_request.number || github.event.merge_group.head_ref || (github.event_name == 'push' && 'main') || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
# Test runs never reach the telemetry server: no live-count ping, no uploads (docs/telemetry.md).
@@ -34,11 +39,14 @@ jobs:
# forks' pull requests and main's pushes (which also save the Rust cache GitHub's pull request runs read), so both
# pools work at once. The repository variable SELF_HOSTED_CI moves them: `all` every job to ours, `light` (the
# desktop paused) or `off` every job to GitHub's. A fork's pull request sees no repository variable, so no switch
# could move it off a machine that is off: it always runs on GitHub's. On ours the build folder persists
# (CARGO_TARGET_DIR, outside the checkout), so the apt packages, Python and the Rust cache steps are GitHub's only.
# could move it off a machine that is off: it always runs on GitHub's. So does a merge queue's run, whatever
# SELF_HOSTED_CI says: its commit can hold a fork's code, which job-started.sh refuses on ours. On ours the build
# folder persists (CARGO_TARGET_DIR, outside the checkout), so the apt packages, Python and the Rust cache steps are
# GitHub's only.
verify:
runs-on: >-
${{ vars.SELF_HOSTED_CI != 'off' && vars.SELF_HOSTED_CI != 'light' && (vars.SELF_HOSTED_CI == 'all' ||
${{ vars.SELF_HOSTED_CI != 'off' && vars.SELF_HOSTED_CI != 'light' && github.event_name != 'merge_group' &&
(vars.SELF_HOSTED_CI == 'all' ||
github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository &&
(endsWith(format('{0}', github.event.pull_request.number), '0') ||
endsWith(format('{0}', github.event.pull_request.number), '1'))) &&
@@ -52,22 +60,24 @@ jobs:
if: runner.environment == 'github-hosted'
with:
python-version: '3.12'
# A merge queue's run reads its commit against main's tip (merge_group's base and head).
- name: Does this pull request need Rust checks?
if: github.event_name == 'pull_request'
if: github.event_name == 'pull_request' || github.event_name == 'merge_group'
id: rust
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha }}
run: python3 tools/ci/rust_changes.py "$BASE_SHA" "$HEAD_SHA" >> "$GITHUB_OUTPUT"
# A pull request adds only its own commits to main, so it reads those; main's pushes read every ref's history.
# A pull request (or a merge queue entry) adds only its own commits to main, so it reads those; main's pushes
# read every ref's history.
- name: Keep game artifacts out of Git
env:
EVENT: ${{ github.event_name }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha }}
run: |
python tools/check_public_tree.py
if [ "$EVENT" = pull_request ]; then
if [ "$EVENT" = pull_request ] || [ "$EVENT" = merge_group ]; then
python tools/check_public_tree.py --range "$BASE_SHA..$HEAD_SHA"
else
python tools/check_public_tree.py --history
@@ -84,21 +94,22 @@ jobs:
echo "WGPU_BACKEND=dx12" >> "$GITHUB_ENV"
# On a pull request, the next three checks compare with HEAD^1: the main that GitHub's merge commit puts the pull
# request on. pull_request.base.sha is main at the pull request's last push and falls behind, so main's own
# changes since then would count as the pull request's (#13874).
# changes since then would count as the pull request's (#13874). A merge queue's entry compares with main's tip
# (merge_group.base_sha), whatever the queue's merge method makes of HEAD^1.
# docs/layers.md: a crate depends only on its own layer or a lower one; today's exceptions only shrink.
- name: Crates follow the layers
env:
BASE: ${{ github.event_name == 'pull_request' && 'HEAD^1' || '' }}
BASE: ${{ github.event_name == 'pull_request' && 'HEAD^1' || github.event.merge_group.base_sha || '' }}
run: python3 tools/check_layers.py ${BASE:+--base "$BASE"}
# docs/layers.md#built-in-addresses: every built-in outside address in the Horizon layer's home; the rest only shrinks.
- name: Built-in addresses stay in their home
env:
BASE: ${{ github.event_name == 'pull_request' && 'HEAD^1' || '' }}
BASE: ${{ github.event_name == 'pull_request' && 'HEAD^1' || github.event.merge_group.base_sha || '' }}
run: python3 tools/check_addresses.py ${BASE:+--base "$BASE"}
# docs/layers.md#file-size: a Rust file over 3000 lines may not grow (pull requests; main has no base to compare).
- name: Big files don't grow
env:
BASE: ${{ github.event_name == 'pull_request' && 'HEAD^1' || '' }}
BASE: ${{ github.event_name == 'pull_request' && 'HEAD^1' || github.event.merge_group.base_sha || '' }}
run: python3 tools/check_file_size.py ${BASE:+--base "$BASE"}
- name: Test offline research tooling
run: python -m unittest discover -s tests -v
@@ -139,10 +150,13 @@ jobs:
cargo +stable test --locked --workspace --doc
# The launcher is its own workspace (launcher/Cargo.toml), so verify's cargo runs never build it. A pull request that
# changes it or a crate it builds is checked here, and so is every push to main: two pull requests can each pass
# alone and break it together (#2059 and the registry ratings did, 2026-10-26).
# alone and break it together (#2059 and the registry ratings did, 2026-10-26). Not in the merge queue: no check of
# its is required, so the queue wouldn't wait for it, and main's push runs it anyway.
launcher:
if: github.event_name != 'merge_group'
runs-on: >-
${{ vars.SELF_HOSTED_CI != 'off' && vars.SELF_HOSTED_CI != 'light' && (vars.SELF_HOSTED_CI == 'all' ||
${{ vars.SELF_HOSTED_CI != 'off' && vars.SELF_HOSTED_CI != 'light' && github.event_name != 'merge_group' &&
(vars.SELF_HOSTED_CI == 'all' ||
github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository &&
(endsWith(format('{0}', github.event.pull_request.number), '0') ||
endsWith(format('{0}', github.event.pull_request.number), '1'))) &&
Generated
+2 -1
View File
@@ -1486,6 +1486,7 @@ dependencies = [
"horizon-server-files",
"horizon-shader-api",
"horizon-shaders",
"horizon-system",
"horizon-telemetry",
"horizon-theme",
"horizon-threads",
@@ -1849,8 +1850,8 @@ dependencies = [
name = "horizon-optifine"
version = "0.1.0"
dependencies = [
"horizon-bench",
"horizon-core",
"horizon-system",
"log",
"regex",
]
+8 -9
View File
@@ -1764,14 +1764,13 @@ fn stack_from_template(template: &Value) -> Option<StackView> {
/// `RangeSelectItemModel.lastIndexLessOrEqual`.
fn last_index_less_or_equal(entries: &[(f32, ItemModelNode)], needle: f32) -> Option<usize> {
let haystack: Vec<f32> = entries.iter().map(|e| e.0).collect();
let found = if haystack.len() < 16 {
haystack
let found = if entries.len() < 16 {
entries
.iter()
.position(|h| *h > needle)
.map_or(haystack.len() as i64 - 1, |i| i as i64 - 1)
.position(|e| e.0 > needle)
.map_or(entries.len() as i64 - 1, |i| i as i64 - 1)
} else {
match binary_search(&haystack, needle) {
match binary_search(entries, needle) {
Ok(index) => index as i64,
Err(insertion) => insertion as i64 - 1,
}
@@ -1780,8 +1779,8 @@ fn last_index_less_or_equal(entries: &[(f32, ItemModelNode)], needle: f32) -> Op
}
/// `Arrays.binarySearch(float[], float)`, including its handling of equal
/// values (-0.0 below 0.0, NaN above everything).
fn binary_search(a: &[f32], key: f32) -> std::result::Result<usize, usize> {
let (mut low, mut high) = (0i64, a.len() as i64 - 1);
fn binary_search(entries: &[(f32, ItemModelNode)], key: f32) -> std::result::Result<usize, usize> {
let (mut low, mut high) = (0i64, entries.len() as i64 - 1);
let bits = |v: f32| -> i32 {
if v.is_nan() {
0x7fc0_0000
@@ -1791,7 +1790,7 @@ fn binary_search(a: &[f32], key: f32) -> std::result::Result<usize, usize> {
};
while low <= high {
let mid = (low + high) >> 1;
let value = a[mid as usize];
let value = entries[mid as usize].0;
if value < key {
low = mid + 1;
} else if value > key {
@@ -158,3 +158,61 @@ fn a_patched_stack_reads_its_patch_over_its_items_defaults() {
}
assert_eq!(view.to_map(), *merged);
}
#[test]
fn last_index_less_or_equal_linear_and_binary() {
use super::last_index_less_or_equal;
let make = |thresholds: &[f32]| -> Vec<(f32, ItemModelNode)> {
thresholds
.iter()
.map(|&t| (t, ItemModelNode::Empty))
.collect()
};
// Empty
assert_eq!(last_index_less_or_equal(&[], 0.0), None);
// Short (< 16): linear search
let short = make(&[-1.0, 0.0, 1.0, 2.5, 5.0]);
assert_eq!(last_index_less_or_equal(&short, -2.0), None);
assert_eq!(last_index_less_or_equal(&short, -1.0), Some(0));
assert_eq!(last_index_less_or_equal(&short, -0.5), Some(0));
assert_eq!(last_index_less_or_equal(&short, 0.0), Some(1));
assert_eq!(last_index_less_or_equal(&short, 2.5), Some(3));
assert_eq!(last_index_less_or_equal(&short, 3.0), Some(3));
assert_eq!(last_index_less_or_equal(&short, 5.0), Some(4));
assert_eq!(last_index_less_or_equal(&short, 10.0), Some(4));
// Long (>= 16): binary search
let thresholds: Vec<f32> = (0..32).map(|i| i as f32 * 10.0).collect();
let long = make(&thresholds);
assert_eq!(last_index_less_or_equal(&long, -5.0), None);
assert_eq!(last_index_less_or_equal(&long, 0.0), Some(0));
assert_eq!(last_index_less_or_equal(&long, 5.0), Some(0));
assert_eq!(last_index_less_or_equal(&long, 10.0), Some(1));
assert_eq!(last_index_less_or_equal(&long, 150.0), Some(15));
assert_eq!(last_index_less_or_equal(&long, 155.0), Some(15));
assert_eq!(last_index_less_or_equal(&long, 310.0), Some(31));
assert_eq!(last_index_less_or_equal(&long, 1000.0), Some(31));
// Short lists use upper-bound tie behavior, including signed zero.
let duplicates = make(&[1.0, 1.0, 2.0]);
assert_eq!(last_index_less_or_equal(&duplicates, 1.0), Some(1));
let zeroes = make(&[-0.0, 0.0]);
assert_eq!(last_index_less_or_equal(&zeroes, -0.0), Some(1));
assert_eq!(last_index_less_or_equal(&zeroes, 0.0), Some(1));
// At 16 entries, vanilla returns Arrays.binarySearch's hit, not the last tie.
// Expected indices were checked on the JVM.
let duplicates = make(&[
0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 7.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0, 13.0,
]);
assert_eq!(last_index_less_or_equal(&duplicates, 7.0), Some(7));
let zeroes = make(&[
-8.0, -7.0, -6.0, -5.0, -4.0, -3.0, -2.0, -0.0, -0.0, 0.0, 0.0, 1.0, 2.0, 3.0, 4.0, 5.0,
]);
assert_eq!(last_index_less_or_equal(&zeroes, -0.0), Some(7));
assert_eq!(last_index_less_or_equal(&zeroes, 0.0), Some(9));
}
+3 -117
View File
@@ -1,6 +1,7 @@
//! The machine a report was measured on. CPU, RAM and OS are detected here; the GPU and the display
//! come from the program that rendered (the game knows its adapter, the launcher its window).
#[cfg(any(target_os = "macos", target_os = "linux"))]
use horizon_telemetry::system::command_output as output;
use serde::{Deserialize, Serialize};
@@ -68,12 +69,12 @@ impl Hardware {
os: std::env::consts::OS.to_owned(),
os_version: os_version(),
arch: std::env::consts::ARCH.to_owned(),
cpu: cpu(),
cpu: horizon_telemetry::system::cpu(),
cpu_threads: std::thread::available_parallelism().ok().map(|n| n.get()),
cpu_cores: probes.cores,
cpu_base_mhz: probes.base,
cpu_boost_mhz: probes.boost,
ram_mb: ram_bytes().map(|b| b >> 20),
ram_mb: horizon_telemetry::system::ram_bytes().map(|b| b >> 20),
storage: None,
form: probes.form,
gpu: None,
@@ -109,84 +110,16 @@ fn probes() -> Probes {
}
}
/// The one-minute load average: above about the number of threads the machine was busy.
pub fn load_average() -> Option<f64> {
if cfg!(target_os = "macos") {
// "{ 1.23 1.10 1.00 }"
output("sysctl", &["-n", "vm.loadavg"])?
.trim_start_matches('{')
.split_whitespace()
.next()?
.parse()
.ok()
} else {
std::fs::read_to_string("/proc/loadavg")
.ok()?
.split_whitespace()
.next()?
.parse()
.ok()
}
}
/// `ac` or `battery`; `None` where it can't be read (and on machines without a battery on Linux).
pub fn power_source() -> Option<&'static str> {
horizon_telemetry::system::power()
}
/// Resident memory of a process, in bytes (macOS, Linux).
pub fn rss_bytes(pid: u32) -> Option<u64> {
if cfg!(target_os = "macos") {
return output("ps", &["-o", "rss=", "-p", &pid.to_string()])?
.parse::<u64>()
.ok()
.map(|kb| kb * 1024);
}
let status = std::fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
let line = status.lines().find(|l| l.starts_with("VmRSS:"))?;
line.split_whitespace()
.nth(1)?
.parse::<u64>()
.ok()
.map(|kb| kb * 1024)
}
/// CPU time a process has used, user and system, in seconds (macOS, Linux).
pub fn cpu_seconds(pid: u32) -> Option<f64> {
if cfg!(target_os = "macos") {
// "[[dd-]hh:]mm:ss.cc"
let time = output("ps", &["-o", "time=", "-p", &pid.to_string()])?;
let (rest, days) = match time.split_once('-') {
Some((d, rest)) => (rest.to_owned(), d.parse::<f64>().ok()?),
None => (time, 0.0),
};
let seconds = rest.split(':').try_fold(0.0, |acc, part| {
part.parse::<f64>().ok().map(|v| acc * 60.0 + v)
})?;
return Some(days * 86_400.0 + seconds);
}
// utime and stime, fields 14 and 15, in clock ticks (100 per second on Linux).
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
let fields: Vec<&str> = stat.rsplit_once(')')?.1.split_whitespace().collect();
let ticks: f64 = fields.get(11)?.parse::<f64>().ok()? + fields.get(12)?.parse::<f64>().ok()?;
Some(ticks / 100.0)
}
#[cfg(target_os = "macos")]
fn os_version() -> Option<String> {
output("sw_vers", &["-productVersion"])
}
#[cfg(target_os = "macos")]
fn cpu() -> Option<String> {
output("sysctl", &["-n", "machdep.cpu.brand_string"])
}
#[cfg(target_os = "macos")]
fn ram_bytes() -> Option<u64> {
output("sysctl", &["-n", "hw.memsize"])?.parse().ok()
}
#[cfg(target_os = "linux")]
fn os_version() -> Option<String> {
let release = std::fs::read_to_string("/etc/os-release").ok()?;
@@ -199,22 +132,6 @@ fn os_version() -> Option<String> {
})
}
#[cfg(target_os = "linux")]
fn cpu() -> Option<String> {
let info = std::fs::read_to_string("/proc/cpuinfo").ok()?;
// x86 names the CPU per core; arm64 only gives the core's implementer and part.
horizon_telemetry::system::linux_cpu_name(&info)
}
#[cfg(target_os = "linux")]
fn ram_bytes() -> Option<u64> {
let info = std::fs::read_to_string("/proc/meminfo").ok()?;
let kb = key_value(&info, "MemTotal", ':')?
.trim_end_matches("kB")
.trim();
kb.parse::<u64>().ok().map(|kb| kb * 1024)
}
#[cfg(target_os = "linux")]
fn key_value<'a>(text: &'a str, key: &str, sep: char) -> Option<&'a str> {
text.lines().find_map(|line| {
@@ -228,39 +145,11 @@ fn os_version() -> Option<String> {
horizon_telemetry::system::windows_version()
}
#[cfg(target_os = "windows")]
fn cpu() -> Option<String> {
horizon_telemetry::system::windows_cpu()
}
#[cfg(target_os = "windows")]
fn ram_bytes() -> Option<u64> {
let out = output(
"powershell",
&[
"-NoProfile",
"-Command",
"(Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory",
],
)?;
out.parse().ok()
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
fn os_version() -> Option<String> {
None
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
fn cpu() -> Option<String> {
None
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
fn ram_bytes() -> Option<u64> {
None
}
#[cfg(test)]
mod tests {
use super::*;
@@ -274,9 +163,6 @@ mod tests {
assert!(hw.cpu.is_some(), "{hw:?}");
assert!(hw.ram_mb.unwrap() > 256, "{hw:?}");
assert!(hw.os_version.is_some(), "{hw:?}");
assert!(load_average().is_some());
assert!(rss_bytes(std::process::id()).unwrap() > 1 << 20);
assert!(cpu_seconds(std::process::id()).unwrap() >= 0.0);
}
}
+3 -1
View File
@@ -13,7 +13,9 @@ use serde::{Deserialize, Serialize};
mod hardware;
pub use hardware::{cpu_seconds, load_average, power_source, rss_bytes, Display, Gpu, Hardware};
pub use hardware::{power_source, Display, Gpu, Hardware};
/// The process probes, from the platform (`horizon-system`).
pub use horizon_telemetry::system::{cpu_seconds, load_average, rss_bytes};
pub const FORMAT: &str = "horizon-benchmark-report";
pub const VERSION: u32 = 1;
+12 -5
View File
@@ -568,6 +568,8 @@ impl Chat {
pub fn set_qol(&mut self, atlas: &HudAtlas, qol: ChatQol) {
if self.qol != qol {
self.qol = qol;
self.scroll = 0;
self.new_since_scroll = false;
self.rewrap(&atlas.font);
}
}
@@ -593,20 +595,25 @@ impl Chat {
.filter(|q| !q.trim().is_empty());
if self.search != query {
self.search = query;
self.scroll = 0;
self.new_since_scroll = false;
self.rewrap(font);
}
}
pub fn searching(&self) -> bool {
self.search.is_some()
}
/// Re-wrap after a width or scale change (ChatComponent.rescaleChat),
/// keeping only the messages the Chat option shows.
/// Re-wrap after a width or scale change (`ChatComponent.rescaleChat`):
/// `resetChatScroll`, then the messages the Chat option shows.
pub fn rescale(&mut self, atlas: &HudAtlas) {
self.rewrap(&atlas.font);
}
fn rewrap(&mut self, font: &crate::font::Font) {
self.scroll = 0;
self.new_since_scroll = false;
self.rewrap(&atlas.font);
}
/// `ChatComponent.refreshTrimmedMessages`: the lines are laid out again
/// where the reader was (`chatScrollbarPos` survives a filter change);
/// `rescale` resets the scroll itself, as `rescaleChat` does.
fn rewrap(&mut self, font: &crate::font::Font) {
self.lines.clear();
let mut messages = std::mem::take(&mut self.messages);
for m in messages.iter_mut().rev() {
@@ -1067,6 +1067,34 @@ fn a_server_filter_mask_hides_or_masks_the_message() {
);
}
/// `ChatComponent.refreshTrimmedMessages`: a filter change lays the lines out
/// again but keeps `chatScrollbarPos` and `newMessageSinceScroll`; only
/// `rescaleChat` resets them, after `resetChatScroll`.
#[test]
fn a_chat_option_change_keeps_the_readers_place_but_a_rescale_does_not() {
let font = font();
let mut chat = Chat::default();
for i in 0..60 {
chat.add_message(
&font,
Text::literal(format!("line {i}")),
None,
ChatSource::Server,
0,
false,
);
}
chat.scroll = 3;
chat.new_since_scroll = true;
chat.set_abilities(&font, ChatAbilities::new(ChatVisibility::System));
assert_eq!(chat.scroll, 3);
assert!(chat.new_since_scroll);
let atlas = crate::hud::HudAtlas::build(Path::new("/nonexistent/horizon-test-assets")).unwrap();
chat.rescale(&atlas);
assert_eq!(chat.scroll, 0);
assert!(!chat.new_since_scroll);
}
/// `SuggestionsList.extractRenderState`: the list's rectangle asks for the
/// pointing hand; outside it the cursor stays the arrow.
#[test]
+20 -2
View File
@@ -196,7 +196,7 @@ impl ColumnLayout {
}
/// The index of the section holding block `y`, if the column holds it.
pub fn section_index(self, y: i32) -> Option<usize> {
usize::try_from((y - self.min_y) >> 4)
usize::try_from((i64::from(y) - i64::from(self.min_y)) >> 4)
.ok()
.filter(|&s| s < self.sections)
}
@@ -700,7 +700,7 @@ impl Chunk {
/// The index of the section holding block `y`, if the column holds it.
#[inline]
pub fn section_index(&self, y: i32) -> Option<usize> {
usize::try_from((y - self.min_y) >> 4)
usize::try_from((i64::from(y) - i64::from(self.min_y)) >> 4)
.ok()
.filter(|&s| s < self.sections.len())
}
@@ -984,6 +984,24 @@ pub struct ChunkExtras {
mod tests {
use super::*;
#[test]
fn extreme_y_queries_are_air_and_writes_leave_the_column_unchanged() {
for layout in [ColumnLayout::VANILLA, ColumnLayout::new(0, 256).unwrap()] {
let mut chunk = Chunk::with_layout(0, 0, layout);
chunk.set(0, layout.min_y(), 0, 7);
for y in [i32::MIN, i32::MIN + 63, -2_146_769_275, i32::MAX] {
assert_eq!(layout.section_index(y), None);
assert_eq!(chunk.section_index(y), None);
assert_eq!(chunk.get(0, y, 0), 0);
assert_eq!(
chunk.light(0, y, 0),
if y < layout.min_y() { 0 } else { 0xf0 }
);
chunk.set(0, y, 0, 9);
}
assert_eq!(chunk.get(0, layout.min_y(), 0), 7);
}
}
#[test]
fn each_light_form_keeps_every_value_and_run() {
// Sky varies alone, block varies alone, both, neither.
let cases: [(fn(usize) -> u8, &str); 4] = [
+11 -2
View File
@@ -419,8 +419,10 @@ impl SceneCollisions<'_, '_> {
let hi = BlockPos::containing(Vec3::new(max.x + 1.0e-7, max.y + 1.0e-7, max.z + 1.0e-7))
.offset(1, 1, 1);
let size = |a: i32, b: i32| i64::from(b) - i64::from(a) + 1;
let cells = size(lo.x, hi.x) * size(lo.y, hi.y) * size(lo.z, hi.z);
if lo.x > hi.x || lo.y > hi.y || lo.z > hi.z || cells > 4096 {
let cells = [size(lo.x, hi.x), size(lo.y, hi.y), size(lo.z, hi.z)]
.into_iter()
.try_fold(1i64, |n, size| n.checked_mul(size));
if lo.x > hi.x || lo.y > hi.y || lo.z > hi.z || cells.is_none_or(|n| n > 4096) {
return false;
}
// A column and section at a time. Most boxes lie in a few sections whose palette says that no
@@ -1111,6 +1113,13 @@ mod section_tests {
binding: &binding,
subject: None,
};
// The fast path must decline an enormous volume without overflowing
// its size calculation or beginning to walk it.
let enormous = Aabb::new(
Vec3::new(-1.0e9, -1.0e9, -1.0e9),
Vec3::new(1.0e9, 1.0e9, 1.0e9),
);
assert!(!scene.cells_without_collision(enormous));
// Under the fence: its section's outer cells may hold a shape reaching in.
let under = Aabb::new(Vec3::new(5.4, 31.6, 5.4), Vec3::new(5.6, 31.8, 5.6));
assert!(!scene.cells_without_collision(under.expand_towards(Vec3::new(0.0, 0.1, 0.0))));
+85 -14
View File
@@ -56,6 +56,8 @@ pub struct Overlay {
struct LoadedRule {
rule: Rule,
tiles: Vec<TileLayer>,
/// By tile: the rules whose `matchTiles` name it (a range of `lists`), which chain on it.
chain: Vec<(u32, u32)>,
overlay: OverlayJoin,
}
@@ -188,6 +190,7 @@ impl ConnectedTextures {
Ok(tiles) => Some(LoadedRule {
rule,
tiles,
chain: Vec::new(),
overlay: OverlayJoin::default(),
}),
Err(e) => {
@@ -292,10 +295,23 @@ impl ConnectedTextures {
let mut by_layer: Vec<Vec<u16>> = Vec::new();
// Block rules by the block each entry names, so a state tests only its own.
let mut by_block: HashMap<&str, Vec<u16>> = HashMap::new();
// Tile rules by the sprite or tile file each names, for chaining on a picked tile.
let mut by_name: HashMap<String, Vec<u16>> = HashMap::new();
for (i, r) in out.rules.iter().enumerate() {
let i = i as u16;
for tile in &r.rule.match_tiles {
if let Some(l) = layer(&sprite_name(tile)) {
let name = sprite_name(tile);
let list = by_name.entry(name.clone()).or_default();
if list.last() != Some(&i) {
list.push(i);
}
// A tile file (`./1.png`) is no model's sprite: only chaining reaches it, by
// name. By layer it would match every face whose sprite has the same pixels
// (identical stills share a layer: Conquest's blank placeholders, dozens).
if !name.contains(':') {
continue;
}
if let Some(l) = layer(&name) {
let l = l as usize;
if by_layer.len() <= l {
by_layer.resize(l + 1, Vec::new());
@@ -316,6 +332,26 @@ impl ConnectedTextures {
let range = push_list(&mut out.lists, &list);
out.by_layer.push(range);
}
let by_name: HashMap<String, (u32, u32)> = by_name
.into_iter()
.map(|(name, list)| (name, push_list(&mut out.lists, &list)))
.collect();
// Set at the end: `by_block` borrows the rules' names.
let chains: Vec<Vec<(u32, u32)>> = out
.rules
.iter()
.map(|r| {
r.rule
.tiles
.iter()
.map(|t| match t {
Tile::Texture(path) => by_name.get(&sprite_name(path)).copied(),
_ => None,
})
.map(Option::unwrap_or_default)
.collect()
})
.collect();
let mut list = Vec::new();
for state in states {
let Some((name, props)) = state else {
@@ -355,6 +391,9 @@ impl ConnectedTextures {
let range = push_list(&mut out.lists, &list);
out.by_state.push(range);
}
for (r, chain) in out.rules.iter_mut().zip(chains) {
r.chain = chain;
}
out
}
@@ -392,9 +431,11 @@ impl ConnectedTextures {
}
fn list(&self, ranges: &[(u32, u32)], i: usize) -> &[u16] {
ranges.get(i).map_or(&[], |&(start, len)| {
&self.lists[start as usize..(start + len) as usize]
})
ranges.get(i).map_or(&[], |&range| self.range(range))
}
fn range(&self, (start, len): (u32, u32)) -> &[u16] {
&self.lists[start as usize..(start + len) as usize]
}
/// What the `face` of the block `state` at `pos` draws, for a quad with sprite `layer`.
@@ -441,7 +482,7 @@ impl ConnectedTextures {
let r = &self.rules[usize::from(i)];
if (block || r.rule.match_blocks.is_empty())
&& (tile || r.rule.match_tiles.is_empty())
&& let Some(pick) = self.apply(
&& let Some((pick, index)) = self.apply(
r,
state,
layer,
@@ -454,7 +495,7 @@ impl ConnectedTextures {
)
{
// "Tiles output by CTM rules can also be matched by another rule": once, by the
// tile rules naming the picked tile (and this block, if they name blocks).
// tile rules naming the picked tile by name (and this block, if they name blocks).
// A tile on the face's own sprite (`tiles=stone`) is no new tile.
let Pick::Tile(TileLayer::Layer { layer: picked, .. }) = pick else {
return pick;
@@ -467,7 +508,7 @@ impl ConnectedTextures {
// again: an overlay would draw twice.
let first = self.list(&self.by_layer, layer as usize);
let again = self
.list(&self.by_layer, picked as usize)
.range(r.chain.get(index).copied().unwrap_or_default())
.iter()
.map(|&j| (j, &self.rules[usize::from(j)]))
.filter(|&(j, r)| {
@@ -488,8 +529,8 @@ impl ConnectedTextures {
&texture,
&mut overlay,
) {
Some(Pick::Keep) => return pick,
Some(chained) => return chained,
Some((Pick::Keep, _)) => return pick,
Some((chained, _)) => return chained,
None => {}
}
}
@@ -512,7 +553,7 @@ impl ConnectedTextures {
biome: &impl Fn() -> Option<&'b str>,
texture: &impl Fn() -> Option<Axes>,
overlay: &mut impl FnMut(Overlay),
) -> Option<Pick> {
) -> Option<(Pick, usize)> {
let rule = &r.rule;
// `Rule::applies_at`, with the biome looked up only for rules that test one. Rules with
// a custom name are not loaded.
@@ -602,7 +643,7 @@ impl ConnectedTextures {
return if quarters.contains(&TileLayer::Skip) {
None
} else {
Some(Pick::Quarters(quarters))
Some((Pick::Quarters(quarters), 0))
};
}
}
@@ -617,7 +658,7 @@ impl ConnectedTextures {
return None;
}
if !joins([0, 1, 0]) {
return Some(Pick::Keep);
return Some((Pick::Keep, 0));
}
0
}
@@ -644,8 +685,8 @@ impl ConnectedTextures {
}
match tile(index) {
TileLayer::Skip => None,
TileLayer::Default => Some(Pick::Keep),
t => Some(Pick::Tile(t)),
TileLayer::Default => Some((Pick::Keep, index)),
t => Some((Pick::Tile(t), index)),
}
}
}
@@ -675,6 +716,7 @@ mod tests {
.enumerate()
.map(|(n, rule)| LoadedRule {
overlay: OverlayJoin::default(),
chain: Vec::new(),
tiles: rule
.tiles
.iter()
@@ -722,6 +764,10 @@ mod tests {
"assets/minecraft/optifine/ctm/b0.png" => Some(2000),
// And `a0` of the first rule.
"assets/minecraft/optifine/ctm/a0.png" => Some(1000),
// Two blank placeholders with the same pixels, so one layer: the tile `grass/1`
// of the third rule (`chaining_follows_the_tile_not_its_pixels`).
"assets/minecraft/optifine/ctm/grass/1.png"
| "assets/minecraft/optifine/ctm/aaa/1.png" => Some(3000),
_ => None,
},
)
@@ -1176,6 +1222,31 @@ mod tests {
assert_eq!(pick_in(&ctm, 1, 3, Face::Up, &[]), tile(2000));
}
#[test]
fn chaining_follows_the_tile_not_its_pixels() {
// Conquest's grass top: a placeholder tile that a rule beside it replaces. Another
// folder's placeholder has the same pixels, hence the same layer, and its rule sorts
// first; it must not draw on the grass.
let ctm = world(&[
(
"grass/top.properties",
"matchTiles=glass\nmethod=fixed\ntiles=1",
),
(
"grass/random_1.properties",
"matchTiles=./1.png\nmethod=fixed\ntiles=17\nfaces=top",
),
(
"aaa/random_1.properties",
"matchTiles=./1.png\nmethod=fixed\ntiles=5\nfaces=top",
),
]);
// Rules in path order: aaa/random_1 (1000), grass/random_1 (2000), grass/top (3000).
assert_eq!(pick_in(&ctm, 1, 3, Face::Up, &[]), tile(2000));
// A face whose own sprite has a placeholder's pixels is no placeholder.
assert_eq!(pick_in(&ctm, 2, 3000, Face::Up, &[]), Pick::Keep);
}
#[test]
fn orient_texture_follows_the_quads_uvs() {
let rule = |orient: &str| {
+115 -17
View File
@@ -56,6 +56,15 @@ pub struct Glue<'a> {
pub struct CoreProgram {
pub vertex: naga::Module,
pub fragment: naga::Module,
/// The blocks and samplers that are none of vanilla's, in binding order: the `i`th is at
/// [`other_binding`]`(i)` (a sampler's object one after its texture; a block leaves that
/// binding unused).
pub other: Vec<String>,
}
/// The binding of the `index`th block or sampler of [`CoreProgram::other`].
pub fn other_binding(index: usize) -> u32 {
OTHER_BINDINGS + 2 * index as u32
}
/// The shaders of one pipeline (`text.vsh`, `text.fsh`) with vanilla's `defines` for it.
@@ -66,6 +75,20 @@ pub fn translate_core(
defines: &[&str],
include: &dyn Fn(&str) -> Option<String>,
glue: &Glue,
) -> Result<CoreProgram, String> {
translate_core_with(vertex, fragment, defines, include, glue, &|_| false)
}
/// [`translate_core`] for a pipeline that also gives the blocks and samplers `provided` says
/// yes to, at their [`CoreProgram::other`] bindings; the others are still an error. `provided`
/// answers a block's name and a sampler's alike: the caller knows which kind each one is.
pub fn translate_core_with(
vertex: &str,
fragment: &str,
defines: &[&str],
include: &dyn Fn(&str) -> Option<String>,
glue: &Glue,
provided: &dyn Fn(&str) -> bool,
) -> Result<CoreProgram, String> {
const CORE: &str = "minecraft:shaders/core/";
let vertex_text = glsl_fixups::fixups(&strip_comments(&expand(vertex, CORE, include)?));
@@ -75,10 +98,16 @@ pub fn translate_core(
.map_err(|e| format!("vertex shader: {e}"))?;
let fragment_code = rewrite(&fragment_text, ShaderStage::Fragment, glue, &mut names)
.map_err(|e| format!("fragment shader: {e}"))?;
let vertex = compile(ShaderStage::Vertex, &vertex_code, defines)
let vertex = compile(ShaderStage::Vertex, &vertex_code, defines, &names, provided)
.map_err(|e| format!("vertex shader: {e}"))?;
let fragment = compile(ShaderStage::Fragment, &fragment_code, defines)
.map_err(|e| format!("fragment shader: {e}"))?;
let fragment = compile(
ShaderStage::Fragment,
&fragment_code,
defines,
&names,
provided,
)
.map_err(|e| format!("fragment shader: {e}"))?;
let given = locations(&vertex, false);
if let Some(missing) = locations(&fragment, true)
.into_iter()
@@ -88,7 +117,11 @@ pub fn translate_core(
"the fragment shader reads location {missing}, which the vertex shader doesn't write"
));
}
Ok(CoreProgram { vertex, fragment })
Ok(CoreProgram {
vertex,
fragment,
other: names.other,
})
}
/// A bound on a pack's includes, which are pasted every time.
@@ -309,7 +342,9 @@ fn block_name(line: &str) -> Option<&str> {
let rest = line[at + "uniform".len()..].trim();
let name_end = rest.find(|c: char| !is_ident(c)).unwrap_or(rest.len());
let (name, tail) = rest.split_at(name_end);
(!name.is_empty() && matches!(tail.trim(), "" | "{")).then_some(name)
// `{` alone, or the whole block on the line (`uniform Day { float daylight; };`).
(!name.is_empty() && (tail.trim().is_empty() || tail.trim_start().starts_with('{')))
.then_some(name)
}
/// What the two stages share: the vertex stage's outputs and their locations, and the names of
@@ -337,7 +372,7 @@ impl Names {
self.other.len() - 1
}
};
OTHER_BINDINGS + 2 * index as u32
other_binding(index)
}
}
@@ -551,7 +586,13 @@ fn rename_main(text: &str) -> String {
}
/// Parses and validates one stage; errors name the line of the rewritten code.
fn compile(stage: ShaderStage, code: &str, defines: &[&str]) -> Result<naga::Module, String> {
fn compile(
stage: ShaderStage,
code: &str,
defines: &[&str],
names: &Names,
provided: &dyn Fn(&str) -> bool,
) -> Result<naga::Module, String> {
let mut options = Options::from(stage);
for define in defines {
let (name, value) = define.split_once(' ').unwrap_or((define, ""));
@@ -583,16 +624,16 @@ fn compile(stage: ShaderStage, code: &str, defines: &[&str]) -> Result<naga::Mod
})?;
let entry = info.get_entry_point(0);
for (handle, global) in module.global_variables.iter() {
let other = global
.binding
.as_ref()
.is_some_and(|b| b.binding >= OTHER_BINDINGS);
if other && !entry[handle].is_empty() {
let name = global.name.as_deref().unwrap_or("?");
let name = name
.strip_prefix("hz_tex_")
.or_else(|| name.strip_prefix("hz_smp_"))
.unwrap_or(name);
let Some(binding) = global.binding.as_ref().map(|b| b.binding) else {
continue;
};
if binding >= OTHER_BINDINGS && !entry[handle].is_empty() {
// The name it was declared with (a block's instance may have none).
let index = ((binding - OTHER_BINDINGS) / 2) as usize;
let name = names.other.get(index).map_or("?", String::as_str);
if provided(name) {
continue;
}
return Err(format!("{name} is not a block or sampler of this pipeline"));
}
}
@@ -868,6 +909,63 @@ mod tests {
translate_core(vsh, fsh, &[], &include, &GLUE).unwrap();
}
/// A block declared on one line, as packs write a one-float uniform, is bound like one
/// over several lines; the blocks and samplers the caller provides are accepted, in binding
/// order, and one it doesn't provide is not.
#[test]
fn a_block_on_one_line_is_bound_and_provided_blocks_are_accepted() {
let vsh = "#version 330\nlayout(std140) uniform Day { float daylight; };\nout vec4 vertexColor;\n\
void main() { vertexColor = vec4(daylight); gl_Position = vec4(0.0); }\n";
let fsh = "#version 330\nuniform sampler2D Bounds;\nin vec4 vertexColor;\nout vec4 fragColor;\n\
void main() { fragColor = vertexColor * texture(Bounds, vec2(0.5)); }\n";
let both = |name: &str| name == "Day" || name == "Bounds";
let program = translate_core_with(vsh, fsh, &[], &include, &GLUE, &both).unwrap();
assert_eq!(program.other, ["Day", "Bounds"]);
// The bindings the caller gives them at.
let bindings = |module: &naga::Module| -> Vec<(Option<String>, u32)> {
module
.global_variables
.iter()
.filter_map(|(_, g)| Some((g.name.clone(), g.binding.as_ref()?.binding)))
.collect()
};
assert!(
bindings(&program.vertex)
.iter()
.any(|(_, b)| *b == other_binding(0)),
"{:?}",
bindings(&program.vertex)
);
let fragment = bindings(&program.fragment);
assert!(
fragment.contains(&(Some("hz_tex_Bounds".into()), other_binding(1))),
"{fragment:?}"
);
assert!(
fragment.contains(&(Some("hz_smp_Bounds".into()), other_binding(1) + 1)),
"{fragment:?}"
);
let error = translate_core(vsh, fsh, &[], &include, &GLUE).unwrap_err();
assert!(error.contains("Day is not a block or sampler"), "{error}");
let only_day = |name: &str| name == "Day";
let error = translate_core_with(vsh, fsh, &[], &include, &GLUE, &only_day).unwrap_err();
assert!(
error.contains("Bounds is not a block or sampler"),
"{error}"
);
}
/// Only a uniform block's declaration names a block, written over several lines or on one.
#[test]
fn block_names_are_read_from_block_declarations_only() {
assert_eq!(block_name("layout(std140) uniform Day {"), Some("Day"));
assert_eq!(block_name("uniform Day { float daylight; };"), Some("Day"));
assert_eq!(block_name("uniform Foo{"), Some("Foo"));
assert_eq!(block_name("uniform Globals"), Some("Globals"));
assert_eq!(block_name("uniform float x;"), None);
assert_eq!(block_name("uniform sampler2D Sampler0;"), None);
}
#[test]
fn what_horizon_does_not_provide_is_an_error() {
let fsh = "#version 330\nin vec4 vertexColor;\nout vec4 fragColor;\nvoid main() { fragColor = vertexColor; }\n";
+4 -4
View File
@@ -36,7 +36,7 @@ struct Globals {
// w: apply the no-pack HDR gain; HDR_WRITE_LIGHT independently keeps float alpha as coverage.
hdr_light: vec4<f32>,
hdr_settings: vec4<f32>,
hdr_shares: vec4<f32>,
hdr_gains: vec4<f32>,
}
@group(0) @binding(0) var<uniform> globals: Globals;
@group(0) @binding(1) var atlas: texture_2d<f32>;
@@ -292,7 +292,7 @@ fn entity_written_color(in: Out, color: vec4<f32>) -> vec4<f32> {
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 && in.overlay != 11u && in.overlay != 12u {
var mark = hdr_entity_mark(in);
if in.overlay == 4u { mark = hdr_scene_alpha(globals.hdr_light.y); }
return vec4(hdr_written(color.rgb, mark, globals.hdr_settings, globals.hdr_shares), color.a);
return vec4(hdr_written(color.rgb, mark, globals.hdr_settings, globals.hdr_gains), color.a);
}
if FRAME_LINEAR && (in.overlay == 11u || in.overlay == 12u) {
// The cracks scale what is under them by twice their colour (CRUMBLING_BLEND). Vanilla's
@@ -451,7 +451,7 @@ fn celestial_color(in: Out) -> vec4<f32> {
@fragment fn fs_celestial(in: Out) -> @location(0) vec4<f32> {
let color = celestial_color(in);
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
return vec4(hdr_written(color.rgb, hdr_entity_mark(in), globals.hdr_settings, globals.hdr_shares), color.a);
return vec4(hdr_written(color.rgb, hdr_entity_mark(in), globals.hdr_settings, globals.hdr_gains), color.a);
}
return frame_stored(color);
}
@@ -472,7 +472,7 @@ fn celestial_color(in: Out) -> vec4<f32> {
// or it would come out darker than the sky around it.
let full = y >= globals.hdr_windows[1][clamp(tier, 1u, 4u) - 1u];
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
return vec4(hdr_written(added, select(1.0, hdr_mark(tier, y), full), globals.hdr_settings, globals.hdr_shares), color.a);
return vec4(hdr_written(added, select(1.0, hdr_mark(tier, y), full), globals.hdr_settings, globals.hdr_gains), color.a);
}
// Premultiplied after the decode: the light the texel adds, at its coverage.
if HDR_WRITE_LIGHT { return vec4(frame_stored(color).rgb * color.a, color.a); }
+6 -159
View File
@@ -192,6 +192,7 @@ fn packed_wanted() -> bool {
pub(crate) mod animate;
mod arena;
pub mod capture;
mod census;
pub mod compositor;
pub(crate) mod core_lines;
pub(crate) mod core_sky;
@@ -427,10 +428,10 @@ struct Globals {
/// full (`hdr::Hdr::windows`); zero without HDR.
hdr_windows: [[f32; 4]; 2],
hdr_light: [f32; 4],
/// Headroom, effective scene gain, world/look and debug, as HDR output's pass has them
/// The scene's peak, HDR lighting's gain, world/look and debug; then the tiers' gains
/// (`Hdr::draw_params`).
hdr_settings: [f32; 4],
hdr_shares: [f32; 4],
hdr_gains: [f32; 4],
}
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
@@ -2547,160 +2548,6 @@ enum FirstFrame {
fn on_off(on: bool) -> &'static str {
if on { "on" } else { "off" }
}
impl Renderer {
/// Sections with GPU buffers, for soak tests watching residency.
pub fn resident_sections(&self) -> usize {
self.sections.len()
}
/// Quads of the far terrain tiles with GPU buffers.
pub fn lod_quads(&self) -> u64 {
self.lods
.values()
.map(|tile| u64::from(tile.mesh.solid + tile.mesh.translucent))
.sum()
}
/// Tiles and quads of the far terrain tiles with GPU buffers, by level.
pub fn lod_levels(&self) -> Vec<[u64; 2]> {
let mut levels = Vec::new();
for (key, tile) in &self.lods {
let level = usize::from(key.level);
if levels.len() <= level {
levels.resize(level + 1, [0, 0]);
}
levels[level][0] += 1;
levels[level][1] += u64::from(tile.mesh.solid + tile.mesh.translucent);
}
levels
}
/// Whether solid quads go to the arena in the compact layout
/// (`packed_quads`), and whether far tiles' own records are on
/// (`lod::packed::far_packing`).
pub fn packed_layout(&self) -> [bool; 2] {
[self.packed_quads, crate::lod::packed::far_packing()]
}
/// Bytes of the arena the resident chunk sections hold, and of the one far
/// tiles hold: the same split `gpu_census` reports, for `status`, so the
/// compact layouts can be seen working.
pub fn vertex_bytes_split(&self) -> [u64; 2] {
let stride = std::mem::size_of::<TerrainVertex>() as u64;
let used = |s: &SectionGpu| (s.solid_slots + u64::from(s.translucent) * 4) * stride;
[
self.sections.values().map(used).sum(),
self.lods.values().map(|tile| used(&tile.mesh)).sum(),
]
}
/// Bytes of the section vertex arenas' buffers, and of the vertices
/// resident sections hold in them.
pub fn section_vertex_bytes(&self) -> [u64; 2] {
let stride = std::mem::size_of::<TerrainVertex>() as u64;
let vertices: u64 = self
.sections
.values()
.chain(self.lods.values().map(|tile| &*tile.mesh))
.map(|s| s.solid_slots + u64::from(s.translucent) * 4)
.sum();
let translucent = self.translucent_arena.as_ref();
let arenas = self.arena.bytes() + translucent.map_or(0, arena::Arena::bytes);
[arenas, vertices * stride]
}
/// Bytes of the renderer's largest GPU resources, by name (`scene_replay --memory`):
/// the terrain arenas (their pages, and what resident sections and far tiles use of
/// them), the quad indices, the block sprites with their mips, the LabPBR maps, flat
/// draws' buffers and the visibility buffer's.
pub fn gpu_census(&self) -> Vec<(&'static str, u64)> {
let stride = std::mem::size_of::<TerrainVertex>() as u64;
let used = |s: &SectionGpu| (s.solid_slots + u64::from(s.translucent) * 4) * stride;
let textures = |t: &[wgpu::Texture]| t.iter().map(texture_bytes).sum::<u64>();
let translucent = self.translucent_arena.as_ref();
vec![
("section_arena_pages", self.arena.bytes()),
(
"translucent_arena_pages",
translucent.map_or(0, arena::Arena::bytes),
),
("sorted_index_pages", self.sorted_indices.bytes()),
(
"near_section_vertices",
self.sections.values().map(used).sum(),
),
(
"held_section_vertices",
self.hold
.sections()
.filter_map(|held| held.gpu.as_ref())
.map(used)
.sum(),
),
(
"far_tile_vertices",
self.lods.values().map(|tile| used(&tile.mesh)).sum(),
),
("quad_indices", self.indices.size()),
("block_sprites", textures(&self.sprite_textures)),
(
"pbr_maps",
self.pbr_maps
.as_ref()
.map_or(0, |maps| maps.iter().flatten().map(texture_bytes).sum()),
),
(
"flat_draws",
self.flat.as_ref().map_or(0, flat::Flat::bytes),
),
(
"visibility_buffer",
self.visbuf.as_ref().map_or(0, visbuffer::Visbuffer::bytes),
),
]
}
/// Device memory the backend's allocator holds, by the label of what holds
/// it, largest first, with its allocated and reserved totals (Vulkan and
/// DirectX 12; none on Metal and OpenGL) (`horizon-ctl memory`).
pub fn gpu_memory_by_label(&self) -> Option<(u64, u64, Vec<(String, u64)>)> {
let report = self.device.generate_allocator_report()?;
let mut by_label: HashMap<String, u64> = HashMap::new();
for a in report.allocations {
*by_label.entry(a.name).or_default() += a.size;
}
let mut labels: Vec<_> = by_label.into_iter().collect();
labels.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
Some((
report.total_allocated_bytes,
report.total_reserved_bytes,
labels,
))
}
/// Heap bytes of the renderer's per-section bookkeeping (`horizon-ctl memory`):
/// resident sections, far tiles, block entities and light sources by section.
pub fn cpu_census(&self) -> [(&'static str, usize); 4] {
use std::mem::size_of;
let per_section = |map_len: usize, lists: usize| {
map_len * (size_of::<SectionKey>() + size_of::<Vec<([i32; 3], u32)>>() + 1)
+ lists * size_of::<([i32; 3], u32)>()
};
let lists = |map: &crate::fast_hash::FxHashMap<SectionKey, Vec<([i32; 3], u32)>>| {
per_section(
map.capacity(),
map.values().map(Vec::capacity).sum::<usize>(),
)
};
let animated: usize = self
.sections
.values()
.map(|s| s.animated_layers.len() * size_of::<u16>())
.sum();
[
("resident_sections", self.sections.heap_bytes() + animated),
(
"far_tiles",
self.lods.capacity()
* (size_of::<crate::lod::TileKey>() + size_of::<lod::Tile>() + 1),
),
("block_entities", lists(&self.block_entities)),
("light_sources", lists(&self.light_blocks)),
]
}
}
impl Renderer {
pub async fn new(
window: Arc<Window>,
@@ -8291,14 +8138,14 @@ impl Renderer {
self.shader_pack_kind().is_some(),
!frame.title,
);
let (hdr_settings, hdr_shares) = self
let (hdr_settings, hdr_gains) = self
.hdr
.as_ref()
.map_or(([0.0; 4], [0.0; 4]), |h| h.draw_params(!frame.title));
self.world_post.baked_light(
hdr_gain,
hdr_settings,
hdr_shares,
hdr_gains,
self.hdr.as_ref().map_or([[0.0; 4]; 2], hdr::Hdr::windows),
);
let jitter = self.world_post.jitter(
@@ -8458,7 +8305,7 @@ impl Renderer {
f32::from(u8::from(hdr_gain)),
],
hdr_settings,
hdr_shares,
hdr_gains,
};
self.uploads
.buffer(&mut encoder, &self.globals, 0, bytemuck::bytes_of(&globals));
@@ -0,0 +1,164 @@
//! What the renderer holds, for status, soak tests and memory reports (`horizon-ctl memory`,
//! `scene_replay --memory`): resident sections and far tiles, and the bytes of its largest
//! GPU resources and of its per-section bookkeeping.
use std::collections::HashMap;
use super::{
Renderer, SectionGpu, SectionKey, TerrainVertex, arena, flat, lod, texture_bytes, visbuffer,
};
impl Renderer {
/// Sections with GPU buffers, for soak tests watching residency.
pub fn resident_sections(&self) -> usize {
self.sections.len()
}
/// Quads of the far terrain tiles with GPU buffers.
pub fn lod_quads(&self) -> u64 {
self.lods
.values()
.map(|tile| u64::from(tile.mesh.solid + tile.mesh.translucent))
.sum()
}
/// Tiles and quads of the far terrain tiles with GPU buffers, by level.
pub fn lod_levels(&self) -> Vec<[u64; 2]> {
let mut levels = Vec::new();
for (key, tile) in &self.lods {
let level = usize::from(key.level);
if levels.len() <= level {
levels.resize(level + 1, [0, 0]);
}
levels[level][0] += 1;
levels[level][1] += u64::from(tile.mesh.solid + tile.mesh.translucent);
}
levels
}
/// Whether solid quads go to the arena in the compact layout
/// (`packed_quads`), and whether far tiles' own records are on
/// (`lod::packed::far_packing`).
pub fn packed_layout(&self) -> [bool; 2] {
[self.packed_quads, crate::lod::packed::far_packing()]
}
/// Bytes of the arena the resident chunk sections hold, and of the one far
/// tiles hold: the same split `gpu_census` reports, for `status`, so the
/// compact layouts can be seen working.
pub fn vertex_bytes_split(&self) -> [u64; 2] {
let stride = std::mem::size_of::<TerrainVertex>() as u64;
let used = |s: &SectionGpu| (s.solid_slots + u64::from(s.translucent) * 4) * stride;
[
self.sections.values().map(used).sum(),
self.lods.values().map(|tile| used(&tile.mesh)).sum(),
]
}
/// Bytes of the section vertex arenas' buffers, and of the vertices
/// resident sections hold in them.
pub fn section_vertex_bytes(&self) -> [u64; 2] {
let stride = std::mem::size_of::<TerrainVertex>() as u64;
let vertices: u64 = self
.sections
.values()
.chain(self.lods.values().map(|tile| &*tile.mesh))
.map(|s| s.solid_slots + u64::from(s.translucent) * 4)
.sum();
let translucent = self.translucent_arena.as_ref();
let arenas = self.arena.bytes() + translucent.map_or(0, arena::Arena::bytes);
[arenas, vertices * stride]
}
/// Bytes of the renderer's largest GPU resources, by name (`scene_replay --memory`):
/// the terrain arenas (their pages, and what resident sections and far tiles use of
/// them), the quad indices, the block sprites with their mips, the LabPBR maps, flat
/// draws' buffers and the visibility buffer's.
pub fn gpu_census(&self) -> Vec<(&'static str, u64)> {
let stride = std::mem::size_of::<TerrainVertex>() as u64;
let used = |s: &SectionGpu| (s.solid_slots + u64::from(s.translucent) * 4) * stride;
let textures = |t: &[wgpu::Texture]| t.iter().map(texture_bytes).sum::<u64>();
let translucent = self.translucent_arena.as_ref();
vec![
("section_arena_pages", self.arena.bytes()),
(
"translucent_arena_pages",
translucent.map_or(0, arena::Arena::bytes),
),
("sorted_index_pages", self.sorted_indices.bytes()),
(
"near_section_vertices",
self.sections.values().map(used).sum(),
),
(
"held_section_vertices",
self.hold
.sections()
.filter_map(|held| held.gpu.as_ref())
.map(used)
.sum(),
),
(
"far_tile_vertices",
self.lods.values().map(|tile| used(&tile.mesh)).sum(),
),
("quad_indices", self.indices.size()),
("block_sprites", textures(&self.sprite_textures)),
(
"pbr_maps",
self.pbr_maps
.as_ref()
.map_or(0, |maps| maps.iter().flatten().map(texture_bytes).sum()),
),
(
"flat_draws",
self.flat.as_ref().map_or(0, flat::Flat::bytes),
),
(
"visibility_buffer",
self.visbuf.as_ref().map_or(0, visbuffer::Visbuffer::bytes),
),
]
}
/// Device memory the backend's allocator holds, by the label of what holds
/// it, largest first, with its allocated and reserved totals (Vulkan and
/// DirectX 12; none on Metal and OpenGL) (`horizon-ctl memory`).
pub fn gpu_memory_by_label(&self) -> Option<(u64, u64, Vec<(String, u64)>)> {
let report = self.device.generate_allocator_report()?;
let mut by_label: HashMap<String, u64> = HashMap::new();
for a in report.allocations {
*by_label.entry(a.name).or_default() += a.size;
}
let mut labels: Vec<_> = by_label.into_iter().collect();
labels.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
Some((
report.total_allocated_bytes,
report.total_reserved_bytes,
labels,
))
}
/// Heap bytes of the renderer's per-section bookkeeping (`horizon-ctl memory`):
/// resident sections, far tiles, block entities and light sources by section.
pub fn cpu_census(&self) -> [(&'static str, usize); 4] {
use std::mem::size_of;
let per_section = |map_len: usize, lists: usize| {
map_len * (size_of::<SectionKey>() + size_of::<Vec<([i32; 3], u32)>>() + 1)
+ lists * size_of::<([i32; 3], u32)>()
};
let lists = |map: &crate::fast_hash::FxHashMap<SectionKey, Vec<([i32; 3], u32)>>| {
per_section(
map.capacity(),
map.values().map(Vec::capacity).sum::<usize>(),
)
};
let animated: usize = self
.sections
.values()
.map(|s| s.animated_layers.len() * size_of::<u16>())
.sum();
[
("resident_sections", self.sections.heap_bytes() + animated),
(
"far_tiles",
self.lods.capacity()
* (size_of::<crate::lod::TileKey>() + size_of::<lod::Tile>() + 1),
),
("block_entities", lists(&self.block_entities)),
("light_sources", lists(&self.light_blocks)),
]
}
}
@@ -33,7 +33,7 @@ struct Uniform {
mode: u32,
pad: [u32; 2],
hdr_settings: [f32; 4],
hdr_shares: [f32; 4],
hdr_gains: [f32; 4],
hdr_light: [f32; 4],
}
@@ -407,7 +407,7 @@ impl CustomSky {
self.written_gain = (globals.hdr_light[3] != 0.0).then(|| {
let mark =
((1.0 - 0.24 * globals.hdr_light[1]).clamp(0.0, 1.0) * 255.0).round() / 255.0;
if globals.hdr_settings[2] > 0.5 && globals.hdr_settings[0] > 1.0 {
if globals.hdr_settings[2] > 0.5 {
(1.0 + (globals.hdr_settings[1] - 1.0) * (1.0 - mark) / 0.24).powf(1.0 / 2.2)
} else {
1.0
@@ -428,7 +428,7 @@ impl CustomSky {
mode: mode as u32,
pad: [0; 2],
hdr_settings: globals.hdr_settings,
hdr_shares: globals.hdr_shares,
hdr_gains: globals.hdr_gains,
hdr_light: globals.hdr_light,
};
let offset = STRIDE * self.frame.len() as u64;
@@ -702,7 +702,7 @@ mod tests {
mode: mode_index as u32,
pad: [0; 2],
hdr_settings: [5.0, 4.0, 1.0, f32::from(u8::from(debug))],
hdr_shares: [0.2, 0.5, 0.6, 1.0],
hdr_gains: [1.6, 2.4, 2.8, 4.0],
hdr_light: [0.8, 0.8, 1.0, f32::from(u8::from(written))],
};
let buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
@@ -10,7 +10,7 @@ struct Layer {
mode: u32,
pad: vec2<u32>,
hdr_settings: vec4<f32>,
hdr_shares: vec4<f32>,
hdr_gains: vec4<f32>,
hdr_light: vec4<f32>,
}
@group(0) @binding(0) var<uniform> layer: Layer;
@@ -54,7 +54,7 @@ fn custom_sky_color(in: Out) -> vec4<f32> {
var color = t.rgb;
if HDR_WRITE_LIGHT && layer.hdr_light.w != 0.0 &&
(layer.mode == ADD || layer.mode == SUBTRACT || layer.mode == REPLACE || layer.mode == ALPHA) {
color = hdr_written(color, 1.0 - 0.24 * layer.hdr_light.y, layer.hdr_settings, layer.hdr_shares);
color = hdr_written(color, 1.0 - 0.24 * layer.hdr_light.y, layer.hdr_settings, layer.hdr_gains);
}
// On a frame of linear light (`FRAME_LINEAR`) the layer's colour is decoded as it is stored.
switch layer.mode {
@@ -84,5 +84,5 @@ fn custom_sky_color(in: Out) -> vec4<f32> {
@fragment fn fs_custom_sky_screen_light(in: Out) -> @location(0) vec4<f32> {
let color = custom_sky_color(in);
return vec4(hdr_written(color.rgb, 1.0 - 0.24 * layer.hdr_light.y, layer.hdr_settings, layer.hdr_shares), color.a);
return vec4(hdr_written(color.rgb, 1.0 - 0.24 * layer.hdr_light.y, layer.hdr_settings, layer.hdr_gains), color.a);
}
+270 -90
View File
@@ -5,6 +5,10 @@
//! or scRGB swapchain at the player's paper white, expanding the world's
//! highlights towards their peak brightness. The world is copied aside before
//! the hand and HUD so only it is expanded.
//!
//! On the float frame Horizon's world light is the same on every display (`TIERS`,
//! `SUNLIT_GAIN`, up to `SCENE_PEAK`), and the tone curve alone fits it to the display's
//! peak (docs/design/hdr-linear-light.md, step 6a).
/// The swapchain HDR output presents to.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
@@ -99,38 +103,53 @@ struct Layered {
params: wgpu::Buffer,
}
/// How one tier of light sources (`mesher::hdr_tier`) shines: `share` of the headroom for
/// its brightest pixels, lifted from brightness `start` (none) to `full` (all of it), the
/// texel's own brightness before lighting.
/// How one tier of light sources (`mesher::hdr_tier`) shines: its brightest pixels are
/// multiplied by `gain`, in multiples of paper white, lifted from brightness `start` (none) to
/// `full` (all of it), the texel's own brightness before lighting.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Tier {
pub share: f32,
pub gain: f32,
pub start: f32,
pub full: f32,
}
/// Low, incandescent, bright and very bright.
/// Low, incandescent, bright and very bright: faint glowers such as magma, flames and lava,
/// glowstone and campfires, the sun.
pub const TIERS: [Tier; 4] = [
Tier {
share: 0.2,
gain: 1.6,
start: 0.08,
full: 0.09,
},
Tier {
share: 0.5,
gain: 2.4,
start: 0.14,
full: 0.38,
},
Tier {
share: 0.6,
gain: 2.8,
start: 0.14,
full: 0.38,
},
Tier {
share: 1.0,
gain: 4.0,
start: 0.14,
full: 0.38,
},
];
/// The scene's peak, in multiples of paper white: just above the brightest light the world
/// writes (the sun's disc, white at the very bright tier's 4), and what the tone curve maps
/// onto the display's peak; light past it (where the sky adds onto the sun) is held at it. The
/// world is mastered for a 1000-nit display at ITU-R BT.2408's 203-nit reference white, as
/// HDR10 content usually is: there it shows as written, a dimmer display eases its highlights
/// into its peak (BT.2390's EETF), and a brighter one shows it as at 1000.
pub const SCENE_PEAK: f32 = 1000.0 / 203.0;
/// HDR lighting: what the strongest light multiplies a surface by (`hdr_light.wgsl`).
pub const SUNLIT_GAIN: f32 = 3.0;
/// The compatibility frame's tiers (the 16-bit or 8-bit frame, until step 5 removes it): each
/// one's share of the display's headroom, as before one peak, so the look HDR has by default
/// changes once, when the float frame replaces it.
const COMPAT_SHARES: [f32; 4] = [0.2, 0.5, 0.6, 1.0];
/// Which pixels of a light source's sprite may shine (`hdr_stats.wgsl`), as a fraction of
/// the way from its darkest to its brightest opaque pixel: `parts` for one whose bright parts
/// shine (a torch's flame, not its handle), `whole` for one that shines all over
@@ -331,7 +350,7 @@ impl Hdr {
let params = || {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Horizon HDR parameters"),
size: 64,
size: 80,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
})
@@ -944,16 +963,19 @@ impl Hdr {
})
}
/// What the world's writers read (`hdr_written`): the scene's peak, HDR lighting's gain,
/// world/look and debug, then the tiers' gains. No part of the display reaches them.
pub(super) fn draw_params(&self, world: bool) -> ([f32; 4], [f32; 4]) {
let p = self.params(world, false);
([p[1], p[7], p[2], p[3]], p[8..12].try_into().unwrap())
let sunlit = if self.lighting { SUNLIT_GAIN } else { 1.0 };
([SCENE_PEAK, sunlit, p[2], p[3]], self.tiers.map(|t| t.gain))
}
/// hdr.wgsl's `Params`.
fn params(&self, world: bool, pq: bool) -> [f32; 16] {
fn params(&self, world: bool, pq: bool) -> [f32; 20] {
let paper = self.paper_white.max(1.0);
let c = self.calibration;
let mut params = [0.0; 16];
let mut params = [0.0; 20];
params[..5].copy_from_slice(&[
paper / 80.0,
(self.peak / paper).max(1.0),
@@ -968,12 +990,17 @@ impl Hdr {
} else {
1.0
};
for (i, t) in self.tiers.iter().enumerate() {
params[8 + i] = t.share;
}
params[8..12].copy_from_slice(&COMPAT_SHARES);
params[12] = c.map_or(0.0, |c| c.step as f32);
params[13] = c.map_or(0.0, |c| c.nits);
params[14] = c.map_or(0.0, |c| c.band);
// Only Horizon's lifted world is known to end at the scene's peak; elsewhere (HDR
// Lighting off, a pack's own light, the title) the curve's source follows the display.
params[16] = if world && self.lighting {
SCENE_PEAK
} else {
0.0
};
params
}
@@ -1050,8 +1077,9 @@ fn logo_mask(device: &wgpu::Device, queue: &wgpu::Queue) -> wgpu::TextureView {
.create_view(&Default::default())
}
/// What the strongest light multiplies a surface by, in HDR lighting: 4, which looked right
/// across biomes; less only where there is little headroom to ease into (`hdr.wgsl`).
/// The compatibility frame's HDR lighting (`fs_hdr`): what the strongest light multiplies a
/// surface by, 4, which looked right across biomes; less only where there is little headroom
/// to ease into. The float frame's is `SUNLIT_GAIN`.
fn scene_gain(peak: f32, paper_white: f32) -> f32 {
(peak / paper_white.max(1.0) * 1.75).clamp(1.0, 4.0)
}
@@ -1069,7 +1097,8 @@ mod tests {
return;
};
// All 8-bit marks, both easing policies, non-grey and over-white colours, look,
// debug, headroom and tier shares; half-float neighbours straddle mark boundaries.
// debug, headroom and tier shares; half-float neighbours straddle mark boundaries. The
// writers take the gain that a share gives.
let source = include_str!("../hdr_light.wgsl").to_owned()
+ r#"
struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: vec4<f32> }
@@ -1088,19 +1117,20 @@ var<private> params: ReferenceParams;
select(0.0, 1.0, (mode & 32u) != 0u), vec4(0.2, 0.5, 0.6, 1.0));
let color = select(vec3(0.9, 0.4, 0.02), vec3(1.4, 0.8, 0.2), (mode & 64u) != 0u);
let expected = reference_lit(color, mark, shows, eased);
let gains = 1.0 + (params.peak - 1.0) * params.share;
let actual = hdr_mark_light(color, mark, shows, eased,
vec4(params.peak, params.scene, params.world, params.debug), params.share);
vec4(params.peak, params.scene, params.world, params.debug), (params.peak - 1.0) * params.share);
errors[i] = select(0u, 1u, any(abs(expected.light - actual.light) > vec3(1e-6)) || expected.lifted != actual.lifted);
// Gain stays above the tone knee until the separate tone curve runs.
let gain = 1.0 + 3.0 * (1.0 - 194.0 / 255.0) / 0.24;
let white = hdr_written(vec3(1.0), 0.76, vec4(5.0, 4.0, 1.0, 0.0), params.share);
let white = hdr_written(vec3(1.0), 0.76, vec4(5.0, 4.0, 1.0, 0.0), gains);
errors[i] |= select(0u, 4u, any(abs(pow(white, vec3(2.2)) - vec3(gain)) > vec3(1e-5)));
let boundary = (f32(i % 255u) + 0.5) / 255.0;
for (var step = -1; step <= 1; step++) {
let stored = unpack2x16float(pack2x16float(vec2(boundary + f32(step) / 65536.0))).x;
let quantized = round(saturate(stored) * 255.0) / 255.0;
let want = pow(reference_lit(color, quantized, params.world > 0.5 && params.peak > 1.0, false).light, vec3(1.0 / 2.2));
let got = hdr_written(color, stored, vec4(params.peak, params.scene, params.world, params.debug), params.share);
let want = pow(reference_lit(color, quantized, params.world > 0.5, false).light, vec3(1.0 / 2.2));
let got = hdr_written(color, stored, vec4(params.peak, params.scene, params.world, params.debug), gains);
errors[i] |= select(0u, 2u, any(abs(want - got) > vec3(1e-6)));
}
}
@@ -1319,10 +1349,12 @@ var<private> params: ReferenceParams;
// Independent pre-refactor light arithmetic applied to a compatibility attachment.
// Float targets in these tests only receive the resulting colour and coverage.
// `lift`: the reference's peak, HDR lighting gain, world and debug, then the tiers' shares
// of the headroom up to that peak (`writers_lift`, `display_lift`).
pub(super) fn legacy_light(
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
hdr: &Hdr,
lift: [[f32; 4]; 2],
source: &wgpu::Texture,
target: &wgpu::Texture,
) {
@@ -1345,7 +1377,7 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
@fragment fn fs() -> @location(0) vec4<f32> {
let c = textureLoad(source, vec2(0), 0);
let mark = round(saturate(c.a) * 255.0) / 255.0;
let light = reference_lit(c.rgb, mark, params.world > 0.5 && params.peak > 1.0, false).light;
let light = reference_lit(c.rgb, mark, params.world > 0.5, false).light;
return vec4(pow(light, vec3(1.0 / 2.2)), 1.0);
}
"#)
@@ -1373,10 +1405,9 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
multiview_mask: None,
cache: None,
});
let (settings, shares) = hdr.draw_params(true);
let params = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
contents: bytemuck::cast_slice(&[settings, shares]),
contents: bytemuck::cast_slice(&lift),
usage: wgpu::BufferUsages::UNIFORM,
});
let view = source.create_view(&Default::default());
@@ -1404,6 +1435,19 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
);
}
/// The float frame's writers' light as `legacy_light` takes it: each tier's gain as a share
/// of the headroom up to the scene's peak.
pub(super) fn writers_lift(hdr: &Hdr) -> [[f32; 4]; 2] {
let (settings, gains) = hdr.draw_params(true);
[settings, gains.map(|g| (g - 1.0) / (settings[0] - 1.0))]
}
/// The compatibility frame's display pass's light (`fs_hdr`) as `legacy_light` takes it.
fn display_lift(hdr: &Hdr) -> [[f32; 4]; 2] {
let p = hdr.params(true, false);
[[p[1], p[7], p[2], p[3]], p[8..12].try_into().unwrap()]
}
#[test]
fn direct_light_matches_the_legacy_non_float_display_below_the_tone_knee() {
let Some((device, queue)) = gpu() else {
@@ -1416,8 +1460,9 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
eprintln!("legacy display GPU skipped: 16-bit UNORM unavailable");
return;
}
// Compare the compatibility display lift with direct colour under the layered HUD.
// The frozen oracle supplies the writer's light from a non-float attachment.
// Compare the compatibility display lift with direct colour under the layered HUD:
// the frozen oracle turns the marks into the light that display gives them, and the
// layered display, whose curve is the identity at 1000 nits, must show it the same.
let shown = |layered: bool, world: [f64; 4]| {
let mut hdr = Hdr::with_debug(
&device,
@@ -1441,7 +1486,14 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
if world[3] == 1.0 {
fill(&mut encoder, &frame, world);
} else {
legacy_light(&device, &mut encoder, &hdr, &marked, &frame);
// The light the compatibility frame shows: its own lift, not the writers'.
legacy_light(
&device,
&mut encoder,
display_lift(&legacy),
&marked,
&frame,
);
}
hdr.begin(&queue, true);
hdr.tonemap(&mut encoder, None);
@@ -1480,78 +1532,206 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
assert!(above[0] > white[0] && above[1] > white[1]);
}
/// The world's writers read nothing of the display: the same gains and the same scene peak
/// at any paper white and peak (docs/design/hdr-linear-light.md, step 6a).
#[test]
fn the_tone_curve_eases_light_into_the_peak() {
fn the_writers_light_is_the_same_on_every_display() {
let Some((device, queue)) = gpu() else {
return;
};
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
(hdr.paper_white, hdr.peak) = (203.0, 1000.0);
let headroom = 1000.0 / 203.0;
// The frame holding `color` (in gamma, as it stores light) after `passes` ran on it.
let after =
|hdr: &mut Hdr, color: [f64; 4], passes: &dyn Fn(&Hdr, &mut wgpu::CommandEncoder)| {
let frame = hdr.frame(&device, [1, 1]);
let mut encoder = device.create_command_encoder(&Default::default());
fill(&mut encoder, &frame, color);
hdr.begin(&queue, true);
passes(hdr, &mut encoder);
texel(&device, &queue, encoder, &frame)
};
let tonemap = |hdr: &Hdr, encoder: &mut wgpu::CommandEncoder| hdr.tonemap(encoder, None);
let written = hdr.draw_params(true);
assert_eq!(
written,
(
[super::SCENE_PEAK, super::SUNLIT_GAIN, 1.0, 0.0],
[1.6, 2.4, 2.8, 4.0]
)
);
for (paper, peak) in [
(203.0, 300.0),
(203.0, 600.0),
(203.0, 1500.0),
(400.0, 600.0),
(480.0, 200.0),
(80.0, 10000.0),
] {
(hdr.paper_white, hdr.peak) = (paper, peak);
assert_eq!(hdr.draw_params(true), written, "{paper}/{peak}");
assert_eq!(
hdr.params(true, false)[16],
super::SCENE_PEAK,
"{paper}/{peak}"
);
}
// The title, and frames Horizon doesn't light (HDR Lighting off, a pack drawing):
// nothing says where their light ends, so the curve's source follows the display.
assert_eq!(hdr.params(false, false)[16], 0.0);
hdr.lighting = false;
assert_eq!(hdr.draw_params(true).0[1], 1.0);
assert_eq!(hdr.params(true, false)[16], 0.0);
}
/// The tone curve maps the scene's peak S onto the display's peak T with ITU-R BT.2390's
/// EETF, keeping hue, after holding light above S at S. Checkpoints are worked out apart
/// from the shader, in f64 from SMPTE ST 2084 and BT.2390.
#[test]
fn the_tone_curve_maps_the_scene_peak_onto_the_display() {
let Some((device, queue)) = gpu() else {
return;
};
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
let scene_peak = super::SCENE_PEAK;
// The frame holding `color` (in gamma, as it stores light), after the curve if `curve`.
let through = |hdr: &mut Hdr, color: [f64; 4], curve: bool| {
let frame = hdr.frame(&device, [1, 1]);
let mut encoder = device.create_command_encoder(&Default::default());
fill(&mut encoder, &frame, color);
hdr.begin(&queue, true);
if curve {
hdr.tonemap(&mut encoder, None);
}
texel(&device, &queue, encoder, &frame)
};
// `exact`, or within one half-float step: S's own gamma rounds up in half float, to
// light just above S that the curve holds at S, and whether that comes back to the same
// half float depends on the GPU's rounding (one step lower on an Apple M4 Pro).
let unchanged = |hdr: &mut Hdr, color: [f64; 4], exact: bool| {
let (got, stored) = (through(hdr, color, true), through(hdr, color, false));
(0..4).all(|c| {
got[c] == stored[c] || !exact && (got[c] - stored[c]).abs() <= stored[c] / 1024.0
})
};
// Linear light in and out, in units of paper white.
let eased = |hdr: &mut Hdr, light: [f32; 3]| {
let mapped = |hdr: &mut Hdr, light: [f32; 3]| {
let [r, g, b] = light.map(|c| f64::from(c.powf(1.0 / 2.2)));
let [r, g, b, a] = after(hdr, [r, g, b, 1.0], &tonemap);
let [r, g, b, a] = through(hdr, [r, g, b, 1.0], true);
assert_eq!(a, 1.0);
[r, g, b].map(|c| c.powf(2.2))
};
let near = |got: f32, want: f32| (got - want).abs() <= 0.005 * want.max(1.0);
// Up to the knee (60% of the peak) the light stays exactly as stored.
for light in [0.0f32, 0.5, 1.0, 2.0, 2.9] {
let g = f64::from(light.powf(1.0 / 2.2));
let stored = after(&mut hdr, [g, g * 0.5, 0.0, 1.0], &|_, _| {});
assert_eq!(
after(&mut hdr, [g, g * 0.5, 0.0, 1.0], &tonemap),
stored,
"{light}"
);
}
// Past it, BT.2390's EETF in PQ at 203 and 1000 nits, with the source's peak at 2771
// nits (13.7 times paper white), worked out apart from the shader.
for (light, want) in [
(3.5, 3.43791),
(4.0, 3.78351),
(headroom, 4.23556),
(8.0, 4.82043),
(12.0, 4.92462),
(50.0, headroom),
// A display at or above the mastering one (T >= S): every stored value up to S comes
// out exactly as it went in, grey or coloured, and light above S is held at S with its
// hue, so 1500 and 10 000 nits show what 1000 does; so do the settings' extremes (Paper
// White 10 to 480 nits, Peak up to 10 000) wherever T reaches S.
for (paper, peak) in [
(203.0, 1000.0),
(203.0, 1500.0),
(203.0, 10000.0),
(480.0, 10000.0),
(10.0, 200.0),
] {
let [r, ..] = eased(&mut hdr, [light; 3]);
assert!(near(r, want), "{light}: {r}, not {want}");
(hdr.paper_white, hdr.peak) = (paper, peak);
for i in 0..=64 {
let g = f64::from((scene_peak * i as f32 / 64.0).powf(1.0 / 2.2));
let exact = i < 64;
assert!(unchanged(&mut hdr, [g, g, g, 1.0], exact), "{peak}: {i}");
assert!(
unchanged(&mut hdr, [g, g * 0.6, g * 0.1, 1.0], exact),
"{peak}: {i}"
);
}
for light in [8.0f32, 50.0] {
let [r, g, b] = mapped(&mut hdr, [light, light / 4.0, 0.0]);
assert!(
near(r, scene_peak) && near(g * 4.0, r) && b == 0.0,
"{peak}: {light}: {r} {g} {b}"
);
}
}
// It rises, stays under the peak, and the brightest channel scales the others: the
// light keeps its hue.
let mut last = 0.0;
for i in 0..40 {
let light = 2.5 * 1.08f32.powi(i);
let [r, g, b] = eased(&mut hdr, [light, light / 4.0, 0.0]);
assert!(r >= last - 0.001 && r <= headroom * 1.002, "{light}: {r}");
assert!(near(g * 4.0, r) && b == 0.0, "{light}: {r} {g} {b}");
last = r;
// Dimmer displays: unchanged up to the knee, then the EETF into T, T at S and past it.
// At 300 nits the knee falls just below white (161 nits): white shows at 198 nits
// where the gates' curve, whose source followed the display, kept it at 203; the
// highlights above it keep more of their steps.
for (paper, peak, knee, checkpoints) in [
(
203.0,
600.0,
2.0,
&[
(2.5, 2.47516),
(3.0, 2.75699),
(4.0, 2.9404),
(scene_peak, 600.0 / 203.0),
(8.0, 600.0 / 203.0),
][..],
),
(
203.0,
300.0,
0.75,
&[
(0.9, 0.89289),
(1.0, 0.97365),
(1.2, 1.10406),
(1.4, 1.20134),
(2.0, 1.36833),
(4.0, 1.47641),
(8.0, 300.0 / 203.0),
],
),
(
400.0,
600.0,
0.8,
&[
(1.0, 0.97973),
(1.2, 1.11501),
(1.5, 1.25607),
(2.0, 1.38818),
(scene_peak, 1.5),
],
),
// No headroom (a peak under paper white): the scene is compressed into white.
(
203.0,
150.0,
0.4,
&[
(1.0, 0.78346),
(1.5, 0.90244),
(2.0, 0.95589),
(4.0, 0.99944),
(scene_peak, 1.0),
],
),
] {
(hdr.paper_white, hdr.peak) = (paper, peak);
for light in [0.0f32, 0.3, knee] {
let g = f64::from(light.powf(1.0 / 2.2));
assert!(
unchanged(&mut hdr, [g, g * 0.5, 0.0, 1.0], true),
"{paper}/{peak}: {light}"
);
}
for &(light, want) in checkpoints {
let [r, ..] = mapped(&mut hdr, [light; 3]);
assert!(near(r, want), "{paper}/{peak}: {light}: {r}, not {want}");
}
// It rises, stays under the peak, and the brightest channel scales the others; a
// half float's step in gamma is 0.1% of the light near white.
let headroom = (peak / paper).max(1.0);
let mut last = 0.0;
for i in 0..40 {
let light = 0.5 * 1.08f32.powi(i);
let [r, g, b] = mapped(&mut hdr, [light, light / 4.0, 0.0]);
assert!(
r >= last * 0.998 && r <= headroom * 1.002,
"{peak}: {light}: {r}"
);
assert!(near(g * 4.0, r) && b == 0.0, "{peak}: {light}: {r} {g} {b}");
last = r;
}
}
// A display with little headroom, 300 nits at a paper white of 203: the knee sits at
// white, so all that SDR shows stays as it is, and the source's peak is 645 nits.
hdr.peak = 300.0;
// Light Horizon doesn't lift (HDR Lighting off, a 0.2 pack's own) has no known peak:
// the curve's source follows the display, the knee at 60% of its peak and at least
// white, as before one peak. At 300 nits the source's peak is 645 nits.
hdr.lighting = false;
(hdr.paper_white, hdr.peak) = (203.0, 300.0);
let headroom = 300.0 / 203.0;
for light in [0.5f32, 0.9, 1.0] {
for light in [0.0f32, 0.5, 0.9, 1.0] {
let g = f64::from(light.powf(1.0 / 2.2));
let stored = after(&mut hdr, [g, g * 0.5, 0.0, 1.0], &|_, _| {});
assert_eq!(
after(&mut hdr, [g, g * 0.5, 0.0, 1.0], &tonemap),
stored,
"{light}"
);
assert!(unchanged(&mut hdr, [g, g * 0.5, 0.0, 1.0], true), "{light}");
}
for (light, want) in [
(1.2, 1.16839),
@@ -1560,12 +1740,12 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
(2.0, 1.44024),
(4.0, headroom),
] {
let [r, ..] = eased(&mut hdr, [light; 3]);
let [r, ..] = mapped(&mut hdr, [light; 3]);
assert!(near(r, want), "{light}: {r}, not {want}");
}
// No headroom (a peak below paper white): a clip at white, channel by channel.
// With no headroom there, a clip at white, channel by channel.
hdr.peak = 150.0;
let [r, g, b] = eased(&mut hdr, [1.5, 0.5, 0.2]);
let [r, g, b] = mapped(&mut hdr, [1.5, 0.5, 0.2]);
assert!(r == 1.0 && near(g, 0.5) && near(b, 0.2), "{r} {g} {b}");
}
@@ -1582,7 +1762,7 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
(hdr.paper_white, hdr.peak, hdr.black) = (250.0, 800.0, 0.0);
(hdr.ui, hdr.look, hdr.lighting) = (0.5, false, false);
hdr.masks.parts = 0.4;
hdr.tiers[1].share = 0.7;
hdr.tiers[1].gain = 3.0;
hdr.debug = true;
hdr.calibration = Some(super::Calibration {
step: 2,
@@ -1,6 +1,6 @@
use super::{
Hdr, Output,
tests::{gpu, legacy_light, texel},
tests::{gpu, legacy_light, texel, writers_lift},
};
use wgpu::TextureFormat as F;
use wgpu::util::DeviceExt;
@@ -648,7 +648,7 @@ impl<'a> Draw<'a> {
}
if !written && !self.raw {
let light = hdr.frame(self.device, [1, 1]);
legacy_light(self.device, &mut encoder, hdr, &frame, &light);
legacy_light(self.device, &mut encoder, writers_lift(hdr), &frame, &light);
return texel(self.device, self.queue, encoder, &light);
}
texel(self.device, self.queue, encoder, &frame)
@@ -662,7 +662,7 @@ fn float_plain_fragments_keep_rgb_and_write_only_coverage() {
return;
};
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
let (settings, shares) = hdr.draw_params(true);
let (settings, gains) = hdr.draw_params(true);
let mut cases = 0;
for hdr_on in [false, true] {
let globals = super::super::Globals {
@@ -677,7 +677,7 @@ fn float_plain_fragments_keep_rgb_and_write_only_coverage() {
hdr_windows: hdr.windows(),
hdr_light: [0.8, 0.8, f32::from(u8::from(hdr_on)), 0.0],
hdr_settings: settings,
hdr_shares: shares,
hdr_gains: gains,
..bytemuck::Zeroable::zeroed()
};
for terrain in [true, false] {
@@ -739,7 +739,7 @@ fn float_stars_keep_the_original_rgb_with_brightness_as_coverage() {
return;
};
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
let (settings, shares) = hdr.draw_params(true);
let (settings, gains) = hdr.draw_params(true);
// Frozen f030d9c80 fragment: the original written branch and its marked fallback.
let source = include_str!("../hdr_light.wgsl").to_owned()
+ include_str!("stars.wgsl")
@@ -750,7 +750,7 @@ fn float_stars_keep_the_original_rgb_with_brightness_as_coverage() {
@fragment fn reference() -> @location(0) vec4<f32> {
let b = stars.color.a;
if HDR_WRITE_LIGHT && stars.hdr_light.w != 0.0 {
return vec4(hdr_written(stars.color.rgb * b, 0.175, stars.hdr_settings, stars.hdr_shares), b);
return vec4(hdr_written(stars.color.rgb * b, 0.175, stars.hdr_settings, stars.hdr_gains), b);
}
return vec4(stars.color.rgb * b, 0.175);
}
@@ -763,7 +763,7 @@ fn float_stars_keep_the_original_rgb_with_brightness_as_coverage() {
let mut data = [0.0_f32; 32];
data[16..20].copy_from_slice(&[0.7, 0.8, 0.9, 0.35]);
data[20..24].copy_from_slice(&settings);
data[24..28].copy_from_slice(&shares);
data[24..28].copy_from_slice(&gains);
data[31] = gain;
let uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
@@ -908,7 +908,7 @@ fn actual_opaque_writers_match_the_legacy_non_float_oracle() {
}
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
(hdr.paper_white, hdr.peak) = (203.0, 1000.0);
let (settings, shares) = hdr.draw_params(true);
let (settings, gains) = hdr.draw_params(true);
let mut count = 0;
for fog in [0.0, 0.5, 1.0] {
for visibility in [1.0, 0.4] {
@@ -921,7 +921,7 @@ fn actual_opaque_writers_match_the_legacy_non_float_oracle() {
hdr_windows: hdr.windows(),
hdr_light: [0.8, 0.8, 1.0, 1.0],
hdr_settings: settings,
hdr_shares: shares,
hdr_gains: gains,
..bytemuck::Zeroable::zeroed()
};
for terrain in [true, false] {
@@ -968,7 +968,7 @@ fn actual_translucent_writers_blend_their_own_light() {
return;
};
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
let (settings, shares) = hdr.draw_params(true);
let (settings, gains) = hdr.draw_params(true);
let globals = super::super::Globals {
fog: [0.0, 100.0, 0.0, 100.0],
fog_ends: [100.0, 100.0, 0.0, 0.0],
@@ -977,7 +977,7 @@ fn actual_translucent_writers_blend_their_own_light() {
hdr_windows: hdr.windows(),
hdr_light: [0.8, 0.8, 1.0, 1.0],
hdr_settings: settings,
hdr_shares: shares,
hdr_gains: gains,
..bytemuck::Zeroable::zeroed()
};
// Texel alpha 100 is clear glass's kind of texel, below the 0.6 the marked frame's
@@ -1035,14 +1035,14 @@ fn unmarked_entity_users_keep_their_source_gain() {
return;
}
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
let (settings, shares) = hdr.draw_params(true);
let (settings, gains) = hdr.draw_params(true);
let globals = super::super::Globals {
fog: [0.0, 100.0, 0.0, 100.0],
fog_ends: [100.0, 100.0, 0.0, 0.0],
hdr_windows: hdr.windows(),
hdr_light: [1.0, 1.0, 1.0, 1.0],
hdr_settings: settings,
hdr_shares: shares,
hdr_gains: gains,
..bytemuck::Zeroable::zeroed()
};
let draw = Draw::new(&device, &queue, false, &globals, 255);
@@ -1079,13 +1079,13 @@ fn additive_texels_below_the_mark_cutoff_keep_their_gain() {
return;
}
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
let (settings, shares) = hdr.draw_params(true);
let (settings, gains) = hdr.draw_params(true);
let globals = super::super::Globals {
fog: [0.0, 100.0, 0.0, 100.0],
hdr_windows: hdr.windows(),
hdr_light: [1.0, 1.0, 1.0, 1.0],
hdr_settings: settings,
hdr_shares: shares,
hdr_gains: gains,
..bytemuck::Zeroable::zeroed()
};
let draw = Draw::new(&device, &queue, false, &globals, 16);
@@ -1129,7 +1129,7 @@ fn check_oit_writer(terrain: bool, kind: &str, overlay: u32, tier: u32, alphas:
}))
.unwrap();
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
let (settings, shares) = hdr.draw_params(true);
let (settings, gains) = hdr.draw_params(true);
let globals = super::super::Globals {
fog: [0.0, 100.0, 0.0, 100.0],
fog_ends: [100.0, 100.0, 0.0, 0.0],
@@ -1138,7 +1138,7 @@ fn check_oit_writer(terrain: bool, kind: &str, overlay: u32, tier: u32, alphas:
hdr_windows: hdr.windows(),
hdr_light: [0.8, 0.8, 1.0, 1.0],
hdr_settings: settings,
hdr_shares: shares,
hdr_gains: gains,
..bytemuck::Zeroable::zeroed()
};
for &alpha in alphas {
@@ -2770,6 +2770,8 @@ file = \"b.wgsl\"
true,
);
(display.paper_white, display.peak) = (paper, peak);
// As the renderer has it under a pack that draws: Horizon lights none of it.
display.lighting = false;
if switched {
use super::super::hdr::Output::{Pq, ScRgb};
display = display.retargeted(&device, &queue, ScRgb);
@@ -47,7 +47,7 @@ struct Uniform {
clip_from_sky: [[f32; 4]; 4],
color: [f32; 4],
hdr_settings: [f32; 4],
hdr_shares: [f32; 4],
hdr_gains: [f32; 4],
hdr_light: [f32; 4],
}
@@ -184,7 +184,7 @@ impl Stars {
clip_from_sky: clip_from_sky.to_cols_array_2d(),
color: [b; 4],
hdr_settings: globals.hdr_settings,
hdr_shares: globals.hdr_shares,
hdr_gains: globals.hdr_gains,
hdr_light: globals.hdr_light,
};
uploads.buffer(encoder, &self.uniform, 0, bytemuck::bytes_of(&uniform));
@@ -5,7 +5,7 @@ struct Stars {
// starBrightness in every channel (RenderSystem.setShaderColor).
color: vec4<f32>,
hdr_settings: vec4<f32>,
hdr_shares: vec4<f32>,
hdr_gains: vec4<f32>,
hdr_light: vec4<f32>,
}
@group(0) @binding(0) var<uniform> stars: Stars;
@@ -20,7 +20,7 @@ struct Stars {
@fragment fn fs_stars() -> @location(0) vec4<f32> {
let b = stars.color.a;
if HDR_WRITE_LIGHT && stars.hdr_light.w != 0.0 {
return vec4(hdr_written(stars.color.rgb * b, 0.175, stars.hdr_settings, stars.hdr_shares), b);
return vec4(hdr_written(stars.color.rgb * b, 0.175, stars.hdr_settings, stars.hdr_gains), b);
}
if HDR_WRITE_LIGHT { return vec4(frame_stored(stars.color).rgb * b, b); }
return vec4(stars.color.rgb * b, 0.175);
@@ -360,13 +360,13 @@ impl Post {
&mut self,
gain: bool,
settings: [f32; 4],
shares: [f32; 4],
gains: [f32; 4],
windows: [[f32; 4]; 2],
) {
// These uniforms do not change baked colour without gain. Keep compatibility
// history stable when only its final display settings change.
let key = Some(if gain {
(true, settings, shares, windows)
(true, settings, gains, windows)
} else {
(false, [0.0; 4], [0.0; 4], [[0.0; 4]; 2])
});
@@ -887,7 +887,7 @@ mod tests {
post.jitter([32, 32], true, Mat4::IDENTITY, DVec3::ZERO);
assert_eq!(post.taa_this_frame.jitter[2], 0.0);
eprintln!(
"TAA baked-light GPU checked gain, all settings/shares/windows and the first frame after a pack"
"TAA baked-light GPU checked gain, all settings/gains/windows and the first frame after a pack"
);
}
@@ -64,7 +64,7 @@ fn warp_fragment(in: TerrainOut, front: bool) -> Fragment {
let fog = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w)) * globals.fog_color.a;
let mark = hdr_scene_alpha(mix(hdr_scene(in.scene.x, in.scene.y, hdr_facing()), globals.hdr_light.y, fog));
return vec4(hdr_written(finished.rgb, mark, globals.hdr_settings, globals.hdr_shares), finished.a);
return vec4(hdr_written(finished.rgb, mark, globals.hdr_settings, globals.hdr_gains), finished.a);
}
return frame_stored(finished);
}
+50 -26
View File
@@ -20,15 +20,20 @@ struct Params {
black: f32,
// The HUD and menus' brightness as a share of paper white.
ui: f32,
// HDR lighting: what the strongest light multiplies a surface by, 1 for none.
// The compatibility frame's lift (`lit`; the float frame's writers have theirs from
// Hdr::draw_params). HDR lighting: what the strongest light multiplies a surface by, 1 for
// none.
scene: f32,
// Per tier, low to very bright (hdr.rs `Tier`): the share of the headroom
// Per tier, low to very bright (hdr.rs `COMPAT_SHARES`): the share of the headroom
// its fully lifted pixels get.
share: vec4<f32>,
// HDR calibration (hdr_calibration.rs): x its step (0 none, 1 black level,
// 2 peak, 3 paper white), y the slider in nits, z where its controls start,
// a share of the height.
calibration: vec4<f32>,
// The scene's peak in units of paper white where Horizon lifts the world (hdr.rs
// `SCENE_PEAK`), else 0: what the tone curve maps onto the display's peak (`tone_scale`).
scene_peak: f32,
};
@group(0) @binding(2) var<uniform> params: Params;
// Horizon's logo, 1 inside, 0 outside, for calibration's test pictures.
@@ -155,7 +160,7 @@ fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
}
fn lit(f: vec3<f32>, a: f32, shows: bool, eased: bool) -> Lit {
return hdr_mark_light(f, a, shows, eased, vec4(params.peak, params.scene, params.world, params.debug), params.share);
return hdr_mark_light(f, a, shows, eased, vec4(params.peak, params.scene, params.world, params.debug), (params.peak - 1.0) * params.share);
}
// Calibration: above its controls the test picture, straight in nits, past
@@ -251,33 +256,52 @@ fn display(light: vec3<f32>, pos: vec4<f32>) -> vec4<f32> {
return display(light, pos);
}
// The tone curve's knee: up to it light stays as it is. Where HDR lighting eased into the peak
// before the curve (`lit`), 60% of it, so bright textures keep their detail; at least paper white.
// Where the scene's peak is unknown (`tone_scale`), the curve's knee: up to it light stays as it
// is. Where HDR lighting eased into the peak before the curve (`lit`), 60% of it, so bright
// textures keep their detail; at least paper white.
fn tone_knee(headroom: f32) -> f32 {
return max(0.6 * headroom, 1.0);
}
// The tone curve on light whose brightest channel is `top`, in units of paper white `paper`
// nits: what to scale the light by, so it ends at most `headroom` (above 1). ITU-R BT.2390's
// EETF in PQ, without the black level lift (`display` does that): the same up to its knee, then
// a Hermite spline that reaches the peak level, at the source's peak. That peak is where the knee
// lands at `tone_knee`, PQ's 10 000 nits at most; light past it shows at the peak. The brightest
// channel sets the scale for all three, so light keeps its hue as it eases (lava stays orange)
// and no channel passes the peak.
fn tone_scale(top: f32, headroom: f32, paper: f32) -> f32 {
if top <= tone_knee(headroom) {
return 1.0;
}
// The tone curve on light whose brightest channel is `top`, in units of paper white `paper` nits:
// what to scale the light by, so it ends at most `headroom` (T). ITU-R BT.2390's EETF in PQ,
// without the black level lift (`display` does that): the same up to its knee, then a Hermite
// spline reaching T at the source's peak, past which light shows at T. The brightest channel sets
// the scale for all three, so light keeps its hue (lava stays orange) and no channel passes T.
//
// The source is the scene's peak S where Horizon lifts the world (`scene_peak`, hdr.rs
// `SCENE_PEAK`): the world's light is the same on every display, light past S is held at S, and
// where T reaches S that is all, so a display above S shows the world as one at S. Where the
// scene's range is unknown (`scene_peak` 0: a 0.2 pack's own light, HDR Lighting off), the
// source follows the display, as the 0.2 contract has it: the peak that puts the EETF's knee at
// `tone_knee`, PQ's 10 000 nits at most.
fn tone_scale(top: f32, scene_peak: f32, headroom: f32, paper: f32) -> f32 {
let peak = pq(vec3(headroom * paper)).x;
let source = min(3.0 * peak - 2.0 * pq(vec3(tone_knee(headroom) * paper)).x, 1.0);
let e = pq(vec3(top * paper)).x / source;
var source: f32;
var e: f32;
var held = 1.0;
if scene_peak > 0.0 {
held = min(1.0, scene_peak / max(top, 1e-6));
if scene_peak <= headroom {
return held;
}
source = pq(vec3(scene_peak * paper)).x;
e = pq(vec3(min(top, scene_peak) * paper)).x / source;
} else {
if top <= tone_knee(headroom) {
return 1.0;
}
source = min(3.0 * peak - 2.0 * pq(vec3(tone_knee(headroom) * paper)).x, 1.0);
e = pq(vec3(top * paper)).x / source;
}
let max_lum = peak / source;
let ks = 1.5 * max_lum - 0.5;
// At least 0: past 3 to 1 in PQ the knee would leave the source's range.
let ks = max(1.5 * max_lum - 0.5, 0.0);
if e >= 1.0 {
return headroom / top;
}
if e <= ks {
return 1.0;
return held;
}
let t = (e - ks) / (1.0 - ks);
let t2 = t * t;
@@ -287,16 +311,16 @@ fn tone_scale(top: f32, headroom: f32, paper: f32) -> f32 {
}
// Hdr::tonemap, after the post effects and before the camera overlays (docs/design/
// hdr-linear-light.md, `[tonemap]`): the light (`world` holds the frame) eased into the peak, in
// gamma, where scaling the light by s scales the stored colour by s^(1/2.2). Light up to the
// knee stays exactly as stored. With no headroom, a clip at white.
// hdr-linear-light.md, `[tonemap]`): the light (`world` holds the frame) fitted to the display, in
// gamma, where scaling the light by s scales the stored colour by s^(1/2.2). Light the curve
// leaves alone stays exactly as stored. With no headroom and no scene peak, a clip at white.
@fragment fn fs_tonemap(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
let g = textureLoad(world, vec2<i32>(pos.xy), 0).rgb;
if params.peak <= 1.0 {
if params.scene_peak == 0.0 && params.peak <= 1.0 {
return vec4(min(g, vec3(1.0)), 1.0);
}
let top = pow(max(max(g.r, max(g.g, g.b)), 0.0), 2.2);
let s = tone_scale(top, params.peak, params.paper * 80.0);
let s = tone_scale(top, params.scene_peak, params.peak, params.paper * 80.0);
return vec4(select(g * pow(s, 1.0 / 2.2), g, s == 1.0), 1.0);
}
@@ -331,7 +355,7 @@ fn layered(at: vec2<i32>) -> vec3<f32> {
}
// Screenshots keep midtones at their drawn brightness, with a shoulder starting at 0.6
// paper white in linear light. Unlike the display curve (whose knee is at least white),
// paper white in linear light. Unlike the display curve (which ends at the display's peak),
// this has an unbounded source range: k + (1-k)*(top-k)/(top+1-2*k) approaches SDR
// white smoothly, with slope 1 at k. One scale keeps the world's linear RGB ratios.
fn screenshot_world(w: vec3<f32>) -> vec3<f32> {
@@ -27,7 +27,8 @@ struct Lit {
lifted: bool,
};
fn hdr_mark_light(f: vec3<f32>, a: f32, shows: bool, eased: bool, settings: vec4<f32>, shares: vec4<f32>) -> Lit {
// `lifts`, per tier: how much its fully lifted pixels gain over their own light (their gain - 1).
fn hdr_mark_light(f: vec3<f32>, a: f32, shows: bool, eased: bool, settings: vec4<f32>, lifts: vec4<f32>) -> Lit {
var lifted = false;
// Light over 1 (a float frame's) shows only where the world does and carries
// no mark (alpha 1), up to the peak: light written as light. Marked pixels clip
@@ -60,7 +61,7 @@ fn hdr_mark_light(f: vec3<f32>, a: f32, shows: bool, eased: bool, settings: vec4
let lift = 1.0 - clamp((a - 0.175 * f32(band)) / 0.165, 0.0, 1.0);
if lift > 0.0 {
let top = max(light.r, max(light.g, light.b));
var gain = 1.0 + (settings.x - 1.0) * shares[tier] * lift;
var gain = 1.0 + lifts[tier] * lift;
if eased {
gain = min(gain, settings.x / max(top, 1e-4));
}
@@ -88,7 +89,8 @@ fn hdr_quantized_mark(mark: f32) -> f32 {
return round(saturate(mark) * 255.0) / 255.0;
}
fn hdr_written(color: vec3<f32>, mark: f32, settings: vec4<f32>, shares: vec4<f32>) -> vec3<f32> {
let shows = settings.z > 0.5 && settings.x > 1.0;
return pow(hdr_mark_light(max(color, vec3(0.0)), hdr_quantized_mark(mark), shows, false, settings, shares).light, vec3(1.0 / 2.2));
// The world's writers' light, from the tiers' `gains` (Hdr::draw_params): the same on every
// display; the tone curve fits it to the display afterwards (fs_tonemap).
fn hdr_written(color: vec3<f32>, mark: f32, settings: vec4<f32>, gains: vec4<f32>) -> vec3<f32> {
return pow(hdr_mark_light(max(color, vec3(0.0)), hdr_quantized_mark(mark), settings.z > 0.5, false, settings, gains - vec4(1.0)).light, vec3(1.0 / 2.2));
}
@@ -396,6 +396,7 @@ fn coverage_uses_pack_texts_final_alpha_and_discard() {
let program = crate::core_shader::CoreProgram {
vertex: naga::front::wgsl::parse_str("@vertex fn main(@location(0) p: vec2<f32>) -> @builtin(position) vec4<f32> { return vec4(p.x / 2.0 - 1.0, 1.0 - p.y, 0.0, 1.0); }").unwrap(),
fragment: naga::front::wgsl::parse_str("struct Result { @location(0) color: vec4<f32> } @fragment fn main(@builtin(position) p: vec4<f32>) -> Result { if p.x > 2.0 { discard; } return Result(vec4(0.4, 0.2, 0.1, 0.5)); }").unwrap(),
other: Vec::new(),
};
gpu.hud.set_text_shader(&gpu.device, Some(&program));
assert!(gpu.hud.pack_text.is_some());
@@ -417,6 +418,7 @@ fn a_pack_text_shader_without_a_colour_output_is_dropped_not_wrapped() {
let program = crate::core_shader::CoreProgram {
vertex: naga::front::wgsl::parse_str("@vertex fn main(@location(0) p: vec2<f32>) -> @builtin(position) vec4<f32> { return vec4(p, 0.0, 1.0); }").unwrap(),
fragment: naga::front::wgsl::parse_str("@fragment fn main() -> @location(1) vec4<f32> { return vec4(1.0); }").unwrap(),
other: Vec::new(),
};
gpu.hud.set_text_shader(&gpu.device, Some(&program));
assert!(gpu.hud.pack_text.is_none());
+55 -21
View File
@@ -2259,22 +2259,14 @@ impl Mesher<'_> {
translucent: face.translucent,
opaque: face.opaque,
};
// A tile keeps the quad's forced translucency; `<skip>` and `<default>` are its own.
let tile = |t: TileLayer| match t {
TileLayer::Layer {
layer,
translucent,
opaque,
} => TileLayer::Layer {
layer,
translucent: translucent || q.render_type.is_none() && q.material.force_translucent,
opaque,
},
TileLayer::Skip | TileLayer::Default => own,
};
let own_translucent = self.translucent(own);
let tile = |t: TileLayer| in_face_pass(t, own, own_translucent);
match pick {
Pick::Keep => self.quad(out, force_solid, own, corners.map(|c| c.0), tint),
Pick::Tile(t) => self.quad(out, force_solid, tile(t), corners.map(|c| c.0), tint),
Pick::Tile(t) => {
let v = corners.map(|c| c.0);
self.quad_as(out, force_solid, tile(t), v, false, tint);
}
Pick::Quarters(tiles) => {
let cuts = cuts(&corners);
for s in cuts.windows(2) {
@@ -2283,7 +2275,7 @@ impl Mesher<'_> {
.map(|(s, t)| bilinear(&corners, s, t));
let quarter = quarter_of(&piece);
let piece = piece.map(|c| c.0);
self.quad(out, force_solid, tile(tiles[quarter]), piece, tint);
self.quad_as(out, force_solid, tile(tiles[quarter]), piece, false, tint);
}
}
}
@@ -2296,12 +2288,7 @@ impl Mesher<'_> {
if count == 0 {
return;
}
let face_translucent = match pick {
Pick::Keep => self.translucent(own),
Pick::Tile(t) => self.translucent(tile(t)),
Pick::Quarters(tiles) => tiles.into_iter().any(|t| self.translucent(tile(t))),
Pick::Nothing => false,
};
let face_translucent = !matches!(pick, Pick::Nothing) && own_translucent;
// Overlay tiles face the standard way too (Continuity: "overlay textures always have the
// standard orientation"), whatever turns the model's own UVs (dirt's and sand's random
// rotations).
@@ -2450,6 +2437,21 @@ fn cuts(c: &[(TerrainVertex, [f32; 2]); 4]) -> Cuts {
}
out
}
/// Tile `t` as drawn over a face whose own sprite is `own`, in the face's pass
/// (`own_translucent`): CTM picks a texture, not a render layer (OptiFine draws a tile in the
/// block's). The tile's texels only drop the alpha test when all are opaque. A Conquest grass
/// blade tile with 7 partly clear texels of 1024 blended, and wrote the depth of its clear
/// rectangle, which Bliss fogs. `<skip>` and `<default>` are the face's own sprite.
fn in_face_pass(t: TileLayer, own: TileLayer, own_translucent: bool) -> TileLayer {
match t {
TileLayer::Layer { layer, opaque, .. } => TileLayer::Layer {
layer,
translucent: own_translucent,
opaque,
},
TileLayer::Skip | TileLayer::Default => own,
}
}
/// The UV a vanilla cube's face gives the block-local point `p` (`BlockElement`'s default UVs),
/// in sprite units: up is north on the top face, south on the bottom one, and up on the sides.
fn standard_uv(facing: horizon_assets::model::Direction, [x, y, z]: [f32; 3]) -> [f32; 2] {
@@ -2574,6 +2576,38 @@ mod quarter_tests {
);
}
#[test]
fn a_tile_draws_in_its_faces_pass() {
// Conquest's grass blade tile: a few partly clear texels, over an opaque face.
let blade = TileLayer::Layer {
layer: 5,
translucent: true,
opaque: false,
};
let face = |translucent| TileLayer::Layer {
layer: 1,
translucent,
opaque: !translucent,
};
let cut = TileLayer::Layer {
layer: 5,
translucent: false,
opaque: false,
};
assert_eq!(in_face_pass(blade, face(false), false), cut);
// Over stained glass the tile blends, as the face does.
let blend = TileLayer::Layer {
layer: 5,
translucent: true,
opaque: false,
};
assert_eq!(in_face_pass(blade, face(true), true), blend);
assert_eq!(
in_face_pass(TileLayer::Default, face(false), false),
face(false)
);
}
#[test]
fn overlays_take_the_cubes_own_uvs() {
use horizon_assets::model::Direction as D;
@@ -24,7 +24,7 @@ fn oit_cloud_color(in: Out, front: bool, fade: bool) -> vec4<f32> {
var color = oit_cloud_color(in, front, true);
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
color = vec4(hdr_written(color.rgb, hdr_scene_alpha(globals.hdr_light.y),
globals.hdr_settings, globals.hdr_shares), color.a);
globals.hdr_settings, globals.hdr_gains), color.a);
}
let seen = oit_sample_transmittance(vec2<i32>(in.clip.xy),
oit_normalize_depth(in.clip.z, in.clip.xy), 1.0 - color.a);
@@ -62,7 +62,7 @@
@fragment fn fs_oit_lightning_accumulate(in: Out) -> @location(0) vec4<f32> {
var color = celestial_color(in);
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
color = vec4(hdr_written(color.rgb, hdr_entity_mark(in), globals.hdr_settings, globals.hdr_shares), color.a);
color = vec4(hdr_written(color.rgb, hdr_entity_mark(in), globals.hdr_settings, globals.hdr_gains), color.a);
}
return oit_accumulate_additive(vec4(color.rgb, min(0.99, color.a)), 0.0, in.clip);
}
@@ -41,7 +41,7 @@ fn oit_terrain_color(in: TerrainOut) -> vec4<f32> {
globals.hdr_light.y, f * globals.fog_color.a));
// Convert the surface before weighting; its mark never belongs to the light behind it.
let fogged = mix(color.rgb, globals.fog_color.rgb, f * globals.fog_color.a);
return vec4(hdr_written(fogged, mark, globals.hdr_settings, globals.hdr_shares) * weighted.a, weighted.a);
return vec4(hdr_written(fogged, mark, globals.hdr_settings, globals.hdr_gains) * weighted.a, weighted.a);
}
return vec4(rgb, weighted.a);
}
@@ -1100,6 +1100,9 @@ impl PackScreen {
0,
);
self.search.draw(&mut out, m, blink_ms, None);
if self.browser.is_none() && self.search.contains(f64::from(mouse.0), f64::from(mouse.1)) {
crate::widgets::text_cursor(true, true);
}
for side in 0..2 {
self.draw_list(&mut out, m, side, mouse);
}
@@ -1302,6 +1305,9 @@ impl PackScreen {
field.x = qx;
field.y = qy;
field.draw(out, m, 0, None);
if field.contains(f64::from(mouse.0), f64::from(mouse.1)) {
crate::widgets::text_cursor(true, true);
}
let (sbx, sby) = self.browser_search_button();
d::button(
out,
+41 -19
View File
@@ -4693,20 +4693,19 @@ impl ParticleEngine {
.collect()
}
/// Camera-facing quads (`QuadParticleRenderState.renderRotatedQuad`).
/// Camera-facing quads (`QuadParticleRenderState.renderRotatedQuad`), turned by
/// `Camera.rotation()`: `rotationYXZ(PI - yRot, -xRot, 0)`.
pub fn render(
&self,
camera: DVec3,
yaw: f32,
pitch: f32,
base: Quat,
partial: f32,
light_at: &dyn Fn([i32; 3]) -> u8,
out: &mut Vec<Vertex>,
) {
self.render_inner::<false>(
camera,
yaw,
pitch,
base,
partial,
0.0,
None,
@@ -4721,8 +4720,7 @@ impl ParticleEngine {
pub fn render_culled(
&self,
camera: DVec3,
yaw: f32,
pitch: f32,
base: Quat,
partial: f32,
pixel_focal_length: f32,
culling_enabled: bool,
@@ -4732,13 +4730,12 @@ impl ParticleEngine {
out: &mut Vec<Vertex>,
) {
if !culling_enabled {
self.render(camera, yaw, pitch, partial, light_at, out);
self.render(camera, base, partial, light_at, out);
return;
}
self.render_inner::<true>(
camera,
yaw,
pitch,
base,
partial,
pixel_focal_length,
frustum,
@@ -4751,8 +4748,7 @@ impl ParticleEngine {
fn render_inner<const CULL: bool>(
&self,
camera: DVec3,
yaw: f32,
pitch: f32,
base: Quat,
partial: f32,
pixel_focal_length: f32,
frustum: Option<&crate::gfx::Frustum>,
@@ -4760,9 +4756,6 @@ impl ParticleEngine {
light_at: &dyn Fn([i32; 3]) -> u8,
out: &mut Vec<Vertex>,
) {
// Camera.setRotation: rotationYXZ(PI - yRot, -xRot, 0).
let base = Quat::from_rotation_y(std::f32::consts::PI - yaw.to_radians())
* Quat::from_rotation_x(-pitch.to_radians());
let mut light_cache = ParticleLightCache::default();
let mut additive = self.additive.lock().unwrap_or_else(|e| e.into_inner());
additive.clear();
@@ -5173,12 +5166,11 @@ mod tests {
let light_at = |_: [i32; 3]| 0xf0;
let mut regular = Vec::new();
engine.render(DVec3::ZERO, 0.0, 0.0, 0.5, &light_at, &mut regular);
engine.render(DVec3::ZERO, Quat::IDENTITY, 0.5, &light_at, &mut regular);
let mut disabled = Vec::new();
engine.render_culled(
DVec3::ZERO,
0.0,
0.0,
Quat::IDENTITY,
0.5,
900.0,
false,
@@ -5207,6 +5199,36 @@ mod tests {
assert_eq!(attributes(&disabled), attributes(&regular));
}
#[test]
fn quads_face_the_rotation_they_are_given() {
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
let sprite = "test:smoke".to_owned();
let rect = Rect {
u0: 0.0,
v0: 0.0,
u1: 1.0,
v1: 1.0,
w: 16,
h: 16,
};
engine.atlas.insert(sprite.clone(), (rect, false));
let mut random = LegacyRandom::new(7);
let mut particle =
Particle::moving(&mut random, ParticleType::Smoke, [0.0, 0.0, -3.0], [0.0; 3]);
particle.sprite = Sprite::Named(sprite);
engine.particles.push(particle);
let draw = |rotation: Quat| {
let mut out = Vec::new();
engine.render(DVec3::ZERO, rotation, 0.5, &|_| 0xf0, &mut out);
out.iter().map(|v| v.pos).collect::<Vec<_>>()
};
// A bed's view turns the quads as the player's own yaw would not.
let ahead = draw(Quat::IDENTITY);
let turned = draw(Quat::from_rotation_y(std::f32::consts::FRAC_PI_2));
assert_eq!(ahead.len(), 4);
assert_ne!(ahead, turned);
}
#[test]
fn a_mod_particles_json_becomes_its_sprite_set() {
let dir =
@@ -5600,7 +5622,7 @@ mod tests {
particle.sprite = Sprite::Index(0);
engine.particles.push(particle);
let mut vertices = Vec::new();
engine.render(DVec3::ZERO, 0.0, 0.0, 0.0, &|_| 0, &mut vertices);
engine.render(DVec3::ZERO, Quat::IDENTITY, 0.0, &|_| 0, &mut vertices);
assert_eq!(vertices.len(), 4);
assert!(vertices.iter().all(
|v| v.color >> 24 == 127 && v.layer & crate::gfx::TRANSLUCENT_PARTICLE_OVERLAY != 0
@@ -143,21 +143,39 @@ mod tests {
p.size = 0.1;
e.particles.push(p.clone());
let mut vertices = Vec::new();
e.render(DVec3::ZERO, 0.0, 0.0, 0.5, &|_| 0, &mut vertices);
e.render(
DVec3::ZERO,
glam::Quat::IDENTITY,
0.5,
&|_| 0,
&mut vertices,
);
assert_eq!(vertices.len(), 4);
assert!(vertices.iter().all(
|v| v.color >> 24 == 127 && v.layer & crate::gfx::TRANSLUCENT_PARTICLE_OVERLAY != 0
));
e.particles[0].set_visible(false);
vertices.clear();
e.render(DVec3::ZERO, 0.0, 0.0, 0.5, &|_| 0, &mut vertices);
e.render(
DVec3::ZERO,
glam::Quat::IDENTITY,
0.5,
&|_| 0,
&mut vertices,
);
assert!(vertices.is_empty());
assert_eq!(e.particles[0].age, 2);
e.pending.push(copy);
e.reload(Path::new(""), &HashMap::new(), &HashSet::new());
assert!(e.pending.is_empty());
vertices.clear();
e.render(DVec3::ZERO, 0.0, 0.0, 0.5, &|_| 0, &mut vertices);
e.render(
DVec3::ZERO,
glam::Quat::IDENTITY,
0.5,
&|_| 0,
&mut vertices,
);
assert!(vertices.is_empty());
e.tick_particle(&mut p, &level);
assert!(p.removed);
@@ -1971,7 +1971,7 @@ mod tests {
// The camera 5 blocks south, at the particle's height.
let camera = DVec3::new(p.position[0], p.position[1], p.position[2] + 5.0);
let mut out = Vec::new();
engine.render(camera, 0.0, 0.0, 0.0, &|_| 0xF0, &mut out);
engine.render(camera, glam::Quat::IDENTITY, 0.0, &|_| 0xF0, &mut out);
assert_eq!(out.len(), 4);
for v in &out {
// The texture's top row (v 0) under the centre, its bottom row over it.
@@ -2023,8 +2023,7 @@ mod tests {
let mut out = Vec::new();
engine.render(
DVec3::new(1.0, 64.0, 6.0),
0.0,
0.0,
glam::Quat::IDENTITY,
0.0,
&|_| 0xF0,
&mut out,
@@ -2138,8 +2137,7 @@ mod tests {
let mut out = Vec::new();
engine.render(
DVec3::new(1.0, 70.0, 1.0),
0.0,
0.0,
glam::Quat::IDENTITY,
0.0,
&|_| 0xF0,
&mut out,
@@ -580,6 +580,15 @@ pub fn button(
"widget/button"
};
sprite(out, sprite_id, x, y, w, h);
// `AbstractWidget.WithInactiveMessage.defaultInactiveMessage`: an inactive
// widget's message is -6250336 (0xFFA0A0A0), not the active white.
let inactive;
let label = if active {
label
} else {
inactive = label.clone().with_color(0xFFA0_A0A0);
&inactive
};
scrolling_string_over_contents(out, m, label, x, y, w, h, 2, now_ms);
}
/// `Tooltip.splitTooltip` of a widget's tooltip, shown under the mouse.
@@ -1150,6 +1159,33 @@ mod button_label_tests {
button(&mut out, m, text, 10, 20, w, 20, true, false, now_ms);
out
}
/// `WithInactiveMessage`: a disabled button's message is grey, not white.
#[test]
fn an_inactive_buttons_label_is_grey() {
let atlas = atlas();
let m = Measure(&atlas.font);
let mut out = Vec::new();
button(
&mut out,
&m,
&Text::literal("Go"),
10,
20,
100,
20,
false,
false,
0,
);
let color = out
.iter()
.find_map(|d| match d {
Draw::Text { text, .. } => text.spans.iter().find_map(|s| s.style.color),
_ => None,
})
.expect("the label is drawn with its inactive style");
assert_eq!(color & 0xFF_FFFF, 0xA0_A0A0);
}
fn scissor_of(draws: &[Draw]) -> Option<[i32; 4]> {
draws.iter().find_map(|d| match d {
Draw::Scissor { x0, y0, x1, y1 } => Some([*x0, *y0, *x1, *y1]),
@@ -310,6 +310,9 @@ pub(crate) fn search_field(
let frame = [x, y, clear[0] - x, h];
search.frame = Some(frame);
search.draw(batch, atlas, None);
if ui::hovered(cursor, x, y, frame[2], h) {
crate::widgets::text_cursor(true, true);
}
if search.value.is_empty() && !focused {
let hint = Text::literal(search.message.clone());
batch.text_styled(atlas, &hint, x + 5.0, search.y, style::muted(), true);
@@ -506,8 +506,8 @@ pub struct Streaming {
decoration_order: DecorationOrder,
order_area: Option<((i32, i32), i32)>,
/// What the queued jobs' priorities were computed from (`requeue`): the
/// centre, the band, and the extra areas.
queue_basis: (((i32, i32), Option<Band>), Vec<((i32, i32), i32)>),
/// centre, the band, the extra areas, and the forced chunks.
queue_basis: forced_ring::Basis,
finishing: FxHashSet<(i32, i32)>,
decorates: bool,
/// `ChunkGenerator::decorates_beside_final`.
@@ -1071,6 +1071,7 @@ pub struct WorkerContext {
}
mod bounded;
mod forced_cover;
mod forced_ring;
mod relight_now;
use bounded::nearest_first;
use forced_cover::ForcedCover;
@@ -1354,11 +1355,9 @@ impl Streaming {
/// render distance 32 nothing was decorated until every proto chunk had
/// been generated.
fn requeue(&mut self) {
let basis = (self.center, self.band);
if self.queue_basis.0 == basis && self.queue_basis.1 == self.extra {
if !forced_ring::basis_changed(self) {
return;
}
self.queue_basis = (basis, self.extra.clone());
let mut queue = self.shared.queue.lock().unwrap();
rank_again(&mut queue, |x, z| self.distance(x, z));
}
@@ -2862,7 +2861,7 @@ impl Ranges<'_> {
_ => own,
}
}
fn distance(&self, x: i32, z: i32) -> i64 {
fn player_distance(&self, x: i32, z: i32) -> i64 {
let d = |c: (i32, i32)| {
let (dx, dz) = (i64::from(x - c.0), i64::from(z - c.1));
dx * dx + dz * dz
@@ -0,0 +1,186 @@
//! How a forced chunk's ring is ranked (#13175).
use super::{Band, FORCED_TERRAIN_RING, Ranges, Streaming};
use std::collections::HashSet;
type Chunks = HashSet<(i32, i32)>;
/// What queued jobs were ranked from (`Streaming::requeue`): the centre, the
/// band, the extra areas, and the forced chunks.
#[derive(Default)]
pub(super) struct Basis {
player: ((i32, i32), Option<Band>),
extra: Vec<((i32, i32), i32)>,
forced: (Chunks, Chunks),
}
/// Whether the queued jobs' ranking is out of date; it is brought up to date.
pub(super) fn basis_changed(s: &mut Streaming) -> bool {
let b = &mut s.queue_basis;
let same = b.player == (s.center, s.band)
&& b.extra == s.extra
&& b.forced.0 == s.forced
&& b.forced.1 == s.mod_forced;
if !same {
b.player = (s.center, s.band);
b.extra.clone_from(&s.extra);
b.forced.0.clone_from(&s.forced);
b.forced.1.clone_from(&s.mod_forced);
}
!same
}
impl Ranges<'_> {
/// `player_distance`, or the nearer one to a forced chunk whose ring reaches
/// (x, z): see `forced_distance`.
pub(super) fn distance(&self, x: i32, z: i32) -> i64 {
self.player_distance(x, z).min(self.forced_distance(x, z))
}
/// Distance (squared) to the nearest forced chunk whose ring reaches
/// (x, z), or `i64::MAX`. A forced chunk's ring is loaded with its ticket
/// (levels 32 to 35 around the FORCED ticket's 31), so the 3x3 it needs to
/// tick is ranked around it, not by how far it lies from the player: vanilla
/// ticks the chunk on the first tick (`ChunkMap.prepareTickingChunk`).
pub(super) fn forced_distance(&self, x: i32, z: i32) -> i64 {
const SIDE: usize = (2 * FORCED_TERRAIN_RING + 1) as usize;
let held = self.forced.len() + self.mod_forced.len();
if held == 0 {
return i64::MAX;
}
let d = |f: (i32, i32)| {
let (dx, dz) = (i64::from(x - f.0), i64::from(z - f.1));
dx * dx + dz * dz
};
if held <= SIDE * SIDE {
return self
.forced_chunks()
.filter(|f| (x - f.0).abs().max((z - f.1).abs()) <= FORCED_TERRAIN_RING)
.map(|&f| d(f))
.min()
.unwrap_or(i64::MAX);
}
// Many forced chunks: look at the columns around (x, z) instead.
(-FORCED_TERRAIN_RING..=FORCED_TERRAIN_RING)
.flat_map(|dz| (-FORCED_TERRAIN_RING..=FORCED_TERRAIN_RING).map(move |dx| (dx, dz)))
.map(|(dx, dz)| (x + dx, z + dz))
.filter(|f| self.is_forced(f.0, f.1))
.map(d)
.min()
.unwrap_or(i64::MAX)
}
}
#[cfg(test)]
mod tests {
use super::super::{GenerationInputs, GenerationScan, NO_CHUNKS};
use super::*;
use crate::fast_hash::{FxHashMap, FxHashSet};
use crate::world::World;
/// A forced chunk's ring is loaded with its ticket, so it ranks around
/// the forced chunk and not by how far it lies from the player. Vanilla
/// then ticks the chunk on the first tick after a reload
/// (`ChunkMap.prepareTickingChunk`: its 3x3 is full at once); ranked from
/// the player at the origin, a ring 4096 blocks out arrived west/north
/// first, over tens of ticks.
#[test]
fn a_forced_chunks_ring_is_ranked_around_it() {
let ranges = |forced| Ranges {
center: (0, 0),
radius: 2,
extra: &[],
forced,
mod_forced: &NO_CHUNKS,
cover: None,
forced_cover: None,
band: None,
};
let forced: HashSet<(i32, i32)> = [(256, 256)].into();
let r = ranges(&forced);
// Squared distance to the forced chunk, by hand: (255, 254) is
// one west and two north of it.
assert_eq!(r.priority(256, 256), 0);
assert_eq!(r.priority(255, 254), 1 + 4);
assert_eq!(r.priority(260, 252), 16 + 16);
// Past the fourth ring (level 36 and up) the player's distance rules,
// as does the whole map without a forced chunk.
assert_eq!(r.priority(261, 256), 261 * 261 + 256 * 256);
let none = HashSet::new();
assert_eq!(ranges(&none).priority(255, 254), 255 * 255 + 254 * 254);
// Many forced chunks (the neighbour lookups) agree with few.
let many: HashSet<(i32, i32)> = (0..100).map(|i| (256 + 20 * i, 256)).collect();
assert_eq!(ranges(&many).priority(255, 254), 5);
assert_eq!(ranges(&many).priority(276, 258), 4);
// The generation plan fetches the 3x3 the chunk ticks with first.
let world = World::new(horizon_world::registry::BlockStateId(0), 0);
let (generating, finishing) = (FxHashSet::default(), FxHashSet::default());
let protos = FxHashMap::default();
let input = GenerationInputs {
ranges: ranges(&forced),
world: &world,
generating: &generating,
protos: &protos,
finishing: &finishing,
decorates: true,
remote: false,
};
let candidates = GenerationScan::default()
.candidates(input, usize::MAX)
.expect("a scan");
let ring: Vec<(i32, i32)> = candidates
.iter()
.map(|&(_, x, z)| (x, z))
.filter(|&(x, z)| (x - 256).abs().max((z - 256).abs()) <= FORCED_TERRAIN_RING)
.collect();
assert_eq!(ring.len(), 81);
assert_eq!(ring[0], (256, 256));
let d = |(x, z): (i32, i32)| (x - 256).pow(2) + (z - 256).pow(2);
for pair in ring.windows(2) {
assert!(
d(pair[0]) <= d(pair[1]),
"{:?} before {:?}",
pair[0],
pair[1]
);
}
let first_nine: HashSet<(i32, i32)> = ring[..9].iter().copied().collect();
let expected: HashSet<(i32, i32)> = (-1..=1)
.flat_map(|dx| (-1..=1).map(move |dz| (256 + dx, 256 + dz)))
.collect();
assert_eq!(first_nine, expected);
}
/// Jobs queued before a chunk was forced follow it once it is
/// (`Streaming::requeue` ranks them again).
#[test]
fn queued_jobs_follow_a_forced_chunk() {
use super::super::{Job, Queued, rank_again};
use std::collections::BinaryHeap;
let job = |x, z, order| Queued {
priority: 0,
at: Some((x, z)),
bias: 0,
order,
job: Job::Generate(x, z),
};
let first_after = |forced: &HashSet<(i32, i32)>| {
let ranges = Ranges {
center: (0, 0),
radius: 2,
extra: &[],
forced,
mod_forced: &NO_CHUNKS,
cover: None,
forced_cover: None,
band: None,
};
// (255, 254) lies nearer the origin than (258, 256), the
// forced chunk's neighbour two chunks east.
let mut queue = BinaryHeap::from(vec![job(255, 254, 1), job(258, 256, 2)]);
rank_again(&mut queue, |x, z| ranges.priority(x, z));
queue.pop().and_then(|q| q.at)
};
assert_eq!(first_after(&HashSet::new()), Some((255, 254)));
assert_eq!(first_after(&[(256, 256)].into()), Some((258, 256)));
}
}
@@ -43,7 +43,7 @@ struct Globals {
// w: apply the no-pack HDR gain; HDR_WRITE_LIGHT independently keeps float alpha as coverage.
hdr_light: vec4<f32>,
hdr_settings: vec4<f32>,
hdr_shares: vec4<f32>,
hdr_gains: vec4<f32>,
}
@group(0) @binding(0) var<uniform> globals: Globals;
@group(0) @binding(1) var sprites: texture_2d_array<f32>;
@@ -788,7 +788,7 @@ fn solid_finish(in: TerrainOut, texel: vec4<f32>, color: vec4<f32>) -> vec4<f32>
fn solid_light(finished: vec4<f32>) -> vec4<f32> {
if HDR_WRITE_LIGHT {
if globals.hdr_light.w != 0.0 {
return vec4(hdr_written(finished.rgb, finished.a, globals.hdr_settings, globals.hdr_shares), 1.0);
return vec4(hdr_written(finished.rgb, finished.a, globals.hdr_settings, globals.hdr_gains), 1.0);
}
return frame_stored(vec4(finished.rgb, 1.0));
}
@@ -870,7 +870,7 @@ fn solid_finish_at(in: TerrainOut, texel: vec4<f32>, color: vec4<f32>, du: vec2<
let fogged = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w)) * globals.fog_color.a;
let mark = hdr_scene_alpha(mix(hdr_scene(in.scene.x, in.scene.y, hdr_facing()), globals.hdr_light.y, fogged));
return vec4(hdr_written(finished.rgb, mark, globals.hdr_settings, globals.hdr_shares), finished.a);
return vec4(hdr_written(finished.rgb, mark, globals.hdr_settings, globals.hdr_gains), finished.a);
}
return frame_stored(finished);
}
@@ -1076,7 +1076,7 @@ fn sky_finished(color: vec3<f32>) -> vec4<f32> {
let mark = 1.0 - 0.24 * globals.hdr_light.y;
if HDR_WRITE_LIGHT {
if globals.hdr_light.w != 0.0 {
return vec4(hdr_written(color, mark, globals.hdr_settings, globals.hdr_shares), 1.0);
return vec4(hdr_written(color, mark, globals.hdr_settings, globals.hdr_gains), 1.0);
}
return frame_stored(vec4(color, 1.0));
}
+55
View File
@@ -1104,6 +1104,34 @@ pub fn tooltip_menu(
);
tooltip_box(batch, atlas, lines, x as f32, y as f32, w, h);
}
/// `BelowOrAboveWidgetTooltipPositioner.positionTooltip`: a keyboard-focused widget's tooltip.
pub fn tooltip_focused(batch: &mut HudBatch, atlas: &HudAtlas, lines: &[Text], rect: [f32; 4]) {
if lines.is_empty() {
return;
}
let (w, h) = tooltip_size(atlas, lines);
let (x, y) = focused_tooltip_position(
(batch.width as i32, batch.height as i32),
rect.map(|v| v as i32),
(w.ceil() as i32, h.ceil() as i32),
);
tooltip_box(batch, atlas, lines, x as f32, y as f32, w, h);
}
/// `BelowOrAboveWidgetTooltipPositioner.positionTooltip`: below, or above when it does not fit.
fn focused_tooltip_position(screen: (i32, i32), rect: [i32; 4], size: (i32, i32)) -> (i32, i32) {
let [left, top, width, height] = rect;
let mut x = left + 3;
let mut y = top + height + 4;
if y + size.1 + 3 > screen.1 {
y = top - size.1 - 4;
}
if x + size.0 > screen.0 {
x = (left + width - size.0 - 3).max(4);
}
(x, y)
}
/// A widget's tooltip while the controller's focus is on it (Horizon's: vanilla has no gamepad
/// input, and the hidden pointer mid-widget would put it over the widget).
pub fn tooltip_under(batch: &mut HudBatch, atlas: &HudAtlas, lines: &[Text], rect: [f32; 4]) {
@@ -2614,6 +2642,33 @@ mod tests {
assert!(!delete_words(&mut v, 1));
assert_eq!(v, "stone");
}
#[test]
fn a_keyboard_focused_tooltip_uses_vanillas_widget_edges() {
let screen = (320, 240);
assert_eq!(
focused_tooltip_position(screen, [7, 160, 98, 20], (170, 40)),
(10, 184)
);
// Exact fit below; one pixel taller goes above.
assert_eq!(
focused_tooltip_position(screen, [7, 160, 98, 20], (170, 53)),
(10, 184)
);
assert_eq!(
focused_tooltip_position(screen, [7, 160, 98, 20], (170, 54)),
(10, 102)
);
// Right overflow aligns against the widget's right edge, not the screen's.
assert_eq!(
focused_tooltip_position(screen, [250, 100, 60, 20], (170, 40)),
(137, 124)
);
assert_eq!(
focused_tooltip_position((120, 240), [7, 160, 98, 20], (170, 40)),
(4, 184)
);
}
#[test]
fn a_pad_focused_tooltip_hangs_from_its_widgets_bottom_left() {
let screen = (427.0, 240.0);
@@ -13,6 +13,8 @@ pub enum Cursor {
#[default]
Arrow,
PointingHand,
/// `CursorTypes.IBEAM`: over a text field that takes input.
Ibeam,
NotAllowed,
/// `CursorTypes.RESIZE_NS`: a scroller held vertically (`MerchantScreen`).
ResizeNs,
@@ -55,6 +57,18 @@ pub fn slider_cursor(hovered: bool, active: bool, dragging: bool) {
}
}
/// `EditBox.handleCursor`: a hovered text field shows the I-beam, or the
/// forbidden sign when it takes no input.
pub fn text_cursor(hovered: bool, editable: bool) {
if hovered {
request_cursor(if editable {
Cursor::Ibeam
} else {
Cursor::NotAllowed
});
}
}
/// The cursor the frame asked for, and back to the arrow for the next one
/// (the window shows the arrow unless a widget asks).
pub fn take_cursor() -> Cursor {
@@ -429,6 +443,16 @@ pub fn record_as(label: &str, [x, y, w, h]: [f32; 4], enabled: bool, kind: Kind)
mod cursor_tests {
use super::*;
#[test]
fn a_hovered_text_field_asks_for_the_i_beam() {
let _ = take_cursor();
text_cursor(false, true);
assert_eq!(take_cursor(), Cursor::Arrow);
text_cursor(true, true);
assert_eq!(take_cursor(), Cursor::Ibeam);
text_cursor(true, false);
assert_eq!(take_cursor(), Cursor::NotAllowed);
}
#[test]
fn a_dragged_slider_asks_for_the_horizontal_resize_cursor() {
let _ = take_cursor();
@@ -319,6 +319,12 @@ impl World {
pub fn bottom_y(&self) -> i32 {
self.layout().min_y()
}
/// `Entity.checkBelowWorld`'s floor, `Level.getMinY() - 64`: the level's own
/// lowest Y (0 in the Nether and the End), not the chunks' widened column.
/// An entity below it is out of the world.
pub fn below_world_y(&self) -> f64 {
f64::from(self.shape.min_y - 64)
}
/// One above the highest block Y the level's chunks hold.
pub fn top_y(&self) -> i32 {
self.layout().max_y()
+2
View File
@@ -73,6 +73,8 @@ horizon-theme = { path = "../horizon-theme" }
# anonymous "I'm playing" ping is the Horizon layer's (`horizon-heartbeat`, in the official executable).
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
horizon-bench = { path = "../horizon-bench" }
# The machine and process probes (the dedicated server's performance report).
horizon-system = { path = "../horizon-system" }
crc32fast = "1"
zip = { version = "9.1.3", default-features = false, features = ["deflate"] }
# Controllers (evdev + libudev on Linux, IOKit on macOS, Windows.Gaming.Input); the `controllers` feature.
+1 -1
View File
@@ -862,7 +862,7 @@ impl Ticker<'_, '_, '_, '_> {
if !self.st.first_tick && self.st.fluids.is_in_fluid(&Tags, LAVA) {
self.st.fall_distance *= 0.5;
}
if self.position().y < f64::from(self.scene.world.bottom_y() - 64) {
if self.position().y < self.scene.world.below_world_y() {
self.st.removed = true;
}
self.st.first_tick = false;
+173 -9
View File
@@ -3,6 +3,7 @@
//! banners, and the loom rules of `LoomMenu` (selectable patterns, the
//! six-layer limit and the result stack).
use crate::{block_entity_render::DYES, items::ItemStack, items::Items};
use horizon_world::nbt::Tag;
use serde_json::Value;
use std::{collections::HashMap, fs, path::Path, sync::Arc};
@@ -30,6 +31,8 @@ pub struct Banners {
dyes: HashMap<Arc<str>, Arc<str>>,
/// Item -> `provides_banner_patterns`, in tag order.
provided: HashMap<Arc<str>, Vec<Arc<str>>>,
/// Banner-pattern tag id -> its members, for a component that names a tag.
pattern_tags: HashMap<String, Vec<Arc<str>>>,
}
fn read_tag(root: &Path, name: &str, depth: u32) -> Vec<Arc<str>> {
@@ -98,6 +101,31 @@ impl Banners {
});
}
banners.no_item_required = read_tag(root, "no_item_required", 0);
// Every tag, `pattern_item/globe` included, for a component naming one.
let tags = root.join("data/minecraft/tags/banner_pattern");
let mut dirs = vec![tags.clone()];
while let Some(dir) = dirs.pop() {
for path in fs::read_dir(dir)
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
{
if path.is_dir() {
dirs.push(path);
} else if path.extension().is_some_and(|e| e == "json")
&& let Some(name) = path
.strip_prefix(&tags)
.ok()
.and_then(|p| p.with_extension("").to_str().map(|s| s.replace('\\', "/")))
{
let members = read_tag(root, &name, 0);
banners
.pattern_tags
.insert(format!("minecraft:{name}"), members);
}
}
}
let owned_defaults;
let defaults = match defaults {
Some(defaults) => Some(defaults),
@@ -159,27 +187,88 @@ impl Banners {
}
/// `LoomMenu.isDyeItem`: the dye colour of a loom dye.
pub fn loom_dye(&self, items: &Items, stack: &ItemStack) -> Option<Arc<str>> {
let tagged = items
if !items
.item_tags
.get("minecraft:loom_dyes")
.is_some_and(|t| t.contains(&*stack.item));
tagged
.then(|| self.dyes.get(&stack.item).cloned())
.flatten()
.is_some_and(|t| t.contains(&*stack.item))
{
return None;
}
let data = stack.data();
if data.is_some_and(|d| d.other("!minecraft:dye").is_some()) {
return None;
}
if let Some(Tag::String(color)) = data.and_then(|d| d.other("minecraft:dye")) {
return Some(Arc::from(
color.to_string_lossy().trim_start_matches("minecraft:"),
));
}
self.dyes.get(&stack.item).cloned()
}
/// `LoomMenu.isPatternItem`.
/// `LoomMenu.isPatternItem`: the item tag and a present
/// `provides_banner_patterns` component (the stack's, else the item's).
pub fn is_pattern_item(&self, items: &Items, stack: &ItemStack) -> bool {
items
if !items
.item_tags
.get("minecraft:loom_patterns")
.is_some_and(|t| t.contains(&*stack.item))
&& self.provided.contains_key(&stack.item)
{
return false;
}
let data = stack.data();
if data.is_some_and(|d| d.other("!minecraft:provides_banner_patterns").is_some()) {
return false;
}
data.is_some_and(|d| d.other("minecraft:provides_banner_patterns").is_some())
|| self.provided.contains_key(&stack.item)
}
/// `LoomMenu.getSelectablePatterns`.
pub fn selectable(&self, pattern_item: Option<&ItemStack>) -> Vec<Arc<str>> {
match pattern_item {
None => self.no_item_required.clone(),
Some(stack) => self.provided.get(&stack.item).cloned().unwrap_or_default(),
Some(stack) => {
let data = stack.data();
if data.is_some_and(|d| d.other("!minecraft:provides_banner_patterns").is_some()) {
return Vec::new();
}
match data.and_then(|d| d.other("minecraft:provides_banner_patterns")) {
Some(tag) => self.provides(tag),
None => self.provided.get(&stack.item).cloned().unwrap_or_default(),
}
}
}
}
/// A `provides_banner_patterns` value: a pattern id, a tag, or a list of
/// either (`HolderSet`'s saved forms).
fn provides(&self, tag: &Tag) -> Vec<Arc<str>> {
match tag {
Tag::String(value) => self.provides_one(&value.to_string_lossy()),
Tag::List(values) => values
.iter()
.flat_map(|value| match value {
Tag::String(value) => self.provides_one(&value.to_string_lossy()),
_ => Vec::new(),
})
.collect(),
_ => Vec::new(),
}
}
fn provides_one(&self, value: &str) -> Vec<Arc<str>> {
// `Identifier.parse`: an id or tag without a namespace is `minecraft`'s.
let namespaced = |id: &str| {
if id.contains(':') {
id.to_owned()
} else {
format!("minecraft:{id}")
}
};
match value.strip_prefix('#') {
Some(tag) => self
.pattern_tags
.get(&namespaced(tag))
.cloned()
.unwrap_or_default(),
None => vec![Arc::from(namespaced(value))],
}
}
}
@@ -249,6 +338,81 @@ mod tests {
assert_eq!(&*b.selectable(Some(&item))[0], "minecraft:flower");
}
#[test]
fn prepared_dye_component() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("items.json").exists() {
return;
}
let items = Items::tags_only(&root, &horizon_world::loot::PackOverlay::NONE).unwrap();
let b = Banners::load(&root);
let mut dye = ItemStack::new("minecraft:white_dye", 1);
assert_eq!(&*b.loom_dye(&items, &dye).unwrap(), "white");
dye.data_mut()
.set_other("minecraft:dye", Some(Tag::String("red".into())));
assert_eq!(&*b.loom_dye(&items, &dye).unwrap(), "red");
dye.data_mut().set_other(
"!minecraft:dye",
Some(Tag::compound(Vec::<(&str, Tag)>::new())),
);
assert!(b.loom_dye(&items, &dye).is_none());
}
#[test]
fn prepared_pattern_component_presence() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("items.json").exists() {
return;
}
let items = Items::tags_only(&root, &horizon_world::loot::PackOverlay::NONE).unwrap();
let b = Banners::load(&root);
let mut pattern = ItemStack::new("minecraft:globe_banner_pattern", 1);
assert!(b.is_pattern_item(&items, &pattern));
pattern.data_mut().set_other(
"!minecraft:provides_banner_patterns",
Some(Tag::compound(Vec::<(&str, Tag)>::new())),
);
assert!(!b.is_pattern_item(&items, &pattern));
}
#[test]
fn prepared_pattern_component_selection() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("items.json").exists() {
return;
}
let b = Banners::load(&root);
let mut pattern = ItemStack::new("minecraft:globe_banner_pattern", 1);
assert_eq!(&*b.selectable(Some(&pattern))[0], "minecraft:globe");
pattern.data_mut().set_other(
"minecraft:provides_banner_patterns",
Some(Tag::List(vec![Tag::String("minecraft:creeper".into())])),
);
assert_eq!(&*b.selectable(Some(&pattern))[0], "minecraft:creeper");
// One id without its namespace, or a tag, as `HolderSet`'s codec reads them.
pattern.data_mut().set_other(
"minecraft:provides_banner_patterns",
Some(Tag::String("creeper".into())),
);
assert_eq!(
b.selectable(Some(&pattern)),
[Arc::from("minecraft:creeper")]
);
pattern.data_mut().set_other(
"minecraft:provides_banner_patterns",
Some(Tag::String("#pattern_item/globe".into())),
);
assert_eq!(b.selectable(Some(&pattern)), [Arc::from("minecraft:globe")]);
pattern.data_mut().set_other(
"!minecraft:provides_banner_patterns",
Some(Tag::compound(Vec::<(&str, Tag)>::new())),
);
assert!(b.selectable(Some(&pattern)).is_empty());
}
#[test]
fn banner_colour_comes_from_the_item() {
assert_eq!(
banner_color("minecraft:light_gray_banner"),
@@ -40,6 +40,17 @@ pub fn carries_record(stack: &crate::items::ItemStack) -> bool {
.is_some_and(|data| data.get("RecordItem").is_some())
}
/// `LecternBlock.getStateForPlacement`: the item's `minecraft:block_entity_data`
/// carries a `Book` (`TypedEntityData.contains` checks the key only), so the
/// placed lectern's `has_book` state says so. Vanilla reads the item's data,
/// not the block entity the placement just filled.
pub fn carries_book(stack: &crate::items::ItemStack) -> bool {
stack
.data()
.and_then(|d| d.other(BLOCK_ENTITY_DATA))
.is_some_and(|data| data.get("Book").is_some())
}
/// `BlockItem.shouldPrintOpWarning` for a player with game master permission:
/// the item carries data of an op-only type.
pub fn op_warning(stack: &crate::items::ItemStack) -> bool {
+2
View File
@@ -139,6 +139,8 @@ pub mod stopwatches;
pub mod structure_blocks;
pub mod studio;
pub mod telemetry;
#[cfg(test)]
pub(crate) mod test_fixtures;
pub mod text_display;
pub mod tick_chunk_membership;
pub mod ticket_levels;
+38 -2
View File
@@ -18,13 +18,28 @@ impl Traveller {
/// `Entity.setAsInsidePortal`: the entity is in the gateway and goes
/// through it on the next tick.
pub fn enter(&mut self, gateway: BlockPos) {
self.state.set_inside(gateway, ENTITY_DELAY);
self.enter_with_delay(gateway, ENTITY_DELAY);
}
/// Enter with the traveller's `Entity.getDimensionChangingDelay`.
pub fn enter_with_delay(&mut self, gateway: BlockPos, delay: i32) {
self.state.set_inside(gateway, delay);
}
/// `Entity.handlePortal`: the entity goes through the gateway it entered
/// (the End gateway has no transition time), or stays where it is.
pub fn travel(&mut self, allowed: bool) -> Option<BlockPos> {
self.state.handle(allowed, 0, ENTITY_DELAY)
self.travel_with_delay(allowed, ENTITY_DELAY)
}
/// Travel with the entity-specific portal cooldown.
pub fn travel_with_delay(&mut self, allowed: bool, delay: i32) -> Option<BlockPos> {
self.state.handle(allowed, 0, delay)
}
/// `Entity.load` updates its counter without replacing the portal process.
pub fn set_cooldown(&mut self, cooldown: i32) {
self.state.cooldown = cooldown;
}
/// Whether the entity's travel is over: it has no portal process left and
@@ -171,6 +186,27 @@ mod tests {
assert!(!traveller.is_done());
}
#[test]
fn entity_specific_cooldowns_refresh_then_expire_outside_the_gateway() {
let gateway = BlockPos::new(702, 100, 0);
for delay in [2, 10, 300] {
let mut traveller = Traveller::default();
traveller.enter_with_delay(gateway, delay);
assert_eq!(traveller.travel_with_delay(true, delay), Some(gateway));
assert_eq!(traveller.cooldown(), delay);
// Continuing contact refreshes the counter, without another trip.
traveller.enter_with_delay(gateway, delay);
assert_eq!(traveller.travel_with_delay(true, delay), None);
assert_eq!(traveller.cooldown(), delay - 1);
for _ in 1..delay {
assert_eq!(traveller.travel_with_delay(true, delay), None);
}
assert!(traveller.is_done());
traveller.enter_with_delay(gateway, delay);
assert_eq!(traveller.travel_with_delay(true, delay), Some(gateway));
}
}
#[test]
fn an_entity_that_may_not_use_portals_stays_in_the_gateway() {
let gateway = BlockPos::new(702, 100, 0);
+55 -20
View File
@@ -84,6 +84,7 @@ fn slime_at(world: &World, facts: &BlockFacts, position: BlockPos) -> bool {
mod fluids;
mod item_fall;
mod item_no_physics;
pub mod nameplate;
mod obstacles;
pub use fluids::{fall_reset_hit, fluid_flow, update_entity_fluids, water_clip};
pub use obstacles::ObstacleGrid;
@@ -948,7 +949,7 @@ impl ItemEntity {
if self.age != i16::MIN as i32 {
self.age += 1;
}
self.age < 6000 && self.position.y > -128.0
self.age < 6000 && self.position.y >= f64::from(world.shape.min_y - 64)
}
/// `Entity.makeStuckInBlock` as `WebBlock.entityInside` and
/// `PowderSnowBlock.entityInside` call it for an item (which, unlike a
@@ -1289,8 +1290,41 @@ pub struct ExperienceOrb {
/// `ExperienceOrb.followingPlayer`: the followed player's entity id, kept
/// until they are gone or further than 8 blocks. Never saved.
pub following: Option<u32>,
/// `Entity.fallDistance` (`fall_distance` in its save).
pub fall_distance: f64,
}
/// An orb's `Entity.move` this tick, for the fall bookkeeping its caller
/// finishes (the long-move cast, `checkFallDamage`'s HIT_GROUND).
#[derive(Clone, Copy, Debug)]
pub struct OrbMove {
pub from: Vec3,
pub moved: Vec3,
/// `Entity.isInWater` from this tick's fluid step.
pub in_water: bool,
}
impl ExperienceOrb {
/// A fresh orb (`new ExperienceOrb(level, pos, motion, value)`): full
/// health, no age, fall or followed player, not yet ticked.
pub fn new(id: u32, position: Vec3, velocity: Vec3, value: i32) -> Self {
Self {
rotation: [0.0, 0.0],
join: Default::default(),
id,
position,
previous: position,
velocity,
value,
age: 0,
on_ground: false,
count: 1,
ticks: 0,
health: 5,
air: 300,
no_gravity: false,
following: None,
fall_distance: 0.0,
}
}
/// `ExperienceOrb.hurtServer`: the damage comes off the health as an
/// `int`; an orb at 0 or below is discarded. True when it is gone.
pub fn hurt(&mut self, damage: f32) -> bool {
@@ -1414,7 +1448,7 @@ impl ExperienceOrb {
fluids: &crate::motion::FluidResources,
on_sound: &mut dyn FnMut([f64; 3], f32),
) -> bool {
self.tick_among(
let (stays, _) = self.tick_among(
world,
facts,
collision,
@@ -1425,10 +1459,12 @@ impl ExperienceOrb {
fluids,
on_sound,
&[],
)
);
stays
}
/// [`Self::tick`], its move also stopped by `entities`: the boxes of the
/// entities that `canBeCollidedWith` it (`EntityGetter.getEntityCollisions`).
/// Also gives the move it made, whose fall bookkeeping the caller finishes.
#[allow(clippy::too_many_arguments)]
pub fn tick_among(
&mut self,
@@ -1442,12 +1478,12 @@ impl ExperienceOrb {
fluids: &crate::motion::FluidResources,
on_sound: &mut dyn FnMut([f64; 3], f32),
entities: &[Aabb],
) -> bool {
) -> (bool, OrbMove) {
self.ticks += 1;
self.previous = self.position;
// `Entity.baseTick`'s fluid step, whose `updateFluidOnEyes` answer the
// water branch below reads: the orb's eye, not its feet, decides.
let eye_in_water = self.update_fluids(world, fluids);
let (eye_in_water, in_water) = self.update_fluids(world, fluids);
let block = world.block(
self.position.x.floor() as i32,
self.position.y.floor() as i32,
@@ -1514,6 +1550,7 @@ impl ExperienceOrb {
let moved = scene
.resolve_in_border_with(bounds, wanted, 0.0, self.on_ground, border, entities)
.unwrap_or(Vec3::ZERO);
let from = self.position;
self.position = Vec3::new(
self.position.x + moved.x,
self.position.y + moved.y,
@@ -1561,7 +1598,12 @@ impl ExperienceOrb {
self.velocity.y = -wanted.y * 0.4;
}
self.age += 1;
!discarded && self.age < 6000
let step = OrbMove {
from,
moved,
in_water,
};
(!discarded && self.age < 6000, step)
}
/// `LavaFluid.entityInside` -> `Entity.lavaHurt`: four damage on the
/// server, through the orb's existing hurt/discard policy, playing the generic
@@ -2020,6 +2062,8 @@ pub struct ArmorStand {
/// (`getAttribute`) on the stand's first server tick, and a save that lists
/// it loads it back, so a stand summoned this tick has none yet.
pub speed_instance: bool,
/// `NAME_TAG_DISTANCE` and `BELOW_NAME_DISTANCE` from server attribute updates.
pub nameplate: crate::entities::nameplate::NameplateDistances,
}
/// The fluid a stand travels in (`LivingEntity.travelInFluid`: water first,
/// then lava), from the heights `Entity::push_by_fluid` measured.
@@ -2103,20 +2147,6 @@ impl ArmorStand {
}
}
impl ArmorStand {
/// `NameTagItem.interactLivingEntity`: a custom name handles the use;
/// only a living target changes. Stack consumption belongs to the caller.
pub fn name_with_tag(&mut self, stack: &ItemStack) -> bool {
if &*stack.item != "minecraft:name_tag" {
return false;
}
let Some(name) = stack.data().and_then(|data| data.custom_name.as_ref()) else {
return false;
};
if self.health > 0.0 {
self.custom_name = Some(crate::component::name_text(name));
}
true
}
pub const SLOTS: [&'static str; 6] = ["mainhand", "offhand", "feet", "legs", "chest", "head"];
/// The `Pose` compound's keys.
pub const POSE_KEYS: [&'static str; 6] =
@@ -2273,6 +2303,7 @@ impl ArmorStand {
enchantment_equipment: Default::default(),
support: Default::default(),
speed_instance: false,
nameplate: Default::default(),
}
}
/// `Entity.getOnPos(0.2)` (`getOnPosLegacy`): the supporting block's
@@ -3346,6 +3377,7 @@ mod world_border_tests {
None,
);
let mut orb = ExperienceOrb {
fall_distance: 0.0,
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
@@ -4757,6 +4789,7 @@ mod orb_underwater_tests {
.iter()
.map(|(v, _)| {
let mut orb = ExperienceOrb {
fall_distance: 0.0,
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
@@ -4792,6 +4825,7 @@ mod render_light_tests {
fn orb(y: f64) -> ExperienceOrb {
ExperienceOrb {
fall_distance: 0.0,
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
@@ -4994,6 +5028,7 @@ mod orb_lava_tests {
fn sample_orb(at: Vec3, health: i32) -> ExperienceOrb {
ExperienceOrb {
fall_distance: 0.0,
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
+18 -7
View File
@@ -159,19 +159,28 @@ impl ItemEntity {
}
impl ExperienceOrb {
/// `Entity.updateFluidInteraction`: refreshes fluid immersion over the
/// orb's box and pushes its velocity by any water or lava current.
/// Answers `isEyeInFluid(FluidTags.WATER)` for the eye the scan reads,
/// `0.425` above the orb's feet.
pub fn update_fluids(&mut self, world: &World, fluids: &FluidResources) -> bool {
update_entity_fluids(
/// orb's box and pushes its velocity by any water or lava current; water
/// resets its fall, and `Entity.baseTick` halves it in lava (`isInLava`
/// is false on the orb's first tick, `Entity.firstTick`). Answers
/// `isEyeInFluid(FluidTags.WATER)` for the eye the scan reads, `0.425`
/// above the orb's feet, and `isInWater`.
pub fn update_fluids(&mut self, world: &World, fluids: &FluidResources) -> (bool, bool) {
let (water, lava, eye) = update_entity_fluids(
self.bounds(),
self.position,
self.position.y + f64::from(0.425f32),
&mut self.velocity,
world,
fluids,
)
.2
);
if water > 0.0 {
self.fall_distance = 0.0;
}
// `tick_among` counts this tick first: `tickCount` is 1 on the first.
if lava > 0.0 && self.ticks > 1 {
self.fall_distance *= 0.5;
}
(eye, water > 0.0)
}
}
impl ArmorStand {
@@ -471,6 +480,7 @@ mod tests {
}
let at = Vec3::new(20.5, 4.0, 20.5);
let orb = ExperienceOrb {
fall_distance: 0.0,
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
@@ -562,6 +572,7 @@ mod tests {
}
let at = Vec3::new(20.5, 4.6, 20.5);
let mut orb = ExperienceOrb {
fall_distance: 0.0,
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
@@ -0,0 +1,150 @@
//! The nameplate attributes carried by armor stands and mobs.
use super::ArmorStand;
use crate::items::ItemStack;
use crate::mobs::Mob;
/// The two synced attributes that control name tags and below-name lines.
/// `None` means the server has not sent a sync for that attribute yet.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct NameplateDistances {
/// `minecraft:name_tag_distance` from the server's attribute updates.
pub name_tag: Option<f64>,
/// `minecraft:below_name_distance` from the server's attribute updates.
pub below_name: Option<f64>,
}
impl NameplateDistances {
/// `LivingEntity.getAttributeValue(Attributes.NAME_TAG_DISTANCE)`: the
/// default is 64 and vanilla's maximum is 512.
pub fn name_tag_distance(&self) -> f64 {
name_tag_distance_of(self.name_tag)
}
/// `LivingEntity.getAttributeValue(Attributes.BELOW_NAME_DISTANCE)`: the
/// default is 10 and vanilla's maximum is 512.
pub fn below_name_distance(&self) -> f64 {
below_name_distance_of(self.below_name)
}
}
impl Mob {
/// Stores both distances, from a model's own live attributes.
pub fn set_nameplate(&mut self, distances: NameplateDistances) {
let attributes = self.extra.attributes.get_or_insert_with(Default::default);
attributes.name_tag_distance = distances.name_tag;
attributes.below_name_distance = distances.below_name;
}
/// Stores `NAME_TAG_DISTANCE` from a server attribute update.
pub fn set_name_tag_distance(&mut self, value: f64) {
self.extra
.attributes
.get_or_insert_with(Default::default)
.name_tag_distance = Some(value);
}
/// Stores `BELOW_NAME_DISTANCE` from a server attribute update.
pub fn set_below_name_distance(&mut self, value: f64) {
self.extra
.attributes
.get_or_insert_with(Default::default)
.below_name_distance = Some(value);
}
/// `LivingEntity.getAttributeValue(Attributes.NAME_TAG_DISTANCE)`.
pub fn name_tag_distance(&self) -> f64 {
name_tag_distance_of(
self.extra
.attributes
.as_ref()
.and_then(|attributes| attributes.name_tag_distance),
)
}
/// `LivingEntity.getAttributeValue(Attributes.BELOW_NAME_DISTANCE)`.
pub fn below_name_distance(&self) -> f64 {
below_name_distance_of(
self.extra
.attributes
.as_ref()
.and_then(|attributes| attributes.below_name_distance),
)
}
}
impl ArmorStand {
/// `NameTagItem.interactLivingEntity`: a custom name handles the use;
/// only a living target changes. Stack consumption belongs to the caller.
pub fn name_with_tag(&mut self, stack: &ItemStack) -> bool {
if &*stack.item != "minecraft:name_tag" {
return false;
}
let Some(name) = stack.data().and_then(|data| data.custom_name.as_ref()) else {
return false;
};
if self.health > 0.0 {
self.custom_name = Some(crate::component::name_text(name));
}
true
}
/// `LivingEntity.getAttributeValue(Attributes.NAME_TAG_DISTANCE)`.
pub fn name_tag_distance(&self) -> f64 {
self.nameplate.name_tag_distance()
}
/// `LivingEntity.getAttributeValue(Attributes.BELOW_NAME_DISTANCE)`.
pub fn below_name_distance(&self) -> f64 {
self.nameplate.below_name_distance()
}
}
/// The vanilla default (64) and maximum (512) for `NAME_TAG_DISTANCE`.
pub fn name_tag_distance_of(value: Option<f64>) -> f64 {
value
.filter(|v| v.is_finite())
.unwrap_or(64.0)
.clamp(0.0, 512.0)
}
/// The vanilla default (10) and maximum (512) for `BELOW_NAME_DISTANCE`.
pub fn below_name_distance_of(value: Option<f64>) -> f64 {
value
.filter(|v| v.is_finite())
.unwrap_or(10.0)
.clamp(0.0, 512.0)
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_core::geometry::Vec3;
#[test]
fn nameplate_distances_use_vanilla_defaults_and_clamp() {
assert_eq!(name_tag_distance_of(None), 64.0);
assert_eq!(name_tag_distance_of(Some(5.0)), 5.0);
assert_eq!(name_tag_distance_of(Some(600.0)), 512.0);
assert_eq!(name_tag_distance_of(Some(-4.0)), 0.0);
assert_eq!(below_name_distance_of(None), 10.0);
assert_eq!(below_name_distance_of(Some(2.0)), 2.0);
assert_eq!(below_name_distance_of(Some(-1.0)), 0.0);
}
#[test]
fn armor_stand_nameplate_values_override_defaults_and_clamp() {
let mut stand = ArmorStand::new(1, Vec3::new(0.5, 64.0, 0.5), 0.0);
assert_eq!(stand.name_tag_distance(), 64.0);
assert_eq!(stand.below_name_distance(), 10.0);
stand.nameplate = NameplateDistances {
name_tag: Some(5.0),
below_name: Some(2.0),
};
assert_eq!(
(stand.name_tag_distance(), stand.below_name_distance()),
(5.0, 2.0)
);
stand.nameplate.name_tag = Some(600.0);
assert_eq!(stand.name_tag_distance(), 512.0);
}
}
+141 -29
View File
@@ -22,6 +22,8 @@ use horizon_world::{
registry::{BlockRegistry, BlockStateId},
};
use std::{collections::HashMap, sync::Arc};
mod boat_load;
pub use boat_load::boat_from_nbt;
mod projectile_fields;
use projectile_fields::{load_projectile_entity_fields, put_projectile_entity_fields};
mod saved_attributes;
@@ -914,15 +916,7 @@ pub fn mob_to_nbt(
Tag::Int(m.extra.more_carrot_ticks),
);
}
"minecraft:goat" => {
map.insert(
"IsScreamingGoat".into(),
Tag::Byte(i8::from(m.extra.screaming)),
);
let [left, right] = m.extra.lost_horns;
map.insert("HasLeftHorn".into(), Tag::Byte(i8::from(!left)));
map.insert("HasRightHorn".into(), Tag::Byte(i8::from(!right)));
}
"minecraft:goat" => crate::mobs::goat::write_nbt(m, map),
"minecraft:creeper" => {
map.insert("Fuse".into(), Tag::Short(m.max_swell as i16));
map.insert(
@@ -2351,11 +2345,7 @@ pub fn mob_from_nbt_with_random(
_ => 0,
};
}
crate::mobs::Species::Goat => {
m.extra.screaming = flag(tag, "IsScreamingGoat");
let horn = |key| number(tag.get(key)).is_some_and(|v| v == 0);
m.extra.lost_horns = [horn("HasLeftHorn"), horn("HasRightHorn")];
}
crate::mobs::Species::Goat => crate::mobs::goat::read_nbt(&mut m, tag),
_ => {}
}
// `Rabbit.readAdditionalSaveData`.
@@ -2370,8 +2360,8 @@ pub fn mob_from_nbt_with_random(
m.extra.owner = crate::save::uuid_from_tag(tag.get("Owner"));
m.extra.tame = m.extra.owner.is_some();
crate::mobs::tame::restore_sitting(&mut m, flag(tag, "Sitting"));
m.extra.color =
number(tag.get("CollarColor")).map_or(crate::mobs::tame::RED, |c| (c as u8) & 15);
let legacy = |c: i64| u8::try_from(c as i8).ok().filter(|c| *c < 16).unwrap_or(0);
m.extra.color = number(tag.get("CollarColor")).map_or(crate::mobs::tame::RED, legacy);
}
if m.species() == crate::mobs::Species::Panda {
let gene = |key| text(tag.get(key)).map_or(0, |n| crate::mobs::panda::gene_from_name(&n));
@@ -2396,8 +2386,8 @@ pub fn mob_from_nbt_with_random(
.and_then(crate::save::item_from_nbt);
m.extra.body_damage = armor.as_ref().map_or(0, |s| s.damage as i32);
m.extra.body_extra = armor.and_then(|s| s.extra);
m.extra.color =
number(tag.get("CollarColor")).map_or(crate::mobs::tame::RED, |c| (c as u8) & 15);
let legacy = |c: i64| u8::try_from(c as i8).ok().filter(|c| *c < 16).unwrap_or(0);
m.extra.color = number(tag.get("CollarColor")).map_or(crate::mobs::tame::RED, legacy);
}
if matches!(
m.species(),
@@ -2650,7 +2640,7 @@ pub fn mob_from_nbt_with_random(
m.extra.counter = number(tag.get("still_timeout")).unwrap_or(0) as i32;
}
if m.species() == crate::mobs::Species::Shulker {
m.extra.variant3 = (number(tag.get("AttachFace")).unwrap_or(0) % 6).unsigned_abs() as u8;
m.extra.variant3 = (number(tag.get("AttachFace")).unwrap_or(0) as i8 % 6).unsigned_abs();
m.extra.counter = number(tag.get("Peek")).map_or(0, |v| v as i8 as i32);
m.extra.color = number(tag.get("Color")).map_or(16, |v| v as i8 as u8);
m.anim.v[0] = m.extra.counter as f32 * 0.01;
@@ -2911,7 +2901,7 @@ pub fn entity_base_tag(
motion,
rotation,
on_ground: raw.is_some_and(|t| flag(t, "OnGround")),
fall_distance: 0.0,
fall_distance: raw.map_or(0.0, |t| ground_from_nbt(t).1),
}
.tag(raw)
}
@@ -3047,6 +3037,7 @@ pub fn arrow_to_nbt(
}
for (key, value) in [
("Fire", Tag::Short(a.fire_ticks as i16)),
("Air", Tag::Short(p.air_supply as i16)),
("life", Tag::Short(a.life as i16)),
("shake", Tag::Byte(a.shake as i8)),
("inGround", Tag::Byte(i8::from(a.in_ground))),
@@ -3125,6 +3116,7 @@ pub fn arrow_from_nbt(
p.owner_uuid = owner;
p.no_gravity = flag(tag, "NoGravity");
p.on_ground = flag(tag, "OnGround");
projectile_fields::load_arrow_entity_fields(&mut p, tag);
let a = p.arrow.as_mut()?;
a.life = number(tag.get("life")).unwrap_or(0) as i16 as i32;
a.shake = (number(tag.get("shake")).unwrap_or(0) as i8 as u8).into();
@@ -3144,8 +3136,6 @@ pub fn arrow_from_nbt(
_ => false,
})
.cloned();
a.fire_ticks = number(tag.get("Fire")).unwrap_or(0) as i16 as i32;
a.fall_distance = float(tag.get("fall_distance")).unwrap_or(0.0);
a.spectral = spectral;
// `SpectralArrow.readAdditionalSaveData`: absent Duration defaults to 200.
if spectral {
@@ -3472,7 +3462,7 @@ pub fn owner_uuid(tag: &Tag) -> Option<[i32; 4]> {
pub fn fireball_from_nbt(tag: &Tag, player: Option<[i32; 4]>) -> Option<Projectile> {
let kind = match kind(tag)?.as_str() {
"minecraft:fireball" => ProjectileKind::LargeFireball {
power: number(tag.get("ExplosionPower")).unwrap_or(1) as i32,
power: i32::from(number(tag.get("ExplosionPower")).unwrap_or(1) as i8),
},
"minecraft:small_fireball" => ProjectileKind::SmallFireball,
"minecraft:dragon_fireball" => ProjectileKind::DragonFireball,
@@ -3702,9 +3692,6 @@ pub fn vehicle_to_nbt(
}
.tag(raw)
}
pub fn boat_from_nbt(tag: &Tag) -> Option<(Vec3, Vec3, f32)> {
Some((position(tag)?, motion(tag), rotation(tag)[0]))
}
/// `Entity.load`'s `OnGround` (`getBooleanOr`: `byteValue() != 0`) and
/// `fall_distance` (`getDoubleOr`: any number's `doubleValue`), defaulting to
/// false and 0.
@@ -4175,6 +4162,30 @@ mod tests {
.is_none()
);
}
/// `Shulker` reads `AttachFace` as a byte before
/// `Direction.from3DDataValue` (`abs(data % 6)`): an int is narrowed
/// first, so 258 is byte 2, not 258 % 6 = 0, and 250 is byte -6, face 0.
#[test]
fn a_shulkers_attach_face_is_narrowed_to_a_byte_first() {
let mut kind = crate::mobs::sensor_scan_tests::kind();
let ty = Arc::make_mut(&mut kind);
ty.id = "minecraft:shulker".into();
ty.species = crate::mobs::Species::Shulker;
let types = [("minecraft:shulker".into(), kind)].into();
let face = |saved: Tag| {
let raw = Tag::compound([
("id", string("minecraft:shulker")),
("Pos", doubles(&[4.5, 220.0, 4.5])),
("AttachFace", saved),
]);
mob_from_nbt(&raw, &types, 1, None).unwrap().extra.variant3
};
assert_eq!(face(Tag::Byte(3)), 3);
assert_eq!(face(Tag::Byte(-2)), 2);
assert_eq!(face(Tag::Int(258)), 2);
assert_eq!(face(Tag::Int(250)), 0);
}
use super::*;
use crate::items::ItemStack;
@@ -4222,6 +4233,33 @@ mod tests {
assert_eq!(tag.get("UUID"), Some(&Tag::IntArray(vec![9, 9, 9, 9])));
}
/// An entity that never moves (an `AreaEffectCloud`) saves the
/// `fall_distance` `Entity.load` read, and 0 when it was made in play.
#[test]
fn a_base_tag_keeps_the_loaded_fall_distance() {
let raw = horizon_world::nbt::parse_snbt(
"{id:\"minecraft:area_effect_cloud\",Pos:[1d,200d,1d],fall_distance:5.0d}",
)
.unwrap()
.0;
let fall = |raw: Option<&super::Tag>| {
let tag = super::entity_base_tag(
"minecraft:area_effect_cloud",
super::Vec3::new(1.0, 200.0, 1.0),
super::Vec3::ZERO,
[0.0, 0.0],
[1; 4],
raw,
);
match tag.get("fall_distance") {
Some(super::Tag::Double(bits)) => f64::from_bits(*bits),
other => panic!("no fall_distance: {other:?}"),
}
};
assert_eq!(fall(Some(&raw)), 5.0);
assert_eq!(fall(None), 0.0);
}
#[test]
fn empty_saved_tags_are_omitted_without_changing_raw_or_nonempty_tags() {
let base = Base {
@@ -4435,6 +4473,30 @@ mod tests {
assert_eq!(load(Tag::Short(7), Tag::Short(19)), (7, 19));
}
/// `Axolotl.Variant.BY_ID` is `OutOfBoundsStrategy.ZERO`: an id outside
/// 0..=4 is lucy (0). `Parrot.Variant.BY_ID` is `CLAMP`: it is the nearest
/// end.
#[test]
fn an_unknown_axolotl_variant_is_lucy_and_a_parrots_is_clamped() {
let variant = |species, id: &str, saved: i32| {
let mut kind = crate::mobs::sensor_scan_tests::kind();
let ty = Arc::make_mut(&mut kind);
ty.id = id.into();
ty.species = species;
let types = [(id.into(), kind)].into();
let raw = Tag::compound([
("id", string(id)),
("Pos", doubles(&[4.5, 220.0, 4.5])),
("Variant", Tag::Int(saved)),
]);
mob_from_nbt(&raw, &types, 1, None).unwrap().extra.variant
};
let axolotl = |saved| variant(crate::mobs::Species::Axolotl, "minecraft:axolotl", saved);
let parrot = |saved| variant(crate::mobs::Species::Parrot, "minecraft:parrot", saved);
assert_eq!([0, 3, 4, 5, 7, -1].map(axolotl), [0, 3, 4, 0, 0, 0]);
assert_eq!([0, 3, 4, 5, 7, -1].map(parrot), [0, 3, 4, 4, 4, 0]);
}
/// A mount keeps its body armour and a helmet (say
/// `/item replace entity <horse> armor.head with minecraft:iron_helmet`)
/// in their own slots through a save: vanilla holds both (`equipment.body`
@@ -4528,6 +4590,35 @@ mod tests {
assert_eq!(m.extra.head.as_deref(), Some("minecraft:iron_helmet"));
}
/// `Wolf` and `Cat` read `CollarColor` with `DyeColor.LEGACY_ID_CODEC`: a
/// byte through `DyeColor.byId`, whose out-of-range answer is the first
/// colour, white (`OutOfBoundsStrategy.ZERO`). Nothing wraps.
#[test]
fn a_collar_colour_outside_the_sixteen_reads_white() {
for (species, id) in [
(crate::mobs::Species::Wolf, "minecraft:wolf"),
(crate::mobs::Species::Cat, "minecraft:cat"),
] {
let mut kind = crate::mobs::sensor_scan_tests::kind();
let ty = Arc::make_mut(&mut kind);
ty.id = id.into();
ty.species = species;
let types = [(id.into(), kind)].into();
let colour = |saved: Option<Tag>| {
let mut fields = vec![("id", string(id)), ("Pos", doubles(&[4.5, 220.0, 4.5]))];
fields.extend(saved.map(|c| ("CollarColor", c)));
let m = mob_from_nbt(&Tag::compound(fields), &types, 1, None).unwrap();
m.extra.color
};
assert_eq!(colour(Some(Tag::Byte(11))), 11, "{id}");
assert_eq!(colour(Some(Tag::Byte(17))), 0, "{id}: not 17 & 15");
assert_eq!(colour(Some(Tag::Byte(-1))), 0, "{id}: not 15");
// Codec.BYTE narrows an int first: 259 is byte 3.
assert_eq!(colour(Some(Tag::Int(259))), 3, "{id}");
assert_eq!(colour(None), crate::mobs::tame::RED, "{id}");
}
}
fn fused_creeper(fuse: Option<i16>) -> (Mob, Tag) {
let mut kind = crate::mobs::sensor_scan_tests::kind();
let ty = Arc::make_mut(&mut kind);
@@ -4771,6 +4862,7 @@ mod tests {
no_gravity: true,
on_ground: true,
fire_ticks: 0,
fall_distance: 0.0,
sound_event: TRIDENT_HIT_GROUND,
item: &trident,
},
@@ -5776,6 +5868,7 @@ mod tests {
no_gravity: false,
on_ground: false,
fire_ticks: 0,
fall_distance: 0.0,
sound_event: TRIDENT_HIT_GROUND,
item: &trident,
},
@@ -5916,8 +6009,7 @@ fn write_axolotl_memories(m: &Mob, map: &mut Compound) {
/// medium salmon or index 0; a missing bucket flag is false.
fn read_variant(m: &mut Mob, tag: &Tag) {
use crate::mobs::Species as S;
// AbstractFish/Axolotl.readAdditionalSaveData: false when absent.
// Tadpole.setFromBucket ignores the input.
// AbstractFish/Axolotl: false when absent. Tadpole.setFromBucket ignores the input.
if matches!(
m.species(),
S::Cod | S::Salmon | S::Pufferfish | S::TropicalFish | S::Axolotl
@@ -5926,7 +6018,8 @@ fn read_variant(m: &mut Mob, tag: &Tag) {
}
let int = |key| number(tag.get(key)).unwrap_or(0) as i32;
match m.species() {
S::Axolotl | S::Parrot => m.extra.variant = int("Variant").clamp(0, 4) as u8,
S::Parrot => m.extra.variant = int("Variant").clamp(0, 4) as u8, // CLAMP
S::Axolotl => m.extra.variant = ((int("Variant") as u32).min(5) % 5) as u8, // ZERO: lucy
// Rabbit.Variant.LEGACY_CODEC: an unknown id is brown.
S::Rabbit => {
m.extra.variant = match int("RabbitType") {
@@ -9433,6 +9526,25 @@ mod stand_fall_save_tests {
}
}
/// `LargeFireball.readAdditionalSaveData` reads `ExplosionPower` with
/// `getByteOr`: an int tag is narrowed (`(byte)(value & 255)`), so 300 is
/// 44 and 200 is -56.
#[test]
fn a_fireball_narrows_its_explosion_power_to_a_byte() {
for (saved, expected) in [(Tag::Int(300), 44), (Tag::Int(200), -56), (Tag::Byte(4), 4)] {
let input = Tag::compound([
("id", string("minecraft:fireball")),
("Pos", doubles(&[4.5, 205.0, 4.5])),
("ExplosionPower", saved),
]);
let fireball = fireball_from_nbt(&input, None).unwrap();
assert!(
matches!(fireball.kind, ProjectileKind::LargeFireball { power } if power == expected),
"{expected}"
);
}
}
#[test]
fn decoded_stand_fall_distance_survives_save_without_raw_or_with_stale_raw() {
let stale = Tag::compound([("fall_distance", Tag::Double(99.0_f64.to_bits()))]);
@@ -0,0 +1,47 @@
use super::{loaded_motion, position, rotation};
use horizon_core::geometry::Vec3;
use horizon_world::nbt::Tag;
/// `Entity.load` for a record with a saved position: clamp coordinates before
/// rebuilding the box. Reject invalid positions; motion over 10 resets.
pub fn boat_from_nbt(tag: &Tag) -> Option<(Vec3, Vec3, f32)> {
let p = position(tag)?;
let p = Vec3::new(
p.x.clamp(-30_000_512.0, 30_000_512.0),
p.y.clamp(-20_000_000.0, 20_000_000.0),
p.z.clamp(-30_000_512.0, 30_000_512.0),
);
p.is_finite()
.then(|| (p, loaded_motion(tag), rotation(tag)[0]))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn load_clamps_each_position_axis_and_only_motion_over_ten() {
let doubles = |v: [f64; 3]| Tag::List(v.map(|v| Tag::Double(v.to_bits())).to_vec());
for (raw, expected) in [
(
[40_000_000.0, 30_000_000.0, -40_000_000.0],
[30_000_512.0, 20_000_000.0, -30_000_512.0],
),
(
[-40_000_000.0, -30_000_000.0, 40_000_000.0],
[-30_000_512.0, -20_000_000.0, 30_000_512.0],
),
([1.25, -64.0, -3.5], [1.25, -64.0, -3.5]),
] {
let tag = Tag::compound([
("Pos", doubles(raw)),
("Motion", doubles([10.0, -10.0, 10.00001])),
]);
let (p, m, _) = boat_from_nbt(&tag).unwrap();
assert_eq!(p.components(), expected);
assert_eq!(m, Vec3::new(10.0, -10.0, 0.0));
}
let tag = Tag::compound([("Pos", doubles([0.0, f64::NAN, 0.0]))]);
assert!(boat_from_nbt(&tag).is_none());
}
}
+5 -2
View File
@@ -1,5 +1,7 @@
//! Experience orbs: `ExperienceOrb` save data.
use super::{Base, ExperienceOrb, Tag, flag, int_value, motion, number, position, put, rotation};
use super::{
Base, ExperienceOrb, Tag, flag, float, int_value, motion, number, position, put, rotation,
};
/// `ExperienceOrb`: Health, Age, Value, Count.
pub fn orb_to_nbt(o: &ExperienceOrb, uuid: [i32; 4]) -> Tag {
@@ -10,7 +12,7 @@ pub fn orb_to_nbt(o: &ExperienceOrb, uuid: [i32; 4]) -> Tag {
motion: o.velocity,
rotation: o.rotation,
on_ground: o.on_ground,
fall_distance: 0.0,
fall_distance: o.fall_distance,
};
let mut tag = base.tag(None);
for (key, value) in [
@@ -35,6 +37,7 @@ pub fn orbs_from_nbt(tag: &Tag) -> Vec<ExperienceOrb> {
};
let count = number(tag.get("Count")).unwrap_or(1).max(1) as u32;
let orb = ExperienceOrb {
fall_distance: float(tag.get("fall_distance")).unwrap_or(0.0),
rotation: rotation(tag),
join: Default::default(),
no_gravity: flag(tag, "NoGravity"),
@@ -45,3 +45,17 @@ pub(super) fn load_projectile_flags_and_entity_fields(p: &mut Projectile, tag: &
p.no_gravity = flag(tag, "NoGravity");
load_projectile_entity_fields(p, tag);
}
/// `Entity.load` for an arrow: fire and fall distance live on its arrow state,
/// air on the projectile and defaults to the 300 of `getMaxAirSupply`.
pub(super) fn load_arrow_entity_fields(p: &mut Projectile, tag: &Tag) {
p.air_supply = tag.get("Air").and_then(int_value).unwrap_or(300);
if let Some(a) = p.arrow.as_mut() {
a.fire_ticks = i32::from(tag.get("Fire").and_then(int_value).unwrap_or(0) as i16);
a.fall_distance = float(tag.get("fall_distance")).unwrap_or(0.0);
}
}
#[cfg(test)]
#[path = "projectile_fields_tests.rs"]
mod tests;
@@ -0,0 +1,17 @@
use crate::entity_nbt::{arrow_from_nbt, arrow_to_nbt};
use horizon_world::nbt::{Tag, parse_snbt};
/// `Entity.load` reads `Air` and `saveWithoutId` writes it back.
#[test]
fn arrow_keeps_its_saved_air() {
let parse = |text: &str| parse_snbt(text).unwrap().0;
let raw = parse("{id:\"minecraft:arrow\",Pos:[1d,200d,1d],Air:32767s,Fire:7s}");
let (arrow, _) = arrow_from_nbt(&raw, None, |_| None).unwrap();
assert_eq!(arrow.air_supply, 32767);
let saved = arrow_to_nbt(&arrow, [197, 0, 0, 1], None, None, Some(&raw)).unwrap();
assert_eq!(saved.get("Air"), Some(&Tag::Short(32767)));
assert_eq!(saved.get("Fire"), Some(&Tag::Short(7)));
let absent = parse("{id:\"minecraft:arrow\",Pos:[1d,200d,1d]}");
let (fresh, _) = arrow_from_nbt(&absent, None, |_| None).unwrap();
assert_eq!(fresh.air_supply, 300);
}
@@ -1,6 +1,6 @@
//! Thrown tridents: `AbstractArrow`, `Projectile` and `ThrownTrident` save data.
use super::{
Base, Pickup, Tag, Vec3, flag, item_from_nbt, item_to_nbt, motion, number, owner_uuid,
Base, Pickup, Tag, Vec3, flag, float, item_from_nbt, item_to_nbt, motion, number, owner_uuid,
position, put, rotation, string, text,
};
@@ -22,6 +22,8 @@ pub struct TridentSave<'a> {
/// `Entity.onGround`: a trident moves by `setPos`, so it keeps the loaded one.
pub on_ground: bool,
pub fire_ticks: i32,
/// `Entity.fallDistance`.
pub fall_distance: f64,
/// `AbstractArrow.soundEvent`: the sound of its next block hit.
pub sound_event: &'a str,
pub item: &'a crate::items::ItemStack,
@@ -38,7 +40,7 @@ pub fn trident_to_nbt(t: &TridentSave, uuid: [i32; 4], raw: Option<&Tag>) -> Tag
motion: t.motion,
rotation: [t.yaw, t.pitch],
on_ground: t.on_ground,
fall_distance: 0.0,
fall_distance: t.fall_distance,
}
.tag(raw);
// Projectile.addAdditionalSaveData omits LeftOwner until it is true,
@@ -95,6 +97,8 @@ pub struct TridentNbt {
pub no_gravity: bool,
pub on_ground: bool,
pub fire_ticks: i32,
/// `Entity.load`: `getDoubleOr("fall_distance", 0.0)`.
pub fall_distance: f64,
/// `SoundEvent`, or the trident's default when missing or no identifier.
pub sound_event: String,
pub item: crate::items::ItemStack,
@@ -120,6 +124,7 @@ pub fn trident_from_nbt(tag: &Tag) -> Option<TridentNbt> {
no_gravity: flag(tag, "NoGravity"),
on_ground: flag(tag, "OnGround"),
fire_ticks: number(tag.get("Fire")).unwrap_or(0) as i16 as i32,
fall_distance: float(tag.get("fall_distance")).unwrap_or(0.0),
sound_event: text(tag.get("SoundEvent"))
.and_then(|s| horizon_client_core::identifier::normalize_id(&s))
.unwrap_or_else(|| TRIDENT_HIT_GROUND.to_owned()),
+15 -62
View File
@@ -1499,8 +1499,8 @@ impl LevelAccess for GameLevel<'_> {
signal
};
}
if let Some(signal) = books::lectern_signal(self.world, pos) {
return signal * i32::from(books::has_book_state(self.registry, state));
if let Some(signal) = books::lectern_signal(self.world, self.registry, state, pos) {
return signal;
}
if let Some(signal) =
machines::shelf_signal(self.world, self.registry, state, pos, direction)
@@ -3357,11 +3357,10 @@ impl Game<'_> {
fn aim(&self) -> Option<([f64; 3], [f64; 3])> {
// Minecraft.pick: from the camera entity's eyes along its view
// (LivingEntity.getViewYRot: the head's yaw).
if let Some(m) = self.camera_mob() {
return Some((
[m.position.x, m.eye_y(), m.position.z],
pick_view_vector(m.head_yaw, m.head_pitch),
));
if let Some(e) = self.camera_entity() {
let [x, y, z] = e.position;
let eye = [x, y + f64::from(e.eye_height), z];
return Some((eye, pick_view_vector(e.y_rot, e.x_rot)));
}
Some((self.eye_position()?, pick_view_vector(self.yaw, self.pitch)))
}
@@ -3404,7 +3403,7 @@ impl Game<'_> {
Some(b)
}
fn outline_context_kind(&self) -> horizon_world::collision_context::EntityContextKind {
if self.camera_mob().is_some() {
if self.camera_entity().is_some() {
horizon_world::collision_context::EntityContextKind::Living
} else {
horizon_world::collision_context::EntityContextKind::Player
@@ -5088,11 +5087,11 @@ impl Game<'_> {
if removed && !creative {
let held = self.held().cloned();
self.mine_block_with_held(&facts.name, facts.hardness);
let harvest = !facts.requires_tool
|| self
.res
.items()
.correct_for_drops(held.as_ref(), &facts.name);
let harvest = self.res.items().has_correct_tool_for_drops(
held.as_ref(),
facts.requires_tool,
&facts.name,
);
if harvest {
// Block.playerDestroy: the mined statistic, the exhaustion and the drops.
self.award_stat("mined", &facts.name, 1);
@@ -7206,8 +7205,7 @@ impl Game<'_> {
if particle_mode == horizon_client::graphics::ParticleCullingMode::Off {
self.particles.render(
camera,
self.yaw,
self.pitch,
self.particle_rotation(),
alpha as f32,
&|b| world.light(b[0], b[1], b[2]),
&mut out,
@@ -7226,8 +7224,7 @@ impl Game<'_> {
let mut particle_culling = self.particle_occlusion_context(camera);
self.particles.render_culled(
camera,
self.yaw,
self.pitch,
self.particle_rotation(),
alpha as f32,
pixel_focal_length,
true,
@@ -8323,22 +8320,6 @@ impl Game<'_> {
}
self.tracked_special_box(id as i32)
}
/// The renderer for the window, with the current block sprites, GUI
/// atlas and world border texture.
/// A new renderer's atlas holds only the pack's sprites: what was drawn into it at run time
/// (skins, maps, the themed UI's glyphs and corners) has to be drawn again.
pub(super) fn forget_atlas_slots(&mut self) {
self.skin_slots.forget();
self.maps.slots.forget();
self.trim_slots.forget();
self.map_patches.clear();
self.kept_block_entities.get_mut().clear();
horizon_client::ui_theme::reset_atlas();
}
fn new_renderer(&self, window: Arc<Window>) -> Result<Renderer> {
self.renderer_for(window, &self.pack, &self.gui)
}
/// Rebuild GPU-owned state after the backend reports device loss.
fn recover_device(&mut self) -> Result<()> {
if !self.renderer.as_ref().is_some_and(Renderer::device_lost) {
@@ -8401,34 +8382,6 @@ impl Game<'_> {
log::info!("Horizon: graphics device recovered; rebuilding world meshes");
Ok(())
}
/// A renderer made again from the resources loaded takes over: what named the old one's
/// atlas slots is forgotten, the world is meshed again, and the shader pack and the
/// settings are applied again.
pub(super) fn adopt_renderer(&mut self, mut renderer: Renderer) {
// A new renderer starts with the vanilla column and without the world border's wall.
renderer.clear_sections(self.world.layout());
self.border_renderer.invalidate();
self.renderer = Some(renderer);
self.forget_atlas_slots();
self.clear_lod();
self.pending_uploads.clear();
let context = self.worker_context();
self.streaming.reload_resources(&mut self.world, context);
self.native_shaders = native_shaders::NativeShaders::picking(&std::mem::take(
&mut self.native_shaders.picked,
));
self.apply_ui_settings();
}
/// `LevelExtractor.allChanged`: displayed sections and mesh work are
/// discarded, then every loaded section is rebuilt.
fn all_sections_changed(&mut self) {
self.clear_lod();
self.pending_uploads.clear();
if let Some(renderer) = self.renderer.as_mut() {
renderer.clear_sections(self.world.layout());
}
self.streaming.reload_sections(&mut self.world);
}
/// A translated message for the player the game acts for: `overlay` is
/// `sendOverlayMessage` (the action bar), else `sendSystemMessage` (chat).
/// A guest gets a packet, so its own client translates it.
@@ -9112,7 +9065,7 @@ impl Game<'_> {
// What faces the camera (particles, item sprites, block entity text) faces a mod's.
// A spectated entity's view is not the local player's yaw and pitch
// (`Camera.setup` reads the camera entity), so it is kept too.
let spectating = self.camera_mob().is_some();
let spectating = self.camera_entity().is_some();
self.posed_rotation = view
.filter(|posed| spectating || Some(*posed) != vanilla)
.map(|(_, yaw, pitch)| (yaw, pitch));
@@ -533,7 +533,8 @@ impl Game<'_> {
let reach: f64 = self.entity_reach;
let mut best: Option<(usize, f64, [f64; 3])> = None;
for (i, a) in self.armor_stands.iter().enumerate() {
if a.marker {
// Minecraft.pick never hits the camera entity it looks from.
if a.marker || self.remote_camera_id() == Some(a.id) {
continue;
}
let b = a.bounds();
@@ -1437,7 +1438,11 @@ impl Game<'_> {
) {
// The stands' models posed on the GPU path (`mobs::skin`), when it runs this frame.
let skinning = self.skinning();
for a in &self.armor_stands {
for a in self
.armor_stands
.iter()
.filter(|a| !self.hides_as_camera(a.id))
{
// ArmorStand.shouldRenderAtSqrDistance: 256 times the box's mean
// edge (4 for a marker's empty box), whatever the entity distance.
// LivingEntityRenderer grows the culling box by 0.5 for a dragon head.
+115 -25
View File
@@ -15,6 +15,7 @@ use horizon_client::{
mesher::Vertex,
mobs::render::{DYE, DYE_NAMES},
test_instance_block::Status,
text::Text,
};
use horizon_protocol::packets::play::{self, Serverbound as S};
@@ -30,12 +31,15 @@ pub(super) const EFFECTS: [(&str, i32); 6] = [
("strength", 3),
("regeneration", 4),
];
const PAYMENT: [&str; 5] = [
"minecraft:iron_ingot",
"minecraft:gold_ingot",
"minecraft:emerald",
"minecraft:diamond",
"minecraft:netherite_ingot",
/// `BeaconScreen.extractBackground`'s payment row in drawing order, with each
/// item's x: netherite, emerald, diamond, gold, iron. Not by value: the
/// emerald is left of the diamond.
const PAYMENT_ROW: [(&str, f32); 5] = [
("minecraft:netherite_ingot", 20.0),
("minecraft:emerald", 41.0),
("minecraft:diamond", 63.0),
("minecraft:gold_ingot", 86.0),
("minecraft:iron_ingot", 108.0),
];
/// `BeaconMenu.PaymentSlot` and the player inventory, relative to the 176 origin.
pub(super) fn slot_position(i: usize) -> Option<(f32, f32)> {
@@ -73,6 +77,43 @@ fn upgrade_button() -> (f32, f32) {
fn inside(p: (f32, f32), x: f32, y: f32, w: f32, h: f32) -> bool {
p.0 >= x && p.1 >= y && p.0 < x + w && p.1 < y + h
}
/// `BeaconPowerButton.setEffect`'s tooltip: the effect's description, and for
/// `BeaconUpgradePowerButton` (`createEffectDescription`) " II" after it.
fn effect_tooltip(e: usize, upgraded: bool) -> Text {
let effect = Text::translate(&format!("effect.minecraft.{}", EFFECTS[e].0), &[]);
if upgraded {
effect.append(" II")
} else {
effect
}
}
/// `BeaconScreen`: every button's tooltip whichever one the cursor is over
/// (`BeaconPowerButton`'s its effect, `BeaconSpriteScreenButton`'s its label),
/// hovered whether or not it is active.
fn hovered_tooltip(
local: (f32, f32),
primary: Option<usize>,
buttons: &[(usize, f32, f32, bool)],
) -> Option<Text> {
for &(e, x, y, _) in buttons {
if inside(local, x, y, 22.0, 22.0) {
return Some(effect_tooltip(e, false));
}
}
if let Some(p) = primary {
let (x, y) = upgrade_button();
if inside(local, x, y, 22.0, 22.0) {
return Some(effect_tooltip(p, true));
}
}
if inside(local, 164.0, 107.0, 22.0, 22.0) {
return Some(Text::translate("gui.done", &[]));
}
if inside(local, 190.0, 107.0, 22.0, 22.0) {
return Some(Text::translate("gui.cancel", &[]));
}
None
}
fn effect_id(name: &str) -> Option<&'static str> {
let n = name.strip_prefix("minecraft:").unwrap_or(name);
EFFECTS.iter().map(|(e, _)| *e).find(|e| *e == n)
@@ -502,12 +543,12 @@ impl Game<'_> {
false,
);
}
for (i, item) in PAYMENT.iter().rev().enumerate() {
let x = [20.0, 41.0, 63.0, 86.0, 108.0][i];
for (item, x) in PAYMENT_ROW {
self.draw_item(batch, &ItemStack::new(item, 1), bx + x, by + 109.0);
}
let atlas = &self.gui.atlas;
let mut tooltip = None;
let buttons = buttons();
let tooltip = hovered_tooltip(local, sel[0], &buttons);
let button =
|batch: &mut HudBatch, x: f32, y: f32, active: bool, selected: bool, icon: &str| {
let hover = inside(local, x, y, 22.0, 22.0);
@@ -535,9 +576,8 @@ impl Game<'_> {
18.0,
u32::MAX,
);
hover
};
for (e, x, y, primary) in buttons() {
for (e, x, y, primary) in buttons {
let tier = EFFECTS[e].1;
let active = if primary {
tier <= levels && tier < 4
@@ -550,16 +590,12 @@ impl Game<'_> {
sel[1] == Some(e)
};
let icon = format!("mob_effect/{}", EFFECTS[e].0);
if button(batch, x, y, active, selected, &icon) {
tooltip = Some(format!("effect.minecraft.{}", EFFECTS[e].0));
}
button(batch, x, y, active, selected, &icon);
}
if let Some(p) = sel[0] {
let (x, y) = upgrade_button();
let icon = format!("mob_effect/{}", EFFECTS[p].0);
if button(batch, x, y, levels >= 4, sel[1] == Some(p), &icon) {
tooltip = Some(format!("effect.minecraft.{}", EFFECTS[p].0));
}
button(batch, x, y, levels >= 4, sel[1] == Some(p), &icon);
}
button(
batch,
@@ -597,14 +633,8 @@ impl Game<'_> {
}
} 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 {
let text = self.translate(&key, &key);
self.draw_tooltip_lines(
batch,
&[(horizon_client::text::Text::literal(text), u32::MAX)],
self.cursor.0,
self.cursor.1,
);
} else if let Some(text) = tooltip {
self.draw_tooltip_lines(batch, &[(text, u32::MAX)], self.cursor.0, self.cursor.1);
}
}
/// `BeaconRenderer`: each section's rotating solid beam and its glow.
@@ -707,6 +737,66 @@ fn effect_box_hits(p: [i32; 3], range: f64, height: f64, lo: [f64; 3], hi: [f64;
#[cfg(test)]
mod tests {
use super::*;
/// `BeaconScreen.extractBackground`: netherite 20, emerald 41, diamond
/// 41+22, gold 42+44, iron 42+66.
#[test]
fn the_payment_row_reads_netherite_emerald_diamond_gold_iron() {
assert_eq!(
PAYMENT_ROW,
[
("minecraft:netherite_ingot", 20.0),
("minecraft:emerald", 41.0),
("minecraft:diamond", 63.0),
("minecraft:gold_ingot", 86.0),
("minecraft:iron_ingot", 108.0),
]
);
}
/// `BeaconUpgradePowerButton.createEffectDescription` appends " II"; the
/// primary buttons' description is the bare effect.
#[test]
fn only_the_upgrade_button_names_the_second_level() {
for e in 0..EFFECTS.len() {
let primary = effect_tooltip(e, false).plain();
let upgrade = effect_tooltip(e, true).plain();
assert!(!primary.is_empty());
assert!(!primary.ends_with(" II"));
assert_eq!(upgrade, format!("{primary} II"));
}
}
/// `BeaconScreenButton.setTooltip`: every button has one while hovered,
/// and the sprite buttons' is their label whether or not they are active
/// (the tick is disabled without a payment and a chosen power).
#[test]
fn every_hovered_beacon_button_shows_its_tooltip() {
let at = |(x, y): (f32, f32)| (x + 11.0, y + 11.0);
let name =
|e: usize| horizon_client::lang::get(&format!("effect.minecraft.{}", EFFECTS[e].0));
let buttons = buttons();
for &(e, x, y, _) in &buttons {
let tip = hovered_tooltip(at((x, y)), None, &buttons).unwrap_or_default();
assert_eq!(tip.plain(), name(e), "button {e}");
}
let upgrade = at(upgrade_button());
assert!(hovered_tooltip(upgrade, None, &buttons).is_none());
assert_eq!(
hovered_tooltip(upgrade, Some(0), &buttons).unwrap().plain(),
format!("{} II", name(0))
);
assert_eq!(
hovered_tooltip(at((164.0, 107.0)), None, &buttons)
.unwrap_or_default()
.plain(),
horizon_client::lang::get("gui.done")
);
assert_eq!(
hovered_tooltip(at((190.0, 107.0)), Some(0), &buttons)
.unwrap_or_default()
.plain(),
horizon_client::lang::get("gui.cancel")
);
assert!(hovered_tooltip((0.0, 0.0), Some(0), &buttons).is_none());
}
#[test]
fn only_active_beacons_have_beams() {
let mut b = Beacon::default();
@@ -131,6 +131,10 @@ pub(super) trait Editor {
fn accepts(&self, _edit: usize, _current: &str, _cursor: usize, _c: char) -> bool {
true
}
/// The box's tooltip (`EditBox.setTooltip`), if it has one.
fn edit_tooltip(&self, _edit: usize) -> Option<Text> {
None
}
/// Whether Enter is Done (a screen with its own `keyPressed`).
fn enter_confirms(&self) -> bool {
true
@@ -171,6 +175,41 @@ impl BlockEditScreen {
}
}
/// `WidgetTooltipHolder.refreshTooltipForNextRenderPass`: hovering or keyboard focus shows it.
fn edit_tooltip(
atlas: &horizon_client::hud::HudAtlas,
editor: &dyn Editor,
items: &[Item],
cursor: (f32, f32),
focus: Option<usize>,
keyboard: bool,
virtual_pointer: bool,
) -> Option<(Vec<Text>, [f32; 4], bool)> {
let mut hovered = None;
for item in items {
if let Item::Edit { index, x, y, w } = item {
let hover = !virtual_pointer && ui::hovered(cursor, *x, *y, *w, 20.0);
let focused = keyboard && focus == Some(*index);
if (hover || focused)
&& let Some(tip) = editor.edit_tooltip(*index)
{
// `Tooltip.splitTooltip` uses 170 GUI pixels, including translated text.
let tooltip = (
atlas.font.split(&tip, 170.0),
[*x, *y, *w, 20.0],
focused && !hover,
);
// `GuiGraphicsExtractor.setTooltipForNextFrame`: the focused widget wins.
if focused {
return Some(tooltip);
}
hovered = Some(tooltip);
}
}
}
hovered
}
impl Game<'_> {
/// `LocalPlayer.open*Block`: the edit screen for the block entity at
/// `pos`.
@@ -243,6 +282,11 @@ impl Game<'_> {
match outcome {
Outcome::Stay => {}
Outcome::Send(packet) => {
// The integrated server is this game: vanilla's packet handler
// applies the edit there, so a local game applies it here.
if self.remote.is_none() {
self.apply_local_editor_packet(&packet);
}
self.send(packet);
self.close_block_editor();
}
@@ -256,6 +300,14 @@ impl Game<'_> {
}
}
/// The server side of an editor's packet, for the game that is its own
/// server: `handleSetJigsawBlock` and its siblings run on the server.
fn apply_local_editor_packet(&mut self, packet: &S) {
if let S::SetJigsawBlock(p) = packet {
self.apply_set_jigsaw_block(p);
}
}
pub(super) fn draw_block_editor(&mut self, batch: &mut HudBatch) {
let cursor = self.cursor;
let Some(items) = self.with_block_editor(|s, e| {
@@ -354,6 +406,29 @@ impl Game<'_> {
}
}
}
// The focus navigator's pointer is hidden, not an actual mouse hover.
let navigation_focus = self.pad_focus_rect();
let focus = navigation_focus.map_or(screen.focus, |rect| {
items.iter().find_map(|item| match item {
Item::Edit { index, x, y, w } if [*x, *y, *w, 20.0] == rect => Some(*index),
_ => None,
})
});
if let Some((lines, rect, focused)) = edit_tooltip(
atlas,
screen.editor.as_ref(),
&items,
cursor,
focus,
self.narration.input_type().is_keyboard(),
navigation_focus.is_some(),
) {
if focused {
ui::tooltip_focused(batch, atlas, &lines, rect);
} else {
ui::tooltip_menu(batch, atlas, &lines, cursor, rect);
}
}
self.block_editor = Some(screen);
}
@@ -643,6 +718,107 @@ pub(super) fn on_off(on: bool) -> Text {
mod tests {
use super::*;
struct TooltipEditor;
impl Editor for TooltipEditor {
fn edits(&mut self) -> &mut [EditBox] {
&mut []
}
fn items(&self, _entity: &BlockEntity, _w: f32, _h: f32) -> Vec<Item> {
Vec::new()
}
fn press(
&mut self,
_entity: &mut BlockEntity,
_pos: Pos,
_id: u8,
_shift: bool,
_alt: bool,
) -> Outcome {
Outcome::Stay
}
fn done(&mut self, _entity: &mut BlockEntity, _pos: Pos) -> Outcome {
Outcome::Stay
}
fn cancel(&mut self, _entity: &mut BlockEntity) {}
fn edit_tooltip(&self, edit: usize) -> Option<Text> {
match edit {
4 => Some(Text::literal(
"A long explanation of the selection priority that must wrap without losing any of its words.",
)),
5 => Some(Text::literal("Placement priority")),
_ => None,
}
}
}
#[test]
fn edit_tooltips_follow_hover_keyboard_and_hidden_navigation_pointer() {
let atlas = horizon_client::hud::HudAtlas::boot();
let items = [
Item::Edit {
index: 0,
x: 7.0,
y: 20.0,
w: 300.0,
},
Item::Edit {
index: 5,
x: 110.0,
y: 160.0,
w: 98.0,
},
Item::Edit {
index: 4,
x: 7.0,
y: 160.0,
w: 98.0,
},
];
let get = |cursor, focus, keyboard, virtual_pointer| {
edit_tooltip(
&atlas,
&TooltipEditor,
&items,
cursor,
focus,
keyboard,
virtual_pointer,
)
};
let (lines, rect, focused) = get((20.0, 165.0), None, false, false).unwrap();
assert_eq!(rect, [7.0, 160.0, 98.0, 20.0]);
assert!(!focused);
assert!(lines.len() > 1);
assert!(
lines
.iter()
.all(|line| atlas.font.width_text(line) <= 170.0)
);
assert_eq!(
lines.iter().map(Text::plain).collect::<Vec<_>>().join(" "),
"A long explanation of the selection priority that must wrap without losing any of its words."
);
assert!(get((0.0, 0.0), Some(4), true, false).unwrap().2);
assert!(get((0.0, 0.0), Some(4), false, false).is_none());
// A real hover uses the menu positioner, even when the field has keyboard focus.
assert!(!get((20.0, 165.0), Some(4), true, false).unwrap().2);
// Keyboard/controller navigation puts a hidden pointer in the field's center.
assert!(get((56.0, 170.0), Some(4), true, true).unwrap().2);
assert!(get((56.0, 170.0), None, true, true).is_none());
assert!(get((20.0, 25.0), Some(0), true, false).is_none());
assert!(get((105.0, 180.0), None, false, false).is_none());
// A keyboard-focused field overrides a different field's real hover.
let (focused_lines, focused_rect, focused) =
get((120.0, 165.0), Some(4), true, false).unwrap();
assert_eq!(
focused_lines.iter().map(Text::plain).collect::<Vec<_>>(),
lines.iter().map(Text::plain).collect::<Vec<_>>()
);
assert_eq!(focused_rect, rect);
assert!(focused);
}
#[test]
fn name_characters_follow_screen_is_valid_character_for_name() {
assert!(valid_character_for_name("", 'a', 0));
@@ -667,4 +843,46 @@ mod tests {
assert_eq!(cycle(&[1, 2, 3], 9, 1), 1);
assert_eq!(cycle(&[1, 2, 3], 9, -1), 2);
}
/// A single-player jigsaw edit is applied by the game that is its own
/// server, as `handleSetJigsawBlock` does there, and marks the chunk.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_local_jigsaw_edit_applies_and_marks_the_chunk() {
let mut game = crate::game::native_machines::tests::server_game();
let pos = (2, 4, 3);
let state = game
.pack
.assets
.registry
.default_state("minecraft:jigsaw")
.unwrap();
game.world.set(pos.0, pos.1, pos.2, state).unwrap();
game.world
.block_entities
.insert(pos, BlockEntity::JigsawBlock(Box::default()));
game.world.modified.clear();
let packet = S::SetJigsawBlock(horizon_protocol::packets::play::SetJigsawBlock {
pos: [2, 4, 3],
name: "minecraft:test".into(),
target: "minecraft:test".into(),
pool: "minecraft:empty".into(),
final_state: "minecraft:air".into(),
joint: "aligned".into(),
selection_priority: 1,
placement_priority: 2,
});
game.block_editor_outcome(Outcome::Send(packet));
let Some(BlockEntity::JigsawBlock(b)) = game.world.block_entities.get(&pos) else {
panic!("no jigsaw");
};
assert_eq!(&*b.name, "minecraft:test", "the edit landed");
assert_eq!((b.selection_priority, b.placement_priority), (1, 2));
assert!(
game.world.modified.contains(&(0, 0)),
"the edit left the chunk unmarked"
);
}
}
@@ -576,6 +576,10 @@ impl Game<'_> {
let full = count == b.max_passengers();
let delta = b.delta_rotation;
let boat_box = b.bounds();
let ridden = horizon_client::motion::RiddenBoat {
under_water: b.under_water(),
max_y: boat_box.max_position().y,
};
let seats: Vec<_> = mobs
.iter()
.enumerate()
@@ -608,6 +612,7 @@ impl Game<'_> {
for (j, offset, at, previous, facing) in seats {
let m = &mut self.mobs[j];
m.seated(id, offset, boat_box);
m.fluids.boat = Some((id, ridden));
m.position = at;
m.previous = previous;
m.velocity = Vec3::ZERO;
+19 -13
View File
@@ -565,7 +565,10 @@ impl Game<'_> {
if f.fluid == Fluid::None || fluid.is_some_and(|want| f.fluid != want) {
return None;
}
let above = self.res.facts().get(self.world.block(x, y + 1, z));
let above = self
.res
.facts()
.get(self.world.block(x, y.wrapping_add(1), z));
// FluidState.getHeight: 1 under the same fluid, else amount / 9.
let height = if above.fluid == f.fluid {
1.0
@@ -597,7 +600,7 @@ impl Game<'_> {
return y as f32 + height;
}
}
(y1 + 1) as f32
(i64::from(y1) + 1) as f32
}
fn boat_status(&self, b: &mut Boat) -> Status {
let bb = b.bounds();
@@ -660,12 +663,12 @@ impl Game<'_> {
);
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,
(g_lo.x.floor() - 1.0) as i32,
(g_hi.x.ceil() + 1.0) as i32,
(g_lo.y.floor() - 1.0) as i32,
(g_hi.y.ceil() + 1.0) as i32,
(g_lo.z.floor() - 1.0) as i32,
(g_hi.z.ceil() + 1.0) as i32,
);
let slab = VoxelShape::from_box(ground).unwrap_or_else(|_| VoxelShape::empty());
let mut scene = SceneCollisions {
@@ -740,7 +743,7 @@ impl Game<'_> {
if moved_y < 0.0 {
let below = self.res.facts().get(self.world.block(
b.position.x.floor() as i32,
b.position.y.floor() as i32 - 1,
(b.position.y.floor() as i32).wrapping_sub(1),
b.position.z.floor() as i32,
));
if below.fluid != Fluid::Water {
@@ -980,7 +983,7 @@ impl Game<'_> {
if driven.contains(&id) {
self.boats[i].wear_off_hurt();
}
if self.boat_fire_tick(i) {
if self.boat_fire_tick(i) || self.boat_below_world(i) {
break 'boat;
}
}
@@ -1099,9 +1102,12 @@ impl Game<'_> {
));
let (lo, hi) = (b.min_position(), b.max_position());
let mut columns = Vec::new();
for x in lo.x.floor() as i32..=(hi.x.ceil() as i32 - 1) {
for y in lo.y.floor() as i32..=(hi.y.ceil() as i32 - 1) {
for z in lo.z.floor() as i32..=(hi.z.ceil() as i32 - 1) {
// Entity.checkInsideBlocks floors both ends of the deflated box.
// Narrowing ceil(max) before subtracting one wraps at i32::MIN,
// turning a tiny box into a scan of the entire integer Y domain.
for x in lo.x.floor() as i32..=hi.x.floor() as i32 {
for y in lo.y.floor() as i32..=hi.y.floor() as i32 {
for z in lo.z.floor() as i32..=hi.z.floor() as i32 {
if let Some(column) = bubble_column_at(
&self.world,
self.pack.assets.registry,
@@ -107,4 +107,42 @@ mod tests {
.sum();
assert_eq!(spilled, 3);
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn saved_deep_end_boat_loads_then_is_discarded_and_extreme_scans_terminate() {
use super::super::Boat;
use horizon_client::{chunk::Chunk, motion::PlayerMotion};
use horizon_core::geometry::Vec3;
use horizon_world::nbt::Tag;
let mut g = crate::game::native_machines::tests::server_game();
g.world.shape.min_y = 0;
let doubles = |v: &[f64]| Tag::List(v.iter().map(|d| Tag::Double(d.to_bits())).collect());
let tag = Tag::compound([
(
"Pos",
doubles(&[-77.75688596047263, -2146769274.8915758, -55.6734678353711]),
),
("Motion", doubles(&[-2e-323, -13104.999899947774, -2e-323])),
]);
g.load_boat(tag, None, "minecraft:oak_boat");
assert_eq!(g.boats.len(), 1);
assert_eq!(g.boats[0].position.y, -20_000_000.0);
assert_eq!(g.boats[0].velocity, Vec3::new(-2e-323, 0.0, -2e-323));
g.player = Some(PlayerMotion::new(g.motion, Vec3::new(-77.75, 64.0, -55.67)).unwrap());
g.world
.chunks
.insert((-5, -4), std::sync::Arc::new(Chunk::empty(-5, -4)));
g.update_chunk_tickets();
assert!(g.entity_ticking_area()(-78, -56));
g.tick_boats();
assert!(g.boats.is_empty());
for y in [f64::from(i32::MIN) - 1.0, f64::from(i32::MAX) + 1.0] {
g.boats
.push(Boat::new(2, "oak".into(), Vec3::new(0.5, y, 0.5), 0.0));
assert!(g.boat_bubble_columns(0).is_empty());
assert_eq!(g.boat_ground_friction(g.boats[0].bounds()), 0.0);
g.boats.clear();
}
}
}
+93 -45
View File
@@ -5,6 +5,7 @@
//! with a redstone pulse, the comparator output and taking the book).
use super::{Game, chat::PLAYER_NAME, lan::LanHost, screen_info};
use horizon_client::{
block_entity_item,
containers::{BlockEntity, Lectern},
first_person::Hand,
hud::{HudBatch, rgba},
@@ -18,8 +19,7 @@ use horizon_client::{
};
use horizon_core::position::BlockPos;
use horizon_protocol::packets::play::{self, Clientbound as C, Serverbound as S};
use horizon_world::level::flags as level_flags;
use horizon_world::nbt::Tag;
use horizon_world::{level::flags as level_flags, nbt::Tag, registry::BlockStateId};
use winit::{
event::{ElementState, KeyEvent},
keyboard::{KeyCode, PhysicalKey},
@@ -914,6 +914,32 @@ impl Game<'_> {
self.set_lectern_powered(key, false);
}
}
/// `LecternBlock.getStateForPlacement`: a lectern placed from an item
/// whose `block_entity_data` holds a `Book`, by a placer who may use
/// game-master blocks, starts with `has_book` on. It runs before the
/// item's `block_state` component, which vanilla applies afterwards and
/// which may set `has_book` back. Returns the state to go on with.
pub(super) fn lectern_placed(
&mut self,
pos: [i32; 3],
state: BlockStateId,
stack: &ItemStack,
game_master: bool,
) -> horizon_client::Result<BlockStateId> {
if !game_master
|| !block_entity_item::carries_book(stack)
|| &*self.res.facts().get(state).name != "minecraft:lectern"
{
return Ok(state);
}
match self.with_props(state, &[("has_book", "true")]) {
Some(new) if new != state => {
self.level_set(pos, new)?;
Ok(new)
}
_ => Ok(state),
}
}
/// `LecternBlock.useItemOn`: a book goes onto an empty lectern.
pub(super) fn use_lectern(&mut self, pos: [i32; 3], held: Option<&ItemStack>) -> bool {
let key = (pos[0], pos[1], pos[2]);
@@ -1686,20 +1712,25 @@ impl Game<'_> {
}
}
}
/// `LecternBlock.getAnalogOutputSignal`'s `HAS_BOOK` check.
pub(super) fn has_book_state(
registry: &'static horizon_world::registry::BlockRegistry,
state: horizon_world::registry::BlockStateId,
) -> bool {
registry
.state(state)
.is_some_and(|s| s.properties.get("has_book").is_some_and(|v| v == "true"))
}
/// `LecternBlockEntity.getRedstoneSignal`.
pub(super) fn lectern_signal(world: &horizon_client::world::World, pos: BlockPos) -> Option<i32> {
/// `LecternBlock.getAnalogOutputSignal` and
/// `LecternBlockEntity.getRedstoneSignal`: the state's `has_book` gates the
/// answer, and an entity with no book reads 14 (a page count of 0 is a page
/// progress of 1.0 with no book to add).
pub(super) fn lectern_signal(
world: &horizon_client::world::World,
registry: &horizon_world::registry::BlockRegistry,
state: BlockStateId,
pos: BlockPos,
) -> Option<i32> {
let s = registry.state(state)?;
if s.name != "minecraft:lectern" {
return None;
}
if s.properties.get("has_book").is_none_or(|v| v != "true") {
return Some(0);
}
match world.block_entities.get(&(pos.x, pos.y, pos.z)) {
Some(BlockEntity::Lectern(l)) => Some(l.book.as_ref().map_or(0, |b| {
Some(BlockEntity::Lectern(l)) => Some(l.book.as_ref().map_or(14, |b| {
let data = b.data();
let count = data
.and_then(|d| d.written.as_ref().map(|w| w.pages.len()))
@@ -1809,6 +1840,10 @@ mod lectern_books_tag_tests {
}
}
#[cfg(test)]
#[path = "lectern_tests.rs"]
mod lectern_tests;
#[cfg(test)]
mod lectern_signal_tests {
use super::super::native_machines::tests::server_game;
@@ -1822,19 +1857,26 @@ mod lectern_signal_tests {
use horizon_world::level::LevelAccess;
use horizon_world::registry::BlockStateId;
fn signal(game: &mut super::Game<'_>, book: Option<ItemStack>, page: i32) -> i32 {
fn signal(
game: &mut super::Game<'_>,
has_book: bool,
book: Option<ItemStack>,
page: i32,
) -> i32 {
let key = (8, 64, 8);
let state: BlockStateId = game
.pack
.assets
.registry
.default_state("minecraft:lectern")
.unwrap();
let registry = game.pack.assets.registry;
let default = registry.default_state("minecraft:lectern").unwrap();
let mut state = registry.state(default).unwrap().clone();
state
.properties
.insert("has_book".into(), has_book.to_string());
let state: BlockStateId = registry.id(&state).unwrap();
game.world.set(key.0, key.1, key.2, state).unwrap();
game.world
.block_entities
.insert(key, BlockEntity::Lectern(Lectern { book, page }));
lectern_signal(&game.world, BlockPos::new(key.0, key.1, key.2)).unwrap()
let pos = BlockPos::new(key.0, key.1, key.2);
lectern_signal(&game.world, registry, state, pos).unwrap()
}
/// `LecternBlock.getAnalogOutputSignal` reads the block entity only while
@@ -1886,30 +1928,19 @@ mod lectern_signal_tests {
);
}
/// `LecternBlockEntity.getRedstoneSignal`: `floor(page / (pageCount - 1)
/// * 14) + (hasBook() ? 1 : 0)`. `hasBook()` asks for a book-content
/// component, so a stack that only sits in the lectern (a non-book item a
/// command left in `Book`) reads 14, not the book's 15.
/// `LecternBlock.getAnalogOutputSignal` asks for the state's `HAS_BOOK`
/// first, so a lectern without it reads 0 whatever its block entity holds;
/// with it, `LecternBlockEntity.getRedstoneSignal` is `floor(page /
/// (pageCount - 1) * 14) + (hasBook() ? 1 : 0)`. `hasBook()` asks for a
/// book-content component, so a stack that only sits in the lectern (a
/// non-book item a command left in `Book`) reads 14, not the book's 15.
/// An entity with no book at all reads 14 too: a page count of 0 is a page
/// progress of 1.0 with no book to add.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_lecterns_signal_adds_one_only_for_a_book() {
let mut game = server_game();
assert_eq!(signal(&mut game, None, 0), 0, "no book");
assert_eq!(
signal(&mut game, Some(ItemStack::new("minecraft:stone", 1)), 0),
14,
"a non-book stack has no book content"
);
// `writable_book_content` is a writable book's default component.
assert_eq!(
signal(
&mut game,
Some(ItemStack::new("minecraft:writable_book", 1)),
0
),
15,
"a writable book is a book"
);
let writable = Some(ItemStack::new("minecraft:writable_book", 1));
let mut written = ItemStack::new("minecraft:written_book", 1);
written.extra = Some(Box::new(StackData {
written: Some(WrittenBook {
@@ -1920,8 +1951,25 @@ mod lectern_signal_tests {
}),
..Default::default()
}));
assert_eq!(signal(&mut game, Some(written.clone()), 0), 1, "page 0");
assert_eq!(signal(&mut game, Some(written), 1), 15, "last page");
// `has_book=false`: no signal, whatever the block entity holds.
assert_eq!(signal(&mut game, false, None, 0), 0);
assert_eq!(
signal(&mut game, false, writable.clone(), 0),
0,
"state denies"
);
// `has_book=true`, an entity with no book: page progress 1.0.
assert_eq!(signal(&mut game, true, None, 0), 14, "no book");
let stone = Some(ItemStack::new("minecraft:stone", 1));
assert_eq!(signal(&mut game, true, stone, 0), 14, "no book content");
// `writable_book_content` is a writable book's default component.
assert_eq!(signal(&mut game, true, writable, 0), 15, "a book");
assert_eq!(
signal(&mut game, true, Some(written.clone()), 0),
1,
"page 0"
);
assert_eq!(signal(&mut game, true, Some(written), 1), 15, "last page");
}
}
impl BookScreen {
@@ -109,6 +109,7 @@ impl Game<'_> {
return;
};
let file = multiplayer::servers_file(&saves());
let _guard = servers::lock();
let (mut list, mut hidden) = servers::load_all(&file);
let mut found = false;
for entry in list.iter_mut().filter(|s| &s.ip == address) {
+77 -21
View File
@@ -37,6 +37,14 @@ struct Entry {
/// (`DamageSource.directEntity`: a potion with no thrower, a cloud).
direct: Option<Text>,
}
struct PendingAttacker {
damage_type: String,
attacker: (u32, Text),
item: Option<Text>,
living: bool,
credit: bool,
}
/// What is stuck in a player's body: `LivingEntity.DATA_ARROW_COUNT_ID` and
/// `DATA_STINGER_COUNT_ID` with the timers that pull them out
/// (`removeArrowTime`, `removeStingerTime`). Kept with the combat record,
@@ -87,7 +95,7 @@ pub(super) struct CombatTracker {
in_combat: bool,
/// Attackers of damage dealt this tick, claimed by the matching
/// `RecordedDamage` event.
pending: Vec<(String, (u32, Text), Option<Text>)>,
pending: Vec<PendingAttacker>,
/// `pending` for a direct entity with no causing entity: only its name.
pending_direct: Vec<(String, Text)>,
/// The lengths of `pending` and `pending_direct` before the first
@@ -107,9 +115,15 @@ pub(super) struct CombatTracker {
/// The player whose primed TNT is going off now (`PrimedTnt.getOwner`):
/// its entity id and display name, the blast's causing entity.
pub(super) blast_owner: Option<(u32, Text)>,
/// `Some(None)` is an explicitly ownerless crystal/cart DamageSource.
pub(super) explicit_explosion_cause: Option<Option<super::explosion::ExplosionCause>>,
pub(super) explicit_explosion_direct: Option<Text>,
/// The guest behind `blast_owner`, when that player is a LAN guest; the
/// host's own blast leaves it None.
pub(super) blast_guest: Option<u32>,
/// `blast_owner` is a player (`getIndirectSourceEntity() instanceof
/// Player`), not a mob whose UUID the TNT's `owner` names.
pub(super) blast_owner_player: bool,
pub(super) projectile_attacker: Option<(u32, Text)>,
/// The projectile hitting the player now is another player's that
/// `ServerPlayer.canHarmPlayer` refuses (the `pvp` rule, an ally).
@@ -129,6 +143,9 @@ pub(super) struct CombatTracker {
/// A firework rocket's burst is hurting the player now: the rocket is
/// the direct entity (`rocket_hurt_player`).
pub(super) rocket_hit: bool,
/// The arrow hurting the player now pierces (`AbstractArrow.getPierceLevel` above 0):
/// `LivingEntity.applyItemBlocking` lets it past a raised shield (`arrow_hits_player`).
pub(super) piercing_hit: bool,
/// The player whose melee hit is landing on the player now: where they
/// stand and the seconds their weapon disables a shield for
/// (`getSecondsToDisableBlocking`).
@@ -319,9 +336,32 @@ impl Game<'_> {
item: Option<Text>,
) {
self.combat.mark();
self.combat
.pending
.push((damage_type.to_owned(), (id, name), item));
self.combat.pending.push(PendingAttacker {
damage_type: damage_type.to_owned(),
attacker: (id, name),
item,
living: true,
credit: true,
});
}
/// A non-living or already removed cause remains in the damage source,
/// but cannot resolve as an EntityReference for kill credit.
pub(super) fn note_uncredited_attacker(
&mut self,
damage_type: &str,
id: u32,
name: Text,
item: Option<Text>,
living: bool,
) {
self.combat.mark();
self.combat.pending.push(PendingAttacker {
damage_type: damage_type.to_owned(),
attacker: (id, name),
item,
living,
credit: false,
});
}
/// The `DamageSource.directEntity` of the next `damage_type` damage when
/// nothing caused it (a splash potion nobody threw): the death message
@@ -358,14 +398,20 @@ impl Game<'_> {
}
_ => continue,
};
let (attacker, item) = self
let (attacker, item, living, credit) = self
.combat
.pending
.iter()
.position(|(t, _, _)| *t == name)
.position(|p| p.damage_type == name)
.map(|i| {
let (_, attacker, item) = self.combat.pending.remove(i);
(Some(attacker), item)
let PendingAttacker {
attacker,
item,
living,
credit,
..
} = self.combat.pending.remove(i);
(Some(attacker), item, living, credit)
})
.unwrap_or_default();
let direct = match attacker {
@@ -380,8 +426,10 @@ impl Game<'_> {
let (fall_location, fall_distance) = self.fall_location();
let now = self.world.game_time;
if let Some(a) = &attacker {
self.combat.last_hurt_by = Some((a.clone(), now));
if !self.combat.in_combat {
if living {
self.combat.last_hurt_by = credit.then(|| (a.clone(), now));
}
if living && !self.combat.in_combat {
self.combat.in_combat = true;
}
}
@@ -480,7 +528,7 @@ impl Game<'_> {
}
}
/// The display component of a custom-named held stack.
fn mob_main_hand_name(&self, id: u32) -> Option<Text> {
pub(super) fn mob_main_hand_name(&self, id: u32) -> Option<Text> {
let m = self.mobs.iter().find(|m| m.id == id)?;
let mut stack = m.extra.main_hand_stack()?.clone();
if !has_custom_name(&stack) {
@@ -500,7 +548,7 @@ impl Game<'_> {
/// `main_hand_custom_name` of a player who is not in place: the guest
/// `conn` between turns, or none for the host while a guest acts (their
/// hotbar slot is kept by the swap).
fn player_main_hand_custom_name(&self, player: Option<u32>) -> Option<Text> {
pub(super) fn player_main_hand_custom_name(&self, player: Option<u32>) -> Option<Text> {
if player == self.lan_acting {
return self.main_hand_custom_name();
}
@@ -895,7 +943,7 @@ mod held_item_tests {
}
#[cfg(test)]
mod noted_tests {
use super::CombatTracker;
use super::{CombatTracker, PendingAttacker};
use horizon_client::text::Text;
/// A canceled hit forgets what was noted for it, also before its hurt
@@ -905,11 +953,13 @@ mod noted_tests {
let mut c = CombatTracker::default();
let arrow = |c: &mut CombatTracker, id| {
c.mark();
c.pending.push((
"minecraft:arrow".into(),
(id, Text::literal("skeleton")),
None,
));
c.pending.push(PendingAttacker {
damage_type: "minecraft:arrow".into(),
attacker: (id, Text::literal("skeleton")),
item: None,
living: true,
credit: true,
});
};
arrow(&mut c, 1);
c.forget_noted(false);
@@ -918,7 +968,10 @@ mod noted_tests {
c.pending_direct
.push(("minecraft:arrow".into(), Text::literal("arrow")));
c.forget_noted(true);
assert_eq!(c.pending.iter().map(|p| p.1.0).collect::<Vec<_>>(), [1]);
assert_eq!(
c.pending.iter().map(|p| p.attacker.0).collect::<Vec<_>>(),
[1]
);
assert!(c.pending_direct.is_empty());
// A hit noted and landed, then one noted and canceled: the landed
// one keeps its attacker.
@@ -927,7 +980,10 @@ mod noted_tests {
c.forget_noted(false);
arrow(&mut c, 4);
c.forget_noted(true);
assert_eq!(c.pending.iter().map(|p| p.1.0).collect::<Vec<_>>(), [1, 3]);
assert_eq!(
c.pending.iter().map(|p| p.attacker.0).collect::<Vec<_>>(),
[1, 3]
);
}
}
#[cfg(test)]
@@ -1010,7 +1066,7 @@ mod guest_hit_tests {
game.combat.pending.clear();
game.guest_mob_event(5, hit);
let noted: Vec<u32> = (game.combat.pending.iter())
.map(|(_, (id, _), _)| *id)
.map(|pending| pending.attacker.0)
.collect();
assert_eq!(noted, [5]);
assert_eq!(game.combat.melee_mob, None, "the hit used it up");
+11 -35
View File
@@ -13,6 +13,7 @@ pub(in crate::game) mod items;
mod misc;
mod place;
mod players;
mod position_args;
pub(super) mod pregen;
mod profiles;
mod raid;
@@ -517,22 +518,6 @@ impl Game<'_> {
pub(super) fn position_arg(&self, ctx: &Context, name: &str) -> Option<[f64; 3]> {
self.coords(ctx, name).map(|c| self.resolve_coords(&c))
}
/// BlockPosArgument.getLoadedBlockPos.
pub(super) fn block_pos_arg(&self, ctx: &Context, name: &str) -> R<[i32; 3]> {
let c = self
.coords(ctx, name)
.ok_or_else(|| err("argument.pos.missing.int", &[]))?;
// BlockPos.containing(getPosition)
let p = self.resolve_coords(&c).map(|v| v.floor() as i32);
let dimension = self.command_source.map_or(self.dim, |s| s.dimension);
if dimension != self.dim || !self.world.chunks.contains_key(&(p[0] >> 4, p[2] >> 4)) {
return Err(err("argument.pos.unloaded", &[]));
}
if p[1] < self.world.bottom_y() || p[1] >= self.world.top_y() {
return Err(err("argument.pos.outofworld", &[]));
}
Ok(p)
}
fn int(ctx: &Context, name: &str) -> Option<i32> {
match ctx.get(name) {
Some(Arg::Int(v)) => Some(*v),
@@ -2628,23 +2613,14 @@ impl Game<'_> {
// `new ExperienceOrb(level, pos, Vec3.ZERO, 0)`, unstuck like any orb spawn.
let at = self.unstuck_orb(pos);
self.next_entity += 1;
self.orbs.push(horizon_client::entities::ExperienceOrb {
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
id: self.next_entity,
position: Vec3::new(at[0], at[1], at[2]),
previous: Vec3::new(at[0], at[1], at[2]),
velocity: Vec3::new(0.0, 0.0, 0.0),
value: 0,
age: 0,
on_ground: false,
count: 1,
ticks: 0,
health: 5,
air: 300,
following: None,
});
let at = Vec3::new(at[0], at[1], at[2]);
let orb = horizon_client::entities::ExperienceOrb::new(
self.next_entity,
at,
Vec3::ZERO,
0,
);
self.orbs.push(orb);
// tryAddFreshEntityWithPassengers refused: ERROR_DUPLICATE_UUID.
if !self.native_joins_flush(false, self.next_entity) {
return Err(err("commands.summon.failed.uuid", &[]));
@@ -3794,7 +3770,7 @@ impl Game<'_> {
vec![Target::Player]
};
let pos = if ctx.get("pos").is_some() {
self.block_pos_arg(ctx, "pos")?
self.spawnable_pos_arg(ctx, "pos")?
} else {
let p = self.source_position();
[
@@ -3855,7 +3831,7 @@ impl Game<'_> {
}
fn cmd_setworldspawn(&mut self, ctx: &Context) -> R<i32> {
let pos = if ctx.get("pos").is_some() {
self.block_pos_arg(ctx, "pos")?
self.spawnable_pos_arg(ctx, "pos")?
} else {
let p = self.source_position();
[
@@ -646,6 +646,7 @@ impl Game<'_> {
orb.count = new.count;
orb.health = new.health;
orb.no_gravity = new.no_gravity;
orb.fall_distance = new.fall_distance;
// Entity.load reads portalCooldown and leaves the portal
// process as it is.
if let Some(portal) = self.portals.entities.get_mut(&id) {
@@ -1039,14 +1039,7 @@ impl Game<'_> {
.unwrap_or(Tag::Compound(Default::default())),
v => horizon_client::scoreboard::json_to_tag(v),
};
edit_components(&mut stack, |c| {
let mut data = match c.get(&"minecraft:custom_data".into()) {
Some(t @ Tag::Compound(_)) => t.clone(),
_ => Tag::Compound(Default::default()),
};
merge_compound(&mut data, &tag);
c.insert("minecraft:custom_data".into(), data);
});
set_custom_data(&mut stack, &tag);
}
"minecraft:set_components" => {
let patch = m["components"].clone();
@@ -1577,6 +1570,22 @@ fn edit_components(stack: &mut ItemStack, f: impl FnOnce(&mut horizon_world::nbt
stack.extra = extra;
stack.tidy();
}
/// `SetCustomDataFunction.run`: `CustomData.update(stack, tag -> tag.merge(value))`.
fn set_custom_data(stack: &mut ItemStack, value: &Tag) {
edit_components(stack, |c| {
let mut data = match c.get(&"minecraft:custom_data".into()) {
Some(t @ Tag::Compound(_)) => t.clone(),
_ => Tag::Compound(Default::default()),
};
merge_compound(&mut data, value);
// Vanilla CustomData.update removes the component when the result is empty.
if matches!(&data, Tag::Compound(m) if m.is_empty()) {
c.remove(&"minecraft:custom_data".into());
} else {
c.insert("minecraft:custom_data".into(), data);
}
});
}
/// `CompoundTag.merge`: nested compounds merge, other values replace.
fn merge_compound(into: &mut Tag, from: &Tag) {
let (Tag::Compound(a), Tag::Compound(b)) = (into, from) else {
@@ -2815,3 +2824,16 @@ mod registered_slot_source_preservation_tests {
}
}
}
#[cfg(test)]
mod set_custom_data_tests {
use super::*;
#[test]
fn merging_an_empty_tag_into_a_stack_without_custom_data_adds_no_component() {
let mut stack = ItemStack::new("minecraft:stick", 1);
set_custom_data(&mut stack, &Tag::Compound(Default::default()));
let saved = horizon_client::item_data::encode_components(&stack);
assert!(saved.is_none_or(|c| c.get("minecraft:custom_data").is_none()));
}
}
@@ -0,0 +1,36 @@
//! Block position argument validation for command execution.
use super::{Context, Game, R, err};
impl Game<'_> {
/// BlockPosArgument.getLoadedBlockPos.
pub(in crate::game) fn block_pos_arg(&self, ctx: &Context, name: &str) -> R<[i32; 3]> {
let c = self
.coords(ctx, name)
.ok_or_else(|| err("argument.pos.missing.int", &[]))?;
// BlockPos.containing(getPosition)
let p = self.resolve_coords(&c).map(|v| v.floor() as i32);
let dimension = self.command_source.map_or(self.dim, |s| s.dimension);
if dimension != self.dim || !self.world.chunks.contains_key(&(p[0] >> 4, p[2] >> 4)) {
return Err(err("argument.pos.unloaded", &[]));
}
if p[1] < self.world.bottom_y() || p[1] >= self.world.top_y() {
return Err(err("argument.pos.outofworld", &[]));
}
Ok(p)
}
/// BlockPosArgument.getSpawnablePos: no loaded-chunk or build-height check,
/// only `Level.isInSpawnableBounds` (upper bounds exclusive).
pub(in crate::game) fn spawnable_pos_arg(&self, ctx: &Context, name: &str) -> R<[i32; 3]> {
let c = self
.coords(ctx, name)
.ok_or_else(|| err("argument.pos.missing.int", &[]))?;
let p = self.resolve_coords(&c).map(|v| v.floor() as i32);
if !(-30_000_000..30_000_000).contains(&p[0])
|| !(-30_000_000..30_000_000).contains(&p[2])
|| !(-20_000_000..20_000_000).contains(&p[1])
{
return Err(err("argument.pos.outofbounds", &[]));
}
Ok(p)
}
}
+7 -3
View File
@@ -52,12 +52,15 @@ impl Game<'_> {
consumable: &ItemConsumable,
stack: &ItemStack,
) {
// Consumable.onConsume runs on the stack's own component.
let consumable = horizon_client::item_sync::stack_consumable(stack, consumable);
let consumable = &*consumable;
self.emit_consume(consumable, stack, 16);
// ServerPlayer: the used statistic and the consume_item trigger.
self.award_stat("used", &stack.item, 1);
self.trigger_item("minecraft:consume_item", stack);
// FoodProperties.onConsume, the player's one ConsumableListener.
if let Some((nutrition, saturation)) = info.food {
if let Some((nutrition, saturation)) = horizon_client::item_sync::food(stack, info.food) {
let at = self.feet();
let pitch = self.world.random.triangle_float(1.0, 0.4);
self.audio.play(&consumable.sound, at, 1.0, pitch);
@@ -96,10 +99,11 @@ impl Game<'_> {
// with infinite materials, the hand when emptied, else the inventory
// (ServerPlayer.handleExtraItemsCreatedOnUse) or a drop.
self.consume_held();
if let Some(remainder) = &info.use_remainder
if let Some(remainder) =
horizon_client::item_sync::use_remainder(stack, info.use_remainder.clone())
&& !self.creative
{
let remainder = ItemStack::new(remainder, 1);
let remainder = ItemStack::new(&remainder, 1);
if self.held().is_none() {
*self.held_mut() = Some(remainder);
} else if let Some(rest) = self.give(remainder) {
+22 -10
View File
@@ -247,8 +247,16 @@ impl Game<'_> {
}
/// `CreakingHeartBlock.playerWillDestroy`: the heart's creaking twitches to
/// death and a natural heart drops experience. `onExplosionHit` does the
/// same for the creaking (`by_player` false), without the experience.
/// same for the creaking, and awards the experience only when its
/// blast's indirect source is a player (`creaking_heart_broken_by`).
pub(super) fn creaking_heart_broken(&mut self, pos: [i32; 3], by_player: bool) {
let award = by_player && !self.creative && !self.spectator();
self.creaking_heart_broken_by(pos, award);
}
/// `creaking_heart_broken` once the breaker is known: `award` is
/// `tryAwardExperience`'s player check (a player who neither builds
/// instantly, `preventsBlockDrops`, nor spectates).
pub(super) fn creaking_heart_broken_by(&mut self, pos: [i32; 3], award: bool) {
let key = (pos[0], pos[1], pos[2]);
if !matches!(
self.world.block_entities.get(&key),
@@ -271,16 +279,20 @@ impl Game<'_> {
.state(state)
.and_then(|s| s.properties.get("natural").cloned())
.is_some_and(|v| v == "true");
if by_player && natural && !self.creative && !self.spectator() {
if award && natural {
let xp = 20 + self.world.random.next_int_bound(5).unwrap_or(0) as u32;
self.spawn_orbs(
[
f64::from(pos[0]) + 0.5,
f64::from(pos[1]) + 0.5,
f64::from(pos[2]) + 0.5,
],
xp,
);
// Block.popExperience: the amount is drawn, then nothing pops
// with `block_drops` off.
if self.ui.rules.bool("block_drops") {
self.spawn_orbs(
[
f64::from(pos[0]) + 0.5,
f64::from(pos[1]) + 0.5,
f64::from(pos[2]) + 0.5,
],
xp,
);
}
}
self.world
.block_entities
+48 -65
View File
@@ -3,10 +3,13 @@
//! breeding, conversions, drops), projectiles, player interactions
//! (feeding, shearing, milking, spawn eggs, throwing) and rendering.
mod bred_draws;
mod effect_instance;
use effect_instance::mob_effect_instance;
mod mob_geometry;
mod mob_group;
mod mob_islands;
mod prep;
mod projectile_targets;
mod projectile_world;
mod pufferfish_player;
mod spawn_eggs;
@@ -796,7 +799,7 @@ impl Game<'_> {
fn wearable(me: &Game<'_>, stack: &ItemStack, fire: bool, name: &str) -> Option<u32> {
let info = me.res.items().get(&stack.item)?;
if info.equip_slot.is_none()
|| !info.equip_damage_on_hurt
|| !horizon_client::item_sync::damage_on_hurt(stack, info.equip_damage_on_hurt)
|| info.max_damage == 0
|| !horizon_client::stations::is_damageable(me.res.items(), stack)
|| (fire && me.res.items().resists_fire(stack, name))
@@ -2309,10 +2312,9 @@ impl Game<'_> {
self.post_game_event(event, at, ctx);
}
// A mob's own `gameEvent(..)` calls: Armadillo.rollUp/rollOut's
// ENTITY_ACTION, Creeper.tick's PRIME_FUSE, Mob.ate's EAT, the
// shulker's lid (Shulker.setRawPeekAmount), an evoker's vexes
// (ENTITY_PLACE), Enderman.teleport's TELEPORT,
// LivingEntity.onEquipItem's EQUIP/UNEQUIP.
// ENTITY_ACTION, Creeper.tick's PRIME_FUSE, Mob.ate's EAT, a fox's
// DRINK, the shulker's lid, an evoker's vexes (ENTITY_PLACE),
// Enderman.teleport's TELEPORT, onEquipItem's EQUIP/UNEQUIP.
MobEvent::GameEvent {
event, at, source, ..
} if matches!(
@@ -2320,6 +2322,7 @@ impl Game<'_> {
"minecraft:entity_action"
| "minecraft:prime_fuse"
| "minecraft:eat"
| "minecraft:drink"
| "minecraft:container_open"
| "minecraft:container_close"
| "minecraft:entity_place"
@@ -2531,6 +2534,13 @@ impl Game<'_> {
});
self.combat.explosion_source = source_mob;
self.combat.explosion_source_named = noted;
let portal_proof = species == Species::SulfurCube
&& source_mob.is_some_and(|id| {
self.portals
.entities
.get(&id)
.is_some_and(|p| p.cooldown > 0)
});
self.combat.explosion_cause = match species {
Species::Creeper => Some("minecraft:creeper"),
Species::Wither => Some("minecraft:wither"),
@@ -2560,7 +2570,11 @@ impl Game<'_> {
Species::Creeper | Species::Wither => {
self.with_hive_emptying_blast(|g| g.explode(at, radius, source))
}
Species::SulfurCube => self.explode_sulfur_cube(at, radius, source),
Species::SulfurCube => self.explode_sulfur_cube(
at,
radius,
source.with_portal_proof(portal_proof),
),
_ => self.explode(at, radius, source),
};
self.combat.explosion_source = None;
@@ -3969,6 +3983,7 @@ impl Game<'_> {
) -> bool {
let post_attack_if_alive = std::mem::take(&mut self.combat.post_attack_if_alive);
let rocket = std::mem::take(&mut self.combat.rocket_hit);
let pierce = std::mem::take(&mut self.combat.piercing_hit);
let melee = self.combat.melee_mob.take();
// ServerPlayer.canHarmPlayer refused another player's projectile.
if self.creative || self.combat.refuse_player_hit {
@@ -3983,7 +3998,7 @@ impl Game<'_> {
.damage_tags
.get("minecraft:is_projectile")
.is_some_and(|t| t.contains(damage_type));
let blocking = self.player_blocking(from, damage_type, false);
let blocking = self.player_blocking(from, damage_type, pierce);
let raised = blocking.is_some();
// Melee: the attacker stands at `from` (another player's hit has no mob behind it).
let attacker = match known {
@@ -4919,38 +4934,7 @@ impl Game<'_> {
ArrowEvent::ShootEvent | ArrowEvent::Drop => {}
}
}
/// The boats, minecarts and primed TNT a projectile can strike
/// (`VehicleEntity.isPickable`, `PrimedTnt.isPickable`).
pub(super) fn pickable_object_targets(&self) -> Vec<(Who, Aabb)> {
let boats = self.boats.iter().map(|b| (Who::Mob(b.id), b.bounds()));
let carts = self.carts.iter().map(|c| (Who::Mob(c.id), c.bounds()));
let tnts = self.tnts.iter().map(|t| (Who::Mob(t.id), t.bounds()));
boats.chain(carts).chain(tnts).collect()
}
/// What a projectile can hit: the mobs, the player, the dragon's parts and
/// the End crystals (under reserved ids, game/dragon.rs).
pub(super) fn projectile_targets(&self) -> Vec<(Who, Aabb)> {
let mut targets: Vec<(Who, Aabb)> = self
.mobs
.iter()
.filter(|m| m.alive())
.map(|m| (Who::Mob(m.id), m.bounds()))
.collect();
if let Some(p) = self.player_view()
&& p.alive
{
targets.push((Who::Player, p.bounds));
}
targets.extend(
self.special_targets()
.into_iter()
.map(|(id, b)| (Who::Mob(id), b)),
);
// Boats, minecarts and primed TNT are pickable for every projectile,
// so every kind shares this one list.
targets.extend(self.pickable_object_targets());
targets
}
/// `Level.isRainingAt` for arrows.
pub(super) fn arrow_rain(&self) -> impl Fn(BlockPos) -> bool + '_ {
move |p: BlockPos| self.raining_at(p)
}
@@ -5201,6 +5185,24 @@ impl Game<'_> {
return Ok(());
}
}
if p.arrow.is_some()
&& let Hit::Entity {
who: Who::Mob(id), ..
} = hit
&& self.item_frames.iter().any(|f| f.id == id)
{
// AbstractArrow.onHitEntity on an item frame: spent when the
// frame took the hit, turned back when a fixed one refused it.
let by_player = p.owner == Some(Who::Player);
let hurt = self.projectile_hurts_frame(id, by_player, by_player && self.creative);
let outcome = if hurt {
EntityHit::Hurt
} else {
EntityHit::Refused
};
self.finish_arrow_hit(p.clone(), outcome);
return Ok(());
}
if p.arrow.is_some()
&& let Hit::Entity {
who: Who::Mob(id), ..
@@ -5319,11 +5321,7 @@ impl Game<'_> {
}
let hurt = match who {
Who::Player => {
let hurt = self.mob_hits_player(
amount,
[p.previous.x, p.previous.y, p.previous.z],
"minecraft:arrow",
);
let hurt = self.arrow_hits_player(amount, p);
// AbstractArrow.onHitEntity: a hit that hurt leaves a
// non-piercing arrow stuck in the body.
if hurt && p.arrow.as_ref().is_some_and(|a| a.pierce == 0) {
@@ -5587,7 +5585,7 @@ impl Game<'_> {
if let Some(id) = self.spawn_mob_raw("minecraft:chicken", atp, true)
&& let Some(c) = self.mobs.iter_mut().find(|m| m.id == id)
{
c.extra.age = -24000;
(c.extra.age, c.extra.variant) = (-24000, p.egg_variant.unwrap_or(0));
}
}
}
@@ -7150,12 +7148,12 @@ impl Game<'_> {
let Some(p) = self.player.as_ref() else {
return Ok(false);
};
// Projectile.shootFromRotation adds getKnownMovement: what the thrower
// last moved, not a velocity the server kept for a guest.
// Projectile.shootFromRotation adds getKnownMovement: what the thrower last moved.
let on_ground = p.ground.on_ground;
let velocity = self.known_player_movement();
let start = Vec3::new(eye[0], eye[1] - 0.1, eye[2]);
let mut proj = Projectile::new(kind.clone(), start, Some(Who::Player));
proj.set_egg_item(stack);
proj.shoot_from_rotation(self.pitch, self.yaw, 0.0, 1.5, 1.0, velocity, on_ground);
self.add_projectile(proj);
// SnowballItem.use / EggItem.use / EnderpearlItem.use: awardStat(ITEM_USED).
@@ -7654,24 +7652,6 @@ fn knock_back(
);
}
/// The instance a mob effect gives the player; `None` for a creative or
/// spectator player (`immune`) unless it is ambient (the nautilus's Breath).
fn mob_effect_instance(
effect: &'static str,
ticks: i32,
amplifier: i32,
ambient: bool,
immune: bool,
) -> Option<horizon_client::effects::EffectInstance> {
if immune && !ambient {
return None;
}
let id = effect.strip_prefix("minecraft:").unwrap_or(effect);
let mut instance = horizon_client::effects::EffectInstance::new(id, ticks, amplifier);
instance.ambient = ambient;
Some(instance)
}
#[cfg(test)]
mod local_sound_event_tests {
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
@@ -8832,6 +8812,9 @@ mod chorus_projectile_drop_tests {
}
}
#[cfg(test)]
#[path = "frame_projectile_tests.rs"]
mod frame_projectile_tests;
#[cfg(test)]
#[path = "loaded_arrow_shot_tests.rs"]
mod loaded_arrow_shot_tests;
@@ -0,0 +1,17 @@
/// The instance a mob effect gives the player; `None` for a creative or
/// spectator player (`immune`) unless it is ambient (the nautilus's Breath).
pub(super) fn mob_effect_instance(
effect: &'static str,
ticks: i32,
amplifier: i32,
ambient: bool,
immune: bool,
) -> Option<horizon_client::effects::EffectInstance> {
if immune && !ambient {
return None;
}
let id = effect.strip_prefix("minecraft:").unwrap_or(effect);
let mut instance = horizon_client::effects::EffectInstance::new(id, ticks, amplifier);
instance.ambient = ambient;
Some(instance)
}
@@ -0,0 +1,57 @@
//! What a projectile can strike: the mobs, the player, the dragon's parts and
//! End crystals, and the non-living entities that are pickable (boats,
//! minecarts, item frames).
use super::super::Game;
use horizon_client::mobs::Who;
use horizon_core::geometry::{Aabb, Vec3};
impl Game<'_> {
/// The boats, minecarts, primed TNT and item frames a projectile can strike
/// (`VehicleEntity.isPickable`, `PrimedTnt.isPickable`,
/// `BlockAttachedEntity.isPickable`).
pub(in crate::game) fn pickable_object_targets(&self) -> Vec<(Who, Aabb)> {
let mut targets: Vec<(Who, Aabb)> = self
.boats
.iter()
.map(|b| (Who::Mob(b.id), b.bounds()))
.collect();
targets.extend(self.carts.iter().map(|c| (Who::Mob(c.id), c.bounds())));
targets.extend(self.tnts.iter().map(|t| (Who::Mob(t.id), t.bounds())));
targets.extend(self.item_frames.iter().map(|f| {
let (lo, hi) = f.bounds();
(
Who::Mob(f.id),
Aabb::new(
Vec3::new(lo[0], lo[1], lo[2]),
Vec3::new(hi[0], hi[1], hi[2]),
),
)
}));
targets
}
/// What a projectile can hit: the mobs, the player, the dragon's parts and
/// the End crystals (under reserved ids, game/dragon.rs), and the vehicles
/// and item frames.
pub(in crate::game) fn projectile_targets(&self) -> Vec<(Who, Aabb)> {
let mut targets: Vec<(Who, Aabb)> = self
.mobs
.iter()
.filter(|m| m.alive())
.map(|m| (Who::Mob(m.id), m.bounds()))
.collect();
if let Some(p) = self.player_view()
&& p.alive
{
targets.push((Who::Player, p.bounds));
}
targets.extend(
self.special_targets()
.into_iter()
.map(|(id, b)| (Who::Mob(id), b)),
);
// Boats, minecarts, primed TNT and item frames are pickable for every
// projectile, so every kind shares this one list.
targets.extend(self.pickable_object_targets());
targets
}
}
@@ -3,6 +3,9 @@
use super::*;
impl ProjectileWorld for ProjWorld<'_, '_> {
fn below_world_y(&self) -> f64 {
self.w.world.below_world_y()
}
fn block_contacts(&self, entity: i32, kind: &str, from: Vec3, to: Vec3, bounds: Aabb) {
horizon_client::block_contact::swept_inside(entity, kind, from, to, bounds, |p| {
let state = self.w.world.block(p.x, p.y, p.z);
@@ -58,7 +58,7 @@ impl Recording {
Self {
seconds,
started: Instant::now(),
cpu_s: horizon_bench::cpu_seconds(std::process::id()),
cpu_s: horizon_system::cpu_seconds(std::process::id()),
spans_were_on,
ticks: Vec::with_capacity(seconds as usize * 20),
sampled_ticks: 0,
@@ -363,7 +363,7 @@ impl Game<'_> {
let costs = tick_profile::take();
let mob_samples = costs.iter().map(|c| c.mob_ticks).sum();
let jobs = job_stats::take();
let cpu = horizon_bench::cpu_seconds(std::process::id())
let cpu = horizon_system::cpu_seconds(std::process::id())
.zip(r.cpu_s)
.map(|(end, start)| round((end - start) / window_s.max(1e-9)));
let players = self.lan.as_ref().map_or(0, |h| h.hub.guests.len());
@@ -382,7 +382,7 @@ impl Game<'_> {
"overhead": overhead(&r, mob_samples, span_ns, mob_ns),
"players": players,
"levels": self.report_levels(),
"memory": { "rss_mb": horizon_bench::rss_bytes(std::process::id()).map(|b| b >> 20) },
"memory": { "rss_mb": horizon_system::rss_bytes(std::process::id()).map(|b| b >> 20) },
"process": process_limits(),
"cpu_cores_used": cpu,
"config": {
@@ -395,9 +395,12 @@ impl Game<'_> {
// The machine's description runs commands: off the tick.
std::thread::spawn(move || {
let mut report = report;
let hw = horizon_bench::Hardware::detect();
report["machine"] = json!({
"os": hw.os, "arch": hw.arch, "cpu": hw.cpu, "cpu_threads": hw.cpu_threads, "ram_mb": hw.ram_mb,
"os": std::env::consts::OS,
"arch": std::env::consts::ARCH,
"cpu": horizon_system::cpu(),
"cpu_threads": std::thread::available_parallelism().ok().map(|n| n.get()),
"ram_mb": horizon_system::ram_bytes().map(|b| b >> 20),
});
match write_report(Path::new("reports"), &report, &text) {
Ok(path) => log::info!(
+43 -8
View File
@@ -37,6 +37,10 @@ pub(super) fn aim_deflect(
owner_uuid: Option<[i32; 4]>,
guest: Option<u32>,
) {
// `WindCharge.deflect`: refused while its `noDeflectTicks` last.
if !p.deflectable() {
return;
}
if let Some(look) = look {
p.velocity = look;
p.needs_sync = true;
@@ -183,6 +187,27 @@ impl Game<'_> {
let dir = view_vector(self.yaw, self.pitch);
let (guest, owner_uuid) = (self.lan_acting, Some(self.acting_player_uuid()));
let p = &mut self.creatures.projectiles[i];
// `WindCharge.deflect` refuses while its `noDeflectTicks` last: the
// blow goes on as an attack that hurts nothing.
if p.deflectable() {
Self::turn_by_player(p, dir, guest, owner_uuid);
}
self.reset_attack_strength();
self.swing_arm();
let from = self
.player
.as_ref()
.map(|p| [p.position.x, p.position.y, p.position.z]);
self.audio
.play("minecraft:entity.player.attack.nodamage", from, 1.0, 1.0);
}
/// `AIM_DEFLECT` by a player's blow: along their view, theirs now.
fn turn_by_player(
p: &mut Projectile,
dir: [f64; 3],
guest: Option<u32>,
owner_uuid: Option<[i32; 4]>,
) {
p.velocity = Vec3::new(dir[0], dir[1], dir[2]);
p.yaw = dir[0].atan2(dir[2]).to_degrees() as f32;
p.pitch = dir[1].atan2(dir[0].hypot(dir[2])).to_degrees() as f32;
@@ -196,14 +221,6 @@ impl Game<'_> {
// at once, not at the fireball's next update ten ticks later.
p.acceleration = 0.1;
p.needs_sync = true;
self.reset_attack_strength();
self.swing_arm();
let from = self
.player
.as_ref()
.map(|p| [p.position.x, p.position.y, p.position.z]);
self.audio
.play("minecraft:entity.player.attack.nodamage", from, 1.0, 1.0);
}
}
@@ -245,6 +262,7 @@ mod tests {
assert_eq!(b.max_position().y, 66.0);
}
/// A wind charge past its first five ticks (`noDeflectTicks` run out).
fn wind_charge() -> Projectile {
let mut p = Projectile::new(
ProjectileKind::WindCharge,
@@ -252,9 +270,26 @@ mod tests {
Some(Who::Mob(7)),
);
p.velocity = Vec3::new(0.0, 1.5, 0.0);
p.no_deflect_ticks = 0;
p
}
#[test]
fn a_fresh_wind_charge_is_not_turned() {
let mut p = wind_charge();
p.no_deflect_ticks = 5;
aim_deflect(
&mut p,
Some(Vec3::new(0.0, -1.0, 0.0)),
Some(Who::Player),
Some([4; 4]),
Some(3),
);
assert_eq!(p.velocity, Vec3::new(0.0, 1.5, 0.0));
assert_eq!(p.owner, Some(Who::Mob(7)));
assert!(!p.needs_sync);
}
#[test]
fn a_redirected_wind_charge_flies_along_the_new_owners_look() {
let mut p = wind_charge();
@@ -319,7 +319,9 @@ impl Game<'_> {
);
let state = self.world.block(origin.x, origin.y, origin.z);
let mut used = Vec::new();
if let Some(op) = case.get("special_attack") {
if let Some(op) = case.get("shield_hit") {
used = self.fixture_shield_hit(op)?;
} else if let Some(op) = case.get("special_attack") {
used = self.fixture_special_attack(op)?;
} else if let Some(op) = case.get("wind_burst") {
let center = [
@@ -497,6 +499,68 @@ impl Game<'_> {
Ok(out)
}
/// Actual player combat and blocking-item side effects, without a window or connection.
fn fixture_shield_hit(&mut self, op: &Value) -> Result<Vec<String>> {
let stack = parse_stack(op["item"].as_str().ok_or("shield_hit item")?)?
.ok_or("shield_hit requires an item")?;
let item = stack.item.clone();
self.creative = op["creative"].as_bool().unwrap_or(false);
self.mode = if self.creative {
GameMode::Creative
} else {
GameMode::Survival
};
self.player = Some(PlayerMotion::new(
self.motion,
horizon_core::geometry::Vec3::new(0.0, 0.0, 0.0),
)?);
self.yaw = 0.0;
self.armor = Default::default();
self.offhand = Some(stack);
self.using = Some((super::shield::OFFHAND_SLOT, 100));
self.combat = Default::default();
let before = self.stats.get("used", &item);
let from = horizon_core::geometry::Vec3::new(
0.0,
0.0,
if op["behind"].as_bool().unwrap_or(false) {
-1.0
} else {
1.0
},
);
let mut arrow = horizon_client::projectiles::Projectile::new(
ProjectileKind::Arrow {
base_damage: op["damage"].as_f64().unwrap_or(8.0),
critical: false,
effect: None,
},
from,
None,
);
arrow.velocity = horizon_core::geometry::Vec3::new(0.0, 0.0, 1.0);
arrow.arrow.as_mut().unwrap().pierce = u8::from(op["pierce"].as_bool().unwrap_or(false));
self.projectile_hit(
&arrow,
horizon_client::projectiles::Hit::Entity {
who: horizon_client::mobs::Who::Player,
at: horizon_core::geometry::Vec3::ZERO,
},
)?;
let out = vec![format!(
"shield health={} used={} stack={}",
num(f64::from(self.player.as_ref().unwrap().health.health())),
self.stats.get("used", &item) - before,
self.offhand
.as_ref()
.map_or_else(|| "-".to_owned(), |s| format!("{}:{}", s.count, s.damage))
)];
self.player = None;
self.offhand = None;
self.using = None;
Ok(out)
}
/// A player holding the case's item right-clicks the block at `pos`
/// (`use_item_as_player`'s block steps): the statistics, the result
/// and the held stack, as `VanillaDispenserOracle.use` prints them.
+2 -1
View File
@@ -909,7 +909,7 @@ impl Game<'_> {
};
let (Some(slot), true) = (
info.equip_slot.as_deref().and_then(Slot::from_name),
info.equip_dispensable,
horizon_client::item_sync::dispensable(stack, info.equip_dispensable),
) else {
return false;
};
@@ -1240,6 +1240,7 @@ impl Game<'_> {
_ => ProjectileKind::Snowball,
};
let mut shot = Projectile::new(kind, at, None);
shot.set_egg_item(&one);
// ArrowItem/SpectralArrowItem.asProjectile: dispensed arrows
// may be picked up.
if let Some(a) = shot.arrow.as_mut() {
+3 -4
View File
@@ -381,7 +381,7 @@ pub(super) struct Dragon {
pub id: u32,
pub position: Vec3,
pub previous: Vec3,
velocity: Vec3,
pub(super) velocity: Vec3,
pub yaw: f32,
pub previous_yaw: f32,
yaw_accel: f32,
@@ -869,14 +869,14 @@ impl Dragon {
/// `ServerExplosion.hurtEntities` knockback on the dragon itself:
/// `pushFromExplosion` adds `(1 - dist) * exposure` along the blast to
/// its eye to the motion (`Entity.push`); the parts are not pushed.
fn whole_box(&self) -> Aabb {
pub(super) fn whole_box(&self) -> Aabb {
let p = self.position;
Aabb::new(
Vec3::new(p.x - 8.0, p.y, p.z - 8.0),
Vec3::new(p.x + 8.0, p.y + 8.0, p.z + 8.0),
)
}
fn explosion_knockback(
pub(super) fn explosion_knockback(
&self,
center: [f64; 3],
radius: f32,
@@ -5831,7 +5831,6 @@ mod dragon_fireball_spawn_tests {
);
}
}
#[cfg(test)]
#[path = "thorns_tests.rs"]
mod thorns_tests;
@@ -68,17 +68,13 @@ fn block_break_speed_factor(value: Option<f64>) -> f32 {
}
impl Game<'_> {
/// `Player.getDestroySpeed` after the tool speed: mining efficiency, haste
/// and mining fatigue.
pub(super) fn modify_destroy_speed(&mut self, mut speed: f32) -> f32 {
if speed > 1.0 {
speed += self
.player
.as_mut()
.and_then(|player| player.attribute("minecraft:mining_efficiency").ok())
.unwrap_or(0.0) as f32;
}
speed * self.potions.effects.mining_speed_factor()
/// `Attributes.MINING_EFFICIENCY` (efficiency), which `Player.getDestroySpeed`
/// adds to a tool speed above 1: 0 without a player or attribute.
pub(super) fn mining_efficiency(&mut self) -> f32 {
self.player
.as_mut()
.and_then(|player| player.attribute("minecraft:mining_efficiency").ok())
.unwrap_or(0.0) as f32
}
/// `Player.getDestroySpeed`'s final `Attributes.BLOCK_BREAK_SPEED` factor:
/// 1.0 by default, so a missing player or attribute changes nothing.
+32 -3
View File
@@ -1535,8 +1535,15 @@ impl Game<'_> {
if self.restart_portal_cooldown(id) {
continue;
}
self.end.gateways.entry(id).or_default().enter(pos);
let delay = self.entity_portal_delay(id);
self.end
.gateways
.entry(id)
.or_default()
.enter_with_delay(pos, delay);
}
// ThrowableProjectile applies the gateway's block effect before Entity.tick.
self.handle_end_gateway_portals_phase(true);
}
/// `ThrownEnderpearl.onInsideBlock` in an End gateway: its owner's
@@ -1558,7 +1565,11 @@ impl Game<'_> {
/// `Entity.handlePortal` for the entities in an End gateway: they go
/// through to the gateway's exit, where they wait for its chunks.
pub(super) fn handle_end_gateway_portals(&mut self) {
// Nobody in a gateway (nearly always): nothing to look up.
self.handle_end_gateway_portals_phase(false);
}
fn handle_end_gateway_portals_phase(&mut self, after_move: bool) {
// Nobody in a gateway: nothing to look up.
if self.remote.is_some() || self.dim != Dim::End || self.end.gateways.is_empty() {
return;
}
@@ -1606,11 +1617,25 @@ impl Game<'_> {
if !ticking {
continue;
}
// Only an entity in a gateway has a state: skip the others before
// the per-id lookups below, which walk the entity lists.
if !self.end.gateways.contains_key(&id) {
continue;
}
let is_pearl = self
.creatures
.projectiles
.iter()
.any(|p| p.id == id && matches!(p.kind, ProjectileKind::EnderPearl));
if is_pearl != after_move {
continue;
}
let delay = self.entity_portal_delay(id);
let Some(state) = self.end.gateways.get_mut(&id) else {
continue;
};
// The End gateway has no transition time (Portal.getPortalTransitionTime).
if let Some(entry) = state.travel(allowed) {
if let Some(entry) = state.travel_with_delay(allowed, delay) {
leaving.push((id, entry));
}
if state.is_done() {
@@ -1939,3 +1964,7 @@ mod tests {
}
}
}
#[cfg(test)]
#[path = "gateway_cooldown_tests.rs"]
mod cooldown_tests;
+1
View File
@@ -2274,6 +2274,7 @@ mod set_rotation_tests {
#[test]
fn set_rotation_keeps_yaw_and_clamps_pitch_on_kinds_that_had_none() {
let mut orb = ExperienceOrb {
fall_distance: 0.0,
join: Default::default(),
no_gravity: false,
id: 0,
+428 -41
View File
@@ -6,6 +6,7 @@ use super::{
Game,
entity::{Blast, Entity, EntityHandle, with_entity, with_entity_mut},
};
use horizon_client::{
Result, blocks::Fluid, collisions::SceneCollisions, items::ItemStack, mobs::Who, text::Text,
};
@@ -23,6 +24,16 @@ use horizon_world::{
};
use std::{cmp::Ordering, io};
/// The causing entity of an explicit explosion damage source. Minecart ignition
/// keeps this in memory only, just like `MinecartTNT.ignitionSource`.
#[derive(Clone, Debug)]
pub(super) struct ExplosionCause {
pub entity: (u32, Text),
pub player: bool,
pub guest: Option<u32>,
pub species: Option<horizon_client::mobs::Species>,
}
/// `Level.clip` with `ClipContext.Block.COLLIDER`, no fluids and the
/// collision context of the entity whose exposure is measured.
struct ColliderRays<'a, 'r> {
@@ -98,6 +109,7 @@ pub(super) enum BlockInteraction {
#[derive(Clone, Copy, Default)]
pub(super) struct ExplosionSource {
silent: bool,
pub(super) portal_proof: bool,
/// The entity that exploded and its kind (`Explosion.getDirectSourceEntity`), when known.
entity: Option<(u32, &'static str)>,
}
@@ -106,6 +118,10 @@ impl ExplosionSource {
self.silent = silent;
self
}
pub(super) fn with_portal_proof(mut self, portal_proof: bool) -> Self {
self.portal_proof = portal_proof;
self
}
pub(super) fn with_entity(mut self, id: u32, kind: &'static str) -> Self {
self.entity = Some((id, kind));
self
@@ -171,6 +187,10 @@ fn blast_direction(center: [f64; 3], origin: [f64; 3]) -> [f64; 3] {
struct ExplosionHookContext<'a> {
center: [f64; 3],
blocks: &'a [[i32; 3]],
explicit_cause: Option<Option<ExplosionCause>>,
direct_name: Option<Text>,
held_item: Option<Text>,
credit: bool,
}
/// An entity in the blast's reach: how far (as a share of the blast's
@@ -187,12 +207,10 @@ fn hurt_mob(
hurtable: bool,
cause: Option<horizon_client::mobs::Species>,
(amount, source): (f32, horizon_client::mobs::Damage),
) {
) -> bool {
let shielded =
cause.is_some_and(|c| horizon_client::mobs::wither::shielded_from(m.species(), c));
if hurtable && !shielded {
m.damage(amount, source);
}
hurtable && !shielded && m.damage(amount, source)
}
/// A blast's causing entity and its name (`Explosion.getIndirectSourceEntity`):
@@ -228,8 +246,101 @@ pub(super) fn client_float() -> f32 {
}
impl Game<'_> {
/// `blast_cause` for the blast going off now.
pub(super) fn explosion_entity_name(&self, id: u32, kind: &str) -> Text {
self.persist_raw(id)
.and_then(|r| r.get("CustomName"))
.map_or_else(
|| Text::translate(&format!("entity.{}", kind.replace(':', ".")), &[]),
|name| {
horizon_client::scoreboard::comp_text(&horizon_client::scoreboard::tag_to_json(
name,
))
},
)
}
fn explosion_cause_resolves(&self, cause: &ExplosionCause) -> bool {
if !cause.player {
return self
.mobs
.iter()
.any(|m| m.id == cause.entity.0 && !m.removed);
}
let acting = self
.lan_acting_entity
.or_else(|| self.player.as_ref().map(|p| p.entity_id as u32));
acting == Some(cause.entity.0)
|| self.lan.as_ref().is_some_and(|host| {
host.hub
.guests
.iter()
.any(|g| g.entity_id as u32 == cause.entity.0)
})
}
/// An explicit DamageSource must not inherit an enclosing blast's cause.
pub(super) fn with_explosion_cause<R>(
&mut self,
cause: Option<ExplosionCause>,
direct_source: (u32, Text),
run: impl FnOnce(&mut Self) -> R,
) -> R {
// The DamageSource retains the removed mob's display name. This is
// deliberately not added to exploded_kinds: its EntityReference no
// longer resolves for kill credit, unlike Horizon's early exploders.
if let Some(cause) = &cause
&& cause.species.is_some()
&& !self.mobs.iter().any(|m| m.id == cause.entity.0)
&& !self
.combat
.exploded
.iter()
.any(|(id, _)| *id == cause.entity.0)
{
self.combat.exploded.push(cause.entity.clone());
}
let source = self.combat.explosion_source.take();
let owner = self.combat.explosion_owner.take();
let player = self.combat.blast_owner.take();
let guest = self.combat.blast_guest.take();
let direct = self.combat.explosion_cause.take();
self.combat.explosion_source = Some(direct_source.0);
let outer_direct = self
.combat
.explicit_explosion_direct
.replace(direct_source.1);
if let Some(cause) = &cause {
if cause.player {
self.combat.blast_owner = Some(cause.entity.clone());
self.combat.blast_guest = cause.guest;
} else {
self.combat.explosion_owner = Some(cause.entity.0);
}
}
let outer = self.combat.explicit_explosion_cause.replace(cause);
let result = run(self);
self.combat.explicit_explosion_cause = outer;
self.combat.explosion_source = source;
self.combat.explosion_owner = owner;
self.combat.blast_owner = player;
self.combat.blast_guest = guest;
self.combat.explosion_cause = direct;
self.combat.explicit_explosion_direct = outer_direct;
result
}
/// The blast damage's cause. An explicit crystal/cart DamageSource may
/// differ from the explosion's own indirect source.
pub(super) fn blast_attacker(&self) -> Option<(u32, Text)> {
if let Some(cause) = &self.combat.explicit_explosion_cause {
return cause.as_ref().map(|c| {
let name = (!c.player)
.then(|| self.mob_display_name(c.entity.0))
.flatten()
.unwrap_or_else(|| c.entity.1.clone());
(c.entity.0, name)
});
}
// `Explosion.getIndirectSourceEntity`: a primed TNT's owner names the
// blast before any mob does, which makes the damage type
// `minecraft:player_explosion` (`server_explode` asks this).
@@ -311,10 +422,15 @@ impl Game<'_> {
pos: [i32; 3],
resists: Option<([i32; 3], f32)>,
cap: Option<f32>,
portal_proof: bool,
wither_immune: Option<&std::collections::HashSet<String, S>>,
) -> (f32, bool) {
let state = self.world.block(pos[0], pos[1], pos[2]);
let f = self.res.facts().get(state);
// USED_PORTAL_DAMAGE_CALCULATOR returns no resistance and never explodes a portal.
if portal_proof && &*f.name == "minecraft:nether_portal" {
return (0.0, true);
}
let fluid = if f.fluid != Fluid::None {
100.0f32
} else {
@@ -347,6 +463,7 @@ impl Game<'_> {
center: [f64; 3],
radius: f32,
resists: Option<([i32; 3], f32)>,
portal_proof: bool,
) -> Vec<[i32; 3]> {
let (min_y, max_y) = self.with_level(|_, level, _| {
use horizon_world::level::LevelAccess;
@@ -395,8 +512,13 @@ impl Game<'_> {
let (taken, spared, again) = match last {
Some((at, taken, spared)) if at == pos => (taken, spared, true),
_ => {
let (taken, spared) =
self.blast_step(pos, resists, cap, wither_immune.as_ref());
let (taken, spared) = self.blast_step(
pos,
resists,
cap,
portal_proof,
wither_immune.as_ref(),
);
last = Some((pos, taken, spared));
(taken, spared, false)
}
@@ -438,7 +560,7 @@ impl Game<'_> {
attacker: Option<&(u32, Text)>,
cause: Option<&horizon_client::advancements::EntityView>,
) -> Option<[f64; 3]> {
let ExplosionHookContext { center, blocks } = *context;
let ExplosionHookContext { center, blocks, .. } = *context;
let diameter = f64::from(radius * 2.0);
// Blast protection: `EXPLOSION_KNOCKBACK_RESISTANCE` (0.15 per level and
// piece, so two Blast Protection IV pieces cancel it).
@@ -491,10 +613,39 @@ impl Game<'_> {
let flying = self.player.as_ref().is_some_and(|p| p.flying);
let mut outcome = horizon_client::health::BlockOutcome::default();
let mut pushed = None;
let explicit = context.explicit_cause.as_ref().and_then(|c| c.as_ref());
let living_attacker = explicit.is_none_or(|c| c.player || c.species.is_some());
let living_non_player = context
.explicit_cause
.as_ref()
.map_or(attacker.is_some(), |c| {
c.as_ref().is_some_and(|c| !c.player && c.species.is_some())
});
// The death message names the blast's causing entity. It goes to this
// player's tracker, the host's or a guest's.
if let Some((id, name)) = attacker.filter(|_| !creative && !spectator && hurtable) {
self.note_named_attacker(damage_type, *id, name.clone());
if living_attacker && context.credit {
self.note_player_attacker(
damage_type,
*id,
name.clone(),
context.held_item.clone(),
);
} else {
self.note_uncredited_attacker(
damage_type,
*id,
name.clone(),
context.held_item.clone(),
living_attacker,
);
}
} else if !creative
&& !spectator
&& hurtable
&& let Some(direct) = context.direct_name.clone()
{
self.note_direct_entity(damage_type, direct);
}
if let Some(p) = self.player.as_mut()
&& let Some(id) = self.health_resources.damage.id(damage_type)
@@ -509,7 +660,7 @@ impl Game<'_> {
id,
amount,
blocking,
attacker.is_some(),
living_non_player,
// The blast's causing entity.
attacker.map_or(0, |(id, _)| *id as i32),
)
@@ -617,7 +768,33 @@ impl Game<'_> {
// same for all of them (`onExplosionHit(this.source)`).
let attacker = self.blast_attacker();
let blast_source = self.combat.explosion_cause.clone();
let context = ExplosionHookContext { center, blocks };
let context = ExplosionHookContext {
center,
blocks,
explicit_cause: self.combat.explicit_explosion_cause.clone(),
direct_name: self.combat.explicit_explosion_direct.clone(),
credit: self
.combat
.explicit_explosion_cause
.as_ref()
.is_none_or(|cause| {
cause
.as_ref()
.is_some_and(|cause| self.explosion_cause_resolves(cause))
}),
held_item: self
.combat
.explicit_explosion_cause
.as_ref()
.and_then(|c| c.as_ref())
.and_then(|c| {
if c.player {
self.player_main_hand_custom_name(c.guest)
} else {
self.mob_main_hand_name(c.entity.0)
}
}),
};
let own = self.explosion_hurt_player(
&context,
radius,
@@ -655,10 +832,17 @@ impl Game<'_> {
// Mobs. `WitherBoss.hurtServer`: a wither shrugs off an undead cause.
let cause = self
.combat
.explosion_owner
.or(exploding)
.and_then(|id| self.mobs.iter().find(|m| m.id == id))
.map(|m| m.species());
.explicit_explosion_cause
.as_ref()
.and_then(|c| c.as_ref())
.and_then(|c| c.species)
.or_else(|| {
self.combat
.explosion_owner
.or(exploding)
.and_then(|id| self.mobs.iter().find(|m| m.id == id))
.map(|m| m.species())
});
// `ServerExplosion.hurtEntities`: every entity in reach, on the walk. Each
// answers `ignoreExplosion` itself (`Entity::ignores_explosion`); the rest
// is the same for all, and only what a blast does to a kind differs. The
@@ -667,7 +851,8 @@ impl Game<'_> {
// (as `retain` does), and a destroyed boat or cart shifts its list, so
// `gone` counts how far.
let blast = Blast {
vehicles_ignore: self.combat.blast_owner.is_none()
vehicles_ignore: self.combat.explicit_explosion_cause.is_none()
&& self.combat.blast_owner.is_none()
&& self.blast_attacker().is_some()
&& !self.ui.rules.bool("mob_griefing"),
items_ignore: items_ignore_explosion(self.ui.rules.bool("mob_griefing"), interaction),
@@ -704,18 +889,53 @@ impl Game<'_> {
kind: horizon_client::mobs::DamageKind::Explosion,
// A projectile's owner (`getIndirectSourceEntity`),
// else the exploding mob itself.
attacker: attacker
attacker: self
.combat
.explicit_explosion_cause
.as_ref()
.map(|&(id, _)| id)
.filter(|&o| o != m.id)
.map(horizon_client::mobs::Who::Mob),
.and_then(|c| c.as_ref())
.filter(|c| c.player)
.map(|_| Who::Player)
.or_else(|| {
attacker
.as_ref()
.map(|&(id, _)| id)
.filter(|&o| o != m.id)
.filter(|_| {
self.combat
.explicit_explosion_cause
.as_ref()
.is_none_or(|c| {
c.as_ref().is_some_and(|c| c.species.is_some())
})
})
.map(horizon_client::mobs::Who::Mob)
}),
from: Some(center),
attacker_species: cause,
};
// `EXPLOSION_KNOCKBACK_RESISTANCE`, not `KNOCKBACK_RESISTANCE`:
// its base is 0 for every mob type, so iron golems and
// ravagers are pushed.
hurt_mob(m, hurtable, cause, (amount, source));
let victim = m.id;
let hurt = hurt_mob(m, hurtable, cause, (amount, source));
if hurt {
if source.attacker == Some(Who::Player) {
let guest = self.combat.blast_guest;
self.last_hurt_by_player.insert(victim, guest);
if let Some(host) = self.lan.as_mut() {
match guest {
Some(conn) => {
host.mob_players.insert(victim, conn);
}
None => {
host.mob_players.remove(&victim);
}
}
}
}
self.resolve_player_responsible(victim, source);
}
// Enderman.hurtServer teleports inside the hurt, before
// the push (the mob order stays as it was).
if let Err(e) = self.settle_hurt_teleports() {
@@ -817,8 +1037,15 @@ impl Game<'_> {
self.explosion_breaks_decorations(center, diameter, interaction);
pushed
}
/// `ServerExplosion.getIndirectSourceEntity`: the causing living entity,
/// including a TNT igniter or the player who broke an end crystal.
fn blast_damage_caused_by_mob(&self) -> bool {
self.combat.explicit_explosion_cause.as_ref().map_or_else(
|| self.blast_attacker().is_some(),
|cause| cause.as_ref().is_some_and(|c| c.species.is_some()),
)
}
/// Decoration kills credit `DamageSource.getEntity`: the explicit blast
/// cause, including a TNT igniter or the player who broke an end crystal.
pub(super) fn explosion_kill_credit(&self) -> Option<super::commands::Target> {
use super::commands::Target;
// A primed TNT's owner (a player) names the blast before any mob does.
@@ -834,10 +1061,10 @@ impl Game<'_> {
}
self.crystal_blast_player()
}
/// `blast_spares_decorations` for the blast going off now: its causing
/// mob (`blast_attacker`) is the source's entity.
/// Decoration hurt checks use the damage source's cause (`instanceof Mob`),
/// unlike vehicles, which use the explosion's indirect source.
pub(super) fn blast_spares_decorations(&self, interaction: BlockInteraction) -> bool {
let by_mob = self.blast_attacker().is_some();
let by_mob = self.blast_damage_caused_by_mob();
blast_spares_decorations(
self.ui.rules.bool("mob_griefing"),
interaction,
@@ -865,7 +1092,7 @@ impl Game<'_> {
interaction: BlockInteraction,
) {
// An armour stand is no `BlockAttachedEntity`: a source in water does not spare it.
let by_mob = self.blast_attacker().is_some();
let by_mob = self.blast_damage_caused_by_mob();
let spares_stands = blast_spares_decorations(
self.ui.rules.bool("mob_griefing"),
interaction,
@@ -961,12 +1188,38 @@ impl Game<'_> {
fire: bool,
source: ExplosionSource,
) -> Result<()> {
let damage_type = super::creatures::damage_type(
horizon_client::mobs::DamageKind::Explosion,
self.blast_attacker().is_some(),
);
self.server_explode_as_source(center, radius, interaction, fire, damage_type, None, source)
self.server_explode_from_cause(center, radius, interaction, fire, source, None)
}
/// An explicit `DamageSource` is independent of the blast's indirect owner.
/// `Some(None)` is explicitly ownerless; `None` keeps the ordinary source.
pub(super) fn server_explode_from_cause(
&mut self,
center: [f64; 3],
radius: f32,
interaction: BlockInteraction,
fire: bool,
source: ExplosionSource,
cause: Option<Option<ExplosionCause>>,
) -> Result<()> {
let blast = |g: &mut Self| {
let damage_type = super::creatures::damage_type(
horizon_client::mobs::DamageKind::Explosion,
g.blast_attacker().is_some(),
);
g.server_explode_as_source(center, radius, interaction, fire, damage_type, None, source)
};
match cause {
None => blast(self),
Some(cause) => {
let (id, kind) = source.entity.ok_or_else(|| {
io::Error::other("an explicit explosion cause needs its direct entity")
})?;
let direct = (id, self.explosion_entity_name(id, kind));
self.with_explosion_cause(cause, direct, blast)
}
}
}
/// `server_explode` with the damage type the player takes and a block
/// whose explosion resistance the damage calculator overrides.
pub(super) fn server_explode_as(
@@ -1016,7 +1269,7 @@ impl Game<'_> {
// Explosion.explode: the blast is heard.
let ctx = horizon_client::sculk::Context::default();
self.post_game_event("minecraft:explode", center, ctx);
let mut targets = self.exploded_positions(center, radius, resists);
let mut targets = self.exploded_positions(center, radius, resists, source.portal_proof);
let dimension = self.dim.id();
self.native_explosion_detonate(dimension, center, source.entity, &mut targets);
let pushed =
@@ -1104,10 +1357,18 @@ impl Game<'_> {
}
let name = self.res.facts().get(state).name.clone();
let is_tnt = &*name == "minecraft:tnt";
// CreakingHeartBlock.onExplosionHit: its creaking dies (no
// experience, as Horizon does not track a TNT's owner).
// CreakingHeartBlock.onExplosionHit: its creaking dies, and a blast
// whose indirect source is a player (a primed TNT's owner) awards
// the heart's experience (`tryAwardExperience`).
if &*name == "minecraft:creaking_heart" {
self.creaking_heart_broken(pos, false);
// tryAwardExperience checks the TNT owner's own abilities.
let award = match self.combat.blast_guest {
Some(conn) => self
.command_guest(conn)
.is_some_and(|g| !g.creative() && !g.spectator()),
None => self.combat.blast_owner_player && !self.creative && !self.spectator(),
};
self.creaking_heart_broken_by(pos, award);
}
// Block.onExplosionHit: the drops copy from the block entity, which
// then spills its contents as the block turns to air.
@@ -1139,6 +1400,15 @@ impl Game<'_> {
if !is_tnt && name.starts_with("minecraft:infested_") {
self.spawn_infestation(pos);
}
// Its dropExperience is whether the blast's indirect source is a
// player (a primed TNT's owner, not a mob's): ores, sculk and spawners
// sample their experience then, and pop it while blocks drop.
if !is_tnt && self.combat.blast_owner_player {
let xp = self.block_experience(&name);
if xp > 0 && self.ui.rules.bool("block_drops") {
self.spawn_orbs(pos.map(|v| f64::from(v) + 0.5), xp);
}
}
if !is_tnt {
let dynamic = self.pending.dynamic(pos);
// LootContextParams.EXPLOSION_RADIUS under DESTROY_WITH_DECAY: the
@@ -1186,10 +1456,18 @@ impl Game<'_> {
let rot = r.next_double() * f64::from(std::f32::consts::TAU);
let fuse = r.next_int_bound(20).unwrap_or(0) + 10;
// TntBlock.wasExploded hands the new TNT the blast's indirect
// source, so a chain reaction keeps the player who started it.
let (owner, guest) = match &self.combat.blast_owner {
Some(_) => (Some(Who::Player), self.combat.blast_guest),
None => (None, None),
// source, so a chain reaction keeps a primed TNT's owner, player or mob.
let (owner, guest) = match &self.combat.explicit_explosion_cause {
// A crystal/cart's explicit DamageSource does not change
// Explosion.getIndirectSourceEntity, which is null for them.
Some(_) => (None, None),
None => match &self.combat.blast_owner {
Some(_) if self.combat.blast_owner_player => {
(Some(Who::Player), self.combat.blast_guest)
}
Some((id, _)) => (Some(Who::Mob(*id)), None),
None => (None, None),
},
};
self.add_primed_tnt(
BlockPos::new(pos[0], pos[1], pos[2]),
@@ -1448,7 +1726,7 @@ mod tests {
game.world.random.set_seed(7);
let expected = exploded_positions_stepwise(&mut game, center, radius, resists);
game.world.random.set_seed(7);
let got = game.exploded_positions(center, radius, resists);
let got = game.exploded_positions(center, radius, resists, false);
assert!(expected.len() > 20, "{radius}: {}", expected.len());
assert_eq!(got, expected, "radius {radius}, {resists:?}");
}
@@ -1795,6 +2073,115 @@ mod tests {
}
}
#[cfg(test)]
mod explicit_source_tests {
use super::*;
use crate::game::native_explosions::NativeBlast;
use horizon_client::{mobs::Mob, motion::PlayerMotion};
#[test]
#[ignore = "requires prepared resources"]
fn explicit_sources_keep_names_without_inventing_kill_credit() {
let mut game = crate::game::native_machines::tests::server_game();
game.options.dedicated = None;
game.creative = false;
game.ui.difficulty = 2;
game.build_registries();
for mode in 0..5 {
let nested = mode == 4;
game.combat = Default::default();
game.mobs.clear();
game.orbs.clear();
game.dropped.clear();
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(10.5, 64.0, 8.5)).unwrap());
let kind = game.res.mob_types().get("minecraft:husk").unwrap().clone();
let species = kind.species;
if mode == 2 || nested {
game.mobs
.push(Mob::new(43, kind, Vec3::new(40.0, 64.0, 40.0), 0.0));
}
// An enclosing blast must not leak its owner into an explicit source.
game.combat.explosion_owner = Some(99);
game.combat.exploded.push((99, Text::literal("Outer")));
let cause = (mode != 0).then(|| ExplosionCause {
entity: (43, Text::literal("Igniter")),
player: false,
guest: None,
species: (mode != 1).then_some(species),
});
if nested {
game.options.dedicated = Some(Vec::new());
game.combat.explicit_explosion_cause = Some(cause);
game.combat.explicit_explosion_direct = Some(Text::literal("Outer"));
game.combat.blast_owner = Some((99, Text::literal("Outer")));
game.combat.blast_guest = Some(17);
let center = [8.5, 64.0, 8.5];
assert!(game.native_explode(
"minecraft:overworld",
center,
2.0,
false,
NativeBlast::Keep
));
assert!(matches!(
game.combat.explicit_explosion_cause,
Some(Some(_))
));
assert!(game.combat.explicit_explosion_direct.is_some());
assert_eq!(
game.combat.blast_owner.as_ref().map(|owner| owner.0),
Some(99)
);
assert_eq!(game.combat.blast_guest, Some(17));
} else {
game.server_explode_from_cause(
[8.5, 64.0, 8.5],
2.0,
BlockInteraction::Keep,
false,
ExplosionSource::default()
.with_entity(7, "minecraft:end_crystal")
.with_silence(true),
Some(cause),
)
.unwrap();
}
let events = std::mem::take(&mut game.player.as_mut().unwrap().events);
game.record_combat(&events);
assert_eq!(
game.combat
.last_blow()
.map(|(kind, id)| (kind.to_owned(), id)),
Some((
if mode == 0 || nested {
"minecraft:explosion"
} else {
"minecraft:player_explosion"
}
.into(),
if mode == 0 || nested { 0 } else { 43 }
))
);
assert_eq!(
game.combat.kill_credit(game.world.game_time),
(mode == 2).then_some(43)
);
if mode == 1 || mode == 3 || nested {
let death = game
.combat_death_message_for(Text::literal("Victim"))
.component();
if nested {
assert_eq!(death["with"].as_array().unwrap().len(), 1);
} else {
assert_eq!(death["with"][1]["text"].as_str(), Some("Igniter"));
}
}
assert_eq!(game.combat.explosion_owner, Some(99));
assert_eq!(game.combat.explicit_explosion_cause.is_some(), nested);
}
}
}
#[cfg(test)]
mod shulker_lock_drop_tests {
use super::super::native_machines::tests::server_game;
+3 -1
View File
@@ -756,7 +756,9 @@ impl Game<'_> {
.filter_map(|(i, s)| Some((i, s?)))
.filter(|(_, s)| counts(s));
let (first, stack) = found.next()?;
let total = stack.count + found.map(|(_, s)| s.count).sum::<u32>();
let total = found
.map(|(_, s)| s.count)
.fold(stack.count, u32::saturating_add);
(ranged || total > 1).then(|| (first, stack.item.clone(), total))
}
/// One widget's box in `corner`: icons in a column, texts beside them.
@@ -201,6 +201,15 @@ impl Rocket {
}
self.life += 1;
}
/// The end of `Projectile.shoot`: yaw and pitch are set straight from the
/// shot's motion (the old rotation follows, as `rotation` is the old one
/// on the next tick).
fn turn_to_shot(&mut self) {
let v = self.velocity;
let horizontal = (v.x * v.x + v.z * v.z).sqrt();
let atan2 = horizon_core::geometry::trig::atan2_degrees;
self.rotation = [atan2(v.x, v.z), atan2(v.y, horizontal)];
}
/// `Projectile.updateRotation`: a fifth of the way from `old` (yaw,
/// pitch) toward its motion's direction, the short way round.
fn update_rotation(&mut self, old: [f32; 2]) {
@@ -485,6 +494,7 @@ impl Game<'_> {
let mut own = random_source::entity();
let mut rocket = self.new_rocket(at, stack, false, &mut own);
rocket.velocity = shoot(&mut own);
rocket.turn_to_shot();
rocket.angled = true;
rocket.owner = owner;
rocket.guest = self.lan_acting.filter(|_| owner == Some(Who::Player));
@@ -1045,6 +1055,20 @@ mod events_tests;
#[cfg(test)]
mod tests {
/// `Projectile.shoot` turns a shot rocket to its motion at once.
#[test]
fn a_shot_rocket_starts_turned_to_its_motion() {
use horizon_core::geometry::Vec3;
let mut rocket = super::Rocket::remote(1, Vec3::ZERO, Vec3::new(0.0, 0.5, 0.0));
rocket.turn_to_shot();
assert!((rocket.rotation[1] - 90.0).abs() < 1e-3);
let mut rocket = super::Rocket::remote(1, Vec3::ZERO, Vec3::new(1.0, 0.0, 1.0));
rocket.turn_to_shot();
assert!((rocket.rotation[0] - 45.0).abs() < 1e-4);
assert!(rocket.rotation[1].abs() < 1e-4);
}
/// A rocket draws its UUID's two longs first, then the motion's two triangles
/// (four doubles), then `nextInt(6)` and `nextInt(7)` for the lifetime, all from
/// the one random. The expected values come from `java.util.Random` replayed
+7 -2
View File
@@ -24,6 +24,9 @@ use std::{collections::HashMap, sync::Arc};
use super::lan_actions::GuestPlay;
#[cfg(test)]
#[path = "fishing_air_tests.rs"]
mod air_tests;
#[cfg(test)]
#[path = "fishing_block_hit_tests.rs"]
mod block_hit_tests;
@@ -714,8 +717,10 @@ impl Game<'_> {
}
/// getOpenWaterTypeForBlock: 0 above water, 1 inside water, 2 invalid.
fn open_water_type(&self, x: i32, y: i32, z: i32) -> u8 {
let f = self.res.facts().get(self.world.block(x, y, z));
if self.world.is_air(self.world.block(x, y, z)) || &*f.name == "minecraft:lily_pad" {
let state = self.world.block(x, y, z);
let f = self.res.facts().get(state);
// `!state.isAir()`: cave_air and void_air are air too.
if self.res.facts().is_air(state) || &*f.name == "minecraft:lily_pad" {
return 0;
}
if f.fluid == Fluid::Water
@@ -0,0 +1,93 @@
//! `FishingHook.getOpenWaterTypeForBlock` tests `!state.isAir()`, and `isAir()` covers
//! cave_air and void_air: a hook over a source under a cave's air is open water, so its
//! catch can be treasure. (A stone block is not air, so it is not open water either.)
use super::{FISHING_TABLE, fishing_loot};
use horizon_world::loot::LootStack;
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn cave_air_above_a_water_source_is_open_water_and_catches_treasure() {
let mut game = super::super::native_machines::tests::server_game();
let (air, cave_air, void_air, water, stone) = {
let registry = &game.pack.assets.registry;
let id = |name: &str| registry.default_state(name).unwrap();
(
id("minecraft:air"),
id("minecraft:cave_air"),
id("minecraft:void_air"),
id("minecraft:water"),
id("minecraft:stone"),
)
};
// The three air blocks read alike: ABOVE_WATER (0), never INVALID (2).
for (name, state) in [("air", air), ("cave_air", cave_air), ("void_air", void_air)] {
game.world.set(5, 63, 5, state).unwrap();
assert_eq!(game.open_water_type(5, 63, 5), 0, "{name} is air");
}
// calculateOpenWater's 5x4x5 area around the hook's block: a source at -1 and the
// cave's air at 0..2, as in a mineshaft room filled with water.
for x in 3..=7 {
for z in 3..=7 {
game.world.set(x, 60, z, water).unwrap();
for y in 61..=63 {
game.world.set(x, y, z, cave_air).unwrap();
}
}
}
assert!(
game.calculate_open_water([5, 61, 5]),
"cave_air above a source is open water"
);
// One block that is not air: the area is not open water.
game.world.set(5, 62, 5, stone).unwrap();
assert!(!game.calculate_open_water([5, 61, 5]));
game.world.set(5, 62, 5, cave_air).unwrap();
// The flag the hook's retrieval reads: `gameplay/fishing`'s treasure entry is
// conditioned on `in_open_water`, so the cave's air decides whether treasure comes.
let loot =
horizon_client::structure_blocks::loot_data(&game.options.resources, &game.pack_files())
.expect("the fishing loot table");
// `gameplay/fishing/treasure`'s items (26.3's table).
let treasure = [
"minecraft:name_tag",
"minecraft:saddle",
"minecraft:bow",
"minecraft:fishing_rod",
"minecraft:book",
"minecraft:nautilus_shell",
];
let catches = |open_water: bool| -> Vec<LootStack> {
(0..400)
.flat_map(|seed| {
let mut sequences = horizon_client::random_sequences::RandomSequences::default();
let mut level_random = horizon_core::random::LegacyRandom::new(seed);
fishing_loot(
&loot,
FISHING_TABLE,
&mut sequences,
&mut level_random,
seed,
0.0,
None,
open_water,
)
})
.collect()
};
let treasure_catches = |items: &[LootStack]| {
items
.iter()
.filter(|item| treasure.contains(&item.item.as_str()))
.count()
};
assert_eq!(
treasure_catches(&catches(false)),
0,
"not open water: no treasure"
);
assert!(
treasure_catches(&catches(true)) > 0,
"open water can catch treasure"
);
}
+6 -6
View File
@@ -95,12 +95,12 @@ impl Game<'_> {
valid_fov_modifier(modifier, base)
});
let target = self.zoomed_fov_target(target);
// A server's spectator camera on a mob: its eye height. Its modifier
// stays 1 (another player's own modifier is not modelled here); the
// zoom extra still narrows it.
if let Some(m) = self.camera_mob() {
let eye = m.eye_height() as f32;
self.view.tick(Some(eye), self.zoomed_fov_target(1.0));
// A server's spectator camera on a mob or an armor stand: its eye
// height. Its modifier stays 1 (another player's own modifier is not
// modelled here); the zoom extra still narrows it.
if let Some(e) = self.camera_entity() {
self.view
.tick(Some(e.eye_height), self.zoomed_fov_target(1.0));
return;
}
let eye = self.player.as_ref().map(|p| {
@@ -0,0 +1,117 @@
//! A projectile that strikes an item frame hurts it (#11383): `ItemFrame.hurtServer`
//! gives the item up, an empty frame breaks, and a fixed one refuses the hit.
use crate::game::{Audio, Game, Options, Progress, Settings, execution_role, load_resources};
use horizon_client::chunk::Chunk;
use horizon_core::position::ChunkPos;
use std::sync::Arc;
fn game() -> Game<'static> {
let root = std::path::PathBuf::from(
std::env::var_os("HORIZON_TEST_RESOURCES")
.expect("set HORIZON_TEST_RESOURCES to prepared resources"),
);
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = execution_role::ExecutionRole::IntegratedServer;
let res: &'static _ = Box::leak(Box::new(loaded.resources));
let mut game = Game::new(
res,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.build_registries();
game.set_allow_commands(true);
game.world
.chunks
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
game.block_ticks
.add_empty_chunk(ChunkPos::new(0, 0))
.unwrap();
for command in [
"forceload add 0 0",
"fill 0 63 0 15 63 15 stone",
"fill 4 64 0 4 66 15 stone",
] {
assert!(game.run_command(command), "{command}");
}
game
}
/// An arrow from `(0.5, 64.5, z)` flying east into the wall at x = 4.
fn shoot(game: &mut Game<'_>, z: f64) {
assert!(game.run_command(&format!(
"summon minecraft:arrow 0.5 64.5 {z} {{Motion:[1.0d,0.0d,0.0d]}}"
)));
for _ in 0..10 {
game.tick_projectiles().unwrap();
}
}
fn dropped(game: &Game<'_>, item: &str) -> usize {
game.dropped
.iter()
.filter(|d| &*d.stack.item == item)
.count()
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn an_arrow_knocks_the_item_out_then_breaks_the_frame() {
let mut game = game();
// On the wall's west face (Facing 4: west).
assert!(game.run_command(
"summon minecraft:item_frame 3.5 64.5 6.5 {Facing:4b,Item:{id:\"minecraft:diamond\",count:1}}"
));
assert_eq!(game.item_frames.len(), 1);
shoot(&mut game, 6.5);
assert_eq!(game.item_frames.len(), 1, "the first hit leaves the frame");
assert!(game.item_frames[0].item.is_none(), "the item came out");
assert_eq!(dropped(&game, "minecraft:diamond"), 1);
// The empty frame breaks and drops itself.
shoot(&mut game, 6.5);
assert!(game.item_frames.is_empty());
assert_eq!(dropped(&game, "minecraft:item_frame"), 1);
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_fixed_frame_refuses_the_arrow() {
let mut game = game();
assert!(game.run_command(
"summon minecraft:item_frame 3.5 64.5 9.5 {Facing:4b,Fixed:1b,Item:{id:\"minecraft:diamond\",count:1}}"
));
shoot(&mut game, 9.5);
assert_eq!(game.item_frames.len(), 1);
assert!(
game.item_frames[0].item.is_some(),
"a fixed frame keeps its item"
);
assert_eq!(dropped(&game, "minecraft:diamond"), 0);
// The frame took the arrow's hit and refused it, so the arrow turned back
// (`AbstractArrow.onHitEntity`'s deflection) instead of reaching the wall.
assert!(
game.creatures
.projectiles
.iter()
.all(|p| p.position.x < 3.9),
"the arrow went through the frame"
);
}
+51 -10
View File
@@ -349,12 +349,45 @@ impl Game<'_> {
}
return true;
}
self.hit_frame(i, true, self.creative);
true
}
/// `ItemFrame.hurtServer` from a projectile (`AbstractArrow.onHitEntity`,
/// `Snowball`, `ThrownEgg`, `ThrownEnderpearl`, `SmallFireball`...): the
/// damage source's entity is the projectile's owner. A fixed frame only
/// gives way to a creative player's shot (`canHurtWhenFixed`), and then
/// breaks whole (`BlockAttachedEntity.hurtServer`). `creative`: that player
/// has infinite materials. False when the frame refused the hit.
pub(super) fn projectile_hurts_frame(
&mut self,
id: u32,
by_player: bool,
creative: bool,
) -> bool {
let Some(i) = self.item_frames.iter().position(|f| f.id == id) else {
return false;
};
if self.item_frames[i].fixed {
if !creative {
return false;
}
self.item_frames[i].item = None;
}
self.hit_frame(i, by_player, creative);
true
}
/// The non-fixed part of `ItemFrame.hurtServer`: a filled frame gives its
/// item up (`shouldDamageDropItem`), an empty one breaks
/// (`BlockAttachedEntity.hurtServer`). `by_player`: the damage source's
/// entity is the acting player, `creative` when they have infinite
/// materials (no drops).
fn hit_frame(&mut self, i: usize, by_player: bool, creative: bool) {
let with_frame = self.item_frames[i].item.is_none();
let entity_id = self.item_frames[i].id;
if with_frame {
let (kind, pos) = (self.item_frames[i].kind(), self.item_frames[i].pos);
if self.hanging_break_cancelled(kind, pos, true) {
return true;
if self.hanging_break_cancelled(kind, pos, by_player) {
return;
}
}
let old_item = self.item_frames[i]
@@ -380,13 +413,14 @@ impl Game<'_> {
if f.item.is_some() {
self.frame_output_changed(f.pos);
}
self.drop_frame_items(&mut f, with_frame, true);
self.drop_frame_items(&mut f, with_frame, by_player, creative);
if !with_frame {
self.item_frames[i].random = f.random.clone();
}
// BLOCK_CHANGE by the attacker after the drops: ItemFrame.hurtServer
// for the item, ItemFrame.dropItem(level, causedBy) for the frame.
let ctx = self.who_context(Some(horizon_client::mobs::Who::Player), None);
let who = by_player.then_some(horizon_client::mobs::Who::Player);
let ctx = self.who_context(who, None);
self.post_game_event("minecraft:block_change", at, ctx);
if !with_frame {
// ItemFrame.hurtServer plays this after the event.
@@ -394,7 +428,9 @@ impl Game<'_> {
.play(&sound(&f, "remove_item"), Some(at), 1.0, 1.0);
}
self.world.modified.insert((f.pos[0] >> 4, f.pos[2] >> 4));
if let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) {
if by_player
&& let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone())
{
self.native_player_item_frame_change(
player,
&name,
@@ -404,7 +440,6 @@ impl Game<'_> {
rotation.rem_euclid(8) as u8,
);
}
true
}
/// `ItemFrame.setItem(stack, true)`: keeps one item, plays the add sound
/// for a non-empty stack and updates the comparators.
@@ -428,14 +463,20 @@ impl Game<'_> {
self.with_level(|b, level, ticks| b.update_neighbour_for_output_signal(level, ticks, at));
}
/// `ItemFrame.dropItem`: the frame (when breaking) and the held item.
fn drop_frame_items(&mut self, f: &mut ItemFrame, with_frame: bool, by_player: bool) {
fn drop_frame_items(
&mut self,
f: &mut ItemFrame,
with_frame: bool,
by_player: bool,
creative: bool,
) {
// The framed map forgets the frame, except when the drops are off and
// something caused the break (`removeFramedMap` runs only for a null
// `causedBy` then).
if self.ui.rules.bool("entity_drops") || !by_player {
self.remove_framed_map(f);
}
if !self.ui.rules.bool("entity_drops") || (by_player && self.creative) {
if !self.ui.rules.bool("entity_drops") || (by_player && creative) {
return;
}
let s = f.step().map(f64::from);
@@ -498,7 +539,7 @@ impl Game<'_> {
self.entity_killed_event_by(center(&f), self.explosion_kill_credit());
self.audio
.play(&sound(&f, "break"), Some(center(&f)), 1.0, 1.0);
self.drop_frame_items(&mut f, true, false);
self.drop_frame_items(&mut f, true, false, false);
// ItemFrame.dropItem(level, causedBy): BLOCK_CHANGE after the drops.
let ctx = self.who_context(None, None);
self.post_game_event("minecraft:block_change", center(&f), ctx);
@@ -532,7 +573,7 @@ impl Game<'_> {
if f.item.is_some() {
self.frame_output_changed(f.pos);
}
self.drop_frame_items(&mut f, true, false);
self.drop_frame_items(&mut f, true, false, false);
// ItemFrame.dropItem(level, null): sound, drops, then event.
let ctx = self.who_context(None, None);
self.post_game_event("minecraft:block_change", center(&f), ctx);
@@ -0,0 +1,176 @@
//! Production gateway counters and direct phases over a silent, empty Game.
use crate::game::{
boats::Boat,
entity::Entity,
minecarts::{CartKind, Minecart},
};
use horizon_client::{
end_gateway::Traveller,
projectiles::{Projectile, ProjectileKind},
};
use horizon_core::{geometry::Vec3, position::BlockPos};
#[test]
#[ignore = "needs explicit HORIZON_TEST_RESOURCES and HORIZON_PORTAL_REFERENCE"]
fn gateway_counters_match_actual_vanilla_methods() {
let reference: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(std::env::var_os("HORIZON_PORTAL_REFERENCE").unwrap()).unwrap(),
)
.unwrap();
let boat = Boat::new(1, "oak".into(), Vec3::ZERO, 0.0);
let cart = Minecart::new(2, CartKind::Rideable, Vec3::ZERO);
let arrow = Projectile::new(
ProjectileKind::Arrow {
base_damage: 2.0,
critical: false,
effect: None,
},
Vec3::ZERO,
None,
);
let delays = [
boat.dimension_changing_delay(),
cart.dimension_changing_delay(),
arrow.dimension_changing_delay(),
horizon_client::nether_portal::ENTITY_DELAY,
];
assert_eq!(serde_json::json!(delays), reference["delays"]);
let mut travellers: [Traveller; 4] = std::array::from_fn(|_| Traveller::default());
let entry = BlockPos::new(0, 100, 0);
for (traveller, delay) in travellers.iter_mut().zip(delays) {
traveller.enter_with_delay(entry, delay);
assert_eq!(traveller.travel_with_delay(true, delay), Some(entry));
assert_eq!(traveller.cooldown(), delay);
traveller.set_cooldown(5);
}
let mut rows = Vec::new();
for (step, ticks) in [1, 3, 1, 1, 8].into_iter().enumerate() {
for _ in 0..ticks {
for (i, (traveller, delay)) in travellers.iter_mut().zip(delays).enumerate() {
if step < 2 && i == 2 {
traveller.enter_with_delay(entry, delay);
}
traveller.travel_with_delay(true, delay);
if step < 2 && i != 2 {
traveller.enter_with_delay(entry, delay);
}
}
}
rows.push(
travellers
.iter()
.map(Traveller::cooldown)
.collect::<Vec<_>>(),
);
}
let phases = game_gateway_phases();
let report = serde_json::json!({"contact":rows,"phases":phases});
assert_eq!(report["contact"], reference["contact"]);
if let Some(path) = std::env::var_os("HORIZON_PORTAL_REPORT") {
std::fs::write(path, serde_json::to_vec_pretty(&report).unwrap()).unwrap();
}
}
// Exercise the real dispatch with cooldown-only states, so no teleport destination is resolved.
fn game_gateway_phases() -> [[i32; 2]; 4] {
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
use horizon_client::chunk::Chunk;
use horizon_world::nbt::Tag;
use std::sync::Arc;
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.headless = true;
options.game_dir = Some(std::env::current_dir().unwrap());
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.build_registries();
game.options.dedicated = Some(Vec::new());
game.world = horizon_client::world::World::new(game.res.facts().air, 1);
for z in -2..=2 {
for x in -2..=2 {
game.world
.chunks
.insert((x, z), Arc::new(Chunk::empty(x, z)));
}
}
game.dim = crate::game::Dim::End;
let mut ids = Vec::new();
for (index, kind) in ["minecraft:oak_boat", "minecraft:ender_pearl"]
.into_iter()
.enumerate()
{
let uuid = [11318, 20, 0, index as i32];
let tag = Tag::compound([
("id", Tag::String(kind.into())),
("UUID", Tag::IntArray(uuid.to_vec())),
(
"Pos",
Tag::List(vec![
Tag::Double(4.5f64.to_bits()),
Tag::Double(210.0f64.to_bits()),
Tag::Double(4.5f64.to_bits()),
]),
),
("NoGravity", Tag::Byte(1)),
]);
game.load_listed(kind, Some(uuid), None, tag).unwrap();
ids.push(game.next_entity);
}
assert_eq!(game.boats.len(), 1);
assert_eq!(game.creatures.projectiles.len(), 1);
assert!(matches!(
game.creatures.projectiles[0].kind,
ProjectileKind::EnderPearl
));
game.tickets.update(Some(crate::ticket_levels::Sources {
forced: vec![(0, 0)],
..Default::default()
}));
assert!(game.entity_ticking_area()(4, 4));
for &id in &ids {
let mut state = Traveller::default();
state.set_cooldown(5);
game.end.gateways.insert(id, state);
}
let counters = |game: &Game| {
[
game.end.gateways[&ids[0]].cooldown(),
game.end.gateways[&ids[1]].cooldown(),
]
};
let mut rows = [[0; 2]; 4];
for tick in 0..2 {
game.handle_end_gateway_portals();
rows[tick * 2] = counters(&game);
assert_eq!(rows[tick * 2], [4 - tick as i32, 5 - tick as i32]);
game.handle_end_gateway_portals_phase(true);
rows[tick * 2 + 1] = counters(&game);
assert_eq!(rows[tick * 2 + 1], [4 - tick as i32, 4 - tick as i32]);
}
assert!(game.end.arrivals.is_empty());
assert_eq!(game.creatures.projectiles.len(), 1);
rows
}
+21 -11
View File
@@ -11,6 +11,7 @@ use horizon_client::{
items::ItemStack,
};
use horizon_core::geometry::{Aabb, Vec3};
use horizon_items::mining::{self, DestroySpeedModifiers};
/// The point a `ServerboundInteractPacket` carries: `EntityHitResult.getLocation()
/// - entity.position()`, measured from the entity's feet, as
@@ -430,20 +431,29 @@ impl Game<'_> {
return 0.0;
}
let held = self.held();
let mut speed = self.items().mining_speed(held, &facts.name);
let tool_speed = self.items().mining_speed(held, &facts.name);
let hardness = facts.hardness;
let harvest = !facts.requires_tool || self.items().correct_for_drops(held, &facts.name);
speed = self.modify_destroy_speed(speed);
speed *= block_break_speed;
if self.mining_eyes_in_water() {
speed *= self.submerged_mining_speed();
}
if self.player.as_ref().is_some_and(|p| !p.ground.on_ground) {
speed /= 5.0;
}
let harvest =
self.items()
.has_correct_tool_for_drops(held, facts.requires_tool, &facts.name);
let player = DestroySpeedModifiers {
// Vanilla reads the attribute (recomputing a dirty one) only then.
mining_efficiency: if tool_speed > 1.0 {
self.mining_efficiency()
} else {
0.0
},
effects: self.potions.effects.mining_speed_factor(),
block_break_speed,
submerged_mining_speed: self
.mining_eyes_in_water()
.then(|| self.submerged_mining_speed()),
on_ground: self.player.as_ref().is_none_or(|p| p.ground.on_ground),
};
let mut speed = mining::destroy_speed(tool_speed, &player);
// NeoForge's PlayerEvent.BreakSpeed, where Player.getDestroySpeed ends.
self.native_break_speed(pos, state, speed, &mut speed);
speed / hardness / if harvest { 30.0 } else { 100.0 }
mining::destroy_progress(hardness, harvest, speed)
}
/// `EntityFluidInteraction`: compare the eyes with the camera fluid surface.
fn mining_eyes_in_water(&self) -> bool {
@@ -147,7 +147,7 @@ impl Game<'_> {
// ItemStack.isDamageableItem: component removal and
// unbreakable are checked before enchantment RNG or hooks.
if stack.count == 0
|| !info.equip_damage_on_hurt
|| !horizon_client::item_sync::damage_on_hurt(&stack, info.equip_damage_on_hurt)
|| component("minecraft:unbreakable").is_some()
|| component("!minecraft:max_damage").is_some()
|| component("!minecraft:damage").is_some()
@@ -242,10 +242,8 @@ impl Game<'_> {
{
stack.damage += amount;
if stack.damage >= max {
let broken = stack.item.clone();
let gone = stack.clone();
*cell = None;
self.award_stat("broken", &broken, 1);
// LivingEntity.onEquippedItemBroken: the sound and the crumbs.
self.player_equipment_broke(break_slot, &gone);
}
@@ -41,6 +41,8 @@ impl Game<'_> {
let mut cells = horizon_client::entities::ItemCells::new(&items);
// A LAN or server guest only shows the host's items.
let merging = self.remote.is_none();
// `Entity.checkBelowWorld`: below `Level.getMinY() - 64`.
let void = f64::from(self.level_min_y() - 64);
let mut obstacles =
horizon_client::entities::ObstacleGrid::new(self.item_entity_collisions());
let mut nearby = Vec::new();
@@ -144,7 +146,7 @@ impl Game<'_> {
2
};
if merging
&& (alive || item.age == 6000 && item.position.y > -128.0)
&& (alive || item.age == 6000 && item.position.y >= void)
&& item.ticks.is_multiple_of(rate)
{
let max_stack = |stack: &ItemStack| self.stack_max_size(stack).max(1);
@@ -157,8 +159,8 @@ impl Game<'_> {
);
}
let item = &mut items[i];
let mut alive = alive || (item.age < 6000 && item.position.y > -128.0);
if merging && !removed[i] && item.age >= 6000 && item.position.y > -128.0 {
let mut alive = alive || (item.age < 6000 && item.position.y >= void);
if merging && !removed[i] && item.age >= 6000 && item.position.y >= void {
let mut extra_life = 0;
self.native_item_expire(item.id, &item.stack, &mut extra_life);
if extra_life > 0 {
@@ -497,6 +499,7 @@ impl Game<'_> {
}
self.next_entity += 1;
self.orbs.push(ExperienceOrb {
fall_distance: 0.0,
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
@@ -793,6 +796,20 @@ impl Game<'_> {
self.player_drop(thrown);
}
/// `ServerPlayer.drop(boolean)` for the guest `conn` (`gi` in the hub): the
/// drop key's throw, then `setRemoteSlot` on the selected slot so a pickup
/// that refills the stack is sent to the client that predicted the drop.
pub(super) fn guest_drop_held(
&mut self,
host: &mut super::lan::LanHost,
gi: usize,
conn: u32,
all: bool,
) {
self.as_guest(host, conn, |g| g.drop_held(all));
host.hub.guests[gi].told_selected();
}
/// `Player.createItemStackToDrop`: an item entity thrown from the eyes
/// along the view direction. `thrown_from_hand` (`drop(stack, true)`, the
/// drop key) names the player as the thrower; `drop(stack, false)`, for
@@ -1032,3 +1049,7 @@ mod escape_tests {
assert_eq!(game.world.random.next_float(), level.next_float());
}
}
#[cfg(test)]
#[path = "item_void_tests.rs"]
mod item_void_tests;
@@ -73,6 +73,9 @@ impl Game<'_> {
_ if let Some(i) = self.res.items().instrument(stack) => i.use_ticks(),
_ if let Some(consumable) = self.consumable(stack) => consumable.ticks(),
_ if let Some(ticks) = patched_ticks => ticks,
_ if let Some(seconds) = horizon_client::item_sync::consumable_seconds(stack) => {
(seconds * 20.0) as i32
}
_ => match info {
Some(i) if let Some(c) = &i.consumable => c.ticks(),
_ if patched_blocks => 72_000,
@@ -0,0 +1,59 @@
//! `Entity.checkBelowWorld`: a dropped item is discarded below its level's
//! floor minus 64, so the Nether and the End lose one at y = -64 while the
//! Overworld keeps it down to y = -128.
use super::super::Dim;
use super::super::native_machines::tests::server_game;
use horizon_client::chunk::Chunk;
use horizon_client::items::ItemStack;
use horizon_client::motion::PlayerMotion;
use horizon_core::geometry::Vec3;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn items_fall_to_each_dimensions_own_void_floor() {
let mut game = server_game();
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(8.5, 70.0, 8.5)).unwrap());
for x in -2..=2 {
for z in -2..=2 {
game.world
.chunks
.insert((x, z), Arc::new(Chunk::empty(x, z)));
}
}
game.update_chunk_tickets();
assert!(game.entity_ticking_area()(8, 8));
let keys: HashSet<(i32, i32)> = [(0, 0)].into();
for (dim, floor) in [
(Dim::Overworld, -128.0),
(Dim::Nether, -64.0),
(Dim::End, -64.0),
] {
game.dim = dim;
game.world.shape = dim.shape();
for (y, survives) in [(floor + 0.5, true), (floor - 0.5, false)] {
game.dropped.clear();
game.spawn_item(
ItemStack::new("minecraft:stone", 1),
[8.5, y, 8.5],
[0.0, 0.0, 0.0],
0,
);
game.ticked_entities_through = u32::MAX;
game.tick_items();
assert_eq!(
!game.dropped.is_empty(),
survives,
"{dim:?}: an item at y {y}, against the floor {floor}"
);
let mut saved = HashMap::new();
game.save_every_entity(&keys, &mut saved);
assert_eq!(
saved.values().map(Vec::len).sum::<usize>() > 0,
survives,
"{dim:?}: an item at y {y} in the save"
);
}
}
}
Loaded 100 of 290 files, more files were not shown because too many files have changed in this diff. Show more