Compare commits

...
Author SHA1 Message Date
Kacper 4df2329711 Merge pull request #11942 from glitchsnake/fix/11804-block-outline-entity-pick
client/render: omit block outline when an entity is targeted (fixes #11804)
2026-11-08 00:37:36 +01:00
Kacper 7ca3fdeedd Merge pull request #11900 from TheMrZZ/fix/h175-frozen-first-tick-water
Register a newly loaded chunk's tick queues before commands reach it
2026-11-08 00:37:29 +01:00
Kacper e4ca9aba95 Merge pull request #11899 from CocofireHD/fix/8390-some-block-entities-render-too-dark-or-have
Block entities: shade by the face normal so shader packs don't darken them twice
2026-11-08 00:37:22 +01:00
Kacper 9848261b44 Merge pull request #11897 from abit-foggy/codex/studio-b2-camera-path
Studio: free camera and camera paths
2026-11-08 00:37:15 +01:00
Kacper 7e66da154b Merge pull request #11896 from NikitaMGrimm/hdr-hud-layer
HDR float frame: HUD and menus record how much of the world they cover
2026-11-08 00:37:08 +01:00
Kacper 3b03674d14 Merge pull request #11893 from CocofireHD/fix/8875-resource-packs-doesn-t-work-well
Fonts: read a pack's missing font files from the game below the overlay
2026-11-08 00:37:01 +01:00
Kacper 3fe5ad0d19 Merge pull request #11892 from TheMrZZ/fix/h173-live-dungeon-spawner
Create missing live dungeon spawner block entities
2026-11-08 00:36:54 +01:00
Kacper a1926a4901 Merge pull request #11882 from CocofireHD/fix/10228-server-specific-resource-pack-not-loading-pr
HUD fonts: pack a bitmap sheet from any texture path and read it from the atlas
2026-11-08 00:36:47 +01:00
Kacper 8dd178353a Merge pull request #11878 from CocofireHD/fix/10095-crash-when-enabling-a-shader-pack-on-opengl
OpenGL: report the context's texture units and refuse a bad pack instead of crashing
2026-11-08 00:36:40 +01:00
Kacper 0845a33141 Merge pull request #11864 from TheMrZZ/fix/h150-direct-passenger-relations
fix: select direct mob passengers for execute relations
2026-11-08 00:36:33 +01:00
Kacper e05360eb74 Merge pull request #11863 from senjinthedragon/glow-ink-text-tier
HDR and shader packs: glowing sign text shines
2026-11-08 00:36:27 +01:00
Kacper b967051f4f Merge pull request #11861 from Joansitoh/fix/11849-home-tab-strip
fix(launcher): wrap Home's tab strip on narrow windows instead of scrolling it sideways
2026-11-08 00:36:20 +01:00
Kacper 6c723039a7 Merge pull request #11858 from TheMrZZ/fix/h170-place-feature-inline
Place valid inline features with /place feature
2026-11-08 00:36:13 +01:00
Kacper a2c4c2d4fc Merge pull request #11856 from Ragbie/fix/infested-hurt-draw-order
Infested draws at the hurt, before an enderman's or a shulker's own roll
2026-11-08 00:36:06 +01:00
Kacper 3b5b0d8a3c Merge pull request #11850 from GIGABAIT93/l10n/uk-nightly-1107
Ukrainian: the 445 strings uk_ua.json lacked (PvP & HUD, Studio, mods, cosmetics)
2026-11-08 00:35:59 +01:00
Kacper 15ebef6a69 Merge pull request #11847 from TheMrZZ/fix/h204-depleted-absorption
Remove a mob's Absorption effect once the absorption is used up
2026-11-08 00:35:52 +01:00
Kacper 40630164be Merge pull request #11839 from vnsmoreira/packs/polytone-legacy
Polytone: legacy math expressions, color and distance functions
2026-11-08 00:35:45 +01:00
Kacper c6a2fd5291 Merge pull request #11838 from vnsmoreira/packs/polytone-groups-fix
Polytone: leave keys beside the attribute groups alone
2026-11-08 00:35:37 +01:00
Kacper 29d41a899b Merge pull request #11836 from yahya-nusseibeh/fix/remote-view-distance-overflow
Multiplayer: a server's view distance and chunk centre can't overflow
2026-11-08 00:35:31 +01:00
Kacper 61d5d3bd8a Merge pull request #11835 from yahya-nusseibeh/fix/portmap-zero-lifetime
Play with Friends: refuse a port mapping granted no lifetime
2026-11-08 00:35:24 +01:00
Kacper 99bf985e0a Merge pull request #11834 from yahya-nusseibeh/fix/guest-tunnel-token
Play with Friends: the guest's loopback tunnel takes only its own client
2026-11-08 00:35:17 +01:00
Kacper dc098b606b Merge pull request #11833 from yahya-nusseibeh/fix/pack-overlay-prune
Resource packs: delete overlays no selection has used lately
2026-11-08 00:35:11 +01:00
Kacper 51f73ac425 Merge pull request #11832 from yahya-nusseibeh/fix/pack-overlay-budget
Resource packs: cap what one overlay build may unpack
2026-11-08 00:35:04 +01:00
Kacper ac9bd0527a Merge pull request #11827 from TheMrZZ/fix/h168-mob-fall-flying
Keep a mob's FallFlying flag and wear its glider as vanilla does
2026-11-08 00:34:57 +01:00
Kacper 8715111af1 Merge pull request #11825 from vnsmoreira/packs/polytone-expressions
Polytone: the scripting expression language
2026-11-08 00:34:50 +01:00
Kacper 790efa9cb7 Merge pull request #11818 from NikitaMGrimm/hdr-note-hud-first
HDR design note: the HUD's layer and the tone curve before light is written
2026-11-08 00:34:43 +01:00
Kacper ae46423a77 Merge pull request #11795 from Hiuoy/port-pack/renamed-tooltip-anvil
Port pack: RenderTooltipEvent.Color and AnvilRepairEvent under their 26.1 names
2026-11-08 00:34:36 +01:00
Kacper 9da1e6f55f Merge pull request #11794 from doodlyyousuf/251107-fix-entity-air-drag
fix: widen entity air and fluid drags from vanilla floats
2026-11-08 00:34:29 +01:00
Kacper e27e97e58b Merge pull request #11793 from TheMrZZ/fix/h149-local-coordinate-math-current
fix: match exact local command rotation arithmetic
2026-11-08 00:34:22 +01:00
Kacper 02b75ab1f3 Merge pull request #11788 from Teiwazik/render/taa-motion-clip
TAA upscaling smears the world under motion
2026-11-08 00:34:15 +01:00
Kacper 3be40a13a3 Merge pull request #11786 from Teiwazik/render/upscaling-sharpness
Upscaling Sharpness: sharpen the TAA-upscaled world
2026-11-08 00:34:07 +01:00
Kacper c69d499386 Merge pull request #11778 from Teiwazik/fix/framed-maps-slots
Framed maps: no slot taken from a map drawn this frame
2026-11-08 00:33:59 +01:00
Kacper 1a83468f0d Merge pull request #11776 from MSedgeMC/fix/frog-panic-speed
Frogs panic at AnimalPanic's 2.0 speed multiplier
2026-11-08 00:33:51 +01:00
Kacper 1abb582ab4 Merge pull request #11769 from MSedgeMC/port_map/stale-batch-two
port map: a row that survived a rename
2026-11-08 00:33:45 +01:00
Kacper d44210f9b4 Merge pull request #11765 from MSedgeMC/test/enchant-durability
test: compare enchantment durability effects
2026-11-08 00:33:38 +01:00
Kacper 3cefb44bba Merge pull request #11740 from Detimes/fix-11721-explode-radius
Older clients: explosions no longer fail for 1.21.8 and older; real radius for 1.21.1
2026-11-08 00:33:25 +01:00
Kacper 5c28d7f248 Merge pull request #11723 from TheMrZZ/fix/loaded-arrow-shot-game-event
Dispatch ordinary arrow shoot events on their first tick
2026-11-08 00:33:18 +01:00
Kacper b1dae52509 Merge pull request #11711 from MSedgeMC/test/falling-block
test: compare falling block landings
2026-11-08 00:33:11 +01:00
Kacper 341bd0ba5b Merge pull request #11709 from TheMrZZ/fix/connected-guest-attribute-commands
Route attribute commands through the selected guest body
2026-11-08 00:33:04 +01:00
Kacper 6e423b9caa Merge pull request #11701 from Alysia42/random-source
Parity: entity and RandomSource.create() randoms draw from the level's random or are hand-seeded
2026-11-08 00:32:57 +01:00
Kacper 604c3a6654 Merge pull request #11694 from TheMrZZ/fix/h289-mob-equipment-custom-data
fix: match typed custom data in mob equipment predicates
2026-11-08 00:32:51 +01:00
Kacper b229c37e45 Merge pull request #11691 from TheMrZZ/fix/warden-loaded-memory-slots
Bind loaded Warden memories to their runtime slots
2026-11-08 00:32:44 +01:00
Kacper dcb6409ebd Merge pull request #11681 from MSedgeMC/test/nether-portal-shape
test: compare nether portal frame search
2026-11-08 00:32:37 +01:00
Kacper 217b5e97f7 Merge pull request #11672 from TheMrZZ/fix/loot-kill-sheep-state
Bind actual sheep target state in loot-kill commands
2026-11-08 00:32:31 +01:00
Kacper 4fe3ed58b4 Merge pull request #11666 from Raydnde/fix/copper-chest-wax-and-viewers-11378
Waxing a double copper chest reaches both halves; a golem's open chest doesn't weather
2026-11-08 00:32:24 +01:00
Kacper c15fa6c55c Merge pull request #11659 from TheMrZZ/fix/camel-inherited-saved-fields
Save Camel inherited horse fields from modeled state
2026-11-08 00:32:17 +01:00
Kacper d6d3042086 Merge pull request #11656 from TheMrZZ/fix/h212-crafter-drop-name
fix: keep vanilla crafter drops unnamed
2026-11-08 00:32:10 +01:00
Kacper c929d96fa5 Merge pull request #11652 from ilguerro/perf/11126-structure-starts-jigsaw
Read saved jigsaw structure starts instead of regenerating them
2026-11-08 00:32:03 +01:00
Kacper 5555cd8cda Merge pull request #11634 from k3yh/cushion-selectors
Selectors find cushions
2026-11-08 00:31:56 +01:00
Kacper f85f824a27 Merge pull request #11633 from MSedgeMC/fix/old-stats-file
Move an old name-based stats file to the player's uuid
2026-11-08 00:31:49 +01:00
Kacper e1a8d50c79 Merge pull request #11625 from TheMrZZ/fix/loaded-sleeper-bed-position
Refresh loaded sleeper position at the bed on its first tick
2026-11-08 00:31:42 +01:00
Kacper b69935ff31 Merge pull request #11624 from TheMrZZ/fix/h290-pot-template-compounds
fix: save pot sherd string templates as item compounds
2026-11-08 00:31:35 +01:00
Kacper 900ce5e15b Merge pull request #11612 from k3yh/ride-vehicles
/ride takes armor stands, displays, interactions and markers as vehicles
2026-11-08 00:31:28 +01:00
Kacper 7435df4fae Merge pull request #11610 from TheMrZZ/fix/world-pack-enchantment-definitions
Load active world-pack enchantment definitions
2026-11-08 00:31:21 +01:00
Kacper afce5629b7 Merge pull request #11590 from TheMrZZ/fix/h145-required-function-tags
fix: reject function tags with unresolved required references
2026-11-08 00:31:14 +01:00
Kacper 7f22f76867 Merge pull request #11576 from TheMrZZ/fix/folder-datapack-metadata
Require folder data-pack metadata sections before discovery
2026-11-08 00:31:06 +01:00
Kacper 08cb78f385 Merge pull request #11573 from vnsmoreira/attributes/modifiers
Environment attributes: the registry and modifiers, checked against vanilla
2026-11-08 00:30:59 +01:00
Kacper ebaea75be6 Merge pull request #11565 from TheMrZZ/fix/function-command-admission
Reject functions containing invalid ordinary commands
2026-11-08 00:30:52 +01:00
Kacper ad2aabd6cf Merge pull request #11558 from Vaspyyy/perf/vault-players-lazy
Vaults: list the players only when a vault looks for them (−12.6% tick_block_entities)
2026-11-08 00:30:46 +01:00
Kacper 534feb4833 Merge pull request #11544 from voidsipp/fix/11328-trade-modifiers
Fix trade modifiers and potion payment matching
2026-11-08 00:30:39 +01:00
Kacper b7d134d2ef Merge pull request #11540 from voidsipp/fix/11330-explosion-item-health
Spill contents when explosions destroy dropped containers
2026-11-08 00:30:32 +01:00
Kacper 921f2fa118 Merge pull request #11533 from TheMrZZ/fix/loot-mine-executor-presence
Carry actual loot-mine executor presence into block drops
2026-11-08 00:30:25 +01:00
Kacper 0429cd882f Merge pull request #11526 from Vaspyyy/perf/cart-push-loot
Minecarts: keep the loot roll out of the copies a cart push makes (−52% tick_carts)
2026-11-08 00:30:17 +01:00
Kacper b68f5b5327 Merge pull request #11509 from voidsipp/fix/smithing-transform-11307
Fix smithing transform optional inputs and result templates
2026-11-08 00:30:10 +01:00
Kacper d21170f02d Merge pull request #11508 from TheMrZZ/fix/camel-loaded-pose-timestamp
Preserve loaded Camel pose timestamp boundaries
2026-11-08 00:30:03 +01:00
Kacper 6488524356 Merge pull request #11498 from TheMrZZ/fix/effective-item-component-validation
Reject stackable damageable item command inputs
2026-11-08 00:29:56 +01:00
Kacper c9e8e4e8e0 Merge pull request #11497 from voidsipp/fix/11316-stack-tool-mining
Respect stack tool mining rules and actual eye immersion
2026-11-08 00:29:49 +01:00
Kacper b0abfbd196 Merge pull request #11491 from DUzzL/fix/10041-adventure-placed-entities
Adventure mode: honor can_place_on for placed entities and fireworks
2026-11-08 00:29:42 +01:00
Kacper 2c0564db8c Merge pull request #11485 from voidsipp/fix/11317-block-crack-feedback
Match player mining crack stages and broadcast distance
2026-11-08 00:29:34 +01:00
Kacper 41a021365e Merge pull request #11483 from TheMrZZ/fix/llama-saved-temper
Preserve signed equine temper across load and save
2026-11-08 00:29:28 +01:00
Kacper ef36a05d90 Merge pull request #11480 from brasga-a/fix/11314-player-food-predicate
Player predicates test food level and saturation
2026-11-08 00:29:21 +01:00
Kacper ab4a0fd56e Merge pull request #11478 from DUzzL/fix/11379-copper-golem-name
Keep the copper golem's name on its statue
2026-11-08 00:29:14 +01:00
Kacper 0e12cc64de Merge pull request #11474 from voidsipp/fix/11319-gateway-item-motion
Preserve item motion through End gateways
2026-11-08 00:29:07 +01:00
Kacper 4a6da129ce Merge pull request #11467 from voidsipp/fix/round5-5-11321
Respect world borders for pistons and natural spawning
2026-11-08 00:29:00 +01:00
Kacper 29f5ef342c Merge pull request #11466 from MSedgeMC/fix/region-external
Region: a chunk's external stream reads even when its stub length disagrees or its .mcc is missing
2026-11-08 00:28:53 +01:00
Kacper 672c9a7974 Merge pull request #11465 from TheMrZZ/fix/h097-loaded-air-first-tick
Discard loaded air falling blocks on their first tick
2026-11-08 00:28:46 +01:00
Kacper 3d275f03bf Merge pull request #11459 from DUzzL/fix/11387-beacon-effect-overlap
Beacon: select players by bounding-box overlap
2026-11-08 00:28:38 +01:00
Kacper 1dd7423d00 Merge pull request #11457 from voidsipp/fix/round5-1-11333
Respect Silent on explosions and potion impacts
2026-11-08 00:28:31 +01:00
Kacper 9c0c0c7210 Merge pull request #11456 from MrCloudy2/fix/dropper-container-entities
Droppers insert into chest boats and container minecarts
2026-11-08 00:28:23 +01:00
Kacper 671a85a7f2 Merge pull request #11455 from NikitaMGrimm/frame-identity-no-fall
Frame identity: keep the player on a block while the scenes are built
2026-11-08 00:28:15 +01:00
Kacper 63a8290dcb Merge pull request #11445 from MSedgeMC/test/furnace-tick
furnace: direct block-entity tick parity (burn table, XP, mid-cook reset)
2026-11-08 00:28:08 +01:00
Kacper fbd9b2ce23 Merge pull request #11434 from DUzzL/fix/11306-anvil-grindstone
Read stack names and durability components in anvil and grindstone
2026-11-08 00:28:00 +01:00
Kacper e290e2c196 Merge pull request #11433 from TheMrZZ/fix/falling-live-data-adoption
Adopt live FallingBlock data updates
2026-11-08 00:27:53 +01:00
Kacper 5671fc9619 Merge pull request #11432 from MSedgeMC/fix/phantom-anchor-save
Save a phantom's circling anchor with its chunk
2026-11-08 00:27:46 +01:00
Kacper 436e839ebf Merge pull request #11425 from TheMrZZ/fix/h051-kill-vehicle-contents
Spill explicit vehicle container contents on kill removal
2026-11-08 00:27:39 +01:00
Kacper 860f3f05d5 Merge pull request #11423 from DUzzL/fix/11311-scaffold-water
Read scaffolding as its stable shape in fluid wall tests
2026-11-08 00:27:33 +01:00
Kacper 88d7c0eb2c Merge pull request #11422 from voidsipp/fix/round5-6-11320
Preserve comparator output and hopper cooldown across saves
2026-11-08 00:27:26 +01:00
Kacper 2cf505bbd7 Merge pull request #11421 from DUzzL/fix/11324-spawn-heightmap
Fix natural spawn height bound off by one
2026-11-08 00:27:19 +01:00
Kacper cd218ea58e Merge pull request #11420 from TheMrZZ/fix/falling-block-state-default
Use modern falling-block state decoding and sand fallback
2026-11-08 00:27:12 +01:00
Kacper 161c4e5677 Merge pull request #11414 from voidsipp/fix/round5-3-11315
Honor block drops and RNG order for mining XP
2026-11-08 00:27:05 +01:00
Kacper a6dc594cdf Merge pull request #11413 from ilguerro/fix/w149
A server player's glide ends on a climbable (as_guest copies fall_flying back)
2026-11-08 00:26:58 +01:00
Kacper 013c3952a7 Merge pull request #11411 from ilguerro/fix/w167
Wait for the client's load after the End credits respawn
2026-11-08 00:26:51 +01:00
Kacper 5365f2be63 Merge pull request #11407 from ilguerro/fix/w157
Drop a converting piglin's inventory like vanilla
2026-11-08 00:26:44 +01:00
Kacper 0f23639844 Merge pull request #11400 from ilguerro/fix/w144
Search neighboring columns for dolphin breathing like vanilla
2026-11-08 00:26:38 +01:00
Kacper 997fe4515d Merge pull request #11390 from UMCEKO/dims/dragon-facts
The dragon fight follows has_ender_dragon_fight (#11282, 5/9)
2026-11-08 00:26:30 +01:00
Kacper 5d16154c0b Merge pull request #11366 from UMCEKO/dims/mob-facts
Mobs and spawn rules read the dimension type (#11282, 4/9)
2026-11-08 00:26:24 +01:00
Kacper 47e562e8b9 Merge pull request #11350 from AlfaRojo/fix/sky-darken-truncates
Sky darkening truncates the light level, as updateSkyBrightness does
2026-11-08 00:26:16 +01:00
Kacper 74b8550421 Merge pull request #11344 from UMCEKO/dims/gameplay-facts
Gameplay reads the dimension type, not the dimension (#11282, 3/9)
2026-11-08 00:26:09 +01:00
Kacper 54c3a21b29 Merge pull request #11116 from Delete228/content-context-geometry-upstream
Native content: resolve block geometry by entity context
2026-11-08 00:26:02 +01:00
Kacper d6f0b3042f Merge pull request #11098 from Delete228/native-state-entity-factory-upstream
Native mods: create block entities from the actual state
2026-11-08 00:25:55 +01:00
Kacper 0422dbc79f Merge pull request #10559 from rison1337/perf/cache-trim-preparation
Reuse unchanged armor-trim preparation across frames
2026-11-08 00:25:48 +01:00
Kacper 914c1e9a3b Merge pull request #10552 from ilguerro/perf/9996-surface
Write surface rule runs of one block at once
2026-11-08 00:25:41 +01:00
Kacper 62dd244cb6 Merge pull request #10511 from TheMrZZ/fix/noai-nautilus-air
Keep superclass air refill for NoAI Nautilus
2026-11-08 00:25:34 +01:00
Kacper 0e446e4336 Merge pull request #10242 from TheMrZZ/fix/h297-potion-effect-icon
Save default icon flags in custom potion effects
2026-11-08 00:25:27 +01:00
Kacper 48344d0a6b Merge pull request #10210 from TheMrZZ/fix/h103-trade-reputation-gate
Defer villager trade reputation until its AI phase
2026-11-08 00:25:20 +01:00
Kacper 87d4a87960 Merge pull request #10201 from DUzzL/fix/10147-projectile-catchup-stutter
Unstick arrows and tridents the server sets moving
2026-11-08 00:25:13 +01:00
VaspyyyandClaude Opus 5.5 1ba320ca73 Vaults: list the players only when a vault looks for them
with_vault built the list of every player (lan_present: eye heights, effects,
omens) for every loaded vault every tick. VaultBlockEntity.Server.tick reads the
players only when the vault's state may change (stateUpdatingResumesAt, once a
second), so the list is now built at the first ask, from the same game state.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 23:01:12 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 80e3532c4a docs: HDR design note: step 4 writes light a whole frame at a time
A blended draw over a surface of the other kind leaves one mark for both, so a frame that mixes
marked and written light would clip the written light and lift it again, or leave the marked part
unlifted. Step 4 now goes path by path: the frame-case rebuild first, then every marking shader on
the default path in one pull request, then Improved Transparency and the visibility buffer.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 21:06:12 +01:00
Horizon agent dd0050e268 Merge remote-tracking branch 'origin/main' into cg65v-10559
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-11-07 19:56:17 +01:00
Florian ErnstandClaude Opus 5.5 56a578f0a6 Merge origin/main into fix/h170-place-feature-inline
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 19:50:54 +01:00
abit-foggyandCodex GPT-6 e5fe17622c chore(studio): merge main into B2 branch
Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-07 08:20:11 -10:00
abit-foggyandCodex GPT-6 114b09babe feat(studio): autocomplete studio commands
Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-07 07:37:39 -10:00
LokiandAntigravity 0a85491338 client/render: omit block outline when an entity is targeted (fixes #11804)
Co-Authored-By: Antigravity <[email protected]>
2026-11-08 02:31:51 +09:00
Florian ErnstandClaude Opus 5.5 5f2e3cb14d Register a newly loaded chunk's tick queues before commands reach it
In a frozen level, water a command placed in newly forceloaded chunks
before the next world tick never flowed: the chunks' tick containers
were only registered by tick_blocks (a running world tick) or by
postprocessing, so the water's flow tick found no container and was
dropped. Vanilla registers them when the chunk becomes a full chunk
(LevelChunk.registerTickContainerInLevel), before any command sees it.

update_world now runs the existing sync_tick_chunks once the streamed
chunks are resident and the unloaded ones are saved with their ticks,
so registration comes before the tick's commands and world tick. Only
an authoritative game (not a server's client) registers.

Fixes #11873

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 18:06:05 +01:00
CocofireHDandopencode a61d272adc Block entities: shade by the face normal so packs don't darken them twice
A block entity's faces were baked with `minecraft_mix_light` and their
normal left zero. A shader pack's `gbuffers_block` (or its fallback)
receives the unshaded colour and the face normal from Iris, then applies
its own lighting, so Horizon's baked shade was applied a second time:
chests, shulkers, banners and the rest came out darker than the terrain
around them (#8390).

`draw_part` now emits the tint alone and the face's world normal
(`normal.w` = 127), and the entity shader applies `minecraft_mix_light`
from it, as it already does for mobs. Lights that are not the level's (an
item preview's `Lighting.Entry`, which the entity shader cannot know)
stay baked into the colour with `normal.w` 0, which is the path
`game/item_draw` gives block-entity items in inventories and hands.

The parity harness compares the tint and the face normal; the loom's GUI
preview keeps its baked `ITEMS_FLAT` colour. The chest cache test now
checks the nether flag reaches the shader instead of changing a baked
colour, so the cached geometry serves both dimensions.

Fixes #8390

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-07 18:05:25 +01:00
abit-foggyandCodex GPT-6 d607c2f137 feat(studio): add freecam and camera paths
Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-07 07:04:37 -10:00
Delete228andCodex e54dcc07cf Merge Horizon main into the stateful block entity PR
Co-authored-by: Codex <[email protected]>
2026-11-07 20:00:52 +03:00
Florian ERNSTandCodex 6079903ffc Create missing live dungeon spawner block entities
Carry the already selected mob through an optional creation-only level hook. Preserve existing entries and the generation initializer and RNG order.

Co-Authored-By: Codex <[email protected]>
2026-11-07 17:54:52 +01:00
CocofireHDandopencode 3a7f276eab Fonts: read a pack font file the overlay lacks from the game
A pack that adds font providers over the game's own can leave the overlay without the vanilla font sheets its merged font/default.json names. Every glyph then became the missing box. Read a bitmap sheet, TrueType file or Unihex file the overlay lacks from the game's own root, as vanilla's resource stack keeps the game below every pack. Font definitions stay the overlay's, so a pack filter can still hide one.

Fixes #8875

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-07 17:54:37 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 beaad0e79d HUD coverage: drop a pack's colour outputs the HUD has no target for
A pack text shader may declare colour outputs past location 0. The HUD's
pipelines have one target, so they went nowhere, but the coverage wrapper kept
them, and one at location 1 collided with the coverage and dropped a shader
that works today.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:50:19 +01:00
Horizon agentandClaude Opus 5.5 ab37cfb5aa Merge origin/main into #11497
items.rs tests: keeps this branch's tool-rules test and main's command
loot zero-count test, which both landed at the same place.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:48:34 +01:00
Horizon agentandClaude Opus 5.5 92b2977e3b Merge origin/main into #11474
end.rs: keeps this branch's live gateway items; main's only change there
(item_entity_to_nbt_with_items) was inside the saved-item path this
branch removes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:46:53 +01:00
CocofireHDandopencode f178cbe003 HUD atlas: pack a bitmap font's sheet from any texture path
A bitmap provider's file may be any texture, and HUD packs keep sheets outside textures/font. HudAtlas already received the sheet from the font commit, but only if the texture was packed: WALKED_TEXTURE_FOLDERS did not include custom paths such as textures/lumiere/hud, so those sheets still fell back to per-glyph run-time copies. Collect every bitmap provider's file while building the atlas, deduplicated against the walked textures, so its glyphs use the sheet's atlas rect.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-07 17:35:29 +01:00
CocofireHDandopencode 0478b4deec Fonts: use an atlas sheet outside textures/font for server HUDs
A bitmap provider may name any texture, and server HUD packs keep their glyph sheets under their own namespace or a path such as textures/lumiere/hud. The HUD atlas already holds those textures, but Font only looked up sheets under font/, so every glyph was decoded from disk and copied into the 512-row run-time region one by one. When that region filled, a glyph became the 6-pixel missing glyph, shifting the rest of the HUD line.

Look the sheet up by its atlas name for every namespace and keep every atlas sprite available to the font. Glyphs now use the sheet's atlas rect, so their advances come from the original texels and the run-time region is not involved.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-07 17:35:29 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 b5a1d40dc6 HDR coverage view: magenta at paper white, not PQ's 10 000 nits
In PQ output the debug view mixed toward PQ (1, 0, 1), so any covered pixel
blew out. Mix toward BT.709 magenta at the paper white instead, encoded like
the picture.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:32:22 +01:00
Florian ErnstandClaude Opus 5.5 1a9c87059b Merge origin/main into fix/connected-guest-attribute-commands
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:15:12 +01:00
Florian ErnstandClaude Opus 5.5 4d8e70da2c Merge origin/main into fix/h297-potion-effect-icon
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:14:59 +01:00
Florian ErnstandClaude Opus 5.5 8efbe1c75e Merge origin/main into fix/h051-kill-vehicle-contents
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:14:58 +01:00
Florian ErnstandClaude Opus 5.5 df25b701c5 Merge origin/main into fix/loaded-sleeper-bed-position
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:14:54 +01:00
TheMrZZandCodex gpt-6.1-sol 3890e57c13 fix: select direct mob passengers for execute relations
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-07 17:14:06 +01:00
Florian ErnstandClaude Opus 5.5 2282116773 Place valid inline features with /place feature
/place feature refused every inline feature value with "An unexpected
error occurred", although vanilla 26.3 places it like a named one
(PlaceCommand.placeFeature over a ResourceOrHolderArgument value) and
reports commands.place.feature.success.inline.

The inline value is read with the existing SNBT-to-JSON pair, decoded
by the same feature codec as a named feature (feature_with_blocks,
uncached), and placed through the same Feature.place path with the
level random. A feature that does not place reports
commands.place.feature.failed; a value that does not decode keeps the
plain failure.

Fixes #11843

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:10:54 +01:00
c74ff0070b HDR float frame: every HUD and menu draw writes its coverage of the world
With HORIZON_HDR_FRAME=float, HDR on and no shader pack, on devices with independent blending, each
HUD and menu pipeline gets a variant with a second output into an R8 target: how much of the world
behind it the draw hides (docs/design/hdr-linear-light.md, The HUD and menus). The fragment shader
is wrapped as it is, so the primary colour, its blend and alpha are unchanged, and the coverage
follows the draw's final colour and blend: layering by its alpha, additive draws not at all,
darkening ones by how much they darken, inverting and replacing ones fully. HORIZON_HDR_DEBUG=coverage
tints it magenta in the last pass; nothing else reads it yet. A resource pack text shader without a
colour output is dropped as an unusable one is, instead of panicking.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:05:50 +01:00
JoansitohandClaude Sonnet 5.5 af13271b86 fix(launcher): wrap Home's tab strip on narrow windows instead of scrolling it sideways
Fixes #11849

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 16:58:59 +01:00
GIGABAIT93andClaude Opus 5.5 0df4792709 Ukrainian: the 445 strings uk_ua.json lacked (PvP & HUD, Studio, mods, cosmetics)
PvP & HUD, Replay Studio, the music page, Browse Mods, the resource pack
browser, cosmetics and emotes, moderation and tempban, the controller page,
graphics tooltips and recipe transfer. Placeholders, leading and trailing
spaces match en_us; durations use abbreviations (хв, год, дн.) so one form
reads right for every number.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:56:20 +02:00
Evrim CicekandClaude Opus 5.5 51eae8d435 rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 18:50:56 +03:00
CocofireHDandopencode 7142128a7a wgpu-hal GL: report the context's texture units per stage
The GL backend reported MAX_TEXTURE_SLOTS (16, WebGL2's minimum) as max_sampled_textures_per_shader_stage even on desktop GL, where drivers usually offer 32. A native shader pack's sky pipeline layout declares the terrain's 10 sampled textures and the frame's 9, so it was refused at 16 although the driver accepts it (#10095).

Query GL_MAX_VERTEX_TEXTURE_IMAGE_UNITS and GL_MAX_TEXTURE_IMAGE_UNITS, take the lower, and cap at the arrays' new 32 slots (MAX_SAMPLERS likewise). On WebGL2 the query still yields 16.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-07 16:49:30 +01:00
CocofireHDandopencode 81df2a1864 Renderer: refuse a native shader pack instead of panicking
A layout or pipeline the backend cannot take (a stage's bindings past its sampled-texture limit, say) reaches the device's error scope. A native pack built its frame, stages and passes outside one, so wgpu's validation error hit the default handler and crashed the game (#10095). Build the pack in a scope and return the first error, which the shader pack screen already shows as a refusal.

The test asks for OpenGL's minimum 16 sampled textures per stage and builds a sky-only pack whose layout needs 19: the pack is an error, not a validation panic.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-07 16:49:30 +01:00
SenjinandClaude Opus 5.5 edb550ac32 HDR and shader packs: glowing sign text shines
Glow ink text was full-bright in the lightmap only: HDR output and shader packs that light
with their own GI (and read the HDR tier, horizonTier) showed it as dark as plain text.
Its glyphs now carry the low HDR tier, like other things that glow.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 16:48:02 +01:00
yahya-nusseibehandClaude Opus 5.5 f2e081520f Resource packs: take the unpack budget without fetch_update
CI's stable Rust deprecates AtomicU64::fetch_update in favour of
try_update, which the workspace's minimum Rust (1.95) doesn't have, so
clippy -D warnings failed. A compare_exchange_weak loop does the same
on every version.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-07 17:47:21 +02:00
Florian ErnstandClaude Opus 5.5 f1ea540f08 Remove a mob's Absorption effect once the absorption is used up
AbsorptionMobEffect.applyEffectTick, asked on every tick, answers false
when the absorption amount is 0, so MobEffectInstance.tickServer drops
the effect before its duration counts down, finite or infinite. A mob's
effect tick had no Absorption case: a used-up Absorption stayed until it
expired. It now ends through the existing removal path; other effects,
infinite ones included, are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 16:34:19 +01:00
Vinícius MoreiraandClaude Opus 5.5 12dd25d421 Polytone: legacy math expressions, color and distance functions
Older packs write expressions in the legacy syntax: uppercase variables
(AGE, DISTANCE_SQUARED, RAIN...), pi and e, and bare rand(), gaussian()
and noise(). Each variable now runs as the scripting call the wiki maps
it to, and a legacy expression keeps the old argument order of lerp,
smoothstep and color. The color functions (red, color, withAlpha,
lerpColor, hsb, lerpHsb...) follow vanilla's ARGB channel rules, and
dist/distSquare join the math table. All expressions of Visual
Effects+ now compile.

Part of #11486

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 12:25:21 -03:00
Vinícius MoreiraandClaude Opus 5.5 c6c0a97e57 Polytone: the scripting expression language
Expressions such as `o.age() * 0.1`, ternaries, `&&`/`||`, strings,
local variables, `if` blocks and calls on context objects are parsed
once when the pack loads, with every name resolved, so a typo is a load
error. A Host answers the context objects (o, p, c, g, r, v), their
methods and the game's global functions; the math functions follow
Java's Math. 1461 of the 1520 distinct expressions of Visual Effects+
compile; the rest use the legacy uppercase names, which come next.

Part of #11486

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 12:22:21 -03:00
Vinícius MoreiraandClaude Opus 5.5 edab7b313f Polytone: leave keys beside the attribute groups alone
An attributes_modifiers object with a group (`base`...) no longer refuses
the file over a key that is none of the modifier's groups: it is left
alone, so a pack Polytone loads isn't rejected over an entry neither reads.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 12:21:34 -03:00
Florian ErnstandClaude Opus 5.5 4924374fa5 Keep a mob's FallFlying flag and wear its glider as vanilla does
A mob loaded or summoned with FallFlying:1b kept the flag even when it
could not glide, and a gliding mob's elytra never wore. The flag was
only the raw tag's value: the loader did not read it, the save only
supplied a default, and the mob tick had no updateFallFlying step.

- mob_from_nbt reads FallFlying into the mob, and mob_to_nbt writes the
  mob's current flag (LivingEntity.read/addAdditionalSaveData).
- The mob's aiStep, with or without AI, runs updateFallFlying while the
  flag is set: canGlide (ground, vehicle, levitation, a glider whose
  next damage does not break it) clears the flag; every 20 glide ticks
  Util.getRandom picks a glider from the mob's own random.
- The game applies that wear with the existing durability path
  (Unbreaking from the level's random) to the mob's kept stack.
- LivingEntity.tick counts fallFlyTicks after aiStep.

Glide travel, checkFallDistanceAccumulation and the ELYTRA_GLIDE game
event are not part of this change.

Fixes #11809

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 16:14:52 +01:00
Horizon agentandClaude Opus 5.5 b86fa679b9 function_tags harness: run without --work-dir
tools/parity.py runs every harness with no arguments, so the harness's
required --work-dir made it fail on every CI run before reaching the
oracle. Without the flag it now captures into a fresh
.local/function-tags-parity; an explicit --work-dir must still be new.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 16:05:53 +01:00
Horizon agentandClaude Opus 5.5 45da47c08b Merge origin/main into #11666
game.rs: keeps this branch's wax_on_sound (honeycomb) beside main's
transform_copper_chest_neighbour (axe/hoe/shovel transformers, #33ec06ab12).
test_use_item_on_parity.py: keeps both the honeycomb double-chest cases
and main's block transformer cases.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 16:03:22 +01:00
yahya-nusseibehandClaude Opus 5.5 04449d8b73 Multiplayer: a server's view distance and chunk centre can't overflow
view_distance and chunk_center come straight from Login,
SetChunkCacheRadius and SetChunkCacheCenter, any i32 a server sends.
remote_chunk_in_range added 3 to the radius and subtracted the centre,
and the streaming radius and sent_radius added 1: a server sending
i32::MAX or i32::MIN panicked a debug build, and in release (no
overflow checks) the wrapped range dropped every loaded chunk.

The storage range is now in_storage_range: the same ClientChunkCache
rule (radius at least 2, plus 3), with a saturating add and abs_diff,
so nothing changes for any radius a real server sends. The +1s
saturate too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-07 17:00:32 +02:00
Horizon agentandClaude Opus 5.5 da0b4899ff Merge origin/main into #11480
Keeps both imports in command_exec_corpus.groups(): this branch's
player_food_predicate_corpus and main's treasure_map_copies.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 16:00:14 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 50b1d6232e docs: HDR design note: marks turned into light once in step 3, tests for mixed light and TAA
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 15:52:07 +01:00
yahya-nusseibehandClaude Opus 5.5 706cf3b3fa Resource packs: delete overlays no selection has used lately
Each distinct pack selection gets its own overlay folder under
.horizon-cache/resourcepacks, holding a full unpacked copy of its zip
packs, and nothing removed old ones. Every server pack, and every new
version of one, left a permanent copy the player can't see: ten
servers with 100 MiB packs kept about a gigabyte.

resource_root now marks the overlay it returns as used by this process
(a shared lock on <overlay>.overlay-cache.use, held until the process
exits, whose time is the last use), then keeps the 8 most recently used
overlays in the folder and deletes the rest. An overlay another
process uses or is building is skipped, so two games sharing a game
folder never pull one out from under each other; the marker goes before
the folder, so a half-deleted overlay is never reused. The lock files
stay, as build() already requires.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-07 16:45:03 +02:00
yahya-nusseibehandClaude Opus 5.5 439b609a94 Play with Friends: the guest's loopback tunnel takes only its own client
After the encrypted join, the guest offered the session on a random
127.0.0.1 port for 30 s and handed it to the first loopback connection.
Any other local user or process that found the port first got the
authenticated session to the host's server, and a web page aiming
fetch at local ports could send HTTP into the tunnel and make the
player's own join fail.

serve_loopback now returns a Loopback with the port, a random 16-byte
token and the thread. A connection must send the token within a second
of connecting (compared in constant time); anything else is dropped and
the port keeps waiting until its deadline. The client's join_friend
passes the token on, and run() writes it raw before the handshake.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-07 16:42:17 +02:00
Nikita Maximilian GrimmandClaude Opus 5.5 537291b26d docs: HDR design note: the HUD's layer and the tone curve before light is written
Light written into the frame can't be taken back out from under the HUD, and without the tone curve it clips at
the peak, so step 3 now holds the HUD's own layer and the tone curve, and step 4 the shader groups. The look
target names the differences allowed (rounding, light behaving as light under glass, water and anti-aliasing)
and the artifacts that aren't.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 15:41:33 +01:00
Horizon agentandClaude Opus 5.5 5ee3b9da9a Run the NoAI nautilus harness without --binary, as parity.py does
parity.py and parity-main call tools/test_*_parity.py without --binary or --work-dir, so the harness failed in 0 s there. Use harness_args.ensure_work_dir, as a98ba0273 did for the dolphin and spell-ticks harnesses.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 15:41:01 +01:00
Horizon agentandClaude Opus 5.5 307d82bb63 Merge main into #10511
Conflicts kept from both sides: crates/horizon-client/src/mobs/ai.rs

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 15:41:01 +01:00
yahya-nusseibehandClaude Opus 5.5 1e38170d49 Play with Friends: refuse a port mapping granted no lifetime
In PCP and NAT-PMP a lifetime of 0 means the mapping is deleted, but a
success reply carrying 0 was taken as a mapping. Mapping::due() was
then always true, so the host renewed on every 100 ms wake-up of its
loop (a UDP exchange each time, under the reach mutex) while
advertising a direct address that no longer existed.

pcp_map and natpmp_map now refuse a zero lifetime when they asked for
one (a delete, which asks for zero, still expects zero), and due()
never fires sooner than 30 s after the last renewal, so a router that
grants a few seconds isn't asked again ten times a second either.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-07 16:36:25 +02:00
yahya-nusseibehandClaude Opus 5.5 b0600cec3c Resource packs: cap what one overlay build may unpack
Applying packs builds an on-disk overlay: every file under assets/ in
every zip pack was inflated (up to 1 GiB each, whole in memory) and
written out, on every worker thread at once. A gigabyte of zeros
deflates to about a megabyte, so a server pack at the 250 MiB download
limit could write some 240 GiB to the player's disk and hold a 1 GiB
buffer per thread, once the player accepted the pack prompt. Vanilla
never unpacks a pack.

Zip::copy_to now streams an entry to its file 64 KiB at a time, taking
each piece from a byte budget shared by the whole build: 16 times the
zips' size on disk, at least 64 MiB, at most 8 GiB. Past it the build
fails with FileTooLarge (so the reload drops the server packs, as for
any failed build) and the half-written overlay folder is removed. An
unreadable entry still costs only that file. Memory per thread is now
the compressed entry plus one piece.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-07 16:31:41 +02:00
Evrim CicekandClaude Opus 5.5 c8753a2ea4 Infested's throw in floats as JOML does; the unit test hurts through Mob::damage
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:31:04 +03:00
Evrim CicekandClaude Opus 5.5 3fc5044868 Infested draws at the hurt, before an enderman's or a shulker's own roll
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:21:38 +03:00
Evrim CicekandClaude Opus 5.5 e44fc5af01 mob AI parity: an effect flag, an infested enderman in the rain and a poisoned shulker
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:21:38 +03:00
TeiwazikandClaude Opus 5.5 43e106cece Upscaling Sharpness: English texts
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 15:08:56 +01:00
umcekoandClaude Opus 5.5 1c8113ce32 Merge origin/main into #11344
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:05:06 +03:00
TeiwazikandClaude Opus 5.5 4b5b667560 Upscaling Sharpness: German and Russian texts
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 14:54:09 +01:00
HiuoyandClaude Opus 5.5 f898655ae6 Port pack: RenderTooltipEvent.Color and AnvilRepairEvent under their 26.1 names
NeoForge 26.1 has neither. A tooltip is drawn from a style's textures, picked
in RenderTooltipEvent.Texture, and the anvil take is AnvilCraftEvent, whose
.Pre comes before the inputs are used and the anvil is damaged (no break
chance). `events` now looks the 1.21.1 names up under those, so ATM10's 6
Color and 4 AnvilRepair users are answered by the hooks that exist.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:54:03 +04:00
73fa431ac5 fix: widen entity air and fluid drags from vanilla floats
Vanilla Entity.getAirDrag is 0.98F. PrimedTnt, FallingBlockEntity and
ItemEntity scaled motion by the f64 0.98, so after four ticks a TNT's
Motion x was 0.092236816 instead of 0.09223682318…. Item underwater
and lava drags, LlamaSpit air drag, and hurting-projectile inertia
were the same class of float-widened constants.

Fixes #6814

Co-Authored-By: OpenCode <[email protected]>
Co-authored-by: monkeycode-ai <[email protected]>
Co-authored-by: monkeycode-ai <[email protected]>
2026-11-07 13:53:30 +00:00
TheMrZZandCodex gpt-6.1-sol e135804a52 fix: match exact local command rotation arithmetic
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-07 14:46:33 +01:00
TeiwazikandClaude Opus 5.5 6689a656a0 TAA: clip history to the neighbourhood spread, weigh new frames more under motion
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 14:39:08 +01:00
TeiwazikandClaude Opus 5.5 fc64f0ee27 Upscaling Sharpness: CAS sharpen after TAA upscaling
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 14:38:31 +01:00
TeiwazikandClaude Sonnet 5.5 4a20af007c Framed maps: no slot taken from a map drawn this frame
The atlas holds 256 map pictures. Every framed map in range claimed one, seen or not, so past 256 the maps took slots from each other within one frame and showed each other's pictures. Now a frame's outline is culled before its map claims a slot, the nearest claim first, and a slot shown this frame is never given away: past the slots, the far maps go undrawn.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 14:33:28 +01:00
MSedgeMC aadec58092 port_map: the FrogAi.initCoreActivity panic gap is closed 2026-11-07 13:32:24 +00:00
MSedgeMC a90c5f8179 Test the panic speed a hurt frog's move control reads
Drives the real goal selector on a built test world: hurt the frog with a
PANIC_CAUSES damage, tick the goals once, and assert the panic goal started
and left vanilla's 2.0 in `Navigation.speed_modifier`, the value
`SmoothSwimmingMoveControl` multiplies. Reverting the constant fails it with
1.25 against 2.0.
2026-11-07 13:32:24 +00:00
MSedgeMC c1e649f61b Frogs panic at AnimalPanic's 2.0 speed multiplier
`FrogAi.initCoreActivity` builds the brain's core activity with
`AnimalPanic(2.0F)`, but the goal list used 1.25.

`PanicGoal.start` hands the multiplier to `PathNavigation.moveTo`, and
`SmoothSwimmingMoveControl.tick` computes
`speedModifier * getAttributeValue(MOVEMENT_SPEED)` and then multiplies by
0.1 on land or 0.02 in water. A frog's movement speed is 1.0, so a fleeing
frog should move 0.2 blocks a tick on land and 0.04 in water; at 1.25 it
moved 0.125 and 0.025, 60% slower.
2026-11-07 13:32:24 +00:00
VaspyyyandClaude Opus 5.5 eff292b1e2 Minecarts: keep the rolled loot out of the copies a cart push makes
push_from_ticked_cart cloned the ticked cart into the list and back each tick,
and with it a chest cart's rolled loot: its whole Items list as NBT. The stored
cart already goes without the loot for the tick (tick_carts_now takes it), so
the copies do too and the loot stays with the ticked cart.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 14:20:35 +01:00
Horizon agent a6b863754b Merge origin/main into PR #11672
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/game/commands/terrain_cmds.rs
#	crates/horizon-items/src/items.rs
2026-11-07 14:19:04 +01:00
Horizon agent 04b8ed64ca Merge origin/main into PR #11625
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/mobs/ai.rs
2026-11-07 14:17:57 +01:00
Horizon agent 1c81831b12 Merge origin/main into PR #11533
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/game/commands/terrain_cmds.rs
2026-11-07 14:17:31 +01:00
Horizon agent 547c7decbc Merge origin/main into PR #11508
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/game/persist.rs
2026-11-07 14:16:30 +01:00
Horizon agentandClaude Opus 5.5 a52b9e48f3 Merge #11366 (main merged) into #11390
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 14:10:37 +01:00
Horizon agentandClaude Opus 5.5 21a27ca73e Merge origin/main into #11433 (main builds again after #11767)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 14:10:29 +01:00
Horizon agentandClaude Opus 5.5 17e7655cdf Merge origin/main into #11366 (main builds again after #11767)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 14:10:29 +01:00
Horizon agentandClaude Opus 5.5 f8ab91d422 Merge origin/main into #11445 (main builds again after #11767)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 14:10:28 +01:00
Horizon agentandClaude Opus 5.5 6b72630759 Merge origin/main into #11116 (main builds again after #11767)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 14:10:28 +01:00
Horizon agentandClaude Opus 5.5 d4515b62ec Merge origin/main into #11425 (main builds again after #11767)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 14:10:28 +01:00
MSedgeMC 4583362634 Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-07 13:09:26 +00:00
MSedgeMC d00cf662b1 Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-07 13:09:20 +00:00
MSedgeMC 6deb10741a port_map: client-007's prepareQuadFlat row cites the renamed helper 2026-11-07 13:07:59 +00:00
Horizon agentandClaude Opus 5.5 7770124ea0 Merge #11366's fix into #11390
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 14:06:02 +01:00
Horizon agentandClaude Opus 5.5 78ab0c3b39 living.rs landing test: MobWorld's level and dimension_type in place of nether/end (main's #10445 test)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 14:06:02 +01:00
Horizon agentandClaude Opus 5.5 7c7d13c458 Merge origin/main into #11390 (through #11366's resolved main merge)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 14:01:51 +01:00
Horizon agentandClaude Opus 5.5 ed7114b4a0 Merge origin/main into #11366 (spawn_rules_fixture: main's spawn_selection imports, without the Dimension this PR removes)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 14:01:39 +01:00
Horizon agentandClaude Opus 5.5 acbb98aa5c Merge origin/main into #11433 (keep both data.rs test modules)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 14:00:23 +01:00
Horizon agentandClaude Opus 5.5 385877d1fc Merge origin/main into #11445 (keep both probe fixtures)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 14:00:13 +01:00
Horizon agentandClaude Opus 5.5 771ccd721f Merge origin/main into #11425 (keep both commands.rs test modules and command_exec imports)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 13:59:55 +01:00
Horizon agentandClaude Opus 5.5 b9e272b1ab Merge origin/main into #11116 (keep both native-mods.md paragraphs)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 13:59:18 +01:00
MSedgeMC 8fe6762146 test: compare enchantment durability effects
Add a direct Unbreaking and Mending durability comparison over the real
enchantment registry: item_damage remove_binomial (small and large paths)
and repair_with_xp, with --no-mending reproducing the Unbreaking corpus.
2026-11-07 12:50:41 +00:00
Horizon agentandClaude Opus 5.5 40dd7109bb Merge origin/main into random-source
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 13:09:37 +01:00
Horizon agentandClaude Opus 5.5 1db1b34dcb Merge origin/main into perf/11126-structure-starts-jigsaw
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 13:08:04 +01:00
Horizon agentandClaude Opus 5.5 d30b514728 Merge origin/main into fix/old-stats-file
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 13:06:46 +01:00
DetimesandClaude Opus 5.5 6993f213b6 step_1_21_1: drop the import the explode rewrite no longer uses
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 12:05:41 +00:00
Horizon agentandClaude Opus 5.5 511771b9f9 Merge origin/main into fix/11317-block-crack-feedback
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 13:03:03 +01:00
Horizon agentandClaude Opus 5.5 344382682e Merge origin/main into fix/11314-player-food-predicate
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 13:02:36 +01:00
Detimes edef044a68 Merge remote-tracking branch 'upstream/main' into fix-11721-explode-radius 2026-11-07 11:58:27 +00:00
DetimesandClaude Opus 5.5 47fd58c339 Older clients: real explosion radius for 1.21.1, explosions no longer fail for 1.21.8 and older
The 1.21.8 and 1.21.1 explode steps re-read a payload the renumbering had already put in the client's ids with
the pinned codec, so the particle's layout was chosen by the wrong id and sending any explosion failed. They now
read and write through the client's ids. 1.21.1's step read the knockback as three doubles where 1.21.4's step
leaves a double and two floats. 1.21.1's strength is 26.3's radius instead of a fixed 3.0.

Fixes #11721

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:54:53 +00:00
Horizon agent 88a75f983b Merge remote-tracking branch 'origin/main' into cg65v-10210 2026-11-07 12:54:24 +01:00
Horizon agent dbd5775947 Merge remote-tracking branch 'origin/main' into cg65v-10210
# Conflicts:
#	crates/horizon-client/src/game/command_harness.rs
#	mods/horizon/src/test/java/dev/horizonlab/parity/VanillaCommandExecOracle.java
#	tools/test_command_exec_parity.py
2026-11-07 12:39:25 +01:00
Horizon agentandClaude Opus 5.5 5a65831d4a Merge #11483's head (main with the clippy as_chunks lint fix)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:53:46 +01:00
Horizon agentandClaude Opus 5.5 64f9bb1a61 Merge main (clippy as_chunks lint fix)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:53:08 +01:00
Horizon agentandClaude Opus 5.5 991983679c Merge main (clippy as_chunks lint fix)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:52:41 +01:00
Florian ERNSTandCodex c2b59b6f99 Dispatch ordinary arrow shoot events on their first tick
Co-Authored-By: Codex <[email protected]>
2026-11-07 11:52:23 +01:00
Horizon agentandClaude Opus 5.5 b844b69826 Merge main (clippy as_chunks lint fix)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:51:48 +01:00
Horizon agentandClaude Opus 5.5 c2386a4616 Merge main (clippy as_chunks lint fix)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:50:06 +01:00
Horizon agentandClaude Opus 5.5 c524b40874 Merge main (clippy as_chunks lint fix)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:49:40 +01:00
Horizon agentandClaude Opus 5.5 d53411a24e Merge main (clippy as_chunks lint fix)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:49:15 +01:00
Horizon agentandClaude Opus 5.5 db503217e3 Merge main (clippy as_chunks lint fix)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:48:22 +01:00
Horizon agentandClaude Opus 5.5 dddf61b000 Merge main (clippy as_chunks lint fix)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:47:28 +01:00
Horizon agentandClaude Opus 5.5 48986d7fd8 Merge main (clippy as_chunks lint fix)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:46:34 +01:00
exampleandClaude Opus 5.5 7746c0ef78 Merge main: picks up #11716's lint fix for verify
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:45:49 +01:00
Horizon agentandClaude Opus 5.5 fbbe20dc9c Merge main (clippy as_chunks lint fix)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:45:40 +01:00
Horizon agentandClaude Opus 5.5 b9928751e4 Merge main (clippy as_chunks lint fix)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:45:12 +01:00
Horizon agentandClaude Opus 5.5 ed98ebc8a1 Merge main (clippy as_chunks lint fix)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:44:46 +01:00
Horizon agentandClaude Opus 5.5 c0ca0cce68 Merge main (clippy as_chunks lint fix)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:43:51 +01:00
Horizon agentandClaude Opus 5.5 984f2d9f06 Merge main (clippy as_chunks lint fix)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:43:23 +01:00
Horizon agentandClaude Opus 5.5 3ed97c7339 Merge main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:39:07 +01:00
Riccardo GuerrieroandClaude Opus 5.5 3f1b851c07 Merge origin/main into #11400 (main's 1.95 lint fix for interpolation.rs)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:37:51 +01:00
Horizon agentandClaude Opus 5.5 aa7516532a Merge main; drop run logs and the unrun diagnostic
tools/test_warden_memory_full_diagnostic.py is not a test_*_parity.py harness, so nothing runs it, and it is 816/839 by design. The canonical owned-memories harness stays. Drops the docs/parity run logs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:37:36 +01:00
Horizon agentandClaude Opus 5.5 43c987818b Drop the docs/parity run logs
Run logs are not docs; the PR body keeps the numbers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:32:52 +01:00
Horizon agentandClaude Opus 5.5 a94924cd72 Merge origin/main into fix/loot-kill-sheep-state
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:32:52 +01:00
Horizon agentandClaude Opus 5.5 fa9500a04d Stop pinning camel temper to variant3 in the pose test
The pose test asserted that Temper lands in the legacy variant3 byte. #11483/#11659 move every equine's temper to the signed equine_temper field; the assertion was outside this test's subject (LastPoseTick), so drop it rather than pin the storage.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:30:49 +01:00
Horizon agentandClaude Opus 5.5 81a719f832 Drop the docs/parity run logs
Run logs are not docs; the PR body keeps the numbers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:30:20 +01:00
Horizon agentandClaude Opus 5.5 029358df0d Merge #11483 (signed equine temper) into fix/camel-inherited-saved-fields
Both PRs added a signed AbstractHorse temper: #11483 for the seven horse and llama species, this one for camels. Keep one field, equine_temper, for every equine: the loader reads Temper unclamped for all of them, temper()/modify_temper() use it for camels too, and the camel saver writes it. The camel_temper field and the camel byte-clamp path are gone; tests follow.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:30:16 +01:00
Horizon agentandClaude Opus 5.5 ec62d53993 Merge origin/main into fix/camel-inherited-saved-fields
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:28:36 +01:00
Horizon agentandClaude Opus 5.5 23b6a5da91 crack feedback tests: give the test guest main's new Joined.configured
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:25:26 +01:00
Horizon agentandClaude Opus 5.5 1e1e48831a Merge origin/main into fix/11317-block-crack-feedback
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:25:20 +01:00
Horizon agentandClaude Opus 5.5 8a38d8831f Drop the docs/parity run logs
Run logs are not docs; the PR body keeps the numbers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:21:39 +01:00
Horizon agentandClaude Opus 5.5 4595cfca2f Merge origin/main into fix/loaded-sleeper-bed-position
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:21:38 +01:00
Horizon agentandClaude Opus 5.5 d7889ba66c Merge origin/main into fix/h290-pot-template-compounds
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:20:30 +01:00
Horizon agentandClaude Opus 5.5 78634140b3 Drop the docs/parity run logs
Run logs are not docs; the PR body keeps the numbers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:19:26 +01:00
Horizon agentandClaude Opus 5.5 5ebfcbdf7f Merge origin/main into fix/world-pack-enchantment-definitions
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:19:26 +01:00
Horizon agentandClaude Opus 5.5 c3083e75cb Fog test: clone the start state, FogState is no longer Copy
FogState now holds the torch flicker's LegacyRandom, so main's rain fog
test (settled_rain_fog_is_its_target_and_easing_is_unchanged) moved
`start` twice; verify failed on E0382.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:18:11 +01:00
Horizon agent 67728a8568 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-07 11:17:34 +01:00
Horizon agentandClaude Opus 5.5 9a1da6539a Merge origin/main into fix/h145-required-function-tags
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:15:52 +01:00
Horizon agentandClaude Opus 5.5 c9af605b1f Drop the docs/parity run logs
Run logs are not docs; the PR body keeps the numbers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:13:33 +01:00
Horizon agentandClaude Opus 5.5 b7772e4a84 Merge origin/main into fix/folder-datapack-metadata
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:13:32 +01:00
Horizon agentandClaude Opus 5.5 199eaad7da Merge #11631 into #11652: one RENAMES table
#11631 adds saved_piece_id (PiecesContainer.load's RENAMES) to the same file; read_jigsaw_start now asks it instead of its own copy of the four old ids. The upper-case id case is dropped from the test: Identifier.parse refuses upper case, so vanilla never reads 'MINECRAFT:JIGSAW' as a jigsaw piece. Land #11631 first.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:13:04 +01:00
Horizon agentandClaude Opus 5.5 2d05c9517f Merge origin/main into #11480
command_exec_corpus.groups: keep main's mob_name_predicate_corpus import beside this branch's player_food_predicate_corpus one.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:11:02 +01:00
Horizon agentandClaude Opus 5.5 e3e18fc523 Drop the docs/parity run logs
Run logs are not docs; the PR body keeps the numbers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:10:09 +01:00
Horizon agentandClaude Opus 5.5 c082361b4d Merge origin/main into fix/function-command-admission
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:10:09 +01:00
Horizon agentandClaude Opus 5.5 2860d47ffd Merge origin/main into #11666
Main's #11661 added LevelAccess::container_open and its test for the open copper chest; this branch keeps that name and its test and drops its own container_has_viewers, folding the copper golems' chests into GameLevel::container_open. The recheck_container_openers doc comments #11661 left stranded above container_open are back on their function.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:10:07 +01:00
Horizon agentandClaude Opus 5.5 a1ec90f7fc Stats: a joining player's old name-based file moves too
load_guest_records read stats/<uuid>.json directly, and on the server thread (the default single player) and on LAN and dedicated servers every player joins through it, so locateStatsFile only reached the in-frame path. The guest's own name goes to locate_player_json.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:08:59 +01:00
Horizon agentandClaude Opus 5.5 4d9d2cee62 Saved pieces: read an id without its namespace; move tag_json's doc back
Identifier.parse reads 'nvi' as minecraft:nvi, so RENAMES applies to a bare id too. The new saved_piece_id had been inserted between tag_json and its doc comment.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:08:17 +01:00
Horizon agentandClaude Opus 5.5 ed53fe24ad Drop the docs/parity run logs
Run logs are not docs; the PR body keeps the numbers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:06:33 +01:00
Horizon agentandClaude Opus 5.5 9fc3a4f0fc Merge origin/main into fix/loot-mine-executor-presence
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:06:32 +01:00
MSedgeMC c19b0817a0 test: compare falling block landings
Add a direct parity suite for the landing branch of
FallingBlockEntity.tick: real falling entities into air, solid blocks,
water, plants, slabs, cobwebs and moving pistons, with concrete powder's
water contact and the anvil landing event behind --no-contact.
2026-11-07 10:05:19 +00:00
Horizon agentandClaude Opus 5.5 3cb902bb21 Drop the docs/parity run logs
Run logs are not docs; the PR body keeps the numbers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:04:50 +01:00
Horizon agentandClaude Opus 5.5 9f8ce3033d Merge origin/main into fix/camel-loaded-pose-timestamp
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:04:49 +01:00
Horizon agentandClaude Opus 5.5 7cad6a50ff Drop the docs/parity run logs
Run logs are not docs; the PR body keeps the numbers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:03:19 +01:00
Horizon agentandClaude Opus 5.5 9712ebdde7 Merge origin/main into fix/effective-item-component-validation
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:03:18 +01:00
Florian ERNSTandCodex 70cedfb11a Route attribute commands through the selected guest body
Co-Authored-By: Codex <[email protected]>
2026-11-07 11:02:58 +01:00
Horizon agentandClaude Opus 5.5 63d0b51776 Drop the docs/parity run log
Run logs are not docs; the PR body keeps the numbers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:00:08 +01:00
Horizon agentandClaude Opus 5.5 8917e8a9cb Merge origin/main into fix/llama-saved-temper
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:00:07 +01:00
Horizon agentandClaude Opus 5.5 354ba4a38a Merge origin/main into fix/h097-loaded-air-first-tick
Keep main's fall_step(entities) signature with the air guard first;
keep main's vibration_tests module beside the new test module.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 10:57:58 +01:00
Alysia Zappavigna 4bb27d7c90 Merge remote-tracking branch 'upstream/main' into pr-11701 2026-11-07 20:48:42 +11:00
Ludovic MarieandClaude Opus 5.5 5e90d9f10b Merge origin/main into #11411; the credits test's Joined takes main's configured field
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 10:18:16 +01:00
Horizon agentandClaude Opus 5.5 faa8a4a52a Merge origin/main into #11413; the glide test's Joined and LanHost take main's new fields
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 10:17:40 +01:00
Ludovic Marie 6c0de3509d Merge remote-tracking branch 'origin/main' into w-11413 2026-11-07 10:17:26 +01:00
Horizon agentandClaude Opus 5.5 70ac0a01b8 Merge origin/main into #11457; the silent TNT test passes main's entity boxes (none)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 10:16:57 +01:00
Ludovic Marie 85a5c9f340 Merge remote-tracking branch 'origin/main' into w-11457 2026-11-07 10:16:57 +01:00
Horizon agentandClaude Opus 5.5 4aaf71dc39 Merge origin/main into #11411; main's spectator portal trips are not a credits respawn
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 10:10:54 +01:00
Ludovic Marie 6a3359d943 Merge remote-tracking branch 'origin/main' into w-11411 2026-11-07 10:10:44 +01:00
Horizon agentandClaude Opus 5.5 6397e50017 Merge origin/main into #11116; main's block_meets_player names its entity a Player
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 10:10:20 +01:00
Ludovic Marie fe36131256 Merge remote-tracking branch 'origin/main' into w-11116 2026-11-07 10:10:11 +01:00
TheMrZZandCodex gpt-6.1-sol 50467de40e fix: match typed custom data in mob equipment predicates
Project only typed custom-data snapshots from existing mob equipment
stacks and evaluate the admitted SNBT subset predicate. Preserve prior
view fields and unsupported unprojected-consumer behavior.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-07 10:05:55 +01:00
Horizon agentandClaude Opus 5.5 a7f5e08990 rustfmt the merge resolution
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 10:05:52 +01:00
Florian ERNSTandCodex d4d2214cfb Bind loaded Warden memories to their runtime slots
Co-Authored-By: Codex <[email protected]>
2026-11-07 09:59:36 +01:00
Horizon agentandClaude Opus 5.5 8368732e4e Merge origin/main into #11425
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 09:50:55 +01:00
Horizon agentandClaude Opus 5.5 e8edf93a48 Merge origin/main into #11457
splash takes both the PR's silent flag and main's hit motion (#11388).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 09:49:08 +01:00
Horizon agentandClaude Opus 5.5 dce6093276 Merge origin/main into #11414
Keep the mining experience cases alongside main's survival-only switch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 09:36:43 +01:00
Horizon agentandClaude Opus 5.5 b8a6663a3a Drop the run report from docs/
The evidence stays in the PR description.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 09:31:02 +01:00
Horizon agentandClaude Opus 5.5 05a81f37b5 Drop the run report from docs/
The evidence stays in the PR description.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 09:30:32 +01:00
MSedgeMC 1bc23d7a17 test: compare nether portal frame search
The portal parity suite only sent entities through portals: the PortalShape
search that turns an obsidian frame into portal blocks was never exercised.
Add a shape family over real frames and a --no-shape switch that reproduces
the previous corpus byte for byte.
2026-11-07 08:16:39 +00:00
Alysia Zappavigna e297f7245b ambience tests: import SoundEngine 2026-11-07 19:09:01 +11:00
Alysia Zappavigna 640dd0bcf5 ambience tests: import SoundEngine 2026-11-07 19:05:56 +11:00
Alysia Zappavigna 175fbe9e52 Merge commit '37d8dc0e5' into random-source
# Conflicts:
#	crates/horizon-client/src/game/lightning.rs
2026-11-07 18:56:19 +11:00
Alysia Zappavigna fde182860f random_source: repeat under HORIZON_STILL; tidy imports 2026-11-07 18:56:10 +11:00
Alysia Zappavigna 3fc0eb473e random_source: one RandomSource.create(); entity and level randoms as vanilla 2026-11-07 18:46:43 +11:00
Florian ERNSTandCodex f9eb279790 Bind actual mob target state in loot-kill predicates
Co-Authored-By: Codex <[email protected]>
2026-11-07 08:46:18 +01:00
exampleandClaude Opus 5.5 69ca4f94b7 Waxing a double copper chest reaches both halves; an open one doesn't weather
HoneycombItem.useOn gives a double chest's other half its game event and
particles and plays a second sound from the clicked block, on the server and
in the client's prediction. The wax sound leaves out the acting player and
goes out in the blocks source.

WeatheringCopperChestBlock.randomTick skips the attempt, and its draw, while
getEntitiesWithContainerOpen has anyone: a player's menu, or a copper golem
(CopperGolem.hasContainerOpen), a double chest counting for both halves.

Fixes #11378

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 08:24:54 +01:00
Florian ERNSTandCodex b322275555 Save Camel inherited horse fields from modeled state
Co-Authored-By: Codex <[email protected]>
2026-11-07 08:11:46 +01:00
TheMrZZandCodex gpt-6.1-sol b8d3a3b132 fix: keep vanilla crafter drops unnamed
Exclude actual crafters from automatic dropped-item name capture while
preserving chest/shulker names, contents and the other copy policies.
Cover the real destruction route against a frozen fresh Java reference.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-07 07:53:50 +01:00
MSedgeMC 03c5e6b091 port_map: PlayerList.locateStatsFile is ported
The row said the name-based stats file is not moved to the uuid file. It names the function that
does it, and the segment rule that decides whether vanilla would look for it at all.
2026-11-07 05:49:55 +00:00
MSedgeMC f4cdc0873c test: pin the old stats file moving to the uuid name
The test works in a temporary world folder: nothing is written when there is nothing to move, the
`players/stats/<name>.json` file lands on the uuid name and is not left behind, a uuid file that
already exists wins and leaves the old file alone, and the segment check refuses capitals and
separators while accepting a lowercase name with dashes, dots and digits.
2026-11-07 05:49:55 +00:00
MSedgeMC f802d6cc2c fix: move an old name-based stats file to the player's uuid
PlayerList.locateStatsFile: the stats file is `stats/<uuid>.json`, but a version that wrote
`stats/<player name>.json` left its numbers under the old name. When the uuid file does not exist
yet and the name is one vanilla would write as a path segment
(FileUtil.isValidPathSegment: `[-._a-z0-9]`, and not `.` or `..` — the check is on
`<name>.json`, so a capital letter in the player's name refuses it), the old file is moved to the
uuid name and that is the file that is read; a failed move leaves the old one in place.

Horizon always resolved the uuid path, so a world opened after such a version started with empty
stats. The 26.3 advancements file has no name-based form (`PlayerList.getPlayerAdvancements`
resolves the uuid directory directly), so only stats move.
2026-11-07 05:49:55 +00:00
k3yhandClaude Opus 5.5 29a511747d Selectors find cushions
A cushion is an ordinary entity in vanilla (a BlockAttachedEntity), but
Horizon had no command target for it: @e never matched one, so execute
if entity, tag, data get, tp and kill missed every cushion. Target::Cushion
joins the level's candidates like a painting; /kill removes it with no
drop and lets its rider go, /tp moves it with its block, data get writes
it through cushion_to_nbt.

Fixes #11626.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 00:44:33 -05:00
MSedgeMC 3afa8e234b port_map: the two saved-start rows say which piece ids are read
PiecesContainer.load now names saved_piece_id, where the rename table lives,
and StructureStart.loadStaticStart spells out that a jigsaw start (the renamed
old ids included) comes back from its saved pieces while another structure's
start comes back from the generator's rebuild of its chunk.
2026-11-07 05:41:18 +00:00
MSedgeMC efe0b28747 test: the four old jigsaw piece ids are read, other piece types are not
A piece tag named minecraft:nvi, pcp, bastionremnant or runtime comes back as
jigsaw, as does minecraft:jigsaw itself; a monument, stronghold or jungle
temple piece (OMB, SHCC, TeJP) comes back as nothing so the start keeps the
generator's rebuild, and so does a tag with no id or one that is not a
compound. With the table narrowed to minecraft:jigsaw alone the first four
fail.
2026-11-07 05:41:18 +00:00
MSedgeMC c7d9d1aedd fix: a saved start's pieces use the ids an old save wrote for jigsaw
PiecesContainer.load maps four piece ids - minecraft:nvi, minecraft:pcp,
minecraft:bastionremnant and minecraft:runtime - to minecraft:jigsaw before it
looks the type up: a save from before those pieces were jigsaw wrote them under
the older names with the same pool-element tag. The saved-start reader only
took the literal minecraft:jigsaw id, so such a start fell through to the
generator's rebuild of its chunk, and where that rebuild makes a different
layout (a world whose structure data or version moved on) the start came back
with no pieces at all: nothing placed, nothing bearding the terrain.

saved_piece_id now holds that rename table for the reader; a piece of another
type (a monument's or a stronghold's, whose NBT is not a pool piece's) still
sends the start to the rebuild.
2026-11-07 05:41:18 +00:00
Florian ERNSTandCodex 6c6efb8d74 Refresh loaded sleeper position from bed geometry on first tick
Co-Authored-By: Codex <[email protected]>
2026-11-07 06:31:19 +01:00
TheMrZZandCodex gpt-6.1-sol fc711c3f6d fix: save pot sherd string templates as item compounds
Encode bare item templates using the primary record form while preserving
compound counts and components. Cover actual canonical load/save and the
real command route against a frozen fresh Java reference.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-07 06:14:22 +01:00
k3yhandClaude Opus 5.5 945f40e8ff /ride takes armor stands, displays, interactions and markers as vehicles
RideCommand.mount forces the ride (startRiding(vehicle, true, true)), so
any entity takes a rider. Horizon only took boats, carts and mobs. A rider
now sits at the vehicle's default passenger attachment (its box height,
less the rider's own vehicle attachment) and gets off on sneak on top of
it, as Entity does for a vehicle that overrides neither.

Fixes #11603.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 23:40:02 -05:00
Florian ERNSTandCodex 4aeb14b3ba Load active world-pack enchantment definitions
Co-Authored-By: Codex <[email protected]>
2026-11-07 05:35:26 +01:00
k3yhandClaude Opus 5.5 b9854b1d82 /ride seats and unseats any server player, whoever runs it
RideCommand.mount and dismount work on any ServerPlayer in vanilla. Horizon's
/ride reached only the player in place, so the console, a function, a command
block or another player could not seat a guest (every player of a server or a
LAN world, and in single player too): the guest's turn now runs through
for_player, as native_mount does. A guest as the vehicle answers
cant_ride_players, as any player does.

Fixes #11602.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 23:16:44 -05:00
TheMrZZandCodex gpt-6.1-sol f3fcb0ca25 fix: reject function tags with unresolved required references
Propagate required dependency failures before expanding function tags so
invalid tags cannot run partial members. Skip whole invalid optional children
while preserving the existing ordered, deduplicated expansion.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-07 04:38:01 +01:00
Florian ERNSTandCodex 84f6959921 fix: require folder data-pack metadata sections
Reject folders without parseable metadata, an object pack section and a non-null description before exposing their contents. Preserve existing parser and format behavior.

Co-Authored-By: Codex <[email protected]>
2026-11-07 03:35:13 +01:00
Vinícius MoreiraandClaude Opus 5.5 f2c9af4827 Environment attributes: the registry and modifiers, checked against vanilla
Ports vanilla 26.3's environment attribute registry (EnvironmentAttributes:
types, defaults, ranges) and one EnvironmentAttributeMap entry: a value or
a {modifier, argument} object, parsed as its codec does (hex, list and int
colors, FloatWithAlpha, BlendToGray) and applied as AttributeModifier.apply
with float math in Java's order (ARGB.alphaBlend, addRgb, subtractRgb,
multiply, greyscale, scaleRGB, srgbLerp; Math.min/max). The layer stack
that sums these comes next; nothing uses the module yet.

The new harness environment_attribute_modifier compares 8,835 cases with
the pinned client, the registry included.

Part of #11486

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 23:07:36 -03:00
Florian ERNSTandCodex 0de6eeb024 fix: reject functions containing invalid ordinary commands
Validate candidate functions at actual compilation permission before exposing them, using refreshed world content and candidate registry IDs.

Co-Authored-By: Codex <[email protected]>
2026-11-07 02:55:05 +01:00
Kevin RamirezandClaude Fable 5.1 3aadba6aba CI: run verify again after a wall-clock test flaked on the slow runner
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-06 19:43:57 -06:00
voidsip 8c991c7472 Isolate trade parity runs and format modifiers 2026-11-06 19:39:51 -06:00
Florian ERNSTandCodex 593e9e05cc Carry actual loot-mine executor presence into block drops
Co-Authored-By: Codex <[email protected]>
2026-11-07 02:20:33 +01:00
voidsip e03f69d607 fix: spill contents of items destroyed by explosions 2026-11-06 19:16:27 -06:00
Horizon agent eadca0ce30 Merge main into #10242
# Conflicts:
#	tools/command_exec_corpus.py
#	tools/test_command_exec_parity.py
2026-11-07 02:01:07 +01:00
Horizon agent 3e88f5909b Merge main into #10210
# Conflicts:
#	tools/test_command_exec_parity.py
2026-11-07 02:00:23 +01:00
Horizon agent d64be662fe Merge main into #10559 2026-11-07 01:53:52 +01:00
Horizon agent 3b3f6aa00f Merge main into #10552 2026-11-07 01:53:46 +01:00
Horizon agent e2268eb854 Merge main into #10201 2026-11-07 01:53:35 +01:00
Florian ERNSTandCodex 8b9e8417af Preserve loaded Camel pose timestamp boundaries
Co-Authored-By: Codex <[email protected]>
2026-11-07 01:42:37 +01:00
Kevin RamirezandClaude Fable 5.1 1426129516 CI: run the checks again after the runner ran out of disk
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-06 18:33:12 -06:00
voidsip a932bc6c86 Create recipe directory for isolated smithing menu fixture 2026-11-06 18:28:12 -06:00
Florian ERNSTandCodex 2aa9308e47 Reject stackable damageable item command inputs
Co-Authored-By: Codex <[email protected]>
2026-11-07 01:04:42 +01:00
Kevin RamirezandClaude Fable 5.1 23c572b374 CI: run the checks again after the runner ran out of disk
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-06 18:03:09 -06:00
voidsip 8158bcae73 Use the item codec crate in the mining parity probe 2026-11-06 17:45:50 -06:00
voidsip 62724ffccf Record scoped mining parity source and artifact hashes 2026-11-06 17:41:16 -06:00
DUzzL 52105d2c9b Adventure mode: gate placed entities and firework on can_place_on 2026-11-07 00:38:11 +01:00
voidsip 9782763e82 Cover mining component admission and explicit break slots 2026-11-06 17:35:52 -06:00
voidsip 2eb6044dfc Keep remote mining fixture compatible with speed cache 2026-11-06 17:34:56 -06:00
Florian ERNSTandCodex dd47001112 Record signed equine temper proof and scope
Co-Authored-By: Codex <[email protected]>
2026-11-07 00:32:23 +01:00
Kevin RamirezandClaude Fable 5.1 6564896c45 CI: run the checks again after the runner ran out of disk
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-06 17:23:11 -06:00
Florian ERNSTandCodex b775c66123 Preserve signed equine temper independently of gameplay limits
Co-Authored-By: Codex <[email protected]>
2026-11-07 00:10:40 +01:00
voidsip e3295a9974 Trim gateway fixture trailing blank line 2026-11-06 17:10:23 -06:00
voidsip 8554155932 Remove obsolete saved gateway arrival helper 2026-11-06 17:00:00 -06:00
MSedgeMC d114d2ef89 test: a stub with inline data or no .mcc still reads the .mcc
Without the change the first reads `Err(InvalidData, "external chunk stub has
inline data")` and the second `Err(Os { code: 2, kind: NotFound })` instead of
the 4000 bytes in `c.-32.0.mcc` and an absent chunk.
2026-11-06 22:56:48 +00:00
MSedgeMC 30a3b019ff fix: read a chunk's external stream the way RegionFile reads it
`RegionFile.getChunkDataInputStream` never trusts the stub's length once the
external flag is set: `streamLength != 0` only logs "Chunk has both internal and
external streams" and the stream comes from the `.mcc`. When the `.mcc` is not a
regular file, `createExternalChunkInputStream` answers `null`, so the chunk is
absent. Horizon refused both: a stub that also declared inline data failed with
"external chunk stub has inline data", and a stub whose `.mcc` was gone failed
with a `NotFound` io error, which aborts a reader where vanilla skips the chunk.
2026-11-06 22:56:47 +00:00
voidsip 4807f13288 Format world border regression assertions 2026-11-06 16:56:09 -06:00
TheMrZZandCodex gpt-6.1-sol 1d72cf2ed8 Discard loaded falling air before age and physics
Use the existing air predicate and Gone result at the start of the actual falling-block tick. Cover admitted command actors with a real Game tick regression and frozen Java parity controls.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-06 23:53:32 +01:00
brasgaandClaude Opus 5.5 5b9fe567ec Player predicates test food level and saturation (#11314)
PlayerPredicate.matches calls FoodPredicate.matches on the player's
FoodData: the food level in `level`, then the saturation, a float
compared as a double, in `saturation`. The player facts carried no food
and the matcher never read `food`, so any food bounds passed.

PlayerFacts now holds the food, always filled (two numbers), for the
player in place and for a server's other players, and player_facts tests
it after the level, as vanilla does.

The command_exec harness gets a group that fills and drains the player's
food in player loops and tests named food predicates, a non-player, and
a tick advancement whose player condition asks only for food.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 19:53:29 -03:00
DUzzL 9558ecf848 Keep the copper golem's name on its statue 2026-11-06 23:49:15 +01:00
Kevin RamirezandClaude Fable 5.1 d6802414fa CI: run the checks again after the runner ran out of disk
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-06 16:43:06 -06:00
DUzzL a7d2edbfb9 Beacon: select players by bounding-box overlap with the effect box 2026-11-06 23:37:57 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 5d44519d5b Frame identity: keep the player on a block while the scenes are built
Left in the air at y 163, the player fell into the sea below while prepare() ran, which unlocked the boat
recipes: their toast showed over the first shots at a different moment each run, so main against itself
failed in the top right corner.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 23:27:38 +01:00
voidsip 7914cfe741 Keep saved-creeper radius test compatible with silence metadata 2026-11-06 16:23:43 -06:00
MSedgeMC c3326aed5e test: direct furnace block-entity tick parity
The command-execution furnace family could not give a checkable gap control
on this host, so this is a direct AbstractFurnaceBlockEntity.serverTick
harness instead: a real RecipeManager and a furnace, blast furnace or smoker
in a block-update level, compared with FurnaceCooking::tick. It pins the
cooking_fuel burn table for every fuel item, the 200/100/100 cook times and
speed multiplier, fuel consumption and LIT state, RecipesUsed experience, a
full output slot, timer decay, and an input changed mid-cook. --no-change
reproduces the corpus before that family.

757/757 cases match the pinned client.
2026-11-06 22:20:04 +00:00
voidsip 088b9e37e5 Merge commit '9d11d0c710705ee0901c2186d78641048a6660b0' into fix/11330-explosion-item-health 2026-11-06 16:13:47 -06:00
voidsip 98cfa7ef55 Merge current upstream main into stack tool mining fix 2026-11-06 16:13:44 -06:00
voidsip a79aa4f977 test: add headless dropped-item explosion regression 2026-11-06 16:13:38 -06:00
voidsip 55d7ad61f1 Merge commit '9d11d0c710705ee0901c2186d78641048a6660b0' into fix/11328-trade-modifiers 2026-11-06 16:13:33 -06:00
voidsip 2b839bce3d Keep smithing regression compatible with baseline APIs 2026-11-06 16:13:32 -06:00
voidsip 929ad64e2a Match trade enchantment modifiers and provider costs 2026-11-06 16:13:26 -06:00
voidsip 5345f12b9d Merge commit '9d11d0c710705ee0901c2186d78641048a6660b0' into fix/smithing-transform-11307 2026-11-06 16:12:56 -06:00
voidsip b1e79509ac Fix smithing transform optional inputs and result templates 2026-11-06 16:12:45 -06:00
voidsip bf40027865 Fix stack tool mining rules and immersion checks 2026-11-06 16:12:44 -06:00
voidsip 0289686b53 Merge commit '9d11d0c710705ee0901c2186d78641048a6660b0' into fix/11317-block-crack-feedback 2026-11-06 16:12:21 -06:00
voidsip eeb3c04215 Match server crack stages and observer range 2026-11-06 16:12:13 -06:00
voidsip 9af63f81d3 Merge commit '9d11d0c710705ee0901c2186d78641048a6660b0' into fix/11319-gateway-item-motion 2026-11-06 16:12:06 -06:00
voidsip 4fd7c5c271 Keep dropped items live through End gateways 2026-11-06 16:11:57 -06:00
voidsip c0aef4ac41 Merge commit '9d11d0c710705ee0901c2186d78641048a6660b0' into fix/round5-5-11321 2026-11-06 16:09:25 -06:00
voidsip 9e11182d53 Respect world borders for pistons and natural spawning 2026-11-06 16:09:24 -06:00
voidsip 854e12bf62 Merge upstream main for source-silence validation 2026-11-06 16:08:31 -06:00
MrCloudy2andClaude Opus 5.5 858582b279 Droppers insert into chest boats and container minecarts
DropperBlock.dispenseFrom finds its target with HopperBlockEntity.getContainerAt:
the block's container, else a container entity (chest boat or raft, chest or
hopper minecart) whose box meets the space in front, picked at random. Horizon's
dropper only asked for a block container, so items fell out next to a chest boat
instead of going in. It now falls back to the entity lookup hoppers already use.

The dispenser harness gains 48 dropper cases with chest boats, rafts and chest
and hopper minecarts centred in front, reaching in from beyond, just past the
space, two at once, behind a chest block and non-container vehicles. The oracle
and the fixture print the container entity's items (Tinv lines).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 23:06:16 +01:00
voidsip c06d1d81c4 Respect Silent on explosions and potion impacts 2026-11-06 16:06:09 -06:00
Florian ERNSTandCodex a44cdec48e Adopt live falling-block data into the resident entity
Co-Authored-By: Codex <[email protected]>
2026-11-06 23:03:23 +01:00
Kevin RamirezandClaude Fable 5.1 03e72bc0b0 CI: run the checks again after the runner ran out of disk
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-06 16:03:08 -06:00
MSedgeMC 197ad9fcc5 port_map: Phantom's anchor_pos is read and written
`tools/port_map/game2-002.tsv` recorded `gap: minor: anchor_pos is not read` and
`not written` for `Phantom.readAdditionalSaveData` and
`Phantom.addAdditionalSaveData`. Both now carry the anchor and say how: the key
only for a phantom that has an anchor, absent or unreadable as no anchor.
2026-11-06 22:00:10 +00:00
MSedgeMC 58feda2a09 test: a phantom's circling anchor through the chunk save
`mob_from_nbt` and `mob_to_nbt` are the pair `Game::load_mob` and
`Game::mob_tree_nbt` go through, so the test loads and saves a phantom by its
compound. A phantom at (0.5, 75, 0.5) whose save names `anchor_pos: [100, 64,
-40]` loads with that anchor, circles to (109.66, 62, -37.41) - the anchor,
`distance*cos(angle)` across and `-4 + height` up - and a phantom that moved its
anchor to (9, 70, -3) saves that position, not the one its compound came in
with. A phantom with no anchor loads with none and saves no `anchor_pos`.

Without reading the anchor the load gives the phantom (0, 80, 0), its own
position five blocks up, and the circle point (9.66, 78, 2.59); without writing
it the save keeps the stale (100, 64, -40).
2026-11-06 22:00:10 +00:00
MSedgeMC c02c215c62 fix: save a phantom's circling anchor with its chunk
`Phantom.addAdditionalSaveData` writes the anchor the phantom circles around
beside its size, and `readAdditionalSaveData` takes it back:

    protected void readAdditionalSaveData(final ValueInput input) {
       super.readAdditionalSaveData(input);
       this.anchorPoint = input.<BlockPos>read("anchor_pos", BlockPos.CODEC).orElse(null);
       this.setPhantomSize(input.getIntOr("size", 0));
    }

    protected void addAdditionalSaveData(final ValueOutput output) {
       super.addAdditionalSaveData(output);
       output.storeNullable("anchor_pos", BlockPos.CODEC, this.anchorPoint);
       output.putInt("size", this.getPhantomSize());
    }

Horizon models that anchor in `extra.home`, which
`PhantomCircleAroundAnchorGoal.selectNext` centres the circle on
(`anchor + (distance*cos(angle), -4 + height, distance*sin(angle))`), but
`mob_to_nbt` wrote only `size` and `mob_from_nbt` read only `size`. A phantom
saved and reloaded therefore kept the anchor `register` gives every new phantom
- its own position five blocks up, vanilla's `finalizeSpawn` value - instead of
the one in its save: a phantom at (0.5, 75, 0.5) loaded with
`anchor_pos: [100, 64, -40]` came back anchored at (0, 80, 0), and its first
circle point was (9.66, 78, 2.59) rather than (109.66, 62, -37.41).

`TagValueOutput.storeNullable` writes the key only for a non-null value, and
`read(...).orElse(null)` gives null for a save without one, so a phantom with no
anchor now writes no `anchor_pos` and loads back with none - the circle then
fills it with its own `blockPosition()`, as vanilla's does.
2026-11-06 22:00:10 +00:00
TheMrZZandCodex gpt-6.1-sol 008aba983e Spill explicit vehicle container contents on kill removal
Write back live menu slots before spilling chest boats, chest rafts and
container carts. Reuse the container split/motion algorithm through its
actual double-origin path, preserving integer callers and empty-slot draws.
Keep ordinary hurt, hull, lead and pending-loot policies separate.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-06 22:45:56 +01:00
Delete228andCodex 0c66644118 Merge Horizon main into the PR branch
Co-authored-by: Codex <[email protected]>
2026-11-07 00:45:54 +03:00
Kevin RamirezandClaude Fable 5.1 fdc26c707e CI: run the checks again after the runner ran out of disk
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-06 15:43:30 -06:00
Riccardo Guerriero 01b3be1502 Merge remote-tracking branch 'origin/main' into perf/11126-structure-starts-jigsaw 2026-11-06 22:42:17 +01:00
voidsip 9c292718aa Persist comparator output and hopper transfer cooldown 2026-11-06 15:40:57 -06:00
Florian ERNSTandCodex 7b53924423 Use modern falling-block state decoding and sand fallback
Co-Authored-By: Codex <[email protected]>
2026-11-06 22:36:12 +01:00
Kevin RamirezandClaude Fable 5.1 d4984f2ca0 CI: run the checks again after the runner ran out of disk
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-06 15:36:08 -06:00
DUzzL dd2d21f0f9 Fix natural spawn height bound off by one
NaturalSpawner.getRandomPosWithin samples up to chunk.getHeight(WORLD_SURFACE)+1,
and ChunkAccess.getHeight is firstAvailable-1, so the bound is firstAvailable.
Horizon already stored firstAvailable and added one more. Also correct the
oracle's fake LevelChunk.getHeight.
2026-11-06 22:30:06 +01:00
umcekoandClaude Sonnet 5.5 5fa72fe13a Gameplay reads the dimension type for lava, water, beds and anchors (#11282, 3/9)
Fast lava, water evaporation, bed and anchor rules, the raid-bright check, the
day timeline, ambient light and the default clock of /time read the level's
dimension type facts instead of matching on the level, as vanilla reads
DimensionType and its attributes. No behaviour change for the three built-in
levels.

Part of #11282

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 00:27:09 +03:00
umcekoandClaude Sonnet 5.5 0b23212f49 The dragon fight follows has_ender_dragon_fight (#11282, 5/9)
The fight tick, end crystal fire and respawn, the boss bar, the fight dragon
check, the exit portal and the DRAGON chunk ticket read the dimension type's
has_ender_dragon_fight, and the End flashes read its End skybox. No behaviour
change for the three built-in levels.

Part of #11282

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 00:27:09 +03:00
umcekoandClaude Sonnet 5.5 5db025666c Mobs and spawn rules read the dimension type (#11282, 4/9)
MobWorld carries the level key and its dimension type facts instead of nether
and end flags, and the spawn light test samples the type's
monster_spawn_light_level as vanilla's DimensionType.MonsterSettings does.
Fast lava, snow golem melting, piglin zombification, fixed-time and day
timeline checks read the type's facts. No behaviour change for the three
built-in levels; random draws are unchanged.

Part of #11282

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 00:27:09 +03:00
voidsip c558b29549 Honor block drops and RNG order for mining XP 2026-11-06 15:19:27 -06:00
DUzzL 37f8ebb618 Read scaffolding as its stable shape in fluid wall tests 2026-11-06 22:15:14 +01:00
Kevin RamirezandClaude Fable 5.1 ab98869192 CI: run the checks again after the runner ran out of disk
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-06 15:14:22 -06:00
DUzzL bedf076c56 Read stack components in anvil and grindstone results 2026-11-06 22:10:39 +01:00
Kevin RamirezandClaude Fable 5.1 7416b4a5f6 CI: run the checks again after the runner ran out of disk
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-06 14:57:15 -06:00
26210e2da4 Read saved jigsaw structure starts before regenerating them
Part of #11126.

Load complete jigsaw starts from the owning chunk's structures.starts into
its shared generator cache, retaining regeneration for unsupported or
undecodable starts. Read pending saves in every dimension and preserve
reference counts alongside the original piece layout.

Cover resource-free round trips, generated layouts across two seeds and
pending-record loading from a different referencing chunk.

Co-Authored-By: Codex (gpt-6.1-sol) <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WZzq8KX3vDMZy53y1vXqeT
2026-11-06 21:50:30 +01:00
Kevin RamirezandClaude Fable 5.1 b97af5d3da CI: run verify again after the runner ran out of disk
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-06 14:28:11 -06:00
rdbtCVSandClaude Opus 5.5 5c05eacc6d Let the remote motion handler reset a trident's stuck timer
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:17:07 +00:00
rdbtCVSandClaude Opus 5.5 ed8eaed845 Merge origin/main into PR #10201
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:11:11 +00:00
rdbtCVSandClaude Opus 5.5 0a6d5eb0a7 Merge origin/main into PR #10511
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:09:33 +00:00
Kevin RamirezandClaude Fable 5.1 ef6f7a2170 Sky darkening truncates the light level, as updateSkyBrightness does (#11323)
Level.updateSkyBrightness is `(int)(15.0F - skyLightLevel)`: the float
truncated. Horizon's environment::sky_darken (and the End copy in
Game::sky_darken) rounded, stepping the darkening up half a level early
at dusk and late at dawn, which moves bed eligibility and the light-
dependent spawns with it.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-06 14:00:13 -06:00
Riccardo Guerriero 425b65a99f Merge remote-tracking branch 'origin/main' into fix/w149 2026-11-06 20:45:56 +01:00
a222a7e47a End a server player's glide on a climbable like vanilla
LivingEntity.travelFallFlying stops the glide on a climbable, and a
server simulates its players' travel (AUTHORITATIVE_SIDE_AND_SERVER).
Horizon's server travel cleared the flag only on the guest's temporary
body; as_guest now copies it back, so the glide ends on the server and
the client, which takes the flag from the server, stops gliding too.

Co-Authored-By: Codex (gpt-6.1-terra) <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:45:15 +01:00
c4dcf2d2c8 Wait for the client's load after the End credits respawn
handleClientCommand(PERFORM_RESPAWN) for a player who won the game
calls restartClientLoadTimerAfterRespawn, as the death respawn does: the
player's movement and actions wait, and isInvulnerableTo spares it, until
its client reports the level loaded or 60 ticks pass. Horizon restarted
the timer only after a death.

Co-Authored-By: Codex (gpt-6.1-terra) <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:41:08 +01:00
be7591c70b Drop a converting piglin's inventory like vanilla
Piglin.finishConversion cancels the admire, then drops every non-empty
stack of its inventory with spawnAtLocation (whole stacks, components
kept), then converts; the converted sound comes before all of it. Horizon
dropped only the admired off-hand item, before the sound, and the
inventory was lost with the piglin.

Co-Authored-By: Codex (gpt-6.1-terra) <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:16:57 +01:00
Delete228andCodex be3fbc2d2b Supply entity kinds in collision probe fixtures
Co-authored-by: Codex <[email protected]>
2026-11-06 16:58:15 +03:00
Delete228andCodex 7af3e1ec44 Test stateful mod block entity lookup in the tooling fixture
Co-authored-by: Codex <[email protected]>
2026-11-06 16:58:15 +03:00
Delete228andCodex 79fe067a7e Declare the state factory with the GameLevel hook points
Co-authored-by: Codex <[email protected]>
2026-11-06 16:41:39 +03:00
cb62d074db Search neighboring columns for dolphin breathing like vanilla
Match vanilla's column order and fallback when choosing a breathing destination.

Co-Authored-By: Codex (gpt-6.1-terra) <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 14:35:37 +01:00
Delete228andCodex 796db06bf8 Resolve mod block shapes from entity context
Co-Authored-By: Codex <[email protected]>
2026-11-06 15:32:57 +03:00
Delete228andCodex 177548d416 Create native block entities from their actual block state
Co-Authored-By: Codex <[email protected]>
2026-11-06 15:21:47 +03:00
Horizon agentandClaude Opus 5.5 5ca811b47a Merge main into #10210; drop the packet trace and receipts
The merchant fixture compares strict command and menu records, so the raw
packet trace (net/command_trace.rs in the client receive loop, the Java
trace reader, the oracle's serialized channel) and the per-operation
receipts were diagnostics only. The production client no longer writes or
joins anything for them; tools/test_merchant_gate_parity.py is a thin
wrapper like the other command_exec fixtures.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 13:04:15 +01:00
Horizon agent 7c32d1c4d6 Merge main into #10242
# Conflicts:
#	crates/horizon-items/src/item_data.rs
#	tools/command_exec_corpus.py
#	tools/test_command_exec_parity.py
2026-11-06 13:01:20 +01:00
Horizon agentandClaude Opus 5.5 2ce4e8da11 Reset a remote trident's life on motion; cover lerpMotion in the remote motion test
ThrownTrident inherits AbstractArrow.lerpMotion, which restarts life even
when the motion is zero. The remote motion test compiles remote_motion out
of remote.rs, so its fixture needs the arrow fields; it now also checks
that zero motion keeps a stuck arrow in place and non-zero motion frees it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 12:38:30 +01:00
Horizon agent 3f1af338cb Merge main into #10201 2026-11-06 12:37:53 +01:00
rison1337andCodex GPT-6 786bd54096 Merge main to pick up mob_to_nbt build fixes
Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-06 15:33:17 +05:00
Riccardo Guerriero 16cdc3afd9 Merge remote-tracking branch 'origin/main' into perf/9996-surface 2026-11-06 10:27:43 +01:00
rison1337andCodex GPT-6 69d388deb0 Reuse completed armor-trim preparation between frames
Keep the completed ordered request list for the current atlas so an
unchanged overflow does not regenerate and upload every trim each frame.
Preserve final LRU residents, retry failures, and invalidate the cache
when equipment, atlas contents or the graphics device changes.

Fixes #10539

Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-06 14:01:59 +05:00
Riccardo GuerrieroandClaude Opus 5.5 0855321d41 A surface run above the minimum surface level stops there
Below the level the band takes its other variant, which may give another
block.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Fekd3xkgubE8VfXrALh2td
2026-11-06 09:50:34 +01:00
Riccardo GuerrieroandClaude Opus 5.5 db6a8b7f44 Write surface rule runs of one block at once
`build_surface_shared` applies the compiled rule at every solid block of a
column. In the Overworld most bands specialise to "the ore veins, then one
block" (deepslate), so 1.7M rule applications per 64 chunks produce ~1M block
writes, each walking the rule tree from the top and going through
`set_block`/`set_block_worldgen_column`.

A band variant that is a block rule, or ore veins followed by one, gives that
block at every position where no vein places one. The walk now writes the whole
run of solid blocks at once: the top y through the single-block path, the rest
through `TerrainChunk::fill_solid_run`, stopping at the first y where a vein
can place a block (each vein's first test, its density), at the band's bottom
and at the write bounds. Only blocks that are opaque for the stage's heightmaps
and carry no fluid take this path, so one update at the run's top leaves the
maps as a write per block does.

`CompiledRule::apply` on a sequence also tests its conditional children in the
loop instead of through a recursive call.

Instructions (callgrind, proto phase, 64 chunks, seed 12345): Overworld
6.49 G -> 6.03 G (-7.0%), surface stage 1.98 G -> 1.53 G (-23%), rule
applications 599 M -> 329 M; Nether 2.97 G -> 2.73 G (-8.1%).

A first draft by DeepSeek V4.1 Flash, via pi, reviewed and reworked.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 09:50:34 +01:00
Florian ERNSTandCodex 3443abbde5 Keep superclass air refill for NoAI Nautilus
Skip only the Nautilus custom air handler under NoAI, preserving the existing Breath/drown path and normal custom behavior. Cover actual Game and full Mob ticks against fresh Java.

Co-Authored-By: Codex <[email protected]>
2026-11-06 07:59:32 +01:00
TheMrZZandCodex gpt-6.1-sol 93365662aa Save default icon flags in custom potion effects
Resolve an absent saved show_icon from show_particles while preserving
explicit icon bytes and the remaining potion fields. Keep this in the
saved item encoder, without changing the general component view.

Add resource-free decoded/modelled codec coverage and a guarded,
zero-tick chest command regression against the pinned Java behavior.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-05 21:52:58 +01:00
TheMrZZandCodex gpt-6.1-sol 751423b897 Defer villager trade reputation to the eligible AI phase
Retain the real last trader on the villager when a trade is accepted,
then deliver the coalesced reputation event after Brain.tick. Keep it
across menu close and live NoAI edits, and refresh current offer prices
when the event actually runs.

Add an actual-owner merchant regression with complete raw capture and
separate strict-all byte-order diagnostics.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-05 20:39:57 +01:00
DUzzL 219a56f260 Unstick arrows and tridents the server sets moving (AbstractArrow.lerpMotion) 2026-11-05 20:27:46 +01:00
389 changed files with 27939 additions and 1921 deletions

No files matched your search

+5 -2
View File
@@ -46,8 +46,11 @@ original classes, game shaders, textures or prepared resources are distributed h
`third_party/wgpu-hal` is [wgpu-hal](https://github.com/gfx-rs/wgpu) 30.0.1 by the gfx-rs
developers, with upstream fixes applied
([gfx-rs/wgpu#10124](https://github.com/gfx-rs/wgpu/pull/10124),
[gfx-rs/wgpu#10714](https://github.com/gfx-rs/wgpu/pull/10714)). It is licensed under MIT or
Apache-2.0; both licenses are in that folder (`LICENSE.MIT`, `LICENSE.APACHE`).
[gfx-rs/wgpu#10714](https://github.com/gfx-rs/wgpu/pull/10714)). Its GL backend also reports
the context's sampled textures and samplers per shader stage (usually 32 on desktop GL,
where WebGL2's minimum 16 was reported before), so a pipeline layout the driver accepts is
not refused. It is licensed under MIT or Apache-2.0; both licenses are in that folder
(`LICENSE.MIT`, `LICENSE.APACHE`).
## naga
+2 -2
View File
@@ -290,12 +290,12 @@ pub fn normalize_quaternion(q: [f32; 4]) -> [f32; 4] {
[x * inv, y * inv, z * inv, w * inv]
}
/// JOML `Math.sin(float)` without fast math.
fn joml_sin(rad: f32) -> f32 {
pub fn joml_sin(rad: f32) -> f32 {
f64::from(rad).sin() as f32
}
/// JOML `Math.cosFromSin(float, float)` without fast math: the magnitude from
/// the sine, the sign from the angle's quadrant (exact zeros at right angles).
fn cos_from_sin(sin: f32, angle: f32) -> f32 {
pub fn cos_from_sin(sin: f32, angle: f32) -> f32 {
let cos = (1.0 - sin * sin).sqrt();
let tau = (std::f64::consts::PI * 2.0) as f32;
let a = angle + std::f32::consts::FRAC_PI_2;
@@ -217,9 +217,10 @@ pub fn stack(tag: &Tag) -> Option<Tag> {
Some(Tag::compound(out))
}
/// `ItemStackTemplate.CODEC`: an item id or `{id, count?, components?}`.
/// Encoding uses the primary record form, including for a bare item id.
fn template(tag: &Tag) -> Option<Tag> {
match tag {
Tag::String(_) => Some(tag.clone()),
Tag::String(_) => Some(Tag::compound([("id", tag.clone())])),
Tag::Compound(_) => {
let mut out = vec![("id", string(&text(tag.get("id"))?))];
if let Some(count) = int(tag.get("count")).filter(|c| *c != 1) {
@@ -1011,3 +1012,69 @@ mod tests {
assert_eq!(items[1].get("count"), Some(&Tag::Int(5)));
}
}
#[cfg(test)]
mod pot_template_tests {
use super::*;
use horizon_world::nbt::parse_snbt;
#[test]
fn decorated_pot_string_template_load_save_uses_item_compound() {
let parse = |s: &str| parse_snbt(s).expect("valid input NBT").0;
let input = |front: &str| {
let mut tag = parse(
r#"{id:"minecraft:decorated_pot",x:4,y:200,z:4,sherds:{back:{id:"minecraft:brick"},right:{id:"minecraft:archer_pottery_sherd"}},item:{id:"minecraft:wheat",count:3,components:{"minecraft:custom_name":"H290 item"}}}"#,
);
let Tag::Compound(root) = &mut tag else {
unreachable!()
};
let Some(Tag::Compound(sides)) = root.get_mut(&"sherds".into()) else {
unreachable!()
};
sides.insert("front".into(), parse(front));
tag
};
let other_fields = |loaded: &Tag, saved: &Tag| {
assert_eq!(saved.get("id"), loaded.get("id"));
for key in ["x", "y", "z", "item"] {
assert_eq!(saved.get(key), loaded.get(key), "{key}");
}
let loaded_sides = loaded.get("sherds").unwrap();
let saved_sides = saved.get("sherds").unwrap();
for side in ["back", "right"] {
assert_eq!(saved_sides.get(side), loaded_sides.get(side), "{side}");
}
assert_eq!(saved_sides.get("left"), None);
};
let plain = r#"{id:"minecraft:angler_pottery_sherd"}"#;
let defaults = r#"{id:"minecraft:angler_pottery_sherd",count:1,components:{}}"#;
let rich = r#"{id:"minecraft:angler_pottery_sherd",count:3,components:{"minecraft:custom_data":{h290:1},"minecraft:custom_name":"H290 sherd"}}"#;
// Actual canonical load/save entry, not the private template helper.
for (front, expected) in [(plain, plain), (defaults, plain), (rich, rich)] {
let loaded = input(front);
let original = loaded.clone();
let saved = canonical(&loaded).expect("actual decorated-pot codec");
other_fields(&loaded, &saved);
assert_eq!(
saved.get("sherds").unwrap().get("front"),
Some(&parse(expected))
);
assert_eq!(canonical(&saved).as_ref(), Some(&saved));
assert_eq!(loaded, original, "read-only canonicalization");
}
println!(
"H290 actual canonical compound/default/count/components, other sides/item/name and idempotency controls admitted"
);
let loaded = input(r#""minecraft:angler_pottery_sherd""#);
let original = loaded.clone();
let saved = canonical(&loaded).expect("actual decorated-pot codec");
other_fields(&loaded, &saved);
assert_eq!(loaded, original, "read-only canonicalization");
assert_eq!(
saved.get("sherds").unwrap().get("front"),
Some(&parse(plain)),
"string template must save the primary item compound",
);
assert_eq!(canonical(&saved).as_ref(), Some(&saved));
}
}
@@ -245,6 +245,7 @@ fn replay(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
settings.fxaa,
settings.taa,
settings.upscale,
0.85,
);
let mut notes = Vec::new();
let mut note = |what: &str, result: horizon_client_render::Result<()>| {
+4 -4
View File
@@ -1071,7 +1071,7 @@ impl Audio {
listener: [0.0; 3],
listener_yaw: 0.0,
master: 1.0,
random: LegacyRandom::new(crate::sound_engine::unique_seed()),
random: crate::random_source::create(),
source_override: None,
capture: None,
heard: Default::default(),
@@ -1232,7 +1232,7 @@ impl Audio {
listener: [0.0; 3],
listener_yaw: 0.0,
master: self.master,
random: LegacyRandom::new(crate::sound_engine::unique_seed()),
random: crate::random_source::create(),
source_override: None,
capture: None,
heard: Default::default(),
@@ -1482,7 +1482,7 @@ impl Audio {
"minecraft:ui.button.click",
1.0,
0.25,
SoundEngine::unseeded(),
crate::random_source::create(),
),
false,
);
@@ -1624,7 +1624,7 @@ impl Audio {
f64::from(pos[2]) + 0.5,
];
let event = crate::sound_events::normalize(event);
let instance = SoundInstance::for_jukebox_song(&event, at, SoundEngine::unseeded());
let instance = SoundInstance::for_jukebox_song(&event, at, crate::random_source::create());
self.play_instance(instance, true).0
}
/// Stop and drop a held instance.
@@ -77,6 +77,9 @@ pub struct BlockEntities {
/// The fluid tanks of mod blocks' block entities, by block state id;
/// built with `states`, like the containers.
mod_fluids: FxHashMap<u32, Arc<ModFluidTanks>>,
/// State-aware native factory ownership. Shared across level snapshots;
/// an empty slice means the declarations have not yet been compiled.
stateful_entities: Arc<[bool]>,
/// The energy buffers of mod blocks' block entities, by block state id;
/// built with `states`, like the containers.
mod_energy: FxHashMap<u32, Arc<ModEnergyBuffer>>,
@@ -209,6 +212,21 @@ impl BlockEntities {
pub fn mod_fluids(&self, state: u32) -> Option<&Arc<ModFluidTanks>> {
self.mod_fluids.get(&state)
}
pub fn set_stateful_entities(&mut self, states: Vec<bool>) {
self.stateful_entities = states.into();
}
pub fn has_stateful_entity_table(&self) -> bool {
!self.stateful_entities.is_empty()
}
pub fn stateful_entities(&self) -> Arc<[bool]> {
self.stateful_entities.clone()
}
pub fn stateful_entity(&self, state: u32) -> bool {
self.stateful_entities
.get(state as usize)
.copied()
.unwrap_or(false)
}
pub fn set_mod_energy(&mut self, energy: FxHashMap<u32, Arc<ModEnergyBuffer>>) {
self.mod_energy = energy;
}
@@ -3,8 +3,9 @@
//! decorated pots, conduits, enchanting-table and lectern books and copper
//! golem statues, End portals and End gateways. Models are vanilla's baked `LayerDefinitions` layers
//! (`entity-models.json`); transforms, animation and textures follow each
//! vanilla renderer. Faces are lit like the `entity` shader: the lightmap at
//! the block plus `minecraft_mix_light` with the level's two diffuse lights.
//! vanilla renderer. Faces carry their tint and world normal: the entity
//! shader (and a shader pack) applies `minecraft_mix_light` from the normal, as
//! vanilla's does.
use crate::{
Result,
bidi::visual,
@@ -128,6 +129,14 @@ fn pack_color(rgb: u32, alpha: u32, brightness: f32) -> u32 {
let c = |shift: u32| (((rgb >> shift) & 255) as f32 * brightness) as u32;
alpha << 24 | c(0) << 16 | c(8) << 8 | c(16)
}
/// A world normal as `Vertex::normal` holds it: RGBA8_SNORM, `w` positive so
/// the entity shader and a shader pack both shade by it. Block-entity models
/// carry no normals of their own (vanilla computes each face's from its
/// polygon, then `minecraft_mix_light` uses it in the shader).
fn pack_normal(normal: Vec3) -> [i8; 4] {
let c = |v: f32| (v.clamp(-1.0, 1.0) * 127.0).round() as i8;
[c(normal.x), c(normal.y), c(normal.z), 127]
}
/// Everything a draw call needs besides the geometry.
#[derive(Clone, Copy)]
@@ -165,6 +174,7 @@ pub fn draw_part(
light,
lights,
glow,
..
} = paint;
for poly in &part.polygons {
let pos = poly.map(|v| m.transform_point3(Vec3::new(v[0], v[1], v[2]) / 16.0));
@@ -177,11 +187,21 @@ pub fn draw_part(
} else {
normal
};
let color = pack_color(tint, 255, diffuse(normal, lights));
// The colour is the tint alone when the caller shades by the level's
// lights: the entity shader applies `minecraft_mix_light` from the
// normal, as vanilla does, so a shader pack receives the unshaded
// colour Iris gives it (a baked shade would be applied a second time
// by the pack's lighting). Other lights (an item preview's
// `Lighting.Entry`) are not the shader's to know, so those stay baked
// into the colour, as before.
let baked = !(lights == diffuse_lights(false) || lights == diffuse_lights(true));
let brightness = if baked { diffuse(normal, lights) } else { 1.0 };
let color = pack_color(tint, 255, brightness);
let normal = if baked { [0; 4] } else { pack_normal(normal) };
for (r, light) in std::iter::once((r, light)).chain(glow.map(|g| (g, FULL_BRIGHT))) {
for (v, position) in poly.iter().zip(pos) {
out.push(Vertex {
normal: [0; 4],
normal,
pos: position.to_array(),
uv: [r.u0 + (r.u1 - r.u0) * v[3], r.v0 + (r.v1 - r.v0) * v[4]],
layer: 0,
@@ -653,15 +673,16 @@ fn sign_text_calls(
let line_height = data.line_height();
let midpoint = (4.0 * line_height / 2.0).floor();
let dark = 0xFF00_0000 | text.dark_color();
let (color, outline, light) = if text.glowing {
let (color, outline, light, tier) = if text.glowing {
let c = text.text_color();
(
0xFF00_0000 | c,
if c == 0 || near { dark } else { 0 },
240 | 240 << 8,
crate::mesher::LOW,
)
} else {
(dark, 0, vertex_light(packed))
(dark, 0, vertex_light(packed), 0)
};
let advance = |c: char| (scene.glyph)(c).advance;
let mut calls: Vec<TextCall> = Vec::with_capacity(text.lines.len());
@@ -693,6 +714,7 @@ fn sign_text_calls(
color: run,
outline,
light,
tier,
}),
}
pen += span.text.chars().map(advance).sum::<f32>();
@@ -707,6 +729,7 @@ fn sign_text_calls(
color,
outline,
light,
tier,
});
}
}
@@ -761,7 +784,7 @@ pub fn text_vertices(glyph: &dyn Fn(char) -> Glyph, call: &TextCall, out: &mut V
.transform_point3(Vec3::new(cx, cy, z))
.to_array(),
uv,
layer: 0,
layer: u32::from(call.tier) << 16,
color: c,
light: call.light,
@@ -830,6 +853,9 @@ pub struct TextCall {
pub outline: u32,
/// Horizon packed light (sky*16 | block*16 << 8).
pub light: u32,
/// HDR emission tier of its glyphs (`mesher::hdr_tier`), 0 for none: glowing sign text
/// shines in HDR output and in shader packs (`horizonTier`), as full-bright text does in SDR.
pub tier: u8,
}
/// Inputs from the game for one frame of block-entity rendering.
@@ -3774,6 +3800,105 @@ mod tests {
// Straight down: ambient only.
assert!((diffuse(-Vec3::Y, lights) - 0.4).abs() < 1e-6);
}
/// A face's colour carries its tint alone and its normal carries the
/// shading: the entity shader applies `minecraft_mix_light` from it, as a
/// shader pack's lighting does, so a baked shade would be applied twice.
#[test]
fn a_face_keeps_its_tint_and_normal_for_the_shader_to_shade() {
let quad = |y: f32, z0: f32, z1: f32| {
[
[0.0, y, z0, 0.0, 0.0],
[0.0, y, z1, 0.0, 1.0],
[16.0, y, z1, 1.0, 1.0],
[16.0, y, z0, 1.0, 0.0],
]
};
// Opposite windings: the first faces up, the second down.
let part = ModelPart {
pose: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
polygons: vec![quad(0.0, 0.0, 16.0), quad(16.0, 16.0, 0.0)],
children: vec![],
};
let mut out = Vec::new();
draw_part(
&part,
"root",
Mat4::IDENTITY,
&|_, _| {},
Paint {
sprite: Rect {
u0: 0.0,
v0: 0.0,
u1: 1.0,
v1: 1.0,
w: 16,
h: 16,
},
tint: 0x123456,
light: 0,
lights: diffuse_lights(false),
glow: None,
},
&mut out,
);
assert_eq!(out.len(), 8);
// The same tint on both faces: no directional shade baked in (`pack_color`
// stores the tint in the vertex colour's BGR byte order).
let tint = pack_color(0x12_3456, 255, 1.0);
assert!(out.iter().all(|v| v.color == tint));
let (up, down) = (&out[0], &out[4]);
assert_eq!(up.normal, [0, 127, 0, 127]);
assert_eq!(down.normal, [0, -127, 0, 127]);
// The shader still gets the difference the old baked colours held.
let lights = diffuse_lights(false);
let up_normal = Vec3::new(0.0, f32::from(up.normal[1]) / 127.0, 0.0);
let down_normal = Vec3::new(0.0, f32::from(down.normal[1]) / 127.0, 0.0);
assert!(diffuse(up_normal, lights) > diffuse(down_normal, lights));
}
/// Lights the entity shader does not know (an item preview's
/// `Lighting.Entry`) stay baked into the colour, and `normal.w` 0 tells
/// the shader to keep it: `game/item_draw` passes those for block-entity
/// items in inventories and hands.
#[test]
fn lights_other_than_the_levels_stay_baked() {
let part = ModelPart {
pose: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
polygons: vec![[
[0.0, 0.0, 0.0, 0.0, 0.0],
[0.0, 0.0, 16.0, 0.0, 1.0],
[16.0, 0.0, 16.0, 1.0, 1.0],
[16.0, 0.0, 0.0, 1.0, 0.0],
]],
children: vec![],
};
let lights = (Vec3::X, Vec3::Z);
let mut out = Vec::new();
draw_part(
&part,
"root",
Mat4::IDENTITY,
&|_, _| {},
Paint {
sprite: Rect {
u0: 0.0,
v0: 0.0,
u1: 1.0,
v1: 1.0,
w: 16,
h: 16,
},
tint: 0x12_3456,
light: 0,
lights,
glow: None,
},
&mut out,
);
let baked = pack_color(0x12_3456, 255, diffuse(Vec3::Y, lights));
assert!(out.iter().all(|v| v.color == baked));
assert!(out.iter().all(|v| v.normal == [0; 4]));
assert_ne!(baked, pack_color(0x12_3456, 255, 1.0));
}
#[test]
fn part_poses_compose_like_model_part() {
let part = ModelPart {
@@ -3917,6 +4042,7 @@ mod tests {
color: 0xFFFF_FF00,
outline: 0xFF33_3300,
light: 240 | 240 << 8,
tier: 0,
};
let mut out = Vec::new();
draw_text(&scene, &call, &mut out);
@@ -3990,6 +4116,7 @@ mod tests {
color: 0xFF00_0000,
outline: 0,
light: 240 | 240 << 8,
tier: 0,
};
let mut out = Vec::new();
draw_text(&scene, &call, &mut out);
@@ -993,7 +993,9 @@ fn cached_closed_chests_match_the_direct_model_and_rebuild_when_opening() {
scene.nether = true;
let nether_closed = render_cached(&scene);
assert_same_vertices(&nether_closed, &render_direct(&renderer, &scene, &blocks));
assert_vertices_differ(&closed_again, &nether_closed);
// The nether is the entity shader's flag now (`entity_light`'s `l1` and
// `globals.time.y`), not a baked colour, so the cached geometry serves both.
assert_same_vertices(&closed_again, &nether_closed);
}
#[test]
@@ -620,6 +620,14 @@ impl Chunk {
entities: Vec::new(),
}
}
/// The original `structures.starts`, including starts with unknown pieces.
pub fn saved_structure_starts(&self) -> Option<&horizon_world::nbt::Compound> {
let structures = self.saved.as_ref()?.root.get(&"structures".into())?;
match structures.get("starts")? {
horizon_world::nbt::Tag::Compound(starts) => Some(starts),
_ => None,
}
}
/// The minimum block Y of the column.
#[inline]
pub fn min_y(&self) -> i32 {
@@ -59,6 +59,9 @@ pub enum BlockEntity {
CreakingHeart(Box<CreakingHeart>),
/// `BannerBlockEntity`: the pattern layers.
Banner(Banner),
/// `CopperGolemStatueBlockEntity`: its own components, which hold the
/// custom name of the golem it was (`createStatue`).
CopperGolemStatue(Compound),
/// `CrafterBlockEntity`.
Crafter(Crafter),
/// `SpawnerBlockEntity` (`crate::mobs::spawner`).
+23 -2
View File
@@ -208,7 +208,7 @@ where
Ok(resolve_world_movement(
self,
MovementRequest {
entity: EntityIdentity(0),
entity: context.entity().unwrap_or(EntityIdentity(0)),
context,
bounds,
movement,
@@ -279,7 +279,7 @@ impl SceneCollisions<'_, '_> {
Ok(resolve_world_movement(
self,
MovementRequest {
entity: EntityIdentity(0),
entity: context.entity().unwrap_or(EntityIdentity(0)),
context,
bounds,
movement,
@@ -300,6 +300,7 @@ impl SceneCollisions<'_, '_> {
CollisionContext::for_entity(
CollisionEntity {
identity: EntityIdentity(0),
kind: horizon_world::collision_context::EntityContextKind::Living,
y,
descending: false,
main_hand: Some(CollisionItem::EMPTY),
@@ -335,6 +336,26 @@ where
{
no_collision_in(world, bounds, true)
}
/// `CollisionGetter.noCollision(entity, bounds)`: retain the real entity snapshot.
pub fn no_collision_with_context<W, E>(
world: &mut W,
bounds: Aabb,
context: CollisionContext<'_>,
) -> bool
where
W: BlockCollisionWorld<Error = E> + EntityMovementWorld<Error = E>,
{
no_world_collision(
world,
CollisionCheckRequest {
entity: context.entity().unwrap_or(EntityIdentity(0)),
context,
bounds,
candidate_budget: 1_000_000,
},
)
.unwrap_or(false)
}
fn no_collision_in<W, E>(world: &mut W, bounds: Aabb, fluids: bool) -> bool
where
W: BlockCollisionWorld<Error = E> + EntityMovementWorld<Error = E>,
@@ -395,6 +395,9 @@ impl BlockEntity {
if name == "minecraft:daylight_detector" {
return Some(Self::DaylightDetector);
}
if name.ends_with("copper_golem_statue") {
return Some(Self::CopperGolemStatue(Default::default()));
}
if name == "minecraft:beacon" {
return Some(Self::Beacon(Box::new(Beacon {
last_check_y: i32::MIN / 2,
@@ -460,6 +463,7 @@ impl BlockEntity {
Self::CommandBlock(_) => crate::command_block::Mode::of_block(name).is_some(),
Self::Skull(_) => is_skull(name),
Self::Banner(_) => name.ends_with("_banner"),
Self::CopperGolemStatue(_) => name.ends_with("copper_golem_statue"),
Self::Shelf(_) => name == "minecraft:shelf" || name.ends_with("_shelf"),
Self::Mod(_) => !name.starts_with("minecraft:"),
Self::Beehive(_) => matches!(name, "minecraft:beehive" | "minecraft:bee_nest"),
@@ -510,6 +514,7 @@ impl BlockEntity {
Self::Lectern(_) => "minecraft:lectern",
Self::Sculk(s) => s.id(),
Self::Banner(_) => "minecraft:banner",
Self::CopperGolemStatue(_) => "minecraft:copper_golem_statue",
Self::Crafter(_) => "minecraft:crafter",
Self::CreakingHeart(_) => "minecraft:creaking_heart",
Self::Spawner(_) => "minecraft:mob_spawner",
@@ -558,7 +563,7 @@ impl BlockEntity {
Self::Mod(m) => m.items.iter().flatten().cloned().collect(),
Self::Lectern(l) => l.book.iter().cloned().collect(),
Self::Sculk(_) => Vec::new(),
Self::Banner(_) => Vec::new(),
Self::Banner(_) | Self::CopperGolemStatue(_) => Vec::new(),
Self::Crafter(c) => c.items.iter().flatten().cloned().collect(),
Self::Campfire(c) => c.items.iter().flatten().cloned().collect(),
Self::ChiseledBookshelf(b) => b.items.iter().flatten().cloned().collect(),
@@ -575,6 +580,7 @@ impl BlockEntity {
| Self::Lectern(_)
| Self::Sculk(_)
| Self::Banner(_)
| Self::CopperGolemStatue(_)
| Self::EndGateway(_)
| Self::Skull(_)
| Self::PotentSulfur(_)
@@ -609,6 +615,7 @@ impl BlockEntity {
| Self::Lectern(_)
| Self::Sculk(_)
| Self::Banner(_)
| Self::CopperGolemStatue(_)
| Self::EndGateway(_)
| Self::Skull(_)
| Self::PotentSulfur(_)
+179 -15
View File
@@ -141,7 +141,8 @@ impl DataPack {
pub fn discover(world: &Path) -> Vec<DataPack> {
discover_folder(&world.join("datapacks"))
}
/// Packs in Create World's temporary pack folder, before a world exists.
/// `FolderRepositorySource.loadPacks`: packs in Create World's temporary
/// folder or a world's data-pack directory, with required folder metadata.
pub fn discover_folder(dir: &Path) -> Vec<DataPack> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
@@ -150,7 +151,7 @@ pub fn discover_folder(dir: &Path) -> Vec<DataPack> {
for e in entries.flatten() {
let name = e.file_name().to_string_lossy().into_owned();
let path = e.path();
if path.is_dir() && path.join("pack.mcmeta").exists() {
if path.is_dir() && folder_metadata_present(&path) {
packs.push(DataPack {
id: format!("file/{name}"),
source: Source::Dir(path),
@@ -168,6 +169,22 @@ pub fn discover_folder(dir: &Path) -> Vec<DataPack> {
packs.sort_by(|a, b| a.id.cmp(&b.id));
packs
}
/// `Pack.readPackMetadata`: even its fallback codec requires a pack section
/// and description. Keep this folder gate separate from format compatibility
/// and the existing interpretation of the description component.
fn folder_metadata_present(path: &Path) -> bool {
let Some(meta) = std::fs::read(path.join("pack.mcmeta"))
.ok()
.and_then(|bytes| crate::packs::parse_meta(&bytes))
else {
return false;
};
meta.as_object()
.and_then(|root| root.get("pack"))
.and_then(serde_json::Value::as_object)
.and_then(|pack| pack.get("description"))
.is_some_and(|description| !description.is_null())
}
/// The resources root's built-in feature packs (`ServerPacksSource`
/// scanning the game's `data/minecraft/datapacks`): every folder with a
/// `pack.mcmeta`, a feature pack (`PackSource.FEATURE`). Off until a world
@@ -608,7 +625,7 @@ pub struct DataStore {
/// `item_modifier` files, for `/item modify`.
pub item_modifiers: BTreeMap<String, serde_json::Value>,
/// The content tables' raw files from the packs after the built-in one:
/// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `dialog/`, `damage_type/`, `data_maps/` and `tags/…/` files, by
/// `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
/// merge them over what they read from the resource root.
pub files: BTreeMap<String, Vec<Vec<u8>>>,
@@ -617,7 +634,7 @@ pub struct DataStore {
}
/// `<kind-dir>/<ns>:<path>` for a pack file the content tables read:
/// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `trade_set/`,
/// `villager_trade/`, `dialog/`, `damage_type/`, `data_maps/` or `tags/<sub>/`, where the id keeps the sub
/// `villager_trade/`, `dialog/`, `damage_type/`, `data_maps/`, `enchantment/` or `tags/<sub>/`, where the id keeps the sub
/// path so a nested tag kind stays addressable.
fn content_id(file: &str) -> Option<String> {
let rest = file.strip_prefix("data/")?;
@@ -625,7 +642,7 @@ fn content_id(file: &str) -> Option<String> {
let (kind, rest) = rest.split_once('/')?;
let kind = match kind {
"recipe" | "recipe_filter" | "loot_table" | "advancement" | "trade_set"
| "villager_trade" | "dialog" | "damage_type" | "data_maps" => kind,
| "villager_trade" | "dialog" | "damage_type" | "data_maps" | "enchantment" => kind,
// A tag id is `{ns}:{path-under-tags}` (`tags/worldgen/biome/
// test:sub/mytag`), the whole path after the namespace, so nested
// registries keep their directory (`TagLoader`'s id shape).
@@ -659,6 +676,7 @@ impl DataStore {
"damage_type",
"data_maps",
"dialog",
"enchantment",
"function",
"item_modifier",
"loot_table",
@@ -714,15 +732,55 @@ impl DataStore {
}
s
}
/// `FunctionManager.getTag`: the functions a tag expands to (nested tags
/// included, each function once).
/// `ServerFunctionLibrary.getTag`: only valid function tags expand
/// (nested tags included, each function once).
pub fn tag_functions(&self, id: &str) -> Vec<String> {
let mut out = Vec::new();
let mut seen_fns = std::collections::HashSet::new();
let mut seen_tags = std::collections::HashSet::new();
self.expand(id, &mut out, &mut seen_fns, &mut seen_tags);
let invalid = self.invalid_function_tags(id);
self.expand(id, &mut out, &mut seen_fns, &mut seen_tags, &invalid);
out
}
/// `TagLoader.tryBuildTag`: missing required members invalidate their tag and
/// required parents. Optional invalid children contribute nothing.
/// Resolve against the current accepted function map, without caching a
/// candidate store or changing the existing expansion's cycle handling.
fn invalid_function_tags<'a>(&'a self, id: &'a str) -> std::collections::HashSet<&'a str> {
let mut invalid = std::collections::HashSet::new();
let mut parents: std::collections::HashMap<&str, Vec<&str>> =
std::collections::HashMap::new();
let mut seen = std::collections::HashSet::new();
let mut pending = vec![id];
while let Some(tag) = pending.pop() {
if !seen.insert(tag) {
continue;
}
let Some(entries) = self.function_tags.get(tag) else {
invalid.insert(tag);
continue;
};
for (entry, required) in entries {
if let Some(child) = entry.strip_prefix('#') {
pending.push(child);
if *required {
parents.entry(child).or_default().push(tag);
}
} else if *required && !self.functions.contains_key(entry) {
invalid.insert(tag);
}
}
}
pending.extend(invalid.iter().copied());
while let Some(child) = pending.pop() {
for &parent in parents.get(child).into_iter().flatten() {
if invalid.insert(parent) {
pending.push(parent);
}
}
}
invalid
}
/// Depth-first walk keeping first-seen order. Each tag is expanded at most
/// once, so reference cycles end and nesting depth is not capped.
fn expand<'a>(
@@ -731,18 +789,19 @@ impl DataStore {
out: &mut Vec<String>,
seen_fns: &mut std::collections::HashSet<&'a str>,
seen_tags: &mut std::collections::HashSet<&'a str>,
invalid: &std::collections::HashSet<&'a str>,
) {
if !seen_tags.insert(id) {
if invalid.contains(id) || !seen_tags.insert(id) {
return;
}
let Some(entries) = self.function_tags.get(id) else {
return;
};
for (e, required) in entries {
for (e, _) in entries {
match e.strip_prefix('#') {
Some(inner) => self.expand(inner, out, seen_fns, seen_tags),
Some(inner) => self.expand(inner, out, seen_fns, seen_tags, invalid),
None => {
if (self.functions.contains_key(e) || *required) && seen_fns.insert(e) {
if self.functions.contains_key(e) && seen_fns.insert(e) {
out.push(e.clone());
}
}
@@ -924,6 +983,97 @@ pub fn worldgen_overrides(
mod tests {
use super::*;
#[test]
fn folder_metadata_is_required_before_contents_are_discovered() {
let root =
std::env::temp_dir().join(format!("horizon-folder-metadata-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
for (name, metadata) in [
("broken", Some("{")),
("root_empty", Some("{}")),
("root_array", Some("[]")),
("pack_empty", Some(r#"{"pack":{}}"#)),
("pack_string", Some(r#"{"pack":"bad"}"#)),
("missing", None),
(
"full",
Some(
r#"{"pack":{"description":"full","pack_format":121,"min_format":[121,0],"max_format":[121,0]}}"#,
),
),
(
"description",
Some(r#"{"pack":{"description":"description only"}}"#),
),
("numeric", Some(r#"{"pack":{"description":42}}"#)),
("null", Some(r#"{"pack":{"description":null}}"#)),
("bom", Some("\u{feff}{\"pack\":{\"description\":\"BOM\"}}")),
(
"comment",
Some("{\"pack\":{\"description\":\"comment\"}}\n// trailing"),
),
] {
let dir = root.join(name);
std::fs::create_dir_all(dir.join("data/h146/function")).unwrap();
std::fs::write(
dir.join(format!("data/h146/function/{name}.mcfunction")),
"scoreboard players set admitted h146 1\n",
)
.unwrap();
if let Some(metadata) = metadata {
std::fs::write(dir.join("pack.mcmeta"), metadata).unwrap();
}
}
let packs = discover_folder(&root);
assert_eq!(
packs.iter().map(|p| p.id.as_str()).collect::<Vec<_>>(),
[
"file/bom",
"file/comment",
"file/description",
"file/full",
"file/numeric"
]
);
let store = DataStore::load(&packs.iter().collect::<Vec<_>>());
assert_eq!(
store
.functions
.keys()
.map(String::as_str)
.collect::<Vec<_>>(),
[
"h146:bom",
"h146:comment",
"h146:description",
"h146:full",
"h146:numeric"
]
);
let numeric = packs.iter().find(|p| p.id == "file/numeric").unwrap();
let raw: serde_json::Value =
serde_json::from_slice(&numeric.read("pack.mcmeta").unwrap()).unwrap();
assert_eq!(raw["pack"]["description"], 42);
// Builtin and feature construction still have their independent paths.
let resources = root.join("resources");
let feature = resources.join("data/minecraft/datapacks/feature");
std::fs::create_dir_all(&feature).unwrap();
std::fs::write(
feature.join("pack.mcmeta"),
r#"{"pack":{"description":"feature"}}"#,
)
.unwrap();
assert_eq!(
feature_packs(&resources)
.iter()
.map(|p| p.id.as_str())
.collect::<Vec<_>>(),
["feature"]
);
assert_eq!(DataPack::builtin(&resources).id, "vanilla");
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn data_map_files_are_content_files_keyed_by_map_namespace_and_registry() {
assert_eq!(
@@ -961,7 +1111,7 @@ mod tests {
std::fs::write(path, t).unwrap();
};
for pack in ["a", "b", "off"] {
write(pack, "pack.mcmeta", "{}");
write(pack, "pack.mcmeta", r#"{"pack":{"description":"fixture"}}"#);
write(pack, "data/minecraft/worldgen/noise/cave.json", pack);
write(pack, "data/p/structure/room/a.nbt", pack);
write(
@@ -1132,6 +1282,20 @@ mod tests {
#[test]
fn tag_resolution_dedupes_fast_ends_cycles_and_nests_deeply() {
let mut store = DataStore::default();
// Every referenced function exists; missing required ones invalidate a tag.
for id in (0..25_000)
.map(|i| format!("t:f{i}"))
.chain(["t:x", "t:y", "t:deep"].map(str::to_owned))
{
store.functions.insert(
id.clone(),
Function {
id,
lines: Vec::new(),
parameters: Vec::new(),
},
);
}
let big: Vec<(String, bool)> = (0..50_000)
.map(|i| (format!("t:f{}", i % 25_000), true))
.collect();
@@ -1216,13 +1380,13 @@ mod tests {
write(
"fns",
"pack.mcmeta",
r#"{"pack":{"min_format":121,"max_format":121}}"#,
r#"{"pack":{"description":"fixture","min_format":121,"max_format":121}}"#,
);
write("fns", "data/x/function/f.mcfunction", "say hi");
write(
"off",
"pack.mcmeta",
r#"{"pack":{"min_format":121,"max_format":121}}"#,
r#"{"pack":{"description":"fixture","min_format":121,"max_format":121}}"#,
);
std::fs::create_dir_all(&dir).unwrap();
let mut zip = zip::ZipWriter::new(std::fs::File::create(dir.join("old.zip")).unwrap());
@@ -399,8 +399,11 @@ pub fn apply_weather(sky: &mut Sky, rain: f32, thunder: f32) {
pub fn sky_darken(environment: &Environment, day_time: i64, rain: f32, thunder: f32) -> i32 {
let mut sky = environment.sample(day_time, None, None);
apply_weather(&mut sky, rain, thunder);
// Level.updateSkyBrightness: darkening from the sky light level.
(15.0 - 15.0 * sky.sky_light_level).round() as i32
// Level.updateSkyBrightness: `(int)(15.0F - skyLightLevel)`, the float
// truncated, not rounded (#11323): the darkening steps up only once the
// light has fallen a whole level. `sky_light_level` is the attribute as a
// fraction of 15, so the product is its raw value in `f32`.
(15.0 - 15.0 * sky.sky_light_level) as i32
}
#[cfg(test)]
+20 -15
View File
@@ -9,6 +9,7 @@ use crate::{
environment::Sky,
world::World,
};
use horizon_core::random::{LegacyRandom, RandomSource};
use std::path::Path;
/// `FogType` of the camera position.
@@ -64,7 +65,7 @@ impl FogTables {
}
/// Fog state that evolves over time.
#[derive(Clone, Copy, Debug, Default)]
#[derive(Clone, Debug)]
pub struct FogState {
/// `LocalPlayer.waterVisionTime` (0..600).
pub water_vision_time: i32,
@@ -72,24 +73,28 @@ pub struct FogState {
pub rain_fog_multiplier: f32,
/// `LightmapRenderStateExtractor.blockLightFlicker`.
pub block_light_flicker: f32,
flicker_seed: u64,
/// `LightmapRenderStateExtractor.random`: `RandomSource.create()`.
flicker_random: LegacyRandom,
}
impl Default for FogState {
fn default() -> Self {
Self {
water_vision_time: 0,
rain_fog_multiplier: 0.0,
block_light_flicker: 0.0,
flicker_random: crate::random_source::create(),
}
}
}
impl FogState {
fn next_float(&mut self) -> f32 {
// splitmix64: the vanilla source is unseeded `RandomSource.create()`.
self.flicker_seed = self.flicker_seed.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.flicker_seed;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
((z ^ (z >> 31)) >> 40) as f32 / (1u32 << 24) as f32
}
/// `LightmapRenderStateExtractor.tick`: the torch light flickers.
pub fn tick_flicker(&mut self) {
let r = &mut self.flicker_random;
let (a, b, c, d) = (
self.next_float(),
self.next_float(),
self.next_float(),
self.next_float(),
r.next_float(),
r.next_float(),
r.next_float(),
r.next_float(),
);
self.block_light_flicker += (a - b) * c * d * 0.1;
self.block_light_flicker *= 0.9;
@@ -750,7 +755,7 @@ mod tests {
rain_fog_multiplier: 0.1,
..FogState::default()
};
let mut settled = start;
let mut settled = start.clone();
settle_rain_fog(&mut settled, 0.5, 15, true);
assert_eq!(settled.rain_fog_multiplier, 0.5);
let mut eased = start;
+135 -32
View File
@@ -126,11 +126,16 @@ struct Cache {
random: horizon_core::random::LegacyRandom,
}
pub struct Font {
/// The atlas rects of the sheets under `textures/font`.
/// The atlas rects of every texture, by resource name: a bitmap provider's
/// `file` may name any texture, not only one under `textures/font`.
sheets: HashMap<String, Rect>,
missing: Glyph,
white: Rect,
root: PathBuf,
/// The game's own resources, below a pack overlay: a font file the overlay
/// lacks (a pack adds providers over vanilla's, or the overlay left a
/// directory out) is read from here, as vanilla's resource stack does.
fallback: Option<PathBuf>,
/// Atlas size and the reserved dynamic region (y, height).
atlas: (u32, u32),
region: (u32, u32),
@@ -199,6 +204,13 @@ fn parse_hex(text: &[u8], out: &mut HashMap<u32, HexGlyph>) {
out.insert(codepoint, HexGlyph { rows, left, right });
}
}
/// A resource file's bytes, from `root` or, when the overlay lacks it, from
/// the game's own resources below (`defaults`).
fn read_resource(root: &Path, defaults: Option<&Path>, path: &str) -> Option<Vec<u8>> {
std::fs::read(root.join(path))
.ok()
.or_else(|| defaults.and_then(|d| std::fs::read(d.join(path)).ok()))
}
fn read_json(root: &Path, path: &str) -> Option<serde_json::Value> {
serde_json::from_slice(&std::fs::read(root.join(path)).ok()?).ok()
}
@@ -281,6 +293,7 @@ fn hex_advance(h: &HexGlyph) -> f32 {
/// pixels, copied into the atlas when first drawn.
fn load_bitmap(
root: &Path,
defaults: Option<&Path>,
p: &serde_json::Value,
sheets: &HashMap<String, Rect>,
pixels: &[u8],
@@ -290,22 +303,36 @@ fn load_bitmap(
let mut out = HashMap::new();
let file = p["file"].as_str().unwrap_or("");
let (ns, name) = file.split_once(':').unwrap_or(("minecraft", file));
let in_atlas = (ns == "minecraft")
.then(|| sheets.get(name.trim_end_matches(".png")).copied())
.flatten();
let name = name.trim_end_matches(".png");
// The atlas names the default namespace's textures by their path and every
// other namespace's as `namespace:path` (HudAtlas::build).
let atlas_name = if ns == "minecraft" {
name.to_owned()
} else {
format!("{ns}:{name}")
};
let in_atlas = sheets.get(&atlas_name).copied();
// The sheet: its size, and its pixels when they are not in the atlas.
let (sw, sh, sheet) = match in_atlas {
Some(rect) => {
let (w, h) = rect.texels(atlas);
(w, h, None)
}
None => match crate::hud::decode(&root.join(resource_path(file, "textures", ""))) {
Ok((w, h, rgba)) => (w, h, Some(Arc::new((w, rgba)))),
Err(_) => {
log::warn!("font sheet {file} could not be read");
return out;
None => {
let path = resource_path(file, "textures", "");
let sheet = crate::hud::decode(&root.join(&path)).or_else(|first| {
defaults
.and_then(|d| crate::hud::decode(&d.join(&path)).ok())
.ok_or(first)
});
match sheet {
Ok((w, h, rgba)) => (w, h, Some(Arc::new((w, rgba)))),
Err(_) => {
log::warn!("font sheet {file} could not be read");
return out;
}
}
},
}
};
let rows: Vec<Vec<char>> = p["chars"]
.as_array()
@@ -382,11 +409,13 @@ fn load_bitmap(
}
/// `TrueTypeGlyphProviderDefinition.load`: a TrueType file (not CFF) under
/// `font/`, its Unicode characters but the `skip` ones.
fn load_true_type(root: &Path, p: &serde_json::Value) -> Option<TrueType> {
fn load_true_type(root: &Path, defaults: Option<&Path>, p: &serde_json::Value) -> Option<TrueType> {
let file = p["file"].as_str()?;
let data = std::fs::read(root.join(resource_path(file, "font", "")))
.map_err(|e| log::warn!("font file {file}: {e}"))
.ok()?;
let path = resource_path(file, "font", "");
let Some(data) = read_resource(root, defaults, &path) else {
log::warn!("font file {file} could not be read");
return None;
};
// FT_Get_Font_Format must say TrueType: CFF outlines ("OTTO") are refused.
if data.starts_with(b"OTTO") {
log::warn!("font file {file} is not in TTF format");
@@ -486,13 +515,16 @@ impl TrueType {
)
}
}
fn load_hex(root: &Path, p: &serde_json::Value) -> HashMap<u32, HexGlyph> {
fn load_hex(root: &Path, defaults: Option<&Path>, p: &serde_json::Value) -> HashMap<u32, HexGlyph> {
let mut glyphs = HashMap::new();
let Some(file) = p["hex_file"].as_str() else {
return glyphs;
};
let path = root.join(resource_path(file, "", "").replace("//", "/"));
let Ok(zip) = Zip::open(&path) else {
let path = resource_path(file, "", "").replace("//", "/");
let Some(zip) = Zip::open(&root.join(&path))
.ok()
.or_else(|| defaults.and_then(|d| Zip::open(&d.join(&path)).ok()))
else {
return glyphs;
};
let names: Vec<String> = zip
@@ -603,7 +635,24 @@ impl Font {
region: (u32, u32),
white: Rect,
) -> Self {
Self::load_with(root, sprites, pixels, atlas, region, white, false)
Self::load_with(root, None, sprites, pixels, atlas, region, white, false)
}
/// [`Font::load`] over a pack overlay: a sheet, TrueType file or Unihex
/// file the overlay lacks is read from `defaults`, the game's own
/// resources, as vanilla's resource stack does. (Definitions stay the
/// overlay's: a pack's filter may hide one from a lower pack.)
#[allow(clippy::too_many_arguments)]
pub fn load_over(
root: &Path,
defaults: &Path,
sprites: &HashMap<String, Rect>,
pixels: &[u8],
atlas: (u32, u32),
region: (u32, u32),
white: Rect,
) -> Self {
let fallback = (root != defaults).then_some(defaults);
Self::load_with(root, fallback, sprites, pixels, atlas, region, white, false)
}
/// A server measures text but never draws it. Decode bitmap sheets only
/// to obtain advances, then release their pixels; never rasterize glyphs.
@@ -616,10 +665,21 @@ impl Font {
w: 1,
h: 1,
};
Self::load_with(root, &HashMap::new(), &[], (1, 1), (0, 0), white, true)
Self::load_with(
root,
None,
&HashMap::new(),
&[],
(1, 1),
(0, 0),
white,
true,
)
}
#[allow(clippy::too_many_arguments)]
fn load_with(
root: &Path,
fallback: Option<&Path>,
sprites: &HashMap<String, Rect>,
pixels: &[u8],
atlas: (u32, u32),
@@ -627,11 +687,7 @@ impl Font {
white: Rect,
metrics_only: bool,
) -> Self {
let sheets = sprites
.iter()
.filter(|(k, _)| k.starts_with("font/"))
.map(|(k, v)| (k.clone(), *v))
.collect();
let sheets = sprites.clone();
let font = Self {
sheets,
missing: Glyph {
@@ -645,6 +701,7 @@ impl Font {
},
white,
root: root.to_owned(),
fallback: fallback.map(|p| p.to_owned()),
atlas,
region,
metrics_only,
@@ -656,11 +713,8 @@ impl Font {
pending: Vec::new(),
full: false,
},
random: horizon_core::random::LegacyRandom::new(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos() as i64),
),
// `Font.random`: `RandomSource.create()`.
random: crate::random_source::create(),
}),
generation: std::cell::Cell::new(next_generation()),
uniform: false,
@@ -698,6 +752,7 @@ impl Font {
Filter::of(p),
load_bitmap(
&self.root,
self.fallback.as_deref(),
p,
&self.sheets,
pixels,
@@ -708,7 +763,7 @@ impl Font {
"unihex" => Some(Provider::Unihex(Filter::of(p), p.clone(), None)),
"ttf" => Some(Provider::TrueType(
Filter::of(p),
Box::new(load_true_type(&self.root, p)?),
Box::new(load_true_type(&self.root, self.fallback.as_deref(), p)?),
)),
_ => None,
})
@@ -868,7 +923,8 @@ impl Font {
}
}
Provider::Unihex(filter, def, glyphs) if filter.passes(uniform, japanese) => {
let glyphs = glyphs.get_or_insert_with(|| load_hex(&self.root, def));
let glyphs = glyphs
.get_or_insert_with(|| load_hex(&self.root, self.fallback.as_deref(), def));
if let Some(h) = glyphs.get(&u32::from(c)).copied()
&& !(non_fishy && fishy_advance(hex_advance(&h)))
{
@@ -1026,7 +1082,8 @@ impl Font {
}
}
Provider::Unihex(filter, def, glyphs) if filter.passes(uniform, japanese) => {
let glyphs = glyphs.get_or_insert_with(|| load_hex(&self.root, def));
let glyphs = glyphs
.get_or_insert_with(|| load_hex(&self.root, self.fallback.as_deref(), def));
if let Some(h) = glyphs.get(&u32::from(c)) {
return Some(hex_advance(h));
}
@@ -1512,6 +1569,52 @@ mod tests {
assert!(texels.iter().any(|t| t[0] == 255));
}
/// A bitmap provider's `file` can name any texture. When the atlas has that
/// texture (HUD packs keep their glyph sheets outside `textures/font`), the
/// glyphs are read from it instead of being copied into the small run-time
/// region one by one; the region then cannot fill up and turn later glyphs
/// into the missing one, whose advance shifts the rest of a HUD line.
#[test]
fn a_bitmap_sheet_outside_font_is_read_from_the_atlas() {
let sheet = png(16, 8, |x, _| x < 2 || (8..14).contains(&x));
let (_, _, pixels) = crate::hud::decode_bytes(&sheet).unwrap();
let rect = Rect {
u0: 0.0,
v0: 0.0,
u1: 1.0,
v1: 1.0,
w: 16,
h: 8,
};
for (ns, file, key) in [
("minecraft", "minecraft:hud/sheet.png", "hud/sheet"),
(
"hudengine",
"hudengine:font/sheet.png",
"hudengine:font/sheet",
),
] {
let def = format!(
r#"{{"providers":[{{"type":"bitmap","file":"{file}","ascent":7,"chars":["ab"]}}]}}"#
);
// The image is only in the atlas: the pack's copy is not on disk.
let root = root(
ns,
&[("assets/minecraft/font/default.json", def.as_bytes())],
);
let sprites = HashMap::from([(key.to_string(), rect)]);
let font = Font::load(&root, &sprites, &pixels, (16, 8), (0, 8), white());
let _ = std::fs::remove_dir_all(&root);
assert_eq!(font.glyph('a').advance, 3.0, "{ns}");
assert_eq!(font.glyph('b').advance, 7.0, "{ns}");
assert!(font.glyph('a').rect.is_some(), "{ns}");
assert!(
font.take_patches().is_empty(),
"{ns}: the atlas sheet is not copied into the run-time region"
);
}
}
#[test]
fn styles_pick_their_font_and_unknown_fonts_draw_missing_glyphs() {
let sheet = png(16, 8, |x, _| x < 2 || (8..14).contains(&x));
+77 -8
View File
@@ -171,6 +171,7 @@ mod gpu_culling;
mod hdr;
mod hdr_icc;
mod hdr_stats;
pub(crate) mod hud_coverage;
pub use hdr::Calibration as HdrCalibration;
pub mod block_light;
mod custom_sky;
@@ -1171,6 +1172,7 @@ pub struct Renderer {
debug_lines_opaque: wgpu::RenderPipeline,
debug_lines: wgpu::RenderPipeline,
debug_lines_on_top: wgpu::RenderPipeline,
debug_crosshair: Option<wgpu::RenderPipeline>,
debug_line_buffer: wgpu::Buffer,
debug_line_capacity: u64,
/// The terrain resources with the debug crosshair's projection
@@ -1424,6 +1426,8 @@ pub struct Renderer {
/// device draws and blends into it, or `LINEAR_FRAME` (`frame_format`). A 16-bit frame
/// with HDR off since reaches the swapchain through `readback`.
frame_format: wgpu::TextureFormat,
/// The no-pack float HDR lane, with independent blending and an R8 coverage target.
hud_coverage: bool,
/// Whether a pack picked or dropped since the renderer was made has been told it runs on
/// the frame that exists (`set_native_shader_pack`).
frame_kept_logged: bool,
@@ -2693,6 +2697,13 @@ impl Renderer {
config.format,
linear,
);
let hud_coverage = hud_coverage::supported(
linear,
hdr_output.is_some(),
device.limits().max_color_attachments,
adapter.get_downlevel_capabilities().flags,
adapter.get_texture_format_features(hud_coverage::FORMAT),
);
if previous.is_none() {
startup_step(format_args!("creating the pipelines"));
}
@@ -2843,8 +2854,9 @@ impl Renderer {
cull: Option<wgpu::Face>,
depth_write: bool,
depth_test: bool,
blend: Option<wgpu::BlendState>| {
kept.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
blend: Option<wgpu::BlendState>,
coverage: bool| {
let desc = wgpu::RenderPipelineDescriptor {
label: Some(label),
layout: Some(layout),
vertex: wgpu::VertexState {
@@ -2885,7 +2897,17 @@ impl Renderer {
}),
multiview_mask: None,
cache: None,
})
};
if coverage {
hud_coverage::pipeline(
&device,
&desc,
wgpu::ShaderSource::Wgsl(include_str!("terrain.wgsl").into()),
)
.expect("the debug crosshair's coverage")
} else {
kept.create_render_pipeline(&desc)
}
};
let pipeline_in = |layout: &wgpu::PipelineLayout,
label: &str,
@@ -2909,6 +2931,7 @@ impl Renderer {
depth_write,
depth_test,
blend,
false,
)
};
// A solid terrain pipeline (back faces culled, depth written, no blend) without HDR
@@ -2931,6 +2954,7 @@ impl Renderer {
true,
true,
None,
false,
)
};
SolidPipeline {
@@ -3175,6 +3199,22 @@ impl Renderer {
false,
Some(alpha_blend),
);
let debug_crosshair = hud_coverage.then(|| {
pipeline_with(
&sprite_constants,
&layout,
"3d crosshair coverage",
"vs_debug_line",
"fs_debug_line",
&debug_line_buffers,
wgpu::PrimitiveTopology::TriangleStrip,
None,
false,
false,
Some(alpha_blend),
true,
)
});
drop(terrain_step);
let globals = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Horizon globals"),
@@ -3528,6 +3568,7 @@ impl Renderer {
&atlas_view,
&glint_view,
&glint_sampler,
hud_coverage,
)?;
hud.atlas_texture = Some(atlas.clone());
let entity_step = crate::spans::startup::step("renderer.entity_pipelines");
@@ -4746,6 +4787,7 @@ impl Renderer {
debug_lines_opaque,
debug_lines,
debug_lines_on_top,
debug_crosshair,
debug_line_buffer,
debug_line_capacity: 4096,
crosshair_group,
@@ -4905,6 +4947,7 @@ impl Renderer {
emission_stats,
hdr_output,
frame_format,
hud_coverage,
frame_kept_logged: false,
hdr_marker: None,
pack_post: None,
@@ -6497,8 +6540,9 @@ impl Renderer {
DisplayWatch::default()
};
let format = self.frame_format;
self.hdr =
output.map(|output| hdr::Hdr::new(&self.device, &self.queue, format, output));
self.hdr = output.map(|output| {
hdr::Hdr::new(&self.device, &self.queue, format, output, self.hud_coverage)
});
if let Some(hdr) = &mut self.hdr {
hdr.look = self.hdr_lighting_setting;
}
@@ -6590,7 +6634,16 @@ impl Renderer {
self.frame_gen.on = on;
}
/// `upscale`: output pixels per rendered pixel, for TAA to rebuild (1: native).
pub fn set_world_post(&mut self, scale: f32, antialias: bool, taa: bool, upscale: f32) {
/// `sharpness`: how much TAA's upscaled picture is sharpened, 0..=1.
pub fn set_world_post(
&mut self,
scale: f32,
antialias: bool,
taa: bool,
upscale: f32,
sharpness: f32,
) {
self.world_post.set_sharpness(&self.device, sharpness);
let taa = taa && self.world_post.supports_taa();
self.world_post.scale = scale.clamp(0.25, 2.0);
self.world_post.antialias = antialias && !taa;
@@ -11538,10 +11591,14 @@ impl Renderer {
}
}
}
let coverage = self.hdr.as_ref().and_then(|h| h.coverage_view());
if let Some(view) = &coverage {
hud_coverage::clear(encoder, view);
}
// GameRenderer.render3dHud: the debug crosshair after the hand and
// the screen effects.
if overlay.crosshair > 0 {
let attachments = [Some(wgpu::RenderPassColorAttachment {
let mut attachments = vec![Some(wgpu::RenderPassColorAttachment {
view: view_target,
resolve_target: None,
depth_slice: None,
@@ -11550,6 +11607,9 @@ impl Renderer {
store: wgpu::StoreOp::Store,
},
})];
if let Some(view) = &coverage {
attachments.push(Some(hud_coverage::attachment(view)));
}
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("3d crosshair"),
timestamp_writes: timing.as_mut().and_then(|t| t.writes("Crosshair")),
@@ -11564,7 +11624,11 @@ impl Renderer {
}),
..Default::default()
});
pass.set_pipeline(&self.debug_lines_on_top);
pass.set_pipeline(if coverage.is_some() {
self.debug_crosshair.as_ref().unwrap()
} else {
&self.debug_lines_on_top
});
pass.set_bind_group(0, &self.crosshair_group, &[]);
pass.set_vertex_buffer(0, self.debug_line_buffer.slice(..));
let first = overlay.first_crosshair;
@@ -11577,6 +11641,7 @@ impl Renderer {
self.hud.draw_range(
encoder,
view_target,
coverage.as_ref(),
&self.depth,
frame.hud,
[self.config.width, self.config.height],
@@ -11599,12 +11664,16 @@ impl Renderer {
radius,
timing.as_mut(),
);
if let Some(view) = &coverage {
hud_coverage::clear(encoder, view);
}
} else if let Some(blur) = &mut self.menu_blur {
blur.release();
}
self.hud.draw_range(
encoder,
view_target,
coverage.as_ref(),
&self.depth,
frame.hud,
[self.config.width, self.config.height],
+215 -65
View File
@@ -73,6 +73,7 @@ struct Targets {
size: [u32; 2],
frame: wgpu::Texture,
world: wgpu::Texture,
coverage: Option<wgpu::Texture>,
group: wgpu::BindGroup,
}
@@ -155,6 +156,8 @@ pub struct Hdr {
targets: Option<Targets>,
/// `HORIZON_HDR_DEBUG=1`: grey what isn't expanded, tint what is magenta.
debug: bool,
coverage: bool,
coverage_debug: bool,
/// Paper white and peak brightness, in nits.
pub paper_white: f32,
pub peak: f32,
@@ -181,10 +184,49 @@ impl Hdr {
queue: &wgpu::Queue,
format: wgpu::TextureFormat,
output: Output,
coverage: bool,
) -> Self {
let debug = std::env::var("HORIZON_HDR_DEBUG").ok();
Self::with_debug(device, queue, format, output, coverage, debug.as_deref())
}
fn with_debug(
device: &wgpu::Device,
queue: &wgpu::Queue,
format: wgpu::TextureFormat,
output: Output,
coverage: bool,
debug: Option<&str>,
) -> Self {
let coverage_debug = coverage && debug == Some("coverage");
let mut source = include_str!("../hdr.wgsl").to_owned();
if coverage_debug {
// The debug view wraps the last pass as it is, so the picture under the tint is
// exactly the one without it.
let entry =
"@fragment fn fs_hdr(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32>";
assert!(
source.contains(entry),
"hdr.wgsl's fs_hdr changed its signature"
);
source = source.replace(entry, "fn picture_hdr(pos: vec4<f32>) -> vec4<f32>");
source.push_str(
r#"
@group(0) @binding(4) var coverage: texture_2d<f32>;
@fragment fn fs_hdr(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
let picture = picture_hdr(pos);
let c = textureLoad(coverage, vec2<i32>(pos.xy), 0).r;
// Magenta at paper white, in the output's own encoding.
let paper = vec3(params.paper, 0.0, params.paper);
let magenta = select(paper, pq(BT709_TO_BT2020 * paper * 80.0), params.pq > 0.5);
return vec4(mix(picture.rgb, magenta, c), picture.a);
}
"#,
);
}
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Horizon HDR"),
source: wgpu::ShaderSource::Wgsl(include_str!("../hdr.wgsl").into()),
source: wgpu::ShaderSource::Wgsl(source.into()),
});
let texture = |binding| wgpu::BindGroupLayoutEntry {
binding,
@@ -196,23 +238,27 @@ impl Hdr {
},
count: None,
};
let mut entries = vec![
texture(0),
texture(1),
texture(3),
wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
];
if coverage_debug {
entries.push(texture(4));
}
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Horizon HDR bindings"),
entries: &[
texture(0),
texture(1),
texture(3),
wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
],
entries: &entries,
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Horizon HDR"),
@@ -308,7 +354,9 @@ impl Hdr {
output,
format,
targets: None,
debug: std::env::var_os("HORIZON_HDR_DEBUG").is_some_and(|v| v == "1"),
debug: debug == Some("1"),
coverage,
coverage_debug,
paper_white: 200.0,
peak: 1000.0,
black: 0.0,
@@ -324,7 +372,7 @@ impl Hdr {
/// The frame every pass draws into this frame, sized like the window.
pub fn frame(&mut self, device: &wgpu::Device, size: [u32; 2]) -> wgpu::Texture {
if self.targets.as_ref().is_none_or(|t| t.size != size) {
let texture = |label, usage| {
let texture = |label, format, usage| {
device.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
size: wgpu::Extent3d {
@@ -335,52 +383,50 @@ impl Hdr {
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: self.format,
format,
usage: usage | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
})
};
let frame = texture(
"Horizon HDR frame",
self.format,
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
);
let world = texture(
"Horizon HDR world",
self.format,
wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::RENDER_ATTACHMENT,
);
let view = |t: &wgpu::Texture| t.create_view(&Default::default());
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Horizon HDR bindings"),
layout: &self.layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&view(&frame)),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(&view(&world)),
},
wgpu::BindGroupEntry {
binding: 2,
resource: self.params.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::TextureView(&self.logo),
},
],
let coverage = self.coverage.then(|| {
texture(
"HUD coverage",
super::hud_coverage::FORMAT,
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
)
});
let group = self.group(device, &frame, &world, coverage.as_ref(), &self.params);
self.targets = Some(Targets {
size,
frame,
world,
coverage,
group,
});
}
self.targets.as_ref().unwrap().frame.clone()
}
pub fn coverage_view(&self) -> Option<wgpu::TextureView> {
Some(
self.targets
.as_ref()?
.coverage
.as_ref()?
.create_view(&Default::default()),
)
}
/// The peak the HDR metadata gives: while calibration's peak step runs, PQ's own
/// 10 000 nits, so a compositor that clips at the declared peak (KWin) lets the test
/// patch through as bright as the display goes and the logo under it shows.
@@ -517,29 +563,13 @@ impl Hdr {
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = |t: &wgpu::Texture| t.create_view(&Default::default());
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Horizon HDR screenshot bindings"),
layout: &self.layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&view(&t.frame)),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(&view(&t.world)),
},
wgpu::BindGroupEntry {
binding: 2,
resource: self.capture_params.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::TextureView(&self.logo),
},
],
});
let group = self.group(
device,
&t.frame,
&t.world,
t.coverage.as_ref(),
&self.capture_params,
);
queue.write_buffer(
&self.capture_params,
0,
@@ -549,6 +579,40 @@ impl Hdr {
Some(texture)
}
fn group(
&self,
device: &wgpu::Device,
frame: &wgpu::Texture,
world: &wgpu::Texture,
coverage: Option<&wgpu::Texture>,
params: &wgpu::Buffer,
) -> wgpu::BindGroup {
let frame = frame.create_view(&Default::default());
let world = world.create_view(&Default::default());
let coverage = coverage.map(|t| t.create_view(&Default::default()));
let texture = |binding, view| wgpu::BindGroupEntry {
binding,
resource: wgpu::BindingResource::TextureView(view),
};
let mut entries = vec![
texture(0, &frame),
texture(1, &world),
wgpu::BindGroupEntry {
binding: 2,
resource: params.as_entire_binding(),
},
texture(3, &self.logo),
];
if self.coverage_debug {
entries.push(texture(4, coverage.as_ref().unwrap()));
}
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Horizon HDR bindings"),
layout: &self.layout,
entries: &entries,
})
}
/// hdr.wgsl's `Params`.
fn params(&self, world: bool, pq: bool) -> [f32; 16] {
let paper = self.paper_white.max(1.0);
@@ -655,9 +719,95 @@ fn scene_gain(peak: f32, paper_white: f32) -> f32 {
#[cfg(test)]
mod tests {
use super::{Output, named, pick};
use super::{Hdr, Output, named, pick};
use wgpu::{SurfaceColorSpaces as S, TextureFormat as F};
#[test]
fn coverage_target_resizes_resets_and_is_sampled_only_by_the_debug_view() {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let Ok((device, queue)) = pollster::block_on(adapter.request_device(&Default::default()))
else {
return;
};
for enabled in [false, true] {
for debug in [None, Some("coverage")] {
let mut hdr =
Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, enabled, debug);
hdr.frame(&device, [2, 1]);
assert_eq!(hdr.coverage_view().is_some(), enabled);
hdr.frame(&device, [4, 2]);
let t = hdr.targets.as_ref().unwrap();
if let Some(coverage) = &t.coverage {
assert_eq!(coverage.size(), t.frame.size());
}
let mut encoder = device.create_command_encoder(&Default::default());
let frame_view = t.frame.create_view(&Default::default());
let mut color = super::super::hud_coverage::attachment(&frame_view);
color.ops.load = wgpu::LoadOp::Clear(wgpu::Color::BLACK);
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
color_attachments: &[Some(color)],
..Default::default()
});
hdr.keep_world(&mut encoder, None);
if let Some(view) = hdr.coverage_view() {
super::super::hud_coverage::clear(&mut encoder, &view);
let mut color = super::super::hud_coverage::attachment(&view);
color.ops.load = wgpu::LoadOp::Clear(wgpu::Color::WHITE);
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
color_attachments: &[Some(color)],
..Default::default()
});
}
let capture = hdr.capture(&device, &queue, &mut encoder, false).unwrap();
let buffer = super::super::copy_frame(&device, &mut encoder, &capture, [4, 2]);
queue.submit([encoder.finish()]);
buffer.map_async(wgpu::MapMode::Read, .., |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let bytes = buffer.get_mapped_range(..).unwrap();
let pixel = u32::from_le_bytes(bytes[..4].try_into().unwrap());
let [r, g, b] = [0, 10, 20].map(|shift| (pixel >> shift) & 0x3ff);
if enabled && debug.is_some() {
// Fully covered: BT.709 magenta at paper white (200 nits here) in BT.2020
// PQ, not PQ's 10 000 nits (1023, 0, 1023).
let near = |got: u32, want: u32| got.abs_diff(want) <= 4;
assert!(near(r, 550) && near(g, 346) && near(b, 583), "{r} {g} {b}");
} else {
assert_eq!(pixel, 0xc0000000);
}
if let Some(view) = hdr.coverage_view() {
let mut encoder = device.create_command_encoder(&Default::default());
super::super::hud_coverage::clear(&mut encoder, &view);
let coverage = hdr.targets.as_ref().unwrap().coverage.as_ref().unwrap();
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 512,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
encoder.copy_texture_to_buffer(
coverage.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &buffer,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(256),
rows_per_image: None,
},
},
coverage.size(),
);
queue.submit([encoder.finish()]);
buffer.map_async(wgpu::MapMode::Read, .., |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
assert_eq!(&buffer.get_mapped_range(..).unwrap()[..4], &[0; 4]);
}
}
}
}
#[test]
fn hdr_shaders_validate() {
let entity = crate::overlay::wgsl_constants() + include_str!("../entity.wgsl");
@@ -0,0 +1,485 @@
//! HUD coverage beside its unchanged colour and alpha, only on the float HDR lane.
pub(crate) const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::R8Unorm;
pub(crate) fn supported(
lane: (bool, bool, bool),
hdr_offered: bool,
attachments: u32,
flags: wgpu::DownlevelFlags,
format: wgpu::TextureFormatFeatures,
) -> bool {
!lane.0
&& lane.1
&& lane.2
&& hdr_offered
&& attachments >= 2
&& flags.contains(wgpu::DownlevelFlags::INDEPENDENT_BLEND)
&& format
.allowed_usages
.contains(wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING)
&& format
.flags
.contains(wgpu::TextureFormatFeatureFlags::BLENDABLE)
}
pub(crate) fn attachment(view: &wgpu::TextureView) -> wgpu::RenderPassColorAttachment<'_> {
wgpu::RenderPassColorAttachment {
view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
}
}
pub(crate) fn clear(encoder: &mut wgpu::CommandEncoder, view: &wgpu::TextureView) {
let mut color = attachment(view);
color.ops.load = wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT);
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("HUD coverage reset"),
color_attachments: &[Some(color)],
..Default::default()
});
}
pub(crate) struct Pipeline {
plain: wgpu::RenderPipeline,
coverage: Option<wgpu::RenderPipeline>,
}
impl Pipeline {
/// `None` when `enabled` and the shader can't be given a coverage output (a resource
/// pack's, which the caller then doesn't use).
pub(crate) fn new(
device: &wgpu::Device,
desc: &wgpu::RenderPipelineDescriptor<'_>,
source: wgpu::ShaderSource<'_>,
enabled: bool,
) -> Option<Self> {
let coverage = if enabled {
Some(pipeline(device, desc, source)?)
} else {
None
};
Some(Self {
plain: device.create_render_pipeline(desc),
coverage,
})
}
pub(crate) fn get(&self, coverage: bool) -> &wgpu::RenderPipeline {
if coverage {
self.coverage.as_ref().expect("HUD coverage pipeline")
} else {
&self.plain
}
}
}
/// Keep the primary output and all its blend factors, including destination alpha. Wrap the
/// final shader result, so pack text's alpha, early returns and discard all apply to coverage.
/// `None` for a fragment shader without a colour output at location 0.
pub(super) fn pipeline(
device: &wgpu::Device,
desc: &wgpu::RenderPipelineDescriptor<'_>,
source: wgpu::ShaderSource<'_>,
) -> Option<wgpu::RenderPipeline> {
let mut desc = desc.clone();
let fragment = desc.fragment.as_mut()?;
let mut module = match source {
wgpu::ShaderSource::Wgsl(s) => naga::front::wgsl::parse_str(&s).ok()?,
wgpu::ShaderSource::Naga(m) => m.into_owned(),
_ => return None,
};
let primary = fragment.targets.first()?.clone()?;
let blend = primary.blend.unwrap_or(wgpu::BlendState::REPLACE).color;
let additive = matches!(
(blend.src_factor, blend.dst_factor),
(
wgpu::BlendFactor::One
| wgpu::BlendFactor::Src
| wgpu::BlendFactor::SrcAlpha
| wgpu::BlendFactor::Zero,
wgpu::BlendFactor::One
)
) && blend.operation == wgpu::BlendOperation::Add;
wrap(&mut module, fragment.entry_point?, blend)?;
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: desc.label,
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
});
let over = wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::One,
dst_factor: wgpu::BlendFactor::OneMinusSrc,
operation: wgpu::BlendOperation::Add,
};
let targets = [
Some(primary),
Some(wgpu::ColorTargetState {
format: FORMAT,
blend: Some(wgpu::BlendState {
color: over,
alpha: over,
}),
write_mask: if additive {
wgpu::ColorWrites::empty()
} else {
wgpu::ColorWrites::RED
},
}),
];
fragment.module = &shader;
fragment.targets = &targets;
Some(device.create_render_pipeline(&desc))
}
fn wrap(module: &mut naga::Module, name: &str, blend: wgpu::BlendComponent) -> Option<()> {
use naga::{Expression as E, Statement as S, TypeInner as T};
use wgpu::BlendFactor as B;
let span = naga::Span::UNDEFINED;
let entry = module.entry_points.iter_mut().find(|e| e.name == name)?;
// Checked before anything is taken apart: a resource pack's shader may have no colour.
let result = entry.function.result.clone()?;
if let T::Struct { members, .. } = &module.types[result.ty].inner {
members
.iter()
.find(|m| matches!(m.binding, Some(naga::Binding::Location { location: 0, .. })))?;
} else if !matches!(
result.binding,
Some(naga::Binding::Location { location: 0, .. })
) {
return None;
}
let mut original = std::mem::take(&mut entry.function);
entry.function.arguments = original.arguments.clone();
for arg in &mut original.arguments {
arg.binding = None;
}
original.result.as_mut().unwrap().binding = None;
original.name = Some("hud_color".into());
let function = module.functions.append(original, span);
let f = &mut entry.function;
let args = (0..f.arguments.len())
.map(|i| f.expressions.append(E::FunctionArgument(i as u32), span))
.collect();
let call = f.expressions.append(E::CallResult(function), span);
f.body.push(
S::Call {
function,
arguments: args,
result: Some(call),
},
span,
);
let one = f
.expressions
.append(E::Literal(naga::Literal::F32(1.0)), span);
let start = f.expressions.len();
let (mut members, mut values, color, offset) = match &module.types[result.ty].inner {
T::Struct {
members,
span: size,
} => {
// HUD pipelines have one target, so a pack's other colour outputs went nowhere;
// left in, one at location 1 would collide with the coverage.
let (members, values): (Vec<_>, Vec<_>) = members
.iter()
.enumerate()
.filter(|(_, m)| {
!matches!(m.binding, Some(naga::Binding::Location { location, .. }) if location != 0)
})
.map(|(i, m)| {
let value = f.expressions.append(
E::AccessIndex {
base: call,
index: i as u32,
},
span,
);
(m.clone(), value)
})
.unzip();
let color = members
.iter()
.position(|m| {
matches!(m.binding, Some(naga::Binding::Location { location: 0, .. }))
})
.unwrap();
let color = values[color];
(members, values, color, *size)
}
_ => (
vec![naga::StructMember {
name: Some("color".into()),
ty: result.ty,
binding: result.binding,
offset: 0,
}],
vec![call],
call,
16,
),
};
let mut component = |index| {
f.expressions
.append(E::AccessIndex { base: color, index }, span)
};
let [r, g, b, a] = [component(0), component(1), component(2), component(3)];
let mut math = |fun, arg, arg1| {
f.expressions.append(
E::Math {
fun,
arg,
arg1: Some(arg1),
arg2: None,
arg3: None,
},
span,
)
};
let low = math(naga::MathFunction::Min, r, g);
let low = math(naga::MathFunction::Min, low, b);
let high = math(naga::MathFunction::Max, r, g);
let high = math(naga::MathFunction::Max, high, b);
// A scalar cannot reproduce RGB-dependent replacement or inversion: surviving pixels
// cover fully, so the later composite cannot leak world light through them.
let c = if blend.operation != wgpu::BlendOperation::Add {
one
} else {
match (blend.src_factor, blend.dst_factor) {
(B::SrcAlpha | B::One, B::OneMinusSrcAlpha) => a,
(B::Zero, B::OneMinusSrc) => high,
// Multiplying retains src.rgb (or 1 - alpha + src.rgb) of the backdrop.
// Use the greatest channel darkening, as for the credits vignette above.
(B::Zero, B::Src)
| (B::Dst, B::Zero)
| (B::Dst, B::OneMinusSrcAlpha)
| (B::Dst, B::Src) => {
let top = if blend.dst_factor == B::OneMinusSrcAlpha {
a
} else {
one
};
let factor = if blend.dst_factor == B::Src && blend.src_factor == B::Dst {
f.expressions.append(
E::Binary {
op: naga::BinaryOperator::Add,
left: low,
right: low,
},
span,
)
} else {
low
};
f.expressions.append(
E::Binary {
op: naga::BinaryOperator::Subtract,
left: top,
right: factor,
},
span,
)
}
_ => one,
}
};
let c = f.expressions.append(
E::Math {
fun: naga::MathFunction::Saturate,
arg: c,
arg1: None,
arg2: None,
arg3: None,
},
span,
);
let scalar = module.types.insert(
naga::Type {
name: None,
inner: T::Scalar(naga::Scalar::F32),
},
span,
);
members.push(naga::StructMember {
name: Some("coverage".into()),
ty: scalar,
binding: Some(naga::Binding::Location {
location: 1,
interpolation: None,
sampling: None,
blend_src: None,
per_primitive: false,
}),
offset,
});
let ty = module.types.insert(
naga::Type {
name: Some("HudCoverage".into()),
inner: T::Struct {
members,
span: (offset + 4).next_multiple_of(16),
},
},
span,
);
values.push(c);
let value = f.expressions.append(
E::Compose {
ty,
components: values,
},
span,
);
f.result = Some(naga::FunctionResult { ty, binding: None });
f.body.push(S::Emit(f.expressions.range_from(start)), span);
f.body.push(S::Return { value: Some(value) }, span);
Some(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn coverage_requires_the_no_pack_float_hdr_lane_and_independent_r8_blending() {
let flags = wgpu::DownlevelFlags::INDEPENDENT_BLEND;
let format = wgpu::TextureFormatFeatures {
allowed_usages: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING,
flags: wgpu::TextureFormatFeatureFlags::BLENDABLE,
};
let lane = (false, true, true);
assert!(supported(lane, true, 2, flags, format));
// Switch unset (including legacy 16-bit HDR and 0.1 packs), HDR off, a 0.2
// pack's float frame, and unsupported devices never allocate coverage.
for other in [
(false, false, true),
(true, false, true),
(true, true, true),
(false, true, false),
] {
assert!(!supported(other, true, 2, flags, format));
}
assert!(!supported(lane, false, 2, flags, format));
assert!(!supported(lane, true, 1, flags, format));
assert!(!supported(
lane,
true,
2,
wgpu::DownlevelFlags::empty(),
format
));
assert!(!supported(
lane,
true,
2,
flags,
wgpu::TextureFormatFeatures {
flags: wgpu::TextureFormatFeatureFlags::empty(),
..format
}
));
assert!(!supported(
lane,
true,
2,
flags,
wgpu::TextureFormatFeatures {
allowed_usages: wgpu::TextureUsages::TEXTURE_BINDING,
..format
}
));
}
#[test]
fn disabled_coverage_builds_only_the_original_pipeline() {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let Ok((device, _)) = pollster::block_on(adapter.request_device(&Default::default()))
else {
return;
};
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Wgsl(
"@vertex fn vs() -> @builtin(position) vec4<f32> { return vec4(0.0); }".into(),
),
});
let desc = 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: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(true),
depth_compare: Some(wgpu::CompareFunction::Always),
stencil: Default::default(),
bias: Default::default(),
}),
multisample: Default::default(),
fragment: None,
multiview_mask: None,
cache: None,
};
// The coverage source must not even be parsed outside the lane.
let pipeline = Pipeline::new(
&device,
&desc,
wgpu::ShaderSource::Wgsl("invalid".into()),
false,
)
.unwrap();
assert!(pipeline.coverage.is_none());
}
#[test]
fn coverage_wraps_the_debug_crosshair_without_changing_world_entry_points() {
let mut module = naga::front::wgsl::parse_str(include_str!("../terrain.wgsl")).unwrap();
wrap(
&mut module,
"fs_debug_line",
wgpu::BlendState::ALPHA_BLENDING.color,
)
.unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap();
}
#[test]
fn coverage_replaces_a_colour_output_the_hud_has_no_target_for() {
let mut module = naga::front::wgsl::parse_str(
"struct Out { @location(0) color: vec4<f32>, @location(1) unused: vec4<f32> }
@fragment fn fs() -> Out { return Out(vec4(0.5), vec4(1.0)); }",
)
.unwrap();
wrap(&mut module, "fs", wgpu::BlendState::ALPHA_BLENDING.color).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap();
let result = module.entry_points[0].function.result.as_ref().unwrap();
let naga::TypeInner::Struct { members, .. } = &module.types[result.ty].inner else {
panic!("the wrapped entry returns a struct");
};
let names: Vec<_> = members.iter().map(|m| m.name.as_deref().unwrap()).collect();
assert_eq!(names, ["color", "coverage"]);
}
}
@@ -90,6 +90,27 @@ impl NativePack {
report: Report,
constants: &[(String, f64)],
lights: super::block_light::LightTable,
) -> Result<(Self, Vec<String>), String> {
// A backend may refuse a layout or pipeline the pack asks for (a stage's bindings
// past its sampled-texture limit, say, as OpenGL's 16 was before #10095): the pack
// is refused with that error instead of a validation panic in the renderer.
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
let built = Self::build(device, queue, terrain, report, constants, lights);
let refused = pollster::block_on(scope.pop());
match (built, refused) {
(Ok(built), None) => Ok(built),
(Ok(_), Some(e)) => Err(e.to_string()),
(Err(e), _) => Err(e),
}
}
fn build(
device: &wgpu::Device,
queue: &wgpu::Queue,
terrain: &TerrainShape<'_>,
report: Report,
constants: &[(String, f64)],
lights: super::block_light::LightTable,
) -> Result<(Self, Vec<String>), String> {
let (globals, format) = (terrain.globals, terrain.format);
let textures = super::native_post::pack_textures(device, queue, &report);
@@ -278,9 +299,90 @@ impl NativePack {
#[cfg(test)]
mod tests {
use super::chosen_shadow;
use super::{NativePack, TerrainShape, chosen_shadow};
use horizon_shader_api::manifest::{OptionKind, PackOption, Shadow};
/// A device with OpenGL's minimum 16 sampled textures per stage refuses a pack whose
/// sky adds the terrain's 10 to the frame's 9: that is the pack's error to report, not
/// a validation panic (#10095).
#[test]
fn a_pack_the_backend_refuses_is_an_error_not_a_panic() {
use horizon_shader_api::{MANIFEST, Pack, check};
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let mut limits = adapter.limits();
limits.max_sampled_textures_per_shader_stage = 16;
let Ok((device, queue)) =
pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
required_limits: limits,
..Default::default()
}))
else {
return;
};
// The terrain's group 0: its uniform and the 10 sampled textures (_terrain.wgsl_'s
// 8 sprite arrays, the lightmap and the emission thresholds).
let entry = |binding, ty| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty,
count: None,
};
let mut entries = vec![entry(
0,
wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
)];
entries.extend((1..=10).map(|binding| {
entry(
binding,
wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
)
}));
let globals = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: None,
entries: &entries,
});
let empty = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: None,
entries: &[],
});
let terrain = TerrainShape {
globals: &globals,
flat: None,
sprites: [1.0, 2048.0],
format: wgpu::TextureFormat::Rgba8Unorm,
sprite_tags: &[],
sprite_materials: &[],
entities: &empty,
weather_groups: [&empty, &empty],
pbr: None,
};
let manifest = "schema = 1\nid = \"t\"\nname = \"T\"\nversion = \"1\"\napi = \"0.1\"\n\
[sky]\nfile = \"sky.wgsl\"\n";
let files = [
(MANIFEST, manifest),
(
"sky.wgsl",
"fn sky(input: SkyInput) -> vec4<f32> { return input.color; }\n",
),
]
.map(|(n, t)| (n.to_owned(), t.as_bytes().to_vec()))
.into();
let report = check(&Pack::from_files(files).unwrap()).unwrap();
let built = NativePack::new(&device, &queue, &terrain, report, &[], Default::default());
assert!(built.is_err(), "the pack should be refused");
}
fn option(id: &str, kind: OptionKind) -> PackOption {
PackOption {
id: id.into(),
@@ -30,7 +30,7 @@ pub fn buffers() -> [Option<wgpu::VertexBufferLayout<'static>>; 1] {
/// What `gbuffers_entities` and `gbuffers_block` read from an entity vertex: camera-relative
/// position, the shared atlas's UV, colour, light (block, sky in 16ths) and the world-oriented
/// normal. The quad's corners, read from the same vertices at set 1, give the face normal where
/// the vertex has none (block entities) and `at_tangent` along the texture's u, as for terrain.
/// the vertex has none (custom geometry) and `at_tangent` along the texture's u, as for terrain.
pub fn inputs() -> horizon_shaders::GbuffersInputs {
let words = std::mem::size_of::<Vertex>() / 4;
let mut declarations = "layout(location = 0) in vec3 hz_v_pos;\nlayout(location = 1) in vec2 hz_v_uv;\n\
@@ -55,6 +55,13 @@ pub struct Post {
sampler: wgpu::Sampler,
copy: wgpu::RenderPipeline,
fxaa: wgpu::RenderPipeline,
/// Contrast-adaptive sharpening of TAA's upscaled picture: the jittered frames are
/// spread over more output pixels, and the blend softens edges.
sharpen: wgpu::RenderPipeline,
/// The strength `sharpen` was built with (`set_sharpness`), and what rebuilding it takes.
sharpness: f32,
shader: wgpu::ShaderModule,
pipeline_layout: wgpu::PipelineLayout,
format: wgpu::TextureFormat,
/// World resolution relative to the window (0.5..=2.0).
pub scale: f32,
@@ -232,8 +239,12 @@ impl Post {
..Default::default()
});
Self {
shader: shader.clone(),
pipeline_layout: pipeline_layout.clone(),
copy: pipeline("fs_copy", &pipeline_layout, format),
fxaa: pipeline("fs_fxaa", &pipeline_layout, format),
sharpen: sharpen_pipeline(device, &shader, &pipeline_layout, format, 0.85),
sharpness: 0.85,
layout,
sampler,
format,
@@ -474,6 +485,21 @@ impl Post {
}
}
/// The Upscaling Sharpness setting, 0..=1: rebuilds the sharpen pipeline when it changed.
pub fn set_sharpness(&mut self, device: &wgpu::Device, sharpness: f32) {
let sharpness = sharpness.clamp(0.0, 1.0);
if sharpness != self.sharpness {
self.sharpen = sharpen_pipeline(
device,
&self.shader,
&self.pipeline_layout,
self.format,
sharpness,
);
self.sharpness = sharpness;
}
}
/// The size of the world's own target, when it has one.
pub fn size(&self) -> Option<[u32; 2]> {
self.target.as_ref().map(|t| t.size)
@@ -535,7 +561,12 @@ impl Post {
pass.set_bind_group(0, &taa.resolve[i], &[]);
pass.draw(0..3, 0..1);
drop(pass);
self.finish_with(encoder, screen, &taa.show[i], timing);
let pipeline = if self.upscale > 1.0 {
&self.sharpen
} else {
&self.copy
};
self.draw_onto(encoder, screen, &taa.show[i], pipeline, timing);
}
/// Draw what `group` binds (`bind`) onto `screen`.
@@ -545,6 +576,23 @@ impl Post {
screen: &wgpu::TextureView,
group: &wgpu::BindGroup,
timing: Option<&mut super::pack_timing::PassTiming>,
) {
let pipeline = if self.antialias {
&self.fxaa
} else {
&self.copy
};
self.draw_onto(encoder, screen, group, pipeline, timing);
}
/// Draw what `group` binds onto `screen` through `pipeline`.
fn draw_onto(
&self,
encoder: &mut wgpu::CommandEncoder,
screen: &wgpu::TextureView,
group: &wgpu::BindGroup,
pipeline: &wgpu::RenderPipeline,
timing: Option<&mut super::pack_timing::PassTiming>,
) {
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: screen,
@@ -561,16 +609,51 @@ impl Post {
color_attachments: &attachments,
..Default::default()
});
pass.set_pipeline(if self.antialias {
&self.fxaa
} else {
&self.copy
});
pass.set_pipeline(pipeline);
pass.set_bind_group(0, group, &[]);
pass.draw(0..3, 0..1);
}
}
/// `fs_sharpen` at `sharpness` (its override), drawing onto `format`.
fn sharpen_pipeline(
device: &wgpu::Device,
shader: &wgpu::ShaderModule,
layout: &wgpu::PipelineLayout,
format: wgpu::TextureFormat,
sharpness: f32,
) -> wgpu::RenderPipeline {
let constants = [("sharpness", f64::from(sharpness))];
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Horizon sharpen"),
layout: Some(layout),
vertex: wgpu::VertexState {
module: shader,
entry_point: Some("vs_post"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: shader,
entry_point: Some("fs_sharpen"),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &constants,
..Default::default()
},
targets: &[Some(wgpu::ColorTargetState {
format,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
})
}
/// This frame's clip space to the previous frame's, for camera-relative projections:
/// a point `camera - previous_camera` further from the previous camera than from this one.
pub(super) fn reprojection(
+9 -1
View File
@@ -725,6 +725,8 @@ pub struct Graphics {
pub taa: bool,
/// TAA upscaling; anything but `Native` comes with `taa` on.
pub upscaling: Upscaling,
/// How much the upscaled picture is sharpened, 0..=100 (percent of the most).
pub upscaling_sharpness: u32,
/// F2 screenshots at this many times the game's resolution: 1, 2, 4 or 8.
pub screenshot_quality: u32,
/// Frame generation (Horizon setting), off by default.
@@ -813,6 +815,7 @@ impl Default for Graphics {
fxaa: false,
taa: false,
upscaling: Upscaling::Native,
upscaling_sharpness: 85,
screenshot_quality: 1,
frame_generation: FrameGeneration::Off,
hdr: false,
@@ -935,6 +938,9 @@ impl Graphics {
"fxaa" => g.fxaa = flag(g.fxaa),
"taa" => g.taa = flag(g.taa),
"upscaling" => g.upscaling = Upscaling::parse(value),
"upscalingSharpness" => {
g.upscaling_sharpness = number(g.upscaling_sharpness).min(100)
}
"screenshotQuality" => {
g.screenshot_quality = Some(number(0))
.filter(|q| SCREENSHOT_QUALITIES.contains(q))
@@ -980,7 +986,7 @@ impl Graphics {
}
pub fn serialize(&self) -> String {
let mut text = format!(
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nentityCullingPrecheck:{}\nparticleCulling:{}\nleafCulling:{}\nblockEntityCacheShulkerBoxes:{}\nblockEntityCacheSigns:{}\nblockEntityCacheDecoratedPots:{}\nblockEntityCacheBells:{}\nblockEntityCacheChests:{}\nblockEntityCacheBanners:{}\nblockEntityCacheCopperGolemStatues:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nframeGeneration:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrLighting:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\nbatchTranslucent:{}\nhdrSmoothGradients:{}\ngpuCulling:{}\n",
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nentityCullingPrecheck:{}\nparticleCulling:{}\nleafCulling:{}\nblockEntityCacheShulkerBoxes:{}\nblockEntityCacheSigns:{}\nblockEntityCacheDecoratedPots:{}\nblockEntityCacheBells:{}\nblockEntityCacheChests:{}\nblockEntityCacheBanners:{}\nblockEntityCacheCopperGolemStatues:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nupscalingSharpness:{}\nscreenshotQuality:{}\nframeGeneration:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrLighting:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\nbatchTranslucent:{}\nhdrSmoothGradients:{}\ngpuCulling:{}\n",
self.preset.name(),
self.lod_distance,
self.far_detail.name(),
@@ -1019,6 +1025,7 @@ impl Graphics {
bool_text(self.fxaa),
bool_text(self.taa),
self.upscaling.name(),
self.upscaling_sharpness,
self.screenshot_quality,
self.frame_generation.name(),
bool_text(self.hdr),
@@ -1202,6 +1209,7 @@ mod tests {
fxaa: true,
taa: true,
upscaling: Upscaling::Balanced,
upscaling_sharpness: 40,
screenshot_quality: 4,
frame_generation: FrameGeneration::Fsr31,
hdr: true,
@@ -97,6 +97,7 @@ pub enum H {
ResolutionScale,
Antialiasing,
Upscaling,
UpscalingSharpness,
ScreenshotQuality,
FrameGeneration,
Hdr,
@@ -372,6 +373,11 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
High,
"Renders the world smaller and rebuilds it with TAA. Faster, softer.",
),
h(
H::UpscalingSharpness,
Low,
"Sharpens the upscaled picture. Higher: crisper edges, more shimmer.",
),
h(
H::ScreenshotQuality,
None,
@@ -785,6 +791,7 @@ impl H {
}
H::Workers => Some(g.workers as f32 / max_workers() as f32),
H::ResolutionScale => Some((g.resolution_scale as f32 - 50.0) / 150.0),
H::UpscalingSharpness => Some(g.upscaling_sharpness as f32 / 100.0),
// Auto, then 10..480 by 10.
H::HdrPaperWhite => Some(g.hdr_paper_white as f32 / 480.0),
// Auto, then 200..2000 by 50.
@@ -804,6 +811,7 @@ impl H {
H::UploadBudget => Some((UPLOAD_STEPS.len() - 1) as u32),
H::Workers => Some(max_workers()),
H::ResolutionScale => Some(30),
H::UpscalingSharpness => Some(20),
H::HdrPaperWhite => Some(41),
H::HdrPeak => Some(37),
H::HdrBlackLevel => Some(100),
@@ -893,6 +901,9 @@ impl H {
},
),
H::Upscaling => labelled("Upscaling", g.upscaling.label()),
H::UpscalingSharpness => {
labelled("Upscaling Sharpness", format!("{}%", g.upscaling_sharpness))
}
H::ScreenshotQuality => {
labelled("Screenshot Quality", format!("{}x", g.screenshot_quality))
}
@@ -965,6 +976,7 @@ impl H {
}
H::Workers => g.workers = (v * max_workers() as f32).round() as u32,
H::ResolutionScale => g.resolution_scale = (50.0 + (v * 30.0).round() * 5.0) as u32,
H::UpscalingSharpness => g.upscaling_sharpness = (v * 20.0).round() as u32 * 5,
H::HdrPaperWhite => g.hdr_paper_white = (v * 48.0).round() as u32 * 10,
H::HdrPeak => {
g.hdr_peak = match (v * 37.0).round() as u32 {
@@ -3222,6 +3234,7 @@ mod tests {
H::UploadBudget,
H::Workers,
H::ResolutionScale,
H::UpscalingSharpness,
H::HdrPaperWhite,
H::HdrPeak,
H::HdrBlackLevel,
+157 -14
View File
@@ -531,6 +531,49 @@ fn cit_sprite(root: &Path, id: &str) -> Option<(PackedImage, Option<TextureFlags
let flags = mcmeta.as_deref().and_then(TextureFlags::from_mcmeta);
Some(((name.to_owned(), w, h, pixels), flags, animation.is_some()))
}
/// The texture of every `bitmap` font provider. A provider's `file` may name
/// any texture, so a pack's glyph sheet need not live under `textures/font`.
fn bitmap_font_textures(root: &Path) -> Vec<PathBuf> {
fn jsons(dir: &Path, out: &mut Vec<PathBuf>) {
for entry in fs::read_dir(dir).into_iter().flatten().flatten() {
let path = entry.path();
if path.is_dir() {
jsons(&path, out);
} else if path.extension().is_some_and(|e| e == "json") {
out.push(path);
}
}
}
let assets = root.join("assets");
let mut defs = Vec::new();
for ns in fs::read_dir(&assets).into_iter().flatten().flatten() {
jsons(&ns.path().join("font"), &mut defs);
}
let mut out = Vec::new();
let mut seen = HashSet::new();
for def in defs {
let Ok(bytes) = fs::read(&def) else { continue };
let bytes = bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(&bytes);
let Ok(json) = serde_json::from_slice::<serde_json::Value>(bytes) else {
continue;
};
for p in json["providers"].as_array().into_iter().flatten() {
if p["type"] != "bitmap" {
continue;
}
let Some(file) = p["file"].as_str() else {
continue;
};
let (ns, path) = file.split_once(':').unwrap_or(("minecraft", file));
let path = assets.join(ns).join("textures").join(path);
if path.is_file() && seen.insert(path.clone()) {
out.push(path);
}
}
}
out
}
/// Folders of `assets/minecraft/textures/` that [`HudAtlas::pack`] walks for
/// the full atlas. A pack may add others through `atlases/blocks.json`.
const WALKED_TEXTURE_FOLDERS: &[&str] = &[
@@ -1102,6 +1145,18 @@ impl HudAtlas {
for folder in folders {
walk(&textures.join(folder), &mut files);
}
// A bitmap provider's `file` may be outside the walked folders (HUD
// packs keep their glyph sheets under their own namespace or path).
// Pack those sheets too, so Font reads them from the atlas instead of
// copying every glyph into the small run-time region.
if folders.contains(&"font") {
let mut seen: HashSet<PathBuf> = files.iter().cloned().collect();
files.extend(
bitmap_font_textures(root)
.into_iter()
.filter(|p| seen.insert(p.clone())),
);
}
// Content mods' entity textures: their equipment assets name them as
// `<ns>:<path>`, and native mods draw their block entities with them
// (`native_draw_quad`: `<ns>:entity/...`); and their mob effects'
@@ -1465,8 +1520,9 @@ impl HudAtlas {
let white = sprites["horizon/white"];
let glint = Self::glint_texture(root, defaults, "enchanted_glint_item");
let glint_armor = Self::glint_texture(root, defaults, "enchanted_glint_armor");
let font = crate::font::Font::load(
let font = crate::font::Font::load_over(
root,
defaults,
&sprites,
&pixels,
(width, height),
@@ -2379,17 +2435,19 @@ impl HudUniform {
}
}
}
use crate::gfx::hud_coverage::{self, Pipeline};
pub struct HudRenderer {
pub sprite_animations: Vec<(Rect, crate::sprites::SpriteAnimation, (usize, usize, u32))>,
/// The shared atlas texture, for run-time glyph uploads.
pub atlas_texture: Option<wgpu::Texture>,
pipeline: wgpu::RenderPipeline,
pipeline: Pipeline,
/// `RenderPipelines.CROSSHAIR`.
invert_pipeline: wgpu::RenderPipeline,
additive_pipeline: wgpu::RenderPipeline,
depth_pipeline: wgpu::RenderPipeline,
vignette_pipeline: wgpu::RenderPipeline,
end_portal_pipeline: wgpu::RenderPipeline,
invert_pipeline: Pipeline,
additive_pipeline: Pipeline,
depth_pipeline: Pipeline,
vignette_pipeline: Pipeline,
end_portal_pipeline: Pipeline,
/// The layout of `group`, which the CIT glint pipelines share.
layout: wgpu::BindGroupLayout,
group: wgpu::BindGroup,
@@ -2399,6 +2457,7 @@ pub struct HudRenderer {
buffer: wgpu::Buffer,
capacity: u64,
format: wgpu::TextureFormat,
coverage: bool,
atlas_view: wgpu::TextureView,
/// The resource pack's text shader, when it has one that works.
pack_text: Option<pack_text::PackText>,
@@ -2413,6 +2472,7 @@ impl HudRenderer {
view: &wgpu::TextureView,
glint_view: &wgpu::TextureView,
glint_sampler: &wgpu::Sampler,
coverage: bool,
) -> Result<Self> {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Horizon HUD"),
@@ -2525,7 +2585,7 @@ impl HudRenderer {
let attributes =
wgpu::vertex_attr_array![0=>Float32x2,1=>Float32x2,2=>Unorm8x4,3=>Float32,4=>Float32x2];
let create = |label, blend, fragment_entry, depth: bool| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
let desc = wgpu::RenderPipelineDescriptor {
label: Some(label),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
@@ -2559,7 +2619,9 @@ impl HudRenderer {
}),
multiview_mask: None,
cache: None,
})
};
let source = wgpu::ShaderSource::Wgsl(include_str!("hud.wgsl").into());
Pipeline::new(device, &desc, source, coverage).expect("the HUD's coverage")
};
let pipeline = create("HUD", wgpu::BlendState::ALPHA_BLENDING, "fs_main", false);
let depth_pipeline = create(
@@ -2650,6 +2712,7 @@ impl HudRenderer {
buffer,
capacity,
format,
coverage,
atlas_view: view.clone(),
pack_text: None,
zoom: 1.0,
@@ -2664,7 +2727,7 @@ impl HudRenderer {
textures: Option<&cit_glint::GlintTextures>,
) {
self.cit_glint = textures.and_then(|t| {
cit_glint::CitGlintPass::new(device, queue, self.format, &self.layout, t)
cit_glint::CitGlintPass::new(device, queue, self.format, &self.layout, t, self.coverage)
});
}
/// Draw the GUI's text with `shader` (`HudAtlas::text_shader`), or natively.
@@ -2674,7 +2737,7 @@ impl HudRenderer {
shader: Option<&horizon_shaders::CoreProgram>,
) {
self.pack_text = shader.and_then(|shader| {
pack_text::PackText::new(device, self.format, &self.atlas_view, shader)
pack_text::PackText::new(device, self.format, &self.atlas_view, shader, self.coverage)
});
}
pub fn animate_sprites(
@@ -2823,6 +2886,7 @@ impl HudRenderer {
self.draw_range(
encoder,
target,
None,
depth,
batch,
size,
@@ -2836,6 +2900,7 @@ impl HudRenderer {
&self,
encoder: &mut wgpu::CommandEncoder,
target: &wgpu::TextureView,
coverage: Option<&wgpu::TextureView>,
depth: &wgpu::TextureView,
batch: &HudBatch,
size: [u32; 2],
@@ -2845,7 +2910,7 @@ impl HudRenderer {
if vertices.is_empty() || size.contains(&0) {
return;
}
let attachments = [Some(wgpu::RenderPassColorAttachment {
let mut attachments = vec![Some(wgpu::RenderPassColorAttachment {
view: target,
resolve_target: None,
depth_slice: None,
@@ -2854,6 +2919,9 @@ impl HudRenderer {
store: wgpu::StoreOp::Store,
},
})];
if let Some(view) = coverage {
attachments.push(Some(hud_coverage::attachment(view)));
}
let runs = batch.runs_in(vertices);
let mut at = 0;
while at < runs.len() {
@@ -2905,16 +2973,17 @@ impl HudRenderer {
Blend::EndPortal => &self.end_portal_pipeline,
Blend::CitGlint(blend) => {
if let Some(cit) = &self.cit_glint {
cit.draw(&mut pass, *blend, range.clone());
cit.draw(&mut pass, *blend, range.clone(), coverage.is_some());
}
continue;
}
};
let pipeline = pipeline.get(coverage.is_some());
match &self.pack_text {
Some(pack_text) if *blend == Blend::Alpha => {
for (part, text) in pack_text::text_runs(&batch.vertices, range.clone()) {
if text {
pack_text.draw(&mut pass, part);
pack_text.draw(&mut pass, part, coverage.is_some());
pass.set_bind_group(0, &self.group, &[]);
} else {
pass.set_pipeline(pipeline);
@@ -2933,6 +3002,10 @@ impl HudRenderer {
}
}
#[cfg(test)]
#[path = "hud/coverage_tests.rs"]
mod coverage_tests;
#[cfg(test)]
mod tests {
use super::*;
@@ -3891,6 +3964,30 @@ texture=wide",
assert_eq!(((r.w, r.h), r.texels(size)), (gui, texels), "{name}");
}
}
/// A bitmap font's `file` may be any texture, not only one under
/// `textures/font`; the atlas packs it so the font can read its glyphs
/// from there instead of copying them into the run-time region.
#[test]
fn a_bitmap_font_sheet_outside_font_is_packed() {
let root = std::env::temp_dir().join(format!("horizon-font-pack-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
write_png(&root, "hud/sheet", 16, 8);
let def = root.join("assets/minecraft/font/default.json");
fs::create_dir_all(def.parent().unwrap()).unwrap();
fs::write(
&def,
r#"{"providers":[{"type":"bitmap","file":"minecraft:hud/sheet.png","ascent":7,"chars":["ab"]}]}"#,
)
.unwrap();
let atlas = HudAtlas::build(&root).unwrap();
let _ = fs::remove_dir_all(&root);
let rect = atlas
.sprites
.get("hud/sheet")
.expect("the font sheet is packed");
assert_eq!(rect.texels((atlas.width, atlas.height)), (16, 8));
}
/// A content mod's GUI textures join the atlas under its namespace;
/// its other textures and vanilla's names stay as they were.
#[test]
@@ -4010,6 +4107,52 @@ texture=wide",
.unwrap();
root
}
/// A pack layer that adds a font provider over the game's own resources:
/// its `font/default.json` names `minecraft:font/test.png`, which only the
/// game's root holds (`HudAtlas::build_over`'s `defaults`).
fn pack_font_over_defaults(name: &str) -> (PathBuf, PathBuf) {
let base = std::env::temp_dir().join(format!(
"horizon-font-fallback-{name}-{}",
std::process::id()
));
let pack = base.join("pack");
let vanilla = base.join("vanilla");
fs::create_dir_all(pack.join("assets/minecraft/font")).unwrap();
fs::create_dir_all(vanilla.join("assets/minecraft/textures/font")).unwrap();
fs::write(
pack.join("assets/minecraft/font/default.json"),
r#"{"providers":[{"type":"bitmap","file":"minecraft:font/test.png","ascent":7,"chars":["ab"]}]}"#,
)
.unwrap();
let mut pixels = vec![0u8; 16 * 8 * 4];
for y in 0..8 {
for x in (0..3).chain(8..13) {
pixels[(y * 16 + x) * 4 + 3] = 255;
}
}
let mut encoder = png::Encoder::new(
fs::File::create(vanilla.join("assets/minecraft/textures/font/test.png")).unwrap(),
16,
8,
);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
encoder
.write_header()
.unwrap()
.write_image_data(&pixels)
.unwrap();
(pack, vanilla)
}
#[test]
fn a_pack_overlay_reads_font_sheets_from_the_game_below_it() {
let (pack, vanilla) = pack_font_over_defaults("sheets");
let atlas = HudAtlas::build_over(&pack, &vanilla).unwrap();
let glyph = atlas.font.glyph('a');
let _ = fs::remove_dir_all(pack.parent().unwrap());
assert!(glyph.rect.is_some(), "text draws, not the missing glyph");
assert!(glyph.advance > 0.0);
}
#[test]
fn the_fonts_only_atlas_measures_text_like_the_full_one() {
let root = two_glyph_font("atlas");
@@ -5,6 +5,7 @@
//! vanilla's glint's is. Without enchantment rules nothing here is made.
use super::HudVertex;
use crate::gfx::hud_coverage::Pipeline;
use horizon_optifine::cit::{Blend, Factor, Kind, Rule};
use std::path::Path;
@@ -230,7 +231,7 @@ pub fn blend_state(blend: Option<Blend>) -> wgpu::BlendState {
/// pipeline per blend.
pub(super) struct CitGlintPass {
group: wgpu::BindGroup,
pipelines: Vec<wgpu::RenderPipeline>,
pipelines: Vec<Pipeline>,
}
impl CitGlintPass {
@@ -241,6 +242,7 @@ impl CitGlintPass {
format: wgpu::TextureFormat,
hud: &wgpu::BindGroupLayout,
textures: &GlintTextures,
coverage: bool,
) -> Option<Self> {
use wgpu::util::DeviceExt;
if textures.is_empty() {
@@ -381,7 +383,7 @@ impl CitGlintPass {
.blends
.iter()
.map(|&blend| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
let desc = wgpu::RenderPipelineDescriptor {
label: Some("CIT glint"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
@@ -409,7 +411,9 @@ impl CitGlintPass {
}),
multiview_mask: None,
cache: None,
})
};
let source = wgpu::ShaderSource::Wgsl(include_str!("../hud.wgsl").into());
Pipeline::new(device, &desc, source, coverage).expect("the CIT glint's coverage")
})
.collect();
Some(Self { group, pipelines })
@@ -421,9 +425,10 @@ impl CitGlintPass {
pass: &mut wgpu::RenderPass<'_>,
blend: u8,
vertices: std::ops::Range<u32>,
coverage: bool,
) {
if let Some(pipeline) = self.pipelines.get(usize::from(blend)) {
pass.set_pipeline(pipeline);
pass.set_pipeline(pipeline.get(coverage));
pass.set_bind_group(1, &self.group, &[]);
pass.draw(vertices, 0..1);
}
@@ -0,0 +1,311 @@
use super::*;
use crate::gfx::hud_coverage;
struct Gpu {
device: wgpu::Device,
queue: wgpu::Queue,
hud: HudRenderer,
}
impl Gpu {
fn new() -> Option<Self> {
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
if !hud_coverage::supported(
(false, true, true),
true,
adapter.limits().max_color_attachments,
adapter.get_downlevel_capabilities().flags,
adapter.get_texture_format_features(hud_coverage::FORMAT),
) {
eprintln!("skipping HUD coverage: adapter lacks independent blending or R8 rendering");
return None;
}
let (device, queue) =
pollster::block_on(adapter.request_device(&Default::default())).ok()?;
let atlas = device.create_texture_with_data(
&queue,
&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 2,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
},
wgpu::util::TextureDataOrder::LayerMajor,
&[255, 255, 255, 255, 255, 255, 255, 0],
);
let view = atlas.create_view(&Default::default());
let hud = HudRenderer::new(
&device,
wgpu::TextureFormat::Rgba16Float,
&view,
&view,
&device.create_sampler(&Default::default()),
true,
)
.unwrap();
Some(Self { device, queue, hud })
}
fn texture(&self, format: wgpu::TextureFormat) -> wgpu::Texture {
self.device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 4,
height: 2,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
})
}
fn draw(&self, batch: &HudBatch, initial: f64, coverage: bool) -> (Vec<u8>, Vec<u8>) {
let frame = self.texture(wgpu::TextureFormat::Rgba16Float);
let mask = self.texture(hud_coverage::FORMAT);
let depth = self.texture(wgpu::TextureFormat::Depth32Float);
let [frame_view, mask_view, depth_view] =
[&frame, &mask, &depth].map(|t| t.create_view(&Default::default()));
self.queue
.write_buffer(&self.hud.buffer, 0, bytemuck::cast_slice(&batch.vertices));
self.queue.write_buffer(
&self.hud.uniform,
0,
bytemuck::bytes_of(&HudUniform::new([4.0, 2.0, 1.0, 0.75], batch.end_portal, 0)),
);
let mut encoder = self.device.create_command_encoder(&Default::default());
for (view, value) in [(&frame_view, 0.3), (&mask_view, initial)] {
let mut attachment = hud_coverage::attachment(view);
attachment.ops.load = wgpu::LoadOp::Clear(wgpu::Color {
r: value,
g: value,
b: value,
a: value,
});
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
color_attachments: &[Some(attachment)],
..Default::default()
});
}
self.hud.draw_range(
&mut encoder,
&frame_view,
coverage.then_some(&mask_view),
&depth_view,
batch,
[4, 2],
0..batch.vertices.len() as u32,
None,
);
let buffers = [&frame, &mask].map(|t| {
let buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 512,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
encoder.copy_texture_to_buffer(
t.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &buffer,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(256),
rows_per_image: None,
},
},
t.size(),
);
buffer
});
self.queue.submit([encoder.finish()]);
let [color, mask] = buffers.map(|buffer| {
buffer.map_async(wgpu::MapMode::Read, .., |r| r.unwrap());
self.device
.poll(wgpu::PollType::wait_indefinitely())
.unwrap();
buffer.get_mapped_range(..).unwrap().to_vec()
});
(
color
.chunks(256)
.flat_map(|r| r[..32].iter().copied())
.collect(),
mask.chunks(256)
.flat_map(|r| r[..4].iter().copied())
.collect(),
)
}
fn check(&self, batch: &HudBatch, initial: f64, expected: [u8; 4]) {
let (plain, _) = self.draw(batch, initial, false);
let (color, mask) = self.draw(batch, initial, true);
assert_eq!(color, plain, "coverage changed the primary colour or alpha");
for (got, want) in mask.iter().zip(expected.repeat(2)) {
assert!(
got.abs_diff(want) <= 1,
"coverage {mask:?}, expected {expected:?}"
);
}
}
}
fn quad(batch: &mut HudBatch, blend: Blend, color: u32, x: f32, uv: f32) {
let start = batch.vertices.len();
batch.quad(
[[x, 0.0], [x + 2.0, 0.0], [x + 2.0, 2.0], [x, 2.0]],
[[uv, 0.5]; 4],
color,
);
batch.blend_since(start, blend);
}
#[test]
fn coverage_follows_hud_blends_discard_scissor_and_depth_without_changing_colour() {
let Some(gpu) = Gpu::new() else {
return;
};
for (blend, color, initial, count, expected) in [
(Blend::Alpha, rgba(255, 255, 255, 128), 0.0, 2, 192),
(Blend::Additive, rgba(255, 255, 255, 128), 0.3, 1, 77),
(Blend::Invert, rgba(255, 255, 255, 32), 0.0, 1, 255),
(Blend::EndPortal, rgba(255, 255, 255, 255), 0.0, 1, 255),
(Blend::Vignette, rgba(102, 102, 102, 255), 0.0, 1, 102),
] {
let mut batch = HudBatch::new([4, 2]);
for _ in 0..count {
quad(&mut batch, blend, color, 0.0, 0.25);
}
// Crosshair fragments with zero texture alpha discard; EndPortal has no discard.
if blend == Blend::Invert {
quad(&mut batch, blend, color, 2.0, 0.75);
}
gpu.check(
&batch,
initial,
[
expected,
expected,
(initial * 255.0).round() as u8,
(initial * 255.0).round() as u8,
],
);
}
let mut batch = HudBatch::new([4, 2]);
for depth in [0.25, 0.75] {
quad(
&mut batch,
Blend::Alpha,
rgba(255, 255, 255, 128),
0.0,
0.25,
);
let start = batch.vertices.len() - 6;
for vertex in &mut batch.vertices[start..] {
vertex.depth = depth;
}
}
batch.blends.push((0..12, Blend::Depth([1, 0, 2, 2])));
gpu.check(&batch, 0.0, [0, 128, 0, 0]);
}
#[test]
fn coverage_preserves_each_cit_blend_and_its_destination_alpha() {
let Some(mut gpu) = Gpu::new() else {
return;
};
use horizon_optifine::cit::parse_blend;
for (name, expected) in [
("alpha", 128),
("add", 0),
("dodge", 0),
("glint", 0),
("burn", 102),
("multiply", 26),
("overlay", 51),
("screen", 255),
("subtract", 255),
("replace", 255),
("0 768", 153),
("774 0", 153),
("770 771 772 773", 128),
] {
let textures = cit_glint::GlintTextures {
width: 1,
height: 1,
pixels: vec![102, 102, 102, 128],
slots: vec![cit_glint::Slot {
rgba: [1.0; 4],
blur: false,
blend: 0,
}],
blends: vec![parse_blend(name).unwrap()],
..Default::default()
};
gpu.hud
.set_cit_glints(&gpu.device, &gpu.queue, Some(&textures));
let mut batch = HudBatch::new([4, 2]);
quad(
&mut batch,
Blend::CitGlint(0),
rgba(0, 0, 0, 255),
0.0,
0.25,
);
quad(
&mut batch,
Blend::CitGlint(0),
rgba(0, 0, 0, 255),
2.0,
0.75,
);
gpu.check(&batch, 0.0, [expected, expected, 0, 0]);
}
}
#[test]
fn coverage_uses_pack_texts_final_alpha_and_discard() {
let Some(mut gpu) = Gpu::new() else {
return;
};
// A translated-style struct result and early return; no dependency on game resources.
let program = horizon_shaders::CoreProgram {
vertex: naga::front::wgsl::parse_str("@vertex fn main(@location(0) p: vec2<f32>) -> @builtin(position) vec4<f32> { return vec4(p.x / 2.0 - 1.0, 1.0 - p.y, 0.0, 1.0); }").unwrap(),
fragment: naga::front::wgsl::parse_str("struct Result { @location(0) color: vec4<f32> } @fragment fn main(@builtin(position) p: vec4<f32>) -> Result { if p.x > 2.0 { discard; } return Result(vec4(0.4, 0.2, 0.1, 0.5)); }").unwrap(),
};
gpu.hud.set_text_shader(&gpu.device, Some(&program));
assert!(gpu.hud.pack_text.is_some());
let mut batch = HudBatch::new([4, 2]);
for x in [0.0, 2.0, 0.0, 2.0] {
quad(&mut batch, Blend::Alpha, rgba(255, 255, 255, 255), x, 0.25);
}
for v in &mut batch.vertices {
v.text = 1;
}
gpu.check(&batch, 0.0, [191, 191, 0, 0]);
}
#[test]
fn a_pack_text_shader_without_a_colour_output_is_dropped_not_wrapped() {
let Some(mut gpu) = Gpu::new() else {
return;
};
let program = horizon_shaders::CoreProgram {
vertex: naga::front::wgsl::parse_str("@vertex fn main(@location(0) p: vec2<f32>) -> @builtin(position) vec4<f32> { return vec4(p, 0.0, 1.0); }").unwrap(),
fragment: naga::front::wgsl::parse_str("@fragment fn main() -> @location(1) vec4<f32> { return vec4(1.0); }").unwrap(),
};
gpu.hud.set_text_shader(&gpu.device, Some(&program));
assert!(gpu.hud.pack_text.is_none());
}
@@ -8,6 +8,7 @@ use std::sync::Arc;
use horizon_shaders::{CoreProgram, Glue};
use super::{HudBatch, HudVertex};
use crate::gfx::hud_coverage::Pipeline;
/// GUI text's vertex format (`POSITION_TEX_COLOR`) from the HUD's vertices. A glyph is two
/// triangles here, its corners top left, top right, bottom right, top left, bottom right, bottom
@@ -81,7 +82,7 @@ const BLOCK_SIZES: [u64; 4] = [48, 64, 160, 48];
/// The pipeline and its resources.
pub(super) struct PackText {
pipeline: wgpu::RenderPipeline,
pipeline: Pipeline,
group: wgpu::BindGroup,
uniform: wgpu::Buffer,
}
@@ -92,6 +93,7 @@ impl PackText {
format: wgpu::TextureFormat,
atlas: &wgpu::TextureView,
program: &CoreProgram,
coverage: bool,
) -> Option<Self> {
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
let module = |label, module: &naga::Module| {
@@ -179,7 +181,7 @@ impl PackText {
..Default::default()
});
let attributes = wgpu::vertex_attr_array![0=>Float32x2,1=>Float32x2,2=>Unorm8x4];
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
let desc = wgpu::RenderPipelineDescriptor {
label: Some("Resource pack text"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
@@ -208,11 +210,17 @@ impl PackText {
}),
multiview_mask: None,
cache: None,
});
};
let source = wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(program.fragment.clone()));
let pipeline = Pipeline::new(device, &desc, source, coverage);
if let Some(e) = pollster::block_on(scope.pop()) {
log::warn!("the resource pack's text shader is not used: {e}");
return None;
}
let Some(pipeline) = pipeline else {
log::warn!("the resource pack's text shader is not used: it has no colour output");
return None;
};
Some(Self {
pipeline,
group,
@@ -234,8 +242,13 @@ impl PackText {
/// Draw `range` (whole glyph quads) with the pack's shader; the caller sets its own
/// pipeline and bind group again after.
pub(super) fn draw(&self, pass: &mut wgpu::RenderPass<'_>, range: std::ops::Range<u32>) {
pass.set_pipeline(&self.pipeline);
pub(super) fn draw(
&self,
pass: &mut wgpu::RenderPass<'_>,
range: std::ops::Range<u32>,
coverage: bool,
) {
pass.set_pipeline(self.pipeline.get(coverage));
pass.set_bind_group(0, &self.group, &[]);
pass.draw(range.clone(), range.start..range.start + 1);
}
+1
View File
@@ -94,6 +94,7 @@ pub mod preedit;
pub mod providers;
pub mod pvp;
pub mod pvp_hud;
pub mod random_source;
pub mod screen_draw;
pub mod screen_effects;
pub mod sculk;
+112 -7
View File
@@ -462,7 +462,7 @@ fn read_meta(source: &Source) -> Option<serde_json::Value> {
/// The first JSON value of a `pack.mcmeta`: what follows it is ignored, as some packs sign the
/// file with a comment after the closing brace. Gson reads past a UTF-8 byte order mark;
/// serde_json rejects it.
fn parse_meta(bytes: &[u8]) -> Option<serde_json::Value> {
pub(crate) fn parse_meta(bytes: &[u8]) -> Option<serde_json::Value> {
serde_json::Deserializer::from_slice(bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes))
.into_iter()
.next()?
@@ -837,24 +837,55 @@ fn build_overlay_uncached(
dependencies,
&mut extract,
)?;
// What the zips may unpack to, all threads together: a server's pack is a download the
// player only accepts, and a gigabyte of zeros deflates to a megabyte, so without it a pack
// at the download limit wrote hundreds of gigabytes here. Vanilla never unpacks a pack.
let budget = std::sync::atomic::AtomicU64::new(unpack_budget(packs));
// Inflating and writing a big pack's files one by one was most of a cold build; its
// folders all exist by now.
crate::par::par_map(&extract, |(owner, inner, target)| {
let extracted: io::Result<()> = crate::par::par_map(&extract, |(owner, inner, target)| {
let Some(Some(z)) = zips.get(*owner) else {
return Ok(());
};
// One unreadable entry costs that file, not the pack: an error here
// failed the build and the reload dropped every server pack in the stack.
match z.read(inner) {
Ok(bytes) => fs::write(target, bytes),
let copied = fs::File::create(target).and_then(|file| {
let mut file = io::BufWriter::new(file);
z.copy_to(inner, &mut file, &budget)?;
io::Write::flush(&mut file)
});
match copied {
Ok(()) => Ok(()),
// Past the budget the build fails, and the reload drops the server packs.
Err(e) if e.kind() == io::ErrorKind::FileTooLarge => Err(e),
// One unreadable entry costs that file, not the pack: an error here
// failed the build and the reload dropped every server pack in the stack.
Err(e) => {
log::warn!("Horizon: skipping unreadable pack entry {inner}: {e}");
let _ = fs::remove_file(target);
Ok(())
}
}
})
.into_iter()
.collect()
.collect();
if extracted.is_err() {
// What was unpacked before the budget ran out goes too, not only the marker.
let _ = fs::remove_dir_all(out);
}
extracted
}
/// The most the zip packs in `packs` may unpack to in one overlay: 16 times their size on disk
/// (real packs are mostly PNGs and JSON, a few times smaller zipped at most), at least 64 MiB
/// for a small pack of text, and never past 8 GiB.
fn unpack_budget(packs: &[Pack]) -> u64 {
packs
.iter()
.filter_map(|p| match &p.source {
Source::Zip(z) => fs::metadata(z).ok().map(|m| m.len()),
Source::Dir(_) => None,
})
.sum::<u64>()
.saturating_mul(16)
.clamp(64 << 20, 8 << 30)
}
/// Files vanilla loads from every pack in turn (`getResourceStack`) rather
/// than from the top pack only.
@@ -1000,7 +1031,10 @@ pub fn resource_root(vanilla: &Path, packs: &[Pack], cache: &Path) -> io::Result
(h ^ u64::from(b)).wrapping_mul(0x100_0000_01b3)
});
let out = cache.join(format!("{hash:016x}"));
cache::hold(&out)?;
build_overlay(vanilla, packs, &out)?;
// Old selections' overlays would otherwise stay for good, each a whole unpacked copy.
cache::prune(cache);
Ok(out)
}
@@ -1736,6 +1770,77 @@ mod tests {
let _ = fs::remove_dir_all(base);
}
#[test]
fn a_pack_that_unpacks_past_its_budget_fails_and_leaves_nothing() {
use std::io::Write as _;
let base = std::env::temp_dir().join(format!("horizon-pack-bomb-{}", std::process::id()));
let _ = fs::remove_dir_all(&base);
let vanilla = base.join("vanilla");
fs::create_dir_all(vanilla.join("assets/minecraft/textures/block")).unwrap();
fs::write(
vanilla.join("assets/minecraft/textures/block/stone.png"),
b"vanilla",
)
.unwrap();
fs::create_dir_all(base.join("packs")).unwrap();
let pack = |name: &str, files: &[(&str, usize)]| {
let path = base.join("packs").join(name);
let mut zip = zip::ZipWriter::new(fs::File::create(&path).unwrap());
let deflated = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated);
zip.start_file("pack.mcmeta", deflated).unwrap();
zip.write_all(br#"{"pack":{"description":"d","pack_format":97}}"#)
.unwrap();
let zeros = vec![0u8; 1 << 20];
for (file, mib) in files {
zip.start_file(*file, deflated).unwrap();
for _ in 0..*mib {
zip.write_all(&zeros).unwrap();
}
}
zip.finish().unwrap();
fs::metadata(&path).unwrap().len()
};
// Four 24 MiB files of zeros: well under the per-file limit, 96 MiB in all from a zip of
// a few hundred KiB, past the 64 MiB a small pack may unpack to.
let size = pack(
"bomb.zip",
&[
("assets/minecraft/textures/block/a.png", 24),
("assets/minecraft/textures/block/b.png", 24),
("assets/minecraft/textures/block/c.png", 24),
("assets/minecraft/textures/block/d.png", 24),
],
);
assert!(size < 1 << 20, "the bomb is small: {size} bytes");
let packs = discover(&vanilla, &base.join("packs"));
assert_eq!(packs.len(), 1);
let out = base.join("overlay");
let error = build_overlay(&vanilla, &packs, &out).unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::FileTooLarge, "{error}");
assert!(
!out.exists(),
"what was unpacked before the budget ran out stays"
);
// The same files within the budget unpack whole.
fs::remove_file(base.join("packs/bomb.zip")).unwrap();
pack(
"fine.zip",
&[
("assets/minecraft/textures/block/a.png", 24),
("assets/minecraft/textures/block/b.png", 24),
],
);
let packs = discover(&vanilla, &base.join("packs"));
build_overlay(&vanilla, &packs, &out).unwrap();
assert_eq!(
fs::metadata(out.join("assets/minecraft/textures/block/b.png"))
.unwrap()
.len(),
24 << 20
);
let _ = fs::remove_dir_all(&base);
}
#[test]
fn zip_entries_never_leave_the_overlay() {
use std::io::Write as _;
let base = std::env::temp_dir().join(format!("horizon-pack-escape-{}", std::process::id()));
@@ -297,6 +297,107 @@ fn lock_path(out: &Path) -> PathBuf {
marker_path(out).with_extension("lock")
}
/// The overlays [`prune`] keeps in a cache folder, newest use first. Each holds a whole unpacked
/// copy of its zip packs, and every server pack, and every new version of one, makes another.
pub(super) const KEEP: usize = 8;
/// The file whose shared lock says a process uses `out`, and whose time is its last use.
fn use_path(out: &Path) -> PathBuf {
marker_path(out).with_extension("use")
}
/// Marks `out` as used by this process until it exits ([`prune`] never deletes it), and as used
/// now. The lock is taken before the overlay is built or read, so a prune in another process
/// that took the folder first finishes deleting it before this one rebuilds it.
pub(super) fn hold(out: &Path) -> io::Result<()> {
use std::sync::{Mutex, PoisonError};
static HELD: Mutex<Vec<(PathBuf, fs::File)>> = Mutex::new(Vec::new());
if let Some(parent) = out.parent().filter(|p| !p.as_os_str().is_empty()) {
fs::create_dir_all(parent)?;
}
let mut held = HELD.lock().unwrap_or_else(PoisonError::into_inner);
let at = match held.iter().position(|(path, _)| path == out) {
Some(at) => at,
None => {
let file = fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(use_path(out))?;
file.lock_shared()?;
held.push((out.into(), file));
held.len() - 1
}
};
// Only the order of the prune depends on it: a filesystem without the time loses nothing.
let _ = held[at].1.set_modified(std::time::SystemTime::now());
Ok(())
}
/// Deletes the overlays in `cache` past the [`KEEP`] most recently used, skipping any that a
/// process holds ([`hold`]) or builds. Only `<16 hex digits>` folders are overlays.
pub(super) fn prune(cache: &Path) {
let Ok(entries) = fs::read_dir(cache) else {
return;
};
let mut overlays: Vec<(std::time::SystemTime, PathBuf)> = entries
.flatten()
.map(|e| e.path())
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.len() == 16 && n.bytes().all(|b| b.is_ascii_hexdigit()))
&& p.is_dir()
})
.map(|p| {
// An overlay built before use files existed goes by its marker or its folder.
let used = [use_path(&p), marker_path(&p), p.clone()]
.iter()
.find_map(|f| fs::metadata(f).and_then(|m| m.modified()).ok())
.unwrap_or(std::time::UNIX_EPOCH);
(used, p)
})
.collect();
overlays.sort_by_key(|(used, _)| std::cmp::Reverse(*used));
for (_, old) in overlays.into_iter().skip(KEEP) {
if let Err(e) = remove_unused(&old) {
log::warn!(
"Horizon: couldn't remove the old pack overlay {}: {e}",
old.display()
);
}
}
}
/// Deletes the overlay `out` unless a process builds or uses it. The lock files stay: deleting
/// them would let two processes lock different files of the same name.
fn remove_unused(out: &Path) -> io::Result<()> {
let open = |path: PathBuf| {
fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(path)
};
let building = open(lock_path(out))?;
let using = open(use_path(out))?;
for lock in [&building, &using] {
match lock.try_lock() {
Ok(()) => {}
Err(fs::TryLockError::WouldBlock) => return Ok(()),
Err(fs::TryLockError::Error(e)) => return Err(e),
}
}
// The marker first: a folder half deleted is never taken for a built one.
match fs::remove_file(marker_path(out)) {
Err(e) if e.kind() != io::ErrorKind::NotFound => return Err(e),
_ => {}
}
fs::remove_dir_all(out)
}
pub(super) fn build(vanilla: &Path, packs: &[Pack], out: &Path) -> io::Result<()> {
if let Some(parent) = out.parent().filter(|p| !p.as_os_str().is_empty()) {
fs::create_dir_all(parent)?;
@@ -368,6 +469,77 @@ mod tests {
use crate::{packs, text::Text};
use std::sync::atomic::{AtomicUsize, Ordering};
#[test]
fn prune_keeps_the_most_recently_used_overlays_and_those_in_use() {
let f = Fixture::new();
let cache = f.0.join("cache");
let now = std::time::SystemTime::now();
// KEEP + 4 overlays, overlay 0 used last; one more not an overlay.
let overlays: Vec<PathBuf> = (0..KEEP + 4)
.map(|i| {
let out = cache.join(format!("{i:016x}"));
f.write(&format!("cache/{i:016x}/assets/a.png"), b"x");
f.write(&format!("cache/{i:016x}.overlay-cache.json"), b"{}");
let used = fs::File::create(use_path(&out)).unwrap();
used.set_modified(now - std::time::Duration::from_secs(60 * i as u64))
.unwrap();
out
})
.collect();
f.write("cache/notes/keep.txt", b"x");
// Another process uses the oldest, and builds the second oldest.
let using = fs::File::open(use_path(&overlays[KEEP + 3])).unwrap();
using.lock_shared().unwrap();
let building = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(false)
.open(lock_path(&overlays[KEEP + 2]))
.unwrap();
building.lock().unwrap();
prune(&cache);
let kept: Vec<bool> = overlays.iter().map(|o| o.is_dir()).collect();
let mut expected = vec![true; KEEP];
expected.extend([false, false, true, true]);
assert_eq!(kept, expected);
assert!(
!marker_path(&overlays[KEEP]).exists(),
"never reused half deleted"
);
assert!(cache.join("notes/keep.txt").exists());
// Released, they go at the next prune.
drop((using, building));
prune(&cache);
assert_eq!(
overlays.iter().filter(|o| o.is_dir()).count(),
KEEP,
"only the most recent stay"
);
assert!(overlays[..KEEP].iter().all(|o| o.is_dir()));
}
#[test]
fn an_overlay_held_by_this_process_is_never_pruned() {
let f = Fixture::new();
let cache = f.0.join("cache");
let held = cache.join("00000000000000ff");
f.write("cache/00000000000000ff/assets/a.png", b"x");
hold(&held).unwrap();
// Older than KEEP others, and still in use.
let old = std::time::SystemTime::now() - std::time::Duration::from_secs(3600);
fs::File::options()
.write(true)
.open(use_path(&held))
.unwrap()
.set_modified(old)
.unwrap();
for i in 0..KEEP {
f.write(&format!("cache/{i:016x}/assets/a.png"), b"x");
}
prune(&cache);
assert!(held.is_dir());
}
struct Fixture(PathBuf);
impl Fixture {
fn new() -> Self {
@@ -0,0 +1,63 @@
//! The randoms vanilla creates unseeded: `RandomSource.create()` (sounds,
//! screens, the torch flicker, a raid's random) and an entity's own random
//! (`Entity.random`). Each is seeded from one shared counter and the clock, as
//! `RandomSupport.generateUniqueSeed` does. Kept out of `horizon_core::random`,
//! whose streams are only ever seeded explicitly.
use horizon_core::random::LegacyRandom;
/// `RandomSource.create()`.
pub fn create() -> LegacyRandom {
LegacyRandom::new(unique_seed())
}
/// An entity's own random (`Entity.random`). Vanilla's is
/// `RandomSource.createThreadLocalInstance`, seeded from Java's
/// `ThreadLocalRandom`; Horizon seeds it as `create` does. Draws from an
/// entity's own random never touch the level's.
pub fn entity() -> LegacyRandom {
create()
}
/// `RandomSupport.generateUniqueSeed`. Under `HORIZON_STILL` (screenshots that
/// come out the same every run) the clock is left out, so the seeds repeat run
/// to run, in the order they are taken.
pub fn unique_seed() -> i64 {
use std::sync::atomic::{AtomicI64, Ordering};
static UNIQUIFIER: AtomicI64 = AtomicI64::new(8_682_522_807_148_012);
let mut current = UNIQUIFIER.load(Ordering::Relaxed);
let next = loop {
let next = current.wrapping_mul(1_181_783_497_276_652_981);
match UNIQUIFIER.compare_exchange(current, next, Ordering::Relaxed, Ordering::Relaxed) {
Ok(_) => break next,
Err(v) => current = v,
}
};
if still() {
return next;
}
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos() as i64);
next ^ nanos
}
/// `HORIZON_STILL=1`, read once, as the clock's `still_requested` reads it.
fn still() -> bool {
static STILL: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*STILL.get_or_init(|| std::env::var("HORIZON_STILL").is_ok_and(|v| v == "1"))
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_core::random::RandomSource;
/// One shared counter: seeds taken back to back never repeat, even when
/// the clock hasn't moved between them.
#[test]
fn each_seed_and_each_random_is_new() {
let seeds: std::collections::HashSet<i64> = (0..1000).map(|_| unique_seed()).collect();
assert_eq!(seeds.len(), 1000);
assert_ne!(create().next_long(), create().next_long());
}
}
@@ -1580,27 +1580,6 @@ impl SoundEngine {
pub fn active_instances(&self) -> Vec<InstanceId> {
self.instance_to_channel.iter().map(|(i, _)| *i).collect()
}
/// A fresh unseeded random (`RandomSource.create()`).
pub fn unseeded() -> LegacyRandom {
LegacyRandom::new(unique_seed())
}
}
/// `RandomSupport.generateUniqueSeed`.
pub fn unique_seed() -> i64 {
use std::sync::atomic::{AtomicI64, Ordering};
static UNIQUIFIER: AtomicI64 = AtomicI64::new(8_682_522_807_148_012);
let mut current = UNIQUIFIER.load(Ordering::Relaxed);
let next = loop {
let next = current.wrapping_mul(1_181_783_497_276_652_981);
match UNIQUIFIER.compare_exchange(current, next, Ordering::Relaxed, Ordering::Relaxed) {
Ok(_) => break next,
Err(v) => current = v,
}
};
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos() as i64);
next ^ nanos
}
/// The `SoundManager` calls game code makes.
pub trait Sounds {
@@ -1774,7 +1753,7 @@ mod tests {
silent: false,
underwater: false,
};
let mut s = SoundInstance::riding_happy_ghast(&ghast, SoundEngine::unseeded());
let mut s = SoundInstance::riding_happy_ghast(&ghast, crate::random_source::create());
assert_eq!(s.volume, 0.0);
let mut ctx = TickContext {
ridden: Some(ghast),
@@ -1821,7 +1800,7 @@ mod tests {
attack_scale: 0.0,
sniffer_digging: true,
};
let mut s = SoundInstance::sniffer(3, SoundEngine::unseeded());
let mut s = SoundInstance::sniffer(3, crate::random_source::create());
assert_eq!(s.position, [0.0; 3]);
assert!(!s.looping);
let mut ctx = TickContext {
@@ -1836,7 +1815,7 @@ mod tests {
s.tick(&ctx);
assert!(s.stopped);
// Removed (missing): stop.
let mut s = SoundInstance::sniffer(3, SoundEngine::unseeded());
let mut s = SoundInstance::sniffer(3, crate::random_source::create());
ctx.mobs.clear();
s.tick(&ctx);
assert!(s.stopped);
@@ -1850,7 +1829,7 @@ mod tests {
silent: false,
underwater: true,
};
let mut s = SoundInstance::riding_nautilus(&nautilus, SoundEngine::unseeded());
let mut s = SoundInstance::riding_nautilus(&nautilus, crate::random_source::create());
let mut ctx = TickContext {
ridden: Some(nautilus),
..TickContext::default()
@@ -1953,7 +1932,7 @@ mod tests {
position: [10.0, 64.0, 5.0],
speed: 0.0,
};
let mut s = SoundInstance::minecart(&cart, SoundEngine::unseeded());
let mut s = SoundInstance::minecart(&cart, crate::random_source::create());
assert!(s.can_start_silent);
assert_eq!(s.volume, 0.0);
@@ -1993,8 +1972,10 @@ mod tests {
position: [0.0, 60.0, 0.0],
speed: 0.4,
};
let mut surface = SoundInstance::riding_minecart(&cart, false, SoundEngine::unseeded());
let mut underwater = SoundInstance::riding_minecart(&cart, true, SoundEngine::unseeded());
let mut surface =
SoundInstance::riding_minecart(&cart, false, crate::random_source::create());
let mut underwater =
SoundInstance::riding_minecart(&cart, true, crate::random_source::create());
let mut ctx = TickContext {
vehicle: Some(99),
@@ -1,8 +1,9 @@
//! `SoundPreviewHandler`: the sound a category volume slider plays while it
//! moves on the title screen's options (`Options.createSoundSliderOptionInstance`
//! calls `preview` when there is no level).
use crate::random_source;
use crate::settings::SOUND_SOURCES;
use crate::sound_engine::{InstanceId, SoundEngine, SoundInstance, Sounds};
use crate::sound_engine::{InstanceId, SoundInstance, Sounds};
/// The sound `SoundPreviewHandler.preview` plays for a `SoundSource` index,
/// or `None` (`SoundEvents.EMPTY`) for master, music and voice.
@@ -44,7 +45,7 @@ impl SoundPreview {
if let Some(old) = self.active.take() {
sounds.release(old);
}
let instance = SoundInstance::for_ui(event, 1.0, volume, SoundEngine::unseeded());
let instance = SoundInstance::for_ui(event, 1.0, volume, random_source::create());
self.active = Some(sounds.play_instance(instance, true).0);
}
@@ -72,7 +73,7 @@ impl SoundPreview {
#[cfg(test)]
mod tests {
use super::*;
use crate::sound_engine::{Bound, TickContext, UI, test_backend::Null};
use crate::sound_engine::{Bound, SoundEngine, TickContext, UI, test_backend::Null};
use crate::sound_events::SoundRegistry;
fn engine() -> SoundEngine {
@@ -70,6 +70,12 @@ pub type Proto = Box<dyn std::any::Any + Send>;
pub trait ChunkGenerator: Send + Sync + 'static {
type Worker: Send + 'static;
fn worker(&self) -> Self::Worker;
/// The persistent wrapper supplies starts from their owning chunk, in every dimension.
fn set_saved_starts(
&self,
_source: Arc<horizon_world::worldgen::generator::SavedStructureStarts>,
) {
}
fn lod_sampler(self: Arc<Self>) -> Box<dyn LodSampler>
where
Self: Sized,
+99
View File
@@ -9,6 +9,102 @@ use horizon_assets::palette::apply_palette;
use serde_json::Value;
use std::{collections::HashMap, fs, path::Path};
/// Runtime trim slots and the completed ordered requests for their current atlas.
/// A frame with more requests than slots keeps the same final residents as the
/// sequential LRU pass, without decoding and evicting them again every frame.
#[derive(Default)]
pub struct TrimSlots {
slots: Vec<(String, usize)>,
atlas_revision: u64,
prepared: Option<Vec<TrimTexture>>,
}
impl TrimSlots {
/// The renderer was rebuilt: runtime pixels must be uploaded again.
pub fn forget(&mut self) {
self.atlas_revision = 0;
self.prepared = None;
}
pub fn prepare(
&mut self,
wanted: Vec<TrimTexture>,
root: &Path,
atlas: &mut crate::hud::HudAtlas,
patches: &mut Vec<crate::font::Patch>,
) {
if self.atlas_revision != atlas.revision {
for (name, _) in self.slots.drain(..) {
atlas.sprites.remove(&name);
}
self.atlas_revision = atlas.revision;
self.prepared = None;
}
let before = self.slots.len();
self.slots
.retain(|(name, _)| atlas.sprites.contains_key(name));
if before != self.slots.len() {
self.prepared = None;
}
if self.prepared.as_ref() == Some(&wanted) {
return;
}
let mut complete = true;
for t in &wanted {
let name = t.sprite();
if let Some(i) = self.slots.iter().position(|e| e.0 == name) {
let e = self.slots.remove(i);
self.slots.push(e);
continue;
}
let Some((w, h, rgba)) = t.generate(root) else {
complete = false;
continue;
};
let slot =
match (0..crate::hud::TRIM_SLOTS).find(|i| !self.slots.iter().any(|e| e.1 == *i)) {
Some(free) => free,
None => {
let (old, slot) = self.slots.remove(0);
atlas.sprites.remove(&old);
slot
}
};
let Some(area) = atlas.sprites.get(&format!("horizon/trim{slot}")).copied() else {
complete = false;
continue;
};
if w > area.w || h > area.h {
complete = false;
continue;
}
let (aw, ah) = (atlas.width as f32, atlas.height as f32);
let (x, y) = ((area.u0 * aw).round(), (area.v0 * ah).round());
patches.push(crate::font::Patch {
x: x as u32,
y: y as u32,
width: w,
height: h,
rgba,
});
atlas.sprites.insert(
name.clone(),
crate::hud::Rect {
u0: area.u0,
v0: area.v0,
u1: (x + w as f32) / aw,
v1: (y + h as f32) / ah,
w,
h,
},
);
self.slots.push((name, slot));
}
// Failed or not-yet-available textures retain their per-frame retries.
self.prepared = complete.then_some(wanted);
}
}
/// An RGBA image: width, height, pixels.
pub type Image = (u32, u32, Vec<u8>);
@@ -249,6 +345,9 @@ fn png_size(path: &Path) -> Option<(u32, u32)> {
(&head[12..16] == b"IHDR").then(|| (be(16), be(20)))
}
#[cfg(test)]
mod slots_tests;
#[cfg(test)]
mod tests {
use super::*;
@@ -0,0 +1,192 @@
use super::*;
use crate::hud::{HudAtlas, Rect, TRIM_SLOTS};
use std::path::PathBuf;
struct Fixture(PathBuf);
impl Fixture {
fn new() -> Self {
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let root = std::env::temp_dir().join(format!(
"horizon-trim-slots-{}-{}",
std::process::id(),
NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
));
fs::create_dir_all(&root).unwrap();
Self(root)
}
fn texture(&self, id: u8) -> TrimTexture {
TrimTexture {
texture: format!("test:trims/entity/humanoid/{id}"),
palette: None,
}
}
fn write(&self, id: u8, width: u32) {
let path = asset_path(&self.0, "textures", &self.texture(id).texture, ".png");
fs::create_dir_all(path.parent().unwrap()).unwrap();
let mut encoder = png::Encoder::new(fs::File::create(path).unwrap(), width, 1);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
encoder
.write_header()
.unwrap()
.write_image_data(&[id, 255 - id, 42, 255].repeat(width as usize))
.unwrap();
}
fn atlas(&self) -> HudAtlas {
let mut atlas = HudAtlas::fonts(&self.0).unwrap();
atlas.width = TRIM_SLOTS as u32 * 2;
atlas.height = 1;
atlas.pixels = vec![0; atlas.width as usize * 4];
for i in 0..TRIM_SLOTS {
atlas.sprites.insert(
format!("horizon/trim{i}"),
Rect {
u0: i as f32 / TRIM_SLOTS as f32,
u1: (i + 1) as f32 / TRIM_SLOTS as f32,
v0: 0.0,
v1: 1.0,
w: 2,
h: 1,
},
);
}
atlas
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn upload(atlas: &mut HudAtlas, patches: &mut Vec<crate::font::Patch>) {
for patch in patches.drain(..) {
for y in 0..patch.height as usize {
let offset = ((patch.y as usize + y) * atlas.width as usize + patch.x as usize) * 4;
let width = patch.width as usize * 4;
atlas.pixels[offset..offset + width]
.copy_from_slice(&patch.rgba[y * width..(y + 1) * width]);
}
}
}
fn color(atlas: &HudAtlas, texture: &TrimTexture) -> Option<[u8; 4]> {
let rect = atlas.sprites.get(&texture.sprite())?;
let x = (rect.u0 * atlas.width as f32).round() as usize;
Some(atlas.pixels[x * 4..x * 4 + 4].try_into().unwrap())
}
#[test]
fn overflow_keeps_the_rendered_pixels_without_reuploading_each_frame() {
let fixture = Fixture::new();
let wanted: Vec<_> = (0..33).map(|id| fixture.texture(id)).collect();
for id in 0..33 {
fixture.write(id, 2);
}
let mut atlas = fixture.atlas();
let mut slots = TrimSlots::default();
let mut patches = Vec::new();
slots.prepare(wanted.clone(), &fixture.0, &mut atlas, &mut patches);
upload(&mut atlas, &mut patches);
// The original sequential 32-slot pass finishes with requests 1..=32.
assert_eq!(color(&atlas, &wanted[0]), None);
for (id, texture) in wanted.iter().enumerate().skip(1) {
assert_eq!(
color(&atlas, texture),
Some([id as u8, 255 - id as u8, 42, 255])
);
}
for _ in 0..3 {
slots.prepare(wanted.clone(), &fixture.0, &mut atlas, &mut patches);
assert!(
patches.is_empty(),
"unchanged overflow must not upload again"
);
}
wanted
.iter()
.skip(1)
.for_each(|t| assert!(color(&atlas, t).is_some()));
}
#[test]
fn changing_request_order_or_equipment_changes_the_resident_images() {
let fixture = Fixture::new();
for id in 0..34 {
fixture.write(id, 2);
}
let mut wanted: Vec<_> = (0..33).map(|id| fixture.texture(id)).collect();
let mut atlas = fixture.atlas();
let mut slots = TrimSlots::default();
let mut patches = Vec::new();
slots.prepare(wanted.clone(), &fixture.0, &mut atlas, &mut patches);
upload(&mut atlas, &mut patches);
wanted.rotate_left(1);
slots.prepare(wanted.clone(), &fixture.0, &mut atlas, &mut patches);
upload(&mut atlas, &mut patches);
assert_eq!(color(&atlas, &fixture.texture(1)), None);
assert_eq!(color(&atlas, &fixture.texture(0)), Some([0, 255, 42, 255]));
wanted = vec![fixture.texture(33), fixture.texture(2)];
slots.prepare(wanted.clone(), &fixture.0, &mut atlas, &mut patches);
upload(&mut atlas, &mut patches);
assert_eq!(color(&atlas, &wanted[0]), Some([33, 222, 42, 255]));
assert_eq!(color(&atlas, &wanted[1]), Some([2, 253, 42, 255]));
}
#[test]
fn an_atlas_rebuild_uploads_the_same_requests_again() {
let fixture = Fixture::new();
fixture.write(7, 2);
let wanted = vec![fixture.texture(7)];
let mut slots = TrimSlots::default();
let mut patches = Vec::new();
let mut atlas = fixture.atlas();
slots.prepare(wanted.clone(), &fixture.0, &mut atlas, &mut patches);
upload(&mut atlas, &mut patches);
atlas = fixture.atlas();
fixture.write(7, 1);
slots.prepare(wanted.clone(), &fixture.0, &mut atlas, &mut patches);
assert_eq!(patches.len(), 1);
assert_eq!(patches[0].width, 1, "reload must decode the new image");
upload(&mut atlas, &mut patches);
assert_eq!(color(&atlas, &wanted[0]), Some([7, 248, 42, 255]));
atlas.pixels.fill(0);
slots.forget();
slots.prepare(wanted.clone(), &fixture.0, &mut atlas, &mut patches);
assert_eq!(
patches.len(),
1,
"a rebuilt renderer needs the pixels again"
);
upload(&mut atlas, &mut patches);
assert_eq!(color(&atlas, &wanted[0]), Some([7, 248, 42, 255]));
atlas.sprites.remove(&wanted[0].sprite());
slots.prepare(wanted.clone(), &fixture.0, &mut atlas, &mut patches);
assert_eq!(patches.len(), 1, "an erased sprite must be restored too");
upload(&mut atlas, &mut patches);
assert_eq!(color(&atlas, &wanted[0]), Some([7, 248, 42, 255]));
}
#[test]
fn missing_or_oversized_images_are_retried_when_they_become_available() {
let fixture = Fixture::new();
let wanted = vec![fixture.texture(9)];
let mut atlas = fixture.atlas();
let mut slots = TrimSlots::default();
let mut patches = Vec::new();
slots.prepare(wanted.clone(), &fixture.0, &mut atlas, &mut patches);
assert_eq!(color(&atlas, &wanted[0]), None);
fixture.write(9, 3);
slots.prepare(wanted.clone(), &fixture.0, &mut atlas, &mut patches);
assert!(patches.is_empty());
fixture.write(9, 2);
slots.prepare(wanted.clone(), &fixture.0, &mut atlas, &mut patches);
assert_eq!(patches.len(), 1);
upload(&mut atlas, &mut patches);
assert_eq!(color(&atlas, &wanted[0]), Some([9, 246, 42, 255]));
}
@@ -56,6 +56,32 @@ fn luma(c: vec3<f32>) -> f32 {
return vec4(b, centre.a);
}
// Contrast-adaptive sharpening (after AMD's CAS) of TAA's upscaled picture: each
// pixel pushed away from its four neighbours, less where they already differ a lot,
// so edges get their crispness back without halos or lifted grain.
// The Upscaling Sharpness setting, 0..1.
override sharpness: f32 = 0.85;
@fragment fn fs_sharpen(in: PostOut) -> @location(0) vec4<f32> {
let size = vec2<i32>(textureDimensions(world));
let p = clamp(vec2<i32>(in.uv * vec2<f32>(size)), vec2(0), size - 1);
let centre = textureLoad(world, p, 0);
let c = centre.rgb;
let n = textureLoad(world, clamp(p + vec2(0, -1), vec2(0), size - 1), 0).rgb;
let s = textureLoad(world, clamp(p + vec2(0, 1), vec2(0), size - 1), 0).rgb;
let w = textureLoad(world, clamp(p + vec2(-1, 0), vec2(0), size - 1), 0).rgb;
let e = textureLoad(world, clamp(p + vec2(1, 0), vec2(0), size - 1), 0).rgb;
let lo = min(c, min(min(n, s), min(w, e)));
let hi = max(c, max(max(n, s), max(w, e)));
// How much room there is before clipping, per channel: little at strong edges.
let room = clamp(min(lo, 2.0 - hi) / max(hi, vec3(0.0001)), vec3(0.0), vec3(1.0));
// Up to -1/5, the most CAS allows (the Upscaling Sharpness setting's 100%).
let lobe = -sqrt(room) * 0.2 * sharpness;
let sharp = (c + lobe * (n + s + w + e)) / (1.0 + 4.0 * lobe);
// The alpha carries the emissive mark through to HDR output (hdr.wgsl).
return vec4(clamp(sharp, lo, hi), centre.a);
}
// TAA (`world_post.rs`): blend this frame into the history, reprojected from the
// depth buffer through the camera's motion and clamped to this frame's 3x3
// neighbourhood so that what moved or appeared does not ghost.
@@ -95,6 +121,28 @@ fn sample_history(uv: vec2<f32>) -> vec3<f32> {
return max(c / weight, vec3(0.0));
}
fn to_ycocg(c: vec3<f32>) -> vec3<f32> {
return vec3(0.25 * (c.r + 2.0 * c.g + c.b), 0.5 * (c.r - c.b), 0.25 * (2.0 * c.g - c.r - c.b));
}
fn from_ycocg(c: vec3<f32>) -> vec3<f32> {
return max(vec3(c.x + c.y - c.z, c.x + c.z, c.x - c.y - c.z), vec3(0.0));
}
// Moves `c` toward the box's centre until it lies inside (clipping keeps the
// colour's hue where a per-channel clamp would shift it).
fn clip_box(c: vec3<f32>, lo: vec3<f32>, hi: vec3<f32>) -> vec3<f32> {
let mid = 0.5 * (hi + lo);
let half_size = max(0.5 * (hi - lo), vec3(0.0001));
let d = c - mid;
let unit = abs(d / half_size);
let far = max(unit.x, max(unit.y, unit.z));
if far > 1.0 {
return mid + d / far;
}
return c;
}
// It runs at the history's size, which upscaling makes larger than the world's.
// The history is unjittered: each output pixel takes the world pixel whose
// jittered sample lands nearest its centre, weighted by how near it lands.
@@ -106,17 +154,24 @@ fn sample_history(uv: vec2<f32>) -> vec3<f32> {
let last = vec2<i32>(size) - 1;
let p = clamp(vec2<i32>(floor(drawn)), vec2(0), last);
let centre = textureLoad(world, p, 0);
var lo = centre.rgb;
var hi = centre.rgb;
var lo = to_ycocg(centre.rgb);
var hi = lo;
// Mean and spread of the 3x3 (YCoCg): the history is clipped to a box around
// the mean a few deviations wide, tighter than min..max, so that what changed
// does not linger as a smear.
var m1 = vec3(0.0);
var m2 = vec3(0.0);
// Reversed depth: the nearest of the 3x3 is the largest, and its motion keeps
// the edges of what is in front from trailing.
var near = 0.0;
for (var y = -1; y <= 1; y++) {
for (var x = -1; x <= 1; x++) {
let q = clamp(p + vec2(x, y), vec2(0), last);
let c = textureLoad(world, q, 0).rgb;
let c = to_ycocg(textureLoad(world, q, 0).rgb);
lo = min(lo, c);
hi = max(hi, c);
m1 += c;
m2 += c * c;
near = max(near, textureLoad(depth, q, 0).r);
}
}
@@ -126,12 +181,19 @@ fn sample_history(uv: vec2<f32>) -> vec3<f32> {
if taa.jitter.z > 0.0 || any(uv != clamp(uv, vec2(0.0), vec2(1.0))) {
return vec4(centre.rgb, centre.a);
}
let past = clamp(sample_history(uv), lo, hi);
// How far this pixel moved since the last frame, in output pixels.
let speed = length((uv - in.uv) * vec2<f32>(textureDimensions(history)));
let moving = clamp(speed / 6.0, 0.0, 1.0);
let mean = m1 / 9.0;
let sigma = sqrt(max(m2 / 9.0 - mean * mean, vec3(0.0)));
let wide = mix(1.25, 0.75, moving) * sigma;
let past = from_ycocg(clip_box(to_ycocg(sample_history(uv)), max(lo, mean - wide), min(hi, mean + wide)));
// How far the sample landed from this pixel's centre, in output pixels: a
// Gaussian of it scales the new frame's share, so that frames whose sample
// fell far away (most of them when upscaling) barely move the pixel.
// fell far away (most of them when upscaling) barely move the pixel. Under
// motion the history is resampled every frame and blurs, so new frames weigh more.
let off = (drawn - (vec2<f32>(p) + 0.5)) * vec2<f32>(textureDimensions(history)) / size;
let share = 0.1 * exp(-2.0 * dot(off, off));
let share = mix(0.1, 0.3, moving) * exp(-2.0 * dot(off, off));
// The alpha carries the emissive mark through to HDR output (hdr.wgsl).
return vec4(mix(past, centre.rgb, share), centre.a);
}
+81 -21
View File
@@ -7,7 +7,10 @@ use std::{
fs,
io::{self, Read, Seek, SeekFrom},
path::Path,
sync::Arc,
sync::{
Arc,
atomic::{AtomicU64, Ordering},
},
};
use zip::{CompressionMethod, ZipArchive};
@@ -169,30 +172,12 @@ impl Zip {
self.index(name).is_some()
}
pub fn read(&self, name: &str) -> io::Result<Vec<u8>> {
let index = self
.index(name)
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, name.to_owned()))?;
// A clone shares the reader's data and the central directory: reading
// needs `&mut`.
let mut archive = self.archive.clone();
let mut entry = archive.by_index_raw(index).map_err(invalid)?;
let method = entry.compression();
let mut raw = Vec::with_capacity((entry.compressed_size() as usize).min(self.len));
entry.read_to_end(&mut raw)?;
let (method, raw) = self.raw(name)?;
match method {
CompressionMethod::Stored => Ok(raw),
CompressionMethod::Deflated => {
// Java's ZipFile trusts neither size an entry declares: it
// inflates the stream to its end and keeps what comes out.
// Packs in the wild (a Nexo-generated one served through
// Dropbox, for one) carry central-directory sizes that point
// at other files, and miniz_oxide, which refuses input past
// the stream's end, failed whole entries with "corrupt
// deflate stream" and with them the whole pack. Only the
// size limit stands between the archive and a zip bomb.
let decoder = flate2::read::DeflateDecoder::new(raw.as_slice());
let mut decoded = Vec::with_capacity(raw.len().min(MAX_ENTRY));
decoder
inflate(&raw)
.take(MAX_ENTRY as u64 + 1)
.read_to_end(&mut decoded)
.map_err(|_| invalid("corrupt deflate stream"))?;
@@ -204,6 +189,81 @@ impl Zip {
_ => Err(invalid("unsupported zip compression")),
}
}
/// What [`Self::read`] reads, written to `out` a piece at a time as it inflates, each piece
/// taken from `budget`: the bytes left for everything sharing it. Past the budget it stops
/// with [`io::ErrorKind::FileTooLarge`], `out` holding part of the entry; the entry's own
/// limit ([`MAX_ENTRY`]) still applies. Memory stays at the compressed entry and one piece,
/// whatever the entry inflates to. The bytes written.
pub fn copy_to(
&self,
name: &str,
out: &mut impl io::Write,
budget: &AtomicU64,
) -> io::Result<u64> {
let (method, raw) = self.raw(name)?;
let mut from: Box<dyn Read + '_> = match method {
CompressionMethod::Stored => Box::new(raw.as_slice()),
CompressionMethod::Deflated => Box::new(inflate(&raw)),
_ => return Err(invalid("unsupported zip compression")),
};
let mut piece = vec![0; 64 << 10];
let mut total = 0u64;
loop {
let n = match from.read(&mut piece) {
Ok(0) => return Ok(total),
Ok(n) => n,
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
Err(_) => return Err(invalid("corrupt deflate stream")),
};
total += n as u64;
if total > MAX_ENTRY as u64 {
return Err(invalid("entry inflates past the size limit"));
}
take(budget, n as u64)?;
out.write_all(&piece[..n])?;
}
}
/// The entry `name` as stored: its compression and its bytes.
fn raw(&self, name: &str) -> io::Result<(CompressionMethod, Vec<u8>)> {
let index = self
.index(name)
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, name.to_owned()))?;
// A clone shares the reader's data and the central directory: reading
// needs `&mut`.
let mut archive = self.archive.clone();
let mut entry = archive.by_index_raw(index).map_err(invalid)?;
let method = entry.compression();
let mut raw = Vec::with_capacity((entry.compressed_size() as usize).min(self.len));
entry.read_to_end(&mut raw)?;
Ok((method, raw))
}
}
/// `n` bytes taken from `budget`, or `FileTooLarge` (taking nothing) when it has fewer left.
fn take(budget: &AtomicU64, n: u64) -> io::Result<()> {
let mut left = budget.load(Ordering::Relaxed);
loop {
let rest = left.checked_sub(n).ok_or_else(|| {
io::Error::new(
io::ErrorKind::FileTooLarge,
"the packs unpack to more than their size allows",
)
})?;
match budget.compare_exchange_weak(left, rest, Ordering::Relaxed, Ordering::Relaxed) {
Ok(_) => return Ok(()),
Err(now) => left = now,
}
}
}
/// A deflated entry's bytes as they inflate. Java's ZipFile trusts neither size an entry
/// declares: it inflates the stream to its end and keeps what comes out. Packs in the wild (a
/// Nexo-generated one served through Dropbox, for one) carry central-directory sizes that point
/// at other files, and miniz_oxide, which refuses input past the stream's end, failed whole
/// entries with "corrupt deflate stream" and with them the whole pack. Only the size limits
/// stand between the archive and a zip bomb.
fn inflate(raw: &[u8]) -> flate2::read::DeflateDecoder<&[u8]> {
flate2::read::DeflateDecoder::new(raw)
}
/// The 22 fixed bytes of the end-of-central-directory record.
@@ -47,6 +47,21 @@
"horizon.studio.replay.seek.invalid": "Ungültige Wiedergabeposition",
"horizon.studio.replay.status": "Studio-Wiedergabe: %s / %s ms (%s%%), Geschwindigkeit %s×, pausiert %s, beendet %s",
"horizon.studio.replay.closed": "Studio-Wiedergabe geschlossen",
"horizon.studio.freecam.enabled": "Studio-Freikamera aktiviert. WASD bewegt sie, Leertaste und Umschalt heben oder senken sie, Strg beschleunigt.",
"horizon.studio.freecam.disabled": "Studio-Freikamera deaktiviert",
"horizon.studio.freecam.unavailable": "Die Studio-Freikamera konnte keine Spielerkamera finden",
"horizon.studio.freecam.replay_required": "Die Studio-Freikamera ist während der Wiedergabe verfügbar",
"horizon.studio.freecam.usage": "Verwendung: /studio freecam <on|off>",
"horizon.studio.keyframe.added": "Kameraschlüsselbild bei %s ms hinzugefügt. Der Pfad wird beim Schließen der Wiedergabe gespeichert.",
"horizon.studio.keyframe.cleared": "Kamerapfad geleert. Die Änderung wird beim Schließen der Wiedergabe gespeichert.",
"horizon.studio.keyframe.removed": "Kameraschlüsselbild %s entfernt. Die Änderung wird beim Schließen der Wiedergabe gespeichert.",
"horizon.studio.keyframe.save_error": "Kamerapfad konnte nicht gespeichert werden: %s",
"horizon.studio.keyframe.remove_usage": "Verwendung: /studio keyframe remove <INDEX>",
"horizon.studio.keyframe.index_invalid": "Kameraschlüsselbildindex liegt außerhalb des Bereichs",
"horizon.studio.keyframe.usage": "Verwendung: /studio keyframe <add|remove INDEX|clear>",
"horizon.studio.path.locked": "Studio-Kamerapfadwiedergabe aktiviert",
"horizon.studio.path.unlocked": "Studio-Kamerapfadwiedergabe gestoppt",
"horizon.studio.path.usage": "Verwendung: /studio path <lock|unlock> (zuerst mindestens ein Schlüsselbild hinzufügen)",
"horizon.studio.saved_recordings": "Gespeicherte Aufzeichnungen",
"horizon.studio.empty": "Keine Aufzeichnungen im Aufnahmeordner gefunden.",
"horizon.studio.empty_hint": "Nutze /studio record start <name> in einer Welt oder lege .mcpr-Dateien dort ab.",
@@ -113,6 +128,7 @@
"Resolution Scale": "Auflösungsskalierung",
"Anti-aliasing": "Kantenglättung",
"Upscaling": "Upscaling",
"Upscaling Sharpness": "Upscaling-Schärfe",
"Paper White": "Papierweiß",
"Peak Brightness": "Spitzenhelligkeit",
"Black Level": "Schwarzwert",
@@ -208,6 +224,7 @@
"World pixels per screen pixel. Below 100% is faster on the GPU.": "Weltpixel pro Bildschirmpixel. Unter 100 % ist es auf der GPU schneller.",
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Glättet Kanten. FXAA: weicher. TAA: am glattesten, evtl. Schlieren.",
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Rendert die Welt kleiner und baut sie mit TAA wieder auf. Schneller, weicher.",
"Sharpens the upscaled picture. Higher: crisper edges, more shimmer.": "Schärft das hochskalierte Bild. Höher: schärfere Kanten, mehr Flimmern.",
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "HDR-Ausgabe auf HDR-Bildschirm: Lava, Feuer, Sonne heller.",
"How frames reach the screen while VSync is off.": "Wie Bilder auf den Bildschirm gelangen, wenn VSync aus ist.",
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Sparsam schläft bis zum nächsten Bild; Präzise wartet aktiv, wie in Vanilla.",
@@ -104,6 +104,21 @@
"horizon.studio.replay.seek.invalid": "Invalid replay position",
"horizon.studio.replay.status": "Studio replay: %s / %s ms (%s%%), speed %s×, paused %s, finished %s",
"horizon.studio.replay.closed": "Studio replay closed",
"horizon.studio.freecam.enabled": "Studio free camera enabled. Use WASD to move, Space and Shift to rise or descend, and Control to move faster.",
"horizon.studio.freecam.disabled": "Studio free camera disabled",
"horizon.studio.freecam.unavailable": "Studio free camera could not find a player camera",
"horizon.studio.freecam.replay_required": "Studio free camera is available during replay playback",
"horizon.studio.freecam.usage": "Usage: /studio freecam <on|off>",
"horizon.studio.keyframe.added": "Camera keyframe added at %s ms. The path is saved when you close the replay.",
"horizon.studio.keyframe.cleared": "Camera path cleared. The change is saved when you close the replay.",
"horizon.studio.keyframe.removed": "Removed camera keyframe %s. The change is saved when you close the replay.",
"horizon.studio.keyframe.save_error": "Could not save camera path: %s",
"horizon.studio.keyframe.remove_usage": "Usage: /studio keyframe remove <INDEX>",
"horizon.studio.keyframe.index_invalid": "Camera keyframe index is out of range",
"horizon.studio.keyframe.usage": "Usage: /studio keyframe <add|remove INDEX|clear>",
"horizon.studio.path.locked": "Studio camera path playback enabled",
"horizon.studio.path.unlocked": "Studio camera path playback stopped",
"horizon.studio.path.usage": "Usage: /studio path <lock|unlock> (add at least one keyframe first)",
"horizon.pages.mods": "Mods",
"horizon.pages.shader_packs": "Shader Packs",
"horizon.pages.shader_options": "Shader Options",
@@ -216,6 +231,7 @@
"Resolution Scale": "Resolution Scale",
"Anti-aliasing": "Anti-aliasing",
"Upscaling": "Upscaling",
"Upscaling Sharpness": "Upscaling Sharpness",
"Paper White": "Paper White",
"Peak Brightness": "Peak Brightness",
"Black Level": "Black Level",
@@ -317,6 +333,7 @@
"World pixels per screen pixel. Below 100% is faster on the GPU.": "World pixels per screen pixel. Below 100% is faster on the GPU.",
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.",
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Renders the world smaller and rebuilds it with TAA. Faster, softer.",
"Sharpens the upscaled picture. Higher: crisper edges, more shimmer.": "Sharpens the upscaled picture. Higher: crisper edges, more shimmer.",
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "HDR output on an HDR display: lava, fire and sunlight shine brighter.",
"How frames reach the screen while VSync is off.": "How frames reach the screen while VSync is off.",
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Efficient sleeps until the next frame; Precise spins, like vanilla.",
@@ -76,6 +76,7 @@
"Resolution Scale": "Масштаб разрешения",
"Anti-aliasing": "Сглаживание",
"Upscaling": "Апскейл",
"Upscaling Sharpness": "Резкость апскейла",
"Paper White": "Белая точка",
"Peak Brightness": "Пиковая яркость",
"Black Level": "Уровень чёрного",
@@ -167,6 +168,7 @@
"World pixels per screen pixel. Below 100% is faster on the GPU.": "Пикселей мира на пиксель экрана. Ниже 100% — быстрее для видеокарты.",
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Сглаживает рваные края. FXAA: мягче. TAA: глаже всего, но движущееся может шлейфить.",
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Рисует мир мельче и восстанавливает его через TAA. Быстрее, но мягче.",
"Sharpens the upscaled picture. Higher: crisper edges, more shimmer.": "Повышает резкость увеличенной картинки. Выше: чётче края, больше мерцания.",
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "HDR-вывод на HDR-экране: лава, огонь и солнце светятся ярче.",
"How frames reach the screen while VSync is off.": "Как кадры попадают на экран, когда вертикальная синхронизация выключена.",
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "«Экономный» спит до следующего кадра; «Точный» крутится, как в ванили.",
@@ -472,5 +472,450 @@
"Pack settings": "Налаштування пакета",
"Built-in settings": "Вбудовані налаштування",
"%s settings": "Налаштування %s",
"Settings made by the pack's author: they change only this pack.": "Налаштування, створені автором пакета: вони змінюють лише цей пакет."
"Settings made by the pack's author: they change only this pack.": "Налаштування, створені автором пакета: вони змінюють лише цей пакет.",
"horizon.extras.header.world": "Світ",
"horizon.extras.pregen": "Попередня генерація чанків",
"horizon.extras.pregen.tooltip": "Вмикає /pregen у ваших одиночних і LAN-світах. З увімкненими читами введіть /pregen start, щоб згенерувати область, перш ніж її досліджувати. На серверах, до яких ви приєднуєтесь, команда не вмикається.",
"horizon.options.three_d_skin_layers": "3D-шари скіна",
"horizon.options.play_in_background": "Гра у фоні",
"horizon.options.trigger_threshold": "Поріг тригера",
"horizon.studio.record.usage": "Використання: /studio record <start [назва]|stop|status>",
"horizon.studio.record.started": "Запис Studio розпочато: %s",
"horizon.studio.record.saved": "Запис Studio збережено: %s",
"horizon.studio.record.error": "Помилка запису Studio: %s",
"horizon.studio.record.status.active": "Запис Studio триває — %s с, %s пакетів, %s КБ (%s)",
"horizon.studio.record.status.inactive": "Запис Studio не ведеться",
"horizon.studio.replay.usage": "Використання: /studio replay <open ШЛЯХ|pause|resume|speed МНОЖНИК|seek МС|status|close>",
"horizon.studio.replay.open.usage": "Використання: /studio replay open <ШЛЯХ>",
"horizon.studio.replay.opened": "Повтор Studio відкрито: %s (%s с, %s пакетів)",
"horizon.studio.replay.open_error": "Не вдалося відкрити повтор: %s",
"horizon.studio.replay.paused": "Повтор Studio призупинено",
"horizon.studio.replay.resumed": "Повтор Studio продовжено",
"horizon.studio.replay.inactive": "Немає активного повтору Studio",
"horizon.studio.replay.speed.usage": "Використання: /studio replay speed <МНОЖНИК>",
"horizon.studio.replay.speed.set": "Швидкість повтору Studio: %s×",
"horizon.studio.replay.speed.invalid": "Неправильна швидкість повтору",
"horizon.studio.replay.seek.usage": "Використання: /studio replay seek <МІЛІСЕКУНДИ>",
"horizon.studio.replay.seeked": "Повтор Studio перемотано на %s мс (%s с)",
"horizon.studio.replay.seek.error": "Не вдалося перемотати повтор: %s",
"horizon.studio.replay.seek.backward_requires_standalone": "Перемотування назад доступне лише в окремому повторі.",
"horizon.studio.replay.seek.invalid": "Неправильна позиція повтору",
"horizon.studio.replay.status": "Повтор Studio: %s / %s мс (%s%%), швидкість %s×, пауза: %s, завершено: %s",
"horizon.studio.replay.closed": "Повтор Studio закрито",
"horizon.pages.studio": "Студія повторів",
"horizon.pages.music": "Музика",
"horizon.pages.music.menu": "Меню",
"horizon.pages.music.overworld": "Верхній світ",
"horizon.pages.music.creative": "Творчий режим",
"horizon.pages.music.underwater": "Під водою",
"horizon.pages.music.end": "Енд",
"horizon.pages.music.boss": "Бос",
"horizon.pages.music.credits": "Титри",
"horizon.pages.music.overworld_biomes": "Біоми Верхнього світу",
"horizon.pages.music.nether_biomes": "Біоми Незеру",
"horizon.pages.music.end_biomes": "Біоми Енду",
"horizon.pages.music.other": "Інша музика",
"horizon.pages.music.empty": "Немає музичних треків: звуки гри не завантажено.",
"horizon.pages.music.tooltip": "Вбудовані треки за ситуаціями: увімкніть будь-який зараз або вимкніть, щоб гра більше його не обирала.",
"horizon.pages.studio.tooltip": "Перегляд збережених записів і їхніх метаданих.",
"horizon.pages.open.studio": "Студія повторів...",
"horizon.studio.saved_recordings": "Збережені записи",
"horizon.studio.empty": "У теці записів немає жодного запису.",
"horizon.studio.empty_hint": "Введіть у світі /studio record start <назва> або покладіть туди файли .mcpr.",
"horizon.studio.open_folder": "Відкрити записи",
"horizon.studio.open_replay": "Відтворити повтор",
"horizon.studio.refresh": "Оновити",
"horizon.studio.unknown_server": "Невідомо",
"horizon.pages.open.music": "Музика...",
"horizon.mods.in_world": " (вийдіть зі світу, щоб змінити)",
"horizon.mods.browse": "Огляд модів...",
"horizon.mods.browse.title": "Огляд модів",
"horizon.mods.browse.search": "Пошук модів...",
"horizon.mods.browse.project": "Мод",
"horizon.mods.browse.loading": "Читання реєстру модів Horizon...",
"horizon.mods.browse.failed": "Не вдалося прочитати реєстр модів: %s",
"horizon.mods.browse.none": "Модів не знайдено",
"horizon.mods.browse.has_update": " (оновлення)",
"horizon.mods.browse.is_installed": " (встановлено)",
"horizon.mods.browse.by": "автор: %s",
"horizon.mods.browse.installing": "Встановлення...",
"horizon.mods.browse.install": "Встановити",
"horizon.mods.browse.update": "Оновити",
"horizon.mods.browse.installed": "Встановлено",
"horizon.mods.browse.open_page": "Відкрити сторінку",
"horizon.mods.browse.enabled": "Мод",
"horizon.mods.browse.changelog": "Список змін",
"horizon.mods.browse.done": "%s %s встановлено й увімкнено.",
"horizon.mods.browse.install_failed": "Не вдалося встановити: %s",
"horizon.mods.switch.worldgen": "змінює генерацію світу",
"horizon.mods.switch.registries": "додає блоки, предмети чи інші записи реєстрів",
"horizon.mods.switch.restart": "Перезапустіть гру, щоб застосувати %s: вони додають блоки, предмети або генерацію світу.",
"GPU Culling": "Відсікання на GPU",
"HDR Lighting": "HDR-освітлення",
"Light sources, the sun and sunlight shine past white. Off: just as SDR.": "Джерела світла, сонце й сонячне світло сяють яскравіше за білий. Вимкнено: як у SDR.",
"Smooth Gradients": "Плавні градієнти",
"16-bit frame: dark skies and shadows don't band. A bit more VRAM. Next start.": "16-бітний кадр: темне небо й тіні без смуг. Трохи більше відеопам’яті. З наступного запуску.",
"Not with software rendering, which runs it on the CPU.": "Не з програмним рендерингом, який виконує це на CPU.",
"Out of video memory: off until the next start.": "Забракло відеопам’яті: вимкнено до наступного запуску.",
"The graphics device was lost: off until the next start.": "Графічний пристрій втрачено: вимкнено до наступного запуску.",
"Not on DirectX 12 yet: try the Vulkan graphics API.": "Поки не працює на DirectX 12: спробуйте графічний API Vulkan.",
"Culls nearby terrain on the GPU: less CPU at high render distance, more GPU.": "Відсікає ближній рельєф на GPU: за великої дальності промальовування менше навантажує CPU, більше — GPU.",
"horizon.realms.not_signed_in": "Увійдіть за допомогою акаунта Microsoft у лаунчері Horizon, щоб грати на Realms.",
"horizon.extras.music_page": "Сторінка «Музика»",
"horizon.extras.zoom": "Наближення",
"horizon.extras.zoom_mod_install": "Наближення: встановіть у модах",
"horizon.extras.wheel_cycles_list_settings.tooltip": "Коліщатко миші над кнопкою в списку налаштувань змінює її значення. Вимкнено, як у ванільній грі: коліщатко прокручує список.",
"horizon.extras.pack_browser": "Огляд наборів ресурсів",
"horizon.extras.music_page.tooltip": "У «Музика і звуки» з’являється сторінка «Музика...»: увімкніть будь-який вбудований трек зараз або вимкніть, щоб гра більше його не обирала. Нічого не змінюється, доки ви не вимкнете трек.",
"horizon.friends.relay_stopped": "Забагато неправильних коротких кодів, тому ретранслятор вимкнено, доки ви знову не поділитеся світом. Друзі все ще можуть приєднатися за повним кодом.",
"horizon.extras.pack_browser.tooltip": "У «Вибрати набори ресурсів» з’являється кнопка «Додати», яка шукає в Modrinth і встановлює набір у вашу теку наборів. Вимкнено: гра ніколи не звертається до Modrinth.",
"horizon.splitscreen.online_only": "Цей сервер працює в онлайн-режимі: офлайн-профілі не можуть приєднатися, кожному гравцеві потрібен власний акаунт Minecraft.",
"horizon.splitscreen.own_account": "Цей сервер вимагає від кожного гравця власного акаунта Minecraft",
"horizon.splitscreen.local_only": "Інші гравці можуть приєднатися до цього сервера лише з власним акаунтом Minecraft: офлайн-профілі грають у вашому світі, через LAN або на сервері у вашій локальній мережі.",
"Shaders": "Шейдери",
"Shader packs, Horizon's and shaders/ folder ones, and their options.": "Пакети шейдерів — від Horizon і з теки shaders/ — та їхні налаштування.",
"Shader packs": "Пакети шейдерів",
"Vanilla's own preset: sets only vanilla's options, as Minecraft does.": "Власний пресет ванільної гри: задає лише ванільні налаштування, як це робить Minecraft.",
"Fullscreen takes the display over, at the mode below.": "Повноекранний режим займає весь дисплей у режимі, вказаному нижче.",
"The display mode exclusive fullscreen uses. Applies when you leave.": "Режим дисплея для ексклюзивного повноекранного режиму. Застосовується, коли ви виходите.",
"Blurs the world behind menus.": "Розмиває світ за меню.",
"How textures are sampled: sharper at a slant with Anisotropic.": "Як вибираються текстури: з анізотропною фільтрацією чіткіші під кутом.",
"How far Anisotropic filtering goes. Higher is sharper at a slant.": "Рівень анізотропної фільтрації. Вищий — чіткіше під кутом.",
"Only with Anisotropic texture filtering.": "Лише з анізотропною фільтрацією текстур.",
"Picks a shader pack, Horizon's or a shaders/ folder one, and sets its options.": "Обирає пакет шейдерів — від Horizon чи з теки shaders/ — і задає його налаштування.",
"Hidden Faces": "Приховані грані",
"Drops leaf faces behind two leaf layers: faster, same crowns.": "Прибирає грані листя за двома шарами листя: швидше, а крони ті самі.",
"Entity Culling Precheck": "Попередня перевірка відсікання сутностей",
"Runs the mob AABB precheck after this many loaded mobs. Zero disables it.": "Запускає попередню перевірку AABB мобів, коли завантажено стільки мобів. Нуль вимикає її.",
"%s mobs": "%s мобів",
"horizon.splitscreen.profiles.offline": " (Офлайн)",
"horizon.splitscreen.profiles.microsoft": " (Microsoft)",
"horizon.splitscreen.profiles.sign_in": "Увійдіть знову в лаунчері",
"horizon.splitscreen.profiles.online": "Microsoft онлайн",
"horizon.splitscreen.account_unavailable": "%s офлайн. Увійдіть знову в лаунчері, потім перезапустіть гру.",
"horizon.splitscreen.account_expired": "Вхід Microsoft для %s закінчився. Увійдіть знову в лаунчері, потім перезапустіть гру.",
"horizon.pack.add": "+",
"horizon.pack.addInfo": "Додати набір ресурсів із Modrinth",
"horizon.pack.addWorking": "Запит до Modrinth...",
"horizon.pack.addNone": "Наборів ресурсів не знайдено",
"horizon.pack.addTitle": "Додати набір ресурсів",
"horizon.pack.install": "Встановити",
"horizon.pack.installing": "Встановлення",
"horizon.pack.installed": "Встановлено",
"horizon.pack.search": "Пошук",
"horizon.pack.searchHint": "Пошук у Modrinth...",
"horizon.pack.filters": "Фільтри",
"horizon.pack.filter.realistic": "Реалістичні",
"horizon.pack.filter.simplistic": "Спрощені",
"horizon.pack.filter.vanillaLike": "Як ванільні",
"horizon.pack.filter.x16": "16x",
"horizon.pack.filter.x32": "32x",
"horizon.pack.filter.x64": "64x",
"horizon.pack.addFailed": "Не вдалося додати набір: %s",
"horizon.extras.header.cosmetics": "Косметика",
"horizon.extras.cosmetics_mine": "Моя косметика",
"horizon.extras.cosmetics_mine.tooltip": "Приховано: усі гравці Horizon бачать вас без косметики, і ви теж. Той самий перемикач, що й у Гардеробі лаунчера.",
"key.emote": "Колесо емоцій",
"horizon.emote_wheel.title": "Емоції",
"horizon.emote_wheel.empty": "Емоцій поки немає",
"horizon.emote_wheel.help": "Відпустіть або клацніть, щоб відтворити · 1–8 · Esc закриває",
"horizon.extras.cosmetics_others": "Косметика інших гравців",
"horizon.extras.cosmetics_others.tooltip": "Що носять інші гравці Horizon: усе, лише стилі ніків або нічого. Приховано, якщо у вас немає косметики, доки ви не оберете. Коли приховано, гра ніколи не питає, що вони носять.",
"horizon.extras.cosmetics_hide_fx": "Приховати сліди й аури інших гравців",
"horizon.extras.cosmetics_hide_fx.tooltip": "Сліди й аури інших гравців приховано; решту їхньої косметики видно. Типово вимкнено.",
"horizon.extras.cosmetics.shown": "Показано",
"horizon.extras.cosmetics.hidden": "Приховано",
"horizon.extras.cosmetics.name_tags": "Лише ніки",
"horizon.moderation.duration.minute": "%s хв",
"horizon.moderation.duration.minutes": "%s хв",
"horizon.moderation.duration.hour": "%s год",
"horizon.moderation.duration.hours": "%s год",
"horizon.moderation.duration.day": "%s дн.",
"horizon.moderation.duration.days": "%s дн.",
"horizon.moderation.reason": "%1$s: %2$s",
"horizon.moderation.mutelist.empty": "Заглушених гравців немає",
"horizon.moderation.mutelist.count": "Заглушених гравців: %s:",
"horizon.moderation.chat_filter": "фільтр чату",
"horizon.moderation.until_unmuted": "до зняття заглушення",
"horizon.moderation.remaining": "ще на %s",
"horizon.moderation.mutelist.entry": "%1$s заглушено гравцем %2$s %3$s",
"horizon.moderation.invalid_duration": "%s — не тривалість: 10m, 2h, 1d",
"horizon.moderation.usage.mute": "/mute <гравець> [тривалість] [причина]",
"horizon.moderation.usage.tempmute": "/tempmute <гравець> <тривалість> [причина]",
"horizon.moderation.usage.unmute": "/unmute <гравець>",
"horizon.moderation.usage.mutelist": "/mutelist",
"horizon.moderation.for_duration": "на %s",
"horizon.moderation.muted": "Заглушено %1$s %2$s",
"horizon.moderation.unmuted": "Знято заглушення з %s",
"horizon.moderation.not_muted": "%s не заглушено",
"horizon.tempban.usage": "/tempban <гравець> <тривалість> [причина]",
"horizon.tempban.invalid_duration": "%s — не тривалість: 30m, 12h, 7d",
"horizon.tempban.dedicated_required": "Тимчасові бани потребують виділеного сервера",
"horizon.tempban.success": "%1$s на %2$s, до %3$s",
"horizon.tempban.list.empty": "Тимчасових банів немає",
"horizon.tempban.list.count": "Тимчасових банів: %s:",
"horizon.tempban.list.entry": "%1$s забанено гравцем %2$s %3$s (до %4$s)",
"horizon.options.pointer_speed": "Швидкість курсора в меню",
"horizon.options.scroll_speed": "Швидкість прокрутки в меню",
"horizon.options.repeat_delay": "Затримка повтору навігації",
"horizon.options.repeat_interval": "Інтервал повтору навігації",
"horizon.options.milliseconds": "%s мс",
"horizon.extras.cosmetics_solo": "В одиночній грі",
"horizon.extras.cosmetics_solo.tooltip": "Показувати вашу косметику й значок вашого ніка також в одиночних світах (сам нік — із «Власним ніком»). Вимкнено: лише на серверах.",
"horizon.extras.horizon_marks": "Логотипи Horizon",
"horizon.extras.horizon_marks.tooltip": "Показувати логотип Horizon біля гравців Horizon: маленький логотип перед іменами інших гравців, які грають у Horizon, над їхніми головами та в списку гравців. Його бачать лише гравці Horizon: сервери нічого не отримують.",
"horizon.extras.horizon_me": "Показувати мене як гравця Horizon",
"horizon.extras.horizon_me.tooltip": "Показувати мене як гравця Horizon: інші гравці Horizon бачать логотип біля вашого імені. Коли ввімкнено, сервіс відповідає кожному, хто про вас питає, що ви грали в Horizon за останні 3 години; коли вимкнено — ніколи про вас не повідомляє.",
"horizon.pages.pvp": "PvP та HUD",
"horizon.pages.pvp.tooltip": "Налаштування прицілу, HUD і PvP, усі типово вимкнені. Тут немає нічого, що показує більше, ніж ванільна гра.",
"horizon.pages.open.pvp": "PvP та HUD...",
"horizon.pvp.header.crosshair": "Приціл",
"horizon.pvp.crosshair": "Власний приціл",
"horizon.pvp.crosshair_shape": "Форма",
"horizon.pvp.crosshair_size": "Розмір",
"horizon.pvp.crosshair_gap": "Проміжок",
"horizon.pvp.crosshair_thickness": "Товщина",
"horizon.pvp.crosshair_colour": "Колір",
"horizon.pvp.crosshair_outline": "Обведення",
"horizon.pvp.crosshair_dot": "Точка в центрі",
"horizon.pvp.crosshair_hover": "Колір на сутностях",
"horizon.pvp.crosshair.tooltip": "Малює ваш власний приціл замість ванільного. Початкові налаштування виглядають як ванільний.",
"horizon.pvp.crosshair_shape.tooltip": "Хрест, T, X, коло, квадрат або лише точка.",
"horizon.pvp.crosshair_size.tooltip": "Довжина кожного променя в пікселях інтерфейсу.",
"horizon.pvp.crosshair_gap.tooltip": "Порожнє місце посередині.",
"horizon.pvp.crosshair_thickness.tooltip": "Ширина кожної лінії.",
"horizon.pvp.crosshair_colour.tooltip": "«Інвертований» показує колір під прицілом інвертованим, як ванільний приціл.",
"horizon.pvp.crosshair_outline.tooltip": "Тонкий чорний край, щоб приціл було видно на будь-якому фоні.",
"horizon.pvp.crosshair_dot.tooltip": "Точка посередині прицілу.",
"horizon.pvp.crosshair_hover.tooltip": "Інший колір, коли приціл наведено на моба чи гравця, якого можна вдарити, — як це визначає ванільний індикатор атаки.",
"horizon.pvp.colour.inverted": "Інвертований",
"horizon.pvp.colour.white": "Білий",
"horizon.pvp.colour.red": "Червоний",
"horizon.pvp.colour.orange": "Помаранчевий",
"horizon.pvp.colour.yellow": "Жовтий",
"horizon.pvp.colour.green": "Зелений",
"horizon.pvp.colour.cyan": "Бірюзовий",
"horizon.pvp.colour.blue": "Синій",
"horizon.pvp.colour.magenta": "Пурпуровий",
"horizon.pvp.colour.black": "Чорний",
"horizon.pvp.shape.cross": "Хрест",
"horizon.pvp.shape.t": "T",
"horizon.pvp.shape.x": "X",
"horizon.pvp.shape.circle": "Коло",
"horizon.pvp.shape.square": "Квадрат",
"horizon.pvp.shape.dot": "Точка",
"horizon.pvp.outline": "Власне обведення блока",
"horizon.pvp.outline.tooltip": "Змінює обведення навколо блока, на який ви дивитеся.",
"horizon.pvp.outline_colour": "Колір",
"horizon.pvp.outline_colour.tooltip": "Колір обведення. Чорний на 40% — ванільний.",
"horizon.pvp.outline_opacity": "Непрозорість",
"horizon.pvp.outline_opacity.tooltip": "Наскільки щільне обведення.",
"horizon.pvp.outline_thickness": "Товщина",
"horizon.pvp.outline_thickness.tooltip": "Ширина обведення, у разах від ванільної.",
"horizon.pvp.outline_fill": "Заливка",
"horizon.pvp.outline_fill.tooltip": "Злегка підфарбовує грані блока, на який ви дивитеся.",
"horizon.pvp.hit": "Власний колір удару",
"horizon.pvp.hit.tooltip": "Змінює колір, яким спалахують моби й гравці, коли їм завдають шкоди. Червоний на 30% — ванільний.",
"horizon.pvp.hit_colour": "Колір",
"horizon.pvp.hit_colour.tooltip": "Колір, яким спалахують поранені моби й гравці.",
"horizon.pvp.hit_opacity": "Непрозорість",
"horizon.pvp.hit_opacity.tooltip": "Наскільки колір їх покриває.",
"horizon.pvp.header.outline": "Обведення блока",
"horizon.pvp.header.hit": "Колір удару",
"horizon.pvp.hud_armour": "Броня",
"horizon.pvp.hud_armour.tooltip": "Броня, яку ви носите, починаючи з голови, і скільки використань лишилося в кожного предмета.",
"horizon.pvp.hud_armour_corner": "Кут броні",
"horizon.pvp.hud_armour_corner.tooltip": "Де показується броня.",
"horizon.pvp.hud_armour_percent": "Міцність у %",
"horizon.pvp.hud_armour_percent.tooltip": "Залишок використань у відсотках від максимуму предмета замість числа.",
"horizon.pvp.hud_effects": "Ефекти",
"horizon.pvp.hud_effects.tooltip": "Ваші ефекти з назвами, рівнями й часом, що лишився, замість значків угорі праворуч.",
"horizon.pvp.hud_effects_corner": "Кут ефектів",
"horizon.pvp.hud_effects_corner.tooltip": "Де показуються ефекти.",
"horizon.pvp.hud_held": "Кількість предмета в руці",
"horizon.pvp.hud_held.tooltip": "Скільки предметів, як у вашій руці, є в інвентарі; з луком чи арбалетом — стріл.",
"horizon.pvp.hud_held_corner": "Кут кількості",
"horizon.pvp.hud_held_corner.tooltip": "Де показується кількість.",
"horizon.pvp.hud_background": "Фон віджетів",
"horizon.pvp.hud_background.tooltip": "Темна підкладка під кожним віджетом.",
"horizon.pvp.hud_opacity": "Непрозорість фону",
"horizon.pvp.hud_opacity.tooltip": "Наскільки темні підкладки під віджетами.",
"horizon.pvp.hud_text_colour": "Колір тексту",
"horizon.pvp.hud_text_colour.tooltip": "Колір тексту віджетів. Міцність лишається від зеленого до червоного.",
"horizon.pvp.hud_shadow": "Тінь тексту",
"horizon.pvp.hud_shadow.tooltip": "Тінь під текстом віджетів, як у тексту ванільного HUD.",
"horizon.pvp.header.hud": "Віджети HUD",
"horizon.pvp.corner.top_left": "Угорі ліворуч",
"horizon.pvp.corner.top_right": "Угорі праворуч",
"horizon.pvp.corner.bottom_left": "Унизу ліворуч",
"horizon.pvp.corner.bottom_right": "Унизу праворуч",
"horizon.pvp.hud_ping": "Пінг",
"horizon.pvp.hud_ping.tooltip": "Затримка, яку сервер повідомляє для вас, у мілісекундах. Лише в мережевій грі.",
"horizon.pvp.hud_ping_corner": "Кут пінгу",
"horizon.pvp.hud_ping_corner.tooltip": "Де показується пінг.",
"horizon.pvp.hud_coords": "Координати",
"horizon.pvp.hud_coords.tooltip": "Ваша позиція в блоках і напрямок погляду, як їх показує F3. Приховано, коли світ чи сервер скорочує F3.",
"horizon.pvp.hud_coords_corner": "Кут координат",
"horizon.pvp.hud_coords_corner.tooltip": "Де показуються координати.",
"horizon.pvp.hud_server": "Адреса сервера",
"horizon.pvp.hud_server.tooltip": "Адреса сервера, на якому ви граєте. Лише в мережевій грі.",
"horizon.pvp.hud_server_corner": "Кут сервера",
"horizon.pvp.hud_server_corner.tooltip": "Де показується адреса.",
"horizon.pvp.hud_combo": "Лічильник комбо",
"horizon.pvp.hud_combo.tooltip": "Удари поспіль, підтверджені сервером, доки вас не поранять або не мине 3 секунди без удару.",
"horizon.pvp.hud_combo_corner": "Кут комбо",
"horizon.pvp.hud_combo_corner.tooltip": "Де показується комбо.",
"horizon.pvp.hud.ping": "Пінг: %s мс",
"horizon.pvp.hud.coords": "XYZ: %s %s %s",
"horizon.pvp.hud.facing": "Напрямок: %s",
"horizon.pvp.hud.combo": "Комбо: %s",
"horizon.pvp.hud.direction.north": "Північ",
"horizon.pvp.hud.direction.south": "Південь",
"horizon.pvp.hud.direction.east": "Схід",
"horizon.pvp.hud.direction.west": "Захід",
"horizon.pvp.hud_fps": "FPS",
"horizon.pvp.hud_fps.tooltip": "Кадрів за останню секунду.",
"horizon.pvp.hud_fps_corner": "Кут FPS",
"horizon.pvp.hud_fps_corner.tooltip": "Де показується FPS.",
"horizon.pvp.hud_cps": "Кліки за секунду",
"horizon.pvp.hud_cps.tooltip": "Ваші ліві й праві кліки за останню секунду.",
"horizon.pvp.hud_cps_corner": "Кут CPS",
"horizon.pvp.hud_cps_corner.tooltip": "Де показуються кліки за секунду.",
"horizon.pvp.hud_keys": "Натискання клавіш",
"horizon.pvp.hud_keys.tooltip": "Клавіші руху, кнопки миші та стрибок, які підсвічуються, поки ви їх тримаєте, і кліки за секунду.",
"horizon.pvp.hud_keys_corner": "Кут натискань",
"horizon.pvp.hud_keys_corner.tooltip": "Де показуються натискання клавіш.",
"horizon.pvp.hud_clock": "Годинник",
"horizon.pvp.hud_clock.tooltip": "Час на вашому комп’ютері.",
"horizon.pvp.hud_clock_corner": "Кут годинника",
"horizon.pvp.hud_clock_corner.tooltip": "Де показується годинник.",
"horizon.pvp.hud_reach": "Дальність",
"horizon.pvp.hud_reach.tooltip": "На якій відстані від ваших очей влучив останній удар, протягом 3 секунд. Це лише вимірювання: ваша дальність лишається ванільною.",
"horizon.pvp.hud_reach_corner": "Кут дальності",
"horizon.pvp.hud_reach_corner.tooltip": "Де показується дальність.",
"horizon.pvp.hud_speed": "Швидкість",
"horizon.pvp.hud_speed.tooltip": "Як швидко ви рухаєтеся по землі, у блоках за секунду.",
"horizon.pvp.hud_speed_corner": "Кут швидкості",
"horizon.pvp.hud_speed_corner.tooltip": "Де показується швидкість.",
"horizon.pvp.hud.fps": "%s FPS",
"horizon.pvp.hud.cps": "CPS: %s | %s",
"horizon.pvp.hud.reach": "Дальність: %s",
"horizon.pvp.hud.speed": "Швидкість: %s б/с",
"horizon.pvp.hud.keys.attack": "ЛКМ",
"horizon.pvp.hud.keys.use": "ПКМ",
"horizon.pvp.hud.keys.cps": "%s CPS",
"horizon.pvp.numeric_enchants": "Рівні чар числами",
"horizon.pvp.numeric_enchants.tooltip": "Підказки та чарувальний стіл показують «Гострота 5» замість «Гострота V».",
"horizon.pvp.header.items": "Предмети",
"horizon.pvp.fire_lower": "Нижчий вогонь",
"horizon.pvp.fire_lower.tooltip": "Опускає полум’я, яке ви бачите, коли горите, щоб воно менше закривало екран.",
"horizon.pvp.shield_size": "Розмір щита",
"horizon.pvp.shield_size.tooltip": "Розмір щита у вашій руці від першої особи.",
"horizon.pvp.totem_size": "Розмір анімації тотема",
"horizon.pvp.totem_size.tooltip": "Розмір тотема, що заповнює екран, коли рятує вас.",
"horizon.pvp.glint_colour": "Колір блиску чар",
"horizon.pvp.glint_colour.tooltip": "Колір мерехтіння на зачарованих предметах і броні. Вимкнено — ванільний фіолетовий.",
"horizon.pvp.clear_water": "Прозора вода",
"horizon.pvp.clear_water.tooltip": "Під водою одразу бачите так далеко, як ванільна гра дозволяє лише після 30 секунд під водою.",
"horizon.pvp.clear_lava": "Прозора лава",
"horizon.pvp.clear_lava.tooltip": "У лаві бачите так далеко, як дозволяє ванільна Вогнестійкість, без цього ефекту.",
"horizon.pvp.header.visual": "Візуальні зміни",
"horizon.pvp.view_time": "Час доби",
"horizon.pvp.view_time.tooltip": "Час доби, який ви бачите: лише небо та його світло. Час у світі йде далі, і на сервер нічого не надсилається.",
"horizon.pvp.view_weather": "Погода",
"horizon.pvp.view_weather.tooltip": "Погода, яку ви бачите: лише дощ, його світло й туман. Погода у світі не змінюється, і на сервер нічого не надсилається.",
"horizon.pvp.header.view": "Що ви бачите",
"horizon.pvp.view.world": "Як у світі",
"horizon.pvp.time.morning": "Ранок",
"horizon.pvp.time.noon": "Полудень",
"horizon.pvp.time.sunset": "Захід сонця",
"horizon.pvp.time.midnight": "Опівніч",
"horizon.pvp.weather.clear": "Ясно",
"horizon.pvp.weather.rain": "Дощ",
"horizon.pvp.weather.thunder": "Гроза",
"horizon.chat.timestamp": "[%s] ",
"horizon.chat.repeats": " (x%s)",
"horizon.pvp.header.chat": "Чат",
"horizon.chat.copied": "Повідомлення скопійовано",
"horizon.chat.search": "Пошук у чаті:",
"horizon.pvp.chat_timestamps": "Позначки часу",
"horizon.pvp.chat_timestamps.tooltip": "Перед кожним повідомленням — час, коли воно надійшло.",
"horizon.pvp.chat_compact": "Компактний чат",
"horizon.pvp.chat_compact.tooltip": "Повідомлення, повторене кілька разів поспіль, показується один раз із кількістю повторів: (x3).",
"horizon.pvp.chat_copy": "Копіювання клацанням",
"horizon.pvp.chat_copy.tooltip": "Клацання по повідомленню у відкритому чаті копіює його текст, якщо клацання не робить чогось іншого.",
"horizon.pvp.chat_search": "Пошук у чаті",
"horizon.pvp.chat_search.tooltip": "Ctrl+F у відкритому чаті: показуються лише повідомлення, що містять введений текст. Enter або повторне Ctrl+F завершує пошук.",
"horizon.pvp.preset": "PvP-пресет",
"horizon.pvp.preset.tooltip": "Вмикає власний приціл, віджети броні й ефектів, перемикання бігу та скрадання, нижчий вогонь, а також моди лічильника FPS і натискань клавіш, якщо їх встановлено. Ваші налаштування кожного з них зберігаються.",
"horizon.pvp.hud_editor_key": "Клавіша редактора HUD",
"horizon.pvp.hud_editor_key.tooltip": "Відкриває редактор HUD із гри. Клацніть, потім натисніть клавішу; Escape скасовує призначення.",
"horizon.pvp.editor": "Редагувати HUD",
"horizon.pvp.editor.tooltip": "Розставте віджети просто в грі: перетягуйте куди завгодно, змінюйте розмір коліщатком, клацніть правою кнопкою, щоб повернути віджет у його кут.",
"horizon.pvp.editor.hint": "Перетягуйте віджети · Коліщатко: розмір · Права кнопка: назад у кут",
"horizon.pvp.combat_feel": "Відчуття бою",
"horizon.pvp.combat_feel.tooltip": "«Класичне» показує бої як у 1.8.9: предмет у руці, його змах, їжу, лук і піднятий меч, поки ви тримаєте «використати», без просідання після кожного удару та без індикатора атаки. Лише вигляд: те, що ви надсилаєте серверу, не змінюється.",
"horizon.pvp.combat_indicator": "Індикатор атаки в «Класичному»",
"horizon.pvp.combat_indicator.tooltip": "Залишає в «Класичному» індикатор атаки, якого в 1.8.9 не було.",
"horizon.pvp.combat_feel.modern": "Сучасне",
"horizon.pvp.combat_feel.legacy": "Класичне (1.8.9)",
"horizon.pvp.header.combat": "Відчуття бою",
"horizon.pvp.combat_sounds": "Звуки ударів 1.8",
"horizon.pvp.combat_sounds.tooltip": "У «Класичному» прибирає звуки атаки, додані в 1.9 (сильний, слабкий, розмашистий, критичний удар...). Звуки поранення лишаються.",
"horizon.pvp.combat_rules": "Бій 1.8 у ваших світах",
"horizon.pvp.combat_rules.tooltip": "В одиночній грі та у світах, відкритих для LAN, бої йдуть за правилами 1.8.9: без перезарядки атаки й розмашистого удару, зі шкодою зброї, Силою, Гостротою і критичними ударами як у 1.8. Сервери, до яких ви приєднуєтесь, лишають свої правила.",
"Bindings": "Призначення",
"Play": "Гра",
"Device": "Пристрій",
"Look": "Огляд",
"Movement": "Рух",
"Menus": "Меню",
"Actions": "Дії",
"Reset": "Скинути",
"Detected": "Виявлено",
"Glyphs": "Значки кнопок",
"Feedback": "Віддача",
"Controller": "Геймпад",
"How the sticks turn and move you, and how menus repeat and scroll.": "Як стіки повертають і рухають вас, а меню повторюють і прокручують.",
"Which button does what. Each player has their own.": "Яка кнопка що робить. У кожного гравця — свої.",
"The controller the game sees, its button glyphs and its feedback.": "Геймпад, який бачить гра, значки його кнопок і віддача.",
"Press this row, then the button that should do it.": "Натисніть цей рядок, потім кнопку, яка має це робити.",
"Off, the game ignores every controller for this player.": "Вимкнено: гра ігнорує всі геймпади для цього гравця.",
"How fast the right stick turns the view.": "Як швидко правий стік повертає огляд.",
"Turn the view the other way, left and right.": "Повертати огляд у протилежний бік ліворуч і праворуч.",
"Turn the view the other way, up and down.": "Повертати огляд у протилежний бік угору і вниз.",
"How far the right stick moves before the view turns. Raise it if the view drifts.": "Наскільки має відхилитися правий стік, перш ніж огляд почне повертатися. Збільште, якщо огляд сам зсувається.",
"Hold the button to sneak, or press it once to start and again to stop.": "Тримайте кнопку, щоб скрадатися, або натисніть раз, щоб почати, і ще раз, щоб припинити.",
"How far the left stick moves before you walk. Raise it if you drift.": "Наскільки має відхилитися лівий стік, перш ніж ви підете. Збільште, якщо вас самого зносить.",
"How far a trigger is pulled before it counts as pressed.": "Наскільки треба натиснути тригер, щоб це вважалося натисканням.",
"How fast the stick moves the crosshair in inventories.": "Як швидко стік рухає курсор в інвентарях.",
"How fast the right stick scrolls a list.": "Як швидко правий стік прокручує список.",
"How long a held direction waits before it repeats.": "Скільки утримуваний напрямок чекає перед першим повтором.",
"How long a held direction takes between repeats.": "Скільки часу минає між повторами утримуваного напрямку.",
"Which on-screen keyboard opens when a text field is pressed.": "Яка екранна клавіатура відкривається, коли натискаєте текстове поле.",
"Every action back on its default button.": "Повернути всі дії на типові кнопки.",
"These settings belong to this player's profile.": "Ці налаштування належать профілю цього гравця.",
"The controller driving this player, or else the first one connected.": "Геймпад, яким керує цей гравець, або інакше перший підключений.",
"The USB vendor and product the system reports. Useful when reporting a controller that misbehaves.": "Виробник і продукт USB, які повідомляє система. Знадобиться, коли повідомляєте про геймпад, що працює неправильно.",
"The glyph family the game picked from the controller's maker.": "Набір значків, який гра обрала за виробником геймпада.",
"Follow the controller, or always show one family's buttons.": "Як у підключеного геймпада або завжди кнопки одного набору.",
"The buttons menus show right now.": "Кнопки, які меню показують зараз.",
"Rumble when something happens to you.": "Вібрація, коли з вами щось стається.",
"The controller keeps playing while this window isn't focused, and losing focus won't pause.": "Геймпад працює, навіть коли це вікно не у фокусі, а втрата фокусу не ставить гру на паузу.",
"A DualSense's triggers push back on bows and tools.": "Тригери DualSense чинять опір, коли ви стріляєте з лука чи працюєте інструментами.",
"What a DualSense's light shows, over USB or Bluetooth.": "Що показує підсвічування DualSense через USB або Bluetooth.",
"Player profile": "Профіль гравця",
"Vendor and product": "Виробник і продукт",
"Detected glyphs": "Виявлені значки",
"Glyphs in use": "Поточні значки",
"Unknown": "Невідомо",
"No controller": "Немає геймпада",
"Editing: %s": "Редагування: %s",
"Press the button for this. %s cancels.": "Натисніть кнопку для цього. %s — скасувати.",
"This button also does: %s": "Ця кнопка також робить: %s",
"horizon.pad.notice.connected.press": "Натисніть, щоб використовувати",
"horizon.recipe_transfer.missing": "Бракує предметів",
"horizon.recipe_transfer.no_room": "Інвентар заповнено",
"horizon.recipe_transfer.unavailable": "Рецепт не підходить для цього меню",
"horizon.recipe_transfer.unsupported": "Сервер вимагає відомого рецепта"
}
@@ -1,7 +1,7 @@
//! Headless transport for the block-entity render parity gate: replays each
//! case (block state, light, partial tick and ticked block-entity fields)
//! through `BlockEntityRenderer` at the block origin and writes the quads
//! (texture, sprite-local UVs, baked colour, light) and sign text calls.
//! (texture, sprite-local UVs, tint, normal, light) and sign text calls.
#![forbid(unsafe_code)]
use glam::{DVec3, Mat4, Quat, Vec3};
use horizon_client::{
@@ -417,7 +417,8 @@ pub fn main() -> Result<()> {
])
})
.collect();
json!({"texture": texture, "v": v, "color": q[0].color, "light": q[0].light})
json!({"texture": texture, "v": v, "color": q[0].color, "light": q[0].light,
"normal": q[0].normal})
})
.collect();
let texts: Vec<Value> = texts
@@ -0,0 +1,79 @@
//! Rust side of tools/test_enchant_durability_parity.py: `Enchantments` value
//! effects for the `minecraft:item_damage` (Unbreaking) and
//! `minecraft:repair_with_xp` (Mending) components on one stack per case.
use horizon_client::{
enchant::{EffectContext, Enchantments},
item_data::StackData,
items::{ItemStack, Items},
};
use horizon_core::random::LegacyRandom;
use horizon_world::loot::PackOverlay;
use serde_json::Value;
use std::{fs, io::Write, path::Path};
pub fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<String> = crate::args().collect();
let input = fs::read_to_string(args.get(1).ok_or("missing INPUT")?)?;
let root = input
.lines()
.find_map(|line| line.strip_prefix("#resources "))
.map(Path::new)
.ok_or("no #resources line")?;
let enchantments = Enchantments::load(root);
let items = Items::catalog(root, &PackOverlay::NONE)?;
let out = fs::File::create(args.get(2).ok_or("missing OUTPUT")?)?;
let mut out = std::io::BufWriter::new(out);
for line in input.lines() {
if line.starts_with("#resources ") || line.trim().is_empty() {
continue;
}
let case: Value = serde_json::from_str(line)?;
let item = case["item"].as_str().unwrap();
let level = case["level"].as_u64().unwrap() as u32;
let component = if case["mode"] == "unbreaking" {
"minecraft:item_damage"
} else {
"minecraft:repair_with_xp"
};
let stack = ItemStack {
item: item.into(),
count: 1,
damage: case["damage"].as_u64().unwrap() as u32,
extra: Some(Box::new(StackData {
enchantments: vec![(
component_enchantment(case["mode"].as_str().unwrap()).into(),
level,
)],
..Default::default()
})),
};
let ctx = EffectContext {
tool: Some(&stack.item),
items: Some(&items),
..Default::default()
};
let mut random = LegacyRandom::new(case["seed"].as_i64().unwrap());
let value = enchantments.modify_value(
Some(&stack),
component,
&ctx,
&mut random,
case["amount"].as_i64().unwrap() as f32,
);
let value = if case["mode"] == "mending" {
value.max(0.0)
} else {
value
};
writeln!(out, "{}", value as i64)?;
}
Ok(())
}
fn component_enchantment(mode: &str) -> &'static str {
if mode == "unbreaking" {
"minecraft:unbreaking"
} else {
"minecraft:mending"
}
}
@@ -0,0 +1,76 @@
//! Environment attribute entries for tools/test_environment_attribute_modifier_parity.py: each
//! case parses an entry for an attribute and applies it to a subject value.
use horizon_world::environment_attributes::{ATTRIBUTES, Attribute, Entry, Kind, Value, attribute};
use std::{error::Error, fmt::Write};
fn bits(f: f32) -> String {
format!("{:08x}", f.to_bits())
}
fn format(value: &Value) -> String {
match value {
Value::Bool(b) => b.to_string(),
Value::Float(f) => bits(*f),
Value::Rgb(v) => v.map(bits).join(" "),
Value::Argb(v) => v.map(bits).join(" "),
Value::Other(json) => json.to_string(),
}
}
fn run(attribute: &Attribute, subject: &str, entry: &str) -> Result<String, Box<dyn Error>> {
let subject = match Entry::parse(attribute, &serde_json::from_str(subject)?)? {
Entry::Override(value) => value,
_ => return Err("the subject is not a plain value".into()),
};
let Ok(entry) = Entry::parse(attribute, &serde_json::from_str(entry)?) else {
return Ok("error".into());
};
let value = entry.apply(&subject);
Ok(format!(
"{}\t{}",
format(&value),
format(&attribute.sanitize(value.clone()))
))
}
/// Every attribute in registration order: id, type, default, and how a float range clamps.
fn registry(output: &mut String) -> std::fmt::Result {
for a in &ATTRIBUTES {
let kind = match a.kind {
Kind::Boolean => "minecraft:boolean",
Kind::Float => "minecraft:float",
Kind::AngleDegrees => "minecraft:angle_degrees",
Kind::RgbColor => "minecraft:rgb_color",
Kind::ArgbColor => "minecraft:argb_color",
Kind::Other => "other",
};
let default = a.default_value();
let range = match default {
Some(Value::Float(_)) => format!(
"{} {}",
format(&a.sanitize(Value::Float(f32::NEG_INFINITY))),
format(&a.sanitize(Value::Float(f32::INFINITY)))
),
_ => "-".into(),
};
let default = default.map_or("unported".into(), |v| format(&v));
writeln!(output, "minecraft:{}\t{kind}\t{default}\t{range}", a.id)?;
}
Ok(())
}
pub fn main() -> Result<(), Box<dyn Error>> {
let args: Vec<String> = crate::args().collect();
let mut output = String::new();
for line in std::fs::read_to_string(&args[1])?.lines() {
if line == "registry" {
registry(&mut output)?;
continue;
}
let fields: Vec<&str> = line.split('\t').collect();
let attribute = attribute(fields[0]).ok_or("unknown attribute")?;
writeln!(output, "{}", run(attribute, fields[1], fields[2])?)?;
}
std::fs::write(&args[2], output)?;
Ok(())
}
@@ -0,0 +1,159 @@
//! Rust side of tools/test_falling_block_parity.py: replays the landing branch
//! of `FallingBlockEntity.tick` over the case's block map through
//! `horizon_world::level::Behaviours::falling_block_landed` and prints the same
//! decision, changed blocks and numeric level events as the Java oracle.
use horizon_core::{position::BlockPos, random::LegacyRandom};
use horizon_world::{
fluid::FluidKind,
level::{Behaviours, Dimension, Landing, LevelAccess, LevelEvent, Ticks, data::load_registry},
registry::BlockStateId,
};
use serde_json::Value;
use std::{
collections::HashMap,
io::{BufRead, Write},
path::Path,
};
struct Cells {
cells: HashMap<BlockPos, BlockStateId>,
air: BlockStateId,
random: LegacyRandom,
events: Vec<String>,
}
impl LevelAccess for Cells {
fn block_state(&self, pos: BlockPos) -> BlockStateId {
self.cells.get(&pos).copied().unwrap_or(self.air)
}
fn set_raw(&mut self, pos: BlockPos, state: BlockStateId) -> Option<BlockStateId> {
let old = self.block_state(pos);
if state == self.air {
self.cells.remove(&pos);
} else {
self.cells.insert(pos, state);
}
Some(old)
}
fn is_loaded(&self, _pos: BlockPos) -> bool {
true
}
fn min_y(&self) -> i32 {
-64
}
fn max_y(&self) -> i32 {
320
}
fn random(&mut self) -> &mut LegacyRandom {
&mut self.random
}
fn game_time(&self) -> i64 {
0
}
fn dimension(&self) -> Dimension {
Dimension::Overworld
}
fn emit(&mut self, event: LevelEvent) {
if let LevelEvent::LevelEvent { id, pos, data } = event
&& id == 1031
{
self.events
.push(format!("L {} {},{},{} {}", id, pos.x, pos.y, pos.z, data));
}
}
}
fn pos(v: &Value) -> BlockPos {
BlockPos::new(
v[0].as_i64().unwrap() as i32,
v[1].as_i64().unwrap() as i32,
v[2].as_i64().unwrap() as i32,
)
}
fn main_landing(case: &Value, b: &Behaviours, air: BlockStateId, out: &mut Vec<String>) {
let mut level = Cells {
cells: HashMap::new(),
air,
random: LegacyRandom::new(case["seed"].as_i64().unwrap()),
events: Vec::new(),
};
for c in case["cells"].as_array().unwrap() {
let state = BlockStateId(c[3].as_i64().unwrap() as u32);
if state != air {
level.cells.insert(pos(c), state);
}
}
let at = pos(&case["pos"]);
let before = level.cells.clone();
let original = BlockStateId(case["state"].as_i64().unwrap() as u32);
// `FallingBlockEntity.fall` drops the WATERLOGGED property before the entity
// carries the state and puts the source's legacy fluid block at the cell;
// `falling_block_fall` does the same for the caller. The case's destination
// state is then restored for the landing probe.
let state = b
.table
.with_bool(original, b.props.waterlogged, false)
.unwrap_or(original);
let destination = level.cells.get(&at).copied();
let legacy = b.legacy_block(b.fluid(original));
if legacy == air {
level.cells.remove(&at);
} else {
level.cells.insert(at, legacy);
}
if let Some(destination) = destination {
level.cells.insert(at, destination);
}
let stuck = matches!(
b.fluid(level.block_state(at)).kind(),
FluidKind::Water | FluidKind::FlowingWater
);
let mut ticks = Ticks::new(0);
let landing = b.falling_block_landed(&mut level, &mut ticks, at, state, stuck);
match landing {
Landing::Placed(placed) => {
out.push(format!("decision placed {}", placed.0));
}
Landing::Broken => out.push("decision broken".into()),
Landing::Continue => out.push("decision continue".into()),
}
let mut changed: Vec<BlockPos> = before
.keys()
.copied()
.chain(level.cells.keys().copied())
.collect();
changed.sort_by_key(|p| (p.x, p.y, p.z));
changed.dedup();
for p in changed {
let now = level.block_state(p);
if before.get(&p).copied().unwrap_or(air) != now {
out.push(format!("set {},{},{} {}", p.x, p.y, p.z, now.0));
}
}
out.extend(level.events);
}
pub fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut args = crate::args().skip(1);
let resources = args.next().expect("resource directory");
let cases = args.next().expect("case file");
let output = args.next().expect("output file");
let resources = Path::new(&resources);
let registry = load_registry(resources)?;
let behaviours = Behaviours::load(&registry, resources)?;
let air = registry.default_state("minecraft:air").unwrap();
let mut out = std::io::BufWriter::new(std::fs::File::create(output)?);
for (index, line) in std::io::BufReader::new(std::fs::File::open(cases)?)
.lines()
.enumerate()
{
let case: Value = serde_json::from_str(&line?)?;
let mut lines = Vec::new();
main_landing(&case, &behaviours, air, &mut lines);
writeln!(out, "== {index}")?;
for line in lines {
writeln!(out, "{line}")?;
}
}
Ok(())
}
@@ -0,0 +1,106 @@
//! Furnace parity (tools/test_furnace_parity.py): reads "furnace KIND TICKS
//! INPUT FUEL OUTPUT TIMER TOTAL LIT LITTOTAL SPEED CHANGE_AT NEW_INPUT"
//! lines, runs the furnace model and prints the slots, timers, LIT state,
//! RecipesUsed counts and each used recipe's experience.
//!
//! The first input line is `#resources RESOURCES`; usage: furnace_fixture INPUT OUTPUT
use horizon_client::{
crafting::Recipes,
furnace::FurnaceCooking,
item_data::{canon_stack, parse_stack},
items::Items,
};
use horizon_client_render::{
chunk::block_entity::{Furnace, FurnaceKind},
providers::Providers,
};
use horizon_world::loot::PackOverlay;
use std::{collections::HashMap, fmt::Write as _, fs};
pub fn main() -> horizon_client::Result<()> {
let args: Vec<String> = crate::args().collect();
let input = fs::read_to_string(args.get(1).ok_or("missing INPUT")?)?;
let root = input
.lines()
.find_map(|line| line.strip_prefix("#resources "))
.map(std::path::PathBuf::from)
.ok_or("no #resources line")?;
let items = Items::catalog(&root, &PackOverlay::NONE)?;
let recipes = Recipes::load(&root, &items, &PackOverlay::NONE)?;
let providers = Providers::load(&root);
let mut out = String::new();
for (n, line) in input
.lines()
.filter(|line| !line.starts_with('#'))
.enumerate()
{
let f: Vec<&str> = line.split(' ').collect();
let kind = match f[1] {
"furnace" => FurnaceKind::Furnace,
"blast_furnace" => FurnaceKind::BlastFurnace,
"smoker" => FurnaceKind::Smoker,
_ => return Err("unknown furnace kind".into()),
};
let ticks: u32 = f[2].parse()?;
let mut items3 = [None, None, None];
for i in 0..3 {
items3[i] = parse_stack(f[3 + i])?;
}
let mut furnace = Furnace {
kind,
items: items3,
lit_remaining: f[8].parse()?,
lit_total: f[9].parse()?,
cooking_timer: f[6].parse()?,
cooking_total: f[7].parse()?,
speed: f[10].parse()?,
recipes_used: HashMap::new(),
};
let change_at: i32 = f[11].parse()?;
let new_input = parse_stack(f[12])?;
let mut lit_state = false;
let mut drops = Vec::new();
for t in 0..ticks {
if t as i32 == change_at {
furnace.set_input(&recipes, new_input.clone());
}
if let Some(lit) = furnace.tick(&recipes, &items, &providers, &mut drops) {
lit_state = lit;
}
}
if change_at == ticks as i32 {
furnace.set_input(&recipes, new_input.clone());
}
write!(out, "{n} kind={} ", f[1])?;
for i in 0..3 {
write!(out, "s{i}={} ", canon_stack(furnace.items[i].as_ref()))?;
}
write!(
out,
"timer={} total={} lit={} littotal={} speed={:.3} state={lit_state} used=",
furnace.cooking_timer,
furnace.cooking_total,
furnace.lit_remaining,
furnace.lit_total,
furnace.speed
)?;
let mut ids: Vec<&String> = furnace.recipes_used.keys().collect();
ids.sort();
for (i, id) in ids.iter().enumerate() {
if i > 0 {
out.push(',');
}
write!(out, "{id}={}", furnace.recipes_used[*id])?;
}
out.push_str(" xp=");
for (i, id) in ids.iter().enumerate() {
if i > 0 {
out.push(',');
}
write!(out, "{id}={}", recipes.cooking_experience(id))?;
}
writeln!(out)?;
}
fs::write(args.get(2).ok_or("missing OUTPUT")?, out)?;
Ok(())
}
@@ -39,18 +39,22 @@ mod creative_mining_probe;
mod creative_search_fixture;
mod cushion_placement_probe;
mod emitter_particles_probe;
mod enchant_durability_fixture;
mod enchanting_fixture;
mod entity_anim_fixture;
mod entity_components_probe;
mod entity_death_loot_fixture;
mod entity_particles_probe;
mod entity_sound_packet_fixture;
mod environment_attribute_modifier_fixture;
mod environment_fixture;
mod falling_block_fixture;
mod finalize_spawn_fixture;
mod fire_starter_sound_probe;
mod firework_tooltip_fixture;
mod first_person_probe;
mod frame_limiter_probe;
mod furnace_fixture;
mod game_loop_probe;
mod ghost_recipe_fixture;
mod held_drop_fixture;
@@ -101,6 +105,7 @@ mod spawn_rules_fixture;
mod sprint_probe;
mod squid_client_probe;
mod stack_foil_fixture;
mod stack_mining_probe;
mod storage_menu_fixture;
mod subtitle_fixture;
mod tab_list_fixture;
@@ -109,6 +114,7 @@ mod text_display_fixture;
mod text_display_render_fixture;
mod toggle_keys_probe;
mod tooltip_display_fixture;
mod trade_components_probe;
mod trading_fixture;
mod translucency_fixture;
mod trident_probe;
@@ -128,12 +134,14 @@ probes! {
block_entity_tickers_fixture,
block_particles_fixture,
block_transform_fixture,
furnace_fixture,
brewing_fixture,
breaking_fixture,
block_tint_probe,
camera_probe,
squid_client_probe,
creative_mining_probe,
stack_mining_probe,
cushion_placement_probe,
cauldron_fixture,
chat_screen_fixture,
@@ -146,11 +154,14 @@ probes! {
conversion_tags_fixture,
creative_search_fixture,
emitter_particles_probe,
enchant_durability_fixture,
enchanting_fixture,
falling_block_fixture,
entity_anim_fixture,
entity_components_probe,
entity_death_loot_fixture,
entity_particles_probe,
environment_attribute_modifier_fixture,
environment_fixture,
finalize_spawn_fixture,
fire_starter_sound_probe,
@@ -217,6 +228,7 @@ probes! {
text_display_fixture,
text_display_render_fixture,
trading_fixture,
trade_components_probe,
trident_probe,
translucency_fixture,
stack_foil_fixture,
@@ -204,6 +204,7 @@ fn goal_name(m: &Mob, name: &str) -> String {
"Float" => "FloatGoal",
"RandomFloatAround" => "RandomFloatAroundGoal",
"GhastLook" => "GhastLookGoal",
"Peek" => "ShulkerPeekGoal",
"GhastShoot" => "GhastShootFireballGoal",
"Stroll" => "WaterAvoidingRandomStrollGoal",
"ParrotWander" => "ParrotWanderGoal",
@@ -642,7 +643,7 @@ fn player_hurt(player: Option<&PlayerView>, e: &MobEvent) -> bool {
..
} => true,
// ServerExplosion.hurtEntities: within twice the radius every hit does damage.
MobEvent::Explode(at, radius, _) => {
MobEvent::Explode(at, radius, _, _) => {
let d = Vec3::new(at[0], at[1], at[2])
.distance_squared(p.position)
.sqrt();
@@ -1068,6 +1069,13 @@ pub fn main() -> horizon_client::Result<()> {
// Set once the brain exists (below).
x if x.starts_with("cooldown=") => {}
"dead" => m.health = 0.0,
// effect=<name>:<amplifier>, never running out.
x if x.starts_with("effect=") => {
let (name, amp) = x[7..].split_once(':').unwrap();
let name: &'static str =
Box::leak(format!("minecraft:{name}").into_boxed_str());
m.add_effect(name, -1, amp.parse().unwrap());
}
"leashed" => m.extra.leash = Some(horizon_client::mobs::leash::Holder::Player),
x if x.starts_with("leashto=") => {
let n: u32 = x[8..].parse().unwrap();
@@ -1208,7 +1216,7 @@ pub fn main() -> horizon_client::Result<()> {
day_time: world.day_time,
raining: s.rain,
thundering: s.thunder,
nether: false,
level: horizon_core::LevelKey::OVERWORLD,
block_tags: &items.block_tags,
landing: &motion.landing,
biomes: &biomes,
@@ -1217,7 +1225,7 @@ pub fn main() -> horizon_client::Result<()> {
mob_drops: true,
max_entity_cramming: 24,
difficulty: LocalDifficulty::new(s.difficulty, world.day_time, 0, 0.0),
end: false,
dimension_type: horizon_world::dimension_type::DimensionFacts::overworld(),
world_seed: 0,
// FlatLevelSource.getSeaLevel.
sea_level: -63,
@@ -30,7 +30,7 @@ use horizon_client::{
mobs::{
MobTypes, Species,
data::MobData,
spawn_rules::{Dimension, SpawnLevel},
spawn_rules::SpawnLevel,
spawn_selection::{
self, Category, Counted, Event, GenEvent, Heightmap, NaturalLevel, SpawnCost,
SpawnPlayer, SpawnRequest, SpawnState, Spawned, StructureAt, StructureSpawns,
@@ -49,12 +49,13 @@ use horizon_client::{
vault::{Vault, VaultEvent, VaultLevel, VaultPlayer, VaultState},
};
use horizon_core::{
LevelKey,
geometry::Aabb,
random::{LegacyRandom, RandomSource},
};
use horizon_world::{
block_collision::BlockCollisionRegistry, chunk::BlockState, loot::PackOverlay, nbt::Tag,
registry::BlockStateId,
block_collision::BlockCollisionRegistry, chunk::BlockState, dimension_type::DimensionFacts,
loot::PackOverlay, nbt::Tag, registry::BlockStateId,
};
use serde_json::Value;
use std::{
@@ -98,6 +99,7 @@ struct Shared<'a> {
entity_types: &'a HashSet<String>,
}
struct World<'a> {
border: horizon_core::geometry::border::BorderGeometry,
s: &'a Shared<'a>,
blocks: Vec<(Bx, BlockStateId)>,
lights: Vec<(Bx, i32, i32)>,
@@ -110,7 +112,7 @@ struct World<'a> {
sea: i32,
seed: i64,
peaceful: bool,
dimension: Dimension,
dimension: &'static DimensionFacts,
dimension_id: String,
slime: f32,
starts: Vec<Start>,
@@ -158,6 +160,10 @@ impl World<'_> {
}
}
impl SpawnLevel for World<'_> {
fn within_world_border(&self, x: i32, z: i32) -> bool {
self.border
.is_within_bounds(f64::from(x), f64::from(z), 0.0)
}
fn state(&self, x: i32, y: i32, z: i32) -> BlockStateId {
if let Some((_, s)) = self
.placed
@@ -214,7 +220,7 @@ impl SpawnLevel for World<'_> {
fn peaceful(&self) -> bool {
self.peaceful
}
fn dimension(&self) -> Dimension {
fn dimension_type(&self) -> &DimensionFacts {
self.dimension
}
fn surface_slime_chance(&self, _: i32, _: i32, _: i32) -> f32 {
@@ -232,28 +238,24 @@ impl SpawnLevel for World<'_> {
}
impl NaturalLevel for World<'_> {
fn min_y(&self) -> i32 {
if self.dimension == Dimension::Overworld {
if self.dimension.id == "minecraft:overworld" {
-64
} else {
0
}
}
fn max_y(&self) -> i32 {
if self.dimension == Dimension::Overworld {
if self.dimension.id == "minecraft:overworld" {
319
} else {
255
}
}
fn ambient_light(&self) -> f32 {
match self.dimension {
Dimension::Overworld => 0.0,
Dimension::Nether => 0.1,
Dimension::End => 0.25,
}
self.dimension.ambient_light
}
fn has_ceiling(&self) -> bool {
self.dimension == Dimension::Nether
self.dimension.id == "minecraft:the_nether"
}
fn mob_type(&self, entity: &str) -> Option<(Species, f32, f32, Category)> {
let t = self.s.types.types.get(entity)?;
@@ -316,6 +318,8 @@ impl NaturalLevel for World<'_> {
}
fn can_spawn_in_chunk(&self, cx: i32, cz: i32) -> bool {
!self.unloaded.contains(&(cx, cz))
&& self.within_world_border(cx << 4, cz << 4)
&& self.within_world_border((cx << 4).wrapping_add(15), (cz << 4).wrapping_add(15))
}
fn no_collision(&self, bounds: Aabb) -> bool {
let lookup = |x: i32, y: i32, z: i32| self.state(x, y, z);
@@ -664,11 +668,8 @@ pub fn main() -> horizon_client::Result<()> {
})
.collect::<Vec<_>>();
let dimension_id = get("dim", "minecraft:overworld");
let dimension = match dimension_id.as_str() {
"minecraft:the_nether" => Dimension::Nether,
"minecraft:the_end" => Dimension::End,
_ => Dimension::Overworld,
};
let dimension =
DimensionFacts::of_builtin_level(LevelKey::builtin_or_overworld(&dimension_id));
let respawn = {
let r = get("respawn", "0,-1000,0/minecraft:overworld");
let (p, d) = r.split_once('/').ok_or("bad respawn")?;
@@ -709,6 +710,11 @@ pub fn main() -> horizon_client::Result<()> {
));
}
let world = World {
border: horizon_core::geometry::border::BorderGeometry::new(
[get("borderx", "0").parse()?, get("borderz", "0").parse()?],
get("bordersize", "59999968").parse()?,
29_999_984,
)?,
s: &shared,
blocks,
lights,
@@ -885,15 +891,17 @@ pub fn main() -> horizon_client::Result<()> {
let categories = state
.filtered_categories(get("enemies", "1") == "1", get("persistent", "1") == "1");
trace(Event::Categories(categories.clone()));
spawn_selection::spawn_for_chunk(
&world,
&mut random,
&mut state,
&categories,
chunk,
&mut spawn,
&mut trace,
);
if mode != "tick_chunk" || world.can_spawn_in_chunk(chunk.0, chunk.1) {
spawn_selection::spawn_for_chunk(
&world,
&mut random,
&mut state,
&categories,
chunk,
&mut spawn,
&mut trace,
);
}
}
log.push(format!("next {}", random.next_int_bound(1000).unwrap_or(0)));
}
@@ -11,14 +11,16 @@ use horizon_client::{
mobs::{
Species,
data::MobData,
spawn_rules::{self, Dimension, SpawnLevel},
spawn_rules::{self, SpawnLevel},
spawn_selection::{self, Heightmap},
spawning::LocalDifficulty,
},
patrol::{self, GameplayAttributes, Member, PatrolLevel, PatrolSpawner},
raid::{self, Action, BarName, RaidLevel, RaidPlayer, RaiderInfo, Raids},
};
use horizon_core::LevelKey;
use horizon_core::random::{LegacyRandom, RandomSource};
use horizon_world::dimension_type::DimensionFacts;
use horizon_world::loot::PackOverlay;
use horizon_world::{chunk::BlockState, nbt::Tag, registry::BlockStateId};
use std::{
@@ -74,7 +76,7 @@ struct World<'a> {
light: i32,
darken: i32,
difficulty: i32,
dimension: Dimension,
dimension: &'static DimensionFacts,
dimension_id: String,
unloaded: HashSet<(i32, i32)>,
no_ticking: HashSet<(i32, i32)>,
@@ -96,14 +98,14 @@ impl World<'_> {
.map_or((self.sky, self.light), |(_, s, b)| (*s, *b))
}
fn min_y(&self) -> i32 {
if self.dimension == Dimension::Overworld {
if self.dimension.id == "minecraft:overworld" {
-64
} else {
0
}
}
fn max_y(&self) -> i32 {
if self.dimension == Dimension::Overworld {
if self.dimension.id == "minecraft:overworld" {
319
} else {
255
@@ -175,7 +177,7 @@ impl SpawnLevel for World<'_> {
fn peaceful(&self) -> bool {
self.difficulty == 0
}
fn dimension(&self) -> Dimension {
fn dimension_type(&self) -> &DimensionFacts {
self.dimension
}
fn surface_slime_chance(&self, _: i32, _: i32, _: i32) -> f32 {
@@ -193,7 +195,7 @@ impl PatrolLevel for World<'_> {
self.patrols
}
fn bright_outside(&self) -> bool {
self.dimension == Dimension::Overworld && self.darken < 4
self.dimension.id == "minecraft:overworld" && self.darken < 4
}
fn patrol_players(&self) -> Vec<([i32; 3], bool)> {
self.player
@@ -548,11 +550,8 @@ pub fn main() -> horizon_client::Result<()> {
.unwrap_or_default()
};
let dimension_id = get("dim", "minecraft:overworld");
let dimension = match dimension_id.as_str() {
"minecraft:the_nether" => Dimension::Nether,
"minecraft:the_end" => Dimension::End,
_ => Dimension::Overworld,
};
let dimension =
DimensionFacts::of_builtin_level(LevelKey::builtin_or_overworld(&dimension_id));
let player = c.get("p0").map(|p| {
let mut parts = p.split('/');
let v: Vec<f64> = parts
@@ -75,6 +75,18 @@ pub fn main() -> horizon_client::Result<()> {
.collect::<Vec<_>>()
.join(";"),
},
"smith_transform" => {
let files = vec![(
"recipe/test:transform".to_owned(),
vec![f[1].as_bytes().to_vec()],
)];
let stations = StationRecipes::load(root, &items, &PackOverlay { files: &files });
let (t, b, a) = (stack(2)?, stack(3)?, stack(4)?);
match stations.smithing_result(&enchantments, t.as_ref(), b.as_ref(), a.as_ref()) {
None => "none".to_owned(),
Some(r) => canon_stack(Some(&r)),
}
}
"smith" => {
let (t, b, a) = (stack(1)?, stack(2)?, stack(3)?);
match stations.smithing_result(&enchantments, t.as_ref(), b.as_ref(), a.as_ref()) {
@@ -10,14 +10,16 @@ use horizon_client::{
mobs::{
MobTypes,
data::MobData,
spawn_rules::{self, Dimension, Reason, SpawnLevel},
spawn_rules::{self, Reason, SpawnLevel},
spawn_selection::{
self, Heightmap, MobType as SelectionMobType, NaturalLevel, SpawnCost, StructureAt,
StructureSpawns, spawn_aabb,
},
},
};
use horizon_core::LevelKey;
use horizon_core::random::{LegacyRandom, RandomSource};
use horizon_world::dimension_type::DimensionFacts;
use horizon_world::loot::PackOverlay;
use horizon_world::{chunk::BlockState, registry::BlockStateId};
use std::{
@@ -28,6 +30,7 @@ use std::{
};
struct Column<'a> {
border: horizon_core::geometry::border::BorderGeometry,
facts: &'a BlockFacts,
data: &'a MobData,
block_tags: &'a HashMap<String, HashSet<String>>,
@@ -43,7 +46,7 @@ struct Column<'a> {
surface: i32,
seed: i64,
peaceful: bool,
dimension: Dimension,
dimension: &'static DimensionFacts,
biome: String,
slime: f32,
/// A player position and whether it is survival or adventure.
@@ -53,6 +56,10 @@ struct Column<'a> {
structure_spawns: StructureSpawns,
}
impl SpawnLevel for Column<'_> {
fn within_world_border(&self, x: i32, z: i32) -> bool {
self.border
.is_within_bounds(f64::from(x), f64::from(z), 0.0)
}
fn state(&self, x: i32, y: i32, z: i32) -> BlockStateId {
if x != self.x0 || z != self.z0 {
return self.air;
@@ -95,7 +102,7 @@ impl SpawnLevel for Column<'_> {
fn peaceful(&self) -> bool {
self.peaceful
}
fn dimension(&self) -> Dimension {
fn dimension_type(&self) -> &DimensionFacts {
self.dimension
}
fn surface_slime_chance(&self, _: i32, _: i32, _: i32) -> f32 {
@@ -264,6 +271,11 @@ pub fn main() -> horizon_client::Result<()> {
)
});
let level = Column {
border: horizon_core::geometry::border::BorderGeometry::new(
[get("borderx", "0").parse()?, get("borderz", "0").parse()?],
get("bordersize", "59999968").parse()?,
29_999_984,
)?,
facts: &facts,
data: &data,
block_tags: &items.block_tags,
@@ -279,11 +291,10 @@ pub fn main() -> horizon_client::Result<()> {
surface: get("surface", "0").parse()?,
seed: get("wseed", "0").parse()?,
peaceful: get("difficulty", "normal") == "peaceful",
dimension: match get("dim", "minecraft:overworld").as_str() {
"minecraft:the_nether" => Dimension::Nether,
"minecraft:the_end" => Dimension::End,
_ => Dimension::Overworld,
},
dimension: DimensionFacts::of_builtin_level(LevelKey::builtin_or_overworld(&get(
"dim",
"minecraft:overworld",
))),
biome: get("biome", "minecraft:plains"),
slime: get("slime", "0.0").parse()?,
player,
@@ -0,0 +1,56 @@
use horizon_client::{
Result, item_data,
items::{ItemStack, Items},
};
use horizon_items::item_nbt;
use horizon_world::loot::PackOverlay;
use std::{
fs::File,
io::{BufRead, BufReader, BufWriter, Write},
path::Path,
};
fn query(items: &Items, stack: &ItemStack, block: &str) -> String {
format!(
"{} {} {} {}",
items.mining_speed(Some(stack), block).to_bits(),
items.correct_for_drops(Some(stack), block),
items
.mining_damage(stack)
.map_or_else(|| "none".into(), |n| n.to_string()),
items.mining_damage_capacity(stack)
)
}
pub fn main() -> Result<()> {
let args: Vec<_> = crate::args().collect();
let root = Path::new(&args[3]);
let items = Items::catalog(root, &PackOverlay::NONE)?;
let mut out = BufWriter::new(File::create(&args[2])?);
for line in BufReader::new(File::open(&args[1])?).lines() {
let row: serde_json::Value = serde_json::from_str(&line?)?;
let input = row["item"].as_str().unwrap();
let (id, components) = input
.find('[')
.map_or((input, None), |i| (&input[..i], Some(&input[i..])));
let mut stack = ItemStack::new(id, 1);
stack.count = row["count"].as_u64().unwrap_or(1) as u32;
if let Some(components) = components {
let tag = item_data::command_components(components)?;
(stack.damage, stack.extra) = item_data::decode_components(Some(&tag));
}
let block = row["block"].as_str().unwrap();
let restored = if stack.count == 0 || id == "minecraft:air" {
stack.clone()
} else {
item_nbt::item_from_nbt(&item_nbt::item_to_nbt(&stack)).unwrap()
};
writeln!(
out,
"{} | {}",
query(&items, &stack, block),
query(&items, &restored, block)
)?;
}
Ok(())
}
@@ -0,0 +1,70 @@
//! Headless trade costs/modifiers: no game, window or server is started.
use horizon_client::{
enchant::Enchantments,
items::{ItemStack, Items},
merchant::{TradeContext, Trades},
};
use horizon_core::random::{LegacyRandom, RandomSource};
use horizon_world::loot::PackOverlay;
use serde_json::{Value, json};
use std::{collections::HashMap, fs, path::Path};
pub fn main() -> horizon_client::Result<()> {
let args: Vec<_> = crate::args().collect();
let root = Path::new(&args[1]);
let items = Items::catalog(root, &PackOverlay::NONE)?;
let enchantments = Enchantments::load(root);
let trades = Trades {
trades: HashMap::new(),
tags: HashMap::new(),
sets: HashMap::new(),
potion_tags: HashMap::new(),
};
let ctx = TradeContext {
items: &items,
enchantments: &enchantments,
variant: "minecraft:plains",
origin: [0.0; 3],
maps: None,
};
let mut output = String::new();
for line in fs::read_to_string(&args[2])?.lines() {
let v: Value = serde_json::from_str(line)?;
let mut random = LegacyRandom::new(v["seed"].as_i64().unwrap());
let o = trades.offer_from(&ctx, &v["trade"], &mut random);
let result = if let Some(o) = o {
let mut levels = o.result.crafting_enchantments().to_vec();
levels.sort_by(|a, b| a.0.cmp(&b.0));
let payments: Vec<_> = ["plain", "renamed", "wrong", "extra_potion"]
.iter()
.map(|kind| {
let mut p =
ItemStack::new("minecraft:potion", 2).with_potion(if *kind == "wrong" {
"minecraft:awkward"
} else {
"minecraft:water"
});
if *kind == "renamed" {
p.data_mut()
.set_loot_component("minecraft:custom_name", &json!("Bottle"));
}
if *kind == "extra_potion" {
p.data_mut()
.potion_extra
.push(("custom_color".into(), horizon_world::nbt::Tag::Int(123)));
}
let mut a = Some(p);
let accepted = o.take(&items, &mut a, &mut None);
json!([accepted, a.map_or(0, |s| s.count)])
})
.collect();
json!({"item": &*o.result.item, "count": o.result.count, "levels": levels.iter().map(|(id,l)| json!([&**id,l])).collect::<Vec<_>>(), "a": o.base_cost_a.stack.count, "b": o.cost_b.as_ref().map(|b| b.stack.count), "payments": payments})
} else {
Value::Null
};
output.push_str(&json!({"result": result, "next":random.next_long()}).to_string());
output.push('\n');
}
fs::write(&args[3], output)?;
Ok(())
}
+96 -7
View File
@@ -638,6 +638,9 @@ pub struct ItemView {
pub enchantments: Vec<(String, u32)>,
pub potion: Option<String>,
pub custom_name: bool,
/// Typed snapshot for supported mob equipment views. `None` leaves this
/// family unprojected and unsupported; an empty compound is actual absence.
pub custom_data: Option<horizon_world::nbt::Tag>,
}
/// An entity as predicates see it.
#[derive(Clone, Debug, Default)]
@@ -689,13 +692,17 @@ pub struct EntityView {
/// real holder without scores. Named predicates snapshot current values.
pub scores: Option<BTreeMap<String, i32>>,
}
/// What `PlayerPredicate` reads of a player beyond its entity. Each part is
/// filled only when a criterion may ask for it (`Tree::player_facts_asked`);
/// a predicate that asks for a part left `None` fails.
/// What `PlayerPredicate` reads of a player beyond its entity. Each record
/// is filled only when a criterion may ask for it (`Tree::player_facts_asked`),
/// the food always (two numbers); a predicate that asks for a part left
/// `None` fails.
#[derive(Clone, Default)]
pub struct PlayerFacts {
/// `Player.experienceLevel`.
pub level: Option<i32>,
/// `Player.getFoodData`: `FoodData.getFoodLevel` and
/// `FoodData.getSaturationLevel`.
pub food: Option<(i32, f32)>,
/// `ServerStatsCounter`: stat type -> stat id -> value, both namespaced.
pub stats: Option<BTreeMap<String, BTreeMap<String, i32>>>,
/// `PlayerAdvancements`: the loaded advancements and the player's
@@ -708,6 +715,7 @@ impl std::fmt::Debug for PlayerFacts {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PlayerFacts")
.field("level", &self.level)
.field("food", &self.food)
.field("stats", &self.stats)
.field(
"advancements",
@@ -847,6 +855,20 @@ pub fn item(v: &Value, it: &ItemView, w: &dyn World) -> bool {
return false;
}
}
// `CustomDataPredicate.matches`: typed subset matching of a projected
// stack snapshot. Unprojected consumers keep their unsupported behavior.
if let Some(pattern) = keyed(preds, "minecraft:custom_data").and_then(Value::as_str)
&& let Some(actual) = it.custom_data.as_ref()
{
let Ok(pattern @ horizon_world::nbt::Tag::Compound(_)) =
crate::command::snbt::parse_fully(pattern)
else {
return false;
};
if !crate::nbt_path::compare_nbt(&pattern, Some(actual)) {
return false;
}
}
true
}
/// `LockCode.canUnlock`: a spectator opens any container; otherwise the
@@ -1093,15 +1115,29 @@ pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> boo
true
}
/// The rest of `PlayerPredicate.matches` (expected from it): the experience
/// level in the `level` bounds; each `stats` entry's value (0 when never
/// counted) in its bounds; each listed recipe known or not as asked; each
/// listed advancement loaded, and done or not, or each named criterion of it
/// granted or not. A field asked of facts that weren't filled fails.
/// level in the `level` bounds; the food (`FoodPredicate.matches`); each
/// `stats` entry's value (0 when never counted) in its bounds; each listed
/// recipe known or not as asked; each listed advancement loaded, and done or
/// not, or each named criterion of it granted or not. A field asked of facts
/// that weren't filled fails.
fn player_facts(p: &Value, f: &PlayerFacts) -> bool {
let level = &p["level"];
if !level.is_null() && !f.level.is_some_and(|l| range_i(level, i64::from(l))) {
return false;
}
// `FoodPredicate.matches`: the food level in `level`, then the
// saturation (a float, compared as a double) in `saturation`.
let food = &p["food"];
if !food.is_null() {
let Some((level, saturation)) = f.food else {
return false;
};
if !range_i(&food["level"], i64::from(level))
|| !range_f(&food["saturation"], f64::from(saturation))
{
return false;
}
}
let stats = &p["stats"];
if !stats.is_null() {
let Some(list) = stats.as_array() else {
@@ -2811,6 +2847,59 @@ mod tests {
assert!(player_matches(json!({}), &PlayerFacts::default()));
}
#[test]
fn player_food_tests_level_and_saturation_in_their_bounds() {
let fed = PlayerFacts {
food: Some((17, 3.0)),
..PlayerFacts::default()
};
for (food, expected) in [
(json!({}), true),
(json!({"level": {"min": 17}}), true),
(json!({"level": {"min": 18}}), false),
(json!({"level": {"max": 16}}), false),
(json!({"level": 17}), true),
(json!({"saturation": {"min": 3.0}}), true),
(json!({"saturation": {"min": 3.5}}), false),
(json!({"saturation": {"max": 2.9}}), false),
(
json!({"level": {"min": 17}, "saturation": {"max": 3.0}}),
true,
),
(
json!({"level": {"min": 17}, "saturation": {"min": 3.5}}),
false,
),
(
json!({"level": {"min": 18}, "saturation": {"min": 3.0}}),
false,
),
] {
assert_eq!(
player_matches(json!({"food": food}), &fed),
expected,
"{food}"
);
}
// The saturation float widens to a double: 1.2f is 1.2000000476837158.
let widened = PlayerFacts {
food: Some((20, 1.2)),
..PlayerFacts::default()
};
assert!(player_matches(
json!({"food": {"saturation": {"min": 1.2}}}),
&widened
));
assert!(!player_matches(
json!({"food": {"saturation": {"max": 1.2}}}),
&widened
));
// No food known: food asked fails, even unbounded.
assert!(!player_matches(
json!({"food": {}}),
&PlayerFacts::default()
));
}
#[test]
fn player_stats_match_each_counter_by_type_and_id() {
let mut counts: BTreeMap<String, BTreeMap<String, i32>> = BTreeMap::new();
(counts.entry("minecraft:custom".into()).or_default())
+8 -7
View File
@@ -3,7 +3,8 @@
//! `minecraft:audio/ambient_sounds` attribute, `UnderwaterAmbientSoundHandler`
//! and `LocalPlayer.updateIsUnderwater` (enter/exit sounds and the
//! `UnderLiquidAmbientSoundInstance` loop), played through the sound engine.
use crate::sound_engine::{AMBIENT, InstanceId, PLAYERS, SoundEngine, SoundInstance, Sounds};
use crate::random_source;
use crate::sound_engine::{AMBIENT, InstanceId, PLAYERS, SoundInstance, Sounds};
use horizon_core::random::{LegacyRandom, RandomSource};
use serde_json::Value;
use std::{collections::HashMap, path::Path};
@@ -160,7 +161,7 @@ pub struct Ambience {
}
impl Default for Ambience {
fn default() -> Self {
Self::new(crate::sound_engine::unique_seed())
Self::new(random_source::unique_seed())
}
}
impl Ambience {
@@ -225,7 +226,7 @@ impl Ambience {
);
let instance = SoundInstance::under_liquid_loop(
"minecraft:ambient.underwater.loop",
SoundEngine::unseeded(),
random_source::create(),
);
sounds.play_instance(instance, false);
}
@@ -276,7 +277,7 @@ impl Ambience {
};
if let Some(sound) = sound {
self.underwater_delay = 0;
let instance = SoundInstance::under_liquid_sub(sound, SoundEngine::unseeded());
let instance = SoundInstance::under_liquid_sub(sound, random_source::create());
sounds.play_instance(instance, false);
}
}
@@ -308,7 +309,7 @@ impl Ambience {
let id = match self.loops.iter().find(|(e, _)| *e == event) {
Some((_, id)) => *id,
None => {
let instance = SoundInstance::biome_loop(&event, SoundEngine::unseeded());
let instance = SoundInstance::biome_loop(&event, random_source::create());
let id = sounds.engine().add(instance, true);
sounds.play_id(id);
self.loops.push((event, id));
@@ -323,7 +324,7 @@ impl Ambience {
for (sound, chance) in &ctx.sounds.additions {
if self.random.next_double() < *chance {
let event = crate::sound_events::normalize(sound);
let instance = SoundInstance::for_ambient_addition(&event, SoundEngine::unseeded());
let instance = SoundInstance::for_ambient_addition(&event, random_source::create());
sounds.play_instance(instance, false);
}
}
@@ -376,7 +377,7 @@ impl Ambience {
#[cfg(test)]
mod tests {
use super::*;
use crate::sound_engine::{Bound, TickContext, test_backend::Null};
use crate::sound_engine::{Bound, SoundEngine, TickContext, test_backend::Null};
use crate::sound_events::SoundRegistry;
fn engine() -> SoundEngine {
+137
View File
@@ -0,0 +1,137 @@
//! Existing pure bed geometry shared by gameplay and the reusable Mob layer.
use horizon_core::geometry::Vec3;
/// The part of a bed a sleeper lies on: `AbstractBedBlock.getSleepHeight`
/// answers with the shape of the block it is given, and `StrawBedBlock`
/// first walks a head over to its foot, so a straw bed is slept on at its
/// foot's shape while the head's own (the pillow's) is taller.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SleepOn {
/// The head itself: `BedBlock` and every other bed.
Head,
/// The foot the straw bed's head lies against.
Foot,
}
/// `StrawBedBlock.getSleepHeight` then `AbstractBedBlock.getSleepHeight`: the
/// part whose shape the sleeper lies on, or no part at all. A straw bed's
/// head takes its foot's block, and only a foot of the same block will do:
/// with the foot broken, or replaced by something else, the height is empty
/// (`OptionalDouble.empty`) and `LivingEntity.startSleeping` returns false,
/// so nobody lies in the half that is left.
pub fn slept_on(
bed: &str,
part: Option<&str>,
foot: Option<(&str, Option<&str>)>,
) -> Option<SleepOn> {
if bed != "minecraft:straw_bed" || part != Some("head") {
return Some(SleepOn::Head);
}
match foot {
Some(("minecraft:straw_bed", Some("foot"))) => Some(SleepOn::Foot),
_ => None,
}
}
/// `VoxelShape.max(Axis.Y)` of the boxes the pack gives a block, in the
/// block's own coordinates: the top of the bed, `1.0` for a full cube, and
/// `None` for a shape with nothing in it (`VoxelShape.isEmpty`).
pub fn shape_top(boxes: &[[f64; 6]], base_y: f64) -> Option<f64> {
boxes
.iter()
.map(|b| b[4] - base_y)
.fold(None, |top: Option<f64>, y| {
Some(top.map_or(y, |top| top.max(y)))
})
}
/// `LivingEntity.setPosToBed`: the middle of the sleeper's block, `0.125`
/// above the top of the shape, which `getSleepHeight` measured from that
/// block's own y.
pub fn pos_to_bed(head: [i32; 3], height: f64) -> Vec3 {
Vec3::new(
f64::from(head[0]) + 0.5,
f64::from(head[1]) + height + 0.125,
f64::from(head[2]) + 0.5,
)
}
#[cfg(test)]
mod tests {
use super::{SleepOn, pos_to_bed, shape_top, slept_on};
/// `StrawBedBlock.getSleepHeight`: a straw bed's head is slept on at its
/// foot, and one whose foot is gone - or is not a straw bed's foot any
/// more - has no height, so `startSleeping` gives up on it.
#[test]
fn a_straw_beds_head_is_slept_on_at_its_foot() {
let straw = "minecraft:straw_bed";
assert_eq!(slept_on(straw, Some("head"), None), None, "missing foot");
assert_eq!(
slept_on(straw, Some("head"), Some((straw, None))),
None,
"missing foot part"
);
assert_eq!(
slept_on(straw, Some("head"), Some((straw, Some("foot")))),
Some(SleepOn::Foot)
);
assert_eq!(
slept_on(straw, Some("head"), Some((straw, Some("head")))),
None,
"a head facing another head"
);
assert_eq!(
slept_on(straw, Some("head"), Some(("minecraft:oak_planks", None))),
None,
"the foot was replaced"
);
assert_eq!(
slept_on(
straw,
Some("head"),
Some(("minecraft:red_bed", Some("foot")))
),
None,
"the foot is another bed"
);
assert_eq!(
slept_on(straw, Some("foot"), None),
Some(SleepOn::Head),
"a straw bed's foot sleeps on its own shape"
);
assert_eq!(
slept_on("minecraft:red_bed", Some("head"), None),
Some(SleepOn::Head),
"an ordinary bed's head is its own shape"
);
}
/// `AbstractBedBlock.getSleepHeight`: the tallest box of the shape, from
/// the block's own y; a shape with nothing in it has no height.
#[test]
fn the_height_is_the_top_of_the_shape() {
// The boxes are the pack's, in world space, as `outline_boxes` gives them.
let bed = [[10.0, 70.0, 5.0, 11.0, 70.5625, 6.0]];
assert_eq!(shape_top(&bed, 70.0), Some(0.5625));
let pillow = [[10.0, 70.0, 5.0, 11.0, 70.6875, 6.0]];
assert_eq!(shape_top(&pillow, 70.0), Some(0.6875));
let two = [
[10.0, 70.0, 5.0, 11.0, 70.5625, 6.0],
[10.0, 70.0, 5.0, 10.5, 70.25, 5.5],
];
assert_eq!(shape_top(&two, 70.0), Some(0.5625), "the tallest box");
assert_eq!(shape_top(&[], 70.0), None, "a bed with no shape");
}
/// `setPosToBed`: `0.125` above the height, in the middle of the block.
/// A straw bed's head slept on a foot `7/16` tall at y 70 puts the player
/// at 70.5625, where the head's own `9/16` (or the flat 0.5625 every bed
/// was given) had them at 70.6875.
#[test]
fn the_sleeper_lies_an_eighth_above_the_shape() {
let at = pos_to_bed([10, 70, 5], 0.4375);
assert_eq!((at.x, at.y, at.z), (10.5, 70.5625, 5.5));
assert_eq!(pos_to_bed([10, 70, 5], 0.5625).y, 70.6875);
}
}
+3 -2
View File
@@ -6,6 +6,7 @@ pub mod advancements;
pub mod ambience;
pub mod animate_tick;
pub mod arrows;
pub mod bed_geometry;
pub mod block_contact;
pub mod powder_snow;
pub use horizon_client_render::{
@@ -19,8 +20,8 @@ pub use horizon_client_render::{
level_loading, light, lod, logging, menu_blur, mesher, minimap, mipmap, mod_browser, mod_list,
model_library, models, multiline_edit, native_emissive, options_file, options_fixes,
options_ui, overlay, pack_browser, pack_screen, packs, pad_cursor, pad_glyph, par, particles,
poi_sections, post_chain, potent_sulfur, preedit, providers, pvp, pvp_hud, screen_draw,
screen_effects, sculk, section_mask, server_terrain, settings, shader_options,
poi_sections, post_chain, potent_sulfur, preedit, providers, pvp, pvp_hud, random_source,
screen_draw, screen_effects, sculk, section_mask, server_terrain, settings, shader_options,
shader_pack_list, signs, skins, sound_engine, sound_events, sound_preview, spans, split_layout,
split_map, split_profiles, sprites, streaming, structure_block, tab_list, telemetry,
terrain_model, test_block, test_instance_block, testgen, title_edition, trims, ui, vault,
+97 -20
View File
@@ -1,5 +1,5 @@
//! Non-player entities simulated on the client world. Dropped items follow
//! `ItemEntity.tick`: gravity 0.04, 0.98 drag (block friction on the ground),
//! `ItemEntity.tick`: gravity 0.04, 0.98F drag (block friction on the ground),
//! a ground bounce of -0.5, slime landing restitution (0.8, including a moving
//! piston that is carrying slime), water buoyancy, pickup delay, 6000-tick despawn,
//! `HoneyBlock.entityInside` when falling against a honey block's side, and
@@ -524,9 +524,25 @@ impl ItemEntity {
spilled.extend(crate::item_data::destroyed_container(&mut self.stack));
Some(spilled)
}
/// The ItemEntity snapshot used by both its move and its noCollision check.
fn collision_context(&self) -> horizon_world::collision_context::CollisionContext<'static> {
use horizon_world::collision_context::{
CollisionContext, CollisionEntity, EntityContextKind,
};
CollisionContext::for_entity(
CollisionEntity {
identity: horizon_world::entity_obstruction::EntityIdentity(u64::from(self.id)),
kind: EntityContextKind::Item,
y: self.position.y,
descending: false,
main_hand: None,
},
false,
)
}
/// `ItemEntity.tick` on a server: an item whose box is inside a block
/// (one placed where it lay) has no physics this tick
/// (`level.noCollision(box.deflate(1.0E-7))`).
/// (`level.noCollision(this, box.deflate(1.0E-7))`).
pub fn inside_block(&self, world: &World, collision: &BlockCollisionRegistry<'_>) -> bool {
let mut scene = SceneCollisions {
world,
@@ -534,7 +550,7 @@ impl ItemEntity {
subject: None,
};
let inner = self.bounds().deflate(Vec3::new(1.0e-7, 1.0e-7, 1.0e-7));
!crate::collisions::no_collision_empty(&mut scene, inner)
!crate::collisions::no_collision_with_context(&mut scene, inner, self.collision_context())
}
/// `Entity.updateFluidInteraction`: entering water after the first tick
/// runs `doWaterSplashEffect`.
@@ -575,6 +591,22 @@ impl ItemEntity {
speed,
);
}
/// `ItemEntity.setUnderwaterMovement` / `setUnderLavaMovement`: horizontal
/// drag is a float (`0.99F` / `0.95F`), and a slow item is lifted by
/// `5.0E-4F` while slower than `0.06F`.
fn set_fluid_movement(&mut self, drag: f32) {
let drag = f64::from(drag);
let lift = if self.velocity.y < f64::from(0.06_f32) {
f64::from(5.0e-4_f32)
} else {
0.0
};
self.velocity = Vec3::new(
self.velocity.x * drag,
self.velocity.y + lift,
self.velocity.z * drag,
);
}
/// `ItemEntity.tick` up to the blocks' push. The game has just run the
/// fluid push (`Entity::push_by_fluid`) and passes the heights it found;
/// it pushes by a bubble column next (`Entity::push_by_bubble_column`) and
@@ -624,18 +656,11 @@ impl ItemEntity {
let old_velocity = self.velocity;
let in_water = water_height > f64::from(0.1f32);
if in_water {
// ItemEntity.setUnderwaterMovement
self.velocity = Vec3::new(
self.velocity.x * 0.99,
self.velocity.y + if self.velocity.y < 0.06 { 5.0e-4 } else { 0.0 },
self.velocity.z * 0.99,
);
// ItemEntity.setUnderwaterMovement: 0.99F, 5.0E-4F, 0.06F.
self.set_fluid_movement(0.99_f32);
} else if lava_height > f64::from(0.1f32) {
self.velocity = Vec3::new(
self.velocity.x * 0.95,
self.velocity.y + if self.velocity.y < 0.06 { 5.0e-4 } else { 0.0 },
self.velocity.z * 0.95,
);
// ItemEntity.setUnderLavaMovement: 0.95F, 5.0E-4F, 0.06F.
self.set_fluid_movement(0.95_f32);
} else {
self.velocity.y -= self.get_gravity();
}
@@ -673,7 +698,13 @@ impl ItemEntity {
entities: entity_boxes,
border: border.map(|b| (b, [self.position.x, self.position.z])),
}
.resolve(self.bounds(), wanted, 0.0, self.on_ground)
.resolve_with_context(
self.bounds(),
wanted,
0.0,
self.on_ground,
self.collision_context(),
)
.unwrap_or(Vec3::ZERO)
};
if move_applies(wanted, moved) {
@@ -804,18 +835,22 @@ impl ItemEntity {
if !no_physics {
self.slime_step_on(world, facts);
}
let mut friction = 0.98;
// `float friction = getAirDrag()` (0.98F); on the ground, times
// the block's friction as a float, then widened for the scale.
let mut friction = 0.98_f32;
if self.on_ground {
let below = world.block(
self.position.x.floor() as i32,
(self.position.y - 0.999_999).floor() as i32,
self.position.z.floor() as i32,
);
friction = f64::from(facts.get(below).friction) * 0.98;
friction *= facts.get(below).friction;
}
let friction = f64::from(friction);
let air = f64::from(0.98_f32);
self.velocity = Vec3::new(
self.velocity.x * friction,
self.velocity.y * 0.98,
self.velocity.y * air,
self.velocity.z * friction,
);
if self.on_ground && self.velocity.y < 0.0 {
@@ -2371,6 +2406,7 @@ impl ArmorStand {
let context = horizon_world::collision_context::CollisionContext::for_entity(
horizon_world::collision_context::CollisionEntity {
identity: horizon_world::entity_obstruction::EntityIdentity(u64::from(self.id)),
kind: horizon_world::collision_context::EntityContextKind::Living,
y: self.position.y,
descending: false,
main_hand: Some(horizon_world::collision_context::CollisionItem::EMPTY),
@@ -3612,12 +3648,53 @@ mod slime_bounce_tests {
#[test]
fn a_slime_landing_stays_upward_after_item_friction() {
// ItemEntity then multiplies Y by 0.98 and applies -0.5 only if still
// ItemEntity then multiplies Y by 0.98F and applies -0.5 only if still
// negative on the ground. A slime bounce must remain positive.
let bounced = vertical_collision_velocity(-1.0, -0.5, ITEM_GRAVITY, true, true) * 0.98;
let bounced =
vertical_collision_velocity(-1.0, -0.5, ITEM_GRAVITY, true, true) * f64::from(0.98_f32);
assert!(bounced > 0.0);
}
#[test]
fn item_air_drag_widens_get_air_drag() {
// ItemEntity.tick: getAirDrag 0.98F, not the f64 0.98 (#6814).
let mut item = super::honey_slide_tests::falling_item(0.5, 64.0, 0.5, 0.1, 0.2, -0.1);
let world = World::new(horizon_world::registry::BlockStateId(0), 0);
let facts = BlockFacts {
states: Vec::new(),
air: horizon_world::registry::BlockStateId(0),
woods: Default::default(),
};
item.tick_settle(
&world,
&facts,
true,
ItemStep {
old_velocity: item.velocity,
movement: Some(item.velocity),
clipped_vertical: false,
},
);
assert_eq!(item.velocity.x.to_bits(), 0x3fb9_1687_3333_3334);
assert_eq!(item.velocity.y.to_bits(), 0x3fc9_1687_3333_3334);
assert_eq!(item.velocity.z.to_bits(), 0xbfb9_1687_3333_3334);
}
#[test]
fn item_fluid_drags_widen_float_constants() {
// setUnderwaterMovement 0.99F / 5.0E-4F; setUnderLavaMovement 0.95F.
let mut water = super::honey_slide_tests::falling_item(0.5, 64.0, 0.5, 0.1, 0.01, -0.2);
water.set_fluid_movement(0.99_f32);
assert_eq!(water.velocity.x.to_bits(), 0x3fb9_5810_6666_6667);
assert_eq!(water.velocity.y.to_bits(), 0x3f85_8106_25ae_147b);
assert_eq!(water.velocity.z.to_bits(), 0xbfc9_5810_6666_6667);
let mut lava = super::honey_slide_tests::falling_item(0.5, 64.0, 0.5, 0.1, 0.01, -0.2);
lava.set_fluid_movement(0.95_f32);
assert_eq!(lava.velocity.x.to_bits(), 0x3fb8_51eb_8000_0000);
assert_eq!(lava.velocity.y.to_bits(), 0x3f85_8106_25ae_147b);
assert_eq!(lava.velocity.z.to_bits(), 0xbfc8_51eb_8000_0000);
}
#[test]
fn a_slime_bounce_marks_the_item_for_immediate_sync() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
+450 -7
View File
@@ -685,9 +685,13 @@ pub fn mob_to_nbt(
} else {
map.remove(&horizon_world::nbt::JavaString::from("TicksFrozen"));
}
// LivingEntity.addAdditionalSaveData: `putBoolean(FallFlying, isFallFlying())`.
map.insert(
"FallFlying".into(),
Tag::Byte(i8::from(m.extra.fall_flying)),
);
for (key, value) in [
("AbsorptionAmount", Tag::Float(0)),
("FallFlying", Tag::Byte(0)),
("current_impulse_context_reset_grace_time", Tag::Int(0)),
(
"Brain",
@@ -909,7 +913,7 @@ pub fn mob_to_nbt(
| "minecraft:llama"
| "minecraft:trader_llama" => {
map.insert("Tame".into(), Tag::Byte(i8::from(m.extra.tame)));
map.insert("Temper".into(), Tag::Int(i32::from(m.extra.variant3)));
map.insert("Temper".into(), Tag::Int(m.extra.equine_temper));
map.insert("Bred".into(), Tag::Byte(i8::from(m.extra.bred)));
// AbstractHorse.addAdditionalSaveData; Horizon does not model the
// eating haystack yet.
@@ -1149,6 +1153,11 @@ pub fn mob_to_nbt(
);
}
"minecraft:camel" | "minecraft:camel_husk" => {
// AbstractHorse.addAdditionalSaveData, including Camel.isTamed.
map.insert("Tame".into(), Tag::Byte(i8::from(m.extra.tame)));
map.insert("Temper".into(), Tag::Int(m.extra.equine_temper));
map.insert("Bred".into(), Tag::Byte(i8::from(m.extra.bred)));
map.insert("EatingHaystack".into(), Tag::Byte(i8::from(m.extra.flag)));
// LastPoseTick: the pose change time, negative while sitting. The
// game time is not known here; the world's save overwrites it
// with the real one (`camel::synced_pose_tick`).
@@ -1190,6 +1199,7 @@ pub fn mob_to_nbt(
map.insert("listener".into(), st.vibration.to_nbt());
map.insert("anger".into(), warden_anger_to_nbt(&st.anger, &mut uuid_of));
}
write_warden_owned_memories(m, map);
}
// Armadillo.addAdditionalSaveData: the time left to the next scute, and
// the state it is in, which is always written (`idle` when it is not
@@ -1250,9 +1260,19 @@ pub fn mob_to_nbt(
Tag::Int(crate::mobs::aquatic::dark_ticks(m)),
);
}
// Phantom.addAdditionalSaveData.
// Phantom.addAdditionalSaveData: the size, and the circling anchor.
// `storeNullable` writes nothing for a phantom that has none, so its
// save carries no `anchor_pos` at all.
"minecraft:phantom" => {
map.insert("size".into(), Tag::Int(m.extra.size));
match m.extra.home {
Some(anchor) => {
map.insert("anchor_pos".into(), Tag::IntArray(anchor.to_vec()));
}
None => {
map.remove(&"anchor_pos".into());
}
}
}
"minecraft:slime" | "minecraft:magma_cube" => {
map.insert("Size".into(), Tag::Int(m.extra.size.max(1) - 1));
@@ -1697,6 +1717,8 @@ pub fn mob_from_nbt(
let mut m = Mob::new(id, kind, position, yaw);
m.velocity = motion(tag);
m.head_pitch = pitch;
// LivingEntity.readAdditionalSaveData: `setSharedFlag(7, getBooleanOr(FallFlying, false))`.
m.extra.fall_flying = flag(tag, "FallFlying");
read_mob_home(&mut m, tag);
m.on_ground = flag(tag, "OnGround");
m.fall_distance = float(tag.get("fall_distance")).unwrap_or(0.0);
@@ -1996,7 +2018,8 @@ pub fn mob_from_nbt(
}
if crate::mobs::equine::is_equine(m.species()) {
m.extra.tame = flag(tag, "Tame");
m.extra.variant3 = number(tag.get("Temper")).unwrap_or(0).clamp(0, 100) as u8;
// AbstractHorse.readAdditionalSaveData: the saved Int as is, camels included.
m.extra.equine_temper = tag.get("Temper").and_then(int_value).unwrap_or(0);
m.extra.bred = flag(tag, "Bred");
m.extra.owner = crate::save::uuid_from_tag(tag.get("Owner"));
let v = number(tag.get("Variant")).unwrap_or(0) as i32;
@@ -2226,6 +2249,9 @@ pub fn mob_from_nbt(
crate::mobs::frog::set_pregnant(&mut m, true);
}
if crate::mobs::camel::is_camel(m.species()) {
// AbstractHorse.readAdditionalSaveData: the eating state (Temper is
// read with the other equines' above).
m.extra.flag = flag(tag, "EatingHaystack");
// The tick is a game time: `camel::finish_load` puts it on the mob's
// clock once the world's time is known.
let tick = match tag.get("LastPoseTick") {
@@ -2303,6 +2329,7 @@ pub fn mob_from_nbt(
crate::mobs::warden::AngerManagement::from_saved(warden_anger_from_nbt(anger));
}
}
restore_warden_owned_memories(&mut m, tag);
}
// Armadillo.readAdditionalSaveData: the saved state through `switchToState`
// (`onSyncedDataUpdated` then restarts the time in that state), idle for a
@@ -2395,8 +2422,11 @@ pub fn mob_from_nbt(
m.extra.flag2 = flag(tag, "wasOnGround");
}
if m.species() == crate::mobs::Species::Phantom {
// Phantom.readAdditionalSaveData: setPhantomSize(size).
// Phantom.readAdditionalSaveData: setPhantomSize(size), and the circling
// anchor, absent or unreadable when the save has no anchor at all
// (`input.read("anchor_pos", BlockPos.CODEC).orElse(null)`).
crate::mobs::phantom::set_size(&mut m, number(tag.get("size")).unwrap_or(0) as i32);
m.extra.home = home_block_pos(tag.get("anchor_pos"));
}
// Zombie, Piglin and Zoglin.readAdditionalSaveData: IsBaby (the baby
// zombies of trial spawners).
@@ -3396,7 +3426,9 @@ pub fn falling_block_to_nbt(
/// (block state tag, position, motion, time, fall distance)
pub fn falling_block_from_nbt(tag: &Tag) -> Option<(Tag, Vec3, Vec3, i32, f64)> {
Some((
tag.get("BlockState")?.clone(),
tag.get("BlockState")
.cloned()
.unwrap_or_else(|| string("minecraft:sand")),
position(tag)?,
motion(tag),
number(tag.get("Time")).unwrap_or(0) as i32,
@@ -3404,6 +3436,21 @@ pub fn falling_block_from_nbt(tag: &Tag) -> Option<(Tag, Vec3, Vec3, i32, f64)>
))
}
/// FallingBlockEntity.readAdditionalSaveData uses the modern state codec.
pub fn falling_state_from_nbt(registry: &BlockRegistry, tag: &Tag) -> Option<BlockStateId> {
BlockState::from_nbt(tag)
.ok()
.and_then(|mut state| {
state.name = horizon_world::identifier::Identifier::parse(&state.name)
.ok()?
.to_string();
registry
.id(&state)
.or_else(|| registry.default_state(&state.name))
})
.or_else(|| registry.default_state("minecraft:sand"))
}
/// `NoGravity` and `DropItem` (true when absent) of a saved falling block.
pub fn falling_block_flags(tag: &Tag) -> (bool, bool) {
(
@@ -4487,7 +4534,7 @@ mod tests {
events
.iter()
.find_map(|e| match e {
crate::mobs::MobEvent::Explode(_, radius, _) => Some(*radius),
crate::mobs::MobEvent::Explode(_, radius, ..) => Some(*radius),
_ => None,
})
.expect("the creeper exploded")
@@ -4766,6 +4813,94 @@ mod tests {
assert!(!armor_stand_from_nbt(&cleared, 7).unwrap().no_gravity);
}
fn falling_decode_registry() -> BlockRegistry {
BlockRegistry::from_nbt(&Tag::compound([
("DataVersion", Tag::Int(horizon_world::DATA_VERSION)),
(
"states",
Tag::List(
["minecraft:air", "minecraft:sand", "minecraft:gravel"]
.into_iter()
.map(string)
.collect(),
),
),
(
"defaults",
Tag::compound([
("minecraft:air", Tag::Int(0)),
("minecraft:sand", Tag::Int(1)),
("minecraft:gravel", Tag::Int(2)),
]),
),
]))
.unwrap()
}
fn falling_decode_input() -> Tag {
horizon_world::nbt::parse_snbt(
"{Pos:[3.5d,200d,4.5d],Motion:[0d,0d,0d],Time:10,DropItem:0b,NoGravity:1b}",
)
.unwrap()
.0
}
fn falling_decode_save(registry: &BlockRegistry, input: &Tag) -> Tag {
let (state, pos, motion, time, distance) =
falling_block_from_nbt(input).expect("absent BlockState is valid input");
let actual = falling_state_from_nbt(registry, &state).unwrap();
assert_eq!(falling_block_flags(input), (true, false));
// Save only the decoded concrete model; no original source compound.
let saved = falling_block_to_nbt(
state_to_nbt(registry, actual),
pos,
motion,
time,
false,
crate::falling_block::Damage::default(),
distance,
[0.0, 0.0],
[96, 0, 0, 1],
);
assert_eq!(saved.get("Pos"), input.get("Pos"));
assert_eq!(saved.get("Motion"), input.get("Motion"));
assert_eq!(saved.get("Time"), Some(&Tag::Int(10)));
assert_eq!(saved.get("DropItem"), Some(&Tag::Byte(0)));
saved
}
#[test]
fn falling_decode_absent_state_saves_actual_sand_without_raw() {
let registry = falling_decode_registry();
let saved = falling_decode_save(&registry, &falling_decode_input());
assert_eq!(saved.get("BlockState"), Some(&string("minecraft:sand")));
}
#[test]
fn falling_decode_modern_boundary_keeps_shared_legacy_reader() {
let registry = falling_decode_registry();
let legacy = Tag::compound([("Name", string("minecraft:gravel"))]);
assert_eq!(
state_from_nbt(&registry, &legacy),
registry.default_state("minecraft:gravel")
);
for (state, expected) in [
(string("minecraft:sand"), "minecraft:sand"),
(
Tag::compound([("id", string("minecraft:gravel"))]),
"minecraft:gravel",
),
(string("minecraft:air"), "minecraft:air"),
(legacy, "minecraft:sand"),
(Tag::Compound(Default::default()), "minecraft:sand"),
] {
let mut input = falling_decode_input();
put(&mut input, "BlockState", state);
let saved = falling_decode_save(&registry, &input);
assert_eq!(saved.get("BlockState"), Some(&string(expected)));
}
}
#[test]
fn falling_block_round_trip_keeps_impact_distance_and_settings() {
let state = Tag::compound([("Name", string("minecraft:damaged_anvil"))]);
@@ -5585,6 +5720,60 @@ fn write_guarded_piglin_anger(m: &Mob, map: &mut Compound) {
}
}
/// `Brain`'s two measured Warden Unit memories, using actual existing slots.
fn write_warden_owned_memories(m: &Mob, map: &mut Compound) {
let brain = map
.entry("Brain".into())
.or_insert_with(|| Tag::Compound(Compound::new()));
let Tag::Compound(brain) = brain else { return };
let memories = brain
.entry("memories".into())
.or_insert_with(|| Tag::Compound(Compound::new()));
let Tag::Compound(memories) = memories else {
return;
};
let mem = m.extra.warden.as_ref().map(|st| &st.brain.mem);
for (key, slot) in [
("minecraft:is_emerging", mem.map(|mem| &mem.is_emerging)),
("minecraft:dig_cooldown", mem.map(|mem| &mem.dig_cooldown)),
] {
if let Some(slot) = slot.filter(|slot| slot.present()) {
let mut entry = Tag::compound([("value", Tag::Compound(Compound::new()))]);
if slot.ttl() != i64::MAX {
put(&mut entry, "ttl", Tag::Long(slot.ttl()));
}
memories.insert(key.into(), entry);
} else {
memories.remove(&key.into());
}
}
}
/// `Brain` / `ExpirableValue`: admitted Unit values with absent or Long TTL.
fn restore_warden_owned_memories(m: &mut Mob, tag: &Tag) {
let memories = tag.get("Brain").and_then(|brain| brain.get("memories"));
let Some(st) = m.extra.warden.as_mut() else {
return;
};
for (key, slot) in [
("minecraft:is_emerging", &mut st.brain.mem.is_emerging),
("minecraft:dig_cooldown", &mut st.brain.mem.dig_cooldown),
] {
let Some(entry) = memories.and_then(|memories| memories.get(key)) else {
// Fresh Java read/load: absent entries do not retain finalize's slot.
slot.clear();
continue;
};
if !matches!(entry.get("value"), Some(Tag::Compound(value)) if value.is_empty()) {
continue; // Unmeasured malformed shapes retain existing initialization.
}
match entry.get("ttl") {
None => slot.set(()),
Some(Tag::Long(ttl)) => slot.set_ttl((), *ttl),
_ => {} // No new numeric/malformed TTL policy.
}
}
}
fn write_axolotl_memories(m: &Mob, map: &mut Compound) {
let brain = map
.entry("Brain".into())
@@ -9168,3 +9357,257 @@ mod carrier_inventory_tests {
);
}
}
#[cfg(test)]
mod camel_inherited_saved_tests {
use super::*;
use crate::mobs::Species;
fn types(species: Species, id: &str) -> HashMap<String, Arc<MobType>> {
let mut kind = crate::mobs::sensor_scan_tests::kind();
let ty = Arc::make_mut(&mut kind);
ty.id = id.into();
ty.species = species;
HashMap::from([(id.to_owned(), kind)])
}
fn input(id: &str, temper: Option<i32>) -> Tag {
let mut raw = Tag::compound([
("id", string(id)),
("Pos", doubles(&[1.5, 200.0, 4.5])),
("Health", Tag::Float(10.0_f32.to_bits())),
("NoAI", Tag::Byte(1)),
("NoGravity", Tag::Byte(1)),
("Silent", Tag::Byte(1)),
("Invulnerable", Tag::Byte(1)),
("LastPoseTick", Tag::Long(1)),
("CustomName", string("kept")),
("Tags", Tag::List(vec![string("kept")])),
("audit:kept", Tag::Int(17)),
]);
if let Some(temper) = temper {
put(&mut raw, "Tame", Tag::Byte(0));
put(&mut raw, "Temper", Tag::Int(temper));
put(&mut raw, "Bred", Tag::Byte(1));
put(&mut raw, "EatingHaystack", Tag::Byte(1));
}
raw
}
#[test]
fn camel_inherited_fields_decode_and_save_without_raw_or_with_stale_raw() {
for (species, id) in [
(Species::Camel, "minecraft:camel"),
(Species::CamelHusk, "minecraft:camel_husk"),
] {
let types = types(species, id);
for temper in [None, Some(0), Some(50), Some(-1), Some(256), Some(i32::MAX)] {
let raw = input(id, temper);
let mob = mob_from_nbt(&raw, &types, 92, None).unwrap();
assert!(
mob.extra.tame,
"Camel.isTamed remains true even with Tame:0b"
);
assert_eq!(mob.extra.bred, temper.is_some());
assert_eq!(
mob.extra.flag,
temper.is_some(),
"actual loaded eating state"
);
assert!(mob.no_ai && mob.no_gravity && mob.silent && mob.invulnerable_flag);
assert!(!mob.extra.camel_sitting && !mob.extra.flag2);
assert_eq!(mob.extra.pose_change_tick, 1);
let mut stale = raw.clone();
for (field, value) in [
("Tame", Tag::Byte(0)),
("Temper", Tag::Long(9999)),
("Bred", Tag::Byte(i8::from(temper.is_none()))),
("EatingHaystack", Tag::Byte(i8::from(temper.is_none()))),
] {
put(&mut stale, field, value);
}
for source in [None, Some(&raw), Some(&stale)] {
let saved = mob_to_nbt(&mob, [92, 0, 0, 1], source, None, |_| None);
assert_eq!(saved.get("Tame"), Some(&Tag::Byte(1)), "{id}, {temper:?}");
assert_eq!(saved.get("Temper"), Some(&Tag::Int(temper.unwrap_or(0))));
for field in ["Bred", "EatingHaystack"] {
assert_eq!(
saved.get(field),
Some(&Tag::Byte(i8::from(temper.is_some())))
);
}
for field in ["NoAI", "NoGravity", "Silent", "Invulnerable"] {
assert_eq!(saved.get(field), Some(&Tag::Byte(1)));
}
assert_eq!(saved.get("UUID"), Some(&Tag::IntArray(vec![92, 0, 0, 1])));
if source.is_some() {
for field in ["CustomName", "Tags", "audit:kept"] {
assert_eq!(saved.get(field), raw.get(field));
}
}
}
}
}
}
#[test]
fn camel_signed_state_and_mutations_override_contradictory_raw() {
for (species, id) in [
(Species::Camel, "minecraft:camel"),
(Species::CamelHusk, "minecraft:camel_husk"),
] {
let types = types(species, id);
for (tag, value) in [
(Tag::Int(-1), -1),
(Tag::Int(256), 256),
(Tag::Int(i32::MAX), i32::MAX),
(Tag::Float((-1.5_f32).to_bits()), -2),
(Tag::Long(4_294_967_297), 1),
(string("bad"), 0),
] {
let mut raw = input(id, Some(50));
put(&mut raw, "Temper", tag);
let mob = mob_from_nbt(&raw, &types, 92, None).unwrap();
assert_eq!(mob.extra.equine_temper, value, "actual signed model state");
assert!(mob.extra.tame && mob.extra.bred && mob.extra.flag);
assert_eq!(
mob_to_nbt(&mob, [92, 0, 0, 1], None, None, |_| None).get("Temper"),
Some(&Tag::Int(value))
);
}
let raw = input(id, Some(50));
let mut mob = mob_from_nbt(&raw, &types, 92, None).unwrap();
crate::mobs::camel::finish_load(&mut mob, 1024);
let unchanged = (
mob.id,
mob.position,
mob.velocity,
mob.health,
mob.extra.variant3,
mob.extra.pose_change_tick,
mob.extra.egg_time,
mob.extra.camel_sitting,
mob.extra.flag2,
);
mob.extra.equine_temper = -77;
mob.extra.bred = false;
mob.extra.flag = false;
for source in [None, Some(&raw)] {
let saved = mob_to_nbt(&mob, [92, 0, 0, 1], source, None, |_| None);
assert_eq!(saved.get("Temper"), Some(&Tag::Int(-77)));
assert_eq!(saved.get("Tame"), Some(&Tag::Byte(1)));
assert_eq!(saved.get("Bred"), Some(&Tag::Byte(0)));
assert_eq!(saved.get("EatingHaystack"), Some(&Tag::Byte(0)));
}
assert_eq!(
(
mob.id,
mob.position,
mob.velocity,
mob.health,
mob.extra.variant3,
mob.extra.pose_change_tick,
mob.extra.egg_time,
mob.extra.camel_sitting,
mob.extra.flag2
),
unchanged
);
}
}
#[test]
fn camel_saved_carry_leaves_the_seven_equine_paths_unchanged() {
for (species, id) in [
(Species::Horse, "minecraft:horse"),
(Species::Donkey, "minecraft:donkey"),
(Species::Mule, "minecraft:mule"),
(Species::SkeletonHorse, "minecraft:skeleton_horse"),
(Species::ZombieHorse, "minecraft:zombie_horse"),
(Species::Llama, "minecraft:llama"),
(Species::TraderLlama, "minecraft:trader_llama"),
] {
let mut raw = input(id, Some(12));
put(&mut raw, "Tame", Tag::Byte(1));
let mob = mob_from_nbt(&raw, &types(species, id), 92, None).unwrap();
assert_eq!(mob.extra.equine_temper, 12);
assert!(!mob.extra.flag, "the other species' load path is unchanged");
for source in [None, Some(&raw)] {
let saved = mob_to_nbt(&mob, [92, 0, 0, 1], source, None, |_| None);
assert_eq!(saved.get("Temper"), Some(&Tag::Int(12)));
assert_eq!(saved.get("Tame"), Some(&Tag::Byte(1)));
assert_eq!(saved.get("Bred"), Some(&Tag::Byte(1)));
// Preserve the existing raw/default behavior outside Camel;
// this control does not claim the other species' eating codec.
assert_eq!(
saved.get("EatingHaystack"),
Some(&Tag::Byte(i8::from(source.is_some())))
);
}
}
}
}
#[cfg(test)]
mod equine_temper_preservation_tests {
use super::*;
use crate::mobs::Species;
#[test]
fn temper_preservation_decodes_and_saves_without_raw_input() {
for (name, species) in [
("llama", Species::Llama),
("trader_llama", Species::TraderLlama),
("horse", Species::Horse),
] {
let mut kind = crate::mobs::sensor_scan_tests::kind();
let def = Arc::make_mut(&mut kind);
def.id = format!("minecraft:{name}");
def.species = species;
let types = [(def.id.clone(), kind.clone())].into();
for value in [50, 0, 30, 100, -7, 1000, i32::MIN, i32::MAX] {
let raw = Tag::compound([
("id", string(&format!("minecraft:{name}"))),
("Pos", doubles(&[1.5, 200.0, 1.5])),
("Temper", Tag::Int(value)),
("Tame", Tag::Byte(1)),
("NoAI", Tag::Byte(1)),
("Strength", Tag::Int(3)),
("ChestedHorse", Tag::Byte(1)),
]);
let m = mob_from_nbt(&raw, &types, 7, None).unwrap();
assert_eq!(m.species(), species);
assert!(m.extra.tame && m.no_ai);
let stale =
Tag::compound([("Temper", Tag::Int(17)), ("audit:unrelated", Tag::Int(9))]);
for base in [None, Some(&raw), Some(&stale)] {
let saved = mob_to_nbt(&m, [93, 0, 0, 1], base, None, |_| None);
assert_eq!(
saved.get("Temper"),
Some(&Tag::Int(value)),
"{name}: loaded temper is not a gameplay limit"
);
assert_eq!(saved.get("Tame"), Some(&Tag::Byte(1)));
if species != Species::Horse {
assert_eq!(saved.get("Strength"), Some(&Tag::Int(3)));
assert_eq!(saved.get("ChestedHorse"), Some(&Tag::Byte(1)));
}
if base == Some(&stale) {
assert_eq!(saved.get("audit:unrelated"), Some(&Tag::Int(9)));
}
}
}
let fresh = Mob::new(7, kind, Vec3::ZERO, 0.0);
let saved = mob_to_nbt(&fresh, [93, 0, 0, 1], None, None, |_| None);
assert_eq!(saved.get("Temper"), Some(&Tag::Int(0)));
let absent = Tag::compound([
("id", string(&format!("minecraft:{name}"))),
("Pos", doubles(&[1.5, 200.0, 1.5])),
]);
let decoded = mob_from_nbt(&absent, &types, 7, None).unwrap();
assert_eq!(
mob_to_nbt(&decoded, [0; 4], None, None, |_| None).get("Temper"),
Some(&Tag::Int(0))
);
}
}
}
+25 -1
View File
@@ -2,9 +2,16 @@
//! WHY: each kind used to check its own `NoGravity` flag and pick its own
//! number, so a kind that forgot the check ignored the flag (#6481).
//! REFS: vanilla `Entity.isNoGravity`, `getDefaultGravity`, `getGravity`,
//! `applyGravity`, `isPushedByFluid`; issue #6509, slice #6544.
//! `applyGravity`, `isPushedByFluid`, `getAirDrag`; issue #6509, slice #6544.
//! LOGS: none. READS-BY: the kinds' ticks in this crate and in `game/`.
//! ENDS-WHEN: kinds share one stored `Entity` state and this folds into it.
use horizon_core::geometry::Vec3;
/// `Entity.scale(getAirDrag())`: 0.98F, widened before it scales double motion.
pub fn scale_air_drag(velocity: Vec3) -> Vec3 {
let drag = f64::from(0.98_f32);
Vec3::new(velocity.x * drag, velocity.y * drag, velocity.z * drag)
}
/// The rules vanilla's `Entity` answers for every kind. A kind overrides one
/// only where its vanilla class does; the defaults are `Entity`'s.
@@ -31,3 +38,20 @@ pub trait EntityRules {
true
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn four_ticks_of_air_drag_match_vanilla_0_98f() {
// Issue #6814: 0.1 × 0.98F⁴ is 0.09223682318…, not 0.1 × 0.98⁴ as f64.
let mut v = Vec3::new(0.1, 0.0, 0.0);
for _ in 0..4 {
v = scale_air_drag(v);
}
assert_eq!(v.x.to_bits(), 0x3fb7_9cd5_1b0c_538e);
assert_eq!(v.y.to_bits(), 0);
assert_eq!(v.z.to_bits(), 0);
}
}
File diff suppressed because it is too large. Load diff
+171 -4
View File
@@ -298,6 +298,7 @@ pub(super) fn item_view(s: &ItemStack) -> ItemView {
.collect(),
potion: s.potion().map(str::to_owned),
custom_name: s.custom_name().is_some(),
custom_data: None,
}
}
impl Game<'_> {
@@ -530,6 +531,8 @@ impl Game<'_> {
let stats = (host.play.get(&conn).map(|p| &p.stats)).filter(|s| s.loaded);
adv::PlayerFacts {
level: asked.level.then_some(g.xp_level),
// The guest's food as last written back from its body.
food: Some((g.food, g.saturation)),
stats: stats.filter(|_| asked.stats).map(|s| s.values.clone()),
advancements: asked
.advancements
@@ -561,13 +564,17 @@ impl Game<'_> {
me
}
/// The player's level, statistics, advancements and recipes that
/// `asked` names, for `PlayerPredicate`; records not loaded stay `None`.
/// `asked` names, and its food, for `PlayerPredicate`; records not
/// loaded stay `None`.
fn player_facts(&self, asked: adv::FactsAsked) -> adv::PlayerFacts {
if self.player.is_none() {
let Some(p) = self.player.as_ref() else {
return adv::PlayerFacts::default();
}
};
adv::PlayerFacts {
level: asked.level.then_some(self.experience.level),
// `ServerPlayer.getFoodData`, read by every player predicate:
// two numbers, so always filled.
food: Some((p.health.food.food, p.health.food.saturation)),
stats: (asked.stats && self.stats.loaded).then(|| self.stats.values.clone()),
advancements: (asked.advancements && self.adv.loaded)
.then(|| (self.adv.tree.clone(), self.adv.progress.clone())),
@@ -690,6 +697,24 @@ impl Game<'_> {
armor.damage = m.extra.body_damage.max(0) as u32;
item_view(&armor)
});
// Only the new snapshot is adopted; the existing view fields stay as
// constructed above. Body and other view consumers stay unprojected.
use horizon_client::mobs::finalize;
for (view, slot) in equipment[..6].iter_mut().zip([
finalize::MAINHAND,
finalize::OFFHAND,
finalize::HEAD,
finalize::CHEST,
finalize::LEGS,
finalize::FEET,
]) {
if let Some(view) = view {
let data = finalize::item_stack(m, slot)
.and_then(|stack| stack.custom_data().cloned())
.unwrap_or_default();
view.custom_data = Some(horizon_world::nbt::Tag::Compound(data));
}
}
let passengers: Vec<EntityView> = self
.mobs
.iter()
@@ -2920,7 +2945,11 @@ mod structure_tag_tests {
let game_tags = pack.join("data/minecraft/tags/worldgen/structure");
std::fs::create_dir_all(&tags).unwrap();
std::fs::create_dir_all(&game_tags).unwrap();
std::fs::write(pack.join("pack.mcmeta"), "{}").unwrap();
std::fs::write(
pack.join("pack.mcmeta"),
r#"{"pack":{"description":"fixture"}}"#,
)
.unwrap();
std::fs::write(
tags.join("village.json"),
r##"{"values": ["#minecraft:village", "minecraft:igloo"]}"##,
@@ -2949,3 +2978,141 @@ mod structure_tag_tests {
let _ = std::fs::remove_dir_all(&dir);
}
}
#[cfg(test)]
mod custom_data_equipment_tests {
use super::*;
use crate::game::native_machines::tests::server_game;
use horizon_client::{mobs::finalize, save};
use horizon_core::position::BlockPos;
use horizon_world::nbt::{Tag, parse_snbt};
fn item_predicate(pattern: &str) -> Value {
serde_json::json!({"items":"minecraft:stick","count":1,
"predicates":{"minecraft:custom_data":pattern}})
}
fn condition(pattern: &str) -> Value {
serde_json::json!({"type":"minecraft:entity_properties","entity":"this",
"predicate":{"minecraft:entity_type":"minecraft:pig",
"minecraft:equipment":{"mainhand":item_predicate(pattern)}}})
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and scratch homes/cwd"]
fn actual_mob_equipment_projects_typed_custom_data_into_named_predicate_evaluation() {
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"));
assert!(game.run_command("fill 0 199 0 15 199 15 stone"));
let clock = game.world.game_time;
let rich = r#"{h289:1b,nested:{k:7,extra:9},entries:[{k:1b,extra:2b},{k:2b}],wide:9007199254740993L,spare:"keep"}"#;
let mut failures = Vec::new();
for (i, data) in [(1, Some(rich)), (2, None)] {
let uuid = [289, 1, 0, i];
let actor = format!("00000121-0000-0001-0000-{i:012x}");
let components = data.map_or(String::new(), |d| {
format!(",components:{{\"minecraft:custom_data\":{d}}}")
});
let x = f64::from(i) + 0.5;
assert!(game.run_command(&format!(
"summon minecraft:pig {x} 200 4.5 {{UUID:[I;289,1,0,{i}],NoAI:1b,NoGravity:1b,Silent:1b,PersistenceRequired:1b,Health:10f,equipment:{{mainhand:{{id:\"minecraft:stick\",count:1{components}}}}}}}"
)));
assert!(game.world.loaded(i, 4));
assert_eq!(
&*game.res.facts().get(game.world.block(i, 199, 4)).name,
"minecraft:stone"
);
assert!(game.run_command(&format!("execute as {actor} if entity @s[type=pig,nbt={{UUID:[I;289,1,0,{i}],NoAI:1b,Health:10f,Pos:[{x}d,200d,4.5d]}}]")));
let id = *game
.persist
.uuids
.iter()
.find(|(_, value)| **value == uuid)
.expect("actual admitted UUID")
.0;
let mob = game.mobs.iter().find(|m| m.id == id).unwrap();
assert!(mob.no_ai);
let stack = finalize::item_stack(mob, finalize::MAINHAND)
.expect("actual loaded equipment stack");
assert_eq!((&*stack.item, stack.count), ("minecraft:stick", 1));
let saved = save::item_to_nbt(&stack);
if let Some(data) = data {
let (expected, used) = parse_snbt(data).unwrap();
assert_eq!(used, data.len());
assert_eq!(
saved
.get("components")
.and_then(|t| t.get("minecraft:custom_data")),
Some(&expected)
);
assert_eq!(
stack.custom_data().unwrap().get(&"h289".into()),
Some(&Tag::Byte(1))
);
assert_eq!(
stack.custom_data().unwrap().get(&"wide".into()),
Some(&Tag::Long(9_007_199_254_740_993))
);
// Contradictory retained raw equipment must not replace actual stack state.
let (wrong, _) = parse_snbt(r#"{equipment:{mainhand:{id:"minecraft:stick",count:1,components:{"minecraft:custom_data":{h289:2b}}}}}"#).unwrap();
let raw = game
.persist
.raw
.entry(id)
.or_insert_with(|| Tag::Compound(Default::default()));
let Tag::Compound(raw) = raw else {
panic!("actual raw compound")
};
raw.insert("equipment".into(), wrong.get("equipment").unwrap().clone());
assert!(game.run_command(&format!("execute if data entity {actor} equipment.mainhand.components{{\"minecraft:custom_data\":{{h289:1b}}}}")));
} else {
assert!(stack.custom_data().is_none());
assert!(game.run_command(&format!("execute unless data entity {actor} equipment.mainhand.components.\"minecraft:custom_data\"")));
}
let mob = game.mobs.iter().find(|m| m.id == id).unwrap();
let view = game.mob_entity(mob);
assert_eq!(
(
&*view.equipment[0].as_ref().unwrap().id,
view.equipment[0].as_ref().unwrap().count
),
("minecraft:stick", 1)
);
let probes: Vec<(&str, bool)> = if data.is_some() {
vec![
("{h289:1b}", true),
("{nested:{k:7}}", true),
("{entries:[{k:2b}]}", true),
("{entries:[{k:2b},{k:1b}]}", true),
("{wide:9007199254740993L}", true),
("{}", true),
("{h289:2b}", false),
("{h289:1}", false),
("{missing:1b}", false),
("{entries:[]}", false),
("{wide:9007199254740992L}", false),
]
} else {
vec![("{}", true), ("{h289:1b}", false)]
};
for (pattern, want) in probes {
let actual =
game.conditions_hold(&condition(pattern), Some(&view), [x, 200.0, 4.5]);
if actual != want {
failures.push((i, pattern, actual, want));
}
}
// Existing non-equipment views are unprojected and retain unsupported legacy behavior.
assert!(game.loot_item_filter(&item_predicate("{h289:99b}"), &stack));
assert_eq!(game.world.game_time, clock, "zero observation ticks");
}
assert!(
failures.is_empty(),
"actual mob-equipment custom_data mismatch: {failures:?}"
);
}
}
+7 -56
View File
@@ -336,9 +336,8 @@ impl Game<'_> {
/// `PalettedTextureManager.getOrPrepare` for the trims drawn this frame
/// (the player's armour and the armour stands'): each generated texture
/// goes into an atlas trim slot, a free one, else the least recently
/// drawn.
/// drawn. Reuse the completed preparation while the requests and atlas stay the same.
pub(super) fn prepare_trims(&mut self) {
use horizon_client::hud::TRIM_SLOTS;
let assets = &self.armor_stand_assets;
let items = self.items();
let mut wanted: Vec<TrimTexture> = Vec::new();
@@ -398,60 +397,12 @@ impl Game<'_> {
wanted.push(t);
}
}
for t in wanted {
let name = t.sprite();
// Entries the atlas no longer has (rebuilt by a resource reload) are free.
let sprites = &self.gui.atlas.sprites;
self.trim_slots.retain(|(n, _)| sprites.contains_key(n));
if let Some(i) = self.trim_slots.iter().position(|e| e.0 == name) {
let e = self.trim_slots.remove(i);
self.trim_slots.push(e);
continue;
}
let Some((w, h, rgba)) = t.generate(&self.armor_stand_assets.root) else {
continue;
};
let used: Vec<usize> = self.trim_slots.iter().map(|e| e.1).collect();
let slot = match (0..TRIM_SLOTS).find(|i| !used.contains(i)) {
Some(free) => free,
None => {
let (old, slot) = self.trim_slots.remove(0);
self.gui.atlas.sprites.remove(&old);
slot
}
};
let Some(area) = self
.gui
.atlas
.sprites
.get(&format!("horizon/trim{slot}"))
.copied()
else {
continue;
};
if w > area.w || h > area.h {
continue;
}
let (aw, ah) = (self.gui.atlas.width as f32, self.gui.atlas.height as f32);
let (x, y) = ((area.u0 * aw).round(), (area.v0 * ah).round());
self.map_patches.push(horizon_client::font::Patch {
x: x as u32,
y: y as u32,
width: w,
height: h,
rgba,
});
let rect = horizon_client::hud::Rect {
u0: area.u0,
v0: area.v0,
u1: (x + w as f32) / aw,
v1: (y + h as f32) / ah,
w,
h,
};
self.gui.atlas.sprites.insert(name.clone(), rect);
self.trim_slots.push((name, slot));
}
self.trim_slots.prepare(
wanted,
&assets.root,
&mut self.gui.atlas,
&mut self.map_patches,
);
}
/// `ArmorStandItem.useOn`.
/// `ArmorStandItem.useOn` up to its `FAIL`s, which a client checks too:
+39 -13
View File
@@ -5,8 +5,10 @@
pub(super) struct AtlasSlots<K> {
count: usize,
/// (image, slot), most recently shown last.
held: Vec<(K, usize)>,
/// (image, slot, the frame it was last shown in), most recently shown last.
held: Vec<(K, usize, u64)>,
/// The frame being drawn (`next_frame`).
frame: u64,
}
impl<K: PartialEq> AtlasSlots<K> {
@@ -14,30 +16,39 @@ impl<K: PartialEq> AtlasSlots<K> {
Self {
count,
held: Vec::new(),
frame: 0,
}
}
/// A new frame starts: what was shown before it may give up its slot.
pub(super) fn next_frame(&mut self) {
self.frame += 1;
}
/// The slot holding `key`, marking it recently shown.
pub(super) fn show<Q: PartialEq + ?Sized>(&mut self, key: &Q) -> Option<usize>
where
K: std::borrow::Borrow<Q>,
{
let i = self.held.iter().position(|e| e.0.borrow() == key)?;
let e = self.held.remove(i);
let mut e = self.held.remove(i);
e.2 = self.frame;
let slot = e.1;
self.held.push(e);
Some(slot)
}
/// A slot for `key`: a free one, else the least recently shown, returned
/// with the image it held.
pub(super) fn claim(&mut self, key: K) -> (usize, Option<K>) {
/// with the image it held. `None` when every slot shows something this
/// frame: taking one would put this image on what was already drawn.
pub(super) fn claim(&mut self, key: K) -> Option<(usize, Option<K>)> {
let (slot, evicted) = if self.held.len() < self.count {
(self.held.len(), None)
} else {
let (old, slot) = self.held.remove(0);
} else if self.held[0].2 != self.frame {
let (old, slot, _) = self.held.remove(0);
(slot, Some(old))
} else {
return None;
};
self.held.push((key, slot));
(slot, evicted)
self.held.push((key, slot, self.frame));
Some((slot, evicted))
}
/// The atlas was replaced: every slot is blank.
pub(super) fn forget(&mut self) {
@@ -52,13 +63,28 @@ mod tests {
#[test]
fn the_least_recently_shown_image_gives_up_its_slot() {
let mut slots = AtlasSlots::new(2);
assert_eq!(slots.claim("a"), (0, None));
assert_eq!(slots.claim("b"), (1, None));
assert_eq!(slots.claim("a"), Some((0, None)));
assert_eq!(slots.claim("b"), Some((1, None)));
slots.next_frame();
assert_eq!(slots.show(&"a"), Some(0));
assert_eq!(slots.claim("c"), (1, Some("b")));
assert_eq!(slots.claim("c"), Some((1, Some("b"))));
assert_eq!(slots.show(&"b"), None);
}
#[test]
fn an_image_shown_this_frame_keeps_its_slot() {
// More maps in view than slots: the ones past the slots go undrawn
// rather than taking a slot another map was drawn with, both showing
// the later picture.
let mut slots = AtlasSlots::new(2);
assert_eq!(slots.claim("a"), Some((0, None)));
assert_eq!(slots.claim("b"), Some((1, None)));
assert_eq!(slots.claim("c"), None);
slots.next_frame();
assert_eq!(slots.show(&"b"), Some(1));
assert_eq!(slots.claim("c"), Some((0, Some("a"))));
}
#[test]
fn a_new_atlas_shows_no_stored_skin() {
// #3385: a skin stored before a pack reload must upload again, not
@@ -68,6 +94,6 @@ mod tests {
assert_eq!(slots.show("skins/abc"), Some(0));
slots.forget();
assert_eq!(slots.show("skins/abc"), None);
assert_eq!(slots.claim("skins/abc".to_string()), (0, None));
assert_eq!(slots.claim("skins/abc".to_string()), Some((0, None)));
}
}
+49 -7
View File
@@ -287,15 +287,26 @@ impl Game<'_> {
return;
};
let range = f64::from(b.levels * 10 + 10);
let Some(pp) = self.player.as_ref().map(|p| p.position) else {
let Some((pp, pl)) = self.player.as_ref().map(|p| (p.position, p.dimensions)) else {
return;
};
let inside = pp.x >= f64::from(p[0]) - range
&& pp.x <= f64::from(p[0] + 1) + range
&& pp.z >= f64::from(p[2]) - range
&& pp.z <= f64::from(p[2] + 1) + range
&& pp.y >= f64::from(p[1]) - range;
if !inside {
let height = f64::from(self.world.top_y() - self.world.bottom_y());
let bounds = pl.bounds(pp);
if !effect_box_hits(
p,
range,
height,
[
bounds.min_position().x,
bounds.min_position().y,
bounds.min_position().z,
],
[
bounds.max_position().x,
bounds.max_position().y,
bounds.max_position().z,
],
) {
return;
}
let secondary = b.secondary.as_deref().and_then(effect_id);
@@ -682,6 +693,17 @@ fn beam(e: &BlockEntity) -> Option<&[(u32, i32)]> {
_ => None,
}
}
/// `BeaconBlockEntity.applyEffects` box: `AABB(pos).inflate(range).expandTowards(0, height, 0)`,
/// tested with `AABB.intersects` (strict) against the player's bounding box.
fn effect_box_hits(p: [i32; 3], range: f64, height: f64, lo: [f64; 3], hi: [f64; 3]) -> bool {
let [x, y, z] = p.map(f64::from);
lo[0] < x + 1.0 + range
&& hi[0] > x - range
&& lo[1] < y + 1.0 + range + height
&& hi[1] > y - range
&& lo[2] < z + 1.0 + range
&& hi[2] > z - range
}
#[cfg(test)]
mod tests {
use super::*;
@@ -696,4 +718,24 @@ mod tests {
Some(&[(0xFFFFFF, 10)][..])
);
}
#[test]
fn effect_box_uses_player_overlap() {
let hit = |x: f64, y: f64| {
effect_box_hits(
[0, 100, 0],
50.0,
384.0,
[x - 0.3, y, -0.3],
[x + 0.3, y + 1.8, 0.3],
)
};
assert!(hit(-50.2, 100.0));
assert!(!hit(-50.4, 100.0));
assert!(hit(51.2, 100.0));
assert!(!hit(51.4, 100.0));
assert!(hit(0.0, 48.3));
assert!(!hit(0.0, 48.1));
assert!(hit(0.0, 534.9));
assert!(!hit(0.0, 535.0));
}
}
+3 -127
View File
@@ -7,6 +7,7 @@
//! a broken bed, or leaving the game.
use super::{Dim, Game};
use horizon_client::Result;
use horizon_client::bed_geometry::{SleepOn, pos_to_bed, shape_top, slept_on};
use horizon_core::geometry::Vec3;
use horizon_world::registry::BlockStateId;
@@ -73,8 +74,8 @@ impl Game<'_> {
// has destroy_on_leave, so a straw bed breaks when its sleeper gets up.
let bed = self.world.block(head[0], head[1], head[2]);
let straw = &*self.res.facts().get(bed).name == "minecraft:straw_bed";
if dim == Dim::Overworld
&& straw
if straw
&& self.dimension_type_of(dim).straw_bed_rule.destroy_on_leave
&& let Err(e) = self.destroy_straw_bed(head)
{
log::warn!("could not break the straw bed at {head:?}: {e}");
@@ -108,57 +109,6 @@ impl Game<'_> {
}
}
/// The part of a bed a sleeper lies on: `AbstractBedBlock.getSleepHeight`
/// answers with the shape of the block it is given, and `StrawBedBlock`
/// first walks a head over to its foot, so a straw bed is slept on at its
/// foot's shape while the head's own (the pillow's) is taller.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum SleepOn {
/// The head itself: `BedBlock` and every other bed.
Head,
/// The foot the straw bed's head lies against.
Foot,
}
/// `StrawBedBlock.getSleepHeight` then `AbstractBedBlock.getSleepHeight`: the
/// part whose shape the sleeper lies on, or no part at all. A straw bed's
/// head takes its foot's block, and only a foot of the same block will do:
/// with the foot broken, or replaced by something else, the height is empty
/// (`OptionalDouble.empty`) and `LivingEntity.startSleeping` returns false,
/// so nobody lies in the half that is left.
fn slept_on(bed: &str, part: Option<&str>, foot: Option<(&str, Option<&str>)>) -> Option<SleepOn> {
if bed != "minecraft:straw_bed" || part != Some("head") {
return Some(SleepOn::Head);
}
match foot {
Some(("minecraft:straw_bed", Some("foot"))) => Some(SleepOn::Foot),
_ => None,
}
}
/// `VoxelShape.max(Axis.Y)` of the boxes the pack gives a block, in the
/// block's own coordinates: the top of the bed, `1.0` for a full cube, and
/// `None` for a shape with nothing in it (`VoxelShape.isEmpty`).
fn shape_top(boxes: &[[f64; 6]], base_y: f64) -> Option<f64> {
boxes
.iter()
.map(|b| b[4] - base_y)
.fold(None, |top: Option<f64>, y| {
Some(top.map_or(y, |top| top.max(y)))
})
}
/// `LivingEntity.setPosToBed`: the middle of the sleeper's block, `0.125`
/// above the top of the shape, which `getSleepHeight` measured from that
/// block's own y.
fn pos_to_bed(head: [i32; 3], height: f64) -> Vec3 {
Vec3::new(
f64::from(head[0]) + 0.5,
f64::from(head[1]) + height + 0.125,
f64::from(head[2]) + 0.5,
)
}
impl Game<'_> {
/// The bed's `part`: head or foot.
fn bed_part(&self, state: BlockStateId) -> Option<String> {
@@ -198,77 +148,3 @@ impl Game<'_> {
.map(|height| pos_to_bed(head, height))
}
}
#[cfg(test)]
mod tests {
use super::{SleepOn, pos_to_bed, shape_top, slept_on};
/// `StrawBedBlock.getSleepHeight`: a straw bed's head is slept on at its
/// foot, and one whose foot is gone - or is not a straw bed's foot any
/// more - has no height, so `startSleeping` gives up on it.
#[test]
fn a_straw_beds_head_is_slept_on_at_its_foot() {
let straw = "minecraft:straw_bed";
assert_eq!(
slept_on(straw, Some("head"), Some((straw, Some("foot")))),
Some(SleepOn::Foot)
);
assert_eq!(
slept_on(straw, Some("head"), Some((straw, Some("head")))),
None,
"a head facing another head"
);
assert_eq!(
slept_on(straw, Some("head"), Some(("minecraft:oak_planks", None))),
None,
"the foot was replaced"
);
assert_eq!(
slept_on(
straw,
Some("head"),
Some(("minecraft:red_bed", Some("foot")))
),
None,
"the foot is another bed"
);
assert_eq!(
slept_on(straw, Some("foot"), None),
Some(SleepOn::Head),
"a straw bed's foot sleeps on its own shape"
);
assert_eq!(
slept_on("minecraft:red_bed", Some("head"), None),
Some(SleepOn::Head),
"an ordinary bed's head is its own shape"
);
}
/// `AbstractBedBlock.getSleepHeight`: the tallest box of the shape, from
/// the block's own y; a shape with nothing in it has no height.
#[test]
fn the_height_is_the_top_of_the_shape() {
// The boxes are the pack's, in world space, as `outline_boxes` gives them.
let bed = [[10.0, 70.0, 5.0, 11.0, 70.5625, 6.0]];
assert_eq!(shape_top(&bed, 70.0), Some(0.5625));
let pillow = [[10.0, 70.0, 5.0, 11.0, 70.6875, 6.0]];
assert_eq!(shape_top(&pillow, 70.0), Some(0.6875));
let two = [
[10.0, 70.0, 5.0, 11.0, 70.5625, 6.0],
[10.0, 70.0, 5.0, 10.5, 70.25, 5.5],
];
assert_eq!(shape_top(&two, 70.0), Some(0.5625), "the tallest box");
assert_eq!(shape_top(&[], 70.0), None, "a bed with no shape");
}
/// `setPosToBed`: `0.125` above the height, in the middle of the block.
/// A straw bed's head slept on a foot `7/16` tall at y 70 puts the player
/// at 70.5625, where the head's own `9/16` (or the flat 0.5625 every bed
/// was given) had them at 70.6875.
#[test]
fn the_sleeper_lies_an_eighth_above_the_shape() {
let at = pos_to_bed([10, 70, 5], 0.4375);
assert_eq!((at.x, at.y, at.z), (10.5, 70.5625, 5.5));
assert_eq!(pos_to_bed([10, 70, 5], 0.5625).y, 70.6875);
}
}
+36 -10
View File
@@ -58,6 +58,8 @@ impl Game<'_> {
self.edit_height(h, x, z)
})?;
// (what the registry, the tags and the facts tell of a state, beside the world being changed)
self.ensure_stateful_entity_table();
let stateful = self.world.block_entities.stateful_entities();
let registry = self.pack.assets.registry;
// (the tags as `block_matches` reads them for commands)
let world_items = match &self.world_tables {
@@ -81,6 +83,7 @@ impl Game<'_> {
let f = facts.get(BlockStateId(s));
watched.get(s as usize).copied().unwrap_or(false)
|| f.block_entity
|| stateful.get(s as usize).copied().unwrap_or(false)
|| BlockEntity::for_block(&f.name).is_some()
},
);
@@ -88,7 +91,7 @@ impl Game<'_> {
// The block entities the new blocks bring, as `native_set_block` makes them (a mod block's own, API 15,
// included), in the order `replace_block_entities` takes them: the positions needing a fresh entity.
let name = |s: u32| self.res.facts().get(BlockStateId(s)).name.clone();
let wanted: Vec<Arc<str>> = out
let wanted: Vec<BlockStateId> = out
.tracked
.iter()
.filter(|&&((x, y, z), old, new)| {
@@ -99,9 +102,11 @@ impl Game<'_> {
.get(&(x, y, z))
.is_some_and(|e| e.fits(&name(new)))
})
.map(|&(_, _, new)| name(new))
.map(|&(_, _, new)| BlockStateId(new))
.collect();
let mut made = make_block_entities(&wanted, |n| self.new_block_entity(n)).into_iter();
let mut made =
make_block_entities(&wanted, |state| self.new_block_entity_for_state(*state))
.into_iter();
let facts = self.res.facts();
let removed = replace_block_entities(
&mut self.world,
@@ -384,15 +389,15 @@ fn resolve_runs(
/// A block entity for each new block, in order: made for each position, as vanilla calls
/// `EntityBlock.newBlockEntity` for each (a mod's may number its instances). A block that makes none (stone) is
/// asked once.
fn make_block_entities(
names: &[Arc<str>],
mut make: impl FnMut(&str) -> Option<BlockEntity>,
fn make_block_entities<S: Eq + std::hash::Hash>(
states: &[S],
mut make: impl FnMut(&S) -> Option<BlockEntity>,
) -> Vec<Option<BlockEntity>> {
let mut none: FxHashSet<&str> = FxHashSet::default();
names
let mut none: FxHashSet<&S> = FxHashSet::default();
states
.iter()
.map(|n| {
if none.contains(&**n) {
if none.contains(n) {
return None;
}
let entity = make(n);
@@ -767,7 +772,7 @@ mod tests {
.map(Arc::<str>::from),
|n| {
calls.push(n.to_string());
(n == "machines:press").then(|| {
(n.as_ref() == "machines:press").then(|| {
BlockEntity::Mod(Box::new(
horizon_client::containers::ModBlockEntityFields {
id: format!("press-{}", calls.len()),
@@ -799,4 +804,25 @@ mod tests {
["machines:press", "minecraft:stone", "machines:press"]
);
}
#[test]
fn null_factories_are_cached_per_state_not_per_block_name() {
// One block can create no BE in one state and a BE in another.
// A cached null for the first state must not suppress the second.
let mut calls = Vec::new();
let made = make_block_entities(&[7_u32, 8, 7, 8], |state| {
calls.push(*state);
(*state == 8).then(|| {
BlockEntity::Mod(Box::new(
horizon_client::containers::ModBlockEntityFields {
id: "machines:heater".into(),
..Default::default()
}
.into(),
))
})
});
assert_eq!(calls, [7, 8, 8]);
assert!(made[0].is_none() && made[2].is_none());
assert!(made[1].is_some() && made[3].is_some());
}
}
@@ -21,6 +21,7 @@ impl Game<'_> {
self.tick_craft_table();
self.tick_container_reach();
self.load_entity_types();
self.ensure_stateful_entity_table();
if !self.world.block_entities.has_states() {
let states = self.ticking_states();
self.world.block_entities.set_states(states);
@@ -206,7 +206,13 @@ impl Game<'_> {
// DecoratedPotBlock.getCloneItemStack: createDecoratedPotInstance, the
// sherds only; the pot's item comes with include_data.
let pot = &*facts.name == "minecraft:decorated_pot";
if (include_data || banner || pot)
// CopperGolemStatueBlockEntity.getItem: collectComponents (the golem's
// custom name), data or not.
let statue = matches!(
self.world.block_entities.get(&key),
Some(BlockEntity::CopperGolemStatue(_))
);
if (include_data || banner || pot || statue)
&& let Some(saved) = self.saved_block_entity(key)
{
let mut picked = block_entity_item::pick(&saved);
+3 -2
View File
@@ -50,10 +50,11 @@ impl Game<'_> {
format!("{}[{}]", s.name, props.join(","))
}
fn put_block(&mut self, pos: [i32; 3], state: BlockStateId) -> Result<()> {
let name = self.res.facts().get(state).name.clone();
self.world.block_entities.remove(&(pos[0], pos[1], pos[2]));
self.level_set(pos, state)?;
if let Some(entity) = self.new_block_entity(&name) {
if !self.world.block_entities.stateful_entity(state.0)
&& let Some(entity) = self.new_block_entity_for_state(state)
{
self.world
.block_entities
.insert((pos[0], pos[1], pos[2]), entity);
+129 -4
View File
@@ -9,7 +9,7 @@ use horizon_client::{
Chat, ChatAbilities, ChatAction, ChatScreen, ChatSource, ChatVisibility, Commands,
SYSTEM_INDICATOR, normalize_chat_message,
},
command::Registries,
command::{ArgType, Dispatcher, MOD_COMMAND, Registries},
gamerules::GameRules,
hud::HudBatch,
screen_draw::Measure,
@@ -55,6 +55,91 @@ pub(super) fn with_native_commands(
)
}
/// Add Studio's local command grammar to the suggestion tree. Commands are
/// still dispatched by `studio_command`; this tree supplies coloring,
/// completion, and Tab acceptance in chat.
fn with_studio_commands(base: &Dispatcher) -> Dispatcher {
fn literal(d: &mut Dispatcher, parent: usize, name: &str) -> usize {
let node = d.literal(parent, name);
d.executes(node, MOD_COMMAND);
node
}
fn argument(d: &mut Dispatcher, parent: usize, name: &str, ty: ArgType) -> usize {
let node = d.argument(parent, name, ty);
d.executes(node, MOD_COMMAND);
node
}
let mut d = base.clone();
let root = d.find("studio").unwrap_or_else(|| d.literal(0, "studio"));
d.executes(root, MOD_COMMAND);
let record = literal(&mut d, root, "record");
let start = literal(&mut d, record, "start");
argument(&mut d, start, "name", ArgType::Word);
literal(&mut d, record, "stop");
literal(&mut d, record, "status");
let replay = literal(&mut d, root, "replay");
let open = literal(&mut d, replay, "open");
argument(&mut d, open, "path", ArgType::Greedy);
literal(&mut d, replay, "pause");
literal(&mut d, replay, "resume");
let speed = literal(&mut d, replay, "speed");
argument(
&mut d,
speed,
"multiplier",
ArgType::Double {
min: 0.01,
max: 20.0,
},
);
let seek = literal(&mut d, replay, "seek");
argument(
&mut d,
seek,
"milliseconds",
ArgType::Integer {
min: 0,
max: i32::MAX,
},
);
literal(&mut d, replay, "status");
literal(&mut d, replay, "close");
let freecam = literal(&mut d, root, "freecam");
argument(
&mut d,
freecam,
"state",
ArgType::StrEnum(&["on", "off"], "argument.enum.invalid"),
);
let keyframe = literal(&mut d, root, "keyframe");
literal(&mut d, keyframe, "add");
let remove = literal(&mut d, keyframe, "remove");
argument(
&mut d,
remove,
"index",
ArgType::Integer {
min: 0,
max: i32::MAX,
},
);
literal(&mut d, keyframe, "clear");
let path = literal(&mut d, root, "path");
argument(
&mut d,
path,
"state",
ArgType::StrEnum(&["lock", "unlock"], "argument.enum.invalid"),
);
d
}
impl Game<'_> {
/// The tree the chat box colours, completes and parses with for the
/// world's connection: `base`, the tree the server sent, or the game's own
@@ -66,12 +151,11 @@ impl Game<'_> {
) -> horizon_client::command::Dispatcher {
let mut names = Vec::new();
self.native_commands(&mut names);
names.push("studio");
let base = match base {
Some(tree) => tree,
None => horizon_client::command::vanilla::game_dispatcher(),
};
with_native_commands(base, &names)
with_studio_commands(&with_native_commands(base, &names))
}
}
@@ -2144,6 +2228,43 @@ mod tests {
assert_eq!(shared.nodes.len(), vanilla.nodes.len());
}
#[test]
fn studio_command_tree_suggests_subcommands_and_arguments() {
let reg = Registries::default();
let base = horizon_client::command::vanilla::game_dispatcher();
let tree = with_studio_commands(base);
let suggestions = |input: &str| {
let parse = tree.parse(input, &reg);
tree.client_suggestions(&parse, input.len(), &reg)
.1
.into_iter()
.map(|suggestion| suggestion.text)
.collect::<Vec<_>>()
};
assert_eq!(
suggestions("studio "),
["freecam", "keyframe", "path", "record", "replay"]
);
assert_eq!(suggestions("studio record "), ["start", "status", "stop"]);
assert_eq!(
suggestions("studio replay "),
[
"close", "open", "pause", "resume", "seek", "speed", "status"
]
);
assert_eq!(suggestions("studio freecam "), ["off", "on"]);
assert_eq!(suggestions("studio keyframe "), ["add", "clear", "remove"]);
assert_eq!(suggestions("studio path "), ["lock", "unlock"]);
assert_eq!(
tree.parse("studio replay open some-recording.mcpr", &reg)
.executable()
.unwrap()
.1,
MOD_COMMAND
);
}
/// When `entity_types` is empty (no `entity-types.json`), fallback includes
/// standard mobs, non-mob entities, and `wind_charge` / `breeze_wind_charge`.
#[test]
@@ -2399,7 +2520,11 @@ mod enchantment_snapshot_tests {
let pack = dir.join("datapacks/model");
let tags = pack.join("data/h024/tags/enchantment");
std::fs::create_dir_all(&tags).unwrap();
std::fs::write(pack.join("pack.mcmeta"), "{}").unwrap();
std::fs::write(
pack.join("pack.mcmeta"),
r#"{"pack":{"description":"fixture"}}"#,
)
.unwrap();
std::fs::write(tags.join("empty.json"), r#"{"values":[]}"#).unwrap();
std::fs::write(
dir.join("datapacks.json"),
@@ -166,7 +166,7 @@ impl Game<'_> {
// its hub record, which leave_guest would otherwise overwrite.
self.in_owner_level(|g| {
let mut host = g.lan.take().ok_or("command harness: no hub")?;
g.start_server_trip(&mut host, conn, plan.dim, Some(plan));
g.start_server_trip(&mut host, conn, plan.dim, Some(plan), false);
g.lan = Some(host);
Ok(())
})
@@ -271,9 +271,19 @@ impl Game<'_> {
self.chat_capture = Some(Vec::new());
self.cmds.results = Some(Vec::new());
self.harness_owner(|g| {
let previous = g.command_source;
if op["source"].as_str() == Some("console") {
// Actual server source; permission-only `as_console`
// otherwise keeps the connected owner's entity.
g.command_source = Some(super::commands::execute::Source {
silent: false,
..g.server_source()
});
}
g.as_console(|g| {
g.run_command(command.strip_prefix('/').unwrap_or(command))
});
g.command_source = previous;
Ok(())
})?;
let messages = self.chat_capture.take().unwrap_or_default();
+387 -10
View File
@@ -68,6 +68,8 @@ pub(super) enum Target {
Dragon(u32),
/// A primed TNT, by its entity id (`minecraft:tnt`).
Tnt(u32),
/// A cushion, by its entity id (`minecraft:cushion`).
Cushion(u32),
/// A falling block, by its entity id (`minecraft:falling_block`).
Falling(u32),
/// A firework rocket, by its entity id (`minecraft:firework_rocket`).
@@ -164,6 +166,7 @@ fn non_player_entity_id(t: Target) -> Option<i32> {
| Target::Crystal(id)
| Target::Dragon(id)
| Target::Tnt(id)
| Target::Cushion(id)
| Target::Falling(id)
| Target::Rocket(id)
| Target::Trident(id)
@@ -565,6 +568,11 @@ impl Game<'_> {
.as_ref()
.filter(|d| d.id == id)
.map(|d| [d.position.x, d.position.y, d.position.z]),
Target::Cushion(id) => self
.cushions
.iter()
.find(|c| c.id == id)
.map(|c| [c.position.x, c.position.y, c.position.z]),
Target::Tnt(id) => self
.tnts
.iter()
@@ -639,6 +647,7 @@ impl Game<'_> {
Target::Crystal(_) => "minecraft:end_crystal".into(),
Target::Dragon(_) => "minecraft:ender_dragon".into(),
Target::Tnt(_) => "minecraft:tnt".into(),
Target::Cushion(_) => "minecraft:cushion".into(),
Target::Falling(_) => "minecraft:falling_block".into(),
Target::Rocket(_) => "minecraft:firework_rocket".into(),
Target::Trident(_) => "minecraft:trident".into(),
@@ -915,6 +924,7 @@ impl Game<'_> {
});
candidates.extend(self.item_frames.iter().map(|f| Target::Frame(f.id)));
candidates.extend(self.paintings.iter().map(|p| Target::Painting(p.id)));
candidates.extend(self.cushions.iter().map(|c| Target::Cushion(c.id)));
candidates.extend(self.others.iter().map(|o| Target::Other(o.id)));
candidates.extend(self.fangs.list.iter().map(|f| Target::Fangs(f.id)));
// The End crystals and the dragon (EndCrystal and EnderDragon
@@ -963,6 +973,7 @@ impl Game<'_> {
Target::Crystal(id),
Target::Dragon(id),
Target::Tnt(id),
Target::Cushion(id),
Target::Falling(id),
Target::Rocket(id),
Target::Trident(id),
@@ -992,6 +1003,7 @@ impl Game<'_> {
Target::Crystal(id) => self.crystals.iter().any(|c| c.id == id),
Target::Dragon(id) => self.dragon.as_ref().is_some_and(|d| d.id == id),
Target::Tnt(id) => self.tnts.iter().any(|t| t.id == id),
Target::Cushion(id) => self.cushions.iter().any(|c| c.id == id),
Target::Falling(id) => self.falling.iter().any(|f| f.id == id),
Target::Rocket(id) => self.rockets.iter().any(|r| r.id == id),
Target::Trident(id) => self.tridents.iter().any(|t| t.id == id),
@@ -1724,6 +1736,16 @@ impl Game<'_> {
}
}
}
Target::Cushion(id) => {
// BlockAttachedEntity.setPos: the block it rests on follows the position.
if let Some(c) = self.cushions.iter_mut().find(|c| c.id == id) {
c.position = v;
c.pos = [v.x.floor() as i32, v.y.floor() as i32, v.z.floor() as i32];
if let Some(r) = rotation {
c.yaw = r[0];
}
}
}
Target::Falling(id) => {
if let Some(f) = self.falling.iter_mut().find(|f| f.id == id) {
f.position = v;
@@ -2061,12 +2083,9 @@ impl Game<'_> {
// `time of <clock>` names the clock; otherwise the dimension's default clock.
let clock = match ctx.get("clock") {
Some(Arg::Resource(c)) => c.clone(),
_ => match self.dim {
Dim::Overworld => "minecraft:overworld".to_owned(),
Dim::End => "minecraft:the_end".to_owned(),
Dim::Nether => {
return Err(err("commands.time.no_default_clock", &[&self.dim.id()]));
}
_ => match self.dimension_type().default_clock.clone() {
Some(clock) => clock,
None => return Err(err("commands.time.no_default_clock", &[&self.dim.id()])),
},
};
let total = self.command_clock_time(&clock);
@@ -2361,18 +2380,23 @@ impl Game<'_> {
if let Some(i) = self.boats.iter().position(|b| b.id == id) {
self.release_vehicle_container(id);
let b = self.boats.remove(i);
self.spill_killed_vehicle_contents(b.position, &b.items);
self.boat_removed(&b);
}
}
Target::Cart(id) => {
self.release_vehicle_container(id);
self.carts.retain(|c| c.id != id);
if let Some(i) = self.carts.iter().position(|c| c.id == id) {
let c = self.carts.remove(i);
self.spill_killed_vehicle_contents(c.position, &c.items);
}
}
Target::Other(id) => self.others.retain(|o| o.id != id),
Target::Fangs(id) => self.fangs.list.retain(|f| f.id != id),
Target::Crystal(id) => self.kill_crystal(id),
Target::Dragon(id) => self.kill_dragon(id),
Target::Tnt(id) => self.tnts.retain(|t| t.id != id),
Target::Cushion(id) => self.kill_cushion(id),
Target::Falling(id) => self.falling.retain(|f| f.id != id),
Target::Rocket(id) => self.rockets.retain(|r| r.id != id),
Target::Trident(id) => self.tridents.retain(|t| t.id != id),
@@ -2386,6 +2410,30 @@ impl Game<'_> {
}
self.native_leaves_flush();
}
/// `AbstractChestBoat.remove` / `AbstractMinecartContainer.remove` for
/// destroying removal (`Entity.kill`): `Containers.dropContents`, separate
/// from hurt-time hull drops and after the live menu writes back its slots.
fn spill_killed_vehicle_contents(&mut self, pos: Vec3, slots: &[Option<ItemStack>]) {
for stack in slots {
let count = stack.as_ref().map_or(0, |s| s.count);
for piece in horizon_world::level::removal::drop_item_stack_at(
&mut self.world.random,
pos.components(),
count,
) {
let Some(stack) = stack else { continue };
self.spawn_item(
ItemStack {
count: piece.count,
..stack.clone()
},
piece.pos,
piece.motion,
0,
);
}
}
}
fn cmd_kill(&mut self, ctx: &Context) -> R<i32> {
let targets = if ctx.get("targets").is_some() {
self.entities(ctx, "targets", false)?
@@ -2641,6 +2689,7 @@ impl Game<'_> {
if previous == state {
return Ok(false);
}
self.ensure_stateful_entity_table();
// A different block replaces the block entity (Level.setBlock).
let (old, new) = (
self.res.facts().get(previous).name.clone(),
@@ -2673,9 +2722,10 @@ impl Game<'_> {
// LevelChunk.setBlockState creates it when the block, not the state, is still
// there after onPlace: a powered hopper turns itself `enabled=false`.
let landed = self.world.block(pos[0], pos[1], pos[2]);
if old != new
let needs_entity = !self.world.block_entities.stateful_entity(landed.0) && old != new;
if needs_entity
&& self.res.facts().get(landed).name == new
&& let Some(entity) = self.new_block_entity(&new)
&& let Some(entity) = self.new_block_entity_for_state(landed)
{
self.world.block_entities.insert(key, entity);
}
@@ -2781,7 +2831,15 @@ impl Game<'_> {
let lock_changed = self.world.block_lock(key) != tag.get("lock");
// So is the custom name.
let name_changed = self.load_block_name(key, &new, &tag);
if after == before && !rolled && !lock_changed && !name_changed {
let cooldown_changed = if name == "minecraft:hopper" {
let state = super::machines::HopperState::load(&tag);
let changed = self.hoppers.get(&key).map_or(-1, |s| s.cooldown) != state.cooldown;
self.hoppers.insert(key, state);
changed
} else {
false
};
if after == before && !rolled && !lock_changed && !name_changed && !cooldown_changed {
return false;
}
self.world.block_entities.insert(key, new);
@@ -4413,6 +4471,7 @@ mod look_at_tests {
Target::Crystal(7),
Target::Dragon(7),
Target::Tnt(7),
Target::Cushion(7),
Target::Falling(7),
Target::Rocket(7),
Target::Trident(7),
@@ -4683,6 +4742,205 @@ mod selector_bounds_tests {
}
}
#[cfg(test)]
mod local_coordinate_command_tests {
use super::super::native_machines::tests::server_game;
use super::*;
use horizon_core::position::BlockPos;
use horizon_world::nbt::Tag;
fn pos_bits(tag: &Tag) -> [u64; 3] {
let Some(Tag::List(pos)) = tag.get("Pos") else {
panic!("actual saved Double position missing")
};
assert_eq!(pos.len(), 3);
std::array::from_fn(|i| match &pos[i] {
Tag::Double(bits) => *bits,
other => panic!("actual Pos must be Double: {other:?}"),
})
}
#[test]
#[ignore = "needs prepared resources and scratch homes/cwd"]
fn h149_actual_registered_local_commands_preserve_exact_saved_positions() {
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"));
assert!(game.run_command("fill 0 199 0 15 199 15 stone"));
assert!(game.run_command("scoreboard objectives add h149 dummy"));
let clock = game.world.game_time;
let mut failures = Vec::new();
let cases = [
(
"south",
"execute positioned 8.5 202.25 8.5 rotated 0.0 0.0 run tp 00000095-0000-0001-0000-000000000001 ^0.75 ^0.5 ^1.25",
[
0x4022800000000000u64,
0x4069580000000000u64,
0x4023800000000000u64,
],
false,
),
(
"west",
"execute positioned 8.5 202.25 8.5 rotated 90.0 0.0 run tp 00000095-0000-0001-0000-000000000002 ^0.75 ^0.5 ^1.25",
[
0x401d000000000000u64,
0x4069580000000000u64,
0x4022800000000000u64,
],
false,
),
(
"north",
"execute positioned 8.5 202.25 8.5 rotated 180.0 0.0 run tp 00000095-0000-0001-0000-000000000003 ^0.75 ^0.5 ^1.25",
[
0x401f000000000000u64,
0x4069580000000000u64,
0x401d000000000000u64,
],
false,
),
(
"east",
"execute positioned 8.5 202.25 8.5 rotated 270.0 0.0 run tp 00000095-0000-0001-0000-000000000004 ^0.75 ^0.5 ^1.25",
[
0x4023800000000000u64,
0x4069580000000000u64,
0x401f000000000000u64,
],
false,
),
(
"oblique",
"execute positioned 8.5 202.25 8.5 rotated 37.5 -22.25 run tp 00000095-0000-0001-0000-000000000005 ^0.1 ^0.1 ^0.1",
[
0x402117934a6314b9u64,
0x40694c2c5ee98f58u64,
0x40213562572e889au64,
],
false,
),
(
"positioned_oblique",
"execute positioned 8.5 202.25 8.5 rotated 123.75 47.125 positioned ^0.1 ^0.1 ^0.1 run tp 00000095-0000-0001-0000-000000000006 ~ ~ ~",
[
0x4020a7648c7aa86bu64,
0x406947d514129374u64,
0x4021025e8b180036u64,
],
false,
),
(
"world",
"execute positioned 8.5 202.25 8.5 rotated 37.5 -22.25 run tp 00000095-0000-0001-0000-000000000007 9.25 202.75 9.75",
[
0x4022800000000000u64,
0x4069580000000000u64,
0x4023800000000000u64,
],
true,
),
(
"relative_eyes",
"execute positioned 8.5 202.25 8.5 rotated 37.5 -22.25 anchored eyes run tp 00000095-0000-0001-0000-000000000008 ~0.75 ~0.5 ~1.25",
[
0x4022800000000000u64,
0x4069580000000000u64,
0x4023800000000000u64,
],
true,
),
(
"feet",
"execute as 00000095-0000-0001-0000-000000000009 at @s anchored feet rotated 37.5 -22.25 run tp @s ^ ^ ^",
[
0x4012000000000000u64,
0x4069000000000000u64,
0x4012000000000000u64,
],
true,
),
(
"eyes",
"execute as 00000095-0000-0001-0000-00000000000a at @s anchored eyes rotated 37.5 -22.25 run tp @s ^ ^ ^",
[
0x4012000000000000u64,
0x4069187ae1400000u64,
0x4012000000000000u64,
],
true,
),
];
for (i, (name, command, want, control)) in cases.into_iter().enumerate() {
let number = i as i32 + 1;
let actor = format!("00000095-0000-0001-0000-{number:012x}");
let uuid = [149, 1, 0, number];
assert!(game.run_command(&format!(r#"summon pig 4.5 200 4.5 {{UUID:[I;149,1,0,{number}],Tags:["h149"],Health:10f,NoAI:1b,NoGravity:1b,Silent:1b,PersistenceRequired:1b,Motion:[0d,0d,0d],Rotation:[11f,7f]}}"#)));
let id = *game
.persist
.uuids
.iter()
.find(|(_, u)| **u == uuid)
.unwrap()
.0;
assert!(game.mobs.iter().any(|m| m.id == id));
assert!(game.run_command(&format!("execute if entity @e[type=pig,nbt={{UUID:[I;149,1,0,{number}],Health:10f,NoAI:1b,NoGravity:1b,Pos:[4.5d,200d,4.5d],Motion:[0d,0d,0d],Rotation:[11f,7f]}}]")));
assert!(game.world.loaded(4, 4));
let before = game.entity_nbt(Target::Mob(id)).unwrap();
assert_eq!(pos_bits(&before), [4.5f64, 200.0, 4.5].map(f64::to_bits));
assert_eq!(before.get("UUID"), Some(&Tag::IntArray(uuid.to_vec())));
assert_eq!(before.get("Health"), Some(&Tag::Float(10f32.to_bits())));
// Discard retained raw input: only actual modeled position may prove this.
game.persist.raw.remove(&id);
assert!(game.run_command("scoreboard players set #r h149 91"));
assert!(game.run_command("scoreboard players set #s h149 17"));
assert!(
game.run_command(&format!(
"execute store result score #r h149 store success score #s h149 run {command}"
)),
"actual command accepted: {name}"
);
assert_eq!(game.score.board.score("#r", "h149").unwrap().value, 1);
assert_eq!(game.score.board.score("#s", "h149").unwrap().value, 1);
let model = game.mobs.iter().find(|m| m.id == id).unwrap();
let model_bits =
[model.position.x, model.position.y, model.position.z].map(f64::to_bits);
let saved = game.entity_nbt(Target::Mob(id)).unwrap();
let got = pos_bits(&saved);
assert_eq!(
model_bits, got,
"actual model and typed canonical save agree: {name}"
);
assert_eq!(saved.get("UUID"), before.get("UUID"));
for key in ["Health", "NoAI", "NoGravity", "Rotation", "Motion"] {
assert_eq!(saved.get(key), before.get(key), "unchanged {key}: {name}");
}
assert!(game.run_command(&format!("execute as {actor} at @s if block ~ 199 ~ stone")));
assert!(game.world.loaded(
f64::from_bits(got[0]).floor() as i32,
f64::from_bits(got[2]).floor() as i32,
));
if control {
assert_eq!(
got, want,
"independent world/relative/anchor control {name}"
);
} else if got != want {
failures.push((name, got, want));
}
assert_eq!(game.world.game_time, clock, "zero observation ticks");
}
assert!(
failures.is_empty(),
"actual registered local-coordinate bit mismatches: {failures:#?}"
);
}
}
#[cfg(test)]
mod query_feedback_tests {
#[test]
@@ -4836,3 +5094,122 @@ mod forced_respawn_tests {
}
}
}
#[cfg(test)]
mod vehicle_kill_tests {
use super::*;
use crate::game::BlockEntity;
use crate::game::{Audio, Options, Progress, Settings, load_resources};
use horizon_world::nbt::parse_snbt;
#[test]
#[ignore = "needs prepared resources and scratch homes/cwd"]
fn kill_spills_explicit_vehicle_contents_after_menu_writeback() {
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.build_registries();
game.set_allow_commands(true);
let clock = game.world.game_time;
let mut differences = Vec::new();
for (index, kind) in [
"oak_chest_boat",
"bamboo_chest_raft",
"chest_minecart",
"hopper_minecart",
"oak_boat",
]
.iter()
.enumerate()
{
let uuid = [51, 0, 0, index as i32 + 1];
let source = r#"{Pos:[5.25d,200d,4.75d],NoGravity:1b,Items:[{Slot:0b,id:"minecraft:diamond",count:3,components:{"minecraft:custom_data":{h051:1}}},{Slot:1b,id:"minecraft:dirt",count:5},{Slot:2b,id:"minecraft:stick",count:7}]}"#.to_string();
let (tag, _) = parse_snbt(&source).unwrap();
let boat = kind.contains("boat") || kind.contains("raft");
let (id, mut slots) = if boat {
game.load_boat(tag, Some(uuid), &format!("minecraft:{kind}"));
let b = game.boats.last().unwrap();
assert_eq!(b.position, Vec3::new(5.25, 200.0, 4.75));
(b.id, b.items.clone())
} else {
game.load_cart(tag, Some(uuid), &format!("minecraft:{kind}"));
let c = game.carts.last().unwrap();
(c.id, c.items.clone())
};
let container = index < 4;
assert_eq!(
slots.iter().flatten().map(|s| s.count).sum::<u32>(),
if container { 15 } else { 0 }
);
let target = if boat {
Target::Boat(id)
} else {
Target::Cart(id)
};
assert_eq!(
game.entity_nbt(target).unwrap().get("UUID"),
Some(&horizon_world::nbt::Tag::IntArray(uuid.to_vec()))
);
// The active menu's actual edited slot must win over the older model.
let key = (i32::MAX - 15, crate::game::minecarts::CART_KEY_Y, id as i32);
if container {
slots[0].as_mut().unwrap().count = 4;
game.world
.block_entities
.insert(key, BlockEntity::Chest(slots.clone()));
game.container = Some(key);
}
let old_len = game.dropped.len();
let actor = format!("00000033-0000-0000-0000-{:012x}", index + 1);
assert!(
game.run_command(&format!("kill {actor}")),
"{kind}: actual command admitted"
);
assert!(!game.boats.iter().any(|b| b.id == id));
assert!(!game.carts.iter().any(|c| c.id == id));
assert!(!game.world.block_entities.contains_key(&key));
assert_ne!(game.container, Some(key));
let dropped = &game.dropped[old_len..];
let expected: Vec<_> = slots.into_iter().flatten().collect();
if dropped.len() != expected.len()
|| expected
.iter()
.any(|s| !dropped.iter().any(|d| &d.stack == s))
{
differences.push(format!(
"{kind}: expected {expected:?}, actual {:?}",
dropped.iter().map(|d| &d.stack).collect::<Vec<_>>()
));
}
}
assert_eq!(game.world.game_time, clock);
assert!(
differences.is_empty(),
"kill must spill authoritative explicit contents: {differences:#?}"
);
}
}
@@ -231,6 +231,11 @@ impl Game<'_> {
let i = self.tnts.iter().position(|t| t.id == id)?;
self.tnt_nbt(i)
}
Target::Cushion(id) => {
let uuid = self.uuid_for(id);
let c = self.cushions.iter().find(|c| c.id == id)?;
entity_nbt::cushion_to_nbt(c, uuid, self.persist.raw.get(&id))
}
Target::Falling(id) => {
let f = self.falling.iter().find(|f| f.id == id)?.clone();
let (uuid, raw) = match &f.saved {
@@ -376,6 +381,13 @@ impl Game<'_> {
// cooldown and previous-hit amount are not read from saved NBT.
slot.invulnerable = old.invulnerable;
slot.last_hurt = old.last_hurt;
// Entity.load updates saved data on the existing entity;
// lastTradedPlayer is transient and survives a live NoAI edit.
if slot.species() == horizon_client::mobs::Species::Villager {
horizon_client::mobs::villager::ext_mut(slot).last_traded_player =
horizon_client::mobs::villager::ext(&old)
.and_then(|ext| ext.last_traded_player);
}
slot.ai = old.ai;
// Mob.readAdditionalSaveData updates persistent home fields,
// while retaining the rest of the live AI/runtime state.
@@ -462,6 +474,42 @@ impl Game<'_> {
}
set_key(&mut tag, "UUID", Tag::IntArray(uuid.to_vec()));
}
Target::Falling(id) => {
// Use load_falling's existing converters, but mutate the resident
// instance: loading/spawning would allocate a new id and join.
let (state, position, velocity, age, fall_distance) =
entity_nbt::falling_block_from_nbt(&tag).ok_or_else(stale)?;
let state = self.state_from_nbt(&state).ok_or_else(stale)?;
let (no_gravity, drop_item) = entity_nbt::falling_block_flags(&tag);
let damage = horizon_client::falling_block::Damage::from_nbt(
&tag,
self.res.facts().get(state).name.ends_with("anvil"),
);
let uuid = self.uuid_for(id);
let falling = self
.falling
.iter_mut()
.find(|f| f.id == id)
.ok_or_else(stale)?;
falling.state = state;
falling.position = position;
falling.previous = position;
falling.velocity = velocity;
falling.rotation = entity_nbt::rotation(&tag);
falling.age = age;
falling.no_gravity = no_gravity;
falling.drop_item = drop_item;
falling.fall_distance = fall_distance;
falling.damage = damage;
// Keep id/join, start_pos, portal state and stuck multiplier.
set_key(&mut tag, "UUID", Tag::IntArray(uuid.to_vec()));
set_key(
&mut tag,
"id",
Tag::String("minecraft:falling_block".into()),
);
falling.saved = Some(Box::new((uuid, tag.clone())));
}
Target::Tnt(id) => {
// Entity.load and PrimedTnt.readAdditionalSaveData mutate the
// resident instance; load_tnt would allocate another id/join.
@@ -534,6 +582,7 @@ impl Game<'_> {
| Target::Boat(id)
| Target::Cart(id)
| Target::Tnt(id)
| Target::Cushion(id)
| Target::Other(id)
| Target::Fangs(id) = t
{
@@ -570,6 +619,7 @@ impl Game<'_> {
// `tags_of` reads a primed TNT's compound; without this arm
// `/tag` wrote nothing and still reported success.
| Target::Tnt(id)
| Target::Cushion(id)
| Target::Orb(id)
| Target::Projectile(id)
| Target::Crystal(id)
@@ -617,6 +667,25 @@ impl Game<'_> {
.comparator_outputs
.get(&pos)
.copied()
.or_else(|| {
let saved = self
.world
.chunks
.get(&(p[0] >> 4, p[2] >> 4))?
.saved
.as_deref()?;
let tag = saved.block_entities.iter().find(|t| {
[t.get("x"), t.get("y"), t.get("z")]
.map(|v| save::number(v).unwrap_or(0) as i32)
== p
&& save::text(t.get("id")).as_deref() == Some("minecraft:comparator")
})?;
let tag = horizon_client::block_entity_nbt::canonical(tag)?;
match tag.get("OutputSignal") {
Some(Tag::Int(v)) => Some(*v),
_ => None,
}
})
.unwrap_or(0);
Some(Tag::compound([
("id", Tag::String("minecraft:comparator".into())),
@@ -722,6 +791,13 @@ impl Game<'_> {
let name = self.data_block_name(p);
let e = self.world.block_entities.get(&key)?;
let mut tag = save::block_entity_data(key, e, &name, self.world.block_lock(key));
if name == "minecraft:hopper" {
set_key(
&mut tag,
"TransferCooldown",
Tag::Int(self.hopper_cooldown(key)),
);
}
if let (Some(custom), Tag::Compound(map)) = (self.world.block_names.get(&key), &mut tag) {
map.entry("CustomName".into())
.or_insert_with(|| Tag::String(custom.as_str().into()));
@@ -762,6 +838,10 @@ impl Game<'_> {
return Ok(());
}
if let Some((_, mut new)) = save::decode_block_entity(&tag) {
if self.data_block_name(*p) == "minecraft:hopper" {
self.hoppers
.insert(key, super::super::machines::HopperState::load(&tag));
}
self.take_loot_table(key, &tag, &mut new);
self.load_block_name(key, &new, &tag);
if let (Some(BlockEntity::Mod(current)), BlockEntity::Mod(loaded)) =
@@ -1220,6 +1300,8 @@ impl Game<'_> {
Some(Target::Other(id))
} else if self.tnts.iter().any(|t| t.id == id) {
Some(Target::Tnt(id))
} else if self.cushions.iter().any(|c| c.id == id) {
Some(Target::Cushion(id))
} else if self.falling.iter().any(|f| f.id == id) {
Some(Target::Falling(id))
} else if self.rockets.iter().any(|r| r.id == id) {
@@ -1813,3 +1895,147 @@ mod modern_literal_tests {
assert!(differences.is_empty(), "{}", differences.join("\n"));
}
}
#[cfg(test)]
mod falling_live_adoption_tests {
use crate::game::{Audio, Game, Options, Progress, Settings, commands::Target, load_resources};
use horizon_client::chunk::Chunk;
use horizon_core::{
geometry::Vec3,
position::{BlockPos, ChunkPos},
};
use horizon_world::nbt::Tag;
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.game_dir = Some(
std::path::PathBuf::from(std::env::var_os("HORIZON_HOME").unwrap())
.join(format!("h098-{:?}", std::thread::current().id())),
);
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.build_registries();
game.set_allow_commands(true);
game.world
.chunks
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
game.block_ticks
.add_empty_chunk(ChunkPos::new(0, 0))
.unwrap();
assert!(game.run_command("difficulty normal"));
if !game.streaming.forced.contains(&(0, 0)) {
assert!(game.run_command("forceload add 0 0"));
}
test(&mut game);
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn commands_mutate_the_resident_falling_model_without_rejoining() {
with_game(|game| {
let uuid = "000003d4-0000-0000-0000-000000000001";
assert!(game.run_command("summon falling_block 4.5 200 5.5 {UUID:[I;980,0,0,1],BlockState:\"minecraft:gravel\",Time:5,DropItem:1b,NoGravity:1b,Motion:[0d,0d,0d],Tags:[keep],Silent:1b}"));
assert!(game.run_command(&format!(
"execute as {uuid} if entity @s[type=falling_block]"
)));
let index = game
.falling
.iter()
.position(|f| game.persist.uuid_of(f.id) == Some([980, 0, 0, 1]))
.unwrap();
game.falling[index].start_pos = BlockPos::new(9, 201, 11);
game.falling[index].stuck = Vec3::new(0.2, 0.3, 0.4);
game.falling[index].portal.cooldown = 17;
game.falling[index].portal.process = Some(horizon_client::nether_portal::Process {
entry: BlockPos::new(8, 199, 8),
time: 7,
inside: true,
});
let before = game.falling[index].clone();
assert!(before.join.is_joined());
assert_eq!((before.age, before.drop_item), (5, true));
let next_id = game.next_entity;
let clock = game.world.game_time;
assert!(!game.run_command(&format!("data merge entity {uuid} {{Time:5}}")));
assert!(
game.run_command(&format!("data merge entity {uuid} {{Time:12}}")),
"valid Time update must reach model"
);
assert_eq!(game.falling[index].age, 12);
assert!(game.run_command(&format!("data merge entity {uuid} {{DropItem:0b}}")));
assert!(!game.falling[index].drop_item);
let f = &game.falling[index];
assert_eq!(
(f.id, f.join, f.start_pos, f.stuck),
(before.id, before.join, before.start_pos, before.stuck)
);
assert_eq!(
(
f.position,
f.previous,
f.velocity,
f.rotation,
f.state,
f.no_gravity,
f.damage,
f.fall_distance
),
(
before.position,
before.position,
before.velocity,
before.rotation,
before.state,
before.no_gravity,
before.damage,
before.fall_distance
)
);
assert_eq!(f.portal.cooldown, before.portal.cooldown);
let (a, b) = (f.portal.process.unwrap(), before.portal.process.unwrap());
assert_eq!((a.entry, a.time, a.inside), (b.entry, b.time, b.inside));
assert_eq!(f.saved.as_ref().unwrap().0, [980, 0, 0, 1]);
assert_eq!(game.persist.uuid_of(f.id), Some([980, 0, 0, 1]));
let saved = game.entity_nbt(Target::Falling(f.id)).unwrap();
assert_eq!(saved.get("Time"), Some(&Tag::Int(12)));
assert_eq!(saved.get("DropItem"), Some(&Tag::Byte(0)));
assert_eq!(
saved.get("BlockState"),
Some(&Tag::String("minecraft:gravel".into()))
);
assert_eq!(
saved.get("Tags"),
Some(&Tag::List(vec![Tag::String("keep".into())]))
);
assert_eq!(saved.get("Silent"), Some(&Tag::Byte(1)));
assert_eq!(game.falling.len(), 1);
assert_eq!(game.next_entity, next_id);
assert_eq!(game.world.game_time, clock);
});
}
}
@@ -780,7 +780,28 @@ impl Game<'_> {
_ => Vec::new(),
},
Some("vehicle") => self.ride_vehicle(entity).into_iter().collect(),
Some("controller") | Some("passengers") => match entity {
Some("passengers") => {
let mut passengers = match entity {
Target::Mob(id)
if self.mobs.iter().any(|m| m.id == id && m.rider.is_some()) =>
{
vec![Target::Player]
}
_ => Vec::new(),
};
// Direct runtime children only. Complete mount insertion order is
// not modeled; retain the existing mob collection order here.
if let Target::Mob(id) | Target::Boat(id) = entity {
passengers.extend(
self.mobs
.iter()
.filter(|m| m.extra.mount == Some(id) && m.alive() && !m.removed)
.map(|m| Target::Mob(m.id)),
);
}
passengers
}
Some("controller") => match entity {
Target::Mob(id) if self.mobs.iter().any(|m| m.id == id && m.rider.is_some()) => {
vec![Target::Player]
}
@@ -1225,6 +1246,209 @@ mod tests {
}
}
#[cfg(test)]
mod passenger_relation_tests {
use super::*;
use crate::game::native_machines::tests::server_game;
use horizon_core::position::BlockPos;
use horizon_world::nbt::Tag;
fn actor(i: usize) -> String {
format!("00000096-0000-0002-0000-{i:012x}")
}
fn value(game: &Game<'_>, i: usize, objective: &str) -> i32 {
game.score.board.score(&actor(i), objective).unwrap().value
}
fn vehicle(game: &mut Game<'_>, child: usize, parent: usize) {
assert!(game.run_command("scoreboard players set #r h150u 91"));
assert!(game.run_command("scoreboard players set #s h150u 17"));
assert!(game.run_command(&format!(
"execute store result score #r h150u store success score #s h150u as {} on vehicle if entity @s[nbt={{UUID:[I;150,2,0,{parent}]}}]", actor(child)
)));
assert_eq!(game.score.board.score("#r", "h150u").unwrap().value, 1);
assert_eq!(game.score.board.score("#s", "h150u").unwrap().value, 1);
}
#[test]
#[ignore = "needs prepared resources and scratch homes/cwd"]
fn h150_actual_registered_passengers_select_direct_live_mob_and_boat_children() {
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"));
assert!(game.run_command("fill 0 199 0 15 199 15 stone"));
for objective in ["h150u", "h150ur", "h150us"] {
assert!(game.run_command(&format!("scoreboard objectives add {objective} dummy")));
}
let clock = game.world.game_time;
// 1->2->3 is a nested mob tree; 4->5 a boat; 6->7,8 two direct children.
let kinds = [
"cow", "pig", "chicken", "oak_boat", "cow", "cow", "pig", "chicken", "pig", "cow",
];
let mut ids = Vec::new();
for (offset, kind) in kinds.into_iter().enumerate() {
let i = offset + 1;
let extra = if kind == "oak_boat" {
""
} else {
",NoAI:1b,Health:1f"
};
assert!(game.run_command(&format!(
"summon {kind} 8.5 200 8.5 {{UUID:[I;150,2,0,{i}],Tags:[\"h150u\"],NoGravity:1b,Silent:1b,Motion:[0d,0d,0d]{extra}}}"
)));
let words = [150, 2, 0, i as i32];
let id = *game
.persist
.uuids
.iter()
.find(|(_, u)| **u == words)
.unwrap()
.0;
let target = if kind == "oak_boat" {
Target::Boat(id)
} else {
Target::Mob(id)
};
assert_eq!(game.target_type(target), format!("minecraft:{kind}"));
assert_eq!(game.target_position(target), Some([8.5, 200.0, 8.5]));
let saved = game.entity_nbt(target).unwrap();
assert_eq!(saved.get("UUID"), Some(&Tag::IntArray(words.to_vec())));
if kind != "oak_boat" {
assert_eq!(saved.get("Health"), Some(&Tag::Float(1f32.to_bits())));
assert_eq!(saved.get("NoAI"), Some(&Tag::Byte(1)));
}
assert!(game.world.loaded(8, 8));
assert!(game.run_command("execute if block 8 199 8 stone"));
// Membership must come from runtime links; no retained Passengers body remains.
game.persist.raw.remove(&id);
ids.push(id);
}
for (child, parent) in [(2, 1), (3, 2), (5, 4), (7, 6), (8, 6)] {
assert!(game.run_command(&format!("ride {} mount {}", actor(child), actor(parent))));
assert_eq!(
game.mobs
.iter()
.find(|m| m.id == ids[child - 1])
.unwrap()
.extra
.mount,
Some(ids[parent - 1])
);
vehicle(&mut game, child, parent);
}
let mut failures = Vec::new();
for (phase, parent, depth, selected) in [
("direct", 1, 1, vec![2]),
("nested", 1, 2, vec![3]),
("boat", 4, 1, vec![5]),
("multiple", 6, 1, vec![7, 8]),
("empty", 9, 1, vec![]),
] {
for (objective, n) in [("h150u", 0), ("h150ur", 91), ("h150us", 17)] {
for i in 1..=10 {
assert!(game.run_command(&format!(
"scoreboard players set {} {objective} {n}",
actor(i)
)));
}
}
let nested = if depth == 2 { " on passengers" } else { "" };
game.cmds.results = Some(Vec::new());
game.chat_capture = Some(Vec::new());
assert!(game.run_command(&format!(
"execute as {} on passengers{nested} store result score @s h150ur store success score @s h150us run scoreboard players add @s h150u 1", actor(parent)
)));
let callbacks = game.cmds.results.take().unwrap();
let messages: Vec<_> = game
.chat_capture
.take()
.unwrap()
.iter()
.map(|t| t.plain())
.collect();
println!("H150 actual {phase}: callbacks={callbacks:?}, feedback={messages:?}");
let expected = vec![(true, 1); selected.len()];
if selected.is_empty() {
assert_eq!(callbacks, expected, "independent empty parent callback");
} else if callbacks != expected {
failures.push(format!("{phase} callbacks {callbacks:?} != {expected:?}"));
}
for i in 1..=10 {
let yes = selected.contains(&i);
let got = (
value(&game, i, "h150u"),
value(&game, i, "h150ur"),
value(&game, i, "h150us"),
);
let want = if yes { (1, 1, 1) } else { (0, 91, 17) };
if phase == "empty" || !yes {
assert_eq!(
got, want,
"independent empty/unrelated/no-descendant control: {phase} {i}"
);
} else if got != want {
failures.push(format!("{phase} {i}: {got:?} != {want:?}"));
}
}
}
assert!(game.run_command(&format!("ride {} dismount", actor(2))));
assert_eq!(
game.mobs
.iter()
.find(|m| m.id == ids[1])
.unwrap()
.extra
.mount,
None
);
vehicle(&mut game, 3, 2);
for phase in ["dismounted", "remounted"] {
if phase == "remounted" {
assert!(game.run_command(&format!("ride {} mount {}", actor(2), actor(1))));
vehicle(&mut game, 2, 1);
}
for (objective, n) in [("h150u", 0), ("h150ur", 91), ("h150us", 17)] {
for i in 1..=10 {
assert!(game.run_command(&format!(
"scoreboard players set {} {objective} {n}",
actor(i)
)));
}
}
assert!(game.run_command(&format!(
"execute as {} on passengers store result score @s h150ur store success score @s h150us run scoreboard players add @s h150u 1", actor(1)
)));
for i in [1, 2, 3, 10] {
let got = (
value(&game, i, "h150u"),
value(&game, i, "h150ur"),
value(&game, i, "h150us"),
);
let want = if phase == "remounted" && i == 2 {
(1, 1, 1)
} else {
(0, 91, 17)
};
if phase == "dismounted" || i != 2 {
assert_eq!(
got, want,
"independent dismount/unrelated control: {phase} {i}"
);
} else if got != want {
failures.push(format!("{phase} {i}: {got:?} != {want:?}"));
}
}
}
assert_eq!(game.world.game_time, clock, "zero actual observation ticks");
assert!(
failures.is_empty(),
"actual registered direct passenger branch/callback/store failures: {failures:?}"
);
}
}
#[cfg(test)]
mod terminal_fork_tests {
use super::Game;
@@ -779,8 +779,79 @@ impl Game<'_> {
if count > max_stack {
return Err(err("arguments.item.overstacked", &[&id, &max_stack]));
}
// ItemInput.createItemStack validates effective components after count.
// Materialization currently discards removals, so inspect the validated
// command patch itself without changing the stack or its saved patch.
let components = self.item_validation_components(&id, components.as_deref())?;
if components.get(&"minecraft:max_damage".to_owned()).is_some()
&& horizon_world::datafix::dynamic::as_int(
components.get(&"minecraft:max_stack_size".to_owned()),
1,
) > 1
{
return Err(err(
"arguments.item.malformed",
&[&"Item cannot be both damageable and stackable"],
));
}
Ok(Some(stack))
}
/// ItemInput.createItemStack: read-only view of the two prototype components used by this command
/// validation, with all typed patch operations (including removals).
fn item_validation_components(
&self,
id: &str,
components: Option<&str>,
) -> R<horizon_world::component_patch::ComponentMap<String, Tag>> {
use horizon_client::command::{Reader, args::read_identifier, snbt::parse_tag_argument};
use horizon_world::component_patch::{ComponentMap, Patch, Prototype};
let mut prototype = Vec::new();
if let Some(item) = self.items().get(id) {
for (key, value) in [
("minecraft:max_damage", item.max_damage),
("minecraft:max_stack_size", item.max_stack),
] {
if item.components.iter().any(|component| component == key) {
prototype.push((key.to_owned(), Tag::Int(value as i32)));
}
}
}
let mut patch = Vec::new();
if let Some(components) = components {
// ItemInput has already validated IDs, values and duplicates. Reuse
// its token/value parsers; do not derive removals from saved output.
let mut reader = Reader::new(components);
reader.expect('[')?;
reader.skip_whitespace();
while reader.can_read() && reader.peek() != ']' {
let removed = reader.peek() == '!';
if removed {
reader.skip();
}
let key = read_identifier(&mut reader)?;
reader.skip_whitespace();
let value = if removed {
None
} else {
reader.expect('=')?;
reader.skip_whitespace();
Some(parse_tag_argument(&mut reader)?)
};
patch.push((key, value));
reader.skip_whitespace();
if reader.peek() == ']' {
break;
}
reader.expect(',')?;
reader.skip_whitespace();
}
reader.expect(']')?;
}
Ok(ComponentMap::from_patch(
Prototype::new(prototype),
&Patch::new(patch),
))
}
/// One item modifier (a typed object, a list or a datapack reference)
/// applied to a stack; `None` when the stack ends up empty.
fn apply_item_modifier(
@@ -1663,6 +1734,93 @@ mod mob_equipment_command_tests {
test(&mut game);
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn world_pack_enchantments_reach_actual_game_catalog() {
use horizon_client::enchant::EffectContext;
use horizon_core::random::LegacyRandom;
with_game(|game| {
let clock = game.world.game_time;
game.world_root = std::path::PathBuf::from(std::env::var_os("HORIZON_HOME").unwrap())
.join("enchantment-catalog-world");
let pack = game.world_root.join("datapacks/catalog");
let definitions = pack.join("data/wpenchant/enchantment");
std::fs::create_dir_all(&definitions).unwrap();
std::fs::write(
pack.join("pack.mcmeta"),
r#"{"pack":{"description":"World enchantment catalog fixture"}}"#,
)
.unwrap();
let definition = serde_json::json!({
"description": "World catalog loaded", "supported_items": "minecraft:diamond_sword",
"weight": 1, "max_level": 1,
"min_cost": {"base": 1, "per_level_above_first": 0},
"max_cost": {"base": 1, "per_level_above_first": 0},
"anvil_cost": 1, "slots": ["mainhand"],
"effects": {"minecraft:damage": [{"effect": {"type": "minecraft:add", "value": 2.0}}]}
});
std::fs::write(
definitions.join("loaded.json"),
serde_json::to_vec(&definition).unwrap(),
)
.unwrap();
std::fs::write(
game.world_root.join("datapacks.json"),
r#"{"enabled":["file/catalog"]}"#,
)
.unwrap();
let functions = pack.join("data/wpenchant/function");
std::fs::create_dir_all(&functions).unwrap();
std::fs::write(functions.join("ready.mcfunction"), "return 1\n").unwrap();
game.reload_datapacks(false);
assert!(game.fns.store.functions.contains_key("wpenchant:ready"));
game.reload_world_tables();
let raw_present = game
.pack_files()
.kind("enchantment")
.any(|(id, files)| id == "wpenchant:loaded" && !files.is_empty());
assert!(
raw_present,
"pack function admitted, but enchantment raw file absent; actual catalog entry present={}",
game.enchantments().get("wpenchant:loaded").is_some()
);
assert!(std::sync::Arc::ptr_eq(
&game.items().enchantments,
&game.stations().enchantments
));
assert!(game.enchantments().get("minecraft:mending").is_some());
assert!(game.enchantments().get("wpenchant:missing").is_none());
let entry = game
.enchantments()
.get("wpenchant:loaded")
.expect("the active Game catalog must adopt the loaded world-pack definition");
assert_eq!(entry.description, "World catalog loaded");
assert_eq!(entry.max_level, 1);
assert!(game.enchantments().can_enchant(
game.items(),
"wpenchant:loaded",
"minecraft:diamond_sword"
));
assert!(!game.enchantments().can_enchant(
game.items(),
"wpenchant:loaded",
"minecraft:stone"
));
assert_eq!(
game.enchantments().modify_value_with(
"wpenchant:loaded",
1,
"minecraft:damage",
&EffectContext::default(),
&mut LegacyRandom::new(0),
1.0
),
3.0
);
assert_eq!(game.world.game_time, clock);
});
}
fn actor(game: &mut Game<'_>, kind: &str, n: i32) -> (String, u32) {
let uuid = format!("00000019-0000-0000-0000-{n:012x}");
assert!(game.run_command(&format!("summon {kind} 4.5 200 5.5 {{UUID:[I;25,0,0,{n}],NoAI:1b,NoGravity:1b,Silent:1b,h025_raw:7,Tags:[h025]}}")), "{kind}/{n}: {:?}", game.ui.chat.recent_lines(3));
@@ -1792,6 +1950,90 @@ mod mob_equipment_command_tests {
}
});
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn effective_item_composition_rejects_before_destination_mutation() {
use super::Slot;
with_game(|game| {
assert!(game.run_command("setblock 4 200 4 chest"));
let slot = Slot::Block([4, 200, 4], 0);
for item in [
"iron_helmet[max_stack_size=2]",
"iron_helmet[max_stack_size=99]",
"stone[max_damage=10,max_stack_size=2]",
] {
assert!(game.run_command("item replace block 4 200 4 container.0 with emerald[custom_data={sentinel:86}] 4"));
let before = game.slot_get(&slot).unwrap();
assert!(
!game.run_command(&format!(
"item replace block 4 200 4 container.0 with {item}"
)),
"invalid effective composition must reject {item}"
);
assert_eq!(game.slot_get(&slot), Some(before));
}
for item in [
"iron_helmet",
"stone[max_stack_size=2]",
"stone[max_stack_size=99]",
"iron_helmet[!max_damage,max_stack_size=2]",
] {
assert!(
game.run_command(&format!(
"item replace block 4 200 4 container.0 with {item}"
)),
"valid effective composition must accept {item}"
);
}
});
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn effective_item_validation_view_uses_loaded_prototypes_and_typed_removals() {
use horizon_world::nbt::Tag;
with_game(|game| {
let damage = "minecraft:max_damage".to_owned();
let size = "minecraft:max_stack_size".to_owned();
let helmet = game.items().get("minecraft:iron_helmet").unwrap();
assert!(helmet.components.contains(&damage));
let plain = game
.item_validation_components("minecraft:iron_helmet", None)
.unwrap();
assert_eq!(
plain.get(&damage),
Some(&Tag::Int(helmet.max_damage as i32))
);
assert_eq!(plain.get(&size), Some(&Tag::Int(helmet.max_stack as i32)));
let removed = game
.item_validation_components(
"minecraft:iron_helmet",
Some("[!max_damage,max_stack_size=2,custom_data={keep:86}]"),
)
.unwrap();
assert!(removed.get(&damage).is_none());
assert_eq!(removed.get(&size), Some(&Tag::Int(2)));
assert!(removed.get(&"minecraft:custom_data".to_owned()).is_some());
let fallback = game
.item_validation_components("minecraft:iron_helmet", Some("[!max_stack_size]"))
.unwrap();
assert!(fallback.get(&damage).is_some());
assert_eq!(
horizon_world::datafix::dynamic::as_int(fallback.get(&size), 1),
1
);
let stone = game
.item_validation_components("minecraft:stone", None)
.unwrap();
assert!(stone.get(&damage).is_none());
assert_eq!(
stone.get(&size),
Some(&Tag::Int(
game.items().get("minecraft:stone").unwrap().max_stack as i32
))
);
});
}
}
#[cfg(test)]
@@ -94,9 +94,29 @@ impl Game<'_> {
let pos = self.place_pos(ctx, "pos")?;
let (cx, cz) = (pos[0] >> 4, pos[2] >> 4);
self.place_check_loaded([cx - 1, cz - 1], [cx + 1, cz + 1])?;
// Inline features are not built by the live feature registry.
// ResourceOrHolderArgument: an inline feature value goes
// through the same codec and `Feature.place`; a value that
// does not decode keeps the plain failure.
if raw.starts_with('{') {
return Err(err("command.failed", &[]));
let (tag, _) = horizon_world::nbt::parse_snbt(&raw)
.map_err(|_| err("command.failed", &[]))?;
let json = horizon_client::scoreboard::tag_to_json(&tag).to_string();
let at = horizon_core::position::BlockPos::new(pos[0], pos[1], pos[2]);
let placed = self
.with_level(|b, level, ticks| {
b.place_inline_feature(level, ticks, &json, at)
})
.ok_or_else(|| err("command.failed", &[]))?;
self.process_level_events()
.map_err(|_| err("command.failed", &[]))?;
if !placed {
return Err(err("commands.place.feature.failed", &[]));
}
self.feedback(Text::translate(
"commands.place.feature.success.inline",
&[num(pos[0]), num(pos[1]), num(pos[2])],
));
return Ok(1);
}
let name = if raw.contains(':') {
raw
@@ -258,3 +278,208 @@ impl Game<'_> {
Ok(1)
}
}
#[cfg(test)]
mod live_dungeon_spawner_tests {
use super::*;
use crate::game::{Audio, Options, Progress, Settings, load_resources};
use horizon_client::{
chunk::Chunk,
containers::BlockEntity,
mobs::spawner::{SpawnDataExt, SpawnerExt},
motion::PlayerMotion,
};
use horizon_core::{geometry::Vec3, position::ChunkPos, random::RandomSource};
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.game_dir = Some(
std::path::PathBuf::from(std::env::var_os("HORIZON_HOME").unwrap())
.join(format!("h173-{:?}", std::thread::current().id())),
);
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
crate::game::growth::install_live_features(&loaded.resources, &loaded.pack, &options);
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.build_registries();
game.set_allow_commands(true);
for cx in -1..=1 {
for cz in -1..=1 {
game.world
.chunks
.insert((cx, cz), Arc::new(Chunk::empty(cx, cz)));
game.block_ticks
.add_empty_chunk(ChunkPos::new(cx, cz))
.unwrap();
}
}
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(2.5, 200.0, 2.5)).unwrap());
game.creative = true;
assert!(game.run_command("forceload add -16 -16 31 31"));
assert!(game.run_command("tick freeze"));
assert!(game.run_command("fill 0 198 0 31 199 31 stone"));
test(&mut game);
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_registered_live_dungeon_keeps_its_real_selected_spawner() {
with_game(|game| {
let clock = game.world.game_time;
assert!(
game.res
.behaviours()
.live_features()
.unwrap()
.configured("minecraft:monster_room")
.is_some()
);
for cmd in [
"fill 3 199 3 13 204 13 barrier strict",
"fill 3 199 3 13 204 13 stone strict",
"fill 6 200 6 10 203 10 air strict",
"fill 11 200 8 13 201 8 air strict",
"setblock 24 200 24 spawner{SpawnData:{entity:{id:\"minecraft:zombie\"}},Delay:20s} strict",
] {
assert!(game.run_command(cmd), "{cmd}");
}
assert!(game.run_command("execute if block 8 200 8 air"));
assert!(game.world.block_entities.get(&(8, 200, 8)).is_none());
let Some(BlockEntity::Spawner(direct)) = game.world.block_entities.get(&(24, 200, 24))
else {
panic!("direct spawner positive")
};
assert_eq!(
direct.next_data().and_then(|d| d.id()).as_deref(),
Some("minecraft:zombie")
);
assert!(game.world.chunks.contains_key(&(-1, -1)));
assert!(game.world.chunks.contains_key(&(1, 1)));
game.world.random.set_seed(173);
assert!(
game.run_command("place feature minecraft:monster_room 8 200 8"),
"actual live feature admission"
);
assert!(game.run_command("execute if block 8 200 8 spawner"));
let mut after = game.world.random.clone();
let continuation: Vec<i64> = (0..4).map(|_| after.next_long()).collect();
println!(
"h173-continuation {}",
serde_json::to_string(&continuation).unwrap()
);
let Some(BlockEntity::Spawner(spawner)) = game.world.block_entities.get(&(8, 200, 8))
else {
panic!("actual generated spawner is missing from live collection")
};
let chosen = spawner
.next_data()
.and_then(|d| d.id())
.expect("actual selected entity");
assert!(
["minecraft:skeleton", "minecraft:zombie", "minecraft:spider"]
.contains(&chosen.as_str())
);
assert!(spawner.potentials.is_empty());
let tag = game.block_entity_nbt([8, 200, 8]).unwrap();
for (key, value) in [
("id", Tag::String("minecraft:mob_spawner".into())),
("x", Tag::Int(8)),
("y", Tag::Int(200)),
("z", Tag::Int(8)),
] {
assert_eq!(tag.get(key), Some(&value));
}
assert_eq!(
tag.get("SpawnData")
.and_then(|t| t.get("entity"))
.and_then(|t| t.get("id")),
Some(&Tag::String(chosen.as_str().into()))
);
assert!(game.run_command("execute if data block 8 200 8 SpawnData.entity.id"));
assert!(game.run_command("execute if data block 24 200 24 {Delay:20s,SpawnData:{entity:{id:\"minecraft:zombie\"}}}"));
assert_eq!(game.world.game_time, clock);
});
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn creation_boundary_preserves_existing_entries_and_rejects_other_blocks() {
with_game(|game| {
use horizon_world::level::LevelAccess;
let request=match horizon_world::nbt::parse_snbt(r#"{id:"minecraft:mob_spawner",SpawnData:{entity:{id:"minecraft:skeleton"}},SpawnPotentials:[]}"#).unwrap().0 {Tag::Compound(c)=>c,_=>unreachable!()};
assert!(game.run_command(r#"setblock 24 200 24 spawner{Delay:77s,MaxNearbyEntities:9s,SpawnData:{entity:{id:"minecraft:zombie",UUID:[I;173,4,5,6],Health:7f}},SpawnPotentials:[]} strict"#));
if let Some(BlockEntity::Spawner(s)) = game.world.block_entities.get_mut(&(24, 200, 24))
{
s.spin = 12.5;
s.o_spin = 6.25;
s.client_delay = 33;
}
let before = game.block_entity_nbt([24, 200, 24]).unwrap();
assert_eq!(
before
.get("SpawnData")
.and_then(|v| v.get("entity"))
.and_then(|v| v.get("UUID")),
Some(&Tag::IntArray(vec![173, 4, 5, 6]))
);
let model = format!("{:?}", game.world.block_entities.get(&(24, 200, 24)));
let random = game.world.random.clone().next_long();
game.with_level(|_, level, _| {
level.create_feature_spawner(BlockPos::new(24, 200, 24), request.clone())
});
assert_eq!(game.block_entity_nbt([24, 200, 24]).unwrap(), before);
assert_eq!(
format!("{:?}", game.world.block_entities.get(&(24, 200, 24))),
model
);
assert_eq!(game.world.random.clone().next_long(), random);
assert!(game.run_command("setblock 26 200 24 spawner strict"));
let mut wrong = BlockEntity::for_block("minecraft:chest").unwrap();
wrong.slots_mut()[0] = Some(horizon_client::items::ItemStack::new(
"minecraft:diamond",
3,
));
game.world.block_entities.insert((26, 200, 24), wrong);
let before = format!("{:?}", game.world.block_entities.get(&(26, 200, 24)));
game.with_level(|_, level, _| {
level.create_feature_spawner(BlockPos::new(26, 200, 24), request.clone())
});
assert_eq!(
format!("{:?}", game.world.block_entities.get(&(26, 200, 24))),
before
);
assert!(game.run_command("setblock 28 200 24 stone strict"));
let revision = game.world.revision;
let count = game.world.block_entities.len();
game.with_level(|_, level, _| {
level.create_feature_spawner(BlockPos::new(28, 200, 24), request.clone())
});
assert!(game.world.block_entities.get(&(28, 200, 24)).is_none());
assert_eq!(game.world.revision, revision);
assert_eq!(game.world.block_entities.len(), count);
});
}
}
@@ -60,6 +60,11 @@ impl Game<'_> {
.iter()
.find(|e| e.id == id)
.map(|e| extents(e.bounds())),
Target::Cushion(id) => self
.cushions
.iter()
.find(|e| e.id == id)
.map(|e| extents(e.bounds())),
Target::Crystal(id) => self
.crystals
.iter()
@@ -620,24 +620,70 @@ impl Game<'_> {
None | Some(Some(Target::Player))
);
let held = self.slot_get(&super::items::Slot::Inv(self.selected));
// LootCommand.dropKillLoot's THIS_ENTITY is the selected live mob.
let target = match t {
Target::Mob(id) => self
.mobs
.iter()
.find(|m| m.id == id)
.map(|m| horizon_client::mobs::drops::loot_entity(m, None)),
_ => None,
};
let loot = target.as_ref().and_then(|_| {
horizon_client::structure_blocks::loot_data(
&self.options.resources,
&self.pack_files(),
)
});
let matcher =
|predicate: &serde_json::Value, entity: &horizon_world::loot::LootEntity| {
loot.as_ref()
.is_some_and(|data| data.entity_matches(predicate, Some(entity)))
};
let drops = match world_items!(self).loot_sequence(&table) {
Some(seq) => {
let random = self.rand_sequences.get(&seq, self.level.seed);
world_items!(self).entity_drops_for_command(
&ty,
by_player,
held.as_ref(),
random,
keep_empty,
)
if let Some(target) = target.as_ref().filter(|_| loot.is_some()) {
world_items!(self).entity_drops_with_target(
&ty,
by_player,
held.as_ref(),
target,
&matcher,
random,
keep_empty,
)
} else {
world_items!(self).entity_drops_for_command(
&ty,
by_player,
held.as_ref(),
random,
keep_empty,
)
}
}
None => {
if let Some(target) = target.as_ref().filter(|_| loot.is_some()) {
world_items!(self).entity_drops_with_target(
&ty,
by_player,
held.as_ref(),
target,
&matcher,
&mut self.world.random,
keep_empty,
)
} else {
world_items!(self).entity_drops_for_command(
&ty,
by_player,
held.as_ref(),
&mut self.world.random,
keep_empty,
)
}
}
None => world_items!(self).entity_drops_for_command(
&ty,
by_player,
held.as_ref(),
&mut self.world.random,
keep_empty,
),
};
(drops, Some(table))
}
@@ -652,6 +698,9 @@ impl Game<'_> {
return Err(err("commands.drop.no_loot_table.block", &[&name]));
};
let tool = self.tool_stack(ctx)?;
// LootCommand.dropBlockLoot: optional THIS_ENTITY is the actual
// source entity, including non-player executors such as stands.
let executor_present = self.source_entity().is_some();
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) {
@@ -671,6 +720,7 @@ impl Game<'_> {
state,
tool.as_ref(),
random,
executor_present,
keep_empty,
)
}
@@ -679,6 +729,7 @@ impl Game<'_> {
state,
tool.as_ref(),
&mut self.world.random,
executor_present,
keep_empty,
),
};
@@ -1085,6 +1136,118 @@ mod loot_mine_component_tests {
"actual loot-mine component-copy failures: {failures:#?}"
);
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and scratch homes/cwd"]
fn loot_mine_uses_the_actual_stand_or_server_executor_presence() {
let root = std::path::PathBuf::from(
std::env::var_os("HORIZON_TEST_RESOURCES").expect("prepared resources required"),
);
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.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();
assert!(game.run_command("forceload add 0 0"));
assert!(game.run_command("fill 0 199 0 15 199 15 stone"));
game.command_source = Some(crate::game::commands::execute::Source {
silent: false,
..game.server_source()
});
assert!(game.source_entity().is_none());
assert!(game.run_command("execute unless entity @s"));
assert!(game.run_command("setblock 12 200 4 chest strict"));
assert!(game.run_command("setblock 2 200 4 snow[layers=1] strict"));
assert!(game.run_command("setblock 4 200 4 dirt strict"));
assert!(game.run_command("item replace block 12 200 4 container.26 with diamond 7"));
assert!(game.run_command("item replace block 12 200 4 container.2 with diamond_shovel"));
assert_eq!(&*slot(&game, 2).item, "minecraft:diamond_shovel");
assert!(game.run_command("execute if items block 12 200 4 container.2 #minecraft:shovels"));
assert!(game.run_command(
"summon armor_stand 8.5 200 8.5 {UUID:[I;60,0,0,1],Health:20f,NoGravity:1b}"
));
let stand = "0000003c-0000-0000-0000-000000000001";
assert!(game.run_command(&format!("execute as {stand} if entity @s[type=armor_stand,nbt={{UUID:[I;60,0,0,1],Health:20f,NoGravity:1b,Pos:[8.5d,200d,8.5d]}}]")));
let id = game
.armor_stands
.iter()
.find(|s| game.persist.uuids.get(&s.id) == Some(&[60, 0, 0, 1]))
.unwrap()
.id;
let before_stand = game.entity_nbt(Target::Stand(id)).unwrap();
assert_eq!(
before_stand.get("UUID"),
Some(&Tag::IntArray(vec![60, 0, 0, 1]))
);
let clock = game.world.game_time;
// Unconditional dirt proves both sources really execute the same path.
for (as_stand, x, expected) in [
(false, 4, "minecraft:dirt"),
(true, 4, "minecraft:dirt"),
(false, 2, "minecraft:emerald"),
(true, 2, "minecraft:snowball"),
] {
assert!(game.source_entity().is_none());
assert!(game.run_command("item replace block 12 200 4 container.0 with emerald 4"));
let before = slot(&game, 0);
let state = game.world.block(x, 200, 4);
let prefix = if as_stand {
format!("execute as {stand} run ")
} else {
String::new()
};
assert!(game.run_command(&format!(
"{prefix}loot replace block 12 200 4 container.0 mine {x} 200 4 diamond_shovel"
)));
let actual = slot(&game, 0);
assert_eq!(
&*actual.item, expected,
"executor stand={as_stand}, block x={x}"
);
if !as_stand && x == 2 {
assert_eq!(actual, before);
} else {
assert_eq!(actual.count, 1);
}
assert_eq!(
(&*slot(&game, 26).item, slot(&game, 26).count),
("minecraft:diamond", 7)
);
assert_eq!(&*slot(&game, 2).item, "minecraft:diamond_shovel");
assert_eq!(game.world.block(x, 200, 4), state);
assert_eq!(game.entity_nbt(Target::Stand(id)).unwrap(), before_stand);
assert_eq!(game.world.game_time, clock);
}
}
}
#[cfg(test)]
@@ -1191,3 +1354,119 @@ mod loot_position_tests {
assert_eq!(game.world.game_time, time);
}
}
#[cfg(test)]
mod loot_kill_target_tests {
use super::*;
use crate::game::{Audio, Options, Progress, Settings, load_resources};
use horizon_client::{chunk::Chunk, containers::BlockEntity};
use std::sync::Arc;
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and scratch homes/cwd"]
fn loot_kill_uses_actual_sheep_state_without_killing_the_target() {
let root = std::path::PathBuf::from(
std::env::var_os("HORIZON_TEST_RESOURCES").expect("prepared resources required"),
);
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.game_dir = Some(
std::path::PathBuf::from(std::env::var_os("HORIZON_HOME").unwrap()).join("h036-game"),
);
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.build_registries();
game.set_allow_commands(true);
game.world
.chunks
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
game.block_ticks
.add_empty_chunk(ChunkPos::new(0, 0))
.unwrap();
assert!(game.run_command("forceload add 0 0"));
assert!(game.run_command("fill 0 199 0 15 199 15 stone"));
let clock = game.world.game_time;
let mut differences = Vec::new();
for (n, color, sheared, wool) in [
(1, 0, true, "minecraft:white_wool"),
(2, 14, true, "minecraft:red_wool"),
(3, 0, false, "minecraft:white_wool"),
(4, 14, false, "minecraft:red_wool"),
] {
let uuid = format!("00000024-0000-0000-0000-{n:012x}");
assert!(game.run_command(&format!(
"summon sheep 4.5 200 4.5 {{UUID:[I;36,0,0,{n}],Health:8f,NoAI:1b,NoGravity:1b,Age:0,Color:{color}b,Sheared:{}b,Motion:[0d,0d,0d]}}", i32::from(sheared)
)));
let mob = game
.mobs
.iter()
.find(|m| game.persist.uuid_of(m.id) == Some([36, 0, 0, n]))
.unwrap();
assert_eq!(mob.kind.id, "minecraft:sheep");
assert_eq!(mob.health, 8.0);
assert!(mob.no_ai && mob.no_gravity);
assert_eq!(mob.extra.color, color);
assert_eq!(mob.extra.sheared, sheared);
let before = (mob.id, mob.position, mob.velocity, mob.health);
let x = 2 + n * 3;
assert!(game.run_command(&format!(
"setblock {x} 200 8 chest[facing=north,type=single,waterlogged=false] strict"
)));
let Some(BlockEntity::Chest(initial)) = game.world.block_entities.get(&(x, 200, 8))
else {
panic!("real chest required")
};
assert!(initial.iter().all(Option::is_none));
assert!(game.run_command(&format!("loot insert {x} 200 8 kill {uuid}")));
let Some(BlockEntity::Chest(items)) = game.world.block_entities.get(&(x, 200, 8))
else {
panic!("real output chest required")
};
let output: Vec<_> = items.iter().flatten().collect();
assert!(
output
.iter()
.any(|s| s.item.as_ref() == "minecraft:mutton" && (1..=2).contains(&s.count)),
"nonempty meat proves the command actually rolled"
);
let actual: u32 = output
.iter()
.filter(|s| s.item.as_ref() == wool)
.map(|s| s.count)
.sum();
if actual != u32::from(!sheared) {
differences.push((n, sheared, wool, actual));
}
let mob = game.mobs.iter().find(|m| m.id == before.0).unwrap();
assert_eq!((mob.id, mob.position, mob.velocity, mob.health), before);
assert_eq!(game.persist.uuid_of(mob.id), Some([36, 0, 0, n]));
assert_eq!((mob.extra.color, mob.extra.sheared), (color, sheared));
}
assert_eq!(game.world.game_time, clock);
assert!(
differences.is_empty(),
"actual command wool-state differences: {differences:?}"
);
}
}
@@ -1000,10 +1000,23 @@ impl Game<'_> {
// An unbounded selector reaches every loaded level, so the target may
// be in another one than the command's (EntitySelector.findEntities,
// AttributeCommand acting on the entity itself).
self.in_target_level(target, |g| {
g.attribute_in_level(id, ctx, target, &attribute)
})
.unwrap_or(Ok(0))
let reply = if matches!(target, Target::Guest(_)) {
self.for_player(target, |g| {
g.attribute_in_level(id, ctx, Target::Player, &attribute)
})
} else {
self.in_target_level(target, |g| {
g.attribute_in_level(id, ctx, target, &attribute)
})
}
.unwrap_or(Ok(None))?;
// Command success belongs to the issuer, after the actual target's
// body/level is restored. Internal system messages still use for_player.
let Some((value, message)) = reply else {
return Ok(0);
};
self.feedback(message);
Ok(value)
}
/// `/attribute` on a target, with the target's own level in place.
fn attribute_in_level(
@@ -1012,7 +1025,7 @@ impl Game<'_> {
ctx: &Context,
target: Target,
attribute: &str,
) -> R<i32> {
) -> R<Option<(i32, Text)>> {
let name = self.target_name(target);
let description = Self::attribute_description(attribute);
let short = attribute.trim_start_matches("minecraft:").to_owned();
@@ -1037,7 +1050,7 @@ impl Game<'_> {
match target {
Target::Player => {
let Some(player) = self.player.as_mut() else {
return Ok(0);
return Ok(None);
};
let Some(key) = player.attributes.key(attribute) else {
return Err(no_attribute(&name, &description));
@@ -1059,11 +1072,10 @@ impl Game<'_> {
} else {
"commands.attribute.base_value.get.success"
};
self.feedback(Text::translate(
k,
&[description, name, java_double_text(v)],
));
Ok((v * scale) as i32)
Ok(Some((
(v * scale) as i32,
Text::translate(k, &[description, name, java_double_text(v)]),
)))
}
"attribute.base.set" => {
let handle = player
@@ -1079,11 +1091,13 @@ impl Game<'_> {
if attribute == "minecraft:max_health" {
let _ = player.clamp_health_to_max();
}
self.feedback(Text::translate(
"commands.attribute.base_value.set.success",
&[description, name, java_double_text(value_arg)],
));
Ok(1)
Ok(Some((
1,
Text::translate(
"commands.attribute.base_value.set.success",
&[description, name, java_double_text(value_arg)],
),
)))
}
"attribute.base.reset" => {
if !player
@@ -1100,11 +1114,13 @@ impl Game<'_> {
if attribute == "minecraft:max_health" {
let _ = player.clamp_health_to_max();
}
self.feedback(Text::translate(
"commands.attribute.base_value.reset.success",
&[description, name, java_double_text(v)],
));
Ok(1)
Ok(Some((
1,
Text::translate(
"commands.attribute.base_value.reset.success",
&[description, name, java_double_text(v)],
),
)))
}
"attribute.modifier.add" => {
if player
@@ -1129,11 +1145,13 @@ impl Game<'_> {
player
.set_modifier(attribute, &modifier_id, value_arg, &op, true)
.map_err(|_| no_attribute(&name, &description))?;
self.feedback(Text::translate(
"commands.attribute.modifier.add.success",
&[Text::literal(modifier_id), description, name],
));
Ok(1)
Ok(Some((
1,
Text::translate(
"commands.attribute.modifier.add.success",
&[Text::literal(modifier_id), description, name],
),
)))
}
"attribute.modifier.remove" => {
if !player
@@ -1152,11 +1170,13 @@ impl Game<'_> {
}
let _ =
player.set_modifier(attribute, &modifier_id, 0.0, "add_value", false);
self.feedback(Text::translate(
"commands.attribute.modifier.remove.success",
&[Text::literal(modifier_id), description, name],
));
Ok(1)
Ok(Some((
1,
Text::translate(
"commands.attribute.modifier.remove.success",
&[Text::literal(modifier_id), description, name],
),
)))
}
_ => {
let Some(handle) = player.attributes.get_instance(Some(key)).ok().flatten()
@@ -1184,16 +1204,18 @@ impl Game<'_> {
],
));
};
self.feedback(Text::translate(
"commands.attribute.modifier.value.get.success",
&[
Text::literal(modifier_id),
description,
name,
java_double_text(v),
],
));
Ok((v * scale) as i32)
Ok(Some((
(v * scale) as i32,
Text::translate(
"commands.attribute.modifier.value.get.success",
&[
Text::literal(modifier_id),
description,
name,
java_double_text(v),
],
),
)))
}
}
}
@@ -1213,10 +1235,11 @@ impl Game<'_> {
(&name, &description),
(value_arg, scale, &modifier_id, &op),
)
.map(Some)
}
Target::Mob(mob_id) => {
let Some(m) = self.mobs.iter_mut().find(|m| m.id == mob_id) else {
return Ok(0);
return Ok(None);
};
// The attributes the mob's gameplay reads live in `finalize`;
// the command edits that store, never a copy of it.
@@ -1245,31 +1268,34 @@ impl Game<'_> {
} else {
"commands.attribute.base_value.get.success"
};
self.feedback(Text::translate(
k,
&[description, name, java_double_text(current)],
));
Ok((current * scale) as i32)
Ok(Some((
(current * scale) as i32,
Text::translate(k, &[description, name, java_double_text(current)]),
)))
}
"attribute.base.set" => {
// AttributeInstance keeps the raw base and clamps only the
// effective value; Health is unchanged.
finalize::set_base(m, attr, value_arg);
self.feedback(Text::translate(
"commands.attribute.base_value.set.success",
&[description, name, java_double_text(value_arg)],
));
Ok(1)
Ok(Some((
1,
Text::translate(
"commands.attribute.base_value.set.success",
&[description, name, java_double_text(value_arg)],
),
)))
}
"attribute.base.reset" => {
let default = finalize::default_value(m, attr).unwrap_or_default();
finalize::set_base(m, attr, default);
let v = finalize::base_value(m, attr);
self.feedback(Text::translate(
"commands.attribute.base_value.reset.success",
&[description, name, java_double_text(v)],
));
Ok(1)
Ok(Some((
1,
Text::translate(
"commands.attribute.base_value.reset.success",
&[description, name, java_double_text(v)],
),
)))
}
"attribute.modifier.add" => {
if finalize::has_modifier(m, attr, &modifier_id) {
@@ -1284,22 +1310,26 @@ impl Game<'_> {
_ => finalize::Op::AddValue,
};
finalize::add_modifier(m, attr, modifier_id.clone(), value_arg, op);
self.feedback(Text::translate(
"commands.attribute.modifier.add.success",
&[Text::literal(modifier_id.clone()), description, name],
));
Ok(1)
Ok(Some((
1,
Text::translate(
"commands.attribute.modifier.add.success",
&[Text::literal(modifier_id.clone()), description, name],
),
)))
}
"attribute.modifier.remove" => {
if !finalize::has_modifier(m, attr, &modifier_id) {
return Err(no_modifier);
}
finalize::remove_modifier(m, attr, &modifier_id);
self.feedback(Text::translate(
"commands.attribute.modifier.remove.success",
&[Text::literal(modifier_id.clone()), description, name],
));
Ok(1)
Ok(Some((
1,
Text::translate(
"commands.attribute.modifier.remove.success",
&[Text::literal(modifier_id.clone()), description, name],
),
)))
}
_ => {
// Permanent entries stay authoritative. The modeled baby
@@ -1313,16 +1343,18 @@ impl Game<'_> {
let Some(v) = amount else {
return Err(no_modifier);
};
self.feedback(Text::translate(
"commands.attribute.modifier.value.get.success",
&[
Text::literal(modifier_id.clone()),
description,
name,
java_double_text(v),
],
));
Ok((v * scale) as i32)
Ok(Some((
(v * scale) as i32,
Text::translate(
"commands.attribute.modifier.value.get.success",
&[
Text::literal(modifier_id.clone()),
description,
name,
java_double_text(v),
],
),
)))
}
}
}
@@ -1365,7 +1397,7 @@ impl Game<'_> {
attribute: &str,
(name, description): (&Text, &Text),
(value_arg, scale, modifier_id, op): (f64, f64, &str, &str),
) -> R<i32> {
) -> R<(i32, Text)> {
use horizon_world::attributes::{Modifier, Operation};
let no_attribute = || {
text_error(
@@ -1496,8 +1528,7 @@ impl Game<'_> {
)
}
};
self.feedback(Text::translate(message, &args));
Ok(result)
Ok((result, Text::translate(message, &args)))
}
// ---- /ride ----------------------------------------------------------------------------
@@ -1508,6 +1539,12 @@ impl Game<'_> {
return Ok(0);
};
let name = self.target_name(target);
// A server player other than the one in place (the console's, a function's or another
// player's /ride): their riding state is their own, so the command runs as them, as
// `native_mount` does. In vanilla every ServerPlayer rides alike.
if let Target::Guest(_) = target {
return self.ride_guest(id, ctx, target, name);
}
if id == "ride.dismount" {
let Some(v) = self.ride_vehicle(target) else {
return Err(err("commands.ride.not_riding", &[&name.plain()]));
@@ -1537,6 +1574,45 @@ impl Game<'_> {
Ok(1)
}
/// `cmd_ride` for a server player not in place (`Target::Guest`): `RideCommand.dismount` and
/// `RideCommand.mount` run as that player, through `for_player`.
fn ride_guest(&mut self, id: &str, ctx: &Context, target: Target, name: Text) -> R<i32> {
if id == "ride.dismount" {
let not_riding = || err("commands.ride.not_riding", &[&name.plain()]);
let vehicle_name = self
.for_player(target, |g| {
let vehicle = g.ride_vehicle(Target::Player)?;
let vehicle_name = g.target_name(vehicle);
g.stop_riding();
Some(vehicle_name)
})
.flatten()
.ok_or_else(not_riding)?;
self.feedback(Text::translate(
"commands.ride.dismount.success",
&[name, vehicle_name],
));
return Ok(1);
}
let vehicles = self.entities(ctx, "vehicle", false)?;
let Some(vehicle) = vehicles.first().copied() else {
return Ok(0);
};
let vehicle_name = self.target_name(vehicle);
self.for_player(target, |g| g.ride_mount(Target::Player, vehicle))
.unwrap_or_else(|| {
Err(err(
"commands.ride.mount.failure.generic",
&[&name.plain(), &vehicle_name.plain()],
))
})?;
self.feedback(Text::translate(
"commands.ride.mount.success",
&[name, vehicle_name],
));
Ok(1)
}
/// `RideCommand.mount` once the targets are known: `target` (the player
/// in place or a mob) rides `vehicle`, as `startRiding(vehicle, true,
/// true)` forces it (also for `native_mount`).
@@ -1550,7 +1626,8 @@ impl Game<'_> {
&[&name.plain(), &current.plain()],
));
}
if vehicle == Target::Player {
// RideCommand.mount: `vehicle.getType() == EntityType.PLAYER`, any server player.
if matches!(vehicle, Target::Player | Target::Guest(_)) {
return Err(err("commands.ride.mount.failure.cant_ride_players", &[]));
}
// getSelfAndPassengers: the vehicle is the target or rides it.
@@ -1620,6 +1697,24 @@ impl Game<'_> {
self.start_riding(id);
return Ok(());
}
// An armor stand, a display, a marker, an interaction or any entity Horizon keeps as its
// compound: the forced ride takes it too, and the rider sits on its passenger point.
Target::Stand(id) | Target::Other(id) => {
let Some(seat) = self.seat(id) else {
return Err(generic());
};
if let Some(rider) = rider {
if !self.try_mount_mob(rider, id) {
return Err(generic());
}
} else {
if !self.native_may_board(id) {
return Err(generic());
}
self.board_seat(id, seat);
}
return Ok(());
}
_ => {}
}
// Mobs carry players and mobs.
@@ -1661,6 +1756,10 @@ impl Game<'_> {
Target::Boat(id)
} else if self.carts.iter().any(|c| c.id == id) {
Target::Cart(id)
} else if self.armor_stands.iter().any(|a| a.id == id) {
Target::Stand(id)
} else if self.others.iter().any(|o| o.id == id) {
Target::Other(id)
} else {
Target::Mob(id)
})
@@ -2350,4 +2449,273 @@ mod vehicle_damage_command_tests {
assert_eq!(game.world.game_time, clock);
});
}
fn attribute_guests(
game: &mut Game<'_>,
) -> Vec<std::sync::mpsc::Receiver<horizon_client::lan::Out>> {
use horizon_client::lan::{Joined, NetConfig, PlayerSender, guest::Guest, hub::HubConfig};
use horizon_protocol::packets::configuration::ClientInformation;
use std::sync::{atomic::AtomicI32, mpsc};
game.world_root = game.options.game_dir.as_ref().unwrap().join("h082-world");
std::fs::create_dir_all(&game.world_root).unwrap();
game.lan_host(
crate::game::lan::LanSettings {
port: 0,
guest_commands: true,
force_game_mode: false,
},
NetConfig {
announce: false,
..NetConfig::loopback(0, "attribute-test".into(), 2)
},
HubConfig {
default_game_mode: 1,
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: 3,
integrated: false,
levels: Default::default(),
},
)
.unwrap();
let mut receivers = Vec::new();
for (conn, name, position) in [
(2, "Bob", [7.5, 100.0, 4.5]),
(3, "Carol", [10.5, 100.0, 4.5]),
] {
let (tx, rx) = mpsc::channel();
let mut guest = Guest::new(
Joined {
conn,
uuid: horizon_protocol::crypto::offline_uuid(name),
name: name.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(),
},
conn as i32,
position,
2,
);
guest.dimension = "minecraft:overworld".into();
guest.placed = true;
guest.loaded = true;
guest.game_mode = 1;
game.lan.as_mut().unwrap().hub.guests.push(guest);
receivers.push(rx);
}
let host = game.lan.as_mut().unwrap();
for name in ["Bob", "Carol"] {
host.hub
.ops
.insert(horizon_protocol::crypto::offline_uuid(name), 2);
}
host.hub.refresh_permissions();
assert!(host.hub.guests.iter().all(|guest| guest.permission == 2));
receivers
}
fn attribute_chat_count(rx: &std::sync::mpsc::Receiver<horizon_client::lan::Out>) -> usize {
use horizon_client::lan::Out;
use horizon_protocol::packets::play::Clientbound;
let mut bodies = Vec::new();
for out in rx.try_iter() {
match out {
Out::Packet(body) => bodies.push(body),
Out::Packets(more) => bodies.extend(more),
Out::Disconnect(_) => panic!("actual registered guest disconnected"),
}
}
bodies
.iter()
.filter(|body| {
let mut r = horizon_protocol::buf::Reader::new(body);
let id = r.varint().unwrap();
matches!(
Clientbound::decode(id, r.rest()).unwrap(),
Clientbound::SystemChat(_)
)
})
.count()
}
#[test]
#[ignore = "needs prepared resources; real registered Guest context and issuer feedback"]
fn direct_guest_attributes_adopt_the_target_and_restore_the_issuer() {
use crate::game::commands::Target;
use horizon_client::motion::PlayerMotion;
use horizon_core::geometry::Vec3;
with_game(|game| {
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(4.5, 100.0, 4.5)).unwrap());
game.creative = true;
assert!(game.run_command("fill 0 99 0 15 99 15 stone"));
assert!(game.run_command("attribute @s minecraft:armor base set 2"));
let host_uuid = game.acting_uuid();
let clock = game.world.game_time;
let dimension = game.dim;
let receivers = attribute_guests(game);
for (conn, name) in [(2, "Bob"), (3, "Carol")] {
let g = game.command_guest(conn).unwrap();
assert_eq!(g.uuid, horizon_protocol::crypto::offline_uuid(name));
assert_eq!(g.health, 20.0);
assert!(g.loaded && g.placed);
assert_eq!(g.game_mode, 1);
assert_eq!(
game.for_player(Target::Guest(conn), |g| g
.player
.as_mut()
.unwrap()
.attribute("minecraft:armor")
.unwrap()),
Some(0.0)
);
}
// Existing guest-issued Player path is positive before direct Guest dispatch.
assert_eq!(
game.for_player(Target::Guest(2), |g| g
.run_command("attribute @s minecraft:armor base set 3")),
Some(true)
);
assert_eq!(
game.for_player(Target::Guest(2), |g| g
.player
.as_mut()
.unwrap()
.attribute("minecraft:armor")
.unwrap()),
Some(3.0)
);
for rx in &receivers {
attribute_chat_count(rx);
}
game.chat_capture = Some(Vec::new());
let accepted = game.run_command("attribute Bob minecraft:armor base set 4");
let issuer = game.chat_capture.take().unwrap();
// This assertion fails old on the actual guest model, not feedback alone.
assert_eq!(
game.for_player(Target::Guest(2), |g| g
.player
.as_mut()
.unwrap()
.attribute("minecraft:armor")
.unwrap()),
Some(4.0)
);
assert!(accepted);
assert_eq!(issuer.len(), 1);
assert!(issuer[0].plain().contains("Bob"));
assert_eq!(
attribute_chat_count(&receivers[0]),
0,
"success belongs to issuer, not Bob"
);
assert_eq!(
game.player
.as_mut()
.unwrap()
.attribute("minecraft:armor")
.unwrap(),
2.0
);
assert_eq!(
game.for_player(Target::Guest(3), |g| g
.player
.as_mut()
.unwrap()
.attribute("minecraft:armor")
.unwrap()),
Some(0.0)
);
assert_eq!(
game.for_player(Target::Guest(2), |g| g
.player
.as_mut()
.unwrap()
.attribute("minecraft:max_health")
.unwrap()),
Some(20.0)
);
game.chat_capture = Some(Vec::new());
assert!(
!game.run_command("attribute Bob minecraft:armor modifier remove h082:missing")
);
let errors = game.chat_capture.take().unwrap();
assert_eq!(errors.len(), 1);
assert!(errors[0].plain().contains("h082:missing"));
assert_eq!(
attribute_chat_count(&receivers[0]),
0,
"error returns to issuer"
);
game.chat_capture = Some(Vec::new());
assert!(game.run_command("attribute Bob minecraft:armor base reset"));
assert_eq!(game.chat_capture.take().unwrap().len(), 1);
assert_eq!(
game.for_player(Target::Guest(2), |g| g
.player
.as_mut()
.unwrap()
.attribute("minecraft:armor")
.unwrap()),
Some(0.0)
);
assert_eq!(game.acting_uuid(), host_uuid);
assert_eq!(game.lan_acting, None);
assert_eq!(game.dim, dimension);
assert_eq!(game.world.game_time, clock);
assert_eq!(game.player.as_ref().unwrap().health.health(), 20.0);
});
}
#[test]
#[ignore = "needs prepared resources; existing target/operation reply controls"]
fn existing_attribute_operations_keep_their_exact_success_messages() {
use horizon_client::motion::PlayerMotion;
use horizon_core::geometry::Vec3;
with_game(|game| {
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(4.5, 200.0, 4.5)).unwrap());
assert!(
game.run_command("summon pig 6.5 200 4.5 {UUID:[I;82,0,0,1],NoAI:1b,NoGravity:1b}")
);
assert!(
game.run_command("summon armor_stand 8.5 200 4.5 {UUID:[I;82,0,0,2],NoGravity:1b}")
);
for target in [
"@s",
"00000052-0000-0000-0000-000000000001",
"00000052-0000-0000-0000-000000000002",
] {
for operation in [
"get",
"base get",
"base set 2",
"base get",
"base reset",
"modifier add h082:reply 1 add_value",
"modifier value get h082:reply",
"get",
"modifier remove h082:reply",
] {
let command = format!("attribute {target} minecraft:armor {operation}");
game.chat_capture = Some(Vec::new());
game.cmds.results = Some(Vec::new());
assert!(game.run_command(&command), "{command}");
let messages = game.chat_capture.take().unwrap();
assert_eq!(messages.len(), 1, "{command}");
let components: Vec<_> = messages.iter().map(|m| m.component()).collect();
println!(
"H082_EXISTING {}",
serde_json::json!({"command":command,"messages":components,"results":game.cmds.results.take().unwrap()})
);
}
}
});
}
}
@@ -279,6 +279,8 @@ impl Game<'_> {
self.dismount_cart(i);
} else if let Some(c) = self.cushions.iter().find(|c| c.id == id).cloned() {
self.get_up_from_cushion(&c);
} else if self.seat(id).is_some() {
self.get_off_seat();
} else {
self.dismount_mount();
}
@@ -4,6 +4,7 @@
//! shearing interactions.
use super::Game;
use horizon_client::{
containers::BlockEntity,
items::ItemStack,
mobs::{
Mob, Species,
@@ -11,7 +12,10 @@ use horizon_client::{
},
};
use horizon_core::random::RandomSource;
use horizon_world::chunk::BlockState;
use horizon_world::{
chunk::BlockState,
nbt::{Compound, Tag},
};
/// `ContainerOpenersCounter.CHECK_TICK_DELAY`.
const RECHECK_TICKS: i64 = 5;
@@ -178,6 +182,15 @@ impl Game<'_> {
if self.level_set(pos, id).is_err() {
return;
}
// `createStatue`: the golem's custom name goes into the statue's components.
let mut components = Compound::new();
if let Some(name) = self.golem_name_tag(m) {
components.insert("minecraft:custom_name".into(), name);
}
self.world.block_entities.insert(
(pos[0], pos[1], pos[2]),
BlockEntity::CopperGolemStatue(components),
);
self.golem_drop_equipment(m);
m.extra.opened_chest = None;
if m.extra.leash.is_some() {
@@ -192,6 +205,53 @@ impl Game<'_> {
1.0,
);
}
/// The golem's `CustomName` as a component: the styled name it was loaded
/// or summoned with while it still matches the plain name, else the plain one.
fn golem_name_tag(&self, m: &Mob) -> Option<Tag> {
let plain = m.custom_name.as_ref()?;
let saved = self
.persist
.raw
.get(&m.id)
.and_then(|raw| raw.get("CustomName"))
.filter(|name| match name {
Tag::String(s) => s.to_string_lossy() == *plain,
Tag::Compound(_) => matches!(
name.get("text"),
Some(Tag::String(s)) if s.to_string_lossy() == *plain
),
_ => false,
});
Some(
saved
.cloned()
.unwrap_or_else(|| Tag::String(plain.as_str().into())),
)
}
/// `Entity.setCustomName` of golem `id` with the component `name`: its plain
/// and styled forms, and the saved compound a later save writes it from.
fn name_golem(&mut self, id: u32, name: Tag) {
let plain = match &name {
Tag::String(s) => Some(s.to_string_lossy()),
Tag::Compound(_) => match name.get("text") {
Some(Tag::String(s)) => Some(s.to_string_lossy()),
_ => None,
},
_ => None,
};
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
m.custom_name = plain;
m.custom_name_text = Some(horizon_client::entity_nbt::name_text(&name));
}
if let Tag::Compound(raw) = self
.persist
.raw
.entry(id)
.or_insert_with(|| Tag::Compound(Default::default()))
{
raw.insert("CustomName".into(), name);
}
}
/// `Mob.dropPreservedEquipment`: the guaranteed drops in `EquipmentSlot`
/// order, the carried stack (main hand) then the antenna flower (saddle),
/// each `spawnAtLocation` at the golem's feet.
@@ -239,9 +299,19 @@ impl Game<'_> {
f64::from(pos[1]),
f64::from(pos[2]) + 0.5,
];
// `removeStatue`: the golem takes the custom name the statue keeps.
let name = match self.world.block_entities.get(&(pos[0], pos[1], pos[2])) {
Some(BlockEntity::CopperGolemStatue(components)) => {
components.get(&"minecraft:custom_name".into()).cloned()
}
_ => None,
};
let Some(id) = self.spawn_mob("minecraft:copper_golem", at, true) else {
return false;
};
if let Some(name) = name {
self.name_golem(id, name);
}
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
m.yaw = yaw;
m.previous_yaw = yaw;
+545 -51
View File
@@ -633,6 +633,40 @@ impl Game<'_> {
self.lan_entity_event(id as i32, if body { 65 } else { 49 }, at);
}
}
/// `LivingEntity.updateFallFlying`'s wear, `ItemStack.hurtAndBreak(1, this,
/// slot)` on the glider `MobEvent::GliderWear` picked: Unbreaking (from the
/// level's random, `Enchantment.modifyItemFilteredCount`) may save it, and
/// the rest goes on the stack where it is worn. A glider is only picked
/// while its next point of wear will not break it (`canGlideUsing`), so
/// this wear never breaks one.
fn wear_mob_glider(&mut self, id: u32, slot: horizon_world::player_inventory::EquipmentSlot) {
use horizon_client::mobs::finalize;
use horizon_world::player_inventory::EquipmentSlot as S;
let Some(stack) = self
.mobs
.iter()
.find(|m| m.id == id)
.and_then(|m| finalize::equipment_stack(m, slot))
else {
return;
};
if !horizon_client::stations::is_damageable(self.res.items(), &stack) {
return;
}
let loss = self.durability_loss(&stack, 1);
if loss == 0 {
return;
}
let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) else {
return;
};
let damage = stack.damage + loss;
match slot {
S::Body => m.extra.body_damage = damage as i32,
S::Saddle => m.set_saddle(Some(ItemStack { damage, ..stack })),
_ => finalize::set_slot_damage(m, slot as usize, damage),
}
}
/// `LivingEntity.hurtArmor` -> `LivingEntity.doHurtEquipment`, from the hit
/// `MobEvent::ArmorHurt` reported. Only `Horse` overrides `hurtArmor` for a
/// mob, and its override asks for the BODY slot alone, so the body piece is
@@ -863,7 +897,9 @@ impl Game<'_> {
"leash": m.extra.leash.map(|h| format!("{h:?}")),
"tame": m.extra.tame,
"sitting": m.extra.sitting,
"temper": m.extra.variant3,
"temper": if horizon_client::mobs::equine::is_equine(m.species()) {
horizon_client::mobs::equine::temper(m)
} else { i32::from(m.extra.variant3) },
"chested": m.extra.chested,
});
// Set apart: one json! with every key goes past the default macro recursion
@@ -930,13 +966,13 @@ impl Game<'_> {
others_solid: None,
daylight: self.dim != Dim::Nether && self.sky_darken() < 4,
// day.json: false from tick 12542, true from 23460 (a step track).
monsters_burn: self.dim == Dim::Overworld
monsters_burn: self.dimension_type().has_timeline("minecraft:day")
&& !(12542..23460).contains(&self.world.day_time.rem_euclid(24000)),
game_time: self.world.game_time,
day_time: self.world.day_time,
raining: weather && rain > 0.2,
thundering: weather && thunder > 0.9,
nether: self.dim == Dim::Nether,
level: self.level_key(),
block_tags: &self.items().block_tags,
landing: &self.motion.landing,
biomes: world_biomes!(self),
@@ -944,7 +980,7 @@ impl Game<'_> {
universal_anger: self.ui.rules.bool("universal_anger"),
mob_drops: self.ui.rules.bool("mob_drops"),
max_entity_cramming: self.ui.rules.int("max_entity_cramming"),
end: self.dim == Dim::End,
dimension_type: self.dimension_type(),
world_seed: self.level.seed,
sea_level: if self.dim == Dim::Nether { 32 } else { 63 },
teams: std::sync::Arc::new(self.mob_teams()),
@@ -1987,7 +2023,7 @@ impl Game<'_> {
}
let mut killed = false;
let mut hurt = false;
let mut infested = false;
let mut infested = Vec::new();
let aim = self.attacker_aim(source.attacker);
// So `living_knockback_v2` sees who hit and what they hold.
let _attacker = self.knockback_scope_for(source.attacker);
@@ -2004,7 +2040,7 @@ impl Game<'_> {
self.audio
.play(&format!("minecraft:{s}"), Some(center), volume, pitch);
}
MobEvent::Infested { .. } => infested = true,
MobEvent::Infested { at, velocities } => infested.push((at, velocities)),
other => {
// Left for the end of the tick.
t.outbox.push(other);
@@ -2016,8 +2052,8 @@ impl Game<'_> {
if hurt {
self.resolve_player_responsible(target, source);
}
if infested {
self.mob_infested(target);
for (at, velocities) in infested {
self.spawn_infested(at, velocities);
}
if hurt
&& let Some(Who::Mob(attacker)) = source.attacker
@@ -2081,18 +2117,6 @@ impl Game<'_> {
.find(|g| g.placed && g.uuid == uuid)
.map(|g| Some(g.conn))
}
/// `InfestedMobEffect.onMobHurt` for a mob: silverfish from half its height up.
pub(super) fn mob_infested(&mut self, mob: u32) {
if let Some(m) = self.mobs.iter().find(|m| m.id == mob) {
let at = [
m.position.x,
m.position.y + f64::from(m.height()) / 2.0,
m.position.z,
];
let look = m.view_vector();
self.infested_hurt(at, look, Some(mob));
}
}
/// `LivingEntity.dropFromGiftLootTable`: the gift table from its random
/// sequence with the mob as `this` (a chicken's egg depends on its
/// variant); the loot data of the game's resources decides, and without it
@@ -2229,9 +2253,11 @@ impl Game<'_> {
let v = p.velocity;
self.particles.llama_spit_burst(at, [v.x, v.y, v.z]);
}
let arrow = p.arrow.is_some();
// Ordinary arrows post from ArrowState's first tick, including loaded arrows.
// Keep the existing immediate post for the other arrow-state kind (tridents).
let immediate = p.arrow.is_some() && !p.is_arrow();
self.add_projectile(p);
if arrow {
if immediate {
self.projectile_event("minecraft:projectile_shoot", owner, at);
}
}
@@ -2263,6 +2289,7 @@ impl Game<'_> {
}
MobEvent::SunProtectionWear { mob } => self.wear_sun_protection(mob),
MobEvent::ArmorHurt { mob, amount, kind } => self.hurt_mob_armor(mob, amount, kind),
MobEvent::GliderWear { mob, slot } => self.wear_mob_glider(mob, slot),
MobEvent::EntityEvent { mob, event, at } => {
self.lan_entity_event(mob as i32, event, at)
}
@@ -2557,7 +2584,7 @@ impl Game<'_> {
} => {
self.spawn_creeper_cloud(at, effects, hidden, ambient, color);
}
MobEvent::Explode(at, radius, species) => {
MobEvent::Explode(at, radius, species, silent) => {
// The exploding mob causes the damage (Creeper.explodeCreeper).
let noted = self.combat.explosion_source.is_some();
let source_mob = self.combat.explosion_source.or_else(|| {
@@ -2586,12 +2613,13 @@ impl Game<'_> {
// Only a creeper or a wither empties the hives it blows up.
// The creeper is already gone from the list, so the event
// carries its kind. A sulfur cube's blast is TNT's.
let source = super::explosion::ExplosionSource::default().with_silence(silent);
let result = match species {
Species::Creeper | Species::Wither => {
self.with_hive_emptying_blast(|g| g.explode(at, radius))
self.with_hive_emptying_blast(|g| g.explode(at, radius, source))
}
Species::SulfurCube => self.explode_sulfur_cube(at, radius),
_ => self.explode(at, radius),
Species::SulfurCube => self.explode_sulfur_cube(at, radius, source),
_ => self.explode(at, radius, source),
};
self.combat.explosion_source = None;
self.combat.explosion_source_named = false;
@@ -2795,7 +2823,7 @@ impl Game<'_> {
MobEvent::BecomeStatue { mob } => self.golem_become_statue(mob),
MobEvent::EnterHive { mob, hive } => self.bee_enter_hive(mob, hive),
MobEvent::ParrotToShoulder { mob } => self.parrot_to_shoulder(mob),
MobEvent::Infested { mob } => self.mob_infested(mob),
MobEvent::Infested { at, velocities } => self.spawn_infested(at, velocities),
MobEvent::Gift { target, item } => {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == target)
&& m.saddle_item().is_none()
@@ -2898,6 +2926,10 @@ impl Game<'_> {
thrown.thrower = Some(thrower);
}
}
MobEvent::DropStack { mob, stack, at } => {
self.native_entity_drop_item(mob, &stack.item, stack.count as i32, at);
self.spawn_item(stack, at, dispenser::new_item_motion(), 10);
}
MobEvent::DropItem {
mob,
item,
@@ -3321,6 +3353,7 @@ impl Game<'_> {
MobEvent::CureZombieVillager { mob } => self.cure_zombie_villager(mob),
MobEvent::Reinforcements { mob, target } => self.call_reinforcements(mob, target),
MobEvent::VillagerMeet { mob, other } => self.villager_meet(mob, other),
MobEvent::VillagerTrade { mob, player } => self.villager_traded_by(mob, player),
MobEvent::VillagerOffers { mob } => {
if let Some(i) = self.mobs.iter().position(|m| m.id == mob) {
self.ensure_offers(i);
@@ -4491,8 +4524,15 @@ impl Game<'_> {
);
}
let events_from = arrow_events.len();
let ordinary_arrow = p.is_arrow();
if let Some(a) = p.arrow.as_mut() {
arrow_events.extend(a.events.drain(..).map(|e| (p.position, e)));
arrow_events.extend(a.events.drain(..).map(|e| {
if ordinary_arrow && e == horizon_client::arrows::ArrowEvent::ShootEvent {
shoot_events.push((p.owner, p.guest, p.position.components()));
}
// Retain even the no-op marker: native impact ranges index this list.
(p.position, e)
}));
}
let wind = matches!(
p.kind,
@@ -4509,7 +4549,13 @@ impl Game<'_> {
if p.arrow.is_some() {
horizon_client::arrows::finish_entity_hit(p, &scene, EntityHit::Deflected);
let a = p.arrow.as_mut().expect("an arrow");
arrow_events.extend(a.events.drain(..).map(|e| (p.position, e)));
arrow_events.extend(a.events.drain(..).map(|e| {
if ordinary_arrow && e == horizon_client::arrows::ArrowEvent::ShootEvent
{
shoot_events.push((p.owner, p.guest, p.position.components()));
}
(p.position, e)
}));
}
deflections.push(id);
continue;
@@ -4574,24 +4620,7 @@ impl Game<'_> {
);
}
for (owner, guest, at) in shoot_events {
if let Some(conn) = guest.filter(|conn| self.lan_acting != Some(*conn)) {
let dispatched = if let Some(mut host) = self.lan.take() {
let result = self.as_guest(&mut host, conn, |g| {
g.projectile_event("minecraft:projectile_shoot", owner, at);
});
self.lan = Some(host);
result.is_some()
} else {
false
};
// An owner who left cannot resolve to the host. The event
// still occurs, with no resolved owner (Projectile.getOwner).
if !dispatched {
self.projectile_event("minecraft:projectile_shoot", None, at);
}
} else {
self.projectile_event("minecraft:projectile_shoot", owner, at);
}
self.projectile_shoot_event(owner, guest, at);
}
self.smoke_trails(trails);
for pos in bubble_tops {
@@ -4953,6 +4982,27 @@ impl Game<'_> {
owners: self,
}
}
/// Dispatch with the projectile's actual owner context, including a guest shooter.
fn projectile_shoot_event(&mut self, owner: Option<Who>, guest: Option<u32>, at: [f64; 3]) {
if let Some(conn) = guest.filter(|conn| self.lan_acting != Some(*conn)) {
let dispatched = if let Some(mut host) = self.lan.take() {
let result = self.as_guest(&mut host, conn, |g| {
g.projectile_event("minecraft:projectile_shoot", owner, at);
});
self.lan = Some(host);
result.is_some()
} else {
false
};
// An owner who left cannot resolve to the host. The event
// still occurs, with no resolved owner (Projectile.getOwner).
if !dispatched {
self.projectile_event("minecraft:projectile_shoot", None, at);
}
} else {
self.projectile_event("minecraft:projectile_shoot", owner, at);
}
}
/// The rest of the tick of an arrow that hit an entity
/// (`arrows::finish_entity_hit`): its sounds and drop, and back among the
/// projectiles if it flies on.
@@ -4968,6 +5018,8 @@ impl Game<'_> {
for event in events {
if event == horizon_client::arrows::ArrowEvent::Drop {
self.spawn_at_location(arrow.position, f64::from(0.1f32), item.clone());
} else if arrow.is_arrow() && event == horizon_client::arrows::ArrowEvent::ShootEvent {
self.projectile_shoot_event(arrow.owner, arrow.guest, arrow.position.components());
} else {
self.arrow_event(arrow.position, event);
}
@@ -5722,7 +5774,13 @@ impl Game<'_> {
// LargeFireball.onHit: both fire and MOB block destruction
// follow mob_griefing; other mob explosions keep their default.
let fire = self.ui.rules.bool("mob_griefing");
let result = self.explode_mob(atp, *power as f32, fire);
let result = self.explode_mob(
atp,
*power as f32,
fire,
super::explosion::ExplosionSource::default()
.with_silence(self.entity_silent(p.id)),
);
self.set_blast_by_player(false);
self.combat.explosion_owner = None;
self.combat.explosion_in_water = false;
@@ -5763,7 +5821,16 @@ impl Game<'_> {
_ => None,
};
let owner = self.potion_owner(p.owner);
self.splash(stack, at, mob, block, p.age, &owner, p.velocity);
self.splash(
stack,
at,
mob,
block,
p.age,
&owner,
self.entity_silent(p.id),
p.velocity,
);
if stack.item.as_ref() != "minecraft:experience_bottle" {
let item = stack.item.to_string();
self.native_potion_splash(&item, [at.x, at.y, at.z]);
@@ -8818,7 +8885,7 @@ mod pearl_tests {
day_time: 1000,
raining: false,
thundering: false,
nether: false,
level: horizon_core::LevelKey::OVERWORLD,
block_tags: &items.block_tags,
landing: &motion.landing,
biomes: &biomes,
@@ -8827,7 +8894,7 @@ mod pearl_tests {
mob_drops: true,
max_entity_cramming: 24,
difficulty: spawning::LocalDifficulty::new(2, 1000, 0, 0.0),
end: false,
dimension_type: horizon_world::dimension_type::DimensionFacts::overworld(),
world_seed: 0,
sea_level: 63,
items: Some(&items),
@@ -9260,3 +9327,430 @@ mod breeding_orb_tests {
assert!(values.iter().any(|v| matches!(v, 4..=6)), "{values:?}");
}
}
#[cfg(test)]
mod loaded_arrow_shot_tests {
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
use horizon_client::{
chunk::Chunk,
mobs::Who,
motion::PlayerMotion,
projectiles::{Projectile, ProjectileKind},
};
use horizon_core::{
geometry::Vec3,
position::ChunkPos,
random::{LegacyRandom, RandomSource},
};
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.game_dir = Some(
std::path::PathBuf::from(std::env::var_os("HORIZON_HOME").unwrap())
.join(format!("h114-{:?}", std::thread::current().id())),
);
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.build_registries();
game.set_allow_commands(true);
game.world
.chunks
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
game.block_ticks
.add_empty_chunk(ChunkPos::new(0, 0))
.unwrap();
assert!(game.run_command("difficulty normal"));
assert!(game.run_command("forceload add 0 0"));
for (x, z) in [(0, 1), (1, 0), (1, 1)] {
game.world
.chunks
.insert((x, z), Arc::new(Chunk::empty(x, z)));
game.block_ticks
.add_empty_chunk(ChunkPos::new(x, z))
.unwrap();
}
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(2.5, 200.0, 2.5)).unwrap());
game.creative = true;
assert!(game.run_command("fill 0 199 0 31 199 31 stone"));
assert!(
game.run_command("summon pig 13.5 200 2.5 {UUID:[I;114,0,0,99],NoAI:1b,NoGravity:1b}")
);
game.world.random = LegacyRandom::new(114);
test(&mut game);
}
fn start_events() {
crate::game::sculk::TEST_POSTED_EVENTS.with(|v| *v.borrow_mut() = Some(Vec::new()));
}
fn take_shots() -> Vec<(&'static str, [f64; 3], Option<Who>)> {
crate::game::sculk::TEST_POSTED_EVENTS.with(|v| {
std::mem::take(v.borrow_mut().as_mut().unwrap())
.into_iter()
.filter(|e| e.0 == "minecraft:projectile_shoot")
.collect()
})
}
fn stop_events() {
crate::game::sculk::TEST_POSTED_EVENTS.with(|v| *v.borrow_mut() = None);
}
fn owner(game: &Game<'_>) -> u32 {
game.mobs
.iter()
.find(|m| game.persist.uuid_of(m.id) == Some([114, 0, 0, 99]))
.unwrap()
.id
}
#[test]
#[ignore = "prepared resources; actual registered load, Game tick and posted events"]
fn loaded_arrow_first_tick_dispatches_once_with_its_real_owner() {
with_game(|game| {
let owner = owner(game);
// Already-shot is an independent silent control before the old failure.
for (n, flag) in [(1, Some(1)), (2, None), (3, Some(0))] {
let extra = flag
.map(|f| format!(",HasBeenShot:{f}b"))
.unwrap_or_default();
assert!(game.run_command(&format!("summon arrow 6.5 201.5 10.5 {{UUID:[I;114,0,0,{n}],NoGravity:1b,Motion:[0d,0d,0d],Owner:[I;114,0,0,99]{extra}}}")));
let id = game
.creatures
.projectiles
.iter()
.find(|p| game.persist.uuid_of(p.id) == Some([114, 0, 0, n]))
.unwrap()
.id;
let p = game
.creatures
.projectiles
.iter()
.find(|p| p.id == id)
.unwrap();
assert_eq!(p.owner_uuid, Some([114, 0, 0, 99]));
assert_eq!(p.arrow.as_ref().unwrap().has_been_shot, flag == Some(1));
assert!(p.no_gravity);
assert_eq!(p.velocity, Vec3::ZERO);
start_events();
game.tick_projectiles().unwrap();
let p = game
.creatures
.projectiles
.iter()
.find(|p| p.id == id)
.unwrap();
assert!(p.arrow.as_ref().unwrap().has_been_shot);
assert_eq!(p.position, Vec3::new(6.5, 201.5, 10.5));
assert_eq!(p.velocity, Vec3::ZERO);
let first = take_shots();
game.tick_projectiles().unwrap();
let second = take_shots();
stop_events();
assert!(second.is_empty(), "no repeated shot on tick2");
let expected = if flag == Some(1) {
Vec::new()
} else {
vec![(
"minecraft:projectile_shoot",
[6.5, 201.5, 10.5],
Some(Who::Mob(owner)),
)]
};
assert_eq!(first, expected, "actual first tick, input={flag:?}");
assert!(game.run_command(&format!(
"kill {}",
horizon_protocol::crypto::uuid_string(
horizon_client::lan::playerdata::ints_uuid([114, 0, 0, n])
)
)));
}
});
}
fn natural(kind: &str) {
with_game(|game| {
let shooter = owner(game);
match kind {
"bow"=>{
assert!(game.run_command("item replace entity @s weapon.mainhand with bow"));
assert!(game.run_command("item replace entity @s hotbar.1 with arrow 4"));
},
"crossbow"=>assert!(game.run_command("item replace entity @s weapon.mainhand with crossbow[charged_projectiles=[{id:\"minecraft:arrow\",count:1}]]")),
_=>{}
}
let clock = game.world.game_time;
start_events();
match kind {
"bow" => {
let bow = game.held().unwrap().clone();
game.release_bow(&bow, 20);
}
"crossbow" => {
let bow = game.held().unwrap().clone();
assert!(game.use_crossbow(&bow));
}
"mob" => {
let mut p = Projectile::new(
ProjectileKind::Arrow {
base_damage: 2.0,
critical: false,
effect: None,
},
Vec3::new(6.5, 201.5, 6.5),
Some(Who::Mob(shooter)),
);
p.velocity = Vec3::new(0.0, 0.0, 1.0);
game.launch_mob_projectile(p);
}
_ => unreachable!(),
}
assert_eq!(game.creatures.projectiles.len(), 1, "real launch {kind}");
let p = &game.creatures.projectiles[0];
let id = p.id;
assert!(id > 0);
assert!(
!p.arrow.as_ref().unwrap().has_been_shot,
"saved pre-tick state"
);
let launch = take_shots();
game.tick_projectiles().unwrap();
let p = game
.creatures
.projectiles
.iter()
.find(|p| p.id == id)
.unwrap();
assert!(p.arrow.as_ref().unwrap().has_been_shot);
let at = [p.position.x, p.position.y, p.position.z];
let first = take_shots();
game.tick_projectiles().unwrap();
let second = take_shots();
stop_events();
assert_eq!(
game.world.game_time, clock,
"fixture directly invokes actual projectile ticks; runtime clocks independently admitted"
);
println!("H114_RNG {kind} {}", game.world.random.next_long());
assert_eq!(
[launch.len(), first.len(), second.len()],
[0, 1, 0],
"actual launch/tick1/tick2 counts {kind}"
);
assert_eq!(
first[0],
(
"minecraft:projectile_shoot",
at,
Some(if kind == "mob" {
Who::Mob(shooter)
} else {
Who::Player
})
)
);
});
}
#[test]
#[ignore = "prepared resources; actual bow release and Game ticks"]
fn bow_launch_uses_only_the_first_tick_event() {
natural("bow")
}
#[test]
#[ignore = "prepared resources; actual charged crossbow use and Game ticks"]
fn crossbow_launch_uses_only_the_first_tick_event() {
natural("crossbow")
}
#[test]
#[ignore = "prepared resources; actual Mob launch adapter and Game ticks"]
fn mob_launch_uses_only_the_first_tick_event() {
natural("mob")
}
#[test]
#[ignore = "prepared resources; non-arrow launch posts remain unchanged"]
fn non_arrow_rocket_and_mob_trident_keep_their_immediate_posts() {
with_game(|game| {
assert!(game.run_command("item replace entity @s weapon.mainhand with crossbow[charged_projectiles=[{id:\"minecraft:firework_rocket\",count:1}]]"));
start_events();
let bow = game.held().unwrap().clone();
assert!(game.use_crossbow(&bow));
assert_eq!(game.rockets.len(), 1);
assert_eq!(take_shots().len(), 1);
game.tick_projectiles().unwrap();
assert!(take_shots().is_empty());
stop_events();
let mut p = Projectile::new(
ProjectileKind::Trident,
Vec3::new(6.5, 201.5, 6.5),
Some(Who::Mob(owner(game))),
);
p.velocity = Vec3::new(0.0, 0.0, 1.0);
start_events();
game.launch_mob_projectile(p);
assert_eq!(take_shots().len(), 1);
game.tick_projectiles().unwrap();
assert!(take_shots().is_empty());
stop_events();
});
}
#[test]
#[ignore = "prepared resources; real loaded owner and finish-hit source"]
fn finish_hit_dispatches_once_and_the_next_tick_does_not_repeat_it() {
with_game(|game| {
assert!(game.run_command("summon arrow 6.5 201.5 10.5 {UUID:[I;114,0,0,20],NoGravity:1b,Motion:[0d,0d,0d],Owner:[I;114,0,0,99]}"));
let mut arrow = game.creatures.projectiles.pop().unwrap();
let owner = owner(game);
assert_eq!(arrow.owner_uuid, Some([114, 0, 0, 99]));
// Resolve through the actual registered owner, as tick_projectiles does.
arrow.owner = game.who_by_uuid(arrow.owner_uuid.unwrap());
assert_eq!(arrow.owner, Some(Who::Mob(owner)));
assert!(!arrow.arrow.as_ref().unwrap().has_been_shot);
let id = arrow.id;
start_events();
game.finish_arrow_hit(arrow, super::EntityHit::Deflected);
let first = take_shots();
assert!(
game.creatures
.projectiles
.iter()
.find(|p| p.id == id)
.unwrap()
.arrow
.as_ref()
.unwrap()
.has_been_shot
);
game.tick_projectiles().unwrap();
assert!(take_shots().is_empty());
stop_events();
assert_eq!(
first,
vec![(
"minecraft:projectile_shoot",
[6.5, 201.5, 10.5],
Some(Who::Mob(owner))
)]
);
});
}
#[test]
#[ignore = "prepared resources; registered Guest context and finish-hit source"]
fn finish_hit_uses_registered_guest_context_and_restores_the_host() {
use horizon_client::lan::{Joined, NetConfig, PlayerSender, guest::Guest, hub::HubConfig};
use horizon_protocol::packets::configuration::ClientInformation;
use std::sync::{atomic::AtomicI32, mpsc};
with_game(|game| {
game.lan_host(
crate::game::lan::LanSettings {
port: 0,
guest_commands: true,
force_game_mode: false,
},
NetConfig {
announce: false,
..NetConfig::loopback(0, "arrow-test".into(), 2)
},
HubConfig {
default_game_mode: 1,
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: 114,
name: "ArrowGuest".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,
[6.5, 200.0, 6.5],
2,
);
guest.dimension = "minecraft:overworld".into();
guest.placed = true;
guest.loaded = true;
guest.permission = 4;
let mut host = game.lan.take().unwrap();
host.hub.guests.push(guest);
let host_uuid = game.acting_player_uuid();
game.as_guest(&mut host,2,|g| {
assert_eq!(g.lan_acting,Some(2));
assert_eq!(g.acting_player_uuid(),[0,0,0,114]);
assert!(g.run_command("item replace entity @s weapon.mainhand with crossbow[charged_projectiles=[{id:\"minecraft:arrow\",count:1}]]"));
let bow=g.held().unwrap().clone();assert!(g.use_crossbow(&bow));
}).unwrap();
game.lan = Some(host);
assert_eq!(game.acting_player_uuid(), host_uuid);
assert_eq!(game.lan_acting, None);
let arrow = game.creatures.projectiles.pop().unwrap();
assert_eq!(arrow.guest, Some(2));
assert_eq!(arrow.owner, Some(Who::Player));
assert_eq!(arrow.owner_uuid, Some([0, 0, 0, 114]));
assert!(!arrow.arrow.as_ref().unwrap().has_been_shot);
let at = [arrow.position.x, arrow.position.y, arrow.position.z];
let id = arrow.id;
start_events();
game.finish_arrow_hit(arrow, super::EntityHit::Deflected);
let first = take_shots();
assert_eq!(game.acting_player_uuid(), host_uuid);
assert_eq!(game.lan_acting, None);
assert_eq!(game.lan.as_ref().unwrap().hub.guests[0].uuid, 114);
assert!(
game.creatures
.projectiles
.iter()
.find(|p| p.id == id)
.unwrap()
.arrow
.as_ref()
.unwrap()
.has_been_shot
);
game.tick_projectiles().unwrap();
assert!(take_shots().is_empty());
stop_events();
assert_eq!(
first,
vec![("minecraft:projectile_shoot", at, Some(Who::Player))]
);
});
}
}
+4 -7
View File
@@ -10,6 +10,7 @@ use horizon_client::{
items::ItemStack,
mobs::Who,
projectiles::{Projectile, ProjectileKind},
random_source,
};
use horizon_core::{geometry::Vec3, random::RandomSource};
@@ -342,13 +343,9 @@ impl Game<'_> {
if rocket {
let at = Vec3::new(eye[0], eye[1] - f64::from(0.15f32), eye[2]);
let mut shot = Projectile::new(ProjectileKind::Snowball, at, None);
shot.shoot(
&mut super::dispenser::entity_random(),
dir.to_array(),
power,
1.0,
);
shot.shoot(&mut random_source::entity(), dir.to_array(), power, 1.0);
self.launch_angled_rocket(at, shot.velocity, ammo, Some(Who::Player));
self.player_projectile_shot();
}
if !rocket {
let effect = super::dispenser::tipped_effect(ammo).or_else(|| {
@@ -382,7 +379,7 @@ impl Game<'_> {
);
self.native_entity_shoot_bow(player, &name, projectile, power as f32);
}
self.player_projectile_shot();
// Ordinary arrows post from ArrowState on their first tick.
let pitch = if index == 0 {
1.0
} else {
@@ -403,6 +403,18 @@ impl Game<'_> {
self.spawn_item(stack, [p.x, p.y, p.z], [0.0; 3], 10);
}
/// `Entity.kill` of a cushion (`/kill`): gone with no drop, its rider let go.
pub(super) fn kill_cushion(&mut self, id: u32) {
let Some(i) = self.cushions.iter().position(|c| c.id == id) else {
return;
};
let c = self.cushions.remove(i);
self.world.modified.insert((c.pos[0] >> 4, c.pos[2] >> 4));
if self.riding == Some(c.id) {
self.dismount_removed_vehicle(c.position.y);
}
}
/// `Cushion.showBreakingParticles`: ten of the matching wool block.
fn cushion_break_particles(&mut self, c: &Cushion) {
let name = format!("minecraft:{}_wool", c.color_name());
@@ -104,6 +104,10 @@ fn floor_pos(p: [f64; 3]) -> String {
enum Target {
Mob(u32),
Stand(u32),
/// A chest boat or raft (`ContainerEntity`).
Boat(u32),
/// A chest or hopper minecart (`ContainerEntity`).
Cart(u32),
}
impl Game<'_> {
@@ -303,6 +307,9 @@ impl Game<'_> {
LOG.with(|log| *log.borrow_mut() = Some(Vec::new()));
let old_mobs: Vec<u32> = self.mobs.iter().map(|m| m.id).collect();
let old_stands: Vec<u32> = self.armor_stands.iter().map(|s| s.id).collect();
let old_vehicles: Vec<u32> = (self.boats.iter().map(|b| b.id))
.chain(self.carts.iter().map(|c| c.id))
.collect();
let pos = &case["pos"];
let origin = BlockPos::new(
@@ -462,9 +469,10 @@ impl Game<'_> {
s.spawn_delay as i16
));
}
self.fixture_entities(&old_mobs, &old_stands, &mut out);
self.fixture_entities(&old_mobs, &old_stands, &old_vehicles, &mut out);
for (i, t) in targets.iter().enumerate() {
out.push(self.fixture_describe(i, t));
self.fixture_vehicle_items(i, t, &mut out);
}
let log = LOG.with(|log| log.borrow_mut().take()).unwrap_or_default();
let event_sounds: Vec<String> = log.iter().filter_map(|l| event_sound(l)).collect();
@@ -720,7 +728,7 @@ impl Game<'_> {
let bonus = self.smash_damage_bonus("minecraft:armor_stand");
(bonus, self.hit_armor_stand(index, |_, _, _| {}))
}
None => return Err("an attack case has a target".into()),
_ => return Err("an attack case has a mob or armor stand target".into()),
};
self.process_level_events()?;
let mut out = Vec::new();
@@ -779,6 +787,7 @@ impl Game<'_> {
continue;
}
},
Target::Boat(_) | Target::Cart(_) => continue,
};
out.push(format!(
"V {i} {health} {} {} {}",
@@ -1039,6 +1048,20 @@ impl Game<'_> {
let yaw = e["yaw"].as_f64().unwrap_or(0.0) as f32;
self.next_entity += 1;
let id = self.next_entity;
if let Some(cart) = super::minecarts::CartKind::from_id(kind) {
let mut c = super::minecarts::Minecart::new(id, cart, at);
c.yaw = yaw;
self.carts.push(c);
return Ok(Target::Cart(id));
}
if let Some((wood, chest)) = super::boats::any_boat_wood(kind) {
let mut boat = super::boats::Boat::new(id, wood.into(), at, yaw);
if chest {
boat = boat.with_chest();
}
self.boats.push(boat);
return Ok(Target::Boat(id));
}
if kind == "minecraft:armor_stand" {
let mut stand = horizon_client::entities::ArmorStand::new(id, at, yaw);
stand.marker = e["marker"].as_bool().unwrap_or(false);
@@ -1127,10 +1150,33 @@ impl Game<'_> {
Ok(Target::Mob(id))
}
/// `Tinv` lines: a container entity target's items by slot, as
/// `VanillaDispenserOracle` prints a `ContainerEntity`'s.
fn fixture_vehicle_items(&self, index: usize, target: &Target, out: &mut Vec<String>) {
let items = match *target {
Target::Boat(id) => self.boats.iter().find(|b| b.id == id).map(|b| &b.items),
Target::Cart(id) => self.carts.iter().find(|c| c.id == id).map(|c| &c.items),
Target::Mob(_) | Target::Stand(_) => None,
};
for (slot, stack) in items.into_iter().flatten().enumerate() {
if let Some(stack) = stack {
out.push(format!("Tinv {index} {slot} {}", canon_stack(Some(stack))));
}
}
}
/// `T` lines: removal, equipment by `EquipmentSlot`, shearing readiness, chests.
fn fixture_describe(&self, index: usize, target: &Target) -> String {
let mut line = format!("T {index} ");
match *target {
Target::Boat(id) => match self.boats.iter().find(|b| b.id == id) {
Some(b) => line.push_str(&b.entity_type()),
None => line.push_str("removed"),
},
Target::Cart(id) => match self.carts.iter().find(|c| c.id == id) {
Some(c) => line.push_str(c.kind.id()),
None => line.push_str("removed"),
},
Target::Stand(id) => {
line.push_str("minecraft:armor_stand");
let Some(stand) = self.armor_stands.iter().find(|s| s.id == id) else {
@@ -1205,7 +1251,13 @@ impl Game<'_> {
}
/// `N` lines for every entity the dispenser added.
fn fixture_entities(&self, old_mobs: &[u32], old_stands: &[u32], out: &mut Vec<String>) {
fn fixture_entities(
&self,
old_mobs: &[u32],
old_stands: &[u32],
old_vehicles: &[u32],
out: &mut Vec<String>,
) {
let line = |kind: &str, p: [f64; 3], v: [f64; 3], extra: &str| {
let mut l = format!(
"N {kind} {} {} {} {} {} {}",
@@ -1293,7 +1345,7 @@ impl Game<'_> {
&format!("fuse={}", t.fuse),
));
}
for b in &self.boats {
for b in self.boats.iter().filter(|b| !old_vehicles.contains(&b.id)) {
let kind = b.entity_type();
let (p, v) = (b.position, b.velocity);
out.push(line(&kind, [p.x, p.y, p.z], [v.x, v.y, v.z], ""));
@@ -1307,11 +1359,11 @@ impl Game<'_> {
"minecraft:experience_orb",
[p.x, p.y, p.z],
[v.x, v.y, v.z],
"",
&format!("value={}", o.value),
));
}
}
for c in &self.carts {
for c in self.carts.iter().filter(|c| !old_vehicles.contains(&c.id)) {
let (p, v) = (c.position, c.velocity);
out.push(line(c.kind.id(), [p.x, p.y, p.z], [v.x, v.y, v.z], ""));
}
+10 -28
View File
@@ -12,13 +12,14 @@ use horizon_client::{
mobs::{Mob, Species, sulfur_cube},
particles::{ParticleOptions, ParticleType},
projectiles::{Projectile, ProjectileKind},
random_source,
};
// Its old home, for mixin code that names it here.
pub(super) use horizon_client::mod_api::NativeDispensed;
use horizon_core::{
geometry::{Aabb, Direction, Vec3},
position::BlockPos,
random::{LegacyRandom, RandomSource},
random::RandomSource,
};
use horizon_world::{
fluid::FluidKind,
@@ -134,31 +135,9 @@ enum Target {
Stand(usize),
}
/// `RandomSource.create()`: the unseeded random an entity owns
/// (`RandomSupport.generateUniqueSeed`). Draws of an entity's own random never
/// touch the level's stream.
pub(super) fn entity_random() -> LegacyRandom {
use std::sync::atomic::{AtomicI64, Ordering};
static UNIQUIFIER: AtomicI64 = AtomicI64::new(8_682_522_807_148_012);
const STEP: i64 = 1_181_783_497_276_652_981;
// seedUniquifier.updateAndGet(s -> s * STEP)
let mut current = UNIQUIFIER.load(Ordering::Relaxed);
let seed = loop {
let next = current.wrapping_mul(STEP);
match UNIQUIFIER.compare_exchange_weak(current, next, Ordering::Relaxed, Ordering::Relaxed)
{
Ok(_) => break next,
Err(actual) => current = actual,
}
};
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos() as i64);
LegacyRandom::new(seed ^ nanos)
}
/// `new ItemEntity(level, x, y, z, stack)`'s motion, from the item's own random.
pub(super) fn new_item_motion() -> [f64; 3] {
let mut r = entity_random();
let mut r = random_source::entity();
let x = r.next_double() * 0.2 - 0.1;
let z = r.next_double() * 0.2 - 0.1;
[x, 0.2, z]
@@ -1103,7 +1082,7 @@ impl Game<'_> {
..stack.clone()
};
// Projectile.shoot spreads the shot with the projectile's own random.
let mut own = entity_random();
let mut own = random_source::entity();
match item.as_str() {
"minecraft:experience_bottle"
| "minecraft:splash_potion"
@@ -1329,7 +1308,7 @@ impl Game<'_> {
if emptied.is_none() {
return Ok((self.dispense_default(pos, facing, stack), CLICK));
}
if self.dim == super::Dim::Nether
if self.dimension_type().water_evaporates
&& matches!(
content,
BucketContent::Fluid(FluidKind::Water) | BucketContent::MobWater
@@ -1673,9 +1652,12 @@ impl Game<'_> {
};
let target = (placed.x, placed.y, placed.z);
let state = self.apply_item_block_state([target.0, target.1, target.2], state, &stack)?;
let name = self.res.facts().get(state).name.clone();
if let Some(entity) = self.new_block_entity(&name) {
if !self.world.block_entities.stateful_entity(state.0)
&& let Some(entity) = self.new_block_entity_for_state(state)
{
self.world.block_entities.insert(target, entity);
}
if self.world.block_entities.get(&target).is_some() {
self.apply_placed_item([target.0, target.1, target.2], &stack, false);
}
self.process_level_events()?;
+25 -13
View File
@@ -8,7 +8,7 @@
//! exit portal, the egg and the next gateway; and its rendering and boss bar.
use super::commands::Target;
use super::{
Dim, Game,
Game,
potions::{Victim, mob_can_be_affected},
};
use horizon_client::{
@@ -19,6 +19,7 @@ use horizon_client::{
mobs::{Damage, DamageKind, Who},
particles::{ParticleOptions, ParticleType},
projectiles::ProjectileKind,
random_source,
text::Text,
};
use horizon_core::position::BlockPos;
@@ -1188,7 +1189,7 @@ impl Game<'_> {
pub(super) fn tick_fight(&mut self) {
// A dragon outside the End (`/summon`) has no fight: `ServerLevel.getDragonFight` is
// null there, but `EnderDragon.aiStep` still flies it.
if self.dim != Dim::End {
if !self.dimension_type().has_ender_dragon_fight {
self.tick_dragon();
return;
}
@@ -1310,7 +1311,7 @@ impl Game<'_> {
/// `EndCrystal.tick` in every dimension: the time (the spin and bob),
/// and fire at its block where there is a dragon fight (the End).
pub(super) fn tick_crystals(&mut self) {
let fire = (self.dim == Dim::End)
let fire = (self.dimension_type().has_ender_dragon_fight)
.then(|| self.pack.assets.registry.default_state("minecraft:fire"))
.flatten();
for i in 0..self.crystals.len() {
@@ -1352,7 +1353,13 @@ impl Game<'_> {
Vec3::new(x - 1.0, y, z - 1.0),
Vec3::new(x + 1.0, y + 2.0, z + 1.0),
));
let _ = self.server_explode(at, 6.0, interaction, false);
let _ = self.server_explode_from(
at,
6.0,
interaction,
false,
super::explosion::ExplosionSource::default().with_silence(self.entity_silent(c.id)),
);
self.combat.explosion_in_water = false;
self.fight.blast_by = None;
}
@@ -3142,7 +3149,11 @@ impl Game<'_> {
/// (`EnderDragonFight.dragonEvent`, visible while the fight is not won
/// and the player is alive within 192 blocks of (0, 128, 0)).
pub(super) fn dragon_boss_progress(&self, at: [f64; 3], alive: bool) -> Option<f32> {
if self.dim != Dim::End || !alive || !self.fight.loaded || self.fight.killed {
if !self.dimension_type().has_ender_dragon_fight
|| !alive
|| !self.fight.loaded
|| self.fight.killed
{
return None;
}
let near = at[0].powi(2) + (at[1] - 128.0).powi(2) + at[2].powi(2) <= 192.0 * 192.0;
@@ -3898,9 +3909,7 @@ impl Game<'_> {
return false;
}
// EndCrystal's constructor: `this.random.nextInt(100000)`, the crystal's own random.
let drawn = super::dispenser::entity_random()
.next_int_bound(100_000)
.unwrap_or(0);
let drawn = random_source::entity().next_int_bound(100_000).unwrap_or(0);
let at = Vec3::new(x + 0.5, y, z + 0.5);
let time = Crystal::start_time(at, drawn, self.clock.still());
// EndCrystal's constructor takes an id from the entity counter, so
@@ -3917,7 +3926,7 @@ impl Game<'_> {
// EndCrystalItem.useOn: ENTITY_PLACE at the block above, by the player.
self.post_block_game_event("minecraft:entity_place", above, self.sculk_actor(), None);
self.world.modified.insert((above[0] >> 4, above[2] >> 4));
if self.dim == Dim::End {
if self.dimension_type().has_ender_dragon_fight {
self.try_respawn();
}
if !self.creative {
@@ -4248,7 +4257,8 @@ impl Game<'_> {
/// It becomes the fight's dragon (`dragonUUID = dragon.getUUID()`).
fn create_new_dragon(&mut self) {
let yaw = self.world.random.next_float() * 360.0;
let seed = self.world.random.next_long();
// The dragon's own random (`Entity.random`), not the level's.
let seed = random_source::unique_seed();
let mut d = Dragon::new(Vec3::new(0.0, 128.0, 0.0), yaw, seed);
// `dragon.getPhaseManager().setPhase(EnderDragonPhase.HOLDING_PATTERN)`.
d.phase = Phase::Holding {
@@ -4305,7 +4315,7 @@ impl Game<'_> {
/// The dragon is the End fight's own (`EnderDragon.dragonFight`, set
/// only for the dragon whose UUID is the fight's `dragonUUID`).
fn is_fight_dragon(&mut self) -> bool {
if self.dim != Dim::End {
if !self.dimension_type().has_ender_dragon_fight {
return false;
}
match (self.dragon.as_ref().map(|d| d.id), self.fight.dragon_uuid) {
@@ -4333,11 +4343,13 @@ impl Game<'_> {
c.position.x.floor() as i32 >> 4,
c.position.z.floor() as i32 >> 4,
));
let _ = self.server_explode(
let _ = self.server_explode_from(
[c.position.x, c.position.y, c.position.z],
6.0,
super::explosion::BlockInteraction::Keep,
false,
super::explosion::ExplosionSource::default()
.with_silence(self.entity_silent(c.id)),
);
}
}
@@ -4450,7 +4462,7 @@ impl Game<'_> {
let _ = self.world.random.triangle(0.0, 0.114_850_000_000_000_01);
let _ = self.world.random.next_float();
// The dragon's own random (`Entity.random`), not the level's.
let seed = super::dispenser::entity_random().next_long();
let seed = random_source::unique_seed();
let mut dragon = Dragon::new(position, yaw, seed);
dragon.id = self.next_entity + 1;
self.next_entity += 9;
@@ -12,6 +12,7 @@ use horizon_client::{
items::ItemStack,
mobs::Who,
projectiles::Projectile,
random_source,
};
use horizon_core::{position::BlockPos, random::RandomSource};
use horizon_world::{fluid::FluidKind, level::LevelAccess, registry::BlockStateId};
@@ -622,7 +623,7 @@ impl Game<'_> {
Some("damage_entity") => {
let min = level_value(&effect["min_damage"], level);
let max = level_value(&effect["max_damage"], level);
let damage = super::dispenser::entity_random().next_float() * (max - min) + min;
let damage = random_source::entity().next_float() * (max - min) + min;
let Some(m) = self.mobs.iter().find(|m| m.id == wearer) else {
return;
};
+21 -71
View File
@@ -3,7 +3,6 @@
//! sky. The dragon fight itself lives in game/dragon.rs: the podium is
//! inactive until the dragon dies.
use super::{Dim, Game};
use horizon_client::nether_portal::ENTITY_DELAY;
use horizon_client::{
Result,
containers::BlockEntity,
@@ -21,15 +20,9 @@ use horizon_core::{
position::BlockPos,
random::{LegacyRandom, RandomSource},
};
use horizon_world::nbt::Tag;
use std::{collections::HashMap, sync::Arc};
/// A list of doubles, as an NBT list of them.
fn doubles(values: [f64; 3]) -> Tag {
Tag::List(values.iter().map(|v| Tag::Double(v.to_bits())).collect())
}
/// `TicketType.PORTAL`'s timeout, in ticks.
const PORTAL_TICKET: u32 = 300;
@@ -64,12 +57,10 @@ struct GatewayArrival {
/// What waits at a gateway's exit.
#[derive(Clone, Debug)]
enum Arriving {
/// An item or a mob, as its saved compound.
Saved(Tag),
/// A thrown ender pearl (projectiles are not saved).
Pearl(Box<Projectile>),
/// A vehicle remains in this level while its destination loads.
Vehicle(u32),
/// An ordinary entity remains live in this level while its destination loads.
Live(u32),
}
impl GatewayJob {
@@ -362,7 +353,7 @@ impl Game<'_> {
/// Iris's `endFlashIntensity` and the flash's direction (camera-relative, length 100: the
/// sprite's centre), for shader packs; no flash outside the End.
pub(super) fn end_flash_for_pack(&self, partial: f32) -> (f32, glam::Vec3) {
if self.dim != Dim::End {
if self.dimension_type().skybox != horizon_world::dimension_type::Skybox::End {
return (0.0, glam::Vec3::ZERO);
}
let flash = &self.end.flash;
@@ -378,7 +369,9 @@ impl Game<'_> {
/// unless Hide Lightning Flashes is on (the sky sprite is not gated), a
/// third of it under a boss bar's world fog.
pub(super) fn end_flash_sky(&self, sky: &mut horizon_client::environment::Sky, partial: f64) {
if self.dim == Dim::End && !self.settings.file.bool("hideLightningFlashes") {
if self.dimension_type().skybox == horizon_world::dimension_type::Skybox::End
&& !self.settings.file.bool("hideLightningFlashes")
{
let intensity = self.end.flash.intensity(partial as f32);
sky.sky_light_factor += if self.boss_overlay.should_create_world_fog() {
intensity / 3.0
@@ -519,7 +512,7 @@ impl Game<'_> {
/// End portals, End gateways and the exit portal, once per tick.
pub(super) fn tick_end(&mut self) -> Result<()> {
self.end.gateway_cooldown = self.end.gateway_cooldown.saturating_sub(1);
if self.dim == Dim::End {
if self.dimension_type().has_ender_dragon_fight {
self.tick_fight();
self.tick_dragon_fireballs();
} else {
@@ -531,7 +524,7 @@ impl Game<'_> {
self.native_joins_flush(false, 0);
self.tick_crystals();
self.tick_eyes();
if self.dim == Dim::End {
if self.dimension_type().skybox == horizon_world::dimension_type::Skybox::End {
self.end.flash.tick(self.world.game_time);
if let Some(n) = self.end.flash.sound_in.as_mut() {
*n = n.saturating_sub(1);
@@ -542,7 +535,7 @@ impl Game<'_> {
}
}
}
if self.dim == Dim::End {
if self.dimension_type().has_ender_dragon_fight {
self.ensure_exit_portal()?;
}
Ok(())
@@ -1486,10 +1479,9 @@ impl Game<'_> {
.collect();
let mut present: std::collections::HashSet<u32> = ids.iter().map(|(id, _)| *id).collect();
// A pearl waiting for its exit keeps its portal cooldown.
present.extend(self.end.arrivals.iter().filter_map(|a| match &a.traveller {
Arriving::Pearl(p) => Some(p.id),
Arriving::Vehicle(id) => Some(*id),
Arriving::Saved(_) => None,
present.extend(self.end.arrivals.iter().map(|a| match &a.traveller {
Arriving::Pearl(p) => p.id,
Arriving::Live(id) => *id,
}));
self.end.gateways.retain(|id, _| present.contains(id));
let mut leaving: Vec<(u32, BlockPos)> = Vec::new();
@@ -1511,19 +1503,20 @@ impl Game<'_> {
}
/// `Entity.teleportToPortalDestination` for an entity that goes through an
/// End gateway: a same-level vehicle stays live while the exit loads.
/// End gateway: an ordinary same-level entity stays live while the exit loads.
fn send_through_gateway(&mut self, id: u32, gateway: BlockPos) {
if matches!(
self.level_entity(id as i32),
Some(
super::commands::Target::Mob(_)
super::commands::Target::Item(_)
| super::commands::Target::Mob(_)
| super::commands::Target::Boat(_)
| super::commands::Target::Cart(_)
)
) {
let (job, target) = self.gateway_job([gateway.x, gateway.y, gateway.z]);
let arrival = GatewayArrival {
traveller: Arriving::Vehicle(id),
traveller: Arriving::Live(id),
job,
target,
};
@@ -1532,21 +1525,7 @@ impl Game<'_> {
}
return;
}
let tag = if let Some(i) = self.dropped.iter().position(|e| e.id == id) {
let uuid = self.uuid_for(id);
let tag = horizon_client::entity_nbt::item_entity_to_nbt_with_items(
&self.dropped[i],
uuid,
self.persist_raw(id),
self.items(),
);
// A gateway keeps the item in its level (teleportSameDimension
// moves it): it doesn't leave, so no native_entity_leave_level.
self.dropped.remove(i);
self.native_leave_skip(id);
self.detach_entity(id);
tag
} else if let Some(i) = self.creatures.projectiles.iter().position(|p| p.id == id) {
if let Some(i) = self.creatures.projectiles.iter().position(|p| p.id == id) {
let pearl = Box::new(self.creatures.projectiles.remove(i));
self.native_leave_skip(id);
let (job, target) = self.gateway_job([gateway.x, gateway.y, gateway.z]);
@@ -1559,16 +1538,7 @@ impl Game<'_> {
if let Some(waiting) = self.arrive_through_gateway(arrival) {
self.end.arrivals.push(waiting);
}
return;
} else {
return;
};
let (job, target) = self.gateway_job([gateway.x, gateway.y, gateway.z]);
self.end.arrivals.push(GatewayArrival {
traveller: Arriving::Saved(tag),
job,
target,
});
}
}
/// The entities that reached a gateway's exit: they land where the
@@ -1586,8 +1556,8 @@ impl Game<'_> {
}
/// `Entity.teleport` of a gateway traveller once its exit's chunk has
/// loaded: an item or a mob joins the chunk it lands in, a pearl flies
/// on from there. The traveller comes back while it still waits.
/// loaded: the ordinary entity moves live and a pearl flies on from there.
/// The traveller comes back while it still waits.
fn arrive_through_gateway(&mut self, arrival: GatewayArrival) -> Option<GatewayArrival> {
let chunk = |at: Vec3| ((at.x.floor() as i32) >> 4, (at.z.floor() as i32) >> 4);
if !self.world.chunks.contains_key(&chunk(arrival.target))
@@ -1608,7 +1578,7 @@ impl Game<'_> {
};
let key = chunk(at);
match arrival.traveller {
Arriving::Vehicle(id) => {
Arriving::Live(id) => {
if let Some(target) = self.level_entity(id as i32) {
// Entity.teleportSameDimension keeps the live entity and
// its motion, and moves every passenger in the same operation.
@@ -1617,26 +1587,6 @@ impl Game<'_> {
self.place_portal_ticket(key);
}
}
Arriving::Saved(mut tag) => {
if let Tag::Compound(c) = &mut tag {
c.insert("Pos".into(), doubles([at.x, at.y, at.z]));
c.insert("Motion".into(), doubles([0.0, 0.0, 0.0]));
// setPortalCooldown before the teleport.
c.insert("PortalCooldown".into(), Tag::Int(ENTITY_DELAY));
}
// A gateway keeps the entity in its level (teleportSameDimension
// moves it, no addDuringTeleport): it doesn't join again.
super::persist::mark_moved(&mut tag);
match self
.world
.loaded_entities
.iter_mut()
.find(|(k, _)| *k == key)
{
Some((_, tags)) => tags.push(tag),
None => self.world.loaded_entities.push((key, vec![tag])),
}
}
Arriving::Pearl(mut pearl) => {
horizon_client::end_gateway::place_pearl(&mut pearl, at);
// ThrownEnderpearl.teleport: placePortalTicket where it came out.
@@ -6,11 +6,7 @@ use horizon_client::{
chunk::Chunk,
mobs::{Mob, MobEvent},
};
use horizon_core::{
geometry::Vec3,
position::ChunkPos,
random::{LegacyRandom, RandomSource},
};
use horizon_core::{geometry::Vec3, position::ChunkPos, random::LegacyRandom};
use std::sync::Arc;
/// A game over 5x5 empty chunks around (0, 0) with a stone floor at y = 3:
@@ -134,16 +130,27 @@ fn an_infested_endermans_silverfish_come_out_where_it_was_hurt() {
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let mut game = floored_game(&root);
let at = Vec3::new(8.5, 4.0, 8.5);
// A seed whose first draw passes the effect's 1-in-10 chance (`nextFloat() <= 0.1`).
let seed = (0..)
.find(|&s| LegacyRandom::new(s).next_float() <= 0.1)
.unwrap();
let mut mob = enderman(&game, 600, at, seed);
mob.add_effect("minecraft:infested", 200, 0);
// What `Mob::damage` leaves for a projectile's hit: the effect's spawn and
// `repeatedlyTryToTeleport`.
mob.outbox.push(MobEvent::Infested { mob: 600 });
mob.extra.counter2 = 64;
// A hurt with nothing living behind it, from a seed whose draws after the
// hurt sound pass the effect's 1-in-10 chance and then `nextInt(10) != 0`:
// `Mob::damage` leaves the silverfish and one `teleport()`.
let hurt = |seed| {
let mut mob = enderman(&game, 600, at, seed);
mob.add_effect("minecraft:infested", 200, 0);
mob.damage(
1.0,
horizon_client::mobs::Damage::of(horizon_client::mobs::DamageKind::Generic),
);
mob
};
let mob = (0..10_000)
.map(hurt)
.find(|m| {
m.extra.counter2 == 1
&& m.outbox
.iter()
.any(|e| matches!(e, MobEvent::Infested { .. }))
})
.expect("no seed spawns silverfish and teleports");
game.mobs.push(mob);
game.settle_hurt_teleports().unwrap();
let ender = game.mobs.iter().find(|m| m.id == 600).unwrap();
+146 -4
View File
@@ -94,6 +94,18 @@ pub(super) enum BlockInteraction {
DestroyWithDecay,
}
/// Properties of the direct entity passed to `ServerLevel.explode`.
#[derive(Clone, Copy, Default)]
pub(super) struct ExplosionSource {
silent: bool,
}
impl ExplosionSource {
pub(super) fn with_silence(mut self, silent: bool) -> Self {
self.silent = silent;
self
}
}
/// `ServerExplosion.hurtEntities`: an entity is hurt when its position is
/// within the blast's diameter of the centre.
pub(super) fn blast_reaches(center: [f64; 3], diameter: f64, at: [f64; 3]) -> bool {
@@ -244,6 +256,7 @@ impl Game<'_> {
context: CollisionContext::for_entity(
CollisionEntity {
identity: EntityIdentity(0),
kind: horizon_world::collision_context::EntityContextKind::None,
y: min[1],
descending: false,
main_hand: Some(CollisionItem::EMPTY),
@@ -633,6 +646,7 @@ impl Game<'_> {
let (items_len, orbs_len) = (self.dropped.len(), self.orbs.len());
let (mut items_kept, mut orbs_kept) = (0, 0);
let (mut boats_gone, mut carts_gone) = (0, 0);
let mut destroyed_contents = Vec::new();
for handle in self.entity_handles() {
let handle = match handle {
EntityHandle::Boat(i) => EntityHandle::Boat(i - boats_gone),
@@ -689,6 +703,17 @@ impl Game<'_> {
if &*item.stack.item != "minecraft:nether_star" {
item.health = (item.health as f32 - amount) as i32;
destroyed = item.health <= 0;
if destroyed {
let at = item.position;
// Health was already reduced above. Run the shared
// onDestroyed path without applying damage twice.
destroyed_contents.extend(
item.hurt(0)
.unwrap_or_default()
.into_iter()
.map(|stack| (stack, at)),
);
}
}
}
// `ExperienceOrb.hurtServer` takes the damage off their 5 health.
@@ -749,6 +774,9 @@ impl Game<'_> {
}
self.dropped.drain(items_kept..items_len);
self.orbs.drain(orbs_kept..orbs_len);
// The original target walk is finished: released contents cannot be
// hurt or pushed by the explosion that destroyed their container.
self.spawn_destroyed_contents(destroyed_contents);
self.native_leaves_flush();
// Frames, paintings, cushions and armour stands last: vanilla lists
// the entities in reach before hurting any, so the items they drop
@@ -863,12 +891,30 @@ impl Game<'_> {
radius: f32,
interaction: BlockInteraction,
fire: bool,
) -> Result<()> {
self.server_explode_from(
center,
radius,
interaction,
fire,
ExplosionSource::default(),
)
}
/// `ServerLevel.explode`: only the direct source's `isSilent` suppresses
/// the sound; terrain, entity damage and the visual packet are unchanged.
pub(super) fn server_explode_from(
&mut self,
center: [f64; 3],
radius: f32,
interaction: BlockInteraction,
fire: bool,
source: ExplosionSource,
) -> Result<()> {
let damage_type = super::creatures::damage_type(
horizon_client::mobs::DamageKind::Explosion,
self.blast_attacker().is_some(),
);
self.server_explode_as(center, radius, interaction, fire, damage_type, None)
self.server_explode_as_source(center, radius, interaction, fire, damage_type, None, source)
}
/// `server_explode` with the damage type the player takes and a block
/// whose explosion resistance the damage calculator overrides.
@@ -880,6 +926,26 @@ impl Game<'_> {
fire: bool,
damage_type: &str,
resists: Option<([i32; 3], f32)>,
) -> Result<()> {
self.server_explode_as_source(
center,
radius,
interaction,
fire,
damage_type,
resists,
ExplosionSource::default(),
)
}
fn server_explode_as_source(
&mut self,
center: [f64; 3],
radius: f32,
interaction: BlockInteraction,
fire: bool,
damage_type: &str,
resists: Option<([i32; 3], f32)>,
source: ExplosionSource,
) -> Result<()> {
if self.serving.is_some() || self.options.dedicated.is_some() {
self.native_explosion_prime(center, radius);
@@ -937,9 +1003,11 @@ impl Game<'_> {
}
}
}
let pitch = (1.0 + (client_float() - client_float()) * 0.2) * 0.7;
self.audio
.play("minecraft:entity.generic.explode", Some(center), 4.0, pitch);
if !source.silent {
let pitch = (1.0 + (client_float() - client_float()) * 0.2) * 0.7;
self.audio
.play("minecraft:entity.generic.explode", Some(center), 4.0, pitch);
}
// ServerLevel.explode sends toBlow.size() whatever the interaction, and
// `ServerExplosion.isSmall` also covers an explosion that keeps blocks.
let block_count = targets.len();
@@ -1060,6 +1128,70 @@ impl Game<'_> {
}
}
#[cfg(test)]
impl Game<'_> {
/// Exercise the real explosion entity walk on an empty, headless world.
pub(super) fn run_explosion_items_fixture(
&mut self,
directory: &std::path::Path,
) -> Result<()> {
#[derive(serde::Deserialize)]
struct Case {
name: String,
stack: String,
health: i32,
distances: Vec<f64>,
#[serde(default)]
sibling: bool,
}
let cases: Vec<Case> =
serde_json::from_str(&std::fs::read_to_string(directory.join("inputs.json"))?)?;
let mut output = Vec::new();
for case in cases {
self.dropped.clear();
let stack = horizon_client::item_data::parse_stack(&case.stack)?
.ok_or("empty fixture stack")?;
self.spawn_item(stack, [0.0, 100.0, 0.0], [0.0; 3], 0);
self.dropped[0].health = case.health;
let original = self.dropped[0].id;
if case.sibling {
self.spawn_item(
ItemStack::new("minecraft:dirt", 1),
[0.0, 100.0, 0.0],
[0.0; 3],
0,
);
}
let mut steps = Vec::new();
for distance in case.distances {
for item in &mut self.dropped {
item.position = Vec3::new(distance, 100.0, 0.0);
}
self.explosion_hurt_entities(
[0.0, 100.0, 0.0],
1.0,
"minecraft:explosion",
BlockInteraction::Destroy,
&[],
);
let mut remaining: Vec<_> = self
.dropped
.iter()
.map(|item| (item.stack.item.to_string(), item.stack.count, item.health))
.collect();
remaining.sort();
steps.push(serde_json::json!(remaining));
if !self.dropped.iter().any(|item| item.id == original) {
break;
}
}
output.push(serde_json::json!({"name": case.name, "steps": steps}));
}
std::fs::write(directory.join("rust.json"), serde_json::to_vec(&output)?)?;
Ok(())
}
}
/// `ServerExplosion.addOrAppendStack`: `ItemEntity.areMergable` (same item and
/// components, combined count within the item's max stack size), then
/// `ItemEntity.merge(.., 16)`.
@@ -1101,6 +1233,16 @@ fn blast_resistance(base: f32, cap: Option<f32>, wither_immune: bool) -> f32 {
mod tests {
use super::*;
#[test]
#[ignore = "requires prepared resources; tools/test_explosion_items_parity.py"]
fn explosion_items_parity_fixture() {
let directory = std::env::var_os("HORIZON_EXPLOSION_ITEMS_FIXTURE")
.expect("set HORIZON_EXPLOSION_ITEMS_FIXTURE to the fixture directory");
let mut game = crate::game::native_machines::tests::server_game();
game.run_explosion_items_fixture(std::path::Path::new(&directory))
.unwrap();
}
/// A dangerous wither skull's blast treats a block as at most 0.8 harder to
/// break, but a `#wither_immune` block keeps its own resistance and any
/// other blast is unchanged.
@@ -137,10 +137,14 @@ impl Game<'_> {
let name = self.res.facts().get(state).name.clone();
// LevelChunk.setBlockState creates the new block entity even when
// the falling entity has no supplied data.
if let Some(entity) = self.new_block_entity(&name) {
let key = (pos[0], pos[1], pos[2]);
let mut tag = horizon_client::save::encode_block_entity(key, &entity, &name);
let key = (pos[0], pos[1], pos[2]);
if !self.world.block_entities.stateful_entity(state.0)
&& let Some(entity) = self.new_block_entity_for_state(state)
{
self.world.block_entities.insert(key, entity);
}
if let Some(entity) = self.world.block_entities.get(&key) {
let mut tag = horizon_client::save::encode_block_entity(key, entity, &name);
// FallingBlockEntity.tick copies each supplied field over the
// new block entity's saved defaults before loadWithComponents.
if let Some(horizon_world::nbt::Tag::Compound(carried)) = block
+2 -1
View File
@@ -13,6 +13,7 @@ use horizon_client::{
mobs::{Damage, Species, Who},
particles::{FireworkExplosion, ParticleOptions, ParticleType},
projectiles::ProjectileWorld,
random_source,
text::Text,
};
use horizon_core::{
@@ -227,7 +228,7 @@ impl Game<'_> {
pub(super) fn new_rocket(&mut self, at: Vec3, stack: &ItemStack, attached: bool) -> Rocket {
let (flight, explosions) = fireworks_of(stack);
// FireworkRocketEntity: motion and lifetime come from the rocket's own random.
let r = &mut super::dispenser::entity_random();
let r = &mut random_source::entity();
let tri = |r: &mut horizon_core::random::LegacyRandom| r.triangle(0.0, 0.002297);
let velocity = Vec3::new(tri(r), 0.05, tri(r));
let lifetime = 10 * (1 + i32::from(flight))
@@ -0,0 +1,277 @@
//! `LevelChunk.registerTickContainerInLevel`: a chunk's block and fluid tick
//! containers are registered when it becomes a full chunk, before a command
//! can reach it. Water placed in a newly loaded chunk of a frozen level,
//! before the level's next world tick, keeps its flow tick
//! (tools/test_frozen_first_tick_water_parity.py measures the same on
//! vanilla 26.3: it flows 5 ticks after placement).
use super::*;
use horizon_client::chunk::Chunk;
use horizon_core::position::ChunkPos;
use std::sync::Arc;
fn game() -> Game<'static> {
let root = std::path::PathBuf::from(
std::env::var_os("HORIZON_TEST_RESOURCES")
.expect("set HORIZON_TEST_RESOURCES to prepared resources"),
);
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = execution_role::ExecutionRole::IntegratedServer;
let res: &'static _ = Box::leak(Box::new(loaded.resources));
let mut game = Game::new(
res,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.build_registries();
game.set_allow_commands(true);
game
}
/// The fluid ticks queued in chunk `key`, or None when the chunk has no
/// fluid tick container.
fn fluid_ticks(game: &Game<'_>, key: (i32, i32)) -> Option<usize> {
game.block_ticks
.fluids
.chunk(ChunkPos::new(key.0, key.1))
.map(|c| c.count())
}
/// A chunk that becomes resident is registered with both tick schedulers by
/// the time the next command runs, with no world tick in between.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn water_placed_before_the_first_world_tick_keeps_its_flow_tick() {
let mut game = game();
let key = (80, 80);
assert!(game.run_command("forceload add 1280 1280"));
// The streaming worker's chunk, made resident as `streaming.update` does.
game.world
.chunks
.insert(key, Arc::new(Chunk::empty(key.0, key.1)));
assert_eq!(fluid_ticks(&game, key), None, "not registered yet");
let time = game.world.game_time;
game.update_world();
assert!(
game.world.chunks.contains_key(&key),
"the chunk stays loaded"
);
assert_eq!(game.world.game_time, time, "no world tick ran");
assert!(game.run_command("setblock 1283 100 1283 stone"));
assert!(game.run_command("setblock 1283 101 1283 water"));
assert_eq!(
fluid_ticks(&game, key),
Some(1),
"the water's flow tick is queued"
);
assert!(
game.block_ticks
.blocks
.chunk(ChunkPos::new(key.0, key.1))
.is_some(),
"the block tick container too"
);
}
/// Registration takes a chunk's saved ticks with their saved delays (they
/// count down from the first world tick that unpacks them, as before), runs
/// no world tick and draws nothing from the level random, and a second
/// `update_world` registers nothing again.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn early_registration_keeps_saved_ticks_and_ticks_nothing() {
use horizon_core::random::RandomSource;
use horizon_core::ticks::{SavedTick, TickPriority, TickTypeId};
let mut game = game();
let key = (80, 80);
assert!(game.run_command("forceload add 1280 1280"));
let saved = SavedTick {
kind: TickTypeId(horizon_world::fluid::FluidKind::Water as u32),
pos: horizon_core::position::BlockPos::new(1285, 101, 1285),
delay: 3,
priority: TickPriority::Normal,
};
let mut chunk = Chunk::empty(key.0, key.1);
chunk.saved = Some(Arc::new(horizon_client::chunk::ChunkExtras {
fluid_ticks: vec![saved],
..Default::default()
}));
game.world.chunks.insert(key, Arc::new(chunk));
let time = game.world.game_time;
let random = game.world.random.clone().next_long();
game.update_world();
game.update_world();
assert_eq!(game.world.game_time, time, "no world tick ran");
assert_eq!(
game.world.random.clone().next_long(),
random,
"no level random draw"
);
let ticks = game
.block_ticks
.fluids
.chunk(horizon_core::position::ChunkPos::new(key.0, key.1))
.expect("registered");
assert_eq!(
ticks.pack(time),
vec![saved],
"the saved tick, its delay unchanged"
);
let extras = game.world.chunks[&key].saved.clone().unwrap();
assert!(
extras.fluid_ticks.is_empty(),
"the scheduler holds the saved tick now"
);
}
/// `ServerLevel.startTickingChunk` unpacks a chunk's saved ticks at the
/// game time it starts ticking (`unpackTicks(getGameTime())`; Horizon reads
/// it as `game_time - 1` inside the world tick). Registering the chunk
/// earlier, in `update_world`, leaves that unpack where it was: a saved block
/// tick with delay 2 and a saved fluid tick with delay 3, registered at game
/// time T, trigger at T - 1 + 2 and T - 1 + 3 once the chunk starts ticking.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn saved_block_and_fluid_ticks_unpack_when_the_chunk_starts_ticking() {
use horizon_core::ticks::{SavedTick, TickPriority, TickTypeId};
let mut game = game();
let key = (80, 80);
assert!(game.run_command("forceload add 1280 1280"));
let at = |y| horizon_core::position::BlockPos::new(1285, y, 1285);
let block = SavedTick {
kind: TickTypeId(1),
pos: at(100),
delay: 2,
priority: TickPriority::Normal,
};
let fluid = SavedTick {
kind: TickTypeId(horizon_world::fluid::FluidKind::Water as u32),
pos: at(101),
delay: 3,
priority: TickPriority::Normal,
};
let mut chunk = Chunk::empty(key.0, key.1);
chunk.saved = Some(Arc::new(horizon_client::chunk::ChunkExtras {
block_ticks: vec![block],
fluid_ticks: vec![fluid],
..Default::default()
}));
// `ticking_chunk`: a lit chunk whose 3x3 neighbours are loaded.
chunk.lit = true;
for dx in -1..=1 {
for dz in -1..=1 {
if (dx, dz) != (0, 0) {
let (x, z) = (key.0 + dx, key.1 + dz);
game.world
.chunks
.insert((x, z), Arc::new(Chunk::empty(x, z)));
}
}
}
game.world.chunks.insert(key, Arc::new(chunk));
game.world.game_time = 500;
game.update_world();
game.update_chunk_tickets();
assert!(
super::ticking_chunk(&game.world, key.0, key.1),
"a ticking chunk"
);
assert!(
game.tickets.levels().loaded_ticking(key.0, key.1),
"a ticking ticket level"
);
let pos = ChunkPos::new(key.0, key.1);
assert_eq!(
game.block_ticks.blocks.chunk(pos).unwrap().pack(500),
vec![block],
"waiting"
);
game.start_ticking_chunks();
let triggers = |ticks: &horizon_core::ticks::ChunkTicks| {
ticks
.queued()
.iter()
.map(|t| (t.pos, t.trigger_tick))
.collect::<Vec<_>>()
};
assert_eq!(
triggers(game.block_ticks.blocks.chunk(pos).unwrap()),
vec![(at(100), 501)]
);
assert_eq!(
triggers(game.block_ticks.fluids.chunk(pos).unwrap()),
vec![(at(101), 502)]
);
}
/// The unload route saves a chunk's queued ticks before its queue goes
/// (`with_entities` -> `attach_save_data`, then the removal), and the chunk
/// loaded back from that save gets them again. Registering twice, or a chunk
/// that left and came back before `update_world`, queues nothing twice.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn an_unloaded_chunk_saves_its_ticks_and_a_reloaded_one_gets_them_back() {
let mut game = game();
let key = (80, 80);
let pos = ChunkPos::new(key.0, key.1);
assert!(game.run_command("forceload add 1280 1280"));
game.world
.chunks
.insert(key, Arc::new(Chunk::empty(key.0, key.1)));
game.update_world();
assert!(game.run_command("setblock 1283 100 1283 stone"));
assert!(game.run_command("setblock 1283 101 1283 water"));
assert_eq!(fluid_ticks(&game, key), Some(1));
// Left and back before the next update_world: the same queue, once.
let resident = game.world.chunks.remove(&key).unwrap();
game.world.chunks.insert(key, resident);
game.update_world();
game.update_world();
assert_eq!(fluid_ticks(&game, key), Some(1), "no second registration");
// The save an unloaded chunk is written with.
let mut saved = (*game.world.chunks[&key]).clone();
game.attach_save_data(&mut saved);
let ticks = saved.saved.as_deref().unwrap().fluid_ticks.clone();
assert_eq!(ticks.len(), 1, "the water's tick goes into the save");
assert_eq!(
ticks[0].pos,
horizon_core::position::BlockPos::new(1283, 101, 1283)
);
assert_eq!(ticks[0].delay, 5, "saved relative to the save time");
// Unloaded: the queue goes with the chunk.
game.world.chunks.remove(&key);
game.update_world();
assert!(
game.block_ticks.fluids.chunk(pos).is_none(),
"dropped after the save"
);
// Loaded back from that save.
game.world.chunks.insert(key, Arc::new(saved));
game.update_world();
assert_eq!(
game.block_ticks
.fluids
.chunk(pos)
.unwrap()
.pack(game.world.game_time),
ticks
);
}
+5 -4
View File
@@ -12,7 +12,7 @@ use horizon_client::{
mesher::Vertex,
particles::{ParticleOptions, ParticleType},
pose::Pose,
sound_engine::unique_seed,
random_source,
};
use horizon_core::{
geometry::{Aabb, Vec3, traverse_blocks, trig},
@@ -453,7 +453,8 @@ impl Game<'_> {
eye_y,
p.position.z - f64::from(y_cos) * 0.3,
);
let mut random = LegacyRandom::new(unique_seed());
// The hook's own random (`Entity.random`).
let mut random = random_source::entity();
let v = cast_velocity(y_sin, y_cos, dir_y, &mut random);
self.next_entity += 1;
self.hook = Some(Hook {
@@ -1480,7 +1481,7 @@ impl Hook {
luck: 0,
lure: 0,
lerp: HookLerp::default(),
random: LegacyRandom::new(unique_seed()),
random: random_source::entity(),
}
}
/// `FishingHook`'s entity for the players tracking it: the owner's id in
@@ -1540,7 +1541,7 @@ impl Game<'_> {
state: State::Flying,
on_ground: false,
lerp: HookLerp::default(),
random: LegacyRandom::new(unique_seed()),
random: random_source::entity(),
});
}
}
+3
View File
@@ -127,6 +127,9 @@ impl Game<'_> {
}
/// `Camera.calculateFov` with `modifyFovBasedOnDeathOrFluid`.
pub(super) fn fov_degrees(&self, partial: f32, camera: [f64; 3]) -> f32 {
if self.studio_freecam.active {
return self.studio_freecam.fov.clamp(1.0, 179.0);
}
let fov = self.view.calculate_fov(
partial,
self.settings.fov as i32,
Loaded 100 of 389 files, more files were not shown because too many files have changed in this diff. Show more