Compare commits

...
316 Commits
Author SHA1 Message Date
rdbt 04a26210e3 Merge pull request #15038 from horizon-rust/fix/main-ci-after-bulk-merge
Fix CEM compilation and scaled mob culling bounds
2026-11-11 02:39:18 +00:00
rdbtCVSandCodex f37bcae92c Scale culling margins with the mob model root
Include the root model scale in the appendage margin so animated elder guardian vertices remain inside the precheck bounds. The existing prepared-model coverage test remains unchanged.

Co-Authored-By: Codex (gpt-6.1-sol) <[email protected]>
2026-11-10 23:55:39 +00:00
rdbtCVSandCodex 04b53013c8 Fix CEM own-model lookup shadowing
Keep the own-file map available when selecting the layers covered by a model.

Co-Authored-By: Codex (gpt-6.1-sol) <[email protected]>
2026-11-10 23:45:10 +00:00
rdbt 5070174aef Merge pull request #14707 from masa-masa-0314/docs/friends-overlay-part2-spec
Docs: propose the Friends overlay Part 2 plan
2026-11-10 23:31:27 +00:00
rdbt 5841fdc1e7 Merge pull request #14658 from readerkid/fix/cem-armour-layers
Map CEM's armour models to the layers 26.3 draws, and the rabbit's feet
2026-11-10 23:31:23 +00:00
rdbt 2093fb4d0d Merge pull request #14627 from MrCloudy2/fix/piston-cactus-vehicles
A cactus pricks a boat or a minecart a piston pushes
2026-11-10 23:31:19 +00:00
rdbt 4e84f9e7fe Merge pull request #14623 from MrCloudy2/fix/projectile-hits-tnt
A projectile hits primed TNT
2026-11-10 23:31:14 +00:00
rdbt 6d45e61cdc Merge pull request #14621 from MrCloudy2/fix/wind-bursts-all-push
Every wind burst of a tick pushes an entity that ticks after the projectiles
2026-11-10 23:31:10 +00:00
rdbt 960c03a4a1 Merge pull request #14619 from MrCloudy2/fix/projectile-fluid-current
A flying projectile is pushed by the water and lava it touches
2026-11-10 23:31:06 +00:00
rdbt 7618cdca8e Merge pull request #14533 from MrCloudy2/fix/geyser-through-scaffolding
A geyser goes through scaffolding seen from below
2026-11-10 23:30:59 +00:00
rdbt 31c02b8f59 Merge pull request #14513 from Detimes/gap-line-of-sight
Native hooks: line of sight between entities and a clear-sight block ray
2026-11-10 23:30:55 +00:00
rdbt 67c6dfbca9 Merge pull request #14512 from Detimes/gap-riding-getters
Native hooks: read the vehicle an entity rides and its passengers
2026-11-10 23:30:51 +00:00
rdbt 7ef8abd367 Merge pull request #14510 from Detimes/gap-server-props-read
Native hooks: read the dedicated server's online mode, port and proxy forwarding
2026-11-10 23:30:47 +00:00
rdbt 63f20e8f30 Merge pull request #14498 from MrCloudy2/fix/geyser-launches-projectiles
A geyser launches projectiles, boats and minecarts
2026-11-10 23:30:42 +00:00
rdbt f25fdf5828 Merge pull request #14490 from MSedgeMC/fix/wither-side-head-los
A wither's side head does not pick targets through a wall
2026-11-10 23:30:38 +00:00
rdbt 87ef5ed8d9 Merge pull request #14484 from MrCloudy2/fix/wind-charge-hurts-vehicles
A wind charge's hit damages a boat or a minecart
2026-11-10 23:30:33 +00:00
rdbt 31e1d6aedf Merge pull request #14462 from ilguerro/fix/spilled-items-pop-up
Items spilled from a destroyed shulker box or bundle pop upward again
2026-11-10 23:30:29 +00:00
rdbt cc4067a168 Merge pull request #14441 from voduff/fix/12038-cube-body-item-replace
A sulfur cube's body takes a swallowable block through /item replace
2026-11-10 23:30:25 +00:00
rdbt f7c9520898 Merge pull request #14437 from J5Casey/fix/synced-recipe-display-numbers
Recipe book numbers a server's synced recipes as the server does
2026-11-10 23:30:21 +00:00
rdbt 417728114c Merge pull request #14430 from horizon-rust/split/crate-lang
Crates ship their own lang files: horizon_platform::lang (layer plan M5)
2026-11-10 23:30:17 +00:00
rdbt 1f2fd92867 Merge pull request #14402 from vnsmoreira/packs/polytone-physics-spawn
Polytone: a particle with physics born inside a block never joins
2026-11-10 23:30:13 +00:00
rdbt b955f83e5b Merge pull request #14401 from DUzzL/fix/14382-jmath-pow-nan
Fix jmath::pow NaN special cases to match Java Math.pow
2026-11-10 23:30:09 +00:00
rdbt 916dd0cebb Merge pull request #14332 from yahya-nusseibeh/fix/registry-rusqlite-0.41
Mod registry: bundle SQLite 3.54 through rusqlite 0.41
2026-11-10 23:30:04 +00:00
rdbt b4977c3700 Merge pull request #14277 from Detimes/gap-mob-death-message
Native hooks: living_death_message (mob death message)
2026-11-10 23:30:00 +00:00
rdbt 8cbfc508ae Merge pull request #14271 from Detimes/gap-entity-lookup
Native hooks: read-only entity lookup by UUID and entity data in any level
2026-11-10 23:29:55 +00:00
rdbt 1843b792ff Merge pull request #14270 from Mik00000/perf/lighting-propagation-batch
perf(light): reuse scratch queues during incremental relight and preserve lazy section loading
2026-11-10 23:29:51 +00:00
rdbt 8b6513f833 Merge pull request #14021 from MSedgeMC/fix/weapon-damage-component
Weapon wear comes from a stack's weapon component
2026-11-10 23:29:47 +00:00
rdbt 3895a99900 Merge pull request #13988 from MSedgeMC/fix/banner-layer-codec
A banner with an invalid layer loads no patterns
2026-11-10 23:29:42 +00:00
rdbt 70f3965c0a Merge pull request #13761 from TheMrZZ/fix/h194-item-count-predicate-2
Match item count equality against the actual stack
2026-11-10 23:29:38 +00:00
rdbt e51f313b02 Merge pull request #11559 from voidsipp/fix/11329-villager-reputation-events
Use cached villager murder witnesses
2026-11-10 23:29:34 +00:00
rdbt 339bda5ed2 Merge pull request #11536 from voidsipp/fix/11336-potion-contents
Expire custom potion contents on tipped arrows
2026-11-10 23:29:29 +00:00
rdbt 0cc2963ccf Merge pull request #11484 from voidsipp/fix/11318-portal-cooldowns
Keep entity-specific Nether portal cooldowns
2026-11-10 23:29:25 +00:00
rdbt 1b27a37702 Merge pull request #15009 from DUzzL/fix/14956-powder-snow-order
Mobs: tick freezing after the move and block effects
2026-11-10 23:26:54 +00:00
rdbt d4d833f199 Merge pull request #14945 from CocofireHD/fix/14857-zombie-villager-profession
A zombie villager's profession is the constructor's draw from its own random (#14857)
2026-11-10 23:26:50 +00:00
rdbt 1101314498 Merge pull request #14775 from NongTham/fix/14664-bed-explosion-flying-kick
test: allow-flight in bed_explosion parity harness (fixes #14664)
2026-11-10 23:26:46 +00:00
rdbt 68b4c07179 Merge pull request #14549 from vivescene/docs/agents-review-when-asked
AGENTS.md: review pull requests only when asked, and submit a decision
2026-11-10 23:26:42 +00:00
rdbt 395789e107 Merge pull request #14509 from Detimes/gap-map-colors-read
Native hooks: read a map's colours (native_map_colors)
2026-11-10 23:26:37 +00:00
rdbt 9223dc0e30 Merge pull request #14446 from Mayonesu0/up-12937-dispenser-equip
Dispensers equip server players and respect armor stand slot locks
2026-11-10 23:26:33 +00:00
rdbt d9fe05af0d Merge pull request #14439 from horizon-rust/split/friends-title-icon
Title screen's Friends icon is inactive without an invite-code resolver (spec #14167 step 4)
2026-11-10 23:26:29 +00:00
rdbt f02798aed5 Merge pull request #14130 from MSedgeMC/port_map/destroy-progress-and-daylight
port map: the destroy-progress stage and the daylight ticker rows are ports
2026-11-10 23:26:25 +00:00
rdbt 900933aae2 Merge pull request #13762 from TheMrZZ/fix/h211-shulker-drop-lock-2
Preserve external locks on destroyed shulker boxes
2026-11-10 23:26:21 +00:00
rdbt 2ef31a7618 Merge pull request #15014 from darkliro/perf/linux-hybrid-cores
threads: detect performance cores on Linux hybrid CPUs
2026-11-10 23:26:03 +00:00
rdbt cbf533a8ae Merge pull request #14360 from NikitaMGrimm/frame-data-count
/data on an item frame detaches its old map when only the stack's count changes
2026-11-10 23:25:53 +00:00
rdbt ccc233474d Merge pull request #13753 from glitchsnake/fix/12935-sculk-particle-resume-reload
sculk: resume vibration particle on reload (fixes #12935)
2026-11-10 23:25:25 +00:00
rdbt 1b16aaecbf Merge pull request #15027 from NikitaMGrimm/fix/hold-fullbright-replay
Main compiles again: hold_fullbright's replay check and the firework tests' new_rocket calls
2026-11-10 23:20:47 +00:00
Nikita Maximilian GrimmandClaude Fable 5.2 2160d3e4d1 The firework event tests pass new_rocket its random
#14603 gave new_rocket the rocket's own random as a fourth argument; the
tests added by 188817342 still called it with three, so the client's tests
stopped compiling on main.

Refs #15025

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-11 00:08:01 +01:00
Nikita Maximilian Grimm 4536ec51ca Merge remote-tracking branch 'origin/main' into fix/hold-fullbright-replay 2026-11-11 00:01:35 +01:00
Nikita Maximilian GrimmandClaude Fable 5.2 72d1238876 hold_fullbright asks the replay whether it plays
#14335 read Game::studio_replay, which the layer split had moved to
Game::replay before it merged, so main stopped compiling.

Fixes #15025

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-10 23:51:58 +01:00
rdbt 214469a4bf Merge pull request #14737 from ilguerro/fix/sitting-cat-blocks-comparator
Silence a chest's comparator while a cat sits on it
2026-11-10 22:51:41 +00:00
rdbt b2bfa61fb1 Merge pull request #14987 from DUzzL/fix/14856-shulker-armor
A shulker keeps its armor attribute once its lid has moved
2026-11-10 22:49:57 +00:00
rdbt 5f4dbb058b Merge pull request #14969 from vermingov/fix/villager-food-level-14967
A villager narrows a loaded FoodLevel to a byte (#14967)
2026-11-10 22:49:50 +00:00
rdbt 292dfe14e1 Merge pull request #14955 from DUzzL/fix/14832-zombie-villager-cure
A zombie villager starts its cure when it loads a ConversionTime
2026-11-10 22:49:45 +00:00
rdbt 506624b951 Merge pull request #14951 from Web-mc1/fix/biome-get-empty
Fix biome table panic when empty
2026-11-10 22:49:39 +00:00
rdbt e7fb465789 Merge pull request #14935 from TheMrZZ/slot-sources/holder-tag-codec
Slot sources: holder and tag codec, and slot-source tags in DataStore
2026-11-10 22:49:33 +00:00
rdbt 31af1ed8ad Merge pull request #14932 from Detimes/perf-replay-excuse
Perf: the replay excuse tests the distance before the entity kind
2026-11-10 22:49:28 +00:00
rdbt af3a8d235b Merge pull request #14928 from MSedgeMC/fix/crafter-recipe-book
port: a crafter's grid counts for the recipe book
2026-11-10 22:49:22 +00:00
rdbt 3c564f0e2b Merge pull request #14910 from oloion/fix/custom-crafting-recipes-book
client: show custom server crafting recipes in recipe book
2026-11-10 22:49:17 +00:00
rdbt 45c4e0d744 Merge pull request #14900 from MSedgeMC/fix/jukebox-block-change
port: a disc coming or going posts block_change
2026-11-10 22:49:12 +00:00
rdbt 802056c402 Merge pull request #14892 from NikitaMGrimm/shader-api-primaries-key
Shader API 0.2: [frame] primaries, and the rule for new names
2026-11-10 22:49:06 +00:00
rdbt fc604a958a Merge pull request #14785 from MSedgeMC/fix/merchant-scroller-cursor
The merchant's trade scroller shows its cursor
2026-11-10 22:49:01 +00:00
rdbt 8f98a65be5 Merge pull request #14776 from NongTham/fix/14715-modifier-key-handling
input: clear modifiers on focus loss and heal alt and super keys (fixes #14715)
2026-11-10 22:48:56 +00:00
rdbt 75dc9db341 Merge pull request #14772 from foudretbr/fix/attack-speed-attribute-modifiers
fix(game): evaluate attack_speed attribute modifiers for player attack cooldown (#14721)
2026-11-10 22:48:51 +00:00
rdbt eb161b9c99 Merge pull request #14745 from MSedgeMC/fix/feed-never-baby
A mob that cannot be a baby does not take the food that grows it
2026-11-10 22:48:44 +00:00
rdbt 7687b2a619 Merge pull request #14742 from foudretbr/fix/modifier-key-debug-shortcuts-focus-loss
fix(input): reset debug keys modifier state on window focus loss (#14715)
2026-11-10 22:48:39 +00:00
rdbt 1d50e158b9 Merge pull request #14723 from ilguerro/fix/firework-silent-explode-stat
Firework rockets: honour Silent, post EXPLODE, count block launches
2026-11-10 22:48:33 +00:00
rdbt 58b5d93f8f Merge pull request #14710 from glitchsnake/fix/14467-squid-noai-motion
mobs: apply squid sinking velocity on land under NoAI (fixes #14467)
2026-11-10 22:48:28 +00:00
rdbt 9951ef958d Merge pull request #14705 from Skr1zzzz/falling-model-cache
Falling blocks: cache each model's vertices instead of rebuilding them every frame
2026-11-10 22:48:22 +00:00
rdbt 8cb5c02d4b Merge pull request #14703 from ilguerro/fix/horses-do-not-climb
Stop horses, llamas, camels and flying monsters climbing vines and ladders
2026-11-10 22:48:17 +00:00
rdbt b75ffd9554 Merge pull request #14699 from NikitaMGrimm/hdr-pq-dither
HDR10 output: the last pass dithers its 10 bits, so a clear sky doesn't band
2026-11-10 22:48:12 +00:00
rdbt c7552a32e5 Merge pull request #14697 from ilguerro/fix/phantoms-glass-roof
Spawn phantoms under a glass roof
2026-11-10 22:48:07 +00:00
rdbt ff03932fb0 Merge pull request #14693 from ilguerro/fix/turtle-frog-lay-events
Post block_place when turtles and frogs lay eggs, and stop bred turtles bursting hearts
2026-11-10 22:48:02 +00:00
rdbt 6a007e8e02 Merge pull request #14691 from ilguerro/fix/campfire-candle-douse
Sneaking keeps the held food off campfires and candles answer pick-block
2026-11-10 22:47:57 +00:00
rdbt a4b71ca8ba Merge pull request #14689 from ilguerro/fix/shulker-teleport-vibration
Post a shulker teleport's game event for sculk sensors
2026-11-10 22:47:52 +00:00
rdbt 1386a7fd1f Merge pull request #14677 from YourPancakes/perf/skip-full-cube-shape-allocation
perf(world): avoid full-cube allocation in collision checks
2026-11-10 22:47:47 +00:00
rdbt 5f029bb260 Merge pull request #14672 from YourPancakes/perf/cache-flat-compute-bind-groups
perf(render): cache flat compute bind groups
2026-11-10 22:47:42 +00:00
rdbt 23a2b65c29 Merge pull request #14667 from vnsmoreira/fix/dispensed-item-random
A dispensed item draws its constructor's motion from its own random
2026-11-10 22:47:37 +00:00
rdbt 62165d135d Merge pull request #14665 from YourPancakes/perf/sky-octagon-half-planes
perf(render): evaluate sky octagon with half-planes
2026-11-10 22:47:31 +00:00
rdbt 2732d737a1 Merge pull request #14653 from Ragbie/tests/pad-parity-actions
pad::parity_tests: the pad_press helper records the press, so four tests pass again
2026-11-10 22:47:25 +00:00
rdbt a1807b93a1 Merge pull request #14629 from Ragbie/tests/tnt-silence-stack
TNT silence tests run on an 8 MiB stack, so they don't overflow on Windows
2026-11-10 22:47:18 +00:00
rdbt 53c0b0694b Merge pull request #14602 from ilguerro/fix/sniffer-dig-vibration-silence
Keep animal_brain.rs at main's line count
2026-11-10 22:47:13 +00:00
rdbt cc3e92cadd Merge pull request #14600 from ilguerro/fix/bred-wolf-voice
Move the bred offspring's draws and their test out of creatures.rs
2026-11-10 22:47:08 +00:00
rdbt 27e0787a23 Merge pull request #14592 from NikitaMGrimm/parity/bucket-mob-from-bucket
A mob from a bucket is not persistent, a sulfur cube keeps its bucket's age, and tadpoles never despawn
2026-11-10 22:47:02 +00:00
rdbt f312c30454 Merge pull request #14585 from foudretbr/fix/cat-default-variant-black
fix(mobs): default cat variant to black when omitted
2026-11-10 22:46:57 +00:00
rdbt a79db77ac1 Merge pull request #14573 from MSedgeMC/ledger/moved-citations
port map: point citations at the modules they moved to
2026-11-10 22:46:52 +00:00
rdbt 5fde1275b5 Merge pull request #14556 from ilguerro/fix/wall-hanging-sign-click-command
Run a waxed wall hanging sign's click command instead of chaining a sign
2026-11-10 22:46:46 +00:00
rdbt 7ebdfbc3ee Merge pull request #14508 from Ragbie/tests/mob-landing-neighbourhood
mob_landing_support_tests load the chunks a pig needs to tick
2026-11-10 22:46:41 +00:00
rdbt 9e4e43a69b Merge pull request #14496 from horizon-rust/fix/service-images-path-crates
Telemetry and mod registry images copy every crate their lockfile builds
2026-11-10 22:46:35 +00:00
rdbt 78f286e657 Merge pull request #14492 from Ragbie/fix/landing-vibrations
Primed TNT and falling blocks make a vibration when they land
2026-11-10 22:46:28 +00:00
rdbt 71e8f7c8cd Merge pull request #14489 from MSedgeMC/fix/wololo-visibility
An invisible blue sheep is not wololoed from five blocks
2026-11-10 22:46:23 +00:00
rdbt 91429d906a Merge pull request #14488 from MSedgeMC/fix/sulfur-tempt-visibility
A sulfur cube's tempt range shrinks for an invisible player
2026-11-10 22:46:17 +00:00
rdbt 846e34c2eb Merge pull request #14486 from vnsmoreira/fix/golden-horse-armor
A horse draws its golden armour
2026-11-10 22:46:10 +00:00
rdbt a084ad2f35 Merge pull request #14468 from Solenopsisbot/fix/high-contrast-pack-hang
Fix startup hang with High Contrast resource pack
2026-11-10 22:46:05 +00:00
rdbt 10588c9b4c Merge pull request #14448 from ilguerro/fix/frame-vibrations
Let sculk hear item frame changes as vanilla does
2026-11-10 22:46:00 +00:00
rdbt 8ad2bee66a Merge pull request #14434 from MSedgeMC/fix/written-book-resolve
port: a written book resolves its selectors and scores when it is opened
2026-11-10 22:45:54 +00:00
rdbt 3e8dbbc2df Merge pull request #14423 from YourPancakes/perf/skip-swim-below-block-reads
perf(mobs): skip redundant swim block reads
2026-11-10 22:45:49 +00:00
rdbt bbe1ec5372 Merge pull request #14415 from YourPancakes/perf/villager-item-sensing-index
perf(mobs): reuse item cells for villager sensing
2026-11-10 22:45:43 +00:00
rdbt 1f6cf55e1c Merge pull request #14413 from MSedgeMC/fix/shulker-neighbor-updates
port: a shulker box lid updates its neighbours as it moves
2026-11-10 22:45:38 +00:00
darkliroandAntigravity fb9b1d3542 threads: detect performance cores on Linux hybrid CPUs
Count performance cores on Linux systems with asymmetric core frequencies
(such as Intel Alder Lake, Raptor Lake, Meteor Lake and ARM big.LITTLE)
via sysfs cpufreq, bringing parity with the Apple silicon performance core
heuristics from #11294.

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

The list now reads all or nothing, and the test asserts the empty answer for a
mixed list, a missing field, and that a list of valid layers still reads back
(and round-trips), so an always-empty result cannot pass it.
2026-11-10 21:58:02 +00:00
DUzzL a8a5843f59 Mobs: tick freezing after the move and block effects, like LivingEntity.aiStep 2026-11-10 22:40:06 +01:00
CocofireHDandDeepSeek V4.1 Flash 63a2feba79 A zombie villager's profession is the constructor's draw from its own random (#14857)
`ZombieVillager.defineSynchedData` seeds `DATA_VILLAGER_DATA` from
`initializeZombieVillagerData(this.random)`: one `nextInt(15)` on the entity's
own random, made inside `Entity`'s constructor. Horizon built a synthetic
`LegacyRandom` out of the mob's random state and id instead, so the profession
never matched vanilla's and the mob's own random never advanced.

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

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

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

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

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

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-10 21:38:36 +01:00
DUzzL b228e6bef7 A shulker keeps its armor attribute once its lid has moved 2026-11-10 20:51:41 +01:00
Verm 6a246dd81d A villager narrows a loaded FoodLevel to a byte (#14967)
Villager.readAdditionalSaveData reads it with getByteOr, which narrows
any numeric tag (IntTag.byteValue is (byte)(value & 255)): FoodLevel:300
loads as 44. The loader kept the raw number.
2026-11-10 19:20:30 +00:00
Web-mc1 4c49ec3523 Fix biome table panic when empty 2026-11-11 07:47:48 +13:00
Oloion 2e6f3cd716 client: show custom server crafting recipes in recipe book (fixes #14608) 2026-11-10 21:32:19 +03:00
DetimesandClaude Opus 5.5 289e90d636 Merge upstream/main into perf-replay-excuse
Brings the branch up to date and lays out the new code and tests as
rustfmt wants them, which the verify job checks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 18:16:08 +00:00
Mayonesu0andClaude Opus 5.5 3275cd1602 Dispensers pick players in getEntitiesOfClass order, loaded or not
Players are entities of the level from placeNewPlayer/respawn on, so a
guest whose client is still loading is equipped too, and guests and the
local player sort with the mobs and stands: entity sections first
(EntitySectionStorage.forEachAccessibleNonEmptySection, SectionPos.asLong
order), then join order by entity id.

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

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

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

Co-Authored-By: ZCode <[email protected]>
2026-11-11 02:01:45 +08:00
DUzzL 5da9065a8d Start the cure when a zombie villager loads a ConversionTime
ZombieVillager.readAdditionalSaveData calls startConverting, so a saved cure swaps weakness for strength on load.
2026-11-10 18:56:44 +01:00
MSedgeMC a04de39fea port_map: CrafterBlockEntity.fillStackedContents is a port
The crafter's nine slots are the menu's MAIN container and count for the
recipe book's craftable check.
2026-11-10 17:45:24 +00:00
MSedgeMC 76d95dbcc9 test: a crafter's grid is stacked for the recipe book
`a_crafters_grid_counts_for_the_recipe_book` opens a crafter menu, puts
four oak planks in its first slot and asks the stacked contents whether
an oak-plank ingredient can be crafted. Without the crafter arm the same
call fails.
2026-11-10 17:45:13 +00:00
MSedgeMC b9ad2da17e fix: a crafter's grid counts for the recipe book
`CrafterBlockEntity.fillStackedContents` hands the crafter's nine slots
to the recipe book's stacked contents (`accountSimpleStack` each), so an
open crafter's grid counts towards the craftable check.

Horizon's `Menu::stacked_contents` read the player's inventory, the mod
sources and the `GRID` container, but a crafter's slots are its `MAIN`
container (`CrafterMenu.addSlots`): with a crafter open the book saw an
empty grid and marked nothing craftable.
2026-11-10 17:45:13 +00:00
DetimesandClaude Haiku 5.5 fab1e1a3e6 Test: entity_near matches the scan it replaced
Differential test over 2000 seeded random inputs: entity sets of up to 40
states (all four kinds, own and other ids), guest positions and slacks,
with entities placed on or next to every edge of the close box, checked
against a verbatim copy of the old scan. Plus an inclusive-edge test and
the own/item/orb exclusions. The inputs are multiples of 1/8 so the box
edges are exact in f64. No world or game resources needed; cargo not run.

Refs #14920

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

Refs #14920

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
2026-11-10 17:40:37 +00:00
MSedgeMC b5a64cf86b port_map: JukeboxBlockEntity.notifyItemChangedInJukebox is a port
A disc coming or going sets `has_record` and posts `block_change` at the
box; the row kept the old gap.
2026-11-10 17:24:58 +00:00
MSedgeMC 0966aa0d3f test: a disc change posts block_change
`a_disc_change_posts_block_change` places a jukebox, records the posted
game events and calls `set_has_record`: the box posts
`minecraft:block_change` at its centre with no source. Without the post
the recorded list is empty.
2026-11-10 17:15:39 +00:00
Nikita Maximilian GrimmandClaude Fable 5.2 9188926fff Shader API 0.2: [frame] primaries, and the rule for new names
A 0.2 pack can't show a colour outside sRGB today: the contract says every
colour is in BT.709 primaries, forever. This adds the key that will let a
pack ask for more, before 0.2 is on by default and packs are written against
that contract.

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

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

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

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-10 18:13:34 +01:00
MSedgeMC b920f8a432 fix: a disc coming or going posts block_change
`JukeboxBlockEntity.notifyItemChangedInJukebox` sets the `has_record`
block state with update flag 2 and then calls
`level.gameEvent(..., GameEvent.BLOCK_CHANGE, pos)`: sculk sensors hear a
disc enter or leave a jukebox. Horizon's `set_has_record` changed the
state but posted no game event.
2026-11-10 17:09:19 +00:00
Florian ErnstandClaude Opus 5.5 16fb5cfed0 Slot sources: holder and tag codec, and slot-source tags in DataStore
SlotSources.CODEC admits a registered holder (`ns:id`), a holder list and a
`#tag` holder set wherever a slot source is accepted, and validates the
`group`, `limit_slots`, `filtered`, `contents` and `slot_range` shapes
(including a `slot_range` source context) against the loaded registry.

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

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

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

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

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

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

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-10 18:02:03 +01:00
readerkid 327b811914 Map CEM's armour models to the layers 26.3 draws, and the rabbit's feet
CEM names a humanoid's armour with two models, `<mob>_outer_armor` and
`<mob>_inner_armor`, but 26.3 draws it on four layers of the model: the helmet,
the chestplate, the boots and the thinner leggings. A CEM name gave one layer
key, so no armour file reached any of them, and the 24 armour layers of the
zombie family's baby models were unreachable.

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

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

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

Fixes #8426

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
2026-11-10 07:16:11 -08:00
NongTham e1848e08a4 input: clear modifiers on focus loss and heal alt and super keys (fixes #14715) 2026-11-10 19:10:27 +07:00
foudretbr 3661bfd7c2 fix(game): evaluate attack_speed attribute modifiers for player attack cooldown (#14721) 2026-11-10 12:55:27 +01:00
MSedgeMC a08205b8ec port_map: MerchantScreen.extractScroller is ported 2026-11-10 11:44:30 +00:00
MSedgeMC 41fdaa3f0c test: the merchant scroller's thumb asks for its cursor
Eight offers put the thumb at the track's top; a hand over it, the vertical
resize while dragged, nothing left of the thumb or past its bottom edge, and
the disabled scroller of seven offers asks for nothing.
2026-11-10 11:44:30 +00:00
MSedgeMC 2398317072 fix: the merchant's trade scroller shows its cursor
MerchantScreen.extractScroller requests CursorTypes.POINTING_HAND over the
scroller's thumb, or RESIZE_NS while it is dragged. Horizon drew the thumb
but asked for no cursor, so the pointer stayed an arrow over it.
2026-11-10 11:44:30 +00:00
MSedgeMC 669e1a162a Move takes_food into mobs/ai/feeding.rs so ai.rs may not grow 2026-11-10 11:29:46 +00:00
NongTham d645b72e8b test: allow-flight in bed_explosion parity harness (fixes #14664) 2026-11-10 18:05:15 +07:00
Loki 3bc4e3035c Merge remote-tracking branch 'origin/main' into fix/12935-sculk-particle-resume-reload 2026-11-10 19:47:20 +09:00
foudretbr 15183261d9 fix(input): reset debug keys modifier state on window focus loss (#14715) 2026-11-10 11:25:52 +01:00
MSedgeMC adbc523e5c port_map: the canAgeUp feeding branch
AgeableMob.canAgeUp to ai.rs::can_age_up, and Animal.mobInteract's food
branches to ai.rs::takes_food: an adult at age 0 that canFallInLove, else
canAgeUp.
2026-11-10 10:20:16 +00:00
MSedgeMC 7f5b3462df test: feeding a frog at a baby's age leaves the food in the hand
A frog and a cow, both at Age -24000, through takes_food and feed_animal: the
frog refuses the food and keeps the age, the cow takes it and grows a tenth.

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

takes_food now asks can_age_up(m), the port of canAgeUp, the same predicate
the rest of the feeding code already used.
2026-11-10 10:20:06 +00:00
1d9040c05a Silence a chest's comparator while a cat sits on it
ChestBlock.isChestBlockedAt is a redstone conductor above the chest or a
cat sitting in the block above it (ChestBlock.java:318-319, the cat test
at :327-338). getAnalogOutputSignal (:346-348) reads getContainer(..,
false), whose combine(.., false) (:279) uses that predicate, so a blocked
chest combines to nothing and the comparator reads 0.

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 11:14:38 +03:00
LokiandAntigravity 27cded80d5 mobs: apply squid sinking velocity on land under NoAI (fixes #14467)
Co-Authored-By: Antigravity <[email protected]>
2026-11-10 15:36:11 +09:00
masa-masa-0314andZCode 64e74bffd2 docs: propose friends overlay Part 2 spec
Co-Authored-By: ZCode <[email protected]>
2026-11-10 14:19:11 +08:00
Nikita Maximilian GrimmandClaude Fable 5.2 db26868e4f HDR10 output: the last pass dithers its 10 bits
The float frame is smooth, but the last pass rounded it to the HDR10
swapchain's 10 bits as it was. Around 90 nits one PQ code is a 1% step
in light, and a clear noon sky is two or three codes tall, so it showed
as wide flat bands with hard edges (#12989's audit, F3).

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

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

Refs #12989

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

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

Claude-Session: https://claude.ai/code/session_01WZzq8MxShENtiMj2DMbm5G
2026-11-10 06:36:40 +01:00
3f1f2fd3a8 Spawn phantoms under a glass roof
PhantomSpawner.tick asks Level.canSeeSky, which is "sky light 15 here"
(BlockAndLightGetter.canSeeSky), not a scan for a full collision cube:
glass and stained glass keep the sky light and spawn phantoms, while
stone, water and a slab roof do not. The advancement location predicate
read the same hand-rolled scan and now reads the sky light too.

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

Claude-Session: https://claude.ai/code/session_01WZzq8MxShENtiMj2DMbm5G
2026-11-10 05:50:01 +01:00
YourPancakes ab7d9f4c50 perf(world): avoid full-cube allocation in collision checks 2026-11-10 07:18:19 +03:00
YourPancakesandChatGPT 4f1b40d9ca fix(render): keep binding cache out of oversized gfx.rs
Co-Authored-By: ChatGPT (GPT-6) <[email protected]>
2026-11-10 07:12:21 +03:00
YourPancakes 81a8d04a1f perf(render): cache flat compute bind groups 2026-11-10 06:55:35 +03:00
Vinícius MoreiraandClaude Opus 5.5 e1a6c5e363 A dispensed item draws its constructor's motion from its own random
DefaultDispenseItemBehavior.spawnItem builds the item with the
five-argument ItemEntity constructor, which draws a default motion (two
nextDouble) from the item's own random before spawnItem sets the thrown
one. Horizon passed the thrown motion straight in and skipped those draws,
so every later draw from the item's random, starting with
moveTowardsClosestSpace's escape speed, came two doubles early.
spawn_item_thrown makes the draws; the dispenser, the vault and the trial
spawner use it.

Fixes #14537

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

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

Claude-Session: https://claude.ai/code/session_01WZzq8MxShENtiMj2DMbm5G
2026-11-10 04:13:00 +01:00
Evrim CicekandClaude Opus 5.5 03a6255967 pad::parity_tests: pad_press records the press as pad_button does (#14648)
Since the radial menu, Pad::holds_action reads the actions pad_button
records for a pressed button; the helper called pad_action directly, so
four tests read a held action as not held.

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

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

Claude-Session: https://claude.ai/code/session_01WZzq8MxShENtiMj2DMbm5G
2026-11-10 03:52:09 +01:00
DetimesandClaude Haiku 5.5 7059358371 Catalog test: accept living_death_message as a free function
living_death_message is a free function in horizon_client_lib::living_death,
so the test needs a set for that module like hunger, landing and chat.

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
2026-11-10 01:52:12 +00:00
Evrim CicekandClaude Opus 5.5 d05c54b48e TNT silence tests run on an 8 MiB stack
Fixes #14628: the five source_silence_game tests overflowed the 2 MiB
test thread in a Windows debug build (4 MiB is enough) and took the whole
horizon-client lib run down.

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

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

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

Harness: tools/test_wind_bursts_all_push_parity.py.

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

Harness: tools/test_projectile_fluid_current_parity.py.

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

Harness: tools/test_piston_cactus_vehicles_parity.py.

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

Harness: tools/test_projectile_hits_tnt_parity.py.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 02:10:37 +01:00
Detimes 7a47e7a21f Merge remote-tracking branch 'upstream/main' into gap-line-of-sight 2026-11-10 01:06:25 +00:00
188817342b Firework rockets: honour Silent, post EXPLODE, count block launches
`FireworkRocketEntity.explode` posts `GameEvent.EXPLODE` from `getOwner()` at
the rocket before the damage, and the entity event passes `!isSilent()` to
`createFireworks`, which gates only the blast and the twinkle of
`FireworkParticles.Starter`, so a silent rocket keeps its sparks and flash.
A rocket launched from a block face also counts as a use, as
`ItemStack.useOn` does for `FireworkRocketItem.useOn`'s SUCCESS.

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

Claude-Session: https://claude.ai/code/session_01WZzq8MxShENtiMj2DMbm5G
2026-11-10 02:05:57 +01:00
Detimes 56c7214447 Merge remote-tracking branch 'upstream/main' into gap-riding-getters 2026-11-10 00:54:38 +00:00
Skr1zzzzandClaude Opus 5.5 c1d32a0f93 Falling blocks: cache each model's vertices instead of rebuilding them every frame
The falling-block loop rebuilt every block's model vertices each frame: the
model lookup, two sprite hash lookups per face, shading and uv mapping. They
depend only on the block state and the model variant picked, so they are now
kept in model space per state and variant; each frame copies them, applies the
same translation as before and sets the block's light. The output is the same,
bit for bit.

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

Part of #13007

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 03:50:36 +03:00
Nikita Maximilian GrimmandClaude Opus 5.5 861914b486 Merge origin/main into parity/bucket-mob-from-bucket
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 01:46:34 +01:00
foudretbr d1ca042ab8 fix(mobs): default cat variant to black when omitted (#14540) 2026-11-10 01:45:52 +01:00
Detimes 1e66c75589 Merge remote-tracking branch 'upstream/main' into gap-server-props-read 2026-11-10 00:40:40 +00:00
Detimes bd6bf2422b Merge remote-tracking branch 'upstream/main' into gap-mob-death-message 2026-11-10 00:25:43 +00:00
MSedgeMC 72879fc35b port map: point citations at the modules they moved to
The game, entity_nbt, stations, creatures, minecarts, particles and remote modules were split this week; twenty rows
still named the file a function left.
2026-11-10 00:17:52 +00:00
voidsip 38b2b41791 Name the arrow expiry harness for its actual coverage 2026-11-09 18:12:25 -06:00
Riccardo GuerrieroandClaude Opus 5.5 5f40c762f6 Move the bred offspring's draws and their test out of creatures.rs
creatures.rs is over the 3000-line limit (docs/layers.md#file-size): the
coat coin, the wolf pup's voice and the test of both move to
game/creatures/bred_draws.rs.

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 00:57:43 +01:00
voduff d68f241a0e Merge remote-tracking branch 'origin/main' into fix/12038-cube-body-item-replace 2026-11-09 23:52:09 +00:00
valuttaandClaude Opus 5.5 176f5b3475 AGENTS.md: review pull requests only when asked, post only findings
An agent asked for some reviews swept about 300 open pull requests, most
reviews saying it found nothing. Agents now review a pull request only
when its author or a maintainer asks for it, post only findings someone
can act on as a real review, and skip reviews that found nothing.
Maintainers' own agents may run wider passes when the maintainer asks.

Closes #14545.

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

Fixes #14538

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

Harness: tools/test_geyser_scaffolding_parity.py.

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

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

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

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

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
2026-11-09 23:11:58 +00:00
DetimesandClaude Haiku 5.5 9f861b1624 Use contains_key in entity lookup test (clippy unnecessary_get_then_check)
Co-Authored-By: Claude Haiku 5.5 <[email protected]>
2026-11-09 23:04:34 +00:00
Evrim Cicek bdf33dd2a3 Merge remote-tracking branch 'upstream/main' into tests/mob-landing-neighbourhood 2026-11-10 02:01:58 +03:00
MrCloudy2andClaude Fable 5.2 016630efda Keep creatures.rs from growing: the vehicle re-tick lives in wind.rs
The file-size rule (docs/layers.md#file-size). hurt_vehicle moves with it.

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

Harness: tools/test_geyser_launch_parity.py.

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 01:51:39 +03:00
MSedgeMC 763b6f6c8c port_map: map WitherBoss side-head target sight 2026-11-09 22:49:37 +00:00
MSedgeMC 88c287eb7e test: a wither's side head does not pick through a wall 2026-11-09 22:49:37 +00:00
MSedgeMC 94f499bdbe fix: wither side heads need sight of a target 2026-11-09 22:49:37 +00:00
MSedgeMC f943e82857 port_map: map EvokerWololoSpellGoal.canUse visibility range 2026-11-09 22:49:30 +00:00
MSedgeMC 6efab1f890 test: an invisible blue sheep is not wololoed from five blocks 2026-11-09 22:49:30 +00:00
MSedgeMC 34b5ab04b0 fix: scale the evoker's wololo range by visibility 2026-11-09 22:49:30 +00:00
MSedgeMC a76ea00dde port_map: map SulfurCubeTemptGoal.canUse visibility range 2026-11-09 22:49:22 +00:00
MSedgeMC d7c7e06634 test: an invisible player tempts a sulfur cube only within two blocks 2026-11-09 22:49:22 +00:00
MSedgeMC 7f8a855d78 fix: scale a sulfur cube's tempt range by visibility 2026-11-09 22:49:22 +00:00
Evrim Cicek 5f68b5da25 Merge remote-tracking branch 'upstream/main' into fix/landing-vibrations 2026-11-10 01:36:24 +03:00
MrCloudy2andClaude Fable 5.2 ca01155bce A wind charge passes a vehicle an earlier charge of the tick broke
Vanilla ticks entities one after the other, so a charge never meets a boat or
minecart that an earlier charge already broke. The tick's hits are all found
first here, so such a charge ticks again without the broken vehicle.

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

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

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

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

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

Fixes #13826

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:23:28 -03:00
SolenopsisbotandCodex 3a22ba32c2 Fix texture metadata parsing during pack startup
Co-Authored-By: Codex <[email protected]>
2026-11-10 09:17:35 +11:00
MrCloudy2andClaude Fable 5.2 eedbb1717c A wind charge's hit damages a boat or a minecart
AbstractWindCharge.onHitEntity hurts what it hit by one point before the
burst; a vehicle takes it through VehicleEntity.hurtServer and breaks on the
fifth hit of a tick. wind_charge_damage only looked among the mobs.

Harness: tools/test_wind_charge_vehicle_parity.py.

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 01:06:08 +03:00
Horizon agent dbbe0e6bf5 Merge origin/main (brings #14090's test_git_slim fix) 2026-11-09 22:59:17 +01:00
5a78eee6ca Items spilled from a destroyed shulker box or bundle pop upward again
`ItemUtils.onContainerDestroyed` makes each stack a destroyed item held
with `new ItemEntity(level, x, y, z, stack)`, whose motion is 0.2 up and
0.2 less 0.1 sideways from the *item's* own random
(ItemEntity.java:61-66). The spill drew three doubles from the level
random instead, so the stacks barely rose and the level's stream
advanced three times per stack. Route it through `spawn_item_own`,
which already draws the four-argument constructor's motion (#13938).

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:32:03 -03:00
voidsip e624d71b21 Remove a needless borrow in the arrow fixture 2026-11-09 15:31:36 -06:00
Horizon agentandClaude Opus 5.5 a190dbbc0d Crates ship their own lang files: horizon_hooks::lang (layer plan M5)
horizon_hooks::lang::register(&'static [(code, json)]), re-exported as
horizon_platform::lang. The game's entry installs the registered files
into horizon_client::lang; Language::load reads them in the horizon
namespace right after the engine's bundled file for the same language,
before packs, and the engine's file wins on a key both have. Regional
variants share one alias table with the bundled files (fr_ca -> fr_fr,
de_ch -> de_de without ß). Nothing registered: languages load as before.

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

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

`Behaviours::shulker_neighbor_updates` ports the two calls, and
`tick_shulkers` runs it once per counted update. The box's collision
shape for the same ticks already reaches the world through
`shulker_collision_state`.
2026-11-09 21:04:49 +00:00
DUzzL 920e317f53 Make jmath::pow return NaN like Java for NaN exponent and |x|==1 with infinite y 2026-11-09 21:47:25 +01:00
Vinícius MoreiraandClaude Opus 5.5 9286b28e24 Polytone: a particle with physics born inside a block goes at once
A custom particle with has_physics whose box starts inside a block's
collision is dropped before its first tick, so its emitters never run.
Falling leaves start 0.05 under their leaf block, and a pack's leaf with a
0.08 hitbox reaches into it: in Java such a leaf never falls, while here
every one fell, and a leaf that emits a resting copy of itself each tick
left trails of them under every tree until the frame rate collapsed.

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:45:54 -03:00
voidsip df4b2599b4 Load the portal fixture's entity-ticking neighborhood 2026-11-09 14:43:57 -06:00
Riccardo Guerriero 8aea9cb52b Merge remote-tracking branch 'origin/main' into fix/frame-vibrations 2026-11-09 21:40:02 +01:00
voidsip 8aaa338f08 Separate Nether portal cooldown handling from gateway changes 2026-11-09 14:20:39 -06:00
voidsip 79b62aaaea Keep witness visibility inputs on seen villagers 2026-11-09 14:17:44 -06:00
Detimes 02b3b03618 Merge remote-tracking branch 'upstream/main' into gap-line-of-sight 2026-11-09 20:17:13 +00:00
DetimesandClaude Haiku 5.5 0acd52bcb5 Work in progress
Co-Authored-By: Claude Haiku 5.5 <[email protected]>
2026-11-09 20:17:11 +00:00
Detimes 02f00f9b83 Merge remote-tracking branch 'upstream/main' into gap-riding-getters 2026-11-09 20:17:08 +00:00
Detimes 7d75faab81 Merge remote-tracking branch 'upstream/main' into gap-server-props-read 2026-11-09 20:17:05 +00:00
Detimes 0eddebf75e Merge remote-tracking branch 'upstream/main' into gap-map-colors-read 2026-11-09 20:17:04 +00:00
DetimesandClaude Haiku 5.5 baab01b031 Work in progress
Co-Authored-By: Claude Haiku 5.5 <[email protected]>
2026-11-09 20:17:03 +00:00
Detimes 034c4b6e5c Merge remote-tracking branch 'upstream/main' into gap-mob-death-message 2026-11-09 20:16:59 +00:00
Detimes 566027307e Merge remote-tracking branch 'upstream/main' into gap-entity-lookup 2026-11-09 20:16:57 +00:00
voidsip e8dbc42da7 Use the oracle memory constructor entity-list type 2026-11-09 14:13:14 -06:00
voidsip adfc70d2f3 Split custom arrow contents and expiration from potion impacts 2026-11-09 14:10:51 -06:00
voidsip 9da6aa25e5 Split cached villager murder witnesses from hurt attribution 2026-11-09 14:10:25 -06:00
voidsip acf9008e49 Merge commit '38174db59bfd5bf90027bd727f894b141c9e72dc' into split/11536-preserve-live
# Conflicts:
#	.github/workflows/ci.yml
2026-11-09 14:06:12 -06:00
voidsip 7e32b7d0da Merge main before splitting villager reputation fixes 2026-11-09 14:06:08 -06:00
Nikita Maximilian GrimmandClaude Opus 5.5 c14ab8d543 Merge remote-tracking branch 'origin/main' into frame-data-count
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 21:05:56 +01:00
voidsip cd9441f90e Merge commit '38174db59bfd5bf90027bd727f894b141c9e72dc' into split/11484-review 2026-11-09 14:05:52 -06:00
Nikita Maximilian GrimmandClaude Opus 5.5 ac296614af /data on an item frame detaches its old map when only the stack's count changes
ItemFrame.readAdditionalSaveData compares the Item stack as read with
ItemStack.matches, count included, and removes the framed map on a
mismatch; only setItem then keeps one item. apply_entity_nbt compared
against item_frame_from_nbt's stack, already cut to one, so a count-only
change kept the old map's marker. It now compares the stack as read.

Fixes #14178

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-09 21:36:55 +02:00
DetimesandClaude Haiku 5.5 de6cc998df Native hooks: read the vehicle an entity rides and its passengers
Adds the read-only helpers native_vehicle_of(entity) and
native_passengers_of(entity) for server mods (vanilla Entity.getVehicle
and getPassengers, first = controlling passenger). Helper-only fragment
riding_getters.json; API number is assigned when the fragment is stamped.

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
2026-11-09 19:00:46 +00:00
DetimesandClaude Haiku 5.5 03b7d245b5 Native hooks: living_death_message (mob death message) (work in progress)
Co-Authored-By: Claude Haiku 5.5 <[email protected]>
2026-11-09 18:59:43 +00:00
DetimesandClaude Haiku 5.5 a6204f1f3a Native hooks: read-only entity lookup by UUID and entity data in any level (work in progress)
Co-Authored-By: Claude Haiku 5.5 <[email protected]>
2026-11-09 18:57:08 +00:00
Mik00000 c2dd2d935d perf(light): reuse scratch queues during incremental relight and preserve lazy section loading
- Reuse thread-local scratch queues (`RelightScratch`) across BFS light propagation passes, avoiding reallocation during incremental relighting.
- Pass borrowed slices `&[Pos]` to `relight_layer` to avoid redundant `Vec<Pos>` heap allocations.
- Cache consecutive vertical column lookups in `lt_sources` to avoid redundant map lookups.
- Preserve lazy loading of sections in `Region`, only loading and cloning sections that are actually read or written.
- Add `relight_single_block_bench` and update `relight_bench` to verify timings for both single-block and large batch edits.
2026-11-09 20:55:36 +02:00
DetimesandClaude Haiku 5.5 9f04ad5318 Native hooks: read the dedicated server's online mode, port and proxy forwarding
Mods can read whether a dedicated server runs in online mode, the port it
listens on and whether it takes its players from a Velocity proxy
(forwarding.secret). The values come from the live network config and
listening socket, so they match what the server does. None off a dedicated
server and before it listens. The forwarding secret is never returned.

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
2026-11-09 18:47:11 +00:00
Horizon agentandClaude Opus 5.5 272fcfa34c mace.rs: pass the borrowed stack as it is (clippy needless_borrow)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:27:53 +01:00
Horizon agentandClaude Opus 5.5 997007191a Banner layers: leave out only the layer the codec rejects
TagValueInput.read returns the codec error's partial value, and DFU's list
codec keeps the valid elements as that partial (checked against
datafixerupper 10.0.21): vanilla keeps the other layers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:21:03 +01:00
MSedgeMC 9790d9d81c Merge remote-tracking branch 'upstream/main' into fix/weapon-damage-component 2026-11-09 18:02:49 +00:00
MSedgeMC ae0ab270ff Merge remote-tracking branch 'upstream/main' into fix/weapon-damage-component 2026-11-09 17:59:18 +00:00
MSedgeMC e865160f46 Merge remote-tracking branch 'upstream/main' into fix/weapon-damage-component 2026-11-09 17:53:42 +00:00
rdbtCVSandClaude Sonnet 5.5 c7a360b9bb Merge main into fix/h194-item-count-predicate-2
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 17:28:42 +00:00
MSedgeMC 9e68efc05b port_map: the destroy-progress stage and the daylight ticker rows are ports
`guest_dig_feedback` sends `(int)(progress * 10)`, the stage
`incrementDestroyProgress` computes, and `tick_daylight_detector` gates on
the dimension type's sky light, so the End is excluded like the Nether.
Both rows still described the old gaps.
2026-11-09 17:19:46 +00:00
rdbtCVSandClaude Sonnet 5.5 b8ceaa3ed4 Merge main into fix/h211-shulker-drop-lock-2
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 17:09:21 +00:00
MSedgeMC 02a8a61de4 Weapon wear comes from a stack's weapon component
ItemStack.postHurtEnemy uses Weapon.itemDamagePerAttack from the stack's
component. Horizon read the item prototype's weapon damage, so a patched
weapon wore 0 or the prototype's amount instead of its own.

Test: a diamond sword with item_damage_per_attack 3 wears 3 per hit.
2026-11-09 16:00:01 +00:00
MSedgeMC 6a68afa6cc A banner with an invalid layer loads no patterns
BannerPatternLayers.CODEC reads its layer list all or nothing: one layer the
codec rejects leaves the empty layer list. Horizon skipped only the bad layer,
so a banner kept the layers before it.
2026-11-09 15:32:06 +00:00
Horizon agent 3c5c7d6077 Merge remote-tracking branch 'origin/main' into fix-11484
# Conflicts:
#	crates/horizon-client/src/game/nether_portal.rs
2026-11-09 16:31:19 +01:00
Horizon agent d93a9a4be4 Merge remote-tracking branch 'origin/main' into fix-11536 2026-11-09 16:21:50 +01:00
Horizon agentandClaude Opus 5.5 2b6aed3dc7 Leave the other harnesses' work folders as on main
A run- folder per invocation under .local/<harness>/ is never removed by runner-gc (it only
looks one level down), and these four harnesses aren't this fix's. Main's harness fixes own them.

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:14:35 +01:00
Horizon agent 6db95ffc1e Merge remote-tracking branch 'origin/main' into fix-11536 2026-11-09 16:14:15 +01:00
Horizon agentandClaude Opus 5.5 cef8682971 Drop the item count fixture nothing runs
Its name matches no harness pattern; test_command_exec_parity.py --only
"item count predicate" already compares the group with vanilla.

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 13:35:18 +01:00
Florian Ernst c2e4f28c3f Start CI again: the previous run stayed queued with no jobs 2026-11-09 13:25:01 +01:00
Florian Ernst 8e26753cda Start CI again: the previous run stayed queued with no jobs 2026-11-09 13:24:59 +01:00
voidsip edfe18495e Keep reputation damage and visibility helpers in focused modules 2026-11-09 05:35:03 -06:00
voidsip 8ede2ff197 Keep potion sound regression in its own module 2026-11-09 05:22:24 -06:00
voidsip f1696089ef Merge commit 'b25a050be818408360d7cf97956350212dda0686' into fix/11329-villager-reputation-events
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
#	crates/horizon-client/src/game/native_hooks.rs
2026-11-09 05:20:13 -06:00
voidsip a4925a7954 Merge commit 'b25a050be818408360d7cf97956350212dda0686' into fix/11336-potion-contents 2026-11-09 05:19:15 -06:00
voidsip 085b4109e3 Merge commit '3690aa6119b9235be7e3b79680c156da8979479b' into fix/11336-potion-contents 2026-11-09 05:19:05 -06:00
voidsip 4a6942b8e9 Merge commit '3690aa6119b9235be7e3b79680c156da8979479b' into fix/11329-villager-reputation-events 2026-11-09 05:18:51 -06:00
Florian ErnstandClaude Opus 5.5 1efd1c20c9 Keep game.rs from growing: Pending::record_from reads name and lock
The file-size check (docs/layers.md#file-size) does not let a file over
3000 lines grow. Breaking a block entity and dropping its resources both
read the custom name and lock from the world, so a helper does it.

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 10:52:21 +01:00
Florian ErnstandClaude Opus 5.5 e2f472508d Preserve external locks on destroyed shulker boxes
Squashed and rebased onto main from #11627.

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 10:01:28 +01:00
LokiandAntigravity 6c199da5f6 sculk: resume vibration particle on reload (fixes #12935)
Co-Authored-By: Antigravity <[email protected]>
2026-11-09 17:46:05 +09:00
voidsip 42b04e0238 Update reputation damage and guest fixtures for main 2026-11-08 20:23:13 -06:00
voidsip b37b703f1c Run portal cooldown proof in a scratch game directory 2026-11-08 19:19:22 -06:00
voidsip 94cad99398 Merge commit 'f858ee752f1c0e76b6d200ec846645532536aded' into fix/11336-potion-contents 2026-11-08 18:41:12 -06:00
voidsip d7dc5628e2 Merge commit 'f858ee752f1c0e76b6d200ec846645532536aded' into fix/11329-villager-reputation-events 2026-11-08 18:38:26 -06:00
voidsip 8b45d15486 Merge commit 'f858ee752f1c0e76b6d200ec846645532536aded' into fix/11318-portal-cooldowns 2026-11-08 18:38:26 -06:00
voidsip e8f93ffc67 Merge main into villager reputation fix 2026-11-08 18:38:25 -06:00
voidsip 2e0f86f312 Merge main and preserve entity portal cooldown phases 2026-11-08 18:38:25 -06:00
voidsip 167caf28d0 Merge commit '976346aff5bfa09c6d8e364c347bdf1791618a98' into fix/11336-potion-contents 2026-11-08 18:13:04 -06:00
Riccardo GuerrieroandClaude Opus 5.5 2485e58daa Merge origin/main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:50:33 +01:00
voidsip 36bad2c06e Give parity harnesses fresh default capture directories 2026-11-07 20:35:20 -06:00
voidsip 7c7bca2e7b Update potion fixture for silent impact argument 2026-11-07 20:35:19 -06:00
voidsip 58552b8cb9 Merge commit 'bc6016d19d4dd6f11f6b954fc3b121546f069ffb' into fix/11336-potion-contents 2026-11-07 20:25:35 -06:00
b064c12aca Let sculk hear item frame changes
Post BLOCK_CHANGE at the frame from ItemFrame.interact, hurtServer and
dropItem, after the changes and drops vanilla performs first. Preserve
BlockAttachedEntity.kill ordering and discard unsupported frames without
a spurious ENTITY_DIE.

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:01:48 +01:00
voidsip 65d35f3261 Initialize configuration history for reputation test guests 2026-11-06 22:02:27 -06:00
voidsip b4dea2a473 Merge commit '77936bf4bee3ec7d641f54b97a3da09f9b72e213' into fix/11329-villager-reputation-events 2026-11-06 21:52:56 -06:00
voidsip bfa20f0413 Initialize upstream native command fields in reputation fixture 2026-11-06 21:31:46 -06:00
voidsip 05c7788a8f Merge commit '41944ed91474c6af28572d2e2273b6b2bd0bcf1e' into fix/11329-villager-reputation-events 2026-11-06 21:22:00 -06:00
voidsip 35ab895667 Validate villager reputation with isolated game and vanilla fixtures 2026-11-06 19:47:29 -06:00
voidsip 178a2bf8b4 Correct isolated potion fixture admission and default invocation 2026-11-06 18:36:13 -06:00
voidsip 673a2515e8 Add stock-compatible accepted-hurt regression fixture 2026-11-06 17:55:03 -06:00
voidsip 93d57cf58c Validate potion components with isolated vanilla methods 2026-11-06 17:52:39 -06:00
voidsip 4f935ae226 Use the standard prepared resource path in the focused harness 2026-11-06 17:28:28 -06:00
voidsip 8eba763b33 Keep legacy cooldown fixture type explicit 2026-11-06 17:14:40 -06:00
voidsip 85029c85e5 Verify portal counters without starting a server 2026-11-06 17:14:13 -06:00
voidsip e08699893a Merge current main into villager reputation fix 2026-11-06 16:15:57 -06:00
voidsip b714d73e46 Deliver villager reputation from accepted damage sources 2026-11-06 16:13:29 -06:00
voidsip 72cd6e42ee Merge commit '9d11d0c710705ee0901c2186d78641048a6660b0' into fix/11336-potion-contents 2026-11-06 16:13:16 -06:00
voidsip 9baf83e129 Fix thrown potion stack components and arrow expiration 2026-11-06 16:13:02 -06:00
voidsip 2d04344265 Merge commit '9d11d0c710705ee0901c2186d78641048a6660b0' into fix/11318-portal-cooldowns 2026-11-06 16:12:27 -06:00
voidsip 226e365523 Use entity-specific portal cooldowns and preserve live counters 2026-11-06 16:12:12 -06:00
voidsip bcc1264dff Share identity-aware merchant price refresh helpers 2026-11-06 15:33:49 -06:00
239 changed files with 12001 additions and 1240 deletions

No files matched your search

+7
View File
@@ -20,6 +20,13 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
is the GitHub account the PR is opened from (`gh api user --jq .login`). Issues say "Filed by", reviews
"Review by". Every comment starts with `🤖 Agent:`. Write only as the person you work for: the lines on
this page that name Ludovic are for his own agents (account `ludovic111`), never for anyone else's.
- **Review only when asked, and decide.** Review a pull request only when its author or a maintainer asks.
Asked by a maintainer to review open pull requests in general, take them one at a time, oldest first, skipping
drafts, your person's own and those they already reviewed. Check that the change does what it says (bugs, edge
cases), what it could break, whether tests cover it, and whether its scope and description hold up. Report only
what you can point to with a `file:line`, each point marked Blocker (fix before merge), Important, Nit,
Suggestion, Question or Praise. Submit a decision, never a plain comment: `gh pr review --approve` when nothing
blocks, `--request-changes` when something does.
- **Attribution.** Commits end with a `Co-Authored-By:` line for the agent that wrote them, and PR bodies
with that agent's own "Generated with" line. Example, Ludovic's agents (Claude Code on `ludovic111`):
first line `> 🤖 Opened by Ludovic's AI agent (Claude), not by Ludovic himself.`, commits
+26
View File
@@ -358,3 +358,29 @@ state of Starling's `crates/horizon-client-render/src/touch.rs` at the same comm
`crates/horizon-client-render/src/android_runtime.rs` and `android_runtime/tests/` are adapted from the
input part of Starling's `crates/horizon-client-render/src/android_runtime.rs` at the same commit.
## PCG3D hash
`hash3` in `crates/horizon-client-render/src/hdr.wgsl`, the noise HDR10 output's dither is made from, is the
`pcg3d` hash of Mark Jarzynski and Marc Olano ("Hash Functions for GPU Rendering", Journal of Computer Graphics
Techniques 9(3), 2020), from <https://github.com/markjarzynski/PCG3D>. Copyright 2020 Mark Jarzynski & Marc Olano,
licensed under MIT:
```
Copyright 2020 Mark Jarzynski & Marc Olano
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction,
including without limitation the rights to use, copy, modify, merge, publish, distribute,
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES
OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
```
+172 -37
View File
@@ -74,11 +74,70 @@ pub trait ResourceSource {
vec!["minecraft".into()]
}
}
/// The bundled file a language reads, and whether it drops `ß`: a regional variant reads its
/// language's file (Swiss German writes "ss" for "ß", as vanilla's de_ch does).
fn bundled_code(code: &str) -> (&str, bool) {
match code {
"ko" => ("ko_kr", false),
"hr" => ("hr_hr", false),
"de_at" => ("de_de", false),
"de_ch" => ("de_de", true),
"fr_ca" | "fr_ch" => ("fr_fr", false),
"es_ar" | "es_cl" | "es_ec" | "es_mx" | "es_uy" | "es_ve" => ("es_es", false),
code => (code, false),
}
}
/// Lang files crates above the engine ship (`horizon_hooks::lang`), each a list of
/// (code, JSON) pairs. The game's entry installs them once, before any language loads.
static CRATE_FILES: std::sync::OnceLock<&'static [&'static [(&'static str, &'static str)]]> =
std::sync::OnceLock::new();
/// Installs the crates' lang files (`horizon_hooks::lang::engine::all()`). Only the first call
/// counts.
pub fn install_crate_files(files: &'static [&'static [(&'static str, &'static str)]]) {
let _ = CRATE_FILES.set(files);
}
/// The crates' entries for language `code` (through [`bundled_code`]'s variants) that the
/// engine's own `horizon` file for it doesn't have: on a key both have, the engine wins.
fn read_crate_files(files: &[&[(&str, &str)]], code: &str, map: &mut HashMap<String, String>) {
if files.is_empty() {
return;
}
let (file, swiss) = bundled_code(code);
let mut own = HashMap::new();
if let Some(bytes) = Bundled.read(&format!("assets/horizon/lang/{code}.json")) {
read_lang_file(&bytes, &mut own);
}
for (c, json) in files.iter().flat_map(|f| f.iter()) {
if *c != file {
continue;
}
let mut theirs = HashMap::new();
if swiss {
read_lang_file(json.replace('ß', "ss").as_bytes(), &mut theirs);
} else {
read_lang_file(json.as_bytes(), &mut theirs);
}
for (key, value) in theirs {
if !own.contains_key(&key) {
map.insert(key, value);
}
}
}
}
/// Horizon's own strings, compiled in so they need no prepared resources.
struct Bundled;
impl ResourceSource for Bundled {
fn read(&self, path: &str) -> Option<Vec<u8>> {
let body = match path {
let code = path
.strip_prefix("assets/horizon/lang/")?
.strip_suffix(".json")?;
let (file, swiss) = bundled_code(code);
let path = format!("assets/horizon/lang/{file}.json");
let body = match path.as_str() {
"assets/horizon/lang/en_us.json" => {
include_str!("../../horizon-client/assets/horizon/lang/en_us.json")
}
@@ -103,10 +162,10 @@ impl ResourceSource for Bundled {
"assets/horizon/lang/tr_tr.json" => {
include_str!("../../horizon-client/assets/horizon/lang/tr_tr.json")
}
"assets/horizon/lang/ko_kr.json" | "assets/horizon/lang/ko.json" => {
"assets/horizon/lang/ko_kr.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ko_kr.json")
}
"assets/horizon/lang/hr_hr.json" | "assets/horizon/lang/hr.json" => {
"assets/horizon/lang/hr_hr.json" => {
include_str!("../../horizon-client/assets/horizon/lang/hr_hr.json")
}
"assets/horizon/lang/pt_pt.json" => {
@@ -331,30 +390,20 @@ impl ResourceSource for Bundled {
"assets/horizon/lang/vec_it.json" => {
include_str!("../../horizon-client/assets/horizon/lang/vec_it.json")
}
"assets/horizon/lang/de_de.json" | "assets/horizon/lang/de_at.json" => {
"assets/horizon/lang/de_de.json" => {
include_str!("../../horizon-client/assets/horizon/lang/de_de.json")
}
// Swiss German writes "ss" for "ß", as vanilla's de_ch does.
"assets/horizon/lang/de_ch.json" => {
let de = include_str!("../../horizon-client/assets/horizon/lang/de_de.json");
return Some(de.replace('ß', "ss").into_bytes());
}
"assets/horizon/lang/fr_fr.json"
| "assets/horizon/lang/fr_ca.json"
| "assets/horizon/lang/fr_ch.json" => {
"assets/horizon/lang/fr_fr.json" => {
include_str!("../../horizon-client/assets/horizon/lang/fr_fr.json")
}
"assets/horizon/lang/es_es.json"
| "assets/horizon/lang/es_ar.json"
| "assets/horizon/lang/es_cl.json"
| "assets/horizon/lang/es_ec.json"
| "assets/horizon/lang/es_mx.json"
| "assets/horizon/lang/es_uy.json"
| "assets/horizon/lang/es_ve.json" => {
"assets/horizon/lang/es_es.json" => {
include_str!("../../horizon-client/assets/horizon/lang/es_es.json")
}
_ => return None,
};
if swiss {
return Some(body.replace('ß', "ss").into_bytes());
}
Some(body.as_bytes().to_vec())
}
fn namespaces(&self) -> Vec<String> {
@@ -438,6 +487,16 @@ impl Language {
/// from every namespace and every source in priority order (later sources
/// override).
pub fn load(sources: &[&dyn ResourceSource], code: &str) -> Self {
let crate_files = CRATE_FILES.get().copied().unwrap_or_default();
Self::load_with(sources, code, crate_files)
}
/// [`Language::load`] with these crates' lang files.
fn load_with(
sources: &[&dyn ResourceSource],
code: &str,
crate_files: &[&[(&str, &str)]],
) -> Self {
let mut map = HashMap::new();
let mut codes = vec![DEFAULT];
if code != DEFAULT {
@@ -453,12 +512,14 @@ impl Language {
sources.iter().flat_map(|s| s.namespaces()).collect();
for c in codes {
for namespace in &namespaces {
for source in sources {
let Some(bytes) = source.read(&format!("assets/{namespace}/lang/{c}.json"))
else {
continue;
};
read_lang_file(&bytes, &mut map);
for (i, source) in sources.iter().enumerate() {
if let Some(bytes) = source.read(&format!("assets/{namespace}/lang/{c}.json")) {
read_lang_file(&bytes, &mut map);
}
// The crates' text right after the engine's own (`Bundled`, the first source).
if i == 0 && namespace == "horizon" {
read_crate_files(crate_files, c, &mut map);
}
}
}
}
@@ -688,6 +749,43 @@ mod tests {
);
}
#[test]
fn a_crates_text_loads_after_the_engines_and_before_packs() {
static CRATE: &[(&str, &str)] = &[
(
"en_us",
r#"{"test.crate.hello": "Hello", "horizon.osk.close": "Shut", "test.crate.packed": "Crate"}"#,
),
(
"de_de",
r#"{"test.crate.hello": "Grüß dich", "horizon.osk.close": "Zu"}"#,
),
("fr_fr", r#"{"test.crate.hello": "Bonjour"}"#),
];
let pack = Mem(HashMap::from([(
"assets/horizon/lang/en_us.json".to_owned(),
br#"{"test.crate.packed": "Pack"}"#.to_vec(),
)]));
let load = |code| Language::load_with(&[&pack], code, &[CRATE]);
let en = load("en_us");
assert_eq!(en.get("test.crate.hello"), Some("Hello"));
// The engine's own file wins on a key both have; a pack wins over both.
assert_eq!(en.get("horizon.osk.close"), Some("Close"));
assert_eq!(en.get("test.crate.packed"), Some("Pack"));
let de = load("de_de");
assert_eq!(de.get("test.crate.hello"), Some("Grüß dich"));
assert_eq!(de.get("horizon.osk.close"), Some("Schließen"));
// Regional variants read their language's file, as the engine's do.
assert_eq!(load("de_ch").get("test.crate.hello"), Some("Grüss dich"));
assert_eq!(load("fr_ca").get("test.crate.hello"), Some("Bonjour"));
// A language the crate lacks falls back to its English.
assert_eq!(load("pl_pl").get("test.crate.hello"), Some("Hello"));
// Without crate files, nothing changes.
let plain = Language::load_with(&[&pack], "de_de", &[]);
assert_eq!(plain.get("test.crate.hello"), None);
assert_eq!(plain.len() + 1, de.len());
}
#[test]
fn a_scoped_language_is_the_calling_threads_and_reports_what_it_lacks() {
let empty = Mem(HashMap::new());
@@ -739,6 +837,20 @@ mod tests {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../horizon-client/assets/horizon/lang")
}
/// The crates whose own lang files the game loads (`crates/<name>/assets/lang`,
/// registered through `horizon_hooks::lang`), with that folder.
fn crate_lang_dirs() -> Vec<(String, std::path::PathBuf)> {
let crates = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
let mut dirs: Vec<_> = std::fs::read_dir(&crates)
.unwrap()
.flatten()
.map(|e| (e.file_name().into_string().unwrap(), e.path()))
.map(|(name, path)| (name, path.join("assets/lang")))
.filter(|(_, dir)| dir.join("en_us.json").is_file())
.collect();
dirs.sort();
dirs
}
/// The keys of a flat bundled file in file order, duplicates included (a map
/// would keep only the last of two equal keys). One `"key": "value"` per line.
fn file_keys(body: &str) -> Vec<String> {
@@ -776,11 +888,33 @@ mod tests {
#[test]
fn bundled_files_are_served_and_have_no_stale_or_repeated_keys() {
let dir = bundled_dir();
let engine = check_lang_dir(&bundled_dir(), true);
assert!(
engine.1 >= 7,
"found {} bundled lang files in {}",
engine.1,
bundled_dir().display()
);
// A crate's files pass the same checks. Its keys are its own: on a key the engine's
// files also have, the engine's text would win and hide the crate's.
for (name, dir) in crate_lang_dirs() {
let (en, _) = check_lang_dir(&dir, false);
for key in en.keys() {
assert!(
!engine.0.contains_key(key),
"{name}: {key} is also the engine's"
);
}
}
}
/// The checks every lang folder passes: flat maps, no repeated key, no key en_us lacks, the
/// same placeholders as English, no empty text. Its en_us and how many files it has.
fn check_lang_dir(dir: &std::path::Path, bundled: bool) -> (HashMap<String, String>, usize) {
let en_body = std::fs::read_to_string(dir.join("en_us.json")).unwrap();
let en: HashMap<String, String> = serde_json::from_str(&en_body).unwrap();
let mut seen = 0;
for entry in std::fs::read_dir(&dir).unwrap().flatten() {
for entry in std::fs::read_dir(dir).unwrap().flatten() {
let name = entry.file_name().into_string().unwrap();
let Some(code) = name.strip_suffix(".json") else {
continue;
@@ -789,7 +923,7 @@ mod tests {
// A file that Bundled::read does not name is never loaded.
let path = format!("assets/horizon/lang/{name}");
assert!(
Bundled.read(&path).is_some(),
!bundled || Bundled.read(&path).is_some(),
"{name} is not served by Bundled"
);
let body = std::fs::read_to_string(entry.path()).unwrap();
@@ -815,11 +949,7 @@ mod tests {
assert!(!value.trim().is_empty(), "{code}: {key} is empty");
}
}
assert!(
seen >= 7,
"found {seen} bundled lang files in {}",
dir.display()
);
(en, seen)
}
#[test]
@@ -834,10 +964,10 @@ mod tests {
}
}
}
let en: HashMap<String, String> = serde_json::from_str(
&std::fs::read_to_string(bundled_dir().join("en_us.json")).unwrap(),
)
.unwrap();
let english = |dir: &std::path::Path| -> HashMap<String, String> {
serde_json::from_str(&std::fs::read_to_string(dir.join("en_us.json")).unwrap()).unwrap()
};
let mut en = english(&bundled_dir());
let crates = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
let mut files = Vec::new();
for krate in [
@@ -847,6 +977,11 @@ mod tests {
] {
rust_files(&crates.join(krate).join("src"), &mut files);
}
// A crate that ships lang files names its keys in its own sources.
for (name, dir) in crate_lang_dirs() {
en.extend(english(&dir));
rust_files(&crates.join(name).join("src"), &mut files);
}
assert!(files.len() > 100, "found only {} sources", files.len());
let mut found = 0;
for file in files {
+61 -1
View File
@@ -7,7 +7,7 @@ use serde_json::Value;
use std::{
collections::{BTreeMap, HashMap},
path::Path,
sync::Arc,
sync::{Arc, OnceLock},
};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -398,7 +398,37 @@ impl Biomes {
pub fn contains(&self, name: &str) -> bool {
self.index.contains_key(name)
}
/// The biome at `id`, clamped to the last entry when `id` is past the
/// table (a stale column from an older registry keeps its id).
///
/// When the table is empty (a resource pack with no biome files), returns
/// a shared default plains biome instead of panicking on `list.len() - 1`.
pub fn get(&self, id: u16) -> &BiomeInfo {
if self.list.is_empty() {
static DEFAULT: OnceLock<BiomeInfo> = OnceLock::new();
return DEFAULT.get_or_init(|| BiomeInfo {
spawns: HashMap::new(),
spawn_costs: HashMap::new(),
creature_probability: 0.0,
increased_fire_burnout: false,
name: "minecraft:plains".to_owned(),
temperature: 0.8,
frozen: false,
downfall: 0.4,
precipitation: false,
grass: 0x7f_c0_55,
modifier: GrassModifier::None,
foliage: 0x6a_ab_37,
dry_foliage: 0x79_75_54,
water: 0x3f_76_e4,
sky: None,
fog: None,
water_fog: 0x3f_76_e4,
ambient_particles: Vec::new(),
features: Vec::new(),
attributes: Default::default(),
});
}
&self.list[usize::from(id).min(self.list.len() - 1)]
}
/// The name of biome `id`, plains (as for an unknown name) when the id is
@@ -795,4 +825,34 @@ mod blend_tests {
}
assert!(at(-2.0) > 0.0 && at(-2.0) < 0.5);
}
/// An empty biome table (a resource pack with no biome files) must not
/// panic on `get`: before the fix, `list.len() - 1` underflowed.
#[test]
fn get_on_empty_biome_table_returns_default_instead_of_panicking() {
use std::sync::Arc;
let empty = Biomes {
builtin: Arc::new(Vec::new()),
index: HashMap::new(),
list: Vec::new(),
info_noise: SimplexNoise::with_skipped_offsets(&mut LegacyRandom::new(2345), true),
colormaps: Arc::new(Colormaps {
grass: Vec::new(),
foliage: Vec::new(),
dry: Vec::new(),
}),
packs: BTreeMap::new(),
};
// Any id returns the shared default plains biome, no panic.
let b = empty.get(0);
assert_eq!(b.name, "minecraft:plains");
let b = empty.get(65535);
assert_eq!(b.name, "minecraft:plains");
// Colours and temperature come from the default.
assert_eq!(b.temperature, 0.8);
assert_eq!(b.grass, 0x7f_c0_55);
assert_eq!(b.foliage, 0x6a_ab_37);
assert_eq!(b.water, 0x3f_76_e4);
}
}
+100
View File
@@ -84,6 +84,71 @@ pub fn contents_effects(stack: &ItemStack) -> Vec<(&'static str, i32, i32)> {
)
.collect()
}
/// `PotionContents.getAllEffects`, retaining each custom instance's flags.
pub fn contents_instances(stack: &ItemStack) -> Vec<crate::effects::EffectInstance> {
use horizon_client_core::nbt_value::{number, text};
use horizon_world::nbt::Tag;
let mut effects: Vec<_> = stack
.potion()
.and_then(potion)
.map_or(&[][..], |p| p.effects)
.iter()
.map(|(id, duration, amp)| crate::effects::EffectInstance::new(id, *duration, *amp))
.collect();
if let Some((_, Tag::List(custom))) = stack
.data()
.and_then(|d| d.potion_extra.iter().find(|(k, _)| k == "custom_effects"))
{
for e in custom {
let Some(def) = text(e.get("id")).and_then(|id| effect(&id)) else {
continue;
};
let mut instance = crate::effects::EffectInstance::new(
def.id,
number(e.get("duration")).unwrap_or(0) as i32,
number(e.get("amplifier")).unwrap_or(0).clamp(0, 255) as i32,
);
instance.ambient = number(e.get("ambient")).unwrap_or(0) != 0;
instance.visible = number(e.get("show_particles")) != Some(0);
effects.push(instance);
}
}
effects
}
/// `PotionContents.getColor`: an explicit colour wins; otherwise mix only
/// visible base and custom effects, with their amplifier weights.
pub fn contents_color(stack: &ItemStack) -> u32 {
use horizon_client_core::nbt_value::number;
if let Some(color) = stack
.data()
.and_then(|d| d.potion_extra.iter().find(|(k, _)| k == "custom_color"))
.and_then(|(_, v)| number(Some(v)))
{
return color as u32;
}
0xFF00_0000
| horizon_client_core::potions::effects_color(
contents_instances(stack)
.iter()
.filter(|e| e.visible)
.map(|e| (e.id, e.amplifier)),
)
}
/// `PotionContents.equals(EMPTY)`: colour and custom name also make contents
/// nonempty, even when there is no registered potion or custom effect.
pub fn contents_empty(stack: &ItemStack) -> bool {
use horizon_world::nbt::Tag;
stack.potion().is_none()
&& stack.data().is_none_or(|d| {
d.potion_extra
.iter()
.all(|(key, value)| match key.as_str() {
"custom_effects" => matches!(value, Tag::List(list) if list.is_empty()),
"custom_color" | "custom_name" => false,
_ => true,
})
})
}
/// Roman amplifier suffix (`potion.potency.N`) and duration lines of a potion.
pub fn tooltip(
potion: Option<&str>,
@@ -706,4 +771,39 @@ mod tests {
let lines = effect_lines([("minecraft:resistance", 15, 0)], 1.0, &translate);
assert_eq!(lines[0].0, "resistance");
}
#[test]
fn custom_contents_color_and_empty_semantics() {
use horizon_world::nbt::Tag;
let mut stack = ItemStack::new("minecraft:tipped_arrow", 1);
assert!(contents_empty(&stack));
stack
.data_mut()
.potion_extra
.push(("custom_effects".into(), Tag::List(Vec::new())));
assert!(contents_empty(&stack));
stack.data_mut().potion_extra = vec![(
"custom_effects".into(),
Tag::List(vec![
Tag::compound([
("id", Tag::String("minecraft:glowing".into())),
("amplifier", Tag::Int(1)),
]),
Tag::compound([
("id", Tag::String("minecraft:poison".into())),
("show_particles", Tag::Byte(0)),
]),
]),
)];
assert!(!contents_empty(&stack));
// Glowing's colour; hidden poison contributes no weight.
assert_eq!(contents_color(&stack), 0xFF94_A061);
stack
.data_mut()
.potion_extra
.push(("custom_color".into(), Tag::Int(123456)));
assert_eq!(contents_color(&stack), 123456);
stack.data_mut().potion_extra = vec![("custom_name".into(), Tag::String("test".into()))];
assert!(!contents_empty(&stack));
assert_eq!(contents_color(&stack), 0xFF38_5DC6);
}
}
@@ -539,6 +539,7 @@ pub struct Data {
pub current: Option<Info>,
pub travel: i32,
pub selector: Selector,
pub reload_particle: bool,
}
/// Which sculk block entity this is.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -1,6 +1,7 @@
//! Data packs (`<world>/datapacks`: folders and zips with `pack.mcmeta`) and
//! the parts of them commands use: `.mcfunction` functions (with macros and
//! line continuation), function tags and predicates.
mod slot_sources;
use crate::zip::Zip;
use horizon_world::{
nbt::Tag,
@@ -663,6 +664,8 @@ pub struct DataStore {
pub item_modifiers: BTreeMap<String, serde_json::Value>,
/// `slot_source` definitions, evaluated against a command's live accessor.
pub slot_sources: BTreeMap<String, serde_json::Value>,
/// Slot-source tags belong to the same loaded snapshot as their definitions.
pub slot_source_tags: BTreeMap<String, Vec<(String, bool)>>,
/// The content tables' raw files from the packs after the built-in one:
/// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `dialog/`, `damage_type/`, `data_maps/`, `enchantment/` and `tags/…/` files, by
/// `<kind-dir>/<ns>:<path>`, each id's files in pack order. The tables
@@ -759,6 +762,16 @@ impl DataStore {
&& let Ok(v) = serde_json::from_slice(&bytes)
{
s.item_modifiers.insert(id, v);
} else if let Some(id) = resource_id(&f, "tags/slot_source", ".json")
&& let Some(bytes) = pack.read(&f)
&& let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes)
{
let entries = tag_entries(&v);
let slot = s.slot_source_tags.entry(id).or_default();
if v["replace"].as_bool() == Some(true) {
slot.clear();
}
slot.extend(entries);
} else if let Some(id) = resource_id(&f, "slot_source", ".json")
&& let Some(bytes) = pack.read(&f)
&& let Ok(v) = serde_json::from_slice(&bytes)
@@ -0,0 +1,86 @@
//! Slot-source holder-set tags share DataStore's pack ordering and snapshot.
use super::DataStore;
use std::collections::BTreeSet;
impl DataStore {
/// TagLoader's required-member validation and first-seen membership order.
pub fn slot_source_tag(&self, id: &str) -> Result<Vec<String>, String> {
fn visit(
store: &DataStore,
id: &str,
active: &mut BTreeSet<String>,
) -> Result<Vec<String>, String> {
if !active.insert(id.to_owned()) {
return Err(format!("Recursive slot-source tag #{id}"));
}
let result = (|| {
let entries = store
.slot_source_tags
.get(id)
.ok_or_else(|| format!("Unknown slot-source tag #{id}"))?;
let mut out = Vec::new();
for (entry, required) in entries {
let values = if let Some(tag) = entry.strip_prefix('#') {
visit(store, tag, active)
} else if store.slot_sources.contains_key(entry) {
Ok(vec![entry.clone()])
} else {
Err(format!("Unknown slot source {entry}"))
};
match values {
Ok(values) => {
for value in values {
if !out.contains(&value) {
out.push(value);
}
}
}
Err(error) if *required => return Err(error),
Err(_) => {}
}
}
Ok(out)
})();
active.remove(id);
result
}
visit(self, id, &mut BTreeSet::new())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn holder_set_tags_keep_order_required_members_and_snapshot_authority() {
let mut store = DataStore::default();
for name in ["t:a", "t:b"] {
store.slot_sources.insert(
name.into(),
serde_json::json!({"type":"slot_range","slots":"container.0"}),
);
}
store.slot_source_tags.insert(
"t:child".into(),
vec![("t:b".into(), true), ("t:a".into(), true)],
);
store.slot_source_tags.insert(
"t:all".into(),
vec![
("t:a".into(), true),
("#t:child".into(), true),
("t:absent".into(), false),
],
);
assert_eq!(store.slot_source_tag("t:all").unwrap(), ["t:a", "t:b"]);
store
.slot_source_tags
.get_mut("t:all")
.unwrap()
.push(("t:absent".into(), true));
assert!(store.slot_source_tag("t:all").is_err());
store
.slot_source_tags
.insert("t:cycle".into(), vec![("#t:cycle".into(), true)]);
assert!(store.slot_source_tag("t:cycle").is_err());
}
}
@@ -0,0 +1,42 @@
//! A bind group's validity depends on the identity of its buffers and the
//! bound slice, not on the bytes written into those buffers.
pub(super) struct BindingKey<B, const N: usize> {
pub first: u64,
pub count: u64,
pub buffers: [B; N],
}
impl<B: PartialEq, const N: usize> BindingKey<B, N> {
pub fn matches(&self, first: u64, count: u64, buffers: [&B; N]) -> bool {
self.first == first
&& self.count == count
&& self
.buffers
.iter()
.zip(buffers)
.all(|(old, new)| old == new)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn changes_to_any_resource_or_range_invalidate_the_binding() {
let key = BindingKey {
first: 16,
count: 8,
buffers: [1u64, 2, 3, 4],
};
assert!(key.matches(16, 8, [&1, &2, &3, &4]));
assert!(!key.matches(0, 8, [&1, &2, &3, &4]));
assert!(!key.matches(16, 9, [&1, &2, &3, &4]));
for i in 0..4 {
let mut resources = [1u64, 2, 3, 4];
resources[i] += 100;
assert!(!key.matches(16, 8, resources.each_ref()));
}
}
}
+99 -45
View File
@@ -19,7 +19,10 @@
//! start, and the run is drawn indirectly with the count the copy reached.
use super::SolidPipeline;
use super::arena::GRAIN;
use smallvec::SmallVec;
#[path = "binding_cache.rs"]
mod binding_cache;
use binding_cache::BindingKey;
/// One section draw for `flatten.wgsl`: index count, first sorted index (or
/// `QUADS` for a solid draw; phase 2: its entry in phase 2's draws), base
@@ -85,6 +88,11 @@ pub struct Call {
pub count: u32,
}
struct CachedGroup<const N: usize> {
key: BindingKey<wgpu::Buffer, N>,
group: wgpu::BindGroup,
}
pub struct Flat {
/// The solid draws in one pass (`fs_solid`), where the GPU doesn't hide pixels
/// by tiles (`split_solid` in `Renderer::draw_frame`: elsewhere than Metal).
@@ -109,8 +117,9 @@ pub struct Flat {
phase2_call_data: Vec<[u32; 5]>,
/// GPU culling's phase 2 uses indirect draws; flat draws without culling do not.
indirect_execution: bool,
/// The copy of phase 2's draws, and its ranges.
phase2_group: Option<(wgpu::BindGroup, u32)>,
/// Bindings reused while the range and all GPU buffers are unchanged.
phase2_group: Option<CachedGroup<4>>,
groups: Vec<CachedGroup<3>>,
/// The layout of a page's bind group 1 (shader packs' terrain pipelines share it).
pub(super) draw_layout: wgpu::BindGroupLayout,
blocks_per_page: u64,
@@ -515,6 +524,7 @@ impl Flat {
phase2_call_data: Vec::new(),
indirect_execution,
phase2_group: None,
groups: Vec::new(),
draw_layout,
blocks_per_page: page_vertices.div_ceil(GRAIN),
translucent_blocks_per_page: page_vertices.div_ceil(GRAIN),
@@ -626,6 +636,8 @@ impl Flat {
}
pub fn clear(&mut self) {
self.pages.clear();
self.groups.clear();
self.phase2_group = None;
self.free_ids.clear();
self.next_id = 0;
}
@@ -665,7 +677,6 @@ impl Flat {
sorted_page: impl Fn(u32) -> wgpu::Buffer,
timing: Option<&mut super::pack_timing::PassTiming>,
) -> bool {
self.phase2_group = None;
let phase2 = phase2.filter(|_| self.indirect_execution);
let phase1_draws = if phase2.is_some() {
phase1_draws
@@ -729,6 +740,9 @@ impl Flat {
self.shadow_calls = shadow_calls;
self.pass_calls = pass_calls;
if total == 0 || total * 4 > device.limits().max_storage_buffer_binding_size {
// Do not retain bindings for a frame that cannot use flat draws.
self.phase2_group = None;
self.groups.clear();
self.calls.clear();
self.phase1_calls = 0;
self.solid_calls = 0;
@@ -802,39 +816,63 @@ impl Flat {
phase2,
self.segments.iter().find(|(s, ..)| *s == Source::Seen),
) {
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Flatten phase 2 section draws"),
layout: &self.seen_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: ranges_at(first, count),
let buffers = [&self.ranges, &self.indices, phase2, &self.phase2_calls];
if !self
.phase2_group
.as_ref()
.is_some_and(|cached| cached.key.matches(first, count, buffers))
{
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Flatten phase 2 section draws"),
layout: &self.seen_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: ranges_at(first, count),
},
wgpu::BindGroupEntry {
binding: 2,
resource: self.indices.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 3,
resource: phase2.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 4,
resource: self.phase2_calls.as_entire_binding(),
},
],
});
self.phase2_group = Some(CachedGroup {
key: BindingKey {
first,
count,
buffers: buffers.map(|b| b.clone()),
},
wgpu::BindGroupEntry {
binding: 2,
resource: self.indices.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 3,
resource: phase2.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 4,
resource: self.phase2_calls.as_entire_binding(),
},
],
});
self.phase2_group = Some((group, count as u32));
group,
});
}
} else {
self.phase2_group = None;
}
let groups: SmallVec<[(wgpu::BindGroup, u32); 8]> = self
.segments
.iter()
.filter_map(|&(source, first, count)| {
let sorted = match source {
Source::Quads => None,
Source::Seen => return None,
Source::Sorted(page) => Some(sorted_page(page)),
};
let mut groups = 0;
for &(source, first, count) in &self.segments {
let sorted = match source {
Source::Quads => None,
Source::Seen => continue,
Source::Sorted(page) => Some(sorted_page(page)),
};
let buffers = [
&self.ranges,
sorted.as_ref().unwrap_or(&self.empty),
&self.indices,
];
if self
.groups
.get(groups)
.is_none_or(|cached| !cached.key.matches(first, count, buffers))
{
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Flatten section draws"),
layout: &self.compute_layout,
@@ -845,7 +883,7 @@ impl Flat {
},
wgpu::BindGroupEntry {
binding: 1,
resource: sorted.as_ref().unwrap_or(&self.empty).as_entire_binding(),
resource: buffers[1].as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 2,
@@ -853,17 +891,32 @@ impl Flat {
},
],
});
Some((group, count as u32))
})
.collect();
let cached = CachedGroup {
key: BindingKey {
first,
count,
buffers: buffers.map(|b| b.clone()),
},
group,
};
if groups < self.groups.len() {
self.groups[groups] = cached;
} else {
self.groups.push(cached);
}
}
groups += 1;
}
self.groups.truncate(groups);
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("Flatten section draws"),
timestamp_writes: timing.and_then(|t| t.compute_writes("Flatten")),
});
pass.set_pipeline(&self.flatten);
for (group, count) in &groups {
pass.set_bind_group(0, group, &[]);
pass.dispatch_workgroups((*count).min(GROUP_LIMIT), count.div_ceil(GROUP_LIMIT), 1);
for cached in &self.groups {
let count = cached.key.count as u32;
pass.set_bind_group(0, &cached.group, &[]);
pass.dispatch_workgroups(count.min(GROUP_LIMIT), count.div_ceil(GROUP_LIMIT), 1);
}
drop(pass);
true
@@ -919,16 +972,17 @@ impl Flat {
encoder: &mut wgpu::CommandEncoder,
timing: Option<&mut super::pack_timing::PassTiming>,
) {
let Some((group, count)) = &self.phase2_group else {
let Some(cached) = &self.phase2_group else {
return;
};
let count = cached.key.count as u32;
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("Flatten phase 2 section draws"),
timestamp_writes: timing.and_then(|t| t.compute_writes("Flatten phase 2")),
});
pass.set_pipeline(&self.flatten_seen);
pass.set_bind_group(0, group, &[]);
pass.dispatch_workgroups((*count).min(GROUP_LIMIT), count.div_ceil(GROUP_LIMIT), 1);
pass.set_bind_group(0, &cached.group, &[]);
pass.dispatch_workgroups(count.min(GROUP_LIMIT), count.div_ceil(GROUP_LIMIT), 1);
}
/// The camera's translucent calls.
+43 -20
View File
@@ -169,7 +169,8 @@ pub struct Hdr {
hand_mark: wgpu::RenderPipeline,
params: wgpu::Buffer,
/// The same pass drawing HDR10 for a screenshot (`capture`), with its own parameters,
/// so it can differ from the swapchain's in one frame.
/// so it can differ from the swapchain's in one frame, and without the swapchain's dither:
/// a screenshot keeps the exact codes.
capture_pipeline: wgpu::RenderPipeline,
capture_params: wgpu::Buffer,
pub output: Output,
@@ -286,8 +287,9 @@ impl Hdr {
entries: &entries,
})
};
// One full-screen pass of hdr.wgsl's `entry` into `format`, reading `layout`.
let pass = |label, layout: &wgpu::BindGroupLayout, entry, format| {
// One full-screen pass of hdr.wgsl's `entry` into `format`, reading `layout`; `dither`
// for the last pass into the HDR10 swapchain (`pq_dither`).
let pass = |label, layout: &wgpu::BindGroupLayout, entry, format, dither: bool| {
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some(label),
bind_group_layouts: &[Some(layout)],
@@ -308,7 +310,10 @@ impl Hdr {
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some(entry),
compilation_options: Default::default(),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &[("pq_dither", f64::from(u8::from(dither)))],
..Default::default()
},
targets: &[Some(wgpu::ColorTargetState {
format,
blend: None,
@@ -321,7 +326,8 @@ impl Hdr {
};
let layout_bindings: &[u32] = if coverage { &[0, 3, 4, 5] } else { &[0, 1, 3] };
let display = layout("Horizon HDR bindings", layout_bindings, true);
let pipeline_for = |format| pass("Horizon HDR", &display, entry_point, format);
let pipeline_for =
|format, dither| pass("Horizon HDR", &display, entry_point, format, dither);
let params = || {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Horizon HDR parameters"),
@@ -333,7 +339,7 @@ impl Hdr {
let layered = coverage.then(|| {
let with = |label, textures: &[u32], params, entry, format| {
let layout = layout(label, textures, params);
let pipeline = pass(label, &layout, entry, format);
let pipeline = pass(label, &layout, entry, format, false);
(layout, pipeline)
};
let hud = super::hud_coverage::HUD_FORMAT;
@@ -395,8 +401,8 @@ impl Hdr {
);
Self {
logo: logo_mask(device, queue),
pipeline: pipeline_for(output.format()),
capture_pipeline: pipeline_for(Output::Pq.format()),
pipeline: pipeline_for(output.format(), output == Output::Pq),
capture_pipeline: pipeline_for(Output::Pq.format(), false),
layout: display,
clear_alpha,
hand_mark,
@@ -1709,19 +1715,32 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
super::super::hud_coverage::clear(&mut encoder, &hdr.coverage_view().unwrap());
hdr.begin(&queue, true);
hdr.present(&queue, &mut encoder, &target, true, None);
let got = texel(&device, &queue, encoder, &target);
for c in &got[..3] {
let c = f64::from(*c);
let ok = match output {
// 10-bit codes, a step either way for rounding.
Output::Pq => (c - 1023.0 * pq(want)).abs() <= 1.0,
// 80 nits a unit, in half floats.
Output::ScRgb => (c * 80.0 - want).abs() <= 2e-3 * want + 1e-4,
// HDR10's exact codes are a screenshot's copy: the swapchain's own get a
// code of dither either way.
let exact = (output == Output::Pq)
.then(|| hdr.capture(&device, &queue, &mut encoder, true).unwrap());
let presented = texel(&device, &queue, encoder, &target);
let exact = exact.map(|copy| {
let encoder = device.create_command_encoder(&Default::default());
texel(&device, &queue, encoder, &copy)
});
for (got, codes) in [(Some(presented), 2.0), (exact, 1.0)] {
let Some(got) = got else {
continue;
};
assert!(
ok,
"{output:?} black {black}, light {light}: {got:?}, not {want} nits"
);
for c in &got[..3] {
let c = f64::from(*c);
let ok = match output {
// 10-bit codes, a step either way for rounding.
Output::Pq => (c - 1023.0 * pq(want)).abs() <= codes,
// 80 nits a unit, in half floats.
Output::ScRgb => (c * 80.0 - want).abs() <= 2e-3 * want + 1e-4,
};
assert!(
ok,
"{output:?} black {black}, light {light}: {got:?}, not {want} nits"
);
}
}
}
}
@@ -2078,3 +2097,7 @@ mod lighting_tests {
#[cfg(test)]
#[path = "hdr_written_tests.rs"]
mod written_tests;
#[cfg(test)]
#[path = "hdr_dither_tests.rs"]
mod dither_tests;
@@ -0,0 +1,313 @@
//! HDR10's dither (`hdr.wgsl`, `dithered`): the last pass into the 10-bit swapchain, and what
//! must stay without it.
use super::{
Hdr, Output,
tests::{fill, gpu},
};
use wgpu::TextureFormat as F;
const SIZE: [u32; 2] = [64, 1024];
/// The ramp: grey rising by one half-float step every `ROWS` rows from `FIRST / 2048`. That is
/// three PQ codes down the picture around 91 nits: as slow as a clear sky, and as fine as the
/// float frame holds.
const FIRST: u32 = 1434;
const ROWS: u32 = 52;
/// SMPTE ST 2084: nits as a 10-bit PQ code, before rounding.
fn code(nits: f64) -> f64 {
let y = (nits / 10_000.0).powf(0.1593017578125);
1023.0 * ((0.8359375 + 18.8515625 * y) / (1.0 + 18.6875 * y)).powf(78.84375)
}
/// The ramp's row `y` as a PQ code, at the 200-nit paper white `Hdr` starts with.
fn ramp_code(y: u32) -> f64 {
code(200.0 * (f64::from(FIRST + y / ROWS) / 2048.0).powf(2.2))
}
fn ramp(device: &wgpu::Device, encoder: &mut wgpu::CommandEncoder, frame: &wgpu::Texture) {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Wgsl(
format!(
r#"
@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {{
return vec4(vec2(f32((i << 1u) & 2u), f32(i & 2u)) * 2.0 - 1.0, 0.0, 1.0);
}}
@fragment fn fs(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {{
return vec4(vec3(f32({FIRST}u + u32(pos.y) / {ROWS}u) / 2048.0), 1.0);
}}
"#
)
.into(),
),
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: None,
layout: None,
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs"),
compilation_options: Default::default(),
targets: &[Some(frame.format().into())],
}),
multiview_mask: None,
cache: None,
});
let view = frame.create_view(&Default::default());
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
color_attachments: &[Some(super::super::hud_coverage::attachment(&view))],
..Default::default()
});
pass.set_pipeline(&pipeline);
pass.draw(0..3, 0..1);
}
/// What `hdr` writes for the float frame `paint` fills, with no HUD over it: its last pass into
/// a swapchain image of its output (`Hdr::present`), or a screenshot's HDR10 copy
/// (`Hdr::capture`). Every pixel's bytes, row by row.
fn shown(
(device, queue): &(wgpu::Device, wgpu::Queue),
hdr: &mut Hdr,
screenshot: bool,
paint: impl Fn(&mut wgpu::CommandEncoder, &wgpu::Texture),
) -> Vec<u8> {
let frame = hdr.frame(device, SIZE);
let mut encoder = device.create_command_encoder(&Default::default());
paint(&mut encoder, &frame);
hdr.keep_world(&mut encoder, None);
super::super::hud_coverage::clear(&mut encoder, &hdr.coverage_view().unwrap());
let image = if screenshot {
hdr.capture(device, queue, &mut encoder, true).unwrap()
} else {
let image = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: frame.size(),
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: hdr.output.format(),
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
hdr.present(queue, &mut encoder, &image, true, None);
image
};
let row = SIZE[0] * image.format().block_copy_size(None).unwrap();
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: u64::from(row * SIZE[1]),
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
encoder.copy_texture_to_buffer(
image.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &buffer,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(row),
rows_per_image: None,
},
},
image.size(),
);
queue.submit([encoder.finish()]);
buffer.map_async(wgpu::MapMode::Read, .., |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
buffer.get_mapped_range(..).unwrap().to_vec()
}
fn float_hdr((device, queue): &(wgpu::Device, wgpu::Queue), output: Output) -> Hdr {
Hdr::with_debug(device, queue, F::Rgba16Float, output, true, None)
}
/// An HDR10 picture's 10-bit codes, red, green and blue.
fn codes(bytes: &[u8]) -> Vec<[u32; 3]> {
let pixels = bytes.as_chunks::<4>().0.iter();
pixels
.map(|p| [0, 10, 20].map(|shift| (u32::from_le_bytes(*p) >> shift) & 0x3ff))
.collect()
}
/// Down every column of a channel: the longest run of rows with one code, and the runs' mean
/// length.
fn runs(codes: &[[u32; 3]], channel: usize) -> (u32, f64) {
let (mut longest, mut count) = (0, 0);
for x in 0..SIZE[0] {
let mut run = 0;
for y in 0..SIZE[1] {
let at = |y: u32| codes[(y * SIZE[0] + x) as usize][channel];
if y > 0 && at(y) != at(y - 1) {
(longest, count, run) = (longest.max(run), count + 1, 0);
}
run += 1;
}
(longest, count) = (longest.max(run), count + 1);
}
(longest, f64::from(SIZE[0] * SIZE[1]) / f64::from(count))
}
/// Whether every row of a picture is one value from side to side: no noise in it.
fn rows_are_flat(bytes: &[u8]) -> bool {
let pixel = bytes.len() / (SIZE[0] * SIZE[1]) as usize;
bytes
.chunks(pixel * SIZE[0] as usize)
.all(|row| row.chunks(pixel).all(|p| p == &row[..pixel]))
}
#[test]
fn a_slow_gradient_gets_grain_instead_of_bands_on_hdr10() {
let Some(gpu) = gpu() else {
return;
};
let ramp = |encoder: &mut wgpu::CommandEncoder, frame: &wgpu::Texture| {
ramp(&gpu.0, encoder, frame);
};
let mut hdr = float_hdr(&gpu, Output::Pq);
// The ramp is what the test says: three codes, top to bottom.
let span = ramp_code(SIZE[1] - 1) - ramp_code(0);
assert!((2.9..3.2).contains(&span), "{span}");
// Without dither, as a screenshot's copy stays: each row its nearest code, in bands hundreds
// of rows tall. A GPU need not round at exactly half a code: an NVIDIA one rounds up from
// 0.55 here, which shows as 0.05 of a code less, so the bounds below leave a quarter code.
let plain = codes(&shown(&gpu, &mut hdr, true, ramp));
let dithered = codes(&shown(&gpu, &mut hdr, false, ramp));
let want = |i: usize| ramp_code(i as u32 / SIZE[0]);
for channel in 0..3 {
for (i, p) in plain.iter().enumerate() {
let off = (f64::from(p[channel]) - want(i)).abs();
assert!(
off <= 0.75,
"plain {channel} at {i}: {p:?}, not {}",
want(i)
);
}
let (longest, _) = runs(&plain, channel);
assert!(longest >= 250, "plain {channel}: bands of {longest} rows");
// With it: each pixel within the noise's one code of its light (and half a code of
// rounding), no column holds a code for long, and a run is a few rows on average.
for (i, d) in dithered.iter().enumerate() {
let off = (f64::from(d[channel]) - want(i)).abs();
assert!(
off <= 1.75,
"dithered {channel} at {i}: {d:?}, not {}",
want(i)
);
}
let (longest, mean) = runs(&dithered, channel);
eprintln!(
"HDR10 dither GPU, channel {channel}: longest run {longest} rows, mean {mean:.2}"
);
assert!(longest <= 64, "dithered {channel}: a run of {longest} rows");
assert!(mean < 4.0, "dithered {channel}: runs of {mean} rows");
// The mean of each 64-row block is the light meant.
let mut worst = 0.0_f64;
for block in 0..SIZE[1] / 64 {
let rows = (block * 64 * SIZE[0]) as usize..((block + 1) * 64 * SIZE[0]) as usize;
let n = rows.len() as f64;
let got = dithered[rows.clone()]
.iter()
.map(|d| f64::from(d[channel]))
.sum::<f64>()
/ n;
let meant = rows.map(want).sum::<f64>() / n;
worst = worst.max((got - meant).abs());
assert!(
(got - meant).abs() < 0.25,
"block {block} of {channel}: {got}, not {meant}"
);
}
eprintln!("HDR10 dither GPU, channel {channel}: block means within {worst:.3} of a code");
}
}
#[test]
fn hdr10s_dither_keeps_both_ends_exact_and_leans_to_them_in_their_last_code() {
let Some(gpu) = gpu() else {
return;
};
let mut hdr = float_hdr(&gpu, Output::Pq);
let grey = |g: f64| {
move |encoder: &mut wgpu::CommandEncoder, frame: &wgpu::Texture| {
fill(encoder, frame, [g, g, g, 1.0]);
}
};
// How far the picture's mean code is from `end`.
let mean_from = |picture: &[[u32; 3]], end: f64| {
let sum: f64 = picture.iter().flatten().map(|c| f64::from(*c)).sum();
(sum / (picture.len() * 3) as f64 - end).abs()
};
// Within a code of an end the noise shrinks to the distance to it. A third of a code from
// the end, the signal plus that noise passes half a code only where the noise is over half
// its range, an eighth of a triangle: so the picture's mean is an eighth of a code from the
// end, not a third. That lean to the end, in the last code alone, is the price of keeping
// the end itself exact. Bound: a GPU that rounds a tenth of a code off the half (as above)
// shows between 0.02 and 0.32 of a code, always short of the third.
let leans = |mean: f64| (0.015..0.325).contains(&mean);
// Black gets no noise: every pixel stays off.
let black = codes(&shown(&gpu, &mut hdr, false, grey(0.0)));
assert!(black.iter().all(|p| *p == [0; 3]));
// A third of a code above black (this grey is a half float): 0 or 1, never lifted past it.
let dim = 0.00038433074951171875;
let meant = code(200.0 * f64::powf(dim, 2.2));
assert!((meant - 1.0 / 3.0).abs() < 0.001, "{meant}");
let near_black = codes(&shown(&gpu, &mut hdr, false, grey(dim)));
assert!(near_black.iter().flatten().all(|c| *c <= 1));
let mean = mean_from(&near_black, 0.0);
eprintln!("HDR10 dither GPU: a third of a code above black shows as {mean:.3}");
assert!(leans(mean), "{mean} above black");
// The top of the range, PQ's 10 000 nits, stays the top: nothing wraps or dims. Light past
// the peak is held at it, so the Peak setting is the signal here.
hdr.peak = 10_000.0;
let top = codes(&shown(&gpu, &mut hdr, false, grey(20.0)));
assert!(top.iter().all(|p| *p == [1023; 3]));
// A third of a code under the top, mirrored: 1022 or 1023.
let (mut under, mut over) = (9000.0, 10_000.0);
for _ in 0..60 {
let middle = (under + over) / 2.0;
if code(middle) < 1023.0 - 1.0 / 3.0 {
under = middle;
} else {
over = middle;
}
}
hdr.peak = under as f32;
let meant = code(f64::from(hdr.peak));
assert!((1023.0 - meant - 1.0 / 3.0).abs() < 0.001, "{meant}");
let near_top = codes(&shown(&gpu, &mut hdr, false, grey(20.0)));
assert!(near_top.iter().flatten().all(|c| *c >= 1022));
let mean = mean_from(&near_top, 1023.0);
eprintln!("HDR10 dither GPU: a third of a code under the top shows as {mean:.3} under");
assert!(leans(mean), "{mean} under the top");
}
#[test]
fn scrgb_and_screenshots_get_no_dither() {
let Some(gpu) = gpu() else {
return;
};
let ramp = |encoder: &mut wgpu::CommandEncoder, frame: &wgpu::Texture| {
ramp(&gpu.0, encoder, frame);
};
let (mut hdr10, mut scrgb) = (float_hdr(&gpu, Output::Pq), float_hdr(&gpu, Output::ScRgb));
// scRGB's swapchain is float: every row is one value from side to side.
assert!(rows_are_flat(&shown(&gpu, &mut scrgb, false, ramp)));
// A screenshot's HDR10 copy is the same bytes whichever swapchain is presented, the
// dithered one included, and has no noise in it.
let screenshot = shown(&gpu, &mut hdr10, true, ramp);
assert!(rows_are_flat(&screenshot));
assert_eq!(screenshot, shown(&gpu, &mut scrgb, true, ramp));
// The swapchain's own picture differs from it: the dither is on there.
assert_ne!(screenshot, shown(&gpu, &mut hdr10, false, ramp));
}
@@ -8,7 +8,7 @@
use glam::{Mat4, Vec3};
use horizon_shader_api::{
Api, Frame as FrameInputs, StageKind, check::CheckedStage, stages::GROUP,
Api, Frame as FrameInputs, Primaries, StageKind, check::CheckedStage, stages::GROUP,
};
use super::Frame;
@@ -1596,7 +1596,11 @@ pub fn frame_inputs(
paper_white: 80.0,
peak: 80.0,
black_level: 0.0,
padding: [0; 3],
// No pack is served another space yet (`[frame] primaries`).
primaries: Primaries::Bt709 as u32,
luminance_gap: [0; 2],
luminance: Primaries::Bt709.luminance(),
padding: 0,
}
}
+45 -6
View File
@@ -173,13 +173,52 @@ fn calibration_picture(pos: vec4<f32>) -> vec4<f32> {
return vec4(nits / 80.0, 1.0);
}
// Light in units of paper white, as the swapchain takes it: HDR10 or scRGB, the black level
// lifted.
fn display(light: vec3<f32>) -> vec4<f32> {
// On for the HDR10 swapchain alone (Hdr::new): its 10 bits get dither. A screenshot's HDR10 copy
// (Hdr::capture) keeps the exact codes, and scRGB's floats need none.
override pq_dither: bool = false;
// Three even random values in [0, 1) for `at`: PCG's 3D hash (Jarzynski and Olano, "Hash Functions
// for GPU Rendering", JCGT 2020). Integers only, so it is the same on every GPU and at every
// resolution, where hashes built on `fract(sin())` repeat or streak at 4K's coordinates.
// Copyright 2020 Mark Jarzynski & Marc Olano, MIT (github.com/markjarzynski/PCG3D; the license is in
// THIRD_PARTY_NOTICES.md).
fn hash3(at: vec3<u32>) -> vec3<f32> {
var v = at * 1664525u + 1013904223u;
v.x += v.y * v.z;
v.y += v.z * v.x;
v.z += v.x * v.y;
v ^= v >> vec3(16u);
v.x += v.y * v.z;
v.y += v.z * v.x;
v.z += v.x * v.y;
return vec3<f32>(v >> vec3(8u)) / 16777216.0;
}
// The PQ signal `e` with dither for its 10 bits: triangular noise of one code either way, each
// channel's own, so a slow gradient (a clear sky is a few codes tall) shows as grain a code deep
// instead of bands, and its mean is the light meant. The noise is the pixel's alone: no frame
// counter, so a still picture stays still. Within a code of black or of the top it shrinks to
// what fits: the signal never leaves 0..1, and black stays exactly black (an OLED's pixel off).
// The price is in that last code alone: the smaller noise keeps the signal's mean but not the
// rounded one, which leans to the end (a third of a code from it shows as an eighth). Under
// 0.00004 nits at black, that is the accepted cost of a true 0.
fn dithered(e: vec3<f32>, pixel: vec2<f32>) -> vec3<f32> {
let at = vec2<u32>(pixel);
let noise = hash3(vec3(at, 0u)) + hash3(vec3(at, 1u)) - 1.0;
return e + noise * min(min(e, 1.0 - e), vec3(1.0 / 1023.0));
}
// Light in units of paper white at the pixel `pos`, as the swapchain takes it: HDR10 or scRGB,
// the black level lifted.
fn display(light: vec3<f32>, pos: vec4<f32>) -> vec4<f32> {
let scrgb = light * params.paper;
if params.pq > 0.5 {
let e = pq(BT709_TO_BT2020 * scrgb * 80.0);
return vec4(select(e, lift_black(e), params.black > 0.0), 1.0);
let lifted = select(e, lift_black(e), params.black > 0.0);
if pq_dither {
return vec4(dithered(lifted, pos.xy), 1.0);
}
return vec4(lifted, 1.0);
}
if params.black > 0.0 {
return vec4(pq_nits(lift_black(pq(scrgb * 80.0))) / 80.0, 1.0);
@@ -209,7 +248,7 @@ fn display(light: vec3<f32>) -> vec4<f32> {
// The HUD and menus at their own brightness, by how much they cover, no brighter than the
// peak (as `fs_hdr_layered`): UI Brightness can pass paper white.
light = min(light * mix(1.0, params.ui, ui), vec3(params.peak));
return display(light);
return display(light, pos);
}
// The tone curve's knee: up to it light stays as it is. Where HDR lighting eased into the peak
@@ -328,5 +367,5 @@ override screenshot_shoulder: bool = true;
if calibrating(pos) {
return calibration_picture(pos);
}
return display(min(pow(layered(vec2<i32>(pos.xy)), vec3(2.2)), vec3(params.peak)));
return display(min(pow(layered(vec2<i32>(pos.xy)), vec3(2.2)), vec3(params.peak)), pos);
}
+14 -2
View File
@@ -52,7 +52,7 @@ pub struct TextureFlags {
impl TextureFlags {
/// The `texture` section of a `.png.mcmeta` file, if it sets either flag.
pub fn from_mcmeta(json: &str) -> Option<Self> {
let meta: serde_json::Value = serde_json::from_str(json).ok()?;
let meta = crate::packs::first_json(json.as_bytes())?;
let section = meta.get("texture")?;
let flag = |key| section.get(key).and_then(serde_json::Value::as_bool) == Some(true);
let flags = Self {
@@ -387,7 +387,7 @@ fn strip_frame(pixels: &[u8], stride: u32, x: u32, y: u32, w: u32, h: u32) -> Ve
}
/// The `animation` section of a texture's `.mcmeta`.
fn animation_of(mcmeta: Option<&str>) -> Option<AnimationMetadata> {
let meta: serde_json::Value = serde_json::from_str(mcmeta?).ok()?;
let meta = crate::packs::first_json(mcmeta?.as_bytes())?;
AnimationMetadata::from_json(&meta.get("animation")?.to_string()).ok()
}
/// An animated strip's first frame: the region the mcmeta's frame size picks
@@ -3110,6 +3110,18 @@ mod coverage_tests;
mod tests {
use super::*;
#[test]
fn animated_texture_metadata_ignores_trailing_pack_signature() {
let meta = r#"{"animation":{"frametime":2,"width":2,"height":2}}
// High Contrast pack signature"#;
let animation = animation_of(Some(meta)).expect("the first JSON value is metadata");
assert_eq!(animation.frame_size(8, 4), [2, 2]);
let pixels = (0..8 * 4).flat_map(|i| [i as u8, 0, 0, 255]).collect();
let (_, height, first) = first_frame((8, 4, pixels), Some(&animation));
assert_eq!(height, 2);
assert_eq!(first.len(), 2 * 2 * 4);
}
#[test]
fn object_skin_revision_tracks_changes_and_capacity_clear() {
let mut atlas = HudAtlas::boot();
+135 -21
View File
@@ -22,6 +22,7 @@ use crate::{
use horizon_core::geometry::{Direction, ShapeOp, VoxelShape};
use horizon_world::registry::{BlockRegistry, BlockStateId};
use std::{
cell::RefCell,
collections::{HashMap, VecDeque},
path::Path,
sync::Arc,
@@ -365,6 +366,48 @@ fn give_scratch(scratch: LightScratch) {
}
}
thread_local! {
static RELIGHT_SCRATCH: RefCell<RelightScratch> = RefCell::new(RelightScratch::new());
}
struct RelightScratch {
decrease: VecDeque<(Pos, u8)>,
cleared: Vec<Pos>,
increase: VecDeque<Pos>,
sources: FxHashMap<(i32, i32), i32>,
}
impl RelightScratch {
fn new() -> Self {
Self {
decrease: VecDeque::new(),
cleared: Vec::new(),
increase: VecDeque::new(),
sources: FxHashMap::default(),
}
}
fn clear(&mut self) {
const MAX_CAPACITY: usize = 16_384;
self.decrease.clear();
if self.decrease.capacity() > MAX_CAPACITY {
self.decrease.shrink_to(MAX_CAPACITY);
}
self.cleared.clear();
if self.cleared.capacity() > MAX_CAPACITY {
self.cleared.shrink_to(MAX_CAPACITY);
}
self.increase.clear();
if self.increase.capacity() > MAX_CAPACITY {
self.increase.shrink_to(MAX_CAPACITY);
}
self.sources.clear();
if self.sources.capacity() > MAX_CAPACITY {
self.sources.shrink_to(MAX_CAPACITY);
}
}
}
/// Where a section sits in the 48-wide region.
#[derive(Clone, Copy)]
struct At {
@@ -880,12 +923,12 @@ impl<'w> Region<'w> {
Self {
layout: world.layout(),
world,
slots: Vec::new(),
slots: Vec::with_capacity(16),
index: FxHashMap::default(),
last: ((i32::MIN, 0, 0), NONE),
}
}
#[inline]
#[inline(always)]
fn slot(&mut self, x: i32, y: i32, z: i32) -> Option<usize> {
let s = self.layout.section_index(y)?;
let key = (x >> 4, s as i32, z >> 4);
@@ -1142,10 +1185,25 @@ pub fn relight(
force: bool,
changed: &mut LightChanges,
) {
let mut region = Region::new(world);
changed.keyed(region.layout);
relight_region(&mut region, tables, sky_light, positions, force, changed);
region.finish();
changed.keyed(world.layout());
if positions.is_empty() {
return;
}
RELIGHT_SCRATCH.with_borrow_mut(|scratch| {
scratch.clear();
let mut region = Region::new(world);
relight_region(
&mut region,
tables,
sky_light,
positions,
force,
scratch,
changed,
);
region.finish();
scratch.clear();
});
}
/// The columns `changed` touched that border a loaded column not lit yet
/// (`unlit`). `relight` reads and writes only lit columns, so an edit's
@@ -1170,10 +1228,11 @@ fn relight_region(
sky_light: bool,
positions: &[Pos],
force: bool,
scratch: &mut RelightScratch,
changed: &mut LightChanges,
) {
crate::spans::time("lt_block", || {
relight_layer(world, tables, false, positions.to_vec(), force, changed)
relight_layer(world, tables, false, positions, force, scratch, changed)
});
if sky_light {
let (min_y, max_y) = (world.layout.min_y(), world.layout.max_y());
@@ -1201,7 +1260,7 @@ fn relight_region(
}
});
crate::spans::time("lt_sky", || {
relight_layer(world, tables, true, seeds, force, changed)
relight_layer(world, tables, true, &seeds, force, scratch, changed)
});
}
}
@@ -1209,14 +1268,30 @@ fn relight_layer(
world: &mut Region,
tables: &LightTables,
sky: bool,
seeds: Vec<Pos>,
seeds: &[Pos],
force: bool,
scratch: &mut RelightScratch,
changed: &mut LightChanges,
) {
let mut decrease: VecDeque<(Pos, u8)> = VecDeque::new();
let mut cleared: Vec<Pos> = Vec::new();
let mut increase: VecDeque<Pos> = VecDeque::new();
for p in seeds {
scratch.decrease.clear();
scratch.cleared.clear();
scratch.increase.clear();
scratch.sources.clear();
if seeds.len() > scratch.decrease.capacity() {
scratch.decrease.reserve(seeds.len());
}
if seeds.len() > scratch.cleared.capacity() {
scratch.cleared.reserve(seeds.len());
}
if seeds.len() > scratch.increase.capacity() {
scratch.increase.reserve(seeds.len());
}
let decrease = &mut scratch.decrease;
let cleared = &mut scratch.cleared;
let increase = &mut scratch.increase;
for &p in seeds {
let Some(old) = level(world, sky, p) else {
continue;
};
@@ -1267,13 +1342,22 @@ fn relight_layer(
}
}
// Sources inside the cleared area light up again.
let mut sources: FxHashMap<(i32, i32), i32> = FxHashMap::default();
let sources = &mut scratch.sources;
let mut last_xz = (i32::MIN, i32::MIN);
let mut last_lowest = 0;
crate::spans::time("lt_sources", || {
for p in cleared {
for &p in cleared.iter() {
let seed = if sky {
let lowest = *sources
.entry((p.0, p.2))
.or_insert_with(|| lowest_sky_source(world, tables, p.0, p.2));
let lowest = if (p.0, p.2) == last_xz {
last_lowest
} else {
let lowest = *sources
.entry((p.0, p.2))
.or_insert_with(|| lowest_sky_source(world, tables, p.0, p.2));
last_xz = (p.0, p.2);
last_lowest = lowest;
lowest
};
if p.1 >= lowest { 15 } else { 0 }
} else {
world
@@ -2119,9 +2203,39 @@ pub(crate) mod tests {
relight(&mut w, &t, true, &positions, false, &mut changed);
}
println!(
"relight_bench: {:?}, {} sections",
start.elapsed(),
changed.sections().count()
"relight_bench (large batch: 400 rounds x 32 blocks): {:?}",
start.elapsed()
);
}
#[test]
#[ignore]
fn relight_single_block_bench() {
let mut w: FxHashMap<_, _> = world(|_| {}).into_iter().collect();
let t = tables();
let mut rng = 0x2545_f491_u32;
let mut next = |n: i32| {
rng ^= rng << 13;
rng ^= rng >> 17;
rng ^= rng << 5;
(rng % n as u32) as i32
};
let start = std::time::Instant::now();
let mut changed = LightChanges::default();
for round in 0..2000 {
let state = [TORCH, SLAB, AIR][round % 3];
let pos = (next(32) - 8, next(12), next(32) - 8);
Arc::make_mut(w.get_mut(&(pos.0 >> 4, pos.2 >> 4)).unwrap()).set(
pos.0 & 15,
pos.1,
pos.2 & 15,
state,
);
relight(&mut w, &t, true, &[pos], false, &mut changed);
}
println!(
"relight_single_block_bench (single block: 2000 rounds): {:?}",
start.elapsed()
);
}
@@ -466,6 +466,13 @@ fn read_meta(source: &Source) -> Option<serde_json::Value> {
/// file with a comment after the closing brace. Gson reads past a UTF-8 byte order mark;
/// serde_json rejects it.
pub(crate) fn parse_meta(bytes: &[u8]) -> Option<serde_json::Value> {
first_json(bytes)
}
/// Reads the first JSON value from resource metadata, matching Gson's
/// `JsonReader` (which ignores a trailing signature or comment). Resource
/// packs commonly append such text to texture metadata as well as pack.mcmeta.
pub(crate) fn first_json(bytes: &[u8]) -> Option<serde_json::Value> {
serde_json::Deserializer::from_slice(bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes))
.into_iter()
.next()?
+18 -34
View File
@@ -526,6 +526,8 @@ pub struct Particle {
no_render: bool,
/// Ticked once when created (`CritParticle` constructor).
pre_tick: bool,
/// A Polytone particle with physics, which joins only where its box is clear of blocks.
spawn_check: bool,
/// `Particle.random`: the particle's own generator (`RandomSource.create()`).
random: LegacyRandom,
controller: Option<Box<dyn native::Controller>>,
@@ -895,6 +897,7 @@ impl Particle {
behaviour: Behaviour::Base,
no_render: false,
pre_tick: false,
spawn_check: false,
frame_ticks: 0,
random: LegacyRandom::new(0),
controller: None,
@@ -1759,28 +1762,6 @@ impl ParticleEngine {
particle.controller = Some(Box::new(controller));
Some(particle)
}
/// Sets (or clears) the resource packs' Polytone custom particle types.
pub fn set_polytone(&mut self, registry: Option<std::sync::Arc<polytone::Registry>>) {
let registry = registry.filter(|r| !r.is_empty());
let same = match (&self.polytone, &registry) {
(Some(a), Some(b)) => std::sync::Arc::ptr_eq(a, b),
(None, None) => true,
_ => false,
};
if !same {
self.polytone_gpu.clear();
self.polytone_overrides = registry
.iter()
.flat_map(|r| {
ParticleType::ALL.iter().filter_map(|&ty| {
let id = format!("minecraft:{}", ty.name());
r.get(&id).is_some().then_some((ty, id))
})
})
.collect();
}
self.polytone = registry;
}
/// The quads the last render blends additively (`RenderPipelines` additive, src alpha and
/// one), for the frame's additive pass.
pub fn take_additive(&self) -> Vec<Vertex> {
@@ -3621,13 +3602,14 @@ impl ParticleEngine {
self.sprite_from_age(&mut q);
q
}
/// ClientLevel.createFireworks: a Starter (or poofs without explosions).
/// ClientLevel.createFireworks: a Starter (or poofs without explosions) with `playSound`.
pub fn fireworks(
&mut self,
at: [f64; 3],
v: [f64; 3],
explosions: Vec<FireworkExplosion>,
camera: [f64; 3],
play_sound: bool,
) {
if explosions.is_empty() {
for _ in 0..self.random.next_int_bound(3).unwrap_or(0) + 2 {
@@ -3651,17 +3633,17 @@ impl ParticleEngine {
life: 0,
lifetime,
twinkle_delay: twinkle,
far,
sound: play_sound.then_some(far),
});
}
fn tick_starters(&mut self) {
for mut s in std::mem::take(&mut self.starters) {
if s.life == 0 {
if s.life == 0 && s.sound.is_some() {
let large = s.explosions.len() >= 3
|| s.explosions
.iter()
.any(|e| e.shape == FireworkShape::LargeBall);
let sound = match (large, s.far) {
let sound = match (large, s.sound == Some(true)) {
(true, true) => "minecraft:entity.firework_rocket.large_blast_far",
(true, false) => "minecraft:entity.firework_rocket.large_blast",
(false, true) => "minecraft:entity.firework_rocket.blast_far",
@@ -3704,16 +3686,13 @@ impl ParticleEngine {
(c & 255) as f32 / 255.0,
((c >> 24) & 255) as f32 / 255.0,
];
self.create(
&ParticleOptions::colored(ParticleType::Flash, rgba),
s.at,
[0.0; 3],
);
let flash = ParticleOptions::colored(ParticleType::Flash, rgba);
self.create(&flash, s.at, [0.0; 3]);
}
s.life += 1;
if s.life > s.lifetime {
if s.twinkle_delay {
let sound = if s.far {
if s.twinkle_delay && s.sound.is_some() {
let sound = if s.sound == Some(true) {
"minecraft:entity.firework_rocket.twinkle_far"
} else {
"minecraft:entity.firework_rocket.twinkle"
@@ -3846,6 +3825,9 @@ impl ParticleEngine {
}
let pending = std::mem::take(&mut self.pending);
for mut p in pending {
if p.spawn_check && !polytone::unobstructed(level, p.bb) {
continue;
}
if p.pre_tick {
p.pre_tick = false;
self.tick_particle(&mut p, level);
@@ -5054,7 +5036,9 @@ struct Starter {
life: i32,
lifetime: i32,
twinkle_delay: bool,
far: bool,
/// `FireworkParticles.Starter.playSound`, with `isFarAwayFromCamera` when
/// it plays: `None` for a silent rocket.
sound: Option<bool>,
}
const STAR: [[f64; 2]; 6] = [
[0.0, 1.0],
@@ -1090,6 +1090,16 @@ const INITIALIZED: [&str; 13] = [
"dz",
];
/// Whether no block's collision shape overlaps `bb` (where the query fails, nothing does).
pub(super) fn unobstructed(level: &ParticleLevel<'_>, bb: horizon_core::geometry::Aabb) -> bool {
let mut scene = crate::collisions::SceneCollisions {
world: level.world,
binding: level.collision,
subject: None,
};
scene.cells_without_collision(bb) || scene.unobstructed(bb).unwrap_or(true)
}
impl super::ParticleController for Controller {
fn tick(&mut self, p: &mut Particle, level: &ParticleLevel<'_>) -> ParticleUpdate {
let mut update = ParticleUpdate::default();
@@ -1320,6 +1330,10 @@ pub(super) fn make(
particle.polytone_type =
(def.limit.is_some() || def.exclusion_radius > 0.0).then(|| Arc::from(name));
particle.bb = super::aabb_at(position, particle.bb_width, particle.bb_height);
// A particle with physics whose box starts inside a block's collision never joins the world:
// it is not drawn and none of its emitters run (measured on the mod: one spawned just under a
// block, where falling leaves start, never shows; one 0.01 clear of it does).
particle.spawn_check = def.has_physics;
particle.controller = Some(Box::new(Controller {
def: def.clone(),
registry: registry.clone(),
@@ -1531,6 +1545,31 @@ impl ParticleEngine {
}
}
impl ParticleEngine {
/// Sets (or clears) the resource packs' Polytone custom particle types.
pub fn set_polytone(&mut self, registry: Option<Arc<Registry>>) {
let registry = registry.filter(|r| !r.is_empty());
let same = match (&self.polytone, &registry) {
(Some(a), Some(b)) => Arc::ptr_eq(a, b),
(None, None) => true,
_ => false,
};
if !same {
self.polytone_gpu.clear();
self.polytone_overrides = registry
.iter()
.flat_map(|r| {
ParticleType::ALL.iter().filter_map(|&ty| {
let id = format!("minecraft:{}", ty.name());
r.get(&id).is_some().then_some((ty, id))
})
})
.collect();
}
self.polytone = registry;
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -2244,6 +2283,144 @@ mod tests {
std::fs::remove_dir_all(root).unwrap();
}
/// `scene`, with the torch a full block of collision: something a particle's box can start in.
fn solid_scene(blocks: &'static BlockRegistry) -> Scene {
use horizon_core::geometry::{Axis, VoxelShape};
let tag = |shape: VoxelShape| {
let sizes = shape.sizes();
let occupied = (0..sizes[2])
.flat_map(|z| {
(0..sizes[1]).flat_map(move |y| (0..sizes[0]).map(move |x| (x, y, z)))
})
.map(|(x, y, z)| u8::from(shape.is_full(x as i32, y as i32, z as i32)))
.collect();
let (min, max) = shape.discrete_bounds();
let axes = shape
.cube_coordinate_axes()
.iter()
.enumerate()
.fold(0, |mask, (i, on)| mask | (i8::from(*on) << i));
let mut fields = vec![
("occupied", Tag::ByteArray(occupied)),
("cube", Tag::Byte(i8::from(shape.is_cube()))),
("cube_axes", Tag::Byte(axes)),
("discrete_min", Tag::IntArray(min.to_vec())),
("discrete_max", Tag::IntArray(max.to_vec())),
];
for (axis, name) in [(Axis::X, "x"), (Axis::Y, "y"), (Axis::Z, "z")] {
let bits = shape.coordinates(axis).iter().map(|v| v.to_bits() as i64);
fields.push((name, Tag::LongArray(bits.collect())));
}
Tag::compound(fields)
};
let names = || Tag::List(STATES.iter().map(|s| Tag::String((*s).into())).collect());
let cached = CachedCollisionRegistry::from_nbt(
&Tag::compound([
("DataVersion", Tag::Int(DATA_VERSION)),
("states", names()),
(
"shapes",
Tag::List(vec![tag(VoxelShape::empty()), tag(VoxelShape::block())]),
),
("cached_shapes", Tag::IntArray(vec![0, 1])),
]),
blocks,
)
.unwrap();
Scene {
collision: BlockCollisionRegistry::new(Arc::new(cached)).unwrap(),
..scene(blocks)
}
}
#[test]
fn a_particle_with_physics_born_inside_a_block_never_joins() {
let blocks = blocks();
let leaf = |physics| {
serde_json::json!({"has_physics": physics, "render_type": "invisible",
"initializer": {"lifetime": "20", "hitbox_size": "0.08"},
"particle_emitters": [{"particle": "test:mark", "chance": "1"}]})
};
let root = pack(&[
(
"assets/test/polytone/custom_particles/leaf.json",
leaf(true),
),
(
"assets/test/polytone/custom_particles/ghost.json",
leaf(false),
),
(
"assets/test/polytone/custom_particles/mark.json",
serde_json::json!({"render_type": "invisible", "has_physics": false}),
),
(
"assets/minecraft/polytone/entity_modifiers/player.json",
serde_json::json!({"emitters": [{"particle": "test:leaf", "chance": "1"}]}),
),
(
"assets/test/polytone/custom_particles/wide.json",
serde_json::json!({"has_physics": true, "render_type": "invisible",
"initializer": {"lifetime": "20", "hitbox_size": 10},
"particle_emitters": [{"particle": "test:mark", "chance": "1"}]}),
),
(
"assets/test/polytone/custom_particles/parent.json",
serde_json::json!({"has_physics": true, "render_type": "invisible",
"initializer": {"lifetime": "20", "hitbox_size": "0.08"},
"particle_emitters": [{"particle": "test:wide", "chance": "1", "y": "-0.05"}]}),
),
(
"assets/minecraft/polytone/entity_modifiers/skeleton.json",
serde_json::json!({"emitters": [{"particle": "test:parent", "chance": "1", "x": "-4.5"}]}),
),
(
"assets/minecraft/polytone/entity_modifiers/creeper.json",
serde_json::json!({"emitters": [{"particle": "test:wide", "chance": "1", "x": "-4.5"}]}),
),
(
"assets/minecraft/polytone/entity_modifiers/zombie.json",
serde_json::json!({"emitters": [{"particle": "test:ghost", "chance": "1"}]}),
),
]);
let scene = solid_scene(blocks);
let level = ParticleLevel {
world: &scene.world,
facts: &scene.facts,
collision: &scene.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
// The solid block is at (1, 64, 1): its bottom face is y 64.
// Spawns one particle, lets the engine take it in and tick it: whether it joined, and
// how many particles it emitted.
let mut spawn = |entity: &str, y: f64| {
engine.particles.clear();
engine.pending.clear();
engine.polytone_entity(&level, entity, [1.5, y, 1.5]);
engine.tick(&level);
let joined = engine.particles.len();
engine.tick(&level);
(
joined,
engine.particles.len() + engine.pending.len() - joined,
)
};
// The solid block is at (1, 64, 1): its bottom face is y 64.
// 0.05 under the block, a 0.08 box reaches into it: it never joins, nothing emitted.
assert_eq!(spawn("minecraft:player", 63.95), (0, 0));
// 0.01 clear of it, it joins and emits.
assert_eq!(spawn("minecraft:player", 63.91), (1, 1));
// Without physics nothing collides.
assert_eq!(spawn("minecraft:zombie", 63.95), (1, 1));
// A wide box reaching a block from five blocks away does not join either.
assert_eq!(spawn("minecraft:creeper", 60.0), (0, 0));
// Nor does one a clear particle emits there: the small one joins, its wide copy never.
assert_eq!(spawn("minecraft:skeleton", 60.0), (1, 0));
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn a_pack_custom_particle_replaces_the_vanilla_type_of_its_id() {
let root = pack(&[(
@@ -43,7 +43,7 @@ fn spawn_tick(engine: &mut ParticleEngine, rng: &mut Lcg, types: &[Emitter], roc
twinkle: false,
};
let at = [-5.5 + rng.next() * 12.0, f64::from(FLOOR_Y + 20), 20.5];
engine.fireworks(at, [0.0, 0.1, 0.0], vec![explosion], engine.camera);
engine.fireworks(at, [0.0, 0.1, 0.0], vec![explosion], engine.camera, true);
}
for &(ty, count, center, delta, speed) in types {
let options = ParticleOptions::simple(ty);
@@ -7,13 +7,16 @@ use horizon_core::{
geometry::{Vec3, traverse_blocks},
position::BlockPos,
};
use horizon_world::collision_context::CollisionContext;
/// `PotentSulfurBlock.ALLOWED_WATER_BLOCKS_ABOVE`.
const ALLOWED_WATER_BLOCKS_ABOVE: i32 = 4;
/// `PotentSulfurBlockEntity.isGeyserPassableBlock`: air, water, or a block
/// with no collision shape.
pub fn is_geyser_passable_block(level: &ParticleLevel, pos: [i32; 3]) -> bool {
/// with no collision shape for `CollisionContext.positionContext(context_y)`.
/// Vanilla passes the height of the block under the column it reads, so
/// scaffolding seen from below lets the gas and the geyser through.
pub fn is_geyser_passable_block(level: &ParticleLevel, pos: [i32; 3], context_y: i32) -> bool {
let state = level.world.block(pos[0], pos[1], pos[2]);
if level.facts.is_air(state) || &*level.facts.get(state).name == "minecraft:water" {
return true;
@@ -23,7 +26,9 @@ pub fn is_geyser_passable_block(level: &ParticleLevel, pos: [i32; 3]) -> bool {
binding: level.collision,
subject: None,
};
match level.collision.collision_shape(&mut scene, state, pos) {
let context = CollisionContext::position(f64::from(context_y));
let position = BlockPos::new(pos[0], pos[1], pos[2]);
match (level.collision).collision_shape_with_context(&mut scene, state, position, &context) {
Ok(shape) => shape.is_empty(),
Err(_) => !level.facts.get(state).full_collision,
}
@@ -46,9 +51,9 @@ pub fn find_noxious_gas_source_block(level: &ParticleLevel, origin: [i32; 3]) ->
let state = level.world.block(pos[0], pos[1], pos[2]);
let is_water_block = &*level.facts.get(state).name == "minecraft:water";
if !is_water_source(level, pos)
|| (!is_water_block && !is_geyser_passable_block(level, pos))
|| (!is_water_block && !is_geyser_passable_block(level, pos, origin[1]))
{
if level.facts.is_air(state) || is_geyser_passable_block(level, pos) {
if level.facts.is_air(state) || is_geyser_passable_block(level, pos, origin[1]) {
return Some(pos);
}
break;
@@ -63,7 +68,7 @@ pub fn find_noxious_gas_source_block(level: &ParticleLevel, origin: [i32; 3]) ->
pub fn unobstructed_block_count(level: &ParticleLevel, pos: [i32; 3], water_blocks: i32) -> i32 {
let geyser_force_height = 6 * water_blocks;
for i in 0..geyser_force_height {
if !is_geyser_passable_block(level, [pos[0], pos[1] + i, pos[2]]) {
if !is_geyser_passable_block(level, [pos[0], pos[1] + i, pos[2]], pos[1] - 1) {
return i;
}
}
@@ -79,7 +84,7 @@ pub fn can_be_reached_by_noxious_gas(
pos: [f64; 3],
) -> bool {
let block = ParticleLevel::block_at(pos);
if !is_geyser_passable_block(level, block) {
if !is_geyser_passable_block(level, block, block[1] - 1) {
return false;
}
let center = source_block.map(|c| f64::from(c) + 0.5);
+36 -1
View File
@@ -232,6 +232,8 @@ impl Selector {
pub struct Tick {
/// A vibration was chosen: its travel time (the particle starts).
pub started: Option<(Info, i32)>,
/// A vibration was reloaded from save: particle resumes from interpolated pos.
pub resumed: Option<(Info, i32)>,
/// A vibration arrived.
pub received: Option<Info>,
/// `onDataChanged`.
@@ -261,6 +263,10 @@ impl Data {
self.selector.start_over();
}
if let Some(info) = self.current {
if self.reload_particle {
self.reload_particle = false;
out.resumed = Some((info, self.travel));
}
let mut changed = self.travel > 0;
self.travel = (self.travel - 1).max(0);
if self.travel <= 0 {
@@ -293,8 +299,11 @@ impl Data {
Tag::compound(entries)
}
pub fn from_nbt(tag: &Tag) -> Self {
let current = tag.get("event").and_then(info_from_nbt);
let reload_particle = current.is_some();
let mut data = Self {
current: tag.get("event").and_then(info_from_nbt),
current,
reload_particle,
..Self::default()
};
if let Some(Tag::Int(t)) = tag.get("event_delay") {
@@ -979,4 +988,30 @@ mod tests {
assert_eq!(back.spreader.cursors[0].charge, 1000);
assert_eq!(back.spreader.cursors[1].charge, 500);
}
#[test]
fn vibration_reloads_particle() {
let tag = Tag::compound([
(
"event",
Tag::compound([
("game_event", Tag::String("minecraft:step".into())),
("distance", Tag::Float(4.0f32.to_bits())),
(
"pos",
Tag::List(vec![
Tag::Double(1.0f64.to_bits()),
Tag::Double(0),
Tag::Double(0),
]),
),
]),
),
("event_delay", Tag::Int(3)),
]);
let mut d = Data::from_nbt(&tag);
assert!(d.reload_particle && d.current.is_some());
let tick = d.tick(10, true, true, |dist| dist as i32);
assert!(tick.resumed.is_some() && !d.reload_particle);
}
}
@@ -965,13 +965,13 @@ const PI: f32 = 3.14159265;
fn disc_distance(d: vec3<f32>, y: f32) -> vec2<f32> {
if d.y * y <= 0.0 { return vec2(-1.0); }
let p = d * (y / d.y);
let h = length(p.xz);
let phi = atan2(p.z, p.x);
let sector = PI / 4.0;
let delta = phi - (floor(phi / sector) * sector + sector * 0.5);
let edge = 512.0 * cos(sector * 0.5) / cos(delta);
if h > edge { return vec2(-1.0); }
let s = h / edge;
// The regular octagon's edge parameter, directly from its half planes.
// max(|x| + tan(pi/8)*|z|, |z| + tan(pi/8)*|x|) / radius
// equals length(p.xz) / edge without atan2, floor or cos per pixel.
let a = abs(p.xz);
let diagonal = 0.41421356237309503;
let s = max(a.x + diagonal * a.y, a.y + diagonal * a.x) / 512.0;
if s > 1.0 { return vec2(-1.0); }
let ay = abs(y);
let rim = sqrt(512.0 * 512.0 + y * y);
return vec2(ay + s * (rim - ay), ay + s * (512.0 - ay));
@@ -14,6 +14,8 @@ pub enum Cursor {
Arrow,
PointingHand,
NotAllowed,
/// `CursorTypes.RESIZE_NS`: a scroller held vertically (`MerchantScreen`).
ResizeNs,
}
thread_local! {
+2 -2
View File
@@ -274,8 +274,8 @@ impl ArrowState {
/// `Arrow.getColor` of the server's own arrow: the colour of the potion on
/// its pickup item, None for a plain arrow (`PotionContents.EMPTY`).
pub fn potion_color(&self) -> Option<u32> {
let potion = self.pickup_item.potion()?;
Some(crate::brewing::potion_color(Some(potion)))
(!crate::brewing::contents_empty(&self.pickup_item))
.then(|| crate::brewing::contents_color(&self.pickup_item))
}
/// The `AbstractArrow` constructor's `pickupItemStack`: the drawn ammo
/// (one item). Ammo drawn without being used up (`INTANGIBLE_PROJECTILE`)
+1
View File
@@ -85,6 +85,7 @@ pub mod join_seq;
pub mod lan;
pub mod landing;
pub mod legacy_combat;
pub mod living_death;
pub mod locator_bar;
pub mod lod_net;
pub mod menu;
+23
View File
@@ -137,6 +137,15 @@ pub struct DebugKeys {
}
impl DebugKeys {
/// Window focus lost, or input reset: clear held modifier state.
pub fn reset(&mut self) {
self.modifier_down = false;
self.used_debug_key_as_modifier = false;
if self.crash_key_time > 0 {
self.crash_key_time = -1;
}
}
/// `keyPress` once the open screen (if any) did not take the key. `key`
/// is the physical key's name (`key.keyboard.f3`); `crash_key_down`
/// whether the crash mapping's key is held (`InputConstants.isKeyDown`).
@@ -487,4 +496,18 @@ mod tests {
press(&mut k, &mut h, &b, "key.keyboard.k", 2);
assert_eq!(h.actions, [(DebugAction::ShowChunkBorders, false)]);
}
#[test]
fn reset_on_focus_loss_clears_held_modifier_and_avoids_spurious_shortcuts() {
let (mut k, mut h, b) = (DebugKeys::default(), Host::default(), KeyBinds::default());
press(&mut k, &mut h, &b, F3, 0);
assert!(k.modifier_down);
// Window loses focus while F3 was down (e.g. desktop shortcut Ctrl+F3)
k.reset();
assert!(!k.modifier_down);
assert!(!k.used_debug_key_as_modifier);
// User refocuses and presses 'b': it should NOT trigger ShowHitboxes
assert!(press(&mut k, &mut h, &b, "key.keyboard.b", 1));
assert!(h.actions.is_empty());
}
}
+10
View File
@@ -28,6 +28,8 @@ pub fn main_with(mods: crate::mod_api::Mods) {
// then the Horizon parts still in this crate; none registers after this point.
game::player_hooks::register();
horizon_hooks::engine::seal();
// The text crates ship (`horizon_hooks::lang`), loaded with the game's own languages.
horizon_client::lang::install_crate_files(horizon_hooks::lang::engine::all());
horizon_client::spans::startup::init(std::time::Instant::now());
horizon_client::spans::trace::start_from_env();
let args: Vec<String> = std::env::args().skip(1).collect();
@@ -230,6 +232,14 @@ pub fn main_with(mods: crate::mod_api::Mods) {
living_jump: |entity, kind| {
crate::mod_api::dispatch_living_jump(crate::mod_api::mods(), entity, kind);
},
living_death_message: |entity, kind, message| {
crate::mod_api::dispatch_living_death_message(
crate::mod_api::mods(),
entity,
kind,
message,
);
},
register_goals: |mob| {
crate::mod_api::dispatch_register_goals(crate::mod_api::mods(), mob);
},
+18 -36
View File
@@ -1505,7 +1505,7 @@ impl LevelAccess for GameLevel<'_> {
{
return signal;
}
machines::container_signal(self.world, self.behaviours, self.items, self.registry, pos)
machines::container_signal(self, pos)
}
fn item_frame_analog_output(&self, pos: BlockPos, facing: Direction) -> Option<i32> {
let mut frames = self
@@ -2328,6 +2328,8 @@ struct Game<'a> {
last_device_recovery: Option<Instant>,
/// CPU geometry storage retained across frames, including empty/title frames.
entity_vertices: Vec<Vertex>,
/// Falling models by state and selected-model address list; cleared on pack/atlas replacement.
falling_vertices: RefCell<HashMap<BlockStateId, Vec<(Vec<usize>, Vec<Vertex>)>>>,
/// Horizon Extras' dynamic lights this frame (`dynamic_lights.rs`), and how brightly each
/// item glows (built the first time the extra is on).
dynamic_lights: Vec<[f32; 4]>,
@@ -5033,8 +5035,8 @@ impl Game<'_> {
self.loot_block_entity(pos, &facts.name)
};
if let Some(entity) = self.world.block_entities.get(&key).cloned() {
let name = self.world.block_names.get(&key).cloned();
self.pending.record(pos, &entity, &facts.name, name);
self.pending
.record_from(&self.world, pos, &entity, &facts.name);
} else if let Some(tag) = spill::saved_entity(&self.world, pos) {
self.pending.record_saved(pos, &tag);
}
@@ -7158,14 +7160,7 @@ impl Game<'_> {
let packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
let start = out.len();
self.mark_motion(start, Self::MOTION_OBJECT | u64::from(f.id));
self.block_model_vertices(
f.state,
horizon_core::random::position_seed(f.start_pos),
glam::Mat4::from_translation(o),
packed,
false,
&mut out,
);
self.falling_model_vertices(f.state, f.start_pos, o, packed, &mut out);
let tint = horizon_client::mobs::render::abgr(tint);
for vertex in &mut out[start..] {
vertex.color = horizon_client::hud_layout::multiply(vertex.color, tint);
@@ -8211,10 +8206,7 @@ impl Game<'_> {
}
if let Some(kind) = mob {
if let Some(id) = self.spawn_mob_at(kind, (target[0], target[1], target[2]), true) {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
horizon_client::entity_nbt::apply_item_components(m, stack);
}
self.mob_placed(id)?;
self.mob_placed(id, stack)?;
}
self.post_block_game_event(
"minecraft:entity_place",
@@ -9972,20 +9964,10 @@ impl Game<'_> {
self.drag_button = None;
}
if let WindowEvent::ModifiersChanged(m) = &event {
self.modifiers = m.state();
horizon_client_input::modifiers::reconcile(
self.modifiers,
&mut self.physical_keys,
&self.settings.keys,
&mut self.toggles,
[self.settings.toggle_crouch, self.settings.toggle_sprint],
[
self.pad.holds_action("key.sneak"),
self.pad.holds_action("key.sprint"),
],
);
self.sync_toggle(SNEAK);
self.sync_toggle(SPRINT);
self.update_modifiers(m.state());
}
if let WindowEvent::Focused(false) = &event {
self.update_modifiers(winit::keyboard::ModifiersState::empty());
}
// `PackSelectionScreen.onFilesDrop` on the Create New World screen.
if let WindowEvent::DroppedFile(path) = &event
@@ -10391,6 +10373,8 @@ impl Game<'_> {
let held = self.captured;
if self.control.is_none() {
self.keys.clear();
self.modifiers = winit::keyboard::ModifiersState::empty();
self.debug_keys.reset();
// Latched sneak/sprint live in `toggles`, not in the
// physical keys: put them back so a short alt-tab
// (no pause screen) does not drop them. Only a mapping
@@ -10398,21 +10382,18 @@ impl Game<'_> {
// no state to keep, and an unfocused window never gets
// its key release, so leaving it down would keep the
// player sneaking or sprinting until the key is tapped.
for (slot, needs_toggle) in [
let toggles = [
self.settings.toggle_crouch,
self.settings.toggle_sprint,
self.settings.toggle_attack,
self.settings.toggle_use,
]
.into_iter()
.enumerate()
{
];
for (slot, needs_toggle) in toggles.into_iter().enumerate() {
self.toggles.0[slot].set_down(false, needs_toggle);
}
self.sync_toggle(0);
self.sync_toggle(1);
self.left = false;
self.right = false;
(self.left, self.right) = (false, false);
self.capture(false);
}
self.regrab_on_refocus = held;
@@ -11685,6 +11666,7 @@ impl<'a> Game<'a> {
renderer: None,
last_device_recovery: None,
entity_vertices: Vec::new(),
falling_vertices: Default::default(),
dynamic_lights: Vec::new(),
luminance: Default::default(),
see_through_vertices: Vec::new(),
@@ -230,15 +230,10 @@ impl adv::World for View<'_, '_> {
_ => None,
}
}
/// `Level.canSeeSky`: sky light 15 at the position, whatever stands
/// above it, as `MobWorld` reads it for the spawn rules.
fn can_see_sky(&self, p: [i32; 3]) -> bool {
(p[1] + 1..p[1] + 200).all(|y| {
!self
.0
.res
.facts()
.get(self.0.world.block(p[0], y, p[2]))
.full_collision
})
self.0.mob_world_base(&[]).can_see_sky(p[0], p[1], p[2])
}
fn light(&self, p: [i32; 3]) -> i32 {
let l = self.0.world.light(p[0], p[1], p[2]);
@@ -153,7 +153,18 @@ impl Game<'_> {
.shulkers
.get_mut(&pos)
.map(|shulker| shulker.tick(pos, facing));
if let Some(push) = tick.and_then(|tick| tick.push) {
let Some(tick) = tick else { continue };
// `ShulkerBoxBlockEntity.doNeighborUpdates` at the start and the
// end of an opening or closing lid: the box's collision shape
// moved, so the neighbours' shapes and their redstone updates run.
for _ in 0..tick.neighbor_updates {
let state = self.world.block(pos[0], pos[1], pos[2]);
let at = BlockPos::new(pos[0], pos[1], pos[2]);
self.with_level(|b, level, ticks| {
b.shulker_neighbor_updates(level, ticks, at, state)
});
}
if let Some(push) = tick.push {
pushes.push((pos, facing, push));
}
}
@@ -47,8 +47,9 @@ fn clone_item<'a>(name: &str, block_item: &'a str) -> &'a str {
"minecraft:kelp_plant" => "minecraft:kelp",
"minecraft:twisting_vines_plant" => "minecraft:twisting_vines",
"minecraft:weeping_vines_plant" => "minecraft:weeping_vines",
// CandleCakeBlock: new ItemStack(Blocks.CAKE), for every colour.
_ if name.ends_with("_candle_cake") => "minecraft:cake",
// CandleCakeBlock: new ItemStack(Blocks.CAKE), for every colour and
// the plain `minecraft:candle_cake`, whose own item is air.
_ if name == "minecraft:candle_cake" || name.ends_with("_candle_cake") => "minecraft:cake",
_ => block_item,
}
}
@@ -424,6 +425,7 @@ mod tests {
("minecraft:kelp_plant", "minecraft:kelp"),
("minecraft:twisting_vines_plant", "minecraft:twisting_vines"),
("minecraft:weeping_vines_plant", "minecraft:weeping_vines"),
("minecraft:candle_cake", "minecraft:cake"),
("minecraft:white_candle_cake", "minecraft:cake"),
("minecraft:light_blue_candle_cake", "minecraft:cake"),
] {
+62 -1
View File
@@ -3,7 +3,7 @@
//! pixels, 1024 characters, 100 pages), signing it into a written book,
//! reading written books, and lecterns (placing a book, turning its pages
//! with a redstone pulse, the comparator output and taking the book).
use super::{Game, lan::LanHost, screen_info};
use super::{Game, chat::PLAYER_NAME, lan::LanHost, screen_info};
use horizon_client::{
containers::{BlockEntity, Lectern},
first_person::Hand,
@@ -18,6 +18,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 winit::{
event::{ElementState, KeyEvent},
keyboard::{KeyCode, PhysicalKey},
@@ -213,6 +214,15 @@ impl Game<'_> {
"minecraft:written_book" => true,
_ => return false,
};
// `ServerPlayer.openItemGui` resolves a written book for the player
// opening it before the screen reads it.
let held;
let stack = if written && self.resolve_held_book() {
held = self.held().cloned();
held.as_ref().unwrap_or(stack)
} else {
stack
};
if self.lan_acting.is_some() {
if written {
let hand = i32::from(self.hand == Hand::Off);
@@ -227,6 +237,56 @@ impl Game<'_> {
self.swing_arm();
true
}
/// `ServerPlayer.openItemGui` -> `WrittenBookContent.resolveForItem`: the
/// selectors and scores of the written book in the acting hand become
/// their text for the player opening it, and the book is marked resolved
/// (so it is never resolved again). True when the pages were rewritten.
fn resolve_held_book(&mut self) -> bool {
let Some(mut book) = self
.held()
.and_then(|s| s.data())
.and_then(|d| d.written.clone())
else {
return false;
};
if book.resolved {
return false;
}
let string = |v: &Tag| match v {
Tag::String(s) => Some(s.to_string_lossy()),
_ => None,
};
let changed = book.resolve(|tag| {
let Tag::Compound(c) = tag else { return None };
if let Some(Tag::String(selector)) = c.get(&"selector".into()) {
let selector = selector.to_string_lossy();
return Some(self.resolve_selector_text(&selector));
}
let score = c.get(&"score".into())?;
let name = score.get("name").and_then(string).unwrap_or_default();
let objective = score.get("objective").and_then(string).unwrap_or_default();
let name = if name == "*" {
PLAYER_NAME
} else {
name.as_str()
};
Some(
self.score
.board
.score(name, &objective)
.map_or_else(String::new, |s| s.value.to_string()),
)
});
if let Some(stack) = self.held_mut() {
stack.data_mut().written = Some(book);
}
// A guest's copy of the book lives in its mirrored inventory: the
// host sends the resolved slot back with the rest of the inventory.
if self.lan_acting.is_some() {
self.guest_inventory_resend = true;
}
changed
}
/// The screen a book in hand opens: `BookEditScreen` for a book and
/// quill, `BookViewScreen` for a written book.
fn book_screen(&self, stack: &ItemStack) -> Option<BookScreen> {
@@ -349,6 +409,7 @@ impl Game<'_> {
generation: 0,
pages,
page_tags: Vec::new(),
resolved: false,
});
*stack = written;
} else {
@@ -23,3 +23,34 @@ fn breeding_drops_one_orb_of_the_whole_roll() {
}
assert!(values.iter().any(|v| matches!(v, 4..=6)), "{values:?}");
}
/// `Turtle.TurtleBreedGoal.breed` overrides `Animal.breed`, so a bred turtle
/// never runs `finalizeSpawnChildFromBreeding`'s heart burst (entity event
/// 18); `Frog.spawnChildFromBreeding` still does.
#[test]
#[ignore = "needs prepared resources (HORIZON_TEST_RESOURCES)"]
fn a_bred_turtle_posts_no_heart_burst_but_a_bred_frog_does() {
let mut game = super::super::native_machines::tests::server_game();
game.audio.capture = Some(Vec::new());
for (kind_id, hearts) in [("minecraft:turtle", 0), ("minecraft:frog", 1)] {
let kind = game.res.mob_types().get(kind_id).unwrap().clone();
game.mobs
.push(Mob::new(1, kind.clone(), Vec3::new(8.5, 64.0, 8.5), 0.0));
game.mobs
.push(Mob::new(2, kind, Vec3::new(9.5, 64.0, 8.5), 0.0));
game.lan_entity_events.clear();
game.breed(1, 2, None);
let burst = game
.lan_entity_events
.iter()
.filter(|(id, event, _)| *id == 1 && *event == 18)
.count();
assert_eq!(
burst, hearts,
"{kind_id} heartbeat events: {:?}",
game.lan_entity_events
);
game.mobs.clear();
game.orbs.clear();
}
}
+18 -2
View File
@@ -5,8 +5,8 @@
use super::Game;
use horizon_assets::item_model::DisplayContext;
use horizon_client::{
Result, block_entities::Pos, block_entity_render::vertex_light, containers::BlockEntity,
items::ItemStack, mesher::Vertex, mobs::Who,
Result, actions::Action, block_entities::Pos, block_entity_render::vertex_light,
containers::BlockEntity, items::ItemStack, mesher::Vertex, mobs::Who,
};
use horizon_core::position::BlockPos;
@@ -25,6 +25,13 @@ impl Game<'_> {
/// to the server. Whether the food went in; None when this isn't a
/// campfire use, so the item's own use runs.
pub(super) fn use_on_campfire(&mut self, pos: [i32; 3]) -> Result<Option<bool>> {
// `ServerPlayerGameMode.useItemOn`'s `suppressUsingBlock`: sneaking
// with a stack in either hand never reaches the block's `useItemOn`,
// so the food stays in the hand and the item's own use (eating)
// runs instead.
if self.keys.held(Action::Sneak) {
return Ok(None);
}
let state = self.world.block(pos[0], pos[1], pos[2]);
if !is_campfire(&self.res.facts().get(state).name) {
return Ok(None);
@@ -60,6 +67,11 @@ impl Game<'_> {
);
self.mark_block_entity_updated(key);
}
// `ServerPlayerGameMode.useItemOn` triggers ITEM_USED_ON_BLOCK with
// the stack before the use once `useItemOn` consumed the click, both
// when the food went in (SUCCESS_SERVER) and on a full campfire
// (CONSUME).
self.trigger_block("minecraft:item_used_on_block", pos, &held);
Ok(Some(placed))
}
@@ -198,3 +210,7 @@ impl Game<'_> {
}
}
}
#[cfg(test)]
#[path = "campfire_tests.rs"]
mod campfire_tests;
@@ -0,0 +1,125 @@
//! Campfire food placement's gate and criterion: `CampfireBlock.useItemOn`
//! reached through `ServerPlayerGameMode.useItemOn`, which skips the block
//! while sneaking with something in hand and then triggers
//! ITEM_USED_ON_BLOCK for the use it consumed.
use super::*;
use crate::game::{Hit, native_machines::tests::server_game};
use horizon_client::{
advancements::{Advancement, Criterion, Rewards, Tree},
held::Source,
items::ItemStack,
motion::PlayerMotion,
};
use horizon_core::geometry::Vec3;
use horizon_world::lazy_json::LazyJson;
use std::sync::Arc;
use winit::keyboard::KeyCode;
/// A server game with a campfire at `pos` (its block entity too) and beef in
/// the main hand, the click forced onto that block.
fn campfire_game(pos: [i32; 3]) -> Game<'static> {
let mut game = server_game();
// A local player, not a dedicated server's silent world: criteria and
// statistics apply to them.
game.options.dedicated = None;
let state = game
.pack
.assets
.registry
.default_state("minecraft:campfire")
.unwrap();
game.world.set(pos[0], pos[1], pos[2], state).unwrap();
game.world.block_entities.put(
(pos[0], pos[1], pos[2]),
BlockEntity::Campfire(Box::default()),
);
let mut player = PlayerMotion::new(
game.motion,
Vec3::new(
f64::from(pos[0]) + 0.5,
f64::from(pos[1]),
f64::from(pos[2]) + 2.5,
),
)
.unwrap();
// `Player.canEat`: hungry, so the beef's own use can start.
player.health.food.food = 6;
game.player = Some(player);
game.creative = false;
*game.held_mut() = Some(ItemStack::new("minecraft:beef", 4));
game.forced_hit = Some(Hit {
pos,
face: [0, 1, 0],
point: [
f64::from(pos[0]) + 0.5,
f64::from(pos[1]) + 1.0,
f64::from(pos[2]) + 0.5,
],
});
game
}
/// The campfire's four cooking slots.
fn slots(game: &Game<'_>, pos: [i32; 3]) -> usize {
match game.world.block_entities.get(&(pos[0], pos[1], pos[2])) {
Some(BlockEntity::Campfire(c)) => c.items.iter().filter(|i| i.is_some()).count(),
_ => panic!("no campfire block entity at {pos:?}"),
}
}
/// `ServerPlayerGameMode.useItemOn`: with something in hand, sneaking skips
/// `state.useItemOn` entirely (`suppressUsingBlock`), so the beef is neither
/// placed nor consumed; the item's own use, eating, starts instead. Without
/// the sneak the campfire takes the beef, and the use it consumed triggers
/// ITEM_USED_ON_BLOCK with the stack before the use.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_sneak_keeps_the_food_and_placing_it_fires_item_used_on_block() {
const POS: [i32; 3] = [2, 4, 3];
let mut game = campfire_game(POS);
// The tree a data pack with one `item_used_on_block` criterion on this
// campfire would be indexed into.
let mut tree = Tree::default();
tree.list.push(Advancement {
id: "horizon:test/campfire".into(),
parent: None,
display: None,
criteria: vec![Criterion {
name: "food".into(),
trigger: "minecraft:item_used_on_block".into(),
conditions: LazyJson::from(serde_json::json!({
"location": {
"type": "minecraft:location_check",
"predicate": {"block": {"blocks": "minecraft:campfire"}}
}
})),
}],
requirements: vec![vec!["food".into()]],
rewards: Rewards::default(),
});
tree.by_id.insert("horizon:test/campfire".into(), 0);
tree.by_trigger
.insert("minecraft:item_used_on_block".into(), vec![(0, 0)]);
game.adv.tree = Arc::new(tree);
let hit = game.forced_hit.unwrap();
game.keys.press(KeyCode::ShiftLeft, Source::Keyboard);
assert!(game.use_hand(Some(&hit)).unwrap());
assert_eq!(slots(&game, POS), 0, "the sneak put no beef on the fire");
assert_eq!(game.held().unwrap().count, 4, "the beef stayed in hand");
assert!(game.using.is_some(), "the beef is being eaten");
assert!(
!game.adv.progress.has("horizon:test/campfire", "food"),
"the skipped block use triggers nothing"
);
// Stop eating, let go of the sneak: the same click now feeds the fire.
game.using = None;
game.keys.release(&KeyCode::ShiftLeft, Source::Keyboard);
assert!(game.use_hand(Some(&hit)).unwrap());
assert_eq!(slots(&game, POS), 1, "the beef went on the fire");
assert_eq!(game.held().unwrap().count, 3, "one beef was used");
assert!(game.using.is_none(), "the click was consumed");
assert!(
game.adv.progress.has("horizon:test/campfire", "food"),
"the placed food fired item_used_on_block"
);
}
+7 -1
View File
@@ -608,7 +608,13 @@ impl Game<'_> {
if !host && !guest {
return;
}
let message = self.mob_death_message(m, killer);
let mut built = Some(self.mob_death_message(m, killer));
let kind = super::native_hooks::mob_kind(m);
horizon_client::living_death::native_living_death_message(m.id, &kind, &mut built);
// A native mod may have changed the message, or set it to None to tell the owner nothing.
let Some(message) = built else {
return;
};
if host {
self.system_message(message);
} else if let Some(lan) = &self.lan {
+12 -18
View File
@@ -8,6 +8,7 @@ pub(super) use data::place_summoned;
mod effects;
pub(super) mod execute;
pub(super) mod extra;
mod item_count;
pub(in crate::game) mod items;
mod misc;
mod place;
@@ -3951,6 +3952,12 @@ impl Game<'_> {
};
let present = components.get(&id.as_str().into());
let result = match term[split..].chars().next() {
// Both count wrappers in `ItemPredicateArgument` use the live
// stack count and the same integer bounds matcher.
Some('=' | '~') if id == "minecraft:count" => {
let value = horizon_world::nbt::parse_snbt(term[split + 1..].trim());
value.is_ok_and(|(v, _)| item_count::matches(stack.count, &v))
}
Some('=') => {
let value = horizon_world::nbt::parse_snbt(term[split + 1..].trim())
.ok()
@@ -3964,28 +3971,11 @@ impl Game<'_> {
}
}
Some('~') => {
// Sub-predicates: `count` and component predicates.
// Component sub-predicates.
let value = horizon_world::nbt::parse_snbt(term[split + 1..].trim())
.ok()
.map(|(t, _)| t);
match (id.as_str(), value) {
("minecraft:count", Some(v)) => {
let n = i64::from(stack.count);
match &v {
horizon_world::nbt::Tag::Compound(_) => {
let get = |k: &str| match v.get(k) {
Some(horizon_world::nbt::Tag::Int(i)) => {
Some(i64::from(*i))
}
_ => None,
};
get("min").is_none_or(|m| n >= m)
&& get("max").is_none_or(|m| n <= m)
}
horizon_world::nbt::Tag::Int(i) => n == i64::from(*i),
_ => false,
}
}
// `MinMaxBounds.Ints.matches`: DamagePredicate's damage-only
// Int bounds shape; effective DAMAGE presence is required.
// Other fields keep the entire existing predicate path.
@@ -6044,3 +6034,7 @@ mod global_selector_order_tests {
);
}
}
#[cfg(test)]
#[path = "item_count_predicate_tests.rs"]
mod item_count_predicate_tests;
@@ -516,9 +516,11 @@ impl Game<'_> {
new.glow = old.glow;
// ItemFrame.readAdditionalSaveData: ItemStack.matches includes
// count, item and components, before adopting the new stack.
// It compares the stack as read; setItem keeps one of it
// only after, as `new.item` already does.
let read = tag.get("Item").and_then(save::item_from_nbt);
if old.item.as_ref().is_some_and(|a| {
new.item
.as_ref()
read.as_ref()
.is_none_or(|b| a.count != b.count || !a.same_item_same_components(b))
}) {
let old = old.clone();
@@ -0,0 +1,21 @@
//! The `count` wrapper of an item predicate (`ItemPredicateArgument`): both
//! `count=` and `count~` read the live stack count.
use horizon_world::nbt::Tag;
/// `MinMaxBounds.Ints.matches` on the stack count: an exact Int, or a
/// compound with optional Int `min` and `max`.
pub(super) fn matches(count: impl Into<i64>, want: &Tag) -> bool {
let n = count.into();
match want {
Tag::Compound(_) => {
let get = |k: &str| match want.get(k) {
Some(Tag::Int(i)) => Some(i64::from(*i)),
_ => None,
};
get("min").is_none_or(|m| n >= m) && get("max").is_none_or(|m| n <= m)
}
Tag::Int(i) => n == i64::from(*i),
_ => false,
}
}
@@ -535,6 +535,15 @@ impl Game<'_> {
let Some(stack) = stack.filter(|s| &*s.item != "minecraft:air" && s.count > 0) else {
return true;
};
// SulfurCube.getEquipmentSlotForItem: a swallowable item is a body item.
if let Slot::Mob(id, "body") = s
&& self.mobs.iter().find(|m| m.id == *id).is_some_and(|m| {
m.species() == horizon_client::mobs::Species::SulfurCube
&& horizon_client::mobs::sulfur_cube::swallowable(m, &stack.item)
})
{
return true;
}
self.items()
.get(&stack.item)
.is_some_and(|info| info.equip_slot.as_deref() == Some(name))
@@ -1979,6 +1988,31 @@ mod mob_equipment_command_tests {
}
});
}
/// `SulfurCube.getEquipmentSlotForItem`: a `#sulfur_cube_swallowable`
/// item is a body item, so the body's `SlotAccess` takes it (#12038).
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_sulfur_cubes_body_takes_a_swallowable_block() {
with_game(|game| {
assert!(game.run_command("summon sulfur_cube 4.5 200 4.5 {NoAI:1b,Tags:[\"cube\"]}"));
let body = |game: &Game<'_>| {
let m = game
.mobs
.iter()
.find(|m| m.kind.id == "minecraft:sulfur_cube");
m.and_then(|m| m.extra.body.clone())
};
let replace = "item replace entity @e[tag=cube,limit=1] armor.body with";
assert!(game.run_command(&format!("{replace} white_wool")));
assert_eq!(body(game).as_deref(), Some("minecraft:white_wool"));
// An item outside the tag stays out, and the body stays on.
assert!(!game.run_command(&format!("{replace} diamond")));
assert_eq!(body(game).as_deref(), Some("minecraft:white_wool"));
// An empty stack always fits: the body comes off.
assert!(game.run_command(&format!("{replace} air")));
assert_eq!(body(game), None);
});
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn effective_item_composition_rejects_before_destination_mutation() {
@@ -770,7 +770,13 @@ impl Game<'_> {
let mut pending = crate::game::spill::Pending::default();
let key = (p[0], p[1], p[2]);
let has_block_entity = if let Some(entity) = self.world.block_entities.get(&key) {
pending.record(p, entity, &name, self.world.block_names.get(&key).cloned());
pending.record(
p,
entity,
&name,
self.world.block_names.get(&key).cloned(),
self.world.block_lock(key),
);
true
} else if let Some(saved) = self.block_entity_nbt(p) {
pending.record_saved(p, &saved);
+27 -35
View File
@@ -2,6 +2,7 @@
//! chunk-generation spawning (`NaturalSpawner`), mob events (attacks,
//! breeding, conversions, drops), projectiles, player interactions
//! (feeding, shearing, milking, spawn eggs, throwing) and rendering.
mod bred_draws;
mod mob_geometry;
mod projectile_world;
mod pufferfish_player;
@@ -3869,18 +3870,18 @@ impl Game<'_> {
q.extra.in_love = 0;
}
let q = self.mobs.iter().find(|m| m.id == partner).cloned();
// Animal.finalizeSpawnChildFromBreeding: entity event 18 for the parent.
self.mob_event_particles(parent, 18);
// Animal.finalizeSpawnChildFromBreeding's heart burst, not a turtle's.
if p.species() != Species::Turtle {
self.mob_event_particles(parent, 18);
}
// Sniffer.spawnChildFromBreeding: an egg instead of a baby.
if p.species() == Species::Sniffer {
self.lay_sniffer_egg(&p);
return;
}
// TurtleBreedGoal.breed: the goal gave the mother her egg; only the
// experience is left, and only with mob loot on.
// TurtleBreedGoal.breed and Frog.spawnChildFromBreeding: only experience.
if matches!(p.species(), Species::Turtle | Species::Frog) {
// One `new ExperienceOrb` of 1 to 7 from the mother's random (not
// split up as `ExperienceOrb.award` does).
// One `new ExperienceOrb` of 1 to 7 from the mother's random.
if self.ui.rules.bool("mob_drops")
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == parent)
{
@@ -3889,9 +3890,7 @@ impl Game<'_> {
}
return;
}
// (Mooshroom draws its own coin in getOffspringVariant.)
let coin = p.species() != Species::Mooshroom
&& self.world.random.next_int_bound(2).unwrap_or(0) == 0;
let coin = self.bred_coat_coin(parent, p.species());
// Axolotl.getBreedOffspring: useRareVariant (one in 1200) makes it blue,
// else either parent's variant. The draws come from the mother's random.
let axolotl = if p.species() == Species::Axolotl {
@@ -3943,7 +3942,7 @@ impl Game<'_> {
// No Mob.finalizeSpawn either: no follow range bonus, right-handed.
spawning::bred_offspring(child);
// getBreedOffspring creates the baby without finalizeSpawn: it keeps
// the default (`classic`) sound variant.
// the default (`classic`) sound variant (a wolf picks its own below).
child.extra.sound_variant = None;
// Fox.getBreedOffspring runs no finalizeSpawn: nothing in the
// baby fox's mouth.
@@ -4058,6 +4057,7 @@ impl Game<'_> {
c.extra.color = mixed;
}
}
self.pick_bred_wolf_voice(parent, id);
self.breeding_orb(parent, [p.position.x, p.position.y, p.position.z]);
return;
}
@@ -4546,6 +4546,7 @@ impl Game<'_> {
// The impacts the mods may cancel (`native_projectile_impact`), on a server.
let hear = self.native_hears_impacts();
let mut impacts: Vec<Impact> = Vec::new();
let mut vehicle_winds = self.vehicle_winds();
{
let targets = self.projectile_targets();
let breezes: Vec<u32> = self
@@ -4653,6 +4654,7 @@ impl Game<'_> {
};
// The projectile as it was, to fly on if a mod cancels its impact.
let before = (hear && !p.stuck).then(|| p.clone());
let wind_before = vehicle_winds.before(p);
let mob_shot = !matches!(p.owner, Some(Who::Player));
// A player's arrows, snowballs and eggs meet every player; a
// guest's flies from that guest (Projectile.getOwner), not
@@ -4787,6 +4789,7 @@ impl Game<'_> {
keep_flags[i] = false;
continue;
}
vehicle_winds.met(wind_before, hit);
if hit != Hit::None {
if let Some(before) = before {
impacts.push(Impact {
@@ -4859,6 +4862,9 @@ impl Game<'_> {
}
self.creatures.projectiles.append(&mut projectiles);
for (p, hit) in hits {
let Some((p, hit)) = self.broken_vehicle_retick(p, hit, &mut vehicle_winds) else {
continue;
};
// A player's arrow on another player lands as the victim's hurt.
if let Some(victim) = self.projectile_pvp_victim(&p, hit) {
self.projectile_hits_player(&p, hit, victim)?;
@@ -4973,17 +4979,6 @@ impl Game<'_> {
8.0,
)
}
/// A projectile's hurt of `amount` on the boat or minecart `id`
/// (`VehicleEntity.hurtServer`): even 0 shakes it (`Snowball`, `ThrownEgg`,
/// `ThrownEnderpearl` `.onHitEntity`), 5 from a small fireball destroys it.
fn hurt_vehicle(&mut self, p: &Projectile, id: u32, amount: f32) {
let creative = p.owner == Some(Who::Player) && self.creative;
if let Some(i) = self.boats.iter().position(|b| b.id == id) {
self.hurt_boat(i, amount, creative);
} else if let Some(i) = self.carts.iter().position(|c| c.id == id) {
self.hurt_cart(i, amount, creative, false);
}
}
/// `BellBlock.onProjectileHit`: a projectile that strikes the bell proper
/// rings it, and a player's shot counts as the player's ring.
fn bell_projectile_hit(
@@ -5168,19 +5163,16 @@ impl Game<'_> {
ArrowEvent::ShootEvent | ArrowEvent::Drop => {}
}
}
/// `Level.isRainingAt` for arrows.
/// The boats, minecarts and primed TNT a projectile can strike
/// (`VehicleEntity.isPickable`, `PrimedTnt.isPickable`).
pub(super) fn pickable_object_targets(&self) -> Vec<(Who, Aabb)> {
let boats = self.boats.iter().map(|b| (Who::Mob(b.id), b.bounds()));
let carts = self.carts.iter().map(|c| (Who::Mob(c.id), c.bounds()));
let tnts = self.tnts.iter().map(|t| (Who::Mob(t.id), t.bounds()));
boats.chain(carts).chain(tnts).collect()
}
/// What a projectile can hit: the mobs, the player, the dragon's parts and
/// the End crystals (under reserved ids, game/dragon.rs).
/// The boats and minecarts a projectile can strike.
pub(super) fn vehicle_targets(&self) -> Vec<(Who, Aabb)> {
let mut targets: Vec<(Who, Aabb)> = self
.boats
.iter()
.map(|b| (Who::Mob(b.id), b.bounds()))
.collect();
targets.extend(self.carts.iter().map(|c| (Who::Mob(c.id), c.bounds())));
targets
}
pub(super) fn projectile_targets(&self) -> Vec<(Who, Aabb)> {
let mut targets: Vec<(Who, Aabb)> = self
.mobs
@@ -5198,9 +5190,9 @@ impl Game<'_> {
.into_iter()
.map(|(id, b)| (Who::Mob(id), b)),
);
// Boats and minecarts are pickable for every projectile
// (`VehicleEntity.isPickable`), so every kind shares this one list.
targets.extend(self.vehicle_targets());
// Boats, minecarts and primed TNT are pickable for every projectile,
// so every kind shares this one list.
targets.extend(self.pickable_object_targets());
targets
}
pub(super) fn arrow_rain(&self) -> impl Fn(BlockPos) -> bool + '_ {
@@ -0,0 +1,148 @@
//! The breeding parent's own random in `getBreedOffspring`: the offspring's
//! coat coin and a wolf pup's voice.
use super::super::Game;
use horizon_client::mobs::Species;
use horizon_core::random::RandomSource;
impl Game<'_> {
/// Cow, Pig, Chicken, Fox, Cat and Wolf.getBreedOffspring: the offspring's
/// variant coin is the breeding parent's own first draw, not the level's.
/// (Mooshroom draws its own coin in getOffspringVariant.)
pub(super) fn bred_coat_coin(&mut self, parent: u32, species: Species) -> bool {
matches!(
species,
Species::Cow
| Species::Pig
| Species::Chicken
| Species::Fox
| Species::Cat
| Species::Wolf
) && self
.mobs
.iter_mut()
.find(|m| m.id == parent)
.is_some_and(|m| m.random.next_boolean())
}
/// WolfSoundVariants.pickRandomSoundVariant: Registry.getRandom over the
/// wolf voice registry, from the parent's own random, after the collar
/// colour's mix.
pub(super) fn pick_bred_wolf_voice(&mut self, parent: u32, child: u32) {
let voices = horizon_client::mobs::finalize::sound_variants(Species::Wolf);
let voice = self.mobs.iter_mut().find(|m| m.id == parent).and_then(|m| {
let i = m.random.next_int_bound(voices.len() as i32).ok()?;
Some(i as u8)
});
if let Some(c) = self.mobs.iter_mut().find(|m| m.id == child) {
c.extra.sound_variant = voice;
}
}
}
#[cfg(test)]
mod tests {
use super::super::*;
/// `Cow`, `Pig`, `Chicken`, `Fox`, `Cat` and `Wolf.getBreedOffspring` draw
/// the offspring's coat from the breeding parent's own random, and only a
/// wolf picks a voice there (`WolfSoundVariants.pickRandomSoundVariant`,
/// that same random, after the collar colour's mix). The level's random
/// answers neither: two runs that differ only in the level's seed must
/// agree, and the parent's stream must read as vanilla's own draws in
/// vanilla's order.
#[test]
#[ignore = "needs prepared resources (HORIZON_TEST_RESOURCES)"]
fn a_pup_takes_its_coat_and_voice_from_the_parents_own_random() {
use horizon_core::random::{LegacyRandom, RandomSource};
// The voice registry, not a hardcoded count of wolf voices.
let voices = horizon_client::mobs::finalize::sound_variants(Species::Wolf).len() as i32;
assert!(voices > 1, "a wolf has several voices");
let cases = [
("minecraft:wolf", 2u8, 8u8),
("minecraft:wolf", 6, 0),
("minecraft:cow", 1, 0),
("minecraft:pig", 2, 0),
("minecraft:chicken", 0, 1),
("minecraft:fox", 1, 0),
("minecraft:cat", 4, 0),
];
let mut heard = std::collections::BTreeSet::new();
// One server game: its resources load once, and only the level's seed
// and the pair change between runs.
let mut game = super::super::super::native_machines::tests::server_game();
for (n, &(kind, own, mate)) in cases.iter().enumerate() {
// Independent entity randoms, kept clear of the ids the spawner
// hands out: the parent is `part`, the partner `mate_id`.
let parent_seed = 4_242 + 1_009 * n as i64;
let mut outcomes = Vec::new();
for (r, level_seed) in [11i64, 2_000_003].into_iter().enumerate() {
game.world.random = LegacyRandom::new(level_seed);
game.mobs.clear();
let (part, mate_id) = (7u32, 8u32);
// Ids the spawner hands out stay clear of the pair's.
game.next_entity = 1_000 + 10 * n as u32 + 2 * r as u32;
let t = game
.res
.mob_types()
.get(kind)
.unwrap_or_else(|| panic!("a {kind}"))
.clone();
for (id, variant) in [(part, own), (mate_id, mate)] {
let mut m = Mob::new(id, t.clone(), Vec3::new(8.5, 64.0, 8.5), 0.0);
m.random = LegacyRandom::new(parent_seed + i64::from(id));
m.extra.variant = variant;
m.extra.in_love = 600;
game.mobs.push(m);
}
game.breed(part, mate_id, None);
let child = game
.mobs
.iter()
.find(|m| m.id != part && m.id != mate_id)
.unwrap_or_else(|| panic!("{kind}: a pup"));
assert_eq!(child.extra.age, -24000, "{kind}: a baby");
// What the parent's own stream answers, draw for draw.
let mut expected = LegacyRandom::new(parent_seed + i64::from(part));
let coin = expected.next_boolean();
let coat = if coin { own } else { mate };
let voice = (kind == "minecraft:wolf").then(|| {
let i = expected.next_int_bound(voices).unwrap() as u8;
heard.insert(i);
i
});
assert_eq!(
child.extra.variant, coat,
"{kind}: the parent's coin picks the coat (level seed {level_seed})"
);
assert_eq!(
child.extra.sound_variant, voice,
"{kind}: the parent's next draw picks the voice (level seed {level_seed})"
);
// The stream ends where vanilla's does: the coin, a wolf's
// voice, then finalizeSpawnChildFromBreeding's 1-to-7 roll.
if game.ui.rules.bool("mob_drops") {
let _ = expected.next_int_bound(7);
}
let mut parent = game
.mobs
.iter()
.find(|m| m.id == part)
.unwrap()
.random
.clone();
for _ in 0..4 {
assert_eq!(
parent.next_int(),
expected.next_int(),
"{kind}: the parent's stream goes on where vanilla leaves it"
);
}
outcomes.push((child.extra.variant, child.extra.sound_variant));
}
assert_eq!(
outcomes[0], outcomes[1],
"{kind}: the level's seed does not decide the pup"
);
}
assert!(heard.len() > 1, "the wolf's voices vary: {heard:?}");
}
}
@@ -47,6 +47,9 @@ impl ProjectileWorld for ProjWorld<'_, '_> {
fn in_water(&self, bounds: Aabb) -> bool {
self.w.fluids(bounds, bounds.max_position().y).in_water
}
fn pushed_by_currents(&self, bounds: Aabb, velocity: Vec3) -> Vec3 {
self.w.pushed_by_currents(bounds, velocity)
}
fn targets(&self) -> &[(Who, Aabb)] {
&self.targets
}
@@ -1123,6 +1123,8 @@ impl Game<'_> {
let mut stand = horizon_client::entities::ArmorStand::new(id, at, yaw);
stand.marker = e["marker"].as_bool().unwrap_or(false);
stand.on_ground = e["ground"].as_bool().unwrap_or(false);
stand.show_arms = e["arms"].as_bool().unwrap_or(false);
stand.disabled_slots = e["disabled"].as_i64().unwrap_or(0) as i32;
if let Some(last_hit) = e["last_hit"].as_i64() {
stand.last_hit = last_hit;
}
+354 -47
View File
@@ -85,6 +85,14 @@ fn block_box(p: Pos) -> Aabb {
Vec3::new(f64::from(p.0 + 1), f64::from(p.1 + 1), f64::from(p.2 + 1)),
)
}
/// `EntitySectionStorage.forEachAccessibleNonEmptySection`: x ascending, then
/// the packed key (`SectionPos.asLong`: z as 22 unsigned bits, then y as 20);
/// within a section the entities go in the order they joined, which ids follow.
fn section_order(at: Vec3) -> (i32, u32, u32) {
let s = |v: f64| (v.floor() as i32) >> 4;
let (x, y, z) = (s(at.x), s(at.y), s(at.z));
(x, z as u32 & 0x3F_FFFF, y as u32 & 0xF_FFFF)
}
fn overlaps(a: Aabb, b: Aabb) -> bool {
let (a0, a1, b0, b1) = (
a.min_position(),
@@ -131,6 +139,8 @@ fn optional_event(behaviour: &'static str) -> i32 {
#[derive(Clone, Copy)]
enum Target {
Player,
/// A server player, by connection: the integrated owner and every guest.
Guest(u32),
Mob(usize),
Stand(usize),
}
@@ -459,7 +469,7 @@ impl Game<'_> {
r.triangle(0.2, spread),
r.triangle(f64::from(s[2]) * pow, spread),
];
self.spawn_item(stack, at, v, 0);
self.spawn_item_thrown(stack, at, v);
}
/// `DefaultDispenseItemBehavior.execute`: drop one item.
fn default_execute(
@@ -719,34 +729,57 @@ impl Game<'_> {
// ---- entities ----
/// Entities whose box overlaps the block at `p`, in the order the level
/// added them (an entity section keeps insertion order; ids grow with it).
/// Entities whose box overlaps the block at `p`, in `getEntitiesOfClass`
/// order ([`section_order`]): players are entities of the level like any
/// other, so they sort with the mobs and stands.
fn targets_at(&self, p: Pos) -> Vec<Target> {
let block = block_box(p);
let mut found: Vec<(u32, Target)> = self
let key = |at: Vec3, id: u32| (section_order(at), id);
let mut found: Vec<(_, Target)> = self
.mobs
.iter()
.enumerate()
.filter(|(_, m)| !m.removed && overlaps(m.bounds(), block))
.map(|(i, m)| (m.id, Target::Mob(i)))
.map(|(i, m)| (key(m.position, m.id), Target::Mob(i)))
.collect();
found.extend(
self.armor_stands
.iter()
.enumerate()
.filter(|(_, s)| overlaps(s.bounds(), block))
.map(|(i, s)| (s.id, Target::Stand(i))),
.map(|(i, s)| (key(s.position, s.id), Target::Stand(i))),
);
found.sort_by_key(|(id, _)| *id);
let mut out: Vec<Target> = found.into_iter().map(|(_, t)| t).collect();
// `ServerLevel.getEntities`: every player placed in this level, the
// integrated owner included (a server has no player of its own), its
// client loaded or not (it joins the level at placeNewPlayer/respawn).
if let Some(host) = &self.lan {
found.extend(
(host.hub.guests.iter())
.filter(|g| host.hub.here(g) && !g.dead && !g.spectator())
.filter(|g| overlaps(g.bounds(), block))
.map(|g| {
(
key(
Vec3::new(g.position[0], g.position[1], g.position[2]),
g.entity_id as u32,
),
Target::Guest(g.conn),
)
}),
);
}
if let Some(v) = self.player_view()
&& v.alive
&& !self.spectator()
&& overlaps(v.bounds, block)
{
out.push(Target::Player);
// The local player joins before the chunks around it load their
// entities (prepareLevels loads only saved tickets): the lowest id,
// 0 as `own_entity_id` has it.
found.push((key(v.position, 0), Target::Player));
}
out
found.sort_by_key(|(k, _)| *k);
found.into_iter().map(|(_, t)| t).collect()
}
/// `EntityType.getYOffset(level, pos, false, box)`: [`Self::drop_offset_from`] without
/// `movedUp`.
@@ -851,6 +884,22 @@ impl Game<'_> {
self.spawn_jockeys(id, false);
Some(id)
}
/// `Player.setItemSlot` of a dispensed piece for the player in place:
/// `onEquipItem` plays the sound and the event; the modifiers follow.
fn put_on_player(&mut self, slot: Slot, one: &ItemStack) {
let name = match slot.armor_index() {
Some(i) => {
self.armor[i] = Some(one.clone());
["head", "chest", "legs", "feet"][i]
}
None => {
self.offhand = Some(one.clone());
"offhand"
}
};
self.player_equipped(name, Some(one));
self.apply_equipment();
}
/// `EquipmentDispenseItemBehavior.dispenseEquipment`: the first living
/// entity in front that `canEquipWithDispenser` gets one of the item.
fn dispense_equipment(&mut self, pos: Pos, facing: Direction, stack: &mut ItemStack) -> bool {
@@ -887,8 +936,23 @@ impl Game<'_> {
_ => slot.armor_index().is_some_and(|i| self.armor[i].is_none()),
}
}
Target::Guest(conn) => {
allows("minecraft:player")
&& self
.lan
.as_ref()
.and_then(|h| h.hub.guest(conn))
.is_some_and(|g| match slot {
Slot::OffHand => g.offhand.is_none(),
_ => slot.armor_index().is_some_and(|i| g.armor[i].is_none()),
})
}
// ArmorStand.canUseSlot: not a locked slot, and a hand needs arms.
Target::Stand(i) => slot.stand_index().is_some_and(|s| {
allows("minecraft:armor_stand") && self.armor_stands[i].equipment[s].is_none()
let stand = &self.armor_stands[i];
allows("minecraft:armor_stand")
&& !stand.is_disabled(s)
&& stand.equipment[s].is_none()
}),
Target::Mob(i) => {
let m = &self.mobs[i];
@@ -905,20 +969,28 @@ impl Game<'_> {
stack.count -= 1;
// setItemSlot: onEquipItem plays the equip sound and the equip event.
let (at, sound) = match target {
Target::Guest(conn) => {
// The guest's own turn: their state in place, so the slot, the
// sound, the game event and the equipment sync run as for the host.
let done = self
.for_player(super::commands::Target::Guest(conn), |g| {
g.put_on_player(slot, &one)
})
.is_some();
if !done {
stack.count += 1;
return false;
}
self.native_block_dispense_armor(
self.dim.id(),
[pos.0, pos.1, pos.2],
&stack.item,
"player",
);
return true;
}
Target::Player => {
// Player.setItemSlot: onEquipItem plays the sound and the event.
let name = match slot.armor_index() {
Some(i) => {
self.armor[i] = Some(one.clone());
["head", "chest", "legs", "feet"][i]
}
None => {
self.offhand = Some(one.clone());
"offhand"
}
};
self.player_equipped(name, Some(&one));
self.apply_equipment();
self.put_on_player(slot, &one);
self.native_block_dispense_armor(
self.dim.id(),
[pos.0, pos.1, pos.2],
@@ -953,7 +1025,7 @@ impl Game<'_> {
}
let who = match target {
Target::Mob(i) => Some(horizon_client::mobs::Who::Mob(self.mobs[i].id)),
Target::Player | Target::Stand(_) => None,
Target::Player | Target::Guest(_) | Target::Stand(_) => None,
};
let ctx = self.who_context(who, None);
self.post_game_event("minecraft:equip", [at.x, at.y, at.z], ctx);
@@ -965,7 +1037,7 @@ impl Game<'_> {
match target {
Target::Stand(_) => "armor_stand",
Target::Mob(_) => "mob",
Target::Player => "player",
Target::Player | Target::Guest(_) => "player",
},
);
true
@@ -987,22 +1059,35 @@ impl Game<'_> {
self.mobs.insert(i, m);
self.handle_mob_events(events)
}
/// A mob a bucket emptied: `setFromBucket` keeps it, then it calls out.
/// Fish and axolotls carry the flag itself (saved as `FromBucket`, read by
/// `requiresCustomPersistence`); the others stay persistent instead.
pub(super) fn mob_placed(&mut self, id: u32) -> Result<()> {
/// A mob a bucket emptied (`MobBucketItem.spawn`): the bucket's components
/// and entity data (`loadFromBucketTag`), then `setFromBucket(true)`, which
/// `requiresCustomPersistence` reads, then it calls out. Only the bucket's
/// data can make it persistent, not the spawn: `spawn_mob_at` creates a
/// persistent mob. A tadpole's setter does nothing: its `fromBucket` is
/// always true.
pub(super) fn mob_placed(&mut self, id: u32, stack: &ItemStack) -> Result<()> {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
if matches!(
m.species(),
m.persistent = false;
horizon_client::entity_nbt::apply_item_components(m, stack);
match m.species() {
Species::Cod
| Species::Salmon
| Species::Pufferfish
| Species::TropicalFish
| Species::Axolotl
) {
m.extra.from_bucket = true;
} else {
m.persistent = true;
| Species::Salmon
| Species::Pufferfish
| Species::TropicalFish
| Species::Axolotl => m.extra.from_bucket = true,
Species::SulfurCube => {
// SulfurCube.loadFromBucketTag: its own lowercase keys, 0
// and false when absent, whatever `Age` the data holds.
let data = stack
.data()
.and_then(|c| c.other("minecraft:bucket_entity_data"));
let age = data.and_then(|d| horizon_client::save::number(d.get("age")));
m.extra.age = age.unwrap_or(0) as i32;
m.extra.age_locked =
data.is_some_and(|d| horizon_client::entity_nbt::flag(d, "age_locked"));
m.extra.scratch[3] = 1;
}
_ => {}
}
}
self.mob_ambient(id)
@@ -1353,13 +1438,10 @@ impl Game<'_> {
self.evaporation_smoke([target.0, target.1, target.2]);
}
if let Some(kind) = mob {
// MobBucketItem.checkExtraContent: the mob (from the bucket, so
// persistent) calls out, then ENTITY_PLACE.
// MobBucketItem.checkExtraContent: the mob from the bucket calls
// out, then ENTITY_PLACE.
if let Some(id) = self.spawn_mob_at(kind, target, true) {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
horizon_client::entity_nbt::apply_item_components(m, &stack);
}
self.mob_placed(id)?;
self.mob_placed(id, &stack)?;
}
self.post_block_game_event(
"minecraft:entity_place",
@@ -2083,7 +2165,7 @@ impl Game<'_> {
};
(Holder::Player, v.bounds.center(), None)
}
Target::Stand(_) => return false,
Target::Guest(_) | Target::Stand(_) => return false,
};
let area = Aabb::new(centre, centre).inflate(Vec3::new(16.0, 16.0, 16.0));
let mut leashed: Vec<(u32, usize)> = self
@@ -2334,4 +2416,229 @@ mod tests {
Some((None, 0))
);
}
use horizon_client::lan::{Joined, NetConfig, PlayerSender, guest::Guest, hub::HubConfig};
use horizon_protocol::packets::configuration::ClientInformation;
use std::sync::{Arc, atomic::AtomicI32, mpsc};
/// A server with no player of its own, an open hub and one guest (conn 2)
/// standing at (8.5, 200, 8.5) on a stone floor.
fn server_with_guest() -> Game<'static> {
let mut game = super::super::native_machines::tests::server_game();
game.build_registries();
game.set_allow_commands(true);
game.block_ticks
.add_empty_chunk(horizon_core::position::ChunkPos::new(0, 0))
.unwrap();
assert!(game.run_command("fill 0 199 0 15 199 15 stone"));
game.lan_host(
super::super::lan::LanSettings {
port: 0,
guest_commands: true,
force_game_mode: false,
},
NetConfig {
announce: false,
..NetConfig::loopback(0, "dispenser-equip-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 (tx, _rx) = mpsc::channel();
let mut guest = Guest::new(
Joined {
conn: 2,
uuid: 208,
name: "Wearer".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(),
},
2,
[8.5, 200.0, 8.5],
2,
);
guest.dimension = "minecraft:overworld".into();
guest.placed = true;
guest.loaded = true;
game.lan.as_mut().unwrap().hub.guests.push(guest);
game
}
fn dispense_at_guest(game: &mut Game<'_>, item: &str) -> (bool, u32) {
let mut stack = ItemStack::new(item, 2);
// The dispenser sits one block over the guest's feet, facing down.
let done = game.dispense_equipment((8, 201, 8), Direction::Down, &mut stack);
(done, stack.count)
}
/// `EquipmentDispenseItemBehavior`: a server's player, the integrated
/// owner or a guest, takes the piece (a server has no player of its own).
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_dispenser_equips_a_server_player_in_front() {
let mut game = server_with_guest();
assert!(game.player.is_none(), "the server thread has no player");
// The guest's feet are in block (8, 200, 8): the dispenser at y 201
// facing down targets the block under it.
let mut stack = ItemStack::new("minecraft:carved_pumpkin", 3);
assert!(game.dispense_equipment((8, 201, 8), Direction::Down, &mut stack));
assert_eq!(stack.count, 2);
let guest = game.lan.as_ref().unwrap().hub.guest(2).unwrap();
assert_eq!(
guest.armor[0].as_ref().map(|s| (&*s.item, s.count)),
Some(("minecraft:carved_pumpkin", 1))
);
// The head is taken now: a second pumpkin is not dispensed onto them.
assert!(!game.dispense_equipment((8, 201, 8), Direction::Down, &mut stack));
assert_eq!(stack.count, 2);
// A boots piece still has its own empty slot.
let (done, left) = dispense_at_guest(&mut game, "minecraft:iron_boots");
assert_eq!((done, left), (true, 1), "boots go on the free feet slot");
let guest = game.lan.as_ref().unwrap().hub.guest(2).unwrap();
assert!(guest.armor[3].is_some() && guest.armor[1].is_none());
}
/// A spectator is not a target (`canEquipWithDispenser`: `!isSpectator()`).
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_dispenser_skips_a_spectator_and_a_dead_player() {
let mut game = server_with_guest();
for spectate in [true, false] {
let g = &mut game.lan.as_mut().unwrap().hub.guests[0];
g.game_mode = if spectate { 3 } else { 0 };
g.dead = !spectate;
let mut stack = ItemStack::new("minecraft:iron_helmet", 1);
assert!(!game.dispense_equipment((8, 201, 8), Direction::Down, &mut stack));
assert_eq!(stack.count, 1);
}
}
/// `ArmorStand.canUseSlot` through `isDisabled`: the first stand in front
/// that can wear the piece gets it. Bit `1 << filterBit` locks a slot
/// (head 4, chest 3, legs 2, feet 1); a hand needs arms.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_dispenser_skips_an_armor_stand_whose_slot_is_locked() {
use horizon_client::entities::ArmorStand;
let mut game = server_with_guest();
game.lan.as_mut().unwrap().hub.guests.clear();
let at = horizon_core::geometry::Vec3::new(8.5, 200.0, 8.5);
let mut stands = [(1, 1u32 << 4), (2, 0)].map(|(id, locked)| {
let mut s = ArmorStand::new(id, at, 0.0);
s.disabled_slots = locked as i32;
s
});
stands[0].show_arms = true;
game.armor_stands = stands.to_vec().into();
// The head-locked first stand is skipped for a helmet; the second wears it.
let mut stack = ItemStack::new("minecraft:iron_helmet", 1);
assert!(game.dispense_equipment((8, 201, 8), Direction::Down, &mut stack));
assert!(game.armor_stands[0].equipment[5].is_none());
assert!(game.armor_stands[1].equipment[5].is_some());
// The same stand takes chest armour: only its head is locked.
let mut stack = ItemStack::new("minecraft:iron_chestplate", 1);
assert!(game.dispense_equipment((8, 201, 8), Direction::Down, &mut stack));
assert!(game.armor_stands[0].equipment[4].is_some());
// Every stand locked for that slot: nothing takes it (the dispenser ejects it).
game.armor_stands[0].disabled_slots = 1 << 4;
game.armor_stands[1].disabled_slots = 1 << 4;
game.armor_stands[1].equipment[5] = None;
let mut stack = ItemStack::new("minecraft:iron_helmet", 1);
assert!(!game.dispense_equipment((8, 201, 8), Direction::Down, &mut stack));
assert_eq!(stack.count, 1);
}
/// A stand of entity id `id` standing at `x`, 200, `z`.
fn stand_at(id: u32, x: f64, z: f64) -> horizon_client::entities::ArmorStand {
let at = horizon_core::geometry::Vec3::new(x, 200.0, z);
horizon_client::entities::ArmorStand::new(id, at, 0.0)
}
/// A guest whose client is still on its loading screen (joined or
/// respawned, no ServerboundPlayerLoadedPacket yet) is in the level all the
/// same: `getEntitiesOfClass` finds it and it wears the piece.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_dispenser_equips_a_player_whose_client_is_still_loading() {
let mut game = server_with_guest();
let g = &mut game.lan.as_mut().unwrap().hub.guests[0];
(g.loaded, g.loading) = (false, true);
let (done, left) = dispense_at_guest(&mut game, "minecraft:iron_helmet");
assert_eq!((done, left), (true, 1));
let guest = game.lan.as_ref().unwrap().hub.guest(2).unwrap();
let head = guest.armor[0].as_ref().map(|s| &*s.item);
assert_eq!(head, Some("minecraft:iron_helmet"));
}
/// Players are entities of the level: in one section they go in join
/// order (entity ids) with the mobs and stands, not after them.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_player_and_a_stand_in_front_go_in_join_order() {
// (stand id, guest id) -> who wears the helmet: the lower id joined first.
for (stand_id, guest_id, guest_first) in [(7, 5, true), (3, 5, false)] {
let mut game = server_with_guest();
game.lan.as_mut().unwrap().hub.guests[0].entity_id = guest_id;
game.armor_stands = vec![stand_at(stand_id, 8.5, 8.5)].into();
let (done, _) = dispense_at_guest(&mut game, "minecraft:iron_helmet");
assert!(done);
let guest = game.lan.as_ref().unwrap().hub.guest(2).unwrap();
assert_eq!(
guest.armor[0].is_some(),
guest_first,
"stand {stand_id}, guest {guest_id}"
);
assert_eq!(game.armor_stands[0].equipment[5].is_some(), !guest_first);
}
}
/// `forEachAccessibleNonEmptySection` walks sections before join order:
/// a stand whose feet are in section x 1 but whose box reaches into block
/// x 15 comes after one in section x 0, even with the lower id.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_dispenser_walks_entity_sections_before_ids() {
let mut game = server_with_guest();
game.lan.as_mut().unwrap().hub.guests.clear();
// Stand boxes are 0.5 wide: x 16.2 spans 15.95..16.45.
game.armor_stands = vec![stand_at(1, 16.2, 8.5), stand_at(2, 15.5, 8.5)].into();
let mut stack = ItemStack::new("minecraft:iron_helmet", 1);
assert!(game.dispense_equipment((15, 201, 8), Direction::Down, &mut stack));
assert!(game.armor_stands[0].equipment[5].is_none());
assert!(game.armor_stands[1].equipment[5].is_some());
}
/// `SectionPos.asLong` packs z (22 bits) over y (20 bits) under a fixed x,
/// and the section set is sorted as longs: z -1 packs as 0x3FFFFF << 20,
/// above z 0's 0; y -1 at z 0 packs as 0xFFFFF, below z 1's 1 << 20.
#[test]
fn sections_sort_as_their_packed_keys() {
use horizon_core::geometry::Vec3;
let at = |x: f64, y: f64, z: f64| super::section_order(Vec3::new(x, y, z));
assert!(at(0.0, 0.0, -1.0) > at(0.0, 0.0, 0.0));
assert!(at(0.0, -1.0, 0.0) < at(0.0, 0.0, 16.0));
assert!(at(0.0, -1.0, 0.0) > at(0.0, 0.0, 0.0));
// x is the outer loop, ascending as a signed coordinate.
assert!(at(-1.0, 0.0, 0.0) < at(0.0, 0.0, 0.0));
assert!(at(15.9, 0.0, 0.0) < at(16.0, 0.0, 0.0));
}
}
@@ -285,3 +285,109 @@ fn a_teleporting_shulker_leaves_its_rider_behind() {
}
assert!(teleported > 0, "no shulker teleported");
}
/// `Shulker.teleportSomewhere`: the teleport posts `GameEvent.TELEPORT` at
/// the block it left, between its sound and level event 2016, so a sculk
/// sensor where it stood hears the shulker arrive.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_teleporting_shulker_posts_teleport_where_it_stood() {
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let mut game = floored_game(&root);
game.build_registries();
game.set_allow_commands(true);
// A sensor just above the shulker hears the vibration where it stands.
game.sculk_prepare();
assert!(game.run_command("setblock 8 5 8 sculk_sensor"));
game.sculk_sync_index();
let at = Vec3::new(8.5, 4.0, 8.5);
let old = [at.x, at.y, at.z];
// `LevelAccessor.gameEvent(Holder, BlockPos, ..)` centers the block.
let old_center = [at.x, at.y + 0.5, at.z];
let mut teleported = false;
for seed in 0..40 {
let id = 900 + seed as u32;
let mut mob = shulker(&game, id, at, seed);
// What `after_hurt` leaves for a low-health teleport.
mob.extra.flag = true;
let uuid = game.level.player_uuid;
let others: Vec<_> = game
.mobs
.iter()
.map(|m| super::creatures::seen(m, uuid))
.collect();
let mut level_random = game.world.random.clone();
let mut events = Vec::new();
{
let world = game.mob_world_base(&others);
horizon_client::mobs::shulker::run_pending_teleports(
&mut mob,
&world,
&mut level_random,
&mut events,
);
}
if mob.position.distance(at) <= 0.5 {
continue;
}
teleported = true;
let sound = events.iter().position(|e| {
matches!(e, MobEvent::Sound(name, p, ..) if *name == "entity.shulker.teleport" && *p == old)
});
let teleport = events.iter().position(|e| {
matches!(
e,
MobEvent::GameEvent { event, at: p, source, .. }
if *event == "minecraft:teleport" && *p == old_center && *source == id
)
});
let level = events.iter().position(
|e| matches!(e, MobEvent::LevelEvent { id: 2016, pos, .. } if *pos == [8, 4, 8]),
);
assert!(
sound.is_some(),
"seed {seed}: no teleport sound: {events:?}"
);
assert!(
teleport.is_some(),
"seed {seed}: no minecraft:teleport game event: {events:?}"
);
assert!(
level.is_some(),
"seed {seed}: no level event 2016: {events:?}"
);
assert!(
sound < teleport && teleport < level,
"seed {seed}: the game event is not between the sound and level event: {events:?}"
);
game.mobs.push(mob);
super::sculk::TEST_POSTED_EVENTS.with(|events| *events.borrow_mut() = Some(Vec::new()));
game.handle_mob_events(events).unwrap();
let posted: Vec<_> = super::sculk::TEST_POSTED_EVENTS.with(|events| {
events
.borrow_mut()
.take()
.unwrap_or_default()
.into_iter()
.filter(|(event, _, _)| *event == "minecraft:teleport")
.map(|(_, pos, source)| (pos, source))
.collect()
});
assert_eq!(
posted,
[(old_center, Some(horizon_client::mobs::Who::Mob(id)))],
"seed {seed}: the teleport event is posted from the shulker at its old position"
);
// The sensor above the old block holds that vibration.
game.chat_capture = Some(Vec::new());
assert!(game.run_command("data get block 8 5 8 listener.selector.event"));
let said = game.chat_capture.take().unwrap_or_default();
let said: String = said.iter().map(horizon_client::text::Text::plain).collect();
assert!(
said.contains("minecraft:teleport") && said.contains("[8.5d, 4.5d, 8.5d]"),
"seed {seed}: the sensor did not hear the teleport: {said}"
);
break;
}
assert!(teleported, "no shulker teleported");
}
+41 -1
View File
@@ -262,6 +262,14 @@ pub(super) trait Entity: EntityRules {
fn is_vehicle(&self) -> bool {
false
}
/// `Entity.getDimensionChangingDelay`, overridden by vehicles and projectiles.
fn dimension_changing_delay(&self) -> i32 {
horizon_client::nether_portal::ENTITY_DELAY
}
/// Whether movement applies portal block effects before Entity's base tick.
fn portal_after_move(&self) -> bool {
false
}
/// Where it stood at the start of its tick, for the kinds that store it
/// (None: `portals.before` has it).
fn portal_from(&self) -> Option<Vec3> {
@@ -323,7 +331,14 @@ impl RenderEntityBounds for Mob {
self.species(),
Species::Guardian | Species::ElderGuardian | Species::Phantom
) {
let radius = width.max(height) * 2.5 + 3.0;
// The model's root scales its appendages too (the elder guardian
// uses 2.35), so the margin must grow along with the model.
let root_scale = self.kind.model.pose[6..]
.iter()
.copied()
.map(f32::abs)
.fold(1.0, f32::max);
let radius = width.max(height) * 2.5 + 3.0 * f64::from(root_scale * self.model_scale());
return Aabb::of_size(at, Vec3::new(radius * 2.0, radius * 2.0, radius * 2.0));
}
@@ -1322,6 +1337,9 @@ impl Entity for ExperienceOrb {
}
impl Entity for Boat {
fn dimension_changing_delay(&self) -> i32 {
10
}
fn set_on_ground(&mut self, on_ground: bool) {
self.on_ground = on_ground;
}
@@ -1397,6 +1415,9 @@ impl Entity for Boat {
}
impl Entity for Minecart {
fn dimension_changing_delay(&self) -> i32 {
10
}
fn set_on_ground(&mut self, on_ground: bool) {
self.on_ground = on_ground;
}
@@ -1723,6 +1744,16 @@ impl Entity for FallingBlock {
}
impl Entity for Projectile {
fn portal_after_move(&self) -> bool {
!matches!(
self.kind,
horizon_client::projectiles::ProjectileKind::LlamaSpit
| horizon_client::projectiles::ProjectileKind::ShulkerBullet { .. }
)
}
fn dimension_changing_delay(&self) -> i32 {
2
}
fn rotation(&self) -> [f32; 2] {
[self.yaw, self.pitch]
}
@@ -1843,6 +1874,9 @@ impl Entity for Projectile {
}
impl Entity for Rocket {
fn dimension_changing_delay(&self) -> i32 {
2
}
fn rotation(&self) -> [f32; 2] {
self.rotation
}
@@ -1910,6 +1944,12 @@ impl Entity for Rocket {
}
impl Entity for ThrownTrident {
fn portal_after_move(&self) -> bool {
true
}
fn dimension_changing_delay(&self) -> i32 {
2
}
fn rotation(&self) -> [f32; 2] {
[self.yaw, self.pitch]
}
+157 -1
View File
@@ -1130,7 +1130,8 @@ impl Game<'_> {
}
if let Some(entity) = self.world.block_entities.get(&key).cloned() {
let custom = self.world.block_names.get(&key).cloned();
self.pending.record(pos, &entity, &name, custom);
self.pending
.record(pos, &entity, &name, custom, self.world.block_lock(key));
} else if let Some(tag) = super::spill::saved_entity(&self.world, pos) {
self.pending.record_saved(pos, &tag);
}
@@ -1793,3 +1794,158 @@ mod tests {
}
}
}
#[cfg(test)]
mod shulker_lock_drop_tests {
use super::super::native_machines::tests::server_game;
use super::*;
use horizon_client::{containers::BlockEntity, save};
use horizon_world::nbt::Tag;
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and scratch homes/cwd"]
fn destruction_captures_the_external_shulker_lock_before_removal() {
let mut game = server_game();
game.build_registries();
game.set_allow_commands(true);
game.block_ticks
.add_empty_chunk(BlockPos::new(0, 0, 0).chunk())
.unwrap();
assert!(game.run_command("fill 0 199 0 15 199 15 stone"));
assert!(
game.ui.rules.bool("block_drops"),
"actual drop rule must be enabled"
);
game.creative = false;
game.selected = 0;
game.inventory[0] = Some(ItemStack::new("minecraft:diamond_pickaxe", 1));
let clock = game.world.game_time;
let mut missing = Vec::new();
for (x, z, kind, locked, route) in [
(3, 3, "shulker_box", true, "command"),
(11, 3, "shulker_box", true, "player"),
(3, 11, "shulker_box", true, "explosion"),
(11, 11, "red_shulker_box", false, "command"),
(7, 3, "chest", true, "command"),
(7, 11, "crafter", true, "command"),
] {
let shulker = kind.ends_with("shulker_box");
let mut fields = Vec::new();
if shulker {
fields.push("CustomName:\"Box\"");
fields.push("Items:[{Slot:0b,id:\"minecraft:diamond\",count:3,components:{\"minecraft:custom_data\":{h211:1}}},{Slot:26b,id:\"minecraft:apple\",count:5}]");
}
if locked {
fields.push("lock:{items:\"minecraft:diamond\"}");
}
assert!(game.run_command(&format!(
"setblock {x} 200 {z} minecraft:{kind}{{{}}}",
fields.join(",")
)));
let pos = [x, 200, z];
let key = (x, 200, z);
assert_eq!(
&*game.res.facts().get(game.world.block(x, 199, z)).name,
"minecraft:stone",
"positive floor"
);
let actual_lock = game.world.block_lock(key).cloned();
assert_eq!(actual_lock.is_some(), locked, "actual external source lock");
if locked {
assert!(matches!(&actual_lock,Some(Tag::Compound(c)) if !c.is_empty()));
}
let entity = game
.world
.block_entities
.get(&key)
.expect("actual command-created source");
assert!(
save::encode_block_entity(key, entity, &format!("minecraft:{kind}"))
.get("lock")
.is_none(),
"external lock must not be invented in modeled serializer"
);
if shulker {
let BlockEntity::Chest(slots) = entity else {
panic!("modeled shulker inventory missing");
};
assert_eq!(slots[0].as_ref().unwrap().count, 3);
assert_eq!(slots[26].as_ref().unwrap().count, 5);
assert_eq!(
game.world.block_names.get(&key),
Some(&Tag::String("Box".into()))
);
}
let start = game.dropped.len();
match route {
"command" => {
assert!(game.run_command(&format!("setblock {x} 200 {z} air destroy")))
}
"player" => game.break_block(pos).unwrap(),
"explosion" => game
.server_explode(
pos.map(|v| f64::from(v) + 0.5),
2.0,
BlockInteraction::Destroy,
false,
)
.unwrap(),
_ => unreachable!(),
}
game.flush_spill();
assert!(
game.res.facts().is_air(game.world.block(x, 200, z)),
"actual destruction occurred: {route}"
);
assert!(!game.world.block_entities.contains_key(&key));
assert!(
game.world.block_lock(key).is_none(),
"actual external source was removed"
);
let item_id = format!("minecraft:{kind}");
let drops: Vec<_> = game.dropped[start..]
.iter()
.filter(|d| *d.stack.item == item_id)
.collect();
assert_eq!(drops.len(), 1, "actual one-item drop: {kind}/{route}");
let stack = &drops[0].stack;
assert_eq!(stack.count, 1);
let data = stack.data();
let dropped_lock = data.and_then(|d| d.other("minecraft:lock"));
if shulker {
let data = data.expect("actual shulker components");
assert_eq!(data.custom_name, Some(Tag::String("Box".into())));
let slots = &data.container;
assert_eq!(slots.len(), 2);
let diamond = &slots.iter().find(|(slot, _)| *slot == 0).unwrap().1;
assert_eq!((&*diamond.item, diamond.count), ("minecraft:diamond", 3));
assert_eq!(
diamond
.data()
.and_then(|d| d.other("minecraft:custom_data"))
.and_then(|t| t.get("h211")),
Some(&Tag::Int(1))
);
assert_eq!(
slots.iter().find(|(slot, _)| *slot == 26).unwrap().1.count,
5
);
}
if shulker && locked {
if dropped_lock != actual_lock.as_ref() {
missing.push((route, dropped_lock.cloned(), actual_lock));
}
} else {
assert!(
dropped_lock.is_none(),
"non-shulker/unlocked policy unchanged"
);
}
assert_eq!(game.world.game_time, clock, "zero observation ticks");
}
assert!(
missing.is_empty(),
"actual external lock lost at destruction capture sites: {missing:?}"
);
}
}
@@ -3,6 +3,66 @@
use super::*;
impl Game<'_> {
/// Cache only model-dependent work, never an entity's seed, light or pose.
pub(super) fn falling_model_vertices(
&self,
state: BlockStateId,
start_pos: BlockPos,
origin: glam::Vec3,
light: u32,
out: &mut Vec<Vertex>,
) {
// `FallingBlockRenderer`: the model is picked by the block's starting position.
let seed = horizon_core::random::position_seed(start_pos);
let Ok(models) = self.pack.render.model_catalog.collect_seeded(state, seed) else {
return;
};
// Baked models live at stable addresses until the pack is replaced.
// Keep multipart order and duplicates: both affect the emitted vertices.
let mut cache = self.falling_vertices.borrow_mut();
let entries = cache.entry(state).or_default();
let index = entries.iter().position(|(ptrs, _)| {
ptrs.iter()
.copied()
.eq(models.iter().map(|(_, m)| *m as *const _ as usize))
});
let index = index.unwrap_or_else(|| {
let mut vertices = Vec::new();
self.block_model_vertices(state, seed, glam::Mat4::IDENTITY, 0, false, &mut vertices);
// Preserve the original coordinates, including signed zero, rather
// than composing an identity transform with the frame's translation.
let positions = models.iter().flat_map(|(_, model)| {
model
.faces
.iter()
.filter(|face| {
self.pack
.layer_names
.get(&face.layer)
.is_some_and(|name| self.gui.atlas.sprites.contains_key(name))
})
.flat_map(|face| face.quad.positions)
});
for (vertex, pos) in vertices.iter_mut().zip(positions) {
vertex.pos = pos;
}
let ptrs = models
.iter()
.map(|(_, m)| *m as *const _ as usize)
.collect();
entries.push((ptrs, vertices));
entries.len() - 1
});
let start = out.len();
out.extend_from_slice(&entries[index].1);
let matrix = glam::Mat4::from_translation(origin);
for vertex in &mut out[start..] {
vertex.pos = matrix
.transform_point3(glam::Vec3::from_array(vertex.pos))
.to_array();
vertex.light = light;
}
}
/// FallingBlockEntity.tick: fall with 0.04 gravity and 0.98F drag, then land.
/// One falling block's `tick`: places or drops its block in its own tick,
/// so a block falling onto one that just landed lands on top of it. `i` is
@@ -14,6 +74,25 @@ impl Game<'_> {
/// shifted every block after it twice, for every falling block, every tick.
/// One that lands, drops or goes leaves the list before its block is placed
/// or dropped, as before; on an error the list keeps it as it was.
/// The end of a piston's `Entity.move(MoverType.PISTON, …)` for falling
/// block `i` after a step of `moved`: `checkFallDamage` adds the drop and,
/// on the ground, runs fallOn and posts HIT_GROUND after a fall, then
/// forgets it, so its own next tick does not land again.
pub(super) fn finish_falling_piston_move(&mut self, i: usize, moved: Vec3) {
let mut f = self.falling[i].clone();
if moved.y < 0.0 {
f.fall_distance -= f64::from(moved.y as f32);
}
if f.on_ground {
self.falling_block_impact(&mut f);
if f.fall_distance > 0.0 {
let bounds = horizon_client::falling_block::bounds(f.position);
self.post_landing_event(f.position, bounds, false);
}
f.fall_distance = 0.0;
}
self.falling[i] = f;
}
pub(super) fn tick_one_falling(&mut self, i: usize, entities: &[Aabb]) -> Result<()> {
let entity_drops = self.ui.rules.bool("entity_drops");
let join = self.falling[i].join;
@@ -134,7 +213,12 @@ impl Game<'_> {
f.velocity.z = 0.0;
}
if on_ground {
// fallOn (causeFallDamage), then HIT_GROUND after a fall.
self.falling_block_impact(f);
if f.fall_distance > 0.0 {
let bounds = horizon_client::falling_block::bounds(f.position);
self.post_landing_event(f.position, bounds, false);
}
f.fall_distance = 0.0;
}
// applyEffectsFromBlocks: WebBlock.entityInside makes it stuck
@@ -233,3 +317,98 @@ impl Game<'_> {
}
}
}
#[cfg(test)]
mod geometry_tests {
use super::*;
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn falling_model_cache_matches_direct_vertices() {
let root = PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
Instant::now(),
false,
false,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = execution_role::ExecutionRole::IntegratedServer;
let game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
for frame in 0..2 {
for (name, pos, origin, light) in [
(
"minecraft:sand",
BlockPos::new(1, 80, 3),
glam::Vec3::new(-3.25, 12.5, 0.0),
0x7010,
),
(
"minecraft:sand",
BlockPos::new(9, 90, -4),
glam::Vec3::new(8.125, -0.0, -9.5),
0xf020,
),
(
"minecraft:gravel",
BlockPos::new(-2, 70, 5),
glam::Vec3::new(-0.0, -17.75, 2.5),
0x5080,
),
] {
let state = game.pack.assets.registry.default_state(name).unwrap();
let seed = horizon_core::random::position_seed(pos);
let mut expected = Vec::new();
game.block_model_vertices(
state,
seed,
glam::Mat4::from_translation(origin),
light,
false,
&mut expected,
);
let mut actual = Vec::new();
game.falling_model_vertices(state, pos, origin, light, &mut actual);
let (_, tint) = game.block_face_sprite(state).unwrap();
let tint = horizon_client::mobs::render::abgr(tint);
for vertices in [&mut expected, &mut actual] {
for v in vertices {
v.color = horizon_client::hud_layout::multiply(v.color, tint);
}
}
assert!(!expected.is_empty());
assert_eq!(actual.len(), expected.len());
for (a, b) in actual.iter().zip(&expected) {
assert_eq!(
a.pos.map(f32::to_bits),
b.pos.map(f32::to_bits),
"frame {frame}"
);
assert_eq!(a.uv.map(f32::to_bits), b.uv.map(f32::to_bits));
assert_eq!(a.glint_uv.map(f32::to_bits), b.glint_uv.map(f32::to_bits));
assert_eq!(
(a.color, a.light, a.normal, a.layer),
(b.color, b.light, b.normal, b.layer)
);
}
}
}
}
}
+35 -2
View File
@@ -342,6 +342,8 @@ impl Game<'_> {
if !self.creative {
self.consume_held();
}
// `ItemStack.useOn` awards ITEM_USED for `FireworkRocketItem.useOn`'s SUCCESS.
self.award_stat("used", "minecraft:firework_rocket", 1);
self.swing_arm();
true
}
@@ -571,6 +573,8 @@ impl Game<'_> {
host.hub
.entity_event(r.id as i32, FIREWORK_EXPLODE_EVENT, at);
}
// `this.gameEvent(GameEvent.EXPLODE, this.getOwner())`, before the blast.
self.rocket_explode_event(r.owner, r.guest, at);
if !r.explosions.is_empty() {
let damage = 5.0 + r.explosions.len() as f32 * 2.0;
let from = r.position;
@@ -648,9 +652,34 @@ impl Game<'_> {
let camera = self.eye_position().unwrap_or(at);
let v = [r.velocity.x, r.velocity.y, r.velocity.z];
self.particles
.fireworks(at, v, r.explosions.clone(), camera);
.fireworks(at, v, r.explosions.clone(), camera, !r.silent);
self.native_firework_explode(r.id, at, r.explosions.len());
}
/// `FireworkRocketEntity.explode`'s game event: EXPLODE at the rocket, from
/// its owner (`Projectile.getOwner`), a guest's rocket as theirs.
fn rocket_explode_event(&mut self, mut owner: Option<Who>, guest: Option<u32>, at: [f64; 3]) {
if let Some(conn) = guest.filter(|conn| self.lan_acting != Some(*conn)) {
let posted = if let Some(mut host) = self.lan.take() {
let posted = self
.as_guest(&mut host, conn, |g| {
let ctx = g.who_context(owner, None);
g.post_game_event("minecraft:explode", at, ctx);
})
.is_some();
self.lan = Some(host);
posted
} else {
false
};
if posted {
return;
}
// An owner who left cannot resolve to the host: no source entity.
owner = None;
}
let ctx = self.who_context(owner, None);
self.post_game_event("minecraft:explode", at, ctx);
}
fn rocket_hurt_mobs(&mut self, hits: &[(u32, f32)], attacker: Option<Who>, at: [f64; 3]) {
let _attacker = self.knockback_scope(if attacker == Some(Who::Player) {
self.player.as_ref().map_or(0, |p| p.entity_id)
@@ -751,7 +780,7 @@ impl Game<'_> {
let at = [r.position.x, r.position.y, r.position.z];
let v = [r.velocity.x, r.velocity.y, r.velocity.z];
self.particles
.fireworks(at, v, r.explosions.clone(), camera.unwrap_or(at));
.fireworks(at, v, r.explosions.clone(), camera.unwrap_or(at), !r.silent);
}
}
/// `dealExplosionDamage` on a player that is not the rocket's
@@ -861,6 +890,10 @@ fn use_on_passes(fall_flying: bool) -> bool {
fall_flying
}
#[cfg(test)]
#[path = "fireworks_events_tests.rs"]
mod events_tests;
#[cfg(test)]
mod tests {
/// A rocket draws its UUID's two longs first, then the motion's two triangles
@@ -0,0 +1,248 @@
//! `FireworkRocketEntity.explode`'s game event and sound gate, and the
//! `ITEM_USED` an `useOn` counts. Prepared resources only.
use super::Game;
use crate::game::{Audio, Options, Progress, Settings, load_resources};
use horizon_client::{
chunk::Chunk,
items::ItemStack,
mobs::Who,
particles::{FireworkExplosion, FireworkShape, ParticleLevel},
};
use horizon_core::{geometry::Vec3, position::ChunkPos, random::LegacyRandom};
use std::sync::Arc;
fn with_game(test: impl FnOnce(&mut Game<'_>)) {
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.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.world
.chunks
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
game.block_ticks
.add_empty_chunk(ChunkPos::new(0, 0))
.unwrap();
game.player = Some(
horizon_client::motion::PlayerMotion::new(game.motion, Vec3::new(2.5, 200.0, 2.5)).unwrap(),
);
game.world.random = LegacyRandom::new(114);
test(&mut game);
}
/// One `small_ball` that twinkles, as `/summon` reads it: a `Starter` of 16
/// ticks that plays a blast first and a twinkle last.
fn twinkling() -> FireworkExplosion {
FireworkExplosion {
shape: FireworkShape::SmallBall,
colors: vec![0xFF5555],
fade: Vec::new(),
trail: true,
twinkle: true,
}
}
fn tick_particles(game: &mut Game<'_>) {
let level = ParticleLevel {
world: &game.world,
facts: game.res.facts(),
collision: game.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
game.particles.tick(&level);
}
/// `FireworkParticles.Starter.playSound`: a silent rocket launches quietly (as
/// it already did) and bursts quietly too, drawing its sparks and flash only.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_silent_rocket_bursts_without_blast_or_twinkle() {
with_game(|game| {
let stack = ItemStack::new("minecraft:firework_rocket", 1);
let mut launching = game.new_rocket(
Vec3::new(6.5, 201.5, 10.5),
&stack,
false,
&mut LegacyRandom::new(0),
);
launching.silent = true;
launching.lifetime = 100;
game.rockets.push(launching);
game.audio.capture = Some(Vec::new());
game.tick_rockets();
let heard = game.audio.capture.take().unwrap_or_default();
assert!(
heard
.iter()
.all(|s| s.event != "minecraft:entity.firework_rocket.launch"),
"a silent rocket launches quietly: {heard:?}"
);
game.rockets.clear();
let (quiet_at, loud_at) = ([6.5, 201.5, 10.5], [12.5, 201.5, 10.5]);
let mut quiet = game.new_rocket(
Vec3::new(quiet_at[0], quiet_at[1], quiet_at[2]),
&stack,
false,
&mut LegacyRandom::new(0),
);
quiet.silent = true;
quiet.explosions = vec![twinkling()];
let mut loud = game.new_rocket(
Vec3::new(loud_at[0], loud_at[1], loud_at[2]),
&stack,
false,
&mut LegacyRandom::new(0),
);
loud.explosions = vec![twinkling()];
game.explode_rocket(&quiet);
game.explode_rocket(&loud);
game.particles.sounds.clear();
for _ in 0..20 {
tick_particles(game);
}
let bursts: Vec<(&str, [f64; 3])> = game
.particles
.sounds
.iter()
.filter(|s| s.0.starts_with("minecraft:entity.firework_rocket."))
.map(|s| (s.0, s.1))
.collect();
assert_eq!(
bursts,
vec![
("minecraft:entity.firework_rocket.blast", loud_at),
("minecraft:entity.firework_rocket.twinkle", loud_at),
],
"only the loud rocket bangs and twinkles, both at its own position"
);
assert!(
!game.particles.is_empty(),
"the silent rocket still drew its sparks and flash"
);
});
}
/// Recording of `Game::post_game_event`, as the sculk tests read it.
fn start_events() {
crate::game::sculk::TEST_POSTED_EVENTS.with(|e| *e.borrow_mut() = Some(Vec::new()));
}
fn take_explosions() -> Vec<(&'static str, [f64; 3], Option<Who>)> {
crate::game::sculk::TEST_POSTED_EVENTS.with(|e| {
std::mem::take(e.borrow_mut().as_mut().unwrap())
.into_iter()
.filter(|e| e.0 == "minecraft:explode")
.collect()
})
}
fn stop_events() {
crate::game::sculk::TEST_POSTED_EVENTS.with(|e| *e.borrow_mut() = None);
}
/// `FireworkRocketEntity.explode`: `gameEvent(GameEvent.EXPLODE, getOwner())` at
/// the rocket, once, with and without explosions; a rocket nobody owns (a
/// dispenser's) has no source entity.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_detonating_rocket_posts_one_explode_from_its_owner() {
with_game(|game| {
let stack = ItemStack::new("minecraft:firework_rocket", 1);
let mut burst = game.new_rocket(
Vec3::new(8.5, 202.0, 8.5),
&stack,
false,
&mut LegacyRandom::new(0),
);
burst.explosions = vec![twinkling()];
burst.owner = Some(Who::Player);
let mut plain = game.new_rocket(
Vec3::new(9.5, 202.0, 8.5),
&stack,
false,
&mut LegacyRandom::new(0),
);
plain.owner = Some(Who::Player);
let mut stray = game.new_rocket(
Vec3::new(10.5, 202.0, 8.5),
&stack,
false,
&mut LegacyRandom::new(0),
);
stray.explosions = vec![twinkling()];
start_events();
game.explode_rocket(&burst);
assert_eq!(
take_explosions(),
vec![("minecraft:explode", [8.5, 202.0, 8.5], Some(Who::Player))],
"the burst is heard once, at the rocket, from the player who shot it"
);
game.explode_rocket(&plain);
assert_eq!(
take_explosions(),
vec![("minecraft:explode", [9.5, 202.0, 8.5], Some(Who::Player))],
"a rocket with no explosions still posts it"
);
game.explode_rocket(&stray);
assert_eq!(
take_explosions(),
vec![("minecraft:explode", [10.5, 202.0, 8.5], None)],
"`getOwner()` is null: no source entity"
);
stop_events();
});
}
/// `ItemStack.useOn` awards `ITEM_USED` for `FireworkRocketItem.useOn`'s
/// SUCCESS; `FireworkRocketItem.use` while gliding awards its own.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn launching_from_a_block_and_while_gliding_use_the_rocket_up() {
with_game(|game| {
game.creative = false;
let stack = ItemStack::new("minecraft:firework_rocket", 1);
let hit = super::Hit {
pos: [2, 199, 2],
face: [0, 1, 0],
point: [2.5, 200.0, 2.5],
};
for used in 1..=5 {
game.inventory[game.selected] = Some(stack.clone());
assert!(game.launch_firework(&hit, &stack), "a rocket goes up");
assert_eq!(
game.stats.get("used", "minecraft:firework_rocket"),
used,
"each right-click on the block counts one use"
);
}
game.player.as_mut().unwrap().fall_flying = true;
game.inventory[game.selected] = Some(stack.clone());
assert!(game.boost_firework(&stack), "the gliding boost launches");
assert_eq!(game.stats.get("used", "minecraft:firework_rocket"), 6);
});
}
+141 -23
View File
@@ -297,23 +297,23 @@ impl Game<'_> {
action = "put";
self.audio
.play(&sound(&self.item_frames[i], "add_item"), Some(at), 1.0, 1.0);
if !self.creative {
self.consume_held();
}
} else {
let s = sound(&self.item_frames[i], "rotate_item");
self.audio.play(&s, Some(at), 1.0, 1.0);
let f = &mut self.item_frames[i];
f.rotation = (f.rotation + 1) % 8;
action = "rotate";
let s = sound(f, "rotate_item");
self.audio.play(&s, Some(at), 1.0, 1.0);
}
let p = self.item_frames[i].pos;
self.world.modified.insert((p[0] >> 4, p[2] >> 4));
self.frame_output_changed(p);
// ItemFrame.interact: BLOCK_CHANGE by the player, after setItem or
// setRotation.
// ItemFrame.interact: BLOCK_CHANGE by the player after setItem or
// setRotation, then the held item is consumed.
let ctx = self.who_context(Some(horizon_client::mobs::Who::Player), None);
self.post_game_event("minecraft:block_change", at, ctx);
if action == "put" && !self.creative {
self.consume_held();
}
if let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) {
let frame = self.item_frames.iter().find(|f| f.id == entity_id);
let item = frame
@@ -362,18 +362,16 @@ impl Game<'_> {
} else {
self.item_frames[i].clone()
};
self.audio.play(
&sound(&f, if with_frame { "break" } else { "remove_item" }),
Some(at),
1.0,
1.0,
);
if !with_frame {
self.item_frames[i].item = None;
} else {
// BlockAttachedEntity.kill, before dropItem. A frame holding an
// item only gives the item up (ItemFrame.hurtServer).
// BlockAttachedEntity.kill precedes ItemFrame.dropItem's break sound.
self.entity_killed_event(at, true);
self.audio.play(&sound(&f, "break"), Some(at), 1.0, 1.0);
}
// ItemFrame.dropItem clears the item (updating comparators) before drops.
if f.item.is_some() {
self.frame_output_changed(f.pos);
}
self.drop_frame_items(&mut f, with_frame, true);
if !with_frame {
@@ -383,10 +381,12 @@ impl Game<'_> {
// for the item, ItemFrame.dropItem(level, causedBy) for the frame.
let ctx = self.who_context(Some(horizon_client::mobs::Who::Player), None);
self.post_game_event("minecraft:block_change", at, ctx);
self.world.modified.insert((f.pos[0] >> 4, f.pos[2] >> 4));
if f.item.is_some() {
self.frame_output_changed(f.pos);
if !with_frame {
// ItemFrame.hurtServer plays this after the event.
self.audio
.play(&sound(&f, "remove_item"), Some(at), 1.0, 1.0);
}
self.world.modified.insert((f.pos[0] >> 4, f.pos[2] >> 4));
if let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) {
self.native_player_item_frame_change(
player,
@@ -519,16 +519,16 @@ impl Game<'_> {
if !self.frame_survives(&f) && !self.hanging_break_cancelled(f.kind(), f.pos, false)
{
self.item_frames.remove(i);
self.entity_killed_event(center(&f), false);
// BlockAttachedEntity.tick discards without a death event.
self.audio
.play(&sound(&f, "break"), Some(center(&f)), 1.0, 1.0);
self.drop_frame_items(&mut f, true, false);
// ItemFrame.dropItem(level, null): BLOCK_CHANGE after the drops.
let ctx = self.who_context(None, None);
self.post_game_event("minecraft:block_change", center(&f), ctx);
if f.item.is_some() {
self.frame_output_changed(f.pos);
}
self.drop_frame_items(&mut f, true, false);
// ItemFrame.dropItem(level, null): sound, drops, then event.
let ctx = self.who_context(None, None);
self.post_game_event("minecraft:block_change", center(&f), ctx);
continue;
}
}
@@ -840,3 +840,121 @@ mod map_limit_tests {
assert_eq!(framed.and_then(|s| s.data()?.map_id), Some(8));
}
}
#[cfg(test)]
mod game_event_tests {
use super::super::native_machines::tests::server_game;
use super::*;
use horizon_client::mobs::Who;
fn frame(id: u32, pos: [i32; 3], item: Option<ItemStack>) -> ItemFrame {
ItemFrame {
id,
pos,
facing: 3,
glow: false,
item,
rotation: 0,
drop_chance: 1.0,
fixed: false,
invisible: false,
check_interval: 0,
custom_name: None,
random: horizon_core::random::LegacyRandom::new(0),
}
}
fn at(id: u32) -> Option<InteractTarget> {
Some(InteractTarget {
id: id as i32,
location: [0.0; 3],
})
}
/// The events posted since [`reset`].
fn posted() -> Vec<(&'static str, [f64; 3], Option<Who>)> {
super::super::sculk::TEST_POSTED_EVENTS.with(|e| e.borrow().clone().unwrap_or_default())
}
fn reset() {
super::super::sculk::TEST_POSTED_EVENTS.with(|e| *e.borrow_mut() = Some(Vec::new()));
}
/// `ItemFrame.interact`: putting an item in and turning it each post
/// `GameEvent.BLOCK_CHANGE` from the player, at the frame's position.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn putting_and_rotating_a_frame_item_posts_block_change() {
let mut game = server_game();
game.item_frames = vec![frame(1, [0, 64, 1], None)];
let center = [0.5, 64.5, 1.03125];
*game.held_mut() = Some(ItemStack::new("minecraft:stick", 3));
let expected = [("minecraft:block_change", center, Some(Who::Player))];
reset();
assert!(game.interact_frame(at(1)));
assert_eq!(posted(), expected);
assert_eq!(game.held().map(|s| s.count), Some(2), "one goes in");
assert_eq!(game.item_frames[0].item.as_ref().map(|s| s.count), Some(1));
reset();
assert!(game.interact_frame(at(1)));
assert_eq!(posted(), expected);
assert_eq!(game.item_frames[0].rotation, 1);
}
/// `ItemFrame.hurtServer`: the item coming out posts `BLOCK_CHANGE` from
/// the player hitting the frame.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn taking_a_frame_item_out_posts_block_change_from_the_player() {
let mut game = server_game();
game.item_frames = vec![frame(
1,
[0, 64, 1],
Some(ItemStack::new("minecraft:stick", 1)),
)];
let center = [0.5, 64.5, 1.03125];
// A player looking straight at the frame's centre from two blocks away
// (eye height 1.62 above the feet).
let feet = Vec3::new(0.5, 64.5 - 1.62, -1.0);
game.player =
Some(horizon_client::motion::PlayerMotion::new(game.motion, feet).expect("a player"));
game.yaw = 0.0;
game.pitch = 0.0;
let expected = [("minecraft:block_change", center, Some(Who::Player))];
reset();
assert!(game.attack_frame(), "the frame is in reach");
assert!(game.item_frames[0].item.is_none());
assert_eq!(posted(), expected);
// The empty frame itself then breaks (`BlockAttachedEntity.hurtServer`
// then `ItemFrame.dropItem`), with the same event.
reset();
assert!(game.attack_frame());
assert!(game.item_frames.is_empty());
assert_eq!(
posted(),
[
("minecraft:entity_die", center, Some(Who::Player)),
("minecraft:block_change", center, Some(Who::Player))
]
);
}
/// `ItemFrame.dropItem` from `BlockAttachedEntity.tick`, when a frame
/// loses the block it hangs on: the event has no source.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_frame_that_falls_posts_block_change_without_source() {
let mut game = server_game();
let mut f = frame(1, [0, 64, 1], None);
// BlockAttachedEntity.tick checks every 100 ticks.
f.check_interval = 99;
game.item_frames = vec![f];
let center = [0.5, 64.5, 1.03125];
reset();
game.tick_item_frames();
assert!(game.item_frames.is_empty(), "nothing holds the frame");
assert_eq!(posted(), [("minecraft:block_change", center, None)]);
}
}
+21 -6
View File
@@ -253,11 +253,17 @@ impl Game<'_> {
registries.function_tags = store.function_tags.keys().cloned().collect();
registries.predicates = store.predicates.keys().cloned().collect();
registries.item_modifiers = store.item_modifiers.keys().cloned().collect();
registries
let sources = registries
.by_registry
.entry("minecraft:slot_source".into())
.or_default()
.ids = store.slot_sources.keys().cloned().collect();
.or_default();
sources.ids = store.slot_sources.keys().cloned().collect();
sources.tags = store
.slot_source_tags
.keys()
.filter(|id| store.slot_source_tag(id).is_ok())
.cloned()
.collect();
for (id, ids) in [
("minecraft:predicate", &registries.predicates),
("minecraft:item_modifier", &registries.item_modifiers),
@@ -311,10 +317,19 @@ impl Game<'_> {
reg.function_tags = self.fns.store.function_tags.keys().cloned().collect();
reg.predicates = self.fns.store.predicates.keys().cloned().collect();
reg.item_modifiers = self.fns.store.item_modifiers.keys().cloned().collect();
reg.by_registry
let sources = reg
.by_registry
.entry("minecraft:slot_source".into())
.or_default()
.ids = self.fns.store.slot_sources.keys().cloned().collect();
.or_default();
sources.ids = self.fns.store.slot_sources.keys().cloned().collect();
sources.tags = self
.fns
.store
.slot_source_tags
.keys()
.filter(|id| self.fns.store.slot_source_tag(id).is_ok())
.cloned()
.collect();
reg.schedules = self.fns.schedule.iter().map(|s| s.id.clone()).collect();
reg.datapacks_enabled = std::iter::once("vanilla".to_owned())
.chain(self.fns.enabled.iter().cloned())
+11 -5
View File
@@ -740,18 +740,24 @@ impl Game<'_> {
/// Haste and Mining Fatigue modifiers (`ADD_MULTIPLIED_TOTAL`), clamped to
/// the attribute's range.
pub(super) fn main_hand_attack_speed(&self) -> f64 {
// The base comes from the player's attribute map so `/attribute ... base set` applies.
if let Some(player) = &self.player
&& let Ok(speed) = player
.attributes
.computed_value_of("minecraft:attack_speed")
&& (self.remote.is_some()
|| !self.applied_modifiers.is_empty()
|| !self.potions.applied.is_empty())
{
return speed.clamp(0.0, 1024.0);
}
let base = self
.player
.as_ref()
.and_then(|player| {
let key = player.attributes.key("minecraft:attack_speed")?;
let handle = player.attributes.lookup(Some(&key))?;
player
.attributes
.instance(&handle)
.computed_value_of("minecraft:attack_speed")
.ok()
.map(|value| value.base_value())
})
.unwrap_or(horizon_client::attack_strength::BASE_ATTACK_SPEED);
let value = base
@@ -21,6 +21,27 @@ pub(super) struct ItemAreas {
}
impl ItemAreas {
/// Build the grid for villager item sensors only when many due sensors
/// would otherwise repeatedly scan a large, spatially spread item list.
/// Sampling a few 32-block columns avoids overhead for crowded piles.
pub(super) fn worthwhile(items: &[ItemEntity], queries: usize) -> bool {
if items.len() < 2048 || queries < 32 {
return false;
}
let stride = (items.len() / 32).max(1);
let mut columns = std::collections::HashSet::new();
for item in items.iter().step_by(stride) {
let p = item.position;
if p.is_finite() {
columns.insert((cell(p.x) >> 2, cell(p.z) >> 2));
if columns.len() >= 9 {
return true;
}
}
}
false
}
/// Whether an item of `items` may meet `area`: false only when none can.
pub(super) fn may_meet(&mut self, items: &[ItemEntity], next_entity: u32, area: Aabb) -> bool {
self.build(items, next_entity);
@@ -308,6 +329,57 @@ mod tests {
assert_eq!(scan.next_from(2, items.len()), 2);
}
#[test]
fn villager_sensing_candidates_match_full_scan_order() {
let items: Vec<_> = (0..2600)
.map(|i| {
let x = f64::from(i % 52) * 31.0 - 800.0;
let z = f64::from(i / 52) * 31.0 - 750.0;
item_at(x, 64.0 + f64::from(i % 3) * 0.25, z)
})
.collect();
assert!(ItemAreas::worthwhile(&items, 32));
assert!(!ItemAreas::worthwhile(&items, 31));
let clustered: Vec<_> = (0..2048).map(|_| item_at(1.0, 64.0, 1.0)).collect();
assert!(!ItemAreas::worthwhile(&clustered, 32));
let mut cells = ItemAreas::default();
for (x, z) in [
(-800.0, -750.0),
(-80.0, 40.0),
(0.0, 0.0),
(220.0, -130.0),
(790.0, 770.0),
] {
// A villager's inflated item sensor box, with a conservative
// fraction for the item bounding box at cell boundaries.
let area = Aabb::new(
Vec3::new(x - 32.4, 48.0, z - 32.4),
Vec3::new(x + 32.4, 80.0, z + 32.4),
);
let expected: Vec<usize> = items
.iter()
.enumerate()
.filter_map(|(i, e)| area.intersects(e.bounds()).then_some(i))
.collect();
let mut scan = cells.scan(&items, 1, area);
let mut actual = Vec::new();
let mut j = 0;
loop {
j = scan.next_from(j, items.len());
let Some(e) = items.get(j) else {
break;
};
if area.intersects(e.bounds()) {
actual.push(j);
}
j += 1;
}
assert_eq!(actual, expected, "sensor at {x}, {z}");
}
}
#[test]
fn an_item_spawned_after_the_cells_were_built_is_seen() {
let mut items = vec![item_at(100.5, 64.0, 100.5)];
@@ -0,0 +1,97 @@
use super::super::native_machines::tests::server_game;
use super::*;
use horizon_core::position::BlockPos;
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and scratch homes/cwd"]
fn count_equality_uses_live_stack_bounds_and_command_flow() {
let mut game = server_game();
game.build_registries();
game.set_allow_commands(true);
game.block_ticks
.add_empty_chunk(BlockPos::new(0, 0, 0).chunk())
.unwrap();
assert!(game.run_command("forceload add 0 0 15 15"));
assert!(game.world.loaded(4, 4), "actual loaded world chunk");
assert!(game.run_command("setblock 4 199 4 stone"));
assert!(game.run_command("setblock 4 200 4 chest{Items:[{Slot:0b,id:\"minecraft:diamond\",count:1},{Slot:1b,id:\"minecraft:diamond\",count:3},{Slot:2b,id:\"minecraft:diamond\",count:4},{Slot:3b,id:\"minecraft:dirt\",count:3},{Slot:5b,id:\"minecraft:stone\",count:2,components:{\"minecraft:custom_data\":{h194:1}}}]}"));
assert!(game.run_command("scoreboard objectives add h194 dummy"));
let clock = game.world.game_time;
let counts = |g: &Game<'_>| {
let horizon_client::containers::BlockEntity::Chest(slots) =
g.world.block_entities.get(&(4, 200, 4)).unwrap()
else {
panic!("actual chest model missing")
};
assert!(slots[4].is_none(), "actual empty slot");
[
slots[0].as_ref().unwrap().count,
slots[1].as_ref().unwrap().count,
slots[2].as_ref().unwrap().count,
]
};
assert_eq!(counts(&game), [1, 3, 4]);
let mut failed = Vec::new();
for (slot, count) in [1, 3, 4].into_iter().enumerate() {
assert!(
game.run_command(&format!(
"execute if items block 4 200 4 container.{slot} diamond"
)),
"plain admitted command path"
);
let stack = ItemStack::new("minecraft:diamond", count);
assert!(
horizon_client::item_data::encode_components(&stack)
.is_none_or(|t| t.get("minecraft:count").is_none()),
"count is not a component patch"
);
for (term, expected) in [
("count=3", count == 3),
("count=4", count == 4),
("!count=3", count != 3),
("count={min:2}", count >= 2),
("count~3", count == 3),
("minecraft:count={min:2,max:3}", (2..=3).contains(&count)),
] {
let predicate = ItemInput {
id: "minecraft:diamond".into(),
tag: false,
any: false,
components: Some(format!("[{term}]")),
};
let actual = game.stack_matches(&predicate, &stack);
if actual != expected {
failed.push(format!(
"direct count{count} {term}: {actual}, expected {expected}"
));
}
assert_eq!(stack.count, count, "read-only evaluator");
assert!(game.run_command("scoreboard players set #r h194 91"));
assert!(game.run_command("scoreboard players set #s h194 17"));
let accepted = game.run_command(&format!("execute store result score #r h194 store success score #s h194 if items block 4 200 4 container.{slot} diamond[{term}]"));
let score = |name| game.score.board.score(name, "h194").unwrap().value;
let got = (accepted, score("#r"), score("#s"));
let want = if expected {
(true, count as i32, 1)
} else {
(false, 0, 0)
};
if got != want {
failed.push(format!(
"command slot{slot} {term}: {got:?}, expected {want:?}"
));
}
}
}
assert!(game.run_command("execute unless items block 4 200 4 container.3 diamond[count=3]"));
assert!(game.run_command("execute unless items block 4 200 4 container.4 diamond[count=3]"));
assert!(
game.run_command("execute if items block 4 200 4 container.5 stone[custom_data={h194:1}]")
);
assert_eq!(counts(&game), [1, 3, 4], "no inventory mutation");
assert_eq!(game.world.game_time, clock, "zero actor ticks");
assert!(
failed.is_empty(),
"actual count predicate mismatches: {failed:?}"
);
}
+129 -11
View File
@@ -309,17 +309,13 @@ impl Game<'_> {
self.spawn_destroyed_contents(destroyed);
}
/// `ItemUtils.onContainerDestroyed`: each stack a destroyed item held
/// becomes an item entity where that item lay (`new ItemEntity(level, x,
/// y, z, stack)`), nudged by 0.2 less 0.1 per axis and pickable at once.
/// becomes an item entity where that item lay, made by
/// `new ItemEntity(level, x, y, z, stack)`: its motion is that
/// constructor's own, 0.2 up and ±0.1 sideways from the item's random,
/// and it is pickable at once.
pub(super) fn spawn_destroyed_contents(&mut self, destroyed: Vec<(ItemStack, Vec3)>) {
for (stack, at) in destroyed {
let r = &mut self.world.random;
let motion = [
r.next_double() * 0.2 - 0.1,
r.next_double() * 0.2 - 0.1,
r.next_double() * 0.2 - 0.1,
];
self.spawn_item(stack, [at.x, at.y, at.z], motion, 0);
self.spawn_item_own(stack, [at.x, at.y, at.z], 0);
}
}
/// `ExperienceOrb.award`: orbs of the largest sizes; each draws the id
@@ -671,27 +667,55 @@ impl Game<'_> {
self.add_item_entity(stack, position, None, pickup_delay)
}
/// `DefaultDispenseItemBehavior.spawnItem`: `new ItemEntity(level, x, y, z,
/// stack)` draws its default motion from the item's own random, then
/// `setDeltaMovement` throws it with `velocity` before `addFreshEntity`.
pub(super) fn spawn_item_thrown(
&mut self,
stack: ItemStack,
position: [f64; 3],
velocity: [f64; 3],
) {
self.add_item_entity_drawn(stack, position, Some(velocity), true, 0);
}
pub(super) fn add_item_entity(
&mut self,
stack: ItemStack,
position: [f64; 3],
velocity: Option<[f64; 3]>,
pickup_delay: u32,
) -> [f64; 3] {
self.add_item_entity_drawn(stack, position, velocity, velocity.is_none(), pickup_delay)
}
/// `add_item_entity`; `constructor_motion` draws the five-argument
/// constructor's default motion, which a given `velocity` then replaces.
fn add_item_entity_drawn(
&mut self,
stack: ItemStack,
position: [f64; 3],
velocity: Option<[f64; 3]>,
constructor_motion: bool,
pickup_delay: u32,
) -> [f64; 3] {
let (item, count) = (stack.item.clone(), stack.count as i32);
self.next_entity += 1;
let p = Vec3::new(position[0], position[1], position[2]);
// The item's own random, in vanilla's order: Entity's UUID, ItemEntity's
// bobOffs and yaw, then (without a given motion) the constructor's motion.
// bobOffs and yaw, then (the five-argument constructor) its default motion.
let mut own = random_source::entity();
let uuid = horizon_client::save::insecure_uuid(&mut own);
self.persist.uuids.insert(self.next_entity, uuid);
let bob = own.next_float() * std::f32::consts::PI * 2.0;
let yaw = own.next_float() * 360.0;
let velocity = velocity.unwrap_or_else(|| {
let constructed = constructor_motion.then(|| {
let x = own.next_double() * 0.2 - 0.1;
[x, 0.2, own.next_double() * 0.2 - 0.1]
});
let velocity = velocity
.or(constructed)
.expect("an item entity without a velocity takes the constructor's");
self.dropped.push(ItemEntity {
rotation: [yaw, 0.0],
pickup_delay: i64::from(pickup_delay),
@@ -850,6 +874,100 @@ impl Game<'_> {
}
}
#[cfg(test)]
mod destroyed_contents_tests {
use super::super::native_machines::tests::server_game;
use super::*;
use horizon_client::items::ItemStack;
use horizon_core::random::RandomSource;
/// The motion of each spilled stack, in the order the spill made them.
fn spilled_motions(game: &Game<'_>) -> Vec<[f64; 3]> {
game.dropped
.iter()
.map(|item| [item.velocity.x, item.velocity.y, item.velocity.z])
.collect()
}
/// `ItemUtils.onContainerDestroyed` makes each stack with
/// `new ItemEntity(level, x, y, z, stack)`, whose motion is 0.2 up and
/// ±0.1 sideways from the *item's* random (ItemEntity.java:61-66), and
/// draws nothing from the level's. Every destroyed container funnels here:
/// lava, fire and cactus in the item's environment tick, an explosion, and
/// the damage command.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn spilled_stacks_rise_from_their_own_randoms() {
let mut game = server_game();
let at = Vec3::new(8.5, 70.0, 8.5);
let stacks = ["minecraft:stone", "minecraft:dirt", "minecraft:glass"]
.into_iter()
.map(|id| (ItemStack::new(id, 1), at))
.collect();
let mut level = game.world.random.clone();
game.spawn_destroyed_contents(stacks);
let motions = spilled_motions(&game);
assert_eq!(motions.len(), 3);
for motion in &motions {
assert_eq!(motion[1], 0.2, "the four-argument constructor rises 0.2");
assert!((-0.1..0.1).contains(&motion[0]), "{motion:?}");
assert!((-0.1..0.1).contains(&motion[2]), "{motion:?}");
}
// Three stacks, three different sideways motions: each drew its own.
assert_ne!(motions[0], motions[1]);
assert_ne!(motions[1], motions[2]);
assert_ne!(motions[0], motions[2]);
// The spill leaves the level's random alone.
assert_eq!(game.world.random.next_double(), level.next_double());
assert_eq!(game.world.random.next_double(), level.next_double());
}
/// The damage command is one of those callers: killing a shulker box item
/// spills its contents with the same upward motion.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_damaged_shulker_box_spills_its_stacks_upward() {
let mut game = server_game();
game.build_registries();
game.set_allow_commands(true);
game.world.chunks.insert(
(0, 0),
std::sync::Arc::new(horizon_client::chunk::Chunk::empty(0, 0)),
);
let feet = Vec3::new(8.5, 70.0, 8.5);
game.player = Some(horizon_client::motion::PlayerMotion::new(game.motion, feet).unwrap());
game.update_chunk_tickets();
let boxed = horizon_client::item_data::parse_stack(
r#"{id:"minecraft:shulker_box",count:1,components:{"minecraft:container":[
{slot:0,item:{id:"minecraft:stone",count:2}},
{slot:4,item:{id:"minecraft:dirt",count:1}},
{slot:8,item:{id:"minecraft:glass",count:3}}]}}"#,
)
.unwrap()
.unwrap();
game.spawn_item(boxed, [8.5, 70.0, 8.5], [0.0; 3], 0);
assert_eq!(game.dropped.len(), 1);
assert!(game.run_command("damage @e[type=minecraft:item,limit=1] 100 minecraft:generic"));
let mut spilled: Vec<_> = game
.dropped
.iter()
.map(|item| (item.stack.item.to_string(), item.stack.count))
.collect();
spilled.sort();
assert_eq!(
spilled,
[
("minecraft:dirt".to_owned(), 1),
("minecraft:glass".to_owned(), 3),
("minecraft:stone".to_owned(), 2)
]
);
for item in &game.dropped {
assert_eq!(item.velocity.y, 0.2, "{item:?}");
}
}
}
#[cfg(test)]
mod escape_tests {
use super::super::native_machines::tests::server_game;
+71
View File
@@ -68,6 +68,11 @@ impl Game<'_> {
self.with_level(|b, level, ticks| {
b.set_block(level, ticks, p, new, level_flags::UPDATE_CLIENTS)
});
// `JukeboxBlockEntity.notifyItemChangedInJukebox`: the disc coming
// or going is a block change the sculk sensors hear.
if self.world.block(pos[0], pos[1], pos[2]) != state {
self.post_block_game_event("minecraft:block_change", pos, None, Some(new));
}
}
}
/// `JukeboxBlock.setPlacedBy`: a jukebox placed from an item whose
@@ -762,3 +767,69 @@ mod jukebox_component_tests {
);
}
}
#[cfg(test)]
mod block_change_tests {
use super::*;
use crate::game::{Audio, Options, Progress, Settings, load_resources};
use horizon_client::chunk::Chunk;
use std::sync::Arc;
/// `JukeboxBlockEntity.notifyItemChangedInJukebox`: the disc coming or
/// going posts `block_change` at the box, so sculk sensors hear it.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_disc_change_posts_block_change() {
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.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.world
.chunks
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
let jukebox = game
.pack
.assets
.registry
.default_state("minecraft:jukebox")
.unwrap();
game.world.set(2, 4, 3, jukebox).unwrap();
super::super::sculk::TEST_POSTED_EVENTS
.with(|events| *events.borrow_mut() = Some(Vec::new()));
game.set_has_record([2, 4, 3], true);
super::super::sculk::TEST_POSTED_EVENTS.with(|events| {
assert!(
events.borrow().as_ref().unwrap().contains(&(
"minecraft:block_change",
[2.5, 4.5, 3.5],
None
)),
"{:?}",
events.borrow()
);
});
}
}
+10 -22
View File
@@ -304,11 +304,10 @@ impl Game<'_> {
self.post_attack(index);
return;
};
let weapon = self
.res
.items()
.get(&stack.item)
.and_then(|i| i.weapon_damage.map(|d| (d, i.max_damage)));
let items = self.res.items();
let weapon = items
.weapon_damage(&stack)
.map(|damage| (damage, items.get(&stack.item).map_or(0, |i| i.max_damage)));
self.native_hurt_enemy_event(&stack, index);
let smash = self.smash_attack();
if smash {
@@ -327,11 +326,10 @@ impl Game<'_> {
/// `ItemStack.postHurtEnemy`: `hurtAndBreak(weapon.itemDamagePerAttack)` of
/// the weapon in the acting hand.
pub(super) fn weapon_wear(&mut self, stack: &ItemStack) {
let weapon = self
.res
.items()
.get(&stack.item)
.and_then(|i| i.weapon_damage.map(|d| (d, i.max_damage)));
let items = self.res.items();
let weapon = items
.weapon_damage(stack)
.map(|damage| (damage, items.get(&stack.item).map_or(0, |i| i.max_damage)));
if let Some((per_attack, max)) = weapon
&& max > 0
&& !self.creative
@@ -392,12 +390,7 @@ impl Game<'_> {
if smash {
self.mace_smash_self(victim_on_ground);
}
if self
.res
.items()
.get(&stack.item)
.is_some_and(|i| i.weapon_damage.is_some())
{
if self.res.items().weapon_damage(&stack).is_some() {
self.award_stat("used", &stack.item, 1);
}
self.post_attack_bursts(&stack, "minecraft:player");
@@ -421,12 +414,7 @@ impl Game<'_> {
{
self.smash_knockback(center, Spared::Stand(id), fall);
}
if self
.res
.items()
.get(&stack.item)
.is_some_and(|i| i.weapon_damage.is_some())
{
if self.res.items().weapon_damage(&stack).is_some() {
self.award_stat("used", &stack.item, 1);
}
self.post_attack_bursts(&stack, "minecraft:armor_stand");
+44 -14
View File
@@ -12,10 +12,15 @@ use horizon_client::{
Result,
containers::{BlockEntity, HOPPER_SLOTS, ModContainer},
items::{ItemStack, Items},
mobs::Mob,
save,
world::World,
};
use horizon_core::{geometry::Direction, position::BlockPos, random::RandomSource};
use horizon_core::{
geometry::{Aabb, Direction, Vec3},
position::BlockPos,
random::RandomSource,
};
use horizon_world::{
level::{Behaviours, LevelAccess},
nbt::Tag,
@@ -224,6 +229,8 @@ struct Containers<'a> {
registry: &'static horizon_world::registry::BlockRegistry,
/// Brewing recipes, for what a brewing stand accepts (not needed for signals).
brewing: Option<&'a horizon_client::brewing::Brewing>,
/// The level's mobs, for the cat half of `ChestBlock.isChestBlockedAt`.
mobs: &'a [Mob],
}
impl Containers<'_> {
fn prop(&self, state: BlockStateId, key: &str) -> Option<&str> {
@@ -251,7 +258,8 @@ impl Containers<'_> {
}
/// `HopperBlockEntity.getBlockContainer` (double chests combined, right half
/// first). `blocked_chests`: `ChestBlock.getContainer(.., false)` returns
/// nothing for a chest under a redstone conductor.
/// nothing for a blocked chest (`isChestBlockedAt`: a redstone conductor
/// above it or a cat sitting on it).
fn view(&self, pos: Pos, blocked_chests: bool) -> Option<View> {
let state = self.world.block(pos.0, pos.1, pos.2);
let name = self.name(state);
@@ -308,6 +316,7 @@ impl Containers<'_> {
self.behaviours.facts(above).flags
& horizon_world::level::data::state_flags::CONDUCTOR
!= 0
|| cat_sitting_above(self.mobs, p)
};
if blocked_chests && is_blocked(pos) {
return None;
@@ -611,20 +620,36 @@ fn bone_meal_stack() -> &'static ItemStack {
&STACK
}
/// Comparator input from a container block entity (0 when there is none).
pub(super) fn container_signal(
world: &World,
behaviours: &Behaviours,
items: &Items,
registry: &'static horizon_world::registry::BlockRegistry,
pos: BlockPos,
) -> i32 {
/// `ChestBlock.isCatSittingOnChest`: a sitting cat whose box meets the block
/// above `key` (the box of the block above, not the chest's own).
pub(super) fn cat_sitting_above(mobs: &[Mob], key: Pos) -> bool {
let area = Aabb::new(
Vec3::new(f64::from(key.0), f64::from(key.1 + 1), f64::from(key.2)),
Vec3::new(
f64::from(key.0 + 1),
f64::from(key.1 + 2),
f64::from(key.2 + 1),
),
);
mobs.iter().any(|m| {
m.species() == horizon_client::mobs::Species::Cat
&& m.alive()
&& horizon_client::mobs::tame::cat_sitting(m)
&& m.bounds().intersects(area)
})
}
/// Comparator input from a container block entity (0 when there is none):
/// `ChestBlock.getContainer(.., false)`, whose blocked check sees the level's
/// cats (`isCatSittingOnChest`).
pub(super) fn container_signal(level: &super::GameLevel<'_>, pos: BlockPos) -> i32 {
let c = Containers {
world,
behaviours,
items,
registry,
world: &*level.world,
behaviours: level.behaviours,
items: level.items,
registry: level.registry,
brewing: None,
mobs: level.entities.mobs,
};
c.view((pos.x, pos.y, pos.z), true)
.filter(|v| !matches!(v.kind, Kind::Composter(_)))
@@ -680,6 +705,7 @@ impl Game<'_> {
items: self.items(),
registry: self.pack.assets.registry,
brewing: Some(&self.stations().brewing),
mobs: &self.mobs,
}
}
pub(super) fn block_can_place(&self, pos: [i32; 3], slot: usize, stack: &ItemStack) -> bool {
@@ -2442,3 +2468,7 @@ mod tests {
});
}
}
#[cfg(test)]
#[path = "machines_chest_cat_tests.rs"]
mod machines_chest_cat_tests;
@@ -0,0 +1,134 @@
//! `ChestBlock.isChestBlockedAt` (ChestBlock.java:318-319) is a redstone
//! conductor above the chest *or* a cat sitting in the block above it
//! (`isCatSittingOnChest`, :327-338). `getAnalogOutputSignal` (:346-348)
//! reads `getContainer(.., false)`, whose `combine(.., false)` (:279) uses
//! that predicate, so a sitting cat leaves the comparator at 0. A hopper's
//! `getContainer(.., true)` ignores the block, and the menu path keeps
//! closing as before.
use super::super::native_machines::tests::server_game;
use super::*;
use horizon_client::chunk::Chunk;
use horizon_core::geometry::Vec3;
use std::sync::Arc;
const CHEST: Pos = (0, 64, 0);
const COMPARATOR: Pos = (1, 64, 0);
const WIRE: Pos = (2, 64, 0);
/// A full chest at `CHEST`, a comparator east of it reading west into it,
/// and the wire that carries the comparator's output, on a floor in a
/// ticking chunk.
fn game() -> Game<'static> {
let mut game = server_game();
game.build_registries();
game.set_allow_commands(true);
for x in -1..=1 {
for z in -1..=1 {
let mut chunk = Chunk::empty(x, z);
chunk.lit = true;
game.world.chunks.insert((x, z), Arc::new(chunk));
game.block_ticks
.add_empty_chunk(BlockPos::new(x * 16, 0, z * 16).chunk())
.unwrap();
}
}
game.player = Some(
horizon_client::motion::PlayerMotion::new(game.motion, Vec3::new(8.5, 64.0, 8.5)).unwrap(),
);
for command in [
"forceload add 0 0",
"fill 0 63 0 15 63 15 stone",
"setblock 0 64 0 minecraft:chest{Items:[{Slot:0b,id:\"minecraft:stone\",count:64}]}",
"setblock 1 64 0 minecraft:comparator[facing=west]",
"setblock 2 64 0 minecraft:redstone_wire",
] {
assert!(game.run_command(command), "{command}");
}
game.update_chunk_tickets();
assert!(game.tickets.levels().block_ticking(0, 0));
game
}
/// A block change beside the comparator, so it reads its input again, then
/// the ticks that settle the comparator and the wire.
fn poke(game: &mut Game<'_>, block: &str) {
assert!(game.run_command(&format!(
"setblock {} {} {} {block}",
COMPARATOR.0,
COMPARATOR.1,
COMPARATOR.2 + 1
)));
for _ in 0..4 {
game.world.game_time += 1;
game.update_chunk_tickets();
game.tick_blocks().unwrap();
}
}
fn summon_cat(game: &mut Game<'_>, sitting: bool) {
assert!(game.run_command(&format!(
"summon cat 0.5 65 0.5 {{Sitting:{}b,Tame:1b}}",
i8::from(sitting)
)));
}
fn wire_power(game: &Game<'_>) -> i32 {
let state = game.world.block(WIRE.0, WIRE.1, WIRE.2);
let info = game
.pack
.assets
.registry
.state(state)
.expect("the wire is a known state");
assert_eq!(info.name, "minecraft:redstone_wire");
info.properties["power"]
.parse()
.expect("a wire has a power property")
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_sitting_cat_silences_the_comparator_reading_a_chest() {
let mut game = game();
poke(&mut game, "glass");
assert_eq!(wire_power(&game), 1, "a full chest reads 1");
summon_cat(&mut game, true);
poke(&mut game, "stone");
assert_eq!(wire_power(&game), 0, "a sitting cat blocks the chest");
// Standing the cat up gives the signal back.
assert!(game.run_command("data merge entity @e[type=minecraft:cat,limit=1] {Sitting:0b}"));
poke(&mut game, "dirt");
assert_eq!(wire_power(&game), 1, "a standing cat blocks nothing");
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_sitting_cat_leaves_the_hopper_and_the_menu_alone() {
let mut game = game();
summon_cat(&mut game, true);
assert!(
game.containers().view(CHEST, false).is_some(),
"a hopper's getContainer(.., true) ignores the cat"
);
assert!(
game.containers().view(CHEST, true).is_none(),
"the comparator's getContainer(.., false) combines to nothing"
);
assert!(
game.chest_menu_blocked(CHEST, "minecraft:chest"),
"a chest with a cat on it opens no menu"
);
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_conductor_above_a_chest_still_silences_the_comparator() {
let mut game = game();
poke(&mut game, "glass");
assert_eq!(wire_power(&game), 1);
assert!(game.run_command("setblock 0 65 0 stone"));
poke(&mut game, "stone");
assert_eq!(wire_power(&game), 0, "a conductor above blocks the chest");
}
+37
View File
@@ -707,6 +707,17 @@ impl MapData {
Some(map)
}
}
/// What a read of a map gives: from the cache when the map is loaded, else from its saved file.
fn map_read<T>(
cached: Option<&MapData>,
saved: impl FnOnce() -> Option<MapData>,
read: impl Fn(&MapData) -> T,
) -> Option<T> {
match cached {
Some(map) => Some(read(map)),
None => saved().map(|map| read(&map)),
}
}
pub(super) struct Maps {
pub data: HashMap<i32, MapData>,
/// The highest map id handed out (`maps/last_id.dat`), loaded on first use.
@@ -926,6 +937,30 @@ impl Game<'_> {
fn map_path(&self, name: &str) -> std::path::PathBuf {
self.world_root.join("data/minecraft/maps").join(name)
}
/// `native_map_colors`: the 128 x 128 colour ids of the world's map `map_id`, row by row (vanilla's
/// `MapItemSavedData.colors`), from the loaded map or its saved file, which is not loaded here. None for a map the
/// world never made or saved, and on a client of a server (as `native_paint_map`).
pub(super) fn native_map_colors(&self, map_id: i32) -> Option<Vec<u8>> {
self.native_map_read(map_id, |map| map.colors.clone())
}
/// `native_map_meta`: the scale, dimension and locked flag of the world's map `map_id`, read as
/// `native_map_colors` reads it.
pub(super) fn native_map_meta(&self, map_id: i32) -> Option<(u8, String, bool)> {
self.native_map_read(map_id, |map| (map.scale, map.dimension.clone(), map.locked))
}
fn native_map_read<T>(&self, map_id: i32, read: impl Fn(&MapData) -> T) -> Option<T> {
if self.remote.is_some() || self.world_root.as_os_str().is_empty() {
return None;
}
let saved = || {
if self.maps.missing.contains(&map_id) {
return None;
}
horizon_client::save::read_dat(&self.map_path(&format!("{map_id}.dat")))
.and_then(|t| MapData::from_nbt(&t))
};
map_read(self.maps.data.get(&map_id), saved, read)
}
/// The map's data, loaded from `data/minecraft/maps/<id>.dat` on first use.
pub(super) fn map(&mut self, id: i32) -> Option<&mut MapData> {
if !self.maps.data.contains_key(&id) && !self.maps.missing.contains(&id) {
@@ -1973,6 +2008,8 @@ impl Game<'_> {
#[cfg(test)]
mod live_frame_tests;
#[cfg(test)]
mod native_read_tests;
#[cfg(test)]
mod save_tests;
#[cfg(test)]
@@ -0,0 +1,33 @@
use super::{MapData, map_read};
#[test]
fn a_painted_picture_reads_back_as_painted() {
let mut map = MapData::fresh(0.0, 0.0, 0, "minecraft:overworld");
let colors: Vec<u8> = (0..16384).map(|i| (i * 7 % 256) as u8).collect();
map.paint(&colors);
assert_eq!(
map_read(Some(&map), || None, |m| m.colors.clone()),
Some(colors)
);
}
#[test]
fn a_loaded_map_wins_over_its_saved_file() {
let loaded = MapData::fresh(0.0, 0.0, 0, "minecraft:overworld");
let saved = MapData::fresh(0.0, 0.0, 2, "minecraft:the_end");
let scale = |cached: Option<&MapData>| {
map_read(
cached,
|| Some(MapData::fresh(0.0, 0.0, 2, "minecraft:the_end")),
|m| m.scale,
)
};
assert_eq!(scale(Some(&loaded)), Some(0));
assert_eq!(scale(None), Some(2));
assert_eq!(saved.dimension, "minecraft:the_end");
}
#[test]
fn a_map_neither_loaded_nor_saved_reads_as_none() {
assert_eq!(map_read(None, || None, |m| m.locked), None);
}
@@ -44,12 +44,18 @@ fn game() -> Game<'static> {
.unwrap();
game.build_registries();
game.set_allow_commands(true);
game.world
.chunks
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
game.block_ticks
.add_empty_chunk(ChunkPos::new(0, 0))
.unwrap();
// Entities tick once every chunk within 2 columns is loaded
// (ChunkMap.prepareEntityTickingChunk, #13953).
for x in -2..=2 {
for z in -2..=2 {
game.world
.chunks
.insert((x, z), Arc::new(Chunk::empty(x, z)));
game.block_ticks
.add_empty_chunk(ChunkPos::new(x, z))
.unwrap();
}
}
assert!(game.run_command("fill 0 99 0 15 99 15 stone"));
game
}
+1
View File
@@ -1258,6 +1258,7 @@ mod tests {
ingredient: |_, _, _| {},
ingredient_test: |_, _, _, _| {},
log_line: |_, _, _, _| {},
living_death_message: |_, _, _| {},
handshake: |_, _, _, _, _| {},
});
assert!(installed.is_ok());
@@ -84,7 +84,7 @@ impl Game<'_> {
}
let camera = self.eye_position().unwrap_or(at);
self.particles
.fireworks(at, velocity, explosions.to_vec(), camera);
.fireworks(at, velocity, explosions.to_vec(), camera, true);
true
}
}
@@ -10,6 +10,7 @@
//! earlier client level), they return `None`.
use super::{Dim, Game, commands::Target};
use horizon_client::mod_api::{EntityRef, LevelRef};
use horizon_core::geometry::Vec3;
impl Game<'_> {
/// What a handle names now, or None for a stale or foreign one.
@@ -156,6 +157,53 @@ impl Game<'_> {
};
Some(LevelRef::new(entity.epoch(), dim.id()))
}
/// `LivingEntity.hasLineOfSight(target)` for a server mod, with the entities as the events name them by id:
/// whether `from_entity` sees `to_entity`. The ray runs from the eyes of `from_entity` to those of `to_entity`
/// (`Entity.getEyeY`), against `ClipContext.Block.COLLIDER` shapes with `from_entity` as the collision context
/// and no fluids, and it counts only when the two stand in the same level and no more than 128 blocks apart.
/// False also for an unknown entity, a client, and a level other than the one being ticked (the level it can
/// answer for). API @API:line_of_sight@.
pub(super) fn native_has_line_of_sight(&self, from_entity: u32, to_entity: u32) -> bool {
if !self.native_level_open(self.dim.id()) {
return false;
}
let (Some(from), Some(to)) = (
self.native_entity(from_entity)
.and_then(|e| self.native_entity_target(e)),
self.native_entity(to_entity)
.and_then(|e| self.native_entity_target(e)),
) else {
return false;
};
let here = self.dim.id();
if self.target_level(from).id() != here || self.target_level(to).id() != here {
return false;
}
let (Some(a), Some(b)) = (self.target_position(from), self.target_position(to)) else {
return false;
};
let from_eye = Vec3::new(a[0], a[1] + self.target_eye_height(from), a[2]);
let to_eye = Vec3::new(b[0], b[1] + self.target_eye_height(to), b[2]);
self.mob_world_base(&[])
.line_of_sight_for(Some(a[1]), from_eye, to_eye)
}
/// `Level.clip` with `ClipContext.Block.COLLIDER`, no fluids and no entity (`CollisionContext.empty()`), as a
/// mod's own ray: true when the segment from `from` to `to` meets no block's collider (a miss). Only the level
/// being ticked is answered; another dimension, and a client, give false, since reaching another loaded level
/// needs a mutable borrow this read takes without one. API @API:line_of_sight@.
pub(super) fn native_clear_between(
&self,
dimension: &str,
from: [f64; 3],
to: [f64; 3],
) -> bool {
self.native_level_open(dimension)
&& self.mob_world_base(&[]).clear_between(
Vec3::new(from[0], from[1], from[2]),
Vec3::new(to[0], to[1], to[2]),
false,
)
}
}
/// A saved UUID's four ints (`UUIDUtil.uuidFromIntArray`) as one number.
@@ -493,3 +541,135 @@ mod client_tests {
assert!(made(&game) > before, "event 42 draws the sweat");
}
}
/// `native_has_line_of_sight` and `native_clear_between` against real blocks and entities of a server's level.
#[cfg(test)]
mod line_of_sight_tests {
use crate::game::{Audio, Game, Options, Progress, Settings, execution_role, load_resources};
use horizon_client::mod_api::NativeAuthority;
use horizon_world::nbt::Compound;
fn game() -> Game<'static> {
let root = std::path::PathBuf::from(
std::env::var_os("HORIZON_TEST_RESOURCES")
.expect("set HORIZON_TEST_RESOURCES to prepared resources"),
);
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = execution_role::ExecutionRole::IntegratedServer;
options.dedicated = Some(Vec::new());
let res: &'static _ = Box::leak(Box::new(loaded.resources));
let mut game = Game::new(
res,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.build_registries();
game.set_allow_commands(true);
game.native_lifecycle
.begin_open(NativeAuthority::IntegratedServer);
game.world.chunks.insert(
(0, 0),
std::sync::Arc::new(horizon_client::chunk::Chunk::empty(0, 0)),
);
game.block_ticks
.add_empty_chunk(horizon_core::position::ChunkPos::new(0, 0))
.unwrap();
game
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_collider_blocks_sight_and_a_bottom_slab_or_water_does_not() {
let mut game = game();
let dim = game.dim.id();
let a = game
.native_summon(dim, "minecraft:pig", [2.5, 200.0, 2.5], Compound::new())
.unwrap();
let b = game
.native_summon(dim, "minecraft:pig", [10.5, 200.0, 2.5], Compound::new())
.unwrap();
let at = |x: f64| [x, 200.8, 2.5];
assert!(game.native_has_line_of_sight(a, b), "open air");
assert!(game.native_has_line_of_sight(b, a), "sight is symmetric");
assert!(game.native_clear_between(dim, at(2.5), at(10.5)));
// A bottom slab's collider tops out at 200.5, under the eyes: the ray passes over it.
assert!(game.run_command("setblock 6 200 2 minecraft:oak_slab[type=bottom]"));
assert!(
game.native_has_line_of_sight(a, b),
"bottom slab under the eyes"
);
// Water has no collider and fluids do not count (`ClipContext.Fluid.NONE`).
assert!(game.run_command("fill 6 199 0 6 205 5 minecraft:water"));
assert!(game.native_has_line_of_sight(a, b), "water is no collider");
// Stone's collider covers the eye line: no sight, and the ray is not clear.
assert!(game.run_command("fill 6 199 0 6 205 5 minecraft:stone"));
assert!(!game.native_has_line_of_sight(a, b), "stone wall");
assert!(
!game.native_has_line_of_sight(b, a),
"stone wall, both ways"
);
assert!(!game.native_clear_between(dim, at(2.5), at(10.5)));
// A segment that stops short of the wall is clear.
assert!(game.native_clear_between(dim, at(2.5), at(5.5)));
// Once the wall is gone, the same pair sees each other again.
assert!(game.run_command("fill 6 199 0 6 205 5 minecraft:air"));
assert!(game.native_has_line_of_sight(a, b), "wall removed");
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn unknown_entities_and_other_levels_see_nothing() {
let mut game = game();
let dim = game.dim.id();
let a = game
.native_summon(dim, "minecraft:pig", [2.5, 200.0, 2.5], Compound::new())
.unwrap();
let b = game
.native_summon(dim, "minecraft:pig", [10.5, 200.0, 2.5], Compound::new())
.unwrap();
assert!(
!game.native_has_line_of_sight(a, u32::MAX),
"no such entity"
);
assert!(
!game.native_has_line_of_sight(a, b + 1000),
"no such entity"
);
// A pig in the Nether is in another level: no sight to it, and no ray in that level here.
let nether = "minecraft:the_nether";
let uuid = (7u128 << 96) | 0x51;
assert!(game.run_command(&format!(
"execute in {nether} run summon pig 8.5 200 8.5 {{UUID:[I;7,0,0,81],NoAI:1b}}"
)));
let (_, far, _) = game
.native_entity_by_uuid(uuid)
.expect("summoned in the Nether");
assert!(!game.native_has_line_of_sight(a, far));
assert!(!game.native_clear_between(nether, [2.5, 200.8, 2.5], [8.5, 200.8, 8.5]));
assert_eq!(game.dim.id(), dim, "the level in place is unchanged");
}
}
@@ -685,6 +685,8 @@ fn catalog_matches_hook_points() {
let conn_hooks = free_hooks(include_str!("../lan/conn.rs"));
// The log writer's (`horizon_client_lib::native_client_hooks`).
let client_hooks = free_hooks(include_str!("../native_client_hooks.rs"));
// The tamed mobs' death messages (`horizon_client_lib::living_death`).
let death_hooks = free_hooks(include_str!("../living_death.rs"));
let declared: BTreeSet<_> = section
.lines()
.chain(level_section.lines())
@@ -709,6 +711,7 @@ fn catalog_matches_hook_points() {
.chain(mob_hooks.iter().copied())
.chain(conn_hooks.iter().copied())
.chain(client_hooks.iter().copied())
.chain(death_hooks.iter().copied())
.collect();
let mut names = BTreeSet::new();
let mut contracts = BTreeSet::new();
@@ -755,6 +758,8 @@ fn catalog_matches_hook_points() {
format!("horizon_client_lib::lan::conn::native_{name}")
} else if client_hooks.contains(name) {
format!("horizon_client_lib::native_client_hooks::native_{name}")
} else if death_hooks.contains(name) {
format!("horizon_client_lib::living_death::native_{name}")
} else if level_hooks.contains(name) {
format!("horizon_client::game::native_hooks::GameLevel<'_>::native_{name}")
} else {
@@ -107,3 +107,61 @@ fn markers_are_listed_with_their_data() {
assert_eq!(game.dim.id(), overworld);
assert!(game.native_level_markers("minecraft:nowhere").is_empty());
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_shared_lookup_finds_the_level_in_place_and_leaves_the_others() {
let mut game = game();
let nether = "minecraft:the_nether";
let overworld = game.dim.id();
let here = "[I;7,0,0,3]";
let here_number = (7u128 << 96) | 3;
assert!(game.run_command(&format!(
"execute in {nether} run summon cow 8.5 70 8.5 {{UUID:{UUID},NoAI:1b}}"
)));
assert!(game.run_command(&format!("summon cow 3.5 80 3.5 {{UUID:{here},NoAI:1b}}")));
let shared = &game;
// The level in place is read from `&self`; the nether level is not, and says so with None.
let (dimension, id, kind) = shared
.native_find_entity_by_uuid(here_number)
.expect("found");
assert_eq!(
(dimension.as_str(), kind.as_str()),
(overworld, "minecraft:cow")
);
assert_eq!(shared.native_find_entity_by_uuid(UUID_NUMBER), None);
// The `&mut` lookup still reaches the nether level, and the same entity answers the same way.
assert_eq!(
game.native_entity_by_uuid(here_number),
Some((overworld.to_owned(), id, kind))
);
assert_eq!(
game.native_entity_by_uuid(UUID_NUMBER).map(|f| f.0),
Some(nether.to_owned())
);
assert_eq!(game.dim.id(), overworld);
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn an_entity_in_another_level_has_its_data_read_in_place() {
let mut game = game();
let nether = "minecraft:the_nether";
let overworld = game.dim.id();
assert!(game.run_command(&format!(
"execute in {nether} run summon cow 8.5 70 8.5 {{UUID:{UUID},NoAI:1b}}"
)));
let (dimension, id, _) = game.native_entity_by_uuid(UUID_NUMBER).expect("found");
let nbt = game
.native_entity_nbt_in(&dimension, id)
.expect("read in its own level");
assert_eq!(nbt.get(&"NoAI".into()), Some(&Tag::Byte(1)));
assert!(
!nbt.contains_key(&"id".into()),
"as native_entity_nbt: no id"
);
// The level in place is the overworld again, and the id names nothing there.
assert_eq!(game.dim.id(), overworld);
assert_eq!(game.native_entity_nbt_in(overworld, id), None);
assert_eq!(game.native_entity_nbt_in("minecraft:nowhere", id), None);
}
+53 -30
View File
@@ -21,6 +21,26 @@ impl Game<'_> {
/// packet is handled (the hub is taken out then): call it from a tick or
/// console hook. API @API:npc_entity_by_uuid@.
pub(super) fn native_entity_by_uuid(&mut self, uuid: u128) -> Option<(String, u32, String)> {
if let Some(found) = self.native_find_entity_by_uuid(uuid) {
return Some(found);
}
// The levels not in place: each one is put in place to look (`npc_in_level`).
let words = horizon_client::lan::playerdata::uuid_ints(uuid);
let dims: Vec<&'static str> = self.levels.parked_dims().iter().map(|d| d.id()).collect();
for dimension in dims {
if let Some(Some((id, kind))) =
self.npc_in_level(dimension, |g| g.uuid_holder_here(words))
{
return Some((dimension.to_owned(), id, kind));
}
}
None
}
/// `native_entity_by_uuid` from `&self`, for many read-only lookups: the server's players and the
/// entities of the level in place. A level not in place keeps its entities in its own state, and
/// reading one means putting it in place (a `&mut` swap), so those levels are left to
/// `native_entity_by_uuid`. Same answer as it for these; None on a client. API @API:npc_entity_lookup@.
pub(super) fn native_find_entity_by_uuid(&self, uuid: u128) -> Option<(String, u32, String)> {
if self.remote.is_some() || self.options.execution_role.is_client() {
return None;
}
@@ -32,37 +52,29 @@ impl Game<'_> {
return Some((dimension, id, "minecraft:player".into()));
}
let words = horizon_client::lan::playerdata::uuid_ints(uuid);
let dims: Vec<&'static str> = std::iter::once(self.dim)
.chain(self.levels.parked_dims())
.map(|d| d.id())
.collect();
for dimension in dims {
let found = self.npc_in_level(dimension, |g| {
// The UUIDs the level remembers, checked against its entities: a
// remembered id may belong to an entity that is gone.
let id = g
.persist
.uuids
.iter()
.find(|(_, w)| **w == words)
.map(|(id, _)| *id)?;
let t = g.level_entity(i32::try_from(id).ok()?)?;
if let Target::Mob(m) = t
&& !g.mobs.iter().any(|x| x.id == m && !x.removed)
{
return None;
}
// The mod API has no natural fence knots (#13036).
if let Target::Knot(_) = t {
return None;
}
Some((id, g.target_type(t)))
});
if let Some(Some((id, kind))) = found {
return Some((dimension.to_owned(), id, kind));
}
let (id, kind) = self.uuid_holder_here(words)?;
Some((self.dim.id().to_owned(), id, kind))
}
/// The entity of the level in place that `words` names, with its type: the UUIDs the level
/// remembers, checked against its entities (a remembered id may belong to an entity that is gone).
fn uuid_holder_here(&self, words: [i32; 4]) -> Option<(u32, String)> {
let id = self
.persist
.uuids
.iter()
.find(|(_, w)| **w == words)
.map(|(id, _)| *id)?;
let t = self.level_entity(i32::try_from(id).ok()?)?;
if let Target::Mob(m) = t
&& !self.mobs.iter().any(|x| x.id == m && !x.removed)
{
return None;
}
None
// The mod API has no natural fence knots (#13036).
if let Target::Knot(_) = t {
return None;
}
Some((id, self.target_type(t)))
}
/// The `minecraft:marker` entities of the loaded level `dimension`: each one's id, its position
/// and its `data` compound (empty for a marker saved without data), as `/data get entity <marker>
@@ -117,6 +129,17 @@ impl Game<'_> {
})
.unwrap_or(false)
}
/// The data of entity `id` in the loaded level `dimension`, as `native_entity_nbt` gives it for the
/// level in place (`/execute in <dimension> run data get entity`, without the `id`). Read after
/// `native_find_entity_by_uuid` or `native_entity_by_uuid` found the entity. None on a client, for a
/// level not loaded, and for no such entity. API @API:npc_entity_lookup@.
pub(super) fn native_entity_nbt_in(&mut self, dimension: &str, id: u32) -> Option<Compound> {
self.npc_in_level(dimension, |g| {
let entity = g.native_entity(id)?;
g.native_entity_nbt(entity)
})
.flatten()
}
/// Turns entity `id` of the loaded level `dimension` to `yaw` and `pitch`
/// in degrees, as `/rotate` does (`Entity.forceSetRotation`: `setYRot`,
/// `setXRot` clamped to -90..90, its body and head with it), without
@@ -34,6 +34,10 @@ pub(super) struct RecipeTables {
/// recipe_content`) over the window's own: what `Game::recipes` reads on
/// a client once they came.
synced: Option<horizon_client::crafting::Recipes>,
/// The display numbers of those recipes: the server numbers its book's
/// displays over its own recipe set, a world's data pack recipes
/// included, so the book numbers them the same (`Game::display_index`).
pub(super) synced_index: Option<horizon_client::recipe_book::RecipeIndex>,
/// The recipe content's parts so far, until its last.
incoming: Vec<(String, serde_json::Value)>,
/// The types that came before the player did: the mods hear of them
@@ -384,9 +388,18 @@ impl Game<'_> {
pub(super) fn synced_recipes(&self) -> Option<&horizon_client::crafting::Recipes> {
self.recipe_tables.synced.as_ref()
}
/// The display numbers the recipe book reads: the server's recipes',
/// once a server sent them, else the world's own.
pub(super) fn display_index(&self) -> &horizon_client::recipe_book::RecipeIndex {
match &self.recipe_tables.synced_index {
Some(index) => index,
None => &self.stations().recipe_index,
}
}
/// A new play session (`Login`): what the last server sent is gone.
pub(super) fn forget_synced_recipes(&mut self) {
self.recipe_tables.synced = None;
self.recipe_tables.synced_index = None;
self.recipe_tables.incoming.clear();
self.recipe_tables.received = None;
self.recipe_tables.tables = None;
@@ -453,6 +466,7 @@ impl Game<'_> {
let received = std::mem::take(&mut self.recipe_tables.incoming);
// No type any more: back on the window's own recipes.
if types.is_empty() {
self.recipe_tables.synced_index = None;
if self.recipe_tables.synced.take().is_some() {
self.recipe_tables.tables = None;
self.forget_book();
@@ -467,11 +481,18 @@ impl Game<'_> {
let of = |v: &serde_json::Value| types.iter().any(|t| v["type"].as_str() == Some(t));
let mut all: Vec<_> = own.into_iter().filter(|(_, v)| !of(v)).collect();
all.extend(received);
// The server numbers the displays over these same recipes: a
// world's data pack recipes shift the numbers of every recipe
// after them, and the book must agree (the server's entries go
// over its own by number).
let index = horizon_client::recipe_book::RecipeIndex::load_from_sources(&all);
horizon_client::crafting::Recipes::from_sources(root, self.items(), &overlay, all)
.map(|recipes| (recipes, index))
});
match built {
Ok(recipes) => {
Ok((recipes, index)) => {
self.recipe_tables.synced = Some(recipes);
self.recipe_tables.synced_index = Some(index);
// What was made of the window's own recipes is made again.
self.recipe_tables.tables = None;
self.forget_book();
@@ -761,6 +761,30 @@ impl Game<'_> {
.map(|lan| lan.hub.lan.shared.motd())
.unwrap_or_default()
}
/// Whether the dedicated server runs in online mode (`online-mode` of server.properties):
/// its players sign in with their accounts. None off a dedicated server and before it listens.
/// API @API:server_settings@.
pub(super) fn native_online_mode(&self) -> Option<bool> {
Some(online_mode_of(&self.dedicated_lan()?.shared.config))
}
/// The port the dedicated server listens on: `server-port` of server.properties, or the
/// `--port` it was started with. None off a dedicated server and before it listens.
/// API @API:server_settings@.
pub(super) fn native_server_port(&self) -> Option<u16> {
Some(self.dedicated_lan()?.port)
}
/// How the dedicated server takes its players from a proxy: `"velocity"` when it signs the
/// forwarded player information with `forwarding.secret` (offline mode behind Velocity),
/// `"none"` when it doesn't; None off a dedicated server and before it listens. The secret
/// itself is never returned. API @API:server_settings@.
pub(super) fn native_proxy_forwarding(&self) -> Option<&'static str> {
Some(proxy_forwarding_of(&self.dedicated_lan()?.shared.config))
}
/// The listening socket of a dedicated server; None on any other side and before it listens.
fn dedicated_lan(&self) -> Option<&horizon_client::lan::Lan> {
self.options.dedicated.as_ref()?;
Some(&self.lan.as_ref()?.hub.lan)
}
/// Whether a player has played on the open world before: their player
/// data file is there (`players/data/<uuid>.dat`). False when no world
/// is open. API @API:join_leave_helpers@.
@@ -1511,6 +1535,22 @@ const DEFAULT_WORLDS: [(&str, &str); 3] = [
("world_the_end", "minecraft:the_end"),
];
/// The online mode a dedicated server's network config carries: it has an
/// `OnlineMode` key (and so signs its logins) only in online mode.
fn online_mode_of(config: &horizon_client::lan::NetConfig) -> bool {
config.online.is_some()
}
/// The proxy forwarding a dedicated server's network config carries: the secret
/// is set only offline behind a proxy (game/dedicated.rs `forwarding_secret`).
fn proxy_forwarding_of(config: &horizon_client::lan::NetConfig) -> &'static str {
if config.forwarding_secret.is_some() {
"velocity"
} else {
"none"
}
}
fn dimension_of_world(world: &str) -> Option<&'static str> {
DEFAULT_WORLDS
.iter()
@@ -2136,4 +2176,22 @@ mod tests {
assert_eq!(world_of_dimension("minecraft:overworld"), Some("world"));
assert_eq!(world_of_dimension("world"), None);
}
#[test]
fn server_settings_follow_the_network_config() {
use horizon_client::lan::{NetConfig, OnlineMode};
let mut config = NetConfig::loopback(25565, String::new(), 20);
// online-mode=false, no proxy: no key, no forwarding secret.
assert!(!online_mode_of(&config));
assert_eq!(proxy_forwarding_of(&config), "none");
// online-mode=false behind Velocity: the secret is set.
config.forwarding_secret = Some(b"secret".to_vec());
assert!(!online_mode_of(&config));
assert_eq!(proxy_forwarding_of(&config), "velocity");
// online-mode=true: the server's key, and forwarding stays off.
config.forwarding_secret = None;
config.online = Some(std::sync::Arc::new(OnlineMode::generate(false).unwrap()));
assert!(online_mode_of(&config));
assert_eq!(proxy_forwarding_of(&config), "none");
}
}
@@ -12,7 +12,7 @@ use std::{
};
use horizon_client::text::Text;
use horizon_shader_api::{Api, Blend, Manifest, Pack, check};
use horizon_shader_api::{Api, Blend, Manifest, Pack, Primaries, check};
use super::Game;
@@ -202,6 +202,12 @@ impl Game<'_> {
/// (docs/design/hdr-linear-light.md, steps 3 and 4) isn't served yet. 0.2 itself is drawn
/// under `HORIZON_HDR_FRAME=float` (`switch`).
fn not_drawn(m: &Manifest, switch: bool) -> Option<String> {
if m.primaries == Primaries::Bt2020 {
return Some(
"[frame] primaries = \"bt2020\" isn't served yet: leave it out, or set \"bt709\""
.into(),
);
}
if m.blend == Blend::Linear {
return Some(
"[frame] blend = \"linear\" isn't served yet: leave it out, or set \"gamma\"".into(),
@@ -354,6 +360,14 @@ mod tests {
assert_eq!(not_drawn(&manifest("0.1", ""), switch), None);
let why = not_drawn(&manifest("0.2", "[frame]\nblend = \"linear\"\n"), switch);
assert!(why.unwrap().contains("blend = \"linear\" isn't served yet"));
let wide = "[frame]\nblend = \"linear\"\nprimaries = \"bt2020\"\n";
let why = not_drawn(&manifest("0.2", wide), switch).unwrap();
assert!(
why.contains("primaries = \"bt2020\" isn't served yet"),
"{why}"
);
let named = manifest("0.2", "[frame]\nprimaries = \"bt709\"\n");
assert_eq!(not_drawn(&named, true), None);
}
let why = not_drawn(&manifest("0.2", ""), false).unwrap();
assert!(
@@ -237,6 +237,69 @@ impl Game<'_> {
any
}
/// The vehicle the entity `entity` (a server player or a mob) sits on, by entity id, as
/// Bukkit's `Entity.getVehicle` reads it: a boat, a minecart or a mob. None when it rides
/// nothing, is not reached (see `native_rider`: inside another player's hook only the acting
/// player and mobs are), and on a client. Read-only. API @API:riding_getters@.
pub(super) fn native_vehicle_of(&self, entity: u32) -> Option<u32> {
if !self.native_serves() {
return None;
}
match self.native_rider(entity)? {
Target::Player => self.riding,
Target::Guest(conn) => {
self.lan
.as_ref()?
.hub
.guests
.iter()
.find(|g| g.conn == conn)?
.vehicle
}
Target::Mob(id) => self.mobs.iter().find(|m| m.id == id)?.extra.mount,
_ => None,
}
}
/// The entity ids of the server players and mobs riding the vehicle `entity` (a boat, a
/// minecart or a mob), in vanilla's `getPassengers` order: the acting player, then the hub's
/// guests in the level, then the mobs in the level's order, so the first is the controlling
/// passenger where a player rides. Empty when nothing rides it, when it is no vehicle Horizon
/// seats riders on, and on a client. Only what `native_rider` reaches: inside another
/// player's hook the hub's guests are not listed. Read-only. API @API:riding_getters@.
pub(super) fn native_passengers_of(&self, entity: u32) -> Vec<u32> {
if !self.native_serves() || self.native_vehicle_target(entity).is_none() {
return Vec::new();
}
let mut riders = Vec::new();
if self.lan_acting.is_some() && self.riding == Some(entity) {
riders.push(self.native_acting_entity());
}
if self.lan_acting.is_none()
&& let Some(host) = &self.lan
{
riders.extend(
host.hub
.guests
.iter()
.filter(|g| {
g.placed
&& !g.dead
&& g.vehicle == Some(entity)
&& g.dimension == self.dim.id()
})
.map(|g| g.entity_id as u32),
);
}
riders.extend(
self.mobs
.iter()
.filter(|m| m.extra.mount == Some(entity) && !m.removed && m.alive())
.map(|m| m.id),
);
riders
}
/// The rider an entity id names in the level in place: the acting player, a server player
/// out of their turn (only while nobody's is swapped in), or a living mob.
fn native_rider(&self, entity: u32) -> Option<Target> {
@@ -391,3 +454,113 @@ impl Game<'_> {
}
}
}
#[cfg(test)]
mod tests {
use super::super::*;
use horizon_client::mod_api::NativeAuthority;
use horizon_world::nbt::Compound;
fn game() -> Game<'static> {
let root = std::path::PathBuf::from(
std::env::var_os("HORIZON_TEST_RESOURCES")
.expect("set HORIZON_TEST_RESOURCES to prepared resources"),
);
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = execution_role::ExecutionRole::IntegratedServer;
options.dedicated = Some(Vec::new());
let res: &'static _ = Box::leak(Box::new(loaded.resources));
let mut game = Game::new(
res,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.build_registries();
game.set_allow_commands(true);
game.native_lifecycle
.begin_open(NativeAuthority::IntegratedServer);
game.world.chunks.insert(
(0, 0),
std::sync::Arc::new(horizon_client::chunk::Chunk::empty(0, 0)),
);
game.block_ticks
.add_empty_chunk(horizon_core::position::ChunkPos::new(0, 0))
.unwrap();
game
}
const AT: [f64; 3] = [4.5, 200.0, 4.5];
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_mob_on_a_mob_reads_as_mounted_and_clears_after_a_dismount() {
let mut game = game();
let dim = game.dim.id();
let pig = game
.native_summon(dim, "minecraft:pig", AT, Compound::new())
.unwrap();
let chicken = game
.native_summon(dim, "minecraft:chicken", AT, Compound::new())
.unwrap();
assert_eq!(game.native_vehicle_of(chicken), None);
assert!(game.native_passengers_of(pig).is_empty());
assert!(game.native_mount(chicken, pig));
assert_eq!(game.native_vehicle_of(chicken), Some(pig));
assert_eq!(game.native_passengers_of(pig), vec![chicken]);
assert_eq!(game.native_vehicle_of(pig), None);
assert!(game.native_passengers_of(chicken).is_empty());
assert!(game.native_dismount(chicken));
assert_eq!(game.native_vehicle_of(chicken), None);
assert!(game.native_passengers_of(pig).is_empty());
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_mob_in_a_boat_is_listed_by_the_boat() {
let mut game = game();
let dim = game.dim.id();
let boat = game
.native_summon(dim, "minecraft:oak_boat", AT, Compound::new())
.unwrap();
let cow = game
.native_summon(dim, "minecraft:cow", AT, Compound::new())
.unwrap();
assert!(game.native_mount(cow, boat));
assert_eq!(game.native_vehicle_of(cow), Some(boat));
assert_eq!(game.native_passengers_of(boat), vec![cow]);
assert_eq!(game.native_vehicle_of(boat), None);
assert!(game.native_passengers_of(cow).is_empty());
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn an_unknown_entity_reads_as_nothing() {
let mut game = game();
let dim = game.dim.id();
let boat = game
.native_summon(dim, "minecraft:oak_boat", AT, Compound::new())
.unwrap();
assert_eq!(game.native_vehicle_of(u32::MAX), None);
assert!(game.native_passengers_of(u32::MAX).is_empty());
assert!(game.native_passengers_of(boat).is_empty());
}
}
+88 -36
View File
@@ -9,7 +9,7 @@
//! A server keeps one level per dimension. Entity portal processes and
//! cooldowns follow their level; shared `Arrival`s hold each traveller's
//! saved compound until its destination chunks are loaded.
use super::entity::{Entity as _, entity_lists_of, for_each_entity};
use super::entity::{Entity as _, entity_lists_of, for_each_entity, with_entity};
use super::{Dim, Game, Result, Screen, level_flags};
use horizon_client::fast_hash::FxHashMap;
use horizon_client::nether_portal::{ENTITY_DELAY, PortalState};
@@ -269,6 +269,16 @@ fn arrival_from_tag(a: &Tag) -> Option<Arrival> {
})
}
/// Keep an explicitly captured counter. Old archives without a positive counter
/// retain the historical fallback; their migration needs separate evidence.
fn preserve_arrival_cooldown(tag: &mut Tag) {
if let Tag::Compound(fields) = tag
&& !matches!(fields.get(&"PortalCooldown".into()), Some(Tag::Int(delay)) if *delay > 0)
{
fields.insert("PortalCooldown".into(), Tag::Int(ENTITY_DELAY));
}
}
impl Game<'_> {
/// The world border's extent.
pub(super) fn border_bounds(&self) -> BorderBounds {
@@ -630,15 +640,15 @@ impl Game<'_> {
/// at the start of their tick: an item or mob that stayed in a portal
/// goes through (transition time 0) and waits for its dimension.
pub(super) fn handle_entity_portals(&mut self) {
self.handle_portals(false);
self.handle_portals(false, false);
}
/// `Entity.handlePortal` of the boats and minecarts, which `tick_boats`
/// and `tick_carts` move: it runs before them, from the position they
/// ended the last tick at, as the start of a vehicle's own tick does.
pub(super) fn handle_vehicle_portals(&mut self) {
self.handle_portals(true);
self.handle_portals(true, false);
}
fn handle_portals(&mut self, vehicles: bool) {
fn handle_portals(&mut self, vehicles: bool, after_move: bool) {
if self.remote.is_some() {
return;
}
@@ -653,51 +663,47 @@ impl Game<'_> {
if vehicles {
before.clear();
}
let mut ids: Vec<(u32, bool)> = Vec::new();
// ServerLevel.tick ticks only the entities in entity-ticking chunks,
// and handlePortal runs in their tick: past the simulation distance an
// entity's portal time and cooldown wait, kept as they are.
let mut ids: Vec<(u32, bool, i32)> = Vec::new();
// Portal handling runs only in entity-ticking chunks; retain frozen
// states while their entities are outside the simulation distance.
let entity_ticking = self.entity_ticking_area();
let mut waiting: Vec<u32> = Vec::new();
let tracked = &self.portals.entities;
let mut present = std::collections::HashSet::new();
for_each_entity!(entity_lists_of!(self), entity => {
let can_sweep = entity.sweeps_portals();
let has_portal_state = tracked.contains_key(&entity.id());
if has_portal_state {
present.insert(entity.id());
}
// Entity.processPortalCooldown still runs for entities this
// portal pass cannot send, such as an ender pearl.
if entity.is_vehicle() == vehicles && (can_sweep || has_portal_state) {
if entity.is_vehicle() == vehicles
&& entity.portal_after_move() == after_move
&& (can_sweep || has_portal_state)
{
let at = entity.position();
if !entity_ticking(at.x.floor() as i32, at.z.floor() as i32) {
if has_portal_state {
waiting.push(entity.id());
}
continue;
}
if can_sweep && entity.portal_from().is_none() {
before.insert(entity.id(), entity.position());
}
if has_portal_state {
ids.push((entity.id(), can_sweep && entity.can_use_portal(self)));
ids.push((
entity.id(),
can_sweep && entity.can_use_portal(self),
entity.dimension_changing_delay(),
));
}
}
});
self.portals.before = before;
let present: std::collections::HashSet<u32> = ids
.iter()
.map(|(id, _)| *id)
.chain(waiting.iter().copied())
.collect();
// Each pass keeps the other group's states.
let (boats, carts) = (&self.boats, &self.carts);
self.portals.entities.retain(|id, _| {
let vehicle = boats.iter().any(|b| b.id == *id) || carts.iter().any(|c| c.id == *id);
present.contains(id) || vehicle != vehicles
});
for (id, allowed) in ids {
self.portals.entities.retain(|id, _| present.contains(id));
for (id, allowed, delay) in ids {
let Some(state) = self.portals.entities.get_mut(&id) else {
continue;
};
if let Some(entry) = state.handle(allowed, 0, ENTITY_DELAY) {
if let Some(entry) = state.handle(allowed, 0, delay) {
leaving.push((id, entry));
}
if state.cooldown == 0 && state.process.is_none() {
@@ -739,6 +745,16 @@ impl Game<'_> {
);
}
}
/// The live entity's `getDimensionChangingDelay`; unknown ids use Entity's default.
pub(super) fn entity_portal_delay(&self, id: u32) -> i32 {
self.entity_handles()
.find_map(|handle| {
with_entity!(self, handle, entity => {
(entity.id() == id).then(|| entity.dimension_changing_delay())
})
})
.unwrap_or(ENTITY_DELAY)
}
/// `Entity.saveWithoutId`: the modeled non-arrow projectile codecs
/// share the live cooldown between command reads and chunk writes.
pub(super) fn save_projectile_portal_cooldown(
@@ -760,21 +776,23 @@ impl Game<'_> {
/// `Entity.setAsInsidePortal` on an entity that is still on its portal
/// cooldown, whatever the portal: the cooldown restarts and it stays.
pub(super) fn restart_portal_cooldown(&mut self, id: u32) -> bool {
let delay = self.entity_portal_delay(id);
match self.portals.entities.get_mut(&id) {
Some(state) if state.cooldown > 0 => {
state.cooldown = ENTITY_DELAY;
state.cooldown = delay;
true
}
_ => false,
}
}
/// `Entity.setPortalCooldown` on an entity that has just spawned in a
/// portal: it stands in the portal for 300 ticks without going through.
/// portal: it waits for its entity-specific cooldown without going through.
pub(super) fn set_portal_cooldown(&mut self, id: u32) {
let delay = self.entity_portal_delay(id);
self.portals.entities.insert(
id,
PortalState {
cooldown: ENTITY_DELAY,
cooldown: delay,
process: None,
},
);
@@ -844,6 +862,10 @@ impl Game<'_> {
/// transition time is 0 too).
pub(super) fn find_entity_portals(&mut self) {
self.find_portals(None);
// AbstractArrow, ThrowableProjectile and AbstractHurtingProjectile apply
// block effects before calling Entity.tick, so their refreshed cooldown
// is decremented (or their new portal handled) in this same tick.
self.handle_portals(false, true);
}
/// The same for the vehicles server players ride, once `tick_guests` has
/// moved them: a ridden vehicle moves with its rider, after the other
@@ -878,7 +900,7 @@ impl Game<'_> {
{
super::lan_entities::assign_entity_id(&mut self.next_entity, &mut r.id);
}
let mut inside: Vec<(u32, BlockPos)> = Vec::new();
let mut inside: Vec<(u32, BlockPos, i32)> = Vec::new();
let mut portal_sections = std::mem::take(&mut self.portals.sections);
// No loaded section has a portal state in its palette (nearly always): no
// sweep below can meet a portal block, so the walk would find nothing.
@@ -916,16 +938,16 @@ impl Game<'_> {
continue;
};
if let Some(pos) = self.swept_portal(from, to, width, height, &mut portal_sections) {
inside.push((id, pos));
inside.push((id, pos, entity.dimension_changing_delay()));
}
});
self.portals.sections = portal_sections;
for (id, pos) in inside {
for (id, pos, delay) in inside {
self.portals
.entities
.entry(id)
.or_default()
.set_inside(pos, ENTITY_DELAY);
.set_inside(pos, delay);
}
}
/// The first nether or End portal block an entity box swept through
@@ -1034,6 +1056,7 @@ impl Game<'_> {
/// entity is saved and removed, and waits for its dimension to load.
fn send_through_portal(&mut self, id: u32, entry: BlockPos) {
let arrivals = self.portals.arrivals.len();
let delay = self.entity_portal_delay(id);
if let Some(i) = self.dropped.iter().position(|e| e.id == id) {
let e = &self.dropped[i];
let plan = self.entity_plan(entry, e.position, 0.25, 0.25, false);
@@ -1203,6 +1226,11 @@ impl Game<'_> {
self.portals.dirty = true;
}
if self.portals.arrivals.len() > arrivals {
if let Some(arrival) = self.portals.arrivals.last_mut()
&& let Tag::Compound(tag) = &mut arrival.tag
{
tag.insert("PortalCooldown".into(), Tag::Int(delay));
}
self.native_entity_portal(self.dim.id(), id, [entry.x, entry.y, entry.z]);
}
}
@@ -1301,9 +1329,8 @@ impl Game<'_> {
"Rotation".into(),
Tag::List(vec![Tag::Float(yaw.to_bits()), Tag::Float(pitch.to_bits())]),
);
// setPortalCooldown before the teleport, copied by restoreFrom.
c.insert("PortalCooldown".into(), Tag::Int(ENTITY_DELAY));
}
preserve_arrival_cooldown(&mut tag);
// ServerLevel.addDuringTeleport: a fresh add, not loaded from disk.
super::persist::mark_fresh(&mut tag);
let key = ((at.x.floor() as i32) >> 4, (at.z.floor() as i32) >> 4);
@@ -1613,6 +1640,27 @@ mod tests {
);
}
#[test]
fn legacy_arrivals_keep_explicit_positive_counters_without_type_migration() {
for old in [None, Some(0), Some(-1), Some(5)] {
let mut tag = Tag::compound([("id", Tag::String("minecraft:oak_boat".into()))]);
if let (Some(old), Tag::Compound(fields)) = (old, &mut tag) {
fields.insert("PortalCooldown".into(), Tag::Int(old));
}
preserve_arrival_cooldown(&mut tag);
let arrival = Arrival {
plan: Plan::end_portal(Dim::Overworld, BlockPos::new(0, 64, 0), 1.375, 0.5625),
tag,
};
let restored = arrival_from_tag(&arrival_tag(&arrival)).unwrap();
let expected = old.filter(|n| *n > 0).unwrap_or(ENTITY_DELAY);
assert_eq!(
restored.tag.get("PortalCooldown"),
Some(&Tag::Int(expected))
);
}
}
#[test]
fn waiting_arrivals_keep_their_end_destination_when_saved() {
let tag = Tag::compound([("id", Tag::String("minecraft:cow".into()))]);
@@ -1638,3 +1686,7 @@ mod tests {
assert!(!arrival_from_tag(&old).unwrap().plan.end);
}
}
#[cfg(test)]
#[path = "portal_cooldown_tests.rs"]
mod cooldown_tests;
+1 -1
View File
@@ -749,7 +749,7 @@ impl Game<'_> {
pub(super) fn hold_fullbright(&mut self) {
let held = fullbright_held(
self.remote.as_ref().map(|r| r.save_folder.is_some()),
self.studio_replay.is_some(),
self.replay.playing(),
);
self.settings.fullbright_held = held;
if let Some(r) = self.renderer.as_mut() {
+1
View File
@@ -420,6 +420,7 @@ impl Game<'_> {
self.random_tick_sections.clear();
self.portals.sections.clear();
self.options.resources = root.clone();
self.falling_vertices.get_mut().clear();
self.pack = pack;
self.gui = gui;
// The new atlas's skin and map slots are blank: players and maps
@@ -45,8 +45,9 @@ fn playing(test: impl FnOnce(&mut Game)) {
test(&mut game);
}
/// Hold `button` on the pad, as a poll that saw it go down would.
fn pad_press(game: &mut Game, button: Button, action: &str) {
/// Hold `button` on the pad, as a poll that saw it go down would: the press
/// is recorded as `pad_button` records it, which is what `holds_action` reads.
fn pad_press(game: &mut Game, button: Button, action: &'static str) {
let mut state = PadState::default();
// A trigger is held by how far it's pulled, not by a button bit.
match button {
@@ -55,6 +56,7 @@ fn pad_press(game: &mut Game, button: Button, action: &str) {
_ => state.set(button, true),
}
game.pad.edges.update(&state, 0.4);
game.pad.actions[button as usize] = vec![action];
game.pad_action(action, true).unwrap();
}
@@ -0,0 +1,159 @@
//! `PhantomSpawner.tick` tests the player's position with `Level.canSeeSky`,
//! "sky light 15 here" (`BlockAndLightGetter.canSeeSky`), not a scan for a
//! full collision cube above. Glass and stained glass are full collision cubes
//! the sky light passes, so phantoms spawn under them; stone, water (which
//! damps the light) and a bottom slab (whose up face occludes the sky-light
//! column) do not. The world is lit by the vanilla light engine, not by hand.
use super::super::*;
use horizon_client::chunk::Chunk;
use horizon_client::light::{LightTables, apply_lighting, light_chunk};
use std::sync::Arc;
/// The prepared resources, or `None` where CI has none (the test then skips).
fn resources() -> Option<std::path::PathBuf> {
let root = std::env::var_os("HORIZON_TEST_RESOURCES")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION)
});
(root.join("occlusion.nbt").is_file() && root.join("entity-types.json").is_file())
.then_some(root)
}
/// A server's game on `root`: hard difficulty, a long-inhabited chunk, the
/// player at (2.5, 99, 2.5), midnight (sky darken 11) and a fixed level seed.
fn game(root: &std::path::Path) -> Game<'static> {
let settings = Settings::default();
let loaded = load_resources(
root.to_path_buf(),
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.to_path_buf();
options.execution_role = execution_role::ExecutionRole::IntegratedServer;
let res: &'static _ = Box::leak(Box::new(loaded.resources));
let mut game = Game::new(
res,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(root),
None,
)
.unwrap();
game.build_registries();
game.set_allow_commands(true);
assert!(game.run_command("difficulty hard"));
for x in -1..=0 {
for z in -1..=0 {
game.world
.chunks
.insert((x, z), Arc::new(Chunk::empty(x, z)));
}
}
// `getCurrentDifficultyAt`: an old chunk is harder, so every attempt
// passes `isHarderThan(random * 3)` and the seed decides only the spawn.
game.world.inhabited.insert((0, 0), 3_600_000);
game.world.day_time = 18000;
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(2.5, 99.0, 2.5)).unwrap());
game.stats.custom("time_since_rest", i32::MAX);
game.world.random.set_seed(0x5EED);
game
}
/// The sky light under a 5×5 roof of `roof` at y=100 (`None`: open sky), lit by
/// `tables`, and the phantoms 64 spawn attempts make for the player under it.
fn attempt(game: &mut Game<'_>, tables: &LightTables, roof: Option<&str>) -> (i32, Vec<Vec3>) {
let mut chunk = Chunk::empty(0, 0);
if let Some(roof) = roof {
let state = game.pack.assets.registry.default_state(roof).unwrap().0;
for x in 0..5 {
for z in 0..5 {
chunk.set(x, 100, z, state);
}
}
}
let region = [
[None, None, None],
[None, Some(&chunk), None],
[None, None, None],
];
let lights = light_chunk(&region, tables, true);
apply_lighting(&mut chunk, lights);
game.world.chunks.insert((0, 0), Arc::new(chunk));
game.mobs.clear();
let mut spawned = Vec::new();
// `random.nextInt(timeSinceRest) >= 72000` almost always passes at
// `i32::MAX`; 64 attempts leave no room for the draw to decide the test.
for _ in 0..64 {
game.stats.phantom_tick = 0;
game.spawn_phantoms();
spawned = game
.mobs
.iter()
.filter(|m| m.species() == horizon_client::mobs::Species::Phantom)
.map(|m| m.position)
.collect();
if !spawned.is_empty() {
break;
}
}
(i32::from(game.world.light(2, 99, 2) >> 4), spawned)
}
#[test]
fn phantoms_spawn_under_a_glass_roof() {
let Some(root) = resources() else { return };
let mut game = game(&root);
let tables = LightTables::load(&root, game.pack.assets.registry, game.res.facts()).unwrap();
// Open night sky.
let (sky, phantoms) = attempt(&mut game, &tables, None);
assert_eq!(sky, 15, "open sky light");
assert!(!phantoms.is_empty(), "open sky spawns phantoms");
// Full collision cubes the sky light still passes.
for roof in ["minecraft:glass", "minecraft:red_stained_glass"] {
let (sky, phantoms) = attempt(&mut game, &tables, Some(roof));
assert_eq!(sky, 15, "{roof} passes the sky light down");
assert!(!phantoms.is_empty(), "phantoms spawn under {roof}");
// `playerPos.above(20 + nextInt(15)).east(-10 + nextInt(21))...`
for at in phantoms {
assert!(
(119.0..=133.0).contains(&at.y) && (at.x - 2.0).abs() <= 10.5,
"{at:?} is not in the spawn box above the player"
);
}
}
}
#[test]
fn no_phantoms_under_a_roof_the_sky_light_does_not_reach() {
let Some(root) = resources() else { return };
let mut game = game(&root);
let tables = LightTables::load(&root, game.pack.assets.registry, game.res.facts()).unwrap();
// Stone stops the light; water and ice damp it by a level; a bottom slab
// is no full cube yet its up face occludes the sky-light column.
for roof in [
"minecraft:stone",
"minecraft:water",
"minecraft:ice",
"minecraft:stone_slab",
] {
let (sky, phantoms) = attempt(&mut game, &tables, Some(roof));
assert!(sky < 15, "{roof} keeps the sky light out, got {sky}");
assert!(phantoms.is_empty(), "no phantom under {roof}");
}
}
+64 -1
View File
@@ -79,6 +79,7 @@ impl Game<'_> {
/// TNT duper's TNT stops at the block that has not moved yet (#11399).
pub(super) fn push_piston_entities(&mut self, pushes: Vec<Push>) {
let mut accumulators = std::collections::HashMap::new();
let mut pricked = Vec::new();
// Where each moving piston first sits in both lists, so a clock of a
// thousand pistons doesn't scan them a thousand times a tick.
let mut index = PistonIndex::default();
@@ -92,9 +93,58 @@ impl Game<'_> {
let targets = self.piston_push_targets(&pushes, &steps);
for push in pushes {
let pos = push.0;
self.push_for_piston(push, &targets, &steps, &mut accumulators);
self.push_for_piston(push, &targets, &steps, &mut accumulators, &mut pricked);
self.advance_piston_collision(pos, &index);
}
self.hurt_cactus_pricked_vehicles(pricked);
}
/// `moveEntityByPiston`'s `applyEffectsFromBlocks` for a boat or a
/// minecart: every cactus its box touches on the way from `start` to
/// `end` pricks it (`CactusBlock.entityInside`, `Entity.checkInsideBlocks`
/// over the box less 1e-5), even when the push moved it nowhere. The hurt
/// waits for the end of the tick's pushes, whose targets are found first.
fn piston_cactus_contact(
&self,
target: Target,
start: Aabb,
end: Aabb,
pricked: &mut Vec<u32>,
) {
let id = match target {
Target::Entity(EntityHandle::Boat(i)) => self.boats[i].id,
Target::Entity(EntityHandle::Cart(i)) => self.carts[i].id,
_ => return,
};
let eps = f64::from(1.0e-5_f32);
let (a, b) = (start.min_position(), end.min_position());
let lo = Vec3::new(a.x.min(b.x), a.y.min(b.y), a.z.min(b.z));
let (a, b) = (start.max_position(), end.max_position());
let hi = Vec3::new(a.x.max(b.x), a.y.max(b.y), a.z.max(b.z));
let facts = self.res.facts();
for x in (lo.x + eps).floor() as i32..=(hi.x - eps).floor() as i32 {
for y in (lo.y + eps).floor() as i32..=(hi.y - eps).floor() as i32 {
for z in (lo.z + eps).floor() as i32..=(hi.z - eps).floor() as i32 {
if &*facts.get(self.world.block(x, y, z)).name == "minecraft:cactus" {
pricked.push(id);
}
}
}
}
}
/// `Entity.hurt(cactus, 1)` for the vehicles `piston_cactus_contact`
/// found, in the order of their pushes (`VehicleEntity.hurtServer`: one
/// more point of damage; a vehicle a piston pushes in a chunk that does
/// not tick entities never wears it off, so the fifth breaks it).
fn hurt_cactus_pricked_vehicles(&mut self, pricked: Vec<u32>) {
for id in pricked {
if let Some(i) = self.boats.iter().position(|b| b.id == id) {
self.hurt_boat(i, 1.0, false);
} else if let Some(i) = self.carts.iter().position(|c| c.id == id) {
self.hurt_cart(i, 1.0, false, false);
}
}
}
/// The bodies any of this tick's moving blocks can reach, in
@@ -373,6 +423,7 @@ impl Game<'_> {
targets: &[Target],
steps: &StepHeights,
accumulators: &mut std::collections::HashMap<Target, PistonMovement>,
pricked: &mut Vec<u32>,
) {
let delta_progress = f64::from(new_progress - piston.progress);
if delta_progress <= 0.0 {
@@ -459,6 +510,7 @@ impl Game<'_> {
step.scale(amount),
movement,
accumulators,
pricked,
);
// `fixEntityWithinPistonBase`: a retracting source piston drags
// an entity that is still inside its own block.
@@ -482,6 +534,7 @@ impl Game<'_> {
back.scale(fix),
movement,
accumulators,
pricked,
);
}
}
@@ -510,6 +563,7 @@ impl Game<'_> {
piston_push::step_vector(movement).scale(delta_progress),
movement,
accumulators,
pricked,
);
}
}
@@ -669,6 +723,7 @@ impl Game<'_> {
movement: Vec3,
piston_direction: Direction,
accumulators: &mut std::collections::HashMap<Target, PistonMovement>,
pricked: &mut Vec<u32>,
) {
let Some(limited) = self.limit_piston(target, movement, accumulators) else {
return;
@@ -686,6 +741,7 @@ impl Game<'_> {
self.projectile_piston_block_effects(i, at);
}
self.piston_block_triggers(before, before);
self.piston_cactus_contact(target, before, before, pricked);
self.release_after_block_move(target);
return;
}
@@ -787,6 +843,9 @@ impl Game<'_> {
if let Some((i, from, ..)) = tnt {
self.finish_tnt_piston_move(i, from, resolved);
}
if let Target::Entity(EntityHandle::Falling(i)) = target {
self.finish_falling_piston_move(i, resolved);
}
if let Some((i, from)) = projectile_from {
self.projectile_piston_block_effects(i, from);
}
@@ -796,6 +855,7 @@ impl Game<'_> {
before
};
self.piston_block_triggers(before, after);
self.piston_cactus_contact(target, before, after, pricked);
}
/// `Entity.restituteMovementAfterCollisions` after a block's push, for a
@@ -1571,6 +1631,7 @@ mod h255_mob_piston_ground_tests {
movement,
Direction::East,
&mut std::collections::HashMap::new(),
&mut Vec::new(),
);
}
/// `AbstractArrow.move` after a push the collisions cut to nothing: a stuck
@@ -1606,6 +1667,7 @@ mod h255_mob_piston_ground_tests {
Vec3::new(0.3, 0.0, 0.0),
Direction::East,
&mut std::collections::HashMap::new(),
&mut Vec::new(),
);
let p = &game.creatures.projectiles[i];
assert_eq!(p.position, before, "the stone stops the push");
@@ -1643,6 +1705,7 @@ mod h255_mob_piston_ground_tests {
movement,
Direction::Down,
budget,
&mut Vec::new(),
);
};
push(game, &mut budget, Vec3::ZERO);
@@ -58,6 +58,22 @@ impl Game<'_> {
self.keys.remove(&key);
}
}
pub(super) fn update_modifiers(&mut self, state: winit::keyboard::ModifiersState) {
self.modifiers = state;
horizon_client_input::modifiers::reconcile(
self.modifiers,
&mut self.physical_keys,
&self.settings.keys,
&mut self.toggles,
[self.settings.toggle_crouch, self.settings.toggle_sprint],
[
self.pad.holds_action("key.sneak"),
self.pad.holds_action("key.sprint"),
],
);
self.sync_toggle(SNEAK);
self.sync_toggle(SPRINT);
}
/// Let a held sneak, sprint or modifier key go when the keyboard reports
/// its modifier up. A release does not always reach the mapping: a screen
/// reads presses only, and an unfocused window is given no release at all,
@@ -90,6 +106,16 @@ impl Game<'_> {
self.physical_keys.remove(&key);
}
}
if !self.modifiers.alt_key() {
for key in [KeyCode::AltLeft, KeyCode::AltRight] {
self.physical_keys.remove(&key);
}
}
if !self.modifiers.super_key() {
for key in [KeyCode::SuperLeft, KeyCode::SuperRight] {
self.physical_keys.remove(&key);
}
}
for (slot, mapping, needs_toggle) in [
(0, "key.sneak", self.settings.toggle_crouch),
(1, "key.sprint", self.settings.toggle_sprint),
@@ -111,12 +111,11 @@ impl Game<'_> {
equipped.push((self.inventory[self.selected].as_ref(), "mainhand"));
equipped.push((self.offhand.as_ref(), "offhand"));
for (stack, slot) in equipped {
let Some(info) = stack.and_then(|s| world_items!(self).get(&s.item)) else {
let Some(stack) = stack else {
continue;
};
for (attribute, id, amount, op, group) in &info.modifiers {
// The attack code still reads the held item's attack damage and
// speed directly; the map copy is what `/attribute` reports.
let modifiers = world_items!(self).attribute_modifiers(stack);
for (attribute, id, amount, op, group) in &modifiers {
if !equipment_modifier_applies(group, slot) {
continue;
}
@@ -339,6 +339,7 @@ impl Game<'_> {
Cursor::Arrow => winit::window::CursorIcon::Default,
Cursor::PointingHand => winit::window::CursorIcon::Pointer,
Cursor::NotAllowed => winit::window::CursorIcon::NotAllowed,
Cursor::ResizeNs => winit::window::CursorIcon::NsResize,
});
}
self.cursor_icon = effective;
@@ -0,0 +1,171 @@
//! Direct production handlers over an empty in-memory world; no server or renderer.
use super::*;
use crate::game::{Audio, Options, Progress, Settings, load_resources};
use horizon_client::chunk::Chunk;
use std::sync::Arc;
#[test]
#[ignore = "needs explicit HORIZON_TEST_RESOURCES and HORIZON_PORTAL_REFERENCE"]
fn portal_contact_and_waiting_handoff_match_vanilla_counters() {
let reference: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(std::env::var_os("HORIZON_PORTAL_REFERENCE").unwrap()).unwrap(),
)
.unwrap();
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.headless = true;
options.game_dir = Some(std::env::current_dir().unwrap());
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.build_registries();
game.options.dedicated = Some(Vec::new());
game.world = horizon_client::world::World::new(game.res.facts().air, 1);
for z in -2..=2 {
for x in -2..=2 {
game.world
.chunks
.insert((x, z), Arc::new(Chunk::empty(x, z)));
}
}
let entry = BlockPos::new(4, 210, 4);
let portal = game
.pack
.assets
.registry
.default_state("minecraft:nether_portal")
.unwrap();
game.world.set(entry.x, entry.y, entry.z, portal).unwrap();
let kinds = [
"minecraft:oak_boat",
"minecraft:minecart",
"minecraft:arrow",
"minecraft:pig",
];
let mut ids = Vec::new();
for (index, kind) in kinds.iter().enumerate() {
let uuid = [11318, 10, 0, index as i32];
let tag = Tag::compound([
("id", Tag::String((*kind).into())),
("UUID", Tag::IntArray(uuid.to_vec())),
("Pos", doubles([4.5, 210.0, 4.5])),
("NoAI", Tag::Byte(1)),
("NoGravity", Tag::Byte(1)),
("PortalCooldown", Tag::Int(5)),
]);
game.load_listed(kind, Some(uuid), None, tag).unwrap();
ids.push(game.next_entity);
}
assert_eq!(game.boats.len(), 1);
assert_eq!(game.carts.len(), 1);
assert_eq!(game.creatures.projectiles.len(), 1);
assert_eq!(game.mobs.len(), 1);
// Direct handler calls do not refresh the headless world's chunk tickets.
// Frozen entities retain their counters across every group/phase pass.
game.tickets
.update(Some(crate::ticket_levels::Sources::default()));
game.handle_vehicle_portals();
game.handle_entity_portals();
game.handle_portals(false, true);
for id in &ids {
assert_eq!(game.portals.entities.get(id).unwrap().cooldown, 5);
}
game.tickets.update(Some(crate::ticket_levels::Sources {
forced: vec![(0, 0)],
..Default::default()
}));
assert!(game.entity_ticking_area()(entry.x, entry.z));
let mut runtime = Vec::new();
for (step, ticks) in [1, 3, 1, 1, 8].into_iter().enumerate() {
if step == 2 {
game.world
.set(entry.x, entry.y, entry.z, game.res.facts().air)
.unwrap();
}
for _ in 0..ticks {
game.handle_vehicle_portals();
game.handle_entity_portals();
game.find_entity_portals();
}
runtime.push(
ids.iter()
.map(|id| {
game.portals
.entities
.get(id)
.map_or(0, |state| state.cooldown)
})
.collect::<Vec<_>>(),
);
}
// The actual departure handler emits a waiting compound before any destination is resolved.
let end = game
.pack
.assets
.registry
.default_state("minecraft:end_portal")
.unwrap();
game.world.set(entry.x, entry.y, entry.z, end).unwrap();
for id in &ids {
game.portals.entities.insert(*id, PortalState::default());
}
game.find_entity_portals();
game.handle_vehicle_portals();
game.handle_entity_portals();
assert_eq!(
game.portals.arrivals.len(),
4,
"all four production travellers departed"
);
let mut handoff = Vec::new();
for kind in kinds {
let arrival = game
.portals
.arrivals
.iter()
.find(|a| horizon_client::save::text(a.tag.get("id")).as_deref() == Some(kind))
.unwrap();
let restored = arrival_from_tag(&arrival_tag(arrival)).unwrap();
let counter = counter(&restored.tag);
game.load_listed(kind, None, None, restored.tag).unwrap();
let id = game.next_entity;
assert_eq!(
game.portals.entities.get(&id).map_or(0, |s| s.cooldown),
counter
);
handoff.push(counter);
}
let report = serde_json::json!({"runtime":runtime,"handoff":handoff});
if let Some(path) = std::env::var_os("HORIZON_PORTAL_REPORT") {
std::fs::write(path, serde_json::to_vec_pretty(&report).unwrap()).unwrap();
}
for (field, expected) in [("runtime", "contact"), ("handoff", "delays")] {
assert_eq!(report[field], reference[expected], "{field}");
}
}
fn counter(tag: &Tag) -> i32 {
horizon_client::save::number(tag.get("PortalCooldown")).unwrap() as i32
}
@@ -344,8 +344,8 @@ impl Game<'_> {
self.process_level_events()
}
/// `PotentSulfurBlockEntity.LAUNCH_ENTITY_TICKER` on the server: mobs and
/// items and primed TNT in the geyser's column with less than
/// `PotentSulfurBlockEntity.LAUNCH_ENTITY_TICKER` on the server: mobs, items,
/// primed TNT, projectiles, boats and minecarts in the geyser's column with less than
/// `0.3 + 0.1 * water` upward speed get 0.2 more, unless they ride
/// something or are of a type geysers do not move. Players are moved by
/// their own client.
@@ -408,6 +408,37 @@ impl Game<'_> {
tnt.velocity.y += push;
}
}
// Every entity in the column is launched (`getEntitiesOfClass(Entity.class)`):
// a projectile in flight or at rest, a boat and a minecart too.
for p in self
.creatures
.projectiles
.iter_mut()
.filter(|p| p.bounds().intersects(area))
{
if p.velocity.y < limit {
p.velocity.y += push;
p.needs_sync = true;
}
}
for boat in self
.boats
.iter_mut()
.filter(|b| b.bounds().intersects(area))
{
if boat.velocity.y < limit {
boat.velocity.y += push;
}
}
for cart in self
.carts
.iter_mut()
.filter(|c| c.bounds().intersects(area))
{
if cart.velocity.y < limit {
cart.velocity.y += push;
}
}
}
/// `PotentSulfurBlockEntity.LAUNCH_ENTITY_TICKER` on the client: the local
+158 -2
View File
@@ -415,7 +415,7 @@ impl Game<'_> {
/// a guest keeps their own while the host's are in `PotionState`.
#[derive(Default)]
pub(super) struct BodyPotions {
applied: Vec<(&'static str, &'static str, f64, &'static str)>,
pub(super) applied: Vec<(&'static str, &'static str, f64, &'static str)>,
protection_for: Option<(u64, Vec<Option<ItemStack>>)>,
enchant_modifiers: Vec<(String, String, f64, String)>,
}
@@ -426,7 +426,7 @@ pub(super) struct PotionState {
thrown: Vec<ThrownPotion>,
/// The area effect clouds a server tracks (game/remote_projectiles.rs).
pub(super) clouds: Vec<super::remote_projectiles::RemoteCloud>,
applied: Vec<(&'static str, &'static str, f64, &'static str)>,
pub(super) applied: Vec<(&'static str, &'static str, f64, &'static str)>,
/// Armor the protection table was computed for.
protection_for: Option<(u64, Vec<Option<ItemStack>>)>,
@@ -3269,3 +3269,159 @@ mod depleted_absorption_tests {
});
}
}
#[cfg(test)]
mod arrow_contents {
//! Actual in-memory Game potion paths. No run loop, world load or server startup.
use super::*;
use crate::game::{Audio, Options, Progress, Settings, execution_role, load_resources};
use horizon_client::chunk::Chunk;
use serde_json::{Value, json};
use std::{path::PathBuf, sync::Arc, time::Instant};
fn stack(row: &Value) -> ItemStack {
let mut stack = ItemStack::new("minecraft:tipped_arrow", 1);
if let Some(potion) = row["potion"].as_str() {
stack = stack.with_potion(&format!("minecraft:{potion}"));
}
let effects = row["custom"]
.as_array()
.unwrap()
.iter()
.map(|e| {
Tag::compound([
(
"id",
Tag::String(
format!("minecraft:{}", e["id"].as_str().unwrap())
.as_str()
.into(),
),
),
("duration", Tag::Int(e["duration"].as_i64().unwrap() as i32)),
("amplifier", Tag::Int(e["amp"].as_i64().unwrap() as i32)),
(
"ambient",
Tag::Byte(i8::from(e["ambient"].as_bool().unwrap())),
),
(
"show_particles",
Tag::Byte(i8::from(e["visible"].as_bool().unwrap())),
),
])
})
.collect();
stack
.data_mut()
.potion_extra
.push(("custom_effects".into(), Tag::List(effects)));
if let Some(color) = row["color"].as_i64() {
stack
.data_mut()
.potion_extra
.push(("custom_color".into(), Tag::Int(color as i32)));
}
if let Some(name) = row["name"].as_str() {
stack
.data_mut()
.potion_extra
.push(("custom_name".into(), Tag::String(name.into())));
}
stack
}
#[test]
#[ignore = "explicit HORIZON_TEST_RESOURCES and isolated potion oracle input/output"]
fn actual_arrow_contents_match_pinned_methods() {
let root =
PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").expect("explicit resources"));
let input =
PathBuf::from(std::env::var_os("HORIZON_POTION_INPUT").expect("explicit input"));
let output =
PathBuf::from(std::env::var_os("HORIZON_POTION_OUTPUT").expect("explicit output"));
let expected: Vec<Value> = serde_json::from_slice(
&std::fs::read(
std::env::var_os("HORIZON_POTION_EXPECTED").expect("pinned method output"),
)
.unwrap(),
)
.unwrap();
let rows: Vec<Value> = serde_json::from_slice(&std::fs::read(input).unwrap()).unwrap();
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
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::current_dir().unwrap());
options.execution_role = execution_role::ExecutionRole::IntegratedServer;
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.world
.chunks
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
let stone = game
.pack
.assets
.registry
.default_state("minecraft:stone")
.unwrap();
game.world.set(0, 64, 0, stone).unwrap();
assert_eq!(rows.len(), 5, "all five arrow cases must execute");
let mut results = Vec::new();
for (index, row) in rows.iter().enumerate() {
let id = row["id"].clone();
let mut arrow = Projectile::new(
ProjectileKind::Arrow {
base_damage: 2.,
critical: false,
effect: None,
},
Vec3::new(0.5, 64.5, 0.5),
None,
);
let st = arrow.arrow.as_mut().unwrap();
st.pickup_item = stack(row);
st.in_ground = true;
st.in_ground_time = 598;
st.last_state = Some(stone);
arrow.stuck = true;
let rain = |_| false;
let scene = game.arrow_scene(&rain);
horizon_client::arrows::tick(&mut arrow, &scene, &[]);
let st = arrow.arrow.as_ref().unwrap();
let item599 = st
.pickup_item
.item
.trim_start_matches("minecraft:")
.to_owned();
let color599 = st.potion_color().map_or(-1, |v| v as i32);
horizon_client::arrows::tick(&mut arrow, &scene, &[]);
let st = arrow.arrow.as_ref().unwrap();
results.push(json!({"id":id,"item599":item599,"color599":color599,"item600":st.pickup_item.item.trim_start_matches("minecraft:"),"color600":st.potion_color().map_or(-1,|v|v as i32)}));
assert_eq!(
results.last().unwrap(),
&expected[index],
"actual arrow tick: {}",
id
);
}
std::fs::write(output, serde_json::to_vec_pretty(&results).unwrap()).unwrap();
}
}
+144 -15
View File
@@ -150,12 +150,6 @@ impl Game<'_> {
fn excused(g: &Guest, world: &World, entities: &[EntityState], guests: &[Guest]) -> bool {
let at = g.position;
let slack = slack(std::array::from_fn(|i| at[i] - g.previous_position[i]));
let close = |p: [f64; 3]| {
let r = 3.0 + slack;
(p[0] - at[0]).abs() <= r
&& (p[2] - at[2]).abs() <= r
&& (-3.0 - slack..=4.0 + slack).contains(&(p[1] - at[1]))
};
let unsent = world
.block_log
.iter()
@@ -174,17 +168,152 @@ fn excused(g: &Guest, world: &World, entities: &[EntityState], guests: &[Guest])
.motion
.as_deref()
.is_some_and(|m: &PlayerMotion| m.auto_spin_attack_ticks > 0);
// Items and experience orbs neither push nor hold anyone up; the list holds the player itself.
let entity = entities.iter().any(|e| {
e.id != g.entity_id
let entity = entity_near(entities, g.entity_id, at, slack);
let player = guests.iter().any(|o| {
o.conn != g.conn && o.placed && o.dimension == g.dimension && close(at, slack, o.position)
});
unsent || pistons || unloaded || riptide || entity || player
}
/// Whether `p` is in the box around `at` that a player's moves are excused for.
fn close(at: [f64; 3], slack: f64, p: [f64; 3]) -> bool {
let r = 3.0 + slack;
(p[0] - at[0]).abs() <= r
&& (p[2] - at[2]).abs() <= r
&& (-3.0 - slack..=4.0 + slack).contains(&(p[1] - at[1]))
}
/// Whether an entity in `entities` other than the guest's own (`own`) is close to `at`. The
/// distance test runs first: most entities are far, and the kind is a string compare. Items and
/// experience orbs neither push nor hold anyone up; the list holds the player itself.
fn entity_near(entities: &[EntityState], own: i32, at: [f64; 3], slack: f64) -> bool {
entities.iter().any(|e| {
close(at, slack, e.position)
&& e.id != own
&& !matches!(
e.kind.as_str(),
"minecraft:item" | "minecraft:experience_orb"
)
&& close(e.position)
});
let player = guests
.iter()
.any(|o| o.conn != g.conn && o.placed && o.dimension == g.dimension && close(o.position));
unsent || pistons || unloaded || riptide || entity || player
})
}
#[cfg(test)]
mod tests {
use super::*;
/// The scan `excused` ran over the entities before `entity_near`, verbatim.
fn old_entity(entities: &[EntityState], own: i32, at: [f64; 3], slack: f64) -> bool {
let close = |p: [f64; 3]| {
let r = 3.0 + slack;
(p[0] - at[0]).abs() <= r
&& (p[2] - at[2]).abs() <= r
&& (-3.0 - slack..=4.0 + slack).contains(&(p[1] - at[1]))
};
entities.iter().any(|e| {
e.id != own
&& !matches!(
e.kind.as_str(),
"minecraft:item" | "minecraft:experience_orb"
)
&& close(e.position)
})
}
/// xorshift64*: a fixed stream, so a failure reruns the same inputs.
struct Rng(u64);
impl Rng {
fn next_u64(&mut self) -> u64 {
self.0 ^= self.0 >> 12;
self.0 ^= self.0 << 25;
self.0 ^= self.0 >> 27;
self.0.wrapping_mul(0x2545_F491_4F6C_DD1D)
}
fn below(&mut self, n: u64) -> u64 {
self.next_u64() % n
}
/// A multiple of 1/8 in `[-n/8, n/8]`: every edge of the box (multiples of 1/4) is exact.
fn step(&mut self, n: i64) -> f64 {
(self.below(2 * n as u64 + 1) as i64 - n) as f64 * 0.125
}
/// An offset from the guest: half the time on or next to an edge of the box.
fn offset(&mut self, edges: [f64; 4]) -> f64 {
if self.below(2) == 0 {
edges[self.below(4) as usize] + self.step(2)
} else {
self.step(160)
}
}
}
fn entity(rng: &mut Rng, at: [f64; 3], slack: f64) -> EntityState {
const KINDS: [&str; 4] = [
"minecraft:item",
"minecraft:experience_orb",
"minecraft:zombie",
"minecraft:pig",
];
let r = 3.0 + slack;
let xz = [r, -r, r, -r];
let y = [-3.0 - slack, 4.0 + slack, -3.0 - slack, 4.0 + slack];
let off = [rng.offset(xz), rng.offset(y), rng.offset(xz)];
EntityState {
id: rng.below(6) as i32,
kind: KINDS[rng.below(4) as usize].to_string(),
position: [at[0] + off[0], at[1] + off[1], at[2] + off[2]],
..Default::default()
}
}
#[test]
fn entity_near_matches_the_scan_it_replaced() {
const SLACKS: [f64; 6] = [0.0, 0.25, 0.5, 1.5, 3.0, 16.0];
let mut rng = Rng(0x9E37_79B9_7F4A_7C15);
let (mut yes, mut no) = (0, 0);
for seed in 0..2_000 {
let slack = SLACKS[rng.below(6) as usize];
let at = [rng.step(160), rng.step(160), rng.step(160)];
let n = rng.below(40) as usize;
let entities: Vec<EntityState> = (0..n).map(|_| entity(&mut rng, at, slack)).collect();
for own in 0..6 {
let new = entity_near(&entities, own, at, slack);
let old = old_entity(&entities, own, at, slack);
assert_eq!(new, old, "seed {seed}, own {own}, at {at:?}, slack {slack}");
if new {
yes += 1;
} else {
no += 1;
}
}
}
assert!(yes > 0 && no > 0, "the inputs must give both answers");
}
#[test]
fn box_edges_are_inclusive_and_own_and_drops_are_skipped() {
let at = [0.0, 0.0, 0.0];
let zombie = |id, position| EntityState {
id,
kind: "minecraft:zombie".to_string(),
position,
..Default::default()
};
let s = 0.5;
assert!(entity_near(&[zombie(1, [3.5, 0.0, 0.0])], 0, at, s));
assert!(!entity_near(&[zombie(1, [3.625, 0.0, 0.0])], 0, at, s));
assert!(entity_near(&[zombie(1, [0.0, 4.5, 0.0])], 0, at, s));
assert!(!entity_near(&[zombie(1, [0.0, 4.625, 0.0])], 0, at, s));
assert!(entity_near(&[zombie(1, [0.0, -3.5, 0.0])], 0, at, s));
assert!(!entity_near(&[zombie(1, [0.0, -3.625, 0.0])], 0, at, s));
// The guest's own entity, and items and orbs, never excuse.
assert!(!entity_near(&[zombie(7, [0.0, 0.0, 0.0])], 7, at, s));
let item = EntityState {
kind: "minecraft:item".to_string(),
..zombie(2, [0.0, 0.0, 0.0])
};
assert!(!entity_near(&[item], 0, at, s));
}
}
+14 -4
View File
@@ -231,7 +231,7 @@ impl Game<'_> {
return;
}
self.ensure_book();
let index = &self.stations().recipe_index;
let index = self.display_index();
let book = self.recipe_ui.book.as_ref();
// SmithingTrimDemoSlotDisplay draws from a random of its own.
let mut random = random_source::entity();
@@ -413,10 +413,15 @@ impl Game<'_> {
r.builtin
.get(registry)
.and_then(|ids| ids.name(id))
.or_else(|| r.registries.name(registry, id))
.map(str::to_owned)
};
let item = |id: i32| name_of("minecraft:item", id);
let category = |id: i32| name_of("minecraft:recipe_book_category", id);
let category = |id: i32| {
name_of("minecraft:recipe_book_category", id).or_else(|| {
horizon_client::recipe_book::Category::from_id(id).map(|c| c.name().to_owned())
})
};
let tag = |name: &str| items.item_tag_order.get(name).cloned().unwrap_or_default();
book.use_server_entries(
entries,
@@ -430,7 +435,7 @@ impl Game<'_> {
fn ensure_book(&mut self) {
if self.recipe_ui.book.is_none() {
let mut book = Book::build(self.recipes(), self.items());
book.set_display_ids(&self.stations().recipe_index);
book.set_display_ids(self.display_index());
// A server numbers and describes its recipes itself.
self.server_entries_over(&mut book, self.recipe_ui.server_entries.values());
self.recipe_ui.book = Some(book);
@@ -1368,6 +1373,11 @@ impl Game<'_> {
p: horizon_protocol::packets::recipes::RecipeBookAdd,
) {
let ui = &mut self.recipe_ui;
// `display_index`, borrowed by field beside the book.
let index = match &self.recipe_tables.synced_index {
Some(index) => index,
None => &world_stations!(self).recipe_index,
};
if p.replace {
ui.client.clear();
ui.server_entries.clear();
@@ -1380,7 +1390,7 @@ impl Game<'_> {
ui.client.highlight.insert(e.id);
}
if e.flags & 1 != 0
&& let Some(id) = world_stations!(self).recipe_index.recipe_of_display(e.id)
&& let Some(id) = index.recipe_of_display(e.id)
&& !toast.iter().any(|t| t == id)
{
toast.push(id.to_owned());
+1 -1
View File
@@ -739,7 +739,7 @@ impl Game<'_> {
other => other,
}
}
fn resolve_selector_text(&mut self, raw: &str) -> String {
pub(super) fn resolve_selector_text(&mut self, raw: &str) -> String {
let mut reader = horizon_client::command::Reader::new(raw);
let ty = horizon_client::command::args::ArgType::Entity {
single: false,
+17
View File
@@ -1101,6 +1101,23 @@ impl Game<'_> {
let source = PositionSource::Block([key.0, key.1, key.2]);
self.vibration_particle(source, info.pos, center(key), travel);
}
if let Some((info, travel)) = tick.resumed {
let source = PositionSource::Block([key.0, key.1, key.2]);
let dest = center(key);
let initial = info.distance.floor() as i32;
// `tryReloadVibrationParticle` works in doubles.
let alpha = if initial > 0 {
1.0 - f64::from(travel) / f64::from(initial)
} else {
0.0
};
let current_pos = [
info.pos[0] + (dest[0] - info.pos[0]) * alpha,
info.pos[1] + (dest[1] - info.pos[1]) * alpha,
info.pos[2] + (dest[2] - info.pos[2]) * alpha,
];
self.vibration_particle(source, current_pos, dest, travel);
}
if let Some(info) = tick.received {
self.sculk_receive(key, info)?;
}
@@ -0,0 +1,210 @@
//! `WallHangingSignBlock`/`CeilingHangingSignBlock.useItemOn`: a hanging sign in
//! hand clicked on a hanging sign's free side chains another sign onto it, unless
//! the side the player faces carries click commands, which the click runs instead
//! (`shouldTryToChainAnotherHangingSign`).
use crate::game::{Game, Hit};
use horizon_client::signs::SignData;
use horizon_client::{containers::BlockEntity, items::ItemStack, mobs::Who};
use horizon_core::geometry::Vec3;
use horizon_world::nbt::Tag;
type Posted = (&'static str, [f64; 3], Option<Who>);
const FAIL_SOUND: &str = "minecraft:block.hanging_sign.waxed_interact_fail";
const COMMAND: &str = "setblock 5 70 5 minecraft:stone";
/// What one click on a hanging sign left behind.
struct Click {
/// The game events it posted, in order.
events: Vec<Posted>,
/// The block at the position the chain would fill, and at (5, 70, 5) where
/// the test command places stone.
chained: String,
commanded: String,
/// The stack left in the main hand.
held: u32,
sounds: Vec<String>,
}
/// Clicks `face` of an oak hanging sign at (0, 70, 0) with an oak hanging sign:
/// the sign has `properties`, is waxed when `waxed`, and its front line runs a
/// `setblock` when `command`. `None` without the prepared resources.
fn click_sign(
sign_block: &str,
properties: &[(&str, &str)],
face: [i32; 3],
waxed: bool,
command: bool,
) -> Option<Click> {
// The prepared resources, or nothing where CI has no vanilla data.
std::env::var_os("HORIZON_TEST_RESOURCES")?;
let mut game = crate::game::native_machines::tests::server_game();
// A sign's click command needs the command tree's registries (`setblock`'s
// block types).
game.build_registries();
// North of the sign, looking south at its front
// (`SignBlockEntity.getSlotPlayerIsFacing`): a wall hanging sign facing
// north and a ceiling one at rotation 8 both show it towards -Z.
game.player = Some(
horizon_client::motion::PlayerMotion::new(game.motion, Vec3::new(0.5, 70.0, -0.5)).ok()?,
);
game.yaw = 0.0;
game.pitch = 0.0;
let registry = &game.pack.assets.registry;
let mut state = registry
.state(registry.default_state(sign_block)?)
.cloned()?;
for (key, value) in properties {
state.properties.insert((*key).into(), (*value).into());
}
let state = registry.id(&state)?;
game.world.set(0, 70, 0, state).ok()?;
let mut sign = SignData {
hanging: true,
waxed,
// `allow_op_features`: the click event may run the command.
allow_op_features: true,
..SignData::default()
};
if command {
sign.front.click[0] = Some(Tag::compound([
("action", Tag::String("run_command".into())),
("command", Tag::String(COMMAND.into())),
]));
}
game.world
.block_entities
.insert((0, 70, 0), BlockEntity::Sign(sign));
*game.held_mut() = Some(ItemStack::new("minecraft:oak_hanging_sign", 2));
game.audio.capture = Some(Vec::new());
let hit = Hit {
pos: [0, 70, 0],
face,
point: [
0.5 + 0.5 * f64::from(face[0]),
70.5,
0.5 + 0.5 * f64::from(face[2]),
],
};
crate::game::sculk::TEST_POSTED_EVENTS.with(|events| *events.borrow_mut() = Some(Vec::new()));
// The player caused it (`GameEvent.Context.of(player, state)`).
game.with_player_actor(|game| game.use_hand(Some(&hit)))
.unwrap();
let events = crate::game::sculk::TEST_POSTED_EVENTS
.with(|events| events.borrow_mut().take().unwrap_or_default());
let name = |game: &Game<'_>, pos: [i32; 3]| {
let state = game.world.block(pos[0], pos[1], pos[2]);
game.res.facts().get(state).name.to_string()
};
Some(Click {
events,
chained: name(&game, [face[0], 70 + face[1], face[2]]),
commanded: name(&game, [5, 70, 5]),
held: game.held().map_or(0, |stack| stack.count),
sounds: game
.audio
.capture
.take()
.unwrap_or_default()
.into_iter()
.map(|sound| sound.event)
.collect(),
})
}
/// The click ran the sign's command: its `setblock` changed (5, 70, 5), nothing
/// chained, the stack in hand is untouched and the waxed sign played its
/// failed-interaction sound.
fn assert_ran_command(click: &Click) {
assert_eq!(click.chained, "minecraft:air", "nothing chained");
assert_eq!(click.commanded, "minecraft:stone", "the command ran");
assert_eq!(click.held, 2, "nothing was placed");
assert!(
click.sounds.contains(&FAIL_SOUND.to_owned()),
"the waxed sign's failed interaction: {:?}",
click.sounds
);
assert!(
!click
.events
.iter()
.any(|(event, _, _)| *event == "minecraft:block_place"),
"{:?}",
click.events
);
}
/// The click chained a `chained` sign onto the one clicked, the placement being
/// the game event the acting player caused there.
fn assert_chained(click: &Click, chained: &str, place_event: [f64; 3]) {
assert_eq!(click.chained, chained);
assert_eq!(click.commanded, "minecraft:air", "no command ran");
assert_eq!(click.held, 1, "the chained sign was used up");
assert!(
click
.events
.contains(&("minecraft:block_place", place_event, Some(Who::Player))),
"{:?}",
click.events
);
assert!(
!click.sounds.contains(&FAIL_SOUND.to_owned()),
"{:?}",
click.sounds
);
}
#[test]
fn a_waxed_wall_hanging_sign_with_a_command_runs_it_instead_of_chaining() {
let Some(click) = click_sign(
"minecraft:oak_wall_hanging_sign",
&[("facing", "north")],
[1, 0, 0],
true,
true,
) else {
return;
};
assert_ran_command(&click);
}
#[test]
fn a_wall_hanging_sign_without_a_command_chains_another() {
let Some(click) = click_sign(
"minecraft:oak_wall_hanging_sign",
&[("facing", "north")],
[1, 0, 0],
false,
false,
) else {
return;
};
assert_chained(&click, "minecraft:oak_wall_hanging_sign", [1.5, 70.5, 0.5]);
}
#[test]
fn a_waxed_ceiling_hanging_sign_with_a_command_runs_it_instead_of_chaining() {
let Some(click) = click_sign(
"minecraft:oak_hanging_sign",
&[("rotation", "8")],
[0, -1, 0],
true,
true,
) else {
return;
};
assert_ran_command(&click);
}
#[test]
fn a_ceiling_hanging_sign_without_a_command_chains_below() {
let Some(click) = click_sign(
"minecraft:oak_hanging_sign",
&[("rotation", "8")],
[0, -1, 0],
false,
false,
) else {
return;
};
assert_chained(&click, "minecraft:oak_hanging_sign", [0.5, 69.5, 0.5]);
}
@@ -108,6 +108,64 @@ impl Game<'_> {
};
sign.waxed && sign.can_execute_click_commands(self.facing_sign_front(pos))
}
/// `WallHangingSignBlock.shouldTryToChainAnotherHangingSign` and
/// `CeilingHangingSignBlock.shouldTryToChainAnotherHangingSign`: a hanging
/// sign item clicked on a hanging sign's free side chains another sign onto
/// it, unless the side the player faces carries click commands the click
/// runs instead (`canExecuteClickCommands`).
pub(super) fn chains_hanging_sign(
&self,
pos: [i32; 3],
face: [i32; 3],
held: Option<&ItemStack>,
) -> bool {
// `itemStack.getItem() instanceof HangingSignItem`, by the item's class
// and not its name.
let hanging_sign = held.is_some_and(|s| {
self.res
.behaviours()
.item_binding(&s.item)
.is_some_and(|i| i.kind == horizon_world::level::ItemKind::HangingSign)
});
let key = (pos[0], pos[1], pos[2]);
if !hanging_sign {
return false;
}
let state = self.world.block(pos[0], pos[1], pos[2]);
let Some(form) = self
.res
.facts()
.get(state)
.sign_form()
.filter(|f| f.hanging)
else {
return false;
};
let free_side = if form.wall {
// `isHittingEditableSide`: a wall hanging sign's board lies in its
// FACING's plane, so its two faces are the editable text and only
// its edges chain.
let Some(facing) = self
.pack
.assets
.registry
.state(state)
.and_then(|s| s.properties.get("facing"))
else {
return false;
};
let axis = match &**facing {
"east" | "west" => 0,
"up" | "down" => 1,
_ => 2,
};
face.iter().position(|c| *c != 0) != Some(axis)
} else {
// `hitResult.getDirection().equals(Direction.DOWN)`.
face == [0, -1, 0]
};
free_side && !self.sign_can_execute_click_commands(key)
}
/// `SignBlockEntity.createCommandSourceStack`: the acting player runs the
/// command from the middle of the sign's block, rotation zero, at the
/// game master level, with `CommandSource.NULL` (no feedback, no failure
+59 -39
View File
@@ -4,7 +4,7 @@
use super::Game;
use horizon_client::{
items::ItemStack,
mobs::{Mob, Species, sniffer},
mobs::{Mob, Species, Who, sniffer},
};
use horizon_core::{geometry::Vec3, random::RandomSource};
@@ -13,15 +13,9 @@ impl Game<'_> {
/// brain already played the plop and dropped the experience).
pub(super) fn lay_sniffer_egg(&mut self, parent: &Mob) {
let at = [parent.position.x, parent.position.y, parent.position.z];
// new ItemEntity(level, x, y, z, stack): a little random push.
let vx = self.world.random.next_double() * 0.2 - 0.1;
let vz = self.world.random.next_double() * 0.2 - 0.1;
self.spawn_item(
ItemStack::new("minecraft:sniffer_egg", 1),
at,
[vx, 0.2, vz],
10,
);
// new ItemEntity(level, x, y, z, stack): the little push is the item's
// own random's, not the level's.
self.spawn_item_own(ItemStack::new("minecraft:sniffer_egg", 1), at, 10);
}
/// A snifflet where the egg was.
pub(super) fn hatch_sniffer(&mut self, pos: [i32; 3]) {
@@ -63,7 +57,8 @@ impl Game<'_> {
}
let id = m.id;
let at = [m.position.x, m.position.y, m.position.z];
if !m.silent && sniffer::state(m) == sniffer::SEARCHING && m.age.is_multiple_of(20) {
// playSearchingSound is playLocalSound: silence does not mute it.
if sniffer::state(m) == sniffer::SEARCHING && m.age.is_multiple_of(20) {
self.audio
.play("minecraft:entity.sniffer.searching", Some(at), 1.0, 1.0);
continue;
@@ -71,36 +66,61 @@ impl Game<'_> {
if !sniffer::digging(m) {
continue;
}
let Some(ms) = m.anim.states[3].millis(m.age as f32) else {
continue;
};
if ms == 0 {
// Sniffer.onDiggingStart: broadcast entity event 63.
self.lan_entity_event(id as i32, 63, at);
let age = m.age;
if let Some(ms) = m.anim.states[3].millis(age as f32) {
if ms == 0 {
// Sniffer.onDiggingStart: broadcast entity event 63.
self.lan_entity_event(id as i32, 63, at);
}
if ms > 1700 && ms < 6000 {
let head = sniffer::head_block(&self.mobs[i]);
let below = self.world.block(head[0], head[1] - 1, head[2]);
// `stateBelow.getRenderShape() != RenderShape.INVISIBLE`.
if self.res.facts().get(below).render_shape
!= horizon_client::blocks::RenderShape::Invisible
{
if self
.particle_sprite(below, [head[0], head[1] - 1, head[2]])
.is_some()
{
let dust = [
f64::from(head[0]) + 0.5,
f64::from(head[1]) + 0.5 - f64::from(0.65_f32),
f64::from(head[2]) + 0.5,
];
// Drawn in this window when it has one, and sent to
// the players within 32 blocks (the server thread's
// window is one of them).
self.landing_dust(
None,
below,
Vec3::new(dust[0], dust[1], dust[2]),
30,
0.0,
);
}
// The hit sound is playLocalSound too: silence does not mute it.
if age.is_multiple_of(10) {
let hit = self.res.facts().get(below).sounds.hit.clone();
self.audio.play(&hit, Some(at), 0.5, 0.5);
}
}
}
}
if ms <= 1700 || ms >= 6000 {
continue;
}
let head = sniffer::head_block(&self.mobs[i]);
let below = self.world.block(head[0], head[1] - 1, head[2]);
if self
.particle_sprite(below, [head[0], head[1] - 1, head[2]])
.is_none()
{
continue;
}
let dust = [
f64::from(head[0]) + 0.5,
f64::from(head[1]) + 0.5 - f64::from(0.65_f32),
f64::from(head[2]) + 0.5,
];
// Drawn in this window when it has one, and sent to the players
// within 32 blocks (the server thread's window is one of them).
self.landing_dust(None, below, Vec3::new(dust[0], dust[1], dust[2]), 30, 0.0);
if !self.mobs[i].silent && self.mobs[i].age.is_multiple_of(10) {
let hit = self.res.facts().get(below).sounds.hit.clone();
self.audio.play(&hit, Some(at), 0.5, 0.5);
// GameEvent.ENTITY_ACTION for the whole dig, outside the dust window.
if age.is_multiple_of(10) {
let head = sniffer::head_block(&self.mobs[i]);
self.post_block_game_event(
"minecraft:entity_action",
head,
Some(Who::Mob(id)),
None,
);
}
}
}
}
#[cfg(test)]
#[path = "sniffers_tests.rs"]
mod sniffers_tests;
@@ -0,0 +1,115 @@
//! Sniffer digging effects: the `entity_action` a dig posts every ten ticks
//! (`Sniffer.emitDiggingParticles`), the local sounds silence does not mute
//! (`playSearchingSound`, the block's hit sound) and the egg's own-random push
//! (`Sniffer.spawnChildFromBreeding`'s `new ItemEntity(..)`).
use super::super::kill_die_vibration_tests::game as sculk_game;
use super::super::sculk::TEST_POSTED_EVENTS;
use super::super::{Game, PlayerMotion};
use super::{Mob, sniffer};
use horizon_client::mobs::Who;
use horizon_core::{geometry::Vec3, random::RandomSource};
fn game() -> Game<'static> {
let mut game = sculk_game();
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(8.5, 100.0, 8.5)).unwrap());
game.mobs.clear();
game
}
/// A digging sniffer at `(8.5, 100, 8.5)`, facing +z, its head above the stone.
fn digging_sniffer(game: &mut Game<'_>) -> (u32, [i32; 3]) {
let kind = game
.res
.mob_types()
.get("minecraft:sniffer")
.unwrap()
.clone();
let mut m = Mob::new(42, kind, Vec3::new(8.5, 100.0, 8.5), 0.0);
sniffer::set_state(&mut m, sniffer::DIGGING);
let head = sniffer::head_block(&m);
game.mobs.push(m);
(42, head)
}
fn posted() -> Vec<(&'static str, [f64; 3], Option<Who>)> {
TEST_POSTED_EVENTS.with(|events| events.borrow().clone().unwrap_or_default())
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn digging_posts_entity_action_every_ten_ticks_outside_the_dust_window() {
let mut game = game();
let (id, head) = digging_sniffer(&mut game);
let at = [
head[0] as f64 + 0.5,
head[1] as f64 + 0.5,
head[2] as f64 + 0.5,
];
// 0 is the first dig tick, 20 is before the window, 40 inside it and 130
// after it; 25 is not a tenth tick.
for age in [0_u32, 20, 40, 130, 25] {
let m = &mut game.mobs[0];
m.age = age;
m.anim.states[3].start(0.0);
TEST_POSTED_EVENTS.with(|events| *events.borrow_mut() = Some(Vec::new()));
game.tick_sniffer_effects();
let events = posted();
let action = events
.iter()
.filter(|(event, ..)| *event == "minecraft:entity_action")
.collect::<Vec<_>>();
if age.is_multiple_of(10) {
assert_eq!(
action,
vec![&("minecraft:entity_action", at, Some(Who::Mob(id)))],
"age {age}"
);
} else {
assert!(action.is_empty(), "age {age} posted {events:?}");
}
}
TEST_POSTED_EVENTS.with(|events| *events.borrow_mut() = None);
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_silent_sniffer_still_plays_the_local_searching_and_hit_sounds() {
let mut game = game();
let _ = digging_sniffer(&mut game);
game.mobs[0].silent = true;
game.mobs[0].age = 20;
sniffer::set_state(&mut game.mobs[0], sniffer::SEARCHING);
game.audio.heard.clear();
game.tick_sniffer_effects();
assert_eq!(
game.audio.heard.get("minecraft:entity.sniffer.searching"),
Some(&1)
);
sniffer::set_state(&mut game.mobs[0], sniffer::DIGGING);
game.mobs[0].age = 40;
game.mobs[0].anim.states[3].start(0.0);
game.audio.heard.clear();
game.tick_sniffer_effects();
assert_eq!(game.audio.heard.get("minecraft:block.stone.hit"), Some(&1));
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn the_egg_push_comes_from_the_items_own_random() {
let mut game = game();
let kind = game
.res
.mob_types()
.get("minecraft:sniffer")
.unwrap()
.clone();
let parent = Mob::new(43, kind, Vec3::new(8.5, 100.0, 8.5), 0.0);
let mut level = game.world.random.clone();
game.lay_sniffer_egg(&parent);
let egg = game.dropped.last().unwrap();
assert_eq!(&*egg.stack.item, "minecraft:sniffer_egg");
assert_eq!(egg.pickup_delay, 10);
// `new ItemEntity(level, x, y, z, stack)` draws its motion from the item's
// random: the level's stream does not move.
assert_eq!(game.world.random.next_double(), level.next_double());
}
+28 -6
View File
@@ -40,13 +40,29 @@ pub(super) struct Pending {
pub(super) copied: HashMap<[i32; 3], (String, Compound)>,
}
impl Pending {
/// Remember what the block entity at `pos` copies into the drops of `block`.
/// `record` for the block entity at `pos` while it is still in `world`:
/// its custom name and lock come from there.
pub(super) fn record_from(
&mut self,
world: &horizon_client::world::World,
pos: [i32; 3],
entity: &BlockEntity,
block: &str,
) {
let key = (pos[0], pos[1], pos[2]);
let name = world.block_names.get(&key).cloned();
self.record(pos, entity, block, name, world.block_lock(key));
}
/// Remember what the block entity at `pos` copies into its own drop.
/// Name and lock live outside the model: snapshot the actual lock before
/// removal forgets its source. Only the supported shulker copy policy takes it.
pub(super) fn record(
&mut self,
pos: [i32; 3],
entity: &BlockEntity,
block: &str,
name: Option<Tag>,
lock: Option<&Tag>,
) {
if let BlockEntity::Banner(banner) = entity {
self.banner.insert(pos, banner.clone());
@@ -60,8 +76,10 @@ impl Pending {
}
// `copy_components` from the block entity: the head tables copy the
// profile (player heads), note block sound and name, the hive tables
// the bees.
// the bees, and shulker tables the actual external lock. This is the
// supported vanilla class policy, not a selected-table include filter.
let include: &[&str] = match entity {
BlockEntity::Chest(_) if is_shulker_box(block) => &["minecraft:lock"],
BlockEntity::Skull(_) if block.starts_with("minecraft:player_") => &[
"minecraft:profile",
"minecraft:note_block_sound",
@@ -74,7 +92,12 @@ impl Pending {
_ => &[],
};
if !include.is_empty() {
let saved = save::encode_block_entity((pos[0], pos[1], pos[2]), entity, block);
let mut saved = save::encode_block_entity((pos[0], pos[1], pos[2]), entity, block);
if is_shulker_box(block)
&& let (Some(lock), Tag::Compound(tag)) = (lock, &mut saved)
{
tag.insert("lock".into(), lock.clone());
}
let mut patch = block_entity_nbt::canonical(&saved)
.map(|saved| block_entity_item::pick(&saved))
.unwrap_or_default();
@@ -462,9 +485,8 @@ impl GameLevel<'_> {
let at = [pos.x, pos.y, pos.z];
if let Some(entity) = self.world.block_entities.get(&key) {
let entity = entity.clone();
let name = self.world.block_names.get(&key).cloned();
let block = self.facts.get(state).name.clone();
self.pending.record(at, &entity, &block, name);
self.pending.record_from(self.world, at, &entity, &block);
} else if let Some(tag) = saved_entity(self.world, [pos.x, pos.y, pos.z]) {
self.pending.record_saved(at, &tag);
}
@@ -899,7 +921,7 @@ mod tests {
};
assert_eq!(banner.layers.len(), item_data::OMINOUS_LAYERS.len());
let mut pending = Pending::default();
pending.record([pos.0, pos.1, pos.2], &loaded, block, None);
pending.record([pos.0, pos.1, pos.2], &loaded, block, None, None);
let mut dropped = ItemStack::new("minecraft:white_banner", 1);
pending.attach([pos.0, pos.1, pos.2], &mut dropped);
assert!(dropped.data().unwrap().ominous_banner);
+9 -7
View File
@@ -10,6 +10,10 @@ use std::{
path::{Path, PathBuf},
};
#[cfg(test)]
#[path = "phantom_spawn_tests.rs"]
mod phantom_spawn_tests;
/// `stats[type][key]`, with `type` and `key` namespaced ids.
#[derive(Default)]
pub(super) struct Stats {
@@ -460,7 +464,11 @@ impl Game<'_> {
position.y.floor() as i32,
position.z.floor() as i32,
];
if pos[1] < 63 || !self.can_see_sky(pos) {
// `playerPos.getY() >= level.getSeaLevel() && level.canSeeSky(playerPos)`:
// sky light 15 at the position, whatever stands above it, so a glass
// or stained-glass roof still spawns phantoms (`MobWorld` reads the
// same rule for the spawn rules and the mob goals).
if pos[1] < 63 || !self.mob_world_base(&[]).can_see_sky(pos[0], pos[1], pos[2]) {
return;
}
// `getCurrentDifficultyAt(playerPos).isHarderThan(random * 3)`.
@@ -499,12 +507,6 @@ impl Game<'_> {
);
}
}
fn can_see_sky(&self, pos: [i32; 3]) -> bool {
(pos[1] + 1..pos[1] + 256).all(|y| {
let f = self.res.facts().get(self.world.block(pos[0], y, pos[2]));
!f.full_collision && f.fluid == horizon_client::blocks::Fluid::None
})
}
}
/// `1 + random.nextInt(difficulty.getId() + 1)` of `PhantomSpawner.tick`: Peaceful still draws.
+1 -22
View File
@@ -171,28 +171,7 @@ impl Game<'_> {
/// `ChestBlock.isChestBlockedAt`: a redstone conductor above the chest or
/// a cat sitting on it.
fn chest_blocked_at(&self, key: (i32, i32, i32)) -> bool {
if self.conductor_above(key) {
return true;
}
// isCatSittingOnChest: a cat whose box meets the block above.
let area = horizon_core::geometry::Aabb::new(
horizon_core::geometry::Vec3::new(
f64::from(key.0),
f64::from(key.1 + 1),
f64::from(key.2),
),
horizon_core::geometry::Vec3::new(
f64::from(key.0 + 1),
f64::from(key.1 + 2),
f64::from(key.2 + 1),
),
);
self.mobs.iter().any(|m| {
m.species() == horizon_client::mobs::Species::Cat
&& m.alive()
&& horizon_client::mobs::tame::cat_sitting(m)
&& m.bounds().intersects(area)
})
self.conductor_above(key) || super::machines::cat_sitting_above(&self.mobs, key)
}
/// `ChestBlock.getMenuProvider` (`combine(.., false)`) and
/// `EnderChestBlock.useWithoutItem`: a blocked chest, or a double chest
+248 -212
View File
@@ -102,14 +102,17 @@ impl PrimedTnt {
}
/// Entity.checkFallDamage after a move that stepped `moved_y`: out of
/// water, each step down is narrowed to a float before it adds to the
/// fall; landing forgets it.
pub(super) fn check_fall_damage(&mut self, moved_y: f64) {
/// fall; landing forgets it. Returns whether it landed after a fall, when
/// the caller posts HIT_GROUND.
pub(super) fn check_fall_damage(&mut self, moved_y: f64) -> bool {
if !self.in_water && moved_y < 0.0 {
self.fall_distance -= f64::from(moved_y as f32);
}
let landed = self.on_ground && self.fall_distance > 0.0;
if self.on_ground {
self.fall_distance = 0.0;
}
landed
}
/// Entity.applyEffectsFromBlocks for a move from `from` to here
/// (`original` the step it asked for, when the move was recorded): powder
@@ -174,9 +177,11 @@ impl PrimedTnt {
blocks: Option<&horizon_world::movement_properties::BoundMovementProperties>,
) -> Result<Vec<BlockPos>> {
self.tick_among(world, collision, assets, facts, fluids, border, blocks, &[])
.map(|(tops, _)| tops)
}
/// [`Self::tick`], its move also stopped by `entities`: the boxes of the
/// entities that `canBeCollidedWith` it (`EntityGetter.getEntityCollisions`).
/// Also says whether it landed after a fall.
#[allow(clippy::too_many_arguments)]
pub(super) fn tick_among(
&mut self,
@@ -188,7 +193,7 @@ impl PrimedTnt {
border: Option<&horizon_core::geometry::border::BorderGeometry>,
blocks: Option<&horizon_world::movement_properties::BoundMovementProperties>,
entities: &[Aabb],
) -> Result<Vec<BlockPos>> {
) -> Result<(Vec<BlockPos>, bool)> {
self.previous = self.position;
self.velocity.y -= self.get_gravity();
let mut wanted = self.velocity;
@@ -226,7 +231,7 @@ impl PrimedTnt {
);
}
self.on_ground = wanted.y < 0.0 && moved.y != wanted.y;
self.check_fall_damage(moved.y);
let landed = self.check_fall_damage(moved.y);
if moved.x != wanted.x {
self.velocity.x = 0.0;
}
@@ -283,7 +288,7 @@ impl PrimedTnt {
self.fall_distance = 0.0;
}
}
Ok(tops)
Ok((tops, landed))
}
}
@@ -401,7 +406,11 @@ impl Game<'_> {
/// so powder snow it was pushed into makes it stuck.
pub(super) fn finish_tnt_piston_move(&mut self, i: usize, from: Vec3, moved: Vec3) {
let t = &mut self.tnts[i];
t.check_fall_damage(moved.y);
if t.check_fall_damage(moved.y) {
let (position, bounds) = (t.position, t.bounds());
self.post_landing_event(position, bounds, false);
}
let t = &mut self.tnts[i];
let factor = horizon_client::motion::block_speed_factor_at(
&self.world,
&self.res.motion().blocks,
@@ -438,7 +447,7 @@ impl Game<'_> {
/// `entities` are the boxes that stop its move (`Game::item_entity_collisions`).
pub(super) fn tick_one_tnt(&mut self, i: usize, entities: &[Aabb]) -> Result<()> {
let t = &mut self.tnts[i];
let tops = t.tick_among(
let (tops, landed) = t.tick_among(
&self.world,
self.collision,
&self.pack.assets,
@@ -449,6 +458,10 @@ impl Game<'_> {
entities,
)?;
let snow = (t.previous, t.position, t.bounds(), t.velocity);
// Entity.checkFallDamage: HIT_GROUND after a fall, during the move.
if landed {
self.post_landing_event(snow.1, snow.2, false);
}
self.entity_powder_snow_particles(snow.0, snow.1, snow.2, snow.3, false);
for pos in tops {
self.bubble_column_particles(pos);
@@ -1538,113 +1551,117 @@ mod tests {
#[test]
fn silent_tnt_keeps_the_blast_without_its_sound() {
let Some(mut game) = source_silence_game() else {
return;
};
let mut rows = Vec::new();
for silent in [false, true, false] {
let glass = game
.pack
.assets
.registry
.default_state("minecraft:glass")
on_big_stack(|| {
let Some(mut game) = source_silence_game() else {
return;
};
let mut rows = Vec::new();
for silent in [false, true, false] {
let glass = game
.pack
.assets
.registry
.default_state("minecraft:glass")
.unwrap();
game.world.set(8, 64, 8, glass).unwrap();
game.audio.heard.clear();
game.add_primed_tnt(BlockPos::new(8, 64, 8), 0.0, 1, None, None);
game.tnts[0].no_gravity = true;
game.tnts[0].velocity = Vec3::ZERO;
let id = game.tnts[0].id;
set_silent(&mut game, id, silent);
let before = particle_count(&game);
game.tick_one_tnt(0, &[]).unwrap();
rows.push(format!(
"explosion {silent} {} {}",
particle_count(&game) > before,
sound_count(&game, "minecraft:entity.generic.explode") != 0
));
assert!(game.tnts.is_empty(), "the fuse still expires");
assert!(
game.res.facts().is_air(game.world.block(8, 64, 8)),
"silence keeps block destruction"
);
assert!(particle_count(&game) > before, "the blast remains visible");
assert_eq!(
sound_count(&game, "minecraft:entity.generic.explode"),
u32::from(!silent),
"direct source Silent={silent}"
);
game.audio.heard.clear();
game.server_explode(
[8.5, 64.0, 8.5],
0.0,
super::super::explosion::BlockInteraction::Keep,
false,
)
.unwrap();
game.world.set(8, 64, 8, glass).unwrap();
game.audio.heard.clear();
game.add_primed_tnt(BlockPos::new(8, 64, 8), 0.0, 1, None, None);
game.tnts[0].no_gravity = true;
game.tnts[0].velocity = Vec3::ZERO;
let id = game.tnts[0].id;
set_silent(&mut game, id, silent);
let before = particle_count(&game);
game.tick_one_tnt(0, &[]).unwrap();
rows.push(format!(
"explosion {silent} {} {}",
particle_count(&game) > before,
sound_count(&game, "minecraft:entity.generic.explode") != 0
));
assert!(game.tnts.is_empty(), "the fuse still expires");
assert!(
game.res.facts().is_air(game.world.block(8, 64, 8)),
"silence keeps block destruction"
);
assert!(particle_count(&game) > before, "the blast remains visible");
assert_eq!(
sound_count(&game, "minecraft:entity.generic.explode"),
u32::from(!silent),
"direct source Silent={silent}"
);
game.audio.heard.clear();
game.server_explode(
[8.5, 64.0, 8.5],
0.0,
super::super::explosion::BlockInteraction::Keep,
false,
)
.unwrap();
assert_eq!(
game.audio.heard.get("minecraft:entity.generic.explode"),
Some(&1),
"a source-free blast stays audible"
);
}
write_silence_output("explosion", &rows);
assert_eq!(
game.audio.heard.get("minecraft:entity.generic.explode"),
Some(&1),
"a source-free blast stays audible"
);
}
write_silence_output("explosion", &rows);
});
}
#[test]
fn silent_potions_keep_the_splash_without_its_sound() {
use horizon_client::projectiles::{Hit, Projectile, ProjectileKind};
let Some(mut game) = source_silence_game() else {
return;
};
let mut rows = Vec::new();
for kind in ["splash_potion", "lingering_potion"] {
for potion in ["awkward", "swiftness", "healing"] {
for silent in [false, true] {
game.audio.heard.clear();
let stack = ItemStack::new(&format!("minecraft:{kind}"), 1)
.with_potion(&format!("minecraft:{potion}"));
let mut projectile = Projectile::new(
ProjectileKind::potion(stack),
Vec3::new(8.5, 64.0, 8.5),
None,
);
projectile.id = game.take_entity_id();
set_silent(&mut game, projectile.id, silent);
let before = particle_count(&game);
let clouds = game.breath_clouds.len();
game.projectile_hit(
&projectile,
Hit::Block {
pos: [8, 63, 8],
at: projectile.position,
face: [0, 1, 0],
},
)
.unwrap();
rows.push(format!(
"{kind} {potion} {silent} {} {}",
particle_count(&game) > before,
sound_count(&game, "minecraft:entity.splash_potion.break") != 0
));
assert!(
particle_count(&game) > before,
"{kind} {potion} Silent={silent}: visible splash"
);
assert_eq!(
sound_count(&game, "minecraft:entity.splash_potion.break"),
u32::from(!silent),
"{kind} {potion} Silent={silent}"
);
assert_eq!(
game.breath_clouds.len() - clouds,
usize::from(kind == "lingering_potion" && potion != "awkward"),
"silence leaves the lingering cloud intact"
);
on_big_stack(|| {
let Some(mut game) = source_silence_game() else {
return;
};
let mut rows = Vec::new();
for kind in ["splash_potion", "lingering_potion"] {
for potion in ["awkward", "swiftness", "healing"] {
for silent in [false, true] {
game.audio.heard.clear();
let stack = ItemStack::new(&format!("minecraft:{kind}"), 1)
.with_potion(&format!("minecraft:{potion}"));
let mut projectile = Projectile::new(
ProjectileKind::potion(stack),
Vec3::new(8.5, 64.0, 8.5),
None,
);
projectile.id = game.take_entity_id();
set_silent(&mut game, projectile.id, silent);
let before = particle_count(&game);
let clouds = game.breath_clouds.len();
game.projectile_hit(
&projectile,
Hit::Block {
pos: [8, 63, 8],
at: projectile.position,
face: [0, 1, 0],
},
)
.unwrap();
rows.push(format!(
"{kind} {potion} {silent} {} {}",
particle_count(&game) > before,
sound_count(&game, "minecraft:entity.splash_potion.break") != 0
));
assert!(
particle_count(&game) > before,
"{kind} {potion} Silent={silent}: visible splash"
);
assert_eq!(
sound_count(&game, "minecraft:entity.splash_potion.break"),
u32::from(!silent),
"{kind} {potion} Silent={silent}"
);
assert_eq!(
game.breath_clouds.len() - clouds,
usize::from(kind == "lingering_potion" && potion != "awkward"),
"silence leaves the lingering cloud intact"
);
}
}
}
}
write_silence_output("potions", &rows);
write_silence_output("potions", &rows);
});
}
fn write_silence_output(name: &str, rows: &[String]) {
@@ -1659,77 +1676,81 @@ mod tests {
#[test]
fn silent_creeper_keeps_the_blast_after_its_source_is_removed() {
let Some(mut game) = source_silence_game() else {
return;
};
for silent in [false, true, false] {
let id = game
.spawn_mob_for("minecraft:creeper", [8.5, 64.0, 8.5], true, true)
.unwrap();
let mob = game.mobs.iter_mut().find(|m| m.id == id).unwrap();
mob.silent = silent;
mob.swell = mob.max_swell;
mob.extra.flag = true;
let mut events = Vec::new();
horizon_client::mobs::hostile::creeper_tick(mob, &mut events);
assert!(mob.removed);
game.mobs.retain(|m| m.id != id);
game.audio.heard.clear();
let particles = particle_count(&game);
game.handle_mob_events(events).unwrap();
assert!(particle_count(&game) > particles);
assert_eq!(
sound_count(&game, "minecraft:entity.generic.explode"),
u32::from(!silent)
);
}
on_big_stack(|| {
let Some(mut game) = source_silence_game() else {
return;
};
for silent in [false, true, false] {
let id = game
.spawn_mob_for("minecraft:creeper", [8.5, 64.0, 8.5], true, true)
.unwrap();
let mob = game.mobs.iter_mut().find(|m| m.id == id).unwrap();
mob.silent = silent;
mob.swell = mob.max_swell;
mob.extra.flag = true;
let mut events = Vec::new();
horizon_client::mobs::hostile::creeper_tick(mob, &mut events);
assert!(mob.removed);
game.mobs.retain(|m| m.id != id);
game.audio.heard.clear();
let particles = particle_count(&game);
game.handle_mob_events(events).unwrap();
assert!(particle_count(&game) > particles);
assert_eq!(
sound_count(&game, "minecraft:entity.generic.explode"),
u32::from(!silent)
);
}
});
}
#[test]
fn silent_launched_potions_and_bottles_keep_their_impact() {
let Some(mut game) = source_silence_game() else {
return;
};
let stone = game
.pack
.assets
.registry
.default_state("minecraft:stone")
.unwrap();
game.world.set(8, 63, 8, stone).unwrap();
for kind in ["splash_potion", "lingering_potion", "experience_bottle"] {
for silent in [false, true, false] {
game.launch_thrown(
ItemStack::new(&format!("minecraft:{kind}"), 1)
.with_potion("minecraft:swiftness"),
Vec3::new(8.5, 64.1, 8.5),
Vec3::new(0.0, -1.0, 0.0),
None,
);
let id = *game.potions.thrown_ids().last().unwrap();
set_silent(&mut game, id, silent);
game.audio.heard.clear();
let particles = particle_count(&game);
let clouds = game.breath_clouds.len();
let xp: u32 = game.orbs.iter().map(|o| o.points() * o.count).sum();
game.tick_effects(true);
assert_eq!(game.potions.thrown_ids().count(), 0);
assert!(particle_count(&game) > particles, "{kind} keeps its splash");
assert_eq!(
sound_count(&game, "minecraft:entity.splash_potion.break"),
u32::from(!silent),
"{kind} Silent={silent}"
);
assert_eq!(
game.breath_clouds.len() - clouds,
usize::from(kind == "lingering_potion")
);
let gained = game.orbs.iter().map(|o| o.points() * o.count).sum::<u32>() - xp;
if kind == "experience_bottle" {
assert!((3..=11).contains(&gained), "silence keeps the experience");
on_big_stack(|| {
let Some(mut game) = source_silence_game() else {
return;
};
let stone = game
.pack
.assets
.registry
.default_state("minecraft:stone")
.unwrap();
game.world.set(8, 63, 8, stone).unwrap();
for kind in ["splash_potion", "lingering_potion", "experience_bottle"] {
for silent in [false, true, false] {
game.launch_thrown(
ItemStack::new(&format!("minecraft:{kind}"), 1)
.with_potion("minecraft:swiftness"),
Vec3::new(8.5, 64.1, 8.5),
Vec3::new(0.0, -1.0, 0.0),
None,
);
let id = *game.potions.thrown_ids().last().unwrap();
set_silent(&mut game, id, silent);
game.audio.heard.clear();
let particles = particle_count(&game);
let clouds = game.breath_clouds.len();
let xp: u32 = game.orbs.iter().map(|o| o.points() * o.count).sum();
game.tick_effects(true);
assert_eq!(game.potions.thrown_ids().count(), 0);
assert!(particle_count(&game) > particles, "{kind} keeps its splash");
assert_eq!(
sound_count(&game, "minecraft:entity.splash_potion.break"),
u32::from(!silent),
"{kind} Silent={silent}"
);
assert_eq!(
game.breath_clouds.len() - clouds,
usize::from(kind == "lingering_potion")
);
let gained = game.orbs.iter().map(|o| o.points() * o.count).sum::<u32>() - xp;
if kind == "experience_bottle" {
assert!((3..=11).contains(&gained), "silence keeps the experience");
}
}
}
}
});
}
fn set_silent(game: &mut Game<'_>, id: u32, silent: bool) {
@@ -1753,51 +1774,66 @@ mod tests {
#[test]
fn silent_bottles_preserve_xp_and_the_server_random_stream() {
use horizon_client::projectiles::{Hit, Projectile, ProjectileKind};
let Some(mut game) = source_silence_game() else {
return;
};
let mut rows = Vec::new();
for seed in [0, 1, -1, 42, i64::MIN, i64::MAX] {
let mut audible = None;
for silent in [false, true] {
game.world.random = horizon_core::random::LegacyRandom::new(seed);
game.orbs.clear();
let mut projectile = Projectile::new(
ProjectileKind::ExperienceBottle,
Vec3::new(8.5, 64.0, 8.5),
None,
);
projectile.id = game.take_entity_id();
set_silent(&mut game, projectile.id, silent);
game.projectile_hit(
&projectile,
Hit::Block {
pos: [8, 63, 8],
at: projectile.position,
face: [0, 1, 0],
},
)
.unwrap();
let xp: u32 = game.orbs.iter().map(|o| o.points() * o.count).sum();
let next = game.world.random.next_long();
assert!((3..=11).contains(&xp));
if let Some((audible_xp, audible_next)) = audible {
assert_eq!(
(xp, next),
(audible_xp, audible_next),
"Silent must not change XP or level RNG"
on_big_stack(|| {
let Some(mut game) = source_silence_game() else {
return;
};
let mut rows = Vec::new();
for seed in [0, 1, -1, 42, i64::MIN, i64::MAX] {
let mut audible = None;
for silent in [false, true] {
game.world.random = horizon_core::random::LegacyRandom::new(seed);
game.orbs.clear();
let mut projectile = Projectile::new(
ProjectileKind::ExperienceBottle,
Vec3::new(8.5, 64.0, 8.5),
None,
);
rows.push(format!(
"bottle_rng {seed} {} {}",
xp == audible_xp,
next == audible_next
));
} else {
audible = Some((xp, next));
projectile.id = game.take_entity_id();
set_silent(&mut game, projectile.id, silent);
game.projectile_hit(
&projectile,
Hit::Block {
pos: [8, 63, 8],
at: projectile.position,
face: [0, 1, 0],
},
)
.unwrap();
let xp: u32 = game.orbs.iter().map(|o| o.points() * o.count).sum();
let next = game.world.random.next_long();
assert!((3..=11).contains(&xp));
if let Some((audible_xp, audible_next)) = audible {
assert_eq!(
(xp, next),
(audible_xp, audible_next),
"Silent must not change XP or level RNG"
);
rows.push(format!(
"bottle_rng {seed} {} {}",
xp == audible_xp,
next == audible_next
));
} else {
audible = Some((xp, next));
}
}
}
write_silence_output("bottles", &rows);
});
}
/// Runs `test` on a thread with an 8 MiB stack. A Game's explosion and potion-impact paths
/// need more than the 2 MiB test thread in a Windows debug build, where those tests
/// overflowed it (4 MiB is enough).
fn on_big_stack(test: impl FnOnce() + Send + 'static) {
let thread = std::thread::Builder::new()
.stack_size(8 << 20)
.spawn(test)
.unwrap();
if let Err(panic) = thread.join() {
std::panic::resume_unwind(panic);
}
write_silence_output("bottles", &rows);
}
fn source_silence_game() -> Option<Game<'static>> {
Loaded 100 of 239 files, more files were not shown because too many files have changed in this diff. Show more