Compare commits

..
Author SHA1 Message Date
rdbt 36eb98b6f6 Merge pull request #15815 from rdbtCVS/t3/fix-nightly-parity-regressions
Fix seven parity regressions blocking nightly builds
2026-11-12 23:53:35 +00:00
rdbtCVSandCodex GPT-6.1 a884525213 Align the head cache test with whole-payload texture rejection
Co-Authored-By: Codex GPT-6.1 <[email protected]>
2026-11-12 23:23:18 +00:00
rdbtCVSandCodex GPT-6.1 c35f0ba323 Clarify the authlib reference outside the game port ledger
Co-Authored-By: Codex GPT-6.1 <[email protected]>
2026-11-12 23:04:01 +00:00
rdbtCVSandCodex GPT-6.1 7e6b2db114 Merge main with the manual-proof parity fix
Co-Authored-By: Codex GPT-6.1 <[email protected]>
2026-11-12 22:55:36 +00:00
rdbtCVSandCodex GPT-6.1 768f0ddb83 Fix seven parity regressions blocking nightly builds
Restore vanilla void thresholds, HUD backdrops and profile texture validation; repair the block-event, AI-label, display-name and save-timer harness contracts.

Co-Authored-By: Codex GPT-6.1 <[email protected]>
2026-11-12 22:55:15 +00:00
rdbt 7c1a43138c Merge pull request #15570 from ilguerro/fix/parity-manual-proof-scripts
Leave the six hand-run proof scripts out of parity runs
2026-11-12 22:43:08 +00:00
rdbt 1a62ef7eb8 Merge pull request #15501 from rdbtCVS/t3/mob-turn-hooks-at-build
Mob islands: find mods that hook a mob's turn at build time
2026-11-12 19:45:27 +00:00
rdbtCVSandClaude Opus 5.5 fd448c5dc3 Mob islands: a mod hooking trample or a block contact keeps the mob tick serial
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 19:23:16 +00:00
rdbtCVSandClaude Opus 5.5 e0d0e34ba3 Mob islands: find mods that hook a mob's turn at build time
Replaces the opt-in native_mob_phase_quiet hook, which every existing mod
leaves unanswered, so any native mod kept the mob tick serial. The build
script now looks itself (horizon_mod_api_gen::mob_turn) and tells the
game through HORIZON_MOB_TURN:

- Mixins: the stage record names each injection's target. One into a
  hook a mob's turn calls, or into anything but a native_* hook point,
  keeps the loop serial.
- Trait mods: a staged trait package or its crates defining a function
  named for a hook the turn waits on keeps it serial. Jump and damage
  post are heard at the join, so overriding them doesn't.

A build without that finding but with trait mods stays serial, as before.
This also covers mixin-only bundles, which the opt-in hook never asked.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 19:23:16 +00:00
rdbt 4e459bf8e7 Merge pull request #15497 from rdbtCVS/t3/mob-island-bounded-reach
Mob islands: bounded reaches instead of serial fallbacks
2026-11-12 19:23:05 +00:00
rdbt 15f090e126 Merge pull request #15627 from senjinthedragon/cem-rot-radians
Custom entity models read rot_x, rot_y and player_rot_x/y in radians
2026-11-12 19:17:38 +00:00
rdbt f21b83c8b2 Merge pull request #15752 from rdbtCVS/fix/launcher-needless-bool
Launcher: fix Rust 1.100's needless_bool lint that fails main's launcher check
2026-11-12 19:16:28 +00:00
rdbt b929bea501 Merge pull request #15514 from yahya-nusseibeh/fix/launcher-safe-name-devices
Launcher: no Windows device names or disguising marks in downloaded file names
2026-11-12 19:12:32 +00:00
rdbtCVSandClaude Opus 5.5 ed2d455bf1 Launcher: Rust 1.100's needless_bool in is_link_or_junction
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 19:12:27 +00:00
rdbt 6dd6357962 Merge pull request #15555 from KBroneZ/claude/up-12038b
A sulfur cube's emptied body slot is not saved from its earlier NBT (#12038)
2026-11-12 19:11:27 +00:00
rdbt 6416121902 Merge pull request #15614 from Akirabane/fix/management-player-id-whole-uuid
Management protocol: a player id is a whole 8-4-4-4-12 UUID, as vanilla's PlayerDto reads it
2026-11-12 19:11:21 +00:00
rdbt eb8b298f9e Merge pull request #14195 from RLC248/fix/predicate-terms-holder-forms
A predicate's terms may be one holder alone or a tag (part of #12711)
2026-11-12 19:11:13 +00:00
rdbt c5127a8777 Merge pull request #15603 from Akirabane/fix/init-validates-config
horizon-server init: refuse a port or world name that server.toml would refuse
2026-11-12 19:10:06 +00:00
rdbt 911bbf054b Merge pull request #15605 from Akirabane/fix/backup-find-whole-label
horizon-server: restore and backups find a backup by its whole label
2026-11-12 19:10:01 +00:00
rdbt f646074830 Merge pull request #14712 from Skr1zzzz/falling-spawn-lookup
Client: spawning a falling block or TNT no longer walks the whole list
2026-11-12 19:09:56 +00:00
rdbt 5e3b0f03b6 Merge pull request #15599 from Akirabane/fix/idle-kick-not-during-end-credits
Dedicated server: no idle kick while the End poem is showing, as vanilla
2026-11-12 19:09:50 +00:00
rdbt bb64b215fe Merge pull request #15720 from angel2rider/fix/15494-glsl-feature
fix(shaders): compile the GLSL frontend independently
2026-11-12 19:08:36 +00:00
rdbt 779ea29839 Merge pull request #15670 from ilguerro/fix/stonecutter-scroll-reset
Stonecutter: the recipe list goes back to the top when its input changes
2026-11-12 19:08:32 +00:00
rdbt 74fd49ced0 Merge pull request #15596 from Akirabane/test/anticheat-e2e-log-off
anticheat_e2e: log = "" writes no flag log and the checks still act (log-off)
2026-11-12 19:07:21 +00:00
rdbt 8cf76b5ced Merge pull request #15669 from ilguerro/fix/fish-sounds-voice-sting
Fish play their sounds at their voice pitch, and silent fish play none
2026-11-12 19:06:03 +00:00
rdbt 53759e036a Merge pull request #15616 from Akirabane/fix/console-ban-already-banned
Dedicated console: /ban and /ban-ip of someone banned already change nothing, as vanilla
2026-11-12 19:05:57 +00:00
rdbt 950b9fed63 Merge pull request #15655 from TiggerTakkyTTT/fix/flint-steel-double-sound
Fix duplicate flint and steel sounds for guests
2026-11-12 19:05:47 +00:00
rdbtCVSandClaude Opus 5.5 01f8706d10 Mob islands: grouped turns reach no trample or block contact hook
A mob's turn calls native_trample (farmland) and the native_block_* contacts (a content mod's
blocks) from the movement code, on the group's own thread. Like the other hooks that hand a value
back, they are skipped in a grouped turn, and a quiet mod must not hook them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 19:00:49 +00:00
rdbtCVSandClaude Opus 5.5 ba4e4cf377 Mob islands: bounded reaches instead of serial fallbacks
The parallel mob tick (#14457) no longer falls back to serial for pets,
raids, changed speeds, teleporting meals, withers, wardens, the tick
profiler or quiet native mods. Each case gets a bounded reach or joins
the mobs involved into one island:

- Pets: the owner's teleport destination box is a second place of the
  pet's island, so it joins the mobs near its owner and none between.
- Raids: islands() already joins a raid's raiders; verify compares
  mobs without the process-wide hurt counter.
- Speed, jump and flying speed: a per-mob locomotion bound from the
  attributes and effects at their highest, derived in docs/mob-islands.md.
- Teleport consumables: half the item's TeleportRandomly diameter, and
  the body reaches down as far as the mob can fall.
- Wither and warden: the ids they keep are links.
- Native mods: a new opt-in hook, native_mob_phase_quiet; while every
  mod says it is quiet, grouped turns skip the hooks that hand a value
  back and replay living_jump and damage_post at the join, in order.
- Tick profiler: each island thread samples into its own sums, merged
  at the join.

native_hooks.rs may not grow, so mod_sound_ok, mod_particles_ok and
sound_id move unchanged to native_level_effects.rs, their main user.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 19:00:49 +00:00
rdbt c442809fa7 Merge pull request #13970 from horizon-rust/fix/sprite-mcmeta-trailing-text
Read a sprite's .mcmeta up to its first value, as vanilla does
2026-11-12 18:55:59 +00:00
rdbtCVSandClaude Sonnet 5.5 af682746e0 Merge main into branch
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 18:40:53 +00:00
rdbtCVSandClaude Sonnet 5.5 f34daa1a75 Merge main into branch
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 18:40:53 +00:00
rdbtCVSandClaude Sonnet 5.5 130d3ce7b6 Merge main into branch
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 18:40:53 +00:00
rdbt 4acdda54f2 Merge pull request #15690 from Akirabane/fix/console-runs-as-server-source
The dedicated console and RCON run as the server's own source, named Server or Rcon, as vanilla's do
2026-11-12 18:40:47 +00:00
rdbt a1c4de50b1 Merge pull request #15607 from Akirabane/fix/management-permission-level-clamp
Management protocol: a permission level is clamped as vanilla's codec reads it
2026-11-12 18:40:41 +00:00
rdbt 3b48fdf7c4 Merge branch 'main' into claude/up-12038b 2026-11-12 18:38:52 +00:00
rdbt 02b94e5b12 Merge branch 'main' into falling-spawn-lookup 2026-11-12 18:38:48 +00:00
rdbt 51a4fe79e6 Merge branch 'main' into fix/fish-sounds-voice-sting 2026-11-12 18:38:44 +00:00
rdbt cd0f850c12 Merge branch 'main' into fix/idle-kick-not-during-end-credits 2026-11-12 18:38:41 +00:00
rdbt 97fc687ceb Merge branch 'main' into fix/console-ban-already-banned 2026-11-12 18:38:38 +00:00
rdbt f7fce57158 Merge branch 'main' into fix/15494-glsl-feature 2026-11-12 18:38:35 +00:00
rdbt fb50a7ad88 Merge branch 'main' into fix/stonecutter-scroll-reset 2026-11-12 18:38:33 +00:00
rdbt db82ec5ba0 Merge branch 'main' into cem-rot-radians 2026-11-12 18:38:30 +00:00
rdbt 0f93711e13 Merge branch 'main' into fix/management-player-id-whole-uuid 2026-11-12 18:38:27 +00:00
rdbt 2524bc9ea2 Merge branch 'main' into fix/sprite-mcmeta-trailing-text 2026-11-12 18:38:24 +00:00
rdbt fcfa91e598 Merge branch 'main' into fix/predicate-terms-holder-forms 2026-11-12 18:38:17 +00:00
rdbt deb3b4fec3 Merge branch 'main' into fix/flint-steel-double-sound 2026-11-12 18:38:15 +00:00
rdbt 3fd1593ca6 Merge branch 'main' into fix/launcher-safe-name-devices 2026-11-12 18:38:09 +00:00
rdbt 95d99935ea Merge pull request #15745 from rdbtCVS/fix/studio-needless-bool
CI: fix two Rust 1.100 lints that fail main's verify
2026-11-12 18:37:35 +00:00
rdbtCVSandClaude Opus 5.5 31fc9ce21d CI: two Rust 1.100 lints on main (needless_bool in studio.rs, unreachable_code without audio)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 18:35:26 +00:00
rdbt 72f92a5ef7 Merge pull request #15594 from Akirabane/test/anticheat-timer-tick-rates
Anticheat timer: test the balance at tick rates other than 20
2026-11-12 18:35:11 +00:00
rdbt 914d5e664d Merge pull request #15644 from Akirabane/fix/server-perf-block-s-linux-only
server_perf: build block_s only on Linux, where its only users are
2026-11-12 18:31:55 +00:00
rdbt 23b4c9c411 Merge pull request #15671 from Akirabane/fix/relay-host-ping-schedule
relay: a host is pinged every 20 s whatever joins arrive, so join traffic cannot end a live host
2026-11-12 18:23:58 +00:00
rdbt 95e346c9ad Merge pull request #15586 from Akirabane/test/anticheat-e2e-combat-restart
anticheat_e2e: a player's kicks are kept across a restart (combat-restart)
2026-11-12 18:23:43 +00:00
rdbt 6f478a6cad Merge pull request #13981 from horizon-rust/fix/feed-particles-tadpole-panda-dolphin
Feeding shows its particles: tadpole slime ball, panda love hearts, dolphin treasure
2026-11-12 18:22:36 +00:00
rdbt 737f81c32d Merge pull request #15656 from Akirabane/fix/ban-date-format-as-simpledateformat
Ban and user cache dates are read as vanilla's SimpleDateFormat reads them
2026-11-12 18:22:28 +00:00
rdbt 408c08e029 Merge pull request #15646 from Akirabane/fix/play-cookie-response-disconnects
LAN and dedicated: a cookie response in play disconnects the player, as vanilla
2026-11-12 18:22:22 +00:00
rdbt 44c7007125 Merge pull request #15601 from Akirabane/fix/stopped-op-permission-level
horizon-server: a stopped op and init --op use op-permission-level, as vanilla's op does
2026-11-12 18:22:13 +00:00
rdbt 551cb3a741 Merge pull request #15592 from Akirabane/fix/pause-when-empty-sprint
Dedicated server: a /tick sprint does not count toward pause-when-empty, as vanilla counts it
2026-11-12 18:22:08 +00:00
rdbt 683603fe21 Merge pull request #15564 from Akirabane/fix/login-unexpected-query-answer
LAN and dedicated login: a query answer or cookie response nobody asked for is refused, as vanilla refuses it
2026-11-12 18:22:02 +00:00
rdbt 5b29177859 Merge pull request #15512 from yahya-nusseibeh/fix/query-challenge-cap
Query: cap the challenges held between prunes
2026-11-12 18:21:55 +00:00
rdbt 3d6224deb4 Merge pull request #15503 from Mayonesu0/up-harness-windows
wire_stream harness runs on Windows: natives, console encoding, .exe
2026-11-12 18:21:49 +00:00
rdbt d1613f705f Merge pull request #15674 from Akirabane/fix/negative-block-sequence-dropped
LAN and dedicated: a block use with a negative sequence is dropped, as vanilla's throw drops it
2026-11-12 18:21:41 +00:00
rdbt e78022f152 Merge pull request #15664 from Akirabane/fix/backup-hand-name-not-auto
horizon-server: a backup made by hand cannot take the name auto, which prune deletes
2026-11-12 18:21:36 +00:00
rdbt 5c4cdb41f6 Merge pull request #15580 from Akirabane/test/anticheat-e2e-off-acting-checks
anticheat_e2e: off also covers the checks that act by default
2026-11-12 18:21:27 +00:00
rdbt 3cac36d0f1 Merge pull request #15577 from Akirabane/fix/anticheat-timer-settings-check
Anticheat: refuse a negative or non-finite timer tolerance_ms or max_lag_ms
2026-11-12 18:21:22 +00:00
rdbt 50d2c4f3e8 Merge pull request #15666 from ilguerro/fix/grindstone-damage-component
Refuse grindstone and anvil inputs without a damage component
2026-11-12 18:14:31 +00:00
rdbt 5e89bcee59 Merge pull request #15661 from ilguerro/fix/happy-ghast-cloud-heal
heal a happy ghast in the clouds every second
2026-11-12 18:14:27 +00:00
rdbt 97aa3cf252 Merge pull request #15590 from TiggerTakkyTTT/fix/flat-light-view
Stop flat and debug worlds retrying light views
2026-11-12 18:14:21 +00:00
rdbt 0ae3420fd5 Merge pull request #15509 from yahya-nusseibeh/fix/panel-login-lockout
Panel: wrong login codes no longer lock out the right one
2026-11-12 18:14:15 +00:00
rdbt 20b1949dbd Merge pull request #15498 from Mayonesu0/up-14807-wire-stream-dig-teleport
wire stream: a teleport's answer ticks keep the position reminder on the script's clock
2026-11-12 18:14:10 +00:00
rdbt 5db98af340 Merge pull request #14387 from martipops/agent/pack-options-shell
Pack Options on the settings shell
2026-11-12 18:14:05 +00:00
rdbt 00b3fa3db7 Merge pull request #14168 from MSedgeMC/fix/block-entity-pos-from-tag
A block entity saved in another chunk moves into the chunk that holds it
2026-11-12 18:14:00 +00:00
rdbt 9b2f425f42 Merge pull request #15618 from Bl4ckspell7/refactor/gfx-in-flight-module
Renderer: frames in flight move out of gfx.rs into gfx/in_flight.rs
2026-11-12 18:13:54 +00:00
rdbt a0a2a53279 Merge pull request #15510 from yahya-nusseibeh/fix/prune-spawn-radius-bound
Prune: a spawn radius past i32 keeps everything instead of nothing
2026-11-12 18:13:49 +00:00
rdbt 13d6ba09ad Merge pull request #15460 from Mayonesu0/up-relbinary-save-reload
entity_save_reload: resolve a relative --binary against the caller's directory
2026-11-12 18:13:45 +00:00
rdbt 1352566ab3 Merge pull request #15452 from Ragbie/tests/world-test-lifecycle-forced-chunk
world_test_def_lifecycle reads a chunk only /forceload holds, and runs automatically again
2026-11-12 18:13:40 +00:00
rdbt ca80643fda Merge pull request #14837 from Xmilon/xmilon/dim-sea
Sea level follows the level's generator
2026-11-12 18:13:36 +00:00
rdbt e9faef471e Merge pull request #14595 from martipops/fix/horizon-preset-name
Graphics: Horizon's own preset row is named Horizon Preset
2026-11-12 18:13:30 +00:00
rdbt 7beafea5e1 Merge pull request #15589 from Akirabane/docs/anticheat-three-sentences
docs/anticheat.md: three sentences that disagreed with the code
2026-11-12 18:13:24 +00:00
rdbt 9e840cecfd Merge pull request #15525 from Mayonesu0/up-15484-pufferfish-contact
Sync randoms before each pufferfish mob contact case
2026-11-12 18:13:18 +00:00
rdbt 78f6ac5536 Merge pull request #14930 from Detimes/perf-effect-meta
Perf: memoise the effect particle lookup in the hub
2026-11-12 17:58:06 +00:00
rdbt 5ca5e36230 Merge pull request #14780 from malithedeveloper/fix/atlas-single-sprite-renames
Resource packs: a renamed atlas sprite reads its resource's texture
2026-11-12 17:58:02 +00:00
rdbt 3e5a89b088 Merge pull request #14580 from DUzzL/fix/14454-minecart-shulker
Let rideable minecarts pick up shulkers beside the rail
2026-11-12 17:57:58 +00:00
rdbt 0609305694 Merge pull request #15511 from yahya-nusseibeh/fix/service-unit-paths
Service: refuse paths a systemd unit would misread
2026-11-12 17:57:53 +00:00
rdbt b723023664 Merge pull request #15445 from AlfaRojo/parity/allay-pickup-cooldown-back
allay_pickup_cooldown back in parity runs; the allay healing test reads the pickup cooldown where it lives
2026-11-12 17:57:47 +00:00
rdbt a5f6b3ba0e Merge pull request #14973 from vermingov/fix/goat-horn-byte-14970
A goat's horn flags are read as bytes (#14970)
2026-11-12 17:57:43 +00:00
rdbt 66129b4902 Merge pull request #14590 from DUzzL/fix/14370-fixture-neighbourhood
Load the 5x5 neighbourhood in prepared-data test fixtures
2026-11-12 17:57:38 +00:00
rdbt ac2603ed41 Merge pull request #15529 from rdbtCVS/t3/explosion-detonate-entities
Native mods: native_explosion_entities spares entities from a blast
2026-11-12 17:51:00 +00:00
rdbt 58d42c41ed Merge branch 'main' into fix/feed-particles-tadpole-panda-dolphin 2026-11-12 17:49:13 +00:00
rdbtandCodex gpt-6.1-sol dffce9c48d Merge pull request #15635 from kacpherek/fix/vanilla-baby-piglin-keeps-ingot
Merge #15635 after current verify, review, and discussion checks.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-12 17:47:04 +00:00
rdbtandCodex gpt-6.1-sol 42e897cc73 Merge pull request #15632 from kacpherek/fix/vanilla-enchant-stack
Merge #15632 after current verify, review, and discussion checks.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-12 17:46:59 +00:00
rdbtandCodex gpt-6.1-sol ebd4205ea6 Merge pull request #15628 from kacpherek/fix/vanilla-gaze-disguise
Merge #15628 after current verify, review, and discussion checks.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-12 17:46:55 +00:00
rdbtandCodex gpt-6.1-sol 14859c67b7 Merge pull request #15499 from kacpherek/fix/7922-tessellation-fallback
Merge #15499 after current verify, review, and discussion checks.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-12 17:46:50 +00:00
rdbtandCodex gpt-6.1-sol 542c7c239e Merge pull request #15344 from Web-mc1/fix/item-count-max-stack
Merge #15344 after current verify, review, and discussion checks.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-12 17:46:45 +00:00
rdbtandCodex gpt-6.1-sol 93d98006a8 Merge pull request #14961 from SamB440/fix/player-rotation-packet-parity
Merge #14961 after current verify, review, and discussion checks.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-12 17:46:41 +00:00
rdbtandCodex gpt-6.1-sol 12b96b27c3 Merge pull request #14899 from NikitaMGrimm/shader-api-entity-stage
Merge #14899 after current verify, review, and discussion checks.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-12 17:46:35 +00:00
rdbtandCodex gpt-6.1-sol 5b912979d4 Merge pull request #14758 from ilguerro/perf/worldgen-noise-masks
Merge #14758 after current verify, review, and discussion checks.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-12 17:46:30 +00:00
rdbtandCodex gpt-6.1-sol e518cb868d Merge pull request #14687 from Mayonesu0/up-14641-stack-components
Merge #14687 after current verify, review, and discussion checks.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-12 17:46:25 +00:00
rdbtandCodex gpt-6.1-sol 3c1027d1e1 Merge pull request #14684 from vnsmoreira/fix/end-crystal-saved-fall
Merge #14684 after current verify, review, and discussion checks.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-12 17:46:20 +00:00
rdbtandCodex gpt-6.1-sol ae8e375feb Merge pull request #14578 from ilguerro/fix/jukebox-song-reload
Merge #14578 after current verify, review, and discussion checks.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-12 17:46:15 +00:00
rdbtandCodex gpt-6.1-sol 7e8805bb61 Merge pull request #14558 from voidsipp/split/11535-pending-entity-provenance-20261109
Merge #14558 after current verify, review, and discussion checks.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-12 17:46:11 +00:00
rdbtandCodex gpt-6.1-sol fb089da76c Merge pull request #14459 from ilguerro/fix/wither-star-lifetime
Merge #14459 after current verify, review, and discussion checks.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-12 17:46:06 +00:00
rdbtandCodex gpt-6.1-sol d162b9f5e5 Merge pull request #14349 from Akirabane/feat/7255-particle-frustum
Merge #14349 after current verify, review, and discussion checks.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-12 17:46:02 +00:00
rdbtandCodex gpt-6.1-sol 748c6ba3d5 Merge pull request #14150 from RLC248/fix/thrown-potion-level-entity
Merge #14150 after current verify, review, and discussion checks.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-12 17:45:57 +00:00
rdbtandCodex gpt-6.1-sol 3665978732 Merge pull request #14149 from horizon-rust/layers/extras-tweaks
Merge #14149 after current verify, review, and discussion checks.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-12 17:45:52 +00:00
rdbtandCodex gpt-6.1-sol 5364cb4ebc Merge pull request #14839 from DUzzL/fix/14804-frame-motion
Merge #14839 after current verify, review, and discussion checks.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-12 17:45:45 +00:00
rdbt 855e215c43 Merge branch 'main' into fix/sprite-mcmeta-trailing-text 2026-11-12 17:45:43 +00:00
rdbtandCodex gpt-6.1-sol 7c8f5e38a6 Merge pull request #14663 from vnsmoreira/fix/painting-on-ground
Merge #14663 after current verify, review, and discussion checks.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-12 17:45:39 +00:00
rdbtandCodex gpt-6.1-sol 108bf1dd93 Merge pull request #14258 from vnsmoreira/fix/raw-entity-flags
Merge #14258 after current verify, review, and discussion checks.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-12 17:45:33 +00:00
rdbtandCodex gpt-6.1-sol 3fc31df485 Merge pull request #11261 from Ragbie/fix/hanging-item-stack-config
Merge #11261 after current verify, review, and discussion checks.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-12 17:45:28 +00:00
rdbtandCodex gpt-6.1-sol 633d28079b Merge pull request #14852 from vermingov/fix/evoker-fangs-fall-distance-14847
Merge #14852 after current verify, review, and discussion checks.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-12 17:44:41 +00:00
rdbt e5efe9dc8d Merge pull request #14301 from Ragbie/tools/fall-clip-wait-neighbourhood
vehicle/mob fall clip wait for the forced chunk's 2-column neighbourhood
2026-11-12 17:44:03 +00:00
rdbtCVSandClaude Opus 5.5 2b8ce785e1 Explosions: list the entities for mods only while native mods are loaded
The spared ids are a set, so a blast over many entities stays linear.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 17:27:10 +00:00
rdbt d52edf0341 Merge pull request #15489 from kacpherek/fix/stack-dump-test-race
Fix the stack dump test that fails one verify run in three
2026-11-12 17:22:19 +00:00
Kacper 852dcc5416 Merge pull request #14476 from vnsmoreira/fix/vex-no-gravity
A vex turns its gravity off every tick
2026-11-12 18:18:06 +01:00
Kacper 62d8085479 Merge branch 'main' into fix/vex-no-gravity 2026-11-12 17:56:51 +01:00
angel2riderandGPT 6.1 Sol 72627408bc fix(shaders): compile the GLSL frontend independently
Fixes #15494.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-12 16:42:31 +00:00
AkirabaneandClaude Opus 5.5 91c0bf1f42 Dedicated console and RCON: commands run as the server's source, as vanilla
The console ran each command with no source, so it ran as the player in
place: `/me waves` said "* Player waves", `teammsg` looked for that player's
team, and relative coordinates (`~ ~ ~`) were that player's position. Vanilla's
console source (MinecraftServer.createCommandSourceStack) has no entity, is
at the world spawn's lower corner and is named "Server"; an RCON client's
(RconConsoleSource) is named "Rcon". as_console_in_overworld now runs the line
with the server's source (feedback heard, as command_harness's console does),
and say, me and the operators' line name it "Server" or "Rcon".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 15:32:55 +01:00
AkirabaneandClaude Sonnet 5.5 c26187a6f0 A use packet with a negative block sequence is dropped before the hub, as in vanilla
ServerGamePacketListenerImpl.handleUseItemOn and handleUseItem call
ackBlockChangesUpTo(sequence) right after the client-loaded check, and it
throws IllegalArgumentException("Expected packet sequence nr >= 0") for a
negative number. The packet processor catches it and only logs "Failed to
handle packet ..., suppressing error": nothing else in the handler runs, the
idle timer is not reset and the player stays. Horizon passed such a packet to
the hub and the game, which used the item or the block. PlayConn::packet now
drops a UseItem or UseItemOn whose sequence is negative.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 14:46:02 +01:00
TiggerTakkyTTTandCodex 5a135748c8 Fix duplicate flint and steel sounds for guests
Co-Authored-By: Codex <[email protected]>
2026-11-12 20:39:38 +07:00
AkirabaneandClaude Sonnet 5.5 5208a5385b Relay: guest joins no longer hold back a host's pings
The relay pinged a host only after ping_every of quiet: every join line it
sent to the host restarted that wait. The host speaks on its control
connection only to answer a ping, so joins arriving less than ping_every
apart for host_timeout left it silent and the relay dropped a host that was
alive, retiring its code (later guests got `ended`). A ping now goes out
every ping_every whatever else is sent meanwhile.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 14:39:32 +01:00
AkirabaneandClaude Sonnet 5.5 d3c30ae2b0 Server: a hand backup cannot take the automatic backups' name
`horizon-server backup auto` wrote `<time>-auto.zip`, byte for byte the name
of an automatic backup, so the next automatic backup's prune deleted it once
it was beyond `keep`, although backups made by hand are never deleted. `auto`
is now refused as a name for a hand backup, with a message saying why; the
automatic backups are named by auto_name, which no longer goes through that
check.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 14:31:33 +01:00
AkirabaneandClaude Opus 5.5 eb44631ea7 Server files: read a ban or cache date's zone as SimpleDateFormat does
BanListEntry and CachedUserNameToIdResolver parse their dates with
SimpleDateFormat("yyyy-MM-dd HH:mm:ss Z", Locale.ROOT). Its zone field
takes +hhmm with hours to 23 and minutes to 59, GMT alone or
GMT+h:mm / GMT-hh:mm, and UTC, in any case, and leaves what follows
unread. parse_date took any four digits after a sign and nothing else,
so "+2400" was a date and "... UTC" was not: an expiry written with UTC
or GMT became a permanent ban.

zone_offset reads the zone that way; the test's values are those
vanilla's own format gave.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 14:30:24 +01:00
Kacpherek 53209c8800 Keep a hit baby piglin's admired gold ingot
PiglinAi.stopHoldingOffHandItem deletes the off-hand gold ingot (without
bartering) only for an adult. A baby tries to equip it, then holds it in
its main hand. stop_admiring dropped it for any age.
2026-11-12 13:43:50 +01:00
Kacpherek 5700ead75f Enchant loot and trade stacks by their own enchantable component
EnchantmentHelper.enchantItem asks the stack for DataComponents.ENCHANTABLE, so
a stack that changed or removed it is not enchanted as the plain item.
2026-11-12 13:43:37 +01:00
Kacpherek e3b03d4adc Read the gaze disguise from the item tag
LivingEntity.PLAYER_NOT_WEARING_DISGUISE_ITEM checks the helmet against
#gaze_disguise_equipment, which Enderman.isBeingStaredBy and the creaking
both use, not the carved pumpkin by name.
2026-11-12 13:43:29 +01:00
SenjinandClaude Opus 5.5 387fc29cb8 Custom entity models read rot_x, rot_y and player_rot_x/y in radians
EMF gives them in radians, the entity ones lerped within the tick
(EMFMath.getEntityRX/RY, getPlayerRX/RY). Degrees made a pack's
turn-in-place counter-rotation spin mobs round several times.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 13:42:46 +01:00
Bl4ckspellandClaude Opus 5.5 16448c5ff1 refactor(gfx): move frames in flight into gfx/in_flight.rs
gfx.rs is over the 3000-line limit and may not grow, and the 4x terrain MSAA (#15479) adds to it. The impl block from after_submit to take_readback moves unchanged; the five private methods gfx.rs still calls become pub(super).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 13:10:16 +01:00
AkirabaneandClaude Sonnet 5.5 38f23d16e4 A cookie response in the play state disconnects the player, as vanilla does
ServerCommonPacketListenerImpl.handleCookieResponse disconnects with
multiplayer.disconnect.unexpected_query_response, and the vanilla server asks
for no cookie, so every response is unexpected. Horizon decoded the packet and
let the game drop it. PlayConn::packet now answers it with that disconnect, for
the singleplayer owner too (a keep-alive has an owner exemption, this has none).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 13:01:32 +01:00
AkirabaneandClaude Opus 5.5 5b4d583209 Management protocol: a PlayerDto id of 32 undashed digits is refused, as vanilla's codec does
PlayerDto reads its id with UUIDUtil.STRING_CODEC (UUID.fromString):
vanilla answers "Invalid UUID string" for
0123456789abcdef0123456789abcdef (UUIDUtil.LENIENT_CODEC, an int array
or that same string codec, refuses it too). Horizon's parse_uuid also
took the 32 undashed digits; now only the 8-4-4-4-12 form parses.

The test's 32-digit case moves to the refused ones. UUID.fromString's
other forms (five shorter dashed groups, a sign) stay refused here; that
is not in this change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 12:55:40 +01:00
AkirabaneandClaude Sonnet 5.5 ebf8069c57 server_perf: build block_s only on Linux, where its only users are
block_s (the sampler's seconds per time block) is called only from the Linux
sampler, `mod linux` of server_perf/samples.rs, which is built for
target_os = "linux". On every other platform clippy with warnings denied
stopped on `function block_s is never used`. It now carries the same cfg as
its users, so Linux builds exactly what it built before.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 12:53:07 +01:00
AkirabaneandClaude Opus 5.5 1751960d36 Dedicated console: /ban and /ban-ip of someone banned already change nothing
BanPlayerCommands.banPlayers skips a player who isBanned, and
BanIpCommands.banIp refuses an address that is, so a second ban keeps the
first one and answers "Nothing changed". The console's own /ban and /ban-ip
relied on UserList::add, which replaces an entry whose reason differs: a
second ban with another reason replaced the first and said "Banned". They now
check isBanned first (Lists::ban and Lists::ban_ip), as the game's /ban does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 12:42:10 +01:00
AkirabaneandClaude Opus 5.5 35e7459102 Management protocol: a permission level is clamped as vanilla's codec reads it
An OperatorDto's permissionLevel and the value of
serversettings/operator_user_permission_level/set are decoded by
PermissionLevel.INT_CODEC: Codec.INT, then PermissionLevel.byId, whose
ByIdMap clamps. So 5 is 4, -1 is 0 and 2.7 is 2; only a non-number is
refused. Horizon answered invalid params for operators/add|set and an
error for the setter.

permission_level() reads it that way for both; the old test that
expected 5 to be refused now expects 4.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 12:41:14 +01:00
AkirabaneandClaude Sonnet 5.5 b942e89cd6 Server: restore and backups find a backup by its whole label
`restore NAME` takes "the name given to `backup` (the newest with it)", but
find matched any file name that ends with `-NAME.zip`, so `restore test`
took a newer `<stamp>-my-test.zip` over the `<stamp>-test.zip` it named, and
`restore auto` or `restore update` could take a hand-made `nightly-auto` or
a `before-update` backup. The label is the whole text between the time
stamp and `.zip`.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 12:28:59 +01:00
AkirabaneandClaude Sonnet 5.5 f117ec604a Server: init refuses a port or world name that server.toml would refuse
horizon-server init took its port and world name from a flag or answer and
wrote server.toml without checking them, so `--port 0` or `--world a/b`
gave "Wrote server.toml" and then every run failed to load it (Config::check
refuses port 0 and a world name with a slash, `..`, backups, logs or
.horizon). init now runs Config::check on what it will write, right after
the port is set and before anything else is written, and stops with that
message.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 12:23:22 +01:00
AkirabaneandClaude Sonnet 5.5 34dcda27f0 Server: a stopped op and init --op use op-permission-level
PlayerList.op writes ServerOpListEntry(MinecraftServer.operatorUserPermissions()),
and DedicatedServer.operatorUserPermissions() is the op-permission-level
property. horizon-server's stopped `op` and `init --op` wrote level 4 whatever
the property said; they now write its level (0 to 4, 4 when unset), read as
the game reads it at its next start: server.properties with server.toml's
keys written over it.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 12:19:35 +01:00
AkirabaneandClaude Opus 5.5 ee8b7b891a Dedicated server: no idle kick while the End poem is showing
ServerGamePacketListenerImpl.tick kicks an idle player only when
!player.wonGame: a client showing the credits sends no action until it
asks to respawn. Horizon's kick_idle kicked such a player once
player-idle-timeout passed.

The decision moves into idles_out, which also takes the guest's
won_game, so it is tested without a running server.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 12:19:16 +01:00
9a314a0b5a Fish play their sounds at their voice pitch, and silent fish play none
`Pufferfish.tick` and `AbstractFish.aiStep` go through
`LivingEntity.makeSound`, which plays `Entity.playSound(sound,
getSoundVolume(), getVoicePitch())`: two random draws for the pitch, at the
entity's feet, and nothing when the entity is silent. The raw `Mob::sound`
helper played at eye height, at pitch 1.0, and ignored `Silent`, so a
`{Silent:1b}` pufferfish still blew up and out and a flop never varied.

`Pufferfish.playerTouch` sends `ClientboundGameEventPacket.PUFFER_FISH_STING`
only inside the `hurtServer` branch and only when the fish is not silent; the
sting for the player now comes from there, as a game event for a guest and as
the local play for the host, instead of the fish's AI tick playing it every
tenth tick whether or not any damage landed.

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 12:09:41 +01:00
TiggerTakkyTTTandCodex 68ac8880d3 Fix light views for flat and debug worlds
Flat and debug generator protos already contain their decorated columns, so expose a copy for lighting neighbours outside the kept area. This prevents the chunk pump from retrying impossible light-view jobs indefinitely.

Co-Authored-By: Codex <[email protected]>
2026-11-12 18:03:39 +07:00
AkirabaneandClaude Opus 5.5 717b8d192f anticheat_e2e: log = "" writes no flag log and the checks still act (log-off)
docs/anticheat.md says an empty `log` writes no file, and no e2e case set
it. The new log-off case runs a survival server with `[anticheat] log = ""`
and a bot attacking without swinging: it is still kicked, the console still
has the [Anticheat] warning of the kick, and logs/anticheat.log is never
written.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 12:02:36 +01:00
AkirabaneandClaude Opus 5.5 7cd92e310d Dedicated server: a /tick sprint does not count toward pause-when-empty
MinecraftServer.tickServer counts a tick as empty only with no player
and no sprint running (ServerTickRateManager.isSprinting), and sets the
count back to 0 otherwise. Horizon counted sprinting ticks, so a sprint
on an empty server reached the pause almost at once and then stayed
stuck: the paused tick does not advance it.

The count moves into count_empty_tick, which also takes the sprint, so
the decision is tested without a running server.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 11:56:24 +01:00
AkirabaneandClaude Opus 5.5 541ef4e5fa Anticheat timer: test the balance at tick rates other than 20
Every timer test ran at 20 ticks a second, although Balance::tick_end takes
1000 / rate ms per client tick and the hub passes the server's rate. The new
test runs a client on time at /tick rate 10 and 60 for ten minutes (never
flagged), 1.5 times as fast (flagged again and again), and 20 ticks a second
on a server at 10 (flagged).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 11:56:23 +01:00
AkirabaneandClaude Opus 5.5 baa43ddf05 docs/anticheat.md: three sentences that disagreed with the code
- A wrong [anticheat] section makes horizon-server start and run refuse to
  start (the error names the key); it is the game, reading server.toml
  itself, that turns the anticheat off with an error.
- ground bans by default too (ban_after = 2), not only combat.
- A kick or a ban always reaches the console; the log line needs a log file
  (log = "" writes none) and the operators' line needs alerts = true.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 11:54:45 +01:00
AkirabaneandClaude Opus 5.5 337b8a751c anticheat_e2e: a player's kicks are kept across a restart (combat-restart)
docs/anticheat.md says a check's kicks are counted across sessions and
restarts, but check_combat bans on a second session of the same running
server. The new combat-restart case kicks a no-swing bot once, checks that
anticheat-strikes.json holds one combat strike, stops the server and starts
it again on the same folder, and expects the same bot, cheating again, to be
banned with the count back at 0. start() reuses a folder that is already
there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 11:44:37 +01:00
AkirabaneandClaude Opus 5.5 f31921a9a3 Anticheat: refuse a negative or non-finite timer tolerance_ms or max_lag_ms
Config::check refused negative thresholds and a prediction tolerance of 0 or
less, but let any [anticheat.timer] tolerance_ms and max_lag_ms through. A
negative tolerance_ms flags a client on time every tick; a max_lag_ms below
-tolerance_ms does too, as the balance rests at the cap; NaN never flags, or
never caps the balance. Both are now numbers of milliseconds from 0 up,
checked by horizon-server before a start and by the game when it reads
server.toml.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 11:22:56 +01:00
AkirabaneandClaude Opus 5.5 1aa472967f anticheat_e2e: off also covers the checks that act by default
check_off ran only the timer check's 1.5x bot, so nothing showed that
`enabled = false` silences combat and ground, the two checks that drop, kick
and ban by default. It now also runs, on a survival server with allow-flight on
(vanilla's floating kick would end the no-fall bot on its own), the no-swing,
two-target and no-fall bots of check_combat and check_ground: none is flagged,
kicked or banned, no anticheat log is written, and their hits land.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 11:15:04 +01:00
Riccardo GuerrieroandClaude Opus 5.5 cc1fea2714 Leave the six hand-run proof scripts out of parity runs
tools/parity.py runs every tools/test_*_parity.py with no arguments. Six of
them are two-step proofs that need arguments (a Java side, then a native
side against its reference): builtin_component_delivery,
entity_movement_predicate, guest_close_event, machine_recipe_placement,
predicate_reference_startup and self_pose_metadata. Run bare, each exits
in 0 s with a usage error, so every parity-main run since they landed
(11-07 to 11-09) has failed on them, and the Nightly waits for a green
parity-main.

A script can now declare PARITY_MANUAL = "<how to run it>": parity.py
leaves it out of every run (--changed and parity-main included) and,
asked for it by name, says how to run it. The six declare it; nothing
else about them changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Fekd42tRMKJk8JighAmETj
2026-11-12 11:08:41 +01:00
AkirabaneandClaude Sonnet 5.5 40ca777682 LAN and dedicated login: refuse a query answer or cookie response nobody asked for
In login_and_configure, while the server waits for LoginAcknowledged, a
CustomQueryAnswer or a CookieResponse was skipped and the login went on to
configuration. Vanilla's ServerLoginPacketListenerImpl.handleCustomQueryPacket
and handleCookieResponse both disconnect with
ServerCommonPacketListenerImpl.DISCONNECT_UNEXPECTED_QUERY
(multiplayer.disconnect.unexpected_query_response). The server sends no query
or cookie request at that point (its one query, the proxy forwarding request,
is sent and answered in forward() before LoginFinished), so any answer is
unexpected. It now sends the login disconnect through refuse(), logs the loss
of the connection and ends the login. Those two packets were the only ones the
wait skipped, so it no longer loops: it reads one packet.

Tests: a query answer and a cookie response each get the disconnect and the two
log lines; a login with nothing stray still reaches configuration.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 10:41:16 +01:00
036dc77592 Stonecutter: the recipe list goes back to the top when its input changes
`StonecutterScreen.containerChanged` (StonecutterScreen.java:205-209) resets
`scrollOffs` and `startIndex`, and the screen hangs it on the input container's
listener (StonecutterMenu.java:37-44), so it runs on any change of the input
slot: adding to the stack, a shift-click into it, and taking a result, which
uses one up (StonecutterMenu.java:66). Picking a recipe or clicking in the
player's inventory is not such a change.

Horizon reset only when the input was empty or there were no recipes, and ran
that after every menu session, so a cut left the list scrolled to its last row.
The reset now follows the input slot having changed, as the enchanting table
does, and a server's slot packet for the input runs it too
(AbstractContainerMenu.setItem -> Slot.set -> setChanged).

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 10:40:01 +01:00
Kacper 2a3b142012 Merge branch 'main' into fix/vex-no-gravity 2026-11-12 10:36:57 +01:00
rdbtCVSandClaude Sonnet 5.5 6d3692c673 Keep the geometry-stage note and add a tessellation note
The merged note broke the two tests that read the geometry note; give
tessellation its own note and cover it in the post loader test.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 09:26:02 +00:00
AkirabaneandClaude Sonnet 5.5 383b68b576 port map: the frustum rows follow the move and the particle frustum test
Frustum.offset and Frustum.pointInFrustum are now ported (Frustum::pulled_back,
Frustum::contains_point), Frustum.isVisible and Frustum.cubeInFrustum cite the
file Frustum moved to (gfx/view_frustum.rs), and QuadParticleGroup.extractRenderState
names ParticleEngine::render_in_view, which does its frustum point test, so its
"no frustum point test" gap is gone.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 10:22:19 +01:00
KBroneZandClaude Sonnet 5.5 69fdcefa2a fix: a sulfur cube's emptied body slot is not saved from its earlier NBT
mob_to_nbt starts from the entity's earlier saved equipment and only
inserted the body; a body taken off (/item replace ... armor.body with air)
kept its old entry, so /data get still read it. Vanilla writes
equipment.body only while the slot holds something.

Fixes #12038

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 11:22:08 +02:00
rdbtCVSandClaude Sonnet 5.5 59493f4837 Format the block entity order test with rustfmt
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 09:20:12 +00:00
AkirabaneandClaude Sonnet 5.5 0a80c3d42f Test that a particle is culled at its position this tick
kacpherek's commit 061ba23407 made the frustum test read the particle's
position this tick instead of an interpolated one, but no test told the two
apart: the tests' particles all had previous equal to position.

The new test draws at partial 0.5 a particle that is ahead now and was far
behind a tick ago (kept) and one that is behind now and was ahead a tick ago
(dropped). With the test using the interpolated point instead, the first is
dropped and the test fails (left 0, right 4).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 10:17:43 +01:00
805f69c8a9 Refuse grindstone and anvil inputs without a damage component
ItemStack.isDamageableItem needs MAX_DAMAGE, DAMAGE and no UNBREAKABLE
(ItemStack.java:421-423); GrindstoneMenu's two input slots (GrindstoneMenu.java:50-52,
:56-58) and AnvilMenu's repair branches (AnvilMenu.java:133, :151, :157) use it. A
stick patched with max_damage but neither the item's durability default nor an
explicit damage is therefore not damageable, so it can no longer enter the
grindstone, nor merge into a "repaired" copy, nor combine its enchantments with another
such stack on the anvil.

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 09:28:59 +01:00
Kacpherek d78f0c49c9 Merge main into fix/evoker-fangs-fall-distance-14847 2026-11-12 09:21:51 +01:00
9303b88c79 heal a happy ghast in the clouds every second
HappyGhast.continuousHeal heals every 20 ticks when
`isInClouds() || precipitationAt(blockPosition()) != NONE`, but Horizon
only asked the precipitation, so a hurt ghast in clear air at the cloud
height healed on the 600-tick interval.

Entity.isInClouds reads the dimension's `visual/cloud_color` alpha (0:
no clouds at all) and `visual/cloud_height` (192.33 in the overworld)
and tests the entity's box against the four blocks above that height.
DimensionFacts now carries both, parsed from the dimension type's
attributes, and MobWorld::in_clouds ports the check; ai_step asks it
before the precipitation, in vanilla's order.

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 08:41:23 +01:00
Kacper ecc1570388 Merge branch 'main' into fix/vex-no-gravity 2026-11-12 08:35:38 +01:00
rdbtCVSandClaude Opus 5.5 050e90b150 Native mods: native_explosion_entities spares entities from a blast
A new server hook, after native_explosion_detonate: an explosion is about
to hurt and push the entities in its reach, and a mod takes out the ids
to spare (NeoForge's ExplosionEvent.Detonate entity list). Spared
entities are neither hurt nor pushed. Players aren't listed; they keep
native_living_incoming_damage.

native_hooks.rs may not grow, so viewer_changes moves unchanged to
native_menus.rs, beside the menus.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 06:06:39 +00:00
rdbtCVSandClaude Sonnet 5.5 87be63d40a Merge origin/main into portal TNT provenance branch
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 05:57:11 +00:00
Mayonesu0andClaude Opus 5.5 278d0355e9 Sync randoms before each pufferfish mob contact case
The ordinary-AI pufferfish rolls FishSwimGoal on its own random, which
both engines seed from the clock. About one run in ten it started
swimming in one engine only, and its position read failed (#15484).
A sync_randoms step before each fish's summon makes the roll repeat.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 06:52:05 +01:00
martipopsandClaude Opus 5.5 83e0929684 Merge upstream main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 00:47:21 -05:00
rdbtCVSandClaude Sonnet 5.5 e3e866eb41 Merge main into layers/extras-tweaks
Resolves the docs/forking.md and docs/layers.md conflicts: main's rows kept, with game/extras/ in place of game/tweaks.rs.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 05:35:15 +00:00
Kacpherek a4a529a565 Keep save.rs from growing: the order position comes from save/block_entity_order.rs 2026-11-12 06:31:48 +01:00
yahya-nusseibehandClaude Opus 5.5 336b647f63 Launcher: no Windows device names or disguising marks in downloaded file names
safe_name let a mod or pack file named CON, NUL.jar or COM1.zip through: on Windows the
joined path names a device, and the rename from its .part file fails or acts on it. It
also let bidirectional and zero-width marks through, so a shown name could read as
another. Both are now refused, like a path separator.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-12 06:43:22 +02:00
yahya-nusseibehandClaude Opus 5.5 f181472b63 Query: cap the challenges held between prunes
Every handshake from a new address added a token, and the map was cleared only every 30 s,
so spoofed UDP handshakes grew it with the packet rate. At 65,536 addresses a new one gets no
answer until the next prune; an address already holding a token still gets a new one.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-12 06:33:31 +02:00
yahya-nusseibehandClaude Opus 5.5 875b98b71f Service: refuse paths a systemd unit would misread
service install wrote the server folder and the program path into the unit as they were. A %
in a folder name is a systemd specifier, a line break adds a directive, and a quote or a
backslash changes ExecStart's quoting. Such a path is now refused with a plain message.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-12 06:31:43 +02:00
yahya-nusseibehandClaude Opus 5.5 5e177afe1d Prune: a spawn radius past i32 keeps everything instead of nothing
prune_chunks.spawn_radius is a u32 cast to i32, so a value above i32::MAX wrapped negative,
no chunk counted as near spawn and a delete run pruned the spawn area. The radius now
saturates at i32::MAX: as large a radius as asked for, keeping every chunk.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-12 06:29:59 +02:00
yahya-nusseibehandClaude Opus 5.5 59972a243d Panel: wrong login codes no longer lock out the right one
Failed logins were counted for everyone and checked before the code, so ten bad /login
requests a minute from anyone who could reach the panel made the owner's own link fail with
429. The limit now applies only to wrong codes; a valid link always signs in.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-12 06:28:11 +02:00
rdbtCVSandClaude Sonnet 5.5 92eb621473 Merge main into fix/goat-horn-byte-14970
Goat horn flags now read in mobs::goat::read_nbt with the byte-valued rule.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 03:51:12 +00:00
Kacpherek 061ba23407 Cull particles by their current position instead of an interpolated one 2026-11-12 04:41:02 +01:00
Mayonesu0andClaude Opus 5.5 80eca9b7e2 wire_stream harness: run on Windows (natives, console encoding, .exe)
vanilla_client picks the natives jar the launcher's rules keep for this
OS and architecture (natives-windows, natives-windows-arm64, ...) from the
version JSON instead of knowing only macOS and Linux. The JVM writes UTF-8
and the reader replaces stray bytes, so a cp1252/cp850 console no longer
kills it; the log is UTF-8. lab.binary finds horizon-client.exe.

Fixes #15500

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 04:37:34 +01:00
Kacpherek af9e445fbd Reject an item template whose count is outside 1 to 99, as vanilla does 2026-11-12 04:33:16 +01:00
Kacpherek 745274bee2 Skip shader programs with a tessellation stage so their fallback is used
A program with a .tcs or .tes stage was loaded as a plain vertex and
fragment program, silently dropping the tessellation stage. Horizon has
no tessellation backend, so skip such a program with a note, as an
unsupported geometry stage already is, and let the fallback chain pick
the next program.

Fixes #7922
2026-11-12 04:33:15 +01:00
Kacpherek f85db126cc Move the player rotation packet handling out of remote.rs so the file shrinks 2026-11-12 04:32:59 +01:00
Kacpherek 8286150240 Record a moved block entity's load order under its corrected position 2026-11-12 04:31:30 +01:00
Mayonesu0andClaude Opus 5.5 62eb25aa1d wire stream: a teleport's answer ticks keep the position reminder on the script's clock
compare_answered compared the two clients' answer ticks whole. In dig_teleport
(`tp @s ~ ~ ~`) the answer moves nobody, and LocalPlayer.sendPosition's 20-tick
position reminder stays on the script's clock (ClientPacketListener.handleMovePlayer
resets neither positionReminder nor xLast): it falls on tick 27 in both clients.
When the server answers Horizon a tick later than vanilla (26 vs 27), Horizon's
answer tick carries the reminder and vanilla's doesn't, which the nightly reported
as an extra move_player_pos (#14807).

The answer ticks now compare without their moves, and their moves compare tick for
tick, on the script's clock.

Fixes #14807

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 04:26:06 +01:00
Kacpherek c497ba5279 Test that an item frame keeps its loaded Motion, OnGround and fall_distance 2026-11-12 04:20:15 +01:00
Kacpherek 22bfbbd417 Merge commit '571ef142c5' into unstick/14459
# Conflicts:
#	crates/horizon-client/src/game/item_entities.rs
2026-11-12 04:11:50 +01:00
DetimesandClaude Opus 5.5 c950a3dafe Merge the branch's main update from GitHub
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 03:09:32 +00:00
Kacpherek f92b723265 Merge commit '571ef142c5' into unstick/14349
# Conflicts:
#	crates/horizon-client-render/src/particles.rs
2026-11-12 04:06:39 +01:00
DetimesandClaude Opus 5.5 784eb9f185 Merge upstream/main into perf-effect-meta
Keeps main's new cave_air_tests module next to effect_meta_tests.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 03:06:17 +00:00
Kacpherek aa71621cae Merge commit '571ef142c5' into unstick/14684
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-12 04:02:32 +01:00
Kacpherek e829142877 Merge commit '571ef142c5' into unstick/14476
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-12 04:02:07 +01:00
Kacpherek 4ae80fa5ea Merge commit '571ef142c5' into unstick/14258
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-12 04:01:23 +01:00
DetimesandClaude Opus 5.5 4484d84eed Effect particles: cache only the particle type ids, not the colour
An effect's colour comes from the resource packs (set_pack_colors) and a
pack reload replaces it without replacing the hub's SyncData, so a cached
particle kept the old colour. The cache now holds only the registry id of
each particle type; the colour is read on every call. New test: a pack
colour change shows although the id is cached.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 02:37:06 +00:00
Kacpherek 0cc70624e2 Merge commit '24b778c3b4' into unstick/14663
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-12 02:55:10 +01:00
Kacpherek f48bf07992 Merge commit '24b778c3b4' into unstick/14899
# Conflicts:
#	crates/horizon-client/src/game/native_shaders.rs
#	docs/api/shaders.md
2026-11-12 02:52:46 +01:00
Kacpherek 19fa5d003a Merge commit 'aab654d4e8' into unstick/14590
# Conflicts:
#	crates/horizon-client/src/game/item_entities.rs
2026-11-12 02:16:48 +01:00
Kacpherek bad917dea7 Merge commit 'aab654d4e8' into unstick/14663
# Conflicts:
#	crates/horizon-client/src/entity_nbt/hanging.rs
2026-11-12 02:14:00 +01:00
Kacpherek cd1c3ad784 Merge commit 'aab654d4e8' into unstick/14852
# Conflicts:
#	crates/horizon-client/src/entity_nbt.rs
2026-11-12 02:10:43 +01:00
Kacpherek 9d9edcc5c7 Ask for the busy thread's stack only once it is in the busy frame
The stack dump test asked for the worker's stack as soon as the worker's
handle arrived. The worker could still be inside the channel send that
woke the test thread, so the sample sometimes held no busy frame and the
test failed about one verify run in three.
2026-11-12 02:00:36 +01:00
Kacpherek 2106fb21d8 Camel pose test: saved entities go back in as pending entities 2026-11-12 01:30:39 +01:00
Kacpherek 4752a44392 Merge remote-tracking branch 'origin/main' into unstick/14558 2026-11-12 01:29:04 +01:00
Kacper 869df6be8e Merge branch 'main' into fix/atlas-single-sprite-renames 2026-11-12 01:15:51 +01:00
Kacper 10ea380a42 Merge branch 'main' into xmilon/dim-sea 2026-11-12 01:15:08 +01:00
Kacper da19583b0e Merge branch 'main' into fix/14804-frame-motion 2026-11-12 01:14:59 +01:00
Kacper a4bf39fccf Merge branch 'main' into parity/allay-pickup-cooldown-back 2026-11-12 01:13:23 +01:00
Kacper a856c936d6 Merge branch 'main' into tests/world-test-lifecycle-forced-chunk 2026-11-12 01:13:05 +01:00
Kacper 79b225758e Merge branch 'main' into up-relbinary-save-reload 2026-11-12 01:12:21 +01:00
Kacper f26c009854 Merge branch 'main' into fix/block-entity-pos-from-tag 2026-11-12 01:10:41 +01:00
Kacper 783c5d4bd5 Merge branch 'main' into fix/predicate-terms-holder-forms 2026-11-12 01:10:32 +01:00
Kacper 8c2241221b Merge branch 'main' into agent/pack-options-shell 2026-11-12 01:10:05 +01:00
Kacper fb003e9b48 Merge branch 'main' into fix/horizon-preset-name 2026-11-12 01:08:43 +01:00
Kacper e243b417ec Merge branch 'main' into falling-spawn-lookup 2026-11-12 01:08:27 +01:00
Kacper 0e06fcffe8 Merge branch 'main' into fix/player-rotation-packet-parity 2026-11-12 01:05:30 +01:00
Kacper c187167717 Merge branch 'main' into perf/worldgen-noise-masks 2026-11-12 01:04:34 +01:00
Kacper 09449b8b5a Merge branch 'main' into up-14641-stack-components 2026-11-12 01:03:49 +01:00
Kacper 3a44c61b5c Merge branch 'main' into fix/jukebox-song-reload 2026-11-12 01:03:26 +01:00
Kacper adf711981d Merge branch 'main' into split/11535-pending-entity-provenance-20261109 2026-11-12 01:02:59 +01:00
Kacper b140490175 Merge branch 'main' into fix/vex-no-gravity 2026-11-12 01:02:37 +01:00
Kacper 7244070cdf Merge branch 'main' into fix/end-crystal-saved-fall 2026-11-12 01:02:28 +01:00
Kacper 05ed4ff36f Merge branch 'main' into tools/fall-clip-wait-neighbourhood 2026-11-12 01:02:15 +01:00
Kacper 765595fc00 Merge branch 'main' into fix/thrown-potion-level-entity 2026-11-12 01:01:39 +01:00
Kacper 071685a3cf Merge branch 'main' into fix/raw-entity-flags 2026-11-12 01:00:35 +01:00
Kacper 1aef29fada Merge branch 'main' into perf-effect-meta 2026-11-12 00:57:32 +01:00
Kacper 80ef919e40 Merge branch 'main' into fix/14454-minecart-shulker 2026-11-12 00:56:26 +01:00
Your Name ad8e395f3c Merge main into fix/item-count-max-stack 2026-11-12 12:40:00 +13:00
Evrim Cicek f071ffe84b Merge remote-tracking branch 'upstream/main' into tools/fall-clip-wait-neighbourhood 2026-11-12 02:18:39 +03:00
AkirabaneandClaude Sonnet 5.5 525c1c9834 Merge origin/main into feat/7255-particle-frustum
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 00:15:44 +01:00
DetimesandClaude Opus 5.5 627864a666 Merge upstream/main: the effect particle cache moves into SyncData
Main moved Hub::effect_meta into SyncData::effect_meta (read by helper
threads since #14506). The per-(effect, ambient) particle cache follows it:
lan/effect_meta.rs keeps ParticleCache and particle_of, SyncData holds the
cache behind a Mutex and Hub::effect_meta delegates as on main. hub.rs
loses the field and module line instead of growing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 23:08:54 +00:00
AkirabaneandClaude Sonnet 5.5 efaac3cdc0 Merge origin/main into feat/7255-particle-frustum
crates/horizon-client-render/src/gfx.rs: this branch moved Frustum into
gfx/view_frustum.rs (kept, with its mod and pub use); main left Frustum as it was
but moved shadow_box into gfx/external.rs, so the old shadow_box beside Frustum
here is dropped.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 00:00:00 +01:00
Kacpherek 6c090eb39d Merge commit '3298a6dcd5' into unstick/14590
# Conflicts:
#	crates/horizon-client/src/game/persist.rs
2026-11-11 23:56:32 +01:00
Kacper 4e44044828 Merge branch 'main' into fix/thrown-potion-level-entity 2026-11-11 23:56:20 +01:00
Kacper 59319a8e2d Merge branch 'main' into fix/vex-no-gravity 2026-11-11 23:54:59 +01:00
Kacper 3086bc8910 Merge branch 'main' into fix/end-crystal-saved-fall 2026-11-11 23:54:50 +01:00
Kacper 632b455a88 Merge branch 'main' into fix/raw-entity-flags 2026-11-11 23:52:59 +01:00
Kacpherek 4a33f95d69 Merge commit '3298a6dcd5' into unstick/14973
# Conflicts:
#	crates/horizon-client/src/entity_nbt.rs
2026-11-11 23:52:10 +01:00
Kacper 4347efb700 Merge branch 'main' into fix/14454-minecart-shulker 2026-11-11 23:48:50 +01:00
DetimesandClaude Opus 5.5 73e29da806 Merge upstream/main into perf-effect-meta
Resolves the conflict with main (new hub modules chunk_batch and far_tests next to effect_meta).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 22:35:24 +00:00
Mayonesu0andClaude Sonnet 5.5 eb0605533b Resolve the save/reload harness's --binary against the caller's directory
Each server runs with its temporary world as the working directory, so a relative
`--binary target/fast/horizon-client` was looked up under the world and failed even when the
binary existed. Make the path absolute before starting the servers, with a unit test.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 23:24:31 +01:00
Kacper 1db662da23 Merge branch 'main' into fix/player-rotation-packet-parity 2026-11-11 23:21:29 +01:00
Kacper ece1d80195 Merge branch 'main' into up-14641-stack-components 2026-11-11 23:19:42 +01:00
Kacper 858de11740 Merge branch 'main' into fix/jukebox-song-reload 2026-11-11 23:19:00 +01:00
Kacper 3528b8ce41 Merge branch 'main' into split/11535-pending-entity-provenance-20261109 2026-11-11 23:18:34 +01:00
Kacper 2525ab8ff8 Merge branch 'main' into tools/fall-clip-wait-neighbourhood 2026-11-11 23:17:29 +01:00
Kacpherek 8a205c4d53 Merge commit '6cc36c6f3a' into unstick/14758
# Conflicts:
#	crates/horizon-core/src/density/selection.rs
2026-11-11 22:30:33 +01:00
Evrim CicekandClaude Opus 5.5 6980c84dfc parity_hosts: world_test_def_lifecycle runs automatically again
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 00:29:48 +03:00
Kacpherek 6d72da27f6 Merge commit '6cc36c6f3a' into unstick/14150
# Conflicts:
#	crates/horizon-client/src/game/dispenser.rs
2026-11-11 22:29:30 +01:00
Evrim Cicek 18e01a3ba2 Merge remote-tracking branch 'upstream/main' into tests/world-test-lifecycle-forced-chunk 2026-11-12 00:28:49 +03:00
Kacpherek c994a813ec Merge commit '6cc36c6f3a' into unstick/14590
# Conflicts:
#	crates/horizon-client/src/game/persist.rs
2026-11-11 22:26:54 +01:00
Kacpherek ee18d85a3d Merge commit '6cc36c6f3a' into unstick/14973
# Conflicts:
#	crates/horizon-client/src/entity_nbt.rs
2026-11-11 22:26:47 +01:00
Kacpherek 1c7ff0f82d Merge commit '6cc36c6f3a' into unstick/11261
# Conflicts:
#	crates/horizon-client/src/entity_nbt/hanging.rs
2026-11-11 22:26:34 +01:00
Evrim CicekandClaude Opus 5.5 8be0fd72d2 world_test_def_lifecycle reads a chunk only /forceload holds
The step's /execute if loaded read the spot's own chunk. The Java oracle's
player isn't ticked, so its tickets stay on the 5x5 chunks around its
spawn, while Horizon's player loads the chunks around wherever it stands:
once GameTestRunner's batch listener released the forced chunks, spots 56
to 104 were unloaded in Java only (4 of 99 records, #15158). The read is
now of chunk x 11, which only /forceload holds and neither player reaches;
both sides see the release at the same step.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 00:25:03 +03:00
Kacpherek 6e6c114701 Merge commit 'a5df8273bb' into unstick/14558
# Conflicts:
#	crates/horizon-client/src/game/commands.rs
2026-11-11 22:11:36 +01:00
Vinícius Moreira 66493ad0a4 Merge commit 'c8e1eb66199f696d1a6218fbbaeef1863ce5cc00' into fix/raw-entity-flags
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-11 17:57:12 -03:00
Vinícius Moreira c3f130ec76 Merge commit 'c8e1eb66199f696d1a6218fbbaeef1863ce5cc00' into fix/vex-no-gravity
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-11 17:57:11 -03:00
Vinícius Moreira 03ed9279f2 Merge commit 'c8e1eb66199f696d1a6218fbbaeef1863ce5cc00' into fix/painting-on-ground
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-11 17:57:08 -03:00
Vinícius Moreira ceb22dff50 Merge commit 'c8e1eb66199f696d1a6218fbbaeef1863ce5cc00' into fix/end-crystal-saved-fall
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-11 17:57:07 -03:00
Kevin RamirezandClaude Opus 5.5 d025af8ef1 allay_pickup_cooldown back in parity runs; the healing test reads the pickup cooldown where it lives
- allay_pickup_cooldown passes on main now (twice locally with prepared
  resources), so it leaves named_only in parity_hosts.json (#15158).
- allays_heal_on_tenth_ticks_without_enabling_noai_work still set and
  read extra.ints[3], which held ITEM_PICKUP_COOLDOWN_TICKS until it
  moved to AllayState::pickup_cooldown: the test fails on main whenever
  prepared resources are present (CI has none, so it skips). It now
  sets and reads the cooldown through allay::set_pickup_cooldown and
  pickup_cooldown.

Part of #15158

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 14:53:54 -06:00
Kacper 682d70268a Merge branch 'main' into fix/player-rotation-packet-parity 2026-11-11 21:50:24 +01:00
Kacpherek fc67e09538 Merge commit 'a5df8273bb' into unstick/14301
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-11 21:46:22 +01:00
Kacpherek 9ccd03e15c Merge commit 'a5df8273bb' into unstick/14687
# Conflicts:
#	crates/horizon-client/src/game/player_equip.rs
#	crates/horizon-items/src/items.rs
2026-11-11 21:43:23 +01:00
Kacpherek a45820d34e Merge commit 'a5df8273bb' into unstick/14578
# Conflicts:
#	crates/horizon-client/src/game/jukebox.rs
2026-11-11 21:41:09 +01:00
rdbtCVSandOpenAI GPT-6.1 Sol 83583196d4 Merge main into sprite metadata fix
Resolve the shared first_json helper conflict by retaining the generic parser while preserving main's metadata behavior and animation regression test.

Co-Authored-By: OpenAI GPT-6.1 Sol <[email protected]>
2026-11-11 20:23:44 +00:00
DetimesandClaude Opus 5.5 b11f2ea604 Merge upstream/main into perf-effect-meta
Brings the branch up to date with main for review.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 20:20:15 +00:00
Kacpherek c52976236b Merge main into pr-14580
# Conflicts:
#	crates/horizon-client/src/game/minecarts.rs
2026-11-11 20:56:50 +01:00
Kacpherek 8ec11feda8 Merge main into pr-14590
# Conflicts:
#	crates/horizon-client/src/game/last_hurt_by_mob_tests.rs
2026-11-11 20:55:11 +01:00
martipopsandClaude Opus 5.5 3e3ed99fde Merge upstream main into Pack Options on the settings shell
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 14:46:38 -05:00
Kacpherek 56b3fbab8c Merge main into agent/pack-options-shell 2026-11-11 20:42:37 +01:00
Kacpherek dfcfa1a0db Merge main into fix/hanging-item-stack-config 2026-11-11 20:41:38 +01:00
RLC248 1759cc1565 Merge remote-tracking branch 'upstream/main' into fix/thrown-potion-level-entity 2026-11-11 19:55:13 +01:00
Web-mc1 b7414749eb Clamp NBT item count to prevent oversized stacks 2026-11-12 06:35:15 +13:00
RLC248 ffb455858e Merge remote-tracking branch 'upstream/main' into fix/thrown-potion-level-entity
# Conflicts:
#	crates/horizon-client/src/game/tnt.rs
2026-11-11 17:21:25 +01:00
Nikita Maximilian GrimmandClaude Fable 5.2 ca1c65d957 Shader API 0.2: an entity-only pack may ask for its shadow map and lights
`[shadow]`, `[[lights]]` and `[[particle_lights]]` were refused without a
`[material]` (or, for the shadow, a `[[post]]`), though the entity stage reads
the shadow maps and the coloured lights too. A 0.2 pack with only `[entity]`
had to add a stage it didn't want. The three checks now take `[entity]` as a
stage that reads them; 0.1 keeps its rules, and its two light messages are
reworded with the same meaning.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-11 14:41:12 +01:00
Nikita Maximilian Grimm d580bddaec Merge remote-tracking branch 'origin/main' into shader-api-entity-stage 2026-11-11 14:39:22 +01:00
rdbtCVSandGPT 6.1 Sol 2962f1172d Merge main into #11261, preserving hanging stack-config coverage
Resolve the corpus registration conflict without changing any main group or hanging-config operation. Restore the BoatItem.use documentation to place_boat.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-11 08:49:19 +00:00
Vinícius Moreira ac68d5ae04 Merge remote-tracking branch 'upstream/main' into fix/end-crystal-saved-fall 2026-11-11 00:03:46 -03:00
Vinícius MoreiraandClaude Opus 5.5 76a8f27085 Merge main into fix/raw-entity-flags: main compiles again (#15038)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 23:57:45 -03:00
Vinícius MoreiraandClaude Opus 5.5 0ba2a84306 Merge main into fix/vex-no-gravity: main compiles again (#15038)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 23:56:36 -03:00
Vinícius MoreiraandClaude Opus 5.5 40b2f16229 Merge main into fix/painting-on-ground: main compiles again (#15038)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 23:56:02 -03:00
Vinícius MoreiraandClaude Opus 5.5 023a57e557 rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 23:48:53 -03:00
Vinícius MoreiraandClaude Opus 5.5 a15fb9d167 An end crystal reads its fall with ground_from_nbt; /tp grounds it; powder snow's shape counts
ground_from_nbt on main already reads OnGround and fall_distance, so the
helper this pull request added goes. TeleportCommand.performTeleport
sets onGround. Powder snow's inside shape for a fall over 2.5 is 0.9
tall, so a crystal above it keeps its fall. The deflate is 1.0E-5F.
The group adds both powder snow cases and a teleported crystal.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 22:42:25 -03:00
Vinícius MoreiraandClaude Opus 5.5 b1499e6662 An end crystal carries its own loaded fall; a cobweb resets it
The loaded OnGround and fall_distance now live on the crystal, so a
crystal summoned where a loaded one was killed starts from nothing and
a teleported crystal keeps its own (the position-keyed compound found
neither). EndCrystal.tick's applyEffectsFromBlocks resets the fall in
a cobweb or powder snow (makeStuckInBlock). Crystal::new builds the
fresh crystal its five call sites wrote out; start_time moves beside it
in dragon/crystal.rs, keeping dragon.rs from growing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 22:35:59 -03:00
Vinícius MoreiraandClaude Opus 5.5 cf0566a27d A leash knot or marker saves its flags as booleans
Entity.load reads NoGravity, Silent, Glowing and the other flags with
getBooleanOr and Entity.saveWithoutId writes each back as 1b only when
true. Horizon saved a leash knot's, a marker's or an eye of ender's raw
compound, keeping bytes like -1b and false flags; other_to_nbt now
normalizes them as the other entities' saves do.

Fixes #13853

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 21:59:38 -03:00
Vinícius MoreiraandClaude Opus 5.5 bdbf33939c A vex turns its gravity off every tick
Vex.tick calls setNoGravity(true) on every tick, AI or not, so a vex that
ticked once saves NoGravity: 1b. Horizon's vex flew through its own
travel without the flag and left it out of the save.

Fixes #13849

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 21:59:36 -03:00
Vinícius MoreiraandClaude Opus 5.5 d0314e8f25 A painting keeps its saved OnGround and fall_distance
Entity.load reads OnGround and fall_distance and Entity.saveWithoutId
writes them back. BlockAttachedEntity.tick never moves a painting nor runs
baseTick, so both stay what was loaded. painting_to_nbt wrote OnGround 0b
and fall_distance 0; it now writes the loaded values from the painting's
saved compound.

Fixes #14543

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 21:59:32 -03:00
Vinícius MoreiraandClaude Opus 5.5 03c750245c An end crystal keeps its saved OnGround and fall_distance
Entity.load reads OnGround and fall_distance and Entity.saveWithoutId
writes them back. EndCrystal.tick never moves the crystal nor runs
baseTick, so both stay what was loaded. Crystal::to_nbt wrote OnGround 0b
and fall_distance 0; it now writes the values from the crystal's loaded
compound.

Fixes #14671

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 21:59:31 -03:00
DUzzL c9544070f8 Merge remote-tracking branch 'upstream/main' into cf/14590 2026-11-11 00:26:48 +01:00
DUzzL 361923c262 Merge remote-tracking branch 'upstream/main' into cf/14590
# Conflicts:
#	crates/horizon-client/src/game/mob_landing_support_tests.rs
2026-11-11 00:26:25 +01:00
martipopsandClaude Opus 5.5 adbf42ffb9 Merge upstream main (#14379) into Pack Options on the settings shell
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 18:22:03 -05:00
Verm fadace66a0 A goat's horn flags are read as bytes (#14970)
Goat reads HasLeftHorn and HasRightHorn with getBooleanOr(key, true): a
number is true when its byte value is not 0, so an int 256 is a lost
horn. The loader tested the whole number; it now uses flag_or, as every
other boolean there does.
2026-11-10 19:33:20 +00:00
RLC248 7c384cd6ae Merge remote-tracking branch 'upstream/main' into fix/thrown-potion-level-entity 2026-11-10 19:31:50 +01:00
DetimesandClaude Opus 5.5 e61bf50d60 Merge upstream/main into perf-effect-meta
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:17:50 +00:00
Mayonesu0andClaude Sonnet 5.5 d1190a4dc7 A bare attribute name saves and applies as minecraft:'s; delivery harness covers tool and attribute_modifiers
Review of #14687.

Vanilla reads Attribute.CODEC and Identifier.CODEC with `minecraft:` as the
default namespace and writes them back whole, so `/item replace ... with
iron_pickaxe[attribute_modifiers=[{type:"attack_damage",id:"x",...}]]` saves
`minecraft:attack_damage` / `minecraft:x`. Horizon kept the strings as given:
the wire writer namespaced them, but the host passed the bare name to
`set_modifier_unclamped`, which skips an attribute it cannot find.

- `Items::attribute_modifiers` namespaces the stack's `type` and `id`, so the
  host applies what the client shows.
- The saved form (`item_to_nbt`) namespaces them as vanilla does.
- `test_builtin_component_delivery_parity.py`: a `tool` and an
  `attribute_modifiers` case (with bare names), run against the 26.3 server.
- Tests: a bare name on the host and across the wire, the saved form, and the
  writers' silent-drop path for a tool or modifier they cannot encode.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 19:10:54 +01:00
DetimesandClaude Opus 5.5 bcb0d01d9e Test the effect particle cache against the uncached lookup
Every (effect, ambient) pair, known or not, gives the same particle
through the cache as through the plain lookup, and the second pass
over the same pairs makes no registry lookup at all.

Refs #14921

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 17:42:42 +00:00
DetimesandClaude Opus 5.5 6ece267eb9 Memoise effect particle lookups in the hub
effect_meta looked up the particle of every visible effect for every
placed guest on every tick: a linear search over the effect table, a
formatted name and a String-keyed registry lookup. The result depends
only on (effect, ambient), so the hub now keeps it per pair. Output is
unchanged. The function moves out of hub.rs into hub/effect_meta.rs,
which keeps hub.rs from growing.

Refs #14921

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 17:42:39 +00:00
Nikita Maximilian GrimmandClaude Fable 5.2 eb06abefe2 Shader API 0.2: the [entity] stage, checked and not yet drawn
A 0.2 pack lights block terrain and nothing else: mobs, chests, dropped
items and particles keep vanilla's lighting, and no stage lets a pack light
them. This adds the API for that stage, so packs can be written and checked
against it before the game draws it.

- `[entity]` in the manifest and `fn entity(input: EntityInput) -> Surface`:
  the texture, the tint apart from vanilla's shade, the hurt flash, the
  light, a normal, the position and what is drawn (`kind`). It returns the
  material stage's `Surface`, so one function can light both.
- Its resources are the material stage's shadow maps and coloured-light
  grid, at the same bindings, so one bind group can serve both. No block
  texture helpers, `reflection`, `opaque_*` or `set_surface`.
- The reference gives vanilla's own as the stage's function, read from
  `entity_color`: the flash is mixed in vanilla's gamma, before the light.
- `horizon-shader check` takes the stage with the material's limits and
  refuses a wrong signature or a constructed `EntityInput`. The game refuses
  a pack that has it until it draws the stage.

A pack without `[entity]` is unchanged and sees none of the stage's names:
every other stage's prelude and entries are the same text as before, held by
the names lists. 0.1 refuses the key.

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

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-10 18:22:04 +01:00
[email protected]andClaude Sonnet 5.5 5fcd41ad3c Leave the recording's login sea level as it was
A recording is made on a remote connection, where the process-wide table
may still hold the sea level of a world opened earlier.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 18:00:36 +01:00
malithedeveloperandClaude Opus 5.5 c1b56cea13 Resource packs: a sprite alias never replaces a texture that is there
An alias written over an existing texture changed what every other reader
of that file sees: an unstitched or paletted sprite built from it, another
source's resource, anything that reads the texture as itself. Aliases now
only fill sprite paths that have no texture of their own, which is what
ItemsAdder and Nexo packs rename to; a rename over an existing texture
draws that texture, as before aliases. With that, vanilla's atlases never
list an alias's sprite, so they are no longer read for it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 19:57:46 +03:00
malithedeveloperandClaude Opus 5.5 fac0918973 Resource packs: a renamed sprite leaves a filtered .mcmeta out
A rename whose resource is vanilla's texture copied vanilla's `.mcmeta`
under the sprite even when a pack's filter hid that `.mcmeta`. Vanilla's
`createStackMetadataFinder` stops at a pack that filters the metadata, so
the alias now takes vanilla's `.mcmeta` only where no filter blocks it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 19:17:14 +03:00
Verm cfa85f016f Evoker fangs keep the fall_distance they were loaded with (#14847)
EvokerFangs extends Entity and never moves, so Entity.saveWithoutId
writes the fall_distance Entity.load read. Their save took Motion, the
pitch and OnGround from the loaded compound but wrote fall_distance 0.0;
it now takes that from the compound too.
2026-11-10 16:05:16 +00:00
DUzzL 615735e12c Keep shulkers that are not picked up out of the cart push fallback 2026-11-10 16:57:33 +01:00
SamB440 c46fd6f944 parity: Respond with acknowledgement packet when receiving PlayerRotation packet 2026-11-10 15:55:17 +00:00
[email protected]andClaude Sonnet 5.5 18bb5b3b1d Keep the files over 3000 lines from growing
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 16:31:11 +01:00
[email protected]andClaude Sonnet 5.5 dc3fe491e3 Sea level follows the level's generator
Fixes #14835

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 16:16:45 +01:00
malithedeveloperandClaude Opus 5.5 d1619f89b3 Resource packs: a renamed sprite keeps a later source's texture
The overlay gave every `single` rename's sprite its resource's file, even
when a later source of the atlas (a plain `single` or a `directory` of a
higher pack) listed that sprite from its own texture again. It now resolves
each atlas's whole source list in order with `atlas_sources::list`
(`SpriteSourceList.list`) over the textures the packs and vanilla leave
visible, and gives a sprite its resource's file only when that final
listing is the rename. Both atlases read the same file, so a sprite the
other atlas lists any other way keeps it. An atlas no pack provides is
vanilla's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 18:09:48 +03:00
DUzzL 4068a09887 Keep an item frame's saved Motion, OnGround and fall_distance 2026-11-10 16:03:09 +01:00
malithedeveloperandClaude Opus 5.5 09cfb0197c Resource packs: a renamed atlas sprite reads its resource's texture
A `single` atlas source with a `sprite` puts its resource's pixels under
another id (SingleFile.run). ItemsAdder and Nexo server packs name their
models' textures only through these renames ("ia:1199" for
minecraft:block/sea_lantern), and every loader reads a model's sprite
from textures/<sprite>.png, which those packs never ship: their models
drew the missing texture or nothing.

The overlay now gives each renamed sprite of the blocks and items
atlases its resource's file and .mcmeta, as SpriteResourceLoader reads
the resource's metadata. A rename over an existing texture replaces it
in the overlay only. Overlay cache version 9 rebuilds cached overlays.

Part of #14485.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 13:39:06 +03:00
Riccardo GuerrieroandClaude Fable 5.2 e8578c2e47 Worldgen: choices sample a branch only where they read it
Vanilla's bulk range choice and interval select sample every branch over the
whole volume and then pick one value per position, and so did the port. In
the Overworld's final density that is the underground caves (cave layer,
cave cheese, 2D spaghetti) on the two thirds of a chunk's 5x49x5 lattice
above the stone, and four or five scales of each spaghetti noise where one
is read.

The noise samplers can already skip the Ys a caller does not read
(sample_needed, a bit per Y per column), but only the lerp asked. The range
choice now asks its out-of-range branch for the positions whose selector is
out of range, the interval select asks each branch for the positions that
select it, and lerp, unary and binary samplers pass a caller's mask on.

Every sampler is still called, in vanilla's order, and a cache still fills
its whole volume (its buffer is read by later scalar queries), so only noise
that nothing reads is skipped. A test spoils every value a noise was not
asked for and checks the result bit for bit against the choices spelled out
per value.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-10 09:59:38 +01:00
Skr1zzzzandClaude Opus 5.5 8c9c801232 Keep game/remote.rs from growing: the AddEntity projectile choice moves out
The new-or-reused id check and the choice between the two spawn paths move
into `tracks_remote_entity` and `spawn_added_projectile` in
remote_projectiles.rs, so the AddEntity handler calls one function and
remote.rs ends up shorter than on main (docs/layers.md#file-size).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 11:25:49 +03:00
Skr1zzzzandClaude Opus 5.5 1fd78941bd Client: spawning a falling block or TNT no longer walks the whole list
Each AddEntity made two passes over `falling` with thousands of blocks in
the air: `spawn_remote_projectile` first removed any entity under the same
id (ClientLevel.addEntity's removeEntity), and `remote_motion` then looked
the new block up from the front of the list, where it is last.

The handler now checks whether the id is already tracked before
`spawn_entity` tracks it. Only then are `falling` and `tnts` filtered for
it; the crystals and the small projectile lists are still filtered every
time, since the client can add to those itself. `remote_motion` and
`move_entity` search `falling` and `tnts` from the back.

8,000 falling blocks, 256 AddEntity packets (debug test build):
10.6 -> 0.9 ms.

Part of #13007

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 10:31:31 +03:00
Mayonesu0andClaude Opus 5.5 28584b59bf A stack's own tool and attribute modifiers reach the client and the hit
The server now sends a stack's minecraft:tool and
minecraft:attribute_modifiers (Tool.STREAM_CODEC,
ItemAttributeModifiers.STREAM_CODEC), and the client reads them back as
their codecs save them. Single player's client and LAN guests now predict
mining from the stack's tool and show its own modifiers in the tooltip.

Equipment modifiers (detectEquipmentUpdates, ItemStack.forEachModifier)
come from the stack's ATTRIBUTE_MODIFIERS, not the item type's, so
Player.attack's getAttributeValue(ATTACK_DAMAGE) honours them: +20
attack_damage on an iron pickaxe hits for 21.

Fixes #14641

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 05:25:59 +01:00
martipopsandClaude Opus 5.5 2f8540be63 Graphics: Horizon's own preset row is named Horizon Preset
The General page showed two rows named Preset: Horizon's own preset, which sets the
options below together, and vanilla's options.graphics.preset. Horizon's is now
Horizon Preset (a new en_us entry; other languages fall back to English until
translated), so the two read apart. The group keeps its name. The search finds both
for "preset", and Horizon's alone for "horizon preset"; the shell_choice_step case
looks for the new name.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:48:56 -05:00
91e62194e8 Jukeboxes keep a finished song stopped after a reload and vibrate when a disc goes in or out
`JukeboxBlockEntity.loadAdditional` -> `JukeboxSongPlayer.setSongWithoutPlaying` brings a song
back only from a saved clock, only under its length, and never starts it (level event 1010 comes
from `play` alone): a record saved without a clock, or with one past its song's end, loads silent
at signal 0. `notifyItemChangedInJukebox` posts `BLOCK_CHANGE` for the block's `has_record` — and
`JukeboxPlayable.tryInsertIntoJukebox` posts a second one with the player as its source, against
the state the click found. `JukeboxBlock` decides both clicks by that state, not by the slot, and
`popOutTheItem` pops nothing from an empty slot.

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

Claude-Session: https://claude.ai/code/session_01WZzq8MxShENtiMj2DMbm5G
2026-11-10 00:58:01 +01:00
DUzzL 9094309407 Let rideable minecarts pick up shulkers beside the rail 2026-11-10 00:48:39 +01:00
martipopsandClaude Opus 5.5 4888b73428 Merge upstream main (#13606) into Pack Options on the settings shell
Pack Options joins the shells pad_clicks_step resets, and its shell arms and
open_pack_options move to options_ui/shell_pages.rs, where main put shell and shell_mut.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:10:04 -05:00
DUzzL d6fb844de4 Load the 5x5 neighbourhood in the prepared-data test fixtures that tick entities
Entity ticking waits for every chunk within 2 columns (ChunkMap.prepareEntityTickingChunk), as of #13953. Add Game::load_ticking_neighbourhood for tests and call it from the fixtures of the 51 prepared-data tests that failed since, and correct the item escape test's drag to 0.98F widened to double.
2026-11-09 23:57:19 +01:00
81f336b814 Give the wither's nether star its ten-minute lifetime
WitherBoss.dropCustomDeathLoot spawns the star and then calls
ItemEntity.setExtendedLifetime, which sets age to -6000, so the
age >= 6000 discard takes it at tick 12000 instead of 6000. Horizon
spawned it like any other death drop, at age 0, and lost it after
five minutes.

Fixes #13937.

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

Claude-Session: https://claude.ai/code/session_01WZzq8MxShENtiMj2DMbm5G
2026-11-09 22:48:11 +01:00
voidsip d42af058e8 Preserve TNT transfer state without trusting saved NBT 2026-11-09 14:58:58 -06:00
voidsip b4b9d03ed5 Keep pending entity load context separate from saved NBT 2026-11-09 14:47:59 -06:00
martipopsandClaude Opus 5.5 467d045271 Pack Options on the settings shell
Pack Options (#11486) becomes a screen on the settings shell, as Shaders
is: each Polytone config section is a page, numbers are sliders that stop
at the entry's step, value lists are choices, booleans switch and free
text reads. The first page starts with the pack-wide presets and Reset
all. The shell gives it keyboard and controller focus, Left/Right
stepping, the search (an entry's tooltip included), descriptions with
the performance-impact line in the strip, and Undo from a snapshot taken
when the screen opens. Leaving sends the pack's choices only when they
differ from that snapshot, so an Undo sends nothing.

options_ui's special cases for the page go, with Lines::titled and the
shell list's branch for it. foot_button takes the glyph room itself
rather than through a wrapper. A pad_menu_e2e case, pack_options, runs
the fixture pack through RB, Right, Undo, Left and B.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:25:04 -05:00
AkirabaneandClaude Sonnet 5.5 45eb484b85 Particles: cull against the camera frustum pulled back 3 blocks, like vanilla
Vanilla tests every particle's point against the camera frustum moved back
3 blocks (ParticleEngine.extract, QuadParticleGroup, Frustum.pointInFrustum).
Horizon only did it behind the opt-in Particle Culling setting, so by default
ParticleEngine::render built a quad for every live particle.

Frustum::pulled_back moves the camera back along its view direction and
Frustum::contains_point is the point test. ParticleEngine::render_in_view
skips a particle outside that frustum before any sprite, light or quad work;
render_culled applies it too, whatever the setting says. render is unchanged.
game.rs passes the camera's frustum on the default path.

Written from the issue's description of the rule; there is no vanilla oracle.

Fixes #7255

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 20:33:25 +01:00
Evrim CicekandClaude Opus 5.5 0dace8ff46 vehicle/mob fall clip: wait for the forced chunk's 2-column neighbourhood
Since #13953 an entity ticks only once every chunk within 2 columns is
loaded (ChunkMap.prepareEntityTickingChunk). Horizon has that
neighbourhood ready a few ticks after vanilla, so these groups, which
waited for the forced chunk alone, read vehicles and mobs that hadn't
moved yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 21:50:30 +03:00
Evrim Cicek 5054ffc2ab Merge branch 'refactor/minecarts-split' into fix/hanging-item-stack-config 2026-11-09 21:03:03 +03:00
MSedgeMC 96a7044c15 Merge remote-tracking branch 'upstream/main' into fix/block-entity-pos-from-tag 2026-11-09 18:00:54 +00:00
RLC248 182994edc8 A predicate's terms may be one holder alone or a tag
HolderSetCodec reads an all_of or any_of terms as a list of holders, one
holder alone (at .terms[0]) or a #tag. The dedicated server's startup check
now follows a lone holder like the list's first element, and refuses a world
whose terms names a predicate tag no pack defines (Unbound tags in registry).
The evaluation reads a lone holder too, so a lone any_of term is true when
its predicate is.

Part of #12711
2026-11-09 18:54:27 +01:00
Evrim Cicek 0a48f1eade Merge remote-tracking branch 'upstream/main' into fix/hanging-item-stack-config 2026-11-09 20:44:56 +03:00
MSedgeMC 9f51db9b40 port_map: BlockEntity.getPosFromTag is a port
The row said a block entity saved in the wrong chunk keeps its saved
x/z; `chunk::block_entity_pos` now moves it into the chunk that saved it,
so the row cites it.
2026-11-09 17:44:04 +00:00
MSedgeMC 301fc5fc05 test: a wrongly chunked block entity is read into its chunk
`chunk::block_entity_pos`'s test pins the arithmetic: (37, 70, -3) in
chunk (0, 0) is (5, 70, 13) after vanilla's `sectionRelative`, an
already-correct position is untouched, and another chunk moves its own
corner.

`a_block_entity_saved_under_another_chunks_coordinates_moves_here`
decodes a chunk whose `block_entities` list names x=37, z=-3 at chunk
(0, 0) and expects the chest at (5, 70, 13). Without the fix it stays at
(37, 70, -3).
2026-11-09 17:44:04 +00:00
MSedgeMC 74db568335 fix: a block entity saved in another chunk moves into the chunk that holds it
`BlockEntity.getPosFromTag` reads the tag's `x`, `y`, `z`, and when they
name another chunk it warns and keeps only the low four bits of `x` and
`z`, so the entity stays in the chunk whose save wrote it. Horizon's
`decode_block_entity` returned the saved coordinates as they were, so a
block entity saved under the wrong chunk's coordinates landed at a
position that chunk may not even hold, instead of where vanilla puts it.

`chunk::block_entity_pos` does the clamp, and the chunk decoder uses it
for every block entity it reads. No line growth in `save.rs` (the size
gate's 3000-line rule).
2026-11-09 17:44:04 +00:00
RLC248 f64da572af A thrown potion or bottle is a level entity
A splash or lingering potion or experience bottle that a player throws
or a dispenser shoots went into a side list that is not part of the
level, so @e, /data get entity, /execute if entity and /kill never found
it. Vanilla adds it with level.addFreshEntity like any entity.

Throws and dispensers now make the same level projectile /summon does
(ProjectileKind::Potion / ExperienceBottle); the player's throw uses
Projectile.shoot_from_rotation (#13201).
2026-11-09 18:22:49 +01:00
Horizon agentandClaude Opus 5.5 fe0a4d80bf Mouse Tweaks: rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:22:34 +01:00
Horizon agentandClaude Opus 5.5 adc454cfc9 Mouse Tweaks and sorting move to game/extras/, one module each (layer plan M5)
A pure move: game/tweaks.rs becomes game/extras/mouse_tweaks.rs, and
the sort buttons and key leave it for game/extras/sorting.rs. Paths,
visibility (pub(in crate::game)) and imports only; game.rs loses its
mod line.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:19:15 +01:00
Horizon agent 2cdecbf069 Merge remote-tracking branch 'origin/pr-13893' into layers/extras-tweaks 2026-11-09 18:19:15 +01:00
Evrim Cicek 72dff2d5b7 Merge branch 'refactor/entity-nbt-hanging' into fix/hanging-item-stack-config 2026-11-09 19:24:22 +03:00
Evrim Cicek 194a86eff3 Merge branch 'refactor/entity-nbt-hanging' into fix/hanging-item-stack-config
# Conflicts:
#	crates/horizon-client/src/entity_nbt.rs
2026-11-09 19:24:21 +03:00
Horizon agentandClaude Opus 5.5 3089267e7c Dolphin treasure: read the found position without moving it
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:50:01 +01:00
Horizon agentandClaude Opus 5.5 895726d381 rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:39:41 +01:00
Horizon agentandClaude Opus 5.5 1f96d244f9 Read a sprite's .mcmeta up to its first value, as vanilla does
ResourceMetadata.fromJsonStream reads one JSON value with Gson and never looks
past it, so packs leave notes after the closing brace. CozyUI+'s highlighted
button ends its .mcmeta with `//` lines: Horizon's strict serde_json read
rejected the file, so the sprite lost its nine slice and animation and drew
as its whole 16-frame strip (#3046).

The nine-slice, GUI animation, texture-flag and atlas sprite readers now share
packs::first_json with pack.mcmeta's reader.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:22:17 +01:00
Horizon agentandClaude Opus 5.5 27979c816b Feeding particles: tadpole slime ball, panda love hearts, dolphin treasure
- Tadpole.feed draws its happy_villager particle on the client too; a tadpole
  is never isBaby, so the client's feed prediction skipped it (#8554).
- Panda.mobInteract's setInLove broadcasts entity event 18, the love hearts
  (#8555).
- DolphinSwimToTreasureGoal.start broadcasts entity event 38 once it finds a
  treasure, and Dolphin.mobInteract answers a #minecraft:fishes item with
  SUCCESS on the client, so the hand swings and the fish isn't eaten (#8567).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:16:35 +01:00
Evrim Cicek 0a11f464a3 Merge remote-tracking branch 'upstream/main' into fix/hanging-item-stack-config
# Conflicts:
#	crates/horizon-client/src/entity_nbt.rs
#	crates/horizon-client/src/game/dispenser.rs
2026-11-09 16:42:23 +03:00
FryedCheese b4733ffec0 Merge branch 'horizon-rust:main' into main 2026-11-08 23:19:56 +13:00
FryedCheese 1b8e661146 Merge pull request #1 from Web-mc1/studio-screen-bridge
Add Screen::Studio so script windows stop borrowing the chat slot
2026-11-08 21:09:44 +13:00
Web-mc1 95b6b28fcb Add Screen::Studio so Studio windows don't borrow the chat slot 2026-11-08 20:52:29 +13:00
Evrim Cicek 2f07a64084 Merge remote-tracking branch 'upstream/main' into fix/hanging-item-stack-config
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-08 02:38:03 +03:00
Horizon agentandClaude Opus 5.5 bb6e18b94d Merge origin/main into #11261 (main builds again after #11767)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 14:10:29 +01:00
Horizon agentandClaude Opus 5.5 b28069d08e rustfmt spawner.rs (verify's fmt step failed on this hunk)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 14:00:50 +01:00
Horizon agentandClaude Opus 5.5 2f4a142e8b Merge origin/main into #11261 (keep both command_exec corpus groups)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 14:00:39 +01:00
Horizon agentandClaude Opus 5.5 83df38c895 Drop an unreachable return after components()
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 09:44:10 +01:00
Evrim Cicek d70f2fdb20 Merge upstream/main: build on #11268's stack config; the item's name loads too
Upstream #11268 configures a placed frame, painting or armor stand through
stack_configured_entity. Keep its placement code, add the item's custom name
there (a painting took none, an armor stand's lost its style), and route
apply_item_stack_config through the same helper.
2026-11-07 02:40:03 +03:00
Evrim CicekandClaude Opus 5.5 d8a86b2454 Dispensed armor stand config, block_pos range, unreadable painting variant
ArmorStandDispenseItemBehavior applies the same stack config with no user
after the dispenser's yaw. BlockAttachedEntity.readAdditionalSaveData keeps
the Pos block for a block_pos 16 blocks or more away, and a painting with an
unreadable variant still loads the rest of its data.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 19:44:48 +03:00
Evrim CicekandClaude Opus 5.5 d4cfdeab5c A placed painting, item frame or armor stand takes its item's stack config
HangingEntityItem.useOn and ArmorStandItem.useOn apply
EntityType.createDefaultStackConfig: the item's custom name and custom data,
then minecraft:entity_data merged over the entity. The hanging entity's
survives() runs on the configured entity; the stand turns to the player's
yaw, pitch 0, afterwards. The vehicle helper becomes apply_item_stack_config,
and /data merge entity now loads a painting (facing, block, known variant).

Fixes #11242

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 19:32:10 +03:00
274 changed files with 9643 additions and 2607 deletions

No files matched your search

+1 -1
View File
@@ -225,7 +225,7 @@ jobs:
Write-Output "Using $($clang.Source)"
& $clang.Source --version
- name: Build the game for native Windows ARM64 (the official game, as game-build.yml ships it)
run: cargo build --release --locked --target aarch64-pc-windows-msvc -p horizon-official --bin horizon-official -p horizon-ctl --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
run: cargo build --release --locked --target aarch64-pc-windows-msvc -p horizon-official --bin horizon-official -p horizon-client-lib --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
- name: Check the game's PE machine
run: python tools/check_pe_arch.py arm64 target/aarch64-pc-windows-msvc/release/horizon-official.exe target/aarch64-pc-windows-msvc/release/horizon-ctl.exe target/aarch64-pc-windows-msvc/release/horizon-prepare.exe target/aarch64-pc-windows-msvc/release/horizon-server.exe
- name: Check game DLL imports
+1 -1
View File
@@ -173,7 +173,7 @@ jobs:
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
- name: Build the official game, horizon-ctl, horizon-prepare and horizon-server
if: ${{ !matrix.musl }}
run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} -p horizon-official --bin horizon-official -p horizon-ctl --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} -p horizon-official --bin horizon-official -p horizon-client-lib --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
- name: The official game is the game players get, under its name
# horizon-official: horizon-client's game with Horizon's own parts plugged in (docs/layers.md, "How a
# Horizon crate plugs in"). Everything below, the launcher and players know it as horizon-client.
+3 -4
View File
@@ -78,8 +78,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
| Crate | Owns |
|---|---|
| `horizon-client` | Gameplay library (`src/lib.rs`: `game.rs` + `game/`, `entry.rs`) and thin executable (`src/main.rs`). Also the dedicated server (`--dedicated`). Re-exports `horizon-client-lib` for existing mod paths. |
| `horizon-client-lib` | Reusable client systems, the control socket, probes and their tests. Its manifest points to the existing sources under `crates/horizon-client/`; the library root is `src/client_lib.rs`. It never depends on gameplay. |
| `horizon-ctl` | `horizon-ctl`, the CLI over the control socket, and its MCP server (platform): `commands.rs` is the command table ([docs/agent-control.md](docs/agent-control.md)) |
| `horizon-client-lib` | Reusable client systems, `horizon-ctl`, probes and their tests. Its manifest points to the existing sources under `crates/horizon-client/`; the library root is `src/client_lib.rs`. It never depends on gameplay. |
| `horizon-items` | Blocks and items below gameplay: `blocks.rs` (block facts), `items.rs`, `item_data.rs` (components), `enchant.rs`, `cooldowns.rs`, `bundle.rs`, `item_nbt.rs`. Re-exported as `horizon_client::items`... |
| `horizon-command` | Command parsing (Brigadier): the vanilla tree, argument types, selectors, suggestions, the tree's network form. Re-exported as `horizon_client::command` |
| `horizon-client-core` | The client lib's bottom layer: `text.rs`, `lang.rs`, `snbt.rs`, `nbt_path.rs`, `component.rs`, `scoreboard.rs`, `bossbar.rs`, `gamerules.rs`, `block_entity_nbt.rs`, registry/particle/sound tables. Re-exported under the old paths (`horizon_client::text`...) |
@@ -213,9 +212,9 @@ state stays separate from the library tests. This compiles gameplay twice for ex
| Input | game.rs `fn window_event`; G: `play_keys.rs` (`fn key_pressed`), `play_input.rs` (`fn play_mouse`, toggled keys, the hotbar wheel); crate `horizon-client-input` (`keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `clipboard.rs`); L: `osk.rs`; G: `pad.rs`, `pad_menu.rs`, `pad_craft.rs`, `pointer_capture.rs` (mouse look, pointer grab) |
| Audio | L: `audio.rs` (mixer), `sound_engine.rs`, `sound_events.rs`, `music.rs`, `ambience.rs`; G: `music.rs`, `subtitle_hud.rs` |
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`, `mobs/cem.rs` + `mobs/cem_anim.rs` (OptiFine/EMF entity models and their animations); `horizon-client-core/src/lang.rs`; crates `horizon-assets`, `horizon-optifine`; G: `packs.rs` |
| Agent control | L: `control.rs` (socket), `crates/horizon-ctl/`; G: `control.rs`, `agent.rs`, `events.rs`, `ui_control.rs` ([docs/agent-control.md](docs/agent-control.md)) |
| Agent control | L: `control.rs` (socket), `bin/horizon-ctl/`; G: `control.rs`, `agent.rs`, `events.rs`, `ui_control.rs` ([docs/agent-control.md](docs/agent-control.md)) |
| Mods | crate `horizon-mods`; G: `mods.rs`; L: `mod_content.rs`, `mod_list.rs` |
| Horizon extras, settings | L: `extras.rs`, `graphics.rs`, `settings.rs`, `minimap.rs`; G: `extras.rs`, `tweaks.rs`, `options.rs` |
| Horizon extras, settings | L: `extras.rs`, `graphics.rs`, `settings.rs`, `minimap.rs`; G: `extras.rs` + `extras/` (each extra's glue: `mouse_tweaks.rs`, `sorting.rs`, `pvp.rs`, `reconnect.rs`), `options.rs` |
## How `Game` is organised
Generated
+1 -11
View File
@@ -1607,6 +1607,7 @@ dependencies = [
"horizon-hdr",
"horizon-hooks",
"horizon-items",
"horizon-mcp",
"horizon-mods",
"horizon-narrator",
"horizon-optifine",
@@ -1728,17 +1729,6 @@ dependencies = [
"ureq",
]
[[package]]
name = "horizon-ctl"
version = "0.1.0"
dependencies = [
"horizon-client-lib",
"horizon-core",
"horizon-mcp",
"horizon-threads",
"serde_json",
]
[[package]]
name = "horizon-discord"
version = "0.1.0"
-2
View File
@@ -13,8 +13,6 @@ members = [
"crates/horizon-client-core",
"crates/horizon-client-input",
"crates/horizon-client-render",
# horizon-ctl and its MCP server (platform).
"crates/horizon-ctl",
"crates/horizon-cosmetics",
"crates/horizon-command",
"crates/horizon-items",
+2
View File
@@ -62,6 +62,8 @@ The GLSL frontend also resets the common type for each comma-separated declarato
variable's array specifier does not change later variables.
It also reads a struct member outside a constant expression through a pointer to the member,
not out of a loaded copy of the whole struct, so a member an atomic function uses can be read.
The shared interpolation defaults also compile with `glsl-in` alone, without needing the
SPIR-V or WGSL frontend to enable them.
## DirectX Shader Compiler
+33 -1
View File
@@ -206,7 +206,7 @@ impl Config {
Ok(file.anticheat)
}
/// Thresholds can't be negative.
/// Thresholds can't be negative, and neither can the timer's milliseconds.
pub fn check(&self) -> Result<(), String> {
for &c in Check::ALL {
let a = self.actions(c);
@@ -220,6 +220,18 @@ impl Config {
));
}
}
// A negative tolerance flags a client on time every tick, and so does a cap below minus the
// tolerance (the balance rests at the cap); NaN never flags, or never caps the balance. No
// negative number of milliseconds means anything, so all are refused.
if [self.timer.tolerance_ms, self.timer.max_lag_ms]
.iter()
.any(|v| !v.is_finite() || *v < 0.0)
{
return Err(
"anticheat.timer: tolerance_ms and max_lag_ms are numbers of milliseconds from 0 up"
.into(),
);
}
let tolerance = self.prediction.tolerance;
if !tolerance.is_finite() || tolerance <= 0.0 {
return Err("anticheat.prediction: tolerance is a number of blocks above 0".into());
@@ -361,6 +373,26 @@ mod tests {
assert!(Config::from_server_toml("[anticheat.timer]\nkick = -1\n").is_err());
}
/// A negative `tolerance_ms` flags a client on time every tick, and so does a `max_lag_ms` below
/// `-tolerance_ms` (the balance rests at the cap); a NaN turns the check (or its cap) off without a
/// word. Every negative or non-finite value is refused, as a negative threshold is.
#[test]
fn refuses_timer_settings_that_are_not_milliseconds_from_0_up() {
for bad in [
"tolerance_ms = -1",
"tolerance_ms = nan",
"tolerance_ms = inf",
"max_lag_ms = -1",
"max_lag_ms = nan",
] {
let text = format!("[anticheat.timer]\n{bad}\n");
let e = Config::from_server_toml(&text).expect_err(bad);
assert!(e.starts_with("anticheat.timer:"), "{bad}: {e}");
}
let edge = "[anticheat.timer]\ntolerance_ms = 0\nmax_lag_ms = 0\n";
assert!(Config::from_server_toml(edge).is_ok());
}
#[test]
fn levels_rise_decay_and_act_in_order() {
let mut c = on();
+25 -1
View File
@@ -45,6 +45,11 @@ mod tests {
/// `seconds` of server ticks of `server_ms` each, with `per_tick` client ticks handled in each.
fn run(b: &mut Balance, server_ms: f64, per_tick: f64, seconds: f64) -> usize {
run_at(b, 20.0, server_ms, per_tick, seconds)
}
/// [`run`] at the tick rate `rate` (`/tick rate`), which the client's ticks follow.
fn run_at(b: &mut Balance, rate: f32, server_ms: f64, per_tick: f64, seconds: f64) -> usize {
let c = TimerConfig::default();
let mut owed = 0.0;
let mut flags = 0;
@@ -53,12 +58,31 @@ mod tests {
owed += per_tick;
while owed >= 1.0 {
owed -= 1.0;
flags += usize::from(b.tick_end(20.0, &c).is_some());
flags += usize::from(b.tick_end(rate, &c).is_some());
}
}
flags
}
/// At another tick rate a vanilla client ticks at the rate the server sent it, and each of its
/// ticks is worth `1000 / rate` ms: never flagged on time, flagged again and again 1.5 times as fast.
#[test]
fn the_balance_follows_the_tick_rate() {
for rate in [10.0_f32, 60.0] {
let tick_ms = 1000.0 / f64::from(rate);
assert_eq!(
run_at(&mut Balance::default(), rate, tick_ms, 1.0, 600.0),
0,
"on time at {rate}"
);
// Half a tick ahead every server tick: 75 flags in a minute at 10 a second, 94 at 60.
let flags = run_at(&mut Balance::default(), rate, tick_ms, 1.5, 60.0);
assert!(flags >= 50, "1.5 times as fast at {rate}: {flags} flags");
}
// The rate counts: at 10 a second, a client sending 20 ticks a second is twice as fast.
assert!(run_at(&mut Balance::default(), 10.0, 100.0, 2.0, 60.0) >= 50);
}
#[test]
fn a_vanilla_client_is_never_flagged() {
let mut b = Balance::default();
+6
View File
@@ -22,6 +22,8 @@ horizon-items = { path = "../horizon-items" }
horizon-core = { path = "../horizon-core" }
# Extension points for the crates above the engine (docs/layers.md, "How a Horizon crate plugs in").
horizon-hooks = { path = "../horizon-hooks" }
# `horizon-ctl mcp`: the Model Context Protocol, shared with the launcher and the server.
horizon-mcp = { path = "../horizon-mcp" }
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
horizon-optifine = { path = "../horizon-optifine" }
horizon-threads = { path = "../horizon-threads" }
@@ -135,6 +137,10 @@ horizon-perf = { path = "../horizon-perf" }
# Every WGSL shader translated as wgpu's DirectX 12 backend does (gfx.rs tests).
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out", "msl-out", "spv-out"] }
[[bin]]
name = "horizon-ctl"
path = "../horizon-client/src/bin/horizon-ctl/main.rs"
[[example]]
name = "chunk_load_bench"
path = "../horizon-client/examples/chunk_load_bench.rs"
@@ -1442,6 +1442,8 @@ impl Audio {
} else {
Err("no output on a dedicated server".into())
};
// Without `audio` a stream can't exist (`Infallible`), so the `Ok` arm never runs.
#[cfg_attr(not(feature = "audio"), allow(unreachable_code))]
let (stream, live) = match opened {
Ok(v) => (Some(v), true),
Err(e) => {
@@ -465,6 +465,9 @@ pub struct Jukebox {
/// reference exists. Keep this separate from legacy playback adoption.
pub song_clock_present: bool,
pub playing: bool,
/// A clock a save brought back, not playing yet: `setSongWithoutPlaying`
/// keeps the song and its clock without the sound's level event 1010.
pub loaded_clock: bool,
}
impl Jukebox {
/// `JukeboxBlockEntity.getTheItem`: the disc it holds.
+3 -177
View File
@@ -210,6 +210,7 @@ mod hdr_icc;
mod hdr_start;
mod hdr_stats;
pub(crate) mod hud_coverage;
mod in_flight;
pub use hdr::{Calibration as HdrCalibration, Output as HdrOutput};
mod hold;
pub use hold::WorldHold;
@@ -12351,133 +12352,6 @@ pub(crate) fn copy_frame(
);
buffer
}
impl Renderer {
/// The frame's readbacks to start right after its submission (on the submit thread when
/// the frame goes there): occlusion culling's results (`cull`) and GPU culling's
/// statistics. GPU frames leave occlusion culling off, so a frame has one at most.
fn after_submit(&mut self) -> Option<submit::AfterSubmit> {
let cull = self.cull.as_mut().and_then(|c| c.submitted());
let gpu = self.gpu_culling.as_mut().and_then(|g| g.submitted());
match (cull, gpu) {
(Some(c), Some(g)) => Some(Box::new(move || {
c();
g();
})),
(Some(c), None) => Some(Box::new(c)),
(None, Some(g)) => Some(Box::new(g)),
(None, None) => None,
}
}
/// Frame `frame`'s statistics shown, unless a newer frame's are: a CPU frame's as it is
/// drawn, a GPU frame's when its counts come back (`GpuCulling::collect`).
fn show_stats(&mut self, frame: u64, [sections, draws, calls]: [usize; 3]) {
if frame > self.stats_frame {
self.stats_frame = frame;
(
self.drawn_sections,
self.section_draws,
self.section_draw_calls,
) = (sections, draws, calls);
}
}
/// Frames between the last one drawn and the one whose statistics are shown: 0 after a
/// CPU frame, one to three in GPU frames, while their counts are on their way back.
pub fn stats_lag(&self) -> u64 {
self.frame_serial - self.stats_frame
}
/// Take in the frame the submit thread submitted: start its readbacks and, offscreen,
/// keep at most two frames ahead of the GPU, as a frame submitted in place does.
fn land_frame(&mut self) {
let Some(offscreen) = self.deferred.take() else {
return;
};
let submission = self.queue.landed();
if let Some(timer) = &mut self.gpu_timer {
timer.submitted(&self.queue);
timer.collect(&self.device, &mut self.gpu_times);
}
if let Some(Some(timer)) = Self::pass_timer(&mut self.pack_post, &mut self.native) {
timer.submitted(&self.queue);
}
if let Some(timer) = &mut self.own_timing {
timer.submitted(&self.queue);
}
if offscreen && let Some(submission) = submission {
self.in_flight.push_back(submission);
if !self.paced_elsewhere {
self.wait_frames();
}
}
}
/// No swapchain paces offscreen frames: keep two on the GPU at most.
pub fn wait_in_flight(&mut self) {
self.land_frame();
self.wait_frames();
}
/// The frame on the submit thread submitted: a surface reconfigure waits for the
/// device and breaks if a frame is submitted while it waits, and the compositor
/// copies views after them. (The end of a frame already lands the frames of the
/// turn before; this is the turn's own.)
pub fn wait_submissions(&mut self) {
self.queue.wait();
}
fn wait_frames(&mut self) {
while self.in_flight.len() > 2 {
let index = self.in_flight.pop_front();
let _ = self.device.poll(wgpu::PollType::Wait {
submission_index: index,
timeout: Some(std::time::Duration::from_secs(5)),
});
}
}
/// Take a screenshot request: queue the frame's copy, or report why the
/// frame cannot be read back.
fn take_readback(
&mut self,
request: ScreenshotRequest,
encoder: &mut wgpu::CommandEncoder,
texture: &wgpu::Texture,
) -> Option<(ScreenshotRequest, wgpu::Buffer)> {
// The copy and PNG writer both assume 8-bit pixels; a float surface would make
// the row stride too small for the copy and abort in validation.
if self.config.format.block_copy_size(None) == Some(4) {
let size = [self.config.width, self.config.height];
let format = self.config.format.remove_srgb_suffix();
// The layered float frame maps the world to SDR, then adds the HUD as drawn.
// Other 16-bit frames keep their clipped 8-bit copy.
let copy = self
.hdr
.as_mut()
.and_then(|hdr| {
hdr.sdr_screenshot(&self.device, encoder, format, request.size.is_some())
})
.or_else(|| {
(texture.format() != format && texture.format() == self.output.frame.format)
.then(|| {
self.readback
.unorm_copy(&self.device, encoder, texture, format)
})
});
let texture = copy.as_ref().unwrap_or(texture);
Some((request, copy_frame(&self.device, encoder, texture, size)))
} else {
let error = format!(
"screenshots need a 32-bit surface format, not {:?}",
self.config.format
);
if request.background {
self.saved_screenshots.push(screenshot_save::Saved {
path: request.path,
result: Err(error),
hdr: None,
});
} else {
self.screenshot_result = Some(Err(error));
}
None
}
}
}
/// `screenshots/2026-10-28_12.00.00.png` -> `screenshots/2026-10-28_12.00.00-hdr.png`.
fn hdr_screenshot_path(path: &std::path::Path) -> PathBuf {
let stem = path.file_stem().unwrap_or_default().to_string_lossy();
@@ -12583,56 +12457,8 @@ fn block_entities_within_reach<'a>(
})
.flatten()
}
/// Camera-relative clip planes (`Frustum`).
#[derive(Clone, Copy, Debug)]
pub struct Frustum {
planes: [Vec4; 6],
}
impl Frustum {
/// Whether the camera-relative box `min..max` is at least partly inside.
pub fn intersects(&self, min: Vec3, max: Vec3) -> bool {
!self.planes.iter().any(|p| {
let sign = p.truncate().cmpge(Vec3::ZERO);
let v = Vec3::select(sign, max, min);
p.truncate().dot(v) + p.w < 0.0
})
}
/// Whether a camera-relative sphere is at least partly inside. Frustum
/// planes are normalized, so each test needs only one dot product and a
/// radius comparison.
pub fn intersects_sphere(&self, center: Vec3, radius: f32) -> bool {
if !center.is_finite() || !radius.is_finite() || radius < 0.0 {
return true;
}
!self
.planes
.iter()
.any(|plane| plane.truncate().dot(center) + plane.w < -radius)
}
/// Depth clip planes alone: X/Y projection jitter cannot change this test.
/// Retaining side-of-screen geometry makes this safe before TAA selects a phase.
pub fn intersects_depth(&self, min: Vec3, max: Vec3) -> bool {
if !min.is_finite() || !max.is_finite() || !min.cmple(max).all() {
return true;
}
!self.planes[4..].iter().any(|p| {
let sign = p.truncate().cmpge(Vec3::ZERO);
let v = Vec3::select(sign, max, min);
p.truncate().dot(v) + p.w < 0.0
})
}
/// Whether the camera-relative box `min..max` is entirely inside. Then
/// `intersects` accepts every box within it: each plane's distance only
/// grows from this box's nearest corner to the inner box's farthest, and
/// rounding keeps that order.
pub fn contains(&self, min: Vec3, max: Vec3) -> bool {
self.planes.iter().all(|p| {
let sign = p.truncate().cmpge(Vec3::ZERO);
let v = Vec3::select(sign, min, max);
p.truncate().dot(v) + p.w >= 0.0
})
}
}
mod view_frustum;
pub use view_frustum::Frustum;
/// Vanilla's `Projection`: reversed depth (near maps to 1, far to 0, tests
/// are GREATER_THAN_OR_EQUAL), which keeps far faces a hair apart, like
/// fire_side's 0.01 pixel in front of its block, from z-fighting.
@@ -0,0 +1,132 @@
//! Frames in flight: the readbacks a submission starts, landing frames the submit thread
//! submitted, keeping offscreen frames at most two ahead of the GPU, and a screenshot's copy.
use super::{Renderer, ScreenshotRequest, copy_frame, screenshot_save, submit};
impl Renderer {
/// The frame's readbacks to start right after its submission (on the submit thread when
/// the frame goes there): occlusion culling's results (`cull`) and GPU culling's
/// statistics. GPU frames leave occlusion culling off, so a frame has one at most.
pub(super) fn after_submit(&mut self) -> Option<submit::AfterSubmit> {
let cull = self.cull.as_mut().and_then(|c| c.submitted());
let gpu = self.gpu_culling.as_mut().and_then(|g| g.submitted());
match (cull, gpu) {
(Some(c), Some(g)) => Some(Box::new(move || {
c();
g();
})),
(Some(c), None) => Some(Box::new(c)),
(None, Some(g)) => Some(Box::new(g)),
(None, None) => None,
}
}
/// Frame `frame`'s statistics shown, unless a newer frame's are: a CPU frame's as it is
/// drawn, a GPU frame's when its counts come back (`GpuCulling::collect`).
pub(super) fn show_stats(&mut self, frame: u64, [sections, draws, calls]: [usize; 3]) {
if frame > self.stats_frame {
self.stats_frame = frame;
(
self.drawn_sections,
self.section_draws,
self.section_draw_calls,
) = (sections, draws, calls);
}
}
/// Frames between the last one drawn and the one whose statistics are shown: 0 after a
/// CPU frame, one to three in GPU frames, while their counts are on their way back.
pub fn stats_lag(&self) -> u64 {
self.frame_serial - self.stats_frame
}
/// Take in the frame the submit thread submitted: start its readbacks and, offscreen,
/// keep at most two frames ahead of the GPU, as a frame submitted in place does.
pub(super) fn land_frame(&mut self) {
let Some(offscreen) = self.deferred.take() else {
return;
};
let submission = self.queue.landed();
if let Some(timer) = &mut self.gpu_timer {
timer.submitted(&self.queue);
timer.collect(&self.device, &mut self.gpu_times);
}
if let Some(Some(timer)) = Self::pass_timer(&mut self.pack_post, &mut self.native) {
timer.submitted(&self.queue);
}
if let Some(timer) = &mut self.own_timing {
timer.submitted(&self.queue);
}
if offscreen && let Some(submission) = submission {
self.in_flight.push_back(submission);
if !self.paced_elsewhere {
self.wait_frames();
}
}
}
/// No swapchain paces offscreen frames: keep two on the GPU at most.
pub fn wait_in_flight(&mut self) {
self.land_frame();
self.wait_frames();
}
/// The frame on the submit thread submitted: a surface reconfigure waits for the
/// device and breaks if a frame is submitted while it waits, and the compositor
/// copies views after them. (The end of a frame already lands the frames of the
/// turn before; this is the turn's own.)
pub fn wait_submissions(&mut self) {
self.queue.wait();
}
pub(super) fn wait_frames(&mut self) {
while self.in_flight.len() > 2 {
let index = self.in_flight.pop_front();
let _ = self.device.poll(wgpu::PollType::Wait {
submission_index: index,
timeout: Some(std::time::Duration::from_secs(5)),
});
}
}
/// Take a screenshot request: queue the frame's copy, or report why the
/// frame cannot be read back.
pub(super) fn take_readback(
&mut self,
request: ScreenshotRequest,
encoder: &mut wgpu::CommandEncoder,
texture: &wgpu::Texture,
) -> Option<(ScreenshotRequest, wgpu::Buffer)> {
// The copy and PNG writer both assume 8-bit pixels; a float surface would make
// the row stride too small for the copy and abort in validation.
if self.config.format.block_copy_size(None) == Some(4) {
let size = [self.config.width, self.config.height];
let format = self.config.format.remove_srgb_suffix();
// The layered float frame maps the world to SDR, then adds the HUD as drawn.
// Other 16-bit frames keep their clipped 8-bit copy.
let copy = self
.hdr
.as_mut()
.and_then(|hdr| {
hdr.sdr_screenshot(&self.device, encoder, format, request.size.is_some())
})
.or_else(|| {
(texture.format() != format && texture.format() == self.output.frame.format)
.then(|| {
self.readback
.unorm_copy(&self.device, encoder, texture, format)
})
});
let texture = copy.as_ref().unwrap_or(texture);
Some((request, copy_frame(&self.device, encoder, texture, size)))
} else {
let error = format!(
"screenshots need a 32-bit surface format, not {:?}",
self.config.format
);
if request.background {
self.saved_screenshots.push(screenshot_save::Saved {
path: request.path,
result: Err(error),
hdr: None,
});
} else {
self.screenshot_result = Some(Err(error));
}
None
}
}
}
@@ -0,0 +1,252 @@
//! The camera's view frustum, as camera-relative clip planes.
use glam::{Vec3, Vec4};
/// Camera-relative clip planes (`Frustum`).
#[derive(Clone, Copy, Debug)]
pub struct Frustum {
pub(super) planes: [Vec4; 6],
}
impl Frustum {
/// Whether the camera-relative box `min..max` is at least partly inside.
pub fn intersects(&self, min: Vec3, max: Vec3) -> bool {
!self.planes.iter().any(|p| {
let sign = p.truncate().cmpge(Vec3::ZERO);
let v = Vec3::select(sign, max, min);
p.truncate().dot(v) + p.w < 0.0
})
}
/// Whether a camera-relative sphere is at least partly inside. Frustum
/// planes are normalized, so each test needs only one dot product and a
/// radius comparison.
pub fn intersects_sphere(&self, center: Vec3, radius: f32) -> bool {
if !center.is_finite() || !radius.is_finite() || radius < 0.0 {
return true;
}
!self
.planes
.iter()
.any(|plane| plane.truncate().dot(center) + plane.w < -radius)
}
/// Depth clip planes alone: X/Y projection jitter cannot change this test.
/// Retaining side-of-screen geometry makes this safe before TAA selects a phase.
pub fn intersects_depth(&self, min: Vec3, max: Vec3) -> bool {
if !min.is_finite() || !max.is_finite() || !min.cmple(max).all() {
return true;
}
!self.planes[4..].iter().any(|p| {
let sign = p.truncate().cmpge(Vec3::ZERO);
let v = Vec3::select(sign, max, min);
p.truncate().dot(v) + p.w < 0.0
})
}
/// Whether the camera-relative box `min..max` is entirely inside. Then
/// `intersects` accepts every box within it: each plane's distance only
/// grows from this box's nearest corner to the inner box's farthest, and
/// rounding keeps that order.
pub fn contains(&self, min: Vec3, max: Vec3) -> bool {
self.planes.iter().all(|p| {
let sign = p.truncate().cmpge(Vec3::ZERO);
let v = Vec3::select(sign, min, max);
p.truncate().dot(v) + p.w >= 0.0
})
}
/// Whether the camera-relative `point` is on or inside every plane (vanilla's
/// `Frustum.pointInFrustum`). A point that is not finite is kept: a test that cannot
/// tell must not hide the particle.
pub fn contains_point(&self, point: Vec3) -> bool {
if !point.is_finite() {
return true;
}
self.planes
.iter()
.all(|plane| plane.truncate().dot(point) + plane.w >= 0.0)
}
/// The same frustum seen from a camera moved `blocks` back along the view direction
/// (vanilla's `Frustum.offset`): the sides open up near the camera and the near plane
/// moves behind it, so what sits just behind or beside the camera stays inside.
///
/// The view direction is the normal of the depth planes (the two are opposite), signed
/// towards where the side planes point. A frustum that has no direction to pull along
/// comes back unchanged: a box with no depth planes (the shadow pass's), and a box whose
/// sides are parallel to the view direction (an orthographic projection), where the side
/// normals cancel and say nothing about which way is forward.
pub fn pulled_back(&self, blocks: f32) -> Frustum {
let sides: Vec3 = self.planes[..4].iter().map(|p| p.truncate()).sum();
let depth = self.planes[4].truncate();
let along = depth.dot(sides);
// A perspective frustum's four side normals each lean forward, so this is well above 1.
if along.abs() < 1.0e-3 {
return *self;
}
let toward_sides = if along > 0.0 { depth } else { -depth };
let axis = toward_sides.normalize_or_zero();
let mut planes = self.planes;
for plane in &mut planes {
plane.w += blocks * plane.truncate().dot(axis);
}
Frustum { planes }
}
/// The frustum of a camera-relative `view_proj`, for tests outside this module.
#[cfg(test)]
pub(crate) fn from_view_projection(view_proj: glam::Mat4) -> Frustum {
Frustum {
planes: super::frustum(view_proj),
}
}
}
#[cfg(test)]
mod tests {
use super::{Frustum, Vec3};
use crate::gfx::world_projection;
use glam::Mat4;
const FOV: f32 = 70.0;
const ASPECT: f32 = 16.0 / 9.0;
const FAR: f32 = 512.0;
const EPS: f32 = 0.01;
fn looking_down_minus_z() -> Frustum {
Frustum::from_view_projection(world_projection(FOV, ASPECT, FAR))
}
fn tangents() -> (f32, f32) {
let up = (FOV / 2.0).to_radians().tan();
(up * ASPECT, up)
}
/// Each of the six planes of the unmoved frustum, a point just inside and one just outside,
/// from the field of view worked out by hand.
#[test]
fn a_point_is_inside_only_within_all_six_planes() {
let view = looking_down_minus_z();
let (tan_x, tan_y) = tangents();
let depth = 10.0;
let inside = [
Vec3::new(0.0, 0.0, -depth),
Vec3::new(depth * tan_x - EPS, 0.0, -depth),
Vec3::new(-(depth * tan_x - EPS), 0.0, -depth),
Vec3::new(0.0, depth * tan_y - EPS, -depth),
Vec3::new(0.0, -(depth * tan_y - EPS), -depth),
Vec3::new(0.0, 0.0, -0.06),
Vec3::new(0.0, 0.0, -(FAR - 0.1)),
];
let outside = [
Vec3::new(depth * tan_x + EPS, 0.0, -depth),
Vec3::new(-(depth * tan_x + EPS), 0.0, -depth),
Vec3::new(0.0, depth * tan_y + EPS, -depth),
Vec3::new(0.0, -(depth * tan_y + EPS), -depth),
Vec3::new(0.0, 0.0, -0.04),
Vec3::new(0.0, 0.0, -(FAR + 0.1)),
Vec3::new(0.0, 0.0, 1.0),
Vec3::new(0.0, 0.0, 0.0),
];
for p in inside {
assert!(view.contains_point(p), "{p} should be inside");
}
for p in outside {
assert!(!view.contains_point(p), "{p} should be outside");
}
assert!(view.contains_point(Vec3::NAN), "an unknown point is kept");
}
/// Camera 3 blocks back: its apex is at z = 3, so the sides open by 3 * tan, the near
/// plane is at z = 3 - 0.05 and the far plane at z = 3 - FAR.
#[test]
fn pulling_the_camera_back_moves_every_plane() {
let (tan_x, tan_y) = tangents();
let back = looking_down_minus_z().pulled_back(3.0);
let inside = [
Vec3::new(0.0, 0.0, 2.9),
Vec3::new(3.0 * tan_x - EPS, 0.0, 0.0),
Vec3::new(-(3.0 * tan_x - EPS), 0.0, 0.0),
Vec3::new(0.0, 3.0 * tan_y - EPS, 0.0),
Vec3::new(0.0, -(3.0 * tan_y - EPS), 0.0),
Vec3::new(0.0, 0.0, 3.0 - FAR + 0.1),
];
let outside = [
Vec3::new(0.0, 0.0, 3.0),
Vec3::new(3.0 * tan_x + EPS, 0.0, 0.0),
Vec3::new(-(3.0 * tan_x + EPS), 0.0, 0.0),
Vec3::new(0.0, 3.0 * tan_y + EPS, 0.0),
Vec3::new(0.0, -(3.0 * tan_y + EPS), 0.0),
Vec3::new(0.0, 0.0, 3.0 - FAR - 0.1),
];
for p in inside {
assert!(back.contains_point(p), "{p} should be inside");
}
for p in outside {
assert!(!back.contains_point(p), "{p} should be outside");
}
// The unmoved frustum does not have the point that only the offset one has.
assert!(!looking_down_minus_z().contains_point(Vec3::new(0.0, 0.0, 2.9)));
// An offset of nothing changes no verdict.
let same = looking_down_minus_z();
let unmoved = same.pulled_back(0.0);
for p in [
Vec3::new(1.0, 2.0, -3.0),
Vec3::new(0.0, 0.0, 1.0),
Vec3::new(40.0, 0.0, -5.0),
] {
assert_eq!(same.contains_point(p), unmoved.contains_point(p));
}
}
/// The offset follows the camera's own view direction, whatever way it looks.
#[test]
fn the_offset_is_along_the_view_direction() {
for (yaw, pitch) in [(0.3_f32, 0.0_f32), (2.0, -0.5), (-1.2, 0.9), (3.0, 1.2)] {
let pose = Mat4::from_rotation_x(pitch) * Mat4::from_rotation_y(yaw);
let view = Frustum::from_view_projection(world_projection(FOV, ASPECT, FAR) * pose);
let forward = pose.inverse().transform_vector3(-Vec3::Z);
let back = view.pulled_back(3.0);
assert!(
back.contains_point(forward * -2.9),
"{yaw} {pitch}: 2.9 behind"
);
assert!(
!back.contains_point(forward * -3.0),
"{yaw} {pitch}: 3 behind"
);
assert!(
!view.contains_point(forward * -2.9),
"{yaw} {pitch}: unmoved"
);
assert!(back.contains_point(forward * 20.0) && view.contains_point(forward * 20.0));
}
}
/// An orthographic box has no apex to pull back from: its side normals cancel, so there is
/// no direction, and the frustum must come back as it was.
#[test]
fn an_orthographic_frustum_stays_where_it_is() {
let ortho =
Frustum::from_view_projection(Mat4::orthographic_rh(-8.0, 8.0, -4.5, 4.5, 100.0, 0.05));
let moved = ortho.pulled_back(3.0);
for p in [
Vec3::new(0.0, 0.0, 2.0),
Vec3::new(0.0, 0.0, -9.0),
Vec3::new(7.9, 4.4, -9.0),
Vec3::new(8.1, 0.0, -9.0),
Vec3::new(0.0, 0.0, -101.0),
] {
assert_eq!(ortho.contains_point(p), moved.contains_point(p), "{p}");
}
}
/// The shadow pass's box has no depth planes, so no direction: it must not move.
#[test]
fn a_frustum_without_depth_planes_stays_where_it_is() {
let mut flat = looking_down_minus_z();
flat.planes[4] = glam::Vec4::W;
flat.planes[5] = glam::Vec4::W;
let moved = flat.pulled_back(3.0);
for p in [
Vec3::new(0.0, 0.0, 2.0),
Vec3::new(0.0, 0.0, -9.0),
Vec3::new(80.0, 0.0, -9.0),
] {
assert_eq!(flat.contains_point(p), moved.contains_point(p));
}
}
}
@@ -853,7 +853,7 @@ impl H {
fn label(self, s: &Settings, out: &EffectiveOutput) -> Text {
let g = &s.horizon;
match self {
H::Preset => labelled("Preset", g.preset.label()),
H::Preset => labelled("Horizon Preset", g.preset.label()),
H::LodDistance => match g.lod_distance {
0 => labelled("LOD Distance", "Off"),
n => labelled("LOD Distance", lang::tr("%s chunks", &[&n])),
@@ -1753,6 +1753,14 @@ mod tests {
ui.set_search("tearing");
let found = ui.listed(&s, SIZE);
assert!(found.iter().any(|(_, i)| *i == Item::Vanilla(Opt::Vsync)));
// Both presets answer "preset"; Horizon's own, named apart from vanilla's, answers its name.
ui.set_search("preset");
let found = ui.listed(&s, SIZE);
let general = |item| (Page::General, item);
assert!(found.contains(&general(Item::Horizon(H::Preset))));
assert!(found.contains(&general(Item::Vanilla(Opt::Preset))));
ui.set_search("horizon preset");
assert_eq!(ui.listed(&s, SIZE), [general(Item::Horizon(H::Preset))]);
ui.set_search("hdr");
let [x, y, w, h] = cols.page(Page::ALL.len() - 1);
press(&mut ui, &mut s, (x + w / 2.0, y + h / 2.0));
+2 -2
View File
@@ -55,7 +55,7 @@ pub struct TextureFlags {
impl TextureFlags {
/// The `texture` section of a `.png.mcmeta` file, if it sets either flag.
pub fn from_mcmeta(json: &str) -> Option<Self> {
let meta = crate::packs::first_json(json.as_bytes())?;
let meta: serde_json::Value = crate::packs::first_json(json.as_bytes()).ok()?;
let section = meta.get("texture")?;
let flag = |key| section.get(key).and_then(serde_json::Value::as_bool) == Some(true);
let flags = Self {
@@ -388,7 +388,7 @@ fn strip_frame(pixels: &[u8], stride: u32, x: u32, y: u32, w: u32, h: u32) -> Ve
}
/// The `animation` section of a texture's `.mcmeta`.
fn animation_of(mcmeta: Option<&str>) -> Option<AnimationMetadata> {
let meta = crate::packs::first_json(mcmeta?.as_bytes())?;
let meta: serde_json::Value = crate::packs::first_json(mcmeta?.as_bytes()).ok()?;
AnimationMetadata::from_json(&meta.get("animation")?.to_string()).ok()
}
/// An animated strip's first frame: the region the mcmeta's frame size picks
@@ -39,14 +39,13 @@ pub fn text_with_backdrop(
background: u32,
) {
if background != 0 {
// Font.PreparedTextBuilder.markBackground: one pixel above and left of
// the glyphs, the glyph advance to the right and nine high: not a
// fixed two-pixel frame.
// GuiGraphicsExtractor.textWithBackdrop adds two pixels around the
// text's advance and the font's nine-pixel line height.
out.push(fill(
x - 1,
y - 1,
x + w,
y + 9,
x - 2,
y - 2,
x + w + 2,
y + 11,
multiply(background, color),
));
}
@@ -1491,10 +1490,10 @@ mod boss_bar_room_tests {
mod backdrop_tests {
use super::*;
/// `markBackground`: the backdrop reaches one pixel above and left of the
/// glyphs, their advance to the right and nine down, not 2 on every side.
/// `GuiGraphicsExtractor.textWithBackdrop`: two pixels around the advance
/// and the nine-pixel font line, independently of glyph-background drawing.
#[test]
fn the_backdrop_frames_the_glyphs_by_one_pixel() {
fn the_backdrop_frames_the_text_by_two_pixels() {
let mut out = Vec::new();
text_with_backdrop(
&mut out,
@@ -1508,6 +1507,6 @@ mod backdrop_tests {
let Some(GuiDraw::Fill { x0, y0, x1, y1, .. }) = out.first() else {
panic!("a backdrop is a fill first");
};
assert_eq!((*x0, *y0, *x1, *y1), (9, 19, 16, 29));
assert_eq!((*x0, *y0, *x1, *y1), (8, 18, 18, 31));
}
}
+34 -46
View File
@@ -1594,9 +1594,9 @@ pub struct OptionsUi {
radial_pick: shell::Shell<crate::radial_editor::PickScreen>,
/// Shaders: the shader packs and the picked one's options, on the settings shell.
shader: shell::Shell<crate::shader_ui::ShaderScreen>,
/// The Pack Options page, and the packs' Polytone config entries by pack id (the game
/// fills them beside `pack_infos`).
pub pack_options: crate::pack_options_ui::PackOptionsUi,
/// Pack Options, on the settings shell, and the packs' Polytone config entries by pack id
/// (the game fills them beside `pack_infos`).
pack_options: shell::Shell<crate::pack_options_ui::PackOptionsScreen>,
pub pack_configs: std::collections::BTreeMap<String, horizon_polytone::pack::Configs>,
pub split_profiles: crate::split_profiles::SplitProfiles,
/// HDR calibration while its page is open.
@@ -3401,10 +3401,6 @@ impl OptionsUi {
c.draw(batch, atlas, cursor);
return;
}
if self.page() == Page::PackOptions {
self.pack_options.draw(batch, atlas, cursor, self.in_world);
return;
}
let waiting = self.waiting();
let in_world = self.in_world;
if self.draw_radial_name(batch, atlas, cursor, in_world) {
@@ -3786,20 +3782,6 @@ impl OptionsUi {
});
return self.shell_events(s, outputs);
}
if self.page() == Page::PackOptions {
let mut events = Vec::new();
for action in self
.pack_options
.click((x, y), size, drag, right || self.shift)
{
match action {
crate::pack_options_ui::Action::ButtonClick => events.push(Event::ButtonClick),
crate::pack_options_ui::Action::SliderDrag => {}
crate::pack_options_ui::Action::Done => events.extend(self.back(s)),
}
}
return events;
}
if self.page() == Page::Packs {
let mut events = Vec::new();
if drag {
@@ -3829,14 +3811,7 @@ impl OptionsUi {
crate::pack_screen::Action::BrowsePick(i) => {
events.push(Event::DownloadPack(i));
}
crate::pack_screen::Action::Options(id) => {
let mut configs = horizon_polytone::pack::Configs::default();
if let Some(own) = self.pack_configs.get(&id) {
configs.merge(own);
}
self.pack_options = crate::pack_options_ui::PackOptionsUi::open(configs);
self.push(Page::PackOptions);
}
crate::pack_screen::Action::Options(id) => self.open_pack_options(&id),
crate::pack_screen::Action::None => {}
}
return events;
@@ -4074,7 +4049,6 @@ impl OptionsUi {
// release, even if the pointer has left the widget or its value stayed.
let slider = self.dragging.take().is_some();
let shell_slider = self.shell_mut().and_then(|shell| shell.release());
self.pack_options.release();
let calibration_slider = self.calibration.as_mut().is_some_and(|c| c.release());
let split_drop = self.split_setup.release();
if slider || shell_slider.is_some() || calibration_slider {
@@ -4168,10 +4142,6 @@ impl OptionsUi {
self.studio.wheel(amount);
return Vec::new();
}
if self.page() == Page::PackOptions {
self.pack_options.scroll(size, amount);
return Vec::new();
}
let plain = self.resource_error.is_none()
&& !(self.pack_screen.is_some() && self.stack.last() == Some(&Page::Packs));
if !plain {
@@ -4255,10 +4225,6 @@ impl OptionsUi {
self.studio.wheel(amount);
return;
}
if self.page() == Page::PackOptions {
self.pack_options.scroll(size, amount);
return;
}
match &mut self.pack_screen {
Some(p) if self.stack.last() == Some(&Page::Packs) => {
p.scroll(cursor.0 as f64, cursor.1 as f64, amount as f64)
@@ -4393,14 +4359,8 @@ impl OptionsUi {
self.can_change_value = false;
match page {
Page::PackOptions => {
let ui = std::mem::take(&mut self.pack_options);
if ui.changed {
let entries = ui.configs.entries().keys().cloned().collect();
events.push(Event::PackOptions {
entries,
chosen: ui.configs.chosen(),
});
}
let screen = std::mem::take(&mut self.pack_options.screen);
events.extend(screen.finished());
}
Page::ShaderOptions | Page::ShaderPacks => {
if s.shader_options.as_ref().is_some_and(|o| o.changed) {
@@ -4960,6 +4920,34 @@ mod tests {
/// A renderer whose destination offers nothing and whose display reports nothing.
const NONE: EffectiveOutput = EffectiveOutput::NONE;
#[test]
fn pack_options_leave_with_a_change_and_with_nothing_after_its_undo() {
use crate::settings_shell::Screen as _;
let mut s = Settings::default();
let atlas = HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets"));
let atlas = atlas.unwrap();
let mut ui = OptionsUi::default();
let mut configs = horizon_polytone::pack::Configs::default();
let birds = serde_json::json!({"default_value": true});
configs.add_entry("p:birds".into(), &birds);
ui.pack_configs.insert("pack".into(), configs);
for undone in [false, true] {
ui.open_pack_options("pack");
assert!(ui.shell().is_some(), "Pack Options is on the shell");
let birds = crate::pack_options_ui::Item::Entry(0);
ui.pack_options.screen.press(&mut s, birds, false);
if undone {
let before = s.clone();
ui.pack_options.screen.undo(&mut s, &before);
}
let events = ui.key(&mut s, &atlas, winit::keyboard::KeyCode::Escape, None);
let sent = events
.iter()
.any(|e| matches!(e, Event::PackOptions { .. }));
assert_eq!(sent, !undone, "undone: {undone}");
}
}
/// The window allows the input method only while `typing` says a search
/// box has the focus (`Game::text_field_focused`), so a page without one
/// must not claim the keys: the movement and menu keys keep working there.
@@ -23,6 +23,7 @@ impl OptionsUi {
Page::ControllerRadialPick => Some(&self.radial_pick),
page if Self::controller_page(page).is_some() => Some(&self.controller),
page if Self::shader_page(page).is_some() => Some(&self.shader),
Page::PackOptions => Some(&self.pack_options),
_ => None,
}
}
@@ -35,9 +36,20 @@ impl OptionsUi {
Page::ControllerRadialPick => Some(&mut self.radial_pick),
page if Self::controller_page(page).is_some() => Some(&mut self.controller),
page if Self::shader_page(page).is_some() => Some(&mut self.shader),
Page::PackOptions => Some(&mut self.pack_options),
_ => None,
}
}
/// Pack Options for the pack `id`, from the pack screen: its Polytone config entries.
pub(super) fn open_pack_options(&mut self, id: &str) {
let mut configs = horizon_polytone::pack::Configs::default();
if let Some(own) = self.pack_configs.get(id) {
configs.merge(own);
}
// Opening a shell starts it afresh: the entries go in after.
self.push(Page::PackOptions);
self.pack_options.screen = crate::pack_options_ui::PackOptionsScreen::open(configs);
}
/// Where the open shell screen asked the focus to open (a radial slot's list, on the slot's
/// action), for a controller's focus to land there.
pub fn shell_opened_focus(&self) -> Option<[f32; 4]> {
@@ -53,13 +65,14 @@ impl OptionsUi {
return;
}
// Every shell: the click may have changed the page.
let shells: [&mut dyn shell::Hosted; 6] = [
let shells: [&mut dyn shell::Hosted; 7] = [
&mut self.extras,
&mut self.graphics,
&mut self.controller,
&mut self.radial,
&mut self.radial_pick,
&mut self.shader,
&mut self.pack_options,
];
for shell in shells {
shell.clicks_step(false);
@@ -1,45 +1,36 @@
//! The Pack Options page (#11486): the config entries of the selected resource packs' Polytone
//! `config_entries` (wiki "Polytone Configs"), in the frame of Horizon's settings screens
//! (`settings_shell`). Numbers are sliders, texts cycle, booleans switch; the entries sit in
//! their `section`s, named by the packs' own translations. Leaving with Done keeps what changed,
//! and the packs load again with it.
use crate::widgets::Action as Declared;
//! The Pack Options screen (#11486), on the settings shell (`settings_shell`): the config entries
//! of the selected resource packs' Polytone `config_entries` (wiki "Polytone Configs"). Each
//! `section` is a page, named by the packs' own translations; numbers are sliders that stop at
//! the entry's `step`, value lists are choices, booleans switch and free text reads. The first
//! page starts with the pack-wide presets and Reset all. The shell gives it the keyboard, a
//! controller's focus, the search, the strip's descriptions and Undo; leaving keeps what differs
//! from how the screen opened, and the packs load again with it.
use crate::{
graphics_style as style,
hud::{HudAtlas, HudBatch},
lang,
settings_shell::{self as shell, Columns, Line, Lines},
text::Text,
ui,
settings::Settings,
settings_shell::{self as shell, Group, Output, Row, Sheet, Value},
};
use horizon_polytone::pack::{ConfigKind, ConfigValue, Configs};
/// What a click asks of the options screen.
/// A row: the pack-wide presets, Reset all, or the entry at an index of `ids`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Action {
ButtonClick,
SliderDrag,
/// Done: leave, keeping what changed.
Done,
pub(crate) enum Item {
Presets,
ResetAll,
Entry(u16),
}
/// The page's state: the entries being edited, how they are listed, and the pointer's hold.
#[derive(Default)]
pub struct PackOptionsUi {
/// The selected packs' entries and the values being picked.
pub configs: Configs,
/// Entry ids, by the row index the list uses.
ids: Vec<String>,
/// The sections in order: the `section` id (none for entries without one) and its rows.
sections: Vec<(Option<String>, Vec<u16>)>,
scroll: ui::Scroll,
dragging: Option<u16>,
/// A value changed since the page opened.
pub changed: bool,
}
/// The presets' row (and its section): past every entry's index.
const PRESETS: u16 = u16::MAX;
const TITLE: &str = "horizon.pack.options.title";
/// The first page's group of rows for the whole pack, and every page's group of entries.
const WHOLE_PACK: &str = "Whole pack";
const PACK_OPTIONS: &str = "Pack settings";
const RESET_ALL: &str = "Reset all";
const RESET_ABOUT: &str = "Puts every option of this pack back to its default.";
const AT_DEFAULTS: &str = "Every option is at its default already.";
const PRESETS_ABOUT: &str = "Sets many options at once, as the pack groups them.";
const PACK_SETTINGS: &str = "Settings made by the pack's author: they change only this pack.";
/// A pack's tooltips, with the performance line after them, run longer than Horizon's own.
const NOTE_LINES: usize = 3;
/// A translation, or `fallback` where no language has the key.
fn translated(key: &str, fallback: impl FnOnce() -> String) -> String {
@@ -65,9 +56,34 @@ fn readable(path: &str) -> String {
.unwrap_or_default()
}
impl PackOptionsUi {
/// The page opens on `configs`, the selected packs' entries with the player's values.
pub fn open(configs: Configs) -> Self {
/// A number as its entry shows it: whole with a whole step, else up to two decimals.
fn number_text(n: f64, step: f64) -> String {
if step >= 1.0 && step.fract() == 0.0 {
format!("{n:.0}")
} else {
let text = format!("{n:.2}");
text.trim_end_matches('0').trim_end_matches('.').to_owned()
}
}
/// The screen: the entries being edited, as they were when it opened (for Undo and for whether
/// leaving changes anything), and how they are paged.
#[derive(Default)]
pub(crate) struct PackOptionsScreen {
/// The selected packs' entries and the values being picked.
pub(crate) configs: Configs,
opened: Configs,
/// Entry ids, by `Item::Entry`'s index.
ids: Vec<String>,
/// The sections in order: the `section` id (none for entries without one) and its entries.
sections: Vec<(Option<String>, Vec<u16>)>,
/// Bumped on every change, for `stamp`: the values are floats, which don't hash.
revision: u64,
}
impl PackOptionsScreen {
/// The screen opens on `configs`, the selected packs' entries with the player's values.
pub(crate) fn open(configs: Configs) -> Self {
let mut ids: Vec<(String, Option<String>, i64)> = configs
.entries()
.iter()
@@ -96,25 +112,21 @@ impl PackOptionsUi {
(None, None) => a.0.cmp(&b.0),
});
Self {
opened: configs.clone(),
configs,
ids: ids.into_iter().map(|(id, ..)| id).collect(),
sections: sections.into_iter().map(|(s, _, rows)| (s, rows)).collect(),
..Self::default()
revision: 0,
}
}
fn layout(&self, w: f32, h: f32) -> (Columns, Vec<Line<u16, u16>>, f32) {
let cols = Columns::of(w, h);
let mut lines = Lines::new();
// The pack-wide presets first, when the entries have some.
if !self.configs.presets().is_empty() {
lines.titled(PRESETS, vec![PRESETS]);
}
for (i, (_, rows)) in self.sections.iter().enumerate() {
lines.titled(i as u16, rows.clone());
}
let (lines, content) = lines.finish();
(cols, lines, content)
/// What leaving sends: the entries and the player's choices, if they differ from how the
/// screen opened (an Undo, or a value stepped back, leaves nothing to send).
pub(crate) fn finished(&self) -> Option<crate::options_ui::Event> {
(self.configs != self.opened).then(|| crate::options_ui::Event::PackOptions {
entries: self.configs.entries().keys().cloned().collect(),
chosen: self.configs.chosen(),
})
}
/// The namespace the presets' translations are under: of the first entry with presets.
@@ -166,21 +178,14 @@ impl PackOptionsUi {
None if back => n - 1,
None => 0,
};
let before = self.configs.clone();
self.configs.apply_preset(&presets[next]);
if self.configs != before {
self.changed = true;
}
self.revision += 1;
}
/// A section's heading: `config.<namespace>.section.<id>`, by its first entry's namespace.
fn section_name(&self, section: u16) -> String {
if section == PRESETS {
let key = format!("{}.presets", self.presets_namespace());
return translated(&key, || lang::get("horizon.pack.options.presets"));
}
let Some((id, rows)) = self.sections.get(usize::from(section)) else {
return String::new();
/// A section's page name: `config.<namespace>.section.<id>`, by its first entry's namespace.
fn section_name(&self, section: usize) -> String {
let Some((id, rows)) = self.sections.get(section) else {
return lang::get("horizon.pack.options.general");
};
let Some(id) = id else {
return lang::get("horizon.pack.options.general");
@@ -192,367 +197,218 @@ impl PackOptionsUi {
translated(&format!("config.{namespace}.section.{id}"), || readable(id))
}
/// An entry's name (`config.<namespace>.<name>`) and its value's label.
fn parts(&self, row: u16) -> (String, Option<String>) {
if row == PRESETS {
let label = self.preset_label(self.current_preset().as_deref());
return (self.section_name(PRESETS), Some(label));
}
let Some(id) = self.ids.get(usize::from(row)) else {
return (String::new(), None);
};
let (namespace, path) = split(id);
let name = translated(&format!("config.{namespace}.{path}"), || readable(path));
let entry = &self.configs.entries()[id];
let value = match self.configs.get(id) {
Some(ConfigValue::Number(n)) => {
let step = match entry.kind {
ConfigKind::Number { step, .. } => step,
_ => 1.0,
};
let text = if step >= 1.0 && step.fract() == 0.0 {
format!("{n:.0}")
} else {
format!("{n:.2}")
.trim_end_matches('0')
.trim_end_matches('.')
.to_owned()
};
Some(text)
}
Some(ConfigValue::Text(t)) => Some(t.clone()),
Some(ConfigValue::Bool(_)) | None => None,
};
let value = value.map(|v| match &entry.value_translation {
Some(key) if lang::has(key) => lang::tr(key, &[&v]),
_ => v,
});
(name, value)
}
/// The tooltip (`config.<namespace>.<name>.tooltip`) and the performance hint.
fn description(&self, row: u16) -> String {
let Some(id) = self.ids.get(usize::from(row)) else {
return String::new();
};
/// The tooltip (`config.<namespace>.<name>.tooltip`) and the performance line after it.
fn description(&self, id: &str) -> String {
let (namespace, path) = split(id);
let key = format!("config.{namespace}.{path}.tooltip");
let mut text = if lang::has(&key) {
let tooltip = if lang::has(&key) {
lang::get(&key)
} else {
String::new()
};
if let Some(impact) = &self.configs.entries()[id].performance_impact {
let level = lang::get(&format!("horizon.pack.options.impact.{impact}"));
let line = lang::tr("horizon.pack.options.impact", &[&level]);
text = if text.is_empty() {
line
} else {
format!("{text} {line}")
};
}
text
}
/// A slider's place (0 to 1) for a number entry.
fn slider(&self, row: u16) -> Option<f32> {
let id = self.ids.get(usize::from(row))?;
let ConfigKind::Number { min, max, .. } = self.configs.entries()[id].kind else {
return None;
};
let Some(ConfigValue::Number(n)) = self.configs.get(id) else {
return None;
};
Some(if max > min {
((n - min) / (max - min)) as f32
} else {
0.0
})
}
fn switch(&self, row: u16) -> Option<bool> {
match self.configs.get(self.ids.get(usize::from(row))?)? {
ConfigValue::Bool(b) => Some(*b),
_ => None,
let impact = self.configs.entries()[id]
.performance_impact
.as_ref()
.map(|impact| {
let level = lang::get(&format!("horizon.pack.options.impact.{impact}"));
lang::tr("horizon.pack.options.impact", &[&level])
});
match impact {
Some(line) if tooltip.is_empty() => line,
Some(line) => format!("{tooltip} {line}"),
None => tooltip,
}
}
pub fn draw(
&mut self,
batch: &mut HudBatch,
atlas: &HudAtlas,
cursor: (f32, f32),
in_world: bool,
) {
let (w, h) = (batch.width, batch.height);
let (cols, lines, content) = self.layout(w, h);
self.scroll.clamp(cols.top, cols.bottom, content);
shell::backdrop(batch, atlas, in_world);
shell::mark(
batch,
atlas,
&cols,
&lang::get("horizon.pack.options.title"),
);
shell::plate(
batch,
atlas,
&cols,
&lang::get("horizon.pack.options.title"),
);
let over_list = cursor.1 >= cols.top && cursor.1 < cols.bottom;
let mut described = None;
shell::list(
batch,
atlas,
&cols,
&lines,
self.scroll.amount,
|section| self.section_name(section),
|batch, row: u16, y| {
let [rx, ry, rw, rh] = cols.row(y);
let hover = over_list && ui::hovered(cursor, rx, ry, rw, rh);
if hover || self.dragging == Some(row) {
described = Some(row);
/// Entry `i`'s row: a switch, a slider in its steps, a choice, or text to read.
fn entry_row(&self, i: u16) -> Option<Row<Item>> {
let id = self.ids.get(usize::from(i))?;
let (namespace, path) = split(id);
let name = translated(&format!("config.{namespace}.{path}"), || readable(path));
let entry = &self.configs.entries()[id];
let shown = |text: String| match &entry.value_translation {
Some(key) if lang::has(key) => lang::tr(key, &[&text]),
_ => text,
};
let value = match (&entry.kind, self.configs.get(id)?) {
(_, ConfigValue::Bool(on)) => Value::Switch(*on),
(&ConfigKind::Number { min, max, step }, &ConfigValue::Number(n)) => {
let span = max - min;
let stops = if step > 0.0 {
(span / step).round()
} else {
0.0
};
Value::Slider {
position: if span > 0.0 {
((n - min) / span) as f32
} else {
0.0
},
steps: (stops >= 1.0).then_some(stops as u32),
text: shown(number_text(n, step)),
}
let (name, value) = self.parts(row);
let label = match &value {
Some(v) => format!("{name}: {v}"),
None => name.clone(),
};
let view = shell::RowView {
label: &label,
name: &name,
value: value.as_deref(),
switch: self.switch(row),
slider: self.slider(row),
live: true,
hover,
focused: false,
held: self.dragging == Some(row),
key: None,
};
shell::row(batch, atlas, &cols, y, &view);
},
);
shell::scroller(batch, atlas, &cols, content, self.scroll.amount);
// The strip: the pointed-at entry's tooltip.
let [x, y, ..] = cols.dock;
style::panel(batch, atlas, cols.dock);
if let Some(row) = described {
let name = Text::literal(self.parts(row).0);
batch.text_styled(atlas, &name, x + 12.0, y + 8.0, style::WHITE, true);
shell::dock_note(batch, atlas, &cols, self.description(row), style::GREY);
}
let mut foot = vec![(cols.button(0), "gui.done", Some(Declared::Done))];
if self
.configs
.chosen()
.iter()
.any(|(id, _)| self.ids.contains(id))
{
foot.push((cols.button(1), "controls.reset", None));
}
for (rect, key, declared) in foot {
if let Some(declared) = declared {
crate::widgets::declare_action(declared, rect, true);
}
let label = Text::literal(lang::get(key));
shell::foot_button(batch, atlas, rect, &label, cursor, (false, true));
}
(ConfigKind::Text { allowed }, ConfigValue::Text(t)) if !allowed.is_empty() => {
Value::Choice(shown(t.clone()))
}
(_, value) => Value::Read(shown(match value {
ConfigValue::Number(n) => number_text(*n, 1.0),
ConfigValue::Text(t) => t.clone(),
ConfigValue::Bool(b) => b.to_string(),
})),
};
Some(Row::new(Item::Entry(i), name, value).about(self.description(id)))
}
/// A press (or a drag with `drag`) at `(x, y)`; `back` turns a cycle backwards.
pub fn click(
&mut self,
(x, y): (f32, f32),
(w, h): (f32, f32),
drag: bool,
back: bool,
) -> Vec<Action> {
let (cols, lines, content) = self.layout(w, h);
let at = |[rx, ry, rw, rh]: [f32; 4]| ui::hovered((x, y), rx, ry, rw, rh);
if drag {
if self.scroll.held() {
self.scroll.drag(y, cols.top, cols.bottom, content);
} else if let Some(row) = self.dragging {
self.set_slider(row, cols.slider_at(x));
return vec![Action::SliderDrag];
}
return Vec::new();
/// The rows for the whole pack: its presets, when it has some, and Reset all.
fn whole_pack(&self) -> Group<Item> {
let mut rows = Vec::new();
if !self.configs.presets().is_empty() {
let key = format!("{}.presets", self.presets_namespace());
let name = translated(&key, || lang::get("horizon.pack.options.presets"));
let label = self.preset_label(self.current_preset().as_deref());
rows.push(
Row::new(Item::Presets, name, Value::Choice(label)).about(lang::get(PRESETS_ABOUT)),
);
}
if cols.scrolls()
&& self
.scroll
.press((x, y), cols.bar_x(), cols.top, cols.bottom, content)
{
return Vec::new();
let at_defaults = self.configs.chosen().is_empty();
let reset = Row::new(Item::ResetAll, lang::get(RESET_ALL), Value::Press)
.about(lang::get(RESET_ABOUT))
.blocked(at_defaults.then(|| lang::get(AT_DEFAULTS)));
rows.push(reset);
Group {
name: WHOLE_PACK,
rows,
}
if at(cols.button(0)) {
return vec![Action::ButtonClick, Action::Done];
}
if at(cols.button(1)) {
self.reset();
return vec![Action::ButtonClick];
}
if y < cols.top || y >= cols.bottom {
return Vec::new();
}
let Some(row) = lines.iter().find_map(|line| {
let row = line.setting?;
at(cols.row(cols.top + line.y - self.scroll.amount)).then_some(row)
}) else {
return Vec::new();
};
if row == PRESETS {
self.cycle_preset(back);
return vec![Action::ButtonClick];
}
let Some(id) = self.ids.get(usize::from(row)).cloned() else {
return Vec::new();
};
let entry = self.configs.entries()[&id].clone();
match (&entry.kind, self.configs.get(&id).cloned()) {
(ConfigKind::Number { .. }, _) => {
self.dragging = Some(row);
self.set_slider(row, cols.slider_at(x));
return vec![Action::SliderDrag];
}
(ConfigKind::Bool, Some(ConfigValue::Bool(b))) => {
self.set(&id, ConfigValue::Bool(!b));
}
(ConfigKind::Text { allowed }, Some(ConfigValue::Text(t))) if !allowed.is_empty() => {
let i = allowed.iter().position(|a| *a == t).unwrap_or(0);
let n = allowed.len();
let next = if back { (i + n - 1) % n } else { (i + 1) % n };
self.set(&id, ConfigValue::Text(allowed[next].clone()));
}
_ => return Vec::new(),
}
vec![Action::ButtonClick]
}
fn set(&mut self, id: &str, value: ConfigValue) {
let before = self.configs.get(id).cloned();
self.configs.set(id, value);
if self.configs.get(id).cloned() != before {
self.changed = true;
}
}
/// Number entry `row` at slider place `at` (0 to 1).
fn set_slider(&mut self, row: u16, at: f32) {
let Some(id) = self.ids.get(usize::from(row)).cloned() else {
return;
};
if let ConfigKind::Number { min, max, .. } = self.configs.entries()[&id].kind {
self.set(&id, ConfigValue::Number(min + f64::from(at) * (max - min)));
}
self.revision += 1;
}
/// Every entry back to its default.
fn reset(&mut self) {
let defaults: Vec<_> = self
.configs
.entries()
.iter()
let defaults: Vec<_> = (self.configs.entries().iter())
.map(|(id, e)| (id.clone(), e.default.clone()))
.collect();
for (id, value) in defaults {
self.set(&id, value);
}
}
}
/// The pointer lets go.
pub fn release(&mut self) {
self.scroll.release();
self.dragging = None;
impl shell::Screen for PackOptionsScreen {
type Page = u16;
type Row = Item;
fn title(&self) -> &'static str {
TITLE
}
/// The mouse wheel scrolls the list.
pub fn scroll(&mut self, (w, h): (f32, f32), amount: f32) {
let (cols, _, content) = self.layout(w, h);
self.scroll
.wheel(amount, 18.0, cols.top, cols.bottom, content);
fn note_lines(&self) -> usize {
NOTE_LINES
}
/// The rows read only the screen's own entries, which change through it alone.
fn stamp(&self, _s: &Settings) -> u64 {
self.revision
}
/// A page per section; the first starts with the rows for the whole pack.
fn sheets(&self, _s: &Settings) -> Vec<Sheet<u16, Item>> {
let about = lang::get(PACK_SETTINGS);
let mut sheets = Vec::new();
for (i, (_, entries)) in self.sections.iter().enumerate() {
let rows = entries.iter().filter_map(|&e| self.entry_row(e)).collect();
let mut groups = vec![Group {
name: PACK_OPTIONS,
rows,
}];
if i == 0 {
groups.insert(0, self.whole_pack());
}
sheets.push(Sheet::new(
i as u16,
self.section_name(i),
about.clone(),
groups,
));
}
if sheets.is_empty() {
let groups = vec![self.whole_pack()];
sheets.push(Sheet::new(0, self.section_name(0), about, groups));
}
sheets
}
/// A switch flips, a choice and the presets step (back with Shift, or Left), Reset all
/// resets.
fn press(&mut self, _s: &mut Settings, item: Item, back: bool) -> Vec<Output> {
let i = match item {
Item::Presets => {
self.cycle_preset(back);
return Vec::new();
}
Item::ResetAll => {
self.reset();
return Vec::new();
}
Item::Entry(i) => i,
};
let Some(id) = self.ids.get(usize::from(i)).cloned() else {
return Vec::new();
};
let kind = self.configs.entries()[&id].kind.clone();
match (kind, self.configs.get(&id).cloned()) {
(_, Some(ConfigValue::Bool(on))) => self.set(&id, ConfigValue::Bool(!on)),
(ConfigKind::Text { allowed }, Some(ConfigValue::Text(t))) if !allowed.is_empty() => {
let at = allowed.iter().position(|a| *a == t).unwrap_or(0);
let n = allowed.len();
let next = if back { (at + n - 1) % n } else { (at + 1) % n };
self.set(&id, ConfigValue::Text(allowed[next].clone()));
}
_ => {}
}
Vec::new()
}
/// A slider moves its entry to the step nearest it (`Configs::set` snaps it).
fn slide(&mut self, _s: &mut Settings, item: Item, position: f32) {
let Item::Entry(i) = item else { return };
let Some(id) = self.ids.get(usize::from(i)).cloned() else {
return;
};
if let ConfigKind::Number { min, max, .. } = self.configs.entries()[&id].kind {
let value = min + f64::from(position.clamp(0.0, 1.0)) * (max - min);
self.set(&id, ConfigValue::Number(value));
}
}
/// With the "Scroll wheel changes settings" extra, the wheel steps a choice and the presets.
fn wheel_step(&mut self, s: &mut Settings, item: Item, back: bool) -> Option<Vec<Output>> {
let steps = match item {
Item::Presets => true,
Item::Entry(i) => self
.entry_row(i)
.is_some_and(|row| matches!(row.value, Value::Choice(_))),
Item::ResetAll => false,
};
if !steps {
return None;
}
let mut outputs = vec![Output::Event(crate::options_ui::Event::ButtonClick)];
outputs.extend(self.press(s, item, back));
Some(outputs)
}
fn undoes(&self) -> bool {
true
}
/// The entries as the screen opened with them.
fn undo(&mut self, _s: &mut Settings, _before: &Settings) -> Vec<Output> {
self.configs = self.opened.clone();
self.revision += 1;
Vec::new()
}
/// Undo shows exactly while an entry differs from how the screen opened.
fn differs(&self, _s: &Settings, _before: &Settings) -> Option<bool> {
Some(self.configs != self.opened)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn configs() -> Configs {
let mut c = Configs::default();
c.add_entry(
"p:fog".into(),
&serde_json::json!({"default_value": 0.5, "section": "sky", "section_order": 2}),
);
c.add_entry("p:rain".into(), &serde_json::json!({"default_value": "drops", "allowed_values": ["drops", "streaks"], "section": "weather", "section_order": 1}));
c.add_entry(
"p:birds".into(),
&serde_json::json!({"default_value": true}),
);
c
}
#[test]
fn sections_sort_by_their_order_and_the_unsectioned_last() {
let ui = PackOptionsUi::open(configs());
let order: Vec<_> = ui.sections.iter().map(|(s, _)| s.as_deref()).collect();
assert_eq!(order, [Some("weather"), Some("sky"), None]);
}
#[test]
fn rows_switch_cycle_and_slide() {
let mut ui = PackOptionsUi::open(configs());
let size = (480.0, 270.0);
let (cols, lines, _) = ui.layout(size.0, size.1);
let ids = ui.ids.clone();
let point = |id: &str| {
let row = ids.iter().position(|i| i == id).unwrap() as u16;
let line = lines.iter().find(|l| l.setting == Some(row)).unwrap();
let [x, y, w, h] = cols.row(cols.top + line.y);
(x + w - 4.0, y + h / 2.0)
};
let (birds, rain) = (point("p:birds"), point("p:rain"));
ui.click(birds, size, false, false);
assert_eq!(ui.configs.get("p:birds"), Some(&ConfigValue::Bool(false)));
ui.click(rain, size, false, false);
assert_eq!(
ui.configs.get("p:rain"),
Some(&ConfigValue::Text("streaks".into()))
);
// The slider's far right end is its maximum.
let fog = point("p:fog");
let (tx, tw) = cols.track();
ui.click((tx + tw, fog.1), size, false, false);
ui.release();
assert_eq!(ui.configs.get("p:fog"), Some(&ConfigValue::Number(1.0)));
assert!(ui.changed);
assert_eq!(ui.configs.chosen().len(), 3);
ui.reset();
assert!(ui.configs.chosen().is_empty());
}
#[test]
fn the_presets_row_cycles_the_pack_wide_presets_and_shows_custom_otherwise() {
let mut c = Configs::default();
let entry = |default: f64, fast: f64, pretty: f64| serde_json::json!({"default_value": default, "presets": {"fast": fast, "pretty": pretty}});
c.add_entry("p:fog".into(), &entry(0.5, 0.0, 1.0));
c.add_entry("p:dust".into(), &entry(0.5, 0.0, 0.8));
let mut ui = PackOptionsUi::open(c);
// The defaults are no preset.
assert_eq!(ui.current_preset(), None);
ui.cycle_preset(false);
assert_eq!(ui.current_preset().as_deref(), Some("fast"));
assert_eq!(ui.configs.get("p:fog"), Some(&ConfigValue::Number(0.0)));
ui.cycle_preset(false);
assert_eq!(ui.current_preset().as_deref(), Some("pretty"));
ui.cycle_preset(true);
assert_eq!(ui.current_preset().as_deref(), Some("fast"));
// One value off the preset: custom again.
ui.set("p:dust", ConfigValue::Number(0.3));
assert_eq!(ui.current_preset(), None);
assert!(ui.changed);
}
}
mod tests;
@@ -0,0 +1,281 @@
use super::*;
use crate::options_ui::Event;
use crate::settings_shell::{Columns, Hosted, Screen as _, Shell, Target};
use winit::keyboard::KeyCode as K;
const SIZE: (f32, f32) = (854.0, 480.0);
/// Fog (a number) under Sky, rain (a list of values) under Weather, birds (a boolean) in no
/// section.
fn configs() -> Configs {
let mut c = Configs::default();
c.add_entry(
"p:fog".into(),
&serde_json::json!({"default_value": 0.5, "section": "sky", "section_order": 2}),
);
c.add_entry("p:rain".into(), &serde_json::json!({"default_value": "drops", "allowed_values": ["drops", "streaks"], "section": "weather", "section_order": 1}));
c.add_entry(
"p:birds".into(),
&serde_json::json!({"default_value": true}),
);
c
}
/// Two entries with the pack-wide presets `fast` and `pretty`.
fn with_presets() -> Configs {
let mut c = Configs::default();
let entry = |default: f64, fast: f64, pretty: f64| serde_json::json!({"default_value": default, "presets": {"fast": fast, "pretty": pretty}});
c.add_entry("p:fog".into(), &entry(0.5, 0.0, 1.0));
c.add_entry("p:dust".into(), &entry(0.5, 0.0, 0.8));
c
}
fn shell_of(configs: Configs) -> Shell<PackOptionsScreen> {
let mut shell = Shell::default();
shell.screen = PackOptionsScreen::open(configs);
shell
}
/// The row of entry `id`, and the page it is on.
fn entry(shell: &Shell<PackOptionsScreen>, id: &str) -> (u16, Item) {
let i = shell.screen.ids.iter().position(|e| e == id).unwrap() as u16;
let page = shell.screen.sections.iter().position(|s| s.1.contains(&i));
(page.unwrap() as u16, Item::Entry(i))
}
fn press(shell: &mut Shell<PackOptionsScreen>, s: &mut Settings, item: Item) -> Vec<Output> {
let at = shell.point_at(s, SIZE, item);
shell.click(s, at, SIZE, false, false, false)
}
fn value<'a>(shell: &'a Shell<PackOptionsScreen>, id: &str) -> Option<&'a ConfigValue> {
shell.screen.configs.get(id)
}
#[test]
fn sections_sort_by_their_order_and_the_unsectioned_last() {
let screen = PackOptionsScreen::open(configs());
let order: Vec<_> = screen.sections.iter().map(|(s, _)| s.as_deref()).collect();
assert_eq!(order, [Some("weather"), Some("sky"), None]);
// Each section is a page, the first one holding Reset all before its entries.
let sheets = screen.sheets(&Settings::default());
let names: Vec<_> = sheets.iter().map(|p| p.name.as_str()).collect();
assert_eq!(names, ["Weather", "Sky", "horizon.pack.options.general"]);
let first: Vec<Vec<Item>> = (sheets[0].groups.iter())
.map(|g| g.rows.iter().map(|r| r.id).collect())
.collect();
let rain = screen.ids.iter().position(|e| e == "p:rain").unwrap() as u16;
assert_eq!(first, [vec![Item::ResetAll], vec![Item::Entry(rain)]]);
}
#[test]
fn rows_switch_cycle_and_slide() {
let mut s = Settings::default();
let mut shell = shell_of(configs());
let (page, birds) = entry(&shell, "p:birds");
shell.show(page);
assert_eq!(
press(&mut shell, &mut s, birds)[0],
Output::Event(Event::ButtonClick)
);
assert_eq!(value(&shell, "p:birds"), Some(&ConfigValue::Bool(false)));
let (page, rain) = entry(&shell, "p:rain");
shell.show(page);
press(&mut shell, &mut s, rain);
let streaks = ConfigValue::Text("streaks".into());
assert_eq!(value(&shell, "p:rain"), Some(&streaks));
// The slider's far right end is its maximum.
let (page, fog) = entry(&shell, "p:fog");
shell.show(page);
let at = shell.point_at(&s, SIZE, fog);
let (track, width) = Columns::of(SIZE.0, SIZE.1).track();
shell.click(&mut s, (track + width, at.1), SIZE, false, false, false);
shell.release();
assert_eq!(value(&shell, "p:fog"), Some(&ConfigValue::Number(1.0)));
assert_eq!(shell.screen.configs.chosen().len(), 3);
// Reset all, on the first page, puts them all back, and is greyed once they are.
shell.show(0);
press(&mut shell, &mut s, Item::ResetAll);
assert!(shell.screen.configs.chosen().is_empty());
let sheets = shell.screen.sheets(&s);
assert!(!sheets[0].groups[0].rows[0].live);
}
#[test]
fn the_presets_row_cycles_the_pack_wide_presets_and_shows_custom_otherwise() {
let mut screen = PackOptionsScreen::open(with_presets());
// The defaults are no preset.
assert_eq!(screen.current_preset(), None);
screen.cycle_preset(false);
assert_eq!(screen.current_preset().as_deref(), Some("fast"));
assert_eq!(screen.configs.get("p:fog"), Some(&ConfigValue::Number(0.0)));
screen.cycle_preset(false);
assert_eq!(screen.current_preset().as_deref(), Some("pretty"));
screen.cycle_preset(true);
assert_eq!(screen.current_preset().as_deref(), Some("fast"));
// One value off the preset: custom again.
screen.set("p:dust", ConfigValue::Number(0.3));
assert_eq!(screen.current_preset(), None);
let presets = &screen.sheets(&Settings::default())[0].groups[0].rows[0];
assert_eq!(presets.id, Item::Presets);
let custom = lang::get("horizon.pack.options.presets.custom");
assert_eq!(presets.value, Value::Choice(custom));
}
#[test]
fn left_and_right_step_the_presets() {
let mut s = Settings::default();
let mut shell = shell_of(with_presets());
shell.set_focus(Some(Target::Row(Item::Presets)));
shell.key(&mut s, K::ArrowRight, None, false, SIZE);
assert_eq!(shell.screen.current_preset().as_deref(), Some("fast"));
shell.key(&mut s, K::ArrowRight, None, false, SIZE);
assert_eq!(shell.screen.current_preset().as_deref(), Some("pretty"));
shell.key(&mut s, K::ArrowLeft, None, false, SIZE);
assert_eq!(shell.screen.current_preset().as_deref(), Some("fast"));
assert_eq!(shell.focused_row(), Some(Item::Presets));
}
#[test]
fn sliders_stop_at_the_entrys_step() {
let mut c = Configs::default();
let json = serde_json::json!({"default_value": 4.0, "min": 0.0, "max": 10.0, "step": 2.0});
c.add_entry("p:clouds".into(), &json);
let mut s = Settings::default();
let mut shell = shell_of(c);
let row = &shell.screen.sheets(&s)[0].groups[1].rows[0];
let stops = Value::Slider {
position: 0.4,
steps: Some(5),
text: "4".into(),
};
assert_eq!(row.value, stops);
// A press a little past the middle of the track lands on the step nearest it.
let at = shell.point_at(&s, SIZE, Item::Entry(0));
let (track, width) = Columns::of(SIZE.0, SIZE.1).track();
shell.click(
&mut s,
(track + width * 0.55, at.1),
SIZE,
false,
false,
false,
);
shell.release();
assert_eq!(value(&shell, "p:clouds"), Some(&ConfigValue::Number(6.0)));
// Held by the press, Right goes one step on and Left one back.
shell.set_focus(Some(Target::Row(Item::Entry(0))));
shell.key(&mut s, K::ArrowRight, None, false, SIZE);
assert_eq!(value(&shell, "p:clouds"), Some(&ConfigValue::Number(8.0)));
shell.key(&mut s, K::ArrowLeft, None, false, SIZE);
shell.key(&mut s, K::ArrowLeft, None, false, SIZE);
assert_eq!(value(&shell, "p:clouds"), Some(&ConfigValue::Number(4.0)));
}
/// A language with the pack's own strings: a name, a section and a tooltip.
struct PackLang;
impl lang::ResourceSource for PackLang {
fn read(&self, path: &str) -> Option<Vec<u8>> {
(path == "assets/minecraft/lang/en_us.json").then(|| {
let strings = serde_json::json!({
"config.minecraft.haze": "Haze",
"config.minecraft.haze.tooltip": "Thickens the distant air.",
"config.minecraft.section.sky": "Sky and fog",
});
serde_json::to_vec(&strings).unwrap()
})
}
}
fn haze() -> Configs {
let mut c = configs();
let json =
serde_json::json!({"default_value": false, "section": "sky", "performance_impact": "high"});
c.add_entry("minecraft:haze".into(), &json);
c
}
#[test]
fn the_search_finds_an_entry_by_its_tooltip_and_the_strip_has_its_impact() {
let language = lang::Language::load(&[&PackLang], "en_us");
lang::scoped(language, || {
let s = Settings::default();
let mut shell = shell_of(haze());
let (page, haze) = entry(&shell, "minecraft:haze");
shell.set_search("distant air");
assert_eq!(shell.listed(&s, SIZE), [(page, haze)]);
let sheets = shell.screen.sheets(&s);
assert_eq!(sheets[usize::from(page)].name, "Sky and fog");
let row = sheets.iter().flat_map(|p| &p.groups).flat_map(|g| &g.rows);
let row = row.into_iter().find(|r| r.id == haze).unwrap();
assert_eq!(row.name, "Haze");
assert_eq!(
row.about,
"Thickens the distant air. Performance impact: high"
);
});
}
#[test]
fn tab_reaches_every_row_of_every_page() {
let mut s = Settings::default();
let mut shell = shell_of(haze());
let pages = shell.screen.sheets(&s).len() as u16;
let mut seen = Vec::new();
for page in 0..pages {
shell.show(page);
shell.set_focus(None);
for _ in 0..8 {
shell.key(&mut s, K::Tab, None, false, SIZE);
if let Some(row) = shell.focused_row()
&& !seen.contains(&row)
{
seen.push(row);
}
}
}
// Reset all is greyed at the defaults; every entry is reached.
let mut entries: Vec<Item> = (0..shell.screen.ids.len() as u16)
.map(Item::Entry)
.collect();
seen.sort_by_key(|item| format!("{item:?}"));
entries.sort_by_key(|item| format!("{item:?}"));
assert_eq!(seen, entries);
}
#[test]
fn undo_puts_the_entries_back_and_then_leaving_sends_nothing() {
let mut s = Settings::default();
let mut shell = shell_of(configs());
let (page, birds) = entry(&shell, "p:birds");
shell.show(page);
press(&mut shell, &mut s, birds);
assert!(shell.foot_at(&s, SIZE).iter().any(|f| f.0 == Target::Undo));
assert!(shell.screen.finished().is_some());
let undo = shell
.foot_at(&s, SIZE)
.into_iter()
.find(|f| f.0 == Target::Undo);
let [x, y, w, h] = undo.unwrap().1;
shell.click(
&mut s,
(x + w / 2.0, y + h / 2.0),
SIZE,
false,
false,
false,
);
assert_eq!(value(&shell, "p:birds"), Some(&ConfigValue::Bool(true)));
assert!(!shell.foot_at(&s, SIZE).iter().any(|f| f.0 == Target::Undo));
assert_eq!(shell.screen.finished(), None);
// Stepped there and back by hand, it sends nothing either.
press(&mut shell, &mut s, birds);
press(&mut shell, &mut s, birds);
assert_eq!(shell.screen.finished(), None);
press(&mut shell, &mut s, birds);
let Some(Event::PackOptions { entries, chosen }) = shell.screen.finished() else {
panic!("a change is sent");
};
assert_eq!(entries.len(), 3);
assert_eq!(chosen.get("p:birds"), Some(&ConfigValue::Bool(false)));
}
+232 -16
View File
@@ -468,17 +468,20 @@ fn read_meta(source: &Source) -> Option<serde_json::Value> {
/// file with a comment after the closing brace. Gson reads past a UTF-8 byte order mark;
/// serde_json rejects it.
pub(crate) fn parse_meta(bytes: &[u8]) -> Option<serde_json::Value> {
first_json(bytes)
first_json(bytes).ok()
}
/// Reads the first JSON value from resource metadata, matching Gson's
/// `JsonReader` (which ignores a trailing signature or comment). Resource
/// packs commonly append such text to texture metadata as well as pack.mcmeta.
pub(crate) fn first_json(bytes: &[u8]) -> Option<serde_json::Value> {
serde_json::Deserializer::from_slice(bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes))
/// The first JSON value of a `.mcmeta` file, as `ResourceMetadata.fromJsonStream` reads it:
/// Gson's `TypeAdapter.read` stops after the value, so text after it (packs leave `//` notes
/// there) is never read, and a UTF-8 byte order mark is skipped.
pub(crate) fn first_json<T: serde::de::DeserializeOwned>(bytes: &[u8]) -> serde_json::Result<T> {
let bytes = bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes);
match serde_json::Deserializer::from_slice(bytes)
.into_iter()
.next()?
.ok()
.next()
{
Some(value) => value,
None => Err(serde::de::Error::custom("empty metadata")),
}
}
/// A pack's Polytone config entries (`assets/<namespace>/polytone/config_entries/<name>.json`,
/// in the pack's base, not its overlays) at their default values.
@@ -827,13 +830,6 @@ fn build_overlay_uncached(
}
// Vanilla sits below every pack, so filters hide its files too.
let filters: Vec<&Filter> = packs.iter().flat_map(|p| &p.filter).collect();
let touched_dirs: BTreeSet<String> = files
.keys()
.flat_map(|f| {
let parts: Vec<&str> = f.split('/').collect();
(1..parts.len()).map(move |n| parts[..n].join("/"))
})
.collect();
let zips: Vec<Option<Zip>> = packs
.iter()
.map(|p| {
@@ -882,6 +878,21 @@ fn build_overlay_uncached(
merged.insert(name.clone(), bytes);
}
}
add_sprite_renames(
vanilla,
&merged,
&filters,
&mut files,
&mut hidden,
dependencies,
);
let touched_dirs: BTreeSet<String> = files
.keys()
.flat_map(|f| {
let parts: Vec<&str> = f.split('/').collect();
(1..parts.len()).map(move |n| parts[..n].join("/"))
})
.collect();
/// A zip entry to write: its pack, its name in the zip and where it goes.
type Extract<'a> = (usize, &'a str, PathBuf);
#[allow(clippy::too_many_arguments)]
@@ -1032,6 +1043,123 @@ fn build_overlay_uncached(
}
extracted
}
/// `SingleFile` with a `sprite`: the atlas holds the resource's pixels, and the resource's
/// `.mcmeta`, under the sprite's id. Every other sprite of the blocks and items atlases is its
/// texture's own id, and the loaders read a model's sprite from `textures/<sprite>.png`, so the
/// overlay gives a renamed sprite that file. ItemsAdder and Nexo packs name their models'
/// textures only through these renames (#14485).
///
/// A sprite gets its resource's file only when the whole source list, in order, ends on the
/// rename (`SpriteSourceList.list`: a later source of the same id replaces it), when the other
/// atlas, which reads the same file, lists it from that resource too or not at all, and when it
/// has no texture of its own. A texture that is there may be read as itself (another source's
/// resource, an unstitched or paletted sprite's input), so no alias ever replaces one; such a
/// sprite draws its own texture, as before aliases.
fn add_sprite_renames(
vanilla: &Path,
merged: &BTreeMap<String, Vec<u8>>,
filters: &[&Filter],
files: &mut BTreeMap<String, (usize, Provided)>,
hidden: &mut BTreeSet<String>,
dependencies: &mut cache::Dependencies,
) {
use crate::atlas_sources::{
SpriteOrigin, SpriteSource, decode, list, list_textures, texture_path,
};
let png = |id: &str| {
texture_path(Path::new(""), id, "textures")
.to_string_lossy()
.replace('\\', "/")
};
let atlases: Vec<_> = ["blocks", "items"]
.into_iter()
.filter_map(|atlas| merged.get(&format!("assets/minecraft/atlases/{atlas}.json")))
.filter_map(|bytes| decode(std::str::from_utf8(bytes).ok()?))
.collect();
let renamed = |source: &SpriteSource| matches!(source, SpriteSource::Single { resource, sprite: Some(sprite) } if sprite != resource);
if !atlases.iter().flatten().any(renamed) {
return;
}
// The textures a source can list: the packs' and vanilla's the packs leave visible.
let mut textures: Vec<String> = list_textures(vanilla)
.into_iter()
.filter(|id| {
let name = png(id);
!hidden.contains(&name) && !filters.iter().any(|f| f.blocks(&name))
})
.chain(files.keys().filter_map(|name| {
let (namespace, rest) = name.strip_prefix("assets/")?.split_once('/')?;
let path = rest.strip_prefix("textures/")?.strip_suffix(".png")?;
Some(format!("{namespace}:{path}"))
}))
.collect();
textures.sort();
textures.dedup();
// Each sprite's resource, `None` where an atlas lists it any other way.
let mut renames: BTreeMap<String, Option<String>> = BTreeMap::new();
for sources in &atlases {
for (sprite, origin) in list(sources, &textures) {
let from = match origin {
SpriteOrigin::File(resource) if resource != sprite => Some(resource),
_ => None,
};
renames
.entry(sprite)
.and_modify(|seen| {
if *seen != from {
*seen = None;
}
})
.or_insert(from);
}
}
let renames = renames
.into_iter()
.filter(|(sprite, _)| textures.binary_search(sprite).is_err())
.filter_map(|(sprite, resource)| Some((png(&resource?), png(&sprite))));
// Each resource is the file the packs give, never another rename's sprite.
let mut found = Vec::new();
for (from, to) in renames {
// `texture_path` names an id that would leave the root as a file that is never there.
if !stays_inside(&from) || !stays_inside(&to) {
continue;
}
let from_meta = format!("{from}.mcmeta");
// A pack's texture with its own `.mcmeta`, or vanilla's below every pack.
let (file, meta) = match files.get(&from) {
Some(file) => (file.clone(), files.get(&from_meta).cloned()),
None => {
let (path, meta_path) = (vanilla.join(&from), vanilla.join(&from_meta));
dependencies.file(&path);
dependencies.file(&meta_path);
if !path.is_file() || filters.iter().any(|f| f.blocks(&from)) {
continue;
}
// `createStackMetadataFinder`: a pack's filter hides vanilla's `.mcmeta` too.
let meta = files.get(&from_meta).cloned().or_else(|| {
(meta_path.is_file() && !filters.iter().any(|f| f.blocks(&from_meta)))
.then(|| (0, (Some(meta_path), from_meta.clone())))
});
((0, (Some(path), from.clone())), meta)
}
};
found.push((to, file, meta));
}
for (to, file, meta) in found {
let to_meta = format!("{to}.mcmeta");
files.insert(to, file);
match meta {
Some(meta) => {
hidden.remove(&to_meta);
files.insert(to_meta, meta);
}
None => {
files.remove(&to_meta);
hidden.insert(to_meta);
}
}
}
}
/// The most the zip packs in `packs` may unpack to in one overlay: 16 times their size on disk
/// (real packs are mostly PNGs and JSON, a few times smaller zipped at most), at least 64 MiB
/// for a small pack of text, and never past 8 GiB.
@@ -1906,6 +2034,94 @@ mod tests {
let _ = fs::remove_dir_all(base);
}
#[test]
fn a_renamed_atlas_sprite_reads_its_resource() {
let base =
std::env::temp_dir().join(format!("horizon-sprite-rename-{}", std::process::id()));
let _ = fs::remove_dir_all(&base);
let write = |path: PathBuf, bytes: &[u8]| {
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, bytes).unwrap();
};
let vanilla = base.join("vanilla");
let textures = vanilla.join("assets/minecraft/textures");
let animation = b"{\"animation\":{}}";
write(textures.join("block/sea_lantern.png"), b"lantern");
write(textures.join("block/sea_lantern.png.mcmeta"), animation);
write(textures.join("item/stick.png"), b"vanilla stick");
write(textures.join("item/stick.png.mcmeta"), animation);
let meta = br#"{"pack":{"description":"d","pack_format":97}}"#;
let pack = base.join("packs/IA");
write(pack.join("pack.mcmeta"), meta);
write(pack.join("assets/iageneric/textures/item/box.png"), b"box");
// An ItemsAdder pack's items atlas: its models name only the `ia:` sprites.
write(
pack.join("assets/minecraft/atlases/items.json"),
br#"{"sources":[
{"type":"single","resource":"iageneric:item/box","sprite":"ia:0"},
{"type":"single","resource":"minecraft:block/sea_lantern","sprite":"ia:1199"},
{"type":"single","resource":"iageneric:item/box","sprite":"minecraft:item/stick"},
{"type":"single","resource":"minecraft:item/stick","sprite":"ia:3"},
{"type":"single","resource":"iageneric:item/gone","sprite":"ia:2"},
{"type":"single","resource":"iageneric:item/box","sprite":"ia:../../../escape"},
{"type":"single","resource":"iageneric:item/box","sprite":"ia:7"},
{"type":"single","resource":"iageneric:item/box","sprite":"ia:8"},
{"type":"single","resource":"iageneric:item/box","sprite":"ia:9"}
]}"#,
);
// A pack above it: later sources of three of those sprites, a blocks atlas that
// disagrees about another, and a filter hiding vanilla's animation of the stick.
let higher = base.join("packs/Top");
write(
higher.join("pack.mcmeta"),
br#"{"pack":{"description":"d","pack_format":97},
"filter":{"block":[{"path":"textures/item/stick\\.png\\.mcmeta"}]}}"#,
);
write(
higher.join("assets/minecraft/atlases/items.json"),
br#"{"sources":[
{"type":"single","resource":"minecraft:block/sea_lantern","sprite":"ia:8"},
{"type":"filter","pattern":{"namespace":"ia","path":"7"}}
]}"#,
);
write(
higher.join("assets/minecraft/atlases/blocks.json"),
br#"{"sources":[
{"type":"single","resource":"minecraft:block/sea_lantern","sprite":"ia:9"}
]}"#,
);
let packs = discover(&vanilla, &base.join("packs"));
let out = base.join("overlay");
build_overlay(&vanilla, &packs, &out).unwrap();
let read = |rel: &str| fs::read(out.join(rel)).ok();
let texture = |id: &str| read(&format!("assets/ia/textures/{id}.png"));
assert_eq!(texture("0").as_deref(), Some(&b"box"[..]));
assert_eq!(read("assets/ia/textures/0.png.mcmeta"), None);
// A vanilla resource comes with its animation.
assert_eq!(texture("1199").as_deref(), Some(&b"lantern"[..]));
assert_eq!(
read("assets/ia/textures/1199.png.mcmeta").as_deref(),
Some(&animation[..])
);
// A texture that is there stays itself: other sources may read it (here as `ia:3`'s
// resource; an unstitched or paletted sprite's input alike).
assert_eq!(
read("assets/minecraft/textures/item/stick.png").as_deref(),
Some(&b"vanilla stick"[..])
);
// ...with its `.mcmeta` only where no filter hides it.
assert_eq!(texture("3").as_deref(), Some(&b"vanilla stick"[..]));
assert_eq!(read("assets/ia/textures/3.png.mcmeta"), None);
// A missing resource adds nothing, and no rename leaves the overlay.
assert_eq!(texture("2"), None);
assert!(!out.join("escape.png").exists());
// The last source of a sprite wins: a filter drops it, a later rename replaces it.
assert_eq!(texture("7"), None);
assert_eq!(texture("8").as_deref(), Some(&b"lantern"[..]));
// The two atlases read one file, and disagree about `ia:9`: it gets neither.
assert_eq!(texture("9"), None);
let _ = fs::remove_dir_all(base);
}
#[test]
fn every_file_of_a_big_stack_comes_from_its_top_pack() {
use std::io::Write as _;
let base = std::env::temp_dir().join(format!("horizon-pack-stack-{}", std::process::id()));
@@ -13,7 +13,8 @@ use std::{collections::BTreeSet, fs, io, path::Path, path::PathBuf};
// Version 5 read every vanilla file to stamp it on Windows.
// Version 6 stamps vanilla's files by their metadata on Windows too.
// Version 7 stacks blockstate files (every pack's definition, bottom first).
const VERSION: u32 = 8;
// Version 8 left an atlas `single` source's renamed sprite without its texture file (#14485).
const VERSION: u32 = 9;
#[derive(Default, Serialize, Deserialize)]
pub(super) struct Dependencies {
+119 -105
View File
@@ -1402,100 +1402,8 @@ impl ParticleLightCache {
}
}
const PARTICLE_SECTION_CACHE_SIZE: usize = 128;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum SectionFrustumResult {
Outside,
Intersects,
Inside,
}
#[derive(Clone, Copy)]
struct CachedSectionFrustum {
section: [i32; 3],
radius_quarters: i32,
result: SectionFrustumResult,
}
struct ParticleSectionFrustumCache {
entries: [Option<CachedSectionFrustum>; PARTICLE_SECTION_CACHE_SIZE],
}
impl Default for ParticleSectionFrustumCache {
fn default() -> Self {
Self {
entries: [None; PARTICLE_SECTION_CACHE_SIZE],
}
}
}
impl ParticleSectionFrustumCache {
fn result(
&mut self,
world: [f64; 3],
camera: DVec3,
radius: f64,
frustum: &crate::gfx::Frustum,
) -> SectionFrustumResult {
let Some(section) = world_section(world) else {
return SectionFrustumResult::Intersects;
};
if !radius.is_finite() || radius < 0.0 || radius * 4.0 > f64::from(i32::MAX) {
return SectionFrustumResult::Intersects;
}
let radius_quarters = (radius * 4.0).ceil() as i32;
let hash = (section[0] as u32).wrapping_mul(73_856_093)
^ (section[1] as u32).wrapping_mul(19_349_663)
^ (section[2] as u32).wrapping_mul(83_492_791)
^ (radius_quarters as u32).wrapping_mul(2_654_435_761);
let slot = hash as usize & (PARTICLE_SECTION_CACHE_SIZE - 1);
if let Some(entry) = self.entries[slot]
&& entry.section == section
&& entry.radius_quarters == radius_quarters
{
return entry.result;
}
let pad = f64::from(radius_quarters) * 0.25;
let camera = camera.to_array();
let min = array::from_fn(|axis| f64::from(section[axis]) * 16.0 - pad - camera[axis]);
let max = array::from_fn(|axis| min[axis] + 16.0 + 2.0 * pad);
let result = if min.into_iter().chain(max).any(|v| !v.is_finite()) {
SectionFrustumResult::Intersects
} else {
let min = FVec3::from_array(min.map(|v| v as f32));
let max = FVec3::from_array(max.map(|v| v as f32));
if !min.is_finite() || !max.is_finite() {
SectionFrustumResult::Intersects
} else if frustum.contains(min, max) {
SectionFrustumResult::Inside
} else if frustum.intersects(min, max) {
SectionFrustumResult::Intersects
} else {
SectionFrustumResult::Outside
}
};
self.entries[slot] = Some(CachedSectionFrustum {
section,
radius_quarters,
result,
});
result
}
}
fn world_section(position: [f64; 3]) -> Option<[i32; 3]> {
let mut block = [0; 3];
for axis in 0..3 {
let value = position[axis].floor();
if !value.is_finite() || value < f64::from(i32::MIN) || value > f64::from(i32::MAX) {
return None;
}
block[axis] = value as i32;
}
Some([block[0] >> 4, block[1] >> 4, block[2] >> 4])
}
mod view_cull;
use view_cull::{PARTICLE_FRUSTUM_OFFSET, ParticleSectionFrustumCache, SectionFrustumResult};
fn below_half_pixel(radius: f64, distance_squared: f64, pixel_focal_length: f32) -> bool {
let projected_limit = 4.0 * radius * f64::from(pixel_focal_length);
@@ -4703,16 +4611,7 @@ impl ParticleEngine {
light_at: &dyn Fn([i32; 3]) -> u8,
out: &mut Vec<Vertex>,
) {
self.render_inner::<false>(
camera,
base,
partial,
0.0,
None,
&mut |_, _| false,
light_at,
out,
);
self.render_in_view(camera, base, partial, None, light_at, out);
}
/// Render particles after rejecting view-frustum misses and any particles
@@ -4730,7 +4629,7 @@ impl ParticleEngine {
out: &mut Vec<Vertex>,
) {
if !culling_enabled {
self.render(camera, base, partial, light_at, out);
self.render_in_view(camera, base, partial, frustum, light_at, out);
return;
}
self.render_inner::<true>(
@@ -4761,10 +4660,16 @@ impl ParticleEngine {
additive.clear();
let mut section_cache =
(CULL && frustum.is_some()).then(ParticleSectionFrustumCache::default);
let view = frustum.map(|f| f.pulled_back(PARTICLE_FRUSTUM_OFFSET));
for p in &self.particles {
if p.no_render {
continue;
}
if let Some(view) = &view
&& !view_cull::in_view(view, p, camera)
{
continue;
}
// SparkParticle.extract: twinkling sparks blink after a third of their life.
if let Behaviour::Spark { twinkle: true, .. } = p.behaviour
&& p.age >= p.lifetime / 3
@@ -5199,6 +5104,115 @@ mod tests {
assert_eq!(attributes(&disabled), attributes(&regular));
}
/// An engine whose smoke particles stand at `positions`, camera-relative (the camera is at
/// the origin looking down -z, like `Mat4::IDENTITY` as a view).
fn smoke_at(positions: &[[f64; 3]]) -> ParticleEngine {
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
let sprite = "test:smoke".to_owned();
engine.atlas.insert(
sprite.clone(),
(
Rect {
u0: 0.0,
v0: 0.0,
u1: 1.0,
v1: 1.0,
w: 16,
h: 16,
},
false,
),
);
let mut random = LegacyRandom::new(7);
for &at in positions {
let mut particle = Particle::moving(&mut random, ParticleType::Smoke, at, [0.0; 3]);
particle.sprite = Sprite::Named(sprite.clone());
engine.particles.push(particle);
}
engine
}
fn quad_vertices(engine: &ParticleEngine, view: Option<&crate::gfx::Frustum>) -> usize {
let mut out = Vec::new();
engine.render_in_view(DVec3::ZERO, Quat::IDENTITY, 0.5, view, &|_| 0xf0, &mut out);
out.len()
}
#[test]
fn particles_outside_the_pulled_back_frustum_make_no_quads() {
use crate::gfx::{Frustum, world_projection};
let view = Frustum::from_view_projection(world_projection(70.0, 16.0 / 9.0, 512.0));
let kept = [
[0.0, 0.0, -5.0], // straight ahead
[0.0, 0.0, 2.5], // behind the camera, inside the 3 blocks of offset
[0.0, 0.0, -300.0], // far, inside the far plane
];
let dropped = [
[0.0, 0.0, 10.0], // well behind the camera
[0.0, 0.0, 3.5], // just past the offset
[100.0, 0.0, -5.0], // far to the side
[0.0, 60.0, -5.0], // far above
[0.0, 0.0, -600.0], // beyond the far plane
];
let all: Vec<_> = kept.iter().chain(&dropped).copied().collect();
let engine = smoke_at(&all);
let only_kept = quad_vertices(&smoke_at(&kept), None);
assert!(only_kept > 0);
assert_eq!(quad_vertices(&engine, Some(&view)), only_kept);
// Without a view every particle is drawn, as before.
assert_eq!(
quad_vertices(&engine, None),
only_kept * all.len() / kept.len()
);
// `render` is the same as no view.
let mut regular = Vec::new();
engine.render(DVec3::ZERO, Quat::IDENTITY, 0.5, &|_| 0xf0, &mut regular);
assert_eq!(regular.len(), quad_vertices(&engine, None));
}
#[test]
fn the_culled_path_applies_the_same_frustum_test() {
use crate::gfx::{Frustum, world_projection};
let view = Frustum::from_view_projection(world_projection(70.0, 16.0 / 9.0, 512.0));
let engine = smoke_at(&[[0.0, 0.0, -5.0], [0.0, 0.0, 10.0]]);
let mut out = Vec::new();
// The disabled setting still keeps vanilla's always-on test.
engine.render_culled(
DVec3::ZERO,
Quat::IDENTITY,
0.5,
900.0,
false,
Some(&view),
|_, _| panic!("disabled culling must not query particle visibility"),
&|_| 0xf0,
&mut out,
);
assert_eq!(
out.len(),
quad_vertices(&smoke_at(&[[0.0, 0.0, -5.0]]), None)
);
}
#[test]
fn a_particle_is_tested_at_its_position_this_tick_not_an_interpolated_one() {
use crate::gfx::{Frustum, world_projection};
let view = Frustum::from_view_projection(world_projection(70.0, 16.0 / 9.0, 512.0));
// The frame is drawn half way (0.5) between `previous` and `position`.
// Ahead now, far behind a tick ago (half way: 12.5 behind, outside): kept.
let mut coming = smoke_at(&[[0.0, 0.0, -5.0]]);
coming.particles[0].previous = [0.0, 0.0, 30.0];
assert!(quad_vertices(&coming, None) > 0);
assert_eq!(
quad_vertices(&coming, Some(&view)),
quad_vertices(&coming, None)
);
// Behind now, ahead a tick ago (half way: 5 ahead, inside): dropped.
let mut going = smoke_at(&[[0.0, 0.0, 10.0]]);
going.particles[0].previous = [0.0, 0.0, -20.0];
assert_eq!(quad_vertices(&going, Some(&view)), 0);
}
#[test]
fn quads_face_the_rotation_they_are_given() {
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
@@ -0,0 +1,138 @@
//! The camera's view frustum for particles: vanilla's always-on point test, and the section
//! cache behind the opt-in Particle Culling setting.
use super::{Particle, ParticleEngine, Vertex};
use glam::{DVec3, Quat, Vec3 as FVec3};
use std::array;
const PARTICLE_SECTION_CACHE_SIZE: usize = 128;
/// How far back, in blocks, the camera's frustum is pulled for the particle test (vanilla's
/// `Frustum.offset` in `ParticleEngine.extract`).
pub(super) const PARTICLE_FRUSTUM_OFFSET: f32 = 3.0;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum SectionFrustumResult {
Outside,
Intersects,
Inside,
}
#[derive(Clone, Copy)]
struct CachedSectionFrustum {
section: [i32; 3],
radius_quarters: i32,
result: SectionFrustumResult,
}
pub(super) struct ParticleSectionFrustumCache {
entries: [Option<CachedSectionFrustum>; PARTICLE_SECTION_CACHE_SIZE],
}
impl Default for ParticleSectionFrustumCache {
fn default() -> Self {
Self {
entries: [None; PARTICLE_SECTION_CACHE_SIZE],
}
}
}
impl ParticleSectionFrustumCache {
pub(super) fn result(
&mut self,
world: [f64; 3],
camera: DVec3,
radius: f64,
frustum: &crate::gfx::Frustum,
) -> SectionFrustumResult {
let Some(section) = world_section(world) else {
return SectionFrustumResult::Intersects;
};
if !radius.is_finite() || radius < 0.0 || radius * 4.0 > f64::from(i32::MAX) {
return SectionFrustumResult::Intersects;
}
let radius_quarters = (radius * 4.0).ceil() as i32;
let hash = (section[0] as u32).wrapping_mul(73_856_093)
^ (section[1] as u32).wrapping_mul(19_349_663)
^ (section[2] as u32).wrapping_mul(83_492_791)
^ (radius_quarters as u32).wrapping_mul(2_654_435_761);
let slot = hash as usize & (PARTICLE_SECTION_CACHE_SIZE - 1);
if let Some(entry) = self.entries[slot]
&& entry.section == section
&& entry.radius_quarters == radius_quarters
{
return entry.result;
}
let pad = f64::from(radius_quarters) * 0.25;
let camera = camera.to_array();
let min = array::from_fn(|axis| f64::from(section[axis]) * 16.0 - pad - camera[axis]);
let max = array::from_fn(|axis| min[axis] + 16.0 + 2.0 * pad);
let result = if min.into_iter().chain(max).any(|v| !v.is_finite()) {
SectionFrustumResult::Intersects
} else {
let min = FVec3::from_array(min.map(|v| v as f32));
let max = FVec3::from_array(max.map(|v| v as f32));
if !min.is_finite() || !max.is_finite() {
SectionFrustumResult::Intersects
} else if frustum.contains(min, max) {
SectionFrustumResult::Inside
} else if frustum.intersects(min, max) {
SectionFrustumResult::Intersects
} else {
SectionFrustumResult::Outside
}
};
self.entries[slot] = Some(CachedSectionFrustum {
section,
radius_quarters,
result,
});
result
}
}
fn world_section(position: [f64; 3]) -> Option<[i32; 3]> {
let mut block = [0; 3];
for axis in 0..3 {
let value = position[axis].floor();
if !value.is_finite() || value < f64::from(i32::MIN) || value > f64::from(i32::MAX) {
return None;
}
block[axis] = value as i32;
}
Some([block[0] >> 4, block[1] >> 4, block[2] >> 4])
}
/// Whether `QuadParticleGroup`'s test keeps `particle`: its position this tick (camera-relative, not
/// interpolated) is on or inside `view`, the camera's frustum pulled back [`PARTICLE_FRUSTUM_OFFSET`] blocks.
pub(super) fn in_view(view: &crate::gfx::Frustum, particle: &Particle, camera: DVec3) -> bool {
let at = DVec3::from_array(particle.position) - camera;
view.contains_point(at.as_vec3())
}
impl ParticleEngine {
/// [`Self::render`], keeping only the particles inside `view`, the camera's frustum pulled
/// back [`PARTICLE_FRUSTUM_OFFSET`] blocks (vanilla's `ParticleEngine.extract` and
/// `QuadParticleGroup`): the test runs whatever the Particle Culling setting says. With
/// `None`, every particle is drawn.
pub fn render_in_view(
&self,
camera: DVec3,
base: Quat,
partial: f32,
view: Option<&crate::gfx::Frustum>,
light_at: &dyn Fn([i32; 3]) -> u8,
out: &mut Vec<Vertex>,
) {
self.render_inner::<false>(
camera,
base,
partial,
0.0,
view,
&mut |_, _| false,
light_at,
out,
);
}
}
@@ -222,11 +222,6 @@ impl<P: Copy, S: Copy> Lines<P, S> {
}
self.y += style::GROUP_GAP;
}
/// A group of rows under its page's own name (`list`'s `page_name`), for pages named at run
/// time, such as a resource pack's config entries (`pack_options_ui`).
pub(crate) fn titled(&mut self, page: P, rows: Vec<S>) {
self.group(page, "", rows);
}
/// The lines and the content's height.
pub(crate) fn finish(self) -> (Vec<Line<P, S>>, f32) {
(self.lines, (self.y - style::GROUP_GAP).max(0.0))
@@ -352,8 +347,7 @@ pub(crate) fn plate(batch: &mut HudBatch, atlas: &HudAtlas, cols: &Columns, head
}
/// The list, clipped to its view: each group's panel, then its names and rows on it. `row`
/// draws a setting at its `y`; `page_name` names a page, and a group without a name of its own
/// (`Lines::titled`), in the player's language.
/// draws a setting at its `y`; `page_name` names a page, in the player's language.
pub(crate) fn list<P: Copy, S: Copy>(
batch: &mut HudBatch,
atlas: &HudAtlas,
@@ -386,7 +380,6 @@ pub(crate) fn list<P: Copy, S: Copy>(
}
let Some(setting) = line.setting else {
let (name, color, dy) = match line.group {
Some(("", _)) => (page_name(line.page), style::muted(), 3.0),
Some((name, _)) => (lang::get(name), style::muted(), 3.0),
None => (page_name(line.page), style::accent(), 4.0),
};
@@ -952,22 +945,10 @@ pub(crate) fn dock_note(
}
}
/// Done or Undo at the foot of the page list.
/// Done, Undo or Apply at the foot of the page list, registered for a controller. Its label
/// leaves `room` at its left for a controller's glyph: a button that declared its job gets one,
/// as vanilla's do (`ui::action_button`), which a half-width Undo or Apply needs.
pub(crate) fn foot_button(
batch: &mut HudBatch,
atlas: &HudAtlas,
rect: [f32; 4],
label: &Text,
cursor: (f32, f32),
flags: (bool, bool),
) {
foot_button_with_room(batch, atlas, rect, label, cursor, flags, 0.0);
}
/// A foot button whose label leaves `room` at its left for a controller's glyph: a button that
/// declared its job gets one, as vanilla's do (`ui::action_button`), which a half-width Undo or
/// Apply needs.
fn foot_button_with_room(
batch: &mut HudBatch,
atlas: &HudAtlas,
rect: [f32; 4],
@@ -2414,7 +2395,7 @@ impl<S: Screen + Default> Hosted for Shell<S> {
}
None => 0.0,
};
foot_button_with_room(batch, atlas, rect, &label, cursor, flags, room);
foot_button(batch, atlas, rect, &label, cursor, flags, room);
}
}
fn click(
+11 -17
View File
@@ -93,18 +93,17 @@ pub fn unpack(value: &str, signature: Signature, valid_uris: &[impl AsRef<str>])
signed: signature == Signature::Signed,
..empty.clone()
};
// Each texture is loaded independently: an invalid or disallowed URL for one
// texture (cape, elytra) must not drop another (the skin). Vanilla's
// SkinManager works the same way.
// Authlib's MinecraftServicesSessionService validates every URL in
// unpackTextures before returning any textures. A bad cape rejects the skin too.
for (kind, texture) in textures {
let Some(url) = texture.get("url").and_then(|u| u.as_str()) else {
continue;
return empty;
};
let allowed = valid_uris
.iter()
.any(|u| url.starts_with(&u.as_ref().replace("{textureId}", "")));
if !allowed {
continue;
return empty;
}
if kind == "SKIN" {
out.slim = texture
@@ -1388,10 +1387,9 @@ mod tests {
&super::TEXTURE_URIS,
);
assert_eq!(textures.slim, slim && !url.contains("evil"));
// Textures are judged per URL (vanilla's SkinManager): the
// cape's URL is always allowed here, so it is kept even when
// the skin's URL is not.
assert!(textures.cape.is_some());
// Authlib rejects the whole payload if any URL is invalid,
// including the allowed cape when the skin is disallowed.
assert_eq!(textures.cape.is_some(), !url.contains("evil"));
assert_eq!(parsed.skin.is_some(), !url.contains("evil"));
assert!(std::sync::Arc::ptr_eq(&parsed, &cache.get(&profile)));
}
@@ -1544,18 +1542,14 @@ mod tests {
assert!(t.slim);
assert!(!unpack(wide, Signature::Signed, &TEXTURE_URIS).slim);
}
/// `unpack` keeps each texture independently: a cape from another origin
/// must not drop the skin.
/// Authlib's `MinecraftServicesSessionService` rejects the whole profile in
/// `unpackTextures` when any texture URL is outside the allowed origins.
#[test]
fn unpack_keeps_the_skin_when_another_texture_is_rejected() {
fn unpack_rejects_the_skin_when_another_texture_is_rejected() {
use super::{Signature, TEXTURE_URIS, unpack};
let mixed = "eyJ0ZXh0dXJlcyI6IHsiU0tJTiI6IHsidXJsIjogImh0dHBzOi8vdGV4dHVyZXMubWluZWNyYWZ0Lm5ldC90ZXh0dXJlL3NraW4xIn0sICJDQVBFIjogeyJ1cmwiOiAiaHR0cHM6Ly9ldmlsLmV4YW1wbGUvdGV4dHVyZS9jYXBlMSJ9fX0=";
let t = unpack(mixed, Signature::Unsigned, &TEXTURE_URIS);
assert_eq!(
t.skin.as_deref(),
Some("https://textures.minecraft.net/texture/skin1")
);
assert_eq!(t.cape, None);
assert_eq!(t, super::Textures::default());
}
#[test]
fn profiles_parse_like_resolvable_profiles() {
+4 -4
View File
@@ -478,9 +478,9 @@ impl Sprites {
let metadata = match horizon_core::prepared_files::read_to_string(
&path.with_extension("png.mcmeta"),
) {
Ok(text) => Some(serde_json::from_str::<
Ok(text) => Some(crate::packs::first_json::<
BTreeMap<String, Box<serde_json::value::RawValue>>,
>(&text)?),
>(text.as_bytes())?),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
Err(error) => return Err(error.into()),
};
@@ -589,9 +589,9 @@ impl Sprites {
let metadata = match horizon_core::prepared_files::read_to_string(
&path.with_extension("png.mcmeta"),
) {
Ok(text) => Some(serde_json::from_str::<
Ok(text) => Some(crate::packs::first_json::<
BTreeMap<String, Box<serde_json::value::RawValue>>,
>(&text)?),
>(text.as_bytes())?),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
Err(error) => return Err(error.into()),
};
@@ -1438,7 +1438,7 @@ impl Streaming {
let entities = std::mem::take(&mut chunk.entities);
world.entities_pending.insert((chunk.x, chunk.z));
if !entities.is_empty() {
world.loaded_entities.push(((chunk.x, chunk.z), entities));
world.queue_entity_nbt((chunk.x, chunk.z), entities);
}
self.generated_total += 1;
// A chunk saved with its light needs no light job; its unlit
+23 -1
View File
@@ -104,7 +104,7 @@ pub fn read_metadata(root: &Path) -> UiMetadata {
let Ok(text) = horizon_core::prepared_files::read_to_string(&p) else {
continue;
};
let Some(nine_slice) = serde_json::from_str(&text)
let Some(nine_slice) = crate::packs::first_json(text.as_bytes())
.ok()
.as_ref()
.and_then(parse_nine_slice)
@@ -2025,6 +2025,28 @@ impl EditBox {
mod tests {
use super::*;
#[test]
fn notes_after_a_sprites_metadata_keep_its_nine_slice() {
// Gson stops reading after the first value, so vanilla keeps this nine slice
// (CozyUI+'s highlighted button, #3046).
let root = std::env::temp_dir().join(format!("horizon-ui-meta-{}", std::process::id()));
let widget = root.join("assets/minecraft/textures/gui/sprites/widget");
std::fs::create_dir_all(&widget).unwrap();
std::fs::write(
widget.join("button_highlighted.png.mcmeta"),
"{\"gui\": {\"scaling\": {\"type\": \"nine_slice\", \"width\": 80, \"height\": 20,\n\
\"border\": {\"left\": 34, \"top\": 8, \"right\": 34, \"bottom\": 8}}}}\n\n// 0 10\n// 1 8\n",
)
.unwrap();
let metadata = read_metadata(&root);
std::fs::remove_dir_all(&root).unwrap();
let slice = metadata.0["gui/sprites/widget/button_highlighted"];
assert_eq!(
(slice.width, slice.height, slice.border),
(80, 20, [34, 8, 34, 8])
);
}
/// The quads' destination rectangles and their UVs.
fn quads(batch: &HudBatch) -> Vec<([f32; 4], [f32; 4])> {
batch
+10 -1
View File
@@ -9,6 +9,9 @@ use horizon_core::random::{LegacyRandom, LevelRandom};
use horizon_world::registry::BlockStateId;
use std::sync::Arc;
mod pending_entity;
pub use pending_entity::{EntityLoadReason, PendingEntity};
mod modified;
pub use modified::ModifiedChunks;
@@ -81,7 +84,7 @@ pub struct World {
/// incrementally by streaming.
pub light_edits: Vec<((i32, i32, i32), u32, u32)>,
/// Saved entities of chunks that just loaded, spawned by the game.
pub loaded_entities: Vec<((i32, i32), Vec<horizon_world::nbt::Tag>)>,
pub loaded_entities: Vec<((i32, i32), Vec<PendingEntity>)>,
/// Chunks loaded since the level's last pass, their entity sections not
/// ready yet (`PersistentEntitySectionManager`'s PENDING), empty ones too:
/// the pass ends by readying them (`processPendingLoads`).
@@ -215,6 +218,12 @@ impl Weather {
}
}
impl World {
/// Queue ordinary NBT; transient transfer state is never decoded from it.
pub fn queue_entity_nbt(&mut self, key: (i32, i32), tags: Vec<horizon_world::nbt::Tag>) {
self.loaded_entities
.push((key, tags.into_iter().map(PendingEntity::from).collect()));
}
pub fn new(air: BlockStateId, seed: i64) -> Self {
Self {
shape: LevelShape::OVERWORLD,
@@ -0,0 +1,89 @@
//! Pending entity NBT and the runtime context that must not be stored inside it.
use horizon_world::nbt::Tag;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum EntityLoadReason {
Ordinary,
TeleportedTnt,
}
#[derive(Clone, Debug)]
pub struct PendingEntity {
tag: Tag,
reason: EntityLoadReason,
}
impl From<Tag> for PendingEntity {
fn from(tag: Tag) -> Self {
Self {
tag,
reason: EntityLoadReason::Ordinary,
}
}
}
impl PendingEntity {
/// Only an in-memory TNT transfer supplies this context, never saved NBT.
pub fn after_tnt_portal(tag: Tag) -> Self {
Self {
tag,
reason: EntityLoadReason::TeleportedTnt,
}
}
pub fn into_parts(self) -> (Tag, EntityLoadReason) {
(self.tag, self.reason)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ordinary_nbt_cannot_request_transient_portal_state() {
for marker in [0, 1] {
let tag = Tag::compound([("horizon:tnt_used_portal", Tag::Byte(marker))]);
let (loaded, reason) = PendingEntity::from(tag.clone()).into_parts();
assert_eq!(loaded, tag);
assert_eq!(reason, EntityLoadReason::Ordinary);
}
}
#[test]
fn queue_swaps_and_appends_keep_each_entitys_context_and_order() {
let ordinary = Tag::compound([("id", Tag::String("ordinary".into()))]);
let transfer = Tag::compound([("id", Tag::String("transfer".into()))]);
let mut batches = vec![(
(1, 2),
vec![
ordinary.clone().into(),
PendingEntity::after_tnt_portal(transfer.clone()),
],
)];
let mut waiting = std::mem::take(&mut batches);
batches.push(((3, 4), vec![ordinary.clone().into()]));
waiting.append(&mut batches);
assert!(batches.is_empty());
let mut batches = waiting.into_iter();
let (key, entities) = batches.next().unwrap();
assert_eq!(key, (1, 2));
let mut entities = entities.into_iter();
assert_eq!(
entities.next().unwrap().into_parts(),
(ordinary.clone(), EntityLoadReason::Ordinary)
);
assert_eq!(
entities.next().unwrap().into_parts(),
(transfer, EntityLoadReason::TeleportedTnt)
);
assert!(entities.next().is_none());
let (key, entities) = batches.next().unwrap();
assert_eq!(key, (3, 4));
assert_eq!(
entities.into_iter().next().unwrap().into_parts(),
(ordinary, EntityLoadReason::Ordinary)
);
assert!(batches.next().is_none());
}
}
@@ -316,6 +316,7 @@
"horizon.multiplayer.invite_code": "Invite Code",
"horizon.multiplayer.relay": "Relay (optional)",
"Preset": "Preset",
"Horizon Preset": "Horizon Preset",
"LOD Distance": "LOD Distance",
"Far Detail": "Far Detail",
"Cave Culling": "Cave Culling",
@@ -707,6 +708,9 @@
"Pick a shader pack. Picking the one in use turns shaders off.": "Pick a shader pack. Picking the one in use turns shaders off.",
"Changed options apply with Apply, or when you leave.": "Changed options apply with Apply, or when you leave.",
"Pack settings": "Pack settings",
"Whole pack": "Whole pack",
"Reset all": "Reset all",
"Every option is at its default already.": "Every option is at its default already.",
"Built-in settings": "Built-in settings",
"%s settings": "%s settings",
"Settings made by the pack's author: they change only this pack.": "Settings made by the pack's author: they change only this pack.",
+14
View File
@@ -21,5 +21,19 @@ fn main() {
if !staged.is_empty() {
println!("cargo:rustc-env=HORIZON_MIXIN_MODS={}", staged.join(","));
}
// Whether a native mod can run in a mob's turn (`game/creatures/mob_islands.rs`). The
// launcher adds trait packages to the workspace's members.
println!(
"cargo:rerun-if-changed={}",
root.join("Cargo.toml").display()
);
for dir in ["native-api", "native-crates"] {
if root.join(dir).is_dir() {
println!("cargo:rerun-if-changed={}", root.join(dir).display());
}
}
if let Some(found) = horizon_mod_api_gen::mob_turn(&root) {
println!("cargo:rustc-env=HORIZON_MOB_TURN={found}");
}
windows_exe::resources("horizon-client");
}
@@ -318,6 +318,8 @@ pub fn main() -> horizon_client::Result<()> {
Ok(())
}
drops::Drop::Orb(v) => writeln!(out, "orb {v}"),
// The star's `setExtendedLifetime` is no part of the loot.
drops::Drop::Aged(s, _) => writeln!(out, "{}", line(&s)),
};
}
}
@@ -237,7 +237,7 @@ fn goal_name(m: &Mob, name: &str) -> String {
"RestrictSun" => "RestrictSunGoal",
"FleeSun" => "FleeSunGoal",
// `Cat.CatAvoidEntityGoal`, `Fox.FoxAvoidEntityGoal`... name their subclass.
"AvoidEntity" if m.species() == Species::Cat => "CatAvoidEntityGoal",
"AvoidEntity" | "CatAvoid" if m.species() == Species::Cat => "CatAvoidEntityGoal",
"AvoidEntity" => "AvoidEntityGoal",
"BowAttack" => "RangedBowAttackGoal",
"Swell" => "SwellGoal",
+14 -14
View File
@@ -1517,10 +1517,8 @@ fn predicate_references(
.unwrap_or("");
match &*full(kind) {
"minecraft:inverted" => predicate_references(&v["term"], w, active),
"minecraft:all_of" | "minecraft:any_of" => v["terms"]
.as_array()
.into_iter()
.flatten()
"minecraft:all_of" | "minecraft:any_of" => terms(v)
.iter()
.try_for_each(|v| predicate_references(v, w, active)),
_ => Ok(()),
}
@@ -1529,6 +1527,16 @@ fn predicate_references(
}
}
/// `terms` as `HolderSetCodec` reads it: a list, or one holder alone; a `#tag` is not expanded.
fn terms(v: &Value) -> &[Value] {
match &v["terms"] {
Value::Array(list) => list,
Value::Null => &[],
Value::String(tag) if tag.starts_with('#') => &[],
one => std::slice::from_ref(one),
}
}
/// The existing condition traversal with a caller-owned leaf context.
/// Leaves run synchronously, in order, with normal boolean short-circuiting.
pub fn conditions_with(
@@ -1547,16 +1555,8 @@ pub fn conditions_with(
.unwrap_or("");
match &*full(ty) {
"minecraft:inverted" => !conditions_with(&v["term"], ctx, test),
"minecraft:all_of" => v["terms"]
.as_array()
.into_iter()
.flatten()
.all(|t| conditions_with(t, ctx, test)),
"minecraft:any_of" => v["terms"]
.as_array()
.into_iter()
.flatten()
.any(|t| conditions_with(t, ctx, test)),
"minecraft:all_of" => terms(v).iter().all(|t| conditions_with(t, ctx, test)),
"minecraft:any_of" => terms(v).iter().any(|t| conditions_with(t, ctx, test)),
_ => test(v, ctx),
}
}
@@ -724,7 +724,7 @@ pub fn markdown() -> String {
let cell = |s: &str| s.replace('|', "\\|");
let mut out = String::from(
"<!-- Generated by `horizon-ctl help --markdown` from the command table in \
crates/horizon-ctl/src/commands.rs: edit the table, then regenerate. -->\n\
crates/horizon-client/src/bin/horizon-ctl/commands.rs: edit the table, then regenerate. -->\n\
# horizon-ctl commands\n\n\
Every command `horizon-ctl` takes, its MCP tool in `horizon-ctl mcp` (the launcher's \
`horizon-launcher mcp` offers the same tools as `game_<tool>`, all but `launch`) and its \
+15 -1
View File
@@ -71,6 +71,10 @@ pub fn trample_allowed(entity: i32, kind: &str, position: BlockPos, distance: f6
let (Some(dimension), Some(hook)) = (LEVEL.get(), TRAMPLE.get()) else {
return true;
};
// A grouped mob turn: no mod hooks it (`native_client_hooks::Deferred`).
if crate::native_client_hooks::deferring() {
return true;
}
let mut cancel = false;
let at = [position.x, position.y, position.z];
hook(dimension, entity, kind, at, distance, &mut cancel);
@@ -87,7 +91,8 @@ fn dispatch(event: Event<'_>, damage: &mut f32) {
let contact = match event {
Event::StepOn(c) | Event::Inside(c) | Event::FallOn(c, _) => c,
};
if contact.block.starts_with("minecraft:") {
// A grouped mob turn reaches no mod's hook (`native_client_hooks::Deferred`).
if contact.block.starts_with("minecraft:") || crate::native_client_hooks::deferring() {
return;
}
if let Some(dimension) = LEVEL.get()
@@ -213,6 +218,15 @@ mod tests {
let bounds = bounds.move_by(Vec3::new(-1.0, 0.0, 0.0));
swept_inside(17, c.kind, at, at, bounds, block);
assert_eq!(SEEN.get(), 8, "stationary overlap still notifies");
// A grouped mob turn, on any thread: no mod hears its contacts or tramples.
let ((), _) = crate::native_client_hooks::deferring_turn(|| {
emit(Event::StepOn(c));
assert_eq!(fall(c, 12.0, 10.0), 10.0);
let _ = TRAMPLE.set(|_, _, _, _, _, cancel| *cancel = true);
assert!(trample_allowed(17, c.kind, c.position, 12.0));
});
assert_eq!(SEEN.get(), 8, "a grouped turn reaches no contact hook");
assert!(!trample_allowed(17, c.kind, c.position, 12.0));
});
let _ =
std::panic::catch_unwind(|| in_level(Some("minecraft:the_nether"), || panic!("test")));
+21 -7
View File
@@ -123,18 +123,32 @@ pub fn spawn_egg_entity_data<'a>(
/// `EntityType.appendDefaultStackConfig` for a hanging entity or armor stand an
/// item places: `Entity.applyImplicitComponents` stores the item's
/// `minecraft:custom_data` as the entity's `data`, then `updateCustomEntityTag`
/// `minecraft:custom_name` as the entity's `CustomName` and its
/// `minecraft:custom_data` as its `data`, then `updateCustomEntityTag`
/// (`TypedEntityData.loadInto`) merges the item's `minecraft:entity_data` over
/// the entity's saved tag `own` when its type is `kind`.
pub fn stack_configured_entity(stack: &crate::items::ItemStack, kind: &str, own: &Tag) -> Tag {
stack_configured_entity_by(stack, kind, own, false)
}
/// [`stack_configured_entity`], `op` saying whether the user may load the
/// data of an op-only entity type (`EntityType.onlyOpCanSetNbt`).
pub fn stack_configured_entity_by(
stack: &crate::items::ItemStack,
kind: &str,
own: &Tag,
op: bool,
) -> Tag {
let mut tag = own.clone();
if let (Tag::Compound(map), Some(custom)) = (
&mut tag,
stack.data().and_then(|d| d.other("minecraft:custom_data")),
) {
map.insert("data".into(), custom.clone());
if let Tag::Compound(map) = &mut tag {
if let Some(name) = stack.data().and_then(|d| d.custom_name.clone()) {
map.insert("CustomName".into(), name);
}
if let Some(custom) = stack.data().and_then(|d| d.other("minecraft:custom_data")) {
map.insert("data".into(), custom.clone());
}
}
match spawn_egg_entity_data(stack, kind, false) {
match spawn_egg_entity_data(stack, kind, op) {
Some(data) => crate::data_cmd::merged(&tag, data),
None => tag,
}
+22
View File
@@ -75,6 +75,12 @@ fn grid_state(count: u32, mut x: i32, mut z: i32) -> u32 {
impl ChunkGenerator for DebugTerrain {
type Worker = ();
fn worker(&self) {}
/// DebugLevelSource's proto is already its decorated column. Keep a copy
/// for lighting a finished neighbour outside the kept area, matching the
/// vanilla FEATURES neighbour path.
fn light_view(&self, proto: &crate::streaming::Proto) -> Option<Chunk> {
proto.downcast_ref::<Chunk>().cloned()
}
/// `DebugLevelSource.buildTerrain` builds nothing; `applyBiomeDecoration`
/// writes the barriers and the grid.
fn proto(&self, _: &mut (), cx: i32, cz: i32) -> crate::streaming::Proto {
@@ -128,4 +134,20 @@ mod tests {
assert_eq!(grid_state(1000, 65, 1), 0);
assert_eq!(grid_state(1000, 61, 3), 30 * 32 + 1);
}
#[test]
fn light_view_keeps_a_debug_proto_column() {
let terrain = DebugTerrain {
shape: LevelShape::OVERWORLD,
biome: 0,
barrier: 0,
states: 0,
};
let mut chunk = Chunk::empty(-2, 5);
chunk.set(0, 0, 0, 42);
let proto: crate::streaming::Proto = Box::new(chunk);
let view = terrain.light_view(&proto).expect("debug proto light view");
assert_eq!((view.x, view.z), (-2, 5));
assert_eq!(view.block(0, 0, 0).0, 42);
}
}
+93 -4
View File
@@ -1652,9 +1652,7 @@ pub fn mob_to_nbt(
if let Some(saddle) = m.saddle_item() {
equipment.insert("saddle".into(), crate::save::item_to_nbt(&saddle));
}
// A happy ghast wears its harness in the body slot (and a nautilus
// its armour, a sulfur cube its swallowed block, a wolf its armor,
// with the durability it lost).
// Body slot: harness, armour, a cube's block, a wolf's armor; emptied, it leaves the earlier save.
if crate::mobs::species::body_slot(m.species())
&& let Some(id) = &m.extra.body
{
@@ -1664,6 +1662,8 @@ pub fn mob_to_nbt(
stack.extra = m.extra.body_extra.clone();
}
equipment.insert("body".into(), crate::save::item_to_nbt(&stack));
} else if crate::mobs::species::body_slot(m.species()) {
equipment.remove(&"body".into());
}
// Vanilla saves `equipment` only when something is equipped.
if equipment.is_empty() {
@@ -2867,7 +2867,7 @@ pub fn evoker_fangs_to_nbt(
motion: raw.map(motion).unwrap_or(Vec3::ZERO),
rotation: [yaw, raw.map(rotation).unwrap_or([0.0; 2])[1]],
on_ground: raw.is_some_and(|t| flag(t, "OnGround")),
fall_distance: 0.0,
fall_distance: raw.map_or(0.0, |t| ground_from_nbt(t).1),
}
.tag(raw);
put(&mut tag, "Warmup", Tag::Int(warmup));
@@ -4163,6 +4163,28 @@ mod tests {
);
}
/// `EvokerFangs` extends `Entity` and never moves: `Entity.saveWithoutId`
/// writes the `fall_distance` it loaded, and 0 for fangs a spell made.
#[test]
fn evoker_fangs_keep_their_loaded_fall_distance() {
let raw = horizon_world::nbt::parse_snbt(
"{id:\"minecraft:evoker_fangs\",Pos:[1d,200d,1d],fall_distance:5.0d,Warmup:100}",
)
.unwrap()
.0;
let at = super::Vec3::new(1.0, 200.0, 1.0);
let fall = |raw: Option<&super::Tag>| match super::evoker_fangs_to_nbt(
at, 0.0, 99, None, [1; 4], raw,
)
.get("fall_distance")
{
Some(super::Tag::Double(bits)) => f64::from_bits(*bits),
other => panic!("no fall_distance: {other:?}"),
};
assert_eq!(fall(Some(&raw)), 5.0);
assert_eq!(fall(None), 0.0);
}
/// `Shulker` reads `AttachFace` as a byte before
/// `Direction.from3DDataValue` (`abs(data % 6)`): an int is narrowed
/// first, so 258 is byte 2, not 258 % 6 = 0, and 250 is byte -6, face 0.
@@ -4549,6 +4571,37 @@ mod tests {
assert_eq!(id("head"), "minecraft:iron_helmet");
}
/// A body item taken off (`/item replace entity <cube> armor.body with minecraft:air`)
/// is not saved again from the entity's earlier save: vanilla writes `equipment.body` only
/// while the slot holds something (#12038).
#[test]
fn an_emptied_body_slot_is_not_saved_from_the_earlier_nbt() {
let mut kind = crate::mobs::sensor_scan_tests::kind();
let ty = Arc::make_mut(&mut kind);
ty.id = "minecraft:sulfur_cube".into();
ty.species = crate::mobs::Species::SulfurCube;
let types = [("minecraft:sulfur_cube".into(), kind)].into();
let raw = Tag::compound([
("id", string("minecraft:sulfur_cube")),
("Pos", doubles(&[4.5, 220.0, 4.5])),
(
"equipment",
Tag::compound([(
"body",
Tag::compound([
("id", string("minecraft:oak_planks")),
("count", Tag::Int(1)),
]),
)]),
),
]);
let mut m = mob_from_nbt(&raw, &types, 1, None).unwrap();
assert_eq!(m.extra.body.as_deref(), Some("minecraft:oak_planks"));
m.extra.body = None;
let saved = mob_to_nbt(&m, [0; 4], Some(&raw), None, |_| None);
assert!(saved.get("equipment").is_none(), "{saved:?}");
}
/// A wolf loads its armor (`equipment.body`) into the body slot with the
/// durability it lost, and a helmet next to it into the head slot.
#[test]
@@ -5514,6 +5567,34 @@ mod tests {
}
}
/// `Goat` reads `HasLeftHorn` and `HasRightHorn` with `getBooleanOr(key,
/// true)`: a number is true when its byte value is not 0, so an int 256
/// (byte 0) is a lost horn, as `0b` is.
#[test]
fn a_goats_horn_flags_are_read_as_bytes() {
let mut kind = crate::mobs::sensor_scan_tests::kind();
let ty = Arc::make_mut(&mut kind);
ty.id = "minecraft:goat".into();
ty.species = crate::mobs::Species::Goat;
let types = [("minecraft:goat".into(), kind)].into();
let lost = |left: Option<Tag>| {
let mut fields = vec![
("id", string("minecraft:goat")),
("Pos", doubles(&[4.5, 220.0, 4.5])),
];
fields.extend(left.map(|v| ("HasLeftHorn", v)));
mob_from_nbt(&Tag::compound(fields), &types, 1, None)
.unwrap()
.extra
.lost_horns
};
assert_eq!(lost(None), [false, false]);
assert_eq!(lost(Some(Tag::Byte(1))), [false, false]);
assert_eq!(lost(Some(Tag::Byte(0))), [true, false]);
assert_eq!(lost(Some(Tag::Int(256))), [true, false]);
assert_eq!(lost(Some(Tag::Int(257))), [false, false]);
}
#[test]
fn non_mob_saves_drop_mob_flags_without_losing_entity_fields() {
let uuid = [104, 0, 0, 1];
@@ -8003,6 +8084,14 @@ mod fireball_nbt_tests {
("Facing", Tag::Byte(1)),
]);
assert_eq!(item_frame_from_nbt(&frame, 1).unwrap().pos, [-1, 80, 6]);
// BlockAttachedEntity.readAdditionalSaveData: a block_pos 16 blocks or
// more from the Pos block is refused for that block.
let mut far = painting.clone();
put(&mut far, "block_pos", Tag::IntArray(vec![28, 80, 22]));
assert_eq!(painting_from_nbt(&far).map(|p| p.0), Some([28, 80, 6]));
let mut near = painting.clone();
put(&mut near, "block_pos", Tag::IntArray(vec![28, 80, 21]));
assert_eq!(painting_from_nbt(&near).map(|p| p.0), Some([28, 80, 21]));
}
#[test]
+68 -21
View File
@@ -13,7 +13,8 @@ fn loaded_pitch(raw: Option<&Tag>) -> f32 {
0.0
}
}
/// Painting.addAdditionalSaveData: `facing`, `variant` and `block_pos`.
/// Painting.addAdditionalSaveData: `facing`, `variant` and `block_pos`, after
/// Entity.saveWithoutId's common fields.
pub fn painting_to_nbt(p: &Painting, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
let (lo, hi) = p.bounds();
let base = Base {
@@ -26,8 +27,12 @@ pub fn painting_to_nbt(p: &Painting, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
),
motion: Vec3::ZERO,
rotation: [f32::from(p.facing) * 90.0, loaded_pitch(raw)],
on_ground: false,
fall_distance: 0.0,
// BlockAttachedEntity.tick never moves a painting (nor runs baseTick), so its
// onGround and fallDistance stay the ones Entity.load read.
on_ground: raw.is_some_and(|raw| flag(raw, "OnGround")),
fall_distance: raw
.and_then(|raw| float(raw.get("fall_distance")))
.unwrap_or(0.0),
};
let mut tag = base.tag(raw);
put(&mut tag, "facing", Tag::Byte(p.facing as i8));
@@ -35,21 +40,37 @@ pub fn painting_to_nbt(p: &Painting, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
put(&mut tag, "block_pos", Tag::IntArray(p.pos.to_vec()));
tag
}
/// `BlockAttachedEntity.readAdditionalSaveData`: `block_pos` when saved; a
/// summoned one has none, and `setPos` puts its block at the block holding
/// the summon position.
/// `BlockAttachedEntity.readAdditionalSaveData`: `block_pos` when saved and
/// closer than 16 blocks to the block holding `Pos`; else (a summoned one
/// has none) `setPos` puts it at that block.
fn attached_block_pos(tag: &Tag) -> Option<[i32; 3]> {
match tag.get("block_pos") {
let stored = match tag.get("block_pos") {
Some(Tag::IntArray(v)) if v.len() == 3 => Some([v[0], v[1], v[2]]),
_ => {
let at = position(tag)?;
Some([
at.x.floor() as i32,
at.y.floor() as i32,
at.z.floor() as i32,
])
}
}
_ => None,
};
let Some(at) = position(tag) else {
return stored;
};
let here = [
at.x.floor() as i32,
at.y.floor() as i32,
at.z.floor() as i32,
];
// Vec3i.closerThan(blockPosition(), 16): the squared distance under 256.
let near = |p: &[i32; 3]| {
(0..3)
.map(|i| (f64::from(p[i]) - f64::from(here[i])).powi(2))
.sum::<f64>()
< 256.0
};
Some(stored.filter(near).unwrap_or(here))
}
/// The block position and facing of a saved painting (`HangingEntity`'s
/// `facing`, a 2D direction).
pub fn painting_placement_from_nbt(tag: &Tag) -> Option<([i32; 3], u8)> {
let pos = attached_block_pos(tag)?;
let facing = wrap_data_value(number(tag.get("facing")).unwrap_or(0), 4);
Some((pos, facing))
}
/// `Direction.from3DDataValue` / `from2DDataValue`: the byte read by
/// `LEGACY_ID_CODEC` indexes the table as `abs(data % modulus)`, never clamped.
@@ -59,8 +80,7 @@ fn wrap_data_value(raw: impl TryInto<i64> + Copy, modulus: i64) -> u8 {
}
/// The block position, facing and variant (if saved) of a painting.
pub fn painting_from_nbt(tag: &Tag) -> Option<([i32; 3], u8, Option<String>)> {
let pos = attached_block_pos(tag)?;
let facing = wrap_data_value(number(tag.get("facing")).unwrap_or(0), 4);
let (pos, facing) = painting_placement_from_nbt(tag)?;
Some((pos, facing, text(tag.get("variant"))))
}
/// ItemFrame.addAdditionalSaveData.
@@ -74,10 +94,14 @@ pub fn item_frame_to_nbt(f: &ItemFrame, uuid: [i32; 4], raw: Option<&Tag>) -> Ta
(lo[1] + hi[1]) / 2.0,
(lo[2] + hi[2]) / 2.0,
),
motion: Vec3::ZERO,
// BlockAttachedEntity.tick never runs super.tick, so the Motion, onGround and
// fallDistance that Entity.load read stay.
motion: raw.map_or(Vec3::ZERO, loaded_motion),
rotation: [0.0, 0.0],
on_ground: false,
fall_distance: 0.0,
on_ground: raw.is_some_and(|raw| flag(raw, "OnGround")),
fall_distance: raw
.and_then(|raw| float(raw.get("fall_distance")))
.unwrap_or(0.0),
};
let mut tag = base.tag(raw);
// `Entity.saveWithoutId`: `CustomName` before the frame's own fields.
@@ -148,4 +172,27 @@ mod tests {
]);
assert_eq!(painting_from_nbt(&tag).unwrap().1, 1);
}
#[test]
fn item_frame_keeps_loaded_motion_on_ground_and_fall_distance() {
let tag = Tag::compound([
("id", string("minecraft:item_frame")),
("Pos", doubles(&[0.5, 64.0, 0.5])),
("Facing", Tag::Byte(2)),
("Motion", doubles(&[0.5, 20.0, -0.25])),
("OnGround", Tag::Byte(1)),
("fall_distance", Tag::Double(3.5f64.to_bits())),
]);
let frame = item_frame_from_nbt(&tag, 1).unwrap();
let saved = item_frame_to_nbt(&frame, [1, 2, 3, 4], Some(&tag));
// A component beyond 10 loads as 0, the others stay.
assert_eq!(motion(&saved), Vec3::new(0.5, 0.0, -0.25));
assert!(flag(&saved, "OnGround"));
assert_eq!(float(saved.get("fall_distance")), Some(3.5));
// A frame made in play has nothing loaded: zeros.
let made = item_frame_to_nbt(&frame, [1, 2, 3, 4], None);
assert_eq!(motion(&made), Vec3::ZERO);
assert!(!flag(&made, "OnGround"));
assert_eq!(float(made.get("fall_distance")), Some(0.0));
}
}
+23
View File
@@ -317,6 +317,12 @@ pub fn settings_from_json(
impl ChunkGenerator for FlatTerrain {
type Worker = ();
fn worker(&self) {}
/// FlatLevelSource's proto is already its decorated column. Keep a copy
/// for lighting a finished neighbour outside the kept area, just as the
/// vanilla pipeline does for FEATURES neighbours.
fn light_view(&self, proto: &crate::streaming::Proto) -> Option<Chunk> {
proto.downcast_ref::<Chunk>().cloned()
}
fn lod_surface(&self, _: &mut (), _: i32, _: i32) -> Option<crate::lod::Surface> {
self.surface
}
@@ -610,4 +616,21 @@ mod tests {
.is_none()
);
}
#[test]
fn light_view_keeps_a_flat_proto_column() {
let terrain = FlatTerrain {
shape: LevelShape::OVERWORLD,
layers: Vec::new(),
biome: 0,
surface: None,
void_platform: None,
};
let mut chunk = Chunk::empty(3, -4);
chunk.set(0, 0, 0, 42);
let proto: crate::streaming::Proto = Box::new(chunk);
let view = terrain.light_view(&proto).expect("flat proto light view");
assert_eq!((view.x, view.z), (3, -4));
assert_eq!(view.block(0, 0, 0).0, 42);
}
}
+44 -19
View File
@@ -476,7 +476,6 @@ mod trial_spawner;
mod trial_visuals;
mod trident;
mod tutorial;
mod tweaks;
mod ui_control;
mod use_hand;
mod using_item;
@@ -2230,7 +2229,7 @@ struct Game<'a> {
/// `AbstractContainerScreen`'s quick craft and skip-release state.
inv_clicks: horizon_client::container_screen::Clicks,
/// The Mouse Tweaks extra's drag and wheel (`tweaks`).
tweaks: tweaks::Tweaks,
tweaks: extras::mouse_tweaks::Tweaks,
/// The wheel and hovered slot of `BundleMouseActions`.
bundle_mouse: bundles::BundleMouse,
/// The last inventory click, for the double click that gathers a stack.
@@ -7211,14 +7210,14 @@ impl Game<'_> {
laps.lap("eg_objects");
// Particles (ParticleEngine.extract): camera-facing quads.
self.mark_stage(out.len(), "PARTICLES");
let world = &self.world;
let particle_mode = self.settings.horizon.particle_culling;
if particle_mode == horizon_client::graphics::ParticleCullingMode::Off {
self.particles.render(
self.particles.render_in_view(
camera,
self.particle_rotation(),
alpha as f32,
&|b| world.light(b[0], b[1], b[2]),
self.view_frustum.as_ref(),
&|b| self.world.light(b[0], b[1], b[2]),
&mut out,
);
} else {
@@ -7241,7 +7240,7 @@ impl Game<'_> {
true,
frustum.as_ref(),
&mut |min, max| particle_culling.hidden(min, max),
&|b| world.light(b[0], b[1], b[2]),
&|b| self.world.light(b[0], b[1], b[2]),
&mut out,
);
}
@@ -8189,19 +8188,16 @@ impl Game<'_> {
.ok_or("missing lit block state")?;
state.properties.insert("lit".into(), "true".into());
self.level_set(hit.pos, registry.resolve(&state)?)?;
self.audio.play(
if item == "fire_charge" {
"minecraft:item.firecharge.use"
} else {
"minecraft:item.flintandsteel.use"
},
Some(at(hit.pos)),
1.0,
horizon_client::items::fire_starter_pitch(
item == "fire_charge",
&mut self.world.random,
),
let sound = if item == "fire_charge" {
"minecraft:item.firecharge.use"
} else {
"minecraft:item.flintandsteel.use"
};
let pitch = horizon_client::items::fire_starter_pitch(
item == "fire_charge",
&mut self.world.random,
);
self.play_block_use_sound(sound, at(hit.pos), pitch);
// FlintAndSteelItem / FireChargeItem.useOn: BLOCK_CHANGE at the lit block,
// and ItemStack.useOn's used statistic.
self.post_block_game_event(
@@ -8232,7 +8228,7 @@ impl Game<'_> {
item == "fire_charge",
&mut self.world.random,
);
self.audio.play(sound, Some(at(target)), 1.0, pitch);
self.play_block_use_sound(sound, at(target), pitch);
// BLOCK_PLACE: flint and steel names the clicked block, a fire charge the
// fire. Then ItemStack.useOn's used statistic.
let placed_at = if item == "fire_charge" {
@@ -12590,6 +12586,35 @@ mod subtitles;
#[cfg(test)]
#[path = "game_tests.rs"]
mod tests;
#[cfg(test)]
mod test_neighbourhood {
//! Shared fixture step for tests that tick entities on prepared data.
use super::*;
impl Game<'_> {
/// Loads the chunks within 2 columns of `(cx, cz)` that are not loaded yet.
/// Entity ticking waits for every chunk in that 5x5 neighbourhood
/// (`ChunkMap.prepareEntityTickingChunk`), so a fixture that ticks mobs,
/// items or TNT in a chunk calls this after setting up its own chunks.
pub(crate) fn load_ticking_neighbourhood(&mut self, cx: i32, cz: i32) {
for z in cz - 2..=cz + 2 {
for x in cx - 2..=cx + 2 {
if self.world.chunks.contains_key(&(x, z)) {
continue;
}
self.world.chunks.insert(
(x, z),
std::sync::Arc::new(horizon_client::chunk::Chunk::empty(x, z)),
);
// A chunk that is already registered keeps its ticks.
let _ = self
.block_ticks
.add_empty_chunk(horizon_core::position::ChunkPos::new(x, z));
}
}
}
}
}
fn write_level_for_role(
level: &mut Level,
@@ -51,6 +51,7 @@ fn game() -> Game<'static> {
game.block_ticks
.add_empty_chunk(ChunkPos::new(0, 0))
.unwrap();
game.load_ticking_neighbourhood(0, 0);
for command in [
"forceload add 0 0",
"fill 0 99 0 15 99 15 stone",
@@ -2709,6 +2709,7 @@ mod postbaseline_lodestone_controls {
game.block_ticks
.add_empty_chunk(BlockPos::new(64, 0, 64).chunk())
.unwrap();
game.load_ticking_neighbourhood(0, 0);
assert!(game.run_command("fill 64 199 64 79 199 79 stone"));
assert!(game.run_command(&format!("summon armor_stand 65.5 200.0 65.5 {{UUID:[I;42321,0,0,102],Health:20f,NoGravity:1b,equipment:{{mainhand:{}}}}}",item("minecraft:compass",tracked,100,1))));
let id = |n| {
+2 -2
View File
@@ -1508,7 +1508,6 @@ impl Game<'_> {
Vec3::new(b.position.x, top, b.position.z)
}
}
/// `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) {
@@ -1516,6 +1515,7 @@ impl Game<'_> {
b.previous_yaw = yaw;
}
}
/// `BoatItem.use`: aim at a block or the water surface.
pub(super) fn place_boat(&mut self, stack: &ItemStack) -> bool {
let Some(boat) = self.boat_to_place(stack) else {
return false;
@@ -1528,7 +1528,7 @@ impl Game<'_> {
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);
self.apply_item_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() {
@@ -18,7 +18,7 @@ impl Game<'_> {
/// `Entity.checkBelowWorld` for the boat at `i`: true when it fell out of
/// the world and is gone.
pub(in crate::game) fn boat_below_world(&mut self, i: usize) -> bool {
if !below_world(self.boats[i].position.y, self.world.bottom_y()) {
if !below_world(self.boats[i].position.y, self.world.shape.min_y) {
return false;
}
self.eject_boat_mobs(i, true);
@@ -41,7 +41,7 @@ impl Game<'_> {
kind: &str,
) -> Option<usize> {
let i = cart_at(&self.carts, id, slot)?;
if !below_world(self.carts[i].position.y, self.world.bottom_y()) {
if !below_world(self.carts[i].position.y, self.world.shape.min_y) {
return Some(i);
}
self.eject_cart_mobs(i);
@@ -68,44 +68,51 @@ mod tests {
assert!(below_world(-64.5, 0));
}
/// A tick discards the boat and the chest minecart under -128 in the
/// overworld, before they move, and spills the cart's slots; the two on
/// the line or above it stay, whatever their fall this tick.
/// A tick discards vehicles below each dimension's own floor, even when
/// its chunk storage extends lower, and spills the container's contents.
/// Vehicles on the line or above it survive this tick.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_tick_discards_the_vehicles_under_the_world() {
use horizon_client::chunk::Chunk;
use horizon_core::position::ChunkPos;
let mut g = crate::game::native_machines::tests::server_game();
g.build_registries();
g.set_allow_commands(true);
g.world
.chunks
.insert((0, 0), std::sync::Arc::new(Chunk::empty(0, 0)));
g.block_ticks.add_empty_chunk(ChunkPos::new(0, 0)).unwrap();
assert!(g.run_command("forceload add 0 0"));
for summon in [
"summon oak_boat 0.5 -128 0.5",
"summon oak_boat 4.5 -128.5 4.5",
"summon minecart 6.5 -127 6.5",
"summon chest_minecart 2.5 -129 2.5 {Items:[{Slot:0b,id:\"minecraft:diamond\",count:3}]}",
] {
assert!(g.run_command(summon), "{summon}");
for min_y in [-64, 0] {
let mut g = crate::game::native_machines::tests::server_game();
g.world.shape.min_y = min_y;
g.build_registries();
g.set_allow_commands(true);
g.world
.chunks
.insert((0, 0), std::sync::Arc::new(Chunk::empty(0, 0)));
g.block_ticks.add_empty_chunk(ChunkPos::new(0, 0)).unwrap();
assert!(g.run_command("forceload add 0 0"));
let line = min_y - 64;
for summon in [
format!("summon oak_boat 0.5 {line} 0.5"),
format!("summon oak_boat 4.5 {} 4.5", f64::from(line) - 0.5),
format!("summon minecart 6.5 {} 6.5", line + 1),
format!(
"summon chest_minecart 2.5 {} 2.5 {{Items:[{{Slot:0b,id:\"minecraft:diamond\",count:3}}]}}",
line - 1
),
] {
assert!(g.run_command(&summon), "{summon}");
}
g.sync_tick_chunks();
g.tick_boats();
g.tick_carts();
let boats: Vec<f64> = g.boats.iter().map(|b| b.position.x).collect();
assert_eq!(boats, [0.5]);
let carts: Vec<f64> = g.carts.iter().map(|c| c.position.x).collect();
assert_eq!(carts, [6.5]);
let spilled: u32 = g
.dropped
.iter()
.filter(|i| &*i.stack.item == "minecraft:diamond")
.map(|i| i.stack.count)
.sum();
assert_eq!(spilled, 3);
}
g.sync_tick_chunks();
g.tick_boats();
g.tick_carts();
let boats: Vec<f64> = g.boats.iter().map(|b| b.position.x).collect();
assert_eq!(boats, [0.5]);
let carts: Vec<f64> = g.carts.iter().map(|c| c.position.x).collect();
assert_eq!(carts, [6.5]);
let spilled: u32 = g
.dropped
.iter()
.filter(|i| &*i.stack.item == "minecraft:diamond")
.map(|i| i.stack.count)
.sum();
assert_eq!(spilled, 3);
}
#[test]
@@ -54,6 +54,9 @@ fn game() -> Game<'static> {
.chunks
.insert((x, 0), Arc::new(Chunk::empty(x, 0)));
}
for x in 0..2 {
game.load_ticking_neighbourhood(x, 0);
}
assert!(game.run_command("fill 0 99 0 31 99 15 stone"));
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(4.5, 100.0, 6.5)).unwrap());
let net = NetConfig {
+9 -125
View File
@@ -4,6 +4,7 @@ mod admin;
pub(super) mod compute;
mod data;
mod display_names;
mod target_position;
pub(super) use data::place_summoned;
mod effects;
pub(super) mod execute;
@@ -451,9 +452,7 @@ impl Game<'_> {
// the server's own source, "Server".
let who = match self.command_source.and_then(|s| s.entity) {
Some(entity) => self.target_name(entity),
None if self.console_broadcast.is_some() => {
Text::literal(if self.rcon_source { "Rcon" } else { "Server" }.to_owned())
}
None if self.console_broadcast.is_some() => self.server_name(),
None => self.player_display_name(),
};
let line = Text::translate("chat.type.admin", &[who, message])
@@ -538,118 +537,6 @@ impl Game<'_> {
conn.and_then(|c| self.command_guest(c))
.map_or(self.dim, |g| Dim::from_id(&g.dimension))
}
pub(super) fn target_position(&self, t: Target) -> Option<[f64; 3]> {
match t {
Target::Player => self
.player
.as_ref()
.map(|p| [p.position.x, p.position.y, p.position.z]),
Target::Guest(conn) => self.command_guest(conn).map(|g| g.position),
Target::Mob(id) => self
.mobs
.iter()
.find(|m| m.id == id)
.map(|m| [m.position.x, m.position.y, m.position.z]),
Target::Item(id) => self
.dropped
.iter()
.find(|i| i.id == id)
.map(|i| [i.position.x, i.position.y, i.position.z]),
Target::Orb(id) => self
.orbs
.iter()
.find(|o| o.id == id)
.map(|o| [o.position.x, o.position.y, o.position.z]),
Target::Projectile(id) => self
.creatures
.projectiles
.iter()
.find(|a| a.id == id)
.map(|a| [a.position.x, a.position.y, a.position.z]),
Target::Stand(id) => self
.armor_stands
.iter()
.find(|a| a.id == id)
.map(|a| [a.position.x, a.position.y, a.position.z]),
Target::Frame(id) => self.item_frames.iter().find(|f| f.id == id).map(|f| {
let (lo, hi) = f.bounds();
[
(lo[0] + hi[0]) / 2.0,
(lo[1] + hi[1]) / 2.0,
(lo[2] + hi[2]) / 2.0,
]
}),
Target::Painting(id) => self.paintings.iter().find(|p| p.id == id).map(|p| {
let (lo, hi) = p.bounds();
[
(lo[0] + hi[0]) / 2.0,
(lo[1] + hi[1]) / 2.0,
(lo[2] + hi[2]) / 2.0,
]
}),
Target::Boat(id) => self
.boats
.iter()
.find(|b| b.id == id)
.map(|b| [b.position.x, b.position.y, b.position.z]),
Target::Cart(id) => self
.carts
.iter()
.find(|c| c.id == id)
.map(|c| [c.position.x, c.position.y, c.position.z]),
Target::Other(id) => self
.others
.iter()
.find(|o| o.id == id)
.map(|o| [o.position.x, o.position.y, o.position.z]),
Target::Crystal(id) => self
.crystals
.iter()
.find(|c| c.id == id)
.map(|c| [c.position.x, c.position.y, c.position.z]),
Target::Dragon(id) => self
.dragon
.as_ref()
.filter(|d| d.id == id)
.map(|d| [d.position.x, d.position.y, d.position.z]),
Target::Cushion(id) => self
.cushions
.iter()
.find(|c| c.id == id)
.map(|c| [c.position.x, c.position.y, c.position.z]),
Target::Tnt(id) => self
.tnts
.iter()
.find(|t| t.id == id)
.map(|t| [t.position.x, t.position.y, t.position.z]),
Target::Falling(id) => self
.falling
.iter()
.find(|f| f.id == id)
.map(|f| [f.position.x, f.position.y, f.position.z]),
Target::Rocket(id) => self
.rockets
.iter()
.find(|r| r.id == id)
.map(|r| [r.position.x, r.position.y, r.position.z]),
Target::Cloud(id) => self.breath_clouds.iter().find(|c| c.id == id).map(|c| c.at),
Target::Trident(id) => self
.tridents
.iter()
.find(|t| t.id == id)
.map(|t| [t.position.x, t.position.y, t.position.z]),
Target::Fangs(id) => self.fangs.list.iter().find(|f| f.id == id).map(|f| f.pos),
Target::Spawner(id) => self
.ominous_spawners
.iter()
.find(|s| s.id == id)
.map(|s| s.position),
Target::Knot(id) => self.natural_knot(id).map(|fence| {
let p = horizon_client::mobs::leash::knot_state(fence).position;
[p.x, p.y, p.z]
}),
}
}
pub(super) fn target_type(&self, t: Target) -> String {
match t {
Target::Player | Target::Guest(_) => "minecraft:player".into(),
@@ -1851,7 +1738,7 @@ impl Game<'_> {
}
Target::Crystal(id) => {
if let Some(c) = self.crystals.iter_mut().find(|c| c.id == id) {
c.position = v;
(c.position, c.fall.0) = (v, true); // performTeleport: setOnGround(true)
}
}
Target::Dragon(id) => {
@@ -1862,6 +1749,8 @@ impl Game<'_> {
}
Target::Tnt(id) => {
if let Some(t) = self.tnts.iter_mut().find(|t| t.id == id) {
// `PrimedTnt.teleport` includes same-level command teleports.
t.used_portal = true;
t.position = v;
t.previous = v;
if let Some(r) = rotation {
@@ -2635,14 +2524,9 @@ impl Game<'_> {
self.next_entity += 1;
let at = Vec3::new(pos[0], pos[1], pos[2]);
let drawn = self.world.random.next_int_bound(100_000).unwrap_or(0);
self.crystals.push(super::dragon::Crystal {
id: self.next_entity,
position: at,
show_bottom: true,
time: super::dragon::Crystal::start_time(at, drawn, self.clock.still()),
beam: None,
invulnerable: false,
});
let time = super::dragon::Crystal::start_time(at, drawn, self.clock.still());
let crystal = super::dragon::Crystal::new(self.next_entity, at, true, time);
self.crystals.push(crystal);
// tryAddFreshEntityWithPassengers refused: ERROR_DUPLICATE_UUID.
if !self.native_joins_flush(false, self.next_entity) {
return Err(err("commands.summon.failed.uuid", &[]));
@@ -4412,7 +4296,7 @@ impl Game<'_> {
.map(|cart| cart.command.name())
})
})
.unwrap_or_else(|| Text::literal("Server")),
.unwrap_or_else(|| self.server_name()),
},
None => self.player_display_name(),
};
@@ -697,6 +697,27 @@ impl Game<'_> {
self.carts[i] = cart;
set_key(&mut tag, "UUID", Tag::IntArray(uuid.to_vec()));
}
Target::Painting(id) => {
// Painting.readAdditionalSaveData over HangingEntity's: the
// facing, the block it hangs on and a known variant
// (`VariantUtils.readVariant(..).ifPresent`: an unknown or
// unreadable one keeps the painting's).
let uuid = self.uuid_for(id);
let (pos, facing) =
entity_nbt::painting_placement_from_nbt(&tag).ok_or_else(stale)?;
let known = entity_nbt::painting_from_nbt(&tag)
.and_then(|(_, _, v)| self.painting_variants.get(&v?).cloned());
let p = self
.paintings
.iter_mut()
.find(|p| p.id == id)
.ok_or_else(stale)?;
(p.pos, p.facing) = (pos, facing);
if let Some(v) = known {
(p.variant, p.width, p.height) = (v.id, v.width, v.height);
}
set_key(&mut tag, "UUID", Tag::IntArray(uuid.to_vec()));
}
Target::Other(id) => {
let uuid = self.uuid_for(id);
let marker = self
@@ -748,6 +769,7 @@ impl Game<'_> {
| Target::Item(id)
| Target::Stand(id)
| Target::Frame(id)
| Target::Painting(id)
| Target::Boat(id)
| Target::Cart(id)
| Target::Tnt(id)
@@ -1545,7 +1567,7 @@ impl Game<'_> {
let before = self.next_entity;
let mut tag = tag;
crate::game::persist::mark_fresh(&mut tag);
self.world.loaded_entities.push((key, vec![tag]));
self.world.queue_entity_nbt(key, vec![tag]);
self.spawn_loaded_entities();
waiting.append(&mut self.world.loaded_entities);
self.world.loaded_entities = waiting;
@@ -61,6 +61,12 @@ impl Game<'_> {
.unwrap_or(Target::Player)
}
/// The name of the server's own source: "Rcon" for a command an RCON client sent
/// (`RconConsoleSource`), "Server" otherwise (`MinecraftServer.createCommandSourceStack`).
pub(in crate::game) fn server_name(&self) -> Text {
Text::literal(if self.rcon_source { "Rcon" } else { "Server" })
}
/// `PlayerList.getPlayerByName`: names are not case sensitive. Horizon:
/// on a server where a mod gave players nicknames
/// (`native_set_nickname`), a name no player has finds the player going
@@ -0,0 +1,116 @@
use super::{Game, Target};
impl Game<'_> {
pub(in crate::game) fn target_position(&self, t: Target) -> Option<[f64; 3]> {
match t {
Target::Player => self
.player
.as_ref()
.map(|p| [p.position.x, p.position.y, p.position.z]),
Target::Guest(conn) => self.command_guest(conn).map(|g| g.position),
Target::Mob(id) => self
.mobs
.iter()
.find(|m| m.id == id)
.map(|m| [m.position.x, m.position.y, m.position.z]),
Target::Item(id) => self
.dropped
.iter()
.find(|i| i.id == id)
.map(|i| [i.position.x, i.position.y, i.position.z]),
Target::Orb(id) => self
.orbs
.iter()
.find(|o| o.id == id)
.map(|o| [o.position.x, o.position.y, o.position.z]),
Target::Projectile(id) => self
.creatures
.projectiles
.iter()
.find(|a| a.id == id)
.map(|a| [a.position.x, a.position.y, a.position.z]),
Target::Stand(id) => self
.armor_stands
.iter()
.find(|a| a.id == id)
.map(|a| [a.position.x, a.position.y, a.position.z]),
Target::Frame(id) => self.item_frames.iter().find(|f| f.id == id).map(|f| {
let (lo, hi) = f.bounds();
[
(lo[0] + hi[0]) / 2.0,
(lo[1] + hi[1]) / 2.0,
(lo[2] + hi[2]) / 2.0,
]
}),
Target::Painting(id) => self.paintings.iter().find(|p| p.id == id).map(|p| {
let (lo, hi) = p.bounds();
[
(lo[0] + hi[0]) / 2.0,
(lo[1] + hi[1]) / 2.0,
(lo[2] + hi[2]) / 2.0,
]
}),
Target::Boat(id) => self
.boats
.iter()
.find(|b| b.id == id)
.map(|b| [b.position.x, b.position.y, b.position.z]),
Target::Cart(id) => self
.carts
.iter()
.find(|c| c.id == id)
.map(|c| [c.position.x, c.position.y, c.position.z]),
Target::Other(id) => self
.others
.iter()
.find(|o| o.id == id)
.map(|o| [o.position.x, o.position.y, o.position.z]),
Target::Crystal(id) => self
.crystals
.iter()
.find(|c| c.id == id)
.map(|c| [c.position.x, c.position.y, c.position.z]),
Target::Dragon(id) => self
.dragon
.as_ref()
.filter(|d| d.id == id)
.map(|d| [d.position.x, d.position.y, d.position.z]),
Target::Cushion(id) => self
.cushions
.iter()
.find(|c| c.id == id)
.map(|c| [c.position.x, c.position.y, c.position.z]),
Target::Tnt(id) => self
.tnts
.iter()
.find(|t| t.id == id)
.map(|t| [t.position.x, t.position.y, t.position.z]),
Target::Falling(id) => self
.falling
.iter()
.find(|f| f.id == id)
.map(|f| [f.position.x, f.position.y, f.position.z]),
Target::Rocket(id) => self
.rockets
.iter()
.find(|r| r.id == id)
.map(|r| [r.position.x, r.position.y, r.position.z]),
Target::Cloud(id) => self.breath_clouds.iter().find(|c| c.id == id).map(|c| c.at),
Target::Trident(id) => self
.tridents
.iter()
.find(|t| t.id == id)
.map(|t| [t.position.x, t.position.y, t.position.z]),
Target::Fangs(id) => self.fangs.list.iter().find(|f| f.id == id).map(|f| f.pos),
Target::Spawner(id) => self
.ominous_spawners
.iter()
.find(|s| s.id == id)
.map(|s| s.position),
Target::Knot(id) => self.natural_knot(id).map(|fence| {
let p = horizon_client::mobs::leash::knot_state(fence).position;
[p.x, p.y, p.z]
}),
}
}
}
+14 -14
View File
@@ -1080,7 +1080,7 @@ impl Game<'_> {
max_entity_cramming: self.ui.rules.int("max_entity_cramming"),
dimension_type: self.dimension_type(),
world_seed: self.level.seed,
sea_level: if self.dim == Dim::Nether { 32 } else { 63 },
sea_level: horizon_world::level::sea_level_of(self.level_key()),
teams: std::sync::Arc::new(self.mob_teams()),
items: Some(self.items()),
lid_openers: &self.lid_openers,
@@ -1280,10 +1280,9 @@ impl Game<'_> {
.is_some()
.then_some(self.level.player_uuid)
.flatten(),
mods: round.listening() || !horizon_client::mod_api::mods().is_empty(),
ticking: &|| Box::new(self.entity_ticking_area()),
};
let mut group = match islands.plan(&mobs, &snapshot, &turns) {
let mut group = match islands.plan(&mobs, &snapshot, &turns, &start) {
Some(plan) => islands.run(plan, mobs.into(), snapshot, start, &mut turns, &shared),
None => {
let mut group = mob_group::Group::new(mobs.into(), snapshot, start);
@@ -1878,10 +1877,9 @@ impl Game<'_> {
for item in stacks {
// Every death drop is its own ItemEntity, and the drop entity's
// own random (not the level's) gives it the constructor's
// horizontal push (its motion otherwise stays [0, 0.2, 0] and
// the drops land stacked at the mob's centre, without disturbing
// the level random that the orbs' merge ids and block ticks read
// next).
// horizontal push (its motion otherwise stays [0, 0.2, 0] and the
// drops land stacked at the mob's centre, without disturbing the
// level random that the orbs' merge ids and block ticks read next).
self.spawn_item_own(item, pos, 10);
}
// LivingEntity.dropExperience: a mod may change the mob's experience
@@ -1921,8 +1919,9 @@ impl Game<'_> {
self.spawn_item_own(item, pos, 10);
}
}
drops::Drop::Aged(s, age) => self.spawn_item_aged(from_loot(&s), pos, age),
drops::Drop::Orb(value) if !consumed => self.spawn_orbs(pos, value as u32),
drops::Drop::Item(_) | drops::Drop::Stack(_) | drops::Drop::Orb(_) => {}
_ => {}
}
}
}
@@ -3234,11 +3233,7 @@ impl Game<'_> {
MobEvent::DolphinTreasure { mob } => {
// ServerLevel.findNearestMapStructure(#dolphin_located, 50).
let Some(at) = self.mobs.iter().find(|m| m.id == mob).map(|m| {
[
m.position.x.floor() as i32,
m.position.y.floor() as i32,
m.position.z.floor() as i32,
]
[m.position.x, m.position.y, m.position.z].map(|v| v.floor() as i32)
}) else {
continue;
};
@@ -3249,7 +3244,11 @@ impl Game<'_> {
.filter(|_| !names.is_empty())
.and_then(|t| t.locate(&names, at, 50));
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == mob) {
m.extra.home = found.map(|(_, p)| p);
m.extra.home = found.as_ref().map(|(_, p)| *p);
}
// DolphinSwimToTreasureGoal.start: entity event 38 once it has a treasure.
if found.is_some() {
self.mob_event_particles(mob, 38);
}
}
MobEvent::EffectPlayer {
@@ -9051,6 +9050,7 @@ mod h169_glide_travel_tests {
.unwrap();
}
}
game.load_ticking_neighbourhood(0, 0);
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(2.5, 200.0, 2.5)).unwrap());
game.creative = true;
assert!(game.run_command("fill 0 199 0 31 199 31 stone"));
@@ -64,6 +64,9 @@ pub(super) struct Group<M: MobSlot> {
pub corpses: Vec<Mob>,
pub gone: Vec<Gone>,
pub guest_events: Vec<(u32, u32, MobEvent)>,
/// The mod hooks its turns kept for the join, with each turn's place in the whole list (a
/// group of the parallel tick with mods loaded: `native_client_hooks::Deferred`).
pub deferred: Vec<(usize, horizon_client::native_client_hooks::Deferred)>,
}
/// What decides each turn besides the group, read at the turn as the serial loop reads it.
@@ -77,7 +80,7 @@ pub(super) struct Turns<'t, 'w> {
/// The one player's position, when the level has one: every mob's nearest.
pub single_player: Option<Option<Vec3>>,
/// Whether a mod canceled a mob's tick.
pub canceled: &'t dyn Fn(u32) -> bool,
pub canceled: &'t (dyn Fn(u32) -> bool + Sync),
/// `getCurrentDifficultyAt` a position.
pub difficulty: &'t (dyn Fn([f64; 3]) -> LocalDifficulty + Sync),
/// The last chunk's difficulty: mobs come in runs of one chunk.
@@ -137,6 +140,7 @@ impl<M: MobSlot> Group<M> {
corpses: Vec::new(),
gone: Vec::new(),
guest_events: Vec::new(),
deferred: Vec::new(),
}
}
@@ -173,7 +177,17 @@ impl<M: MobSlot> Group<M> {
foreign.reader.set(at);
}
if let Some(foreign) = foreign {
foreign.in_seed_order(at, || self.turn(i, turns, shared));
foreign.in_seed_order(at, || {
if foreign.defers {
let ((), kept) =
horizon_client::native_client_hooks::deferring_turn(|| {
self.turn(i, turns, shared);
});
self.deferred.extend(kept.into_iter().map(|d| (at, d)));
} else {
self.turn(i, turns, shared);
}
});
} else {
self.turn(i, turns, shared);
}
@@ -363,7 +377,10 @@ impl<M: MobSlot> Group<M> {
shared.guest_ids,
self.player.as_ref(),
);
if let Some((player, mob)) = named {
// A grouped turn runs while no mod hooks the change (`horizon_mod_api_gen::mob_turn`).
if let Some((player, mob)) = named
&& !horizon_client::native_client_hooks::deferring()
{
let kind = super::super::native_hooks::mob_kind(m);
let mut cancel = false;
Game::native_living_change_target(
File diff suppressed because it is too large. Load diff
@@ -5,9 +5,14 @@ use super::super::{
};
use horizon_client::{mobs::Species, scoreboard::teams_allow_damage};
use horizon_core::geometry::Vec3;
use horizon_protocol::packets::play::{Clientbound, GameEvent};
/// `ClientboundGameEventPacket.PUFFER_FISH_STING`.
const PUFFER_FISH_STING: u8 = 9;
impl Game<'_> {
/// `Pufferfish.playerTouch`: accepted player damage, then poison with this fish as source.
/// `Pufferfish.playerTouch`: accepted player damage, the sting game event, then poison with
/// this fish as source.
pub(in crate::game) fn touch_pufferfish(&mut self) {
if self.remote.is_some() || self.spectator() {
return;
@@ -46,10 +51,11 @@ impl Game<'_> {
m.extra.size,
[m.position.x, m.position.y, m.position.z],
self.mob_entity(m),
m.silent,
)
})
.collect();
for (id, puff, at, source) in touching {
for (id, puff, at, source, silent) in touching {
if !self.hurt_player_from(
(1 + puff) as f32,
Some(at),
@@ -58,6 +64,10 @@ impl Game<'_> {
) {
continue;
}
// `if (!this.isSilent())` inside the `hurtServer` branch: before the poison.
if !silent {
self.puffer_fish_sting();
}
let changes = self.potions.effects.changes;
self.give_player_effect("minecraft:poison", 60 * puff, 0);
if self.potions.effects.changes != changes {
@@ -65,4 +75,17 @@ impl Game<'_> {
}
}
}
/// `ServerPlayer.connection.send(ClientboundGameEventPacket(PUFFER_FISH_STING, 0.0F))`: the
/// stung player's own client plays it (`ClientPacketListener.handleGameEvent`). A server has
/// no player of its own, so the packet is the only way to reach it.
fn puffer_fish_sting(&mut self) {
self.guest_packets.push(Clientbound::GameEvent(GameEvent {
event: PUFFER_FISH_STING,
param: 0.0,
}));
}
}
#[cfg(test)]
#[path = "pufferfish_sting_tests.rs"]
mod pufferfish_sting_tests;
@@ -0,0 +1,95 @@
//! `Pufferfish.playerTouch`: the sting game event (`ClientboundGameEventPacket.PUFFER_FISH_STING`)
//! goes out only inside the `hurtServer` branch, and only when the fish is not
//! silent; the poison follows it.
use crate::game::{Audio, Game, Options, Progress, Settings, execution_role, load_resources};
use horizon_client::mobs::Mob;
use horizon_client::motion::PlayerMotion;
use horizon_core::geometry::Vec3;
use horizon_protocol::packets::play::Clientbound;
/// A game whose window player touches one puffed (state 2) pufferfish. The
/// server acts for the guest `lan_acting`: `playerTouch` runs there, and the
/// event it sends reaches that player's own client.
fn game_with(silent: bool) -> 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 = 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.creative = false;
game.lan_acting = Some(7);
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(8.5, 66.0, 8.5)).unwrap());
let kind = game.res.mob_types()["minecraft:pufferfish"].clone();
let mut fish = Mob::new(9001, kind, Vec3::new(8.5, 66.0, 8.5), 0.0);
fish.extra.size = 2;
fish.silent = silent;
game.mobs.push(fish);
game
}
/// The game events the stung player's client was sent, as `(event, parameter)`.
fn stings(game: &Game<'_>) -> Vec<(u8, f32)> {
game.guest_packets
.iter()
.filter_map(|packet| match packet {
Clientbound::GameEvent(e) => Some((e.event, e.param)),
_ => None,
})
.collect()
}
fn health(game: &Game<'_>) -> f32 {
game.player.as_ref().unwrap().health.health()
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn the_sting_event_goes_out_once_per_accepted_hit() {
let mut g = game_with(false);
g.touch_pufferfish();
assert_eq!(health(&g), 17.0, "1 + PuffState 2 of damage landed");
assert_eq!(stings(&g), vec![(9, 0.0)], "PUFFER_FISH_STING, parameter 0");
let poison = g.potions.effects.get("poison").expect("poisoned");
assert_eq!((poison.duration, poison.amplifier), (120, 0));
// A second touch inside the hurt cooldown is refused (`hurtServer` is
// false): no damage and no event.
g.touch_pufferfish();
assert_eq!(health(&g), 17.0, "the cooldown refused the second hit");
assert_eq!(stings(&g).len(), 1, "a refused hit sends nothing");
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_silent_fish_damages_and_poisons_without_the_sting() {
let mut g = game_with(true);
g.touch_pufferfish();
assert_eq!(health(&g), 17.0, "the damage still lands");
assert!(g.potions.effects.get("poison").is_some(), "the poison too");
assert!(stings(&g).is_empty(), "a silent fish sends no event");
}
+271 -26
View File
@@ -308,6 +308,17 @@ fn overload_warning(behind: Duration, tick: Duration) -> String {
)
}
/// `ServerGamePacketListenerImpl.tick`'s idle kick: a player whose last action is more than
/// `minutes` (`player-idle-timeout`, none when 0 or less) behind, counted here in ticks, is kicked,
/// unless the End poem is showing (`ServerPlayer.wonGame`): its client sends no action until it
/// asks to respawn.
fn idles_out(idle_ticks: u64, minutes: i32, won_game: bool) -> bool {
let Ok(minutes @ 1..) = u64::try_from(minutes) else {
return false;
};
!won_game && idle_ticks > minutes * 1200
}
/// Log4j's console pattern: `[HH:mm:ss] [thread/LEVEL]: message`.
pub(super) fn log(level: &str, message: &str) {
log_from("Server thread", level, message);
@@ -528,6 +539,28 @@ fn parse_whitelist_bypass(text: &str) -> std::result::Result<Vec<String>, String
Ok(out)
}
impl Lists {
/// The console's `/ban` of one player (`BanPlayerCommands.banPlayers`): whether they are banned now.
/// One banned already (`isBanned`) is left as they are, their first reason kept.
fn ban(&mut self, user: &NameAndId, reason: Option<String>) -> bool {
!self.bans.contains(user)
&& self.bans.add(BanEntry::new(
user.clone(),
Some("Server".into()),
None,
reason,
))
}
/// The console's `/ban-ip` (`BanIpCommands.banIp`): whether the address is banned now. One banned
/// already (`isBanned`) is left as it is.
fn ban_ip(&mut self, ip: &str, reason: Option<String>) -> bool {
let ip = ip.to_owned();
!self.ip_bans.contains(&ip)
&& self
.ip_bans
.add(BanEntry::new(ip, Some("Server".into()), None, reason))
}
fn load() -> Self {
let mut l = Self {
ops: OpList::new("ops.json"),
@@ -864,6 +897,28 @@ fn auto_save(state: &ServerState) -> bool {
state.lists.lock().unwrap().autosave
}
/// `MinecraftServer.tickServer`'s pause-when-empty count. `pause_after` is its
/// `pauseWhenEmptySeconds() * 20`; while that is above 0, a tick with no player and no
/// `/tick sprint` running (`ServerTickRateManager.isSprinting`) adds one to `empty_ticks`, and any
/// other tick sets it back to 0. A `/pregen` task keeps the server busy as a player would: paused,
/// nothing would post-process its chunks. Returns whether the server is paused this tick.
fn count_empty_tick(
empty_ticks: &mut i32,
pause_after: i32,
players: usize,
pregen: bool,
sprinting: bool,
) -> bool {
if pause_after > 0 {
if players == 0 && !pregen && !sprinting {
*empty_ticks += 1;
} else {
*empty_ticks = 0;
}
}
pause_after > 0 && *empty_ticks >= pause_after
}
/// `MinecraftServer.setAutoSave`: enable or disable periodic saves and report whether it changed.
pub(super) fn set_auto_save(lists: &Mutex<Lists>, enabled: bool) -> bool {
let mut l = lists.lock().unwrap();
@@ -1048,9 +1103,17 @@ impl Game<'_> {
// now rather than at the first tick (reading parses functions; it runs none), and a broken
// predicate graph stops the start before anything listens (a partial check: predicate_graph.rs).
self.ensure_datapacks();
if let Err(error) = predicate_graph::check(&self.fns.store.predicates) {
let store = &self.fns.store;
let tag = |id: &str| {
let (ns, path) = id.split_once(':').unwrap_or(("minecraft", id));
store
.files
.contains_key(&format!("tags/{ns}:predicate/{path}"))
};
if let Err(error) = predicate_graph::check(&store.predicates, tag) {
let level = match error {
predicate_graph::GraphError::Unbound(_) => "ERROR",
predicate_graph::GraphError::Unbound(_)
| predicate_graph::GraphError::UnboundTags(_) => "ERROR",
predicate_graph::GraphError::Recursive(_) => "WARN",
};
for line in error.lines() {
@@ -1371,16 +1434,13 @@ impl Game<'_> {
// Vanilla reads pauseWhenEmptySeconds on every tick, so management changes take effect
// without restarting the server.
let pause_after = state.pause_when_empty_seconds.wrapping_mul(20);
if pause_after > 0 {
// A `/pregen` task keeps the server busy as a player would:
// paused, nothing would post-process its chunks.
if players == 0 && self.pregen_level().is_none() {
state.empty_ticks += 1;
} else {
state.empty_ticks = 0;
}
}
let paused = pause_after > 0 && state.empty_ticks >= pause_after;
let paused = count_empty_tick(
&mut state.empty_ticks,
pause_after,
players,
self.pregen_level().is_some(),
self.clock.sprinting(),
);
state.paused = paused;
if paused && state.empty_ticks == pause_after {
log(
@@ -1578,12 +1638,15 @@ impl Game<'_> {
let Some(host) = self.lan.as_mut() else {
return;
};
let limit = u64::try_from(minutes).unwrap_or(0) * 1200;
let idle: Vec<u32> = host
.hub
.guests
.iter()
.filter(|g| host.hub.ticks.saturating_sub(g.last_action_tick) > limit)
.filter(|g| {
let idle_ticks = host.hub.ticks.saturating_sub(g.last_action_tick);
let won_game = host.play.get(&g.conn).is_some_and(|play| play.won_game);
idles_out(idle_ticks, minutes, won_game)
})
.map(|g| g.conn)
.collect();
for conn in idle {
@@ -1692,9 +1755,17 @@ impl Game<'_> {
/// bound to the overworld, whichever level the last server tick left in
/// place (the last one ticked, often the nether). Its commands run there
/// and the level in place is put back afterwards.
/// The source is the server's own: no entity, at the world spawn, its feedback heard
/// (the console's and an RCON client's always is).
fn as_console_in_overworld<T>(&mut self, f: impl FnOnce(&mut Self) -> T) -> T {
let back = console_level(self.dim).and_then(|dim| self.enter_command_level(dim));
let source = super::commands::execute::Source {
silent: false,
..self.server_source()
};
let outer = self.command_source.replace(source);
let out = self.as_console(f);
self.command_source = outer;
if let Some(back) = back {
self.enter_command_level(back);
}
@@ -1912,12 +1983,7 @@ impl Game<'_> {
return;
};
let reason = (rest.len() > 1).then(|| rest[1..].join(" "));
let added = state.lists.lock().unwrap().bans.add(BanEntry::new(
user.clone(),
Some("Server".into()),
None,
reason.clone(),
));
let added = state.lists.lock().unwrap().ban(&user, reason.clone());
if added {
self.console_reply(Text::translate(
"commands.ban.success",
@@ -1957,12 +2023,7 @@ impl Game<'_> {
return;
};
let reason = (rest.len() > 1).then(|| rest[1..].join(" "));
let added = state.lists.lock().unwrap().ip_bans.add(BanEntry::new(
ip.clone(),
Some("Server".into()),
None,
reason.clone(),
));
let added = state.lists.lock().unwrap().ban_ip(&ip, reason.clone());
if !added {
self.console_reply(Text::translate("commands.banip.failed", &[]));
return;
@@ -2580,6 +2641,23 @@ mod tests {
assert!(parse_whitelist_bypass("not-a-uuid-at-all").is_err());
}
/// ServerGamePacketListenerImpl.tick: a player idle past `player-idle-timeout` minutes is kicked,
/// unless the End poem is showing (`ServerPlayer.wonGame`), which sends no action.
#[test]
fn a_player_watching_the_end_credits_is_not_kicked_for_idling() {
use super::idles_out;
// 5 minutes: 6000 ticks. One more is over the limit.
assert!(!idles_out(6000, 5, false));
assert!(idles_out(6001, 5, false));
assert!(!idles_out(6001, 5, true));
assert!(!idles_out(1_000_000, 5, true));
// The credits are over (won_game false again): the same idle time kicks.
assert!(idles_out(1_000_000, 5, false));
// 0 or less: no timeout.
assert!(!idles_out(1_000_000, 0, false));
assert!(!idles_out(1_000_000, -3, false));
}
/// `SaveAllCommand.saveAll`: a save that throws is the `ERROR_FAILED` the
/// source is told about, not a log line the caller never sees.
#[test]
@@ -2604,6 +2682,51 @@ mod tests {
);
}
/// MinecraftServer.tickServer: a tick only counts toward pause-when-empty with no player and
/// no `/tick sprint` running (`ServerTickRateManager.isSprinting`); a sprint on an empty server
/// keeps the count at 0, so it is never paused, and the count starts again after it.
#[test]
fn a_sprint_on_an_empty_server_does_not_count_toward_the_pause() {
let pause_after = 60 * 20;
let mut empty_ticks = 0;
// One server tick: (players, a `/pregen` task, a `/tick sprint`) -> paused.
let tick = |empty_ticks: &mut i32, players, pregen, sprinting| {
super::count_empty_tick(empty_ticks, pause_after, players, pregen, sprinting)
};
for _ in 0..2000 {
assert!(!tick(&mut empty_ticks, 0, false, true));
}
assert_eq!(empty_ticks, 0);
// The sprint ended: 1200 empty ticks later the server pauses, not before.
for n in 1..=pause_after {
assert_eq!(
tick(&mut empty_ticks, 0, false, false),
n == pause_after,
"tick {n}"
);
}
// A sprint that starts while paused resumes the server at once.
assert!(!tick(&mut empty_ticks, 0, false, true));
assert_eq!(empty_ticks, 0);
// A player, or a `/pregen` task, resets the count too.
empty_ticks = 5;
assert!(!tick(&mut empty_ticks, 1, false, false));
assert_eq!(empty_ticks, 0);
empty_ticks = 5;
assert!(!tick(&mut empty_ticks, 0, true, false));
assert_eq!(empty_ticks, 0);
// pause-when-empty-seconds=0 never pauses and leaves the count alone.
empty_ticks = 5;
assert!(!super::count_empty_tick(
&mut empty_ticks,
0,
0,
false,
false
));
assert_eq!(empty_ticks, 5);
}
/// A `panic!("literal")` carries a `&str`, a formatted one a `String`; anything else is
/// named as unknown, so the crash line is never empty.
#[test]
@@ -2713,6 +2836,46 @@ mod tests {
}
}
/// `BanPlayerCommands.banPlayers` skips a player who `isBanned`, and `BanIpCommands.banIp` refuses an
/// address that is: the console's `/ban` and `/ban-ip` of one banned already change nothing (the first
/// reason stays, and the console hears "Nothing changed"), whatever reason they give.
#[test]
fn the_console_does_not_ban_again_who_is_banned_already() {
use super::{BanList, Lists, NameAndId};
let dir = std::env::temp_dir().join(format!("horizon-console-ban-{}", std::process::id()));
fs::create_dir_all(&dir).unwrap();
let mut lists = Lists {
ops: super::OpList::new(dir.join("ops.json")),
white: super::WhiteList::new(dir.join("whitelist.json")),
bans: BanList::new(dir.join("banned-players.json")),
ip_bans: super::IpBanList::new(dir.join("banned-ips.json")),
use_whitelist: false,
enforce_whitelist: false,
op_level: 4,
player_idle_timeout: 0,
stop: false,
autosave: true,
whitelist_bypass: Vec::new(),
};
let alpha = NameAndId {
id: 0x5ca1ab1e,
name: "Alpha".into(),
};
assert!(lists.ban(&alpha, Some("cheating".into())));
assert!(!lists.ban(&alpha, Some("again".into())));
assert!(!lists.ban(&alpha, None));
let reason = lists.bans.get(&alpha).and_then(|b| b.reason.clone());
assert_eq!(reason.as_deref(), Some("cheating"));
assert!(lists.ban_ip("1.2.3.4", Some("spam".into())));
assert!(!lists.ban_ip("1.2.3.4", Some("more spam".into())));
let reason = lists
.ip_bans
.get(&"1.2.3.4".to_owned())
.and_then(|b| b.reason.clone());
assert_eq!(reason.as_deref(), Some("spam"));
let _ = fs::remove_dir_all(&dir);
}
/// A temporary ban (`/tempban`, the chat filter's) keeps the player out and says when it ends;
/// once it has ended, or after `/pardon`, they come in.
#[test]
@@ -2796,3 +2959,85 @@ mod tests {
fs::remove_dir_all(dir).unwrap();
}
}
#[cfg(test)]
mod console_source_tests {
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
use horizon_client::chunk::Chunk;
use horizon_core::position::ChunkPos;
use std::sync::Arc;
/// Runs `command` as the console does and returns whether it worked and every message it printed.
fn console(game: &mut Game<'_>, command: &str) -> (bool, String) {
game.chat_capture = Some(Vec::new());
let ok = game.as_console_in_overworld(|g| g.run_command(command));
let said = game.chat_capture.take().unwrap_or_default();
let said = said
.iter()
.map(|t| t.component().to_string())
.collect::<Vec<_>>()
.join("\n");
(ok, said)
}
/// `MinecraftServer.createCommandSourceStack`: the console's source has no entity and is named "Server"
/// (an RCON client's, `RconConsoleSource`, "Rcon"): `/me` says so, and `teammsg`, which needs an entity,
/// refuses it (`permissions.requires.entity`) instead of running as the player in place.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and scratch homes/cwd"]
fn the_console_runs_as_the_server_not_the_player_in_place() {
let root = std::path::PathBuf::from(
std::env::var_os("HORIZON_TEST_RESOURCES").expect("prepared resources required"),
);
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.build_registries();
game.set_allow_commands(true);
game.world
.chunks
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
game.block_ticks
.add_empty_chunk(ChunkPos::new(0, 0))
.unwrap();
let (ok, said) = console(&mut game, "me waves");
assert!(
ok && said.contains("chat.type.emote") && said.contains("\"Server\""),
"{said}"
);
game.rcon_source = true;
let (ok, said) = console(&mut game, "me waves");
assert!(ok && said.contains("\"Rcon\""), "{said}");
game.rcon_source = false;
let (ok, said) = console(&mut game, "teammsg hi");
assert!(
!ok && said.contains("permissions.requires.entity"),
"{said}"
);
// The console's source is put back after each line.
assert!(game.command_source.is_none());
}
}
@@ -1,13 +1,16 @@
//! A partial check of the world's predicate graph before a dedicated server is Ready.
//!
//! Vanilla refuses to start a world whose packs' predicates reference a predicate no pack defines
//! (`MappedRegistry.freeze`: "Unbound values in registry"), or reference each other in a cycle
//! (`MappedRegistry.freeze`: "Unbound values in registry"), name a predicate tag no pack defines
//! ("Unbound tags in registry"), or reference each other in a cycle
//! (`ReloadableServerRegistries.validateLootRegistries`: a reference "is recursively called").
//!
//! This is new, partial validation, not a port of `RegistryDataLoader` or `ValidationContext`. It
//! follows a predicate's holders only where the condition codecs hold one: `all_of` and `any_of`
//! `terms`, and `inverted` `term`. A holder is an identifier (a reference) or an inline condition,
//! which is followed in the same way. Every other condition type and field, an identifier that does
//! which is followed in the same way. `terms` is a `HolderSetCodec`: a list of holders, one holder
//! alone (`compactListCodec`, at `.terms[0]`), or a `#tag`. A tag must be defined; the entries of a
//! defined tag are not followed. Every other condition type and field, an identifier that does
//! not parse, and the holders of loot tables, advancements and item modifiers are not checked.
use horizon_client_core::identifier::parse_identifier;
use serde_json::Value;
@@ -20,6 +23,8 @@ const REGISTRY: &str = "minecraft:predicate";
pub(super) enum GraphError {
/// Referenced ids that no predicate binds.
Unbound(BTreeSet<String>),
/// Predicate tags that a `terms` names and no pack defines.
UnboundTags(BTreeSet<String>),
/// References that re-enter a predicate while it is being validated.
Recursive(Vec<Recursion>),
}
@@ -39,6 +44,10 @@ impl GraphError {
"Unbound values in registry ResourceKey[minecraft:root / {REGISTRY}]: [{}]",
ids.iter().cloned().collect::<Vec<_>>().join(", ")
)],
GraphError::UnboundTags(ids) => vec![format!(
"Unbound tags in registry ResourceKey[minecraft:root / {REGISTRY}]: [{}]",
ids.iter().cloned().collect::<Vec<_>>().join(", ")
)],
GraphError::Recursive(found) => found
.iter()
.map(|r| {
@@ -62,14 +71,16 @@ fn holders(condition: &Value) -> Vec<(String, &Value)> {
return Vec::new();
};
match ty.as_str() {
"minecraft:all_of" | "minecraft:any_of" => condition
.get("terms")
.and_then(Value::as_array)
.into_iter()
.flatten()
.enumerate()
.map(|(i, term)| (format!(".terms[{i}]"), term))
.collect(),
"minecraft:all_of" | "minecraft:any_of" => match condition.get("terms") {
Some(Value::Array(terms)) => terms
.iter()
.enumerate()
.map(|(i, term)| (format!(".terms[{i}]"), term))
.collect(),
Some(Value::String(tag)) if tag.starts_with('#') => Vec::new(),
Some(term) => vec![(".terms[0]".to_owned(), term)],
None => Vec::new(),
},
"minecraft:inverted" => condition
.get("term")
.map(|term| vec![(".term".to_owned(), term)])
@@ -78,12 +89,22 @@ fn holders(condition: &Value) -> Vec<(String, &Value)> {
}
}
/// Every reference a condition holds, inline conditions included.
fn references(condition: &Value, out: &mut Vec<String>) {
/// The predicate tag an `all_of` or `any_of` names as its `terms` (`#ns:path`).
fn tag(condition: &Value) -> Option<String> {
let ty = parse_identifier(condition.get("type")?.as_str()?)?;
if !matches!(ty.as_str(), "minecraft:all_of" | "minecraft:any_of") {
return None;
}
parse_identifier(condition.get("terms")?.as_str()?.strip_prefix('#')?)
}
/// Every reference and tag a condition holds, inline conditions included.
fn references(condition: &Value, out: &mut Vec<String>, tags: &mut Vec<String>) {
tags.extend(tag(condition));
for (_, holder) in holders(condition) {
match holder {
Value::String(name) => out.extend(parse_identifier(name)),
Value::Object(_) => references(holder, out),
Value::Object(_) => references(holder, out, tags),
_ => {}
}
}
@@ -125,12 +146,17 @@ fn walk<'v>(
}
}
/// The world's predicates (by normalized id, after pack precedence): missing references first, as
/// the registry freezes before anything is validated, then recursive ones.
pub(super) fn check(predicates: &BTreeMap<String, Value>) -> Result<(), GraphError> {
/// The world's predicates (by normalized id, after pack precedence) and whether a pack defines a
/// predicate tag: missing references first, then missing tags, as the registry freezes before
/// anything is validated, then recursive references.
pub(super) fn check(
predicates: &BTreeMap<String, Value>,
has_tag: impl Fn(&str) -> bool,
) -> Result<(), GraphError> {
let mut referenced = Vec::new();
let mut tags = Vec::new();
for condition in predicates.values() {
references(condition, &mut referenced);
references(condition, &mut referenced, &mut tags);
}
let unbound: BTreeSet<String> = referenced
.into_iter()
@@ -139,6 +165,10 @@ pub(super) fn check(predicates: &BTreeMap<String, Value>) -> Result<(), GraphErr
if !unbound.is_empty() {
return Err(GraphError::Unbound(unbound));
}
let unbound: BTreeSet<String> = tags.into_iter().filter(|id| !has_tag(id)).collect();
if !unbound.is_empty() {
return Err(GraphError::UnboundTags(unbound));
}
let mut found = Vec::new();
for (id, condition) in predicates {
let mut stack = vec![id.as_str()];
@@ -193,7 +223,7 @@ mod tests {
// An identifier that does not parse is not checked here.
("a:v", json!({"type": "inverted", "term": "Not An Id"})),
]);
assert_eq!(check(&predicates), Ok(()));
assert_eq!(check(&predicates, |_| false), Ok(()));
}
#[test]
@@ -205,7 +235,7 @@ mod tests {
),
("a:c", json!({"type": "inverted", "term": "a:c"})),
]);
let error = check(&predicates).unwrap_err();
let error = check(&predicates, |_| false).unwrap_err();
assert_eq!(
error,
GraphError::Unbound(["a:gone".to_owned(), "b:none".to_owned()].into())
@@ -234,7 +264,7 @@ mod tests {
json!({"type": "any_of", "terms": [{"type": "inverted", "term": "prg:self"}]}),
),
]);
let mut lines = check(&predicates).unwrap_err().lines();
let mut lines = check(&predicates, |_| false).unwrap_err().lines();
lines.sort();
// The two cycle lines are the ones vanilla 26.3 logged for this pack.
assert_eq!(
@@ -246,4 +276,74 @@ mod tests {
]
);
}
#[test]
fn a_lone_terms_holder_is_terms_0_and_a_tag_must_be_defined() {
let target = json!({"type": "minecraft:all_of", "terms": []});
// Vanilla 26.3 starts a world whose `terms` is one bound holder alone.
let valid = graph(&[
("prg:target", target.clone()),
("prg:ref", json!({"type": "all_of", "terms": "prg:target"})),
(
"prg:inline",
json!({"type": "any_of", "terms": {"type": "inverted", "term": "prg:target"}}),
),
(
"prg:tagged",
json!({"type": "any_of", "terms": "#prg:some"}),
),
]);
assert_eq!(check(&valid, |tag| tag == "prg:some"), Ok(()));
let missing = graph(&[
("prg:target", target.clone()),
(
"prg:ref",
json!({"type": "minecraft:all_of", "terms": "prg:missing"}),
),
(
"prg:tagged",
json!({"type": "all_of", "terms": "#prg:none"}),
),
]);
// Missing values first, then missing tags: the lines vanilla 26.3 logged for each.
assert_eq!(
check(&missing, |_| false).unwrap_err().lines(),
[
"Unbound values in registry ResourceKey[minecraft:root / minecraft:predicate]: [prg:missing]"
]
);
let tags = graph(&[
("prg:target", target.clone()),
(
"prg:ref",
json!({"type": "minecraft:all_of", "terms": "#prg:none"}),
),
]);
assert_eq!(
check(&tags, |_| false).unwrap_err().lines(),
[
"Unbound tags in registry ResourceKey[minecraft:root / minecraft:predicate]: [prg:none]"
]
);
let cycle = graph(&[
("prg:target", target),
(
"prg:ref",
json!({"type": "minecraft:any_of", "terms": "prg:loop"}),
),
(
"prg:loop",
json!({"type": "minecraft:inverted", "term": "prg:ref"}),
),
]);
let mut lines = check(&cycle, |_| false).unwrap_err().lines();
lines.sort();
assert_eq!(
lines,
[
"Found loot table element validation problem in {prg:loop@minecraft:predicate}.term->{prg:ref@minecraft:predicate}.terms[0]: prg:loop of type minecraft:predicate is recursively called",
"Found loot table element validation problem in {prg:ref@minecraft:predicate}.terms[0]->{prg:loop@minecraft:predicate}.term: prg:ref of type minecraft:predicate is recursively called",
]
);
}
}
@@ -982,6 +982,7 @@ impl Game<'_> {
items: [stack],
ticks: 0,
playing: false,
loaded_clock: false,
}),
);
}
@@ -1442,6 +1443,9 @@ impl Game<'_> {
ProjectileKind::Egg => "minecraft:egg",
ProjectileKind::SmallFireball => "minecraft:small_fireball",
ProjectileKind::WindCharge => "minecraft:wind_charge",
ProjectileKind::Potion(_, true) => "minecraft:lingering_potion",
ProjectileKind::Potion(..) => "minecraft:splash_potion",
ProjectileKind::ExperienceBottle => "minecraft:experience_bottle",
_ => "minecraft:unknown_projectile",
};
let (p, v) = (shot.position, shot.velocity);
+14 -16
View File
@@ -12,7 +12,6 @@ use horizon_client::{
mobs::{Mob, Species, sulfur_cube},
particles::{ParticleOptions, ParticleType},
projectiles::{Projectile, ProjectileKind},
random_source,
};
// Its old home, for mixin code that names it here.
pub(super) use horizon_client::mod_api::NativeDispensed;
@@ -1198,9 +1197,9 @@ impl Game<'_> {
"minecraft:experience_bottle"
| "minecraft:splash_potion"
| "minecraft:lingering_potion" => {
let mut shot = Projectile::new(ProjectileKind::Snowball, at, None);
shot.shoot(&mut random_source::entity(), dir, power, uncertainty);
self.launch_thrown(one, at, shot.velocity, None);
let mut shot = super::potions::thrown_projectile(one, at, false);
shot.shoot_own(dir, power, uncertainty);
self.add_projectile(shot);
}
_ => {
let kind = match item.as_str() {
@@ -1267,14 +1266,18 @@ impl Game<'_> {
self.next_entity += 1;
let (_, uuid) = horizon_client::mobs::living_random();
self.persist.uuids.insert(self.next_entity, uuid);
let mut stand =
let stand =
horizon_client::entities::ArmorStand::new(self.next_entity, foot, y_rot(facing));
// Entity.applyImplicitComponents: a name from an anvil names the stand.
if let Some(name) = stack.custom_name() {
stand.custom_name = Some(horizon_client::text::Text::literal(name));
}
self.world.modified.insert((front.0 >> 4, front.2 >> 4));
self.armor_stands.push(stand);
// EntityType.appendDefaultStackConfig: the dispenser's yaw first, then
// the item's createDefaultStackConfig with no user.
self.apply_item_stack_config(
super::commands::Target::Stand(self.next_entity),
"minecraft:armor_stand",
&stack,
false,
);
(shrink(stack), CLICK)
}
/// A tame donkey, mule or llama in front without a chest gets it.
@@ -1343,7 +1346,7 @@ impl Game<'_> {
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.apply_item_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();
@@ -1393,12 +1396,7 @@ impl Game<'_> {
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,
);
self.apply_item_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(
+14 -35
View File
@@ -191,22 +191,12 @@ pub(super) struct Crystal {
pub beam: Option<[i32; 3]>,
/// `setPermanentlyInvulnerable` (respawned spike crystals).
pub invulnerable: bool,
/// `onGround` and `fallDistance`: a crystal never moves itself, so they stay
/// as loaded until `/tp` grounds it or a cobweb or powder snow resets the
/// fall (`crystal.rs`).
pub fall: (bool, f64),
}
impl Crystal {
/// The start of `time` (the spin and bob) for a crystal at `position`: the random draw
/// vanilla makes, or under `HORIZON_STILL` one seeded from the position alone. Which crystal
/// gets which draw, and a server's entity ids, follow the order chunks load in, which differs
/// between launches; the position is the same in every copy of the world. The draw is made
/// either way, so the random stays where it would be.
pub(super) fn start_time(position: Vec3, drawn: i32, still: bool) -> i32 {
if still {
horizon_client::mobs::entity_random(0, position)
.next_int_bound(100_000)
.unwrap_or(0)
} else {
drawn
}
}
/// `EntityType.END_CRYSTAL`: 2 x 2.
pub(super) fn bounds(&self) -> Aabb {
let p = self.position;
@@ -1291,14 +1281,8 @@ impl Game<'_> {
let time = Crystal::start_time(at, drawn, self.clock.still());
// EndCrystal's constructor takes an id from the entity counter.
self.next_entity += 1;
self.crystals.push(Crystal {
id: self.next_entity,
position: at,
show_bottom: true,
time,
beam: None,
invulnerable: false,
});
self.crystals
.push(Crystal::new(self.next_entity, at, true, time));
self.world
.modified
.insert((at.x.floor() as i32 >> 4, at.z.floor() as i32 >> 4));
@@ -1328,6 +1312,7 @@ impl Game<'_> {
.flatten();
for i in 0..self.crystals.len() {
self.crystals[i].time += 1;
self.crystal_block_effects(i);
let p = self.crystals[i].position;
let b = [p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32];
if let Some(fire) = fire
@@ -3666,7 +3651,7 @@ impl Crystal {
self.position,
Vec3::ZERO,
[0.0, 0.0],
(false, 0.0),
self.fall,
uuid,
raw,
);
@@ -3694,6 +3679,7 @@ impl Crystal {
_ => None,
},
invulnerable: horizon_client::entity_nbt::flag(tag, "Invulnerable"),
fall: horizon_client::entity_nbt::ground_from_nbt(tag),
})
}
}
@@ -3916,14 +3902,8 @@ impl Game<'_> {
// EndCrystal's constructor takes an id from the entity counter, so
// the crystal is an entity a command can name (`@e[type=end_crystal]`).
self.next_entity += 1;
self.crystals.push(Crystal {
id: self.next_entity,
position: at,
show_bottom: false,
time,
beam: None,
invulnerable: false,
});
self.crystals
.push(Crystal::new(self.next_entity, at, false, time));
// EndCrystalItem.useOn: ENTITY_PLACE at the block above, by the player.
self.post_block_game_event("minecraft:entity_place", above, self.sculk_actor(), None);
self.world.modified.insert((above[0] >> 4, above[2] >> 4));
@@ -4219,12 +4199,9 @@ impl Game<'_> {
let drawn = self.world.random.next_int_bound(100_000).unwrap_or(0);
let time = Crystal::start_time(at, drawn, self.clock.still());
self.crystals.push(Crystal {
id: 0,
position: at,
show_bottom: true,
time,
beam: Some([0, 128, 0]),
invulnerable: true,
..Crystal::new(0, at, true, time)
});
let _ = self.set_state_named([cx, h, cz], "minecraft:bedrock", &[]);
let _ = self.set_state_named([cx, h + 1, cz], "minecraft:fire", &[]);
@@ -4769,6 +4746,7 @@ impl Game<'_> {
}
}
mod crystal;
mod death_render;
use death_render::{dissolve_color, dissolve_mask, dragon_rays};
@@ -5733,6 +5711,7 @@ mod dragon_fireball_spawn_tests {
.insert((x, z), Arc::new(Chunk::empty(x, z)));
}
}
game.load_ticking_neighbourhood(0, 0);
assert!(game.run_command("forceload add 0 0"));
game
}
@@ -0,0 +1,60 @@
//! An end crystal's construction and the blocks its tick reads.
use super::*;
impl Crystal {
/// `new EndCrystal(level, x, y, z)`: no beam, not invulnerable, on no
/// ground with no fall (what `Entity`'s constructor leaves).
pub(in crate::game) fn new(id: u32, position: Vec3, show_bottom: bool, time: i32) -> Self {
Self {
id,
position,
show_bottom,
time,
beam: None,
invulnerable: false,
fall: (false, 0.0),
}
}
/// The start of `time` (the spin and bob) for a crystal at `position`: the random draw
/// vanilla makes, or under `HORIZON_STILL` one seeded from the position alone. Which crystal
/// gets which draw, and a server's entity ids, follow the order chunks load in, which differs
/// between launches; the position is the same in every copy of the world. The draw is made
/// either way, so the random stays where it would be.
pub(in crate::game) fn start_time(position: Vec3, drawn: i32, still: bool) -> i32 {
if still {
horizon_client::mobs::entity_random(0, position)
.next_int_bound(100_000)
.unwrap_or(0)
} else {
drawn
}
}
}
impl Game<'_> {
/// `EndCrystal.tick`'s `applyEffectsFromBlocks` for crystal `i`, which never
/// moves: a cobweb or powder snow in its box (2 x 2 x 2, deflated by
/// `1.0E-5F`) calls `makeStuckInBlock`, which resets its fall distance.
/// Powder snow's inside shape for a fall over 2.5 is only 0.9 tall
/// (`FALLING_COLLISION_SHAPE`). A bubble column's reset and `stepOn` for a
/// grounded crystal are not ported.
pub(super) fn crystal_block_effects(&mut self, i: usize) {
let (p, fall) = (self.crystals[i].position, self.crystals[i].fall.1);
let e = f64::from(1.0e-5_f32);
let lo = [p.x - 1.0 + e, p.y + e, p.z - 1.0 + e].map(|v| v.floor() as i32);
let hi = [p.x + 1.0 - e, p.y + 2.0 - e, p.z + 1.0 - e].map(|v| v.floor() as i32);
let facts = self.res.facts();
let stuck = (lo[0]..=hi[0]).any(|x| {
(lo[1]..=hi[1]).any(|y| {
(lo[2]..=hi[2]).any(|z| match &*facts.get(self.world.block(x, y, z)).name {
"minecraft:cobweb" => true,
"minecraft:powder_snow" => fall <= 2.5 || p.y + e < f64::from(y) + 0.9,
_ => false,
})
})
});
if stuck {
self.crystals[i].fall.1 = 0.0;
}
}
}
@@ -47,6 +47,7 @@ fn game() -> Game<'static> {
game.block_ticks
.add_empty_chunk(ChunkPos::new(0, 0))
.unwrap();
game.load_ticking_neighbourhood(0, 0);
assert!(game.run_command("fill 0 99 0 15 99 15 stone"));
game
}
@@ -68,6 +68,7 @@ fn items_past_the_simulation_distance_enter_no_end_gateway() {
game.world
.chunks
.insert((cx, 0), Arc::new(Chunk::empty(cx, 0)));
game.load_ticking_neighbourhood(cx, 0);
game.world.set(x, 64, 8, gateway).unwrap();
game.dropped
.push(item(id, Vec3::new(f64::from(x) + 0.5, 64.2, 8.5)));
@@ -36,6 +36,7 @@ fn a_mob_presses_a_plate_only_inside_the_simulation_distance() {
game.world
.chunks
.insert((cx, 0), Arc::new(Chunk::empty(cx, 0)));
game.load_ticking_neighbourhood(cx, 0);
game.world.set(x, 63, 8, stone).unwrap();
game.world.set(x, 64, 8, plate).unwrap();
game.mobs.push(Mob::new(
+73 -1
View File
@@ -862,13 +862,36 @@ impl Game<'_> {
let (mut items_kept, mut orbs_kept) = (0, 0);
let (mut boats_gone, mut carts_gone) = (0, 0);
let mut destroyed_contents = Vec::new();
// The entities in reach, less those a mod spares (`native_explosion_entities`); no list
// without native mods, which are the only ones to spare any.
let spared: std::collections::HashSet<u32> = if horizon_client::mod_api::mods().is_empty() {
Default::default()
} else {
let reach: Vec<u32> = self
.entity_handles()
.filter_map(|handle| {
with_entity!(self, handle, e => {
let feet = e.position();
let dist = blast_distance(center, [feet.x, feet.y, feet.z]);
(dist <= diameter).then(|| e.id())
})
})
.collect();
let mut kept = reach.clone();
self.native_explosion_entities(self.dim.id(), center, &mut kept);
let kept: std::collections::HashSet<u32> = kept.into_iter().collect();
reach.into_iter().filter(|id| !kept.contains(id)).collect()
};
for handle in self.entity_handles() {
let handle = match handle {
EntityHandle::Boat(i) => EntityHandle::Boat(i - boats_gone),
EntityHandle::Cart(i) => EntityHandle::Cart(i - carts_gone),
other => other,
};
let hit = with_entity!(self, handle, e => self.blast_hit(e, center, diameter, &blast));
let spare =
!spared.is_empty() && spared.contains(&with_entity!(self, handle, e => e.id()));
let hit = with_entity!(self, handle, e => self.blast_hit(e, center, diameter, &blast))
.filter(|_| !spare);
let mut destroyed = false;
if let Some(Hit {
dist,
@@ -1828,6 +1851,55 @@ mod tests {
})
}
/// Spares one entity from every blast (`native_explosion_entities`).
struct Spare(u32);
impl crate::mod_api::Hooks for Spare {
fn explosion_entities(
&self,
_: &mut dyn crate::mod_api::Host,
event: &mut crate::mod_api::ExplosionEntitiesEvent<'_>,
) {
event.entities.retain(|&id| id != self.0);
}
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_mod_spares_an_entity_from_a_blast() {
crate::mod_api::in_own_process(
"game::explosion::tests::a_mod_spares_an_entity_from_a_blast",
a_mod_spares_an_entity_from_a_blast_in_own_process,
);
}
/// Two husks either side of a blast: the one a mod takes out of the list is neither hurt nor
/// pushed, the other is both.
fn a_mod_spares_an_entity_from_a_blast_in_own_process() {
let mut game = crate::game::native_machines::tests::server_game();
game.build_registries();
let spare: Box<dyn crate::mod_api::Hooks> = Box::new(Spare(2));
assert!(crate::mod_api::MODS.set(vec![spare].into()).is_ok());
let kind = game.res.mob_types().get("minecraft:husk").unwrap().clone();
for (id, x) in [(1, 6.5), (2, 10.5)] {
let husk =
horizon_client::mobs::Mob::new(id, kind.clone(), Vec3::new(x, 64.0, 8.5), 0.0);
game.mobs.push(husk);
}
let full = game.mobs[0].health;
game.server_explode_from_cause(
[8.5, 64.0, 8.5],
2.0,
BlockInteraction::Keep,
false,
ExplosionSource::default().with_silence(true),
None,
)
.unwrap();
let (hit, spared) = (&game.mobs[0], &game.mobs[1]);
assert!(hit.health < full && hit.velocity != Vec3::ZERO);
assert_eq!((spared.health, spared.velocity), (full, Vec3::ZERO));
}
#[test]
fn a_mob_that_refuses_the_hit_is_still_pushed() {
use horizon_client::mobs::{Damage, DamageKind, Mob, Species};
+4 -1
View File
@@ -4,9 +4,12 @@
//! Each hook does nothing while its extra is off and no mod uses it.
//!
//! Each Horizon extra's game glue is a child module here, so it leaves the engine in one move
//! (docs/layers.md, plan M5): `reconnect` (auto-reconnect), `pvp` (the PvP & HUD options).
//! (docs/layers.md, plan M5): `reconnect` (auto-reconnect), `pvp` (the PvP & HUD options),
//! `mouse_tweaks` and `sorting` (the inventory extras).
pub(in crate::game) mod mouse_tweaks;
pub(in crate::game) mod pvp;
mod reconnect;
mod sorting;
use super::{Dim, Game, Screen, native_hooks::NativeMinimap};
use horizon_client::{
@@ -17,26 +17,12 @@
//! other side. Down over a result slot takes one craft or the whole furnace
//! output to the inventory; nothing ever goes into a result slot.
//!
//! Sorting draws a small button by a menu's storage slots and one by the
//! inventory's (an optional key sorts the part under the cursor); the
//! clicks come from `inv_sort::plan`.
use super::{Creative, Game, Screen};
use horizon_client::{
containers::BlockEntity,
hud::{HudBatch, rgba},
inv_sort,
items::ItemStack,
keybinds,
menu::Input,
stations::hover_name,
};
use winit::{event::MouseButton, keyboard::KeyCode};
/// A sort button's side in GUI pixels.
const SORT_BUTTON: f32 = 9.0;
//! Sorting, which clicks through `tweak_click` too, is in `sorting.rs`.
use crate::game::{Creative, Game, Screen};
use horizon_client::{containers::BlockEntity, items::ItemStack, menu::Input};
#[derive(Default)]
pub(super) struct Tweaks {
pub(in crate::game) struct Tweaks {
drag: Option<Drag>,
/// Wheel steps not yet acted on (a trackpad scrolls in fractions).
wheel: f32,
@@ -44,7 +30,7 @@ pub(super) struct Tweaks {
/// A server's menu the tweaks run in: their clicks are vanilla's in these,
/// and the menu engine knows which slot takes what. Not the menus whose slots
/// cost or trigger something (anvils, enchanting, merchants, beacons, crafters).
pub(super) fn tweak_menu(name: &str) -> bool {
pub(in crate::game) fn tweak_menu(name: &str) -> bool {
name == "inventory"
|| name.starts_with("minecraft:generic_9x")
|| matches!(
@@ -72,7 +58,7 @@ struct Drag {
impl Game<'_> {
/// The open screen is one the tweaks run in, with the extra on.
pub(super) fn tweaks_on(&self) -> bool {
pub(in crate::game) fn tweaks_on(&self) -> bool {
if !self.settings.extras.mouse_tweaks
|| self.screen != Screen::Inventory
|| self.station.is_some()
@@ -87,7 +73,7 @@ impl Game<'_> {
}
}
/// One ordinary click on a game slot. False when nothing took it.
pub(super) fn tweak_click(&mut self, slot: usize, button: i32, input: Input) -> bool {
pub(in crate::game) fn tweak_click(&mut self, slot: usize, button: i32, input: Input) -> bool {
if self.remote.is_some() {
return self.remote_menu_ops(&[(Some(slot), button, input)]);
}
@@ -123,7 +109,12 @@ impl Game<'_> {
}
/// A press on `slot`: true when the tweaks took it (a right-drag
/// placing), else vanilla's press runs and the tweaks watch the drag.
pub(super) fn tweak_press(&mut self, slot: Option<usize>, right: bool, shift: bool) -> bool {
pub(in crate::game) fn tweak_press(
&mut self,
slot: Option<usize>,
right: bool,
shift: bool,
) -> bool {
self.tweaks.drag = None;
let Some(slot) = slot.filter(|&s| s != 200 && s < Creative::DISPLAY) else {
return false;
@@ -174,7 +165,7 @@ impl Game<'_> {
}
/// The cursor moved while a button is held: true when the tweaks own
/// the drag (vanilla's quick craft drag doesn't grow).
pub(super) fn tweak_drag_move(&mut self, slot: Option<usize>) -> bool {
pub(in crate::game) fn tweak_drag_move(&mut self, slot: Option<usize>) -> bool {
let Some(mut drag) = self.tweaks.drag.take() else {
return false;
};
@@ -222,11 +213,11 @@ impl Game<'_> {
owned
}
/// The button went up: the drag ends.
pub(super) fn tweak_release(&mut self) {
pub(in crate::game) fn tweak_release(&mut self) {
self.tweaks.drag = None;
}
/// The wheel over an inventory screen: true when the tweaks used it.
pub(super) fn tweak_scroll(&mut self, delta: f32, slot: Option<usize>) -> bool {
pub(in crate::game) fn tweak_scroll(&mut self, delta: f32, slot: Option<usize>) -> bool {
if !self.settings.extras.mouse_tweaks_wheel || !self.tweaks_on() || self.carried.is_some() {
return false;
}
@@ -356,144 +347,6 @@ impl Game<'_> {
}
}
impl Game<'_> {
/// The sort buttons of the open screen, with the extra on: their
/// corner (from the screen's) and the slots each sorts. Any menu screen
/// gets them, by its slots' roles (`native_slot_roles`): one by the top
/// right of its storage slots (a chest's title line) when it has some,
/// one by the main inventory's (the "Inventory" label's line).
fn sort_buttons(&self) -> Vec<((f32, f32), Vec<usize>)> {
use super::NativeSlotRole as Role;
if !self.settings.extras.sort {
return Vec::new();
}
let roles = self.native_slot_roles();
let of = |role| -> Vec<usize> {
roles
.iter()
.filter(|(_, r)| *r == role)
.map(|(s, _)| *s)
.collect()
};
// Over the section's last column: (160, 5) over a chest's slots.
let right = |slots: &[usize]| {
slots
.iter()
.map(|&s| self.slot_position(s).0 + 8.0)
.fold(f32::MIN, f32::max)
};
let mut buttons = Vec::new();
let storage = of(Role::Storage);
if !storage.is_empty() {
let top = storage
.iter()
.map(|&s| self.slot_position(s).1 - 13.0)
.fold(f32::MAX, f32::min);
buttons.push(((right(&storage), top), storage));
}
// On the "Inventory" label's line (`inventoryLabelY`, height - 94, 12
// above the first row): a mod's screen places its inventory itself.
let main = of(Role::Inventory);
if !main.is_empty() {
let y = self
.native_screen_inventory_y()
.map_or(self.screen_height() - 95.0, |y| y - 13.0);
buttons.push(((right(&main), y), main));
}
buttons
}
/// The sort buttons: a bordered square with three bars, lighter under
/// the cursor.
pub(super) fn draw_sort_buttons(&self, batch: &mut HudBatch, ox: f32, oy: f32) {
let atlas = &self.gui.atlas;
for ((x, y), _) in self.sort_buttons() {
let (x, y) = (ox + x, oy + y);
let (cx, cy) = self.cursor;
let hovered = cx >= x && cx < x + SORT_BUTTON && cy >= y && cy < y + SORT_BUTTON;
let face = if hovered {
rgba(220, 220, 220, 255)
} else {
rgba(160, 160, 160, 255)
};
batch.fill(atlas, x, y, SORT_BUTTON, SORT_BUTTON, rgba(55, 55, 55, 255));
batch.fill(
atlas,
x + 1.0,
y + 1.0,
SORT_BUTTON - 2.0,
SORT_BUTTON - 2.0,
face,
);
for (i, w) in [5.0, 4.0, 3.0].into_iter().enumerate() {
let by = y + 2.0 + 2.0 * i as f32;
batch.fill(atlas, x + 2.0, by, w, 1.0, rgba(55, 55, 55, 255));
}
}
}
/// A left press on a sort button sorts its slots (true).
pub(super) fn sort_press(&mut self) -> bool {
let Some(r) = self.renderer.as_ref() else {
return false;
};
let batch = HudBatch::new([r.config.width, r.config.height]);
let (ox, oy) = self.inventory_origin(&batch);
let (cx, cy) = self.cursor;
let hit = self.sort_buttons().into_iter().find(|((x, y), _)| {
let (x, y) = (ox + x, oy + y);
cx >= x && cx < x + SORT_BUTTON && cy >= y && cy < y + SORT_BUTTON
});
let Some((_, slots)) = hit else {
return false;
};
self.sort_slots(&slots);
true
}
/// The sort key: sorts the part under the cursor (the container when
/// the cursor is over no slot).
pub(super) fn sort_key_press(&mut self, key: KeyCode) -> bool {
keybinds::key_name(key).is_some_and(|name| self.sort_input(name))
}
pub(super) fn sort_mouse_press(&mut self, button: MouseButton) -> bool {
self.sort_input(&keybinds::mouse_name(button))
}
fn sort_input(&mut self, name: &str) -> bool {
let e = &self.settings.extras;
if !e.sort || e.sort_key != name || self.recipe_search_focused() {
return false;
}
let buttons = self.sort_buttons();
let hovered = self.inventory_target().0;
let slots = buttons
.iter()
.find(|(_, slots)| hovered.is_some_and(|h| slots.contains(&h)))
.or(buttons.first())
.map(|(_, slots)| slots.clone());
let Some(slots) = slots else {
return false;
};
self.sort_slots(&slots);
true
}
/// Sorts `slots` with ordinary left clicks (needs an empty hand).
fn sort_slots(&mut self, slots: &[usize]) {
if self.carried.is_some() {
return;
}
let views: Vec<Option<ItemStack>> = slots.iter().map(|&s| self.slot_view(s)).collect();
let clicks = {
let rank = |s: &ItemStack| {
let order = self.creative_screen.order_of(s).unwrap_or(usize::MAX);
(order, hover_name(&self.pack.names, s))
};
let max = |s: &ItemStack| self.max_stack(s);
inv_sort::plan(&views, &rank, &max)
};
for i in clicks {
self.tweak_click(slots[i], 0, Input::Pickup);
}
}
}
#[cfg(test)]
mod tests {
use super::tweak_menu;
@@ -0,0 +1,155 @@
//! Sorting (`sort`, `sortKey`; #332, `docs/extras.md`), off by default: a small button by a
//! menu's storage slots and one by the inventory's (an optional key sorts the part under the
//! cursor). The clicks come from `inv_sort::plan` and go through the Mouse Tweaks' ordinary slot
//! clicks (`mouse_tweaks.rs`), so they work wherever vanilla's do.
use crate::game::Game;
use horizon_client::{
hud::{HudBatch, rgba},
inv_sort,
items::ItemStack,
keybinds,
menu::Input,
stations::hover_name,
};
use winit::{event::MouseButton, keyboard::KeyCode};
/// A sort button's side in GUI pixels.
const SORT_BUTTON: f32 = 9.0;
impl Game<'_> {
/// The sort buttons of the open screen, with the extra on: their
/// corner (from the screen's) and the slots each sorts. Any menu screen
/// gets them, by its slots' roles (`native_slot_roles`): one by the top
/// right of its storage slots (a chest's title line) when it has some,
/// one by the main inventory's (the "Inventory" label's line).
fn sort_buttons(&self) -> Vec<((f32, f32), Vec<usize>)> {
use crate::game::NativeSlotRole as Role;
if !self.settings.extras.sort {
return Vec::new();
}
let roles = self.native_slot_roles();
let of = |role| -> Vec<usize> {
roles
.iter()
.filter(|(_, r)| *r == role)
.map(|(s, _)| *s)
.collect()
};
// Over the section's last column: (160, 5) over a chest's slots.
let right = |slots: &[usize]| {
slots
.iter()
.map(|&s| self.slot_position(s).0 + 8.0)
.fold(f32::MIN, f32::max)
};
let mut buttons = Vec::new();
let storage = of(Role::Storage);
if !storage.is_empty() {
let top = storage
.iter()
.map(|&s| self.slot_position(s).1 - 13.0)
.fold(f32::MAX, f32::min);
buttons.push(((right(&storage), top), storage));
}
// On the "Inventory" label's line (`inventoryLabelY`, height - 94, 12
// above the first row): a mod's screen places its inventory itself.
let main = of(Role::Inventory);
if !main.is_empty() {
let y = self
.native_screen_inventory_y()
.map_or(self.screen_height() - 95.0, |y| y - 13.0);
buttons.push(((right(&main), y), main));
}
buttons
}
/// The sort buttons: a bordered square with three bars, lighter under
/// the cursor.
pub(in crate::game) fn draw_sort_buttons(&self, batch: &mut HudBatch, ox: f32, oy: f32) {
let atlas = &self.gui.atlas;
for ((x, y), _) in self.sort_buttons() {
let (x, y) = (ox + x, oy + y);
let (cx, cy) = self.cursor;
let hovered = cx >= x && cx < x + SORT_BUTTON && cy >= y && cy < y + SORT_BUTTON;
let face = if hovered {
rgba(220, 220, 220, 255)
} else {
rgba(160, 160, 160, 255)
};
batch.fill(atlas, x, y, SORT_BUTTON, SORT_BUTTON, rgba(55, 55, 55, 255));
batch.fill(
atlas,
x + 1.0,
y + 1.0,
SORT_BUTTON - 2.0,
SORT_BUTTON - 2.0,
face,
);
for (i, w) in [5.0, 4.0, 3.0].into_iter().enumerate() {
let by = y + 2.0 + 2.0 * i as f32;
batch.fill(atlas, x + 2.0, by, w, 1.0, rgba(55, 55, 55, 255));
}
}
}
/// A left press on a sort button sorts its slots (true).
pub(in crate::game) fn sort_press(&mut self) -> bool {
let Some(r) = self.renderer.as_ref() else {
return false;
};
let batch = HudBatch::new([r.config.width, r.config.height]);
let (ox, oy) = self.inventory_origin(&batch);
let (cx, cy) = self.cursor;
let hit = self.sort_buttons().into_iter().find(|((x, y), _)| {
let (x, y) = (ox + x, oy + y);
cx >= x && cx < x + SORT_BUTTON && cy >= y && cy < y + SORT_BUTTON
});
let Some((_, slots)) = hit else {
return false;
};
self.sort_slots(&slots);
true
}
/// The sort key: sorts the part under the cursor (the container when
/// the cursor is over no slot).
pub(in crate::game) fn sort_key_press(&mut self, key: KeyCode) -> bool {
keybinds::key_name(key).is_some_and(|name| self.sort_input(name))
}
pub(in crate::game) fn sort_mouse_press(&mut self, button: MouseButton) -> bool {
self.sort_input(&keybinds::mouse_name(button))
}
fn sort_input(&mut self, name: &str) -> bool {
let e = &self.settings.extras;
if !e.sort || e.sort_key != name || self.recipe_search_focused() {
return false;
}
let buttons = self.sort_buttons();
let hovered = self.inventory_target().0;
let slots = buttons
.iter()
.find(|(_, slots)| hovered.is_some_and(|h| slots.contains(&h)))
.or(buttons.first())
.map(|(_, slots)| slots.clone());
let Some(slots) = slots else {
return false;
};
self.sort_slots(&slots);
true
}
/// Sorts `slots` with ordinary left clicks (needs an empty hand).
fn sort_slots(&mut self, slots: &[usize]) {
if self.carried.is_some() {
return;
}
let views: Vec<Option<ItemStack>> = slots.iter().map(|&s| self.slot_view(s)).collect();
let clicks = {
let rank = |s: &ItemStack| {
let order = self.creative_screen.order_of(s).unwrap_or(usize::MAX);
(order, hover_name(&self.pack.names, s))
};
let max = |s: &ItemStack| self.max_stack(s);
inv_sort::plan(&views, &rank, &max)
};
for i in clicks {
self.tweak_click(slots[i], 0, Input::Pickup);
}
}
}
@@ -44,6 +44,7 @@ fn loaded_air_is_discarded_on_first_actual_game_tick() {
game.block_ticks
.add_empty_chunk(ChunkPos::new(0, 0))
.unwrap();
game.load_ticking_neighbourhood(0, 0);
assert!(game.run_command("forceload add 0 0"));
assert!(game.run_command("fill 0 199 0 15 199 15 stone"));
assert!(game.res.facts().is_air(game.world.block(4, 204, 4)));
@@ -67,6 +67,9 @@ fn game() -> Game<'static> {
.add_empty_chunk(ChunkPos::new(x, 0))
.unwrap();
}
for x in 0..3 {
game.load_ticking_neighbourhood(x, 0);
}
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(24.5, 201.0, 12.5)).unwrap());
let net = NetConfig {
announce: false,
+1
View File
@@ -273,6 +273,7 @@ mod item_collision_tests {
game.block_ticks
.add_empty_chunk(ChunkPos::new(0, 0))
.unwrap();
game.load_ticking_neighbourhood(0, 0);
assert!(game.run_command("forceload add 0 0"));
assert!(game.run_command("summon minecart 4.5 200 4.5"));
assert!(game.run_command("summon oak_boat 10.5 200 10.5"));
@@ -55,6 +55,9 @@ fn game() -> Game<'static> {
.chunks
.insert((x, 0), Arc::new(Chunk::empty(x, 0)));
}
for x in 0..2 {
game.load_ticking_neighbourhood(x, 0);
}
assert!(game.run_command("fill 0 99 0 31 99 15 stone"));
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(28.5, 100.0, 10.5)).unwrap());
let net = NetConfig {
@@ -56,6 +56,9 @@ fn game() -> Game<'static> {
.add_empty_chunk(ChunkPos::new(cx, 0))
.unwrap();
}
for cx in 0..2 {
game.load_ticking_neighbourhood(cx, 0);
}
for command in [
"difficulty normal",
"forceload add 0 0 31 15",
@@ -49,6 +49,7 @@ fn game() -> Game<'static> {
game.block_ticks
.add_empty_chunk(ChunkPos::new(0, 0))
.unwrap();
game.load_ticking_neighbourhood(0, 0);
for command in [
"forceload add 0 0",
"fill 0 99 0 15 99 15 stone",
+25 -10
View File
@@ -694,7 +694,7 @@ impl Game<'_> {
position: [f64; 3],
velocity: [f64; 3],
) {
self.add_item_entity_drawn(stack, position, Some(velocity), true, 0);
self.add_item_entity_drawn(stack, position, Some(velocity), true, 0, 0);
}
pub(super) fn add_item_entity(
@@ -704,7 +704,21 @@ impl Game<'_> {
velocity: Option<[f64; 3]>,
pickup_delay: u32,
) -> [f64; 3] {
self.add_item_entity_drawn(stack, position, velocity, velocity.is_none(), pickup_delay)
self.add_item_entity_drawn(
stack,
position,
velocity,
velocity.is_none(),
pickup_delay,
0,
)
}
/// `spawnAtLocation` for a drop that then calls
/// `ItemEntity.setExtendedLifetime`: the item joins the world at `age`
/// instead of 0, so the wither's nether star lives 12000 ticks, not 6000.
pub(super) fn spawn_item_aged(&mut self, stack: ItemStack, position: [f64; 3], age: i32) {
self.add_item_entity_drawn(stack, position, None, true, 10, age);
}
/// `add_item_entity`; `constructor_motion` draws the five-argument
@@ -716,6 +730,7 @@ impl Game<'_> {
velocity: Option<[f64; 3]>,
constructor_motion: bool,
pickup_delay: u32,
age: i32,
) -> [f64; 3] {
let (item, count) = (stack.item.clone(), stack.count as i32);
self.next_entity += 1;
@@ -738,6 +753,7 @@ impl Game<'_> {
rotation: [yaw, 0.0],
pickup_delay: i64::from(pickup_delay),
random: own,
age,
..ItemEntity::new(
self.next_entity,
stack,
@@ -1017,14 +1033,10 @@ mod escape_tests {
let mut game = server_game();
let feet = horizon_core::geometry::Vec3::new(8.5, 70.0, 8.5);
game.player = Some(horizon_client::motion::PlayerMotion::new(game.motion, feet).unwrap());
// Entity-ticking chunks also need the surrounding loaded chunks.
for x in -2..=2 {
for z in -2..=2 {
game.world
.chunks
.insert((x, z), Arc::new(Chunk::empty(x, z)));
}
}
game.world
.chunks
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
game.load_ticking_neighbourhood(0, 0);
game.update_chunk_tickets();
assert!(game.entity_ticking_area()(8, 8));
let stone = game
@@ -1056,3 +1068,6 @@ mod escape_tests {
#[cfg(test)]
#[path = "item_void_tests.rs"]
mod item_void_tests;
#[cfg(test)]
#[path = "wither_star_tests.rs"]
mod wither_star_tests;
+117 -49
View File
@@ -8,7 +8,7 @@ use horizon_client::{
containers::{BlockEntity, Jukebox},
first_person::Hand,
items::ItemStack,
mobs::Species,
mobs::{Species, Who},
particles::{ParticleOptions, ParticleType},
text::Text,
};
@@ -53,33 +53,49 @@ impl Game<'_> {
_ => None,
}
}
fn set_has_record(&mut self, pos: [i32; 3], has: bool) {
self.set_has_record_notify(pos, has, true);
fn set_has_record(&mut self, pos: [i32; 3], has: bool) -> bool {
self.set_has_record_notify(pos, has, true)
}
/// The state change alone (`level.setBlock` with flag 2); `notify` adds
/// `notifyItemChangedInJukebox`'s `block_change`, which
/// `JukeboxBlock.setPlacedBy` does not post.
fn set_has_record_notify(&mut self, pos: [i32; 3], has: bool, notify: bool) {
fn set_has_record_notify(&mut self, pos: [i32; 3], has: bool, notify: bool) -> bool {
let state = self.world.block(pos[0], pos[1], pos[2]);
let registry = self.pack.assets.registry;
let Some(mut s) = registry.state(state).cloned() else {
return;
return false;
};
s.properties.insert(
"has_record".into(),
if has { "true" } else { "false" }.into(),
);
if let Some(new) = registry.id(&s) {
let p = BlockPos::new(pos[0], pos[1], pos[2]);
self.with_level(|b, level, ticks| {
b.set_block(level, ticks, p, new, level_flags::UPDATE_CLIENTS)
});
// `JukeboxBlockEntity.notifyItemChangedInJukebox`: the disc coming
// or going is a block change the sculk sensors hear.
if notify && self.world.block(pos[0], pos[1], pos[2]) != state {
self.post_block_game_event("minecraft:block_change", pos, None, Some(new));
}
let Some(new) = registry.id(&s) else {
return false;
};
let p = BlockPos::new(pos[0], pos[1], pos[2]);
self.with_level(|b, level, ticks| {
b.set_block(level, ticks, p, new, level_flags::UPDATE_CLIENTS)
});
if notify {
let state = self.world.block(pos[0], pos[1], pos[2]);
self.post_block_game_event("minecraft:block_change", pos, None, Some(state));
}
true
}
/// `JukeboxBlock.HAS_RECORD`, the state both clicks are decided by.
fn jukebox_has_record(&self, pos: [i32; 3]) -> bool {
let state = self.world.block(pos[0], pos[1], pos[2]);
self.pack
.assets
.registry
.state(state)
.and_then(|s| s.properties.get("has_record"))
.is_some_and(|v| v == "true")
}
/// `JukeboxBlockEntity.notifyItemChangedInJukebox`: the block's
/// `has_record`, then `BLOCK_CHANGE` with the state it now has.
fn jukebox_record_changed(&mut self, pos: [i32; 3], has: bool) {
self.set_has_record(pos, has);
}
/// `JukeboxBlock.setPlacedBy`: a jukebox placed from an item whose
/// `block_entity_data` carries a `RecordItem` keeps that record, so
@@ -121,9 +137,10 @@ impl Game<'_> {
{
j.playing = true;
j.song_clock_present = true;
j.loaded_clock = false;
j.ticks = 0;
}
self.set_has_record(pos, true);
self.jukebox_record_changed(pos, true);
self.start_song(pos, &song);
self.jukebox_changed(pos);
}
@@ -141,15 +158,17 @@ impl Game<'_> {
was_playing = j.playing;
j.playing = false;
j.song_clock_present = false;
j.loaded_clock = false;
j.ticks = 0;
}
// `setTheItem(EMPTY)` first tells the block, then stops the song.
self.jukebox_record_changed(pos, false);
// `JukeboxSongPlayer.stop` posts only while a song plays.
if was_playing {
self.stop_song_heard(pos);
} else {
self.stop_song(pos);
}
self.set_has_record(pos, false);
self.jukebox_changed(pos);
}
pub(super) fn jukebox_set_item(&mut self, pos: [i32; 3], stack: Option<ItemStack>) {
@@ -164,8 +183,9 @@ impl Game<'_> {
j.items[0] = stack;
j.playing = song.is_some();
j.song_clock_present = song.is_some();
j.loaded_clock = false;
j.ticks = 0;
self.set_has_record(pos, has_record);
self.jukebox_record_changed(pos, has_record);
match song {
Some(song) => self.start_song(pos, &song),
None if was_playing => self.stop_song_heard(pos),
@@ -184,7 +204,10 @@ impl Game<'_> {
{
return false;
}
if self.jukebox(pos).is_some_and(|j| j.record().is_some()) {
let state = self.world.block(pos[0], pos[1], pos[2]);
// The state, not the slot, decides the click: `JukeboxBlock` with a
// record pops it out, without one takes a disc.
if self.jukebox_has_record(pos) {
// TRY_WITH_EMPTY_HAND: only the main hand goes on to useWithoutItem.
if self.hand != Hand::Main {
return false;
@@ -199,7 +222,7 @@ impl Game<'_> {
let Some(song) = jukebox_playable(stack, self.res.items()) else {
return false;
};
// JukeboxPlayable.tryInsertIntoJukebox
// JukeboxPlayable.tryInsertIntoJukebox: the one disc the player gives up.
let record = ItemStack {
count: 1,
..stack.clone()
@@ -207,7 +230,9 @@ impl Game<'_> {
if !self.creative {
self.consume_held();
}
self.award_custom_stat("play_record", 1);
// `JukeboxBlockEntity.setTheItem`: the slot takes the disc, the block
// tells its listeners, and the song plays (`play`); a disc already in
// the slot (under a state that lost its record) is replaced.
self.world.block_entities.insert(
(pos[0], pos[1], pos[2]),
BlockEntity::Jukebox(Jukebox {
@@ -215,13 +240,23 @@ impl Game<'_> {
ticks: 0,
playing: true,
song_clock_present: true,
loaded_clock: false,
}),
);
self.set_has_record(pos, true);
self.jukebox_record_changed(pos, true);
if let Some(song) = song {
self.start_song(pos, &song);
}
self.jukebox_changed(pos);
// `tryInsertIntoJukebox`: the same change again, against the state this
// click found, with the player as its source.
self.post_block_game_event(
"minecraft:block_change",
pos,
Some(Who::Player),
Some(state),
);
self.award_custom_stat("play_record", 1);
// ServerPlayerGameMode.useItemOn: the action was consumed.
self.trigger_block("minecraft:item_used_on_block", pos, stack);
true
@@ -235,14 +270,8 @@ impl Game<'_> {
if &*self.res.facts().get(state).name != "minecraft:jukebox" {
return false;
}
let has_record = self
.pack
.assets
.registry
.state(state)
.and_then(|s| s.properties.get("has_record"))
.is_some_and(|v| v == "true");
has_record || held.is_some_and(|stack| jukebox_playable(stack, self.res.items()).is_some())
self.jukebox_has_record(pos)
|| held.is_some_and(|stack| jukebox_playable(stack, self.res.items()).is_some())
}
/// Level event 1010: the song from the jukebox, and the "Now Playing" overlay.
fn start_song(&mut self, pos: [i32; 3], song: &str) {
@@ -317,20 +346,28 @@ impl Game<'_> {
m.extra.party_jukebox = playing.then_some(pos);
}
}
/// `popOutTheItem`: the disc above the jukebox, and the song stops.
/// `popOutTheItem`: the disc above the jukebox, and the song stops. An
/// empty slot pops nothing out (`if (!itemBeforePoppingOut.isEmpty())`),
/// whatever the state says.
fn eject_record(&mut self, pos: [i32; 3]) {
let key = (pos[0], pos[1], pos[2]);
let mut was_playing = false;
let record = match self.world.block_entities.get_mut(&key) {
let (record, was_playing) = match self.world.block_entities.get_mut(&key) {
Some(BlockEntity::Jukebox(j)) => {
was_playing = j.playing;
if j.items[0].is_none() {
return;
}
let was_playing = j.playing;
j.playing = false;
j.song_clock_present = false;
j.loaded_clock = false;
j.ticks = 0;
j.items[0].take()
(j.items[0].take(), was_playing)
}
_ => None,
_ => return,
};
// `removeTheItem` -> `setTheItem(EMPTY)`: the block loses its record,
// then the song stops.
self.jukebox_record_changed(pos, false);
// `JukeboxSongPlayer.stop` posts only while a song plays: a disc
// taken out after its song ended is silent.
if was_playing {
@@ -347,7 +384,6 @@ impl Game<'_> {
);
self.spawn_item_own(record, at, 10);
}
self.set_has_record(pos, false);
self.jukebox_changed(pos);
}
/// A broken jukebox stops its song (its disc drops with the contents).
@@ -359,11 +395,11 @@ impl Game<'_> {
let playing = match self.world.block_entities.get(&key) {
Some(BlockEntity::Jukebox(j)) if j.playing => j.record().and_then(|r| {
let song = song_of_record(r, self.items())?;
Some((song, j.ticks))
Some((song, j.ticks, j.loaded_clock))
}),
_ => None,
};
let Some((song, ticks)) = playing else {
let Some((song, ticks, loaded)) = playing else {
return;
};
let pos = [key.0, key.1, key.2];
@@ -374,13 +410,26 @@ impl Game<'_> {
if let Some(BlockEntity::Jukebox(j)) = self.world.block_entities.get_mut(&key) {
j.playing = false;
j.song_clock_present = false;
j.loaded_clock = false;
j.ticks = 0;
}
// `setSongWithoutPlaying` never adopted a clock past its song's
// end: loading such a jukebox is silent, while a song that ends
// here stops out loud.
if loaded {
return;
}
self.stop_song_heard(pos);
self.jukebox_changed(pos);
return;
}
if !self.jukebox_sounds.contains_key(&key) && ticks == 0 {
if loaded {
// The song a save kept goes on without its sound: vanilla starts
// one only from `play` (level event 1010).
if let Some(BlockEntity::Jukebox(j)) = self.world.block_entities.get_mut(&key) {
j.loaded_clock = false;
}
} else if !self.jukebox_sounds.contains_key(&key) && ticks == 0 {
self.start_song(pos, &song);
}
if ticks % 20 == 0 {
@@ -452,12 +501,19 @@ pub(super) fn jukebox_signals(
) -> Option<(i32, i32)> {
match world.block_entities.get(&(pos.x, pos.y, pos.z)) {
Some(BlockEntity::Jukebox(j)) => {
let comparator = j
.record()
.and_then(|r| song_of_record(r, items))
.and_then(|s| items.songs.get(&s).map(|song| song.comparator))
let song = j.record().and_then(|r| song_of_record(r, items));
let comparator = song
.as_deref()
.and_then(|s| items.songs.get(s).map(|song| song.comparator))
.unwrap_or(0);
Some((if j.playing { 15 } else { 0 }, comparator))
// `JukeboxSongPlayer.isPlaying`, less a clock a save brought back
// past its song's end: the first tick drops that one.
let stale = j.loaded_clock
&& song
.as_deref()
.and_then(|s| items.songs.get(s))
.is_some_and(|s| s.finished(j.ticks));
Some((if j.playing && !stale { 15 } else { 0 }, comparator))
}
_ => None,
}
@@ -578,18 +634,24 @@ mod jukebox_play_tests {
);
// The song is still playing after 41 ticks.
assert!(
lines.iter().all(|l| l.ends_with("minecraft:jukebox_play")),
!lines
.iter()
.any(|l| l.ends_with("minecraft:jukebox_stop_play")),
"{lines:?}"
);
}
/// A jukebox whose song is stopped posts no play event: only the twenty
/// ticks of a playing song do.
/// ticks of a playing song do. Its insert's `BLOCK_CHANGE` events are the
/// only ones there.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_stopped_jukebox_posts_no_play_event() {
let Some(lines) = playing(0) else { return };
assert!(lines.is_empty(), "{lines:?}");
assert!(
lines.iter().all(|l| l.ends_with("minecraft:block_change")),
"{lines:?}"
);
}
}
@@ -656,7 +718,8 @@ mod saved_song_clock_tests {
assert!(game.run_command("setblock 8 199 4 stone"));
assert!(game.run_command("setblock 8 200 4 jukebox[has_record=true]{RecordItem:{id:\"minecraft:music_disc_cat\",count:1,components:{\"minecraft:custom_data\":{h032:1}}}}"));
let j = game.jukebox([8, 200, 4]).unwrap();
assert!(j.playing, "preserve legacy record-only adoption");
// `setSongWithoutPlaying`: a record whose save had no clock is silent.
assert!(!j.playing, "a record without a clock does not play on load");
assert_eq!(j.ticks, 0);
let saved = game.block_entity_nbt([8, 200, 4]).unwrap();
assert_eq!(
@@ -692,6 +755,7 @@ mod jukebox_component_tests {
ticks: 0,
song_clock_present: true,
playing: true,
loaded_clock: false,
}),
);
jukebox_signals(
@@ -775,6 +839,10 @@ mod jukebox_component_tests {
}
}
#[cfg(test)]
#[path = "jukebox_tests.rs"]
mod jukebox_tests;
#[cfg(test)]
mod block_change_tests {
use super::*;
@@ -0,0 +1,288 @@
//! Jukeboxes against `JukeboxBlock`, `JukeboxBlockEntity` and `JukeboxPlayable`:
//! what a load brings back of a saved song, the `BLOCK_CHANGE` of an insert and
//! of an eject, and which click the `has_record` state allows.
use super::Game;
use crate::game::{Audio, Options, Progress, Settings, load_resources};
use horizon_client::{
chunk::Chunk,
containers::{BlockEntity, Jukebox},
items::ItemStack,
mobs::Who,
};
use horizon_world::registry::BlockStateId;
use std::sync::Arc;
/// The game events posted while `f` runs, as `(event, position, source)`.
fn record(f: impl FnOnce()) -> Vec<(&'static str, [f64; 3], Option<Who>)> {
crate::game::sculk::TEST_POSTED_EVENTS.with(|e| *e.borrow_mut() = Some(Vec::new()));
f();
crate::game::sculk::TEST_POSTED_EVENTS.with(|e| e.borrow_mut().take().unwrap_or_default())
}
/// A windowless integrated-server game from the prepared resources with a
/// resident chunk; `None` without them.
fn with_game<R>(f: impl FnOnce(&mut Game<'static>) -> R) -> Option<R> {
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES")?);
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()).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)));
Some(f(&mut game))
}
const AT: [f64; 3] = [0.5, 64.5, 0.5];
/// The jukebox at (0, 64, 0) in the state `record` names; its state id.
fn place_jukebox(game: &mut Game<'_>, record: bool) -> BlockStateId {
let registry = game.pack.assets.registry;
let mut state = registry
.state(registry.default_state("minecraft:jukebox").unwrap())
.unwrap()
.clone();
state.properties.insert(
"has_record".into(),
if record { "true" } else { "false" }.into(),
);
let id = registry.id(&state).unwrap();
game.world.set(0, 64, 0, id).unwrap();
id
}
/// A jukebox block entity as a save writes it, loaded back into the world at
/// (0, 64, 0): `RecordItem` and, with a `clock`, `ticks_since_song_started`.
fn load_saved(game: &mut Game<'_>, clock: Option<i64>) {
let clock = clock.map_or(String::new(), |n| format!("ticks_since_song_started:{n}L,"));
let (tag, _) = horizon_world::nbt::parse_snbt(&format!(
"{{id:\"minecraft:jukebox\",x:0,y:64,z:0,\
RecordItem:{{id:\"minecraft:music_disc_11\",count:1,}},{clock}}}"
))
.unwrap();
let (pos, model) = horizon_client::save::decode_block_entity(&tag).unwrap();
game.world.block_entities.insert(pos, model);
}
/// `JukeboxBlockEntity.setTheItem` posts `BLOCK_CHANGE` with the state the
/// block now has, and `JukeboxPlayable.tryInsertIntoJukebox` then posts the
/// same change again with the player as its source (against the state the
/// click found). `popOutTheItem` -> `setTheItem(EMPTY)` posts one on the way
/// out, before the song's own `JUKEBOX_STOP_PLAY`. A sculk sensor beside a
/// jukebox heard neither insert nor eject: only the song's end.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn an_insert_posts_two_block_changes_and_an_eject_one() {
let Some((inserted, popped)) = with_game(|game| {
place_jukebox(game, false);
let disc = ItemStack::new("minecraft:music_disc_cat", 1);
let inserted = record(|| {
assert!(
game.use_jukebox([0, 64, 0], Some(&disc)),
"the disc goes in"
);
});
let popped = record(|| {
assert!(
game.use_jukebox([0, 64, 0], None),
"the empty hand pops it out"
);
});
(inserted, popped)
}) else {
return;
};
assert_eq!(
inserted,
vec![
("minecraft:block_change", AT, None),
("minecraft:block_change", AT, Some(Who::Player)),
],
"the slot's change, then the player's"
);
assert_eq!(
popped,
vec![
("minecraft:block_change", AT, None),
("minecraft:jukebox_stop_play", AT, None),
],
"the record leaves the block before its song stops"
);
}
/// `JukeboxBlock.useWithoutItem` only pops when the state says a record is
/// there, and `popOutTheItem` pops nothing from an empty slot; `useItemOn`
/// only inserts when it says there is none, and replacing the slot's item is
/// `setTheItem`'s own doing. Horizon decided both by the slot.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn the_has_record_state_decides_both_clicks() {
let Some((ignored, replaced)) = with_game(|game| {
// has_record with nothing in the slot: the click pops nothing and the
// disc stays in hand, so the state stays as it is.
let recorded = place_jukebox(game, true);
game.world
.block_entities
.insert((0, 64, 0), BlockEntity::Jukebox(Jukebox::default()));
let disc = ItemStack::new("minecraft:music_disc_cat", 1);
let ignored = record(|| {
assert!(
game.use_jukebox([0, 64, 0], Some(&disc)),
"the click is consumed"
);
});
assert_eq!(game.world.block(0, 64, 0), recorded, "keeps its record");
assert!(
game.jukebox([0, 64, 0]).unwrap().record().is_none(),
"the held disc is not inserted"
);
// No record in the state with a disc in the slot: the held one replaces
// it (`JukeboxBlockEntity.setTheItem`), it does not eject the old one.
place_jukebox(game, false);
let old = ItemStack::new("minecraft:music_disc_13", 1);
game.world.block_entities.insert(
(0, 64, 0),
BlockEntity::Jukebox(Jukebox {
items: [Some(old)],
..Default::default()
}),
);
let replaced = record(|| {
assert!(
game.use_jukebox([0, 64, 0], Some(&disc)),
"the disc goes in"
);
});
let j = game.jukebox([0, 64, 0]).unwrap();
assert_eq!(&*j.record().unwrap().item, "minecraft:music_disc_cat");
assert!(j.playing, "the new disc plays");
(ignored, replaced)
}) else {
return;
};
assert!(ignored.is_empty(), "nothing happened: {ignored:?}");
assert_eq!(
replaced,
vec![
("minecraft:block_change", AT, None),
("minecraft:block_change", AT, Some(Who::Player)),
],
"the old disc is replaced, not popped out"
);
}
/// `JukeboxBlockEntity.loadAdditional` hands a saved clock to
/// `JukeboxSongPlayer.setSongWithoutPlaying`: a song under its length comes
/// back playing, without `play`'s level event 1010 and its "Now Playing", a
/// clock past the song's end is dropped without a `JUKEBOX_STOP_PLAY`, and a
/// record saved without a clock is no song at all.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_saved_song_comes_back_without_its_sound() {
let Some(cases) = with_game(|game| {
place_jukebox(game, true);
let signal = |game: &Game<'_>| {
let p = horizon_core::position::BlockPos::new(0, 64, 0);
super::jukebox_signals(&game.world, game.items(), p)
.unwrap()
.0
};
let mut cases = Vec::new();
for clock in [Some(41), Some(10_000), None, Some(0)] {
load_saved(game, clock);
let events = record(|| game.tick_jukebox((0, 64, 0)));
let j = game.jukebox([0, 64, 0]).unwrap();
cases.push((
clock,
(j.playing, j.ticks, j.song_clock_present),
signal(game),
game.titles.overlay.is_some(),
events,
));
}
cases
}) else {
return;
};
let at = |clock: Option<i64>| cases.iter().find(|c| c.0 == clock).unwrap();
// Mid-song and at its start: the clock goes on, at 15, quietly; only the
// twenty-tick chatter of a song that is running.
assert_eq!(at(Some(41)).1, (true, 42, true), "the clock goes on");
assert_eq!(at(Some(41)).2, 15);
assert!(!at(Some(41)).3, "nothing says \"Now Playing\" on a load");
assert!(at(Some(41)).4.is_empty(), "{:?}", at(Some(41)).4);
assert_eq!(at(Some(0)).1, (true, 1, true));
assert!(!at(Some(0)).3, "a clock saved at its start stays quiet");
assert_eq!(
at(Some(0)).4,
vec![("minecraft:jukebox_play", AT, None)],
"the first tick's game event is vanilla's"
);
// Past the end: gone, and silent about going.
assert_eq!(at(Some(10_000)).1, (false, 0, false));
assert_eq!(at(Some(10_000)).2, 0, "a dropped song powers nothing");
assert!(at(Some(10_000)).4.is_empty(), "{:?}", at(Some(10_000)).4);
// A record saved without a clock: no song, no signal, no event.
assert_eq!(at(None).1, (false, 0, false));
assert_eq!(at(None).2, 0);
assert!(!at(None).3, "a record alone starts no song");
assert!(at(None).4.is_empty(), "{:?}", at(None).4);
}
/// The same three cases on the load path itself, which needs no resources: only
/// a saved clock brings a song back, and it comes back unstarted.
#[test]
fn only_a_saved_clock_brings_a_song_back() {
let load = |clock: Option<i64>| {
let clock = clock.map_or(String::new(), |n| format!("ticks_since_song_started:{n}L,"));
let (tag, _) = horizon_world::nbt::parse_snbt(&format!(
"{{id:\"minecraft:jukebox\",x:0,y:64,z:0,\
RecordItem:{{id:\"minecraft:music_disc_11\",count:1,}},{clock}}}"
))
.unwrap();
match horizon_client::save::decode_block_entity(&tag).unwrap().1 {
BlockEntity::Jukebox(j) => j,
other => panic!("not a jukebox: {other:?}"),
}
};
let mid = load(Some(41));
assert_eq!(mid.ticks, 41);
assert!(
mid.playing && mid.song_clock_present,
"a saved clock brings the song back"
);
let unclocked = load(None);
assert_eq!(
unclocked.record().map(|r| &*r.item),
Some("minecraft:music_disc_11")
);
assert_eq!(unclocked.ticks, 0);
assert!(
!unclocked.playing && !unclocked.song_clock_present,
"a record alone is no song"
);
}
@@ -47,6 +47,7 @@ fn game() -> Game<'static> {
game.block_ticks
.add_empty_chunk(ChunkPos::new(0, 0))
.unwrap();
game.load_ticking_neighbourhood(0, 0);
for command in [
"forceload add 0 0",
"fill 0 63 0 15 63 15 stone",
+1 -1
View File
@@ -1161,7 +1161,7 @@ impl Game<'_> {
let scale = 1i32 << m.scale;
let start_x = m.center[0] / scale - 64;
let start_z = m.center[1] / scale - 64;
let sea_level = 63;
let sea_level = horizon_world::level::sea_level_of(self.level_key());
let quarts: Vec<[i32; 3]> = (0..128)
.flat_map(|row| {
(0..128).map(move |column| {
@@ -656,10 +656,16 @@ impl Game<'_> {
self.station_sound(sound);
}
} else if kind == Kind::Stonecutter {
// `StonecutterMenu.container`'s listener runs on every change of the
// input slot (`slotsChanged` and `containerChanged` both hang off
// it): a recipe button or an inventory click is not one.
let input_changed = before[0] != menu.slot(0).cloned();
if let Some(s) = self.station.as_mut() {
s.selected = usize::try_from(menu.data[0]).ok();
s.stone = std::mem::take(&mut menu.stone);
s.stonecutter_changed();
if input_changed {
s.stonecutter_changed();
}
}
for sound in sounds.into_iter().filter(|_| !predicting) {
self.station_sound(sound);
+40 -47
View File
@@ -233,6 +233,19 @@ fn pickup_instead_of_push(species: Species, is_passenger: bool, cart_is_vehicle:
!cart_is_vehicle && !is_passenger && species != Species::IronGolem
}
/// `EntitySelector.pushableBy` asks `isPushable`: `Shulker` takes no push
/// (`Mob::pushable` leaves it out) but still counts, so a cart offers it
/// `startRiding` like any other living entity.
fn offered_pickup(pushable: bool, species: Species, alive: bool) -> bool {
pushable || species == Species::Shulker && alive
}
/// `Shulker.push(Entity)` is empty, so a shulker that is not picked up (it
/// already rides something) neither moves nor slows the cart.
fn takes_push_from_cart(species: Species) -> bool {
species != Species::Shulker
}
/// `Entity.canRide` for a cart: the boarding cooldown, and a warden or the
/// wither never rides (`canRide` false).
fn can_ride_cart(species: Species, boarding_cooldown: i32) -> bool {
@@ -1794,7 +1807,10 @@ impl Game<'_> {
let (teams, own) = (self.mob_teams(), self.team_with_owner(id, || None));
near.iter()
.map(|&j| &self.mobs[j as usize])
.filter(|m| m.pushable() && pushable_by(own, teams.of(Who::Mob(m.id)), true))
.filter(|m| {
offered_pickup(m.pushable(), m.species(), m.alive())
&& pushable_by(own, teams.of(Who::Mob(m.id)), true)
})
.map(|m| m.id)
.collect()
};
@@ -1827,6 +1843,9 @@ impl Game<'_> {
}
continue;
}
if !takes_push_from_cart(species) {
continue;
}
let other = self.mobs[j].position;
let Some(v) = super::boat_passengers::push_impulse(pos, other) else {
continue;
@@ -2144,51 +2163,6 @@ 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 {
@@ -2210,7 +2184,7 @@ impl Game<'_> {
.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);
self.apply_item_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(
@@ -3382,6 +3356,25 @@ mod tests {
assert!(!can_ride_cart(Species::Warden, 0));
}
/// A shulker beside the rail is picked up although `Mob::pushable` leaves it out.
#[test]
fn a_cart_picks_up_a_shulker_but_not_a_dead_one() {
assert!(offered_pickup(false, Species::Shulker, true));
assert!(!offered_pickup(false, Species::Shulker, false));
assert!(!offered_pickup(false, Species::Bat, true));
assert!(offered_pickup(true, Species::Cow, true));
assert!(pickup_instead_of_push(Species::Shulker, false, false));
assert!(can_ride_cart(Species::Shulker, 0));
}
/// A shulker already riding a boat is no pickup candidate and `Shulker.push` is a no-op.
#[test]
fn a_mounted_shulker_does_not_push_a_cart() {
assert!(!pickup_instead_of_push(Species::Shulker, true, false));
assert!(!takes_push_from_cart(Species::Shulker));
assert!(takes_push_from_cart(Species::Cow));
}
/// `WitherBoss.canRide` returns false, so a moving cart cannot scoop the boss up.
#[test]
fn a_wither_never_rides_a_cart() {
@@ -268,3 +268,40 @@ fn efficiency_applies_to_a_same_item_swap() {
}
});
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_stacks_attribute_modifiers_replace_its_items_attack_damage() {
with_survival_game(|game| {
// Player.attack reads getAttributeValue(ATTACK_DAMAGE): the base 1
// plus what detectEquipmentUpdates gave the player for the held stack
// (ItemStack.forEachModifier, the stack's ATTRIBUTE_MODIFIERS). An
// iron pickaxe's own modifier is +3 (ToolMaterial.IRON's 2 plus the
// pickaxe's 1); the stack's +20 replaces it, so the hit is 21 (#14641).
for (stack, damage) in [
("minecraft:iron_pickaxe", 4.0),
(
"minecraft:iron_pickaxe[attribute_modifiers=[{type:\"minecraft:attack_damage\",id:\"minecraft:base_attack_damage\",amount:20.0,operation:\"add_value\",slot:\"mainhand\"}]]",
21.0,
),
// Identifier.CODEC reads bare names as `minecraft:`'s: the same
// modifier, so the attribute the host applies it to is found.
(
"minecraft:iron_pickaxe[attribute_modifiers=[{type:\"attack_damage\",id:\"base_attack_damage\",amount:20.0,operation:\"add_value\",slot:\"mainhand\"}]]",
21.0,
),
// In the off hand slot group, it does not apply to the main hand.
(
"minecraft:iron_pickaxe[attribute_modifiers=[{type:\"minecraft:attack_damage\",id:\"minecraft:base_attack_damage\",amount:20.0,operation:\"add_value\",slot:\"offhand\"}]]",
1.0,
),
("minecraft:iron_pickaxe[!attribute_modifiers]", 1.0),
] {
assert!(game.run_command(&format!(
"item replace entity @s weapon.mainhand with {stack}"
)));
game.tick_hand();
assert_eq!(game.attack_damage(), damage, "{stack}");
}
});
}
@@ -44,18 +44,13 @@ fn game() -> Game<'static> {
.unwrap();
game.build_registries();
game.set_allow_commands(true);
// Entities tick once every chunk within 2 columns is loaded
// (ChunkMap.prepareEntityTickingChunk, #13953).
for x in -2..=2 {
for z in -2..=2 {
game.world
.chunks
.insert((x, z), Arc::new(Chunk::empty(x, z)));
game.block_ticks
.add_empty_chunk(ChunkPos::new(x, z))
.unwrap();
}
}
game.world
.chunks
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
game.block_ticks
.add_empty_chunk(ChunkPos::new(0, 0))
.unwrap();
game.load_ticking_neighbourhood(0, 0);
assert!(game.run_command("fill 0 99 0 15 99 15 stone"));
game
}
+14 -107
View File
@@ -992,6 +992,8 @@
use super::native_data_maps::NativeDataMapType;
use super::native_guards::{box_meets, finite, finite_params, mod_image_fits};
use super::native_item_energy::ItemEnergyBuffer;
use super::native_level_effects::{mod_particles_ok, mod_sound_ok, sound_id};
use super::native_menus::viewer_changes;
use super::native_world_edit::{property_change, with_property};
use super::{
Game, GameLevel, LevelDimension, Screen,
@@ -8504,6 +8506,18 @@ impl Game<'_> {
blocks,
);
}
/// An explosion at `center` in `dimension` is about to hurt and push `entities`, the ids of
/// the entities in its reach (players aside: `native_living_incoming_damage` hears them),
/// after `native_explosion_detonate`: `ServerExplosion.hurtEntities`. Take out the ids to
/// spare: they are neither hurt nor pushed. Server-side. API @API:explosion_entities@.
pub(super) fn native_explosion_entities(
&mut self,
dimension: &str,
center: [f64; 3],
entities: &mut Vec<u32>,
) {
mod_api::dispatch_explosion_entities(self, mod_api::mods(), dimension, center, entities);
}
/// An explosion at `center` in `dimension`, about to break `blocks`, pushes
/// `entity` (None for a player, whom `native_acting_player` names):
/// `ServerExplosion.hurtEntities`, after the knockback is calculated. Change `knockback_velocity` to change
@@ -9991,113 +10005,6 @@ fn mob_source(damage: &Damage) -> HurtSource<'static> {
}
}
/// Whether `id` is a sound event's id, `namespace:path` as `Identifier`
/// allows: the namespace of `[a-z0-9_.-]`, the path also with `/`.
/// Whether a native mod's sound can be sent: a namespaced event, a sound
/// source, a position inside the world's range and a finite, non-negative
/// volume and pitch.
pub(super) fn mod_sound_ok(
sound: &str,
at: [f64; 3],
source: usize,
volume: f32,
pitch: f32,
) -> bool {
let position_ok = at
.into_iter()
.all(|v| v.is_finite() && v.abs() <= f64::from(i32::MAX) / 8.0);
let gain_ok = volume.is_finite() && volume >= 0.0 && pitch.is_finite() && pitch >= 0.0;
sound_id(sound)
&& source < horizon_client::settings::SOUND_SOURCES.len()
&& position_ok
&& gain_ok
}
/// Whether a native mod's particles can be sent. Block particle types need
/// a known state (`state_ok`); item, trail and vibration particles aren't
/// supported by the network dispatch; a mod's own type must be namespaced.
pub(super) fn mod_particles_ok(
options: &ParticleOptions,
state: Option<BlockStateId>,
state_ok: impl Fn(BlockStateId) -> bool,
at: [f64; 3],
spread: [f64; 3],
speed: f64,
) -> bool {
use ParticleType as T;
// A native mod's own type: the name has to be namespaced, and a client
// draws it only when its pack has the sprite set.
if let Some(name) = &options.mod_particle {
let namespaced = name
.split_once(':')
.is_some_and(|(ns, path)| !ns.is_empty() && !path.is_empty());
if !namespaced {
return false;
}
}
let supported = match options.ty {
T::Block | T::BlockMarker | T::FallingDust | T::DustPillar | T::BlockCrumble => {
state.is_some_and(state_ok)
}
T::Item | T::Trail | T::Vibration => false,
T::Geyser | T::GeyserPlume | T::GeyserBase | T::GeyserPoof => options
.geyser
.is_some_and(|(_, impulse)| impulse.is_finite()),
_ => true,
};
supported
&& at.into_iter().all(f64::is_finite)
&& spread
.into_iter()
.all(|v| v.is_finite() && v.abs() <= f32::MAX as f64)
&& speed.is_finite()
&& speed.abs() <= f32::MAX as f64
&& options.color.into_iter().all(f32::is_finite)
&& options.to_color.into_iter().all(f32::is_finite)
&& options.scale.is_finite()
}
fn sound_id(id: &str) -> bool {
let Some((namespace, path)) = id.split_once(':') else {
return false;
};
let allowed = |c: char, slash: bool| {
c.is_ascii_lowercase() || c.is_ascii_digit() || "_.-".contains(c) || (slash && c == '/')
};
!namespace.is_empty()
&& !path.is_empty()
&& namespace.chars().all(|c| allowed(c, false))
&& path.chars().all(|c| allowed(c, true))
}
/// Sets the viewer counts `known` holds for `dim` to `now` and returns the
/// changes as `(pos, previous, viewers)`, in position order. A container that
/// is no longer counted goes to 0 and leaves `known`.
pub(super) fn viewer_changes(
known: &mut HashMap<(&'static str, (i32, i32, i32)), u32>,
dim: &'static str,
mut now: HashMap<(i32, i32, i32), u32>,
) -> Vec<((i32, i32, i32), u32, u32)> {
let mut changes = Vec::new();
known.retain(|&(d, pos), previous| {
if d != dim {
return true;
}
let viewers = now.remove(&pos).unwrap_or(0);
if viewers != *previous {
changes.push((pos, *previous, viewers));
*previous = viewers;
}
viewers > 0
});
for (pos, viewers) in now {
known.insert((dim, pos), viewers);
changes.push((pos, 0, viewers));
}
changes.sort_unstable();
changes
}
pub(super) fn dimension_id(dimension: LevelDimension) -> &'static str {
match dimension {
LevelDimension::Overworld => "minecraft:overworld",
@@ -3,9 +3,9 @@
//! Every player in range hears or sees them, as `ServerLevel.playSound` and
//! `sendParticles` send them. They return false on a client, for a handle of
//! an earlier open, or for arguments the network can't carry.
use super::native_hooks::{mod_particles_ok, mod_sound_ok};
use super::{Dim, Game, creatures::NativeSound, lan_particles::QueuedParticle};
use horizon_client::mod_api::{LevelRef, ParticleOptions};
use horizon_client::particles::ParticleType;
use horizon_world::registry::BlockStateId;
impl Game<'_> {
@@ -85,3 +85,82 @@ impl Game<'_> {
true
}
}
/// Whether `id` is a sound event's id, `namespace:path` as `Identifier`
/// allows: the namespace of `[a-z0-9_.-]`, the path also with `/`.
/// Whether a native mod's sound can be sent: a namespaced event, a sound
/// source, a position inside the world's range and a finite, non-negative
/// volume and pitch.
pub(super) fn mod_sound_ok(
sound: &str,
at: [f64; 3],
source: usize,
volume: f32,
pitch: f32,
) -> bool {
let position_ok = at
.into_iter()
.all(|v| v.is_finite() && v.abs() <= f64::from(i32::MAX) / 8.0);
let gain_ok = volume.is_finite() && volume >= 0.0 && pitch.is_finite() && pitch >= 0.0;
sound_id(sound)
&& source < horizon_client::settings::SOUND_SOURCES.len()
&& position_ok
&& gain_ok
}
/// Whether a native mod's particles can be sent. Block particle types need
/// a known state (`state_ok`); item, trail and vibration particles aren't
/// supported by the network dispatch; a mod's own type must be namespaced.
pub(super) fn mod_particles_ok(
options: &ParticleOptions,
state: Option<BlockStateId>,
state_ok: impl Fn(BlockStateId) -> bool,
at: [f64; 3],
spread: [f64; 3],
speed: f64,
) -> bool {
use ParticleType as T;
// A native mod's own type: the name has to be namespaced, and a client
// draws it only when its pack has the sprite set.
if let Some(name) = &options.mod_particle {
let namespaced = name
.split_once(':')
.is_some_and(|(ns, path)| !ns.is_empty() && !path.is_empty());
if !namespaced {
return false;
}
}
let supported = match options.ty {
T::Block | T::BlockMarker | T::FallingDust | T::DustPillar | T::BlockCrumble => {
state.is_some_and(state_ok)
}
T::Item | T::Trail | T::Vibration => false,
T::Geyser | T::GeyserPlume | T::GeyserBase | T::GeyserPoof => options
.geyser
.is_some_and(|(_, impulse)| impulse.is_finite()),
_ => true,
};
supported
&& at.into_iter().all(f64::is_finite)
&& spread
.into_iter()
.all(|v| v.is_finite() && v.abs() <= f32::MAX as f64)
&& speed.is_finite()
&& speed.abs() <= f32::MAX as f64
&& options.color.into_iter().all(f32::is_finite)
&& options.to_color.into_iter().all(f32::is_finite)
&& options.scale.is_finite()
}
pub(super) fn sound_id(id: &str) -> bool {
let Some((namespace, path)) = id.split_once(':') else {
return false;
};
let allowed = |c: char, slash: bool| {
c.is_ascii_lowercase() || c.is_ascii_digit() || "_.-".contains(c) || (slash && c == '/')
};
!namespace.is_empty()
&& !path.is_empty()
&& namespace.chars().all(|c| allowed(c, false))
&& path.chars().all(|c| allowed(c, true))
}
@@ -560,6 +560,34 @@ impl Game<'_> {
}
}
/// Sets the viewer counts `known` holds for `dim` to `now` and returns the
/// changes as `(pos, previous, viewers)`, in position order. A container that
/// is no longer counted goes to 0 and leaves `known`.
pub(super) fn viewer_changes(
known: &mut std::collections::HashMap<(&'static str, (i32, i32, i32)), u32>,
dim: &'static str,
mut now: std::collections::HashMap<(i32, i32, i32), u32>,
) -> Vec<((i32, i32, i32), u32, u32)> {
let mut changes = Vec::new();
known.retain(|&(d, pos), previous| {
if d != dim {
return true;
}
let viewers = now.remove(&pos).unwrap_or(0);
if viewers != *previous {
changes.push((pos, *previous, viewers));
*previous = viewers;
}
viewers > 0
});
for (pos, viewers) in now {
known.insert((dim, pos), viewers);
changes.push((pos, 0, viewers));
}
changes.sort_unstable();
changes
}
#[cfg(test)]
mod tests {
use super::*;
@@ -208,6 +208,9 @@ fn not_drawn(m: &Manifest, switch: bool) -> Option<String> {
.into(),
);
}
if m.entity.is_some() {
return Some("the [entity] stage isn't drawn yet: leave it out".into());
}
(m.api != Api::V0_1 && !switch).then(|| {
format!(
"it is written for shader API {}, which is experimental and drawn only under \
@@ -359,6 +362,8 @@ mod tests {
why.contains("primaries = \"bt2020\" isn't served yet"),
"{why}"
);
let why = not_drawn(&manifest("0.2", "[entity]\nfile = \"e.wgsl\"\n"), switch);
assert!(why.unwrap().contains("[entity] stage isn't drawn yet"));
let named = manifest("0.2", "[frame]\nprimaries = \"bt709\"\n");
assert_eq!(not_drawn(&named, true), None);
}
+56 -12
View File
@@ -13,6 +13,7 @@ use super::entity::{Entity as _, entity_lists_of, for_each_entity, with_entity};
use super::{Dim, Game, Result, Screen, level_flags};
use horizon_client::fast_hash::FxHashMap;
use horizon_client::nether_portal::{ENTITY_DELAY, PortalState};
use horizon_client::world::PendingEntity;
use horizon_core::{
geometry::{Aabb, Axis, Vec3},
position::BlockPos,
@@ -74,10 +75,22 @@ impl Plan {
/// An entity on its way to a dimension that isn't loaded.
#[derive(Clone, Debug)]
struct Arrival {
/// Teleport-only state; deliberately absent from arrival_tag.
tnt_used_portal: bool,
plan: Plan,
tag: Tag,
}
impl Arrival {
fn new(plan: Plan, tag: Tag, tnt_used_portal: bool) -> Self {
Self {
plan,
tag,
tnt_used_portal,
}
}
}
/// The nether portal state of the world (`Game::portals`).
#[derive(Default)]
pub(super) struct Portals {
@@ -257,6 +270,7 @@ fn arrival_from_tag(a: &Tag) -> Option<Arrival> {
return None;
};
Some(Arrival {
tnt_used_portal: false,
plan: Plan {
dim,
approximate: BlockPos::new(x, y, z),
@@ -531,6 +545,7 @@ impl Game<'_> {
self.tickets.add_portal(chunk);
}
}
/// `NetherPortalBlock.createDimensionTransition`: the arrival position
/// and the yaw turned by.
pub(super) fn arrival(&self, plan: &Plan, exit: FoundRectangle) -> (Vec3, f32) {
@@ -1098,7 +1113,7 @@ impl Game<'_> {
);
self.remove_dropped(i);
self.detach_entity(id);
self.portals.arrivals.push(Arrival { plan, tag });
self.portals.arrivals.push(Arrival::new(plan, tag, false));
self.portals.dirty = true;
} else if let Some(i) = self.tnts.iter().position(|t| t.id == id) {
let t = &self.tnts[i];
@@ -1106,10 +1121,13 @@ impl Game<'_> {
if !self.may_enter(plan.dim) {
return;
}
self.tnts[i].used_portal = true;
let tag = self.tnt_nbt(i);
self.tnts.remove(i);
let transferred = self.tnts.remove(i);
self.forget_entity(id);
self.portals.arrivals.push(Arrival { plan, tag });
self.portals
.arrivals
.push(Arrival::new(plan, tag, transferred.used_portal));
self.portals.dirty = true;
} else if let Some(i) = self.tridents.iter().position(|t| t.id == id) {
let plan = self.entity_plan(entry, self.tridents[i].position, 0.5, 0.5, false);
@@ -1121,7 +1139,7 @@ impl Game<'_> {
self.tridents[i].to_nbt(uuid, self.pack.assets.registry, self.persist_raw(id));
self.tridents.remove(i);
self.forget_entity(id);
self.portals.arrivals.push(Arrival { plan, tag });
self.portals.arrivals.push(Arrival::new(plan, tag, false));
self.portals.dirty = true;
} else if let Some(i) = self.rockets.iter().position(|r| r.id == id) {
let plan = self.entity_plan(entry, self.rockets[i].position, 0.25, 0.25, false);
@@ -1132,7 +1150,7 @@ impl Game<'_> {
let tag = self.rockets[i].to_nbt(uuid, self.level.player_uuid, self.persist_raw(id));
self.rockets.remove(i);
self.forget_entity(id);
self.portals.arrivals.push(Arrival { plan, tag });
self.portals.arrivals.push(Arrival::new(plan, tag, false));
self.portals.dirty = true;
} else if let Some(i) = self.orbs.iter().position(|o| o.id == id) {
let plan = self.entity_plan(entry, self.orbs[i].position, 0.5, 0.5, false);
@@ -1143,7 +1161,7 @@ impl Game<'_> {
let tag = horizon_client::entity_nbt::orb_to_nbt(&self.orbs[i], uuid);
self.orbs.remove(i);
self.forget_entity(id);
self.portals.arrivals.push(Arrival { plan, tag });
self.portals.arrivals.push(Arrival::new(plan, tag, false));
self.portals.dirty = true;
} else if let Some(i) = self.mobs.iter().position(|m| m.id == id) {
let m = &self.mobs[i];
@@ -1162,7 +1180,7 @@ impl Game<'_> {
for g in gone {
self.detach_entity(g);
}
self.portals.arrivals.push(Arrival { plan, tag });
self.portals.arrivals.push(Arrival::new(plan, tag, false));
self.portals.dirty = true;
} else if let Some(i) = self.boats.iter().position(|b| b.id == id) {
let b = &self.boats[i];
@@ -1182,7 +1200,7 @@ impl Game<'_> {
}
self.boats.remove(i);
self.detach_entity(id);
self.portals.arrivals.push(Arrival { plan, tag });
self.portals.arrivals.push(Arrival::new(plan, tag, false));
self.portals.dirty = true;
} else if let Some(i) = self.carts.iter().position(|c| c.id == id) {
let c = &self.carts[i];
@@ -1213,7 +1231,7 @@ impl Game<'_> {
}
self.carts.remove(i);
self.detach_entity(id);
self.portals.arrivals.push(Arrival { plan, tag });
self.portals.arrivals.push(Arrival::new(plan, tag, false));
self.portals.dirty = true;
} else if let Some(i) = self
.creatures
@@ -1248,7 +1266,7 @@ impl Game<'_> {
};
self.creatures.projectiles.remove(i);
self.forget_entity(id);
self.portals.arrivals.push(Arrival { plan, tag });
self.portals.arrivals.push(Arrival::new(plan, tag, false));
self.portals.dirty = true;
}
if self.portals.arrivals.len() > arrivals {
@@ -1323,7 +1341,11 @@ impl Game<'_> {
i += 1;
continue;
}
let Arrival { plan, mut tag } = self.portals.arrivals.remove(i);
let Arrival {
plan,
mut tag,
tnt_used_portal,
} = self.portals.arrivals.remove(i);
self.portals.dirty = true;
let motion = horizon_client::entity_nbt::motion(&tag);
let [yaw, pitch] = horizon_client::entity_nbt::rotation(&tag);
@@ -1359,6 +1381,11 @@ impl Game<'_> {
preserve_arrival_cooldown(&mut tag);
// ServerLevel.addDuringTeleport: a fresh add, not loaded from disk.
super::persist::mark_fresh(&mut tag);
let tag = if tnt_used_portal {
PendingEntity::after_tnt_portal(tag)
} else {
tag.into()
};
let key = ((at.x.floor() as i32) >> 4, (at.z.floor() as i32) >> 4);
match self
.world
@@ -1423,7 +1450,7 @@ impl Game<'_> {
if f.id != 0 {
self.forget_entity(f.id);
}
self.portals.arrivals.push(Arrival { plan, tag });
self.portals.arrivals.push(Arrival::new(plan, tag, false));
self.portals.dirty = true;
Some(plan.dim)
}
@@ -1598,6 +1625,20 @@ impl Game<'_> {
mod tests {
use super::*;
#[test]
fn portal_transfer_pending_arrival_does_not_save_transient_tnt_immunity() {
let arrival = Arrival {
plan: Plan::end_portal(Dim::End, BlockPos::new(0, 64, 0), 0.98, 0.98),
tag: Tag::compound([("id", Tag::String("minecraft:tnt".into()))]),
tnt_used_portal: true,
};
assert!(
!arrival_from_tag(&arrival_tag(&arrival))
.unwrap()
.tnt_used_portal
);
}
#[test]
fn end_portals_lead_to_the_end_spawn_and_back_to_the_world_spawn() {
let spawn = BlockPos::new(-7, 70, 12);
@@ -1677,6 +1718,7 @@ mod tests {
let arrival = Arrival {
plan: Plan::end_portal(Dim::Overworld, BlockPos::new(0, 64, 0), 1.375, 0.5625),
tag,
tnt_used_portal: false,
};
let restored = arrival_from_tag(&arrival_tag(&arrival)).unwrap();
let expected = old.filter(|n| *n > 0).unwrap_or(ENTITY_DELAY);
@@ -1692,6 +1734,7 @@ mod tests {
let tag = Tag::compound([("id", Tag::String("minecraft:cow".into()))]);
for (from, dim) in [(Dim::Overworld, Dim::End), (Dim::End, Dim::Overworld)] {
let arrival = Arrival {
tnt_used_portal: false,
plan: Plan::end_portal(from, BlockPos::new(3, 64, -5), 0.9, 1.4),
tag: tag.clone(),
};
@@ -1703,6 +1746,7 @@ mod tests {
}
// Arrivals saved before End portals were Nether portal plans.
let mut old = arrival_tag(&Arrival {
tnt_used_portal: false,
plan: Plan::end_portal(Dim::End, BlockPos::new(0, 64, 0), 0.25, 0.25),
tag,
});
+2
View File
@@ -126,6 +126,8 @@ impl Game<'_> {
.map(super::native_hooks::mob_kind)
.unwrap_or_default();
self.native_entity_enter_love_mode(id, &kind, self.lan_acting_uuid);
// Animal.setInLove: entity event 18, the love hearts.
self.mob_event_particles(id, 18);
}
self.consume_held();
if let Some(stack) = dropped {
+38 -22
View File
@@ -7,6 +7,7 @@ mod display;
mod hurt_by_refs;
use super::{FallingBlock, Game};
use horizon_client::world::EntityLoadReason;
use horizon_client::{
Result,
chunk::Chunk,
@@ -534,7 +535,7 @@ impl Game<'_> {
let player_uuid = self.level.player_uuid;
for (key, tags) in loaded {
let count = tags.len();
for mut tag in tags {
for (mut tag, reason) in tags.into_iter().map(|pending| pending.into_parts()) {
// A world older builds named by the offline profile names the player that
// way in its entities too: migrate them as they load, so the next save of
// the chunk keeps the account's UUID (`playerdata::migrate_owner`).
@@ -591,7 +592,16 @@ impl Game<'_> {
}
// Kinds outside `entity_lists!` first, then the listed kinds
// (`Entity::load_nbt`), then whatever else is a compound.
if let Err(tag) = self.load_unlisted(key, &kind, uuid, tag)
let loaded = if reason == EntityLoadReason::TeleportedTnt
&& kind == "minecraft:tnt"
&& !is_mob
{
self.load_tnt_with_portal(tag, uuid, true);
Ok(())
} else {
self.load_unlisted(key, &kind, uuid, tag)
};
if let Err(tag) = loaded
&& let Err(tag) = self.load_listed(&kind, uuid, player_uuid, tag)
{
if !kind.is_empty() && entity_nbt::position(&tag).is_some() {
@@ -852,6 +862,9 @@ impl Game<'_> {
/// `PrimedTnt.readAdditionalSaveData`: its numeric short fuse (80 when absent) and motion.
// Its Passengers ride it (EntityType.loadEntityRecursive forces them on).
pub(super) fn load_tnt(&mut self, tag: Tag, uuid: Option<[i32; 4]>) {
self.load_tnt_with_portal(tag, uuid, false);
}
fn load_tnt_with_portal(&mut self, tag: Tag, uuid: Option<[i32; 4]>, used_portal: bool) {
if uuid.is_some_and(|u| self.target_with_uuid(u).is_some()) {
return;
}
@@ -868,6 +881,7 @@ impl Game<'_> {
// to int before short narrowing; integer tags narrow directly.
let fuse = tnt_fuse(&tag);
self.tnts.push(super::tnt::PrimedTnt {
used_portal,
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: entity_nbt::flag(&tag, "NoGravity"),
@@ -1370,7 +1384,7 @@ impl Game<'_> {
{
entity_nbt::fill_common_defaults(&mut tag);
}
tag
with_raw(tag, None) // Entity.saveWithoutId's flags, as booleans.
}
/// The root of a stack of riders (`Entity.getRootVehicle`).
fn mob_root(&self, mut i: usize) -> usize {
@@ -1500,7 +1514,7 @@ impl Game<'_> {
(at.z.floor() as i32).div_euclid(16),
);
self.persist.restored.insert(root_uuid);
self.world.loaded_entities.push((key, vec![tag]));
self.world.queue_entity_nbt(key, vec![tag]);
self.spawn_loaded_entities();
}
self.persist.restored.insert(uuid);
@@ -2998,7 +3012,7 @@ mod tests {
}
// The level is a server's, whose attachments a mod reaches.
game.options.dedicated = Some(Vec::new());
game.world.loaded_entities.push(((0, 0), tags));
game.world.queue_entity_nbt((0, 0), tags);
game.spawn_loaded_entities();
let saved = game.entity_chunks(&HashSet::from([(0, 0)]), false);
let saved: Vec<&Tag> = saved.iter().flat_map(|(_, tags)| tags).collect();
@@ -3088,7 +3102,7 @@ mod tests {
.filter(|c| c.potion.is_some())
.count()
};
game.world.loaded_entities.push(((0, 0), vec![cloud]));
game.world.queue_entity_nbt((0, 0), vec![cloud]);
game.spawn_loaded_entities();
assert_eq!(clouds(&game), 1, "the cloud loads");
assert_eq!(game.breath_clouds[0].motion, [0.6, 0.0, 0.0]);
@@ -3100,7 +3114,7 @@ mod tests {
Vec3::new(0.6, 0.0, 0.0)
);
assert_eq!(clouds(&game), 0, "and takes it out of the level");
game.world.loaded_entities.push(((0, 0), written));
game.world.queue_entity_nbt((0, 0), written);
game.spawn_loaded_entities();
assert_eq!(clouds(&game), 1, "loading the chunk again gives one cloud");
assert_eq!(game.breath_clouds[0].motion, [0.6, 0.0, 0.0]);
@@ -4258,6 +4272,7 @@ mod attribute_instance_save_tests {
assert!(game.run_command("forceload add 0 0"));
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(2.5, 200.0, 2.5)).unwrap());
game.creative = true;
game.load_ticking_neighbourhood(0, 0);
test(&mut game);
}
fn mob(game: &Game<'_>, n: i32) -> usize {
@@ -5276,6 +5291,7 @@ mod h262_enderman_absolute_anger_tests {
game.run_command("summon pig 13.5 200 2.5 {UUID:[I;262,0,0,99],NoAI:1b,NoGravity:1b}")
);
game.world.random.set_seed(262);
game.load_ticking_neighbourhood(0, 0);
test(&mut game);
}
fn load(game: &mut Game<'_>, n: i32, end: Option<i64>) -> (u32, usize) {
@@ -5511,6 +5527,7 @@ mod sulfur_body_attribute_tests {
assert!(game.run_command("scoreboard objectives add h192 dummy"));
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(2.5, 200.0, 2.5)).unwrap());
game.creative = true;
game.load_ticking_neighbourhood(0, 0);
test(&mut game);
}
/// `/attribute <cube> <attribute> get 1000`, read back through a score.
@@ -5536,9 +5553,9 @@ mod sulfur_body_attribute_tests {
(2, ""),
] {
assert!(game.run_command(&format!(
"summon minecraft:sulfur_cube {}.5 200 4.5 {{UUID:[I;192,0,0,{n}],NoAI:1b,NoGravity:1b,Silent:1b,PersistenceRequired:1b,Size:1{body}}}",
3 * n
)));
"summon minecraft:sulfur_cube {}.5 200 4.5 {{UUID:[I;192,0,0,{n}],NoAI:1b,NoGravity:1b,Silent:1b,PersistenceRequired:1b,Size:1{body}}}",
3 * n
)));
}
let attrs = [
"knockback_resistance",
@@ -5582,8 +5599,8 @@ mod sulfur_body_attribute_tests {
fn a_bodiless_cube_keeps_its_command_knockback_resistance_effect() {
with_game(|game| {
assert!(game.run_command(
"summon minecraft:sulfur_cube 4.5 200 8.5 {UUID:[I;192,0,0,9],NoAI:1b,NoGravity:1b,Silent:1b,PersistenceRequired:1b,Size:1}"
));
"summon minecraft:sulfur_cube 4.5 200 8.5 {UUID:[I;192,0,0,9],NoAI:1b,NoGravity:1b,Silent:1b,PersistenceRequired:1b,Size:1}"
));
let sel = "@e[type=minecraft:sulfur_cube,nbt={UUID:[I;192,0,0,9]},limit=1]";
let cached = |game: &Game<'_>| {
let m = game
@@ -5650,9 +5667,9 @@ mod sulfur_body_attribute_tests {
(11, ",equipment:{body:{id:\"minecraft:oak_planks\"}}"),
] {
assert!(game.run_command(&format!(
"summon minecraft:sulfur_cube {}.5 200 8.5 {{UUID:[I;192,0,0,{n}],NoAI:1b,NoGravity:1b,Silent:1b,PersistenceRequired:1b,Size:1{body}}}",
n - 8
)));
"summon minecraft:sulfur_cube {}.5 200 8.5 {{UUID:[I;192,0,0,{n}],NoAI:1b,NoGravity:1b,Silent:1b,PersistenceRequired:1b,Size:1{body}}}",
n - 8
)));
}
game.tick_world(true).unwrap();
let cached = |game: &Game<'_>, n: i32| {
@@ -5765,6 +5782,7 @@ mod summoned_knot_tests {
game.run_command("summon pig 13.5 200 2.5 {UUID:[I;129,0,0,99],NoAI:1b,NoGravity:1b}")
);
game.world.random.set_seed(129);
game.load_ticking_neighbourhood(0, 0);
test(&mut game);
}
fn knot_id(game: &Game<'_>, n: i32) -> u32 {
@@ -5895,12 +5913,8 @@ mod summoned_knot_tests {
#[ignore = "prepared resources; negative fractional registered position"]
fn negative_fractional_knot_position_uses_floor_before_centering() {
with_game(|game| {
game.world
.chunks
.insert((-1, -1), Arc::new(Chunk::empty(-1, -1)));
game.block_ticks
.add_empty_chunk(ChunkPos::new(-1, -1))
.unwrap();
// Chunk (-1, -1) is already loaded by the fixture's neighbourhood.
assert!(game.world.chunks.contains_key(&(-1, -1)));
assert!(game.run_command("setblock -2 199 -3 stone strict"));
assert!(game.run_command("setblock -2 200 -3 oak_fence strict"));
assert!(game.run_command("summon leash_knot -1.2 200.8 -2.1 {UUID:[I;129,0,0,5]}"));
@@ -6127,7 +6141,9 @@ mod camel_pose_saved_tests {
.into_iter()
.flat_map(|(_, tags)| tags)
.collect();
game.world.loaded_entities.push(((0, 0), written));
game.world
.loaded_entities
.push(((0, 0), written.into_iter().map(Into::into).collect()));
game.spawn_loaded_entities();
for step in 0..8 {
for number in 1..=cases.len() as i32 {
@@ -1498,6 +1498,7 @@ mod h255_mob_piston_ground_tests {
.unwrap();
}
}
game.load_ticking_neighbourhood(0, 0);
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(2.5, 200.0, 2.5)).unwrap());
game.creative = true;
assert!(game.run_command("fill 0 199 0 31 199 31 stone"));
@@ -112,19 +112,21 @@ impl Game<'_> {
equipped.push((self.inventory[self.selected].as_ref(), "mainhand"));
equipped.push((self.offhand.as_ref(), "offhand"));
for (stack, slot) in equipped {
// ItemStack.forEachModifier: the stack's own ATTRIBUTE_MODIFIERS
// (its item's unless a command or a mod replaced them, #14641).
let Some(stack) = stack else {
continue;
};
let modifiers = world_items!(self).attribute_modifiers(stack);
for (attribute, id, amount, op, group) in &modifiers {
if !equipment_modifier_applies(group, slot) {
for (attribute, id, amount, op, group) in world_items!(self).attribute_modifiers(stack)
{
if !equipment_modifier_applies(&group, slot) {
continue;
}
if player
.set_modifier_unclamped(attribute, id, *amount, op, true)
.set_modifier_unclamped(&attribute, &id, amount, &op, true)
.is_ok()
{
self.applied_modifiers.push((attribute.clone(), id.clone()));
self.applied_modifiers.push((attribute, id));
}
}
}
@@ -35,6 +35,7 @@ fn mobs_past_the_simulation_distance_neither_enter_portals_nor_cool_down() {
game.world
.chunks
.insert((cx, 0), Arc::new(Chunk::empty(cx, 0)));
game.load_ticking_neighbourhood(cx, 0);
for z in [8, 12] {
game.world.set(x, 63, z, stone).unwrap();
}
Loaded 100 of 274 files, more files were not shown because too many files have changed in this diff. Show more