Compare commits

..
Author SHA1 Message Date
rdbt 221d303a90 Merge pull request #14409 from Joansitoh/fix-14407-version-picker-scroll
Launcher: the Home version picker scrolls its list
2026-11-10 22:45:32 +00:00
rdbt 83cb8979be Merge pull request #14399 from Cx188/fix/14338-command-hints-scroll
chat: fix touchpad scrolling in command suggestions
2026-11-10 22:45:26 +00:00
rdbt 1f10f8bbb0 Merge pull request #14398 from MSedgeMC/fix/social-scrollbar
The social player list shows its scrollbar
2026-11-10 22:45:22 +00:00
rdbt 67de3115e2 Merge pull request #14396 from MSedgeMC/fix/creative-tab-tooltip
The creative inventory's tabs say their name on hover
2026-11-10 22:45:17 +00:00
rdbt 53aab90fb5 Merge pull request #14393 from vivescene/perf/vehicle-mob-grid
Server tick: boats and minecarts read the mobs near them, not every mob
2026-11-10 22:45:12 +00:00
rdbt 0e120f96d5 Merge pull request #14392 from YourPancakes/perf/reuse-native-sprite-frames
perf(render): reuse native sprite frames when sizes match
2026-11-10 22:45:06 +00:00
rdbt 1f1fa1fc4d Merge pull request #14386 from vnsmoreira/fix/glide-fall-accumulation
A gliding mob caps its fall at 1 unless it dives
2026-11-10 22:44:59 +00:00
rdbt 8ffe3913ea Merge pull request #14379 from martipops/agent/pack-card-pad-target
Pack screen: a pack card's options button is a pad target
2026-11-10 22:44:54 +00:00
rdbt 87fe5531ed Merge pull request #14378 from ilguerro/fix/mount-feeding-eat
Feeding a horse or camel makes a vibration and a Silent one eats quietly
2026-11-10 22:44:50 +00:00
rdbt 255baf27e3 Merge pull request #14367 from DUzzL/fix/14297-hopper-decorated-pot
Let hoppers pull items out of a decorated pot
2026-11-10 22:44:44 +00:00
rdbt a713ed14e5 Merge pull request #14364 from DUzzL/fix/14298-tab-name-translation
Resolve a creative tab name given as a translation key
2026-11-10 22:44:37 +00:00
rdbt 4973e64a25 Merge pull request #14362 from voduff/map-stale-gaps-005
port map: player, villager, witch, wind and wither skull render rows (client-005, client-007)
2026-11-10 22:44:32 +00:00
rdbt 0a42c8f111 Merge pull request #14359 from ilguerro/fix/breed-partner-range
Move the breed goal tests out of goals.rs
2026-11-10 22:44:27 +00:00
rdbt 8c1118a3cf Merge pull request #14357 from voduff/map-stale-gaps-003
port map: boat wobble, skeleton shake, cat poses, block displays are drawn (client-003)
2026-11-10 22:44:22 +00:00
rdbt 6a88a1335d Merge pull request #14354 from voduff/map-stale-gaps-008
port map: block entity crack overlays are drawn (client-008)
2026-11-10 22:44:17 +00:00
rdbt e02af2c3bf Merge pull request #14347 from Cx188/fix/8406-stage-local-reachability
Drop unreachable graphics-stage GLSL helpers
2026-11-10 22:44:11 +00:00
rdbt a31f9f4763 Merge pull request #14344 from MSedgeMC/fix/test-block-analog-state-gate
A test block reads its block state mode for a comparator
2026-11-10 22:44:03 +00:00
rdbt 50486a59b0 Merge pull request #14337 from voduff/map-particle-anvil
port map: the particle limit and anvil rename box rows name their fns
2026-11-10 22:43:58 +00:00
rdbt e6ab43107a Merge pull request #14330 from yahya-nusseibeh/fix/report-claim-before-filing
Telemetry server: claim a bug report before filing its issue
2026-11-10 22:43:53 +00:00
rdbt 912e18ed0a Merge pull request #14329 from yahya-nusseibeh/fix/text-push-in-place
Text: append siblings in place instead of cloning the tree each time
2026-11-10 22:43:47 +00:00
rdbt eef3c52455 Merge pull request #14328 from yahya-nusseibeh/fix/translation-span-budget
Text: bound what one translation's fallback template expands to
2026-11-10 22:43:42 +00:00
rdbt 0e4511ae40 Merge pull request #14326 from ilguerro/fix/breeze-dust-support
Breeze dust crumbles the fence, wall or gate it stands on
2026-11-10 22:43:36 +00:00
rdbt dfa0603931 Merge pull request #14315 from vnsmoreira/packs/polytone-rnoise
Polytone: r.noise is 1.21's PerlinSimplexNoise, seed 0, octave 1
2026-11-10 22:43:31 +00:00
rdbt cf3d748474 Merge pull request #14312 from ilguerro/fix/enderman-persistent-anger
Keep an enderman's anger after it loses its target
2026-11-10 22:43:25 +00:00
rdbt 865f209838 Merge pull request #14308 from MSedgeMC/fix/named-fish-stays
A named fish or axolotl never despawns
2026-11-10 22:43:20 +00:00
rdbt aefc8c5254 Merge pull request #14307 from Akirabane/fix/jmath-log-windows
jmath: Math.log on Windows rounds like HotSpot's x86-64 stub, as sin and cos do
2026-11-10 22:43:15 +00:00
rdbt b1f72f3449 Merge pull request #14304 from NikitaMGrimm/record-start-copy
record-start no longer fails copying a world the server is saving
2026-11-10 22:43:10 +00:00
rdbt 450a11d633 Merge pull request #14289 from senjinthedragon/ctm-shared-layers
Connected textures: a texture a rule matches keeps a layer of its own
2026-11-10 22:43:05 +00:00
rdbt cb0513691e Merge pull request #14288 from vnsmoreira/fix/vehicle-fall-clip-wait
command_exec: two boat groups wait for their entity-ticking neighbourhood
2026-11-10 22:42:58 +00:00
rdbt b36353a08a Merge pull request #14255 from Cx188/fix/8578-controlled-chunk-acks
Add a controlled fixed-64 chunk ACK mode to horizon-bots
2026-11-10 22:42:53 +00:00
rdbt aeca67e1e7 Merge pull request #13818 from RINNECODER/up12/cem-legacy-textures
OptiFine CEM: a pre-1.21.5 pack's old mob textures paint its models and the climate variants
2026-11-10 22:42:48 +00:00
rdbt 95cfe9ab5c Merge pull request #13816 from RINNECODER/up12/cem-this
OptiFine CEM: `this` is a replacing entry's own bone
2026-11-10 22:42:43 +00:00
rdbt 7b3a3d06fa Merge pull request #13727 from MSedgeMC/fix/dialog-book-pause
A dialog's pause and a lectern's book decide whether the world stops
2026-11-10 22:42:37 +00:00
rdbt d5f9b60203 Merge pull request #12642 from itarqos5/perf/particle-threads
Particles: tick the independent ones on several threads (stacked on #12641)
2026-11-10 22:42:31 +00:00
rdbt fbd69c11f1 Merge pull request #12449 from RINNECODER/up3/pack-compact-terrain
Shader packs: keep solid terrain in the compact layout
2026-11-10 22:42:27 +00:00
rdbt 6ed7be61d9 Merge pull request #11230 from Ragbie/fix/vehicle-item-stack-config
A placed or dispensed minecart or boat takes its item's name, custom data and entity_data
2026-11-10 22:42:01 +00:00
rdbt aff92ca2df Merge pull request #14504 from horizon-rust/ci/runner-watch
runner watch: one issue when jobs wait for self-hosted runners that are offline
2026-11-10 22:36:30 +00:00
rdbt 54ee56ea1c Merge pull request #14303 from NikitaMGrimm/agent-e2e-windows
agent_control_e2e passes on Windows again: screen commands wait for the first frame
2026-11-10 22:36:24 +00:00
rdbt 9221eaf7b5 Merge pull request #14323 from NikitaMGrimm/fix/layer-checks-merge-base
Local layer and address checks no longer fail a branch for entries main dropped after it
2026-11-10 22:36:21 +00:00
rdbt 7b42678bcd Merge pull request #14342 from NikitaMGrimm/prepared-data-stale-tests
Five prepared-data tests run again: datapack fixtures, the gateway test, HORIZON_TEST_RESOURCES
2026-11-10 22:36:17 +00:00
rdbt e7af6c5683 Merge pull request #14372 from Joansitoh/fix/14369-rotate-source
Fix /rotate relative angles to use the command source's rotation
2026-11-10 22:36:13 +00:00
rdbt 26f0f7b710 Merge pull request #14507 from horizon-rust/docs/operations-runbook
docs/operations.md: running Horizon by hand without agents or the maintainer's machines
2026-11-10 22:36:09 +00:00
rdbt 37518b7953 Merge pull request #14429 from rdbtCVS/perf/forced-cover-distance
Streaming: forced chunks by distance, one lookup per column instead of a scan
2026-11-10 22:34:12 +00:00
rdbt c5ebfaad17 Merge pull request #14316 from voduff/team-feedback-names
Scoreboard: /team and /scoreboard name holders and teams with vanilla's hover and insertion
2026-11-10 22:34:09 +00:00
rdbt 1ce50ca842 Merge pull request #13760 from TheMrZZ/fix/allay-jukebox-source-2
An allay dances to the jukebox it heard first
2026-11-10 22:33:43 +00:00
rdbt 03fe63b323 Merge pull request #14336 from voduff/map-moved-fns
port map: rows follow fns split into their own files
2026-11-10 22:33:40 +00:00
rdbt 0e9d4928a1 Merge pull request #14363 from voduff/map-stale-gaps-004
port map: golem, mooshroom, ocelot, phantom, snow golem render rows (client-004)
2026-11-10 22:33:37 +00:00
rdbt 10dd71a575 Merge pull request #14428 from DUzzL/fix/14235-plate-minecart
Fix a fenced minecart not re-triggering the pressure plate above it
2026-11-10 22:33:33 +00:00
rdbt f722c4455a Merge pull request #14453 from AfrobamaYT/parity-shim-loop
Parity: the java shim never starts itself, and stops with a reason if a wrapper loops it back
2026-11-10 22:33:29 +00:00
rdbt ff4dd57b32 Merge pull request #14456 from rdbtCVS/fix/armadillo-baby-follow-box
Armadillo: a baby follows only adults its sensor's box meets
2026-11-10 22:33:25 +00:00
rdbt 6e7600425c Merge pull request #14633 from NikitaMGrimm/parity/projectile-rotation-lerp
Thrown projectiles and llama spit turn a fifth of the way towards their motion each tick
2026-11-10 22:33:22 +00:00
rdbt 9ba4c83c35 Merge pull request #14385 from Akirabane/fix/9370c-skin-loader-bounds
Skin loader: bound its downloads, `states` and `resources`
2026-11-10 22:31:11 +00:00
rdbt 98d56df183 Merge pull request #14952 from masa-masa-0314/audit/realms-26.3-main
Realms: finish world extraction after cancellation
2026-11-10 22:31:07 +00:00
rdbt ec5b969a9c Merge pull request #14272 from NikitaMGrimm/parity-windows-builds
Local parity runs work on Windows: harnesses no longer fail with "Access is denied" rebuilding a running game
2026-11-10 22:31:04 +00:00
rdbt fddebc2c59 Merge pull request #14335 from Akirabane/fix/3823-fullbright-remote
Fullbright is held off on another's server; single player and a LAN host keep it
2026-11-10 22:30:37 +00:00
rdbt d4f99b0b3b Merge pull request #14343 from Akirabane/fix/12820-offline-own-cosmetics
Cosmetics: the own model on an offline-mode server is asked by the signed-in account
2026-11-10 22:30:33 +00:00
rdbt cc3b9703a4 Merge pull request #14346 from Akirabane/fix/9370a-pack-icon-budget
Pack screen: send each pack icon to the atlas once, not every frame
2026-11-10 22:30:30 +00:00
rdbt 85e2eddf52 Merge pull request #14353 from DUzzL/fix/14264-dragon-wing-armour
Charge the host's armour for a dragon wing hit
2026-11-10 22:30:26 +00:00
rdbt d907493401 Merge pull request #14355 from Evan-lhm/split/gfx-section-fade
gfx.rs: the chunk fade-in helpers move to gfx/section_fade.rs
2026-11-10 22:30:23 +00:00
rdbt 09ab1bf799 Merge pull request #14548 from DUzzL/fix/14443-minecart-soul-sand
Minecart: powered rail pushes a resting cart away from soul sand
2026-11-10 22:30:13 +00:00
rdbt c822ad5a5b Merge pull request #14603 from Mayonesu0/up-14475-firework-lifetime
A firework rocket draws its UUID, motion, lifetime and shot from one random
2026-11-10 22:30:08 +00:00
rdbt 550bfa1815 Merge pull request #14797 from foudretbr/fix/launcher-preview-lag-animating-items
fix(cosmetics): permit 60 fps drag pictures while animating items in viewer (#14656)
2026-11-10 22:30:04 +00:00
rdbt 9b6036466e Merge pull request #14854 from lucasmts59/fix/dialog-text-glyph-bounds
A dialog's body text is clicked where its glyphs are drawn
2026-11-10 22:30:00 +00:00
rdbt 9c1241b581 Merge pull request #15003 from MSedgeMC/fix/strider-lava-fall
A strider that lands in lava takes no fall damage
2026-11-10 22:29:57 +00:00
rdbt 9fb79e9ae5 Merge pull request #15005 from MSedgeMC/fix/sulfur-cube-silent-step
A sulfur cube carrying a block walks silently
2026-11-10 22:29:53 +00:00
rdbt ee286fe931 Merge pull request #14842 from foudretbr/perf/shader-water-patch-uniform-reads
perf(shaders): unroll boat water patch checks to avoid local array spilling (#14782)
2026-11-10 22:28:00 +00:00
rdbt 926790a953 Merge pull request #14810 from tarzinio/fix-particle-culling-answers
Keep answers of earlier particle culling jobs
2026-11-10 22:27:57 +00:00
rdbt 5991716f9b Merge pull request #14796 from DUzzL/fix/14753-cube-wasonground
Run AbstractCubeMob.tick for NoAI cubes so wasOnGround updates
2026-11-10 22:27:54 +00:00
rdbt 41bf552edc Merge pull request #14778 from DUzzL/fix/14752-minecart-pitch
Reset a minecart's pitch to 0 every tick like vanilla's old behaviour
2026-11-10 22:27:50 +00:00
rdbt 705826487f Merge pull request #14788 from NongTham/fix/14748-carved-pumpkin-swing
use_item: do not swing non-swappable equippables on empty target (fixes #14748)
2026-11-10 22:27:47 +00:00
rdbt 87327e8228 Merge pull request #14790 from zenith600/fix/hidden-processes-platform-path
Launcher: hidden_processes test names horizon-prepare through horizon-platform
2026-11-10 22:27:44 +00:00
rdbt 743c4af6a8 Merge pull request #14878 from NikitaMGrimm/hdr-calibrate-wording
HDR settings: the Calibrate row no longer says Windows' peak is too high
2026-11-10 22:27:40 +00:00
rdbt 8f761ea16e Merge pull request #14881 from Art34222/fix/full-detail-distance-chunks
Keep vanilla's chunk count on Full Detail Distance
2026-11-10 22:27:37 +00:00
rdbt 50be3bbb80 Merge pull request #15002 from MSedgeMC/fix/shelf-block-activate
port: a container writing a shelf's slot posts block_activate
2026-11-10 22:27:33 +00:00
rdbt d4e561413b Merge pull request #14903 from MSedgeMC/fix/recipe-book-narrow-escape
Escape closes the recipe book, not the screen, when it covers one
2026-11-10 22:27:00 +00:00
rdbt ce94025311 Merge pull request #14906 from MSedgeMC/ledger/lectern-maybuild
port map: the lectern's Take Book row names the build gate it already has
2026-11-10 22:26:57 +00:00
rdbt 885a98f2df Merge pull request #14915 from Detimes/perf-mob-near
Perf: raider companions, shout range and the cat bed check use the mob grid
2026-11-10 22:26:52 +00:00
rdbt a72bf4935b Merge pull request #14913 from Detimes/perf-piston-index
Perf: index moving-piston tickers by position
2026-11-10 22:26:49 +00:00
rdbt 4af062b305 Merge pull request #14912 from Detimes/perf-adv-tick
Perf: no advancement tick view without a listening criterion
2026-11-10 22:26:45 +00:00
rdbt a4b68b5d40 Merge pull request #14953 from Web-mc1/fix/far-tiles-div-zero
Fix div_ceil(0) when HORIZON_FAR_TILES=0
2026-11-10 22:26:42 +00:00
rdbt 14c1840967 Merge pull request #14960 from MSedgeMC/fix/pet-dead-attacker
A pet does not avenge a dead attacker
2026-11-10 22:26:39 +00:00
rdbt 7866f95d80 Merge pull request #14963 from MSedgeMC/fix/fox-sleep-inputs
A fox with movement input does not sleep
2026-11-10 22:26:35 +00:00
rdbt ab98b12b18 Merge pull request #14965 from DUzzL/fix/14833-phantom-pitch
Phantoms keep the pitch their move control sets
2026-11-10 22:26:32 +00:00
rdbt d7b00ddb4b Merge pull request #14966 from RLC248/fix/sulfur-cube-growth-age-crossing
A sulfur cube grows to size 2 only when it grows up
2026-11-10 22:26:28 +00:00
rdbt 9fcedb7da9 Merge pull request #14968 from DUzzL/fix/14908-furnace-neg-cooktime
fix(furnace): saturate idle cooking timer at a negative total instead of panicking
2026-11-10 22:26:25 +00:00
rdbt 2a17e04399 Merge pull request #14985 from MSedgeMC/fix/book-link-cursor
A link in a book asks for the pointing hand
2026-11-10 22:26:22 +00:00
rdbt bbe2b1be3c Merge pull request #14992 from senjinthedragon/vec4-uniforms
Shader packs: horizonDynamicLight* and horizonWaterPatch* are vec4s to the type check, not floats
2026-11-10 22:26:18 +00:00
rdbt 1b30bc56af Merge pull request #14983 from MSedgeMC/fix/suggestion-hand-cursor
The command suggestions ask for the pointing hand
2026-11-10 22:25:36 +00:00
rdbt ee2e56ced0 Merge pull request #14990 from DUzzL/fix/14909-shulker-neg-peek
fix(shulker): a negative Peek no longer panics the lid easing
2026-11-10 22:25:33 +00:00
rdbt 32a7bbc853 Merge pull request #14996 from KBroneZ/claude/fork-14503
Leak each distinct block registry once in Assets::load
2026-11-10 22:25:31 +00:00
rdbt a02e0034cf Merge pull request #15008 from MSedgeMC/fix/spider-vehicle-attachment-note
port_map: the spider's vehicle attachment names its rule
2026-11-10 22:25:19 +00:00
MSedgeMC 4a93a8edcc port_map: the spider's vehicle attachment names its rule
The row pointed at passenger_attachment, which is the seat for riders on the
spider, and claimed the spider always uses its fixed seat. The rule is
vehicle_attachment: 0.3125 of the scale only when the vehicle's box is no wider
than the spider's, and a wider vehicle falls to the type's vehicle attachment,
EntityAttachment.VEHICLE's AT_FEET fallback, which is the feet.
2026-11-10 21:38:43 +00:00
MSedgeMC f09665e100 port_map: the loaded cube's silent step names its arm
SulfurCube.playStepSound had no row; it is the sulfur cube arm of step_sound.
2026-11-10 21:32:44 +00:00
MSedgeMC 41036d79a3 Test the loaded cube that steps silently
ai::step_sound over a stone block: a sulfur cube with a stone body answers
None, and an empty one answers block.stone.step at volume 0.15 and pitch 1.0.
Without the arm the loaded cube reads Some(("block.stone.step", 0.15, 1.0)).
2026-11-10 21:29:37 +00:00
MSedgeMC dba73d15d4 A sulfur cube carrying a block walks silently
SulfurCube.playStepSound returns before its super while the cube has a block
in its body, so a loaded cube makes no footstep at all; an empty one takes the
block's own step at 15%. step_sound had no sulfur cube arm, so a loaded cube
walked with block.stone.step like an empty one.

The arm sits in step_sound, which moves out of ai.rs into mobs/ai/step.rs with
the block sound ids it interns: ai.rs loses 100 lines of code to the move.
2026-11-10 21:29:37 +00:00
MSedgeMC ea6b0327df port_map: the strider's lava landing names its arm
Strider.checkFallDamage had no row; it is the lava guard in check_fall_damage.
2026-11-10 21:16:49 +00:00
MSedgeMC 4c3c5d6dd1 Test the strider that falls into lava
Mob::check_fall_damage with six blocks of fall and the mob standing inside a
lava pool: the strider takes 0.0 and keeps a fall distance of 0.0 with no
LandingDust, a pig in the same lava takes 3.0, and a strider landing on stone
takes 3.0 too. Without the arm the strider in lava reads 3.0.
2026-11-10 21:16:44 +00:00
MSedgeMC 53d72939bc A strider that lands in lava takes no fall damage
Strider.checkFallDamage resets the fall distance and returns while the strider
is in lava, so the base's landing never runs: no fall damage and no dust, and
a strider that drops into a lava sea walks out of it owing nothing. Horizon
ran the base for every species, so a strider that fell six blocks into lava
took the three a pig would.
2026-11-10 21:16:44 +00:00
MSedgeMC e2f5019d91 port_map: ShelfBlockEntity.setChanged posts block_activate
A container's slot write on a shelf posts the no-argument setChanged's
block activation.
2026-11-10 20:58:22 +00:00
MSedgeMC dc468324cf test: a container write on a shelf posts block_activate
`a_container_writing_a_shelf_posts_block_activate` puts a shelf and its
block entity and runs the container-change path on its view: the posted
events hold `("minecraft:block_activate", [2.5, 4.5, 3.5], None)`.
Without the post the same test fails with `Some([])`.
2026-11-10 20:58:22 +00:00
MSedgeMC bffad5a2aa fix: a container writing a shelf's slot posts block_activate
`ShelfBlockEntity.setChanged()` is overridden without arguments and
passes `GameEvent.BLOCK_ACTIVATE` to `setChanged(Holder)`, so any
container write to a shelf (a hopper's or dropper's `setItem`) posts a
block activation at the shelf and syncs it to clients.

Horizon's `Game::view_changed` marked the shelf's chunk and re-read the
comparator, but posted no game event.
2026-11-10 20:58:07 +00:00
SenjinandClaude Opus 5.5 db8460de3a Shader packs: horizonDynamicLight* and horizonWaterPatch* are vec4s to the type check: a pack declaring them read 0
The type check (beb30045fd) keeps a built-in only as the type it gives, sized by components(), which
took Horizon's own vec4s for floats: a pack declaring horizonDynamicLight<n> (#12069) or
horizonWaterPatch<n> as vec4 had them switched off and read 0. Their counts stay ints.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 21:14:59 +01:00
MSedgeMC a1b69de138 port_map: BookViewScreen's link cursor 2026-11-10 19:25:47 +00:00
MSedgeMC 857436a4f7 test: only a clickable book word asks for the hand
An open_url click does; a show_text hover alone leaves the arrow, though it
still shows its tooltip.
2026-11-10 19:25:47 +00:00
MSedgeMC dd415f3cf3 fix: a link in a book asks for the pointing hand
BookViewScreen.extractRenderState visits its pages with
HoveredTextEffects.TOOLTIP_AND_CURSOR, which shows the hover tooltip and
requests the pointing hand over a clickable word. Horizon showed the tooltip
but never asked for the cursor, so links looked like plain text.
2026-11-10 19:25:23 +00:00
MSedgeMC 2f0107b159 port_map: SuggestionsList's pointing hand 2026-11-10 19:24:49 +00:00
MSedgeMC 93831bb101 test: the suggestion list asks for the pointing hand
Inside its rectangle the draw requests Cursor::PointingHand; one pixel left of
it the cursor stays the arrow.
2026-11-10 19:24:49 +00:00
MSedgeMC 404c0d8314 fix: the command suggestions ask for the pointing hand
CommandSuggestions.SuggestionsList.extractRenderState ends with
rect.contains(mouseX, mouseY) -> requestCursor(CursorTypes.POINTING_HAND).
Horizon's draw_list highlighted the hovered row but requested no cursor, so
the pointer stayed an arrow over the suggestion list.
2026-11-10 19:24:27 +00:00
DUzzL 159615221a fix(furnace): saturate idle cooking timer at a negative total instead of panicking 2026-11-10 20:15:26 +01:00
RLC248 4f5fa9c7ed A sulfur cube grows to size 2 only when it grows up (#14916)
The cube's AI step grew every adult of size 1 to size 2 on its second
tick, and shrank every baby above size 1 back to size 1. Vanilla's
SulfurCube.ageBoundaryReached runs only where AgeableMob.setAge crosses
the baby boundary, which finalize::set_age already does. The tick check
is gone: a summoned or loaded adult of size 1 and a split baby of size 2
keep their size.

command_exec gets a "sulfur cube size ticks" group with the cubes' AI on.
2026-11-10 20:14:20 +01:00
DUzzL 50f9032a65 fix(shulker): ease the lid with Mth.clamp semantics so a negative Peek cannot panic 2026-11-10 20:10:44 +01:00
Web-mc1 511921359c Fix div_ceil(0) when HORIZON_FAR_TILES=0 2026-11-11 07:49:33 +13:00
masa-masa-0314andZCode 1158ed5a3a Realms: finish world extraction after cancellation
Match vanilla's cancellation boundary once a world archive starts extracting, and cover cancellation on both sides of that boundary.

Co-Authored-By: ZCode <[email protected]>
2026-11-11 02:44:22 +08:00
MSedgeMC 3eaaf3070c port_map: map FoxSleepGoal.canUse movement inputs 2026-11-10 18:42:21 +00:00
MSedgeMC 74abfb6fa7 test: a fox with movement input does not sleep 2026-11-10 18:42:21 +00:00
MSedgeMC 0990beeb2b fix: a fox needs no movement input to sleep 2026-11-10 18:42:06 +00:00
MSedgeMC 4a73bc0b34 port_map: map OwnerHurtByTargetGoal.canUse target validity 2026-11-10 18:41:04 +00:00
MSedgeMC e631c28418 test: a pet does not avenge a dead attacker 2026-11-10 18:41:04 +00:00
MSedgeMC 4871665711 fix: a pet does not avenge a dead attacker 2026-11-10 18:40:49 +00:00
DetimesandClaude Opus 5.5 594e8622a7 Merge upstream/main into perf-mob-near
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:29:34 +00:00
DetimesandClaude Opus 5.5 ea212b9152 Merge upstream/main into perf-piston-index
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:25:11 +00:00
DUzzL a0677225dd Phantom: empty look control keeps the pitch its move control sets 2026-11-10 18:31:53 +01:00
MSedgeMC 7950bfbc6f port map: the lectern's Take Book row names the build gate it already has 2026-11-10 17:29:49 +00:00
DetimesandClaude Opus 5.5 c9cd62bd5b Keep game.rs from growing: the tick-view gate lives with the criteria helpers
game.rs is over the 3000-line limit, so the gate and its test move into
native_criteria.rs, which already builds the player's advancement views,
instead of a new module declared in game.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 17:21:28 +00:00
Florian ErnstandClaude Opus 5.5 1aec392a34 Move the allay jukebox harness to its own pull request
The fix and its Rust tests stay here (about 350 lines). The harness,
its transcript and the VanillaProtocolClient mode follow in a stacked
pull request, as asked in review.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 18:01:44 +01:00
arrteandClaude Opus 5.5 dd81778073 Keep vanilla's chunk count on Full Detail Distance
With LOD Distance on, the Render Distance row is relabelled "Full Detail
Distance", and its value came from Horizon's own "%s chunks" key instead
of vanilla's options.chunks, so "12 Chunks" turned into "12 chunks".
Format the value with options.chunks, as the vanilla row does.

Fixes #14880

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 19:57:51 +03:00
Nikita Maximilian GrimmandClaude Fable 5.2 1c93067327 HDR settings: the Calibrate row no longer says Windows' peak is too high
On Windows the HDR page's Calibrate row said "Your display's reported peak is
often too high: calibrate to set it up." That is wrong for a player who ran
Windows HDR Calibration: its profile overrides the display's own figure in the
`MaxLuminance` DXGI reports, so their good value was called too high. Horizon
can't tell which source the number has, and nothing says reported peaks are
too high in general.

The row now reads "Auto uses the peak Windows reports. Calibrate if highlights
look clipped.", true for either source. A new test keeps the row's four
descriptions within the description strip's two lines at vanilla's 427-wide
GUI, where a longer sentence would be cut off.

Nothing else changes: the setup states, the button labels and calibration are
as before. The comments and docs/graphics.md say what is known of the figure.
The old sentence was in en_us.json only.

Part of #12989.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-10 17:52:40 +01:00
MSedgeMC b47d5104da port_map: RecipeBookComponent's narrow Escape 2026-11-10 16:43:14 +00:00
MSedgeMC 6395c90680 test: Escape closes only a narrow book, not its menu
Under NARROW with the book open it takes the key; at NARROW, or with the book
closed, Escape belongs to the container.
2026-11-10 16:43:10 +00:00
MSedgeMC 43f69a7018 fix: Escape closes the recipe book, not the screen, when it covers one
RecipeBookComponent.keyPressed closes the book when Escape is pressed and
!isOffsetNextToMainGUI (a window narrower than NARROW, where the open book
covers the menu). Horizon's Escape arm went straight to close_container, so
pressing Escape threw away the inventory or crafting table instead of the
book.
2026-11-10 16:43:10 +00:00
DetimesandClaude Haiku 5.5 197aeeeea6 Test grid-backed raider and cat scans against the full scan
Companions, shout range and the cat bed check must give the same ids in
the same order with and without the mob grid. Two lattices of 300 and
200 mobs cover raiders within and beyond 8 and 16 blocks, and lying and
standing cats around four beds.

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
2026-11-10 16:42:15 +00:00
DetimesandClaude Haiku 5.5 6b7e237730 Scan only the mob grid for raider companions, shout range and cat bed check
companions and shout_range walked every mob to find raiders in a box
around the patrolling raider; the cat's occupied check walked every mob
for cats lying beside a bed. Use others_near with the box each already
computes, one block wider so every accepted mob's bounds meet it. The
filters are unchanged, so results and their order stay the same.

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
2026-11-10 16:41:43 +00:00
DetimesandClaude b6fc1844be Test: the advancement tick view is gated by the trigger's listeners
Covers view_needed: before the advancements load the view is built as
before; after that it is built only for a trigger with a listener in
tree.by_trigger, the same lookup trigger() makes. Needs no resources.

Co-Authored-By: Claude <[email protected]>
2026-11-10 16:41:32 +00:00
DetimesandClaude 9fcdc6481d Perf: skip the advancement tick view when no criterion listens
tick_advancements built the player's EntityView every tick (and every
20 ticks for minecraft:location) even when no loaded criterion listens
to that trigger, and then dropped it. The view is now built only when
the trigger's own lookup (tree.by_trigger) finds listeners, or before
the advancements are loaded, when the tree is not read yet, so the
view is built exactly as before. load_advancements and the early outs
of trigger keep their order.

Co-Authored-By: Claude <[email protected]>
2026-11-10 16:41:28 +00:00
DetimesandClaude 5df2749ffe Test: moving-piston index matches the linear scan
Adds a unit test in level/ticks.rs that loads, replaces and removes
moving pistons, then edits the Vec from outside the index (a direct
push and a reversed Vec), and checks that every lookup equals the old
linear scan, that tickers stay in ascending id order, and that
`moving_piston_has` accepts only the current id.

Co-Authored-By: Claude <[email protected]>
2026-11-10 16:41:16 +00:00
DetimesandClaude 8e942033a2 Perf: index moving-piston tickers by position (no O(N^2) scans per tick)
Ticking moving pistons ran an `any` scan over every moving piston for
each ticker, and the lookups, progress updates and set/remove went
through linear `find`/`retain`/`position`, so a tick with N moving
pistons cost about N^2 comparisons.

`Ticks` now keeps a position-to-id hint next to `moving_pistons`. The
hint is only trusted when the slot it leads to really holds the position
(the Vec is sorted by ascending id, so the slot is a binary search), and
it falls back to the old scan otherwise, so a Vec edited from outside
still gives the same answer. The Vec, its order, and every observable
result are unchanged.

Co-Authored-By: Claude <[email protected]>
2026-11-10 16:41:14 +00:00
lucasmts59andClaude Opus 5.5 da83875ae3 Hit dialog body text where its glyphs are drawn
ActiveTextCollector.ClickableStyleFinder tests each glyph's own area
(BakedSheetGlyph.GlyphInstance: its top and bottom, the shadow, bold
thickness and italic shear), so a glyph a font's ascent moves off its
line is clicked where it is drawn; only empty glyphs cover the line's 9
pixels. The last area with a click event wins, not the first character
under the cursor.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 17:10:22 +01:00
levzzz 1405044d09 Merge pull request #14569 from Ragbie/fix/discord-socket-test-cfg
horizon-discord: #[cfg(unix)] back on the socket test, so the tests build on Windows
2026-11-10 21:01:13 +05:00
KBroneZandClaude Opus 5.5 be247b49e0 fix: leak each distinct block registry once in Assets::load
Assets::load leaked a new BlockRegistry (about 11 MB for vanilla) on every
call. The game calls it once, but tests call it once per fixture, and none
of it is freed. Keep the &'static contract and intern by content: equal
registries (same states in order, same defaults) share one leaked copy.

Closes #14503

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 18:00:24 +02:00
foudretbr 55509cffaf perf(shaders): unroll boat water patch checks to avoid local array spilling (#14782) 2026-11-10 16:32:04 +01:00
tarzinio 58d23e2654 Keep answers of earlier particle culling jobs 2026-11-10 14:25:11 +01:00
foudretbr fc51d09b64 fix(cosmetics): permit 60 fps drag pictures while animating items in viewer (#14656) 2026-11-10 13:11:08 +01:00
DUzzL b782c0d9b5 Run AbstractCubeMob.tick for NoAI slimes, magma cubes and sulfur cubes 2026-11-10 12:56:20 +01:00
NongTham 4264ba0c4e use_item: do not swing non-swappable equippables on empty target (fixes #14748) 2026-11-10 18:50:05 +07:00
DUzzL 36fb430fbc Reset a minecart's pitch to 0 every tick like OldMinecartBehavior 2026-11-10 12:30:47 +01:00
zenith600andClaude Opus 5.5 0ea7d1707c Launcher: hidden_processes test names horizon-prepare through horizon-platform
The Windows-only test still built `horizon_prepare::java::Jdk`, but since
92886cd964 the launcher reaches that crate only through horizon-platform's
`prepare` feature, so `cargo clippy --all-targets` in launcher/ failed on
Windows (Linux CI never compiles this file). Use
`horizon_platform::prepare::java::Jdk`, as backend.rs does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 16:55:46 +05:30
Ludovic Marie 8933e8c2d8 Merge pull request #14741 from horizon-rust/governance/elections-tiers
🤖 Agent: founder-imposed governance amendment of 2026-11-10 (Ludovic's go, ~09:55Z): three kinds of action, elections by tier, the permissions table checked against the live Discord, panel, ops list and GitHub, and P41 with a 24 h veto; docs only, verify green on this head. The council can overturn it by poll.
2026-11-10 11:25:36 +01:00
Horizon agentandClaude Opus 5.5 226a707d0d GOVERNANCE.md: three kinds of action, elections by tier, who may do what, P41
Founder-imposed amendment of 2026-11-10. Free actions need no vote; a role
with power or a body's own decision is a 24-hour body vote with a 24-hour
council veto; everything else stays a council poll (48 h only for this
document, the licence, money and org ownership). Adds the election table,
its hard cases and Kira's reminders (5.3), the permissions table checked
against the live Discord, panel, ops list and GitHub teams (2.2), and P41:
the mods decide moderation matters, council veto 24 h (section 7).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 11:06:36 +01:00
Mayonesu0andClaude Sonnet 5.5 acd16c9ae8 A firework rocket draws its UUID, motion, lifetime and shot from one random
FireworkRocketItem.asProjectile builds the rocket from Entity.random, then
Projectile.spawnProjectileUsingShoot spreads the shot with that same random.
Horizon made a second random for the constructor's draws, so a dispensed or
crossbow rocket's LifeTime (and the shot's spread) were out of step.

new_rocket now takes the rocket's own random and draws the UUID, motion and
lifetime from it in vanilla's order; launch_angled_rocket makes it and hands it
to the shot. seed_sync gets a dispensed-rocket case (UUID, LifeTime, Motion).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 02:07:51 +01:00
Evrim CicekandClaude Opus 5.5 5f0ea2347a horizon-discord: #[cfg(unix)] back on the socket test
Fixes #14568: 62e62c21e6 put the switch test between the socket test's
attribute and the test, so the socket test (std::os::unix) compiled on
Windows and the switch test ran only on unix.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 03:13:09 +03:00
Nikita Maximilian GrimmandClaude Opus 5.5 5c6f115aa6 Thrown projectiles and llama spit turn a fifth of the way towards their motion each tick
ThrowableProjectile.tick and LlamaSpit.tick call Projectile.updateRotation,
which lerps pitch and yaw 20% from the tick's old rotation towards the
motion's direction (the spit with its motion before drag and gravity).
Horizon set the target angles at once.

Fixes #14544

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 01:09:21 +01:00
MSedgeMC 9e7fc5f18c Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-09 23:41:45 +00:00
Horizon agentandClaude Opus 5.5 5499eea24e docs/operations.md: running Horizon by hand without agents or the maintainer's machines
A runbook for the Horizon Team and the council: who can do what today, merging by hand, the Nightly
and how to stop or roll it back, what waits when the self-hosted runners are offline and what to do,
the Mojang republish issue, the mods registry (form pull requests, forks, takedowns), Kira, the
official server and security incidents. It points at release-flow.md, ci-runners.md, security.md
and GOVERNANCE.md for the details instead of repeating them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 00:06:25 +01:00
DUzzL 6a4ea1890f Minecart: resting cart on a powered rail reads the block's redstone-conductor flag (soul sand) 2026-11-10 00:02:53 +01:00
Horizon agentandClaude Opus 5.5 9dc8b78a8f runner watch: one issue when jobs wait for self-hosted runners that are offline
Jobs that need the maintainer's machines (bots on horizon-light, parity, parity-main, the Mac's
Nightly and launcher builds) wait up to a day when those machines are off, then fail, and nothing
says why. Every 30 minutes, on GitHub's runners, tools/ci/runner_watch.py lists the jobs of queued and
running runs that wait for a self-hosted runner; when one has waited over 2 hours in a pool where no
job runs at all, it opens one issue naming the pools, what stops and what to do, keeps it current,
and closes it once nothing waits. Only the workflow token (actions: read, issues: write).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 00:00:38 +01:00
0xRinne 20d37726a2 Merge branch 'main' into up12/cem-legacy-textures 2026-11-09 16:16:51 -06:00
0xRinne a95c4653f4 Merge branch 'main' into up12/cem-this 2026-11-09 16:16:48 -06:00
2d79ae6ace Armadillo: a baby follows only adults its sensor's box meets
NearestLivingEntitySensor.doTick finds the living entities whose boxes meet the
baby's box inflated by its FOLLOW_RANGE (16). BabyFollowAdult took every adult
others_near returned, which only narrows to grid cells and, when the grid falls
back to a scan, returns every mob: the baby checked line of sight to, and could
follow, adults anywhere in the level. Found by kacpherek's verify mode in #13644.

Co-Authored-By: Kacper <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 22:13:48 +00:00
AfrobamaYTandClaude Opus 5.5 dc2b1acd07 Parity: the java shim never starts itself, and stops with a reason if a wrapper loops it back
The oracle cache's shim started the real java with the PATH it got, its own folder first. A java
wrapper that looks java up on PATH (a JDK selector, a build wrapper) then found the shim again,
which found the wrapper again: every round left a python3 alive under Popen, past 5,000 on one
machine, until RAM and swap were full.

real() skips candidates that resolve to the shim; the real program gets PARITY_SHIM_REAL_PATH as
its PATH; and past 4 rounds inside itself (PARITY_SHIM_DEPTH) the shim exits with a reason.
Tests use a wrapper java that stops itself after a few rounds, so a looping shim fails them
rather than the machine: without the fix all three fail, with it they pass.

Fixes #14450

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 23:08:44 +01:00
Nikita Maximilian GrimmandClaude Fable 5.2 0f5b71e021 Merge remote-tracking branch 'origin/main' into prepared-data-stale-tests
piston_push.rs: main's version (#13655 made the same correction to the order test's expected push).

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-09 22:48:56 +01:00
JoansitohandClaude Sonnet 5.5 2a4d90b9cf Launcher: the Version menu's state lives beside the picker, not in main.rs
main.rs is over 3000 lines and may not grow (docs/layers.md#file-size): the menu's
open flag, scroll and the window height it was opened at become one PickerState in
glass_home.rs, replacing picker_open.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 22:39:48 +01:00
DUzzL 22bfb7d055 Sweep a cart's blocks right after its move, like AbstractMinecart.move
OldMinecartBehavior's AbstractMinecart.move calls
applyEffectsFromBlocks(position(), position()) straight after the move,
while the cart is still at the rail's top height, and again at the end of
the tick. The port swept once at the settled position, so a cart held by a
fence on a slope never reached the pressure plate above it again.
2026-11-09 22:23:28 +01:00
JoansitohandClaude Sonnet 5.5 80687d32d1 Launcher: the Home version picker scrolls its list
The Version menu capped its height to the room under the button and its
rows scroll (with the launcher's scrollbar and take_wheel); Manage
versions stays pinned below them.

Fixes #14407

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 21:59:51 +01:00
DUzzL 82d1afa019 Merge remote-tracking branch 'origin/fix/14297-hopper-decorated-pot' into fix/14297-hopper-decorated-pot 2026-11-09 21:59:29 +01:00
DUzzL c7c8dd8f82 Merge upstream/main into fix/14297-hopper-decorated-pot 2026-11-09 21:59:16 +01:00
rdbtCVSandClaude Sonnet 5.5 74a38d29aa Keep ai.rs the same size: inline the one-use creature local
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 20:53:49 +00:00
rdbtCVSandClaude Sonnet 5.5 e10e2144bd Merge main into fix/14297-hopper-decorated-pot
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 20:49:39 +00:00
CxandClaude b13153310c chat: fix touchpad scrolling in command suggestions
Round the suggestion list scroll offset instead of truncating f64->i32.
Fractional touchpad/PixelDelta wheel deltas were dropped in one
direction, so command hints scrolled down but not up.

Fixes #14338

Co-Authored-By: Claude <[email protected]>
2026-11-09 23:45:47 +03:00
MSedgeMC 3a642c76c0 port_map: AbstractSelectionList.scrollBarX 2026-11-09 20:45:37 +00:00
MSedgeMC 79551ef835 test: the social list's scrollbar sits right of the rows
Its column, from getRowRight + scrollbarWidth + 2 at two window widths, and
the overflow rule that decides whether it shows.
2026-11-09 20:45:37 +00:00
MSedgeMC d4a019ad08 fix: the social player list shows its scrollbar
AbstractSelectionList.extractRenderState draws its scrollbar at scrollBarX
(getRowRight + scrollbarWidth + 2) whenever the rows overflow. Horizon clipped
and scrolled the rows but drew no bar, so a long list gave no sign of its
position.
2026-11-09 20:45:37 +00:00
MSedgeMC a794f852d7 port_map: CreativeModeInventoryScreen.checkTabHovering is ported 2026-11-09 20:45:23 +00:00
MSedgeMC ff65ebc7fc test: only a tab's inset icon is hovered
The cursor on the 3 pixel border of a tab's slot is outside checkTabHovering's
21x27 icon rectangle; inside it the tab is the hovered one.
2026-11-09 20:45:23 +00:00
MSedgeMC 6767556a09 fix: the creative inventory's tabs say their name on hover
CreativeModeInventoryScreen.checkTabHovering shows a tab's display name when
the cursor is on its icon (3 pixels in from the 26x32 slot). Horizon drew the
tabs without the tooltip, so a tab's name only appeared in a search result's
tooltip.
2026-11-09 20:45:23 +00:00
valuttaandClaude Fable 5.2 0a079cdd75 Server tick: boats and minecarts read the mobs near them, not every mob
Each vehicle walked every mob of the level several times a tick:

- Its box queries (`cart_entity_collisions`, `boat_obstacles`,
  `boat_contacts`) now go through `mobs_meeting`, which reads the mobs
  near the box from a grid of the mobs' boxes. The first query of a
  stretch of vehicle ticks builds it, and the boats' grid serves the
  carts ticking right after them.
- Mob pushes on carts ask that grid once per cart when the mobs
  outnumber the carts, instead of asking the carts' grid once per mob.
- Who rides a vehicle (`boat_mobs`, asked by every boat, and
  `cart_is_vehicle`, asked by every cart on rails) comes from one walk
  per stretch.

The grid and the riders hold until the mob list's next write (its
`ListStamp`): after one, the walk answers until it has cost about as
much as a new grid. With native mods loaded the walks stay. The mobs
found and their order are those of the walk. Refs #14321.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-09 21:40:42 +01:00
YourPancakes fd70c6aa11 perf(render): reuse native sprite frames when sizes match 2026-11-09 23:36:19 +03:00
c451a0eca5 Streaming: the forced chunks' code in a module of its own, with a test
ForcedCover and the Ranges methods that read the forced chunks (is_forced,
forced_chunks, and in_main_range past the player's circle) go to
streaming/forced_cover.rs, so streaming.rs doesn't grow. A test checks that the
distance map answers every margin as the scan of every forced chunk does, as the
forced chunks change, and that past MOST no map is made.

Co-Authored-By: Kacper <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:31:15 +00:00
dc2a951126 Streaming: forced chunks by distance, one lookup per column instead of a scan
in_main_range went through every forced chunk for each column it was asked about, several times
a tick. While update runs (nothing can change the forced chunks then, as for the extra areas'
cover), a map of each column's distance to the nearest forced chunk answers instead; it is made
again when they change. The same answers. Streaming on the bench: 6.2 ms a tick to 4.7.

Co-Authored-By: Kacper <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:31:14 +00:00
AkirabaneandClaude Sonnet 5.5 722f39f691 Skin loader: bound its downloads, states and resources
`Downloader` kept an entry in `states` for every texture URL asked for and
started a thread (with its own HTTP agent) for each, and an entry in
`resources` for every `texture` id of a profile, none ever dropped. A tab list
of 200 players started about 400 threads at once.

At most 16 downloads now run at once: past that `state` answers `Pending`
without recording the key, and the caller, which asks every frame, starts it
when one has finished. `states` and `resources` keep at most 4096 entries each,
dropping the oldest settled one first (a running download is never dropped); a
dropped texture loads again from the disk cache or the pack. A download thread
that panics before answering now answers "failed" through a drop guard, so its
place is given back and the texture is retried after the wait. The fetch
function and the bounds are fields so a test can set them.

Part of #9370.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 21:23:04 +01:00
JoansitohandClaude Sonnet 5.5 218dc467c4 Fix /rotate relative angles to use the command source's rotation
Fixes #14369

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 21:21:33 +01:00
valuttaandClaude Opus 5.5 a79f9c81ba Server tick: skip the per-mob work of idle vehicle and portal passes
- Vehicles: the mobs' BoxReach is worked out by the first box query of a
  stretch of vehicle ticks, not before it, so a level without boats walks
  no mobs for it. Mob pushes on carts work out the box only of the mobs the
  carts' grid puts near a cart.
- Entity portals: the sweep walk stops at once when no loaded section has
  a portal state in its palette, and the pass before it no longer works
  out every mob's size to learn that mobs use portals.

Behaviour is unchanged: each skip turns away only what the full test
would have turned away. Refs #14221.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 21:00:55 +01:00
martipopsandClaude Opus 5.5 222d449920 A pack card's options button is a pad target
The pack screen is a pointer screen: a pad moves a crosshair, which
glides onto the nearest control and hops between controls with the
D-pad. A card's options button registered no target, so the crosshair
never settled on it and the D-pad couldn't reach it; only aiming the
stick by hand opened Pack Options. It registers one now, clipped to the
list's view like its rows.

pad_menu_e2e: pack_card_options, with a fixture pack that has Polytone
config entries, hops onto the button with the D-pad, opens Pack Options
with A and comes back with B.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 14:59:16 -05:00
voduffandClaude Opus 5.5 bd35a34d53 port map: client-005 and client-007 render rows the player can see now
Rows whose 'gap: player:' notes describe things Horizon draws today.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:56:00 +00:00
voduffandClaude Opus 5.5 d6020c9fcc port map: block entity cracks on bells, conduits, lecterns, shulker boxes, skulls
render_breaking redraws these Kinds with the crack overlay now; drop the stale gaps.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:56:00 +00:00
voduffandClaude Opus 5.5 bc3893a448 port map: client-003 render rows the player can see now
Rows whose 'gap: player:' notes describe things Horizon draws today.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:55:46 +00:00
voduffandClaude Opus 5.5 79f8b1c258 port map: client-004 render rows the player can see now
Rows whose 'gap: player:' notes describe things Horizon draws today.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:55:46 +00:00
Evan-lhmandClaude Opus 5.5 46b6725e3e gfx.rs: the chunk fade-in helpers move to gfx/section_fade.rs
A pure move (docs/layers.md#file-size): section_instance, chunk_visibility, fade_millis, section_visibility,
FadeInputs and CHUNK_VISIBILITY_THRESHOLD, unchanged but for pub(super). gfx.rs re-imports them, so callers keep
their paths. It makes room for #13780 step 2's few lines of ray tracing glue.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:53:10 +01:00
MSedgeMC b529106e69 port_map: the test block own signal reads the state mode 2026-11-09 19:51:47 +00:00
MSedgeMC 78bfaf571a test: a test block state mode gates the powered entity 2026-11-09 19:51:47 +00:00
MSedgeMC 30e60a02ec fix: a test block reads the block state mode for its own signal 2026-11-09 19:51:47 +00:00
voduffandClaude Opus 5.5 7ae38e4441 port map: the chest minecart save rows follow Minecart's to_nbt/from_nbt to minecarts/nbt.rs
Part of #14327

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:49:54 +00:00
voduff 469ca1d618 Merge remote-tracking branch 'origin/main' into map-moved-fns 2026-11-09 19:49:26 +00:00
Cx188andCodex d18ae680a9 Drop unreachable graphics-stage GLSL helpers (#8406)
Retain calls from each stage main and global declarations, including
referenced overloads and prototypes. Blank unreachable function spans
without moving diagnostic lines. Cover the public translation APIs and
post loader with independent positive and negative GLSL regressions.

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-09 22:49:03 +03:00
DUzzL 51e490f6f9 Let hoppers pull items out of a decorated pot 2026-11-09 20:48:36 +01:00
Vinícius MoreiraandClaude Opus 5.5 cc900e6c6e Polytone: r.noise is 1.21's PerlinSimplexNoise, seed 0, octave 1
r.noise read a single simplex noise of seed 0, which is octave 0 of
Polytone's noise: the mod builds 1.18-1.21's PerlinSimplexNoise (gone
from 26.3) with octaves {1}, whose noise comes from a random seeded by
the first noise's value at its own offsets, read at twice the input with
the noise offsets. So grass and leaves a pack tints with the noise took
other shades than on Java.

Measured on the real mod with a probe pack whose grass colormap encodes
the noise (column the coarse value, row the fine part, calibrated with
ramps): r.noise(x/8, z/8) at 1527 blocks matches this construction to
the decoding's 0.016, and so do polytone/noises definitions with seed 0
or 1 and octaves {0}, {1} and {0, 1} (their weights a third and two
thirds). SimplexNoise gains get2_f64/get3_f64: the mod sums and seeds in
double.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:48:00 -03:00
voduffandClaude Opus 5.5 6b6d099127 port map: the particle limit and anvil rename box rows name their fns
ParticleEngine::create moved to particles/spawn.rs, and the anvil's
50-character unbordered rename box is built by stations/anvil.rs's
rename_box; both rows named fns map-check couldn't find.

Part of #14327

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:45:23 +00:00
AkirabaneandClaude Opus 5.5 a05066c560 Cosmetics: the own model on an offline-mode server is asked by the signed-in account
Fixes #12820, the part kept open there: the player's own model. An
offline-mode server names the player by nameUUIDFromBytes of their name (a
version 3 UUID), which the cosmetics service doesn't know, so their own
cosmetics, name tag style and Horizon's mark were missing. own_uuid now asks
by the signed-in account's UUID for such a profile, as In single player does
for the window's own server; an online profile is kept, and signed out the
profile is kept as before.

No new setting: an offline server behaves as an online one, under My
cosmetics. Other players are still asked by the UUID the server gives them,
since on an offline server anyone can take any name. The service receives
the account's UUID only, no name.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:45:06 +01:00
DUzzL 59b4f00696 Share the host's push-and-hurt path 2026-11-09 20:44:59 +01:00
voduffandClaude Opus 5.5 8e8f6ff6fd port map: rows follow fns split out of remote.rs, game.rs, potions.rs, creatures.rs and entity_nbt.rs
Thirteen rows named a fn at the file it was split out of (poll_remote,
tick_world_icon, potion_cloud_to_nbt, the spawn egg fns, the hanging
and trident loaders), so map-check reported "no fn". They now name the
file that holds each fn.

Part of #14327

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:42:13 +00:00
voduffandClaude Opus 5.5 e916d5a66c port map: rows follow mob_geometry to game/creatures/mob_geometry.rs
Game::mob_geometry moved out of game/creatures.rs; 16 rows in
client-003/004/005/007 and client2-002 still named the old file, so
map-check reported "no fn". They now name the new file.

Part of #14327

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:39:25 +00:00
yahya-nusseibehandClaude Opus 5.5 1d7c9f150e Telemetry server: claim a bug report before filing its issue
The duplicate check in enqueue stops a double click from filing twice
only once the first issue is recorded. While the first request still
waited on GitHub (up to 20 s) the row's issue was empty, so a second
identical request found the row, got no issue back and filed a second
issue for it; the 10-minute upkeep (file_pending) could likewise file a
report a request was filing at that moment.

Store::claim moves a report from 'new' to 'filing' for exactly one
caller, and Store::release gives it back when GitHub refuses. report()
and file_pending() both claim first; a request that loses the claim is
answered 202 with no issue yet, as when GitHub fails. A report left
'filing' by a server that stopped mid-filing goes back to 'new' when it
starts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-09 21:34:15 +02:00
AkirabaneandClaude Sonnet 5.5 fdd5bac5a8 Pack screen: send each pack icon to the atlas once, not every frame
bind_icons gave every icon drawn in a frame a slot in drawing order and pushed
its 128 by 128 RGBA pixels (64 KiB) every frame, into an atlas that keeps what
it is given. The screen now keeps which slot holds which icon from one frame to
the next: an icon is sent when it takes a slot, the slot of the icon drawn
longest ago is taken when all are in use, changed pixels under the same key are
sent into the same slot, and a new atlas (another revision) empties the table.

Icons past the 64 slots in a single frame still keep unknown_pack, as before.

Part of #9370.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 20:34:12 +01:00
AkirabaneandClaude Opus 5.5 1d60510ba1 Fullbright is held off on another's server; single player and a LAN host keep it
Fixes #3823, as decided there. Fullbright was already off by default; on a
connection that plays no save of the window's own (Remote::save_folder is
None: a multiplayer server, or another player's LAN world) the renderer now
draws the normal lightmap and the graphics screen hides the row. Single
player, a LAN host, a splitscreen seat in P1's world and Replay Studio keep
it, and the saved choice is never changed.

The flag is applied with the settings and again each tick, since joining a
server sets the connection after the settings were applied. The row reads
Settings::fullbright_held, which each game sets for itself (not saved), so a
splitscreen seat does not share it through a process-wide flag.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:33:22 +01:00
yahya-nusseibehandClaude Opus 5.5 3e9ac65dba Text: append siblings in place instead of cloning the tree each time
Text::push (MutableComponent.append) cloned the whole JSON component
built so far on every append, then added one sibling, so N siblings
cost N^2. to_text_with pushes once per "extra" entry, and a server's
chat, title or server-list MOTD can carry hundreds of thousands: 20 000
siblings (80 KB) took 76 s on the packet-handling thread.

push now takes the tree out of its Arc and appends in place
(Arc::make_mut, so a Text cloned earlier keeps its own tree). A styled
text is still wrapped once first, including one built from a single
styled span with no saved tree, so its style doesn't reach what is
appended. The JSON produced is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-09 21:31:14 +02:00
Vinícius MoreiraandClaude Opus 5.5 820af79e89 A gliding mob caps its fall at 1 unless it dives
LivingEntity.updateFallFlying starts with checkFallDistanceAccumulation:
a glide whose vertical speed is above -0.5 keeps at most 1 of its fall.
Horizon's update_fall_flying left it out, so a gliding mob kept its whole
fall. The glide code moves to mobs/ai_glide.rs, keeping ai.rs from
growing.

Fixes #13868

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:30:46 -03:00
Nikita Maximilian GrimmandClaude Opus 5.5 c1cf271fbb Layer and address checks read the base's lists at the branch's merge base
Fixes #14317. A local --base origin/main read the lists at main's tip, so entries main dropped after the branch left it counted as the branch's additions. check_file_size.py already compares with the merge base; CI's HEAD^1 is its own merge base.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:29:16 +01:00
yahya-nusseibehandClaude Opus 5.5 96665e3a8e Text: bound what one translation's fallback template expands to
A translatable component a server sends ({"translate", "fallback",
"with"}) falls back to the server's own template when the language has
no such key, and format_translation copied the argument's spans for
every %1$s in it. With the argument being another such component the
copies multiply: 300 references nested three deep is a 4 KB server-list
MOTD or chat message that becomes 27 million spans, and the client ran
out of memory. Vanilla's TranslatableContents.decompose keeps references
to its arguments, so its cost stays linear.

format_translation now shows the template verbatim, as it already does
for a malformed one, when the expansion would pass 32768 spans or 4 MiB
of text. Real translations are nowhere near either.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-09 21:28:56 +02:00
DUzzL 98ff540d65 Resolve a creative tab name given as a translation key 2026-11-09 20:27:25 +01:00
voduffandClaude Opus 5.5 9d5f89a756 Scoreboard: /team and /scoreboard name holders and teams with vanilla's hover and insertion (#14279)
ScoreHolder.getFeedbackDisplayName replaces the display name's hover
with ShowText of the scoreboard name (an entity's UUID, a player's
name); /team and /scoreboard replies name holders with it. Horizon used
the plain display name. PlayerTeam's displayNameStyle also inserts the
team's name, which Horizon's formatted team name lacked.

test_mob_display_name_parity.py compares the /team and /scoreboard
replies too (10/10; without this commit 5 differ).

Fixes #14279

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:23:28 +00:00
Nikita Maximilian GrimmandClaude Opus 5.5 f881b09c67 Merge remote-tracking branch 'origin/main' into prepared-data-stale-tests
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:23:27 +01:00
DUzzL 624b34b3f9 Charge the host's armour for a dragon wing hit 2026-11-09 20:19:49 +01:00
MSedgeMC 352a141050 port_map: the fish's despawn rule names its port
AbstractFish.removeWhenFarAway and Axolotl.removeWhenFarAway had no row, so
the ledger read the whole pair as unported; named_keeps_it carries it now.
2026-11-09 19:18:10 +00:00
MSedgeMC f6fbdbeebc Test the named fish that stays
Mob::despawn_check on a cod, a salmon and an axolotl 200 blocks from the
player: each with a custom name stays and each without one is removed, and a
zombie's name saves it nothing. Without the name half of removeWhenFarAway the
named cod is discarded like the unnamed one.
2026-11-09 19:18:10 +00:00
MSedgeMC f52894e0dc A named fish or axolotl never despawns
AbstractFish.removeWhenFarAway and Axolotl.removeWhenFarAway answer
`!fromBucket() && !hasCustomName()`, so a cod a player has named stays put
however far it swims, and one let out of a bucket is persistent before it.
Only the bucket half was ported: from_bucket is in
requires_custom_persistence, but check_despawn's removable flag knew nothing
of the name, so a name tag bought a fish nothing and it was discarded past the
128-block despawn distance like any other water mob.

named_keeps_it answers the name half for the fish and the axolotl, and
removable consults it beside the feline rule: a named cod, salmon, tropical
fish, pufferfish, tadpole or axolotl is never removed for distance, and no
other mob's name is persistence.
2026-11-09 19:18:10 +00:00
AkirabaneandClaude Sonnet 5.5 0fb13fa3c8 jmath: Math.log on Windows rounds like HotSpot's x86-64 stub, as sin and cos do
Windows' libm rounds log differently from Java's x86-64 intrinsic for about
0.1% of arguments (299 in 200,000 polar-method radii, #5064 point 1), and
Gaussian draws, density functions and a few block behaviours call it. The
platform's ln is replaced there by exact::log, in the module that already does
this for sin and cos: x split into a power of two and a mantissa in
[sqrt(1/2), sqrt(2)), ln m as 2 atanh((m - 1) / (m + 1)) in double-double with
a quick path checked against an error bound, and the full series with its
terms summed exactly where the bound leaves the rounding open.

exact::log is correctly rounded. HotSpot's stub is not quite (one ulp off for
about 0.002% of arguments), so this matches the JDK on all but those. On
374,043 arguments exact::log was never off from a 70-digit reference; Java's
Math.log was 7 times and the platform ln 331 times. The tests pin ten
arguments where Windows' ln and the JDK differ, the seven where HotSpot
itself is one ulp off, 28 arguments within 2^-65 of a half-way point, the
edges, and that both paths agree.

Part of #5064.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 20:10:34 +01:00
Vinícius MoreiraandClaude Opus 5.5 e5332ab63e command_exec: two boat groups wait for their entity-ticking neighbourhood
vehicle fall clip (#13005) and boat float height (#13022) waited for
their own forced chunk only. Since #13953 an entity ticks once every chunk
within 2 columns is loaded (ChunkMap.prepareEntityTickingChunk), so their
boats and minecarts fell only when an earlier group had left the
neighbours loaded: boat float height failed all 59 reads on main, and
vehicle fall clip failed whenever a group was added before it. Both now
wait for the 5x5 ring, as the other ticking groups do.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:05:58 -03:00
voduffandClaude Opus 5.5 909d83c284 Commands: a mob's name carries its show_entity hover and UUID insertion (#14220)
Entity.getDisplayName adds a HoverEvent.ShowEntity (type, UUID, name)
and the UUID string as insertion to the team-formatted name. Horizon's
command replies named a mob with the team formatting only, and /summon
without NBT with a bare type name.

mob_command_name (commands/display_names.rs) builds it; target_name's
mob arm and /summon use it. New harness test_mob_display_name_parity.py
(4/4; main: 0/4).

Fixes #14220

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:58:30 +00:00
itarqos5andClaude Sonnet 5.5 e104190b38 Merge main into particle threads again: both sides add a module line
#14115 moved send_particles and create out of particles.rs into spawn.rs,
and this branch had added `mod simple;` on the same line. Both modules stay.
cargo test -p horizon-client-render --lib particles: 59 passed, including
parallel_tick_matches_serial_tick.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 18:54:33 +00:00
Nikita Maximilian GrimmandClaude Opus 5.5 f67f4687a1 Prepared-data tests: give datapack fixtures a pack section, expect TNT's float box, run the gateway test as the console, read HORIZON_TEST_RESOURCES
Six #[ignore]d tests that need prepared vanilla data had gone stale or never
ran (#14261):

- two datapack fixtures lacked the pack section #11576 requires;
- the piston order test expected a push of 0.5, but #12231 gave primed TNT
  vanilla's float box (half width 0.98F / 2, widened);
- the end gateway age test ran /setblock at permission 0 without the command
  registries;
- index_on_vanilla_tree and the_humanoid_mesh_bakes_as_vanillas_zombie read
  HORIZON_RESOURCES and returned early without it.

Fixes #14261

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:52:53 +01:00
Nikita Maximilian Grimm 3e7b3fe7a1 Merge remote-tracking branch 'origin/main' into parity-windows-builds 2026-11-09 19:50:58 +01:00
itarqos5andClaude Sonnet 5.5 2caff14d10 Merge main into particle threads, and give each worker thread its own level
Conflict: particles/tick_bench.rs. Main (c221aa9ec9) adds `cached_chunk` to the
`ParticleLevel` literal of the old benchmark body; this branch had rewritten
that body into `with_scene` and `measure`. Kept this branch's structure and
added the field to the one literal.

That commit also makes the merge fail to build, which no textual conflict
shows: `ParticleLevel::cached_chunk` is a `Cell`, so a `ParticleLevel` is no
longer `Sync`, and `tick_all` shared `&ParticleLevel` between its scoped
threads (E0277). Each worker thread now takes a view of the same world with an
empty one-chunk cache of its own (`thread_view`, in simple.rs, so particles.rs
does not grow), built on the engine thread and moved into the thread. The
thread that runs the first share keeps the caller's level and its cache. A
cache shared between threads could not have held "the last chunk looked up"
for any one of them anyway.

cargo test -p horizon-client-render --lib particles: 59 passed, including
parallel_tick_matches_serial_tick. fmt, clippy on the particle files and
tools/check_file_size.py --base origin/main are clean.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 18:49:41 +00:00
SenjinandClaude Opus 5.5 dc59cbc710 Connected textures: a texture a rule matches keeps a layer of its own
Identical still textures share one layer (d874278161), and connected-texture rules find their
faces by layer. A rule matching one of them by name (matchTiles, connectTiles) then also took the
faces of the others. Since chained tiles resolve (#13707), a resource pack's rule over its own
transparent tile 19 (`matchTiles=./19.png`, drawing wheat stages) reached every grass block's top
layer, a placeholder with the same pixels, and put plants on the grass.

The rules are read before the models insert their textures, and every name they match keeps a
layer of its own (Sprites::keep_apart). Identical textures no rule names still share.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:45:18 +01:00
Cx188andCodex da831bab1d Add controlled chunk batch ACK rate selection (#8578)
Keep one acknowledgement per completed batch while optionally requesting
64 chunks per tick. Preserve vanilla estimation by default, record the
selected mode, and cover the framed receive path in both modes.

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-09 21:42:56 +03:00
Nikita Maximilian Grimm f25a5afc00 Merge remote-tracking branch 'origin/main' into record-start-copy 2026-11-09 18:53:46 +01:00
Nikita Maximilian Grimm cc03d693a2 Merge remote-tracking branch 'origin/main' into agent-e2e-windows 2026-11-09 18:53:36 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 d9ef984808 parity.py on Windows: record each harness's builds, and keep conflicting ones apart
The tracer read each command from the subprocess.Popen audit event, which on Windows carries the command line
Popen built, not the list: no harness's cargo build was recognised, so the probe prebuild built nothing and probes'
Rust sources never became dependencies. It now splits the line back (CommandLineToArgvW), and dep_info finds an
.exe's .d file.

Cargo on Windows names a program in deps/ without the hash of its features, so a harness building horizon-client
with another selection (-p horizon-client-lib --bins) relinked the file a running harness's game was using:
"Access is denied", and every later build of the common selection failed too. On Windows a harness now waits while
a running harness built one of its programs differently.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:50:07 +01:00
Riccardo GuerrieroandClaude Opus 5.5 d3408a7e1e Keep an enderman's anger after it loses its target
Enderman.aiStep runs NeutralMob.updatePersistentAnger(level, true): holding
a target names it (angry_at) and keeps 20 to 39 seconds of anger running,
so an enderman that lost its player (a daylight teleport, a reload) stays
angry, and EndermanLookForPlayerGoal takes back a player it is angry at on
sight, without a new stare (isAngerInducing). The goal's continue
conditions (forCombat().ignoreLineOfSight()) have no range, so a held
target is no longer dropped past the 64-block follow range.

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

Claude-Session: https://claude.ai/code/session_01WZzq8MxShENtiMj2DMbm5G
2026-11-09 18:36:52 +01:00
itarqos5 0f23e5e538 Merge remote-tracking branch 'origin/main' into pr/particle-threads 2026-11-09 17:02:25 +00:00
Horizon agentandClaude Opus 5.5 45a2f32631 particles.rs doesn't grow: the threaded tick moves to particles/simple.rs
tick_simple, Simple, sprite_from_age_in, tick_all, finish_simple and the thread
settings move unchanged into a child module; particles.rs keeps the calls.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:04:48 +01:00
Horizon agent ef85f6dd7b Merge main into threaded particle ticks
# Conflicts:
#	crates/horizon-client-render/src/particles.rs
2026-11-09 16:24:19 +01:00
Riccardo GuerrieroandClaude Opus 5.5 a3c2a271ef Move the breed goal tests out of goals.rs
goals.rs is over the 3000-line limit and may not grow: the two Breed
goal test modules move to their own files, and the range check uses
Mob::distance_sqr, so goals.rs ends shorter than on main.

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

Claude-Session: https://claude.ai/code/session_01WZzq8MxShENtiMj2DMbm5G
2026-11-09 14:37:39 +01:00
Florian Ernst 8fe0b417be Start CI again: the previous run stayed queued with no jobs 2026-11-09 13:24:57 +01:00
RINNECODERandClaude Opus 5.5 13a8b0c3fe OptiFine CEM: a pre-1.21.5 pack's mob texture paints its old models and the climate variants
A pack made before 1.21.5 ships entity/pig/pig.png, cow.png,
red_mooshroom.png, brown_mooshroom.png and entity/chicken.png in the old
64x32 layout, and its .jem files measure their UVs on them. 26.3 draws
the renamed pig_temperate (64x64) through those UVs: misplaced,
see-through panels. Cold pigs, climate cows and mooshrooms kept
vanilla's textures.

When the packs have an old texture, the adult and its climate variants
draw the adult's model, reshaped by the adult's .jem when it has no
texture of its own, with the vanilla parts' UVs in the old layout (faces
past its bottom, the 1.21.5 cow's muzzle, are dropped). The renderer maps
the 26.3 name (pig_cold, mooshroom_brown) to the old one through
CemLayer::texture_for, so random textures follow it. A variant with a
.jem of its own is left alone.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 05:04:12 -06:00
RINNECODERandClaude Opus 5.5 7fd4a41cec OptiFine CEM: this is a replacing entry's own bone, not the vanilla part
OptiFine's CEM animation docs: "this" is the current custom model, "part"
the original one. A replacing entry's `this` wrote the vanilla part's
pose instead. Conquest Reforged's pig sets this.tx/ty/tz to its bone's
translate, which flung the head 7 px off the body.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 04:55:41 -06:00
Florian ErnstandClaude Opus 5.5 6ddde6fcde Keep game.rs from growing: declare the allay test module in allays.rs
The file-size check (docs/layers.md#file-size) does not let a file over
3000 lines grow.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 10:53:49 +01:00
Florian ErnstandClaude Opus 5.5 770f30306a An allay dances to the jukebox it heard first
Squashed and rebased onto main from #12466.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 10:01:28 +01:00
MSedgeMC 323705ef9b Merge remote-tracking branch 'upstream/main' into fix/dialog-book-pause
# Conflicts:
#	crates/horizon-client/src/game/books.rs
2026-11-09 08:33:49 +00:00
MSedgeMC 94a2f9a782 A dialog's pause and a lectern's book decide whether the world stops
Minecraft.pause asks the screen Minecraft set. A dialog is a DialogScreen of
its own: isPauseScreen returns its CommonDialogData.pause, true by default, so
a server's dialog freezes a single-player world unless the dialog says
otherwise. The warning above it is a ConfirmScreen (true) and the waiting
screen answers false. Horizon parsed `pause` and never read it, so every dialog
kept the world running. A lectern's book is LecternScreen, whose isPauseScreen
overrides BookViewScreen's true to false: reading on a lectern leaves the world
ticking, reading the same book in hand does not. Horizon paused for both.

pauses_game now asks the dialog, then the book, then the screen, the way each
of them is its own screen over the last.
2026-11-09 08:04:10 +00:00
fc4a51753d Feeding a horse or camel makes a vibration and a Silent one eats quietly
`AbstractHorse.handleEating` and `Camel.handleEating` play the eating sound
only when the mount is not Silent, at its feet, with a pitch from the mount's
own random, and then post `GameEvent.EAT` from the mount. A growing baby's
HAPPY_VILLAGER position draws three doubles from that random first (the
server's `Level.addParticle` shows nothing; the client draws its own).
Horizon played the sound for Silent mounts too, at the body's centre with
the level's random, and posted no game event, so sculk sensors and wardens
never heard a mount eat.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Codex (gpt-6.1-sol) <[email protected]>
Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
2026-11-09 07:23:49 +01:00
af8fa33bf0 Keep breeding partners within eight blocks
Match BreedGoal.getFreePartner and TargetingConditions.test by rejecting
partners beyond the spherical range while keeping the existing box query
and nearest-first ordering. Keep partners exactly eight blocks away.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Codex (gpt-6.1-sol) <[email protected]>
Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
2026-11-09 07:23:49 +01:00
12d3b1f189 Breeze dust crumbles the fence, wall or gate it stands on
`Breeze.emitGroundParticles` and `emitJumpTrailParticles` take
`getInBlockState`, else `getBlockStateOn` (`Entity.getOnPos(1.0E-5F)`), which
keeps the supporting block under the feet. Fences, walls and fence gates
collide 1.5 blocks high, so the cell 0.2 below a breeze standing on one is air
and no dust came out. The ground dust also skips invisible blocks as vanilla
does. The crumbs now go through `ClientLevel.addParticle`, so each particle's
own generator (`Particle.random`) makes its draws and the client level's random
is left alone.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Codex (gpt-6.1-sol) <[email protected]>
Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
2026-11-09 07:23:48 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 d0f851fca9 agent_control_e2e passes again: the screen commands wait for the first frame
- A game answers its control socket before it has drawn a frame: `ui`, `click`, `set`,
  `screen` and `click-button`, which read the last drawn frame, now wait for the first one
  (UiWait), as `ui`, `click` and `set` already wait for a frame after a click.
- The ui scenario expects Create New World's Game tab, the manage scenario a widget the
  Horizon options page still has (ai-agent), and the options scenario takes GUI scale 2 as
  half the GUI of scale 1, so display scaling other than 100 % passes.
- The no-ai check honours HORIZON_E2E_HEADLESS like the other scenarios.

Fixes #12972

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 00:27:31 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 7a08fd9041 record-start: the server thread holds still while the world is copied
Pausing the integrated server for the copy ran its pause save (IntegratedServer.tickServer's
saveEverything) during the copy, often after the call that saved had returned. That save
renames level.dat and the player files into place and hands region files to the region
writer, so the copy failed with "The system cannot find the file specified", or could read
a region file mid-write. Now the server thread saves, then waits on its own thread until the
copy is done; the pause flag stays as it was.

Fixes #12977

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 00:17:50 +01:00
itarqos5andClaude Sonnet 5.5 32e184c196 Particles: tick the independent ones on several threads
ParticleEngine::tick ran every live particle one after another. Most of them (flames,
smoke, poofs, dust, crits and a firework spark's own motion) read and write only their
own state, the world and the sprite sets, so tick_simple now ticks them on up to four
scoped threads once there are 2048 particles. A spark's trail child, which takes the next
engine seed, and every other behaviour still run on the engine thread afterwards, in the
order they had, so the particles, the engine's random and the sounds come out as before.

On the stress_particles mix (isolated benchmark, particles/tick_bench.rs): whole-scene
engine tick 1.81 ms on one thread, 1.40 / 1.27 / 1.18 / 1.15 ms on 2 / 3 / 4 / 6 threads.
parallel_tick_matches_serial_tick runs both over 90 ticks with rockets and compares every
particle, the engine's random and the sounds.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-08 16:20:06 +00:00
RINNECODERandClaude Opus 5.5 957ecaff5f Shader packs: keep solid terrain in the compact layout
With a `shaders/` pack, sections uploaded their solid quads as four
`TerrainVertex`es each instead of one 24-byte record (`mesher::packed`): the
pack's terrain and shadow programs read vertex attributes. At render distance
64 that doubled the section arena (5.9 GB of vertices against 2.9 GB) and
BSL and Photon flights stalled under memory pressure.

- Flat pack draws now pull every input from the arena page with a GLSL port of
  terrain.wgsl's `section_corner` (`pack_atlas::PULL_FLAT`): position, UV,
  layer, light and colour bit for bit (the colour as the Unorm8x4 fetch
  converts it), and the quad's four corners for the normal, tangent and
  midpoints. Their pipelines take no vertex buffer.
- Records carry each corner's alpha (3-bit codes in spare bits of words 3 and
  4): with `separateAo` it is the corner's ambient occlusion, and one alpha a
  quad made almost every face fail the exact check.
- Records always end on a whole quad of slots, so the translucent vertices a
  pack's frames keep after them start a quad (their normals were read across
  quads).
- `section-layout compact|wide` (horizon-ctl) switches the layout in a running
  game for A/B checks; `follow_packed_layout` keeps it on for flat packs.

A GPU test draws records and their wide vertices through a pack's terrain
inputs and requires the same bits for every input.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 03:46:41 -06:00
Evrim Cicek 3cd63747b9 Merge remote-tracking branch 'upstream/main' into fix/vehicle-item-stack-config
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-08 02:37:22 +03:00
Evrim Cicek 99f76baf29 Merge remote-tracking branch 'upstream/main' into fix/vehicle-item-stack-config
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-07 16:53:40 +03:00
Evrim Cicek 52939c99ab Merge remote-tracking branch 'upstream/main' into fix/vehicle-item-stack-config
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-07 02:30:24 +03:00
Evrim CicekandClaude Opus 5.5 8590b6e650 A vehicle from an item also takes its custom_data, and a boat keeps its placing yaw
Review: Entity.applyImplicitComponent applies two components, the custom
name and minecraft:custom_data (saved as `data`), and BoatItem.use /
BoatDispenseItemBehavior turn the boat to the player's or dispenser's yaw
after the stack's config, so entity_data's Rotation does not keep it.
Also moves place_minecart's doc line back onto it.

Harness group "vehicle item stack config": the three new cases 24/27
before, 27/27 now.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 18:57:18 +03:00
Evrim CicekandClaude Opus 5.5 70f07d8757 A placed or dispensed minecart or boat takes its item's name and entity_data
MinecartItem.useOn, BoatItem.use and the dispenser's
MinecartDispenseItemBehavior / BoatDispenseItemBehavior apply
EntityType.createDefaultStackConfig to the new vehicle: the item's custom
name (applyComponentsFromItemStack), then minecraft:entity_data loaded
over it (updateCustomEntityTag) when it names the vehicle's type, an
op-only type only from an op player and never from a dispenser. Horizon
placed default vehicles. Game::apply_vehicle_stack_config does both,
through the /data merge loader, with the spawn egg's guard.

Command harness group "vehicle item stack config": 12/23 -> 23/23.

Fixes #11195

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 18:36:09 +03:00
184 changed files with 8633 additions and 1512 deletions

No files matched your search

+34
View File
@@ -0,0 +1,34 @@
name: runner watch
# Every 30 minutes: one issue when jobs wait over 2 hours for a self-hosted runner nobody runs (the maintainer's
# machines are off), closed again once nothing waits (tools/ci/runner_watch.py, docs/operations.md). It runs on
# GitHub's runners, never ours: it must work when every self-hosted runner is gone.
# gh workflow run "runner watch" -f dry_run=true
on:
schedule:
- cron: '11,41 * * * *'
workflow_dispatch:
inputs:
dry_run:
description: Only print what it would do with the issue
type: boolean
default: false
permissions:
actions: read
contents: read
issues: write
concurrency:
group: runner-watch
cancel-in-progress: false
jobs:
watch:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: Jobs waiting for a self-hosted runner
env:
GH_TOKEN: ${{ github.token }}
run: python3 tools/ci/runner_watch.py ${{ inputs.dry_run && '--dry-run' || '' }}
+145 -45
View File
@@ -2,6 +2,9 @@
> Adopted 2026-11-09 by the founder as his last decision as head of the project. It takes effect when he
> announces that he is stepping down. From then on only the council changes it, by a 2/3 poll (section 15).
>
> Amended 2026-11-10 by the founder (three kinds of action, elections by tier, who may do what, and council poll P41:
> the mods decide moderation matters). The council can overturn this amendment by poll.
Horizon has no boss. A **council of equals** decides by polls. **Theme groups** do the work in their areas, each
with a **lead**. The **Horizon Team** reviews and merges code. **Moderators** keep the Discord server and the
@@ -50,13 +53,13 @@ the Horizon Team, for example.
| Level | What it can do | How you get in | How you leave | 18+ |
| --- | --- | --- | --- | --- |
| **Member** | Play, chat, report bugs, suggest ideas, open issues and pull requests, pick group roles | Join the Discord server or the GitHub repo | Leave, or get banned | No |
| **Contributor** (⛏️ Iron, 🥇 Gold, 💎 Diamond, 🟪 Netherite ranks) | Write in #contributors; `/claim` issues; nominate people for group lead (if active in that group) | One merged pull request in a public horizon-rust repo, linked with `/link-github` and `/verify-github`. Ranks come from merged pull requests: 10, 40, 100 and 200 | Contributor status and ranks stay | No |
| **Group member** | Read and write in the group's channel; get the group's pings; take part in the group's advisory poll on its lead | Pick the group's role in Channels & Roles, or with `/groups` | Remove the role | No |
| **🤝 Support** | Answer and close posts in #horizon-support | `/apply`; vote of the mods and the Team (as today) | Resign, 30 days inactive, or removed by the Chief of Mods or the council | No |
| **🛡️ Moderator** | Time out, delete messages, manage threads, quarantine. No bans | `/apply`, then the selection filter; mods' vote; the council may object within 24 h | Resign, 30 days inactive, suspended by the Chief of Mods, removed by a council poll (simple majority) | **Yes** |
| **🛡️ Chief of Mods** | A moderator's powers, plus bans; leads the mods; suspends a mod at once pending the council | Council poll (simple majority), from the mods with at least 30 days | Resign, 30 days inactive, council poll (simple majority) | **Yes** |
| **🧭 Group lead** | Decides inside the group's topic (section 6); manages the group's channel; triage on the group's GitHub issues | Nomination, the filter, a 24 h public discussion with an advisory group poll, then a council poll (simple majority); 30 days probation | Resign, 30 days inactive (automatic), council poll (simple majority); confirmed again every 6 months | **Yes** |
| **🌌 Horizon Team** | Review and merge pull requests (GitHub `horizon-team`, write access); `/nightly` and triage commands (trusted reviewers); #horizon-team | Nomination, the filter, a 24 h public discussion, then a council poll (2/3); 30 days probation | Resign, 60 days without a merge or review (automatic), council poll (2/3, without the person) | **Yes** |
| **Contributor** (⛏️ Iron, 🥇 Gold, 💎 Diamond, 🟪 Netherite ranks) | Write in #contributors; `/claim` issues; vote in their group's elections (if active in that group) | One merged pull request in a public horizon-rust repo, linked with `/link-github` and `/verify-github`. Ranks come from merged pull requests: 10, 40, 100 and 200 | Contributor status and ranks stay | No |
| **Group member** | Read and write in the group's channel; get the group's pings; vote in the group's elections and votes when active | Pick the group's role in Channels & Roles, or with `/groups` | Remove the role | No |
| **🤝 Support** | Answer and close posts in #horizon-support | Given by any moderator (a free action, section 2.1) | Resign, 30 days inactive, or removed by a moderator, the Chief of Mods or the council | No |
| **🛡️ Moderator** | Time out, delete messages, manage threads, quarantine. No bans | Elected by the mods (section 5.3); council veto within 24 h | Resign, 30 days inactive, suspended by the Chief of Mods, recalled by the mods, removed by a council poll (simple majority) | **Yes** |
| **🛡️ Chief of Mods** | A moderator's powers, plus bans; leads the mods; suspends a mod at once pending the council | Elected by the mods, from the mods with at least 30 days (section 5.3); council veto within 24 h | Resign, 30 days inactive, recalled by the mods, council poll (simple majority) | **Yes** |
| **🧭 Group lead** | Decides inside the group's topic (section 6); manages the group's channel; triage on the group's GitHub issues | Elected by the group's active members and the Team (section 5.3); council veto within 24 h; 30 days probation | Resign, 30 days inactive (automatic), recalled by the same electorate, council poll (simple majority); confirmed again every 6 months | **Yes** |
| **🌌 Horizon Team** | Review and merge pull requests (GitHub `horizon-team`, write access); `/nightly` and triage commands (trusted reviewers); #horizon-team | Elected by the Team, 2/3 (section 5.3); council veto within 24 h; 30 days probation | Resign, 60 days without a merge or review (automatic), recalled by the Team (2/3), council poll (2/3, without the person) | **Yes** |
| **🏛️ Council** | Decides everything else by poll (section 4); bans; emergency actions by any 2 members | Nominated by a council member, the filter, a 24 h public discussion, then a council poll (2/3); 30 days probation | Resign, inactivity (section 5.5), council poll (2/3, without the person) | **Yes** |
**The account holder** isn't a level. The founder (Ludovic) still holds the project's accounts (GitHub organisation
@@ -64,6 +67,55 @@ owner, Discord server owner, the Microsoft app approved by Mojang, the domains,
council's decisions on them and hands them over step by step (section 11). As a council member he has one vote,
like everyone else.
### 2.1 Three kinds of action
Most things need no vote at all. A vote is for power over other people, and for decisions that bind a whole body.
| Kind | What | How |
| --- | --- | --- |
| **Free** | Everyday actions inside your own role (the list below, and anything of the same weight) | Just do it. Kira logs the ones she sees in #staff-log. The tier above can reverse it; if people disagree, the tier above votes. A pattern of abuse is a recall |
| **Body vote** | Giving or taking a role that carries power (an election), or a decision that belongs to one body: the mods, the Team, a group | `/elect` or `/vote body:` in Kira (section 5.3). Any council member can call a veto within 24 hours |
| **Council poll** | Rules, money, accounts and ownership, the council's own members, vetoes, anything that spans several bodies | `/poll` (section 4) |
Free actions, for example:
- a lead gives or removes their group's helper role (🧩 Horizon Modding Team), through Kira;
- a moderator gives or removes 🤝 Support, through Kira;
- a moderator times out, deletes, quarantines, or changes a channel's slowmode or topic; the Chief of Mods bans and
unbans;
- the Team merges and labels;
- a server operator restarts the official server, or prunes backups and chunks by the written rule;
- a lead pins, renames or creates threads in their group's channels;
- Kira's automatic actions.
**The 24-hour rule.** Every vote lasts 24 hours at most, and closes earlier once its result can't change. Only council
polls on this document, the licence, money and the GitHub organisation's ownership last 48 hours. A veto is called
within 24 hours of a result.
### 2.2 Who may do what
What each post decides, and what it holds on Discord, on the official server and on GitHub. Anything not listed is a
council matter.
| Post | Decides | Discord | Official server | GitHub |
| --- | --- | --- | --- | --- |
| **Member** | — | Chat, threads, picks group roles | Play | Issues, pull requests, `/claim` |
| **Group helper** (🧩 Horizon Modding Team) | — | Writes in the group's team channel | — | — |
| **🤝 Support** | Closes support posts | Manages threads in #horizon-support | — | — |
| **🛡️ Moderator** | Moderation matters, by the mods' vote (section 7) | Time out, delete messages, manage threads and nicknames, audit log, voice mute and move; quarantine through Kira; the staff channels | In-game op if also elected operator | — |
| **🛡️ Chief of Mods** | A moderator's, plus bans and unbans; suspends a mod at once; breaks ties in the mods' votes | Plus Ban | As a moderator | — |
| **Server operator** | In-game moderation by the server rules | — | Op (the server's ops list); no panel, no `server.toml` | — |
| **🧭 Server & SMP lead** | The server's settings, by the written rules (backup first, warning in game) | As every lead | A panel sub-user once a council accounts poll gives one | As every lead |
| **🧭 Group lead** | The group's free actions; gives and removes its helper role | In the group's channels: delete messages, manage and pin threads, ping the group; the helper role through Kira | — | Triage on the group's labels through Kira (no repository role) |
| **🌌 Horizon Team** | Merges; elects Team members | #horizon-team and the staff channels. The role still carries timeout and delete; whether it keeps them is a mod matter | — | `horizon-team`: write on horizon, horizon-mods, horizon-mixins, horizon-native-mod-template and horizon-server; also kira and horizon-site if P37 passes |
| **🔧 Server Manager** | — | Manage Channels and Manage Roles (the server's layout); given by council poll | — | — |
| **🏛️ Council** | Council polls, vetoes, emergencies (any 2) | A moderator's permissions plus Ban, mention @everyone and pin; #council | Panel sub-users for two members if P38 passes | The `council` team: code-owner reviews and the release tools' owner powers if P35 passes, security managers if P36 passes; a second organisation owner if P28 passes |
| **Account holder** (the founder) | Carries out decisions on his accounts (section 11) | Server owner | The panel's owner account | Organisation owner |
Kira applies a passed election everywhere she can: Discord roles herself, GitHub team membership when the council has
given her a token for it. Whatever she can't do (a panel sub-user, the ops list, a GitHub team without a token) she
posts as a list of clicks for a named person.
## 3. Each level in detail
### Member
@@ -84,13 +136,13 @@ an issue or a pull request on GitHub, or a request for a council poll (any counc
Each theme group (section 6) has a role that anyone can pick and drop. It opens the group's channel and receives the
group's pings. Picking a role grants no power. A group member counts as **active in the group** when Kira has seen at
least one counted action in the group's area (section 5.1) in the last 60 days. Only active group members can
nominate a lead or vote in the group's advisory poll.
least one counted action in the group's area (section 5.1) in the last 60 days. Active group members vote in the
group's elections and decisions (section 5.3).
### 🤝 Support
Unchanged: Support members help players in #horizon-support. They answer every post there and mark posts solved.
The role carries no permission, so it has no age rule. They join through `/apply`, and the mods and the Team vote.
The role carries no permission, so it has no age rule. A moderator gives it, and takes it back, through Kira.
### 🛡️ Moderators and the Chief of Mods
@@ -151,7 +203,7 @@ don't go through the filter or a probation. They do make the 18+ declaration (se
| --- | --- |
| Who starts one | Any council member, with `/poll` (Kira). Anyone else asks a council member to start it |
| Where | #polls-and-decisions, in public. Polls about a person (removal, appeals, the age rule) run in #council, and only their result is public |
| How long | 24 hours. It closes early only if every council member has voted |
| How long | 24 hours; 48 hours for this document, the licence, money and the GitHub organisation's ownership. It closes early only if every council member has voted |
| Ballot | Yes, No or Abstain. You can change it until the poll closes |
| Quorum | At least half of the council votes (Abstain counts). With 6 members, 3 votes |
| Simple majority | More Yes than No |
@@ -165,20 +217,20 @@ don't go through the filter or a probation. They do make the 18+ declaration (se
| Simple majority | Two-thirds |
| --- | --- |
| Day-to-day project decisions: direction, priorities, settling disputes between groups, a public statement | Adding council members and Horizon Team members |
| Group leads and co-leads: appointing, removing, confirming every 6 months | Removing a council member or a Team member (without the person concerned) |
| Moderators, the Chief of Mods, Support: confirming, removing | Rules: this document, the server rules, the contributing policy, the licence |
| Day-to-day project decisions: direction, priorities, settling disputes between groups, a public statement | Adding council members |
| Vetoing a body's vote or election (section 5.3) | Removing a council member or a Team member (without the person concerned) |
| Removing a group lead, a moderator, the Chief of Mods or Support (their bodies can also recall them) | Rules: this document, the server rules, the contributing policy, the licence |
| Ending a probation early | Money (section 12) |
| Confirming an emergency action | Account handover steps (section 11) |
| Second-level moderation appeals | Waiving part of the selection filter (section 5.4) |
| Deciding for a body that has fewer than 3 voters | Waiving part of the selection filter (section 5.4) |
| Bringing back someone who stepped down for inactivity, within 6 months | Changing the council's size |
**When in doubt, two-thirds.** Any council member can say a poll needs 2/3, and Kira then counts it at 2/3.
### 4.4 Discussion
Polls that add someone to the council, the Team or a lead post start with a **24-hour public discussion** (section
5.3). Other polls are discussed in their own thread while they run. A member who wants more time votes No and says
Polls that add someone to the council start with a **24-hour public discussion**. Other polls, and elections, are
discussed in their own thread while they run. A member who wants more time votes No and says
why. A failed poll can come back after 7 days.
### 4.5 Conflicts of interest
@@ -220,7 +272,7 @@ false age declaration (section 9).
## 5. The selection filter
Nobody becomes a lead, a Team member or a council member just because they are well liked or talk a lot. Kira
measures the work, and only people above a floor can be nominated. The council then decides among them.
measures the work, and only people above a floor can be nominated. Their body then elects them (section 5.3).
### 5.1 What Kira measures
@@ -254,25 +306,63 @@ A nomination is refused below these floors.
The numbers are checked again once a year (section 15). They should stay reachable for people who really work on
Horizon and out of reach for people who don't.
### 5.3 The path to a role
### 5.3 Elections
1. **Nomination.** Run `/nominate member: role: [group:]` in Kira.
- A **group lead** can be nominated by an active member of that group or by a council member. A self-nomination
needs a second from another active group member or a council member.
- A **Team member** can be nominated by a council or Team member. Someone can also nominate themselves with
`/apply`, if a council or Team member sponsors them.
- A **council member** can only be nominated by a council member.
- **Moderators** apply with `/apply`.
2. **The filter.** Kira checks the floor and the 18+ declaration. Below the floor, Kira refuses and says which line
is short.
3. **Public discussion, 24 hours.** Kira posts the nomination and the evidence card in a thread of
#polls-and-decisions. Contributors and the council can comment. For a lead, Kira also runs an **advisory poll** in
the group's channel for the same 24 hours. Only active group members vote in it, and the council sees the result.
4. **Council poll, 24 hours.** Simple majority for a lead, 2/3 for the Team and the council. For moderators, the mods'
vote replaces this step, and any two council members can object within 24 hours; the council then decides by poll.
5. **Probation, 30 days.** The role is held in full, except for the Team limits in section 3. At day 25, Kira asks the
council in #council whether anything is wrong. Probation ends by itself at day 30, unless a council poll (simple
majority) ended it earlier.
The body that works with a person elects them. Kira runs it: `/elect post: member: [group:]`, and `action: remove`
for a recall.
| Post | Who votes | Passes with | Who can start it |
| --- | --- | --- | --- |
| **🛡️ Moderator** | The mods (🛡️ Mod and 🛡️ Chief of Mods) | Simple majority; the Chief of Mods breaks a tie | Any mod, a council member |
| **🛡️ Chief of Mods** | The mods; candidates are mods for 30 days or more | Simple majority | Any mod, a council member |
| **Server operator** | The mods and the 🧭 Server lead | Simple majority | The Server lead, the Chief of Mods, a council member |
| **🧭 Group lead** (a co-lead once the group has 8 active members) | The group's active members, and the Team | Simple majority | The current lead, a Team member, a council member |
| **🌌 Horizon Team** | The Team | Two thirds, and at least 3 Yes | A Team member, a council member |
| **🏛️ Council** | The council, by poll after a 24-hour discussion (section 4) | Two thirds, and at least 3 Yes | A council member |
| 🤝 Support, a group's helper role | Nobody: a free action (section 2.1) | — | A moderator; the group's lead |
An **active group member** has held the group's role for 14 days or more (Kira counts from when she first saw it)
and has at least one merged pull request or review with the group's label in the last 60 days.
1. **Start.** Kira checks the floor (5.2) and the 18+ declaration. If the candidate falls short, the person starting
gets a private "not now", and the candidate a private message saying what is missing. Nothing is posted and no
reason is ever shown to anyone else. Kira checks what she can measure (verified GitHub, merged pull requests and
reviews, overall and by the group's label, rank, days in the server, Kira level, clean record, the declaration);
the voters judge the rest.
2. **Vote, 24 hours,** in the body's channel, discussed in the vote's thread. Yes, No or Abstain, changeable while it
runs. Kira shows how many voted, never who. It closes early once the result can't change.
3. **Result,** applied at once. Kira gives or takes the Discord role and, with a token, the GitHub team; she lists the
rest as clicks for a named person. Only the result is published, never the ballots.
4. **Veto, 24 hours.** Any council member can call it with the button under the result. A council poll (simple
majority, without the people involved) decides; if it passes, Kira undoes the result.
5. **Probation, 30 days,** for leads and Team members. It ends by itself unless someone who can vote starts a recall.
The council can always remove someone by its own poll. **Body votes** work the same way for a body's own decisions:
`/vote body:mods`, `body:team` or `body:group`, simple majority, 24 hours, the same veto.
#### Hard cases
| Case | What happens |
| --- | --- |
| Fewer than 3 can vote | The council decides by poll |
| Quorum | A third of the electorate, and at least 3 ballots (Abstain counts). Without it, nothing changes |
| Tie | Fails, and nothing changes. In the mods' votes, the Chief of Mods' ballot breaks it |
| The candidate | Doesn't vote on themselves, and can decline (the election ends). The person who started it votes normally |
| Conflicts | Whoever the vote is about, or who gave or received the moderation action in question, doesn't vote |
| Several candidates for one seat | Nominations within the first 12 hours join one approval ballot. The seat goes to the most approvals if that is at least half of the voters; otherwise a 24-hour run-off between the top two |
| A seat that is held | Changes only by a recall or by the 6-month confirmation |
| The council is already voting on it | No election starts for the same person and post |
| Cooldown | A failed or vetoed election can't be started again for the same person and post for 30 days. At most 3 elections run at once in a body |
| Vacancy | When a lead or the Chief of Mods leaves, Kira opens the seat's election herself: 24 hours of nominations, then the vote with everyone nominated. Nobody nominated: the seat stays open, and the tier above acts meanwhile. Kira never picks a candidate |
| Abuse of a free action | The tier above reverses it; a disagreement goes to its vote. A pattern is a recall |
| Emergency | Section 4.6. The Chief of Mods can still suspend a mod at once |
| Kira is down | The body may run the same vote as a Discord poll in its channel for 24 hours; Kira records the result when she is back |
#### Reminders, not nominations
Once a month in a group's channel, Kira says how to propose someone, only when the group has no lead or someone in it
meets a post's floor. When a seat opens, she posts once in the electorate's channel. Once a month in #council, she
lists privately the people who meet a floor. These are suggestions: only people start elections.
### 5.4 Exceptions
@@ -299,8 +389,8 @@ holder or an org owner removes GitHub access when the Team role ends.
### 5.6 Terms
- **Group leads** are confirmed every 6 months by a simple-majority poll. If someone else in the group passes the
floor and is nominated, both go on the same poll.
- **Group leads** are confirmed every 6 months by an election in their group. If someone else in the group passes the
floor and is nominated within its first 12 hours, both go on the same ballot.
- **Council and Team** have no fixed term. They stay as long as they are active and keep the council's trust. Removal
is always possible (section 4.7).
@@ -339,8 +429,9 @@ the design system:
- technical and design direction inside the area, when vanilla behaviour and the default look don't change;
- accepting or declining an idea or a proposal in the area. Any two active group members or one council member can
take that decision to the council;
- giving and removing the group's helper role, through Kira (a free action);
- how the group organises itself (threads, meetings, the channel's rules on top of the server's);
- asking the Team for reviews, adding `needs-proof`, and nominating people.
- asking the Team for reviews, adding `needs-proof`, and starting elections in the group.
### 6.4 What goes to the council
@@ -356,21 +447,28 @@ the design system:
The work goes on. The Team reviews and merges, and Kira still routes the group's issues and questions to its
channel. Until a lead is chosen, any council member may take a lead's decision for that group and says so in the
channel. Kira pins a "lead wanted" note with the floor. **No lead is better than a lead who doesn't do the work:**
channel. Kira posts once that the seat is open and how to start its election (section 5.3). **No lead is better than a lead who doesn't do the work:**
nobody is appointed just to fill the seat.
## 7. Moderation and appeals
- **The mods decide moderation matters** (council poll P41, imposed by the founder on 2026-11-10): who joins or
leaves the mod team, the mods' permissions and the staff channels, bans and unbans, and channel settings for
moderation. Everyday actions are free (section 2.1). Anything bigger is a mods' vote (`/vote body:mods`, simple
majority, 24 hours), and the Chief of Mods breaks ties. **The council keeps a veto:** within 24 hours of a mods'
decision, any council member can call a council poll, and a simple majority overturns it.
- **The server rules** are in #rules. Moderators enforce them, the Chief of Mods leads the mods, and the council can
change the rules (2/3).
- **Every action has a mod case**: who acted, what they did, and why. Kira keeps the cases.
- **First appeal:** within 14 days, through #appeal (for a quarantine) or mod mail. A moderator who wasn't involved,
or the Chief of Mods, answers within 72 hours.
- **Second appeal:** for bans, timeouts over 24 hours and quarantines, a council poll in #council (simple majority).
Members who were involved don't vote. The answer is final for 90 days.
- **Second appeal:** for bans, timeouts over 24 hours and quarantines, a mods' vote in #mod-chat. Mods who were
involved don't vote, and the council's veto applies. The answer is final for 90 days.
- **Not appealable here:** actions that Discord's rules or the law require. Those are reported where the law says.
- **Removing a moderator:** the Chief of Mods can suspend a moderator at once, and the council decides within 72 hours
(simple majority). The council removes the Chief of Mods the same way.
- **Removing a moderator:** the Chief of Mods can suspend a moderator at once, and the mods decide within 72 hours by
a recall vote. The mods recall the Chief of Mods the same way. The council can also remove either by poll.
- **Council members keep the ban permission** for emergencies and when no moderator is around. They log the ban like
any other, and the mods can reverse it by vote.
- **Staff are moderated like everyone.** When a council member is involved, the council decides without them.
## 8. Conflicts and hard cases
@@ -458,7 +556,9 @@ and the founder carries it out.
## 13. Transparency
- **#polls-and-decisions:** every poll and every result, posted by Kira, and the 24-hour discussions of nominations.
For polls that aren't about a person, each member's vote is shown once the poll closes.
For polls that aren't about a person, each member's vote is shown once the poll closes. The results of elections
and body votes are posted there too; for elections, only the result.
- **#staff-log:** the free actions Kira sees (roles given or taken, bans, unbans), so the tier above can check them.
- **The decisions log:** Kira keeps an append-only record of every poll, emergency action, appointment and
step-down, with its date and result. It is public and machine-readable, so the agents can check a decision there.
- **This document** lives in horizon-rust/horizon as `GOVERNANCE.md`. A short summary is pinned in #rules and in
+95 -2
View File
@@ -70,6 +70,8 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
other one 30 000 characters long), flood-book (100 pages of 1024 characters
into a writable book), flood-sign (four 384-character lines on the block
below), flood-recipe (recipe book placements)
--ack-mode MODE chunk batch feedback: vanilla (default) or 64 (fixed chunks per tick);
one ACK per completed batch; use explore for a controlled pacing test
--flood N packets a tick for the flood behaviours; default 100
--attack NAME attack the player NAME twice a second, from wherever the bot is (a reach test),
swinging after each attack as vanilla's client does. NAME,NAME...: each of them
@@ -171,6 +173,8 @@ struct Options {
/// --crits: fight bots alternate jumped critical hits and sweeps at a sword's cooldown.
crits: bool,
flood: usize,
/// None uses vanilla estimation; --ack-mode 64 requests a fixed rate.
ack_rate: Option<f32>,
no_read: bool,
no_loaded: bool,
height: f64,
@@ -209,6 +213,7 @@ fn options() -> Result<Options, String> {
no_swing: false,
crits: false,
flood: 100,
ack_rate: None,
no_read: false,
no_loaded: false,
height: 24.0,
@@ -266,6 +271,13 @@ fn options() -> Result<Options, String> {
o.explore_speed = value.parse().map_err(|_| number("blocks a second"))?;
}
"--pvp" => o.pvp = value.parse().map_err(|_| number("true or false"))?,
"--ack-mode" => {
o.ack_rate = match value.as_str() {
"vanilla" => None,
"64" => Some(64.0),
_ => return Err(format!("--ack-mode {value}: use vanilla or 64")),
};
}
"--flood" => o.flood = value.parse().map_err(|_| number("packets a tick"))?,
"--attack" => o.attack = Some(value),
"--attack-after" => o.attack_after = value.parse().map_err(|_| number("seconds"))?,
@@ -950,6 +962,7 @@ fn read(
writer: Arc<Mutex<ConnWriter>>,
bot: Arc<Bot>,
track: bool,
ack_rate: Option<f32>,
mut capture: Option<Capture>,
) {
use ids::play::clientbound as cb;
@@ -1035,7 +1048,8 @@ fn read(
batch.finished(r.varint().unwrap_or(0));
send(
play::Serverbound::ChunkBatchReceived(play::ChunkBatchReceived {
chunks_per_tick: batch.desired_chunks_per_tick(),
chunks_per_tick: ack_rate
.unwrap_or_else(|| batch.desired_chunks_per_tick()),
})
.encode(),
)
@@ -1139,7 +1153,8 @@ fn run_bot(
let capture = (index == 0)
.then(|| o.capture.as_deref().and_then(Capture::create))
.flatten();
thread::spawn(move || read(reader, writer, bot, track, capture));
let ack_rate = o.ack_rate;
thread::spawn(move || read(reader, writer, bot, track, ack_rate, capture));
}
use ids::play::serverbound as sb;
// Wait for the spawn position.
@@ -1519,6 +1534,7 @@ fn main() {
"behaviour": o.behaviour,
"seed": o.seed,
"explore_speed": o.explore_speed,
"ack_mode": o.ack_rate.map_or("vanilla", |_| "64"),
"pvp": o.pvp,
"view_distance": o.view_distance,
"seconds": o.seconds,
@@ -1584,6 +1600,83 @@ mod tests {
AddEntity, Clientbound, MoveEntity, MoveEntityPos, RemoveEntities, SetEntityData, VecDelta,
};
/// Exercise the production reader over framed connections. Pings are barriers
/// so extra ACKs are observable without sleeps or timing guesses.
fn check_chunk_acks(ack_rate: Option<f32>) {
use horizon_protocol::connection::Pipe;
let (client, server) = Pipe::pair();
let (reader, writer) = Connection::local(client).split();
let mut server = Connection::local(server);
server
.reader
.set_read_timeout(Some(Duration::from_secs(2)))
.unwrap();
let bot = Arc::new(Bot::default());
bot.online.store(true, Ordering::SeqCst);
let handle = thread::spawn(move || {
read(
reader,
Arc::new(Mutex::new(writer)),
bot,
false,
ack_rate,
None,
)
});
for (nonce, size) in [0, 1, 5].into_iter().enumerate() {
server
.send(&raw(ids::play::clientbound::CHUNK_BATCH_START, |_| {}))
.unwrap();
server
.send(&raw(ids::play::clientbound::PING, |w| w.i32(nonce as i32)))
.unwrap();
let (id, body) = server.recv().unwrap();
assert_eq!(
id,
ids::play::serverbound::PONG,
"batch start must not send an ACK"
);
assert_eq!(Reader::new(&body).i32().unwrap(), nonce as i32);
server
.send(&raw(ids::play::clientbound::CHUNK_BATCH_FINISHED, |w| {
w.varint(size)
}))
.unwrap();
server
.send(&raw(ids::play::clientbound::PING, |w| w.i32(nonce as i32)))
.unwrap();
let (id, body) = server.recv().unwrap();
assert_eq!(id, ids::play::serverbound::CHUNK_BATCH_RECEIVED);
let rate = Reader::new(&body).f32().unwrap();
if let Some(fixed) = ack_rate {
assert_eq!(rate, fixed);
} else if size == 0 {
assert_eq!(rate, 3.5, "vanilla initial rate must remain the default");
} else {
assert!(rate.is_finite() && rate > 0.0);
}
let (id, body) = server.recv().unwrap();
assert_eq!(
id,
ids::play::serverbound::PONG,
"exactly one ACK per finished batch"
);
assert_eq!(Reader::new(&body).i32().unwrap(), nonce as i32);
}
server.writer.shutdown();
handle.join().unwrap();
}
#[test]
fn fixed_64_ack_is_sent_once_per_finished_batch() {
check_chunk_acks(Some(64.0));
}
#[test]
fn vanilla_acks_remain_once_per_finished_batch() {
check_chunk_acks(None);
}
fn feed(entities: &mut Entities, packet: &Clientbound) {
let bytes = packet.encode();
let mut r = Reader::new(&bytes);
@@ -514,6 +514,34 @@ pub fn name_text(tag: &Tag) -> Text {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn nested_fallback_references_stay_bounded() {
// 300 references to the argument, nested three deep: 27 million copies of "A" from a
// 4 KB component, which ran a client out of memory.
let bomb = |depth: usize, text: &str| {
let pattern = "%1$s".repeat(300);
let mut v = serde_json::json!({ "text": text });
for _ in 0..depth {
v = serde_json::json!({"translate": "x.unknown", "fallback": pattern, "with": [v]});
}
v
};
let started = std::time::Instant::now();
let text = to_text(&bomb(3, "A"), &mut |s| Text::literal(s));
assert!(text.spans.len() < 1 << 15, "{} spans", text.spans.len());
// One long argument referenced many times multiplies bytes, not spans.
let long = "B".repeat(64 << 10);
let text = to_text(&bomb(1, &long), &mut |s| Text::literal(s));
let bytes: usize = text.spans.iter().map(|s| s.text.len()).sum();
assert!(bytes <= 4 << 20, "{bytes} bytes");
assert!(started.elapsed() < std::time::Duration::from_secs(5));
// A real fallback still formats.
let ok = serde_json::json!({
"translate": "x.unknown", "fallback": "%1$s and %1$s", "with": [{"text": "A"}]
});
assert_eq!(to_text(&ok, &mut |s| Text::literal(s)).plain(), "A and A");
}
use crate::lang::{self, Language, ResourceSource};
struct Mem(std::collections::HashMap<String, Vec<u8>>);
+19 -1
View File
@@ -529,10 +529,13 @@ impl Team {
None => t,
}
}
/// `PlayerTeam.getFormattedDisplayName`.
/// `PlayerTeam.getFormattedDisplayName`: the display name in the team's
/// `displayNameStyle`, which its constructor makes the team's name on hover
/// (`ShowText`) and as insertion.
pub fn formatted_display_name(&self) -> Text {
let inner = hover_name(comp_text(&self.display_name), &self.name).with_style(&Style {
click: None,
insertion: Some(self.name.clone()),
..Style::default()
});
self.apply_color(bracketed(inner))
@@ -1411,6 +1414,21 @@ fn finish(node: &mut serde_json::Map<String, Value>, extra: Vec<Comp>) -> Comp {
#[cfg(test)]
mod tests {
use super::*;
/// #14279: `PlayerTeam`'s `displayNameStyle` puts the team's name on hover
/// and as insertion; `getFormattedDisplayName` brackets the name in it.
#[test]
fn a_teams_formatted_name_inserts_the_team_name() {
let team = Team::new("reds");
let c = team.formatted_display_name().component();
assert_eq!(c["translate"], "chat.square_brackets");
let inner = &c["with"][0];
assert_eq!(inner["text"], "reds");
assert_eq!(inner["insertion"], "reds");
assert_eq!(inner["hover_event"]["action"], "show_text");
// `{"text":"reds"}` or its short form `"reds"`: the same component.
let value = &inner["hover_event"]["value"];
assert!(value == "reds" || value["text"] == "reds", "{value}");
}
fn board() -> Scoreboard {
let mut sb = Scoreboard::default();
+70 -12
View File
@@ -282,17 +282,20 @@ impl Text {
pub fn push(&mut self, other: impl Into<Text>) {
let other = other.into();
let sibling = other.component();
let mut root = if self.spans.is_empty() && self.source.is_none() {
json!({ "text": "" })
} else {
let c = self.component();
if STYLE_KEYS.iter().any(|k| c.get(*k).is_some()) {
json!({ "text": "", "extra": [c] })
} else {
c
}
// The tree is appended to in place: cloning all of it on every append made a component
// with N siblings (a server's chat or MOTD "extra") cost N^2.
let root = match self.source.take() {
None if self.spans.is_empty() => std::sync::Arc::new(json!({ "text": "" })),
None => std::sync::Arc::new(self.component()),
Some(c) => c,
};
if let Value::Object(o) = &mut root {
let mut root = if STYLE_KEYS.iter().any(|k| root.get(*k).is_some()) {
let c = std::sync::Arc::try_unwrap(root).unwrap_or_else(|c| (*c).clone());
std::sync::Arc::new(json!({ "text": "", "extra": [c] }))
} else {
root
};
if let Value::Object(o) = std::sync::Arc::make_mut(&mut root) {
match o.get_mut("extra") {
Some(Value::Array(a)) => a.push(sibling),
_ => {
@@ -300,7 +303,7 @@ impl Text {
}
}
}
self.source = Some(root.into());
self.source = Some(root);
self.spans.extend(other.spans);
}
/// Apply `style` underneath every span (MutableComponent.withStyle on a parent).
@@ -671,11 +674,22 @@ pub fn legacy_format(style: &Style, code: char, reset: &Style) -> Style {
s
}
/// The most spans one translation expands to. Vanilla's `TranslatableContents.decompose` keeps
/// references to its arguments, so its cost stays linear in the input; copying them here makes
/// nested `%1$s` templates multiply (`k` references nested `D` deep: `k^D` copies), and a server's
/// `fallback` template is any text it likes.
const MAX_TRANSLATION_SPANS: usize = 1 << 15;
/// The most text one translation expands to: one long argument referenced many times multiplies
/// bytes, not spans.
const MAX_TRANSLATION_BYTES: usize = 1 << 22;
/// TranslatableContents.decomposeTemplate: `%s` takes the next argument,
/// `%n$s` a numbered one and `%%` is a percent sign. Malformed templates are
/// shown verbatim.
/// shown verbatim, and so is one that would expand past [`MAX_TRANSLATION_SPANS`] or
/// [`MAX_TRANSLATION_BYTES`].
pub fn format_translation(template: &str, args: &[Text]) -> Text {
let mut out = Text::default();
let mut bytes = 0usize;
let mut literal = String::new();
let mut next = 0usize;
let chars: Vec<char> = template.chars().collect();
@@ -725,6 +739,14 @@ pub fn format_translation(template: &str, args: &[Text]) -> Text {
let Some(arg) = args.get(index) else {
return Text::literal(template);
};
bytes = bytes
.saturating_add(literal.len())
.saturating_add(arg.spans.iter().map(|s| s.text.len()).sum());
if out.spans.len().saturating_add(arg.spans.len()) >= MAX_TRANSLATION_SPANS
|| bytes > MAX_TRANSLATION_BYTES
{
return Text::literal(template);
}
if !literal.is_empty() {
out.spans.push(Span {
text: std::mem::take(&mut literal),
@@ -750,6 +772,42 @@ pub fn format_translation(template: &str, args: &[Text]) -> Text {
mod tests {
use super::*;
#[test]
fn appending_builds_the_same_tree_in_linear_time() {
// A styled text is wrapped once, so its style doesn't reach what follows; a plain one
// takes the siblings itself; a copy taken before an append keeps its own tree.
let mut styled = Text::literal("a").with_style(&Style {
bold: Some(true),
..Style::default()
});
styled.push(Text::literal("b"));
let before = styled.clone();
styled.push(Text::literal("c"));
assert_eq!(
styled.component(),
json!({"text": "", "extra": [{"text": "a", "bold": true}, {"text": "b"}, {"text": "c"}]})
);
assert_eq!(
before.component(),
json!({"text": "", "extra": [{"text": "a", "bold": true}, {"text": "b"}]})
);
let mut plain = Text::default();
plain.push(Text::literal("x"));
plain.push(Text::literal("y"));
assert_eq!(
plain.component(),
json!({"text": "", "extra": [{"text": "x"}, {"text": "y"}]})
);
// 20 000 siblings, as a server's chat may carry: 76 s before, as each append cloned the
// whole tree.
let started = std::time::Instant::now();
let mut long = Text::default();
for _ in 0..20_000 {
long.push(Text::literal("a"));
}
assert_eq!(long.spans.len(), 20_000);
assert!(started.elapsed() < std::time::Duration::from_secs(5));
}
#[test]
fn click_events_serialize_and_inherit_as_one_style_field() {
for (event, json) in [
(
+58 -2
View File
@@ -6,7 +6,12 @@ use horizon_world::{
worldgen::{tags::TagDefinitions, terrain::TerrainBlockStates},
};
use serde_json::Value;
use std::{collections::BTreeSet, io, path::Path, sync::Arc};
use std::{
collections::BTreeSet,
io,
path::Path,
sync::{Arc, Mutex},
};
pub const MATERIALS: [&str; 6] = ["bedrock", "dirt", "stone", "sand", "oak_planks", "bricks"];
#[derive(Clone, Copy)]
@@ -182,6 +187,23 @@ static GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::
fn invalid(message: &str) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, message)
}
/// Every block registry [`Assets::load`] has handed out, one per distinct
/// content. Callers keep `&'static` references, so a registry is never freed;
/// loading equal data again (tests load the same root once per fixture)
/// returns the one already leaked instead of leaking another (about 11 MB for
/// vanilla).
static REGISTRIES: Mutex<Vec<&'static BlockRegistry>> = Mutex::new(Vec::new());
/// `registry` leaked, or the equal one leaked before it.
fn intern_registry(registry: BlockRegistry) -> &'static BlockRegistry {
let mut leaked = REGISTRIES.lock().unwrap_or_else(|e| e.into_inner());
if let Some(&same) = leaked.iter().find(|&&known| *known == registry) {
return same;
}
let registry: &'static BlockRegistry = Box::leak(Box::new(registry));
leaked.push(registry);
registry
}
impl Assets {
pub fn load(root: &Path) -> Result<Self> {
let manifest: Value = horizon_world::json_file::read(&root.join("horizon-resources.json"))?;
@@ -256,7 +278,7 @@ impl Assets {
// Chunks hold every state of this registry: content mods can take it
// past vanilla's 16-bit global palette.
crate::chunk::set_block_states(registry.len());
let registry: &'static BlockRegistry = Box::leak(Box::new(registry));
let registry = intern_registry(registry);
Ok(Self {
registry,
properties,
@@ -320,6 +342,16 @@ impl RenderAssets {
}
None => crate::model_library::ParsedModels::default(),
};
// Connected-texture rules find their faces by sprite layer: what they match by name keeps a
// layer of its own, decided before the models insert their textures.
let ctm_rules = if draws {
horizon_optifine::ctm::load(root)
} else {
Vec::new()
};
sprites.keep_apart(crate::connected::ConnectedTextures::matched_sprites(
&ctm_rules,
));
let mut models = if draws {
let _step = crate::spans::startup::step("core.models_bake");
crate::models::prepare_with_models(root, registry, &mut sprites, &parsed)?
@@ -359,6 +391,7 @@ impl RenderAssets {
let mut found = Vec::new();
crate::connected::ConnectedTextures::load(
root,
ctm_rules,
registry,
&mut sprites,
|state, face| {
@@ -496,6 +529,29 @@ mod tests {
));
}
#[test]
fn equal_registries_are_leaked_once() {
use horizon_world::registry::BlockRegistry;
let registry = |extra: &str| {
let facts = format!(
r#"{{"defaults":{{"minecraft:air":0,"fixture:{extra}":1}},"states":[
["air",true,false,"","minecraft:air"],
["{extra}",false,false,"","fixture:{extra}"]
]}}"#
);
BlockRegistry::from_facts_json(facts.as_bytes()).unwrap()
};
let first = super::intern_registry(registry("interned_once"));
let again = super::intern_registry(registry("interned_once"));
let other = super::intern_registry(registry("interned_other"));
assert!(std::ptr::eq(first, again), "equal content leaked twice");
assert!(!std::ptr::eq(first, other), "different content shared");
assert_eq!(
other.default_state("fixture:interned_other"),
Some(horizon_world::registry::BlockStateId(1))
);
}
#[test]
fn sprite_pixels_come_back_the_same_once_packed() {
// Two layers of 16x16 RGBA with some structure, as sprites have.
+10 -1
View File
@@ -1974,7 +1974,11 @@ impl ChatScreen {
return false;
}
let max = l.list.len().saturating_sub(self.layout.line_limit) as i32;
l.offset = ((l.offset as f64 - amount.clamp(-1.0, 1.0)) as i32).clamp(0, max) as usize;
// Round instead of truncating: touchpad/PixelDelta produces fractional
// amounts and truncation drops movement in one direction (#14338).
l.offset = (l.offset as f64 - amount.clamp(-1.0, 1.0))
.round()
.clamp(0.0, max as f64) as usize;
true
}
/// `CommandSuggestions.formatText`: argument colours over the input. The
@@ -2086,6 +2090,11 @@ impl ChatScreen {
) {
let limit = l.list.len().min(self.layout.line_limit);
let [rx, ry, rw, rh] = l.rect;
// SuggestionsList.extractRenderState: the whole list asks for the
// pointing hand while the pointer is inside its rectangle.
if mouse.0 >= rx && mouse.0 < rx + rw && mouse.1 >= ry && mouse.1 < ry + rh {
crate::widgets::request_cursor(crate::widgets::Cursor::PointingHand);
}
let has_prev = l.offset > 0;
let has_next = l.list.len() > l.offset + limit;
let moved = l.last_mouse != mouse;
@@ -1066,3 +1066,32 @@ fn a_server_filter_mask_hides_or_masks_the_message() {
.all(|s| s.style.color.is_none())
);
}
/// `SuggestionsList.extractRenderState`: the list's rectangle asks for the
/// pointing hand; outside it the cursor stays the arrow.
#[test]
fn the_suggestion_list_asks_for_the_pointing_hand() {
let font = font();
let m = Measure(&font);
let registries = Registries::default();
let c = Commands {
dispatcher: crate::command::vanilla::game_dispatcher(),
registries: &registries,
};
let mut screen = ChatScreen::new(&m, &c, "", false, 0, 640, 360, full());
screen.type_text(&m, &c, "/");
let rect = screen
.suggestions
.as_ref()
.expect("the root commands are a suggestion list")
.rect;
let _ = crate::widgets::take_cursor();
screen.draw(&m, (rect[0] + 2, rect[1] + 2), 0, 0);
assert_eq!(
crate::widgets::take_cursor(),
crate::widgets::Cursor::PointingHand
);
let _ = crate::widgets::take_cursor();
screen.draw(&m, (rect[0] - 1, rect[1] + 2), 0, 0);
assert_eq!(crate::widgets::take_cursor(), crate::widgets::Cursor::Arrow);
}
+48 -3
View File
@@ -124,15 +124,25 @@ fn sprite_name(resource: &str) -> String {
}
impl ConnectedTextures {
/// The rules under `root`, with their tiles inserted into `sprites` (before it finishes).
/// `face_layer` answers a block state's sprite on a side, for rules that connect by tile.
/// The sprites `rules` match by name (`matchTiles`, `connectTiles`), for
/// [`Sprites::keep_apart`]: before the models insert theirs.
pub fn matched_sprites(rules: &[Rule]) -> Vec<String> {
(rules.iter())
.flat_map(|r| r.match_tiles.iter().chain(&r.connect_tiles))
.map(|t| sprite_name(t))
.collect()
}
/// `rules` (`ctm::load` under `root`), with their tiles inserted into `sprites` (before it
/// finishes). `face_layer` answers a block state's sprite on a side, for rules that connect
/// by tile.
pub fn load(
root: &Path,
mut rules: Vec<Rule>,
registry: &BlockRegistry,
sprites: &mut Sprites,
mut face_layer: impl FnMut(BlockStateId, Face) -> Option<u32>,
) -> Self {
let mut rules = ctm::load(root);
// Rules with a custom name load no tiles: section meshes see no block entity.
rules.retain(|r| r.name.is_none());
// Rule indices are `u16`.
@@ -1270,6 +1280,41 @@ mod tests {
assert!(!ctm.stacked_pane_hides(3, 3, post));
}
#[test]
fn rules_name_the_sprites_they_match() {
// `matchTiles` among the textures and beside the file (a chain onto another rule's
// tile), and `connectTiles`: each as the sprite name the textures and tiles are inserted
// under.
let dir = "assets/minecraft/optifine/ctm/pack/crops";
let rule = |file: &str, text: &str| {
Rule::parse(
&format!("{dir}/{file}"),
&horizon_optifine::properties::Properties::parse(text),
)
.unwrap()
.unwrap()
};
let rules = [
rule(
"1.properties",
"matchTiles=./19.png\nmethod=random\ntiles=5-6",
),
rule(
"2.properties",
"matchTiles=textures/block/extra/placeholder.png\nmethod=overlay\n\
connectTiles=textures/block/stone.png\ntiles=0-16",
),
];
assert_eq!(
ConnectedTextures::matched_sprites(&rules),
[
format!("{dir}/19.png"),
"minecraft:block/extra/placeholder".to_owned(),
"minecraft:block/stone".to_owned(),
]
);
}
#[test]
fn sprite_names() {
assert_eq!(
@@ -12,6 +12,8 @@ use std::collections::HashMap;
use std::path::Path;
use std::sync::{Arc, RwLock};
mod noise;
pub struct Environment {
system: System,
/// What the packs' expressions read; the frame sets it before sampling.
@@ -559,13 +561,9 @@ impl<'a> BlockHost<'a> {
}
}
/// The simplex noise `r.noise(x, y)` reads, the same for blocks and particles.
pub(crate) fn polytone_noise() -> &'static horizon_core::noise::SimplexNoise {
static NOISE: std::sync::OnceLock<horizon_core::noise::SimplexNoise> =
std::sync::OnceLock::new();
NOISE.get_or_init(|| {
horizon_core::noise::SimplexNoise::new(&mut horizon_core::random::LegacyRandom::new(0))
})
/// The noise `r.noise(x, y)` reads, the same for blocks and particles.
pub(crate) fn polytone_noise() -> &'static noise::PerlinSimplex {
noise::builtin()
}
impl horizon_polytone::expr::Host for BlockHost<'_> {
@@ -615,7 +613,7 @@ impl horizon_polytone::expr::Host for BlockHost<'_> {
(V::Object(2), "noise") => {
let mut coords = args.iter().filter(|v| !matches!(v, V::Str(_))).map(V::num);
let (x, y) = (coords.next().unwrap_or(0.0), coords.next().unwrap_or(0.0));
num(f64::from(polytone_noise().get2(x, y)))
num(polytone_noise().get(x, y))
}
_ => Err(format!("{name} is not available to block colormaps")),
}
@@ -0,0 +1,116 @@
//! Polytone's `r.noise(x, y)`: 1.18-1.21's `PerlinSimplexNoise` over simplex noises, which 26.3 no
//! longer has. Measured on the real mod (Polytone 26.3-6.8.6) with a pack whose grass colormap encodes
//! the noise: the built-in noise is seed 0 with octaves `{1}`, read with the noise offsets
//! (`getValue(x, y, true)`), and a `polytone/noises` definition with seed 0 or 1 and octaves `{0}`,
//! `{1}` or `{0, 1}` matches this construction too.
use horizon_core::noise::SimplexNoise;
use horizon_core::random::{LegacyRandom, RandomSource};
/// `PerlinSimplexNoise(random, octaves)`: one simplex noise per octave, the highest octave first.
pub(crate) struct PerlinSimplex {
levels: Vec<Option<SimplexNoise>>,
input_factor: f64,
value_factor: f64,
}
impl PerlinSimplex {
/// The construction of `PerlinSimplexNoise(new LegacyRandomSource(seed), octaves)`: octave 0 takes
/// the first noise of `seed`'s random; the positive octaves come from a random seeded by that
/// noise's value at its own offsets, the negative ones from `seed`'s random after it.
pub(crate) fn new(seed: i64, octaves: &[i32]) -> Self {
let mut random = LegacyRandom::new(seed);
let lowest = octaves.iter().copied().min().unwrap_or(0);
let highest = octaves.iter().copied().max().unwrap_or(0);
let count = (highest - lowest + 1) as usize;
let first = SimplexNoise::new(&mut random);
let top = highest;
let wanted = |index: i32| octaves.contains(&(top - index));
let mut levels: Vec<Option<SimplexNoise>> = (0..count).map(|_| None).collect();
for index in top + 1..count as i32 {
if index >= 0 && wanted(index) {
levels[index as usize] = Some(SimplexNoise::new(&mut random));
} else {
random.consume_count(262);
}
}
if highest > 0 {
// `(long)(getValue(xo, yo, zo) * 9.223372E18F)`: the noise at its own offsets, which
// 26.3's get3 reads at (0, 0, 0). The float constant is 2^63.
let value = first.get3_f64(0.0, 0.0, 0.0) * 9.223_372e18_f32 as f64;
let mut upper = LegacyRandom::new(value as i64);
for index in (0..top).rev() {
if (index as usize) < count && wanted(index) {
levels[index as usize] = Some(SimplexNoise::new(&mut upper));
} else {
upper.consume_count(262);
}
}
}
if (0..count as i32).contains(&top) && octaves.contains(&0) {
levels[top as usize] = Some(first);
}
Self {
levels,
input_factor: 2f64.powi(highest),
value_factor: 1.0 / (2f64.powi(count as i32) - 1.0),
}
}
/// `getValue(x, y, true)`: each octave read with its offsets, which 26.3's simplex adds itself.
pub(crate) fn get(&self, x: f64, y: f64) -> f64 {
let (mut input, mut value, mut sum) = (self.input_factor, self.value_factor, 0.0);
for level in &self.levels {
if let Some(noise) = level {
sum += noise.get2_f64(x * input, y * input) * value;
}
input /= 2.0;
value *= 2.0;
}
sum
}
}
/// The built-in `r.noise(x, y)`: seed 0, octaves `{1}`.
pub(crate) fn builtin() -> &'static PerlinSimplex {
static NOISE: std::sync::OnceLock<PerlinSimplex> = std::sync::OnceLock::new();
NOISE.get_or_init(|| PerlinSimplex::new(0, &[1]))
}
#[cfg(test)]
mod tests {
use super::*;
/// `r.noise(x/8, z/8)` read on the real mod (Polytone 26.3-6.8.6): a grass colormap whose pixel
/// encodes the value, decoded to about 0.01 (polytone-work/rnoise in the measurement notes).
#[test]
fn the_builtin_noise_reads_what_the_mod_draws() {
for (x, z, mod_value) in [
(0, 10, -0.28373),
(3, 10, -0.14683),
(10, 0, 0.31833),
(24, 14, -0.55941),
(40, 25, -0.48358),
(7, 3, -0.68202),
] {
let value = builtin().get(f64::from(x) / 8.0, f64::from(z) / 8.0);
assert!(
(value - mod_value).abs() < 0.02,
"({x}, {z}): {value} against {mod_value}"
);
}
}
/// The octaves add as `PerlinSimplexNoise`'s do: `{0, 1}` is a third of `{1}` and two thirds of
/// `{0}` (measured: 0.067 = 0.33 x -0.284 + 0.67 x 0.247 at (0, 10) / 8).
#[test]
fn two_octaves_weigh_a_third_and_two_thirds() {
let both = PerlinSimplex::new(0, &[0, 1]);
let (low, high) = (PerlinSimplex::new(0, &[0]), PerlinSimplex::new(0, &[1]));
for (x, y) in [(0.0, 1.25), (2.5, -3.0), (17.1, 4.4)] {
let mixed = high.get(x, y) / 3.0 + low.get(x, y) * 2.0 / 3.0;
assert!((both.get(x, y) - mixed).abs() < 1e-12);
}
assert!((both.get(0.0, 1.25) - 0.067).abs() < 0.02);
}
}
+190 -101
View File
@@ -60,6 +60,7 @@ mod entity_outline;
mod fluid;
#[cfg(test)]
mod inter_stage_tests;
mod section_fade;
mod terrain_model;
#[cfg(test)]
pub(crate) mod test_gpu;
@@ -74,7 +75,10 @@ pub use entity_batch::{
WATER_MASK_OVERLAY, WORLD_TEXT_OVERLAY, WORLD_TEXT_POLYGON_OFFSET_OVERLAY, is_native_terrain,
mark_oit,
};
use external::{ShadowCull, shadow_box, shadow_casters, shadow_runs};
use section_fade::{
CHUNK_VISIBILITY_THRESHOLD, FadeInputs, chunk_visibility, fade_millis, section_instance,
section_visibility,
};
pub use view::{View, ViewSet};
/// Window.getAppropriateLineWidth, in framebuffer pixels.
@@ -190,7 +194,6 @@ mod cull;
mod dx12_compiler;
mod end_sky;
pub mod entity_skin;
pub mod external;
mod flat;
mod frame_gen;
mod gpu_culling;
@@ -367,62 +370,6 @@ fn pack_terrain_camera(camera: DVec3) -> DVec3 {
let whole = camera.floor();
whole + ((camera - whole) * 65536.0).round() / 65536.0
}
/// A section's instance: its camera-relative origin and its UV step, negated
/// past its fade code while it fades in over far terrain (`lod::instance`).
fn section_instance(origin: [f32; 3], fade: u32, visibility: f32) -> [f32; 4] {
let step = 1.0 / crate::mesher::UV_STEPS;
[
origin[0],
origin[1],
origin[2],
if fade == 0 {
// The step is a power of two, so its mantissa is free: it holds the vanilla
// chunk fade-in's `1 - visibility` in 23 bits (terrain.wgsl's `instance_step` and
// `instance_visibility` read it back). A whole section is the bare step.
let faded = ((1.0 - visibility) * 8_388_608.0)
.round()
.clamp(0.0, 8_388_607.0) as u32;
f32::from_bits(step.to_bits() | faded)
} else {
-(fade as f32 + step)
},
]
}
/// `SectionRenderDispatcher.RenderSection.getVisibility`: how far through its fade-in a section
/// uploaded `elapsed_ms` ago is.
fn chunk_visibility(elapsed_ms: u64, fade_ms: u64) -> f32 {
if elapsed_ms >= fade_ms {
1.0
} else {
elapsed_ms as f32 / fade_ms as f32
}
}
/// `Util.toMillis`: `Mth.floor(seconds * 1000.0)`.
fn fade_millis(seconds: f32) -> u64 {
(f64::from(seconds) * 1000.0).floor().max(0.0) as u64
}
/// `chunk_visibility` at `now` of a section first uploaded at `uploaded`
/// (`Renderer::section_uploaded`; none: one that fades in over far terrain, or
/// whose column left), with the chunk fade-in `fade_ms`.
fn section_visibility(uploaded: Option<&Instant>, fade_ms: u64, now: Instant) -> f32 {
uploaded.map_or(1.0, |&t| {
let elapsed = now.saturating_duration_since(t).as_millis() as u64;
chunk_visibility(elapsed, fade_ms)
})
}
/// What a section's fade depends on besides the section itself
/// (`SectionGpu::drawn_faded`): the time, the chunk fade-in time, the sections'
/// first uploads (`Renderer::section_uploaded`) and those held while far
/// terrain comes in (`Renderer::lod_retiring`).
#[derive(Clone, Copy)]
struct FadeInputs<'a> {
now: Instant,
fade_ms: u64,
uploaded: &'a FxHashMap<SectionKey, Instant>,
retiring: &'a crate::fast_hash::FxHashSet<SectionKey>,
}
/// `LevelRenderer.CHUNK_VISIBILITY_THRESHOLD`: a section's block entities wait for it.
const CHUNK_VISIBILITY_THRESHOLD: f32 = 0.3;
/// A new value for `Renderer::block_entity_revision`.
fn next_block_entity_revision() -> u64 {
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
@@ -1178,6 +1125,8 @@ pub struct Renderer {
/// Sections upload their solid quads in the compact layout (`mesher::packed`): the solid
/// passes read the arena themselves (`flat`) and no shader pack reads it as vertices.
packed_quads: bool,
/// The compact layout is allowed (`HORIZON_PACKED_QUADS`, `set_packed_layout`).
packed_on: bool,
/// The visibility buffer for flat frames' solid terrain (`visbuffer`,
/// `HORIZON_VISBUFFER=1`).
visbuf: Option<visbuffer::Visbuffer>,
@@ -1420,10 +1369,8 @@ pub struct Renderer {
/// Per-frame scratch, kept to avoid allocating every frame.
visible: Vec<resident::Visible>,
visible_lods: Vec<lod::Visible>,
/// The sections a shader pack's shadow camera sees (`external::ShadowView`).
/// The sections a shader pack's shadow camera sees (`pack_post::ShadowView`).
shadow_visible: Vec<resident::Visible>,
/// The external shader pipeline world frames call (`external`); `None` draws vanilla's way.
external: Option<Box<dyn external::ExternalPipeline>>,
translucent_draws: Vec<(u64, usize, [u32; 5], (u32, Option<u32>))>,
origins: Vec<[f32; 4]>,
/// `DrawIndexedIndirectArgs` words: index count, instance count, first
@@ -4957,8 +4904,8 @@ impl Renderer {
}
None => (solid, solid_opaque, solid_cutout, solid_mirror),
};
// The solid quads in the compact layout, while no shader pack reads the arena
// (`follow_packed_layout`).
// The solid quads in the compact layout where every pass reads it; the first world
// frames settle it for the picked pack (`follow_packed_layout`).
let packed_quads = flat.is_some() && !shader_pack && packed_wanted();
packed::pack_quads(packed_quads);
// A native mod's world warp builds its pipelines with these layouts once it is given one.
@@ -5126,6 +5073,7 @@ impl Renderer {
world_warps,
flat_on: flat.is_some(),
packed_quads,
packed_on: packed_wanted(),
cull_on: cull.is_some(),
sides_on: true,
gpu_culling: None,
@@ -5294,7 +5242,6 @@ impl Renderer {
solid_runs: page_runs::PageRuns::default(),
phase2_runs: page_runs::PageRuns::default(),
shadow_runs: page_runs::PageRuns::default(),
external: None,
block_entities: Default::default(),
block_entity_section_revisions: Default::default(),
block_entity_membership_revisions: Default::default(),
@@ -5678,9 +5625,6 @@ impl Renderer {
self.offscreen = Some(Self::offscreen_target(&self.device, &self.config));
}
self.depth = Self::depth(&self.device, &self.config);
if let Some(pipeline) = &mut self.external {
pipeline.resize([width, height]);
}
}
/// Whether sections are drawn as one index list per frame (`flat`).
pub fn flat_draws(&self) -> bool {
@@ -6766,13 +6710,17 @@ impl Renderer {
flat.set_translucent_page_vertices(arena.page_vertices());
}
}
/// Uploads solid quads in the compact layout (`packed_quads`) while no shader pack runs:
/// a pack's terrain and shadow programs, and Horizon's own packs' stages, read the
/// arena's vertices as `TerrainVertex`es. Leaving it drops every section and far tile
/// for the game to mesh again, as `follow_translucent_layout` does.
/// Uploads solid quads in the compact layout (`packed_quads`) unless a pack reads the
/// arena's vertices as `TerrainVertex`es: Horizon's own packs' stages, and a `shaders/`
/// pack's terrain and shadow programs outside flat draws (flat ones unpack the records,
/// `pack_atlas::PULL_FLAT`). Leaving it drops every section and far tile for the game to
/// mesh again, as `follow_translucent_layout` does.
fn follow_packed_layout(&mut self) {
let pack = self.pack_post.as_ref().and_then(|p| p.1.as_ref()).is_some();
let want = self.flat.is_some() && !pack && self.native.is_none() && packed_wanted();
let want = self.flat.is_some()
&& (!pack || self.pack_terrain_flat)
&& self.native.is_none()
&& self.packed_on;
if want == self.packed_quads {
return;
}
@@ -6783,6 +6731,10 @@ impl Renderer {
let waiting = self.hold.take_all();
self.place_held(waiting);
}
log::info!(
"Horizon: compact solid terrain {}",
if want { "on" } else { "off" }
);
self.packed_quads = want;
packed::pack_quads(want);
let laid_out = self.sections.values().any(|s| s.packed)
@@ -6792,6 +6744,17 @@ impl Renderer {
self.clear_sections(self.layout);
}
}
/// Allows the compact layout of solid quads or not, for A/B checks: every section and far
/// tile is meshed again in the layout that follows. Replies whether sections now use it.
pub fn set_packed_layout(&mut self, on: bool) -> bool {
if on != self.packed_on {
self.packed_on = on;
self.follow_packed_layout();
self.sections_dropped = true;
self.clear_sections(self.layout);
}
self.packed_quads
}
/// Whether every section and far tile was dropped since the last call, to be
/// laid out again (`follow_translucent_layout`): the game meshes them again,
/// as for a new renderer.
@@ -7234,7 +7197,8 @@ impl Renderer {
.as_ref()
.map_or([1, 1], |t| [t.width(), t.height()]),
terrain: self.pack_atlas.as_ref().map(|_| pack_post::Geometry {
buffers: if flat { &buffers[..1] } else { &buffers },
// Flat terrain programs pull their vertices (`pack_atlas::PULL_FLAT`).
buffers: if flat { &[] } else { &buffers },
table: self.flat.as_ref().filter(|_| flat).map(|f| &f.draw_layout),
depth_write: true,
atlas: None,
@@ -7297,19 +7261,12 @@ impl Renderer {
}
Ok(report)
}
/// Sets the external shader pipeline world frames call (`external`), or none.
pub fn set_external_pipeline(&mut self, pipeline: Option<Box<dyn external::ExternalPipeline>>) {
self.external = pipeline;
}
/// Makes the running shader pack, of either kind, forget what it kept from earlier frames:
/// the next frame draws as a freshly loaded pack's first. Nothing to do without a pack.
pub fn reset_shader_pack_history(&mut self) {
// A frame still on the submit thread uses the targets and readbacks replaced
// here.
self.land_frame();
if let Some(pipeline) = &mut self.external {
pipeline.reset_history();
}
if let Some((_, Some(post))) = &mut self.pack_post {
post.reset_history(&self.device);
}
@@ -7411,7 +7368,8 @@ impl Renderer {
lightmap,
gui_size,
terrain: terrain.then(|| pack_post::Geometry {
buffers: if flat { &buffers[..1] } else { &buffers },
// Flat terrain programs pull their vertices (`pack_atlas::PULL_FLAT`).
buffers: if flat { &[] } else { &buffers },
table: table.as_ref(),
depth_write: true,
atlas: None,
@@ -7576,7 +7534,8 @@ impl Renderer {
);
horizon_shaders::GeometryInputs {
inputs,
pull: pack_atlas::PULL.to_owned(),
// Flat terrain inputs pull their own vertex (`PULL_FLAT`, `gl_VertexIndex` → `hz_vertex_index`).
pull: String::new(),
index: "hz_indices[@]".to_owned(),
}
});
@@ -8644,13 +8603,6 @@ impl Renderer {
// A shader pack's shadow pass draws the sections in its camera's box, all of them (the
// occlusion graph follows the eye, not the light), within shadowDistanceRenderMul; for
// an Iris-format pack, only those whose shadow can fall into the camera's view.
let state = external::FrameState {
frame,
output: &output,
};
if let Some(pipeline) = self.external.as_mut().filter(|_| !frame.title) {
pipeline.begin_frame(&state);
}
let shadow_view = self
.pack_post
.as_ref()
@@ -8662,12 +8614,6 @@ impl Renderer {
let native = self.native.as_ref().filter(|_| !frame.title)?;
native.shadow.as_ref()?;
native.frame.shadow_view(frame)
})
.or_else(|| {
self.external
.as_ref()
.filter(|_| !frame.title)?
.shadow_request(&state)
});
// `LevelRenderer.extractSectionDrawGroups`' fade-in time, for the instances.
let fade_ms = fade_millis(frame.chunk_fade);
@@ -12799,6 +12745,61 @@ impl Cull for Frusta {
self.0.contains(min, max) || self.1.is_some_and(|f| f.contains(min, max))
}
}
/// A shader pack's shadow camera's box, and with `casters` only what can shade the camera's view
/// (`shadow_casters`): a box must meet all of them.
#[derive(Clone, Copy, Debug)]
pub struct ShadowCull {
pub light: Frustum,
pub casters: Option<[Frustum; 2]>,
/// `shadowDistance × shadowDistanceRenderMul`: only what lies within this many blocks of the
/// camera along each axis, a cube around it. Our probe's Iris 1.11.7 maps keep the sections
/// whose box, grown by `SHADOW_CASTER_MARGIN` as for the casters, meets that cube (#8464):
/// out to its corners, and a section 1 block past its side but not one 1.5 blocks past.
pub reach: Option<f32>,
}
impl ShadowCull {
/// Whether the camera-relative box `min..max`, grown by the margin, meets (`whole`: lies
/// within) `reach`'s cube.
fn within_reach(&self, min: Vec3, max: Vec3, whole: bool) -> bool {
let (min, max) = (min - SHADOW_CASTER_MARGIN, max + SHADOW_CASTER_MARGIN);
self.reach.is_none_or(|d| {
if whole {
min.cmpge(Vec3::splat(-d)).all() && max.cmple(Vec3::splat(d)).all()
} else {
max.cmpge(Vec3::splat(-d)).all() && min.cmple(Vec3::splat(d)).all()
}
})
}
}
/// How far past a section's 16 blocks the shadow pass looks for what can shade the view: our
/// probes' Iris 1.11.7 maps keep the sections whose box, grown by this much on every side, meets
/// `shadow_casters`' shape. Over 76 scenes at fields of view 30 to 110, Iris keeps every section
/// that needs up to 1.1224 blocks and drops every one that needs 1.1252 or more (#8464): one
/// growth, whatever the field of view, of 9/8 (a fit, not Iris's code). Without it the edges of
/// the wedge lose a column of terrain.
const SHADOW_CASTER_MARGIN: f32 = 1.125;
impl Cull for ShadowCull {
fn intersects(&self, min: Vec3, max: Vec3) -> bool {
let (grown_min, grown_max) = (min - SHADOW_CASTER_MARGIN, max + SHADOW_CASTER_MARGIN);
self.within_reach(min, max, false)
&& self.light.intersects(min, max)
&& self
.casters
.is_none_or(|c| c.iter().all(|f| f.intersects(grown_min, grown_max)))
}
fn contains(&self, min: Vec3, max: Vec3) -> bool {
let (grown_min, grown_max) = (min - SHADOW_CASTER_MARGIN, max + SHADOW_CASTER_MARGIN);
self.within_reach(min, max, true)
&& self.light.contains(min, max)
&& self
.casters
.is_none_or(|c| c.iter().all(|f| f.contains(grown_min, grown_max)))
}
/// Our probe's Iris 1.11.7 maps fit testing the casters' whole sections (a guess that matched).
fn tight(&self) -> bool {
self.casters.is_none()
}
}
/// The block entities of `sections` whose box, a block wider on every side, comes within the
/// 64 blocks block entities are drawn to (`Renderer::visible_block_entities_in_frustum`).
fn block_entities_within_reach<'a>(
@@ -12821,6 +12822,17 @@ fn block_entities_within_reach<'a>(
})
.flatten()
}
/// The shadow camera's box that the shadow pass draws terrain in: the sides of `view.cull`, and
/// its near and far planes only with `depth_planes`.
fn shadow_box(view: &pack_post::ShadowView) -> Frustum {
let mut planes = frustum(view.cull);
if !view.depth_planes {
// `frustum`'s faces at depth 0 and 1 take in everything.
planes[4] = Vec4::W;
planes[5] = Vec4::W;
}
Frustum { planes }
}
/// Camera-relative clip planes (`Frustum`).
#[derive(Clone, Copy, Debug)]
pub struct Frustum {
@@ -12944,6 +12956,21 @@ fn projection_center(view_proj: Mat4) -> Option<Vec3> {
let b = DVec3::new(rows[0].w, rows[1].w, rows[2].w);
Some((a.inverse() * -b).as_vec3())
}
/// The runs of a section's solid quads the shadow pass draws: every face within the unculled
/// distance, or everywhere for `every_face`; else the quads that can face the light.
fn shadow_runs(
sides: &Sides,
view: &pack_post::ShadowView,
origin: [f32; 3],
draw: impl FnMut(u32, u32),
) {
let unculled = view.unculled.is_some_and(|d| {
let o = Vec3::from(origin) + 8.0;
o.x.abs().max(o.z.abs()) <= d + 8.0
});
let toward = (!unculled && !view.every_face).then_some(view.light * 1.0e5);
facing_runs(sides, toward, origin, draw);
}
/// Calls `draw(first, quads)` for each run of a mesh's solid quads that can
/// face `eye` (`projection_center`), the mesh at the camera-relative
@@ -12970,6 +12997,68 @@ fn facing_runs_from(
None => sides.every(part, draw),
}
}
/// The camera's view `view_proj` (wgpu depth, reversed or not) stretched without end toward
/// `light` (a direction): the boxes whose shadow, cast away from the light, can fall into the
/// view. Its planes are the view's faces turned toward the light, and through each edge between
/// such a face and one turned away, the plane along the light; unused ones take in everything.
fn shadow_casters(view_proj: Mat4, light: Vec3) -> [Frustum; 2] {
let faces = frustum(view_proj);
let toward = faces.map(|p| p.truncate().dot(light) >= 0.0);
let inverse = view_proj.inverse();
// Corner (x, y, z) of clip space's box; `frustum`'s faces at x -1 and 1 are 0 and 1, at
// y -1 and 1 are 2 and 3, at depth 0 and 1 are 4 and 5.
let corner = |c: [usize; 3]| {
inverse.project_point3(Vec3::new(
c[0] as f32 * 2.0 - 1.0,
c[1] as f32 * 2.0 - 1.0,
c[2] as f32,
))
};
let corners: Vec<[usize; 3]> = (0..8).map(|i| [i & 1, (i >> 1) & 1, i >> 2]).collect();
let centre = corners.iter().map(|&c| corner(c)).sum::<Vec3>() / 8.0;
let mut planes = [Vec4::W; 12];
let mut n = 0;
for (k, &face) in faces.iter().enumerate() {
if toward[k] {
planes[n] = face;
n += 1;
}
}
for a in &corners {
for axis in 0..3 {
if a[axis] == 1 {
continue;
}
let mut b = *a;
b[axis] = 1;
// The edge from a to b lies on the faces of the two other axes.
let [f, g] = [(axis + 1) % 3, (axis + 2) % 3].map(|o| o * 2 + a[o]);
if toward[f] == toward[g] {
continue;
}
let (p, q) = (corner(*a), corner(b));
let normal = (q - p).cross(light);
if normal.length_squared() < 1e-12 {
continue;
}
let normal = normal.normalize();
let side = if normal.dot(centre - p) < 0.0 {
-1.0
} else {
1.0
};
// At most six such edges with at most four faces toward the light: 12 planes.
if let Some(plane) = planes.get_mut(n) {
*plane = (normal * side).extend(-normal.dot(p) * side);
n += 1;
}
}
}
let [a, b] = [0, 6].map(|i| Frustum {
planes: planes[i..i + 6].try_into().expect("six planes"),
});
[a, b]
}
fn taa_frustum(view_proj: Mat4, rendered_size: [u32; 2]) -> Frustum {
// TAA shifts clip x/y by at most half a rendered pixel times clip w.
// Widen x/y before extracting the planes: this includes every such shifted
@@ -15191,8 +15280,8 @@ fn log_os_reported(reported: Option<crate::graphics::Reported>) {
});
}
/// What the OS reports in absolute nits, HDR output's scale (Windows: DXGI's SDR white level,
/// the brightness slider's, as Paper White, and its `MaxLuminance`, mostly the display's EDID
/// guess, as Peak Brightness), kept within the settings' ranges. The player's values win.
/// the brightness slider's, as Paper White, and its `MaxLuminance`, the display's or a calibration
/// profile's, as Peak Brightness), kept within the settings' ranges. The player's values win.
fn os_reported(info: &wgpu::DisplayHdrInfo) -> Option<crate::graphics::Reported> {
let luminance = info.luminance?;
let usable = |n: f32| n.is_finite() && n > 0.0;
@@ -15268,7 +15357,7 @@ mod shadow_face_tests {
}
/// The layers (faces) of the quads the shadow pass draws of `solid`.
fn drawn(view: &external::ShadowView, origin: [f32; 3]) -> Vec<u16> {
fn drawn(view: &pack_post::ShadowView, origin: [f32; 3]) -> Vec<u16> {
let mut solid = block([7.0, 7.0, 7.0]);
let sides = crate::mesher::sort_tinted(&mut solid, &mut Vec::new());
let mut layers = Vec::new();
@@ -15281,8 +15370,8 @@ mod shadow_face_tests {
layers
}
fn view(every_face: bool) -> external::ShadowView {
external::ShadowView {
fn view(every_face: bool) -> pack_post::ShadowView {
pack_post::ShadowView {
cull: Mat4::IDENTITY,
light: Vec3::Y,
distance: None,
@@ -15496,7 +15585,7 @@ mod shadow_caster_tests {
..horizon_shaders::Shadow::default()
};
let camera = DVec3::new(13.5, y + 1.62, -13.5);
let view = external::ShadowView {
let view = pack_post::ShadowView {
cull: pack_uniforms::shadow_culling_at(Vec3::Y, camera, &shadow),
light: Vec3::Y,
distance: None,
@@ -1,214 +0,0 @@
//! The hook an external shader pipeline fills (#14035, layer plan M6). A shader pack's runtime
//! (a platform crate, later) implements [`ExternalPipeline`]; the renderer calls it at fixed
//! points of a world frame and draws vanilla's way wherever it doesn't answer. With none set
//! (`Renderer::set_external_pipeline`), each point is one `Option` test.
//!
//! Workspace-internal: the trait names the engine's frame, its mesh format and its pass order,
//! has one implementer in this workspace, carries no stability promise and is never exposed to
//! mods. A mesher or pass change updates its implementer in the same PR. HDR output
//! (`output.rs`, `frame_policy`) is not an implementer: it stays the one output stage after the
//! frame, and a pipeline draws into the frame it gives.
//!
//! Shadows are an engine frame point, on request: a pipeline that wants a shadow pass answers
//! [`ShadowView`], the engine culls the sections from it ([`ShadowCull`]) and hands back the
//! terrain seen from the light. Vanilla's path never asks, so it never computes shadow
//! visibility.
use glam::{Mat4, Vec3, Vec4};
use super::{Cull, EffectiveOutput, Frame, Frustum, Sides, facing_runs, frustum};
/// What a world frame gives an external pipeline at its points: the frame's own values, and
/// the display's from the renderer's one output snapshot (`EffectiveOutput`: whether HDR is on,
/// paper white, peak), never worked out again, so each split-screen window keeps its own.
pub struct FrameState<'a> {
pub frame: &'a Frame<'a>,
pub output: &'a EffectiveOutput,
}
/// An external shader pipeline, called by the renderer at fixed points of a world frame (not
/// title frames). Every method has a default that leaves vanilla's way in place.
pub trait ExternalPipeline: Send {
/// A world frame starts, before culling.
fn begin_frame(&mut self, _state: &FrameState<'_>) {}
/// The shadow pass this frame wants; `None` draws none.
fn shadow_request(&self, _state: &FrameState<'_>) -> Option<ShadowView> {
None
}
/// The window's drawable size changed.
fn resize(&mut self, _size: [u32; 2]) {}
/// Forget what was kept from earlier frames: the next is drawn as a fresh pipeline's first.
fn reset_history(&mut self) {}
}
/// What the shadow camera sees of the terrain, for the game's draw lists: its view and
/// projection in wgpu's clip space, the direction toward the light, and how far from the camera
/// terrain is drawn (`shadowDistanceRenderMul`).
pub struct ShadowView {
pub cull: glam::Mat4,
pub light: glam::Vec3,
pub distance: Option<f32>,
/// Draw everything, every face, within this many blocks (`shadow.culling=false|reversed`):
/// packs that voxelize the world in their shadow pass need all of it.
pub unculled: Option<f32>,
/// Every face of the drawn sections, not only those that can face the light. Iris 1.11.7's
/// shadow map holds the faces turned away from the light too (#9033): under a hill at low sun
/// a cave's ceiling, which faces away, is often all that shadows the cave.
pub every_face: bool,
/// Only the sections whose shadow can fall into the camera's view: those the camera's
/// frustum, stretched toward the light, takes in (`shadow_casters`). Our probes' Iris 1.11.7
/// maps fit keeping only those with `shadow.culling` unset (#8464): under a noon sun, only
/// the terrain above the ground the camera sees; with a low sun ahead, none behind the camera.
pub view_casters: bool,
/// Whether the projection's near and far planes in `cull` drop sections too. Our probes'
/// Iris 1.11.7 maps hold the terrain at every depth we put it along the light (from 535 blocks
/// beyond the shadow camera to about 120 before it), whatever `shadowNearPlane` and
/// `shadowFarPlane` the pack sets, or none (#10403): only its sides cull.
pub depth_planes: bool,
}
/// A shader pack's shadow camera's box, and with `casters` only what can shade the camera's view
/// (`shadow_casters`): a box must meet all of them.
#[derive(Clone, Copy, Debug)]
pub struct ShadowCull {
pub light: Frustum,
pub casters: Option<[Frustum; 2]>,
/// `shadowDistance × shadowDistanceRenderMul`: only what lies within this many blocks of the
/// camera along each axis, a cube around it. Our probe's Iris 1.11.7 maps keep the sections
/// whose box, grown by `SHADOW_CASTER_MARGIN` as for the casters, meets that cube (#8464):
/// out to its corners, and a section 1 block past its side but not one 1.5 blocks past.
pub reach: Option<f32>,
}
impl ShadowCull {
/// Whether the camera-relative box `min..max`, grown by the margin, meets (`whole`: lies
/// within) `reach`'s cube.
fn within_reach(&self, min: Vec3, max: Vec3, whole: bool) -> bool {
let (min, max) = (min - SHADOW_CASTER_MARGIN, max + SHADOW_CASTER_MARGIN);
self.reach.is_none_or(|d| {
if whole {
min.cmpge(Vec3::splat(-d)).all() && max.cmple(Vec3::splat(d)).all()
} else {
max.cmpge(Vec3::splat(-d)).all() && min.cmple(Vec3::splat(d)).all()
}
})
}
}
/// How far past a section's 16 blocks the shadow pass looks for what can shade the view: our
/// probes' Iris 1.11.7 maps keep the sections whose box, grown by this much on every side, meets
/// `shadow_casters`' shape. Over 76 scenes at fields of view 30 to 110, Iris keeps every section
/// that needs up to 1.1224 blocks and drops every one that needs 1.1252 or more (#8464): one
/// growth, whatever the field of view, of 9/8 (a fit, not Iris's code). Without it the edges of
/// the wedge lose a column of terrain.
const SHADOW_CASTER_MARGIN: f32 = 1.125;
impl Cull for ShadowCull {
fn intersects(&self, min: Vec3, max: Vec3) -> bool {
let (grown_min, grown_max) = (min - SHADOW_CASTER_MARGIN, max + SHADOW_CASTER_MARGIN);
self.within_reach(min, max, false)
&& self.light.intersects(min, max)
&& self
.casters
.is_none_or(|c| c.iter().all(|f| f.intersects(grown_min, grown_max)))
}
fn contains(&self, min: Vec3, max: Vec3) -> bool {
let (grown_min, grown_max) = (min - SHADOW_CASTER_MARGIN, max + SHADOW_CASTER_MARGIN);
self.within_reach(min, max, true)
&& self.light.contains(min, max)
&& self
.casters
.is_none_or(|c| c.iter().all(|f| f.contains(grown_min, grown_max)))
}
/// Our probe's Iris 1.11.7 maps fit testing the casters' whole sections (a guess that matched).
fn tight(&self) -> bool {
self.casters.is_none()
}
}
/// The shadow camera's box that the shadow pass draws terrain in: the sides of `view.cull`, and
/// its near and far planes only with `depth_planes`.
pub(super) fn shadow_box(view: &ShadowView) -> Frustum {
let mut planes = frustum(view.cull);
if !view.depth_planes {
// `frustum`'s faces at depth 0 and 1 take in everything.
planes[4] = Vec4::W;
planes[5] = Vec4::W;
}
Frustum { planes }
}
/// The runs of a section's solid quads the shadow pass draws: every face within the unculled
/// distance, or everywhere for `every_face`; else the quads that can face the light.
pub(super) fn shadow_runs(
sides: &Sides,
view: &ShadowView,
origin: [f32; 3],
draw: impl FnMut(u32, u32),
) {
let unculled = view.unculled.is_some_and(|d| {
let o = Vec3::from(origin) + 8.0;
o.x.abs().max(o.z.abs()) <= d + 8.0
});
let toward = (!unculled && !view.every_face).then_some(view.light * 1.0e5);
facing_runs(sides, toward, origin, draw);
}
/// The camera's view `view_proj` (wgpu depth, reversed or not) stretched without end toward
/// `light` (a direction): the boxes whose shadow, cast away from the light, can fall into the
/// view. Its planes are the view's faces turned toward the light, and through each edge between
/// such a face and one turned away, the plane along the light; unused ones take in everything.
pub(super) fn shadow_casters(view_proj: Mat4, light: Vec3) -> [Frustum; 2] {
let faces = frustum(view_proj);
let toward = faces.map(|p| p.truncate().dot(light) >= 0.0);
let inverse = view_proj.inverse();
// Corner (x, y, z) of clip space's box; `frustum`'s faces at x -1 and 1 are 0 and 1, at
// y -1 and 1 are 2 and 3, at depth 0 and 1 are 4 and 5.
let corner = |c: [usize; 3]| {
inverse.project_point3(Vec3::new(
c[0] as f32 * 2.0 - 1.0,
c[1] as f32 * 2.0 - 1.0,
c[2] as f32,
))
};
let corners: Vec<[usize; 3]> = (0..8).map(|i| [i & 1, (i >> 1) & 1, i >> 2]).collect();
let centre = corners.iter().map(|&c| corner(c)).sum::<Vec3>() / 8.0;
let mut planes = [Vec4::W; 12];
let mut n = 0;
for (k, &face) in faces.iter().enumerate() {
if toward[k] {
planes[n] = face;
n += 1;
}
}
for a in &corners {
for axis in 0..3 {
if a[axis] == 1 {
continue;
}
let mut b = *a;
b[axis] = 1;
// The edge from a to b lies on the faces of the two other axes.
let [f, g] = [(axis + 1) % 3, (axis + 2) % 3].map(|o| o * 2 + a[o]);
if toward[f] == toward[g] {
continue;
}
let (p, q) = (corner(*a), corner(b));
let normal = (q - p).cross(light);
if normal.length_squared() < 1e-12 {
continue;
}
let normal = normal.normalize();
let side = if normal.dot(centre - p) < 0.0 {
-1.0
} else {
1.0
};
// At most six such edges with at most four faces toward the light: 12 planes.
if let Some(plane) = planes.get_mut(n) {
*plane = (normal * side).extend(-normal.dot(p) * side);
n += 1;
}
}
}
let [a, b] = [0, 6].map(|i| Frustum {
planes: planes[i..i + 6].try_into().expect("six planes"),
});
[a, b]
}
@@ -548,27 +548,107 @@ impl PackAtlas {
}
}
/// A `TerrainVertex` of flat draws' arena page (`hz_vertices`, six words each) into the vertex
/// inputs, for vertex `hz_vertex_index`.
pub const PULL: &str = " uint hz_pw = hz_vertex_index * 6u;\n \
hz_v_pos = uintBitsToFloat(uvec3(hz_vertices[hz_pw], hz_vertices[hz_pw + 1u], hz_vertices[hz_pw + 2u]));\n \
hz_v_uv = uvec2(hz_vertices[hz_pw + 3u] & 65535u, hz_vertices[hz_pw + 3u] >> 16u);\n \
hz_v_layer = hz_vertices[hz_pw + 4u] & 65535u;\n \
uint hz_pl = hz_vertices[hz_pw + 4u] >> 16u;\n \
hz_v_light = uvec2(hz_pl & 255u, hz_pl >> 8u);\n \
hz_v_color = unpackUnorm4x8(hz_vertices[hz_pw + 5u]);\n";
/// GLSL: terrain.wgsl's `section_corner` over the arena page (`hz_vertices`), by quad: a
/// vertex's words as `TerrainVertex` holds them (position, then UV, layer with light, colour),
/// from vertices below `PACKED_BASE` or a compact record (`mesher/packed.rs`) past it. Records
/// give back their quad's vertices bit for bit, so packs read either layout alike.
/// `hz_arena_corner`: a vertex's position and UV words; `hz_record_corner`: corner `k`'s of the
/// record `lo`, `hi`; `hz_record_rest`: its layer-with-light and colour words.
const CORNER_FN: &str = "void hz_arena_corner(uint hz_av, out uvec3 hz_p, out uint hz_uv) {\n \
uint hz_w = hz_av * 6u;\n \
hz_p = uvec3(hz_vertices[hz_w], hz_vertices[hz_w + 1u], hz_vertices[hz_w + 2u]);\n \
hz_uv = hz_vertices[hz_w + 3u];\n\
}\n\
uint hz_corner_offset(uint hz_rel, uint hz_side, uint hz_du, uint hz_dw) {\n \
return hz_rel == 0u ? hz_side : (hz_rel == 1u ? hz_du : hz_dw);\n\
}\n\
void hz_record_corner(uvec3 hz_lo, uvec3 hz_hi, uint hz_k, out uvec3 hz_p, out uint hz_uv) {\n \
uint hz_o = (hz_hi.z >> 12u) & 0x3ffffu;\n \
uvec3 hz_block = uvec3(hz_o & 15u, (hz_o >> 4u) & 15u, (hz_o >> 8u) & 15u);\n \
uint hz_axis = (hz_o >> 12u) & 3u;\n \
uint hz_side = (hz_o >> 14u) & 1u;\n \
uint hz_step = (((hz_o >> 15u) & 3u) + (((hz_o >> 17u) & 1u) == 1u ? 4u - hz_k : hz_k)) & 3u;\n \
uint hz_du = (hz_step == 1u || hz_step == 2u) ? 1u : 0u;\n \
uint hz_dw = hz_step >= 2u ? 1u : 0u;\n \
uvec3 hz_pos = hz_block + uvec3(hz_corner_offset((3u - hz_axis) % 3u, hz_side, hz_du, hz_dw),\n \
hz_corner_offset((4u - hz_axis) % 3u, hz_side, hz_du, hz_dw),\n \
hz_corner_offset((5u - hz_axis) % 3u, hz_side, hz_du, hz_dw));\n \
uint hz_sel = hz_lo.z >> 24u;\n \
uint hz_u = (hz_lo.z >> (6u * ((hz_sel >> (2u * hz_k)) & 1u))) & 63u;\n \
uint hz_v = (hz_lo.z >> (12u + 6u * ((hz_sel >> (2u * hz_k + 1u)) & 1u))) & 63u;\n \
hz_p = floatBitsToUint(vec3(hz_pos));\n \
hz_uv = (hz_u << 10u) | (hz_v << 26u);\n\
}\n\
uvec2 hz_record_rest(uvec3 hz_lo, uvec3 hz_hi, uint hz_k) {\n \
uint hz_l = hz_k == 0u ? hz_hi.x >> 16u : (hz_k == 1u ? hz_hi.y : (hz_k == 2u ? hz_hi.y >> 12u : hz_hi.z));\n \
hz_l &= 0xfffu;\n \
uint hz_pl = ((hz_l & 63u) << 2u) | (((hz_l >> 6u) & 63u) << 10u);\n \
uint hz_g = (hz_lo.y >> (8u * hz_k)) & 255u;\n \
uint hz_t = hz_lo.x;\n \
uint hz_code = hz_k == 1u ? (hz_hi.x >> 28u) & 7u : (hz_k == 2u ? (hz_hi.y >> 24u) & 7u\n \
: (hz_k == 3u ? (hz_hi.y >> 27u) & 7u : 0u));\n \
uint hz_alpha = hz_code == 0u ? hz_t & 0xff000000u : (hz_code * 51u) << 24u;\n \
uint hz_c = (hz_g * ((hz_t >> 16u) & 255u) / 255u) | ((hz_g * ((hz_t >> 8u) & 255u) / 255u) << 8u)\n \
| ((hz_g * (hz_t & 255u) / 255u) << 16u) | hz_alpha;\n \
return uvec2((hz_hi.x & 0xffffu) | (hz_pl << 16u), hz_c);\n\
}\n";
/// GLSL: a flat-drawn vertex's inputs, and its quad's four corners' positions (`hz_cp0`..) and
/// UV words (`hz_uv0`..), pulled from the arena page as terrain.wgsl's `vs_section_flat` pulls
/// them (`CORNER_FN`): a record is read once for all four, and only the vertex's own light and
/// colour are worked out. `hz_slot`: the slot its block is found by (`hz_block_slots`, 256 a
/// block). The colour as the vertex fetch converts a Unorm8x4 (terrain.wgsl's `unorm8x4`).
const PULL_FLAT: &str = " uint hz_qv = uint(gl_VertexIndex);\n \
uint hz_own = hz_qv & 3u;\n \
uint hz_av = hz_qv & ~3u;\n \
uint hz_slot = hz_qv;\n \
bool hz_rec = false;\n \
uvec3 hz_lo = uvec3(0u);\n \
uvec3 hz_hi = uvec3(0u);\n \
if (hz_av >= 0x10000000u) {\n \
hz_slot = (hz_av - 0x10000000u) >> 2u;\n \
uint hz_rw = hz_slot * 6u;\n \
hz_lo = uvec3(hz_vertices[hz_rw], hz_vertices[hz_rw + 1u], hz_vertices[hz_rw + 2u]);\n \
hz_hi = uvec3(hz_vertices[hz_rw + 3u], hz_vertices[hz_rw + 4u], hz_vertices[hz_rw + 5u]);\n \
hz_rec = (hz_hi.z & 0x80000000u) == 0u;\n \
hz_av = hz_slot + hz_lo.x;\n \
}\n \
uvec3 hz_cp0; uvec3 hz_cp1; uvec3 hz_cp2; uvec3 hz_cp3;\n \
uint hz_uv0; uint hz_uv1; uint hz_uv2; uint hz_uv3;\n \
uvec2 hz_rest;\n \
if (hz_rec) {\n \
hz_record_corner(hz_lo, hz_hi, 0u, hz_cp0, hz_uv0);\n \
hz_record_corner(hz_lo, hz_hi, 1u, hz_cp1, hz_uv1);\n \
hz_record_corner(hz_lo, hz_hi, 2u, hz_cp2, hz_uv2);\n \
hz_record_corner(hz_lo, hz_hi, 3u, hz_cp3, hz_uv3);\n \
hz_rest = hz_record_rest(hz_lo, hz_hi, hz_own);\n \
} else {\n \
hz_arena_corner(hz_av, hz_cp0, hz_uv0);\n \
hz_arena_corner(hz_av + 1u, hz_cp1, hz_uv1);\n \
hz_arena_corner(hz_av + 2u, hz_cp2, hz_uv2);\n \
hz_arena_corner(hz_av + 3u, hz_cp3, hz_uv3);\n \
uint hz_ow = (hz_av + hz_own) * 6u;\n \
hz_rest = uvec2(hz_vertices[hz_ow + 4u], hz_vertices[hz_ow + 5u]);\n \
}\n \
uvec3 hz_vp = hz_own == 0u ? hz_cp0 : (hz_own == 1u ? hz_cp1 : (hz_own == 2u ? hz_cp2 : hz_cp3));\n \
uvec3 hz_vr = uvec3(hz_own == 0u ? hz_uv0 : (hz_own == 1u ? hz_uv1 : (hz_own == 2u ? hz_uv2 : hz_uv3)), hz_rest);\n \
hz_v_pos = uintBitsToFloat(hz_vp);\n \
hz_v_uv = uvec2(hz_vr.x & 65535u, hz_vr.x >> 16u);\n \
hz_v_layer = hz_vr.y & 65535u;\n \
hz_v_light = uvec2((hz_vr.y >> 16u) & 255u, hz_vr.y >> 24u);\n \
hz_v_color = vec4(float(hz_vr.z & 255u), float((hz_vr.z >> 8u) & 255u), float((hz_vr.z >> 16u) & 255u),\n \
float(hz_vr.z >> 24u)) * (1.0 / 255.0);\n";
/// GLSL: the face normal and tangent of a flat-drawn vertex, from its quad's first three corners
/// (quads are four consecutive vertices of the page; position words 0-2, UV word 3).
const FACE: &str = " uint hz_q = (uint(gl_VertexIndex) & ~3u) * 6u;\n \
vec3 hz_p0 = uintBitsToFloat(uvec3(hz_vertices[hz_q], hz_vertices[hz_q + 1u], hz_vertices[hz_q + 2u]));\n \
vec3 hz_p1 = uintBitsToFloat(uvec3(hz_vertices[hz_q + 6u], hz_vertices[hz_q + 7u], hz_vertices[hz_q + 8u]));\n \
vec3 hz_p2 = uintBitsToFloat(uvec3(hz_vertices[hz_q + 12u], hz_vertices[hz_q + 13u], hz_vertices[hz_q + 14u]));\n \
uvec3 hz_w = uvec3(hz_vertices[hz_q + 3u], hz_vertices[hz_q + 9u], hz_vertices[hz_q + 15u]);\n \
/// (`PULL_FLAT`'s corners: four consecutive vertex indices, in either layout).
const FACE: &str = " vec3 hz_p0 = uintBitsToFloat(hz_cp0);\n \
vec3 hz_p1 = uintBitsToFloat(hz_cp1);\n \
vec3 hz_p2 = uintBitsToFloat(hz_cp2);\n \
uvec3 hz_w = uvec3(hz_uv0, hz_uv1, hz_uv2);\n \
vec2 hz_t0 = vec2(hz_w.x & 65535u, hz_w.x >> 16u);\n \
vec2 hz_d1 = vec2(hz_w.y & 65535u, hz_w.y >> 16u) - hz_t0;\n \
vec2 hz_d2 = vec2(hz_w.z & 65535u, hz_w.z >> 16u) - hz_t0;\n \
uint hz_w3 = hz_vertices[hz_q + 21u];\n \
uint hz_w3 = hz_uv3;\n \
vec2 hz_t3 = vec2(hz_w3 & 65535u, hz_w3 >> 16u);\n \
hz_mid = (clamp(hz_t0 * hz_uv_scale, 0.0, 1.0) + clamp((hz_t0 + hz_d1) * hz_uv_scale, 0.0, 1.0)\n \
+ clamp((hz_t0 + hz_d2) * hz_uv_scale, 0.0, 1.0) + clamp(hz_t3 * hz_uv_scale, 0.0, 1.0)) * 0.25;\n \
@@ -620,11 +700,19 @@ impl PackAtlas {
/// The vertex inputs of a gbuffers program drawing terrain (`TerrainVertex`, terrain.wgsl's
/// `vs_terrain` or, for `flat` draws, `vs_terrain_flat`), in atlas coordinates.
pub fn terrain_inputs(&self, flat: bool) -> horizon_shaders::GbuffersInputs {
let mut declarations = String::from(
"layout(location = 0) in vec3 hz_v_pos;\nlayout(location = 1) in uvec2 hz_v_uv;\n\
layout(location = 2) in uint hz_v_layer;\nlayout(location = 3) in vec4 hz_v_color;\n\
layout(location = 4) in uvec2 hz_v_light;\n",
);
// Flat draws pull their vertices from the arena page (`PULL_FLAT`), which may hold
// solid quads in the compact layout; others read them as vertex attributes.
let mut declarations = if flat {
String::from(
"vec3 hz_v_pos; uvec2 hz_v_uv; uint hz_v_layer; vec4 hz_v_color; uvec2 hz_v_light;\n",
)
} else {
String::from(
"layout(location = 0) in vec3 hz_v_pos;\nlayout(location = 1) in uvec2 hz_v_uv;\n\
layout(location = 2) in uint hz_v_layer;\nlayout(location = 3) in vec4 hz_v_color;\n\
layout(location = 4) in uvec2 hz_v_light;\n",
)
};
let origin = if flat {
declarations.push_str(
"layout(set = 1, binding = 0) readonly buffer HzBlockSlots { uint hz_block_slots[]; };\n\
@@ -637,7 +725,8 @@ impl PackAtlas {
layout(set = 3, binding = 1) readonly buffer HzVertices { uint hz_vertices[]; };\n\
layout(set = 3, binding = 2) readonly buffer HzTerrainTables { int hz_tables[]; };\n",
);
"hz_slot_origins[hz_block_slots[uint(gl_VertexIndex) >> 8u]]"
declarations.push_str(CORNER_FN);
"hz_slot_origins[hz_block_slots[hz_slot >> 8u]]"
} else {
declarations.push_str("layout(location = 5) in vec4 hz_v_origin;\n");
"hz_v_origin"
@@ -700,6 +789,7 @@ impl PackAtlas {
);
let mut body = body;
if flat {
body.insert_str(0, PULL_FLAT);
body.push_str(FACE);
body.push_str(
" uint hz_quad = uint(gl_VertexIndex) / 4u;\n \
@@ -125,7 +125,7 @@ fn terrain_fade_reaches_pack_pixels_through_real_instances() {
terrain: Some(atlas.terrain_inputs(flat)),
terrain_geometry: geometry.then(|| horizon_shaders::GeometryInputs {
inputs: atlas.terrain_inputs(true),
pull: super::PULL.to_owned(),
pull: String::new(),
index: "@".to_owned(),
}),
..Default::default()
@@ -245,6 +245,135 @@ fn terrain_vertices_go_down_to_the_32768th_below_them() {
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
/// A shader pack's flat terrain inputs read a compact record (`mesher::packed`) as the four
/// vertices it holds: a whole tinted face with its own ambient occlusion per corner
/// (`separateAo`), two more, and a slab's top kept as its vertices (`packed::FULL`).
#[test]
fn compact_records_feed_a_pack_the_vertices_they_hold() {
use crate::mesher::packed;
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 16,
height: 16,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let mut encoder = device.create_command_encoder(&Default::default());
let mut atlas = PackAtlas::new(&device, &mut encoder, &[texture], 1, 1, 16).unwrap();
queue.submit([encoder.finish()]);
atlas.separate_ao = true;
let tint = 0x7F_B238u32;
let ao = [255u32, 153, 102, 204];
let face = |x: f32, y: f32| {
let pos = [
[x, y, 5.0],
[x, y, 6.0],
[x + 1.0, y, 6.0],
[x + 1.0, y, 5.0],
];
let uv = [[0, 0], [16 << 10, 0], [16 << 10, 16 << 10], [0, 16 << 10]];
(0..4).map(move |i| {
let [r, g, b] = [16, 8, 0].map(|s| 200 * (tint >> s & 255) / 255);
TerrainVertex {
pos: pos[i],
uv: uv[i],
layer: 0x8000,
light: 0xF0 | 0x40 << 8,
color: r | g << 8 | b << 16 | ao[i] << 24,
}
})
};
let solid: Vec<_> = [(3.0, 16.0), (5.0, 16.0), (7.0, 16.0), (3.0, 8.5)]
.into_iter()
.flat_map(|(x, y)| face(x, y))
.collect();
let records = packed::layout(&solid, &[tint; 4]);
assert_eq!(
packed::packed_quads(&records, 4),
3,
"whole faces pack, the slab doesn't"
);
let page = bytemuck::cast_slice(&records);
let packed_range = packed::PACKED_BASE..packed::PACKED_BASE + 16;
let origin = f32::from_bits((1. / UV_STEPS).to_bits());
let mut failures = Vec::new();
for expression in [
"gl_Vertex",
"gl_Color",
"vec4(gl_MultiTexCoord0.xy, gl_MultiTexCoord1.xy)",
"vec4(gl_Normal, 0.0)",
"at_tangent",
"at_midBlock",
"vec4(mc_midTexCoord, mc_Entity.xy)",
] {
for shadow in [false, true] {
let flags = [true, shadow];
let wide = sample_probe(&device, &queue, &atlas, &solid, origin, flags, expression);
let compact = sample_page(
&device,
&queue,
&atlas,
page,
packed_range.clone(),
origin,
flags,
expression,
);
let bits = |v: &[[f32; 4]]| v.iter().flatten().map(|f| f.to_bits()).collect::<Vec<_>>();
if bits(&wide) != bits(&compact) {
failures.push(format!(
"{expression} shadow={shadow}: {wide:?} != {compact:?}"
));
}
if expression == "gl_Color" {
for (corner, want) in ao.iter().enumerate() {
let got = compact[corner][3];
if (got - *want as f32 / 255.0).abs() > 1e-6 {
failures.push(format!("corner {corner}'s alpha {got} != {want}/255"));
}
}
}
}
}
// A pack's frames keep the translucent quads in the section arena after the solid range: three
// records and no `FULL` quad, then a water quad, whose corners must read as its own.
let mut page: Vec<[u32; 6]> = packed::layout(&solid[..12], &[tint; 3]);
let water: Vec<_> = face(9.0, 15.0).collect();
let at = page.len() as u32;
page.extend(water.iter().map(|v| bytemuck::cast::<_, [u32; 6]>(*v)));
for expression in ["vec4(gl_Normal, 0.0)", "at_tangent", "at_midBlock"] {
let flags = [true, false];
let wide = sample_probe(&device, &queue, &atlas, &water, origin, flags, expression);
let page = bytemuck::cast_slice(&page);
let after = sample_page(
&device,
&queue,
&atlas,
page,
at..at + 4,
origin,
flags,
expression,
);
if wide != after {
failures.push(format!("water {expression}: {wide:?} != {after:?}"));
}
}
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
fn sample(
device: &wgpu::Device,
queue: &wgpu::Queue,
@@ -271,18 +400,38 @@ fn sample_probe(
atlas: &PackAtlas,
vertices: &[TerrainVertex],
scale: f32,
flags: [bool; 2],
expression: &str,
) -> Vec<[f32; 4]> {
let page = bytemuck::cast_slice(vertices);
let drawn = 0..vertices.len() as u32;
sample_page(device, queue, atlas, page, drawn, scale, flags, expression)
}
/// `sample_probe` of the vertex indices `drawn` of an arena page holding `page`.
#[allow(clippy::too_many_arguments)]
fn sample_page(
device: &wgpu::Device,
queue: &wgpu::Queue,
atlas: &PackAtlas,
page: &[u8],
drawn: std::ops::Range<u32>,
scale: f32,
[flat, shadow]: [bool; 2],
expression: &str,
) -> Vec<[f32; 4]> {
let count = drawn.len() as u32;
let pp = |code| horizon_shaders::Preprocessed {
code,
..Default::default()
};
let vertex = pp(format!(
"attribute vec2 mc_midTexCoord;\nvarying vec4 probe;\nvoid main() {{\n\
"attribute vec2 mc_midTexCoord;\nattribute vec4 at_tangent;\n\
attribute vec4 at_midBlock;\nattribute vec4 mc_Entity;\nvarying vec4 probe;\nvoid main() {{\n\
probe = {expression};\n\
gl_Position = vec4((float(gl_VertexID) + 0.5) * {} - 1.0, 0.0, 0.0, 1.0);\n}}",
2. / vertices.len() as f32,
gl_Position = vec4((float(gl_VertexID - {}) + 0.5) * {} - 1.0, 0.0, 0.0, 1.0);\n}}",
drawn.start,
2. / count as f32,
));
// Integer attachments retain the float bits without requiring float-render-target
// capabilities on the adapter that the test runner picks.
@@ -369,7 +518,8 @@ fn sample_probe(
module: &vs,
entry_point: Some("main"),
compilation_options: Default::default(),
buffers: &vertex_layouts[..if flat { 1 } else { 2 }],
// Flat draws pull their vertices from the page (`PULL_FLAT`), as in the game.
buffers: if flat { &[] } else { &vertex_layouts[..] },
},
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::PointList,
@@ -408,7 +558,7 @@ fn sample_probe(
);
let ids = buffer(bytemuck::bytes_of(&[0u32; 2]), wgpu::BufferUsages::STORAGE);
let data = buffer(
bytemuck::cast_slice(vertices),
page,
wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::VERTEX,
);
let rects = buffer(
@@ -444,7 +594,7 @@ fn sample_probe(
let output = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: vertices.len() as u32,
width: count,
height: 1,
depth_or_array_layers: 1,
},
@@ -457,7 +607,7 @@ fn sample_probe(
});
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: vertices.len() as u64 * 16,
size: u64::from(count) * 16,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
@@ -481,11 +631,11 @@ fn sample_probe(
for (k, group) in groups.iter().enumerate() {
pass.set_bind_group(k as u32, group, &[]);
}
pass.set_vertex_buffer(0, data.slice(..));
if !flat {
pass.set_vertex_buffer(0, data.slice(..));
pass.set_vertex_buffer(1, origin.slice(..));
}
pass.draw(0..vertices.len() as u32, 0..1);
pass.draw(drawn, 0..1);
}
encoder.copy_texture_to_buffer(
output.as_image_copy(),
@@ -1201,7 +1201,31 @@ pub(super) type TerrainDraw<'a> = dyn FnMut(
Option<(u32, &wgpu::BindGroupLayout)>,
) + 'a;
pub(super) use super::external::ShadowView;
/// What the shadow camera sees of the terrain, for the game's draw lists: its view and
/// projection in wgpu's clip space, the direction toward the light, and how far from the camera
/// terrain is drawn (`shadowDistanceRenderMul`).
pub(super) struct ShadowView {
pub cull: glam::Mat4,
pub light: glam::Vec3,
pub distance: Option<f32>,
/// Draw everything, every face, within this many blocks (`shadow.culling=false|reversed`):
/// packs that voxelize the world in their shadow pass need all of it.
pub unculled: Option<f32>,
/// Every face of the drawn sections, not only those that can face the light. Iris 1.11.7's
/// shadow map holds the faces turned away from the light too (#9033): under a hill at low sun
/// a cave's ceiling, which faces away, is often all that shadows the cave.
pub every_face: bool,
/// Only the sections whose shadow can fall into the camera's view: those the camera's
/// frustum, stretched toward the light, takes in (`shadow_casters`). Our probes' Iris 1.11.7
/// maps fit keeping only those with `shadow.culling` unset (#8464): under a noon sun, only
/// the terrain above the ground the camera sees; with a low sun ahead, none behind the camera.
pub view_casters: bool,
/// Whether the projection's near and far planes in `cull` drop sections too. Our probes'
/// Iris 1.11.7 maps hold the terrain at every depth we put it along the light (from 535 blocks
/// beyond the shadow camera to about 120 before it), whatever `shadowNearPlane` and
/// `shadowFarPlane` the pack sets, or none (#10403): only its sides cull.
pub depth_planes: bool,
}
/// An arena page's key, native vertices and optional per-quad source records.
pub(super) type TerrainPage = (u32, wgpu::Buffer, Option<wgpu::Buffer>);
@@ -1137,6 +1137,10 @@ fn components(name: &str) -> usize {
| "endFlashPosition" => 3,
"eyeBrightness" | "eyeBrightnessSmooth" | "atlasSize" => 2,
"entityColor" => 4,
// Horizon's own vec4s, a dynamic light and half a boat's water patch box; their counts
// are ints.
n if n.starts_with("horizonDynamicLight") && n != "horizonDynamicLightCount" => 4,
n if n.starts_with("horizonWaterPatch") && n != "horizonWaterPatchCount" => 4,
n if n.starts_with("gbuffer") || n.starts_with("shadow") && n != "shadowAngle" => 16,
_ => 1,
}
@@ -1208,6 +1212,28 @@ mod tests {
(&matrix, "gbufferModelView", "mat4", true),
(&matrix, "gbufferModelView", "mat3", false),
(&Value::i(&[1]), GBUFFERS_FOG_MODE, "int", true),
// Horizon's own: a dynamic light and a water patch box half are vec4s, their counts
// ints.
(
&Value::f(&[1.0, 64.0, 2.0, 14.0]),
"horizonDynamicLight0",
"vec4",
true,
),
(
&Value::f(&[1.0, 64.0, 2.0, 14.0]),
"horizonDynamicLight0",
"float",
false,
),
(
&Value::f(&[0.5, -1.0, 62.0, 62.5]),
"horizonWaterPatch0",
"vec4",
true,
),
(&Value::i(&[3]), "horizonDynamicLightCount", "int", true),
(&Value::i(&[1]), "horizonWaterPatchCount", "int", true),
] {
assert_eq!(value.read_as(name, ty), kept, "{ty} {name}");
}
@@ -0,0 +1,60 @@
//! How far through vanilla's chunk fade-in a section is, and the instance that carries it to `terrain.wgsl`.
use crate::fast_hash::FxHashMap;
use std::time::Instant;
/// A section's instance: its camera-relative origin and its UV step, negated
/// past its fade code while it fades in over far terrain (`lod::instance`).
pub(super) fn section_instance(origin: [f32; 3], fade: u32, visibility: f32) -> [f32; 4] {
let step = 1.0 / crate::mesher::UV_STEPS;
[
origin[0],
origin[1],
origin[2],
if fade == 0 {
// The step is a power of two, so its mantissa is free: it holds the vanilla
// chunk fade-in's `1 - visibility` in 23 bits (terrain.wgsl's `instance_step` and
// `instance_visibility` read it back). A whole section is the bare step.
let faded = ((1.0 - visibility) * 8_388_608.0)
.round()
.clamp(0.0, 8_388_607.0) as u32;
f32::from_bits(step.to_bits() | faded)
} else {
-(fade as f32 + step)
},
]
}
/// `SectionRenderDispatcher.RenderSection.getVisibility`: how far through its fade-in a section
/// uploaded `elapsed_ms` ago is.
pub(super) fn chunk_visibility(elapsed_ms: u64, fade_ms: u64) -> f32 {
if elapsed_ms >= fade_ms {
1.0
} else {
elapsed_ms as f32 / fade_ms as f32
}
}
/// `Util.toMillis`: `Mth.floor(seconds * 1000.0)`.
pub(super) fn fade_millis(seconds: f32) -> u64 {
(f64::from(seconds) * 1000.0).floor().max(0.0) as u64
}
/// `chunk_visibility` at `now` of a section first uploaded at `uploaded`
/// (`Renderer::section_uploaded`; none: one that fades in over far terrain, or
/// whose column left), with the chunk fade-in `fade_ms`.
pub(super) fn section_visibility(uploaded: Option<&Instant>, fade_ms: u64, now: Instant) -> f32 {
uploaded.map_or(1.0, |&t| {
let elapsed = now.saturating_duration_since(t).as_millis() as u64;
chunk_visibility(elapsed, fade_ms)
})
}
/// What a section's fade depends on besides the section itself
/// (`SectionGpu::drawn_faded`): the time, the chunk fade-in time, the sections'
/// first uploads (`Renderer::section_uploaded`) and those held while far
/// terrain comes in (`Renderer::lod_retiring`).
#[derive(Clone, Copy)]
pub(super) struct FadeInputs<'a> {
pub(super) now: Instant,
pub(super) fade_ms: u64,
pub(super) uploaded: &'a FxHashMap<super::SectionKey, Instant>,
pub(super) retiring: &'a crate::fast_hash::FxHashSet<super::SectionKey>,
}
/// `LevelRenderer.CHUNK_VISIBILITY_THRESHOLD`: a section's block entities wait for it.
pub(super) const CHUNK_VISIBILITY_THRESHOLD: f32 = 0.3;
+6 -5
View File
@@ -30,8 +30,9 @@ fn marker_driver(text: &str) -> &str {
pub struct Reported {
pub paper_white: u32,
pub peak: u32,
/// The peak is the display's own claim (DXGI's `MaxLuminance`, mostly its EDID figure),
/// which often runs high: Auto still uses it, but the HDR page asks to calibrate.
/// The peak is the system's own figure (DXGI's `MaxLuminance`): the display's, or a
/// calibration profile's (Windows HDR Calibration), which Horizon can't tell apart. Auto
/// uses it, and the HDR page offers calibration.
pub peak_guessed: bool,
}
@@ -40,7 +41,7 @@ pub struct Reported {
pub enum HdrSetup {
/// The system says nothing: the player calibrates.
Unknown,
/// The system says, but only guesses the peak: the player calibrates.
/// The system says, but not where its peak comes from: the page offers calibration.
Guessed,
/// Peak Brightness and Paper White follow the system.
Automatic,
@@ -1355,7 +1356,7 @@ mod tests {
}
#[test]
fn hdr_setup_asks_to_calibrate_where_the_peak_is_guessed() {
fn hdr_setup_offers_calibration_where_the_peaks_source_is_unknown() {
let wayland = Reported {
paper_white: 203,
peak: 1015,
@@ -1370,7 +1371,7 @@ mod tests {
assert_eq!(g.hdr_setup(None), HdrSetup::Unknown);
assert_eq!(g.hdr_setup(Some(wayland)), HdrSetup::Automatic);
assert_eq!(g.hdr_setup(Some(windows)), HdrSetup::Guessed);
// Auto still takes the guess, over the 1000-nit default.
// Auto still takes the reported peak, over the 1000-nit default.
assert_eq!(g.peak_nits(Some(windows)), 1000);
assert_eq!(g.paper_white_nits(Some(windows)), 400);
for (peak, paper_white) in [(800, 0), (0, 240)] {
+70 -12
View File
@@ -697,7 +697,7 @@ fn description(
"Your display didn't say how bright it gets: calibrate to set it up."
}
(Item::Horizon(H::HdrCalibrate), Setup::Guessed) => {
"Your display's reported peak is often too high: calibrate to set it up."
"Auto uses the peak Windows reports. Calibrate if highlights look clipped."
}
(Item::Horizon(H::HdrCalibrate), Setup::Automatic) => {
"Already set up for your display. Only calibrate if HDR looks wrong."
@@ -705,7 +705,7 @@ fn description(
(Item::Horizon(H::HdrCalibrate), Setup::Changed) => {
"Puts Peak Brightness and Paper White back to your display's setup."
}
// A guessed peak's Auto is what calibration replaced; paper white is the player's own.
// A peak of unknown source isn't said to be set up; paper white is the player's own.
(Item::Horizon(h @ (H::HdrPeak | H::HdrPaperWhite)), Setup::Automatic | Setup::Changed)
if h == H::HdrPaperWhite || out.reported.is_some_and(|r| !r.peak_guessed) =>
{
@@ -1219,10 +1219,13 @@ impl Item {
}
pub fn label(self, s: &Settings, out: &EffectiveOutput) -> Text {
match self {
Item::Vanilla(Opt::RenderDistance) if s.horizon.lod_distance > 0 => labelled(
"Full Detail Distance",
lang::tr("%s chunks", &[&s.render_distance]),
),
// Far terrain renames the row; its value stays vanilla's `options.chunks`.
Item::Vanilla(Opt::RenderDistance) if s.horizon.lod_distance > 0 => {
Text::option_name_value(
Text::literal(lang::get("Full Detail Distance")),
Text::tr("options.chunks", &[&s.render_distance]),
)
}
Item::Vanilla(o) => o.label(s),
Item::Horizon(h) => h.label(s, out),
Item::Extra(e) => Text::option_name_value(
@@ -1318,7 +1321,11 @@ impl Item {
/// Whether a setting is on the screen now: Upscaling only while TAA is, which does it.
fn shown(item: Item, s: &Settings) -> bool {
item != Item::Horizon(H::Upscaling) || s.horizon.taa
match item {
Item::Horizon(H::Upscaling) => s.horizon.taa,
Item::Extra(crate::extras::Item::Fullbright) => !s.fullbright_held,
_ => true,
}
}
/// A control's label as its name and its value ("Render Distance" and "12 chunks").
fn parts(item: Item, s: &Settings, out: &EffectiveOutput) -> (String, Option<String>) {
@@ -1430,7 +1437,7 @@ impl Screen for GraphicsScreen {
g::gpu_culling_offer(),
g::reduced_far_detail_suggested(),
);
shell::stamp_of(&(read(s), offers))
shell::stamp_of(&(read(s), s.fullbright_held, offers))
}
fn sheets(&self, s: &Settings) -> Vec<Sheet<Page, Item>> {
let sheet = |(page, groups): (Page, Vec<(&'static str, Vec<Setting>)>)| Sheet {
@@ -1990,6 +1997,31 @@ mod tests {
assert_eq!(key(&mut ui, &mut s, K::Escape), [Output::Done]);
}
/// On another's server Fullbright's row is gone and the screen draws again; its choice is
/// kept for the next world that allows it (#3823).
#[test]
fn fullbright_row_is_hidden_while_held_and_keeps_its_choice() {
let mut s = Settings::default();
s.extras.fullbright = true;
let screen = GraphicsScreen::default();
let listed = |s: &Settings, item| {
let sheets = screen.sheets(s);
let groups = sheets.into_iter().flat_map(|sheet| sheet.groups);
groups.flat_map(|group| group.rows).any(|r| r.id == item)
};
let fullbright = Item::Extra(crate::extras::Item::Fullbright);
let lights = Item::Extra(crate::extras::Item::DynamicLights);
assert!(listed(&s, fullbright));
let free = screen.stamp(&s);
s.fullbright_held = true;
assert!(!listed(&s, fullbright));
assert!(listed(&s, lights));
assert_ne!(screen.stamp(&s), free);
assert!(s.extras.fullbright);
s.fullbright_held = false;
assert!(listed(&s, fullbright));
}
/// The strip says what a row does, what its chosen value does where that differs, and why a
/// greyed row can't be used; the search finds a row by what it does.
#[test]
@@ -2110,10 +2142,36 @@ mod tests {
assert_eq!(item.slider(&s), Some(13.0 / 23.0));
s.horizon.lod_distance = 12;
assert_eq!(item.slider(&s), Some(1.0 / 23.0));
assert_eq!(
Item::Vanilla(Opt::RenderDistance).label(&s, &NONE).plain(),
format!("Full Detail Distance: {near} chunks")
);
}
/// With far terrain on, Render Distance reads "Full Detail Distance" but keeps vanilla's
/// `options.chunks` value: it was Horizon's "%s chunks", so "12 Chunks" became "12 chunks"
/// (#14880).
#[test]
fn full_detail_distance_keeps_the_vanilla_chunk_count() {
struct Vanilla;
impl lang::ResourceSource for Vanilla {
fn read(&self, path: &str) -> Option<Vec<u8>> {
(path == "assets/minecraft/lang/en_us.json").then(|| {
serde_json::to_vec(&serde_json::json!({
"options.renderDistance": "Render Distance",
"options.chunks": "%s Chunks",
"options.generic_value": "%s: %s"
}))
.unwrap()
})
}
}
let mut s = Settings::default();
let near = s.render_distance;
let render = Item::Vanilla(Opt::RenderDistance);
let ((off, on), _) = lang::scoped(lang::Language::load(&[&Vanilla], "en_us"), || {
let off = render.label(&s, &NONE).plain();
s.horizon.lod_distance = 256;
(off, render.label(&s, &NONE).plain())
});
assert_eq!(off, format!("Render Distance: {near} Chunks"));
assert_eq!(on, format!("Full Detail Distance: {near} Chunks"));
}
#[test]
@@ -164,7 +164,7 @@ fn auto_levels_and_setup_read_their_own_renderers_report() {
}
/// The setup rows' label and description for each kind of report: none, a compositor's
/// (Wayland), and Windows', whose peak is the display's own guess.
/// (Wayland), and Windows', whose peak may be the display's or a calibration profile's.
#[test]
fn the_setup_rows_ask_to_calibrate_unless_the_system_set_the_display_up() {
let trusted = offering(true, Some(true));
@@ -200,7 +200,7 @@ fn the_setup_rows_ask_to_calibrate_unless_the_system_set_the_display_up() {
[
(
calibrate.into(),
"Your display's reported peak is often too high: calibrate to set it up."
"Auto uses the peak Windows reports. Calibrate if highlights look clipped."
),
("Peak Brightness: Auto (1000)".into(), "-"),
("Paper White: Auto (400)".into(), "-"),
@@ -219,7 +219,7 @@ fn the_setup_rows_ask_to_calibrate_unless_the_system_set_the_display_up() {
);
// Calibrated or set by hand, either report offers its automatic setup back.
s.horizon.hdr_peak = 800;
// Peak's Auto stays a guess on Windows: only Paper White is said to be set up there.
// Peak's Auto has no known source on Windows: only Paper White is said to be set up there.
let changed = |peak| {
[
(
@@ -348,3 +348,30 @@ fn undo_never_restores_a_held_hdr_start() {
screen.undo(&mut s, &opened);
assert_eq!(s.horizon, opened.horizon);
}
/// The Calibrate row's descriptions fit the strip's two lines at vanilla's 427-wide GUI (a
/// 1080p window at scale 4 is wider): a longer one would end in "…".
#[test]
fn the_calibrate_descriptions_fit_the_strip() {
let atlas = crate::hud::HudAtlas::build(std::path::Path::new("/nonexistent")).unwrap();
let mut guessed = offering(true, Some(true));
guessed.reported = guessed.reported.map(|r| crate::graphics::Reported {
peak_guessed: true,
..r
});
let mut changed = Settings::default();
changed.horizon.hdr_peak = 800;
let item = Item::Horizon(H::HdrCalibrate);
let width = Columns::of(427.0, 240.0).dock[2] - 24.0;
for (s, out) in [
(Settings::default(), NONE),
(Settings::default(), guessed),
(Settings::default(), offering(true, Some(true))),
(changed, guessed),
] {
let text = description(item, &s, &out, "-");
assert_ne!(text, "-");
let lines = atlas.font.split(&Text::literal(text.to_owned()), width);
assert!(lines.len() <= 2, "{text}");
}
}
@@ -13,8 +13,9 @@
//! - word 1: each corner's grey level before the tint;
//! - word 2: the UV rectangle in 1/32 of the sprite (u0, u1, v0, v1, 6 bits each) and, per
//! corner, which edge of it its u and v take;
//! - word 3: the layer, and corner 0's light (sky and block light over 4, 6 bits each);
//! - word 4: corners 1 and 2's light;
//! - word 3: the layer, corner 0's light (sky and block light over 4, 6 bits each), and
//! corner 1's alpha code (`ALPHA_STEP` times it; 0: word 0's alpha);
//! - word 4: corners 1 and 2's light, and corners 2 and 3's alpha codes;
//! - word 5: corner 3's light, the face (block x, y, z, the axis it faces along, which side
//! of the block, and where and which way its corners go round), and `FULL` clear.
use super::TerrainVertex;
@@ -27,6 +28,8 @@ pub const WHITE: u32 = 0xFF_FFFF;
/// Word 5's bit of a quad kept as four vertices; word 0 is then the slots from the record
/// to its first vertex.
pub const FULL: u32 = 1 << 31;
/// The step of a corner's own alpha (words 3 and 4's codes): 255 / 5.
pub const ALPHA_STEP: u32 = 51;
/// The draws' base vertex of a compact mesh: `PACKED_BASE + 4 * slot + corner` is a corner of
/// the quad whose record is at `slot`; below it, vertex indices name vertices.
pub const PACKED_BASE: u32 = 1 << 28;
@@ -133,12 +136,24 @@ pub fn pack_quad(quad: &[TerrainVertex; 4], tint: u32) -> Option<[u32; 6]> {
for (i, v) in quad.iter().enumerate() {
grays |= gray_of(v.color, tint)? << (8 * i);
}
// Corners 1 to 3's alpha (a shader pack's `separateAo`: each corner's ambient occlusion,
// multiples of 51 on a whole face): `ALPHA_STEP` times the code, 0 for corner 0's.
let mut codes = [0u32; 3];
for (code, v) in codes.iter_mut().zip(&quad[1..]) {
let a = v.color >> 24;
if v.color & 0xFF00_0000 != alpha {
if a == 0 || a % ALPHA_STEP != 0 {
return None;
}
*code = a / ALPHA_STEP;
}
}
let words = [
tint | alpha,
grays,
rect | select << 24,
u32::from(quad[0].layer) | light[0] << 16,
light[1] | light[2] << 12,
u32::from(quad[0].layer) | light[0] << 16 | codes[0] << 28,
light[1] | light[2] << 12 | codes[1] << 24 | codes[2] << 27,
light[3] | orient << 12,
];
let exact = (0..4).all(|c| {
@@ -190,12 +205,18 @@ pub fn packed_vertex(words: &[u32; 6], corner: usize) -> TerrainVertex {
let light = ((light & 63) << 2 | (light >> 6 & 63) << 10) as u16;
let g = w1 >> (8 * i) & 255;
let [r, gr, b] = channels(w0).map(|t| g * t / 255);
let code = [0, w3 >> 28 & 7, w4 >> 24 & 7, w4 >> 27 & 7][corner];
let alpha = if code == 0 {
w0 & 0xFF00_0000
} else {
(code * ALPHA_STEP) << 24
};
TerrainVertex {
pos: pos.map(|c| c as f32),
uv,
layer: (w3 & 0xFFFF) as u16,
light,
color: r | gr << 8 | b << 16 | (w0 & 0xFF00_0000),
color: r | gr << 8 | b << 16 | alpha,
}
}
@@ -247,11 +268,10 @@ pub fn place_into(
let n = quads.len();
let packed = records.iter().filter(|r| r.is_some()).count();
let full = n - packed;
let pad = if full > 0 {
n.next_multiple_of(4) - n
} else {
0
};
// Whole quads of slots: what follows (a quad's own vertices, the translucent ones after
// the solid range) then starts a quad, as a shader pack's vertex stage reads its quad's
// corners from the vertex index (`pack_atlas::PULL_FLAT`).
let pad = n.next_multiple_of(4) - n;
if n + pad + 4 * full >= 4 * n {
return;
}
@@ -334,6 +354,22 @@ mod tests {
}
}
#[test]
fn each_corners_ambient_occlusion_alpha_packs_on_its_steps() {
// A shader pack's `separateAo`: the alpha byte is each corner's occlusion.
let mut quad = top([0, 1, 2, 3]);
for (v, a) in quad.iter_mut().zip([153u32, 255, 51, 204]) {
v.color = v.color & 0x00FF_FFFF | a << 24;
}
let words = pack_quad(&quad, TINT).expect("occlusion on the 51 steps packs");
for (corner, v) in quad.iter().enumerate() {
assert!(same(&packed_vertex(&words, corner), v), "corner {corner}");
}
// A partial face's blended occlusion falls between the steps.
quad[2].color = quad[2].color & 0x00FF_FFFF | 140 << 24;
assert!(pack_quad(&quad, TINT).is_none());
}
#[test]
fn quads_off_the_grid_or_the_steps_keep_their_vertices() {
// Half a block up: a slab's top.
@@ -375,4 +411,13 @@ mod tests {
// Nothing smaller to gain: no layout at all.
assert!(layout(&odd, &[TINT]).is_empty());
}
#[test]
fn the_records_end_on_a_whole_quad_of_slots() {
// Five whole faces, every one a record: the translucent vertices after them start a quad.
let solid: Vec<TerrainVertex> = (0..5).flat_map(|_| top([0, 1, 2, 3])).collect();
let out = layout(&solid, &[TINT; 5]);
assert_eq!(packed_quads(&out, 5), 5);
assert_eq!(out.len(), 8);
}
}
+256 -13
View File
@@ -368,6 +368,57 @@ pub struct PackScreen {
browser_search: TextField,
/// The browser's search box has the keyboard.
browser_focus: bool,
/// Which atlas slot holds which icon, kept from one frame to the next
/// ([`PackScreen::bind_icons`]).
icon_slots: std::cell::RefCell<IconSlots>,
}
/// The atlas's pack icon slots and the icon each holds. The atlas keeps what is
/// written into a slot, so an icon is sent once, when it takes a slot, and not
/// again every frame it is drawn.
#[derive(Clone, Debug, Default)]
struct IconSlots {
/// The atlas the pixels went into ([`HudAtlas::revision`]); a new atlas holds none of them.
revision: u64,
/// Frames bound so far: an icon drawn in this one keeps its slot.
frame: u64,
/// Least recently drawn first: the icon's texture key, its slot, its pixels, and the
/// frame it was last drawn in.
held: Vec<(String, usize, Arc<[u8]>, u64)>,
}
impl IconSlots {
/// Starts a frame on `atlas`; an atlas that is not the one the slots were filled in empties them.
fn begin(&mut self, atlas: &HudAtlas) {
if self.revision != atlas.revision {
self.held.clear();
self.revision = atlas.revision;
}
self.frame += 1;
}
/// The slot for the icon drawn under `texture`, and whether its pixels have to be sent. A new
/// icon takes a free slot, else the slot of the icon drawn longest ago; none when every slot
/// holds an icon drawn in this frame, and that icon keeps `unknown_pack`.
fn slot_for(&mut self, texture: &str, icon: &Arc<[u8]>) -> Option<(usize, bool)> {
if let Some(at) = self.held.iter().position(|h| h.0 == texture) {
let mut kept = self.held.remove(at);
let same = Arc::ptr_eq(&kept.2, icon);
kept.2 = icon.clone();
kept.3 = self.frame;
let slot = kept.1;
self.held.push(kept);
return Some((slot, !same));
}
let slot = match (0..PACK_ICON_SLOTS).find(|i| !self.held.iter().any(|h| h.1 == *i)) {
Some(free) => free,
None if self.held.first().is_some_and(|h| h.3 < self.frame) => self.held.remove(0).1,
None => return None,
};
self.held
.push((texture.to_owned(), slot, icon.clone(), self.frame));
Some((slot, true))
}
}
fn tr(key: &str) -> Text {
@@ -459,6 +510,7 @@ impl PackScreen {
field
},
browser_focus: false,
icon_slots: Default::default(),
changed: false,
};
s.reposition(m);
@@ -931,10 +983,13 @@ impl PackScreen {
self.model.commit()
}
/// Points the icon blits of `draws` at the atlas's pack icon slots and
/// writes the icons into them, one slot per icon drawn (vanilla registers a
/// texture per pack). Icons past the last slot keep `unknown_pack`.
/// writes the icons into them (vanilla registers a texture per pack). An
/// icon keeps its slot from one frame to the next and is sent when it takes
/// the slot, not every frame; the slot of the icon drawn longest ago is
/// taken first. Icons past the last slot in one frame keep `unknown_pack`.
pub fn bind_icons(&self, draws: &mut [Draw], atlas: &HudAtlas, patches: &mut Vec<Patch>) {
let mut slots = 0..PACK_ICON_SLOTS;
let mut slots = self.icon_slots.borrow_mut();
slots.begin(atlas);
for draw in draws {
let Draw::Blit { texture, .. } = draw else {
continue;
@@ -942,20 +997,23 @@ impl PackScreen {
let Some(icon) = self.icons.get(texture.as_str()) else {
continue;
};
let Some(slot) = slots.next() else {
return;
let Some((slot, send)) = slots.slot_for(texture, icon) else {
continue;
};
let sprite = format!("horizon/pack-icon{slot}");
let Some(rect) = atlas.sprites.get(&sprite) else {
slots.held.pop();
return;
};
patches.push(Patch {
x: (rect.u0 * atlas.width as f32).round() as u32,
y: (rect.v0 * atlas.height as f32).round() as u32,
width: PACK_ICON_SIZE,
height: PACK_ICON_SIZE,
rgba: icon.to_vec(),
});
if send {
patches.push(Patch {
x: (rect.u0 * atlas.width as f32).round() as u32,
y: (rect.v0 * atlas.height as f32).round() as u32,
width: PACK_ICON_SIZE,
height: PACK_ICON_SIZE,
rgba: icon.to_vec(),
});
}
*texture = sprite;
}
}
@@ -1618,6 +1676,12 @@ impl PackScreen {
let hover = hovered == Some(i)
&& d::inside(mouse, bx, by, OPTIONS_SIZE, OPTIONS_SIZE);
Self::draw_options_button(out, bx, by, hover);
// A control for a pad's crosshair to glide onto and its D-pad to hop to,
// while the list shows it whole.
let mark = crate::widgets::targets_mark();
let size = OPTIONS_SIZE as f32;
crate::widgets::target([bx as f32, by as f32, size, size]);
crate::widgets::clip_targets(mark, l.y as f32, l.bottom() as f32);
if hover {
tooltip = Some(tr("horizon.pack.optionsInfo"));
}
@@ -1745,9 +1809,14 @@ mod icon_tests {
.unwrap();
assert_eq!(id, "file/Configurable");
let (cx, cy) = (bx + OPTIONS_SIZE / 2, by + OPTIONS_SIZE / 2);
// Hovered, it explains itself.
// Hovered, it explains itself; a pad's crosshair reaches it as a control.
let draws = screen.draw(&m, (cx, cy), 0, 0);
assert!(draws.iter().any(|d| matches!(d, Draw::Tooltip { .. })));
crate::widgets::end_frame(crate::widgets::generation() + 1);
let size = OPTIONS_SIZE as f32;
let button = [bx as f32, by as f32, size, size];
let targets = crate::widgets::last_targets();
assert!(targets.iter().any(|t| t.rect == button), "{targets:?}");
assert_eq!(
screen.click(&m, cx as f64, cy as f64),
Action::Options("file/Configurable".into())
@@ -2070,3 +2139,177 @@ mod footer_label_tests {
assert_eq!(lang::code(), before, "the global language was left alone");
}
}
#[cfg(test)]
mod icon_slot_tests {
use super::*;
fn atlas() -> HudAtlas {
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap()
}
/// `count` packs with an icon each, the pixels of icon `i` all `i`.
fn packs(count: usize) -> Vec<PackInfo> {
let side = PACK_ICON_SIZE as usize;
(0..count)
.map(|i| PackInfo {
id: format!("file/pack{i:03}"),
title: Text::literal(format!("pack{i:03}")),
description: Text::literal(""),
description_style: Style::default(),
source: Source::BuiltIn,
compat: Compat::Compatible,
icon: Some(vec![i as u8; side * side * 4].into()),
required: false,
position: Position::Top,
fixed: false,
})
.collect()
}
/// One frame as `options_ui` draws it: the calls, then the icons bound. The icons drawn
/// (their texture keys before binding), the textures after, and the patches sent.
fn frame(screen: &PackScreen, atlas: &HudAtlas) -> (Vec<String>, Vec<String>, Vec<Patch>) {
let m = Measure(&atlas.font);
let mut draws = screen.draw(&m, (0, 0), 0, 0);
let drawn: Vec<String> = draws
.iter()
.filter_map(|d| match d {
Draw::Blit { texture, .. } if screen.icons.contains_key(texture) => {
Some(texture.clone())
}
_ => None,
})
.collect();
let mut patches = Vec::new();
screen.bind_icons(&mut draws, atlas, &mut patches);
let bound = draws
.iter()
.filter_map(|d| match d {
Draw::Blit { texture, .. } if texture.contains("pack-icon") => {
Some(texture.clone())
}
_ => None,
})
.collect();
(drawn, bound, patches)
}
/// Only the available list has packs: one row of 36 for each of `count` icons fits in a
/// window `height` high.
fn screen(m: &Measure, count: usize, height: i32) -> PackScreen {
PackScreen::new(
m,
tr("resourcePack.title"),
480,
height,
false,
packs(count),
&[],
)
}
/// Every frame sent every drawn icon again (a 128 by 128 RGBA copy each) into the same
/// atlas, which keeps what it is given: an icon is sent when it takes a slot, once.
#[test]
fn an_icon_is_sent_once_and_not_every_frame() {
let atlas = atlas();
let screen = screen(&Measure(&atlas.font), 5, 400);
let (drawn, bound, patches) = frame(&screen, &atlas);
assert_eq!((drawn.len(), bound.len(), patches.len()), (5, 5, 5));
for again in 0..3 {
let (_, rebound, resent) = frame(&screen, &atlas);
assert_eq!(rebound, bound, "frame {again}");
assert_eq!(resent.len(), 0, "frame {again}");
}
}
/// More icons drawn in one frame than there are slots: the first 64 get one, the rest keep
/// `unknown_pack`, and the next frame does not move them or send anything.
#[test]
fn past_the_slots_in_one_frame_the_rest_keep_unknown_pack() {
let atlas = atlas();
let screen = screen(&Measure(&atlas.font), PACK_ICON_SLOTS + 6, 4000);
let (drawn, bound, patches) = frame(&screen, &atlas);
assert_eq!(
drawn.len(),
PACK_ICON_SLOTS + 6,
"the window shows every row"
);
assert_eq!(
(bound.len(), patches.len()),
(PACK_ICON_SLOTS, PACK_ICON_SLOTS)
);
let (_, rebound, resent) = frame(&screen, &atlas);
assert_eq!((rebound, resent.len()), (bound, 0));
}
/// Scrolling through more packs than slots: every icon drawn gets a slot, each one is sent
/// once on the way down, and a frame that draws what the last one drew sends nothing.
#[test]
fn scrolling_sends_only_the_icons_that_come_into_view() {
let atlas = atlas();
let m = Measure(&atlas.font);
let mut screen = screen(&m, 70, 330);
let mut seen = std::collections::HashSet::new();
let mut sent = 0;
// 150 wheel steps of 18 are more than the 2520 high list.
for step in 0..150 {
let (drawn, bound, patches) = frame(&screen, &atlas);
assert_eq!(bound.len(), drawn.len(), "step {step}: none falls back");
let new = drawn.iter().filter(|t| seen.insert((*t).clone())).count();
assert_eq!(patches.len(), new, "step {step}");
sent += patches.len();
assert_eq!(frame(&screen, &atlas).2.len(), 0, "step {step}: same view");
screen.scroll(100.0, 200.0, -1.0);
}
assert_eq!((seen.len(), sent), (70, 70));
}
/// The slot of the icon drawn longest ago is the one taken, and the icon that left it is
/// sent again when it comes back.
#[test]
fn the_icon_drawn_longest_ago_gives_up_its_slot() {
let icons: Vec<Arc<[u8]>> = packs(PACK_ICON_SLOTS + 1)
.into_iter()
.map(|p| p.icon.unwrap())
.collect();
let mut slots = IconSlots::default();
let key = |i: usize| format!("k{i}");
slots.begin(&atlas());
for i in 0..PACK_ICON_SLOTS {
assert_eq!(slots.slot_for(&key(i), &icons[i]), Some((i, true)));
}
// The next frame draws icon 0 first, then a new one: slot 1, the oldest left, is taken.
slots.frame += 1;
assert_eq!(slots.slot_for(&key(0), &icons[0]), Some((0, false)));
assert_eq!(
slots.slot_for("new", &icons[PACK_ICON_SLOTS]),
Some((1, true))
);
// Icon 1 comes back: it is sent again, into the slot of the next oldest.
assert_eq!(slots.slot_for(&key(1), &icons[1]), Some((2, true)));
}
/// New pixels under the same key take the same slot and are sent; a new atlas holds none
/// of the icons, so all are sent again.
#[test]
fn changed_pixels_and_a_new_atlas_send_again() {
let atlas = atlas();
let mut screen = screen(&Measure(&atlas.font), 3, 400);
let (_, bound, patches) = frame(&screen, &atlas);
assert_eq!(patches.len(), 3);
let key = icon_texture("file/pack001");
screen
.icons
.insert(key, vec![200; patches[1].rgba.len()].into());
let (_, rebound, resent) = frame(&screen, &atlas);
assert_eq!(rebound, bound);
assert_eq!(resent.len(), 1);
assert_eq!((resent[0].x, resent[0].y), (patches[1].x, patches[1].y));
assert_eq!(resent[0].rgba[0], 200);
let other = self::atlas();
assert_ne!(other.revision, atlas.revision);
assert_eq!(frame(&screen, &other).2.len(), 3);
}
}
+18 -64
View File
@@ -31,8 +31,10 @@ mod native;
pub mod polytone;
mod polytone_gpu;
mod polytone_model;
mod simple;
mod spawn;
pub use native::{ParticleController, ParticleUpdate};
use simple::{PARALLEL_MIN, parallel_threads, sprite_from_age_in, tick_simple};
/// `ParticleOptions` with the parameters of the option classes used here.
#[derive(Clone, Debug)]
@@ -1325,6 +1327,9 @@ pub struct ParticleEngine {
/// A native mod's sprite sets, by namespaced particle name.
mod_sets: HashMap<String, (u32, Option<ModBehaviour>)>,
set_sprites: Vec<Vec<u32>>,
/// How many live particles it takes to tick them on several threads (`tick_all`), and how many threads.
parallel_min: usize,
parallel_threads: usize,
/// Resource packs' Polytone custom particle types (#11486).
polytone: Option<std::sync::Arc<polytone::Registry>>,
/// Vanilla particle types a pack replaces with a custom one of the same id
@@ -1527,6 +1532,8 @@ impl ParticleEngine {
sets: HashMap::new(),
mod_sets: HashMap::new(),
set_sprites: Vec::new(),
parallel_min: PARALLEL_MIN,
parallel_threads: parallel_threads(),
polytone: None,
polytone_overrides: HashMap::new(),
polytone_gpu: Vec::new(),
@@ -2121,23 +2128,7 @@ impl ParticleEngine {
}
/// `SingleQuadParticle.setSpriteFromAge`.
fn sprite_from_age(&self, p: &mut Particle) {
if p.removed {
return;
}
if let Some(set) = p.set {
// A sprite set's index can outlive the reload that rebuilt them.
let Some(sprites) = self.set_sprites.get(set as usize) else {
return;
};
let lifetime = p.lifetime.max(1);
let index = if p.frame_ticks > 0 {
(p.age.max(0) as i64 / i64::from(p.frame_ticks)) % sprites.len() as i64
} else {
(p.age.max(0) as i64 * (sprites.len() as i64 - 1) / i64::from(lifetime))
.clamp(0, sprites.len() as i64 - 1)
};
p.sprite = Sprite::Index(sprites[index as usize]);
}
sprite_from_age_in(&self.set_sprites, p);
}
#[allow(clippy::too_many_lines)]
/// `ParticleProvider.createParticle`: provider draws use the engine's
@@ -3847,9 +3838,7 @@ impl ParticleEngine {
self.emitters = emitters;
let mut particles = std::mem::take(&mut self.particles);
self.spawned_in_tick = Some(Vec::new());
for p in &mut particles {
self.tick_particle(p, level);
}
self.tick_all(&mut particles, level);
particles.retain(|p| !p.removed);
self.particles = particles;
for (options, pos, velocity) in self.spawned_in_tick.take().unwrap_or_default() {
@@ -3954,7 +3943,15 @@ impl ParticleEngine {
#[allow(clippy::too_many_lines)]
fn tick_particle(&mut self, p: &mut Particle, level: &ParticleLevel) {
match p.behaviour {
Behaviour::Base => p.base_tick(level),
Behaviour::Base
| Behaviour::Mod { .. }
| Behaviour::Animated
| Behaviour::Snowflake
| Behaviour::Crit
| Behaviour::Spark { .. } => {
let simple = tick_simple(&self.set_sprites, p, level);
self.finish_simple(p, simple);
}
Behaviour::Controlled { .. } => {
if let Some(mut controller) = p.controller.take() {
let update = controller.tick(p, level);
@@ -3973,23 +3970,6 @@ impl ParticleEngine {
p.remove();
}
}
Behaviour::Mod { size, alpha } => {
p.base_tick(level);
p.size = mod_curve(size, p.age, p.lifetime);
p.rgba[3] = mod_curve(alpha, p.age, p.lifetime);
self.sprite_from_age(p);
}
Behaviour::Animated => {
p.base_tick(level);
self.sprite_from_age(p);
}
Behaviour::Snowflake => {
p.base_tick(level);
self.sprite_from_age(p);
p.velocity[0] *= f64::from(0.95f32);
p.velocity[1] *= f64::from(0.9f32);
p.velocity[2] *= f64::from(0.95f32);
}
Behaviour::Lava => {
p.base_tick(level);
if !p.removed && p.random.next_float() > p.age as f32 / p.lifetime as f32 {
@@ -4000,11 +3980,6 @@ impl ParticleEngine {
);
}
}
Behaviour::Crit => {
p.base_tick(level);
p.rgba[1] *= 0.96;
p.rgba[2] *= 0.9;
}
Behaviour::Drip {
fluid,
stage,
@@ -4475,27 +4450,6 @@ impl ParticleEngine {
}
}
}
Behaviour::Spark {
fade,
trail,
twinkle,
} => {
p.base_tick(level);
self.sprite_from_age(p);
if p.age > p.lifetime / 2 {
p.rgba[3] = 1.0 - (p.age as f32 - (p.lifetime / 2) as f32) / p.lifetime as f32;
if let Some(f) = fade {
for i in 0..3 {
p.rgba[i] += (f[i] - p.rgba[i]) * 0.2;
}
}
}
if trail && p.age < p.lifetime / 2 && (p.age + p.lifetime) % 2 == 0 {
let mut child = self.spark(p.position, [0.0; 3], p.rgba, fade, false, twinkle);
child.age = child.lifetime / 2;
self.pending.push(child);
}
}
Behaviour::Spell { original_alpha } => {
p.base_tick(level);
self.sprite_from_age(p);
@@ -774,7 +774,7 @@ impl Host for Ctx<'_, '_> {
// `r.noise(x, y)`: one simplex noise (named noises aren't read yet).
let mut coords = args.iter().filter(|v| !matches!(v, V::Str(_))).map(V::num);
let (x, y) = (coords.next().unwrap_or(0.0), coords.next().unwrap_or(0.0));
num(f64::from(crate::environment::polytone_noise().get2(x, y)))
num(crate::environment::polytone_noise().get(x, y))
}
(id, _) if id >= BLOCKS => {
let at = *self
@@ -0,0 +1,161 @@
//! The particles that tick on their own (`tick_simple`): the behaviours that read and write only
//! their own particle, the world and the sprite sets, which `ParticleEngine::tick_all` runs on several
//! threads in a busy scene.
use super::{Behaviour, Particle, ParticleEngine, ParticleLevel, Sprite, mod_curve};
/// The live particles it takes before `tick_all` uses more than one thread: a scoped thread costs
/// tens of microseconds to start and join, and a simple particle about a tenth of one.
pub(super) const PARALLEL_MIN: usize = 2048;
/// Threads for ticking particles: half the machine's workers, at most four (the chunk workers and
/// the render thread need cores too); `HORIZON_WORKER_THREADS` caps them as it does every pool.
pub(super) fn parallel_threads() -> usize {
(horizon_threads::worker_count() / 2).clamp(1, 4)
}
pub(super) fn sprite_from_age_in(set_sprites: &[Vec<u32>], p: &mut Particle) {
if p.removed {
return;
}
if let Some(set) = p.set {
// A sprite set's index can outlive the reload that rebuilt them.
let Some(sprites) = set_sprites.get(set as usize) else {
return;
};
let lifetime = p.lifetime.max(1);
let index = if p.frame_ticks > 0 {
(p.age.max(0) as i64 / i64::from(p.frame_ticks)) % sprites.len() as i64
} else {
(p.age.max(0) as i64 * (sprites.len() as i64 - 1) / i64::from(lifetime))
.clamp(0, sprites.len() as i64 - 1)
};
p.sprite = Sprite::Index(sprites[index as usize]);
}
}
/// The tick of the behaviours that read and write only their own particle, the world and the engine's
/// sprite sets: no particle spawned, no sound, nothing drawn from the engine's random. Those are the
/// bulk of a busy scene (flames, smoke, poofs, dust, crits), and `ParticleEngine::tick` runs them on
/// several threads. Any other behaviour is left alone, for `ParticleEngine::tick_particle`.
pub(super) fn tick_simple(
set_sprites: &[Vec<u32>],
p: &mut Particle,
level: &ParticleLevel,
) -> Simple {
match p.behaviour {
Behaviour::Base => p.base_tick(level),
Behaviour::Mod { size, alpha } => {
p.base_tick(level);
p.size = mod_curve(size, p.age, p.lifetime);
p.rgba[3] = mod_curve(alpha, p.age, p.lifetime);
sprite_from_age_in(set_sprites, p);
}
Behaviour::Animated => {
p.base_tick(level);
sprite_from_age_in(set_sprites, p);
}
Behaviour::Snowflake => {
p.base_tick(level);
sprite_from_age_in(set_sprites, p);
p.velocity[0] *= f64::from(0.95f32);
p.velocity[1] *= f64::from(0.9f32);
p.velocity[2] *= f64::from(0.95f32);
}
Behaviour::Crit => {
p.base_tick(level);
p.rgba[1] *= 0.96;
p.rgba[2] *= 0.9;
}
Behaviour::Spark { fade, trail, .. } => {
p.base_tick(level);
sprite_from_age_in(set_sprites, p);
if p.age > p.lifetime / 2 {
p.rgba[3] = 1.0 - (p.age as f32 - (p.lifetime / 2) as f32) / p.lifetime as f32;
if let Some(f) = fade {
for i in 0..3 {
p.rgba[i] += (f[i] - p.rgba[i]) * 0.2;
}
}
}
if trail && p.age < p.lifetime / 2 && (p.age + p.lifetime) % 2 == 0 {
return Simple::SparkTrail;
}
}
_ => return Simple::No,
}
Simple::Done
}
/// The same world with a one-chunk lookup cache of its own, for a worker thread.
///
/// `ParticleLevel::cached_chunk` is a `Cell`, which a level shared between threads may not hold:
/// each thread gets a view with an empty cache, built here on the engine thread and moved in. A
/// cache they shared could not hold "the last chunk looked up" for any one of them in any case.
fn thread_view<'a>(level: &ParticleLevel<'a>) -> ParticleLevel<'a> {
ParticleLevel {
world: level.world,
facts: level.facts,
collision: level.collision,
player: level.player,
cached_chunk: std::cell::Cell::new(None),
}
}
/// What `tick_simple` did with a particle.
#[derive(Clone, Copy, PartialEq, Eq)]
pub(super) enum Simple {
/// Not one of its behaviours: the engine ticks it.
No,
/// Ticked completely.
Done,
/// Ticked, except that a firework spark's trail wants a child. Only the engine can make one:
/// it takes the next seed, so the children come in the order the sparks have.
SparkTrail,
}
impl ParticleEngine {
/// What `tick_simple` leaves to the engine: a spark's trail child.
pub(super) fn finish_simple(&mut self, p: &Particle, simple: Simple) {
if simple != Simple::SparkTrail {
return;
}
if let Behaviour::Spark { fade, twinkle, .. } = p.behaviour {
let mut child = self.spark(p.position, [0.0; 3], p.rgba, fade, false, twinkle);
child.age = child.lifetime / 2;
self.pending.push(child);
}
}
/// One tick of every live particle. The ones `tick_simple` covers are independent of each
/// other and of the engine, so with enough of them they tick on several threads; every other
/// one then ticks here, in the order it had, so the particles, the engine's random and what
/// is spawned come out as when all of them ticked one after another.
pub(super) fn tick_all(&mut self, particles: &mut [Particle], level: &ParticleLevel) {
if particles.is_empty() || particles.len() < self.parallel_min || self.parallel_threads < 2
{
for p in particles {
self.tick_particle(p, level);
}
return;
}
let sets = &self.set_sprites;
let per = particles.len().div_ceil(self.parallel_threads);
let mut handled = vec![Simple::No; particles.len()];
std::thread::scope(|scope| {
let run = |part: &mut [Particle], done: &mut [Simple], level: &ParticleLevel| {
for (p, done) in part.iter_mut().zip(done) {
*done = tick_simple(sets, p, level);
}
};
let mut parts = particles.chunks_mut(per).zip(handled.chunks_mut(per));
let own = parts.next();
for (part, done) in parts {
let view = thread_view(level);
scope.spawn(move || run(part, done, &view));
}
// This thread takes the first share, instead of waiting for the others.
if let Some((part, done)) = own {
run(part, done, level);
}
});
for (p, done) in particles.iter_mut().zip(handled) {
match done {
Simple::No => self.tick_particle(p, level),
simple => self.finish_simple(p, simple),
}
}
}
}
@@ -1,8 +1,12 @@
//! The particle stress scene's load on `ParticleEngine`, outside the game: `cargo test --profile fast
//! -p horizon-client-render --lib particle_tick_cost -- --ignored --nocapture` (prepared resources;
//! `PARTICLE_BENCH_SECS` sets the run, default 5). It spawns what tools/stress_scenes.py's `particles`
//! scene makes each tick (flames, cosy smoke, end rods, TNT debris) over its stone floor and times the
//! spawns and the engine tick apart, once the engine holds as many particles as it will.
//! The particle stress scene's load on `ParticleEngine`, outside the game.
//!
//! - `parallel_tick_matches_serial_tick`: the engine ticking on several threads (`tick_all`) leaves
//! every particle, the engine's random and its sounds exactly as ticking them one after another.
//! - `particle_tick_cost` (`cargo test --profile fast -p horizon-client-render --lib
//! particle_tick_cost -- --ignored --nocapture`, prepared resources; `PARTICLE_BENCH_SECS` sets
//! the run, default 2): spawns what tools/stress_scenes.py's `particles` scene makes each tick
//! (flames, cosy smoke, end rods, TNT debris, rockets) over its stone floor and times the spawns
//! and the engine tick apart, once the engine holds as many particles as it will.
use super::*;
use crate::{blocks::BlockFacts, chunk::Chunk, collisions, world::World};
use std::sync::Arc;
@@ -25,6 +29,7 @@ impl Lcg {
}
}
/// What to spawn each tick: a type, how many, around where, how far, how fast.
type Emitter = (ParticleType, usize, [f64; 3], [f64; 3], f64);
fn spawn_tick(engine: &mut ParticleEngine, rng: &mut Lcg, types: &[Emitter], rockets: usize) {
@@ -58,9 +63,29 @@ fn spawn_tick(engine: &mut ParticleEngine, rng: &mut Lcg, types: &[Emitter], roc
}
}
#[test]
#[ignore = "a measurement, not a check"]
fn particle_tick_cost() {
/// The scene's emitters, per tick: flames, cosy smoke, end rods, and TNT's debris (poof and smoke).
fn emitters() -> [Emitter; 5] {
let ty = |name: &str| ParticleType::from_name(name).unwrap();
let (fx, fy, fz) = (0.5, f64::from(FLOOR_Y + 5), 20.5);
let tnt = [0.5, f64::from(FLOOR_Y + 24), 26.5];
[
(ty("flame"), 320, [fx, fy, fz], [8.0, 4.0, 8.0], 0.01),
(
ty("campfire_cosy_smoke"),
8,
[fx, fy, fz],
[8.0, 4.0, 8.0],
0.0,
),
(ty("end_rod"), 80, [fx, fy, fz], [8.0, 4.0, 8.0], 0.05),
(ty("poof"), 258, tnt, [3.0, 3.0, 3.0], 0.4),
(ty("smoke"), 238, tnt, [3.0, 3.0, 3.0], 0.4),
]
}
/// Runs `f` over the stress scene's world (a stone floor at y 289 under the particles). Skipped
/// without prepared resources.
fn with_scene(f: impl FnOnce(&ParticleLevel)) {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
@@ -88,69 +113,127 @@ fn particle_tick_cost() {
world.set(x, FLOOR_Y, z, floor).unwrap();
}
}
let level = ParticleLevel {
f(&ParticleLevel {
world: &world,
facts: &facts,
collision: &collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let ty = |name: &str| ParticleType::from_name(name).unwrap();
let (fx, fy, fz) = (0.5, f64::from(FLOOR_Y + 5), 20.5);
let tnt = [0.5, f64::from(FLOOR_Y + 24), 26.5];
// The scene's emitters, per tick, and TNT's debris (poof and smoke).
let flame: Emitter = (ty("flame"), 320, [fx, fy, fz], [8.0, 4.0, 8.0], 0.01);
let cosy: Emitter = (
ty("campfire_cosy_smoke"),
8,
[fx, fy, fz],
[8.0, 4.0, 8.0],
0.0,
});
}
fn engine(parallel_min: usize, threads: usize) -> ParticleEngine {
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
engine.camera = [0.5, 298.0, 0.5];
engine.parallel_min = parallel_min;
engine.parallel_threads = threads;
engine
}
/// Every live particle's fields, as text.
fn state(engine: &ParticleEngine) -> Vec<String> {
engine
.particles
.iter()
.map(|p| format!("{:?}", engine.snapshot(p)))
.collect()
}
#[test]
fn parallel_tick_matches_serial_tick() {
with_scene(|level| {
let types = emitters();
// One engine ticks every particle in turn; the other on three threads. Same seeds, same spawns.
let mut serial = engine(usize::MAX, 1);
let mut threaded = engine(0, 3);
for e in [&mut serial, &mut threaded] {
e.seed_randoms(7, 8, 9);
}
let (mut a, mut b) = (Lcg(99), Lcg(99));
for tick in 0..90 {
spawn_tick(&mut serial, &mut a, &types, 3);
spawn_tick(&mut threaded, &mut b, &types, 3);
serial.tick(level);
threaded.tick(level);
assert_eq!(serial.len(), threaded.len(), "tick {tick}");
if tick % 10 == 9 {
assert_eq!(state(&serial), state(&threaded), "tick {tick}");
}
}
assert!(serial.len() > 1000, "{} particles", serial.len());
assert_eq!(serial.sounds.len(), threaded.sounds.len());
assert_eq!(serial.next_randoms(), threaded.next_randoms());
});
}
/// Spawns and ticks `types` once the engine is full, for `secs`, and prints the cost per tick.
fn measure(
level: &ParticleLevel,
name: &str,
(types, rockets): (&[Emitter], usize),
(parallel_min, threads): (usize, usize),
secs: f64,
) {
let mut engine = engine(parallel_min, threads);
let mut rng = Lcg(1234);
for _ in 0..600 {
spawn_tick(&mut engine, &mut rng, types, rockets);
engine.tick(level);
}
let (mut spawn, mut tick, mut ticks, mut alive) =
(Duration::ZERO, Duration::ZERO, 0u64, 0usize);
let end = Instant::now() + Duration::from_secs_f64(secs);
while Instant::now() < end {
let t = Instant::now();
spawn_tick(&mut engine, &mut rng, types, rockets);
spawn += t.elapsed();
let t = Instant::now();
engine.tick(level);
tick += t.elapsed();
alive += engine.len();
ticks += 1;
}
let per = |d: Duration| d.as_secs_f64() * 1000.0 / ticks as f64;
let alive = alive / ticks as usize;
eprintln!(
"{name:24} alive {alive:6} | per tick: spawn {:6.3} ms, engine tick {:6.3} ms | {:5.0} ns per particle",
per(spawn),
per(tick),
per(tick) * 1.0e6 / alive.max(1) as f64
);
let rod: Emitter = (ty("end_rod"), 80, [fx, fy, fz], [8.0, 4.0, 8.0], 0.05);
let poof: Emitter = (ty("poof"), 258, tnt, [3.0, 3.0, 3.0], 0.4);
let smoke: Emitter = (ty("smoke"), 238, tnt, [3.0, 3.0, 3.0], 0.4);
let cases: [(&str, Vec<Emitter>, usize); 7] = [
("flame 320/tick", vec![flame], 0),
("cosy smoke 8/tick", vec![cosy], 0),
("end rod 80/tick", vec![rod], 0),
("poof 258/tick", vec![poof], 0),
("smoke 238/tick", vec![smoke], 0),
("3 rockets/tick", vec![], 3),
("the whole scene", vec![flame, cosy, rod, poof, smoke], 3),
];
let secs: f64 = std::env::var("PARTICLE_BENCH_SECS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(2.0);
for (name, types, rockets) in cases {
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
engine.camera = [0.5, 298.0, 0.5];
let mut rng = Lcg(1234);
for _ in 0..600 {
spawn_tick(&mut engine, &mut rng, &types, rockets);
engine.tick(&level);
}
#[test]
#[ignore = "a measurement, not a check"]
fn particle_tick_cost() {
with_scene(|level| {
let [flame, cosy, rod, poof, smoke] = emitters();
let all = [flame, cosy, rod, poof, smoke];
let secs: f64 = std::env::var("PARTICLE_BENCH_SECS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(2.0);
let one = (usize::MAX, 1);
eprintln!("default threads: {}", parallel_threads());
for (name, types, rockets) in [
("flame 320/tick", vec![flame], 0),
("cosy smoke 8/tick", vec![cosy], 0),
("end rod 80/tick", vec![rod], 0),
("poof 258/tick", vec![poof], 0),
("smoke 238/tick", vec![smoke], 0),
("3 rockets/tick", vec![], 3),
] {
measure(level, name, (&types, rockets), one, secs);
}
let (mut spawn, mut tick, mut ticks, mut alive) =
(Duration::ZERO, Duration::ZERO, 0u64, 0usize);
let end = Instant::now() + Duration::from_secs_f64(secs);
while Instant::now() < end {
let t = Instant::now();
spawn_tick(&mut engine, &mut rng, &types, rockets);
spawn += t.elapsed();
let t = Instant::now();
engine.tick(&level);
tick += t.elapsed();
alive += engine.len();
ticks += 1;
measure(level, "whole scene, 1 thread", (&all, 3), one, secs);
for threads in [2, 3, 4, 6] {
measure(
level,
&format!("whole scene, {threads} threads"),
(&all, 3),
(PARALLEL_MIN, threads),
secs,
);
}
let per = |d: Duration| d.as_secs_f64() * 1000.0 / ticks as f64;
let alive = alive / ticks as usize;
eprintln!(
"{name:20} alive {alive:6} | per tick: spawn {:6.3} ms, engine tick {:6.3} ms | {:5.0} ns per particle",
per(spawn),
per(tick),
per(tick) * 1.0e6 / alive.max(1) as f64
);
}
});
}
@@ -203,6 +203,9 @@ pub struct Settings {
pub startup_api: crate::graphics::GraphicsApi,
/// `Monitor.videoModes` of the window's monitor, for the Video page (set by the game).
pub video_modes: Vec<crate::window::VideoMode>,
/// Fullbright is held off: this window plays on another's server (set by the game, not
/// saved). The graphics screen hides its row; `extras.fullbright` keeps the choice.
pub fullbright_held: bool,
/// Controller settings (`horizon-controller.txt`, Horizon's own).
pub controller: crate::gamepad::PadSettings,
/// Horizon's optional extras (`horizon-extras.txt`), all off by default.
@@ -293,6 +296,7 @@ impl Default for Settings {
horizon: Default::default(),
startup_api: Default::default(),
video_modes: Vec::new(),
fullbright_held: false,
controller: Default::default(),
extras: Default::default(),
mods: Default::default(),
@@ -514,6 +518,7 @@ impl Settings {
horizon: Default::default(),
startup_api: Default::default(),
video_modes: Vec::new(),
fullbright_held: false,
controller: Default::default(),
extras: Default::default(),
mods: Default::default(),
+332 -7
View File
@@ -932,7 +932,56 @@ pub struct Downloader {
failed: std::collections::HashMap<(Kind, String), std::time::Instant>,
/// How long a failed texture download waits before it is tried again.
retry_after: std::time::Duration,
/// The `states` keys, oldest first, so the oldest settled one can go ([`Bounds::states`]).
state_order: std::collections::VecDeque<(Kind, String)>,
/// The `resources` keys, oldest first ([`Bounds::resources`]).
resource_order: std::collections::VecDeque<String>,
/// Downloads started whose answer `state` has not taken yet: one thread each.
in_flight: usize,
/// Fetches a texture's body; the HTTP GET, but for a test.
fetch: fn(&str) -> Option<Vec<u8>>,
bounds: Bounds,
}
/// A download thread's answer to `state`. If the thread panics before giving it (a body that
/// breaks the decoder, say), dropping this answers "failed" instead: the download's place in
/// `in_flight` is given back and the texture is retried after the wait, where an answer that never
/// came kept the key `Pending` for good and, with the bound, the place taken for the session.
struct Answer {
send: std::sync::mpsc::Sender<((Kind, String), Option<Vec<u8>>)>,
key: Option<(Kind, String)>,
}
impl Answer {
fn give(&mut self, image: Option<Vec<u8>>) {
if let Some(key) = self.key.take() {
let _ = self.send.send((key, image));
}
}
}
impl Drop for Answer {
fn drop(&mut self) {
self.give(None);
}
}
/// What the downloader keeps and runs at most. Every texture URL a server (or a tab list of
/// hundreds of players) names used to add a `states` entry and a thread of its own, and every
/// `texture` id a `resources` entry, none of them ever dropped.
#[derive(Clone, Copy, Debug)]
struct Bounds {
/// Entries of `states`; the oldest settled (ready or failed) one is dropped past this. A
/// dropped texture is loaded again from the disk cache when it is asked for.
states: usize,
/// Entries of `resources`; the oldest is dropped past this, and read from the packs again.
resources: usize,
/// Downloads running at once. Past this `state` says `Pending` and starts nothing, so the
/// caller, which asks again every frame, starts it when one has finished.
downloads: usize,
}
const BOUNDS: Bounds = Bounds {
states: 4096,
resources: 4096,
downloads: 16,
};
/// `images` is bounded only so a long session on a busy server does not grow
/// without end; the disk cache holds everything it drops.
const MAX_SKIN_IMAGES: usize = 1024;
@@ -964,6 +1013,11 @@ impl Downloader {
resources: Default::default(),
failed: Default::default(),
retry_after: std::time::Duration::from_secs(60),
state_order: Default::default(),
resource_order: Default::default(),
in_flight: 0,
fetch: http_get,
bounds: BOUNDS,
}
}
/// The resource packs changed (or were first loaded) at `root`: patch
@@ -973,6 +1027,7 @@ impl Downloader {
self.images.remove(&location);
self.image_order.retain(|l| *l != location);
}
self.resource_order.clear();
self.resource_root = root;
}
/// `ClientAsset.ResourceTexture`: loads `<namespace>:textures/<path>.png`
@@ -1002,12 +1057,47 @@ impl Downloader {
}
None => Download::Failed,
};
self.resources.insert(location.to_owned(), state);
if self.resources.insert(location.to_owned(), state).is_none() {
self.resource_order.push_back(location.to_owned());
while self.resources.len() > self.bounds.resources
&& let Some(old) = self.resource_order.pop_front()
{
self.resources.remove(&old);
}
}
}
/// Forgets how `location` loaded, so `resource` reads it again.
fn forget_resource(&mut self, location: &str) {
if self.resources.remove(location).is_some() {
self.resource_order.retain(|l| l != location);
}
}
/// Records how `key` stands. A new key may push the oldest settled one out of `states`;
/// downloads running are never dropped, so their answer has a key to land on.
fn set_state(&mut self, key: (Kind, String), state: Download) {
if self.states.insert(key.clone(), state).is_some() {
return;
}
self.state_order.push_back(key);
while self.states.len() > self.bounds.states {
let Some(at) = self
.state_order
.iter()
.position(|k| self.states.get(k) != Some(&Download::Pending))
else {
break;
};
if let Some(old) = self.state_order.remove(at) {
self.states.remove(&old);
self.failed.remove(&old);
}
}
}
/// How the download of `url` stands, starting it the first time. A
/// failure is permanent for `retry_after`, then tried again.
pub fn state(&mut self, kind: Kind, url: &str) -> Download {
while let Ok((key, image)) = self.done.try_recv() {
self.in_flight = self.in_flight.saturating_sub(1);
let state = match image {
Some(rgba) => {
match key.0 {
@@ -1023,29 +1113,42 @@ impl Downloader {
Download::Failed
}
};
self.states.insert(key, state);
self.set_state(key, state);
}
let Some(hash) = texture_hash(url) else {
return Download::Failed;
};
let key = (kind, hash);
let full = self.in_flight >= self.bounds.downloads;
if let Some(state) = self.states.get(&key).copied() {
let due = self
.failed
.get(&key)
.is_none_or(|at| at.elapsed() >= self.retry_after);
if state != Download::Failed || !due {
// A failure that is due waits for a free download like a new texture does.
if state != Download::Failed || !due || full {
return state;
}
self.states.remove(&key);
self.failed.remove(&key);
self.state_order.retain(|k| *k != key);
}
self.states.insert(key.clone(), Download::Pending);
if full {
// Not recorded: the next call asks again, and starts it once one has finished.
return Download::Pending;
}
self.in_flight += 1;
self.set_state(key.clone(), Download::Pending);
let digest: String = skin_location(&key.1)["skins/".len()..].to_owned();
let (url, send, root) = (url.to_owned(), self.send.clone(), self.root.clone());
let fetch = self.fetch;
std::thread::spawn(move || {
let image = Self::load_texture(&root, kind, &digest, &url, http_get);
let _ = send.send((key, image));
let mut answer = Answer {
send,
key: Some(key),
};
let image = Self::load_texture(&root, kind, &digest, &url, fetch);
answer.give(image);
});
Download::Pending
}
@@ -1108,7 +1211,7 @@ impl Downloader {
if location.starts_with("patch/") {
let Some(image) = self.images.get(location).cloned() else {
// Dropped from memory: read from the packs again.
self.resources.remove(location);
self.forget_resource(location);
return false;
};
self.queue(location.to_owned(), image);
@@ -1925,3 +2028,225 @@ mod tests {
assert_eq!(served.load(Ordering::SeqCst), 2);
}
}
#[cfg(test)]
mod download_bound_tests {
use super::{Bounds, Download, Downloader, Kind};
use std::sync::atomic::{AtomicUsize, Ordering};
fn downloader(name: &str, bounds: Bounds, fetch: fn(&str) -> Option<Vec<u8>>) -> Downloader {
let root =
std::env::temp_dir().join(format!("horizon-test-skin-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let mut d = Downloader::new(root);
d.fetch = fetch;
d.bounds = bounds;
d
}
fn url(i: usize) -> String {
format!("https://textures.example/texture/skin{i:05}")
}
/// Asks again until the texture's download has settled.
fn wait(d: &mut Downloader, url: &str) -> Download {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
loop {
let state = d.state(Kind::Skin, url);
if state != Download::Pending || std::time::Instant::now() > deadline {
return state;
}
std::thread::sleep(std::time::Duration::from_millis(2));
}
}
static RUNNING: AtomicUsize = AtomicUsize::new(0);
static PEAK: AtomicUsize = AtomicUsize::new(0);
/// A fetch that is slow enough for the others to pile up behind it, and counts how many run.
fn slow_failure(_: &str) -> Option<Vec<u8>> {
let now = RUNNING.fetch_add(1, Ordering::SeqCst) + 1;
PEAK.fetch_max(now, Ordering::SeqCst);
std::thread::sleep(std::time::Duration::from_millis(20));
RUNNING.fetch_sub(1, Ordering::SeqCst);
None
}
/// The same, not counted: tests run side by side, and the count is another test's.
fn sleepy_failure(_: &str) -> Option<Vec<u8>> {
std::thread::sleep(std::time::Duration::from_millis(20));
None
}
/// A tab list of 60 players names 60 textures at once: no more than the bound run together,
/// the ones that waited start as the others finish, and every one ends. Every frame asks again.
#[test]
fn downloads_run_a_few_at_a_time_and_the_late_ones_start_later() {
let bounds = Bounds {
states: 4096,
resources: 4096,
downloads: 4,
};
let mut d = downloader("threads", bounds, slow_failure);
let urls: Vec<String> = (0..60).map(url).collect();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
let mut first = true;
loop {
let states: Vec<Download> = urls.iter().map(|u| d.state(Kind::Skin, u)).collect();
if first {
// Asked all at once: four started, and the others were not even recorded.
assert_eq!(d.in_flight, 4);
assert_eq!(d.states.len(), 4);
assert!(states.iter().all(|s| *s == Download::Pending));
first = false;
}
if states.iter().all(|s| *s == Download::Failed) {
break;
}
assert!(
std::time::Instant::now() < deadline,
"{} still pending",
states.len()
);
std::thread::sleep(std::time::Duration::from_millis(2));
}
assert!(
PEAK.load(Ordering::SeqCst) <= 4,
"{}",
PEAK.load(Ordering::SeqCst)
);
assert_eq!(d.states.len(), 60);
assert_eq!(d.in_flight, 0);
}
/// More textures than `states` holds: the map keeps to its bound, the oldest settled ones go
/// (and their failure time with them), and one that went is asked again as a new texture.
#[test]
fn the_states_keep_to_their_bound_and_drop_the_oldest() {
let bounds = Bounds {
states: 8,
resources: 4096,
downloads: 16,
};
let mut d = downloader("states", bounds, |_| None);
for i in 0..40 {
let u = url(i);
assert_eq!(wait(&mut d, &u), Download::Failed, "{i}");
assert!(d.states.len() <= 8 && d.state_order.len() == d.states.len());
assert!(d.failed.len() <= 8);
}
let kept: Vec<usize> = (0..40)
.filter(|i| {
d.states
.contains_key(&(Kind::Skin, super::texture_hash(&url(*i)).unwrap()))
})
.collect();
assert_eq!(kept, (32..40).collect::<Vec<_>>());
// The first one went: asked again, it starts again (and fails again).
assert_eq!(d.state(Kind::Skin, &url(0)), Download::Pending);
assert_eq!(d.in_flight, 1);
}
/// A fetch that panics, as a decoder can on a body a server chose.
fn panicking(_: &str) -> Option<Vec<u8>> {
panic!("a fetch that panics, on purpose");
}
/// A thread that panics before it answers still gives its place back: with two places and six
/// textures whose fetch panics, every one settles as failed instead of the places staying
/// taken for good.
#[test]
fn a_download_that_panics_gives_its_place_back() {
let bounds = Bounds {
states: 4096,
resources: 4096,
downloads: 2,
};
let mut d = downloader("panics", bounds, panicking);
for i in 0..6 {
assert_eq!(wait(&mut d, &url(i)), Download::Failed, "{i}");
}
assert_eq!(d.in_flight, 0);
assert_eq!(d.states.len(), 6);
}
/// A fetch long enough for a test to ask between its start and its end.
fn slowish_failure(_: &str) -> Option<Vec<u8>> {
std::thread::sleep(std::time::Duration::from_millis(150));
None
}
/// A failure whose wait is over is retried like a new texture: not while every place is taken
/// (it keeps answering `Failed`, and starts nothing), and as soon as one is free.
#[test]
fn a_failure_that_is_due_waits_for_a_free_place() {
let bounds = Bounds {
states: 4096,
resources: 4096,
downloads: 1,
};
let mut d = downloader("due", bounds, slowish_failure);
let wait_time = d.retry_after;
assert_eq!(wait(&mut d, &url(0)), Download::Failed);
// Another texture takes the only place; the first one's retry becomes due but has to wait.
assert_eq!(d.state(Kind::Skin, &url(1)), Download::Pending);
d.retry_after = std::time::Duration::ZERO;
assert_eq!(d.state(Kind::Skin, &url(0)), Download::Failed);
assert_eq!(d.in_flight, 1);
d.retry_after = wait_time;
assert_eq!(wait(&mut d, &url(1)), Download::Failed);
// The place is free: the retry starts.
d.retry_after = std::time::Duration::ZERO;
assert_eq!(d.state(Kind::Skin, &url(0)), Download::Pending);
assert_eq!(d.in_flight, 1);
}
/// A texture that is running is never the one dropped, however many settle behind it.
#[test]
fn a_running_download_keeps_its_place_in_the_states() {
let bounds = Bounds {
states: 2,
resources: 4096,
downloads: 16,
};
let mut d = downloader("running", bounds, sleepy_failure);
assert_eq!(d.state(Kind::Skin, &url(0)), Download::Pending);
for i in 1..6 {
d.set_state((Kind::Cape, format!("settled{i}")), Download::Ready);
}
assert_eq!(
d.states
.get(&(Kind::Skin, super::texture_hash(&url(0)).unwrap())),
Some(&Download::Pending)
);
assert!(d.states.len() <= 3);
assert_eq!(wait(&mut d, &url(0)), Download::Failed);
}
/// `resources` keeps to its bound too: a server's `texture` ids, one for each unique one, no
/// longer pile up, and one that was dropped is read from the packs again.
#[test]
fn the_resource_textures_keep_to_their_bound() {
let bounds = Bounds {
states: 4096,
resources: 8,
downloads: 16,
};
let mut d = downloader("resources", bounds, |_| None);
for i in 0..40 {
d.resource(&format!("patch/n/{i}"), "n", &format!("t{i}"));
assert!(d.resources.len() <= 8 && d.resource_order.len() == d.resources.len());
}
let kept: Vec<usize> = (0..40)
.filter(|i| d.resources.contains_key(&format!("patch/n/{i}")))
.collect();
assert_eq!(kept, (32..40).collect::<Vec<_>>());
// Forgetting one by hand (as `again` does) leaves no stale key behind.
d.forget_resource("patch/n/35");
assert_eq!(d.resource_order.len(), d.resources.len());
assert!(!d.resource_order.iter().any(|l| l == "patch/n/35"));
// New packs start the count over.
d.set_resource_root(std::env::temp_dir());
assert!(d.resources.is_empty() && d.resource_order.is_empty());
}
}
+98 -19
View File
@@ -127,6 +127,9 @@ pub struct Sprites {
/// textures under their own names, and one layer serves them all (the GPU's array may
/// hold as few as 2048).
still: std::collections::HashMap<StillKey, u32>,
/// Names a connected-texture rule matches by name (`matchTiles`, `connectTiles`): each keeps a
/// layer of its own ([`Sprites::keep_apart`]).
apart: std::collections::HashSet<String>,
/// Decoded textures kept on disk between launches ([`Sprites::with_disk_cache`]).
disk: Option<crate::sprite_cache::SpriteCache>,
/// Textures [`Sprites::prefetch`] decoded ahead of their insert, by sprite name or
@@ -218,6 +221,13 @@ impl Sprites {
..Self::default()
}
}
/// Keeps each of `names` on a layer of its own, never shared with an identical texture: the
/// connected-texture rules that name them find their faces by layer, so a texture sharing it
/// would be matched too (a tile a rule chains onto, and a model's transparent placeholder
/// with the same pixels). Before the textures that bear these names are inserted.
pub fn keep_apart(&mut self, names: impl IntoIterator<Item = String>) {
self.apart.extend(names);
}
/// Write what this run decoded to the cache file.
pub fn save_disk_cache(&mut self) {
if let Some(disk) = &mut self.disk {
@@ -287,8 +297,13 @@ impl Sprites {
let mut resized = Vec::with_capacity(self.native.len());
self.pixels.clear();
for (from, frames, initial) in &self.native {
let frames: Arc<Vec<Vec<u8>>> =
Arc::new(frames.iter().map(|f| resample(f, *from, size)).collect());
// Native frames already match the atlas size: share the immutable
// buffers with animations rather than copying every frame again.
let frames: Arc<Vec<Vec<u8>>> = if *from == size {
Arc::clone(frames)
} else {
Arc::new(frames.iter().map(|f| resample(f, *from, size)).collect())
};
self.pixels.extend_from_slice(&frames[*initial]);
resized.push(frames);
}
@@ -686,23 +701,24 @@ impl Sprites {
.is_some_and(|(_, p)| p.starts_with("item/")),
dither_cutouts: crate::mipmap::dither_cutouts(),
};
let still = (animation.is_none() && frames.len() == 1).then(|| {
use std::hash::{Hash, Hasher};
let mut frame = std::collections::hash_map::DefaultHasher::new();
frames[0].hash(&mut frame);
let mut maps = std::collections::hash_map::DefaultHasher::new();
pbr.hash(&mut maps);
StillKey {
frame: frame.finish(),
pbr: maps.finish(),
side,
settings: [
settings.strategy as u32,
settings.alpha_cutoff_bias.to_bits(),
],
flags: [settings.has_transparent, settings.item, opaque, translucent],
}
});
let still =
(animation.is_none() && frames.len() == 1 && !self.apart.contains(name)).then(|| {
use std::hash::{Hash, Hasher};
let mut frame = std::collections::hash_map::DefaultHasher::new();
frames[0].hash(&mut frame);
let mut maps = std::collections::hash_map::DefaultHasher::new();
pbr.hash(&mut maps);
StillKey {
frame: frame.finish(),
pbr: maps.finish(),
side,
settings: [
settings.strategy as u32,
settings.alpha_cutoff_bias.to_bits(),
],
flags: [settings.has_transparent, settings.item, opaque, translucent],
}
});
if let Some(&same) = still.as_ref().and_then(|k| self.still.get(k)) {
self.layers.insert(name.to_owned(), same);
return Ok(same);
@@ -872,6 +888,35 @@ mod tests {
use super::*;
use std::fs::{self, File};
#[test]
fn native_frames_at_atlas_size_are_shared_without_resampling() {
let pixels = vec![
[10, 20, 30, 255].repeat((SPRITE_SIZE * SPRITE_SIZE) as usize),
[40, 50, 60, 255].repeat((SPRITE_SIZE * SPRITE_SIZE) as usize),
];
let native = Arc::new(pixels.clone());
let smaller = Arc::new(vec![vec![12; 8 * 8 * 4], vec![34; 8 * 8 * 4]]);
let metadata = r#"{"frametime":2}"#;
let mut sprites = Sprites {
native: vec![
(SPRITE_SIZE, Arc::clone(&native), 1),
(8, Arc::clone(&smaller), 0),
],
pbr: vec![[None, None], [None, None]],
animations: vec![
test_frames(0, pixels.clone(), metadata),
test_frames(1, smaller.as_ref().clone(), metadata),
],
..Default::default()
};
sprites.finish();
assert!(Arc::ptr_eq(&sprites.animations[0].frames, &native));
assert!(!Arc::ptr_eq(&sprites.animations[1].frames, &smaller));
assert_eq!(sprites.animations[0].pixels(1), pixels[1].as_slice());
let resized = resample(&smaller[0], 8, SPRITE_SIZE);
assert_eq!(sprites.animations[1].pixels(0), resized.as_slice());
assert_eq!(&sprites.pixels[..pixels[1].len()], pixels[1].as_slice());
}
#[test]
fn the_texture_budget_picks_the_largest_side_that_fits() {
// 1300 layers: vanilla's count, about.
assert_eq!(side_within(512, 1300, DEFAULT_TEXTURE_BUDGET), 128);
@@ -1282,6 +1327,40 @@ mod tests {
assert_eq!(sprites.pixels.len(), 3 * 16 * 16 * 4);
}
#[test]
fn a_texture_a_connected_rule_matches_keeps_a_layer_of_its_own() {
// A resource pack's transparent placeholders: a model's layer and a tile another
// connected-texture rule chains onto (`matchTiles=./19.png`) have the same pixels. The
// rule finds its faces by layer, so the one it names can't share the others'.
let root =
std::env::temp_dir().join(format!("horizon-sprite-apart-{}", std::process::id()));
let directory = root.join("assets/a/textures/block");
fs::create_dir_all(&directory).unwrap();
let write = |name: &str| {
let mut encoder =
png::Encoder::new(File::create(directory.join(name)).unwrap(), 16, 16);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
encoder
.write_header()
.unwrap()
.write_image_data(&[0; 16 * 16 * 4])
.unwrap();
};
for name in ["placeholder.png", "named.png", "again.png"] {
write(name);
}
let mut sprites = Sprites::default();
sprites.keep_apart(["a:block/named".to_owned()]);
let placeholder = sprites.insert(&root, "a:block/placeholder").unwrap();
let named = sprites.insert(&root, "a:block/named").unwrap();
let again = sprites.insert(&root, "a:block/again").unwrap();
fs::remove_dir_all(&root).unwrap();
// The named one alone; the others still share.
assert_ne!(named, placeholder);
assert_eq!(again, placeholder);
assert_eq!(sprites.count(), 2);
}
#[test]
fn a_32px_only_atlas_does_not_add_an_extra_mipmap() {
let mut sprites = Sprites {
native: vec![
+26 -22
View File
@@ -605,6 +605,9 @@ pub struct Streaming {
/// Set while `update` runs, after it refreshed `cover`: nothing else can
/// change `extra` meanwhile, so `cover` answers without comparing them.
cover_current: bool,
/// `forced` and `mod_forced` as each column's distance to the nearest one, made again when
/// they change; read like `cover`, while `cover_current`.
forced_cover: Option<ForcedCover>,
/// Mesh the sections for drawing (false on a server thread).
pub mesh: bool,
/// The pregeneration band (`set_band`).
@@ -806,6 +809,7 @@ impl GenerationScan {
forced: input.ranges.forced,
mod_forced: input.ranges.mod_forced,
cover: input.ranges.cover,
forced_cover: None,
band: input.ranges.band,
};
self.remember(ranges, input.world);
@@ -1066,8 +1070,10 @@ pub struct WorkerContext {
pub light: Arc<LightTables>,
}
mod bounded;
mod forced_cover;
mod relight_now;
use bounded::nearest_first;
use forced_cover::ForcedCover;
impl Streaming {
pub fn new<G: ChunkGenerator>(
generator: Arc<G>,
@@ -1176,6 +1182,7 @@ impl Streaming {
extra: Vec::new(),
cover: None,
cover_current: false,
forced_cover: None,
mesh: true,
band: None,
band_ranked: None,
@@ -1691,6 +1698,7 @@ impl Streaming {
let mut columns = std::mem::take(&mut self.mesh_columns_scratch);
columns.clear();
ExtraCover::refresh(&mut self.cover, &self.extra);
ForcedCover::refresh(&mut self.forced_cover, &self.forced, &self.mod_forced);
self.cover_current = true;
// The sweeps below walk every loaded column and proto chunk. Run them
// at once when the view moves and every eighth tick otherwise: a
@@ -1870,6 +1878,7 @@ impl Streaming {
forced: &self.forced,
mod_forced: &self.mod_forced,
cover: ExtraCover::current(&self.cover, &self.extra, self.cover_current),
forced_cover: self.forced_cover.as_ref().filter(|_| self.cover_current),
band: self.band.as_ref(),
};
let (protos, generating) = (&self.protos, &self.generating);
@@ -2040,6 +2049,7 @@ impl Streaming {
forced: &self.forced,
mod_forced: &self.mod_forced,
cover: ExtraCover::current(&self.cover, &self.extra, self.cover_current),
forced_cover: self.forced_cover.as_ref().filter(|_| self.cover_current),
band: self.band.as_ref(),
};
self.waiting.retain(|&(x, sy, z)| {
@@ -2145,6 +2155,7 @@ impl Streaming {
forced: &self.forced,
mod_forced: &self.mod_forced,
cover: ExtraCover::current(&self.cover, &self.extra, self.cover_current),
forced_cover: self.forced_cover.as_ref().filter(|_| self.cover_current),
band: self.band.as_ref(),
},
world,
@@ -2283,6 +2294,7 @@ impl Streaming {
forced: &self.forced,
mod_forced: &self.mod_forced,
cover: ExtraCover::current(&self.cover, &self.extra, self.cover_current),
forced_cover: self.forced_cover.as_ref().filter(|_| self.cover_current),
band: self.band.as_ref(),
}
}
@@ -2563,6 +2575,8 @@ struct Ranges<'a> {
/// `extra` indexed by a grid or buckets: coverage queries without a
/// scan of every area.
cover: Option<&'a ExtraCover>,
/// `forced` and `mod_forced` by distance (`Streaming::forced_cover`), while they match it.
forced_cover: Option<&'a ForcedCover>,
band: Option<&'a Band>,
}
/// The chunks a pregeneration task (`/pregen`) wants lit now: rows `z0..=z1`
@@ -2611,6 +2625,7 @@ impl Band {
+ i64::from(x - self.x0 + 8).max(0)
}
}
/// For every column near the extra areas, how far it lies outside the
/// nearest one's square (Chebyshev distance less its radius; 0 inside). A server has an area per player, and streaming asks of every
/// loaded and wanted column whether one reaches it, several times a tick:
@@ -2918,16 +2933,6 @@ impl Ranges<'_> {
.iter()
.any(|&(c, radius)| (x - c.0).abs().max((z - c.1).abs()) <= radius + margin)
}
/// Whether a `/forceload` or a native mod's ticket holds the chunk.
fn is_forced(&self, x: i32, z: i32) -> bool {
self.forced.contains(&(x, z)) || self.mod_forced.contains(&(x, z))
}
/// Every chunk a `/forceload` or a native mod's ticket holds, once each.
fn forced_chunks(&self) -> impl Iterator<Item = &(i32, i32)> + '_ {
self.forced
.iter()
.chain(self.mod_forced.iter().filter(|c| !self.forced.contains(c)))
}
/// The columns meshed for drawing: those vanilla draws around the
/// player (`in_view_distance`), and forced ones.
fn in_view(&self, x: i32, z: i32) -> bool {
@@ -2939,18 +2944,7 @@ impl Ranges<'_> {
if self.radius >= 0 && dx * dx + dz * dz <= r * r {
return true;
}
// A forced chunk holds a FORCED ticket at level 31
// (`ChunkMap.FORCED_TICKET_LEVEL`), and the level rises one per ring
// (`ChunkTracker.computeLevelFromNeighbor`): the margin is the
// vanilla status ring the caller asks for.
let reach = match margin {
1 => FORCED_FULL_RING,
2 => FORCED_FEATURES_RING,
3 => FORCED_TERRAIN_RING,
_ => margin + 1,
};
self.forced_chunks()
.any(|f| (x - f.0).abs() <= reach && (z - f.1).abs() <= reach)
self.near_forced(x, z, margin)
}
}
impl Drop for Streaming {
@@ -3484,6 +3478,7 @@ mod tests {
forced: &forced,
mod_forced: &NO_CHUNKS,
cover: None,
forced_cover: None,
band: None,
};
let generate = |x, z, priority, order| Queued {
@@ -3724,6 +3719,7 @@ mod tests {
forced: &self.forced,
mod_forced: &NO_CHUNKS,
cover: None,
forced_cover: None,
band: None,
},
world: &self.world,
@@ -4107,6 +4103,7 @@ mod tests {
forced: &forced,
mod_forced: &NO_CHUNKS,
cover: None,
forced_cover: None,
band: None,
};
let square = |x: i32, z: i32, n: i32| {
@@ -4164,6 +4161,7 @@ mod tests {
forced: &forced,
mod_forced: &NO_CHUNKS,
cover: None,
forced_cover: None,
band: None,
};
let span = radius * 3 + 20;
@@ -4201,6 +4199,7 @@ mod tests {
forced: &forced,
mod_forced: &NO_CHUNKS,
cover: None,
forced_cover: None,
band: None,
};
for x in -5i32..=15 {
@@ -4235,6 +4234,7 @@ mod tests {
forced: &forced,
mod_forced: &NO_CHUNKS,
cover: None,
forced_cover: None,
band: None,
};
let level = |x: i32, z: i32| 31 + ((x - 40).abs().max((z + 9).abs()) - view).max(0);
@@ -4290,6 +4290,7 @@ mod tests {
forced: &forced,
mod_forced: &NO_CHUNKS,
cover: None,
forced_cover: None,
band: None,
};
let level = |x: i32, z: i32| 31 + (x - 40).abs().max((z + 40).abs());
@@ -4430,6 +4431,7 @@ mod cover_tests {
forced: &forced,
mod_forced: &NO_CHUNKS,
cover,
forced_cover: None,
band: None,
};
let (scan, grid) = (ranges(None), ranges(Some(&cover)));
@@ -4473,6 +4475,7 @@ mod cover_tests {
forced: &forced,
mod_forced: &NO_CHUNKS,
cover,
forced_cover: None,
band: None,
};
let (scan, buckets) = (ranges(None), ranges(Some(&cover)));
@@ -4539,6 +4542,7 @@ mod cover_tests {
forced: &forced,
mod_forced: &NO_CHUNKS,
cover,
forced_cover: None,
band: None,
};
// The columns streaming asks about: everything kept, and the
@@ -0,0 +1,165 @@
//! The chunks a `/forceload` or a native mod's ticket holds, as `Ranges` reads them, and their
//! distance map.
use super::{FORCED_FEATURES_RING, FORCED_FULL_RING, FORCED_TERRAIN_RING, Ranges};
use crate::fast_hash::FxHashMap;
use std::collections::HashSet;
/// `Streaming::forced` and `mod_forced` as the Chebyshev distance from each column within
/// `REACH` of one to the nearest: `Ranges::in_main_range` answers by one lookup instead of going
/// through every forced chunk (it is asked for every column, several times a tick).
pub(super) struct ForcedCover {
forced: HashSet<(i32, i32)>,
mod_forced: HashSet<(i32, i32)>,
near: FxHashMap<(i32, i32), u8>,
}
impl ForcedCover {
/// The widest ring `in_main_range` asks of a forced chunk (margins 1 to 4).
const REACH: i32 = FORCED_TERRAIN_RING + 1;
/// Past this many forced chunks the cover is not made: the scan answers.
const MOST: usize = 4096;
pub(super) fn refresh(
cover: &mut Option<Self>,
forced: &HashSet<(i32, i32)>,
mod_forced: &HashSet<(i32, i32)>,
) {
if cover
.as_ref()
.is_some_and(|c| c.forced == *forced && c.mod_forced == *mod_forced)
{
return;
}
if forced.len() + mod_forced.len() > Self::MOST {
*cover = None;
return;
}
let mut near = FxHashMap::default();
for &(fx, fz) in forced.iter().chain(mod_forced) {
for dx in -Self::REACH..=Self::REACH {
for dz in -Self::REACH..=Self::REACH {
let d = dx.abs().max(dz.abs()) as u8;
near.entry((fx + dx, fz + dz))
.and_modify(|e: &mut u8| *e = (*e).min(d))
.or_insert(d);
}
}
}
*cover = Some(Self {
forced: forced.clone(),
mod_forced: mod_forced.clone(),
near,
});
}
/// Whether a forced chunk lies within `reach` of `(x, z)`, or `None` past `REACH`.
pub(super) fn within(&self, x: i32, z: i32, reach: i32) -> Option<bool> {
(reach <= Self::REACH).then(|| {
self.near
.get(&(x, z))
.is_some_and(|&d| i32::from(d) <= reach)
})
}
}
impl Ranges<'_> {
/// Whether a `/forceload` or a native mod's ticket holds the chunk.
pub(super) fn is_forced(&self, x: i32, z: i32) -> bool {
self.forced.contains(&(x, z)) || self.mod_forced.contains(&(x, z))
}
/// Every chunk a `/forceload` or a native mod's ticket holds, once each.
pub(super) fn forced_chunks(&self) -> impl Iterator<Item = &(i32, i32)> + '_ {
self.forced
.iter()
.chain(self.mod_forced.iter().filter(|c| !self.forced.contains(c)))
}
/// `in_main_range` past the player's circle: whether a forced chunk lies within the ring
/// `margin` asks for.
pub(super) fn near_forced(&self, x: i32, z: i32, margin: i32) -> bool {
// A forced chunk holds a FORCED ticket at level 31
// (`ChunkMap.FORCED_TICKET_LEVEL`), and the level rises one per ring
// (`ChunkTracker.computeLevelFromNeighbor`): the margin is the
// vanilla status ring the caller asks for.
let reach = match margin {
1 => FORCED_FULL_RING,
2 => FORCED_FEATURES_RING,
3 => FORCED_TERRAIN_RING,
_ => margin + 1,
};
if let Some(hit) = self.forced_cover.and_then(|c| c.within(x, z, reach)) {
return hit;
}
self.forced_chunks()
.any(|f| (x - f.0).abs() <= reach && (z - f.1).abs() <= reach)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn chunks(seed: &mut u64, n: usize, spread: i32) -> HashSet<(i32, i32)> {
let mut next = |m: i32| {
*seed ^= *seed << 13;
*seed ^= *seed >> 7;
*seed ^= *seed << 17;
(*seed % (2 * m as u64 + 1)) as i32 - m
};
(0..n).map(|_| (next(spread), next(spread))).collect()
}
/// With the forced chunks by distance, every column gets the answer the scan of every forced
/// chunk gives, at every margin streaming asks (and those past the map's reach), after the
/// forced chunks change too.
#[test]
fn the_distances_answer_as_the_scan() {
let mut seed = 0x2545_F491_4F6C_DD1Du64;
let mut cover = None;
for case in 0..24 {
let spread = [3, 12, 40][case % 3];
let forced = chunks(&mut seed, 1 + case % 9, spread);
// Some mod tickets on forced chunks, some apart.
let mut mod_forced = chunks(&mut seed, case % 4, spread);
mod_forced.extend(forced.iter().take(case % 2));
ForcedCover::refresh(&mut cover, &forced, &mod_forced);
let made = cover.as_ref().expect("a few forced chunks get a map");
let ranges = |forced_cover| Ranges {
// The player's circle far away: only the forced chunks answer.
center: (10_000, 10_000),
radius: 2,
extra: &[],
forced: &forced,
mod_forced: &mod_forced,
cover: None,
forced_cover,
band: None,
};
let (scan, map) = (ranges(None), ranges(Some(made)));
let span = spread + 10;
for z in -span..=span {
for x in -span..=span {
for margin in 0..=7 {
assert_eq!(
map.in_main_range(x, z, margin),
scan.in_main_range(x, z, margin),
"case {case} ({x}, {z}) margin {margin}"
);
}
for d in [false, true] {
assert_eq!(map.requested(x, z, d), scan.requested(x, z, d));
assert_eq!(map.chunk_kept(x, z, d), scan.chunk_kept(x, z, d));
assert_eq!(map.unloaded(x, z, d), scan.unloaded(x, z, d));
}
}
}
}
}
/// Too many forced chunks for a map: none is made, and the scan answers.
#[test]
fn past_the_most_no_map_is_made() {
let forced: HashSet<(i32, i32)> = (0..=ForcedCover::MOST as i32).map(|x| (x, 0)).collect();
let mut cover = None;
ForcedCover::refresh(&mut cover, &HashSet::new(), &HashSet::from([(0, 0)]));
assert!(cover.is_some());
ForcedCover::refresh(&mut cover, &forced, &HashSet::new());
assert!(cover.is_none());
}
}
+11 -1
View File
@@ -524,8 +524,18 @@ fn packed_corner(lo: vec3<u32>, hi: vec3<u32>, corner: u32) -> ArenaCorner {
let packed_light = ((light & 63u) << 2u) | (((light >> 6u) & 63u) << 10u);
let g = (lo.y >> (8u * corner)) & 255u;
let t = lo.x;
// Corners 1 to 3's own alpha (`packed::ALPHA_STEP` times the code; 0: word 0's).
var code = 0u;
if corner == 1u {
code = (hi.x >> 28u) & 7u;
} else if corner == 2u {
code = (hi.y >> 24u) & 7u;
} else if corner == 3u {
code = (hi.y >> 27u) & 7u;
}
let alpha = select((code * 51u) << 24u, t & 0xff000000u, code == 0u);
let color = (g * ((t >> 16u) & 255u) / 255u) | ((g * ((t >> 8u) & 255u) / 255u) << 8u)
| ((g * (t & 255u) / 255u) << 16u) | (t & 0xff000000u);
| ((g * (t & 255u) / 255u) << 16u) | alpha;
return ArenaCorner(bitcast<vec3<u32>>(vec3<f32>(pos)),
vec3((u << 10u) | (v << 26u), (hi.x & 0xffffu) | (packed_light << 16u), color));
}
@@ -530,7 +530,7 @@
"HDR Calibration %s/%s: %s": "HDR Calibration %s/%s: %s",
"Sets the three below full screen, one test picture at a time.": "Sets the three below full screen, one test picture at a time.",
"Your display didn't say how bright it gets: calibrate to set it up.": "Your display didn't say how bright it gets: calibrate to set it up.",
"Your display's reported peak is often too high: calibrate to set it up.": "Your display's reported peak is often too high: calibrate to set it up.",
"Auto uses the peak Windows reports. Calibrate if highlights look clipped.": "Auto uses the peak Windows reports. Calibrate if highlights look clipped.",
"Already set up for your display. Only calibrate if HDR looks wrong.": "Already set up for your display. Only calibrate if HDR looks wrong.",
"Puts Peak Brightness and Paper White back to your display's setup.": "Puts Peak Brightness and Paper White back to your display's setup.",
"Set up for your display. Leave on Auto unless it looks wrong.": "Set up for your display. Leave on Auto unless it looks wrong.",
+5 -5
View File
@@ -1989,13 +1989,13 @@ mod tests {
}
/// The criteria an inventory change and a recipe unlock test, on vanilla's tree:
/// `cargo test -p horizon-client --lib -- --ignored --nocapture index_on_vanilla_tree`
/// with HORIZON_RESOURCES set to the prepared resources.
/// with HORIZON_TEST_RESOURCES set to the prepared resources.
#[test]
#[ignore = "needs the prepared resources"]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn index_on_vanilla_tree() {
let Some(root) = std::env::var_os("HORIZON_RESOURCES").map(std::path::PathBuf::from) else {
return;
};
let root = std::path::PathBuf::from(
std::env::var_os("HORIZON_TEST_RESOURCES").expect("HORIZON_TEST_RESOURCES"),
);
let data = root.join("data");
let t = std::time::Instant::now();
let tree = Tree::load(&data);
+39 -8
View File
@@ -26,8 +26,9 @@ pub struct Tab {
/// The page of tabs it is on: vanilla's are 0, mods' after, as NeoForge
/// pages them.
pub page: u32,
/// A Polytone creative tab modifier's `name`: its text and colour, over the tab's own.
pub name: Option<(String, Option<u32>)>,
/// A Polytone creative tab modifier's `name`: its text (a translation key when
/// the flag is set) and colour, over the tab's own.
pub name: Option<(String, Option<u32>, bool)>,
}
impl Tab {
/// The tab without its stacks.
@@ -103,25 +104,28 @@ pub struct Creative {
/// The packs' Polytone creative tab modifiers, applied by `rebuild`.
pack: Vec<crate::polytone_creative_tabs::TabModifier>,
}
/// A text component's plain text and its colour (a name, or `#rrggbb`).
fn component_text(json: &Value) -> (String, Option<u32>) {
/// A text component's text, its colour (a name, or `#rrggbb`) and whether the
/// text is a `translate` key, to be resolved when drawn.
fn component_text(json: &Value) -> (String, Option<u32>, bool) {
match json {
Value::String(s) => (s.clone(), None),
Value::String(s) => (s.clone(), None, false),
Value::Object(o) => {
let translate = o.get("translate").and_then(Value::as_str);
let text = o
.get("text")
.or_else(|| o.get("translate"))
.and_then(Value::as_str)
.or(translate)
.unwrap_or("")
.to_owned();
let translate = o.get("text").is_none() && translate.is_some();
let color = o.get("color").and_then(Value::as_str).and_then(|c| {
c.strip_prefix('#')
.and_then(|hex| u32::from_str_radix(hex, 16).ok())
.or_else(|| crate::text::named_color(c))
});
(text, color)
(text, color, translate)
}
_ => (String::new(), None),
_ => (String::new(), None, false),
}
}
/// The grid's stacks for one tab, search text and language.
@@ -912,6 +916,16 @@ impl Creative {
let y = if tab.top { -28.0 } else { Self::HEIGHT - 4.0 };
(x, y)
}
/// `CreativeModeInventoryScreen.checkTabHovering`: the tab whose icon
/// rectangle, 3 pixels in from its 26x32 slot (`isHovering(x + 3, y + 3,
/// 21, 27)`), holds the cursor. The screen draws its display name.
pub fn hovered_tab(tabs: &[Tab], origin: (f32, f32), cursor: (f32, f32)) -> Option<usize> {
tabs.iter().position(|tab| {
let (x, y) = Self::tab_position(tab);
let (x, y) = (origin.0 + x + 3.0, origin.1 + y + 3.0);
cursor.0 >= x && cursor.0 < x + 21.0 && cursor.1 >= y && cursor.1 < y + 27.0
})
}
/// `CreativeModeInventoryScreen.extractTabButton`'s cursor: an unselected
/// tab asks for the pointing hand when the whole-pixel mouse, relative to
/// the screen origin, is strictly inside its 26x32 button.
@@ -1789,4 +1803,21 @@ mod tests {
assert!(c.tab_headers().is_empty());
assert_eq!(c.search_tab(), None);
}
/// `CreativeModeInventoryScreen.checkTabHovering`: the 3-pixel inset of a
/// tab's 26x32 slot (21x27) is the hover area whose name the screen shows.
#[test]
fn only_a_tabs_inset_icon_is_hovered() {
let screen = tooltip_tabs();
let tab = &screen.tabs[0];
let (x, y) = Creative::tab_position(tab);
let at =
|dx: f32, dy: f32| Creative::hovered_tab(&screen.tabs, (0.0, 0.0), (x + dx, y + dy));
assert_eq!(at(4.0, 4.0), Some(0));
assert_eq!(at(23.5, 4.0), Some(0));
assert_eq!(at(2.0, 4.0), None);
assert_eq!(at(24.0, 4.0), None);
assert_eq!(at(4.0, 2.0), None);
assert_eq!(at(4.0, 30.0), None);
}
}
+105 -11
View File
@@ -8,28 +8,51 @@ use crate::{
text::{Style, Text},
};
/// `ActiveTextCollector.ClickableStyleFinder.accept`: the style under a drawn
/// message glyph, using the same visual order and advances as rendering.
/// `ActiveTextCollector.ClickableStyleFinder.accept`: the last style `wanted`
/// under the cursor, in the same visual order and advances as rendering.
/// A drawn glyph is hit where it is drawn (`BakedSheetGlyph.GlyphInstance`),
/// even far below the line when its font's `ascent` moves it; an empty one
/// covers the line's 9 pixels (`EmptyArea`).
pub fn line_style_at(
font: &Font,
text: &Text,
x: f32,
y: f32,
cursor: (f32, f32),
wanted: impl Fn(&Style) -> bool,
) -> Option<Style> {
let mut left = cursor.0 - x;
if left < 0.0 || !(0.0..9.0).contains(&(cursor.1 - y)) {
return None;
}
let text = text.resolve_legacy();
let visual = crate::bidi::visual(&text);
let mut pen = x;
let mut found = None;
for (c, style) in visual.chars() {
left -= font.advance(c, style);
if left < 0.0 {
return Some(style.clone());
let advance = font.advance(c, style);
let object = c == '\u{fffc}' && style.inline_object.is_some();
let glyph = (!object).then(|| font.glyph_styled(c, style));
let (left, top, bottom) = match glyph {
Some(g) if g.rect.is_some() => {
let thick = if style.is_bold() { 0.1 } else { 0.0 };
let shear = if style.is_italic() {
1.0 - 0.25 * g.bottom
} else {
0.0
};
// Dialog text is drawn with a shadow, which extends the bottom.
(
pen + g.left + shear - thick,
y + g.top - thick,
y + g.bottom + g.offset + thick,
)
}
_ => (pen, y, y + 9.0),
};
let inside = (left..pen + advance).contains(&cursor.0) && (top..bottom).contains(&cursor.1);
if inside && wanted(style) {
found = Some(style.clone());
}
pen += advance;
}
None
found
}
/// `HeaderAndFooterLayout.DEFAULT_HEADER_AND_FOOTER_HEIGHT`.
@@ -485,7 +508,7 @@ mod tests {
]),
&mut |_| Text::empty(),
);
let style = |x, y| line_style_at(&font, &text, 10.0, 20.0, (x, y));
let style = |x, y| line_style_at(&font, &text, 10.0, 20.0, (x, y), |_| true);
assert!(style(9.9, 20.0).is_none());
assert_eq!(style(17.9, 20.0).unwrap().click, None);
for x in [18.0, 21.0, 25.9] {
@@ -503,6 +526,77 @@ mod tests {
assert!(style(21.0, 29.0).is_none());
}
#[test]
fn a_glyph_moved_down_by_its_ascent_is_clicked_where_it_is_drawn() {
use crate::{component, hud::Rect, text::ClickEvent};
let root =
std::env::temp_dir().join(format!("horizon-dialog-ascent-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let mut sheet = Vec::new();
{
let mut encoder = png::Encoder::new(&mut sheet, 8, 8);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
encoder
.write_header()
.unwrap()
.write_image_data(&[255; 8 * 8 * 4])
.unwrap();
}
for (path, bytes) in [
(
"assets/minecraft/font/low.json",
br#"{"providers":[{"type":"bitmap","file":"minecraft:font/box.png","height":8,"ascent":-28,"chars":["a"]}]}"#
.as_slice(),
),
("assets/minecraft/textures/font/box.png", sheet.as_slice()),
] {
let path = root.join(path);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, bytes).unwrap();
}
let font = Font::load(
&root,
&std::collections::HashMap::new(),
&[],
(4096, 1024),
(512, 512),
Rect {
u0: 0.0,
v0: 0.0,
u1: 0.0,
v1: 0.0,
w: 1,
h: 1,
},
);
let _ = std::fs::remove_dir_all(&root);
let text = component::to_text(
&serde_json::json!([
{"text": "a", "font": "minecraft:low"},
{"text": "a", "font": "minecraft:low", "click_event": {
"action": "custom", "id": "test:requests"
}},
]),
&mut |_| Text::empty(),
);
let click = |x, y| {
line_style_at(&font, &text, 10.0, 20.0, (x, y), |s| s.click.is_some())
.and_then(|s| s.click)
};
let requests = Some(ClickEvent::Custom {
id: "test:requests".into(),
payload: None,
});
for (x, y) in [(19.0, 55.0), (22.0, 60.0), (27.9, 63.9)] {
assert_eq!(click(x, y), requests, "drawn 35 below the line at {x}, {y}");
}
assert_eq!(click(22.0, 24.0), None, "the line's own row holds nothing");
assert_eq!(click(22.0, 54.9), None, "above the glyph");
assert_eq!(click(22.0, 65.0), None, "below the glyph and its shadow");
assert_eq!(click(15.0, 60.0), None, "the plain glyph left of it");
}
#[test]
fn grids_pack_full_rows_and_centre_the_rest() {
// AuthMe's two buttons: one row, 2 apart.
+2 -1
View File
@@ -166,7 +166,8 @@ impl FurnaceCooking for Furnace {
self.cooking_timer = 0;
}
} else if self.cooking_timer > 0 {
self.cooking_timer = (self.cooking_timer - 2).clamp(0, self.cooking_total);
// Vanilla Mth.clamp is min(max(v, 0), max): saturates at a negative total instead of panicking.
self.cooking_timer = (self.cooking_timer - 2).max(0).min(self.cooking_total);
}
(was_lit != lit).then_some(lit)
}
+7 -7
View File
@@ -1524,14 +1524,14 @@ impl LevelAccess for GameLevel<'_> {
fn block_entity_signal(
&self,
pos: BlockPos,
_state: BlockStateId,
state: BlockStateId,
_direction: Direction,
) -> i32 {
// TestBlock.ownSignal.
// TestBlock.ownSignal: the block state's mode gates the entity.
if let Some(BlockEntity::TestBlock(b)) =
self.world.block_entities.get(&(pos.x, pos.y, pos.z))
{
return b.own_signal();
return crate::game::test_blocks::own_signal(self.registry, state, b.powered);
}
// TrappedChestBlock.getSignal: the players with it open, at most 15.
if let Some(count) = self.lid_openers.get(&(pos.x, pos.y, pos.z)) {
@@ -2873,9 +2873,9 @@ struct Game<'a> {
water_plane_memo: std::cell::Cell<Option<f32>>,
/// The camera's block and the second `water_plane` last scanned at.
water_plane_scan: std::cell::Cell<Option<([i32; 3], i64)>>,
/// The mobs' box reach while the boats and minecarts tick, with the mob count it was
/// worked out for (`vehicle_mob_reach`).
vehicle_mob_reach: std::cell::Cell<Option<(usize, horizon_client::mobs::BoxReach)>>,
/// What the boats' and minecarts' ticks know about the mobs: their box reach
/// (`vehicle_mob_reach`) and a grid of their boxes (`mobs_meeting`).
vehicle_mobs: boat_passengers::VehicleMobs,
persist: persist::EntityMemory,
entity_attachments: HashMap<[i32; 4], horizon_world::attachments::Attachments>,
/// Connected to a server: the world comes from the network (game/remote.rs).
@@ -11992,7 +11992,7 @@ impl<'a> Game<'a> {
sky_darken_memo: Default::default(),
water_plane_memo: Default::default(),
water_plane_scan: Default::default(),
vehicle_mob_reach: Default::default(),
vehicle_mobs: Default::default(),
persist: Default::default(),
entity_attachments: Default::default(),
remote: None,
@@ -1255,7 +1255,7 @@ impl Game<'_> {
}
self.check_inventory_trigger();
// ServerPlayer.tick: PlayerTrigger.TICK every tick.
let me = self.player_entity_for("minecraft:tick");
let me = self.player_entity_if_listened("minecraft:tick");
self.trigger("minecraft:tick", |c, w| {
let ctx = adv::Entities {
this: Some(&me),
@@ -1289,7 +1289,7 @@ impl Game<'_> {
}
self.tick_marks();
if self.ticks.is_multiple_of(20) {
let me = self.player_entity_for("minecraft:location");
let me = self.player_entity_if_listened("minecraft:location");
self.trigger("minecraft:location", |c, w| {
let ctx = adv::Entities {
this: Some(&me),
@@ -0,0 +1,158 @@
//! An allay dances to the jukebox it heard first: `Allay.setJukeboxPlaying`
//! keeps that jukebox's position while it dances, and only a stop from there
//! ends the dance; another jukebox's `jukebox_play` (every 20 ticks of its
//! song) starts it again (tools/test_allay_jukebox_source_parity.py measures
//! the same on vanilla 26.3).
use crate::game::*;
use horizon_client::chunk::Chunk;
use horizon_core::position::ChunkPos;
use std::sync::Arc;
fn game() -> Game<'static> {
let root = std::path::PathBuf::from(
std::env::var_os("HORIZON_TEST_RESOURCES")
.expect("set HORIZON_TEST_RESOURCES to prepared resources"),
);
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = execution_role::ExecutionRole::IntegratedServer;
let res: &'static _ = Box::leak(Box::new(loaded.resources));
let mut game = Game::new(
res,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.build_registries();
game.set_allow_commands(true);
game.world
.chunks
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
game.block_ticks
.add_empty_chunk(ChunkPos::new(0, 0))
.unwrap();
for command in [
"tick freeze",
"forceload add 0 0",
"fill 0 99 0 15 99 15 stone",
"fill 7 99 7 9 102 9 glass",
"fill 8 100 8 8 101 8 air",
"setblock 5 100 8 jukebox",
"setblock 12 100 8 jukebox",
"summon allay 8.5 100 8.5 {PersistenceRequired:1b,UUID:[I;2577,0,0,1]}",
] {
assert!(game.run_command(command), "{command}");
}
step(&mut game, 12);
game
}
fn step(game: &mut Game<'_>, ticks: usize) {
for _ in 0..ticks {
assert!(game.run_command("tick step 1"));
game.tick_server(false).unwrap();
}
}
fn dancing(game: &Game<'_>) -> bool {
game.mobs
.iter()
.find(|m| m.species() == horizon_client::mobs::Species::Allay)
.expect("the allay")
.extra
.flag
}
fn play(game: &mut Game<'_>, x: i32, item: &str) {
assert!(game.run_command(&format!(
"item replace block {x} 100 8 container.0 with {item}"
)));
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn stopping_the_first_jukebox_stops_the_dance_until_the_other_plays_again() {
let mut game = game();
assert!(!dancing(&game));
play(&mut game, 5, "minecraft:music_disc_cat");
step(&mut game, 5);
assert!(dancing(&game), "jukebox A starts the dance");
play(&mut game, 12, "minecraft:music_disc_cat");
step(&mut game, 25);
assert!(dancing(&game));
// B has played for 25 ticks: its next jukebox_play is 15 ticks away.
play(&mut game, 5, "minecraft:air");
step(&mut game, 1);
assert!(
!dancing(&game),
"the stop of A, the dance's jukebox, ends it though B still plays"
);
step(&mut game, 20);
assert!(dancing(&game), "B's next jukebox_play starts it again");
}
// Controls added after the baseline: the source through an in-place load, a
// NoAI allay, and the out-of-reach check of `aiStep`.
const ALLAY: &str = "00000a11-0000-0000-0000-000000000001";
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn control_data_merge_keeps_the_dance_and_its_jukebox() {
let mut game = game();
play(&mut game, 5, "minecraft:music_disc_cat");
step(&mut game, 5);
play(&mut game, 12, "minecraft:music_disc_cat");
step(&mut game, 5);
assert!(game.run_command(&format!("data merge entity {ALLAY} {{CustomName:\"x\"}}")));
step(&mut game, 1);
assert!(dancing(&game), "Entity.load keeps DATA_DANCING");
// The kept jukebox is still A: B's stop does not end the dance, A's does.
play(&mut game, 12, "minecraft:air");
step(&mut game, 1);
assert!(dancing(&game));
play(&mut game, 5, "minecraft:air");
step(&mut game, 1);
assert!(!dancing(&game));
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn control_a_no_ai_allay_never_dances() {
let mut game = game();
assert!(game.run_command(&format!("data merge entity {ALLAY} {{NoAI:1b}}")));
play(&mut game, 5, "minecraft:music_disc_cat");
step(&mut game, 25);
assert!(!dancing(&game));
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn control_an_allay_out_of_reach_stops_on_a_twentieth_tick() {
let mut game = game();
play(&mut game, 5, "minecraft:music_disc_cat");
step(&mut game, 2);
assert!(dancing(&game));
assert!(game.run_command("fill 7 99 7 9 102 9 air"));
assert!(game.run_command(&format!("tp {ALLAY} 5.5 112 8.5")));
// Farther than 10 blocks from A's centre: stopped by the next tickCount % 20.
step(&mut game, 20);
assert!(!dancing(&game));
}
+6 -24
View File
@@ -3,7 +3,6 @@
//! and the hand-over interaction (`Allay.mobInteract`).
use super::Game;
use horizon_client::{
containers::BlockEntity,
first_person::Hand,
items::ItemStack,
mobs::{Species, allay},
@@ -74,9 +73,6 @@ impl Game<'_> {
pub(super) fn tick_allays(&mut self) {
let griefing = self.ui.rules.bool("mob_griefing");
// The playing jukeboxes, found at the first allay: no allay starts
// or stops one, and the block entities run into the thousands.
let mut jukeboxes: Option<Vec<(i32, i32, i32)>> = None;
for i in 0..self.mobs.len() {
if self.mobs[i].species() != Species::Allay
|| !self.mobs[i].alive()
@@ -85,26 +81,8 @@ impl Game<'_> {
continue;
}
let pos = self.mobs[i].position;
// Dancing: a playing jukebox within 10 blocks.
let jukeboxes = jukeboxes.get_or_insert_with(|| {
self.world
.block_entities
.iter()
.filter(|(_, e)| matches!(e, BlockEntity::Jukebox(j) if j.playing))
.map(|(k, _)| *k)
.collect()
});
let nearest = jukeboxes
.iter()
.map(|k| {
(f64::from(k.0) + 0.5 - pos.x).powi(2)
+ (f64::from(k.1) + 0.5 - pos.y).powi(2)
+ (f64::from(k.2) + 0.5 - pos.z).powi(2)
})
.fold(f64::INFINITY, f64::min);
// `Allay.setDancing` wants effective AI: a `NoAI` allay never dances.
let panicking = horizon_client::mobs::goals::should_panic(&self.mobs[i]);
self.mobs[i].extra.flag = allay::dances(self.mobs[i].no_ai, panicking, nearest);
// The dance follows the jukebox events the allay hears
// (`mob_listen`), `Allay.tick` and `aiStep`.
let Some(held) = self.mobs[i].extra.main_hand_stack().cloned() else {
self.mobs[i].extra.point = None;
continue;
@@ -425,3 +403,7 @@ impl Game<'_> {
false
}
}
#[cfg(test)]
#[path = "allay_jukebox_source_tests.rs"]
mod allay_jukebox_source_tests;
+313 -42
View File
@@ -7,10 +7,13 @@
use super::{Game, boats::Boat, lan_mobs::guest_bounds};
use horizon_client::{
camera::wrap_degrees,
mobs::{BoxReach, Mob, Species, Who, drops::is_animal, equine, riders},
fast_hash::FxHashMap,
join_seq::ListStamp,
mobs::{BoxReach, Mob, Species, Who, drops::is_animal, equine, riders, seen_grid::SeenGrid},
scoreboard::pushable_by,
};
use horizon_core::geometry::{Aabb, Vec3};
use std::cell::{Cell, RefCell};
/// `Entity.boardingCooldown` once a mob leaves its boat.
pub(super) const BOARDING_COOLDOWN: i32 = 60;
@@ -18,6 +21,35 @@ pub(super) const BOARDING_COOLDOWN: i32 = 60;
/// feet are.
const PLAYER_RIDING_OFFSET: f64 = 0.6;
/// How many box queries the walk over every mob answers, once a write to the mob list has
/// made the grid stale, before a new grid is built: about what building one costs (with
/// 25,000 mobs, six to eight walks). It doubles with each grid of a stretch, so vehicles
/// that each write a mob (a boat pushing the pig beside it) never pay more for grids than
/// for their walks.
const WALKS_BEFORE_GRID: u32 = 8;
/// What a stretch of vehicle ticks knows about the mobs, so a boat or a minecart reads the
/// mobs near it and not every mob of the level ([`Game::mobs_meeting`]).
#[derive(Default)]
pub(super) struct VehicleMobs {
/// The mobs' [`BoxReach`] with the mob count it was worked out for; None outside a stretch.
reach: Cell<Option<(usize, BoxReach)>>,
grid: RefCell<MobGrid>,
/// The last query's candidates, kept for their allocation.
near: Cell<Vec<u32>>,
/// Each ridden vehicle's mobs by slot, for the mob list as it stood ([`Game::vehicle_riders`]).
riders: RefCell<Option<(ListStamp, FxHashMap<u32, Vec<usize>>)>>,
}
#[derive(Default)]
struct MobGrid {
/// The mobs' boxes by column and the list they were read from: they hold until that
/// list's next write ([`ListStamp`]), which every push, pick-up, death and join is.
built: Option<(ListStamp, SeenGrid)>,
/// The queries walked since the grid went stale, and how many are before the next grid.
walked: u32,
patience: u32,
}
/// `Entity.canRide` for a mob species: `Warden` and `WitherBoss` override it
/// to return false, so no vehicle ever takes them aboard.
pub(super) fn species_can_ride(species: Species) -> bool {
@@ -66,32 +98,165 @@ pub(super) fn boat_pushes(boat: Aabb, other: Aabb, other_is_boat: bool) -> bool
}
impl Game<'_> {
/// The players in boat `id`: the window's player (or the server player
/// acting as it) and the server's other players.
/// Starts or ends a stretch of vehicle ticks (`tick_boats`, `tick_carts`): while one runs,
/// a vehicle's box query tests the mobs' positions against their [`BoxReach`] first.
/// A mob joining or leaving works it out again, and so does [`Self::vehicle_picked_up`].
/// a vehicle's box query ([`Self::mobs_meeting`]) reads the mobs near it from a grid of
/// their boxes, or tests the mobs' positions against their [`BoxReach`] first. A mob
/// joining or leaving works the reach out again, and so does [`Self::vehicle_picked_up`].
/// The first query that needs one works it out (the reach's count is one no list has), so
/// a level without boats or carts never walks its mobs for them.
pub(super) fn vehicles_ticking(&self, on: bool) {
self.vehicle_mob_reach
.set(on.then(|| (self.mobs.len(), BoxReach::of(&self.mobs))));
let reach = on.then(|| (usize::MAX, BoxReach::default()));
self.vehicle_mobs.reach.set(reach);
// The boats' grid serves the carts ticking right after them; an older one goes.
let mut grid = self.vehicle_mobs.grid.borrow_mut();
let stamp = self.mobs.stamp();
let built = grid.built.take().filter(|(s, _)| *s == stamp);
*grid = MobGrid {
built,
..Default::default()
};
}
/// A vehicle picked a mob up: a shulker turns its lid up (`Shulker.startRiding`), which
/// changes its box, so the next query works the [`BoxReach`] out again.
pub(super) fn vehicle_picked_up(&self) {
if let Some((_, reach)) = self.vehicle_mob_reach.get() {
self.vehicle_mob_reach.set(Some((usize::MAX, reach)));
if let Some((_, reach)) = self.vehicle_mobs.reach.get() {
self.vehicle_mobs.reach.set(Some((usize::MAX, reach)));
}
}
/// The mobs' [`BoxReach`] while vehicles tick, none otherwise.
pub(super) fn vehicle_mob_reach(&self) -> Option<BoxReach> {
let (count, reach) = self.vehicle_mob_reach.get()?;
let (count, reach) = self.vehicle_mobs.reach.get()?;
if count == self.mobs.len() {
return Some(reach);
}
let reach = BoxReach::of(&self.mobs);
self.vehicle_mob_reach.set(Some((self.mobs.len(), reach)));
self.vehicle_mobs.reach.set(Some((self.mobs.len(), reach)));
Some(reach)
}
/// The slots, ascending, of the mobs whose vehicle is `id`, whatever their state. While
/// vehicles tick, one walk over the mobs answers for every vehicle until the mob list is
/// next written: each boat asks for its passengers and each cart on rails whether it is
/// ridden, and each one walked every mob of the level.
fn vehicle_riders(&self, id: u32) -> Vec<usize> {
let rides = |v: u32| move |&j: &usize| self.mobs[j].extra.mount == Some(v);
if !self.vehicle_mobs_indexed() {
return (0..self.mobs.len()).filter(rides(id)).collect();
}
let stamp = self.mobs.stamp();
let mut riders = self.vehicle_mobs.riders.borrow_mut();
if riders.as_ref().is_none_or(|(s, _)| *s != stamp) {
let mut by_vehicle = FxHashMap::<u32, Vec<usize>>::default();
for (j, m) in self.mobs.iter().enumerate() {
if let Some(v) = m.extra.mount {
by_vehicle.entry(v).or_default().push(j);
}
}
*riders = Some((stamp, by_vehicle));
}
let of = riders
.as_ref()
.and_then(|(_, by_vehicle)| by_vehicle.get(&id));
of.cloned().unwrap_or_default()
}
/// Whether a mob (one not removed) rides vehicle `id`: `Entity.isVehicle` for a cart.
pub(super) fn mob_rides(&self, id: u32) -> bool {
let riders = self.vehicle_riders(id);
riders.iter().any(|&j| !self.mobs[j].removed)
}
/// Whether what a stretch of vehicle ticks reads from the mob list may stand in for a walk
/// over it: a stretch runs, and no native mod is loaded (a hook can move a mob unseen, as
/// for the carts' `CartIndex`).
fn vehicle_mobs_indexed(&self) -> bool {
self.vehicle_mobs.reach.get().is_some() && horizon_client::mod_api::mods().is_empty()
}
/// The slots, ascending, of the mobs whose box may meet `area` into `out` (a superset: the
/// caller still tests each box), from a grid of the mobs' boxes that the first query of a
/// stretch of vehicle ticks builds. False: walk them all. That is outside a stretch or with
/// native mods loaded ([`Self::vehicle_mobs_indexed`]), when the grid cannot say, and for
/// a while after a write to the mob list ([`MobGrid`]).
fn vehicle_mobs_near(&self, area: Aabb, out: &mut Vec<u32>) -> bool {
if !self.vehicle_mobs_indexed() {
return false;
}
let stamp = self.mobs.stamp();
let mut grid = self.vehicle_mobs.grid.borrow_mut();
if grid.built.as_ref().is_none_or(|(s, _)| *s != stamp) {
if grid.walked < grid.patience {
grid.walked += 1;
return false;
}
grid.built = Some((stamp, SeenGrid::new(self.mobs.iter().map(Mob::bounds))));
grid.walked = 0;
grid.patience = (grid.patience * 2).max(WALKS_BEFORE_GRID);
}
let built = grid.built.as_ref();
built.is_some_and(|(_, cells)| cells.candidates(area, out))
}
/// A vehicle's box query: `each` gets every mob whose box meets `area`, in slot order,
/// with its slot and its box. While vehicles tick it reads the mobs near the area
/// ([`Self::vehicle_mobs_near`]) or, walking them all, tests each position against the
/// [`BoxReach`] before working the box out. The mobs and their order are those of a walk
/// that tests every box.
pub(super) fn mobs_meeting(&self, area: Aabb, mut each: impl FnMut(usize, &Mob, Aabb)) {
let mut near = self.vehicle_mobs.near.take();
if self.vehicle_mobs_near(area, &mut near) {
for &j in &near {
let m = &self.mobs[j as usize];
let b = m.bounds();
if b.intersects(area) {
each(j as usize, m, b);
}
}
} else {
let reach = self.vehicle_mob_reach();
for (j, m) in self.mobs.iter().enumerate() {
if reach.is_some_and(|r| !r.may_meet(m, area)) {
continue;
}
let b = m.bounds();
if b.intersects(area) {
each(j, m, b);
}
}
}
self.vehicle_mobs.near.set(near);
}
/// The slots, ascending, of the mobs whose box may meet a cart, read from the mobs' grid
/// once per cart. None when the mobs are the fewer (asking the carts' grid once per mob
/// is then the shorter way) or the grid does not answer.
pub(super) fn mobs_near_carts(&self) -> Option<Vec<u32>> {
if self.mobs.len() <= self.carts.len() {
return None;
}
let (mut slots, mut near) = (Vec::new(), Vec::new());
for c in self.carts.iter() {
if !self.vehicle_mobs_near(c.bounds(), &mut near) {
return None;
}
slots.extend_from_slice(&near);
}
slots.sort_unstable();
slots.dedup();
Some(slots)
}
/// Whether mob `m`, whose box `reach` bounds, may touch a cart: the carts' grid (`cells`)
/// names the carts near it, or every cart when the grid cannot (a superset either way).
pub(super) fn mob_may_meet_cart(
&self,
reach: BoxReach,
m: &Mob,
cells: &SeenGrid,
near: &mut Vec<u32>,
) -> bool {
if !cells.candidates(reach.around(m.position), near) {
near.clear();
near.extend(0..self.carts.len() as u32);
}
near.iter()
.any(|&k| reach.may_meet(m, self.carts[k as usize].bounds()))
}
/// The players in boat `id`: the window's player (or the server player
/// acting as it) and the server's other players.
pub(super) fn boat_players(&self, id: u32) -> usize {
let guests = self.lan.as_ref().map_or(0, |h| {
h.hub
@@ -105,12 +270,9 @@ impl Game<'_> {
/// The mobs in boat `id`, in their seat order after the players
/// (`Entity.addPassenger` puts a player first).
pub(super) fn boat_mobs(&self, id: u32) -> Vec<usize> {
(0..self.mobs.len())
.filter(|&j| {
let m = &self.mobs[j];
m.extra.mount == Some(id) && !m.removed && m.alive()
})
.collect()
let mut riders = self.vehicle_riders(id);
riders.retain(|&j| !self.mobs[j].removed && self.mobs[j].alive());
riders
}
/// `getPassengers().size()` of boat `id`.
pub(super) fn boat_passengers(&self, id: u32) -> usize {
@@ -174,18 +336,12 @@ impl Game<'_> {
/// `AbstractBoat.canVehicleCollide`: the other boats, minecarts,
/// shulkers and pushable mobs and players, but not its own passengers.
pub(super) fn boat_obstacles(&self, id: u32, area: Aabb) -> Vec<Aabb> {
let reach = self.vehicle_mob_reach();
let mut out: Vec<Aabb> = (0..self.mobs.len())
.filter(|&j| {
let m = &self.mobs[j];
// The box first: it turns away nearly every mob of the level.
reach.is_none_or(|r| r.may_meet(m, area))
&& m.bounds().intersects(area)
&& pushable(m)
&& (m.extra.mount.is_none() || self.mob_root_vehicle(j) != Some(id))
})
.map(|j| self.mobs[j].bounds())
.collect();
let mut out = Vec::new();
self.mobs_meeting(area, |j, m, b| {
if pushable(m) && (m.extra.mount.is_none() || self.mob_root_vehicle(j) != Some(id)) {
out.push(b);
}
});
out.extend(
self.boats
.iter()
@@ -230,19 +386,14 @@ impl Game<'_> {
let pick_up = self.boat_players(id) == 0;
// canAddPassenger, which startRiding asks after the checks below.
let room = !self.boats[i].under_water();
// The box first: it turns away nearly every mob of the level, and the
// teams are only worked out when something touches the boat.
let reach = self.vehicle_mob_reach();
let sweep = area.union_bounds(bb);
let near: Vec<usize> = (0..self.mobs.len())
.filter(|&j| {
if reach.is_some_and(|r| !r.may_meet(&self.mobs[j], sweep)) {
return false;
}
let mb = self.mobs[j].bounds();
mb.intersects(area) || mb.intersects(bb)
})
.collect();
// The mobs near the two boxes: the teams are only worked out when something
// touches the boat.
let mut near = Vec::new();
self.mobs_meeting(area.union_bounds(bb), |j, _, mb| {
if mb.intersects(area) || mb.intersects(bb) {
near.push(j);
}
});
let outside = self.players_outside(id);
let touching = !near.is_empty()
|| outside
@@ -531,4 +682,124 @@ mod tests {
assert!(boat_pushes(boat, at(64.5), true));
assert!(!boat_pushes(boat, at(64.5625), true));
}
/// A mob pushes the cart its box touches while a mob far from every cart pushes none and
/// is not pushed, whichever grid finds them: the carts' (four carts in four of its columns,
/// asked once per mob while the mobs are the fewer) or, in a crowd, the mobs' (asked once
/// per cart).
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn only_the_mob_touching_a_cart_pushes_it() {
use super::super::minecarts::{CartKind, Minecart};
let pushed = [0, 12].map(|crowd: u32| {
let mut game = super::super::native_machines::tests::server_game();
let pig = game.res.mob_types().get("minecraft:pig").unwrap().clone();
for (i, (x, z)) in [(0.5, 0.5), (20.5, 0.5), (0.5, 20.5), (20.5, 20.5)]
.into_iter()
.enumerate()
{
let at = Vec3::new(x, 64.0, z);
game.carts
.push(Minecart::new(100 + i as u32, CartKind::Chest, at));
}
// 0.5 west of the second cart (boxes 0.9 and 0.98 wide), in the middle of them
// all, and the crowd well away.
let spots = [(20.0, 0.5), (10.5, 10.5)].into_iter();
let crowd = (0..crowd).map(|i| (40.5 + 3.0 * f64::from(i), 40.5));
for (i, (x, z)) in spots.chain(crowd).enumerate() {
let at = Vec3::new(x, 64.0, z);
game.mobs.push(Mob::new(1 + i as u32, pig.clone(), at, 0.0));
}
game.tick_carts();
let velocity = |id| game.mobs.iter().find(|m| m.id == id).unwrap().velocity;
assert!(velocity(1).x < 0.0, "pushed back west: {:?}", velocity(1));
let idle = game.mobs.iter().filter(|m| m.velocity == Vec3::ZERO);
assert_eq!(idle.count(), game.mobs.len() - 1);
velocity(1)
});
assert_eq!(pushed[0], pushed[1]);
}
/// A vehicle's box query during a stretch of vehicle ticks names the mobs a walk over
/// every box names, in the same order, also right after a mob moved into the box (a
/// push or a pick-up writes the mob list in the middle of a stretch).
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_vehicles_box_query_matches_a_walk_over_every_mob() {
let mut game = super::super::native_machines::tests::server_game();
let pig = game.res.mob_types().get("minecraft:pig").unwrap().clone();
// A small LCG keeps the scene the same on every run.
let mut seed = 0x2545_f491_4f6c_dd1du64;
let mut next = |range: f64| {
seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
(seed >> 11) as f64 / (1u64 << 53) as f64 * range
};
for id in 1..=400 {
let at = Vec3::new(next(48.0), 64.0 + next(3.0), next(48.0));
game.mobs.push(Mob::new(id, pig.clone(), at, 0.0));
}
let mut met = 0;
for round in 0..300 {
// Three stretches, each with two writes to the mob list.
if round % 100 == 0 {
game.vehicles_ticking(true);
}
let corner = Vec3::new(next(46.0), 63.0 + next(3.0), next(46.0));
let size = Vec3::new(0.5 + next(2.5), 0.5 + next(1.5), 0.5 + next(2.5));
let area = Aabb::new(corner, corner.add_vector(size));
if round % 40 == 20 {
let j = round % game.mobs.len();
game.mobs[j].position = area.center();
}
let walked: Vec<Aabb> = game
.mobs
.iter()
.map(Mob::bounds)
.filter(|b| b.intersects(area))
.collect();
met += walked.len();
assert_eq!(game.boat_obstacles(u32::MAX, area), walked, "round {round}");
if round % 100 == 99 {
// The last comparisons ran against the grid: it stands, for the list as it is.
let fresh = |(stamp, _): &(ListStamp, SeenGrid)| *stamp == game.mobs.stamp();
assert!(
game.vehicle_mobs
.grid
.borrow()
.built
.as_ref()
.is_some_and(fresh)
);
game.vehicles_ticking(false);
}
}
assert!(met > 100, "the boxes met {met} mobs");
}
/// Who rides a vehicle follows the mob list through a stretch of vehicle ticks: a mob that
/// boards counts from then on, one removed no longer does, and a dead one still makes
/// its cart a vehicle but is no passenger of a boat.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_vehicle_knows_its_mob_riders_as_soon_as_they_board() {
let mut game = super::super::native_machines::tests::server_game();
let pig = game.res.mob_types().get("minecraft:pig").unwrap().clone();
for id in 1..=4 {
game.mobs.push(Mob::new(id, pig.clone(), Vec3::ZERO, 0.0));
}
let riders =
|game: &Game| [100, 101, 102].map(|id| (game.mob_rides(id), game.boat_mobs(id)));
game.mobs[0].extra.mount = Some(100);
game.vehicles_ticking(true);
let none = (false, vec![]);
assert_eq!(riders(&game), [(true, vec![0]), none.clone(), none.clone()]);
game.mobs[0].removed = true;
game.mobs[1].extra.mount = Some(101);
game.mobs[3].extra.mount = Some(102);
game.mobs[3].health = 0.0;
let after = [none, (true, vec![1]), (true, vec![])];
assert_eq!(riders(&game), after);
game.vehicles_ticking(false);
assert_eq!(riders(&game), after);
}
}
+11
View File
@@ -1467,6 +1467,13 @@ impl Game<'_> {
}
}
/// `BoatItem.use`: aim at a block or the water surface.
/// `Entity.setYRot` on a boat just made (its old rotation moves with it).
pub(super) fn set_boat_yaw(&mut self, id: u32, yaw: f32) {
if let Some(b) = self.boats.iter_mut().find(|b| b.id == id) {
b.yaw = yaw;
b.previous_yaw = yaw;
}
}
pub(super) fn place_boat(&mut self, stack: &ItemStack) -> bool {
let Some(boat) = self.boat_to_place(stack) else {
return false;
@@ -1477,7 +1484,11 @@ impl Game<'_> {
boat.position.z.floor() as i32 >> 4,
);
self.world.modified.insert(key);
let (id, kind, yaw) = (boat.id, boat.entity_type(), boat.yaw);
self.boats.push(boat);
self.apply_vehicle_stack_config(super::commands::Target::Boat(id), &kind, stack, true);
// BoatItem.use: setYRot(player.getYRot()) after the stack's config.
self.set_boat_yaw(id, yaw);
if self.serving.is_some() || self.options.dedicated.is_some() {
self.native_vehicle_create(
self.boats.last().unwrap().entity_type().as_str(),
+98 -9
View File
@@ -1594,15 +1594,17 @@ impl Game<'_> {
&tr("gui.done", "Done"),
);
}
// HoveredTextEffects.TOOLTIP_AND_CURSOR: the hovered words'
// show_text hover event, as the chat shows it.
if let Some(hover) = pages
.get(*page)
.and_then(|t| self.book_style_at(t, left))
.and_then(|s| s.hover)
{
let lines = self.wrapped_rows(&hover, (w as i32 / 2).max(200));
self.draw_tooltip_lines(batch, &lines, mx, my);
// HoveredTextEffects.TOOLTIP_AND_CURSOR: a clickable word
// asks for the hand, and a show_text hover shows its tooltip,
// as the chat does.
if let Some(style) = pages.get(*page).and_then(|t| self.book_style_at(t, left)) {
if let Some(cursor) = link_cursor(&style) {
horizon_client::widgets::request_cursor(cursor);
}
if let Some(hover) = style.hover {
let lines = self.wrapped_rows(&hover, (w as i32 / 2).max(200));
self.draw_tooltip_lines(batch, &lines, mx, my);
}
}
}
}
@@ -1647,6 +1649,16 @@ pub(super) fn lectern_signal(world: &horizon_client::world::World, pos: BlockPos
}
}
/// `HoveredTextEffects.TOOLTIP_AND_CURSOR`: only a word with a click event asks
/// for the pointing hand over a book's page; a hover event alone shows its
/// tooltip.
fn link_cursor(style: &horizon_client::text::Style) -> Option<horizon_client::widgets::Cursor> {
style
.click
.is_some()
.then_some(horizon_client::widgets::Cursor::PointingHand)
}
#[cfg(test)]
mod title_box_tests {
use super::book_title_box;
@@ -1836,6 +1848,60 @@ mod lectern_signal_tests {
assert_eq!(signal(&mut game, Some(written), 1), 15, "last page");
}
}
impl BookScreen {
/// `BookViewScreen.isPauseScreen`: the class inherits `Screen`'s true, so
/// a book read in hand stops a single-player world. `LecternScreen`
/// overrides it false, so the same pages read on a lectern leave the
/// world ticking (its menu is an `AbstractContainerScreen`, which does
/// not pause either).
pub(super) fn pauses_world(&self) -> bool {
!matches!(
self,
Self::View {
lectern: Some(_),
..
}
)
}
}
#[cfg(test)]
mod pause_tests {
use super::BookScreen;
use horizon_client::{text::Text, ui};
fn view(lectern: Option<(i32, i32, i32)>) -> BookScreen {
BookScreen::View {
pages: Vec::<Text>::new(),
page: 0,
lectern,
}
}
#[test]
fn a_lecterns_book_does_not_pause_the_world() {
// BookViewScreen keeps Screen.isPauseScreen's true; LecternScreen
// overrides it false although both read the same pages.
assert!(view(None).pauses_world());
assert!(!view(Some((10, 64, -3))).pauses_world());
}
#[test]
fn a_book_being_edited_or_signed_pauses() {
// BookEditScreen and BookSignScreen do not override isPauseScreen.
let edit = |signing| BookScreen::Edit {
slot: 0,
pages: Vec::new(),
page: 0,
signing,
title: String::new(),
title_edit: ui::EditBox::new(0.0, 0.0, 0.0, 0.0),
page_edit: ui::EditBox::new(0.0, 0.0, 0.0, 0.0),
};
assert!(edit(false).pauses_world());
assert!(edit(true).pauses_world());
}
}
#[cfg(test)]
mod lectern_reach_tests {
@@ -1929,3 +1995,26 @@ mod sign_tests {
}
}
}
#[cfg(test)]
mod link_cursor_tests {
use super::link_cursor;
use horizon_client::text::{ClickEvent, Style, Text};
use horizon_client::widgets::Cursor;
/// `HoveredTextEffects.TOOLTIP_AND_CURSOR`: a clickable word asks for the
/// pointing hand; a hover event alone only shows its tooltip.
#[test]
fn only_a_clickable_book_word_asks_for_the_hand() {
let link = Style {
click: Some(ClickEvent::OpenUrl("https://example.com".into())),
..Style::default()
};
let hover_only = Style {
hover: Some(Box::new(Text::literal("tip"))),
..Style::default()
};
assert_eq!(link_cursor(&link), Some(Cursor::PointingHand));
assert_eq!(link_cursor(&hover_only), None);
}
}
+201 -18
View File
@@ -1,9 +1,10 @@
//! Breeze particles (`Breeze.tick`): block dust at its feet while it stands,
//! a burst of twenty while it slides and a short trail along a long jump.
use super::Game;
use super::{BlockStateId, Game};
use horizon_client::{
mobs::{Species, breeze},
particles::Particle,
blocks::RenderShape,
mobs::{Mob, Species, breeze},
particles::{ParticleOptions, ParticleType},
};
impl Game<'_> {
@@ -33,21 +34,16 @@ impl Game<'_> {
} else {
self.mobs[i].extra.scratch[1] = 0;
}
let m = &self.mobs[i];
let (x, y, z) = (
m.position.x.floor() as i32,
m.position.y.floor() as i32,
m.position.z.floor() as i32,
);
let mut at = [x, y, z];
let mut state = self.world.block(x, y, z);
if self.res.facts().is_air(state) {
at[1] = (m.position.y - 0.2).floor() as i32;
state = self.world.block(x, at[1], z);
let (block, state) = self.breeze_ground(&self.mobs[i]);
// `emitGroundParticles` skips an invisible block; the jump trail
// (`emitJumpTrailParticles`) does not ask.
if !jump_trail && self.res.facts().get(state).render_shape == RenderShape::Invisible {
continue;
}
let Some((sprite, tint)) = self.particle_sprite(state, at) else {
let Some((sprite, tint)) = self.particle_sprite(state, block) else {
continue;
};
let m = &self.mobs[i];
let at = if jump_trail {
[
m.position.x + m.velocity.x,
@@ -57,11 +53,198 @@ impl Game<'_> {
} else {
[m.position.x, m.position.y, m.position.z]
};
// `ClientLevel.addParticle` with no speed: each crumb's own
// generator draws its lifetime, its little push and the quarter of
// the block's sprite it shows, never the level's random.
let options = ParticleOptions::block(ParticleType::Block, sprite, tint);
for _ in 0..amount {
let particle =
Particle::terrain(&mut self.world.random, at, [0.0; 3], sprite.clone(), tint);
self.particles.push(particle);
self.particles.add(&options, at, [0.0; 3]);
}
}
}
/// `Breeze.emitGroundParticles`'s block: `getInBlockState`, else
/// `getBlockStateOn`, which is `Entity.getOnPos(1.0E-5F)` — the supporting
/// block the feet rest on, or the cell just below them. A fence, a wall and
/// a fence gate have a 1.5-block collision, so a breeze on one has its feet
/// half a block above that block's own cell, where the cell below them is
/// air and only the supporting block has the material.
pub(super) fn breeze_ground(&self, m: &Mob) -> ([i32; 3], BlockStateId) {
let feet = [
m.position.x.floor() as i32,
m.position.y.floor() as i32,
m.position.z.floor() as i32,
];
let in_block = self.world.block(feet[0], feet[1], feet[2]);
if !self.res.facts().is_air(in_block) {
return (feet, in_block);
}
let on_ground = m
.on_ground
.then(|| self.supporting_block(m.position, m.bounds()));
let at = on_ground.flatten().map_or(
[
feet[0],
(m.position.y - f64::from(1.0e-5_f32)).floor() as i32,
feet[2],
],
|p| [p.x, p.y, p.z],
);
(at, self.world.block(at[0], at[1], at[2]))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::game::{Audio, Options, Progress, Settings, load_resources};
use horizon_client::chunk::Chunk;
use horizon_client::mobs::Mob;
use horizon_core::geometry::Vec3;
use horizon_core::random::LegacyRandom;
use std::sync::Arc;
/// A game on the prepared resources with an empty chunk at (0, 0), the
/// window's player (the camera the dust needs) and one breeze standing
/// with its feet at `feet`.
fn game_with_breeze(feet: Vec3) -> 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,
false,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
let res: &'static _ = Box::leak(Box::new(loaded.resources));
let mut game = Game::new(
res,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.world
.chunks
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
// Eight blocks south of the breeze: inside the 32 blocks the dust needs.
game.player = Some(
horizon_client::motion::PlayerMotion::new(game.motion, Vec3::new(8.5, 66.0, 16.5))
.unwrap(),
);
game.particles.camera = [8.5, 66.0, 16.5];
let kind = game.res.mob_types()["minecraft:breeze"].clone();
game.mobs.push(Mob::new(13001, kind, feet, 0.0));
game
}
/// A breeze standing on a fence rests its feet at 64 + 1.5, where its own
/// cell is air: `Breeze.emitGroundParticles` asks `getBlockStateOn` for the
/// supporting block, so the crumbs are the fence's. Every one of them is
/// made by the particle's own generator (`Particle.random`), so the
/// client level's random is where it was before the tick.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn the_dust_of_a_breeze_on_a_fence_keeps_the_client_level_random() {
let mut game = game_with_breeze(Vec3::new(8.5, 65.5, 8.5));
let registry = &game.pack.assets.registry;
let stone = registry.default_state("minecraft:stone").unwrap();
let fence = registry.default_state("minecraft:oak_fence").unwrap();
game.world.set(8, 63, 8, stone).unwrap();
game.world.set(8, 64, 8, fence).unwrap();
game.mobs[0].on_ground = true;
assert_eq!(
game.breeze_ground(&game.mobs[0]),
([8, 64, 8], fence),
"the feet's own cell is air; the fence is what holds them up"
);
game.world.random = LegacyRandom::new(1234);
let level = game.world.random.state();
game.tick_breeze_effects();
assert_eq!(
game.particles.made().get("block").copied().unwrap_or(0),
1,
"vanilla emits one crumb of the fence the breeze stands on"
);
assert_eq!(
game.world.random.state(),
level,
"the crumb's constructor draws belong to the particle, not the level"
);
}
/// The control: a breeze standing on a full block's top also crumbles it,
/// and its constructor's draws still leave the client level's random alone.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn the_dust_of_a_breeze_on_a_full_block_keeps_the_client_level_random() {
let mut game = game_with_breeze(Vec3::new(8.5, 64.0, 8.5));
let stone = game
.pack
.assets
.registry
.default_state("minecraft:stone")
.unwrap();
game.world.set(8, 63, 8, stone).unwrap();
game.mobs[0].on_ground = true;
game.world.random = LegacyRandom::new(1234);
let level = game.world.random.state();
game.tick_breeze_effects();
assert_eq!(
game.world.random.state(),
level,
"the crumb's constructor draws belong to the particle, not the level"
);
assert_eq!(game.particles.made().get("block").copied().unwrap_or(0), 1);
}
/// `Entity.getOnPos(1.0E-5F)` keeps a fence's, a wall's or a fence gate's
/// own cell while the feet rest on its 1.5-block collision; on a full block
/// it is the block below the feet. Reading 0.2 below the feet, as the dust
/// used to, lands in air on all three.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn the_ground_of_a_breeze_is_the_fence_wall_or_gate_it_stands_on() {
let mut game = game_with_breeze(Vec3::new(8.5, 65.5, 8.5));
let registry = &game.pack.assets.registry;
let stone = registry.default_state("minecraft:stone").unwrap();
game.world.set(8, 63, 8, stone).unwrap();
game.mobs[0].on_ground = true;
for name in [
"minecraft:oak_fence",
"minecraft:cobblestone_wall",
"minecraft:oak_fence_gate",
] {
let state = registry.default_state(name).unwrap();
game.world.set(8, 64, 8, state).unwrap();
assert_eq!(
game.breeze_ground(&game.mobs[0]),
([8, 64, 8], state),
"{name}"
);
}
// The same breeze on the full block's top reads that block.
game.world.set(8, 64, 8, stone).unwrap();
game.mobs[0].position = Vec3::new(8.5, 65.0, 8.5);
assert_eq!(game.breeze_ground(&game.mobs[0]), ([8, 64, 8], stone));
}
}
+1 -1
View File
@@ -2694,7 +2694,7 @@ mod damage_type_registry_tests {
let pack = dir.join("datapacks/model");
let types = pack.join("data/example/damage_type");
std::fs::create_dir_all(&types).unwrap();
std::fs::write(pack.join("pack.mcmeta"), "{}").unwrap();
std::fs::write(pack.join("pack.mcmeta"), r#"{"pack":{"description":""}}"#).unwrap();
std::fs::write(
types.join("test.json"),
r#"{"message_id":"example.test","scaling":"never","exhaustion":0.0}"#,
+5 -9
View File
@@ -3,6 +3,7 @@
mod admin;
pub(super) mod compute;
mod data;
mod display_names;
pub(super) use data::place_summoned;
mod effects;
pub(super) mod execute;
@@ -782,13 +783,7 @@ impl Game<'_> {
let block = Text::translate(&format!("block.{block}"), &[]);
Text::translate("entity.minecraft.falling_block_type", &[block])
}
Target::Mob(id) => self.mobs.iter().find(|m| m.id == id).map_or_else(
|| Text::translate("entity.", &[]),
|m| match self.mob_team_of(m) {
Some(team) => team.formatted_name(m.command_name()),
None => m.command_name(),
},
),
Target::Mob(id) => self.mob_command_name(id),
other => {
// Entity.getDisplayName: the CustomName component, else the type.
let custom = super::native_entities::target_id(other)
@@ -2618,9 +2613,10 @@ impl Game<'_> {
if self.res.mob_types().contains_key(&kind) {
// SummonCommand.createEntity loads the mob from `{id:..}`: its yaw
// is the compound's (none, so 0), with no level-random draw.
self.spawn_mob_spawned(&short, pos, super::creatures::SpawnYaw::Fixed(0.0))
let id = self
.spawn_mob_spawned(&short, pos, super::creatures::SpawnYaw::Fixed(0.0))
.ok_or_else(|| err("commands.summon.failed.uuid", &[]))?;
name = Text::translate(&entity_name_key(&kind), &[]);
name = self.target_name(Target::Mob(id));
} else {
match short.as_str() {
"item" => {
@@ -421,6 +421,16 @@ impl Game<'_> {
// random (Entity.random) goes on, as a spawn egg's entity
// data finds it.
slot.random = old.random.clone();
// Entity.load keeps the unsaved Allay.jukeboxPos and
// DATA_DANCING of the entity it loads into.
if slot.species() == horizon_client::mobs::Species::Allay {
slot.extra.flag = old.extra.flag;
if let (Some(st), Some(was)) =
(slot.extra.allay.as_mut(), old.extra.allay.as_ref())
{
st.jukebox = was.jukebox;
}
}
if keep_liked_player {
slot.extra.tame = old.extra.tame;
slot.extra.owner = old.extra.owner;
@@ -0,0 +1,34 @@
//! `Entity.getDisplayName` for the mobs command replies name: the name as its
//! team shows it, with the `show_entity` hover and the UUID insertion.
use super::super::Game;
use horizon_client::text::Text;
impl Game<'_> {
/// `Entity.getDisplayName` for the mob `id`: `PlayerTeam.formatNameForTeam`
/// of `getName()`, then `withHoverEvent(createHoverEvent())` (a `ShowEntity`
/// of the type, UUID and name) and `withInsertion(getStringUUID())`. A mob
/// gets its UUID when it joins the level; one without keeps the bare name.
pub(in crate::game) fn mob_command_name(&self, id: u32) -> Text {
let Some(m) = self.mobs.iter().find(|m| m.id == id) else {
return Text::translate("entity.", &[]);
};
let name = m.command_name();
let shown = match self.mob_team_of(m) {
Some(team) => team.formatted_name(name.clone()),
None => name.clone(),
};
let Some(uuid) = self.persist.uuid_of(id) else {
return shown;
};
let uuid = horizon_client::save::uuid_text(uuid);
let kind = horizon_client::command::args::normalize(&m.kind.id);
shown.with_component_fields(vec![
(
"hover_event".into(),
serde_json::json!({"action": "show_entity", "id": kind, "uuid": uuid,
"name": name.component()}),
),
("insertion".into(), uuid.into()),
])
}
}
@@ -520,11 +520,12 @@ impl Game<'_> {
let Some(pos) = self.target_position(t) else {
return Ok(0);
};
let current = self.target_rotation(t);
let mut look_at_target = None;
let rot = if let Some(Arg::Coords(c)) = ctx.get("rotation") {
// Relative parts are offsets from the entity's own rotation.
c.rotation(current)
// RotateCommand.rotate: relative parts are offsets from the command
// source's rotation (WorldCoordinates.getRotation); the entity's own
// rotation cancels out in Entity.forceSetRotation.
c.rotation(self.source_rotation())
} else {
let mut faced_entity = None;
let to = if ctx.has("entity") {
@@ -441,6 +441,15 @@ impl Game<'_> {
}
Ok(Some(json!({ "ok": true, "flat": r.flat_draws() })))
}
"section-layout" => {
let r = self.renderer.as_mut().ok_or("no renderer")?;
let compact = match words.get(1).copied() {
Some("compact") => r.set_packed_layout(true),
Some("wide") => r.set_packed_layout(false),
_ => return Err("section-layout compact|wide".into()),
};
Ok(Some(json!({ "ok": true, "compact": compact })))
}
"section-cull" => {
let r = self.renderer.as_mut().ok_or("no renderer")?;
match words.get(1).copied() {
@@ -325,6 +325,8 @@ fn an_interact_packet_cannot_name_a_mob_the_pick_would_refuse() {
assert!(named_mob_pickable(true, 17, Some(23), Some(29)));
}
#[path = "mob_display_name_tests.rs"]
mod mob_display_name_tests;
#[path = "pet_order_tests.rs"]
mod pet_order_tests;
/// The packet's location is `EntityHitResult.getLocation() -
+37 -3
View File
@@ -261,9 +261,11 @@ fn worn_of(
}
/// The UUID the service knows the player's own model by. On a server, the profile it gave
/// (`profile`). In single player the window's own server (`solo`) names the player by their
/// offline profile, which the service doesn't know: the signed-in account's (`account`) then, only
/// with *Cosmetics in single player* on (`shown`); off, nothing is asked or drawn.
/// (`profile`), but an offline-mode server's is `nameUUIDFromBytes` of the name (version 3), which
/// the service doesn't know: the signed-in account's (`account`) then, as an online server's would
/// be (#12820). Only the own model: another player's name can be anyone's there. In single player
/// the window's own server (`solo`) names the player by their offline profile too: the account's
/// then, only with *Cosmetics in single player* on (`shown`); off, nothing is asked or drawn.
fn own_uuid(
profile: Option<u128>,
solo: bool,
@@ -271,6 +273,7 @@ fn own_uuid(
account: impl FnOnce() -> Option<u128>,
) -> Option<u128> {
match solo {
false if profile.is_some_and(|p| (p >> 76) & 0xf == 3) => account().or(profile),
false => profile,
true if shown && profile.is_some() => account(),
true => None,
@@ -1060,6 +1063,37 @@ mod tests {
assert!(worn.is_none() && asked.is_empty());
}
#[test]
fn an_offline_servers_profile_asks_by_my_account_and_an_online_one_by_its_own() {
const ACCOUNT: u128 = 0x1f2e_3d4c_5b6a_4978_8695_a4b3_c2d1_e0ff;
let offline = horizon_protocol::crypto::offline_uuid("Steve");
let online = 0x069a_79f4_44e9_4726_a5be_fca9_0e38_aaf5;
let signed_in = || Some(ACCOUNT);
// An offline-mode server's profile: the service knows the account, so the account's.
assert_eq!(
own_uuid(Some(offline), false, false, signed_in),
Some(ACCOUNT)
);
// An online server's profile is the account's own already: kept as it is.
assert_eq!(
own_uuid(Some(online), false, false, signed_in),
Some(online)
);
// Signed out: the profile, as before.
assert_eq!(
own_uuid(Some(offline), false, false, || None),
Some(offline)
);
// No profile yet: nothing to ask by.
assert_eq!(own_uuid(None, false, false, signed_in), None);
// Single player still follows its own setting.
assert_eq!(own_uuid(Some(offline), true, false, signed_in), None);
assert_eq!(
own_uuid(Some(offline), true, true, signed_in),
Some(ACCOUNT)
);
}
#[test]
fn single_player_shows_my_cosmetics_only_with_its_setting_and_asks_by_my_account() {
// The window's own server names the player by their offline profile.
@@ -28,8 +28,12 @@ impl Game<'_> {
/// `itemGroup.<mod>.<name>`, else its `title` as a lang key (a mod's own
/// `itemGroup.<mod>`), else `title` as English text.
pub(super) fn creative_tab_title(&self, tab: &horizon_client::creative::Tab) -> String {
if let Some((name, _)) = &tab.name {
return name.clone();
if let Some((name, _, translate)) = &tab.name {
return if *translate {
self.translate(name, name)
} else {
name.clone()
};
}
match horizon_client::creative::item_group_key(&tab.id) {
Some(key) => self.translate(key, &tab.title),
@@ -291,6 +295,13 @@ impl Game<'_> {
if let Some(lines) = effect_tip {
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
}
// CreativeModeInventoryScreen.checkTabHovering: a tab says its name.
if let Some(i) = Creative::hovered_tab(&tabs, (ox, oy), self.cursor)
&& let Some(t) = tabs.get(i)
{
let title = self.creative_tab_title(t);
self.tooltip(batch, &title, self.cursor.0, self.cursor.1);
}
}
pub(super) fn creative_trash_hovered(batch: &HudBatch, cursor: (f32, f32)) -> bool {
let (ox, oy) = Self::creative_origin(batch);
+1 -1
View File
@@ -3059,7 +3059,7 @@ impl Game<'_> {
// `CrossbowItem.createProjectile`: a rocket, from 0.15 under the eyes.
let mut at = arrow.position;
at.y -= f64::from(0.15f32) - 0.1;
self.launch_angled_rocket(at, arrow.velocity, &ammo, arrow.owner);
self.launch_angled_rocket(at, &ammo, arrow.owner, |_| arrow.velocity);
} else {
Self::arrow_from_ammo(&ammo, &mut arrow);
self.launch_mob_projectile(arrow);
+5 -4
View File
@@ -10,7 +10,6 @@ use horizon_client::{
items::ItemStack,
mobs::Who,
projectiles::{Projectile, ProjectileKind},
random_source,
};
use horizon_core::{geometry::Vec3, random::RandomSource};
@@ -342,10 +341,12 @@ impl Game<'_> {
let rocket = &*ammo.item == FIREWORK_ROCKET;
if rocket {
let at = Vec3::new(eye[0], eye[1] - f64::from(0.15f32), eye[2]);
let mut shot = Projectile::new(ProjectileKind::Snowball, at, None);
shot.shoot(&mut random_source::entity(), dir.to_array(), power, 1.0);
// Its first tick posts the shot (Projectile.tick).
self.launch_angled_rocket(at, shot.velocity, ammo, Some(Who::Player));
self.launch_angled_rocket(at, ammo, Some(Who::Player), |own| {
let mut shot = Projectile::new(ProjectileKind::Snowball, at, None);
shot.shoot(own, dir.to_array(), power, 1.0);
shot.velocity
});
}
if !rocket {
let effect = super::dispenser::tipped_effect(ammo).or_else(|| {
+73 -5
View File
@@ -90,6 +90,22 @@ pub(super) struct DialogState {
warning: bool,
}
impl DialogState {
/// `Minecraft.pause` over these screens: `DialogScreen.isPauseScreen` is
/// the dialog's own `pause` (`CommonDialogData.pause`, true by default),
/// `WaitingForResponseScreen` overrides it false, and the warning above
/// the dialog is a `ConfirmScreen`, which inherits `Screen`'s true.
pub(super) fn pauses_world(&self) -> bool {
if self.warning {
return true;
}
if self.waiting.is_some() {
return false;
}
self.open.as_ref().is_some_and(|open| open.dialog.pause)
}
}
/// `CycleButton.cycleValue`: the next option (`delta` 1) or the one before
/// it, wrapping.
fn cycle(open: &mut Open, input: usize, delta: i32) {
@@ -557,17 +573,19 @@ impl Game<'_> {
batch.text_styled(atlas, &text, x, y, u32::MAX, true);
// AbstractStringWidget: only the styled glyph under the
// pointer requests a hand, not the whole message widget.
if (!clipped || in_area(cursor.1))
&& let Some(style) = horizon_client::dialog_screen::line_style_at(
let under = |wanted: fn(&horizon_client::text::Style) -> bool| {
horizon_client::dialog_screen::line_style_at(
&atlas.font,
&text,
x,
y,
cursor,
wanted,
)
{
widgets::widget_cursor(style.click.is_some(), true);
if let Some(hover) = style.hover {
};
if !clipped || in_area(cursor.1) {
widgets::widget_cursor(under(|s| s.click.is_some()).is_some(), true);
if let Some(hover) = under(|s| s.hover.is_some()).and_then(|s| s.hover) {
tooltip = Some(*hover);
}
}
@@ -898,6 +916,7 @@ impl Game<'_> {
x,
y,
cursor,
|style| style.click.is_some(),
)
.and_then(|style| style.click);
}
@@ -1259,3 +1278,52 @@ mod background_tests {
}
}
}
#[cfg(test)]
mod pause_tests {
use super::*;
fn dialog(pause: bool) -> Dialog {
let s = |value: &str| Tag::String(value.into());
Dialog::parse(&Tag::compound([
("type", s("notice")),
("title", s("Hi")),
("pause", Tag::Byte(i8::from(pause))),
]))
.unwrap()
}
fn state(pause: bool) -> DialogState {
DialogState {
open: Some(Open {
dialog: dialog(pause),
values: Vec::new(),
fields: Vec::new(),
focus: None,
scroll: ui::Scroll::default(),
dragging: None,
selecting: None,
}),
waiting: None,
warning: false,
}
}
/// `DialogScreen.isPauseScreen` is `dialog.common().pause()`, true by
/// default, so a server's dialog stops a single-player world unless it
/// asks otherwise. The waiting screen and the warning override it.
#[test]
fn a_dialog_pauses_the_world_only_while_its_pause_says_so() {
assert!(state(true).pauses_world());
assert!(!state(false).pauses_world());
// No dialog screen at all.
assert!(!DialogState::default().pauses_world());
// DialogScreen.WarningScreen extends ConfirmScreen: true.
let mut warning = state(false);
warning.warning = true;
assert!(warning.pauses_world());
// WaitingForResponseScreen.isPauseScreen is false.
let mut waiting = state(true);
waiting.waiting = Some(Instant::now());
assert!(!waiting.pauses_world());
}
}
+23 -5
View File
@@ -1097,6 +1097,16 @@ impl Game<'_> {
count: 1,
..stack.clone()
};
// A rocket makes its own random in `launch_angled_rocket`: the constructor's draws
// and then the shot's spread come from that one.
if item == "minecraft:firework_rocket" {
self.launch_angled_rocket(at, &one, None, |own| {
let mut shot = Projectile::new(ProjectileKind::Snowball, at, None);
shot.shoot(own, dir, power, uncertainty);
shot.velocity
});
return;
}
// Projectile.shoot spreads the shot with the projectile's own random.
let mut own = random_source::entity();
match item.as_str() {
@@ -1107,11 +1117,6 @@ impl Game<'_> {
shot.shoot(&mut own, dir, power, uncertainty);
self.launch_thrown(one, at, shot.velocity, None);
}
"minecraft:firework_rocket" => {
let mut shot = Projectile::new(ProjectileKind::Snowball, at, None);
shot.shoot(&mut own, dir, power, uncertainty);
self.launch_angled_rocket(at, shot.velocity, &one, None);
}
_ => {
let kind = match item.as_str() {
"minecraft:arrow" => ProjectileKind::Arrow {
@@ -1248,7 +1253,12 @@ impl Game<'_> {
boat = boat.with_chest();
}
self.world.modified.insert((front.0 >> 4, front.2 >> 4));
let (id, kind, yaw) = (boat.id, boat.entity_type(), boat.yaw);
self.boats.push(boat);
// BoatDispenseItemBehavior: createDefaultStackConfig with no user,
// then setYRot(direction.toYRot()).
self.apply_vehicle_stack_config(super::commands::Target::Boat(id), &kind, &stack, false);
self.set_boat_yaw(id, yaw);
if self.serving.is_some() || self.options.dedicated.is_some() {
let boat = self.boats.last().unwrap();
self.native_vehicle_create(
@@ -1295,6 +1305,14 @@ impl Game<'_> {
);
self.world.modified.insert((front.0 >> 4, front.2 >> 4));
self.carts.push(cart);
// AbstractMinecart.createMinecart: createDefaultStackConfig with no user.
let id = self.next_entity;
self.apply_vehicle_stack_config(
super::commands::Target::Cart(id),
kind.id(),
&stack,
false,
);
if self.serving.is_some() || self.options.dedicated.is_some() {
let cart = self.carts.last().unwrap();
self.native_vehicle_create(
+3 -12
View File
@@ -3108,18 +3108,8 @@ impl Game<'_> {
if hurts {
self.note_dragon_attacker("minecraft:mob_attack");
}
let (health, rules) = (&self.health_resources, self.world.health_rules);
let id = health.damage.id("minecraft:mob_attack");
let mut canceled = false;
if let Some(p) = self.player.as_mut() {
push_seen_with_hurt(p, push, |p| {
if let Some(id) = id.filter(|_| hurts)
&& let Ok(o) = p.hurt_blocking(health, rules, id, 5.0, None)
{
canceled = o.canceled;
}
});
}
let id = self.health_resources.damage.id("minecraft:mob_attack");
let canceled = self.push_seen_with_equipment_hurt(push, id.filter(|_| hurts), 5.0);
self.combat.forget_noted(canceled);
if !sitting
&& !hurt_lately
@@ -4815,6 +4805,7 @@ use death_render::{dissolve_color, dissolve_mask, dragon_rays};
/// reaches the players tracking them, not them. So the push moves the player
/// only together with a hurt that lands outside the hurt cooldown, and is
/// dropped otherwise.
#[cfg(test)]
fn push_seen_with_hurt(
p: &mut horizon_client::motion::PlayerMotion,
push: Vec3,
@@ -108,7 +108,12 @@ fn a_ticking_gateway_counts_its_age_in_its_block_entity() {
game.world
.chunks
.insert((2, 0), Arc::new(Chunk::empty(2, 0)));
assert!(game.run_command("setblock 40 64 8 minecraft:end_gateway{Age:0L}"));
// A dedicated server with nobody on it: the console runs the command, on
// the command registries (as block_entity_anims' gateway test does).
game.build_registries();
assert!(
game.as_console(|game| game.run_command("setblock 40 64 8 minecraft:end_gateway{Age:0L}"))
);
game.update_chunk_tickets();
assert!(game.block_ticking_area()(40, 8));
// What `/data get block` and the chunk save encode (`TheEndGatewayBlockEntity.saveAdditional`).
+8
View File
@@ -247,6 +247,11 @@ pub(super) trait Entity: EntityRules {
fn portal_size(&self) -> Option<(f32, f32)> {
None
}
/// Whether [`Self::portal_size`] is Some, without working the size out (a
/// mob's depends on its age, scale and pose).
fn sweeps_portals(&self) -> bool {
self.portal_size().is_some()
}
/// `Entity.canUsePortal(false)` for a kind in `portal_size`. A kind that
/// cannot stays tracked, so its cooldown and process run out.
fn can_use_portal(&self, _game: &Game<'_>) -> bool {
@@ -1105,6 +1110,9 @@ impl Entity for Mob {
fn portal_size(&self) -> Option<(f32, f32)> {
Some((self.width(), self.height()))
}
fn sweeps_portals(&self) -> bool {
true
}
fn can_use_portal(&self, game: &Game<'_>) -> bool {
game.mob_can_use_portal(self)
}
+59 -14
View File
@@ -272,18 +272,35 @@ pub(super) fn fireworks_of(stack: &ItemStack) -> (u8, Vec<FireworkExplosion>) {
.and_then(|d| d.fireworks.clone())
.unwrap_or((1, Vec::new()))
}
/// `FireworkRocketEntity(Level, double, double, double, ItemStack)`, after the UUID:
/// the motion's two triangles, then the lifetime's two `nextInt`s, all from `random`.
fn rocket_draws(random: &mut horizon_core::random::LegacyRandom, flight: u8) -> (Vec3, i32) {
let tri = |r: &mut horizon_core::random::LegacyRandom| r.triangle(0.0, 0.002297);
let velocity = Vec3::new(tri(random), 0.05, tri(random));
let lifetime = 10 * (1 + i32::from(flight))
+ random.next_int_bound(6).unwrap_or(0)
+ random.next_int_bound(7).unwrap_or(0);
(velocity, lifetime)
}
impl Game<'_> {
pub(super) fn new_rocket(&mut self, at: Vec3, stack: &ItemStack, attached: bool) -> Rocket {
/// A rocket built from `own`, the rocket's own random (`Entity.random`): the
/// UUID, the motion and the lifetime draw from it in that order, and a rocket
/// that is then shot (`Projectile.shoot`) goes on drawing from the same one.
pub(super) fn new_rocket(
&mut self,
at: Vec3,
stack: &ItemStack,
attached: bool,
own: &mut horizon_core::random::LegacyRandom,
) -> Rocket {
let (flight, explosions) = fireworks_of(stack);
// FireworkRocketEntity: motion and lifetime come from the rocket's own random.
let r = &mut random_source::entity();
let tri = |r: &mut horizon_core::random::LegacyRandom| r.triangle(0.0, 0.002297);
let velocity = Vec3::new(tri(r), 0.05, tri(r));
let lifetime = 10 * (1 + i32::from(flight))
+ r.next_int_bound(6).unwrap_or(0)
+ r.next_int_bound(7).unwrap_or(0);
// Entity.<init>: the id comes from the one counter at construction.
// Entity.<init>: the id comes from the one counter at construction, the UUID
// from the random's first two longs.
let id = self.take_entity_id();
self.persist
.uuids
.insert(id, horizon_client::save::insecure_uuid(own));
let (velocity, lifetime) = rocket_draws(own, flight);
Rocket {
rotation: [0.0, 0.0],
join: Default::default(),
@@ -318,7 +335,7 @@ impl Game<'_> {
hit.point[1] + f64::from(hit.face[1]) * 0.15,
hit.point[2] + f64::from(hit.face[2]) * 0.15,
);
let mut rocket = self.new_rocket(at, stack, false);
let mut rocket = self.new_rocket(at, stack, false, &mut random_source::entity());
rocket.owner = Some(Who::Player);
rocket.guest = self.lan_acting;
self.rockets.push(rocket);
@@ -330,15 +347,19 @@ impl Game<'_> {
}
/// A rocket shot at an angle (`DATA_SHOT_AT_ANGLE`): out of a dispenser
/// (`FireworkRocketItem.asProjectile`) or a crossbow.
///
/// `shoot` gives the shot's velocity from the rocket's own random, after the
/// constructor's draws (`asProjectile`, then `spawnProjectileUsingShoot`).
pub(super) fn launch_angled_rocket(
&mut self,
at: Vec3,
velocity: Vec3,
stack: &ItemStack,
owner: Option<Who>,
shoot: impl FnOnce(&mut horizon_core::random::LegacyRandom) -> Vec3,
) {
let mut rocket = self.new_rocket(at, stack, false);
rocket.velocity = velocity;
let mut own = random_source::entity();
let mut rocket = self.new_rocket(at, stack, false, &mut own);
rocket.velocity = shoot(&mut own);
rocket.angled = true;
rocket.owner = owner;
rocket.guest = self.lan_acting.filter(|_| owner == Some(Who::Player));
@@ -413,7 +434,7 @@ impl Game<'_> {
self.play_player_bound_sound("minecraft:item.lead.break", 6, 1.0, 1.0, false);
}
let at = self.player.as_ref().map_or(Vec3::ZERO, |p| p.position);
let mut rocket = self.new_rocket(at, stack, true);
let mut rocket = self.new_rocket(at, stack, true, &mut random_source::entity());
rocket.owner = Some(Who::Player);
rocket.guest = self.lan_acting;
self.rockets.push(rocket);
@@ -842,6 +863,30 @@ fn use_on_passes(fall_flying: bool) -> bool {
#[cfg(test)]
mod tests {
/// A rocket draws its UUID's two longs first, then the motion's two triangles
/// (four doubles), then `nextInt(6)` and `nextInt(7)` for the lifetime, all from
/// the one random. The expected values come from `java.util.Random` replayed
/// outside the game: a rocket whose UUID draw were skipped would read 30, 22
/// and 26 here.
#[test]
fn a_rockets_lifetime_follows_its_uuid_and_motion_draws() {
use horizon_core::random::LegacyRandom;
for (seed, flight, lifetime) in [
(1, 1, 26),
(12_345, 1, 26),
(987_654_321, 1, 25),
(1, 3, 46),
] {
let mut own = LegacyRandom::new(seed);
horizon_client::save::insecure_uuid(&mut own);
assert_eq!(
super::rocket_draws(&mut own, flight).1,
lifetime,
"seed {seed}"
);
}
}
/// A flying rocket's vertices carry its own id for frame generation's and a temporal
/// upscaler's motion vectors (`Frame::entity_motion`), so the motion matched between
/// two frames is its flight, not only the camera's.
@@ -54,6 +54,33 @@ impl Game<'_> {
.finish_equipment_hurt(&self.health_resources, hit)?;
Ok((outcome, actual))
}
/// `push_seen_with_hurt` for the host: the push is kept only when the hurt
/// marks the velocity as seen, and the hurt goes through
/// `hurt_player_equipment` so the worn armor loses durability. Answers
/// whether a native hook canceled the hurt.
pub(super) fn push_seen_with_equipment_hurt(
&mut self,
push: Vec3,
source: Option<horizon_world::damage_source::DamageTypeId>,
amount: f32,
) -> bool {
let Some(p) = self.player.as_mut() else {
return false;
};
let before = p.velocity;
let marked_before = std::mem::take(&mut p.health.sync_velocity);
p.velocity = before.add_vector(push);
let canceled = source
.and_then(|id| self.hurt_player_equipment(id, amount, None, false, 0).ok())
.is_some_and(|(o, _)| o.canceled);
if let Some(p) = self.player.as_mut() {
if !p.health.sync_velocity {
p.velocity = before;
}
p.health.sync_velocity |= marked_before;
}
canceled
}
/// The armor hits among a tick's motion events (`Player.hurtArmor` from
/// `getDamageAfterArmorAbsorb`), whatever the damage: a mob, an explosion,
/// a cactus, lava, a campfire.
+150 -4
View File
@@ -708,6 +708,20 @@ impl Game<'_> {
}
for &(pos, _) in &view.parts {
self.container_changed(pos);
// `ShelfBlockEntity.setChanged()` (the no-argument override posts
// `BLOCK_ACTIVATE`): a container writing a shelf's slot is heard.
if matches!(
self.world.block_entities.get(&pos),
Some(BlockEntity::Shelf(_))
) {
let state = self.world.block(pos.0, pos.1, pos.2);
self.post_block_game_event(
"minecraft:block_activate",
[pos.0, pos.1, pos.2],
None,
Some(state),
);
}
}
}
/// `BlockEntity.setChanged` at `pos`: comparators re-read the container.
@@ -899,6 +913,9 @@ impl Game<'_> {
}
/// Remove one item from a slot (`removeItem(slot, 1)`).
fn take_one(&mut self, view: &View, slot: usize) -> Option<ItemStack> {
if view.kind == Kind::Pot {
return self.pot_take_one(view.parts[0].0);
}
let target = self.slot_mut_at(view, slot)?;
let current = target.as_mut().filter(|s| s.count > 0)?;
let one = ItemStack {
@@ -911,8 +928,56 @@ impl Game<'_> {
}
Some(one)
}
/// `ContainerSingleItem.removeItem(0, 1)` on a decorated pot's saved slot.
fn pot_take_one(&mut self, pos: Pos) -> Option<ItemStack> {
let mut tag = spill::saved_entity(&self.world, [pos.0, pos.1, pos.2])?;
let mut item = tag.get("item").and_then(save::item_from_nbt)?;
if item.count == 0 {
return None;
}
let one = ItemStack {
count: 1,
..item.clone()
};
item.count -= 1;
self.pot_store(pos, &mut tag, (item.count > 0).then_some(item));
Some(one)
}
/// Undo `pot_take_one` after a failed move.
fn pot_put_back(&mut self, pos: Pos, one: ItemStack) {
let Some(mut tag) = spill::saved_entity(&self.world, [pos.0, pos.1, pos.2]) else {
return;
};
let item = match tag.get("item").and_then(save::item_from_nbt) {
Some(mut current) => {
current.count += one.count;
current
}
None => one,
};
self.pot_store(pos, &mut tag, Some(item));
}
/// Write a pot's saved item slot, keeping the tag's other metadata.
fn pot_store(&mut self, pos: Pos, tag: &mut Tag, item: Option<ItemStack>) {
if let Tag::Compound(map) = tag {
match item {
Some(item) => {
map.insert("item".into(), save::item_to_nbt(&item));
}
None => {
map.remove(&"item".into());
}
}
}
spill::put_saved_entity(&mut self.world, [pos.0, pos.1, pos.2], tag.clone());
self.mark_block_entity_updated(pos);
}
/// Undo `take_one` after a failed move.
fn put_back(&mut self, view: &View, slot: usize, one: ItemStack) {
if view.kind == Kind::Pot {
self.pot_put_back(view.parts[0].0, one);
return;
}
if let Some(target) = self.slot_mut_at(view, slot) {
match target {
Some(current) => current.count += one.count,
@@ -1003,7 +1068,11 @@ impl Game<'_> {
.assets
.registry
.state(self.world.block(above.0, above.1, above.2))
.is_none_or(|s| s.name != "minecraft:composter")
.is_none_or(|s| {
// Composters and decorated pots are containers without a
// modeled block entity.
s.name != "minecraft:composter" && s.name != "minecraft:decorated_pot"
})
{
return;
}
@@ -1274,7 +1343,7 @@ impl Game<'_> {
/// (`getSourceContainerOrHandler`). `None` when there is no container.
fn suck_from_container(&mut self, above: Pos, at: [f64; 3], hopper: &View) -> Option<bool> {
let c = self.containers();
let Some(source) = c.view(above, false) else {
let Some(source) = c.hopper_insertion_view(above) else {
let id = self.entity_container_at(at)?;
self.native_hopper_inventory_search(
[
@@ -1339,10 +1408,10 @@ impl Game<'_> {
for slot in source.slots_for_face_iter(Some(Direction::Down)) {
{
let c = self.containers();
let Some(stack) = c.get(source, slot) else {
let Some(stack) = c.insertion_item(source, slot).map(Cow::into_owned) else {
continue;
};
if !c.can_take(source, slot, stack, Direction::Down) {
if !c.can_take(source, slot, &stack, Direction::Down) {
continue;
}
}
@@ -2295,4 +2364,81 @@ mod pot_hopper_insertion_tests {
"actual hopper/pot transfer missing: {failures:?}"
);
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and scratch homes/cwd"]
fn actual_hopper_pulls_from_the_saved_pot_slot() {
let mut game = server_game();
game.build_registries();
game.set_allow_commands(true);
game.block_ticks
.add_empty_chunk(BlockPos::new(0, 0, 0).chunk())
.unwrap();
assert!(game.run_command("fill 0 199 0 15 199 15 stone"));
assert!(
game.run_command(
"setblock 5 201 4 decorated_pot{item:{id:\"minecraft:wheat\",count:2}}"
)
);
assert!(
game.run_command(
"setblock 5 200 4 hopper[facing=down,enabled=true]{TransferCooldown:0}"
)
);
let start = game.world.game_time;
for elapsed in 1..=9 {
game.world.game_time = start + elapsed;
game.tick_hopper((5, 200, 4));
}
let Some(BlockEntity::Chest(slots)) = game.world.block_entities.get(&(5, 200, 4)) else {
panic!("hopper inventory missing");
};
let held: u32 = slots.iter().flatten().map(|s| s.count).sum();
assert_eq!(held, 2, "the hopper took both items");
let tag = spill::saved_entity(&game.world, [5, 201, 4]).expect("saved pot");
assert!(tag.get("item").is_none(), "the pot is empty");
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::game::native_machines::tests::server_game;
/// `ShelfBlockEntity.setChanged` (no arguments) posts `BLOCK_ACTIVATE`:
/// a container writing a shelf's slot is a block activation.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_container_writing_a_shelf_posts_block_activate() {
let mut game = server_game();
let key = (2, 4, 3);
let state = game
.pack
.assets
.registry
.default_state("minecraft:oak_shelf")
.unwrap();
game.world.set(key.0, key.1, key.2, state).unwrap();
game.world
.block_entities
.put(key, BlockEntity::Shelf(Box::default()));
let view = game
.containers()
.view(key, false)
.expect("the shelf's view");
super::super::sculk::TEST_POSTED_EVENTS.with(|e| *e.borrow_mut() = Some(Vec::new()));
game.view_changed(&view);
super::super::sculk::TEST_POSTED_EVENTS.with(|e| {
assert!(
e.borrow().as_ref().unwrap().contains(&(
"minecraft:block_activate",
[2.5, 4.5, 3.5],
None
)),
"{:?}",
e.borrow()
);
});
}
}
+194 -52
View File
@@ -469,11 +469,12 @@ impl Game<'_> {
[x, y, z]
}
}
/// `AbstractMinecart.isRedstoneConductor`: the block state's own flag
/// (soul sand conducts without a full collision shape).
fn conductor(&self, x: i32, y: i32, z: i32) -> bool {
self.res
.facts()
.get(self.world.block(x, y, z))
.full_collision
self.res.behaviours().facts(self.world.block(x, y, z)).flags
& horizon_world::level::data::state_flags::CONDUCTOR
!= 0
}
/// `OldMinecartBehavior.getPos`: the point on the rail under (x, y, z).
fn rail_pos(&self, x: f64, y: f64, z: f64) -> Option<[f64; 3]> {
@@ -529,11 +530,9 @@ impl Game<'_> {
/// collision, which sets a TNT minecart off.
fn cart_entity_collisions(&self, c: &Minecart) -> Vec<Aabb> {
let near = c.bounds().inflate(Vec3::new(2.0, 2.0, 2.0));
// Every cart asks about every entity of the level each tick, and the box
// turns nearly all of them away: it is tested first, before a mob's
// pushable state, and only the boxes kept are collected.
// Every moving cart asks each tick, and the box turns nearly every entity of
// the level away: it is tested first, and only the mobs near it are read.
let close = |b: &Aabb| b.intersects(near);
let reach = self.vehicle_mob_reach();
let mut boxes: Vec<Aabb> = self
.carts
.iter()
@@ -546,22 +545,13 @@ impl Game<'_> {
.map(super::boats::Boat::bounds)
.filter(close),
)
.chain(
self.mobs
.iter()
.filter(|m| reach.is_none_or(|r| r.may_meet(m, near)))
.map(|m| (m, m.bounds()))
.filter(|(m, b)| {
close(b)
&& !m.removed
&& m.extra.mount != Some(c.id)
&& (m.pushable()
|| m.species() == horizon_client::mobs::Species::Shulker
&& m.alive())
})
.map(|(_, b)| b),
)
.collect();
self.mobs_meeting(near, |_, m, b| {
let counts = m.pushable() || m.species() == Species::Shulker && m.alive();
if !m.removed && m.extra.mount != Some(c.id) && counts {
boxes.push(b);
}
});
if self.riding != Some(c.id)
&& let Some(p) = self.player.as_ref()
&& !self.spectator()
@@ -700,12 +690,7 @@ impl Game<'_> {
/// sits in it. `getSlowdownFactor` (0.997) and the 0.75 speed scale key on
/// this, not on who rides.
fn cart_is_vehicle(&self, id: u32) -> bool {
self.riding == Some(id)
|| self.ridden_by_guest(id)
|| self
.mobs
.iter()
.any(|m| !m.removed && m.extra.mount == Some(id))
self.riding == Some(id) || self.ridden_by_guest(id) || self.mob_rides(id)
}
/// `OldMinecartBehavior.moveAlongTrack`.
fn move_along_track(
@@ -714,7 +699,8 @@ impl Game<'_> {
pos: [i32; 3],
rail: &Rail,
intent: [f64; 2],
) -> (Vec3, Vec3) {
slot: usize,
) {
c.fall_distance = 0.0;
let ridden = self.cart_is_vehicle(c.id);
let (x, z) = (c.position.x, c.position.z);
@@ -785,11 +771,11 @@ impl Game<'_> {
0.0,
(scale * c.velocity.z).clamp(-max, max),
);
// Entity.move records this step at the rail's top height; the tick's
// applyEffectsFromBlocks sweeps it, whatever the cart does after.
let step_from = c.position;
self.move_cart(c, delta);
let step = (step_from, c.position);
// 🤖 Agent: `AbstractMinecart.move` runs applyEffectsFromBlocks at
// once, the cart still at the rail's top height (below it is lowered).
let here = c.position;
self.cart_inside_blocks(c, slot, here, here);
let at = |e: [i32; 3], c: &Minecart| {
c.position.x.floor() as i32 - pos[0] == e[0]
&& c.position.z.floor() as i32 - pos[2] == e[2]
@@ -839,7 +825,6 @@ impl Game<'_> {
}
}
}
step
}
/// `Entity.checkInsideBlocks` for a recorded move of the cart from `from`
/// to `to`: the entity-sensing blocks (plates, buttons, tripwires, detector
@@ -1367,11 +1352,10 @@ impl Game<'_> {
// applyGravity
c.velocity.y -= c.get_gravity();
let pos = self.rail_block(c.position);
let mut step = None;
if self.minecart_improvements() {
self.move_along_track_new(c, intent);
} else if let Some(rail) = self.rail(pos[0], pos[1], pos[2]) {
step = Some(self.move_along_track(c, pos, &rail, intent));
self.move_along_track(c, pos, &rail, intent, slot);
if &*rail.name == "minecraft:activator_rail" {
self.activate_cart(c, rail.powered);
}
@@ -1382,6 +1366,8 @@ impl Game<'_> {
// The old behavior derives yaw once per tick; the experiment has
// already recorded each step's yaw and pitch.
if !self.minecart_improvements() {
// OldMinecartBehavior.tick: setXRot(0.0F) before the yaw update.
c.pitch = 0.0;
let (xd, zd) = (c.previous.x - c.position.x, c.previous.z - c.position.z);
if xd * xd + zd * zd > 0.001 {
c.yaw = horizon_core::geometry::trig::atan2_degrees_double(zd, xd);
@@ -1395,13 +1381,13 @@ impl Game<'_> {
c.flipped = !c.flipped;
}
c.yaw %= 360.0;
c.pitch %= 360.0;
}
if !self.minecart_improvements() {
self.cart_block_effects(c);
// applyEffectsFromBlocks over the step moveAlongTrack recorded.
if let Some((from, to)) = step {
self.cart_inside_blocks(c, slot, from, to);
}
// OldMinecartBehavior.tick: applyEffectsFromBlocks() once settled.
let here = c.position;
self.cart_inside_blocks(c, slot, here, here);
}
// OldMinecartBehavior.tick ends with pushAndPickupEntities, before
// AbstractMinecart.tick's updateFluidInteraction. The experiment still
@@ -1631,27 +1617,30 @@ impl Game<'_> {
}
self.push_carts_from_mobs(&cells);
}
/// `AbstractMinecart.push(mob)` from each mob walking into a cart
/// (`LivingEntity.pushEntities` -> `doPush`): the cart gets the push and
/// the mob a quarter of it back (`Entity.push`, which needs the mob to be
/// pushable and to carry no one). Bats push nothing (`Bat.pushEntities`).
fn push_carts_from_mobs(&mut self, cells: &horizon_client::mobs::seen_grid::SeenGrid) {
let teams = self.mob_teams();
/// `AbstractMinecart.push(mob)` from each mob walking into a cart (`LivingEntity.pushEntities`
/// -> `doPush`): the cart gets the push and the mob a quarter of it back (`Entity.push`, which
/// needs the mob to be pushable and to carry no one). Bats push nothing (`Bat.pushEntities`),
/// nor does a mob whose box reaches no cart: its box is worked out only if it may.
fn push_carts_from_mobs(&mut self, cells: &SeenGrid) {
let (teams, mut near) = (self.mob_teams(), Vec::new());
// The mobs a cart is near, or every mob, each then asked which carts it is near.
let slots = self.mobs_near_carts();
let reach = slots.is_none().then(|| self.vehicle_mob_reach()).flatten();
let slots = slots.unwrap_or_else(|| (0..self.mobs.len() as u32).collect());
// (mob index, feet, box)
let pushers: Vec<(usize, Vec3, Aabb)> = self
.mobs
.iter()
.enumerate()
let pushers: Vec<(usize, Vec3, Aabb)> = slots
.into_iter()
.map(|j| (j as usize, &self.mobs[j as usize]))
.filter(|(_, m)| {
!m.removed
&& m.alive()
&& m.species() != Species::Bat
&& reach.is_none_or(|r| self.mob_may_meet_cart(r, m, cells, &mut near))
&& pushable_by(teams.of(Who::Mob(m.id)), None, true)
})
.map(|(j, m)| (j, m.position, m.bounds()))
.collect();
drop(teams);
let mut near = Vec::new();
for (j, feet, bb) in pushers {
if !cells.candidates(bb, &mut near) {
near.extend(0..self.carts.len() as u32);
@@ -2131,6 +2120,51 @@ impl Game<'_> {
p.velocity = Vec3::ZERO;
}
}
/// `EntityType.createDefaultStackConfig` on a vehicle an item made: the
/// item's custom name and custom data (`applyComponentsFromItemStack`:
/// `Entity.applyImplicitComponent`'s two, saved as `CustomName` and
/// `data`), then its
/// `minecraft:entity_data` loaded over the vehicle (`updateCustomEntityTag`)
/// when it names this type. An op-only type (command block and spawner
/// minecarts) takes it from an op player only, never from a dispenser
/// (`by_player` false).
pub(super) fn apply_vehicle_stack_config(
&mut self,
target: super::commands::Target,
kind: &str,
stack: &ItemStack,
by_player: bool,
) {
use horizon_world::nbt::Tag;
let name = stack.data().and_then(|d| d.custom_name.clone());
let custom = stack
.data()
.and_then(|d| d.other("minecraft:custom_data"))
.cloned();
let op = by_player && self.acting_player_is_op();
let data =
horizon_client::block_entity_item::spawn_egg_entity_data(stack, kind, op).cloned();
if name.is_none() && custom.is_none() && data.is_none() {
return;
}
let Some(mut own) = self.entity_nbt(target) else {
return;
};
if let Tag::Compound(map) = &mut own {
if let Some(name) = name {
map.insert("CustomName".into(), name);
}
if let Some(custom) = custom {
map.insert("data".into(), custom);
}
}
let tag = match &data {
Some(data) => horizon_client::data_cmd::merged(&own, data),
None => own,
};
// The vehicle's own compound, its name and data merged in: it loads back.
let _ = self.apply_entity_nbt(target, tag);
}
/// `MinecartItem.useOn`: a cart on the clicked rail.
pub(super) fn place_minecart(&mut self, hit: &Hit, stack: &ItemStack) -> bool {
let Some(kind) = CartKind::from_id(&stack.item) else {
@@ -2151,6 +2185,8 @@ impl Game<'_> {
.modified
.insert((hit.pos[0] >> 4, hit.pos[2] >> 4));
self.carts.push(cart);
let id = self.next_entity;
self.apply_vehicle_stack_config(super::commands::Target::Cart(id), kind.id(), stack, true);
if self.serving.is_some() || self.options.dedicated.is_some() {
let cart = self.carts.last().unwrap();
self.native_vehicle_create(
@@ -3500,6 +3536,29 @@ mod tnt_impact_tests {
false
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_powered_rail_pushes_a_resting_cart_away_from_soul_sand() {
let mut game = server_game();
let registry = game.pack.assets.registry;
let rail = registry.default_state("minecraft:powered_rail").unwrap();
let mut state = registry.state(rail).unwrap().clone();
state.properties.insert("shape".into(), "east_west".into());
state.properties.insert("powered".into(), "true".into());
let rail = registry.id(&state).unwrap();
game.world.set(5, 64, 8, rail).unwrap();
for (block, pushed) in [
("minecraft:soul_sand", true),
("minecraft:stone", true),
("minecraft:oak_slab", false),
] {
let id = registry.default_state(block).unwrap();
game.world.set(4, 64, 8, id).unwrap();
let push = game.redstone_direction([5, 64, 8], super::Shape::EastWest);
assert_eq!(push.x == 1.0, pushed, "{block}");
}
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_tnt_cart_stopped_head_on_by_a_wall_does_not_blow_up() {
@@ -3568,6 +3627,89 @@ mod tnt_impact_tests {
registry.state(game.world.block(6, 65, 8))
);
}
/// A cart blocked by a fence on a slope sits below the plate on the fence,
/// but `AbstractMinecart.move` sweeps its blocks while it is still at the
/// rail's top height, so the plate clocks on and off.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_cart_resting_against_a_fence_keeps_toggling_the_plate_above_it() {
let mut game = server_game();
let registry = game.pack.assets.registry;
let stone = registry.default_state("minecraft:stone").unwrap();
let fence = registry.default_state("minecraft:oak_fence").unwrap();
let plate = registry
.default_state("minecraft:oak_pressure_plate")
.unwrap();
let rail = registry.default_state("minecraft:rail").unwrap();
let mut slope = registry.state(rail).unwrap().clone();
slope
.properties
.insert("shape".into(), "ascending_east".into());
let slope = registry.id(&slope).unwrap();
for x in 0..16 {
for z in 0..16 {
game.world.set(x, 62, z, stone).unwrap();
}
}
game.world.set(0, 64, 8, fence).unwrap();
game.world.set(0, 65, 8, plate).unwrap();
game.world.set(1, 63, 8, stone).unwrap();
game.world.set(1, 64, 8, slope).unwrap();
game.world.set(2, 64, 8, stone).unwrap();
game.world.set(2, 65, 8, rail).unwrap();
game.carts.push(Minecart::new(
1,
CartKind::Rideable,
Vec3::new(1.9, 64.9, 8.5),
));
// Scheduled ticks and carts only run in lit, surrounded chunks near a
// player.
game.settings.simulation_distance = 4;
game.settings.render_distance = 12;
game.player = Some(
horizon_client::motion::PlayerMotion::new(game.motion, Vec3::new(8.5, 70.0, 12.5))
.unwrap(),
);
for cx in -2..=2 {
for cz in -2..=2 {
if (cx, cz) != (0, 0) {
let mut chunk = horizon_client::chunk::Chunk::empty(cx, cz);
chunk.lit = true;
game.world
.chunks
.insert((cx, cz), std::sync::Arc::new(chunk));
game.block_ticks
.add_empty_chunk(horizon_core::position::ChunkPos::new(cx, cz))
.unwrap();
}
}
}
let mut center = (**game.world.chunks.get(&(0, 0)).unwrap()).clone();
center.lit = true;
game.world
.chunks
.insert((0, 0), std::sync::Arc::new(center));
game.update_chunk_tickets();
let mut edges = 0;
let mut last = false;
for _ in 0..200 {
game.world.game_time += 1;
game.tick_carts_now();
game.tick_blocks().unwrap();
let on = registry
.state(game.world.block(0, 65, 8))
.unwrap()
.properties
.get("powered")
.is_some_and(|v| v == "true");
if on != last {
edges += 1;
last = on;
}
}
assert!(edges >= 4, "the plate changed {edges} times in 200 ticks");
}
}
#[cfg(test)]
@@ -0,0 +1,79 @@
//! A mob named in a command reply carries `Entity.getDisplayName`'s hover and
//! insertion (#14220): `HoverEvent.ShowEntity` of its type, UUID and name, and
//! its UUID string to insert.
use super::*;
use crate::game::commands::Target;
use horizon_client::chunk::Chunk;
use horizon_core::position::ChunkPos;
use std::sync::Arc;
fn game() -> Game<'static> {
let root = std::path::PathBuf::from(
std::env::var_os("HORIZON_TEST_RESOURCES")
.expect("set HORIZON_TEST_RESOURCES to prepared resources"),
);
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = execution_role::ExecutionRole::IntegratedServer;
let res: &'static _ = Box::leak(Box::new(loaded.resources));
let mut game = Game::new(
res,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.build_registries();
game.set_allow_commands(true);
game.world
.chunks
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
game.block_ticks
.add_empty_chunk(ChunkPos::new(0, 0))
.unwrap();
for command in ["forceload add 0 0", "fill 0 63 0 15 63 15 stone"] {
assert!(game.run_command(command), "{command}");
}
game
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_summoned_mobs_name_shows_the_entity_on_hover() {
let mut game = game();
assert!(game.run_command("summon minecraft:pig 4.5 64 4.5 {NoAI:1b}"));
let id = game.mobs.last().unwrap().id;
let uuid = horizon_client::save::uuid_text(game.persist.uuid_of(id).expect("a UUID"));
let name = game.target_name(Target::Mob(id)).component();
assert_eq!(name["translate"], "entity.minecraft.pig");
assert_eq!(name["insertion"], uuid.as_str());
let hover = &name["hover_event"];
assert_eq!(hover["action"], "show_entity");
assert_eq!(hover["id"], "minecraft:pig");
assert_eq!(hover["uuid"], uuid.as_str());
assert_eq!(hover["name"]["translate"], "entity.minecraft.pig");
// On a team the name is the team's formatting of it, the hover on the whole.
assert!(game.run_command("team add reds"));
assert!(game.run_command("team modify reds color red"));
assert!(game.run_command("team join reds @e[type=minecraft:pig]"));
let name = game.target_name(Target::Mob(id)).component();
assert_eq!(name["color"], "red");
assert_eq!(name["insertion"], uuid.as_str());
assert_eq!(name["hover_event"]["uuid"], uuid.as_str());
}
+236 -14
View File
@@ -450,6 +450,24 @@ impl Game<'_> {
used = true;
}
if m.is_baby() && age_up > 0 && !m.extra.age_locked {
// AbstractHorse.handleEating (Camel's too): the HAPPY_VILLAGER's
// `getRandomX(1.0)`, `getRandomY() + 0.5` and `getRandomZ(1.0)`
// each draw a double from the mount's own random before it ages
// up. A server's `Level.addParticle` shows nothing: the client
// draws its own sparkle (`remote_feed_particles`).
let (feet, width, height) = (
[m.position.x, m.position.y, m.position.z],
f64::from(m.width()),
f64::from(m.height()),
);
horizon_client::particles::around_self(
&mut m.random,
feet,
width,
height,
horizon_client::particles::Burst::HORSE_EATING,
|_, _| {},
);
m.extra.age = (m.extra.age + age_up * 20).min(0);
used = true;
}
@@ -460,22 +478,29 @@ impl Game<'_> {
if !used {
return false;
}
// eating(): the mouth opens and the horse eats.
// eating(): the mouth opens and, unless the mount is silent, it eats at
// its feet, its own random drawing `1.0F + (nextFloat() - nextFloat())
// * 0.2F` for the pitch.
m.anim.v[2] = 1.0;
let at = m.center();
let at = [m.position.x, m.position.y, m.position.z];
let id = m.id;
let sound = match m.species() {
Species::Camel => "minecraft:entity.camel.eat",
Species::CamelHusk => "minecraft:entity.camel_husk.eat",
Species::Donkey => "minecraft:entity.donkey.eat",
Species::Mule => "minecraft:entity.mule.eat",
Species::Horse if m.is_baby() => "minecraft:entity.baby_horse.eat",
// ZombieHorse.getEatingSound.
Species::ZombieHorse => "minecraft:entity.zombie_horse.eat",
_ => "minecraft:entity.horse.eat",
};
let pitch = (self.world.random.next_float() - self.world.random.next_float()) * 0.2 + 1.0;
self.audio.play(sound, Some(at), 1.0, pitch);
if !m.silent {
let sound = match m.species() {
Species::Camel => "minecraft:entity.camel.eat",
Species::CamelHusk => "minecraft:entity.camel_husk.eat",
Species::Donkey => "minecraft:entity.donkey.eat",
Species::Mule => "minecraft:entity.mule.eat",
Species::Horse if m.is_baby() => "minecraft:entity.baby_horse.eat",
// ZombieHorse.getEatingSound.
Species::ZombieHorse => "minecraft:entity.zombie_horse.eat",
_ => "minecraft:entity.horse.eat",
};
let pitch = (m.random.next_float() - m.random.next_float()) * 0.2 + 1.0;
self.audio.play(sound, Some(at), 1.0, pitch);
}
// gameEvent(GameEvent.EAT): at the mount, from the mount.
let ctx = self.who_context(Some(Who::Mob(id)), None);
self.post_game_event("minecraft:eat", at, ctx);
// Animal.setInLove: entity event 18.
if loved {
self.love_cause.insert(id, self.lan_acting);
@@ -1578,6 +1603,203 @@ mod tests {
assert_eq!(pose, Some(Pose::Swimming));
}
/// A server-side game with nothing in it but an empty chunk, or None when
/// the prepared resources are missing (CI has no vanilla data).
fn server_game() -> Option<Game<'static>> {
use crate::game::{Audio, Options, Progress, Settings, load_resources};
let root = std::env::var_os("HORIZON_TEST_RESOURCES")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION)
});
if !root.join("movement-properties.nbt").exists() {
return None;
}
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.ok()?;
let mut options = Options::parse(std::iter::empty()).ok()??;
options.resources = root.clone();
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
let res: &'static _ = Box::leak(Box::new(loaded.resources));
let mut game = Game::new(
res,
loaded.pack,
loaded.gui?,
options,
settings,
Audio::silent(&root),
None,
)
.ok()?;
game.creative = false;
game.options.dedicated = Some(Vec::new());
game.world.chunks.insert(
(0, 0),
std::sync::Arc::new(horizon_client::chunk::Chunk::empty(0, 0)),
);
game.streaming.forced.insert((0, 0));
Some(game)
}
/// What one feeding made the level show.
#[derive(Debug)]
struct Shown {
sounds: Vec<(String, [f64; 3], f32, f32)>,
events: Vec<(&'static str, [f64; 3], Option<Who>)>,
}
/// Feeds `item` to mob 0 and collects the sounds and the `minecraft:eat`
/// game events it made.
fn feed(game: &mut Game<'_>, item: &str) -> (bool, Shown) {
game.inventory[game.selected] = Some(ItemStack::new(item, 2));
game.audio.capture = Some(Vec::new());
crate::game::sculk::TEST_POSTED_EVENTS
.with(|events| *events.borrow_mut() = Some(Vec::new()));
let used = game.interact_mount(0, item);
let sounds = game
.audio
.capture
.take()
.unwrap()
.into_iter()
.map(|s| (s.event, s.position, s.volume, s.pitch))
.collect();
let events = crate::game::sculk::TEST_POSTED_EVENTS.with(|events| {
events
.borrow_mut()
.take()
.unwrap_or_default()
.into_iter()
.filter(|(event, _, _)| *event == "minecraft:eat")
.collect()
});
(used, Shown { sounds, events })
}
/// A whole, tamed mount of `kind` standing at `at` with `seed` for its own
/// random, alone in `game`.
fn mount(game: &mut Game<'_>, kind: &str, id: u32, at: [f64; 3], seed: i64) {
let kind = game.res.mob_types().get(kind).unwrap().clone();
let mut m = horizon_client::mobs::Mob::new(id, kind, Vec3::new(at[0], at[1], at[2]), 0.0);
m.extra.tame = true;
m.health = m.attrs.max_health;
m.random = horizon_core::random::LegacyRandom::new(seed);
game.mobs.clear();
game.mobs.push(m);
}
/// `AbstractHorse.handleEating`'s growth particle position draws:
/// `getRandomX(1.0)`, `getRandomY()` and `getRandomZ(1.0)` take a double each.
fn sparkle_draws(r: &mut horizon_core::random::LegacyRandom) {
for _ in 0..3 {
r.next_double();
}
}
/// `AbstractHorse.handleEating`: a mouthful that ages a baby up draws the
/// happy villager's position from the mount's own random (`getRandomX`,
/// `getRandomY` and `getRandomZ` take a double each) before it grows; a
/// mount that is not silent then eats with `1.0F + (nextFloat() -
/// nextFloat()) * 0.2F` of its own random at its feet, and every used
/// mouthful is a `minecraft:eat` game event. A silent mount eats
/// noiselessly, drawing no pitch, and a locked baby grows not at all.
#[test]
fn feeding_a_mount_draws_from_its_own_random_and_posts_eat() {
let Some(mut game) = server_game() else {
return;
};
// A silent baby horse fed wheat: the particle's draws, the growth, the
// event, and no eating sound (so no pitch draws).
mount(&mut game, "minecraft:horse", 1, [1.5, 64.0, -2.5], 7);
game.mobs[0].extra.age = -24000;
game.mobs[0].silent = true;
let level_random = game.world.random.clone().next_long();
let (used, shown) = feed(&mut game, "minecraft:wheat");
assert!(used, "wheat is horse food");
let mut r = horizon_core::random::LegacyRandom::new(7);
sparkle_draws(&mut r);
let at = [1.5, 64.0, -2.5];
assert!(shown.sounds.is_empty(), "Silent: {shown:?}");
assert_eq!(shown.events, vec![("minecraft:eat", at, Some(Who::Mob(1)))]);
assert_eq!(game.mobs[0].extra.age, -23600, "wheat ages a horse by 20 s");
assert_eq!(
game.mobs[0].random.clone().next_double(),
r.next_double(),
"three doubles for the particle, none for a silent pitch"
);
assert_eq!(
game.world.random.clone().next_long(),
level_random,
"the level's own random drew nothing"
);
// A hurt adult draws no particle and eats at its feet, its pitch from
// its own random, after healing.
mount(&mut game, "minecraft:horse", 2, [0.5, 64.0, 0.5], 11);
game.mobs[0].health = game.mobs[0].attrs.max_health - 5.0;
let max = game.mobs[0].attrs.max_health;
let (used, shown) = feed(&mut game, "minecraft:wheat");
assert!(used);
let mut r = horizon_core::random::LegacyRandom::new(11);
let pitch = (r.next_float() - r.next_float()) * 0.2 + 1.0;
let at = [0.5, 64.0, 0.5];
assert_eq!(
shown.sounds,
vec![("minecraft:entity.horse.eat".to_string(), at, 1.0, pitch)],
"{shown:?}"
);
assert_eq!(game.mobs[0].health, max - 3.0, "wheat heals 2");
assert_eq!(shown.events, vec![("minecraft:eat", at, Some(Who::Mob(2)))]);
// An age-locked baby that is tamed and whole eats nothing at all.
mount(&mut game, "minecraft:horse", 3, [0.5, 64.0, 2.5], 13);
game.mobs[0].extra.age = -24000;
game.mobs[0].extra.age_locked = true;
let (used, shown) = feed(&mut game, "minecraft:wheat");
assert!(!used, "a locked baby leaves the wheat alone: {shown:?}");
assert!(
shown.sounds.is_empty() && shown.events.is_empty(),
"{shown:?}"
);
assert_eq!(game.mobs[0].extra.age, -24000);
assert_eq!(
game.mobs[0].random.clone().next_double(),
horizon_core::random::LegacyRandom::new(13).next_double(),
"nothing drawn"
);
// A baby camel fed cactus: `Camel.handleEating` draws the same particle,
// then eats and posts EAT, the three doubles before the two floats.
mount(&mut game, "minecraft:camel", 4, [0.5, 64.0, 4.5], 21);
game.mobs[0].extra.age = -24000;
let (used, shown) = feed(&mut game, "minecraft:cactus");
assert!(used, "cactus is camel food");
let mut r = horizon_core::random::LegacyRandom::new(21);
sparkle_draws(&mut r);
let pitch = (r.next_float() - r.next_float()) * 0.2 + 1.0;
let at = [0.5, 64.0, 4.5];
assert_eq!(
shown.sounds,
vec![("minecraft:entity.camel.eat".to_string(), at, 1.0, pitch)],
"{shown:?}"
);
assert_eq!(shown.events, vec![("minecraft:eat", at, Some(Who::Mob(4)))]);
assert_eq!(game.mobs[0].extra.age, -23800, "camel cactus ages 10 s");
}
#[test]
fn the_escape_vector_reaches_the_edge_of_the_square() {
let edge = (0.9 + 0.6 + f64::from(1.0e-5_f32)) / 2.0;
@@ -5,7 +5,7 @@
//! a NeoForge mod calls its own `SimpleCriterionTrigger.trigger`.
use super::{Game, advancements::item_view, commands::Target};
use horizon_client::{
advancements::{self as adv, EntityView},
advancements::{self as adv, EntityView, Tree},
items::ItemStack,
};
use serde_json::Value;
@@ -129,6 +129,25 @@ fn passes(
&& adv::conditions(&conditions["player"], &player, world)
}
/// Whether a per-tick trigger needs the player's view. Until the advancements
/// are loaded the tree is not read yet, so the view is built as before. After
/// that, only the lookup `trigger` makes (`by_trigger`) can use it.
fn view_needed(loaded: bool, tree: &Tree, trigger: &str) -> bool {
!loaded || tree.by_trigger.contains_key(trigger)
}
impl Game<'_> {
/// [`Self::player_entity_for`] when [`view_needed`] says so, and an empty
/// view otherwise: `trigger` then finds no listener and never reads it.
pub(super) fn player_entity_if_listened(&mut self, trigger: &str) -> EntityView {
if view_needed(self.adv.loaded, &self.adv.tree, trigger) {
self.player_entity_for(trigger)
} else {
EntityView::default()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -225,4 +244,16 @@ mod tests {
assert!(!mod_trigger("inventory_changed"));
assert!(!mod_trigger(":pipe_connected"));
}
#[test]
fn view_is_needed_only_for_a_listened_trigger_once_loaded() {
let mut tree = Tree::default();
// Before loading the tree is empty but not read yet: built as before.
assert!(view_needed(false, &tree, "minecraft:tick"));
assert!(!view_needed(true, &tree, "minecraft:tick"));
tree.by_trigger
.insert("minecraft:tick".into(), vec![(0, 0)]);
assert!(view_needed(true, &tree, "minecraft:tick"));
assert!(!view_needed(true, &tree, "minecraft:location"));
}
}
@@ -661,7 +661,7 @@ impl Game<'_> {
let mut waiting: Vec<u32> = Vec::new();
let tracked = &self.portals.entities;
for_each_entity!(entity_lists_of!(self), entity => {
let can_sweep = entity.portal_size().is_some();
let can_sweep = entity.sweeps_portals();
let has_portal_state = tracked.contains_key(&entity.id());
// Entity.processPortalCooldown still runs for entities this
// portal pass cannot send, such as an ender pearl.
@@ -880,15 +880,21 @@ impl Game<'_> {
}
let mut inside: Vec<(u32, BlockPos)> = Vec::new();
let mut portal_sections = std::mem::take(&mut self.portals.sections);
// No loaded section has a portal state in its palette (nearly always): no
// sweep below can meet a portal block, so the walk would find nothing.
if !self.level_may_hold_portal(&mut portal_sections) {
self.portals.sections = portal_sections;
return;
}
// Only the entities that tick (in entity-ticking chunks) move, so only
// they can have swept into a portal.
let entity_ticking = self.entity_ticking_area();
// The first portal block each entity's box swept through since the
// start of its tick (a stuck arrow that did not move enters none).
for_each_entity!(entity_lists_of!(self), entity => {
let Some((width, height)) = entity.portal_size() else {
if !entity.sweeps_portals() {
continue;
};
}
let (id, to) = (entity.id(), entity.position());
if !entity_ticking(to.x.floor() as i32, to.z.floor() as i32) {
continue;
@@ -906,6 +912,9 @@ impl Game<'_> {
let from = entity
.portal_from()
.unwrap_or_else(|| self.portals.before.get(&id).copied().unwrap_or(to));
let Some((width, height)) = entity.portal_size() else {
continue;
};
if let Some(pos) = self.swept_portal(from, to, width, height, &mut portal_sections) {
inside.push((id, pos));
}
@@ -994,20 +1003,33 @@ impl Game<'_> {
return true;
}
let y_mask = SectionMask::below(y1 as usize + 1).from(y0 as usize);
let behaviours = self.res.behaviours();
let chunks = &self.world.chunks;
(x0..=x1).any(|cx| {
(z0..=z1).any(|cz| {
chunks.get(&(cx, cz)).is_some_and(|chunk| {
let portals = sections.get((cx, cz), chunk, |v| {
let state = horizon_world::registry::BlockStateId(v);
behaviours.portal_axis(state).is_some() || self.is_end_portal(state)
});
let portals = sections.get((cx, cz), chunk, |v| self.is_portal_state(v));
!(portals & y_mask).is_empty()
})
})
})
}
/// [`Self::may_hold_portal`] for the whole level: whether any loaded section has
/// a nether or End portal state in its palette.
fn level_may_hold_portal(
&self,
sections: &mut horizon_client::portal_sections::PortalSections,
) -> bool {
self.world.chunks.iter().any(|(&key, chunk)| {
!sections
.get(key, chunk, |v| self.is_portal_state(v))
.is_empty()
})
}
/// A nether or End portal state, by its id in a palette.
fn is_portal_state(&self, v: u32) -> bool {
let state = horizon_world::registry::BlockStateId(v);
self.res.behaviours().portal_axis(state).is_some() || self.is_end_portal(state)
}
/// `Entity.teleportCrossDimension` away from the loaded dimension: the
/// entity is saved and removed, and waits for its dimension to load.
fn send_through_portal(&mut self, id: u32, entry: BlockPos) {
+44 -1
View File
@@ -126,6 +126,8 @@ impl Game<'_> {
self.options_from_title = from_title;
let minecart_improvements = self.minecart_improvements();
let mods_live = self.mods_live_now();
// From the title after leaving a server, the row is back before the next world ticks.
self.hold_fullbright();
let ui = &mut self.ui.options;
ui.open_at(!from_title && self.title.is_none(), page);
ui.mods_live = mods_live;
@@ -560,10 +562,10 @@ impl Game<'_> {
{
self.settings.horizon.save(dir);
}
self.hold_fullbright();
let s = &self.settings;
if let Some(r) = self.renderer.as_mut() {
r.gamma = s.gamma;
r.fullbright = s.extras.fullbright;
r.improved_transparency = s.improved_transparency;
r.set_present_mode(s.vsync, s.horizon.present_mode);
r.animated_textures = s.horizon.animated_textures;
@@ -740,6 +742,30 @@ impl Game<'_> {
}
}
impl Game<'_> {
/// Fullbright as this window may draw it (`fullbright_held`): the renderer's flag and the
/// graphics screen's row. Each tick (`tick_frame_visuals`) and when settings apply, so
/// joining or leaving a server needs no call of its own.
pub(super) fn hold_fullbright(&mut self) {
let held = fullbright_held(
self.remote.as_ref().map(|r| r.save_folder.is_some()),
self.studio_replay.is_some(),
);
self.settings.fullbright_held = held;
if let Some(r) = self.renderer.as_mut() {
r.fullbright = self.settings.extras.fullbright && !held;
}
}
}
/// Fullbright is held off on another's server, where it would light what the server shows
/// dark (#3823): a connection with no save of this window's own (`Remote::save_folder`), as a
/// multiplayer server or a LAN guest has. Single player, a LAN host, a splitscreen seat in
/// P1's world and Replay Studio keep it. `connection`: none, or whether it plays a save.
fn fullbright_held(connection: Option<bool>, studio: bool) -> bool {
connection == Some(false) && !studio
}
/// The named device can be a connected pad that has not driven the game yet.
fn detected_controller_style(
device: Option<&horizon_client::controller_ui::Device>,
@@ -775,6 +801,23 @@ fn windowed_again(w: &winit::window::Window) {
}
}
#[cfg(test)]
mod fullbright_tests {
use super::fullbright_held;
#[test]
fn fullbright_is_held_only_on_another_servers_world() {
// The title screen, with no connection.
assert!(!fullbright_held(None, false));
// Single player and a LAN host play their own save; so does a splitscreen seat in P1's.
assert!(!fullbright_held(Some(true), false));
// A multiplayer server, or another player's world opened to LAN.
assert!(fullbright_held(Some(false), false));
// Replay Studio plays a recording, not a server.
assert!(!fullbright_held(Some(false), true));
}
}
#[cfg(test)]
mod controller_context_tests {
use super::detected_controller_style;
@@ -20,6 +20,7 @@ use std::{
type ChunkKey = (i32, i32);
type SectionKey = (i32, i32, i32);
const MAX_SNAPSHOT_CHUNKS: i64 = 4_096;
const MAX_PARTICLE_ANSWERS: usize = 4_096;
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
struct CullKey {
@@ -175,25 +176,48 @@ impl ParticleAsyncCuller {
}
fn begin_frame(&mut self, camera: glam::DVec3, world: &World) {
while let Ok(mut results) = self.results_rx.try_recv() {
self.pending = false;
let scratch = std::mem::take(&mut results.scratch_queries);
if scratch.capacity() > self.queries.get_mut().capacity() {
*self.queries.get_mut() = scratch;
}
self.results = Some(results);
}
self.camera = camera.to_array();
self.stamp = SnapshotKey {
world_revision: world.revision,
chunks_revision: world.chunks.membership_revision(),
bottom_y: world.bottom_y(),
};
if self
.results
.as_ref()
.is_some_and(|results| results.stamp != self.stamp)
{
let camera_bits = self.camera.map(f64::to_bits);
while let Ok(mut results) = self.results_rx.try_recv() {
self.pending = false;
let scratch = std::mem::take(&mut results.scratch_queries);
if scratch.capacity() > self.queries.get_mut().capacity() {
*self.queries.get_mut() = scratch;
}
// A job only answers the clusters that missed, so merge instead of replacing.
if results.stamp != self.stamp {
continue;
}
results.answers.retain(|(key, _)| key.camera == camera_bits);
if let Some(old) = self
.results
.as_mut()
.filter(|old| old.stamp == results.stamp)
{
old.answers.retain(|(key, _)| key.camera == camera_bits);
// Newest first, so dedup keeps the newest answer of a key.
let mut merged = std::mem::take(&mut results.answers);
merged.append(&mut old.answers);
merged.sort_by_key(|(key, _)| *key);
merged.dedup_by_key(|(key, _)| *key);
merged.truncate(MAX_PARTICLE_ANSWERS);
old.answers = merged;
} else {
self.results = Some(results);
}
}
if self.results.as_ref().is_some_and(|results| {
results.stamp != self.stamp
|| results
.answers
.iter()
.any(|(key, _)| key.camera != camera_bits)
}) {
self.results = None;
}
self.queries.get_mut().clear();
@@ -781,4 +805,43 @@ mod tests {
assert!(results.answers[0].1.is_none());
drop(live);
}
#[test]
fn answers_of_an_earlier_job_survive_a_later_job_for_other_clusters() {
use std::time::{Duration, Instant};
let world = World::new(horizon_world::registry::BlockStateId(0), 0);
let mut culler = ParticleAsyncCuller::new().unwrap();
culler.begin_frame(glam::DVec3::ZERO, &world);
let (snapshot, _live) = wall(None);
let key = |x: f64| CullKey {
camera: [0.0; 3].map(f64::to_bits),
min: [x, -0.55, -0.55].map(f64::to_bits),
max: [x + 2.1, 0.55, 0.55].map(f64::to_bits),
};
let (a, b) = (key(7.95), key(11.95));
for k in [a, b] {
culler
.jobs
.send(CullJob {
stamp: culler.stamp,
snapshot: Arc::clone(&snapshot),
queries: vec![k],
})
.unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
loop {
culler.begin_frame(glam::DVec3::ZERO, &world);
if culler.cached(&k).is_some() {
break;
}
assert!(Instant::now() < deadline, "worker did not answer");
std::thread::sleep(Duration::from_millis(5));
}
}
assert!(
culler.cached(&a).is_some(),
"answer of the first job was dropped"
);
assert!(culler.cached(&b).is_some());
}
}
@@ -62,6 +62,11 @@ impl Game<'_> {
self.capture(true);
}
Screen::Paused | Screen::Inventory => {
// RecipeBookComponent.keyPressed: a narrow window's open
// book takes Escape to close itself, not the screen.
if self.screen == Screen::Inventory && self.recipe_book_escape() {
return Ok(());
}
self.close_container();
self.pause_focus = None;
self.screen = Screen::Playing;
@@ -49,7 +49,14 @@ impl Game<'_> {
&& !self.is_published()
&& !self.has_split_players()
&& self.title.is_none()
&& (self.screen.is_pause_screen() || self.book.is_some() || self.win_screen_pauses())
&& top_screen_pauses(
// A dialog (and its warning or waiting screen) is the
// screen Minecraft set, over whatever was open.
self.dialog_open().then(|| self.dialogs.pauses_world()),
self.book.as_ref().map(|book| book.pauses_world()),
self.screen.is_pause_screen(),
self.win_screen_pauses(),
)
}
/// A screen open over gameplay while `screen` stays `Playing`: a book or
/// lectern, a sign, a server dialog, the code of conduct, a link or
@@ -362,9 +369,17 @@ impl Game<'_> {
}
}
/// Which of the things over gameplay decides `Minecraft.pause`: a dialog is
/// its own `DialogScreen` (Minecraft set it over whatever was open) and
/// comes first, then a book, else `Gui.screen`; the win screen's poem rides
/// with its screen. `None` means that kind of screen is not open.
fn top_screen_pauses(dialog: Option<bool>, book: Option<bool>, screen: bool, win: bool) -> bool {
dialog.or(book).unwrap_or(screen || win)
}
#[cfg(test)]
mod tests {
use super::studio_suppresses_focus_pause;
use super::{studio_suppresses_focus_pause, top_screen_pauses};
#[test]
fn studio_replay_and_freecam_never_pause_on_focus_loss() {
@@ -372,4 +387,27 @@ mod tests {
assert!(studio_suppresses_focus_pause(false, true));
assert!(!studio_suppresses_focus_pause(false, false));
}
/// `Minecraft.pause` asks the screen Minecraft set, and each of a dialog,
/// a book and the win screen is a screen of its own that replaces what
/// was open: the dialog's `pause`, the book's `BookViewScreen`/
/// `LecternScreen`, and `WinScreen`'s `!poem` decide, not the screen
/// underneath.
#[test]
fn the_screen_on_top_decides_whether_the_world_pauses() {
// Nothing replaces the screen: its own isPauseScreen answer, with
// the win screen's poem rule when it is the win screen.
assert!(top_screen_pauses(None, None, true, false));
assert!(top_screen_pauses(None, None, false, true));
assert!(!top_screen_pauses(None, None, false, false));
// A dialog over a pause screen: its own pause answers.
assert!(!top_screen_pauses(Some(false), None, true, false));
assert!(top_screen_pauses(Some(true), None, false, false));
// A lectern's book over a pause screen does not pause; a book in
// hand does.
assert!(!top_screen_pauses(None, Some(false), true, false));
assert!(top_screen_pauses(None, Some(true), false, false));
// A dialog opens over the book.
assert!(!top_screen_pauses(Some(false), Some(true), false, false));
}
}
+6 -1
View File
@@ -1211,7 +1211,12 @@ impl Game<'_> {
twinkle: false,
}],
));
let rocket = self.new_rocket(Vec3::new(at[0], at[1], at[2]), &stack, false);
let rocket = self.new_rocket(
Vec3::new(at[0], at[1], at[2]),
&stack,
false,
&mut random_source::entity(),
);
self.rockets.push(rocket);
}
/// `BehaviorUtils.throwItem`: a hero's gift tossed toward the player.
@@ -31,6 +31,13 @@ const SWAP_TICKS: f32 = 30.0;
/// Widest GUI at which the book sits next to the menu (`widthTooNarrow`).
const NARROW: f32 = 379.0;
/// `RecipeBookComponent.isOffsetNextToMainGUI`: false in a window narrower than
/// `NARROW`, where the open book covers the menu. Escape closes only the book
/// there (`keyPressed`), not the container.
pub(super) fn escape_closes_book(width: f32, open: bool) -> bool {
open && width < NARROW
}
/// A ghost recipe slot: the items it cycles through.
struct Ghost {
slot: usize,
@@ -1115,6 +1122,22 @@ impl Game<'_> {
self.send_book_settings(book_type);
self.recipe_ui.overlay = None;
}
/// `RecipeBookComponent.keyPressed`: Escape closes the book, not the
/// screen, while it covers a narrow menu; a wide window's Escape reaches
/// the container.
pub(super) fn recipe_book_escape(&mut self) -> bool {
let Some(book_type) = self.active_book() else {
return false;
};
let Some(batch) = self.batch_for_size() else {
return false;
};
if !escape_closes_book(batch.width, self.book_open(book_type)) {
return false;
}
self.close_narrow_book(book_type, &batch);
true
}
/// `RecipeBookComponent.tryPlaceRecipe`: false when the click is refused.
fn try_place(&mut self, entry_index: usize, use_max: bool) -> bool {
let Some(book_type) = self.active_book() else {
@@ -1899,3 +1922,16 @@ fn sub_rect(r: Rect, [x, y, w, h]: [f32; 4]) -> Rect {
h: h as u32,
}
}
#[cfg(test)]
mod tests {
/// `RecipeBookComponent.keyPressed`: Escape closes a narrow window's open
/// book (`!isOffsetNextToMainGUI`), not the container; a wide window's
/// book leaves Escape to the container.
#[test]
fn escape_closes_only_a_narrow_books_menu() {
assert!(super::escape_closes_book(super::NARROW - 1.0, true));
assert!(!super::escape_closes_book(super::NARROW, true));
assert!(!super::escape_closes_book(super::NARROW - 1.0, false));
}
}
+4 -1
View File
@@ -11199,7 +11199,7 @@ pub(super) fn item_has_use(info: &horizon_client::items::ItemInfo) -> bool {
let id = info.id.trim_start_matches("minecraft:");
id.ends_with("_spear")
|| info.consumable.is_some()
|| info.equip_slot.is_some()
|| (info.equip_slot.is_some() && info.swappable)
|| info.blocks_attacks.is_some()
|| horizon_client::items::bucket_contents(&info.id).is_some()
|| id.ends_with("_boat")
@@ -13479,6 +13479,9 @@ mod tests {
let rod = items.get(rod).unwrap();
assert!(!super::use_ends_click(rod, gliding), "{}", rod.id);
}
// A carved pumpkin has an equip_slot (head) but is not swappable, so it has no Item.use.
let pumpkin = items.get("minecraft:carved_pumpkin").unwrap();
assert!(!super::use_ends_click(pumpkin, gliding));
}
}
@@ -12,6 +12,7 @@ pub const HELP: &[&str] = &[
"particle NAME X Y Z [DX DY DZ] [COUNT] spawn particles (vanilla /particle spread)",
"signedit X Y Z [front|back] open the sign editor for the sign at X Y Z",
"section-draws [flat|indirect] how terrain sections are drawn (A/B checks); replies the mode",
"section-layout compact|wide solid quads' layout in the arena, all meshed again (A/B checks); replies compact",
"section-cull [on|off] occlusion culling of solid sections (A/B checks); replies the mode",
"section-sides [on|off] leave out solid quads facing away from the eye (A/B checks); replies the mode",
"gpu-culling [on|off|auto] GPU Culling as its setting sets it, not saved (A/B checks); replies the mode",
@@ -426,6 +426,7 @@ impl Game<'_> {
/// flicker whenever the game is not paused, frozen or not, while `TextureManager.tick`
/// needs the level running normally. `HORIZON_STILL` keeps both with the world.
pub(super) fn tick_frame_visuals(&mut self, running: bool) {
self.hold_fullbright();
if running || !self.clock.still() {
self.fog.tick_flicker();
}
+16 -11
View File
@@ -583,20 +583,25 @@ impl Game<'_> {
if include_world {
let root = world_root.as_ref().expect("a local world's save folder");
if let Some(server) = self.integrated.as_ref() {
server.set_paused(true);
let saved = server.call(|game| game.save_game().map_err(|e| e.to_string()));
let copied = copy_tree(root, &dir.join("world"));
server.set_paused(self.pause);
match saved {
Some(Ok(())) => {}
Some(Err(e)) => {
// The server thread saves, then waits there for the copy: pausing it instead
// runs its pause save (which renames files into place) during the copy.
let (saved_tx, saved) = mpsc::channel();
let (copied, copied_rx) = mpsc::channel::<()>();
// A server that has stopped drops the task, and `saved_tx` with it.
server.post(move |game| {
let _ = saved_tx.send(game.save_game().map_err(|e| e.to_string()));
let _ = copied_rx.recv();
});
match saved.recv() {
Ok(Ok(())) => {}
Ok(Err(e)) => {
return Err(format!("saving the world for the recording: {e}").into());
}
None => {
log::warn!("Horizon: the integrated server did not save for the recording")
}
Err(_) => return Err("the integrated server has stopped".into()),
}
copied.map_err(|e| format!("copying the world: {e}"))?;
let result = copy_tree(root, &dir.join("world"));
drop(copied);
result.map_err(|e| format!("copying the world: {e}"))?;
} else {
self.save_game_with(true)?;
copy_tree(root, &dir.join("world"))
+36 -2
View File
@@ -241,7 +241,7 @@ impl Game<'_> {
}
fn holder_for(&mut self, t: Target) -> Option<Holder> {
let name = self.holder_name(t)?;
let shown = self.target_name(t);
let shown = feedback_name(self.target_name(t), &name);
Some(Holder {
display: Some(literal(&shown.plain())),
text: shown,
@@ -957,6 +957,20 @@ pub(super) fn local_tab_players(me: TabPlayer, guests: &[Guest]) -> Vec<TabPlaye
players
}
/// `ScoreHolder.getFeedbackDisplayName`: the holder's display name with its
/// hover replaced by `HoverEvent.ShowText` of its scoreboard name (a player's
/// name, an entity's UUID string); the insertion stays.
fn feedback_name(display: Text, scoreboard_name: &str) -> Text {
let mut component = display.component();
if let serde_json::Value::Object(o) = &mut component {
o.insert(
"hover_event".into(),
serde_json::json!({"action": "show_text", "value": scoreboard_name}),
);
}
display.with_component(component)
}
/// The commands' view of the game.
struct GameEnv<'a, 'b> {
g: &'a mut Game<'b>,
@@ -997,7 +1011,7 @@ impl Env for GameEnv<'_, '_> {
Target::Mob(id) | Target::Item(id) => uuid_text(self.g.uuid_for(id)),
_ => return None,
};
let text = self.g.target_name(entity);
let text = feedback_name(self.g.target_name(entity), &name);
Some((
Holder {
display: Some(literal(&text.plain())),
@@ -1107,6 +1121,26 @@ mod tab_list_tests {
#[cfg(test)]
mod team_tests {
use super::*;
/// #14279: `ScoreHolder.getFeedbackDisplayName` keeps the display name and
/// its insertion, and shows the scoreboard name (an entity's UUID) on hover.
#[test]
fn a_feedback_name_shows_the_scoreboard_name_on_hover() {
let uuid = "00000001-0000-0002-0000-000300000004";
let display = Text::translate("entity.minecraft.pig", &[]).with_component_fields(vec![
(
"hover_event".into(),
serde_json::json!({"action": "show_entity", "id": "minecraft:pig", "uuid": uuid}),
),
("insertion".into(), uuid.into()),
]);
let c = feedback_name(display, uuid).component();
assert_eq!(c["translate"], "entity.minecraft.pig");
assert_eq!(c["insertion"], uuid);
assert_eq!(
c["hover_event"],
serde_json::json!({"action": "show_text", "value": uuid})
);
}
const WOLF: [i32; 4] = [1, 2, 3, 4];
const CAT: [i32; 4] = [5, 6, 7, 8];
+16 -2
View File
@@ -786,6 +786,19 @@ impl Game<'_> {
}
if self.mobs[index].species() == Species::Allay && self.mobs[index].alive() {
self.allay_listen(index, event, pos, ctx);
// Allay.JukeboxListener, its own listener beside the vibrations'.
let playing = match event {
"minecraft:jukebox_play" => Some(true),
"minecraft:jukebox_stop_play" => Some(false),
_ => None,
};
let m = &mut self.mobs[index];
if let Some(playing) = playing
&& allay::hears_jukebox(m, pos)
{
let at = pos.map(|v| v.floor() as i32);
allay::set_jukebox_playing(m, at, playing);
}
}
}
/// A game event at a block (`Level.gameEvent(source, event, pos)`).
@@ -2230,8 +2243,9 @@ impl Game<'_> {
== horizon_client::blocks::Fluid::Water
}
/// `Entity.checkSupportingBlock`: the block under this box closest to the
/// entity. Everything but the player works it out when it asks.
fn supporting_block(&self, position: Vec3, bounds: Aabb) -> Option<BlockPos> {
/// entity. Everything but the player works it out when it asks (`Breeze`'s
/// ground dust too).
pub(super) fn supporting_block(&self, position: Vec3, bounds: Aabb) -> Option<BlockPos> {
let (min, max) = (bounds.min_position(), bounds.max_position());
let foot = Aabb::new(
Vec3::new(min.x, min.y - 1.0e-6, min.z),
@@ -109,3 +109,19 @@ fn a_torch_that_stays_connects_the_wire_in_a_line_and_powers_it() {
"minecraft:redstone_wire[east=side,north=none,power=15,south=none,west=side]"
);
}
/// `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.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_test_block_reads_the_state_mode_for_its_own_signal() {
let mut game = game();
run(&mut game, &["setblock 5 100 2 test_block[mode=start]"]);
assert!(game.run_command("data merge block 5 100 2 {mode:\"fail\",powered:1b}"));
let pos = BlockPos::new(5, 100, 2);
let state = game.world.block(5, 100, 2);
let signal =
game.with_level(|_, level, _| level.block_entity_signal(pos, state, Direction::North));
assert_eq!(signal, 15, "the state says start and the entity is powered");
}
@@ -7,9 +7,20 @@ use horizon_client::{
};
use horizon_core::position::BlockPos;
use horizon_protocol::packets::play;
use horizon_world::registry::{BlockRegistry, BlockStateId};
type Pos = (i32, i32, i32);
/// `TestBlock.ownSignal`: the block state's mode is the gate, and only then
/// does the block entity's powered flag count.
pub(super) fn own_signal(registry: &BlockRegistry, state: BlockStateId, powered: bool) -> i32 {
let start = registry
.state(state)
.and_then(|s| s.properties.get("mode"))
.is_some_and(|mode| mode == "start");
if start && powered { 15 } else { 0 }
}
impl Game<'_> {
fn test_block_mut(&mut self, pos: Pos) -> Option<&mut TestBlock> {
match self.world.block_entities.get_mut(&pos)? {
@@ -213,7 +213,11 @@ fn dragon_takes_player_thorns_through_its_body() {
Some(("minecraft:thorns", Some(77), Some(77)))
);
assert_eq!(d.hurt_by_player, Some((FightWho::Host, 0)));
assert_eq!(g.armor[1].as_ref().unwrap().damage, 2);
// Thorns' own 2 plus the hit's `max(1, damage / 4)`: 2 for the head's 10, 1 for the wing's 5.
assert_eq!(
g.armor[1].as_ref().unwrap().damage,
if wing { 3 } else { 4 }
);
assert_eq!(
g.audio
.capture
@@ -231,10 +235,10 @@ fn dragon_takes_player_thorns_through_its_body() {
let health = g.player.as_ref().unwrap().health.health();
g.dragon_hurts_player("minecraft:mob_attack", 1.0);
assert_eq!(g.player.as_ref().unwrap().health.health(), health);
assert_eq!(g.armor[1].as_ref().unwrap().damage, 4);
assert_eq!(g.armor[1].as_ref().unwrap().damage, 5);
let before = g.dragon.as_ref().unwrap().health;
g.dragon_hurts_player("minecraft:indirect_magic", 1.0);
assert_eq!(g.dragon.as_ref().unwrap().health, before);
assert_eq!(g.armor[1].as_ref().unwrap().damage, 4);
assert_eq!(g.armor[1].as_ref().unwrap().damage, 5);
});
}
+26 -6
View File
@@ -23,7 +23,8 @@ pub(super) const HELP: &[&str] = &[
];
/// After a click, `ui`, `click` and `set` wait for a frame drawn since, so
/// they never read the screen the click left.
/// they never read the screen the click left; before the first frame, for
/// that one, as a game answers its socket before it has drawn anything.
#[derive(Default)]
pub(super) struct UiWait {
/// The widget generation at the last click, and when it happened.
@@ -33,9 +34,10 @@ pub(super) struct UiWait {
impl UiWait {
fn stale(&self) -> bool {
self.after.is_some_and(|(generation, at)| {
generation == widgets::generation() && at.elapsed() < Duration::from_secs(1)
})
widgets::generation() == 0
|| self.after.is_some_and(|(generation, at)| {
generation == widgets::generation() && at.elapsed() < Duration::from_secs(1)
})
}
/// The commands that can run now.
pub(super) fn ready(&mut self) -> Vec<Request> {
@@ -121,7 +123,8 @@ impl Game<'_> {
})
}
/// `ui`, `click` and `set`; `Handled::No` for other commands.
/// `ui`, `click` and `set`, and `screen` and `click-button` held until the first frame;
/// `Handled::No` for other commands.
pub(super) fn ui_command(
&mut self,
words: &[&str],
@@ -129,7 +132,10 @@ impl Game<'_> {
reply: &Sender<String>,
event_loop: &dyn EventLoopCtl,
) -> Result<Handled> {
if !matches!(words[0], "ui" | "click" | "set") {
// `screen` and `click-button` read the last drawn frame too: they wait for the first.
let first_frame =
matches!(words[0], "screen" | "click-button") && widgets::generation() == 0;
if !matches!(words[0], "ui" | "click" | "set") && !first_frame {
return Ok(Handled::No);
}
if self.ui_wait.stale() {
@@ -218,3 +224,17 @@ impl Game<'_> {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A game answers its socket before it draws: `ui` would read no widgets yet.
#[test]
fn widget_commands_wait_for_the_first_frame() {
let wait = UiWait::default();
assert!(wait.stale());
widgets::end_frame(1);
assert!(!wait.stale());
}
}
@@ -56,7 +56,7 @@ fn datapack_damage_initial_snapshot_uses_the_prepared_worlds_features() {
fs::create_dir_all(pack.join("data/demo/damage_type")).unwrap();
fs::write(
pack.join("pack.mcmeta"),
r#"{"features":{"enabled":["minecraft:trade_rebalance"]}}"#,
r#"{"pack":{"description":""},"features":{"enabled":["minecraft:trade_rebalance"]}}"#,
)
.unwrap();
fs::write(
+36 -164
View File
@@ -21,10 +21,16 @@ use horizon_core::{
#[cfg(test)]
#[path = "fall_flying_tests.rs"]
mod fall_flying_tests;
#[path = "ai_glide.rs"]
mod glide;
use glide::update_fall_flying;
#[cfg(test)]
#[path = "wet_fire_tests.rs"]
mod wet_fire_tests;
mod step;
pub(crate) use step::step_sound;
use crate::powder_snow::particle_velocity as powder_snow_particle_velocity;
/// The inside-block effects of fire and lava (`InsideBlockEffectType`
@@ -707,9 +713,7 @@ pub fn tick(
if m.no_ai {
update_fall_flying(m, w, events);
reset_effect_fall_distance(m);
// Mob.aiStep still runs without AI (only serverAiStep is skipped),
// so undead still burn in daylight and the endermite's life keeps
// counting.
// Mob.aiStep still runs without AI: undead burn, endermites age.
burn_undead(m, w, events);
if m.species() == Species::Endermite {
endermite_lifetime(m);
@@ -758,6 +762,7 @@ pub fn tick(
if m.species() == Species::GlowSquid {
aquatic::glow_squid_dark_tick(m);
}
super::cube::no_ai_tick(m, w, events); // AbstractCubeMob.tick
// Shulker.tick after its living tick: a NoAI shell still keeps a
// face and eases its lid, pushing what the lid opens into.
if m.species() == Species::Shulker {
@@ -1677,61 +1682,6 @@ fn ageable_step(
}
}
/// `LivingEntity.updateFallFlying` on the server, which `aiStep` calls while
/// the glide flag is set, with or without AI: a mob that cannot glide
/// (`canGlide`: on the ground, riding, levitating, or no slot holding a
/// glider whose next damage will not break it, `canGlideUsing`) stops
/// gliding; else on every 20th glide tick one of its gliders, picked with
/// `Util.getRandom` from the mob's own random, takes a point of wear.
/// `checkFallDistanceAccumulation` and the `ELYTRA_GLIDE` game event of
/// every 10th tick are not ported here.
fn update_fall_flying(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
use horizon_world::player_inventory::EquipmentSlot;
if !m.extra.fall_flying {
return;
}
let gliders: Vec<EquipmentSlot> = match w.items {
Some(items) => EquipmentSlot::ALL
.into_iter()
.filter(|&slot| {
super::finalize::equipment_stack(m, slot).is_some_and(|stack| {
crate::item_sync::can_glide_using(&stack, glider_slot_name(slot), items)
})
})
.collect(),
None => Vec::new(),
};
if m.on_ground
|| m.extra.mount.is_some()
|| m.has_effect("minecraft:levitation")
|| gliders.is_empty()
{
m.extra.fall_flying = false;
return;
}
let ticks = m.extra.fall_fly_ticks + 1;
if ticks % 10 == 0 && (ticks / 10) % 2 == 0 {
let pick = m.random.next_int_bound(gliders.len() as i32).unwrap_or(0) as usize;
events.push(MobEvent::GliderWear {
mob: m.id,
slot: gliders[pick],
});
}
}
/// `EquipmentSlot`'s serialized name, which `equippable` names its slot by.
fn glider_slot_name(slot: horizon_world::player_inventory::EquipmentSlot) -> &'static str {
use horizon_world::player_inventory::EquipmentSlot as S;
match slot {
S::MainHand => "mainhand",
S::OffHand => "offhand",
S::Feet => "feet",
S::Legs => "legs",
S::Chest => "chest",
S::Head => "head",
S::Body => "body",
S::Saddle => "saddle",
}
}
/// `LivingEntity.tick` after `aiStep`: the glide's tick count.
fn count_fall_fly_ticks(m: &mut Mob) {
m.extra.fall_fly_ticks = if m.extra.fall_flying {
@@ -2225,7 +2175,7 @@ fn species_ai_step(
Species::Vex => super::vex::ai_step(m, w),
Species::Ravager => super::ravager::ai_step(m, w, events),
Species::Wither => super::wither::ai_step(m, w, level_random, events),
Species::Allay => super::allay::ai_step(m),
Species::Allay => super::allay::ai_step(m, w),
Species::Bee => super::bee::ai_step(m, w),
Species::PolarBear => super::polar_bear::ai_step(m, w),
Species::Breeze => super::breeze::ai_step(m),
@@ -2308,10 +2258,10 @@ fn check_despawn(m: &mut Mob, w: &MobWorld) -> bool {
let persistent = m.persistent || requires_custom_persistence(m);
// removeWhenFarAway: animals, villagers, golems, patrols and raiders,
// zombie villagers being cured and ridden mobs stay, bar the two felines.
let creature = m.kind.category == "creature";
let feline_goes = feline_removed_when_far(m.species(), m.extra.tame || m.extra.trusting, m.age);
let removable = !(never_removed_when_far(m.species())
|| creature && m.kind.flags.animal && !m.extra.jockey && !feline_goes
|| super::aquatic::named_keeps_it(m)
|| m.kind.category == "creature" && m.kind.flags.animal && !m.extra.jockey && !feline_goes
|| super::raider::keeps_alive(m, d)
|| super::zombie_villager::keeps_alive(m)
|| super::riders::keeps_alive(m));
@@ -3260,109 +3210,6 @@ pub fn movement_sounds(m: &Mob) -> bool {
| Species::Breeze
)
}
/// `playStepSound`: the species' footstep, or the block's step sound at 15%.
pub fn step_sound(
m: &Mob,
w: &MobWorld,
pos: [i32; 3],
state: horizon_world::registry::BlockStateId,
) -> Option<(&'static str, f32, f32)> {
use Species as S;
let baby = m.is_baby();
let quiet = |s: &'static str| Some((s, 0.15, 1.0));
match m.species() {
// `playStepSound` overridden with an empty body: a fish, an allay, a bee,
// a happy ghast and a nautilus have no footfall of their own, so the
// block's step sound is not taken either.
S::Allay
| S::Bee
| S::Cod
| S::Salmon
| S::TropicalFish
| S::Pufferfish
| S::Tadpole
| S::HappyGhast
| S::Nautilus
| S::ZombieNautilus => None,
S::Armadillo => quiet("entity.armadillo.step"),
S::Chicken | S::Cow | S::Pig | S::Wolf => {
super::finalize::variant_sound(m, "step").and_then(quiet)
}
S::Mooshroom => quiet("entity.cow.step"),
S::Llama | S::TraderLlama => quiet("entity.llama.step"),
S::Frog => quiet("entity.frog.step"),
S::Goat => quiet("entity.goat.step"),
S::Panda => quiet("entity.panda.step"),
S::Parrot => quiet("entity.parrot.step"),
S::PolarBear => quiet("entity.polar_bear.step"),
S::Sheep => quiet("entity.sheep.step"),
S::Sniffer => quiet("entity.sniffer.step"),
S::Turtle if baby => quiet("entity.turtle.shamble_baby"),
S::Turtle => quiet("entity.turtle.shamble"),
S::Creaking => quiet("entity.creaking.step"),
S::Hoglin => quiet("entity.hoglin.step"),
S::Zoglin => quiet("entity.zoglin.step"),
S::Piglin => quiet("entity.piglin.step"),
S::PiglinBrute => quiet("entity.piglin_brute.step"),
S::Ravager => quiet("entity.ravager.step"),
S::Skeleton => quiet("entity.skeleton.step"),
S::Stray => quiet("entity.stray.step"),
S::Bogged => quiet("entity.bogged.step"),
S::Parched => quiet("entity.parched.step"),
S::WitherSkeleton => quiet("entity.wither_skeleton.step"),
S::Spider | S::CaveSpider => quiet("entity.spider.step"),
// Silverfish.playStepSound and Endermite.playStepSound: both ask for
// their own step at 15%, though neither type's movement emission plays
// it (`movement_sounds`).
S::Silverfish => quiet("entity.silverfish.step"),
S::Endermite => quiet("entity.endermite.step"),
S::Zombie | S::ZombifiedPiglin => quiet("entity.zombie.step"),
S::Husk => quiet("entity.husk.step"),
S::Drowned => quiet("entity.drowned.step"),
S::ZombieVillager => quiet("entity.zombie_villager.step"),
// Camel.playStepSound and CamelHusk.playStepSound: sand and concrete
// powder have their own step.
S::CamelHusk | S::Camel => {
let sand = w.has_tag(
"minecraft:camel_sand_step_sound_blocks",
&w.facts.get(state).name,
);
Some(match (m.species(), sand) {
(S::Camel, true) => ("entity.camel.step_sand", 1.0, 1.0),
(S::Camel, false) => ("entity.camel.step", 1.0, 1.0),
(_, true) => ("entity.camel_husk.step_sand", 0.4, 1.0),
(_, false) => ("entity.camel_husk.step", 0.4, 1.0),
})
}
S::IronGolem => Some(("entity.iron_golem.step", 1.0, 1.0)),
// CopperGolem.playStepSound: its oxidation level's step.
S::CopperGolem => Some((super::copper_golem::sound(m, "step"), 1.0, 1.0)),
S::Strider if m.fluids.in_lava => Some(("entity.strider.step_lava", 1.0, 1.0)),
S::Strider => Some(("entity.strider.step", 1.0, 1.0)),
S::Warden => Some(("entity.warden.step", 10.0, 1.0)),
_ => {
// Entity.playStepSound: the block's sound type.
let _ = pos;
let sounds = &w.facts.get(state).sounds;
let step: &'static str = super::ai::intern_sound(&sounds.step);
Some((step, sounds.volume * 0.15, sounds.pitch))
}
}
}
/// A block sound id as a static string (the block facts intern them).
pub fn intern_sound(s: &std::sync::Arc<str>) -> &'static str {
use std::{collections::HashSet, sync::Mutex};
static NAMES: Mutex<Option<HashSet<&'static str>>> = Mutex::new(None);
let mut names = NAMES.lock().unwrap_or_else(|e| e.into_inner());
let set = names.get_or_insert_with(HashSet::new);
let key = s.strip_prefix("minecraft:").unwrap_or(s);
if let Some(found) = set.get(key) {
return found;
}
let leaked: &'static str = Box::leak(key.to_owned().into_boxed_str());
set.insert(leaked);
leaked
}
/// `getSwimSound`. A skeleton horse's own override counts the steps it takes
/// through the water, so it needs the mob, not just its species.
pub fn swim_sound(m: &mut Mob) -> &'static str {
@@ -3434,6 +3281,31 @@ mod despawn_tests {
assert!(!feline_removed_when_far(s, false, 12000), "{s:?}");
}
}
/// `AbstractFish.removeWhenFarAway` and `Axolotl.removeWhenFarAway`: a fish or
/// an axolotl with a custom name is kept however far it swims, while the name
/// saves no other mob — a monster still goes.
#[test]
fn a_named_fish_or_axolotl_never_despawns() {
crate::mobs::world::with_test_world(&[], |w, types| {
let far = MobWorld {
// 200 blocks from the player: past the 128 a monster loses.
nearest_player: Some(Vec3::new(200.0, 64.0, 0.0)),
..w.clone()
};
let checked = |id: &str, name: Option<&str>| {
let kind = std::sync::Arc::new(types.types[id].clone());
let mut m = Mob::new(1, kind, Vec3::new(0.0, 64.0, 0.0), 0.0);
m.custom_name = name.map(str::to_owned);
despawn_check(&mut m, &far)
};
for fish in ["minecraft:cod", "minecraft:salmon", "minecraft:axolotl"] {
assert!(checked(fish, Some("Bubbles")), "{fish} keeps its name");
assert!(!checked(fish, None), "{fish} without one goes");
}
// A name is not persistence for anything else.
assert!(!checked("minecraft:zombie", Some("Bubbles")));
});
}
}
#[cfg(test)]
+152
View File
@@ -0,0 +1,152 @@
//! `Mob.playStepSound` and its overrides: the species' own footstep, or the block
//! sound at 15%, and the block sound ids the facts carry.
use super::{Mob, MobWorld, Species};
/// `playStepSound`: the species' footstep, or the block's step sound at 15%.
pub fn step_sound(
m: &Mob,
w: &MobWorld,
pos: [i32; 3],
state: horizon_world::registry::BlockStateId,
) -> Option<(&'static str, f32, f32)> {
use Species as S;
let baby = m.is_baby();
let quiet = |s: &'static str| Some((s, 0.15, 1.0));
match m.species() {
// `playStepSound` overridden with an empty body: a fish, an allay, a bee,
// a happy ghast and a nautilus have no footfall of their own, so the
// block's step sound is not taken either.
S::Allay
| S::Bee
| S::Cod
| S::Salmon
| S::TropicalFish
| S::Pufferfish
| S::Tadpole
| S::HappyGhast
| S::Nautilus
| S::ZombieNautilus => None,
S::Armadillo => quiet("entity.armadillo.step"),
S::Chicken | S::Cow | S::Pig | S::Wolf => {
crate::mobs::finalize::variant_sound(m, "step").and_then(quiet)
}
S::Mooshroom => quiet("entity.cow.step"),
S::Llama | S::TraderLlama => quiet("entity.llama.step"),
S::Frog => quiet("entity.frog.step"),
S::Goat => quiet("entity.goat.step"),
S::Panda => quiet("entity.panda.step"),
S::Parrot => quiet("entity.parrot.step"),
S::PolarBear => quiet("entity.polar_bear.step"),
S::Sheep => quiet("entity.sheep.step"),
S::Sniffer => quiet("entity.sniffer.step"),
S::Turtle if baby => quiet("entity.turtle.shamble_baby"),
S::Turtle => quiet("entity.turtle.shamble"),
S::Creaking => quiet("entity.creaking.step"),
S::Hoglin => quiet("entity.hoglin.step"),
S::Zoglin => quiet("entity.zoglin.step"),
S::Piglin => quiet("entity.piglin.step"),
S::PiglinBrute => quiet("entity.piglin_brute.step"),
S::Ravager => quiet("entity.ravager.step"),
S::Skeleton => quiet("entity.skeleton.step"),
S::Stray => quiet("entity.stray.step"),
S::Bogged => quiet("entity.bogged.step"),
S::Parched => quiet("entity.parched.step"),
S::WitherSkeleton => quiet("entity.wither_skeleton.step"),
S::Spider | S::CaveSpider => quiet("entity.spider.step"),
// SulfurCube.playStepSound: a cube carrying a block in its body makes no
// footstep at all; an empty one takes the block's sound at the bottom.
S::SulfurCube if crate::mobs::sulfur_cube::has_body(m) => None,
// Silverfish.playStepSound and Endermite.playStepSound: both ask for
// their own step at 15%, though neither type's movement emission plays
// it (`movement_sounds`).
S::Silverfish => quiet("entity.silverfish.step"),
S::Endermite => quiet("entity.endermite.step"),
S::Zombie | S::ZombifiedPiglin => quiet("entity.zombie.step"),
S::Husk => quiet("entity.husk.step"),
S::Drowned => quiet("entity.drowned.step"),
S::ZombieVillager => quiet("entity.zombie_villager.step"),
// Camel.playStepSound and CamelHusk.playStepSound: sand and concrete
// powder have their own step.
S::CamelHusk | S::Camel => {
let sand = w.has_tag(
"minecraft:camel_sand_step_sound_blocks",
&w.facts.get(state).name,
);
Some(match (m.species(), sand) {
(S::Camel, true) => ("entity.camel.step_sand", 1.0, 1.0),
(S::Camel, false) => ("entity.camel.step", 1.0, 1.0),
(_, true) => ("entity.camel_husk.step_sand", 0.4, 1.0),
(_, false) => ("entity.camel_husk.step", 0.4, 1.0),
})
}
S::IronGolem => Some(("entity.iron_golem.step", 1.0, 1.0)),
// CopperGolem.playStepSound: its oxidation level's step.
S::CopperGolem => Some((crate::mobs::copper_golem::sound(m, "step"), 1.0, 1.0)),
S::Strider if m.fluids.in_lava => Some(("entity.strider.step_lava", 1.0, 1.0)),
S::Strider => Some(("entity.strider.step", 1.0, 1.0)),
S::Warden => Some(("entity.warden.step", 10.0, 1.0)),
_ => {
// Entity.playStepSound: the block's sound type.
let _ = pos;
let sounds = &w.facts.get(state).sounds;
let step: &'static str = intern_sound(&sounds.step);
Some((step, sounds.volume * 0.15, sounds.pitch))
}
}
}
/// A block sound id as a static string (the block facts intern them).
pub(crate) fn intern_sound(s: &std::sync::Arc<str>) -> &'static str {
use std::{collections::HashSet, sync::Mutex};
static NAMES: Mutex<Option<HashSet<&'static str>>> = Mutex::new(None);
let mut names = NAMES.lock().unwrap_or_else(|e| e.into_inner());
let set = names.get_or_insert_with(HashSet::new);
let key = s.strip_prefix("minecraft:").unwrap_or(s);
if let Some(found) = set.get(key) {
return found;
}
let leaked: &'static str = Box::leak(key.to_owned().into_boxed_str());
set.insert(leaked);
leaked
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_core::geometry::Vec3;
use std::sync::Arc;
/// `SulfurCube.playStepSound`: a cube carrying a block in its body returns
/// before the block's sound, so it walks silently, while an empty cube takes
/// the block's step at 15% like any other mob.
#[test]
fn a_loaded_sulfur_cube_walks_silently() {
let ran = crate::mobs::world::with_built_test_world(
&[],
|world, registry| {
let stone = registry.default_state("minecraft:stone").unwrap();
world
.chunks
.insert((0, 0), Arc::new(crate::chunk::Chunk::empty(0, 0)));
for x in 0..=4 {
for z in 0..=4 {
world.set(x, 63, z, stone).unwrap();
}
}
},
|w, types| {
let stepped = |body: Option<&str>| {
let kind = Arc::new(types.types["minecraft:sulfur_cube"].clone());
let mut m = Mob::new(1, kind, Vec3::new(0.5, 64.0, 0.5), 0.0);
m.extra.body = body.map(Into::into);
let state = w.world.block(0, 63, 0);
step_sound(&m, w, [0, 63, 0], state)
};
assert_eq!(stepped(Some("minecraft:stone")), None, "loaded");
let empty = stepped(None);
assert!(empty.is_some(), "an empty cube takes the block's step");
assert_eq!(empty.unwrap().1, 0.15, "the block's 15%");
},
);
let _ = ran;
}
}
@@ -0,0 +1,60 @@
//! `LivingEntity.updateFallFlying` for a mob: the glide's checks and its gliders' wear.
use super::*;
/// `LivingEntity.updateFallFlying` on the server, which `aiStep` calls while
/// the glide flag is set, with or without AI: a mob that cannot glide
/// (`canGlide`: on the ground, riding, levitating, or no slot holding a
/// glider whose next damage will not break it, `canGlideUsing`) stops
/// gliding; else on every 20th glide tick one of its gliders, picked with
/// `Util.getRandom` from the mob's own random, takes a point of wear.
/// It starts with `checkFallDistanceAccumulation`: a glide that is not
/// diving keeps at most 1 of its fall. The `ELYTRA_GLIDE` game event of
/// every 10th tick is not ported here.
pub(super) fn update_fall_flying(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
use horizon_world::player_inventory::EquipmentSlot;
if !m.extra.fall_flying {
return;
}
crate::mobs::leash::check_fall_distance(m);
let gliders: Vec<EquipmentSlot> = match w.items {
Some(items) => EquipmentSlot::ALL
.into_iter()
.filter(|&slot| {
crate::mobs::finalize::equipment_stack(m, slot).is_some_and(|stack| {
crate::item_sync::can_glide_using(&stack, glider_slot_name(slot), items)
})
})
.collect(),
None => Vec::new(),
};
if m.on_ground
|| m.extra.mount.is_some()
|| m.has_effect("minecraft:levitation")
|| gliders.is_empty()
{
m.extra.fall_flying = false;
return;
}
let ticks = m.extra.fall_fly_ticks + 1;
if ticks % 10 == 0 && (ticks / 10) % 2 == 0 {
let pick = m.random.next_int_bound(gliders.len() as i32).unwrap_or(0) as usize;
events.push(MobEvent::GliderWear {
mob: m.id,
slot: gliders[pick],
});
}
}
/// `EquipmentSlot`'s serialized name, which `equippable` names its slot by.
fn glider_slot_name(slot: horizon_world::player_inventory::EquipmentSlot) -> &'static str {
use horizon_world::player_inventory::EquipmentSlot as S;
match slot {
S::MainHand => "mainhand",
S::OffHand => "offhand",
S::Feet => "feet",
S::Legs => "legs",
S::Chest => "chest",
S::Head => "head",
S::Body => "body",
S::Saddle => "saddle",
}
}
+124 -15
View File
@@ -75,11 +75,71 @@ pub fn considers_item_equal(held: &ItemStack, candidate: &ItemStack) -> bool {
held.item == candidate.item && held.potion() == candidate.potion()
}
/// `Allay.setDancing` marks an allay dancing only under `isEffectiveAi()`, so
/// `NoAI` never dances; panicking stops it, and 10 blocks is the jukebox reach
/// (`distance_sq` to the nearest playing jukebox's centre, infinite for none).
pub fn dances(no_ai: bool, panicking: bool, distance_sq: f64) -> bool {
!no_ai && !panicking && distance_sq <= 100.0
/// `GameEvent.JUKEBOX_PLAY.notificationRadius()`: the `JukeboxListener`'s radius.
const JUKEBOX_LISTENER_RADIUS: i64 = 10;
/// `EuclideanGameEventListenerRegistry.getPostableListenerPosition` for the
/// `JukeboxListener`: its source is the allay's eye (`EntityPositionSource`
/// with `getEyeHeight`), and the event is heard when the blocks of the eye and
/// of the event are at most 10 apart (`BlockPos.distSqr`).
pub fn hears_jukebox(m: &Mob, pos: [f64; 3]) -> bool {
let block = |p: [f64; 3]| p.map(|v| i64::from(v.floor() as i32));
let (source, listener) = (block(pos), block([m.position.x, m.eye_y(), m.position.z]));
let d = [0, 1, 2].map(|i| source[i] - listener[i]);
d[0] * d[0] + d[1] * d[1] + d[2] * d[2] <= JUKEBOX_LISTENER_RADIUS * JUKEBOX_LISTENER_RADIUS
}
/// `Allay.setDancing`: only under `isEffectiveAi()` (so `NoAI` never changes
/// it), and a panicking allay does not start.
fn set_dancing(m: &mut Mob, dancing: bool) {
if m.no_ai || dancing && super::goals::should_panic(m) {
return;
}
m.extra.flag = dancing;
}
/// `Allay.setJukeboxPlaying`, from the `JukeboxListener` (`jukebox_play` and
/// `jukebox_stop_play` at `jukebox`): a play starts a dance and keeps that
/// jukebox while the allay is not dancing yet; a stop ends it only from the
/// kept jukebox, or with none kept.
pub fn set_jukebox_playing(m: &mut Mob, jukebox: [i32; 3], playing: bool) {
let Some(kept) = m.extra.allay.as_ref().map(|st| st.jukebox) else {
return;
};
if playing {
if !m.extra.flag {
set_jukebox(m, Some(jukebox));
set_dancing(m, true);
}
} else if kept.is_none() || kept == Some(jukebox) {
set_jukebox(m, None);
set_dancing(m, false);
}
}
fn set_jukebox(m: &mut Mob, jukebox: Option<[i32; 3]>) {
if let Some(st) = m.extra.allay.as_mut() {
st.jukebox = jukebox;
}
}
/// `Allay.aiStep`'s `isDancing() && shouldStopDancing() && tickCount % 20 == 0`:
/// the dance ends when no jukebox is kept, it is not within 10 blocks of the
/// allay (`closerToCenterThan` its centre) or is no jukebox any more.
fn stop_dancing_away(m: &mut Mob, w: &MobWorld) {
if !m.extra.flag || !m.age.is_multiple_of(20) {
return;
}
let kept = m.extra.allay.as_ref().and_then(|st| st.jukebox);
let stays = kept.is_some_and(|[x, y, z]| {
let centre = Vec3::new(f64::from(x) + 0.5, f64::from(y) + 0.5, f64::from(z) + 0.5);
centre.distance_squared(m.position) < f64::from(JUKEBOX_LISTENER_RADIUS as i32).powi(2)
&& &*w.facts.get(w.world.block(x, y, z)).name == "minecraft:jukebox"
});
if !stays {
set_dancing(m, false);
set_jukebox(m, None);
}
}
/// `Mob.getPickupReach` (1, 1, 1) around the box: `Mob.aiStep`'s looting loop
@@ -103,6 +163,8 @@ pub struct State {
/// None for a memory that never expires. The memory itself is
/// `Extra::tame` and `Extra::owner`.
pub liked_player_ttl: Option<i64>,
/// `Allay.jukeboxPos`: the jukebox the dance started from. Not saved.
pub jukebox: Option<[i32; 3]>,
}
/// The pocket is independent of the main-hand filter.
@@ -410,6 +472,10 @@ pub fn tick(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
if let Some(info) = tick.received {
receive_vibration(m, dimension(w), info);
}
// Allay.tick, after the vibration ticker.
if super::goals::should_panic(m) {
set_dancing(m, false);
}
}
/// `Allay.travel`: `travelFlying(input, getSpeed())` — the airborne speed is
/// the one the move control set this tick, and no gravity applies, so an
@@ -419,10 +485,12 @@ pub fn travel(m: &mut Mob, w: &MobWorld, input: Vec3, events: &mut Vec<MobEvent>
m.travel_flying_speeds(w, input, events, 0.02, 0.02, speed);
true
}
/// `Allay.aiStep` / `tick`: healing, cooldowns, the hold and spin clocks.
pub fn ai_step(m: &mut Mob) {
/// `Allay.aiStep` / `tick`: healing, the dance's jukebox check, cooldowns, the
/// hold and spin clocks.
pub fn ai_step(m: &mut Mob, w: &MobWorld) {
tick_liked_player(m);
passive_heal(m);
stop_dancing_away(m, w);
for i in [2, 3] {
if m.extra.ints[i] > 0 {
m.extra.ints[i] -= 1;
@@ -614,14 +682,55 @@ mod allay_parity_tests {
#[test]
fn a_no_ai_allay_does_not_dance_to_a_playing_jukebox() {
// Beside a playing jukebox and at the edge of its 10-block reach.
for distance_sq in [0.0, 1.5 * 1.5, 99.0, 100.0] {
assert!(!dances(true, false, distance_sq), "NoAI at {distance_sq}");
assert!(dances(false, false, distance_sq), "AI at {distance_sq}");
}
// Out of reach, or panicking: no allay dances.
assert!(!dances(false, false, 100.5));
assert!(!dances(false, true, 1.0));
let mut mob = test_allay();
mob.no_ai = true;
set_jukebox_playing(&mut mob, [0, 64, 0], true);
assert!(!mob.extra.flag, "NoAI");
mob.no_ai = false;
set_jukebox_playing(&mut mob, [0, 64, 0], true);
assert!(mob.extra.flag, "AI");
// A burning allay panics: it does not start dancing.
let mut burning = test_allay();
burning.fire_ticks = 20;
set_jukebox_playing(&mut burning, [0, 64, 0], true);
assert!(!burning.extra.flag);
}
fn test_allay() -> Mob {
let mut kind = crate::mobs::sensor_scan_tests::kind();
let kind_mut = std::sync::Arc::make_mut(&mut kind);
kind_mut.species = crate::mobs::Species::Allay;
kind_mut.id = "minecraft:allay".into();
Mob::new(37, kind, Vec3::new(0.5, 64.0, 0.5), 0.0)
}
// Added after the baseline: the kept jukebox and the listener's reach.
#[test]
fn only_the_kept_jukebox_stops_the_dance() {
let mut mob = test_allay();
set_jukebox_playing(&mut mob, [3, 64, 0], true);
// Another jukebox plays: the first one stays the dance's.
set_jukebox_playing(&mut mob, [-4, 64, 0], true);
assert_eq!(mob.extra.allay.as_ref().unwrap().jukebox, Some([3, 64, 0]));
set_jukebox_playing(&mut mob, [-4, 64, 0], false);
assert!(mob.extra.flag, "a stop elsewhere does not end it");
set_jukebox_playing(&mut mob, [3, 64, 0], false);
assert!(!mob.extra.flag);
assert_eq!(mob.extra.allay.as_ref().unwrap().jukebox, None);
// With none kept, any stop ends a dance.
mob.extra.flag = true;
set_jukebox_playing(&mut mob, [9, 64, 9], false);
assert!(!mob.extra.flag);
}
#[test]
fn the_jukebox_listener_hears_ten_blocks_from_the_eye_block() {
let mob = test_allay();
let eye = mob.eye_y().floor();
// Block distance squared from the eye's block: 100 is heard, 101 not.
assert!(hears_jukebox(&mob, [10.5, eye, 0.5]));
assert!(!hears_jukebox(&mob, [10.5, eye + 1.0, 0.5]));
assert!(hears_jukebox(&mob, [6.5, eye + 8.0, 0.5]));
}
#[test]
+15
View File
@@ -730,6 +730,21 @@ impl Goal for SquidFlee {
}
}
}
/// `AbstractFish.removeWhenFarAway` and `Axolotl.removeWhenFarAway`: a fish or
/// an axolotl with a custom name is never removed for distance, and one let out
/// of a bucket is persistent before it (`isPersistenceRequired`).
pub fn named_keeps_it(m: &Mob) -> bool {
m.custom_name.is_some()
&& matches!(
m.species(),
Species::Cod
| Species::Salmon
| Species::TropicalFish
| Species::Pufferfish
| Species::Tadpole
| Species::Axolotl
)
}
/// `AbstractSchoolingFish`: cod, salmon and tropical fish.
fn schooling(s: Species) -> bool {
matches!(s, Species::Cod | Species::Salmon | Species::TropicalFish)
+33 -11
View File
@@ -12,10 +12,7 @@ use super::{
goal_clone,
},
};
use horizon_core::{
geometry::{Aabb, Vec3},
random::RandomSource,
};
use horizon_core::{geometry::Vec3, random::RandomSource};
pub const IDLE: u8 = 0;
pub const ROLLING: u8 = 1;
@@ -86,18 +83,20 @@ struct BabyFollowAdult {
const FOLLOW_MIN: f64 = 5.0;
const FOLLOW_MAX: f64 = 16.0;
const FOLLOW_SPEED: f64 = 1.25;
/// The armadillo's `FOLLOW_RANGE` attribute, the sensor's reach.
const FOLLOW_RANGE: f64 = 16.0;
impl BabyFollowAdult {
/// `NearestVisibleLivingEntities.findClosest(same type and not a baby)`.
/// `NearestVisibleLivingEntities.findClosest(same type and not a baby)`, of the living
/// entities `NearestLivingEntitySensor.doTick` finds: those whose boxes meet the baby's box
/// inflated by its `FOLLOW_RANGE` (16, `Mob.createMobAttributes`).
fn nearest_adult(m: &mut Mob, cx: &mut Cx) -> Option<u32> {
let species = m.species();
let p = m.position;
let area = Aabb::new(
Vec3::new(p.x - FOLLOW_MAX, p.y - FOLLOW_MAX, p.z - FOLLOW_MAX),
Vec3::new(p.x + FOLLOW_MAX, p.y + FOLLOW_MAX, p.z + FOLLOW_MAX),
);
let area = m
.bounds()
.inflate(Vec3::new(FOLLOW_RANGE, FOLLOW_RANGE, FOLLOW_RANGE));
let ids: Vec<(u32, f64)> =
cx.w.others_near(area)
.filter(|s| s.alive && !s.baby && s.species == species)
.filter(|s| s.alive && !s.baby && s.species == species && s.bounds.intersects(area))
.map(|s| (s.id, m.distance_sqr(s.position)))
.collect();
let mut best: Option<(u32, f64)> = None;
@@ -780,6 +779,29 @@ mod tests {
});
}
#[test]
fn a_baby_follows_only_adults_its_sensor_box_meets() {
// NearestLivingEntitySensor.doTick: the entities whose boxes meet the baby's box inflated
// by FOLLOW_RANGE (16). Both adults are within BabyFollowAdult's 17 blocks. The baby's box
// (0.35 to 0.7 wide, by its age scale) inflated reaches x = 16.675 to 16.85: the adult at
// x = 17.0 (0.7 wide, its box from 16.65) is found, the one at x = 17.2 (from 16.85) is
// not, though a scan of every mob returns it.
for (x, follows) in [(17.0, true), (17.2, false)] {
let Some([mut baby, adult]) = real([[0.5, 64.0, 0.5], [x, 64.0, 0.5]]) else {
return;
};
baby.extra.age = -24000;
crate::mobs::world::with_test_world(&[adult.seen(None)], |w, _| {
run_goals(&mut baby, w, 1);
assert_eq!(
baby.ai.goals.is_running("BabyFollowAdult"),
follows,
"x = {x}"
);
});
}
}
#[test]
fn a_baby_stays_when_the_adult_is_within_five_blocks() {
// BabyFollowAdult: `!closerThan(adult, 5)`.
@@ -0,0 +1,105 @@
use super::*;
/// A cow in love at (0.5, 64, 0.5) and single in-love cows at `mates`, on
/// a flat grass shelf, one tick of its goals: whether its `Breed` goal
/// took a mate, and the block it walks to. `None` without the vanilla
/// data.
fn courtship(mates: &[[f64; 3]]) -> Option<(bool, Option<[i32; 3]>)> {
// A mate's `Seen` needs its kind, so two loads.
let mut kind = None;
crate::mobs::world::with_built_test_world(&[], shelf, |_, types| {
kind = Some(Arc::new(types.types["minecraft:cow"].clone()));
});
let kind = kind?;
let seen: Vec<Seen> = mates
.iter()
.enumerate()
.map(|(i, [x, y, z])| {
let mut p = Mob::new(i as u32 + 2, kind.clone(), Vec3::new(*x, *y, *z), 0.0);
p.extra.in_love = 600;
p.seen(None)
})
.collect();
let mut out = None;
crate::mobs::world::with_built_test_world(&seen, shelf, |w, _| {
let mut m = Mob::new(1, kind.clone(), Vec3::new(0.5, 64.0, 0.5), 0.0);
m.extra.in_love = 600;
m.on_ground = true;
let mut lr = LegacyRandom::new(0);
let mut events = Vec::new();
let mut cx = Cx {
w,
lr: &mut lr,
events: &mut events,
};
let mut goals = std::mem::take(&mut m.ai.goals);
goals.tick(&mut m, &mut cx);
m.ai.goals = goals;
out = Some((m.ai.goals.is_running("Breed"), m.ai.nav.target_pos));
});
out
}
/// A flat grass shelf the cows stand and walk on.
fn shelf(world: &mut crate::world::World, registry: &horizon_world::registry::BlockRegistry) {
let grass = registry.default_state("minecraft:grass_block").unwrap();
for cx in -3..3 {
for cz in -3..3 {
world
.chunks
.insert((cx, cz), Arc::new(crate::chunk::Chunk::empty(cx, cz)));
}
}
for x in -16..=16 {
for z in -16..=16 {
world.set(x, 63, z, grass).unwrap();
}
}
}
/// `BreedGoal.getFreePartner` searches the box `getBoundingBox().inflate(8.0)`
/// but keeps only what `PARTNER_TARGETING` (`forNonCombat().range(8.0)`)
/// passes, and `TargetingConditions.test` rejects a target whose
/// `distanceToSqr` is above 8 squared.
#[test]
fn a_mate_outside_the_eight_block_sphere_is_ignored() {
// 7,0,5 apart: squared 74, inside the box, outside the sphere.
let Some((running, target)) = courtship(&[[7.5, 64.0, 5.5]]) else {
return;
};
assert!(!running, "a mate 8.6 blocks away is out of range");
assert_eq!(target, None, "it does not walk to it");
// Just outside: 8 on x and 0.6 on z, squared 64.36.
let Some((running, target)) = courtship(&[[8.5, 64.0, 1.1]]) else {
return;
};
assert!(!running, "a mate just past eight blocks is out of range");
assert_eq!(target, None, "it does not walk to it");
}
/// The other half of the same predicate: the sphere keeps a diagonal mate
/// and one exactly eight blocks away, and the nearest of two still wins.
#[test]
fn a_mate_inside_the_eight_block_sphere_is_courted() {
// 5,0,5 apart: squared 50.
let Some((running, target)) = courtship(&[[5.5, 64.0, 5.5]]) else {
return;
};
assert!(running, "a diagonal mate 7.1 blocks away is in range");
let [x, _, z] = target.expect("it walks toward its mate");
assert!((x, z) == (5, 5), "it heads for its mate, got {x} {z}");
// Exactly eight: squared 64 is not above the limit.
let Some((running, target)) = courtship(&[[8.5, 64.0, 0.5]]) else {
return;
};
assert!(running, "a mate exactly eight blocks away is in range");
let [x, _, z] = target.expect("it walks toward its mate");
assert!((x, z) == (8, 0), "it heads for its mate, got {x} {z}");
// Two mates: the nearer (5,5) wins over the one at exactly eight.
let Some((running, target)) = courtship(&[[8.5, 64.0, 0.5], [5.5, 64.0, 5.5]]) else {
return;
};
assert!(running);
let [x, _, z] = target.expect("it walks toward its mate");
assert!((x, z) == (5, 5), "the nearest mate, got {x} {z}");
}
@@ -0,0 +1,90 @@
use super::*;
/// Two llamas in love, two blocks apart on a flat grass shelf, ticked by
/// the AI until the first breeds: its own events, with the `mob_drops`
/// game rule at `drops`. `None` without the vanilla data.
fn llama_events(drops: bool) -> Option<Vec<MobEvent>> {
// The partner's `Seen` needs its kind, so two loads.
let mut kind = None;
crate::mobs::world::with_built_test_world_rules(&[], true, drops, shelf, |_, types| {
kind = Some(Arc::new(types.types["minecraft:llama"].clone()));
});
let kind = kind?;
let mut partner = Mob::new(2, kind.clone(), Vec3::new(2.5, 64.0, 0.5), 0.0);
// AbstractHorse.canParent: only tame llamas breed.
partner.extra.tame = true;
partner.extra.in_love = 600;
let seen = [partner.seen(None)];
let mut out = None;
crate::mobs::world::with_built_test_world_rules(&seen, true, drops, shelf, |w, _| {
let mut m = Mob::new(1, kind, Vec3::new(0.5, 64.0, 0.5), 0.0);
m.extra.in_love = 600;
m.extra.tame = true;
let mut lr = LegacyRandom::new(0);
let mut events = Vec::new();
for _ in 0..600 {
crate::mobs::ai::tick(&mut m, w, &mut lr, &mut events);
if events
.iter()
.any(|e| matches!(e, MobEvent::Breed { parent: 1, .. }))
{
break;
}
}
out = Some(events);
});
out
}
/// A flat grass shelf two llamas can stand on.
fn shelf(world: &mut crate::world::World, registry: &horizon_world::registry::BlockRegistry) {
let grass = registry.default_state("minecraft:grass_block").unwrap();
for cx in -3..3 {
for cz in -3..3 {
world
.chunks
.insert((cx, cz), Arc::new(crate::chunk::Chunk::empty(cx, cz)));
}
}
for x in -8..=8 {
for z in -8..=8 {
world.set(x, 63, z, grass).unwrap();
}
}
}
/// `finalizeSpawnChildFromBreeding` makes the experience draw only inside
/// the `mob_drops` check, from the breeding parent's own random: the
/// breed goal pushes one 1-7 orb with the rule on, none with it off, and
/// the pair breeds either way.
#[test]
fn the_breed_xp_follows_the_mob_drops_rule() {
let Some(on) = llama_events(true) else {
return;
};
let Some(off) = llama_events(false) else {
return;
};
let orb = |events: &[MobEvent]| {
events.iter().find_map(|e| match e {
MobEvent::Experience { amount, .. } => Some(*amount),
_ => None,
})
};
assert!(
orb(&on).is_some_and(|a| (1..=7).contains(&a)),
"mob_drops on: one orb of 1-7, got {:?}",
orb(&on)
);
assert!(orb(&off).is_none(), "mob_drops off: no experience draw");
assert!(
on.iter()
.any(|e| matches!(e, MobEvent::Breed { parent: 1, .. })),
"mob_drops on: the pair breeds"
);
assert!(
off.iter()
.any(|e| matches!(e, MobEvent::Breed { parent: 1, .. })),
"mob_drops off: the pair still breeds"
);
}
+253 -14
View File
@@ -7,7 +7,7 @@
//! `cat_collar` to `cat`).
//!
//! Built when the packs load; a mob's draw only swaps the layer's part tree. Without CEM files
//! nothing is built and every model stays vanilla's, and a file no layer uses isn't parsed.
//! or old mob textures ([`LEGACY`]) every model stays vanilla's, and a file no layer uses isn't parsed.
//!
//! The seam with random entity textures (ETF): a `.jem`'s own `texture` replaces the layer's
//! vanilla texture name, and the random texture lookup then gets that name. Variants without
@@ -34,7 +34,7 @@ pub struct CemModels {
layers: HashMap<String, CemLayer>,
}
/// A model layer as a `.jem` reshapes it.
/// A model layer as a `.jem` reshapes it, or as a pack's pre-1.21.5 mob texture needs it.
pub struct CemLayer {
pub model: Arc<Part>,
/// The `.jem`'s animations, run after the vanilla animation.
@@ -45,19 +45,34 @@ pub struct CemLayer {
/// The `attachments` the pack's bones declare, by the bone's `cem#<n>` name: the item
/// anchors a renderer looks up (`right_handheld_item`, `left_handheld_item`, `fox_item`).
pub anchors: Vec<(String, String, [f32; 3])>,
/// The packs' pre-1.21.5 textures this layer paints with, by the vanilla texture they
/// stand for without its climate ([`legacy_stem`]): `entity/cow/mooshroom_red`
/// is drawn as `entity/cow/red_mooshroom`.
legacy: Vec<(&'static str, &'static str)>,
}
impl CemLayer {
/// What this layer draws in place of the vanilla `texture`: the `.jem`'s own, or the old
/// name of a texture 1.21.5 renamed when the packs paint this layer with it.
pub fn texture_for(&self, texture: &str) -> Option<&str> {
self.texture.as_deref().or_else(|| {
let stem = legacy_stem(texture);
self.legacy
.iter()
.find(|(new, _)| *new == stem)
.map(|(_, old)| *old)
})
}
}
/// How many fallbacks a chain follows (`cat_baby_collar`, `cat_collar`, `cat` takes two).
const FALLBACK_STEPS: usize = 3;
impl CemModels {
/// The `.jem` files under `root` applied to the layers of `types`.
/// The `.jem` files under `root` applied to the layers of `types`, and the layers the
/// packs' pre-1.21.5 mob textures paint ([`LEGACY`]).
pub fn load(root: &Path, types: &HashMap<String, Arc<MobType>>) -> Self {
let files = jem_files(root);
if files.is_empty() {
set_rule_species(Vec::new());
return Self::default();
}
let (models, standing) = Self::from_files(root, &files, &vanilla_layers(types));
set_rule_species(standing);
models
@@ -104,6 +119,8 @@ impl CemModels {
// Each file parsed once, when a layer first tries it.
let mut parsed: HashMap<&str, Option<EntityModel>> = HashMap::new();
let mut layers = HashMap::new();
// The layers that took their own file (`pig.jem` for `pig#main`), for [`LEGACY`].
let mut own = HashMap::new();
// The species whose rules read the standing block, for the per-tick gate.
let mut standing_species = Vec::new();
for (key, mut candidates) in plan {
@@ -137,9 +154,20 @@ impl CemModels {
// only: the layer keeps its own texture.
if layer_suffix(&key) != layer_suffix(&layer_key(name)) {
layer.texture = None;
} else if key == layer_key(name) {
own.insert(key.clone(), (name, path));
}
layers.insert(key, layer);
}
let own: HashMap<String, (&str, &str, &EntityModel)> = own
.into_iter()
.filter_map(|(key, (name, path))| {
Some((key, (name, path, parsed.get(path)?.as_ref()?)))
})
.collect();
for family in LEGACY {
family.layers(root, vanilla, &own, &mut layers);
}
(Self { layers }, standing_species)
}
@@ -604,13 +632,10 @@ impl CemLayer {
// beside the pack's own.
hide_unnamed(node, &named);
}
// A replacing entry stands in for the vanilla part; an attached one is its own.
let this = if p.attach {
bone.name.clone()
} else {
target.clone()
};
entries.push((this, target, p.animations.as_slice()));
// `this` is the entry's own bone, replacing or attached ("current custom model",
// `cem_animation.txt`); `part` the vanilla part it hangs on. Conquest's pig writes
// `this.tx` = its `translate`: on the vanilla head that flung the head 7 px off.
entries.push((bone.name.clone(), target, p.animations.as_slice()));
node.children.push(bone);
}
let names = super::cem_anim::Names {
@@ -624,10 +649,137 @@ impl CemLayer {
animation,
texture: jem.texture.as_deref().and_then(atlas_name),
anchors,
legacy: Vec::new(),
}
}
}
/// A mob 1.21.5 split by climate or renamed the texture of: a pack made before paints the
/// adult and its climate variants with its one old texture (`entity/pig/pig`, 64x32), whose
/// UVs the adult's model measures.
struct Legacy {
/// The adult's model; its climate variants', which the pack knew as the adult.
model: &'static str,
variants: &'static [&'static str],
/// The vanilla textures without climate ([`legacy_stem`]), and their old names.
textures: &'static [(&'static str, &'static str)],
/// How many times taller the new texture is than the old: the texels the old model read
/// kept their places.
taller: f32,
}
const LEGACY: &[Legacy] = &[
Legacy {
model: "pig",
variants: &["cold_pig"],
textures: &[("entity/pig/pig", "entity/pig/pig")],
taller: 2.0,
},
Legacy {
model: "cow",
variants: &["cold_cow", "warm_cow"],
textures: &[("entity/cow/cow", "entity/cow/cow")],
taller: 2.0,
},
Legacy {
model: "mooshroom",
variants: &[],
textures: &[
("entity/cow/mooshroom_red", "entity/cow/red_mooshroom"),
("entity/cow/mooshroom_brown", "entity/cow/brown_mooshroom"),
],
taller: 2.0,
},
Legacy {
model: "chicken",
variants: &["cold_chicken"],
textures: &[("entity/chicken/chicken", "entity/chicken")],
taller: 1.0,
},
];
/// A vanilla mob texture without its climate: `entity/pig/pig_cold` is `entity/pig/pig`.
fn legacy_stem(texture: &str) -> &str {
["_temperate", "_warm", "_cold"]
.iter()
.find_map(|climate| texture.strip_suffix(climate))
.unwrap_or(texture)
}
impl Legacy {
/// The family's layers (adult and climate variants) when the packs have an old texture of
/// it: the adult's vanilla model, reshaped by its own `.jem` when there is one without a
/// texture of its own, read in the old layout. Without this a pig drew the 64x64 texture
/// through a `.jem`'s 64x32 UVs, and cold pigs and mooshrooms vanilla's textures.
fn layers(
&self,
root: &Path,
vanilla: &HashMap<&str, (&Arc<Part>, &str)>,
own: &HashMap<String, (&str, &str, &EntityModel)>,
layers: &mut HashMap<String, CemLayer>,
) {
// `root` holds vanilla's files too, which never had the old names.
let legacy: Vec<_> = self
.textures
.iter()
.copied()
.filter(|(_, old)| {
root.join(format!("assets/minecraft/textures/{old}.png"))
.is_file()
})
.collect();
let adult = format!("{}#main", self.model);
let Some(&(part, _)) = vanilla.get(adult.as_str()) else {
return;
};
let jem = own.get(&adult);
if legacy.is_empty() || jem.is_some_and(|(_, _, jem)| jem.texture.is_some()) {
return;
}
let rules = jem.map(|&(_, path, _)| load_rules(root, path));
for model in std::iter::once(self.model).chain(self.variants.iter().copied()) {
let key = format!("{model}#main");
// A variant with a file of its own was made after 1.21.5.
if !vanilla.contains_key(key.as_str()) || key != adult && own.contains_key(&key) {
continue;
}
let tree = (**part).clone();
let mut layer = match (jem, &rules) {
(Some(&(name, _, jem)), Some(rules)) => CemLayer::build(&tree, jem, name, rules),
_ => CemLayer {
model: Arc::new(tree),
animation: None,
texture: None,
anchors: Vec::new(),
legacy: Vec::new(),
},
};
old_layout(Arc::make_mut(&mut layer.model), self.taller);
layer.legacy = legacy.clone();
layers.insert(key, layer);
}
}
}
/// The vanilla parts of `part` (not a `.jem`'s `cem#` bones) reading a texture `taller` times
/// shorter. Faces past its bottom belong to parts newer than it (the 1.21.5 cow's muzzle):
/// the model it was painted for had none.
fn old_layout(part: &mut Part, taller: f32) {
if part.name.starts_with("cem#") {
return;
}
for polygon in &mut part.polygons {
for vertex in &mut polygon.vertices {
vertex[4] *= taller;
}
}
part.polygons
.retain(|p| p.vertices.iter().all(|v| v[4] <= 1.0));
for child in &mut part.children {
old_layout(child, taller);
}
}
/// The first part named `name`, depth first (`ModelPart.getChild` through the tree).
fn find_mut<'a>(part: &'a mut Part, name: &str) -> Option<&'a mut Part> {
if part.name == name {
@@ -880,6 +1032,75 @@ mod tests {
assert!(models.layer("sheep#main").is_none());
}
#[test]
fn a_pre_1_21_5_pig_paints_with_its_old_texture() {
// Conquest's pig: the head replaced, the legs left vanilla.
let pig = r#"{"textureSize": [64, 32], "models": [{"part": "head", "translate": [5, 9.7, -11],
"boxes": [{"coordinates": [-11, -14, 3, 8, 8, 8], "uvNorth": [8, 8, 16, 16]}]}]}"#;
let mut leg = plane("right_hind_leg", vec![]);
leg.polygons[0].vertices[2][4] = 0.3125; // v 20 of the 64x64 texture
let tree = plane("root", vec![plane("head", vec![]), leg]);
let files = [("optifine/cem/pig.jem", pig)];
let new = models("legacy-new", &files, &["pig#main"], &tree);
assert_eq!(
new.layer("pig#main")
.unwrap()
.texture_for("entity/pig/pig_temperate"),
None
);
let files = [files[0], ("textures/entity/pig/pig.png", "")];
let old = models("legacy-old", &files, &["pig#main"], &tree);
let layer = old.layer("pig#main").unwrap();
assert_eq!(
layer.texture_for("entity/pig/pig_warm"),
Some("entity/pig/pig")
);
// The leg's v 20 is 20 of the old texture's 32; the pack's own box keeps its UVs.
let leg = get(&layer.model, "right_hind_leg");
assert_eq!(leg.polygons[0].vertices[2][4], 0.625);
assert_eq!(get(&layer.model, "cem#0").polygons[0].vertices[0][4], 0.25);
}
#[test]
fn a_pre_1_21_5_pack_paints_variants_with_the_adults_old_texture() {
// A pig without a `.jem`, its head with a face past the old texture's bottom.
let mut head = plane("head", vec![]);
let mut newer = head.polygons[0].clone();
newer.vertices[2][4] = 0.578125; // v 37 of 64: past the old 32
head.polygons.push(newer);
let tree = plane("root", vec![head]);
let keys = ["pig#main", "cold_pig#main"];
let files = [("textures/entity/pig/pig.png", "")];
let pigs = models("legacy-family", &files, &keys, &tree);
let cold = pigs.layer("cold_pig#main").unwrap();
assert_eq!(
cold.texture_for("entity/pig/pig_cold"),
Some("entity/pig/pig")
);
assert_eq!(get(&cold.model, "head").polygons.len(), 1);
// A cold pig with a file of its own was made for its own texture.
let file = format!(r#"{{"models": [{{"part": "head", {BOX}}}]}}"#);
let files = [files[0], ("optifine/cem/cold_pig.jem", file.as_str())];
let pigs = models("legacy-own-variant", &files, &keys, &tree);
let cold = pigs.layer("cold_pig#main").unwrap();
assert_eq!(cold.texture_for("entity/pig/pig_cold"), None);
// Mooshrooms take each colour's old name.
let files = [("textures/entity/cow/red_mooshroom.png", "")];
let moosh = models("legacy-mooshroom", &files, &["mooshroom#main"], &tree);
let layer = moosh.layer("mooshroom#main").unwrap();
let red = layer.texture_for("entity/cow/mooshroom_red");
assert_eq!(red, Some("entity/cow/red_mooshroom"));
assert_eq!(layer.texture_for("entity/cow/mooshroom_brown"), None);
// Without the old names nothing changes.
let files = [("textures/entity/pig/pig_temperate.png", "")];
assert!(
models("legacy-modern", &files, &keys, &tree)
.layer("pig#main")
.is_none()
);
}
const BOX: &str = r#""boxes": [{"coordinates": [0, 0, 0, 1, 1, 1], "textureOffset": [0, 0]}]"#;
/// A `.jem` with one entry on `part`, attached or replacing it.
@@ -1018,6 +1239,24 @@ mod tests {
assert_eq!(attached(&models, "zombie_baby#main", "body"), Some(true));
}
#[test]
fn this_is_a_replacing_entrys_own_bone() {
// Conquest's pig: the head replaced, posed through `this`.
let pig = r#"{"models": [{"part": "head", "translate": [5, 9.7, -11],
"boxes": [{"coordinates": [-11, -14, 3, 8, 8, 8], "uvNorth": [8, 8, 16, 16]}],
"animations": [{"this.tx": "7"}]}]}"#;
let tree = plane("root", vec![plane("head", vec![])]);
let files = [("optifine/cem/pig.jem", pig)];
let models = models("this-bone", &files, &["pig#main"], &tree);
let layer = models.layer("pig#main").unwrap();
let mut poses = super::super::anim::Poses::new(&layer.model);
let animation = layer.animation.as_ref().unwrap();
animation.run_with(1, 1, |_| {}, &mut poses);
// `this.tx` writes the pack's bone; the vanilla head it replaces stays put.
assert_eq!(poses.get("cem#0")[0], 7.0);
assert_eq!(poses.get("head")[0], 0.0);
}
#[test]
fn an_unknown_part_is_skipped_and_the_rest_applies() {
let file =
+7 -1
View File
@@ -444,7 +444,13 @@ impl Animation {
}
/// `run` for the entity `id` in the frame `counter`, whose parameters `fill` writes.
fn run_with(&self, id: u32, counter: u32, fill: impl FnOnce(&mut [f32]), poses: &mut Poses) {
pub(super) fn run_with(
&self,
id: u32,
counter: u32,
fill: impl FnOnce(&mut [f32]),
poses: &mut Poses,
) {
let preview = IN_GUI.get();
SLOTS.with_borrow_mut(|slots| {
slots.clear();
@@ -338,6 +338,11 @@ impl Mob {
}
/// LookControl.tick (and SmoothSwimmingLookControl).
pub fn look_control_tick(&mut self) {
// Phantom.PhantomLookControl.tick is empty: the pitch the move control
// sets from the flight stays.
if self.species() == super::Species::Phantom {
return;
}
let look = self.ai.look.clone();
let fox = self.species() == super::Species::Fox;
// Fox.FoxLookControl: no turning in sleep.
Loaded 100 of 184 files, more files were not shown because too many files have changed in this diff. Show more