Compare commits

...
Author SHA1 Message Date
rdbtCVSandClaude Sonnet 5.5 34fadce9b8 Mob islands: villagers draw their own random under the parallel tick, and ordered waits sleep
A mob's first draw on the ordered level random made every lane wait for all earlier mobs, and in a farm-shaped
level all of those first draws were villager brains. With HORIZON_PARALLEL_TICK on, a villager's brain now draws
from a private random seeded from its own entity random (serial and grouped alike, so grouped still equals serial).
Ordered waits spin, yield, then sleep on a condvar. Adds pm_shaped_bench.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-13 03:26:46 +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
Ludovic Marie f24ee1accf Merge pull request #15361 from UMCEKO/perf-platform
🤖 Agent: queued for merge at Ludovic's request.
2026-11-12 12:12:30 +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
umcekoandClaude Opus 5.5 327daa3899 Merge origin/main into perf-platform
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 13:12:23 +03:00
Kacper ba91f75cb2 Merge pull request #13731 from TheApple05/fix/13666-chest-boat-loot
Fix chest boat loot for hopper transfers and destruction
2026-11-12 11:08:09 +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
Kacper 5164ada2a6 Merge branch 'main' into fix/13666-chest-boat-loot 2026-11-12 10:36:41 +01:00
Kacper a21f8a3947 Merge pull request #14047 from MrCloudy2/fix/wind-charge-no-deflect-ticks
A player's wind charge can't be turned in its first five ticks
2026-11-12 10:36:19 +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
Kacper f0811d3363 Merge pull request #14598 from ilguerro/perf/faster-client-lib-tests
Load the prepared test resources once per test process
2026-11-12 09:50:51 +01:00
Kacper c543cb8689 Merge pull request #14596 from Ragbie/fix/orb-fall-distance
Experience orbs keep their fall distance and make a vibration when they land
2026-11-12 09:50:34 +01:00
Kacper 4b65ec8a0d Merge pull request #13578 from NikitaMGrimm/hdr-one-peak
HDR float frame: one peak, the world mastered for 1000 nits
2026-11-12 09:49:46 +01:00
Kacper bb8a15db1e Merge pull request #15359 from Akirabane/fix/guest-invalid-entity-attack-kick
A guest who attacks an item, an orb, themselves or an arrow is disconnected, as handleAttack does
2026-11-12 09:39:18 +01:00
Kacper 555b885429 Merge pull request #14860 from vermingov/fix/cloud-fall-distance-14853
An area effect cloud keeps the fall_distance it was loaded with (#14853)
2026-11-12 09:38:57 +01:00
Kacper 722e63500a Merge pull request #14848 from vermingov/fix/trident-fall-distance-14846
A trident keeps the fall_distance it was loaded with (#14846)
2026-11-12 09:38:40 +01:00
Kacper f1708966d5 Merge pull request #14612 from Solenopsisbot/t3/nameplate-distance-attributes
Client: honor synced nameplate distances
2026-11-12 09:38:23 +01:00
Kacper 7b45eb9614 Merge pull request #13962 from horizon-rust/split/telemetry-probes
horizon-system: the process probes leave horizon-bench (layer plan M5)
2026-11-12 09:33:41 +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
Kacper 911aa6ddc6 Merge pull request #14904 from vermingov/fix/collar-colour-14897
A wolf's or cat's CollarColor outside 0..15 reads white (#14897)
2026-11-12 09:27:07 +01:00
Kacper 5816e84e56 Merge pull request #14822 from DUzzL/fix/14800-arrow-air
An arrow loads and keeps its saved Air
2026-11-12 09:26:50 +01:00
Kacper 1cd6c70899 Merge pull request #14771 from zenith600/launcher/version-file-without-game
Launcher: a folder with 26.3's version file but not its game says to play 26.3 once
2026-11-12 09:26:32 +01:00
Kacpherek d78f0c49c9 Merge main into fix/evoker-fangs-fall-distance-14847 2026-11-12 09:21:51 +01:00
Kacper eb084c5152 Merge pull request #15058 from ilguerro/fix/fox-village-shade-defend-trusted
Keep foxes out of the shade in villages and defending the players they trust
2026-11-12 09:20:15 +01:00
Kacper aa3483bf5a Merge branch 'main' into perf/faster-client-lib-tests 2026-11-12 09:15:37 +01:00
Kacper 23678db4f7 Merge branch 'main' into fix/orb-fall-distance 2026-11-12 09:15:28 +01:00
Kacper a97725d63c Merge branch 'main' into fix/wind-charge-no-deflect-ticks 2026-11-12 09:15:09 +01:00
Kacper 0ce3cf8624 Merge branch 'main' into hdr-one-peak 2026-11-12 09:14:54 +01:00
Kacper aa548b0a41 Merge branch 'main' into fix/guest-invalid-entity-attack-kick 2026-11-12 09:04:15 +01:00
Kacper cd4e73e270 Merge branch 'main' into fix/collar-colour-14897 2026-11-12 09:04:06 +01:00
Kacper d946b838fe Merge branch 'main' into fix/cloud-fall-distance-14853 2026-11-12 09:03:57 +01:00
Kacper b2d30693b8 Merge branch 'main' into fix/trident-fall-distance-14846 2026-11-12 09:03:48 +01:00
Kacper 8b13f108ef Merge branch 'main' into fix/14800-arrow-air 2026-11-12 09:03:38 +01:00
Kacper a15a3ea50c Merge branch 'main' into launcher/version-file-without-game 2026-11-12 09:03:29 +01:00
Kacper 5ddbaaab62 Merge branch 'main' into t3/nameplate-distance-attributes 2026-11-12 09:03:19 +01:00
Kacper 6c34352b0f Merge pull request #14931 from Detimes/perf-equipment-cache
Perf: convert a guest's equipment only when a stack changed
2026-11-12 08:52:49 +01:00
Kacper 52540d4ff2 Merge pull request #13830 from RINNECODER/up13/camera-armor-stand
Spectator camera: a server's camera on an armor stand holds the view
2026-11-12 08:52:32 +01:00
Kacper f671c935c2 Merge pull request #11190 from Ragbie/fix/tnt-minecart-explosion-power
TNT minecarts keep explosion_power and explosion_speed_factor, and blow with them
2026-11-12 08:52:14 +01:00
Kacper d0c091ae1d Merge branch 'main' into fix/13666-chest-boat-loot 2026-11-12 08:52:13 +01:00
Kacper 11e85c0411 Merge pull request #14784 from MSedgeMC/fix/port-error-tooltip
The LAN port box says why its number is wrong
2026-11-12 08:51:57 +01:00
Kacpherek 2dabcba7dc Merge main into fix/fox-village-shade-defend-trusted 2026-11-12 08:44:32 +01:00
Kacper c94cdb0c76 Merge pull request #14131 from MSedgeMC/fix/component-can_eat
Food and consumable components come from the stack
2026-11-12 08:41:33 +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
Kacper 8182a33000 Merge pull request #14943 from vermingov/fix/shulker-attach-face-14940
A shulker's AttachFace is narrowed to a byte before the face is picked (#14940)
2026-11-12 08:30:45 +01:00
Kacper dd90805814 Merge branch 'main' into fix/tnt-minecart-explosion-power 2026-11-12 08:30:16 +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
Kacper 0c1a839a02 Merge pull request #15192 from MSedgeMC/fix/ibeam
A hovered text field shows the I-beam cursor
2026-11-12 06:49:20 +01:00
Kacper 00f230d640 Merge pull request #13843 from v0ntr3n/fix/thunder-trap-random-streams
Use Level.randValue for thunder strike columns and trap horse RNG for scatter
2026-11-12 06:48:52 +01:00
Kacper 535211eb7e Merge pull request #13966 from horizon-rust/fix/warden-silent-unmount-wither-easy
Wardens: silent ones make no sound, forced riders get off to dig; withers' idle volleys wait for Normal
2026-11-12 06:47:41 +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
Kacper d4c59f8c7a Merge pull request #15021 from DUzzL/fix/15013-launcher-ipv4-fallback
launcher: fall back to IPv4 when IPv6 to Microsoft sign-in is dead
2026-11-12 06:34:08 +01: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
Kacper 82f042de20 Merge branch 'main' into split/telemetry-probes 2026-11-12 06:27:32 +01:00
Kacper c351efbab6 Merge branch 'main' into fix/warden-silent-unmount-wither-easy 2026-11-12 06:27:20 +01:00
Kacper 3054963dec Merge pull request #15289 from DUzzL/fix/15276-set-custom-data
Remove empty custom_data after set_custom_data item modifier
2026-11-12 06:27:02 +01:00
Kacper 25a8fab320 Merge branch 'main' into fix/ibeam 2026-11-12 06:26:03 +01:00
Kacper 194c5c2c6c Merge pull request #15217 from GIGABAIT93/docs/index-updated
docs: the index's `updated`, and the fields a reader may rely on (#10562)
2026-11-12 06:25:38 +01:00
Kacper bab03f04db Merge pull request #15170 from MSedgeMC/fix/loot-set-enchantments
Set Enchantments replaces the levels
2026-11-12 06:25:24 +01:00
Kacper 9a71098242 Merge pull request #15061 from DUzzL/fix/15057-egg-breed
Thrown eggs hatch the chicken variant of their item
2026-11-12 06:25:11 +01:00
Kacper 6d76e89de2 Merge pull request #15053 from ilguerro/fix/fox-eat-effects-panda-pickup
Give a fox that eats its food its effects, and credit a panda's pickup
2026-11-12 06:24:20 +01:00
Kacper 609981c352 Merge pull request #14740 from TiggerTakkyTTT/fix/wind-burst-ender-dragon
Fix Wind Burst knockback for the Ender Dragon
2026-11-12 06:24:05 +01:00
Kacper 3aeb206813 Merge pull request #14587 from MSedgeMC/fix/skeleton-never-freezes
A skeleton and a loaded sulfur cube never freeze
2026-11-12 06:23:52 +01:00
Kacper 9bd670d3d3 Merge pull request #14574 from voidsipp/split/11535-sulfur-portal-compile-fix-20261109
Preserve portal blocks in sulfur cube explosions
2026-11-12 06:23:39 +01:00
Kacper 63bd6c914d Merge pull request #14561 from ilguerro/fix/lectern-book-state
Lecterns take a placed book into has_book, gate the comparator on it and post block_change
2026-11-12 06:23:26 +01:00
Kacper ea787a658c Merge pull request #14553 from ilguerro/fix/adventure-sign-place-on
Place a can_place_on block against a sign in adventure mode
2026-11-12 06:23:14 +01:00
Kacper 14e27946ca Merge pull request #15410 from Web-mc1/fix/item-set-count-overflow
Guard item count arithmetic against i32 overflow
2026-11-12 06:20:31 +01:00
Kacper 9639526d6b Merge pull request #15360 from Web-mc1/fix/pvp-held-count-overflow
Guard held item count against u32 overflow
2026-11-12 06:20:18 +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
Kacper d9b9996a4d Merge pull request #14850 from DUzzL/fix/14806-fire-reset
A mob's negative Fire is reset to 0 on its first tick
2026-11-12 04:54:06 +01:00
Kacper a71f9d3bd3 Merge pull request #11507 from voidsipp/fix/shield-blocking-11338
Fix built-in shield piercing, use statistics and durability
2026-11-12 04:53:39 +01:00
Kacpherek 91e8f91fee Skeleton trap test: the horse's random is an EntityRandom 2026-11-12 04:52:33 +01: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 c6544a2311 Foxes do not flee wolves and polar bears while defending
Vanilla gates both avoidance goals on !isDefending(); a defending fox now goes on to the melee attack.
2026-11-12 04:49:41 +01:00
Kacpherek 63ad77a1da Send the drink game event for a fox's drinkable food
A consumable with the DRINK animation posts minecraft:drink instead of minecraft:eat, and the mob game-event forwarding now passes minecraft:drink on.
2026-11-12 04:48:53 +01:00
rdbtCVSandClaude Sonnet 5.5 440a7f0144 Format the held-count fold with rustfmt
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 03:47:32 +00:00
rdbtCVSandClaude Sonnet 5.5 96bb424a7f Format saturating item count arithmetic
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 03:47:18 +00:00
Kacpherek 5cdcbf9812 Accept libtest's singular banner for a one-test parity run 2026-11-12 04:47:15 +01:00
Kacpherek 3cc15c8ba0 Keep enchantments that set_enchantments does not name
Only the supplied enchantments are set, as vanilla does; the others on the stack stay.
2026-11-12 04:44:31 +01:00
Kacpherek 061ba23407 Cull particles by their current position instead of an interpolated one 2026-11-12 04:41:02 +01:00
Kacpherek e983ba70a5 Limit the never-freeze override to Skeleton; the bogged, parched and wither skeleton freeze 2026-11-12 04:39:52 +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 1a5bea076d Set a placed lectern's has_book before the item's block_state is applied 2026-11-12 04:35:28 +01:00
Kacpherek 672b1cae9a Test that an empty set_custom_data merge adds no custom_data component 2026-11-12 04:33:33 +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 9a2135d7b2 Decide whether a sign consumes the click before its commands run 2026-11-12 04:32:31 +01:00
Kacpherek beb77a48e0 Wrap the invalid-port tooltip to 170 pixels like vanilla 2026-11-12 04:31:33 +01:00
Kacpherek 8286150240 Record a moved block entity's load order under its corrected position 2026-11-12 04:31:30 +01:00
Kacper 2abe71ca92 Merge pull request #14589 from MSedgeMC/fix/drowned-fluid-push
A swimming drowned is not pushed by the current
2026-11-12 04:29:17 +01:00
Kacpherek d7a25b8aec Merge commit '571ef142c5' into unstick/14574
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
#	crates/horizon-client/src/game/explosion.rs
#	crates/horizon-client/src/game/nether_portal.rs
2026-11-12 04:28:26 +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
Detimes c6683ed9a2 Merge branch 'perf-equipment-cache' of https://github.com/Detimes/horizon into perf-equipment-cache 2026-11-12 03:21:14 +00:00
DetimesandClaude Opus 5.5 0a484606c9 Merge upstream/main into perf-equipment-cache
Keeps both new tests at the end of hub_tests.rs (main's teleport accept test and the equipment cache test).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 03:21:12 +00: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 98961ccd14 Merge commit '571ef142c5' into unstick/15061
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
#	crates/horizon-client/src/projectiles.rs
2026-11-12 04:16:58 +01:00
Kacper f787306fce Merge pull request #15457 from svetch/fix/26.3-seventh-republish
Prepare: accept Mojang's seventh 26.3 republish (9f67e10e, index e99f9973)
2026-11-12 04:16:20 +01:00
Kacper d8bf54d1e6 Merge pull request #14494 from vnsmoreira/fix/explosion-creaking-heart-xp
A player's blast awards a natural creaking heart's experience
2026-11-12 04:16:11 +01:00
Kacper f165e7fa29 Merge pull request #15419 from yahya-nusseibeh/fix/launcher-sign-in-bounds
Launcher: bound the sign-in services' interval and lifetimes
2026-11-12 04:16:01 +01:00
Kacper 9d73f6a26a Merge pull request #15151 from MSedgeMC/fix/chiseled-shelf-can-take
fix: a chiseled bookshelf only gives a book to a hopper with room
2026-11-12 04:15:48 +01:00
Kacpherek c2bead90c4 Workflow files as on main: this pull request changes the item count only 2026-11-12 04:14:31 +01:00
Kacpherek e470dac100 Merge remote-tracking branch 'origin/main' into takeover/15410 2026-11-12 04:14:20 +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
Kacper 4a60f12112 Merge pull request #13630 from MSedgeMC/fix/goat-long-jump
A goat keeps its long-jump cooldown in its chunk save
2026-11-12 04:09:39 +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
Kacper 192be7194f Merge pull request #15120 from MSedgeMC/port_map/farmland-is-near-water
port_map: FarmlandBlock.isNearWater is the water fluid search
2026-11-12 04:01:45 +01:00
Kacpherek 4ae80fa5ea Merge commit '571ef142c5' into unstick/14258
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-12 04:01:23 +01:00
Kacper 5f3caf9be4 Merge pull request #13881 from Skr1zzzz/split-renderer
Move building and adopting the renderer out of game.rs into game/render_settings.rs
2026-11-12 04:00:55 +01:00
Kacper 571ef142c5 Merge pull request #15477 from Ragbie/fix/stack-dump-windows-unused
horizon-threads: stack_dump's Unix-only code no longer fails clippy on Windows
2026-11-12 03:48:06 +01:00
Kacper 21845f7949 Merge pull request #14376 from Joansitoh/fix/14371-rod-neighbours
Lightning rod: a strike updates the front block's neighbours without redstone experiments
2026-11-12 03:46:31 +01:00
Kacper 27a4dc65c9 Merge pull request #14365 from oloion/perf/range-dispatch-binary-search
assets: avoid heap allocation in item model range dispatch binary search
2026-11-12 03:46:19 +01:00
Kacper deaab60ba9 Merge pull request #15418 from yahya-nusseibeh/fix/protocol-inflate-growth
Protocol: grow the inflate buffer instead of reserving the declared size
2026-11-12 03:38:58 +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
Kacper c2c3cea8e3 Merge pull request #15478 from Bl4ckspell7/refactor/gfx-census-module
Renderer: residency and memory figures move out of gfx.rs into gfx/census.rs
2026-11-12 03:33:14 +01:00
Kacper a5dbd2c5c1 Merge pull request #15240 from yahya-nusseibeh/fix/neoforge-version-path
Port-pack: only a plain NeoForge version makes the loader jar's path
2026-11-12 03:32:40 +01:00
Kacper 4c4de96fa5 Merge pull request #14760 from MSedgeMC/fix/walk-target-scores
A stroll scores its ground the way the jar does
2026-11-12 03:31:51 +01:00
Kacper c19aaf98e5 Merge pull request #14060 from MSedgeMC/fix/jigsaw-priority-tooltip
The jigsaw's priority boxes explain themselves on hover
2026-11-12 03:31:18 +01:00
umcekoandClaude Opus 5.5 7798130a18 Merge origin/main into perf-platform
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 05:24:24 +03:00
Kacper 30a7b86bc8 Merge pull request #15296 from rdbtCVS/fix/deep-end-boat-void
Prevent deeply fallen boats from crashing dedicated servers
2026-11-12 03:18:14 +01:00
Kacper 0e0f95efa6 Merge pull request #15281 from Akirabane/fix/lan-accept-teleport-twice
LAN: a second accept of the same teleport id disconnects the guest, as vanilla does
2026-11-12 03:17:57 +01:00
Kacper 483497ef1c Merge pull request #14695 from ilguerro/fix/cave-air-is-air
Count cave air as air for open-water fishing and the flying kick
2026-11-12 03:17:40 +01:00
Kacper f7ba023dc6 Merge pull request #15435 from Akirabane/refactor/move-model-parts
Move the model parts out of mobs.rs into mobs/model.rs
2026-11-12 03:16:54 +01:00
Kacper cad46c5ff5 Merge pull request #15414 from mentallyunstable-lab/docs/layers-exceptions-15408
docs(layers): regenerate today's exceptions from tools/layers.json
2026-11-12 03:16:29 +01:00
Kacper 654bed3a7f Merge pull request #15248 from yahya-nusseibeh/fix/port-pack-licences
Port-pack: licence text stays one comment line, and families match whole words
2026-11-12 03:16:17 +01:00
Kacper 6154ce2282 Merge pull request #15160 from MSedgeMC/fix/brightness-gate
Level: getMaxLocalRawBrightness answers 15 outside the +-30,000,000 gate
2026-11-12 03:15:55 +01:00
Kacper 5e331b277a Merge pull request #15101 from angel2rider/tools/fixtures-ask-for-work-dir
tools: the command fixtures ask for their work directory instead of crashing
2026-11-12 03:15:33 +01:00
Kacper 96712da3cb Merge pull request #15091 from angel2rider/tools/port-ledger-json-after-query
tools: port_ledger.py accepts --json after the query's name, as its own help says
2026-11-12 03:15:22 +01:00
Kacper 755ba0b9a5 Merge pull request #15071 from MSedgeMC/fix/batch11-c
A tamed cat stops fleeing players
2026-11-12 03:15:11 +01:00
Kacper 29223492fc Merge pull request #15278 from DUzzL/fix/15270-alt-space
Swallow SC_KEYMENU so Alt+Space does not open the Windows system menu
2026-11-12 03:15:01 +01:00
Kacper d5f9e06dac Merge pull request #14902 from MSedgeMC/fix/chat-scroll-keeps-place
Changing the Chat option keeps the reader's place
2026-11-12 03:14:36 +01:00
Kacper 26d4f3f273 Merge pull request #14160 from ilguerro/fix/item-void-floor
Discard dropped items below each dimension's own void floor
2026-11-12 03:13:32 +01:00
Kacper fc71cbfeed Merge pull request #13755 from MSedgeMC/fix/follow-invisible
Follow-mob goals ignore invisible mobs
2026-11-12 03:13:02 +01:00
Kacpherek fcf7c70c83 Merge commit '24b778c3b4' into unstick/14131
# Conflicts:
#	crates/horizon-client/src/item_sync.rs
2026-11-12 02:58:34 +01:00
Kacpherek be34bdfd3c Merge commit '24b778c3b4' into unstick/14589
# Conflicts:
#	crates/horizon-client/src/mobs/living_tests.rs
2026-11-12 02:57:18 +01:00
Kacper 09d748f19c Merge branch 'main' into fix/shulker-attach-face-14940 2026-11-12 02:55:48 +01:00
Kacpherek 0cc70624e2 Merge commit '24b778c3b4' into unstick/14663
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-12 02:55:10 +01:00
Kacper 0d55f3075b Merge pull request #15190 from MSedgeMC/fix/chunk-empty-sections
Chunk storage: a chunk without a sections list loads as an empty chunk
2026-11-12 02:54:55 +01:00
Kacpherek e72b69caca Merge commit '24b778c3b4' into unstick/14494
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-12 02:54:54 +01:00
Kacpherek 166e09f55e Merge commit '24b778c3b4' into unstick/14587
# Conflicts:
#	tools/port_map/game-006.tsv
2026-11-12 02:52:49 +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
Kacper 24b778c3b4 Merge pull request #14173 from DUzzL/fix/14071-full-inventory-pickup
Sync a guest's refilled stack after a predicted drop
2026-11-12 02:45:28 +01:00
Kacper e1895229b8 Merge pull request #14499 from vnsmoreira/fix/explosion-player-xp
A player's blast pops the experience of what it breaks
2026-11-12 02:38:08 +01:00
Kacper cf79b811ac Merge pull request #13752 from DUzzL/fix/13665-crafted-stat
Award the crafted statistic for crafting-table and inventory-grid takes
2026-11-12 02:37:45 +01:00
Kacper 2b86defaa7 Merge pull request #13749 from MSedgeMC/fix/equippable-damage-on-hurt
Armor damage_on_hurt comes from the stack's equippable component
2026-11-12 02:37:35 +01:00
rdbt 0da40f990f Merge pull request #15493 from rdbtCVS/perf/mob-islands-to-main
Mobs: land the parallel mob islands on main (#15056 merged into the stack base)
2026-11-12 01:32:29 +00:00
Kacper d63ae80019 Merge pull request #15330 from Akirabane/fix/snow-golem-stroll-probability
A snow golem strolls with vanilla's rare probability, 1.0000001E-5
2026-11-12 02:26:31 +01:00
Kacper cd5c829460 Merge pull request #15318 from Akirabane/fix/new-world-folder-name
Create New World: the folder name is found as vanilla's findResultFolder finds it
2026-11-12 02:26:20 +01:00
Kacper 14ff11b96b Merge pull request #14651 from senjinthedragon/boat-passenger-water
Mobs: a mob sitting in a boat on water is not in the water, so a drowned or zombie in a boat burns in daylight
2026-11-12 02:25:43 +01:00
rdbtCVS 12bbd34fe5 Merge remote-tracking branch 'origin/main' into land-islands 2026-11-12 01:24:55 +00:00
Kacper 4a015bfb7c Merge pull request #15244 from yahya-nusseibeh/fix/servers-dat-lock
Client: hold the servers.dat lock on every save, each process with its own temp file
2026-11-12 02:24:25 +01:00
Kacper aaad7c57c1 Merge pull request #15237 from yahya-nusseibeh/fix/windows-console-close
Server: on Windows, wait for the clean stop when the console closes
2026-11-12 02:24:09 +01:00
Kacpherek 61c43e5109 Merge commit 'aab654d4e8' into unstick/11507
# Conflicts:
#	crates/horizon-client/src/game/dispense_fixture.rs
#	crates/horizon-client/src/game/shield.rs
2026-11-12 02:19:15 +01:00
Kacpherek 10721b288e Merge commit 'aab654d4e8' into unstick/14848
# Conflicts:
#	crates/horizon-client/src/entity_nbt/trident.rs
#	crates/horizon-client/src/game/trident.rs
2026-11-12 02:17:22 +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 be9a3c000f Merge commit 'aab654d4e8' into unstick/14822
# Conflicts:
#	crates/horizon-client/src/entity_nbt/projectile_fields.rs
2026-11-12 02:16:25 +01:00
Kacpherek 82a17c59ae Merge commit 'aab654d4e8' into unstick/14695
# Conflicts:
#	crates/horizon-client/src/lan/hub_tests.rs
2026-11-12 02:15:37 +01:00
Kacper 646722c466 Merge pull request #15290 from DUzzL/fix/15275-broken-stat
Count the broken statistic for every equipment break
2026-11-12 02:14:29 +01:00
Kacper 02f9ee4629 Merge pull request #14431 from Mayonesu0/up-13175-forced-chunk-ring
Rank a forced chunk's ring around it so it ticks on the first tick (#13175)
2026-11-12 02:14:03 +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
Kacper 902c147f8e Merge pull request #15231 from yahya-nusseibeh/fix/friends-try-every-endpoint
Friends: try every endpoint that answers before giving up on a direct join
2026-11-12 02:13:39 +01:00
Kacper 92166e6236 Merge pull request #14914 from vermingov/fix/axolotl-variant-14907
An axolotl with an unknown Variant id is lucy, not blue (#14907)
2026-11-12 02:13:22 +01:00
Kacpherek eb6536a832 Merge commit 'aab654d4e8' into unstick/14499
# Conflicts:
#	crates/horizon-client/src/game/explosion.rs
2026-11-12 02:13:10 +01:00
Kacper cf26b92d45 Merge pull request #11548 from socksprox/items/destroy-progress
Move the mining speed formula into horizon-items
2026-11-12 02:13:04 +01:00
Kacpherek bb9d4c2467 Merge commit 'aab654d4e8' into unstick/14860
# Conflicts:
#	crates/horizon-client/src/entity_nbt.rs
2026-11-12 02:13:03 +01:00
Kacpherek d9d89df9d8 Merge commit 'aab654d4e8' into unstick/14494
# Conflicts:
#	crates/horizon-client/src/game/explosion.rs
2026-11-12 02:12:20 +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 5a5d9f780c Merge commit 'aab654d4e8' into unstick/14131
# Conflicts:
#	crates/horizon-client/src/game/using_item.rs
2026-11-12 02:10:09 +01:00
Kacpherek 687caa60c4 Merge commit 'aab654d4e8' into unstick/13755
# Conflicts:
#	crates/horizon-client/src/mobs.rs
2026-11-12 02:08:55 +01:00
Kacpherek 9503016e93 Merge commit 'aab654d4e8' into unstick/13752
# Conflicts:
#	crates/horizon-client/src/menu/place/transfer_all_tests.rs
2026-11-12 02:07:56 +01:00
Kacpherek 344bcdec9a Merge commit 'aab654d4e8' into unstick/15021
# Conflicts:
#	launcher/src/account/tests.rs
2026-11-12 02:07:33 +01:00
Kacpherek cce82d001c Merge commit 'aab654d4e8' into unstick/13749
# Conflicts:
#	crates/horizon-client/src/item_sync.rs
2026-11-12 02:06:57 +01:00
Kacpherek f9d2eddb83 Merge commit 'aab654d4e8' into unstick/15190
# Conflicts:
#	crates/horizon-world/src/chunk.rs
2026-11-12 02:06:21 +01:00
Kacpherek d66d48d9a3 Merge commit 'aab654d4e8' into unstick/15278
# Conflicts:
#	crates/horizon-threads/src/lib.rs
2026-11-12 02:05:59 +01:00
Kacpherek 18415b2fbd Merge commit 'aab654d4e8' into unstick/11190
# Conflicts:
#	crates/horizon-client/src/game/minecarts/nbt.rs
2026-11-12 02:05:06 +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
Kacper aab654d4e8 Merge pull request #15314 from Akirabane/docs/port-map-game-012-fixed-gaps
port map: four rows of game-012.tsv now say what live goat and fox spawns do
2026-11-12 02:00:27 +01:00
Kacper b7eeb79a33 Merge pull request #15051 from ilguerro/fix/beacon-payment-order-tooltips
Beacon: pay emerald before diamond and tooltip every button
2026-11-12 02:00:15 +01:00
Kacper db7b03ffda Merge pull request #14571 from voidsipp/fix/11318-end-gateway-cooldowns
Use entity-specific End gateway cooldowns
2026-11-12 02:00:00 +01:00
Kacper e27a62210c Merge pull request #14373 from Joansitoh/fix/14368-spawnable-pos
Check only the spawnable bounds for /spawnpoint and /setworldspawn
2026-11-12 01:59:50 +01:00
umcekoandClaude Opus 5.5 474e9eb4c8 Perf: the entity context lines live in the per-mob and per-projectile helpers
creatures.rs may not grow after main moved the mob loop out; end_mob clears the mob's context, Projectile::tick sets its own, and the fangs start with none.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 03:59:29 +03:00
Kacper 06e9cfe6da Merge pull request #14010 from ilguerro/fix/hoglin-pacified-breeding
Keep mobs/ai.rs from growing: the hoglin check moves to piglin.rs
2026-11-12 01:58:54 +01:00
Kacper b6941b78f2 Merge branch 'main' into fix/snow-golem-stroll-probability 2026-11-12 01:55:53 +01:00
Kacper d64883cef3 Merge branch 'main' into fix/lan-accept-teleport-twice 2026-11-12 01:55:43 +01:00
Kacper 7ab3a23f95 Merge branch 'main' into fix/new-world-folder-name 2026-11-12 01:55:34 +01:00
umcekoandClaude Opus 5.5 9fd3efbf81 Merge origin/main into perf-platform-clean
The mob loop moved into creatures/mob_group.rs; its per-mob sample takes start_mob/end_mob there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 03:53:38 +03:00
Kacper 07050a34bd Merge pull request #15420 from yahya-nusseibeh/fix/launcher-data-link-above-root
Launcher: follow links above the data folder when moving it
2026-11-12 01:48:21 +01:00
Kacper 9054341b05 Merge pull request #15417 from AlfaRojo/fix/fishing-hook-harness-peaceful
fishing_hook_entities: run the servers in peaceful
2026-11-12 01:48:02 +01:00
Kacper 30db6aa7fd Merge pull request #14473 from voidsipp/split/11469-damage-source-foundation
Preserve explicit explosion damage sources
2026-11-12 01:46:47 +01:00
Kacper 663e7457ab Merge pull request #14260 from ilguerro/fix/void-floor-per-dimension
Start the void 64 blocks below each level's own floor
2026-11-12 01:45:31 +01:00
Kacper 2b375bf38c Merge pull request #15421 from vnsmoreira/packs/core-provided-blocks
Core shaders: blocks and samplers a pipeline provides, and blocks on one line
2026-11-12 01:36:40 +01:00
Kacper 172cec105a Merge pull request #15201 from DUzzL/fix/15134-flame-particles
Particles face the frame's camera, not the player's yaw and pitch
2026-11-12 01:36:24 +01:00
Kacpherek 45f36d84e1 Keep reset_unlit_fire before control.rs's test module 2026-11-12 01:36:18 +01:00
rdbt 5b28083ef3 Merge pull request #15481 from rdbtCVS/server/engine-parallel-tick
Server: [engine] parallel_tick turns the parallel tick on from server.toml
2026-11-12 00:36:18 +00:00
Kacper 3072b90817 Merge pull request #14885 from vermingov/fix/fireball-power-byte-14883
A fireball narrows a loaded ExplosionPower to a byte (#14883)
2026-11-12 01:36:07 +01:00
rdbt 02c7045ff6 Merge pull request #15056 from rdbtCVS/perf/mob-islands-finish
Mobs: parallel mob islands with ordered RNG and full-state verification (draft)
2026-11-12 00:36:06 +00:00
Solenopsisbot 8368def6fa Merge remote-tracking branch 'origin/main' into t3/nameplate-distance-attributes
# Conflicts:
#	crates/horizon-client/src/entities.rs
2026-11-12 11:35:51 +11:00
Kacper 4c29ce57c7 Merge pull request #13820 from RINNECODER/up12/ctm-chain-and-pass
OptiFine CTM: a picked tile chains to the rules naming it, and draws in its face's pass
2026-11-12 01:35:49 +01:00
rdbt 291b2c2b03 Merge pull request #14552 from rdbtCVS/perf/mob-loop-group
Mobs: the mob loop runs as a group of mobs, still on one thread (parallel mob tick, draft)
2026-11-12 00:35:30 +00:00
Kacpherek 10004fcd77 Merge remote-tracking branch 'origin/main' into unstick/14850 2026-11-12 01:35:00 +01:00
rdbt 9fa96cab6f Merge pull request #14531 from rdbtCVS/perf/ordered-draws
Random: one LegacyRandom stream drawn in list order from several threads (parallel mob tick, draft)
2026-11-12 00:34:44 +00:00
rdbt 892c64cc69 Merge pull request #14529 from rdbtCVS/perf/item-moves-ahead
Parallel tick: the dropped items' own moves worked out ahead on helpers
2026-11-12 00:34:14 +00:00
Joansitoh f8e59c3e3b Merge upstream main into fix/14371-rod-neighbours 2026-11-12 01:31:16 +01:00
Kacpherek 2106fb21d8 Camel pose test: saved entities go back in as pending entities 2026-11-12 01:30:39 +01:00
rdbtCVSandClaude Sonnet 5.5 6fad4fb984 Server: [engine] parallel_tick in server.toml turns the parallel tick on
A host with a fixed startup command (a game panel running `./horizon-server run`) can't set
HORIZON_PARALLEL_TICK. `[engine] parallel_tick = true` makes horizon-server start the game with the
variable set to 1, the way `low_memory` sets MIMALLOC_ALLOW_THP. Off leaves the variable alone, so one
already in the environment still applies.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 00:30:19 +00:00
Kacpherek 4752a44392 Merge remote-tracking branch 'origin/main' into unstick/14558 2026-11-12 01:29:04 +01:00
Kacper 32ed49b6d8 Merge pull request #15191 from MSedgeMC/fix/disabledlabel
A disabled button's label is grey
2026-11-12 01:24:41 +01:00
Kacper 35992267b9 Merge pull request #15150 from MSedgeMC/fix/jigsaw-local-edit
fix: a jigsaw edited in single player is applied
2026-11-12 01:24:24 +01:00
Kacper 95d025f4c2 Merge pull request #14566 from DUzzL/fix/14461-firework-dispenser-rotation
Turn a shot firework rocket to its motion like Projectile.shoot
2026-11-12 01:24:07 +01:00
Kacper 40b9d160c9 Merge pull request #13747 from MSedgeMC/fix/loom-dye-component
A loom reads a dye stack's dye component
2026-11-12 01:23:50 +01:00
Kacper 984872a0dd Merge pull request #15313 from AlfaRojo/fix/driven-mount-client-hurt
A driven horse takes no fall damage on its rider's client
2026-11-12 01:20:14 +01:00
Kacper 4d00052e33 Merge pull request #14179 from voduff/frames-projectile-hits
Projectiles hit item frames: the item comes out, then the frame breaks
2026-11-12 01:19:48 +01:00
rdbtCVSandClaude Sonnet 5.5 51b9a663bd Mobs: island tests wrap their random in EntityRandom after main's owner types
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 00:18:59 +00:00
rdbtCVS 76557beb69 Merge branch 'w-loop' into w-isl 2026-11-12 00:17:37 +00:00
rdbtCVSandClaude Sonnet 5.5 bf955abf30 Mobs: lend the level random to a group as a plain LegacyRandom after main's LevelRandom wrapper
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 00:16:49 +00:00
Kacper 94077f9e66 Merge branch 'main' into launcher/version-file-without-game 2026-11-12 01:15:59 +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
rdbtCVS 2d2ebb8fa5 Merge remote-tracking branch 'fork/perf/ordered-draws' into w-loop 2026-11-12 00:14:26 +00:00
rdbtCVSandClaude Sonnet 5.5 4aec48949f Merge perf/item-moves-ahead into perf/mob-loop-group
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 00:14:26 +00:00
rdbtCVSandClaude Sonnet 5.5 929928d3e6 Merge main into perf/ordered-draws
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 00:13:26 +00: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 1dcb48e1ec Merge branch 'main' into fix/26.3-seventh-republish 2026-11-12 01:12:39 +01:00
Kacper 79b225758e Merge branch 'main' into up-relbinary-save-reload 2026-11-12 01:12:21 +01:00
Kacper 80570541d0 Merge branch 'main' into fix/goat-long-jump 2026-11-12 01:11:14 +01:00
Kacper 475ea2f6a7 Merge branch 'main' into fix/thunder-trap-random-streams 2026-11-12 01:11:06 +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 a792e58186 Merge branch 'main' into perf/item-moves-ahead 2026-11-12 01:09:23 +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 cc15269fb6 Merge branch 'main' into fix/deep-end-boat-void 2026-11-12 01:07:37 +01:00
Kacper 1177119180 Merge branch 'main' into fix/15275-broken-stat 2026-11-12 01:07:28 +01:00
Kacper b0918fb06c Merge branch 'main' into fix/loot-set-enchantments 2026-11-12 01:07:18 +01:00
Kacper 702b9e5a6e Merge branch 'main' into docs/index-updated 2026-11-12 01:07:03 +01:00
Kacper 0bdbffccf7 Merge branch 'main' into fix/fox-village-shade-defend-trusted 2026-11-12 01:06:24 +01:00
Kacper e3d11137f6 Merge branch 'main' into fix/fox-eat-effects-panda-pickup 2026-11-12 01:06:15 +01:00
Kacper 2bfb755693 Merge branch 'main' into fix/beacon-payment-order-tooltips 2026-11-12 01:06:06 +01:00
Kacper 783c053601 Merge branch 'main' into fix/15276-set-custom-data 2026-11-12 01:05:54 +01:00
Kacper 1416eb94b9 Merge branch 'main' into fix/pvp-held-count-overflow 2026-11-12 01:05:46 +01:00
Kacper 76a0ded489 Merge branch 'main' into fix/item-set-count-overflow 2026-11-12 01:05:37 +01:00
Kacper 0e06fcffe8 Merge branch 'main' into fix/player-rotation-packet-parity 2026-11-12 01:05:30 +01:00
Kacper 44699b4c51 Merge branch 'main' into fix/protocol-inflate-growth 2026-11-12 01:05:19 +01:00
Kacper 22b2093962 Merge branch 'main' into fix/14806-fire-reset 2026-11-12 01:05:11 +01:00
Kacper 7a67cce201 Merge branch 'main' into t3/nameplate-distance-attributes 2026-11-12 01:04:46 +01:00
Kacper 11bda3ecc3 Merge branch 'main' into fix/port-error-tooltip 2026-11-12 01:04:43 +01:00
Kacper c187167717 Merge branch 'main' into perf/worldgen-noise-masks 2026-11-12 01:04:34 +01:00
Kacper c26677ed47 Merge branch 'main' into fix/13666-chest-boat-loot 2026-11-12 01:04:14 +01:00
Kacper 7a725b44e8 Merge branch 'main' into fix/wind-burst-ender-dragon 2026-11-12 01:04:06 +01:00
Kacper 09449b8b5a Merge branch 'main' into up-14641-stack-components 2026-11-12 01:03:49 +01:00
Kacper c9fbcbc9c0 Merge branch 'main' into fix/orb-fall-distance 2026-11-12 01:03:41 +01:00
Kacper 3f287372f4 Merge branch 'main' into boat-passenger-water 2026-11-12 01:03:40 +01:00
Kacper 3a44c61b5c Merge branch 'main' into fix/jukebox-song-reload 2026-11-12 01:03:26 +01:00
Kacper d3e0272348 Merge branch 'main' into fix/11318-end-gateway-cooldowns 2026-11-12 01:03:18 +01:00
Kacper e3d38ae5bd Merge branch 'main' into fix/lectern-book-state 2026-11-12 01:03:08 +01:00
Kacper adf711981d Merge branch 'main' into split/11535-pending-entity-provenance-20261109 2026-11-12 01:02:59 +01:00
Kacper 58cdf1b551 Merge branch 'main' into fix/adventure-sign-place-on 2026-11-12 01:02:51 +01:00
Kacper 818e0a229e Merge branch 'main' into split/11469-damage-source-foundation 2026-11-12 01:02:42 +01:00
Kacper b140490175 Merge branch 'main' into fix/vex-no-gravity 2026-11-12 01:02:37 +01:00
Kacper 09fa6403c5 Merge branch 'main' into up-13175-forced-chunk-ring 2026-11-12 01:02:33 +01:00
Kacper 7244070cdf Merge branch 'main' into fix/end-crystal-saved-fall 2026-11-12 01:02:28 +01:00
Kacper cad3d55f76 Merge branch 'main' into fix/14368-spawnable-pos 2026-11-12 01:02:24 +01:00
Kacper 0c03fe844b Merge branch 'main' into perf-equipment-cache 2026-11-12 01:02:19 +01:00
Kacper 05ed4ff36f Merge branch 'main' into tools/fall-clip-wait-neighbourhood 2026-11-12 01:02:15 +01:00
Kacper 0d85f3f65f Merge branch 'main' into fix/void-floor-per-dimension 2026-11-12 01:01:57 +01:00
Kacper ede16abf28 Merge branch 'main' into fix/guest-invalid-entity-attack-kick 2026-11-12 01:01:56 +01:00
Kacper 39046b18e8 Merge branch 'main' into fix/item-void-floor 2026-11-12 01:01:48 +01:00
Kacper 765595fc00 Merge branch 'main' into fix/thrown-potion-level-entity 2026-11-12 01:01:39 +01:00
Kacper 02b5729038 Merge branch 'main' into fix/wind-charge-no-deflect-ticks 2026-11-12 01:00:51 +01:00
Kacper 071685a3cf Merge branch 'main' into fix/raw-entity-flags 2026-11-12 01:00:35 +01:00
Kacper 370854a657 Merge branch 'main' into fix/explosion-creaking-heart-xp 2026-11-12 01:00:16 +01:00
Kacper 46d26691cf Merge branch 'main' into fix/explosion-player-xp 2026-11-12 01:00:07 +01:00
Kacper 713fe9176d Merge branch 'main' into packs/core-provided-blocks 2026-11-12 01:00:07 +01:00
Kacper 2e23fd1dd1 Merge branch 'main' into perf/faster-client-lib-tests 2026-11-12 00:59:58 +01:00
Kacper 66319c12e6 Merge branch 'main' into fix/launcher-data-link-above-root 2026-11-12 00:59:58 +01:00
Kacper 31b0e68219 Merge branch 'main' into fix/windows-console-close 2026-11-12 00:59:42 +01:00
Kacper 8bf7dd6dfe Merge branch 'main' into fix/15134-flame-particles 2026-11-12 00:59:34 +01:00
Kacper 5814b1cb5f Merge branch 'main' into fix/friends-try-every-endpoint 2026-11-12 00:59:25 +01:00
Kacper 59cd7697be Merge branch 'main' into fix/jigsaw-local-edit 2026-11-12 00:59:21 +01:00
Kacper 063632d5f5 Merge branch 'main' into fix/axolotl-variant-14907 2026-11-12 00:59:12 +01:00
Kacper 8424af6894 Merge branch 'main' into fix/fireball-power-byte-14883 2026-11-12 00:59:04 +01:00
Kacper a0d2e7d935 Merge branch 'main' into fix/14071-full-inventory-pickup 2026-11-12 00:58:52 +01:00
Kacper 90827741ae Merge branch 'main' into up13/camera-armor-stand 2026-11-12 00:58:39 +01:00
Kacper 7e779ceb8b Merge branch 'main' into fix/loom-dye-component 2026-11-12 00:58:28 +01:00
Kacper 5481445402 Merge branch 'main' into up12/ctm-chain-and-pass 2026-11-12 00:58:12 +01:00
Kacper 2c62ea5aaa Merge branch 'main' into items/destroy-progress 2026-11-12 00:58:10 +01:00
Kacper 0bd5635d74 Merge branch 'main' into fix/hoglin-pacified-breeding 2026-11-12 00:58:04 +01:00
Kacper 53ef669745 Merge branch 'main' into fix/14461-firework-dispenser-rotation 2026-11-12 00:57:55 +01:00
Kacper 1aef29fada Merge branch 'main' into perf-effect-meta 2026-11-12 00:57:32 +01:00
Kacper 44d13ad5d4 Merge branch 'main' into fix/disabledlabel 2026-11-12 00:57:23 +01:00
Kacper f66e1a8d91 Merge branch 'main' into fix/servers-dat-lock 2026-11-12 00:57:15 +01:00
Bl4ckspellandClaude Opus 5.5 83703c28b2 refactor(gfx): move residency and memory figures into gfx/census.rs
gfx.rs is over the 3000-line limit and may not grow, and the 4x terrain MSAA work adds to it. The impl block from resident_sections to cpu_census moves unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 00:57:12 +01:00
Kacper 634df8744a Merge branch 'main' into fix/fishing-hook-harness-peaceful 2026-11-12 00:57:06 +01:00
Kacper e8b23d06ed Merge branch 'main' into fix/jigsaw-priority-tooltip 2026-11-12 00:56:34 +01:00
Kacper 80ef919e40 Merge branch 'main' into fix/14454-minecart-shulker 2026-11-12 00:56:26 +01:00
Kacper c1894da34b Merge branch 'main' into fix/walk-target-scores 2026-11-12 00:56:08 +01:00
Kacper 1b016ba22e Merge branch 'main' into fix/chiseled-shelf-can-take 2026-11-12 00:55:44 +01:00
Kacper 0482839c1b Merge branch 'main' into fix/neoforge-version-path 2026-11-12 00:55:35 +01:00
Kacper 023cef202f Merge branch 'main' into fix/launcher-sign-in-bounds 2026-11-12 00:55:27 +01:00
rdbtCVSandClaude Sonnet 5.5 97f41ca90f Mobs: keep a tick serial while a mob holds a teleporting consumable
A fox that finishes chorus fruit teleports within half the item's
diameter on each axis (item data) and then falls to the first ground
below, so no margin bounds its destination. Islands::plan now returns
None, in production and verify, while any mob holds such an item in
either hand or is using one; it checks the item, not the species.

Adds a destination-herd regression and a plan() case, and documents the
fallback and the audit of other self-moves in docs/mob-islands.md.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 23:55:27 +00:00
Kacper fdc97df507 Merge branch 'main' into split-renderer 2026-11-12 00:55:09 +01:00
Kacper 09fdaa392a Merge branch 'main' into fix/batch11-c 2026-11-12 00:55:01 +01:00
Kacper 6e5270105b Merge branch 'main' into tools/port-ledger-json-after-query 2026-11-12 00:54:52 +01:00
Kacper 7c9db3e706 Merge branch 'main' into tools/fixtures-ask-for-work-dir 2026-11-12 00:54:44 +01:00
Kacper e8b01b7282 Merge branch 'main' into fix/port-pack-licences 2026-11-12 00:54:27 +01:00
Kacper f22a814607 Merge branch 'main' into refactor/move-model-parts 2026-11-12 00:54:19 +01:00
Kacper fd7c423476 Merge branch 'main' into perf/range-dispatch-binary-search 2026-11-12 00:54:10 +01:00
Kacper 731699144c Merge branch 'main' into port_map/farmland-is-near-water 2026-11-12 00:54:01 +01:00
Kacper b14027d065 Merge branch 'main' into fix/brightness-gate 2026-11-12 00:53:53 +01:00
Kacper 5133e20195 Merge branch 'main' into docs/layers-exceptions-15408 2026-11-12 00:53:44 +01:00
Evrim CicekandClaude Opus 5.5 1fc67d4d10 stack_dump: the Unix-only code is unused on Windows without failing clippy
after_handler is only called on the signal path (Linux, Android, Apple),
and the tests' busy thread only serves the test that reads another
thread's stack there: on Windows clippy -D warnings stopped in
horizon-threads, and every build warned about after_handler. The function
takes the same cfg_attr(.., allow(dead_code)) as Watched::thread, and the
test helpers and their imports the test's cfg.

Fixes #15476.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 02:45:58 +03:00
Your Name 59f63a251a Merge remote-tracking branch 'origin/main' into fix/pvp-held-count-overflow 2026-11-12 12:40:48 +13:00
Your Name e745db772e Fix held item count overflow: use saturating_add; drop unrelated CI changes 2026-11-12 12:40:14 +13:00
Your Name ad8e395f3c Merge main into fix/item-count-max-stack 2026-11-12 12:40:00 +13:00
rdbt 1511bd7a23 Merge pull request #15474 from horizon-rust/fix/ci-core-shader-test-locations
Fix failing core_shader vertex-index test
2026-11-11 23:32:26 +00:00
Evrim Cicek 7573ae6a3e Merge remote-tracking branch 'upstream/main' into fix/orb-fall-distance
# Conflicts:
#	crates/horizon-client/src/entity_nbt.rs
#	crates/horizon-client/src/game/commands.rs
#	crates/horizon-client/src/game/loaded_arrow_shot_tests.rs
#	crates/horizon-client/src/game/remote.rs
#	tools/command_exec_corpus.py
2026-11-12 02:28:46 +03:00
AkirabaneandClaude Sonnet 5.5 3e75afe983 port_map: the goat and fox rows of game-012.tsv name the callers that exist
Goat.finalizeSpawn, GoatAi.initMemories and Fox.populateDefaultEquipmentSlots
said that Ctx::finalize and Ctx::populate call goat_spawn and
fox::populate_default_equipment, and that goat_spawn calls goat_init_memories.
They do not: Ctx::finalize and Ctx::populate have their own copies of the same
draws, and goat_spawn draws the two cooldowns itself.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 00:27:54 +01:00
Akirabane 29df2938b1 Merge origin/main into docs/port-map-game-012-fixed-gaps 2026-11-12 00:27:11 +01:00
rdbtCVSandClaude Sonnet 5.5 87949f0a24 Fix core_shader vertex-index test: match the fragment shader's locations
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 23:27:05 +00:00
rdbtCVSandClaude Sonnet 5.5 c5e63f9f06 Merge main into perf/item-moves-ahead
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 23:25:42 +00: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
rdbt 09de8c7bbb Merge pull request #15466 from rdbtCVS/fix/ci-main-core-tests
Fix the two failing horizon-client-core tests on main
2026-11-11 23:08:15 +00:00
umcekoandClaude Opus 5.5 47b40ecd35 Merge remote-tracking branch 'origin/main' into perf-platform-clean
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 02:03:21 +03:00
rdbtCVSandClaude Sonnet 5.5 bc6dbfe589 Fix the two failing horizon-client-core tests on main
- th_th.json (game and lang_review copy): drop the HDR peak hint en_us
  no longer has, as b36e25ab16 did for Ukrainian;
  bundled_files_are_served_and_have_no_stale_or_repeated_keys failed on it.
- booleans_are_read_as_bytes: the sign side in the has_glowing_text case had
  no messages, so it now loads empty (4787069c5b); give it four lines.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 23:02:37 +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 0808d1f189 Merge commit '3298a6dcd5' into unstick/14179
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
2026-11-11 23:59:55 +01:00
Kacper 014462436f Merge branch 'main' into docs/index-updated 2026-11-11 23:59:38 +01:00
Kacper 7dffe8983b Merge branch 'main' into fix/15276-set-custom-data 2026-11-11 23:58:31 +01:00
Kacper 5a4d8c11e6 Merge branch 'main' into fix/deep-end-boat-void 2026-11-11 23:58:23 +01:00
Kacper 789be0131d Merge branch 'main' into fix/item-set-count-overflow 2026-11-11 23:58:07 +01:00
Kacper 54ea398de4 Merge branch 'main' into fix/protocol-inflate-growth 2026-11-11 23:57:50 +01:00
Kacpherek 219d6b6b4e Keep mobs.rs from growing: the client_side note sits on its field's line 2026-11-11 23:57:12 +01:00
Kacper cccf79f63a Merge branch 'main' into fix/13666-chest-boat-loot 2026-11-11 23:56:45 +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 0c07b18c76 Merge branch 'main' into perf-equipment-cache 2026-11-11 23:54:41 +01:00
Kacpherek 71d150b028 Read the redstone experiments flag from the enabled features in the lightning rod test 2026-11-11 23:54:20 +01:00
Kacper f8cf08849e Merge branch 'main' into fix/guest-invalid-entity-attack-kick 2026-11-11 23:54:18 +01:00
Kacper d78f353156 Merge branch 'main' into fix/wind-charge-no-deflect-ticks 2026-11-11 23:53:15 +01:00
Kacpherek 251c800717 Close the chat option change test in chat_tests 2026-11-11 23:53:01 +01:00
Kacper 632b455a88 Merge branch 'main' into fix/raw-entity-flags 2026-11-11 23:52:59 +01:00
Kacper 6c58b77fed Merge branch 'main' into fix/explosion-creaking-heart-xp 2026-11-11 23:52:41 +01:00
Kacper 02b3da3eec Merge branch 'main' into fix/explosion-player-xp 2026-11-11 23:52:32 +01:00
Kacper 0bd65042f2 Merge branch 'main' into perf/faster-client-lib-tests 2026-11-11 23:52:23 +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
Kacpherek 245d97a312 Close the drowned current test in living_tests 2026-11-11 23:51:47 +01:00
Kacpherek eb1fbb151d Merge commit '3298a6dcd5' into unstick/14943
# Conflicts:
#	crates/horizon-client/src/entity_nbt.rs
2026-11-11 23:50:58 +01:00
Kacpherek 4676650694 Merge commit '3298a6dcd5' into unstick/15313 2026-11-11 23:50:19 +01:00
Kacpherek 9aae4a6671 Merge commit '3298a6dcd5' into unstick/14376 2026-11-11 23:50:18 +01:00
Kacpherek 660288e50b Merge commit '3298a6dcd5' into unstick/14902 2026-11-11 23:50:18 +01:00
Kacpherek 2497c21f48 Merge commit '3298a6dcd5' into unstick/14589 2026-11-11 23:50:17 +01:00
Kacpherek 5cbded2bd9 Merge commit '3298a6dcd5' into unstick/14904
# Conflicts:
#	crates/horizon-client/src/entity_nbt.rs
2026-11-11 23:50:15 +01:00
Kacper 53ffc05840 Merge branch 'main' into packs/core-provided-blocks 2026-11-11 23:50:04 +01:00
Kacper 5b275cd324 Merge branch 'main' into fix/launcher-data-link-above-root 2026-11-11 23:49:56 +01:00
Kacper 05c0a3942f Merge branch 'main' into fix/servers-dat-lock 2026-11-11 23:49:35 +01:00
Kacper fa34419e4b Merge branch 'main' into fix/fishing-hook-harness-peaceful 2026-11-11 23:49:32 +01:00
Kacper a640a5a91a Merge branch 'main' into fix/windows-console-close 2026-11-11 23:49:26 +01:00
Kacper 7475a27b7b Merge branch 'main' into fix/friends-try-every-endpoint 2026-11-11 23:49:17 +01:00
Kacper 721a16e17b Merge branch 'main' into fix/15134-flame-particles 2026-11-11 23:49:09 +01:00
Kacper 20c3b24dab Merge branch 'main' into fix/disabledlabel 2026-11-11 23:49:00 +01:00
Kacper 8d60a65c6c Merge branch 'main' into fix/jigsaw-priority-tooltip 2026-11-11 23:48:59 +01:00
Kacper 72d6a5b383 Merge branch 'main' into fix/jigsaw-local-edit 2026-11-11 23:48:51 +01:00
Kacper 4347efb700 Merge branch 'main' into fix/14454-minecart-shulker 2026-11-11 23:48:50 +01:00
Kacper b1104143ef Merge branch 'main' into fix/14461-firework-dispenser-rotation 2026-11-11 23:48:42 +01:00
Kacper 1c98b67b44 Merge branch 'main' into fix/14071-full-inventory-pickup 2026-11-11 23:48:33 +01:00
Kacper 1299432e7a Merge branch 'main' into fix/walk-target-scores 2026-11-11 23:48:32 +01:00
Kacper 271bace8e1 Merge branch 'main' into fix/hoglin-pacified-breeding 2026-11-11 23:48:24 +01:00
Kacper d07604cd8e Merge branch 'main' into up13/camera-armor-stand 2026-11-11 23:48:15 +01:00
Kacper 195dceeb94 Merge branch 'main' into fix/chiseled-shelf-can-take 2026-11-11 23:48:07 +01:00
Kacper dec1f7bd51 Merge branch 'main' into up12/ctm-chain-and-pass 2026-11-11 23:48:06 +01:00
Kacper 3943570b53 Merge branch 'main' into fix/neoforge-version-path 2026-11-11 23:47:58 +01:00
Kacper a5b58a242b Merge branch 'main' into fix/loom-dye-component 2026-11-11 23:47:56 +01:00
Kacper 8dc176f4e6 Merge branch 'main' into fix/launcher-sign-in-bounds 2026-11-11 23:47:50 +01:00
Kacper 6494dc92e0 Merge branch 'main' into items/destroy-progress 2026-11-11 23:47:39 +01:00
Kacper 13fe0905ba Merge branch 'main' into split-renderer 2026-11-11 23:47:32 +01:00
Kacper 7cae9ff3c4 Merge branch 'main' into fix/batch11-c 2026-11-11 23:47:08 +01:00
Kacper d9d0c7ad13 Merge branch 'main' into tools/port-ledger-json-after-query 2026-11-11 23:47:00 +01:00
Kacper d60a13d0d3 Merge branch 'main' into tools/fixtures-ask-for-work-dir 2026-11-11 23:46:51 +01:00
Kacper 38e2c2522a Merge branch 'main' into fix/port-pack-licences 2026-11-11 23:46:35 +01:00
Kacper 8c46ceea46 Merge branch 'main' into refactor/move-model-parts 2026-11-11 23:46:26 +01:00
Kacper 7d7fa98a44 Merge branch 'main' into perf/range-dispatch-binary-search 2026-11-11 23:46:17 +01:00
Kacper fd2f881a32 Merge branch 'main' into port_map/farmland-is-near-water 2026-11-11 23:46:08 +01:00
Kacper 0f209ac875 Merge branch 'main' into fix/brightness-gate 2026-11-11 23:46:00 +01:00
Kacper de7ceb78d7 Merge branch 'main' into docs/layers-exceptions-15408 2026-11-11 23:45:51 +01:00
SolenopsisbotandCodex c3325b01f3 Client: honor synced nameplate distances
Co-Authored-By: Codex (GPT-6.1 Sol) <[email protected]>
2026-11-12 09:45:10 +11:00
rdbt 3298a6dcd5 Merge pull request #15462 from rdbtCVS/fix/upstream-ci-test-api-drift
Fix compilation after entity RNG and network data refactors
2026-11-11 22:41:17 +00:00
umcekoandClaude Opus 5.5 77b12a46b1 server_profile_e2e: the disconnect step starts once the timed capture's file has arrived
The client refuses a server start while the last file is still coming in.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 01:40:12 +03: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
rdbtCVSandCodex gpt-6.1-sol efa58613aa Fix compilation after RNG and network data refactors
Update stale test call sites and the player RNG field to the current APIs without changing seeds, draws or assertions.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-11 22:34:32 +00:00
Kacpherek 59dbb4703d Merge commit '8d44c49cd3' into unstick/14561
# Conflicts:
#	crates/horizon-client/src/game/books.rs
#	crates/horizon-client/src/game/use_hand.rs
2026-11-11 23:31:04 +01:00
Kacpherek fe78b81e88 Merge commit '8d44c49cd3' into unstick/14740
# Conflicts:
#	crates/horizon-client/src/game/dragon.rs
2026-11-11 23:27:19 +01:00
rdbt 2ddce27f40 Merge pull request #14927 from vermingov/fix/horse-variant-wrap-14926
A horse's unknown coat or markings id wraps (#14926)
2026-11-11 22:27:15 +00:00
rdbt 48cef8f361 Merge pull request #14444 from voidsipp/split/11454-offer-packets
Keep merchant offer packets tied to price refreshes
2026-11-11 22:26:34 +00:00
rdbt ec56b95628 Merge pull request #14412 from voidsipp/fix/11589-guest-owned-rng
Give player bodies their own random streams
2026-11-11 22:26:27 +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
rdbt 8d44c49cd3 Merge pull request #14536 from ilguerro/fix/saved-legacy-structures
Load saved mineshafts, fortresses and strongholds instead of regenerating them
2026-11-11 22:24:14 +00:00
rdbt 1d1d79ebc1 Merge pull request #14701 from ilguerro/fix/template-loading
Load structure templates faster and preserve numeric NBT fields
2026-11-11 22:24:09 +00:00
rdbt f7453ff16f Merge pull request #15167 from ilguerro/perf/worldgen-surface
Surface stage: sample each ore vein's densities over its own rows, when first read
2026-11-11 22:24:05 +00:00
Kacper 28f0c9802e Merge branch 'main' into fix/driven-mount-client-hurt 2026-11-11 23:23:37 +01:00
Kacper e1a97b5783 Merge branch 'main' into fix/15275-broken-stat 2026-11-11 23:23:28 +01:00
Kacper 4e86da4a7a Merge branch 'main' into fix/loot-set-enchantments 2026-11-11 23:23:18 +01:00
rdbt cb5de34db6 Merge pull request #15252 from rdbtCVS/perf/zlib-level-2-50-player
Reduce packet compression cost and benchmark 50-player traffic
2026-11-11 22:22:37 +00:00
rdbt 9f72537d21 Merge pull request #15243 from yahya-nusseibeh/fix/ci-pin-actions
CI: pin the third-party Rust actions to reviewed commits
2026-11-11 22:22:32 +00:00
rdbt 06f24ae718 Merge pull request #15007 from darkliro/perf/intel-raptor-lake-vulkan
gfx: allow indirect multi-draw on Raptor Lake with Mesa >= 26.0.8
2026-11-11 22:22:27 +00:00
Kacper 98df3549d2 Merge branch 'main' into fix/fox-village-shade-defend-trusted 2026-11-11 23:22:23 +01:00
rdbt fa68c60740 Merge pull request #15004 from sunrise-org/dims/portal-scales
Portals and execute in scale coordinates by the dimension types (#11282, 6/9)
2026-11-11 22:22:23 +00:00
rdbt 04d109da79 Merge pull request #14979 from lucasmts59/fix/pack-text-world
A resource pack's core/text draws world text (text displays, name tags)
2026-11-11 22:22:18 +00:00
Kacper 7d256e1766 Merge branch 'main' into fix/fox-eat-effects-panda-pickup 2026-11-11 23:22:14 +01:00
rdbt 936e9e0cba Merge pull request #14934 from Detimes/perf-recording-box
Perf: skip recording's block test for a viewer no changed block can reach
2026-11-11 22:22:13 +00:00
rdbt 373c49af50 Merge pull request #14933 from Detimes/perf-biome-resend
Perf: encode resent biomes once per set of columns
2026-11-11 22:22:08 +00:00
Kacper 1ec82f9a9f Merge branch 'main' into fix/beacon-payment-order-tooltips 2026-11-11 23:22:05 +01:00
rdbt 45c080011c Merge pull request #14815 from senjinthedragon/dynamic-resolution
Dynamic Resolution: the world's resolution follows a target frame rate
2026-11-11 22:22:02 +00:00
rdbt 0fa6d4d68e Merge pull request #14795 from alexcatdad/nix-module
Nix: services.horizon-server, a NixOS module for a Horizon server
2026-11-11 22:21:57 +00:00
rdbt 6e344a9fd6 Merge pull request #14744 from NongTham/lang/thai-game-client
Game: review and complete Thai translations (th_th.json)
2026-11-11 22:21:52 +00:00
rdbt 1d3ed915df Merge pull request #14711 from YourPancakes/perf/parse-packet-header-once
perf(net): parse packet frame headers once
2026-11-11 22:21:48 +00:00
rdbt f5ccf953c4 Merge pull request #14506 from rdbtCVS/perf/parallel-tick-helpers
Parallel tick: helper threads for mob prep, random tick masks and mob states, behind HORIZON_PARALLEL_TICK
2026-11-11 22:21:41 +00:00
rdbt 0179d86295 Merge pull request #14502 from horizon-rust/ci/forks-verify-on-github
Forks' pull requests run verify on GitHub's runners, never on the desktop's
2026-11-11 22:21:36 +00:00
Kacper 1db662da23 Merge branch 'main' into fix/player-rotation-packet-parity 2026-11-11 23:21:29 +01:00
rdbt 5b03edb56e Merge pull request #14455 from rdbtCVS/perf/anticheat-log-thread
Anticheat: write the flag log on a thread of its own, joined on drop
2026-11-11 22:21:25 +00:00
Kacper d62d4787ab Merge branch 'main' into fix/chat-scroll-keeps-place 2026-11-11 23:21:20 +01:00
rdbt 7e2abf5fc4 Merge pull request #14452 from horizon-rust/split/pages-fields
Pages v2: text fields, and a page in the title screen's Friends icon slot
2026-11-11 22:21:19 +00:00
rdbt 17273e22ba Merge pull request #14361 from NongTham/lang/thai-launcher
Launcher: review and complete Thai translations (th.json)
2026-11-11 22:21:06 +00:00
Kacper 012f70508a Merge branch 'main' into fix/14806-fire-reset 2026-11-11 23:21:02 +01:00
rdbt 81d944457c Merge pull request #14356 from Evan-lhm/rt/acceleration-structures
Ray tracing: the terrain's acceleration structures, near the camera only (#13780 step 2)
2026-11-11 22:21:01 +00:00
rdbt f2b210b1f1 Merge pull request #14331 from horizon-rust/split/cosmetics-settings
Cosmetics settings leave the engine's Extras (spec #14091 step 5a)
2026-11-11 22:20:56 +00:00
rdbt d77c945717 Merge pull request #14283 from NikitaMGrimm/typed-randoms-1a
The level's random and a mob's own random have types of their own
2026-11-11 22:20:43 +00:00
Kacper 40a5a99ca8 Merge branch 'main' into fix/port-error-tooltip 2026-11-11 23:20:26 +01:00
rdbt b4ce8a563b Merge pull request #13942 from horizon-rust/layers/chat-guard-hook
Chat filter: the hub asks an engine hook (layer plan M6)
2026-11-11 22:20:06 +00:00
rdbt c9df2c9f93 Merge pull request #13930 from horizon-rust/layers/extras-reconnect
Auto-reconnect's game glue moves to game/extras/reconnect.rs (layer plan M5)
2026-11-11 22:20:01 +00:00
rdbt 62a8e1f9fa Merge pull request #13730 from NikitaMGrimm/shader-api-linear-blend
Shader API 0.2: packs that ask for linear blending are drawn, glass and water blend as light
2026-11-11 22:19:54 +00:00
Kacper ece1d80195 Merge branch 'main' into up-14641-stack-components 2026-11-11 23:19:42 +01:00
Kacper c0cfcda90e Merge branch 'main' into boat-passenger-water 2026-11-11 23:19:33 +01:00
Kacper 4a90452d73 Merge branch 'main' into fix/drowned-fluid-push 2026-11-11 23:19:17 +01:00
Kacper 858de11740 Merge branch 'main' into fix/jukebox-song-reload 2026-11-11 23:19:00 +01:00
Kacper 68d5cd7114 Merge branch 'main' into fix/11318-end-gateway-cooldowns 2026-11-11 23:18:51 +01:00
Kacper 3528b8ce41 Merge branch 'main' into split/11535-pending-entity-provenance-20261109 2026-11-11 23:18:34 +01:00
Kacper b1fb8a4048 Merge branch 'main' into fix/adventure-sign-place-on 2026-11-11 23:18:25 +01:00
Kacper 37d6df3219 Merge branch 'main' into split/11469-damage-source-foundation 2026-11-11 23:18:16 +01:00
Kacper 59b2aed66c Merge branch 'main' into up-13175-forced-chunk-ring 2026-11-11 23:17:56 +01:00
Kacper b9abf293aa Merge branch 'main' into fix/14371-rod-neighbours 2026-11-11 23:17:47 +01:00
Kacper cb8761a18c Merge branch 'main' into fix/14368-spawnable-pos 2026-11-11 23:17:38 +01:00
Kacper 2525ab8ff8 Merge branch 'main' into tools/fall-clip-wait-neighbourhood 2026-11-11 23:17:29 +01:00
rdbt 6b734b015b Merge pull request #14451 from itarqos5/test/vanilla-redstone-rules-2
Tests: 25 more vanilla redstone, fluid and block rules (stacked on #14237)
2026-11-11 22:17:24 +00:00
rdbt bd4cf5bf17 Merge pull request #11585 from voidsipp/fix/11337-special-attack-followup
Preserve exhaustion and sprint slowdown on special entity hits
2026-11-11 22:17:14 +00:00
Kacper f5dbdcadba Merge branch 'main' into fix/void-floor-per-dimension 2026-11-11 23:17:13 +01:00
rdbt ebcccff515 Merge pull request #13580 from TheApple05/fix/13174-wayland-file-drop
Fix confirmed resource-pack drag-and-drop imports
2026-11-11 22:17:09 +00:00
Kacper d361eaf394 Merge branch 'main' into frames-projectile-hits 2026-11-11 23:17:03 +01:00
rdbt bae784326f Merge pull request #13913 from iseaprojects/fix/11316-custom-durability
Honor custom MAX_DAMAGE on normally non-durable items
2026-11-11 22:17:03 +00:00
rdbt bea5186176 Merge pull request #14296 from ilguerro/fix/enderman-block-and-stare
fix: endermen keep carried blocks out of entities, stare once per 400 ticks and reach blocks through outlines
2026-11-11 22:16:57 +00:00
Kacper fe4d3a2e94 Merge branch 'main' into fix/item-void-floor 2026-11-11 23:16:54 +01:00
rdbt f9a650f3ac Merge pull request #14319 from DylanGonzaloFerreyra/fix-split-memory-queue
A splitscreen player's first view no longer disconnects it
2026-11-11 22:16:52 +00:00
rdbt e3ba3ae992 Merge pull request #14417 from Ragbie/fix/rocket-fall-distance
Firework rockets keep their fall distance
2026-11-11 22:16:45 +00:00
rdbt 5a7076f3ea Merge pull request #14425 from Mayonesu0/up-13541-portal-armor-attrs
Keep the player's attribute modifiers through a portal respawn
2026-11-11 22:16:40 +00:00
rdbt e1eb285cc7 Merge pull request #14442 from Mik00000/fix/spear-reach-pvp
fix(client): synchronize look rotation and update pvp hud for spear stab (fixes #14092)
2026-11-11 22:16:35 +00:00
rdbt 786b48499c Merge pull request #14458 from Solenopsisbot/t3/fix-pregen-square-crash
fix: bound pregen save backlog
2026-11-11 22:16:30 +00:00
rdbt f782dd4326 Merge pull request #14477 from voduff/fix/14424-legacy-chunk-entities
A chunk saved before 1.17 loads the entities it keeps
2026-11-11 22:16:25 +00:00
rdbt 7955d4c512 Merge pull request #14505 from foudretbr/fix/area-effect-cloud-motion-persistence
fix(game): preserve area effect cloud Motion across chunk save and reload
2026-11-11 22:16:20 +00:00
rdbt 6858deccf1 Merge pull request #14547 from ilguerro/fix/sign-editor-holder
signs keep one editor at a time and only open on plain text
2026-11-11 22:16:12 +00:00
rdbt d16d9b6d15 Merge pull request #14559 from MSedgeMC/fix/death-screen-links
The death screen's cause behaves as a link
2026-11-11 22:16:07 +00:00
rdbt 855f1f9043 Merge pull request #14567 from fbikat/launcher-uninstall-moved-data
Launcher: uninstall deletes the data of a moved data location
2026-11-11 22:16:00 +00:00
rdbt 16b7843619 Merge pull request #14581 from Mayonesu0/up-14478-painting-pitch
Keep a painting's saved pitch (Rotation[1]) through save and load
2026-11-11 22:15:55 +00:00
rdbt 9c306d6f5b Merge pull request #14588 from Mayonesu0/up-14481-absorption-roundtrip
Keep a loaded mob's AbsorptionAmount as saved (negative or above the maximum)
2026-11-11 22:15:48 +00:00
rdbt 2a095d62d5 Merge pull request #14605 from DUzzL/fix/14466-fireball-rotation
Turn fireballs towards their movement every tick
2026-11-11 22:15:39 +00:00
rdbt 60ffd2a342 Merge pull request #14609 from Mayonesu0/up-12788-hoglin-anger
Compare a broadcast player target by distance in the hoglin brain
2026-11-11 22:15:35 +00:00
rdbt 449ebe1366 Merge pull request #14630 from MSedgeMC/fix/shield-block-stat
port: a blocked hit counts the blocking item as used
2026-11-11 22:15:30 +00:00
rdbt 55a7086663 Merge pull request #14655 from J5Casey/fix/viewer-transfer-worn
Recipe viewers: a transfer takes worn, enchanted and renamed items
2026-11-11 22:15:25 +00:00
rdbt 51094095b2 Merge pull request #14666 from martipops/agent/osk-wishlist
On-screen keyboard: Done finishes books and signs, chat suggestions by pad, hints wrap
2026-11-11 22:15:20 +00:00
rdbt b3b496a449 Merge pull request #14669 from JustKarkat/fix/player-head-resource-pack-texture
fix: player heads keep their custom skins with resource packs
2026-11-11 22:15:15 +00:00
rdbt b7db4654ee Merge pull request #14709 from Skr1zzzz/integrated-crash-save
Single player: a tick that panics or fails still saves the world
2026-11-11 22:15:08 +00:00
rdbt af4217724b Merge pull request #14716 from Skr1zzzz/removal-passengers
Client: removing entities seats passengers off only the vehicles among them
2026-11-11 22:15:03 +00:00
rdbt 27f8cff837 Merge pull request #14729 from ilguerro/fix/pet-sitting-pose
Pets sit only where vanilla lets them, and a cat's chest pose reaches every player
2026-11-11 22:14:57 +00:00
Akirabane 1b5be38573 Merge remote-tracking branch 'origin/main' into local/M1-move-model-parts 2026-11-11 23:14:52 +01:00
rdbt 1ebd5f32ab Merge pull request #14733 from ilguerro/fix/powder-snow-cauldron-extinguish
Hiss when powder snow or a cauldron puts a burning player out, and leave the block outside the world border
2026-11-11 22:14:52 +00:00
rdbt 51ca66993a Merge pull request #14739 from foudretbr/fix/consumable-component-use
fix(game): resolve dynamic consumable component on item stack (#14634)
2026-11-11 22:14:47 +00:00
rdbt 3af468a074 Merge pull request #14746 from MSedgeMC/fix/dripstone-drip-face
A fluid drip hangs on the fluid block's own face, and a flowing fluid drips
2026-11-11 22:14:42 +00:00
rdbt f0ccc014ad Merge pull request #14774 from MSedgeMC/fix/breathe-under-water
The air supply the jar gives each type
2026-11-11 22:14:37 +00:00
rdbt de5b2f94db Merge pull request #14813 from foudretbr/fix/armor-stand-saved-pitch
fix(mobs): load and retain armor stand pitch from entity Rotation (#14799)
2026-11-11 22:14:24 +00:00
rdbt 8edbc6760d Merge pull request #14827 from Mik00000/fix/flint-steel-rapid-use
fix(client): allow rapid flint and steel usage when holding offhand item (fixes #14773)
2026-11-11 22:14:17 +00:00
rdbt d47d019d27 Merge pull request #14843 from vermingov/fix/trident-negative-life-14834
A trident keeps a negative life and shake across a reload (#14834)
2026-11-11 22:14:12 +00:00
rdbt 5c03db3283 Merge pull request #14864 from DUzzL/fix/14824-spit-bullet-nbt
Keep Air, Fire and fall_distance on llama spit and shulker bullet saves
2026-11-11 22:14:05 +00:00
rdbt 33f8d2f80c Merge pull request #14869 from vermingov/fix/item-age-short-14867
A dropped item narrows a loaded Age and PickupDelay to a short (#14867)
2026-11-11 22:13:58 +00:00
rdbt 5d0aeb8197 Merge pull request #14954 from Web-mc1/fix/add-padding-bits-zero
Guard add_padding against bits==0 and bits>=64
2026-11-11 22:13:54 +00:00
rdbt 91a474ae11 Merge pull request #14962 from MSedgeMC/fix/fox-alert-visibility
An invisible player far away does not keep a fox alert
2026-11-11 22:13:49 +00:00
Akirabane 54909bf033 Merge remote-tracking branch 'origin/main' into local/W3-02-invalid-attack-kick 2026-11-11 23:13:48 +01:00
rdbt 827b18454f Merge pull request #14974 from vermingov/fix/falling-block-flags-14971
A falling block's saved flags are read as bytes (#14971)
2026-11-11 22:13:43 +00:00
rdbt 6edd7b88a9 Merge pull request #14976 from vermingov/fix/gateway-exact-teleport-14975
An end gateway's ExactTeleport is read as a byte (#14975)
2026-11-11 22:13:39 +00:00
rdbt 44316071c3 Merge pull request #14978 from vermingov/fix/block-entity-flag-byte-14977
A block entity's booleans are read as bytes (#14977)
2026-11-11 22:13:34 +00:00
rdbt d2cf911a6d Merge pull request #14988 from DUzzL/fix/14896-itemframe-neg-rotation
Keep the sign of a loaded item frame ItemRotation
2026-11-11 22:13:29 +00:00
rdbt 2b4656e70f Merge pull request #14993 from DUzzL/fix/14895-thrown-item-count
Force thrown item stack count to 1 like ThrowableItemProjectile.setItem
2026-11-11 22:13:24 +00:00
rdbt 548a41afa2 Merge pull request #14995 from MSedgeMC/fix/lectern-book-block-change
port: a lectern's book state change posts block_change
2026-11-11 22:13:19 +00:00
rdbt f15d4a5845 Merge pull request #14998 from Ragbie/fix/piston-arrow-bubble-column
Pushed arrows and client shulker bullets take the bubble column push
2026-11-11 22:13:14 +00:00
rdbt be7e745e48 Merge pull request #15016 from DUzzL/fix/14942-vehicle-powder-snow
Count powder snow freeze ticks on boats and minecarts
2026-11-11 22:13:08 +00:00
rdbt b67388bf60 Merge pull request #15026 from DUzzL/fix/14964-orb-air
Keep an experience orb's Air through load and save
2026-11-11 22:13:02 +00:00
rdbt f738f99adb Merge pull request #15043 from DUzzL/fix/15020-windcharge-air-fire
Keep Air and Fire on wind charges when saving and loading
2026-11-11 22:12:57 +00:00
rdbt b94b07ff5e Merge pull request #15044 from DUzzL/fix/15022-crystal-fire
An end crystal's first tick clears a loaded Fire
2026-11-11 22:12:52 +00:00
Akirabane 2106e9b21e Merge remote-tracking branch 'origin/main' into local/R13-snow-golem-stroll 2026-11-11 23:12:46 +01:00
Román BenjáminandClaude Opus 5.5 f5a3990fbf Prepare: accept Mojang's seventh 26.3 republish (9f67e10e, index e99f9973)
Fixes #15456.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 23:12:02 +01:00
Akirabane b1cdc91bf4 Merge remote-tracking branch 'origin/main' into local/R09-world-folder-name 2026-11-11 23:11:43 +01:00
Akirabane e7cdaa120a Merge remote-tracking branch 'origin/main' into local/PM4-game-012 2026-11-11 23:10:40 +01:00
Akirabane 58a9cbddca Merge remote-tracking branch 'origin/main' into local/R16-accept-teleport 2026-11-11 23:09:47 +01:00
umcekoandClaude Opus 5.5 3da49d7a02 Perf: the inflation-cap test checks the bomb against the announced cap
Its gzip size depends on the flate2 backend that feature unification picks
(3.7 MB alone, 5.2 MB in a workspace test run); what matters is that the
announced-size cap lets it through, so the inflated cap is what stops it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 00:58:35 +03:00
umcekoandClaude Opus 5.5 eae9be3b4f Perf: rustfmt the sampler's ring attribute
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 00:51:16 +03:00
umcekoandClaude Opus 5.5 f21daf7263 Perf: horizon-server report prints a short summary again
On main `horizon-server report` printed the report's text summary; with the
capture it printed only the file's path, and the text form moved to
horizon-perf, which no release ships, so a host on a VPS could not read the
result there. horizon-perf-format, already shipped through the platform,
gets server_brief: the kind and length, TPS and MSPT, CPU, RSS, players,
chunks and entities, and the five heaviest tick phases. The report prints
it before the path.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 00:47:34 +03:00
umcekoandClaude Opus 5.5 21a8b3bff1 Perf: the one-shot Open server retries a failed accept until its deadline
Any accept() error other than WouldBlock ended the server that offers a
capture to the viewer, before the viewer had fetched it: an aborted
connection (ECONNABORTED, common on BSDs and macOS, WSAECONNRESET on
Windows) or a full file table (EMFILE) left Open broken for that click.
Errors now wait 10 ms and try again until the offer's deadline or a cancel.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 00:45:29 +03:00
umcekoandClaude Opus 5.5 1979dc6dc2 Perf: a thread's sampler ring grows when a session needs more
A thread's ring was sized at its first session and kept, but the size a
session needs depends on its Hz, --wall (twice the entries) and the unwind
mode (about 16 KiB per stack copy), so a later capture with --wall or a
higher --hz lost most of its samples to `dropped` (95 of 99 in the new
test). A session now gets a new, larger ring when the thread's is smaller
than it needs. A replaced ring stays allocated for the thread's life, since
a late signal's handler may still hold it; rings only ever grow, so this is
at most a few per thread.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 00:44:51 +03:00
umcekoandClaude Opus 5.5 b2df8cee34 Perf: a server profile stops when the guest who asked for it leaves
The requester was keyed by connection id and never cleared on disconnect,
so after the admin's client dropped during an open-ended `profile server
start` everyone got AlreadyRunning until the 600 s window ended, and the
same admin, reconnected under a new id, got NotRunning when stopping. Each
tick's requests now include a stop (reason DISCONNECTED) once the
requester's connection is gone; the file stays in the server's reports/ and
its path is logged, and the slot frees as soon as it is saved, so anyone
(the same admin included) can start again. server_profile_e2e covers the
disconnect and the start after reconnecting.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 00:43:06 +03:00
umcekoandClaude Opus 5.5 ec1fb9a182 Perf: Share uploads again when the receipt's upload is gone
share_saved always reused an existing .hzperf.share.json and only re-pinned
it, so once the upload expired (a temporary one after an hour, a shared one
after 30 days) or was deleted, Share failed for that capture until the
receipt was removed by hand. A 404 or 410 on the pin of a reused receipt now
uploads the capture again, replaces the receipt (still 0600, written beside
and renamed over) and pins the new upload.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 00:40:09 +03:00
umcekoandClaude Opus 5.5 cec503f33e Perf: a typed /profiler goes where its completion came from
with_profiler keeps a server's or mod's own `profiler` node, so the chat
completed the server's command while handle_chat_input still answered
/profiler locally, and the server's command could not be reached. Horizon's
nodes now carry their own command id; a typed /profiler runs locally only
when the chat tree's `profiler` is Horizon's (or there is no server), and
otherwise goes to the server or mod that owns the name. The client's own
buttons keep running locally.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 00:36:22 +03: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
Kacpherek 56d067048f Merge commit '6cc36c6f3a' into unstick/15296
# Conflicts:
#	crates/horizon-client/src/game/boats/below_world.rs
2026-11-11 22:29:50 +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 175c1a69a6 Merge commit '6cc36c6f3a' into unstick/14813
# Conflicts:
#	crates/horizon-client/src/entity_nbt.rs
2026-11-11 22:28:43 +01:00
Kacpherek fa5f2c393e Merge commit '6cc36c6f3a' into unstick/14927
# Conflicts:
#	crates/horizon-client/src/entity_nbt.rs
2026-11-11 22:27:46 +01:00
Kacpherek 68b16245aa Merge commit '6cc36c6f3a' into unstick/14904
# Conflicts:
#	crates/horizon-client/src/entity_nbt.rs
2026-11-11 22:27:17 +01:00
umcekoandClaude Opus 5.5 5ccb6cd15b Perf: a server's click can no longer run /profiler on the client
click_command answered any clicked /profiler line itself, so a server's
chat component, book or dialog with run_command "/profiler share" uploaded
the player's last capture after one click. The client's own [Open],
[Share], [Start], [Stop] and retry buttons now carry a client-only click
action that no server text decodes to (as [Folder]'s open_file already
was); a clicked run_command is a plain command again, sent to the server.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 00:27:09 +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
Kacpherek 32a43ad647 Merge commit '6cc36c6f3a' into unstick/14943
# Conflicts:
#	crates/horizon-client/src/entity_nbt.rs
2026-11-11 22:25:32 +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 d53c507d5e Merge commit '6cc36c6f3a' into unstick/15026
# Conflicts:
#	crates/horizon-client/src/entity_nbt.rs
2026-11-11 22:24:59 +01:00
Kacpherek b5c00e8040 Merge commit '6cc36c6f3a' into unstick/14914
# Conflicts:
#	crates/horizon-client/src/entity_nbt.rs
2026-11-11 22:24:24 +01:00
Kacpherek 23ae7d42c2 Merge commit '6cc36c6f3a' into unstick/14885
# Conflicts:
#	crates/horizon-client/src/entity_nbt.rs
2026-11-11 22:24:15 +01:00
Kacpherek 46179fd42e Pack screen: no I-beam on the filter while the Add-pack browser covers it 2026-11-11 22:23:34 +01:00
umcekoandClaude Opus 5.5 91491459d5 Perf: a server part whose last chunk fails to finish still gets its Stop
The merger was taken out before finish(), so when finish() failed the
abandoned part was closed with a fresh merger that had yielded nothing,
and no Stop{DISCONNECTED} was written for a part that began. finish() now
borrows the merger, which keeps what it yielded for cut_short.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 00:14:36 +03:00
umcekoandClaude Opus 5.5 4cce8f8484 Perf: cap the server's part and take only its own side's fields
A client took whatever size a server announced and inflated every chunk
without limit, and appended fields of any side into the player's capture.
The announced size is now capped at 64 MiB (the share endpoint's own cap),
the inflated part at 512 MiB (the timeline's 256 MiB cap plus the rest of
a 600 s capture; real captures inflate about 3x from 130-230 KB a minute),
and one field at 64 MiB, which also bounds what waits half-arrived. A field
whose message is not side = SERVER, or has no side (an attachment), refuses
the part; what arrived stays with the incomplete warning, as for any part
that fails.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 00:12:29 +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
Kacpherek 1aa3ad9441 Merge commit 'a5df8273bb' into unstick/14179
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
#	crates/horizon-client/src/game/frames.rs
2026-11-11 22:06:31 +01:00
Kacpherek fc67c07589 Merge commit 'a5df8273bb' into unstick/14571
# Conflicts:
#	crates/horizon-client/src/game/entity.rs
2026-11-11 22:04:59 +01:00
umceko c7227eb3f7 Merge remote-tracking branch 'origin/main' into perf-platform-clean
# Conflicts:
#	crates/horizon-client-render/src/gfx/pack_timing.rs
#	crates/horizon-platform/src/lib.rs
2026-11-12 00:03:27 +03: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 190aa00a63 Merge commit 'c8e1eb66199f696d1a6218fbbaeef1863ce5cc00' into fix/explosion-creaking-heart-xp
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-11 17:57:10 -03:00
Vinícius Moreira a4266531f2 Merge commit 'c8e1eb66199f696d1a6218fbbaeef1863ce5cc00' into fix/explosion-player-xp
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-11 17:57:09 -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 bb6b34d530 Merge branch 'main' into fix/15275-broken-stat 2026-11-11 21:51:13 +01:00
Kacper b2258c35e7 Merge branch 'main' into fix/loot-set-enchantments 2026-11-11 21:51:09 +01:00
Kacper 90fed51ab1 Merge branch 'main' into fix/fox-village-shade-defend-trusted 2026-11-11 21:50:47 +01:00
Kacper 5de4e1ee74 Merge branch 'main' into fix/fox-eat-effects-panda-pickup 2026-11-11 21:50:43 +01:00
Kacper be2f80a4cf Merge branch 'main' into fix/beacon-payment-order-tooltips 2026-11-11 21:50:39 +01:00
Kacper 682d70268a Merge branch 'main' into fix/player-rotation-packet-parity 2026-11-11 21:50:24 +01:00
Kacper 12e1d88d71 Merge branch 'main' into fix/chat-scroll-keeps-place 2026-11-11 21:50:20 +01:00
Kacper 6b1f808b13 Merge branch 'main' into fix/14806-fire-reset 2026-11-11 21:50:12 +01:00
Kacper 81336ce7a9 Merge branch 'main' into fix/wind-burst-ender-dragon 2026-11-11 21:50:02 +01:00
Kacper 47e22facbb Merge branch 'main' into fix/drowned-fluid-push 2026-11-11 21:49:56 +01:00
Kacper f2a97a7329 Merge branch 'main' into fix/skeleton-never-freezes 2026-11-11 21:49:52 +01:00
Kacper 1d2b14b01d Merge branch 'main' into fix/14371-rod-neighbours 2026-11-11 21:49:49 +01:00
Kacper 1a9bf76f69 Merge branch 'main' into fix/item-void-floor 2026-11-11 21:49:44 +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
yahya-nusseibeh 715402eb80 Merge remote-tracking branch 'upstream/main' into fix/port-pack-licences 2026-11-11 22:43:21 +02:00
yahya-nusseibehandClaude Opus 5.5 18e26a3e4d Port pack: count a family with its version run on as that family
The whole-word match turned GPLv3, LGPLv3, AGPLv3 and MPLv2 into custom: their token is
gplv3, not gpl. A token now matches a family when it is the family's name followed by
nothing, or by an optional v and digits. Tests for those spellings.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 22:43:18 +02:00
Kacpherek 95756af921 Merge commit 'a5df8273bb' into unstick/15313
# Conflicts:
#	crates/horizon-client/src/mobs.rs
2026-11-11 21:42:52 +01:00
Kacpherek 72e4d61b57 Merge commit 'a5df8273bb' into unstick/14651
# Conflicts:
#	crates/horizon-client/src/mobs/living_tests.rs
2026-11-11 21:42:15 +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
Kacpherek 4c2306d61d Merge commit 'a5df8273bb' into unstick/14561
# Conflicts:
#	crates/horizon-client/src/game.rs
#	crates/horizon-client/src/game/books.rs
2026-11-11 21:39:53 +01:00
Kacpherek ff08319e96 Merge commit 'a5df8273bb' into unstick/15192
# Conflicts:
#	crates/horizon-client-render/src/widgets.rs
2026-11-11 21:39:52 +01:00
Kacpherek 6604778b58 Merge commit 'a5df8273bb' into unstick/14431
# Conflicts:
#	crates/horizon-client-render/src/streaming.rs
2026-11-11 21:39:33 +01:00
Kacpherek 194ddaa041 Merge commit 'a5df8273bb' into unstick/14553
# Conflicts:
#	crates/horizon-client/src/game/use_hand.rs
2026-11-11 21:39:07 +01:00
Kacpherek 289674508b Merge commit 'a5df8273bb' into unstick/14784
# Conflicts:
#	tools/port_map/client2-015.tsv
2026-11-11 21:36:54 +01:00
Kacpherek ce25b504c8 Merge commit 'a5df8273bb' into unstick/14260
# Conflicts:
#	crates/horizon-client/src/projectiles.rs
2026-11-11 21:36:40 +01:00
Kacpherek 5a281cec36 Merge commit 'a5df8273bb' into unstick/14473
# Conflicts:
#	crates/horizon-client/src/game/explosion.rs
2026-11-11 21:36:25 +01:00
Akirabane f2ea985b8d Merge remote-tracking branch 'origin/main' into local/W3-02-invalid-attack-kick 2026-11-11 21:33:47 +01:00
Akirabane 33a70e3fa4 Merge remote-tracking branch 'origin/main' into local/R13-snow-golem-stroll 2026-11-11 21:33:28 +01:00
Akirabane f5ea4bde3a Merge remote-tracking branch 'origin/main' into local/R09-world-folder-name 2026-11-11 21:33:25 +01:00
Akirabane 251976843a Merge remote-tracking branch 'origin/main' into local/PM4-game-012 2026-11-11 21:33:23 +01:00
Akirabane 8e17b81335 Merge remote-tracking branch 'origin/main' into local/R16-accept-teleport 2026-11-11 21:33:21 +01:00
rdbtCVSandGPT 6.1 Sol 1bcc5ec3f9 assets: preserve vanilla range-dispatch binary-search hits
Address kacpherek's request on PR #14365: remove the post-hit tie scan while keeping slice-based search and the short-list upper bound. Extend the existing regression test with 16-entry duplicate and signed-zero lists; expected indices were observed on the JVM and the pinned vanilla method was checked with javap.

Validation: cargo check -p horizon-assets --locked; cargo test -p horizon-assets --locked (53 passed); cargo fmt -p horizon-assets -- --check; cargo clippy -p horizon-assets --all-targets --locked -- -D warnings.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-11 20:31:36 +00:00
rdbtCVSandGPT 6.1 Sol 9ac80b72b7 Move command position validation out of oversized dispatcher
Address the maintainer request on PR #14373 by relocating the loaded and spawnable block-position helpers into a sibling module. Keep both implementations and their game-module visibility unchanged.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-11 20:31:23 +00: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
rdbtCVSandGPT 6.1 Sol 1c5f7c2d3c Merge main into fix/thunder-trap-random-streams
Address the reviewer request by retaining main's invulnerable_time field rename alongside the PR's RNG changes.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-11 20:23:21 +00:00
AkirabaneandClaude Sonnet 5.5 a8d3ffaf2e Move the model parts out of mobs.rs into mobs/model.rs
Polygon, ModelNormals and Part move byte for byte; only visibility changes
(the private members the sibling modules reach become pub(super)), and mobs.rs
declares the module and re-exports Part and Polygon. A port map row follows the
move. mobs.rs goes from 4931 to 4720 lines without test modules, so a change
to Seen has room.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 21:23:03 +01:00
AkirabaneandClaude Opus 5.5 e7922700f0 Check that the guest's attack on the pig hurts it
The attack-checks test only asserted that attacking a pig does not
disconnect the guest. It now also asserts that the pig lost health,
so a normal attack on a mob is shown to still land.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 21:21:23 +01: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
DetimesandClaude Opus 5.5 7ad2c3b0bc Merge upstream/main into perf-equipment-cache
Brings the branch up to date with main for review.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 20:11:47 +00:00
LucasandClaude Opus 5.5 884fc3f5b1 World text: the core_text tests in their own file
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 21:01:54 +01: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 da035b00ec Merge main into pr-14962
# Conflicts:
#	tools/port_map/game-012.tsv
2026-11-11 20:56:24 +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
Kacpherek f2b07381ab Merge main into pr-13755
# Conflicts:
#	crates/horizon-client/src/mobs.rs
2026-11-11 20:55:08 +01:00
Kacpherek 983bd56fb5 Merge main into pr-14598
# Conflicts:
#	crates/horizon-client/src/lan/prediction.rs
2026-11-11 20:54:52 +01:00
Vinícius MoreiraandClaude Opus 5.5 bee546c085 Core shaders: blocks and samplers a pipeline provides, and blocks on one line
A pack's core shader could read only vanilla's blocks and samplers. A
pipeline that gives it more (the improved-transparency stages' depth bounds
and coefficients for core/text, Polytone's blocks) translates it with
translate_core_with and the names it provides; CoreProgram::other lists
them in binding order, at other_binding(i). A uniform block declared on one
line is bound like one over several lines, where it came out unbound and
the shader was refused. An unprovided one is named as the pack declared it.

Part of #11486

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 16:53:50 -03: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 80392718df Merge main into fix/15134-flame-particles 2026-11-11 20:45:10 +01:00
Kacpherek 122a7650ad Merge main into fix/disabledlabel 2026-11-11 20:45:03 +01:00
Kacpherek c4c5649ce9 Merge main into fix/chunk-empty-sections 2026-11-11 20:44:56 +01:00
Kacpherek da7c9d05b3 Merge main into fix/brightness-gate 2026-11-11 20:44:48 +01:00
Kacpherek b61cff5625 Merge main into fix/chiseled-shelf-can-take 2026-11-11 20:44:41 +01:00
Kacpherek 938c04c32e Merge main into fix/jigsaw-local-edit 2026-11-11 20:44:34 +01:00
Kacpherek 37f6d169d7 Merge main into port_map/farmland-is-near-water 2026-11-11 20:44:26 +01:00
Kacpherek ebcd7beedb Merge main into tools/fixtures-ask-for-work-dir 2026-11-11 20:44:19 +01:00
Kacpherek 1e45ee6ed3 Merge main into tools/port-ledger-json-after-query 2026-11-11 20:44:12 +01:00
Kacpherek 8bf3aa3982 Merge main into fix/batch11-c 2026-11-11 20:44:04 +01:00
Kacpherek 1ae7d9053c Merge main into fix/15022-crystal-fire 2026-11-11 20:43:36 +01:00
Kacpherek d6dd30c7ce Merge main into fix/14964-orb-air 2026-11-11 20:43:21 +01:00
Kacpherek 7b98f70c85 Merge main into fix/15013-launcher-ipv4-fallback 2026-11-11 20:43:13 +01:00
Kacpherek 7c723354eb Merge main into fix/walk-target-scores 2026-11-11 20:43:06 +01:00
Kacpherek c9675c1ba5 Merge main into fix/14461-firework-dispenser-rotation 2026-11-11 20:42:52 +01:00
Kacpherek 56b3fbab8c Merge main into agent/pack-options-shell 2026-11-11 20:42:37 +01:00
Kacpherek debbbf6585 Merge main into fix/jigsaw-priority-tooltip 2026-11-11 20:42:16 +01:00
Kacpherek ac4204f395 Merge main into split-renderer 2026-11-11 20:42:09 +01:00
Kacpherek 8215f3afef Merge main into shader-api-linear-blend 2026-11-11 20:41:54 +01:00
Kacpherek 7714b1d37a Merge main into fix/goat-long-jump 2026-11-11 20:41:46 +01:00
Kacpherek dfcfa1a0db Merge main into fix/hanging-item-stack-config 2026-11-11 20:41:38 +01:00
Kacpherek b045dddfa1 Merge main into fix/tnt-minecart-explosion-power 2026-11-11 20:41:30 +01:00
yahya-nusseibeh 9e701221fd Merge upstream main into fix/servers-dat-lock 2026-11-11 21:32:53 +02:00
yahya-nusseibeh 02b49472ef Merge upstream main into fix/ci-pin-actions 2026-11-11 21:32:50 +02:00
yahya-nusseibeh 5a76ab5f51 Merge upstream main into fix/neoforge-version-path 2026-11-11 21:32:42 +02:00
yahya-nusseibeh ef218f6ced Merge upstream main into fix/windows-console-close 2026-11-11 21:32:37 +02:00
yahya-nusseibeh 8e4d76ef04 Merge upstream main into fix/friends-try-every-endpoint 2026-11-11 21:32:29 +02:00
yahya-nusseibehandClaude Opus 5.5 a8f543b1cc Launcher: follow links above the data folder when moving it
Moving the data folder was refused when any folder above a data root was a symbolic link (a
~/.local/share on another disk, Fedora Atomic's /home). Only the roots are copied, so only the
root itself may not be a link.

Split out of #15247 at review.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 21:31:44 +02:00
yahya-nusseibehandClaude Opus 5.5 5f61926401 Launcher: bound the sign-in services' interval and lifetimes
The device-code interval, its expiry and the Minecraft token lifetime came from the services
unchecked; Instant + a huge interval panicked the sign-in thread and left the window on the
code. The interval is clamped to 1-60 s (slow-down too), the device code's expiry to an hour
and the token lifetime to a week, all added with saturating_add.

Split out of #15247 at review.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 21:31:24 +02:00
lucasmts59andClaude Opus 5.5 6cd362ab78 Run a resource pack's core/text on world text
Vanilla 26.3 draws text displays, name tags and their backgrounds with
core/text (TEXT_POLYGON_OFFSET, and TEXT_SEE_THROUGH with
IS_SEE_THROUGH), so a pack's text shader reaches them, not only the
GUI's. The Globals and fog blocks the pack shaders read are made once a
frame for core/rendertype_lines, core/sky and core/text.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 20:25:30 +01:00
lucasmts59andClaude Opus 5.5 39f876471f Core shaders: 26.3's #include, varying locations and valued defines
Pastes an #include every time, as shaderc does (vanilla's includes carry
guards), and a #moj_import once, as 26.2's GlslPreprocessor did; a
shader may paste at most 1024 files and grow to 1 MiB, so includes that
multiply are refused instead of exhausting memory. Keeps a 26.3
shader's own varying locations on both stages, even where another
branch gives the same name another one, so branch-local varyings no
longer push a program past OpenGL's 16. Takes NAME VALUE defines, as
RenderPipeline.Builder.withShaderDefine(name, int).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 20:25:30 +01:00
Kevin RamirezandClaude Opus 5.5 ad8dd2f57e fishing_hook_entities: run the servers in peaceful
The flat world lies below y 40 and seed 12345 has slime chunks within
the view distance, so either server may spawn a slime
(Slime.checkSlimeSpawnRules) during the minute the steps take. On
Horizon one did: it pushed the boat off the bobber's line before the
cast and killed Alice, so the boat was never hooked. Fishing hooks do
not depend on the difficulty; peaceful keeps every monster out.

Fixes #15323

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 13:25:20 -06:00
yahya-nusseibehandClaude Opus 5.5 98b40e8e5e protocol: grow the inflate buffer instead of reserving the declared size
A compressed frame declares its uncompressed size up front, and the inflater reserved that
much at once: a few bytes declaring 8 MiB made the reader allocate 8 MiB per frame. The
buffer now grows as output arrives (capped by DEFLATE's ~1032:1 ratio), so a lying frame
is refused after a small allocation and a real large body still inflates whole.

Split out of #15249 at review.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 21:16:47 +02:00
mentallyunstable-labandClaude Opus 5.5 018ca6a76e docs(layers): regenerate today's exceptions from tools/layers.json
The table said 47 exceptions at d5504cd328 and still listed edges that
are gone (the horizon-client-input row; -theme, -bench, -telemetry and
-mod-api-gen for the render crate). Rebuild it from the 30 entries in
'allowed' at a89f338 and point at tools/check_layers.py for the live
count.

Closes #15408

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 21:10:55 +02:00
Kevin Ramirez 242fba5b5a Merge upstream main for CI (play_ping_tests fix) 2026-11-11 13:07:53 -06:00
DetimesandClaude Opus 5.5 03decca8b2 Merge upstream/main into perf-recording-box
Resolves the conflict with main so verify and parity can run again.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 19:07:44 +00: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 c93c23248a Guard item count arithmetic against i32 overflow 2026-11-12 07:45:27 +13:00
Evrim CicekandClaude Opus 5.5 d2a9fcd3f3 Merge upstream/main into fix/rocket-fall-distance
new_rocket now takes the rocket's own random (upstream); the rocket's splash and move helpers stay before it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 21:45:06 +03:00
Web-mc1 fbfa055eff Guard item count arithmetic against i32 overflow 2026-11-12 07:40:58 +13:00
Ludovic Marie 937cadf658 Merge branch 'main' into split/pages-fields 2026-11-11 19:38:59 +01:00
Ludovic Marie 3e2b1d6d19 Merge branch 'main' into split/cosmetics-settings 2026-11-11 19:38:56 +01:00
Ludovic Marie dc8f30b7e8 Merge branch 'main' into layers/chat-guard-hook 2026-11-11 19:38:53 +01:00
Ludovic Marie e5dedb5239 Merge branch 'main' into layers/extras-reconnect 2026-11-11 19:38:51 +01:00
umceko fb85eb77e0 Merge remote-tracking branch 'origin/main' into perf-platform-clean
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
2026-11-11 21:18:57 +03:00
umcekoandClaude Opus 5.5 a3efbe017d profile_client_e2e: the stop line reads "Profile saved" since the chat redesign
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 21:18:38 +03:00
Web-mc1 b610bc7618 Guard held item count against u32 overflow 2026-11-12 07:15:10 +13:00
Web-mc1 b082e400c6 Guard held item count against u32 overflow 2026-11-12 07:09:38 +13:00
AkirabaneandClaude Opus 5.5 9a267cf4bf Disconnect a player who attacks an item, an orb, themselves or an arrow
ServerGamePacketListenerImpl.handleAttack disconnects a player whose
attack names an ItemEntity, an ExperienceOrb, the player itself or an
AbstractArrow that is not isAttackable, with
multiplayer.disconnect.invalid_entity_attacked, after the world border,
attack range and PIERCING_WEAPON checks. A hosted or dedicated Horizon
server let the attack end without a word.

handleAttack's checks before Player.attack move to
game/lan_actions/attack_checks.rs: the border and range checks
guest_attack already did, then that test. An arrow's isAttackable reads
#minecraft:redirectable_projectile from the tags the players were sent,
so a data pack that adds arrows to it keeps them attackable.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 18:56:15 +01:00
Web-mc1 b7414749eb Clamp NBT item count to prevent oversized stacks 2026-11-12 06:35:15 +13:00
umcekoandClaude Opus 5.5 239b96621a Performance platform: files over 3000 lines don't grow
The pass timing switches and readings and the world pass helpers move
from gfx.rs to gfx/pass_timing_api.rs, the streamer's queue census and
job_stats to streaming/, Horizon's mark sprite to mark_sprite.rs, and
`horizon-ctl lan op` to game/lan_op.rs. A guest's plugin channel
routing moves beside horizon_channel, the client takes the profile
channel's replies before handle_packet, and tick_profile starts and
ends a mob's tick for both the sampler and the mob costs. The server
bench test reads the saved line the server prints now.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 19:53:39 +03: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
AkirabaneandClaude Sonnet 5.5 f19afb61ad Merge origin/main into local/R16-accept-teleport
The conflict was the AcceptTeleportation arm in lan/hub.rs: the handler stays in
hub/accept_teleport.rs, which takes main's change of the guest's prediction
record (g.record instead of g.anticheat.record).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 17:14:23 +01:00
Kevin RamirezandClaude Opus 5.5 d9eab08f7f A driven horse takes no fall damage on its rider's client
Entity.hurt only calls hurtServer on a server level. The rider's client
ticks the horse it drives with the server's mob code, so a fall, lava or
suffocation hurt and killed the client's copy: it lay dead on the ground
while the server's horse lived, and could be ridden again (#15294).

Fixes #15294

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 09:34:53 -06:00
YourPancakes-dev d404575740 test(render): use the worker-count fix from main
Drop the duplicate test change now that upstream handles hybrid Linux CPUs.
2026-11-11 18:33:13 +03:00
AkirabaneandClaude Sonnet 5.5 c6f61bbcc4 port_map: the goat and fox spawn rows of game-012.tsv name the live spawn path
Goat.finalizeSpawn, GoatAi.initMemories and Fox.populateDefaultEquipmentSlots
are reached by live spawns; Fox.finalizeSpawn keeps the one gap that is left.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 16:29:41 +01:00
umcekoandClaude Opus 5.5 08b55819b1 Performance platform: leave out what is not the profiler
Removes the fork's review and claim tooling and its docs, and the scene
corpus, which the design keeps separate (an input fixture, not a
measurement). The design doc describes the viewer and upload API in
horizon-rust/horizon-site and drops the planning sections; tests and
doc comments point at playhorizon.dev and horizon-site. The debug keys'
comment is upstream's again.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 18:21:33 +03:00
umcekoandClaude Opus 5.5 d27e19390d Merge upstream main into the performance platform
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 18:18:28 +03:00
Umut Cevdet Koçak ad491915a5 Merge pull request #280 from sunrise-org/perf-idle-cost
Profiler docs: idle hooks' cost within the margin of error
2026-11-11 18:16:50 +03:00
umcekoandClaude Opus 5.5 f356ceda41 Profiler docs: the idle hooks' cost is within the margin of error
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 18:14:56 +03:00
rdbtCVSandCodex gpt-6.1-sol 70865d77e8 Fix deeply fallen boat loading and dedicated-server void crashes
Match vanilla saved boat bounds and void removal, and make chunk and collision coordinate arithmetic safe at integer boundaries.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-11 14:58:17 +00:00
Riccardo Guerriero 09b8b5b94b Merge origin/main into perf/faster-client-lib-tests 2026-11-11 15:57:05 +01:00
Riccardo Guerriero d526f569a0 Merge origin/main into fix/beacon-payment-order-tooltips 2026-11-11 15:57:04 +01:00
Riccardo Guerriero 957ba89eac Merge origin/main into fix/item-void-floor 2026-11-11 15:57:04 +01:00
DUzzL c4146a25af Count the broken statistic for every equipment break
ServerPlayer.onEquippedItemBroken awards Stats.ITEM_BROKEN whatever the
slot. Award it in player_equipment_broke instead of at two call sites, so
worn armour, shields and enchantment-driven breaks count too.
2026-11-11 15:33:49 +01:00
GIGABAIT93andClaude Opus 5.5 dc064e4bb1 docs: broken_on picks a version, it isn't only shown
A reader skips a version broken on the player's Horizon commit for an
older one that builds; only downloads, stars and updated are for show and
sort. None of the four is checked against a downloaded file.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 16:21:50 +02:00
yahya-nusseibehandClaude Opus 5.5 c59deaa19b Port-pack: only a plain NeoForge version makes the loader jar's path
A modpack's NeoForge version (modrinth.index.json dependencies.neoforge, CurseForge, modlist)
went unchecked into loader_jar's file name and URL. A version such as
"21.1.1/neoforge-21.1.1-universal.jar#/../../../../<dir>/x" made fetch create every directory
on that path and rename a real NeoForge jar over <dir>/x-universal.jar, with no checksum
(the .sha1 lookup got the jar and failed).

loader_jar now takes only [A-Za-z0-9._+-]{1,64} without "..", and the loader jar is downloaded
only with a 40-hex SHA-1 from Maven.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 16:06:13 +02:00
DUzzL c741fd4d39 Remove empty custom_data after set_custom_data item modifier 2026-11-11 15:04:13 +01:00
AkirabaneandClaude Sonnet 5.5 fb6b8c54e2 A snow golem strolls with the probability vanilla gives it
SnowGolem.registerGoals builds its WaterAvoidingRandomStrollGoal with the
three-argument constructor and 1.0000001E-5F, not the default 0.001 that
Stroll::avoiding_water uses.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 15:00:03 +01:00
AkirabaneandClaude Sonnet 5.5 d1a243289c LAN: a second accept of the same teleport id disconnects
ServerGamePacketListenerImpl.handleAcceptTeleportPacket: an accept of the last
teleport id sent with no teleport awaited is invalid player movement. The
handler moves into hub/accept_teleport.rs so hub.rs does not grow.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 14:47:49 +01:00
DUzzL eb18b83652 Swallow SC_KEYMENU so Alt+Space does not open the Windows system menu 2026-11-11 14:46:32 +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
AkirabaneandClaude Opus 5.5 c4889805f3 Create New World: name the folder as findResultFolder does
WorldCreationUiState.findResultFolder trims the name (String.trim), takes the translated
selectWorld.newWorld when nothing is left, and asks FileUtil.findAvailableName for a free folder:
sanitizeName turns only SharedConstants.ILLEGAL_FILE_CHARACTERS and . / " into _, a name Windows
reserves is wrapped in underscores, a name ending in " (n)" counts on from n, the name is cut to
255 characters with its count, and a count that can't be parsed falls back to "World".

Horizon turned every character but letters, digits, space, - and _ into _ (Bob's world! became
Bob_s world_), named a blank world's folder World, and counted every taken name up from 1. The
rules move out of title.rs, a big file, into title/world_folder.rs with their test.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 14:40:07 +01:00
DUzzL 64fc971ebc Honor an egg's explicit chicken variant removal before the item default 2026-11-11 14:39:38 +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
rdbtCVSandCodex GPT-6.1 57e1e16ca2 Reduce packet compression level and measure the tradeoff
Co-Authored-By: Codex GPT-6.1 <[email protected]>
2026-11-11 13:23:59 +00:00
rdbtCVSandGPT 6.1 Sol b52d83f71e fix(ui): wrap and anchor focused jigsaw priority tooltips
Match Tooltip.splitTooltip, WidgetTooltipHolder and BelowOrAboveWidgetTooltipPositioner. Keep real hover distinct from the hidden keyboard/controller pointer and test priority label/value/packet mapping.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-11 13:23:09 +00:00
DUzzL 7aef48e484 Tick restored wind-charge fire and sync HasVisualFire 2026-11-11 14:14:52 +01:00
GIGABAIT93andClaude Opus 5.5 c6e83d5f09 docs: the index's updated, and the fields a reader may rely on
`projects[].updated` is the date of a project's newest version, set by the
registry's publish job (horizon-rust/horizon-mods#683). While the index is
preview, a reader may rely on every field but the publish job's four
(`downloads`, `stars`, `updated`, `broken_on`), which may be absent at any
time. Point (3) of #10562.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 15:10:46 +02:00
rdbtCVSandGPT 6.1 Sol 72c066552e Merge main into hoglin feeding fix, preserving the extracted predicate
Keep main's takes_food extraction and canAgeUp baby check, and apply the contributor's pacified-hoglin breeding gate in ai/feeding.rs. Normalize two test citations to the vanilla method names.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-11 13:09:38 +00:00
yahya-nusseibehandClaude Opus 5.5 3f254035ec Port-pack: licence text stays one comment line, and families match whole words
- A jar's licence text (and file name) went into a `#` comment of the carried mod's
  horizon-mod.toml as it was: a line break ended the comment and wrote keys of its own. Control
  characters are now replaced with spaces.
- license_kind matched families by substring, so "limited" read as MIT (permissive), "template"
  as MPL (copyleft) and "Discord" as ISC. It now matches whole words of the text.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 15:01:06 +02:00
rdbtCVSandGPT 6.1 Sol 5644d16a61 Fix held-stack break handling when processed wear is zero
Follow ItemStack.hurtAndBreak and keep custom-durable stacks at their damage limit intact when effective wear is zero. Extend the survival mining regression and disambiguate a Rust field from a vanilla ledger citation.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-11 12:38:30 +00:00
yahya-nusseibehandClaude Opus 5.5 d3da93ba30 CI: pin the third-party Rust actions to reviewed commits
dtolnay/rust-toolchain@stable (a branch) and Swatinem/rust-cache@v2 (a movable tag) run inside
the jobs that build the archives players download, before the upload Nightly signs: whoever
could move those refs could change released binaries. Both are pinned to full commit SHAs
(rust-toolchain's stable branch head and rust-cache v2.9.3), with toolchain: stable passed
explicitly since rust-toolchain reads the toolchain from its ref otherwise.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 14:22:40 +02:00
yahya-nusseibehandClaude Opus 5.5 545b8f9e8d Client: hold the servers.dat lock on every save, each process with its own temp file
servers::lock() keeps a ping storing an icon and a save of the list from undoing each other,
but adding or editing an entry on the screen, and the gameplay saves of a server's resource
pack choice and Code of Conduct, loaded and saved without it: a favicon saved meanwhile could
drop the edit, or the edit the icon. Every writer also used the same servers.dat_tmp, so
overlapping writes could rename an interleaved file into place, which then loads as an empty
list and is saved as one.

Those three now hold the lock (the screen releases it before reloading the list), and the
temporary file is named per process.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 14:19:49 +02:00
rdbtCVSandGPT 6.1 Sol 906b9143f3 Merge main into layers/chat-guard-hook
Resolve startup registration and layer-exception conflicts while preserving both branches.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-11 12:17:22 +00:00
rdbtCVSandGPT 6.1 Sol 8c893c55f0 Merge main into split-renderer to pick up deterministic CI fixture
Preserve the contributor's renderer extraction unchanged while including main's fix for the Git maintenance race in test_git_slim. The diff against main remains the same four moved methods and port-map citation.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-11 11:59:51 +00:00
yahya-nusseibehandClaude Opus 5.5 c2e172a8a3 Server: on Windows, wait for the clean stop when the console closes
For CTRL_CLOSE_EVENT, CTRL_LOGOFF_EVENT and CTRL_SHUTDOWN_EVENT, Windows ends the process as
soon as the console handler returns, so the supervisor died right after counting the request
and never stopped its game. The game runs in its own process group and gets no event: it kept
the world and the port, unsupervised, until it was killed without a save.

The handler now waits, within what Windows allows (4 s for a close, 15 s for a logoff or
shutdown), until the supervisor reports with signals::stopped() that its game has stopped.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 13:49:20 +02:00
DUzzL 9147b0893b Particles face the frame's camera, not the player's own yaw and pitch 2026-11-11 12:29:42 +01:00
yahya-nusseibehandClaude Opus 5.5 004628230c Friends: try every endpoint that answers before giving up on a direct join
join_direct gave the handshake only to the first endpoint that answered the hello, and returned
its error with `?`, so the invite's other endpoints and its relay were never tried. Invites
carry the host's LAN addresses on the default port, and on the guest's own network another
Horizon host at the same private address (a housemate's game) can answer first: a valid code
failed as "The code doesn't match".

Each answering endpoint now gets the handshake in turn until one takes the code. When none
does, the relay is tried as before; without a relay, the wrong code (else the first failure)
is reported.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 13:27:42 +02:00
rdbtCVSandCodex GPT-6.1 Sol 4d8fbac921 Keep non-default mob locomotion serial until acceleration is bounded
Initial velocity does not bound movement after MoveControl reads movement and flight attributes. Conservatively exclude non-default locomotion and movement effects, reject non-finite velocity, and cover a stationary fast cow crossing into a distant herd in production and verify.

Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-11 11:27:41 +00:00
YourPancakes-dev 4e43f7a14f test(render): honor the worker policy on hybrid Linux CPUs 2026-11-11 14:23:50 +03:00
TheGT 75cfb4cc28 Merge remote-tracking branch 'origin/main' into tools/fixtures-ask-for-work-dir 2026-11-11 11:23:39 +00:00
TheGT dd60b729ee Merge remote-tracking branch 'origin/main' into tools/port-ledger-json-after-query 2026-11-11 11:22:13 +00:00
MSedgeMC 651abefe2b port_map: WithInactiveMessage's grey label 2026-11-11 11:14:29 +00:00
MSedgeMC 8858d0c7e0 test: a disabled button's label is grey
The label's Draw::Text colour is 0xFFA0A0A0 when the button is inactive.
2026-11-11 11:14:29 +00:00
MSedgeMC df589fa039 test: a chunk without sections loads as an empty chunk
Without the change `ChunkData::new` answers
`Err(InvalidData, "chunk is missing sections")`.
2026-11-11 11:05:38 +00:00
MSedgeMC 2c8c04518d fix: a chunk without a sections list loads as a chunk with no sections
`SerializableChunkData.parse` reads the section list with
`getListOrEmpty("sections")`, so a chunk that does not carry one is a chunk with
no sections rather than an unreadable one. `ChunkData::new` refused it, so an
empty or hand-written chunk could not be opened; it now keeps an empty list in
the tag, which is what the writer would put there and what the accessors below
look for.
2026-11-11 11:05:37 +00:00
MSedgeMC 08e01dc3da port_map: EditBox.handleCursor's I-beam 2026-11-11 10:37:18 +00:00
MSedgeMC c289d208e6 test: a hovered text field asks for the I-beam cursor
Not hovered asks nothing; hovered asks Ibeam; a read-only one asks NotAllowed.
2026-11-11 10:37:18 +00:00
MSedgeMC 84d6e3d97a fix: a hovered text field shows the I-beam cursor
EditBox.handleCursor requests CursorTypes.IBEAM while hovered and editable, and
NOT_ALLOWED when it takes no text. Horizon's cursor set had no I-beam, and the
pack screen's search boxes and the settings shell's search never asked for one.
widgets::text_cursor adds it, the two screens call it where their fields are
drawn, and the client maps Cursor::Ibeam to CursorIcon::Text.
2026-11-11 10:36:57 +00:00
MSedgeMC 226d535e77 fix: a disabled button's label is grey
AbstractWidget.WithInactiveMessage.getMessage returns
ComponentUtils.mergeStyles(message, Style.EMPTY.withColor(-6250336)) while the
widget is inactive, so a disabled button's message is drawn grey (0xFFA0A0A0).
screen_draw::button drew the same white label in both states; it now colours
the label when inactive.
2026-11-11 10:35:57 +00:00
YourPancakes-dev b0ba94929e test(net): cover malformed packet frame headers
Make the connection retry test independent of external addresses and add a reproducible release benchmark.
2026-11-11 13:35:52 +03:00
MSedgeMC 7f0f9337f3 port_map: Set Enchantments replaces the levels 2026-11-11 09:52:12 +00:00
MSedgeMC 7d011d64aa test: Set Enchantments replaces the levels 2026-11-11 09:52:12 +00:00
MSedgeMC 4ea481cb2f fix: Set Enchantments replaces the levels 2026-11-11 09:52:12 +00:00
rdbtCVSandCodex GPT-6.1 Sol e592fc3af0 Keep owned mobs serial until owner teleport reach is bounded
Cover real wolf, cat and parrot teleports between separated herds in production and verify. Document conservative reach maintenance and implementation/performance acceptance requirements.

Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-11 09:31:56 +00:00
rdbtCVSandGPT 6.1 Sol ddb70f7a42 gfx: limit Raptor Lake exemption to tested Mesa 26.0.8
Keep older stable and prerelease Mesa drivers on the conservative first_instance workaround. Exercise the release boundary, distribution suffix, unknown versions, earlier Intel families and backend/vendor guards.

Review fix by rdbtCVS's AI agent (OpenCode, GPT 6.1 Sol).

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-11 10:28:33 +01:00
darkliroandAntigravity 505c938cad gfx: allow indirect multi-draw on Raptor Lake with Mesa >= 26
Allow indirect multi-draw and GPU culling on Intel Gen12 Raptor Lake
adapters when running on Mesa 26.0+. Tested on Intel Iris Xe Graphics
(RPL-U, device 0xa7a1) under Mesa 26.0.8: verified zero terrain flicker
or attribute corruption while cutting frame times by 28%.

Older Mesa versions and earlier Intel generations (Ice Lake, Tiger Lake,
Rocket Lake, Alder Lake) retain the workaround.

Co-Authored-By: Antigravity <[email protected]>
2026-11-11 10:28:33 +01:00
umcekoandClaude Opus 5.5 adbb0b4578 Merge main into dims/portal-scales
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 12:25:08 +03:00
MSedgeMC 2aeaeb2495 test: the +-30,000,000 gate answers 15, inside answers the raw brightness
`TestLevel` gains a `sky_light` override so the two answers differ (0 inside,
15 outside); without the change both read 0.
2026-11-11 09:24:13 +00:00
MSedgeMC 782331b499 fix: full daylight outside the +-30,000,000 gate in getMaxLocalRawBrightness
`LevelReader.getMaxLocalRawBrightness(pos)` answers 15 when the position is
outside `x >= -30000000 && z >= -30000000 && x < 30000000 && z < 30000000`,
because there is no chunk to ask there; inside it is `getRawBrightness(pos,
skyDarken)`. Horizon always asked the level, so past the gate it returned
whatever the (unloaded, dark) level answered: saplings and mushrooms never grow
and nothing that reads the brightness sees daylight.
2026-11-11 09:24:13 +00:00
rdbtCVSandGPT 6.1 Sol 0f1921c196 Make special-attack parity runnable without a prebuilt test artifact
Build and select the gameplay library test executable for the automatic parity runner; retain the explicit artifact override. Cover argument-free invocation and Cargo artifact selection.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-11 09:01:00 +00: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
MSedgeMC 30e4e488b2 port_map: ChiseledBookShelfBlockEntity.canTakeItem asks the target's room
The hopper's path applies the `hasAnyMatching` rule through
`can_take_into`.
2026-11-11 08:49:01 +00:00
rdbtCVSandGPT 6.1 Sol b2095cc1ca Merge current main into TNT minecart explosion fix
Preserve the contributor's TNT power change and the minecart extraction already on main. Git resolved both touched files without manual conflict edits. The net change against main is three files (+82/-2).

Validation: rustfmt, public-tree, layer/file-size checks and three command-fixture Python tests pass. The focused Rust test attempt failed in sccache before compiling Horizon; vanilla resources and the port ledger are unavailable, so gameplay proof remains outstanding.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-11 08:42:36 +00:00
MSedgeMC e2ee8ad7d9 test: a full hopper cannot take a book from a chiseled shelf
`a_full_hopper_cannot_take_a_book_from_a_chiseled_shelf` asks the rule
with a hopper full of stone (false) and an empty one (true). Without the
shelf rule both answer true.
2026-11-11 08:40:34 +00:00
MSedgeMC c59a4d6ffd fix: a chiseled bookshelf only gives a book to a hopper with room
`ChiseledBookShelfBlockEntity.canTakeItem` is
`target.hasAnyMatching(other -> other.isEmpty() || (same item and
components && other.count + stack.count <= target.getMaxStackSize(other)))`:
a hopper may take a book only when it has a free slot or a matching stack
with room.

Horizon's `Containers::can_take` had no shelf rule, so a full hopper
still pulled a book out (and put it back). The hopper's path now asks
`can_take_into`, which applies the shelf rule before the existing checks.
2026-11-11 08:40:13 +00:00
MSedgeMC 4256dec408 port_map: handleSetJigsawBlock applies locally too
The guest path marks the block entity; the integrated game applies the
same packet through the editor's Done.
2026-11-11 08:33:44 +00:00
MSedgeMC 959c3b6180 test: a single-player jigsaw edit applies and marks the chunk
`a_local_jigsaw_edit_applies_and_marks_the_chunk` sends a
`SetJigsawBlock` through the editor's Done path on an integrated game:
the block entity takes the new name and priorities and its chunk is
marked. Without the local application the name stays the default.
2026-11-11 08:33:44 +00:00
MSedgeMC e00864ba59 fix: a jigsaw edited in single player is applied
`ServerboundSetJigsawBlockPacket` is handled on the server
(`handleSetJigsawBlock`), which writes the block entity and marks it. The
editor's Done only builds that packet, and `Game::send` drops it when
there is no remote (the integrated server is this game), so a jigsaw edit
in single player changed nothing.

`block_editor_outcome` now applies the packet locally when `remote` is
none, through the same `apply_set_jigsaw_block` a guest's edit uses.
2026-11-11 08:33:23 +00:00
TiggerTakkyTTTandCodex 866057db34 Merge upstream/main into fix/wind-burst-ender-dragon
Co-Authored-By: Codex <[email protected]>
2026-11-11 15:30:28 +07:00
rdbtCVSandCodex GPT-6.1 Terra 9062522b9d Merge updated mob groundwork into parallel islands
Co-Authored-By: Codex GPT-6.1 Terra <[email protected]>
2026-11-11 08:23:32 +00:00
rdbtCVSandCodex GPT-6.1 Terra fa0e2b01cd Merge updated item and helper work into mob groundwork
Co-Authored-By: Codex GPT-6.1 Terra <[email protected]>
2026-11-11 08:22:25 +00:00
rdbtCVSandCodex GPT-6.1 Terra f663c83f98 Merge updated parallel tick helpers into item moves
Co-Authored-By: Codex GPT-6.1 Terra <[email protected]>
2026-11-11 08:20:28 +00:00
rdbtCVSandCodex GPT-6.1 Terra 4740691c04 Merge origin/main into parallel tick helpers
Co-Authored-By: Codex GPT-6.1 Terra <[email protected]>
2026-11-11 08:18:54 +00:00
foudretbr 853ec54bd3 fix(mobs): load and retain armor stand pitch from entity Rotation (#14799)
Read the stand's pitch component alongside yaw during NBT deserialization and preserve both components across save roundtrips.
2026-11-11 08:42:18 +01:00
TheGT ee8d4c4a72 Merge remote-tracking branch 'origin/main' into tools/port-ledger-json-after-query 2026-11-11 07:36:06 +00:00
Riccardo GuerrieroandClaude Fable 5.2 4fcb17840a Surface stage: sample each ore vein's densities over its own rows, when first read
The surface stage bound three whole-chunk density volumes for the ore veins
(copper's vein toggle, the shared richness, iron's) before its walk, although
each vein only reads the rows of its own y range and the richness is read in
about a quarter of the chunks.

A vein's rule now binds its densities with the rows it can read: the buffer
covers those rows widened to whole 16-block world rows, and it is sampled at
the first read. Other bindings (carvers, plain bind_density), and a volume
whose steps are not 1, are sampled whole when bound, as before. The per-block
read uses plain fields of the buffer, so the hot path keeps no copy of the
volume on the stack.

Same sampler, same positions inside the rows, same values: chunk dumps of
4 seeds x Overworld/Nether/End, terrain and decorated, are identical to main.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-11 07:57:13 +01:00
MSedgeMC fb05c1acfc port_map: FarmlandBlock.isNearWater is the water fluid search
Vanilla's predicate is `state.getFluidState().is(FluidTags.WATER)`, which
the inline 9x2x9 search already matches; the row's `canBeHydrated` gap
was wrong.
2026-11-11 05:02:45 +00:00
TheGTandOpenCode 8e4e46fdd6 tools: the command fixtures ask for their work directory instead of crashing
Co-Authored-By: OpenCode <[email protected]>
2026-11-11 04:36:10 +00:00
TheGTandOpenCode 9351cd381f tools: port_ledger.py accepts --json after the query's name, as its own help says
Co-Authored-By: OpenCode <[email protected]>
2026-11-11 04:26:33 +00:00
rdbtCVSandGPT 6.1 Sol 724b70e606 Merge repaired main to refresh item-frame verification
The current-head verify run 42944105269 used the old synthetic merge with the inherited CEM shadowing error. Main now includes the shared repair from PR #15038. Preserve both the contributor change and signed-byte regression fixup; no duplicated repair code.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-11 04:11:52 +00:00
MSedgeMC 72e3ec1924 Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-11 03:53:38 +00:00
MSedgeMC a2cdad2a10 Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-11 03:53:17 +00:00
MSedgeMC 53e8715d83 Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-11 03:53:14 +00:00
MSedgeMC 278dee9871 Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-11 03:53:02 +00:00
MSedgeMC 08eed8f8a7 Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-11 03:53:00 +00:00
MSedgeMC 7a8364a0f7 Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-11 03:52:53 +00:00
MSedgeMC 8eb87f05ff Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-11 03:52:50 +00:00
MSedgeMC c5406b0bf0 Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-11 03:52:45 +00:00
SenjinandClaude Opus 5.5 2f3a3e302d Dynamic Resolution: a window gathers timed frames until it has enough, so a slow scene still steps down
A window cleared its GPU timings when its time was up, then wanted eight: at 10 FPS (five frames in
500 ms) or with timestamps arriving in gaps, every window started over and the scale never came
down in the scenes that need it most (review on #14815).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 04:29:54 +01:00
Nikita Maximilian GrimmandClaude Fable 5.2 17acc701da Merge remote-tracking branch 'origin/main' into typed-randoms-1a
Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-11 04:26:01 +01:00
MSedgeMC c96f20ac6c port_map: map CatAvoidEntityGoal continue 2026-11-11 03:23:17 +00:00
MSedgeMC fb6707ec92 test: a tamed cat stops fleeing players 2026-11-11 03:23:17 +00:00
MSedgeMC dfc7fdd786 fix: a tamed cat stops fleeing players 2026-11-11 03:23:02 +00:00
Vinícius Moreira 306dca0a21 Merge remote-tracking branch 'upstream/main' into fix/explosion-creaking-heart-xp 2026-11-11 00:03:46 -03:00
Vinícius Moreira 541bc4b519 Merge remote-tracking branch 'upstream/main' into fix/explosion-player-xp 2026-11-11 00:03:46 -03: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
Evan-lhm 3c17bd7960 Merge remote-tracking branch 'origin/main' into rt/acceleration-structures
# Conflicts:
#	crates/horizon-client/src/mobs/cem.rs
2026-11-11 03:45:10 +01:00
Nikita Maximilian Grimm bdef01a46c Merge remote-tracking branch 'origin/main' into shader-api-linear-blend 2026-11-11 03:39:56 +01:00
DUzzL 6352b7468c Thrown eggs hatch the chicken variant of their item
ThrownEgg.onHit sets the chicken's variant from the item's CHICKEN_VARIANT component (egg temperate, blue_egg cold, brown_egg warm); a thrown or dispensed egg now carries it.
2026-11-11 03:37:51 +01:00
rdbtCVSandGPT 6.1 Sol a005b741f2 Narrow loaded frame rotation before taking its remainder
Match TagValueInput.getByteOr for wider numeric and fractional tags, and cover signed frame rotation through NBT round trips for both frame kinds.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-11 02:08:40 +00:00
rdbtCVSandGPT 6.1 Sol 4f699a47f4 fix: keep lectern cursor tests out of production builds
Restore the cursor test module's cfg(test) guard displaced by the new lectern regression test. Cite the actual pinned LecternBlock.resetBookState method instead of nonexistent setHasBook.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-11 01:45:20 +00:00
Vinícius MoreiraandClaude Opus 5.5 26d32147ea Wrap the zombie owner's summon
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 22:43:53 -03:00
Vinícius MoreiraandClaude Opus 5.5 b5c4b2f84b Wrap the zombie owner's summon
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 22:43:52 -03:00
Vinícius MoreiraandClaude Opus 5.5 0b2ed50a68 A nested blast drops the TNT owner; the zombie owner stays and leaves no drops
A native explosion inside a TNT blast is Level.explode(null, ...): it
now takes blast_owner, blast_guest and blast_owner_player aside with
the rest of the blast's state. The group keeps its zombie persistent
and clears the zombie's drops.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 22:43:46 -03:00
Vinícius MoreiraandClaude Opus 5.5 75ef2801f5 A nested blast drops the TNT owner; the zombie owner stays and leaves no drops
A native explosion inside a TNT blast is Level.explode(null, ...): it
now takes blast_owner, blast_guest and blast_owner_player aside with
the rest of the blast's state. The group keeps its zombie persistent
and clears the zombie's drops.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 22:43:46 -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 699cf1101f A mob-owned TNT's blast pops no block experience
PrimedTnt.getOwner names the mob a saved owner UUID points at, so
dropExperience (the indirect source is a player) is false. Keep
whether the owner is a player beside blast_owner (a chain reaction
hands a mob owner on as a mob), and compare only whether orbs came
out, since they may merge.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 22:33:19 -03:00
Vinícius MoreiraandClaude Opus 5.5 386d0560a1 A mob-owned TNT awards no creaking heart experience; block_drops off pops none
PrimedTnt.getOwner names the mob a saved owner UUID points at, so its
blast's indirect source is no player and tryAwardExperience awards
nothing. Keep whether the owner is a player beside blast_owner (a chain
reaction hands a mob owner on as a mob). Block.popExperience draws the
amount, then pops nothing with block_drops off.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 22:32:56 -03:00
f90e164b4b Give a fox that eats its food its effects, and credit a panda's pickup
`Fox.aiStep` finishes the item in the mouth with `ItemStack.finishUsingItem`
(`Consumable.onConsume`), which ran only the chorus fruit's teleport here: run
the whole consume in vanilla's order — `emitParticlesAndSounds` (the
consumable's sound, and the four draws a crumb of `spawnItemParticles` costs,
though `Level.addParticle` is empty on a server), `FoodProperties.onConsume`'s
own sound, the `on_consume_effects` through `LivingEntity.addEffect` (so a
golden apple's regeneration and absorption land) and `GameEvent.EAT` at the
fox's feet with the fox as its source.

`Panda.pickUpItem` calls `onItemPickup` before it takes the item, so
`CriteriaTriggers.THROWN_ITEM_PICKED_UP_BY_ENTITY` fires for whoever threw it,
as it already did for the fox (`LivingEntity.onItemPickup`).

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 02:32:32 +01:00
MSedgeMC 22f000349e Merge branch 'fix/breathe-under-water' of https://github.com/MSedgeMC/horizon into fix/breathe-under-water 2026-11-11 01:10:17 +00:00
MSedgeMC 8101d02e21 Merge remote-tracking branch 'upstream/main' into fix/breathe-under-water
# Conflicts:
#	crates/horizon-client/src/mobs/ai.rs
2026-11-11 01:10:11 +00:00
MSedgeMC 49873c3371 Merge main; keep both sides' tests and module declarations 2026-11-11 01:03:02 +00:00
MSedgeMC 359388eea7 Merge main; keep both sides' tests and module declarations 2026-11-11 01:03:00 +00:00
MSedgeMC 07af125581 Merge main; keep both sides' tests and module declarations 2026-11-11 01:02:53 +00: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 4d6923514e A player's blast awards a natural creaking heart's experience
CreakingHeartBlock.onExplosionHit awards the heart's experience
(tryAwardExperience) when the explosion's indirect source is a player, a
primed TNT's owner, unless that player is in creative or spectator
(preventsBlockDrops). Horizon always broke the heart as if no player
caused it.

Part of #11330

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 21:59:34 -03:00
Vinícius MoreiraandClaude Opus 5.5 5e5258f410 A player's blast pops the experience of what it breaks
Block.onExplosionHit passes dropExperience = the explosion's indirect
source is a player (a primed TNT's owner) to spawnAfterBreak, so ores,
sculk and spawners blasted by a player's TNT sample and pop their
experience while blocks drop. Horizon popped none.

Part of #11330

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 21:59:33 -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
MSedgeMC d9672a3587 Merge remote-tracking branch 'upstream/main' into fix/jigsaw-priority-tooltip
# Conflicts:
#	tools/port_map/client2-016.tsv
2026-11-11 00:48:02 +00:00
MSedgeMC e9198b0004 Merge main: the air block stays in ai/air.rs 2026-11-11 00:45:17 +00:00
Evan-lhmandClaude Opus 5.5 bf26fb0d64 cem.rs compiles on main again (the same change as #15042)
The list of layer keys shadowed the own-file map, so main stopped compiling after #14658.
Identical to #15042, so the next merge of main takes it without a conflict.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 01:40:10 +01:00
Umut Cevdet Koçak add317d016 Merge pull request #277 from sunrise-org/perf-m1-heap
Sampled heap profiling: allocations and live memory by call stack
2026-11-11 03:39:28 +03:00
umcekoandClaude Opus 5.5 b944c970c3 Heap samples leave out the profiler's own threads
The e2e capture's live heap was all on horizon-profile: the CPU
sampler's drain thread copies the binary's unwind sections (~9 MB,
framehop's Module::new from StackUnwinder::refresh) at the start of the
capture and holds them to the end, so the heap sampler profiled the
profiler. Samples were already tagged with the allocating thread.

heap_sample::ignore_this_thread() stops sampling a thread's allocations
(its frees still leave the live set). The sampler's drain thread, the
capture writer (WriterThread::spawn_with), the finishing thread and
the gauges thread call it.

server_perf_e2e checks live bytes over every thread, none on a profiler
thread, and that the top live function is not the profiler's; it
samples every 64 KiB so its 4 s capture holds some. It now passes: 1.0
MiB live on tick, workers and chunk threads, 19,453 samples.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 03:39:05 +03:00
Evan-lhm e1255c4465 Merge remote-tracking branch 'origin/main' into rt/acceleration-structures 2026-11-11 01:29:58 +01:00
DUzzL 2a94e35372 End crystal tick clears a loaded Fire (Entity.applyEffectsFromBlocks, fire immune) 2026-11-11 01:21:07 +01:00
umceko 3030aa3652 Merge remote-tracking branch 'fork/perf-platform-upstream' into perf-m1-heap
# Conflicts:
#	tools/server_perf_e2e.py
2026-11-11 03:20:39 +03:00
Nikita Maximilian Grimm 8b16965108 Merge remote-tracking branch 'origin/main' into shader-api-linear-blend
# Conflicts:
#	crates/horizon-client/src/game/native_shaders.rs
#	docs/api/shaders.md
2026-11-11 01:17:01 +01:00
DUzzL f306365eb8 Keep Air and Fire on wind charges and breeze wind charges when saving and loading 2026-11-11 01:12:27 +01:00
Umut Cevdet Koçak f65b2574e1 Merge pull request #279 from sunrise-org/perf-chat-calm
Profiler chat: calm lines, action bar progress, and the mark sprite fixed
2026-11-11 03:03:02 +03:00
umcekoandClaude Sonnet 5.5 f80e0cfbf0 Profiler chat: one calm line per outcome, progress on the action bar, and the mark sprite drawn
Chat keeps the lines with a next step (started, saved, shared, failures); saving,
uploading, opened and marked go to the action bar; the saved line carries the
capture's length from when stop was asked; [Retry] names its profile.

Text::styled dropped the style's inline object (Style::inherit never passes one
on), so the profiler tag drew the font's [OBJ] box instead of horizon:gui/mark.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 03:02:11 +03:00
TiggerTakkyTTTandCodex 15e02ce312 fix(cem): preserve layer ownership map
Avoid shadowing the ownership map with the layer-key list so the latest main branch compiles after the CEM armour-layer merge.

Co-Authored-By: Codex <[email protected]>
2026-11-11 07:00:19 +07:00
TiggerTakkyTTT 1c15680e9a Merge remote-tracking branch 'upstream/main' into fix/wind-burst-ender-dragon 2026-11-11 06:57:15 +07:00
rdbtCVSandGPT 6.1 Sol 7cdd21f24d Merge repaired main into PR #15021 to refresh CI
By rdbtCVS's AI agent (OpenCode, GPT 6.1 Sol). Preserve the contributor and maintainer history while including main's merged #15027 repair for the inherited studio_replay and rocket RNG compilation failures. No PR-specific code changes.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-10 23:52:38 +00:00
Nikita Maximilian GrimmandClaude Opus 5.5 68a4fb2261 Merge origin/main into typed-randoms-1a
mobs.rs: main (#14857) moved the yaw draw out of living_random into
Mob::with_random. Main's version stays, returning the random as an
EntityRandom as before.

mobs/zombie_villager.rs: main split the test fixture and added tests
that hand Mob::with_random a plain LegacyRandom. Main's side stays; the
fixture seeds the mob's random with set_seed, as it did here, and the
new tests wrap theirs with EntityRandom::own.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 00:51:31 +01:00
DUzzL 27b18c07f0 Merge remote-tracking branch 'upstream/main' into rv/r14566 2026-11-11 00:45:19 +01:00
TiggerTakkyTTTandCodex 61d8dea2a7 fix(combat): scale dragon wind burst knockback
Apply the burst damage-calculator multiplier to the Ender Dragon impulse before native knockback hooks, and cover the behavior with a regression test.

Co-Authored-By: Codex <[email protected]>
2026-11-11 06:39:34 +07:00
umcekoandClaude Opus 5.5 94279ded10 Merge main into dims/portal-scales
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 02:30:18 +03:00
rdbtCVSandOpenCode GPT 6.1 Sol ad43388bb5 Merge additive helper-test dependency from mob-loop-group
Includes unchanged test-only cherry-pick of 41a579d428 as 5cc88b517b. Preserve original implementation, contributor credits, and ordered-RNG dependency history.

Co-Authored-By: OpenCode GPT 6.1 Sol <[email protected]>
2026-11-10 23:28:33 +00: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
MSedgeMC 02ae7bfa26 Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-10 23:21:20 +00:00
MSedgeMC 0ba1641e9c Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-10 23:21:18 +00:00
MSedgeMC 7bf8f4b226 Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-10 23:21:16 +00:00
MSedgeMC a06a1853e2 Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-10 23:21:13 +00:00
DUzzL ba8b8cda4a Merge upstream/main into fix/14466-fireball-rotation 2026-11-11 00:19:23 +01:00
b972efd9fa test: prove item work-ahead collisions, RNG and native fallback
Retain the 307-item corpus and add real boat and border cases, independently expected RNG advances, the environment gate and off/on/off comparison. Reject count, age and pickup-delay mutations independently and retain actual merge invalidation. Exercise 300 nonmerging opaque-component stacks in each native contact mode.

Repair the existing escape test's baseline chunk fixture and promote vanilla's 0.98F drag to double in its expected value; no production gameplay change.

Co-Authored-By: Kacper <[email protected]>
Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-10 23:17:06 +00:00
MSedgeMC d9cf87a5a8 fix: the lectern's book change posts unconditionally, as resetBookState does
`LecternBlock.resetBookState` calls `setBlockAndUpdate` and then posts
`gameEvent(BLOCK_CHANGE, Context.of(entity, state))` with no condition on the
state having changed; `placeBook` hands the placing entity to it and
`resetBookState`'s other caller passes none. Horizon's helper only posted when
the state differed, so the gate is gone: the post follows the property write.
2026-11-10 23:13:27 +00:00
MSedgeMC c635024f69 Merge remote-tracking branch 'upstream/main' into fix/lectern-book-block-change
# Conflicts:
#	crates/horizon-client/src/game/books.rs
2026-11-10 23:13:27 +00:00
8a4ca5e7de Merge helper verification into dependent item moves
Preserve helper test commit 41a579d428 and the item phase's original history and authors.

Co-Authored-By: Kacper <[email protected]>
Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-10 23:08:41 +00:00
rdbtCVSandGPT 6.1 Sol 0afcabe0ec test(launcher): exercise sign-in builder in IPv4 fallback test
Use the production timeout builder rather than an unrelated test agent, and
cover IPv4-first multi-address and IPv6-only resolver ordering.

Fixup by rdbtCVS's AI agent (OpenCode, GPT 6.1 Sol), not by rdbtCVS personally.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-10 23:07:38 +00:00
MSedgeMC 2760a81580 Merge remote-tracking branch 'upstream/main' into fix/fox-alert-visibility
# Conflicts:
#	crates/horizon-client/src/mobs/fox.rs
#	tools/port_map/game-012.tsv
2026-11-10 23:03:54 +00:00
umcekoandClaude Sonnet 5.5 1ab052ca3c Portals and execute in scale coordinates by the dimension types (#11282, 6/9)
Vanilla scales coordinates between levels by DimensionType.getTeleportationScale
(NetherPortalBlock.getExitPortal, CommandSourceStack.withLevel), which reads each
dimension type's coordinate_scale. portal_plan, start_server_trip and /execute in
now take the two types' scales instead of hand-written 8 and 1/8.

/execute in looks the level up through known_level, which names the levels the
server runs.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 02:03:52 +03:00
DUzzL 67e0a30ea8 Merge remote-tracking branch 'upstream/main' into rv/r14566 2026-11-10 23:59:43 +01:00
DUzzL 02d449ced2 Merge upstream/main into fix/14461-firework-dispenser-rotation 2026-11-10 23:59:29 +01:00
Evan-lhmandClaude Opus 5.5 e8e1971422 Ray queries: the quad index buffer moves to ray_query.rs (gfx.rs may not grow)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 23:58:55 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 20ea6e500e Merge origin/main into typed-randoms-1a
tnt.rs: main wrapped the silent bottle test in on_big_stack. Main's
side stays, and the test seeds the level's random with set_seed, as it
did here.

Tests main added since assign a plain LegacyRandom to World.random or
Mob.random (breezes, bred_draws, fireworks_events_tests, mounts,
turtle). They wrap it with LevelRandom::for_level or EntityRandom::own.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 23:56:27 +01:00
rdbtCVS 5cc88b517b test: cross parallel tick helper thresholds with real resources
Compare deferred and in-place metadata for 2,085 varied mobs, including pack-selected effect particles and switch-off vector reuse. Exercise live climbing, snapshot order and nearest-player invalidation for 293 mobs. Build 132 random-tick section masks ahead and retain exact event, state and random comparisons through edits and reloads.

Co-Authored-By: Kacper <[email protected]>
Co-Authored-By: GPT 6.1 Sol <[email protected]>
(cherry picked from commit 41a579d428)
2026-11-10 22:55:55 +00:00
Evan-lhmandClaude Opus 5.5 4591f41aeb Merge origin/main into rt/acceleration-structures
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 23:55:00 +01:00
MSedgeMC 761650fe14 Merge remote-tracking branch 'upstream/main' into fix/breathe-under-water
# Conflicts:
#	crates/horizon-client/src/mobs/ai.rs
#	tools/port_map/game2-003.tsv
2026-11-10 22:51:20 +00:00
Umut Cevdet Koçak b97a7d0dec Merge pull request #278 from sunrise-org/perf-m2-reader-memory
Perf platform M2: finer sample blocks, memory_labels/counters ops, samples in tick, complete server memory report
2026-11-11 01:48:42 +03:00
Umut Cevdet Koçak b6832dc940 Merge pull request #276 from sunrise-org/perf-l1-layers
Profiler on upstream's layers: Open and Share through a hook, no built-in address
2026-11-11 01:48:38 +03:00
MSedgeMC 2e8cc55201 Merge remote-tracking branch 'upstream/main' into fix/walk-target-scores
# Conflicts:
#	crates/horizon-client/src/mobs/ai.rs
2026-11-10 22:47:33 +00:00
umcekoandClaude Sonnet 5.5 7942b6d4c3 Memory report: a typed, complete server report with entities, players, chunk send caches, queues and unattributed_mb
`horizon-ctl memory` and a capture's memory snapshots are built from `#[derive(Serialize)]` structs instead
of `json!`; existing keys keep their shape. A dedicated server (no window) names its columns `columns`;
single player keeps `client_columns` and `server_columns`.

New, all estimated from capacities:
- `entities`: count and bytes by entity type (top 30), across every level
- `players`: count and, per player by name, the send queue (packets and bytes), chunks waiting, chunks sent
  and tracked entities
- `chunk_send`: the encoded section and heightmap caches and the shared compressed frames
- `light_queue`, `generation_queue`: lengths and the bytes of their tables
- `allocator`: `live_heap_mb` (mimalloc's statistics), `commit_mb`, `peak_commit_mb`, `overhead_mb`
- `named_mb` and `unattributed_mb` (resident minus the named parts) on every side

The server e2e asserts a stop snapshot has `players`, `entities` and `unattributed_mb`.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 01:47:31 +03:00
umcekoandClaude Sonnet 5.5 c7a26889ff horizon-perf: tick carries the top functions of the samples around the tick
`hot` is the top 30 functions (the `hot` op's row) of the main thread's samples in the time blocks
overlapping the tick, only the slow samples when the tick is slow, and `samples_from`/`samples_to` say the
window those blocks cover. With 1 s blocks that is the second around the tick, not the tick's own samples.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 01:47:31 +03:00
umcekoandClaude Sonnet 5.5 46b588e72a horizon-perf: memory_labels lists a capture's memory snapshots and counters its counters
`memory_labels` (CLI `query memory-labels`, MCP and wasm through the shared op list) gives one row per
snapshot, `{label, t, side, rows}`, in time order. `counters` gives one row per counter definition,
`{id, name, unit, kind, labels, side, samples}`. Each has a golden reply. `memory` also ranks a negative
megabyte row by its size, so `unattributed_mb` can show.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 01:47:31 +03:00
umcekoandClaude Sonnet 5.5 2843049551 Profiler: CPU sample time blocks are 1 s, up to 1800, and a time range selects the blocks it overlaps
`block_s` defaults to 1.0 and `MAX_TIME_BLOCKS` is 1800 (30 minutes, the block index a `u16`); a longer
session keeps the rest in the last block, as before. The reader selects every block a `--from`/`--to`
range overlaps, a range inside one block picks that block, and the reply's `selection.from`/`to` name the
blocks the weights cover, so a 2 s range no longer leaves Functions and the flame graph empty.

Capture size before and after on the 200-bot benchmark and a client capture is not measured yet: pending a
quiet machine. If growth is over 2x, blocks older than 5 minutes get merged at write time.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 01:47:31 +03:00
rdbtCVSandOpenCode GPT 6.1 Sol 316e0ee2b0 Mobs: join island workers and propagate their original panic payload
Retain every scoped worker handle, release the first-lane guard, join all workers, then resume the first original worker payload. Extend the production-path injected spawn/setup/mid-turn regression to assert exact payloads as well as timeout-bounded release.

Co-Authored-By: OpenCode GPT 6.1 Sol <[email protected]>
2026-11-10 22:45:42 +00:00
rdbtCVSandOpenCode GPT 6.1 Sol e725b49629 Mobs: prove startup release and merge snapshots, with serial raid fallback
Inject later-spawn and worker-setup failure through the production island runner; keep first and pending guards inside the scope callback. Compare independent cloned Gaussian/long outcomes and final group snapshots. Keep raid-member ticks serial in both production and verify so the serial diagnostic copy cannot mutate shared hurt ordering. Cover one-CPU fallback and preserve the original ticked check.

Co-Authored-By: OpenCode GPT 6.1 Sol <[email protected]>
2026-11-10 22:42:35 +00:00
umcekoandClaude Opus 5.5 9e7925e2e9 Sampled heap profiling: allocations and live memory by call stack
horizon-threads' heap_sample wraps mimalloc and is the game's allocator
on Linux (alloc-count and alloc-census still replace it). While a
capture runs it samples about one allocation per 512 KiB
(HORIZON_HEAP_SAMPLE_KIB; 0 turns it off) with an exponential countdown
per thread, records the stack, thread and span context, and weighs
each sample interval / (1 - exp(-size / interval)) bytes. Frees look a
pointer up in a 1 MiB table of per-bucket counts and only take the lock
on a hit. One session per process: the first capture to start owns it,
so the integrated server's joined side does not start another.

Captures write two more kinds, alloc and live (bytes, period 1), into
the same tables as the CPU samples, without the allocator's own frames
at the leaf end.

Measured (fast profile, Linux x86_64, 24 threads, machine at load ~50
from other builds):
- Off: alloc_count_bench 64 B alloc+free pairs, +0.4-0.6 ns a pair
  (+9-11% of mimalloc's 5 ns on 1 thread; +1-14% on 8, noisy): one
  relaxed load and branch on each side, as designed.
- On, 200 walking bots with a capture: the sampler is 1.2% of the
  tick thread's CPU samples (1.24 and 0.77 ms a tick), about 70% of it
  .eh_frame unwinding, at ~40-70 us a sample; 63k and 100k samples in
  ~2 min. MSPT avg on 89/148/224/97 vs off 108/69/217/65 ms: the
  overloaded server (TPS 5-14) and the machine load swamp the A/B.
- Capture size: 209 and 231 KB on vs 171 and 171 KB off (+22-35%).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 01:40:00 +03:00
umcekoandClaude Opus 5.5 81b07fb2d6 Profiler format and reader: live heap samples
SampleSource gains LIVE (sampled heap bytes still allocated when the
capture stopped, at their allocation's time block), and Overhead gains
heap_samples and heap_sample_ns.

horizon-perf reads them: --select live works in query hot/tree/callers
and in the folded and pprof exports, in bytes (a total, not a rate per
tick); the summary prints a heap line (live and allocated MiB, the
functions holding the most) and the heap sampler's cost.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 01:40:00 +03:00
DUzzL fd0574f5cf Keep an experience orb's Air through load and save 2026-11-10 23:39:58 +01:00
41a579d428 test: cross parallel tick helper thresholds with real resources
Compare deferred and in-place metadata for 2,085 varied mobs, including pack-selected effect particles and switch-off vector reuse. Exercise live climbing, snapshot order and nearest-player invalidation for 293 mobs. Build 132 random-tick section masks ahead and retain exact event, state and random comparisons through edits and reloads.

Co-Authored-By: Kacper <[email protected]>
Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-10 22:32:11 +00:00
DUzzL d13e35bb8f launcher: fall back to IPv4 when IPv6 to Microsoft is dead
Interleave the address families of resolved sign-in hosts and bound the
connect time, so a broken IPv6 route no longer fails sign-in with
'Could not reach Microsoft'. Fixes #15013.
2026-11-10 23:27:51 +01:00
rdbtCVSandOpenCode GPT 6.1 Sol 753df1c63a Mobs: load planner test chunks through ChunkMap's mutation API
Resource-backed islands tests now compile and execute: 12 passed, including real grouped-versus-serial and verify paths. Scoped seed regressions: 3 passed; ordered RNG: 10 passed, benchmark ignored.

Co-Authored-By: OpenCode GPT 6.1 Sol <[email protected]>
2026-11-10 22:25:55 +00:00
rdbtCVSandOpenCode GPT 6.1 Sol a47aa92bcd Merge additive ordered-reseed dependency from mob-loop-group
Includes 0b21fcbf60, the unchanged cherry-pick of 9298d7961c. Preserve the original islands implementation and both contributors' history.

Co-Authored-By: OpenCode GPT 6.1 Sol <[email protected]>
2026-11-10 22:16:32 +00:00
e24554895d Keep foxes out of the shade in villages and defending the players they trust
Fox.SeekShelterGoal.canUse no longer looks for shade when the fox stands in a
village (ServerLevel.isVillage: within one section of a village POI), so a
village fox keeps strolling in the sun as vanilla's does.

Fox.DefendTrustedTargetGoal is now registered at target priority 3: the fox
takes whoever last hurt a trusted entity as its target, plays FOX_AGGRO at its
feet, sets DEFENDING (the growl and the avoid/stroll guards were dead code) and
drops the flag through Fox.setTarget(null) when the goal stops.

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 23:13:12 +01:00
Mik00000 f790048622 fix(client): leave TNT ignition sound and consumption to server on remote flint usage 2026-11-11 00:12:01 +02:00
rdbtCVS 0b21fcbf60 Random: preserve the ordered lane and stream when reseeding
Treat LegacyRandom::set_seed as an ordered mutation: acquire the shared stream before replacing its LCG state and clearing the Gaussian cache, without discarding the gate. This also publishes a reseed-only item's state through end_item and keeps finish_lane and LaneDone working.

Add timeout-bounded multi-lane regressions for reseeding before/after draws, reseed-only items, cross-lane Gaussian state and panic cleanup, plus serial cache-reset coverage. The regression rejects the original gate loss, omitted synchronization and retained Gaussian cache.

Co-Authored-By: Kacper <[email protected]>
Co-Authored-By: OpenCode GPT 6.1 Sol <[email protected]>
(cherry picked from commit 9298d7961c)
2026-11-10 22:08:34 +00:00
cda60d91ae Mobs: bound island lanes and verify scoped seed ordering
Cap waiting lanes at available hardware, release all unstarted lanes on spawn failure, reject invalid planner inputs, and verify full outputs including pending pushes and Gaussian state. Order new mob RNG allocations with level draws; record/replay diagnostic seeds without resetting the global counter or pinning other threads. Add resource-backed planning, visibility, moved-rider and actual verify coverage.

Co-Authored-By: Kacper <[email protected]>
Co-Authored-By: OpenCode GPT 6.1 Sol <[email protected]>
2026-11-10 22:08:15 +00:00
DUzzL 3beead5ef0 Count powder snow freeze ticks on boats and minecarts
InsideBlockEffectType.FREEZE raises Entity.ticksFrozen for every entity
box touching powder snow and Entity.saveWithoutId writes TicksFrozen
while positive. Boats and minecarts never tracked it.
2026-11-10 23:05:08 +01:00
9298d7961c Random: preserve the ordered lane and stream when reseeding
Treat LegacyRandom::set_seed as an ordered mutation: acquire the shared stream before replacing its LCG state and clearing the Gaussian cache, without discarding the gate. This also publishes a reseed-only item's state through end_item and keeps finish_lane and LaneDone working.

Add timeout-bounded multi-lane regressions for reseeding before/after draws, reseed-only items, cross-lane Gaussian state and panic cleanup, plus serial cache-reset coverage. The regression rejects the original gate loss, omitted synchronization and retained Gaussian cache.

Co-Authored-By: Kacper <[email protected]>
Co-Authored-By: OpenCode GPT 6.1 Sol <[email protected]>
2026-11-10 21:59:04 +00:00
foudretbr 1ee6ac1ed9 style(client): rustfmt consumable test 2026-11-10 22:56:02 +01:00
umcekoandClaude Sonnet 5.5 bd94713fdd /profiler: stop only saves; [Open] and [Share] are the player's to click
`/profiler stop` prints the saved file and never uploads. Where a captures part is
registered (the official build) the line carries [Open] and [Share] buttons that run
`/profiler open <id>` and `/profiler share <id>` (no id: the last capture saved);
a build without the part answers both "not available in this build". horizon-ctl
gains `profile open FILE` and `profile share FILE`. The profiler harnesses read the
new saved line, and `profile_client_e2e.py --official` checks the buttons and open_at.
Docs: saving, Open and Share are independent of telemetry; Share is the only upload.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 00:28:07 +03:00
umcekoandClaude Sonnet 5.5 a86ab9d4bc Layers: the profiler crates have layers, and benchmarks sit on horizon-system
The benchmark report format reads its hardware through `horizon-system`, not
`horizon-telemetry`, so `horizon-bench` is an engine crate and the two `-> horizon-bench`
exceptions go. `horizon-perf-format` is engine, `horizon-perf` a tool, `horizon-perf-share`
Horizon. The launcher and `horizon-server` reach the report format and the capture files
through `horizon_platform::{bench, perf_format}`. `horizon-threads` joins the portable
crates, which `horizon-world` already used.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 00:28:07 +03:00
umcekoandClaude Sonnet 5.5 ccf6b2c913 Perf captures: Open and Share go through a hook, not a built-in address
Saving a capture is local. A registered `Hooks::captures` offers the viewer page,
Open (serve the file once on 127.0.0.1 and open the viewer on it) and Share (the
upload). `horizon-perf-share`, a Horizon crate in `horizon-official`'s parts, holds
both, with the addresses in `horizon-addresses` and their env overrides; the upload
code moves there from `horizon-perf`, which gains `horizon-perf open`.

The saved line and the `profile stop` reply carry no address: `open_at` is present
only when a build has the hook. `horizon-client` keeps `horizon-perf` for tests only.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 00:28:07 +03:00
c4ec5dfd57 Beacon: pay emerald before diamond and tooltip every button
`BeaconScreen.extractBackground` draws the payment row as netherite (x+20),
emerald (x+41), diamond (x+41+22), gold (x+42+44) and iron (x+42+66); the row
no longer follows the items' value. `BeaconPowerButton.setEffect` gives every
power button its effect's tooltip, and `BeaconUpgradePowerButton`'s
`createEffectDescription` appends " II". `BeaconSpriteScreenButton`'s tooltip
is its label (`CommonComponents.GUI_DONE` / `GUI_CANCEL`), hovered whether or
not the button is active.

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 22:27:10 +01:00
foudretbr 407bc867f4 test(client): build consumable test registries without field reassignment 2026-11-10 22:22:18 +01:00
MSedgeMC c3f9c44e15 port_map: LecternBlock.setHasBook posts block_change
The place and reset rows now record the event and its source.
2026-11-10 20:43:54 +00:00
Evrim CicekandClaude Opus 5.5 1110d526f2 A server's client leaves the piston path's bubble column particles to the server
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 23:36:20 +03:00
DUzzL dc226c105c Force thrown item stack count to 1 like ThrowableItemProjectile.setItem 2026-11-10 21:22:45 +01:00
MSedgeMC 976dd1cc06 test: a lectern book removal posts block_change
`a_lectern_book_removal_posts_block_change` puts a lectern with a book
and clears `has_book` through the property helper: the block reads
`has_book=false` and the posted events hold
`("minecraft:block_change", [2.5, 4.5, 3.5], None)`. Without the post the
list is empty.
2026-11-10 20:20:52 +00:00
MSedgeMC e1e8ea3822 fix: a lectern's book state change posts block_change
`LecternBlock.setHasBook` (through `placeBook` and `resetBookState`)
writes `has_book`, then calls `level.gameEvent(entity, GameEvent.BLOCK_CHANGE,
pos, Context.of(newState))`; the property drives the lectern's comparator
output and the item it drops.

Horizon's `set_lectern_property` wrote the state with flag 3 and posted
no game event, so a book put on or taken off a lectern was silent to
sculk sensors.

The placement path passes the placing player, a removal passes no source,
as vanilla's callers do.
2026-11-10 20:20:37 +00:00
DUzzL f2a4074c91 Use Mth.atan2 and the float RAD_TO_DEG factor for fireball rotation 2026-11-10 21:17:47 +01:00
Evrim CicekandClaude Opus 5.5 f3a6fbbdbf Pushed arrows and client shulker bullets take the bubble column push (#14981)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 23:17:20 +03:00
Verm c793eeaf45 Keep a sign's has_glowing_text on Codec.BOOL's whole-number test
SignText reads has_glowing_text through Codec.BOOL, and
NbtOps.getBooleanValue is doubleValue() != 0, so an int 256 is true
there, unlike getBooleanOr's byte test. It has its own read now.
2026-11-10 19:51:44 +00:00
Verm cc76626978 A block entity's booleans are read as bytes (#14977)
getBooleanOr tests a number's byte value, so an int 256 is false. The
flag read every block entity's canonical form goes through tested the
whole number.
2026-11-10 19:47:25 +00:00
Verm ee3e2699ca An end gateway's ExactTeleport is read as a byte (#14975)
TheEndGatewayBlockEntity reads it with getBooleanOr: a number is true
when its byte value is not 0, so an int 256 is false. The decoder tested
the whole number; it now uses flag.
2026-11-10 19:42:15 +00:00
Verm 9c702e54fe A falling block's saved flags are read as bytes (#14971)
FallingBlockEntity reads HurtEntities, CancelDrop and DropItem with
getBooleanOr: a number is true when its byte value is not 0, so an int
256 is false. The loaders tested the whole number; they now use flag
and flag_or.
2026-11-10 19:34:15 +00: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
Umut Cevdet Koçak e301d92133 Merge branch 'horizon-rust:main' into perf-platform-upstream 2026-11-10 22:13:29 +03:00
Verm b6a0f352eb Keep a trident's saved life apart from its Loyalty ground time
AbstractArrow.life is the saved despawn count; inGroundTime is not
saved and starts at 0 on load. The trident used one counter for both,
so a loaded negative life held back the Loyalty return (in_ground_time
> 4). life is now its own field, loaded and saved as it is, counted
where tickDespawn counts it (not for a Loyalty trident that can be
picked up) and reset by startFalling; in_ground_time starts at 0.
2026-11-10 19:08:56 +00:00
Web-mc1 a0b9b67f4a Guard add_padding against bits==0 and bits>=64 2026-11-11 07:52:59 +13:00
foudretbr 0d242f4125 fix(game): keep the registry-resolved consumable over a raw wire re-decode
from_network writes the wire patch's consumable through the real registries;
consumable() then decoded the raw bytes again without them, dropping registered
effects and replacing a registered sound with the prototype's. Decode the raw
bytes only when no resolved component exists. The test now uses a registered
sound that differs from the honey bottle's and a registered effect.
2026-11-10 19:46:42 +01:00
MSedgeMC 67aab17c4e port_map: map FoxBehaviorGoal.alertable visibility range 2026-11-10 18:41:46 +00:00
MSedgeMC 4d0178d549 test: an invisible player far away is not alerting 2026-11-10 18:41:46 +00:00
MSedgeMC 1e13e7a7e2 fix: scale a fox's alert range by visibility 2026-11-10 18:41:31 +00:00
DUzzL e61aa0e8b0 Keep the sign of a loaded ItemRotation like ItemFrame.setRotation 2026-11-10 19:34:35 +01:00
DetimesandClaude Opus 5.5 fd4d917b90 Biome resend: collect the watching guests once, so hub.rs shrinks
resend_biomes filtered the guests twice (for the bodies and for sending);
it now collects the guests that watch once and zips them with their bodies.
Same receivers in the same order, and hub.rs, over the 3000-line limit,
no longer grows.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 18:32:46 +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 8ef9fdfb0c Merge upstream/main into perf-biome-resend
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:22:39 +00:00
DetimesandClaude Opus 5.5 94a37b8562 Merge upstream/main into perf-recording-box
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:19:53 +00: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 c9b853dda2 Add a save/reload parity harness for a painting's saved pitch
Summons paintings with several Rotation pitches on vanilla's dedicated
server and Horizon's, saves and reloads the world, and compares
/data get entity ... Rotation, facing and variant before and after.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 19:17:31 +01:00
Verm b5906ff6d5 A trident narrows a loaded Fire to a short as well
Entity.load reads Fire with getShortOr; the trident's loader now
narrows it as the arrow's does.
2026-11-10 18:15:34 +00:00
Verm e8d176ca8d A dropped item narrows a loaded Fire to a short as well
Entity.load reads Fire with getShortOr; the item's loader now reads it
through the same floor-and-narrow read as Health, Age and PickupDelay.
2026-11-10 18:11:19 +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
Verm 7d37a291c2 A shulker's AttachFace is narrowed to a byte before the face is picked (#14940)
Shulker reads AttachFace with Direction.LEGACY_ID_CODEC: a byte through
from3DDataValue (abs(data % 6)). The loader took the remainder of the
whole number, so an int 258 was face 0 where vanilla's byte 2 is face 2.
The shulker bullet's Dir already narrowed first.
2026-11-10 18:02:13 +00:00
martipopsandClaude Opus 5.5 0f157e4f3a On-screen keyboard: the hint after a wrapped one starts its own line
A hint broken over several lines could have the next hint packed onto its
last line ("...the way you want it to   RB Symbols"), reading as part of
it. The lines of a broken hint are now its own.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 12:57:04 -05:00
Verm eb8de82c63 A horse saves back the Variant it was loaded with
Horse.addAdditionalSaveData writes typeVariant as it is, so Variant:9
reads back 9 although it shows coat 2. The save now keeps the loaded
int while the horse still shows that coat and those markings, and
packs the two ids once its look changed.
2026-11-10 17:49:18 +00: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 394455b62e Test: the section reach box never rejects a viewer with a near block
Random viewers and sections (20,000 cases, with an empty section checked
too), and every single block around 60 random viewers including the edge
of the reach, assert that reaches() keeps any viewer for which near()
holds. The sections-with-hits count and the blocks-near count guard
against a test that proves nothing.

Refs #14923

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 17:42:39 +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
DetimesandClaude Haiku 5.5 d2bc41cd4e Test: equipment conversion repeats only for changed stacks
Compares the cached equipment with a direct conversion of every equipped
slot over static ticks, after a count change and after a slot is cleared.

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
2026-11-10 17:42:31 +00:00
DetimesandClaude Haiku 5.5 61ffa23382 Perf: cache converted equipment per guest
Held, offhand and armor stacks were converted to network stacks for every
guest on every tick. Each guest now keeps the converted stack of each
equipment slot together with the ItemStack it came from, and converts
again only when that stack differs by full equality. The /reload path
clears the cache, since conversions depend on the registries.

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
2026-11-10 17:42:28 +00:00
DetimesandClaude Opus 5.5 79e5720a76 Perf: skip recording's per-viewer block test when no changed block is in reach
broadcast_block_changes tested every changed block of a section against
every recording viewer, although only a block inside the viewer's `near`
reach is ever recorded. bounds() takes the section's box once, and
reaches() rejects a viewer whose reach misses it, using the same
expressions as near() so the reject is conservative. note_changes() keeps
the per-block loop and its order, and moves the loop out of hub.rs.

Refs #14923

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 17:41:53 +00:00
Verm 6d91dd0c76 A horse's unknown coat or markings id wraps (#14926)
Horse.getVariant is Variant.byId(typeVariant & 255) and getMarkings
Markings.byId((typeVariant & 0xFF00) >> 8); both maps are
OutOfBoundsStrategy.WRAP. The loader clamped them, so Variant:9 was
dark brown where vanilla's horse is chestnut. The saved Variant still
comes from the two ids, not the loaded number.
2026-11-10 17:41:47 +00:00
DetimesandClaude Haiku 5.5 a5fe3eea40 Test that shared resent biome bodies match the old packets
bodies() encodes one body per set of columns. The test builds the packet
each receiver got before (cloned columns, encoded alone) for three
receivers sharing five columns, a receiver with a subset in another
order, and one with none, and compares the bytes.

Refs #14925

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
2026-11-10 17:41:40 +00:00
DetimesandClaude Haiku 5.5 d9146d02b5 Encode resent biomes once per set of columns
resend_biomes cloned each receiver's ChunkBiomeData and encoded the
ChunksBiomes packet again for every guest. Receivers that have the same
columns now share one encoded body, built by the new lan::biome_resend
module; hub.rs gets smaller. Each guest still gets the same bytes in
the same column order, and the fake-player filter is unchanged.

Refs #14925

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
2026-11-10 17:41:31 +00:00
Verm fca27e4c63 An axolotl with an unknown Variant id is lucy, not blue (#14907)
Axolotl.Variant.BY_ID is OutOfBoundsStrategy.ZERO: an id outside 0..4
is the first variant, lucy. The loader shared the parrot's arm, whose
CLAMP is right for the parrot but made Variant:7 the rare blue axolotl.
2026-11-10 17:33:46 +00:00
Verm c44aa01ebb A wolf's or cat's CollarColor outside 0..15 reads white (#14897)
Wolf and Cat read CollarColor with DyeColor.LEGACY_ID_CODEC: a byte
through DyeColor.byId, whose out-of-range answer is the first colour
(OutOfBoundsStrategy.ZERO). The loader masked the id with & 15, so 17b
was orange and -1b black where vanilla's collar is white.
2026-11-10 17:26:10 +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
Verm c1aba1d3f4 Test the item's Age and PickupDelay sentinels through load and save
-32768 (never ages) and 32767 (never counts down), given as ints, and
the ints that narrow onto them, load as the sentinels the tick tests
for and save as the shorts vanilla writes.
2026-11-10 17:12:37 +00:00
Verm f61130508b A fireball narrows a loaded ExplosionPower to a byte (#14883)
LargeFireball.readAdditionalSaveData reads it with getByteOr, which
narrows any numeric tag (IntTag.byteValue is (byte)(value & 255)):
ExplosionPower:300 loads as 44. The loader kept the raw number, so
such a fireball exploded with power 300 while its save showed 44b.
2026-11-10 17:04:53 +00: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
DUzzL 4e7219047b Wrap previous fireball angles before easing towards the movement 2026-11-10 17:53:31 +01:00
MSedgeMC 7ab5bfd238 port_map: ChatComponent.refreshTrimmedMessages keeps the scroll 2026-11-10 16:40:49 +00:00
MSedgeMC e347c6f99f test: a Chat option change keeps the reader's place
Sixty lines, the reader three lines back with a new message since: a Chat
Visibility change leaves both, a rescale resets both.
2026-11-10 16:40:45 +00:00
Verm 5d5e2712ad A dropped item floors a float Age or PickupDelay before narrowing it
getShortOr floors a float or double tag before the short narrowing. The
item's loader did that for Health only; Age and PickupDelay now go
through the same read.
2026-11-10 16:40:39 +00:00
MSedgeMC f03a2ffe21 fix: changing the Chat option keeps the reader's place
ChatComponent.setVisibleMessageFilter rewraps through refreshTrimmedMessages,
which leaves chatScrollbarPos alone; only rescaleChat resets it first
(resetChatScroll). Horizon's rewrap always reset the scroll and
newMessageSinceScroll, so changing Chat Visibility threw a scrolled-back
reader to the bottom.
2026-11-10 16:40:25 +00:00
DUzzL 34a9b0144c Share the projectile flag and entity field load to keep entity_nbt.rs from growing 2026-11-10 17:32:07 +01:00
Verm 4a42d89978 A dropped item narrows a loaded Age and PickupDelay to a short (#14867)
ItemEntity.readAdditionalSaveData reads both with getShortOr, which
narrows any numeric tag (IntTag.shortValue is (short)(value & 65535)):
Age:40000 loads as -25536. The loader narrowed Health this way but kept
Age and PickupDelay as the raw number, so such an item was past 6000
and despawned on its first tick.
2026-11-10 16:31:18 +00:00
foudretbr 76178bacf0 fix(game): resolve dynamic consumable component on item stack (#14634)
Read the minecraft:consumable component from saved components or the wire patch on an ItemStack rather than only checking the prototype definition. This ensures custom consumable items properly start eating, tick use duration, emit consume particles and sound, and complete without desynchronizing movement.
2026-11-10 17:26:04 +01:00
Verm 935faca944 An area effect cloud keeps the fall_distance it was loaded with (#14853)
AreaEffectCloud extends Entity and never moves, so Entity.saveWithoutId
writes the fall_distance Entity.load read. entity_base_tag, which the
cloud's save uses, took OnGround from the loaded compound but wrote
fall_distance 0.0; it now takes that from the compound too.
2026-11-10 16:18:38 +00: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
martipopsandClaude Opus 5.5 3e4dce841c On-screen keyboard: a wrapped hint keeps its button with its first word
Breaking a too-wide hint at its spaces could leave the button's print
alone on a line ("B", then the label under it). The print now stays with
the hint's first word, and that line is drawn smaller if it is still
wider than the panel.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 10:58:53 -05:00
Verm 70ee24e9ef A trident keeps the fall_distance it was loaded with (#14846)
Entity.saveWithoutId writes fall_distance from fallDistance and
Entity.load reads it back. AbstractArrow.tick never calls Entity.move,
so a trident keeps the loaded value until water resets it. The trident's
save wrote 0.0 and its load ignored the key; it now reads, keeps and
saves it as the arrow does, and water resets it in the ticks that reach
Entity.baseTick. Lava's halving is not modelled for a trident.
2026-11-10 15:58:46 +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
Mayonesu0andClaude Sonnet 5.5 bd11bddc68 Add a save/reload parity harness for a loaded mob's AbsorptionAmount
Summons pigs with several AbsorptionAmount values on vanilla's dedicated
server and Horizon's, saves and reloads the world, and compares
/data get entity ... AbsorptionAmount before and after the reload.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 16:47:51 +01:00
JustKarkat 55e719d3b7 fix: skins::unpack keeps each profile texture independently
unpack() rejected a profile's textures all-or-nothing: any texture URL
not on textures.minecraft.net dropped every texture, so a head's skin
was lost whenever another texture (cape, elytra) named a disallowed URL.

Vanilla's SkinManager judges each texture per URL: disallowed ones are
omitted and the rest are kept. unpack() now skips an invalid texture
instead of rejecting the profile, so custom player heads keep their skin
on servers whose profiles carry extra textures with foreign URLs (e.g.
hub.fadecloud.com).

The head-profile cache test expected the all-or-nothing behaviour; its
cape assertion now matches vanilla's per-URL semantics, and a regression
test covers a mixed valid-skin + disallowed-cape profile.

Refs #5376
2026-11-10 09:47:46 -06:00
Verm 1f230b80a9 A trident keeps a negative life and shake across a reload (#14834)
AbstractArrow reads life as the saved short, negative too, and shakeTime
as the saved byte & 255. The arrow already did; the trident's own copy
kept both unsigned, so a loaded life:-1s or shake:-1b saved back as 0.
Both are signed on the trident now, read as the arrow reads them, with
vanilla's 'if (shakeTime > 0) --shakeTime' and pickup at shakeTime <= 0.
2026-11-10 15:31:39 +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
DUzzL 4bdf605756 Keep Air, Fire and fall_distance on llama spit and shulker bullet saves 2026-11-10 16:21:40 +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 267c3838b6 Reset a negative mob Fire to 0 on its first tick 2026-11-10 16:06:42 +01:00
DUzzL 4068a09887 Keep an item frame's saved Motion, OnGround and fall_distance 2026-11-10 16:03:09 +01:00
martipopsandClaude Opus 5.5 4e3e68b758 On-screen keyboard: a hint wider than the panel breaks at its spaces
A single hint wider than the whole panel (a long translation) ran past it:
it now breaks at its spaces onto lines of its own, and only a line that is
one word wider than the panel is drawn smaller, to fit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 09:58:09 -05:00
Mik00000 a98cde40f8 fix(client): allow rapid flint and steel usage when holding offhand item (fixes #14773) 2026-11-10 16:47:30 +02:00
DUzzL dfbdf94740 An arrow loads and keeps its saved Air 2026-11-10 15:10:23 +01:00
Oloion afb34764ec assets: eliminate heap allocations in RangeSelectItemModel binary search 2026-11-10 17:00:17 +03:00
umcekoandClaude Opus 5.5 fc9eb64042 Captures name players: the privacy section and its harness say so
A server owner has to know which connection floods or lags, so net rows carry each player's name, client
brand and view distance. server_perf_e2e still checks that the world, seed, MOTD, mob and sign names and the
IP stay out, and now checks that net names both bots.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 16:22:22 +03:00
umcekoandClaude Opus 5.5 fc9acc4a74 Profiler: a client capture records its connection, and a server-thread start waits for the last file
The window now hands its server connection's byte counters to the capture each frame, so a client capture's
network tab has data. A server-thread capture asked for while the previous one is still being written waits
for that file instead of refusing, which the window had already reported as started.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 16:22:22 +03:00
umcekoandClaude Sonnet 5.5 79b1df4051 Golden net replies carry each connection's name, brand and view distance
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 15:58:46 +03:00
SenjinandClaude Opus 5.5 4afaf25272 Dynamic Resolution: the world's resolution follows a target frame rate
Off by default. Resolution Scale becomes the floor, and the scale rises toward 100% while the
GPU keeps Target FPS (30 to 240 in steps of 5, 60 by default), falling back when frames take
longer. A verdict is the median GPU time of a window of frames; a step up waits until the
frame would still fit if its cost grew with the pixels. Frames of a world still loading don't
count.

Each change starts TAA's and a shader pack's history over, so under a shader pack it moves
gently (3 s windows, 10% steps, 8 s between changes) and without one quickly (0.5 s, 5%, 1 s).
F3, the performance overlay (`res 85%`) and horizon-ctl's status show the scale; changes log at
debug level.

GPU Culling's Auto moves with it out of game.rs into game/frame_autotune.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 13:47:38 +01:00
umcekoandClaude Sonnet 5.5 31ae839a81 Format the ported profiler work
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 15:18:40 +03:00
umcekoandClaude Sonnet 5.5 226fb118f8 Captures name each connection: player, client brand and view distance; a client records its own server traffic
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 15:17:58 +03:00
umcekoandClaude Sonnet 5.5 4390baae27 Profiler chat: Horizon's mark as the line's tag
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 15:15:50 +03:00
MSedgeMC 445b1bbd4c The iron golem keeps its air through its own decreaseAirSupply
The tag is right and the behaviour was wrong for the wrong reason: an iron
golem is not in can_breathe_under_water, and it stays under water without a
bubble because IronGolem.decreaseAirSupply hands back the air it is given
(`iload_1; ireturn`). Taking the golem out of the tag alone left nothing to
hold its air, so it drowned after 320 ticks where vanilla never hurts it.

The drown path now asks keeps_its_air beside can_breathe_under_water: a golem
under water stays in baseTick's drowning branch, as a type missing from the
tag does, but its consume is 0, so its air stays at 300, it never reaches the
-20 that hurts and it never refills either — exactly the three things the
override decides.

The test states both halves: the shipped data has no iron_golem in the tag (a
copper golem, which is in it, keeps its air through the tag and has no
decreaseAirSupply override), and 320 ticks with its eyes in water leave the
golem at (0.0, 300) where the same mob without the arm is (2.0, 0).
2026-11-10 12:02:55 +00:00
umcekoandClaude Sonnet 5.5 aa3df3b4f7 Read a single-player capture's GPU frames without naming the side
The profile e2e checks the GPU summary and frame series too.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 15:01:15 +03:00
umcekoandClaude Sonnet 5.5 51fa349fe4 Record every GPU-timed frame in captures and split the world pass into draw groups
A capture made with --gpu now writes each timed frame (gpu_frames = 24: its
start on the capture clock, first-to-last timestamp total, every pass and part)
in blocks about once a second, with the CPU ms of every drawn frame. GpuPasses
ms becomes the median over the whole capture instead of the last 15 frames.

Where the device stamps inside passes, every timed frame splits the world
passes into their draw groups (sky, solid terrain, occlusion boxes, entities,
translucent terrain, clouds, weather, hand...) and compute passes into their
dispatches, so GPU culling's parts show without detail mode; Metal keeps pass
boundaries, recorded as GpuDevice.pass_split.

query gpu gains per-pass and per-part samples, p50/p95/p99/max and share of the
GPU frame p50, plus a summary row with GPU and CPU frame percentiles and a
gpu/cpu/mixed bound verdict; query gpu_frames gives the series with slow frames
flagged. Older captures read with empty statistics.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 14:44:54 +03:00
MSedgeMC 479276f66d port_map: WorldOptionsScreen.setPortError is ported 2026-11-10 11:44:22 +00:00
MSedgeMC 17cdf49055 test: a rejected port says why in its tooltip
Above 65535, below 1024, not a number and negative all read the invalid-port
reason; 1024, 25565 and 65535 keep the Port Number label, and an empty box
takes the suggestion.
2026-11-10 11:44:22 +00:00
MSedgeMC b49f625199 fix: the LAN port box says why its number is wrong
WorldOptionsScreen.setPortError swaps the port box's tooltip from
PORT_INFO_TEXT to INVALID_PORT when tryParsePort rejects the value. Horizon
always showed Port Number, so a red box never said what was wrong.
2026-11-10 11:44:22 +00:00
umcekoandClaude Sonnet 5.5 034ffdbfef Parse /profiler through its command tree, with gpu literals and spark-style chat lines
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 14:35:00 +03:00
MSedgeMC c1fad2fb18 port_map: the loaded cube's freeze immunity
SulfurCube.canFreeze had no row; it is the cube's arm of freezing.rs::can_freeze.
2026-11-10 11:28:57 +00:00
MSedgeMC 9b087a4560 A sulfur cube holding a block does not freeze
SulfurCube.canFreeze answers `!hasBodyItem() && super`, so a cube carrying a
block in its body takes no frozen ticks from powder snow and no freeze damage,
while one without a body freezes like the cube mobs do. can_freeze answered
the tag and the skeleton's refusal and nothing else, so a loaded cube shook and
took the 1 a fully frozen mob takes.

The test puts a sulfur cube with a stone body at 0 and at 140 frozen ticks: it
stays at 0 with no damage and melts to 138, where the cube without a body gains
a tick (1) and takes the 1 at 140. Without the arm the loaded cube reads (1,
0.0) like the empty one.
2026-11-10 11:17:11 +00:00
umcekoandClaude Sonnet 5.5 28fa20f13d Share in-game profiler captures through direct viewer links
Upload completed chat-started captures on a background worker and show a clickable capture URL. Preserve the local file and an owner-only receipt before pinning, so /profiler share can retry without uploading twice. Use an application user agent accepted by the deployed API. Keep control-socket and environment captures local.

Validated timed and manual in-game completion against the live API with byte-identical download, plus retry/ownership regression, command completion and Clippy. Test uploads removed after validation.

Fixes sunrise-org/horizon#267

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 14:11:21 +03:00
umcekoandClaude Sonnet 5.5 ac784c6832 Record complete 1% and 0.1% timing lows for performance captures
Keep bounded raw unit timings during recording and expose exact slow-tail means through meta. Tick rates express work capacity capped at 20 TPS; old or incomplete captures omit lows. Related: sunrise-org/horizon#264.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 14:08:24 +03:00
MSedgeMC db693c8f36 port_map: the underwater breathing names its arm
canBreatheUnderWater had no row in any of the three classes; the tag half and
the two overrides are air.rs::can_breathe_under_water.
2026-11-10 11:03:00 +00:00
MSedgeMC f39838d4b2 Test who drowns and who breathes
ai::air::tick_air on a mob with its eyes in water for 320 ticks: a guardian
takes 0.0 where an iron golem takes the drown's 2.0, an adult happy ghast 2.0
where its baby takes 0.0, and a sulfur cube 2.0 without a block in its body and
0.0 with one. Without the tag correction and the two overrides the iron golem
reads 0.0.
2026-11-10 11:00:22 +00:00
MSedgeMC 33a0458cf2 Only the jar's types breathe under water
LivingEntity.canBreatheUnderWater is the can_breathe_under_water tag, and
Horizon's copy of it carried an iron golem that the tag does not list: an iron
golem standing in water never lost its air and never drowned. Two class
overrides were missing with it — HappyGhast.canBreatheUnderWater answers true
for a baby, and SulfurCube.canBreatheUnderWater true while it has a block in
its body — so both drowned where vanilla lets them breathe.

The tag's list loses the iron golem, and the drown predicate now asks
can_breathe_under_water, which adds the baby happy ghast and the sulfur cube
with a body. The air-supply block moves out of ai.rs into mobs/ai/air.rs, which
takes 60 lines of code out of ai.rs.
2026-11-10 11:00:22 +00:00
zenith600andClaude Opus 5.5 4d3d579464 launcher: say when a folder has the version file but not the game (#14286)
A folder where the official launcher wrote versions/26.3/26.3.json but not
26.3.jar (a version picked but never played, or a download that stopped)
said "no Minecraft 26.3 client jar", the same as a folder that never had
26.3. It now says the version file is there and to play 26.3 once in that
launcher. A wrong jar and a folder without the version file keep their
messages.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 16:29:39 +05:30
MSedgeMC d46bea7851 port_map: the stroll's score names its port
The mushroom cow's row said its mycelium rule lived only in the spawn check;
walk_target_value carries it now, and the enderman, the pillager and the
warden have their flat 0 beside the other walk-target rows.
2026-11-10 10:53:43 +00:00
MSedgeMC a25182804a Test the walk-target scores of four classes
walk_target_value at three positions that differ only in the block below: a
cow scores grass 10.0 and stone and mycelium by the light, a mushroom cow
mycelium 10.0 and grass by the light (never 10), a zombie the negated light,
and an enderman, a pillager and a warden 0.0 on all three. Without the two
rules the mushroom cow reads 0.5 for its mycelium, the light term, and the
three flat classes read the zombie's score.
2026-11-10 10:50:20 +00:00
MSedgeMC dd178e638f A stroll scores its ground the way the jar does
PathfinderMob.getWalkTargetValue is what a random stroll ranks its ten
candidate positions by, and three of its class overrides had no port:
Enderman.getWalkTargetValue, Pillager's and Warden's answer 0 wherever the
position is, so their strolls pick one of the ten at random, while Horizon
scored them by the negated light and sent them to the dark. A mushroom cow's
override replaces Animal's outright — mycelium 10, everything else by the
light — so grass is worth nothing to it, while Horizon gave it the cow's grass
10 and nothing for its own mycelium.

walk_target_value moves out of ai.rs into mobs/ai/walk_target.rs with the two
rules, and ai.rs loses 75 lines of code to the move.
2026-11-10 10:50:20 +00:00
umcekoandClaude Sonnet 5.5 9cddcfb071 Carry the remaining profiler and benchmark work onto upstream's layout
Benchmark A/B runs and --benchmark-set/--profile, the memory section, span
laps through streaming and the world update, the server report and the
/profiler command tree, ported from the original patches onto the files
upstream split out of game.rs.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 13:48:22 +03: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
umcekoandClaude Sonnet 5.5 4039f0eeb6 Merge origin/main into perf-platform-upstream
Conflicts kept both sides: the platform's pass timing feature moves into the
device search's new open_adapter, its Options fields and work-attribution
calls follow the code upstream moved out of game.rs, and the perf channel
joins upstream's list of Horizon channels.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 13:37:36 +03:00
MSedgeMC ea435acad4 port_map: the stalactite tick rows name the client drip
PointedDripstoneBlock.animateTick, getFluidAboveStalactite and canFillCauldron
were unlisted on the client side: the drip they decide is
animate_tick.rs::dripstone_drip, and the 26.3 filter reads the fluid's type.
2026-11-10 10:27:46 +00:00
MSedgeMC 71eb66aa68 test: a waterlogged slab and its fluid drip in the tick zoo
The half of `trySpawnDripParticles` that decides where a fluid drop lands had
no case: every fluid the zoo put a block over was air or stone, both full
cubes, so the branch that reads the fluid block's own face was never taken.

Two cases put a waterlogged bottom slab over a dry one - a fluid block with a
full down face and a dry half-height block under it, the arrangement the branch
exists for - at seeds 0 and 1057. The corpus goes from 2,188 cases and 462,718
lines to 2,190 and 462,960; the 2,188 cases keep their own seeds and print the
same 462,718 lines as before, byte for byte, on both sides.

Negative run, against the same corpus: the face back to `full_collision` makes
only these two cases differ, 78 lines against 67 at seed 0 and 81 against 76 at
seed 1057, both short of the dripping_water particle the client adds on the
slab's top face.
2026-11-10 10:27:46 +00:00
MSedgeMC 92ce70e08a fix: the dripstone reads the fluid above its root, not a source
Two reads of the fluid a drip comes from were taken from the wrong accessor.

`ClientLevel.doAnimateTick` hands `trySpawnDripParticles` the fluid block's own
face, `state.isFaceSturdy(this, pos, Direction.DOWN)`, and its collision shape,
`state.getCollisionShape(this, pos).max(Y)`. Horizon passed `full_collision`,
the "collision shape is a full cube" fact, for the face: a waterlogged bottom
slab has a full down face and is not a full cube, so with the dry slab under it
the client put a dripping_water particle on that slab's top and Horizon put
none (78 lines against 67 at seed 0, 81 against 76 at seed 1057). The facts
already carry `isFaceSturdy(.., SupportType.FULL)` by direction ordinal, so the
down face is bit 0.

`PointedDripstoneBlock.canFillCauldron(Fluid)` is `fluid == Fluids.LAVA ||
fluid == Fluids.WATER`: the fluid above the root by its type. Horizon also
required it to still be a source (`fluid_level >= 8 && !fluid_falling`), which
drops the flowing fluid a stalactite drips in vanilla.
2026-11-10 10:27:46 +00:00
NongTham 2d9d67e048 Game: review and complete Thai translations (th_th.json)
- Translate 96 missing strings including Controller Radial Menu, HDR, Shaders, and Recipes
- Reach 100% coverage (1,151/1,151 keys) with zero placeholders changed and zero empty values
- Record all 1,151 keys as reviewed in tools/lang_review/game/th_th.json
2026-11-10 17:16:54 +07:00
TiggerTakkyTTTandCodex 01731db683 fix(combat): make Wind Burst affect the Ender Dragon
Fixes #14724.

Co-Authored-By: Codex <[email protected]>
2026-11-10 17:03:22 +07:00
MSedgeMC 7b9fca2a49 Move the freezing helpers into mobs/ai/freezing.rs
ai.rs is over the 3000-line limit and may not grow, so in_powder_snow and can_freeze move out; the skeleton rule stays
the same.
2026-11-10 09:51:34 +00:00
NongTham b689c295dc Launcher: review and complete Thai translations (th.json)
- Translate remaining English error and status strings
- Polish phrasing across Home, Settings, Mods, and Servers
- Record all 1,240 keys as reviewed in tools/lang_review/launcher/th.json
2026-11-10 16:40:27 +07: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
a77e5ec7f3 Hiss when powder snow or a cauldron puts a burning player out, and leave the block outside the world border
Entity.applyEffectsFromBlocks plays playEntityOnFireExtinguishedSound once a move
left the entity no longer on fire (Entity.java:992-1003, 1047-1061), which powder
snow and water cauldrons cause through InsideBlockEffectType.EXTINGUISH; the
server's copy of the player now plays it after the block changes. The same two
blocks gate their block change on mayInteract (PowderSnowBlock.java:85-98,
LayeredCauldronBlock.java:73-79), which for a player is ServerPlayer.mayInteract
(ServerPlayer.java:2108-2110) and ServerLevel.mayInteract (ServerLevel.java:874-876):
outside the world border or under spawn protection the powder snow stays and the
cauldron keeps its level, while the fire still goes out and the hiss still plays.

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

Claude-Session: https://claude.ai/code/session_01WZzq8MxShENtiMj2DMbm5G
2026-11-10 09:18:22 +01:00
ebe873fb5e Pets sit only where vanilla lets them, and a cat's chest pose reaches every player
`TamableAnimal.isOrderedToSit` is the saved flag an interaction flips;
`isInSittingPose` is bit 1 of `DATA_FLAGS_ID`, which only
`SitWhenOrderedToGoal.start` (once `canUse` allows it: not in water, on the
ground) and a cat's `CatSitOnBlockGoal`/`CatLieOnBlockGoal` write, and which
the models, `ChestBlock` and the metadata read. A wolf fed a bone while it
swims is no longer drawn sitting.

A cat's chest/bed pose now goes out in that bit (the metadata carried the
order instead), so the other players of a LAN world see it sitting, and
`readAdditionalSaveData` sets the pose from the saved order as
`TamableAnimal:81-82` does.

`RunAroundLikeCrazyGoal.canUse` takes its spot from
`DefaultRandomPos.getPos(horse, 5, 4)` -- ten attempts past the build-height,
home, stable-destination and malus filters, the best walk-target value -- and
`start` walks to it, instead of a raw offset into a hill or over a pond.
`Cat.registerGoals` strolls out of water one tick in 100000
(`1.0000001E-5F`), not one in 1000.

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

Claude-Session: https://claude.ai/code/session_01WZzq8MxShENtiMj2DMbm5G
2026-11-10 09:18:19 +01:00
Skr1zzzzandClaude Opus 5.5 aef2b895df Client: removing entities seats passengers off only the vehicles among them
`remove_entities` called `remote_set_passengers(id, &[])` for every removed
id, and each call walks every tracked entity (`seat_passengers`), the mobs,
the displays and the armor stands. With thousands of falling blocks removed
a tick, that was most of the packet's cost, for entities that carry nobody.

The packet's vehicles are now collected once, before the per-id cleanup:
the removed ids that some passenger, mob, display, armor stand, interaction,
controlled boat or the player's own ride names as its vehicle. Only those
get the call; every other per-id step keeps its order.

8,000 falling blocks, RemoveEntities of 256 ids (debug test build):
51.3 -> 1.3 ms.

Part of #13007

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 10:58:04 +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
Skr1zzzzandClaude Opus 5.5 cbe92651a1 Single player: a tick that panics or fails still saves the world
Vanilla's MinecraftServer.runServer catches what a tick throws and still calls
stopServer in its finally, so an integrated server saves its players and levels
after a crash. Horizon's dedicated server already does this (stop_after_crash);
single player's server thread did not: a failed tick returned before
stop_integrated, and a panic unwound the thread, so everything since the last
autosave was lost, for the host and for every guest.

The integrated tick loop now catches a panic or a failed tick, stops through
stop_after_crash (the guest whose turn was interrupted keeps its last file) and
stop_integrated, and then returns the original error.

Part of #14394

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 09:28:07 +03:00
YourPancakes d51f612f54 perf(net): parse packet frame headers once 2026-11-10 09:24:54 +03:00
f7ff9d2410 Load structure templates faster and preserve numeric NBT fields
Stream template blocks from decompressed NBT instead of building the full
tag tree. Preserve list unwrapping, duplicate keys, limits and block order.

Follow StructureTemplate.load/loadPalette and ListTag.addAndUnwrap in
vanilla 26.3. Read numeric positions and state indices through the same
NumericTag conversions as CompoundTag.getIntOr and ListTag.getDoubleOr.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Codex (gpt-6.1-sol) <[email protected]>
Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
2026-11-10 06:35:48 +01: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
4b8f61ed0e Count cave air as air for open-water fishing and the flying kick
FishingHook.getOpenWaterTypeForBlock tests !state.isAir()
(FishingHook.java:416), and isAir() covers air, cave_air and void_air;
ServerGamePacketListenerImpl.noBlocksAround filters on the same predicate
(ServerGamePacketListenerImpl.java:543). open_water_type now reads
BlockFacts::is_air, and no_blocks_in reads the hub's state table, so a hook
under a mineshaft's air counts as open water (treasure can be caught) and a
survival guest hovering there is kicked for flying after 80 ticks.

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

Claude-Session: https://claude.ai/code/session_01WZzq8MxShENtiMj2DMbm5G
2026-11-10 04:58:50 +01:00
martipopsandClaude Opus 5.5 309da9f821 On-screen keyboard: rustfmt the hint wrap's test
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 22:38:05 -05:00
J5CaseyandClaude Opus 5.5 d81eb7ffe9 Recipe viewers: a transfer takes worn, enchanted and renamed items
A recipe viewer's transfer (JEI's "+", Menu::transfer_recipe) counted and
moved only the stacks vanilla's recipe book may use
(Inventory.isUsableForCrafting: not damaged, enchanted or renamed). A
recipe with a tool as an ingredient, such as a mod rod made from a worn
fishing rod, then moved nothing, though crafting it by hand works and JEI
moves such items. The viewer's transfer now counts and moves every stack
of the item; the recipe book's placement keeps vanilla's rule.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01KtNXhvTSpY15Rjzwuqh8Nf
2026-11-09 20:58:32 -06:00
martipopsandClaude Opus 5.5 922c047e22 On-screen keyboard: the hints wrap, and the panel grows to hold them
The hint line under the keys had a fixed 30 dp strip, so a longer line (RT
Complete over chat, a translation, the pack's wider letters) ran past the
panel or had to shrink. The hints now wrap onto as many lines as they need
within the panel's width, in order, and the panel grows to fit them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 21:46:50 -05:00
martipopsandClaude Opus 5.5 175998d817 On-screen keyboard: Done finishes, a New Line key, and chat's suggestions by pad
The user's list for the controller's keyboard:
- Done (and Menu) on a book and quill's page or a sign was Enter, so it
  made a new line. It is now the screen's own Done (the book saves and
  closes, the sign closes with its text); a New Line key beside it makes
  the line there.
- Over chat, RT is Tab (CommandSuggestions.keyPressed): it fills in the
  selected suggestion and again the next, and the right stick picks the
  one before or after (Up and Down), so commands complete without closing
  the keyboard. The hint line shows RT Complete there.
- A key's label shrinks to fit when it is wider than its key (a long
  translation, New Line in the pack's font).

pad_menu_e2e: osk_book_done, osk_sign_done, osk_chat_suggestions.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:52:32 -05:00
Senjin 1f7d2adcbe Mobs: a mob sitting in a boat on water is not in the water, so a drowned or zombie in a boat burns in daylight
AbstractBoat.modifyPassengerFluidInteractionBox for mobs: a boat that isn't underwater keeps only the part of its passenger's box above the boat's top, and all of it when the boat reaches the passenger's head. Players had it (motion/fluids.rs); mobs measured their whole box, so a passenger's legs below the surface made it count as in the water: it never burned in daylight, the flow pushed it and boarding splashed.
2026-11-10 02:49:35 +01:00
MSedgeMC a445b69634 port_map: BlocksAttacks.hurtBlockingItem has the ITEM_USED award
The row said the statistic was never awarded; `after_block` awards it
before the blocked-damage check now.
2026-11-10 01:33:27 +00:00
MSedgeMC 5b0d6e7047 test: a shielded hit counts the shield as used
`a_blocked_hit_counts_the_shield_as_used` holds a shield in the off hand
with the use action running and calls `after_block` twice: a hit it takes
whole (5.0 blocked) takes the `used` count from 0 to 1, and a hit it
blocks nothing of takes it to 2. Without the award both stay 0.
2026-11-10 01:33:05 +00:00
MSedgeMC 2d7f975701 fix: a blocked hit counts the blocking item as used
`BlocksAttacks.hurtBlockingItem` awards `Stats.ITEM_USED` for the item
before it applies the damage function, and `LivingEntity.hurtServer`
calls it before its `damageBlocked > 0` branch: every hit taken while
using a blocking item counts it, even one it blocks nothing of.

Horizon's `after_block` applied the damage and broke the item at its max
damage but never awarded the statistic. It now does, on the
authoritative side (`remote.is_none()`, vanilla's `!level.isClientSide`),
before the early return for a hit that blocked nothing.
2026-11-10 01:32:40 +00:00
Riccardo GuerrieroandClaude Fable 5.1 1258ec339a Load the prepared test resources once per test process
horizon-client-lib's unit tests spent 71% of their time in the 708 mobs::
tests and 7% in lan::prediction, and nearly all of that was each test
reading the whole prepared root again in a debug build: Assets (block
registry, tags), BlockFacts, MotionResources, MobData, MobTypes, Biomes,
Items and the collision registry, about 7 s a call under load. A test
calling the fixture 16 times paid 16 times (157 s).

The tests only read those tables and build their own World over them, so
mobs/world.rs keeps the vanilla root's bundle (TestResources) in a OnceLock
and run_test_world builds the per-test World on it as before. A mod root
in a test's scratch folder is still loaded fresh each call, since the same
path can hold different content between tests. test_fixtures::vanilla()
does the same for Assets, RenderAssets, collisions and MotionResources;
lan::prediction's steps_in uses it, and motion.rs and entities.rs can
follow.

Same tests, same cases, same assertions; nothing new is #[ignore]d.
cargo test -p horizon-client-lib --lib on an 8-core laptop at 8 threads:
388 s -> 99 s wall (three interleaved runs each), 3061 s -> 770 s summed.

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

Claude-Session: https://claude.ai/code/session_01WZzq8MxShENtiMj2DMbm5G
2026-11-10 01:58:51 +01:00
Mayonesu0andClaude Sonnet 5.5 ce5491d573 Keep a loaded mob's AbsorptionAmount as saved, negative or above the maximum
LivingEntity.readAdditionalSaveData stores AbsorptionAmount through
internalSetAbsorptionAmount, which does not clamp; only setAbsorptionAmount
(the Absorption effect, the damage path) clamps to [0, max_absorption].
mob_from_nbt clamped it to at least 0.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 01:51:00 +01:00
MSedgeMC 7cbe3d49b0 port_map: the drowned's fluid push names its arm
Drowned.isPushedByFluid had no row; it is the drowned arm of
living.rs::Mob::pushed_by_fluid now.
2026-11-10 00:50:48 +00:00
MSedgeMC 83122825e5 Test the drowned that a current leaves alone
Mob::pushed_by_fluid, the predicate both push sites of
LivingEntity.updateFluidInteraction read: a drowned with its swimming flag set
answers false, the same drowned with the flag cleared answers true, the water
mobs that never take a push (a cod, a squid, a turtle, a nautilus) answer
false either way, and a zombie answers true. Without the drowned arm the
swimming one answers true too.
2026-11-10 00:50:42 +00:00
MSedgeMC c3af4fadeb A swimming drowned is not pushed by the current
Drowned.isPushedByFluid answers `!isSwimming()`: a drowned swimming after its
target holds its line in a stream, while one walking the bottom is carried
along like any other mob. Horizon answered the plain LivingEntity rule for
every drowned, so a drowned chasing a player through flowing water was swept
off its path, and the current was applied even while its own move control was
driving it.

pushed_by_fluid now answers the drowned with its swimming flag, which
Drowned.updateSwimming keeps on the mob (`extra.flag`) and the clients read
from the shared flags, and the rest of the table stays as it was.
2026-11-10 00:50:42 +00: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
MSedgeMC fbf4ddd296 port_map: the freeze immunity names its port
canFreeze had no row in either place: LivingEntity's tag-and-wearables half
and the Skeleton override are both ai.rs::can_freeze.
2026-11-10 00:47:14 +00:00
MSedgeMC a8c16eb5db Test the skeleton that powder snow leaves alone
LivingEntity.aiStep's freezing, through freeze_tick, in a world with a powder
snow block at the mob's feet: a skeleton, a stray, a bogged, a parched and a
wither skeleton gain no frozen tick (0) where a zombie gains one (1), and one
of them put at 140 frozen ticks melts to 138 with no damage where the zombie
takes its 1. Without the skeleton arm the skeleton freezes like the zombie.
2026-11-10 00:47:14 +00:00
MSedgeMC ce66fd40bd A skeleton never freezes
Skeleton.canFreeze answers false, so powder snow builds no frozen ticks on a
skeleton and never deals it the 1 a fully frozen mob takes, and a skeleton
already frozen melts out of it at two ticks a tick. The stray, the bogged, the
parched and the wither skeleton inherit the same false. Horizon only asked the
freeze_immune_entity_types tag — which lists the stray, the polar bear, the
snow golem and the wither — and the worn freeze_immune_wearables, so a plain
skeleton standing in powder snow froze like a zombie.

can_freeze now answers the five skeletons before the tag, which is where
vanilla puts the class override.
2026-11-10 00:47:13 +00:00
Evrim CicekandClaude Opus 5.5 22e92450e4 ExperienceOrb::new builds a fresh orb
The summon and remote-spawn sites build their orb with it, so the new
fall_distance field doesn't grow commands.rs and remote.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 03:37:25 +03:00
DUzzL 2f0e97de09 Turn fireballs towards their movement every tick
AbstractHurtingProjectile.tick calls ProjectileUtil.rotateTowardsMovement(this, 0.2F); the fireball kinds skipped it and kept their creation rotation.
2026-11-10 01:34:26 +01:00
Mayonesu0andClaude Sonnet 5.5 d5adb5fc37 Keep a painting's saved pitch (Rotation[1]) through save and load
Entity.load sets xRot from Rotation; Painting.readAdditionalSaveData's
setDirection only writes the yaw, so Entity.saveWithoutId writes the
loaded pitch back. painting_to_nbt wrote a fixed 0.0. It now reads the
pitch from the retained compound with setXRot's rules (non-finite
ignored, xRot % 360 clamped to +-90).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 01:33:54 +01:00
alexcatdadandClaude Opus 5.5 5895774d7e Nix: services.horizon-server, a NixOS module for a Horizon server
The module runs `horizon-server run` as its own user in /var/lib/horizon-server, in a hardened unit
that stops it with SIGTERM (the world saves first) and turns the update check off, since Nix picks the
release. The first start writes server.toml with `horizon-server init` from `init`; every start then
sets `port`, `resources` and `settings` with `horizon-server config set`, which checks them and keeps
the owner's other edits. `openFirewall` opens the port.

It ships no game files and fetches none. The game files are either a folder the owner prepared
(`resources`, given as a string so they never enter the store), or `prepare`: horizon-prepare run before
the server on a server jar and a Minecraft folder the owner put on the machine, in a unit with no network.

nix/tests/server.nix checks it in a VM without any of Mojang's files. Fixes #13841.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 02:28:51 +02:00
Evrim CicekandClaude Opus 5.5 827ff272ab A rocket's first tick doesn't halve its fall in lava
Entity.isInLava is false while firstTick holds; the rocket cleared its
first-tick flag before the lava check. The `rocket fall distance` group
gains a rocket summoned inside lava.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 03:22:22 +03:00
Evrim CicekandClaude Opus 5.5 ee1dfabaa6 An orb's first tick doesn't halve its fall in lava
Entity.isInLava is false while firstTick holds, so Entity.baseTick halves
the fall only from the second tick. Review finding on #14440; the
`orb fall distance` group gains an orb summoned inside lava.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 03:22:21 +03:00
fbiandClaude Sonnet 5.5 134eb9f0a6 Launcher: uninstall deletes the data of a moved data location
`uninstall --remove-data` only deleted backend::data_dir() when the folder was
called `horizon-launcher`, a check made for HORIZON_LAUNCHER_HOME ("may point
anywhere"). Once the player has moved the data (Settings > Data folder), the
data folder is the location's launcher root, which the move always names
`<folder>/launcher`, so the check failed for every moved install: nothing was
deleted, and the build folder, removed inside the same `if`, stayed too.

A moved location's launcher folder is now deleted when it is the configured one
and has the name the move gives it; its build folder when it is `<folder>/build`
or inside the launcher folder. The game folder is never touched, and a location
file edited by hand to name another folder is still refused. Under
HORIZON_LAUNCHER_HOME the old name check stays.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 00:09:32 +00:00
Evrim CicekandClaude Opus 5.5 5e1eab0e05 Experience orbs keep their fall distance and post hit_ground on landing
Part of #14440: an orb's Entity.fallDistance builds up as it falls and is
saved and loaded (and taken by /data merge). Entity.baseTick's fluid step
resets it in water and halves it in lava; Entity.move's long-move ray
resets it past a fall-resetting block or water, and checkFallDamage adds
the drop and, landing after a fall, posts HIT_GROUND. Adds the
`orb fall distance` command-exec group.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 03:07:01 +03:00
voidsip fc83f106f2 Encode gateway fixture positions as NBT double bits 2026-11-09 18:04:41 -06:00
voidsip 7f8e095514 Keep ordinary explosion reference tests on the default calculator 2026-11-09 18:04:34 -06: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
MSedgeMC 4471b929a3 port_map: DeathScreen's cause links and hover 2026-11-09 23:57:15 +00:00
MSedgeMC 89e9f69c8c test: a death cause's link is the glyph under the pointer
The cause line is four 6-pixel glyphs centred in 427; the last two carry an
open_url click event, and only the pointer inside a glyph of the line finds
it.
2026-11-09 23:57:15 +00:00
MSedgeMC 80ae207954 fix: the death screen's cause behaves as a link
DeathScreen.mouseClicked runs a ClickableStyleFinder over the cause line and
opens an open_url click event through clickUrlAction; its render visits the
line with HoveredTextEffects.TOOLTIP_AND_CURSOR. Horizon drew the cause with
text_styled and ignored clicks and hover on it.
2026-11-09 23:57:15 +00:00
69a156f587 Mobs: the mob loop in islands on several threads (HORIZON_PARALLEL_TICK)
With the parallel tick on, tick_creature_entities splits its mobs into
islands: a mob's reach for the tick (its move, a teleport, what it reads
around itself, the village data, the mobs it is linked to by id) meets only
mobs of its own island. Islands are packed into one group per thread; each
group runs its mobs' turns in list order (creatures/mob_group.rs), draws the
level random from a lane of one OrderedDraws, and reads a mob of another
group by id as the serial loop would show it (Board/Foreign). The results
merge back in list order. HORIZON_PARALLEL_TICK=verify also runs the serial
loop on copies and logs a tick that differs.

Each turn still decides whether its mob is loaded and ticking, as the serial
loop does, so the plan gives every mob a ticking mob's reach. The loop stays
serial with native mods, on a sampled tick, under HORIZON_TEST_SEEDS, with
fewer than 256 ticking mobs, with a wither or warden, or when one island
holds three quarters of the ticking mobs. A group's thread holds its lane's
LaneDone, so a panic in one group lets the others finish and reaches the
caller.

Tests: the plan's islands and packing, and an end-to-end run of three herds
in three groups against the serial loop (mobs, level random, death marks).

From kacpherek's #13644 (mob_islands.rs); for #14457.

Co-Authored-By: Kacper <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 23:55:41 +00:00
voidsip d1d35697a1 Clarify the gateway phase fixture validation boundary 2026-11-09 17:52:57 -06:00
voidsip 6a9154801f Check real gateway cooldown dispatch before and after movement 2026-11-09 17:51:55 -06:00
DUzzL 9094309407 Let rideable minecarts pick up shulkers beside the rail 2026-11-10 00:48:39 +01:00
foudretbr ded3d99728 fix(game): streamline dragon geometry and snap targets to respect file size limit 2026-11-10 00:26:23 +01:00
DUzzL 72c06622be Turn a shot firework rocket to its motion like Projectile.shoot 2026-11-10 00:25:30 +01:00
72fe5b13ba Mobs: the mob loop's body moves into a group of mobs, still one thread
The per-mob turn of tick_creatures (its world view, brain, movement, events,
riders, deaths and pushes) moves into creatures/mob_group.rs as Group::tick, on
a Group that owns the mobs it ticks, their snapshot grid, pushes, events and the
level random. tick_creatures builds one group of every mob and runs it in list
order, so nothing changes: whether a mob is loaded and ticking, and the
per-chunk difficulty, are still decided at the mob's own turn. The parallel tick
(#14457) will run several groups. creatures.rs shrinks by 177 lines.

From kacpherek's #13644.

Co-Authored-By: Kacper <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 23:24:28 +00:00
aaf5aa6fcf Mobs: what the parallel mob tick's groups read, behind no switch
MobSlot lets the mob loop's helpers (apply_brain_events, riders, a school's
recruits) work on a group's borrowed mobs as on the list's own; MobWorld.foreign
answers a lookup by id of a mob in another group (ForeignSeen); PendingPushes::
append puts one group's pushes after another's, each mob's in order; and a
SeenGrid of part of a list can answer as the whole list's grid would
(answering_as). Nothing sets them yet: every caller passes the list's own mobs
and foreign: None. For #14457.

From kacpherek's #13644; the grid follows main's scan threshold.

Co-Authored-By: Kacper <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 23:20:15 +00:00
eaa027179b Random: a lane that panics is marked finished, so the others don't wait on it
LaneDone, held by a lane's thread while it runs, marks the lane finished when
dropped, also while a panic unwinds: the other lanes run to their end instead of
waiting forever for its items, and the panic reaches the caller when it joins
them. Review finding 1 on #13644; the test times out without it.

Co-Authored-By: Kacper <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 23:20:15 +00:00
bbaa73ec43 Random: one LegacyRandom stream drawn in list order from several threads
OrderedDraws shares a LegacyRandom among lanes that each run their part of a list
in list order: an item's first draw waits until every other lane has passed it,
so every draw comes out as one thread would make it. A copy of a gated random
draws on its own. For the parallel mob tick (#14457).

Taken from kacpherek's #13644.

Co-Authored-By: Kacper <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 23:20:15 +00:00
d3c8eb00ea Parallel tick: the dropped items' own moves worked out ahead on helpers
While the parallel tick is on, each ticking item's move (ItemEntity.tick up to its
merge) is worked out on a copy on the helper threads before the item loop, and
put in place at its turn. The move reads only the item, the blocks, the obstacle
boxes and the border, none of which the loop changes; an item inside a block
escapes at its own random's speed, which the copy carries. A merge before its turn
that changed its count, age or pickup delay sends it back to the move at its turn,
as do native mods and a mod listening to the round. HORIZON_PARALLEL_TICK=verify
makes each move again at its turn on a copy and logs a difference.

The item's tick reads ItemTables (the blocks, collisions, motion tables, border)
so helpers run the same code the turn runs; ObstacleGrid moves to
entities/obstacles.rs and gains a query with the caller's scratch. A test ticks
307 items (escaping blocks, in water, falling, merging) both ways and checks the
items and the level's random come out the same.

From kacpherek's #13644, redone on main's item tick.

Co-Authored-By: Kacper <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 23:20:11 +00: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
foudretbr d42021f1d2 fix(game): preserve area effect cloud Motion across chunk save and reload (#14482) 2026-11-10 00:02:31 +01:00
Mayonesu0andClaude Opus 5.5 2b3062609c Compare a broadcast player target by distance in the hoglin brain
HoglinAi.setAttackTargetIfCloserThanCurrent compares the current and the
broadcast target by position (BehaviorUtils.getTargetNearestMe), players
included. The flush kept the current target whenever either side was
Who::Player, since Who::Player is each mob's own player. The broadcast now
carries the hoglin that sent it, and the flush places the new target as
that hoglin's player and the current one as the receiver's (host, or the
LAN guest in mob_players). A receiver that takes another mob's player is
bound to that player.

Part of #12788.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 23:59:53 +01:00
Horizon agentandClaude Opus 5.5 9b8c6dc0b0 Forks' pull requests run verify on GitHub's runners, never on the desktop's
A fork's workflow run sees no repository variable, so SELF_HOSTED_CI can't move it: a fork's pull
request whose number ends in 0 or 1 always asked for the desktop's horizon-checks runners, even with
SELF_HOSTED_CI=light (#14131 waited there for hours). With the desktop off, its required verify
would wait a day, fail, and wait again on a re-run, so the pull request could never merge. verify,
launcher, mod-registry and telemetry-server now pick our checks runners only for this repository's
own branches; a test pins it for every pull_request workflow.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 23:59:07 +01: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
Horizon agentandClaude Opus 5.5 1ff33fd853 Merge origin/main into split/cosmetics-settings
extras.rs: main dropped DiscordPresence, this branch dropped the cosmetics
rows: both removals kept. layers.json: main dropped client-lib -> theme,
this branch dropped client-render -> cosmetics: both removals kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 23:51:45 +01:00
793565a33f Lecterns take a placed book into has_book, gate the comparator on it and post block_change
`LecternBlock.getStateForPlacement` sets HAS_BOOK from the item's
`block_entity_data.Book` when the placer may use game-master blocks, so a
lectern placed from a book-carrying item draws its book.

`LecternBlock.getAnalogOutputSignal` reads the state's HAS_BOOK before asking
`LecternBlockEntity.getRedstoneSignal`, so `/setblock ... lectern[has_book=true]`
with no book now reads 14 instead of 0, and `has_book=false` reads 0.

`LecternBlock.resetBookState` sets POWERED and HAS_BOOK in one write, then posts
`minecraft:block_change` (the player who put the book on, none when
`LecternBlockEntity.onBookItemRemove` takes it off) and updates below, so a
sculk sensor hears both.

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

Claude-Session: https://claude.ai/code/session_01WZzq8MxShENtiMj2DMbm5G
2026-11-09 23:49:04 +01:00
c9a064bbc5 Random: a lane that panics is marked finished, so the others don't wait on it
LaneDone, held by a lane's thread while it runs, marks the lane finished when
dropped, also while a panic unwinds: the other lanes run to their end instead of
waiting forever for its items, and the panic reaches the caller when it joins
them. Review finding 1 on #13644; the test times out without it.

Co-Authored-By: Kacper <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 22:48:09 +00:00
3831c9df9f Random: one LegacyRandom stream drawn in list order from several threads
OrderedDraws shares a LegacyRandom among lanes that each run their part of a list
in list order: an item's first draw waits until every other lane has passed it,
so every draw comes out as one thread would make it. A copy of a gated random
draws on its own. For the parallel mob tick (#14457).

Taken from kacpherek's #13644.

Co-Authored-By: Kacper <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 22:46:51 +00:00
voidsip 8f96b0b6bb Format the level-up sequence setup 2026-11-09 16:46:37 -06:00
voduff 3365857605 Merge remote-tracking branch 'origin/main' into fix/14424-legacy-chunk-entities 2026-11-09 22:36:25 +00:00
voidsip c98211b51b Send refreshed merchant offers after trade level-ups 2026-11-09 16:35:29 -06:00
voidsip 21023c478e Cover merchant offer packets at the level-two boundary 2026-11-09 16:35:28 -06:00
SolenopsisbotandCodex 71709ecb3f fix: keep save backpressure out of oversized module
Co-Authored-By: Codex <[email protected]>
2026-11-10 09:34:42 +11:00
voidsip 4bed23dc4c Leave portal placement helpers in their existing module 2026-11-09 16:29:08 -06:00
itarqos5andClaude Sonnet 5.5 7cc8eae1d1 Tests: leaves count their distance from a log and decay only at seven
`LeavesBlock.updateDistance`: DISTANCE is one more than the smallest of the six face neighbours', a
`#minecraft:logs` block counting 0, another leaf its own DISTANCE and anything else 7, spreading one
step per scheduled tick. A row of nine leaves reads 1..6 then the cap and returns to the cap when the
log goes; logs, stripped wood and nether stems count while planks, stone, fences and leaves at the cap
do not; the six sides count and the diagonals do not. Only a non-persistent leaf at 7 is removed by a
random tick (`decaying`). The existing test covered a single leaf at 7 and one log beside a leaf.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 22:29:05 +00:00
itarqos5andClaude Sonnet 5.5 5885feff36 Tests: a stair takes its corner shape from the stairs beside it
`StairBlock.getStairsShape`, recomputed by `updateShape` when a horizontal neighbour changes: a
same-half stair on the facing side, turned across, makes an OUTER corner (LEFT when it faces the
subject's counter-clockwise), else one behind it makes an INNER corner; either is refused when the
block across the corner is a stair like the subject (`canTakeShape`), and the outer test is first.
Twelve arrangements: both handednesses, same axis, other half, a non-stair, both neighbours at once,
and both refusals.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 22:26:10 +00:00
b820331574 Place a can_place_on block against a sign in adventure mode
SignBlock.useWithoutItem answers PASS when the player may not build and
the sign is not waxed and has no click action to run (SignBlock.java:139-146),
so ServerPlayerGameMode.useItemOn goes on to the item phase
(ServerPlayerGameMode.java:411-427) and ItemStack.useOn's canPlaceOn test
lets a `can_place_on` block place against the sign (ItemStack.java:358-362).
Horizon's sim's sign arm answered SUCCESS, which `use_hand` consumed before
the item was used. `sign_consumes_click` now asks the sign's block entity
the question vanilla asks it, and use_hand only ends the click when it says
a waxed sign, a click-command side or a player who may build takes it.

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

Claude-Session: https://claude.ai/code/session_01WZzq8MxShENtiMj2DMbm5G
2026-11-09 23:20:28 +01:00
voduffandClaude Opus 5.5 3e6842009d A chunk saved before 1.17 loads the entities it keeps (#14424)
SerializableChunkData.postLoadChunk adds a full chunk's own entities list
(ServerLevel.addLegacyChunkEntities): a chunk last saved before entities had
files of their own keeps them there. Horizon read only entities/, so an old
world's minecarts and mobs were gone. A re-save writes no such list and the
entities go to entities/, as in vanilla.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 22:19:35 +00:00
2b01dbcd5e signs keep one editor at a time and only open on plain text
SignBlock.openTextEdit gives the sign to one player (`playerWhoMayEdit`,
kept on the block entity like vanilla's SignBlockEntity, not on the
connection): another player's click opens nothing, their write is dropped
(`SignBlockEntity.updateSignText`) and their dye, ink sac or honeycomb is
refused without being used (`SignBlock.useItemOn`). The write releases the
sign, and a sign tick drops a holder who walked out of the block
interaction range plus four blocks or left the world
(`SignBlockEntity.tick`, `playerIsTooFarAwayToEdit`).

The editor also opens only on a side whose lines are plain text
(`SignText.hasEditableText`): on the click and after placement
(`SignBlock.setPlacedBy`), so a translatable or score line is no longer
replaced by typing into it.

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

Claude-Session: https://claude.ai/code/session_01WZzq8MxShENtiMj2DMbm5G
2026-11-09 23:19:11 +01:00
voidsip ca8cc5a43b Prove nested native damage cannot inherit an outer credited source 2026-11-09 16:17:25 -06:00
SolenopsisbotandCodex dbef6e3e14 fix: bound asynchronous chunk save backlog
Co-Authored-By: Codex <[email protected]>
2026-11-10 09:15:19 +11:00
5f61de11d9 Parallel tick: mob entity data and attributes from plain tables, on helpers
mob_meta reads SyncData instead of the hub (SyncData::effect_meta, with main's
shown_color; Hub::effect_meta calls it), and the attribute instances read
AttrSources (the catalog, the items, /attribute's maps) instead of Game. While
the parallel tick is on, the mobs' entity data and attributes are built after
the pass on the pool and put in place (game/lan/mob_states.rs); with it off
they are filled in place as before, with no new Vecs. What is read in order
(idle state, lead, equipment network ids) stays in the pass. A test checks the
states put in place equal those built in place.

From kacpherek's #13644.

Co-Authored-By: Kacper <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 22:14:03 +00:00
1ef6e26dfa Parallel tick: mob prep and random tick masks on the helper threads
Before the mob loop, each mob's climbing check, snapshot entry and nearest
player read only that mob, the blocks and the players, so they are mapped on the
pool from 256 mobs (creatures/prep.rs, out of creatures.rs). A mob that moved
before its turn is asked its nearest player again where it stands. The masks the
random ticks read are built on the pool before the random ticks; the draws and
the ticks stay serial. The random tick test runs a fourth path with its masks
built ahead and checks it gives vanilla's scan's events and random values.

From kacpherek's #13644.

Co-Authored-By: Kacper <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 22:14:03 +00:00
2c9aa0ac86 Parallel tick: one pool of helper threads, behind HORIZON_PARALLEL_TICK
horizon_client::parallel: one rayon pool made once (at most 8 threads, the server
thread takes parts too), par_map that gives every part's results in the items'
order, and the HORIZON_PARALLEL_TICK switch (off by default; 1 or verify turn it
on). With the switch off every phase runs its serial code on the server thread.
Goal is Send + Sync, so mobs can be read from helper threads.

From kacpherek's #13644.

Co-Authored-By: Kacper <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 22:14:03 +00:00
605f1ead0c Anticheat: join the log's writer thread when the anticheat drops
The writer thread was detached, so the last lines handed to it could be lost when
the server stopped. LogWriter now closes the channel and joins the thread on drop.
The thread writes the lines waiting together and flushes once none is left.
Tests: every line reaches the file in order by the drop, and dropping the writer
waits for a thread that is behind.

Co-Authored-By: Kacper <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 22:13:43 +00:00
Horizon agentandClaude Opus 5.5 92dab6cafc Pages v2: text fields, and a page in the title screen's Friends icon slot
horizon_hooks::pages gains Row::Field (Row::field(label, text).max(n)),
vanilla's EditBox on a page drawn as its own screen, with Act::Edit (each
change, before any other act or redraw reaches the page) and Act::Submit
(Enter); Place::Title, the title screen's Friends icon slot, unchanged in
place and look; Page::back, the label of the button that leaves such a
page; and pages::connect, for a Title page to join a server.

The icon opens the first Title page as multiplayer::Screen::Page, a form
laid out as Join a Friend (multiplayer/page.rs); its fields go through the
title screen's caret like every field of its own (focus, typing, caret
keys, selection, copy, paste, the max length), Tab walks fields then
buttons. Without a Title page the icon opens Join a Friend while a
resolver is registered, else it stays inactive. The options shell skips
fields. title.rs loses lines.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 23:11:27 +01:00
itarqos5andClaude Sonnet 5.5 3adf1bbe97 Tests: a door half also reads the power beside its other half
`DoorBlock.neighborChanged` is `hasNeighborSignal(pos) || hasNeighborSignal(the other half)`. Power
written raw beside one half, then a block update that reaches only the other half, opens it and the
half that copies it. The existing door test could not see this term: the half with power beside it
always saw it itself, so dropping the other-half check left it green.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 22:07:58 +00:00
itarqos5andClaude Sonnet 5.5 7fad4b662d Tests: the hopper test now tells a quasi-connected hopper from a plain one
The diagonal cases put power where it sends the hopper no block update, so the hopper never looked
and the case passed for any implementation: mutating the port to be quasi-connected left the test
green. Each such case now pokes the hopper with a block on a side no case uses, as the piston and
dispenser tests do, and the same mutation turns it red.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 22:07:58 +00:00
itarqos5andClaude Sonnet 5.5 2139b4464a Tests: a fence connects by vanilla's rules
`FenceBlock.connectsTo` is `!isExceptionForConnection(state) && faceSolid || sameFence || gate`.
Eighteen neighbours: any full face joins (stone, dirt, glass) except leaves, barriers, pumpkins and
melons; torches and air do not; another fence joins only within its own family (wooden against
nether brick does not); a gate joins only when its facing lies across the line.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 22:03:11 +00:00
Mik00000 c37356308c fix(client): synchronize look rotation and update pvp hud for spear stab (fixes #14092) 2026-11-09 23:53:33 +02: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
Evrim CicekandClaude Opus 5.5 7d0ab4d38a post_landing_event takes its state from landing_state
The block a landing names (mainSupportingBlockPos, else getOnPosLegacy)
is worked out in one place for items, armor stands and rockets.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 00:48:03 +03:00
Umut Cevdet Koçak 18c2782391 Merge pull request #259 from sunrise-org/fix/perf-upload-public-check
Fix the capture upload E2E driver for the public CDN
2026-11-10 00:47:14 +03:00
Mayonesu0andClaude Sonnet 5.5 ac2a95ab1d Keep streaming.rs from growing: the forced ring's ranking basis lives in forced_ring (#13175)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 22:31:34 +01:00
Mayonesu0andClaude Sonnet 5.5 faef0581d1 Test that queued jobs follow a forced chunk (#13175)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 22:31:34 +01:00
Mayonesu0andClaude Sonnet 5.5 4df6ddc3f3 Rank a forced chunk's ring around it (#13175)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 22:31:34 +01:00
umcekoandCodex gpt-6.1-sol eb150b0297 Make the capture upload check work through the public CDN
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-10 00:29:46 +03:00
Umut Cevdet Koçak 88cc456f65 Merge pull request #258 from sunrise-org/feat/perf-sharing
Move the perf viewer to the site and implement capture sharing in the CLI
2026-11-10 00:25:02 +03:00
Evrim Cicek 34d493fb45 Merge remote-tracking branch 'upstream/main' into fix/rocket-fall-distance 2026-11-10 00:08:45 +03:00
Horizon agent b4a5943911 Merge origin/main into shader-api-linear-blend 2026-11-09 22:07:36 +01:00
umcekoandCodex gpt-6.1-sol b2d4f65e77 Move the perf viewer to the site and add capture uploads and share links
Keep Rust reader artifacts in the game repository, move the viewer source into sunrise-org/horizon-site, and add CLI uploads and remote capture reads. Update fork automation for the organization transfer.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-10 00:03:48 +03:00
Dylan FerreyraandClaude Opus 5.5 86f629b61e Say that the queue limit is for network clients only
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:01:33 -03:00
voidsip d42af058e8 Preserve TNT transfer state without trusting saved NBT 2026-11-09 14:58:58 -06:00
Mayonesu0andClaude Sonnet 5.5 dc10ffbf02 Keep the player's attribute modifiers through a respawn packet
ClientPacketListener.handleRespawn gives the new LocalPlayer
AttributeMap.assignAllValues of the old one when the packet keeps the
attribute modifiers (every level change), assignBaseValues after a death.
The new player here started empty, so the worn armour's modifiers and any
custom one were gone after an End or Nether portal until re-equipped.

Fixes #13541

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 21:57:24 +01:00
8b7a7d5cfe Anticheat: write the flag log on a thread of its own
Every flag wrote its log line from the tick thread, piece by piece. With 40 load-test bots
(which fail the movement prediction every tick: 76,000 lines in 3.5 minutes) that was
lan_guests 7.7 ms a tick; a lagging player's flood costs the same. The lines now go, in order,
to a writer thread: lan_guests 1.9 ms on the same run.

Co-Authored-By: Kacper <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:51:59 +00:00
Evrim CicekandClaude Opus 5.5 e96b2ff99a Firework rockets keep their fall distance
#14318: a rocket's Entity.fallDistance builds up as it flies down and is
saved and loaded. Entity.baseTick's fluid step resets it in water (and
splashes on entry, and lets currents push the rocket); lava halves it.
Entity.move's long-move ray resets it past a fall-resetting block or water,
checkFallDamage adds the drop and posts HIT_GROUND on landing, and a
cobweb or powder snow makes it stuck. Adds the `rocket fall distance`
command-exec group.

Items now splash with their motion before the water's current pushes it,
as Entity.updateFluidInteraction does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 23:48:48 +03:00
voidsip b4b9d03ed5 Keep pending entity load context separate from saved NBT 2026-11-09 14:47:59 -06:00
Nikita Maximilian GrimmandClaude Opus 5.5 9f1fb2c77c HDR tone curve test: the scene's peak itself may come back one half-float step off
S's gamma rounds up in half float, to light just above S that the curve holds at S. Whether that comes back to the same half float depends on the GPU's rounding: on an Apple M4 Pro (Metal) it lands one step lower, so the exact check at S failed there. Every stored value below S still comes back bit-identical.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 21:45:09 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 46fe2c7f3d Merge main into #13578; graphics.md's HDR paragraph is main's plus this PR's sentences
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 21:28:40 +01: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
itarqos5andClaude Sonnet 5.5 5f234b1b99 Tests: a note block cycles through 25 notes and follows its power
`NoteBlock.useWithoutItem` cycles NOTE through its 25 values and wraps on the 25th click, and POWERED
follows the neighbour signal on and off.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 20:24:45 +00:00
itarqos5andClaude Sonnet 5.5 f284d61b06 Tests: a note block takes its instrument from the block below or a head above
`NoteBlock.setInstrument`, run when it is placed: a mob head above decides the instrument
(`worksAboveNoteBlock`), else the block below does, except that a head below gives harp and a
block with no instrument is harp too. Twenty-four cases from `Blocks.java`'s `.instrument(...)`
registrations, the seven head types included.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 20:24:44 +00:00
itarqos5andClaude Sonnet 5.5 09de0cc427 Tests: sand falls through what is free and rests on what is not
`FallingBlock.isFree` is `isAir || FIRE tag || liquid || canBeReplaced`: sand falls through air, both
kinds of fire, any liquid and the blocks flagged replaceable (grass, vines, dead bushes, snow) and
rests on everything else, torches, carpets, slabs, saplings and cobwebs included.

Each block stands on what it needs: placing the sand above is a neighbour update, and a carpet,
sapling or lily pad with nothing to stand on is removed by it, which frees the sand for a reason
that is not the rule under test. (A first draft missed this, and read as Horizon letting sand fall
through carpet; the exported flag and vanilla agree that carpet is not replaceable.)

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 20:24:43 +00:00
JoansitohandClaude Sonnet 5.5 f23a03f6c7 Lightning rod: update the front block's neighbours without redstone experiments
Fixes #14371

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 21:23:34 +01:00
JoansitohandClaude Sonnet 5.5 644d598685 Check only the spawnable bounds for /spawnpoint and /setworldspawn
Vanilla resolves the position with BlockPosArgument.getSpawnablePos: no
loaded-chunk or build-height check, only Level.isInSpawnableBounds, else
argument.pos.outofbounds.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 21:21:36 +01:00
voidsip afd4df1cd9 Use entity-specific End gateway cooldowns 2026-11-09 14:20:42 -06:00
itarqos5andClaude Sonnet 5.5 3606b9ea41 Tests: lava flowing down onto water makes stone and stays lava
`LavaFluid.spreadTo`: lava spreading down into water turns the water's block to stone. Water below a
lava source does not harden the source itself, since `LiquidBlock.shouldSpreadLiquid` skips the side
below; the existing test only covered water beside lava.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 20:19:44 +00:00
itarqos5andClaude Sonnet 5.5 de91e0b39d Tests: lava spreads three blocks on a floor in the overworld
`LavaFluid.getDropOff` is 2 outside the Nether (1 in an ultrawarm dimension): lava loses two levels a
block and reaches only three, at levels 2, 4 and 6. Run long enough for its 30-tick steps, including
the random slowdown of a rising level, so it checks the extent and not the timing.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 20:19:43 +00:00
itarqos5andClaude Sonnet 5.5 2ffd4f31c2 Tests: water advances one block every five ticks
`WaterFluid.getTickDelay` is 5: a source's first spread is five ticks after it is placed and each
flowing block schedules the next five on, so the water reaches distance d on tick 5 * d, none
sooner, out to its seven blocks and never the eighth. The existing test checked only where the water
ended up, not when.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 20:19:42 +00:00
itarqos5andClaude Sonnet 5.5 41b4268ace Tests: a comparator reads the output vanilla gives each state-based block
`getAnalogOutputSignal` of the blocks whose output is a function of their own state, read through a
comparator as in play: a beehive's honey level, a cake's `(7 - bites) * 2`, a candle cake's full 14,
a composter's level, an end portal frame's 15 with an eye, a lava cauldron's 3, a water or powder
snow cauldron's level, a respawn anchor's `floor(charges / 4 * 15)` (0, 3, 7, 11, 15) and a lit
copper bulb's 15. Sixty-four state rows, each reporting its own mismatch.

The comparator is placed first: it computes only when told a neighbour changed, and a block that
has an output tells it when it is placed. (Two earlier drafts got this wrong, and one misnamed the
end portal frame's property: it is `eye`, not `has_eye`.)

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 20:17:17 +00:00
voidsip 6d6dd49139 Send merchant offers only when prices refresh 2026-11-09 14:16:58 -06:00
voidsip fa7424ca31 Protect sulfur cube blasts while portal cooldown is positive 2026-11-09 14:15:30 -06:00
voidsip 37249efd34 Check explicit blast context survives a nested native blast 2026-11-09 14:15:06 -06:00
Horizon agent 98498da9aa Merge origin/main (brings #14090's test_git_slim fix) 2026-11-09 21:14:49 +01:00
Horizon agent 536c0f938b Merge branch 'split/cosmetics-fx' into split/cosmetics-settings 2026-11-09 21:13:54 +01:00
voidsip a2b63b3e3b Keep explicit damage source state across nested native explosions 2026-11-09 14:13:34 -06:00
voidsip 1f3f7cebae Separate explicit explosion damage causes from blast ownership 2026-11-09 14:11:42 -06:00
voidsip 66013b7277 Give remote player bodies their own random stream 2026-11-09 14:09:48 -06:00
Horizon agent 316bef4484 Merge origin/main (brings #14090's test_git_slim fix) 2026-11-09 21:04:19 +01:00
Ludovic Marie 4cea8c2ee6 Merge remote-tracking branch 'origin/main' into split/telemetry-probes 2026-11-09 21:00:24 +01:00
itarqos5andClaude Sonnet 5.5 a203a7255b Tests: a dispenser and a dropper trigger on any side and through the block above
`DispenserBlock.neighborChanged`: TRIGGERED is `hasNeighborSignal(pos) || hasNeighborSignal(pos.above())`,
so power on any of its six sides, its front included (a piston skips that one), or beside the block
above it, triggers it, and losing the power un-triggers it. A dropper inherits all of it.

A source that is not beside it sends no block update, so each change is followed by a poke: a block
placed and removed beside it.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 19:56:35 +00:00
itarqos5andClaude Sonnet 5.5 6260f30e4e Tests: redstone wire climbs and descends only where nothing conductive cuts it
`RedStoneWireBlock.getConnectingSide`: a wire climbs onto a block beside it only if nothing
conductive sits on top of the wire itself, and steps down to a wire below a gap only if the block
beside it is not conductive. The wire is placed by an item so its connections are computed, and
with none at all it is drawn as a cross, which tells a real connection from a cut one.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 19:56:34 +00:00
itarqos5andClaude Sonnet 5.5 cbbb38b654 Tests: redstone wire connects to each neighbour by that block's own rule
`RedStoneWireBlock` connects through each neighbour's `shouldRedstoneWireConnectTo`: any signal
source from any side, a repeater only along its axis, an observer only when it faces the wire. And
`getConnectionState` draws a wire whose only connection is on one axis straight, so the opposite
side connects too, where one with no connection stays a dot. Twelve neighbours.

(A first draft expected the opposite side to stay unconnected, and was wrong.)

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 19:56:33 +00:00
itarqos5andClaude Sonnet 5.5 f4ddbb4327 Tests: a piston is powered by its neighbours but its front, and by quasi-connectivity
`PistonBaseBlock.getNeighborSignal` skips the side the piston pushes towards, then also takes what
powers the block above it: that block's neighbours in every direction but down, the pushing side
and the block two above included. Twelve placements of a redstone block around a piston facing
east: the asymmetric front exclusion and the upward-only quasi-connectivity among them.

A source that is not beside the piston sends it no block update, so it takes effect only when
something beside it changes (BUD, the reason quasi-connectivity is a quirk and not a power
source). Each case pokes the piston with a block beside it. A first draft did not, and expected
the piston to extend at once; vanilla does not either.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 19:56:32 +00:00
itarqos5andClaude Sonnet 5.5 29968525ac Tests: trapdoors and fence gates open with power beside them
`TrapDoorBlock` and `FenceGateBlock` take POWERED and OPEN from `hasNeighborSignal` of their own
position and give both up with it. Oak and iron trapdoors, oak and bamboo fence gates.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 19:54:53 +00:00
itarqos5andClaude Sonnet 5.5 7cb62d72e4 Tests: a door opens with power beside either half and closes without it
`DoorBlock.neighborChanged` powers a door from a signal at either of its halves; the half with the
power beside it runs the check and the other copies the state through `updateShape`. Power beside
each half in turn, for wooden, iron and bamboo doors.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 19:54:52 +00:00
itarqos5andClaude Sonnet 5.5 a912cb9c47 Tests: a hopper is disabled by power on any of its six sides and nothing else
`HopperBlock.checkPoweredState` sets ENABLED to `!hasNeighborSignal`: power at any of its six sides
switches it off at once and removing it switches it back on, while power diagonally above does
nothing, since a hopper has no quasi-connectivity. Also checked at placement (`onPlace`).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 19:54:50 +00:00
itarqos5andClaude Sonnet 5.5 fe962aa5f6 Tests: a redstone lamp lights at once and goes dark exactly four ticks after its power
`RedstoneLampBlock` lights the moment it is powered and loses its light on a tick scheduled four
ticks after the power goes: lit through the third tick, dark at the fourth, and power that returns
in time cancels it. The existing lever test only checked that it was dark after five.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 19:54:49 +00:00
itarqos5andClaude Sonnet 5.5 7c4cdcea29 Tests: a powered rail passes power eight rails along in both directions
`PoweredRailBlock.findPoweredRailSignal` gives up at `searchDepth >= 8`: a rail takes power from a
powered neighbour up to eight rails away along the line, both ways, and not from the ninth.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 19:54:47 +00:00
itarqos5andClaude Sonnet 5.5 9972a26864 Tests: a repeater is locked only by a powered diode pointing into its side
`RepeaterBlock.isLocked` is `getAlternateSignal > 0` with `sideInputDiodesOnly`: a diode beside it
locks it only when it is powered and outputs into it. An unpowered repeater or comparator there does
not, and neither does a redstone block, which does feed a comparator's side input.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 19:54:46 +00:00
itarqos5andClaude Sonnet 5.5 6e73d47b54 Tests: a torch toggled eight times in sixty ticks burns out and relights after 160
`RedstoneTorchBlock`: turning off adds a toggle; eight inside the last 60 ticks burn the torch out
and `isToggledTooFrequently` keeps it dark until the toggles age, with a restart tick scheduled 160
ticks out. Seven quick cycles leave it working, the eighth burns it out, and eight spread over 96
ticks never do.

The lever is clicked, not rewritten: `LeverBlock.pull` is what updates the neighbours of the block it
is attached to, so a plain setBlock of a powered lever never reaches the torch above it. (A first
draft rewrote it, saw the torch never toggle, and was wrong about why.)

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 19:54:45 +00:00
Evan-lhm 4802e85471 Merge branch 'split/gfx-section-fade' into rt/acceleration-structures 2026-11-09 20:53:11 +01:00
Evan-lhm 218816fbf6 Merge remote-tracking branch 'origin/main' into rt/acceleration-structures 2026-11-09 20:49:40 +01:00
itarqos5andClaude Sonnet 5.5 a5b8c780bf Tests: a target block holds its output 20 ticks for an arrow and 8 otherwise
`TargetBlock.updateRedstoneOutput` schedules the reset 20 ticks out for an arrow and 8 for anything
else, and a hit while the output is held changes nothing (`hasScheduledTick`), the new strength
included.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 19:45:00 +00:00
itarqos5andClaude Sonnet 5.5 1159d8f197 Tests: a target block gives vanilla's strength for where a projectile hit
`TargetBlock.getRedstoneStrength` is `max(1, ceil(15 * clamp((0.5 - distance) / 0.5, 0, 1)))` from
the hit's distance to the face's centre. The expected values are vanilla's formula run in Java for
51 hits on three faces, double rounding included: the same offset gives 12 on one face and 13 on
another, because frac(6.6) and frac(5.6) are not the same double.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 19:45:00 +00:00
itarqos5andClaude Sonnet 5.5 0817fddb83 Tests: a copper bulb flips its light only when it becomes powered
`CopperBulbBlock.checkAndFlip` flips LIT only on a rising edge of power and lets POWERED follow the
signal either way, so the light alternates on every power-up and ignores the power-downs. Five
variants (plain, exposed, weathered, oxidized, waxed), and one placed beside power, where onPlace
runs the same check.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 19:45:00 +00:00
Evrim CicekandClaude Opus 5.5 c1bbcf1535 Move the firework rocket NBT out of entity_nbt.rs
Part of #14043: a pure move to entity_nbt/rocket.rs, so the rocket's
fall_distance (#14318) lands in the moved file.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 22:38:02 +03:00
Horizon agentandClaude Opus 5.5 398bec3d6e Move the cosmetics settings out of the engine's Extras (spec #14091 step 5a)
The six cosmetics settings (my cosmetics, in single player, other players'
cosmetics, hide others' trails and auras, Horizon logos, show me as Horizon)
leave horizon-client-render's Extras for game/cosmetics/settings.rs:

- their horizon-extras.txt lines go through a settings Store (same keys, so
  players and the launcher's Wardrobe keep their choice; others' line still
  written only once chosen), always registered so a game without cosmetics
  keeps the Wardrobe's lines;
- their rows are a Place::Extras page section, shown while cosmetics are
  offered, in the same Cosmetics group at the end of the Extras page;
- the values live in the per-seat Seat, and the owner flag is read there
  instead of being copied into Extras each frame.

horizon-client-render no longer depends on horizon-cosmetics: the allowed
entry "horizon-client-render -> horizon-cosmetics" is removed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:37:10 +01:00
itarqos5andClaude Sonnet 5.5 3c11bce138 Tests: a comparator compares and subtracts by vanilla's rules
`ComparatorBlock.calculateOutputSignal`: no input is no output; a side input above the input
silences it; otherwise COMPARE passes the input through and SUBTRACT outputs `input - side`.
`shouldTurnOn` agrees except that equal inputs turn COMPARE on and SUBTRACT off. Nine cases, read
from the wire in front of the comparator. Signal strengths come from lever-and-wire lines (a wire
gives up one level a block).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 19:36:28 +00:00
itarqos5andClaude Sonnet 5.5 8057b16916 Tests: buttons stay pressed for their own ticks and a second click does not extend it
`ButtonBlock.press` powers the button and schedules a tick `ticksToStayPressed` out. Stone buttons
stay pressed 20 ticks and every wooden one 30 (oak, bamboo and cherry pinned). A button that is
already pressed answers a second click with nothing: the timer is not extended.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 19:36:25 +00: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
Dylan FerreyraandClaude Opus 5.5 3750bd11b1 A splitscreen player's first view no longer disconnects it
A memory connection's outbox closed past MAX_PENDING (96 MiB), as a
socket's does. A memory connection gets every pending chunk at once
(PlayerChunkSender.collectChunksToSend), so a player joining where the
terrain is already loaded can queue more than that in one burst, and
the server closed its connection with no reason: "<name> could not
stay / End of stream".

The limit is for clients that read slowly over the network; those keep
it.

Fixes #12849.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:28:39 -03:00
Horizon agent 07c4f9364b Merge origin/main into fix/11316-custom-durability 2026-11-09 20:28:10 +01:00
Evan-lhmandClaude Opus 5.5 fb6b997e15 Resident::iter in every build: ray_scene walks the sections when the camera changes column
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:17:19 +01:00
Evan-lhmandClaude Opus 5.5 73b311e9d1 Ray tracing: the terrain's acceleration structures, near the camera only (#13780 step 2)
Continues #1180. With ray queries on (`HORIZON_RAY_TRACING=1` in a horizon-extras
build), `ray_scene` keeps one BLAS per section's solid quads within 8 chunks of
the camera (`HORIZON_RT_CHUNKS`), compacted once built, and a TLAS of them, built
again only when a section's mesh changes or the camera enters another chunk
column (instances sit relative to that column). Nothing traces them yet.

The arena keeps its compact layout: a section about to get a BLAS has its solid
quads written out as vertices by a compute pass (`rt_unpack.wgsl`, appended to
terrain.wgsl to use its `section_corner`) into a reused buffer.

The off path is main's: `without_ray_queries_there_is_no_scene` opens a device
without ray queries and checks there is no scene and no extra buffer usage. The
renderer's side is three one-line calls in gfx.rs (`ray_mark`, `ray_clear`,
`ray_prepare`), the methods living in ray_scene.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:12:34 +01:00
Riccardo GuerrieroandClaude Opus 5.5 72f206a80c fix: endermen keep carried blocks out of entities, stare once per 400 ticks and reach blocks through outlines
- EndermanLeaveBlockGoal.canPlaceBlock: level.getEntities(this.enderman, cube)
  must be empty, so the player (not a spectator), dropped items, armor
  stands, boats, projectiles, frames and every other entity block the cube,
  and the enderman's own box no longer does.
- Enderman.playStareSound: once per 400 ticks (lastStareSound), stamped
  but silent for a Silent enderman, at getEyeY (2.55 up). It plays when
  DATA_CREEPY changes while DATA_STARED_AT holds (onSyncedDataUpdated, value
  by value in id order), so the end of a stare's anger plays it too.
- Enderman.setTarget stamps targetChangeTime for every target and zeroes it
  on none, which customServerAiStep's daylight teleport reads.
- EndermanTakeBlockGoal.tick clips with ClipContext.Block.OUTLINE, the
  shapes' random offset included: a flower or torch now hides a block.

The block random offset (BlockBehaviour offsetType) moves to
StateFacts::offset_at, shared by the mesher and the mob clip.

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

Claude-Session: https://claude.ai/code/session_01WZzq8MxShENtiMj2DMbm5G
2026-11-09 20:09:10 +01:00
Ludovic Marie 0694db7dcb Merge pull request #14152 from horizon-rust/split/telemetry-scrub
🤖 Agent: scrub.rs moved unchanged to horizon-system; horizon-telemetry re-exports it, so the launcher and the ingest server keep their paths.
2026-11-09 20:00:36 +01:00
Horizon agent f3fdefa0ec Merge remote-tracking branch 'origin/main' into rpn4-14179
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
2026-11-09 19:56:12 +01:00
Horizon agentandClaude Opus 5.5 b21058f361 deflect tests: the turned wind charge is past its no-deflect ticks
The two redirect tests built a fresh wind charge, which this PR now (as
WindCharge.deflect) refuses for its first five ticks; their fixture clears
no_deflect_ticks, and a new test checks a fresh one keeps its course and owner.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:51:31 +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
Ludovic Marie 1e4f136720 Merge pull request #14078 from horizon-rust/layers/extras-pvp-reads
🤖 Agent: every read of settings.extras.pvp outside the PvP module now goes through one accessor in game/extras/pvp.rs (pvp_legacy_look, pvp_mutes_sound, pvp_hides_particle and so on), same values at the same places, so nothing new runs per frame.
2026-11-09 19:40:06 +01:00
Ludovic Marie a8aca2ba12 Merge pull request #13941 from horizon-rust/layers/extras-pvp
🤖 Agent: pure move of game/pvp.rs to game/extras/pvp.rs (git sees a rename), stacked on #13930; only super:: paths and visibility change, docs/pvp.md follows the path.
2026-11-09 19:39:45 +01:00
AppaandCodex GPT-6 125308948a Merge branch 'main' of https://github.com/horizon-rust/horizon into fix/13174-wayland-file-drop
Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-09 10:30:25 -08:00
Horizon agent 9ec09e4714 Merge remote-tracking branch 'origin/layers/moderation-durations' into layers/chat-guard-hook
# Conflicts:
#	crates/horizon-client/src/game/chat_filter.rs
2026-11-09 19:26:44 +01:00
Horizon agent 58f80819d9 Merge remote-tracking branch 'rv3n/14131' into rv3-comp 2026-11-09 19:21:19 +01:00
MSedgeMC f606efbf36 Merge remote-tracking branch 'upstream/main' into fix/component-can_eat 2026-11-09 18:03:15 +00:00
Evrim Cicek 5054ffc2ab Merge branch 'refactor/minecarts-split' into fix/hanging-item-stack-config 2026-11-09 21:03:03 +03:00
Nikita Maximilian Grimm 57e9e2b751 Merge origin/main into typed-randoms-1a
# Conflicts:
#	crates/horizon-client/src/game/persist.rs
2026-11-09 19:02:51 +01:00
MSedgeMC 96a7044c15 Merge remote-tracking branch 'upstream/main' into fix/block-entity-pos-from-tag 2026-11-09 18:00:54 +00:00
MSedgeMC e8397cd8eb Merge remote-tracking branch 'upstream/main' into fix/component-can_eat 2026-11-09 17:55:10 +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 dd2d560901 Merge branch 'refactor/minecarts-split' into fix/tnt-minecart-explosion-power 2026-11-09 20:51:09 +03:00
voduffandClaude Opus 5.5 7837d91790 Projectiles hit item frames: the item comes out, then the frame breaks (#11383)
Frames are pickable (BlockAttachedEntity.isPickable), so a projectile
meets them and ItemFrame.hurtServer runs: a filled frame gives its item
up on any non-explosion hit, an empty one breaks, a fixed one refuses
unless the shooter is creative (and a refused arrow is turned back).
Horizon left frames out of every projectile's targets.

projectile_targets/vehicle_targets move to creatures/projectile_targets.rs
and list frames; attack_frame's body becomes hit_frame, shared with the
new projectile_hurts_frame, which arrows, tridents and hurt_vehicle's
callers reach. New harness test_frame_projectile_parity.py (27/27;
main: 22/27).

Part of #11383

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:49:21 +00:00
DUzzL 53b7a6768d Record the predicted drop as sent so a refilling pickup syncs to a guest 2026-11-09 18:47:18 +01:00
Riccardo GuerrieroandClaude Opus 5.5 9d025d7e3c Start the void 64 blocks below each level's own floor
Entity.checkBelowWorld acts below Level.getMinY() - 64. Horizon used the
chunks' column bottom, which is widened to the Overworld's -64 in every
level, so in the Nether and the End (min Y 0) players and mobs only took
void damage, and arrows, thrown potions and other projectiles were only
removed, below -128 instead of -64; a guest's death there was not named
"fell out of the world". World::below_world_y gives the level's own floor
minus 64, and an entity exactly on it stays, as vanilla's strict < keeps it.

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

Claude-Session: https://claude.ai/code/session_01WZzq8MxShENtiMj2DMbm5G
2026-11-09 18:47:16 +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
Horizon agentandClaude Opus 5.5 a03902b64d Use the stack's whole consumable component, finish the merged series
The eight component branches (#14131-#14138) each edited the same spots and
three of them left an unclosed block in finish_consuming. Merged here:

- item_sync::stack_consumable builds the component a use reads once: the
  stack's own when it has one, with Consumable.CODEC's defaults for the
  fields it leaves out (1.6 s, eat, entity.generic.eat, particles), else the
  item's. finish_consuming, the per-tick particles and sounds and the
  instant-consume check use it.
- Consumable.canConsume's food check asks the stack's food component.
- A direct sound reads SoundEvent.DIRECT_CODEC's `sound_id`, and every
  ItemUseAnimation name maps (trident, crossbow and spyglass were eat).
- The glider test's doc comment is back on its test.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:42:09 +01:00
Horizon agent 30637c0820 Merge remote-tracking branch 'rv3/14136' into comp
# Conflicts:
#	crates/horizon-client/src/game/consume.rs
#	crates/horizon-client/src/item_sync.rs
2026-11-09 18:37:24 +01:00
Horizon agent b63d7491d2 Merge remote-tracking branch 'rv3/14135' into comp
# Conflicts:
#	crates/horizon-client/src/game/consume.rs
#	crates/horizon-client/src/item_sync.rs
2026-11-09 18:37:21 +01:00
Horizon agent e64e477126 Merge remote-tracking branch 'rv3/14133' into comp
# Conflicts:
#	crates/horizon-client/src/item_sync.rs
2026-11-09 18:37:14 +01:00
Horizon agent dc94adc898 Merge remote-tracking branch 'rv3/14134' into comp
# Conflicts:
#	crates/horizon-client/src/item_sync.rs
2026-11-09 18:37:05 +01:00
Horizon agent f914bcd4a9 Merge remote-tracking branch 'rv3/14138' into comp
# Conflicts:
#	crates/horizon-client/src/item_sync.rs
2026-11-09 18:36:53 +01:00
Horizon agent 0a6d0dd257 Merge remote-tracking branch 'rv3/14137' into comp
# Conflicts:
#	crates/horizon-client/src/item_sync.rs
2026-11-09 18:36:51 +01:00
Horizon agent c719e4ac0e Merge remote-tracking branch 'rv3/14132' into comp
# Conflicts:
#	crates/horizon-client/src/item_sync.rs
2026-11-09 18:36:50 +01:00
Nikita Maximilian Grimm 7049d0e811 Merge remote-tracking branch 'origin/main' into hdr-one-peak
# Conflicts:
#	docs/graphics.md
2026-11-09 18:34:01 +01:00
rdbtCVSandClaude Sonnet 5.5 78e094e614 Merge main into fix/tnt-minecart-explosion-power
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 17:29:33 +00:00
rdbtCVSandClaude Sonnet 5.5 1a4e885e2c Merge main into layers/chat-guard-hook
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 17:26:02 +00:00
rdbtCVSandClaude Sonnet 5.5 f81f6de46b cargo fmt
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 17:23:28 +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
MSedgeMC ff42a99d98 port_map: Dispensable comes from the stack 2026-11-09 17:20:59 +00:00
MSedgeMC aced4bb6b9 test: Dispensable comes from the stack 2026-11-09 17:20:59 +00:00
MSedgeMC 97a323b499 fix: Dispensable comes from the stack 2026-11-09 17:20:59 +00:00
MSedgeMC 227333eacd port_map: Use remainder comes from the stack 2026-11-09 17:20:53 +00:00
MSedgeMC 9367ed7710 test: Use remainder comes from the stack 2026-11-09 17:20:53 +00:00
MSedgeMC e15589bbbb fix: Use remainder comes from the stack 2026-11-09 17:20:53 +00:00
MSedgeMC 84fe5b4f12 port_map: Consumable particles come from the stack 2026-11-09 17:20:47 +00:00
MSedgeMC 7ee566f6b7 test: Consumable particles come from the stack 2026-11-09 17:20:47 +00:00
MSedgeMC 3774ca6edd fix: Consumable particles come from the stack 2026-11-09 17:20:47 +00:00
MSedgeMC c73321876c port_map: Consumable sound comes from the stack 2026-11-09 17:20:41 +00:00
MSedgeMC 19d083fa26 test: Consumable sound comes from the stack 2026-11-09 17:20:41 +00:00
MSedgeMC 8d35e7fc0a fix: Consumable sound comes from the stack 2026-11-09 17:20:41 +00:00
MSedgeMC 46f0546de6 port_map: Consumable animation comes from the stack 2026-11-09 17:20:13 +00:00
MSedgeMC 7c173c30b1 test: Consumable animation comes from the stack 2026-11-09 17:20:13 +00:00
MSedgeMC 715a969071 fix: Consumable animation comes from the stack 2026-11-09 17:20:13 +00:00
MSedgeMC 5097c53509 port_map: Consumable seconds come from the stack 2026-11-09 17:20:08 +00:00
MSedgeMC a89477a4b9 test: Consumable seconds come from the stack 2026-11-09 17:20:08 +00:00
MSedgeMC 430b6dc4c5 fix: Consumable seconds come from the stack 2026-11-09 17:20:07 +00:00
MSedgeMC e591b36a8d port_map: Food can_always_eat comes from the stack 2026-11-09 17:20:01 +00:00
MSedgeMC 701eaa6f9c test: Food can_always_eat comes from the stack 2026-11-09 17:20:01 +00:00
MSedgeMC 018a51c27f fix: Food can_always_eat comes from the stack 2026-11-09 17:20:01 +00:00
MSedgeMC 4db980e5de port_map: Food nutrition and saturation come from the stack 2026-11-09 17:19:53 +00:00
MSedgeMC d40ab7df1a test: Food nutrition and saturation come from the stack 2026-11-09 17:19:53 +00:00
MSedgeMC 54578041bc fix: Food nutrition and saturation come from the stack 2026-11-09 17:19:52 +00: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
945c667281 Discard dropped items below each dimension's own void floor
Entity.checkBelowWorld discards at getY() < Level.getMinY() - 64, so the Nether and the End (minY 0) lose an item at y = -64, not the Overworld's -128. Use the level's minimum y in ItemEntity.tick and the item tick, and keep an item at exactly the threshold, as vanilla's < does.

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 18:10:14 +01:00
Horizon agentandClaude Opus 5.5 bdec76846e Merge main: keep main's blocking_item_max_damage
main's 3b34446f84 added the same helper with the fuller ItemStack.isDamageableItem rule (an
explicit DAMAGE component on a non-damageable prototype); this branch keeps that one and its own
ItemDamageFunction.apply.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:08:04 +01:00
Horizon agentandClaude Opus 5.5 93ebb80b92 Carry blocking_item_used through the staged equipment hurt
main's health/equipment.rs (3cdf31772f) runs a hurt in stages and copies the block results
between them; it now copies blocking_item_used too, so a staged hit reports the shield use as
the one-shot hurt does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:58:51 +01:00
MSedgeMC 8d7267f7f7 port_map: JigsawBlockEditScreen lives in jigsaw_edit.rs 2026-11-09 16:51:30 +00:00
Nikita Maximilian Grimm bdc0092803 Merge origin/main into typed-randoms-1a 2026-11-09 17:50:32 +01:00
rdbtCVSandClaude Sonnet 5.5 c3834bb29d Merge main into fix/11316-custom-durability
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 16:48:09 +00:00
Evrim Cicek 345041401e Merge branch 'refactor/minecarts-split' into fix/tnt-minecart-explosion-power
# Conflicts:
#	crates/horizon-client/src/game/minecarts.rs
2026-11-09 19:36:49 +03:00
Horizon agent 9e82a7acd1 Merge remote-tracking branch 'origin/main' into fix-11585 2026-11-09 17:30:30 +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
MSedgeMC 0eb2196f99 The jigsaw's priority boxes explain themselves on hover
JigsawBlockEditScreen sets a tooltip on its selection and placement priority
boxes, explaining what each number does. Horizon's jigsaw editor drew both
boxes without one. The block editor now takes an optional tooltip per box and
draws it over the screen, and the jigsaw gives its two priority boxes theirs.
2026-11-09 16:21:58 +00:00
Nikita Maximilian Grimm 18c03d23d8 Merge remote-tracking branch 'fork/hdr-one-peak' into hdr-one-peak 2026-11-09 17:14:50 +01:00
Horizon agentandClaude Opus 5.5 2b579a4067 items: a durable prototype drops an explicit zero damage when saved
PatchedDataComponentMap keeps only values that differ from the prototype:
a diamond pickaxe already has DAMAGE 0, so the zero components_tag now keeps
for a stack-declared max_damage is dropped again by the prototype-aware save.
A stick, whose prototype has no DAMAGE, keeps it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:11:40 +01:00
Horizon agentandClaude Opus 5.5 4eee03985c The engine reads PvP & HUD's options through game/extras/pvp.rs (layer plan M5)
Every read of settings.extras.pvp outside the PvP module and its HUD
editor (glint, attack indicator, legacy look and rules, sound and
particle filters, shield and totem size, fire height, clear fluids,
chat search and copy) now calls one accessor in game/extras/pvp.rs.
Same values, read at the same places: each accessor becomes one engine
knob or hook when PvP & HUD leaves the engine.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:10:24 +01:00
Horizon agent dceee849cb Merge remote-tracking branch 'origin/main' into r-13578
# Conflicts:
#	crates/horizon-client-render/src/gfx/native_post.rs
2026-11-09 17:08:54 +01:00
Horizon agentandClaude Opus 5.5 10b318cd6b Merge main into split-renderer
main moved more of game.rs out (#13877, #13878, #13880...): the conflict is only the
neighbours of the moved functions; game.rs takes main and drops the four functions
this branch moves to game/render_settings.rs, unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:08:14 +00:00
Nikita Maximilian GrimmandClaude Opus 5.5 79cfecbdb9 HDR float frame: main's encoding-switch test tunes a tier's gain, as one peak has no share
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:05:29 +01:00
Horizon agentandClaude Opus 5.5 1414d6350f Chat filter: the hub asks an engine hook, horizon-client-lib no longer names horizon-chat-filter
Layer plan M6. horizon-server-files gets chat_guard (the ChatGuard trait, Place, Action, Mute, and an
install/load registry). horizon-chat-filter implements the trait and its loader; entry installs it
at startup. Behaviour unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:03:42 +01:00
MrCloudy2andClaude Opus 5.5 298d3f12e9 A player's wind charge can't be turned in its first five ticks
WindCharge.deflect refuses while noDeflectTicks (5, one less after each tick,
not saved) lasts: a projectile hitting a young wind charge doesn't redirect it
(Projectile.onRedirectProjectile), and a player's blow goes on as an attack
that hurts nothing. A breeze's wind charge has no such wait.
Harness: tools/test_wind_charge_no_deflect_ticks_parity.py.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:02:39 +01:00
Horizon-agentandClaude Opus 5.5 fe35609011 Tests from main build their FrameRequest with linear_blend
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:01:52 +00:00
Horizon agentandClaude Opus 5.5 73a745339e Carry an arrow's piercing in a combat flag so creatures.rs doesn't grow
creatures.rs is over 3000 lines (docs/layers.md#file-size). The arrow's hit on the player goes
through shield.rs's arrow_hits_player, which sets combat.piercing_hit; hurt_player_from takes it
as it takes rocket_hit and hands it to player_blocking. Same behaviour as hurt_player_with_pierce.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:00:58 +01:00
Horizon agent 70287510ab Merge remote-tracking branch 'origin/main' into fix-11507 2026-11-09 16:59:46 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 ef090d751b Merge origin/main into typed-randoms-1a
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:56:42 +01:00
Horizon agentandClaude Opus 5.5 26afbb999c Merge #13745's formatting into #13747
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:53:52 +01:00
Horizon agentandClaude Opus 5.5 d906242b0e Format the pattern tag walk
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:53:45 +01:00
Horizon-agentandClaude Opus 5.5 43851ba7e7 Frame policy moves out of gfx.rs into gfx/frame_policy.rs
frame_format, frame_configuration, overlay_format and horizon_tone_curve
move unchanged beside their tests (frame_policy_tests.rs), so gfx.rs does
not grow against main with linear blending in.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:50:31 +00: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-agent 0317c86476 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-09 15:49:41 +00:00
Horizon agentandClaude Opus 5.5 dbf99fe456 Merge #13748 into #13749
Both add an equippable reader beside `equippable` in item_sync.rs: with
#13748 merged in, either lands first without a conflict. The new test's
doc comment now describes it, and the glider test keeps its own.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:40:24 +01:00
Horizon agentandClaude Opus 5.5 7842fca4da Keep the glider test's doc on it; cover a removed equippable
The new test went between the glider test's doc comment and its fn.
Also check that a removed component plays no equip sound, as the
description says.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:39:57 +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 ef01959fb1 Merge #13745 (with #13744) into #13747
The three loom PRs change banner.rs side by side: with the others merged
in, any of them can land first without a conflict. All their tests kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:39:39 +01:00
Horizon agentandClaude Opus 5.5 ec2cd929ab Merge #13744 into #13745; read nested pattern tags
Both PRs change the loom's component reads in banner.rs: merging #13744
here lets either land first without a conflict (both tests kept).

provides_banner_patterns names `#minecraft:pattern_item/<name>`, a tag in
a subfolder, and an id may omit its namespace: load every tag under
tags/banner_pattern and namespace bare ids (Identifier.parse), with
test cases for both.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:39:06 +01:00
Horizon agentandClaude Opus 5.5 6f79c55c2a Keep game.rs at its size: leave the drop check in place there
game.rs is over the 3000-line limit (docs/layers.md#file-size); the two forms are the same check.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:36:42 +01:00
Horizon agent 529cd743a0 Merge remote-tracking branch 'origin/main' into fix-11548
# Conflicts:
#	crates/horizon-client/src/game/hand.rs
2026-11-09 16:35:26 +01:00
Horizon agentandClaude Opus 5.5 3877ae52d8 Merge origin/main into the branch
The verify job compared big files against this branch's old base, so main's own growth of options_ui.rs since then failed the size check.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:35:03 +01:00
Nikita Maximilian Grimm 8e46f3971d Merge remote-tracking branch 'origin/main' into hdr-one-peak
# Conflicts:
#	crates/horizon-client-render/src/gfx/native_post.rs
2026-11-09 16:30:19 +01:00
Horizon agentandClaude Opus 5.5 e364221b4b Resource-pack drops: no winit fork
The game workspace pinned winit to an unreleased backport in a personal
fork (crmne/winit) for native Wayland file drops. Every player build would
then depend on a repository outside winit's own, which the project doesn't
take. The drop import itself works wherever winit already delivers
DroppedFile (Windows, macOS, X11, XWayland); native Wayland follows once a
winit 0.30 release carries rust-windowing/winit#4729.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:24:59 +01:00
Horizon agent b23b5a810f Merge main into resource-pack drops 2026-11-09 16:24:58 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 89faf5b548 Merge origin/main into typed-randoms-1a
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:24:23 +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 agent a1d48cf2c0 Merge remote-tracking branch 'origin/main' into fix-11585 2026-11-09 16:21:54 +01:00
Horizon agentandClaude Opus 5.5 ebe1a5d52b Wardens: silent ones make no sound, forced riders get off to dig; withers' idle volleys wait for Normal
- A Silent:1b warden played its emerge, dig, roar, sniff, tendril, attack and sonic boom sounds:
  both warden sound helpers now check `silent`, as Entity.playSound does (#8835).
- ForceUnmount was stubbed out: a warden put on a vehicle with /ride or a summon's Passengers
  never got off, so it could never dig. It now runs vanilla's unRide (passengers ejected, the warden
  set down where its vehicle lets it off) and stops in the tick it starts (#8833).
- WitherBoss.customServerAiStep counts the side heads' idle updates only on Normal and Hard: on Easy
  a wither fired its random blue skulls all the same (#8840).

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:14:41 +01:00
Horizon agent 956eb692ea Merge remote-tracking branch 'origin/main' into fix-11585 2026-11-09 16:14:20 +01:00
Horizon agent 61a0362859 Merge remote-tracking branch 'origin/main' into fix-11507 2026-11-09 16:14:13 +01:00
iseaprojectsandopencode 0f6227abba Honor custom MAX_DAMAGE on normally non-durable items
`ItemStack.isDamageableItem` asks for the MAX_DAMAGE, DAMAGE and
absence of UNBREAKABLE components, not for the item prototype's own
durability. A stick given `minecraft:max_damage` and `minecraft:damage`
is therefore damageable and wears while mining, but Horizon only looked
at the prototype `max_damage` in `mining_damage_capacity` and in the
attack wear path, so such stacks never took durability damage and an
explicit `damage: 0` was dropped on save.

- `durability::stack_max_damage` mirrors `getMaxDamage` for any stack:
  MAX_DAMAGE present, DAMAGE present (prototype, stack value, or an
  explicit zero), no UNBREAKABLE.
- `mining_damage_capacity` and `damage_held_in_slot` both use it.
- `components_tag` keeps an explicit `damage: 0` when the stack carries
  its own `max_damage`; durable prototypes still drop the zero, matching
  vanilla's `fromPatch` sanitizing.

The capacity logic lives in its own module so `items.rs` (already past
the 3000-line limit) does not grow; the thin method there delegates.

Completes the deferred part of #11316: custom durability on non-durable
item types. Covered by unit tests, the `mining_component_tests` game
regression, and 74 stack-mining parity cases against vanilla 26.3.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-09 11:12:01 -04:00
MSedgeMC 166073d4bc Re-run checks: the previous run failed in an unrelated fixture / harness 2026-11-09 14:52:23 +00:00
Horizon agentandClaude Opus 5.5 92742bef1b horizon-system: the scrubber leaves horizon-telemetry (layer plan M5)
scrub.rs moves unchanged from horizon-telemetry to horizon-system (engine);
horizon-telemetry re-exports it at horizon_telemetry::scrub for the launcher.
The repro export (agent control) reads it there, so gameplay no longer uses
horizon-telemetry for it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:36:00 +01:00
Horizon agentandClaude Opus 5.5 bb29b70b32 Cargo.lock: horizon-system for horizon-client and horizon-optifine
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:23:16 +01:00
Horizon agentandClaude Opus 5.5 5a589fb0f0 The PvP & HUD options' game glue moves to game/extras/pvp.rs (layer plan M5)
A pure move under game/extras/, where each Horizon extra's game glue
gathers: paths and visibility only (pub(in crate::game)). game.rs
loses its mod line.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:21:29 +01:00
Horizon agentandClaude Opus 5.5 a9ce60dde5 Auto-reconnect's game glue moves to game/extras/reconnect.rs (layer plan M5)
A pure move: the connection's start, join and end, the countdown and the
retry leave game/extras.rs for a child module of their own, so the extra
leaves the engine in one move once the seam lands.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:16:18 +01:00
Horizon agentandClaude Opus 5.5 3ad8a18a7b horizon-system: the process probes leave horizon-bench (layer plan M5)
rss_bytes, cpu_seconds and load_average move to horizon-system (engine);
horizon-bench re-exports them for the launcher. horizon-system's cpu() and
ram_bytes() become public and replace horizon-bench's identical copies.
The dedicated server's performance report and cem_expr_bench read them
there, so gameplay no longer uses horizon-bench for them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:14:20 +01:00
MSedgeMC e632dda2b7 cargo fmt 2026-11-09 14:07:35 +00:00
MSedgeMC d22f313e9e Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-09 14:00:02 +00:00
Evrim Cicek 16237bb2b7 Merge remote-tracking branch 'upstream/main' into fix/tnt-minecart-explosion-power
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-09 16:47:50 +03: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
Skr1zzzzandClaude Opus 5.5 71e2401edc Move building and adopting the renderer out of game.rs into game/render_settings.rs
Part of #11857. new_renderer, adopt_renderer, forget_atlas_slots and all_sections_changed join game/render_settings.rs. Methods that were private in game.rs become
pub(super), the same reach.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:29:00 +03:00
Nikita Maximilian GrimmandClaude Opus 5.5 7c94c21638 The level's random and a mob's own random have types of their own
EntityRandom (Entity.random) and LevelRandom (Level.random) wrap a
LegacyRandom in horizon_core::random, each with one named constructor
(EntityRandom::own, LevelRandom::for_level) and its own positional type.
World.random is a LevelRandom and Mob.random an EntityRandom, with
entity_random, living_random, Mob::with_random and
mob_from_nbt_with_random. Both deref to LegacyRandom while the rest
migrates (#13813). No draw changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 14:28:22 +01:00
MSedgeMC e957a4bd85 Keep mobs.rs and goals.rs under the file-size limit
Mob::seen moves to mobs/seen.rs and the FollowMob test to mobs/invisible_follow_tests.rs, so neither file over
3000 lines grows: 6858 -> 6777 and 3740 -> 3738.
2026-11-09 13:05:29 +00:00
RINNECODERandClaude Opus 5.5 6c5f62af35 Spectator camera: a server's camera on an armor stand holds the view
ClientPacketListener.handleSetCamera takes any entity the level holds, but
remote_set_camera only looked among the mobs, so a camera on an armor stand
(the usual target of a server's cutscene or menu scene) was dropped: the
view stayed on the player, who kept moving and turning. The stand now holds
the camera like a mob does: the view looks out of its eyes along its yaw,
the player sends no moves, the crosshair skips the stand and first person
does not draw it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 05:25:24 -06:00
Tran Anh Van d029364bd4 Use Level.randValue for thunder strike columns and trap horse RNG for scatter
ServerLevel.tickThunder picks its strike column via Level.getBlockRandomPos(minX, 0, minZ, 15), which advances Level.randValue (Ticks::block_random_pos) and extracts bits 2..=5 for X and 10..=13 for Z instead of drawing from Level.random.

SkeletonTrapGoal.tick draws each additional trap horse's horizontal push (two triangle(0.0, 1.1485) draws) from the original trap horse's own random after creating and mounting its skeleton rider, rather than drawing from Level.random before rider creation.
2026-11-09 18:19:33 +07:00
RINNECODERandClaude Opus 5.5 89f90f0b65 OptiFine CTM: a tile draws in the pass of the face it replaces
CTM picks a texture, not a render layer: OptiFine draws a tile in the
block's layer. Horizon took the pass from the tile's own sprite, so one
of Conquest Reforged's six random grass blade tiles (7 partly clear
texels of 1024) put its overhang plane in the translucent pass. That
pass has no alpha test, so the plane's clear rectangle wrote depth
(depthtex0 had it, depthtex1 not), and Bliss fogged it: faint
rectangles against the sky. A tile now takes the face's pass; its own
texels still decide whether it skips the alpha test.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 04:50:07 -06:00
RINNECODERandClaude Opus 5.5 f965ae85f3 OptiFine CTM: a picked tile chains to the rules naming it, not to its layer's
Identical still sprites share one layer. Chaining looked the picked tile's
rules up by that layer, so a tile chained to every rule naming a tile with
the same pixels. Conquest's grass top repeats 16 blank placeholders that
random rules beside them replace (matchTiles=./N.png); dozens of other
folders' placeholders are blank too, and their rules, sorting first, drew
their own tiles on the grass. A rule's tile now chains to the rules naming
that tile, and a tile file (not a model's sprite) no longer matches faces by
layer.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 04:50:07 -06:00
Riccardo GuerrieroandClaude Opus 5.5 983d9a3235 Keep mobs/ai.rs from growing: the hoglin check moves to piglin.rs
ai.rs is over the 3000-line limit (docs/layers.md#file-size), so the
Hoglin.canFallInLove check and its explanation move to
piglin::refuses_love; takes_food keeps one line for it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 11:08:18 +01:00
MSedgeMC 87c473666a Do not load items the selection test does not read 2026-11-09 09:03:32 +00:00
MSedgeMC 1c9f8a4359 Keep the tag type in the test 2026-11-09 09:03:30 +00:00
MSedgeMC d0850614ba Follow mob goals ignore invisible mobs 2026-11-09 08:50:42 +00:00
MSedgeMC f4d10e7099 Import the Tag type the loom component read uses 2026-11-09 08:48:45 +00:00
MSedgeMC 1b029a08f6 Import the Tag type the loom component read uses 2026-11-09 08:48:43 +00:00
MSedgeMC 8a5211f260 Import the Tag type the loom component read uses 2026-11-09 08:48:41 +00:00
MSedgeMC 0831968204 Armor damage_on_hurt comes from the stack's equippable component
LivingEntity.doHurtEquipment skips a piece whose Equippable.damageOnHurt is
false. Horizon read the prototype's flag for both the player's armor and a
mob's, so a stack patched to false still wore.

Test: a golden helmet patched with damage_on_hurt:0b does not wear, and a
removed equippable component does not wear.
2026-11-09 08:43:33 +00:00
MSedgeMC 1e8745416d Equip sounds come from the stack's equippable component
Player and mount equipping read the item prototype's equip_sound. A stack's
own minecraft:equippable replaces the component, so its equip_sound (or the
generic default when omitted) must win.

Test: a golden helmet with a slot-only equippable component uses the generic
sound, not the gold one; a patch naming a sound uses that sound.
2026-11-09 08:43:26 +00:00
MSedgeMC 9fc9722501 A loom reads a dye stack's dye component
LoomMenu.isDyeItem needs the LOOM_DYES tag and a present DYE component, and
the result uses that component's colour. Horizon read the prototype's dye,
so a patched or removed dye kept its item default.

Test: white dye patched red colours red; white dye with !minecraft:dye is not
a loom dye.
2026-11-09 08:43:19 +00:00
MSedgeMC adfd8e43c8 A loom offers the patterns a stack's component names
LoomMenu.getSelectablePatterns reads the stack's PROVIDES_BANNER_PATTERNS
HolderSet. Horizon read the item prototype's list, so a patched pattern item
offered the wrong patterns.

Test: a globe banner pattern patched to creeper offers creeper, and a removed
component offers none.
2026-11-09 08:43:11 +00:00
MSedgeMC f7267efa1b A loom takes a pattern item whose provides component was removed
LoomMenu.isPatternItem needs the LOOM_PATTERNS item tag and a present
PROVIDES_BANNER_PATTERNS component. Horizon accepted any tagged item whose
prototype provided patterns, even after !minecraft:provides_banner_patterns.

Test: a globe banner pattern with the component removed is not a pattern
item.
2026-11-09 08:43:04 +00:00
AppaandCodex aac293db9b Keep the boat identity across loot advancement rewards
Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-09 00:25:43 -08:00
AppaandCodex a328e9c5db Keep chest boat loot handling in the boat module
Respect the repository size limit on commands and LAN action modules.

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-09 00:24:50 -08:00
AppaandCodex 4272268cbc Guard chest boat loot fixtures with a fresh hopper cooldown
Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-09 00:21:18 -08:00
DUzzL d288db6b6e Award the crafted statistic for crafting-table and inventory-grid takes 2026-11-09 09:18:02 +01:00
AppaandCodex 3ef0ebf52f Fix chest boat loot unpacking for hopper access and destruction
Roll pending loot before automation accesses chest-boat slots or destroying
removals spill them. Keep occupied slots, synchronize open-menu copies, and
award container loot only for the player opening a pending table.

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-09 00:17:30 -08:00
Nikita Maximilian GrimmandClaude Opus 5.5 e8327d7baa Merge origin/main into shader-api-linear-blend
Main's output owner (#13233) decides each renderer's frame in gfx/output.rs.
Whether the frame holds linear light is now part of that decision:
`FrameRequest::linear_blend` carries the picked 0.2 pack's `[frame] blend`, and
`resolve_frame` sets `FrameConfiguration::linear_light` where the frame is the
float one. The renderer reads it from its output instead of a field of its own,
so a pack that changes its blend asks for another frame and the game makes the
renderer again through main's frame check, without a separate outcome key.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 08:35:46 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 51ff5618de Shader API 0.2: fade-in and custom skies on a frame of linear light
Under a 0.2 pack with `[frame] blend = "linear"`, two draws still mixed or wrote
vanilla's gamma values into the linear frame:

- A section fading in mixed the pack's material result, linear light, toward
  the gamma fog colour, so new chunks faded in too bright and unlike "gamma".
  The fade now mixes in gamma, as the fog does.
- A resource pack's OptiFine custom sky layers, drawn when the pack has no sky
  stage, wrote gamma, so that sky looked washed out. They now store their
  colour decoded. Multiply and overlay layers convert their factor so the
  product stays what it is under "gamma". Add and subtract add light, as
  "linear" means to; dodge, burn and screen come out close to their gamma
  results.

Under "gamma", and without a 0.2 pack, nothing changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 07:51:46 +01:00
MSedgeMC 35fc116bd3 fix: drop the goat's old call and pass the memory value to number 2026-11-09 06:41:45 +00:00
21f90219c6 worldgen: load saved mineshafts, fortresses and strongholds
Read these structure pieces from saved starts instead of regenerating their
layouts and losing placed chest or spawner state. Follow StructureStart.loadStaticStart,
PiecesContainer.load and the MineshaftPieces, NetherFortressPieces and
StrongholdPieces tag constructors in vanilla 26.3.

Keep whole-start regeneration when a required piece cannot decode. Preserve
stronghold Source flags and vanilla numeric and optional entrance defaults.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Codex (gpt-6.1-sol) <[email protected]>
Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
2026-11-09 07:23:50 +01:00
ede2ada183 Make a pacified hoglin refuse breeding food
A hoglin within reach of a #hoglin_repellents block keeps its PACIFIED
memory for 200 ticks, and Hoglin.canFallInLove is false while it is set.
Animal.mobInteract then refuses the crimson fungus: nothing is eaten, no
love mode, no baby. Its baby branch asks canAgeUp instead, so a pacified
baby still grows up.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
2026-11-09 07:23:48 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 cc100b4470 Merge origin/main into hdr-one-peak
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 07:20:47 +01:00
MSedgeMC 2a95475e8f refactor: keep the goat's save code in goat.rs
`entity_nbt.rs` is over the 3000-line limit and may not grow (docs/layers.md
#file-size), so the goat's save pair moves into `mobs/goat.rs`: the screaming
and horn fields `Goat.addAdditionalSaveData` writes plus the long jump
cooldown memory, and their read side. `entity_nbt` keeps a one-line call.
2026-11-09 05:52:47 +00:00
Nikita Maximilian GrimmandClaude Opus 5.5 732dfb1cb0 Render examples: pass the pack's frame blend to the renderer
The examples that make a renderer of their own were still passing a bool where
`Renderer::new` now takes the picked pack's `[frame] blend`; they pass `None`,
no pack, so they draw as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 06:50:10 +01:00
AppaandOpenAI Codex 2d6f393adb Require an empty scratch directory for pack-drop integration test
Guard before creating fixtures because Cargo runs unit tests from their package directory. Validate the integration test by invoking its built executable directly from an isolated game directory.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-08 21:38:50 -08:00
AppaandOpenAI Codex 713f1bd2b1 Implement confirmed resource-pack drops in startup and game options
Fixes #11698. Route resource-pack drops through the vanilla confirmation, copy ZIPs and folders without overwriting existing packs, and refresh the available list while preserving pending selections. Keep Create World's data-pack route and cover the UI, copy safety, and both event callbacks.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-08 21:29:12 -08:00
MSedgeMC 47894e831f port_map: a goat's long-jump cooldown is saved and read
`Goat.addAdditionalSaveData`'s row in `tools/port_map/game-012.tsv` names the
three own fields; it now also names the `GoatAi` `LONG_JUMP_COOLDOWN_TICKS`
brain memory that the save covers.
2026-11-09 05:18:42 +00:00
MSedgeMC 5e735bd030 test: a goat's long-jump cooldown through the chunk save
`mob_to_nbt`/`mob_from_nbt` are the pair `Game::mob_tree_nbt` and
`Game::load_mob` go through. A goat with 57 ticks of long-jump cooldown saves
`Brain.memories."minecraft:long_jump_cooling_down"` as `{value: 57}` and the
reloaded goat's `frog::jump_cooldown` is 57 again. A goat with no cooldown
writes no memory, even over a compound that still names one.

Without the write the save has no memory (left `None`, right `Some(Compound({"value": Int(57)}))`), and the
reloaded goat has no cooldown (left `None`, right `Some(57)`).
2026-11-09 05:18:42 +00:00
MSedgeMC da4591bc88 fix: a goat keeps its long-jump cooldown in its chunk save
`GoatAi` sets `LONG_JUMP_COOLDOWN_TICKS` when a goat long jumps, and
`CountDownCooldownTicks` counts it down; `Brain.Packed` saves every brain
memory with `LivingEntity.addAdditionalSaveData` and reads it back.

Horizon kept the cooldown in the goat's `extra.memories` (the shared
`frog::jump_cooldown` the long jump goal asks) and wrote no `Brain`, so a goat
that had just long jumped unloaded with its chunk and came back ready to jump
again. The save now writes `minecraft:long_jump_cooling_down` as the integer
memory it is and the load puts it back in the goat's memories.
2026-11-09 05:18:42 +00:00
Nikita Maximilian GrimmandClaude Opus 5.5 a6a7b85f9e Shader API 0.2: draw packs that ask for linear blending
A 0.2 pack with `[frame] blend = "linear"` was refused: the game showed the
"isn't served yet" message and drew without it. Now it draws. Its float frame
holds linear light, so its glass, water and smoke blend as light does, while
solid surfaces look as under "gamma".

The pack's material, sky and weather results are stored as they are. Horizon's
own draws into the frame (entities, particles, clouds, weather, the sky, stars,
sun and moon, the world border, outlines and the debug lines, world warp and
the visibility buffer) decode their colour at the end of their shaders. The
clear colour is decoded too. Block breaking keeps darkening as vanilla's, and
the material's fog still mixes in gamma. What a stage reads (scene_color,
opaque_color, the water mirror) is copied instead of decoded, so [post],
[tonemap] and everything after the tonemap (the hand, camera overlays, HUD,
screenshots, HDR output) see the same values under either blend.

Where the device can't draw a float frame, the pack draws on the 8-bit frame
in gamma instead of being refused. Resource packs' core sky, stars and line
shaders write gamma, so under "linear" Horizon's own draw in their place.

Changing a pack's blend now makes the frame again, like changing its API line.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 05:53:21 +01:00
voidsip 69cb922604 Keep special attack helpers in focused modules 2026-11-08 22:48:45 -06:00
voidsip ee9bae684c Merge commit '3690aa6119b9235be7e3b79680c156da8979479b' into fix/11337-special-attack-followup 2026-11-08 22:46:55 -06:00
Nikita Maximilian GrimmandClaude Opus 5.5 dbcbe4fe87 HDR one peak docs: the identity holds where Peak reaches 1000/203 Paper White
The design note and graphics.md said a display at 1000 nits or more shows
the scene as written. That holds at a paper white of 203; in general the
curve is the identity where Peak Brightness is at least 1000/203 times
Paper White, so say that.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 05:05:05 +01:00
AppaandOpenAI Codex da4e938c99 Fix native Wayland file-drop delivery with winit backport
Use the winit 0.30.13 backport from rust-windowing/winit#4729, pinned to its reviewed revision. Document the Apache-2.0 dependency and removal condition.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-08 20:04:15 -08:00
Nikita Maximilian GrimmandClaude Opus 5.5 8423eabc57 HDR float frame: pin one peak's identity at the settings' extremes
Paper White now runs from 10 to 480 nits and Peak up to 10 000 (#12881): the tone curve
test checks that the world shows exactly as written wherever the display's peak reaches
the scene's, at 480/10 000 and 10/200 nits too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 04:30:47 +01:00
Nikita Maximilian Grimm fd46aa03c5 Merge remote-tracking branch 'origin/main' into hdr-one-peak 2026-11-09 04:29:46 +01:00
Nikita Maximilian Grimm 87f2c1314e Merge branch 'hdr-step5-gates' into hdr-one-peak 2026-11-09 04:12:22 +01:00
voidsip 57a3d9bb51 Keep special attack fixtures isolated and deterministic 2026-11-08 21:04:10 -06:00
Nikita Maximilian GrimmandClaude Opus 5.5 4c2a2c42d2 HDR float frame: one peak, the world mastered for 1000 nits
On the float frame the world's light no longer depends on the display. Light sources
multiply their brightest pixels by fixed gains in multiples of paper white (1.6, 2.4,
2.8 and 4 from faint glowers to the sun), and HDR lighting's strongest light by 3. The
tone curve alone fits that scene to the display: light above the scene's peak (1000/203
of paper white, a 1000-nit display at BT.2408's 203-nit white) is held at it, keeping
its hue, then BT.2390's EETF maps the scene's peak onto the display's.

For players: on a 1000-nit display the world shows exactly as written; a 1500-nit or
brighter one shows the same picture, the sun included, instead of a brighter sun and
lava; a dimmer one keeps its midtones and rolls the highlights off below its peak.
Before, the gains scaled with the display's headroom and the curve then compressed
that light again from a peak it derived from the same headroom, so the look changed
with the Peak setting.

Light Horizon doesn't lift (HDR Lighting off, the title, a 0.2 pack's own) has no
known peak, so there the curve keeps the source that follows the display, as the 0.2
contract describes. HDR off, HDR Lighting off, the HUD, the black lift, the SDR
screenshot's shoulder and the 16-bit or 8-bit compatibility frame (HDR's default until
step 5, which changes the look once) are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 03:26:04 +01:00
voidsip b2d8cfa7f1 Merge commit 'f858ee752f1c0e76b6d200ec846645532536aded' into fix/11337-special-attack-followup 2026-11-08 18:38:54 -06:00
voidsip 366d731251 Merge main and preserve special attack follow-up behavior 2026-11-08 18:38:53 -06:00
umcekoandClaude Opus 5.5 3d11f0b6c6 Merge upstream main into perf-platform
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 21:15:53 +03:00
umcekoandClaude Opus 5.5 db571535c2 Merge perf-platform web work into the upstream sync
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 21:07:08 +03:00
Umut Cevdet Koçak 7d9d35c729 Merge pull request #257 from UMCEKO/plat/web-finish
Perf web: call tree, flame graph, info points, copy id, bench and compare in every view
2026-11-08 21:03:37 +03:00
umcekoandClaude Opus 5.5 6354608451 Merge perf-platform into the finished web viewer
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 20:47:45 +03:00
umcekoandClaude Sonnet 5.5 1fc4ccd439 Perf web: UI review fixes (numbers from the wasm, no file name, no duplicated views, units and labels)
- Summary tiles read the wasm's own series stats (new optional `stats` on series rows, asked for with filters.stats); the old browser-side statistics disagreed with the CLI summary (MSPT 8.9 against 10.7, CPU 8.47 against 6.6).
- Queries no longer carry the real file name, so the Query tab never shows it.
- Slow ticks: every kept tick opens into its full span tree; the meaningless min/median/p95 of the slowest 16, the histogram and the sparkline are gone. The summary no longer repeats the worst ticks and lists only the top steps of the tick.
- Metadata: tabs that only repeated Memory, Network and World are gone, rows without data are left out, empty tabs are hidden, enum values read as words, and the mod crate column reads the `crate` field the wasm sends.
- Units on every number, frame figures say frames, warnings are worded from their codes, compare rows have readable names and drop an all-"not enough data" verdict column, network packet columns hide when nothing was counted, TPS windows longer than the capture are not shown.
- Keyboard focus is no longer hidden under the sticky tab bar, and rows that open show a chevron.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 20:42:38 +03:00
umcekoandClaude Sonnet 5.5 ce9fdee6d4 Perf web: offer only the exports the capture can fill, group by thread ignores the picked thread, one verdict colour scheme, strings through t()
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 20:33:14 +03:00
umcekoandClaude Sonnet 5.5 8d1b4c8cca Perf web: every row view shows the change and verdict against a second capture
The diff gains opt-in function and call-path rows (--calls, calls in hp_diff), matched by stable id. One comparison state feeds Summary, Spans, Functions and the Call tree; rows only the second capture has are listed apart.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 20:22:47 +03:00
umcekoandClaude Sonnet 5.5 fee41659bd Perf web: hide the sample picker when there is one choice, drop an unsafe cast in export
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 20:04:27 +03:00
Evrim Cicek 4c246e24f2 Merge remote-tracking branch 'upstream/main' into fix/tnt-minecart-explosion-power
# Conflicts:
#	crates/horizon-client/src/game/minecarts.rs
2026-11-08 20:02:17 +03:00
umcekoandClaude Sonnet 5.5 b0154fc77d Merge plat/web-bench
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 19:57:30 +03:00
umceko 2dbff9aefe Merge plat/web-points 2026-11-08 19:57:16 +03:00
umceko dee8aecd7a Merge plat/web-flame 2026-11-08 19:57:15 +03:00
umceko 3a0a7390bf Merge plat/web-tree 2026-11-08 19:57:13 +03:00
umcekoandClaude Sonnet 5.5 e434d37311 Perf web: info points on well-known spans and functions, and a copy-id control on every row
Spans and functions the profiler meets most (tick_blocks, tick_lan, streaming,
tick_mobs, lan_chunks, random_ticks, allocator and memory calls...) get a short
explanation keyed by stable id in the translation file, shown as an accessible
hover and focus tooltip. Every row of the Summary, Spans, Functions, Call tree
and Slow ticks views copies its stable id from a control that appears on row
hover or keyboard focus, so rows stay clean at rest.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 19:56:27 +03:00
umcekoandClaude Sonnet 5.5 544600c10a Perf web: flame graph view for the call tree
A List / Flame graph toggle in the Call tree tab draws the current side, thread,
selection, grouping, merge mode and focus as a canvas flame graph: widths are time,
colours are the tree's crate groups, hover shows name, time and share, a click zooms
in with a trail back, and arrow keys, Enter and Escape do the same from the keyboard.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 19:49:44 +03:00
umcekoandClaude Sonnet 5.5 75e0f7387b Perf web: call tree with colour by crate, group by, selections, detail panel and search
The tree rows carry their crate, and a request can ask for each row's own time as a percent.
The tree tab is split into focused components; chains of single-child functions share a line.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 19:41:38 +03:00
Umut Cevdet Koçak 285846c5ee Merge pull request #256 from UMCEKO/perf/w1-web-ui
Web UI for horizon-perf
2026-11-08 19:33:32 +03:00
umcekoandClaude Sonnet 5.5 9df9c5bde2 Perf web: Bench tab with frame times per phase, repeat statistics and A/B verdicts
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 19:29:59 +03:00
umcekoandClaude Opus 5.5 28561fd634 Launcher: a native mod's generated main uses the game's current allocator
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:19:52 +03:00
umcekoandClaude Opus 5.5 f3613a539d Perf web: landing page, tabs only where there is data, and no duplicated views
The no-capture screen is now a landing page: what the viewer reads and how to get a capture. A tab appears only when its query answers with rows for the side on show, so GPU is gone from a server and empty tabs are gone everywhere; Metadata's sub-tabs follow the same rule.

Health, and Summary's network and dimension cards, repeated other views and are gone. So are the file name and size, the report box, New profile, the sort direction and row limit, the placeholder flame graph, and id chips.

Fixes: slow ticks say they cover only the slowest kept, in time order; query errors show their real message; Compare is a table of real rows for the side on show; table headers line up with their columns.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:18:46 +03:00
umcekoandClaude Opus 5.5 04a9f56511 Perf web: review fixes, design-system polish and no upload path
Removes sharing entirely: the Share button, share links, the upload API, its S3 storage and the CLI's `share` and `upload`. A capture stays on the machine.

Fixes:
- ureq and the snapshot files are gone, so the wasm builds and the tree is 7,600 lines lighter
- merge mode hides wrappers and merges a function across its callers
- `group --by mod` backs the mods view, charging a sample to its innermost mod frame
- a failed compare shows its error, and the compared capture is closed when replaced
- Hot shows milliseconds rather than calling them percent; Tree bars share one scale
- tick details line up; wasm replies are typed structs; launcher lock is current

Polish follows docs/design-system.md: Geist, list rows with aligned number columns, one accent, one sticky tab row, a real opening screen, focus states and reduced motion. Panels share one query hook, and dead icons, strings and exports are removed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:00:20 +03:00
Claude 5ea2d3c0fb Perf web UI: host-machine tab, new profile button, drop filename/top drop title
Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-11-08 16:20:42 +01:00
Claude be3db9da27 Perf platform web UI: complete UI1-UI7 client and U1/U2 upload API
Fan out implementation of the remaining perf-platform web UI pieces and upload API:
health/metadata, call tree/group-by/select/detail/search, flat/mods/merge/info,
flame graph and ticks tab, compare/GPU/export/copy/share/open links, and
upload route handlers, S3 storage, validation, share pin, sweep, rate limit.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-11-08 16:12:05 +01:00
Claude 0d6d1f1f66 Perf platform wasm + web UI updates
Co-Authored-By: Claude Code <[email protected]>
2026-11-08 15:43:10 +01:00
Claude bc4559b024 Improve web/perf UI: tabs, paste, panels, theme, typography
Co-Authored-By: Claude Code <[email protected]>
2026-11-08 15:42:48 +01: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
umcekoandClaude Opus 5.5 993525d333 Merge upstream main into perf-platform
Upstream moved Game::shut_down and idle to game/window_handler.rs and loading_frame and start_game to game/loading_window.rs; finish_profile and the benchmark set and profile fields move with them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 10:14:49 +03:00
Umut Cevdet Koçak 3bd70dbc06 Merge pull request #255 from UMCEKO/plat/L1
Perf platform L1: retire the slow-tick span log lines; docs point to captures
2026-11-08 07:59:40 +03:00
umcekoandClaude Sonnet 5.5 0b4c261d0c Perf platform L1: keep per-event attribution as a log line, pacing_bench drops pre-capture frames
The slow ring no longer shows up as span lists in the log, but whose packets or events took a tick
stay in the server owner's log as a separate line. pacing_bench keeps only frames over the threshold
recorded during the capture.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 07:53:56 +03:00
umcekoandClaude Sonnet 5.5 caac772100 Perf platform L1: slow-tick and slow-frame span lists leave the log; pacing_bench reads captures; docs point to captures
The slow ring and the captures keep the steps of slow ticks and frames; the notes channel that only fed the log line (event timings, packet notes, spawn counters) goes, and the spawn counters move to the counter registry.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 07:36:33 +03:00
Umut Cevdet Koçak 1d7e15c969 Merge pull request #254 from UMCEKO/plat/R1b
Perf platform R1b: rate-limited transfer of the server's capture into the client's file
2026-11-08 07:12:22 +03:00
umcekoandClaude Sonnet 5.5 68685775d9 Perf platform R1b: rate-limited transfer of the server's capture into the client's file
The server sends its finished file in 32 KiB chunks over horizon:perf, at most 256 KiB/s and not while the guest has a backlog. The client merges it field by field into its own stream (PartMerger, no decode), or saves it unchanged for profile server alone; a cut transfer ends the file with SERVER_PART_INCOMPLETE.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 07:07:33 +03:00
Umut Cevdet Koçak e87894a81d Merge pull request #253 from UMCEKO/plat/R1a
Perf platform R1a: profile a remote Horizon server from a client (channel, level 4, server copy)
2026-11-08 06:42:23 +03:00
umcekoandClaude Sonnet 5.5 35d8ee793f Perf platform R1a: profile a remote Horizon server from a client over horizon:perf
A client asks a Horizon server (LAN host, dedicated server) to capture its side: operator level 4, one capture at a time, the server logs who profiled and keeps its copy in reports/. profile server start|stop work on a joined server; a bare profile also asks and a refusal is a warning in the client file.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 06:35:26 +03:00
Umut Cevdet Koçak 78caa5b352 Merge pull request #252 from UMCEKO/plat/C2
Perf platform C2: single player captures both sides; horizon-ctl profile server
2026-11-08 06:05:14 +03:00
umcekoandClaude Sonnet 5.5 3597945405 Perf platform C2: the gpu e2e checks read the client side's options
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 06:05:00 +03:00
umcekoandClaude Sonnet 5.5 87623d9714 Perf platform C2: fit the server capture options and tests to the gpu and timeline pieces after the rebase
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 06:00:17 +03:00
umcekoandClaude Sonnet 5.5 71a09a89c0 Perf platform C2: every mark reply is on the game clock; start lists the sides asked for
A server-only mark replied capture-relative and a joined one on the game clock; both now use the game clock.
The e2e checks the server mark against the client mark's game-clock t.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 05:54:54 +03:00
umcekoandClaude Sonnet 5.5 18c344ff3c Fix profile mark and status routing for joined and finished server captures
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 05:54:54 +03:00
umcekoandClaude Sonnet 5.5 1b28cc6feb Perf platform C2: e2e reads the client side by name, docs say what stop and mark do per side
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 05:54:41 +03:00
umcekoandClaude Sonnet 5.5 af837708aa Perf platform C2: single player captures both sides; profile server
The integrated server thread writes its side (kind INTEGRATED) into the window's capture file, on the same clock, and a bare profile means both. profile server writes the server thread's own file.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 05:54:41 +03:00
Umut Cevdet Koçak ba04e6991d Merge pull request #251 from UMCEKO/plat/E1
Perf platform E1: horizon-perf export to pprof, folded and Perfetto
2026-11-08 05:47:20 +03:00
umcekoandClaude Sonnet 5.5 56cf9c3301 Perf platform E1: typed pprof labels and sample states, finer pprof period, two-side export tests
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 05:46:48 +03:00
umcekoandClaude Sonnet 5.5 05925da3cd Perf platform E1: horizon-perf export to pprof, folded stacks and a Perfetto trace
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 05:46:48 +03:00
Umut Cevdet Koçak a072c2275f Merge pull request #250 from UMCEKO/plat/B1
Perf platform B1: benchmark reports in the capture, horizon-perf attach, server_bench --profile
2026-11-08 05:42:43 +03:00
umcekoandClaude Sonnet 5.5 4e27118c3b Perf platform B1: fit the bench op and stop_with to the gpu piece after the rebase
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 05:41:55 +03:00
umcekoandClaude Sonnet 5.5 415797f30a perf: keep local paths, label and bot errors out of captures
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 05:37:05 +03:00
umcekoandClaude Sonnet 5.5 c283f0ce85 Perf platform B1: benchmark reports in the capture, horizon-perf attach, --benchmark --profile, server_bench --profile
Adds BenchResult (field 21) and Attachment (field 22) to the capture contract. horizon-perf reads bare benchmarks/*.json as one-sided captures, answers query bench and prints bench lines in the summary, and attach appends an attachment on the wire bytes so unknown fields survive. The game's --benchmark --profile records a capture and embeds the report through profile stop --bench; server_bench.py --profile records Horizon's measure window with horizon-report and attaches the run record.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 05:37:03 +03:00
Umut Cevdet Koçak 5bc42b54cf Merge pull request #249 from UMCEKO/plat/T1
Perf platform T1: timeline in the capture; HORIZON_TRACE and trace write .hzperf
2026-11-08 05:35:15 +03:00
umcekoandClaude Sonnet 5.5 980844073d Merge fix: drop the needless options update after rebasing T1 onto G1
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 05:35:00 +03:00
umcekoandClaude Sonnet 5.5 780a965ae2 Fix T1 review findings: trace seconds range, TLS-safe span, docs
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 05:29:27 +03:00
umcekoandClaude Sonnet 5.5 7b7afe9a8f Perf platform T1: timeline chunks in the capture; HORIZON_TRACE, trace and horizon-trace write .hzperf
The Chrome-trace writer in spans.rs is replaced by per-thread buffers drained once a second into
TimelineChunk (field 15), capped at 60 s or 256 MiB with timeline_stopped_t and a warning.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 05:29:18 +03:00
Umut Cevdet Koçak dc191e58df Merge pull request #248 from UMCEKO/plat/G1
Perf platform G1: GPU pass timings and counters in the capture
2026-11-08 05:09:48 +03:00
umcekoandClaude Sonnet 5.5 9f08382773 Perf platform G1: GPU pass timings and counters in the capture
Adds the gpu field (GpuPasses per timer) to the contract, written from the
renderer's PassTiming readings at stop; profile start --gpu[=detail], the
HORIZON_SHADER_PACK_TIMING and HORIZON_GPU_PASS_DETAIL aliases, horizon-perf
query gpu with fragments_per_pixel, a summary line and a meta count.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 05:01:54 +03:00
Umut Cevdet Koçak b787473097 Merge pull request #247 from UMCEKO/plat/C1
Perf platform C1: client captures: horizon-ctl profile client, F3+O, HORIZON_PROFILE
2026-11-08 04:31:55 +03:00
umcekoandClaude Sonnet 5.5 89feab9f8f Perf platform C1: adapt the client capture to the span, world and net recorders
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 04:31:42 +03:00
umcekoandClaude Sonnet 5.5 dd5fd7e80d Perf platform C1: fix review findings
One-line docstring and debug-key comment, F3+O feedback decided by who pressed stop, hardware described off the render thread, and the gauge probe spawned on the first frame with this second's counts.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 04:25:38 +03:00
umcekoandClaude Sonnet 5.5 e715b5b743 Perf platform C1: client captures with horizon-ctl profile, F3+O and HORIZON_PROFILE
The window's frames feed an always-on client health history and slow ring.
profile start|stop|status|mark (and F3+O, HORIZON_PROFILE=DIR) write a .hzperf
into reports/ in the game home, with the render thread's samples, retention and
the translated saved message. The capture session is shared with the dedicated
server's through a Role; the gauge probes run off the frame thread.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 04:25:38 +03:00
Umut Cevdet Koçak 4dbc406616 Merge pull request #246 from UMCEKO/plat/S1b
Perf platform S1b: spans, workers, net and world move from report.rs into the capture
2026-11-08 03:56:40 +03:00
umcekoandClaude Sonnet 5.5 c5a342f764 Fix S1b review: rustfmt the rule asserts, check only the report's span bit in the drop test
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 03:52:19 +03:00
umcekoandClaude Sonnet 5.5 6efe76a432 Fix S1b review: spans and job stats off when a recording drops, rule and option tests, far ring 0 when off
A Recording now turns spans and job stats off when it is dropped, so a capture that failed
to start or a shutdown no longer leaves them on. A leaver's unread bytes are lost (noted on
TrafficRecorder). The e2e script's docstring is one line and its build command is in
docs/perf-platform.md, which also says the privacy checks' split and the dropped between-tick spans.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 03:52:18 +03:00
umcekoandClaude Sonnet 5.5 958af813dd Perf platform S1b: spans, workers, net and world move from report.rs into the capture
The dedicated server's capture now carries span_blocks (5 s blocks with parents), the spans
summary (totals, histogram, 16 worst ticks with their trees), workers, per-player net bytes by
join order and the world at start and stop with sampled mob costs. perf-*.json and .txt are no
longer written. capture.proto gains the game_rules and horizon_options allowlists and the mob
timing overhead fields. Spans recorded between ticks no longer count in a tick.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 03:52:18 +03:00
Umut Cevdet Koçak da474503d8 Merge pull request #245 from UMCEKO/plat/M1
Perf platform M1: horizon-perf mcp server
2026-11-08 03:38:50 +03:00
umcekoandClaude Sonnet 5.5 50386e55df horizon-perf: mcp server with perf_summary, perf_query, perf_diff, perf_list and perf_follow (M1)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 03:36:48 +03:00
Umut Cevdet Koçak fc1972ffe7 Merge pull request #244 from UMCEKO/plat/Q2
Perf platform Q2: sample queries hot, tree, callers, callees, split, group, frames
2026-11-08 03:28:49 +03:00
umcekoandClaude Sonnet 5.5 05b2a7afee perf Q2: split t:<thread> uses that thread's units; frames warns on mixed threads; real-writer smoke test
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 03:24:19 +03:00
umcekoandClaude Sonnet 5.5 4e71e99f98 Perf platform Q2: sample queries hot, tree, callers, callees, split, group, frames; hot functions in the summary
Reads the samples section: functions merged by normalized name, ids over the
normalized name and call path, filters (thread, select, slow, where, window),
several captures for frames, meta counts samples, summary gains hot functions
and the entity/block/chunk splits.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 03:15:17 +03:00
Umut Cevdet Koçak 8fc4c27855 Merge pull request #243 from UMCEKO/plat/S2
Perf platform S2: sampler call trees in the capture; horizon-report samples by default
2026-11-08 02:58:17 +03:00
Evrim Cicek 3ff75a8ecb Merge remote-tracking branch 'upstream/main' into fix/tnt-minecart-explosion-power 2026-11-08 02:38:26 +03: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
umcekoandClaude Sonnet 5.5 35ddec5e9e Fix S2 review findings: one sample node per (parent, function), one-line doc, sampler share test, design doc notes
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 02:30:00 +03:00
umcekoandClaude Sonnet 5.5 9102edfb56 Perf platform S2: sampler call trees in the capture; horizon-report samples by default
The samples field (SampleProfile) is built from horizon_threads::Profile in
horizon-client-render: a deduplicated function table, a call-node chain per
stack, a context table and weights per thread, state, slow flag and time block.
The sampler's aggregate gains time blocks. horizon-report samples the tick
thread on Linux unless --no-samples, and finishes the file off the tick thread.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 02:04:12 +03:00
Umut Cevdet Koçak be4be0ce6b Merge pull request #242 from UMCEKO/plat/S1a
Perf platform S1a: dedicated servers: horizon-report writes .hzperf
2026-11-08 01:00:06 +03:00
umcekoandClaude Sonnet 5.5 d7b9e267e0 Fix S1a review: PERF_UI_URL points at /perf, note report output in hosting docs
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 00:53:36 +03:00
umcekoandClaude Sonnet 5.5 769cdcd232 Fix S1a review findings: one-line comments, unknown slow-tick cpu is 0, empty capture is incomplete, report checks completion only after the window, record files exception in the design doc
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 00:53:35 +03:00
umcekoandClaude Sonnet 5.5 3d9ef21f68 Perf platform S1a: horizon-report writes a .hzperf on the dedicated server
The always-on server health history and slow ring feed a capture file
(Start, history, slow_history, health, counters, slow, Stop) in reports/,
with retention of 30 files or 1 GiB that never touches renamed files.
horizon-report accepts --no-samples and --profile; horizon-server report
prints the saved path and the web UI address.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 00:53:35 +03:00
Umut Cevdet Koçak 6634e4ba98 Merge pull request #241 from UMCEKO/plat/typed
horizon-perf: every request, reply and vocabulary is typed
2026-11-08 00:33:32 +03:00
umceko ad01e692c9 horizon-perf: every request, reply and vocabulary is typed
Replies are serde structs (one Envelope<R>, a row struct per op, typed meta, watch events, diff and bench diff) and
closed vocabularies are enums (Op, Select, ContextKey, GroupBy, Unit, Verdict, HealthField, Flag, the proto name enums).
The CLI parses strings into them at the edge; no serde_json::Value, json! or string-keyed match is left in the library.

The JSON is the same as before as parsed JSON (tests/golden holds 49 cases captured from the old code). Intentional
differences, each visible in the golden diff:
- --where dimension=X is now the key dim, so dim and dimension merge in selection.where
- follow --json spells a stop reason window_cap (it said "window cap"), like query meta
- an unknown --by is a usage error at the edge instead of a world-only query error
- JSON object keys now come out in declaration order, not alphabetical
2026-11-08 00:32:47 +03:00
umceko 1547313299 horizon-perf: the golden follow case includes a window-cap stop 2026-11-08 00:28:45 +03:00
umceko 9874954fd0 horizon-perf: golden outputs of every query, diff and follow --json case, from before the typing refactor 2026-11-08 00:18:35 +03:00
Umut Cevdet Koçak 2e68167379 Merge pull request #240 from UMCEKO/plat/D1
Perf platform D1: horizon-perf diff with block statistics, and summary --baseline
2026-11-07 23:57:34 +03:00
umcekoandClaude Sonnet 5.5 14f833917a horizon-perf: diff with two-sample block statistics, drift warning, summary --baseline, benchmark report ab (D1)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 23:54:36 +03:00
Umut Cevdet Koçak 9ca66bdb02 Merge pull request #239 from UMCEKO/plat/Q1
Perf platform Q1: horizon-perf queries without samples, stable ids and filters
2026-11-07 23:44:15 +03:00
umcekoandClaude Sonnet 5.5 0da566c2ed horizon-perf: summary sections, filters and tests for the span queries (Q1)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 23:43:16 +03:00
umcekoandClaude Sonnet 5.5 8883d77504 horizon-perf: span, tick, series, world, net, meta, memory, mark and warning queries with stable ids (Q1)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 23:43:16 +03:00
Umut Cevdet Koçak 838d140cbc Merge pull request #238 from UMCEKO/plat/F2
Perf platform F2: health recorder, slow ring and counter registry
2026-11-07 23:37:30 +03:00
umcekoandClaude Sonnet 5.5 d2825d34f8 Perf platform F2: health recorder tiers, slow ring and counter registry
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 23:35:06 +03:00
Umut Cevdet Koçak e6f80a4ee4 Merge pull request #237 from UMCEKO/plat/D
Perf platform D: alloc-count, per-thread allocation counts in the frame stats
2026-11-07 23:30:32 +03:00
umcekoandClaude Sonnet 5.5 1d4d4feaf5 alloc-count: ON is false with system-allocator; rustfmt
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 23:25:49 +03:00
umcekoandClaude Sonnet 5.5 ff458a358d alloc-count: one-line comments, rustfmt
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 23:25:49 +03:00
umcekoandClaude Sonnet 5.5 c61ca75ca4 Add alloc-count: per-thread allocation counts, and alloc in the frame stats
horizon-threads gets a feature alloc-count (alloc-census implies it) with
CountingAllocator: each thread owns a cache-line slot of four counters, so a
block costs two plain load/store bumps. GameAllocator picks the census, the
counting mimalloc or plain mimalloc, and main.rs and the scene_replay example
declare it; without the feature the allocator is unchanged. The census counts
the same way.

In a counting build stats, bench and fly reply with alloc (window-thread
allocations and KiB per frame, all-thread rates, net growth), and stats raw and
fly raw add alloc_count and alloc_bytes beside frame_ms. Other builds reply
null. docs/memory.md gets the section with the measured cost.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 23:25:49 +03:00
umcekoandClaude Sonnet 5.5 cc52c95887 Docs: memory.md says what holds the game's memory and what the heap census costs
Describes only what exists: horizon-ctl memory and what each number means, the
alloc-census build (how tags are chosen, what it costs: +1.8% resident, frame
CPU time within the run-to-run spread, measured with --benchmark standard),
spans/trace/stats, resident memory in benchmark reports, scene_replay --memory
and the system-allocator feature for heaptrack on small probes.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 23:25:40 +03:00
Umut Cevdet Koçak e0f6c4f5e8 Merge pull request #236 from UMCEKO/plat/H
Perf platform H: in-process sampling profiler with symbolized function trees
2026-11-07 23:19:38 +03:00
umcekoandClaude Sonnet 5.5 91813c5ae0 horizon-threads: in-process symbolization and merged function trees for the sampler's aggregates
symbols.rs resolves addresses against this process's own binaries (DWARF through addr2line, the ELF symbol table for full paths), keeping base file names only. tree.rs names the aggregate's trie, merges it into a function tree and ranks functions by own and total samples. The pprof, folded and HTML writers stay out; the capture's samples field replaces them. Also fixes the spans test's work:: path and a clippy as_chunks lint.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 23:16:34 +03:00
umcekoandClaude Sonnet 5.5 a8a0d2cc25 horizon-threads: a profile session drains the sampler into bounded aggregates, with wall-clock samples
`profile::start` runs a writer thread that drains the sampler's rings every 100 ms, unwinds the stack copies there and folds each record
into a call-stack trie (131k nodes at most) plus counters per stack and context (span, entity type, chunk, block, dimension, slow tick).
A tick marker decides afterwards whether the samples of that tick count as slow, so the report can keep only the ticks over a threshold.
Tick durations go to a fixed log-bucket histogram per thread with the ten worst ticks. `Session::status` reads progress while it runs.

`Config::wall` arms a second timer per thread on the monotonic clock, signalling SIGRTMIN so it queues instead of merging with the
CPU-time SIGPROF; each handler blocks the other, so they never nest on one ring. A wall sample is marked running when the thread's CPU
clock advanced by more than half the period since its previous wall sample (the handlers' own microseconds don't count).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 23:16:33 +03:00
umcekoandClaude Sonnet 5.5 8e88e778a6 Profiler: the game loops store what they are doing in the sampler's context
The innermost span (spans::guard), the entity type before each mob, item,
cart, projectile and block entity tick, the chunk and block of random and
scheduled block ticks, the chunk in natural spawning, the level's dimension,
and a tick marker with its duration at the end of each server tick and each
client frame. Plain stores into the sampler's thread-local; the new
horizon_threads::profile module makes them no-ops where the sampler does not
run and interns the names behind the ids.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 23:16:33 +03:00
umcekoandClaude Sonnet 5.5 7b5cf13614 horizon-threads: the sampler unwinds copies of the stack with .eh_frame when frame pointers are off
Frame pointers cost 29-34% world load in release builds, so the release build keeps them off and the SIGPROF handler now copies
the interrupted registers and the top 16 KiB of the thread's stack (clamped to its bounds) into a byte ring, memcpy only.
`Session::drain`, on the caller's thread, unwinds each copy with the loaded objects' `.eh_frame` tables. The frame-pointer walk
stays in the handler as the fast path when this build keeps frame pointers (`Unwind::Auto`, decided at start from a probe).
Ring entries are variable length; a ring holds two seconds of samples by default, and a full one drops and counts as before.

New dependencies (Linux only): `framehop` 0.16 (MIT/Apache-2.0, samply's unwinder; default features off, so no Mach-O/PE
parsers) unwinds from `.eh_frame`, and `object` 0.39 locates the sections in each loaded ELF. There is no in-tree unwinder, and
writing a CFI interpreter is the alternative. Both share one `gimli` 0.33, which was not in the lock before.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 23:16:33 +03:00
umcekoandClaude Sonnet 5.5 1514d3d16a horizon-threads: a frame-pointer unwinder and a Linux CPU-time stack sampler
`unwind` walks frame pointers within a thread's stack bounds (shared with the allocation profiler). `sampler` arms a per-thread
CPU-time POSIX timer that signals SIGPROF to its own thread; the async-signal-safe handler copies the thread's context and
stack into a preallocated ring, and `tick_end` writes tick-duration markers so a report can keep only slow ticks.
Nothing is installed or armed until `sampler::start`. Frame pointers stay off in `.cargo/config.toml`: the benchmark
shows world load +29% (7 of 8 pairs) with `-C force-frame-pointers=yes`.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 23:16:32 +03:00
Umut Cevdet Koçak 3e093c2c6f Merge pull request #235 from UMCEKO/plat/C
Perf platform C: GPU pass timing
2026-11-07 23:16:19 +03:00
umcekoandClaude Sonnet 5.5 e9db26a19a Shorten the detail test doc to one line
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 23:10:35 +03:00
umcekoandClaude Sonnet 5.5 267d39c511 GPU pass timing: fix review findings (docs on tables, uploads entry, phase 2 test)
step moves above tables' doc comment. The docs now say GPU culling uploads is a pass entry of its
own, only in detail frames on devices with encoder timestamps, not a part of GPU culling. A new
GPU test times both phases and the uploads and checks detail leaves the counts and draws as an
untimed frame's; the test device now asks for SHADER_EARLY_DEPTH_TEST so phase 2 can run.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 23:10:35 +03:00
umcekoandClaude Sonnet 5.5 3b3de54fa3 GPU pass timing: where detail shows (gpu-passes detail, F3, scene_replay)
`horizon-ctl gpu-passes detail` turns on dispatch times and, on Vulkan, per-pass counters;
stats and bench add `gpu_pass_detail`, `gpu_pass_parts` and `gpu_counters`, F3 lists the parts
and the six passes with the most fragments, and `scene_replay --gpu-detail on` reports both per
view after the timed frames. RADV counts fragments per 2x2 quad, so a calibration draw at setup
finds each device's `fragment_unit` and `fragments_per_pixel` is computed from it.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 23:10:35 +03:00
umcekoandClaude Sonnet 5.5 a1cf28d100 GPU pass timing: pipeline statistics on the world passes
`gpu-passes detail` frames now alternate between a parts frame (times) and a counters frame
(vertex, clipper and fragment shader invocations per world pass, split into segments such as
before translucent, occlusion boxes and translucent terrain). A counters frame throws its times
away, and CountedPass ends its query on drop, so none stays open at the end of a pass. Vulkan
only; a device without pipeline statistics keeps parts frames.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 23:10:35 +03:00
umcekoandClaude Sonnet 5.5 517546d1af GPU pass timing: dispatch parts inside a pass, and GPU culling's dispatches and uploads
`gpu-passes detail` (the renderer's `set_pass_detail`, or HORIZON_GPU_PASS_DETAIL) times each
dispatch of GPU culling (apply, cull, scan, scatter; count2, scan2, scatter2) and its uploads
plus clear_buffer, so a slow pass can be split. A timestamp pair brackets each part inside one
compute pass where the device has TIMESTAMP_QUERY_INSIDE_PASSES, else each part gets a pass of
its own. Off by default; `on` records the same queries and passes as before.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 23:10:35 +03:00
umcekoandClaude Opus 5.5 cb7a5c204e gpu-passes: time the frames the submit thread submits too
land_frame told the GPU timer and a shader pack's pass timer that their frame
was submitted, but not Horizon's own pass timer: with the submit thread on, its
first timed frame was read and every later one waited forever, so gpu-passes,
F3 and scene_replay showed the first frame's pass times only.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 23:10:35 +03:00
Umut Cevdet Koçak 7833ed6c05 Merge pull request #234 from UMCEKO/plat/P1
Perf platform P1: horizon-perf readers, latest/list/follow and a health-only summary
2026-11-07 23:07:48 +03:00
umcekoandClaude Sonnet 5.5 8b7cc25b66 perf: stream captures in 64 KiB slices, one-line doc, pin gzip trailer and header paths
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 23:05:29 +03:00
umcekoandClaude Sonnet 5.5 0689bc6505 horizon-perf: whole-file and follow readers, latest, list, follow and a health-only summary (P1)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 23:01:49 +03:00
Umut Cevdet Koçak e9e7050f32 Merge pull request #233 from UMCEKO/plat/A
Perf platform A: paired A/B statistics and the travel benchmark scenario
2026-11-07 22:51:54 +03:00
umcekoandClaude Sonnet 5.5 afdac528fc Benchmark A/B: drop the references to the removed bench_stats.py
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 22:42:49 +03:00
umcekoandClaude Sonnet 5.5 875c7d7a2b Benchmark: the travel scenario, --benchmark-set and bench raw
horizon-client --benchmark travel flies 512 blocks at 16 blocks/s with the time
and weather frozen, then holds at the end for four blocks: the renderer after
travel, where GPU culling's slowdown shows. Scenario::parse replaces scenes();
--benchmark-set SWITCH=VALUE sets one of the SWITCHES (defaults as gfx has them)
before the world opens, and a refused switch ends the run with the game's
reason. The bench control word takes raw like stats and fly, and the report gets
gpu.<phase>.p50_ms and cpu.<phase>.p50_ms for the trip and hold phases.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 22:30:08 +03:00
umcekoandClaude Sonnet 5.5 9a2931de68 Benchmark report: paired A/B statistics and the optional ab field
horizon_bench::compare pairs the ab.a/ab.b blocks (B A A B order) and gives the
Hodges-Lehmann effect, the exact Wilcoxon signed-rank interval, a verdict, the
blocks' noise and the first-block flag. tools/bench_stats.py computes the same
numbers in Python and checks a report's stored result against its frames.
Report gains an optional ab field; the format stays version 1.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 22:30:08 +03:00
umcekoandClaude Opus 5.5 52128e9f79 docs: perf platform's gzip writer goes through flate2 (pure Rust), as F1 built it
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 22:17:42 +03:00
Umut Cevdet Koçak e635a9b0b4 horizon-perf-format: the .hzperf capture contract (F1) (#232)
horizon-perf-format: the .hzperf capture contract (F1)
2026-11-07 22:17:18 +03:00
umcekoandClaude Opus 5.5 0d21d07827 horizon-perf-format: the .hzperf capture contract (F1)
The new crate holds piece F1 of docs/perf-platform.md: capture.proto (Capture's
top level with Start, Stop, strings, marks, warnings, health, history, the slow
ring and counters), compiled by protox + prost-build in build.rs, and the gzip
writer (flate2, rust_backend) that appends whole top-level fields with a sync
flush after each, on a horizon-perf-writer thread fed by a 4,096-field bounded
channel that drops and counts instead of waiting.

Tests: the frozen tests/fixtures/v1.hzperf decodes to its listed values; after
every flush the file on disk inflates to exactly the fields written; a file cut
at random points yields only whole fields, in order; unknown field numbers are
skipped; the descriptor lint passes capture.proto and rejects a missing
_UNSPECIFIED, a JSON-name collision and a group; a full channel drops, counts
and never waits, and Stop records the drops.

Fixes #2

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 22:16:46 +03:00
umcekoandClaude Opus 5.5 b91b5516f3 fork: /claim bot and review batches for the perf platform fork
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 20:31:53 +03:00
umceko be1de92536 Merge branch 'docs/perf-platform-spec' into perf-platform 2026-11-07 20:28:31 +03:00
umcekoandClaude Opus 5.5 b3be633edf docs: perf platform tracker lives on UMCEKO/horizon#1
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 20:22:26 +03:00
umcekoandClaude Opus 5.5 ae1555ab94 docs: perf platform pieces have no PR size limit; tracker #11902
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 20:17:03 +03:00
umcekoandClaude Opus 5.5 ee3a790501 docs: design spec for one performance platform (.hzperf, horizon-perf, web UI)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 20:10:46 +03:00
Horizon agentandClaude Opus 5.5 c3a16de36d Merge origin/main into #11585
dispense_fixture.rs: chains this branch's special_attack fixture op and
main's wind_burst op.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:51:14 +01:00
Horizon agentandClaude Opus 5.5 6ca76d9e9c Merge origin/main into #11507
dispense_fixture.rs: chains this branch's shield_hit fixture op and
main's wind_burst op.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:50:23 +01:00
Evrim Cicek c58a3eef6f Merge remote-tracking branch 'origin/fix/tnt-fuse-any-number' into fix/tnt-minecart-explosion-power
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-07 16:54:08 +03:00
Evrim Cicek ca57097ae8 Merge remote-tracking branch 'upstream/main' into fix/tnt-minecart-explosion-power
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-07 16:53:53 +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
voidsip fd7dfc59df Guard special attack tails with original damage admission 2026-11-06 21:07:44 -06:00
socksproxandClaude Opus 5.5 507006a08d Move the mining speed formula into horizon-items
Player.getDestroySpeed, Player.hasCorrectToolForDrops and
BlockBehaviour.getDestroyProgress move from horizon-client's gameplay into
horizon_items::mining, taking plain inputs, so frontends that cannot link
horizon-client mine by the same rules. destroy_progress in game/hand.rs
gathers the player's modifiers and calls it, with the native_break_speed
hook still where getDestroySpeed ends; break_block_as_player uses the same
harvest check. Same f32 operations in the same order: no behaviour change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 02:24:09 +01:00
voidsip b98cb1a611 Give each shield fixture run a fresh game directory 2026-11-06 18:07:05 -06:00
voidsip ec468766cd Assert accepted attack outcomes before comparing follow-up costs 2026-11-06 17:57:23 -06: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 Cicek b7eb967776 Merge remote-tracking branch 'upstream/main' into fix/tnt-minecart-explosion-power
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-07 02:30:21 +03:00
voidsip c74851a64b Initialize starvation blocking state and bound fixture startup 2026-11-06 17:25:24 -06:00
voidsip 7e176a3b9e Preserve upstream cart helper spacing 2026-11-06 16:14:29 -06:00
voidsip 9dee3221a3 Merge current main into special attack follow-up fix 2026-11-06 16:14:12 -06:00
voidsip ab335d9ebb Preserve accepted special-target attack exhaustion and slowdown 2026-11-06 16:13:47 -06:00
voidsip 6b6a9577c7 Merge current upstream for shield blocking validation 2026-11-06 16:13:01 -06:00
voidsip 316f9680d5 Fix built-in shield blocking inputs and item wear 2026-11-06 16:12:41 -06: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
Evrim CicekandClaude Opus 5.5 5a60e2252b TNT minecarts keep explosion_power and explosion_speed_factor, and blow with them
MinecartTNT reads explosion_power and explosion_speed_factor with
getFloatOr (4 and 1), clamped to 0..128, writes each only off its
default, and explode() blows with explosionPowerBase +
explosionSpeedFactor * nextDouble() * 1.5 * min(speed, 5). Horizon blew
every cart at 4 + 1.5 * speed * r and kept whatever the loaded compound
had for the fields, unclamped.

Command harness group "tnt minecart explosion power": 13/18 -> 18/18.

Fixes #11167

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 17:28:56 +03:00
983 changed files with 100776 additions and 9351 deletions

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
# `cargo xtask …` runs the repository's task crate, so the commands in docs/perf-platform.md and
# web/perf/README.md work as written. `cargo run -p xtask -- …` stays equivalent.
[alias]
xtask = "run --package xtask --"
+2 -1
View File
@@ -36,8 +36,9 @@ jobs:
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable, 2026-11
with:
toolchain: stable
targets: ${{ matrix.target }}
- name: The aarch64 Linux linker
if: matrix.target == 'aarch64-unknown-linux-gnu'
+16 -12
View File
@@ -29,16 +29,18 @@ jobs:
# One job, not one per check: each job takes a runner from the organisation's shared pool, and with every pull request
# and every merge to main starting this workflow, three jobs a run left the pool's queue tens of minutes deep.
#
# Where (docs/ci-runners.md): our checks runners (label horizon-checks: the desktop's, an isolated user, so a fork's
# pull request runs there too) take the pull requests whose number ends in 0 or 1, about what their three runners
# can do; GitHub's runners take the others and main's pushes (which also save the Rust cache GitHub's pull request
# runs read), so both pools work at once. The repository variable SELF_HOSTED_CI moves them: `all` every job to
# ours, `light` (the desktop paused) or `off` every job to GitHub's. On ours the build folder persists
# Where (docs/ci-runners.md): our checks runners (label horizon-checks: the desktop's) take this repository's own
# pull requests whose number ends in 0 or 1, about what their three runners can do; GitHub's runners take the others,
# forks' pull requests and main's pushes (which also save the Rust cache GitHub's pull request runs read), so both
# pools work at once. The repository variable SELF_HOSTED_CI moves them: `all` every job to ours, `light` (the
# desktop paused) or `off` every job to GitHub's. A fork's pull request sees no repository variable, so no switch
# could move it off a machine that is off: it always runs on GitHub's. On ours the build folder persists
# (CARGO_TARGET_DIR, outside the checkout), so the apt packages, Python and the Rust cache steps are GitHub's only.
verify:
runs-on: >-
${{ vars.SELF_HOSTED_CI != 'off' && vars.SELF_HOSTED_CI != 'light' && (vars.SELF_HOSTED_CI == 'all' ||
github.event_name == 'pull_request' && (endsWith(format('{0}', github.event.pull_request.number), '0') ||
github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository &&
(endsWith(format('{0}', github.event.pull_request.number), '0') ||
endsWith(format('{0}', github.event.pull_request.number), '1'))) &&
fromJSON('["self-hosted", "linux", "horizon-checks"]') || 'ubuntu-24.04' }}
timeout-minutes: 45
@@ -110,7 +112,7 @@ jobs:
fi
rustup toolchain install stable --profile minimal --component rustfmt --component clippy
# Dependencies and the workspace's own build, saved from main's runs (pull requests read main's cache).
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
if: steps.rust.outputs.required != 'false' && runner.environment == 'github-hosted'
with:
shared-key: verify
@@ -141,7 +143,8 @@ jobs:
launcher:
runs-on: >-
${{ vars.SELF_HOSTED_CI != 'off' && vars.SELF_HOSTED_CI != 'light' && (vars.SELF_HOSTED_CI == 'all' ||
github.event_name == 'pull_request' && (endsWith(format('{0}', github.event.pull_request.number), '0') ||
github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository &&
(endsWith(format('{0}', github.event.pull_request.number), '0') ||
endsWith(format('{0}', github.event.pull_request.number), '1'))) &&
fromJSON('["self-hosted", "linux", "horizon-checks"]') || 'ubuntu-24.04' }}
timeout-minutes: 45
@@ -166,7 +169,7 @@ jobs:
libx11-xcb-dev libfontconfig-dev libvulkan-dev libasound2-dev libudev-dev
fi
rustup toolchain install stable --profile minimal --component rustfmt --component clippy
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
if: steps.launcher.outputs.required != 'false' && runner.environment == 'github-hosted'
with:
workspaces: launcher
@@ -199,13 +202,14 @@ jobs:
run: |
$cores = (Get-CimInstance Win32_Processor | Measure-Object -Property NumberOfLogicalProcessors -Sum).Sum
"CARGO_BUILD_JOBS=$cores" >> $env:GITHUB_ENV
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable, 2026-11
with:
toolchain: stable
targets: aarch64-pc-windows-msvc
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
shared-key: windows-arm64-game
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
workspaces: launcher
shared-key: windows-arm64-launcher
+6 -3
View File
@@ -50,7 +50,9 @@ jobs:
ref: ${{ inputs.ref }}
persist-credentials: false
clean: true
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable, 2026-11
with:
toolchain: stable
- run: bash tools/ci/runner-env.sh
env:
HORIZON_NO_SCCACHE: ${{ inputs.trusted && '1' || '' }}
@@ -156,10 +158,11 @@ jobs:
# may end up in a release. target/ is rebuilt from scratch; .local/ is relinked by vanilla-data.sh, which
# keeps its preparations outside the checkout.
clean: true
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable, 2026-11
with:
toolchain: stable
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
if: runner.environment == 'github-hosted' && !matrix.musl
with:
# A release restores only what a release saved: an untrusted build has its own key and saves nothing.
+3 -2
View File
@@ -173,10 +173,11 @@ jobs:
# Always from a clean checkout, the Mac mini's runner too: its work folder is shared with pull requests'
# parity jobs, and nothing they leave may end up in a launcher release (target/ is rebuilt from scratch).
clean: true
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable, 2026-11
with:
toolchain: stable
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
if: runner.environment == 'github-hosted'
with:
workspaces: launcher
+7 -3
View File
@@ -20,8 +20,12 @@ concurrency:
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
check:
# Our checks runners (label horizon-checks, docs/ci-runners.md); SELF_HOSTED_CI=light or off: GitHub's.
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && vars.SELF_HOSTED_CI != 'light' && fromJSON('["self-hosted", "linux", "horizon-checks"]') || 'ubuntu-latest' }}
# Our checks runners (label horizon-checks, docs/ci-runners.md); SELF_HOSTED_CI=light or off: GitHub's. A fork's
# pull request sees no repository variable, so it always runs on GitHub's (it would wait for the desktop when off).
runs-on: >-
${{ vars.SELF_HOSTED_CI != 'off' && vars.SELF_HOSTED_CI != 'light' &&
(github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) &&
fromJSON('["self-hosted", "linux", "horizon-checks"]') || 'ubuntu-latest' }}
timeout-minutes: 20
defaults:
run:
@@ -30,7 +34,7 @@ jobs:
- uses: actions/checkout@v7
- run: rustup toolchain install stable --profile minimal --component rustfmt --component clippy
# Ours keep their build folder between jobs.
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
if: runner.environment == 'github-hosted'
with:
workspaces: crates/horizon-mod-registry
+3 -1
View File
@@ -55,8 +55,10 @@ jobs:
persist-credentials: false
# Keeps .local/ (parity state, probe builds, the previous audit) and target/.
clean: false
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable, 2026-11
if: steps.when.outputs.run == 'true'
with:
toolchain: stable
- name: Tools from the runner
if: steps.when.outputs.run == 'true'
run: bash tools/ci/runner-env.sh
+1 -1
View File
@@ -38,7 +38,7 @@ jobs:
sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
fi
rustup toolchain install stable --profile minimal
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
if: runner.environment == 'github-hosted'
with:
shared-key: pack-bench
+3 -1
View File
@@ -37,7 +37,9 @@ jobs:
persist-credentials: false
# Keeps .local/ (parity state, probe builds, oracle results) and target/ between runs.
clean: false
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable, 2026-11
with:
toolchain: stable
- name: Tools from the runner
run: bash tools/ci/runner-env.sh
- name: Vanilla data, and resources prepared by this preparer
+3 -1
View File
@@ -71,7 +71,9 @@ jobs:
echo "::warning::head ${HEAD_SHA:0:9} conflicts with main ${main:0:9}: tested alone, without main's fixes"
echo "Tested: head ${HEAD_SHA:0:9} alone (it conflicts with main ${main:0:9})" | tee -a "$GITHUB_STEP_SUMMARY"
fi
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable, 2026-11
with:
toolchain: stable
- name: Tools from the runner
run: bash tools/ci/runner-env.sh
- name: Vanilla data, and resources prepared by this preparer
+56
View File
@@ -0,0 +1,56 @@
name: perf-wasm
# The site consumes this reader bundle from horizon-rust/horizon-site.
on:
push:
branches: [main]
paths:
- 'crates/horizon-perf/**'
- 'crates/horizon-perf-format/**'
- 'crates/horizon-bench/**'
- 'xtask/**'
- 'Cargo.lock'
- 'Cargo.toml'
- '.github/workflows/perf-wasm.yml'
pull_request:
types: [opened, synchronize, reopened]
paths:
- 'crates/horizon-perf/**'
- 'crates/horizon-perf-format/**'
- 'crates/horizon-bench/**'
- 'xtask/**'
- 'Cargo.lock'
- 'Cargo.toml'
- '.github/workflows/perf-wasm.yml'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: perf-wasm-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 30
env:
HORIZON_TELEMETRY_URL: "off"
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
with:
key: perf-wasm
# The agreement test: the C ABI, called natively, must print the bytes the CLI prints.
- name: The wasm ABI agrees with the CLI
run: cargo test -p horizon-perf --features wasm --test wasm
- name: Build the wasm the page loads
run: cargo run -p xtask -- perf-wasm
- uses: actions/upload-artifact@v4
with:
name: perf-wasm
path: target/perf-wasm
if-no-files-found: error
+7 -3
View File
@@ -20,8 +20,12 @@ concurrency:
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
check:
# Our checks runners (label horizon-checks, docs/ci-runners.md); SELF_HOSTED_CI=light or off: GitHub's.
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && vars.SELF_HOSTED_CI != 'light' && fromJSON('["self-hosted", "linux", "horizon-checks"]') || 'ubuntu-latest' }}
# Our checks runners (label horizon-checks, docs/ci-runners.md); SELF_HOSTED_CI=light or off: GitHub's. A fork's
# pull request sees no repository variable, so it always runs on GitHub's (it would wait for the desktop when off).
runs-on: >-
${{ vars.SELF_HOSTED_CI != 'off' && vars.SELF_HOSTED_CI != 'light' &&
(github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) &&
fromJSON('["self-hosted", "linux", "horizon-checks"]') || 'ubuntu-latest' }}
timeout-minutes: 20
defaults:
run:
@@ -30,7 +34,7 @@ jobs:
- uses: actions/checkout@v7
- run: rustup toolchain install stable --profile minimal --component rustfmt --component clippy
# Ours keep their build folder between jobs.
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
if: runner.environment == 'github-hosted'
with:
workspaces: crates/horizon-telemetry-server
+2 -1
View File
@@ -102,6 +102,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
| `horizon-shader-api` | Horizon's own WGSL shader packs ([docs/api/shaders.md](docs/api/shaders.md)): `horizon-shaders.toml`, the `.hshader` package; shared by the game, the launcher and the registry |
| `horizon-shaders` | Shader packs (the `shaders/` folder format), rebuilt clean-room from a written spec of the format; no wgpu |
| `horizon-theme` | Design tokens and fonts for the launcher, the game's own menus and the website |
| `horizon-perf-format` | The `.hzperf` capture contract ([docs/perf-platform.md](docs/perf-platform.md) §3): `proto/horizon/perf/v1/capture.proto`, its generated types, the gzip writer and its thread. No Horizon dependencies; nothing in the game reads a capture |
| `horizon-telemetry`, `horizon-telemetry-server`, `horizon-bench` | Opt-in data collection, its ingest server, the benchmark report format |
| `horizon-system` | The machine the game runs on (OS, CPU, memory, drive, power, GPU maker and memory), for the crash report, the renderer's GPU info and telemetry |
| `horizon-platform` | The platform's public API: what the Horizon layer may use of the engine ([docs/layers.md](docs/layers.md)); re-exports `horizon-system` today |
@@ -213,7 +214,7 @@ state stays separate from the library tests. This compiles gameplay twice for ex
| 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), `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
+389 -6
View File
@@ -18,6 +18,15 @@ version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
[[package]]
name = "addr2line"
version = "0.26.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59317f77929f0e679d39364702289274de2f0f0b22cbf50b2b8cff2169a0b27a"
dependencies = [
"gimli",
]
[[package]]
name = "adler2"
version = "2.0.1"
@@ -129,6 +138,12 @@ dependencies = [
"libc",
]
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "arboard"
version = "3.7.0"
@@ -264,6 +279,12 @@ version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "beef"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1"
[[package]]
name = "bit-set"
version = "0.10.0"
@@ -559,7 +580,7 @@ checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681"
dependencies = [
"serde",
"termcolor",
"unicode-width",
"unicode-width 0.2.2",
]
[[package]]
@@ -706,6 +727,25 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-queue"
version = "0.3.14"
@@ -926,6 +966,12 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]]
name = "either"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
[[package]]
name = "embedded-io"
version = "0.4.0"
@@ -986,6 +1032,12 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "fastrand"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "fdeflate"
version = "0.3.7"
@@ -1084,6 +1136,18 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "framehop"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f54fe4785e899d4d6f43793b151c63c5647240fc630b005509d2614a939f693"
dependencies = [
"arrayvec",
"cfg-if",
"fallible-iterator",
"gimli",
]
[[package]]
name = "fs4"
version = "1.1.0"
@@ -1221,6 +1285,15 @@ dependencies = [
"windows 0.62.2",
]
[[package]]
name = "gimli"
version = "0.33.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf7f043f89559805f8c7cacc432749b2fa0d0a0a9ee46ce47164ed5ba7f126c"
dependencies = [
"stable_deref_trait",
]
[[package]]
name = "gl_generator"
version = "0.14.0"
@@ -1401,7 +1474,7 @@ dependencies = [
name = "horizon-bench"
version = "0.1.0"
dependencies = [
"horizon-telemetry",
"horizon-system",
"serde",
"serde_json",
]
@@ -1457,10 +1530,13 @@ dependencies = [
"horizon-mods",
"horizon-narrator",
"horizon-optifine",
"horizon-perf",
"horizon-perf-format",
"horizon-protocol",
"horizon-server-files",
"horizon-shader-api",
"horizon-shaders",
"horizon-system",
"horizon-telemetry",
"horizon-theme",
"horizon-threads",
@@ -1523,7 +1599,6 @@ dependencies = [
"horizon-anticheat",
"horizon-assets",
"horizon-bench",
"horizon-chat-filter",
"horizon-client-core",
"horizon-client-input",
"horizon-client-render",
@@ -1536,6 +1611,8 @@ dependencies = [
"horizon-mods",
"horizon-narrator",
"horizon-optifine",
"horizon-perf",
"horizon-perf-format",
"horizon-protocol",
"horizon-server-files",
"horizon-shader-api",
@@ -1554,6 +1631,7 @@ dependencies = [
"open",
"png",
"pollster",
"rayon",
"rustix 1.1.5",
"serde",
"serde_json",
@@ -1584,12 +1662,13 @@ dependencies = [
"horizon-client-input",
"horizon-command",
"horizon-core",
"horizon-cosmetics",
"horizon-hdr",
"horizon-hooks",
"horizon-items",
"horizon-narrator",
"horizon-optifine",
"horizon-perf",
"horizon-perf-format",
"horizon-polytone",
"horizon-protocol",
"horizon-shader-api",
@@ -1807,6 +1886,7 @@ dependencies = [
"horizon-game",
"horizon-heartbeat",
"horizon-official-servers",
"horizon-perf-share",
"horizon-platform",
"log",
"winresource",
@@ -1825,18 +1905,60 @@ dependencies = [
name = "horizon-optifine"
version = "0.1.0"
dependencies = [
"horizon-bench",
"horizon-core",
"horizon-system",
"log",
"regex",
]
[[package]]
name = "horizon-perf"
version = "0.1.0"
dependencies = [
"flate2",
"horizon-bench",
"horizon-perf-format",
"horizon-perf-share",
"prost",
"regex",
"serde",
"serde_json",
]
[[package]]
name = "horizon-perf-format"
version = "0.1.0"
dependencies = [
"flate2",
"prost",
"prost-build",
"prost-types",
"protox",
]
[[package]]
name = "horizon-perf-share"
version = "0.1.0"
dependencies = [
"getrandom 0.3.4",
"horizon-addresses",
"horizon-platform",
"log",
"open",
"serde",
"serde_json",
"ureq",
"url",
]
[[package]]
name = "horizon-platform"
version = "0.1.0"
dependencies = [
"horizon-bench",
"horizon-core",
"horizon-hooks",
"horizon-perf-format",
"horizon-prepare",
"horizon-protocol",
"horizon-server-files",
@@ -1921,7 +2043,6 @@ dependencies = [
"chrono",
"getrandom 0.3.4",
"horizon-anticheat",
"horizon-bench",
"horizon-chat-filter",
"horizon-mcp",
"horizon-platform",
@@ -2005,9 +2126,14 @@ version = "0.1.0"
name = "horizon-threads"
version = "0.1.0"
dependencies = [
"addr2line",
"framehop",
"libc",
"libmimalloc-sys",
"memmap2",
"mimalloc",
"object",
"rustc-demangle",
"windows-sys 0.61.2",
]
@@ -2017,6 +2143,7 @@ version = "0.1.0"
dependencies = [
"flate2",
"horizon-core",
"horizon-threads",
"libm",
"log",
"lz4_flex",
@@ -2258,6 +2385,15 @@ dependencies = [
"once_cell",
]
[[package]]
name = "itertools"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.18"
@@ -2484,6 +2620,72 @@ version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "logos"
version = "0.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff472f899b4ec2d99161c51f60ff7075eeb3097069a36050d8037a6325eb8154"
dependencies = [
"logos-derive 0.15.1",
]
[[package]]
name = "logos"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb2c55a318a87600ea870ff8c2012148b44bf18b74fad48d0f835c38c7d07c5f"
dependencies = [
"logos-derive 0.16.1",
]
[[package]]
name = "logos-codegen"
version = "0.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "192a3a2b90b0c05b27a0b2c43eecdb7c415e29243acc3f89cc8247a5b693045c"
dependencies = [
"beef",
"fnv",
"lazy_static",
"proc-macro2",
"quote",
"regex-syntax",
"rustc_version",
"syn 2.0.119",
]
[[package]]
name = "logos-codegen"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "58b3ffaa284e1350d017a57d04ada118c4583cf260c8fb01e0fe28a2e9cf8970"
dependencies = [
"fnv",
"proc-macro2",
"quote",
"regex-automata",
"regex-syntax",
"syn 2.0.119",
]
[[package]]
name = "logos-derive"
version = "0.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "605d9697bcd5ef3a42d38efc51541aa3d6a4a25f7ab6d1ed0da5ac632a26b470"
dependencies = [
"logos-codegen 0.15.1",
]
[[package]]
name = "logos-derive"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52d3a9855747c17eaf4383823f135220716ab49bea5fbea7dd42cc9a92f8aa31"
dependencies = [
"logos-codegen 0.16.1",
]
[[package]]
name = "lru"
version = "0.18.5"
@@ -2526,6 +2728,28 @@ dependencies = [
"libc",
]
[[package]]
name = "miette"
version = "7.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7"
dependencies = [
"cfg-if",
"miette-derive",
"unicode-width 0.1.14",
]
[[package]]
name = "miette-derive"
version = "7.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "mimalloc"
version = "0.1.52"
@@ -2579,6 +2803,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "multimap"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084"
[[package]]
name = "multiversion_no_op"
version = "1.0.0"
@@ -3159,6 +3389,15 @@ dependencies = [
"objc2-foundation 0.2.2",
]
[[package]]
name = "object"
version = "0.39.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b"
dependencies = [
"memchr",
]
[[package]]
name = "oid-registry"
version = "0.8.1"
@@ -3490,6 +3729,16 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa"
[[package]]
name = "prettyplease"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn 2.0.119",
]
[[package]]
name = "proc-macro-crate"
version = "3.5.0"
@@ -3514,6 +3763,96 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
[[package]]
name = "prost"
version = "0.14.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1"
dependencies = [
"bytes",
"prost-derive",
]
[[package]]
name = "prost-build"
version = "0.14.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042"
dependencies = [
"heck",
"itertools",
"log",
"multimap",
"petgraph",
"prettyplease",
"prost",
"prost-types",
"regex",
"syn 2.0.119",
"tempfile",
]
[[package]]
name = "prost-derive"
version = "0.14.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf"
dependencies = [
"anyhow",
"itertools",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "prost-reflect"
version = "0.16.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01b80ea363c31af2de2b92e3c07ed1156628f7838c4afb4df75ee78a37fedbd1"
dependencies = [
"logos 0.16.1",
"miette",
"prost",
"prost-types",
]
[[package]]
name = "prost-types"
version = "0.14.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a"
dependencies = [
"prost",
]
[[package]]
name = "protox"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f25a07a73c6717f0b9bbbd685918f5df9815f7efba450b83d9c9dea41f0e3a1"
dependencies = [
"bytes",
"miette",
"prost",
"prost-reflect",
"prost-types",
"protox-parse",
"thiserror 2.0.21",
]
[[package]]
name = "protox-parse"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "072eee358134396a4643dff81cfff1c255c9fbd3fb296be14bdb6a26f9156366"
dependencies = [
"logos 0.15.1",
"miette",
"prost-types",
"thiserror 2.0.21",
]
[[package]]
name = "quick-error"
version = "2.0.1"
@@ -3665,6 +4004,26 @@ dependencies = [
"objc2-quartz-core 0.3.2",
]
[[package]]
name = "rayon"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
]
[[package]]
name = "rc2"
version = "0.8.1"
@@ -3793,6 +4152,12 @@ dependencies = [
"smallvec",
]
[[package]]
name = "rustc-demangle"
version = "0.1.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb"
[[package]]
name = "rustc-hash"
version = "1.1.0"
@@ -4351,6 +4716,18 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"once_cell",
"rustix 1.1.5",
"windows-sys 0.61.2",
]
[[package]]
name = "termcolor"
version = "1.4.1"
@@ -4620,6 +4997,12 @@ version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
[[package]]
name = "unicode-width"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
[[package]]
name = "unicode-width"
version = "0.2.2"
+3
View File
@@ -37,6 +37,9 @@ members = [
"crates/horizon-discord",
"crates/horizon-official-servers",
"crates/horizon-bench",
"crates/horizon-perf-format",
"crates/horizon-perf",
"crates/horizon-perf-share",
"crates/horizon-friends",
"crates/horizon-relay",
"crates/horizon-mods",
+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
+6
View File
@@ -35,6 +35,12 @@ pub const COSMETICS_API: &str = "https://cosmetics.playhorizon.dev";
/// "I'm playing" ping, benchmark uploads (`HORIZON_TELEMETRY_URL` overrides it).
pub const TELEMETRY: &str = "https://telemetry.playhorizon.dev";
/// The performance viewer page a saved capture opens in (`HORIZON_PERF_VIEWER` overrides it).
pub const PERF_VIEWER: &str = "https://playhorizon.dev/perf";
/// The capture upload API behind Share (`HORIZON_PERF_CAPTURES` overrides it).
pub const PERF_CAPTURES: &str = "https://playhorizon.dev/api/perf/captures";
/// Public STUN servers for Play with Friends' NAT discovery: two independent operators, so one
/// outage doesn't break discovery (`HORIZON_STUN_SERVERS` overrides them).
pub const STUN_SERVERS: &[&str] = &["stun.cloudflare.com:3478", "stun.l.google.com:19302"];
+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();
+8 -9
View File
@@ -1764,14 +1764,13 @@ fn stack_from_template(template: &Value) -> Option<StackView> {
/// `RangeSelectItemModel.lastIndexLessOrEqual`.
fn last_index_less_or_equal(entries: &[(f32, ItemModelNode)], needle: f32) -> Option<usize> {
let haystack: Vec<f32> = entries.iter().map(|e| e.0).collect();
let found = if haystack.len() < 16 {
haystack
let found = if entries.len() < 16 {
entries
.iter()
.position(|h| *h > needle)
.map_or(haystack.len() as i64 - 1, |i| i as i64 - 1)
.position(|e| e.0 > needle)
.map_or(entries.len() as i64 - 1, |i| i as i64 - 1)
} else {
match binary_search(&haystack, needle) {
match binary_search(entries, needle) {
Ok(index) => index as i64,
Err(insertion) => insertion as i64 - 1,
}
@@ -1780,8 +1779,8 @@ fn last_index_less_or_equal(entries: &[(f32, ItemModelNode)], needle: f32) -> Op
}
/// `Arrays.binarySearch(float[], float)`, including its handling of equal
/// values (-0.0 below 0.0, NaN above everything).
fn binary_search(a: &[f32], key: f32) -> std::result::Result<usize, usize> {
let (mut low, mut high) = (0i64, a.len() as i64 - 1);
fn binary_search(entries: &[(f32, ItemModelNode)], key: f32) -> std::result::Result<usize, usize> {
let (mut low, mut high) = (0i64, entries.len() as i64 - 1);
let bits = |v: f32| -> i32 {
if v.is_nan() {
0x7fc0_0000
@@ -1791,7 +1790,7 @@ fn binary_search(a: &[f32], key: f32) -> std::result::Result<usize, usize> {
};
while low <= high {
let mid = (low + high) >> 1;
let value = a[mid as usize];
let value = entries[mid as usize].0;
if value < key {
low = mid + 1;
} else if value > key {
@@ -158,3 +158,61 @@ fn a_patched_stack_reads_its_patch_over_its_items_defaults() {
}
assert_eq!(view.to_map(), *merged);
}
#[test]
fn last_index_less_or_equal_linear_and_binary() {
use super::last_index_less_or_equal;
let make = |thresholds: &[f32]| -> Vec<(f32, ItemModelNode)> {
thresholds
.iter()
.map(|&t| (t, ItemModelNode::Empty))
.collect()
};
// Empty
assert_eq!(last_index_less_or_equal(&[], 0.0), None);
// Short (< 16): linear search
let short = make(&[-1.0, 0.0, 1.0, 2.5, 5.0]);
assert_eq!(last_index_less_or_equal(&short, -2.0), None);
assert_eq!(last_index_less_or_equal(&short, -1.0), Some(0));
assert_eq!(last_index_less_or_equal(&short, -0.5), Some(0));
assert_eq!(last_index_less_or_equal(&short, 0.0), Some(1));
assert_eq!(last_index_less_or_equal(&short, 2.5), Some(3));
assert_eq!(last_index_less_or_equal(&short, 3.0), Some(3));
assert_eq!(last_index_less_or_equal(&short, 5.0), Some(4));
assert_eq!(last_index_less_or_equal(&short, 10.0), Some(4));
// Long (>= 16): binary search
let thresholds: Vec<f32> = (0..32).map(|i| i as f32 * 10.0).collect();
let long = make(&thresholds);
assert_eq!(last_index_less_or_equal(&long, -5.0), None);
assert_eq!(last_index_less_or_equal(&long, 0.0), Some(0));
assert_eq!(last_index_less_or_equal(&long, 5.0), Some(0));
assert_eq!(last_index_less_or_equal(&long, 10.0), Some(1));
assert_eq!(last_index_less_or_equal(&long, 150.0), Some(15));
assert_eq!(last_index_less_or_equal(&long, 155.0), Some(15));
assert_eq!(last_index_less_or_equal(&long, 310.0), Some(31));
assert_eq!(last_index_less_or_equal(&long, 1000.0), Some(31));
// Short lists use upper-bound tie behavior, including signed zero.
let duplicates = make(&[1.0, 1.0, 2.0]);
assert_eq!(last_index_less_or_equal(&duplicates, 1.0), Some(1));
let zeroes = make(&[-0.0, 0.0]);
assert_eq!(last_index_less_or_equal(&zeroes, -0.0), Some(1));
assert_eq!(last_index_less_or_equal(&zeroes, 0.0), Some(1));
// At 16 entries, vanilla returns Arrays.binarySearch's hit, not the last tie.
// Expected indices were checked on the JVM.
let duplicates = make(&[
0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 7.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0, 13.0,
]);
assert_eq!(last_index_less_or_equal(&duplicates, 7.0), Some(7));
let zeroes = make(&[
-8.0, -7.0, -6.0, -5.0, -4.0, -3.0, -2.0, -0.0, -0.0, 0.0, 0.0, 1.0, 2.0, 3.0, 4.0, 5.0,
]);
assert_eq!(last_index_less_or_equal(&zeroes, -0.0), Some(7));
assert_eq!(last_index_less_or_equal(&zeroes, 0.0), Some(9));
}
+4 -2
View File
@@ -9,5 +9,7 @@ description = "The benchmark report format (docs/benchmark-report.md), shared by
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# The hardware probes shared with telemetry (cores, clocks, drive, VRAM, power, form).
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
# The hardware probes (cores, clocks, drive, VRAM, power, form); a reader of reports (the wasm
# build of horizon-perf) needs the types below, never the probes.
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
horizon-system = { path = "../horizon-system" }
+434
View File
@@ -0,0 +1,434 @@
//! Paired A/B comparison of benchmark blocks (docs/benchmark-report.md, "A/B runs").
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::{round, Frames};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Verdict {
TooFewPairs,
WithinNoise,
Negligible,
Slower,
Faster,
}
/// B against A over paired blocks; lower milliseconds are better.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Paired {
pub pairs: usize,
pub a: f64,
pub b: f64,
/// Hodges-Lehmann estimate of b - a.
pub diff: f64,
/// Absent when `a` is zero.
pub diff_pct: Option<f64>,
/// Exact Wilcoxon signed-rank interval; absent below 6 pairs.
pub ci95: Option<[f64; 2]>,
pub confidence: Option<f64>,
/// Median distance between neighbouring blocks of the same arm.
pub noise: Option<f64>,
pub verdict: Verdict,
}
type Getter = fn(&Frames) -> Option<f64>;
fn median(values: &[f64]) -> f64 {
let mut v = values.to_vec();
v.sort_by(f64::total_cmp);
match v.len() {
0 => 0.0,
n if n % 2 == 1 => v[n / 2],
n => (v[n / 2 - 1] + v[n / 2]) / 2.0,
}
}
fn round4(v: f64) -> f64 {
(v * 10_000.0).round() / 10_000.0
}
/// `(a, b)` per pair, `noise` left for the caller.
pub fn paired(pairs: &[(f64, f64)]) -> Paired {
let n = pairs.len();
let a = median(&pairs.iter().map(|p| p.0).collect::<Vec<_>>());
let b = median(&pairs.iter().map(|p| p.1).collect::<Vec<_>>());
let d: Vec<f64> = pairs.iter().map(|p| p.1 - p.0).collect();
let mut walsh = Vec::with_capacity(n * (n + 1) / 2);
for i in 0..n {
for j in i..n {
walsh.push((d[i] + d[j]) / 2.0);
}
}
walsh.sort_by(f64::total_cmp);
let diff = median(&walsh);
let interval = interval(n, &walsh);
let verdict = match interval {
None => Verdict::TooFewPairs,
Some(([lo, hi], _)) if lo <= 0.0 && 0.0 <= hi => Verdict::WithinNoise,
Some(_) if diff.abs() < 0.05_f64.max(0.02 * a) => Verdict::Negligible,
Some(([lo, _], _)) if lo > 0.0 => Verdict::Slower,
Some(_) => Verdict::Faster,
};
Paired {
pairs: n,
a: round(a) + 0.0,
b: round(b) + 0.0,
diff: round(diff) + 0.0,
diff_pct: (a != 0.0).then(|| round(100.0 * diff / a) + 0.0),
ci95: interval.map(|([lo, hi], _)| [round(lo) + 0.0, round(hi) + 0.0]),
confidence: interval.map(|(_, c)| round4(c)),
noise: None,
verdict,
}
}
/// The widest symmetric interval of the sorted Walsh averages whose tails hold at most 2.5% each, and its confidence.
fn interval(n: usize, walsh: &[f64]) -> Option<([f64; 2], f64)> {
let m = walsh.len();
// Null probabilities of T+ (halving is exact in binary, so these are the counts over 2^n).
let mut p = vec![0.0f64; m + 1];
p[0] = 1.0;
for r in 1..=n {
for t in (0..=m).rev() {
p[t] = (p[t] + if t >= r { p[t - r] } else { 0.0 }) / 2.0;
}
}
let (mut k, mut tail, mut best) = (0, 0.0, 0.0);
while k < m {
tail += p[k];
if tail > 0.025 {
break;
}
k += 1;
best = tail;
}
(k >= 1).then(|| ([walsh[k - 1], walsh[m - k]], 1.0 - 2.0 * best))
}
/// The result of an A/B run: pairs of blocks in B A A B order, recomputable from the report's `frames`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Ab {
pub switch: String,
pub a: String,
pub b: String,
pub pairs: u32,
pub block_s: f64,
pub warmup_s: f64,
/// Block 0 (measured before any switch) lies outside the later B blocks.
pub first_block_outside_range: bool,
/// By `gpu_p50_ms`, `cpu_p50_ms`, `frame_p50_ms`; a metric missing from any block is left out.
pub result: BTreeMap<String, Paired>,
}
impl Ab {
/// Pairs the `ab.a`/`ab.b` entries of `frames` by `repeat` (0, 1, 2...): pair k is blocks 2k and 2k+1.
pub fn from_frames(
switch: &str,
a: &str,
b: &str,
block_s: f64,
warmup_s: f64,
frames: &[Frames],
) -> Result<Ab, String> {
let mut blocks: Vec<&Frames> = frames
.iter()
.filter(|f| f.phase == "ab.a" || f.phase == "ab.b")
.collect();
blocks.sort_by_key(|f| f.repeat);
if blocks.is_empty()
|| !blocks.len().is_multiple_of(2)
|| blocks
.iter()
.enumerate()
.any(|(i, f)| f.repeat as usize != i)
{
return Err("A/B blocks must be numbered 0..N with N even".to_owned());
}
if blocks.chunks(2).any(|p| p[0].phase == p[1].phase) {
return Err("each pair needs one ab.a and one ab.b block".to_owned());
}
let pairs = blocks.len() / 2;
let metrics: [(&str, Getter); 3] = [
("gpu_p50_ms", |f| f.gpu_p50_ms),
("cpu_p50_ms", |f| f.cpu_p50_ms),
("frame_p50_ms", |f| Some(f.p50_ms)),
];
let mut result = BTreeMap::new();
let mut series: BTreeMap<&str, Vec<f64>> = BTreeMap::new();
for (name, get) in metrics {
let Some(values) = blocks.iter().map(|f| get(f)).collect::<Option<Vec<f64>>>() else {
continue;
};
let by_arm: Vec<(f64, f64)> = blocks
.chunks(2)
.zip(values.chunks(2))
.map(|(p, v)| {
if p[0].phase == "ab.a" {
(v[0], v[1])
} else {
(v[1], v[0])
}
})
.collect();
let mut stat = paired(&by_arm);
let gaps: Vec<f64> = (1..values.len().saturating_sub(1))
.step_by(2)
.filter(|&i| blocks[i].phase == blocks[i + 1].phase)
.map(|i| (values[i] - values[i + 1]).abs())
.collect();
stat.noise = (!gaps.is_empty()).then(|| round(median(&gaps)) + 0.0);
result.insert(name.to_owned(), stat);
series.insert(name, values);
}
let first_block_outside_range = series
.get("gpu_p50_ms")
.or_else(|| series.get("frame_p50_ms"))
.is_some_and(|values| {
let later: Vec<f64> = (1..values.len())
.filter(|&i| blocks[i].phase == "ab.b")
.map(|i| values[i])
.collect();
blocks[0].phase == "ab.b"
&& !later.is_empty()
&& (values[0] < later.iter().copied().fold(f64::INFINITY, f64::min)
|| values[0] > later.iter().copied().fold(f64::NEG_INFINITY, f64::max))
});
Ok(Ab {
switch: switch.to_owned(),
a: a.to_owned(),
b: b.to_owned(),
pairs: pairs as u32,
block_s,
warmup_s,
first_block_outside_range,
result,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{App, Kind, Report};
// Expected values: exact-rational Walsh averages and a brute-force sign enumeration, run once outside this code.
fn check(
a: &[f64],
b: &[f64],
diff: f64,
ci: Option<[f64; 2]>,
conf: Option<f64>,
verdict: Verdict,
) {
let pairs: Vec<(f64, f64)> = a.iter().copied().zip(b.iter().copied()).collect();
let p = paired(&pairs);
assert_eq!(
(p.pairs, p.diff, p.ci95, p.confidence, p.verdict),
(a.len(), diff, ci, conf, verdict),
"{b:?}"
);
}
#[test]
fn the_decided_vectors() {
use Verdict::*;
let z = |n: usize| vec![0.0; n];
let up = |n: usize| (1..=n).map(|v| v as f64).collect::<Vec<_>>();
check(&z(8), &up(8), 4.5, Some([2.0, 7.0]), Some(0.9609), Slower);
let wobble = [-0.3, 0.2, -0.1, 0.4, -0.2, 0.1, 0.3, -0.4];
check(
&z(8),
&wobble,
0.0,
Some([-0.3, 0.3]),
Some(0.9609),
WithinNoise,
);
check(&z(6), &up(6), 3.5, Some([1.0, 6.0]), Some(0.9688), Slower);
check(&z(5), &up(5), 3.0, None, None, TooFewPairs);
let a = [3.65, 3.70, 3.62, 3.68, 3.66, 3.71, 3.63, 3.69];
let b = [5.63, 5.60, 5.70, 5.58, 5.65, 5.62, 5.66, 5.61];
check(&a, &b, 1.96, Some([1.905, 2.03]), Some(0.9609), Slower);
let tiny: Vec<f64> = (1..=8).map(|v| 10.0 + v as f64 / 100.0).collect();
check(
&[10.0; 8],
&tiny,
0.045,
Some([0.02, 0.07]),
Some(0.9609),
Negligible,
);
let down: Vec<f64> = up(8).iter().map(|_| 0.0).collect();
check(
&up(8),
&down,
-4.5,
Some([-7.0, -2.0]),
Some(0.9609),
Faster,
);
let mixed = [0.5, -0.2, 0.0, 0.3, 0.9, -0.1, 0.4];
check(
&z(7),
&mixed,
0.225,
Some([-0.1, 0.65]),
Some(0.9531),
WithinNoise,
);
let ten = [0.3, -0.1, 0.8, 0.2, -0.5, 0.6, 0.1, 0.4, 0.7, 0.05];
check(
&z(10),
&ten,
0.25,
Some([-0.025, 0.55]),
Some(0.9512),
WithinNoise,
);
}
#[test]
fn medians_percent_and_empty_input() {
let a = [3.65, 3.70, 3.62, 3.68, 3.66, 3.71, 3.63, 3.69];
let b = [5.63, 5.60, 5.70, 5.58, 5.65, 5.62, 5.66, 5.61];
let p = paired(&a.iter().copied().zip(b).collect::<Vec<_>>());
assert_eq!(
(p.a, p.b, p.diff_pct, p.noise),
(3.67, 5.625, Some(53.406), None)
);
assert_eq!(
paired(&[(0.0, 1.0); 8]).diff_pct,
None,
"no percent of zero"
);
assert_eq!(paired(&[]).verdict, Verdict::TooFewPairs);
}
fn block(phase: &str, repeat: u32, frame: f64, cpu: Option<f64>, gpu: Option<f64>) -> Frames {
Frames {
phase: phase.into(),
repeat,
frames: 100,
seconds: 4.0,
avg_fps: 250.0,
low_1pct_fps: 100.0,
low_01pct_fps: 90.0,
p50_ms: frame,
p95_ms: frame + 1.0,
p99_ms: frame + 2.0,
p999_ms: frame + 3.0,
max_ms: frame + 4.0,
cpu_p50_ms: cpu,
gpu_p50_ms: gpu,
histogram: vec![0; crate::HISTOGRAM_EDGES_MS.len()],
}
}
// B A A B B A A B B A A B, as gpu milliseconds; frame time is gpu + 1.
fn twelve(first_b: f64) -> Vec<Frames> {
let gpu = [
first_b, 3.0, 3.2, 5.0, 5.2, 3.1, 3.1, 5.1, 5.4, 3.4, 3.3, 5.3,
];
gpu.iter()
.enumerate()
.map(|(i, &g)| {
block(
if matches!(i % 4, 0 | 3) {
"ab.b"
} else {
"ab.a"
},
i as u32,
g + 1.0,
Some(g / 2.0),
Some(g),
)
})
.collect()
}
fn from(frames: &[Frames]) -> Result<Ab, String> {
Ab::from_frames("gpu-culling", "off", "on", 4.0, 2.0, frames)
}
#[test]
fn noise_and_the_first_block_flag() {
let ab = from(&twelve(6.4)).unwrap();
assert!(
ab.first_block_outside_range,
"6.4 is 1 ms above the other B blocks"
);
let gpu = &ab.result["gpu_p50_ms"];
// Neighbours (1,2) (3,4) (5,6) (7,8) (9,10): 0.2 0.2 0 0.3 0.1.
assert_eq!(
(gpu.pairs, gpu.a, gpu.b, gpu.noise),
(6, 3.15, 5.25, Some(0.2))
);
assert_eq!(
(gpu.ci95, gpu.verdict),
(Some([1.8, 3.4]), Verdict::Slower),
"every d is positive: min and max Walsh averages"
);
assert_eq!(ab.result["cpu_p50_ms"].noise, Some(0.1));
assert_eq!(
(ab.pairs, ab.switch.as_str(), ab.a.as_str(), ab.b.as_str()),
(6, "gpu-culling", "off", "on")
);
assert!(!from(&twelve(5.2)).unwrap().first_block_outside_range);
}
#[test]
fn a_metric_missing_from_one_block_is_left_out() {
let mut frames = twelve(5.2);
frames[7].gpu_p50_ms = None;
let ab = from(&frames).unwrap();
assert_eq!(
ab.result.keys().collect::<Vec<_>>(),
["cpu_p50_ms", "frame_p50_ms"]
);
let mut frames = twelve(6.4);
frames[0].gpu_p50_ms = None;
frames[0].p50_ms = 6.0;
assert!(
!from(&frames).unwrap().first_block_outside_range,
"frame time 6.0 is inside 6.0..6.4 of the later B blocks"
);
}
#[test]
fn blocks_that_do_not_pair_are_refused() {
assert!(from(&[]).is_err());
assert!(from(&twelve(5.2)[..11]).is_err(), "odd number of blocks");
let mut frames = twelve(5.2);
frames[1].phase = "ab.b".into();
assert!(from(&frames).is_err(), "a pair of two B blocks");
let mut frames = twelve(5.2);
frames[3].repeat = 9;
assert!(from(&frames).is_err(), "a gap in the numbering");
}
#[test]
fn a_report_with_ab_round_trips_and_old_ones_still_parse() {
let mut r = Report::new(Kind::Game, App::default());
let old = r.to_json();
assert!(!old.contains("\"ab\""));
assert_eq!(Report::parse(&old).unwrap().ab, None);
r.frames = twelve(5.2);
r.ab = Some(from(&r.frames).unwrap());
let json = r.to_json();
assert_eq!(Report::parse(&json).unwrap(), r);
assert!(
json.contains("\"first_block_outside_range\": false")
&& json.contains("\"verdict\": \"slower\"")
);
assert_eq!(r.for_upload().ab, r.ab);
let mut few = paired(&[(1.0, 2.0); 3]);
few.noise = None;
assert!(serde_json::to_string(&few)
.unwrap()
.contains("\"ci95\":null,\"confidence\":null"));
}
}
+20 -125
View File
@@ -1,7 +1,8 @@
//! The machine a report was measured on. CPU, RAM and OS are detected here; the GPU and the display
//! come from the program that rendered (the game knows its adapter, the launcher its window).
use horizon_telemetry::system::command_output as output;
#[cfg(any(target_os = "macos", target_os = "linux"))]
use horizon_system::command_output as output;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
@@ -61,6 +62,7 @@ pub struct Display {
pub refresh_hz: Option<u32>,
}
#[cfg(not(target_arch = "wasm32"))]
impl Hardware {
pub fn detect() -> Hardware {
let probes = probes();
@@ -68,12 +70,12 @@ impl Hardware {
os: std::env::consts::OS.to_owned(),
os_version: os_version(),
arch: std::env::consts::ARCH.to_owned(),
cpu: cpu(),
cpu: horizon_system::cpu(),
cpu_threads: std::thread::available_parallelism().ok().map(|n| n.get()),
cpu_cores: probes.cores,
cpu_base_mhz: probes.base,
cpu_boost_mhz: probes.boost,
ram_mb: ram_bytes().map(|b| b >> 20),
ram_mb: horizon_system::ram_bytes().map(|b| b >> 20),
storage: None,
form: probes.form,
gpu: None,
@@ -84,13 +86,14 @@ impl Hardware {
/// [`Hardware::detect`] with the kind of drive `game_dir` is on.
pub fn detect_in(game_dir: &std::path::Path) -> Hardware {
Hardware {
storage: horizon_telemetry::system::storage(game_dir).map(str::to_owned),
storage: horizon_system::storage(game_dir).map(str::to_owned),
..Hardware::detect()
}
}
}
/// What the telemetry probes read (`horizon_telemetry::system`).
#[cfg(not(target_arch = "wasm32"))]
/// What `horizon_system::Machine` reads.
struct Probes {
cores: Option<u16>,
base: Option<u32>,
@@ -98,95 +101,31 @@ struct Probes {
form: Option<String>,
}
#[cfg(not(target_arch = "wasm32"))]
fn probes() -> Probes {
let s = horizon_telemetry::payload::System::detect();
let s = horizon_system::Machine::detect();
let some = |n: u32| (n > 0).then_some(n);
Probes {
cores: (s.cores > 0).then_some(s.cores),
base: some(s.cpu_base_mhz),
boost: some(s.cpu_boost_mhz),
form: (!s.form.is_empty()).then_some(s.form),
}
}
/// The one-minute load average: above about the number of threads the machine was busy.
pub fn load_average() -> Option<f64> {
if cfg!(target_os = "macos") {
// "{ 1.23 1.10 1.00 }"
output("sysctl", &["-n", "vm.loadavg"])?
.trim_start_matches('{')
.split_whitespace()
.next()?
.parse()
.ok()
} else {
std::fs::read_to_string("/proc/loadavg")
.ok()?
.split_whitespace()
.next()?
.parse()
.ok()
form: (!s.form.is_empty()).then(|| s.form.to_owned()),
}
}
#[cfg(not(target_arch = "wasm32"))]
/// `ac` or `battery`; `None` where it can't be read (and on machines without a battery on Linux).
pub fn power_source() -> Option<&'static str> {
horizon_telemetry::system::power()
}
/// Resident memory of a process, in bytes (macOS, Linux).
pub fn rss_bytes(pid: u32) -> Option<u64> {
if cfg!(target_os = "macos") {
return output("ps", &["-o", "rss=", "-p", &pid.to_string()])?
.parse::<u64>()
.ok()
.map(|kb| kb * 1024);
}
let status = std::fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
let line = status.lines().find(|l| l.starts_with("VmRSS:"))?;
line.split_whitespace()
.nth(1)?
.parse::<u64>()
.ok()
.map(|kb| kb * 1024)
}
/// CPU time a process has used, user and system, in seconds (macOS, Linux).
pub fn cpu_seconds(pid: u32) -> Option<f64> {
if cfg!(target_os = "macos") {
// "[[dd-]hh:]mm:ss.cc"
let time = output("ps", &["-o", "time=", "-p", &pid.to_string()])?;
let (rest, days) = match time.split_once('-') {
Some((d, rest)) => (rest.to_owned(), d.parse::<f64>().ok()?),
None => (time, 0.0),
};
let seconds = rest.split(':').try_fold(0.0, |acc, part| {
part.parse::<f64>().ok().map(|v| acc * 60.0 + v)
})?;
return Some(days * 86_400.0 + seconds);
}
// utime and stime, fields 14 and 15, in clock ticks (100 per second on Linux).
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
let fields: Vec<&str> = stat.rsplit_once(')')?.1.split_whitespace().collect();
let ticks: f64 = fields.get(11)?.parse::<f64>().ok()? + fields.get(12)?.parse::<f64>().ok()?;
Some(ticks / 100.0)
horizon_system::power()
}
#[cfg(not(target_arch = "wasm32"))]
#[cfg(target_os = "macos")]
fn os_version() -> Option<String> {
output("sw_vers", &["-productVersion"])
}
#[cfg(target_os = "macos")]
fn cpu() -> Option<String> {
output("sysctl", &["-n", "machdep.cpu.brand_string"])
}
#[cfg(target_os = "macos")]
fn ram_bytes() -> Option<u64> {
output("sysctl", &["-n", "hw.memsize"])?.parse().ok()
}
#[cfg(not(target_arch = "wasm32"))]
#[cfg(target_os = "linux")]
fn os_version() -> Option<String> {
let release = std::fs::read_to_string("/etc/os-release").ok()?;
@@ -199,22 +138,7 @@ fn os_version() -> Option<String> {
})
}
#[cfg(target_os = "linux")]
fn cpu() -> Option<String> {
let info = std::fs::read_to_string("/proc/cpuinfo").ok()?;
// x86 names the CPU per core; arm64 only gives the core's implementer and part.
horizon_telemetry::system::linux_cpu_name(&info)
}
#[cfg(target_os = "linux")]
fn ram_bytes() -> Option<u64> {
let info = std::fs::read_to_string("/proc/meminfo").ok()?;
let kb = key_value(&info, "MemTotal", ':')?
.trim_end_matches("kB")
.trim();
kb.parse::<u64>().ok().map(|kb| kb * 1024)
}
#[cfg(not(target_arch = "wasm32"))]
#[cfg(target_os = "linux")]
fn key_value<'a>(text: &'a str, key: &str, sep: char) -> Option<&'a str> {
text.lines().find_map(|line| {
@@ -223,45 +147,19 @@ fn key_value<'a>(text: &'a str, key: &str, sep: char) -> Option<&'a str> {
})
}
#[cfg(not(target_arch = "wasm32"))]
#[cfg(target_os = "windows")]
fn os_version() -> Option<String> {
horizon_telemetry::system::windows_version()
}
#[cfg(target_os = "windows")]
fn cpu() -> Option<String> {
horizon_telemetry::system::windows_cpu()
}
#[cfg(target_os = "windows")]
fn ram_bytes() -> Option<u64> {
let out = output(
"powershell",
&[
"-NoProfile",
"-Command",
"(Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory",
],
)?;
out.parse().ok()
horizon_system::windows_version()
}
#[cfg(not(target_arch = "wasm32"))]
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
fn os_version() -> Option<String> {
None
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
fn cpu() -> Option<String> {
None
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
fn ram_bytes() -> Option<u64> {
None
}
#[cfg(test)]
#[cfg(all(test, not(target_arch = "wasm32")))]
mod tests {
use super::*;
@@ -274,9 +172,6 @@ mod tests {
assert!(hw.cpu.is_some(), "{hw:?}");
assert!(hw.ram_mb.unwrap() > 256, "{hw:?}");
assert!(hw.os_version.is_some(), "{hw:?}");
assert!(load_average().is_some());
assert!(rss_bytes(std::process::id()).unwrap() > 1 << 20);
assert!(cpu_seconds(std::process::id()).unwrap() >= 0.0);
}
}
+12 -1
View File
@@ -11,9 +11,15 @@ use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
pub mod compare;
mod hardware;
pub use hardware::{cpu_seconds, load_average, power_source, rss_bytes, Display, Gpu, Hardware};
#[cfg(not(target_arch = "wasm32"))]
pub use hardware::power_source;
pub use hardware::{Display, Gpu, Hardware};
/// The process probes, from the platform (`horizon-system`).
#[cfg(not(target_arch = "wasm32"))]
pub use horizon_system::{cpu_seconds, load_average, rss_bytes};
pub const FORMAT: &str = "horizon-benchmark-report";
pub const VERSION: u32 = 1;
@@ -234,6 +240,9 @@ pub struct Report {
pub metrics: BTreeMap<String, Summary>,
#[serde(default)]
pub frames: Vec<Frames>,
/// Present in A/B runs only.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ab: Option<compare::Ab>,
/// Local only: a name the person running it chose.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
@@ -244,6 +253,7 @@ pub struct Report {
impl Report {
/// An empty report for this machine, created now.
#[cfg(not(target_arch = "wasm32"))]
pub fn new(kind: Kind, app: App) -> Report {
Self::with_hardware(kind, app, Hardware::detect())
}
@@ -261,6 +271,7 @@ impl Report {
conditions: Conditions::default(),
metrics: BTreeMap::new(),
frames: Vec::new(),
ab: None,
label: None,
local: Vec::new(),
}
+1 -1
View File
@@ -7,7 +7,7 @@ rust-version.workspace = true
description = "The dedicated server's chat filter: its [chat_filter] settings, the slur list's matcher and the mutes, with no game state (docs/chat-filter.md)"
[dependencies]
# The moderation durations, shared with `/tempban`.
# The hook the hub asks (`chat_guard`), and the moderation durations.
horizon-server-files = { path = "../horizon-server-files" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+136 -36
View File
@@ -1,8 +1,8 @@
//! The dedicated server's chat filter (docs/chat-filter.md), without the game: `server.toml`'s
//! `[chat_filter]`, the matcher for the slur list (`data/slurs.txt`, or the owner's `words_file`), and
//! the escalation (a refused message, then a mute, then a temporary ban), with the mutes kept in a file
//! so that they outlive a restart. The server's hub (`horizon_client::lan`) asks it before it relays a
//! player's text and carries out what it decides.
//! so that they outlive a restart. The server's hub (`horizon_client::lan`) asks it, through the engine's
//! `horizon_server_files::chat_guard` hook, before it relays a player's text, and carries out what it decides.
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
@@ -10,6 +10,8 @@ use std::path::{Path, PathBuf};
mod normalise;
use horizon_server_files::chat_guard::{self, ChatGuard, Loaded, Place};
pub use horizon_server_files::chat_guard::{Action, Mute};
pub use horizon_server_files::duration::{
DurationUnit, duration_parts, duration_text, now_millis, parse_duration,
};
@@ -221,49 +223,59 @@ fn fits(entry: &[Run], runs: &[Run], whole: bool) -> bool {
.all(|(e, r)| e.letter == r.letter && r.count >= e.count)
}
/// What a hit leads to.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Action {
/// The text is refused, nothing more.
Refused,
/// Refused, and the player can't chat until then (epoch milliseconds).
Muted { until: i64 },
/// Refused, and the player is banned until then.
Banned { until: i64 },
/// A mute as the mutes file keeps it.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
struct SavedMute {
uuid: String,
name: String,
until: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
by: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
reason: Option<String>,
}
impl Action {
pub fn name(self) -> &'static str {
match self {
Action::Refused => "refused",
Action::Muted { .. } => "muted",
Action::Banned { .. } => "banned",
impl From<SavedMute> for Mute {
fn from(m: SavedMute) -> Self {
let SavedMute {
uuid,
name,
until,
by,
reason,
} = m;
Mute {
uuid,
name,
until,
by,
reason,
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Mute {
pub uuid: String,
pub name: String,
/// Epoch milliseconds; `None`: until `/unmute`.
pub until: Option<i64>,
/// The operator who muted them (`/mute`, `/tempmute`); `None`: the filter.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub by: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
impl Mute {
pub fn active(&self, now: i64) -> bool {
self.until.is_none_or(|until| until > now)
impl From<Mute> for SavedMute {
fn from(m: Mute) -> Self {
let Mute {
uuid,
name,
until,
by,
reason,
} = m;
SavedMute {
uuid,
name,
until,
by,
reason,
}
}
}
#[derive(Default, Serialize, Deserialize)]
struct MutesFile {
mutes: Vec<Mute>,
mutes: Vec<SavedMute>,
}
/// One player's hits: how many, and when the last one stops counting.
@@ -293,7 +305,7 @@ impl Guard {
{
for m in saved.mutes {
if let Some(uuid) = parse_uuid(&m.uuid) {
mutes.insert(uuid, m);
mutes.insert(uuid, m.into());
}
}
}
@@ -431,7 +443,7 @@ impl Guard {
};
let now = now_millis();
self.mutes.retain(|_, m| m.active(now));
let mut mutes: Vec<Mute> = self.mutes.values().cloned().collect();
let mut mutes: Vec<SavedMute> = self.mutes.values().cloned().map(Into::into).collect();
mutes.sort_by(|a, b| a.name.cmp(&b.name));
if let Ok(text) = serde_json::to_string_pretty(&MutesFile { mutes }) {
let tmp = file.with_extension("json.tmp");
@@ -442,6 +454,94 @@ impl Guard {
}
}
impl ChatGuard for Guard {
fn matches(&self, text: &str) -> bool {
self.filter.matches(text)
}
fn muted(&self, uuid: u128, now: i64) -> Option<&Mute> {
Guard::muted(self, uuid, now)
}
fn mutes(&self, now: i64) -> Vec<&Mute> {
Guard::mutes(self, now)
}
fn mute(
&mut self,
uuid: u128,
name: &str,
until: Option<i64>,
by: &str,
reason: Option<String>,
) {
Guard::mute(self, uuid, name, until, by, reason);
}
fn strike(&mut self, uuid: u128, name: &str, now: i64) -> (u32, Action) {
Guard::strike(self, uuid, name, now)
}
fn unmute(&mut self, name: &str) -> Option<String> {
Guard::unmute(self, name)
}
fn log(
&mut self,
now: i64,
name: &str,
uuid: u128,
place: Place,
count: u32,
action: Action,
text: &str,
) -> Result<(), String> {
let path = &self.config.log;
if path.is_empty() {
return Ok(());
}
let line = log_line(now, name, uuid, place.name(), count, action, text);
append_log(Path::new(path), &line).map_err(|e| format!("{path}: {e}"))
}
}
/// `server.toml`'s `[chat_filter]` for the dedicated server ([`chat_guard::install`] it at startup): on
/// by default, off (with an error logged) when the section is wrong. The mutes are kept in `dir`.
pub fn load(server_toml: &str, dir: &Path) -> Loaded {
let mut loaded = Loaded::default();
let config = match Config::from_server_toml(server_toml) {
Ok(config) => config,
Err(e) => {
loaded.log.push((
"ERROR",
format!("server.toml: {e}; the chat filter stays off"),
));
return loaded;
}
};
if !config.enabled {
return loaded;
}
let (guard, problem) = Guard::load(config, dir);
if let Some(problem) = problem {
loaded.log.push(("WARN", problem));
}
loaded.log.push((
"INFO",
format!(
"Chat filter on: {} words (server.toml [chat_filter]; enabled = false turns it off)",
guard.filter.len()
),
));
loaded.guard = Some(Box::new(guard));
loaded
}
/// Gives the dedicated server this chat filter.
pub fn install() {
chat_guard::install(load);
}
/// One line of `logs/chat-filter.log`, without its newline.
pub fn log_line(
now: i64,
@@ -51,8 +51,14 @@ fn float(tag: Option<&Tag>) -> Option<f32> {
other => int(Some(other))? as f32,
})
}
/// `TagValueInput.getBooleanOr`: a number is true when its byte value is not 0.
fn flag(tag: Option<&Tag>) -> Option<bool> {
Some(int(tag)? != 0)
Some(int(tag)? as i8 != 0)
}
/// `Codec.BOOL` through `NbtOps.getBooleanValue`: a number is true when it
/// is not 0 as a whole (`doubleValue() != 0`), unlike `getBooleanOr`.
fn codec_flag(tag: Option<&Tag>) -> Option<bool> {
Some(float(tag)? != 0.0)
}
fn boolean(v: bool) -> Tag {
Tag::Byte(i8::from(v))
@@ -308,7 +314,7 @@ pub fn sign_side(tag: Option<&Tag>) -> Tag {
(
"has_glowing_text",
boolean(
tag.and_then(|t| flag(t.get("has_glowing_text")))
tag.and_then(|t| codec_flag(t.get("has_glowing_text")))
.unwrap_or(false),
),
),
@@ -868,6 +874,32 @@ mod tests {
);
}
/// `getBooleanOr` tests a number's byte value: an int 256 is false and
/// 257 true, for a sign's wax and a piston's flags alike.
#[test]
fn booleans_are_read_as_bytes() {
let sign = |waxed: &str| {
canon(&format!(
"{{id:\"minecraft:sign\",x:1,y:2,z:3,is_waxed:{waxed}}}"
))
.get("is_waxed")
.cloned()
};
assert_eq!(sign("1b"), Some(Tag::Byte(1)));
assert_eq!(sign("256"), Some(Tag::Byte(0)));
assert_eq!(sign("257"), Some(Tag::Byte(1)));
// `SignText` reads `has_glowing_text` through `Codec.BOOL`, which tests
// the whole number: 256 is true there.
let glowing = canon(
r#"{id:"minecraft:sign",x:1,y:2,z:3,front_text:{messages:["","","",""],has_glowing_text:256}}"#,
);
let front = glowing.get("front_text").unwrap();
assert_eq!(front.get("has_glowing_text"), Some(&Tag::Byte(1)));
let piston = canon(r#"{id:"minecraft:piston",x:1,y:2,z:3,extending:256,source:513}"#);
assert_eq!(piston.get("extending"), Some(&Tag::Byte(0)));
assert_eq!(piston.get("source"), Some(&Tag::Byte(1)));
}
#[test]
fn every_type_has_defaults() {
for id in TYPES.iter().chain(NEWEST.iter()) {
+32 -1
View File
@@ -61,6 +61,8 @@ pub enum ClickEvent {
/// A local file to open with the system handler.
/// Only allow this in locally constructed messages, never server/LAN text.
OpenFile(String),
/// A `/profiler` line the client's own buttons run; never decoded from a server's text, so only they reach the local profiler.
Profiler(String),
}
/// A non-character `minecraft:object` embedded in a text component.
@@ -202,7 +204,12 @@ impl Text {
}
}
pub fn styled(s: impl Into<String>, style: Style) -> Self {
Self::literal(s).with_style(&style)
let mut text = Self::literal(s).with_style(&style);
// `inherit` never passes an object on, but a styled span's own style carries it.
for span in &mut text.spans {
span.style.inline_object.clone_from(&style.inline_object);
}
text
}
pub fn colored(s: impl Into<String>, color: u32) -> Self {
Self::styled(s, Style::color(color))
@@ -619,6 +626,9 @@ fn style_json(style: &Style) -> Vec<(String, Value)> {
event
}
ClickEvent::OpenFile(path) => json!({ "action": "open_file", "path": path }),
ClickEvent::Profiler(command) => {
json!({ "action": "run_command", "command": command })
}
};
out.push(("click_event".to_owned(), event));
}
@@ -772,6 +782,27 @@ pub fn format_translation(template: &str, args: &[Text]) -> Text {
mod tests {
use super::*;
#[test]
fn a_styled_object_span_keeps_its_object_through_append_and_slice() {
let object = InlineObject::Atlas {
atlas: "minecraft:gui".into(),
sprite: "horizon:gui/mark".into(),
fallback: "H".into(),
};
let sprite = Text::styled(
"\u{fffc}",
Style {
inline_object: Some(object.clone()),
..Style::color(WHITE)
},
);
let line = Text::colored("[", DARK_GRAY)
.append(sprite)
.append(Text::colored("] ", DARK_GRAY));
let line = line.slice(0, 3);
assert_eq!(line.spans[1].style.inline_object, Some(object));
assert_eq!(line.plain(), "[H]");
}
#[test]
fn appending_builds_the_same_tree_in_linear_time() {
// A styled text is wrapped once, so its style doesn't reach what follows; a plain one
// takes the siblings itself; a copy taken before an append keeps its own tree.
+7 -2
View File
@@ -27,6 +27,11 @@ 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" }
# The .hzperf capture format and writer (docs/perf-platform.md).
horizon-perf-format = { path = "../horizon-perf-format" }
# One pool of helper threads for the server tick's parallel phases (`parallel.rs`): threads made
# once, not for every phase of every tick.
rayon = "1.10"
horizon-hdr = { path = "../horizon-hdr" }
horizon-world = { path = "../horizon-world" }
horizon-protocol = { path = "../horizon-protocol" }
@@ -36,8 +41,6 @@ horizon-server-files = { path = "../horizon-server-files" }
getrandom = "0.3"
# The dedicated server's anticheat, off unless server.toml's [anticheat] turns it on (docs/anticheat.md).
horizon-anticheat = { path = "../horizon-anticheat" }
# The dedicated server's chat filter: [chat_filter], the slur list, mutes (docs/chat-filter.md).
horizon-chat-filter = { path = "../horizon-chat-filter" }
# Shader packs: reading them and turning their GLSL into naga modules.
horizon-shaders = { path = "../horizon-shaders" }
# Horizon's own shader packs: the format, the stages and the check (docs/shaders.md).
@@ -129,6 +132,8 @@ workspace = true
horizon-theme = { path = "../horizon-theme" }
horizon-client-render = { path = "../horizon-client-render", features = ["test-support"] }
horizon-items = { path = "../horizon-items", features = ["test-support"] }
# Reads back the captures the server_perf tests write.
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"] }
+5 -1
View File
@@ -9,7 +9,6 @@ description = "The client's world data, rendering and screens: chunks, meshing,
[dependencies]
# The settings stores of the crates above the engine (docs/layers.md, "Platform registries").
horizon-hooks = { path = "../horizon-hooks" }
horizon-cosmetics = { path = "../horizon-cosmetics" }
# The GUI atlas's pixels, kept compressed once uploaded (hud.rs `pack_pixels`).
lz4_flex = { version = "0.14", default-features = false, features = ["std", "safe-encode", "safe-decode", "checked-decode"] }
horizon-assets = { path = "../horizon-assets" }
@@ -24,6 +23,7 @@ horizon-narrator = { path = "../horizon-narrator" }
# OptiFine-format pack files: random entity textures (hud.rs), connected textures
# (connected.rs) and custom item texture rules (cit.rs).
horizon-optifine = { path = "../horizon-optifine" }
horizon-perf-format = { path = "../horizon-perf-format" }
horizon-polytone = { path = "../horizon-polytone" }
horizon-protocol = { path = "../horizon-protocol" }
horizon-shader-api = { path = "../horizon-shader-api" }
@@ -75,6 +75,9 @@ wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
audio = ["dep:cpal"]
# Helpers horizon-client's tests build their fixtures with.
test-support = []
# The `scene_replay` example's allocator counts allocations (horizon-threads' `alloc-count`).
alloc-count = ["horizon-threads/alloc-count"]
# horizon-client's `horizon-extras`: asking the device for hardware ray tracing on request
# (`gfx/ray_query.rs`, `HORIZON_RAY_TRACING=1`). Without it the device is requested as without
# the variable.
@@ -84,6 +87,7 @@ horizon-extras = []
workspace = true
[dev-dependencies]
horizon-perf = { path = "../horizon-perf" }
# Tests: a TrueType font to load, and the design system the menus' motion curves match.
horizon-theme = { path = "../horizon-theme" }
horizon-items = { path = "../horizon-items", features = ["test-support"] }
@@ -6,13 +6,14 @@
//! [--mipmaps N] [--lod-distance CHUNKS] [--render-distance CHUNKS] [--size WxH]
//! [--section-draws flat|indirect] [--section-cull on|off] [--section-sides on|off]
//! [--gpu-culling on|off] [--root RESOURCES] [--screenshot PNG]
//! [--world SAVE --leaf-culling MODE] [--memory on]`
//! [--world SAVE --leaf-culling MODE] [--memory on] [--gpu-detail on|off]`
//! `--screenshot` saves the first view's last frame, after the timed frames.
//! `--world` meshes the captured sections again from that save's region files
//! (blocks, biomes and saved light; `ao`, `biomeBlendRadius` and `cutoutLeaves`
//! from `./options.txt`) with `--leaf-culling` (default `off`), so a mesher
//! change can be measured on the captured view. A section whose 3x3 columns are
//! not all saved keeps its captured mesh.
//! `--gpu-detail on` adds GPU culling's dispatch times and, on Vulkan, the world passes' counters.
//! `--memory on` adds the renderer's largest GPU resources and, on macOS, the process's
//! physical footprint by category (`footprint`), taken after the timed frames.
use horizon_client_render::{
@@ -24,6 +25,7 @@ use horizon_client_render::{
gfx::{
PreparedTextures, Renderer, ScreenshotRequest,
capture::{self, AtlasCheck, Scene},
pack_timing,
panorama::Faces,
},
graphics::LeafCullingMode,
@@ -61,7 +63,10 @@ use winit::{
// The game's allocator, so that `--memory`'s footprint is the game's: the system heap kept
// gigabytes of the meshes `--world` frees dirty.
#[global_allocator]
static ALLOCATOR: horizon_threads::Allocator = horizon_threads::ALLOCATOR;
static ALLOCATOR: horizon_threads::GameAllocator = horizon_threads::GAME_ALLOCATOR;
/// Frames drawn with detail on, enough for a few timed frames of each kind.
const DETAIL_FRAMES: usize = 60;
#[derive(Default)]
struct Options {
@@ -84,6 +89,7 @@ struct Options {
world: Option<PathBuf>,
leaf_culling: LeafCullingMode,
memory: bool,
gpu_detail: bool,
}
fn on_off(value: &str) -> Result<bool, String> {
@@ -136,6 +142,7 @@ fn options() -> Result<Options, String> {
"--screenshot" => o.screenshot = Some(value.into()),
"--world" => o.world = Some(value.into()),
"--memory" => o.memory = on_off(&value)?,
"--gpu-detail" => o.gpu_detail = on_off(&value)?,
"--leaf-culling" => {
o.leaf_culling = LeafCullingMode::ALL
.into_iter()
@@ -400,6 +407,23 @@ fn replay(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
None => notes.push("screenshot: not saved".into()),
}
}
let mut detail = serde_json::Map::new();
if o.gpu_detail {
renderer.set_pass_detail(true);
for _ in 0..DETAIL_FRAMES {
draw(&mut renderer)?;
}
detail.insert(
"gpu_pass_parts_median_ms".into(),
pack_timing::parts_json(&renderer.own_pass_parts()),
);
let unit = renderer.pass_detail_support().and_then(|s| s.fragment_unit);
detail.insert(
"gpu_counters_median".into(),
pack_timing::counters_by_pass_json(&renderer.own_pass_counters(), unit),
);
renderer.set_pass_detail(false);
}
views.push(json!({
"yaw_offset": offset,
"gpu_ms": summary(&gpu),
@@ -417,6 +441,9 @@ fn replay(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
// indices hidden.
"culled": renderer.culled_sections(),
}));
if let Some(Value::Object(view)) = views.last_mut() {
view.extend(detail);
}
}
let section_draws = if renderer.flat_draws() {
"flat"
@@ -424,6 +451,11 @@ fn replay(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
"indirect"
};
let memory = o.memory.then(|| memory_report(&renderer));
let gpu_detail = o
.gpu_detail
.then(|| renderer.pass_detail_support())
.flatten()
.map(pack_timing::support_json);
let report = json!({
"capture": o.dir.display().to_string(),
"adapter": renderer.info.name,
@@ -454,6 +486,7 @@ fn replay(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
"notes": notes,
"views": views,
"memory": memory,
"gpu_detail": gpu_detail,
});
println!("{}", serde_json::to_string_pretty(&report)?);
Ok(())
@@ -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) => {
@@ -92,6 +92,9 @@ pub struct BlockEntities {
/// instance blocks, `shows_box`), so a frame looks at them without scanning the map.
/// Like `mods`, one taken out stays listed until `put` or `remove`.
boxes: FxHashSet<Pos>,
/// The signs a player's editor is on (`SignBlockEntity.playerWhoMayEdit`
/// set), so the level's tick checks those alone instead of every sign.
sign_editors: FxHashSet<Pos>,
asleep: FxHashSet<Pos>,
}
@@ -312,6 +315,44 @@ impl BlockEntities {
.filter(move |((cx, cz), _)| !far(*cx, x) && !far(*cz, z))
.flat_map(|(_, set)| set)
}
/// `SignBlockEntity.playerWhoMayEdit` of the sign at `pos`.
pub fn sign_editor(&self, pos: &Pos) -> Option<u128> {
match self.map.get(pos) {
Some(BlockEntity::Sign(sign)) => sign.allowed_editor,
_ => None,
}
}
/// `SignBlockEntity.setAllowedPlayerEditor`: the one player whose editor
/// is on the sign at `pos`, or `None` for nobody's.
pub fn set_sign_editor(&mut self, pos: Pos, who: Option<u128>) {
let Some(BlockEntity::Sign(sign)) = self.map.get_mut(&pos) else {
return;
};
sign.allowed_editor = who;
if who.is_some() {
self.sign_editors.insert(pos);
} else {
self.sign_editors.remove(&pos);
}
}
/// The positions of the signs a player's editor is on, in no order.
pub fn sign_editors(&self) -> impl Iterator<Item = &Pos> {
self.sign_editors.iter()
}
/// Drops `pos` from the signs an editor is on, for a sign that is gone or
/// no longer holds one. `set_sign_editor` keeps the list itself.
pub fn forget_sign_editor(&mut self, pos: Pos) {
self.sign_editors.remove(&pos);
}
/// Keeps `pos` listed in `sign_editors` exactly while `entity` holds an
/// editor, as `note_box` does for the boxes.
fn note_sign_editor(&mut self, pos: Pos, entity: &BlockEntity) {
if matches!(entity, BlockEntity::Sign(sign) if sign.allowed_editor.is_some()) {
self.sign_editors.insert(pos);
} else {
self.sign_editors.remove(&pos);
}
}
/// `LevelChunk.getBlockEntity`: the block entity at `pos`, if any.
pub fn get(&self, pos: &Pos) -> Option<&BlockEntity> {
self.map.get(pos)
@@ -351,6 +392,7 @@ impl BlockEntities {
self.attachments.remove(&pos);
}
let new_box = shows_box(&entity);
self.note_sign_editor(pos, &entity);
self.asleep.remove(&pos);
let old = self.map.insert(pos, entity);
if old.is_none() {
@@ -398,6 +440,7 @@ impl BlockEntities {
forget_mod(&mut self.mods, *pos);
}
self.boxes.remove(pos);
self.sign_editors.remove(pos);
removed
}
/// The block entity at `pos`, out of the map for a while: its slot stays
@@ -418,6 +461,7 @@ impl BlockEntities {
self.mods.entry(chunk_of(pos)).or_default().insert(pos);
}
self.note_box(pos, Some(&entity));
self.note_sign_editor(pos, &entity);
if self.map.insert(pos, entity).is_none()
&& !self.live.contains_key(&pos)
&& !self.pending.contains_key(&pos)
@@ -445,6 +489,7 @@ impl BlockEntities {
&mut self.attachments,
);
let (mods, boxes) = (&mut self.mods, &mut self.boxes);
let sign_editors = &mut self.sign_editors;
self.asleep.clear();
self.map.retain(|p, e| {
let kept = keep(p, e);
@@ -454,6 +499,7 @@ impl BlockEntities {
pending.remove(p);
forget_mod(mods, *p);
boxes.remove(p);
sign_editors.remove(p);
}
kept
});
@@ -463,6 +509,7 @@ impl BlockEntities {
self.map.clear();
self.mods.clear();
self.boxes.clear();
self.sign_editors.clear();
self.asleep.clear();
self.tickers = Arc::default();
self.live.clear();
@@ -484,6 +531,7 @@ impl BlockEntities {
self.asleep.remove(&p);
let new_is_mod = matches!(e, BlockEntity::Mod(_));
self.note_box(p, Some(&e));
self.note_sign_editor(p, &e);
let old = self.map.insert(p, e);
if old.is_some() {
self.replaced.insert(p);
+1 -1
View File
@@ -24,7 +24,7 @@ struct Out {
return out;
}
@fragment fn fs_border(in: Out) -> @location(0) vec4<f32> {
return border_color(in);
return frame_stored(border_color(in));
}
fn border_color(in: Out) -> vec4<f32> {
let color = textureSample(forcefield, forcefield_sampler, in.uv);
+12 -5
View File
@@ -568,6 +568,8 @@ impl Chat {
pub fn set_qol(&mut self, atlas: &HudAtlas, qol: ChatQol) {
if self.qol != qol {
self.qol = qol;
self.scroll = 0;
self.new_since_scroll = false;
self.rewrap(&atlas.font);
}
}
@@ -593,20 +595,25 @@ impl Chat {
.filter(|q| !q.trim().is_empty());
if self.search != query {
self.search = query;
self.scroll = 0;
self.new_since_scroll = false;
self.rewrap(font);
}
}
pub fn searching(&self) -> bool {
self.search.is_some()
}
/// Re-wrap after a width or scale change (ChatComponent.rescaleChat),
/// keeping only the messages the Chat option shows.
/// Re-wrap after a width or scale change (`ChatComponent.rescaleChat`):
/// `resetChatScroll`, then the messages the Chat option shows.
pub fn rescale(&mut self, atlas: &HudAtlas) {
self.rewrap(&atlas.font);
}
fn rewrap(&mut self, font: &crate::font::Font) {
self.scroll = 0;
self.new_since_scroll = false;
self.rewrap(&atlas.font);
}
/// `ChatComponent.refreshTrimmedMessages`: the lines are laid out again
/// where the reader was (`chatScrollbarPos` survives a filter change);
/// `rescale` resets the scroll itself, as `rescaleChat` does.
fn rewrap(&mut self, font: &crate::font::Font) {
self.lines.clear();
let mut messages = std::mem::take(&mut self.messages);
for m in messages.iter_mut().rev() {
@@ -1067,6 +1067,34 @@ fn a_server_filter_mask_hides_or_masks_the_message() {
);
}
/// `ChatComponent.refreshTrimmedMessages`: a filter change lays the lines out
/// again but keeps `chatScrollbarPos` and `newMessageSinceScroll`; only
/// `rescaleChat` resets them, after `resetChatScroll`.
#[test]
fn a_chat_option_change_keeps_the_readers_place_but_a_rescale_does_not() {
let font = font();
let mut chat = Chat::default();
for i in 0..60 {
chat.add_message(
&font,
Text::literal(format!("line {i}")),
None,
ChatSource::Server,
0,
false,
);
}
chat.scroll = 3;
chat.new_since_scroll = true;
chat.set_abilities(&font, ChatAbilities::new(ChatVisibility::System));
assert_eq!(chat.scroll, 3);
assert!(chat.new_since_scroll);
let atlas = crate::hud::HudAtlas::build(Path::new("/nonexistent/horizon-test-assets")).unwrap();
chat.rescale(&atlas);
assert_eq!(chat.scroll, 0);
assert!(!chat.new_since_scroll);
}
/// `SuggestionsList.extractRenderState`: the list's rectangle asks for the
/// pointing hand; outside it the cursor stays the arrow.
#[test]
+20 -2
View File
@@ -196,7 +196,7 @@ impl ColumnLayout {
}
/// The index of the section holding block `y`, if the column holds it.
pub fn section_index(self, y: i32) -> Option<usize> {
usize::try_from((y - self.min_y) >> 4)
usize::try_from((i64::from(y) - i64::from(self.min_y)) >> 4)
.ok()
.filter(|&s| s < self.sections)
}
@@ -700,7 +700,7 @@ impl Chunk {
/// The index of the section holding block `y`, if the column holds it.
#[inline]
pub fn section_index(&self, y: i32) -> Option<usize> {
usize::try_from((y - self.min_y) >> 4)
usize::try_from((i64::from(y) - i64::from(self.min_y)) >> 4)
.ok()
.filter(|&s| s < self.sections.len())
}
@@ -984,6 +984,24 @@ pub struct ChunkExtras {
mod tests {
use super::*;
#[test]
fn extreme_y_queries_are_air_and_writes_leave_the_column_unchanged() {
for layout in [ColumnLayout::VANILLA, ColumnLayout::new(0, 256).unwrap()] {
let mut chunk = Chunk::with_layout(0, 0, layout);
chunk.set(0, layout.min_y(), 0, 7);
for y in [i32::MIN, i32::MIN + 63, -2_146_769_275, i32::MAX] {
assert_eq!(layout.section_index(y), None);
assert_eq!(chunk.section_index(y), None);
assert_eq!(chunk.get(0, y, 0), 0);
assert_eq!(
chunk.light(0, y, 0),
if y < layout.min_y() { 0 } else { 0xf0 }
);
chunk.set(0, y, 0, 9);
}
assert_eq!(chunk.get(0, layout.min_y(), 0), 7);
}
}
#[test]
fn each_light_form_keeps_every_value_and_run() {
// Sky varies alone, block varies alone, both, neither.
let cases: [(fn(usize) -> u8, &str); 4] = [
@@ -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.
@@ -513,6 +516,10 @@ pub struct SignData {
pub waxed: bool,
/// `allow_op_features`: the sign's click events may run commands.
pub allow_op_features: bool,
/// `playerWhoMayEdit`: the one player whose editor is on this sign, as a
/// UUID. Every other player's click, write and applicator is refused
/// while it is set. Not saved (vanilla keeps it out of the NBT).
pub allowed_editor: Option<u128>,
}
/// `VibrationInfo`.
#[derive(Clone, Copy, Debug, PartialEq)]
+11 -2
View File
@@ -419,8 +419,10 @@ impl SceneCollisions<'_, '_> {
let hi = BlockPos::containing(Vec3::new(max.x + 1.0e-7, max.y + 1.0e-7, max.z + 1.0e-7))
.offset(1, 1, 1);
let size = |a: i32, b: i32| i64::from(b) - i64::from(a) + 1;
let cells = size(lo.x, hi.x) * size(lo.y, hi.y) * size(lo.z, hi.z);
if lo.x > hi.x || lo.y > hi.y || lo.z > hi.z || cells > 4096 {
let cells = [size(lo.x, hi.x), size(lo.y, hi.y), size(lo.z, hi.z)]
.into_iter()
.try_fold(1i64, |n, size| n.checked_mul(size));
if lo.x > hi.x || lo.y > hi.y || lo.z > hi.z || cells.is_none_or(|n| n > 4096) {
return false;
}
// A column and section at a time. Most boxes lie in a few sections whose palette says that no
@@ -1111,6 +1113,13 @@ mod section_tests {
binding: &binding,
subject: None,
};
// The fast path must decline an enormous volume without overflowing
// its size calculation or beginning to walk it.
let enormous = Aabb::new(
Vec3::new(-1.0e9, -1.0e9, -1.0e9),
Vec3::new(1.0e9, 1.0e9, 1.0e9),
);
assert!(!scene.cells_without_collision(enormous));
// Under the fence: its section's outer cells may hold a shape reaching in.
let under = Aabb::new(Vec3::new(5.4, 31.6, 5.4), Vec3::new(5.6, 31.8, 5.6));
assert!(!scene.cells_without_collision(under.expand_towards(Vec3::new(0.0, 0.1, 0.0))));
+85 -14
View File
@@ -56,6 +56,8 @@ pub struct Overlay {
struct LoadedRule {
rule: Rule,
tiles: Vec<TileLayer>,
/// By tile: the rules whose `matchTiles` name it (a range of `lists`), which chain on it.
chain: Vec<(u32, u32)>,
overlay: OverlayJoin,
}
@@ -188,6 +190,7 @@ impl ConnectedTextures {
Ok(tiles) => Some(LoadedRule {
rule,
tiles,
chain: Vec::new(),
overlay: OverlayJoin::default(),
}),
Err(e) => {
@@ -292,10 +295,23 @@ impl ConnectedTextures {
let mut by_layer: Vec<Vec<u16>> = Vec::new();
// Block rules by the block each entry names, so a state tests only its own.
let mut by_block: HashMap<&str, Vec<u16>> = HashMap::new();
// Tile rules by the sprite or tile file each names, for chaining on a picked tile.
let mut by_name: HashMap<String, Vec<u16>> = HashMap::new();
for (i, r) in out.rules.iter().enumerate() {
let i = i as u16;
for tile in &r.rule.match_tiles {
if let Some(l) = layer(&sprite_name(tile)) {
let name = sprite_name(tile);
let list = by_name.entry(name.clone()).or_default();
if list.last() != Some(&i) {
list.push(i);
}
// A tile file (`./1.png`) is no model's sprite: only chaining reaches it, by
// name. By layer it would match every face whose sprite has the same pixels
// (identical stills share a layer: Conquest's blank placeholders, dozens).
if !name.contains(':') {
continue;
}
if let Some(l) = layer(&name) {
let l = l as usize;
if by_layer.len() <= l {
by_layer.resize(l + 1, Vec::new());
@@ -316,6 +332,26 @@ impl ConnectedTextures {
let range = push_list(&mut out.lists, &list);
out.by_layer.push(range);
}
let by_name: HashMap<String, (u32, u32)> = by_name
.into_iter()
.map(|(name, list)| (name, push_list(&mut out.lists, &list)))
.collect();
// Set at the end: `by_block` borrows the rules' names.
let chains: Vec<Vec<(u32, u32)>> = out
.rules
.iter()
.map(|r| {
r.rule
.tiles
.iter()
.map(|t| match t {
Tile::Texture(path) => by_name.get(&sprite_name(path)).copied(),
_ => None,
})
.map(Option::unwrap_or_default)
.collect()
})
.collect();
let mut list = Vec::new();
for state in states {
let Some((name, props)) = state else {
@@ -355,6 +391,9 @@ impl ConnectedTextures {
let range = push_list(&mut out.lists, &list);
out.by_state.push(range);
}
for (r, chain) in out.rules.iter_mut().zip(chains) {
r.chain = chain;
}
out
}
@@ -392,9 +431,11 @@ impl ConnectedTextures {
}
fn list(&self, ranges: &[(u32, u32)], i: usize) -> &[u16] {
ranges.get(i).map_or(&[], |&(start, len)| {
&self.lists[start as usize..(start + len) as usize]
})
ranges.get(i).map_or(&[], |&range| self.range(range))
}
fn range(&self, (start, len): (u32, u32)) -> &[u16] {
&self.lists[start as usize..(start + len) as usize]
}
/// What the `face` of the block `state` at `pos` draws, for a quad with sprite `layer`.
@@ -441,7 +482,7 @@ impl ConnectedTextures {
let r = &self.rules[usize::from(i)];
if (block || r.rule.match_blocks.is_empty())
&& (tile || r.rule.match_tiles.is_empty())
&& let Some(pick) = self.apply(
&& let Some((pick, index)) = self.apply(
r,
state,
layer,
@@ -454,7 +495,7 @@ impl ConnectedTextures {
)
{
// "Tiles output by CTM rules can also be matched by another rule": once, by the
// tile rules naming the picked tile (and this block, if they name blocks).
// tile rules naming the picked tile by name (and this block, if they name blocks).
// A tile on the face's own sprite (`tiles=stone`) is no new tile.
let Pick::Tile(TileLayer::Layer { layer: picked, .. }) = pick else {
return pick;
@@ -467,7 +508,7 @@ impl ConnectedTextures {
// again: an overlay would draw twice.
let first = self.list(&self.by_layer, layer as usize);
let again = self
.list(&self.by_layer, picked as usize)
.range(r.chain.get(index).copied().unwrap_or_default())
.iter()
.map(|&j| (j, &self.rules[usize::from(j)]))
.filter(|&(j, r)| {
@@ -488,8 +529,8 @@ impl ConnectedTextures {
&texture,
&mut overlay,
) {
Some(Pick::Keep) => return pick,
Some(chained) => return chained,
Some((Pick::Keep, _)) => return pick,
Some((chained, _)) => return chained,
None => {}
}
}
@@ -512,7 +553,7 @@ impl ConnectedTextures {
biome: &impl Fn() -> Option<&'b str>,
texture: &impl Fn() -> Option<Axes>,
overlay: &mut impl FnMut(Overlay),
) -> Option<Pick> {
) -> Option<(Pick, usize)> {
let rule = &r.rule;
// `Rule::applies_at`, with the biome looked up only for rules that test one. Rules with
// a custom name are not loaded.
@@ -602,7 +643,7 @@ impl ConnectedTextures {
return if quarters.contains(&TileLayer::Skip) {
None
} else {
Some(Pick::Quarters(quarters))
Some((Pick::Quarters(quarters), 0))
};
}
}
@@ -617,7 +658,7 @@ impl ConnectedTextures {
return None;
}
if !joins([0, 1, 0]) {
return Some(Pick::Keep);
return Some((Pick::Keep, 0));
}
0
}
@@ -644,8 +685,8 @@ impl ConnectedTextures {
}
match tile(index) {
TileLayer::Skip => None,
TileLayer::Default => Some(Pick::Keep),
t => Some(Pick::Tile(t)),
TileLayer::Default => Some((Pick::Keep, index)),
t => Some((Pick::Tile(t), index)),
}
}
}
@@ -675,6 +716,7 @@ mod tests {
.enumerate()
.map(|(n, rule)| LoadedRule {
overlay: OverlayJoin::default(),
chain: Vec::new(),
tiles: rule
.tiles
.iter()
@@ -722,6 +764,10 @@ mod tests {
"assets/minecraft/optifine/ctm/b0.png" => Some(2000),
// And `a0` of the first rule.
"assets/minecraft/optifine/ctm/a0.png" => Some(1000),
// Two blank placeholders with the same pixels, so one layer: the tile `grass/1`
// of the third rule (`chaining_follows_the_tile_not_its_pixels`).
"assets/minecraft/optifine/ctm/grass/1.png"
| "assets/minecraft/optifine/ctm/aaa/1.png" => Some(3000),
_ => None,
},
)
@@ -1176,6 +1222,31 @@ mod tests {
assert_eq!(pick_in(&ctm, 1, 3, Face::Up, &[]), tile(2000));
}
#[test]
fn chaining_follows_the_tile_not_its_pixels() {
// Conquest's grass top: a placeholder tile that a rule beside it replaces. Another
// folder's placeholder has the same pixels, hence the same layer, and its rule sorts
// first; it must not draw on the grass.
let ctm = world(&[
(
"grass/top.properties",
"matchTiles=glass\nmethod=fixed\ntiles=1",
),
(
"grass/random_1.properties",
"matchTiles=./1.png\nmethod=fixed\ntiles=17\nfaces=top",
),
(
"aaa/random_1.properties",
"matchTiles=./1.png\nmethod=fixed\ntiles=5\nfaces=top",
),
]);
// Rules in path order: aaa/random_1 (1000), grass/random_1 (2000), grass/top (3000).
assert_eq!(pick_in(&ctm, 1, 3, Face::Up, &[]), tile(2000));
// A face whose own sprite has a placeholder's pixels is no placeholder.
assert_eq!(pick_in(&ctm, 2, 3000, Face::Up, &[]), Pick::Keep);
}
#[test]
fn orient_texture_follows_the_quads_uvs() {
let rule = |orient: &str| {
+267 -37
View File
@@ -56,6 +56,15 @@ pub struct Glue<'a> {
pub struct CoreProgram {
pub vertex: naga::Module,
pub fragment: naga::Module,
/// The blocks and samplers that are none of vanilla's, in binding order: the `i`th is at
/// [`other_binding`]`(i)` (a sampler's object one after its texture; a block leaves that
/// binding unused).
pub other: Vec<String>,
}
/// The binding of the `index`th block or sampler of [`CoreProgram::other`].
pub fn other_binding(index: usize) -> u32 {
OTHER_BINDINGS + 2 * index as u32
}
/// The shaders of one pipeline (`text.vsh`, `text.fsh`) with vanilla's `defines` for it.
@@ -66,6 +75,20 @@ pub fn translate_core(
defines: &[&str],
include: &dyn Fn(&str) -> Option<String>,
glue: &Glue,
) -> Result<CoreProgram, String> {
translate_core_with(vertex, fragment, defines, include, glue, &|_| false)
}
/// [`translate_core`] for a pipeline that also gives the blocks and samplers `provided` says
/// yes to, at their [`CoreProgram::other`] bindings; the others are still an error. `provided`
/// answers a block's name and a sampler's alike: the caller knows which kind each one is.
pub fn translate_core_with(
vertex: &str,
fragment: &str,
defines: &[&str],
include: &dyn Fn(&str) -> Option<String>,
glue: &Glue,
provided: &dyn Fn(&str) -> bool,
) -> Result<CoreProgram, String> {
const CORE: &str = "minecraft:shaders/core/";
let vertex_text = glsl_fixups::fixups(&strip_comments(&expand(vertex, CORE, include)?));
@@ -75,10 +98,16 @@ pub fn translate_core(
.map_err(|e| format!("vertex shader: {e}"))?;
let fragment_code = rewrite(&fragment_text, ShaderStage::Fragment, glue, &mut names)
.map_err(|e| format!("fragment shader: {e}"))?;
let vertex = compile(ShaderStage::Vertex, &vertex_code, defines)
let vertex = compile(ShaderStage::Vertex, &vertex_code, defines, &names, provided)
.map_err(|e| format!("vertex shader: {e}"))?;
let fragment = compile(ShaderStage::Fragment, &fragment_code, defines)
.map_err(|e| format!("fragment shader: {e}"))?;
let fragment = compile(
ShaderStage::Fragment,
&fragment_code,
defines,
&names,
provided,
)
.map_err(|e| format!("fragment shader: {e}"))?;
let given = locations(&vertex, false);
if let Some(missing) = locations(&fragment, true)
.into_iter()
@@ -88,11 +117,21 @@ pub fn translate_core(
"the fragment shader reads location {missing}, which the vertex shader doesn't write"
));
}
Ok(CoreProgram { vertex, fragment })
Ok(CoreProgram {
vertex,
fragment,
other: names.other,
})
}
/// A bound on a pack's includes, which are pasted every time.
const MAX_PASTES: u32 = 1024;
const MAX_EXPANDED: usize = 1 << 20;
/// The file with its includes inlined (`#include <ns:path>`, `#include "relative"` and the same
/// with `#moj_import`), each file once, `#version` and `#extension` lines dropped.
/// with `#moj_import`), `#version` and `#extension` lines dropped. An `#include` is pasted every
/// time, as vanilla's shaderc does (the files' `#ifndef` guards keep one copy); a `#moj_import`
/// once.
fn expand(
text: &str,
dir: &str,
@@ -103,6 +142,7 @@ fn expand(
dir: &str,
include: &dyn Fn(&str) -> Option<String>,
seen: &mut HashSet<String>,
pastes: &mut u32,
depth: u32,
out: &mut String,
) -> Result<(), String> {
@@ -113,15 +153,19 @@ fn expand(
let directive = line.trim_start().strip_prefix('#').map(str::trim_start);
let target = directive.and_then(|d| {
d.strip_prefix("include")
.or_else(|| d.strip_prefix("moj_import"))
.map(str::trim)
.map(|t| (t.trim(), false))
.or_else(|| d.strip_prefix("moj_import").map(|t| (t.trim(), true)))
});
if let Some(target) = target {
if let Some((target, once)) = target {
let id = include_id(target, dir).ok_or_else(|| format!("bad include {target}"))?;
if seen.insert(id.clone()) {
if seen.insert(id.clone()) || !once {
*pastes += 1;
if *pastes > MAX_PASTES {
return Err(format!("includes paste more than {MAX_PASTES} files"));
}
let file = include(&id).ok_or_else(|| format!("{id} not found"))?;
let parent = &id[..id.rfind('/').map_or(id.len(), |i| i + 1)];
walk(&file, parent, include, seen, depth + 1, out)?;
walk(&file, parent, include, seen, pastes, depth + 1, out)?;
}
out.push('\n');
} else if directive
@@ -132,11 +176,16 @@ fn expand(
out.push_str(line);
out.push('\n');
}
if out.len() > MAX_EXPANDED {
return Err(format!(
"includes grow the shader past {MAX_EXPANDED} bytes"
));
}
}
Ok(())
}
let mut out = String::new();
walk(text, dir, include, &mut HashSet::new(), 0, &mut out)?;
walk(text, dir, include, &mut HashSet::new(), &mut 0, 0, &mut out)?;
Ok(out)
}
@@ -293,14 +342,16 @@ fn block_name(line: &str) -> Option<&str> {
let rest = line[at + "uniform".len()..].trim();
let name_end = rest.find(|c: char| !is_ident(c)).unwrap_or(rest.len());
let (name, tail) = rest.split_at(name_end);
(!name.is_empty() && matches!(tail.trim(), "" | "{")).then_some(name)
// `{` alone, or the whole block on the line (`uniform Day { float daylight; };`).
(!name.is_empty() && (tail.trim().is_empty() || tail.trim_start().starts_with('{')))
.then_some(name)
}
/// What the two stages share: the vertex stage's outputs in location order, and the names of
/// What the two stages share: the vertex stage's outputs and their locations, and the names of
/// the blocks and samplers of other pipelines in binding order.
#[derive(Default)]
struct Names {
varyings: Vec<String>,
varyings: Vec<(String, u32)>,
other: Vec<String>,
}
@@ -308,8 +359,8 @@ impl Names {
fn location(&self, varying: &str) -> Option<u32> {
self.varyings
.iter()
.position(|v| v == varying)
.map(|i| i as u32)
.find(|(v, _)| v == varying)
.map(|(_, location)| *location)
}
/// A block or sampler of another pipeline: its binding (two for a sampler).
@@ -321,7 +372,7 @@ impl Names {
self.other.len() - 1
}
};
OTHER_BINDINGS + 2 * index as u32
other_binding(index)
}
}
@@ -344,6 +395,16 @@ fn rewrite(
// Where the pack's own code starts: the glue above it is left as it is.
let pack_start = out.len();
let mut outputs: Vec<&str> = Vec::new();
let explicit: Vec<u32> = if vertex {
text.lines()
.flat_map(declaration_lines)
.filter_map(declaration)
.filter(|d| d.storage == "out")
.filter_map(|d| d.location)
.collect()
} else {
Vec::new()
};
for source_line in text.lines() {
for line in declaration_lines(source_line) {
if let Some(name) = block_name(line) {
@@ -423,10 +484,21 @@ fn rewrite(
}
"out" if vertex => {
// One location each: naga takes scalars and vectors only.
let location = names.location(decl.name).unwrap_or_else(|| {
names.varyings.push(decl.name.to_owned());
names.varyings.len() as u32 - 1
});
let location = decl
.location
.or_else(|| names.location(decl.name))
.unwrap_or_else(|| {
(0..)
.find(|l| {
!explicit.contains(l)
&& names.varyings.iter().all(|(_, v)| v != l)
})
.unwrap_or_default()
});
let varying = (decl.name.to_owned(), location);
if !names.varyings.contains(&varying) {
names.varyings.push(varying);
}
writeln!(
out,
"layout(location = {location}) {qualifiers} out {} {};",
@@ -435,9 +507,12 @@ fn rewrite(
.unwrap();
}
"in" => {
let location = names.location(decl.name).ok_or_else(|| {
format!("input {} isn't written by the vertex shader", decl.name)
})?;
let location = decl
.location
.or_else(|| names.location(decl.name))
.ok_or_else(|| {
format!("input {} isn't written by the vertex shader", decl.name)
})?;
writeln!(
out,
"layout(location = {location}) {qualifiers} in {} {};",
@@ -511,10 +586,17 @@ fn rename_main(text: &str) -> String {
}
/// Parses and validates one stage; errors name the line of the rewritten code.
fn compile(stage: ShaderStage, code: &str, defines: &[&str]) -> Result<naga::Module, String> {
fn compile(
stage: ShaderStage,
code: &str,
defines: &[&str],
names: &Names,
provided: &dyn Fn(&str) -> bool,
) -> Result<naga::Module, String> {
let mut options = Options::from(stage);
for define in defines {
options.defines.insert((*define).to_owned(), String::new());
let (name, value) = define.split_once(' ').unwrap_or((define, ""));
options.defines.insert(name.to_owned(), value.to_owned());
}
let module = Frontend::default().parse(&options, code).map_err(|e| {
e.errors
@@ -542,16 +624,16 @@ fn compile(stage: ShaderStage, code: &str, defines: &[&str]) -> Result<naga::Mod
})?;
let entry = info.get_entry_point(0);
for (handle, global) in module.global_variables.iter() {
let other = global
.binding
.as_ref()
.is_some_and(|b| b.binding >= OTHER_BINDINGS);
if other && !entry[handle].is_empty() {
let name = global.name.as_deref().unwrap_or("?");
let name = name
.strip_prefix("hz_tex_")
.or_else(|| name.strip_prefix("hz_smp_"))
.unwrap_or(name);
let Some(binding) = global.binding.as_ref().map(|b| b.binding) else {
continue;
};
if binding >= OTHER_BINDINGS && !entry[handle].is_empty() {
// The name it was declared with (a block's instance may have none).
let index = ((binding - OTHER_BINDINGS) / 2) as usize;
let name = names.other.get(index).map_or("?", String::as_str);
if provided(name) {
continue;
}
return Err(format!("{name} is not a block or sampler of this pipeline"));
}
}
@@ -652,7 +734,8 @@ mod tests {
#[test]
fn a_packs_gl_vertex_index_reads_the_glues_vertex_number() {
let vsh = "#version 330\n#include <minecraft:dynamictransforms.glsl>\n#include <minecraft:projection.glsl>\n\
in vec3 Position;\nin vec4 Color;\nin vec2 UV0;\nout vec4 vertexColor;\nout vec2 texCoord0;\n\
layout(location = 0) in vec3 Position;\nlayout(location = 1) in vec4 Color;\nlayout(location = 2) in vec2 UV0;\n\
layout(location = 2) out vec4 vertexColor;\nlayout(location = 3) out vec2 texCoord0;\n\
void main() {\n int corner = gl_VertexIndex % 4;\n\
gl_Position = ProjMat * ModelViewMat * vec4(Position + vec3(float(corner), 0.0, 0.0), 1.0);\n\
vertexColor = Color;\n texCoord0 = UV0;\n}\n";
@@ -716,6 +799,96 @@ mod tests {
assert_eq!(read, [0, 2]);
}
#[test]
fn explicit_varying_locations_are_kept() {
let vsh = "#version 330\nin vec3 Position;\n\
#ifdef IS_GUI\nlayout(location = 4) out float gui;\n#else\nlayout(location = 4) out float world;\n#endif\n\
layout(location = 13) out vec4 a;\nout float b;\n\
void main() { gl_Position = vec4(Position, 1.0); a = vec4(1.0); b = 1.0;\n\
#ifdef IS_GUI\ngui = 1.0;\n#else\nworld = 1.0;\n#endif\n}\n";
let fsh = "#version 330\n#ifdef IS_GUI\nlayout(location = 4) in float gui;\n#endif\n\
layout(location = 13) in vec4 a;\nin float b;\nout vec4 fragColor;\n\
void main() { fragColor = a * b;\n#ifdef IS_GUI\nfragColor *= gui;\n#endif\n}\n";
let program = translate_core(vsh, fsh, &["IS_GUI"], &include, &GLUE).unwrap();
let mut read = locations(&program.fragment, true);
read.sort_unstable();
assert_eq!(read, [0, 4, 13]);
}
#[test]
fn an_include_is_pasted_every_time_and_a_moj_import_once() {
let file = |id: &str| {
(id == "minecraft:shaders/include/g.glsl")
.then(|| "#ifndef G\n#define G\nconst float g = 1.0;\n#endif\n".to_owned())
};
let twice =
"#ifdef A\n#include <minecraft:g.glsl>\n#else\n#include <minecraft:g.glsl>\n#endif\n";
let expanded = expand(twice, "minecraft:shaders/core/", &file).unwrap();
assert_eq!(expanded.matches("const float g").count(), 2);
let imported = expand(
&twice.replace("#include", "#moj_import"),
"minecraft:shaders/core/",
&file,
)
.unwrap();
assert_eq!(imported.matches("const float g").count(), 1);
}
#[test]
fn a_location_another_branch_gave_the_same_name_isnt_reused() {
let vsh = "#version 330\nin vec3 Position;\n\
#ifdef IS_GUI\nlayout(location = 0) out vec4 vertexColor;\n\
#else\nlayout(location = 1) out vec4 vertexColor;\nlayout(location = 0) out vec4 worldOther;\n#endif\n\
void main() { gl_Position = vec4(Position, 1.0); vertexColor = vec4(1.0);\n\
#ifndef IS_GUI\nworldOther = vec4(0.5);\n#endif\n}\n";
let fsh = "#version 330\n\
#ifdef IS_GUI\nlayout(location = 0) in vec4 vertexColor;\n\
#else\nlayout(location = 1) in vec4 vertexColor;\nlayout(location = 0) in vec4 worldOther;\n#endif\n\
out vec4 fragColor;\nvoid main() { fragColor = vertexColor;\n\
#ifndef IS_GUI\nfragColor *= worldOther;\n#endif\n}\n";
let world = translate_core(vsh, fsh, &[], &include, &GLUE).unwrap();
let mut written = locations(&world.vertex, false);
written.sort_unstable();
assert_eq!(written, [0, 1]);
let mut read = locations(&world.fragment, true);
read.sort_unstable();
assert_eq!(read, [0, 1]);
let gui = translate_core(vsh, fsh, &["IS_GUI"], &include, &GLUE).unwrap();
assert_eq!(locations(&gui.vertex, false), [0]);
}
#[test]
fn includes_that_multiply_are_refused_once_over_budget() {
let file = |id: &str| {
let level: u32 = id
.strip_prefix("minecraft:shaders/include/l")?
.strip_suffix(".glsl")?
.parse()
.ok()?;
let next = format!("#include <minecraft:l{}.glsl>\n", level + 1).repeat(4);
Some(format!(
"#ifndef L{level}\n#define L{level}\n{}#endif\n",
if level < 15 { next.as_str() } else { "" }
))
};
let error = expand(
"#include <minecraft:l0.glsl>\n",
"minecraft:shaders/core/",
&file,
)
.unwrap_err();
assert!(error.contains("paste more than"), "{error}");
}
#[test]
fn a_define_with_a_value_is_defined_to_it() {
let vsh = "#version 330\nin vec3 Position;\n\
void main() { gl_Position = vec4(Position, float(COUNT)); }\n";
let fsh =
"#version 330\nout vec4 fragColor;\nvoid main() { fragColor = vec4(COUNT / 4); }\n";
assert!(translate_core(vsh, fsh, &["COUNT 8"], &include, &GLUE).is_ok());
}
#[test]
fn minified_varying_declarations_are_rewritten() {
let vsh = "#version 330\nout float sphericalVertexDistance;out float cylindricalVertexDistance;\
@@ -736,6 +909,63 @@ mod tests {
translate_core(vsh, fsh, &[], &include, &GLUE).unwrap();
}
/// A block declared on one line, as packs write a one-float uniform, is bound like one
/// over several lines; the blocks and samplers the caller provides are accepted, in binding
/// order, and one it doesn't provide is not.
#[test]
fn a_block_on_one_line_is_bound_and_provided_blocks_are_accepted() {
let vsh = "#version 330\nlayout(std140) uniform Day { float daylight; };\nout vec4 vertexColor;\n\
void main() { vertexColor = vec4(daylight); gl_Position = vec4(0.0); }\n";
let fsh = "#version 330\nuniform sampler2D Bounds;\nin vec4 vertexColor;\nout vec4 fragColor;\n\
void main() { fragColor = vertexColor * texture(Bounds, vec2(0.5)); }\n";
let both = |name: &str| name == "Day" || name == "Bounds";
let program = translate_core_with(vsh, fsh, &[], &include, &GLUE, &both).unwrap();
assert_eq!(program.other, ["Day", "Bounds"]);
// The bindings the caller gives them at.
let bindings = |module: &naga::Module| -> Vec<(Option<String>, u32)> {
module
.global_variables
.iter()
.filter_map(|(_, g)| Some((g.name.clone(), g.binding.as_ref()?.binding)))
.collect()
};
assert!(
bindings(&program.vertex)
.iter()
.any(|(_, b)| *b == other_binding(0)),
"{:?}",
bindings(&program.vertex)
);
let fragment = bindings(&program.fragment);
assert!(
fragment.contains(&(Some("hz_tex_Bounds".into()), other_binding(1))),
"{fragment:?}"
);
assert!(
fragment.contains(&(Some("hz_smp_Bounds".into()), other_binding(1) + 1)),
"{fragment:?}"
);
let error = translate_core(vsh, fsh, &[], &include, &GLUE).unwrap_err();
assert!(error.contains("Day is not a block or sampler"), "{error}");
let only_day = |name: &str| name == "Day";
let error = translate_core_with(vsh, fsh, &[], &include, &GLUE, &only_day).unwrap_err();
assert!(
error.contains("Bounds is not a block or sampler"),
"{error}"
);
}
/// Only a uniform block's declaration names a block, written over several lines or on one.
#[test]
fn block_names_are_read_from_block_declarations_only() {
assert_eq!(block_name("layout(std140) uniform Day {"), Some("Day"));
assert_eq!(block_name("uniform Day { float daylight; };"), Some("Day"));
assert_eq!(block_name("uniform Foo{"), Some("Foo"));
assert_eq!(block_name("uniform Globals"), Some("Globals"));
assert_eq!(block_name("uniform float x;"), None);
assert_eq!(block_name("uniform sampler2D Sampler0;"), None);
}
#[test]
fn what_horizon_does_not_provide_is_an_error() {
let fsh = "#version 330\nin vec4 vertexColor;\nout vec4 fragColor;\nvoid main() { fragColor = vertexColor; }\n";
@@ -0,0 +1,241 @@
//! Dynamic Resolution: the world's resolution scale follows the GPU's frame time toward a target
//! frame rate, between the Resolution Scale setting (the floor) and 100%.
//!
//! The setting is the lowest scale the player accepts; where the GPU has room for more, the
//! scale rises toward 100% a step at a time, and falls back toward the floor where frames take
//! longer than the target allows. A verdict is the median GPU time of a window of frames, which
//! a few slow frames (a chunk load) don't move, and a step up is taken only when the frame would
//! still fit if its cost grew with the pixels: most of a frame doesn't, so it errs toward
//! staying.
//!
//! Each change has a price: new render targets, and history that starts over (TAA, and a
//! shader pack's own: its denoised light, reflections and accumulation). So a shader pack gets
//! the gentle pace (long windows, big steps, a long rest after each change): walking from a
//! heavy scene to a light one changes the scale once, not every few frames. Without a pack the
//! quick pace follows the scene within a second.
//!
//! Nothing here draws or reads the GPU: the caller feeds it the frames and applies the scale it
//! answers.
/// How fast the scale follows the frame.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Pace {
/// A shader pack: its history is expensive to start over.
Gentle,
/// Horizon's own renderer.
Quick,
}
impl Pace {
/// Milliseconds of frames in one verdict.
fn window_ms(self) -> f64 {
match self {
Pace::Gentle => 3_000.0,
Pace::Quick => 500.0,
}
}
/// Milliseconds of frames after a change before the next verdict counts, while the new
/// targets and the restarted history settle.
fn settle_ms(self) -> f64 {
match self {
Pace::Gentle => 8_000.0,
Pace::Quick => 1_000.0,
}
}
/// Percentage points per change.
fn step(self) -> u32 {
match self {
Pace::Gentle => 10,
Pace::Quick => 5,
}
}
}
/// The frame is too slow when its GPU time passes the target's frame time by this share.
const DOWN_ABOVE: f32 = 1.05;
/// A step up is taken when the frame, its cost grown with the pixels, stays under this share.
const UP_BELOW: f32 = 0.95;
/// Timed frames a window needs for a verdict (a GPU without timestamps gives none).
const MIN_TIMED: usize = 8;
/// The scale in use and what it has measured since the last verdict.
#[derive(Clone, Debug, Default)]
pub struct DynamicResolution {
gpu: Vec<f32>,
window_ms: f64,
since_change_ms: f64,
/// The scale in use, in percent; `None` until the first change (the floor).
scale: Option<u32>,
}
impl DynamicResolution {
pub fn new() -> Self {
Self::default()
}
/// Back to the floor, with nothing measured (turned off, or the world changed).
pub fn reset(&mut self) {
*self = Self::new();
}
/// The scale in use, in percent, for a Resolution Scale of `floor`: never below it, never
/// above 100% unless the floor itself is (then it is the floor).
pub fn scale(&self, floor: u32) -> u32 {
self.scale.unwrap_or(floor).clamp(floor, floor.max(100))
}
/// One frame: its GPU time where its timestamps have arrived, and its whole time, in
/// milliseconds. Answers the new scale, in percent, when this frame completed a window that
/// changed it.
pub fn observe(
&mut self,
gpu_ms: Option<f32>,
frame_ms: f32,
floor: u32,
target_fps: u32,
pace: Pace,
) -> Option<u32> {
self.window_ms += f64::from(frame_ms);
self.since_change_ms += f64::from(frame_ms);
if let Some(gpu) = gpu_ms {
self.gpu.push(gpu);
}
// A window lasts its time and holds enough timed frames: a slow scene (or timestamps that
// arrive with gaps) keeps gathering past the time rather than starting over with too few,
// which would never judge the scenes that most need a lower scale. A GPU that can't be
// timed gathers nothing and so leaves the scale alone.
if self.window_ms < pace.window_ms() || self.gpu.len() < MIN_TIMED {
return None;
}
self.window_ms = 0.0;
let timed = std::mem::take(&mut self.gpu);
// At or above 100% there is nothing to vary; a change still settling isn't judged yet.
if floor >= 100 || self.since_change_ms < pace.settle_ms() {
return None;
}
let gpu = median(timed);
let target = 1000.0 / target_fps.max(1) as f32;
let now = self.scale(floor);
let next = if gpu > target * DOWN_ABOVE && now > floor {
now.saturating_sub(pace.step()).max(floor)
} else if now < 100 {
let up = (now + pace.step()).min(100);
let grown = (up as f32 / now as f32).powi(2);
if gpu * grown < target * UP_BELOW {
up
} else {
now
}
} else {
now
};
if next == now {
return None;
}
self.scale = Some(next);
self.since_change_ms = 0.0;
Some(next)
}
}
fn median(mut values: Vec<f32>) -> f32 {
values.sort_by(f32::total_cmp);
values[values.len() / 2]
}
#[cfg(test)]
mod tests {
use super::*;
/// `ms` milliseconds of frames, each `gpu` long on the GPU and `frame` long in all; the last
/// answer.
fn run(
d: &mut DynamicResolution,
ms: f64,
gpu: f32,
frame: f32,
floor: u32,
pace: Pace,
) -> Option<u32> {
let mut answer = None;
let mut t = 0.0;
while t < ms {
if let Some(scale) = d.observe(Some(gpu), frame, floor, 60, pace) {
answer = Some(scale);
}
t += f64::from(frame);
}
answer
}
#[test]
fn it_starts_at_the_floor_and_climbs_while_there_is_room() {
let mut d = DynamicResolution::new();
assert_eq!(d.scale(70), 70);
// 8 ms of a 16.7 ms budget: room to grow, one step per window and settle.
assert_eq!(run(&mut d, 1_200.0, 8.0, 8.0, 70, Pace::Quick), Some(75));
assert_eq!(run(&mut d, 30_000.0, 8.0, 8.0, 70, Pace::Quick), Some(100));
assert_eq!(d.scale(70), 100);
}
#[test]
fn a_slow_frame_steps_down_but_never_below_the_floor() {
let mut d = DynamicResolution::new();
run(&mut d, 30_000.0, 8.0, 8.0, 70, Pace::Quick);
assert_eq!(d.scale(70), 100);
// 25 ms against 16.7: down a step at a time, to the floor and no further.
run(&mut d, 60_000.0, 25.0, 25.0, 70, Pace::Quick);
assert_eq!(d.scale(70), 70);
}
#[test]
fn a_slow_scene_still_steps_down() {
let mut d = DynamicResolution::new();
run(&mut d, 30_000.0, 8.0, 8.0, 70, Pace::Quick);
assert_eq!(d.scale(70), 100);
// 10 FPS: five frames in a 500 ms window, fewer than a verdict needs; the window keeps
// gathering until it has them.
run(&mut d, 60_000.0, 100.0, 100.0, 70, Pace::Quick);
assert_eq!(d.scale(70), 70);
}
#[test]
fn a_frame_near_the_target_stays_put() {
let mut d = DynamicResolution::new();
// 15 ms: under the target, but a step up (75 -> 80%) would make it 17 ms.
assert_eq!(run(&mut d, 60_000.0, 15.0, 16.0, 75, Pace::Quick), None);
assert_eq!(d.scale(75), 75);
}
#[test]
fn the_gentle_pace_takes_big_steps_and_long_rests() {
let mut d = DynamicResolution::new();
// The first window (3 s) can't count yet: a change rests 8 s, and the first counts
// from the start.
assert_eq!(run(&mut d, 7_000.0, 8.0, 8.0, 70, Pace::Gentle), None);
assert_eq!(run(&mut d, 3_000.0, 8.0, 8.0, 70, Pace::Gentle), Some(80));
// Then nothing for the 8 s rest.
assert_eq!(run(&mut d, 7_000.0, 8.0, 8.0, 70, Pace::Gentle), None);
assert_eq!(d.scale(70), 80);
}
#[test]
fn nothing_changes_at_or_above_100_or_without_gpu_times() {
let mut d = DynamicResolution::new();
assert_eq!(run(&mut d, 30_000.0, 30.0, 30.0, 100, Pace::Quick), None);
assert_eq!(d.scale(120), 120);
let mut d = DynamicResolution::new();
for _ in 0..1_000 {
assert_eq!(d.observe(None, 8.0, 70, 60, Pace::Quick), None);
}
assert_eq!(d.scale(70), 70);
}
#[test]
fn reset_goes_back_to_the_floor() {
let mut d = DynamicResolution::new();
run(&mut d, 30_000.0, 8.0, 8.0, 70, Pace::Quick);
d.reset();
assert_eq!(d.scale(70), 70);
}
}
+17 -11
View File
@@ -36,7 +36,7 @@ struct Globals {
// w: apply the no-pack HDR gain; HDR_WRITE_LIGHT independently keeps float alpha as coverage.
hdr_light: vec4<f32>,
hdr_settings: vec4<f32>,
hdr_shares: vec4<f32>,
hdr_gains: vec4<f32>,
}
@group(0) @binding(0) var<uniform> globals: Globals;
@group(0) @binding(1) var atlas: texture_2d<f32>;
@@ -292,12 +292,17 @@ fn entity_written_color(in: Out, color: vec4<f32>) -> vec4<f32> {
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 && in.overlay != 11u && in.overlay != 12u {
var mark = hdr_entity_mark(in);
if in.overlay == 4u { mark = hdr_scene_alpha(globals.hdr_light.y); }
return vec4(hdr_written(color.rgb, mark, globals.hdr_settings, globals.hdr_shares), color.a);
return vec4(hdr_written(color.rgb, mark, globals.hdr_settings, globals.hdr_gains), color.a);
}
return color;
if FRAME_LINEAR && (in.overlay == 11u || in.overlay == 12u) {
// The cracks scale what is under them by twice their colour (CRUMBLING_BLEND). Vanilla's
// product is in gamma, so under light it is (2c)^2.2: they give half of that.
return vec4(pow(2.0 * max(color.rgb, vec3(0.0)), vec3(2.2)) * 0.5, color.a);
}
return frame_stored(color);
}
@fragment fn fs_entity_unmarked(in: Out, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
return entity_color(in, front);
return frame_stored(entity_color(in, front));
}
fn entity_color(in: Out, front: bool) -> vec4<f32> {
var uv = in.uv;
@@ -355,7 +360,7 @@ fn entity_color(in: Out, front: bool) -> vec4<f32> {
// rendertype_entity_shadow.fsh: the shadow texture times the vertex colour,
// then fog, with no alpha cutout, so a faint shadow in dim light still draws.
@fragment fn fs_entity_shadow(in: Out) -> @location(0) vec4<f32> {
return shadow_color(in);
return frame_stored(shadow_color(in));
}
fn shadow_color(in: Out) -> vec4<f32> {
var color = textureSample(atlas, atlas_sampler, in.uv) * in.color;
@@ -446,9 +451,9 @@ fn celestial_color(in: Out) -> vec4<f32> {
@fragment fn fs_celestial(in: Out) -> @location(0) vec4<f32> {
let color = celestial_color(in);
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
return vec4(hdr_written(color.rgb, hdr_entity_mark(in), globals.hdr_settings, globals.hdr_shares), color.a);
return vec4(hdr_written(color.rgb, hdr_entity_mark(in), globals.hdr_settings, globals.hdr_gains), color.a);
}
return color;
return frame_stored(color);
}
// The sky's celestial pass (sun, moon, the End sky): fs_celestial's light,
@@ -467,9 +472,10 @@ fn celestial_color(in: Out) -> vec4<f32> {
// or it would come out darker than the sky around it.
let full = y >= globals.hdr_windows[1][clamp(tier, 1u, 4u) - 1u];
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
return vec4(hdr_written(added, select(1.0, hdr_mark(tier, y), full), globals.hdr_settings, globals.hdr_shares), color.a);
return vec4(hdr_written(added, select(1.0, hdr_mark(tier, y), full), globals.hdr_settings, globals.hdr_gains), color.a);
}
if HDR_WRITE_LIGHT { return vec4(added, color.a); }
// Premultiplied after the decode: the light the texel adds, at its coverage.
if HDR_WRITE_LIGHT { return vec4(frame_stored(color).rgb * color.a, color.a); }
if !full { return vec4(added, 0.0); }
return vec4(added, 1.0 - hdr_mark(tier, y));
}
@@ -492,7 +498,7 @@ fn energy_swirl_fog(in: Out) -> f32 {
}
@fragment fn fs_energy_swirl(in: Out) -> @location(0) vec4<f32> {
let color = energy_swirl_base(in);
return vec4(mix(color.rgb, globals.fog_color.rgb, energy_swirl_fog(in)), color.a);
return frame_stored(vec4(mix(color.rgb, globals.fog_color.rgb, energy_swirl_fog(in)), color.a));
}
// RenderPipelines.WATER_MASK: depth only (the pipeline masks the colour off).
@@ -621,7 +627,7 @@ struct WeatherTime { values: vec4<f32> }
return out;
}
@fragment fn fs_weather(in: Out) -> @location(0) vec4<f32> {
return weather_color(in);
return frame_stored(weather_color(in));
}
fn weather_color(in: Out) -> vec4<f32> {
// Repeat within this sprite, never across the atlas. The atlas uses nearest
+9 -215
View File
@@ -166,24 +166,6 @@ pub struct Extras {
/// built-in track and leaving the off ones out (`musicPage`, off by
/// default: vanilla plays exactly as before).
pub music_page: bool,
/// Cosmetics (`horizon_cosmetics`, off unless enabled), the lines `horizon_cosmetics::prefs`
/// shares with the launcher's Wardrobe: Show my cosmetics (the account's, sent to the service;
/// on by default), how much of other players' to show (no line until chosen:
/// `effective_others`), and whether their trails and auras are hidden (off by default).
pub cosmetics_mine: bool,
pub cosmetics_others: Option<horizon_cosmetics::prefs::Others>,
pub cosmetics_hide_fx: bool,
/// My cosmetics in single player too (`cosmeticsSolo`, off by default): the window's own server
/// names the player by their offline profile, which the service doesn't know, so their own
/// cosmetics and name tag style show only with this on.
pub cosmetics_solo: bool,
/// Whether the signed-in account owns a cosmetic, as the service said (never saved; the game
/// sets it): with no choice made, others' cosmetics show only then.
pub cosmetics_owner: bool,
/// Horizon's mark before the names of other Horizon players (name tags, player list), and
/// whether the service may say this player plays Horizon: both on by default.
pub horizon_marks: bool,
pub horizon_listed: bool,
/// 3D skin layers (`threeDSkinLayers`, Options → Skin Customization): each
/// painted texel of a player's outer layer drawn as a slab
/// (`horizon_client::mobs::skin_layers`).
@@ -238,13 +220,6 @@ impl Default for Extras {
wheel_cycles_settings: false,
music_off: Vec::new(),
music_page: false,
cosmetics_mine: true,
cosmetics_others: None,
cosmetics_hide_fx: false,
cosmetics_solo: false,
cosmetics_owner: false,
horizon_marks: true,
horizon_listed: true,
three_d_skin_layers: false,
pvp: Default::default(),
}
@@ -254,11 +229,6 @@ fn bool_text(v: bool) -> &'static str {
if v { "true" } else { "false" }
}
impl Extras {
/// How much of other players' cosmetics shows: the player's choice, else all of it for an
/// account that owns a cosmetic and none for any other (a vanilla game).
pub fn effective_others(&self) -> horizon_cosmetics::prefs::Others {
horizon_cosmetics::prefs::Others::effective(self.cosmetics_others, self.cosmetics_owner)
}
pub fn load(dir: &Path) -> Self {
fs::read_to_string(dir.join(FILE))
.map(|t| Self::parse(&t))
@@ -353,15 +323,6 @@ impl Extras {
.collect()
}
"musicPage" => e.music_page = flag(e.music_page),
"cosmeticsMine" => e.cosmetics_mine = flag(e.cosmetics_mine),
"cosmeticsOthers" => {
e.cosmetics_others =
horizon_cosmetics::prefs::Others::parse(value).or(e.cosmetics_others)
}
"cosmeticsHideFx" => e.cosmetics_hide_fx = flag(e.cosmetics_hide_fx),
"cosmeticsSolo" => e.cosmetics_solo = flag(e.cosmetics_solo),
horizon_cosmetics::prefs::MARKS => e.horizon_marks = flag(e.horizon_marks),
horizon_cosmetics::prefs::LISTED => e.horizon_listed = flag(e.horizon_listed),
SKIN_LAYERS_KEY => e.three_d_skin_layers = flag(e.three_d_skin_layers),
key => {
e.pvp.parse_line(key, value);
@@ -372,7 +333,7 @@ impl Extras {
}
pub fn serialize(&self) -> String {
let mut text = format!(
"packBrowser:{}\npregen:{}\nfullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nlodZoomDetail:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\nreproKeys:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\nwheelCyclesListSettings:{}\nmusicOff:{}\nmusicPage:{}\ncosmeticsMine:{}\n{}cosmeticsHideFx:{}\ncosmeticsSolo:{}\n{}:{}\n{}:{}\n{}:{}\n",
"packBrowser:{}\npregen:{}\nfullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nlodZoomDetail:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\nreproKeys:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\nwheelCyclesListSettings:{}\nmusicOff:{}\nmusicPage:{}\n{}:{}\n",
bool_text(self.pack_browser),
bool_text(self.pregen),
bool_text(self.fullbright),
@@ -421,16 +382,6 @@ impl Extras {
bool_text(self.wheel_cycles_settings),
self.music_off.join(","),
bool_text(self.music_page),
bool_text(self.cosmetics_mine),
// Written once the player chose.
self.cosmetics_others
.map_or(String::new(), |o| format!("cosmeticsOthers:{}\n", o.name())),
bool_text(self.cosmetics_hide_fx),
bool_text(self.cosmetics_solo),
horizon_cosmetics::prefs::MARKS,
bool_text(self.horizon_marks),
horizon_cosmetics::prefs::LISTED,
bool_text(self.horizon_listed),
SKIN_LAYERS_KEY,
bool_text(self.three_d_skin_layers),
);
@@ -616,13 +567,6 @@ pub enum Item {
/// The Select Resource Packs screen's Add button (Modrinth).
PackBrowser,
Pregen,
/// Shown only while cosmetics are enabled (`horizon_cosmetics::enabled`).
CosmeticsMine,
CosmeticsOthers,
CosmeticsHideFx,
CosmeticsSolo,
HorizonMarks,
HorizonMe,
/// On the PvP & HUD page (`pvp::rows`).
Pvp(crate::pvp::Opt),
}
@@ -630,8 +574,10 @@ pub enum Item {
pub fn rows() -> Vec<Result<Item, &'static str>> {
use Item::*;
// Fullbright, Dynamic lights and Hide block-break particles change the picture: they
// are on the graphics screen's Quality page (`graphics_ui::rows`), not here.
let mut rows = vec![
// are on the graphics screen's Quality page (`graphics_ui::rows`), not here. The Cosmetics
// group comes last, from the cosmetics' own section (`pages::Place::Extras`) while they are
// offered.
vec![
Err("horizon.extras.header.third_person"),
Ok(OwnNameTag),
Err("horizon.extras.header.inventory"),
@@ -652,19 +598,7 @@ pub fn rows() -> Vec<Result<Item, &'static str>> {
Err("horizon.extras.header.menus"),
Ok(WheelCyclesSettings),
Ok(PackBrowser),
];
if horizon_cosmetics::enabled() {
rows.extend([
Err("horizon.extras.header.cosmetics"),
Ok(CosmeticsMine),
Ok(CosmeticsSolo),
Ok(CosmeticsOthers),
Ok(CosmeticsHideFx),
Ok(HorizonMarks),
Ok(HorizonMe),
]);
}
rows
]
}
impl Item {
/// The language key of the caption (`horizon.*`, assets/horizon/lang).
@@ -690,12 +624,6 @@ impl Item {
Item::WheelCyclesSettings => "horizon.extras.wheel_cycles_settings",
Item::PackBrowser => "horizon.extras.pack_browser",
Item::Pregen => "horizon.extras.pregen",
Item::CosmeticsMine => "horizon.extras.cosmetics_mine",
Item::CosmeticsOthers => "horizon.extras.cosmetics_others",
Item::CosmeticsHideFx => "horizon.extras.cosmetics_hide_fx",
Item::CosmeticsSolo => "horizon.extras.cosmetics_solo",
Item::HorizonMarks => "horizon.extras.horizon_marks",
Item::HorizonMe => "horizon.extras.horizon_me",
Item::Pvp(o) => o.caption_key(),
}
}
@@ -722,12 +650,6 @@ impl Item {
Item::WheelCyclesSettings => "horizon.extras.wheel_cycles_list_settings.tooltip",
Item::PackBrowser => "horizon.extras.pack_browser.tooltip",
Item::Pregen => "horizon.extras.pregen.tooltip",
Item::CosmeticsMine => "horizon.extras.cosmetics_mine.tooltip",
Item::CosmeticsOthers => "horizon.extras.cosmetics_others.tooltip",
Item::CosmeticsHideFx => "horizon.extras.cosmetics_hide_fx.tooltip",
Item::CosmeticsSolo => "horizon.extras.cosmetics_solo.tooltip",
Item::HorizonMarks => "horizon.extras.horizon_marks.tooltip",
Item::HorizonMe => "horizon.extras.horizon_me.tooltip",
Item::Pvp(o) => o.description_key(),
}
}
@@ -763,12 +685,6 @@ impl Item {
Item::WheelCyclesSettings => "Scroll wheel changes settings",
Item::PackBrowser => "Resource pack browser",
Item::Pregen => "Chunk pregeneration",
Item::CosmeticsMine => "My cosmetics",
Item::CosmeticsOthers => "Other players' cosmetics",
Item::CosmeticsHideFx => "Hide other players' trails and auras",
Item::CosmeticsSolo => "In single player",
Item::HorizonMarks => "Horizon logos",
Item::HorizonMe => "Show me as Horizon",
Item::Pvp(o) => o.caption(),
}
}
@@ -783,8 +699,7 @@ impl Item {
Pvp(o) => return o.parent().map(Pvp),
Fullbright | DynamicLights | OwnNameTag | HideBlockParticles | MouseTweaks | Sort
| AutoReconnect | NoAi | AgentControl | ReproKeys | Splitscreen | ChatAnimation
| WheelCyclesSettings | MusicPage | PackBrowser | CosmeticsMine | CosmeticsOthers
| CosmeticsHideFx | CosmeticsSolo | HorizonMarks | HorizonMe | Pregen => {
| WheelCyclesSettings | MusicPage | PackBrowser | Pregen => {
return None;
}
})
@@ -842,24 +757,6 @@ impl Item {
Item::ReproKeys => {
"F3+R starts or stops a repro recording and F3+E writes its bundle. Off by default: a recording copies the world."
}
Item::CosmeticsMine => {
"Hidden: every Horizon player sees you without your cosmetics, you included. The same switch as the launcher's Wardrobe."
}
Item::CosmeticsOthers => {
"What other Horizon players wear: everything, only their name tag styles, or nothing. Hidden unless you own cosmetics, until you choose. Hidden, the game never asks what they wear."
}
Item::CosmeticsHideFx => {
"Other players' trails and auras stay hidden; their other cosmetics still show. Off by default."
}
Item::CosmeticsSolo => {
"Show your cosmetics and your name tag's badge in your single-player worlds too (your name tag with Own name tag). Off: only on servers."
}
Item::HorizonMarks => {
"Show the Horizon logo next to Horizon players: a small logo before the names of other players who use Horizon, above their heads and in the player list. Only Horizon players see it: servers receive nothing."
}
Item::HorizonMe => {
"Show me as a Horizon player: other Horizon players see the logo by your name. On, the service answers anyone who asks about you that you played Horizon in the last 3 hours; off, it never reports you."
}
Item::Pvp(o) => o.description(),
Item::WheelCyclesSettings => {
"The scroll wheel over a button in the settings changes its value, as in vanilla. Off, the wheel only scrolls the list."
@@ -886,12 +783,6 @@ impl Item {
| Item::Splitscreen
| Item::WheelCyclesSettings
| Item::PackBrowser => Some(Cost::None),
Item::CosmeticsMine
| Item::CosmeticsOthers
| Item::CosmeticsHideFx
| Item::CosmeticsSolo
| Item::HorizonMarks
| Item::HorizonMe => Some(Cost::Low),
_ => None,
}
}
@@ -917,10 +808,6 @@ impl Item {
Item::WheelCyclesSettings => e.wheel_cycles_settings,
Item::PackBrowser => e.pack_browser,
Item::Pregen => e.pregen,
Item::CosmeticsHideFx => e.cosmetics_hide_fx,
Item::CosmeticsSolo => e.cosmetics_solo,
Item::HorizonMarks => e.horizon_marks,
Item::HorizonMe => e.horizon_listed,
Item::Pvp(o) => return o.switch(&e.pvp),
_ => return None,
})
@@ -930,30 +817,14 @@ impl Item {
match self {
Item::SortKey => crate::keybinds::display_name(&e.sort_key).plain(),
Item::Pvp(o) if o.switch(&e.pvp).is_none() => o.value(&e.pvp).unwrap_or_default(),
Item::CosmeticsMine => match e.cosmetics_mine {
true => crate::lang::get_or("horizon.extras.cosmetics.shown", "Shown"),
false => crate::lang::get_or("horizon.extras.cosmetics.hidden", "Hidden"),
},
Item::CosmeticsOthers => {
use horizon_cosmetics::prefs::Others;
match e.effective_others() {
Others::Shown => crate::lang::get_or("horizon.extras.cosmetics.shown", "Shown"),
Others::NameTags => {
crate::lang::get_or("horizon.extras.cosmetics.name_tags", "Name tags only")
}
Others::Hidden => {
crate::lang::get_or("horizon.extras.cosmetics.hidden", "Hidden")
}
}
}
other => match other.switch(e) {
Some(true) => crate::lang::get_or("On", "On"),
_ => crate::lang::get_or("Off", "Off"),
},
}
}
/// A click (`back` for the right button: it turns the three-way cosmetics button the other
/// way; every other item left is a switch, so both flip it). The sort key binds instead.
/// A click (`back` for the right button: a PvP & HUD choice steps back; a switch flips
/// either way). The sort key binds instead.
pub fn cycle(self, e: &mut Extras, back: bool) {
match self {
Item::Fullbright => e.fullbright = !e.fullbright,
@@ -975,16 +846,6 @@ impl Item {
Item::WheelCyclesSettings => e.wheel_cycles_settings = !e.wheel_cycles_settings,
Item::PackBrowser => e.pack_browser = !e.pack_browser,
Item::Pregen => e.pregen = !e.pregen,
Item::CosmeticsMine => e.cosmetics_mine = !e.cosmetics_mine,
Item::CosmeticsOthers => {
// From what the button shows; the result is the player's choice from now on.
let o = e.effective_others();
e.cosmetics_others = Some(if back { o.next().next() } else { o.next() });
}
Item::CosmeticsHideFx => e.cosmetics_hide_fx = !e.cosmetics_hide_fx,
Item::CosmeticsSolo => e.cosmetics_solo = !e.cosmetics_solo,
Item::HorizonMarks => e.horizon_marks = !e.horizon_marks,
Item::HorizonMe => e.horizon_listed = !e.horizon_listed,
Item::Pvp(o) => o.cycle(&mut e.pvp, back),
Item::SortKey => {}
}
@@ -1003,73 +864,6 @@ impl Item {
mod tests {
use super::*;
#[test]
fn other_players_cosmetics_cycle_three_ways_and_read_the_old_switch() {
use horizon_cosmetics::prefs::Others;
let mut e = Extras {
cosmetics_owner: true,
..Extras::default()
};
assert_eq!(
(e.effective_others(), e.cosmetics_hide_fx, e.cosmetics_mine),
(Others::Shown, false, true)
);
Item::CosmeticsOthers.cycle(&mut e, false);
assert_eq!(e.cosmetics_others, Some(Others::NameTags));
Item::CosmeticsOthers.cycle(&mut e, false);
assert_eq!(e.cosmetics_others, Some(Others::Hidden));
// The right button goes back.
Item::CosmeticsOthers.cycle(&mut e, true);
assert_eq!(e.cosmetics_others, Some(Others::NameTags));
// Ownership is the service's, never written.
assert_eq!(
Extras::parse(&e.serialize()),
Extras {
cosmetics_owner: false,
..e.clone()
}
);
// The launcher's Wardrobe writes the same lines.
assert_eq!(
horizon_cosmetics::prefs::Prefs::parse(&e.serialize()).others,
Some(Others::NameTags)
);
// Before this, the line was on or off.
assert_eq!(
Extras::parse("cosmeticsOthers:false").cosmetics_others,
Some(Others::Hidden)
);
}
#[test]
fn others_cosmetics_are_off_for_a_player_who_owns_none_until_chosen() {
use horizon_cosmetics::prefs::Others;
let mut e = Extras::default();
// Nothing chosen, nothing owned: the button shows Hidden and no line is written.
assert_eq!(e.effective_others(), Others::Hidden);
assert_eq!(
Item::CosmeticsOthers.value(&e),
crate::lang::get_or("horizon.extras.cosmetics.hidden", "Hidden")
);
assert!(!e.serialize().contains("cosmeticsOthers"));
// The service says the account owns one: shown, still not a choice.
e.cosmetics_owner = true;
assert_eq!(e.effective_others(), Others::Shown);
assert!(!e.serialize().contains("cosmeticsOthers"));
// A non-owner pressing the button goes on from Hidden, and that choice is kept.
e.cosmetics_owner = false;
Item::CosmeticsOthers.cycle(&mut e, false);
assert_eq!(e.cosmetics_others, Some(Others::Shown));
let saved = Extras::parse(&e.serialize());
assert_eq!(saved.effective_others(), Others::Shown);
// An owner who chose Hidden keeps it.
let hidden = Extras {
cosmetics_owner: true,
..Extras::parse("cosmeticsOthers:hidden\n")
};
assert_eq!(hidden.effective_others(), Others::Hidden);
}
#[test]
fn a_saved_wheel_setting_from_before_the_default_changed_is_dropped() {
assert!(!Extras::parse("wheelCyclesSettings:true\n").wheel_cycles_settings);
+2 -19
View File
@@ -206,14 +206,13 @@ impl Screen for ExtrasScreen {
fn note_lines(&self) -> usize {
NOTE_LINES
}
/// The rows read the extras, the mods, whether cosmetics are offered and the sections' rows.
/// The rows read the extras, the mods and the sections' rows.
fn stamp(&self, s: &Settings) -> u64 {
let mut added = Vec::new();
for (_, section) in sections() {
section.rows(&mut added);
}
let cosmetics = horizon_cosmetics::enabled();
shell::stamp_of(&(&s.extras, &s.mods, cosmetics, added))
shell::stamp_of(&(&s.extras, &s.mods, added))
}
fn sheets(&self, s: &Settings) -> Vec<Sheet<(), ExtraRow>> {
let mut groups = vec![Group {
@@ -321,22 +320,6 @@ mod tests {
assert_ne!(s.extras.mouse_tweaks_wheel, before);
}
#[test]
fn the_three_way_cosmetics_row_turns_both_ways() {
use horizon_cosmetics::prefs::Others;
let mut s = Settings::default();
let mut screen = ExtrasScreen;
let row = ExtraRow::Item(Item::CosmeticsOthers);
// Not chosen, nothing owned: it shows Hidden, and forward wraps to Shown.
assert_eq!(s.extras.effective_others(), Others::Hidden);
screen.press(&mut s, row, false);
assert_eq!(s.extras.cosmetics_others, Some(Others::Shown));
screen.press(&mut s, row, true);
assert_eq!(s.extras.cosmetics_others, Some(Others::Hidden));
screen.press(&mut s, row, true);
assert_eq!(s.extras.cosmetics_others, Some(Others::NameTags));
}
/// The sort key binds instead of cycling; the wheel leaves it be.
#[test]
fn the_key_row_waits_for_a_key() {
File diff suppressed because it is too large. Load diff
@@ -136,6 +136,8 @@ impl Arena {
} else {
wgpu::BufferUsages::VERTEX
};
// BLAS builds read sections' vertices here (`ray_scene`).
let usage = usage | super::surface_setup::ray_query::blas_input(device);
Self::with_usage(device, stride, capacity, usage, PAGE_BYTES)
}
/// Sorted translucent indices; `storage` when compute passes read them.
@@ -0,0 +1,164 @@
//! What the renderer holds, for status, soak tests and memory reports (`horizon-ctl memory`,
//! `scene_replay --memory`): resident sections and far tiles, and the bytes of its largest
//! GPU resources and of its per-section bookkeeping.
use std::collections::HashMap;
use super::{
Renderer, SectionGpu, SectionKey, TerrainVertex, arena, flat, lod, texture_bytes, visbuffer,
};
impl Renderer {
/// Sections with GPU buffers, for soak tests watching residency.
pub fn resident_sections(&self) -> usize {
self.sections.len()
}
/// Quads of the far terrain tiles with GPU buffers.
pub fn lod_quads(&self) -> u64 {
self.lods
.values()
.map(|tile| u64::from(tile.mesh.solid + tile.mesh.translucent))
.sum()
}
/// Tiles and quads of the far terrain tiles with GPU buffers, by level.
pub fn lod_levels(&self) -> Vec<[u64; 2]> {
let mut levels = Vec::new();
for (key, tile) in &self.lods {
let level = usize::from(key.level);
if levels.len() <= level {
levels.resize(level + 1, [0, 0]);
}
levels[level][0] += 1;
levels[level][1] += u64::from(tile.mesh.solid + tile.mesh.translucent);
}
levels
}
/// Whether solid quads go to the arena in the compact layout
/// (`packed_quads`), and whether far tiles' own records are on
/// (`lod::packed::far_packing`).
pub fn packed_layout(&self) -> [bool; 2] {
[self.packed_quads, crate::lod::packed::far_packing()]
}
/// Bytes of the arena the resident chunk sections hold, and of the one far
/// tiles hold: the same split `gpu_census` reports, for `status`, so the
/// compact layouts can be seen working.
pub fn vertex_bytes_split(&self) -> [u64; 2] {
let stride = std::mem::size_of::<TerrainVertex>() as u64;
let used = |s: &SectionGpu| (s.solid_slots + u64::from(s.translucent) * 4) * stride;
[
self.sections.values().map(used).sum(),
self.lods.values().map(|tile| used(&tile.mesh)).sum(),
]
}
/// Bytes of the section vertex arenas' buffers, and of the vertices
/// resident sections hold in them.
pub fn section_vertex_bytes(&self) -> [u64; 2] {
let stride = std::mem::size_of::<TerrainVertex>() as u64;
let vertices: u64 = self
.sections
.values()
.chain(self.lods.values().map(|tile| &*tile.mesh))
.map(|s| s.solid_slots + u64::from(s.translucent) * 4)
.sum();
let translucent = self.translucent_arena.as_ref();
let arenas = self.arena.bytes() + translucent.map_or(0, arena::Arena::bytes);
[arenas, vertices * stride]
}
/// Bytes of the renderer's largest GPU resources, by name (`scene_replay --memory`):
/// the terrain arenas (their pages, and what resident sections and far tiles use of
/// them), the quad indices, the block sprites with their mips, the LabPBR maps, flat
/// draws' buffers and the visibility buffer's.
pub fn gpu_census(&self) -> Vec<(&'static str, u64)> {
let stride = std::mem::size_of::<TerrainVertex>() as u64;
let used = |s: &SectionGpu| (s.solid_slots + u64::from(s.translucent) * 4) * stride;
let textures = |t: &[wgpu::Texture]| t.iter().map(texture_bytes).sum::<u64>();
let translucent = self.translucent_arena.as_ref();
vec![
("section_arena_pages", self.arena.bytes()),
(
"translucent_arena_pages",
translucent.map_or(0, arena::Arena::bytes),
),
("sorted_index_pages", self.sorted_indices.bytes()),
(
"near_section_vertices",
self.sections.values().map(used).sum(),
),
(
"held_section_vertices",
self.hold
.sections()
.filter_map(|held| held.gpu.as_ref())
.map(used)
.sum(),
),
(
"far_tile_vertices",
self.lods.values().map(|tile| used(&tile.mesh)).sum(),
),
("quad_indices", self.indices.size()),
("block_sprites", textures(&self.sprite_textures)),
(
"pbr_maps",
self.pbr_maps
.as_ref()
.map_or(0, |maps| maps.iter().flatten().map(texture_bytes).sum()),
),
(
"flat_draws",
self.flat.as_ref().map_or(0, flat::Flat::bytes),
),
(
"visibility_buffer",
self.visbuf.as_ref().map_or(0, visbuffer::Visbuffer::bytes),
),
]
}
/// Device memory the backend's allocator holds, by the label of what holds
/// it, largest first, with its allocated and reserved totals (Vulkan and
/// DirectX 12; none on Metal and OpenGL) (`horizon-ctl memory`).
pub fn gpu_memory_by_label(&self) -> Option<(u64, u64, Vec<(String, u64)>)> {
let report = self.device.generate_allocator_report()?;
let mut by_label: HashMap<String, u64> = HashMap::new();
for a in report.allocations {
*by_label.entry(a.name).or_default() += a.size;
}
let mut labels: Vec<_> = by_label.into_iter().collect();
labels.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
Some((
report.total_allocated_bytes,
report.total_reserved_bytes,
labels,
))
}
/// Heap bytes of the renderer's per-section bookkeeping (`horizon-ctl memory`):
/// resident sections, far tiles, block entities and light sources by section.
pub fn cpu_census(&self) -> [(&'static str, usize); 4] {
use std::mem::size_of;
let per_section = |map_len: usize, lists: usize| {
map_len * (size_of::<SectionKey>() + size_of::<Vec<([i32; 3], u32)>>() + 1)
+ lists * size_of::<([i32; 3], u32)>()
};
let lists = |map: &crate::fast_hash::FxHashMap<SectionKey, Vec<([i32; 3], u32)>>| {
per_section(
map.capacity(),
map.values().map(Vec::capacity).sum::<usize>(),
)
};
let animated: usize = self
.sections
.values()
.map(|s| s.animated_layers.len() * size_of::<u16>())
.sum();
[
("resident_sections", self.sections.heap_bytes() + animated),
(
"far_tiles",
self.lods.capacity()
* (size_of::<crate::lod::TileKey>() + size_of::<lod::Tile>() + 1),
),
("block_entities", lists(&self.block_entities)),
("light_sources", lists(&self.light_blocks)),
]
}
}
@@ -0,0 +1,315 @@
//! A resource pack's `core/text` on world text, as vanilla 26.3's `TEXT_POLYGON_OFFSET` and
//! `TEXT_SEE_THROUGH` draw text displays, name tags and their backgrounds.
use crate::core_shader::{CoreProgram, Glue};
use std::sync::Arc;
/// The entity vertices' light bytes are sky then block; vanilla's `UV2` is block then sky.
pub(crate) const WORLD_GLUE: Glue = Glue {
inputs: &[
("Position", "vec3"),
("Color", "vec4"),
("UV0", "vec2"),
("UV2", "ivec2"),
],
prelude: "layout(location = 0) in vec3 hz_pos;\n\
layout(location = 1) in vec2 hz_uv;\n\
layout(location = 3) in vec4 hz_color;\n\
layout(location = 4) in uvec4 hz_light;",
setup: "Position = hz_pos; Color = hz_color; UV0 = hz_uv; UV2 = ivec2(hz_light.y, hz_light.x);",
finish: "",
vertex_id: "int(gl_VertexIndex)",
};
pub(crate) const SEE_THROUGH_GLUE: Glue = Glue {
inputs: &[("Position", "vec3"), ("Color", "vec4"), ("UV0", "vec2")],
prelude: "layout(location = 0) in vec3 hz_pos;\n\
layout(location = 1) in vec2 hz_uv;\n\
layout(location = 3) in vec4 hz_color;",
setup: "Position = hz_pos; Color = hz_color; UV0 = hz_uv;",
finish: "",
vertex_id: "int(gl_VertexIndex)",
};
#[derive(Default)]
pub struct TextShaders {
pub gui: Option<Arc<CoreProgram>>,
pub world: Option<Arc<CoreProgram>>,
pub see_through: Option<Arc<CoreProgram>>,
}
#[derive(Clone, Copy)]
pub(super) struct PackBlocks {
pub(super) globals: [u8; 48],
pub(super) fog: [f32; 6],
}
impl super::Renderer {
pub fn set_world_text_shaders(&mut self, shaders: &TextShaders) {
self.core_text = CoreText::new(
&self.device,
self.output.frame.format,
shaders.world.as_deref(),
shaders.see_through.as_deref(),
&self.entity_atlas,
(&self.lightmap_view, &self.lightmap_sampler),
);
}
pub(super) fn pack_blocks(
&self,
frame: &super::Frame,
fog: [f32; 4],
ends: [f32; 4],
) -> PackBlocks {
let globals = crate::post_chain::Globals {
glint_alpha: frame.glint_alpha,
..frame.post_effects.globals
};
let size = [self.config.width, self.config.height];
PackBlocks {
globals: globals.bytes(size, frame.post_effects.menu_blur_radius as i32),
fog: [fog[0], fog[1], fog[2], fog[3], ends[0], ends[1]],
}
}
pub(super) fn write_core_text(
&self,
pack: &PackBlocks,
view: &super::View,
pose: glam::Mat4,
jitter: glam::Mat4,
fog_color: [f32; 4],
) {
let Some(core) = &self.core_text else {
return;
};
let data =
super::core_lines::uniforms(pack.globals, view, pose, jitter, 1.0, fog_color, pack.fog);
self.queue.write_buffer(&core.uniform, 0, &data);
}
pub(super) fn bind_world_text(
&self,
pass: &mut wgpu::RenderPass<'_>,
allowed: bool,
see_through: bool,
) {
let pack = self
.core_text
.as_ref()
.filter(|_| allowed)
.and_then(|core| {
let pipeline = if see_through {
core.see_through.as_ref()
} else {
core.polygon_offset.as_ref()
};
Some((pipeline?, &core.group))
});
match pack {
Some((pipeline, group)) => {
pass.set_pipeline(pipeline);
pass.set_bind_group(0, group, &[]);
}
None => pass.set_bind_group(0, &self.entity_group, &[]),
}
}
}
const BLOCK_OFFSETS: [u64; 4] = [0, 256, 512, 768];
const BLOCK_SIZES: [u64; 4] = [48, 64, 160, 48];
pub(super) struct CoreText {
pub(super) polygon_offset: Option<wgpu::RenderPipeline>,
pub(super) see_through: Option<wgpu::RenderPipeline>,
pub(super) group: wgpu::BindGroup,
uniform: wgpu::Buffer,
}
impl CoreText {
pub(super) fn new(
device: &wgpu::Device,
format: wgpu::TextureFormat,
world: Option<&CoreProgram>,
see_through: Option<&CoreProgram>,
atlas: &wgpu::TextureView,
lightmap: (&wgpu::TextureView, &wgpu::Sampler),
) -> Option<Self> {
if world.is_none() && see_through.is_none() {
return None;
}
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
let internal = device.push_error_scope(wgpu::ErrorFilter::Internal);
let buffer = |binding| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: wgpu::BufferSize::new(BLOCK_SIZES[binding as usize]),
},
count: None,
};
let texture = |binding| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
};
let sampler = |binding| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
};
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Resource pack world text"),
entries: &[
buffer(0),
buffer(1),
buffer(2),
buffer(3),
texture(4),
sampler(5),
texture(8),
sampler(9),
],
});
let uniform = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Resource pack world text uniforms"),
size: 1024,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let glyphs = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("Resource pack world text glyphs"),
..Default::default()
});
let mut resources: Vec<wgpu::BindGroupEntry> = (0..4)
.map(|i| wgpu::BindGroupEntry {
binding: i as u32,
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
buffer: &uniform,
offset: BLOCK_OFFSETS[i],
size: wgpu::BufferSize::new(BLOCK_SIZES[i]),
}),
})
.collect();
resources.extend([
wgpu::BindGroupEntry {
binding: 4,
resource: wgpu::BindingResource::TextureView(atlas),
},
wgpu::BindGroupEntry {
binding: 5,
resource: wgpu::BindingResource::Sampler(&glyphs),
},
wgpu::BindGroupEntry {
binding: 8,
resource: wgpu::BindingResource::TextureView(lightmap.0),
},
wgpu::BindGroupEntry {
binding: 9,
resource: wgpu::BindingResource::Sampler(lightmap.1),
},
]);
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Resource pack world text"),
layout: &layout,
entries: &resources,
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Resource pack world text"),
bind_group_layouts: &[Some(&layout)],
..Default::default()
});
let attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint8x4,5=>Snorm8x4,6=>Float32x2];
let buffers = [Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<crate::mesher::Vertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &attributes,
})];
let pipeline = |label, program: &CoreProgram, depth: wgpu::DepthStencilState| {
let module = |module: &naga::Module| {
device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some(label),
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module.clone())),
})
};
let (vertex, fragment) = (module(&program.vertex), module(&program.fragment));
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(label),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &vertex,
entry_point: Some("main"),
compilation_options: Default::default(),
buffers: &buffers,
},
primitive: wgpu::PrimitiveState {
cull_mode: Some(wgpu::Face::Back),
..Default::default()
},
depth_stencil: Some(depth),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &fragment,
entry_point: Some("main"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
})
};
let depth = |write, compare, bias| wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(write),
depth_compare: Some(compare),
stencil: Default::default(),
bias,
};
let offset = wgpu::DepthBiasState {
constant: 10,
slope_scale: 1.0,
clamp: 0.0,
};
let polygon_offset = world.map(|program| {
pipeline(
"Resource pack polygon offset text",
program,
depth(true, wgpu::CompareFunction::GreaterEqual, offset),
)
});
let see_through = see_through.map(|program| {
pipeline(
"Resource pack see-through text",
program,
depth(false, wgpu::CompareFunction::Always, Default::default()),
)
});
let internal = pollster::block_on(internal.pop());
if let Some(e) = internal.or(pollster::block_on(validation.pop())) {
log::warn!("the resource pack's world text shader is not used: {e}");
return None;
}
Some(Self {
polygon_offset,
see_through,
group,
uniform,
})
}
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,201 @@
use super::*;
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
pollster::block_on(adapter.request_device(&Default::default())).ok()
}
#[test]
fn a_pack_text_shader_reads_the_light_as_vanilla_does() {
let Some((device, queue)) = device() else {
eprintln!("no GPU: skipping");
return;
};
let vsh = "#version 330\n#extension GL_ARB_separate_shader_objects : require\n\
layout(location = 0) in vec3 Position;\nlayout(location = 1) in vec4 Color;\n\
layout(location = 2) in vec2 UV0;\nlayout(location = 3) in ivec2 UV2;\n\
layout(location = 0) flat out ivec2 light;\n\
void main() { gl_Position = vec4(Position, 1.0); light = UV2; }\n";
let fsh = "#version 330\n#extension GL_ARB_separate_shader_objects : require\n\
layout(location = 0) flat in ivec2 light;\nlayout(location = 0) out vec4 fragColor;\n\
void main() { fragColor = vec4(vec2(light) / 255.0, 0.0, 1.0); }\n";
let program =
crate::core_shader::translate_core(vsh, fsh, &[], &|_| None, &WORLD_GLUE).unwrap();
const SIZE: u32 = 64;
let texture = |format, usage| {
device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: SIZE,
height: SIZE,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage,
view_formats: &[],
})
};
let binding = wgpu::TextureUsages::TEXTURE_BINDING;
let atlas = texture(wgpu::TextureFormat::Rgba8Unorm, binding).create_view(&Default::default());
let sampler = device.create_sampler(&Default::default());
let text = CoreText::new(
&device,
wgpu::TextureFormat::Rgba8Unorm,
Some(&program),
None,
&atlas,
(&atlas, &sampler),
)
.expect("the pack's pipeline");
let target = wgpu::TextureUsages::RENDER_ATTACHMENT;
let color = texture(
wgpu::TextureFormat::Rgba8Unorm,
target | wgpu::TextureUsages::COPY_SRC,
);
let depth = texture(wgpu::TextureFormat::Depth32Float, target);
let light = 0x30 | (0xC0 << 8);
let corner = |x: f32, y: f32| crate::mesher::Vertex {
pos: [x, y, 0.5],
uv: [0.0; 2],
layer: 0,
color: u32::MAX,
light,
normal: [0; 4],
glint_uv: [0.0; 2],
};
let quad = [
corner(-1.0, -1.0),
corner(1.0, -1.0),
corner(1.0, 1.0),
corner(-1.0, -1.0),
corner(1.0, 1.0),
corner(-1.0, 1.0),
];
let vertices = wgpu::util::DeviceExt::create_buffer_init(
&device,
&wgpu::util::BufferInitDescriptor {
label: None,
contents: bytemuck::cast_slice(&quad),
usage: wgpu::BufferUsages::VERTEX,
},
);
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: u64::from(SIZE * SIZE * 4),
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut encoder = device.create_command_encoder(&Default::default());
{
let color_view = color.create_view(&Default::default());
let depth_view = depth.create_view(&Default::default());
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: None,
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &color_view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &depth_view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(0.0),
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
});
pass.set_pipeline(text.polygon_offset.as_ref().unwrap());
pass.set_bind_group(0, &text.group, &[]);
pass.set_vertex_buffer(0, vertices.slice(..));
pass.draw(0..6, 0..1);
}
encoder.copy_texture_to_buffer(
color.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &read,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(SIZE * 4),
rows_per_image: Some(SIZE),
},
},
color.size(),
);
queue.submit([encoder.finish()]);
read.slice(..)
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let pixels = read.slice(..).get_mapped_range().unwrap().to_vec();
let middle = ((SIZE / 2 * SIZE + SIZE / 2) * 4) as usize;
assert_eq!(
&pixels[middle..middle + 4],
&[0xC0, 0x30, 0, 0xFF],
"UV2 is (block, sky)"
);
}
#[test]
#[ignore = "requires prepared resources in HORIZON_TEST_RESOURCES or .local/horizon-client/26.3, and a GPU"]
fn vanillas_text_shader_makes_both_world_text_pipelines() {
let root = std::env::var_os("HORIZON_TEST_RESOURCES")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| {
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION)
});
let read = |path: &str| std::fs::read_to_string(root.join(path)).ok();
let vertex = read("assets/minecraft/shaders/core/text.vsh").expect("vanilla's text.vsh");
let fragment = read("assets/minecraft/shaders/core/text.fsh").expect("vanilla's text.fsh");
let include = |id: &str| {
let (namespace, path) = id.split_once(':')?;
read(&format!("assets/{namespace}/{path}"))
};
let translate = |defines: &[&str], glue: &Glue| {
crate::core_shader::translate_core(&vertex, &fragment, defines, &include, glue)
.expect("vanilla's text shader translates")
};
let world = translate(&[], &WORLD_GLUE);
let see_through = translate(&["IS_SEE_THROUGH"], &SEE_THROUGH_GLUE);
let (device, _) = device().expect("a GPU");
let texture = || {
device
.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 4,
height: 4,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
})
.create_view(&Default::default())
};
let (atlas, lightmap) = (texture(), texture());
let sampler = device.create_sampler(&Default::default());
let text = CoreText::new(
&device,
wgpu::TextureFormat::Rgba8Unorm,
Some(&world),
Some(&see_through),
&atlas,
(&lightmap, &sampler),
)
.expect("the pack's world text pipelines");
assert!(text.polygon_offset.is_some() && text.see_through.is_some());
}
@@ -33,7 +33,7 @@ struct Uniform {
mode: u32,
pad: [u32; 2],
hdr_settings: [f32; 4],
hdr_shares: [f32; 4],
hdr_gains: [f32; 4],
hdr_light: [f32; 4],
}
@@ -174,7 +174,12 @@ fn mesh() -> Vec<Vertex> {
}
impl CustomSky {
pub(super) fn new(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self {
/// The layers' pipelines for a frame of `format`, holding linear light where `linear`
/// (`linear_blend`).
pub(super) fn new(
device: &wgpu::Device,
(format, linear): (wgpu::TextureFormat, bool),
) -> Self {
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Custom sky bindings"),
entries: &[
@@ -261,10 +266,7 @@ impl CustomSky {
"fs_custom_sky"
}),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &[(
"HDR_WRITE_LIGHT",
f64::from(u8::from(format == super::LINEAR_FRAME)),
)],
constants: &super::linear_blend::frame_constants(format, linear),
..Default::default()
},
targets: &[Some(wgpu::ColorTargetState {
@@ -405,7 +407,7 @@ impl CustomSky {
self.written_gain = (globals.hdr_light[3] != 0.0).then(|| {
let mark =
((1.0 - 0.24 * globals.hdr_light[1]).clamp(0.0, 1.0) * 255.0).round() / 255.0;
if globals.hdr_settings[2] > 0.5 && globals.hdr_settings[0] > 1.0 {
if globals.hdr_settings[2] > 0.5 {
(1.0 + (globals.hdr_settings[1] - 1.0) * (1.0 - mark) / 0.24).powf(1.0 / 2.2)
} else {
1.0
@@ -426,7 +428,7 @@ impl CustomSky {
mode: mode as u32,
pad: [0; 2],
hdr_settings: globals.hdr_settings,
hdr_shares: globals.hdr_shares,
hdr_gains: globals.hdr_gains,
hdr_light: globals.hdr_light,
};
let offset = STRIDE * self.frame.len() as u64;
@@ -617,7 +619,7 @@ mod tests {
label: None,
source: wgpu::ShaderSource::Wgsl(source.into()),
});
let sky = CustomSky::new(&device, wgpu::TextureFormat::Rgba16Float);
let sky = CustomSky::new(&device, (wgpu::TextureFormat::Rgba16Float, false));
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: None,
bind_group_layouts: &[Some(&sky.layout)],
@@ -661,19 +663,37 @@ mod tests {
let view = target.create_view(&Default::default());
// Independent scalar expectation: a sky mark of 206/255 gives this gamma gain.
let hdr_gain = (1.0f32 + 3.0 * (1.0 - 206.0 / 255.0) / 0.24).powf(1.0 / 2.2);
for (mode_index, mode, debug, written) in MODES
// A frame of linear light (`FRAME_LINEAR`, `[frame] blend = "linear"`): the layer stored
// decoded, products kept exact.
let linear_modes = [
Blend::Replace,
Blend::Multiply,
Blend::Overlay,
Blend::Alpha,
]
.map(|m| {
(
MODES.iter().position(|&n| n == m).unwrap(),
m,
false,
false,
true,
)
});
for (mode_index, mode, debug, written, linear) in MODES
.iter()
.copied()
.enumerate()
.map(|(i, m)| (i, m, false, true))
.chain([(5, Blend::Screen, true, true)])
.map(|(i, m)| (i, m, false, true, false))
.chain([(5, Blend::Screen, true, true, false)])
.chain(
MODES
.iter()
.copied()
.enumerate()
.map(|(i, m)| (i, m, false, false)),
.map(|(i, m)| (i, m, false, false, false)),
)
.chain(linear_modes)
{
let gain = if written { hdr_gain } else { 1.0 };
let uniform = Uniform {
@@ -682,7 +702,7 @@ mod tests {
mode: mode_index as u32,
pad: [0; 2],
hdr_settings: [5.0, 4.0, 1.0, f32::from(u8::from(debug))],
hdr_shares: [0.2, 0.5, 0.6, 1.0],
hdr_gains: [1.6, 2.4, 2.8, 4.0],
hdr_light: [0.8, 0.8, 1.0, f32::from(u8::from(written))],
};
let buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
@@ -717,7 +737,10 @@ mod tests {
module: &shader,
entry_point: Some(entry),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &[("HDR_WRITE_LIGHT", 1.0)],
constants: &[
("HDR_WRITE_LIGHT", 1.0),
("FRAME_LINEAR", f64::from(u8::from(linear))),
],
..Default::default()
},
targets: &[Some(wgpu::ColorTargetState {
@@ -824,7 +847,17 @@ mod tests {
(alpha - coverage).abs() < 0.001,
"{mode:?}: alpha {alpha}, expected {coverage}"
);
let expected = if debug {
let decode = |v: f32| v.powf(2.2);
let expected = if linear {
// The sky under it is linear light, 2.0; the layer is gamma.
match mode {
Blend::Replace => decode(t),
Blend::Multiply => d * decode(1.0 + (t - 1.0) * k),
Blend::Overlay => d * decode(2.0 * (0.5 + (t - 0.5) * k)),
Blend::Alpha => decode(t) * k + d * (1.0 - k),
_ => unreachable!(),
}
} else if debug {
let grey = (0.2126f32 * 0.8f32.powf(2.2)
+ 0.7152 * 0.4f32.powf(2.2)
+ 0.0722 * 0.2f32.powf(2.2))
@@ -845,11 +878,11 @@ mod tests {
};
assert!(
(got - expected).abs() < 0.004,
"{mode:?} debug {debug}: {got} expected {expected}"
"{mode:?} debug {debug} linear {linear}: {got} expected {expected}"
);
}
eprintln!(
"HDR custom sky GPU ran all nine modes with and without gain, plus debug Screen, checking RGB and coverage"
"HDR custom sky GPU ran all nine modes with and without gain, plus debug Screen and four on linear light, checking RGB and coverage"
);
}
}
@@ -10,7 +10,7 @@ struct Layer {
mode: u32,
pad: vec2<u32>,
hdr_settings: vec4<f32>,
hdr_shares: vec4<f32>,
hdr_gains: vec4<f32>,
hdr_light: vec4<f32>,
}
@group(0) @binding(0) var<uniform> layer: Layer;
@@ -54,18 +54,27 @@ fn custom_sky_color(in: Out) -> vec4<f32> {
var color = t.rgb;
if HDR_WRITE_LIGHT && layer.hdr_light.w != 0.0 &&
(layer.mode == ADD || layer.mode == SUBTRACT || layer.mode == REPLACE || layer.mode == ALPHA) {
color = hdr_written(color, 1.0 - 0.24 * layer.hdr_light.y, layer.hdr_settings, layer.hdr_shares);
color = hdr_written(color, 1.0 - 0.24 * layer.hdr_light.y, layer.hdr_settings, layer.hdr_gains);
}
// On a frame of linear light (`FRAME_LINEAR`) the layer's colour is decoded as it is stored.
switch layer.mode {
// dst * src: white leaves the sky alone.
case MULTIPLY: { return vec4(mix(vec3(1.0), t.rgb, k), 1.0); }
// 2 * dst * src: grey leaves it alone.
case OVERLAY: { return vec4(mix(vec3(0.5), t.rgb, k), 1.0); }
case REPLACE: { return vec4(color, 1.0); }
// mix(dst, src, alpha).
case ALPHA: { return vec4(color, k); }
// add, subtract, dodge, burn, screen: the layer premultiplied, black leaves it alone.
default: { return vec4(color * k, 1.0); }
// dst * src: white leaves the sky alone. Decoded, the product stays exact:
// (dst * src)^2.2 = dst^2.2 * src^2.2.
case MULTIPLY: { return frame_stored(vec4(mix(vec3(1.0), t.rgb, k), 1.0)); }
// 2 * dst * src: grey leaves it alone. Decoded as entity.wgsl's crumbling, (2 src)^2.2 / 2,
// so it stays exact too.
case OVERLAY: {
let factor = mix(vec3(0.5), t.rgb, k);
if FRAME_LINEAR { return vec4(pow(2.0 * factor, vec3(2.2)) * 0.5, 1.0); }
return vec4(factor, 1.0);
}
case REPLACE: { return frame_stored(vec4(color, 1.0)); }
// mix(dst, src, alpha): alpha is coverage, kept.
case ALPHA: { return frame_stored(vec4(color, k)); }
// add, subtract, dodge, burn, screen: the layer premultiplied, black leaves it alone. On
// linear light add and subtract add and take away light, as `"linear"` means to; dodge,
// burn and screen come out near their gamma results, not exactly.
default: { return vec4(frame_stored(vec4(color, 1.0)).rgb * k, 1.0); }
}
}
@@ -75,5 +84,5 @@ fn custom_sky_color(in: Out) -> vec4<f32> {
@fragment fn fs_custom_sky_screen_light(in: Out) -> @location(0) vec4<f32> {
let color = custom_sky_color(in);
return vec4(hdr_written(color.rgb, 1.0 - 0.24 * layer.hdr_light.y, layer.hdr_settings, layer.hdr_shares), color.a);
return vec4(hdr_written(color.rgb, 1.0 - 0.24 * layer.hdr_light.y, layer.hdr_settings, layer.hdr_gains), color.a);
}
+124 -24
View File
@@ -202,34 +202,70 @@ pub fn is_adreno_dx12(info: &wgpu::AdapterInfo) -> bool {
info.backend == wgpu::Backend::Dx12 && matches!(info.vendor, 0x4d4f_4351 | 0x5143)
}
/// A released Mesa version from its driver info, allowing distribution suffixes.
/// Development and release-candidate builds do not establish a stable release's safety.
fn parse_mesa_version(driver_info: &str) -> Option<(u32, u32, u32)> {
let version = driver_info
.strip_prefix("Mesa ")?
.split_whitespace()
.next()?;
let (version, suffix) = version.split_once('-').unwrap_or((version, ""));
if suffix.starts_with("devel") || suffix.starts_with("rc") {
return None;
}
let mut parts = version.split('.');
let version = (
parts.next()?.parse().ok()?,
parts.next()?.parse().ok()?,
parts.next()?.parse().ok()?,
);
parts.next().is_none().then_some(version)
}
/// Intel Gen11 (Ice Lake) and Gen12 (Tiger Lake, Rocket Lake, Alder Lake,
/// Raptor Lake) ANV Vulkan drivers have broken `first_instance` for
/// Raptor Lake) ANV Vulkan drivers had broken `first_instance` for
/// instance-step vertex attributes (#3529, #4599), causing terrain sections to
/// be drawn with another section's origin and flicker.
///
/// PR #15007 tested Raptor Lake (RPL-U, 0xa7a1) on Mesa 26.0.8 without terrain
/// flicker. Allow indirect multi-draw and GPU culling for Raptor Lake from that
/// release; earlier or unidentified drivers keep the workaround.
pub fn is_broken_intel_vulkan(info: &wgpu::AdapterInfo) -> bool {
info.backend == wgpu::Backend::Vulkan
&& info.vendor == 0x8086
&& (matches!(
info.device,
0x8a50..=0x8a71 // Gen11: Ice Lake
| 0x9a40..=0x9a7f // Gen12: Tiger Lake
| 0x4c80..=0x4c9a // Gen12: Rocket Lake
| 0x4600..=0x46ff // Gen12: Alder Lake (incl. 0x46a8 in #4599)
| 0xa700..=0xa7ff // Gen12: Raptor Lake
) || [
"ICL",
"Ice Lake",
"TGL",
"Tiger Lake",
"RKL",
"Rocket Lake",
"ADL",
"Alder Lake",
"RPL",
"Raptor Lake",
]
.iter()
.any(|tag| info.name.contains(tag)))
if info.backend != wgpu::Backend::Vulkan || info.vendor != 0x8086 {
return false;
}
let is_raptor_lake = matches!(info.device, 0xa700..=0xa7ff)
|| info.name.contains("RPL")
|| info.name.contains("Raptor Lake");
if is_raptor_lake
&& parse_mesa_version(&info.driver_info).is_some_and(|version| version >= (26, 0, 8))
{
return false;
}
matches!(
info.device,
0x8a50..=0x8a71 // Gen11: Ice Lake
| 0x9a40..=0x9a7f // Gen12: Tiger Lake
| 0x4c80..=0x4c9a // Gen12: Rocket Lake
| 0x4600..=0x46ff // Gen12: Alder Lake (incl. 0x46a8 in #4599)
| 0xa700..=0xa7ff // Gen12: Raptor Lake
) || [
"ICL",
"Ice Lake",
"TGL",
"Tiger Lake",
"RKL",
"Rocket Lake",
"ADL",
"Alder Lake",
"RPL",
"Raptor Lake",
]
.iter()
.any(|tag| info.name.contains(tag))
}
fn supported(adapter: &wgpu::Adapter, device: &wgpu::Device) -> bool {
@@ -1134,6 +1170,70 @@ mod tests {
assert!(!is_adreno_dx12(&info));
}
#[test]
fn intel_vulkan_detection_preserves_workaround_on_older_mesa_and_allows_modern_raptor_lake() {
let mut info = wgpu::AdapterInfo {
name: "Intel(R) Iris(R) Xe Graphics (RPL-U)".into(),
vendor: 0x8086,
device: 0xa7a1,
device_type: wgpu::DeviceType::IntegratedGpu,
device_pci_bus_id: String::new(),
driver: "Intel open-source Mesa driver".into(),
driver_info: "Mesa 26.0.8-1ubuntu0.3".into(),
backend: wgpu::Backend::Vulkan,
subgroup_min_size: 0,
subgroup_max_size: 0,
transient_saves_memory: None,
limit_bucket: None,
};
for (driver_info, broken) in [
("Mesa 26.0.8-1ubuntu0.3", false),
("Mesa 26.0.8", false),
("Mesa 26.1.0", false),
("Mesa 27.0.0", false),
("Mesa 25.2.0", true),
("Mesa 26.0.0", true),
("Mesa 26.0.7", true),
("Mesa 26.0.8-devel", true),
("Mesa 27.0.0-rc1", true),
("Mesa 27.0.0-devel (git-abcdef)", true),
("Mesa 26", true),
("Mesa 26.0.8.1", true),
("Mesa unknown", true),
("26.0.8", true),
("", true),
] {
info.driver_info = driver_info.into();
assert_eq!(is_broken_intel_vulkan(&info), broken, "{driver_info}");
}
// PCI identification and the name fallback both allow the tested family.
info.driver_info = "Mesa 26.0.8".into();
info.device = 0;
assert!(!is_broken_intel_vulkan(&info));
info.name = "Intel Graphics".into();
info.device = 0xa7a1;
assert!(!is_broken_intel_vulkan(&info));
// Earlier families keep their workaround even with a modern Mesa release.
for (device, name) in [
(0x8a52, "Intel Ice Lake"),
(0x9a49, "Intel Tiger Lake"),
(0x4c8a, "Intel Rocket Lake"),
(0x46a8, "Intel Alder Lake"),
] {
info.device = device;
info.name = name.into();
assert!(is_broken_intel_vulkan(&info), "{name}");
}
// A Mesa-looking version cannot turn on this workaround for other backends/vendors.
info.backend = wgpu::Backend::Dx12;
assert!(!is_broken_intel_vulkan(&info));
info.backend = wgpu::Backend::Vulkan;
info.vendor = 0x10de;
assert!(!is_broken_intel_vulkan(&info));
}
/// Draws as `render` writes them: solid (page, quads at a base vertex)
/// first, then translucent (page, sorted page, count, first sorted index,
/// base vertex).
@@ -0,0 +1,72 @@
//! The frame policy: which frame a renderer draws, what the overlays draw on and whether
//! Horizon's tone curve runs (tests in `frame_policy_tests.rs`).
use super::{LINEAR_FRAME, WIDE_FRAME};
/// What a renderer's frame is drawn in: `LINEAR_FRAME` under the switch where the device draws
/// it, for a 0.2 pack, or for the layered HDR frame where the surface offers HDR output
/// (`linear`: `(a 0.2 pack picked, the layered HDR frame, the device draws it)`); else
/// `WIDE_FRAME` when it is made with HDR on and Smooth Gradients (`wide_frame`,
/// `output::resolve_frame`), the surface offering HDR output (`hdr_offered`) and the device
/// drawing it (`draws_wide_frame`): an 8-bit frame's steps band in dark gradients, and HDR
/// shows them. Else the swapchain's 8-bit format, as without HDR.
pub(super) fn frame_format(
wide_frame: bool,
hdr_offered: bool,
device_draws_it: bool,
swapchain: wgpu::TextureFormat,
linear: (bool, bool, bool),
) -> wgpu::TextureFormat {
if linear.2 && (linear.0 || linear.1 && hdr_offered) {
LINEAR_FRAME
} else if wide_frame && hdr_offered && device_draws_it {
WIDE_FRAME
} else {
swapchain.remove_srgb_suffix()
}
}
/// The frame's format, and whether it is the layered HDR frame (HUD coverage): for HDR on
/// under the switch (`hdr_float`), with no pack or a 0.2 one, where the device keeps the HUD
/// on a layer of its own.
pub(super) fn frame_configuration(
wide_frame: bool,
device: (bool, bool, bool, bool),
swapchain: wgpu::TextureFormat,
linear_pack: bool,
hdr_float: bool,
) -> (wgpu::TextureFormat, bool) {
let (hdr_offered, wide_ok, linear_ok, coverage_ok) = device;
let layered = hdr_float && hdr_offered && linear_ok && coverage_ok;
(
frame_format(
wide_frame,
hdr_offered,
wide_ok,
swapchain,
(linear_pack, layered, linear_ok),
),
layered,
)
}
/// What the hand, the camera overlays and the HUD draw on: the frame, except for a 0.2 pack's
/// float frame without the HUD's layer, which is SDR: the pack's picture finishes on the
/// swapchain's 8-bit format first, so they clip and blend per draw as on vanilla's frame.
pub(super) fn overlay_format(
frame: wgpu::TextureFormat,
layered: bool,
swapchain: wgpu::TextureFormat,
) -> wgpu::TextureFormat {
if frame == LINEAR_FRAME && !layered {
swapchain.remove_srgb_suffix()
} else {
frame
}
}
/// Whether Horizon's tone curve (`Hdr::tonemap`) runs on this frame: on the layered frame's
/// world, unless a 0.2 pack's `[tonemap]` already mapped it. One tone mapping, never two.
pub(super) fn horizon_tone_curve(layered: bool, world: bool, pack_tonemap: bool) -> bool {
layered && world && !pack_tonemap
}
@@ -0,0 +1,137 @@
//! The frame policy: which frame a renderer draws (`frame_configuration`), and what follows
//! from the frame chosen.
#[test]
fn float_frames_never_schedule_light_mark_passes() {
use super::{LINEAR_FRAME, WIDE_FRAME, light_marks};
use wgpu::TextureFormat::{Bgra8Unorm, Rgb10a2Unorm, Rgba8Unorm};
for hdr in [false, true] {
assert!(!light_marks(LINEAR_FRAME, hdr));
for format in [WIDE_FRAME, Bgra8Unorm, Rgba8Unorm, Rgb10a2Unorm] {
assert_eq!(light_marks(format, hdr), hdr);
}
}
}
#[test]
fn frame_policy_keeps_compatibility_frames_and_requires_layering_for_float_hdr() {
use super::{LINEAR_FRAME, WIDE_FRAME, frame_configuration};
use wgpu::TextureFormat::{Bgra8Unorm, Bgra8UnormSrgb};
for bits in 0..128 {
let hdr = bits & 1 != 0;
let offered = bits & 2 != 0;
let wide_ok = bits & 4 != 0;
let float_ok = bits & 8 != 0;
let coverage_ok = bits & 16 != 0;
let switch = bits & 32 != 0;
let smooth = bits & 64 != 0;
for pack in ["none", "0.1", "0.2", "shaders"] {
let float_hdr = matches!(pack, "none" | "0.2");
let expected = if switch && float_ok && float_hdr && hdr && offered && coverage_ok {
(LINEAR_FRAME, true)
} else if switch && float_ok && pack == "0.2" {
(LINEAR_FRAME, false)
} else if hdr && smooth && offered && wide_ok {
(WIDE_FRAME, false)
} else {
(Bgra8Unorm, false)
};
assert_eq!(
frame_configuration(
hdr && smooth,
(offered, wide_ok, float_ok, coverage_ok),
Bgra8UnormSrgb,
switch && pack == "0.2",
switch && hdr && float_hdr,
),
expected,
"bits {bits}, pack {pack}"
);
}
}
}
#[test]
fn float_hdr_falls_back_for_each_missing_hud_layer_requirement() {
use super::{LINEAR_FRAME, WIDE_FRAME, frame_configuration, hud_coverage};
use wgpu::{DownlevelFlags as D, TextureFormat as F, TextureFormatFeatureFlags as Fl};
use wgpu::{TextureFormatFeatures, TextureUsages as U};
let usages = U::RENDER_ATTACHMENT | U::TEXTURE_BINDING;
for (attachments, flags, allowed_usages, format_flags) in [
(1, D::INDEPENDENT_BLEND, usages, Fl::BLENDABLE),
(2, D::empty(), usages, Fl::BLENDABLE),
(2, D::INDEPENDENT_BLEND, U::TEXTURE_BINDING, Fl::BLENDABLE),
(2, D::INDEPENDENT_BLEND, U::RENDER_ATTACHMENT, Fl::BLENDABLE),
(2, D::INDEPENDENT_BLEND, usages, Fl::empty()),
] {
let coverage = hud_coverage::supported(
attachments,
flags,
TextureFormatFeatures {
allowed_usages,
flags: format_flags,
},
);
let device = (true, true, true, coverage);
assert_eq!(
frame_configuration(true, device, F::Bgra8Unorm, false, true),
(WIDE_FRAME, false)
);
assert_eq!(
frame_configuration(false, device, F::Bgra8Unorm, false, true),
(F::Bgra8Unorm, false)
);
assert_eq!(
frame_configuration(true, device, F::Bgra8Unorm, true, false),
(LINEAR_FRAME, false)
);
// A 0.2 pack keeps its float frame, without the layer: SDR.
assert_eq!(
frame_configuration(true, device, F::Bgra8Unorm, true, true),
(LINEAR_FRAME, false)
);
}
}
#[test]
fn a_0_2_pack_without_the_hud_layer_finishes_on_the_swapchains_8_bits() {
use super::{LINEAR_FRAME, WIDE_FRAME, overlay_format};
use wgpu::TextureFormat::{Bgra8Unorm, Bgra8UnormSrgb, Rgba8Unorm};
assert_eq!(
overlay_format(LINEAR_FRAME, false, Bgra8UnormSrgb),
Bgra8Unorm
);
assert_eq!(overlay_format(LINEAR_FRAME, false, Rgba8Unorm), Rgba8Unorm);
// Everything else draws its overlays on its frame: the layered frame's light, the
// 16-bit frame, the 8-bit frame.
for (frame, layered) in [
(LINEAR_FRAME, true),
(WIDE_FRAME, false),
(Bgra8Unorm, false),
] {
assert_eq!(overlay_format(frame, layered, Bgra8UnormSrgb), frame);
}
}
#[test]
fn one_tone_curve_runs_on_the_layered_world() {
use super::horizon_tone_curve;
// No pack, or a 0.2 pack without `[tonemap]`: Horizon's curve.
assert!(horizon_tone_curve(true, true, false));
// A pack's `[tonemap]` mapped it already; never both.
assert!(!horizon_tone_curve(true, true, true));
// No world (the title screen), or no layered frame (SDR, the compatibility frames).
assert!(!horizon_tone_curve(true, false, false));
assert!(!horizon_tone_curve(false, true, false));
}
#[test]
fn only_a_no_pack_layered_world_applies_hdr_gain() {
use super::no_pack_hdr_gain;
assert!(no_pack_hdr_gain(true, false, true));
assert!(!no_pack_hdr_gain(false, false, true));
assert!(!no_pack_hdr_gain(true, true, true));
assert!(!no_pack_hdr_gain(true, false, false));
}
@@ -71,7 +71,7 @@ use super::{
Frustum, SectionKey,
arena::{Arena, try_create},
occlusion::Reach,
pack_timing::PassTiming,
pack_timing::{PassTiming, compute_parts},
resident,
submit::Queue,
upload::Uploads,
@@ -374,6 +374,21 @@ pub(super) enum Prepared {
OutOfMemory,
}
/// A part of a compute pass: `pipeline` over `[x, y]` workgroups, nothing when `x` is 0.
fn step<'a>(
group: &'a wgpu::BindGroup,
pipeline: &'a wgpu::ComputePipeline,
[x, y]: [u32; 2],
) -> impl FnMut(&mut wgpu::ComputePass<'_>) + 'a {
move |pass| {
if x > 0 {
pass.set_bind_group(0, group, &[]);
pass.set_pipeline(pipeline);
pass.dispatch_workgroups(x, y, 1);
}
}
}
/// The frame's `tables` before the regions, into `words`, and the parameters
/// the frustum test and facing read with them; `None` when the CPU draws the
/// frame (`Prepared::Cpu`): a sum of a plane's terms could leave i32
@@ -1190,12 +1205,13 @@ impl GpuCulling {
uploads: &mut Uploads,
encoder: &mut wgpu::CommandEncoder,
input: &FrameInputs<'_>,
timing: Option<&mut PassTiming>,
) -> Prepared {
self.pages.clear();
self.ids = 0;
self.n_updates = 0;
self.occluding = false;
self.prepare_buffers(device, queue, uploads, encoder, input)
self.prepare_buffers(device, queue, uploads, encoder, input, timing)
.unwrap_or(Prepared::OutOfMemory)
}
@@ -1206,6 +1222,7 @@ impl GpuCulling {
uploads: &mut Uploads,
encoder: &mut wgpu::CommandEncoder,
input: &FrameInputs<'_>,
mut timing: Option<&mut PassTiming>,
) -> Option<Prepared> {
// Every buffer grows before anything is written. One made anew drops
// the bind group at once, and a new section table needs every record:
@@ -1287,6 +1304,9 @@ impl GpuCulling {
occl = OCCLUDE | if probe { PROBE } else { 0 };
}
}
let uploading = timing
.as_mut()
.and_then(|t| t.encoder_begin(encoder, "GPU culling uploads"));
if self.records.all_dirty {
// The whole table, after it grew or more records changed than
// updates take (GPU culling switched on in a loaded world, a
@@ -1352,6 +1372,9 @@ impl GpuCulling {
// are written before they are read.
let cleared = if self.occluding { tests_at } else { phase1_end };
encoder.clear_buffer(&self.counts.buffer, 0, Some(cleared * 4));
if let Some(t) = timing {
t.encoder_end(encoder, uploading);
}
if self.group.is_none() {
self.group = Some(self.bind(device));
if let Some(test) = &mut self.test {
@@ -1403,23 +1426,24 @@ impl GpuCulling {
let Some(group) = self.group.as_ref().filter(|_| self.ids > 0) else {
return;
};
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("GPU culling"),
timestamp_writes: timing.and_then(|t| t.compute_writes("GPU culling")),
});
pass.set_bind_group(0, group, &[]);
let [apply, cull, scan, scatter] = &self.pipelines;
if self.n_updates > 0 {
pass.set_pipeline(apply);
pass.dispatch_workgroups(self.n_updates.div_ceil(WORKGROUP), 1, 1);
}
let blocks = self.ids.div_ceil(WORKGROUP);
pass.set_pipeline(cull);
pass.dispatch_workgroups(blocks.min(ROW), blocks.div_ceil(ROW), 1);
pass.set_pipeline(scan);
pass.dispatch_workgroups(self.pass_pages, 1, 1);
pass.set_pipeline(scatter);
pass.dispatch_workgroups(blocks.min(ROW), blocks.div_ceil(ROW), 1);
let rows = [blocks.min(ROW), blocks.div_ceil(ROW)];
let updates = [self.n_updates.div_ceil(WORKGROUP), 1];
let (mut apply, mut cull) = (step(group, apply, updates), step(group, cull, rows));
let pages = [self.pass_pages, 1];
let (mut scan, mut scatter) = (step(group, scan, pages), step(group, scatter, rows));
compute_parts(
encoder,
timing,
"GPU culling",
&mut [
("apply", &mut apply),
("cull", &mut cull),
("scan", &mut scan),
("scatter", &mut scatter),
],
);
}
/// The kept sections' solid draws, a count draw per page, with the quad
@@ -1494,19 +1518,22 @@ impl GpuCulling {
if !self.occluding {
return;
}
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("GPU culling phase 2"),
timestamp_writes: timing.and_then(|t| t.compute_writes("GPU culling phase 2")),
});
pass.set_bind_group(0, group, &[]);
let [count2, scan2, scatter2] = &test.phase2;
let blocks = self.ids.div_ceil(WORKGROUP);
pass.set_pipeline(count2);
pass.dispatch_workgroups(blocks.min(ROW), blocks.div_ceil(ROW), 1);
pass.set_pipeline(scan2);
pass.dispatch_workgroups(self.pass_pages, 1, 1);
pass.set_pipeline(scatter2);
pass.dispatch_workgroups(blocks.min(ROW), blocks.div_ceil(ROW), 1);
let rows = [blocks.min(ROW), blocks.div_ceil(ROW)];
let pages = [self.pass_pages, 1];
let (mut count, mut scatter) = (step(group, count2, rows), step(group, scatter2, rows));
let mut scan = step(group, scan2, pages);
compute_parts(
encoder,
timing,
"GPU culling phase 2",
&mut [
("count2", &mut count),
("scan2", &mut scan),
("scatter2", &mut scatter),
],
);
}
/// After the frame's world passes (phase 2's counts too), in the same
@@ -2720,6 +2747,11 @@ mod tests {
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
let needed =
wgpu::Features::MULTI_DRAW_INDIRECT_COUNT | wgpu::Features::INDIRECT_FIRST_INSTANCE;
let timing = adapter.features()
& (wgpu::Features::TIMESTAMP_QUERY
| wgpu::Features::TIMESTAMP_QUERY_INSIDE_PASSES
| wgpu::Features::TIMESTAMP_QUERY_INSIDE_ENCODERS
| wgpu::Features::SHADER_EARLY_DEPTH_TEST);
if !adapter.features().contains(needed)
|| super::super::flat::is_broken_intel_vulkan(&adapter.get_info())
{
@@ -2727,7 +2759,7 @@ mod tests {
}
eprintln!("GPU culling on {} ({name})", adapter.get_info().name);
pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
required_features: needed,
required_features: needed | timing,
..Default::default()
}))
.ok()
@@ -2834,6 +2866,11 @@ mod tests {
// was drawn.
let (mut eyed, mut hidden, mut several) = (0, 0, 0);
let (mut edge, mut edge_drawn) = (None, false);
// Timed in detail as the renderer does: the commands, and so the draws, stay the same.
let mut timing = PassTiming::new(device, queue);
if let Some(t) = &mut timing {
t.set_detail(true);
}
for case in 0..25 {
let radius = [2, 5, 9][case % 3];
let at_edge = case == 24;
@@ -2981,6 +3018,9 @@ mod tests {
edge = Some(id);
}
let whole = gpu.records.all_dirty;
if let Some(t) = &mut timing {
t.begin(device, queue);
}
let mut encoder = device.create_command_encoder(&Default::default());
let input = FrameInputs {
camera,
@@ -3000,11 +3040,18 @@ mod tests {
camera: camera + DVec3::Y * 2.0e7,
..input
};
let prepared = gpu.prepare(device, queue, &mut uploads, &mut encoder, &up);
let prepared = gpu.prepare(device, queue, &mut uploads, &mut encoder, &up, None);
assert_eq!(prepared, Prepared::Cpu, "case 7");
assert!(gpu.records.all_dirty || !gpu.records.dirty.is_empty());
}
let prepared = gpu.prepare(device, queue, &mut uploads, &mut encoder, &input);
let prepared = gpu.prepare(
device,
queue,
&mut uploads,
&mut encoder,
&input,
timing.as_mut(),
);
assert_eq!(prepared, Prepared::Gpu, "case {case}");
match case {
4 => assert!(
@@ -3060,12 +3107,15 @@ mod tests {
culled += 1;
}
}
gpu.dispatch(&mut encoder, None);
gpu.dispatch(&mut encoder, timing.as_mut());
// Its statistics come back with a CPU part of the case's own.
let cpu = [case * 7, case * 3, case];
gpu.copy_stats(&mut encoder, case as u64, cpu);
uploads.finish(&encoder);
queue.submit([encoder.finish()]);
if let Some(t) = &mut timing {
t.submitted(queue);
}
gpu.submitted().expect("a readback free")();
let pages = u64::from(gpu.pass_pages);
let counts = read(device, queue, &gpu.counts.buffer, (STATS + pages) * 4);
@@ -3124,6 +3174,18 @@ mod tests {
"of {eyed} from an eye, {hidden} with a bucket left out, {several} in three runs"
);
assert!(edge_drawn, "the bucket at the section's edge");
if let Some(t) = &timing {
let parts = t.part_medians();
let names = |pass: &str| {
let parts = parts.iter().find(|p| p.0 == pass)?;
Some(parts.1.iter().map(|p| p.0.as_str()).collect::<Vec<_>>())
};
assert_eq!(
names("GPU culling"),
Some(vec!["apply", "cull", "scan", "scatter"]),
"{parts:?}"
);
}
assert!(culled > 2_000, "{culled} within the distance left out");
assert!(faded * 10 > compared, "{faded} fading of {compared} drawn");
assert!(
@@ -3136,6 +3198,113 @@ mod tests {
);
}
/// Detail times both phases and the uploads and leaves counts and draws as an untimed frame's.
#[test]
fn detail_times_both_phases_and_leaves_the_draws_alone() {
on_each_device(|device, queue| {
let mut rng = Rng(0x1F83_D9AB_FB41_BD6B);
let (frustum, _) = view(&mut rng);
let records: Vec<Record> = (0..400)
.map(|i| {
let key = (rng.range(-6, 6), rng.range(0, 23), rng.range(-6, 6));
Record::new(
key,
i % 3,
u64::from(i) * 1024,
1 + rng.below(500),
UNBOUNDED,
)
})
.collect();
let mut timing = PassTiming::new(device, queue);
let early = device
.features()
.contains(wgpu::Features::SHADER_EARLY_DEPTH_TEST);
let Some(t) = timing.as_mut().filter(|_| early) else {
eprintln!("no timestamps or early depth test: skipped");
return;
};
t.set_detail(true);
let run = |timing: &mut Option<PassTiming>| {
let mut gpu = GpuCulling::new(device, Some(wgpu::TextureFormat::Rgba8Unorm))
.expect("the pass");
let mut uploads = Uploads::new(device);
for r in &records {
gpu.records.insert(*r);
}
let mut out = Vec::new();
for frame in 0..12 {
if let Some(t) = timing {
t.begin(device, queue);
}
let mut encoder = device.create_command_encoder(&Default::default());
let input = FrameInputs {
camera: DVec3::new(0.5, 70.0, 0.5),
min_y: -64,
sections: 24,
radius: 5,
frustum: &frustum,
reach: None,
eye: None,
view_proj: Mat4::IDENTITY,
occlude: true,
};
let prepared = gpu.prepare(
device,
queue,
&mut uploads,
&mut encoder,
&input,
timing.as_mut(),
);
assert_eq!(prepared, Prepared::Gpu, "frame {frame}");
assert!(gpu.occluding(), "frame {frame}: phase 2 runs");
gpu.dispatch(&mut encoder, timing.as_mut());
gpu.compact(&mut encoder, timing.as_mut());
uploads.finish(&encoder);
queue.submit([encoder.finish()]);
if let Some(t) = timing {
t.submitted(queue);
}
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let pages = u64::from(gpu.pass_pages);
out.push((
read(device, queue, &gpu.counts.buffer, (STATS + 2 * pages) * 4),
read(device, queue, &gpu.draws.buffer, gpu.draws.bytes),
));
}
out
};
let timed = run(&mut timing);
let plain = run(&mut None);
assert_eq!(timed, plain, "detail changes what is drawn");
let t = timing.as_ref().expect("the timer");
let parts = t.part_medians();
let names = |pass: &str| {
let parts = parts.iter().find(|p| p.0 == pass)?;
Some(parts.1.iter().map(|p| p.0.as_str()).collect::<Vec<_>>())
};
assert_eq!(
names("GPU culling"),
Some(vec!["apply", "cull", "scan", "scatter"]),
"{parts:?}"
);
assert_eq!(
names("GPU culling phase 2"),
Some(vec!["count2", "scan2", "scatter2"]),
"{parts:?}"
);
// The uploads are a pass of their own, never a part of one.
assert_eq!(names("GPU culling uploads"), None, "{parts:?}");
let passes = t.medians();
assert_eq!(
passes.iter().any(|p| p.0 == "GPU culling uploads"),
t.detail_support().encoder_stamps,
"{passes:?}"
);
});
}
/// The statistics come back with their frame, in place and from the submit
/// thread, submitted as gfx.rs submits frames (`submit_later`, or in place
/// with its `after` handed back): three frames on their way fill the ring, a
@@ -3377,7 +3546,7 @@ struct Quad {
view_proj: Mat4::IDENTITY,
occlude: false,
};
let prepared = gpu.prepare(device, queue, &mut uploads, &mut encoder, &input);
let prepared = gpu.prepare(device, queue, &mut uploads, &mut encoder, &input, None);
assert_eq!(prepared, Prepared::Gpu, "frame {frame}");
let mut params = tables(&input, &mut words).expect("the planes fit");
params.pages = gpu.pass_pages;
+270 -90
View File
@@ -5,6 +5,10 @@
//! or scRGB swapchain at the player's paper white, expanding the world's
//! highlights towards their peak brightness. The world is copied aside before
//! the hand and HUD so only it is expanded.
//!
//! On the float frame Horizon's world light is the same on every display (`TIERS`,
//! `SUNLIT_GAIN`, up to `SCENE_PEAK`), and the tone curve alone fits it to the display's
//! peak (docs/design/hdr-linear-light.md, step 6a).
/// The swapchain HDR output presents to.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
@@ -99,38 +103,53 @@ struct Layered {
params: wgpu::Buffer,
}
/// How one tier of light sources (`mesher::hdr_tier`) shines: `share` of the headroom for
/// its brightest pixels, lifted from brightness `start` (none) to `full` (all of it), the
/// texel's own brightness before lighting.
/// How one tier of light sources (`mesher::hdr_tier`) shines: its brightest pixels are
/// multiplied by `gain`, in multiples of paper white, lifted from brightness `start` (none) to
/// `full` (all of it), the texel's own brightness before lighting.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Tier {
pub share: f32,
pub gain: f32,
pub start: f32,
pub full: f32,
}
/// Low, incandescent, bright and very bright.
/// Low, incandescent, bright and very bright: faint glowers such as magma, flames and lava,
/// glowstone and campfires, the sun.
pub const TIERS: [Tier; 4] = [
Tier {
share: 0.2,
gain: 1.6,
start: 0.08,
full: 0.09,
},
Tier {
share: 0.5,
gain: 2.4,
start: 0.14,
full: 0.38,
},
Tier {
share: 0.6,
gain: 2.8,
start: 0.14,
full: 0.38,
},
Tier {
share: 1.0,
gain: 4.0,
start: 0.14,
full: 0.38,
},
];
/// The scene's peak, in multiples of paper white: just above the brightest light the world
/// writes (the sun's disc, white at the very bright tier's 4), and what the tone curve maps
/// onto the display's peak; light past it (where the sky adds onto the sun) is held at it. The
/// world is mastered for a 1000-nit display at ITU-R BT.2408's 203-nit reference white, as
/// HDR10 content usually is: there it shows as written, a dimmer display eases its highlights
/// into its peak (BT.2390's EETF), and a brighter one shows it as at 1000.
pub const SCENE_PEAK: f32 = 1000.0 / 203.0;
/// HDR lighting: what the strongest light multiplies a surface by (`hdr_light.wgsl`).
pub const SUNLIT_GAIN: f32 = 3.0;
/// The compatibility frame's tiers (the 16-bit or 8-bit frame, until step 5 removes it): each
/// one's share of the display's headroom, as before one peak, so the look HDR has by default
/// changes once, when the float frame replaces it.
const COMPAT_SHARES: [f32; 4] = [0.2, 0.5, 0.6, 1.0];
/// Which pixels of a light source's sprite may shine (`hdr_stats.wgsl`), as a fraction of
/// the way from its darkest to its brightest opaque pixel: `parts` for one whose bright parts
/// shine (a torch's flame, not its handle), `whole` for one that shines all over
@@ -331,7 +350,7 @@ impl Hdr {
let params = || {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Horizon HDR parameters"),
size: 64,
size: 80,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
})
@@ -944,16 +963,19 @@ impl Hdr {
})
}
/// What the world's writers read (`hdr_written`): the scene's peak, HDR lighting's gain,
/// world/look and debug, then the tiers' gains. No part of the display reaches them.
pub(super) fn draw_params(&self, world: bool) -> ([f32; 4], [f32; 4]) {
let p = self.params(world, false);
([p[1], p[7], p[2], p[3]], p[8..12].try_into().unwrap())
let sunlit = if self.lighting { SUNLIT_GAIN } else { 1.0 };
([SCENE_PEAK, sunlit, p[2], p[3]], self.tiers.map(|t| t.gain))
}
/// hdr.wgsl's `Params`.
fn params(&self, world: bool, pq: bool) -> [f32; 16] {
fn params(&self, world: bool, pq: bool) -> [f32; 20] {
let paper = self.paper_white.max(1.0);
let c = self.calibration;
let mut params = [0.0; 16];
let mut params = [0.0; 20];
params[..5].copy_from_slice(&[
paper / 80.0,
(self.peak / paper).max(1.0),
@@ -968,12 +990,17 @@ impl Hdr {
} else {
1.0
};
for (i, t) in self.tiers.iter().enumerate() {
params[8 + i] = t.share;
}
params[8..12].copy_from_slice(&COMPAT_SHARES);
params[12] = c.map_or(0.0, |c| c.step as f32);
params[13] = c.map_or(0.0, |c| c.nits);
params[14] = c.map_or(0.0, |c| c.band);
// Only Horizon's lifted world is known to end at the scene's peak; elsewhere (HDR
// Lighting off, a pack's own light, the title) the curve's source follows the display.
params[16] = if world && self.lighting {
SCENE_PEAK
} else {
0.0
};
params
}
@@ -1050,8 +1077,9 @@ fn logo_mask(device: &wgpu::Device, queue: &wgpu::Queue) -> wgpu::TextureView {
.create_view(&Default::default())
}
/// What the strongest light multiplies a surface by, in HDR lighting: 4, which looked right
/// across biomes; less only where there is little headroom to ease into (`hdr.wgsl`).
/// The compatibility frame's HDR lighting (`fs_hdr`): what the strongest light multiplies a
/// surface by, 4, which looked right across biomes; less only where there is little headroom
/// to ease into. The float frame's is `SUNLIT_GAIN`.
fn scene_gain(peak: f32, paper_white: f32) -> f32 {
(peak / paper_white.max(1.0) * 1.75).clamp(1.0, 4.0)
}
@@ -1069,7 +1097,8 @@ mod tests {
return;
};
// All 8-bit marks, both easing policies, non-grey and over-white colours, look,
// debug, headroom and tier shares; half-float neighbours straddle mark boundaries.
// debug, headroom and tier shares; half-float neighbours straddle mark boundaries. The
// writers take the gain that a share gives.
let source = include_str!("../hdr_light.wgsl").to_owned()
+ r#"
struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: vec4<f32> }
@@ -1088,19 +1117,20 @@ var<private> params: ReferenceParams;
select(0.0, 1.0, (mode & 32u) != 0u), vec4(0.2, 0.5, 0.6, 1.0));
let color = select(vec3(0.9, 0.4, 0.02), vec3(1.4, 0.8, 0.2), (mode & 64u) != 0u);
let expected = reference_lit(color, mark, shows, eased);
let gains = 1.0 + (params.peak - 1.0) * params.share;
let actual = hdr_mark_light(color, mark, shows, eased,
vec4(params.peak, params.scene, params.world, params.debug), params.share);
vec4(params.peak, params.scene, params.world, params.debug), (params.peak - 1.0) * params.share);
errors[i] = select(0u, 1u, any(abs(expected.light - actual.light) > vec3(1e-6)) || expected.lifted != actual.lifted);
// Gain stays above the tone knee until the separate tone curve runs.
let gain = 1.0 + 3.0 * (1.0 - 194.0 / 255.0) / 0.24;
let white = hdr_written(vec3(1.0), 0.76, vec4(5.0, 4.0, 1.0, 0.0), params.share);
let white = hdr_written(vec3(1.0), 0.76, vec4(5.0, 4.0, 1.0, 0.0), gains);
errors[i] |= select(0u, 4u, any(abs(pow(white, vec3(2.2)) - vec3(gain)) > vec3(1e-5)));
let boundary = (f32(i % 255u) + 0.5) / 255.0;
for (var step = -1; step <= 1; step++) {
let stored = unpack2x16float(pack2x16float(vec2(boundary + f32(step) / 65536.0))).x;
let quantized = round(saturate(stored) * 255.0) / 255.0;
let want = pow(reference_lit(color, quantized, params.world > 0.5 && params.peak > 1.0, false).light, vec3(1.0 / 2.2));
let got = hdr_written(color, stored, vec4(params.peak, params.scene, params.world, params.debug), params.share);
let want = pow(reference_lit(color, quantized, params.world > 0.5, false).light, vec3(1.0 / 2.2));
let got = hdr_written(color, stored, vec4(params.peak, params.scene, params.world, params.debug), gains);
errors[i] |= select(0u, 2u, any(abs(want - got) > vec3(1e-6)));
}
}
@@ -1319,10 +1349,12 @@ var<private> params: ReferenceParams;
// Independent pre-refactor light arithmetic applied to a compatibility attachment.
// Float targets in these tests only receive the resulting colour and coverage.
// `lift`: the reference's peak, HDR lighting gain, world and debug, then the tiers' shares
// of the headroom up to that peak (`writers_lift`, `display_lift`).
pub(super) fn legacy_light(
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
hdr: &Hdr,
lift: [[f32; 4]; 2],
source: &wgpu::Texture,
target: &wgpu::Texture,
) {
@@ -1345,7 +1377,7 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
@fragment fn fs() -> @location(0) vec4<f32> {
let c = textureLoad(source, vec2(0), 0);
let mark = round(saturate(c.a) * 255.0) / 255.0;
let light = reference_lit(c.rgb, mark, params.world > 0.5 && params.peak > 1.0, false).light;
let light = reference_lit(c.rgb, mark, params.world > 0.5, false).light;
return vec4(pow(light, vec3(1.0 / 2.2)), 1.0);
}
"#)
@@ -1373,10 +1405,9 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
multiview_mask: None,
cache: None,
});
let (settings, shares) = hdr.draw_params(true);
let params = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
contents: bytemuck::cast_slice(&[settings, shares]),
contents: bytemuck::cast_slice(&lift),
usage: wgpu::BufferUsages::UNIFORM,
});
let view = source.create_view(&Default::default());
@@ -1404,6 +1435,19 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
);
}
/// The float frame's writers' light as `legacy_light` takes it: each tier's gain as a share
/// of the headroom up to the scene's peak.
pub(super) fn writers_lift(hdr: &Hdr) -> [[f32; 4]; 2] {
let (settings, gains) = hdr.draw_params(true);
[settings, gains.map(|g| (g - 1.0) / (settings[0] - 1.0))]
}
/// The compatibility frame's display pass's light (`fs_hdr`) as `legacy_light` takes it.
fn display_lift(hdr: &Hdr) -> [[f32; 4]; 2] {
let p = hdr.params(true, false);
[[p[1], p[7], p[2], p[3]], p[8..12].try_into().unwrap()]
}
#[test]
fn direct_light_matches_the_legacy_non_float_display_below_the_tone_knee() {
let Some((device, queue)) = gpu() else {
@@ -1416,8 +1460,9 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
eprintln!("legacy display GPU skipped: 16-bit UNORM unavailable");
return;
}
// Compare the compatibility display lift with direct colour under the layered HUD.
// The frozen oracle supplies the writer's light from a non-float attachment.
// Compare the compatibility display lift with direct colour under the layered HUD:
// the frozen oracle turns the marks into the light that display gives them, and the
// layered display, whose curve is the identity at 1000 nits, must show it the same.
let shown = |layered: bool, world: [f64; 4]| {
let mut hdr = Hdr::with_debug(
&device,
@@ -1441,7 +1486,14 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
if world[3] == 1.0 {
fill(&mut encoder, &frame, world);
} else {
legacy_light(&device, &mut encoder, &hdr, &marked, &frame);
// The light the compatibility frame shows: its own lift, not the writers'.
legacy_light(
&device,
&mut encoder,
display_lift(&legacy),
&marked,
&frame,
);
}
hdr.begin(&queue, true);
hdr.tonemap(&mut encoder, None);
@@ -1480,78 +1532,206 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
assert!(above[0] > white[0] && above[1] > white[1]);
}
/// The world's writers read nothing of the display: the same gains and the same scene peak
/// at any paper white and peak (docs/design/hdr-linear-light.md, step 6a).
#[test]
fn the_tone_curve_eases_light_into_the_peak() {
fn the_writers_light_is_the_same_on_every_display() {
let Some((device, queue)) = gpu() else {
return;
};
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
(hdr.paper_white, hdr.peak) = (203.0, 1000.0);
let headroom = 1000.0 / 203.0;
// The frame holding `color` (in gamma, as it stores light) after `passes` ran on it.
let after =
|hdr: &mut Hdr, color: [f64; 4], passes: &dyn Fn(&Hdr, &mut wgpu::CommandEncoder)| {
let frame = hdr.frame(&device, [1, 1]);
let mut encoder = device.create_command_encoder(&Default::default());
fill(&mut encoder, &frame, color);
hdr.begin(&queue, true);
passes(hdr, &mut encoder);
texel(&device, &queue, encoder, &frame)
};
let tonemap = |hdr: &Hdr, encoder: &mut wgpu::CommandEncoder| hdr.tonemap(encoder, None);
let written = hdr.draw_params(true);
assert_eq!(
written,
(
[super::SCENE_PEAK, super::SUNLIT_GAIN, 1.0, 0.0],
[1.6, 2.4, 2.8, 4.0]
)
);
for (paper, peak) in [
(203.0, 300.0),
(203.0, 600.0),
(203.0, 1500.0),
(400.0, 600.0),
(480.0, 200.0),
(80.0, 10000.0),
] {
(hdr.paper_white, hdr.peak) = (paper, peak);
assert_eq!(hdr.draw_params(true), written, "{paper}/{peak}");
assert_eq!(
hdr.params(true, false)[16],
super::SCENE_PEAK,
"{paper}/{peak}"
);
}
// The title, and frames Horizon doesn't light (HDR Lighting off, a pack drawing):
// nothing says where their light ends, so the curve's source follows the display.
assert_eq!(hdr.params(false, false)[16], 0.0);
hdr.lighting = false;
assert_eq!(hdr.draw_params(true).0[1], 1.0);
assert_eq!(hdr.params(true, false)[16], 0.0);
}
/// The tone curve maps the scene's peak S onto the display's peak T with ITU-R BT.2390's
/// EETF, keeping hue, after holding light above S at S. Checkpoints are worked out apart
/// from the shader, in f64 from SMPTE ST 2084 and BT.2390.
#[test]
fn the_tone_curve_maps_the_scene_peak_onto_the_display() {
let Some((device, queue)) = gpu() else {
return;
};
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
let scene_peak = super::SCENE_PEAK;
// The frame holding `color` (in gamma, as it stores light), after the curve if `curve`.
let through = |hdr: &mut Hdr, color: [f64; 4], curve: bool| {
let frame = hdr.frame(&device, [1, 1]);
let mut encoder = device.create_command_encoder(&Default::default());
fill(&mut encoder, &frame, color);
hdr.begin(&queue, true);
if curve {
hdr.tonemap(&mut encoder, None);
}
texel(&device, &queue, encoder, &frame)
};
// `exact`, or within one half-float step: S's own gamma rounds up in half float, to
// light just above S that the curve holds at S, and whether that comes back to the same
// half float depends on the GPU's rounding (one step lower on an Apple M4 Pro).
let unchanged = |hdr: &mut Hdr, color: [f64; 4], exact: bool| {
let (got, stored) = (through(hdr, color, true), through(hdr, color, false));
(0..4).all(|c| {
got[c] == stored[c] || !exact && (got[c] - stored[c]).abs() <= stored[c] / 1024.0
})
};
// Linear light in and out, in units of paper white.
let eased = |hdr: &mut Hdr, light: [f32; 3]| {
let mapped = |hdr: &mut Hdr, light: [f32; 3]| {
let [r, g, b] = light.map(|c| f64::from(c.powf(1.0 / 2.2)));
let [r, g, b, a] = after(hdr, [r, g, b, 1.0], &tonemap);
let [r, g, b, a] = through(hdr, [r, g, b, 1.0], true);
assert_eq!(a, 1.0);
[r, g, b].map(|c| c.powf(2.2))
};
let near = |got: f32, want: f32| (got - want).abs() <= 0.005 * want.max(1.0);
// Up to the knee (60% of the peak) the light stays exactly as stored.
for light in [0.0f32, 0.5, 1.0, 2.0, 2.9] {
let g = f64::from(light.powf(1.0 / 2.2));
let stored = after(&mut hdr, [g, g * 0.5, 0.0, 1.0], &|_, _| {});
assert_eq!(
after(&mut hdr, [g, g * 0.5, 0.0, 1.0], &tonemap),
stored,
"{light}"
);
}
// Past it, BT.2390's EETF in PQ at 203 and 1000 nits, with the source's peak at 2771
// nits (13.7 times paper white), worked out apart from the shader.
for (light, want) in [
(3.5, 3.43791),
(4.0, 3.78351),
(headroom, 4.23556),
(8.0, 4.82043),
(12.0, 4.92462),
(50.0, headroom),
// A display at or above the mastering one (T >= S): every stored value up to S comes
// out exactly as it went in, grey or coloured, and light above S is held at S with its
// hue, so 1500 and 10 000 nits show what 1000 does; so do the settings' extremes (Paper
// White 10 to 480 nits, Peak up to 10 000) wherever T reaches S.
for (paper, peak) in [
(203.0, 1000.0),
(203.0, 1500.0),
(203.0, 10000.0),
(480.0, 10000.0),
(10.0, 200.0),
] {
let [r, ..] = eased(&mut hdr, [light; 3]);
assert!(near(r, want), "{light}: {r}, not {want}");
(hdr.paper_white, hdr.peak) = (paper, peak);
for i in 0..=64 {
let g = f64::from((scene_peak * i as f32 / 64.0).powf(1.0 / 2.2));
let exact = i < 64;
assert!(unchanged(&mut hdr, [g, g, g, 1.0], exact), "{peak}: {i}");
assert!(
unchanged(&mut hdr, [g, g * 0.6, g * 0.1, 1.0], exact),
"{peak}: {i}"
);
}
for light in [8.0f32, 50.0] {
let [r, g, b] = mapped(&mut hdr, [light, light / 4.0, 0.0]);
assert!(
near(r, scene_peak) && near(g * 4.0, r) && b == 0.0,
"{peak}: {light}: {r} {g} {b}"
);
}
}
// It rises, stays under the peak, and the brightest channel scales the others: the
// light keeps its hue.
let mut last = 0.0;
for i in 0..40 {
let light = 2.5 * 1.08f32.powi(i);
let [r, g, b] = eased(&mut hdr, [light, light / 4.0, 0.0]);
assert!(r >= last - 0.001 && r <= headroom * 1.002, "{light}: {r}");
assert!(near(g * 4.0, r) && b == 0.0, "{light}: {r} {g} {b}");
last = r;
// Dimmer displays: unchanged up to the knee, then the EETF into T, T at S and past it.
// At 300 nits the knee falls just below white (161 nits): white shows at 198 nits
// where the gates' curve, whose source followed the display, kept it at 203; the
// highlights above it keep more of their steps.
for (paper, peak, knee, checkpoints) in [
(
203.0,
600.0,
2.0,
&[
(2.5, 2.47516),
(3.0, 2.75699),
(4.0, 2.9404),
(scene_peak, 600.0 / 203.0),
(8.0, 600.0 / 203.0),
][..],
),
(
203.0,
300.0,
0.75,
&[
(0.9, 0.89289),
(1.0, 0.97365),
(1.2, 1.10406),
(1.4, 1.20134),
(2.0, 1.36833),
(4.0, 1.47641),
(8.0, 300.0 / 203.0),
],
),
(
400.0,
600.0,
0.8,
&[
(1.0, 0.97973),
(1.2, 1.11501),
(1.5, 1.25607),
(2.0, 1.38818),
(scene_peak, 1.5),
],
),
// No headroom (a peak under paper white): the scene is compressed into white.
(
203.0,
150.0,
0.4,
&[
(1.0, 0.78346),
(1.5, 0.90244),
(2.0, 0.95589),
(4.0, 0.99944),
(scene_peak, 1.0),
],
),
] {
(hdr.paper_white, hdr.peak) = (paper, peak);
for light in [0.0f32, 0.3, knee] {
let g = f64::from(light.powf(1.0 / 2.2));
assert!(
unchanged(&mut hdr, [g, g * 0.5, 0.0, 1.0], true),
"{paper}/{peak}: {light}"
);
}
for &(light, want) in checkpoints {
let [r, ..] = mapped(&mut hdr, [light; 3]);
assert!(near(r, want), "{paper}/{peak}: {light}: {r}, not {want}");
}
// It rises, stays under the peak, and the brightest channel scales the others; a
// half float's step in gamma is 0.1% of the light near white.
let headroom = (peak / paper).max(1.0);
let mut last = 0.0;
for i in 0..40 {
let light = 0.5 * 1.08f32.powi(i);
let [r, g, b] = mapped(&mut hdr, [light, light / 4.0, 0.0]);
assert!(
r >= last * 0.998 && r <= headroom * 1.002,
"{peak}: {light}: {r}"
);
assert!(near(g * 4.0, r) && b == 0.0, "{peak}: {light}: {r} {g} {b}");
last = r;
}
}
// A display with little headroom, 300 nits at a paper white of 203: the knee sits at
// white, so all that SDR shows stays as it is, and the source's peak is 645 nits.
hdr.peak = 300.0;
// Light Horizon doesn't lift (HDR Lighting off, a 0.2 pack's own) has no known peak:
// the curve's source follows the display, the knee at 60% of its peak and at least
// white, as before one peak. At 300 nits the source's peak is 645 nits.
hdr.lighting = false;
(hdr.paper_white, hdr.peak) = (203.0, 300.0);
let headroom = 300.0 / 203.0;
for light in [0.5f32, 0.9, 1.0] {
for light in [0.0f32, 0.5, 0.9, 1.0] {
let g = f64::from(light.powf(1.0 / 2.2));
let stored = after(&mut hdr, [g, g * 0.5, 0.0, 1.0], &|_, _| {});
assert_eq!(
after(&mut hdr, [g, g * 0.5, 0.0, 1.0], &tonemap),
stored,
"{light}"
);
assert!(unchanged(&mut hdr, [g, g * 0.5, 0.0, 1.0], true), "{light}");
}
for (light, want) in [
(1.2, 1.16839),
@@ -1560,12 +1740,12 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
(2.0, 1.44024),
(4.0, headroom),
] {
let [r, ..] = eased(&mut hdr, [light; 3]);
let [r, ..] = mapped(&mut hdr, [light; 3]);
assert!(near(r, want), "{light}: {r}, not {want}");
}
// No headroom (a peak below paper white): a clip at white, channel by channel.
// With no headroom there, a clip at white, channel by channel.
hdr.peak = 150.0;
let [r, g, b] = eased(&mut hdr, [1.5, 0.5, 0.2]);
let [r, g, b] = mapped(&mut hdr, [1.5, 0.5, 0.2]);
assert!(r == 1.0 && near(g, 0.5) && near(b, 0.2), "{r} {g} {b}");
}
@@ -1582,7 +1762,7 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
(hdr.paper_white, hdr.peak, hdr.black) = (250.0, 800.0, 0.0);
(hdr.ui, hdr.look, hdr.lighting) = (0.5, false, false);
hdr.masks.parts = 0.4;
hdr.tiers[1].share = 0.7;
hdr.tiers[1].gain = 3.0;
hdr.debug = true;
hdr.calibration = Some(super::Calibration {
step: 2,
@@ -1,6 +1,6 @@
use super::{
Hdr, Output,
tests::{gpu, legacy_light, texel},
tests::{gpu, legacy_light, texel, writers_lift},
};
use wgpu::TextureFormat as F;
use wgpu::util::DeviceExt;
@@ -465,7 +465,7 @@ impl<'a> Draw<'a> {
}
fn material(&mut self, api: horizon_shader_api::Api) {
let source = super::super::native_material::coverage_test_shader(api);
let source = super::super::native_material::coverage_test_shader((api, false));
self.shader = self
.device
.create_shader_module(wgpu::ShaderModuleDescriptor {
@@ -478,7 +478,7 @@ impl<'a> Draw<'a> {
Vec::new(),
Default::default(),
None,
api,
(api, false),
);
self.queue.write_buffer(
&frame.buffer,
@@ -648,7 +648,7 @@ impl<'a> Draw<'a> {
}
if !written && !self.raw {
let light = hdr.frame(self.device, [1, 1]);
legacy_light(self.device, &mut encoder, hdr, &frame, &light);
legacy_light(self.device, &mut encoder, writers_lift(hdr), &frame, &light);
return texel(self.device, self.queue, encoder, &light);
}
texel(self.device, self.queue, encoder, &frame)
@@ -662,7 +662,7 @@ fn float_plain_fragments_keep_rgb_and_write_only_coverage() {
return;
};
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
let (settings, shares) = hdr.draw_params(true);
let (settings, gains) = hdr.draw_params(true);
let mut cases = 0;
for hdr_on in [false, true] {
let globals = super::super::Globals {
@@ -677,7 +677,7 @@ fn float_plain_fragments_keep_rgb_and_write_only_coverage() {
hdr_windows: hdr.windows(),
hdr_light: [0.8, 0.8, f32::from(u8::from(hdr_on)), 0.0],
hdr_settings: settings,
hdr_shares: shares,
hdr_gains: gains,
..bytemuck::Zeroable::zeroed()
};
for terrain in [true, false] {
@@ -739,7 +739,7 @@ fn float_stars_keep_the_original_rgb_with_brightness_as_coverage() {
return;
};
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
let (settings, shares) = hdr.draw_params(true);
let (settings, gains) = hdr.draw_params(true);
// Frozen f030d9c80 fragment: the original written branch and its marked fallback.
let source = include_str!("../hdr_light.wgsl").to_owned()
+ include_str!("stars.wgsl")
@@ -750,7 +750,7 @@ fn float_stars_keep_the_original_rgb_with_brightness_as_coverage() {
@fragment fn reference() -> @location(0) vec4<f32> {
let b = stars.color.a;
if HDR_WRITE_LIGHT && stars.hdr_light.w != 0.0 {
return vec4(hdr_written(stars.color.rgb * b, 0.175, stars.hdr_settings, stars.hdr_shares), b);
return vec4(hdr_written(stars.color.rgb * b, 0.175, stars.hdr_settings, stars.hdr_gains), b);
}
return vec4(stars.color.rgb * b, 0.175);
}
@@ -763,7 +763,7 @@ fn float_stars_keep_the_original_rgb_with_brightness_as_coverage() {
let mut data = [0.0_f32; 32];
data[16..20].copy_from_slice(&[0.7, 0.8, 0.9, 0.35]);
data[20..24].copy_from_slice(&settings);
data[24..28].copy_from_slice(&shares);
data[24..28].copy_from_slice(&gains);
data[31] = gain;
let uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
@@ -908,7 +908,7 @@ fn actual_opaque_writers_match_the_legacy_non_float_oracle() {
}
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
(hdr.paper_white, hdr.peak) = (203.0, 1000.0);
let (settings, shares) = hdr.draw_params(true);
let (settings, gains) = hdr.draw_params(true);
let mut count = 0;
for fog in [0.0, 0.5, 1.0] {
for visibility in [1.0, 0.4] {
@@ -921,7 +921,7 @@ fn actual_opaque_writers_match_the_legacy_non_float_oracle() {
hdr_windows: hdr.windows(),
hdr_light: [0.8, 0.8, 1.0, 1.0],
hdr_settings: settings,
hdr_shares: shares,
hdr_gains: gains,
..bytemuck::Zeroable::zeroed()
};
for terrain in [true, false] {
@@ -968,7 +968,7 @@ fn actual_translucent_writers_blend_their_own_light() {
return;
};
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
let (settings, shares) = hdr.draw_params(true);
let (settings, gains) = hdr.draw_params(true);
let globals = super::super::Globals {
fog: [0.0, 100.0, 0.0, 100.0],
fog_ends: [100.0, 100.0, 0.0, 0.0],
@@ -977,7 +977,7 @@ fn actual_translucent_writers_blend_their_own_light() {
hdr_windows: hdr.windows(),
hdr_light: [0.8, 0.8, 1.0, 1.0],
hdr_settings: settings,
hdr_shares: shares,
hdr_gains: gains,
..bytemuck::Zeroable::zeroed()
};
// Texel alpha 100 is clear glass's kind of texel, below the 0.6 the marked frame's
@@ -1035,14 +1035,14 @@ fn unmarked_entity_users_keep_their_source_gain() {
return;
}
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
let (settings, shares) = hdr.draw_params(true);
let (settings, gains) = hdr.draw_params(true);
let globals = super::super::Globals {
fog: [0.0, 100.0, 0.0, 100.0],
fog_ends: [100.0, 100.0, 0.0, 0.0],
hdr_windows: hdr.windows(),
hdr_light: [1.0, 1.0, 1.0, 1.0],
hdr_settings: settings,
hdr_shares: shares,
hdr_gains: gains,
..bytemuck::Zeroable::zeroed()
};
let draw = Draw::new(&device, &queue, false, &globals, 255);
@@ -1079,13 +1079,13 @@ fn additive_texels_below_the_mark_cutoff_keep_their_gain() {
return;
}
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
let (settings, shares) = hdr.draw_params(true);
let (settings, gains) = hdr.draw_params(true);
let globals = super::super::Globals {
fog: [0.0, 100.0, 0.0, 100.0],
hdr_windows: hdr.windows(),
hdr_light: [1.0, 1.0, 1.0, 1.0],
hdr_settings: settings,
hdr_shares: shares,
hdr_gains: gains,
..bytemuck::Zeroable::zeroed()
};
let draw = Draw::new(&device, &queue, false, &globals, 16);
@@ -1129,7 +1129,7 @@ fn check_oit_writer(terrain: bool, kind: &str, overlay: u32, tier: u32, alphas:
}))
.unwrap();
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
let (settings, shares) = hdr.draw_params(true);
let (settings, gains) = hdr.draw_params(true);
let globals = super::super::Globals {
fog: [0.0, 100.0, 0.0, 100.0],
fog_ends: [100.0, 100.0, 0.0, 0.0],
@@ -1138,7 +1138,7 @@ fn check_oit_writer(terrain: bool, kind: &str, overlay: u32, tier: u32, alphas:
hdr_windows: hdr.windows(),
hdr_light: [0.8, 0.8, 1.0, 1.0],
hdr_settings: settings,
hdr_shares: shares,
hdr_gains: gains,
..bytemuck::Zeroable::zeroed()
};
for &alpha in alphas {
@@ -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
}
}
}
@@ -149,6 +149,12 @@ fn composed() -> Vec<(&'static str, Vec<&'static str>, String)> {
vec!["terrain.wgsl", "visbuffer.wgsl"],
super::terrain_shader() + include_str!("../visbuffer.wgsl"),
),
// Ray tracing's unpacking of compact sections (gfx/ray_scene.rs's `Unpacker`).
(
"ray tracing unpack",
vec!["terrain.wgsl", "rt_unpack.wgsl"],
super::surface_setup::ray_query::ray_scene::unpack_shader(),
),
// A native mod's world warp (gfx/world_warp.rs): its copies with the plainest warp.
(
"world warp terrain",
@@ -164,10 +170,15 @@ fn composed() -> Vec<(&'static str, Vec<&'static str>, String)> {
vec!["entity.wgsl", "static_entity.wgsl", "world_warp.wgsl"],
super::world_warp::entity_shader(super::world_warp::IDENTITY).unwrap(),
),
(
"world primitives",
vec!["world_primitives.wgsl"],
super::world_primitives::shader(),
),
(
"improved transparency world border",
vec!["border.wgsl", "oit.wgsl", "oit_border.wgsl"],
include_str!("../border.wgsl").to_owned()
super::border_shader()
+ include_str!("../oit.wgsl")
+ include_str!("../oit_border.wgsl"),
),
@@ -0,0 +1,51 @@
//! A shader API 0.2 pack's `[frame] blend = "linear"` (docs/design/hdr-linear-light.md, How the
//! frame stores the picture): its float frame holds linear light instead of vanilla's gamma, so
//! glass, water and smoke blend as light does. The pack's material, sky and weather stages store
//! their results as they are. Horizon's own draws into the frame decode theirs at the end of
//! their shaders (hdr_light.wgsl's `frame_stored`), so a solid surface looks as under `"gamma"`.
//! What the pack reads of the frame (`scene_color`, `opaque_color`, the water mirror) is copied
//! as it is instead of decoded. The tonemap encodes the picture into gamma again, so the hand,
//! the camera overlays, the HUD, the screenshots and HDR output see the same either way.
/// Whether a frame of `format` stores linear light for a 0.2 pack that asks for it (`linear`,
/// `FrameRequest::linear_blend`): only the float frame. Where the device can't draw that, the
/// 0.2 pack runs on the 8-bit frame, which keeps vanilla's gamma, as `"gamma"` does: eight bits
/// of linear light would band in the darks.
pub(super) fn stores_linear(linear: bool, format: wgpu::TextureFormat) -> bool {
linear && format == super::LINEAR_FRAME
}
/// The constants of a pipeline of Horizon's that draws into the world's frame of `format`
/// (hdr_light.wgsl): light written, not marked, on the float frame; and decoded where the
/// frame holds linear light (`linear`).
pub(super) fn frame_constants(
format: wgpu::TextureFormat,
linear: bool,
) -> [(&'static str, f64); 2] {
[
(
"HDR_WRITE_LIGHT",
f64::from(u8::from(format == super::LINEAR_FRAME)),
),
("FRAME_LINEAR", f64::from(u8::from(linear))),
]
}
/// The world's clear colour, given in vanilla's gamma, as a frame that holds linear light
/// (`linear`) stores it.
pub(super) fn stored_clear(color: wgpu::Color, linear: bool) -> wgpu::Color {
if !linear {
return color;
}
let decode = |v: f64| v.max(0.0).powf(2.2);
wgpu::Color {
r: decode(color.r),
g: decode(color.g),
b: decode(color.b),
a: color.a,
}
}
#[cfg(test)]
#[path = "linear_blend_tests.rs"]
mod tests;
@@ -0,0 +1,413 @@
use super::super::{
LINEAR_FRAME,
native_post::{Decoder, INTERMEDIATE, line_helpers},
};
use super::{frame_constants, stored_clear};
use horizon_shader_api::Api;
use wgpu::util::DeviceExt;
/// One full-screen draw into the frame: `vec4(rgb, alpha)` from Horizon's own shaders, which
/// give vanilla's gamma (`frame_stored`), or a 0.2 pack's, which gives linear light
/// (`horizon_line_out`).
#[derive(Clone, Copy)]
enum Writer {
Horizon,
Pack,
}
const SOURCE: &str = "
// The colour comes in a buffer: naga 30 rejects this fragment with the colour made of
// overrides (`ExpressionAlreadyInScope` once they are processed).
@group(0) @binding(0) var<uniform> test_color: vec4<f32>;
override TEST_PACK: bool = false;
@vertex fn test_vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
return vec4<f32>(vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u)) * 2.0 - 1.0, 0.0, 1.0);
}
@fragment fn test_fs() -> @location(0) vec4<f32> {
let c = test_color;
if TEST_PACK {
return vec4<f32>(horizon_line_out(c.rgb), c.a);
}
return frame_stored(c);
}
";
fn gpu() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
pollster::block_on(adapter.request_device(&Default::default())).ok()
}
/// What a 0.2 stage reads of a 1x1 frame (`scene_color`): the world cleared to `clear`, in
/// vanilla's gamma, then `draws` alpha-blended over it, then the production decoding copy.
fn stage_input(
(device, queue): &(wgpu::Device, wgpu::Queue),
linear: bool,
clear: [f64; 3],
draws: &[(Writer, [f32; 4])],
) -> [f32; 4] {
let texture = |format, usage| {
device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | usage,
view_formats: &[],
})
};
let frame = texture(LINEAR_FRAME, wgpu::TextureUsages::TEXTURE_BINDING);
let input = texture(INTERMEDIATE, wgpu::TextureUsages::COPY_SRC);
let frame_view = frame.create_view(&Default::default());
let source =
include_str!("../hdr_light.wgsl").to_owned() + line_helpers(Api::V0_2, linear) + SOURCE;
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Wgsl(source.into()),
});
let mut encoder = device.create_command_encoder(&Default::default());
let [r, g, b] = clear;
let clear = stored_clear(wgpu::Color { r, g, b, a: 1.0 }, linear);
let mut load = wgpu::LoadOp::Clear(clear);
for &(writer, color) in draws {
let mut constants = frame_constants(LINEAR_FRAME, linear).to_vec();
constants.push((
"TEST_PACK",
f64::from(u8::from(matches!(writer, Writer::Pack))),
));
let options = wgpu::PipelineCompilationOptions {
constants: &constants,
..Default::default()
};
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: None,
layout: None,
vertex: wgpu::VertexState {
module: &module,
entry_point: Some("test_vs"),
compilation_options: options.clone(),
buffers: &[],
},
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some("test_fs"),
compilation_options: options,
targets: &[Some(wgpu::ColorTargetState {
format: LINEAR_FRAME,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
multiview_mask: None,
cache: None,
});
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &frame_view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load,
store: wgpu::StoreOp::Store,
},
})],
..Default::default()
});
let color = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
contents: bytemuck::cast_slice(&color),
usage: wgpu::BufferUsages::UNIFORM,
});
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout: &pipeline.get_bind_group_layout(0),
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: color.as_entire_binding(),
}],
});
pass.set_pipeline(&pipeline);
pass.set_bind_group(0, &group, &[]);
pass.draw(0..3, 0..1);
load = wgpu::LoadOp::Load;
}
if let wgpu::LoadOp::Clear(_) = load {
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &frame_view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load,
store: wgpu::StoreOp::Store,
},
})],
..Default::default()
});
}
Decoder::new(device, linear).run(
device,
&mut encoder,
&frame_view,
&input.create_view(&Default::default()),
);
texel((device, queue), encoder, &input)
}
/// The first texel of the half-float `texture` once `encoder` ran.
fn texel(
(device, queue): (&wgpu::Device, &wgpu::Queue),
mut encoder: wgpu::CommandEncoder,
texture: &wgpu::Texture,
) -> [f32; 4] {
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 256,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
encoder.copy_texture_to_buffer(
texture.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &buffer,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(256),
rows_per_image: None,
},
},
wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
);
queue.submit([encoder.finish()]);
buffer.map_async(wgpu::MapMode::Read, .., |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let bytes = buffer.get_mapped_range(..).unwrap();
std::array::from_fn(|i| half::f16::from_le_bytes([bytes[2 * i], bytes[2 * i + 1]]).to_f32())
}
fn assert_near(got: [f32; 4], want: [f32; 3], what: &str) {
for i in 0..3 {
// Half floats, and a gamma frame's half float decoded, hold about three digits.
assert!(
(got[i] - want[i]).abs() < 0.003 * want[i].max(1.0),
"{what}: {got:?}, expected {want:?}"
);
}
assert_eq!(got[3], 1.0, "{what}: {got:?}");
}
// The background is gamma (0.8, 0.2, 0.5), linear (0.6121, 0.0290, 0.2176). Horizon's glass
// gives gamma (0.1, 0.9, 0.4), the pack's linear (0.0, 0.5, 1.0); both cover a quarter. Under
// "gamma" the frame mixes gamma, a stage reads `decode(0.25 * src + 0.75 * bg)`: Horizon's
// (0.3556, 0.1156, 0.1944), the pack's, encoded first, (0.3250, 0.0887, 0.3556). Under
// "linear" it mixes light, `0.25 * decode(src) + 0.75 * decode(bg)`: Horizon's
// (0.4606, 0.2200, 0.1965), the pack's (0.4590, 0.1467, 0.4132).
#[test]
fn translucent_draws_blend_in_the_light_the_frame_holds() {
let Some(gpu) = gpu() else {
eprintln!("linear blend GPU unavailable");
return;
};
let background = [0.8, 0.2, 0.5];
for (linear, horizon, pack) in [
(false, [0.3556, 0.1156, 0.1944], [0.3250, 0.0887, 0.3556]),
(true, [0.4606, 0.2200, 0.1965], [0.4590, 0.1467, 0.4132]),
] {
let glass = [(Writer::Horizon, [0.1, 0.9, 0.4, 0.25])];
let got = stage_input(&gpu, linear, background, &glass);
assert_near(got, horizon, &format!("Horizon's glass, linear {linear}"));
let glass = [(Writer::Pack, [0.0, 0.5, 1.0, 0.25])];
let got = stage_input(&gpu, linear, background, &glass);
assert_near(got, pack, &format!("the pack's glass, linear {linear}"));
}
}
// What a stage reads of solid surfaces is the same light under either blend: Horizon's gamma
// (0.3, 0.7, 1.5) decodes to (0.0707, 0.4563, 2.4401), the pack's light (0.25, 0.5, 2.0) and
// the clear colour, gamma (0.8, 0.2, 0.5), to (0.6121, 0.0290, 0.2176).
#[test]
fn stages_read_the_same_light_under_either_blend() {
let Some(gpu) = gpu() else {
eprintln!("linear blend GPU unavailable");
return;
};
let clear = [0.8, 0.2, 0.5];
let cases = [
(
vec![(Writer::Horizon, [0.3, 0.7, 1.5, 1.0])],
[0.0707, 0.4563, 2.4401],
),
(
vec![(Writer::Pack, [0.25, 0.5, 2.0, 1.0])],
[0.25, 0.5, 2.0],
),
(vec![], [0.6121, 0.0290, 0.2176]),
];
for (draws, want) in cases {
let [gamma, linear] = [false, true].map(|linear| stage_input(&gpu, linear, clear, &draws));
assert_near(gamma, want, "gamma");
assert_near(linear, want, "linear");
for i in 0..3 {
assert!((gamma[i] - linear[i]).abs() < 0.003 * want[i].max(1.0));
}
}
}
// A section fading in mixes the pack's material result toward the fog colour in vanilla's
// gamma, as the fog does. Fog gamma 0.8, the pack's light 0.2 (gamma 0.2^(1/2.2) = 0.4812), half
// visible: under "linear" the frame stores (0.5 * 0.8 + 0.5 * 0.4812)^2.2 = 0.3757, not the
// 0.5 a mix in light gives; under "gamma" the result is already gamma, 0.5 * 0.8 + 0.5 * 0.2.
#[test]
fn a_fading_section_mixes_toward_the_fog_in_gamma() {
let Some((device, queue)) = gpu() else {
eprintln!("linear blend GPU unavailable");
return;
};
let globals = super::super::Globals {
fog_color: [0.8; 4],
// The pack's material result, as `horizon_material_color` gives it.
sky_color: [0.2, 0.2, 0.2, 1.0],
..bytemuck::Zeroable::zeroed()
};
let globals = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
contents: bytemuck::bytes_of(&globals),
usage: wgpu::BufferUsages::UNIFORM,
});
for (linear, want) in [(false, 0.5), (true, 0.3757)] {
let source = super::super::native_material::coverage_test_shader((Api::V0_2, linear))
+ "
@fragment fn test_faded() -> @location(0) vec4<f32> {
var o: HorizonMaterialOut;
// Half visible (`horizon_material_vertex`), past 32 blocks.
o.hdr_code = 32768u << 16u;
o.spherical = 40.0;
return horizon_material_faded(o, horizon_terrain_globals.sky_color);
}
";
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Wgsl(source.into()),
});
let constants = frame_constants(LINEAR_FRAME, linear)
.map(|(name, value)| (format!("horizon_terrain_{name}"), value));
let constants: Vec<(&str, f64)> = constants.iter().map(|(n, v)| (n.as_str(), *v)).collect();
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: None,
layout: None,
vertex: wgpu::VertexState {
module: &module,
entry_point: Some("test_vertex"),
compilation_options: Default::default(),
buffers: &[],
},
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some("test_faded"),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &constants,
..Default::default()
},
targets: &[Some(LINEAR_FRAME.into())],
}),
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
multiview_mask: None,
cache: None,
});
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout: &pipeline.get_bind_group_layout(0),
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: globals.as_entire_binding(),
}],
});
let frame = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: LINEAR_FRAME,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let mut encoder = device.create_command_encoder(&Default::default());
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &frame.create_view(&Default::default()),
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})],
..Default::default()
});
pass.set_pipeline(&pipeline);
pass.set_bind_group(0, &group, &[]);
pass.draw(0..3, 0..1);
}
let got = texel((&device, &queue), encoder, &frame);
assert!(
(got[0] - want).abs() < 0.002,
"linear {linear}: {got:?}, expected {want}"
);
assert_eq!(got[3], 1.0, "alpha kept");
}
}
// The frame O1 resolves (`output::resolve_frame`) holds linear light only for a 0.2 pack that
// asks for it, on the float frame. On a device without the float frame the pack draws anyway,
// in gamma. A blend changed is another frame, so the game makes the renderer again
// (`Renderer::frame_follows`).
#[test]
fn only_a_float_frame_holds_linear_light() {
use super::super::output::{FrameRequest, resolve_frame, tests::window};
let settings = crate::graphics::Graphics::default();
for float_frame in [false, true] {
for (linear_pack, linear_blend) in [(false, false), (true, false), (true, true)] {
let (mut caps, reading) = window(None, None);
caps.float_frame = float_frame;
let request = FrameRequest {
linear_pack,
linear_blend,
hdr_float: false,
};
let frame = resolve_frame(
&settings,
request,
caps,
reading,
wgpu::TextureFormat::Bgra8UnormSrgb,
);
assert_eq!(frame.format == LINEAR_FRAME, float_frame && linear_pack);
assert_eq!(
frame.linear_light,
float_frame && linear_blend,
"float {float_frame}, linear {linear_blend}"
);
}
}
}
@@ -22,7 +22,9 @@ const ENTRIES: &str = "
override horizon_material_fog: bool = true;
fn horizon_material_fogged(color: vec4<f32>, spherical: f32, cylindrical: f32) -> vec4<f32> {
if horizon_material_fog {
return horizon_terrain_apply_fog(color, spherical, cylindrical);
// In vanilla's gamma, as vanilla mixes it: on a frame of linear light, encoded for it.
let gamma = horizon_terrain_frame_gamma(color);
return horizon_terrain_frame_stored(horizon_terrain_apply_fog(gamma, spherical, cylindrical));
}
return color;
}
@@ -61,11 +63,14 @@ fn horizon_material_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color
}
// terrain.wgsl's fade-in (terrain.vsh's chunkVisibility): whole within 16 blocks, the section's
// own visibility from 32 out, toward the fog colour, alpha kept. The distance blend runs here on
// the interpolated distance, as vanilla's interpolated `visibility`, not once per triangle.
// the interpolated distance, as vanilla's interpolated `visibility`, not once per triangle. In
// vanilla's gamma, as the fog: on a frame of linear light, encoded for it.
fn horizon_material_faded(in: HorizonMaterialOut, color: vec4<f32>) -> vec4<f32> {
let instance = 1.0 - f32(in.hdr_code >> 16u) / 65535.0;
let visibility = mix(1.0, instance, clamp((in.spherical - 16.0) / 16.0, 0.0, 1.0));
return vec4(mix(horizon_terrain_globals.fog_color.rgb, color.rgb, visibility), color.a);
let gamma = horizon_terrain_frame_gamma(color);
let faded = mix(horizon_terrain_globals.fog_color.rgb, gamma.rgb, visibility);
return horizon_terrain_frame_stored(vec4(faded, color.a));
}
// `light`: sky, block, 16 per level.
fn horizon_material_out(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>,
@@ -277,8 +282,9 @@ pub struct TerrainShape<'a> {
pub flat: Option<&'a wgpu::BindGroupLayout>,
/// terrain.wgsl's `SPRITE_ARRAYS` and `SPRITE_SPLIT`.
pub sprites: [f64; 2],
/// The world target's colour format.
/// The world target's colour format, and whether it holds linear light (`linear_blend`).
pub format: wgpu::TextureFormat,
pub linear: bool,
/// The pack's tags of each tagged sprite layer, as `(layer, bits)`
/// (`Manifest::sprite_tags`).
pub sprite_tags: &'a [(u32, u32)],
@@ -369,12 +375,12 @@ fn shader_source(
tags: &[(u32, u32)],
materials: &[(u32, u32)],
(vertex, displaced): (bool, bool),
api: horizon_shader_api::Api,
(api, linear): (horizon_shader_api::Api, bool),
) -> String {
format!(
"{source}\n// Horizon's terrain (terrain.wgsl)\n{}{}{}{}{}{ENTRIES}{}{}",
vanilla_terrain(),
super::native_post::line_helpers(api),
super::native_post::line_helpers(api, linear),
if vertex {
DISPLACE_PACK
} else {
@@ -398,9 +404,9 @@ fn module(
materials: &[(u32, u32)],
vertex: bool,
displaced: bool,
api: horizon_shader_api::Api,
line: (horizon_shader_api::Api, bool),
) -> Result<naga::Module, String> {
let text = shader_source(&stage.source, tags, materials, (vertex, displaced), api);
let text = shader_source(&stage.source, tags, materials, (vertex, displaced), line);
naga::front::wgsl::parse_str(&text)
.map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text)))
}
@@ -424,7 +430,7 @@ impl NativeMaterial {
shape.sprite_materials,
vertex,
displaced.is_some(),
frame.api,
(frame.api, frame.linear),
)?;
let mut constants = present_constants(&module, constants);
let sprite_names = [
@@ -433,10 +439,10 @@ impl NativeMaterial {
];
constants.extend(sprite_names.into_iter().zip(shape.sprites));
constants.push(("horizon_material_fog", f64::from(u8::from(fog))));
constants.push((
"horizon_terrain_HDR_WRITE_LIGHT",
f64::from(u8::from(shape.format == super::LINEAR_FRAME)),
));
let [(_, write_light), (_, linear)] =
super::linear_blend::frame_constants(shape.format, frame.linear);
constants.push(("horizon_terrain_HDR_WRITE_LIGHT", write_light));
constants.push(("horizon_terrain_FRAME_LINEAR", linear));
constants.push((
"horizon_displace_distance",
displaced.map_or(0.0, |d| f64::from(d.distance)),
@@ -586,7 +592,9 @@ impl NativeMaterial {
}
#[cfg(test)]
pub(super) fn coverage_test_shader(api: horizon_shader_api::Api) -> String {
/// A material pack's terrain shader on shader API line `api`, its frame holding linear light
/// where `linear`, with a full-screen `test_vertex`.
pub(super) fn coverage_test_shader((api, linear): (horizon_shader_api::Api, bool)) -> String {
use horizon_shader_api::{MANIFEST, Pack, StageKind, check};
let manifest = format!(
"schema = 1\nid = \"coverage\"\nname = \"Coverage\"\nversion = \"1\"\napi = \"{api}\"\n[material]\nfile = \"material.wgsl\"\n[displacement]\n"
@@ -601,7 +609,7 @@ pub(super) fn coverage_test_shader(api: horizon_shader_api::Api) -> String {
.iter()
.find(|s| s.kind == StageKind::Material)
.unwrap();
shader_source(&stage.source, &[], &[], (false, true), api)
shader_source(&stage.source, &[], &[], (false, true), (api, linear))
+ r#"
override TEST_TIER: u32 = 0u;
override TEST_OVERLAY: u32 = 0u;
@@ -687,7 +695,7 @@ mod tests {
let tags = [(7, 1), (3, 1), (9, 2)];
let materials = [(5, 1000)];
assert!(layer_table("t", &tags).contains("case 3u, 7u: { return 1u; }"));
let module = module(&stage, &tags, &materials, false, false, Api::V0_1).unwrap();
let module = module(&stage, &tags, &materials, false, false, (Api::V0_1, false)).unwrap();
let info = naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
@@ -701,7 +709,7 @@ mod tests {
&tags,
&materials,
(false, false),
Api::V0_1
(Api::V0_1, false)
))
)
});
@@ -743,7 +751,7 @@ mod tests {
.into();
let report = check(&Pack::from_files(files).unwrap()).unwrap();
let stage = report.stages.iter().find(|s| s.kind == StageKind::Material);
let module = module(stage.unwrap(), &[], &[], true, false, Api::V0_2).unwrap();
let module = module(stage.unwrap(), &[], &[], true, false, (Api::V0_2, false)).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
@@ -766,14 +774,20 @@ mod tests {
let mut report = check(&Pack::from_files(files).unwrap()).unwrap();
let stage = report.stages.remove(0);
let materials = [(5, 1000)];
let module = module(&stage, &[], &materials, false, true, Api::V0_1).unwrap();
let module = module(&stage, &[], &materials, false, true, (Api::V0_1, false)).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap_or_else(|e| {
let text = shader_source(&stage.source, &[], &materials, (false, true), Api::V0_1);
let text = shader_source(
&stage.source,
&[],
&materials,
(false, true),
(Api::V0_1, false),
);
panic!("{}", e.emit_to_string(&text))
});
for entry in [
@@ -830,7 +844,7 @@ mod tests {
let report = check(&Pack::from_files(files).unwrap()).unwrap();
let source = report.stages[0].source.clone()
+ horizon_shader_api::stages::PBR_STUB
+ super::super::native_post::line_helpers(api)
+ super::super::native_post::line_helpers(api, false)
+ entry;
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
@@ -129,7 +129,7 @@ impl NativePack {
textures,
lights,
terrain.pbr,
api,
(api, terrain.linear && api == horizon_shader_api::Api::V0_2),
);
// `[displacement]`, unless its option is off; nothing to displace without heights.
let displacement = report
@@ -361,6 +361,7 @@ mod tests {
flat: None,
sprites: [1.0, 2048.0],
format: wgpu::TextureFormat::Rgba8Unorm,
linear: false,
sprite_tags: &[],
sprite_materials: &[],
entities: &empty,
@@ -16,43 +16,57 @@ use super::Frame;
pub(super) const INTERMEDIATE: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
/// What the renderer's own entries call where a stage's colours cross into and out of it: as
/// they are under 0.1; under 0.2 vanilla's 2.2 power, as the prelude's `horizon_decode` and
/// `horizon_encode` (the shadow pass has no prelude, so these stand alone).
pub fn line_helpers(api: Api) -> &'static str {
match api {
Api::V0_1 => {
/// they are under 0.1; under 0.2 vanilla's 2.2 power in, as the prelude's `horizon_decode`, and
/// out as the frame stores them: encoded, as `horizon_encode`, or where the frame holds linear
/// light (`linear`, `[frame] blend = "linear"`) as they are. The shadow pass has no prelude, so
/// these stand alone.
pub fn line_helpers(api: Api, linear: bool) -> &'static str {
match (api, linear) {
(Api::V0_1, _) => {
"fn horizon_line_in(c: vec3<f32>) -> vec3<f32> { return c; }
fn horizon_line_out(c: vec3<f32>) -> vec3<f32> { return c; }
"
}
Api::V0_2 => {
(Api::V0_2, false) => {
"fn horizon_line_in(c: vec3<f32>) -> vec3<f32> {
return pow(select(vec3<f32>(0.0), c, c > vec3<f32>(0.0)), vec3<f32>(2.2));
}
fn horizon_line_out(c: vec3<f32>) -> vec3<f32> {
return pow(select(vec3<f32>(0.0), c, c > vec3<f32>(0.0)), vec3<f32>(1.0 / 2.2));
}
"
}
(Api::V0_2, true) => {
"fn horizon_line_in(c: vec3<f32>) -> vec3<f32> {
return pow(select(vec3<f32>(0.0), c, c > vec3<f32>(0.0)), vec3<f32>(2.2));
}
fn horizon_line_out(c: vec3<f32>) -> vec3<f32> { return c; }
"
}
}
}
/// 0.2's decoding copy: a picture in the frame's gamma into linear light in `INTERMEDIATE`, of
/// the same size, alpha 1 (`scene_color`, `opaque_color`, the mirror).
/// 0.2's decoding copy: a picture of the frame into linear light in `INTERMEDIATE`, of the same
/// size, alpha 1 (`scene_color`, `opaque_color`, the mirror). A frame of vanilla's gamma is
/// decoded; one that holds linear light (`linear`) is copied as it is.
#[derive(Clone)]
pub struct Decoder(wgpu::RenderPipeline);
impl Decoder {
fn new(device: &wgpu::Device) -> Self {
let source = line_helpers(Api::V0_2).to_owned()
+ "@group(0) @binding(0) var horizon_from: texture_2d<f32>;
@vertex fn horizon_vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
pub(super) fn new(device: &wgpu::Device, linear: bool) -> Self {
let read = if linear { "c" } else { "horizon_line_in(c)" };
let source = line_helpers(Api::V0_2, false).to_owned()
+ &format!(
"@group(0) @binding(0) var horizon_from: texture_2d<f32>;
@vertex fn horizon_vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {{
return vec4<f32>(vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u)) * 2.0 - 1.0, 0.0, 1.0);
}
@fragment fn horizon_fs(@builtin(position) p: vec4<f32>) -> @location(0) vec4<f32> {
return vec4<f32>(horizon_line_in(textureLoad(horizon_from, vec2<i32>(p.xy), 0).rgb), 1.0);
}
";
}}
@fragment fn horizon_fs(@builtin(position) p: vec4<f32>) -> @location(0) vec4<f32> {{
let c = textureLoad(horizon_from, vec2<i32>(p.xy), 0).rgb;
return vec4<f32>({read}, 1.0);
}}
"
);
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Shader API 0.2 decode"),
source: wgpu::ShaderSource::Wgsl(source.into()),
@@ -353,9 +367,11 @@ pub struct NativeFrame {
frames: u32,
/// `frame.wetness`, and when it was last written.
wetness: (f32, Option<std::time::Instant>),
/// The shader API line of the pack, and under 0.2 the decoding copy and the mirror it
/// decoded (`linear_mirror`).
/// The shader API line of the pack, whether the world's frame holds linear light for it
/// (`[frame] blend = "linear"`), and under 0.2 the decoding copy and the mirror it decoded
/// (`linear_mirror`).
pub api: Api,
pub linear: bool,
pub decoder: Option<Decoder>,
mirror: Option<wgpu::TextureView>,
}
@@ -433,15 +449,16 @@ fn eased(value: f32, target: f32, half_life: f32, dt: f32) -> f32 {
}
impl NativeFrame {
/// The frame of a pack with the `[shadow]` settings `shadow`, if any, and the
/// block light colours `lights`.
/// The frame of a pack of the shader API line `api` with the `[shadow]` settings `shadow`, if
/// any, and the block light colours `lights`, onto a world's frame that holds linear light
/// when `linear` (a 0.2 pack's).
pub fn new(
device: &wgpu::Device,
shadow: Option<&horizon_shader_api::manifest::Shadow>,
textures: Vec<(wgpu::TextureView, wgpu::Sampler)>,
lights: super::block_light::LightTable,
pbr: Option<&[wgpu::Texture; 2]>,
api: Api,
(api, linear): (Api, bool),
) -> Self {
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Native shader pack frame"),
@@ -565,7 +582,8 @@ impl NativeFrame {
shadow_camera: shadow.map(super::native_shadow::camera),
reflects: false,
api,
decoder: (api == Api::V0_2).then(|| Decoder::new(device)),
linear,
decoder: (api == Api::V0_2).then(|| Decoder::new(device, linear)),
mirror: None,
last: None,
eye: [f32::NAN; 4],
@@ -1759,7 +1777,7 @@ mod tests {
Vec::new(),
Default::default(),
None,
Api::V0_1,
(Api::V0_1, false),
);
frame.pack.still_ticks = Some(47_811);
frame.pack.eye_light = [0, 15];
@@ -1809,7 +1827,7 @@ mod tests {
Vec::new(),
Default::default(),
None,
Api::V0_1,
(Api::V0_1, false),
)
};
let (mut used, mut fresh) = (new(), new());
@@ -1875,7 +1893,7 @@ file = \"c.wgsl\"
Vec::new(),
Default::default(),
None,
Api::V0_1,
(Api::V0_1, false),
);
let mut post = NativePost::new(
&device,
@@ -1988,7 +2006,7 @@ file = \"c.wgsl\"
Vec::new(),
Default::default(),
None,
Api::V0_1,
(Api::V0_1, false),
);
queue.write_buffer(
&frame.buffer,
@@ -2116,7 +2134,7 @@ file = \"b.wgsl\"
Vec::new(),
Default::default(),
None,
Api::V0_1,
(Api::V0_1, false),
);
let mut post = NativePost::new(
&device,
@@ -2236,7 +2254,7 @@ file = \"b.wgsl\"
Vec::new(),
Default::default(),
None,
Api::V0_1,
(Api::V0_1, false),
);
let mut post = NativePost::new(
&device,
@@ -2571,7 +2589,7 @@ file = \"b.wgsl\"
Vec::new(),
Default::default(),
None,
Api::V0_2,
(Api::V0_2, false),
);
let inputs = FrameInputs {
headroom: 1.0,
@@ -2655,7 +2673,7 @@ file = \"b.wgsl\"
Vec::new(),
Default::default(),
None,
Api::V0_2,
(Api::V0_2, false),
);
let fields = |i: &FrameInputs| (i.hdr, i.headroom, i.paper_white, i.peak, i.black_level);
assert_eq!(
@@ -2725,7 +2743,7 @@ file = \"b.wgsl\"
Vec::new(),
Default::default(),
None,
Api::V0_2,
(Api::V0_2, false),
);
let inputs = FrameInputs {
hdr: u32::from(hdr),
@@ -2752,6 +2770,8 @@ file = \"b.wgsl\"
true,
);
(display.paper_white, display.peak) = (paper, peak);
// As the renderer has it under a pack that draws: Horizon lights none of it.
display.lighting = false;
if switched {
use super::super::hdr::Output::{Pq, ScRgb};
display = display.retargeted(&device, &queue, ScRgb);
@@ -516,7 +516,7 @@ fn shader_source(
+ section(TERRAIN, "struct Globals {", "// Sky (SkyRenderer)")),
PREFIX
),
super::native_post::line_helpers(api)
super::native_post::line_helpers(api, false)
)
}
@@ -39,12 +39,13 @@ fn vanilla_sky() -> String {
prefixed(&text, PREFIX)
}
/// The sky shader for the pack's sky stage `source`.
fn shader_source(source: &str, api: horizon_shader_api::Api) -> String {
/// The sky shader for the pack's sky stage `source`, of the line `api`, onto a frame that holds
/// linear light when `linear`.
fn shader_source(source: &str, (api, linear): (horizon_shader_api::Api, bool)) -> String {
format!(
"{source}\n// Horizon's sky (terrain.wgsl)\n{}{}{PACK_ENTRY}",
vanilla_sky(),
super::native_post::line_helpers(api)
super::native_post::line_helpers(api, linear)
)
}
@@ -102,7 +103,7 @@ fn build(
frame: &NativeFrame,
stage: &CheckedStage,
) -> Result<(naga::Module, wgpu::PipelineLayout), String> {
let text = shader_source(&stage.source, frame.api);
let text = shader_source(&stage.source, (frame.api, frame.linear));
let module = naga::front::wgsl::parse_str(&text)
.map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text)))?;
let mut groups: [Option<&wgpu::BindGroupLayout>; GROUP as usize + 1] = Default::default();
@@ -198,7 +199,7 @@ mod tests {
// A pack may use the renderer's names for its own things.
let own = "override gain: f32;\nconst PI: f32 = 3.0;\nfn view_ray() -> f32 { return PI; }\n\
fn sky(input: SkyInput) -> vec4<f32> { return input.color * gain * view_ray(); }\n";
let text = shader_source(&stage(own).source, horizon_shader_api::Api::V0_1);
let text = shader_source(&stage(own).source, (horizon_shader_api::Api::V0_1, false));
let module = naga::front::wgsl::parse_str(&text).unwrap_or_else(|e| {
panic!("{}", e.emit_to_string(&text));
});
@@ -261,7 +262,7 @@ mod tests {
Vec::new(),
Default::default(),
None,
horizon_shader_api::Api::V0_1,
(horizon_shader_api::Api::V0_1, false),
);
queue.write_buffer(
&frame.buffer,
+5 -2
View File
@@ -696,13 +696,16 @@ impl Oit {
),
] {
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
let pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(label),
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes(label)),
color_attachments: &first,
depth_stencil_attachment: read_only(terrain_depth),
..Default::default()
});
let pixels = super::pixel_count(terrain_depth);
let mut pass =
super::pack_timing::counted(timing.as_deref_mut(), pass, pixels, label);
pass.set_bind_group(GROUP, group, &[]);
draw(&mut pass, stage);
}
@@ -972,7 +975,7 @@ mod tests {
);
let border = stages("border");
validate(
&(include_str!("../border.wgsl").to_owned()
&(super::super::border_shader()
+ include_str!("../oit.wgsl")
+ include_str!("../oit_border.wgsl")),
&border.iter().map(String::as_str).collect::<Vec<_>>(),
@@ -69,7 +69,7 @@ impl Features {
let border_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Horizon improved transparency world border"),
source: wgpu::ShaderSource::Wgsl(
(include_str!("../border.wgsl").to_owned()
(super::border_shader()
+ include_str!("../oit.wgsl")
+ include_str!("../oit_border.wgsl"))
.into(),
@@ -77,6 +77,9 @@ impl DisplayReading {
pub struct FrameRequest {
/// A shader API 0.2 pack is picked under `HORIZON_HDR_FRAME=float`: its frame is float.
pub linear_pack: bool,
/// That pack's `[frame] blend` is `"linear"`: its frame holds linear light where it is
/// float (`linear_blend`).
pub linear_blend: bool,
/// Under `HORIZON_HDR_FRAME=float`, with no pack available or a 0.2 one: the frame is
/// the layered float one wherever HDR output goes.
pub hdr_float: bool,
@@ -89,6 +92,8 @@ pub struct FrameConfiguration {
pub(crate) overlay_format: wgpu::TextureFormat,
/// The layered float HDR frame: an 8-bit HUD target and an R8 coverage target beside it.
pub(crate) hud_layer: bool,
/// The frame holds linear light, not vanilla's gamma (`linear_blend::stores_linear`).
pub(crate) linear_light: bool,
}
/// The display a 0.2 pack's `Frame` describes (docs/api/shaders-reference-0.2.md).
@@ -166,6 +171,7 @@ impl EffectiveOutput {
format: wgpu::TextureFormat::Bgra8Unorm,
overlay_format: wgpu::TextureFormat::Bgra8Unorm,
hud_layer: false,
linear_light: false,
},
nits: [203.0, 1000.0, 0.0],
ui_brightness: 1.0,
@@ -259,6 +265,7 @@ pub(crate) fn resolve_frame(
format,
overlay_format: super::overlay_format(format, hud_layer, swapchain),
hud_layer,
linear_light: super::linear_blend::stores_linear(request.linear_blend, format),
}
}
@@ -383,10 +390,12 @@ pub(crate) mod tests {
fn the_resolver_picks_one_output_and_frame_for_each_combination() {
let no_pack = FrameRequest {
linear_pack: false,
linear_blend: false,
hdr_float: true,
};
let pack_0_2 = FrameRequest {
linear_pack: true,
linear_blend: false,
hdr_float: true,
};
// A 0.1 pack, or none without the float switch; a refused, missing or disabled
@@ -499,6 +508,7 @@ pub(crate) mod tests {
};
let request = FrameRequest {
linear_pack: false,
linear_blend: false,
hdr_float: true,
};
let on = |driver: Option<&str>| {
@@ -741,6 +751,7 @@ pub(crate) mod tests {
};
let no_pack = FrameRequest {
linear_pack: false,
linear_blend: false,
hdr_float: true,
};
let (caps, reading) = window(Some(Output::Pq), Some(true));
@@ -460,6 +460,7 @@ mod tests {
const NO_PACK: FrameRequest = FrameRequest {
linear_pack: false,
linear_blend: false,
hdr_float: true,
};
fn on() -> Graphics {
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,169 @@
//! The world pass after culling, and the renderer's GPU pass timing switches and readings.
use super::{Renderer, native_pack, pack_post, pack_timing};
pub(super) fn world_pass_after_culling<'e>(
encoder: &'e mut wgpu::CommandEncoder,
view: &wgpu::TextureView,
depth: &wgpu::TextureView,
keep_depth: bool,
mut timing: Option<&mut pack_timing::PassTiming>,
name: &str,
pixels: u64,
) -> pack_timing::CountedPass<'e> {
let pass = after_culling_pass(
encoder,
view,
depth,
keep_depth,
timing.as_deref_mut().and_then(|t| t.writes(name)),
);
pack_timing::counted(timing, pass, pixels, "before translucent")
}
/// The pixels of a depth attachment, for `fragments_per_pixel`.
pub(super) fn pixel_count(depth: &wgpu::TextureView) -> u64 {
let texture = depth.texture();
u64::from(texture.width()) * u64::from(texture.height())
}
pub(super) fn after_culling_pass<'e>(
encoder: &'e mut wgpu::CommandEncoder,
view: &wgpu::TextureView,
depth: &wgpu::TextureView,
keep_depth: bool,
timestamp_writes: Option<wgpu::RenderPassTimestampWrites<'_>>,
) -> wgpu::RenderPass<'e> {
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Horizon world, after culling"),
timestamp_writes,
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: depth,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Load,
// A shader pack reads it as depthtex0.
store: if keep_depth {
wgpu::StoreOp::Store
} else {
wgpu::StoreOp::Discard
},
}),
stencil_ops: None,
}),
..Default::default()
})
}
impl Renderer {
/// Times the shader pack's passes on the GPU while `on` (the F3 overlay is open or a capture records them).
pub fn set_pass_timing(&mut self, on: bool) {
self.pass_timing = on;
}
/// Times the parts of Horizon's own passes while `on`.
pub fn set_pass_detail(&mut self, on: bool) {
self.pass_detail = on;
}
/// Times a frame at most once per `gap` while passes are timed (the F3 overlay needs far fewer
/// samples than a measurement).
pub fn set_pass_timing_gap(&mut self, gap: std::time::Duration) {
self.pass_timing_gap = gap;
}
/// Keeps each timed frame's pass times for `take_timed_frames` while `on` (a capture records them).
pub fn set_keep_timed_frames(&mut self, on: bool) {
self.keep_timed_frames = on;
}
/// The frames both timers timed since the last call, without waiting on the GPU.
pub fn take_timed_frames(&mut self) -> pack_timing::TimedFrames {
let own = self
.own_timing
.as_mut()
.map(pack_timing::PassTiming::take_frames)
.unwrap_or_default();
let pack = match Self::pass_timer(&mut self.pack_post, &mut self.native) {
Some(Some(t)) => t.take_frames(),
_ => Vec::new(),
};
pack_timing::TimedFrames { own, pack }
}
/// The running shader pack's timer, of either kind.
pub(super) fn pass_timer<'a>(
pack: &'a mut Option<(i32, Option<pack_post::PackPost>)>,
native: &'a mut Option<native_pack::NativePack>,
) -> Option<&'a mut Option<pack_timing::PassTiming>> {
match (pack, native) {
(Some((_, Some(post))), _) => Some(&mut post.timing),
(_, Some(native)) => Some(&mut native.timing),
_ => None,
}
}
/// Each pass of the running shader pack with its median GPU time in milliseconds, while
/// they are timed (`set_pass_timing`).
pub fn shader_pass_times(&self) -> Vec<(String, f32)> {
let timer = match (&self.pack_post, &self.native) {
(Some((_, Some(post))), _) => post.timing.as_ref(),
(_, Some(native)) => native.timing.as_ref(),
_ => None,
};
timer
.map(pack_timing::PassTiming::medians)
.unwrap_or_default()
}
/// Each of Horizon's own passes with its median GPU time in milliseconds, while they are
/// timed (`set_pass_timing`).
pub fn own_pass_times(&self) -> Vec<(String, f32)> {
self.own_timing
.as_ref()
.map(pack_timing::PassTiming::medians)
.unwrap_or_default()
}
/// Each of Horizon's own passes with its parts' median GPU times in milliseconds.
pub fn own_pass_parts(&self) -> Vec<(String, Vec<(String, f32)>)> {
self.own_timing
.as_ref()
.map(pack_timing::PassTiming::part_medians)
.unwrap_or_default()
}
/// Each counted pass's counters (medians), while detail is on and the device has them.
pub fn own_pass_counters(&self) -> Vec<(String, pack_timing::PassCounters)> {
self.own_timing
.as_ref()
.map(pack_timing::PassTiming::counter_medians)
.unwrap_or_default()
}
/// Both timers' readings and this adapter, for a capture; `None` when neither has timed a pass.
pub fn gpu_readout(&self) -> Option<pack_timing::GpuReadout> {
let own = self
.own_timing
.as_ref()
.map(pack_timing::PassTiming::readings)
.filter(|r| !r.is_empty());
let pack = match (&self.pack_post, &self.native) {
(Some((_, Some(post))), _) => post.timing.as_ref(),
(_, Some(native)) => native.timing.as_ref(),
_ => None,
}
.map(pack_timing::PassTiming::readings)
.filter(|r| !r.is_empty());
(own.is_some() || pack.is_some()).then(|| pack_timing::GpuReadout {
adapter: self.info.clone(),
own,
pack,
})
}
/// Whether dispatch parts and counters are being asked for (`set_pass_detail`).
pub fn pass_detail(&self) -> bool {
self.pass_detail
}
/// What detail timing can show on this device; `None` without pass timing.
pub fn pass_detail_support(&self) -> Option<pack_timing::DetailSupport> {
self.own_timing
.as_ref()
.map(pack_timing::PassTiming::detail_support)
}
}
@@ -3,9 +3,45 @@
//! draws with it yet. Without the variable, and in a build without `horizon-extras` (a Horizon
//! extra, docs/layers.md), the device is opened exactly as before.
/// The terrain's acceleration structures (step 2).
#[path = "ray_scene.rs"]
pub(in crate::gfx) mod ray_scene;
/// The ray query feature.
const RAY_QUERY: wgpu::Features = wgpu::Features::EXPERIMENTAL_RAY_QUERY;
/// Whether `device` was opened with ray queries.
pub(in crate::gfx) fn on(device: &wgpu::Device) -> bool {
device.features().contains(RAY_QUERY)
}
/// What buffers that BLAS builds read need on top of their own usage: nothing without ray
/// queries, so the off path's buffers are main's.
pub(in crate::gfx) fn blas_input(device: &wgpu::Device) -> wgpu::BufferUsages {
if on(device) {
wgpu::BufferUsages::BLAS_INPUT
} else {
wgpu::BufferUsages::empty()
}
}
/// The index buffer of `quads` quads (triangles 0 1 2 and 0 2 3), which the terrain draws
/// and BLAS builds both read.
pub(in crate::gfx) fn quad_indices(device: &wgpu::Device, quads: u32) -> wgpu::Buffer {
use wgpu::util::DeviceExt;
let indices: Vec<u32> = (0..quads)
.flat_map(|q| {
let b = q * 4;
[b, b + 1, b + 2, b, b + 2, b + 3]
})
.collect();
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Quad indices"),
contents: bytemuck::cast_slice(&indices),
usage: wgpu::BufferUsages::INDEX | blas_input(device),
})
}
/// Whether the player asked for ray tracing (the device needs the feature from its creation).
fn requested() -> bool {
asked(std::env::var("HORIZON_RAY_TRACING").ok().as_deref())
@@ -0,0 +1,538 @@
//! The terrain as acceleration structures, with ray tracing on (#13780 step 2, after the #1180
//! spike; `ray_query`): one BLAS per section's solid quads within `reach` chunks of the camera,
//! compacted once built, and one TLAS of them, built again only when a BLAS changes or the camera
//! enters another chunk column (its instances sit relative to that column). Nothing traces them
//! yet. Without ray queries on the device there is no scene and nothing here runs.
//!
//! The section arena keeps its compact layout: a section about to get a BLAS has its solid quads
//! written out as vertices first (`Unpacker`, `rt_unpack.wgsl`), as the solid draws expand them.
use glam::DVec3;
use crate::fast_hash::FxHashMap;
use crate::gfx::{Renderer, SectionGpu, SectionKey, arena::Arena, resident::Resident};
use crate::mesher::TerrainVertex;
/// BLAS builds per frame at most: a world that just loaded builds over a few frames.
const BUILDS_PER_FRAME: usize = 512;
/// Chunks around the camera the TLAS spans, unless `HORIZON_RT_CHUNKS` says otherwise. The
/// #1180 spike's TLAS over every section cost 2-7 ms of CPU a frame at render distance 32.
const REACH: i32 = 8;
/// The renderer's side: the calls gfx.rs makes, each one line there.
impl Renderer {
/// Section `key` got a new mesh, or lost it.
pub(in crate::gfx) fn ray_mark(&mut self, key: SectionKey) {
if let Some(rt) = &mut self.ray_scene {
rt.mark(key);
}
}
/// Every section is gone (a new world, a new dimension).
pub(in crate::gfx) fn ray_clear(&mut self) {
if let Some(rt) = &mut self.ray_scene {
rt.clear();
}
}
/// The frame's BLAS builds and TLAS, recorded in `encoder` before the world pass.
pub(in crate::gfx) fn ray_prepare(
&mut self,
encoder: &mut wgpu::CommandEncoder,
camera: DVec3,
title: bool,
) {
if let Some(rt) = &mut self.ray_scene
&& !title
{
crate::spans::time("ray scene", || {
rt.prepare(
&self.device,
&self.queue,
encoder,
&self.sections,
&self.arena,
&self.indices,
camera,
)
});
}
}
}
/// The terrain's acceleration structures.
pub(in crate::gfx) struct RayScene {
/// Each section's BLAS, while it has solid quads within reach.
blas: FxHashMap<SectionKey, wgpu::Blas>,
/// Sections meshed again or emptied since their BLAS was built.
dirty: Vec<SectionKey>,
tlas: wgpu::Tlas,
/// The camera's chunk column when the TLAS was built: its instances are placed relative to
/// that column's corner, so moving inside it needs no new TLAS.
anchor: Option<(i32, i32)>,
/// Chunks around the anchor the TLAS spans.
reach: i32,
/// BLAS built this frame, to measure for compaction once submitted.
to_compact: Vec<(SectionKey, wgpu::Blas)>,
/// BLAS whose compacted size the GPU is measuring.
compacting: Vec<(SectionKey, wgpu::Blas)>,
/// Sections in the arena's compact layout, written out as vertices for their BLAS.
unpacker: Unpacker,
}
impl RayScene {
/// The scene, when the device has ray queries (`ray_query`).
pub(in crate::gfx) fn new(device: &wgpu::Device) -> Option<Self> {
if !super::on(device) {
return None;
}
let reach = std::env::var("HORIZON_RT_CHUNKS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(REACH);
Some(Self {
blas: FxHashMap::default(),
dirty: Vec::new(),
tlas: create_tlas(device, 1024),
anchor: None,
reach,
to_compact: Vec::new(),
compacting: Vec::new(),
unpacker: Unpacker::new(device, 1 << 20),
})
}
fn mark(&mut self, key: SectionKey) {
self.dirty.push(key);
}
fn clear(&mut self) {
self.blas.clear();
self.dirty.clear();
self.to_compact.clear();
self.compacting.clear();
self.anchor = None;
}
/// Compacts BLAS built on earlier frames: built with room to spare, a compacted copy (made
/// once the GPU has measured it) takes about a quarter. Whether a BLAS was replaced.
fn compact(&mut self, queue: &wgpu::Queue) -> bool {
let mut changed = false;
let blas = &mut self.blas;
self.compacting.retain(|(key, b)| {
if blas.get(key) != Some(b) {
return false;
}
if !b.ready_for_compaction() {
return true;
}
blas.insert(*key, queue.compact_blas(b));
changed = true;
false
});
// Built and submitted last frame: the GPU can now measure them.
for (key, b) in std::mem::take(&mut self.to_compact) {
if self.blas.get(&key) == Some(&b) {
b.prepare_compaction_async(|_| {});
self.compacting.push((key, b));
}
}
changed
}
/// Builds the BLAS of sections marked since the last frame (at most `BUILDS_PER_FRAME`, and
/// as many as the unpacker has room for), and the TLAS again when a BLAS changed or the
/// camera entered another chunk column.
#[allow(clippy::too_many_arguments)]
fn prepare(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
sections: &Resident<SectionGpu>,
arena: &Arena,
indices: &wgpu::Buffer,
camera: DVec3,
) {
let column = crate::gfx::resident::camera_column(camera);
// Only sections within `reach` get a BLAS (at render distance 32 all of them took 1.3 GB
// of video memory); those past `reach + 2` lose theirs, so walking along a chunk border
// doesn't build the same ones over and over.
let keep = self.reach + 2;
let mut changed = self.compact(queue);
if self.anchor != Some(column) {
let before = self.blas.len();
self.blas.retain(|k, _| within(k, column, keep));
changed |= self.blas.len() != before;
for (key, s, _) in sections.iter() {
if within(&key, column, self.reach) && s.solid > 0 && !self.blas.contains_key(&key)
{
self.dirty.push(key);
}
}
}
let take = self.dirty.len().min(BUILDS_PER_FRAME);
let mut builds: Vec<Build<'_>> = Vec::new();
let mut deferred = Vec::new();
// Vertices the unpacker writes this frame.
let mut unpacked = 0;
for key in self.dirty.drain(..take) {
if builds.iter().any(|b| b.key == key) {
continue;
}
match sections
.find(key)
.filter(|s| s.solid > 0 && within(&key, column, self.reach))
{
Some(s) => {
let at = if s.packed {
let vertices = u64::from(s.solid) * 4;
if unpacked + vertices > self.unpacker.capacity {
if unpacked > 0 {
// The rest waits for the next frame's room.
deferred.push(key);
continue;
}
self.unpacker.grow(device, vertices);
}
unpacked += vertices;
Some(unpacked - vertices)
} else {
None
};
let (size, blas) = new_blas(device, s.solid);
builds.push(Build {
key,
s,
size,
blas,
unpacked: at,
});
}
None => changed |= self.blas.remove(&key).is_some(),
}
}
self.dirty.extend(deferred);
if builds.is_empty() && !changed && self.anchor == Some(column) {
return;
}
self.unpacker.run(device, encoder, arena, &builds);
for b in &builds {
self.blas.insert(b.key, b.blas.clone());
self.to_compact.push((b.key, b.blas.clone()));
}
let near = |k: &SectionKey| within(k, column, self.reach);
let count = self.blas.keys().filter(|k| near(k)).count();
if count > self.tlas.get().len() {
self.tlas = create_tlas(device, (count as u32).next_power_of_two());
}
let min_y = sections.min_y();
let mut n = 0;
for (key, blas) in self.blas.iter().filter(|(k, _)| near(k)) {
let o = instance_origin(*key, min_y, column);
self.tlas[n] = Some(wgpu::TlasInstance::new(
blas,
[
1.0, 0.0, 0.0, o[0], 0.0, 1.0, 0.0, o[1], 0.0, 0.0, 1.0, o[2],
],
0,
0xff,
));
n += 1;
}
for i in n..self.tlas.get().len() {
if self.tlas[i].is_none() {
break;
}
self.tlas[i] = None;
}
let entries = build_entries(&builds, arena, &self.unpacker, indices);
encoder.build_acceleration_structures(entries.iter(), std::iter::once(&self.tlas));
self.anchor = Some(column);
}
}
/// Whether section `key` is within `reach` chunks of column `column`, along x and z.
fn within(key: &SectionKey, column: (i32, i32), reach: i32) -> bool {
(key.0 - column.0).abs() <= reach && (key.2 - column.1).abs() <= reach
}
/// A BLAS to build this frame.
struct Build<'a> {
key: SectionKey,
s: &'a SectionGpu,
size: wgpu::BlasTriangleGeometrySizeDescriptor,
blas: wgpu::Blas,
/// Its first vertex in the unpacker's buffer, for a section in the compact layout.
unpacked: Option<u64>,
}
impl Build<'_> {
/// The buffer holding its solid quads as vertices, and its first vertex there.
fn vertices<'b>(&self, arena: &'b Arena, unpacker: &'b Unpacker) -> (&'b wgpu::Buffer, u64) {
match self.unpacked {
Some(at) => (&unpacker.out, at),
None => (arena.buffer(self.s.page), self.s.start),
}
}
}
/// `rt_unpack.wgsl`'s `RtJob`.
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct RtJob {
base: u32,
quads: u32,
out: u32,
pad: u32,
}
/// Writes the solid quads of sections in the arena's compact layout out as vertices, for their
/// BLAS (BLAS builds read vertices; the records are only for the draws to expand). The buffer
/// is reused every frame: a frame takes only the sections it has room for.
struct Unpacker {
pipeline: wgpu::ComputePipeline,
out: wgpu::Buffer,
/// Vertices `out` holds.
capacity: u64,
}
impl Unpacker {
fn new(device: &wgpu::Device, capacity: u64) -> Self {
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Ray tracing: unpack sections"),
source: wgpu::ShaderSource::Wgsl(unpack_shader().into()),
});
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("Ray tracing: unpack sections"),
layout: None,
module: &module,
entry_point: Some("cs_rt_unpack"),
compilation_options: Default::default(),
cache: None,
});
Self {
pipeline,
out: unpacked_buffer(device, capacity),
capacity,
}
}
/// Room for at least `vertices` (one section larger than the buffer).
fn grow(&mut self, device: &wgpu::Device, vertices: u64) {
self.capacity = vertices.next_power_of_two();
self.out = unpacked_buffer(device, self.capacity);
}
/// Writes out the builds' sections that are in the compact layout, one dispatch per arena
/// page.
fn run(
&self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
arena: &Arena,
builds: &[Build<'_>],
) {
let mut pages: Vec<u32> = builds
.iter()
.filter(|b| b.unpacked.is_some())
.map(|b| b.s.page)
.collect();
pages.sort_unstable();
pages.dedup();
if pages.is_empty() {
return;
}
let empty = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout: &self.pipeline.get_bind_group_layout(0),
entries: &[],
});
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("Ray tracing: unpack sections"),
timestamp_writes: None,
});
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &empty, &[]);
for page in pages {
let jobs: Vec<RtJob> = builds
.iter()
.filter_map(|b| {
let at = b.unpacked.filter(|_| b.s.page == page)?;
Some(RtJob {
base: b.s.solid_base(),
quads: b.s.solid,
out: at as u32,
pad: 0,
})
})
.collect();
let jobs_buffer = wgpu::util::DeviceExt::create_buffer_init(
device,
&wgpu::util::BufferInitDescriptor {
label: Some("Ray tracing: unpack jobs"),
contents: bytemuck::cast_slice(&jobs),
usage: wgpu::BufferUsages::STORAGE,
},
);
let arena_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout: &self.pipeline.get_bind_group_layout(1),
entries: &[wgpu::BindGroupEntry {
binding: 19,
resource: arena.buffer(page).as_entire_binding(),
}],
});
let out_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout: &self.pipeline.get_bind_group_layout(2),
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: jobs_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: self.out.as_entire_binding(),
},
],
});
let most = jobs.iter().map(|j| j.quads * 4).max().unwrap_or(0);
pass.set_bind_group(1, &arena_group, &[]);
pass.set_bind_group(2, &out_group, &[]);
pass.dispatch_workgroups(most.div_ceil(64), jobs.len() as u32, 1);
}
}
}
/// terrain.wgsl with the unpacking entry point after it, which reads sections as its solid
/// draws do (`section_corner`).
pub(in crate::gfx) fn unpack_shader() -> String {
crate::gfx::terrain_shader() + include_str!("../rt_unpack.wgsl")
}
fn unpacked_buffer(device: &wgpu::Device, vertices: u64) -> wgpu::Buffer {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Ray tracing: unpacked sections"),
size: vertices * std::mem::size_of::<TerrainVertex>() as u64,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::BLAS_INPUT,
mapped_at_creation: false,
})
}
fn create_tlas(device: &wgpu::Device, capacity: u32) -> wgpu::Tlas {
device.create_tlas(&wgpu::CreateTlasDescriptor {
label: Some("Terrain sections"),
max_instances: capacity,
flags: wgpu::AccelerationStructureFlags::PREFER_FAST_TRACE,
update_mode: wgpu::AccelerationStructureUpdateMode::Build,
})
}
/// A new BLAS for `solid` quads, and its size.
fn new_blas(
device: &wgpu::Device,
solid: u32,
) -> (wgpu::BlasTriangleGeometrySizeDescriptor, wgpu::Blas) {
let size = wgpu::BlasTriangleGeometrySizeDescriptor {
vertex_format: wgpu::VertexFormat::Float32x3,
vertex_count: solid * 4,
index_format: Some(wgpu::IndexFormat::Uint32),
index_count: Some(solid * 6),
flags: wgpu::AccelerationStructureGeometryFlags::OPAQUE,
};
let blas = device.create_blas(
&wgpu::CreateBlasDescriptor {
label: None,
flags: wgpu::AccelerationStructureFlags::PREFER_FAST_TRACE
| wgpu::AccelerationStructureFlags::ALLOW_COMPACTION,
update_mode: wgpu::AccelerationStructureUpdateMode::Build,
},
wgpu::BlasGeometrySizeDescriptors::Triangles {
descriptors: vec![size.clone()],
},
);
(size, blas)
}
/// Build entries reading each section's solid vertices (`pos` is `TerrainVertex`'s first field)
/// with the shared quad indices.
fn build_entries<'a>(
builds: &'a [Build<'a>],
arena: &'a Arena,
unpacker: &'a Unpacker,
indices: &'a wgpu::Buffer,
) -> Vec<wgpu::BlasBuildEntry<'a>> {
let stride = std::mem::size_of::<TerrainVertex>() as u64;
builds
.iter()
.map(|b| {
let (source, first) = b.vertices(arena, unpacker);
wgpu::BlasBuildEntry {
blas: &b.blas,
geometry: wgpu::BlasGeometries::TriangleGeometries(vec![
wgpu::BlasTriangleGeometry {
size: &b.size,
vertex_buffer: source,
first_vertex: first as u32,
vertex_stride: stride,
index_buffer: Some(indices),
first_index: Some(0),
transform_buffer: None,
transform_buffer_offset: None,
},
]),
}
})
.collect()
}
/// Where section `key` sits relative to the corner of chunk column `anchor`.
fn instance_origin(key: SectionKey, min_y: i32, anchor: (i32, i32)) -> [f32; 3] {
[
((key.0 - anchor.0) * 16) as f32,
(min_y + key.1 * 16) as f32,
((key.2 - anchor.1) * 16) as f32,
]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn instances_sit_relative_to_the_anchor_column() {
// Section 5 of column (3, -2), the world starting at y = -64, seen from column (2, -2).
assert_eq!(instance_origin((3, 5, -2), -64, (2, -2)), [16.0, 16.0, 0.0]);
assert_eq!(instance_origin((2, 0, -2), -64, (2, -2)), [0.0, -64.0, 0.0]);
}
#[test]
fn the_unpack_shader_validates_with_terrain() {
let source = unpack_shader();
let module = naga::front::wgsl::parse_str(&source).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap();
}
/// The off path is main's: a device opened without ray queries (every device without
/// `HORIZON_RAY_TRACING`, and every plain build) gets no scene, and its buffers no usage.
#[test]
fn without_ray_queries_there_is_no_scene() {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let Ok((device, _)) = pollster::block_on(adapter.request_device(&Default::default()))
else {
return;
};
assert!(!super::super::on(&device));
assert!(RayScene::new(&device).is_none());
assert!(super::super::blas_input(&device).is_empty());
}
}
@@ -92,7 +92,6 @@ impl<T> Resident<T> {
self.items.iter_mut().map(|(_, v)| v)
}
/// Every section's key, value and bounds.
#[cfg(any(test, debug_assertions))]
pub fn iter(&self) -> impl Iterator<Item = (SectionKey, &T, Option<[Vec3; 2]>)> {
let items = self.items.iter().zip(&self.bounds);
items.map(|((key, value), bounds)| (*key, value, *bounds))
@@ -47,7 +47,7 @@ struct Uniform {
clip_from_sky: [[f32; 4]; 4],
color: [f32; 4],
hdr_settings: [f32; 4],
hdr_shares: [f32; 4],
hdr_gains: [f32; 4],
hdr_light: [f32; 4],
}
@@ -60,7 +60,11 @@ pub(super) struct Stars {
}
impl Stars {
pub(super) fn new(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self {
/// The stars onto a frame of `format`, holding linear light when `linear`.
pub(super) fn new(
device: &wgpu::Device,
(format, linear): (wgpu::TextureFormat, bool),
) -> Self {
let triangles = star_triangles();
let vertices = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Stars vertex buffer"),
@@ -135,10 +139,7 @@ impl Stars {
module: &shader,
entry_point: Some("fs_stars"),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &[(
"HDR_WRITE_LIGHT",
f64::from(u8::from(format == super::LINEAR_FRAME)),
)],
constants: &super::linear_blend::frame_constants(format, linear),
..Default::default()
},
targets: &[Some(wgpu::ColorTargetState {
@@ -183,7 +184,7 @@ impl Stars {
clip_from_sky: clip_from_sky.to_cols_array_2d(),
color: [b; 4],
hdr_settings: globals.hdr_settings,
hdr_shares: globals.hdr_shares,
hdr_gains: globals.hdr_gains,
hdr_light: globals.hdr_light,
};
uploads.buffer(encoder, &self.uniform, 0, bytemuck::bytes_of(&uniform));
@@ -5,7 +5,7 @@ struct Stars {
// starBrightness in every channel (RenderSystem.setShaderColor).
color: vec4<f32>,
hdr_settings: vec4<f32>,
hdr_shares: vec4<f32>,
hdr_gains: vec4<f32>,
hdr_light: vec4<f32>,
}
@group(0) @binding(0) var<uniform> stars: Stars;
@@ -20,8 +20,8 @@ struct Stars {
@fragment fn fs_stars() -> @location(0) vec4<f32> {
let b = stars.color.a;
if HDR_WRITE_LIGHT && stars.hdr_light.w != 0.0 {
return vec4(hdr_written(stars.color.rgb * b, 0.175, stars.hdr_settings, stars.hdr_shares), b);
return vec4(hdr_written(stars.color.rgb * b, 0.175, stars.hdr_settings, stars.hdr_gains), b);
}
if HDR_WRITE_LIGHT { return vec4(stars.color.rgb * b, b); }
if HDR_WRITE_LIGHT { return vec4(frame_stored(stars.color).rgb * b, b); }
return vec4(stars.color.rgb * b, 0.175);
}
@@ -2,12 +2,12 @@
//! an adapter that can't present to the window gives way to another of its backend
//! (#13493).
use super::{
Renderer, check_layers, cull, describe_adapter, is_software, oit, startup_step, surface_format,
timing,
Renderer, check_layers, cull, describe_adapter, is_software, oit, pack_timing, startup_step,
surface_format, timing,
};
// Ray tracing on request, which the device needs from its creation (gfx.rs may not grow).
#[path = "ray_query.rs"]
mod ray_query;
pub(super) mod ray_query;
use std::sync::Arc;
use winit::window::Window;
@@ -135,6 +135,7 @@ pub(super) async fn open_adapter(
// atomics on images: their buffers and images. Filtering 32-bit float textures: shader
// packs' RGBA32F buffers, which GL filters and mipmaps. Count draws: GPU culling's (`gpu_culling`).
let features = timing::GpuTimer::features(adapter)
| pack_timing::PassTiming::features(adapter)
| cull::Cull::features(adapter)
| oit::features(adapter)
| ray_query::features(adapter)
@@ -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));
}
}
}
@@ -57,7 +57,7 @@ impl Visbuffer {
device: &wgpu::Device,
terrain_layout: &wgpu::BindGroupLayout,
flat: &Flat,
color: wgpu::TextureFormat,
(color, linear): (wgpu::TextureFormat, bool),
constants: &[(&str, f64)],
) -> Self {
let flat_layout = &flat.draw_layout;
@@ -65,13 +65,8 @@ impl Visbuffer {
let resolve_constants: Vec<(&str, f64)> = constants
.iter()
.copied()
.chain([
("VIS_BLOCKS_PER_PAGE", blocks_per_page as f64),
(
"HDR_WRITE_LIGHT",
f64::from(u8::from(color == super::LINEAR_FRAME)),
),
])
.chain([("VIS_BLOCKS_PER_PAGE", blocks_per_page as f64)])
.chain(super::linear_blend::frame_constants(color, linear))
.collect();
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Horizon visibility buffer"),
@@ -244,17 +239,17 @@ impl Visbuffer {
encoder: &'e mut wgpu::CommandEncoder,
depth: &wgpu::TextureView,
clear: bool,
timing: Option<&mut PassTiming>,
) -> wgpu::RenderPass<'e> {
mut timing: Option<&mut PassTiming>,
) -> super::pack_timing::CountedPass<'e> {
let (ids, _) = self.ids.as_ref().expect("fitted before the world pass");
let name = if clear {
"Visibility IDs"
} else {
"Visibility IDs after culling"
};
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
let pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Horizon visibility buffer"),
timestamp_writes: timing.and_then(|t| t.writes(name)),
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes(name)),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: ids,
resolve_target: None,
@@ -277,7 +272,8 @@ impl Visbuffer {
stencil_ops: None,
}),
..Default::default()
})
});
super::pack_timing::counted(timing, pass, super::pixel_count(depth), name)
}
/// Phase 1's solid calls into the IDs, as the normal passes draw them: the opaque
@@ -14,9 +14,10 @@ pub(super) struct Weather {
count: u32,
}
impl Weather {
/// The weather's pipeline onto a frame of `format`, holding linear light when `linear`.
pub fn new(
device: &wgpu::Device,
format: wgpu::TextureFormat,
(format, linear): (wgpu::TextureFormat, bool),
entities: &wgpu::BindGroupLayout,
shader: &wgpu::ShaderModule,
) -> Self {
@@ -62,7 +63,10 @@ impl Weather {
fragment: Some(wgpu::FragmentState {
module: shader,
entry_point: Some("fs_weather"),
compilation_options: Default::default(),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &super::linear_blend::frame_constants(format, linear),
..Default::default()
},
targets: &[Some(wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
@@ -225,11 +229,11 @@ struct HorizonWeatherOut {
";
/// The shader of a native pack's weather stage `source`.
fn native_source(source: &str, api: horizon_shader_api::Api) -> String {
fn native_source(source: &str, (api, linear): (horizon_shader_api::Api, bool)) -> String {
format!(
"{source}\n// Horizon's weather (entity.wgsl)\n{}{}{NATIVE_ENTRIES}",
super::wgsl_prefix::prefixed(&super::entity_shader(), "horizon_entity_"),
super::native_post::line_helpers(api)
super::native_post::line_helpers(api, linear)
)
}
@@ -246,7 +250,7 @@ pub(super) fn native_pipeline(
constants: &[(String, f64)],
) -> Result<wgpu::RenderPipeline, String> {
use horizon_shader_api::stages::GROUP;
let text = native_source(&stage.source, frame.api);
let text = native_source(&stage.source, (frame.api, frame.linear));
let module = naga::front::wgsl::parse_str(&text)
.map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text)))?;
let present = super::native_post::present_constants(&module, constants);
@@ -327,7 +331,7 @@ mod tests {
let source = StageKind::Weather.prelude(api)
+ "fn weather(input: WeatherInput) -> vec4<f32> {\n \
return input.color * vec4(input.light, input.position.x, f32(input.snow));\n}\n";
let text = native_source(&source, api);
let text = native_source(&source, (api, false));
let module = naga::front::wgsl::parse_str(&text)
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&text)));
naga::valid::Validator::new(
@@ -360,13 +360,13 @@ impl Post {
&mut self,
gain: bool,
settings: [f32; 4],
shares: [f32; 4],
gains: [f32; 4],
windows: [[f32; 4]; 2],
) {
// These uniforms do not change baked colour without gain. Keep compatibility
// history stable when only its final display settings change.
let key = Some(if gain {
(true, settings, shares, windows)
(true, settings, gains, windows)
} else {
(false, [0.0; 4], [0.0; 4], [[0.0; 4]; 2])
});
@@ -887,7 +887,7 @@ mod tests {
post.jitter([32, 32], true, Mat4::IDENTITY, DVec3::ZERO);
assert_eq!(post.taa_this_frame.jitter[2], 0.0);
eprintln!(
"TAA baked-light GPU checked gain, all settings/shares/windows and the first frame after a pack"
"TAA baked-light GPU checked gain, all settings/gains/windows and the first frame after a pack"
);
}
@@ -7,6 +7,11 @@
use super::Vertex;
use glam::Mat4;
/// world_primitives.wgsl after what it shares with the frame's other shaders (`frame_stored`).
pub(super) fn shader() -> String {
include_str!("../hdr_light.wgsl").to_owned() + include_str!("world_primitives.wgsl")
}
/// A frame's primitives, camera-relative, as the game tessellated them: quads of 4 vertices
/// from the shared atlas (plain colours on its white texel, text on its glyphs, beams on the
/// beacon beam sprite) and lines as `gizmos::camera_relative` gives them. `*_through` ones are
@@ -57,11 +62,12 @@ pub struct WorldPrimitives {
pub type Upload<'u> = &'u mut dyn FnMut(&wgpu::Buffer, &[u8]);
impl WorldPrimitives {
/// Pipelines drawing into `color_format` over a reversed Depth32Float depth.
pub fn new(device: &wgpu::Device, color_format: wgpu::TextureFormat) -> Self {
/// Pipelines drawing into `color_format` over a reversed Depth32Float depth, decoding their
/// colours where the frame holds linear light (`linear`).
pub fn new(device: &wgpu::Device, (color_format, linear): (wgpu::TextureFormat, bool)) -> Self {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("World primitives"),
source: wgpu::ShaderSource::Wgsl(include_str!("world_primitives.wgsl").into()),
source: wgpu::ShaderSource::Wgsl(shader().into()),
});
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("World primitives"),
@@ -150,7 +156,10 @@ impl WorldPrimitives {
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some(entry[1]),
compilation_options: Default::default(),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &super::linear_blend::frame_constants(color_format, linear),
..Default::default()
},
targets: &[Some(wgpu::ColorTargetState {
format: color_format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
@@ -468,7 +477,7 @@ mod tests {
lines_through: &lines_through,
};
let mut primitives = WorldPrimitives::new(&device, format);
let mut primitives = WorldPrimitives::new(&device, (format, false));
let atlas_view = atlas.create_view(&Default::default());
assert!(primitives.prepare(
&device,
@@ -41,7 +41,7 @@ struct QuadOut {
if in.wrap.y > 0.0 { uv.y = in.wrap.x + fract(in.uv.y) * in.wrap.y; }
let color = textureSample(atlas, atlas_sampler, uv) * in.color;
if color.a <= 0.0 { discard; }
return color;
return frame_stored(color);
}
struct LineOut {
@@ -73,5 +73,5 @@ struct LineOut {
}
@fragment fn fs_line(in: LineOut) -> @location(0) vec4<f32> {
return in.color;
return frame_stored(in.color);
}
@@ -3,7 +3,7 @@ pub(super) fn pipeline(
device: &wgpu::Device,
layout: Option<&wgpu::PipelineLayout>,
shader: &wgpu::ShaderModule,
format: wgpu::TextureFormat,
(format, linear): (wgpu::TextureFormat, bool),
) -> wgpu::RenderPipeline {
let attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint8x4,5=>Snorm8x4,6=>Float32x2];
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
@@ -34,7 +34,10 @@ pub(super) fn pipeline(
fragment: Some(wgpu::FragmentState {
module: shader,
entry_point: Some("fs_entity_unmarked"),
compilation_options: Default::default(),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &super::linear_blend::frame_constants(format, linear),
..Default::default()
},
targets: &[Some(wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
@@ -63,6 +66,8 @@ mod tests {
label: None,
source: wgpu::ShaderSource::Wgsl(
r#"
override HDR_WRITE_LIGHT: bool = false;
override FRAME_LINEAR: bool = false;
struct Out { @builtin(position) pos:vec4<f32>, @location(0) color:vec4<f32> }
@vertex fn vs_entity(@location(0) pos:vec3<f32>, @location(3) color:vec4<f32>)->Out {
return Out(vec4(pos,1.0),color);
@@ -72,7 +77,12 @@ struct Out { @builtin(position) pos:vec4<f32>, @location(0) color:vec4<f32> }
.into(),
),
});
let pipeline = pipeline(&device, None, &shader, wgpu::TextureFormat::Rgba8Unorm);
let pipeline = pipeline(
&device,
None,
&shader,
(wgpu::TextureFormat::Rgba8Unorm, false),
);
let texture = |format, usage| {
device.create_texture(&wgpu::TextureDescriptor {
label: None,
@@ -148,6 +148,8 @@ pub(super) struct Kit {
pub entity: wgpu::BindGroupLayout,
pub static_entity: wgpu::BindGroupLayout,
pub format: wgpu::TextureFormat,
/// The frame holds linear light (`linear_blend`).
pub linear: bool,
pub sprite_constants: [(&'static str, f64); 2],
/// Whether the display offers HDR output: the solid terrain is built lit by it too.
pub hdr_output: bool,
@@ -319,20 +321,19 @@ impl WorldWarps {
let buffered = [Some(vertices), Some(instances.clone())];
let pulled = [None, Some(instances)];
// As gfx.rs's: a float frame takes light where others take marks.
let write_light = (
"HDR_WRITE_LIGHT",
f64::from(u8::from(kit.format == super::LINEAR_FRAME)),
);
let [write_light, linear] = super::linear_blend::frame_constants(kit.format, kit.linear);
let lit = [
kit.sprite_constants[0],
kit.sprite_constants[1],
write_light,
linear,
];
let plain = [
kit.sprite_constants[0],
kit.sprite_constants[1],
("HDR_LIGHTING", 0.0),
write_light,
linear,
];
let depth = |write| wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
@@ -454,7 +455,7 @@ impl WorldWarps {
module: &entity,
entry_point: Some(look.fragment),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &[write_light],
constants: &[write_light, linear],
..Default::default()
},
targets: &[Some(wgpu::ColorTargetState {
@@ -64,7 +64,7 @@ fn warp_fragment(in: TerrainOut, front: bool) -> Fragment {
let fog = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w)) * globals.fog_color.a;
let mark = hdr_scene_alpha(mix(hdr_scene(in.scene.x, in.scene.y, hdr_facing()), globals.hdr_light.y, fog));
return vec4(hdr_written(finished.rgb, mark, globals.hdr_settings, globals.hdr_shares), finished.a);
return vec4(hdr_written(finished.rgb, mark, globals.hdr_settings, globals.hdr_gains), finished.a);
}
return finished;
return frame_stored(finished);
}
+14 -1
View File
@@ -824,6 +824,11 @@ pub struct Graphics {
pub overlay: Overlay,
/// World resolution as a percentage of the window's (50..=200).
pub resolution_scale: u32,
/// Dynamic Resolution: below 100%, `resolution_scale` is the floor, and the scale rises
/// toward 100% while the GPU keeps `target_fps` (`crate::dynamic_resolution`). Off by default.
pub dynamic_resolution: bool,
/// The frame rate Dynamic Resolution keeps (30..=240).
pub target_fps: u32,
/// FXAA over the world.
pub fxaa: bool,
/// TAA over the world (FXAA is off while it is on).
@@ -920,6 +925,8 @@ impl Default for Graphics {
workers: 0,
overlay: Overlay::Off,
resolution_scale: 100,
dynamic_resolution: false,
target_fps: 60,
fxaa: false,
taa: false,
upscaling: Upscaling::Native,
@@ -1081,6 +1088,8 @@ impl Graphics {
}
"workers" => g.workers = number(g.workers).min(64),
"resolutionScale" => g.resolution_scale = number(g.resolution_scale).clamp(50, 200),
"dynamicResolution" => g.dynamic_resolution = flag(g.dynamic_resolution),
"targetFps" => g.target_fps = number(g.target_fps).clamp(30, 240),
"fxaa" => g.fxaa = flag(g.fxaa),
"taa" => g.taa = flag(g.taa),
"upscaling" => g.upscaling = Upscaling::parse(value),
@@ -1135,7 +1144,7 @@ impl Graphics {
}
pub fn serialize(&self) -> String {
let mut text = format!(
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nentityCullingPrecheck:{}\nparticleCulling:{}\nleafCulling:{}\nopaqueIce:{}\nblockEntityCacheShulkerBoxes:{}\nblockEntityCacheSigns:{}\nblockEntityCacheDecoratedPots:{}\nblockEntityCacheBells:{}\nblockEntityCacheChests:{}\nblockEntityCacheBanners:{}\nblockEntityCacheCopperGolemStatues:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nupscalingSharpness:{}\nscreenshotQuality:{}\nframeGeneration:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiNits:{}\nhdrLighting:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\nbatchTranslucent:{}\nhdrSmoothGradients:{}\ngpuCulling:{}\n",
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nentityCullingPrecheck:{}\nparticleCulling:{}\nleafCulling:{}\nopaqueIce:{}\nblockEntityCacheShulkerBoxes:{}\nblockEntityCacheSigns:{}\nblockEntityCacheDecoratedPots:{}\nblockEntityCacheBells:{}\nblockEntityCacheChests:{}\nblockEntityCacheBanners:{}\nblockEntityCacheCopperGolemStatues:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\ndynamicResolution:{}\ntargetFps:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nupscalingSharpness:{}\nscreenshotQuality:{}\nframeGeneration:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiNits:{}\nhdrLighting:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\nbatchTranslucent:{}\nhdrSmoothGradients:{}\ngpuCulling:{}\n",
self.preset.name(),
self.lod_distance,
self.far_detail.name(),
@@ -1172,6 +1181,8 @@ impl Graphics {
Overlay::Graph => "graph",
},
self.resolution_scale,
bool_text(self.dynamic_resolution),
self.target_fps,
bool_text(self.fxaa),
bool_text(self.taa),
self.upscaling.name(),
@@ -1537,6 +1548,8 @@ mod tests {
workers: 6,
overlay: Overlay::Graph,
resolution_scale: 75,
dynamic_resolution: false,
target_fps: 60,
fxaa: true,
taa: true,
upscaling: Upscaling::Balanced,
@@ -100,6 +100,8 @@ pub enum H {
Workers,
Overlay,
ResolutionScale,
DynamicResolution,
TargetFps,
Antialiasing,
Upscaling,
UpscalingSharpness,
@@ -366,6 +368,16 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
High,
"World pixels per screen pixel. Below 100% is faster on the GPU.",
),
h(
H::DynamicResolution,
Medium,
"Raises Resolution Scale toward 100% while the frame rate allows it.",
),
h(
H::TargetFps,
Low,
"The frame rate Dynamic Resolution keeps.",
),
h(
H::Antialiasing,
Medium,
@@ -795,6 +807,7 @@ impl H {
}
H::Workers => Some(g.workers as f32 / max_workers() as f32),
H::ResolutionScale => Some((g.resolution_scale as f32 - 50.0) / 150.0),
H::TargetFps => Some((g.target_fps as f32 - 30.0) / 210.0),
H::UpscalingSharpness => Some(g.upscaling_sharpness as f32 / 100.0),
// Auto, then 10..480 by 10.
H::HdrPaperWhite => Some(crate::graphics::nits_position(g.hdr_paper_white)),
@@ -815,6 +828,7 @@ impl H {
H::UploadBudget => Some((UPLOAD_STEPS.len() - 1) as u32),
H::Workers => Some(max_workers()),
H::ResolutionScale => Some(30),
H::TargetFps => Some(42),
H::UpscalingSharpness => Some(20),
H::HdrPaperWhite | H::HdrUiBrightness => Some(crate::graphics::NITS_STEPS),
H::HdrPeak => Some(PEAK_POSITIONS),
@@ -839,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])),
@@ -914,6 +928,8 @@ impl H {
n => labelled("Worker Threads", n),
},
H::ResolutionScale => labelled_percent("Resolution Scale", g.resolution_scale),
H::DynamicResolution => labelled("Dynamic Resolution", on_off(g.dynamic_resolution)),
H::TargetFps => labelled("Target FPS", g.target_fps),
H::Antialiasing => labelled(
"Anti-aliasing",
match (g.fxaa, g.taa) {
@@ -978,6 +994,7 @@ impl H {
}
H::Workers => g.workers = (v * max_workers() as f32).round() as u32,
H::ResolutionScale => g.resolution_scale = (50.0 + (v * 30.0).round() * 5.0) as u32,
H::TargetFps => g.target_fps = (30.0 + (v * 42.0).round() * 5.0) as u32,
H::UpscalingSharpness => g.upscaling_sharpness = (v * 20.0).round() as u32 * 5,
H::HdrPaperWhite => g.hdr_paper_white = crate::graphics::nits_at(v),
H::HdrUiBrightness => g.hdr_ui_nits = crate::graphics::nits_at(v),
@@ -1037,6 +1054,7 @@ impl H {
H::Fog => g.fog = !g.fog,
H::Weather => g.weather = !g.weather,
H::AnimatedTextures => g.animated_textures = !g.animated_textures,
H::DynamicResolution => g.dynamic_resolution = !g.dynamic_resolution,
H::DitherCutoutMips => g.dither_cutout_mips = !g.dither_cutout_mips,
H::PresentMode => g.present_mode = step(&PresentMode::ALL, g.present_mode, back),
// Off, FXAA, TAA.
@@ -1187,6 +1205,7 @@ impl Item {
Item::Horizon(H::Fog) => g.fog,
Item::Horizon(H::Weather) => g.weather,
Item::Horizon(H::AnimatedTextures) => g.animated_textures,
Item::Horizon(H::DynamicResolution) => g.dynamic_resolution,
Item::Horizon(H::DitherCutoutMips) => g.dither_cutout_mips,
Item::Horizon(H::HdrLighting) => g.hdr_lighting,
Item::Horizon(H::BatteryMode) => g.battery,
@@ -1734,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));
@@ -2704,6 +2731,7 @@ mod tests {
H::UploadBudget,
H::Workers,
H::ResolutionScale,
H::TargetFps,
H::UpscalingSharpness,
H::HdrPaperWhite,
H::HdrPeak,
+50 -26
View File
@@ -20,15 +20,20 @@ struct Params {
black: f32,
// The HUD and menus' brightness as a share of paper white.
ui: f32,
// HDR lighting: what the strongest light multiplies a surface by, 1 for none.
// The compatibility frame's lift (`lit`; the float frame's writers have theirs from
// Hdr::draw_params). HDR lighting: what the strongest light multiplies a surface by, 1 for
// none.
scene: f32,
// Per tier, low to very bright (hdr.rs `Tier`): the share of the headroom
// Per tier, low to very bright (hdr.rs `COMPAT_SHARES`): the share of the headroom
// its fully lifted pixels get.
share: vec4<f32>,
// HDR calibration (hdr_calibration.rs): x its step (0 none, 1 black level,
// 2 peak, 3 paper white), y the slider in nits, z where its controls start,
// a share of the height.
calibration: vec4<f32>,
// The scene's peak in units of paper white where Horizon lifts the world (hdr.rs
// `SCENE_PEAK`), else 0: what the tone curve maps onto the display's peak (`tone_scale`).
scene_peak: f32,
};
@group(0) @binding(2) var<uniform> params: Params;
// Horizon's logo, 1 inside, 0 outside, for calibration's test pictures.
@@ -155,7 +160,7 @@ fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
}
fn lit(f: vec3<f32>, a: f32, shows: bool, eased: bool) -> Lit {
return hdr_mark_light(f, a, shows, eased, vec4(params.peak, params.scene, params.world, params.debug), params.share);
return hdr_mark_light(f, a, shows, eased, vec4(params.peak, params.scene, params.world, params.debug), (params.peak - 1.0) * params.share);
}
// Calibration: above its controls the test picture, straight in nits, past
@@ -251,33 +256,52 @@ fn display(light: vec3<f32>, pos: vec4<f32>) -> vec4<f32> {
return display(light, pos);
}
// The tone curve's knee: up to it light stays as it is. Where HDR lighting eased into the peak
// before the curve (`lit`), 60% of it, so bright textures keep their detail; at least paper white.
// Where the scene's peak is unknown (`tone_scale`), the curve's knee: up to it light stays as it
// is. Where HDR lighting eased into the peak before the curve (`lit`), 60% of it, so bright
// textures keep their detail; at least paper white.
fn tone_knee(headroom: f32) -> f32 {
return max(0.6 * headroom, 1.0);
}
// The tone curve on light whose brightest channel is `top`, in units of paper white `paper`
// nits: what to scale the light by, so it ends at most `headroom` (above 1). ITU-R BT.2390's
// EETF in PQ, without the black level lift (`display` does that): the same up to its knee, then
// a Hermite spline that reaches the peak level, at the source's peak. That peak is where the knee
// lands at `tone_knee`, PQ's 10 000 nits at most; light past it shows at the peak. The brightest
// channel sets the scale for all three, so light keeps its hue as it eases (lava stays orange)
// and no channel passes the peak.
fn tone_scale(top: f32, headroom: f32, paper: f32) -> f32 {
if top <= tone_knee(headroom) {
return 1.0;
}
// The tone curve on light whose brightest channel is `top`, in units of paper white `paper` nits:
// what to scale the light by, so it ends at most `headroom` (T). ITU-R BT.2390's EETF in PQ,
// without the black level lift (`display` does that): the same up to its knee, then a Hermite
// spline reaching T at the source's peak, past which light shows at T. The brightest channel sets
// the scale for all three, so light keeps its hue (lava stays orange) and no channel passes T.
//
// The source is the scene's peak S where Horizon lifts the world (`scene_peak`, hdr.rs
// `SCENE_PEAK`): the world's light is the same on every display, light past S is held at S, and
// where T reaches S that is all, so a display above S shows the world as one at S. Where the
// scene's range is unknown (`scene_peak` 0: a 0.2 pack's own light, HDR Lighting off), the
// source follows the display, as the 0.2 contract has it: the peak that puts the EETF's knee at
// `tone_knee`, PQ's 10 000 nits at most.
fn tone_scale(top: f32, scene_peak: f32, headroom: f32, paper: f32) -> f32 {
let peak = pq(vec3(headroom * paper)).x;
let source = min(3.0 * peak - 2.0 * pq(vec3(tone_knee(headroom) * paper)).x, 1.0);
let e = pq(vec3(top * paper)).x / source;
var source: f32;
var e: f32;
var held = 1.0;
if scene_peak > 0.0 {
held = min(1.0, scene_peak / max(top, 1e-6));
if scene_peak <= headroom {
return held;
}
source = pq(vec3(scene_peak * paper)).x;
e = pq(vec3(min(top, scene_peak) * paper)).x / source;
} else {
if top <= tone_knee(headroom) {
return 1.0;
}
source = min(3.0 * peak - 2.0 * pq(vec3(tone_knee(headroom) * paper)).x, 1.0);
e = pq(vec3(top * paper)).x / source;
}
let max_lum = peak / source;
let ks = 1.5 * max_lum - 0.5;
// At least 0: past 3 to 1 in PQ the knee would leave the source's range.
let ks = max(1.5 * max_lum - 0.5, 0.0);
if e >= 1.0 {
return headroom / top;
}
if e <= ks {
return 1.0;
return held;
}
let t = (e - ks) / (1.0 - ks);
let t2 = t * t;
@@ -287,16 +311,16 @@ fn tone_scale(top: f32, headroom: f32, paper: f32) -> f32 {
}
// Hdr::tonemap, after the post effects and before the camera overlays (docs/design/
// hdr-linear-light.md, `[tonemap]`): the light (`world` holds the frame) eased into the peak, in
// gamma, where scaling the light by s scales the stored colour by s^(1/2.2). Light up to the
// knee stays exactly as stored. With no headroom, a clip at white.
// hdr-linear-light.md, `[tonemap]`): the light (`world` holds the frame) fitted to the display, in
// gamma, where scaling the light by s scales the stored colour by s^(1/2.2). Light the curve
// leaves alone stays exactly as stored. With no headroom and no scene peak, a clip at white.
@fragment fn fs_tonemap(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
let g = textureLoad(world, vec2<i32>(pos.xy), 0).rgb;
if params.peak <= 1.0 {
if params.scene_peak == 0.0 && params.peak <= 1.0 {
return vec4(min(g, vec3(1.0)), 1.0);
}
let top = pow(max(max(g.r, max(g.g, g.b)), 0.0), 2.2);
let s = tone_scale(top, params.peak, params.paper * 80.0);
let s = tone_scale(top, params.scene_peak, params.peak, params.paper * 80.0);
return vec4(select(g * pow(s, 1.0 / 2.2), g, s == 1.0), 1.0);
}
@@ -331,7 +355,7 @@ fn layered(at: vec2<i32>) -> vec3<f32> {
}
// Screenshots keep midtones at their drawn brightness, with a shoulder starting at 0.6
// paper white in linear light. Unlike the display curve (whose knee is at least white),
// paper white in linear light. Unlike the display curve (which ends at the display's peak),
// this has an unbounded source range: k + (1-k)*(top-k)/(top+1-2*k) approaches SDR
// white smoothly, with slope 1 at k. One scale keeps the world's linear RGB ratios.
fn screenshot_world(w: vec3<f32>) -> vec3<f32> {
@@ -1,4 +1,22 @@
override HDR_WRITE_LIGHT: bool = false;
// The frame holds linear light: a shader API 0.2 pack's `[frame] blend = "linear"`.
override FRAME_LINEAR: bool = false;
// What the frame stores of `c`, a colour in vanilla's gamma: `c`, or its light (vanilla's 2.2
// power) where the frame holds linear light, so what blends over it mixes as light does.
fn frame_stored(c: vec4<f32>) -> vec4<f32> {
if FRAME_LINEAR {
return vec4(pow(max(c.rgb, vec3(0.0)), vec3(2.2)), c.a);
}
return c;
}
// `frame_stored` undone: the stored colour `c` in vanilla's gamma.
fn frame_gamma(c: vec4<f32>) -> vec4<f32> {
if FRAME_LINEAR {
return vec4(pow(max(c.rgb, vec3(0.0)), vec3(1.0 / 2.2)), c.a);
}
return c;
}
// The light of colour `f` under the world's mark `a` (the alpha the world left, hdr_mark) where
// the world `shows`, else the colour clipped at white; and whether a light source lifted it.
@@ -9,7 +27,8 @@ struct Lit {
lifted: bool,
};
fn hdr_mark_light(f: vec3<f32>, a: f32, shows: bool, eased: bool, settings: vec4<f32>, shares: vec4<f32>) -> Lit {
// `lifts`, per tier: how much its fully lifted pixels gain over their own light (their gain - 1).
fn hdr_mark_light(f: vec3<f32>, a: f32, shows: bool, eased: bool, settings: vec4<f32>, lifts: vec4<f32>) -> Lit {
var lifted = false;
// Light over 1 (a float frame's) shows only where the world does and carries
// no mark (alpha 1), up to the peak: light written as light. Marked pixels clip
@@ -42,7 +61,7 @@ fn hdr_mark_light(f: vec3<f32>, a: f32, shows: bool, eased: bool, settings: vec4
let lift = 1.0 - clamp((a - 0.175 * f32(band)) / 0.165, 0.0, 1.0);
if lift > 0.0 {
let top = max(light.r, max(light.g, light.b));
var gain = 1.0 + (settings.x - 1.0) * shares[tier] * lift;
var gain = 1.0 + lifts[tier] * lift;
if eased {
gain = min(gain, settings.x / max(top, 1e-4));
}
@@ -70,7 +89,8 @@ fn hdr_quantized_mark(mark: f32) -> f32 {
return round(saturate(mark) * 255.0) / 255.0;
}
fn hdr_written(color: vec3<f32>, mark: f32, settings: vec4<f32>, shares: vec4<f32>) -> vec3<f32> {
let shows = settings.z > 0.5 && settings.x > 1.0;
return pow(hdr_mark_light(max(color, vec3(0.0)), hdr_quantized_mark(mark), shows, false, settings, shares).light, vec3(1.0 / 2.2));
// The world's writers' light, from the tiers' `gains` (Hdr::draw_params): the same on every
// display; the tone curve fits it to the display afterwards (fs_tonemap).
fn hdr_written(color: vec3<f32>, mark: f32, settings: vec4<f32>, gains: vec4<f32>) -> vec3<f32> {
return pow(hdr_mark_light(max(color, vec3(0.0)), hdr_quantized_mark(mark), settings.z > 0.5, false, settings, gains - vec4(1.0)).light, vec3(1.0 / 2.2));
}
+9 -11
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 {
@@ -102,9 +102,7 @@ pub struct HudAtlas {
pub glint: (Vec<u8>, u32, u32),
/// `misc/enchanted_glint_armor`, the armour layers' `GlintSampler`.
pub glint_armor: (Vec<u8>, u32, u32),
/// A resource pack's own `core/text` shader for the GUI's text, translated;
/// none draws the text natively.
pub text_shader: Option<std::sync::Arc<crate::core_shader::CoreProgram>>,
pub text_shaders: crate::gfx::core_text::TextShaders,
/// A resource pack's own `core/rendertype_lines` shader for the block outline, translated;
/// none draws the outline natively.
pub lines_shader: Option<std::sync::Arc<crate::core_shader::CoreProgram>>,
@@ -390,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
@@ -981,7 +979,7 @@ impl HudAtlas {
font: crate::font::Font::metrics(root),
glint: (Vec::new(), 1, 1),
glint_armor: (Vec::new(), 1, 1),
text_shader: None,
text_shaders: Default::default(),
lines_shader: None,
sky_shader: None,
stars_shader: None,
@@ -1050,7 +1048,7 @@ impl HudAtlas {
glint: (vec![0; 4], 1, 1),
glint_armor: (vec![0; 4], 1, 1),
white,
text_shader: None,
text_shaders: Default::default(),
lines_shader: None,
sky_shader: None,
stars_shader: None,
@@ -1403,9 +1401,9 @@ impl HudAtlas {
edition.3 = pixels;
}
images.push(("horizon/white".into(), 4, 4, vec![255; 64]));
// The controller's art, unless a pack brought its own (walked above, as a mod's GUI
// textures are).
for image in crate::pad_glyph::images() {
// The controller's art and Horizon's mark, unless a pack brought its own (walked above, as a
// mod's GUI textures are).
for image in crate::pad_glyph::images().chain(crate::mark_sprite::image()) {
if !images.iter().any(|i| i.0 == image.0) {
images.push(image);
}
@@ -1581,7 +1579,7 @@ impl HudAtlas {
sprite_animations,
glint,
glint_armor,
text_shader: None,
text_shaders: Default::default(),
lines_shader: None,
sky_shader: None,
stars_shader: None,
@@ -396,6 +396,7 @@ fn coverage_uses_pack_texts_final_alpha_and_discard() {
let program = crate::core_shader::CoreProgram {
vertex: naga::front::wgsl::parse_str("@vertex fn main(@location(0) p: vec2<f32>) -> @builtin(position) vec4<f32> { return vec4(p.x / 2.0 - 1.0, 1.0 - p.y, 0.0, 1.0); }").unwrap(),
fragment: naga::front::wgsl::parse_str("struct Result { @location(0) color: vec4<f32> } @fragment fn main(@builtin(position) p: vec4<f32>) -> Result { if p.x > 2.0 { discard; } return Result(vec4(0.4, 0.2, 0.1, 0.5)); }").unwrap(),
other: Vec::new(),
};
gpu.hud.set_text_shader(&gpu.device, Some(&program));
assert!(gpu.hud.pack_text.is_some());
@@ -417,6 +418,7 @@ fn a_pack_text_shader_without_a_colour_output_is_dropped_not_wrapped() {
let program = crate::core_shader::CoreProgram {
vertex: naga::front::wgsl::parse_str("@vertex fn main(@location(0) p: vec2<f32>) -> @builtin(position) vec4<f32> { return vec4(p, 0.0, 1.0); }").unwrap(),
fragment: naga::front::wgsl::parse_str("@fragment fn main() -> @location(1) vec4<f32> { return vec4(1.0); }").unwrap(),
other: Vec::new(),
};
gpu.hud.set_text_shader(&gpu.device, Some(&program));
assert!(gpu.hud.pack_text.is_none());
@@ -76,9 +76,8 @@ pub(super) fn load_core(
}
}
/// The packs' core shaders the atlas carries (text, block outline, sky, stars).
pub(super) fn load_shaders(atlas: &mut super::HudAtlas, root: &Path, defaults: &Path) {
atlas.text_shader = load(root, defaults);
atlas.text_shaders.gui = load(root, defaults);
atlas.lines_shader = load_core(
root,
defaults,
@@ -89,6 +88,12 @@ pub(super) fn load_shaders(atlas: &mut super::HudAtlas, root: &Path, defaults: &
let sky = |name| load_core(root, defaults, name, &[], &crate::gfx::core_sky::GLUE);
atlas.sky_shader = sky("sky");
atlas.stars_shader = sky("stars");
let world = |defines: &[&str], glue| load_core(root, defaults, "text", defines, glue);
atlas.text_shaders.world = world(&[], &crate::gfx::core_text::WORLD_GLUE);
atlas.text_shaders.see_through = world(
&["IS_SEE_THROUGH"],
&crate::gfx::core_text::SEE_THROUGH_GLUE,
);
}
/// Each uniform block's offset in the one buffer (`core_shader::BLOCKS` order).
+5
View File
@@ -43,6 +43,7 @@ pub mod crash;
pub mod custom_sky;
pub mod datapack;
pub mod dynamic_light;
pub mod dynamic_resolution;
pub mod effects;
pub mod environment;
pub mod extras;
@@ -73,6 +74,7 @@ pub mod level_loading;
pub mod light;
pub mod lod;
pub mod logging;
pub mod mark_sprite;
pub mod menu_blur;
pub mod menu_style;
pub mod mesher;
@@ -101,6 +103,9 @@ pub mod pad_glyph;
pub mod paged_maps;
pub mod par;
pub mod particles;
#[cfg(target_os = "linux")]
pub mod perf_capture;
pub mod perf_gpu;
pub mod poi_sections;
pub mod polytone_post;
pub mod post_chain;
@@ -0,0 +1,11 @@
//! Horizon's mark (`docs/media/brand/mark-dark.svg`) on the text grid, for an inline text sprite.
/// The mark's name in the HUD atlas.
pub const SPRITE: &str = "horizon:gui/mark";
const PNG: &[u8] = include_bytes!("../../horizon-client/assets/horizon/textures/gui/mark.png");
/// The mark as the HUD atlas takes an image.
pub fn image() -> std::iter::Once<(String, u32, u32, Vec<u8>)> {
let (width, height, rgba) = crate::hud::decode_bytes(PNG).expect("Horizon's mark is PNG");
std::iter::once((SPRITE.to_owned(), width, height, rgba))
}
+58 -46
View File
@@ -21,9 +21,7 @@ use crate::{
models::catalog::BakedFace,
};
use horizon_assets::model::Direction as ModelDirection;
use horizon_core::{
biome::BiomeZoom, geometry::Direction, position::BlockPos, random::position_seed,
};
use horizon_core::{biome::BiomeZoom, geometry::Direction, position::BlockPos};
use horizon_optifine::ctm::connect::{Axes as CtmAxes, Face as CtmFace};
use horizon_world::{
block_render::FaceVisibility,
@@ -927,19 +925,7 @@ impl MeshTables {
|| (matches!(name, "sculk_sensor" | "calibrated_sculk_sensor")
&& props.get("sculk_sensor_phase").map(String::as_str) == Some("active"));
// OffsetType.XZ/XYZ with getMaxHorizontalOffset/getMaxVerticalOffset.
let offset = s.random_offset.then(|| {
let horizontal = if s.has_class("SpeleothemBlock") {
0.125
} else {
0.25
};
let vertical = match name {
"small_dripleaf" => 0.1,
"short_grass" | "fern" | "short_dry_grass" | "tall_dry_grass" => 0.2,
_ => 0.0,
};
(horizontal, vertical)
});
let offset = s.max_offset();
let mut face = [0u8; 6];
let mut cover = [0f32; 6];
// A server's visibility knows no state (`FaceVisibility::empty`):
@@ -1223,15 +1209,7 @@ impl MeshTables {
}
/// `OffsetType.XZ`/`XYZ`: a per-column jitter from `Mth.getSeed(x, 0, z)`.
fn offset(info: &Info, x: i32, z: i32) -> Option<[f64; 3]> {
let (horizontal, vertical) = info.offset?;
let seed = position_seed(BlockPos::new(x, 0, z));
let nibble = |shift: u32| ((seed >> shift) & 15) as f32 / 15.0;
let h = f64::from(horizontal);
Some([
((f64::from(nibble(0)) - 0.5) * 0.5).clamp(-h, h),
(f64::from(nibble(4)) - 1.0) * f64::from(vertical),
((f64::from(nibble(8)) - 0.5) * 0.5).clamp(-h, h),
])
Some(horizon_items::blocks::column_offset(info.offset?, x, z))
}
/// Border width: smooth lighting samples up to two blocks out along a face normal.
const B: i32 = 2;
@@ -2281,22 +2259,14 @@ impl Mesher<'_> {
translucent: face.translucent,
opaque: face.opaque,
};
// A tile keeps the quad's forced translucency; `<skip>` and `<default>` are its own.
let tile = |t: TileLayer| match t {
TileLayer::Layer {
layer,
translucent,
opaque,
} => TileLayer::Layer {
layer,
translucent: translucent || q.render_type.is_none() && q.material.force_translucent,
opaque,
},
TileLayer::Skip | TileLayer::Default => own,
};
let own_translucent = self.translucent(own);
let tile = |t: TileLayer| in_face_pass(t, own, own_translucent);
match pick {
Pick::Keep => self.quad(out, force_solid, own, corners.map(|c| c.0), tint),
Pick::Tile(t) => self.quad(out, force_solid, tile(t), corners.map(|c| c.0), tint),
Pick::Tile(t) => {
let v = corners.map(|c| c.0);
self.quad_as(out, force_solid, tile(t), v, false, tint);
}
Pick::Quarters(tiles) => {
let cuts = cuts(&corners);
for s in cuts.windows(2) {
@@ -2305,7 +2275,7 @@ impl Mesher<'_> {
.map(|(s, t)| bilinear(&corners, s, t));
let quarter = quarter_of(&piece);
let piece = piece.map(|c| c.0);
self.quad(out, force_solid, tile(tiles[quarter]), piece, tint);
self.quad_as(out, force_solid, tile(tiles[quarter]), piece, false, tint);
}
}
}
@@ -2318,12 +2288,7 @@ impl Mesher<'_> {
if count == 0 {
return;
}
let face_translucent = match pick {
Pick::Keep => self.translucent(own),
Pick::Tile(t) => self.translucent(tile(t)),
Pick::Quarters(tiles) => tiles.into_iter().any(|t| self.translucent(tile(t))),
Pick::Nothing => false,
};
let face_translucent = !matches!(pick, Pick::Nothing) && own_translucent;
// Overlay tiles face the standard way too (Continuity: "overlay textures always have the
// standard orientation"), whatever turns the model's own UVs (dirt's and sand's random
// rotations).
@@ -2472,6 +2437,21 @@ fn cuts(c: &[(TerrainVertex, [f32; 2]); 4]) -> Cuts {
}
out
}
/// Tile `t` as drawn over a face whose own sprite is `own`, in the face's pass
/// (`own_translucent`): CTM picks a texture, not a render layer (OptiFine draws a tile in the
/// block's). The tile's texels only drop the alpha test when all are opaque. A Conquest grass
/// blade tile with 7 partly clear texels of 1024 blended, and wrote the depth of its clear
/// rectangle, which Bliss fogs. `<skip>` and `<default>` are the face's own sprite.
fn in_face_pass(t: TileLayer, own: TileLayer, own_translucent: bool) -> TileLayer {
match t {
TileLayer::Layer { layer, opaque, .. } => TileLayer::Layer {
layer,
translucent: own_translucent,
opaque,
},
TileLayer::Skip | TileLayer::Default => own,
}
}
/// The UV a vanilla cube's face gives the block-local point `p` (`BlockElement`'s default UVs),
/// in sprite units: up is north on the top face, south on the bottom one, and up on the sides.
fn standard_uv(facing: horizon_assets::model::Direction, [x, y, z]: [f32; 3]) -> [f32; 2] {
@@ -2596,6 +2576,38 @@ mod quarter_tests {
);
}
#[test]
fn a_tile_draws_in_its_faces_pass() {
// Conquest's grass blade tile: a few partly clear texels, over an opaque face.
let blade = TileLayer::Layer {
layer: 5,
translucent: true,
opaque: false,
};
let face = |translucent| TileLayer::Layer {
layer: 1,
translucent,
opaque: !translucent,
};
let cut = TileLayer::Layer {
layer: 5,
translucent: false,
opaque: false,
};
assert_eq!(in_face_pass(blade, face(false), false), cut);
// Over stained glass the tile blends, as the face does.
let blend = TileLayer::Layer {
layer: 5,
translucent: true,
opaque: false,
};
assert_eq!(in_face_pass(blade, face(true), true), blend);
assert_eq!(
in_face_pass(TileLayer::Default, face(false), false),
face(false)
);
}
#[test]
fn overlays_take_the_cubes_own_uvs() {
use horizon_assets::model::Direction as D;
@@ -24,7 +24,7 @@ fn oit_cloud_color(in: Out, front: bool, fade: bool) -> vec4<f32> {
var color = oit_cloud_color(in, front, true);
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
color = vec4(hdr_written(color.rgb, hdr_scene_alpha(globals.hdr_light.y),
globals.hdr_settings, globals.hdr_shares), color.a);
globals.hdr_settings, globals.hdr_gains), color.a);
}
let seen = oit_sample_transmittance(vec2<i32>(in.clip.xy),
oit_normalize_depth(in.clip.z, in.clip.xy), 1.0 - color.a);
Loaded 100 of 983 files, more files were not shown because too many files have changed in this diff. Show more