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
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
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
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 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 344bcdec9a Merge commit 'aab654d4e8' into unstick/15021
# Conflicts:
#	launcher/src/account/tests.rs
2026-11-12 02:07:33 +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
Kacpherek 45f36d84e1 Keep reset_unlit_fire before control.rs's test module 2026-11-12 01:36:18 +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
Kacpherek 10004fcd77 Merge remote-tracking branch 'origin/main' into unstick/14850 2026-11-12 01:35:00 +01:00
Joansitoh f8e59c3e3b Merge upstream main into fix/14371-rod-neighbours 2026-11-12 01:31:16 +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 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 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 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 0c03fe844b Merge branch 'main' into perf-equipment-cache 2026-11-12 01:02:19 +01:00
Kacper ede16abf28 Merge branch 'main' into fix/guest-invalid-entity-attack-kick 2026-11-12 01:01:56 +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 2e23fd1dd1 Merge branch 'main' into perf/faster-client-lib-tests 2026-11-12 00:59:58 +01:00
Kacper 90827741ae Merge branch 'main' into up13/camera-armor-stand 2026-11-12 00:58:39 +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 1b016ba22e Merge branch 'main' into fix/chiseled-shelf-can-take 2026-11-12 00:55:44 +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 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
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
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 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
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
Kacper 6c58b77fed Merge branch 'main' into fix/explosion-creaking-heart-xp 2026-11-11 23:52:41 +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 9aae4a6671 Merge commit '3298a6dcd5' into unstick/14376 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 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 8dc176f4e6 Merge branch 'main' into fix/launcher-sign-in-bounds 2026-11-11 23:47:50 +01:00
Kacper 13fe0905ba Merge branch 'main' into split-renderer 2026-11-11 23:47:32 +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
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 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 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 4a90452d73 Merge branch 'main' into fix/drowned-fluid-push 2026-11-11 23:19:17 +01:00
Kacper b1fb8a4048 Merge branch 'main' into fix/adventure-sign-place-on 2026-11-11 23:18:25 +01:00
Kacper b9abf293aa Merge branch 'main' into fix/14371-rod-neighbours 2026-11-11 23:17:47 +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
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
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 46179fd42e Pack screen: no I-beam on the filter while the Add-pack browser covers it 2026-11-11 22:23:34 +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
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 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
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 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
Akirabane f2ea985b8d Merge remote-tracking branch 'origin/main' into local/W3-02-invalid-attack-kick 2026-11-11 21:33:47 +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 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 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 983bd56fb5 Merge main into pr-14598
# Conflicts:
#	crates/horizon-client/src/lan/prediction.rs
2026-11-11 20:54:52 +01:00
Kacpherek b61cff5625 Merge main into fix/chiseled-shelf-can-take 2026-11-11 20:44:41 +01:00
Kacpherek 37f6d169d7 Merge main into port_map/farmland-is-near-water 2026-11-11 20:44:26 +01:00
Kacpherek 7b98f70c85 Merge main into fix/15013-launcher-ipv4-fallback 2026-11-11 20:43:13 +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-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
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
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
Riccardo Guerriero 09b8b5b94b Merge origin/main into perf/faster-client-lib-tests 2026-11-11 15:57:05 +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
DUzzL c741fd4d39 Remove empty custom_data after set_custom_data item modifier 2026-11-11 15:04:13 +01:00
DUzzL 64fc971ebc Honor an egg's explicit chicken variant removal before the item default 2026-11-11 14:39:38 +01: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 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
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 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 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
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
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
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
Vinícius Moreira 306dca0a21 Merge remote-tracking branch 'upstream/main' into fix/explosion-creaking-heart-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 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 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
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
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
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
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 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
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 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 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
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
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 7f8e095514 Keep ordinary explosion reference tests on the default calculator 2026-11-09 18:04:34 -06: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
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
voidsip fa7424ca31 Protect sulfur cube blasts while portal cooldown is positive 2026-11-09 14:15:30 -06: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 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
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
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
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
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 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
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
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
MSedgeMC 35fc116bd3 fix: drop the goat's old call and pass the memory value to number 2026-11-09 06:41:45 +00: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
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
164 changed files with 5938 additions and 1089 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;
+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));
}
@@ -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,
@@ -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);
@@ -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));
}
@@ -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();
+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.
@@ -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;
+54 -19
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;
@@ -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);
}
}
+92 -22
View File
@@ -916,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(
@@ -2353,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`.
@@ -2372,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));
@@ -2398,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(),
@@ -2652,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;
@@ -2913,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)
}
@@ -3049,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))),
@@ -3127,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();
@@ -3146,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 {
@@ -4174,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;
@@ -4221,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 {
@@ -4551,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);
@@ -4794,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,
},
@@ -5799,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,
},
+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()),
+8 -53
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
@@ -8321,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) {
@@ -8399,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.
@@ -9110,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.
+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 {
+3
View File
@@ -143,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`).
+8 -17
View File
@@ -2613,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", &[]));
@@ -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()));
}
}
+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
+24 -32
View File
@@ -3,6 +3,8 @@
//! 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;
@@ -2310,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!(
@@ -2321,6 +2322,7 @@ impl Game<'_> {
"minecraft:entity_action"
| "minecraft:prime_fuse"
| "minecraft:eat"
| "minecraft:drink"
| "minecraft:container_open"
| "minecraft:container_close"
| "minecraft:entity_place"
@@ -2532,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"),
@@ -2561,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;
@@ -3970,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 {
@@ -3984,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 {
@@ -5307,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) {
@@ -5575,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));
}
}
}
@@ -7138,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).
@@ -7642,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};
@@ -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)
}
@@ -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;
+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,
+31 -7
View File
@@ -109,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)>,
}
@@ -117,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
@@ -417,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 {
@@ -453,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;
@@ -501,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)
}
@@ -1253,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 =
@@ -1341,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.
@@ -1702,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:?}");
}
+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.
+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| {
@@ -499,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,
@@ -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,
@@ -3477,13 +3477,7 @@ impl Game<'_> {
if host.hub.guests[gi].spectator() {
return;
}
// handleAttack: a target inside the world border, then
// `isWithinAttackRange(mainHandItem, box, 3.0)` before anything else, players,
// mobs, objects and fireballs alike.
if let Some(target) = self.guest_target_box(host, gi, id)
&& (!self.inside_border(&target)
|| self.as_guest(host, conn, |g| g.within_attack_range(&target)) != Some(true))
{
if self.guest_attack_stops(host, gi, id) {
return;
}
// Native mods, where Player.attack starts: a canceled blow does
@@ -6220,6 +6214,7 @@ fn eye_columns(at: [f64; 3], body: Dimensions) -> [std::ops::RangeInclusive<i32>
[range(at[0]), range(at[2])]
}
mod attack_checks;
mod dig_stop;
use dig_stop::{StopDig, stop_dig};
@@ -0,0 +1,333 @@
//! `ServerGamePacketListenerImpl.handleAttack`'s checks between the target's lookup and
//! `Player.attack`: the world border, the attack range, and the targets no player may attack.
use super::*;
use horizon_client::projectiles::ProjectileKind;
use horizon_protocol::{packets::configuration::UpdateTags, text::translatable};
impl Game<'_> {
/// Whether a guest's attack on entity `id` ends before `Player.attack` (handleAttack).
/// The guest who attacks an `ItemEntity`, an `ExperienceOrb`, themselves or an
/// `AbstractArrow` that is not `isAttackable` is disconnected with
/// `multiplayer.disconnect.invalid_entity_attacked`.
pub(super) fn guest_attack_stops(&mut self, host: &mut LanHost, gi: usize, id: i32) -> bool {
let conn = host.hub.guests[gi].conn;
// handleAttack: a target inside the world border, then
// `isWithinAttackRange(mainHandItem, box, 3.0)` before anything else, players,
// mobs, objects and fireballs alike.
if let Some(target) = self.guest_target_box(host, gi, id)
&& (!self.inside_border(&target)
|| self.as_guest(host, conn, |g| g.within_attack_range(&target)) != Some(true))
{
return true;
}
let Some(target) = self.refused_target(host, gi, id) else {
return false;
};
// The same checks for these targets, then `PIERCING_WEAPON` in the main hand
// returns before the kick; handleAttack ignores a player whose client has not
// loaded (hasClientLoaded) before all of them.
if !host.hub.guests[gi].client_loaded()
|| !self.inside_border(&target)
|| self.as_guest(host, conn, |g| {
g.within_attack_range(&target) && !g.holds_piercing_weapon()
}) != Some(true)
{
return true;
}
let name = host.hub.guests[gi].name.clone();
host.hub.kick(
conn,
translatable("multiplayer.disconnect.invalid_entity_attacked", &[]),
);
log::warn!("Player {name} tried to attack an invalid entity");
true
}
/// The box of entity `id` when handleAttack refuses it as a target: an `ItemEntity`, an
/// `ExperienceOrb`, the attacking player, or an `AbstractArrow` (an arrow, a spectral
/// arrow, a trident) that is not `isAttackable`. Only for a guest in the level in place,
/// whose entities these are.
fn refused_target(&self, host: &LanHost, gi: usize, id: i32) -> Option<Aabb> {
let g = &host.hub.guests[gi];
if !host.hub.here(g) {
return None;
}
if g.entity_id == id {
return Some(g.bounds());
}
let id = u32::try_from(id).ok().filter(|&id| id != 0)?;
if let Some(item) = self.dropped.iter().find(|i| i.id == id) {
return Some(item.bounds());
}
if let Some(orb) = self.orbs.iter().find(|o| o.id == id) {
return Some(orb.bounds());
}
let sync = host.hub.lan.shared.sync();
let refused =
|kind: &str| !arrow_attackable(&sync.tags, sync.id("minecraft:entity_type", kind));
if let Some(p) = self.creatures.projectiles.iter().find(|p| {
p.id == id
&& matches!(
p.kind,
ProjectileKind::Arrow { .. } | ProjectileKind::Trident
)
}) {
return refused(p.entity_type()).then(|| p.bounds());
}
// A thrown trident: `EntityType.TRIDENT`, 0.5 x 0.5.
let t = self.tridents.iter().find(|t| t.id == id)?;
let p = t.position;
refused("minecraft:trident").then(|| {
Aabb::new(
Vec3::new(p.x - 0.25, p.y, p.z - 0.25),
Vec3::new(p.x + 0.25, p.y + 0.5, p.z + 0.25),
)
})
}
/// The main-hand stack has `minecraft:piercing_weapon` (a spear's).
fn holds_piercing_weapon(&self) -> bool {
self.held().is_some_and(|stack| {
self.pack
.item_renderer
.models
.components_view(&horizon_client::item_render::stack_view(stack))
.contains_key("minecraft:piercing_weapon")
})
}
}
/// `AbstractArrow.isAttackable`: the arrow's type (`type_id`) is in
/// `#minecraft:redirectable_projectile`, as the server sent its tags to the players, a world's
/// data packs included. A type or a tag missing from the data counts as attackable, so no
/// player is kicked over data the server does not have.
fn arrow_attackable(tags: &UpdateTags, type_id: Option<i32>) -> bool {
let Some(type_id) = type_id else {
return true;
};
let tag = tags
.registries
.iter()
.find(|r| r.registry == "minecraft:entity_type")
.and_then(|r| {
r.tags
.iter()
.find(|t| t.name == "minecraft:redirectable_projectile")
});
tag.is_none_or(|t| t.ids.contains(&type_id))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::game::{Audio, Options, Progress, Settings, load_resources};
use horizon_client::chunk::Chunk;
use horizon_client::lan::{Joined, NetConfig, Out, PlayerSender, guest::Guest, hub::HubConfig};
use horizon_core::position::ChunkPos;
use horizon_protocol::packets::configuration::{
ClientInformation, RegistryTags, TagEntry, UpdateTags,
};
use std::sync::{Arc, atomic::AtomicI32, mpsc};
fn tags(redirectable: &[i32]) -> UpdateTags {
UpdateTags {
registries: vec![RegistryTags {
registry: "minecraft:entity_type".into(),
tags: vec![TagEntry {
name: "minecraft:redirectable_projectile".into(),
ids: redirectable.to_vec(),
}],
}],
}
}
/// `AbstractArrow.isAttackable` reads the tag the players were sent: a data pack that
/// puts arrows in `#minecraft:redirectable_projectile` makes them attackable, and a
/// vanilla client then picks and attacks them, which must not kick.
#[test]
fn an_arrow_is_attackable_only_in_the_sent_redirectable_tag() {
assert!(!arrow_attackable(&tags(&[30, 40]), Some(5)));
assert!(arrow_attackable(&tags(&[5, 30, 40]), Some(5)));
assert!(arrow_attackable(&tags(&[30]), None), "an unknown type");
assert!(
arrow_attackable(&UpdateTags { registries: vec![] }, Some(5)),
"no entity type tags"
);
}
fn disconnects(rx: &mpsc::Receiver<Out>) -> Vec<horizon_world::nbt::Tag> {
rx.try_iter()
.filter_map(|out| match out {
Out::Disconnect(reason) => Some(reason),
_ => None,
})
.collect()
}
/// handleAttack disconnects a player who attacks a dropped item, an experience orb,
/// themselves, an arrow or a thrown trident (`invalid_entity_attacked`); a pig is
/// attacked as before, and an item out of reach, or attacked with a spear in hand,
/// ends the attack without a kick.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn attacking_an_item_an_orb_oneself_or_an_arrow_kicks_the_player() {
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.game_dir = Some(
std::env::temp_dir().join(format!("horizon-attack-checks-unit-{}", std::process::id())),
);
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.set_allow_commands(true);
let stone = game
.pack
.assets
.registry
.default_state("minecraft:stone")
.unwrap()
.0;
let mut chunk = Chunk::empty(0, 0);
for x in 0..16 {
for z in 0..16 {
chunk.set(x, 199, z, stone);
}
}
game.world.chunks.insert((0, 0), Arc::new(chunk));
game.block_ticks
.add_empty_chunk(ChunkPos::new(0, 0))
.unwrap();
assert!(game.run_command("forceload add 0 0"));
game.lan_host(
crate::game::lan::LanSettings {
port: 0,
guest_commands: true,
force_game_mode: false,
},
NetConfig {
announce: false,
..NetConfig::loopback(0, "attack-checks-test".into(), 2)
},
HubConfig {
default_game_mode: 0,
guest_commands: true,
force_game_mode: false,
hardcore: false,
view_distance: 2,
simulation_distance: 2,
dimension: "minecraft:overworld".into(),
allow_flight: false,
seed: 0,
max_players: 2,
integrated: false,
levels: Default::default(),
},
)
.unwrap();
let summoned = |game: &mut Game<'_>, command: &str| {
let before = game.next_entity;
assert!(game.run_command(command), "{command}");
game.lan_assign_ids();
assert!(game.next_entity > before, "{command}");
game.next_entity as i32
};
let pig = summoned(
&mut game,
"summon pig 6.5 200 4.5 {NoAI:1b,NoGravity:1b,PersistenceRequired:1b}",
);
let item = summoned(
&mut game,
"summon item 4.5 200 6 {NoGravity:1b,Item:{id:\"minecraft:stone\",count:1}}",
);
let far_item = summoned(
&mut game,
"summon item 4.5 200 12.5 {NoGravity:1b,Item:{id:\"minecraft:stone\",count:1}}",
);
let orb = summoned(
&mut game,
"summon experience_orb 3 200 4.5 {NoGravity:1b,Value:1s}",
);
let arrow = summoned(&mut game, "summon arrow 4.5 200.5 3 {NoGravity:1b}");
let trident = summoned(&mut game, "summon trident 5.5 200.5 3 {NoGravity:1b}");
let (tx, rx) = mpsc::channel();
let mut guest = Guest::new(
Joined {
conn: 2,
uuid: 332,
name: "AttackGuest".into(),
properties: Vec::new(),
info: ClientInformation::new(2),
brand: "test".into(),
sender: PlayerSender::new(tx, Arc::default()),
latency: Arc::new(AtomicI32::new(0)),
address: "127.0.0.1:25565".parse().unwrap(),
expected: false,
configured: Default::default(),
},
game.next_entity as i32 + 1,
[4.5, 200.0, 4.5],
2,
);
game.next_entity += 1;
guest.dimension = "minecraft:overworld".into();
guest.placed = true;
guest.loaded = true;
guest.load_timeout = 0;
let me = guest.entity_id;
let mut host = game.lan.take().unwrap();
host.hub.guests.push(guest);
let attack = |game: &mut Game<'_>, host: &mut LanHost, id: i32| {
game.lan_guest_packet(host, 2, S::Attack(play::Attack { id }));
};
let kicked = vec![translatable(
"multiplayer.disconnect.invalid_entity_attacked",
&[],
)];
let hp = |g: &Game<'_>| g.mobs.iter().find(|m| m.id as i32 == pig).unwrap().health;
let full = hp(&game);
attack(&mut game, &mut host, pig);
assert_eq!(disconnects(&rx), vec![], "a pig is a target");
assert!(hp(&game) < full, "the pig is not hurt: {full} health left");
attack(&mut game, &mut host, far_item);
assert_eq!(disconnects(&rx), vec![], "an item out of reach");
host.hub.guests[0].inventory[0] = Some(ItemStack::new("minecraft:iron_spear", 1));
host.hub.guests[0].selected = 0;
attack(&mut game, &mut host, item);
assert_eq!(disconnects(&rx), vec![], "a spear in hand");
host.hub.guests[0].inventory[0] = None;
for (what, id) in [
("an item", item),
("an experience orb", orb),
("themselves", me),
("an arrow", arrow),
("a thrown trident", trident),
] {
attack(&mut game, &mut host, id);
assert_eq!(disconnects(&rx), kicked, "{what}");
}
}
}
+42 -6
View File
@@ -163,8 +163,13 @@ impl WorldOptions {
/// the cursor is over it. The box is drawn even with LAN off, so the tooltip
/// shows there too; a remote world has no such box.
fn port_tooltip(port: &EditBox, cursor: (f32, f32), remote: bool) -> Option<Text> {
(!remote && ui::hovered(cursor, port.x, port.y, port.width, port.height))
.then(|| Text::translate("lanServer.port", &[]))
if remote || !ui::hovered(cursor, port.x, port.y, port.width, port.height) {
return None;
}
Some(match port_error(&port.value) {
Some(key) => Text::translate(key, &[Text::literal("1024"), Text::literal("65535")]),
None => Text::translate("lanServer.port", &[]),
})
}
fn snapshot(&self) -> Snapshot {
Snapshot {
@@ -556,6 +561,18 @@ impl Restrictions {
}
}
/// `WorldOptionsScreen.tryParsePort`'s invalid value: not a number in
/// `PORT_LOWER_BOUND..=PORT_HIGHER_BOUND`. A number another process holds
/// (`HttpUtil.isPortAvailable`) is not checked: the port map records that gap.
fn port_error(value: &str) -> Option<&'static str> {
if value.is_empty() {
return None;
}
match value.parse::<u32>() {
Ok(port) if (1024..=65535).contains(&port) => None,
_ => Some("lanServer.port.invalid"),
}
}
impl Game<'_> {
pub(super) fn remote_world_options_permission(&mut self, permission: bool) {
if let Some(o) = self.world_options.as_mut() {
@@ -1019,7 +1036,8 @@ impl Game<'_> {
}
// The widgets are drawn; the port box's tooltip goes over them.
if let Some(tip) = WorldOptions::port_tooltip(&o.port, cursor, o.remote) {
ui::tooltip(batch, atlas, &[tip], cursor);
// Tooltip.splitTooltip: font.split(message, 170).
ui::tooltip(batch, atlas, &atlas.font.split(&tip, 170.0), cursor);
}
}
@@ -1918,11 +1936,29 @@ mod tests {
}
/// `portEdit.setTooltip(PORT_INFO_TEXT)`: hovering the port box shows its
/// label; a remote world (`o.remote`) has no port box to hover.
/// label; `setPortError` turns that into the reason a number is rejected.
/// A remote world (`o.remote`) has no port box to hover.
#[test]
fn the_port_box_explains_itself_on_hover() {
let port = EditBox::new(158.0, 100.0, 40.0, 20.0);
assert!(WorldOptions::port_tooltip(&port, (159.0, 101.0), false).is_some());
let mut port = EditBox::new(158.0, 100.0, 40.0, 20.0);
let tip = |port: &EditBox| {
WorldOptions::port_tooltip(port, (159.0, 101.0), false).map(|t| t.plain())
};
assert_eq!(tip(&port).as_deref(), Some("lanServer.port"));
// Empty takes the suggestion, a number in range is fine.
assert_eq!(tip(&port).as_deref(), Some("lanServer.port"));
for bad in ["0", "80", "70000", "abc", "-1"] {
port.set_value(bad);
assert_eq!(
tip(&port).as_deref(),
Some("lanServer.port.invalid"),
"{bad}"
);
}
for good in ["1024", "25565", "65535"] {
port.set_value(good);
assert_eq!(tip(&port).as_deref(), Some("lanServer.port"), "{good}");
}
assert!(WorldOptions::port_tooltip(&port, (157.0, 101.0), false).is_none());
assert!(WorldOptions::port_tooltip(&port, (159.0, 121.0), false).is_none());
assert!(WorldOptions::port_tooltip(&port, (159.0, 101.0), true).is_none());
@@ -0,0 +1,146 @@
//! Lectern placement and the `block_change` game event of a book going on or
//! off (`LecternBlock.getStateForPlacement`, `LecternBlock.placeBook` and
//! `LecternBlockEntity.onBookItemRemove`).
use super::super::native_machines::tests::server_game;
use super::Game;
use crate::game::{Hit, sculk::TEST_POSTED_EVENTS};
use horizon_client::{
containers::{BlockEntity, Lectern},
items::ItemStack,
mobs::Who,
};
/// A lectern at `pos`: the state's `has_book`, and the entity's book.
fn lectern(game: &mut Game<'_>, pos: [i32; 3], has_book: bool, book: Option<ItemStack>) {
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 = registry.id(&state).unwrap();
game.world.set(pos[0], pos[1], pos[2], state).unwrap();
game.world.block_entities.insert(
(pos[0], pos[1], pos[2]),
BlockEntity::Lectern(Lectern { book, page: 0 }),
);
}
/// The lectern item `/give` hands out with a written book in its
/// `block_entity_data`.
fn lectern_item() -> ItemStack {
let text = r#"{"minecraft:block_entity_data":{id:"minecraft:lectern",Book:{id:"minecraft:written_book",count:1,components:{"minecraft:written_book_content":{title:"t",author:"a",pages:["p1","p2"]}}}}}"#;
let components = horizon_world::nbt::parse_snbt(text).expect("snbt").0;
let (damage, extra) = horizon_client::item_data::decode_components(Some(&components));
ItemStack {
damage,
extra,
..ItemStack::new("minecraft:lectern", 1)
}
}
fn click(pos: [i32; 3]) -> Hit {
Hit {
pos,
face: [0, 1, 0],
point: [f64::from(pos[0]) + 0.5, f64::from(pos[1]), 0.5],
}
}
/// The `has_book` of the lectern state at `pos`, and what a comparator beside
/// it reads (`LecternBlock.getAnalogOutputSignal`).
fn state_at(game: &Game<'_>, pos: [i32; 3]) -> (String, i32) {
let registry = game.pack.assets.registry;
let state = game.world.block(pos[0], pos[1], pos[2]);
let has_book = registry
.state(state)
.unwrap()
.properties
.get("has_book")
.cloned()
.unwrap_or_default();
let at = horizon_core::position::BlockPos::new(pos[0], pos[1], pos[2]);
let comparator = super::lectern_signal(&game.world, registry, state, at).expect("a lectern");
(has_book, comparator)
}
/// `LecternBlock.getStateForPlacement`: the state's `has_book` comes from the
/// item's `block_entity_data.Book` for a placer who may use game-master blocks
/// (`minecraft:lectern` is in `BlockEntityTypes.OP_ONLY_CUSTOM_DATA`, so the
/// block entity only takes the book for one too), and not for anyone else.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_placed_lectern_takes_has_book_from_its_item() {
let mut game = server_game();
game.set_mode_id(1);
game.ui.registries.permission = 2;
*game.held_mut() = Some(lectern_item());
assert!(game.use_hand(Some(&click([8, 64, 8]))).unwrap());
assert!(
matches!(game.world.block_entities.get(&(8, 64, 8)), Some(BlockEntity::Lectern(l)) if l.book.is_some())
);
// A two-page book on page 0: `floor(0 / 1 * 14) + 1`.
assert_eq!(state_at(&game, [8, 64, 8]), ("true".into(), 1));
// The same item from a placer who may not use game-master blocks gains
// neither the state nor the block entity book, so the comparator reads 0.
game.ui.registries.permission = 0;
*game.held_mut() = Some(lectern_item());
assert!(game.use_hand(Some(&click([10, 64, 8]))).unwrap());
assert!(
matches!(game.world.block_entities.get(&(10, 64, 8)), Some(BlockEntity::Lectern(l)) if l.book.is_none())
);
assert_eq!(state_at(&game, [10, 64, 8]), ("false".into(), 0));
}
fn record_events(record: bool) {
TEST_POSTED_EVENTS.with(|e| *e.borrow_mut() = record.then(Vec::new));
}
/// The `block_change` game events posted since `record_events(true)`, with
/// their position and source entity.
fn block_changes() -> Vec<(&'static str, [f64; 3], Option<Who>)> {
TEST_POSTED_EVENTS.with(|e| {
let events = e.borrow();
let changes = events.as_ref().unwrap().iter();
changes
.filter(|(e, _, _)| *e == "minecraft:block_change")
.copied()
.collect()
})
}
/// `LecternBlock.placeBook` -> `resetBookState` with the player as the source
/// entity, and `LecternBlockEntity.onBookItemRemove` -> `resetBookState(null,
/// ...)`: one `block_change` at the lectern each way, and `has_book` follows.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_book_going_on_or_off_posts_one_block_change() {
let mut game = server_game();
let key = (8, 64, 8);
lectern(&mut game, [8, 64, 8], false, None);
*game.held_mut() = Some(ItemStack::new("minecraft:written_book", 1));
record_events(true);
assert!(game.use_hand(Some(&click([8, 64, 8]))).unwrap());
assert_eq!(
block_changes(),
[(
"minecraft:block_change",
[8.5, 64.5, 8.5],
Some(Who::Player)
)]
);
assert_eq!(state_at(&game, [8, 64, 8]).0, "true");
// Taking it back off through the lectern's menu.
record_events(true);
let mut menu = game.lectern_menu(key).expect("the lectern's menu");
menu.set_slot(&game.menu_env(), 0, None);
game.lectern_menu_done(key, &menu);
assert_eq!(
block_changes(),
[("minecraft:block_change", [8.5, 64.5, 8.5], None)]
);
assert_eq!(state_at(&game, [8, 64, 8]).0, "false");
record_events(false);
}
+70 -10
View File
@@ -19,6 +19,7 @@ use horizon_core::{
position::BlockPos,
random::{LegacyRandom, RandomSource},
};
use horizon_world::level::Ticks;
pub(super) struct Bolt {
/// The entity id a server's players know it by (0 until it has one).
@@ -65,6 +66,13 @@ fn in_viewer_box(bolt: [f64; 3], p: [f64; 3]) -> bool {
&& p[1] >= bolt[1] - 15.0
&& p[1] <= bolt[1] + 21.0
}
/// `ServerLevel.tickThunder` -> `Level.getBlockRandomPos(minX, 0, minZ, 15)`:
/// picks the strike column from the level's separate `randValue` LCG.
fn thunder_strike_column(ticks: &mut Ticks, cx: i32, cz: i32) -> (i32, i32) {
let r = ticks.block_random_pos(cx * 16, 0, cz * 16, 15);
(r.x, r.z)
}
impl Game<'_> {
/// `ServerChunkCache.tickSpawningChunk`: one `tickThunder` roll per
/// spawning chunk (within 128 blocks of a player) that is in entity
@@ -90,8 +98,7 @@ impl Game<'_> {
continue;
}
// getBlockRandomPos(minX, 0, minZ, 15)
let r = self.world.random.next_int();
let (x, z) = (cx * 16 + (r & 15), cz * 16 + (r >> 16 & 15));
let (x, z) = thunder_strike_column(&mut self.block_ticks, cx, cz);
let Some(pos) = self.lightning_target(x, z) else {
continue;
};
@@ -445,14 +452,14 @@ impl Game<'_> {
// is the same block as before, so its placement does not schedule
// it, and without this it stayed powered for good.
b.schedule_tick(level, ticks, BlockPos::new(p[0], p[1], p[2]), 8);
if level.redstone_experiments {
b.lightning_rod_update_neighbors(
level,
ticks,
BlockPos::new(p[0], p[1], p[2]),
state,
);
}
// `updateNeighbours`: unconditional; the experiments only decide
// whether the update carries an orientation.
b.lightning_rod_update_neighbors(
level,
ticks,
BlockPos::new(p[0], p[1], p[2]),
state,
);
});
}
// Level event 3002 with the rod's axis: sparks along it.
@@ -831,6 +838,44 @@ mod tests {
}
}
/// `LightningRodBlock.onLightningStrike` updates the neighbours of the block
/// in front of the rod whether or not redstone experiments are on (#14371):
/// a wire beside that block is strongly powered by the struck rod.
#[test]
#[ignore = "needs prepared resources (HORIZON_TEST_RESOURCES)"]
fn a_struck_rod_updates_the_wire_beside_the_block_behind_it() {
let mut game = super::super::native_machines::tests::server_game();
let registry = game.pack.assets.registry;
let stone = registry.default_state("minecraft:stone").unwrap();
let wire = registry.default_state("minecraft:redstone_wire").unwrap();
let mut rod = registry
.state(registry.default_state("minecraft:lightning_rod").unwrap())
.cloned()
.unwrap();
rod.properties.insert("facing".into(), "up".into());
let rod = registry.id(&rod).unwrap();
// The rod on stone at (8, 64, 8); wire on stone beside that block.
game.world.set(8, 64, 8, stone).unwrap();
game.world.set(9, 63, 8, stone).unwrap();
game.world.set(9, 64, 8, wire).unwrap();
game.world.set(8, 65, 8, rod).unwrap();
assert!(
!game
.level
.enabled_features
.iter()
.any(|feature| feature == "minecraft:redstone_experiments")
);
game.strike_rod([8.5, 66.0, 8.5]);
let power = registry
.state(game.world.block(9, 64, 8))
.unwrap()
.properties
.get("power")
.cloned();
assert_eq!(power.as_deref(), Some("15"));
}
#[test]
fn viewer_box_reaches_15_around_and_down_and_21_up() {
let bolt = [0.0, 64.0, 0.0];
@@ -841,6 +886,21 @@ mod tests {
assert!(!in_viewer_box(bolt, [15.5, 64.0, 0.0]));
}
/// `ServerLevel.tickThunder` picks its strike column from `Level.randValue`
/// (`Ticks::block_random_pos`), using bits `2..=5` for X and `10..=13` for Z
/// rather than drawing from `Level.random`.
#[test]
fn thunder_strike_column_uses_block_random_pos_lcg_bits() {
let mut ticks = Ticks::new(12345);
let mut expected_lcg = 12345i32;
for (cx, cz) in [(0, 0), (-3, 5), (12, -8)] {
expected_lcg = expected_lcg.wrapping_mul(3).wrapping_add(1_013_904_223);
let r = expected_lcg >> 2;
let expected = (cx * 16 + (r & 15), cz * 16 + ((r >> 8) & 15));
assert_eq!(thunder_strike_column(&mut ticks, cx, cz), expected);
}
}
/// `Pig.thunderHit`: striking a pig with lightning converts it to a zombified
/// piglin while preserving age (#7510).
#[test]
@@ -122,6 +122,61 @@ fn a_rocket_posts_its_shot_once_and_a_silent_one_launches_quietly() {
assert_eq!(game.audio.heard.get(launch), Some(&1));
});
}
/// #14440: an orb's move adds its drop to the fall outside water; landing
/// after a fall posts HIT_GROUND and forgets the fall (`Entity.checkFallDamage`);
/// in water the drop adds nothing, so a landing posts nothing.
#[test]
#[ignore = "prepared resources; actual registered load, Game tick and posted events"]
fn a_landing_orb_posts_hit_ground_after_a_fall() {
use horizon_client::entities::OrbMove;
with_game(|game| {
assert!(game.run_command("summon experience_orb 6.5 200 10.5 {Value:1,fall_distance:2d}"));
let i = game.orbs.len() - 1;
let from = Vec3::new(6.5, 200.5, 10.5);
let down = Vec3::new(0.0, -0.5, 0.0);
start_events();
// In the air: the drop adds to the fall.
game.orbs[i].on_ground = false;
game.orb_moved(
i,
OrbMove {
from,
moved: down,
in_water: false,
},
);
assert_eq!(game.orbs[i].fall_distance, 2.5);
// On the ground: HIT_GROUND, then the fall is gone.
game.orbs[i].on_ground = true;
game.orb_moved(
i,
OrbMove {
from,
moved: down,
in_water: false,
},
);
assert_eq!(game.orbs[i].fall_distance, 0.0);
// In water with no fall: no drop, so no landing event.
game.orb_moved(
i,
OrbMove {
from,
moved: down,
in_water: true,
},
);
let events: Vec<_> = crate::game::sculk::TEST_POSTED_EVENTS.with(|v| {
std::mem::take(v.borrow_mut().as_mut().unwrap())
.into_iter()
.filter(|e| e.0 == "minecraft:hit_ground")
.collect()
});
stop_events();
assert_eq!(events.len(), 1);
assert_eq!(game.orbs[i].fall_distance, 0.0);
});
}
/// #14318: an angled rocket falling onto stone builds up its fall and posts
/// HIT_GROUND when it lands (`Entity.checkFallDamage`); one falling into water
/// splashes once (`Entity.doWaterSplashEffect`: the sound and SPLASH) and lands
+86 -1
View File
@@ -489,6 +489,29 @@ impl Containers<'_> {
}
}
/// `canTakeItemThroughFace` (furnaces and brewing stands restrict it).
/// `Container.canTakeItem` for a hopper's slot of `source`: the vanilla
/// shelf rule needs to know where the item is going.
fn can_take_into(
&self,
view: &View,
slot: usize,
stack: &ItemStack,
face: Direction,
target: &View,
) -> bool {
if view.kind == Kind::Bookshelf {
// `ChiseledBookShelfBlockEntity.canTakeItem`: the target must have
// a free slot or a matching stack with room (`hasAnyMatching`).
return (0..target.len()).any(|i| match self.get(target, i) {
None => true,
Some(other) => {
other.same_item_same_components(stack)
&& other.count + stack.count <= self.max_stack(other)
}
});
}
self.can_take(view, slot, stack, face)
}
fn can_take(&self, view: &View, slot: usize, stack: &ItemStack, face: Direction) -> bool {
if let Kind::Composter(level) = view.kind {
// OutputContainer.canTakeItemThroughFace.
@@ -1443,7 +1466,7 @@ impl Game<'_> {
let Some(stack) = c.insertion_item(source, slot).map(Cow::into_owned) else {
continue;
};
if !c.can_take(source, slot, &stack, Direction::Down) {
if !c.can_take_into(source, slot, &stack, Direction::Down, hopper) {
continue;
}
}
@@ -2482,3 +2505,65 @@ mod tests {
#[cfg(test)]
#[path = "machines_chest_cat_tests.rs"]
mod machines_chest_cat_tests;
#[cfg(test)]
mod can_take_tests {
use super::*;
use crate::game::native_machines::tests::server_game;
use horizon_client::containers::ChiseledBookshelf;
/// `ChiseledBookShelfBlockEntity.canTakeItem`: a hopper takes a book only
/// when it has a free slot or a matching stack with room.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_full_hopper_cannot_take_a_book_from_a_chiseled_shelf() {
let mut game = server_game();
let shelf_pos = (2, 4, 3);
let hopper_pos = (2, 5, 3);
let shelf_state = game
.pack
.assets
.registry
.default_state("minecraft:chiseled_bookshelf")
.unwrap();
let hopper_state = game
.pack
.assets
.registry
.default_state("minecraft:hopper")
.unwrap();
game.world
.set(shelf_pos.0, shelf_pos.1, shelf_pos.2, shelf_state)
.unwrap();
game.world
.set(hopper_pos.0, hopper_pos.1, hopper_pos.2, hopper_state)
.unwrap();
let book = ItemStack::new("minecraft:book", 1);
let mut shelf = ChiseledBookshelf::default();
shelf.items[0] = Some(book.clone());
game.world
.block_entities
.insert(shelf_pos, BlockEntity::ChiseledBookshelf(Box::new(shelf)));
let stone = ItemStack::new("minecraft:stone", 64);
game.world
.block_entities
.insert(hopper_pos, BlockEntity::Chest(vec![Some(stone.clone()); 5]));
let shelf_view = game.containers().view(shelf_pos, false).expect("shelf");
let full_hopper = game.containers().view(hopper_pos, false).expect("hopper");
assert!(
!game
.containers()
.can_take_into(&shelf_view, 0, &book, Direction::Down, &full_hopper),
"a full hopper takes no book"
);
game.world
.block_entities
.insert(hopper_pos, BlockEntity::Chest(vec![None; 5]));
let empty_hopper = game.containers().view(hopper_pos, false).expect("hopper");
assert!(
game.containers()
.can_take_into(&shelf_view, 0, &book, Direction::Down, &empty_hopper),
"an empty hopper takes the book"
);
}
}
+14 -1
View File
@@ -146,6 +146,10 @@ pub(super) struct Minecart {
pub push: Vec3,
/// TNT minecarts: ticks until the explosion, -1 when not primed.
pub fuse: i32,
/// TNT minecarts: `explosionPowerBase` and `explosionSpeedFactor`, the
/// blast's power at rest and its gain with the cart's speed.
pub explosion_power: f32,
pub explosion_speed_factor: f32,
/// Hopper minecarts: an unpowered activator rail enables them.
pub enabled: bool,
/// Chest minecarts: the loot table rolled into `items` on load, saved
@@ -362,6 +366,8 @@ impl Minecart {
fuel: 0,
push: Vec3::ZERO,
fuse: -1,
explosion_power: DEFAULT_EXPLOSION_POWER,
explosion_speed_factor: DEFAULT_EXPLOSION_SPEED_FACTOR,
enabled: true,
loot: None,
first_tick: true,
@@ -1547,7 +1553,9 @@ impl Game<'_> {
}
let speed = speed_sqr.sqrt().min(5.0);
let r = c.random.next_double();
let power = (4.0 + r * 1.5 * speed) as f32;
// MinecartTNT.explode: the base power plus the speed's share.
let power = (f64::from(c.explosion_power)
+ f64::from(c.explosion_speed_factor) * r * 1.5 * speed) as f32;
let at = [c.position.x, c.position.y, c.position.z];
// The cart is the explosion's source: it is gone before the blast
// looks for entities, so it can't hit itself.
@@ -2920,6 +2928,11 @@ impl Game<'_> {
.render_state(&scene, state.0, m, light, out);
}
}
/// `MinecartTNT.DEFAULT_EXPLOSION_POWER_BASE` and `DEFAULT_EXPLOSION_SPEED_FACTOR`.
const DEFAULT_EXPLOSION_POWER: f32 = 4.0;
const DEFAULT_EXPLOSION_SPEED_FACTOR: f32 = 1.0;
/// `VehicleEntity.hurtServer`'s destroy test: a creative player, damage past
/// 40, or (`MinecartTNT.shouldSourceDestroy`) fire or an explosion hitting a
/// TNT cart.
@@ -32,6 +32,8 @@ impl Minecart {
}
map.remove(&JavaString::from("DisplayOffset"));
map.remove(&JavaString::from("DisplayState"));
map.remove(&JavaString::from("explosion_power"));
map.remove(&JavaString::from("explosion_speed_factor"));
crate::game::boats::put_ticks_frozen(map, self.ticks_frozen);
let mut put = |k: &str, v: Tag| {
map.insert(JavaString::from(k), v);
@@ -62,7 +64,26 @@ impl Minecart {
put("PushZ", Tag::Double(self.push.z.to_bits()));
put("Fuel", Tag::Short(self.fuel as i16));
}
CartKind::Tnt => put("fuse", Tag::Int(self.fuse)),
// MinecartTNT.addAdditionalSaveData: the powers only off their defaults.
CartKind::Tnt => {
put("fuse", Tag::Int(self.fuse));
for (key, value, default) in [
(
"explosion_power",
self.explosion_power,
DEFAULT_EXPLOSION_POWER,
),
(
"explosion_speed_factor",
self.explosion_speed_factor,
DEFAULT_EXPLOSION_SPEED_FACTOR,
),
] {
if value != default {
put(key, Tag::Float(value.to_bits()));
}
}
}
// BaseCommandBlock.save (its CustomName shares the entity's key).
CartKind::CommandBlock => {
for (key, value) in self.command.save() {
@@ -151,6 +172,21 @@ impl Minecart {
// readAdditionalSaveData: the saved push has no y.
c.push = Vec3::new(double("PushX"), 0.0, double("PushZ"));
c.fuse = int("fuse").unwrap_or(-1);
if kind == CartKind::Tnt {
// MinecartTNT.readAdditionalSaveData: getFloatOr, kept to 0..128.
let float = |k: &str, default: f32| match tag.get(k) {
Some(Tag::Float(v)) => f32::from_bits(*v),
Some(Tag::Double(v)) => f64::from_bits(*v) as f32,
Some(Tag::Long(v)) => *v as f32,
Some(other) => {
horizon_client_core::nbt_path::int_value(other).map_or(default, |v| v as f32)
}
None => default,
};
c.explosion_power = float("explosion_power", DEFAULT_EXPLOSION_POWER).clamp(0.0, 128.0);
c.explosion_speed_factor =
float("explosion_speed_factor", DEFAULT_EXPLOSION_SPEED_FACTOR).clamp(0.0, 128.0);
}
// readAdditionalSaveData: DisplayState, DisplayOffset (default offset when absent).
c.custom_display = tag.get("DisplayState").cloned();
c.custom_offset = int("DisplayOffset");
+34 -16
View File
@@ -1,7 +1,7 @@
//! Custom names: `NameTagItem.interactLivingEntity` and the name tag
//! (`EntityRenderer.submitNameTag`): above the head, facing the camera,
//! on a translucent black background, shown when the name is visible or the
//! mob is under the crosshair within 64 blocks, as its team's name tag
//! mob is under the crosshair within its name-tag distance (64 by default), as its team's name tag
//! visibility allows, in the team's colours, with its score in the
//! objective displayed below names. Other players' names (on a LAN host
//! or a server) show whenever `LivingEntityRenderer.shouldShowName` allows,
@@ -260,10 +260,12 @@ impl Game<'_> {
}
None => return None,
};
// The tag's 64 block distance is measured to the entity: copy the
// name only for a mob the frame can show it on.
// The tag's per-entity distance (vanilla's default is 64) is
// measured to the entity: copy the name only for a mob the frame
// can show it on.
let at = glam::DVec3::new(m.position.x, m.position.y, m.position.z);
(at.distance_squared(camera) < 64.0 * 64.0).then_some((m, name, false))
let max = m.name_tag_distance();
(at.distance_squared(camera) < max * max).then_some((m, name, false))
});
let own = self.own_name_tag().map(|(m, _)| m.id);
let players = players
@@ -285,7 +287,7 @@ impl Game<'_> {
}
// EntityAttachment.NAME_TAG: 0.5 above the box of the current pose.
let p = name_tag_at(m, alpha);
// The name tag distance (64) is measured to the entity.
// The name tag distance is measured to the entity.
let distance_sqr =
glam::DVec3::new(m.position.x, m.position.y, m.position.z).distance_squared(camera);
// Match the model's renderer-specific box, not the tag anchor: the
@@ -297,6 +299,7 @@ impl Game<'_> {
distance_sqr,
(lo - camera - inflate).as_vec3(),
(hi - camera + inflate).as_vec3(),
m.name_tag_distance(),
) {
continue;
}
@@ -337,12 +340,13 @@ impl Game<'_> {
// MobRenderer.shouldShowName: a visible name, or the mob under the
// crosshair; players (LivingEntityRenderer) need neither.
// `ClientMannequin.belowNameDisplay`: the description is `scoreText`, drawn
// within the below-name distance (10) whether the name shows or not.
// within the below-name distance (10 by default) whether the name
// shows or not.
let description = (m.species() == horizon_client::mobs::Species::Mannequin
&& distance_sqr < 10.0 * 10.0)
.then(|| m.extra.description.component())
.flatten()
.map(|tag| horizon_client::entity_nbt::name_text(&tag).resolve_legacy());
&& distance_sqr < m.below_name_distance().powi(2))
.then(|| m.extra.description.component())
.flatten()
.map(|tag| horizon_client::entity_nbt::name_text(&tag).resolve_legacy());
let is_own = Some(m.id) == own;
let name_shown = sb::name_shown(team, viewer, view)
&& (!is_own || !view.is_vehicle)
@@ -400,14 +404,14 @@ impl Game<'_> {
.unwrap_or(shown);
let mut lines = Vec::new();
// EntityRenderer.extractNameTags: the score shows within the
// below-name distance (10), under the name.
// per-entity below-name distance (10 by default), under the name.
let mut lift = 0.0;
if m.species() == horizon_client::mobs::Species::Mannequin {
if let Some(text) = description {
lines.push((text, 0.0));
lift = f64::from(9.0f32 * 1.15 * 0.025);
}
} else if distance_sqr < 10.0 * 10.0
} else if distance_sqr < m.below_name_distance().powi(2)
&& let Some(score) = holder.and_then(|h| self.below_name_text(&h))
{
lines.push((score.resolve_legacy(), 0.0));
@@ -466,6 +470,7 @@ impl Game<'_> {
distance_sqr,
(origin - half).as_vec3(),
(origin + glam::DVec3::new(half.x, height + grow, half.z)).as_vec3(),
a.name_tag_distance(),
) {
continue;
}
@@ -507,7 +512,7 @@ impl Game<'_> {
let mut lines = Vec::new();
let mut lift = 0.0;
let holder = self.persist.uuids.get(&a.id).map(|u| uuid_text(*u));
if distance_sqr < 10.0 * 10.0
if distance_sqr < a.below_name_distance().powi(2)
&& let Some(score) = holder.and_then(|h| self.below_name_text(&h))
{
lines.push((score.resolve_legacy(), 0.0));
@@ -829,8 +834,10 @@ fn name_tag_in_view(
distance_sqr: f64,
min: glam::Vec3,
max: glam::Vec3,
max_distance: f64,
) -> bool {
distance_sqr < 64.0 * 64.0 && frustum.is_none_or(|view| view.intersects(min, max))
distance_sqr < max_distance * max_distance
&& frustum.is_none_or(|view| view.intersects(min, max))
}
fn first_mob_indices(mobs: &[Mob]) -> std::collections::HashMap<u32, usize> {
@@ -1145,7 +1152,8 @@ mod tests {
Some(&View),
181.0,
glam::Vec3::new(-0.8, 8.5, -10.8),
glam::Vec3::new(0.8, 11.3, -9.2)
glam::Vec3::new(0.8, 11.3, -9.2),
64.0
));
for (offset, distance, expected) in [
([0.0, 0.0, -10.0], 100.0, true),
@@ -1161,11 +1169,21 @@ mod tests {
Some(&View),
distance,
glam::Vec3::from_array(offset) - glam::Vec3::splat(0.5),
glam::Vec3::from_array(offset) + glam::Vec3::splat(0.5)
glam::Vec3::from_array(offset) + glam::Vec3::splat(0.5),
64.0
),
expected
);
}
// A server-synced `minecraft:name_tag_distance` of 5 hides a tag
// six blocks away even though the vanilla default reaches 64.
assert!(!name_tag_in_view(
Some(&View),
36.0,
glam::Vec3::splat(-0.5),
glam::Vec3::splat(0.5),
5.0
));
}
#[test]
@@ -702,7 +702,19 @@ impl Game<'_> {
});
self.portals.before = before;
self.portals.entities.retain(|id, _| present.contains(id));
// SulfurCube.tickFuse runs before Entity.handlePortal. A cube removed
// there must neither decrement its cooldown nor travel before its blast.
let removed_before_portal: std::collections::HashSet<u32> = self
.mobs
.iter()
.filter(|_| !vehicles)
.filter(|m| horizon_client::mobs::sulfur_cube::removes_before_portal_tick(m))
.map(|m| m.id)
.collect();
for (id, allowed, delay) in ids {
if removed_before_portal.contains(&id) {
continue;
}
let Some(state) = self.portals.entities.get_mut(&id) else {
continue;
};
+26 -1
View File
@@ -258,7 +258,7 @@ impl Game<'_> {
.get_or_insert_with(random_source::entity)
.next_float()
};
let stays = {
let (stays, step) = {
let audio = &mut self.audio;
let mut on_sound = |pos: [f64; 3], pitch: f32| {
audio.play("minecraft:entity.generic.burn", Some(pos), 0.4, pitch);
@@ -280,6 +280,7 @@ impl Game<'_> {
// orb that is done is removed.
let snow = (o.previous, o.position, o.bounds(), o.velocity);
self.entity_powder_snow_particles(snow.0, snow.1, snow.2, snow.3, false);
self.orb_moved(i, step);
if !stays {
self.orbs.remove(i);
}
@@ -304,6 +305,30 @@ impl Game<'_> {
}
}
impl Game<'_> {
/// `Entity.move`'s fall bookkeeping after orb `i`'s move: a move of a
/// block or more casts for a reset block or water, `checkFallDamage` adds
/// the drop outside water and, on the ground after a fall, posts
/// HIT_GROUND, then forgets it. `Block.fallOn` (farmland's random draw,
/// honey's slide) is not run for an orb yet, nor for the other entities
/// that are not living.
pub(super) fn orb_moved(&mut self, i: usize, step: horizon_client::entities::OrbMove) {
let o = &self.orbs[i];
let mut fall = self.vehicle_fall_reset(o.fall_distance, step.from, step.moved);
if !step.in_water && step.moved.y < 0.0 {
fall -= f64::from(step.moved.y as f32);
}
if o.on_ground {
if fall > 0.0 {
let (position, bounds) = (o.position, o.bounds());
self.post_landing_event(position, bounds, false);
}
fall = 0.0;
}
self.orbs[i].fall_distance = fall;
}
}
#[cfg(test)]
mod tests {
use super::{find_join, first_join};
+99
View File
@@ -44,6 +44,10 @@ impl Game<'_> {
.iter()
.position(|e| wants(self, e) && reach.intersects(e.bounds()));
if let Some(j) = taken {
let before = self.dropped[j].clone();
// `Panda.pickUpItem`'s `onItemPickup` comes first: the
// advancement of whoever threw it.
self.mob_item_pickup_trigger(i, &before);
let e = self.remove_dropped(j);
self.add_pickup(Taken::Item(Box::new(e.clone())), Collector::Mob(id));
panda::hold(&mut self.mobs[i], &e.stack.item, e.stack.count, true);
@@ -187,3 +191,98 @@ impl Game<'_> {
}
}
}
#[cfg(test)]
mod pickup_tests {
use super::*;
use crate::game::native_machines::tests::server_game;
use horizon_client::advancements as adv;
use horizon_client::motion::PlayerMotion;
use horizon_world::lazy_json::LazyJson;
const ADV: &str = "horizon:test/panda_pickup";
const TRIGGER: &str = "minecraft:thrown_item_picked_up_by_entity";
const THROWER: [i32; 4] = [413, 0, 0, 1];
/// An advancement on the pickup trigger, as a data pack would add one: no
/// vanilla advancement tests it, so the tree needs one of its own.
fn install_pickup_advancement(game: &mut Game<'_>) {
let mut tree = std::sync::Arc::new(adv::Tree::default());
let one = std::sync::Arc::get_mut(&mut tree).unwrap();
one.list.push(adv::Advancement {
id: ADV.into(),
parent: None,
display: None,
criteria: vec![adv::Criterion {
name: "pickup".into(),
trigger: TRIGGER.into(),
conditions: LazyJson::new(&serde_json::json!({})),
}],
requirements: vec![vec!["pickup".into()]],
rewards: Default::default(),
});
one.by_id.insert(ADV.into(), 0);
one.by_trigger.insert(TRIGGER.into(), vec![(0, 0)]);
game.adv.tree = tree;
}
/// `Panda.pickUpItem` calls `onItemPickup`, which fires
/// `CriteriaTriggers.THROWN_ITEM_PICKED_UP_BY_ENTITY` for whoever threw the
/// bamboo, as the fox's pickup does (`LivingEntity.onItemPickup`).
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_panda_that_takes_a_thrown_item_credits_the_thrower() {
let mut game = server_game();
game.options.dedicated = None;
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(4.5, 100.0, 4.5)).unwrap());
game.level.player_uuid = Some(THROWER);
game.level.health = 20.0;
game.creative = false;
// A world of its own under the temporary folder, never the cwd.
game.world_root =
std::env::temp_dir().join(format!("horizon-panda-pickup-world-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&game.world_root);
game.build_registries();
game.set_allow_commands(true);
install_pickup_advancement(&mut game);
// `DistanceManager` keeps a chunk entity-ticking only while its whole
// 5x5 neighbourhood is resident.
for cx in -2..=2 {
for cz in -2..=2 {
game.world.chunks.insert(
(cx, cz),
std::sync::Arc::new(crate::chunk::Chunk::empty(cx, cz)),
);
}
}
for command in [
"forceload add 0 0",
"tick freeze",
"fill 0 99 0 15 99 15 stone",
"summon minecraft:panda 4.5 100 6.5 {UUID:[I;9,9,9,9],PersistenceRequired:1b,CanPickUpLoot:1b}",
"summon minecraft:item 4.5 100 6.5 {Item:{id:\"minecraft:bamboo\",count:1},Thrower:[I;413,0,0,1],PickupDelay:0s}",
] {
assert!(game.run_command(command), "{command}");
}
let id = game.mob_by_uuid([9, 9, 9, 9]).unwrap();
let holding = |g: &Game<'_>| {
g.mobs
.iter()
.any(|m| m.id == id && m.extra.main_hand.is_some())
};
for _ in 0..20 {
if holding(&game) {
break;
}
assert!(game.run_command("tick step 1"));
game.tick_server(false).unwrap();
}
assert!(holding(&game), "the panda took the bamboo");
assert!(
game.adv.progress.has(ADV, "pickup"),
"the thrower's advancement: {:?}",
game.adv.progress.criteria
);
let _ = std::fs::remove_dir_all(&game.world_root);
}
}
+70 -6
View File
@@ -6,7 +6,7 @@ use super::Game;
use horizon_client::mobs::{Species, riders};
use horizon_core::{
geometry::{Aabb, Vec3},
random::RandomSource,
random::{LegacyRandom, RandomSource},
};
use std::collections::HashMap;
@@ -50,6 +50,12 @@ fn mount_rolls(species: Species, baby: bool, natural: bool) -> MountRolls {
}
}
/// `SkeletonTrapGoal.tick`: each additional trap horse's horizontal push draws
/// two `triangle(0.0, 1.1485)` offsets from the original trap horse's random.
fn trap_horse_scatter(random: &mut LegacyRandom) -> (f64, f64) {
(random.triangle(0.0, 1.1485), random.triangle(0.0, 1.1485))
}
impl Game<'_> {
/// `Entity.startRiding` for two mobs.
pub(super) fn mount_mob(&mut self, rider: u32, vehicle: u32) {
@@ -700,18 +706,21 @@ impl Game<'_> {
let Some(other) = self.spawn_mob("minecraft:skeleton_horse", at, true) else {
continue;
};
let (dx, dz) = (
self.world.random.triangle(0.0, 1.1485),
self.world.random.triangle(0.0, 1.1485),
);
// SkeletonTrapGoal.createHorse: scatter is drawn after the rider.
if let Some(h) = self.mobs.iter_mut().find(|m| m.id == other) {
h.invulnerable_time = TRAP_INVULNERABLE;
h.extra.tame = true;
h.extra.age = 0;
h.velocity = Vec3::new(h.velocity.x + dx, h.velocity.y, h.velocity.z + dz);
}
if let Some(rider) = self.trap_skeleton(at) {
self.mount_mob(rider, other);
let Some(horse) = self.mobs.iter_mut().find(|m| m.id == id) else {
continue;
};
let (dx, dz) = trap_horse_scatter(&mut horse.random);
if let Some(h) = self.mobs.iter_mut().find(|m| m.id == other) {
h.velocity = Vec3::new(h.velocity.x + dx, h.velocity.y, h.velocity.z + dz);
}
}
}
}
@@ -768,4 +777,59 @@ mod tests {
assert!(!r(Species::Drowned, true).nautilus);
assert!(!r(Species::Zombie, false).camel_husk);
}
#[test]
fn trap_horse_scatter_draws_two_triangles_from_original_horse_random() {
let mut horse_random = LegacyRandom::new(0x5DEECE66D);
let mut expected = horse_random.clone();
for _ in 0..3 {
let (dx, dz) = trap_horse_scatter(&mut horse_random);
assert_eq!(dx, expected.triangle(0.0, 1.1485));
assert_eq!(dz, expected.triangle(0.0, 1.1485));
}
assert_eq!(horse_random.next_long(), expected.next_long());
}
/// `SkeletonTrapGoal.tick`: springing a skeleton trap draws the three
/// additional horses' horizontal velocity scatter from the original trap
/// horse's own `random` after each rider is created.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn spring_skeleton_trap_draws_scatter_from_trap_horse_after_each_rider() {
use horizon_client::chunk::Chunk;
use std::sync::Arc;
let mut game = super::super::native_machines::tests::server_game();
game.world
.chunks
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
let at = [8.5, 64.0, 8.5];
let trap_id = game
.spawn_mob_raw("minecraft:skeleton_horse", at, false)
.unwrap();
let mut expected_horse_rng = LegacyRandom::new(777);
if let Some(horse) = game.mobs.iter_mut().find(|m| m.id == trap_id) {
horse.extra.trap = true;
horse.random = horizon_core::random::EntityRandom::own(expected_horse_rng.clone());
}
game.spring_skeleton_trap(trap_id);
let expected_pushes: Vec<(f64, f64)> = (0..3)
.map(|_| trap_horse_scatter(&mut expected_horse_rng))
.collect();
let horse = game.mobs.iter_mut().find(|m| m.id == trap_id).unwrap();
assert!(!horse.extra.trap);
assert_eq!(horse.random.next_long(), expected_horse_rng.next_long());
let extra_horses: Vec<&horizon_client::mobs::Mob> = game
.mobs
.iter()
.filter(|m| m.species() == Species::SkeletonHorse && m.id != trap_id)
.collect();
assert_eq!(extra_horses.len(), 3);
for (h, (dx, dz)) in extra_horses.iter().zip(expected_pushes) {
assert_eq!((h.velocity.x, h.velocity.z), (dx, dz));
assert!(h.extra.rider.is_some());
}
}
}
+16 -8
View File
@@ -9,6 +9,7 @@ use horizon_client::{
collisions::SceneCollisions,
};
use horizon_client::{
entities::nameplate::NameplateDistances,
hud::HudBatch,
mobs::{Anim, Brain, Mob, MobType},
mouse::Rotation,
@@ -289,9 +290,9 @@ impl Game<'_> {
}
// Minecraft.cameraEntity: a server's spectator camera on a mob or
// another player.
let (entity, descending, entity_kind) = match self.camera_mob() {
Some(m) => (
super::remote_camera::mob_camera_entity(m),
let (entity, descending, entity_kind) = match self.camera_entity() {
Some(e) => (
e,
false,
horizon_world::collision_context::EntityContextKind::Living,
),
@@ -460,11 +461,17 @@ impl Game<'_> {
/// Keep the player's model in step with the player (`LivingEntity.tick`
/// body turning, `WalkAnimationState`, the swing and the pose).
pub(super) fn tick_player_model(&mut self) {
let scale = self
.player
.as_mut()
.and_then(|p| p.attribute("minecraft:scale").ok())
.unwrap_or(1.0) as f32;
let (scale, nameplate) = self.player.as_mut().map_or_else(
|| (1.0, NameplateDistances::default()),
|p| {
let scale = p.attribute("minecraft:scale").unwrap_or(1.0) as f32;
let nameplate = NameplateDistances {
name_tag: p.attribute("minecraft:name_tag_distance").ok(),
below_name: p.attribute("minecraft:below_name_distance").ok(),
};
(scale, nameplate)
},
);
let Some(p) = self.player.as_ref() else {
self.player_model = None;
return;
@@ -527,6 +534,7 @@ impl Game<'_> {
};
m.extra.ints[1] = bed;
m.extra.model_scale = scale;
m.set_nameplate(nameplate);
// The crouch flag is still last tick's here.
let (from, at) =
player_model_positions(previous_position, position, m.extra.crouching, crouching);
@@ -176,6 +176,7 @@ mod tests {
fn orb_at(x: f64, y: f64, z: f64) -> Taken {
let at = horizon_core::geometry::Vec3::new(x, y, z);
Taken::Orb(Box::new(ExperienceOrb {
fall_distance: 0.0,
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
@@ -338,6 +338,7 @@ impl Game<'_> {
window.set_cursor(match effective {
Cursor::Arrow => winit::window::CursorIcon::Default,
Cursor::PointingHand => winit::window::CursorIcon::Pointer,
Cursor::Ibeam => winit::window::CursorIcon::Text,
Cursor::NotAllowed => winit::window::CursorIcon::NotAllowed,
Cursor::ResizeNs => winit::window::CursorIcon::NsResize,
Cursor::ResizeEw => winit::window::CursorIcon::EwResize,
+13 -71
View File
@@ -64,6 +64,8 @@ use winit::{event::MouseButton, keyboard::KeyCode};
mod abilities;
#[path = "remote_poll.rs"]
mod poll;
#[path = "remote_attributes.rs"]
mod remote_attributes;
#[path = "remote_tables.rs"]
mod tables;
@@ -2806,23 +2808,8 @@ impl Game<'_> {
if kind == "minecraft:experience_orb" {
// Its value (ExperienceOrb.DATA_VALUE) follows in SetEntityData.
self.orbs.retain(|o| o.id != id as u32);
self.orbs.push(ExperienceOrb {
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
id: id as u32,
position: at,
previous: at,
velocity: Vec3::ZERO,
value: 1,
age: 0,
on_ground: false,
count: 1,
ticks: 0,
health: 5,
air: 300,
following: None,
});
self.orbs
.push(ExperienceOrb::new(id as u32, at, Vec3::ZERO, 1));
}
if let Some(t) = mob_type {
let mut mob = Mob::new(id as u32, t, at, head);
@@ -3258,60 +3245,6 @@ impl Game<'_> {
}
}
/// `ClientPacketListener.handleUpdateAttributes` for a mob, each value from
/// `AttributeInstance.calculateValue`: its maximum health (the hearts drawn
/// over its mount), and its `minecraft:scale`, which sizes its box
/// (`LivingEntity.getDimensions`) and model (`LivingEntityRenderState.scale`)
/// as the local player's does: NPC plugins make their fake players bigger
/// with it, datapacks scale any mob.
fn remote_mob_attributes(&mut self, id: i32, values: &[play::AttributeSnapshot]) {
let catalog = &self.motion.attributes;
let value = |name: &str| {
let attribute = catalog.id(name)?;
let s = values
.iter()
.find(|s| s.attribute == attribute.registry_id())?;
Some(
catalog
.attribute(attribute)
.definition
.sanitize(snapshot_value(s)),
)
};
let (max_health, scale) = (value("minecraft:max_health"), value("minecraft:scale"));
let (speed, jump) = (
value("minecraft:movement_speed"),
value("minecraft:jump_strength"),
);
let Some(m) = self.mob_index.get_mut(&mut self.mobs, id as u32) else {
return;
};
// A horse its rider's client moves goes at its own speed and jumps
// its own height (`getSpeed`, `getCustomJump`).
if horizon_client::mobs::equine::moved_by_rider(m.species()) {
if let Some(speed) = speed {
horizon_client::mobs::finalize::set_base(
m,
horizon_client::mobs::finalize::Attr::MovementSpeed,
speed,
);
}
if let Some(jump) = jump {
m.extra.vector[0] = jump;
}
}
if let Some(max_health) = max_health {
horizon_client::mobs::finalize::set_base(
m,
horizon_client::mobs::finalize::Attr::MaxHealth,
max_health,
);
}
if let Some(scale) = scale {
m.extra.model_scale = scale as f32;
}
}
pub(super) fn handle_packet(&mut self, packet: C) -> Result<()> {
self.native_client_packet(&packet);
match packet {
@@ -4565,7 +4498,16 @@ impl Game<'_> {
// HUD draws and the health it may hold), reach, speed and so on.
// Then the received health again, which the old maximum clamped.
C::UpdateAttributes(p) if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) => {
let nameplate = self.remote_nameplate_values(&p.values);
self.apply_own_attributes(p.values);
if let Some(m) = self.player_model.as_mut() {
if let Some(value) = nameplate.name_tag {
m.set_name_tag_distance(value);
}
if let Some(value) = nameplate.below_name {
m.set_below_name_distance(value);
}
}
self.apply_remote_health();
}
C::UpdateAttributes(p) => self.remote_mob_attributes(p.id, &p.values),
@@ -0,0 +1,89 @@
//! `ClientPacketListener.handleUpdateAttributes` for entity attributes that
//! affect the client-side nameplate.
use super::{Game, snapshot_value};
use horizon_client::entities::nameplate::NameplateDistances;
use horizon_protocol::packets::play;
impl Game<'_> {
/// Computes one synced attribute using the catalog's vanilla range.
fn remote_attribute_value(
&self,
name: &str,
values: &[play::AttributeSnapshot],
) -> Option<f64> {
let attribute = self.motion.attributes.id(name)?;
let snapshot = values
.iter()
.find(|snapshot| snapshot.attribute == attribute.registry_id())?;
Some(
self.motion
.attributes
.attribute(attribute)
.definition
.sanitize(snapshot_value(snapshot)),
)
}
/// The two attributes read by `LivingEntityRenderer` and
/// `EntityRenderer.extractNameTags`.
pub(super) fn remote_nameplate_values(
&self,
values: &[play::AttributeSnapshot],
) -> NameplateDistances {
NameplateDistances {
name_tag: self.remote_attribute_value("minecraft:name_tag_distance", values),
below_name: self.remote_attribute_value("minecraft:below_name_distance", values),
}
}
/// `ClientPacketListener.handleUpdateAttributes` for a mob. The same
/// packet can target a player, mob or armor stand.
pub(super) fn remote_mob_attributes(&mut self, id: i32, values: &[play::AttributeSnapshot]) {
let max_health = self.remote_attribute_value("minecraft:max_health", values);
let scale = self.remote_attribute_value("minecraft:scale", values);
let speed = self.remote_attribute_value("minecraft:movement_speed", values);
let jump = self.remote_attribute_value("minecraft:jump_strength", values);
let nameplate = self.remote_nameplate_values(values);
let Some(m) = self.mob_index.get_mut(&mut self.mobs, id as u32) else {
if let Some(a) = self.armor_stands.iter_mut().find(|a| a.id == id as u32) {
if let Some(value) = nameplate.name_tag {
a.nameplate.name_tag = Some(value);
}
if let Some(value) = nameplate.below_name {
a.nameplate.below_name = Some(value);
}
}
return;
};
// A horse its rider's client moves goes at its own speed and jumps
// its own height (`getSpeed`, `getCustomJump`).
if horizon_client::mobs::equine::moved_by_rider(m.species()) {
if let Some(speed) = speed {
horizon_client::mobs::finalize::set_base(
m,
horizon_client::mobs::finalize::Attr::MovementSpeed,
speed,
);
}
if let Some(jump) = jump {
m.extra.vector[0] = jump;
}
}
if let Some(max_health) = max_health {
horizon_client::mobs::finalize::set_base(
m,
horizon_client::mobs::finalize::Attr::MaxHealth,
max_health,
);
}
if let Some(scale) = scale {
m.extra.model_scale = scale as f32;
}
if let Some(value) = nameplate.name_tag {
m.set_name_tag_distance(value);
}
if let Some(value) = nameplate.below_name {
m.set_below_name_distance(value);
}
}
}
@@ -7,6 +7,7 @@
use super::Game;
use horizon_client::{
camera::{CameraEntity, LivingCamera},
entities::ArmorStand,
mobs::Mob,
};
@@ -53,6 +54,35 @@ pub(super) fn mob_camera_entity(m: &Mob) -> CameraEntity {
}
}
/// `Camera.alignWithEntity`'s view of an armor stand, the usual camera of a
/// server's cutscene or menu scene. `ArmorStand.setYHeadRot` keeps its head
/// on `yRot`, so its view turns with its yaw; its eyes are its dimensions'
/// (`getDimensionsMarker`): none for a marker, `BABY_DIMENSIONS`' 0.9875 for
/// a small stand, the type's 1.7775 otherwise.
pub(super) fn stand_camera_entity(a: &ArmorStand) -> CameraEntity {
let eye_height = if a.marker {
0.0
} else {
a.probe_dimensions().2 as f32
};
CameraEntity {
position: [a.position.x, a.position.y, a.position.z],
previous: [a.previous.x, a.previous.y, a.previous.z],
x_rot: a.pitch,
y_rot: a.yaw,
x_rot_o: a.pitch,
y_rot_o: a.previous_yaw,
eye_height,
living: Some(LivingCamera {
scale: 1.0,
camera_distance: 4.0,
sleeping: None,
dead: None,
}),
mount: None,
}
}
/// `GameRenderer.checkEntityPostEffect`: the chain a spectated creeper, spider
/// or enderman puts on the screen.
pub(super) fn entity_post_effect(kind: &str) -> Option<&'static str> {
@@ -68,7 +98,9 @@ pub(super) fn entity_post_effect(kind: &str) -> Option<&'static str> {
impl Game<'_> {
/// `ClientPacketListener.handleSetCamera`.
pub(super) fn remote_set_camera(&mut self, camera_id: i32) {
let in_level = self.mobs.iter().any(|m| m.id == camera_id as u32);
let id = camera_id as u32;
let in_level =
self.mobs.iter().any(|m| m.id == id) || self.armor_stands.iter().any(|a| a.id == id);
let player = self
.remote
.as_ref()
@@ -102,6 +134,18 @@ impl Game<'_> {
let id = self.remote_camera_id()?;
self.mobs.iter().find(|m| m.id == id)
}
/// The view of the entity a server attached the camera to, while the
/// level still holds it: a mob or an armor stand.
pub(super) fn camera_entity(&self) -> Option<CameraEntity> {
if let Some(m) = self.camera_mob() {
return Some(mob_camera_entity(m));
}
let id = self.remote_camera_id()?;
self.armor_stands
.iter()
.find(|a| a.id == id)
.map(stand_camera_entity)
}
/// The entity under a spectator's crosshair (`Minecraft.hitResult` as an
/// `EntityHitResult`): the nearest mob or object before any block.
pub(super) fn spectator_click_target(&self) -> Option<i32> {
@@ -122,7 +166,28 @@ impl Game<'_> {
#[cfg(test)]
mod tests {
use super::{camera_after_packet, entity_post_effect};
use super::{camera_after_packet, entity_post_effect, stand_camera_entity};
use horizon_client::entities::ArmorStand;
use horizon_core::geometry::Vec3;
/// A server's camera on an armor stand looks out of the stand's eyes
/// (EntityType.ARMOR_STAND `eyeHeight(1.7775F)`, `BABY_DIMENSIONS`
/// 0.9875F for a small one, nothing for a marker), along its yaw.
#[test]
fn an_armor_stand_holds_the_camera_at_its_eyes() {
let mut stand = ArmorStand::new(7, Vec3::new(10.5, 64.0, -3.5), 90.0);
stand.yaw = 135.0;
stand.pitch = -20.0;
let view = stand_camera_entity(&stand);
assert_eq!(view.position, [10.5, 64.0, -3.5]);
assert_eq!(view.eye_height, 1.7775f32);
assert_eq!((view.y_rot_o, view.y_rot), (90.0, 135.0));
assert_eq!(view.x_rot, -20.0);
stand.small = true;
assert_eq!(stand_camera_entity(&stand).eye_height, 0.9875f32);
stand.marker = true;
assert_eq!(stand_camera_entity(&stand).eye_height, 0.0);
}
/// Only a creeper, spider or enderman has a spectator chain.
#[test]
@@ -221,6 +221,53 @@ impl Game<'_> {
self.streaming.radius
}
}
/// 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();
}
pub(super) fn new_renderer(&self, window: Arc<Window>) -> Result<Renderer> {
self.renderer_for(window, &self.pack, &self.gui)
}
/// 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.
pub(super) 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);
}
}
#[cfg(test)]
+1 -1
View File
@@ -20,7 +20,7 @@ use super::input_trace::{InputOrigin, TraceEvent, TraceKind};
use super::{Game, Screen};
use horizon_client::{Result, gamepad::Button, keybinds, text::Text, title::world_management};
use horizon_core::geometry::Vec3;
use horizon_telemetry::scrub::Scrubber;
use horizon_system::scrub::Scrubber;
use serde::Deserialize;
use serde_json::{Value, json};
use std::{
+26 -9
View File
@@ -21,6 +21,20 @@ mod ram_tests;
pub(super) const OFFHAND_SLOT: usize = 40;
impl Game<'_> {
/// `AbstractArrow.onHitEntity` on the player: a piercing arrow passes item blocking, whoever
/// shot it, because `applyItemBlocking` reads the direct entity's pierce level.
pub(super) fn arrow_hits_player(
&mut self,
amount: f32,
p: &horizon_client::projectiles::Projectile,
) -> bool {
self.combat.piercing_hit = p.arrow.as_ref().is_some_and(|a| a.pierce > 0);
self.mob_hits_player(
amount,
[p.previous.x, p.previous.y, p.previous.z],
"minecraft:arrow",
)
}
/// The stack in an inventory slot, the off hand at `OFFHAND_SLOT`.
pub(super) fn stack_in(&self, slot: usize) -> Option<&ItemStack> {
if slot == OFFHAND_SLOT {
@@ -144,6 +158,7 @@ impl Game<'_> {
&& blocked > 0.0
{
let outcome = BlockOutcome {
blocking_item_used: true,
blocked,
fully_blocked: blocked >= damage,
..Default::default()
@@ -193,22 +208,23 @@ impl Game<'_> {
let pitch = 0.8 + self.world.random.next_float() * 0.4;
self.audio.play(sound, at, 1.0, pitch);
}
if !outcome.blocking_item_used {
return;
}
// BlocksAttacks.hurtBlockingItem: the hit counts the blocking item as
// used, before the blocked-damage gate (vanilla awards it on the
// server even when the item blocks none of the damage).
if self.remote.is_none() {
self.award_stat("used", &stack.item, 1);
}
if outcome.blocked <= 0.0 {
return;
}
// ItemDamageFunction.apply, then hurtAndBreak.
let (threshold, base, factor) = component.item_damage;
let item_damage = if outcome.blocked < threshold {
0
} else {
(base + factor * outcome.blocked).floor() as i32
};
let item_damage = ItemDamageFunction {
threshold,
base,
factor,
}
.apply(outcome.blocked);
if item_damage > 0
&& !self.creative
&& let Some(max) = blocking_item_max_damage(
@@ -242,7 +258,7 @@ impl Game<'_> {
}
}
}
if projectile {
if outcome.blocked <= 0.0 || projectile {
return;
}
let Some(id) = attacker else {
@@ -409,6 +425,7 @@ mod tests {
direct: 1,
};
let outcome = BlockOutcome {
blocking_item_used: true,
hurt: true,
blocked: 5.0,
fully_blocked: true,
@@ -296,6 +296,32 @@ impl Game<'_> {
self.open_text_edit(key, front);
}
}
/// Whether `SignBlock.useWithoutItem` on the sign at `pos` consumes the
/// click. Vanilla answers: a waxed sign (the failed-interaction sound), a
/// side whose lines carry a click action (`executeClickCommandsIfPresent`,
/// which runs it), and a player who may build (the text editor). Every
/// other sign answers PASS, so `ServerPlayerGameMode.useItemOn` goes on to
/// `ItemStack.useOn` and an adventure-mode `can_place_on` stack places
/// against it. Vanilla also passes while another player is editing the
/// sign and when its text has styled lines; neither is modelled here.
pub(super) fn sign_consumes_click(&self, pos: [i32; 3]) -> bool {
let key = (pos[0], pos[1], pos[2]);
let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get(&key) else {
return false;
};
if sign.waxed || self.may_build() {
return true;
}
let side = if self.facing_sign_front(key) {
&sign.front
} else {
&sign.back
};
side.click
.iter()
.flatten()
.any(|event| click_action(event).is_some())
}
/// `SignBlock.useItemOn` with a `SignApplicator` in hand (dyes, ink sacs,
/// honeycomb): the item changes the side the player faces and is used up.
/// Whether it did; a sign it does not apply to goes on to the empty hand.
@@ -0,0 +1,150 @@
//! `SignBlock.useWithoutItem`: the click it passes goes on to the item
//! (`ServerPlayerGameMode.useItemOn`'s `useWithoutItem` then `ItemStack.useOn`),
//! so an adventure player's `can_place_on` block places against the sign.
use horizon_client::{
command::args::GameMode, containers::BlockEntity, items::ItemStack, mobs::Who, signs::SignData,
};
use horizon_world::nbt::Tag;
type Posted = (&'static str, [f64; 3], Option<Who>);
/// A server's game with nobody playing, or `None` without prepared resources.
fn game() -> Option<crate::game::Game<'static>> {
std::env::var_os("HORIZON_TEST_RESOURCES")?;
let mut game = crate::game::native_machines::tests::server_game();
game.build_registries();
game.set_allow_commands(true);
game.player = None;
Some(game)
}
/// `stack` with a `can_place_on` component naming `block`.
fn held(name: &str, count: u32, block: &str) -> ItemStack {
let mut stack = ItemStack::new(name, count);
stack.data_mut().other.push((
"minecraft:can_place_on".into(),
Tag::compound([("blocks", Tag::List(vec![Tag::String(block.into())]))]),
));
stack
}
/// An adventure player (`mayBuild` false) clicks the south side of the sign
/// at [2, 4, 3] with `stack` in hand, as `Minecraft.startUseItem` does; the
/// game events the click posted.
fn click_sign(game: &mut crate::game::Game<'_>, sign: SignData, stack: ItemStack) -> Vec<Posted> {
let registry = game.pack.assets.registry;
let sign_state = registry.default_state("minecraft:oak_sign").unwrap();
game.world.set(2, 4, 3, sign_state).unwrap();
game.world.set(2, 4, 4, game.res.facts().air).unwrap();
game.world
.block_entities
.insert((2, 4, 3), BlockEntity::Sign(sign));
game.set_mode(GameMode::Adventure);
*game.held_mut() = Some(stack);
crate::game::sculk::TEST_POSTED_EVENTS.with(|events| *events.borrow_mut() = Some(Vec::new()));
let hit = crate::game::Hit {
pos: [2, 4, 3],
face: [0, 0, 1],
point: [2.5, 4.5, 3.9],
};
game.with_player_actor(|g| g.use_hand(Some(&hit))).unwrap();
crate::game::sculk::TEST_POSTED_EVENTS.with(|events| events.borrow_mut().take().unwrap())
}
/// `SignBlock.useWithoutItem` answers PASS for a sign with no click action to
/// run, that is not waxed and that a player who may not build aims at, so
/// ServerPlayerGameMode.useItemOn goes on to `ItemStack.useOn`: a
/// `can_place_on` block naming the sign places against it.
#[test]
fn an_adventure_click_on_a_sign_places_a_can_place_on_block() {
let Some(mut game) = game() else {
return;
};
let planks = game
.pack
.assets
.registry
.default_state("minecraft:oak_planks")
.unwrap();
let events = click_sign(
&mut game,
SignData::default(),
held("minecraft:oak_planks", 2, "minecraft:oak_sign"),
);
assert_eq!(game.world.block(2, 4, 4), planks, "placed against the sign");
assert_eq!(game.held().map(|s| s.count), Some(1), "one plank is used");
assert_eq!(
events,
vec![("minecraft:block_place", [2.5, 4.5, 4.5], Some(Who::Player))]
);
// `can_place_on` names another block: nothing places and nothing is used.
let events = click_sign(
&mut game,
SignData::default(),
held("minecraft:oak_planks", 2, "minecraft:stone"),
);
assert_ne!(game.world.block(2, 4, 4), planks, "nothing places");
assert_eq!(game.held().map(|s| s.count), Some(2), "nothing is used");
assert!(events.is_empty(), "{events:?}");
}
/// The signs whose `useWithoutItem` consumes the click keep it, so the
/// `can_place_on` block is never placed: a waxed sign (its failed-interaction
/// sound) and a sign whose side runs a click command.
#[test]
fn a_waxed_sign_or_a_click_command_keeps_the_adventure_click() {
let Some(mut game) = game() else {
return;
};
let planks = game
.pack
.assets
.registry
.default_state("minecraft:oak_planks")
.unwrap();
let stone = game
.pack
.assets
.registry
.default_state("minecraft:stone")
.unwrap();
let waxed = SignData {
waxed: true,
..SignData::default()
};
click_sign(
&mut game,
waxed,
held("minecraft:oak_planks", 2, "minecraft:oak_sign"),
);
assert_ne!(game.world.block(2, 4, 4), planks, "a waxed sign keeps it");
assert_eq!(game.held().map(|s| s.count), Some(2), "nothing is used");
// A `run_command` line runs, and the click ends there.
let mut command = SignData {
allow_op_features: true,
..SignData::default()
};
command.front.click[0] = Some(Tag::compound([
("action", Tag::String("run_command".into())),
(
"command",
Tag::String("setblock 5 4 3 minecraft:stone".into()),
),
]));
let events = click_sign(
&mut game,
command,
held("minecraft:oak_planks", 2, "minecraft:oak_sign"),
);
assert_eq!(game.world.block(5, 4, 3), stone, "the click command ran");
assert_ne!(game.world.block(2, 4, 4), planks, "the command keeps it");
assert_eq!(game.held().map(|s| s.count), Some(2), "nothing is used");
assert!(
!events.iter().any(|e| e.0 == "minecraft:block_place"),
"{events:?}"
);
}
@@ -43,6 +43,7 @@ impl Game<'_> {
if let (Some(area), Hurt::Landed { .. }) = (area, hurt) {
let (knockback, _) = self.extra_knockback("minecraft:ender_dragon", scale);
self.attack_effects(area, None, false, super::mace::Spared::Nothing, None);
self.post_attack_dragon_burst();
self.special_attack_slowdown(knockback);
self.special_attack_exhaustion();
}
+70
View File
@@ -1362,6 +1362,76 @@ mod tests {
Some(game)
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn portal_blast_sulfur_cube_uses_only_positive_cooldown() {
use horizon_client::mobs::Mob;
use horizon_client::nether_portal::PortalState;
let mut game = server_game().expect("set HORIZON_TEST_RESOURCES");
let portal = game
.pack
.assets
.registry
.default_state("minecraft:nether_portal")
.unwrap();
for (cooldown, silent) in [
(1, false),
(1, true),
(0, false),
(0, true),
(-1, false),
(-1, true),
] {
game.world.set(8, 64, 8, portal).unwrap();
let kind = game
.res
.mob_types()
.get("minecraft:sulfur_cube")
.unwrap()
.clone();
let mut cube = Mob::new(9123, kind, Vec3::new(8.5, 64.0, 8.5), 0.0);
cube.silent = silent;
cube.extra.body = Some("minecraft:tnt".into());
cube.extra.counter2 = 2; // fuse 1: explodes on this tick.
game.mobs.push(cube);
game.portals.entities.insert(
9123,
PortalState {
cooldown,
process: None,
},
);
// The actual level tick orders the portal pass before the mob pass.
game.audio.heard.clear();
game.handle_entity_portals();
game.tick_creatures().unwrap();
assert_eq!(
game.world.block(8, 64, 8) == portal,
cooldown > 0,
"cooldown {cooldown}, Silent={silent}"
);
assert_eq!(
game.audio
.heard
.get("minecraft:entity.generic.explode")
.copied()
.unwrap_or(0),
u32::from(!silent),
"cooldown {cooldown}, Silent={silent}"
);
}
// An unrelated native/sourceless blast never inherits the cube's calculator.
game.world.set(8, 64, 8, portal).unwrap();
game.server_explode(
[8.5, 64.0625, 8.5],
4.0,
super::super::explosion::BlockInteraction::Destroy,
false,
)
.unwrap();
assert_ne!(game.world.block(8, 64, 8), portal);
}
#[test]
fn loaded_tnt_fuse_uses_numeric_short_and_saves_without_raw() {
use horizon_world::nbt::Tag;
+31
View File
@@ -54,6 +54,8 @@ pub(super) struct ThrownTrident {
pub(super) on_ground: bool,
/// `Entity.remainingFireTicks`; on clients, the synchronized on-fire flag.
pub(super) fire_ticks: i32,
/// `Entity.fallDistance`: kept as loaded; water resets it.
fall_distance: f64,
/// `AbstractArrow.soundEvent`: the trident's own until it first hits a block.
sound_event: String,
/// Water membership from the preceding `baseTick` (not updated while stuck).
@@ -129,6 +131,7 @@ impl ThrownTrident {
self.no_gravity = n.no_gravity;
self.on_ground = n.on_ground;
self.fire_ticks = n.fire_ticks;
self.fall_distance = n.fall_distance;
self.sound_event = n.sound_event;
}
/// `AbstractArrow.addAdditionalSaveData` + `DealtDamage`.
@@ -155,6 +158,7 @@ impl ThrownTrident {
no_gravity: self.no_gravity,
on_ground: self.on_ground,
fire_ticks: self.fire_ticks,
fall_distance: self.fall_distance,
sound_event: &self.sound_event,
item: &self.stack,
},
@@ -201,6 +205,7 @@ impl ThrownTrident {
no_gravity: false,
on_ground: false,
fire_ticks: 0,
fall_distance: 0.0,
sound_event: horizon_client::entity_nbt::TRIDENT_HIT_GROUND.to_owned(),
was_touching_water: false,
random: horizon_core::random::LegacyRandom::new(i64::from(id)),
@@ -620,6 +625,7 @@ impl Game<'_> {
no_gravity: false,
on_ground: false,
fire_ticks: 0,
fall_distance: 0.0,
sound_event: horizon_client::entity_nbt::TRIDENT_HIT_GROUND.to_owned(),
was_touching_water: false,
random: horizon_core::random::LegacyRandom::new(i64::from(id)),
@@ -1223,6 +1229,10 @@ impl Game<'_> {
t.has_been_shot = true;
let rain = self.arrow_rain();
t.was_touching_water = self.arrow_scene(&rain).in_water(t.position);
// Entity.updateInWaterStateAndDoFluidPushing: resetFallDistance in water.
if t.was_touching_water {
t.fall_distance = 0.0;
}
if t.fire_ticks > 0 {
t.fire_ticks -= 1;
}
@@ -1514,6 +1524,27 @@ mod tests {
assert_eq!(back.last_state, t.last_state);
}
/// `Entity.saveWithoutId` writes the `fall_distance` `Entity.load` read:
/// a trident keeps the one it was loaded with.
#[test]
fn a_trident_keeps_its_fall_distance_across_a_reload() {
use horizon_world::nbt::Tag;
let registry = stone_registry();
let fall = |tag: &Tag| match tag.get("fall_distance") {
Some(Tag::Double(bits)) => Some(f64::from_bits(*bits)),
_ => None,
};
let fresh = ThrownTrident::remote(1, Vec3::ZERO, 0.0, 0.0);
assert_eq!(fall(&fresh.to_nbt([1; 4], &registry, None)), Some(0.0));
let tag = Tag::compound([
("Pos", Tag::List(vec![Tag::Double(0); 3])),
("fall_distance", Tag::Double(5.0_f64.to_bits())),
]);
let loaded = horizon_client::entity_nbt::trident_from_nbt(&tag).unwrap();
let back = ThrownTrident::from_nbt(loaded, &tag, &registry);
assert_eq!(fall(&back.to_nbt([1; 4], &registry, None)), Some(5.0));
}
/// `AbstractArrow.startFalling` on `new Random(42)`: its first three
/// `nextFloat() * 0.2F` draws (computed from Java's LCG) scale x, y, z.
#[test]
+19 -2
View File
@@ -127,6 +127,10 @@ impl Game<'_> {
self.with_level(|b, _, _| b.cake_candle_state(cake, item.as_deref()).is_some());
let pots_flower = self
.with_level(|b, _, _| b.potted_flower_state(cake, item.as_deref()).is_some());
// Read before the use runs: a sign's click commands execute in
// `process_level_events` and may change or remove the sign, but
// vanilla keeps the click consumed (`executedClickCommand`).
let sign_consumes = self.sign_consumes_click(hit.pos);
let (result, branch) = self.with_level(|b, level, ticks| {
b.use_item_on_branch(
level,
@@ -178,7 +182,15 @@ impl Game<'_> {
_ => {}
}
}
if result.consumes_action() {
// SignBlock.useWithoutItem answers PASS for a sign that is
// not waxed, has no click action to run and that nobody may
// build on: ServerPlayerGameMode.useItemOn goes on to
// ItemStack.useOn, so an adventure-mode `can_place_on` stack
// still places against the sign.
let sign_passes = branch == horizon_world::level::UseBranch::WithoutItem
&& self.res.facts().get(cake).sign_form().is_some()
&& !sign_consumes;
if result.consumes_action() && !sign_passes {
// ServerPlayerGameMode.useItemOn: ITEM_USED_ON_BLOCK with
// the stack before the use, or DEFAULT_BLOCK_USE.
match branch {
@@ -461,6 +473,8 @@ impl Game<'_> {
return Ok(true);
};
let target = [pos.x, pos.y, pos.z];
let game_master = self.can_use_game_master_blocks();
let state = self.lectern_placed(target, state, &stack, game_master)?;
let state = self.apply_item_block_state(target, state, &stack)?;
let key = (target[0], target[1], target[2]);
if !self.world.block_entities.stateful_entity(state.0)
@@ -469,7 +483,6 @@ impl Game<'_> {
self.world.block_entities.insert(key, entity);
}
if self.world.block_entities.get(&key).is_some() {
let game_master = self.can_use_game_master_blocks();
self.apply_placed_item(target, &stack, game_master);
// Block.setPlacedBy.
match self.world.block_entities.get(&key) {
@@ -672,6 +685,10 @@ impl Game<'_> {
}
}
#[cfg(test)]
#[path = "sign_use_tests.rs"]
mod sign_use_tests;
#[cfg(test)]
#[path = "sign_chain_tests.rs"]
mod sign_chain_tests;
+7 -2
View File
@@ -11,10 +11,15 @@ impl Game<'_> {
let Some(stack) = self.held().cloned() else {
return false;
};
// Consumable.canConsume asks the stack's own `food`.
let consumable = self.consumable(&stack);
if consumable.is_none()
|| (info.food.is_some()
&& (self.player.is_none() || !self.can_eat(&stack, info.can_always_eat)))
|| (horizon_client::item_sync::food(&stack, info.food).is_some()
&& (self.player.is_none()
|| !self.can_eat(
&stack,
horizon_client::item_sync::can_always_eat(&stack, info.can_always_eat),
)))
{
return false;
}
+49
View File
@@ -92,6 +92,50 @@ fn triggers(name: &str) -> bool {
|| n == "bell"
}
impl Game<'_> {
/// `Player.itemAttackInteraction` applies trigger-only held-item effects
/// after an EnderDragonPart is successfully hit.
pub(super) fn post_attack_dragon_burst(&mut self) {
if let Some(held) = self.held().cloned() {
self.post_attack_bursts(&held, "minecraft:ender_dragon");
}
}
/// `ServerExplosion.hurtEntities` knockback for a trigger-only blast such
/// as Wind Burst. The dragon is not in the normal entity lists, but its
/// body still receives the blast impulse (without taking damage).
pub(super) fn wind_burst_knockback(
&mut self,
center: [f64; 3],
radius: f32,
multiplier: f32,
blocks: &[[i32; 3]],
) {
let Some(d) = self.dragon.as_ref() else {
return;
};
let whole = d.whole_box();
let (lo, hi) = (whole.min_position(), whole.max_position());
let exposure = self.seen_percent(center, [lo.x, lo.y, lo.z], [hi.x, hi.y, hi.z]);
let Some((id, mut push)) = self
.dragon
.as_ref()
.and_then(|d| Some((d.id, d.explosion_knockback(center, radius, exposure)?)))
else {
return;
};
for component in &mut push {
*component *= f64::from(multiplier);
}
self.native_explosion_knockback(self.dim.id(), center, blocks, Some(id), &mut push);
if let Some(d) = self.dragon.as_mut() {
d.velocity = Vec3::new(
d.velocity.x + push[0],
d.velocity.y + push[1],
d.velocity.z + push[2],
);
}
}
/// `WindChargeItem.use`.
pub(super) fn throw_wind_charge(&mut self) -> Result<bool> {
let (Some(eye), Some(p)) = (self.eye_position(), self.player.as_ref()) else {
@@ -572,6 +616,7 @@ impl Game<'_> {
self.post_game_event("minecraft:explode", center, ctx);
let mut targets = self.wind_positions(center, radius);
let pushed = self.wind_knockback(center, radius, multiplier, breeze, wind_charge, &targets);
self.wind_burst_knockback(center, radius, multiplier, &targets);
let block_count = targets.len();
// Explosion.canTriggerBlocks: a breeze's charge needs mob griefing.
if !breeze || self.ui.rules.bool("mob_griefing") {
@@ -740,6 +785,10 @@ impl Game<'_> {
}
}
}
#[cfg(test)]
#[path = "wind_dragon_tests.rs"]
mod wind_dragon_tests;
#[cfg(test)]
mod tests {
use super::*;
@@ -0,0 +1,88 @@
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
use horizon_client::chunk::Chunk;
use horizon_core::geometry::Vec3;
use std::sync::Arc;
fn end_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 = crate::game::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);
assert_eq!(
game.enter_command_level(crate::game::Dim::End),
Some(crate::game::Dim::Overworld)
);
assert!(game.dimension_type().has_ender_dragon_fight, "the End");
for x in -1..2 {
for z in -1..2 {
game.world
.chunks
.insert((x, z), Arc::new(Chunk::empty(x, z)));
}
}
assert!(game.run_command("forceload add 0 0"));
game
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_wind_burst_pushes_the_dragon_without_damage() {
let mut game = end_game();
game.spawn_dragon_spawned([0.0, 70.0, 0.0])
.expect("spawned dragon");
game.dragon.as_mut().expect("dragon").velocity = Vec3::ZERO;
game.wind_burst([0.0, 66.0, 0.0], 5.0, 1.0, false, false)
.unwrap();
let dragon = game.dragon.as_ref().expect("dragon");
assert_eq!(dragon.health, 200.0);
assert!(
dragon.velocity.y > 0.0,
"Wind Burst did not push the dragon"
);
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_wind_burst_scales_the_dragons_push_by_its_multiplier() {
let mut game = end_game();
game.spawn_dragon_spawned([0.0, 70.0, 0.0])
.expect("spawned dragon");
game.dragon.as_mut().expect("dragon").velocity = Vec3::ZERO;
game.wind_burst([0.0, 66.0, 0.0], 5.0, 1.22, false, false)
.unwrap();
let push = game.dragon.as_ref().expect("dragon").velocity.y;
assert!(push > 0.0, "Wind Burst did not push the dragon");
assert!(push > 0.6, "Wind Burst multiplier was not applied: {push}");
}
+6
View File
@@ -512,6 +512,7 @@ impl PlayerMotion {
status: self.status.clone(),
blocking,
blocked: 0.0,
blocking_item_used: false,
fully_blocked: false,
block_sound: false,
knocked: false,
@@ -530,6 +531,7 @@ impl PlayerMotion {
let outcome = BlockOutcome {
hurt,
blocked: environment.blocked,
blocking_item_used: environment.blocking_item_used,
fully_blocked: environment.fully_blocked,
sound: environment.block_sound,
knockback: environment.knocked,
@@ -624,6 +626,7 @@ pub(crate) struct Environment<'a> {
/// The raised blocking item, if any, and what it blocked.
pub(crate) blocking: Option<Blocking>,
pub(crate) blocked: f32,
pub(crate) blocking_item_used: bool,
/// What it blocked covers the whole hit, as difficulty scaled it.
pub(crate) fully_blocked: bool,
pub(crate) block_sound: bool,
@@ -704,6 +707,8 @@ pub struct AttackRecord {
/// The outcome of a hurt with a raised blocking item.
#[derive(Clone, Copy, Debug, Default)]
pub struct BlockOutcome {
/// `hurtBlockingItem` ran, including when reductions resolve to zero.
pub blocking_item_used: bool,
pub hurt: bool,
pub blocked: f32,
/// `applyItemBlocking`'s `damageBlocked >= damage`, against the damage
@@ -938,6 +943,7 @@ impl HurtWorld for Environment<'_> {
if damage <= 0.0 || b.bypassed || b.pierce {
return Ok(0.0);
}
self.blocking_item_used = true;
self.blocked = b.rules.resolve(&mut MatchedSets, damage, b.angle)?;
self.fully_blocked = self.blocked >= damage;
Ok(self.blocked)
@@ -17,6 +17,7 @@ pub struct EquipmentHurt {
pending: Option<PendingHurt<()>>,
inner: Option<bool>,
blocked: f32,
blocking_item_used: bool,
fully_blocked: bool,
block_sound: bool,
knocked: bool,
@@ -76,6 +77,7 @@ impl PlayerMotion {
status: self.status.clone(),
blocking: hit.blocking.clone(),
blocked: hit.blocked,
blocking_item_used: hit.blocking_item_used,
fully_blocked: hit.fully_blocked,
block_sound: hit.block_sound,
knocked: hit.knocked,
@@ -92,6 +94,7 @@ impl PlayerMotion {
)?;
let result = step(&mut h.hurt, &mut combat);
hit.blocked = environment.blocked;
hit.blocking_item_used = environment.blocking_item_used;
hit.fully_blocked = environment.fully_blocked;
hit.block_sound = environment.block_sound;
hit.knocked = environment.knocked;
@@ -123,6 +126,7 @@ impl PlayerMotion {
pending: None,
inner: None,
blocked: 0.0,
blocking_item_used: false,
fully_blocked: false,
block_sound: false,
knocked: false,
@@ -219,6 +223,7 @@ impl PlayerMotion {
Ok(BlockOutcome {
hurt,
blocked: hit.blocked,
blocking_item_used: hit.blocking_item_used,
fully_blocked: hit.fully_blocked,
sound: hit.block_sound,
knockback: hit.knocked,
+1
View File
@@ -183,6 +183,7 @@ impl FoodWorld for TickWorld<'_> {
status: Default::default(),
blocking: None,
blocked: 0.0,
blocking_item_used: false,
fully_blocked: false,
block_sound: false,
knocked: false,
+323
View File
@@ -143,6 +143,147 @@ pub fn patched_use(stack: &ItemStack) -> (Option<i32>, bool) {
out
}
/// `FoodProperties.canAlwaysEat` from the stack's `food` component.
pub fn can_always_eat(stack: &ItemStack, default: bool) -> bool {
let data = stack.data();
if data.is_some_and(|d| d.other("!minecraft:food").is_some()) {
return false;
}
if let Some(food) = data.and_then(|d| d.other("minecraft:food")) {
return horizon_world::datafix::dynamic::as_bool(food.get("can_always_eat"), false);
}
default
}
/// `Consumable.consumeSeconds` from the stack's `consumable` component.
pub fn consumable_seconds(stack: &ItemStack) -> Option<f32> {
let data = stack.data();
if data.is_some_and(|d| d.other("!minecraft:consumable").is_some()) {
return None;
}
data.and_then(|d| d.other("minecraft:consumable"))
.map(|consumable| {
horizon_world::datafix::dynamic::as_float(consumable.get("consume_seconds"), 1.6)
})
}
/// `UseRemainder` from the stack's `use_remainder` component.
pub fn use_remainder(stack: &ItemStack, default: Option<String>) -> Option<String> {
let data = stack.data();
if data.is_some_and(|d| d.other("!minecraft:use_remainder").is_some()) {
return None;
}
if let Some(remainder) = data.and_then(|d| d.other("minecraft:use_remainder")) {
return match remainder.get("id") {
Some(Tag::String(id)) => Some(id.to_string_lossy()),
_ => None,
};
}
default
}
/// `Equippable.dispensable` from the stack's `equippable` component.
pub fn dispensable(stack: &ItemStack, default: bool) -> bool {
let data = stack.data();
if data.is_some_and(|d| d.other("!minecraft:equippable").is_some()) {
return false;
}
if let Some(equippable) = data.and_then(|d| d.other("minecraft:equippable")) {
return horizon_world::datafix::dynamic::as_bool(equippable.get("dispensable"), true);
}
default
}
/// `FoodProperties.nutrition` and `saturation` from the stack's `food`
/// component, else the item's.
pub fn food(stack: &ItemStack, default: Option<(u32, f32)>) -> Option<(u32, f32)> {
let data = stack.data();
if data.is_some_and(|d| d.other("!minecraft:food").is_some()) {
return None;
}
if let Some(food) = data.and_then(|d| d.other("minecraft:food")) {
return Some((
horizon_world::datafix::dynamic::as_int(food.get("nutrition"), 0).max(0) as u32,
horizon_world::datafix::dynamic::as_float(food.get("saturation"), 0.0),
));
}
default
}
/// `Consumable.animation` from the stack's `consumable` component.
pub fn consumable_animation(stack: &ItemStack) -> Option<horizon_world::consumable::UseAnimation> {
use horizon_world::consumable::UseAnimation;
let data = stack.data();
if data.is_some_and(|d| d.other("!minecraft:consumable").is_some()) {
return None;
}
let Some(Tag::String(animation)) = data
.and_then(|d| d.other("minecraft:consumable"))
.and_then(|c| c.get("animation"))
else {
return None;
};
// ItemUseAnimation.CODEC's names.
Some(match &*animation.to_string_lossy() {
"none" => UseAnimation::None,
"drink" => UseAnimation::Drink,
"block" => UseAnimation::Block,
"bow" => UseAnimation::Bow,
"trident" => UseAnimation::Trident,
"crossbow" => UseAnimation::Crossbow,
"spyglass" => UseAnimation::Spyglass,
"toot_horn" => UseAnimation::TootHorn,
"brush" => UseAnimation::Brush,
"bundle" => UseAnimation::Bundle,
"spear" => UseAnimation::Spear,
_ => UseAnimation::Eat,
})
}
/// `Consumable.sound` from the stack's `consumable` component.
pub fn consumable_sound(stack: &ItemStack) -> Option<String> {
let data = stack.data();
if data.is_some_and(|d| d.other("!minecraft:consumable").is_some()) {
return None;
}
let sound = data
.and_then(|d| d.other("minecraft:consumable"))
.and_then(|c| c.get("sound"))?;
match sound {
Tag::String(id) => Some(id.to_string_lossy()),
// SoundEvent.DIRECT_CODEC: `{sound_id, range}`.
Tag::Compound(_) => match sound.get("sound_id") {
Some(Tag::String(id)) => Some(id.to_string_lossy()),
_ => None,
},
_ => None,
}
}
/// `Consumable.hasConsumeParticles` from the stack's `consumable` component.
pub fn consumable_particles(stack: &ItemStack) -> Option<bool> {
let data = stack.data();
if data.is_some_and(|d| d.other("!minecraft:consumable").is_some()) {
return None;
}
data.and_then(|d| d.other("minecraft:consumable"))
.map(|c| horizon_world::datafix::dynamic::as_bool(c.get("has_consume_particles"), true))
}
/// The `consumable` component a use reads: the stack's own when it has one
/// (its time, animation, sound and particles, with `Consumable.CODEC`'s
/// defaults for what it leaves out), else the item's. The item's effects stay:
/// a stack's `on_consume_effects` are not read.
pub fn stack_consumable<'a, E: Clone>(
stack: &ItemStack,
item: &'a horizon_world::consumable::Consumable<String, E>,
) -> std::borrow::Cow<'a, horizon_world::consumable::Consumable<String, E>> {
let data = stack.data();
if data.is_some_and(|d| d.other("!minecraft:consumable").is_some())
|| !data.is_some_and(|d| d.other("minecraft:consumable").is_some())
{
return std::borrow::Cow::Borrowed(item);
}
let mut c = item.clone();
c.seconds = consumable_seconds(stack).unwrap_or(1.6);
c.animation =
consumable_animation(stack).unwrap_or(horizon_world::consumable::UseAnimation::Eat);
c.sound = consumable_sound(stack).unwrap_or_else(|| "minecraft:entity.generic.eat".into());
c.particles = consumable_particles(stack).unwrap_or(true);
std::borrow::Cow::Owned(c)
}
/// The parts of a stack's `equippable` component (`Equippable`) the client
/// draws with.
#[derive(Clone, Debug, PartialEq)]
@@ -1427,6 +1568,188 @@ mod tests {
assert_eq!(net_c.effects, effects);
}
#[test]
fn food_can_always_eat_comes_from_the_stack() {
let mut stack = ItemStack::new("minecraft:apple", 1);
stack.data_mut().set_other(
"minecraft:food",
Some(Tag::compound([("can_always_eat", Tag::Byte(1))])),
);
assert!(can_always_eat(&stack, false));
assert!(!can_always_eat(
&ItemStack::new("minecraft:apple", 1),
false
));
stack.data_mut().set_other(
"!minecraft:food",
Some(Tag::compound(Vec::<(&str, Tag)>::new())),
);
assert!(!can_always_eat(&stack, true));
}
#[test]
fn consumable_seconds_come_from_the_stack() {
let mut stack = ItemStack::new("minecraft:apple", 1);
stack.data_mut().set_other(
"minecraft:consumable",
Some(Tag::compound([(
"consume_seconds",
Tag::Float(0.5f32.to_bits()),
)])),
);
assert_eq!(consumable_seconds(&stack), Some(0.5));
stack.data_mut().set_other(
"!minecraft:consumable",
Some(Tag::compound(Vec::<(&str, Tag)>::new())),
);
assert_eq!(consumable_seconds(&stack), None);
}
#[test]
fn use_remainder_comes_from_the_stack() {
let mut stack = ItemStack::new("minecraft:mushroom_stew", 1);
stack.data_mut().set_other(
"minecraft:use_remainder",
Some(Tag::compound([(
"id",
Tag::String("minecraft:bowl".into()),
)])),
);
assert_eq!(
use_remainder(&stack, None).as_deref(),
Some("minecraft:bowl")
);
stack.data_mut().set_other(
"!minecraft:use_remainder",
Some(Tag::compound(Vec::<(&str, Tag)>::new())),
);
assert_eq!(
use_remainder(&stack, Some("minecraft:glass_bottle".into())),
None
);
}
#[test]
fn dispensable_comes_from_the_stack() {
let mut stack = ItemStack::new("minecraft:golden_helmet", 1);
stack.data_mut().set_other(
"minecraft:equippable",
Some(Tag::compound([
("slot", Tag::String("head".into())),
("dispensable", Tag::Byte(0)),
])),
);
assert!(!dispensable(&stack, true));
stack.data_mut().set_other(
"!minecraft:equippable",
Some(Tag::compound(Vec::<(&str, Tag)>::new())),
);
assert!(!dispensable(&stack, true));
}
#[test]
fn food_nutrition_comes_from_the_stack() {
let mut stack = ItemStack::new("minecraft:apple", 1);
stack.data_mut().set_other(
"minecraft:food",
Some(Tag::compound([
("nutrition", Tag::Int(9)),
("saturation", Tag::Float(4.5f32.to_bits())),
])),
);
assert_eq!(food(&stack, Some((4, 2.4))), Some((9, 4.5)));
stack.data_mut().set_other(
"!minecraft:food",
Some(Tag::compound(Vec::<(&str, Tag)>::new())),
);
assert_eq!(food(&stack, Some((4, 2.4))), None);
}
#[test]
fn consumable_animation_comes_from_the_stack() {
let mut stack = ItemStack::new("minecraft:apple", 1);
stack.data_mut().set_other(
"minecraft:consumable",
Some(Tag::compound([("animation", Tag::String("drink".into()))])),
);
assert_eq!(
consumable_animation(&stack),
Some(horizon_world::consumable::UseAnimation::Drink)
);
stack.data_mut().set_other(
"!minecraft:consumable",
Some(Tag::compound(Vec::<(&str, Tag)>::new())),
);
assert_eq!(consumable_animation(&stack), None);
}
#[test]
fn consumable_sound_comes_from_the_stack() {
let mut stack = ItemStack::new("minecraft:apple", 1);
stack.data_mut().set_other(
"minecraft:consumable",
Some(Tag::compound([(
"sound",
Tag::String("minecraft:entity.generic.drink".into()),
)])),
);
assert_eq!(
consumable_sound(&stack).as_deref(),
Some("minecraft:entity.generic.drink")
);
stack.data_mut().set_other(
"!minecraft:consumable",
Some(Tag::compound(Vec::<(&str, Tag)>::new())),
);
assert_eq!(consumable_sound(&stack), None);
}
#[test]
fn consumable_particles_come_from_the_stack() {
let mut stack = ItemStack::new("minecraft:apple", 1);
stack.data_mut().set_other(
"minecraft:consumable",
Some(Tag::compound([("has_consume_particles", Tag::Byte(0))])),
);
assert_eq!(consumable_particles(&stack), Some(false));
stack.data_mut().set_other(
"!minecraft:consumable",
Some(Tag::compound(Vec::<(&str, Tag)>::new())),
);
assert_eq!(consumable_particles(&stack), None);
}
/// A stack's own `consumable` replaces the item's: what it leaves out
/// takes the codec's defaults, not the item's values.
#[test]
fn a_stacks_consumable_takes_the_codecs_defaults() {
use horizon_world::consumable::{Consumable, UseAnimation};
let honey: Consumable<String> = Consumable {
seconds: 2.0,
animation: UseAnimation::Drink,
sound: "minecraft:item.honey_bottle.drink".into(),
particles: false,
effects: vec![],
};
let mut stack = ItemStack::new("minecraft:honey_bottle", 1);
assert_eq!(*stack_consumable(&stack, &honey), honey);
stack.data_mut().set_other(
"minecraft:consumable",
Some(Tag::compound([(
"sound",
Tag::compound([(
"sound_id",
Tag::String("minecraft:entity.generic.drink".into()),
)]),
)])),
);
let c = stack_consumable(&stack, &honey);
assert_eq!(c.seconds, 1.6);
assert_eq!(c.animation, UseAnimation::Eat);
assert_eq!(c.sound, "minecraft:entity.generic.drink");
assert!(c.particles);
}
#[test]
fn merchant_water_cost_survives_the_offer_packet() {
use horizon_protocol::{
@@ -0,0 +1,45 @@
//! The converted equipment of one guest. A slot keeps the stack it was converted from; the
//! stack is converted again only when the slot holds a different one, compared in full.
use crate::items::ItemStack;
use horizon_protocol::item::Stack;
/// Equipment slots: held, offhand, then the armor from head to feet.
pub const SLOTS: usize = 6;
/// The wire slot of each equipment slot, in the order the equipment is listed. Armor is stored
/// head..feet; the wire slots are feet(2) legs(3) chest(4) head(5).
const WIRE_SLOTS: [u8; SLOTS] = [0, 1, 5, 4, 3, 2];
#[derive(Default)]
struct Slot {
source: Option<ItemStack>,
converted: Option<Stack>,
}
#[derive(Default)]
pub struct EquipmentCache {
slots: [Slot; SLOTS],
}
impl EquipmentCache {
/// Whether `source` differs from the stack slot `index` was last converted from.
pub fn is_stale(&self, index: usize, source: Option<&ItemStack>) -> bool {
self.slots[index].source.as_ref() != source
}
/// Records the stack slot `index` was converted from, and the conversion.
pub fn store(&mut self, index: usize, source: Option<ItemStack>, converted: Option<Stack>) {
self.slots[index] = Slot { source, converted };
}
/// `(wire slot, stack)` for each equipment slot that holds an item.
pub fn equipment(&self) -> Vec<(u8, Option<Stack>)> {
WIRE_SLOTS
.iter()
.zip(&self.slots)
.filter(|(_, slot)| slot.source.is_some())
.map(|(&wire, slot)| (wire, slot.converted.clone()))
.collect()
}
}
+3
View File
@@ -301,6 +301,8 @@ pub struct Guest {
/// with `armor` so each player compares against their own snapshot.
pub location_armor: [Option<ItemStack>; 4],
pub offhand: Option<ItemStack>,
/// The converted held, offhand and armor stacks, kept while they do not change.
pub equipment: super::equipment_cache::EquipmentCache,
pub selected: usize,
pub carried: Option<ItemStack>,
/// The inventory menu's crafting grid (2x2) and its result
@@ -557,6 +559,7 @@ impl Guest {
armor: [None, None, None, None],
location_armor: [None, None, None, None],
offhand: None,
equipment: Default::default(),
selected: 0,
carried: None,
craft_grid: Default::default(),
+24 -30
View File
@@ -9,6 +9,7 @@ use super::{
chunks::ChunkEncoder,
data::SyncData,
entity::{Entities, EntityState, Viewer},
equipment_cache,
guest::{Guest, SentEffect, stack_to_network},
};
use crate::{
@@ -3279,28 +3280,19 @@ impl Hub {
}
}
fn equipment_of(
&mut self,
main: &Option<ItemStack>,
off: &Option<ItemStack>,
armor: [&Option<ItemStack>; 4],
) -> Vec<(u8, Option<horizon_protocol::item::Stack>)> {
// Armor is stored head..feet; the wire slots are feet(2) legs(3) chest(4) head(5).
let mut out = Vec::new();
let slots = [
(0u8, main),
(1, off),
(5, armor[0]),
(4, armor[1]),
(3, armor[2]),
(2, armor[3]),
];
for (slot, item) in slots {
if item.is_some() {
out.push((slot, self.stack(item)));
/// The equipment of a placed guest; only the stacks that changed since the last call are converted.
fn guest_equipment(&mut self, i: usize) -> Vec<(u8, Option<horizon_protocol::item::Stack>)> {
for index in 0..equipment_cache::SLOTS {
let g = &self.guests[i];
let source = equipment_source(g, index);
if !g.equipment.is_stale(index, source) {
continue;
}
let source = source.cloned();
let converted = self.stack(&source);
self.guests[i].equipment.store(index, source, converted);
}
out
self.guests[i].equipment.equipment()
}
/// Whether an entity is tracked by the current dimension's level.
@@ -3379,16 +3371,7 @@ impl Hub {
let guest_states: Vec<_> = placed
.into_iter()
.map(|i| {
let (main, off, armor) = {
let g = &self.guests[i];
(
g.inventory.get(g.selected).cloned().flatten(),
g.offhand.clone(),
g.armor.clone(),
)
};
let equipment =
self.equipment_of(&main, &off, [&armor[0], &armor[1], &armor[2], &armor[3]]);
let equipment = self.guest_equipment(i);
let g = &self.guests[i];
let mut state = self.player_state(
g.entity_id,
@@ -3752,6 +3735,8 @@ impl Hub {
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_millis() as i64);
for g in &mut self.guests {
// Converted under the old registries: convert again under the new ones.
g.equipment = Default::default();
let mut granted = Vec::new();
// PlayerAdvancements.reload: isFirstPacket again, the progress
// read against the new tree; a player whose advancements were
@@ -5266,6 +5251,15 @@ fn listed_properties(
}
}
/// The stack in equipment slot `index` (`equipment_cache::SLOTS`): held, offhand, armor head..feet.
fn equipment_source(g: &Guest, index: usize) -> Option<&ItemStack> {
match index {
0 => g.inventory.get(g.selected)?.as_ref(),
1 => g.offhand.as_ref(),
_ => g.armor.get(index - 2)?.as_ref(),
}
}
#[cfg(test)]
#[path = "hub_tests.rs"]
mod tests;
@@ -1381,6 +1381,57 @@ fn memory_connection_first_view_sends_every_ready_column() {
);
}
/// The equipment as it was before the cache: every equipped slot converted in turn.
fn equipment_converted_every_time(
hub: &mut Hub,
) -> Vec<(u8, Option<horizon_protocol::item::Stack>)> {
let g = &hub.guests[0];
let sources = [
(0u8, g.inventory.get(g.selected).cloned().flatten()),
(1, g.offhand.clone()),
(5, g.armor[0].clone()),
(4, g.armor[1].clone()),
(3, g.armor[2].clone()),
(2, g.armor[3].clone()),
];
let mut out = Vec::new();
for (slot, item) in sources {
if item.is_some() {
out.push((slot, hub.stack(&item)));
}
}
out
}
#[test]
fn equipment_is_converted_again_only_when_a_stack_changes() {
use crate::items::ItemStack;
let stack = |item: &str, count: u32| ItemStack {
item: item.into(),
count,
damage: 0,
extra: None,
};
let (mut hub, _rx) = hub_with_a_guest();
hub.guests[0].inventory[0] = Some(stack("minecraft:stone", 1));
hub.guests[0].offhand = Some(stack("minecraft:shield", 1));
hub.guests[0].armor[0] = Some(stack("minecraft:iron_helmet", 1));
let first = equipment_converted_every_time(&mut hub);
for _ in 0..3 {
assert_eq!(hub.guest_equipment(0), first);
}
hub.guests[0].inventory[0] = Some(stack("minecraft:stone", 3));
let changed = equipment_converted_every_time(&mut hub);
assert_ne!(changed, first);
assert_eq!(hub.guest_equipment(0), changed);
hub.guests[0].offhand = None;
let cleared = equipment_converted_every_time(&mut hub);
assert_eq!(cleared.len(), changed.len() - 1);
assert_eq!(hub.guest_equipment(0), cleared);
}
/// `ServerGamePacketListenerImpl.handleAcceptTeleportPacket`: an accept whose id is
/// the last teleport's (`awaitingTeleport`) while none is awaited any more
/// (`awaitingPositionFromClient` is null) disconnects with
Loaded 100 of 164 files, more files were not shown because too many files have changed in this diff. Show more