Compare commits

..
Author SHA1 Message Date
rdbt ae0c0273f5 Merge pull request #15050 from DUzzL/fix/15047-portpack-placeholder
port-pack: accept a lone placeholder mod id template
2026-11-11 22:12:46 +00:00
rdbt ff0dbe7864 Merge pull request #15064 from ilguerro/fix/zombie-fire-aspect-rules
Zombies burn you only with a bare hand, on the difficulty's chance
2026-11-11 22:12:41 +00:00
rdbt e4042d5a2e Merge pull request #15066 from MSedgeMC/fix/reset-keys-active
The Controls Reset All button is inactive until a binding changes
2026-11-11 22:12:36 +00:00
rdbt 9fe0fd9cc7 Merge pull request #15073 from KBroneZ/claude/up-7843
A client plays with the server's block and item tags
2026-11-11 22:12:30 +00:00
rdbt 8ae679f4e1 Merge pull request #15105 from vnsmoreira/packs/s00-arrays
Core shaders: an array sized by its initializer only translates (#9386)
2026-11-11 22:12:24 +00:00
rdbt c493a84388 Merge pull request #15114 from angel2rider/docs/agents-map-lang-coverage
docs: the agents map says what lang_coverage.py --summary prints
2026-11-11 22:12:15 +00:00
rdbt d1255f85ab Merge pull request #15124 from MSedgeMC/fix/crafter-slot-state-mark
port: a crafter's slot state write marks the chunk
2026-11-11 22:12:10 +00:00
rdbt f176986623 Merge pull request #15129 from qaesen/fix-plugin-resource-packs-8570
FIX(hud): enable full compatibility for plugin resource packs (Nexo, ItemsAdder, BetterModel) (fixes #8570)
2026-11-11 22:12:05 +00:00
rdbt f0832b0998 Merge pull request #15145 from angel2rider/fix/14539-portal-cooldown
Entity.saveWithoutId writes the live PortalCooldown, not the loaded one
2026-11-11 22:11:59 +00:00
rdbt 55477b7c27 Merge pull request #15146 from MSedgeMC/fix/end-gateway-age-marks
port: an end gateway's ageing marks the chunk
2026-11-11 22:11:54 +00:00
rdbt 947c085c0f Merge pull request #15148 from MSedgeMC/fix/chat-delay-slider
The Chat Delay control is a working slider
2026-11-11 22:11:49 +00:00
rdbt 9b49d846f0 Merge pull request #15162 from MSedgeMC/fix/server-icon-1024
A server icon is kept up to 1024x1024, not only 64x64
2026-11-11 22:11:44 +00:00
rdbt e4822fe906 Merge pull request #15169 from MSedgeMC/fix/tooltip-attribute-stack
Tooltip attribute modifiers come from the stack
2026-11-11 22:08:49 +00:00
rdbt 24a2bf3900 Merge pull request #15184 from MSedgeMC/fix/region-damaged
Region: a chunk the file cannot deliver is absent, not an error
2026-11-11 22:08:45 +00:00
rdbt b2a2758162 Merge pull request #15187 from MSedgeMC/fix/chunk-default-pos
Chunk storage: a chunk without xPos/zPos is the chunk at 0, 0
2026-11-11 22:08:39 +00:00
rdbt 6757c5ed35 Merge pull request #15188 from MSedgeMC/fix/backdrop
A text backdrop frames the glyphs by one pixel
2026-11-11 22:08:35 +00:00
rdbt f57714de73 Merge pull request #15189 from angel2rider/fix/7899-ssbo-binary-init
Shader packs: a buffer object can start with the bytes of a file
2026-11-11 22:08:30 +00:00
rdbt 7b02095700 Merge pull request #15193 from DUzzL/fix/15180-enderman-place
Enderman: check carried block survival when placing; play stare sound from synced data
2026-11-11 22:08:25 +00:00
rdbt 78249e7c9e Merge pull request #15195 from MSedgeMC/fix/scrollcursor
The pack list's scrollbar shows its hover and drag cursor
2026-11-11 22:08:20 +00:00
rdbt 1e10e533cb Merge pull request #15219 from yahya-nusseibeh/fix/legacy-trim-preview-bounds
Bound legacy smithing-trim previews from old servers
2026-11-11 22:08:16 +00:00
rdbt a2d3ac8b64 Merge pull request #15226 from yahya-nusseibeh/fix/protocol-plain-text-cap
Protocol text: a disconnect reason can't expand past 1 MiB
2026-11-11 22:08:11 +00:00
rdbt 8e26a88f53 Merge pull request #15232 from yahya-nusseibeh/fix/supervisor-queued-work
Supervisor: apply queued restores and updates before every start
2026-11-11 22:08:07 +00:00
rdbt 6b704ba6f3 Merge pull request #15233 from yahya-nusseibeh/fix/server-toml-atomic
Server: write server.toml atomically under one lock, and keep the world if a restore fails
2026-11-11 22:08:02 +00:00
rdbt f8f2571c0b Merge pull request #15236 from yahya-nusseibeh/fix/launcher-accounts-atomic
Launcher: save accounts.json atomically and merge what a launch renewed
2026-11-11 22:07:57 +00:00
rdbt 9c08a53ebf Merge pull request #15242 from yahya-nusseibeh/fix/forged-log-lines
Server: players can't forge the game's log lines or flood the log
2026-11-11 22:07:53 +00:00
rdbt 083321a7fd Merge pull request #15251 from yahya-nusseibeh/fix/realms-download-files
Client: private Realms temp files, a bounded pax record, and no leaked boot-screen font
2026-11-11 22:07:45 +00:00
rdbt bbe8dfd73f Merge pull request #15254 from yahya-nusseibeh/fix/server-backup-settings-edges
Server: backup and settings edge cases
2026-11-11 22:07:41 +00:00
rdbt 0b4c9b627e Merge pull request #15353 from Akirabane/fix/login-query-payload-cap
Login: a custom-query payload over 1 MiB is refused, as vanilla refuses it
2026-11-11 22:07:36 +00:00
rdbt 1a42c8bf10 Merge pull request #15357 from Akirabane/fix/waypoint-icon-saved
A player's /waypoint modify icon is saved with the player, as vanilla saves it
2026-11-11 22:07:31 +00:00
rdbt b2ae481dee Merge pull request #15362 from DylanGonzaloFerreyra/split-play-hud
Move the boss bar, scoreboard and team packets into play/hud.rs
2026-11-11 22:07:26 +00:00
rdbt 6f3ec8b315 Merge pull request #15368 from AlfaRojo/fix/decorated-pot-normals
The decorated pot keeps its face normals and still culls
2026-11-11 22:07:21 +00:00
rdbt f7c43dd03b Merge pull request #15202 from loicpiller/feature/9432-realms-availability
Realms: the availability check vanilla makes before use (1/2)
2026-11-11 22:07:16 +00:00
rdbt 8ba548a33d Merge pull request #15205 from Akirabane/tooling/5879-water-e2e-cases
anticheat_e2e: prediction-water plays kelp, a waterfall, a changing current and waterlogged blocks
2026-11-11 22:07:11 +00:00
rdbt 07257993e6 Merge pull request #15214 from Akirabane/fix/12358-route-no-window
Finding the gateway on Windows opens no console window
2026-11-11 22:07:06 +00:00
rdbt bda383e91b Merge pull request #15271 from rdbtCVS/fix/far-chunk-gaps
Fix missing chunks and stalled retries beyond the server view distance
2026-11-11 22:07:01 +00:00
rdbt b78c1e633a Merge pull request #15348 from kacpherek/tooling/pr-ready-quick
pr_ready: run the harnesses only where CI's parity job would
2026-11-11 22:06:57 +00:00
rdbt 117db61043 Merge pull request #15422 from kacpherek/fix/14887-naga-subgroup-2d
Shader packs: allow subgroup ids in 2-D compute workgroups
2026-11-11 22:06:51 +00:00
rdbt b32ba7fa20 Merge pull request #15423 from kacpherek/fix/14886-naga-quad-swap
Shader packs: translate the subgroupQuadSwap functions
2026-11-11 22:06:46 +00:00
rdbt 95a330832d Merge pull request #15185 from MSedgeMC/fix/conversion-absorption
A converting mob keeps its golden hearts
2026-11-11 22:06:36 +00:00
rdbt eb0b785ec8 Merge pull request #15186 from MSedgeMC/port_map/zombie-drowning-rows
port map: the drowning conversion has rows
2026-11-11 22:06:31 +00:00
rdbt 0742928943 Merge pull request #15447 from kacpherek/fix/uk-ua-stale-key
main is red: a stale Ukrainian string and a sulfur cube harness group
2026-11-11 22:06:23 +00:00
Ludovic Marie 692a4d8059 Merge pull request #15413 from horizon-rust/server-tick-untimed-spans
🤖 Agent: timing spans around every untimed call of a level's pass (about 200 ms of the SMP's 250-330 ms overworld pass had none, #15349); spans read nothing while off, and no behaviour changes.
2026-11-11 22:42:03 +01:00
Ludovic Marie ceff58d796 Merge pull request #15409 from horizon-rust/fix/watchdog-server-stack
🤖 Agent: the watchdog now reads the stuck server thread's stack (a SIGURG handler installed only when a tick hangs, 5 s wait, else the report says why), so the next SMP freeze (#15349) names the function it was in; nothing changes until a tick hangs.
2026-11-11 22:41:59 +01:00
Kacpherek 31915393c1 command_exec: the sulfur cube size group shrinks its cubes before killing them
tests/test_command_exec_group_cleanup.py fails on main since #13823: the new 'sulfur cube size attributes' group kills its cubes after its last tick without shrinking them, so a size 2 cube would split inside the next group. Same shrink as the other sulfur cube groups; both commands are setup commands, so no record changes.
2026-11-11 22:32:22 +01:00
Kacpherek b36e25ab16 Ukrainian: drop the HDR peak hint en_us no longer has
en_us dropped "Your display's reported peak is often too high: calibrate to set it up." in 1c93067327; #14706 added its Ukrainian text back today, so lang::tests::bundled_files_are_served_and_have_no_stale_or_repeated_keys fails on main and on every pull request tested against it.
2026-11-11 22:32:10 +01:00
Kacper e86000f6eb Merge pull request #15309 from Akirabane/docs/port-map-game-003-fixed-gaps
port map: two rows of game-003.tsv no longer list gaps that are fixed
2026-11-11 22:29:58 +01:00
Kacper 1bd446f250 Merge pull request #15306 from Akirabane/docs/port-map-game2-001-fixed-gaps
port map: three rows of game2-001.tsv no longer list gaps that are fixed
2026-11-11 22:29:46 +01:00
Kacper 08be24761f Merge pull request #15223 from yahya-nusseibeh/fix/control-one-line-requests
horizon-server: a management kick reason can't inject control requests
2026-11-11 22:29:22 +01:00
Kacper ccbb5f85bc Merge pull request #15052 from DUzzL/fix/15048-rabbit-yaw
Rabbit: start the move control in MOVE_TO towards the origin like vanilla
2026-11-11 22:28:30 +01:00
Kacper e705c9175d Merge pull request #15036 from DUzzL/fix/15017-dispense-position
Fix dispense position float order to match vanilla
2026-11-11 22:28:11 +01:00
Kacper 2d6af5f5ea Merge pull request #14917 from Detimes/perf-block-event-queue
Perf: the block-event queue pops and dedupes in O(1)
2026-11-11 22:27:44 +01:00
Kacper 6cc36c6f3a Merge pull request #15229 from yahya-nusseibeh/fix/management-connection-cap
Cap management and API connections, and give a request one deadline
2026-11-11 22:20:45 +01:00
Kacper 60f0ab264d Merge pull request #15055 from ilguerro/fix/pot-comparator-signal
A comparator beside a decorated pot reads its item
2026-11-11 22:20:28 +01:00
Kacper 29f46b1320 Merge pull request #15045 from DUzzL/fix/14941-camel-lastposetick
Keep a camel's saved LastPoseTick an absolute game time while it is not ticking
2026-11-11 22:20:11 +01:00
Kacper 7a756c4b47 Merge pull request #14756 from ilguerro/perf/worldgen-noise
Worldgen: the range choice picks its values without a branch per block
2026-11-11 22:19:41 +01:00
Kacper b3e977c932 Merge pull request #15305 from Akirabane/fix/lan-keep-alive-pending-id
LAN: a keep-alive answer that is not the pending one disconnects the guest, as vanilla does
2026-11-11 22:18:55 +01:00
Kacper 7b3c73f790 Merge pull request #15298 from Akirabane/fix/lan-owner-name-taken
LAN: a login with the host's own name is refused while the host is online, as vanilla does
2026-11-11 22:18:38 +01:00
Kacper d740d99388 Merge pull request #15279 from Akirabane/fix/fov-walking-speed-from-server
The field of view uses the walking speed the server sends in the abilities packet
2026-11-11 22:18:21 +01:00
Kacper 915337910a Merge pull request #15130 from vnsmoreira/packs/polytone-limit-join
Polytone: a type's limit and exclusion_radius count only particles in the world
2026-11-11 22:18:04 +01:00
Kacper cf1f44ac13 Merge pull request #15035 from KBroneZ/claude/up-8688
Preview the anvil with a server's data-pack enchantments
2026-11-11 22:17:46 +01:00
Kacper 8ea9f4a20f Merge pull request #14944 from Detimes/perf-held-item
Perf: borrow the held item in tick_guests instead of cloning the stack
2026-11-11 22:17:19 +01:00
Kacper 94233f3ce1 Merge pull request #14882 from vermingov/fix/fire-short-14879
A mob and an armor stand narrow a loaded Fire to a short (#14879)
2026-11-11 22:16:23 +01:00
Kacper 8778015009 Merge pull request #14877 from vermingov/fix/mob-hurt-time-short-14876
A mob narrows a loaded HurtTime and DeathTime to a short (#14876)
2026-11-11 22:16:06 +01:00
Kacper 114e8cad52 Merge pull request #14787 from DUzzL/fix/14754-leash-knot-nosave
Skip leash knots when saving chunk entities
2026-11-11 22:15:49 +01:00
Kacper c63152903d Merge pull request #14777 from foudretbr/fix/drowned-husk-conversion-and-cure-timer
fix(mobs): align drowned and husk conversion ticks and cure random with vanilla (#14770)
2026-11-11 22:15:32 +01:00
Kacper 35b1b3358d Merge pull request #14686 from Mayonesu0/up-14542-armor-stand-first-tick-attrs
A loaded armor stand saves the movement_speed its first tick makes
2026-11-11 22:15:15 +01:00
Kacper 33859e433d Merge pull request #14636 from NikitaMGrimm/parity/ominous-spawner-loaded-rotation
An ominous item spawner keeps the rotation, OnGround and fall_distance it was loaded with
2026-11-11 22:14:58 +01:00
Kacper b5bba8a620 Merge pull request #14591 from DUzzL/fix/14465-item-frame-facing
Wrap item frame and painting facing bytes like vanilla's Direction codecs
2026-11-11 22:14:41 +01:00
Kacper ca7044d165 Merge pull request #14560 from Ragbie/refactor/entity-nbt-orb
Move the experience orb NBT out of entity_nbt.rs
2026-11-11 22:14:24 +01:00
Kacper 93beed5df7 Merge pull request #14183 from DUzzL/fix/14157-sign-four-lines
Load a sign side empty unless messages has exactly 4 lines
2026-11-11 22:14:06 +01:00
Kacper 7c61110eee Merge pull request #14162 from RLC248/fix/mace-smash-motion-at-hit
A mace smash on a player sends the attacker's motion after the hit
2026-11-11 22:13:49 +01:00
Kacper edd751003e Merge pull request #14147 from MSedgeMC/fix/sheep-colour-codec
An unknown saved sheep colour reads white
2026-11-11 22:13:32 +01:00
Kacper 571e833b48 Merge pull request #13823 from NikitaMGrimm/parity/sulfur-cube-bucket-health
A sulfur cube saves its size's max_health and keeps its size's speed after a reload
2026-11-11 22:13:15 +01:00
Kacper c8e1eb6619 Merge pull request #15286 from GIGABAIT93/ci/prepare-lock-link
CI vanilla data: horizon-prepare's lock file is no prepared output
2026-11-11 21:48:30 +01:00
Kacper 31578f0509 Merge pull request #15208 from KBroneZ/claude/up-15164
Random tick chunks in position order, not chunk load order
2026-11-11 21:48:18 +01:00
Kacper 12ddb73e82 Merge pull request #15172 from angel2rider/tools/tools-a-flag-is-not-a-directory-or-a-folder
tools: a flag is not a directory or a folder
2026-11-11 21:48:10 +01:00
Kacper d0f5bccf60 Merge pull request #13737 from RINNECODER/up10/boat-table-trig
Boats: Mth's sine table for steering, dismounts and minecart pushes; lastYd for water entry
2026-11-11 21:42:00 +01:00
Kacper a5df8273bb Merge pull request #15358 from ilguerro/fix/spider-hard-effect
Spiders spawned at runtime get their Hard-difficulty effect
2026-11-11 21:34:16 +01:00
Kacper a0bede5135 Merge pull request #15297 from bct8925/adreno-vulkan
Adreno on Vulkan: pack the shaders' indexed tables, and test occlusion in the shader
2026-11-11 21:34:08 +01:00
Kacper 470578dd2e Merge pull request #15163 from MSedgeMC/fix/bundle-tooltip-lines
An empty bundle's tooltip is as tall as its description
2026-11-11 21:34:00 +01:00
Kacper a5fce8fada Merge pull request #15153 from tarzinio/fix-edit-queue-cap
Cap the occlusion edit queue when cave culling is off
2026-11-11 21:33:53 +01:00
Kacper f9a645f1ec Merge pull request #15125 from MSedgeMC/fix/potion-douse-source
port: a doused campfire's block_change carries the potion's owner
2026-11-11 21:33:45 +01:00
Kacper a93fe71b74 Merge pull request #15118 from MSedgeMC/ledger/rows-3
port_map: SelectWorldScreen's button states are all modelled
2026-11-11 21:33:35 +01:00
Kacper 203baeb0bd Merge pull request #15107 from angel2rider/tools/velocity-probe-protocol
tools: the velocity probe reads the protocol it announces, instead of writing 777 by hand
2026-11-11 21:33:26 +01:00
Kacper 307029382a Merge pull request #15106 from vnsmoreira/packs/s00-vertex-index
Core shaders: a pack's gl_VertexIndex reads the glue's vertex number
2026-11-11 21:33:19 +01:00
Kacper 098012518a Merge pull request #15077 from ilguerro/fix/zombie-powder-snow-sunburn
Keep a zombie in powder snow from catching fire in daylight
2026-11-11 21:33:11 +01:00
Kacper a21d37c81d Merge pull request #15028 from sunrise-org/dims/render-facts
Sky, shading and ambient light follow the dimension type (#11282, 7/9)
2026-11-11 21:33:03 +01:00
Kacper 2c1584e88c Merge pull request #14708 from Akirabane/structure-mobs-preload-13857
Server: the mob data generation finalizes with is read when the world opens
2026-11-11 21:32:32 +01:00
Kacper c7d7576e77 Merge pull request #14706 from GIGABAIT93/l10n/uk-nightly-1110
Ukrainian: the 96 strings uk_ua.json lacked (radial menu, HDR, fuel)
2026-11-11 21:32:25 +01:00
Kacper 37bee174e9 Merge pull request #14406 from rdbtCVS/perf/heightmap-columns
Heightmaps: a write marks its column, found again alone on its next read (split of #13644, part 1)
2026-11-11 21:31:24 +01:00
Kacper 34afc9aced Merge pull request #14389 from vnsmoreira/fix/comparator-setblock-output
A comparator placed with OutputSignal by /setblock keeps it
2026-11-11 21:31:14 +01:00
Kacper 1f31436c29 Merge pull request #14345 from MSedgeMC/fix/command-block-edit-mark
Editing a command block marks its chunk
2026-11-11 21:31:05 +01:00
Kacper 99d9a0b931 Merge pull request #14022 from MSedgeMC/fix/weapon-disable-blocking-component
Disable-blocking seconds come from a stack's weapon component
2026-11-11 21:30:09 +01:00
Kacper 3eeeb544f0 Merge pull request #13836 from NikitaMGrimm/projectile-uuid-draws
A projectile draws its UUID and its shot from its own random, as vanilla does
2026-11-11 21:30:02 +01:00
Kacper d5157fe7d6 Merge pull request #13832 from RINNECODER/up13/vehicle-below-world
Boats and minecarts more than 64 blocks under the world are discarded
2026-11-11 21:29:54 +01:00
Kacpherek ba299deb2d Shader packs: translate subgroupQuadSwap Horizontal, Vertical and Diagonal
naga's GLSL frontend did not know the GL_KHR_shader_subgroup_quad swaps, so any program using
them (FidelityFX SPD in several packs) failed with Unknown function. They map to naga's QuadSwap gather.
2026-11-11 21:00:56 +01:00
Kacpherek dfd289adfc Shader packs: allow subgroup ids in 2-D compute workgroups
naga rejected gl_SubgroupID and gl_SubgroupInvocationID when the workgroup had more than one
dimension, a WGSL rule GLSL does not have, so pack compute passes with a 2-D workgroup were skipped.
2026-11-11 20:58:47 +01:00
Kacpherek 4e03ad84b9 Merge main into pr-15055
# Conflicts:
#	crates/horizon-client/src/game/machines.rs
2026-11-11 20:53:28 +01:00
Kacpherek 969effa7e0 Merge main into fix/far-chunk-gaps 2026-11-11 20:45:19 +01:00
Kacpherek 258ff26bd6 Merge main into fix/15048-rabbit-yaw 2026-11-11 20:43:58 +01:00
Kacpherek f85370184c Merge main into fix/15047-portpack-placeholder 2026-11-11 20:43:50 +01:00
Kacpherek b58389b53c Merge main into fix/14941-camel-lastposetick 2026-11-11 20:43:43 +01:00
Kacpherek bd20630159 Merge main into fix/15017-dispense-position 2026-11-11 20:43:28 +01:00
yahya-nusseibeh a0118a4bfc Merge upstream main into fix/server-backup-settings-edges 2026-11-11 21:33:01 +02:00
yahya-nusseibeh 61bd1fcfe4 Merge upstream main into fix/realms-download-files 2026-11-11 21:32:58 +02:00
yahya-nusseibeh d77c870a98 Merge upstream main into fix/forged-log-lines 2026-11-11 21:32:47 +02:00
yahya-nusseibeh 3fdf92d7f4 Merge upstream main into fix/launcher-accounts-atomic 2026-11-11 21:32:35 +02:00
yahya-nusseibeh 1d37d845bd Merge upstream main into fix/server-toml-atomic 2026-11-11 21:32:32 +02:00
yahya-nusseibeh 9df2ce4dc1 Merge upstream main into fix/management-connection-cap 2026-11-11 21:32:23 +02:00
yahya-nusseibeh 6f509879fd Merge upstream main into fix/protocol-plain-text-cap 2026-11-11 21:32:21 +02:00
yahya-nusseibeh 8246b4ac62 Merge upstream main into fix/control-one-line-requests 2026-11-11 21:32:18 +02:00
yahya-nusseibeh 1fbb508e06 Merge upstream main into fix/legacy-trim-preview-bounds 2026-11-11 21:32:10 +02:00
DetimesandClaude Opus 5.5 e317cb70da Merge upstream/main into perf-block-event-queue
Resolves the conflict with main so verify and parity can run again.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 19:09:44 +00:00
yahya-nusseibeh 8bbb380e2a Merge remote-tracking branch 'upstream/main' into fix/supervisor-queued-work 2026-11-11 21:09:28 +02:00
yahya-nusseibehandClaude Opus 5.5 3ffd798109 Keep switch()'s doc comment above switch
The queued-work change split it so apply_queued took the first half.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 21:08:35 +02:00
Horizon agentandClaude Opus 5.5 99e14c6d02 Name the untimed parts of a server level's pass in tick spans
The official SMP's slow-tick lines spend ~200 ms of a 300 ms overworld
pass outside every named span. Spans now wrap each untimed call of the
level loop (enter_level, the mods' level hooks, streaming, update_world),
of tick/tick_world and of update_world, and level_tick wraps the world
tick, so the line names where that time goes.

Refs #15349

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 20:02:43 +01:00
Horizon agentandClaude Opus 5.5 18ceb494e9 Server watchdog: put the server thread's stack in the crash report
ServerWatchdog's report only had the last 50 log lines, so a tick stuck
for 60 s on the official server (#15349) never said where. The watchdog now
interrupts the server thread with a signal (Linux, Android, Apple) whose
handler captures that thread's backtrace, and the report carries it as its
stack, as vanilla's report carries the server thread's stack. A thread that
does not answer in 5 s, or a system without the signal, is named in the
report's details instead.

Refs #15349

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 19:35:19 +01:00
Vinícius Moreira 5a52b1c13c Merge commit 'a85af177a8' into packs/polytone-limit-join 2026-11-11 15:27:30 -03:00
Kevin RamirezandClaude Opus 5.5 b745f39a11 The decorated pot keeps its face normals and still culls
#11899 left the shade of a world block entity to the entity shader,
which reads Vertex::normal, but the decorated pot's arm still
overwrote every normal with CULL_BACK (a baked-colour quad) to cull
the back faces of its flat top and bottom: the pot had neither a shade
nor a normal. mesher::cull_back keeps a shaded quad's normal with
SHADED_CULL_W (shade and cull, as the bat does) and gives CULL_BACK to
a baked one. The harness accepts SHADED_CULL_W as a shaded normal.

Fixes #15322

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 12:26:40 -06:00
AkirabaneandClaude Sonnet 5.5 871dc11a41 A player's /waypoint modify icon is saved as locator_bar_icon, as vanilla does
Waypoint.Icon is written to the player's tag when it has data (a style
other than the default, or a colour) and read back when nothing is held
in memory. A tag without the key gives the default icon.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 18:57:42 +01:00
AkirabaneandClaude Sonnet 5.5 654cefca28 Login custom queries: refuse a payload over 1048576 bytes, as vanilla does
ClientboundCustomQueryPacket and ServerboundCustomQueryAnswerPacket read what is
left of the packet as the payload and throw "Payload may not be larger than
1048576 bytes" past MAX_PAYLOAD_SIZE. Horizon read it with Rest, of any size.
RestN<N> is Rest with that limit; both packets use it.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 18:45:01 +01:00
Dylan FerreyraandClaude Opus 5.5 4b430232ed Move the boss bar, scoreboard and team packets into play/hud.rs
BossAction, BossEvent, NumberFormat, SetDisplayObjective, ResetScore,
SetScore, SetObjective, TeamParameters and SetPlayerTeam, with their
codecs and the boss bar's two flag helpers, move byte for byte into
packets/play/hud.rs. play.rs re-exports the nine public items, so every
`packets::play::…` path still resolves.

The port map follows: the two rest-012 rows that name boss_flags and
SetPlayerTeam::write point at the new file.

Part of #13063.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 14:43:17 -03:00
Kacpherek deb0b34fcb pr_ready: run the harnesses only where CI's parity job would
CI leaves most pull requests' harnesses to parity-main and runs them on the pull request only when it changes a harness, an oracle, a probe or the runner (parity-scope in ci.yml). pr_ready ran every affected harness locally whenever the vanilla data was there: about 80 harness-minutes per pull request by the repository's own numbers. It now follows CI's scope and lists the others; --harnesses runs them anyway.
2026-11-11 18:16:52 +01:00
AkirabaneandClaude Sonnet 5.5 67063cfdcc port_map: the two death-message rows of game-003.tsv no longer list gaps
CombatTracker.getMessageForAssistedFall and DamageSource.getLocalizedDeathMessage
are ported with their .item forms; the rows now say what the code does.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 16:03:27 +01:00
KBroneZandClaude Opus 5.5 48f231af5d test: pin that random-tick chunks come out in position order
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 17:02:18 +02:00
AkirabaneandClaude Sonnet 5.5 60c4d1d4f4 port_map: three rows of game2-001.tsv no longer list gaps
Wolf.getLeashOffset, ArmorStand.brokenByAnything and HangingEntity.canCoexist
are ported as vanilla's bytecode reads; the rows now say what the code does.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 16:00:58 +01:00
yahya-nusseibehandClaude Opus 5.5 83a152c98a Keep this change apart from the other open fixes' lines
Moves a new test (or a doc comment) so the branch merges cleanly with the other open pull
requests that touch the same file, in any order.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 16:46:08 +02:00
bct8925andClaude Opus 5.5 a09d036f50 Adreno on Vulkan: pack the shaders' indexed tables, and test occlusion in the shader
Qualcomm's Adreno Vulkan driver (Adreno 740, build 0676.41 of 2023-08-14) fails to link a
shader that indexes a constant array by vertex or pixel, which naga makes a private array, and
wgpu reports the failed pipeline as a lost device: the renderer never starts. The occlusion
test's box corners (cull.wgsl, gpu_occlusion.wgsl), the panorama cube (panorama.wgsl) and the
fading tiles' Bayer matrix (terrain.wgsl) are packed into u32 words read through a switch
instead, with tests that the packed tables are the old ones.

With the early depth test, occlusion culling on that driver now and then left sections out for
a frame (the world flashing black or see-through as a block broke); Adreno on Vulkan tests the
boxes in the shader, as Metal and DirectX 12 do.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 09:39:27 -05:00
yahya-nusseibehandClaude Opus 5.5 482c381f6b Client: private Realms temp files, a bounded pax record, and no leaked boot-screen font
- Realms downloads and uploads used predictable names in the shared temp folder and
  File::create, which follows a planted link or reuses a planted file. They now use an
  unguessable name and a new owner-only file (create_new, 0600).
- A pax record whose length was shorter than its own prefix ("1 x") sliced backwards and
  panicked the Realms download worker; it is now refused.
- The Windows boot-screen painter restored its fonts in the wrong order, leaving one selected so
  DeleteObject failed: one HFONT leaked per repaint. It now restores the DC's own font.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 16:31:51 +02:00
GIGABAIT93andClaude Opus 5.5 95ff1c5769 CI vanilla data: horizon-prepare's lock file is no prepared output
vanilla-data.sh moves whatever a preparation adds to .local/ into
shared-local/prepared/<key> and links it back. Since the preparer locks
.local/ (.horizon-prepare.lock), that took the lock file too: .local/'s
lock became a link into one preparation. Once that preparation is
pruned (four kept, two runners share them) the link dangles, the
`.local/*` glob that drops old links skips dotfiles, and the next
preparation fails: ".local/.horizon-prepare.lock: No such file or
directory".

The lock now stays a plain file: it is left out of the entries, a link
to it is removed, and old .entries that list it are skipped.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 16:25:46 +02:00
yahya-nusseibehandClaude Opus 5.5 f46f2dbc98 Rate-limit refused quiet commands without a new LanHost field
game/lan.rs is over 3000 lines and may not grow (docs/layers.md#file-size): a refused payload is
now logged at most once a second, kept in lan_quiet.rs, instead of once per connection through a
LanHost field.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 16:06:08 +02:00
982c71f54d Spiders spawned at runtime get their Hard-difficulty effect
Spider.finalizeSpawn (Spider.java:160-172) rolls
nextFloat() < 0.1F * getSpecialMultiplier() on HARD, then nextInt(5) for
Speed, Strength, Regeneration or Invisibility, and applies it for good
(MobEffectInstance(effect, -1)), for every spawn reason. Only the chunk
generation port did that; the runtime finalizer (summon, spawn eggs,
spawners, natural spawning) had no spider arm. Add one, share the
setRandomEffect mapping (Spider.java:207-217) between both paths, and pin
the odds, duration, effect set and the CaveSpider exception with a test.

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 14:47:10 +01:00
AkirabaneandClaude Sonnet 5.5 af65ddae12 LAN: only the pending keep-alive answer counts
ServerCommonPacketListenerImpl.handleKeepAlive accepts an answer whose id is
the pending challenge; any other answer disconnects the player with
disconnect.timeout unless they are the singleplayer owner.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 14:38:56 +01:00
rdbtCVSandCodex gpt-6.1-sol 23d4652362 Fix gaps and stalled retries in saved far-chunk sending
Send lit resident far columns with complete light layers, scan past blocked entries within the existing budget, and retain missing chunks for retry without player movement.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-11 13:33:14 +00:00
AkirabaneandClaude Sonnet 5.5 765ff1ed80 LAN: a login with the owner's name is refused while the owner is online
IntegratedPlayerList.canPlayerLogin returns multiplayer.disconnect.name_taken
when the joining name is the singleplayer owner's (ignoring case) and a player
of that name is online.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 14:31:35 +01:00
AkirabaneandClaude Opus 5.5 6b2d1cd585 Field of view: divide by the walking speed of the server's abilities packet
ClientPacketListener.handlePlayerAbilities sets the local player's walking speed from the packet
(Abilities.setWalkingSpeed), and AbstractClientPlayer.getFieldOfViewModifier divides the movement
speed by it. Horizon kept the packet but always divided by 0.1, so a server that sends another
walking speed got a different field of view (walking speed 0.2 with a movement speed of 0.2: 1.5
instead of 1.0).

The abilities code moves out of game/remote.rs, a big file, into game/remote_abilities.rs, with the
walking speed the field of view now reads (default 0.1 before any packet) and its test.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 14:28:04 +01:00
rdbtCVSandGPT 6.1 Sol dc35fac460 Fix weapon component formatting and extend regression coverage
Cover prototype inheritance, zero-default replacement, custom weapons, empty stacks, and persisted component removal without changing the contributor's gameplay fix.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-11 13:05:59 +00:00
AkirabaneandClaude Sonnet 5.5 f7224c29ee Finding the gateway on Windows opens no console window
`portmap::default_gateway` runs `route print` on Windows. A program with no
console of its own, such as the launcher, got a console window for it each
time. The command now starts with CREATE_NO_WINDOW, as the other crates'
commands do.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 13:42:12 +01:00
yahya-nusseibehandClaude Opus 5.5 978f37d092 Server: backup and settings edge cases
- Backup zips (which hold server.toml and server.properties with the API token, RCON password
  and management secret) are created owner-only.
- Pruning automatic backups deletes only `<stamp>-auto.zip`, not a backup labelled by hand
  "nightly-auto".
- backups.every no longer panics on a wide last character ("6é") or overflows on a huge number.
- A Discord rate-limit header of "NaN" no longer ends the Discord thread.
- world.name may not be ".", nor backups, logs or .horizon, which pointed the world at the server
  folder or the supervisor's own folders.
- init stops instead of replacing a whitelist.json or ops.json it could not read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 14:39:48 +02:00
KBroneZandClaude Opus 5.5 87d97ec860 test: keep the random tick order test beside block_ticks (game.rs may not grow)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 14:27:42 +02:00
KBroneZandClaude Opus 5.5 7117330d80 fix: random tick chunks in position order, not chunk load order
tick_blocks built the random-tick list from the world map's keys, an
FxHashMap filled as the streaming workers finish, so two runs of one world
random-ticked their chunks in different orders and the level random's draws
landed on different blocks. Game::random_tick_chunks now sorts the list by
chunk position. Vanilla walks its simulation tracker's fastutil table, whose
slot order follows that table's history; position order is deterministic.

Fixes #15164

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 14:19:06 +02:00
AkirabaneandClaude Opus 5.5 77303a4d58 anticheat_e2e: prediction-water plays kelp, a waterfall, a changing current and waterlogged blocks
#5879 lists these as the unknown blocks around the water flags of the official server. water_play now
builds each one beside the player (the server only fills loaded chunks), checks through the client that
the blocks are there as built, and plays in them: sprint-swimming down and up a kelp forest and holding
jump at its surface; jump held at a waterfall's foot, walking into it across the water it spreads,
sinking down it from the top, and standing at its foot while it stops and starts again; standing and
walking in a channel whose source goes and comes back; standing on waterlogged slabs, walking into
waterlogged fences and up waterlogged stairs.

The check's client renders offscreen when HORIZON_E2E_HEADLESS is set, as agent_control_e2e.py does,
so a run on a desktop opens no window that takes the focus.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 13:17:06 +01:00
yahya-nusseibehandClaude Opus 5.5 7da7209026 Server: players can't forge the game's log lines or flood the log
- The supervisor matched "Stopping server" and "Done (" anywhere in a stdout line, so chat
  saying "/INFO]: Stopping server" told management clients the server was stopping and hid the
  real notice. It now looks at the message after the first "/INFO]: " only.
- A quiet-commands payload's mod id went unescaped into the dedicated server's stdout and log:
  a newline in it forged whole lines. decode now refuses control characters, DEL and the
  section sign in the mod id and every line, as a chat command must pass.
- An empty batch (3 bytes) was queued and logged as a summary line, before any permission check,
  hundreds of times a tick; it is now ignored, a batch with no room is counted on the player's
  last waiting one, and a refused payload is logged once per connection.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 14:14:04 +02:00
rdbtCVSandGPT 6.1 Sol 4cb7f5a348 Merge main into vehicle-below-world, preserving the moved cart lookup
Keep main's minecart index/display extraction and expose cart_at to the below-world discard helper. Vehicle discard behavior is unchanged.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-11 11:53:09 +00:00
yahya-nusseibehandClaude Opus 5.5 ccf20df11d Launcher: save accounts.json atomically and merge what a launch renewed
Play loads accounts.json, asks the services about each account (several requests, 20 s
timeouts), then saved that whole old copy without the lock: a sign-in finished meanwhile was
dropped (its secrets orphaned in the keychain) and a sign-out came back. save_to also wrote in
place, so a crash or full disk mid-write left a file read as no accounts.

save_to now writes a new file and renames it over the old one, and the launch paths save with
save_renewed: under the accounts' lock, the renewed names and skins go into accounts.json as it
is now, keeping accounts added or removed since.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 13:47:42 +02:00
Loïc PILLERandClaude Fable 5.2 bbdbf1a285 Realms: the availability check vanilla makes before use (#9432)
RealmsAvailability: GET mco/client/compatible, then GET mco/available for
a compatible client, once per account and shared by every caller. Nothing
consumes it yet: the Realms screen's gate and its refusal screens follow.

- realms/client.rs: client_compatible, has_parental_consent, and
  RealmsError::message (vanilla's errorMessage, split out of text()).
- realms/availability.rs: Availability, Pending, AvailabilityCache, get,
  current. Non-blocking; only an unexpected error is checked again.
- docs/realms-availability.md: how a screen consumes it (#14986).
- realms.rs: the module line, and a stale ResetKind comment made one line
  so the file does not grow.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-11 12:39:13 +01:00
yahya-nusseibehandClaude Opus 5.5 06d1d236bb Server: write server.toml atomically under one lock, and keep the world if a restore fails
- server.toml was read, changed and written in place with no lock by management clients, the
  API, the CLI and sync_back: two overlapping writes dropped one change, and a crash after the
  truncate left an empty file, which reads as every default and was then written into
  server.properties. Writers in the process now hold one lock, and server.toml and
  server.properties are written to a temporary file renamed over the old one.
- A whole-world restore whose last rename failed returned with the world moved aside and no
  world in its place, and the game then made a new one. The old world is now moved back.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 13:34:33 +02:00
yahya-nusseibehandClaude Opus 5.5 c8370400c6 Supervisor: apply queued restores and updates before every start
- A restore or update asked for while the game was stopped (or while it was already
  stopping) was only consumed on Exit::Restart, so the game started on the current world and
  the request waited for some later, unrelated restart, and a stuck update kept every later
  one refused. Both are now applied at the head of the loop, before every start.
- An update timeout is clamped to a day (API, supervisor) and refused past it by the CLI:
  Instant + a huge Duration panicked the supervisor after it had switched releases.
- A repeated stop from the API or the management protocol no longer kills the game while it
  saves; only a second signal from a person does, as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 13:31:24 +02:00
yahya-nusseibehandClaude Opus 5.5 289aba5aa5 Cap management and API connections, and give a request one deadline
The management server spawned a thread for every connection with no cap, and read the
upgrade request before authentication with only a 10 s timeout per read, so a byte every few
seconds held a connection forever, and a 1 MiB Content-Length was allocated up front. An
unauthenticated client could open thousands.

http::read_request now has one deadline for the whole request (REQUEST_DEADLINE, 10 s), grows
the body as it arrives, and both the management listener and http::serve hold at most
MAX_OPEN (64) connections at once through a Slot given back on drop; past it a new connection
is closed unanswered.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 13:22:14 +02:00
MSedgeMC aa8769a5ac port_map: AbstractScrollArea.extractScrollbar's cursor requests 2026-11-11 11:17:57 +00:00
MSedgeMC 84c2f81616 test: the pack scrollbar asks for the hand, then the resize cursor
Over the bar the cursor is PointingHand; with the bar held it is ResizeNs.
2026-11-11 11:17:57 +00:00
MSedgeMC 90562aede1 fix: the pack list's scrollbar asks for its cursor
AbstractScrollArea.extractScrollbar requests POINTING_HAND over a scrollable
bar and RESIZE_NS while it is held. The pack screen drew its scroller without
asking, so the pointer stayed an arrow over both lists. The draw now requests
the hand or the resize cursor from the held state it already tracks.
2026-11-11 11:17:37 +00:00
DUzzL 54ef9e095a Enderman: require the carried block to survive when placing it; play the stare sound from synced data 2026-11-11 12:02:27 +01:00
MSedgeMC f852a2d582 port_map: the drowning conversion has rows
`Zombie.isUnderWaterConverting` and the `ConversionTracker` it drives had no
row, nor did the two husk overrides that send the conversion to a zombie; the
behaviour is `undead.rs::custom_server_ai_step`, and the row cites it for
each method the jar declares there.

Names read from `javap -p` on 26.3: `Zombie.isUnderWaterConverting`,
`Zombie.convertsInWater`, `Zombie.convertsToWhenDrowning`,
`Zombie.getConversionSound`, `ConversionTracker.tick`, `Husk.convertsInWater`,
`Husk.convertsToWhenDrowning`, `Husk.getConversionSound` and
`ZombieVillager.convertsInWater` (false in the jar, so it is not part of the
arm).

`map-check`: 165 rows, no problems.
2026-11-11 10:44:44 +00:00
MSedgeMC 7f625eeb61 test: a chunk without a position loads at the origin
Without the change `ChunkData::new` answers
`Err(InvalidData, "chunk is missing xPos")` for the first case.
2026-11-11 10:44:35 +00:00
MSedgeMC bc01321fa4 fix: a chunk without xPos/zPos is the chunk at 0, 0
`SerializableChunkData.parse` builds the position with `getIntOr("xPos", 0)`
and `getIntOr("zPos", 0)`, so a chunk that does not carry either tag is the
chunk at 0, 0 and `Tag.asInt` accepts the narrower integer tags as well as
`Int`. `ChunkData::new` refused the whole chunk, so an edited or hand-written
chunk without them could not be opened at all.
2026-11-11 10:44:35 +00:00
MSedgeMC 4c68279066 port_map: Font.PreparedTextBuilder.markBackground's glyph box 2026-11-11 10:35:27 +00:00
MSedgeMC 424af5fc17 test: the backdrop frames the glyphs by one pixel
The fill is (x - 1, y - 1) to (x + w, y + 9): one above and left, the advance
right, nine down.
2026-11-11 10:35:27 +00:00
TheGTandOpenCode 7d2ecff35c Shader packs: a buffer object can start with the bytes of a file
The documented `bufferObject.<n> = <size> <file>` form was refused:
the loader read the value as a size and nothing else, so a pack with
`bufferObject.2 = 16 data/seed.bin` lost its buffer with the note
"not a buffer object".

The loader now reads that form: the file's bytes come from the pack,
relative to `shaders`, and the buffer starts with them. A file that is
not there, that leaves the pack, or that is bigger than the buffer it
fills is refused with a note, as the other pack properties are.

Co-Authored-By: OpenCode <[email protected]>
2026-11-11 10:35:09 +00:00
MSedgeMC e4920df1c1 fix: a text backdrop frames the glyphs by one pixel
Font.PreparedTextBuilder.markBackground reaches one pixel left of the first
glyph and one above it, the glyph advance to the right and nine pixels down.
Horizon drew two pixels on every side (right and bottom included), so every
HUD title, action bar, boss bar and selected-item backdrop was a couple of
pixels too large. The fill now matches the glyph box.
2026-11-11 10:35:07 +00:00
MSedgeMC 47312851b0 test: a damaged or cut-off chunk reads as absent
Without the change the four length cases fail with `Err(InvalidData, "invalid
chunk payload length")` and the cut-off case with `Err(UnexpectedEof, "failed
to fill whole buffer")`.
2026-11-11 10:25:19 +00:00
MSedgeMC f48c714adc fix: a chunk the region file cannot deliver is absent, not an error
`RegionFile.getChunkDataInputStream` answers `null` whenever the file cannot
hand over the chunk: a header cut off by the end of the file ("Chunk header is
truncated"), a declared length of 0 ("allocated, but stream is missing"), a
length past the chunk's sectors ("stream is truncated") or a payload shorter
than its own header. Horizon returned an `Err` for all four, so a reader that
walks a region gave up on the whole file instead of skipping the chunk.
2026-11-11 10:25:19 +00:00
MSedgeMC 6be3db13d7 port_map: the convertCommon row says which path copies absorption
The gap note said absorption is never copied; the SINGLE path now does.
2026-11-11 10:25:11 +00:00
MSedgeMC e3e8b4c973 test: a zombie's absorption crosses into its drowned
A zombie at 3.0 absorption converts through `single` into a drowned: the new
mob reads 3.0. Without the copy the new mob has 0.0, the effect having been
copied but not the field the effect filled.
2026-11-11 10:25:11 +00:00
MSedgeMC f3946c6bbb fix: a converting mob keeps its absorption
`ConversionType.convertCommon` copies the effects, then
`to.setAbsorptionAmount(from.getAbsorptionAmount())` - the golden hearts an
absorption effect left on the mob are a plain field, and the effect copy does
not rebuild them.

`conversion::single` copied the effects but never the field, so a zombie with
three absorption hearts that turned into a drowned came out with none.
2026-11-11 10:16:19 +00:00
TheGTandOpenCode e690aec116 tools: a flag is not a directory or a folder
Three tools took their first argument as a path to write into or
download into, so "--help" created ./--help: the pad glyphs wrote
14 PNGs into it, and fetch_dxc.py downloaded Microsoft's 22 MB archive
into it. runner_pause.py, a watcher the service runs with no
arguments, started watching whatever it was given and then crashed on
a folder that was not there yet.

They print their usage and exit instead, and the shared dev-tool test
covers the two that write.

Co-Authored-By: OpenCode <[email protected]>
2026-11-11 09:53:34 +00:00
MSedgeMC 9823bf6c48 port_map: Tooltip attribute modifiers come from the stack 2026-11-11 09:52:00 +00:00
MSedgeMC 47f735a888 test: Tooltip attribute modifiers come from the stack 2026-11-11 09:52:00 +00:00
MSedgeMC 30ae132596 fix: Tooltip attribute modifiers come from the stack 2026-11-11 09:52:00 +00:00
umcekoandClaude Opus 5.5 0cec8f447b Merge main into dims/render-facts
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 12:25:08 +03:00
MSedgeMC e559e8646f port_map: ClientBundleTooltip's empty description height 2026-11-11 09:11:14 +00:00
MSedgeMC c48801cb57 test: an empty bundle's height follows its description lines
Three wrapped lines reserve 3 * 9 + 13 + 8 pixels and put the bar at 3 * 9 +
4, not the fixed two-line height.
2026-11-11 09:11:14 +00:00
MSedgeMC 200a255241 fix: an empty bundle's tooltip is as tall as its description
ClientBundleTooltip.getEmptyBundleBackgroundHeight is
getEmptyBundleDescriptionTextHeight(font) + 13 + 8, and that height is
font.split(BUNDLE_EMPTY_DESCRIPTION, 96).size() * 9: the description's wrapped
line count at one decimal. Horizon hardcoded two lines (18), so a translation
whose description wraps to three lines drew the progress bar and the box's
bottom two pixels too high.

tooltip_layout and bundle_tooltip take the line count; the tooltip's probe and
draw compute it from the same font and 96 pixel width the description is drawn
at. SmithingScreen's slot icons moved to game/smithing_icons.rs so the
3,000-line stations.rs does not grow.
2026-11-11 09:10:51 +00:00
MSedgeMC 0f6a5b1b58 port_map: ServerData.validateIcon's 1024 limit 2026-11-11 09:06:59 +00:00
MSedgeMC e9c6b85aa8 test: an icon up to 1024 square is kept
64, 128 and 1024 pixel PNGs are kept (128 and 1024 would fail the old
64x64-only gate); 1025 and a non-PNG are not.
2026-11-11 09:06:59 +00:00
MSedgeMC c01e5ea7bd fix: a server icon is kept up to 1024x1024, not only 64x64
ServerData.validateIcon keeps any PNG whose width and height are at most 1024
(MAX_ICON_SIZE); FaviconTexture.upload still needs exactly 64x64 to draw one.
Horizon used icon_pixels (64x64 only) as the gate too, so a server whose
favicon was, say, 128x128 had its icon dropped from servers.dat and never
saved on join. valid_icon now answers the save question, and icon_pixels keeps
answering the draw one.
2026-11-11 09:06:37 +00:00
tarzinio eb56315c76 Cap the occlusion edit queue when cave culling is off
Fixes #15152
2026-11-11 09:50:20 +01:00
MSedgeMC 12782bd26c port_map: Options.chatDelay's message and slider 2026-11-11 08:29:08 +00:00
MSedgeMC 28c51ac694 test: the Chat Delay row says its delay
Default reads None; 2.0 and 6.0 seconds read the delay message with one
decimal; back to zero reads None. The slider test from the previous commit
still pins the tenths.
2026-11-11 08:29:08 +00:00
MSedgeMC 3df28380e8 fix: the Chat Delay row shows its delay
Options.chatDelay's message is options.chat.delay_none at zero and
options.chat.delay with the delay to one decimal otherwise (both label and
value_text). This folds the message half of #15068 into this PR, so one change
covers the control: the IntRange(0, 60) slider from the previous commits and
the message here.

For merge hygiene the new values module sits above mod shell_pages (not after
pub use Opt) and the ChatDelay arms lead their matches (not after the wheel
sensitivity arm), so the wheel-sensitivity PR's hunks do not touch these lines.
2026-11-11 08:29:08 +00:00
MSedgeMC 00c3584214 port_map: OptionInstance.IntRangeBase.toSliderValue and the Chat Delay tenths 2026-11-11 08:12:38 +00:00
MSedgeMC f288262d66 test: the Chat Delay slider runs from 0 to 6 seconds in tenths
0.0, 3.0 and 6.0 sit at 0, 1/2 and 1 of the track (a stored 6.5 clamps), the
slider has 60 steps, and set_slider stores 0.0, 3.0 and 6.0.
2026-11-11 08:12:38 +00:00
MSedgeMC 5a75c90dbc fix: the Chat Delay control is a working slider
Options.chatDelay is IntRange(0, 60) xmap(v -> v / 10.0, v -> v * 10, true),
a slider from 0 to 6 seconds in tenths: toSliderValue is (tenths / 60) and
fromSliderValue is floor(map(v, 0, 1, 0, 61)) / 10. Horizon had no kind or
set_slider arm for it, so the Accessibility row drew a button whose click
changed nothing. The value labels (generic, on_off, chat_px) moved to
options_ui_values.rs with the mapping so options_ui.rs does not grow.
2026-11-11 08:12:38 +00:00
MSedgeMC 8918f22af2 port_map: TheEndGatewayBlockEntity.portalTick marks the chunk
The server ticker's age change is a `setChanged`.
2026-11-11 08:10:12 +00:00
MSedgeMC c8f98e4a8c test: an ageing end gateway marks the chunk
`a_gateway_that_ages_marks_the_chunk` ticks a gateway one server tick: the
age reads 1 and its chunk is in `world.modified`. Without the mark the
chunk stays clean.
2026-11-11 08:10:05 +00:00
MSedgeMC 1e12fc40bc fix: an end gateway's ageing marks the chunk
`TheEndGatewayBlockEntity.portalTick` ages the block entity and ends with
`setChanged`, so the age the beam and the chunk save read is saved.

Horizon's `tick_end_gateway` copied the server age onto the block entity
but never marked the chunk.
2026-11-11 08:09:43 +00:00
TheGTandOpenCode 26c04d14c3 Entity.saveWithoutId writes the live PortalCooldown, not the loaded one
A mob, an item and a vehicle kept the PortalCooldown they were loaded
with, so their countdown never showed in a save or in /data get entity.
The portal state ticks the countdown down every tick, as
Entity.processPortalCooldown does; the save now takes it from there, as
the projectile writer already did.

Fixes #14539

Co-Authored-By: OpenCode <[email protected]>
2026-11-11 07:59:37 +00:00
Vinícius MoreiraandClaude Opus 5.5 a6deb3e7de Polytone: exclusion_radius sees only particles already in the world
Measured on the mod: two particles of a type with exclusion_radius 2,
spawned a block apart in the same tick, both show, with or without
physics; one born inside a block (dropped) keeps no clear one from
spawning beside it. Horizon also counted the pending particles of the
tick, so the second was refused. The check now looks only at those that
joined; a later tick's spawn near them is still excluded.

Part of #11486

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 04:06:29 -03:00
qaesen 63976a33d7 hud: add progressive downscaling fallback for oversized server packs (fixes #8570) 2026-11-11 07:48:51 +01:00
Vinícius MoreiraandClaude Opus 5.5 aed6fcaad9 Polytone: a particle that never joins takes no place under its type's limit
Measured on the mod: with limit 1, one particle with physics born inside a
block and one clear of it in the same tick, the clear one shows. Horizon
counted the pending one, which is dropped only when it joins, so the clear
one was refused and nothing showed. A pending particle with physics now
counts only once it joins: the limit is checked again at the join, after
the drop.

Part of #11486

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 03:43:40 -03:00
MSedgeMC f09967622c port_map: AbstractThrownPotion.douseFire carries the owner's source
The campfire's block-change event is posted from the potion's owner.
2026-11-11 05:19:53 +00:00
MSedgeMC f561a0f625 test: a doused campfire posts block_change from the potion owner
`a_doused_campfire_posts_block_change_from_the_potion_owner` puts a lit
campfire at (2, 4, 3) and douses it with a mob owner: the block reads
unlit and the posted events hold
`("minecraft:block_change", [2.5, 4.5, 3.5], Some(Who::Mob(7)))`.
With no source passed the event's source is None.
2026-11-11 05:19:53 +00:00
MSedgeMC d0321de02b fix: a doused campfire's block_change carries the potion's owner
`AbstractThrownPotion.douseFire` passes `getOwner()` to
`CampfireBlock.douse(entity, level, pos, state)`, which calls
`level.gameEvent(entity, GameEvent.BLOCK_CHANGE, pos)`: the sculk sensor
hears the campfire go out from the potion's owner (possibly no owner at
all).

Horizon's `potion_hit_block`/`douse_fire` posted the event with no
source; the candle path stays sourceless, as vanilla's
`AbstractCandleBlock.extinguish(null, ...)` is.
2026-11-11 05:19:53 +00:00
MSedgeMC 1a2d9072d6 port_map: CrafterBlockEntity.setSlotState marks the chunk
The menu's slot state write-back lands on the block entity and marks it.
2026-11-11 05:03:37 +00:00
MSedgeMC e01e03f4db test: a slot toggled in the menu marks the crafter's chunk
`a_slot_toggled_in_the_menu_lands_on_the_crafter` opens a crafter with
slot 3 disabled, clicks it with an empty hand and checks the block
entity: the slot is enabled and the chunk is marked. Without the mark the
click leaves `world.modified` empty.
2026-11-11 05:03:37 +00:00
MSedgeMC efd000579d fix: a crafter's slot state write marks the chunk
`CrafterBlockEntity.setSlotState` ends with `setChanged`, which is what
saves a toggled slot. Horizon applied the menu's slot states to the block
entity on every click (`menu_click`'s write-back) but never marked the
chunk, so a disabled slot could be lost on save.

The write-back is now `Game::apply_crafter_slot_states`: it copies the
menu's flags, and when one changed calls `container_changed` (the
container-marking path, which also re-reads comparators).
2026-11-11 05:03:37 +00:00
MSedgeMC f4c4b3c8bd port_map: SelectWorldScreen's button states are modelled 2026-11-11 05:00:30 +00:00
MSedgeMC 9c99f95069 Merge remote-tracking branch 'upstream/main' into fix/reset-keys-active 2026-11-11 04:54:10 +00:00
TheGTandOpenCode f2708c690f docs: the agents map says what lang_coverage.py --summary prints
Co-Authored-By: OpenCode <[email protected]>
2026-11-11 04:52:56 +00:00
yahya-nusseibehandClaude Opus 5.5 62f7e8ef03 Bound legacy smithing-trim previews from old servers
A 1.20.2-1.21.4 server's smithing-trim display (or trim recipe) is expanded into one
SmithingTrim per pattern, each with its own copy of the base and material displays. Those
can themselves be trim displays, so a few hundred bytes asked for billions of nodes.

Refuse a preview whose patterns x (base + material weight) exceeds MAX_TRIM_PREVIEW (4096),
counted with the new SlotDisplay::weight, both in SlotDisplay::read (1.21.2-1.21.4) and in
the 1.21.1 trim recipe translation; the 1.21.1 path also builds each display once.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 06:51:11 +02:00
Vinícius MoreiraandClaude Opus 5.5 f5bad28164 Comparator: each doc comment above its own function
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 01:47:37 -03:00
TheGTandOpenCode 071a5d1d3a tools: the velocity probe reads the protocol it announces, instead of writing 777 by hand
Co-Authored-By: OpenCode <[email protected]>
2026-11-11 04:42:45 +00:00
yahya-nusseibehandClaude Opus 5.5 b689e1dfa2 Protocol text: a disconnect reason can't expand past 1 MiB
text::plain flattens a server's Disconnect component into one String,
and format_java copies an argument once per %1$s in the server's own
`fallback` template. A fallback of 16 000 "%1$s" (64 KB) whose argument
is the same construct, nested three levels (well inside the depth-32
guard), asked for about 4e12 bytes; the allocation failed and the client
aborted instead of showing the reason. The login phase's json_text, a
second copy of the same expansion, had no depth limit either.

plain, format_java and json_text now stop at MAX_TEXT (1 MiB), cut at a
character boundary (push_capped). A disconnect screen needs a few
hundred bytes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 06:40:51 +02:00
Vinícius MoreiraandClaude Opus 5.5 8204825b4e Core shaders: an array sized by its initializer only translates (#9386)
naga's GLSL front end refuses `const float[] w = float[](...)` and
`float w[] = ...`, which vanilla's GL compiler sizes from the initializer.
The size is written in, in the declarator and the constructor. Brackets
are matched in one pass and the declarator is looked for at most 256 bytes
back, so a long line stays linear.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 01:37:49 -03:00
Vinícius MoreiraandClaude Opus 5.5 680e91bdf9 Core shaders: a helper taking the sampler it reads translates (#9386)
naga's GLSL front end refuses sampler2D function parameters that vanilla's
GL compiler takes. Each such function is copied once per set of samplers
its callers pass, the copy reading those samplers by name: overloads keep
their own bodies, nested calls are rewritten inside out, prototypes become
the copies' prototypes, and copies go after their samplers' declarations
(lucasmts59's finding in review).

Pack text is untrusted. Brackets are matched in one pass and the scans for
calls have a budget, so no text freezes a load. A sampler parameter with
no name, edits that would overlap, or anything else the rewrite can't
follow leave the whole shader as the pack wrote it, for naga to refuse.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 01:37:33 -03:00
yahya-nusseibehandClaude Opus 5.5 a2864d9edd horizon-server: a management kick reason can't inject control requests
players/kick put the management client's reason, newlines included,
into "console kick <name> <reason>", which Control::ask writes as one
line to the game's control endpoint. The endpoint reads one request per
line, so a reason of "x\nconsole op Mallory" ran a second request: any
console command, or any other control verb, which the management
protocol never offers.

The kick reason now drops control characters, and Control::ask itself
refuses a request with \n or \r before sending anything, so every caller
is covered (the API's /command also refuses \r now, as it did \n).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 06:26:10 +02:00
Vinícius MoreiraandClaude Opus 5.5 c0998126c0 Core shaders: a pack's gl_VertexIndex reads the glue's vertex number
26.3 packs write gl_VertexIndex where older ones wrote gl_VertexID: both
mean vanilla's vertex number, four per quad. Only gl_VertexID read the
glue's expression, so on the text pipeline (six vertices per glyph) a
26.3 pack read the raw index. Both now read it; the glue's own code above
the pack's keeps the real index.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 01:09:48 -03:00
a54d9f825a Keep a zombie in powder snow from catching fire in daylight
Mob.isSunBurnTick (Mob.java:500-506) counts isInPowderSnow and wasInPowderSnow
as not burnable, so a mob standing in powder snow never ignites; Horizon only
tested water and rain, so a zombie in powder snow ignited in daylight and
PowderSnowBlock.entityInside (PowderSnowBlock.java:88-99) then destroyed the
block under it with mobGriefing and put it out, leaving it to burn freely.
The scan at burn time is isInPowderSnow; m.in_powder_snow, the scan baseTick
made before the mob travelled, is wasInPowderSnow.

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 05:05:20 +01:00
MSedgeMC d7107e8f1b Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-11 03:53:41 +00:00
MSedgeMC 4caa4a90c3 Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-11 03:53:31 +00:00
0ce71c6934 Zombies burn you only with a bare hand, on the difficulty's chance
`Zombie.doHurtTarget` (Zombie.java:315-325) rolls `effectiveDifficulty * 0.3`
for a burn of `2 * (int)effectiveDifficulty` seconds only when
`super.doHurtTarget` landed, the main hand is empty and the zombie is on fire,
and every species that extends `Zombie` inherits it: a burning husk, drowned,
zombie villager and zombified piglin now burn too, while a zombie holding a
weapon and a blow a shield took whole no longer do. The chance is drawn in
`do_hurt_target` where vanilla draws it, in the mob's own tick, and the game
lights the burn only for the blow it rides.

The turtle egg's break sound also draws its pitch from the level random
(Zombie.java:553-555): `0.9 + level.getRandom().nextFloat() * 0.2`.

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 04:42:19 +01:00
KBroneZ e7499779c8 Merge remote-tracking branch 'upstream/main' into claude/up-8688 2026-11-11 05:33:30 +02:00
KBroneZ c1503f7ada Merge remote-tracking branch 'upstream/main' into claude/up-7843 2026-11-11 05:33:13 +02:00
Nikita Maximilian GrimmandClaude Fable 5.2 4f76f9b35d Merge remote-tracking branch 'origin/main' into projectile-uuid-draws
Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-11 04:25:26 +01:00
Nikita Maximilian GrimmandClaude Fable 5.2 bd8ae9f1ed Merge remote-tracking branch 'origin/main' into parity/ominous-spawner-loaded-rotation
Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-11 04:23:26 +01:00
Nikita Maximilian GrimmandClaude Fable 5.2 95848131df Merge remote-tracking branch 'origin/main' into parity/sulfur-cube-bucket-health
Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-11 04:21:24 +01:00
Vinícius MoreiraandClaude Opus 5.5 f0a379cbc1 Merge main into fix/comparator-setblock-output: main compiles again (#15038)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 23:57:09 -03:00
MSedgeMC 90187f9942 port_map: KeyBindsScreen's Reset All active state 2026-11-11 02:01:31 +00:00
MSedgeMC 57bf54407a test: Reset All's active state follows the bindings
The default map has nothing to reset; moving one binding turns it on, setting
it back or reset_all turns it off.
2026-11-11 02:01:31 +00:00
MSedgeMC 5666c93e13 fix: Reset All is greyed out until a binding changes
KeyBindsScreen.extractRenderState walks the mappings and sets
resetButton.active from KeyMapping.isDefault, so the button is inactive until
some binding differs from its default. Horizon drew it active and reset every
mapping on any click.
2026-11-11 02:01:31 +00:00
KBroneZandClaude Opus 5.5 a1bc8fc608 fix: a client plays with the server's block and item tags
The client kept only the dialog tags of ClientboundUpdateTagsPacket and
read block and item tags from the game's own files, so a data pack's or
plugin's tags (climbable, mineable, ...) never reached a player on a
server. Dynamic now keeps every registry's tags; a server session builds
the game's Items with the server's block and item tags in place of its
own (by name, through the built-in registries), and Game::items() reads
it. The play-state packet after /reload replaces them the same way
(ClientPacketListener.handleUpdateTags).

The server tables (the synced enchantments from #9301 and now the items)
move to game/remote_tables.rs, so remote.rs shrinks.

Closes #7843

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 03:39:00 +02:00
DUzzL ee581f9834 Rabbit: start the move control in MOVE_TO towards (0, 0, 0) like the constructor's setSpeedModifier(0.0) 2026-11-11 02:12:23 +01:00
MSedgeMC 4919ef98dd Restore the weapon component test the merge dropped 2026-11-11 01:07:56 +00:00
MSedgeMC e723d9ea15 Merge main: keep weapon_damage beside weapon_disable_blocking 2026-11-11 01:07:39 +00:00
DUzzL 822fbc2af9 port-pack: read a lone placeholder mod id template instead of failing with a TOML error 2026-11-11 02:00:40 +01:00
Vinícius MoreiraandClaude Opus 5.5 9c31a83fed A comparator placed with OutputSignal by /setblock keeps it
BlockInput.place loads the tag into the comparator's block entity, and
ComparatorBlockEntity.loadAdditional reads getIntOr(OutputSignal, 0).
Horizon keeps a comparator's output beside the block entities and only
/data wrote it; /setblock and /fill now load it through the same
load_comparator_output.

Fixes #13870

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 21:59:37 -03:00
MSedgeMC 59d9424230 Merge upstream/main into fix/command-block-edit-mark 2026-11-11 00:42:21 +00:00
DUzzL 0e9f555a5b Keep a camel's saved LastPoseTick an absolute game time while it is not ticking 2026-11-11 01:20:10 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 6e036e8918 Merge origin/main into projectile-uuid-draws
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 00:52:29 +01:00
umcekoandClaude Opus 5.5 a97cefe7f7 Merge main into dims/render-facts
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 02:30:21 +03:00
KBroneZandClaude Opus 5.5 15dc5e301c test: keep the remote anvil test beside anvil.rs, stations.rs not growing
stations.rs is over the 3000-line limit and may not grow
(docs/layers.md#file-size): the test module hangs off stations/anvil.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 01:18:36 +02:00
Nikita Maximilian GrimmandClaude Opus 5.5 b02f367216 Merge origin/main into projectile-uuid-draws
dispenser.rs: main (#14603) moved the firework rocket out of the match
into launch_angled_rocket, which makes the rocket's own random. This
branch only renamed the random argument in that arm, so main's version
stays and the arm goes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 23:58:45 +01:00
DUzzL 29bfd0e362 Fix dispense position float order to match vanilla 2026-11-10 23:57:50 +01:00
umcekoandClaude Sonnet 5.5 d07042696f Sky, shading and ambient light follow the dimension type (#11282, 7/9)
A datapack dimension (`data/<ns>/dimension/`) gets its own `DimensionType`, and
vanilla's renderer reads the skybox, cardinal light, ambient light and
attributes from it, not from the level's key. The client's draw choices now
read the level's dimension type facts the same way.

- Cardinal light picks the diffuse lights and the section shading; the type's
  `has_skylight` picks the section sky light.
- The skybox picks the sun, moon, stars and clouds, the End sky and its flashes,
  the sun shadows and the shader sky type; the ambient light picks the entity
  shadow darkening.
- The day timeline, `water_evaporates` and `piglins_zombify` pick the firefly
  bush and creaking effects, the dripstone particle and the piglin conversion
  shake; the minimap reads the level shape's ceiling.
- `Resources::environment_of` picks the environment table by dimension type id.

For the Overworld, the Nether and the End every site returns what it did before.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 01:57:34 +03:00
KBroneZandClaude Opus 5.5 57b2669f0a fix: preview the anvil with a server's data-pack enchantments
On a server whose data pack adds an enchantment, the anvil showed the red
cross for a book of it: the client's result prediction read the world's
own enchantment table, which lacks the entry. AnvilMenu.createResult on
the client reads the synced registry, so the station previews and the menu
engine now read Game::enchantments(), the server's table in a server
session and the world's otherwise.

Closes #8688

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 00:47:40 +02:00
Nikita Maximilian Grimm f9c3e85868 Merge remote-tracking branch 'origin/main' into parity/ominous-spawner-loaded-rotation
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-10 23:36:02 +01:00
Nikita Maximilian Grimm b555275591 Merge remote-tracking branch 'origin/main' into parity/sulfur-cube-bucket-health
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-10 23:27:21 +01:00
d9ed797708 Heightmaps: clone stale bits before concurrently refreshed heights
A snapshot could copy an old cached height and then a stale bit another reader had just cleared, retaining the old height as clean. Acquire the stale-bit snapshot first: marked columns rescan, and cleared bits publish the refreshed height. A concurrent Chunk snapshot test covers both heightmaps and fails with the original ordering.

Co-Authored-By: Kacper <[email protected]>

Co-Authored-By: OpenCode GPT 6.1 Sol <[email protected]>
2026-11-10 22:03:40 +00:00
c45072ca0e A comparator beside a decorated pot reads its item
DecoratedPotBlock.getAnalogOutputSignal goes through
AbstractContainerMenu.getRedstoneSignalFromBlockEntity ->
getRedstoneSignalFromContainer, but Horizon keeps a pot's one slot in the
chunk's saved block entity rather than world.block_entities, so the
container signal found no container and read 0. The comparator path now
builds the pot's ContainerSingleItem view from the saved tag (the same one
hopper insertion already used) and reads the slot through it, so a pot
with items powers a comparator and a hopper insertion updates it.

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 22:31:22 +01:00
DetimesandClaude Opus 5.5 14ac9b88e5 Merge upstream/main into perf-block-event-queue
Brings the branch up to date and lays out the new code and tests as
rustfmt wants them, which the verify job checks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 18:26:50 +00:00
Verm 452d4be9dc A server player's saved Fire is narrowed to a short too
Entity.load reads Fire with getShortOr for every entity. The player's
loader read it, Air and TicksFrozen through one unnarrowed read; Fire
is now narrowed, the other two stay ints as vanilla reads them.
2026-11-10 18:05:35 +00:00
DetimesandClaude Opus 5.5 c08c3e765c Test: held_attack_item returns the item the old clone returned
Checks the borrowed lookup against the clone-and-flatten expression it
replaces, over an empty slot, filled slots, slots past the end and an
empty inventory.

Refs #14939

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 18:01:58 +00:00
DetimesandClaude Opus 5.5 d386833f8b Perf: borrow the held item in tick_guests instead of cloning the stack
tick_guests cloned the whole ItemStack of every guest's selected slot
every tick, only to compare its item with play.attack_item. The new
held_attack_item helper borrows the item, and the Arc is cloned only
when the held item changed. Same result, no clone on the common path.

Refs #14939

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 18:01:39 +00:00
Verm 32621f2275 A mob and an armor stand narrow a loaded Fire to a short (#14879)
Entity.load reads Fire with getShortOr, which narrows any numeric tag
(IntTag.shortValue is (short)(value & 65535)): Fire:40000 loads as
-25536. The arrow, the other projectiles and the boat already narrowed
it; the mob's and the armor stand's loaders kept the raw number.
2026-11-10 16:57:36 +00:00
Verm b6172e204f A mob narrows a loaded HurtTime and DeathTime to a short (#14876)
LivingEntity.readAdditionalSaveData reads both with getShortOr, which
narrows any numeric tag (IntTag.shortValue is (short)(value & 65535)):
HurtTime:40000 loads as -25536 and never counts down. The loader kept
the raw number, so such a mob counted down from 40000 and was drawn
hurt all the while.
2026-11-10 16:50:37 +00:00
DetimesandClaude b642094bb6 Test: block-event queue matches the old Vec behaviour
Replays pushes, duplicates, pops with re-queues during a run, deferred
events put back after the run, and retains against the old Vec model
(contains + push, remove(0)) and checks queue contents and pop order
after every step. Also checks 5000 distinct events pop in queue order
and a duplicate is dropped. No game resources needed.

Refs #14873

Co-Authored-By: Claude <[email protected]>
2026-11-10 16:41:27 +00:00
DetimesandClaude e2cb60d768 Perf: block-event queue is O(1) per event, not O(E^2) per tick
run_block_events and client_drain_block_events popped with Vec::remove(0),
which shifts the whole tail per event, and block_event deduped with a
linear contains. The queue is now a VecDeque (pop_front) with an
FxHashSet mirror for the dedupe. BlockEventData derives Hash.

All queue writes go through Ticks::queue_block_event, pop_block_event
and retain_block_events so the set cannot drift. Queue order, dedupe
results and the re-queue and reschedule paths are unchanged.

Refs #14873

Co-Authored-By: Claude <[email protected]>
2026-11-10 16:41:21 +00:00
foudretbr fc3ac6a620 fix(mobs): align drowned and husk conversion ticks and cure random with vanilla (#14770) 2026-11-10 12:52:35 +01:00
DUzzL 800b42b719 Skip leash knots when saving chunk entities (EntityType.noSave) 2026-11-10 12:33:41 +01:00
Riccardo GuerrieroandClaude Fable 5.2 9ee7b165cd Worldgen: the range choice picks its values without a branch per block
RangeChoiceSampler::sample_volume kept or replaced each value with a
conditional the compiler turned into one branch per value. Final density
runs it over a chunk's whole 16x384x16 volume (the noodle caves), where the
choice changes often, so the branch was mispredicted: about 4.5% of proto
generation time at one thread.

The select now runs in its own function over plain slices, on the values'
bits, which the compiler vectorizes. Each value is the one vanilla's
conditional store leaves; a test checks it bit for bit against a scalar
reference, with NaN bounds, NaN selectors and signed zeros.

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

Claude-Session: https://claude.ai/code/session_01WZzq8MWgQD4Rbxknozw5LZ
2026-11-10 11:47:04 +01:00
MSedgeMC c3121e9a5a Rebuild the weapon component read on current main without dropping attribute_modifiers 2026-11-10 09:59:59 +00:00
AkirabaneandClaude Opus 5.5 9c0c8ed4f4 Server: the mob data generation finalizes with is read when the world opens
The first mob a fresh chunk generates (spawnOriginalMobs) read the structure mob
finalizer's data on the server's tick: every mob type, the biome tags, the spawn
registries, the enchantments and the item tags, from the prepared resources.
Where those files are slow to read, that one read stalled the server for 6 to
7 s, a minute after players first walked into new terrain (#13857).

The terrain now starts that read on a thread of its own when its level loads
(every dimension but the End, whose chunks generate no mobs). The cell stays a
OnceLock: a mob that needs the data before the read ends waits for it, and the
data is read once.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 07:23:29 +01:00
GIGABAIT93andClaude Opus 5.5 5a452cc97f Ukrainian: the 96 strings uk_ua.json lacked (radial menu, HDR, fuel)
The controller's radial menu and its page, the HDR options' tooltips and
the recipe viewer's fuel tooltip. Placeholders and edge spaces match
en_us.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 08:06:12 +02:00
Mayonesu0andClaude Sonnet 5.5 37bbe0bdea A loaded armor stand saves the movement_speed its first tick makes
armor_stand_to_nbt kept a saved `attributes: []` over the stand's live
speed_instance (or_insert), so a stand loaded with an empty list never saved
the movement_speed instance LivingEntity.aiStep -> removeFrost creates.
Append it to the saved list once the stand has ticked, keeping the other
saved instances. The mob corpus gains a stand summoned with `attributes:[]`.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 06:26:14 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 fa19462873 An ominous item spawner's loaded yaw wraps at 360 too
Entity.load ends with setRot, which takes the yaw and the pitch % 360.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 02:47:46 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 4976814641 An ominous item spawner keeps the rotation, OnGround and fall_distance it was loaded with
Entity.load reads Rotation (its pitch clamped to -90..90 after % 360),
OnGround and fall_distance, and a spawner never moves or turns, so its
save writes them back. Horizon saved 0 for all three.

Fixes #14572

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 02:10:54 +01:00
DUzzL f2b0c69f72 Wrap item frame and painting facing bytes like Direction.from3DDataValue/from2DDataValue 2026-11-10 01:04:45 +01:00
Evrim CicekandClaude Opus 5.5 6d0d16d48b Move the experience orb NBT out of entity_nbt.rs
Part of #14043: a pure move to entity_nbt/orb.rs, so the orb's
fall_distance (#14440) lands in the moved file.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 02:47:49 +03:00
MSedgeMC 7b73baf589 Rebuild the weapon component read on current main 2026-11-09 23:45:27 +00:00
fd7b18c787 Heightmaps: test that a write finds its own column again
A write two below the height asks about no block on the next read; a write at or
above it is found again down that column alone, and the other columns keep theirs.

Co-Authored-By: Kacper <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:36:39 +00:00
5c7ea99917 Heightmaps: a write marks its column, found again alone on its next read
A block written at or above a column's height (grass spreading, a crop, a mob breaking a
block) used to drop the whole chunk's map, and the next spawn attempt there scanned all 256
columns again. The write now marks that column; its next read finds its height down that
column only. The heights are those a rebuild gives. Natural spawning on the bench: 4.8 ms a
tick to 4.0.

Co-Authored-By: Kacper <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:26:42 +00:00
MSedgeMC 9f6b9e408d port_map: the command block edit marks the chunk 2026-11-09 19:51:59 +00:00
MSedgeMC a81a3151c3 test: an edited command block dirties its chunk 2026-11-09 19:51:59 +00:00
MSedgeMC bc9c686b25 fix: editing a command block marks its chunk 2026-11-09 19:51:59 +00:00
Horizon agentandClaude Opus 5.5 4787069c5b Sign sides: keep the first 4 valid lines, as SignText's codec does
Run against the 26.3 SignBlockEntity: an entry that fails is dropped
(ListCodec's partial) and Util.fixedSize keeps the first 4 lines of a
longer list with the colour and glow; only fewer than 4 valid lines load
the empty side.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:54:19 +01:00
Horizon agentandClaude Opus 5.5 fa250c3c93 Sheep colour: read Color as Codec.BYTE does (byteValue)
DyeColor.LEGACY_ID_CODEC is Codec.BYTE: an int tag of 259 reads as byte 3
(light blue), not white.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:53:53 +01:00
MSedgeMC 6a31935f68 Merge upstream/main 2026-11-09 18:00:32 +00:00
MSedgeMC 4694211e8d Merge remote-tracking branch 'upstream/main' into fix/weapon-disable-blocking-component 2026-11-09 17:59:40 +00:00
MSedgeMC 0ff4404c9e Merge remote-tracking branch 'upstream/main' into fix/weapon-disable-blocking-component 2026-11-09 17:54:04 +00:00
DUzzL b50a445987 Load a sign side empty unless messages has exactly 4 lines 2026-11-09 18:50:32 +01:00
MSedgeMC 16d4b2c348 fix: read the sheep colour through the saved tag
`Option::and_then` gives the closure the `&Tag`, not the option the save
helpers take; pass it through `Some` so the branch compiles.
2026-11-09 17:49:57 +00:00
RLC248 d702f7cf60 A mace smash sends the attacker's motion after the hit
MaceItem.hurtEnemy resets the attacker's fall and sets its motion, and the
server sends that motion to the attacker in a ClientboundSetEntityMotionPacket
right after the hit. A LAN guest's PvP mace smash now sends the motion the
hit gave it, as it already did for mob hits. The server_knockback harness no
longer lists pvp-mace as a known difference.

Fixes #8246
2026-11-09 18:31:54 +01:00
MSedgeMC f6a6dcc007 fix: drop needless borrows in the weapon disable paths 2026-11-09 17:24:58 +00:00
MSedgeMC 3df13bccbd port_map: the sheep's colour id follows the codec's out-of-bounds strategy
`tools/port_map/game-012.tsv` records `Sheep.readAdditionalSaveData`; its
`Color` read now goes through `mobs/farm.rs::saved_color`, which gives white
for an id the legacy codec does not know.
2026-11-09 17:23:00 +00:00
MSedgeMC 851627a847 test: an unknown sheep colour reads white
`mob_from_nbt` is what `Game::load_mob` goes through. A sheep with
`Color: 99b` loads with `extra.color == 0` (white), not the wrapped 3.

Without the codec bound the sheep loads light blue: `left: 3`, `right: 0`.
2026-11-09 17:23:00 +00:00
MSedgeMC 0a36da7779 fix: an unknown saved sheep colour reads white
`Sheep.readAdditionalSaveData` reads `Color` with
`DyeColor.LEGACY_ID_CODEC` and `orElse(DEFAULT_COLOR)`. The codec's id map is
`ByIdMap.continuous(..., OutOfBoundsStrategy.ZERO)`, so an id outside 0..=15
reads as the first colour, white.

Horizon masked the id with `& 15`: a saved `Color: 99b` became light blue (3)
instead of white, and a negative id became a wrapped colour the sheep never
had.

The read now accepts only 0..=15 and gives white for anything else, in
`mobs/farm.rs::saved_color` beside the sheep's other colour code.
2026-11-09 17:23:00 +00:00
MSedgeMC f8599e2685 Disable-blocking seconds come from a stack's weapon component
LivingEntity.getSecondsToDisableBlocking reads
Weapon.disableBlockingForSeconds. Horizon read the prototype's value, so a
patched weapon disabled a shield for 0 seconds instead of its own.

Test: a diamond sword with disable_blocking_for_seconds 1.5 reports 1.5.
2026-11-09 16:00:10 +00:00
Horizon agentandClaude Opus 5.5 c80f0361be Projectiles: put shoot_own's doc comment back on shoot_own
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:49:12 +01:00
Horizon agentandClaude Opus 5.5 c08d2b301e Merge origin/main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:49:12 +01:00
RINNECODERandClaude Opus 5.5 e31eeeb2bc Boats and minecarts more than 64 blocks under the world are discarded
Entity.checkBelowWorld runs in AbstractBoat.tick (through Entity.baseTick,
after the fire) and in AbstractMinecart.tick (after its hurt and damage wear
off), before either moves: under the level's floor minus 64, onBelowWorld
discards it. Horizon had no such check for vehicles, so a boat or minecart
that fell out of the world kept falling for ever (#8024 measured one at
y -243 where vanilla had removed it by tick 33).

The discard ejects the mob riders where they are and spills a chest boat's or
container minecart's slots (AbstractChestBoat.remove,
AbstractMinecartContainer.remove), with no ENTITY_DIE and no vehicle item.
The logic lives in game/boats/below_world.rs; minecarts.rs keeps its size.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 05:41:46 -06:00
Nikita Maximilian GrimmandClaude Opus 5.5 08d02c752f Format the shot calls that lost their random argument
A projectile added keeps an earlier UUID for its id (a client copy's from the
spawn packet) through or_insert again, so the client copy needs no reset.

Part of #12561

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 12:13:18 +01:00
Nikita Maximilian Grimm 31edfa94fb Merge remote-tracking branch 'origin/main' into parity/sulfur-cube-bucket-health 2026-11-09 12:07:49 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 3a35efafa8 Keep a loaded sulfur cube's saved Health in its own load
entity_nbt.rs may not grow (over 3000 lines), so the sulfur cube's own load
re-reads its saved Health after sizing it instead of the living load
skipping the Health Boost clamp: the result is the same, the clamp to the
unsized maximum no longer sticks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 11:57:18 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 916b481669 Size a loaded sulfur cube before its Health Boost clamp
The living load refreshed a Health Boost holder's maximum and clamped its
Health before sulfur_cube::from_nbt sized it, so a size-3 cube saved with
Health 12 under Health Boost dropped to the constructor size's 8. Vanilla
sizes the cube (AbstractCubeMob.readAdditionalSaveData) before
LivingEntity's setHealth; its own load now sets the maximum.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 11:44:42 +01:00
Nikita Maximilian Grimm 8db4b7bc14 Merge remote-tracking branch 'origin/main' into projectile-uuid-draws 2026-11-09 11:37:10 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 b5fd8221fc Keep a loaded projectile's UUID through Projectile::own_uuid
The loads take the own random's UUID through one helper, so persist.rs and
creatures.rs keep their size. A client copy drops its own random's UUID: the
spawn packet's, kept before it is added, wins.

Part of #12561

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 11:37:08 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 2ba5206ba9 A sulfur cube saves its size's max_health and movement_speed
SulfurCube.setSize sets the max_health (4 per size) and movement_speed
(0.2 + 0.1 * size) attribute bases, so every load, spawn, split and bucket
release leaves both instances to save. Horizon's set_size wrote the cached
values only: the save held no max_health, and from the first tick a
movement_speed of the type's 0.7, which a reloaded cube then moved at.
set_size now records the bases (the spawn path did that alone before), the
load applies saved bases after the size as LivingEntity does, and Health
Boost changes refresh a sulfur cube's maximum like any model-owned one.

Fixes #13801

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 11:26:12 +01:00
RINNECODERandClaude Opus 5.5 65b8c76509 Boats: Mth's sine table for steering and dismounts, and lastYd for water entry
Boat steering, a boat rider's dismount vector and a minecart rider's push
used the platform's sine and cosine, where vanilla uses `Mth`'s 65,536
entry table, so a boat paddled at most yaws drifted from vanilla's path
(about 1e-4 of the push per tick). They now go through
`horizon_core::geometry::trig`, as `AbstractBoat.controlBoat`,
`Entity.getCollisionHorizontalEscapeVector` (reusing the mounts port)
and `Entity.getInputVector` do. The dismount also uses vanilla's float
products: the boat's width times `Mth.SQRT_OF_TWO`, the 1.0E-5F margin and
the player's 0.6F width.

`getWaterLevelAbove` scans from the hull's top up to
`ceil(maxY - lastYd)`, where `lastYd` is the boat's vertical velocity at
its last `checkFallDamage` (before collisions zero it); Horizon scanned
only the hull top's own row, so a boat falling fast into deep water could
pick another surface height.

Fixes #11381

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 02:26:58 -06:00
Nikita Maximilian GrimmandClaude Opus 5.5 412e1a72c9 A projectile shoots with its own random after its UUID's draws
Vanilla's Entity makes its UUID from the first two draws of its own random
(Mth.createInsecureUUID), and Projectile.shoot spreads the shot with that same
random, from which an arrow keeps drawing (AbstractArrow.setBaseDamageFromMob,
its later rolls). Horizon shot dispensed, crossbow and mob projectiles with a
fresh random that never drew the UUID, player throws with the level's random,
gave an arrow's later draws a random of their own, and gave projectiles a
clock-seeded UUID when first asked.

An arrow now makes its own random in Projectile::new and draws its UUID first;
another kind makes it at the shot (shoot_own), the same stream since nothing
draws between. Every shot goes through shoot_own, the arrow keeps the random,
and the skeleton's base damage comes from it. add_projectile keeps the UUID; a
player's trident takes its shot's. Loaded and summoned projectiles take their
own random's UUID unless the tag has one.

The seed sync proof reads a dispensed arrow's and snowball's UUID and Motion
and a summoned arrow's UUID: 25/30 on main, 30/30 with this.

Part of #12561

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 05:21:24 +01:00
262 changed files with 8864 additions and 1507 deletions

No files matched your search

+1 -1
View File
@@ -221,7 +221,7 @@ jobs:
Write-Output "Using $($clang.Source)"
& $clang.Source --version
- name: Build the game for native Windows ARM64 (the official game, as game-build.yml ships it)
run: cargo build --release --locked --target aarch64-pc-windows-msvc -p horizon-official --bin horizon-official -p horizon-ctl --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
run: cargo build --release --locked --target aarch64-pc-windows-msvc -p horizon-official --bin horizon-official -p horizon-client-lib --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
- name: Check the game's PE machine
run: python tools/check_pe_arch.py arm64 target/aarch64-pc-windows-msvc/release/horizon-official.exe target/aarch64-pc-windows-msvc/release/horizon-ctl.exe target/aarch64-pc-windows-msvc/release/horizon-prepare.exe target/aarch64-pc-windows-msvc/release/horizon-server.exe
- name: Check game DLL imports
+1 -1
View File
@@ -170,7 +170,7 @@ jobs:
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
- name: Build the official game, horizon-ctl, horizon-prepare and horizon-server
if: ${{ !matrix.musl }}
run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} -p horizon-official --bin horizon-official -p horizon-ctl --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} -p horizon-official --bin horizon-official -p horizon-client-lib --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
- name: The official game is the game players get, under its name
# horizon-official: horizon-client's game with Horizon's own parts plugged in (docs/layers.md, "How a
# Horizon crate plugs in"). Everything below, the launcher and players know it as horizon-client.
+6 -13
View File
@@ -22,13 +22,6 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
is the GitHub account the PR is opened from (`gh api user --jq .login`). Issues say "Filed by", reviews
"Review by". Every comment starts with `🤖 Agent:`. Write only as the person you work for: the lines on
this page that name Ludovic are for his own agents (account `ludovic111`), never for anyone else's.
- **Review only when asked, and decide.** Review a pull request only when its author or a maintainer asks.
Asked by a maintainer to review open pull requests in general, take them one at a time, oldest first, skipping
drafts, your person's own and those they already reviewed. Check that the change does what it says (bugs, edge
cases), what it could break, whether tests cover it, and whether its scope and description hold up. Report only
what you can point to with a `file:line`, each point marked Blocker (fix before merge), Important, Nit,
Suggestion, Question or Praise. Submit a decision, never a plain comment: `gh pr review --approve` when nothing
blocks, `--request-changes` when something does.
- **Attribution.** Commits end with a `Co-Authored-By:` line for the agent that wrote them, and PR bodies
with that agent's own "Generated with" line. Example, Ludovic's agents (Claude Code on `ludovic111`):
first line `> 🤖 Opened by Ludovic's AI agent (Claude), not by Ludovic himself.`, commits
@@ -85,8 +78,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
| Crate | Owns |
|---|---|
| `horizon-client` | Gameplay library (`src/lib.rs`: `game.rs` + `game/`, `entry.rs`) and thin executable (`src/main.rs`). Also the dedicated server (`--dedicated`). Re-exports `horizon-client-lib` for existing mod paths. |
| `horizon-client-lib` | Reusable client systems, the control socket, probes and their tests. Its manifest points to the existing sources under `crates/horizon-client/`; the library root is `src/client_lib.rs`. It never depends on gameplay. |
| `horizon-ctl` | `horizon-ctl`, the CLI over the control socket, and its MCP server (platform): `commands.rs` is the command table ([docs/agent-control.md](docs/agent-control.md)) |
| `horizon-client-lib` | Reusable client systems, `horizon-ctl`, probes and their tests. Its manifest points to the existing sources under `crates/horizon-client/`; the library root is `src/client_lib.rs`. It never depends on gameplay. |
| `horizon-items` | Blocks and items below gameplay: `blocks.rs` (block facts), `items.rs`, `item_data.rs` (components), `enchant.rs`, `cooldowns.rs`, `bundle.rs`, `item_nbt.rs`. Re-exported as `horizon_client::items`... |
| `horizon-command` | Command parsing (Brigadier): the vanilla tree, argument types, selectors, suggestions, the tree's network form. Re-exported as `horizon_client::command` |
| `horizon-client-core` | The client lib's bottom layer: `text.rs`, `lang.rs`, `snbt.rs`, `nbt_path.rs`, `component.rs`, `scoreboard.rs`, `bossbar.rs`, `gamerules.rs`, `block_entity_nbt.rs`, registry/particle/sound tables. Re-exported under the old paths (`horizon_client::text`...) |
@@ -136,8 +128,8 @@ reads `README.md`, `launcher/links.json`, `docs/api/`, `roadmap/roadmap.json` an
## Build and test fast
```sh
python3 tools/pr_ready.py # the self-check: fmt, clippy -D warnings, touched crates' tests, harnesses; paste its block in the PR
python3 tools/pr_ready.py --quick # same without harnesses
python3 tools/pr_ready.py # the self-check: fmt, clippy -D warnings, touched crates' tests; paste its block in the PR
python3 tools/pr_ready.py --harnesses # also the affected harnesses (by default only when you change a harness, oracle, probe or the runner, as in CI)
cargo test -p horizon-client -p horizon-client-lib --lib --bins --tests # skips the examples (pr_ready does this); `--doc` separately
cargo test -p horizon-client-lib --lib mod_content::tests:: -- --ignored # the slow content-root tests, #[ignore]d; pr_ready runs them when mod content, assets, blocks or items change
cargo build --profile fast -p horizon-client # optimized, quick to link: use it to play or run lan_e2e
@@ -146,7 +138,8 @@ python3 tools/parity.py --changed-files paths.txt --list # which harnesses cov
python3 tools/parity.py mob_ai sculk # named harnesses
python3 tools/lan_e2e.py --check chest # two real games over LAN (server-thread host)
python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
python3 tools/lang_coverage.py --summary # the keys each language still misses, and its English leftovers
python3 tools/lang_coverage.py --summary # one line per language: how many keys it carries and what it misses
python3 tools/lang_coverage.py --summary --show-keys # the same, then the keys behind the counts
```
- Harnesses and the game need the vanilla data in `.local/` (`minecraft/`, `horizon-client/26.3`, `datagen/`,
@@ -218,7 +211,7 @@ state stays separate from the library tests. This compiles gameplay twice for ex
| Input | game.rs `fn window_event`; G: `play_keys.rs` (`fn key_pressed`), `play_input.rs` (`fn play_mouse`, toggled keys, the hotbar wheel); crate `horizon-client-input` (`keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `clipboard.rs`); L: `osk.rs`; G: `pad.rs`, `pad_menu.rs`, `pad_craft.rs`, `pointer_capture.rs` (mouse look, pointer grab) |
| Audio | L: `audio.rs` (mixer), `sound_engine.rs`, `sound_events.rs`, `music.rs`, `ambience.rs`; G: `music.rs`, `subtitle_hud.rs` |
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`, `mobs/cem.rs` + `mobs/cem_anim.rs` (OptiFine/EMF entity models and their animations); `horizon-client-core/src/lang.rs`; crates `horizon-assets`, `horizon-optifine`; G: `packs.rs` |
| Agent control | L: `control.rs` (socket), `crates/horizon-ctl/`; G: `control.rs`, `agent.rs`, `events.rs`, `ui_control.rs` ([docs/agent-control.md](docs/agent-control.md)) |
| Agent control | L: `control.rs` (socket), `bin/horizon-ctl/`; G: `control.rs`, `agent.rs`, `events.rs`, `ui_control.rs` ([docs/agent-control.md](docs/agent-control.md)) |
| Mods | crate `horizon-mods`; G: `mods.rs`; L: `mod_content.rs`, `mod_list.rs` |
| Horizon extras, settings | L: `extras.rs`, `graphics.rs`, `settings.rs`, `minimap.rs`; G: `extras.rs`, `tweaks.rs`, `options.rs` |
+4 -3
View File
@@ -109,9 +109,10 @@ python3 tools/pr_ready.py --base upstream/main # or origin/main if you work on
```
It looks at what your branch changes and runs what matters: rustfmt, clippy with warnings denied, the tests of the
crates you touched (and the launcher's), generated files, `Cargo.lock`, the Python tests, and the vanilla harnesses
your change can affect. Without the vanilla data on your machine it lists those harnesses instead; CI runs them.
`--quick` skips the harnesses. It exits non-zero when something fails.
crates you touched (and the launcher's), generated files, `Cargo.lock` and the Python tests. Like CI, it runs the
vanilla harnesses only when your branch changes a harness, an oracle, a probe or the runner; otherwise it lists the
ones your change can affect, and main's parity run checks them after the merge. `--harnesses` runs them on your
machine anyway (it needs the vanilla data). It exits non-zero when something fails.
For the game workspace, Clippy checks all targets of the touched crates and their transitive workspace dependents,
including examples. The dependency scope includes optional, platform, build and development dependencies. Tests run
Generated
+1 -11
View File
@@ -1532,6 +1532,7 @@ dependencies = [
"horizon-hdr",
"horizon-hooks",
"horizon-items",
"horizon-mcp",
"horizon-mods",
"horizon-narrator",
"horizon-optifine",
@@ -1649,17 +1650,6 @@ dependencies = [
"ureq",
]
[[package]]
name = "horizon-ctl"
version = "0.1.0"
dependencies = [
"horizon-client-lib",
"horizon-core",
"horizon-mcp",
"horizon-threads",
"serde_json",
]
[[package]]
name = "horizon-discord"
version = "0.1.0"
-2
View File
@@ -13,8 +13,6 @@ members = [
"crates/horizon-client-core",
"crates/horizon-client-input",
"crates/horizon-client-render",
# horizon-ctl and its MCP server (platform).
"crates/horizon-ctl",
"crates/horizon-cosmetics",
"crates/horizon-command",
"crates/horizon-items",
@@ -271,15 +271,31 @@ fn int_array(tag: Option<&Tag>) -> Option<&Vec<i32>> {
fn is_pos(tag: &Tag) -> bool {
matches!(tag, Tag::IntArray(v) if v.len() == 3)
}
/// The default `SignText`: four empty lines, black, not glowing.
fn empty_sign_side() -> Tag {
Tag::compound([
("messages", Tag::List(vec![string(""); 4])),
("color", string("black")),
("has_glowing_text", boolean(false)),
])
}
/// The loaded `SignText` of one side, or the empty text.
pub fn sign_side(tag: Option<&Tag>) -> Tag {
let empty = || string("");
let mut messages: Vec<Tag> = match tag.and_then(|t| t.get("messages")) {
Some(Tag::List(list)) => list.iter().filter_map(component).collect(),
_ => Vec::new(),
// `SignText.DIRECT_CODEC`: `messages` is a component list (an entry that
// fails is dropped, `ListCodec`'s partial result) cut to 4 lines by
// `Util.fixedSize`. Fewer than 4 valid lines fail the side, and
// `SignBlockEntity.loadAdditional` then loads an empty one; more keep the
// first 4 with the side's colour and glow.
let lines: Option<Vec<Tag>> = match tag.and_then(|t| t.get("messages")) {
Some(Tag::List(list)) => {
let lines: Vec<Tag> = list.iter().filter_map(component).take(4).collect();
(lines.len() == 4).then_some(lines)
}
_ => None,
};
let Some(messages) = lines else {
return empty_sign_side();
};
messages.resize_with(4, empty);
messages.truncate(4);
let mut out = vec![
("messages", Tag::List(messages)),
(
@@ -957,6 +973,32 @@ mod tests {
assert_eq!(structure.get("showboundingbox"), Some(&Tag::Byte(1)));
}
#[test]
fn sign_side_needs_exactly_four_messages() {
let ok = snbt("{messages:[\"a\",\"\",\"\",\"\"],color:\"red\",has_glowing_text:1b}");
let side = sign_side(Some(&ok));
assert_eq!(side.get("color"), Some(&string("red")));
// A fifth line, or a bad entry among five, leaves the first 4 valid
// lines with the colour (checked against the 26.3 codec).
for kept in [
"{messages:[\"a\",\"\",\"\",\"\",\"e\"],color:\"red\",has_glowing_text:1b}",
"{messages:[\"a\",1,\"\",\"\",\"\"],color:\"red\",has_glowing_text:1b}",
] {
let side = sign_side(Some(&snbt(kept)));
assert_eq!(side.get("messages"), ok.get("messages"), "{kept}");
assert_eq!(side.get("color"), Some(&string("red")), "{kept}");
}
for bad in [
"{color:\"red\",has_glowing_text:1b}",
"{messages:[\"a\",\"b\"],color:\"red\",has_glowing_text:1b}",
"{messages:[\"a\",\"\",\"\",1],color:\"red\",has_glowing_text:1b}",
] {
let side = sign_side(Some(&snbt(bad)));
assert_eq!(side, sign_side(None), "{bad}");
assert_eq!(side.get("color"), Some(&string("black")));
}
}
#[test]
fn canonical_is_idempotent() {
for id in TYPES.iter().chain(NEWEST.iter()) {
@@ -356,6 +356,12 @@ impl KeyBinds {
pub fn is_default(&self, name: &str) -> bool {
self.get(name) == Self::default_of(name)
}
/// `KeyBindsScreen.extractRenderState`: Reset All is active only while a
/// mapping differs from its default (`KeyMapping.isDefault`, the bound key
/// against `defaultKey`).
pub fn changed_from_defaults(&self) -> bool {
self.keys.keys().any(|name| !self.is_default(name))
}
pub fn set(&mut self, name: &str, key: &str) {
if let Some((n, _, _)) = all_mappings().find(|m| m.0 == name) {
self.keys.insert(n, key.to_owned());
@@ -645,6 +651,22 @@ mod tests {
assert_eq!(k.translate_all(KeyCode::Digit9), [Ok(KeyCode::Digit9)]);
assert_eq!(k.translate_all(KeyCode::F3), [Ok(KeyCode::F3)]);
}
/// `KeyBindsScreen.extractRenderState`: Reset All is active only while a
/// binding differs from its default.
#[test]
fn reset_all_is_active_only_after_a_binding_moves() {
let mut keys = KeyBinds::default();
assert!(!keys.changed_from_defaults());
keys.set("key.attack", "key.mouse.right");
assert!(keys.changed_from_defaults());
// Setting a mapping back to its own default leaves nothing to reset.
keys.set("key.attack", KeyBinds::default_of("key.attack"));
assert!(!keys.changed_from_defaults());
keys.set("key.jump", "key.keyboard.z");
keys.reset_all();
assert!(!keys.changed_from_defaults());
}
#[test]
fn display_names() {
assert_eq!(display_name("key.keyboard.w").plain(), "W");
+6
View File
@@ -22,6 +22,8 @@ horizon-items = { path = "../horizon-items" }
horizon-core = { path = "../horizon-core" }
# Extension points for the crates above the engine (docs/layers.md, "How a Horizon crate plugs in").
horizon-hooks = { path = "../horizon-hooks" }
# `horizon-ctl mcp`: the Model Context Protocol, shared with the launcher and the server.
horizon-mcp = { path = "../horizon-mcp" }
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
horizon-optifine = { path = "../horizon-optifine" }
horizon-threads = { path = "../horizon-threads" }
@@ -130,6 +132,10 @@ horizon-items = { path = "../horizon-items", features = ["test-support"] }
# Every WGSL shader translated as wgpu's DirectX 12 backend does (gfx.rs tests).
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out", "msl-out", "spv-out"] }
[[bin]]
name = "horizon-ctl"
path = "../horizon-client/src/bin/horizon-ctl/main.rs"
[[example]]
name = "chunk_load_bench"
path = "../horizon-client/examples/chunk_load_bench.rs"
@@ -15,7 +15,7 @@ use crate::{
font::{Font, Glyph},
gfx::mark_oit,
hud::Rect,
mesher::{CULL_BACK, Vertex},
mesher::{CULL_BACK, Vertex, cull_back},
models::load_visibility,
signs::{self, SignData, SignText},
text::{Style, Text},
@@ -3093,9 +3093,9 @@ impl BlockEntityRenderer {
}
}
// `DecoratedPotRenderer` submits every part with `entitySolid`, which culls
// back faces: the flat `top` and `bottom` planes show one side each.
// back faces: the flat `top` and `bottom` planes show one side each (#15322).
for vertex in &mut out[first..] {
vertex.normal = CULL_BACK;
vertex.normal = cull_back(vertex.normal);
}
}
Kind::EndPortal => {
@@ -4760,9 +4760,12 @@ mod tests {
28,
"the pot submits all of vanilla's quads"
);
// A part the shader shades keeps its normal (#15322): `w` alone says cull.
assert!(
verts.iter().all(|v| v.normal == CULL_BACK),
"entitySolid culls the pot's back faces"
verts
.iter()
.all(|v| v.normal[3] == crate::mesher::SHADED_CULL_W && v.normal[..3] != [0; 3]),
"entitySolid culls the pot's back faces and shades it by its normals"
);
}
+164 -19
View File
@@ -4,6 +4,7 @@
use horizon_core::paletted::{PalettedContainer, Strategy};
use horizon_world::nbt::{Compound, Tag};
use horizon_world::registry::BlockStateId;
use std::sync::atomic::{AtomicI16, AtomicU64, Ordering};
use std::sync::{Arc, OnceLock};
pub mod block_entity;
@@ -457,6 +458,44 @@ impl Light {
}
}
}
/// One of a chunk's heightmaps (`Chunk::surface`): the heights, built whole on the first read,
/// and the columns a write may have moved since, each found again on its next read. Reads share
/// the chunk (helper threads read the world): a stale column is found the same by any of them.
#[derive(Default)]
struct Heights {
heights: OnceLock<Box<[AtomicI16; 256]>>,
stale: [AtomicU64; 4],
}
impl Heights {
/// `Heightmap.update`: a write two or more below column `i`'s height can't move it; any
/// other marks the column.
fn write(&mut self, i: usize, y: i32) {
let Some(heights) = self.heights.get_mut() else {
return;
};
let stale = self.stale[i >> 6].get_mut();
if *stale & 1 << (i & 63) == 0 && y >= i32::from(*heights[i].get_mut()) - 1 {
*stale |= 1 << (i & 63);
}
}
}
impl Clone for Heights {
fn clone(&self) -> Self {
let copy = Self::default();
// A reader can refresh a height and then clear its stale bit while we clone. Copy the
// bits first: a stale snapshot will rescan; a clean one acquires the refreshed height.
// Copying heights first could keep an old height with a newly cleared bit.
for (to, from) in copy.stale.iter().zip(&self.stale) {
to.store(from.load(Ordering::Acquire), Ordering::Relaxed);
}
if let Some(heights) = self.heights.get() {
let _ = copy.heights.set(Box::new(std::array::from_fn(|i| {
AtomicI16::new(heights[i].load(Ordering::Relaxed))
})));
}
copy
}
}
#[inline]
fn column(x: i32, z: i32) -> usize {
((z & 15) << 4 | (x & 15)) as usize
@@ -574,10 +613,10 @@ pub struct Chunk {
pub sections: Vec<Arc<Section>>,
/// `Heightmap.Types.WORLD_SURFACE` per column (`z << 4 | x`): one above
/// the highest non-air block, or the column's minimum Y. Built on the first read; a
/// `set` that can move a column's surface drops it.
surface: OnceLock<Box<[i16; 256]>>,
/// `set` that can move a column's surface marks that column, found again on its next read.
surface: Heights,
/// `Heightmap.Types.MOTION_BLOCKING` per column, kept like `surface`.
motion_blocking: OnceLock<Box<[i16; 256]>>,
motion_blocking: Heights,
pub lit: bool,
/// Light read from a save with `isLightOn`: no light job is needed.
pub light_loaded: bool,
@@ -618,8 +657,8 @@ impl Chunk {
z,
min_y: layout.min_y(),
sections: (0..layout.sections()).map(|_| empty.clone()).collect(),
surface: OnceLock::new(),
motion_blocking: OnceLock::new(),
surface: Heights::default(),
motion_blocking: Heights::default(),
lit: false,
light_loaded: false,
fresh: false,
@@ -698,12 +737,7 @@ impl Chunk {
// move it. Any other change rebuilds that map on the next read.
if previous != v {
for map in [&mut self.surface, &mut self.motion_blocking] {
if map
.get()
.is_some_and(|heights| y >= i32::from(heights[column(x, z)]) - 1)
{
*map = OnceLock::new();
}
map.write(column(x, z), y);
}
}
previous
@@ -714,24 +748,55 @@ impl Chunk {
let section = Arc::make_mut(&mut self.sections[s]);
section.blocks = Section::from_blocks(values);
section.compact();
self.surface = OnceLock::new();
self.motion_blocking = OnceLock::new();
self.surface = Heights::default();
self.motion_blocking = Heights::default();
}
/// `getHeight(Heightmap.Types.WORLD_SURFACE, x, z)` (`x`, `z` within the
/// chunk): one above the highest block that isn't `air`, or the column's
/// minimum Y.
/// Sections must change only through `set` once this has been read.
pub fn world_surface(&self, x: i32, z: i32, air: impl Fn(u32) -> bool) -> i32 {
let surface = self.surface.get_or_init(|| self.prime(|v| !air(v)));
i32::from(surface[column(x, z)])
self.height(&self.surface, x, z, |v| !air(v))
}
/// `getHeight(Heightmap.Types.MOTION_BLOCKING, x, z)` (`x`, `z` within
/// the chunk): one above the highest state where `blocks` (a motion
/// blocking block or a fluid), or the column's minimum Y. Kept as
/// `world_surface` is.
pub fn motion_blocking(&self, x: i32, z: i32, blocks: impl Fn(u32) -> bool) -> i32 {
let heights = self.motion_blocking.get_or_init(|| self.prime(blocks));
i32::from(heights[column(x, z)])
self.height(&self.motion_blocking, x, z, blocks)
}
/// One column's height in `map`: the whole map built on its first read, a column a write
/// marked found again down that column alone.
fn height(&self, map: &Heights, x: i32, z: i32, counts: impl Fn(u32) -> bool) -> i32 {
let heights = map.heights.get_or_init(|| {
let built = self.prime(&counts);
Box::new(std::array::from_fn(|i| AtomicI16::new(built[i])))
});
let i = column(x, z);
let (word, bit) = (i >> 6, 1u64 << (i & 63));
if map.stale[word].load(Ordering::Acquire) & bit != 0 {
let height = self.column_height(i, &counts);
heights[i].store(height, Ordering::Relaxed);
map.stale[word].fetch_and(!bit, Ordering::Release);
return i32::from(height);
}
i32::from(heights[i].load(Ordering::Relaxed))
}
/// [`Self::prime`] for column `i` alone.
fn column_height(&self, i: usize, counts: impl Fn(u32) -> bool) -> i16 {
for (s, section) in self.sections.iter().enumerate().rev() {
if section.blocks.uniform().is_some_and(|v| !counts(v)) {
continue;
}
let base = self.min_y + s as i32 * 16;
if let Some(dy) = (0..16)
.rev()
.find(|&dy| counts(section.blocks.get(dy << 8 | i)))
{
return (base + dy as i32 + 1) as i16;
}
}
self.min_y as i16
}
/// `Heightmap.primeHeightmaps`: each section from the top, skipping
/// those of one state that doesn't count, until every column is found.
@@ -797,7 +862,7 @@ impl Chunk {
+ self.sections.capacity() * std::mem::size_of::<Arc<Section>>();
census.heightmap_bytes += [&self.surface, &self.motion_blocking]
.iter()
.filter(|map| map.get().is_some())
.filter(|map| map.heights.get().is_some())
.count()
* std::mem::size_of::<[i16; 256]>();
for section in &self.sections {
@@ -1020,9 +1085,89 @@ mod tests {
let before = chunk.world_surface(3, 3, is_air);
chunk.set(3, MIN_Y + HEIGHT, 3, stone);
chunk.set(3, before - 1, 3, chunk.get(3, before - 1, 3));
assert!(chunk.surface.get().is_some());
assert!(chunk.surface.heights.get().is_some());
assert_eq!(chunk.world_surface(3, 3, |_| unreachable!()), before);
}
/// A write two below a column's height can't move it, so the next read asks about no block;
/// a write at or above it (placing on top, clearing the top block) is found again on the next
/// read by looking down that column alone, and the other columns keep their heights.
#[test]
fn a_write_finds_its_own_column_again() {
// Each column holds its own state (16 + i), so a read that looks at another column's
// blocks shows up as a state that isn't air or this column's.
let state = |i: usize| 16 + i as u32;
let mut chunk = Chunk::empty(0, 0);
for i in 0..256 {
for y in MIN_Y..MIN_Y + 20 + (i as i32 % 7) {
chunk.set(i as i32 & 15, y, i as i32 >> 4, state(i));
}
}
let seen = std::cell::RefCell::new(Vec::new());
let surface = |c: &Chunk, i: usize| {
c.world_surface(i as i32 & 15, i as i32 >> 4, |v| {
seen.borrow_mut().push(v);
v == 0
})
};
let heights: Vec<i32> = (0..256).map(|i| surface(&chunk, i)).collect();
assert_eq!(heights[0], MIN_Y + 20);
assert_eq!(heights[6], MIN_Y + 26);
seen.borrow_mut().clear();
let (column, x, z) = (0x35, 5, 3);
let top = heights[column];
// Two below the height: the column keeps its map entry.
chunk.set(x, top - 2, z, 0);
assert_eq!(surface(&chunk, column), top);
assert!(seen.borrow().is_empty());
chunk.set(x, top - 2, z, state(column));
// At the height: placed on top, found again down this column only.
chunk.set(x, top, z, state(column));
assert_eq!(surface(&chunk, column), top + 1);
assert!(!seen.borrow().is_empty());
assert!(seen.borrow().iter().all(|&v| v == 0 || v == state(column)));
seen.borrow_mut().clear();
assert_eq!(surface(&chunk, column), top + 1);
for i in (0..256).filter(|&i| i != column) {
assert_eq!(surface(&chunk, i), heights[i]);
}
assert!(
seen.borrow().is_empty(),
"a read found again an untouched column"
);
// One below the height: the top block cleared, the height drops by one.
chunk.set(x, top, z, 0);
assert_eq!(surface(&chunk, column), top);
assert!(seen.borrow().iter().all(|&v| v == 0 || v == state(column)));
}
/// Chunk snapshots can be cloned while helper threads refresh a stale height. No snapshot
/// may retain the old height with its stale bit cleared, for either map.
#[test]
fn cloning_during_a_height_refresh_keeps_the_new_height() {
for _ in 0..4096 {
let mut chunk = Chunk::empty(0, 0);
chunk.set(0, MIN_Y + 4, 0, 1);
assert_eq!(chunk.world_surface(0, 0, |v| v == 0), MIN_Y + 5);
assert_eq!(chunk.motion_blocking(0, 0, |v| v != 0), MIN_Y + 5);
chunk.set(0, MIN_Y + 5, 0, 1);
let ready = std::sync::Barrier::new(2);
std::thread::scope(|scope| {
scope.spawn(|| {
ready.wait();
assert_eq!(chunk.world_surface(0, 0, |v| v == 0), MIN_Y + 6);
assert_eq!(chunk.motion_blocking(0, 0, |v| v != 0), MIN_Y + 6);
});
ready.wait();
for _ in 0..4 {
let snapshot = chunk.clone();
assert_eq!(snapshot.world_surface(0, 0, |v| v == 0), MIN_Y + 6);
assert_eq!(snapshot.motion_blocking(0, 0, |v| v != 0), MIN_Y + 6);
}
});
}
}
/// MOTION_BLOCKING ignores states that don't count (air, grass) and
/// follows writes as the surface map does, both maps kept at once.
#[test]
@@ -4,11 +4,13 @@
//!
//! The pack's GLSL (26.3's `#include` or the older `#moj_import`, with explicit locations or
//! without) is written again as the Vulkan-flavoured GLSL 450 naga reads:
//! - GLSL vanilla's GL compiler takes and naga doesn't is rewritten first ([`glsl_fixups`]: a
//! helper taking the `sampler2D` it reads, an array sized by its initializer only);
//! - includes are inlined, each file once (as `GlslPreprocessor` imported them), from `include`;
//! - vanilla's uniform blocks get the bindings of [`BLOCKS`], and each of [`SAMPLERS`] is split
//! into a texture and a sampler object, combined again where the sampler is named;
//! - the vertex inputs become globals the caller's [`Glue`] fills before the pack's `main`
//! (renamed `hz_main`) runs, and `gl_VertexID` reads the glue's expression;
//! (renamed `hz_main`) runs, and `gl_VertexID` and `gl_VertexIndex` read the glue's expression;
//! - varyings get locations by name across the stages, the fragment output location 0.
use std::collections::HashSet;
@@ -18,6 +20,8 @@ use naga::ShaderStage;
use naga::front::glsl::{Frontend, Options};
use naga::valid::{Capabilities, ValidationFlags, Validator};
mod glsl_fixups;
/// Vanilla's uniform blocks (`shaders/include/*.glsl`) and their bindings in set 0.
pub const BLOCKS: [(&str, u32); 4] = [
("Globals", 0),
@@ -43,7 +47,7 @@ pub struct Glue<'a> {
pub setup: &'a str,
/// Statements after it, on `gl_Position`.
pub finish: &'a str,
/// The expression `gl_VertexID` reads.
/// The expression the pack's `gl_VertexID` and `gl_VertexIndex` read.
pub vertex_id: &'a str,
}
@@ -64,8 +68,8 @@ pub fn translate_core(
glue: &Glue,
) -> Result<CoreProgram, String> {
const CORE: &str = "minecraft:shaders/core/";
let vertex_text = strip_comments(&expand(vertex, CORE, include)?);
let fragment_text = strip_comments(&expand(fragment, CORE, include)?);
let vertex_text = glsl_fixups::fixups(&strip_comments(&expand(vertex, CORE, include)?));
let fragment_text = glsl_fixups::fixups(&strip_comments(&expand(fragment, CORE, include)?));
let mut names = Names::default();
let vertex_code = rewrite(&vertex_text, ShaderStage::Vertex, glue, &mut names)
.map_err(|e| format!("vertex shader: {e}"))?;
@@ -337,6 +341,8 @@ fn rewrite(
writeln!(out, "{ty} {name};").unwrap();
}
}
// Where the pack's own code starts: the glue above it is left as it is.
let pack_start = out.len();
let mut outputs: Vec<&str> = Vec::new();
for source_line in text.lines() {
for line in declaration_lines(source_line) {
@@ -462,11 +468,18 @@ fn rewrite(
if !vertex {
return Ok(out);
}
let mut code = replace_ident(
&rename_main(&out),
"gl_VertexID",
&format!("({})", glue.vertex_id),
// The pack's `gl_VertexID` and 26.3's `gl_VertexIndex` both mean vanilla's vertex number
// (four per quad, an `int`): both read the glue's expression, which for a pipeline that
// draws six vertices per quad turns its own `gl_VertexIndex` back into that number. Only the
// pack's code is rewritten, so the glue's expression keeps reading the real index.
let vertex_id = format!("({})", glue.vertex_id);
let pack = replace_ident(
&rename_main(&out[pack_start..]),
"gl_VertexIndex",
&vertex_id,
);
let pack = replace_ident(&pack, "gl_VertexID", &vertex_id);
let mut code = format!("{}{pack}", &out[..pack_start]);
write!(
code,
"void main() {{\n{}\nhz_main();\n{}\n}}\n",
@@ -633,6 +646,43 @@ mod tests {
assert_eq!(program.fragment.entry_points.len(), 1);
}
/// A 26.3 pack's `gl_VertexIndex` means vanilla's vertex number, four per quad. The text
/// pipeline draws six per glyph, so the pack's code must read the glue's `hz_vertex_id()`
/// (as `gl_VertexID` does), never the raw index.
#[test]
fn a_packs_gl_vertex_index_reads_the_glues_vertex_number() {
let vsh = "#version 330\n#include <minecraft:dynamictransforms.glsl>\n#include <minecraft:projection.glsl>\n\
in vec3 Position;\nin vec4 Color;\nin vec2 UV0;\nout vec4 vertexColor;\nout vec2 texCoord0;\n\
void main() {\n int corner = gl_VertexIndex % 4;\n\
gl_Position = ProjMat * ModelViewMat * vec4(Position + vec3(float(corner), 0.0, 0.0), 1.0);\n\
vertexColor = Color;\n texCoord0 = UV0;\n}\n";
let program = translate_core(vsh, VANILLA_FSH, &["IS_GUI"], &include, &GLUE).unwrap();
let module = &program.vertex;
let named = |name: &str| {
module
.functions
.iter()
.find(|(_, f)| f.name.as_deref() == Some(name))
.unwrap_or_else(|| panic!("no {name}"))
};
let (vertex_id, _) = named("hz_vertex_id");
let (_, pack_main) = named("hz_main");
fn calls(block: &naga::Block, target: naga::Handle<naga::Function>) -> bool {
block.iter().any(|statement| match statement {
naga::Statement::Call { function, .. } => *function == target,
naga::Statement::Block(inner) => calls(inner, target),
naga::Statement::If { accept, reject, .. } => {
calls(accept, target) || calls(reject, target)
}
naga::Statement::Loop {
body, continuing, ..
} => calls(body, target) || calls(continuing, target),
_ => false,
})
}
assert!(calls(&pack_main.body, vertex_id));
}
/// A 26.2-style pack shader as a HUD pack writes it: `#moj_import`, no locations, a flat
/// varying, `gl_VertexID`, `texelFetch` and `textureSize` on the glyph atlas, `GameTime`.
#[test]
@@ -0,0 +1,603 @@
//! GLSL that vanilla's GL compiler takes and naga's front end doesn't (#9386), rewritten into
//! what both take:
//! - a function with `sampler2D` parameters is copied once per set of samplers its callers pass
//! (all of them a shader's global samplers), the copy reading those samplers by name;
//! - an unsized array (`const float[] w = float[](...)`, `float w[] = ...`) takes the size its
//! initializer gives.
/// The longest shader rewritten (vanilla's, includes expanded, are a few KiB).
const MAX_LEN: usize = 64 * 1024;
/// The bytes the rewrite may scan for calls (the whole text per call it rewrites): thousands of
/// calls leave the text as it was rather than freeze a load.
const SCAN_BUDGET: usize = 64 << 20;
/// `text` with the rewrites, where they apply; anything they can't follow is left as it was.
pub(super) fn fixups(text: &str) -> String {
if text.len() > MAX_LEN {
return text.to_owned();
}
size_unsized_arrays(&specialize_sampler_params(text))
}
fn is_ident(c: char) -> bool {
c.is_ascii_alphanumeric() || c == '_'
}
/// A name a sampler can have (`sampler2D ;` gives none, `t[2]` isn't one).
fn is_name(s: &str) -> bool {
s.chars().all(is_ident) && s.starts_with(|c: char| !c.is_ascii_digit()) && s != "sampler2D"
}
/// The identifier that ends right before byte `end` (whitespace skipped), and where it starts.
fn ident_before(text: &str, end: usize) -> Option<(usize, &str)> {
let trimmed = text[..end].trim_end();
let start = trimmed
.char_indices()
.rev()
.take_while(|(_, c)| is_ident(*c))
.last()?
.0;
let ident = &trimmed[start..];
(!ident.is_empty() && !ident.starts_with(|c: char| c.is_ascii_digit()))
.then_some((start, ident))
}
/// For each byte of `text`, the byte of the bracket closing the one opening there (counting
/// that kind of bracket only), or `None`: one pass, so a scan asking for many stays linear.
fn closings(text: &str) -> Vec<Option<usize>> {
let mut out = vec![None; text.len()];
let mut open: [Vec<usize>; 3] = Default::default();
for (i, b) in text.bytes().enumerate() {
let (kind, opens) = match b {
b'(' => (0, true),
b')' => (0, false),
b'[' => (1, true),
b']' => (1, false),
b'{' => (2, true),
b'}' => (2, false),
_ => continue,
};
if opens {
open[kind].push(i);
} else if let Some(at) = open[kind].pop() {
out[at] = Some(i);
}
}
out
}
/// The comma-separated parts of `list` at its own nesting level.
fn split_top(list: &str) -> Vec<&str> {
let mut parts = Vec::new();
let (mut depth, mut start) = (0i32, 0);
for (i, c) in list.char_indices() {
match c {
'(' | '[' | '{' => depth += 1,
')' | ']' | '}' => depth -= 1,
',' if depth == 0 => {
parts.push(&list[start..i]);
start = i + 1;
}
_ => {}
}
}
if !list[start..].trim().is_empty() || !parts.is_empty() {
parts.push(&list[start..]);
}
parts
}
/// A function at the file's top level whose parameters include a `sampler2D`.
struct SamplerFn {
name: String,
/// The header from its return type to its `{` (or `;` for a prototype), and the body after.
start: usize,
end: usize,
params: Vec<String>,
/// The indices of the `sampler2D` parameters and their names.
samplers: Vec<(usize, String)>,
/// `None` for a prototype.
body: Option<String>,
return_type: String,
}
fn sampler_functions(text: &str) -> Vec<SamplerFn> {
let closing = closings(text);
let mut out = Vec::new();
let mut depth = 0i32;
let bytes = text.as_bytes();
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'{' => depth += 1,
b'}' => depth -= 1,
b'(' if depth == 0 => {
if let (Some((name_at, name)), Some(close)) = (ident_before(text, i), closing[i])
&& let Some((type_at, return_type)) = ident_before(text, name_at)
{
let params: Vec<String> = split_top(&text[i + 1..close])
.into_iter()
.map(|p| p.trim().to_owned())
.collect();
let samplers: Vec<(usize, String)> = params
.iter()
.enumerate()
.filter(|(_, p)| p.split_whitespace().any(|w| w == "sampler2D"))
.map(|(n, p)| (n, p.split_whitespace().last().unwrap_or("").to_owned()))
.collect();
// One without a name is left for naga to refuse.
if !samplers.iter().all(|(_, name)| is_name(name)) {
i = close + 1;
continue;
}
let after = text[close + 1..].trim_start();
let next = close + 1 + (text[close + 1..].len() - after.len());
if !samplers.is_empty() && (after.starts_with('{') || after.starts_with(';')) {
let (end, body) = if after.starts_with('{') {
let Some(body_end) = closing[next] else {
break;
};
(body_end + 1, Some(text[next..=body_end].to_owned()))
} else {
(next + 1, None)
};
out.push(SamplerFn {
name: name.to_owned(),
start: type_at,
end,
params,
samplers,
body,
return_type: return_type.to_owned(),
});
i = end;
continue;
}
}
// Any other header: no other one sits in its parentheses.
if let Some(close) = closing[i] {
i = close + 1;
continue;
}
}
_ => {}
}
i += 1;
}
out
}
/// The calls to `name` outside `skip` (its own header): each call's span and arguments. The
/// bytes read are added to `scanned`.
fn calls<'a>(
scanned: &std::cell::Cell<usize>,
text: &'a str,
name: &str,
skip: &[(usize, usize)],
) -> Vec<(usize, usize, Vec<&'a str>)> {
scanned.set(scanned.get() + text.len());
let closing = closings(text);
let mut out = Vec::new();
let mut from = 0;
while let Some(found) = text[from..].find(name) {
let at = from + found;
from = at + name.len();
let before = text[..at].chars().next_back();
let rest = &text[at + name.len()..];
let open = at + name.len() + (rest.len() - rest.trim_start().len());
if before.is_some_and(is_ident)
|| !text[open..].starts_with('(')
|| skip.iter().any(|(s, e)| (*s..*e).contains(&at))
{
continue;
}
let Some(close) = closing[open] else {
continue;
};
scanned.set(scanned.get() + close - open);
out.push((at, close + 1, split_top(&text[open + 1..close])));
}
out
}
/// Every `from` standing as an identifier on its own, but not a member after a `.` (a
/// parameter `s` leaves the swizzle `c.s` alone).
fn replace_ident(text: &str, from: &str, to: &str) -> String {
if from.is_empty() {
return text.to_owned();
}
let mut out = String::with_capacity(text.len());
let mut done = 0;
while let Some(found) = text[done..].find(from) {
let at = done + found;
// Read in the whole text: a match right after another (`ss`) is part of it.
let before = text[..at].trim_end().chars().next_back();
let joined = text[..at].chars().next_back().is_some_and(is_ident);
let after = text[at + from.len()..].chars().next();
out.push_str(&text[done..at]);
if joined || before == Some('.') || after.is_some_and(is_ident) {
out.push_str(from);
} else {
out.push_str(to);
}
done = at + from.len();
}
out.push_str(&text[done..]);
out
}
/// A body with its sampler parameters reading `chosen`, all at once: a parameter named like
/// another's sampler (`mix2(sampler2D Sampler0, sampler2D Sampler1)` called with them swapped)
/// still reads its own.
fn read_samplers(body: &str, params: &[(usize, String)], chosen: &[String]) -> String {
let mut body = body.to_owned();
for (n, (_, param)) in params.iter().enumerate() {
body = replace_ident(&body, param, &format!("hz_sampler_param_{n}"));
}
for (n, sampler) in chosen.iter().enumerate().take(params.len()) {
body = replace_ident(&body, &format!("hz_sampler_param_{n}"), sampler);
}
body
}
/// The byte after `uniform sampler2D <name>;`, where the shader declares that sampler.
fn sampler_declaration_end(text: &str, name: &str) -> Option<usize> {
let mut from = 0;
while let Some(found) = text[from..].find("sampler2D") {
let at = from + found;
from = at + "sampler2D".len();
let before = text[..at].trim_end();
if !before.ends_with("uniform")
|| before[..before.len() - 7]
.chars()
.next_back()
.is_some_and(is_ident)
{
continue;
}
let rest = &text[from..];
let ident = rest.trim_start();
let len = ident.chars().take_while(|c| is_ident(*c)).count();
if &ident[..len] != name {
continue;
}
let after = ident[len..].trim_start();
if after.starts_with(';') {
return Some(text.len() - after.len() + 1);
}
}
None
}
/// The samplers a call passes for `f`'s `sampler2D` parameters, in order.
fn chosen_samplers(f: &SamplerFn, args: &[&str]) -> Vec<String> {
f.samplers
.iter()
.filter_map(|(n, _)| args.get(*n).map(|a| a.trim().to_owned()))
.collect()
}
/// One pass per function with `sampler2D` parameters, until none is left or one can't be
/// followed (a caller passes something that is no sampler's name).
fn specialize_sampler_params(original: &str) -> String {
let mut text = original.to_owned();
// Bytes scanned for calls so far, against [`SCAN_BUDGET`].
let scanned = std::cell::Cell::new(0usize);
let spent = || scanned.get() > SCAN_BUDGET;
for _ in 0..16 {
let functions = sampler_functions(&text);
// From the outside in: no call inside another function still to be copied.
let inside_another = |f: &SamplerFn, at: usize| {
functions
.iter()
.any(|g| g.name != f.name && g.body.is_some() && (g.start..g.end).contains(&at))
};
let spans_of = |f: &SamplerFn| -> Vec<(usize, usize)> {
functions
.iter()
.filter(|g| g.name == f.name)
.map(|g| (g.start, g.end))
.collect()
};
let Some(f) = functions.iter().find(|f| {
f.body.is_some()
&& !spent()
&& calls(&scanned, &text, &f.name, &spans_of(f))
.iter()
.all(|(at, ..)| !inside_another(f, *at))
}) else {
if spent() {
break;
}
// None left to copy.
return text;
};
let spans = spans_of(f);
let found = calls(&scanned, &text, &f.name, &spans);
// Each call's samplers must be plain names (a global's, or a later pass's parameter).
let mut variants: Vec<Vec<String>> = Vec::new();
let mut call_starts: Vec<usize> = Vec::new();
let mut followable = true;
for (at, _, args) in &found {
let chosen = chosen_samplers(f, args);
if chosen.len() != f.samplers.len() || !chosen.iter().all(|a| is_name(a)) {
followable = false;
break;
}
call_starts.push(*at);
if !variants.contains(&chosen) {
variants.push(chosen);
}
if variants.len() > 64 {
followable = false;
break;
}
}
// Overloads (`tap(sampler2D, vec2)` and `tap(sampler2D, vec3)`) each keep their own
// body; a call picks its samplers by position, so they must sit at the same places.
let same_places = functions.iter().filter(|g| g.name == f.name).all(|g| {
g.samplers
.iter()
.map(|s| s.0)
.eq(f.samplers.iter().map(|s| s.0))
});
if !followable || !same_places || spent() {
break;
}
// A body may call another overload of itself with its own sampler (vanilla's
// `sampleNearest` calls the six-argument one): once its sampler parameters read a
// sampler, those calls name that copy too, and their sets join the variants.
let rewrite_own_calls = |body: &str| -> Option<(String, Vec<Vec<String>>)> {
let mut body = body.to_owned();
let mut found = Vec::new();
let mut starts: Vec<usize> = calls(&scanned, &body, &f.name, &[])
.iter()
.map(|c| c.0)
.collect();
starts.sort_unstable_by(|a, b| b.cmp(a));
for start in starts {
if spent() {
return None;
}
let (end, chosen, with) = {
let (_, end, args) = calls(&scanned, &body, &f.name, &[])
.into_iter()
.find(|c| c.0 == start)?;
let chosen = chosen_samplers(f, &args);
let rest: Vec<&str> = args
.iter()
.enumerate()
.filter(|(n, _)| !f.samplers.iter().any(|(s, _)| s == n))
.map(|(_, a)| a.trim())
.collect();
let with = format!("{}_hz_{}({})", f.name, chosen.join("_"), rest.join(", "));
(end, chosen, with)
};
if chosen.len() != f.samplers.len() || !chosen.iter().all(|a| is_name(a)) {
return None;
}
found.push(chosen);
body.replace_range(start..end, &with);
}
Some((body, found))
};
let specialize = |g: &SamplerFn, chosen: &[String]| {
let body = read_samplers(g.body.as_deref().unwrap_or_default(), &g.samplers, chosen);
rewrite_own_calls(&body)
};
let same: Vec<&SamplerFn> = functions.iter().filter(|g| g.name == f.name).collect();
let mut next = 0;
while followable && next < variants.len() {
let chosen = variants[next].clone();
for g in same.iter().filter(|g| g.body.is_some()) {
match specialize(g, &chosen) {
Some((_, inner)) => {
for v in inner {
if !variants.contains(&v) {
variants.push(v);
}
}
}
None => followable = false,
}
}
next += 1;
followable &= variants.len() <= 64 && !spent();
}
if !followable {
break;
}
// One copy of an overload per set of samplers its callers pass, keeping its other
// parameters; a prototype becomes the copies' prototypes.
let signature = |g: &SamplerFn, chosen: &[String]| {
let params: Vec<&str> = g
.params
.iter()
.enumerate()
.filter(|(n, _)| !g.samplers.iter().any(|(s, _)| s == n))
.map(|(_, p)| p.as_str())
.collect();
format!(
"{} {}_hz_{}({})",
g.return_type,
g.name,
chosen.join("_"),
params.join(", ")
)
};
let copies_of = |g: &SamplerFn| -> String {
variants
.iter()
.map(|chosen| match &g.body {
Some(_) => {
let body = specialize(g, chosen).map(|(b, _)| b).unwrap_or_default();
format!("{} {body}\n", signature(g, chosen))
}
None => format!("{};\n", signature(g, chosen)),
})
.collect()
};
// Rebuild from the back: the calls, then the function's own spans (each prototype and
// definition becomes its copies). A call nested in another's arguments starts after it,
// so it is rewritten first, and the call around it is read again from the text as it is
// then. A copy reads its samplers by name, so definitions above their samplers'
// declaration (vanilla's `sample_lightmap.glsl` is included above `uniform sampler2D
// Sampler2;`) move after it, together and in their order, as long as no call comes first.
let first_call = call_starts.iter().copied().min().unwrap_or(usize::MAX);
let declared = variants
.iter()
.flatten()
.filter_map(|s| sampler_declaration_end(&text, s))
.max();
let mut edits: Vec<(usize, Option<(usize, String)>)> =
call_starts.into_iter().map(|at| (at, None)).collect();
let (mut moved, mut move_to) = (String::new(), None);
for g in &same {
match (
&g.body,
declared.filter(|&end| end > g.start && end <= first_call),
) {
(Some(_), Some(end)) => {
edits.push((g.start, Some((g.end, String::new()))));
moved.push_str(&copies_of(g));
move_to = Some(end);
}
_ => edits.push((g.start, Some((g.end, copies_of(g))))),
}
}
if let Some(end) = move_to {
edits.push((end, Some((end, format!("\n{moved}")))));
}
// Pack text is untrusted: edits that overlap, or off the text, leave it as it was.
let mut spans: Vec<(usize, usize)> = edits
.iter()
.filter_map(|(s, e)| e.as_ref().map(|(end, _)| (*s, *end)))
.collect();
spans.sort_unstable();
if spans.windows(2).any(|w| w[1].0 < w[0].1) {
break;
}
edits.sort_by_key(|(start, _)| std::cmp::Reverse(*start));
let mut sound = true;
for (start, edit) in edits {
let (end, with) = match edit {
Some(span) => span,
None => {
let found = calls(&scanned, &text, &f.name, &[])
.into_iter()
.find(|c| c.0 == start);
let Some((_, end, args)) = found else {
continue;
};
let rest: Vec<&str> = args
.iter()
.enumerate()
.filter(|(n, _)| !f.samplers.iter().any(|(s, _)| s == n))
.map(|(_, a)| a.trim())
.collect();
let copy = format!("{}_hz_{}", f.name, chosen_samplers(f, &args).join("_"));
(end, format!("{copy}({})", rest.join(", ")))
}
};
if start > end || text.get(start..end).is_none() || spent() {
sound = false;
break;
}
text.replace_range(start..end, &with);
}
if !sound {
break;
}
}
// One that couldn't be followed: the shader as written, never half rewritten.
original.to_owned()
}
/// Each array declared without a size that an initializer sizes: `T[] name = T[](a, b)` and
/// `T name[] = T[](a, b)` (or `T[2](...)`) are written with the size.
fn size_unsized_arrays(text: &str) -> String {
let mut text = text.to_owned();
let mut closing = closings(&text);
// Each array sized reads the text's brackets again: a few hundred are plenty.
let mut left = 256;
let mut from = 0;
while let Some(found) = text[from..].find('=') {
let eq = from + found;
from = eq + 1;
// `==`, `<=`, `>=`, `!=`, `+=` ... are no initializers.
let prev = text[..eq].chars().next_back();
let next = text[eq + 1..].chars().next();
if matches!(
prev,
Some('=' | '<' | '>' | '!' | '+' | '-' | '*' | '/' | '%' | '&' | '|' | '^')
) || next == Some('=')
{
continue;
}
// The constructor: `T [ n? ] (`.
let rest = &text[eq + 1..];
let ty_start = eq + 1 + (rest.len() - rest.trim_start().len());
let ty_len = text[ty_start..]
.chars()
.take_while(|c| is_ident(*c))
.count();
if ty_len == 0 {
continue;
}
let after_ty = &text[ty_start + ty_len..];
let bracket = ty_start + ty_len + (after_ty.len() - after_ty.trim_start().len());
if !text[bracket..].starts_with('[') {
continue;
}
let Some(bracket_end) = closing[bracket] else {
continue;
};
let after_bracket = &text[bracket_end + 1..];
let paren = bracket_end + 1 + (after_bracket.len() - after_bracket.trim_start().len());
if !text[paren..].starts_with('(') {
continue;
}
let Some(paren_end) = closing[paren] else {
continue;
};
// The declarator: `name []` or `T [] name`, between the statement's start and `=`.
// (Looked for on the line, at most 256 bytes back, so a long line stays linear.)
let mut back = eq.saturating_sub(256);
while !text.is_char_boundary(back) {
back += 1;
}
let statement_start = text[back..eq]
.rfind([';', '{', '}', '\n'])
.map_or(back, |i| back + i + 1);
let declarator = &text[statement_start..eq];
let Some(empty) = declarator.rfind('[') else {
continue;
};
let inside = &declarator[empty + 1..];
let Some(close) = inside.find(']') else {
continue;
};
if !inside[..close].trim().is_empty() {
continue;
}
let size = split_top(&text[paren + 1..paren_end]).len().to_string();
// Sized in the constructor too, where it isn't.
if text[bracket + 1..bracket_end].trim().is_empty() {
text.replace_range(bracket + 1..bracket_end, &size);
}
let at = statement_start + empty + 1;
text.replace_range(at..at + close, &size);
// The declarator is before `=`, so `=` moved by what its size changed.
from = eq + 1 + size.len() - close;
closing = closings(&text);
left -= 1;
if left == 0 {
break;
}
}
text
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,291 @@
//! The rewrites on GLSL a pack writes, with the text each gives written out by hand.
use super::*;
#[test]
fn a_sampler_parameter_becomes_a_copy_per_sampler_passed() {
let text = "vec4 blur(sampler2D tex, vec2 uv);\n\
vec4 blur(sampler2D tex, vec2 uv) { return texture(tex, uv) + texture(tex, uv * 2.0); }\n\
void main() { vec4 a = blur(Sampler0, uv); vec4 b = blur( Sampler1 , uv + 1.0); vec4 c = blur(Sampler0, uv); }\n";
let out = specialize_sampler_params(text);
assert!(!out.contains("sampler2D"), "{out}");
assert!(out.contains(
"vec4 blur_hz_Sampler0(vec2 uv) { return texture(Sampler0, uv) + texture(Sampler0, uv * 2.0); }"
));
assert!(out.contains("vec4 blur_hz_Sampler1(vec2 uv)"));
assert!(out.contains("blur_hz_Sampler0(uv)"));
assert!(out.contains("blur_hz_Sampler1(uv + 1.0)"));
// One copy per sampler, not per call (and its prototype, from the prototype above).
assert_eq!(out.matches("vec4 blur_hz_Sampler0(vec2 uv) {").count(), 1);
assert_eq!(out.matches("vec4 blur_hz_Sampler0(vec2 uv);").count(), 1);
}
#[test]
fn a_sampler_passed_on_through_another_function_is_followed() {
let text = "vec4 inner(sampler2D t, vec2 uv) { return texture(t, uv); }\n\
vec4 outer(vec2 uv, sampler2D s) { return inner(s, uv); }\n\
void main() { vec4 a = outer(uv, Sampler2); }\n";
let out = specialize_sampler_params(text);
assert!(!out.contains("sampler2D"), "{out}");
assert!(out.contains("outer_hz_Sampler2(uv)"), "{out}");
assert!(out.contains("inner_hz_Sampler2(uv)"), "{out}");
}
/// A call in another call's arguments is rewritten before the call around it, which is
/// then read again: neither edit lands on the other's old text.
#[test]
fn a_call_nested_in_another_calls_arguments_is_rewritten_inside_out() {
let text = "vec2 tap(sampler2D s, vec2 uv) { return texture(s, uv).xy; }\n\
void main() { vec2 a = tap(Sampler0, tap(Sampler0, uv));\n\
vec2 b = tap( Sampler1 , tap( Sampler0 , uv ) ); }\n";
let out = specialize_sampler_params(text);
assert!(!out.contains("sampler2D") && !out.contains("tap("), "{out}");
assert!(
out.contains("vec2 a = tap_hz_Sampler0(tap_hz_Sampler0(uv));"),
"{out}"
);
assert!(
out.contains("vec2 b = tap_hz_Sampler1(tap_hz_Sampler0(uv)); }"),
"{out}"
);
assert!(out.contains("vec2 tap_hz_Sampler0(vec2 uv) { return texture(Sampler0, uv).xy; }"));
assert!(out.contains("vec2 tap_hz_Sampler1(vec2 uv) { return texture(Sampler1, uv).xy; }"));
}
/// Overloads that differ past their sampler each keep their own body in their copies.
#[test]
fn overloads_with_a_sampler_each_keep_their_own_body() {
let text = "vec4 tap(sampler2D s, vec2 p) { return texture(s, p); }\n\
vec4 tap(sampler2D s, vec3 p) { return texture(s, p.xy / p.z); }\n\
void main() { vec4 a = tap(Sampler0, uv); vec4 b = tap(Sampler0, vec3(uv, 2.0)); }\n";
let out = specialize_sampler_params(text);
assert!(!out.contains("sampler2D"), "{out}");
assert!(
out.contains("vec4 tap_hz_Sampler0(vec2 p) { return texture(Sampler0, p); }"),
"{out}"
);
assert!(
out.contains("vec4 tap_hz_Sampler0(vec3 p) { return texture(Sampler0, p.xy / p.z); }"),
"{out}"
);
assert_eq!(out.matches("vec4 tap_hz_Sampler0(").count(), 2, "{out}");
assert!(out.contains("vec4 a = tap_hz_Sampler0(uv);"), "{out}");
assert!(
out.contains("vec4 b = tap_hz_Sampler0(vec3(uv, 2.0));"),
"{out}"
);
}
/// A copy reads its samplers by name, so it goes after a sampler declared below the helper
/// (vanilla's world `text.vsh` includes `sample_lightmap.glsl` above `Sampler2`).
#[test]
fn a_copy_goes_after_its_samplers_declaration() {
let text = "vec4 tap(sampler2D s, vec2 uv) { return texture(s, uv); }\n\
uniform sampler2D Sampler2;\n\
void main() { vec4 c = tap(Sampler2, vec2(0.5)); }\n";
let out = specialize_sampler_params(text);
let declared = out
.find("uniform sampler2D Sampler2;")
.expect("declaration kept");
let copy = out
.find("vec4 tap_hz_Sampler2(vec2 uv)")
.expect("copy written");
let call = out
.find("tap_hz_Sampler2(vec2(0.5))")
.expect("call rewritten");
assert!(declared < copy && copy < call, "{out}");
assert_eq!(out.matches("tap_hz_Sampler2(vec2 uv)").count(), 1, "{out}");
assert!(!out.contains("tap(sampler2D"), "{out}");
}
/// A pack's text is untrusted: a sampler declared inside the helper itself (and text that
/// isn't ASCII) gives edits that would overlap, so the shader is left as it is for the
/// translator to refuse: never a panic, never a half-rewritten shader.
#[test]
fn a_sampler_declared_inside_the_helper_never_panics() {
let text = "vec4 tap(sampler2D s, vec2 uv) { é uniform sampler2D Sampler0; x return texture(s, uv); ééé }\n\
void main() { fragColor = tap(Sampler0, vec2(0.0)); }\n";
let out = specialize_sampler_params(text);
assert_eq!(out, text);
}
/// Vanilla's `texture_sampling.glsl` shape: one overload calls the other with its own
/// sampler. That call is rewritten too, and both copies come after the sampler's declaration
/// in their own order, the called one first.
#[test]
fn an_overload_passing_its_sampler_to_another_is_followed() {
let text = "vec4 sampleNearest(sampler2D source, vec2 uv, vec2 pixelSize, vec2 du, vec2 dv, vec2 texelSize) { return texture(source, uv + du + dv); }\n\
vec4 sampleNearest(sampler2D source, vec2 uv, vec2 pixelSize) { return sampleNearest(source, uv, pixelSize, dFdx(uv), dFdy(uv), pixelSize); }\n\
uniform sampler2D Sampler0;\n\
void main() { vec4 c = sampleNearest(Sampler0, uv, px); }\n";
let out = specialize_sampler_params(text);
assert!(
!out.contains("sampleNearest(") && !out.contains("sampler2D source"),
"{out}"
);
let declared = out
.find("uniform sampler2D Sampler0;")
.expect("declaration");
let six = out
.find("vec4 sampleNearest_hz_Sampler0(vec2 uv, vec2 pixelSize, vec2 du, vec2 dv, vec2 texelSize)")
.expect("six-argument copy");
let three = out
.find("vec4 sampleNearest_hz_Sampler0(vec2 uv, vec2 pixelSize) { return sampleNearest_hz_Sampler0(uv, pixelSize, dFdx(uv), dFdy(uv), pixelSize); }")
.expect("three-argument copy calling the six-argument one");
let call = out
.find("vec4 c = sampleNearest_hz_Sampler0(uv, px);")
.expect("call");
assert!(declared < six && six < three && three < call, "{out}");
}
/// A prototype above `main` with the definition below it becomes the copies' prototypes, so
/// the call still has a declaration above it.
#[test]
fn a_prototype_becomes_the_copies_prototypes() {
let text = "uniform sampler2D Sampler0;\n\
vec4 tap(sampler2D s, vec2 uv);\n\
void main() { vec4 c = tap(Sampler0, uv); }\n\
vec4 tap(sampler2D s, vec2 uv) { return texture(s, uv); }\n";
let out = specialize_sampler_params(text);
let prototype = out
.find("vec4 tap_hz_Sampler0(vec2 uv);")
.expect("prototype copy");
let call = out.find("tap_hz_Sampler0(uv); }").expect("call");
let definition = out
.find("vec4 tap_hz_Sampler0(vec2 uv) { return texture(Sampler0, uv); }")
.expect("definition copy");
assert!(prototype < call && call < definition, "{out}");
}
/// Parameters named like the samplers they are passed, swapped, still read their own; and a
/// parameter `s` leaves the swizzle `c.s` alone.
#[test]
fn sampler_parameters_are_replaced_at_once_and_not_as_members() {
let text = "uniform sampler2D Sampler0;\nuniform sampler2D Sampler1;\n\
vec4 mix2(sampler2D Sampler0, sampler2D Sampler1, vec2 uv) { return texture(Sampler0, uv) - texture(Sampler1, uv); }\n\
vec4 swz(sampler2D s, vec2 uv) { vec4 c = texture(s, uv); return vec4(c.s, c.t, 0.0, 1.0); }\n\
void main() { vec4 a = mix2(Sampler1, Sampler0, uv) + swz(Sampler0, uv); }\n";
let out = specialize_sampler_params(text);
assert!(
out.contains("{ return texture(Sampler1, uv) - texture(Sampler0, uv); }"),
"{out}"
);
assert!(
out.contains("{ vec4 c = texture(Sampler0, uv); return vec4(c.s, c.t, 0.0, 1.0); }"),
"{out}"
);
}
#[test]
fn a_parameter_matched_twice_in_one_identifier_stays_whole() {
assert_eq!(replace_ident("c.ss + ss * s", "s", "t"), "c.ss + ss * t");
assert_eq!(replace_ident("brightness(s)", "s", "t"), "brightness(t)");
}
/// `fsh` translated beside a vertex shader that passes its position on.
fn translate(fsh: &str) -> Result<crate::core_shader::CoreProgram, String> {
let vsh = "#version 330\nin vec3 Position;\nout vec2 texCoord0;\n\
void main() {\n gl_Position = vec4(Position, 1.0);\n texCoord0 = Position.xy;\n}\n";
let glue = crate::core_shader::Glue {
inputs: &[("Position", "vec3")],
prelude: "layout(location = 0) in vec3 hz_position;",
setup: "Position = hz_position;",
finish: "",
vertex_id: "int(gl_VertexIndex)",
};
crate::core_shader::translate_core(vsh, fsh, &[], &|_| None, &glue)
}
/// #9386 end to end: naga refuses a helper taking the sampler it reads, as GLSL vanilla's GL
/// compiler takes it; the pack's shader translates, the helper a copy reading `Sampler0`.
#[test]
fn a_helper_taking_its_sampler_translates() {
let fsh = "#version 330\nuniform sampler2D Sampler0;\nin vec2 texCoord0;\nout vec4 fragColor;\n\
vec4 tap(sampler2D tex, vec2 at, float w) { return texture(tex, at) * w; }\n\
void main() {\n fragColor = tap(Sampler0, texCoord0, 0.5) + tap(Sampler0, texCoord0 + 0.01, 0.5);\n}\n";
let program = translate(fsh).unwrap();
let names: Vec<_> = program
.fragment
.functions
.iter()
.filter_map(|(_, f)| f.name.as_deref())
.collect();
assert!(names.contains(&"tap_hz_Sampler0"), "{names:?}");
assert!(!names.contains(&"tap"), "{names:?}");
}
/// A sampler parameter with no name used to hand `replace_ident` an empty name to replace,
/// which it found at the same place forever, growing the text until the game ran out of memory.
#[test]
fn a_sampler_parameter_without_a_name_is_left_as_it_was() {
let text = "uniform sampler2D Sampler0;\n\
vec4 tap(sampler2D ;, vec2 uv) { return texture(Sampler0, uv); }\n\
void main() { vec4 c = tap(Sampler0, uv); }\n";
assert_eq!(fixups(text), text);
assert_eq!(replace_ident("abc", "", "x"), "abc");
}
/// Each call rewritten reads the whole text again: thousands of them stop at the scan budget,
/// the shader left as it was (never half rewritten) instead of freezing the load.
#[test]
fn thousands_of_calls_are_left_as_they_were() {
let shader = format!(
"uniform sampler2D S;\nvec4 t(sampler2D s) {{ return texture(s, vec2(0.0)); }}\n\
void main() {{ vec4 c; {}}}\n",
"c = t(S);".repeat(5000)
);
assert_eq!(fixups(&shader), shader);
// A few of them are rewritten.
let few = shader.replace(&"c = t(S);".repeat(5000), &"c = t(S);".repeat(3));
assert_eq!(
fixups(&few).matches("c = t_hz_S(").count(),
3,
"{}",
fixups(&few)
);
}
#[test]
fn unsized_arrays_take_their_initializers_size() {
let text = "const float[] w = float[](0.25, 0.5, 0.25);\n\
const vec2 offsets[] = vec2[](vec2(1.0, 0.0), vec2(0.0, 1.0));\n\
float k[] = float[4](1.0, 2.0, 3.0, 4.0);\n\
bool same = a == b;\n";
let out = size_unsized_arrays(text);
assert!(
out.contains("const float[3] w = float[3](0.25, 0.5, 0.25);"),
"{out}"
);
assert!(
out.contains("const vec2 offsets[2] = vec2[2](vec2(1.0, 0.0), vec2(0.0, 1.0));"),
"{out}"
);
assert!(
out.contains("float k[4] = float[4](1.0, 2.0, 3.0, 4.0);"),
"{out}"
);
assert!(out.contains("bool same = a == b;"));
}
#[test]
fn a_wide_character_after_a_sized_array_never_panics() {
// Ten values make the size two digits, which moves the `=` two bytes right: the scan
// went on from where it used to be, inside the `é`.
let text = "float w[]\u{e9}= float[](0.,1.,2.,3.,4.,5.,6.,7.,8.,9.);\n";
assert_eq!(
size_unsized_arrays(text),
"float w[10]\u{e9}= float[10](0.,1.,2.,3.,4.,5.,6.,7.,8.,9.);\n"
);
}
/// #9386 end to end: naga refuses an array sized by its initializer only.
#[test]
fn an_array_sized_by_its_initializer_translates() {
let fsh = "#version 330\nuniform sampler2D Sampler0;\nin vec2 texCoord0;\nout vec4 fragColor;\n\
const float[] weights = float[](0.25, 0.5, 0.25);\n\
void main() {\n vec4 sum = vec4(0.0);\n\
for (int i = 0; i < 3; i++) {\n sum += texture(Sampler0, texCoord0 + float(i) * 0.01) * weights[i];\n }\n\
fragColor = sum;\n}\n";
let program = translate(fsh);
assert!(program.is_ok(), "{:?}", program.err());
}
+19 -6
View File
@@ -439,20 +439,33 @@ fn deliver(home: &Path, report: Report) -> Option<PathBuf> {
}
}
/// The report for a hung server tick, written by the watchdog thread
/// (`ServerWatchdog`): the stuck thread's stack is not available to Rust, so
/// the report carries none.
pub fn report_watchdog(home: &Path, side: &'static str, message: String) -> Option<PathBuf> {
/// The report for a hung server tick, written by the watchdog thread (`ServerWatchdog`): the
/// stack is the server thread's, read from the watchdog (`horizon_threads::stack_dump`), as
/// vanilla's report carries the server thread's stack; without it, the details say why.
pub fn report_watchdog(
home: &Path,
side: &'static str,
message: String,
server_stack: Result<String, horizon_threads::stack_dump::NoStack>,
) -> Option<PathBuf> {
let mut details = system_details(side);
let stack = match server_stack {
Ok(stack) => stack_lines(&stack, false),
Err(why) => {
details.push(("Server thread stack".into(), why.to_string()));
String::new()
}
};
deliver(
home,
Report {
time: Local::now(),
description: "Watching Server".into(),
error: format!("{message} (Watching Server)"),
stack: String::new(),
stack,
thread: thread_name(side),
side,
details: system_details(side),
details,
log: crate::logging::recent_lines(),
},
)
+2 -1
View File
@@ -155,7 +155,8 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
var back = c;
// Normal w: 0 keeps the baked colour, +-127 shades by the normal, and a small positive 1
// keeps the baked colour but culls back faces (entitySolid models such as the decorated pot).
// -126/127 shades like -127 and also culls (entityCutoutCull models such as the bat).
// -126/127 shades like -127 and also culls (entityCutoutCull models such as the bat, and the
// entitySolid decorated pot).
out.cull = select(0u, 1u, (normal.w > 0.0 && normal.w < 0.5) || (normal.w < 0.0 && normal.w > -0.996));
if abs(normal.w) >= 0.5 {
c = vec4(c.rgb * entity_light(normal.xyz, globals.time.y != 0.0), c.a);
+48 -5
View File
@@ -37,7 +37,9 @@
//! are drawn in a pass of their own between the phases, which reads phase
//! 1's depth as a texture and makes the same comparison in the shader: the
//! fragment's depth against the stored one, greater or equal passing. The
//! same fragments pass, so the same sections are seen.
//! same fragments pass, so the same sections are seen. Adreno's Vulkan driver
//! takes that path too: with the early test it now and then leaves sections
//! out for a frame.
use std::num::NonZeroU64;
use std::sync::{
Arc,
@@ -220,17 +222,42 @@ const RESULTS_USAGE: wgpu::BufferUsages =
const READBACK_USAGE: wgpu::BufferUsages =
wgpu::BufferUsages::MAP_READ.union(wgpu::BufferUsages::COPY_DST);
/// The first `n` values of a packed lookup in WGSL `source` (`fn name`, as cull.wgsl's
/// `box_triangle_corner`): four bits each, eight a word, its words in the order of its cases.
#[cfg(test)]
pub(super) fn packed_lookup(source: &str, name: &str, n: usize) -> Vec<u32> {
let body = &source[source.find(&format!("fn {name}(")).expect("the lookup")..];
let body = &body[..body.find("return").expect("its return")];
let words: Vec<u32> = body
.split(|c: char| !c.is_ascii_alphanumeric())
.filter_map(|t| t.strip_prefix("0x")?.strip_suffix('u'))
.map(|hex| u32::from_str_radix(hex, 16).expect("a hex word"))
.collect();
(0..n)
.map(|v| (words[v / 8] >> (v % 8 * 4)) & 0xf)
.collect()
}
/// Qualcomm's PCI vendor id, which wgpu reports for Adreno GPUs.
const ADRENO: u32 = 0x5143;
impl Cull {
/// The device feature culling needs, where the adapter has it.
pub fn features(adapter: &wgpu::Adapter) -> wgpu::Features {
adapter.features() & wgpu::Features::SHADER_EARLY_DEPTH_TEST
}
/// Where the device can run the test, if it can: Vulkan with early
/// depth tests, or Metal and DirectX 12 with the test in the shader, all
/// drawing an indirect multi-draw's entries with the instance counts the
/// GPU wrote, with fragment shaders that write storage.
/// depth tests (Adreno's in the shader), or Metal and DirectX 12 with the
/// test in the shader, all drawing an indirect multi-draw's entries with
/// the instance counts the GPU wrote, with fragment shaders that write
/// storage.
pub fn supported(adapter: &wgpu::Adapter, device: &wgpu::Device) -> Option<DepthTest> {
let depth_test = match adapter.get_info().backend {
let info = adapter.get_info();
let depth_test = match info.backend {
// Adreno's Vulkan driver (the AYANEO Pocket DS's Adreno 740, build 0676.41) now and
// then leaves sections out with the early test, the world flashing black or
// see-through as a block breaks; the test in the shader, as on Metal, holds.
wgpu::Backend::Vulkan if info.vendor == ADRENO => DepthTest::InShader,
wgpu::Backend::Vulkan | wgpu::Backend::Gl
if device
.features()
@@ -772,4 +799,20 @@ mod tests {
.unwrap_or_else(|e| panic!("{depth_test:?}: {e:?}"));
}
}
/// The box's 12 triangles, as cull.wgsl and gpu_occlusion.wgsl draw them: two per face,
/// counter-clockwise seen from outside, corners by bit (x 1, y 2, z 4).
#[test]
fn the_packed_box_corners_are_the_box_triangles() {
let triangles = [
0, 6, 2, 0, 4, 6, 1, 3, 7, 1, 7, 5, 0, 1, 5, 0, 5, 4, 2, 7, 3, 2, 6, 7, 0, 3, 1, 0, 2,
3, 4, 5, 7, 4, 7, 6,
];
for source in [
include_str!("cull.wgsl"),
include_str!("gpu_occlusion.wgsl"),
] {
assert_eq!(packed_lookup(source, "box_triangle_corner", 36), triangles);
}
}
}
+16 -6
View File
@@ -34,11 +34,21 @@ struct Test {
@group(0) @binding(5) var<uniform> params: Params;
// The corners (bit 0: x, bit 1: y, bit 2: z) of the box's 12 triangles,
// counter-clockwise seen from outside.
const CORNERS = array<u32, 36>(
0u, 6u, 2u, 0u, 4u, 6u, 1u, 3u, 7u, 1u, 7u, 5u, 0u, 1u, 5u, 0u, 5u, 4u,
2u, 7u, 3u, 2u, 6u, 7u, 0u, 3u, 1u, 0u, 2u, 3u, 4u, 5u, 7u, 4u, 7u, 6u,
);
// counter-clockwise seen from outside: 0 6 2, 0 4 6, 1 3 7, 1 7 5, 0 1 5, 0 5 4,
// 2 7 3, 2 6 7, 0 3 1, 0 2 3, 4 5 7, 4 7 6.
// Packed four bits each, eight a word, read through a switch: a constant array indexed by the
// vertex would be a private array, which Adreno's Vulkan compiler (build 0676.41) fails to link.
fn box_triangle_corner(v: u32) -> u32 {
var word: u32;
switch v / 8u {
case 0u: { word = 0x31640260u; }
case 1u: { word = 0x05105717u; }
case 2u: { word = 0x76237245u; }
case 3u: { word = 0x54320130u; }
default: { word = 0x00006747u; }
}
return (word >> (v % 8u * 4u)) & 0xfu;
}
struct Box {
@builtin(position) position: vec4<f32>,
@@ -95,7 +105,7 @@ fn vs_box(@builtin(vertex_index) v: u32, @builtin(instance_index) t: u32) -> Box
out.position = select(vec4(0.0, 0.0, 0.0, 1.0), vec4(p, 1.0, 1.0), v < 3u);
return out;
}
out.position = globals.view_proj * vec4(corner(origin, low, high, CORNERS[v]), 1.0);
out.position = globals.view_proj * vec4(corner(origin, low, high, box_triangle_corner(v)), 1.0);
return out;
}
@@ -9,11 +9,20 @@
@group(0) @binding(12) var<storage, read> box_sections: array<Section>;
// The corners (bit 0: x, bit 1: y, bit 2: z) of the box's 12 triangles,
// counter-clockwise seen from outside (cull.wgsl's).
const CORNERS = array<u32, 36>(
0u, 6u, 2u, 0u, 4u, 6u, 1u, 3u, 7u, 1u, 7u, 5u, 0u, 1u, 5u, 0u, 5u, 4u,
2u, 7u, 3u, 2u, 6u, 7u, 0u, 3u, 1u, 0u, 2u, 3u, 4u, 5u, 7u, 4u, 7u, 6u,
);
// counter-clockwise seen from outside (cull.wgsl's `box_triangle_corner`).
// Packed four bits each, eight a word, read through a switch: a constant array indexed by the
// vertex would be a private array, which Adreno's Vulkan compiler (build 0676.41) fails to link.
fn box_triangle_corner(v: u32) -> u32 {
var word: u32;
switch v / 8u {
case 0u: { word = 0x31640260u; }
case 1u: { word = 0x05105717u; }
case 2u: { word = 0x76237245u; }
case 3u: { word = 0x54320130u; }
default: { word = 0x00006747u; }
}
return (word >> (v % 8u * 4u)) & 0xfu;
}
struct BoxOut {
@builtin(position) position: vec4<f32>,
@@ -26,7 +35,7 @@ fn vs_box(@builtin(vertex_index) v: u32, @builtin(instance_index) t: u32) -> Box
let o = bitcast<vec3<f32>>(box_origins[id].xyz);
var out: BoxOut;
out.id = id;
out.position = params.view_proj * vec4(box_corner(o, box_sections[id].a.z, CORNERS[v]), 1.0);
out.position = params.view_proj * vec4(box_corner(o, box_sections[id].a.z, box_triangle_corner(v)), 1.0);
return out;
}
@@ -42,6 +42,8 @@ const DISTANT_SQR: f64 = 3600.0;
const SECTION_DIAGONAL: f64 = 28.0;
/// In `Graph::rays`: the line from this corner stays in reached sections.
const SEES: u8 = u8::MAX;
/// Edits kept before a full resend.
const EDIT_CAP: usize = 1 << 16;
/// VisGraph connectivity per column, one entry per section (bottom first).
/// A missing column or section connects every face.
@@ -611,6 +613,8 @@ pub struct Occlusion {
queue: VecDeque<u32>,
/// Changes whenever `contains` may answer differently (`generation`).
generation: u64,
/// Set when edits were dropped; the next search resends every column.
resync: bool,
}
impl Occlusion {
@@ -631,6 +635,7 @@ impl Occlusion {
vis: Vec::new(),
queue: VecDeque::new(),
generation: next_generation(),
resync: false,
}
}
pub fn set(&mut self, key: SectionKey, vis: u64) {
@@ -639,13 +644,35 @@ impl Occlusion {
}
}
fn changed(&mut self, key: SectionKey, vis: u64) {
self.edits.push(Edit::Set(key, vis));
self.dirty = true;
if self.edits.len() >= EDIT_CAP || self.changed.len() >= EDIT_CAP {
// nobody drains these with cave culling off
self.resync = true;
self.edits.clear();
self.changed.clear();
self.changed_late.clear();
}
if self.resync {
return;
}
self.edits.push(Edit::Set(key, vis));
self.changed.push(key);
if self.running.is_some() {
self.changed_late.push(key);
}
}
fn resync_edits(&mut self) {
self.resync = false;
self.edits.clear();
self.edits.push(Edit::Clear);
for (&(x, z), column) in &self.columns.map {
for (y, &vis) in column.iter().enumerate() {
if vis != ALL_VISIBLE {
self.edits.push(Edit::Set((x, y as i32, z), vis));
}
}
}
}
/// Every section of the column `x`, `z` goes (sees through every face).
pub fn remove_column(&mut self, x: i32, z: i32) {
let Some(column) = self.columns.map.remove(&(x, z)) else {
@@ -674,6 +701,7 @@ impl Occlusion {
self.columns.map.clear();
self.edits.clear();
self.edits.push(Edit::Clear);
self.resync = false;
self.dirty = true;
// Another world: the old graph says nothing about it. Without one, every
// section was visible already, and stays so.
@@ -769,6 +797,9 @@ impl Occlusion {
let from = self.columns.section_of(camera);
self.dirty = false;
self.searched = Some((from, radius));
if self.resync {
self.resync_edits();
}
let graph = std::mem::take(&mut self.spare);
if let Some(worker) = &self.worker {
let job = Job {
@@ -1793,4 +1824,36 @@ mod tests {
assert_ne!(answers(&o, around, spread + radius), walled.1);
assert_ne!(o.generation(), walled.0, "the graph went");
}
#[test]
fn edit_queue_stays_bounded_without_searches() {
let mut o = Occlusion::new(0, 4);
let total = (1usize << 16) + 5000;
for i in 0..total {
o.set(((i % 700) as i32, 1, (i / 700) as i32), 1 + (i as u64 % 5));
}
assert!(o.edits.len() <= 1 << 16, "{} edits queued", o.edits.len());
assert!(
o.changed.len() <= 1 << 16,
"{} changed queued",
o.changed.len()
);
let mut fresh = Occlusion::new(0, 4);
for (&(x, z), column) in &o.columns.map {
for (y, &vis) in column.iter().enumerate() {
fresh.set((x, y as i32, z), vis);
}
}
let camera = at((0, 1, 0));
for _ in 0..2 {
o.settle(camera, 6);
fresh.settle(camera, 6);
}
assert_eq!(
reached(&o),
reached(&fresh),
"the worker saw the same world"
);
}
}
@@ -5,6 +5,7 @@
use std::collections::BTreeMap;
use horizon_shaders::{BufferObject, CustomImage, Format, Kind};
use wgpu::util::DeviceExt;
pub(super) struct PackImage {
pub(super) def: CustomImage,
@@ -468,19 +469,33 @@ impl Buffers {
.iter()
.map(|(binding, o)| {
let least = self.minimums.get(binding).copied().unwrap_or(0);
let wanted = buffer_size(*o, size, least);
let wanted = buffer_size(o, size, least);
if wanted > largest {
log::warn!(
"shader pack: bufferObject.{binding} wants {wanted} bytes, this device \
binds at most {largest}"
);
}
device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Shader pack buffer"),
size: wanted.min(largest),
usage: wgpu::BufferUsages::STORAGE,
mapped_at_creation: false,
})
let size = wanted.min(largest);
// A buffer the pack initializes from a file starts with the file's bytes, the
// rest of it zero.
match o.initial_data() {
Some(data) => {
let mut contents = data.to_vec();
contents.resize(size as usize, 0);
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Shader pack buffer"),
contents: &contents,
usage: wgpu::BufferUsages::STORAGE,
})
}
None => device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Shader pack buffer"),
size,
usage: wgpu::BufferUsages::STORAGE,
mapped_at_creation: false,
}),
}
})
.collect();
let entries: Vec<_> = self
@@ -509,7 +524,7 @@ impl Buffers {
/// The bytes a storage buffer is made with for a render size of `render`: what the pack
/// declares, or more when a shader binds it as a larger block (`least`), which wgpu needs where
/// OpenGL binds the smaller buffer; a multiple of 4.
fn buffer_size(object: BufferObject, render: [u32; 2], least: u64) -> u64 {
fn buffer_size(object: &BufferObject, render: [u32; 2], least: u64) -> u64 {
object.size(render).max(least).next_multiple_of(4)
}
@@ -612,18 +627,18 @@ mod tests {
/// bound as a 48-byte block failed wgpu's validation, #7178); a larger one keeps its size.
#[test]
fn a_buffer_is_at_least_its_shaders_block() {
assert_eq!(buffer_size(BufferObject::Bytes(40), [1, 1], 48), 48);
assert_eq!(buffer_size(&BufferObject::Bytes(40), [1, 1], 48), 48);
assert_eq!(
buffer_size(BufferObject::Bytes(40980), [1, 1], 40992),
buffer_size(&BufferObject::Bytes(40980), [1, 1], 40992),
40992
);
assert_eq!(buffer_size(BufferObject::Bytes(100), [1, 1], 48), 100);
assert_eq!(buffer_size(BufferObject::Bytes(41), [1, 1], 0), 44);
assert_eq!(buffer_size(&BufferObject::Bytes(100), [1, 1], 48), 100);
assert_eq!(buffer_size(&BufferObject::Bytes(41), [1, 1], 0), 44);
let per_pixel = BufferObject::PerPixel {
bytes: 4,
scale: [0.5, 0.5],
};
assert_eq!(buffer_size(per_pixel, [10, 10], 64), 100);
assert_eq!(buffer_size(per_pixel, [4, 4], 64), 64);
assert_eq!(buffer_size(&per_pixel, [10, 10], 64), 100);
assert_eq!(buffer_size(&per_pixel, [4, 4], 64), 64);
}
}
@@ -399,6 +399,20 @@ impl Rotation {
#[cfg(test)]
mod tests {
use super::*;
/// CubeMap's cube: its corners' indices, six faces of two triangles.
#[test]
fn the_packed_cube_corners_are_cubemaps_indices() {
let indices = [
4, 7, 6, 4, 6, 5, 5, 6, 2, 5, 2, 1, 1, 2, 3, 1, 3, 0, 0, 3, 7, 0, 7, 4, 7, 3, 2, 7, 2,
6, 0, 4, 5, 0, 5, 1,
];
let source = include_str!("panorama.wgsl");
assert_eq!(
super::super::cull::packed_lookup(source, "cube_corner", 36),
indices
);
}
use std::{
fs,
sync::atomic::{AtomicU32, Ordering},
@@ -8,19 +8,27 @@ struct Out {
@builtin(position) clip: vec4<f32>,
@location(0) direction: vec3<f32>,
}
// The cube's corner of each of its 36 vertices (CubeMap's indices): 4 7 6, 4 6 5, 5 6 2,
// 5 2 1, 1 2 3, 1 3 0, 0 3 7, 0 7 4, 7 3 2, 7 2 6, 0 4 5, 0 5 1.
// Packed four bits each, eight a word, read through a switch: a constant array indexed by the
// vertex would be a private array, which Adreno's Vulkan compiler (build 0676.41) fails to link.
fn cube_corner(v: u32) -> u32 {
var word: u32;
switch v / 8u {
case 0u: { word = 0x65564674u; }
case 1u: { word = 0x13211252u; }
case 2u: { word = 0x47073003u; }
case 3u: { word = 0x40627237u; }
default: { word = 0x00001505u; }
}
return (word >> (v % 8u * 4u)) & 0xfu;
}
@vertex fn vs_panorama(@builtin(vertex_index) index: u32) -> Out {
let corners = array<vec3<f32>, 8>(
vec3(-1.0, -1.0, -1.0), vec3(1.0, -1.0, -1.0),
vec3(1.0, 1.0, -1.0), vec3(-1.0, 1.0, -1.0),
vec3(-1.0, -1.0, 1.0), vec3(1.0, -1.0, 1.0),
vec3(1.0, 1.0, 1.0), vec3(-1.0, 1.0, 1.0),
);
let indices = array<u32, 36>(
4, 7, 6, 4, 6, 5, 5, 6, 2, 5, 2, 1,
1, 2, 3, 1, 3, 0, 0, 3, 7, 0, 7, 4,
7, 3, 2, 7, 2, 6, 0, 4, 5, 0, 5, 1,
);
let position = corners[indices[index]];
// CubeMap's corners: -1 or 1 on each axis, x 1 at corners 1, 2, 5 and 6, y at 2, 3, 6
// and 7, z at 4 to 7.
let c = cube_corner(index);
let high = vec3((c ^ (c >> 1u)) & 1u, c & 2u, c & 4u) != vec3(0u);
let position = select(vec3(-1.0), vec3(1.0), high);
var out: Out;
out.clip = projection.view_proj * vec4(position, 1.0);
out.direction = position;
+145 -20
View File
@@ -12,8 +12,11 @@ use std::{
};
use wgpu::util::DeviceExt;
use model_reducing::reducible_model_sprites;
mod animate;
pub mod cit_glint;
mod model_reducing;
mod pack_text;
pub mod pictures;
pub(crate) mod sprite_modifiers;
@@ -1057,25 +1060,6 @@ impl HudAtlas {
blinks: Default::default(),
}
}
/// `layout`, but when `extras` (Custom Item Textures) make the images more than `max`
/// holds, without them: they are left out, not the packs.
fn layout_leaving_out(
images: &mut Vec<PackedImage>,
animated: &mut Vec<String>,
extras: &HashSet<String>,
max: u32,
) -> Result<(u32, Vec<(u32, u32)>, u32)> {
match Self::layout(images, max) {
Err(e) if !extras.is_empty() => {
images.retain(|i| !extras.contains(&i.0));
animated.retain(|n| !extras.contains(n));
let layout = Self::layout(images, max)?;
log::warn!("left out the custom item textures: {e}");
Ok(layout)
}
layout => layout,
}
}
/// Shelf placements of `images` (tallest first) in the narrowest atlas
/// width from 4096 up that keeps the atlas, glyph rows included, within
/// `max` texels a side; and the first row free for glyphs. High-resolution
@@ -1460,7 +1444,20 @@ impl HudAtlas {
}
images.sort_by(|a, b| b.2.cmp(&a.2).then(b.1.cmp(&a.1)));
let (width, placements, glyph_region) =
Self::layout_leaving_out(&mut images, &mut animated, &cit_names, MAX_ATLAS_SIZE)?;
Self::layout_leaving_out(&mut images, &mut animated, &cit_names, MAX_ATLAS_SIZE)
.or_else(|error| -> Result<_> {
let models = reducible_model_sprites(root, &images, &bordered);
let layout = Self::layout_reducing_models(
&mut images,
&mut gui_sizes,
&models,
MAX_ATLAS_SIZE,
)?;
log::warn!(
"reduced HUD model texture resolution to keep the resource pack: {error}"
);
Ok(layout)
})?;
// A multiple of 1024 rather than a power of two: the default resources
// need 4096x5120 (8192 rows before), Faithful 32x 4096x12288 (16384).
let height = (glyph_region + GLYPH_ROWS).next_multiple_of(1024);
@@ -3778,6 +3775,47 @@ mod tests {
assert_eq!(banner.texels((atlas.width, atlas.height)), (16, 32));
}
#[test]
fn a_root_single_sprite_strip_taller_than_the_atlas_packs_one_frame() {
let root = animated_root();
let atlases = root.join("assets/minecraft/atlases");
fs::create_dir_all(&atlases).unwrap();
fs::write(
atlases.join("items.json"),
r#"{"sources":[{"type":"single","resource":"nexo:abc123"},
{"type":"single","resource":"nexo:still"}]}"#,
)
.unwrap();
// Plugin-generated names need no item/ prefix. Keep the production
// strip's height, but use narrow frames to keep this fixture cheap.
let textures = root.join("assets/nexo/textures");
fs::create_dir_all(&textures).unwrap();
write_strip(
&textures.join("abc123.png"),
16,
32768,
&[[255, 0, 0, 255], [0, 0, 255, 255]],
Some(r#"{"animation":{"frames":[1024,0],"frametime":3}}"#),
);
write_strip(&textures.join("still.png"), 16, 32, &[[7, 8, 9, 255]], None);
let atlas = HudAtlas::build(&root).unwrap();
let _ = fs::remove_dir_all(&root);
let rect = atlas.sprites["nexo:abc123"];
assert_eq!(rect.texels((atlas.width, atlas.height)), (16, 16));
assert_eq!(texel_at(&atlas, rect, 0, 15), [0, 0, 255, 255]);
let (_, animation) = atlas
.sprite_animations
.iter()
.find(|(r, _)| r.u0 == rect.u0 && r.v0 == rect.v0)
.expect("the root-level single sprite animates");
assert_eq!(animation.frame(0), 1024);
assert_eq!(animation.frame(3), 0);
assert_eq!(animation.frame_count(), 2048);
assert_eq!(
atlas.sprites["nexo:still"].texels((atlas.width, atlas.height)),
(16, 32)
);
}
#[test]
fn an_animated_gui_sprite_packs_its_first_frame_and_animates() {
let root =
std::env::temp_dir().join(format!("horizon-atlas-gui-anim-{}", std::process::id()));
@@ -4184,6 +4222,93 @@ texture=wide",
assert_inside(&images, width, &placements, free);
}
#[test]
fn model_resolution_fallback_protects_font_sources_and_gui_aliases() {
let root = animated_root();
write_png(&root, "item/font_sheet", 32, 32);
write_png(&root, "gui/panel", 32, 32);
write_png(&root, "hash", 32, 32);
let font = root.join("assets/minecraft/font/default.json");
fs::create_dir_all(font.parent().unwrap()).unwrap();
fs::write(
font,
r#"{"providers":[{"type":"bitmap","file":"minecraft:item/font_sheet.png","ascent":7,"chars":["a"]}]}"#,
).unwrap();
let atlases = root.join("assets/minecraft/atlases");
fs::create_dir_all(&atlases).unwrap();
fs::write(
atlases.join("items.json"),
r#"{"sources":[
{"type":"single","resource":"item/font_sheet","sprite":"font_alias"},
{"type":"single","resource":"gui/panel","sprite":"gui_alias"},
{"type":"single","resource":"hash"}
]}"#,
)
.unwrap();
let images = vec![
("item/font_sheet".to_owned(), 32, 32, Vec::new()),
("item/blurred".to_owned(), 32, 32, Vec::new()),
("block/stone".to_owned(), 32, 32, Vec::new()),
];
let models =
reducible_model_sprites(&root, &images, &HashSet::from(["item/blurred".to_owned()]));
let _ = fs::remove_dir_all(root);
assert_eq!(
models,
HashSet::from(["hash".to_owned(), "block/stone".to_owned()])
);
}
#[test]
fn overflowing_model_textures_shrink_without_changing_gui_sizes_or_slots() {
let mut images = vec![
(
"nexo:hash".into(),
1024,
1024,
[10, 20, 30, 255].repeat(1024 * 1024),
),
(
"gui/panel".into(),
32,
32,
[40, 50, 60, 255].repeat(32 * 32),
),
("horizon/map".into(), 128, 128, Vec::new()),
];
let models = HashSet::from(["nexo:hash".to_owned(), "horizon/map".to_owned()]);
let mut sizes = HashMap::new();
assert!(HudAtlas::layout(&images, 1024).is_err());
let (width, placements, free) =
HudAtlas::layout_reducing_models(&mut images, &mut sizes, &models, 1024).unwrap();
assert_inside(&images, width, &placements, free);
assert!(free + GLYPH_ROWS <= 1024);
let model = images.iter().find(|i| i.0 == "nexo:hash").unwrap();
assert_eq!((model.1, model.2), (256, 256));
assert_eq!(model.3, [10, 20, 30, 255].repeat(256 * 256));
assert_eq!(sizes["nexo:hash"], (1024, 1024));
let gui = images.iter().find(|i| i.0 == "gui/panel").unwrap();
assert_eq!((gui.1, gui.2), (32, 32));
assert_eq!(gui.3, [40, 50, 60, 255].repeat(32 * 32));
let slot = images.iter().find(|i| i.0 == "horizon/map").unwrap();
assert_eq!((slot.1, slot.2), (128, 128));
assert!(slot.3.is_empty());
}
#[test]
fn fitting_models_keep_their_pixels_and_existing_logical_sizes() {
let mut images = vec![("item/gem".into(), 32, 32, [1, 2, 3, 255].repeat(32 * 32))];
let original = images.clone();
let mut sizes = HashMap::from([("item/gem".to_owned(), (16, 16))]);
let models = HashSet::from(["item/gem".to_owned()]);
HudAtlas::layout_reducing_models(&mut images, &mut sizes, &models, 1024).unwrap();
assert_eq!(images, original);
assert_eq!(sizes["item/gem"], (16, 16));
// Protected oversized images remain an error; no unbounded retry or
// silent removal of GUI/font resources when shrinking cannot help.
images.push(("font/sheet".into(), 1024, 1024, Vec::new()));
assert!(HudAtlas::layout_reducing_models(&mut images, &mut sizes, &models, 1024).is_err());
assert_eq!((images[0].1, images[0].2), (1024, 1024));
assert_eq!(sizes["item/gem"], (16, 16));
}
#[test]
fn textures_that_cannot_fit_are_an_error_not_a_gpu_panic() {
let error = HudAtlas::layout(&sized(300, 1024, 1024), MAX_ATLAS_SIZE).unwrap_err();
assert!(error.to_string().contains("do not fit a 16384x16384 atlas"));
@@ -0,0 +1,105 @@
//! Resolution fallback for model textures that exceed the shared HUD atlas.
use super::{HashMap, HashSet, HudAtlas, PackedImage, Path, Result, bitmap_font_textures};
/// Only model atlas sprites may lose resolution. Font sheets can also be
/// model textures, so protect both their file names and renamed sources.
pub(super) fn reducible_model_sprites(
root: &Path,
images: &[PackedImage],
bordered: &HashSet<String>,
) -> HashSet<String> {
use crate::atlas_sources::{self, SpriteOrigin};
let fonts: HashSet<_> = bitmap_font_textures(root).into_iter().collect();
let protected = |name: &str| {
let path = name.split_once(':').map_or(name, |(_, path)| path);
path.starts_with("gui/")
|| path.starts_with("font/")
|| fonts.contains(&atlas_sources::texture_path(root, name, "textures"))
};
let mut models: HashSet<String> = images
.iter()
.filter(|(name, ..)| name.starts_with("item/") || name.starts_with("block/"))
.map(|(name, ..)| name.clone())
.collect();
let textures = atlas_sources::list_textures(root);
let mut protected_aliases = HashSet::new();
for (atlas, sources) in atlas_sources::atlases(root) {
if atlas != "minecraft:blocks" && atlas != "minecraft:items" {
continue;
}
for (id, origin) in atlas_sources::list(&sources, &textures) {
let name = id.strip_prefix("minecraft:").unwrap_or(&id).to_owned();
if let SpriteOrigin::File(texture) = origin {
if protected(&texture) {
protected_aliases.insert(name);
} else {
models.insert(name);
}
}
}
}
models.retain(|name| {
!bordered.contains(name) && !protected(name) && !protected_aliases.contains(name)
});
models
}
impl HudAtlas {
/// `layout`, but when `extras` (Custom Item Textures) make the images more than `max`
/// holds, without them: they are left out, not the packs.
pub(super) fn layout_leaving_out(
images: &mut Vec<PackedImage>,
animated: &mut Vec<String>,
extras: &HashSet<String>,
max: u32,
) -> Result<(u32, Vec<(u32, u32)>, u32)> {
match Self::layout(images, max) {
Err(e) if !extras.is_empty() => {
images.retain(|i| !extras.contains(&i.0));
animated.retain(|n| !extras.contains(n));
let layout = Self::layout(images, max)?;
log::warn!("left out the custom item textures: {e}");
Ok(layout)
}
layout => layout,
}
}
/// Last resort for a pack that exceeds the shared atlas: halve model
/// textures until they fit, retaining their logical GUI sizes. Never
/// shrink run-time slots (empty pixels), or below the vanilla 16 texels.
pub(super) fn layout_reducing_models(
images: &mut [PackedImage],
gui_sizes: &mut HashMap<String, (u32, u32)>,
models: &HashSet<String>,
max: u32,
) -> Result<(u32, Vec<(u32, u32)>, u32)> {
loop {
let error = match Self::layout(images, max) {
Ok(layout) => return Ok(layout),
Err(error) => error,
};
let mut changed = false;
for (name, w, h, pixels) in images.iter_mut() {
if !models.contains(name) || pixels.is_empty() || (*w <= 16 && *h <= 16) {
continue;
}
let (nw, nh) = ((*w / 2).max((*w).min(16)), (*h / 2).max((*h).min(16)));
let mut scaled = Vec::with_capacity((nw * nh * 4) as usize);
for y in 0..nh {
for x in 0..nw {
let at = (((y * *h / nh) * *w + x * *w / nw) * 4) as usize;
scaled.extend_from_slice(&pixels[at..at + 4]);
}
}
gui_sizes.entry(name.clone()).or_insert((*w, *h));
*pixels = scaled;
(*w, *h) = (nw, nh);
changed = true;
}
if !changed {
return Err(error);
}
images.sort_by(|a, b| b.2.cmp(&a.2).then(b.1.cmp(&a.1)));
}
}
}
@@ -39,11 +39,14 @@ pub fn text_with_backdrop(
background: u32,
) {
if background != 0 {
// Font.PreparedTextBuilder.markBackground: one pixel above and left of
// the glyphs, the glyph advance to the right and nine high: not a
// fixed two-pixel frame.
out.push(fill(
x - 2,
y - 2,
x + w + 2,
y + 9 + 2,
x - 1,
y - 1,
x + w,
y + 9,
multiply(background, color),
));
}
@@ -1484,3 +1487,27 @@ mod boss_bar_room_tests {
}
}
}
#[cfg(test)]
mod backdrop_tests {
use super::*;
/// `markBackground`: the backdrop reaches one pixel above and left of the
/// glyphs, their advance to the right and nine down, not 2 on every side.
#[test]
fn the_backdrop_frames_the_glyphs_by_one_pixel() {
let mut out = Vec::new();
text_with_backdrop(
&mut out,
Text::literal("X"),
10,
20,
6,
u32::MAX,
0xFF00_0000,
);
let Some(GuiDraw::Fill { x0, y0, x1, y1, .. }) = out.first() else {
panic!("a backdrop is a fill first");
};
assert_eq!((*x0, *y0, *x1, *y1), (9, 19, 16, 29));
}
}
@@ -59,6 +59,16 @@ pub const CULL_BACK: [i8; 4] = [0, 0, 0, 1];
/// `Vertex::normal.w` of a shaded quad of an `entityCutoutCull` model (the bat): shaded by the
/// normal like -127, and `entity.wgsl` also discards its back-facing pixels.
pub const SHADED_CULL_W: i8 = -126;
/// `Vertex::normal` of a quad of an `entitySolid` model, which culls its back faces: a quad
/// the shader shades by its normal keeps it ([`SHADED_CULL_W`]), a baked-colour one (no
/// normal) gets [`CULL_BACK`].
pub fn cull_back(normal: [i8; 4]) -> [i8; 4] {
if normal == [0; 4] {
CULL_BACK
} else {
[normal[0], normal[1], normal[2], SHADED_CULL_W]
}
}
/// A section vertex: vanilla's `DefaultVertexFormat.BLOCK` (position, colour,
/// UV0, UV2) with the sprite's array layer beside the light, in 24 bytes
/// rather than vanilla's 28: the UV is fixed point, which holds model UVs
@@ -145,6 +155,17 @@ impl TerrainVertex {
}
}
#[cfg(test)]
mod cull_back_tests {
use super::{CULL_BACK, SHADED_CULL_W, cull_back};
/// #15322: a shaded `entitySolid` quad keeps its normal and culls; a baked one has
/// no normal and only culls.
#[test]
fn cull_back_keeps_a_shaded_normal() {
assert_eq!(cull_back([0; 4]), CULL_BACK);
assert_eq!(cull_back([0, 127, 0, 127]), [0, 127, 0, SHADED_CULL_W]);
}
}
#[cfg(test)]
mod vertex_tests {
use super::TerrainVertex;
+23 -48
View File
@@ -6,6 +6,8 @@ use std::time::Instant;
pub use crate::settings::Opt;
#[path = "options_ui_widgets.rs"]
mod widgets;
use crate::{
gamepad::{self, GlyphStyle},
hdr_calibration::Picture,
@@ -17,6 +19,11 @@ use crate::{
text::{self, Style, Text},
ui,
};
pub(crate) use widgets::{button, inactive};
#[path = "options_ui_values.rs"]
mod values;
pub(crate) use values::{chat_delay_label, chat_px, generic, on_off, percent};
mod shell_pages;
@@ -35,18 +42,6 @@ pub static HORIZON_EXTRAS: std::sync::atomic::AtomicBool = std::sync::atomic::At
fn horizon_extras() -> bool {
HORIZON_EXTRAS.load(std::sync::atomic::Ordering::Relaxed)
}
fn generic(caption: &str, value: Text) -> Text {
Text::translate("options.generic_value", &[tr(caption), value])
}
fn percent(caption: &str, value: f32) -> Text {
Text::translate(
"options.percent_value",
&[
tr(caption),
Text::literal(((f64::from(value) * 100.0) as i32).to_string()),
],
)
}
/// `text` cut into lines of at most `width` characters, at spaces where it has them.
fn wrap(text: &str, width: usize) -> Vec<String> {
let mut lines = Vec::new();
@@ -71,20 +66,11 @@ fn wrap(text: &str, width: usize) -> Vec<String> {
}
lines
}
fn on_off(v: bool) -> Text {
tr(if v { "options.on" } else { "options.off" })
}
/// A music track's name as its row shows it: the file, without its folder
/// (`minecraft:music/game/calm1` -> `calm1`; the group names the situation).
fn track_name(id: &str) -> &str {
id.rsplit('/').next().unwrap_or(id)
}
fn chat_px(caption: &str, px: f32) -> Text {
Text::translate(
"options.pixel_value",
&[tr(caption), Text::literal((px as i32).to_string())],
)
}
pub(crate) enum Kind {
/// Slider position 0..1.
Slider(f32),
@@ -353,6 +339,7 @@ impl Opt {
pub(crate) fn kind(self, s: &Settings) -> Kind {
let unit = |v: f32, lo: f32, hi: f32| Kind::Slider(((v - lo) / (hi - lo)).clamp(0.0, 1.0));
match self {
Opt::ChatDelay => values::kind(s.file.double("chatDelay")),
Opt::FovEffects | Opt::DarknessPulsing => {
Kind::Slider(s.file.double(self.file_key().unwrap()).sqrt() as f32)
}
@@ -461,6 +448,7 @@ impl Opt {
/// The discrete step count for stepped sliders, or `None` if continuous.
pub fn slider_steps(self) -> Option<u32> {
match self {
Opt::ChatDelay => Some(60),
Opt::Fov => Some(80),
Opt::RenderDistance => Some((crate::options_file::MAX_RENDER_DISTANCE - 2) as u32),
Opt::SimulationDistance => Some(27),
@@ -511,7 +499,7 @@ impl Opt {
pub fn label(self, s: &Settings) -> Text {
let c = self.caption();
match self {
Opt::ChatDelay => tr("options.chat.delay_none"),
Opt::ChatDelay => chat_delay_label(s),
Opt::FovEffects
| Opt::DarknessPulsing
| Opt::DistortionEffects
@@ -716,7 +704,7 @@ impl Opt {
} else {
"options.narrator.notavailable"
}),
Opt::ChatDelay => tr("options.chat.delay_none"),
Opt::ChatDelay => chat_delay_label(s),
Opt::HideLightningFlashes => on_off(s.file.bool("hideLightningFlashes")),
Opt::MonochromeLogo => on_off(s.file.bool("darkMojangStudiosBackground")),
Opt::HideSplashTexts => on_off(s.file.bool("hideSplashTexts")),
@@ -901,6 +889,7 @@ impl Opt {
let v = v.clamp(0.0, 1.0);
let step = |lo: f32, hi: f32| (lo + v * (hi - lo)).round();
match self {
Opt::ChatDelay => values::set(s, v),
Opt::FovEffects | Opt::DarknessPulsing => s.file.set(
self.file_key().unwrap(),
crate::options_file::V::Double(f64::from(v).powi(2)),
@@ -1306,7 +1295,7 @@ pub enum Page {
Music,
}
#[derive(Clone, Debug, PartialEq)]
enum Act {
pub(crate) enum Act {
None,
Push(Page),
Done,
@@ -1371,7 +1360,7 @@ enum WKind {
code: String,
},
}
struct Widget {
pub(crate) struct Widget {
x: f32,
y: f32,
w: f32,
@@ -1707,27 +1696,6 @@ impl Default for OptionsUi {
}
}
}
fn button(x: f32, y: f32, w: f32, label: Text, act: Act) -> Widget {
Widget {
x,
y,
w,
h: 20.0,
kind: WKind::Button {
label,
active: true,
act,
},
list: false,
row: 0,
}
}
fn inactive(mut w: Widget) -> Widget {
if let WKind::Button { active, .. } = &mut w.kind {
*active = false;
}
w
}
fn option_widget(s: &Settings, opt: Opt, x: f32, y: f32, w: f32) -> Widget {
// OptionsSubScreen.addContents: the narrator button is active only while
// the narrator is (`narratorButton.active = narrator.isActive()`).
@@ -3094,13 +3062,20 @@ impl OptionsUi {
}
content = y + self.scroll - top + 2.0;
out[list_start..].iter_mut().for_each(|w| w.list = true);
out.push(button(
let reset_all = button(
cx - 154.0,
h - 27.0,
150.0,
tr("controls.resetAll"),
Act::ResetKeys,
));
);
// KeyBindsScreen.extractRenderState: active only while some
// binding differs from its default.
out.push(if s.keys.changed_from_defaults() {
reset_all
} else {
inactive(reset_all)
});
out.push(button(cx + 4.0, h - 27.0, 150.0, tr("gui.done"), Act::Done));
}
Page::Language => {
@@ -174,3 +174,42 @@ fn a_held_hdr_start_shows_as_the_graphics_screen_opens() {
assert_eq!(ui.graphics.screen.output.held, held);
}
}
/// The slider position `Opt::ChatDelay`'s kind() gives the stored seconds.
fn delay_pos(seconds: f64) -> f32 {
match values::kind(seconds) {
Kind::Slider(p) => p,
_ => panic!("the chat delay is a slider"),
}
}
/// `Options.chatDelay`: `IntRange(0, 60)` xmap(v / 10) — a slider in tenths of
/// a second from 0 to 6, not a button with no value behind it.
#[test]
fn the_chat_delay_slider_steps_in_tenths() {
assert_eq!(delay_pos(0.0), 0.0);
assert!((delay_pos(3.0) - 0.5).abs() < 1e-6);
assert_eq!(delay_pos(6.0), 1.0);
// A value past the option's range clamps to its last step.
assert_eq!(delay_pos(6.5), 1.0);
assert_eq!(Opt::ChatDelay.slider_steps(), Some(60));
let mut s = Settings::default();
set_slider(Opt::ChatDelay, &mut s, 0.0);
assert_eq!(s.file.double("chatDelay"), 0.0);
set_slider(Opt::ChatDelay, &mut s, 0.5);
assert!((s.file.double("chatDelay") - 3.0).abs() < 1e-9);
set_slider(Opt::ChatDelay, &mut s, 1.0);
assert!((s.file.double("chatDelay") - 6.0).abs() < 1e-9);
}
/// `Options.chatDelay`'s message: None at zero, else the delay to one decimal.
#[test]
fn the_chat_delay_slider_says_its_delay() {
let mut s = Settings::default();
assert_eq!(chat_delay_label(&s).plain(), "options.chat.delay_none");
s.file.set("chatDelay", crate::options_file::V::Double(2.0));
assert_eq!(chat_delay_label(&s).plain(), "Chat Delay: 2.0 second(s)");
s.file.set("chatDelay", crate::options_file::V::Double(6.0));
assert_eq!(chat_delay_label(&s).plain(), "Chat Delay: 6.0 second(s)");
s.file.set("chatDelay", crate::options_file::V::Double(0.0));
assert_eq!(chat_delay_label(&s).plain(), "options.chat.delay_none");
}
@@ -0,0 +1,54 @@
//! The small option value labels (`generic`, `percent`, `on_off`, `chat_px`,
//! `chat_delay_label`) and `Options.chatDelay`'s slider mapping.
use super::*;
pub(crate) fn generic(caption: &str, value: Text) -> Text {
Text::translate("options.generic_value", &[tr(caption), value])
}
pub(crate) fn on_off(v: bool) -> Text {
tr(if v { "options.on" } else { "options.off" })
}
pub(crate) fn chat_px(caption: &str, px: f32) -> Text {
Text::translate(
"options.pixel_value",
&[tr(caption), Text::literal((px as i32).to_string())],
)
}
pub(crate) fn percent(caption: &str, value: f32) -> Text {
Text::translate(
"options.percent_value",
&[
tr(caption),
Text::literal(((f64::from(value) * 100.0) as i32).to_string()),
],
)
}
/// `Options.chatDelay`'s message: `options.chat.delay_none` at zero, else
/// `options.chat.delay` with the delay to one decimal (`%.1f` seconds).
pub(crate) fn chat_delay_label(s: &Settings) -> Text {
let value = s.file.double("chatDelay") as f32;
if value <= 0.0 {
Text::translate("options.chat.delay_none", &[])
} else {
Text::translate_with_fallback(
"options.chat.delay",
"Chat Delay: %s second(s)",
&[Text::literal(format!("{value:.1}"))],
)
}
}
/// `IntRange(0, 60).toSliderValue` on the stored seconds (`xmap(v / 10.0)`):
/// the tenth-of-a-second step over the slider's 60.
pub(crate) fn kind(value: f64) -> Kind {
Kind::Slider(((value * 10.0).floor() / 60.0).clamp(0.0, 1.0) as f32)
}
/// `IntRange(0, 60).fromSliderValue` then `xmap`'s `/ 10.0`:
/// floor(map(v, 0, 1, 0, 61)), the last step capped at 60, in tenths.
pub(crate) fn from_slider(v: f32) -> f64 {
f64::from((v.clamp(0.0, 1.0) * 61.0).floor().min(60.0) / 10.0)
}
/// `Options.chatDelay.set`: the slider's value as the stored seconds.
pub(crate) fn set(s: &mut Settings, v: f32) {
s.file
.set("chatDelay", crate::options_file::V::Double(from_slider(v)));
}
@@ -0,0 +1,25 @@
//! The option screens' widget constructors: a 20-pixel button row and the
//! same widget turned inactive.
use super::*;
pub(crate) fn button(x: f32, y: f32, w: f32, label: Text, act: Act) -> Widget {
Widget {
x,
y,
w,
h: 20.0,
kind: WKind::Button {
label,
active: true,
act,
},
list: false,
row: 0,
}
}
pub(crate) fn inactive(mut w: Widget) -> Widget {
if let WKind::Button { active, .. } = &mut w.kind {
*active = false;
}
w
}
@@ -1718,6 +1718,19 @@ impl PackScreen {
[32, 2],
);
if l.scrollable() {
// AbstractScrollArea.extractScrollbar: over the bar, the pointer
// points, or resizes while the bar is held.
let over_bar = mouse.0 >= l.scroll_bar_x()
&& mouse.0 < l.scroll_bar_x() + 6
&& mouse.1 >= l.y
&& mouse.1 < l.bottom();
if over_bar {
crate::widgets::request_cursor(if l.held.is_some() {
crate::widgets::Cursor::ResizeNs
} else {
crate::widgets::Cursor::PointingHand
});
}
let sh = l.scroller_height().clamp(32, l.height - 8);
let sy = (l.scroll as i32 * (l.height - sh) / l.max_scroll() + l.y).max(l.y);
d::sprite(
@@ -2197,6 +2210,29 @@ mod icon_slot_tests {
/// Only the available list has packs: one row of 36 for each of `count` icons fits in a
/// window `height` high.
/// `AbstractScrollArea.extractScrollbar`: over a held pack list's bar the
/// pointer asks to resize vertically, at rest it points.
#[test]
fn the_pack_scrollbar_asks_for_its_cursor() {
let atlas = atlas();
let m = Measure(&atlas.font);
let mut screen = screen(&m, 30, 200);
let l = &screen.lists[0];
assert!(l.scrollable());
let at = (l.scroll_bar_x() + 2, l.y + 8);
let _ = crate::widgets::take_cursor();
screen.draw(&m, at, 0, 0);
assert_eq!(
crate::widgets::take_cursor(),
crate::widgets::Cursor::PointingHand
);
screen.lists[0].held = Some(f64::from(at.1));
screen.draw(&m, at, 0, 0);
assert_eq!(
crate::widgets::take_cursor(),
crate::widgets::Cursor::ResizeNs
);
}
fn screen(m: &Measure, count: usize, height: i32) -> PackScreen {
PackScreen::new(
m,
@@ -3825,7 +3825,7 @@ impl ParticleEngine {
}
let pending = std::mem::take(&mut self.pending);
for mut p in pending {
if p.spawn_check && !polytone::unobstructed(level, p.bb) {
if !self.polytone_joins(&p, level) {
continue;
}
if p.pre_tick {
@@ -1100,6 +1100,30 @@ pub(super) fn unobstructed(level: &ParticleLevel<'_>, bb: horizon_core::geometry
scene.cells_without_collision(bb) || scene.unobstructed(bb).unwrap_or(true)
}
impl ParticleEngine {
/// Whether a pending Polytone particle joins the world. One with physics whose box starts
/// inside a block's collision never does, and its type's `limit` is then counted among those
/// that joined, so one that never joins takes no place (measured on the mod: with `limit` 1,
/// one born inside a block and one clear of it in the same tick, the clear one shows).
pub(super) fn polytone_joins(&self, p: &Particle, level: &ParticleLevel<'_>) -> bool {
if !p.spawn_check {
return true;
}
if !unobstructed(level, p.bb) {
return false;
}
let Some(name) = p.polytone_type.as_deref() else {
return true;
};
let def = self.polytone.as_ref().and_then(|r| r.get(name));
let limit = def.and_then(|d| d.limit.filter(|_| d.gpu.is_none()));
limit.is_none_or(|limit| {
let same = |q: &&Particle| !q.removed && q.polytone_type.as_deref() == Some(name);
self.particles.iter().filter(same).count() < limit as usize
})
}
}
impl super::ParticleController for Controller {
fn tick(&mut self, p: &mut Particle, level: &ParticleLevel<'_>) -> ParticleUpdate {
let mut update = ParticleUpdate::default();
@@ -1214,7 +1238,8 @@ pub(super) fn make(
velocity: [f64; 3],
seed: i64,
) -> Option<Particle> {
// `limit`: at most that many of the type alive at once.
// `limit`: at most that many of the type alive at once. A pending one with physics may never
// join (`polytone_joins`), so it takes no place until it does.
// (A GPU particle's `limit` is its ring of records instead.)
if let Some(limit) = def.limit
&& def.gpu.is_none()
@@ -1222,7 +1247,7 @@ pub(super) fn make(
let alive = engine
.particles
.iter()
.chain(&engine.pending)
.chain(engine.pending.iter().filter(|p| !p.spawn_check))
.filter(|p| !p.removed && p.polytone_type.as_deref() == Some(name))
.count();
if alive >= limit as usize {
@@ -1230,10 +1255,11 @@ pub(super) fn make(
}
}
// `exclusion_radius`: none spawns within that distance of a live one of its type (rain
// splashes, one per patch of ground).
// splashes, one per patch of ground from tick to tick). Only those already in the world count: measured on
// the mod, two spawned in the same tick a block apart both show, with or without physics.
if def.exclusion_radius > 0.0 && def.gpu.is_none() {
let r2 = def.exclusion_radius * def.exclusion_radius;
let crowded = engine.particles.iter().chain(&engine.pending).any(|p| {
let crowded = engine.particles.iter().any(|p| {
!p.removed
&& p.polytone_type.as_deref() == Some(name)
&& (0..3)
@@ -1858,7 +1884,8 @@ mod tests {
let root = pack(&[
(
"assets/test/polytone/custom_particles/splash.json",
serde_json::json!({"has_physics": false, "exclusion_radius": 1, "render_type": "invisible"}),
serde_json::json!({"has_physics": false, "exclusion_radius": 1, "render_type": "invisible",
"initializer": {"lifetime": "100"}}),
),
(
"assets/test/polytone/custom_particles/drop.json",
@@ -1868,7 +1895,8 @@ mod tests {
"assets/minecraft/polytone/entity_modifiers/player.json",
serde_json::json!({"emitters": [
{"particle": "test:splash", "x": "0", "chance": "1"},
// 0.5 from the first: excluded.
// 0.5 from the first, in the same tick: the first isn't in the world yet,
// so it spawns (measured on the mod).
{"particle": "test:splash", "x": "0.5", "chance": "1"},
// 3 away: spawns.
{"particle": "test:splash", "x": "3", "chance": "1"},
@@ -1890,7 +1918,15 @@ mod tests {
engine.polytone_entity(&level, "minecraft:player", [1.0, 64.0, 1.0]);
let mut xs: Vec<f64> = engine.pending.iter().map(|p| p.position[0]).collect();
xs.sort_by(f64::total_cmp);
assert_eq!(xs, [1.0, 1.5, 4.0]);
assert_eq!(xs, [1.0, 1.5, 1.5, 4.0]);
// Once they are in the world, the next tick's splashes near them are excluded; ones
// farther than the radius from all of them spawn, again without excluding each other.
engine.tick(&level);
let splashes = |p: &&Particle| p.polytone_type.as_deref() == Some("test:splash");
engine.polytone_entity(&level, "minecraft:player", [1.0, 64.0, 1.0]);
assert_eq!(engine.pending.iter().filter(splashes).count(), 0);
engine.polytone_entity(&level, "minecraft:player", [9.0, 64.0, 1.0]);
assert_eq!(engine.pending.iter().filter(splashes).count(), 3);
std::fs::remove_dir_all(root).unwrap();
}
@@ -2421,6 +2457,70 @@ mod tests {
std::fs::remove_dir_all(root).unwrap();
}
/// Measured on the mod: with `limit` 1, one particle born inside a block and one clear of it
/// in the same tick, the clear one shows. The one that never joins takes no place.
#[test]
fn a_particle_that_never_joins_takes_no_place_under_its_limit() {
let blocks = blocks();
let lim = |x: &str| serde_json::json!({"particle": "test:lim", "chance": "1", "x": x});
let root = pack(&[
(
"assets/test/polytone/custom_particles/lim.json",
serde_json::json!({"has_physics": true, "render_type": "invisible", "limit": 1,
"initializer": {"lifetime": "20", "hitbox_size": "0.08"}}),
),
// The first inside the block's reach, the second three blocks clear of it.
(
"assets/minecraft/polytone/entity_modifiers/player.json",
serde_json::json!({"emitters": [lim("0"), lim("3")]}),
),
// Two clear of it: the limit still keeps one.
(
"assets/minecraft/polytone/entity_modifiers/zombie.json",
serde_json::json!({"emitters": [lim("3"), lim("6")]}),
),
// `exclusion_radius` with physics: the one inside the block doesn't keep the clear
// one a block away from spawning.
(
"assets/test/polytone/custom_particles/exc.json",
serde_json::json!({"has_physics": true, "render_type": "invisible",
"exclusion_radius": 2,
"initializer": {"lifetime": "20", "hitbox_size": "0.08"}}),
),
(
"assets/minecraft/polytone/entity_modifiers/skeleton.json",
serde_json::json!({"emitters": [
{"particle": "test:exc", "chance": "1", "x": "0"},
{"particle": "test:exc", "chance": "1", "x": "1"}]}),
),
]);
let scene = solid_scene(blocks);
let level = ParticleLevel {
world: &scene.world,
facts: &scene.facts,
collision: &scene.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
let mut joined = |entity: &str| {
engine.particles.clear();
engine.pending.clear();
// The solid block is at (1, 64, 1): 0.05 under it, a 0.08 box reaches into it.
engine.polytone_entity(&level, entity, [1.5, 63.95, 1.5]);
engine.tick(&level);
engine
.particles
.iter()
.map(|p| p.position[0])
.collect::<Vec<_>>()
};
assert_eq!(joined("minecraft:player"), vec![4.5]);
assert_eq!(joined("minecraft:zombie"), vec![4.5]);
assert_eq!(joined("minecraft:skeleton"), vec![2.5]);
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn a_pack_custom_particle_replaces_the_vanilla_type_of_its_id() {
let root = pack(&[(
@@ -303,8 +303,12 @@ fn faded(code: u32, local: vec3<f32>, dx: vec3<f32>, dy: vec3<f32>) -> bool {
let pixel = max(length(dx), length(dy));
let unit = exp2(ceil(log2(max(pixel * 1.5, 1.0 / 16.0))));
let p = vec2<u32>(bitcast<vec2<u32>>(vec2<i32>(floor(plane / unit)))) & vec2(3u);
let bayer = array<u32, 16>(0u, 8u, 2u, 10u, 12u, 4u, 14u, 6u, 3u, 11u, 1u, 9u, 15u, 7u, 13u, 5u);
return (f32(bayer[p.y * 4u + p.x]) + 0.5) / 16.0 >= shown;
// The 4x4 Bayer matrix by rows, 0 8 2 10, 12 4 14 6, 3 11 1 9, 15 7 13 5: four bits each,
// two rows a word (a constant array indexed by the pixel would be a private array, which
// Adreno's Vulkan compiler, build 0676.41, fails to link).
let rows = select(0x6e4ca280u, 0x5d7f91b3u, p.y >= 2u);
let bayer = (rows >> (((p.y & 1u) * 4u + p.x) * 4u)) & 0xfu;
return (f32(bayer) + 0.5) / 16.0 >= shown;
}
// A fading tile or section is drawn a hair toward the camera (depth is
// reversed: nearer is larger), the newer the more, so it wins where it draws
@@ -1053,5 +1053,100 @@
"horizon.studio.topbar.add_marker": "Додати маркер",
"horizon.studio.topbar.clear_keyframes": "🗑 Очистити ключові кадри",
"horizon.studio.topbar.timeline_hud": "⏱ Шкала часу",
"horizon.studio.topbar.split_scene": "Розділити сцену"
"horizon.studio.topbar.split_scene": "Розділити сцену",
"horizon.options.radial_mode": "Радіальне меню",
"horizon.options.radial_mode.about": "Утримуйте кнопку відкриття радіального меню або натисніть її раз, щоб відкрити, і ще раз, щоб закрити.",
"horizon.options.radial_mode.hold.about": "Утримуйте кнопку відкриття й відпустіть, щоб виконати те, на що вказує стік.",
"horizon.options.radial_mode.toggle.about": "Натисніть кнопку відкриття, щоб відкрити меню; A або повторне натискання кнопки відкриття виконує те, на що вказує стік, B закриває.",
"horizon.options.radial_stick": "Вибір у радіальному меню",
"horizon.options.radial_stick.about": "Стік, яким ви вказуєте на комірки радіального меню.",
"horizon.options.radial_stick.right": "Правий стік",
"horizon.options.radial_stick.left": "Лівий стік",
"horizon.options.radial_stick.either": "Будь-який",
"horizon.options.radial_stick.right.about": "Правий стік вказує на комірки; лівим ви й далі ходите.",
"horizon.options.radial_stick.left.about": "Лівий стік вказує, а ви стоїте на місці. Під тим самим пальцем — хрестовина праворуч, тож оберіть кнопку відкриття на сторінці «Радіальне меню».",
"horizon.options.radial_stick.either.about": "Вказує стік, яким рухали останнім; жоден не рухає вас і не повертає огляд. Відкрити меню можна лише бампером, тригером або кнопкою View.",
"horizon.pages.controller_radial": "Радіальне меню",
"horizon.radial.page.general": "Загальне",
"horizon.radial.page.camera": "Камера",
"horizon.radial.release_stick": "Відпустіть стік",
"horizon.radial.action.key_inventory": "Інвентар",
"horizon.radial.action.key_chat": "Чат",
"horizon.radial.action.key_command": "Команди",
"horizon.radial.action.key_advancements": "Досягнення",
"horizon.radial.action.key_togglePerspective": "Перспектива",
"horizon.radial.action.key_swapOffhand": "Поміняти руки",
"horizon.radial.action.key_drop": "Викинути",
"horizon.radial.action.key_pickItem": "Вибрати блок",
"horizon.radial.action.key_screenshot": "Знімок екрана",
"horizon.radial.action.key_toggleGui": "Сховати HUD",
"horizon.radial.action.key_smoothCamera": "Кінематографічна камера",
"horizon.radial.action.key_socialInteractions": "Соціальне",
"horizon.radial.action.key_emote": "Емоція",
"horizon.radial.action.pad_hotbarPrevious": "Попередня комірка",
"horizon.radial.action.pad_hotbarNext": "Наступна комірка",
"horizon.radial.action.key_fullscreen": "Повний екран",
"horizon.radial.action.key_quickActions": "Швидкі дії",
"horizon.radial.action.key_debug_overlay": "Екран налагодження",
"horizon.radial.missing": "Відсутня",
"horizon.radial.why.missing": "Невідома дія",
"horizon.radial.why.cosmetics": "Потрібна увімкнена косметика",
"horizon.radial.why.quick_actions": "На цьому сервері немає швидких дій",
"horizon.radial.why.social": "Лише на сервері",
"horizon.radial.next": "→ %s",
"horizon.radial.state.first_person": "Від першої особи",
"horizon.radial.state.third_person_back": "Від третьої особи",
"horizon.radial.state.third_person_front": "Вигляд спереду",
"horizon.radial.state.shown": "Показано",
"horizon.radial.state.hidden": "Приховано",
"horizon.radial.state.on": "Увімк.",
"horizon.radial.state.off": "Вимк.",
"horizon.radial.pick_title": "Оберіть дію",
"horizon.radial.group.actions": "Дії",
"horizon.radial.pick.slot": "Комірка «%s» сторінки «%s»",
"horizon.radial.pick.about": "Ставить цю дію в комірку й повертається назад. Поточну дію комірки позначено.",
"horizon.radial.pick.current": "Поточна",
"horizon.radial.missing.about": "Ця гра не знає дії %s: комірка зберігає її, але нічого не виконує. Натисніть або «праворуч», щоб поставити сюди іншу.",
"horizon.radial.page.new": "Нова сторінка",
"horizon.pages.open.controller_radial": "Радіальне меню...",
"horizon.radial.group.slots": "Комірки",
"horizon.radial.group.page": "Сторінка",
"horizon.radial.group.all": "Усі сторінки",
"horizon.radial.empty": "Порожньо",
"horizon.radial.page_of": "Сторінка %s з %s. Кожна комірка — це напрямок стіка вибору, поки кнопку відкриття утримано.",
"horizon.radial.slot.0": "Угору",
"horizon.radial.slot.1": "Угору праворуч",
"horizon.radial.slot.2": "Праворуч",
"horizon.radial.slot.3": "Униз праворуч",
"horizon.radial.slot.4": "Униз",
"horizon.radial.slot.5": "Униз ліворуч",
"horizon.radial.slot.6": "Ліворуч",
"horizon.radial.slot.7": "Угору ліворуч",
"horizon.radial.slot.about": "Дія, яку виконує цей напрямок. Натисніть, щоб побачити список дій; «праворуч» — наступна, «ліворуч» або Shift+клік — попередня.",
"horizon.radial.rename": "Перейменувати сторінку",
"horizon.radial.rename.about": "Дайте цій сторінці власну назву.",
"horizon.radial.name_title": "Назва сторінки",
"horizon.radial.new_page.about": "Додає порожню сторінку після останньої й показує її.",
"horizon.radial.new_page.full": "Меню вміщує щонайбільше %s сторінок.",
"horizon.radial.delete_page": "Видалити сторінку",
"horizon.radial.delete_page.about": "Одразу видаляє цю сторінку та її комірки. «Скасувати» повертає її.",
"horizon.radial.delete_page.last": "У меню лишається щонайменше одна сторінка.",
"horizon.radial.reset": "Скинути всі сторінки",
"horizon.radial.reset.about": "Одразу повертає сторінки «Загальне» й «Камера» та видаляє решту. «Скасувати» повертає їх.",
"horizon.radial.reset.default": "Сторінки вже типові.",
"horizon.radial.opener": "Кнопка відкриття",
"horizon.radial.opener.about": "Кнопка, яку утримують, щоб відкрити меню. Натисніть, потім натисніть кнопку геймпада: бампер або хрестовину, але не A, B, X, Y чи правий стік, якими керують і скасовують.",
"horizon.radial.opener.about.left": "Кнопка, яку утримують, щоб відкрити меню. Натисніть, потім натисніть кнопку геймпада: бампер, A, X або Y, але не хрестовину чи лівий стік, якими керують, і не B, яка скасовує.",
"horizon.radial.opener.about.either": "Кнопка, яку утримують, щоб відкрити меню. Натисніть, потім натисніть кнопку геймпада: бампер, тригер або View; обидва великі пальці керують, а B скасовує.",
"horizon.radial.unbound": "Жодна кнопка не відкриває меню (кнопку під пальцем, яким обирають, звільнено): натисніть, потім натисніть кнопку геймпада.",
"horizon.radial.conflict": "Спільна з іншою дією: у грі цю кнопку забирає меню. Натисніть, щоб обрати іншу.",
"Radial Menu": "Радіальне меню",
"Named pages of eight actions, held open with a button.": "Іменовані сторінки по вісім дій, відкриті, поки утримуєте кнопку.",
"The shader pack lights the world itself.": "Пакет шейдерів сам освітлює світ.",
"How bright the HUD and menus are. Auto: at paper white.": "Яскравість HUD і меню. «Авто»: на рівні білої точки.",
"HDR output stopped the game the last time it started with this graphics driver. Choose a mode to try again.": "Минулого разу з цим графічним драйвером HDR-вивід зупинив гру. Оберіть режим, щоб спробувати знову.",
"horizon.pad.action.radial": "Радіальне меню",
"horizon.recipes.fuel.options": "Утримуйте SHIFT, щоб закріпити цю підказку, потім клацніть інгредієнт, щоб переглянути рецепти.",
"horizon.recipes.fuel.any": "Будь-яке паливо",
"horizon.recipes.fuel.minecraft": "Minecraft"
}
@@ -714,7 +714,7 @@ pub fn markdown() -> String {
let cell = |s: &str| s.replace('|', "\\|");
let mut out = String::from(
"<!-- Generated by `horizon-ctl help --markdown` from the command table in \
crates/horizon-ctl/src/commands.rs: edit the table, then regenerate. -->\n\
crates/horizon-client/src/bin/horizon-ctl/commands.rs: edit the table, then regenerate. -->\n\
# horizon-ctl commands\n\n\
Every command `horizon-ctl` takes, its MCP tool in `horizon-ctl mcp` (the launcher's \
`horizon-launcher mcp` offers the same tools as `game_<tool>`, all but `launch`) and its \
+115 -80
View File
@@ -1996,7 +1996,7 @@ pub fn mob_from_nbt_with_random(
if crate::mobs::equine::is_equine(m.species()) {
m.health = m.health.min(m.attrs.max_health);
}
m.hurt_time = number(tag.get("HurtTime")).unwrap_or(0) as i32;
m.hurt_time = i32::from(number(tag.get("HurtTime")).unwrap_or(0) as i16);
// What it holds (equipment.mainhand / offhand), when saved.
if let Some(eq) = tag.get("equipment") {
m.extra
@@ -2034,7 +2034,7 @@ pub fn mob_from_nbt_with_random(
// vanilla); a loaded mob holds only what its file lists.
m.extra.set_main_hand(None);
}
m.death_time = number(tag.get("DeathTime")).unwrap_or(0) as i32;
m.death_time = i32::from(number(tag.get("DeathTime")).unwrap_or(0) as i16);
m.impulse_grace_time =
number(tag.get("current_impulse_context_reset_grace_time")).unwrap_or(0) as i32;
// Mob.readAdditionalSaveData overrides what the constructor set (copper
@@ -2423,7 +2423,7 @@ pub fn mob_from_nbt_with_random(
restore_axolotl_memories(&mut m, tag);
}
read_variant(&mut m, tag);
m.fire_ticks = number(tag.get("Fire")).unwrap_or(0) as i32;
m.fire_ticks = i32::from(number(tag.get("Fire")).unwrap_or(0) as i16);
// Entity.load: `input.getIntOr("Air", getMaxAirSupply())`, 4800 for a dolphin.
m.air = number(tag.get("Air")).map_or(m.max_air_supply(), |air| air as i32);
if m.species() == crate::mobs::Species::Allay {
@@ -2609,7 +2609,7 @@ pub fn mob_from_nbt_with_random(
}
// Sheep.readAdditionalSaveData: white and unsheared when absent.
if m.species() == crate::mobs::Species::Sheep {
m.extra.color = number(tag.get("Color")).map_or(0, |c| (c as u8) & 15);
m.extra.color = crate::mobs::farm::saved_color(tag.get("Color"));
m.extra.sheared = flag(tag, "Sheared");
}
// GlowSquid.readAdditionalSaveData: not dimmed when absent.
@@ -2959,7 +2959,8 @@ pub fn item_entity_from_nbt(tag: &Tag, id: u32, bob_offset: f32) -> Option<ItemE
/// `OminousItemSpawner.addAdditionalSaveData`: its item (left out when
/// empty) and `spawn_item_after_ticks`. Its tick count is the entity's
/// `tickCount`, which isn't saved: a reloaded spawner counts from 0 again.
/// It never moves, so the `Motion` it was loaded with is what it saves.
/// It never moves or turns: it saves the Motion, Rotation, OnGround and
/// fall_distance it was loaded with (`Entity.load`).
pub fn ominous_item_spawner_to_nbt(
item: &crate::items::ItemStack,
position: Vec3,
@@ -2972,13 +2973,12 @@ pub fn ominous_item_spawner_to_nbt(
uuid,
position,
motion: raw.map_or(Vec3::ZERO, loaded_motion),
rotation: [0.0, 0.0],
on_ground: false,
fall_distance: 0.0,
rotation: raw.map_or([0.0, 0.0], rotation),
on_ground: raw.is_some_and(|t| flag(t, "OnGround")),
fall_distance: float(raw.and_then(|t| t.get("fall_distance"))).unwrap_or(0.0),
};
let mut tag = base.tag(raw);
// `ItemStack.CODEC` can't encode `ItemStack.EMPTY`, so an empty item
// writes no key.
// `ItemStack.CODEC` can't encode `ItemStack.EMPTY`: an empty item writes no key.
if let Tag::Compound(map) = &mut tag {
map.remove(&"item".into());
}
@@ -3005,59 +3005,8 @@ pub fn ominous_item_spawner_from_nbt(tag: &Tag) -> Option<(crate::items::ItemSta
Some((item, position(tag)?, after))
}
/// `ExperienceOrb`: Health, Age, Value, Count.
pub fn orb_to_nbt(o: &ExperienceOrb, uuid: [i32; 4]) -> Tag {
let base = Base {
id: "minecraft:experience_orb",
uuid,
position: o.position,
motion: o.velocity,
rotation: o.rotation,
on_ground: o.on_ground,
fall_distance: 0.0,
};
let mut tag = base.tag(None);
for (key, value) in [
("Health", Tag::Short(o.health as i16)),
("Age", Tag::Short(o.age.min(i16::MAX as u32) as i16)),
("Value", Tag::Short(o.value as i16)),
("Count", Tag::Int(o.count.min(i32::MAX as u32) as i32)),
] {
put(&mut tag, key, value);
}
// Entity.saveWithoutId writes NoGravity only when set.
if o.no_gravity {
put(&mut tag, "NoGravity", Tag::Byte(1));
}
tag
}
/// One orb with its merged `Count`.
pub fn orbs_from_nbt(tag: &Tag) -> Vec<ExperienceOrb> {
let Some(position) = position(tag) else {
return Vec::new();
};
let count = number(tag.get("Count")).unwrap_or(1).max(1) as u32;
let orb = ExperienceOrb {
rotation: rotation(tag),
join: Default::default(),
no_gravity: flag(tag, "NoGravity"),
id: 0,
position,
previous: position,
velocity: motion(tag),
// `getShortOr("Value", 0)`, unclamped.
value: tag.get("Value").and_then(int_value).unwrap_or(0) as i16 as i32,
age: number(tag.get("Age")).unwrap_or(0).max(0) as u32,
on_ground: flag(tag, "OnGround"),
count,
ticks: 0,
health: number(tag.get("Health")).unwrap_or(5) as i32,
// ExperienceOrb's followingPlayer is transient: never saved, so a
// loaded orb starts without one.
following: None,
};
vec![orb]
}
mod orb;
pub use orb::{orb_to_nbt, orbs_from_nbt};
/// `Projectile` + `AbstractArrow.addAdditionalSaveData` of a live arrow;
/// `in_block` is its `inBlockState`, `raw` the compound it was loaded with.
@@ -3849,19 +3798,7 @@ pub fn armor_stand_to_nbt(a: &ArmorStand, uuid: [i32; 4], raw: Option<&Tag>) ->
("AbsorptionAmount", Tag::Float(0)),
("FallFlying", Tag::Byte(0)),
("current_impulse_context_reset_grace_time", Tag::Int(0)),
// AttributeMap.pack: the movement_speed instance the stand's first
// tick makes (removeFrost), at the registry default.
(
"attributes",
Tag::List(if a.speed_instance {
vec![Tag::compound([
("id", string(SPEED_ATTRIBUTE)),
("base", Tag::Double(0.7f64.to_bits())),
])]
} else {
Vec::new()
}),
),
("attributes", Tag::List(Vec::new())),
(
"Brain",
Tag::compound([("memories", Tag::Compound(Compound::new()))]),
@@ -3871,6 +3808,18 @@ pub fn armor_stand_to_nbt(a: &ArmorStand, uuid: [i32; 4], raw: Option<&Tag>) ->
] {
map.entry(key.into()).or_insert(value);
}
// `removeFrost`'s first-tick movement_speed, over a saved list without it.
if a.speed_instance
&& let Some(Tag::List(list)) = map.get_mut(&"attributes".into())
&& !list
.iter()
.any(|t| text(t.get("id")).as_deref() == Some(SPEED_ATTRIBUTE))
{
list.push(Tag::compound([
("id", string(SPEED_ATTRIBUTE)),
("base", Tag::Double(0.7f64.to_bits())),
]));
}
}
tag
}
@@ -3884,7 +3833,7 @@ pub fn armor_stand_from_nbt(tag: &Tag, id: u32) -> Option<ArmorStand> {
if list.iter().any(|attr| text(attr.get("id")).as_deref() == Some(SPEED_ATTRIBUTE)));
a.velocity = loaded_motion(tag);
a.health = float(tag.get("Health")).unwrap_or(20.0) as f32;
a.fire_ticks = number(tag.get("Fire")).unwrap_or(0) as i32;
a.fire_ticks = i32::from(number(tag.get("Fire")).unwrap_or(0) as i16);
a.custom_name = tag.get("CustomName").map(name_text);
a.name_visible = flag(tag, "CustomNameVisible");
if let Some(eq) = tag.get("equipment") {
@@ -4601,6 +4550,27 @@ mod tests {
}
}
/// `LivingEntity.readAdditionalSaveData` reads `HurtTime` and `DeathTime`
/// with `getShortOr`: an int tag is narrowed (`(short)(value & 65535)`),
/// so 40000 is -25536 and never counts down.
#[test]
fn a_mob_narrows_its_hurt_and_death_time_to_a_short() {
let kind = crate::mobs::sensor_scan_tests::kind();
let types = [(kind.id.to_string(), kind.clone())].into();
let load = |hurt: Tag, death: Tag| {
let raw = Tag::compound([
("id", string(&kind.id)),
("Pos", doubles(&[4.5, 220.0, 4.5])),
("HurtTime", hurt),
("DeathTime", death),
]);
let m = mob_from_nbt(&raw, &types, 1, None).unwrap();
(m.hurt_time, m.death_time)
};
assert_eq!(load(Tag::Int(40000), Tag::Int(65546)), (-25536, 10));
assert_eq!(load(Tag::Short(7), Tag::Short(19)), (7, 19));
}
/// A mount keeps its body armour and a helmet (say
/// `/item replace entity <horse> armor.head with minecraft:iron_helmet`)
/// in their own slots through a save: vanilla holds both (`equipment.body`
@@ -5891,6 +5861,43 @@ mod tests {
assert_eq!(attributes(&armor_stand_from_nbt(&saved, 1).unwrap()), speed);
}
#[test]
fn a_loaded_stand_gains_the_speed_instance_its_first_tick_makes() {
// Vanilla 26.3: `/summon armor_stand ~ ~ ~ {attributes:[]}` saves `[]` until the
// first tick, whose removeFrost makes movement_speed (base 0.7d); a saved
// instance of another attribute stays in front of it.
let ids = |stand: &ArmorStand, raw: &Tag| match armor_stand_to_nbt(stand, [0; 4], Some(raw))
.get("attributes")
{
Some(Tag::List(attrs)) => attrs
.iter()
.map(|a| (text(a.get("id")).unwrap(), a.get("base").cloned()))
.collect::<Vec<_>>(),
other => panic!("attributes: {other:?}"),
};
let health = (
"minecraft:max_health".to_owned(),
Some(Tag::Double(20f64.to_bits())),
);
let speed = (
"minecraft:movement_speed".to_owned(),
Some(Tag::Double(0.7f64.to_bits())),
);
let empty = Tag::compound([("attributes", Tag::List(Vec::new()))]);
let saved = Tag::compound([(
"attributes",
Tag::List(vec![Tag::compound([
("id", string("minecraft:max_health")),
("base", Tag::Double(20f64.to_bits())),
])]),
)]);
let mut stand = ArmorStand::new(1, Vec3::ZERO, 0.0);
assert_eq!(ids(&stand, &empty), []);
assert_eq!(ids(&stand, &saved), std::slice::from_ref(&health));
stand.speed_instance = true;
assert_eq!(ids(&stand, &empty), std::slice::from_ref(&speed));
assert_eq!(ids(&stand, &saved), [health, speed]);
}
#[test]
fn summoned_trident_defaults_are_not_pickable_or_already_shot() {
let raw = Tag::compound([
("Pos", doubles(&[0.0, 0.0, 0.0])),
@@ -7411,11 +7418,11 @@ mod sheep_nbt_tests {
assert_eq!(number(tag.get("InWaterTime")), Some(300));
assert_eq!(number(tag.get("DrownedConversionTime")), Some(-1));
assert_eq!((back.extra.counter, back.extra.counter2), (300, 0));
(zombie.extra.counter, zombie.extra.counter2) = (600, 300);
(zombie.extra.counter, zombie.extra.counter2) = (600, 301);
let (back, tag) = round_trip(&zombie);
assert_eq!(number(tag.get("InWaterTime")), Some(600));
assert_eq!(number(tag.get("DrownedConversionTime")), Some(299));
assert_eq!((back.extra.counter, back.extra.counter2), (600, 300));
assert_eq!(number(tag.get("DrownedConversionTime")), Some(300));
assert_eq!((back.extra.counter, back.extra.counter2), (600, 301));
// A skeleton outside powder snow is not afflicted: -1 whatever the
// counter holds, as with a fresh or NoAI one.
let (_, tag) = round_trip(&new("minecraft:skeleton"));
@@ -9462,6 +9469,34 @@ mod pickup_loot_save_tests {
mod stand_fall_save_tests {
use super::*;
/// `Entity.load` reads `Fire` with `getShortOr`: an int tag is narrowed
/// (`(short)(value & 65535)`), so 40000 is -25536 and the stand or mob
/// does not burn.
#[test]
fn a_stand_and_a_mob_narrow_a_loaded_fire_to_a_short() {
let kind = crate::mobs::sensor_scan_tests::kind();
let types = [(kind.id.to_string(), kind.clone())].into();
for (fire, expected) in [
(Tag::Int(40000), -25536),
(Tag::Int(65546), 10),
(Tag::Short(60), 60),
] {
let input = Tag::compound([
("id", string(&kind.id)),
("Pos", doubles(&[4.5, 205.0, 4.5])),
("Fire", fire),
]);
assert_eq!(
armor_stand_from_nbt(&input, 1).unwrap().fire_ticks,
expected
);
assert_eq!(
mob_from_nbt(&input, &types, 1, None).unwrap().fire_ticks,
expected
);
}
}
#[test]
fn decoded_stand_fall_distance_survives_save_without_raw_or_with_stale_raw() {
let stale = Tag::compound([("fall_distance", Tag::Double(99.0_f64.to_bits()))]);
@@ -39,10 +39,16 @@ fn attached_block_pos(tag: &Tag) -> Option<[i32; 3]> {
}
}
}
/// `Direction.from3DDataValue` / `from2DDataValue`: the byte read by
/// `LEGACY_ID_CODEC` indexes the table as `abs(data % modulus)`, never clamped.
fn wrap_data_value(raw: impl TryInto<i64> + Copy, modulus: i64) -> u8 {
let byte = raw.try_into().unwrap_or(0) as i8;
(i64::from(byte) % modulus).unsigned_abs() as u8
}
/// The block position, facing and variant (if saved) of a painting.
pub fn painting_from_nbt(tag: &Tag) -> Option<([i32; 3], u8, Option<String>)> {
let pos = attached_block_pos(tag)?;
let facing = (number(tag.get("facing")).unwrap_or(0) & 3) as u8;
let facing = wrap_data_value(number(tag.get("facing")).unwrap_or(0), 4);
Some((pos, facing, text(tag.get("variant"))))
}
/// ItemFrame.addAdditionalSaveData.
@@ -92,7 +98,7 @@ pub fn item_frame_from_nbt(tag: &Tag, id: u32) -> Option<ItemFrame> {
Some(ItemFrame {
id,
pos,
facing: number(tag.get("Facing")).unwrap_or(0).clamp(0, 5) as u8,
facing: wrap_data_value(number(tag.get("Facing")).unwrap_or(0), 6),
glow: kind(tag).as_deref() == Some("minecraft:glow_item_frame"),
item: tag
.get("Item")
@@ -107,3 +113,26 @@ pub fn item_frame_from_nbt(tag: &Tag, id: u32) -> Option<ItemFrame> {
random: crate::random_source::entity(),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn negative_facing_wraps_like_vanilla_direction_codecs() {
// from3DDataValue: abs(-1 % 6) = 1 (UP); 7 -> 1; -6 -> 0.
for (raw, want) in [(-1i8, 1u8), (7, 1), (-6, 0), (5, 5), (0, 0)] {
let tag = Tag::compound([
("Facing", Tag::Byte(raw)),
("block_pos", Tag::IntArray(vec![0, 0, 0])),
]);
assert_eq!(item_frame_from_nbt(&tag, 1).unwrap().facing, want);
}
// from2DDataValue: abs(-1 % 4) = 1.
let tag = Tag::compound([
("facing", Tag::Byte(-1)),
("block_pos", Tag::IntArray(vec![0, 0, 0])),
]);
assert_eq!(painting_from_nbt(&tag).unwrap().1, 1);
}
}
@@ -0,0 +1,56 @@
//! Experience orbs: `ExperienceOrb` save data.
use super::{Base, ExperienceOrb, Tag, flag, int_value, motion, number, position, put, rotation};
/// `ExperienceOrb`: Health, Age, Value, Count.
pub fn orb_to_nbt(o: &ExperienceOrb, uuid: [i32; 4]) -> Tag {
let base = Base {
id: "minecraft:experience_orb",
uuid,
position: o.position,
motion: o.velocity,
rotation: o.rotation,
on_ground: o.on_ground,
fall_distance: 0.0,
};
let mut tag = base.tag(None);
for (key, value) in [
("Health", Tag::Short(o.health as i16)),
("Age", Tag::Short(o.age.min(i16::MAX as u32) as i16)),
("Value", Tag::Short(o.value as i16)),
("Count", Tag::Int(o.count.min(i32::MAX as u32) as i32)),
] {
put(&mut tag, key, value);
}
// Entity.saveWithoutId writes NoGravity only when set.
if o.no_gravity {
put(&mut tag, "NoGravity", Tag::Byte(1));
}
tag
}
/// One orb with its merged `Count`.
pub fn orbs_from_nbt(tag: &Tag) -> Vec<ExperienceOrb> {
let Some(position) = position(tag) else {
return Vec::new();
};
let count = number(tag.get("Count")).unwrap_or(1).max(1) as u32;
let orb = ExperienceOrb {
rotation: rotation(tag),
join: Default::default(),
no_gravity: flag(tag, "NoGravity"),
id: 0,
position,
previous: position,
velocity: motion(tag),
// `getShortOr("Value", 0)`, unclamped.
value: tag.get("Value").and_then(int_value).unwrap_or(0) as i16 as i32,
age: number(tag.get("Age")).unwrap_or(0).max(0) as u32,
on_ground: flag(tag, "OnGround"),
count,
ticks: 0,
health: number(tag.get("Health")).unwrap_or(5) as i32,
// ExperienceOrb's followingPlayer is transient: never saved, so a
// loaded orb starts without one.
following: None,
};
vec![orb]
}
+55 -61
View File
@@ -497,7 +497,7 @@ use horizon_client::random_source;
pub use advancements::AdvancementDisplay;
use horizon_client::actions::Action as Mapping;
use horizon_client::held::Source;
use horizon_client::spans::startup;
use horizon_client::spans::{startup, time as span};
use horizon_client::{
Result,
assets::{Assets, RenderAssets},
@@ -3889,6 +3889,7 @@ impl Game<'_> {
golem_chests.extend(self.chest_other_half(key));
}
let native_authoritative = self.native_authoritative();
let environment_type = self.dimension_type();
let mut level = GameLevel {
gateway_servers: &mut self.be_anims.gateway_servers,
border: self.border.geometry(),
@@ -3911,11 +3912,7 @@ impl Game<'_> {
events: &mut self.level_events,
dimension,
darken,
environment: match dimension {
LevelDimension::Overworld => self.res.environment(),
LevelDimension::Nether => self.res.nether_environment(),
LevelDimension::End => self.res.end_environment(),
},
environment: self.res.environment_of(&environment_type.id),
raining: rain > 0.2,
thundering: thunder > 0.9,
player_box,
@@ -5545,7 +5542,7 @@ impl Game<'_> {
// the activated item counts down.
self.boss_overlay.tick_darken(running);
self.tick_item_activation();
self.native_inventory_ticks();
span("mods_inventory_ticks", || self.native_inventory_ticks());
if running {
// `MinecraftServer.tickChildren`: `commandFunctions` runs before
// any level ticks, so a tick function reads the game time and the
@@ -5553,7 +5550,7 @@ impl Game<'_> {
// long before this point; the lazy scoreboard/storage read has to
// be done by then too, or a load tag writes over what it restores.
self.load_scoreboard();
self.tick_functions();
span("tick_functions", || self.tick_functions());
self.world.game_time += 1;
if self.ui.rules.bool("advance_weather") {
self.world.weather.tick_timers(&mut self.world.random);
@@ -5567,7 +5564,7 @@ impl Game<'_> {
}
// ClientLevel.tick: the sky flash fades in a frozen level too.
self.tick_weather_effects();
let _ = self.finish_player_autosaves(false);
let _ = span("player_autosaves", || self.finish_player_autosaves(false));
// MinecraftServer.tickServer's countdown, then autoSave: three
// hundred seconds of ticks at the rate the server runs at now.
self.ticks_until_autosave -= 1;
@@ -5576,7 +5573,7 @@ impl Game<'_> {
serving.set_saving(self.autosaving);
}
if self.autosaving {
self.save_game_with(false)?;
span("autosave", || self.save_game_with(false))?;
self.ticks_until_autosave = self.next_autosave_interval();
if let Some(serving) = &self.serving {
serving.set_saving(false);
@@ -5585,27 +5582,27 @@ impl Game<'_> {
if running {
self.advance_clock();
// `ServerLevel.tickTime`, once the clock has moved on.
self.run_scheduled_functions();
span("scheduled_functions", || self.run_scheduled_functions());
}
if self.use_delay > 0 {
self.use_delay -= 1;
}
self.tick_totem();
self.update_totem_ready();
self.tick_command_extras();
self.native_tick();
self.tell_native_now();
span("tick_command_extras", || self.tick_command_extras());
span("mods_tick", || self.native_tick());
span("mods_tick", || self.tell_native_now());
self.tick_world(running)
}
/// What the tick runs after the local player's own update: effects, the
/// world's entities and blocks, and the network hub.
fn tick_world(&mut self, running: bool) -> Result<()> {
// Entities added since the last tick join the level before it ticks.
self.native_joins_flush(false, 0);
self.share_spawn_conditions();
span("mods_joins_flush", || self.native_joins_flush(false, 0));
span("share_spawn_conditions", || self.share_spawn_conditions());
horizon_client::mobs::sulfur_cube::set_tnt_explodes(self.ui.rules.bool("tnt_explodes"));
self.update_chunk_tickets();
self.score_destroyed_entities();
span("chunk_tickets", || self.update_chunk_tickets());
span("score_destroyed", || self.score_destroyed_entities());
horizon_client::spans::time("tick_effects", || self.tick_effects(running));
self.tick_sign_editor();
// Player.tick's ItemCooldowns.tick for the window's player (a
@@ -5629,8 +5626,8 @@ impl Game<'_> {
sent = horizon_client::spans::time("tick_blocks", || self.tick_blocks())?;
// What the blocks spawned (a dispensed minecart, a lit TNT, a
// placed boat) joins before the entities tick.
self.native_joins_flush(false, 0);
self.lan_mark_synced();
span("mods_joins_flush", || self.native_joins_flush(false, 0));
span("lan_mark_synced", || self.lan_mark_synced());
// `EntityTickList.forEach`: what joined before the entity pass
// (function summons, dropped or dispensed items) ticks in it;
// what joins during it waits for the next tick
@@ -5640,7 +5637,7 @@ impl Game<'_> {
}
// ChunkMap still ticks while /tick freeze holds block/entity ticks.
if !running {
self.save_chunks_eagerly();
span("save_chunks_eagerly", || self.save_chunks_eagerly());
}
self.begin_ordered_ticks();
if running {
@@ -5654,8 +5651,8 @@ impl Game<'_> {
// ServerLevel.tick: the arrivals of this server tick are in the level
// (`addDuringTeleport`), and each vehicle handles its portal as its
// own tick opens.
self.land_portal_arrivals();
self.handle_vehicle_portals();
span("portal_arrivals", || self.land_portal_arrivals());
span("vehicle_portals", || self.handle_vehicle_portals());
self.ordered_before(ordered_tick::Batch::Boats)?;
horizon_client::spans::time("tick_boats", || self.tick_boats());
self.ordered_before(ordered_tick::Batch::Carts)?;
@@ -5685,11 +5682,11 @@ impl Game<'_> {
self.ordered_before(ordered_tick::Batch::Items)?;
horizon_client::spans::time("tick_items", || self.tick_items());
if self.remote.is_none() {
self.hoppers_catch_items();
span("hoppers_catch_items", || self.hoppers_catch_items());
}
// Merged, burnt, despawned or picked up.
self.native_leaves_flush();
self.native_joins_flush(false, 0);
span("mods_leaves_flush", || self.native_leaves_flush());
span("mods_joins_flush", || self.native_joins_flush(false, 0));
horizon_client::spans::time("touch_arrows", || self.touch_arrows());
}
horizon_client::spans::time("spawn_loaded_entities", || self.spawn_loaded_entities());
@@ -5699,22 +5696,22 @@ impl Game<'_> {
self.ordered_before(ordered_tick::Batch::Mobs)?;
horizon_client::spans::time("tick_mobs", || self.tick_mobs())?;
// Dead, despawned or gone into a hive.
self.native_leaves_flush();
span("mods_leaves_flush", || self.native_leaves_flush());
horizon_client::spans::time("recheck_golem_openers", || self.recheck_golem_openers());
self.tick_mod_container_viewers();
span("mod_viewers", || self.tick_mod_container_viewers());
// Orbs, primed TNT and falling blocks still to tick, in join order.
self.run_ordered_ticks(horizon_client::join_seq::JoinSeq::MAX, false)?;
horizon_client::spans::time("tick_experience", || self.tick_experience());
// Merged, despawned or picked up.
self.native_leaves_flush();
span("mods_leaves_flush", || self.native_leaves_flush());
horizon_client::spans::time("entity_portals", || {
self.find_entity_portals();
self.resolve_arrivals();
});
}
// The rest of the tick's spawns join, and what left is told of.
self.native_joins_flush(false, 0);
self.native_leaves_flush();
span("mods_joins_flush", || self.native_joins_flush(false, 0));
span("mods_leaves_flush", || self.native_leaves_flush());
// ServerLevel.tick broadcasts the changed blocks after the scheduled and
// random ticks (`tick_blocks`), before the block events, the entities
// and the block entities: what those change goes out with the next
@@ -5729,23 +5726,23 @@ impl Game<'_> {
if let Some(log) = self.world.block_log.as_mut() {
log.extend(held);
}
self.tick_friends();
span("tick_friends", || self.tick_friends());
horizon_client::spans::time("tick_hand", || self.tick_hand());
horizon_client::spans::time("tick_sleep", || self.tick_sleep());
self.announce_sleep_status();
self.free_left_beds();
span("sleep_status", || self.announce_sleep_status());
span("sleep_status", || self.free_left_beds());
if running {
horizon_client::spans::time("tick_end", || self.tick_end())?;
horizon_client::spans::time("end_gateways", || {
self.find_end_gateway_portals();
self.resolve_end_gateway_arrivals()
})?;
self.keep_gateway_destinations();
span("end_gateways", || self.keep_gateway_destinations());
}
if running {
// Primed TNT and falling blocks that joined since the spawn packets.
self.run_ordered_ticks(horizon_client::join_seq::JoinSeq::MAX, true)?;
self.apply_late_wind_pushes();
span("late_wind_pushes", || self.apply_late_wind_pushes());
}
// Everything that has joined has had its spawn packet and, if it joined
// before the entity passes (TNT a piston or repeater primed), its first
@@ -5764,21 +5761,21 @@ impl Game<'_> {
self.tick_block_entity_anims()
});
// A conduit's hurt teleports an enderman now (`Enderman.hurtServer`).
self.settle_hurt_teleports()?;
span("settle_hurt_teleports", || self.settle_hurt_teleports())?;
horizon_client::spans::time("tick_particles", || self.tick_particles());
self.tick_client_ambient();
span("tick_client_ambient", || self.tick_client_ambient());
}
// ServerLevel.tick's `entityManager.tick()`, frozen or not, after every
// block entity ticker (the bells, conduits and gateways above too): the
// chunks loaded before this pass have their entities loaded from the next.
self.world.entities_pending.clear();
self.tick_frame_visuals(running);
span("tick_frame_visuals", || self.tick_frame_visuals(running));
if running {
// MinecraftServer.tickServer: GameTestTicker.SINGLETON.tick().
self.tick_game_tests();
span("tick_game_tests", || self.tick_game_tests());
}
// ServerBossEvent broadcasts, after this level's entities have ticked.
self.publish_boss_bars();
span("publish_boss_bars", || self.publish_boss_bars());
// Minecraft.handleKeybinds: held use repeats only while no item is in
// use (a raised shield or a drawn bow places and uses nothing).
if self.right
@@ -6734,19 +6731,14 @@ impl Game<'_> {
// Entity shadows (EntityRenderer.extractShadow); a native pack's sun shadows replace them.
self.mark_translucent_entity(out.len(), "minecraft:entity_shadow");
let pack_shadows = self.renderer.as_ref().is_some_and(|r| {
r.pack_entity_shadows() || (self.dim == Dim::Overworld && r.native_shadow())
r.pack_entity_shadows() || (self.overworld_skybox() && r.native_shadow())
});
let shadows = self.settings.entity_shadows && self.pack.polytone_colors.entity_shadows();
let shadow_sprite = (shadows && !pack_shadows)
.then(|| self.gui.atlas.sprites.get("misc/shadow").copied())
.flatten();
if let Some(sprite) = shadow_sprite {
// DimensionType.ambientLight: the_nether 0.1, the_end 0.25.
let ambient = match self.dim {
Dim::Nether => 0.1,
Dim::End => 0.25,
Dim::Overworld => 0.0,
};
let ambient = self.dimension_type().ambient_light;
let darken = self.sky_darken();
let a = alpha;
let lerp = |p: Vec3, q: Vec3| {
@@ -6909,7 +6901,7 @@ impl Game<'_> {
emissive: &self.gui.atlas.emissive,
translucent_sprites: &self.gui.atlas.translucent,
atlas_revision: self.gui.atlas.revision,
nether: self.dim == Dim::Nether,
nether: self.nether_lighting(),
lid: &lid,
sign: &|p| match self.world.block_entities.get(&(p[0], p[1], p[2])) {
Some(BlockEntity::Sign(sign)) => Some(sign),
@@ -7627,8 +7619,10 @@ impl Game<'_> {
r.clear_sections(self.world.layout());
}
self.apply_settings();
self.streaming.sky_light = dim != Dim::Nether;
self.streaming.nether_shading = dim == Dim::Nether;
let facts = self.dimension_type_of(dim);
self.streaming.sky_light = facts.height.has_skylight;
self.streaming.nether_shading =
facts.cardinal_light == horizon_world::dimension_type::CardinalLight::Nether;
self.load_world_state();
// The level the world opens in, before any of its chunks.
self.native_level_opened(dim);
@@ -9317,10 +9311,10 @@ impl Game<'_> {
game_time: self.world.game_time,
day_time: self.view_day_time(),
render_distance: self.streaming.radius,
sky_type: match self.dim {
Dim::Overworld => 1,
Dim::End => 2,
Dim::Nether => 0,
sky_type: match self.dimension_type().skybox {
horizon_world::dimension_type::Skybox::Overworld => 1,
horizon_world::dimension_type::Skybox::End => 2,
horizon_world::dimension_type::Skybox::None => 0,
},
dimension: match self.dim {
Dim::Overworld => "minecraft:overworld",
@@ -9662,13 +9656,13 @@ impl Game<'_> {
pr.lap("pr_dragon_bolts");
let (celestial, stars) = if title {
(Vec::new(), None)
} else if self.dim == Dim::Overworld
} else if self.overworld_skybox()
&& self.potions.effects.get("darkness").is_none()
&& self.potions.effects.get("blindness").is_none()
{
// Camera.doesMobEffectBlockSky: no sun, moon or stars.
self.celestial_geometry(&sky, rain)
} else if self.dim == Dim::End && self.settings.horizon.sky {
} else if self.end_skybox() && self.settings.horizon.sky {
(self.end_flash_geometry(), None)
} else {
(Vec::new(), None)
@@ -9742,7 +9736,7 @@ impl Game<'_> {
let frame = Frame {
weather: &weather,
weather_time: horizon_client::weather::Time::new(self.world.game_time, alpha as f32),
nether_lighting: self.dim == Dim::Nether,
nether_lighting: self.nether_lighting(),
camera,
yaw,
pitch,
@@ -9782,8 +9776,8 @@ impl Game<'_> {
|| self.potions.effects.get("darkness").is_some(),
self.boss_overlay.should_create_world_fog(),
),
skybox: self.dim == Dim::Overworld && self.settings.horizon.sky,
end_sky: self.dim == Dim::End && self.settings.horizon.sky,
skybox: self.overworld_skybox() && self.settings.horizon.sky,
end_sky: self.end_skybox() && self.settings.horizon.sky,
end_sky_sprite: self
.gui
.atlas
@@ -9807,7 +9801,7 @@ impl Game<'_> {
glint_alpha: self.glint_frame.alpha,
glint_ticks: self.glint_frame.scroll_ticks(),
clouds: (!title
&& self.dim == Dim::Overworld
&& self.overworld_skybox()
&& horizon_client::clouds::visible(&self.settings.clouds, sky.cloud_color[3]))
.then(|| self.gui.atlas.sprites.get("horizon/white"))
.flatten()
@@ -1954,8 +1954,7 @@ impl Game<'_> {
mut skin: Option<&mut horizon_client::mobs::skin::Skin>,
out: &mut Vec<Vertex>,
) {
let lights =
horizon_client::block_entity_render::diffuse_lights(self.dim == super::Dim::Nether);
let lights = horizon_client::block_entity_render::diffuse_lights(self.nether_lighting());
let mut submit = |state: &horizon_client::item_render::ItemRenderState,
pose: glam::Mat4,
out: &mut Vec<Vertex>| {
@@ -2028,7 +2027,7 @@ impl Game<'_> {
out,
light,
lights: Some(horizon_client::block_entity_render::diffuse_lights(
self.dim == super::Dim::Nether,
self.nether_lighting(),
)),
glint: self.glint(),
tier: 0,
@@ -801,8 +801,17 @@ impl Game<'_> {
let server = self.be_anims.gateway_servers.entry(key).or_insert(initial);
let triggered = server.portal_tick();
let age = server.age;
if let Some(BlockEntity::EndGateway(g)) = self.world.block_entities.get_mut(&pos) {
let mut aged = false;
if let Some(BlockEntity::EndGateway(g)) = self.world.block_entities.get_mut(&pos)
&& g.age != age
{
g.age = age;
aged = true;
}
if aged {
// `TheEndGatewayBlockEntity.portalTick` ends with `setChanged`: the
// age the beam and the chunk save read moved.
self.world.modified.insert((pos.0 >> 4, pos.2 >> 4));
}
if triggered {
self.gateway_cooldown_event(key);
@@ -1108,3 +1117,41 @@ mod bell_ownership_tests {
assert!(anims.bells.is_empty());
}
}
#[cfg(test)]
mod gateway_age_tests {
use super::*;
use crate::game::native_machines::tests::server_game;
/// `TheEndGatewayBlockEntity.portalTick`: an age that moved is a change
/// to save.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_gateway_that_ages_marks_the_chunk() {
let mut game = server_game();
let state = game
.pack
.assets
.registry
.default_state("minecraft:end_gateway")
.unwrap();
let pos = (2, 4, 3);
game.world.set(pos.0, pos.1, pos.2, state).unwrap();
game.world.block_entities.insert(
pos,
BlockEntity::EndGateway(horizon_client::containers::EndGateway::default()),
);
game.world.modified.clear();
game.tick_end_gateway(pos);
let Some(BlockEntity::EndGateway(g)) = game.world.block_entities.get(&pos) else {
panic!("no gateway");
};
assert_eq!(g.age, 1, "the tick aged the gateway");
assert!(
game.world.modified.contains(&(0, 0)),
"the aged gateway left the chunk unmarked"
);
}
}
+28 -15
View File
@@ -26,21 +26,7 @@ impl Game<'_> {
self.sync_tick_chunks();
self.start_ticking_chunks();
laps.lap("tb_tickets");
let levels = self.tickets.levels();
let bounds = levels.random_tick_bounds();
// Keep the world map's chunk order, including empty chunks
// (tick_chunk_random still draws for precipitation in those).
scratch.ticking.clear();
scratch
.ticking
.extend(self.world.chunks.keys().copied().filter(|&(x, z)| {
if let Some((min_x, min_z, max_x, max_z)) = bounds
&& (x < min_x || x > max_x || z < min_z || z > max_z)
{
return false;
}
levels.random_ticking(x, z) && ticking_chunk(&self.world, x, z)
}));
self.random_tick_chunks(&mut scratch.ticking);
laps.lap("tb_chunks");
// Entity.checkInsideBlocks for the plates, tripwires, detector rails and
// buttons under the players (the host and the LAN guests), the minecarts
@@ -287,6 +273,30 @@ impl Game<'_> {
Ok(sent)
}
/// `ChunkMap.forEachBlockTickingChunk`: the chunks that random tick this
/// tick, empty ones included (`tickChunk` still draws for precipitation in
/// those).
///
/// Vanilla walks the simulation tracker's own table, whose order doesn't
/// follow chunk loading. The world map's order does: it is filled as the
/// streaming workers finish, so the level random's draws landed on other
/// blocks from one run of a world to the next (#15164). Chunk position
/// order is the same every run; vanilla's exact order is its fastutil
/// table's slot order, which follows that table's whole history.
pub(super) fn random_tick_chunks(&self, out: &mut Vec<(i32, i32)>) {
let levels = self.tickets.levels();
let bounds = levels.random_tick_bounds();
out.clear();
out.extend(self.world.chunks.keys().copied().filter(|&(x, z)| {
if let Some((min_x, min_z, max_x, max_z)) = bounds
&& (x < min_x || x > max_x || z < min_z || z > max_z)
{
return false;
}
levels.random_ticking(x, z) && ticking_chunk(&self.world, x, z)
}));
out.sort_unstable();
}
/// Track which chunks are ticking (Ticks needs chunk load/unload).
pub(super) fn sync_tick_chunks(&mut self) {
if !self.tick_chunks.synchronize(&self.world.chunks) {
@@ -360,3 +370,6 @@ impl Game<'_> {
Ok(())
}
}
#[cfg(test)]
mod random_order_tests;
@@ -0,0 +1,57 @@
//! `ChunkMap.forEachBlockTickingChunk`: the order chunks random tick in must
//! not follow the order they finished loading in, or the level random's draws
//! land on other blocks from one run of a world to the next (#15164).
use super::super::Game;
use super::super::native_machines::tests::server_game;
use horizon_client::chunk::Chunk;
use horizon_core::geometry::Vec3;
use std::sync::Arc;
/// A game whose player stands in chunk (0, 0), with lit chunks loaded in
/// `order`.
fn game_loading(order: &[(i32, i32)]) -> Game<'static> {
let mut game = server_game();
game.settings.simulation_distance = 5;
game.settings.render_distance = 8;
game.player = Some(
horizon_client::motion::PlayerMotion::new(game.motion, Vec3::new(8.5, 65.0, 8.5)).unwrap(),
);
game.world.chunks.clear();
for &(x, z) in order {
let mut chunk = Chunk::empty(x, z);
chunk.lit = true;
game.world.chunks.insert((x, z), Arc::new(chunk));
}
game.update_chunk_tickets();
game
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn chunks_random_tick_in_the_same_order_however_they_loaded() {
let mut loaded: Vec<(i32, i32)> = (-8..=8)
.flat_map(|x| (-8..=8).map(move |z| (x, z)))
.collect();
let first = game_loading(&loaded);
// The streaming workers finish in another order on another run.
loaded.reverse();
loaded.rotate_left(97);
let second = game_loading(&loaded);
let (mut a, mut b) = (Vec::new(), Vec::new());
first.random_tick_chunks(&mut a);
second.random_tick_chunks(&mut b);
assert!(
a.contains(&(0, 0)) && a.contains(&(4, -4)),
"the simulation distance's chunks random tick: {a:?}"
);
assert!(
!a.contains(&(8, 0)),
"a chunk past the simulation distance doesn't"
);
assert_eq!(a, b);
let mut sorted = a.clone();
sorted.sort_unstable();
assert_eq!(a, sorted, "chunk position order");
}
+56 -15
View File
@@ -2,6 +2,7 @@
//! paddling controls, collisions, riding (`positionRider`, `clampRotation`,
//! dismounting), `BoatItem` placement, `VehicleEntity.hurtServer` breaking,
//! saving and `AbstractBoatRenderer` with animated paddles.
mod below_world;
mod placement;
use super::{Game, InteractTarget, entity::Entity, entity_culling::at, view_vector};
@@ -21,7 +22,7 @@ use horizon_client::{
motion::{BubbleColumn, RiddenBoat, bubble_column_at},
};
use horizon_core::{
geometry::{Aabb, ShapeOp, Vec3, VoxelShape},
geometry::{Aabb, ShapeOp, Vec3, VoxelShape, trig},
position::BlockPos,
random::{LegacyRandom, RandomSource},
};
@@ -96,6 +97,9 @@ pub(super) struct Boat {
/// `Entity.fallDistance`: counted by `AbstractBoat.checkFallDamage`, saved
/// and loaded (`fall_distance`).
pub fall_distance: f64,
/// `AbstractBoat.lastYd`: the vertical velocity at the last move's
/// `checkFallDamage`, before collisions zero it (not saved).
pub last_yd: f64,
/// `Entity.stuckSpeedMultiplier`: set where the boat sits in powder snow
/// (`PowderSnowBlock.entityInside`), spent by the next move.
stuck: Vec3,
@@ -162,6 +166,21 @@ pub(super) fn saved_fire(tag: &Tag) -> i32 {
}
pub(super) const WIDTH: f64 = 1.375;
pub(super) const HEIGHT: f64 = 0.5625;
/// `AbstractBoat.controlBoat`'s push along the boat's yaw:
/// `Mth.sin(-yRot * 0.017453292F) * acceleration` and `Mth.cos(..)`, with
/// `Mth`'s sine table and float products, widened to double.
fn paddle_push(yaw: f32, acceleration: f32) -> (f64, f64) {
let (x, z) = (-yaw * 0.017_453_292, yaw * 0.017_453_292);
(
f64::from(trig::sin(f64::from(x)) * acceleration),
f64::from(trig::cos(f64::from(z)) * acceleration),
)
}
/// `getBbWidth() * Mth.SQRT_OF_TWO` in `getDismountLocationForPassenger`: a
/// float product.
fn boat_escape_width() -> f64 {
f64::from(WIDTH as f32 * std::f32::consts::SQRT_2)
}
impl Boat {
/// `AbstractBoat.tick`: the hurt wobble and the damage wear off, before
/// `Entity.baseTick` can burn the boat.
@@ -217,6 +236,7 @@ impl Boat {
input: [false; 4],
on_ground: false,
fall_distance: 0.0,
last_yd: 0.0,
stuck: Vec3::ZERO,
chest: false,
items: Vec::new(),
@@ -542,7 +562,8 @@ impl Game<'_> {
fn water_level_above(&self, b: &Boat) -> f32 {
let bb = b.bounds();
let (lo, hi) = (bb.min_position(), bb.max_position());
let (y0, y1) = (hi.y.floor() as i32, hi.y.ceil() as i32);
// Up to where a falling boat's top was a move ago (`ceil(maxY - lastYd)`).
let (y0, y1) = (hi.y.floor() as i32, (hi.y - b.last_yd).ceil() as i32);
'rows: for y in y0..y1 {
let mut height = 0.0f32;
for x in lo.x.floor() as i32..hi.x.ceil() as i32 {
@@ -834,9 +855,9 @@ impl Game<'_> {
if down {
acceleration -= 0.005;
}
let r = b.yaw.to_radians();
b.velocity.x += f64::from((-r).sin() * acceleration);
b.velocity.z += f64::from(r.cos() * acceleration);
let (x, z) = paddle_push(b.yaw, acceleration);
b.velocity.x += x;
b.velocity.z += z;
b.paddling = [right && !left || up, left && !right || up];
}
// Entity.move: collisions zero the blocked components, the world border's
@@ -855,6 +876,7 @@ impl Game<'_> {
b.position.z + moved.z,
);
b.on_ground = movement.y < 0.0 && moved.y > movement.y;
b.last_yd = b.velocity.y;
b.fall_distance = self.boat_fall(&b, moved.y);
if (moved.x - movement.x).abs() > 1.0e-7 {
b.velocity.x = 0.0;
@@ -977,7 +999,7 @@ impl Game<'_> {
};
self.boats[i].wear_off_hurt();
// Entity.baseTick burns it before it moves; the fire may break it.
if !self.boat_fire_tick(i) {
if !self.boat_fire_tick(i) && !self.boat_below_world(i) {
let b = self.boats[i].clone();
self.boats[i] = self.tick_boat(b, false);
self.boat_server_passes(id, &driven, &ticking, &round);
@@ -1435,7 +1457,7 @@ impl Game<'_> {
if self.dismount_in_portal(self.boats[i].position) {
return;
}
let target = self.boat_dismount_target(i, 0.6, self.yaw);
let target = self.boat_dismount_target(i, f64::from(0.6f32), self.yaw);
if let Some(p) = self.player.as_mut() {
p.position = target;
p.velocity = Vec3::ZERO;
@@ -1445,14 +1467,8 @@ impl Game<'_> {
/// facing `yaw`.
pub(super) fn boat_dismount_target(&self, i: usize, width: f64, yaw: f32) -> Vec3 {
let b = &self.boats[i];
let r = yaw.to_radians();
let distance = (WIDTH * std::f64::consts::SQRT_2 + width + 1.0e-5) / 2.0;
let (s, c) = (-(r.sin()) as f64, r.cos() as f64);
let m = s.abs().max(c.abs());
let (x, z) = (
b.position.x + s * distance / m,
b.position.z + c * distance / m,
);
let off = super::mounts::escape_vector(boat_escape_width(), width, yaw);
let (x, z) = (b.position.x + off.x, b.position.z + off.z);
let top = b.bounds().max_position().y;
let (bx, by, bz) = (x.floor() as i32, top.floor() as i32, z.floor() as i32);
let below = self.res.facts().get(self.world.block(bx, by - 1, bz));
@@ -1917,6 +1933,31 @@ impl Game<'_> {
mod tests {
use super::*;
/// `controlBoat`'s push with `Mth`'s sine table (65,536 float entries,
/// indexed by `(long)(radians * 10430.378350470453) & 65535`) and float
/// products. Expected values worked out apart from Horizon, in Python with
/// float32 rounding; the platform's sine differs in the fourth digit.
#[test]
fn paddling_pushes_along_the_sine_table() {
for (yaw, push) in [
(17.3, (-0.011893643997609615, 0.03819085285067558)),
(-123.45, (0.03337550163269043, -0.022047126665711403)),
(250.0, (0.03758755698800087, -0.013681206852197647)),
] {
assert_eq!(paddle_push(yaw, 0.04), push, "yaw {yaw}");
}
}
/// `getDismountLocationForPassenger`'s escape vector for a player (0.6F
/// wide) facing 17.3 degrees: the boat's float width times
/// `Mth.SQRT_OF_TWO`, the 1.0E-5F margin and the sine table, worked out
/// apart from Horizon as above.
#[test]
fn a_boat_rider_steps_off_along_the_sine_table() {
let v = super::super::mounts::escape_vector(boat_escape_width(), f64::from(0.6f32), 17.3);
assert_eq!((v.x, v.z), (-0.3962207300897882, 1.2722768119620014));
}
fn boat() -> Boat {
Boat::new(1, Arc::from("oak"), Vec3::ZERO, 0.0)
}
@@ -0,0 +1,110 @@
//! `Entity.checkBelowWorld` for boats and minecarts: one more than 64 blocks
//! under its level's floor is discarded (`onBelowWorld`) before it moves.
//! `AbstractBoat.tick` reaches it through `Entity.baseTick`, after the fire;
//! `AbstractMinecart.tick` calls it itself, after its hurt and damage wear off.
//! A discard is a destroying removal: the riders get off where they are and a
//! chest boat or container minecart spills its slots
//! (`AbstractChestBoat.remove`, `AbstractMinecartContainer.remove`), but no
//! ENTITY_DIE and no item of the vehicle itself.
use super::super::{minecarts::cart_at, native_ticks::TickRound};
use super::Game;
/// `Entity.checkBelowWorld`: `getY() < level.getMinY() - 64`.
pub(super) fn below_world(y: f64, min_y: i32) -> bool {
y < f64::from(min_y - 64)
}
impl Game<'_> {
/// `Entity.checkBelowWorld` for the boat at `i`: true when it fell out of
/// the world and is gone.
pub(in crate::game) fn boat_below_world(&mut self, i: usize) -> bool {
if !below_world(self.boats[i].position.y, self.world.bottom_y()) {
return false;
}
self.eject_boat_mobs(i, true);
let id = self.boats[i].id;
self.release_vehicle_container(id);
let b = self.boats.remove(i);
self.drop_vehicle_contents(b.position.components(), &b.items);
self.boat_removed(&b);
true
}
/// The minecart `id` at or after `slot`, unless `AbstractMinecart.tick`'s
/// `checkBelowWorld` discards it: then its tick is over and the round's
/// post hook runs for it.
pub(in crate::game) fn cart_in_world(
&mut self,
id: u32,
slot: usize,
round: &TickRound,
kind: &str,
) -> Option<usize> {
let i = cart_at(&self.carts, id, slot)?;
if !below_world(self.carts[i].position.y, self.world.bottom_y()) {
return Some(i);
}
self.eject_cart_mobs(i);
self.release_vehicle_container(id);
let c = self.carts.remove(i);
self.drop_vehicle_contents(c.position.components(), &c.items);
self.native_tick_post_one(round, id, kind);
None
}
}
#[cfg(test)]
mod tests {
use super::below_world;
/// The line is 64 under the floor and the vehicle must be below it:
/// -128 in the overworld (floor -64), -64 in the Nether and the End.
#[test]
fn a_vehicle_falls_out_of_the_world_64_blocks_under_the_floor() {
assert!(!below_world(-128.0, -64));
assert!(below_world(-128.001, -64));
assert!(!below_world(-100.0, -64));
assert!(!below_world(-64.0, 0));
assert!(below_world(-64.5, 0));
}
/// A tick discards the boat and the chest minecart under -128 in the
/// overworld, before they move, and spills the cart's slots; the two on
/// the line or above it stay, whatever their fall this tick.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_tick_discards_the_vehicles_under_the_world() {
use horizon_client::chunk::Chunk;
use horizon_core::position::ChunkPos;
let mut g = crate::game::native_machines::tests::server_game();
g.build_registries();
g.set_allow_commands(true);
g.world
.chunks
.insert((0, 0), std::sync::Arc::new(Chunk::empty(0, 0)));
g.block_ticks.add_empty_chunk(ChunkPos::new(0, 0)).unwrap();
assert!(g.run_command("forceload add 0 0"));
for summon in [
"summon oak_boat 0.5 -128 0.5",
"summon oak_boat 4.5 -128.5 4.5",
"summon minecart 6.5 -127 6.5",
"summon chest_minecart 2.5 -129 2.5 {Items:[{Slot:0b,id:\"minecraft:diamond\",count:3}]}",
] {
assert!(g.run_command(summon), "{summon}");
}
g.sync_tick_chunks();
g.tick_boats();
g.tick_carts();
let boats: Vec<f64> = g.boats.iter().map(|b| b.position.x).collect();
assert_eq!(boats, [0.5]);
let carts: Vec<f64> = g.carts.iter().map(|c| c.position.x).collect();
assert_eq!(carts, [6.5]);
let spilled: u32 = g
.dropped
.iter()
.filter(|i| &*i.stack.item == "minecraft:diamond")
.map(|i| i.stack.count)
.sum();
assert_eq!(spilled, 3);
}
}
+32 -1
View File
@@ -8,9 +8,12 @@ use super::commands;
#[cfg(test)]
#[path = "revenge_home_tests.rs"]
mod revenge_home_tests;
#[cfg(test)]
#[path = "zombie_ignite_tests.rs"]
mod zombie_ignite_tests;
use horizon_client::{
health::HealthEvent,
mobs::{Mob, Species},
mobs::{Mob, Species, Who},
motion::MotionEvent,
particles::{ParticleOptions, ParticleType},
text::Text,
@@ -118,6 +121,11 @@ pub(super) struct CombatTracker {
/// it the attacker is the mob nearest to where the hit came from, which
/// walks every mob.
pub(super) melee_mob: Option<u32>,
/// The attack event handled last hurt its target (`doHurtTarget` returned
/// true). The events of one blow read it: `Zombie.doHurtTarget` burns only
/// once `super.doHurtTarget` did (`MobEvent::ZombieIgnite` follows the
/// attack it rides).
pub(super) hit_landed: bool,
/// A firework rocket's burst is hurting the player now: the rocket is
/// the direct entity (`rocket_hurt_player`).
pub(super) rocket_hit: bool,
@@ -222,6 +230,29 @@ impl CombatTracker {
}
}
impl Game<'_> {
/// `Entity.igniteForSeconds`: `seconds * 20` ticks of fire, scaled by the
/// target's burning-time attribute.
pub(super) fn ignite_entity(
&mut self,
target: Who,
seconds: i32,
) -> horizon_client::Result<()> {
match target {
Who::Player => {
if !self.creative
&& let Some(p) = self.player.as_mut()
{
p.ignite_for_ticks(seconds * 20)?;
}
}
Who::Mob(id) => {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
m.ignite_for_ticks(seconds * 20);
}
}
}
Ok(())
}
/// `LivingEntity.makeDrownParticles`: eight bubbles around the entity,
/// moving with it.
pub(super) fn drown_particles(&mut self, at: [f64; 3], velocity: [f64; 3]) {
@@ -46,13 +46,13 @@ pub(super) struct CommandBlockScreen {
}
/// What `ServerboundSetCommandBlockPacket` carries.
struct CommandBlockEdit {
pos: Pos,
command: String,
track_output: bool,
mode: Mode,
conditional: bool,
auto: bool,
pub(super) struct CommandBlockEdit {
pub(super) pos: Pos,
pub(super) command: String,
pub(super) track_output: bool,
pub(super) mode: Mode,
pub(super) conditional: bool,
pub(super) auto: bool,
}
#[derive(Clone, Copy, PartialEq)]
@@ -948,8 +948,6 @@ impl Game<'_> {
self.as_guest_messaged(host, conn, |game| {
if allowed {
game.apply_command_block(&edit);
// BaseCommandBlock.onUpdated: the clients get the new command.
game.mark_block_entity_updated(edit.pos);
} else {
game.system_message(Text::translate("advMode.notAllowed", &[]));
}
@@ -984,7 +982,7 @@ impl Game<'_> {
);
}
/// What `handleSetCommandBlock` does once the player may edit the block.
fn apply_command_block(&mut self, s: &CommandBlockEdit) {
pub(super) fn apply_command_block(&mut self, s: &CommandBlockEdit) {
let pos = s.pos;
let registry = self.pack.assets.registry;
let Some(current) = self.block_state_at(pos) else {
@@ -1042,6 +1040,9 @@ impl Game<'_> {
};
self.system_message(Text::translate(key, &[Text::literal(command)]));
}
// `BaseCommandBlock.onUpdated`: the edited block entity is saved and
// its clients (and comparators) see the new command.
self.mark_block_entity_updated(pos);
}
}
+38 -18
View File
@@ -118,12 +118,14 @@ impl Game<'_> {
Target::Item(id) => {
let uuid = self.uuid_for(id);
let e = self.dropped.iter().find(|e| e.id == id)?;
entity_nbt::item_entity_to_nbt_with_items(
let mut tag = entity_nbt::item_entity_to_nbt_with_items(
e,
uuid,
self.persist.raw.get(&id),
self.items(),
)
);
self.save_portal_cooldown(id, &mut tag);
tag
}
Target::Stand(id) => {
let uuid = self.uuid_for(id);
@@ -149,6 +151,7 @@ impl Game<'_> {
self.put_mob_passengers(&mut tag, id);
// AbstractBoat.addAdditionalSaveData: the live or pending leash.
self.write_boat_leash(&mut tag, i);
self.save_portal_cooldown(id, &mut tag);
tag
}
Target::Cart(id) => {
@@ -156,6 +159,7 @@ impl Game<'_> {
let c = self.carts.iter().find(|c| c.id == id)?;
let mut tag = c.to_nbt(uuid, self.persist.raw.get(&id));
self.put_mob_passengers(&mut tag, id);
self.save_portal_cooldown(id, &mut tag);
tag
}
Target::Fangs(id) => {
@@ -780,6 +784,13 @@ impl Game<'_> {
if self.pot_data(pos).is_some() {
return self.set_pot_data(pos, &tag).unwrap_or(false);
}
if let Some(changed) = self.load_comparator_output(pos, &tag) {
let changed = changed.unwrap_or(false);
if changed {
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
}
return changed;
}
let key = (pos[0], pos[1], pos[2]);
let was_auto = self.command_block_auto(key);
let Some(e) = self.world.block_entities.get(&key) else {
@@ -887,6 +898,29 @@ impl Game<'_> {
true
}
/// `ComparatorBlockEntity.loadAdditional` (`getIntOr("OutputSignal", 0)`)
/// for a comparator at `p`: None for any other block; else whether the
/// loaded output differs from the one it had (None inside when the tag
/// does not decode). The block's own metadata comes from `comparator_data`.
fn load_comparator_output(&mut self, p: [i32; 3], tag: &Tag) -> Option<Option<bool>> {
let mut input = self.comparator_data(p)?;
let before = input.get("OutputSignal").cloned();
if let Some(value) = tag.get("OutputSignal") {
set_key(&mut input, "OutputSignal", value.clone());
} else if let Tag::Compound(map) = &mut input {
map.remove(&"OutputSignal".into());
}
let Some(normalized) = horizon_client::block_entity_nbt::canonical(&input) else {
return Some(None);
};
let output = normalized.get("OutputSignal").cloned();
if let Some(Tag::Int(value)) = output {
let pos = horizon_core::position::BlockPos::new(p[0], p[1], p[2]);
self.block_ticks.comparator_outputs.insert(pos, value);
}
Some(Some(output != before))
}
/// `ComparatorBlockEntity.saveAdditional`: expose the circuit's live output,
/// rather than keeping a second copy in the ordinary block entity map.
fn comparator_data(&self, p: [i32; 3]) -> Option<Tag> {
@@ -1050,22 +1084,8 @@ impl Game<'_> {
return Ok(());
}
let key = (p[0], p[1], p[2]);
if let Some(mut input) = self.comparator_data(*p) {
// ComparatorBlockEntity.loadAdditional reads getIntOr(0).
// Reuse the existing codec, with the block's own metadata.
if let Some(value) = tag.get("OutputSignal") {
set_key(&mut input, "OutputSignal", value.clone());
} else if let Tag::Compound(map) = &mut input {
map.remove(&"OutputSignal".into());
}
let normalized = horizon_client::block_entity_nbt::canonical(&input)
.ok_or_else(|| err("commands.data.block.invalid", &[]))?;
if let Some(Tag::Int(output)) = normalized.get("OutputSignal") {
self.block_ticks.comparator_outputs.insert(
horizon_core::position::BlockPos::new(p[0], p[1], p[2]),
*output,
);
}
if let Some(changed) = self.load_comparator_output(*p, &tag) {
changed.ok_or_else(|| err("commands.data.block.invalid", &[]))?;
self.mark_block_entity_updated(key);
return Ok(());
}
@@ -15,7 +15,7 @@ use horizon_protocol::packets::{
Empty,
play::{self, Clientbound as C, DialogHolder},
};
use horizon_world::nbt::Tag;
use horizon_world::nbt::{JavaString, Tag};
use std::{collections::HashMap, time::Instant};
/// Waypoint.Icon: the locator bar style and colour of a transmitter.
@@ -33,6 +33,48 @@ impl Default for WaypointIcon {
}
}
impl WaypointIcon {
/// `Waypoint.Icon.hasData`.
fn has_data(&self) -> bool {
self.style != "minecraft:default" || self.color.is_some()
}
/// `LivingEntity.readAdditionalSaveData`'s `locator_bar_icon`
/// (`Waypoint.Icon.CODEC`: a required `style` and an optional int `color`).
/// None when the key is missing or does not decode, which is the default icon.
fn from_saved(entity: Option<&Tag>) -> Option<Self> {
let icon = entity?.get("locator_bar_icon")?;
let Some(Tag::String(style)) = icon.get("style") else {
return None;
};
let color = match icon.get("color") {
Some(Tag::Int(rgb)) => Some(*rgb),
_ => None,
};
Some(Self {
style: style.to_string_lossy(),
color,
})
}
/// `LivingEntity.addAdditionalSaveData`'s `locator_bar_icon`: written only
/// when the icon `hasData`, so any other icon takes the key out.
fn save_into(&self, entity: &mut Option<Tag>) {
if !self.has_data() {
if let Some(Tag::Compound(map)) = entity {
map.remove(&JavaString::from("locator_bar_icon"));
}
return;
}
let mut fields = vec![("style", Tag::String(self.style.as_str().into()))];
if let Some(rgb) = self.color {
fields.push(("color", Tag::Int(rgb)));
}
if let Tag::Compound(map) = entity.get_or_insert_with(|| Tag::Compound(Default::default()))
{
map.insert("locator_bar_icon".into(), Tag::compound(fields));
}
}
}
/// A `/fetchprofile` lookup in flight: what it resolves, the message ids it
/// answers with (`commands.fetchprofile.<kind>.success|failure`), the
/// argument they show, and the source to answer.
@@ -401,6 +443,12 @@ impl Game<'_> {
.waypoint_icons
.get(&self.waypoint_target(t))
.cloned()
// The player's own icon comes back from their saved `locator_bar_icon`.
.or_else(|| {
(t == Target::Player)
.then(|| WaypointIcon::from_saved(self.level.player_extra.as_ref()))
.flatten()
})
.unwrap_or_default()
}
/// `WaypointArgument.getWaypoint`.
@@ -509,6 +557,10 @@ impl Game<'_> {
&[Text::colored(name, rgb)],
)
};
// LivingEntity.addAdditionalSaveData: the player's icon goes into their saved tag.
if t == Target::Player {
icon.save_into(&mut self.level.player_extra);
}
let t = self.waypoint_target(t);
self.cmds.waypoint_icons.insert(t, icon);
// WaypointCommand.mutateIcon: untrack, change, track again.
@@ -1310,3 +1362,81 @@ impl Game<'_> {
}
}
impl Game<'_> {}
#[cfg(test)]
mod waypoint_icon_save_tests {
use super::*;
use crate::save::Level;
fn icon(style: &str, color: Option<i32>) -> WaypointIcon {
WaypointIcon {
style: style.into(),
color,
}
}
#[test]
fn an_icon_set_by_waypoint_modify_survives_a_save_and_a_load() {
let mut level = Level::default();
icon("minecraft:bowtie", Some(0x00FF_0000)).save_into(&mut level.player_extra);
// The player's file: `locator_bar_icon` is `{style, color}`.
let saved = level.player_tag();
assert_eq!(
saved.get("locator_bar_icon"),
Some(&Tag::compound([
("style", Tag::String("minecraft:bowtie".into())),
("color", Tag::Int(0x00FF_0000)),
]))
);
// Loaded again: the same icon.
assert_eq!(
WaypointIcon::from_saved(Some(&saved)),
Some(icon("minecraft:bowtie", Some(0x00FF_0000)))
);
}
#[test]
fn a_style_alone_or_a_colour_alone_is_data_and_is_saved() {
let mut only_style = None;
icon("minecraft:bowtie", None).save_into(&mut only_style);
let tag = only_style.unwrap();
assert_eq!(
tag.get("locator_bar_icon"),
Some(&Tag::compound([(
"style",
Tag::String("minecraft:bowtie".into())
)]))
);
let mut only_color = None;
icon("minecraft:default", Some(7)).save_into(&mut only_color);
assert_eq!(
WaypointIcon::from_saved(only_color.as_ref()),
Some(icon("minecraft:default", Some(7)))
);
}
#[test]
fn a_player_with_no_icon_saves_no_key_and_a_reset_takes_it_out() {
let mut level = Level::default();
assert!(level.player_tag().get("locator_bar_icon").is_none());
// The default icon on a player with no file writes nothing, not even a file.
WaypointIcon::default().save_into(&mut level.player_extra);
assert!(level.player_extra.is_none());
icon("minecraft:default", Some(1)).save_into(&mut level.player_extra);
assert!(level.player_tag().get("locator_bar_icon").is_some());
// `/waypoint modify ... reset` leaves the default icon: the key goes.
WaypointIcon::default().save_into(&mut level.player_extra);
assert!(level.player_tag().get("locator_bar_icon").is_none());
}
#[test]
fn a_save_without_the_key_or_with_a_broken_one_loads_the_default_icon() {
assert_eq!(WaypointIcon::from_saved(None), None);
let none = Tag::compound([("Health", Tag::Float(20f32.to_bits()))]);
assert_eq!(WaypointIcon::from_saved(Some(&none)), None);
// `style` is required by the codec.
let no_style =
Tag::compound([("locator_bar_icon", Tag::compound([("color", Tag::Int(5))]))]);
assert_eq!(WaypointIcon::from_saved(Some(&no_style)), None);
}
}
+20 -32
View File
@@ -2270,6 +2270,11 @@ impl Game<'_> {
self.next_entity += 1;
p.id = self.next_entity;
}
// The UUID its own random made, unless it already has one (a client
// copy's from the spawn packet).
if let Some(uuid) = p.uuid.take() {
self.persist.uuids.entry(p.id).or_insert(uuid);
}
// Projectile.setOwner: the acting player's or the mob's UUID, which
// its save keeps even once the owner is gone.
if p.owner_uuid.is_none() {
@@ -2550,7 +2555,8 @@ impl Game<'_> {
// above use their movement's flyDist/nextFlap threshold.
MobEvent::GameEvent { .. } => {}
MobEvent::AttackPlayer(amount, from) => {
self.mob_hits_player(amount, from, "minecraft:mob_attack");
self.combat.hit_landed =
self.mob_hits_player(amount, from, "minecraft:mob_attack");
}
// doHurtTarget: the effect only when hurtServer took damage.
// Bee.doHurtTarget: a sting that hurt leaves the stinger in the
@@ -2574,7 +2580,9 @@ impl Game<'_> {
ticks,
amplifier,
} => {
if self.mob_hits_player(amount, from, "minecraft:mob_attack") {
self.combat.hit_landed =
self.mob_hits_player(amount, from, "minecraft:mob_attack");
if self.combat.hit_landed {
self.give_player_effect(effect, ticks, amplifier);
}
}
@@ -2763,7 +2771,7 @@ impl Game<'_> {
amount,
source,
} => {
self.mob_attacks_mob(target, amount, source);
self.combat.hit_landed = self.mob_attacks_mob(target, amount, source);
}
MobEvent::DamageMob {
target,
@@ -2783,7 +2791,8 @@ impl Game<'_> {
// CaveSpider, Husk and WitherSkeleton.doHurtTarget: the
// effect sits inside the branch where the hit landed, and
// `LivingEntity.addEffect` takes a mob as well as a player.
if self.mob_attacks_mob(target, amount, source)
self.combat.hit_landed = self.mob_attacks_mob(target, amount, source);
if self.combat.hit_landed
&& let Some(t) = self.mobs.iter_mut().find(|m| m.id == target)
{
t.add_effect(effect, ticks, amplifier);
@@ -3456,20 +3465,11 @@ impl Game<'_> {
t.add_effect(effect, ticks, amplifier);
}
}
MobEvent::Ignite { target, seconds } => match target {
Who::Player => {
if !self.creative
&& let Some(p) = self.player.as_mut()
{
p.ignite_for_ticks(seconds * 20)?;
}
}
Who::Mob(id) => {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
m.ignite_for_ticks(seconds * 20);
}
}
},
MobEvent::Ignite(target, seconds) => self.ignite_entity(target, seconds)?,
MobEvent::ZombieIgnite(target, seconds) if self.combat.hit_landed => {
self.ignite_entity(target, seconds)?
}
MobEvent::ZombieIgnite(..) => {}
MobEvent::Breed {
parent,
partner,
@@ -5524,10 +5524,7 @@ impl Game<'_> {
let mut dodged = false;
// AbstractArrow.onHitEntity: a burning arrow sets its target alight.
if p.arrow.as_ref().is_some_and(|a| a.on_fire()) {
self.handle_mob_events(vec![MobEvent::Ignite {
target: who,
seconds: 5,
}])?;
self.handle_mob_events(vec![MobEvent::Ignite(who, 5)])?;
}
let hurt = match who {
Who::Player => {
@@ -7368,16 +7365,7 @@ impl Game<'_> {
let velocity = self.known_player_movement();
let start = Vec3::new(eye[0], eye[1] - 0.1, eye[2]);
let mut proj = Projectile::new(kind.clone(), start, Some(Who::Player));
proj.shoot_from_rotation(
&mut self.world.random,
self.pitch,
self.yaw,
0.0,
1.5,
1.0,
velocity,
on_ground,
);
proj.shoot_from_rotation(self.pitch, self.yaw, 0.0, 1.5, 1.0, velocity, on_ground);
self.add_projectile(proj);
// SnowballItem.use / EggItem.use / EnderpearlItem.use: awardStat(ITEM_USED).
self.award_stat("used", item, 1);
+1 -1
View File
@@ -371,7 +371,7 @@ impl Game<'_> {
}
// Flame: the arrow is spawned burning (EnchantmentHelper.onProjectileSpawned).
self.ignite_spawned_arrow(&weapon, &mut arrow);
arrow.shoot(&mut self.world.random, dir.to_array(), power, 1.0);
arrow.shoot_own(dir.to_array(), power, 1.0);
self.add_projectile(arrow);
let (player, name, projectile) = (
horizon_client::lan::playerdata::ints_uuid(self.acting_player_uuid()),
@@ -44,6 +44,8 @@ mod transfer;
/// What the tick loop tells `ServerWatchdog`: when the tick in progress was
/// due (`getNextTickTime`), how long a tick is, and whether the server runs.
struct Watchdog {
/// The thread running the ticks, whose stack the report carries.
server: horizon_threads::stack_dump::Watched,
base: Instant,
next_nanos: AtomicU64,
tick_nanos: AtomicU64,
@@ -53,9 +55,11 @@ struct Watchdog {
impl Watchdog {
/// `DedicatedServer.initServer`: a thread that stops a server whose tick
/// runs `max_tick_time` ms past its due time; none when that is 0 or less.
/// Started from the thread that runs the ticks.
fn start(max_tick_time: i64) -> Option<Arc<Self>> {
let max = Duration::from_millis(u64::try_from(max_tick_time).ok().filter(|m| *m > 0)?);
let watchdog = Arc::new(Self {
server: horizon_threads::stack_dump::Watched::current(),
base: Instant::now(),
next_nanos: AtomicU64::new(0),
tick_nanos: AtomicU64::new(50_000_000),
@@ -84,6 +88,8 @@ impl Watchdog {
let now = u64::try_from(self.base.elapsed().as_nanos()).unwrap_or(u64::MAX);
let behind = now.saturating_sub(next);
if behind > max {
// ServerWatchdog: the server thread's stack first, while it is still stuck.
let stack = self.server.stack(Duration::from_secs(5));
let took = format!("{:.2}", behind as f32 / 1e9);
let should = format!(
"{:.2}",
@@ -106,6 +112,7 @@ impl Watchdog {
std::path::Path::new("."),
"server",
message,
stack,
) {
Some(path) => log_from(
"Server Watchdog",
+5 -1
View File
@@ -198,7 +198,11 @@ impl Game<'_> {
} else {
format!("minecraft:{name}")
};
let ids = r.registries.dialog_tags.get(&name).into_iter().flatten();
let ids = r
.registries
.tag("minecraft:dialog", &name)
.into_iter()
.flatten();
ids.filter_map(|&i| list?.get(usize::try_from(i).ok()?)?.1.clone())
.collect()
};
+7 -7
View File
@@ -439,14 +439,15 @@ impl Game<'_> {
// ---- DefaultDispenseItemBehavior ----
/// `DispenserBlock.getDispensePosition(source, scale, offset)`.
/// `DispenserBlock.getDispensePosition(source, scale, offset)`; vanilla adds
/// `scale * step + offset` first, then adds that sum to the block centre.
fn dispense_position(pos: Pos, facing: Direction, scale: f64, offset: [f64; 3]) -> Vec3 {
let s = step(facing);
let c = Self::center(pos);
Vec3::new(
c[0] + scale * f64::from(s[0]) + offset[0],
c[1] + scale * f64::from(s[1]) + offset[1],
c[2] + scale * f64::from(s[2]) + offset[2],
c[0] + (scale * f64::from(s[0]) + offset[0]),
c[1] + (scale * f64::from(s[1]) + offset[1]),
c[2] + (scale * f64::from(s[2]) + offset[2]),
)
}
/// `DefaultDispenseItemBehavior.spawnItem` at `getDispensePosition`.
@@ -1193,13 +1194,12 @@ impl Game<'_> {
return;
}
// Projectile.shoot spreads the shot with the projectile's own random.
let mut own = random_source::entity();
match item.as_str() {
"minecraft:experience_bottle"
| "minecraft:splash_potion"
| "minecraft:lingering_potion" => {
let mut shot = Projectile::new(ProjectileKind::Snowball, at, None);
shot.shoot(&mut own, dir, power, uncertainty);
shot.shoot(&mut random_source::entity(), dir, power, uncertainty);
self.launch_thrown(one, at, shot.velocity, None);
}
_ => {
@@ -1246,7 +1246,7 @@ impl Game<'_> {
a.set_item(&one);
a.pickup = horizon_client::arrows::Pickup::Allowed;
}
shot.shoot(&mut own, dir, power, uncertainty);
shot.shoot_own(dir, power, uncertainty);
self.add_projectile(shot);
}
}
+1 -5
View File
@@ -380,11 +380,7 @@ impl Game<'_> {
/// The environment attributes of the current dimension.
pub(super) fn dim_environment(&self) -> &Environment {
match self.dim {
Dim::Overworld => self.res.environment(),
Dim::Nether => self.res.nether_environment(),
Dim::End => self.res.end_environment(),
}
self.res.environment_of(&self.dimension_type().id)
}
/// SkyRenderer.renderEndFlash, drawn after the renderer's resident sky mesh.
@@ -124,6 +124,7 @@ impl Game<'_> {
|| level.facts.is_air(below)
|| &*support.name == "minecraft:bedrock"
|| !support.full_collision
|| !b.can_survive(level, ticks, at, state)
|| occupants.iter().any(|bounds| bounds.intersects(area))
{
return None;
+1 -1
View File
@@ -189,7 +189,7 @@ impl Game<'_> {
let (bx, bz) = (view.x.floor() as i32, view.z.floor() as i32);
// Underground, under a roof or in the Nether: the floor around the player.
let covered = self.world.light(bx, head, bz) >> 4 == 0;
let layer = if self.dim == Dim::Nether || covered {
let layer = if self.world.shape.has_ceiling || covered {
Layer::Below(head)
} else {
Layer::Surface
+1 -4
View File
@@ -22,9 +22,6 @@ use horizon_client::{
hud_layout, hud_overlays,
};
/// `Abilities.walkingSpeed`.
const WALKING_SPEED: f32 = 0.1;
fn valid_fov_modifier(modifier: f32, fallback: f32) -> f32 {
if modifier.is_finite() && (0.01..=10.0).contains(&modifier) {
modifier
@@ -80,7 +77,7 @@ impl Game<'_> {
});
Some(FovPlayer {
flying: p.flying,
walking_speed: WALKING_SPEED,
walking_speed: self.remote_walking_speed(),
// Player.aiStep keeps `speed` as the float movement speed attribute.
movement_speed: f64::from(p.speed),
using,
+1 -2
View File
@@ -704,8 +704,7 @@ impl Game<'_> {
}
// `extractRenderState`: FIXED, seeded by the frame entity.
let skinning = self.skinning();
let lights =
horizon_client::block_entity_render::diffuse_lights(self.dim == super::Dim::Nether);
let lights = horizon_client::block_entity_render::diffuse_lights(self.nether_lighting());
for (stack, id, m, light, cull, kind) in items {
let entity_start = out.len();
self.mark_entity(entity_start, kind);
@@ -116,3 +116,39 @@ impl Game<'_> {
left.then_some(stack)
}
}
/// The item of the stack in `selected` (`Player.tick`'s main-hand item), borrowed: a guest's
/// tick compares it with the last one and clones it only when it changed.
pub(super) fn held_attack_item(
inventory: &[Option<ItemStack>],
selected: usize,
) -> Option<&Arc<str>> {
inventory.get(selected)?.as_ref().map(|s| &s.item)
}
#[cfg(test)]
mod tests {
use super::*;
fn stack(item: &str) -> ItemStack {
ItemStack {
item: item.into(),
count: 1,
damage: 0,
extra: None,
}
}
#[test]
fn held_attack_item_matches_the_cloned_stack() {
let mut inventory: Vec<Option<ItemStack>> = vec![None; 36];
inventory[0] = Some(stack("minecraft:stone"));
inventory[3] = Some(stack("minecraft:diamond_sword"));
for selected in [0, 1, 3, 35, 36, 1000] {
// The clone the tick used to make, as the reference.
let cloned = inventory.get(selected).cloned().flatten().map(|s| s.item);
assert_eq!(held_attack_item(&inventory, selected).cloned(), cloned);
}
assert_eq!(held_attack_item(&[], 0), None);
}
}
+3 -3
View File
@@ -471,7 +471,7 @@ impl Game<'_> {
light: u32,
out: &mut Vec<Vertex>,
) {
let lights = diffuse_lights(self.dim == Dim::Nether);
let lights = diffuse_lights(self.nether_lighting());
self.submit_item_lit(state, pose, light, lights, out);
}
/// [`Self::submit_item_world`] shaded by other diffuse lights (a
@@ -573,7 +573,7 @@ impl Game<'_> {
light: u32,
skinned: &mut super::creatures::Skinned,
) -> bool {
let lights = diffuse_lights(self.dim == Dim::Nether);
let lights = diffuse_lights(self.nether_lighting());
let Some((mark, bounds)) = self.submit_item_skinned(state, &[pose], light, lights) else {
return false;
};
@@ -618,7 +618,7 @@ impl Game<'_> {
let range =
super::entity_culling::default_range(glam::DVec3::splat(0.25), self.view_scale());
let skinning = self.skinning();
let lights = diffuse_lights(self.dim == Dim::Nether);
let lights = diffuse_lights(self.nether_lighting());
for (item, at, partial, entity) in live.chain(taken) {
let entity_start = out.len();
self.mark_motion(entity_start, (2 << 32) | u64::from(item.id));
+6 -15
View File
@@ -3556,13 +3556,9 @@ impl Game<'_> {
&& self.pvp_land(host, victim, &strike)
{
let on_ground = Self::pvp_victim_on_ground(host, victim);
self.as_guest(host, conn, |g| {
g.weapon_hit_player(on_ground);
g.pvp_exhaustion();
if strike.stops_sprint() {
g.stop_sprint_after_extra_knockback();
}
});
let stops_sprint = strike.stops_sprint();
let sent = self.as_guest(host, conn, |g| g.pvp_hit_after(on_ground, stops_sprint));
self.send_guest_motion(host, conn, sent.flatten());
}
return;
}
@@ -4377,16 +4373,11 @@ impl Game<'_> {
// Player.tick: a different main-hand item resets the attack
// strength, and detectEquipmentUpdates gives the guest its modifiers.
let g = &host.hub.guests[gi];
let held = g
.inventory
.get(g.selected)
.cloned()
.flatten()
.map(|s| s.item);
let held = super::held_items::held_attack_item(&g.inventory, g.selected);
let play = host.play.entry(conn).or_default();
if play.attack_item != held {
if play.attack_item.as_ref() != held {
play.attack_ticks = 0;
play.attack_item = held;
play.attack_item = held.cloned();
}
self.guest_delayed_destroy(host, conn);
self.guest_server_tick(host, conn);
+2 -4
View File
@@ -131,10 +131,8 @@ pub(super) fn acts_on_player(e: &MobEvent) -> bool {
| MobEvent::StabDismount {
target: Who::Player,
}
| MobEvent::Ignite {
target: Who::Player,
..
}
| MobEvent::Ignite(Who::Player, _)
| MobEvent::ZombieIgnite(Who::Player, _)
| MobEvent::IronGolemHit {
target: Who::Player,
..
+2 -2
View File
@@ -191,8 +191,8 @@ impl Game<'_> {
let disable_blocking = if self.using.is_some() {
0.0
} else {
self.attack_item_info()
.map_or(0.0, |i| i.disable_blocking_seconds)
self.held()
.map_or(0.0, |stack| self.items().weapon_disable_blocking(stack))
};
let ignite = self.weapon_ignite_seconds();
Some(Strike {
+23 -4
View File
@@ -36,13 +36,25 @@ impl Game<'_> {
let batch = match quiet::decode(data) {
Ok(b) => b,
Err(e) => {
super::dedicated::log(
"WARN",
&format!("{name} sent quiet commands that were refused: {e}"),
);
// At most one a second: a client sending refused payloads by the hundred a tick
// must not fill the log.
static LAST: std::sync::Mutex<Option<std::time::Instant>> =
std::sync::Mutex::new(None);
let mut last = LAST.lock().unwrap_or_else(|e| e.into_inner());
if last.is_none_or(|t| t.elapsed() >= std::time::Duration::from_secs(1)) {
*last = Some(std::time::Instant::now());
super::dedicated::log(
"WARN",
&format!("{name} sent quiet commands that were refused: {e}"),
);
}
return;
}
};
// Nothing to run, nothing to log: an empty batch would only be a summary line.
if batch.lines.is_empty() {
return;
}
let waiting: usize = host
.quiet
.iter()
@@ -51,6 +63,13 @@ impl Game<'_> {
.sum();
let room = PENDING.saturating_sub(waiting);
let dropped = batch.lines.len().saturating_sub(room);
// No room at all: counted on the player's last waiting batch, not a new (logged) one.
if room == 0
&& let Some(last) = host.quiet.iter_mut().rev().find(|b| b.conn == conn)
{
last.dropped += dropped;
return;
}
host.quiet.push_back(QuietBatch {
conn,
mod_id: batch.mod_id,
@@ -280,8 +280,8 @@ impl Game<'_> {
};
let above = self.world.block(pos.x, root_y + 1, pos.z);
let above_facts = self.res.facts().get(above);
let nether = self.dim == super::Dim::Nether;
let fluid = if &*above_facts.name == "minecraft:mud" && !nether {
let evaporates = self.dimension_type().water_evaporates;
let fluid = if &*above_facts.name == "minecraft:mud" && !evaporates {
horizon_client::blocks::Fluid::Water
} else {
above_facts.fluid
@@ -289,7 +289,9 @@ impl Game<'_> {
let ty = match fluid {
horizon_client::blocks::Fluid::Water => ParticleType::DrippingDripstoneWater,
horizon_client::blocks::Fluid::Lava => ParticleType::DrippingDripstoneLava,
horizon_client::blocks::Fluid::None if nether => ParticleType::DrippingDripstoneLava,
horizon_client::blocks::Fluid::None if evaporates => {
ParticleType::DrippingDripstoneLava
}
horizon_client::blocks::Fluid::None => ParticleType::DrippingDripstoneWater,
};
let offset = self
+16 -4
View File
@@ -622,7 +622,11 @@ impl Game<'_> {
Dim::Nether => "level_the_nether",
Dim::End => "level_the_end",
});
if let Err(e) = self.enter_level(None, dim) {
// The level's own share of the pass, outside its world tick: a slow tick's line names
// each of them, and `level_tick` holds the world tick itself (#15349).
if let Err(e) =
horizon_client::spans::time("enter_level", || self.enter_level(None, dim))
{
result = Err(e);
break;
}
@@ -633,10 +637,14 @@ impl Game<'_> {
packets_drained: false,
};
if observe_level {
let _span = horizon_client::spans::guard("mods_level_tick_pre");
self.native_level_tick_pre(dim.id());
}
self.update_dedicated_streaming();
self.update_world();
horizon_client::spans::time("dedicated_streaming", || {
self.update_dedicated_streaming();
});
horizon_client::spans::time("update_world", || self.update_world());
let tick_span = horizon_client::spans::guard("level_tick");
result = horizon_client::block_contact::in_level(
(self.serving.is_some() || self.options.dedicated.is_some()).then_some(dim.id()),
|| {
@@ -653,16 +661,19 @@ impl Game<'_> {
}
},
);
drop(tick_span);
if result.is_err() {
break;
}
if observe_level {
let _span = horizon_client::spans::guard("mods_level_tick_post");
self.native_level_tick_post(dim.id());
}
self.publish_server_debug(dim);
horizon_client::spans::time("server_debug", || self.publish_server_debug(dim));
// An entity that crossed into a level that already ticked this
// server tick is in it when the tick ends (`addDuringTeleport`).
if running == Some(true) {
let _span = horizon_client::spans::guard("late_portal_arrivals");
let mut moved = false;
for &earlier in &order[..k] {
if !self.arrival_chunks(earlier).is_empty()
@@ -680,6 +691,7 @@ impl Game<'_> {
if let Some(&first) = order.first()
&& self.dim != first
{
let _span = horizon_client::spans::guard("enter_level");
let _ = self.enter_level(None, first);
}
self.level_pass = Pass::default();
+22
View File
@@ -378,6 +378,28 @@ impl Game<'_> {
self.audio.play(sound, Some(feet), 1.0, 1.0);
Some(fall)
}
/// What a server's guest does after their hit landed on another player:
/// the weapon's side, the exhaustion and the sprint a knockback stops.
/// Returns the attacker's motion when a smash set it, which
/// `MaceItem.hurtEnemy` sends at once (`ClientboundSetEntityMotionPacket`),
/// not at the end of the tick after gravity (#8246). The mark from before
/// the hit stays.
pub(super) fn pvp_hit_after(
&mut self,
victim_on_ground: bool,
stops_sprint: bool,
) -> Option<Vec3> {
let p = self.player.as_mut()?;
let marked = std::mem::take(&mut p.health.sync_velocity);
self.weapon_hit_player(victim_on_ground);
let p = self.player.as_mut()?;
let sent = std::mem::replace(&mut p.health.sync_velocity, marked).then_some(p.velocity);
self.pvp_exhaustion();
if stops_sprint {
self.stop_sprint_after_extra_knockback();
}
sent
}
/// `Player.itemAttackInteraction` after the player's hit hurt another
/// player: the weapon's side of it, as `weapon_hit` does for a mob. A
/// smash stops the fall and its Wind Burst (a `post_attack` explosion on
+29 -19
View File
@@ -80,7 +80,7 @@ impl Game<'_> {
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Kind {
Generic,
/// The saved decorated-pot slot, exposed only to hopper insertion.
/// The saved decorated-pot slot: hopper insertion and the comparator signal.
Pot,
/// `AbstractFurnaceBlockEntity` (a `WorldlyContainer`).
Furnace,
@@ -362,24 +362,28 @@ impl Containers<'_> {
container: None,
})
}
/// `HopperBlockEntity.getBlockContainer` at its insertion target only.
/// Pots keep their authoritative slot in the chunk's saved tag. Pending
/// loot is deliberately left to its existing path, without unpacking it.
fn hopper_insertion_view(&self, pos: Pos) -> Option<View> {
self.view(pos, false).or_else(|| {
if self.name(self.world.block(pos.0, pos.1, pos.2)) != "minecraft:decorated_pot"
|| spill::saved_entity(self.world, [pos.0, pos.1, pos.2])
.is_some_and(|tag| matches!(tag.get("LootTable"), Some(Tag::String(_))))
{
return None;
}
Some(View {
parts: vec![(pos, 1)],
kind: Kind::Pot,
container: None,
})
/// The saved decorated-pot slot as a container: `DecoratedPotBlockEntity`
/// is a `ContainerSingleItem`, but Horizon keeps pots out of generic
/// container discovery (`world.block_entities`), so this is built on demand.
/// A pot with pending loot is left to its existing unpacking path.
fn saved_pot_view(&self, pos: Pos) -> Option<View> {
if self.name(self.world.block(pos.0, pos.1, pos.2)) != "minecraft:decorated_pot"
|| spill::saved_entity(self.world, [pos.0, pos.1, pos.2])
.is_some_and(|tag| matches!(tag.get("LootTable"), Some(Tag::String(_))))
{
return None;
}
Some(View {
parts: vec![(pos, 1)],
kind: Kind::Pot,
container: None,
})
}
/// `HopperBlockEntity.getBlockContainer` at its insertion target only.
/// Pots keep their authoritative slot in the chunk's saved tag.
fn hopper_insertion_view(&self, pos: Pos) -> Option<View> {
self.view(pos, false).or_else(|| self.saved_pot_view(pos))
}
/// The `ContainerSingleItem` slot for hopper insertion: only a saved pot
/// needs an owned decode. Existing modeled containers stay borrowed.
fn insertion_item(&self, view: &View, slot: usize) -> Option<Cow<'_, ItemStack>> {
@@ -519,8 +523,9 @@ impl Containers<'_> {
}
let mut total = 0.0f32;
for i in 0..view.len() {
if let Some(s) = self.get(view, i) {
total += s.count as f32 / self.max_stack(s) as f32;
// `insertion_item` decodes a saved pot's slot; every other kind is `get`.
if let Some(s) = self.insertion_item(view, i) {
total += s.count as f32 / self.max_stack(&s) as f32;
}
}
total /= view.len().max(1) as f32;
@@ -653,6 +658,7 @@ pub(super) fn container_signal(level: &super::GameLevel<'_>, pos: BlockPos) -> i
};
c.view((pos.x, pos.y, pos.z), true)
.filter(|v| !matches!(v.kind, Kind::Composter(_)))
.or_else(|| c.saved_pot_view((pos.x, pos.y, pos.z)))
.map_or(0, |v| c.signal(&v))
}
@@ -2425,6 +2431,10 @@ mod pot_hopper_insertion_tests {
}
}
#[cfg(test)]
#[path = "machines_pot_signal_tests.rs"]
mod pot_signal_tests;
#[cfg(test)]
mod tests {
use super::*;
@@ -380,6 +380,40 @@ mod tests {
c.set_slot_state(4, true);
assert_eq!(c.redstone_signal(), 1);
}
/// `CrafterBlockEntity.setSlotState`: a slot toggled in the menu lands on
/// the block entity itself, whose `setChanged` marks the chunk.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_slot_toggled_in_the_menu_lands_on_the_crafter() {
use crate::game::native_machines::tests::server_game;
let mut game = server_game();
let key = (2, 4, 3);
let state = game
.pack
.assets
.registry
.default_state("minecraft:crafter")
.unwrap();
game.world.set(key.0, key.1, key.2, state).unwrap();
let mut crafter = Crafter::default();
crafter.set_slot_state(3, false);
game.world
.block_entities
.put(key, BlockEntity::Crafter(crafter));
game.container = Some(key);
game.world.modified.clear();
game.crafter_slot_clicked(303, 0, horizon_client::menu::Input::Pickup);
let Some(BlockEntity::Crafter(c)) = game.world.block_entities.get(&key) else {
panic!("no crafter");
};
assert!(!c.disabled[3], "the click enabled the slot");
assert!(
game.world.modified.contains(&(0, 0)),
"the toggled slot left the chunk unmarked"
);
}
#[test]
fn hoppers_fill_the_smaller_stacks_first() {
let mut c = Crafter::default();
@@ -0,0 +1,98 @@
//! `DecoratedPotBlock.getAnalogOutputSignal` (`getRedstoneSignalFromBlockEntity`
//! → `getRedstoneSignalFromContainer`): a pot's one slot lives in the chunk's
//! saved block entity, so a comparator beside it has to read that tag.
use super::super::{native_machines::tests::server_game, spill};
use super::*;
use horizon_client::save;
use horizon_world::level::LevelAccess;
/// `getRedstoneSignalFromBlockEntity` for the block at `pos`, the path a
/// comparator's `getAnalogOutputSignal` takes.
fn signal(game: &mut Game<'_>, pos: Pos) -> i32 {
let state = game.world.block(pos.0, pos.1, pos.2);
game.with_level(|_, level, _| {
level.block_entity_analog_signal(BlockPos::new(pos.0, pos.1, pos.2), state, Direction::West)
})
}
fn saved_count(game: &Game<'_>, pos: Pos) -> u32 {
let tag = spill::saved_entity(&game.world, [pos.0, pos.1, pos.2]).expect("a saved pot");
save::item_from_nbt(tag.get("item").expect("the pot's item"))
.expect("a decodable pot item")
.count
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and scratch homes/cwd"]
fn a_pot_feeds_a_comparator_from_its_saved_slot_like_a_chest() {
let mut game = server_game();
game.build_registries();
game.set_allow_commands(true);
assert!(game.run_command("fill 0 199 0 15 199 15 stone"));
let pot = (8, 200, 8);
let chest = (8, 200, 10);
assert!(game.run_command("setblock 8 200 8 minecraft:decorated_pot"));
// Vanilla's signal for one slot (`Mth.lerpDiscrete(count / 64, 0, 15)`); a
// chest at the same fill fraction in all 27 slots reads the same.
for (count, expected) in [(64, 15), (32, 8), (1, 1)] {
assert!(game.run_command(&format!(
"data merge block 8 200 8 {{item:{{id:\"minecraft:stone\",count:{count}}}}}"
)));
let items: Vec<String> = (0..27)
.map(|slot| format!("{{Slot:{slot}b,id:\"minecraft:stone\",count:{count}}}"))
.collect();
assert!(game.run_command(&format!(
"setblock 8 200 10 minecraft:chest{{Items:[{}]}}",
items.join(",")
)));
assert_eq!(signal(&mut game, pot), expected, "a pot holding {count}");
assert_eq!(
signal(&mut game, chest),
expected,
"a chest holding {count} in every slot"
);
}
assert!(game.run_command("data remove block 8 200 8 item"));
assert!(game.run_command("data merge block 8 200 10 {Items:[]}"));
assert_eq!(signal(&mut game, pot), 0, "an empty pot");
assert_eq!(signal(&mut game, chest), 0, "an empty chest");
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and scratch homes/cwd"]
fn a_hopper_inserted_item_updates_the_comparator_beside_the_pot() {
let mut game = server_game();
game.build_registries();
game.set_allow_commands(true);
assert!(game.run_command("fill 0 199 0 15 199 15 stone"));
let hopper = (7, 200, 8);
let pot = (8, 200, 8);
let comparator = BlockPos::new(9, 200, 8);
assert!(game.run_command(
"setblock 7 200 8 minecraft:hopper[facing=east,enabled=true]{Items:[{Slot:0b,id:\"minecraft:stone\",count:1}],TransferCooldown:0}"
));
assert!(game.run_command(
"setblock 8 200 8 minecraft:decorated_pot{item:{id:\"minecraft:stone\",count:63}}"
));
assert!(game.run_command("setblock 9 200 8 minecraft:comparator[facing=west]"));
let output = |game: &Game<'_>| {
game.block_ticks
.comparator_outputs
.get(&comparator)
.copied()
.unwrap_or(0)
};
// `ComparatorBlock.tick` on the read side of the comparator.
let tick = |game: &mut Game<'_>| {
game.with_level(|b, level, ticks| b.scheduled_block_tick(level, ticks, comparator));
};
tick(&mut game);
assert_eq!(output(&game), 14, "63 items: lerpDiscrete(63/64, 0, 15)");
for _ in 0..8 {
game.world.game_time += 1;
game.tick_hopper(hopper);
}
assert_eq!(saved_count(&game, pot), 64, "the hopper filled the pot");
tick(&mut game);
assert_eq!(output(&game), 15, "the comparator re-read the saved pot");
}
+22 -9
View File
@@ -485,7 +485,7 @@ impl Game<'_> {
brewing: &self.stations().brewing,
banners: &self.stations().banners,
station_recipes: &self.stations().recipes,
enchantments: &self.stations().enchantments,
enchantments: self.enchantments(),
names: &self.pack.names,
trade: Some(&trade_env),
maps: level_maps
@@ -675,14 +675,8 @@ impl Game<'_> {
// EnchantmentMenu.slotsChanged: new offers for the item.
self.station_changed();
}
if kind == Kind::CrafterBlock
&& let Some(BlockEntity::Crafter(c)) = self
.container
.and_then(|k| self.world.block_entities.get_mut(&k))
{
for (i, d) in c.disabled.iter_mut().enumerate() {
*d = menu.data[i] == 1;
}
if kind == Kind::CrafterBlock {
self.apply_crafter_slot_states(&menu);
}
if matches!(kind, Kind::Furnace(_))
&& let Some(BlockEntity::Furnace(f)) = self
@@ -706,6 +700,25 @@ impl Game<'_> {
}
true
}
/// `CrafterBlockEntity.setSlotState`: the menu's slot states land on the
/// block entity, whose `setChanged` marks the chunk for saving.
pub(super) fn apply_crafter_slot_states(&mut self, menu: &horizon_client::menu::Menu) {
let Some(key) = self.container else {
return;
};
let Some(BlockEntity::Crafter(c)) = self.world.block_entities.get_mut(&key) else {
return;
};
let mut changed = false;
for (i, d) in c.disabled.iter_mut().enumerate() {
let disabled = menu.data[i] == 1;
changed |= *d != disabled;
*d = disabled;
}
if changed {
self.container_changed(key);
}
}
pub(super) fn click_inventory(&mut self, right: bool) {
if self.mod_browser_press(right) || self.mount_click(right) {
return;
+5 -6
View File
@@ -30,8 +30,7 @@ use horizon_world::registry::BlockStateId;
use std::{collections::HashMap, sync::Arc};
mod index;
mod nbt;
pub(super) use index::CartIndex;
use index::cart_at;
pub(super) use index::{CartIndex, cart_at};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub(super) enum CartKind {
@@ -1895,7 +1894,7 @@ impl Game<'_> {
if !self.native_tick_pre_one(&mut round, id, kind) {
continue;
}
let Some(i) = cart_at(&self.carts, id, i) else {
let Some(i) = self.cart_in_world(id, i, &round, kind) else {
continue;
};
// The ticked copy takes the loot roll instead of copying it: only
@@ -2028,8 +2027,8 @@ impl Game<'_> {
} else {
(left, forward)
};
let r = self.yaw.to_radians();
let (s, co) = (f64::from(r.sin()), f64::from(r.cos()));
let r = f64::from(self.yaw * 0.017_453_292);
let (s, co) = (f64::from(trig::sin(r)), f64::from(trig::cos(r)));
let intent = [left * co - forward * s, forward * co + left * s];
let mut c = self.carts[i].clone();
let alive = self.tick_cart(&mut c, intent, i, &mut CartIndex::default());
@@ -2913,7 +2912,7 @@ impl Game<'_> {
emissive: &self.gui.atlas.emissive,
translucent_sprites: &self.gui.atlas.translucent,
atlas_revision: self.gui.atlas.revision,
nether: self.dim == super::Dim::Nether,
nether: self.nether_lighting(),
lid: &lid,
sign: &|_| None,
banner: &|_| Vec::new(),
@@ -97,7 +97,7 @@ impl CartIndex {
/// the usual case while carts tick in order) or found by a scan. A world
/// with hundreds of mineshaft chest carts asked this of every cart several
/// times a tick.
pub(super) fn cart_at(carts: &[Minecart], id: u32, hint: usize) -> Option<usize> {
pub(in crate::game) fn cart_at(carts: &[Minecart], id: u32, hint: usize) -> Option<usize> {
if carts.get(hint).is_some_and(|c| c.id == id) {
return Some(hint);
}
+1 -1
View File
@@ -639,7 +639,7 @@ impl Game<'_> {
light,
lights: if lit {
Some(horizon_client::block_entity_render::diffuse_lights(
self.dim == super::Dim::Nether,
self.nether_lighting(),
))
} else {
None
+1 -1
View File
@@ -1079,7 +1079,7 @@ fn dismount_location(
}
/// `Entity.getCollisionHorizontalEscapeVector`: out of the vehicle's box
/// along `yaw`, to the edge of the square it spans.
fn escape_vector(vehicle_width: f64, passenger_width: f64, yaw: f32) -> Vec3 {
pub(super) fn escape_vector(vehicle_width: f64, passenger_width: f64, yaw: f32) -> Vec3 {
// 1.0E-5F: a float literal widened to double.
let distance = (vehicle_width + passenger_width + f64::from(1.0e-5_f32)) / 2.0;
let radians = f64::from(yaw * 0.017_453_292);
@@ -432,7 +432,7 @@ impl Game<'_> {
light: 0,
block: [0; 3],
poses: Vec::new(),
lights: diffuse_lights(self.dim == Dim::Nether),
lights: diffuse_lights(self.nether_lighting()),
vertices: std::mem::take(out),
kept: false,
spins: Vec::new(),
@@ -8,7 +8,7 @@
//! player in view gets `native_mob_layer` after its vanilla model
//! (`EntityRenderersEvent.AddLayers`), in the same frame.
use super::{Dim, Game, native_block_entity_draw::BlockEntityDraw};
use super::{Game, native_block_entity_draw::BlockEntityDraw};
use glam::{DVec3, Mat4};
use horizon_client::{
block_entity_render::diffuse_lights,
@@ -184,7 +184,7 @@ impl Game<'_> {
light,
[feet.x, feet.y, feet.z].map(|c| c.floor() as i32),
mob_pose(feet - camera, render.body_yaw),
diffuse_lights(self.dim == Dim::Nether),
diffuse_lights(self.nether_lighting()),
std::mem::take(out),
);
let keep = hook(&render, &mut draw);
@@ -755,6 +755,17 @@ impl Game<'_> {
})
.unwrap_or(ENTITY_DELAY)
}
/// `Entity.saveWithoutId` writes the live `PortalCooldown`: the countdown
/// `Entity.processPortalCooldown` ticks down, not the value the entity was
/// loaded with. Every entity that can stand in a portal takes it from the
/// portal state, as the projectile writer does. An entity the portal
/// state does not know is not on a cooldown, and writes 0.
pub(super) fn save_portal_cooldown(&self, id: u32, tag: &mut Tag) {
horizon_client::nether_portal::put_portal_cooldown(
tag,
horizon_client::nether_portal::portal_cooldown(&self.portals.entities, id),
);
}
/// `Entity.saveWithoutId`: the modeled non-arrow projectile codecs
/// share the live cooldown between command reads and chunk writes.
pub(super) fn save_projectile_portal_cooldown(
@@ -81,7 +81,7 @@ impl Game<'_> {
}
/// A saved spawner, as `readAdditionalSaveData` leaves it: its tick
/// count starts again from 0.
pub(super) fn load_ominous_item_spawner(&mut self, tag: horizon_world::nbt::Tag) {
pub(super) fn load_ominous_item_spawner(&mut self, mut tag: horizon_world::nbt::Tag) {
let Some((item, at, spawn_item_after)) =
horizon_client::entity_nbt::ominous_item_spawner_from_nbt(&tag)
else {
@@ -92,6 +92,17 @@ impl Game<'_> {
let saved = horizon_client::entity_nbt::uuid(&tag);
// Its saved UUID and the attachments saved under it (#7398).
self.remember_uuid(id, saved, &tag);
// Entity.load's `setXRot` keeps the pitch in -90..90 and its `setRot`
// takes both `% 360`; the save writes the rotation back from this tag.
let [yaw, pitch] = horizon_client::entity_nbt::rotation(&tag);
let (yaw, pitch) = (yaw % 360.0, (pitch % 360.0).clamp(-90.0, 90.0));
let floats = [yaw, pitch].map(|v| horizon_world::nbt::Tag::Float(v.to_bits()));
if let horizon_world::nbt::Tag::Compound(map) = &mut tag {
map.insert(
"Rotation".into(),
horizon_world::nbt::Tag::List(floats.into()),
);
}
let uuid = self.uuid_for(id);
self.ominous_spawners.push(OminousItemSpawner {
id,
@@ -79,6 +79,7 @@ fn find_join<T: Joined>(list: &[T], join: JoinSeq) -> Option<usize> {
impl Game<'_> {
/// A level tick starts: what has joined so far ticks this tick.
pub(super) fn begin_ordered_ticks(&mut self) {
let _span = horizon_client::spans::guard("ordered_ticks");
let pending_buf = std::mem::take(&mut self.ordered.pending_buf);
let firsts = [
first_join(&self.armor_stands),
@@ -111,6 +112,7 @@ impl Game<'_> {
/// Ticks, one at a time in join order, every orb, primed TNT and falling
/// block that joined before `cut` and has not ticked this tick.
pub(super) fn run_ordered_ticks(&mut self, cut: JoinSeq, late: bool) -> Result<()> {
let _span = horizon_client::spans::guard("ordered_ticks");
let mut first_error = None;
let mut followed = None;
let mut events = false;
+51 -8
View File
@@ -707,7 +707,7 @@ impl Game<'_> {
p.id = self.next_entity;
self.restore_portal_cooldown(p.id, &tag);
self.remember_tags(p.id, &tag);
self.remember_uuid(p.id, uuid, &tag);
self.remember_uuid(p.id, Some(p.own_uuid(uuid)), &tag);
self.add_projectile(p);
}
}
@@ -725,8 +725,8 @@ impl Game<'_> {
self.next_entity += 1;
p.id = self.next_entity;
self.restore_portal_cooldown(p.id, &tag);
// Entity.load: the saved or summoned UUID, as the other projectiles keep theirs.
self.remember_uuid(p.id, uuid, &tag);
// Entity.load: the saved or summoned UUID, else its own random's.
self.remember_uuid(p.id, Some(p.own_uuid(uuid)), &tag);
self.add_projectile(p);
self.remember_last_projectile_tags(&tag);
}
@@ -752,7 +752,7 @@ impl Game<'_> {
self.next_entity += 1;
p.id = self.next_entity;
self.restore_portal_cooldown(p.id, &tag);
self.remember_uuid(p.id, uuid, &tag);
self.remember_uuid(p.id, Some(p.own_uuid(uuid)), &tag);
self.add_projectile(p);
self.remember_last_projectile_tags(&tag);
}
@@ -771,7 +771,7 @@ impl Game<'_> {
self.next_entity += 1;
p.id = self.next_entity;
self.restore_portal_cooldown(p.id, &tag);
self.remember_uuid(p.id, uuid, &tag);
self.remember_uuid(p.id, Some(p.own_uuid(uuid)), &tag);
self.add_projectile(p);
self.remember_last_projectile_tags(&tag);
}
@@ -1613,6 +1613,7 @@ impl Game<'_> {
}
self.write_leash(&mut tag, i);
self.put_mob_passengers(&mut tag, id);
self.save_portal_cooldown(id, &mut tag);
tag
}
/// Forget an entity that left the world (its UUID and loaded compound).
@@ -1895,7 +1896,8 @@ impl Game<'_> {
out.entry(chunk_of(at)).or_default().push(tag);
}
for o in &self.others {
if keys.contains(&chunk_of(o.position)) {
// EntityType.noSave: Entity.getEncodeId is null, so the chunk never stores it.
if keys.contains(&chunk_of(o.position)) && o.kind.as_str() != "minecraft:leash_knot" {
let uuid = self
.persist
.uuids
@@ -2192,12 +2194,13 @@ impl Game<'_> {
keys.contains(&chunk_of(at)).then(|| {
let id = self.dropped[i].id;
let uuid = self.uuid_for(id);
let tag = entity_nbt::item_entity_to_nbt_with_items(
let mut tag = entity_nbt::item_entity_to_nbt_with_items(
&self.dropped[i],
uuid,
self.persist.raw.get(&id),
self.items(),
);
self.save_portal_cooldown(id, &mut tag);
(chunk_of(at), tag)
})
}
@@ -2345,6 +2348,7 @@ impl Game<'_> {
self.put_mob_passengers(&mut tag, id);
// AbstractBoat.addAdditionalSaveData: the lead's holder.
self.write_boat_leash(&mut tag, i);
self.save_portal_cooldown(id, &mut tag);
Some((chunk_of(at), tag))
}
pub(super) fn save_cart(
@@ -2359,6 +2363,7 @@ impl Game<'_> {
let uuid = self.saved_uuid_or_random(id);
let mut tag = self.carts[i].to_nbt(uuid, self.persist.raw.get(&id));
self.put_mob_passengers(&mut tag, id);
self.save_portal_cooldown(id, &mut tag);
Some((chunk_of(at), tag))
}
pub(super) fn save_armor_stand(
@@ -6004,7 +6009,20 @@ mod camel_pose_saved_tests {
game.block_ticks
.add_empty_chunk(ChunkPos::new(0, 0))
.unwrap();
assert!(game.run_command("forceload add 0 0"));
// A mob only ticks with its whole 5x5 chunk neighbourhood loaded.
for x in -2..=2 {
for z in -2..=2 {
if (x, z) != (0, 0) {
game.world
.chunks
.insert((x, z), Arc::new(Chunk::empty(x, z)));
game.block_ticks
.add_empty_chunk(ChunkPos::new(x, z))
.unwrap();
}
}
}
assert!(game.run_command("forceload add -32 -32 32 32"));
assert!(game.run_command("tick freeze"));
let initial = game.world.game_time;
assert!(game.run_command("tick step 128"));
@@ -6074,6 +6092,31 @@ mod camel_pose_saved_tests {
game.tick_server(false).unwrap();
}
}
// Unload and reload the chunk, then let time pass: the saved game time
// stays what it was (`Camel.addAdditionalSaveData` writes it unchanged).
let written: Vec<Tag> = game
.entity_chunks(&HashSet::from([(0, 0)]), true)
.into_iter()
.flat_map(|(_, tags)| tags)
.collect();
game.world.loaded_entities.push(((0, 0), written));
game.spawn_loaded_entities();
for step in 0..8 {
for number in 1..=cases.len() as i32 {
let index = game
.mobs
.iter()
.position(|m| game.persist.uuids.get(&m.id) == Some(&[94, 0, 0, number]))
.unwrap();
let value = actors[number as usize - 1].1;
assert_eq!(
game.mob_tree_nbt(index).get("LastPoseTick"),
Some(&Tag::Long(value)),
"reloaded timestamp {value} after {step} steps"
);
}
game.tick_server(false).unwrap();
}
}
}
+8 -1
View File
@@ -1949,7 +1949,14 @@ impl Game<'_> {
// Projectile.onHit calls onHitBlock first, then the potion's
// affectEntitiesAround; both read the stack's `potion_contents`.
if let Some((cell, face)) = hit {
self.potion_hit_block(stack, cell, face);
// `AbstractThrownPotion.douseFire` passes `getOwner()` to the
// campfire's `douse`, which is where the game event's source is.
let who = if owner.player {
Some(Who::Player)
} else {
owner.attacker.map(Who::Mob)
};
self.potion_hit_block(stack, cell, face, who);
}
self.affect_entities_around(stack, at, owner.player);
let lingering = &*stack.item == "minecraft:lingering_potion";
@@ -18,7 +18,7 @@ impl Game<'_> {
fn quick_actions_dialog(&self) -> Option<Tag> {
let registries = &self.remote.as_ref()?.registries;
let dialogs = registries.registries.get("minecraft:dialog")?;
let tag = registries.dialog_tags.get("minecraft:quick_actions")?;
let tag = registries.tag("minecraft:dialog", "minecraft:quick_actions")?;
match tag.len() {
0 => None,
1 => {
+13 -39
View File
@@ -60,8 +60,12 @@ use std::{
};
use winit::{event::MouseButton, keyboard::KeyCode};
#[path = "remote_abilities.rs"]
mod abilities;
#[path = "remote_poll.rs"]
mod poll;
#[path = "remote_tables.rs"]
mod tables;
type BuiltinCache = Vec<(std::path::PathBuf, Arc<Builtin>)>;
static BUILTIN: std::sync::Mutex<BuiltinCache> = std::sync::Mutex::new(Vec::new());
@@ -539,9 +543,8 @@ pub(super) struct Remote {
pub(super) builtin: Arc<Builtin>,
lang: Arc<Language>,
pub(super) registries: Arc<Dynamic>,
/// The game's enchantments and the server's that it lacks (a data
/// pack's), from their synced data; `None` when the server has none.
pub(super) enchantments: Option<Arc<horizon_client::enchant::Enchantments>>,
/// The game's tables with what the server's synced data puts over them.
pub(super) tables: tables::ServerTables,
pub(super) joined: bool,
/// `ServerReconfigScreen` is up since this tick.
pub(super) reconfiguring: Option<u64>,
@@ -1159,7 +1162,7 @@ impl Game<'_> {
.unwrap_or_else(|| "Connecting to the server...".into()),
lang,
registries: Arc::new(Dynamic::default()),
enchantments: None,
tables: Default::default(),
joined: false,
reconfiguring: None,
logged_in: false,
@@ -1389,7 +1392,7 @@ impl Game<'_> {
status: "Playing Studio Replay".into(),
lang,
registries: Arc::new(Dynamic::default()),
enchantments: None,
tables: Default::default(),
joined: false,
reconfiguring: None,
logged_in: true,
@@ -2259,25 +2262,9 @@ impl Game<'_> {
.collect()
})
.unwrap_or_default();
let entries: Vec<(&str, serde_json::Value)> = registries
.registries
.get("minecraft:enchantment")
.into_iter()
.flatten()
.filter_map(|(name, data)| {
Some((
name.as_str(),
horizon_client::scoreboard::tag_to_json(data.as_ref()?),
))
})
.collect();
let enchantments = self
.stations()
.enchantments
.with_entries(entries.iter().map(|(n, v)| (*n, v)))
.map(Arc::new);
let tables = self.server_tables(&registries);
if let Some(r) = self.remote.as_mut() {
r.enchantments = enchantments;
r.tables = tables;
let bits = ceil_log2(biomes.len());
let column = r.chunk_format.layout();
r.chunk_format =
@@ -2414,7 +2401,7 @@ impl Game<'_> {
.as_ref()
.is_none_or(|r| r.dimension.has_skylight);
self.streaming.sky_light = has_sky;
self.streaming.nether_shading = self.dim == Dim::Nether;
self.streaming.nether_shading = self.nether_lighting();
// The seed in SpawnInfo is already the hashed one the client's BiomeManager holds.
self.streaming.biome_zoom = horizon_core::biome::BiomeZoom::new(spawn.seed);
let player = self.player.as_ref();
@@ -5298,6 +5285,7 @@ impl Game<'_> {
self.remote_server_data(p.icon.as_deref());
}
C::MapItemData(p) => self.remote_map_item_data(&p),
C::UpdateTags(p) => self.remote_update_tags(&p),
// handleTickingState / handleTickingStep: the level's TickRateManager.
C::TickingState(p) => {
self.clock.set_rate(p.rate);
@@ -6729,20 +6717,6 @@ impl Game<'_> {
Ok(())
}
/// ClientPacketListener.handlePlayerAbilities: the player flies, may fly
/// and is invulnerable as the server says (bits 2, 4 and 1).
fn apply_remote_abilities(&mut self) {
let (Some(player), Some(a)) = (
self.player.as_mut(),
self.remote.as_ref().and_then(|r| r.abilities.as_ref()),
) else {
return;
};
player.invulnerable = a.flags & 1 != 0;
player.flying = a.flags & 2 != 0;
player.may_fly = a.flags & 4 != 0;
player.flying_speed = a.flying_speed;
}
/// The local player's share of every touching entity's
/// `LivingEntity.pushEntities`, and of the boats' pushes: on a client
/// `EntitySelector.pushableBy` lets entities push only the local player,
@@ -6960,7 +6934,7 @@ impl Game<'_> {
}
let game_time = self.world.game_time;
// `AbstractPiglin.isConverting`: the dimension is not piglin safe.
let nether = self.dim == super::Dim::Nether;
let nether = !self.dimension_type().piglins_zombify;
// What a wither's side heads can look at, by network entity id: the
// mobs and the player (position and eye height).
let wither_targets: Vec<(i32, [f64; 3], f64)> = if self
@@ -0,0 +1,69 @@
//! The server's ClientboundPlayerAbilitiesPacket on the local player (`ClientPacketListener
//! .handlePlayerAbilities`).
use super::*;
/// `Abilities.walkingSpeed` before the server sends one.
const WALKING_SPEED: f32 = 0.1;
/// The walking speed `AbstractClientPlayer.getFieldOfViewModifier` divides the movement speed by:
/// the last abilities packet's (`Abilities.setWalkingSpeed`), else the default.
fn walking_speed(abilities: Option<&play::PlayerAbilities>) -> f32 {
abilities.map_or(WALKING_SPEED, |a| a.walking_speed)
}
impl Game<'_> {
/// ClientPacketListener.handlePlayerAbilities: the player flies, may fly
/// and is invulnerable as the server says (bits 2, 4 and 1). The walking
/// speed is read where the field of view needs it
/// ([`Game::remote_walking_speed`]), the instabuild bit where the packet
/// is handled (`Game::creative`).
pub(super) fn apply_remote_abilities(&mut self) {
let (Some(player), Some(a)) = (
self.player.as_mut(),
self.remote.as_ref().and_then(|r| r.abilities.as_ref()),
) else {
return;
};
player.invulnerable = a.flags & 1 != 0;
player.flying = a.flags & 2 != 0;
player.may_fly = a.flags & 4 != 0;
player.flying_speed = a.flying_speed;
}
/// `Abilities.getWalkingSpeed()` of the local player: the server's, on a server.
pub(in crate::game) fn remote_walking_speed(&self) -> f32 {
walking_speed(self.remote.as_ref().and_then(|r| r.abilities.as_ref()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_client::camera::{FovPlayer, field_of_view_modifier};
fn fov(walking: f32, movement: f64) -> f32 {
let player = FovPlayer {
flying: false,
walking_speed: walking,
movement_speed: movement,
using: None,
};
field_of_view_modifier(&player, true, 1.0)
}
#[test]
fn the_field_of_view_divides_by_the_servers_walking_speed() {
// A server that sets the walking speed to 0.2 and the movement speed to 0.2: vanilla's
// getFieldOfViewModifier is (0.2 / 0.2 + 1) / 2 = 1. Kept at 0.1 it would be
// (0.2 / 0.1 + 1) / 2 = 1.5, a zoomed-out view.
let packet = play::PlayerAbilities {
flags: 0,
flying_speed: 0.05,
walking_speed: 0.2,
};
assert_eq!(fov(walking_speed(Some(&packet)), 0.2), 1.0);
// Before any packet: vanilla's default 0.1, a still player at the base speed 0.1.
assert_eq!(walking_speed(None), 0.1);
assert_eq!(fov(walking_speed(None), 0.1), 1.0);
}
}
@@ -188,6 +188,8 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti
// Enderman.DATA_CREEPY (after DATA_CARRY_STATE): the open mouth
// and raised head of an angry enderman.
(Species::Enderman, 17, Value::Bool(creepy)) => m.extra.flag = *creepy,
// Enderman.DATA_STARED_AT: kept in scratch[0] for the stare sound.
(Species::Enderman, 18, Value::Bool(stared)) => m.extra.scratch[0] = i32::from(*stared),
// Warden.CLIENT_ANGER_LEVEL: the heartbeat's pace.
(Species::Warden, 16, Value::Int(anger)) => {
if let Some(st) = m.extra.warden.as_mut() {
@@ -498,7 +500,9 @@ impl Game<'_> {
(14, _, Value::OptPos(bed)) => Some(bed.map_or(0, |b| self.bed_facing(b))),
_ => None,
});
let mut stare_at = None;
if let Some(m) = self.mob_index.get_mut(&mut self.mobs, id as u32) {
stare_at = stare_sound(m, values);
apply(m, values, &r.builtin, r.entity_id);
apply_registry_data(m, values, &r.registries, self.world.game_time);
if let Some(code) = bed
@@ -507,9 +511,43 @@ impl Game<'_> {
m.extra.ints[1] = code;
}
}
if let Some(at) = stare_at {
// Level.playLocalSound: heard here only, never shared.
self.audio
.play_unshared("minecraft:entity.enderman.stare", Some(at), 2.5, 1.0);
}
}
}
/// `Enderman.onSyncedDataUpdated` / `playStareSound`: DATA_CREEPY changing
/// while DATA_STARED_AT is set plays the stare sound at the eye, at most once
/// per 400 ticks and never when silent. Entries are handled in index order, so
/// a stared-at flag sent in the same packet counts only for later entries.
/// Returns the sound position and records the tick in scratch[1].
fn stare_sound(m: &mut Mob, values: &[(u8, i32, Value)]) -> Option<[f64; 3]> {
if m.species() != Species::Enderman {
return None;
}
let mut stared = m.extra.scratch[0] != 0;
let mut sound = None;
for (index, _, value) in values {
match (*index, value) {
(18, Value::Bool(b)) => stared = *b,
(17, Value::Bool(_)) if stared => {
let tick = m.age as i32;
if tick >= m.extra.scratch[1].wrapping_add(400) {
m.extra.scratch[1] = tick;
if !m.silent {
sound = Some([m.position.x, m.eye_y(), m.position.z]);
}
}
}
_ => {}
}
}
sound
}
/// A mob as a client constructs it before its data arrives: the synched
/// defaults the server leaves out (`getNonDefaultValues`) that differ from
/// what `Mob::new` gives, such as a wolf's and a cat's red collar.
@@ -950,6 +988,26 @@ mod tests {
/// #2920: a server's horse is moved by its rider's client, which jumps
/// it by the type's jump strength (`createBaseHorseAttributes`,
/// `createBaseChestedHorseAttributes`) until the server syncs its own.
/// Enderman.playStareSound: only a DATA_CREEPY change while staring, once
/// per 400 ticks, at the eye height.
#[test]
fn an_endermans_stare_sound_follows_synced_data() {
let Some(mut m) = mob("minecraft:enderman") else {
return;
};
let creepy = [(17, s::BOOLEAN, Value::Bool(true))];
let stared = [(18, s::BOOLEAN, Value::Bool(true))];
assert!(stare_sound(&mut m, &creepy).is_none());
apply(&mut m, &stared);
m.age = 500;
let at = stare_sound(&mut m, &creepy).expect("stare sound");
assert!((at[1] - m.eye_y()).abs() < 1e-9);
m.age = 899;
assert!(stare_sound(&mut m, &creepy).is_none());
m.age = 900;
assert!(stare_sound(&mut m, &creepy).is_some());
}
#[test]
fn a_servers_horse_jumps_its_types_strength() {
for (kind, strength) in [("horse", 0.7), ("donkey", 0.5), ("mule", 0.5)] {
@@ -0,0 +1,175 @@
//! The game's tables with what a server's synced data puts over them: its
//! data packs' enchantments, and its block and item tags.
use super::super::Game;
use horizon_client::{enchant::Enchantments, items::Items};
use horizon_protocol::{packets::configuration::UpdateTags, registry::Dynamic};
use std::{collections::HashMap, sync::Arc};
/// `None` where the server sent nothing over the game's own table.
#[derive(Default)]
pub(crate) struct ServerTables {
/// The game's enchantments and the server's that it lacks (a data
/// pack's), from their synced data.
pub(crate) enchantments: Option<Arc<Enchantments>>,
/// The game's items with the block and item tags the server sent
/// (`ClientboundUpdateTagsPacket`) in place of its own.
pub(crate) items: Option<Arc<Items>>,
}
impl Game<'_> {
/// The tables `registries` puts over the game's.
pub(super) fn server_tables(&self, registries: &Dynamic) -> ServerTables {
let entries: Vec<(&str, serde_json::Value)> = registries
.registries
.get("minecraft:enchantment")
.into_iter()
.flatten()
.filter_map(|(name, data)| {
Some((
name.as_str(),
horizon_client::scoreboard::tag_to_json(data.as_ref()?),
))
})
.collect();
let enchantments = self
.stations()
.enchantments
.with_entries(entries.iter().map(|(n, v)| (*n, v)))
.map(Arc::new);
ServerTables {
enchantments,
items: self.server_items(registries),
}
}
/// The game's items with the server's block and item tags, by name. The
/// window's own server thread plays with the world's tables instead.
fn server_items(&self, registries: &Dynamic) -> Option<Arc<Items>> {
let r = self.remote.as_ref().filter(|_| self.integrated.is_none())?;
let names = |registry: &str| -> Option<HashMap<String, Vec<String>>> {
let ids = r.builtin.get(registry)?;
let tags = registries
.tags
.get(registry)?
.iter()
.map(|(name, members)| {
let members = members.iter().filter_map(|&id| ids.name(id));
(name.clone(), members.map(str::to_owned).collect())
});
Some(tags.collect())
};
let (blocks, items) = (names("minecraft:block"), names("minecraft:item"));
if blocks.is_none() && items.is_none() {
return None;
}
Some(Arc::new(self.res.items().with_tags(blocks, items)))
}
/// `ClientPacketListener.handleUpdateTags` (after a `/reload`): the tags
/// it names replace the ones the configuration sent.
pub(super) fn remote_update_tags(&mut self, packet: &UpdateTags) {
let Some(r) = self.remote.as_ref() else {
return;
};
let mut registries = Dynamic::clone(&r.registries);
registries.add_tags(packet);
let tables = self.server_tables(&registries);
if let Some(r) = self.remote.as_mut() {
r.registries = Arc::new(registries);
r.tables = tables;
}
}
}
#[cfg(test)]
mod tests {
use super::super::super::{
Audio, Game, Options, Progress, Settings, execution_role::ExecutionRole, load_resources,
};
use horizon_protocol::{
connection::Pipe,
packets::configuration::{RegistryTags, TagEntry, UpdateTags},
};
use std::collections::HashSet;
fn client() -> 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!("no menu handoff"),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = ExecutionRole::Client;
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();
let (client, _server) = Pipe::pair();
game.connect_local(client, "TagTest").unwrap();
game
}
/// A server whose data pack makes stone climbable and sticks planks: the
/// client reads its tags, not the game's own (#7843).
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_servers_block_and_item_tags_replace_the_games() {
let mut game = client();
assert!(!game.items().block_tags["minecraft:climbable"].contains("minecraft:stone"));
let builtin = game.remote.as_ref().unwrap().builtin.clone();
let id = |registry: &str, name: &str| builtin.get(registry).unwrap().id(name).unwrap();
let tag = |registry: &str, name: &str, members: &[&str]| RegistryTags {
registry: registry.into(),
tags: vec![TagEntry {
name: name.into(),
ids: members.iter().map(|m| id(registry, m)).collect(),
}],
};
game.remote_update_tags(&UpdateTags {
registries: vec![
tag(
"minecraft:block",
"minecraft:climbable",
&["minecraft:ladder", "minecraft:stone"],
),
tag(
"minecraft:item",
"minecraft:planks",
&["minecraft:stick", "minecraft:oak_planks"],
),
],
});
let items = game.items();
let set = |names: &[&str]| names.iter().map(|n| n.to_string()).collect::<HashSet<_>>();
assert_eq!(
items.block_tags["minecraft:climbable"],
set(&["minecraft:ladder", "minecraft:stone"])
);
// The server's tags replace the game's: a tag it didn't send is gone.
assert!(!items.block_tags.contains_key("minecraft:fences"));
assert_eq!(
items.item_tag_order["minecraft:planks"],
["minecraft:stick", "minecraft:oak_planks"]
);
assert!(items.item_tags["minecraft:planks"].contains("minecraft:stick"));
}
}
+22 -6
View File
@@ -21,7 +21,11 @@ use horizon_client::{
screen_effects::{ScreenInputs, ScreenQuad, lightmap_brightness, nausea_has_effect},
};
use horizon_core::random::{LegacyRandom, RandomSource};
use horizon_world::{heightmap::HeightmapType, worldgen::terrain::TerrainBlockStates};
use horizon_world::{
dimension_type::{CardinalLight, Skybox},
heightmap::HeightmapType,
worldgen::terrain::TerrainBlockStates,
};
pub(super) fn player_tracking_body(player: &PlayerMotion) -> TrackingBody {
TrackingBody {
@@ -96,6 +100,18 @@ fn rains_at(world: &World, biomes: &Biomes, x: i32, y: i32, z: i32) -> bool {
}
impl Game<'_> {
/// `GameRenderer`'s cardinal light: the level's type uses the Nether's diffuse lights.
pub(super) fn nether_lighting(&self) -> bool {
self.dimension_type().cardinal_light == CardinalLight::Nether
}
/// `SkyRenderer`'s overworld skybox: sun, moon, stars and clouds.
pub(super) fn overworld_skybox(&self) -> bool {
self.dimension_type().skybox == Skybox::Overworld
}
/// `SkyRenderer`'s End skybox and its flashes.
pub(super) fn end_skybox(&self) -> bool {
self.dimension_type().skybox == Skybox::End
}
pub(super) fn forward(&self) -> [f64; 3] {
let (yaw, pitch) = (
f64::from(self.yaw).to_radians(),
@@ -151,7 +167,7 @@ impl Game<'_> {
forward: forward.map(|c| c as f32),
rain,
thunder,
skybox: self.dim == Dim::Overworld,
skybox: self.overworld_skybox(),
blindness: self
.potions
.effects
@@ -267,7 +283,7 @@ impl Game<'_> {
} else {
f64::from(self.pack.polytone_colors.env.horizon_height)
};
self.dim == Dim::Overworld
self.overworld_skybox()
&& camera.y < horizon
&& fog_type(
&self.world,
@@ -522,10 +538,10 @@ impl Game<'_> {
let height_no_leaves =
|p: [i32; 3]| motion_blocking_leaves(world, facts, p[0], p[2], i32::MAX, false);
// The day timeline: firefly bush sounds from 12600 to 23400.
let firefly_bush_sounds = self.dim == Dim::Overworld
let firefly_bush_sounds = self.dimension_type().has_timeline("minecraft:day")
&& (12600..23401).contains(&self.world.day_time.rem_euclid(24000));
// EnvironmentAttributes.CREAKING_ACTIVE: the same overworld night.
let creaking_active = self.dim == Dim::Overworld
let creaking_active = self.dimension_type().has_timeline("minecraft:day")
&& (12600..23401).contains(&self.world.day_time.rem_euclid(24000));
let tables = &self.pack.context.tables;
let model_offset = |state, x, z| tables.offset(state, x, z);
@@ -552,7 +568,7 @@ impl Game<'_> {
model_offset: &model_offset,
gateway_particles: &gateway_particles,
thundering,
ultrawarm: self.dim == Dim::Nether,
ultrawarm: self.dimension_type().water_evaporates,
};
if let Some(e) = eye {
let block = [
@@ -26,6 +26,14 @@ impl Resources {
pub(super) fn environment(&self) -> &Environment {
&self.core.environment
}
/// The environment of a dimension type id; a type with no table of its own gets the overworld's.
pub(super) fn environment_of(&self, dimension_type: &str) -> &Environment {
match dimension_type {
"minecraft:the_nether" => self.nether_environment(),
"minecraft:the_end" => self.end_environment(),
_ => self.environment(),
}
}
pub(super) fn mob_types(&self) -> &HashMap<String, Arc<MobType>> {
&self.rest().mob_types
}
+1 -3
View File
@@ -280,9 +280,7 @@ impl Game<'_> {
fn pause_screen_additions(&self) -> Option<(Text, Tag)> {
let registries = &self.remote.as_ref()?.registries;
let dialogs = registries.registries.get("minecraft:dialog")?;
let tag = registries
.dialog_tags
.get("minecraft:pause_screen_additions")?;
let tag = registries.tag("minecraft:dialog", "minecraft:pause_screen_additions")?;
// addCustomDialogButtons' label: computeExternalTitle falls back to the title.
let button = |dialog_tag: Tag| -> Option<(Text, Tag)> {
let dialog = Dialog::parse(&dialog_tag)?;
@@ -110,6 +110,33 @@ fn a_torch_that_stays_connects_the_wire_in_a_line_and_powers_it() {
);
}
/// `handleSetCommandBlock` edits the block entity through
/// `BaseCommandBlock.setCommand`/`onUpdated`: the command block is marked
/// changed and its update tag reaches the clients. Horizon applied the edit
/// without marking the chunk.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn editing_a_command_block_marks_its_chunk() {
let mut game = game();
run(&mut game, &["setblock 5 100 2 command_block[facing=up]"]);
game.world.modified.clear();
if let Some(log) = game.world.block_log.as_mut() {
log.clear();
}
game.apply_command_block(&super::command_blocks::CommandBlockEdit {
pos: (5, 100, 2),
command: "say hi".into(),
track_output: false,
mode: horizon_client::command_block::Mode::Redstone,
conditional: false,
auto: false,
});
assert!(
game.world.modified.contains(&(0, 0)),
"the edited command block's chunk is dirty"
);
}
/// `TestBlock.ownSignal` reads the block state's `mode`, not the block
/// entity's saved copy: a `start` state with a powered block entity answers
/// 15 even when `/data merge` left a different mode in the entity.
@@ -0,0 +1,61 @@
//! `SmithingScreen`'s empty slot icons: the base and addition lists of a
//! template (`SmithingTemplateItem`'s create*IconList helpers).
/// The base and additions slot icons of the template in the smithing table:
/// `SmithingTemplateItem.createNetheriteUpgradeTemplate` (the 11 tools and
/// armour of `SmithingTemplateItem.createNetheriteUpgradeIconList`, then the
/// ingot) and `SmithingTemplateItem.createArmorTrimTemplate` (the four armour
/// pieces, then the seven trim materials), as `getBaseSlotEmptyIcons` and
/// `getAdditionalSlotEmptyIcons` give them.
pub(super) fn smithing_slot_icons(
template: Option<&str>,
) -> ([&'static str; 11], usize, [&'static str; 7], usize) {
let none = ([""; 11], 0, [""; 7], 0);
match template.map(|t| t.trim_start_matches("minecraft:")) {
Some("netherite_upgrade_smithing_template") => (
[
"helmet",
"sword",
"chestplate",
"pickaxe",
"leggings",
"axe",
"boots",
"hoe",
"shovel",
"nautilus_armor",
"spear",
],
11,
["ingot", "", "", "", "", "", ""],
1,
),
Some(t) if t.ends_with("_armor_trim_smithing_template") => (
[
"helmet",
"chestplate",
"leggings",
"boots",
"",
"",
"",
"",
"",
"",
"",
],
4,
[
"ingot",
"redstone_dust",
"lapis_lazuli",
"quartz",
"diamond",
"emerald",
"amethyst_shard",
],
7,
),
_ => none,
}
}
@@ -161,8 +161,8 @@ impl Game<'_> {
let disable_blocking = if self.using.is_some() {
0.0
} else {
self.attack_item_info()
.map_or(0.0, |i| i.disable_blocking_seconds)
self.held()
.map_or(0.0, |stack| self.items().weapon_disable_blocking(stack))
};
Some(PlayerStab {
victim,
+65 -8
View File
@@ -55,7 +55,13 @@ impl Game<'_> {
/// face it hit, the cell behind that (the block it hit) and the four
/// horizontal neighbours of the cell in front. `hit` is the block and
/// `face` the face it was hit on.
pub(super) fn potion_hit_block(&mut self, stack: &ItemStack, hit: [i32; 3], face: Direction) {
pub(super) fn potion_hit_block(
&mut self,
stack: &ItemStack,
hit: [i32; 3],
face: Direction,
owner: Option<Who>,
) {
// `PotionContents.is(PotionTags.DOUSES_FIRE)` on the stack's `potion_contents`.
if !self.potion_in_tag(stack, "minecraft:douses_fire") {
return;
@@ -65,8 +71,8 @@ impl Game<'_> {
[pos[0] + n[0], pos[1] + n[1], pos[2] + n[2]]
};
let effect = relative(hit, face);
self.douse_fire(effect);
self.douse_fire(relative(effect, face.opposite()));
self.douse_fire(effect, owner);
self.douse_fire(relative(effect, face.opposite()), owner);
// Direction.Plane.HORIZONTAL.
for direction in [
Direction::North,
@@ -74,14 +80,14 @@ impl Game<'_> {
Direction::South,
Direction::West,
] {
self.douse_fire(relative(effect, direction));
self.douse_fire(relative(effect, direction), owner);
}
}
/// `AbstractThrownPotion.douseFire`: a fire block is destroyed without
/// drops, a lit candle or candle cake goes out, and a lit campfire goes
/// out after level event 1009 and its block-change game event.
fn douse_fire(&mut self, pos: [i32; 3]) {
fn douse_fire(&mut self, pos: [i32; 3], owner: Option<Who>) {
let at = BlockPos::new(pos[0], pos[1], pos[2]);
let state = self.world.block(pos[0], pos[1], pos[2]);
let name = self.res.facts().get(state).name.clone();
@@ -97,9 +103,9 @@ impl Game<'_> {
&& let Some(mut lit) = self.lit_campfire(state, &name)
{
self.level_event_at(1009, pos, 0);
// CampfireBlock.douse: the block-change game event, whose source
// is the potion's owner (not tracked here).
self.post_block_game_event("minecraft:block_change", pos, None, None);
// `CampfireBlock.douse(entity, ...)`: the block-change game event,
// sourced from the potion's owner as vanilla passes `getOwner()`.
self.post_block_game_event("minecraft:block_change", pos, owner, None);
lit.properties.insert("lit".into(), "false".into());
if let Ok(unlit) = self.pack.assets.registry.resolve(&lit) {
let _ = self.level_set(pos, unlit);
@@ -257,3 +263,54 @@ impl Game<'_> {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::game::native_machines::tests::server_game;
/// `AbstractThrownPotion.douseFire` -> `CampfireBlock.douse(entity, ...)`:
/// the campfire's block change is sourced from the potion's owner.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_doused_campfire_posts_block_change_from_the_potion_owner() {
let mut game = server_game();
let pos = [2, 4, 3];
let campfire = game
.pack
.assets
.registry
.default_state("minecraft:campfire")
.unwrap();
game.world.set(pos[0], pos[1], pos[2], campfire).unwrap();
// The lit campfire the douse looks for.
let lit = game.lit_campfire(campfire, "minecraft:campfire");
assert!(lit.is_some(), "the campfire starts lit");
super::super::sculk::TEST_POSTED_EVENTS.with(|e| *e.borrow_mut() = Some(Vec::new()));
game.douse_fire(pos, Some(Who::Mob(7)));
let after = game.world.block(pos[0], pos[1], pos[2]);
assert!(
!game
.pack
.assets
.registry
.state(after)
.and_then(|s| s.properties.get("lit"))
.is_some_and(|v| v == "true"),
"the campfire is out"
);
super::super::sculk::TEST_POSTED_EVENTS.with(|e| {
assert!(
e.borrow().as_ref().unwrap().contains(&(
"minecraft:block_change",
[2.5, 4.5, 3.5],
Some(Who::Mob(7))
)),
"{:?}",
e.borrow()
);
});
}
}
Loaded 100 of 262 files, more files were not shown because too many files have changed in this diff. Show more