Compare commits

..
Author SHA1 Message Date
rdbt 13be1a9e4f Merge pull request #10529 from atsauban/fix/launcher-narrow-layout-6362
fix(launcher): support responsive wrapping on narrow windows
2026-11-06 21:26:42 +00:00
rdbt 6a978d3bef Merge pull request #11051 from Delete228/native-dropped-item-contact
Native mods: expose dropped-item block contact
2026-11-06 21:26:37 +00:00
rdbt a8055893a2 Merge pull request #11033 from DUzzL/fix/10587-death-protection-item
Death protection: use the death_protection item and its effects, for players and mobs
2026-11-06 21:26:31 +00:00
rdbt ef78519011 Merge pull request #9952 from Ragbie/fix/iron-golem-toss
Iron golems toss by knockback resistance and keep their anger like vanilla
2026-11-06 21:26:25 +00:00
rdbt c30775263f Merge pull request #10197 from IanWorley/native-api/client-world-snapshots
Native mods: client world snapshots a mod reads on its worker threads
2026-11-06 21:26:18 +00:00
rdbt 04176c2939 Merge pull request #10133 from Ragbie/fix/mounted-attack-box
A mounted mob reaches from its vehicle's width, and a rider is hit from its seat up
2026-11-06 21:26:10 +00:00
rdbt 96505f501d Merge pull request #11113 from Detimes/hooks-bots
Native mods: helpers for server-side player bots (one movement tick, block collision boxes)
2026-11-06 21:26:04 +00:00
rdbt c932d4bec1 Merge pull request #10166 from hildlabs/remote-servers-ssh
Launcher: a server on another computer, run over SSH
2026-11-06 21:25:59 +00:00
rdbt c87e44bf63 Merge pull request #11062 from voidsipp/fix/round4-7-10178
Add local particle spawning for native client mods
2026-11-06 21:25:53 +00:00
rdbt d8d0d5e832 Merge pull request #10554 from ilguerro/perf/9996-startup
Parse entity-models.json once at startup
2026-11-06 21:25:47 +00:00
rdbt 79d2b975f5 Merge pull request #11264 from senjinthedragon/hand-translucent
Shader packs: gbuffers_hand_water for translucent held items
2026-11-06 21:25:41 +00:00
rdbt 0b9487e6e9 Merge pull request #11090 from Delete228/native-fluid-provider-upstream
Native mods: let existing block fluid helpers reach native-owned tanks
2026-11-06 21:25:36 +00:00
rdbt e32b03ea27 Merge pull request #11064 from horizon-rust/roadmap/proposal-issues
Roadmap: attach 22 proposal issues to their items, two new Later items
2026-11-06 21:25:29 +00:00
rdbt c4d8c79e2b Merge pull request #11076 from voidsipp/fix/round4-5-10444
Add native client sound parameter interception
2026-11-06 21:21:31 +00:00
rdbt 8cc05f114c Merge pull request #11196 from arslanarm/shaderpacks/sky-part-blends
Shader packs: the sky's parts blend as vanilla draws them
2026-11-06 21:21:25 +00:00
rdbt b80e816a76 Merge pull request #10532 from PzmuV1517/shaders/perf-background
Shader packs: a pack builds on a thread of its own while the game draws
2026-11-06 21:21:20 +00:00
rdbt 9b8b755e4c Merge pull request #11183 from rison1337/perf/dynamic-light-selection
Stream dynamic-light sources and bound nearest-light selection
2026-11-06 21:21:14 +00:00
rdbt 5692713279 Merge pull request #10556 from ilguerro/perf/9996-light
Light a section from its palette's light info, not its state ids
2026-11-06 21:21:08 +00:00
rdbt ff31439c92 Merge pull request #9993 from MSedgeMC/fix/prediction-snap
A refused block change that comes back inside the player snaps it out
2026-11-06 21:20:30 +00:00
rdbt 02641599e8 Merge pull request #11221 from AlfaRojo/fix/player-boarding-cooldown
A player boards a vehicle again right after leaving it
2026-11-06 21:20:24 +00:00
rdbt 12030689f8 Merge pull request #11237 from NikitaMGrimm/pack-sequence-clock
Shader packs: a set frame clock for repeatable captures (screenshot-sequence 1/3)
2026-11-06 21:20:19 +00:00
rdbt ce68709f1f Merge pull request #11281 from ilguerro/perf/mesh-row-masks
Reduce repeated work in section meshing
2026-11-06 21:20:13 +00:00
rdbt 3e5b2ef194 Merge pull request #11268 from DUzzL/fix/11242-hanging-entity-data
Apply item custom data and entity data to placed frames, paintings and armor stands
2026-11-06 21:20:07 +00:00
rdbt 14bb1416d1 Merge pull request #11016 from DUzzL/fix/10549-per-level-border
Keep one world border per level
2026-11-06 21:20:02 +00:00
rdbt 1f666cfec4 Merge pull request #11044 from Scholiboi/fix/remote-pearl-bubble-column
Pearls on a server's client hover in bubble columns as vanilla's do
2026-11-06 21:19:56 +00:00
rdbt 8b578746ea Merge pull request #11205 from arslanarm/shaderpacks/float-into-integer-buffers
Shader packs: a float output into an integer buffer no longer refuses its program
2026-11-06 21:19:50 +00:00
rdbt af55aad580 Merge pull request #11204 from arslanarm/shaderpacks/r11f-images
Shader packs: an R11F_G11F_B10F image is stored into and read back
2026-11-06 21:19:44 +00:00
rdbt b11e8a54b6 Merge pull request #10571 from Skr1zzzz/text-display-cull
Text displays: keep their glyphs, and skip the ones whose text is off screen
2026-11-06 21:19:38 +00:00
rdbt 0ef93c96b9 Merge pull request #11092 from rison1337/perf/cull-behind-camera-falling-blocks
Skip falling-block mesh preparation behind the camera
2026-11-06 21:19:33 +00:00
rdbt 5fca014076 Merge pull request #11227 from daMpoff/fix/launcher-instance-menu-width
Fix overflow in translated launcher instance menus
2026-11-06 21:19:27 +00:00
rdbt c1c985ce5b Merge pull request #10606 from Hiuoy/feat/entity-interact-specific
Native hooks: PlayerInteractEvent.EntityInteractSpecific, the point a player right-clicks on an entity
2026-11-06 21:19:22 +00:00
rdbt af24ac05bb Merge pull request #10564 from Alysia42/ghast-fireball-pickable
Arrows and ender pearls now redirect ghast fireballs instead of passing through them
2026-11-06 21:19:16 +00:00
rdbt 1a92fe7767 Merge pull request #11042 from Vaspyyy/launcher-updates-in-updates
Launcher: update status and Check now move from About to Updates
2026-11-06 21:19:10 +00:00
rdbt 079ed1a010 Merge pull request #11276 from apeirs/fix/mob-block-layers-cull
Mob block layers cull back faces like BlockModelRenderState
2026-11-06 21:19:05 +00:00
rdbt e55180e7dd Merge pull request #10018 from ilguerro/fix/player-death-stats
A player's death makes a vibration and counts toward Killed By
2026-11-06 21:18:59 +00:00
rdbt da4fb17df7 Merge pull request #11099 from Delete228/native-living-item-use-upstream
Native mods: item use after a mob passes interaction
2026-11-06 21:18:53 +00:00
rdbt 114e9920ac Merge pull request #11275 from TheMrZZ/fix/command-enchantment-map-validation
Validate command enchantment map entries before execution
2026-11-06 21:18:47 +00:00
rdbt ad2c0d2ecd Merge pull request #11355 from TheMrZZ/fix/vehicle-damage-command
Route generic damage commands to boats and minecarts
2026-11-06 21:18:41 +00:00
rdbt 976afe97e2 Merge pull request #11065 from voidsipp/fix/11038-server-property-numbers
Match Java numeric server properties
2026-11-06 21:18:34 +00:00
rdbt 7c58203441 Merge pull request #9668 from GabrielNichols/codex/recipes-hmod-server-fill
Keep .hmod Recipes searchable and placeable on the server
2026-11-06 21:18:28 +00:00
rdbt cb201c59e4 Merge pull request #10527 from Akirabane/feat/transfer-status
dedicated: transfer-status, the counts a proxy reads before it sends players
2026-11-06 21:18:22 +00:00
rdbt d22ecb0b52 Merge pull request #11299 from pehe1/fix/3109-airborne-knockback
Apply a guest jump before knockback at takeoff
2026-11-06 21:18:16 +00:00
rdbt 62075a5b32 Merge pull request #11070 from Delete228/native-block-clone-stack-upstream
Native mods: select the stack returned by Pick Block
2026-11-06 21:18:10 +00:00
rdbt aea2bdadb1 Merge pull request #11114 from Delete228/content-item-block-alias-upstream
Native content: place a block through an item alias
2026-11-06 21:18:04 +00:00
rdbt f59035948c Merge pull request #10177 from anthra123x/fix/ultrawide-fullscreen-taskbar
fix(client): prevent taskbar overlap and window shift in borderless fullscreen
2026-11-06 21:17:58 +00:00
rdbt d05f889fe1 Merge pull request #10129 from xFedeT/xfedet-launcher-transparent-topbar
Make launcher titlebar match Glass UI
2026-11-06 21:17:19 +00:00
rdbt c96af77eb6 Merge pull request #11144 from xxaxio/fix/11082-linux-audio-buffer
audio: support HORIZON_AUDIO_BUFFER override for Linux buffer underruns
2026-11-06 21:17:13 +00:00
rdbt d4df5dbf59 Merge pull request #10572 from GIGABAIT93/feat/launch-api-content
Content API: --launch-api says what content a build loads, and docs/api/content.md (#10562)
2026-11-06 21:16:35 +00:00
rdbt 66e6b23fb8 Merge pull request #11243 from AlfaRojo/fix/lead-hold-old-rotation
The lead held in first person turns with the mouse, without jitter
2026-11-06 21:16:29 +00:00
rdbt 918a4c8434 Merge pull request #11247 from Joansitoh/fix/11178-wither-skull-save
Keep wither skulls in flight through a chunk save
2026-11-06 21:16:23 +00:00
rdbt a3c540e8c0 Merge pull request #11255 from AlfaRojo/fix/screen-over-playing-releases-keys
A book, sign editor or prompt over the world releases the held buttons
2026-11-06 21:16:17 +00:00
rdbt e243cbac06 Merge pull request #11046 from voidsipp/fix/round4-2-10408
Honor remapped function keys from XKB
2026-11-06 21:16:11 +00:00
rdbt 4c5f37de8d Merge pull request #11199 from arslanarm/shaderpacks/noise-texture-resolution
Shader packs: noiseTextureResolution sizes the default noisetex
2026-11-06 21:16:05 +00:00
rdbt ba7cdd4d8c Merge pull request #11188 from TheMrZZ/fix/entity-scores-predicate
Evaluate named entity_scores predicates for bound holders
2026-11-06 21:15:58 +00:00
rdbt b61394f396 Merge pull request #11206 from arslanarm/shaderpacks/runtime-array-length
Shader packs: the length of a buffer block's runtime array reads back
2026-11-06 21:15:53 +00:00
rdbt a13866fff3 Merge pull request #10537 from arslanarm/shaderpacks/end-boss-fog-sky
The dragon fight's world fog leaves out the sky, as vanilla does
2026-11-06 21:15:46 +00:00
rdbt f9302c0736 Merge pull request #11023 from ilguerro/fix/9776-item-vibrations
Casting and reeling a fishing rod and throwing an eye of ender make the vibrations vanilla's do
2026-11-06 21:15:40 +00:00
rdbt 44e4124142 Merge pull request #10005 from UMCEKO/fix/projectile-owner-any-entity
Projectiles owned by any non-player entity need mob_griefing
2026-11-06 21:15:34 +00:00
rdbt dc040a9dd7 Merge pull request #11127 from Delete228/discarded-mob-lookup-upstream
Hide discarded mobs from immediate lookup and passenger saves
2026-11-06 21:15:29 +00:00
rdbt d54d736546 Merge pull request #11030 from ilguerro/fix/illager-target-memory
Merge remote-tracking branch 'origin/main' into fix/illager-target-memory
2026-11-06 21:15:23 +00:00
rdbt e4928503ac Merge pull request #11224 from TheMrZZ/fix/text-floating-zero
Canonicalize floating zero when parsing text tags
2026-11-06 21:15:17 +00:00
rdbt 2a83a524fc Merge pull request #11343 from TheMrZZ/fix/tnt-live-data-adoption
Adopt live TNT command data without replacing its identity
2026-11-06 21:15:11 +00:00
rdbt cfd68c41cf Merge pull request #11155 from TheMrZZ/fix/h125-mob-name-predicates
Fix live mob custom-name component predicates
2026-11-06 21:15:04 +00:00
rdbt 2c149ab9e6 Merge pull request #10096 from yahya-nusseibeh/fix/rcon-one-guess
RCON: one password guess per connection
2026-11-06 21:14:59 +00:00
rdbt aebabfb5c6 Merge pull request #11139 from TheMrZZ/fix/pillager-trader-saved-inventory
Preserve Pillager and WanderingTrader saved inventories
2026-11-06 21:14:53 +00:00
rdbt 90a09f5b25 Merge pull request #11346 from AlfaRojo/fix/creeper-explosion-radius
A creeper blows with its ExplosionRadius
2026-11-06 21:14:48 +00:00
rdbt ea2986d57b Merge pull request #11223 from AlfaRojo/fix/release-use-before-move
An arrow leaves from where the player was when the bow was released
2026-11-06 21:14:42 +00:00
rdbt 7093191256 Merge pull request #11043 from voidsipp/fix/11034-protocol-identifiers
Validate resource identifiers when reading packets
2026-11-06 21:14:36 +00:00
rdbt d774839f1f Merge pull request #11202 from arslanarm/shaderpacks/compute-shadow-reads
Shader packs: a compute stage reading a shadow buffer turns the shadow pass on
2026-11-06 21:14:30 +00:00
rdbt 6cfb046532 Merge pull request #11203 from arslanarm/shaderpacks/celestial-alpha-blend
Shader packs: the sun and moon leave their own alpha, as vanilla's celestial blend
2026-11-06 21:14:24 +00:00
rdbt fb0866ef3f Merge pull request #11278 from TheMrZZ/fix/h035-loot-mine-pot-components
Preserve pot decorations in loot mine results
2026-11-06 21:14:18 +00:00
rdbt eec2eb3b32 Merge pull request #11067 from voidsipp/fix/11039-nbt-path-reader
Match NBT path quoted escapes and index tokens
2026-11-06 21:14:12 +00:00
rdbt 4e8ed32e62 Merge pull request #10173 from MSedgeMC/fix/10071-auto-update-enabled
launcher: an automatic update keeps a disabled mod off
2026-11-06 21:14:06 +00:00
rdbt 8f55a6c245 Merge pull request #10542 from TheMrZZ/fix/player-default-effect-amplifier
Omit the default amplifier from saved player effects
2026-11-06 21:14:01 +00:00
rdbt 13664f3799 Merge pull request #11157 from TheMrZZ/fix/terminal-execute-fork-errors
Honor fork state for terminal execute condition errors
2026-11-06 21:13:55 +00:00
rdbt 1fb80a5d79 Merge pull request #11354 from MSedgeMC/fix/explosion-item-damage
fix: a blast takes its damage off a dropped item's health
2026-11-06 21:13:49 +00:00
rdbt 3d8521b429 Merge pull request #11209 from TheMrZZ/fix/inline-slot-range-admission
Reject unknown inline slot-range names before execution
2026-11-06 21:13:42 +00:00
rdbt 6181c89ed6 Merge pull request #11246 from TheMrZZ/fix/item-reference-uuid-codec
Decode Item Owner and Thrower with numeric UUID streams
2026-11-06 21:13:37 +00:00
rdbt 2018af984c Merge pull request #11284 from TheMrZZ/fix/mob-command-equipment-slots
Respect mob body-slot admission and saddle commands
2026-11-06 21:13:31 +00:00
rdbt f31d16a2c6 Merge pull request #11201 from TheMrZZ/fix/h154-unless-function-fallthrough
Fix unless-function continuation after fallthrough
2026-11-06 21:13:25 +00:00
rdbt c7d5881fdc Merge pull request #10996 from ilguerro/fix/sleeping-mob-push
A sleeping villager, fox or player isn't pushed by the mobs that walk into it
2026-11-06 21:13:19 +00:00
rdbt c815a0f81c Merge pull request #11162 from TheMrZZ/fix/modern-sign-literal-quotes
Preserve literal quotation characters in modern sign messages
2026-11-06 21:13:13 +00:00
rdbt 08c3b12345 Merge pull request #11032 from ilguerro/fix/piglin-admired-drop
Merge remote-tracking branch 'origin/main' into fix/piglin-admired-drop
2026-11-06 21:13:07 +00:00
rdbt 0925060e6f Merge pull request #11079 from horizon-rust/platforms/intel-mac-native-mods
Native mods on Intel Macs: the launcher brings Rust for x86_64 macOS
2026-11-06 21:13:01 +00:00
rdbt 08f1c4f6c6 Merge pull request #11244 from Gabbotampo/fix/11222-negative-holder-ids
Item components: refuse negative holder ids and holder set sizes
2026-11-06 21:12:56 +00:00
rdbt 838375d4c2 Merge pull request #11363 from Skr1zzzz/older-servers-unknown-types
Older servers: an entity or block entity type the pinned version lacks is dropped, not renumbered
2026-11-06 21:12:50 +00:00
rdbt 8bb2525e31 Merge pull request #11009 from ilguerro/fix/trade-display-drop-chance
A villager's held-up trade item never drops when it dies
2026-11-06 21:12:44 +00:00
rdbt ef6be13a1c Merge pull request #11056 from TheMrZZ/fix/interaction-saved-defaults
Save default Interaction dimensions and response
2026-11-06 21:12:39 +00:00
rdbt 014ad04d1c Merge pull request #11179 from TheMrZZ/fix/h113-command-damage-vibration
Emit mob command damage vibrations before returning
2026-11-06 21:12:33 +00:00
rdbt f2ec4e9c01 Merge pull request #11171 from TheMrZZ/fix/arrow-owner-command-nbt
Expose loaded arrow Owner references in command NBT
2026-11-06 21:12:27 +00:00
rdbt 0d840d54e3 Merge pull request #10219 from TheMrZZ/fix/selector-unknown-criterion
Reject missing advancement selector criteria
2026-11-06 21:12:21 +00:00
rdbt 43528d198c Merge pull request #10476 from TheMrZZ/fix/h220-macro-source-type
fix: report actual macro argument NBT type
2026-11-06 21:12:15 +00:00
rdbt 3d92e52a83 Merge pull request #10565 from TheMrZZ/fix/entity-nbt-boolean-byte
Match entity NBT boolean loads to numeric byte conversion
2026-11-06 21:12:09 +00:00
rdbt 0ef3df34d6 Merge pull request #10373 from xxaxio/fix/10317-narrate-incompatible-server
multiplayer: narrate incompatible server in server list
2026-11-06 21:12:02 +00:00
rdbt e4872aeba7 Merge pull request #11071 from TheMrZZ/fix/tnt-saved-default-power
Omit stale default power from saved TNT
2026-11-06 21:11:56 +00:00
rdbt d08f9ce9d5 Merge pull request #11108 from TheMrZZ/fix/animal-stale-love-cause
Remove stale Animal LoveCause when no reference is modeled
2026-11-06 21:11:51 +00:00
rdbt 162dd10147 Merge pull request #11305 from ilguerro/test/widen-harness-inputs
Widen four parity harnesses' inputs
2026-11-06 21:11:45 +00:00
rdbt 5919e28e0a Merge pull request #11341 from MSedgeMC/test/explosion-edge-ring
test: pin the bed explosion harness's blast edge to the item's own age
2026-11-06 21:11:08 +00:00
rdbt 8985543344 Merge pull request #10042 from NikitaMGrimm/hdr-note-serve-0-1-until-step-5
HDR design note: serve shader API 0.1 until 0.2 is on by default
2026-11-06 21:11:02 +00:00
rdbt 29f5c3504f Merge pull request #10540 from M0-kii/fix/changed-file-paths
Keep exact changed paths and rename sources in tooling checks
2026-11-06 21:10:57 +00:00
rdbt 06c3ba2173 Merge pull request #10019 from IsT3RiK/traduction-fr
Launcher French: translate the strings left in English, vous throughout
2026-11-06 21:10:51 +00:00
rdbt 57dcec2e2d Merge pull request #11173 from SamsVT/thai-translation
Launcher: rewrite the Thai translation (th.json)
2026-11-06 21:10:46 +00:00
rdbt a3b0891fc2 Merge pull request #11254 from MSedgeMC/fix/ghast-still-timeout
A happy ghast keeps its still timeout across a save
2026-11-06 21:10:40 +00:00
rdbt 68d5e2ec6c Merge pull request #11057 from DUzzL/fix/11018-marker-common-nbt
Save common Entity fields for registered Markers
2026-11-06 21:10:33 +00:00
rdbt 909a8123d0 Merge pull request #10546 from TheMrZZ/fix/h074-happy-ghast-ambient-position
Fix HappyGhast ambient sound position
2026-11-06 21:10:27 +00:00
rdbt da3a5aff7e Merge pull request #11265 from NikitaMGrimm/still-crystal-phase
HORIZON_STILL: end crystals start their spin from their position
2026-11-06 21:10:21 +00:00
rdbt e1d2186ee1 Merge pull request #11142 from voidsipp/fix/ci-shared-parity-invocation
Let dolphin and spell parity harnesses use runner defaults
2026-11-06 21:10:16 +00:00
rdbt e500989785 Merge pull request #11262 from MSedgeMC/test/enchant-gate
enchanting: the stack's damage and existing enchantments at the table
2026-11-06 21:10:10 +00:00
rdbt 57ad8718c3 Merge pull request #11198 from MSedgeMC/test/creative-removal
test: a creative player's break leaves the block, but no drops or statistic
2026-11-06 21:10:03 +00:00
rdbt c12fe63d33 Merge pull request #11109 from Detimes/native-refresh-commands
Native mods: native_refresh_commands rebuilds the players' command trees at run time
2026-11-06 21:09:57 +00:00
rdbt 71e18e8c0a Merge pull request #11054 from TaquitoSuabe/fix/haswell-vulkan-fallback
gfx: skip broken Haswell Vulkan driver (hasvk) and fall back to OpenGL
2026-11-06 21:09:51 +00:00
rdbt 880b690d6c Merge pull request #10086 from horizon-rust/pvp-safe/sneak-input
Multiplayer: sneaking for a use is the input of the last tick, as vanilla's isShiftKeyDown
2026-11-06 21:09:45 +00:00
rdbt d55b7f8594 Merge pull request #10551 from ilguerro/fix/9759-golem-peaceful
Peaceful drops a mob's player target and a golem's anger
2026-11-06 21:09:39 +00:00
rdbt b849bc3dab Merge pull request #11007 from ilguerro/fix/use-swing-attack-reset
A use that swings the hand resets the attack charge
2026-11-06 21:09:33 +00:00
rdbt 85d5ce37f0 Merge pull request #10908 from DUzzL/fix/10550-trident-hurts-players
A thrown trident hurts other players on a served world
2026-11-06 21:09:26 +00:00
rdbt 5395de1ab4 Merge pull request #10967 from ilguerro/fix/mob-block-fall-sound
A mob's damaging fall plays the fall sound of the block it lands on
2026-11-06 21:09:19 +00:00
rdbt 24459551e6 Merge pull request #11100 from Delete228/native-held-break-slot-upstream
Native mods: choose the break slot for held-item durability
2026-11-06 21:09:12 +00:00
rdbt 848ffed94d Merge pull request #11136 from DUzzL/fix/11088-slime-split-delay
Split slimes, magma cubes and sulfur cubes when the body is removed
2026-11-06 21:09:06 +00:00
rdbt 66ee01b737 Merge pull request #11143 from Delete228/frozen-sprint-tick-boundary-upstream
Restore the frozen tick decision before ending a sprint
2026-11-06 21:09:01 +00:00
rdbt 7227485ca2 Merge pull request #11211 from AlfaRojo/fix/elytra-glide-saved
A player saved while gliding comes back gliding
2026-11-06 21:08:55 +00:00
rdbt d914334acc Merge pull request #11226 from Horizon-is-vibe-coded/main
fix(checkerboard): clamp negative scale before shift to avoid pathological shift distance
2026-11-06 21:08:49 +00:00
rdbt 07b8f97138 Merge pull request #11229 from AlfaRojo/fix/own-model-freeze-shiver
The player's own third-person model shivers when fully frozen
2026-11-06 21:08:44 +00:00
rdbt 5e8d557b17 Merge pull request #11292 from Ragbie/fix/snbt-trailing-comma
SNBT: a compound, list or array may end with a comma, as vanilla's grammar allows
2026-11-06 21:08:38 +00:00
rdbt 07a162cb30 Merge pull request #11272 from echo-ilovech3ss/fix/tp-passenger-followups
game: cover passenger rotation through /tp in rider test
2026-11-06 21:08:32 +00:00
rdbt 8146a586da Merge pull request #11279 from DUzzL/fix/11271-smithing-trim
Draw trims on the smithing preview armour
2026-11-06 21:08:27 +00:00
rdbt dc8aba8bee Merge pull request #11259 from DUzzL/fix/11252-autosprint-sign
Keep a latched sprint toggle out of the text-field Ctrl shortcuts
2026-11-06 21:08:21 +00:00
rdbt f1f352044f Merge pull request #11348 from d3xc0/native-default-state
Native mods: a block default state by name (native_default_state)
2026-11-06 21:08:15 +00:00
rdbt 83feefe860 Merge pull request #11368 from Vaspyyy/docs/claimable-issues-query
AGENTS.md: one query for the issues free to claim
2026-11-06 21:08:09 +00:00
rdbt 2132e6126f Merge pull request #11313 from DUzzL/fix/11290-beacon-beam-view
Cull back faces of beacon beams
2026-11-06 21:08:03 +00:00
rdbt 97b36f8e1b Merge pull request #11362 from AlfaRojo/fix/mob-protection-order
A mob's Protection runs after Resistance and not against a sonic boom
2026-11-06 21:07:58 +00:00
rdbt 521c26d6ca Merge pull request #11357 from AlfaRojo/fix/bone-meal-spreader-particles
Bone meal on a spreading block sparks over the block above it
2026-11-06 21:07:52 +00:00
rdbt 218624479d Merge pull request #11352 from AlfaRojo/fix/damage-taken-stat-threshold
Damage Taken is not awarded for a huge hit
2026-11-06 21:07:46 +00:00
rdbt 4dc1282f65 Merge pull request #9953 from Hiuoy/feat/9921-port-pack-events
Port pack: `events`, a mod's NeoForge listeners against the hooks
2026-11-06 21:04:33 +00:00
rdbt b351455eae Merge pull request #10245 from Hiuoy/feat/anvil-update
Native hooks: AnvilUpdateEvent, a mod's say in an anvil's result
2026-11-06 21:04:28 +00:00
rdbt 217e87e8f3 Merge pull request #10240 from MSedgeMC/fix/item-cull-frame
Dropped items: cull at the box the frame draws, not the tick's
2026-11-06 21:04:22 +00:00
rdbt e3356386d0 Merge pull request #11153 from DUzzL/fix/11055-cushion-sit-remote
Seat other players on a cushion they sit on
2026-11-06 21:04:15 +00:00
rdbt bf17581664 Merge pull request #11152 from DUzzL/fix/11074-dismount-hint
Show the dismount hint when the player starts riding
2026-11-06 21:04:09 +00:00
rdbt 02e62fbedf Merge pull request #11182 from DUzzL/fix/11137-golem-target-mounts
Keep golems off zombie horses, camel husks and zombie nautilus
2026-11-06 21:04:03 +00:00
rdbt e0f9934254 Merge pull request #11240 from DUzzL/fix/10287-shulker-piston-align
Snap piston-pushed shulkers to the block centre
2026-11-06 21:03:57 +00:00
rdbt 76bda62793 Merge pull request #11169 from DUzzL/fix/11130-mod-worldgen-feature-dir
Read mod features from worldgen/feature/, 26.3's folder
2026-11-06 21:03:51 +00:00
rdbt b12c89bf04 Merge pull request #11129 from Delete228/native-container-provisioning-upstream
Avoid inventory notifications when provisioning empty native slots
2026-11-06 21:03:45 +00:00
rdbt 345e6efdc9 Merge pull request #11151 from voidsipp/fix/gateway-passenger-player-ticks
Run the mounted player tick in the gateway parity scene
2026-11-06 21:03:38 +00:00
rdbt 77ec563892 Merge pull request #11112 from ilguerro/tooling/use-item-on-interact-start
use_item_on's oracle posts item_interact_start when a use starts
2026-11-06 21:03:32 +00:00
rdbt 5bd038ed99 Merge pull request #11156 from xxaxio/docs/11154-index-design-notes
docs/README.md: index live mod reloading design note
2026-11-06 21:03:26 +00:00
rdbt 06e2041d43 Merge pull request #11149 from xxaxio/docs/11145-platform-scope
docs/native-client: update stale platform scope note
2026-11-06 21:03:19 +00:00
rdbt 2643b0e246 Merge pull request #11134 from xxaxio/docs/stale-notes-cleanup
docs/server-levels: remove stale End credits for server players note
2026-11-06 21:03:13 +00:00
rdbt f235e95518 Merge pull request #11131 from xxaxio/fix/11120-test-harness-args-windows
tests/test_harness_args: match system path separator for default work dir
2026-11-06 21:03:07 +00:00
rdbt 1e68c9db3d Merge pull request #11010 from Hiuoy/docs/neoforge-labels-tooltip
Native hook catalog: tooltip_render_pre is RenderTooltipEvent.Pre; the Next hooks have all landed
2026-11-06 21:03:00 +00:00
VaspyyyandClaude Opus 5.5 ee88e8c870 AGENTS.md: one query for the issues free to claim
Agents had to open issues one by one to see if they were taken, and the rule
did not mention Discord claims (Kira's `in progress` label). Give them the
ready-made search and name both claim signals.

Fixes #11367

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 21:48:25 +01:00
rdbtCVSandClaude Opus 5.5 18eb428ffb Drop the agent marker from a code comment
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:35:38 +00:00
rdbtCVSandClaude Opus 5.5 cd99e67d1c Native client doc: macOS is a target too
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:35:11 +00:00
Kevin RamirezandClaude Fable 5.1 71622aabb3 A mob's Protection runs after Resistance and not against a sonic boom (#11308)
LivingEntity.getDamageAfterMagicAbsorb applies Resistance, returns 0 when
nothing is left, returns the damage as is for #bypasses_enchantments (the
warden's sonic_boom), and only then the enchantment protection. Horizon's
Mob::hurt_server ran the protection first and never read the tag, so a
zombie in a Protection IV chestplate kept 1.6 of a 10-point sonic boom.

DamageKind::bypasses_enchantments (SonicBoom, or a NamedDamage with the
new BYPASSES_ENCHANTMENTS tag) gates the protection, which now follows
Resistance as vanilla orders them.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-06 14:33:17 -06:00
rdbtCVSandClaude Opus 5.5 9884fc3af1 Keep #[cfg(test)] on the test module the merge of main took it from
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:30:45 +00:00
rdbtCVSandClaude Opus 5.5 1e137ff7f3 Launcher: DRAWN is only read off Linux now
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:19:06 +00:00
MSedgeMC bae2b17a0d fix: a blast takes its damage off a dropped item's health
`ServerExplosion.hurtEntities` damages every entity in reach, and
`ItemEntity.hurtServer` writes the result back:

    this.health = (int)((float)this.health - amount);
    if (this.health <= 0) { this.getItem().onDestroyed(this); this.discard(); }

Horizon's item branch only asked whether the damage reached 5 and destroyed the
item, so an item that lived through a blast kept all 5 points of health: a
power-5 bed blast that leaves vanilla's item at 1 left Horizon's at 5.

The damage is now subtracted in f32 and truncated after the subtraction, as the
cast does, and the item is destroyed at zero health as before. The nether star
stays immune through `ItemStack.canBeHurtBy`'s explosion exception, and a blast
whose source is an entity with `mobGriefing` off never reaches the branch
(`items_ignore`).
2026-11-06 20:15:40 +00:00
MSedgeMC 6f5be3bbf8 test: the blast edge ring reads the survivor's health too
A dropped item starts with 5 health and `ItemEntity.hurtServer` takes the
blast's damage off it, so a survivor's `Health` says how much of the blast it
took. The pinned compound the ring already reads carries it, so the compared
line grows one fact:

  data push -0.03 -0.00 -0.05 health 1 keep 0.9800

Red on main: Horizon reports `health 5` where vanilla reports `1`.
2026-11-06 20:15:40 +00:00
Florian ERNSTandCodex 627617ee6b Route damage commands to actual boat and minecart damage
Co-Authored-By: Codex <[email protected]>
2026-11-06 21:15:20 +01:00
Kevin RamirezandClaude Fable 5.1 9343b3c357 Bone meal on a spreading block sparks over the block above it (#11310)
BoneMealItem.addGrowthParticles places a NEIGHBOR_SPREADER's happy
villager sparks at BonemealableBlock.getParticlePos, which is the block
above for grass, nylium, netherrack and moss; a GROWER keeps the block
itself, rooted dirt and mangrove leaves the one below. Horizon used the
bone-mealed block for spreaders too, a block lower than vanilla.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-06 14:13:58 -06:00
rdbtCVSandClaude Opus 5.5 b85a07d423 Merge origin/main into PR #10129
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:11:11 +00:00
rdbtCVSandClaude Opus 5.5 9b788418c3 Merge origin/main into PR #10219
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:11:08 +00:00
rdbtCVSandClaude Opus 5.5 19eff5eb79 Merge origin/main into PR #10532
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:11:07 +00:00
rdbtCVSandClaude Opus 5.5 af5731d305 Merge origin/main into PR #9993
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:10:13 +00:00
Kevin RamirezandClaude Fable 5.1 53c13c0cd9 combat: no JDK method named in the damage_taken docs (the ledger reads them as citations)
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-06 14:09:37 -06:00
rdbtCVSandClaude Opus 5.5 33cd3992bf Merge origin/main into PR #10019
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:06:12 +00:00
Kevin RamirezandClaude Fable 5.1 a419cc37c7 Damage Taken is not awarded for a huge hit (#11339)
Player.actuallyHurt awards DAMAGE_TAKEN as Math.round(dmg * 10.0F) only
while dmg < 3.4028235E37F. Horizon awarded every hurt with Rust's round,
so /damage @s 3.4e38 added a saturated i32::MAX where vanilla adds
nothing. A damage_taken_award helper applies the threshold and
jmath::round_f32 (Math.round(float): ties up, saturating).

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-06 14:06:03 -06:00
Skr1zzzzandClaude Opus 5.5 ec534edc6c Older servers: an entity or block entity type the pinned version lacks is dropped, not renumbered
A modded 1.21.11 server's own entity type (Sit's sit:entity_sit is 157) kept its number, which in 26.3 is
the zombie villager: every sitting player showed one under them. Chunks already dropped such block entities;
their updates now go too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 23:05:51 +03:00
rdbtCVS 8c5025d3d9 Merge origin/main into PR #10476
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	tools/command_exec_corpus.py
#	tools/test_command_exec_parity.py
2026-11-06 20:00:41 +00:00
rdbtCVS d6113ce7e5 Merge origin/main into PR #10018
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	tools/lan_e2e.py
2026-11-06 19:59:36 +00:00
rdbtCVS 1c389c3758 Merge origin/main into PR #10606
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/tests/mod_api_hooks.rs
2026-11-06 19:59:35 +00:00
rdbtCVS 8d5e529b46 Merge origin/main into PR #10005
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
2026-11-06 19:59:01 +00:00
rdbtCVS 6f82bf74b1 Merge origin/main into PR #10173
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	launcher/src/browser/horizon/auto/tests.rs
2026-11-06 19:59:00 +00:00
Kevin RamirezandClaude Fable 5.1 e7c082ee6f A creeper blows with its ExplosionRadius (#11332)
Creeper.readAdditionalSaveData reads ExplosionRadius as a byte (default
3), addAdditionalSaveData writes it back, and explodeCreeper blows with
it, twice it when powered. Horizon used a fixed 3 or 6 and never read the
key, so a summoned creeper with ExplosionRadius:12b blew with 3.

Extra.explosion_radius holds it (3 from Mob::new), entity_nbt reads any
numeric tag narrowed to a byte and writes the byte, and creeper_tick
scales it by the powered multiplier.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-06 13:56:10 -06:00
Florian ERNSTandCodex 5efccc59f0 Adopt live TNT command data without replacing its identity
Co-Authored-By: Codex <[email protected]>
2026-11-06 20:53:04 +01:00
MSedgeMC cdf61801bb test: pin the bed explosion harness's blast edge to the item's own age
The survivor the harness gained at the edge of the blast is read through the
item's own tick counter. A push on an item decays every tick
(`Entity.getAirDrag`, 0.98), and the two servers do not advance the same number
of game ticks between the same commands: at three script points of one case the
vanilla survivor was 31, 33 and 48 ticks old and Horizon's 38, 41 and 75, so a
`Motion` read there measures the pacing rather than the blast.

The edge item's `Age` is now read before the blast, and its whole NBT (`Age`,
`Pos` and `Motion` from one snapshot) at two pinned ages after it. The case
compares the push carried back over the ticks since the blast, to two decimals
because a tick of slack between the blast and the read is worth 2%, and the
decay the two snapshots show per tick, to four, which is `getAirDrag`'s 0.98.
Both agree with vanilla's in all twelve cases.
2026-11-06 19:50:58 +00:00
f7190cff67 Widen four parity harnesses' inputs
Cover all block states, registered enchantments, lore styles and
representative Lunge tiers using the existing vanilla oracles.

Co-Authored-By: pi coding agent (DeepSeek-V4.1-Flash) <[email protected]>
Co-Authored-By: Codex (gpt-6.1-sol) <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>

Claude-Session: https://claude.ai/code/session_01WZzq8KX3vDMZy53y1vXqeT
2026-11-06 20:32:02 +01:00
DUzzL 02896862e8 Cull back faces of beacon beams 2026-11-06 20:29:32 +01:00
Pedro CavalcanteandCodex 87761cd5b0 Apply a guest jump before subsequent combat packets
Co-Authored-By: Codex <[email protected]>
2026-11-06 16:14:13 -03:00
Arthur Black ff80bb806c Native mods: a block default state by name (native_default_state) 2026-11-06 11:08:00 -08:00
Evrim CicekandClaude Opus 5.5 d41641f85d SNBT: a compound, list or array may end with a comma
SnbtGrammar accepts one trailing comma before the closing brace or
bracket; a comma alone and two in a row stay errors. Both readers take it:
horizon-world's parse_snbt (NBT arguments, item components) and the text
component reader (tellraw and other component arguments).

Fixes #11285

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 22:06:09 +03:00
Florian ERNSTandCodex 9bc728f303 Respect mob body-slot admission and saddle commands
Co-Authored-By: Codex <[email protected]>
2026-11-06 19:46:24 +01:00
TheMrZZandCodex gpt-6.1-sol 58fc786742 Preserve pot decorations in loot mine results
Record the actual block entity or its existing pure save projection in a local Pending, then attach existing component copies to the unchanged block drops. Keep source blocks and loot replacement policy unchanged.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-06 19:22:40 +01:00
Florian ERNSTandCodex 3d2be9b1bd Validate command enchantment map entries against active definitions
Co-Authored-By: Codex <[email protected]>
2026-11-06 19:12:28 +01:00
DUzzL 258dee9c8d Generate the trim texture of the smithing preview's result 2026-11-06 19:12:15 +01:00
apeirs 24036af2fb Mob block layers cull back faces like BlockModelRenderState
A mooshroom's mushrooms, an iron golem's poppy, a snow golem's pumpkin and a
copper golem's antenna block are submitted through BlockModelRenderState.submit,
whose block-item sheets (ITEM_CUTOUT, ITEM_TRANSLUCENT) cull back faces. Only the
enderman's carried block culled, so the coplanar faces of the other layers' cross
models z-fought as the mob moved (#5301).

The cull now goes by each quad's winding, as the GPU's does, rather than the baked
facing, which is the nearest axis and 45 degrees off for a cross model's planes.

Fixes #5301
2026-11-06 19:02:50 +01:00
65ee18174a Reduce repeated work in section meshing
Precompute row and direction masks, speed adjacent palette reads, and bypass
random model selection for a single unweighted variant.

Co-Authored-By: Codex (gpt-6.1-terra) <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WZzq8KX3vDMZy53y1vXqeT
2026-11-06 18:53:25 +01:00
DUzzL 10e24b4f32 Apply an item's custom data and entity data to a placed frame, painting or armor stand 2026-11-06 18:43:23 +01:00
echo-ilovech3ss 5303bf09af game: cover passenger rotation through /tp in rider test
Root cause: #9335 follow-ups from #8733 left passenger rotation after /tp covered only by parity corpora (tools/passenger_teleport_corpus.py), which cannot run without vanilla data. The Game-level rider test exercised offset carry (sub-item 1) but not the rider keeping its angle to the vehicle through absolute and relative turns (sub-item 2).

What changed: extend teleport_rider_tests::a_vehicle_carries_its_riders_and_a_rider_leaves_its_vehicle with a second horse/villager pair: absolute turn 0->40 leaves rider at 70 (30-degree offset kept), relative turn to 50 shifts rider 70->80, and facing (turn_riders=false) leaves rider at 80. No runtime change; the teleport/passenger writers already implement Entity.teleportSameDimension/calculatePassengerTransition via vehicle_teleport::passenger_rotation.

Vanilla reference: 26.3 Entity.calculatePassengerTransition, PositionMoveRotation.calculateAbsolute, Entity.setXRot (pitch clamp).

Refs #9335
2026-11-06 23:08:34 +05:30
MSedgeMC e524b6a114 test: an enchanted or damaged stack at the enchanting table
tools/test_enchanting_parity.py always built a fresh ItemStack for its offer
corpus, so EnchantmentMenu.slotsChanged's ItemStack.isEnchantable gate (the
enchantable component and an empty minecraft:enchantments) was never
exercised, and the oracle skipped the gate entirely. Fixture lines now carry
damage and a pre-existing enchantments entry; the oracle applies the gate and
the probe builds the same stack. The default corpus is unchanged and
--no-components reproduces it.

68992/68992 enchanting cases match.
2026-11-06 17:22:55 +00:00
DUzzL 533dd084ae Keep a latched sprint or sneak toggle out of the edit shortcuts' modifiers 2026-11-06 18:11:35 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 f1fac7cc6f HORIZON_STILL: end crystals start their spin from their position
A crystal's spin and bob start from a random draw. Which crystal gets which
draw, and a server's entity ids that seed a client's copies, follow the order
chunks load in, so crystals stood at different angles in each launch of a
frozen scene. Under HORIZON_STILL the start now comes from the position; the
draw is still made, and normal play is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 18:05:51 +01:00
SenjinandClaude Opus 5.5 7b3be8d0b9 Shader packs: gbuffers_hand_water for translucent held items
The hand's quads from a translucent sheet (stained glass, ice...) go after the opaque ones in
its buffer and draw after deferred with gbuffers_hand_water (or its fallbacks: gbuffers_hand,
textured_lit, textured, basic), blended, as translucent terrain does; through gbuffers_hand
they came out solid.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 18:04:31 +01:00
Kevin RamirezandClaude Fable 5.1 408f38d62a tests: the key-release fixture stubs screen_over_playing, which mapped_key_event asks now
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-06 10:52:52 -06:00
Kevin RamirezandClaude Fable 5.1 e68a0653a0 A book, sign editor or prompt over the world releases the held buttons (#9729)
Gui.setScreen runs KeyMapping.releaseAll for every screen and
Minecraft.tick skips handleKeybinds while one is up, books, the sign
editor, dialogs and prompts included. Horizon keeps Screen::Playing under
those, and its release-all (track_toggle_screens) and the client input
gate (remote_handle_input) only knew the server dialog, while the book's
and sign's mouse arms eat both edges of a click: a dig held into a book
went on after the book closed, until the next click.

gameplay_has_keyboard now takes "a screen is over the world"
(screen_over_playing) in place of the dialog alone, and remote_handle_input
is not ready while one is up.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-06 10:46:37 -06:00
Florian ERNSTandCodex a8201731a1 Decode Item Owner and Thrower through numeric UUID streams
Co-Authored-By: Codex <[email protected]>
2026-11-06 17:27:10 +01:00
Kevin RamirezandClaude Fable 5.1 ff4fad66c9 The lead held in first person turns with the mouse, no jitter (#10276)
LocalPlayer.getRopeHoldPosition eases the lead's hand end from the view
to yRotO/xRotO by half the partial tick. Entity.turn moves yRotO and xRotO
by the mouse's delta too, and AbstractBoat.clampRotation moves yRotO with
the clamp, so only what the tick turned (a boat's deltaRotation) eases in.
Horizon's rope_rot_old was a once-a-tick snapshot the turn never moved:
while the mouse moved, the hand end lagged by half the turn since the
tick began and snapped back at the next tick, 20 times a second.

turn_player now moves rope_rot_old through the same Rotation::turn as the
view, and both boat clamps (the turn's and the tick's) move it with the
view. A test pins that a mouse turn leaves no gap, a boat turn eases, and
xRotO clamps at 90.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-06 10:22:24 -06:00
MSedgeMC a13fa8c13c port_map: HappyGhast's two save methods
`HappyGhast.addAdditionalSaveData` writes `still_timeout`, the timer
`still_tick` keeps in `counter`; `readAdditionalSaveData` reads it back and
a save without it is 0.
2026-11-06 16:17:03 +00:00
MSedgeMC 5c86d98a9b test: a happy ghast's still timeout survives the save
0, 7 and 10 through `mob_to_nbt` and `mob_from_nbt`: the Int vanilla writes,
the timer back on the mob, and `on_still_timeout` following it. Without the
writer the saved tag has no `still_timeout` (the test fails on `left: None,
right: Some(0)`).
2026-11-06 16:17:03 +00:00
MSedgeMC 9004c6c8d6 fix: a happy ghast keeps its still timeout in the world save
`HappyGhast.addAdditionalSaveData` writes `still_timeout`, the
`serverStillTimeout` behind `staysStill`/`isOnStillTimeout`, and
`readAdditionalSaveData` reads it back:

    protected void addAdditionalSaveData(ValueOutput out) {
        super.addAdditionalSaveData(out);
        out.putInt("still_timeout", this.serverStillTimeout);
    }

Horizon keeps that timer in `Mob.extra.counter` (`happy_ghast::still_tick`
counts it down after the first 60 ticks, `player_above` refreshes it to 10)
but neither `mob_to_nbt` nor `mob_from_nbt` touched it, so a ghast saved
while it was held still came back with 0. For the ticks vanilla restores, a
dropped item resting on its back had no box to rest on
(`EntityGetter.getEntityCollisions` takes a happy ghast only while
`isOnStillTimeout`), and a rider had no control (`getControllingPassenger`
asks `isOnStillTimeout`).
2026-11-06 16:17:03 +00:00
GabbotampoandClaude Opus 5.5 5fe1090401 Item components: refuse negative holder ids and holder set sizes
ByteBufCodecs.holder in vanilla 26.3 reads a VarInt and, when it isn't 0,
looks the entry up with `byIdOrThrow(id - 1)`, which no negative id passes.
skip.rs's `holder` accepted any nonzero id, so a slot whose kinetic_weapon
sound holder was negative was kept as a raw component, and hashing it for an
inventory click computed `id - 1` on i32::MIN: a panic with overflow checks
on, a wrapped id otherwise.

ByteBufCodecs.holderSet reads the size plus one and builds
`new ArrayList(size)`, which refuses a negative size. `holder_set` computed
`n - 1` before checking the sign, the same overflow for i32::MIN.

Both readers now refuse a negative value with a protocol error, so every
component that reads a holder or a holder set gets the same boundary.
Valid direct and registry holders read as before (their vanilla hashes in
hash/raw.rs's tests are unchanged).

Fixes #11222

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 17:07:33 +01:00
daMpoffandOpenAI Codex 0ab59f64d4 Fix overflow in translated launcher instance menus
Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-06 19:02:25 +03:00
Kevin RamirezandClaude Fable 5.1 b0d94ed2c0 The player's own third-person model shivers when fully frozen (#11217)
LivingEntityRenderer.isShaking is isFullyFrozen (DATA_TICKS_FROZEN at or
over 140), and setupRotations wobbles the body while it is. Mobs and
other players already shiver, but the window's own model, rebuilt each
tick by tick_player_model, never got the player's frozen ticks, and the
window ignored the DATA_TICKS_FROZEN its server sends it, so a player in
powder snow stood still in third person.

The own model takes ticks_frozen from the player's body, and own entity
data index 7 sets it on the window's player as LocalPlayer reads it.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-06 10:01:55 -06:00
Horizon-is-vibe-coded a4c8dc296d fix(checkerboard): clamp negative scale before shift
Guard against negative scale values producing excessive shift
distances in CheckerboardBiomeSource::noise_biome by clamping
scale to a non-negative lower bound before adding the +2 offset.

This keeps the shift distance within a sane range and avoids
surprising behavior for out-of-range runtime scale inputs.
2026-11-06 17:00:42 +01:00
Florian ERNSTandCodex 1d800a7655 Canonicalize floating zero when parsing text tags
Co-Authored-By: Codex <[email protected]>
2026-11-06 16:57:32 +01:00
DUzzL 776bb4f4d5 Snap pistons-pushed shulkers to the block centre (Shulker.setPos) 2026-11-06 16:53:06 +01:00
Kevin RamirezandClaude Fable 5.1 9fb818013c An arrow leaves from where the player was when the bow was released (#8423)
ServerGamePacketListenerImpl handles a client's packets in order: the
RELEASE_USE_ITEM a client sends from handleKeybinds comes before the move
LocalPlayer.tick sends in the same client tick, so the server shoots from
the position of the move it handled the tick before, one tick of strafe
behind the camera. Hub::process_inbox applies moves at once and hands a
PlayerAction to the game for after the pass; it held a queued move behind
a dig, UseItem, UseItemOn and Interact, not behind the release, the drops
or the stab. The move ran first, and the arrow left a tick ahead of the
camera: strafing left, it seemed to come from the left instead of the
right.

Every PlayerAction now holds the queued move for the next tick. A test
drains a release followed by a move and checks the game gets the release
alone with the guest's position and known movement unchanged.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-06 09:53:03 -06:00
Kevin RamirezandClaude Fable 5.1 efc682f297 A player boards a vehicle again right after leaving it (#11158)
Entity.removePassenger sets the leaving passenger's boardingCooldown to
60, but Player.removeVehicle overrides Entity.removeVehicle and zeroes it
right after super.removeVehicle(), so a player never waits to board again;
only mobs do. #8733 (from #8586, which missed that override) gave the host
and every guest the 60 ticks, checked by may_board before boarding a boat,
cart, cushion, ghast, llama, mount or nautilus: the delay the issue
reports.

The player's cooldown goes: the boarding_cooldown and riding_at_tick
fields of Game and Guest, their swap for a guest's turn, the per-tick
count-down and may_board at its eight call sites. Each interaction still
refuses a sneaking player, the other half of Entity.canRide. Mobs keep
extra.boarding_cooldown.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-06 09:44:13 -06:00
Nikita Maximilian GrimmandClaude Opus 5.5 0bd8f4c89c Shader packs: the scripted clock's step in decimal milliseconds
A 60 fps clock steps 16.666667 ms a frame, which a whole number of
milliseconds can't give. FrameTiming::nth takes the step as f64.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 16:28:13 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 e173055d5d Shader packs: a scripted clock that comes before STILL's ticks
PackInputs::frame_timing gives the frame number, the seconds since frame 1
and the seconds since the frame before. Iris-format packs take it for
frameCounter, frameTimeCounter and frameTime and smooth wetness,
eyeBrightnessSmooth and centerDepthSmooth by its delta; custom uniforms
start over on its frame 1 and carry their state after. Horizon packs take
it for time, frame_index and delta_time, ease by it, and do not treat a
slow frame as a gap. Nothing sets it yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 16:28:13 +01:00
Florian ERNSTandCodex 5b44880bfb Reject unknown inline slot-range names before execution
Co-Authored-By: Codex <[email protected]>
2026-11-06 16:26:05 +01:00
Kevin RamirezandClaude Fable 5.1 b2a6c25a6e A player saved while gliding comes back gliding (#11193)
LivingEntity.addAdditionalSaveData writes FallFlying as isFallFlying(), and
readAdditionalSaveData sets shared flag 7 from it, so a player who saves
mid-glide resumes the glide on the next load. Horizon wrote a fixed
FallFlying of 0 and never read it back: the player came back standing in
mid-air.

Level now keeps fall_flying, read from any numeric FallFlying tag (as
getBooleanOr does) and written from the guest's glide; playerdata's store
and apply carry it between the Guest and the file. The server's next
sync_player_state sends the 0x80 shared flag to the client, and
updateFallFlying's port ends the glide if it can't go on.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-06 09:24:52 -06:00
TheMrZZandCodex gpt-6.1-sol 942e212ca7 Fix unless-function continuation after fallthrough
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-06 16:20:21 +01:00
JoansitohandGrok <grok-4.7> e380e88edb Keep wither skulls in flight through a chunk save
Entity.shouldBeSaved is true for every projectile in 26.3, and
WitherSkull adds `dangerous` on top of the hurting projectile's
fields. Horizon listed WitherSkull as not saved, so a skull still in
the air when its chunk unloaded was gone when the chunk came back,
and a blue skull came back as nothing.

Write and read the skull the same way as the fireballs: `dangerous`
as a byte (absent means a black skull), and no `Fire` since
WitherSkull.shouldBurn is false. The fire rule now keys on the
fireballs proper (is_item_fireball) instead of "not blast-immune",
which would have lit a saved skull.

Co-Authored-By: Grok <grok-4.7> <[email protected]>
2026-11-06 16:18:52 +01:00
Florian ERNSTandCodex 5201202a30 Evaluate named entity_scores predicates for bound holders
Co-Authored-By: Codex <[email protected]>
2026-11-06 15:59:55 +01:00
rison1337andCodex GPT-6 f5c1c04245 Stream dynamic light sources and bound nearest-light selection
Avoid owning held-item names and intermediate lists on every frame.
Keep stable nearest-16 lighting output and document FPS and image checks.

Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-06 19:48:30 +05:00
TheMrZZandCodex gpt-6.1-sol fcb378d2dd Emit mob command damage vibrations before returning
Post the existing damage event for accepted health-changing mob command hits so a same-tick teleport cannot move its origin. Preserve the existing duplicate marker and reinforcement handling; pin the real command/listener route against Java.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-06 15:42:35 +01:00
MSedgeMC 4062a09766 test: mine a block in creative too, where nothing drops
tools/test_block_removal_parity.py only ever mined as a survival player:
every mine case ran ServerPlayerGameMode.destroyBlock in SURVIVAL, so the
creative branch (Player.preventsBlockDrops, no mined statistic, no tool
wear) was never compared even though game.rs::break_block_as_player splits
on it. Half the mine cases now run in creative, with a tool that can destroy
blocks in creative (a sword and a mace cannot, which the creative-mining
predicate corpus already owns).

VanillaDispenserOracle's Miner gains a creative flag (isCreative and
hasInfiniteMaterials) and the real CREATIVE game mode; the game's dispense
fixture sets the same mode for the case. --survival-only reproduces the
corpus as it was before this change.

4000/4000 cases match on the new corpus.
2026-11-06 14:29:14 +00:00
Florian ERNSTandCodex 48791d642e Expose loaded arrow Owner references in command NBT
Co-Authored-By: Codex <[email protected]>
2026-11-06 15:26:21 +01:00
DUzzL 7f87be741b Keep golems off zombie horses, camel husks and zombie nautilus 2026-11-06 15:22:15 +01:00
Arslan ArmanulyandClaude Opus 5.5 5a44d6dd7f Shader packs: the sky's parts blend as vanilla draws them
gbuffers_skybasic drew every part of the sky with the translucent blend.
Vanilla 26.3 draws the sky disc and the dark disc with RenderPipelines.SKY
(ColorTargetState.DEFAULT, no blending), the sunset fan translucent and
the stars with RenderPipelines.STARS (BlendFunction.OVERLAY: colour
SRC_ALPHA ONE, alpha ONE ZERO). Iris 1.11.7 drew them so with probe packs
of ours: the sky disc kept exactly what the program wrote, (0.3, 0.6,
0.9, 0.25) in buffers of four formats, and the stars added 0.25 x 0.5 to
the disc's 1.0 and left their own alpha 0.5. Without a
blend.gbuffers_skybasic of the pack's own, the disc and the void, and
the stars, now draw with pipelines of the same program that blend so,
keeping a blend.gbuffers_skybasic.<buffer> the pack sets (Iris blended
the disc into that buffer only).

Part of #10490

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 19:20:28 +05:00
Arslan ArmanulyandClaude Opus 5.5 54c4a07e61 Shader packs: a float output into an integer buffer no longer refuses its program
A program writing a float vec4 into an integer colortex (RGBA32UI...) was
refused: wgpu has no float output into an integer target, so the whole
program, and every buffer it writes, was lost. Iris 1.11.7 drew such a
program with a probe pack of ours: its other buffers kept what it wrote,
and the integer buffer read the floats' bits back (GL leaves that value
undefined; these are NVIDIA 615.78.08's). The output now writes the
float's bits as the buffer's integer type.

Part of #10490

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 19:19:41 +05:00
Arslan ArmanulyandClaude Opus 5.5 1fd49b9082 Shader packs: noiseTextureResolution sizes the default noisetex
Horizon always made its default noise texture 256 wide. Iris 1.11.7
made it the size a pack's `const int noiseTextureResolution` gives, when
declared in a fragment shader (composite, deferred, gbuffers, final, or
shadow.fsh with no shadow pass), and ignored it in a vertex shader, in
probe packs of ours. The setting is now read like a colortex format.

Part of #10490

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 19:19:10 +05:00
Arslan ArmanulyandClaude Opus 5.5 892f24b15d Shader packs: the length of a buffer block's runtime array reads back
`.length()` of a runtime-sized array in a named buffer block made naga's
validator refuse the program: its GLSL frontend loads the array and takes
the length of the loaded value. After parsing, the length is now taken of
the array's pointer. Iris 1.11.7 ran such a program in a probe pack of
ours and read the buffer's length (bufferObject size / stride).

Part of #10490

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 19:19:00 +05:00
Arslan ArmanulyandClaude Opus 5.5 dde8c502ca Shader packs: a compute stage reading a shadow buffer turns the shadow pass on
Horizon decided whether to run the shadow pass from the samplers of the
programs' vertex and fragment stages only. A pack whose only reader of
shadowtex0 or shadowcolor0 was a compute stage (prepare.csh alone, or a
composite.csh beside a composite.fsh that reads none) got an empty map
(1.0), while Iris 1.11.7 drew it for probe packs of ours (0.3956 depth,
0.251 shadowcolor0 at our noon scene). Compute stages now count as the
other stages do.

Part of #10490

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 19:18:41 +05:00
Arslan ArmanulyandClaude Opus 5.5 b31e63adc8 Shader packs: an R11F_G11F_B10F image is stored into and read back
Horizon made an R11F_G11F_B10F custom image as RGBA16F, then refused
every program that declared it layout(r11f_g11f_b10f), since the shader's
storage format no longer matched the texture. Iris 1.11.7 stored into such
an image and read it back, with a probe pack of ours: (0.25, 1.5, 3.0)
read back as written, alpha 1, also filtered between texels. The image is
now Rg11b10Ufloat where the device stores into that format; elsewhere it
stays the RGBA16F stand-in, and the shader's image declares that format.

Part of #10490

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 19:18:33 +05:00
Arslan ArmanulyandClaude Opus 5.5 e4bb7bf7ae Shader packs: the sun and moon leave their own alpha, as vanilla's celestial blend
The default blend of gbuffers_skytextured added the sun's alpha over the
sky's (One, OneMinusSrcAlpha). Vanilla 26.3 draws the sun and moon with
RenderPipelines.CELESTIAL, BlendFunction.OVERLAY: colour (SRC_ALPHA, ONE),
alpha (ONE, ZERO). Iris 1.11.7 left the sun's own alpha (2.0) in an
RGBA32F buffer the sky had written 1.0 to, with a probe pack of ours;
Horizon left 1.0.

Part of #10490

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 19:18:12 +05:00
DUzzL f7b204a231 Read mod features from worldgen/feature/, 26.3's folder 2026-11-06 15:17:45 +01:00
SamSu 6635e7a46a Launcher: rewrite the Thai translation (th.json)
Thank you for adding Thai to the launcher in #8306. We were really
happy to see it. When we used it, though, many lines were hard to
understand for us as Thai players. The translation looks
machine-made, and many words have their everyday meaning instead of
their computer meaning. We rewrote it so that Thai players can use the
launcher comfortably. 731 of the 1225 strings changed. The others
were already good, so we kept them as they were.

Below is each word we changed, what it is now, and how a Thai reader
understands the old word. We hope this makes our reasons clear, even
if you don't read Thai:

- build: อาคาร / โครงสร้าง -> บิลด์
  อาคาร means a building (a house, an office). โครงสร้าง means the
  structure of something. "Native build" read as "the original
  building". Thai developers and players say บิลด์, written as it
  sounds.
- instance: ตัวอย่าง -> อินสแตนซ์
  ตัวอย่าง means "example" or "sample", so "Instance" looked like a
  demo copy of the game, not a separate game setup. อินสแตนซ์ is the
  English word written in Thai, as Thai players say it.
- native (mod, build): ดั้งเดิม -> เนทีฟ
  ดั้งเดิม means traditional or original. "Native mod" read as "the
  original mod", which says nothing about compiled code. เนทีฟ is the
  usual Thai programming word.
- launcher: ตัวเรียกใช้งาน / ตัวเปิด / (left in English) -> ลันเชอร์
  The same thing had three names, so a player could not tell they
  were one program. ลันเชอร์ is what Thai players call it.
- jar: ขวด / โหล -> ไฟล์ jar
  ขวด is a bottle and โหล a glass jar for food. "Port gets the jars"
  read as "Port gets the bottles". It is a file type, so ไฟล์ jar.
- Loader: รถตักดิน -> ตัวโหลด
  รถตักดิน is a wheel loader, the construction vehicle.
- Client: ลูกค้า -> ไคลเอนต์
  ลูกค้า is a shop customer. Here it means the game client.
- Running / run: วิ่ง -> กำลังทำงาน / รัน
  วิ่ง is running on foot. "Running" read as "jogging". A program
  that runs is ทำงาน (working) or รัน.
- Proxy: หนังสือมอบฉันทะ -> พร็อกซี
  หนังสือมอบฉันทะ is a legal power-of-attorney letter.
- port: ท่าเรือ -> พอร์ต
  ท่าเรือ is a harbour for ships. A network port is พอร์ต.
- cape: แหลม -> ผ้าคลุม
  แหลม is a headland on a coast (or "sharp"). A Minecraft cape is a
  cloak, ผ้าคลุม.
- cosmetics: เครื่องสำอาง -> คอสเมติก
  เครื่องสำอาง is make-up (lipstick, powder). Thai gamers call
  in-game items to wear คอสเมติก.
- seed: เมล็ดพันธุ์ -> seed
  เมล็ดพันธุ์ is a plant seed for farming. Thai Minecraft players use
  the English word for a world seed.
- Home (page): บ้าน -> หน้าแรก
  บ้าน is a house. The Home page is the first page, หน้าแรก.
- Ban: บ้าน -> แบน
  Ban was also translated as บ้าน ("house"), so the ban button said
  "house". Thai players say แบน.
- founder (set): ผู้ก่อตั้ง -> ผู้เล่นรุ่นแรก
  ผู้ก่อตั้ง is the person who founded a company, so "Free for
  founders" read as "free for the people who started Horizon". The
  shop page says the set is for "the players who were here first", so
  it is ผู้เล่นรุ่นแรก, "early players". It is not "sponsors" either:
  the set is free.
- player counts: คน -> ผู้เล่น
  "{online} of {max} players" used คน (person). In a server list Thai
  players expect ผู้เล่น (player), as the game itself says.
- Java Edition: รุ่นจาวา -> Java Edition
  A product name, like Minecraft and Microsoft, which stay in
  English. รุ่นจาวา ("the Java version") reads as a Java release.
- 78 strings still in English -> translated
  They showed English in the middle of a Thai screen.
- Polite particle ครับ removed
  ครับ is the spoken "sir" that ends a sentence when a man talks. In
  UI text it sounds like a person talking, not a program.

We kept every {placeholder} and every middle dot of the English, and
we used one Thai word for each English term across the whole file.

The two of us, both native Thai speakers, read every changed line
(SamsVT and NotNiceKun). If anything still reads wrong,
please tell us and we will gladly fix it. Thank you for Horizon!
2026-11-06 21:11:40 +07:00
Florian ERNSTandCodex b127089760 Preserve literal modern sign string messages
Co-Authored-By: Codex <[email protected]>
2026-11-06 15:04:48 +01:00
Delete228andCodex e575a22cb2 Use the isolated game environment for mob discard parity
Co-authored-by: Codex <[email protected]>
2026-11-06 16:58:15 +03:00
AxxioandAntigravity 4e6b887535 multiplayer: narrate incompatible server version and motd
Narrate incompatible server list entries matching vanilla narration order: select -> incompatible -> version -> motd. Also remove the note about incompatible server status from docs/narrator.md known differences.

Closes #10317

Co-Authored-By: Antigravity <[email protected]>
2026-11-06 14:49:21 +01:00
Florian ERNSTandCodex 4d3f9944eb Honor fork state for terminal execute condition errors
Co-Authored-By: Codex <[email protected]>
2026-11-06 14:44:04 +01:00
AxxioandAntigravity 4e36ae1e4e docs/README.md: index live mod reloading design note
Link docs/design/mod-hotload.md under the Design notes section.

Fixes #11154.

Co-Authored-By: Antigravity <[email protected]>
2026-11-06 14:39:41 +01:00
TheMrZZandCodex gpt-6.1-sol 253a694871 Fix live mob custom-name component predicates
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-06 14:37:37 +01:00
AxxioandAntigravity bfdff7afb6 docs/native-client: update stale platform scope note
macOS and Windows are supported platforms with dedicated documentation under docs/platform/. Replace the stale 'macOS and Windows are out of scope' sentence with a reference to docs/platform/.

Fixes #11145.

Co-Authored-By: Antigravity <[email protected]>
2026-11-06 14:36:19 +01:00
voidsip ff416a1889 Tick the gateway passenger player in the command oracle scene 2026-11-06 07:33:50 -06:00
AxxioandAntigravity 89dc5664de audio: support HORIZON_AUDIO_BUFFER override for Linux buffer underruns
On Linux, Horizon asks ALSA for a 1024-frame fixed buffer to keep sound effects low-latency. When PipeWire is configured with min-quantum >= 1024 (common for Bluetooth audio or in VMs), the 1024-frame buffer repeatedly underruns with POLLERR and produces no sound.

Allow setting HORIZON_AUDIO_BUFFER=default or HORIZON_AUDIO_BUFFER=<frames> (e.g. 2048) to override the fixed buffer request and use a larger or system-default buffer. Document the environment variable in native-client.md.

Fixes #11082.

Co-Authored-By: Antigravity <[email protected]>
2026-11-06 14:21:51 +01:00
voidsip a98ba02730 Allow parity runner defaults in dolphin and spell harnesses 2026-11-06 07:19:21 -06:00
Delete228andCodex 36232d8820 Restore the frozen tick decision before ending a sprint
Co-authored-by: Codex <[email protected]>
2026-11-06 16:17:27 +03:00
Florian ERNSTandCodex 85b299df9d Preserve Pillager and WanderingTrader saved inventories
Co-Authored-By: Codex <[email protected]>
2026-11-06 14:13:19 +01:00
Delete228andCodex c968fb0d7d Keep native compatibility checks runnable in the upstream test harness
Co-authored-by: Codex <[email protected]>
2026-11-06 16:11:31 +03:00
Delete228andCodex 1bb6912a4b Keep native compatibility checks runnable in the upstream test harness
Co-authored-by: Codex <[email protected]>
2026-11-06 16:11:31 +03:00
DUzzL 7a46ba1f27 Show the dismount hint when the player starts riding 2026-11-06 14:09:41 +01:00
DUzzL f3d842d846 Seat other players on a cushion they sit on 2026-11-06 14:08:47 +01:00
Delete228andCodex 8f73091827 Format the native compatibility changes
Co-authored-by: Codex <[email protected]>
2026-11-06 16:05:48 +03:00
AxxioandAntigravity 03d9b39fc4 tests/test_harness_args: match system path separator for default work dir
DEFAULT is a Path object that formats with backslashes on Windows, while harness_args.py appends str(default). Pinning against str(DEFAULT) instead of the hardcoded POSIX string lets the test pass across all platforms.

Co-Authored-By: Antigravity <[email protected]>
2026-11-06 13:52:31 +01:00
Delete228 a5fa6f0d09 Keep empty native container provisioning out of inventory notifications 2026-11-06 15:51:42 +03:00
Delete228andCodex 04448803c8 Hide discarded mobs from immediate lookup and passenger saves
Keep removed mob entries out of command enumeration and UUID/ID lookup before
the normal sweep, and prevent a discarded passenger from saving inside its
vehicle. Preserve existing death-time and health rules.

Validate frozen Game commands/API37/save assembly against real26.3 discard
and entity-manager behavior with both client and server JARs.

Co-Authored-By: Codex <[email protected]>
2026-11-06 15:51:39 +03:00
AxxioandAntigravity 8f39c8afd2 docs: remove stale server End credits note
Co-Authored-By: Antigravity <[email protected]>
2026-11-06 13:48:49 +01:00
DUzzL 0f992292b7 Split slimes, magma cubes and sulfur cubes when the body is removed
Horizon split a dying cube at the moment of death. Vanilla
AbstractCubeMob.remove runs the split when the entity is removed, which
LivingEntity.tickDeath does after the death animation (deathTime 20),
so the smaller variants appear about one second later.
2026-11-06 13:37:46 +01:00
Delete228andCodex 21d66471d4 Bind content item aliases to ordinary block placement
Compile place_block declarations into the existing BlockItem binding table and load secondary item-to-block mappings for client prediction and server placement. Preserve alias item identity, component defaults and existing primary block-item mappings.

Validation: 49 compiler, 38 declaration and 63 item tests pass. Explicit prepared-resource tests verify original placement context IDs, real survival consumption, adventure refusal and dry client prediction. Strict clippy, formatting and public-tree checks pass.

Co-Authored-By: Codex <[email protected]>
2026-11-06 15:35:03 +03:00
Florian ERNSTandCodex 6e302fab48 Remove stale Animal LoveCause when no reference is modeled
Co-Authored-By: Codex <[email protected]>
2026-11-06 13:34:29 +01:00
Delete228andCodex b55e8616c1 Keep native entity data through creative Pick Block
Co-Authored-By: Codex <[email protected]>
2026-11-06 15:32:57 +03:00
Riccardo GuerrieroandClaude Opus 5.5 f7af3f420f use_item_on's oracle posts item_interact_start when a use starts
The oracle's stand-in player overrode LivingEntity.startUsingItem and only recorded the item, so
the real vanilla vibration never ran: ItemStack.causeUseVibration posts ITEM_INTERACT_START when
the stack's use effects ask for interaction vibrations, which every item's UseEffects.DEFAULT
does (DataComponents.COMMON_ITEM_COMPONENTS). Since #10272 Horizon posts it, so the harness
reported 284 false mismatches (4916/5200). It now calls causeUseVibration under the same guard
as vanilla: 5200/5200.

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 13:29:30 +01:00
Delete228andCodex ec74bd066b Add item fallback after native mob interactions pass
Keep entity actions and API 29 ahead of item fallback, reuse normal packet and crosshair selection, and let the first response consume with or without an acting-hand swing. Cover bounded native Mob entries with a pending fragment, generated dispatch and actual Game priority/filter tests; leave other living interaction tables explicit.

Co-Authored-By: Codex <[email protected]>
2026-11-06 15:21:46 +03:00
Delete228andCodex 3de12c0c7c Allow held-item break effects to name an explicit equipment slot
Keep ordinary wear, Unbreaking, durability criteria, stack removal and break handling in one path. Existing damage_held callers retain MainHand behavior; callers that model an offhand use may select OffHand without editing stack damage directly.

Validation: native prepared-resource Game test verifies offhand wear, break event48, unchanged main-hand stack, legacy event47 and creative immunity. Strict gameplay clippy, formatting and public-tree checks pass.

Co-Authored-By: Codex <[email protected]>
2026-11-06 15:21:44 +03:00
Riccardo Guerriero 01954d87c5 Merge remote-tracking branch 'origin/main' into fix/illager-target-memory 2026-11-06 13:19:46 +01:00
Ludovic Marie 41f822b659 Merge pull request #10301 from foudretbr/feat/screen-transparent-background-hook
🤖 Agent: foudretbr's rebase onto main (a1ff75a4a) drops the stray `);` that broke `tests/mod_api_hooks.rs`, and the file-wide import reorder. The hook change is the same 9 files: with no mod loaded, every translucent screen background is still the vanilla gradient. The 10 hook tests and tests.test_stamp_native_hooks pass.
2026-11-06 13:19:07 +01:00
Ludovic Marie d6fd737e31 Merge pull request #10382 from voidsipp/fix/10255-slow-event-note
🤖 Agent: merged main so this builds again (its old base still called `mob_to_nbt` without the UUID lookup that #10544 fixed). Burst notes are unchanged: one group per packet kind, each naming the player who spent the most time; the 7 burst and event-timing tests pass on the merged head.
2026-11-06 13:19:03 +01:00
rison1337andCodex GPT-6 52766de1c4 Skip falling-block mesh preparation behind the camera
Cache conservative envelopes of every baked model alternative, invalidating
on mutable model access. Reject rear-origin falling blocks only when their
complete padded model is outside the posed camera's depth clip planes.
Depth-only clipping preserves TAA jitter and screen-edge geometry; shader
packs keep their original path.

Include geometry/cache regression tests and separate ordinary/TAA FPS and
image comparisons, with front/empty controls and an oversized model fixture.

Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-06 17:19:01 +05:00
Ludovic Marie ce25432200 Merge pull request #11086 from horizon-rust/docs/readme-refresh
🤖 Agent: README/docs-media only; keeps the Project status block and '### Java parity' table the site and tests read, so playhorizon.dev's parity number is unaffected.
2026-11-06 13:18:59 +01:00
Delete228andCodex 15405e4ccf Route standard block fluid helpers through native-owned storage
Co-Authored-By: Codex <[email protected]>
2026-11-06 15:16:38 +03:00
Horizon agentandClaude Opus 5.5 bf4657da26 README: a cleaner page with fresh screenshots of today's game and launcher
The README's pictures were from mid-October (launcher 0.2.30, the old
menus), so this retakes them and tidies the page around them.

- Pictures: Nightly 2026-11-05 (66f3981) windowed on the Mac mini's M4
  Pro (Metal) with seed 8675309: a river valley with a village and a long
  view, a cherry-grove sunset, a crimson forest with a piglin, survival
  with the HUD, and two players over LAN; the launcher 0.2.47's Home (with
  those screenshots in its instance) and Mods pages. WebP, 712 KB in all,
  under docs/media/readme/. The five README-only images they replace are
  removed; village/nether/underwater.webp stay (the launcher embeds them).
- Text: a short pitch, what works in the current release, a download
  table with each platform's file and requirements (Windows x64 and Arm,
  macOS Apple Silicon and Intel from 12, Linux glibc), the Microsoft
  sign-in requirement, mods and shader packs (Java mods don't run;
  modpacks import), hosting, agent control, build from source,
  contributing, and Mojang's "NOT AN OFFICIAL MINECRAFT PRODUCT" line.
- Fixed claims: macOS Intel and Windows on Arm are out (not "from the next
  Nightly"); the known-issues line points to docs/known-issues.md.
- Two badges instead of seven: the parity and gap badges the tests and
  tools/parity_gaps.py keep in sync. "Project status", its gap block and
  the "### Java parity: N%" table are unchanged, so the website still
  reads its number and #build-from-source/#project-status still resolve.

Builds on #11040 by Web-mc1, who first noticed the launcher pictures were
outdated and retook them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 13:14:09 +01:00
Horizon agentandClaude Opus 5.5 3c6c890993 Drop the selector criterion run-log doc
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 13:11:20 +01:00
Horizon agentandClaude Opus 5.5 0efe1c2c13 Player effects also leave out ambient false and show_particles true
MobEffectInstance.Details.MAP_CODEC uses optionalFieldOf("ambient", false)
and ("show_particles", true), so like amplifier 0 those defaults are not
written (the mob writer already does this). The harness gets a default
work dir, so tools/parity.py can run it with no arguments; the run-log
doc is dropped.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 13:11:18 +01:00
Horizon agentandClaude Opus 5.5 a2090d0b0f Entity boolean harness: default work dir; drop the run-log doc
tools/parity.py runs each harness with no arguments, and this one exited
asking for --work-dir; it now uses a fresh folder under .local.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 13:11:18 +01:00
Horizon agentandClaude Opus 5.5 5b12729b5f Happy ghast ambient goes through Mob::voice; drop the capture harness
Mob::voice already plays at the feet (Entity.playSound), stays quiet for a
Silent entity, uses getSoundVolume and HappyGhast's pitch 1.0, so the
special arm is not needed and a Silent happy ghast no longer sounds. The
test checks the Silent case too. The 238-line capture harness ran a Java
server, a native server and a client for 1200 ticks on every ai.rs
change; the resource test covers the position.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 13:11:14 +01:00
Horizon agentandClaude Opus 5.5 cf20ccc4f9 rustfmt the new toolchain test
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 13:07:27 +01:00
voidsip 58a5690ec6 Add native client sound parameter interception 2026-11-06 06:05:23 -06:00
Florian ERNSTandCodex d2b61ade91 Omit stale default power from saved TNT
Co-Authored-By: Codex <[email protected]>
2026-11-06 13:01:50 +01:00
DetimesandClaude Opus 5.5 cdd4ffe95d Format native_bots.rs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 12:00:29 +00:00
Horizon agent f217a34593 Merge main into #10240
# Conflicts:
#	tools/port_map/client-004.tsv
2026-11-06 13:00:17 +01:00
Horizon agent f15541c0fe Merge remote-tracking branch 'origin/main' into pvp-2/sneak-input
# Conflicts:
#	crates/horizon-client/src/game/remote.rs
2026-11-06 12:59:58 +01:00
Horizon agent 965c9d9aed Merge main into #10219
# Conflicts:
#	crates/horizon-client/src/game/scoreboard.rs
2026-11-06 12:59:46 +01:00
Horizon agent 97f85f4530 Merge main into #10540 2026-11-06 12:57:33 +01:00
Horizon agent 4ce74ca81c Merge main into #10565 2026-11-06 12:56:03 +01:00
Horizon agent 2dcb671e13 Merge main into #10546 2026-11-06 12:55:54 +01:00
Horizon agent 964d919d96 Merge main into #10542 2026-11-06 12:55:19 +01:00
Delete228andCodex fd61fa626e Expose block clone stacks to native mods
Co-Authored-By: Codex <[email protected]>
2026-11-06 14:53:55 +03:00
voidsip db25d1d092 Match NBT path quoted escapes and index tokens 2026-11-06 05:53:36 -06:00
Ludovic Marie 398da21689 Merge pull request #10372 from Skr1zzzz/tab-shown-10108
🤖 Agent: merged #10312 (with main) in. The cache compares the whole Scoreboard twice per frame (displayed_ids and layout) and clones it on every change, so a server ticking a sidebar score every tick rebuilds and clones each frame; still cheaper than the old double sort, and tab_list parity passes on a union holding this head.
2026-11-06 12:50:14 +01:00
Ludovic Marie 297a718908 Merge pull request #10312 from Skr1zzzz/mp-fps-4664
🤖 Agent: merged main in: #10328 (merged meanwhile) now caches the layouts of the tags your collect_name_tags keeps, and #10098's new hide_gui argument is passed in your early tab_list::visible check too, so F1 still hides the list on the skip path. Skipping a tag whose entity box (inflated 0.5) is off the frustum matches vanilla, where shouldRender gates the name tag with the entity. 194 selected harnesses run on a union holding this head: tab_list and hud pass, the 8 failures are identical without it.
2026-11-06 12:50:08 +01:00
DUzzL 8a78ad3315 Apply death_protection death_effects for players and mobs, read mob hands by component 2026-11-06 12:48:18 +01:00
voidsip 82dc682cbc Parse server properties numbers like Java 2026-11-06 05:48:05 -06:00
Evrim CicekandClaude Opus 5.5 09160dc1e2 Merge upstream/main into fix/mounted-attack-box
Minecarts and primed TNT now seat their riders from the riders map;
the branch's seated box is kept on that path.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 14:46:14 +03:00
voidsip f1ef5cb53a Expose local particle spawning to native client mods 2026-11-06 05:43:33 -06:00
Ludovic Marie 28a389b7a3 Merge pull request #10557 from Detimes/feat-10404-older-clients-1b
🤖 Agent: shelf mushroom stands in as brown_mushroom_block (solid, not the walk-through brown mushroom), explorer maps as filled_map; 26.2 refusal from #10477 still in place
2026-11-06 12:42:26 +01:00
Horizon agentandClaude Opus 5.5 8ead30efe3 Native mods on Intel Macs: the launcher brings Rust for x86_64 macOS
The x64 macOS launcher (#1161) installed and ran the game, but native mod builds
stopped with "the launcher has none for macos x86_64": its managed toolchain
had no x86_64-apple-darwin host. Add Rust 1.99.1's official rustc, cargo and
rust-std for it, pinned by the SHA-256 static.rust-lang.org publishes; the
linker is the system's, as on Apple Silicon.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 12:41:15 +01:00
atsauban 052d9ebf20 fix(launcher): support responsive wrapping on narrow windows
Prevent vertical letter-by-letter text stacking and control clipping
when running in narrow tiling or compact displays:
- Top bar switches to compact tab padding and hides long subtitle
- Home actions and subtabs adaptively scroll and wrap without overflowing
- Mods and Versions headers stack title and filters vertically
- Settings navigates via top scrollable pill bar on narrow displays
- About tiles and Account cards stack cleanly
- Setting rows wrap controls instead of shrinking labels to 0 width
2026-11-06 18:40:50 +07:00
Evrim CicekandClaude Opus 5.5 d1ce5ddfd9 Merge upstream/main into fix/iron-golem-toss
Upstream's UUID bridge (#10304) now writes a mob anger target's UUID
through mob_to_nbt's uuid_of. The branch keeps its two additions on top:
a target read from the save that is not in the world keeps the loaded
UUID, and the loaded angry_at goes once the anger is over.

A mob only gets its UUID when it is first saved, so an anger target
not saved yet came out with no angry_at (wolves too). mob_tree_nbt now
gives a target that is still in the world its UUID first; the new
anger_at_a_mob_not_saved_yet_names_it test failed without that.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 14:37:40 +03:00
Horizon agentandClaude Opus 5.5 25ce3945c8 Roadmap: link 22 proposal issues to their items; launcher accessibility and local builds as Later items
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 12:37:18 +01:00
Florian ERNSTandCodex 40b1fc9100 Save default Interaction dimensions and response
Co-Authored-By: Codex <[email protected]>
2026-11-06 12:35:47 +01:00
Detimes a61d6fa54f Merge remote-tracking branch 'upstream/main' into hooks-bots 2026-11-06 11:34:12 +00:00
TaquitoSuabe 38d23bc13c gfx: skip broken Haswell Vulkan driver (hasvk) and fall back to OpenGL
- Mesa's hasvk Vulkan driver for Intel Haswell (Gen 7.5, vendor 0x8086) reports conformance version 0.0.0.0 and suffers from severe vertex buffer corruption and polygon tearing across 3D terrain (#9912).
- Check is_broken_haswell_vulkan in open_with_retry to skip non-conformant hasvk on Haswell and fall back cleanly to Mesa crocus OpenGL 4.6 Core Profile.
- Preserve Ivy Bridge (IVB) on Vulkan (#4059).
2026-11-06 04:31:50 -07:00
Delete228andCodex c4c0e6fc6b Expose dropped-item block contact to native mods
Co-Authored-By: Codex <[email protected]>
2026-11-06 14:25:58 +03:00
voidsip d2d046b1be Honor mapped function keys in keyboard input 2026-11-06 05:18:42 -06:00
foudretbr ae2e74af51 Native mod API: transparent screen background hook and menu blur
Fixes #10131

# Conflicts:
#	crates/horizon-client/tests/mod_api_hooks.rs
2026-11-06 12:17:57 +01:00
DUzzL 62028656e0 Save common Entity fields for registered Markers 2026-11-06 12:15:42 +01:00
VaspyyyandClaude Opus 5.5 6f2b33ff95 Launcher Settings: update status and Check now move to Updates
Horizon's update status (Up to date, Install) and the launcher's version with
Check now / Update sat under About, while the Updates section held only the
two auto-update switches. They now open Updates, in both looks: the Glass
tiles above the switches, the classic rows at the top of its Updates group.
About keeps the data folder (now a row like Logs), logs and the applications
menu.

Fixes #11035

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 12:14:33 +01:00
voidsip 5fd9d40d4c Validate packet resource identifiers when decoding 2026-11-06 05:14:09 -06:00
Detimes d0a80d6eb8 Merge remote-tracking branch 'upstream/main' into native-refresh-commands 2026-11-06 11:06:23 +00:00
DUzzL 3a0d345f06 Use the death-protection item, not the totem id, for the totem animation
The integrated server matched the used item by id (minecraft:totem_of_undying)
and used it up before the client looked for it, so any other item with
death_protection showed a plain totem in the activation animation.

Vanilla LivingEntity.checkTotemDeathProtection takes the first hand whose
stack has DataComponents.DEATH_PROTECTION, keeps a copy before shrink(1), and
uses that copy for the stat, the used_totem trigger and the animation.

Part of #10587
2026-11-06 12:01:57 +01:00
ScholiboiandClaude Opus 5.5 09ac779466 Pearls on a server's client hover in bubble columns as vanilla's do
ThrowableProjectile.tick runs on the client too: handleFirstTickBubbleColumn
before gravity, then applyEffectsFromBlocks' BubbleColumnBlock.entityInside
after the move, with Projectile's unclamped pushes (+0.06 inside, +0.1 at the
surface, -0.03 dragging down). The client's remote_projectile_tick had none of
it, so a pearl held in a stasis chamber on a server sank between the tracker's
updates and snapped back on each one.

Projectile::tick_remote adds both for ThrowableProjectiles, through a new
ProjectileWorld::bubble_column hook (bubble_column_at for the game's world).
The integrated and dedicated servers keep pushing their pearls in the
creature tick, so their projectiles are not pushed twice.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 16:28:27 +05:30
Riccardo Guerriero cd7ce3024a Merge remote-tracking branch 'origin/main' into fix/piglin-admired-drop 2026-11-06 11:58:16 +01:00
Riccardo Guerriero 8eb1e7cf4f Merge remote-tracking branch 'origin/main' into fix/illager-target-memory 2026-11-06 11:55:42 +01:00
4e94325ba5 Evokers and illusioners keep an unseen target for 15 seconds
Their target goals call setUnseenMemoryTicks(300): the evoker's player
and villager goals, the illusioner's player, villager and iron golem
goals. Pillagers and vindicators keep the default 60.

Co-Authored-By: Codex (gpt-6.1-terra) <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 11:55:42 +01:00
Riccardo Guerriero 225bcde8b9 Merge remote-tracking branch 'origin/main' into fix/trade-display-drop-chance 2026-11-06 11:52:39 +01:00
1dd1969640 Tests skip when the vanilla data is missing, as CI has none
Co-Authored-By: Codex (gpt-6.1-terra) <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 11:52:39 +01:00
Horizon agentandClaude Opus 5.5 7e13949bcf 26.2 stand-ins: a solid block for the shelf mushroom, filled_map for explorer maps
The shelf mushroom is a solid shelf players stand and bounce on (ShelfMushroomBlock's shape); the brown
mushroom it stood in as has no collision, so a 26.2 client would fall through what the server stands on and
be pulled back. Its block now shows as brown_mushroom_block, whose collision holds the shelf's; the item stays
brown_mushroom. The new explorer maps are filled maps (MapItem), so they show as filled_map, which draws their
map_id, not as an empty map. v26_2_ids.rs regenerated with --substitutes. TooltipDisplay's writer no longer
allocates a list on every write.

Review follow-up for #10557 (Detimes).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 11:51:51 +01:00
Ludovic Marie a1ff75a4a5 Merge pull request #11021 from horizon-rust/fix/audit-issue-flood
🤖 Agent: tooling only. When more than 20 harnesses newly fail at once, the audit now opens one issue instead of 421, and the open-issue lookup no longer stops at 200.
2026-11-06 11:51:49 +01:00
Ludovic Marie bedc6fbeda Merge pull request #11012 from Web-mc1/main
🤖 Agent: tooling only. ensure_work_dir now returns the folder argparse picks, so the --work-dir=PATH spelling no longer crashes four harnesses on sys.argv.index.
2026-11-06 11:51:46 +01:00
Riccardo Guerriero ad6e268383 Merge remote-tracking branch 'origin/main' into fix/sleeping-mob-push 2026-11-06 11:49:31 +01:00
445ba1168e Tests skip when the vanilla data is missing, as CI has none
Co-Authored-By: Codex (gpt-6.1-terra) <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 11:49:31 +01:00
Horizon agentandClaude Opus 5.5 31310a69f6 harness_args: return the work dir the run uses
Four harnesses looked the directory up with sys.argv.index('--work-dir'), which raises
ValueError for the --work-dir=PATH spelling this PR keeps. ensure_work_dir now returns the
directory argparse will pick (the last one given) and they use that.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 11:48:41 +01:00
Horizon agentandClaude Opus 5.5 e517343add Nightly audit: one issue when many harnesses newly fail at once
The 2026-11-06 audit tested a main commit that did not compile (mob_to_nbt arity in raids.rs) and opened
421 "Parity regression" issues, one per harness. Above 20 new failures the audit now opens one issue with
the list and the first compiler error, and keeps those harnesses' previous results so the next audit
compares them again. The open-issue lookup no longer stops at 200.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 11:46:50 +01:00
Riccardo Guerriero d49f1a0444 Merge remote-tracking branch 'origin/main' into fix/mob-block-fall-sound 2026-11-06 11:46:33 +01:00
d2fbe234ba Tests skip when the vanilla data is missing, as CI has none
Co-Authored-By: Codex (gpt-6.1-terra) <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 11:46:33 +01:00
DetimesandClaude Opus 5.5 ba4d3d471f Document the player bot helpers in native-mods.md
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 10:44:19 +00:00
DetimesandClaude Opus 5.5 a6a93455e8 Add native_simulate_player_move and native_collision_boxes for server-side player bots
One tick of a server player's own movement from a bot's input, played on a
copy of the body with the movement prediction's replay code, and the block
collision boxes in a box (Level.getBlockCollisions). Read-only; nothing runs
unless a mod calls them. Helper fragment player_bots, docs, unit tests.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 10:44:05 +00:00
DUzzL c545bd4cb1 Keep one world border per level 2026-11-06 11:34:26 +01:00
DetimesandClaude Opus 5.5 a0bb275b62 Native mods: native_refresh_commands rebuilds the players' command trees at run time (#10812)
A Game helper (Host::refresh_commands for trait mods): at the start of the
next server tick every mod's native_register_commands is asked again,
LanHost::native_commands and the hub's mod tree are rebuilt, and every
placed player is sent their tree again after native_player_commands_sent,
as PlayerList.sendPlayerPermissionLevel / Commands.sendCommands after
/reload. Hub::store_mod_commands keeps the new tree without sending it, so
each player gets one filtered tree. Servers whose mods never call it are
unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 10:28:45 +00:00
HiuoyandClaude Opus 5.5 3eda4b1b14 Native hook catalog: tooltip_render_pre is RenderTooltipEvent.Pre; Next hooks have all landed
`tooltip_render_pre` (#10302) is labelled `RenderTooltipEvent.Pre`, so
`horizon-port-pack events` counts the 10 ATM10 mods that listen to it.
The "Next hooks" table in docs/native-hooks-neoforge.md listed
`player_tick_pre` and the client tick pair, which have now landed: the
section lists them with the rest.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 14:16:33 +04:00
Web-mc1 8ccf1404e7 harnesses: keep the --work-dir a caller gives
Five command-oracle harnesses looked for a bare `--work-dir` and appended
their default anyway when the caller wrote `--work-dir=PATH`. argparse takes
the last value, so the caller's directory was silently replaced and the run
wrote its scratch output somewhere they did not ask for. They now share the
guard test_boat_leasher_parity.py already used, and tests/test_harness_args.py
pins both spellings.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
2026-11-06 10:13:20 +00:00
1de40d7cd3 A use that swings the hand resets the attack charge
ServerPlayer.swingAndResetAttackStrength: the guest's block, item, leash
knot and entity uses reset the attack ticker when their swing starts.

Co-Authored-By: Codex (gpt-6.1-terra) <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 11:12:32 +01:00
e3ca63226c Casting and reeling a fishing rod and throwing an eye of ender make the vibrations vanilla's do
- FishingRodItem.use posts cast and retrieval vibrations using the stack's use effects.
- EnderEyeItem.use posts a projectile-shoot vibration.

Part of #9776.

Co-Authored-By: Codex (gpt-6.1-terra) <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 11:07:40 +01:00
77f00c606d A villager's held-up trade item never drops when it dies
ShowTradesToPlayer sets the main hand's drop chance to 0 when it shows
an offer (displayAsHeldItem) and back to 0.085 when it clears the hand
(clearHeldItem).

Co-Authored-By: Codex (gpt-6.1-terra) <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 11:07:25 +01:00
5751bbd65f Native hooks: PlayerInteractEvent.EntityInteractSpecific, the point a player right-clicks on an entity
Add the native hook `entity_interact_specific` (NeoForge's `PlayerInteractEvent.EntityInteractSpecific`): a right-click on an entity hears the point clicked, measured from the entity's position, before `native_interact_entity`; a claim swings the hand and ends the click. Server-side.

Co-Authored-By: DeepSeek Pro via OpenRouter <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 13:58:31 +04:00
Horizon agentandClaude Opus 5.5 1c0fabcd7e Merge main into this branch
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 10:56:05 +01:00
GIGABAIT93andClaude Opus 5.5 10ec872cc1 docs/api/content.md: where a launcher puts content and what it checks
The install side of the Launch API, experimental: what a build loads (the
new `content`), the folder and the on/off file of each kind under
--game-dir, when a change applies, native mods needing a rebuild, what to do
with a .hpack, and the checks before installing as mod-index.md specifies
them (where it comes from, size and SHA-256, the minisign key, revoked[] and
installed copies, the manifest). Listed in docs/api/README.md. Part of #10562.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 11:49:14 +02:00
a2b395d91a A piglin always drops the item it admires, and one that isn't an ingot stops it despawning
Piglin.holdInOffHand equips the item with setItemSlotAndDropWhenKilled,
a guaranteed drop even without a player's kill, and makes the piglin
persistent unless the item is a gold ingot.

Co-Authored-By: Codex (gpt-6.1-terra) <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 10:46:42 +01:00
ae31791c33 A sleeping villager, fox or player isn't pushed by the mobs that walk into it
LivingEntity.push(Entity) does nothing while the entity sleeps, and
doPush reaches the other entity through it. The mob snapshot carries
sleeping (a villager in bed, Fox.isSleeping) and push_entities skips the
push. The sleeper's own tick still pushes the others.

Co-Authored-By: Codex (gpt-6.1-terra) <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 10:46:41 +01:00
Horizon agent 1fa86c919f Merge remote-tracking branch 'origin/main' into review-10557 2026-11-06 10:46:37 +01:00
GIGABAIT93andClaude Opus 5.5 2710305be4 --launch-api says what content this build loads
`content` in horizon-client's reply, from the constants the build checks
content against: content mods' manifest api lines (APIS without the removed
WebAssembly 0.1), native mods' hooks level and that they need a rebuild, the
shader schema and the line it draws, the resource and data pack formats, the
modpack format and the index schema. A launcher offers only what works. The
generated reference shows the hooks level as a placeholder, since it rises
with every hook. Part of #10562.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 11:44:04 +02:00
Ludovic Marie f4dcf8a2fb Merge pull request #10545 from horizon-rust/fix/revert-10416
🤖 Agent: main merged in (the old base had the mob_to_nbt test breakage, since fixed on main); still only the revert of #10416.
2026-11-06 10:42:19 +01:00
Florian ERNSTandCodex c87e5a6893 Omit the default amplifier from saved player effects
Co-Authored-By: Codex <[email protected]>
2026-11-06 10:39:48 +01:00
Ludovic Marie 0b2f2fda73 Merge pull request #10475 from Hiuoy/feat/player-tick-pre
🤖 Agent: the pre-tick hook swapped each guest's whole state into Game every tick even with no mod loaded; I pushed a guard so `as_guest` runs only when `mod_api::mods()` isn't empty. The fragment passes tests.test_stamp_native_hooks.
2026-11-06 10:39:30 +01:00
Horizon agent ef64ea9d3e Merge remote-tracking branch 'origin/main' into fix/revert-10416 2026-11-06 10:38:23 +01:00
Florian ERNSTandCodex 54cdfe00fe Load entity boolean flags through NBT byte conversion
Co-Authored-By: Codex <[email protected]>
2026-11-06 10:37:37 +01:00
Detimes 03ded99473 Merge remote-tracking branch 'upstream/main' into feat-10404-older-clients-1b 2026-11-06 09:31:26 +00:00
Ludovic Marie 0873f24d20 Merge pull request #10525 from Web-mc1/main
🤖 Agent: verify failed because bake_bricolage.py imports fontTools before it reads its arguments, and CI has no fontTools. I pushed a change that checks the arguments first, so the new usage test passes without fontTools installed.
2026-11-06 10:30:01 +01:00
Ludovic Marie 823cf263ff Merge pull request #10510 from megabytesme/fix/windows-icon-required
🤖 Agent: #10398 asked that a local build in a folder with a space still builds, so I pushed a fallback for GNU windres when the volume has no 8.3 names: outside CI it warns and builds without the icon instead of failing. With the taskbar-icon commit, `cargo check --target x86_64-pc-windows-gnu` passes on the desktop for horizon-client and horizon-client-input.
2026-11-06 10:29:57 +01:00
Hiuoy e5d96bad14 Merge remote-tracking branch 'origin/main' into feat/player-tick-pre 2026-11-06 13:29:54 +04:00
Ludovic Marie 296ac7fbf6 Merge pull request #10379 from voidsipp/fix/10102-prune-region-transactions
🤖 Agent: a failed region already restores its originals, so blocking the server start on any prune error took a server down for one corrupt chunk record. I pushed a change that starts it when no journal is left, with a supervisor test for it. Note that delete mode now writes regions one at a time with fsync, so a large prune takes longer than the old parallel rewrite. 169 horizon-server tests pass on Linux.
2026-11-06 10:29:53 +01:00
Hiuoy a6661aab7e Merge remote-tracking branch 'origin/main' into feat/9921-port-pack-events 2026-11-06 13:29:26 +04:00
Hiuoy 2c92825807 Merge remote-tracking branch 'origin/main' into feat/anvil-update 2026-11-06 13:29:23 +04:00
Riccardo Guerriero ed739050ae Merge remote-tracking branch 'origin/main' into fix/9759-golem-peaceful 2026-11-06 10:27:42 +01:00
Riccardo Guerriero 1caa6c8931 Merge remote-tracking branch 'origin/main' into perf/9996-startup 2026-11-06 10:27:23 +01:00
Riccardo Guerriero ddb365e6b4 Merge remote-tracking branch 'origin/main' into perf/9996-light 2026-11-06 10:27:23 +01:00
Ludovic Marie 64f691f9f7 Merge pull request #10544 from martipops/fix/raids-mob-to-nbt
🤖 Agent: raids.rs now passes the same `persist.uuid_of` lookup persist.rs gives `mob_to_nbt`, and the four entity_nbt.rs test calls get `|_| None`, so main builds again after #10304 (verify green on this head).
2026-11-06 10:26:17 +01:00
Ludovic Marie 0167f90c29 Merge pull request #10347 from PzmuV1517/shaders/menu-3-translations
🤖 Agent: translations only; %s placeholders kept and every file parses. The languages you flagged as uncertain (ba_ru, cv_cu, sah_sah...) stay worth a native speaker's look.
2026-11-06 10:26:13 +01:00
Ludovic Marie 28d84f2078 Merge pull request #10346 from PzmuV1517/shaders/menu-2-drop-old-pages
🤖 Agent: removes the old pack pages, their two slider kinds and four lang keys in all 87 files; nothing else read option_label_within, checked with clippy -D warnings on the stack over current main.
2026-11-06 10:26:09 +01:00
Ludovic Marie 5b16680739 Merge pull request #10337 from PzmuV1517/shaders/compat-6-paste-flat-matrix-offset
🤖 Agent: The non-constant texel offset becomes offset / textureSize(level 0) added to the coordinate: right for the base level, but at a sampled lower mip the shift is in base-level texels, not that level's (GL moves by texels of the level read). Fine for Arc's FXAA at level 0; worth a note if a pack hits it with textureLodOffset.
2026-11-06 10:26:05 +01:00
Ludovic Marie 086633c199 Merge pull request #10336 from PzmuV1517/shaders/compat-5-iris-macros
🤖 Agent: CAT_* macros come from biomes::CATEGORIES, so they pick up #10499's CAT_MOUNTAIN 17 and CAT_UNDERGROUND 18 with no second table to keep in sync; your test comparing every macro with constants() covers that.
2026-11-06 10:26:00 +01:00
Ludovic Marie fb75cde631 Merge pull request #10335 from PzmuV1517/shaders/compat-4-glsl-leniency
🤖 Agent: The constant evaluator now refuses a non-finite F32 fold with Overflow, and only GLSL function bodies turn that into a run-time expression, so WGSL input still rejects 1.0/0.0 at compile time. Vendored naga's own 133 lib tests pass with the chain.
2026-11-06 10:25:56 +01:00
Ludovic Marie 869d1aa812 Merge pull request #10334 from PzmuV1517/shaders/compat-3-integer-samplers
🤖 Agent: The integer-sampler helpers texelFetch the texel under the point with the coordinate clamped to the edge, which is what GL's nearest read of an integer texture gives; a bias argument is accepted and ignored, as GL does for these. Stack 3/6, checked with the rest of the chain on current main (clippy -D warnings, horizon-shaders and render tests).
2026-11-06 10:25:52 +01:00
2af85dcc84 A mob's damaging fall plays the fall sound of the block it lands on
LivingEntity.playBlockFallSound: the block 0.2 below the feet, at half
its volume and three quarters of its pitch, not for a silent mob or air.
Most mobs play it after their own fall sound, horses and llamas after
the damage, and a llama's damaging fall under six blocks plays it alone.

Co-Authored-By: Codex (gpt-6.1-terra) <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 10:24:38 +01:00
DUzzL 6285cca37c A thrown trident hurts other players on a served world (ThrownTrident.onHitEntity) 2026-11-06 10:19:18 +01:00
Horizon agentandClaude Opus 5.5 44a12076d5 Swap a guest in for player_tick_pre only when a native mod is loaded
as_guest swaps a player's whole state into Game: running it for every
player every tick with no mod to call cost the server for nothing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 10:17:18 +01:00
Skr1zzzz 079d82f968 Text displays: keep their glyphs, and skip the ones whose text is off screen
Every frame each text display built a batch, emitted its styled glyphs and
transformed every vertex, and a display of width or height 0 (what plugins
summon for holograms) is noCulling, kept to the distance rule because its
transformation can put the text anywhere: 200 of them cost ~0.3 ms a frame
with none on screen (#4664).

Each display's local-space text quads are now kept, rebuilt when the text,
its style, opacity, background, shadow, see-through, the font epoch or the
GUI atlas revision change (obfuscated text every frame), and only
transformed per frame. A noCulling display is skipped when the box of its
quads, through this frame's full matrix, misses the frustum: it can't draw a
pixel. Displays with a size keep vanilla's box test; the view range stays.
2026-11-06 12:13:04 +03:00
PzmuV1517 f4146eea7c Shader packs: rustfmt the pack loading call 2026-11-06 11:10:17 +02:00
Hiuoy 503727b594 Merge remote-tracking branch 'origin/main' into feat/anvil-update
# Conflicts:
#	crates/horizon-client/tests/mod_api_hooks.rs
2026-11-06 13:08:39 +04:00
Hiuoy 9f4b5e0a80 Merge remote-tracking branch 'origin/main' into feat/9921-port-pack-events
# Conflicts:
#	crates/horizon-port-pack/src/lib.rs
2026-11-06 13:08:29 +04:00
Hiuoy 41eec6978b Merge remote-tracking branch 'origin/main' into feat/player-tick-pre
# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
2026-11-06 13:07:52 +04:00
martipopsandClaude Opus 5.5 88cddcec82 Tests: pass mob_to_nbt its UUID lookup in entity_nbt's tests too
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 03:55:48 -05:00
Alysia Zappavigna bc6b668820 Format the pickable projectile tests 2026-11-06 19:54:27 +11:00
Horizon agentandClaude Opus 5.5 aad7542283 Keep the local no-alias fallback for GNU windres
#10398 asked that a local build in a folder with a space still builds: a
volume without 8.3 names gives windres no usable path, so outside CI
such a build warns and goes on without the icon, as before. CI and
every other case still fail without it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 09:50:46 +01:00
2a931e3e63 Peaceful drops a mob's player target and a golem's anger
On Peaceful, vanilla never lets a player be a mob's target
(`LivingEntity.canAttack` refuses a player there), so a mob that already
held one gives it up. A neutral mob also stops being angry and forgets
who hurt it (`NeutralMob.stopBeingAngry` calls `setLastHurtByMob(null)`).
Horizon dropped a player target only for creative and spectator players,
so an iron golem that had been angered kept attacking after the
difficulty was set to Peaceful.

Fixes #9759.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Fekd3xkgubE8VfXrALh2td
Co-Authored-By: Codex gpt-6.1-terra <[email protected]>
2026-11-06 09:50:35 +01:00
Alysia Zappavigna be4ce23d67 Let projectiles strike ghast fireballs, as Projectile.isPickable does (fixes #10547) 2026-11-06 19:48:44 +11:00
MegaBytesMe cfdca1dd6e fix: set Windows taskbar icon 2026-11-06 08:45:12 +00:00
Horizon agentandClaude Opus 5.5 e096a156a1 Check bake_bricolage's arguments before importing fontTools
CI has no fontTools: the import failed before main() could print the
usage, so the new test saw a traceback (verify failed).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 09:41:09 +01:00
TheMrZZandCodex gpt-6.1-sol 4a20680924 Fix HappyGhast ambient sound position
Use the entity position for the special ambient caller and cover adult and baby natural events through the real Mob tick and recording route.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-06 09:40:23 +01:00
Horizon agentandClaude Opus 5.5 85a5195411 Revert "Natural spawning: a start's height ends at the column's first empty block" (#10416)
parity-main: natural_spawn first fails at f6a9444de (#10416), of 182 merges
since 66f3981f5. The spawn start heights it produces differ from vanilla 26.3
by up to tens of blocks (chunk/10 monster: vanilla y 99, Horizon 62), so
players would see mobs spawn at the wrong heights. Reverted until the change
passes the natural_spawn harness.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 09:38:57 +01:00
martipopsandClaude Opus 5.5 3aaa70563e Raids: pass mob_to_nbt its UUID lookup, so main compiles
#10304 gave mob_to_nbt a fifth argument after the raid observation's call to it
was written, and the two merged without a conflict.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 03:35:09 -05:00
Horizon agentandClaude Opus 5.5 361766f621 Merge #10312 (with main) into #10372
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 09:29:35 +01:00
Horizon agentandClaude Opus 5.5 835f062e8e Merge main into #10312 (names.rs, scoreboard.rs)
names.rs: #10328 landed on main; its NameTagLayouts frame now wraps this
PR's frustum-culled collect_name_tags, both test sets kept.
scoreboard.rs: #10098 added the hide_gui argument to tab_list::visible;
the early visibility check here passes !self.ui.hide_gui too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 09:28:22 +01:00
Horizon agentandClaude Opus 5.5 1f00e33453 Group slow-tick bursts by packet kind and name the top player
The Team review (kacpherek) on #10382: groups filled in arrival order
under a 32 cap hid a late UseItemOn burst behind other players' moves;
count > 1 dropped 69 players sending one 6-9 ms use each; and one slow
event hid the rest of its group. Groups are now one per packet kind (a
bounded set) with the time of their short events only (the slow ones
are noted on their own), and each names the player who spent the most
of it: "213 x UseItemOn under 10 ms each; most from Steve: 140 of
them, 700.0 ms".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 09:26:36 +01:00
Kacper d3f799f5d5 Merge pull request #10368 from TheMrZZ/fix/h331-arrow-hit-category
Play ordinary arrow hits in the neutral sound category
2026-11-06 09:23:40 +01:00
Kacper 918767ac08 Merge pull request #10286 from Ragbie/fix/dragon-damage-command
/damage hurts the Ender Dragon and End crystals as vanilla does
2026-11-06 09:21:37 +01:00
Kacper 089a7c1242 Merge pull request #10259 from Ragbie/fix/port-pack-old-layout
Port pack: a mod made for 1.20 is not carried or counted as come over
2026-11-06 09:20:28 +01:00
Kacper 6cfb512e1e Merge pull request #10311 from rison1337/perf/cull-offscreen-blob-shadows
Skip block scans for off-screen entity shadows
2026-11-06 09:20:12 +01:00
Kacper 6f6042da38 Merge pull request #10474 from martipops/fix/pad-hid-layout
Controllers: read Bluetooth Xbox pads from their layout, on any firmware
2026-11-06 09:20:06 +01:00
Kacper 345a66c710 Merge pull request #10014 from Hiuoy/feat/client-logging-hooks
Native hooks: ClientPlayerNetworkEvent.LoggingIn / .LoggingOut (client_player_network_logging_in, client_disconnecting's label)
2026-11-06 09:19:55 +01:00
Kacper 2cc12be695 Merge pull request #10295 from foudretbr/fix/launcher-native-cache-busy
launcher: retry native cache fixture exec on ETXTBSY and copy via temp rename
2026-11-06 09:19:49 +01:00
Kacper 8126b95fb6 Merge pull request #10264 from ilguerro/perf/9996-aquifer
Aquifers keep their 12 nearby cells unpacked and reuse 8 of them a cell down
2026-11-06 09:19:43 +01:00
Kacper a36ce4ef76 Merge pull request #10495 from voidsipp/fix/boat-second-passenger-authority
Use the first passenger for remote boat control
2026-11-06 09:19:36 +01:00
Kacper d53d7bf02a Merge pull request #10087 from yahya-nusseibeh/fix/port-pack-capped-reads
port-pack: cap every jar and pack entry it inflates (launcher OOM from a mod search)
2026-11-06 09:19:27 +01:00
Kacper e42f12db85 Merge pull request #10232 from KBroneZ/claude/fix-10162-pack-templates
Read structure templates from data packs
2026-11-06 09:19:18 +01:00
Kacper 988f7d1ade Merge pull request #10362 from CocofireHD/fix/10273-reoccuring-freezes-on-diamondfire-server-mcd
Multiplayer: a guest server's resent columns keep the packet's light
2026-11-06 09:19:12 +01:00
Kacper 8e3cade985 Merge pull request #10307 from Skr1zzzz/fix-9190
Friends: Cancel and Escape go back to the screen that opened it
2026-11-06 09:19:07 +01:00
Kacper 8ca4e1d985 Merge pull request #9975 from Hiuoy/feat/client-tick-hooks
Native hooks: ClientTickEvent.Pre / .Post (client_tick_pre, client_tick_post)
2026-11-06 09:19:01 +01:00
Kacper b5c12d0f76 Merge pull request #10185 from degepie/fix/pack-lang-lenient-merge
Resource packs: read a lang file leniently when merging the layers
2026-11-06 09:18:54 +01:00
Kacper 2251cfb716 Merge pull request #10209 from ilguerro/perf/9997-climate-search
Climate searches pass distances along and stop a box's sum once it is too far
2026-11-06 09:18:49 +01:00
Kacper e5b2d2ea71 Merge pull request #10320 from TheMrZZ/fix/squid-damage-result
Preserve inherited squid damage acceptance result
2026-11-06 09:18:42 +01:00
Kacper c1a7b0d35e Merge pull request #10484 from voidsipp/fix/10445-leashed-happy-ghast
Match Happy Ghast leash movement and distances
2026-11-06 09:18:30 +01:00
Kacper b502fe41ac Merge pull request #9973 from MSedgeMC/fix/trample-hook
Landing: a trample is cancellable, so a claims mod can stop it
2026-11-06 09:18:24 +01:00
Kacper a8c5f833e4 Merge pull request #9924 from Hiuoy/feat/9921-hook-neoforge-labels
Native hook catalog: NeoForge labels for 39 hooks
2026-11-06 09:18:18 +01:00
Kacper 8ca4e05005 Merge pull request #10333 from PzmuV1517/shaders/compat-2-constant-array-crash
Shader packs: a global constant reads an element of a constant array (Ebin no longer crashes)
2026-11-06 09:18:08 +01:00
Kacper adcc59b920 Merge pull request #10332 from PzmuV1517/shaders/compat-1-real-packs-summary
Shader packs: real_packs keeps going past a panicking pack and sums up each pack
2026-11-06 09:18:02 +01:00
Kacper f42833e146 Merge pull request #10489 from TheMrZZ/fix/noai-dolphin-air-reset
Restore NoAI Dolphin air after its superclass tick
2026-11-06 09:17:55 +01:00
Kacper 1bd640668a Merge pull request #10304 from MSedgeMC/fix/uuid-bridge
Entity saves: a UUID bridge for references to a mob
2026-11-06 09:17:49 +01:00
Kacper cc5f7ff239 Merge pull request #10235 from MSedgeMC/fix/armor-stand-light-probe
Armour stands: probe the marker's own box and every other stand's eye, at 15 while burning
2026-11-06 09:17:38 +01:00
Kacper f7a5a3809f Merge pull request #10200 from Hiuoy/feat/break-speed
Native hooks: PlayerEvent.BreakSpeed, how fast a player digs a block
2026-11-06 09:17:32 +01:00
Kacper 1faeccc439 Merge pull request #10293 from IanWorley/fix/tests-on-apple-silicon
Tests: the drop-motion and install.sh signature tests pass on Apple silicon
2026-11-06 09:17:20 +01:00
Kacper 57512281ba Merge pull request #10266 from TheMrZZ/fix/ominous-warning-sound-source
Use neutral source for ominous item warning
2026-11-06 09:17:10 +01:00
Kacper ebafaf775a Merge pull request #10478 from TheMrZZ/fix/spellcaster-spell-ticks
Bind Evoker and Illusioner NBT to their live spell counter
2026-11-06 09:17:04 +01:00
Kacper 2f890f7df9 Merge pull request #10524 from TheMrZZ/fix/mob-local-sounds
Keep local mob sounds out of shared server capture
2026-11-06 09:16:57 +01:00
Kacper 62a7855892 Merge pull request #10499 from arslanarm/shaderpacks/lush-caves-biome-category
Shader packs: biome_category matches Iris's readings, fitted to vanilla's biome tags
2026-11-06 09:16:46 +01:00
Kacper cc3714e6ad Merge pull request #10358 from CocofireHD/fix/8713-no-zoom-menu-in-extras-on-mac
Extras: a Zoom row that reaches the mod that replaced the extra
2026-11-06 09:16:40 +01:00
Kacper d08bf63777 Merge pull request #10127 from CocofireHD/fix/9273-stuck-button-highlight
UI: a button's highlight goes when its screen leaves
2026-11-06 09:16:34 +01:00
Kacper 92dbf8406e Merge pull request #10134 from Hiuoy/feat/9921-more-labels
Native hook catalog: NeoForge labels for five more hooks
2026-11-06 09:16:28 +01:00
Kacper 77ac54a216 Merge pull request #10000 from TheMrZZ/fix/raid-live-village-pois
Validate loaded bed claims for raid villages
2026-11-06 09:16:21 +01:00
Kacper 8815f97746 Merge pull request #10141 from ilguerro/perf/9996-perlin
Perlin volumes skip the Ys a lerp does not read, and batch Ys across cells
2026-11-06 09:16:15 +01:00
Kacper 7e74759055 Merge pull request #10309 from Skr1zzzz/fix-9570
Shader packs: material maps share the stitched colour atlas's layout
2026-11-06 09:16:08 +01:00
Kacper 6c0d2781e6 Merge pull request #10297 from CocofireHD/fix/10037-directional-audio-bug-3d-positional-sound-an
Audio: play multi-channel buffers direct like OpenAL
2026-11-06 09:15:57 +01:00
Kacper 45a678e309 Merge pull request #10221 from MSedgeMC/fix/item-light-probe
Dropped items: light the block at the item's eye, and 15 for one on fire
2026-11-06 09:15:51 +01:00
Kacper efaa2a0e3f Merge pull request #10238 from MSedgeMC/fix/entity-light-probe-frame
Mobs and projectiles: light the block at the eye of the frame, not of the tick
2026-11-06 09:15:46 +01:00
Kacper ed5ac7df1f Merge pull request #10483 from voidsipp/fix/10468-finite-native-options
Keep native shader float options finite
2026-11-06 09:15:39 +01:00
Kacper 3e92f5e6fd Merge pull request #10076 from yahya-nusseibeh/fix/launcher-rustls
Launcher: rustls 0.23.46 for four RustSec advisories
2026-11-06 09:15:33 +01:00
Kacper a0e3c383a2 Merge pull request #10012 from some-du6e/optifine/color-properties
OptiFine custom colours: read optifine/color.properties
2026-11-06 09:15:26 +01:00
Kacper 1c9379c29e Merge pull request #10247 from TheMrZZ/fix/noai-glow-squid-countdown
Keep glow squid dark countdown running with NoAI
2026-11-06 09:15:20 +01:00
Kacper 46e88c47b1 Merge pull request #10075 from Ragbie/fix/nautilus-sight-armadillo-panic
A nautilus only goes for a pufferfish it sees, and frost panics armadillos
2026-11-06 09:15:15 +01:00
Kacper 8671d460bf Merge pull request #10480 from voidsipp/fix/snbt-unicode-key-order
Keep UTF-16 key order in compound SNBT
2026-11-06 09:15:09 +01:00
Kacper 9f282e42ea Merge pull request #9992 from UMCEKO/fix/tnt-owner-saved
Primed TNT saves and loads its owner
2026-11-06 09:15:03 +01:00
Kacper 7b0e4fee81 Merge pull request #10310 from xxaxio/fix/launcher-account-skin-overflow
launcher: fix account skin arm-type segmented control overflow and caption layout
2026-11-06 09:14:57 +01:00
Kacper c76e42d88d Merge pull request #10066 from Hiuoy/feat/render-level-stage
Native hook catalog: RenderLevelStageEvent labels for the world drawing hooks
2026-11-06 09:14:51 +01:00
Kacper fe8b0c622f Merge pull request #10328 from rison1337/perf/cache-name-tag-layout
Cache unchanged entity name-tag glyph layouts
2026-11-06 09:14:45 +01:00
Kacper c8137fbec9 Merge pull request #10302 from foudretbr/feat/tooltip-render-surface
Native mod API: item tooltip render surface and container item drawing
2026-11-06 09:14:33 +01:00
Kacper a4d6ba2781 Merge pull request #10062 from MSedgeMC/feat/scissor-intersect
A nested GUI scissor clips to the intersection and restores the outer one
2026-11-06 09:14:27 +01:00
Kacper 84cc6b5d64 Merge pull request #10089 from yahya-nusseibeh/fix/telemetry-store-off-workers
Telemetry server: hub, intake and export off the async workers too
2026-11-06 09:14:21 +01:00
Kacper 5258de5549 Merge pull request #10038 from BnCatt/older-servers/1.21.11-either-holders
Older servers: read 1.21.11's EitherHolder item components
2026-11-06 09:14:15 +01:00
Kacper c939630a79 Merge pull request #10135 from TaquitoSuabe/perf/gl-occlusion-culling
gfx: enable early depth occlusion culling on OpenGL
2026-11-06 09:14:09 +01:00
Kacper 932d351e11 Merge pull request #10306 from TheMrZZ/fix/lodestone-tracker-default
Omit the saved lodestone tracker true default
2026-11-06 09:11:39 +01:00
M0-kiiandCodex bd0c8cb4fd Preserve changed filenames and rename sources in tooling checks
Read Git's NUL-delimited UTF-8 output without newline conversion, and
include deleted source paths when files move between check scopes.
Keep pr_ready's line accounting consistent with parity selection.

Fixes #10535

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-06 11:41:32 +03:30
Kacper 8504efabb8 Merge pull request #10001 from Ragbie/fix/enderman-teleport-danger
Endermen teleport only where vanilla lets them
2026-11-06 09:11:31 +01:00
Kacper 63db515796 Merge pull request #10502 from arslanarm/shaderpacks/dimension-folder-gbuffers
Shader packs: terrain without a program in the pack is drawn by nothing
2026-11-06 09:11:25 +01:00
Kacper d629a4f6f6 Merge pull request #10497 from arslanarm/shaderpacks/terrain-block-id-mid
Shader packs: terrain's mc_Entity.y reads 0 off fluids, as in Iris
2026-11-06 09:11:18 +01:00
Kacper 77f2a09212 Merge pull request #10341 from Skr1zzzz/prepare-spans
Spans: the frame's prepare steps
2026-11-06 09:11:12 +01:00
Kacper aa1a7c0c42 Merge pull request #10156 from Hiuoy/feat/10132-living-damage
Native hooks: LivingDamageEvent.Pre and .Post, the damage a living entity's health takes
2026-11-06 09:11:06 +01:00
Kacper 36e6c2dc3f Merge pull request #10231 from TheMrZZ/fix/selector-empty-tags
Correct empty tag selector predicates
2026-11-06 09:10:58 +01:00
Kacper aca06a277b Merge pull request #10282 from TheMrZZ/fix/item-frame-missing-facing
Default loaded item frames to DOWN when Facing is absent
2026-11-06 09:10:52 +01:00
Kacper fedc1c7313 Merge pull request #10175 from vnsmoreira/fix-translucent-color-feedback
Fix shader-pack color-buffer reads in translucent draws
2026-11-06 09:10:41 +01:00
Kacper df6139520e Merge pull request #10034 from Ragbie/fix/mob-spear-reach
A mob's melee reach follows the spear it holds
2026-11-06 09:10:35 +01:00
Kacper 1a57f3660d Merge pull request #10070 from yahya-nusseibeh/fix/launcher-no-remove-var
Launcher: open HORIZON_LAUNCHER_PROJECT once without remove_var
2026-11-06 09:10:29 +01:00
Kacper 1e030fd397 Merge pull request #10348 from TheMrZZ/fix/guest-respawn-world-point
Keep guest respawn world information separate from personal landing
2026-11-06 09:10:23 +01:00
Kacper 4c1c81539f Merge pull request #10360 from TheMrZZ/fix/ageable-integer-load
Load animal age and love with NumericTag integer conversion
2026-11-06 09:10:17 +01:00
Kacper bf6bb5d470 Merge pull request #10343 from rison1337/perf/cull-offscreen-entity-flames
Skip offscreen burning-entity flame geometry
2026-11-06 09:10:11 +01:00
Kacper f4cc8528ae Merge pull request #10508 from Skr1zzzz/head-skin-cache
Player heads: parse a profile once, not on every draw
2026-11-06 09:10:04 +01:00
Kacper c12ad4d2cf Merge pull request #10207 from MSedgeMC/fix/pack-update-keeps-mod-choice
launcher: a pack update keeps the player's mod choices
2026-11-06 09:09:52 +01:00
Kacper b8dccdaa55 Merge pull request #10376 from TheMrZZ/fix/hive-default-saved-patch
Omit default hive block state from saved item patches
2026-11-06 09:09:45 +01:00
Kacper 50ac3abcda Merge pull request #10202 from TheMrZZ/fix/furnace-output-components
Furnaces: require output component identity before cooking
2026-11-06 09:09:38 +01:00
Kacper f4eb3234a8 Merge pull request #10496 from voidsipp/fix/10465-server-address-ports
Match vanilla server-address port syntax
2026-11-06 09:09:30 +01:00
Horizon agentandClaude Opus 5.5 3728c4b7d1 Play every arrow and trident tick sound on NEUTRAL
The crossbow hit, trident hit and trident hit_ground sounds come from the same
Entity.playSound as the arrow hit (AbstractArrow.onHitBlock / onHitEntity,
ThrownTrident.onHitEntity), so they take getSoundSource(), NEUTRAL, too; the
name-based guess put them on PLAYERS.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 09:03:29 +01:00
Horizon agent 899d87e22a Merge remote-tracking branch 'origin/main' into w10368 2026-11-06 09:03:00 +01:00
Horizon agent 1fd348869c Merge branch 'w10339' into w10286
# Conflicts:
#	crates/horizon-client/src/game/dragon.rs
2026-11-06 09:02:54 +01:00
Horizon agentandClaude Opus 5.5 ec6b1817a5 Start the server after a prune that rolled itself back
A failed region restores its originals before the error comes back, so
the world is whole: blocking the start then took a server down for a
corrupt chunk record. Only a recovery that cannot finish stops it now.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 09:02:36 +01:00
Horizon agentandClaude Opus 5.5 07c3ef02d0 Dragon scanning turn: vanilla degree conversion in double
DragonSittingScanningPhase.doServerTick computes Math.acos(dot) * 180.0F / (float) Math.PI
and Mth.atan2(..) * 180.0F / (float) Math.PI in double, not a multiply by the float
RAD_TO_DEG constant. Also moves the Silent test doc comment back onto its test.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 09:01:37 +01:00
Kacper a02fdabe6f Merge pull request #10061 from MSedgeMC/fix/region-file-compression
Region files are written with the compression server.properties names
2026-11-06 09:00:51 +01:00
Kacper d16b78e715 Merge pull request #10022 from DUzzL/fix/9964-burning-powder-snow
Burning players and mobs destroy powder snow they stand in
2026-11-06 09:00:43 +01:00
Kacper e5cc020034 Merge pull request #10272 from MSedgeMC/fix/item-interact-start
Starting an item use posts item_interact_start
2026-11-06 09:00:36 +01:00
Kacper 799fcccba4 Merge pull request #10279 from MSedgeMC/fix/jukebox-play-vibration
A playing jukebox posts jukebox_play every twenty ticks
2026-11-06 09:00:28 +01:00
Kacper 54feca80d1 Merge pull request #10246 from MSedgeMC/fix/disable-mob-generation
The End's no-creatures rule comes from the data, not the dimension
2026-11-06 09:00:22 +01:00
Kacper c1f8fee513 Merge pull request #10226 from MSedgeMC/fix/creative-search-focus
The creative search box loses the focus with its tab
2026-11-06 09:00:15 +01:00
Kacper 8ca476e489 Merge pull request #10172 from MSedgeMC/feat/creative-chat-key
The chat key opens the creative inventory's search tab
2026-11-06 09:00:07 +01:00
Kacper efabb913d0 Merge pull request #10098 from MSedgeMC/fix/f1-tab-list
F1 takes the player list away
2026-11-06 08:59:58 +01:00
Kacper bed239a2cd Merge pull request #10154 from Vaspyyy/fix/stonecutter-take-sound-once
Play a station's take sound once per shift-click
2026-11-06 08:59:51 +01:00
Kacper 63ddd76a15 Merge pull request #10100 from yahya-nusseibeh/fix/nbt-heap-quota
Network NBT: no more tags than vanilla's quota admits
2026-11-06 08:59:44 +01:00
Kacper 208f1c010f Merge pull request #10101 from DUzzL/fix/10051-frame-item-count
Keep one item in item frames filled by /item or NBT
2026-11-06 08:59:35 +01:00
Kacper 935e3927ab Merge pull request #10004 from DUzzL/fix/9960-allay-aim-eyes
Allay: aim deliveries at the player's eyes
2026-11-06 08:59:28 +01:00
Kacper 412594e68d Merge pull request #10016 from lightcodegit/fix/loot-guest-and-double-chest
Loot: guests roll a container's table on open; double chests roll both halves
2026-11-06 08:59:21 +01:00
Kacper 63d25cbd63 Merge pull request #10155 from lightcodegit/fix/push-entities-up
Snow, dripstone clay and spreading sculk lift what stands on them
2026-11-06 08:58:57 +01:00
Kacper 43aba8dbe5 Merge pull request #10126 from lightcodegit/fix/spectator-cant-open
A refused spectator opening gets the red action bar
2026-11-06 08:58:50 +01:00
Kacper 9ee9bb4d0e Merge pull request #10105 from DUzzL/fix/10041-adventure-can-place-on
Adventure mode: honor can_place_on for buckets and flint and steel
2026-11-06 08:58:42 +01:00
Kacper 4d5f0a965f Merge pull request #10010 from DUzzL/fix/9976-scaffolding-projectiles
Fix projectiles hitting the underside of scaffolding
2026-11-06 08:58:35 +01:00
Kacper 585e28d1fb Merge pull request #10186 from degepie/fix/enter-block-trigger
Fire the enter_block trigger from the server's block pass
2026-11-06 08:58:28 +01:00
Kacper 966a63278e Merge pull request #10094 from DUzzL/fix/10045-enderman-anger-harming
Splash of harming makes endermen teleport instead of hurting them
2026-11-06 08:58:21 +01:00
PzmuV1517 aff26bf9f9 Shader packs: a pack builds on a thread of its own while the game draws
Loading a shaders/ pack made its pipelines on the main thread, and the driver
compiles each program there: the game froze for 5 to 50 s on a big pack
(Complementary Reimagined, Photon), with no frame drawn.

The renderer now builds the pack on a "Shader pack" thread (load_shader_pack)
with its own handles to the device and the inputs, and the game keeps drawing
without a pack meanwhile; each frame poll_shader_pack checks the thread and
runs the pack once it is built, logging its programs and notes as before. A
pack the thread refuses or panics on is turned off as before. Picking another
pack, or none, drops one that is still building.
2026-11-06 09:58:20 +02:00
Kacper cf8a5a51b4 Merge pull request #10097 from DUzzL/fix/10052-hotbar-q-same-tick
Send the carried slot before a drop
2026-11-06 08:58:13 +01:00
Kacper 22462182e3 Merge pull request #10494 from voidsipp/fix/10466-final-live-config-values
Apply only final config values to the running server
2026-11-06 08:56:41 +01:00
Kacper c8c4a29367 Merge pull request #10363 from CocofireHD/fix/8668-container-to-hopper-minecart-transfer-speed
Pin the hopper minecart's one-item-a-tick transfer (#8668)
2026-11-06 08:56:34 +01:00
Horizon agent 0c47273286 Merge remote-tracking branch 'origin/main' into w10339 2026-11-06 08:56:14 +01:00
Kacper f24cc569db Merge pull request #10515 from Alysia42/tnt-first-tick
Tick TNT that joined during a tick in that tick's late pass
2026-11-06 08:56:09 +01:00
Kacper c3140e1bae Merge pull request #10192 from KBroneZ/claude/fix-10160-state-provider
upgrade-datapack: a simple state provider becomes a state object
2026-11-06 08:56:03 +01:00
Kacper 10b0942e4f Merge pull request #10191 from KBroneZ/claude/fix-10159-empty-noise
upgrade-datapack: a noise with no octaves becomes a silent 26.3 noise
2026-11-06 08:55:56 +01:00
Kacper 81aeeacf55 Merge pull request #10522 from kacpherek/perf/smp-server
Server: boats and minecarts stop walking every mob, and a Perfetto trace of every thread
2026-11-06 08:54:22 +01:00
Kacper a0261a708a Merge pull request #10190 from KBroneZ/claude/fix-10158-default-states
Read default_block and default_fluid in 26.3's block state form
2026-11-06 08:52:19 +01:00
Kacper 894f7a0684 Merge pull request #10167 from DUzzL/fix/10113-f3-keys-main-menu
Handle F3 debug keys on the title screen
2026-11-06 08:52:13 +01:00
Kacper d5b1c59f67 Merge pull request #10507 from DUzzL/fix/10447-lead-creative-spectator
Keep the lead in creative and drop mob leads when the holder spectates
2026-11-06 08:52:07 +01:00
Kacper b0ea840d13 Merge pull request #10128 from DUzzL/fix/10048-ladder-mob-push
Mobs on a ladder or vine are not pushable; no crit while climbing
2026-11-06 08:52:00 +01:00
Kacper 479d2082a8 Merge pull request #10187 from DUzzL/fix/10073-horse-jump-anim
Fix horse rear ending before a donkey or mule lands
2026-11-06 08:51:52 +01:00
Kacper d4b24f27d6 Merge pull request #10206 from DUzzL/fix/10157-shulker-tooltip
Fix numbered translation args in container item tooltips
2026-11-06 08:51:46 +01:00
Kacper 3ad30a2021 Merge pull request #10488 from AlfaRojo/fix/crossbow-flame-ignite
Crossbows: Flame ignites the arrows they shoot
2026-11-06 08:51:38 +01:00
MegaBytesMe 017caac28c fix: track Windows resource tool changes 2026-11-06 07:46:17 +00:00
Florian ERNSTandCodex 061648308e Keep local mob sounds out of shared server capture
Co-Authored-By: Codex <[email protected]>
2026-11-06 08:41:33 +01:00
Kacpherek f80bf5d7f3 Vehicles: test a mob's position before working out its box
A boat's or minecart's box query (boat_contacts, boat_obstacles,
cart_entity_collisions) asked every mob of the level for its box, and
Mob::bounds reads the mob's whole size (dims: sleeping, baby, species, scale)
across several cache lines. While boats and carts tick, the widest half width
and tallest height of the mobs (BoxReach) are worked out once, and a mob whose
position is farther than that from the query is skipped without its box. The
reach is worked out again when a mob joins or leaves, and nothing a vehicle
does resizes a mob, so the same mobs are found.
2026-11-06 08:36:08 +01:00
GabrielNichols ce90330576 Sync Recipes content scenario with official main 2026-11-06 04:32:30 -03:00
Detimes 7c411b3a58 Renumber registry ids for a 26.2 client, with stand-ins for what 26.2 lacks
What a Horizon server sends a 26.2 client (accept_older_clients) now carries 26.2's
registry ids: tags, block states, entity, block entity, block, attribute, sound and
particle ids, and items and components through the existing Way::Down writers, which
now leave out components the client lacks. A creative slot or container click from the
client is read with its ids up.

generate_registry_id_adapter.py --substitutes writes, from both versions' data
reports, a stand-in for each 26.3 entry 26.2 lacks (poplar as birch, wool and concrete
slabs and stairs as stone ones of the same shape, ...); what has none (the cushion
entity) isn't sent. v26_2_ids.rs is regenerated with it.

Part of #10404.
2026-11-06 07:29:28 +00:00
DUzzL b6e947c9a4 Merge remote-tracking branch 'upstream/main' into fix/10051-frame-item-count
# Conflicts:
#	crates/horizon-client/src/game/commands/items.rs
2026-11-06 08:29:22 +01:00
Ludovic Marie 0d21d5520e Merge pull request #10477 from Detimes/feat-10404-older-clients-1a
🤖 Agent: a 26.2 client is now refused in configuration before any untranslated registry data, tags or play packets (bebf670); 26.3 path byte-for-byte unchanged with the option off
2026-11-06 08:27:12 +01:00
AkirabaneandClaude Opus 5.5 db1cde61a8 dedicated: transfer-status, the counts a proxy reads before it sends players
A proxy that balances players between servers has to know, before it sends
one, how many players a server has and whether a login would be refused as
full. transfer-status answers with counts only: the players placed, held and
expected, the hand-overs under way, the places the player limit counts, the
limit, and whether the server is full. It names no player, changes nothing
and writes nothing to the log, since a proxy asks it often. Like the player-*
requests, it is refused while [transfer] is off.

Refs #7484

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 08:23:45 +01:00
Web-mc1 89bac30424 tests: pin how the dev tools take their arguments
Both tools above used to crash, or write into a flag-named path, instead of
asking for what they need. These tests run them in an empty folder and check
that they exit non-zero with a usage line and write nothing.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
2026-11-06 07:20:12 +00:00
Web-mc1 cd5b93a54a bake_bricolage: ask for its two fonts
Both arguments were read unguarded, so running it with none, or with one, was an
IndexError rather than the command its docstring shows. Print the usage line.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
2026-11-06 07:20:12 +00:00
Web-mc1 35bb6ede00 json_load_fixtures: ask for an output directory
The script read sys.argv[1] straight away, so a missing argument ended in an
IndexError traceback, and a mistyped flag created a directory named --help and
filled it with 66 MB of fixtures. Print the usage instead: a flag is not a path.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
2026-11-06 07:20:12 +00:00
Arslan ArmanulyandClaude Opus 5.5 da06786848 The dragon fight's world fog leaves out the sky, as vanilla does
GameRenderer.renderLevel gives LevelRenderer.render shouldRenderSky false
while a boss bar makes world fog (the Ender Dragon's), so the frame is only
cleared to the fog colour. Horizon drew the End's sky box behind the island
during the fight. fog::sky_pass_skipped now holds vanilla's conditions:
LevelRenderer.addSkyPass's (lava, powder snow, blindness or darkness) and
the boss fog.

Part of #10491

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 12:19:07 +05:00
Kacpherek 02bdb03444 Vehicles: seat riders and find boat contacts without a pass over every mob per vehicle
position_riders finds every vehicle's riders in one pass over the mobs instead
of one per boat, minecart and primed TNT. A boat tests a mob's box before
anything else, and works out the scoreboard teams only when something touches
it, instead of once per boat a tick.
2026-11-06 08:10:31 +01:00
Kacpherek c7183d6232 Trace: every thread's spans as a timeline Perfetto opens
HORIZON_TRACE=FILE, horizon-ctl trace FILE [SECONDS] and horizon-trace on a
dedicated server's console write each span of every thread as a Chrome trace
event, so what ran beside what, and what a thread waited for, can be read off
in ui.perfetto.dev. A thread that records spans but is never taken from (a far
terrain or lighting worker while spans are on) now drops them past 16k instead
of growing for as long as spans stay on.
2026-11-06 08:10:30 +01:00
Florian ERNSTandCodex 8b33877963 Bind spellcaster NBT to the live spell counter
Load and save Evoker and Illusioner SpellTicks using the counter already consumed by casting goals and server AI. Cover the real codec and actual Game tick path.

Co-Authored-By: Codex <[email protected]>
2026-11-06 08:05:53 +01:00
Alysia Zappavigna 3b56859e04 Tick TNT that joined during a tick in that tick's late pass (fixes #10063) 2026-11-06 18:05:23 +11:00
Ludovic Marie 98af9399cd Merge pull request #10509 from horizon-rust/perf/launcher-prepared-cache
🤖 Agent: is_prepared now answers from a 5 s per-version cache (21.4 µs to 0.21 µs per call); need_prepared and the CLI still run the full per-file check, so an empty JSON is caught before launch.
2026-11-06 07:53:06 +01:00
Horizon agentandClaude Opus 5.5 bebf670623 Turn a 26.2 client away before untranslated registry data
Only packet ids are translated for an older client so far (#10404): the
registry data, tags (registry ids) and play packets (chunks, light, login,
player_action numbering...) would reach a real 26.2 client in 26.3 ids and
layouts. With accept_older_clients on, such a client now logs in and is
told so in configuration, right after the known packs and before any of
them. NetConfig::older_clients_play (tests only, not in server.toml) keeps
the play path reachable for the id renumbering test until the later parts
translate the contents.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 07:51:40 +01:00
MegaBytesMe 376be8985d fix: handle spaces in Windows resource paths 2026-11-06 06:51:09 +00:00
Horizon agent 5daf0853e3 Merge remote-tracking branch 'origin/main' into review-10477 2026-11-06 07:44:24 +01:00
Horizon agentandClaude Sonnet 5.5 bb8a9d9524 Launcher: pages drawn ask "is it prepared?" from memory, not the disk
#10389 made is_prepared parse research-lock.json and the manifest, list the
prepared folder and stat every table on each call. Pages call it while drawing
(instance rows, the Play menu, can_start), so every frame did file I/O.

is_prepared now keeps its answer per version folder for 5 s and drops it when
the launcher installs, removes, reuses resources or a job exits.
check_prepared runs the full check and refreshes the answer; starting the game
or a server (need_prepared), the CLI and the post-prepare auto-play use it, so
an empty JSON is still caught before a launch.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-06 07:44:15 +01:00
voidsip a4586852b1 Merge remote-tracking branch 'refs/remotes/origin/leash-refresh-main' into fix/10445-leashed-happy-ghast
# Conflicts:
#	mods/horizon/src/test/java/dev/horizonlab/parity/VanillaCommandExecOracle.java
2026-11-06 00:31:03 -06:00
Ludovic Marie 3a81751180 Merge pull request #10458 from TheMrZZ/fix/h028-stand-fall-save
🤖 Agent: Entity.checkFallDamage subtracts (float)ya into the double, which the f64::from(.. as f32) step reproduces, and Entity.load reads fall_distance back. Main merged in again after #10422 landed (GitHub read the criss-cross bases as a conflict); the diff to main is this PR's own 6 files.
2026-11-06 07:28:07 +01:00
Horizon-agentandClaude Opus 5.5 ec72efe92d Merge main into #10458 after #10422 landed
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 07:25:35 +01:00
Ludovic Marie 87c51ab9c9 Merge pull request #10471 from arslanarm/shaderpacks/shadow-pass-block-entities
🤖 Agent: `camera_list` is now collected into a Vec on every frame, even with no pack, where the old iterator was lazy; it's one small allocation per frame. The shadow-only set and the spawner's `shadow_pass_drew` scan (linear in shadow_visible per spawner) run only under a pack whose shadow draws entities.
2026-11-06 07:23:45 +01:00
voidsip f2a63474b9 Match vanilla server-address port syntax 2026-11-06 00:22:03 -06:00
voidsip 24224f4728 Respect the controlling passenger for remote boats 2026-11-06 00:21:23 -06:00
voidsip 4e580b2732 fix(server): apply only final values in config batches 2026-11-06 00:21:04 -06:00
Arslan ArmanulyandClaude Opus 5.5 0a14bac24c Shader packs: biome_category matches Iris's readings, fitted to vanilla's biome tags
`biome_category` read 0 (CAT_NONE) in lush caves and the other cave biomes,
where Iris 1.11.7 reads 5 (CAT_PLAINS), and differed in 12 more vanilla
biomes. A probe pack of ours read Iris's value in each of vanilla 26.3's 67
biomes (`fillbiome` around the camera): they fit vanilla's `is_*` biome tags
(BiomeTags; `is_savanna` unused), then desert, swamps, mushroom fields and
snowy plains by name, NONE for the void, and PLAINS for the rest. Mountain
biomes read 17, which Iris names CAT_MOUNTAIN; CAT_UNDERGROUND is 18.

Biomes vanilla doesn't have keep the old guess from words of their name.

Fixes #10453

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 11:18:32 +05:00
Arslan ArmanulyandClaude Opus 5.5 4eb7a27e58 Shader packs: terrain without a program in the pack is drawn by nothing
A pack (or the dimension folder it uses) with no gbuffers program for terrain
had the game draw terrain itself, into colortex0 and depth. Iris 1.11.7 draws
none: with probe packs of ours having no gbuffers programs, at the root or in
world-1 below a root that has them, colortex0 held only the sky (or fog
colour), entities and particles, depthtex0 had no terrain, and colortex1 to 7
none either; water and lava were missing too.

When no program of gbuffers_terrain's chain (nor _solid or _cutout) is in the
folder, the pack takes solid and cutout terrain from the game and draws
nothing; the same for translucent terrain and gbuffers_water's chain. A
program that is there but fails to load still leaves terrain to the game.

Fixes #10455

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 11:18:32 +05:00
Kacper 108c130237 Merge pull request #10340 from CocofireHD/fix/10327-docs-readme-md-index-the-benchmark-and-perfo
docs: index benchmark and performance result pages in docs/README.md
2026-11-06 07:18:18 +01:00
Kacper 73fdce3c01 Merge pull request #10375 from voidsipp/fix/10326-docs-index
Index the missing design, server and release docs
2026-11-06 07:18:10 +01:00
Kacper bf22a35ab3 Merge pull request #10350 from xxaxio/docs/10325-index-player-launcher-docs
docs/README.md: index missing player, launcher and platform pages
2026-11-06 07:18:04 +01:00
Kacper e41b6599ec Merge pull request #10351 from xxaxio/docs/10324-remove-stale-not-ported-notes
docs: remove stale "not ported" notes
2026-11-06 07:17:58 +01:00
Kacper 0c2aec59f6 Merge pull request #10234 from KBroneZ/claude/fix-10164-forceload
Load chunks in /forceload add, as vanilla does
2026-11-06 07:17:48 +01:00
Kacper 50c2ade6c6 Merge pull request #10233 from KBroneZ/claude/fix-10163-template-loot
Keep a placed template's container loot table
2026-11-06 07:17:42 +01:00
Kacper 3281781334 Merge pull request #10370 from xxaxio/fix/10322-map-can-place-on-banner
game/maps: honour can_place_on when using a map on a banner in adventure mode
2026-11-06 07:17:35 +01:00
Kacper 5871fa922f Merge pull request #10278 from Joansitoh/fix/10044-underwater-sky
Stop the underwater sky scan on kelp and seagrass
2026-11-06 07:17:25 +01:00
Kacper 16bb8b3e65 Merge pull request #10277 from Joansitoh/fix/10044-bucket-liquid
Replace a bubble column when a bucket empties into it
2026-11-06 07:17:18 +01:00
Kacper 1e35996644 Merge pull request #10274 from DUzzL/fix/10250-zip-end-record-disk
Normalize zip END records with equal non-zero disk numbers
2026-11-06 07:17:12 +01:00
foudretbr 8be5c59fce Native mod API: item tooltip render surface and container item drawing
Fixes #10199
2026-11-06 07:17:10 +01:00
Florian ERNSTandCodex 3aaff8da93 Restore NoAI Dolphin air after its superclass tick
Use the existing species maximum after shared Mob tick work, preserving the normal underwater countdown. Cover the real full-tick path and actual Game commands against fresh Java.

Co-Authored-By: Codex <[email protected]>
2026-11-06 07:16:46 +01:00
Ludovic Marie 499b049803 Merge pull request #10422 from TheMrZZ/fix/h046-loot-pickup-save
🤖 Agent: Mob.addAdditionalSaveData always writes canPickUpLoot(), so moving it out of the put-if-absent defaults is the fix (Allay and sulfur cube overrides now win over a loaded byte). Main merged in after #10393 landed; clippy and the entity_nbt tests pass on the merge.
2026-11-06 07:16:40 +01:00
Ludovic Marie 29825eb774 Merge pull request #10411 from DUzzL/fix/10318-piglin-gear-compare
🤖 Agent: piglin_replace follows Piglin.canReplaceCurrentItem in order (PREVENT_ARMOR_CHANGE first, then wanted/unwanted, then Mob.canReplaceCurrentItem), with getPreferredWeaponType null for babies.
2026-11-06 07:12:21 +01:00
Horizon-agentandClaude Opus 5.5 ded0d1a283 Merge main into #10458
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 07:11:56 +01:00
Horizon-agentandClaude Opus 5.5 1c8729793f Merge main into #10422
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 07:11:51 +01:00
Ludovic Marie 69c1400e58 Merge pull request #10389 from CocofireHD/fix/10303-my-game-crash-after-launch
🤖 Agent: `is_prepared` now reads the manifest and stats every top-level JSON on each call, and the launcher calls it from render paths (glass_home, glass_palette, can_start): a few hundred µs per frame at most, but a cache keyed on the folder would remove it. I dropped the unused `is_json_file`; launcher clippy and backend tests pass.
2026-11-06 07:11:07 +01:00
Ludovic Marie 948c6d6a88 Merge pull request #10438 from megabytesme/fix/windows-cross-compile-icon
🤖 Agent: checked on Linux with mingw: the build script now compiles `main_res.o` for x86_64-pc-windows-gnu and passes it to the link, which the host-gated `#[cfg(windows)]` skipped. I kept the warn-only fallback outside CI for windres paths with a space (#10398).
2026-11-06 07:11:03 +01:00
Ludovic Marie b8c058a249 Merge pull request #10386 from voidsipp/fix/10355-boat-attack-knockback
🤖 Agent: causeExtraKnockback's non-living branch is Entity.push (no knockback resistance, no halving), as strike_boat does; the guest path still checks the attack range before skipping look_at_entity. We merged #10434 into this branch: both extend the boat fixture and harness.
2026-11-06 07:10:59 +01:00
Ludovic Marie 5a0c5d522f Merge pull request #10437 from voidsipp/fix/10401-snbt-float-text
🤖 Agent: for one-digit mantissas {:.1e} picks the closest two-digit decimal, which is what Java's Float/Double.toString does (Float.MIN_VALUE prints 1.4E-45). We switched the harness to the shared release world_probe: debug at CARGO_BUILD_JOBS=1 would have compiled horizon-world again in CI.
2026-11-06 07:10:55 +01:00
Ludovic Marie ff27525aaf Merge pull request #10448 from masa-masa-0314/fix/realms-add-realm-strings
🤖 Agent: RealmsPdpScreen shows the trial description (and a Try for free button) only when trialAvailable; Horizon does not ask for it, so we dropped the description and kept the title with Buy, vanilla's no-trial layout. Titles are drawn plain where vanilla underlines/bolds them: small follow-up.
2026-11-06 07:10:52 +01:00
Ludovic Marie 54112649da Merge pull request #10413 from DUzzL/fix/10319-golem-reset-anger
🤖 Agent: start() matches NeutralMob.stopBeingAngry plus IronGolem.startPersistentAngerTimer (nextInt(381) + 400 = rangeOfSeconds(20, 39)) at target priority 4. We moved the creeper test's doc comment back onto its own test: the new test had been inserted between them.
2026-11-06 07:10:48 +01:00
Ludovic Marie ef207a7c92 Merge pull request #10391 from masa-masa-0314/feat/realms-tier-distance
🤖 Agent: the tier fetch and both PUTs hit the same paths as RealmsClient.getRealmTierConfiguration/updateRenderDistance/updateSimulationDistance, and a reply missing either range is an error as in vanilla; nothing beyond what the vanilla settings tab offers.
2026-11-06 07:10:43 +01:00
Ludovic Marie c86e080406 Merge pull request #10388 from voidsipp/fix/conversion-command-tags
🤖 Agent: only Tags move to the new id, as ConversionType.convertCommon copies them; the old raw compound (UUID, species data) is left behind, which the save/reload test checks.
2026-11-06 07:10:39 +01:00
Ludovic Marie 26243ba3b7 Merge pull request #10392 from voidsipp/fix/7436-fishing-ground-motion
🤖 Agent: hook_block_hit now runs on the client before moving, as FishingHook.checkCollision does on both sides, and gravity is f64::from(0.03f32) (getDefaultGravity is a float).
2026-11-06 07:10:35 +01:00
Ludovic Marie a4f021ec67 Merge pull request #10414 from voidsipp/fix/4832-remote-pig-steering
🤖 Agent: matches Pig/Strider.getControllingPassenger (saddled, first passenger a player holding the right stick in either hand); the server side reads the same rule through moved_by_client, so it no longer simulates the mount twice.
2026-11-06 07:10:31 +01:00
Ludovic Marie a7bf97e67d Merge pull request #10417 from voidsipp/fix/next-1-glint-override
🤖 Agent: component_bool matches NbtOps.getBooleanValue (doubleValue != 0, so 0.5f and NaN are true, Short 256 too), not NumericTag.asBoolean's byteValue; that is the Hypixel glint_override report.
2026-11-06 07:10:28 +01:00
Ludovic Marie 11e2482ae5 Merge pull request #10426 from voidsipp/fix/levitation-fall-distance
🤖 Agent: LivingEntity.aiStep resets fall distance for slow_falling or levitation before travel, NoAI included; dropping the later travelInAir-style reset is right, it erased the current tick's descent.
2026-11-06 07:10:24 +01:00
Ludovic Marie 363906684c Merge pull request #10430 from voidsipp/fix/10397-packet-utf8-replacement
🤖 Agent: packet_utf8 differs from from_utf8_lossy only on ED A0..BF (encoded surrogates), which Java consumes as one replacement; the length limits still use the encoded bytes first and UTF-16 units after, as Utf8String.read does.
2026-11-06 07:10:20 +01:00
Ludovic Marie a567b64f20 Merge pull request #10433 from voidsipp/fix/10399-unreadable-moderation-lists
🤖 Agent: reading the bytes once and parsing them removes the exists()-then-load race too; only NotFound starts an empty list, so a directory or a locked ops.json now stops the stopped-server command instead of being overwritten.
2026-11-06 07:10:16 +01:00
Ludovic Marie faa7e2746f Merge pull request #10407 from DUzzL/fix/10323-evoker-fangs-teams
🤖 Agent: fangs_allied follows Entity.isAlliedTo both ways (AbstractIllager tag only when both are teamless, Evoker's vex root-owner rule); guests on the evoker's team are still bitten, as before, which is a follow-up rather than a regression.
2026-11-06 07:10:12 +01:00
Horizon-agent b4197ddd94 Merge remote-tracking branch 'origin/main' into w-10471
# Conflicts:
#	crates/horizon-client-render/src/gfx.rs
2026-11-06 07:08:12 +01:00
Ludovic Marie ad8663977c Merge pull request #10459 from TheMrZZ/fix/armor-stand-command-equip-events
🤖 Agent: follows LivingEntity.onEquipItem (same item+components or firstTick: nothing; EQUIPPABLE on the new stack picks equip over unequip) and data merge keeps first_tick. We removed the docs/parity run-log file.
2026-11-06 07:03:36 +01:00
Ludovic Marie 0b3a4bda9c Merge pull request #10424 from TheMrZZ/fix/hostile-sound-categories
🤖 Agent: all nine families are Monster subclasses (entity.hostile.* is Monster's shared hurt/death/fall set), so HOSTILE is right; the list scan is 34 short strings per sound, negligible. We removed the docs/parity run-log file.
2026-11-06 07:03:32 +01:00
Ludovic Marie 5fb8f59ea0 Merge pull request #10432 from TheMrZZ/fix/numeric-damage-component
🤖 Agent: DataComponents.DAMAGE goes through Codec.INT (Number.intValue: truncation, Long keeps its low 32 bits), and dynamic::as_int already does that. We removed the docs/parity run-log file.
2026-11-06 07:03:29 +01:00
Ludovic Marie 62d2b18c16 Merge pull request #10400 from TheMrZZ/fix/inline-fishing-tool-context
🤖 Agent: LootCommand.dropFishingLoot's no-tool endpoint passes ItemStack.EMPTY, and the code only reads tool_stack when a tool, mainhand or offhand was given, so an omitted tool stays empty. We removed the docs/parity run-log file.
2026-11-06 07:03:25 +01:00
Ludovic Marie 17541e8bff Merge pull request #10387 from TheMrZZ/fix/baby-pig-passenger-attachment
🤖 Agent: Pig BABY_DIMENSIONS has PASSENGER (0, 0.5, 0), so the one match arm is right. We removed docs/parity/baby-pig-passenger-attachment.md: run logs and hashes belong in the PR description, not in docs/.
2026-11-06 07:03:22 +01:00
Skr1zzzz a8107ee025 Player heads: parse a profile once, not on every draw
head_skin decoded the textures property (base64, JSON, URL checks) and hashed
the skin location (SHA-1) for every head on every frame, before asking the
atlas whether the skin was resident; an armor stand's worn head also turned
its profile NBT into JSON each frame. With 60 stands wearing textured heads
and 36 heads in the inventory that was ~9 us a head a frame (#4664: a server
full of menu heads, CPU-bound with the inventory open).

The pure part (profile -> textures -> skin location, failures included) is
now kept in a bounded cache beside the resolver; worn heads reuse the JSON of
an unchanged profile. The download state, atlas residency, drawn tracking and
eviction re-uploads still run per draw.
2026-11-06 09:02:52 +03:00
Kevin RamirezandClaude Fable 5.1 927b546c9a Crossbows: Flame ignites the arrows they shoot (#10482)
`Game::shoot_crossbow` set the arrow's weapon and Piercing count but never
ran the weapon's `projectile_spawned` ignite, which only the bow path
applied. In vanilla, `AbstractArrow`'s constructor runs
`EnchantmentHelper.onProjectileSpawned` for every arrow built with a
weapon, bow or crossbow, so a Flame crossbow shoots burning arrows that
prime TNT and set entities on fire.

The bow's `bow_projectile_ignite` becomes `ignite_spawned_arrow(weapon,
arrow)`, shared by both weapons; the crossbow ignites each arrow of its
volley after Piercing, as the constructor does. The port map rows follow
the rename, and a resource-backed regression checks no Flame, Flame and
Flame with Multishot.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-06 00:02:20 -06:00
Ludovic Marie fff2a81e41 Merge pull request #10395 from DUzzL/fix/10321-spawnpoint-bubble-column
🤖 Agent: confirmed in Blocks.<clinit>: water, lava and bubble_column are the only three blocks with .liquid(), so the name list is complete for Block.isPossibleToRespawnInThis.
2026-11-06 07:02:01 +01:00
Ludovic Marie 1f883e7a79 Merge pull request #10402 from Skr1zzzz/server-tick-spans
🤖 Agent: `tick_blocks` starts a fresh `laps` after the scheduled ticks, so the stretch between `tb_entity_inside` and `tb_broadcast` shows only through `random_ticks`, `tb_scheduled` and `tb_fluids`. The counters and laps cost one relaxed load each when spans are off.
2026-11-06 07:01:57 +01:00
Ludovic Marie c956e11ff8 Merge pull request #10470 from DUzzL/fix/10441-ghastling-sounds
🤖 Agent: HappyGhast.getHurtSound/getDeathSound pick GHASTLING_* by isBaby, and happy_ghast::sounds already does exactly that, so the new voice_sounds arm is the whole fix.
2026-11-06 07:01:53 +01:00
Ludovic Marie f6a9444deb Merge pull request #10416 from Skr1zzzz/spawn-parity
🤖 Agent: checked against 26.3: `ChunkAccess.getHeight` returns `getFirstAvailable - 1` and `getRandomPosWithin` adds the 1 back, so topEmptyY is Horizon's `world_surface` itself. The old +1 made the bound 130 for a column solid to y 63. run-parity's natural_spawn harness checks the draws end to end.
2026-11-06 07:01:50 +01:00
Ludovic Marie 0610b830ca Merge pull request #10434 from voidsipp/fix/4545-boat-paddle-transition
🤖 Agent: rowing_time now gates on paddle_controller like AbstractBoat.getPaddleState (flag AND a controlling passenger) and clamps a as Mth.clampedLerp does; #10386 carries this branch merged, queued after it.
2026-11-06 07:01:46 +01:00
Ludovic Marie 8a21328406 Merge pull request #10439 from Skr1zzzz/random-ticks
🤖 Agent: the `[false; 256]` table relies on a local palette never holding more than 256 entries (global palettes keep the per-block path). The test covers 1, 2, 256 and 300 states, a stale palette entry, and the LCG draws against an independent oracle.
2026-11-06 07:01:39 +01:00
Ludovic Marie d165254d41 Merge pull request #10393 from TheMrZZ/fix/h011-orb-saved-uuid
🤖 Agent: matches Entity.saveWithoutId: the orb's command NBT now reads its registered uuid_for(id) instead of a fresh random UUID per read; queued first of the 10393/10422/10458 stack (the other two carry this branch merged).
2026-11-06 07:01:34 +01:00
Ludovic Marie 0125ca5281 Merge pull request #10435 from Hiuoy/feat/model-layers
🤖 Agent: `native_bake_layer` re-reads entity-models.json on every call for a vanilla layer no mob has (the doc says so), so a mod that bakes per frame would hitch; caching that document is the natural next step. The humanoid-against-zombie#main bake test is the right oracle, and the fragment stamps cleanly (tests.test_stamp_native_hooks).
2026-11-06 07:01:29 +01:00
Ludovic Marie 743d6229fe Merge pull request #10419 from senjinthedragon/resize-free-first
🤖 Agent: the early return for an unchanged size stays ahead of the new `device.poll(wait)`, so the GPU sync happens once per real size change. A drag-resize pays it per new size, a fair price for not holding both sets of buffers.
2026-11-06 07:01:25 +01:00
Ludovic Marie 6df8d31833 Merge pull request #10456 from arslanarm/shaderpacks/smooth-across-teleports
🤖 Agent: `world_seen` never resets inside a `Custom`, so the restart on entering the Nether relies on the pack being rebuilt for each dimension. If `PackPost` is ever kept across dimensions, it will need resetting there.
2026-11-06 07:01:21 +01:00
Ludovic Marie cc4911fc2b Merge pull request #10457 from arslanarm/shaderpacks/colortex-mips-blend
🤖 Agent: only `Source::Color` gets the linear-mip sampler; shadowcolor, custom textures and the lightmap keep `Linear` (nearest level), which matches what was measured. Buffers without mipmaps read the same as before.
2026-11-06 07:01:17 +01:00
Ludovic Marie 8d1a46ef24 Merge pull request #10467 from arslanarm/shaderpacks/shadow-depth-filter-precision
🤖 Agent: the 256ths weights are a fit to one NVIDIA GPU but now apply on every GPU, so an AMD or Intel reading of Iris may move them later. The cost is two floors and a multiply per filtered shadow read.
2026-11-06 07:01:13 +01:00
Ludovic Marie a6a228a193 Merge pull request #10469 from arslanarm/shaderpacks/shadow-pass-spectator
🤖 Agent: the spectator rule keys on `pack_ids.is_some()`, so a native pack's shadow keeps the first-person model, as the test pins. The other game modes stay as before, since only spectator was measured.
2026-11-06 07:01:09 +01:00
Ludovic Marie 57140bb20f Merge pull request #10473 from arslanarm/shaderpacks/terrain-vertex-grid
🤖 Agent: the +1/32 bias moves only places within one float step under a 32768th line, so #9839's water tops (29094.34) and 13/15 offsets (28398.93) still floor as before, and the new test case pins the 1, 0.5 and 9.5 edges.
2026-11-06 07:01:05 +01:00
Ludovic Marie 0c7ddd45c5 Merge pull request #10436 from arslanarm/shaderpacks/shadow-pass-depth-culling
🤖 Agent: with the near and far planes dropped, the shadow pass now draws every section inside the box's sides out to its reach, so a pack with tight planes draws more shadow terrain than before. Worth watching in the cave perf numbers. #10471 conflicts with this in gfx.rs; I'll merge main into it once this lands.
2026-11-06 07:01:00 +01:00
Horizon-agentandClaude Opus 5.5 c9b0011952 Merge #10434 (boat paddles) into #10386
Both extend the boat authority fixture and harness: keep the paddle and
attack cases, both Case fields and both docstring paragraphs. The harness
keeps main's fast-profile build with #10434's --resources option.
Queued after #10434, so either landing first leaves the other mergeable.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 06:57:38 +01:00
Horizon-agentandClaude Opus 5.5 a25a11fbf7 Merge #10422 and #10393 into #10458
All three add a command_exec fixture group at the same lines and a test
module at the end of entity_nbt.rs; keep every group, flag, dispatch block
and module. Queued in order so whichever lands first leaves the others
mergeable.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 06:56:29 +01:00
Horizon-agentandClaude Opus 5.5 9f9daee867 Merge #10393 (orb saved UUID) into #10422
Both add a command_exec fixture group at the same lines; keep both groups,
their isolation sets, flags and dispatch blocks. Queued in order so either
landing first leaves the other mergeable.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 06:55:28 +01:00
DUzzL 468a73f128 Keep the lead in creative and break mob leads when the holder spectates 2026-11-06 06:54:41 +01:00
Horizon-agentandClaude Opus 5.5 c542322582 Float text harness: build the shared release world_probe
A debug world_probe at one job compiles horizon-world from scratch beside
the release probe every other horizon-world harness uses.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 06:54:15 +01:00
Horizon-agentandClaude Opus 5.5 264d5f38c7 Realms product page: the trial header without its description
Horizon does not ask trialAvailabilityTask. Vanilla shows the trial
description (and offers the trial in the footer) only when one is
available; otherwise the header is the title alone and the footer offers
Buy, which is what this screen shows.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 06:53:56 +01:00
Horizon-agentandClaude Opus 5.5 dd23018283 Keep the creeper test's doc comment on its own test
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 06:53:11 +01:00
Horizon-agentandClaude Opus 5.5 3b575a0016 Drop the run-log document from the PR
The PR description already holds the vanilla reference and the measured
results; docs/ keeps player and contributor documentation only.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 06:52:48 +01:00
Horizon-agentandClaude Opus 5.5 94d295d20c Drop the run-log document from the PR
The PR description already holds the vanilla reference and the measured
results; docs/ keeps player and contributor documentation only.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 06:52:45 +01:00
voidsip 625f6ca1a5 Match Happy Ghast leash movement and distances 2026-11-05 23:52:43 -06:00
Horizon-agentandClaude Opus 5.5 ec080faa9f Drop the run-log document from the PR
The PR description already holds the vanilla reference and the measured
results; docs/ keeps player and contributor documentation only.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 06:52:42 +01:00
Horizon-agentandClaude Opus 5.5 1d1b060e1a Drop the run-log document from the PR
The PR description already holds the vanilla reference and the measured
results; docs/ keeps player and contributor documentation only.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 06:52:37 +01:00
Horizon-agentandClaude Opus 5.5 5e8d9a5123 Drop the run-log document from the PR
The PR description already holds the vanilla reference and the measured
results; docs/ keeps player and contributor documentation only.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 06:52:26 +01:00
voidsip d12a260a53 Reject overflowing native shader float options 2026-11-05 23:52:06 -06:00
voidsip 0d70c87082 Keep UTF-16 key order in compound SNBT 2026-11-05 23:47:01 -06:00
Horizon-agentandClaude Opus 5.5 3649dfb9eb json_file: drop is_json_file, which nothing calls
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 06:45:51 +01:00
Horizon-agentandClaude Opus 5.5 94d8386027 build.rs: keep the no-icon fallback for local builds
The GNU windres the launcher's native mod builds use can't take a path with a
space, so a local build there must go on without the icon (#10398 asked to keep
it); CI still fails without it. One Windows block for the stack and the icon.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 06:45:50 +01:00
TheMrZZandCodex gpt-6.1-sol 81cf3ef358 fix: name actual macro argument NBT type
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-06 06:41:16 +01:00
HiuoyandClaude Opus 5.5 5c1d4d53f6 Native hooks: PlayerTickEvent.Pre, the start of a player's server tick (#10464)
native_player_tick_pre() runs once a tick for every living player in the
world, at the top of their ServerPlayer.tick, before their digging, item
use and cooldowns. native_player_tick (API 31) already ends the same tick.
It fires in tick_guests after the checks that skip a dead player and one on
a server trip, so the pair brackets the same players. Single player's own
player is a guest of its internal server.

The fragment player_tick_pre.json labels it PlayerTickEvent.Pre (side
server, not cancellable) for horizon-port-pack events. 23 of ATM10's mods
listen to it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 09:36:24 +04:00
martipopsandClaude Opus 5.5 b32520088e Controllers: read Bluetooth Xbox pads from their layout, on any firmware
SDL rows match a firmware version exactly, and gilrs' copy has no row for
045e:0b13 at 0x0520. The pad then falls back to gilrs' default mapping: its
right stick is on ABS_Z/ABS_RZ, which nothing read, its triggers on
ABS_GAS/ABS_BRAKE, which that mapping doesn't name, and X and Y stay crossed
because it lists C and Z.

Recognise that layout from what the pad reports, so a new firmware works
without a row.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 00:28:36 -05:00
Arslan ArmanulyandClaude Opus 5.5 460ecfce99 Shader packs: terrain's mc_Entity.y reads 0 off fluids, as in Iris
Terrain's `mc_Entity.y` was -1 on every block but water and lava, and -1 on
those too when the pack has no block.properties. Iris 1.11.7, with probe packs
of ours, reads 1 on water and lava and 0 on every other block, whether
block.properties maps it, leaves it out, or the pack has no such file.

The sprite layers' table now starts each layer at (-1, 0), with y 1 on the
fluid sprites, before block.properties is read.

Fixes #10452

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 10:27:02 +05:00
Arslan ArmanulyandClaude Opus 5.5 c9bc6104b3 Shader packs: a terrain vertex a float step under a 32768th sits on it
A shader pack's terrain gl_Vertex floors each vertex's place in its
section to a 32768th. Plants whose random offset is 9/15 of a block came
out a 32768th under a half block, where our probe packs read Iris
1.11.7's exactly on it: we guess, untested, that our float sums put them
a hair under the line. A place up to 1/32 of a 32768th under a grid line
is now on it.

Part of #8464

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 10:26:00 +05:00
Arslan ArmanulyandClaude Opus 5.5 02c05de455 Shader packs: shadowtex filters in whole 256ths, as our Iris readings fit
A pack's texture() on shadowtex0/1 through a plain sampler blended the
four texels with the exact fraction. Iris 1.11.7's filtered reads on our
NVIDIA GPU fit weights in whole 256ths: the fraction rounded half up on
each axis, the far corner's weight their product over 256 rounded half
up, the others the rest of each axis. #8464's lit probe then differs
from Iris's at no pixel of ow_ground_noon.

Part of #8464

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 10:25:52 +05:00
Arslan ArmanulyandClaude Opus 5.5 6a737285e8 Shader packs: the shadow pass takes the block entities of the sections it draws
A shader pack's shadow pass drew the block entities the camera's view
had built: a chest beside the view, in a section whose terrain the
shadow pass draws, was left out, and a spawner's mob out of the view
and out of those sections went in. Our probe packs' Iris 1.11.7 shadow
maps hold a chest wherever the shadow pass draws its section's terrain
and nothing of the one spawner in our scenes, which lies outside them.
The block entities of the sections the shadow pass drew last frame are
now added hidden from the camera, and a spawner's mob that is out of
the view and out of those sections is left out.

Part of #8464

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 10:25:51 +05:00
Arslan ArmanulyandClaude Opus 5.5 1354db6f08 Shader packs: a spectator's own model stays out of the shadow pass, as in Iris
In first person, a shader pack's shadow pass drew the spectator's own
model (its head) at the camera. Our probe packs' readings of Iris
1.11.7's shadow map in spectator mode hold the terrain below the camera
there.

Part of #8464

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 10:25:42 +05:00
HiuoyandClaude Opus 5.5 3e4828d791 port-pack events: ModConfigEvent counts as hooked when its three events are
37 of ATM10's mods listen to the parent ModConfigEvent, which hears the
three events NeoForge's config page documents under it: Loading, Reloading
and Unloading. config_loading, config_reloading and config_unloading answer
them, so the parent now counts as hooked rather than as another phase's:
93 of 326 names, up from 92.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 09:24:40 +04:00
Detimes d503874bff Let 26.2 clients join a Horizon server, off by default: handshake and packet ids
A server.toml option, [server] accept_older_clients (horizon-accept-older-clients), off
by default. On, a login handshake stating 776 opens an adapter::ClientSession on the
connection instead of being refused, and a 26.2 status ping is told its own protocol.
The session follows the connection's states as Session does, mirrored, and renumbers
packet ids both ways with the inverse of the generated 26.2 tables, in the blocking
login path and in the io_pool play path. Off, nothing changes.

Part of #10404.
2026-11-06 05:22:16 +00:00
Florian ERNSTandCodex f97e4b9527 Emit game events for armor-stand command equipment changes
Co-Authored-By: Codex <[email protected]>
2026-11-06 06:12:01 +01:00
TheMrZZandCodex gpt-6.1-sol 504b987937 Save armor stands' actual fall distance
Adopt and project the existing fall counter, including Java's per-movement float narrowing before double accumulation. Preserve travel and landing-call order, and cover the real tick/save route plus decoded saves without raw data.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-06 05:59:09 +01:00
masa-masa-0314andZCode c81874d772 Realms: the settings sliders' tooltips
`createDistanceOption`'s cached constant tooltips, shown while the
pointer is over either distance slider.

Co-Authored-By: ZCode <[email protected]>
2026-11-06 12:55:30 +08:00
Ludovic Marie a589a3dd9b Merge pull request #10449 from horizon-rust/fix/native-stale-engine-target
🤖 Agent: staged files now get the current time, so a Nightly's source unpacked with its commit's older mtimes can no longer pass for already-compiled crates (Windows/macOS fs::copy kept them); one-time stage rewrite for old stages.
2026-11-06 05:53:41 +01:00
Arslan ArmanulyandClaude Opus 5.5 8d38356bf4 Shader packs: smooth() keeps going through teleports, as in Iris
A custom uniform's smooth() restarted on every frame whose camera biome was
unknown, so after each teleport it started again, and it started from the
unknown frame's values (biome -1, biome_category 0, temperature 0): the
frame that restarted it also stored them, and the first frame that knew the
biome went on from there.

Now smooth() restarts only until the first frame that knows the camera's
biome, that frame included, and then keeps going for as long as the pack
runs. With a probe pack of ours, Iris 1.11.7 kept smoothing across
teleports within a dimension and started again when the player entered
the Nether.

Fixes #10450
Part of #9307

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 09:53:24 +05:00
Arslan ArmanulyandClaude Opus 5.5 4c0ef1d7df Shader packs: colortex mipmaps blend their two nearest levels, as in Iris
A shader pack's colortex read between two mipmap levels (textureLod at a
fractional level) took the nearest level: the buffers' sampler had the
default, nearest, mipmap filter. In Iris 1.11.7 our probe packs read every
colortex, in composite and in final, as the two nearest levels mixed. The
buffers now get a sampler of their own that blends them; shadowcolor and
the other textures keep theirs (not measured).

Fixes #10451
Part of #9307

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 09:53:24 +05:00
Horizon agentandClaude Opus 5.5 4b24e21a8a launcher: format the stamping test
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 05:49:06 +01:00
DUzzL ebafc91999 Use ghastling hurt and death sounds for baby HappyGhasts 2026-11-06 05:47:17 +01:00
HiuoyandClaude Opus 5.5 e925e25781 port-pack events: ModelEvent.RegisterAdditional is looked up as RegisterStandalone
NeoForge's documentation describes one event under two names. 1.21.1's
"Registering Additional Models" (ModelEvent.RegisterAdditional, one
ModelResourceLocation per model) is "Registering Standalone Models"
(ModelEvent.RegisterStandalone, a key and a baker per model) from 1.21.5 on.
Both versions use the same sentence, and the example method is still
registerAdditional. standalone_models (API 151) answers it, so the 47 ATM10
mods that listen to it now count as hooked: 92 of 326 names, up from 91.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 08:46:39 +04:00
Horizon agentandClaude Opus 5.5 1fe1d5e860 launcher: stamp staged native source files with the current time
A Nightly's source keeps its commit's mtimes when unpacked, and fs::copy
keeps them on Windows and macOS, so a changed engine file reached
native/stage/ older than the previous build and Cargo reused the old
horizon-core. sync now sets each written file's mtime to now; a stage
written before this is rebuilt once.

Fixes #10421

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 05:41:23 +01:00
masa-masa-0314andZCode 382f264b6a Realms: the Add Realm screen as vanilla's product page
The screen behind Add Realm is 26.3's `RealmsPdpScreen`: the header's
logo, the trial header, then the friends, minigames and private-server
entries with their 80x45 pictures and 182-wide text, scrolling between
the header and the footer, whose buttons are the PDP's own title (the
buy link) and Back.

Its old strings were dead: `mco.title` never existed, and 26.3's
deprecated.json removes `mco.selectServer.popup` and
`mco.selectServer.buy`, so the screen drew raw keys.

Co-Authored-By: ZCode <[email protected]>
2026-11-06 12:39:31 +08:00
Ludovic Marie 679f8c6e3f Merge pull request #10440 from horizon-rust/fix/options-emote-key
🤖 Agent: options.txt is vanilla's key list again; the emote key is saved as emoteKey in horizon-extras.txt and an old key_key.emote line is migrated, options parity passed on the desktop.
2026-11-06 05:18:23 +01:00
Horizon agentandClaude Sonnet 5.5 c6d5769e4f Options: rustfmt keybinds.rs
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-06 05:13:24 +01:00
Ben 1ad899c7b5 fix: embed Windows icon resources for cross builds 2026-11-06 04:13:14 +00:00
voidsip b9439b3508 Match Java text for tiny floats and doubles 2026-11-05 22:12:54 -06:00
Arslan ArmanulyandClaude Opus 5.5 6a8ea6fc18 Shader packs: the shadow pass draws terrain outside the pack's near and far planes
Our probe pack's Iris 1.11.7 shadow maps hold the terrain at every depth we
put it along the light (from 535 blocks beyond the shadow camera to about 120
before it), whatever shadowNearPlane and shadowFarPlane the pack sets, or
none. Horizon dropped the sections outside the shadow projection's near and
far planes; since #9768 those are the pack's own planes with no offset, so
in our probe's cave scene with the planes at 0.05 and 256 the surface toward
the light was missing from the shadow map. A shaders/ pack's shadow pass now
culls by the box's sides only; Horizon's own packs keep their planes.

Fixes #10403

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 09:06:26 +05:00
Florian ERNSTandCodex 1e1c6a900f Decode numeric damage components with Codec.INT semantics
Co-Authored-By: Codex <[email protected]>
2026-11-06 05:03:08 +01:00
voidsip ff381b5dc4 fix(server): reject unreadable lists in stopped moderation commands 2026-11-05 22:02:46 -06:00
HiuoyandClaude Opus 5.5 7dc8ba568f Native mods: entity model layers built in code, baked and drawn (#10425)
A NeoForge mod builds its entity models in code (createBodyLayer: a
MeshDefinition of CubeListBuilder boxes at PartPoses, a LayerDefinition),
registers them in EntityRenderersEvent.RegisterLayerDefinitions, bakes them
with EntityModelSet.bakeLayer and draws the ModelPart tree it posed in
setupAnim with ModelPart.render. Native mods get the same:

- the builders and ModelPart, ported from vanilla (mobs/model_layers.rs):
  box UV from texOffs, mirror, deformation, texture scale and visible faces
  as ModelPart.Cube bakes them; poses, reset_pose, get_child;
- native_layer_definitions, a collect hook asked once at start, keyed by
  ModelLayerLocation ids in the mod's own namespace;
- native_bake_layer: a registered layer, or vanilla's by its
  entity-models.json name (minecraft:zombie#main);
- native_draw_model_part: ModelPart.render under a mob's or block entity's
  frame, textured from an atlas sprite.

The humanoid mesh built with these builders bakes to exactly vanilla's
minecraft:zombie#main (an ignored test against the prepared resources).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 08:02:00 +04:00
Ludovic Marie a1710b6962 Merge pull request #10429 from horizon-rust/perf/seen-snapshot
🤖 Agent: the kept buffer is cleared at the end of every tick, so no villager Arc<villager::Seen> outlives the tick, and #[inline] on Mob::seen only changes where each Seen is written (in its slot instead of a stack copy): same values, same order. creature_prepare 1.2 → 0.8 ms in both A/B rounds.
2026-11-06 04:56:53 +01:00
voidsip e7956591f3 Match vanilla replacement decoding for packet strings 2026-11-05 21:53:19 -06:00
Horizon agentandClaude Sonnet 5.5 4b352e89c1 Options: the emote key tests read a versioned options.txt, rustfmt
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-06 04:52:55 +01:00
Horizon agent 2726d8040b Merge remote-tracking branch 'origin/main' into perf/seen-snapshot
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
2026-11-06 04:52:00 +01:00
Horizon agentandClaude Sonnet 5.5 6da74ac228 Options: the emote key lives in horizon-extras.txt, options.txt is vanilla's again
Options parity regression from #10092: key.emote was in the vanilla key list, so options.txt
gained a key_key.emote line and every key after it shifted. It is now a Horizon-only mapping
(HORIZON_MAPPINGS): bound in Controls and translated like the others, saved as emoteKey in
horizon-extras.txt. A key_key.emote line written by the broken build is read once and dropped.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-06 04:44:03 +01:00
voidsip 6716cb7407 Merge remote-tracking branch 'refs/remotes/validation/worker7-main' into fix/4545-boat-paddle-transition
# Conflicts:
#	crates/horizon-client/src/game/remote_vehicles.rs
2026-11-05 21:43:23 -06:00
yahya-nusseibehandClaude Opus 5.5 58d5f3b519 Merge main into fix/nbt-heap-quota
Brings the branch up to date and re-runs CI: verify failed once in
horizon-relay's slow_first_lines_and_too_many_connections_are_closed (a
timing test, 5.6 s against its 3 s on a loaded runner), which this
change doesn't touch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-06 05:43:15 +02:00
voidsip 8181ce0222 Reset mob fall distance before travel 2026-11-05 21:42:45 -06:00
Ludovic Marie 76b8b4eb83 Merge pull request #10423 from horizon-rust/fix/sculk-ticking-mobs
🤖 Agent: a mob that did not tick still gets its last position refreshed (no step), so one moved by a command while out of range doesn't count a walk or a landing once it ticks again; damage and mount events stay for every mob since vanilla raises them from hurt/startRiding. Proven on vanilla 26.3 with ore_probe2.py (settled pig/item stop lighting redstone ore once the player is 6 chunks away).
2026-11-06 04:40:22 +01:00
Florian ERNSTandCodex 6303017b03 Classify the verified hostile sound families as hostile
Co-Authored-By: Codex <[email protected]>
2026-11-06 04:38:19 +01:00
Horizon agent e16a5ef0da Merge remote-tracking branch 'origin/main' into fix/sculk-ticking-mobs 2026-11-06 04:37:28 +01:00
voidsip 76addba6cc Tick remote boat paddles and stop empty boats rowing 2026-11-05 21:36:53 -06:00
Skr1zzzz cc643f4af4 Random ticks: a section's candidate mask classifies each palette entry once
Rebuilding a changed or new section's random-tick candidate mask asked
whether the block state ticks randomly for each of its 4,096 blocks. A local
palette holds a handful of states: each entry is now classified once and the
mask built from the decoded palette indices. Single-value and global
palettes keep the previous path. Same mask, same draws.
2026-11-06 06:35:23 +03:00
TheMrZZandCodex gpt-6.1-sol c843c1aa05 Save the modeled mob loot-pickup flag
Write CanPickUpLoot on every save through the existing virtual getter, preserving the Allay and sulfur-cube overrides instead of retaining stale raw bytes. Cover decoded and mutated models plus frozen actual command reads.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-06 04:30:58 +01:00
Skr1zzzz 283c6723c0 Natural spawning: a start's height ends at the column's first empty block
NaturalSpawner.getRandomPosWithin draws Y in [minY, getHeight(WORLD_SURFACE)
+ 1], and ChunkAccess.getHeight is getFirstAvailable - 1: the top is the
first empty block. Horizon's world_surface already returns that block and
the spawner still added one, so every start could land one block higher
than vanilla's, in the air above the surface, where vanilla's draw never
goes; nextInt's bound was one larger too, so the draws differed from
vanilla's from there on.
2026-11-06 06:22:19 +03:00
Ludovic Marie 1532c9c153 Merge pull request #10331 from horizon-rust/perf/lan-entity-state-cache
🤖 Agent: idle entities (outside the simulation-level entity-ticking range) no longer run sendChanges, as in vanilla's ChunkMap.tick, so their 60-tick MoveEntityPos stops; a bare state (no data) is never paired, and the idle check follows the tracker's viewers (placed or held). tick_lan −1.66 ms (−24 %) in 3 A/B pairs.
2026-11-06 04:22:17 +01:00
voidsip 23a880843d Preserve custom model data flags through the item render bridge 2026-11-05 21:20:17 -06:00
voidsip 7b4634a912 Fix client steering for saddled pigs and striders 2026-11-05 21:20:10 -06:00
Ludovic Marie 2daa740748 Merge pull request #10412 from horizon-rust/fix/remote-player-offhand-nametag
🤖 Agent: queued. Every tracked entity now records what it rides, so any passenger (item/block display included) hides the player's tag as vanilla's isVehicle does; one risk checked: a removed vehicle leaves a stale id in passengers' vehicle, harmless since tags look up live entities.
2026-11-06 04:19:50 +01:00
Ludovic Marie cd547654d9 Merge pull request #10406 from horizon-rust/fix/aistep-ticking-mobs
🤖 Agent: the gate is the set of mobs the loop actually ticked (Creatures::ticked), not a second ticking-area test, so a mob that walks out of range during its own tick still finishes its pickups as in vanilla; proven against a vanilla 26.3 server (pickup_probe.py: only the ticking villager and zombie take their item).
2026-11-06 04:08:51 +01:00
Horizon agentandClaude Opus 5.5 0ed3868329 Name tags: any passenger hides its vehicle's name, as isVehicle does
Vanilla's LivingEntityRenderer.shouldShowName hides a name tag for any
entity with a passenger (`!entity.isVehicle()`). The client only counted
mobs, text displays, armor stands and interactions as passengers, so a
server that hangs its own name over a player with an item display, a
block display or any other entity still got the player's tag drawn
under it: two names (#5666).

Every tracked entity now records the vehicle SetPassengers puts it on,
and the name tags read that, whatever the passenger's type. The typed
lists stay for placing the riders.

The rest of #5666 was fixed already on main: the sword drawn in the
off hand by #5111 (each remote hand renders its own stack), the double
tag of custom-named players by #8457, and text display and armor stand
riders by #9754.

Fixes #5666

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 04:07:46 +01:00
SenjinandClaude Opus 5.5 7b80ab60c7 Shader packs: free the old buffers before a resize makes the new ones
A pack with large buffers (SEUS PTGI at 3440x1440) held both sets at once while resizing and ran
out of video memory going back to fullscreen.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 04:00:58 +01:00
voidsip 6beb50ebcd Preserve boolean glint overrides in item model views 2026-11-05 20:58:38 -06:00
Horizon agent c1d16aabde Merge remote-tracking branch 'origin/main' into perf/seen-snapshot 2026-11-06 03:55:25 +01:00
Horizon agent c0c3096e85 Merge branch 'fix/aistep-ticking-mobs' into fix/sculk-ticking-mobs 2026-11-06 03:55:04 +01:00
Horizon agent 74150d5ef8 Merge remote-tracking branch 'origin/main' into fix/aistep-ticking-mobs 2026-11-06 03:54:43 +01:00
Skr1zzzz d3678c9112 Atlas readback tests skip without an adapter, as the module's others do
CI runners have no GPU: the two material-atlas tests panicked in
request_adapter instead of returning like layers_land_in_their_cells.
2026-11-06 05:53:06 +03:00
DUzzL dfb9757d97 Evoker fangs: honour scoreboard teams when deciding allies 2026-11-06 03:50:47 +01:00
Skr1zzzz a58d5700f3 Merge remote-tracking branch 'origin/main' into server-tick-spans
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
2026-11-06 05:50:36 +03:00
Skr1zzzz 9cdcf66e69 Spans: inside the server tick's mob spawning, block and creature steps
A slow tick named spawn_natural, tick_blocks and tick_creature_entities but
nothing inside them. With 100 bots they take ~11, ~21 and ~10 ms a tick, and
an index for spawn_selection's nearest-player shortlist saved nothing: the
time was elsewhere. Their steps now have laps (sn_*, tb_*, tc_*), and the
natural-spawn pass counts its attempts and spawns (sa_attempts/sa_spawns,
one note a tick). They show the attempt loop itself: ~18,850 attempts a tick
for ~3 spawns (#3910). Only when HORIZON_SPIKES is set.
2026-11-06 05:48:23 +03:00
Horizon agentandClaude Opus 5.5 c442edabd9 Merge origin/main; idle states follow the tracker's viewers (held players too)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 03:43:53 +01:00
Florian ERNSTandCodex 29f04e40ed Pass the tool to inline fishing loot
Co-Authored-By: Codex <[email protected]>
2026-11-06 03:42:10 +01:00
Horizon agentandClaude Opus 5.5 04f6bd1214 Sculk events: only mobs and items that tick step, land and flap
Steps, landings, splashes, flaps and stepping on redstone ore come from
an entity's own tick in vanilla (Entity.move, applyEffectsFromBlocks
from LivingEntity.aiStep and ItemEntity.tick), and ServerLevel.tick
ticks only the entities in entity-ticking chunks. Here every loaded mob
and dropped item on the ground stepped on the block under it each tick:
a pig or an item that had settled on redstone ore kept lighting it after
the player went six chunks away.

Those events are now for the mobs tick_creature_entities ticked (and
those standing in the ticking area, as the sculk fixture posts before
the mobs tick) and the items in the ticking area. A mob that did not
tick keeps where it is as its last position, without a step. Damage and
mounting are raised by hurt and startRiding, so they stay for every mob.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 03:29:26 +01:00
TheMrZZandCodex gpt-6.1-sol 27d093c94f Use registered XP-orb UUID in saved command reads
Reuse the existing identity getter rather than generating a UUID per read. Cover repeated saved reads, absent raw input and stale raw UUIDs through the actual Game getter and command fixture.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-06 03:21:06 +01:00
voidsip 36c0d3f938 Predict fishing bobber block hits before client movement 2026-11-05 20:20:54 -06:00
DUzzL 6bd507f89d Treat bubble column as liquid when validating a respawn point 2026-11-06 03:20:54 +01:00
masa-masa-0314andZCode 7a846eebc6 Realms: the settings tab's render and simulation distances
RealmsSettingsTab's two distance sliders, fed by
getRealmTierConfiguration (asked until it answers, as
tierConfigurationFetched does) and saved with updateRenderDistance /
updateSimulationDistance only when a value changed, alongside
saveSettings' existing name, description and region update.

Co-Authored-By: ZCode <[email protected]>
2026-11-06 10:17:04 +08:00
DUzzL bd152b1ecf Piglins: compare armour and weapons when both or neither item is wanted 2026-11-06 03:13:01 +01:00
Florian ERNSTandCodex 6f9981bfbf Use the baby pig passenger attachment when seating riders
Co-Authored-By: Codex <[email protected]>
2026-11-06 03:10:51 +01:00
voidsip 6cf1b6b74c Keep command tags when converting mobs 2026-11-05 20:10:33 -06:00
DUzzL 12454e54bd Port ResetUniversalAngerTargetGoal to iron golems 2026-11-06 03:08:44 +01:00
Horizon agentandClaude Opus 5.5 6b2c83656c Mob snapshot: build each Seen in its slot and keep the buffer
tick_creature_entities snapshots every loaded mob (`Seen`, ~400 bytes)
before the loop and again after each mob's tick. Mob::seen lives in the
client lib and was not inlined into the game crate, so each one was
built on the stack and copied into its slot, and the snapshot's
megabytes were allocated and freed every tick.

Mob::seen is #[inline] now, and the emptied buffer stays in Creatures
for the next tick. The snapshot holds the same values.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 03:07:34 +01:00
voidsip d7e1eaf955 Apply melee extra knockback to boats 2026-11-05 20:06:28 -06:00
08393ef6f7 Parse entity-models.json once at startup
Part of #9998.

Startup parsed entity-models.json (2.4 MB) into a serde_json::Value six
times: for the handoff's GameCpuResources, MobTypes, the player type, both
player arm models and the spin-attack model. The rest worker did five of
those one after another. Nothing in the document changes during a loader
generation, and a pack change starts a new one.

load_resources now parses it once on its own step beside load_core and
shares it as an Arc<EntityModelDocument> with every model loader. A
missing or malformed document leaves each caller on its old path, so
errors and fallbacks are unchanged; a dedicated server draws no models and
skips the parse.

Callgrind, the whole resource load in the game (to "resources loaded"):
21.576 G -> 20.631 G instructions (-4.38%); the entity-model path alone
1.594 G -> 0.349 G (-78.1%). A new test builds MobTypes from a shared
document and from its own parse and compares every type, model and layer.

A first draft by DeepSeek V4.1 Flash (pi coding agent), reviewed by Claude.

Co-Authored-By: pi coding agent (DeepSeek-V4.1-Flash) <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Fekd3xkgubE8VfXrALh2td
2026-11-06 03:04:20 +01:00
ebe3818eb7 Light a section from its palette's light info, not its state ids
light_chunk filled its 48x48xheight region from every non-air section by
unpacking 4,096 state ids into a scratch array, then reading each one
back, looking up LightTables::info and storing 2 of its bytes. That round
trip was 35% of a light job, and the flood fill only ever reads those 2
bytes.

PalettedContainer::unpack_ids_mapped decodes the packed storage through a
lookup table in one pass (out[i] = map[id of entry i]), with a fast path
for 4 bits per entry, a linear palette's usual width. light.rs's new
states_of maps a section's palette to the packed light info once (at most
256 entries), then copies the region's 16-wide rows from the staged
values. A section whose palette emits light stages full infos and seeds
block light and the queue per cell, in the same order as before. Air now
writes info[0], which is 0, the value the region already holds (asserted
in debug builds).

Callgrind, gen_light_bench --seed 1234 --radius 4 --threads 1 (81 lit
columns): the light job 1.991 G -> 1.260 G instructions (-36.7%), the
whole run -1.15%. The light job is 5-7% of the workers' CPU, so this is
about 2% of the chunk pipeline.

Exact: gen_light_bench's block and light checksum is identical for three
seeds and centres at 1 and 4 threads, light_chunk_matches_old_algorithm
(an independent light implementation over random 3x3 regions) passes
unchanged, and the new section_states_match_the_unpacked_ids compares
states_of with a walk over unpacked state ids for one value, 4 to 8 bits
and the global palette, with and without light sources.

A first draft by DeepSeek V4.1 Flash (pi coding agent), reviewed by Claude.

Co-Authored-By: pi coding agent (DeepSeek-V4.1-Flash) <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Fekd3xkgubE8VfXrALh2td
2026-11-06 03:04:19 +01:00
CocofireHDandopencode 7c57f5f906 prepared files: treat a killed export as unused
A killed exporter left an empty gameplay-facts.json (or another table) behind.
The preparer only checked that the path exists, so "prepare again" kept the
empty file, and the launcher called the folder prepared and started the game
on it. Trust an earlier export only when it still parses, and treat a folder
with an empty top-level JSON table as not prepared so Play prepares it again.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-06 03:01:43 +01:00
voidsip 11dda135a8 Attribute bursts of short LAN events in slow tick notes 2026-11-05 19:52:54 -06:00
CocofireHDandopencode 7cc9057fa6 Extras: keep the Zoom hint out of the Mods page's way in
The not-installed Zoom row pushed Page::Mods from Extras, which
every_setting_and_page_is_reached_from_one_place refuses: Options > Mods
already has the one way in. It is now a note that says where to install
Zoom; once the mod is built in the row is still its switch and Settings
button.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-06 02:47:58 +01:00
voidsip 8a65d28c0d fix(server): recover interrupted region pruning before startup 2026-11-05 19:47:51 -06:00
Ludovic Marie 69fab597a6 Merge pull request #10203 from Detimes/feat-10174-mod-deps
🤖 Agent: Checked on the desktop against main 9f8f6842d (merges cleanly, lock passes --locked). All 19 new lock packages are permissive (MIT/Apache/BSD-3/ISC-style webpki/Zlib/BSL option on ryu); the only build scripts (mysql, mysql_common) just pass target env vars, no downloads; TLS is rustls+ring only, flate2 uses miniz_oxide, no openssl/native-tls/-sys/cmake. cargo tree for horizon-client (default, --no-default-features, --target all) is identical to main, so players' game and native-mod compile are unchanged; the launcher's cold cargo fetch gains 21 crates / +2.5 MB once (noise in wall time). Note for later: webpki 0.22 and rustls-pemfile are unmaintained upstream; worth moving to mysql 28 when it no longer rewrites the lock.
2026-11-06 02:45:58 +01:00
Florian ERNSTandCodex 8dd47b5ef2 Omit default hive block state from saved item patches
Co-Authored-By: Codex <[email protected]>
2026-11-06 02:37:28 +01:00
voidsip 790e5f14d4 docs: index design server and release guides 2026-11-05 19:35:16 -06:00
CocofireHDandopencode 3c01dce30d resources: name the JSON file a parse error came from
serde's "expected value at line 1 column 1" says nothing about which of the
prepared folder's tables is damaged, so a player with a killed export only saw
a crash report. Read the core tables through horizon_world::json_file, which
prefixes the path, and have the startup check name a top-level table left
empty before the window opens.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-06 02:28:51 +01:00
Skr1zzzz 643d699d51 Player list: keep the shown list's layout between frames
While Tab is held every frame sorted every entry twice (once for the faces,
once for the rows), allocated team keys inside the comparator, rebuilt and
measured every row's text, split the header and footer, and pruned the
health states with a scan per row (#10108). TabList now keeps the sorted
order and the laid-out rows, rebuilt only when their inputs differ (players,
scoreboard, header/footer, width, font and language); the faces reuse the
order; sort keys are borrowed; the health states go by a uuid set. Hearts
still animate every frame.
2026-11-06 04:24:37 +03:00
Horizon agentandClaude Opus 5.5 66dc3229a0 Mobs outside the simulation distance pick nothing up
Mob.aiStep's looting, the villagers' item sensor and the fox, dolphin,
panda, piglin, allay and sulfur cube item passes run after the mob loop
over every loaded mob. In vanilla they are part of the mob's own tick,
and ServerLevel.tick ticks only the entities in inEntityTickingRange: a
villager six chunks from the nearest player, loaded but outside the
simulation distance, took the bread at its feet here and not there.

The loop records the mobs whose tick ran (Creatures::ticked), and those
passes leave the others alone.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 02:20:47 +01:00
AxxioandAntigravity 2948fb86ed game/maps: honour can_place_on when using a map on a banner in adventure mode
In adventure mode, vanilla allows ItemStack.useOn if the item's can_place_on component matches the clicked block. Previously, use_map_on_banner checked only !self.may_build() and returned early without checking can_place_on.

This honours can_place_on_in_adventure on the banner's block position, allowing adventure mode players with matching maps to toggle banner markers.

Co-Authored-By: Antigravity <[email protected]>
2026-11-06 02:18:05 +01:00
Ludovic Marie 9f8f6842db Merge pull request #10366 from horizon-rust/perf/sculk-entity-events
🤖 Agent: each mob footstep scanned every mob three times (two id lookups and the warden/allay listener loop); now an event with no sculk block in reach and no warden or allay within 24 blocks skips the context and the listeners. Listeners give up past 16 blocks before any side effect, so vibrations are unchanged.
2026-11-06 02:16:56 +01:00
TheMrZZandCodex gpt-6.1-sol 7d5d34ae32 Play ordinary arrow hits in the neutral sound category
Override only ArrowSound::HitGround through Audio::play_on. Add an actual Game/Java block-hit capture using the existing headless client, Studio recorder and official sound codec.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-06 02:16:39 +01:00
Ludovic Marie 1525ae0e3b Merge pull request #10364 from horizon-rust/fix/founder-claim-use-checkout
🤖 Agent: launcher-only fix. Maps the service's 403 use_checkout / not_offered_here to their own messages (86 tables), and drops one_click after use_checkout so the Shop opens the free checkout. Risk: none on the hot path; only the claim's error branch changes. Launcher tests 719 passed on the desktop.
2026-11-06 02:09:16 +01:00
Ludovic Marie 09450fb6f9 Merge pull request #10361 from horizon-rust/perf/mob-loop-overhead
🤖 Agent: villager_pickups tested every loaded villager against every dropped item, looking up tags before the reach: that was 12.8 of 23 ms of the mob phase in the load test (22.7 → 8.1 ms after this). The new ItemScan keeps the full scan's order and falls back to it if the item list changes under it.
2026-11-06 02:07:26 +01:00
Florian ERNSTandCodex 97d414cc4d Load animal age and love with NumericTag integer conversion
Co-Authored-By: Codex <[email protected]>
2026-11-06 02:04:52 +01:00
Horizon agentandClaude Opus 5.5 53bc816fa1 Launcher: rustfmt the claim refusal test
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 02:03:19 +01:00
Ludovic Marie 2993ef0b20 Merge pull request #10116 from horizon-rust/pvp/legacy-knockback
🤖 Agent: merging. Every knock_back caller outside the legacy rules passes legacy = false, so the 26.3 path is unchanged. 1.8's air lift and the unhalved sprint push only run under the hosted-world switch.
2026-11-06 01:53:48 +01:00
Horizon agentandClaude Opus 5.5 1411281eea Launcher: a refused founder claim says why and leads to the checkout
The cosmetics service answers POST /v1/claim with 403 use_checkout where
Paddle sells (the set goes through its free checkout there) and 403
not_offered_here where the claim is left out. The launcher mapped neither,
so a player saw "Horizon's cosmetics service had a problem" (Discord bug
thread, 2026-11-05).

- Both codes are the Shop's own refusals now, each with its own words, in
  all 86 lang tables.
- After use_checkout the saved answer drops one_click, so the Wardrobe stops
  offering "Claim for free" and the Shop's button opens the free checkout on
  playhorizon.dev.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 01:52:42 +01:00
Skr1zzzz f0de57ae23 Merge remote-tracking branch 'origin/main' into mp-fps-4664
# Conflicts:
#	crates/horizon-client/src/game/scoreboard.rs
2026-11-06 03:52:21 +03:00
Ludovic Marie 12d7eaccdb Merge pull request #10115 from horizon-rust/pvp/legacy-server
🤖 Agent: merging. [combat] is only written when legacy is on, and unknown keys are rejected. The game reads it in dedicated.rs, and a bad section logs and keeps 26.3's rules. The test now looks for the section line, not the template's '# [combat]' comment, which made verify fail on the first head.
2026-11-06 01:51:17 +01:00
Horizon agent 92bc3bc40f Merge remote-tracking branch 'origin/main' into perf/sculk-entity-events 2026-11-06 01:50:16 +01:00
Horizon agent d6d7cc3e29 Merge remote-tracking branch 'origin/main' into perf/mob-loop-overhead 2026-11-06 01:49:56 +01:00
Ludovic Marie 342824b506 Merge pull request #10114 from horizon-rust/pvp/legacy-rules
🤖 Agent: merging. legacy_rules() is remote.is_none() && combatRules, so a window on any server keeps 26.3's cooldown and damage. Measured over LAN: 1.8 rules 7 and 7 (diamond axe, charged then right after a swap), 26.3 9 and 3.6.
2026-11-06 01:48:43 +01:00
Skr1zzzz 2f075689f5 Multiplayer: skip name tags whose entity is off screen before laying out their text
Every frame built, styled and measured the name tag of every player within
64 blocks, drawn or not: with 150 players and the camera facing the sky that
was ~0.74 ms a frame (#4664). A tag is now built only for an entity vanilla
would render, its culling box grown by 0.5 in the frame's frustum
(EntityRenderer.shouldRender), checked before any text work; armor stands
likewise. Status and native snapshot queries still see every tag.

other_players finds each player's entity through one id->index map instead
of scanning every entity per player (the first match, as iter().find).
2026-11-06 03:47:29 +03:00
Ludovic Marie 195515000a Merge pull request #10112 from horizon-rust/pvp/legacy-effects
🤖 Agent: merging. The 1.9 hit sounds and particles are filtered on receipt (remote.rs, lan_particles.rs), so nothing sent changes. Hurt sounds and crit particles stay, and 1.8 hit sounds are off by default.
2026-11-06 01:46:16 +01:00
rison1337andCodex GPT-6 3f11bff706 Keep blob-shadow culling enabled with native mods
Remove the blanket trait-mod presence bypass while preserving shader-pack guards. Verify the projected shadow in a game with an active native camera hook.

Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-06 05:44:30 +05:00
Ludovic Marie 1ff952a4f0 Merge pull request #10111 from horizon-rust/pvp/legacy-hand
🤖 Agent: merging. Legacy only moves the hand's matrices, and only for items with vanilla's flat display (pack-moved and post-1.8 items keep 26.3's). It's off by default, and the renderer is the only reader of the equip state it changes. On scripted fights against a vanilla server, #10117's Modern-vs-Legacy packet comparison matched on every scenario that is stable between two identical clients.
2026-11-06 01:43:50 +01:00
CocofireHDandopencode 27b110085e Docs: note the Zoom row on the Extras page
Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-06 01:40:05 +01:00
CocofireHDandopencode 5bb149a282 Extras: a Zoom row that reaches the mod that replaced the extra
Zoom moved from the built-in extras to Horizon's official native mod
(#2030), so the Extras page lost its Zoom setting. Players looking for it
there, including the macOS report in #8713, found nothing.

The Extras page now keeps a Zoom row: with the mod built in it is the mod's
own on/off switch plus a Settings button that opens its page; without the
mod it opens the Mods page where it is installed. The cause is not
macOS-specific: every platform lost the row when Zoom moved.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-06 01:40:04 +01:00
rison1337andCodex GPT-6 4b3116c54f Keep flame culling enabled with native mods
Remove the blanket trait-mod presence bypass. Retain shader-pack and TAA guards, and document the native camera-hook image checks.

Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-06 05:37:40 +05:00
PzmuV1517 7b2ce7a1bc Shaders: the shader screen's words in every language
The shader screen's 17 new captions and descriptions, in the 86 languages besides English.
2026-11-06 02:30:40 +02:00
PzmuV1517 9e90050c60 Shaders: the old shader pack pages go
The shader screen (shader_ui) draws the pack list and every pack's settings,
so the options screen's own rows for them go: the Shader Packs and Shader
Options button lists, a Horizon pack's option pages, their actions and the two
pack slider kinds, with the label fitter only they used and the four lang keys
only they read.
2026-11-06 02:30:40 +02:00
PzmuV1517 131d5d73e2 Shaders: the shader screen reads the picked pack's settings however it opens
A button that pushes the Shader Packs page (as one on the Options screen would) listed the packs but left the picked pack's settings unread until a pack was picked again. Every way into the screen now reads them with the list.
2026-11-06 02:30:40 +02:00
AxxioandAntigravity b08f1074b1 docs: remove stale "not ported" notes
Co-Authored-By: Antigravity <[email protected]>
2026-11-06 01:28:10 +01:00
AxxioandAntigravity 9a076b6a24 docs/README.md: index missing player, launcher and platform pages
Co-Authored-By: Antigravity <[email protected]>
2026-11-06 01:26:57 +01:00
Horizon agentandClaude Opus 5.5 b40c996fb9 Mob looting: walk only the items in the cells a reach meets
A mob near some dropped item tested every item on the level (2,000 in
the load test: 1.5 ms a tick). ItemAreas now keeps the items of each
cell, and ItemScan walks the list in its order from one item in a met
cell to the next, testing what the scan tested; it keeps up with the
items the walk removes, and tests every item from there on if the list
changes any other way. The villagers' pickups walk the same way.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 01:26:11 +01:00
PzmuV1517 f488baa0ab Shaders: the shader pack list and each pack's settings in the settings frame
Shader Packs... opens a screen in the frame of the graphics and controller
settings (settings_shell) instead of two vanilla button lists. Its first page
lists the installed packs (a tick box on the one in use; picking it again
turns shaders off), the pack folder, and Horizon's own Shadow Cache under
"Built-in settings". Under the picked pack's name, the page list holds the
pack's own screens (a shaders/ pack's screen.NAMEs, a Horizon pack's
[[pages]]), as "Pack settings": switches, sliders, choices, profiles and links
to deeper screens, with the pack's own descriptions in the strip. The search
finds an option on any of the pack's screens. A shaders/ pack's changes apply
with Apply or when the screen closes; picking another pack keeps the last
one's changes first.
2026-11-06 02:24:17 +02:00
Florian ERNSTandCodex 1d13a300fa Keep guest respawn world information separate from personal landing
Co-Authored-By: Codex <[email protected]>
2026-11-06 01:23:39 +01:00
Horizon agent 99e1caf4db Merge remote-tracking branch 'origin/main' into perf/sculk-entity-events 2026-11-06 01:22:32 +01:00
Horizon agent 400335608e Merge remote-tracking branch 'origin/main' into perf/mob-loop-overhead 2026-11-06 01:22:31 +01:00
CocofireHDandopencode 54642a5e80 Pin the hopper minecart's one-item-a-tick transfer (#8668)
MinecartHopper.tick has no transfer cooldown in 26.3: it calls tryConsumeItems every tick, and consumedItemThisFrame caps a tick at one item even when makeStepAlongTrack tries too. The reported pre-#2250 whole-stack pickup is gone; add the regression test that catches it coming back, and cite the per-frame cap next to hopper_cart_suck.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-06 01:20:48 +01:00
Ludovic Marie e5875b0b19 Merge pull request #10342 from horizon-rust/agent/native-call-attribution
🤖 Agent: launcher-only fix in native mod error tracing; fmt/clippy/613 lib tests green on the desktop, new test fails without the fix.
2026-11-06 01:18:17 +01:00
Horizon agentandClaude Opus 5.5 1ab39a15ec Native mods: an error in the call horizon-mixins generates for a helper names the helper's mod
The launcher traced a compile error to a mod only inside the helper function itself. horizon-mixins
wraps each helper in a block that calls it, `{ fn helper(…) { … } helper(…) }`. When Horizon
changes a type the helper's signature mentions (flair 1.0.0: `extra` became five-element tuples),
the error lands on that generated call, so no mod was named and the whole build failed instead of
leaving that mod out. A block holding only the helper and its call now counts as the helper's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 01:16:25 +01:00
rison1337andCodex GPT-6 5905a4977f Skip offscreen burning-entity flame geometry
Cull conservative bounds of flame billboards before light lookup and vertex expansion. Keep shader/mod/TAA paths and unsupported dimensions, with emitted-geometry and edge-image coverage plus paired FPS samples.

Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-06 05:16:18 +05:00
Skr1zzzz 98d37c0887 Spans: the frame's prepare steps
HORIZON_SPIKES named almost nothing inside bench's prepare phase: with 150
players on a server and the camera facing the sky, prepare took ~0.43 ms a
frame and its spans accounted for ~0.005 ms. Each step between the fixed
ticks and the renderer now has a lap (pr_hud, pr_entities, pr_lights,
pr_frame, ...), dynamic lights two finer ones. They showed the cost: name
tags (eg_name_tags) built every frame for every player, drawn or not (#4664).
2026-11-06 03:13:25 +03:00
CocofireHD be24d83d97 Merge remote-tracking branch 'origin/main' into fix/10273-reoccuring-freezes-on-diamondfire-server-mcd
Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-06 01:12:19 +01:00
CocofireHDandopencode 89ca6821b8 test: keep docs/README.md links and benchmark index resolving
Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-06 01:10:13 +01:00
CocofireHDandopencode 4eafef1180 docs: index the benchmark and performance result pages
Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-06 01:10:11 +01:00
CocofireHDandopencode 561f797c04 Multiplayer: a guest server's resent columns keep the packet's light
A resident column the server sends again was always relit here, for a
Horizon server's bulk edits whose packet carries the light from before
its relight. A proxy's next server resends its whole world at the same
coordinates, so every column queued light jobs for itself and its eight
neighbours, which a small CPU could not work through.

Relight a resent column only when the server announced Horizon's quiet
commands, the same feature flag Horizon servers send in configuration;
any other server's packet light is final, as vanilla applies it. A
reconfiguration resets the announcement until the new server sends its
own.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-06 01:05:31 +01:00
Evrim Cicek dcfa4b07f7 Merge upstream/main 2026-11-06 03:05:16 +03:00
Evrim CicekandClaude Opus 5.5 00b9953741 Dragon: hover's fly speed, Mth.wrapDegrees, Mth trig on the server step, scanning's 10 degrees
From review, each measured against vanilla:
- DragonHoverPhase.getFlySpeed is 1, not the default 0.6: a hovering dragon thrown upwards
  climbs back at vanilla's rate (new harness case, 12 commands off before).
- wrap / wrap64 are Mth.wrapDegrees: Java's % then one step of 360. rem_euclid lost the low
  bits of a negative angle (-10.123456 became -10.123444) every tick (unit test).
- The parts' placement, the strafe aim and the flap timing use Mth.sin / Mth.cos and vanilla's
  float casts.
- DragonSittingScanningPhase turns only while the player is more than 10 degrees off the
  heading, with Mth.atan2 and vanilla's degree constant; dist is clamped in both divisions.
- A dragon's parts start at the origin, as vanilla's part entities do until the first aiStep,
  so its first scanning turn aims from there.

The command execution harness gets "dragon scanning turn" (a dragon facing a survival player and
one turned away): 6/12 before, 12/12 now. All dragon and End harnesses match; the whole command
execution harness: 15832/15832.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 03:05:04 +03:00
Horizon agentandClaude Opus 5.5 e82ac45192 Format the idle-entity change
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 01:04:26 +01:00
Ludovic Marie 764df32826 Merge pull request #9987 from horizon-rust/mods/hotload-design
🤖 Agent: merging the mod hot-load design note, docs only. The point to check is the native measurement it rests on: a one-line gameplay change rebuilds in 570 s against 812 s cold, and mold doesn't help, which is why the run-time switch came before background builds.
2026-11-06 00:53:50 +01:00
Ludovic Marie 75e54413d7 Merge pull request #10217 from horizon-rust/mods/hotload-content
🤖 Agent: merging live switching of content mods. The point to check is the server's world tables: with no data pack they used to stay on the startup resources, so a switched mod's data never applied. After a live switch they are now built from the new root (data_switched), checked in game by a mod function that loads, unloads and loads again.
2026-11-06 00:53:47 +01:00
PzmuV1517 623aa1d3ea Shader packs: a texel offset given as a parameter moves the coordinate
GLSL asks a texel offset to be a constant expression; GL drivers also take one a
function receives as a `const in ivec2` parameter (Arc's FXAA samples its neighbours
that way, so its final pass was skipped in every dimension). Such an offset now moves
the coordinate by that many texels of the base level; a constant one stays an offset.
2026-11-06 01:52:10 +02:00
PzmuV1517 82e2c96a20 Shader packs: CAT_* and PPT_* are macros of the stage files too
The biome categories and precipitations custom uniforms compare with are defined for the
GLSL as well, with the same values: Sildur's Enhanced Default reads
`biome_category == CAT_THE_END` in gbuffers_skytextured, which failed on the unknown
name. BIOME_* still needs the world's registry and stays in custom uniforms.
2026-11-06 01:52:10 +02:00
PzmuV1517 ee50f8ae1a Shader packs: empty declarations, precision on parameters, infinities in functions
Three things GL drivers take that naga's GLSL front end refused:
- an empty declaration (`;`) at global scope in any version, as a macro that writes a
  function leaves after it (DrDestens: 56 programs skipped);
- a precision qualifier on a function parameter (`float rand(highp vec2 p)`,
  TrailerShaders: final and composite);
- a constant expression in a function whose value is infinite or NaN
  (`1.0 / toeX` with toeX 0, Epoch's tonemap at its defaults): it is computed at run
  time, as GL does, instead of folded into a literal that can't hold it. WGSL keeps
  refusing it.
2026-11-06 01:52:10 +02:00
PzmuV1517 bfc40195eb Shader packs: texture() and textureLod() read integer samplers
GLSL lets a pack sample a usampler2D or isampler2D with texture(); GL drivers read an
integer texture with nearest filtering, so it is the texel the point falls in. naga
rejects sampling an integer image, and Mello, Voyager, Exposa and Reverie lost the
programs that read their integer colortex buffers that way (Mello 20, Voyager 42, Exposa
48). Those calls now go to helpers that texelFetch the texel under the point, clamped to
the edge, at level 0 (textureLod: the level it names).
2026-11-06 01:52:10 +02:00
PzmuV1517 cb65d4f80d Shader packs: a global constant reads an element of a constant array
`const vec4 s = vec4(scale[0].x, ...)` over a constant array read the index and the
array from the function's arena while folding in the module's: naga panicked (index out
of bounds) and Ebin Resurrected failed to load. Both now come from the global arena.
2026-11-06 01:52:10 +02:00
PzmuV1517 04116c33c6 Shader packs: real_packs reads past a pack that panics and sums up each pack
A panic in one pack no longer stops the run: it is caught, and each pack ends with a
SUMMARY line of the programs skipped in each dimension. A pack without shaders.properties
keys is read as one (every key at its default), not a failure.
2026-11-06 01:52:10 +02:00
PzmuV1517 1da773a501 Shader packs: a matrix built from one vector takes its components column by column
`mat2(v)` with a vec4 `v` is the matrix with columns v.xy and v.zw in GLSL, as when the
components come as several arguments. naga's GLSL front end made each column the whole
vector, which failed validation ("Composing 0's component type is not expected"):
Bloop's water normals (prepare1) were skipped.
2026-11-06 01:52:10 +02:00
PzmuV1517 a02c7fcd4b Shader packs: DH_BLOCK_* are defined, with or without Distant Horizons
Packs compare block ids with Distant Horizons' block materials outside any
DISTANT_HORIZONS guard (Exposa: `blockID == DH_BLOCK_LEAVES`), so Iris defines them in
its standard macros whether the mod is there or not. Exposa's composite and composite1
failed on the unknown names; DH_BLOCK_UNKNOWN to DH_BLOCK_ILLUMINATED are now 0 to 15,
the mod's EDhApiBlockMaterial numbers.
2026-11-06 01:52:10 +02:00
PzmuV1517 19f87e8059 Shader packs: an integer vertex output travels flat without saying so
GLSL asks a fragment input of integer type to be `flat`, but since 4.30 only the
fragment stage's interpolation qualifier counts: a vertex stage may write
`out uint frag_worldTN;` and GL drivers link it to `flat in uint`. naga left such an
output without interpolation and refused it (Alpha Piscium: 111 programs). An integer
vertex output or fragment input with no qualifier is now flat.
2026-11-06 01:52:10 +02:00
PzmuV1517 83e2920c5e Shader packs: a paste that makes no single token stays its tokens
`colors2_colorspaces_ ## a ## _to_ ## b ##(x)` (Alpha Piscium) pastes the name's last
part to its `(`. The preprocessor made one token of `..._6_to_8(`, which named no macro,
so the call to the function-like macro `colors2_colorspaces_6_to_8` was left for the
compiler as an unknown function. As GL drivers' preprocessors do, the paste now stays the
tokens it is made of, and the name is rescanned with its `(`.
2026-11-06 01:52:10 +02:00
rison1337andCodex GPT-6 9734a8a255 Cache unchanged entity name-tag glyph layouts
Retain bounded local text quads while updating camera transforms, lighting and cosmetic marks every frame. Invalidate on text/font/atlas/bidi changes and bypass animated or inline content.

Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-06 04:49:27 +05:00
Florian ERNSTandCodex cd7d6a3dcc Preserve inherited squid damage acceptance result
Co-Authored-By: Codex <[email protected]>
2026-11-06 00:48:21 +01:00
Ludovic Marie e0bd7cd69a Merge pull request #8742 from Akirabane/feat/7484-request-logs
🤖 Agent: the request log reads the player's name before the request runs, so a release or drop that removes the guest still logs who it was. The e2e this PR adds ran on this exact stack against current main: 31/31 over real control sockets with horizon-bots.
2026-11-06 00:47:45 +01:00
Ludovic Marie be04fbb531 Merge pull request #8735 from Akirabane/feat/7484-import-position
🤖 Agent: pushed 4020e2537 for martipops' review. It refuses a rotation that overflows f32 (1e39 became inf/NaN after the cast), refuses a named position when the record still has a RootVehicle to restore (the vehicle would undo it), keeps recentered on retries, and documents that velocity is the server's body only. Tests added for each; union-tested on the desktop.
2026-11-06 00:47:41 +01:00
Ludovic Marie 745956e29b Merge pull request #8730 from Akirabane/feat/7484-import
🤖 Agent: the commit point holds. place_arrival writes the .dat with write_dat_durable plus the advancements and stats with write_durable, each a Result, before ok, and the player stays held on any error. Retries after the commit answer through placed_arrival. martipops' two reviews are answered point by point in the comment above. server_transfer_e2e passed on the final stack (31/31).
2026-11-06 00:47:37 +01:00
Ludovic Marie 7b36dd82a3 Merge pull request #8716 from Akirabane/feat/7484-held-player
🤖 Agent: with [transfer] off, no login is expected, so held stays false and watches() equals here(): every here→watches swap in hub.rs and the loading_chunks refactor are no-ops for ordinary servers. With it on, a held player is a viewer but not tracked, and gets list changes through broadcast_watching. Union-tested with main and the stack on the desktop, plus server_transfer_e2e (31/31).
2026-11-06 00:47:32 +01:00
Horizon agentandClaude Opus 5.5 153044c095 Sculk: a mob's game event costs nothing when nothing can hear it
tick_mob_sculk_events posts every loaded mob's footsteps, landings and
splashes. Each event looked its mob up by a scan of every mob (for the
context and the supporting block) and asked every mob whether it was a
warden or an allay, so a server with thousands of mobs paid three scans
of them for each footstep near no listener.

Now, as GameEventDispatcher only visits the listeners around the event:
while the mobs' events are posted, mobs are found by id through a slot
map, the wardens and allays are listed once with their positions, and
an event with no sculk block in reach and no warden or allay within 24
blocks skips the context and the listeners. A mob that stands as it
stood with no step due (every mob outside the simulation distance)
leaves at once, and a mob riding nothing, as before, skips the mount
bookkeeping. Mods still get every event (native_game_event).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 00:43:01 +01:00
Horizon agentandClaude Opus 5.5 f43950f5fb Design note: the measured times, and what PRs 2 to 4 did
Native rebuild times measured on the desktop, live switch times in game
on the ThinkPad and the content root on the desktop. Option A as built:
trait mods switch at run time, RenderHooks and the launcher's build-all
come next. The browser's buttons and index fetch as implemented.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 00:38:39 +01:00
Horizon agentandClaude Opus 5.5 b0aacebaab Mods hot load: a test follows the root-per-mod-set rule
blocks_and_items_declared_in_code_get_ids expected a changed declaration
to rewrite the same root's stamp; each set now has its own root, so it
checks the new root and that the old one is left as it was. Found by
running the content-root tests on real prepared resources.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 00:37:43 +01:00
Horizon agentandClaude Opus 5.5 1d8a1699ad Mob loop: villagers test reach before item tags, no player view copies
villager_pickups tested every loaded villager against every dropped item,
looking the item up in the villager food table and tags before the reach:
on a server with hundreds of villagers and thousands of items that was
most of the mob phase. The reach comes first now, and a villager with no
item in its cells skips the scan (ItemAreas, as the looting passes do).

Mobs outside the simulation distance only check despawning, which reads
the nearest player and the difficulty: they no longer copy the player
view they see. A ticking mob borrows its guest's view for its tick (the
tick reads it through &MobWorld) instead of cloning it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 00:30:30 +01:00
Ludovic Marie 42e6d9cc49 Merge pull request #10263 from foudretbr/fix/unknown-block-state-panic
🤖 Agent: unknown states now read as air at the three places they enter (chunk palettes, block updates, BlockFacts::get), and the palette scan is skipped when nothing is unknown, so a vanilla-width chunk costs one maybe_has over its palette. Vanilla would disconnect on such an id (MissingPaletteEntryException) instead; air matches what Horizon already did for a wider host, so I took it. Union-tested on the desktop (clippy, wide_states, horizon-items).
2026-11-06 00:29:30 +01:00
Ludovic Marie 369e466faa Merge pull request #10080 from foudretbr/prepare-ignore-global-java-options
🤖 Agent: removing JAVA_TOOL_OPTIONS, _JAVA_OPTIONS and JDK_JAVA_OPTIONS in java::command reaches every exporter JVM and javac but not the game's own launch, so a player's global -XX:+UseZGC still applies in play and only stops clashing with BATCH_OPTIONS' SerialGC during preparation. Built and tested in a union on the desktop (clippy, horizon-prepare tests).
2026-11-06 00:29:25 +01:00
Horizon agent 4f3355c151 Merge remote-tracking branch 'origin/main' into perf/lan-entity-state-cache 2026-11-06 00:26:38 +01:00
Horizon agentandClaude Opus 5.5 34ddea78e1 Merge #8735's review fixes into #8742
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 00:22:26 +01:00
Horizon agentandClaude Opus 5.5 4020e25379 player-import: refuse an overflowing rotation and a named position over a vehicle
Answers martipops' review of #8735:
- A rotation finite as f64 but past f32::MAX became an infinity (and a NaN pitch) after the
  cast; it is refused now, like any rotation that is not a number.
- A record with a vehicle still to restore (RootVehicle) seats the player back on it at the
  vehicle's own place, which undid a position the request named; the two are refused together.
- A retried import of the same hand-over, or a retry after the commit, answers the same
  `recentered` as the first reply (kept on the guest until it is placed).
- docs/server.md says the velocity is the server's only: the arrival's teleport carries no
  motion, as a vanilla login's does not.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 00:22:22 +01:00
Horizon agentandClaude Opus 5.5 7dcc5b46f3 Server: send idle entities' changes as ChunkMap does, describe them bare (#10253)
ChunkMap.tick runs ServerEntity.sendChanges only for an entity that changed
section, needs sync, or is in DistanceManager.inEntityTickingRange. Horizon
ran it for every tracked entity, so an idle one sent its position every 60
ticks and advanced its update clock. EntityState::idle now carries the
range (the simulation level, Levels::in_entity_ticking_range), and the
tracker skips sendChanges for an idle entity that stayed in its section and
needs no sync; a lead change still goes out at once (Leashable).

The game then stops describing such entities in full every tick: an idle
mob or item that was in the tracker's last pass at the same place, carries
no passengers, and is already tracked by every client within its tracking
range is described bare (where it is, none of its data). The tracker never
reads its data and never pairs a bare state, so every pairing still sends
the entity as it is (game/lan_idle.rs).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 00:21:37 +01:00
Skr1zzzz d5479156d7 Multiplayer: the hidden player list and its skins stop costing per player
draw_tab_list ran every frame and, through remote_tab_players, cloned every
listed player and formatted two object-skin keys for each before it knew
whether the list was shown; update_remote_skins then searched every entity
once per level player and cloned the whole skin map. With 150 players and
none drawn a frame still cost ~0.4 ms more than with none (#4664).

- The list's visibility comes from the listed count; the players are built
  only while it is shown or when it opens/closes (narration).
- The id:/name: object skins publish only when a player, its skin or the
  atlas's object skins changed (a fingerprint and an atlas revision), so
  inline faces keep the server's skin as before.
- update_remote_skins finds players and mannequins through one id->index map
  and moves the resolved skins in instead of cloning them.
2026-11-06 02:18:39 +03:00
Ludovic Marie 2b74332fbe Merge pull request #10117 from horizon-rust/pvp/wire-stream-legacy
🤖 Agent: merging. The default --clients vanilla,horizon run is unchanged, and so is main's one-client capture (now 'one or two of'). horizon-legacy only adds a horizon-extras.txt in its own scratch home. On the desktop, Modern vs Legacy matched on 14 of 16 scenarios; eat and dig_teleport also differed between two identical Modern clients there.
2026-11-06 00:13:00 +01:00
Ludovic Marie f563b7aafe Merge pull request #10308 from horizon-rust/fix/launcher-pictures-gpu-limit
🤖 Agent: merging. Pictures are now sized to the window × scale and capped by the atlas limit (the wgpu adapters' min on Linux, 16384 on Metal/DX), instead of a fixed 3840. RSS stays flat at 173 MiB for 5 min on a real GPU. One thing to watch: the Linux probe opens its own Vulkan/GL instance at startup on a background thread, and until it answers pictures cap at 8192.
2026-11-06 00:12:56 +01:00
Skr1zzzz b73ef3faa3 Shader packs: material maps share the stitched colour atlas's layout
With a stitched colour atlas (sprites at their own sizes), the normal and
specular atlases were still packed as a grid by PackAtlas::material, so a
block's material texels sat under another sprite's colour (#9570). The
companion atlases now reuse the colour atlas's size, rectangles and padding;
each map is nearest-sampled from its single level into its sprite's
rectangle (an fs_material entry point), and animated sprites copy into the
same rectangles. The grid path is unchanged.
2026-11-06 02:12:54 +03:00
Evrim CicekandClaude Opus 5.5 b7841886cf A summoned Ender Dragon hovers where it was put, as vanilla's does
EnderDragon's constructor sets EnderDragonPhase.HOVERING; only EnderDragonFight.createNewDragon
sets the holding pattern. Dragon::new started every dragon holding its pattern, so a dragon from
/summon, a spawn egg or a save without DragonPhase flew off. It now starts hovering, and
create_new_dragon sets the holding pattern.

Hovering had never run on the server, and the flight step diverged there: the target is a hair
away, where Vec3.normalize returns zero under 1.0E-5 and vanilla's Mth.sin/cos table and
Mth.atan2 turn the dragon differently from the exact functions. The step now uses them, the
doubles of moveRelative's input vector and vanilla's 0.91F and 0.8F as doubles.

The command execution harness gets "summoned dragon hovers" (phase and position every 20 ticks
over 120): 0/13 before, 25/25 now. The dragon harnesses (server dragon, contacts, NoAI, not the
fight's, death outside the End, death roar, forced End, spawn eggs in fluids, dispensers) match.

Fixes #10285

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 02:11:40 +03:00
rison1337andCodex GPT-6 868e494b0c Cull off-screen blob shadows before scanning blocks
Use conservative projected-shadow bounds, retaining shadows whose casters are outside the view. Keep shader packs and native mods on the original path. Add resource-backed bounds coverage and measured FPS results.

Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-06 04:11:09 +05:00
AxxioandAntigravity 576c5942f1 launcher: fix account skin arm-type segmented control overflow and caption layout
In the glass theme account settings (skin_panel), long translated option
labels such as French 'Bras classiques' protruded outside the segmented
control pill due to an inherited ml(40px) margin and cross-axis stretching.
Additionally, the multi-sentence caption wrapped into a 10-line block inside
the narrow column, clipping off the bottom on 1080p displays.

- Remove the ml(40px) margin in glass theme mode.
- Set items_start() on the control column so segments size to natural width.
- Adjust segments option padding to 12px with flex_none and justify_center.
- Reorganize skin_panel so the caption spans full width across the bottom.
- Adjust Microsoft card width to 360px and reduce vertical padding in glass_who_plays.

Co-Authored-By: Antigravity <[email protected]>
2026-11-06 00:10:36 +01:00
Skr1zzzz 575fadbeeb Friends: Cancel and Escape go back to the screen that opened it
Join a Friend opens from the title screen's Friends button and from the
server list. Both exits always went to the server list, so the title's
entry landed the player on a list they never opened (#9190, item 10).
The page now remembers whether the title opened it.
2026-11-06 02:10:26 +03:00
Horizon agentandClaude Opus 5.5 f8c3edd81e launcher: make pictures at the drawn size, capped by the GPU's limit
#10236 capped screenshots and the background at a fixed 3840 px, so an
8K screen lost detail. Pictures are now made at what the screen shows:

- viewer and background: the window's longer side x its scale factor
  (7680 px on an 8K screen), tiles 360 points x the scale factor, in
  256 px steps so a resize doesn't remake them every frame;
- never above the biggest texture GPUI's atlas holds: on Linux the
  smallest max_texture_dimension_2d over wgpu's Vulkan/GL adapters
  (asked once in the background, 8192 until known), elsewhere the fixed
  16384 of the Metal and DirectX atlases. A larger picture would make
  the atlas add a max-size texture on every frame (zed#66595).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 00:10:21 +01:00
Florian ERNSTandCodex 6df80522aa Omit the saved lodestone tracker true default
Co-Authored-By: Codex <[email protected]>
2026-11-06 00:08:11 +01:00
MSedgeMC f16012e26c Entity saves: a UUID bridge for references to a mob
A save can now name an entity it only knows as a `Who`: `mob_to_nbt` takes a
`uuid_of` resolver, `who_uuid` writes a `Who` as a UUID and `who_by_uuid` turns a
stored UUID back into the `Who` of a loaded player or mob. `EntityMemory::uuid_of`
supplies the id -> UUID map the game layer keeps, and the game layer's `mob_by_uuid`
now resolves through the same seam.

The neutral mobs' `angry_at` is the first writer: anger at another mob now saves
that mob's UUID instead of nothing. The warden's suspect list and the shulker
bullet's target are the two follow-ups this bridge unblocks; neither is written
here.
2026-11-05 23:06:45 +00:00
foudretbr 5a92cfb6b1 launcher: retry native cache fixture exec on ETXTBSY and copy via temp rename
Under Linux, executing a freshly copied test fixture can fail with ETXTBSY (os error 26) if another test thread forked while the file was being written. Copy the fixture via a temporary file before renaming it into place, and retry exec/spawn if ETXTBSY occurs.

Fixes #10271
2026-11-06 00:04:15 +01:00
Horizon agentandClaude Opus 5.5 5ba18cae86 PvP & HUD: rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 00:01:54 +01:00
Horizon agentandClaude Opus 5.5 f06b417ac4 Server: the [combat] test looks for the section, not the template's comment
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 00:01:54 +01:00
Horizon agentandClaude Opus 5.5 346a0a8d81 lan_e2e: legacy-pvp hits right after a hotbar swap for an uncharged hit
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 00:01:54 +01:00
Horizon agentandClaude Opus 5.5 fc8781c2ce PvP & HUD: document Legacy's sounds and particles
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 00:01:54 +01:00
Horizon agentandClaude Opus 5.5 6d36365aeb PvP & HUD: rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 00:01:54 +01:00
Horizon agentandClaude Opus 5.5 3400a0316b PvP & HUD: 1.8 knockback under legacy combat rules
With the legacy rules on, a player's knockback follows 1.8.9's
EntityLivingBase.knockBack: the motion halves and the 0.4 push lifts in the
air too (26.3 lifts only from the ground), and knockback resistance is a
chance to take none instead of a cut. The sprint and Knockback push is added
on top as attackTargetEntityWithCurrentItem's addVelocity (0.1 up) instead
of going through knockback, for players and mobs alike.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 00:01:54 +01:00
Horizon agentandClaude Opus 5.5 4d4c3ff32f Server: [combat] legacy = true in server.toml for 1.8 combat rules
horizon-server accepts and keeps a [combat] section (off by default, left out
of the file while off); the dedicated game reads it itself, like
[anticheat], and turns on the legacy rules a hosted world's PvP page switch
gives. A wrong section is logged and fights keep 26.3's rules.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 00:01:54 +01:00
Horizon agentandClaude Opus 5.5 932050ffab lan_e2e: legacy-pvp scrolls the PvP page to its switch
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 00:01:54 +01:00
Horizon agentandClaude Opus 5.5 04c3b710cf PvP & HUD: Legacy leaves out the sweep, damage hearts and 1.9 hit sounds
Under Combat feel Legacy the client draws no sweep_attack or
damage_indicator particles a server sends (1.8.9 had neither), and with
'1.8 hit sounds' plays none of the entity.player.attack.* sounds 1.9
added. Hurt sounds, crits and enchanted hits stay. Only what this client
shows changes; nothing is sent.

1.8.9 drew no red tint over armour (LayerArmorBase.shouldCombineTextures
is false, so setBrightness skips it) and the body's flash is red at 0.3,
as 26.3's: both already match.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 00:01:54 +01:00
Horizon agentandClaude Opus 5.5 a6f2414081 PvP & HUD: document Combat feel
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 00:01:54 +01:00
Horizon agentandClaude Opus 5.5 62db23c23b PvP & HUD: rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 00:01:54 +01:00
Horizon agentandClaude Opus 5.5 eabc4160a8 PvP & HUD: Combat feel Legacy draws the hand as 1.8.9 did
Combat feel: Modern (default) or Legacy (1.8.9). Legacy changes only the
picture: a flat item in first person takes 1.8.9's ItemRenderer pose
(swing, eating and drinking, bow pull) through 26.3's own display
transform, a sword is held up while use is held (1.8.9's block pose,
swinging as 1.7.10 did), the main hand no longer dips after each hit,
and the attack indicator hides unless the player keeps it. The clicks
and every packet stay 26.3's. Poses 1.8.9 lacked (crossbow, trident,
spear, brush) and resource packs' own displays stay modern.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 00:01:54 +01:00
Horizon agentandClaude Opus 5.5 cccc296d9a PvP & HUD: the fall distance is already an f64
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 00:01:54 +01:00
Horizon agentandClaude Opus 5.5 270e435b3f PvP & HUD: 1.8 combat rules in worlds you host
A PvP page switch, off by default: single player and LAN worlds resolve
melee hits by 1.8.9's rules (EntityPlayer.attackTargetEntityWithCurrentItem):
no attack cooldown and no sweep, 1.8.9 weapon damage (ItemSword 4 / ItemAxe 3 /
ItemPickaxe 2 / ItemSpade 1 plus ToolMaterial), Strength x(1 + 1.3 per level)
and Weakness -0.5 per level, Sharpness 1.25 per level, and critical hits that
allow sprinting and need no charge. Only a game that is the server uses them;
a window playing on a server never does, so what it sends stays the same.

The window passes the switch to its server thread each frame, like the
distances. lan_e2e's legacy-pvp check measures two diamond axe hits 12 ticks
apart: 7 and 7 under the rules, 9 then a cooldown-cut hit without.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 00:01:54 +01:00
Horizon agent 5f625c748e Merge remote-tracking branch 'origin/main' into pvp/wire-stream-legacy
# Conflicts:
#	tools/test_wire_stream_parity.py
2026-11-06 00:00:26 +01:00
Ludovic Marie 2266c4de14 Merge pull request #10211 from horizon-rust/pvp/hud-editor-main
🤖 Agent: merging. These are #10079's commits merged with main and nothing else; #10079 had landed in the already-merged pvp/hud-layout. The editor opens only from a world and is the only screen that writes the layout.
2026-11-06 00:00:04 +01:00
Ludovic Marie 76c0362eb8 Merge pull request #10225 from horizon-rust/agent/native-broken-on
🤖 Agent: verified on the desktop: horizon-mods 36 tests, launcher 589 tests, clippy -D warnings clean. broken_on is read only from the registry index (the publish job adds it from horizon-mods' daily native-compat run). The warning shows only when the entry's Horizon is the commit the mod would build on, so a mod pinned elsewhere doesn't warn.
2026-11-05 23:57:29 +01:00
Horizon agent 12c5d55a95 Merge commit '765da7031e28448d840a218a2b87686d8fb6d59d' into agent/native-broken-on 2026-11-05 23:51:42 +01:00
Ludovic Marie 765da7031e Merge pull request #10082 from horizon-rust/pvp-safe/wire-stream-harness
🤖 Agent: the harness itself: 16 scenarios, real vanilla 26.3 client vs Horizon, per tick and field; on main 11/16 match, with the series + #9918 16/16. Named-only and solo in parity_hosts (needs a virtual screen, ~15 min). Merge of main resolved a game.rs mod-list conflict (tick_profile + tick_script).
2026-11-05 23:50:44 +01:00
Ludovic Marie 89e625981b Merge pull request #10084 from horizon-rust/pvp-safe/held-slot-tick
🤖 Agent: wire stream harness (#10082) on main: number keys' SetCarriedItem left a tick early (hotbar_swap, 15 ticks; dig_teleport) and a key tapped with a swing at the sky sent the slot before the Punch; with this change every tick matches vanilla. Head = cc7200cd7a's +35 −43 plus merges of #10083, #10085 and main (conflicts in remote.rs resolved to the tree the harness after-run built and passed).
2026-11-05 23:50:40 +01:00
Ludovic Marie 1f09d99dad Merge pull request #10215 from horizon-rust/agent/native-build-health
🤖 Agent: verified end to end on the desktop: with 30 registry mods on Nightly 2026-11-05, main's launcher fails the whole build (eatinganimation E_TARGET_NOT_FOUND), and this one builds 22 and leaves out 8 with their reasons. MIXINS_REV moves to 5a64af9 (horizon-mixins#27, #28). The risk to watch: a compile failure costs one full recompile per round, capped at 3.
2026-11-05 23:50:36 +01:00
Horizon agentandClaude Opus 5.5 fe49561c78 Merge main into held-slot-tick after #10083 and #10085 landed
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 23:48:21 +01:00
CocofireHDandopencode 22807a3f3a Audio: play multi-channel buffers direct like OpenAL
Stereo music and records were downmixed to mono and panned by the camera, so turning away hard-panned them and distance attenuation could silence them. OpenAL Soft plays a multi-channel buffer without spatialization or distance attenuation (AL_SOFT_source_spatialize auto), as vanilla's HRTF-off context does.

Keep the decoded channel count through the cache and mixer; mono buffers keep the existing panning and linear attenuation. Add a mixer test that stereo frames stay direct and unattenuated at any camera angle or distance.

Fixes #10037

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-05 23:47:14 +01:00
Ian WorleyandClaude Opus 5.5 b27965b4ca test: pass the drop-motion and install.sh signature tests on Apple silicon
Vanilla's Math.sin is HotSpot's intrinsic, which rounds one of the drop
test's throws a bit lower on AArch64 than on x86-64; a vanilla 26.3 run
on an M-series Mac gives the AArch64 value. The install.sh fixture only
holds the Linux x86-64 archive and ran the cases with /usr/bin's
OpenSSL, LibreSSL on macOS: a fake uname picks that archive, and the
cases get the OpenSSL 3 the script checked for.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 16:41:36 -06:00
Horizon agent 152eaed3bd Merge remote-tracking branch 'origin/agent/native-build-health' into agent/native-broken-on 2026-11-05 23:36:37 +01:00
Horizon agent efbd46aaad Merge commit '6d99e14ff0c70ae60bc56b67829b526409fa068e' into agent/native-build-health
# Conflicts:
#	launcher/lang/af.json
#	launcher/lang/ar.json
#	launcher/lang/az.json
#	launcher/lang/ba.json
#	launcher/lang/be.json
#	launcher/lang/bg.json
#	launcher/lang/br.json
#	launcher/lang/bs.json
#	launcher/lang/ca.json
#	launcher/lang/cs.json
#	launcher/lang/cv.json
#	launcher/lang/cy.json
#	launcher/lang/da.json
#	launcher/lang/de.json
#	launcher/lang/el.json
#	launcher/lang/eo.json
#	launcher/lang/es.json
#	launcher/lang/et.json
#	launcher/lang/eu.json
#	launcher/lang/fa.json
#	launcher/lang/fi.json
#	launcher/lang/fil.json
#	launcher/lang/fo.json
#	launcher/lang/fr.json
#	launcher/lang/fur.json
#	launcher/lang/fy.json
#	launcher/lang/ga.json
#	launcher/lang/gd.json
#	launcher/lang/gl.json
#	launcher/lang/haw.json
#	launcher/lang/he.json
#	launcher/lang/hi.json
#	launcher/lang/hr.json
#	launcher/lang/hu.json
#	launcher/lang/hy.json
#	launcher/lang/id.json
#	launcher/lang/ig.json
#	launcher/lang/is.json
#	launcher/lang/it.json
#	launcher/lang/ja.json
#	launcher/lang/ka.json
#	launcher/lang/kk.json
#	launcher/lang/kn.json
#	launcher/lang/ko.json
#	launcher/lang/ky.json
#	launcher/lang/la.json
#	launcher/lang/lb.json
#	launcher/lang/li.json
#	launcher/lang/lmo.json
#	launcher/lang/lo.json
#	launcher/lang/lt.json
#	launcher/lang/lv.json
#	launcher/lang/mk.json
#	launcher/lang/mn.json
#	launcher/lang/ms.json
#	launcher/lang/mt.json
#	launcher/lang/nl.json
#	launcher/lang/no.json
#	launcher/lang/oc.json
#	launcher/lang/pl.json
#	launcher/lang/pt-br.json
#	launcher/lang/pt.json
#	launcher/lang/ro.json
#	launcher/lang/ru.json
#	launcher/lang/sah.json
#	launcher/lang/se.json
#	launcher/lang/sk.json
#	launcher/lang/sl.json
#	launcher/lang/so.json
#	launcher/lang/sq.json
#	launcher/lang/sr.json
#	launcher/lang/sv.json
#	launcher/lang/szl.json
#	launcher/lang/ta.json
#	launcher/lang/th.json
#	launcher/lang/tr.json
#	launcher/lang/tt.json
#	launcher/lang/uk.json
#	launcher/lang/ur.json
#	launcher/lang/uz.json
#	launcher/lang/vec.json
#	launcher/lang/vi.json
#	launcher/lang/yi.json
#	launcher/lang/yo.json
#	launcher/lang/zh.json
#	launcher/lang/zu.json
2026-11-05 23:36:06 +01:00
Ludovic Marie 6d99e14ff0 Merge pull request #10189 from horizon-rust/pvp-safe/click-slot-order
🤖 Agent: inventory in the wire stream harness: a shift-click's changed slots went out 10, 37 where vanilla's Int2ObjectOpenHashMap iterates 37, 10; now byte-identical, unit test pinned to the captured order.
2026-11-05 23:30:06 +01:00
Ludovic Marie 6421a384f5 Merge pull request #10188 from horizon-rust/pvp-safe/pick-view-vector
🤖 Agent: bridge in the wire stream harness: 19 of 19 UseItemOn cursors differed from vanilla by ~6e-5 on main (double trig in the pick ray), all bit-identical with Mth's float table. Hot path cost: one table lookup per frame instead of libm sin/cos.
2026-11-05 23:30:02 +01:00
Ludovic Marie 39e99fdbd6 Merge pull request #10085 from horizon-rust/pvp-safe/vehicle-order
🤖 Agent: read against LocalPlayer.sendChanges and AbstractBoat.tick (rider look, vehicle, then sprint; paddles first). No riding scenario in the harness yet, so the proof is the read and the unchanged 16 scenarios. Land before #10084, which merges this branch.
2026-11-05 23:29:58 +01:00
Ludovic Marie ad1a6a3101 Merge pull request #10083 from horizon-rust/pvp-safe/dig-sequence
🤖 Agent: verified with the wire stream harness (#10082): on main, dig_teleport answered a teleport mid-dig with a new START (sequence 34) instead of vanilla's ABORT; with the series every tick matches. pr_ready clean except the desktop nix-shell's uname-less install.sh test.
2026-11-05 23:29:54 +01:00
Horizon agentandClaude Opus 5.5 e692430ffe Merge #10085 (pvp-safe/vehicle-order) into held-slot-tick: both rewrite remote_send_state
The held slot leaves remote_send_state (this PR); the boat's paddles go first and a
passenger sends its look, vehicle, then sprint (#10085).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 23:26:29 +01:00
Horizon agentandClaude Opus 5.5 861d3168cb Merge #10083 (pvp-safe/dig-sequence) into held-slot-tick: both edit the login's reset
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 23:26:03 +01:00
Horizon agentandClaude Opus 5.5 3431d0e18f wire stream: a one-client run (--clients horizon) stops after its capture
A loaded desktop's vanilla client failed to log in; rerunning Horizon alone and
comparing with the earlier vanilla capture crashed on the missing client.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 23:23:56 +01:00
Evrim CicekandClaude Opus 5.5 d95d0be4e4 /damage on the dragon: a perched dragon refuses a direct arrow, the hit pushes it, the right guest gets the kill
From review:
- A perched dragon shrugs off a direct arrow, trident or wind charge given as `by`
  (AbstractDragonSittingPhase.onHurt is 0, under 0.01): the command is refused.
- dealDefaultKnockback: a counted hit of a type not in #no_knockback pushes the dragon away from
  the command's source position (`at`, else the direct entity), or in no direction without one.
- credit_dragon_kill switches to the credited guest whenever they are not the one acting, as the
  mob kill credit does, so a guest's command that credits another guest awards that guest.

The harness scenario summons its dragons holding their pattern (DragonPhase 0): vanilla summons a
hovering dragon, which is sitting, and Horizon one holding its pattern; that gap is separate.
The intermediate build: 36/39; now 39/39.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 01:22:38 +03:00
Ludovic Marie c4e4800d44 Merge pull request #10241 from martipops/controller/buttons-prompts-v2
🤖 Agent: the keyboard path is a mechanical move from keys.contains to held(Action), with the same default keys underneath; old horizon-controller.txt files keep their buttons (no bindLayout line), and the pad's stack drop also reaches the server path through drop_stack. Union pr_ready green.
2026-11-05 23:19:40 +01:00
Ludovic Marie f33f49aa06 Merge pull request #10181 from martipops/fix/split-pack-overlay
🤖 Agent: a guest now gets host.vanilla_root(), not P1's overlay, so build_overlay_uncached can no longer delete the files it is building from.
2026-11-05 23:19:36 +01:00
Ludovic Marie c4c815689b Merge pull request #10059 from martipops/fix/pad-e2e-linux
🤖 Agent: harness only; hard links keep 60000 regions cheap on disk, and focus_on no longer raises StopIteration on a scrolled-out row.
2026-11-05 23:19:32 +01:00
Ludovic Marie 2aaaa76e9b Merge pull request #10056 from martipops/feat/pad-status-mapping
🤖 Agent: the router's per-frame status list is built only while a control socket is open, so splitscreen play without horizon-ctl pays nothing for it.
2026-11-05 23:19:28 +01:00
Ludovic Marie 460dbb0e5e Merge pull request #10054 from martipops/fix/ctl-socket-path
🤖 Agent: the check is SocketAddr::from_pathname, and the test compares it with a real bind at every length 1-150, so it follows the OS limit (104 on macOS, 108 on Linux) without a hardcoded number.
2026-11-05 23:19:24 +01:00
Ludovic Marie b756cf01e6 Merge pull request #10029 from foudretbr/feat/launcher-socks-proxy
🤖 Agent: SOCKS goes through ureq's own socks-proxy feature (socks 0.3.4, MIT/Apache), and the loopback SOCKS5 test proves the request reaches an .invalid host only through the proxy. Nothing changes for players without a proxy set.
2026-11-05 23:19:20 +01:00
Ludovic Marie d6bac327fa Merge pull request #9966 from Mayonesu0/fix/9633-wheel-scrolls-settings-list
🤖 Agent: matches 26.3: OptionsList inherits AbstractScrollArea.mouseScrolled, so the wheel scrolls the list and never reaches a row's CycleButton; the old key is dropped because every install saved it as true. Main merged in, union pr_ready green.
2026-11-05 23:19:15 +01:00
Ian WorleyandClaude Opus 5.5 8f4888a2ef docs(native-mods): document client world snapshots
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 16:14:46 -06:00
Ian WorleyandClaude Opus 5.5 d1eef82997 feat(native-api): client world snapshots for mods' worker threads
Client mods that bake from the world in bulk (a minimap's tiles, a world
map export) had to clone `game.world.chunks` and read `world_biomes!`,
which pins them to one Horizon commit. Four stable helpers replace that:

- `native_client_chunks(from, to)` shares the client world's loaded
  chunks of an area as `NativeClientChunks` (Send + Sync): block state,
  biome id, loaded, each column's top non-empty section, build height.
- `native_client_chunks_changed(&snapshot)` names the chunks that differ
  from the snapshot now. A held chunk is copied before any edit, so the
  same allocation means the same chunk.
- `native_client_biomes()` gives the biome table the terrain is drawn
  with (`NativeClientBiomes`): names and tints by `NativeBiomeId`.
- `native_client_biomes_changed(&biomes)` says when that table was
  replaced (a mod's seasons, a resource reload, another world).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 16:14:46 -06:00
Horizon agent a35ea8b72a Merge remote-tracking branch 'origin/pvp-safe/wire-stream-harness' into pvp/wire-stream-legacy 2026-11-05 23:11:05 +01:00
Horizon agent ec21e06600 Merge remote-tracking branch 'origin/main' into pvp-safe/wire-stream-harness
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-11-05 23:08:51 +01:00
Horizon agentandClaude Opus 5.5 9027fcd390 Time tick_creature_entities' steps as spans for horizon-report
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 23:07:47 +01:00
Florian ERNSTandCodex 9c5982d8e0 Default loaded item frames down when Facing is absent
Co-Authored-By: Codex <[email protected]>
2026-11-05 23:07:30 +01:00
Horizon agentandClaude Opus 5.5 8d09d8b3f2 wire stream: a teleport's two answer ticks compare with each other, the rest on the script's clock
Aligning every tick after the teleport on its acceptance moved the script's own
packets (the button release, the 20-tick position) by the server's lag: on the
desktop vanilla got the answer at tick 26, Horizon at 29, and both clients'
packets were otherwise identical tick for tick.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 23:06:50 +01:00
Horizon agentandClaude Opus 5.5 25d55ff457 Merge main into mods/hotload-content (after #10104 and #10218)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 23:06:43 +01:00
Ludovic Marie 4f4a931b21 Merge pull request #10269 from horizon-rust/agent/redstone-catalog-harness
🤖 Agent: harness and examples-only probe, nothing players run. New head: CI's parallel shards raced on one schematic .part file; each process now writes its own (three concurrent downloads checked against the pinned digest), and parity / parity passes. Point to check: item-moving builds are reported as not comparable, never as a pass. Result: 111/111 verified contraptions match vanilla.
2026-11-05 23:05:43 +01:00
Horizon agent cabfc96e14 Merge remote-tracking branch 'origin/agent/native-build-health' into agent/native-broken-on 2026-11-05 23:00:38 +01:00
Horizon agentandClaude Opus 5.5 2044fc756c launcher: trace a compile error inside a mod's support module to that mod
The second end-to-end build of 30 registry mods on 6475f62 stopped on
errors inside carryblock's, distant_horizons', smartf3's and treefall's
support modules (made before the crate split), which named no mod: only
injected helpers were traced. An error inside the inline module a mod
declared (one the pristine source doesn't have) now names that mod, so
the build leaves all of them out in one more compile.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 23:00:08 +01:00
MSedgeMC 1665d177c6 test: a playing jukebox posts jukebox_play every twenty ticks
A windowless game with a resident chunk, a jukebox at (0, 64, 0) and a disc put
in through use_jukebox: 41 ticks of the jukebox tick post the event at ticks 0,
20 and 40 and nothing else, and a jukebox that is never played posts none.
2026-11-05 21:59:53 +00:00
MSedgeMC 36dac313c8 fix: a playing jukebox tells the listeners around it
JukeboxSongPlayer.tick posts GameEvent.JUKEBOX_PLAY at the block every
PLAY_EVENT_INTERVAL_TICKS (20) ticks of a song, right before the music
particles:

  37: invokevirtual #97   // Method shouldEmitJukeboxPlayingEvent:()Z
  44: getstatic     #101  // Field GameEvent.JUKEBOX_PLAY
  56: invokeinterface     // LevelAccessor.gameEvent:(Holder,BlockPos,Context)
  66: invokestatic  #104  // Method spawnMusicParticles:(LevelAccessor,BlockPos;)V

Horizon drew the note particle in the same twenty-tick branch and posted
nothing, so a sculk sensor beside a playing jukebox never clicked and an allay
never heard the song, although the stop half (JUKEBOX_STOP_PLAY with level
event 1011) was already posted when the jukebox went away.
2026-11-05 21:59:53 +00:00
Evrim CicekandClaude Opus 5.5 25bec21906 /damage hurts the Ender Dragon and End crystals as vanilla does
cmd_damage had no arm for them, so both said "Target is invulnerable".

EnderDragon.hurtServer is hurt(body, ...): a quarter plus up to 1; reallyHurt runs only with a
player behind the source or an #is_explosion type and refuses fire, and hurt succeeds either
way unless the dragon is dying or the hit is under 0.01. EndCrystal.hurtServer refuses an
Invulnerable crystal (unless generic_kill, out_of_world or a creative player), fire and the
dragon's own damage; else it is removed, and blows up unless the damage is an explosion.

The command execution harness gets a "dragon command damage" group: 15/31 before, 31/31 after.

Fixes #10265

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 00:59:38 +03:00
Horizon agentandClaude Opus 5.5 043705f347 Redstone catalogue: each shard writes its own schematic part file
CI runs the three shards at once on an empty .local/lra-catalog: two of them
downloaded the same schematic into one `.part` name and one rename found it
gone. The part file now carries the process id.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 22:58:28 +01:00
Ludovic Marie 0f5dc8d6d5 Merge pull request #10268 from horizon-rust/agent/redstone-catalog-oracle
🤖 Agent: test-only oracle (mods/horizon/src/test). Point to check: run() clears block ticks after it loads containers and comparators, and sets the lectern's page field directly. Otherwise setChanged and setPage schedule ticks or power the lectern before step 0, which is what made 29 builds differ by an extra tick (see #10269's numbers).
2026-11-05 22:55:22 +01:00
009378de29 Stop the underwater sky scan on kelp and seagrass.
canSeeSkyFromBelowWater stops when a block dampens light and is not liquid(). Kelp and seagrass hold water, so guardians under them were still rolling the 1-in-20 and spawned less often.

Co-Authored-By: Grok <grok-4.7> <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-11-05 22:53:40 +01:00
b9607ce998 Replace a bubble column when a bucket empties into it.
emptyContents destroys the block only when it is not liquid(). A bubble column is liquid and not a LiquidBlock, so breaking it first played destroy particles and a sculk vibration.

Co-Authored-By: Grok <grok-4.7> <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-11-05 22:53:40 +01:00
MSedgeMC 9fb4fde63b test: a use start is an item_interact_start vibration
A windowless game from the prepared resources, a bow in hand: the two events the
use posts are read back from the game's own event recorder, at the player's feet
(10, 64, -4). A spear, whose use effects set interact_vibrations false, posts
neither.
2026-11-05 21:49:11 +00:00
MSedgeMC d2cd07f1ac fix: starting an item use posts item_interact_start
LivingEntity.startUsingItem ends with
this.useItem.causeUseVibration(this, GameEvent.ITEM_INTERACT_START), and
ItemStack.causeUseVibration posts only when the stack's use effects ask for
interaction vibrations - the same gate and the same position stopUsingItem
already used for ITEM_INTERACT_FINISH. Horizon posted only the finish event, so
a sculk sensor beside a player drawing a bow never clicked and an allay never
heard the use start.

use_vibration takes the event now, and start_using_item calls it where vanilla
posts it: after the use is set up, before the kinetic weapon's map (which this
change leaves alone).
2026-11-05 21:49:11 +00:00
Ludovic Marie 83da211d38 Merge pull request #10267 from horizon-rust/agent/redstone-catalog-data
🤖 Agent: data and reader only, nothing players run. The catalogue is WTFPL from maybeachromium (LICENSE beside it). Point to check: tools/lra_schem.py is the one parser of untrusted downloaded schematics. It reads gzip NBT with struct only, never executes or imports from them, and rejects a palette/varint count mismatch.
2026-11-05 22:48:26 +01:00
Florian ERNSTandCodex dec4b523f3 Use neutral source for ominous item warning
Co-Authored-By: Codex <[email protected]>
2026-11-05 22:42:57 +01:00
Horizon agentandClaude Opus 5.5 b7df353fdf Merge main into fix/9633-wheel-scrolls-settings-list
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 22:42:57 +01:00
MSedgeMC 3236b57773 Declare no extra comment paths for the trample hook fragment
stamp_native_hooks.py refuses a fragment that lists a guarded file
(native_hooks.rs is the generated source it stamps itself), so the fragment
declared an invalid comment path and the repo-wide stamp check failed.
2026-11-05 21:35:17 +00:00
foudretbr d55c2840a7 Block states: map states past registry to air instead of panicking
A server with mods or states the client does not have can send block states outside our registry. Instead of panicking with an out of bounds slice access when indexing BlockFacts or unpacking palettes, map unknown states to air.
2026-11-05 22:34:51 +01:00
DUzzL cb7babf1cf Normalize END records whose disk numbers are equal but not zero 2026-11-05 22:32:18 +01:00
Ludovic Marie 3c7bb44aa1 Merge pull request #10220 from xxaxio/fix/10072-launcher-auto-update-race
🤖 Agent: per-home mod lock fixes the stale-manifest race; UI-thread switch/remove use try_lock_home so an update's downloads never freeze the window.
2026-11-05 22:31:06 +01:00
Ludovic Marie 51e5d01c83 Merge pull request #10145 from horizon-rust/cosmetics-fx/wardrobe
🤖 Agent: Wardrobe layer, retargeted to main after #10144. preview::Draw gains alpha and Draw::model; main's live view now builds the body with it. A companion is drawn at its anchor, an aura see-through at the feet with its turned-away faces skipped, and a trail as a short run of particles. The Wardrobe gets Companion, Trail and Aura rows, in all 86 launcher languages. Verified on this head: rustfmt on game and launcher, 71 horizon-cosmetics tests, launcher wardrobe/i18n/lang tests (23 + 7).
2026-11-05 22:29:16 +01:00
Ludovic Marie b0a738abc3 Merge pull request #10144 from horizon-rust/cosmetics-fx/render
🤖 Agent: render layer, retargeted to main after #10143. It draws companions on the GPU skin path (identical ones share one buffer) and trails and auras in one see-through span after the players. Nothing is drawn beyond 48 blocks or outside the view. Measured in-game with HORIZON_COSMETICS_LOCAL: +0.6 ms prepare at 16 wearers and +1.1 to 2.4 ms at 64, mostly the auras built on the CPU; the follow-up is #10251. Verified on this head: horizon-client clippy -D warnings.
2026-11-05 22:26:58 +01:00
Evrim CicekandClaude Opus 5.5 c572bb7f88 Port pack: folder entries don't vote in old_layout; the report's reason in one line
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 00:25:58 +03:00
Ludovic Marie c8eee9842f Merge pull request #10143 from horizon-rust/cosmetics-fx/settings
🤖 Agent: settings layer, retargeted to main after #10142. The unused "Show other players' auras and trails" (off) becomes "Hide other players' trails and auras", off by default; it is the cosmeticsHideFx line, shared by the game's Extras and the launcher's Wardrobe, and the old cosmeticsAuras line is dropped on save. Translated into the game's 75 languages and the launcher's 86. Verified on this head: clippy -D warnings on game and launcher.
2026-11-05 22:24:25 +01:00
Ludovic Marie 731544d1c5 Merge pull request #10218 from horizon-rust/mods/native-switch
🤖 Agent: merging the native on/off switch. The point to check is the hot path: each Hooks dispatch adds one relaxed atomic load per mod, and lists that aren't a slice of the global MODS (tests, single-mod dispatch) are never gated. RenderHooks and mixin code are left unswitched on purpose, as the PR says.
2026-11-05 22:24:20 +01:00
Ludovic Marie a27b7f20fe Merge pull request #10213 from horizon-rust/agent/smp-profiler
🤖 Agent: horizon-report format 2 (mob costs, level spans, tick_lan/sculk sub-spans, workers, network, overhead, stop/status). verify green on eb55d13; live on the SMP since 21:08Z as prof2 at 0.03 % of MSPT; the one desktop test failure is a load-sensitive 1 s login cap that passed 3/3 alone. Only spans and counters added, no gameplay change.
2026-11-05 22:24:16 +01:00
MSedgeMC 9329e154cb Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-05 21:21:52 +00:00
MSedgeMC 72a1b13eae Merge remote-tracking branch 'upstream/main' into fix/trample-hook 2026-11-05 21:21:41 +00:00
Ludovic Marie 2ed3ede17f Merge pull request #10142 from horizon-rust/cosmetics-fx/runtime
🤖 Agent: runtime layer, retargeted to main after #10140. follow.rs (frame-rate independent lag, snap after a teleport, kept out of the crosshair cone and clear of the head) and trail.rs (spawns by distance walked, 24 particles per player and 512 in all) are pure horizon-cosmetics code, plus the fx_bench example. Nothing in the game calls them yet. Verified on this head: clippy -D warnings and 68 horizon-cosmetics tests.
2026-11-05 22:21:39 +01:00
Ludovic Marie 11e7d633d6 Merge pull request #10214 from xxaxio/fix/9948-music-toast-animated-sprite
🤖 Agent: any GUI sprite with an animation section now packs its first frame and animates, so the 16x64 music_notes strip no longer squashes into one slot; load-time only.
2026-11-05 22:21:35 +01:00
Evrim CicekandClaude Opus 5.5 2ea59c309b Port pack: a mod made for 1.20 is not carried or counted as come over
check already refused a jar whose data folders are named as before 1.21; carry and report did
not, so a 1.20 pack's mods were carried and counted as partial while 26.3 read none of their
recipes, loot tables, tags or structures (Prominence II: 149 of 308 "come over", 28,948 recipes
and 8,784 loot tables in recipes/ and loot_tables/).

old_layout moves to carry and reads every folder 1.21 renamed (structures/, advancements/,
tags/blocks/, ...), by the larger share: a 1.20 jar may hold a misnamed singular tag folder and
a 1.21 jar a stale plural copy. carry writes old_layout to carry-report.json and carries
nothing; the report's row says why, is not one Port can carry over, and the launcher does not
count it as portable.

Fixes #10249

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 00:21:33 +03:00
Ludovic Marie 8dcf45e95e Merge pull request #10171 from xxaxio/fix/9957-sulfur-cubes-parity
🤖 Agent: sulfur cube blast is TNT-interaction only with mobGriefing (NONE without), ENTITY_INTERACT sourced by the cube, tnt.disabled overlay and used stat.
2026-11-05 22:21:31 +01:00
Ludovic Marie 489a1334a6 Merge pull request #10122 from xxaxio/fix/9961-camel-second-seat-passenger
🤖 Agent: camel husk jockey seats its parched; camel capacity is vanilla's canAddPassenger size() <= 2, babies included.
2026-11-05 22:18:26 +01:00
Ludovic Marie 166addb003 Merge pull request #9994 from xxaxio/fix/9958-ender-eye-frame-drops-and-used-stat
🤖 Agent: completing the ring destroys the 3x3 opening with drops (EnderEyeItem.useOn destroyBlock(pos, true)) and awards ender_eye used.
2026-11-05 22:18:22 +01:00
Ludovic Marie f66e017f5c Merge pull request #9986 from xxaxio/fix/9793-riding-body-yaw-portal-inventory
🤖 Agent: rider body follows the vehicle kind as in vanilla (boat yaw, solveBodyRot's 85° clamp for living vehicles, on-foot turn in minecarts); portal E guard only while inside the portal this tick.
2026-11-05 22:18:17 +01:00
Ludovic Marie 2d483f7ffc Merge pull request #10140 from horizon-rust/cosmetics-fx/format
🤖 Agent: format layer of the companions/trails/auras stack, rebased onto the emotes: Slot gets Companion, Trail and Aura next to Emote, and Item::parse checks both kinds of block. Item::animate also builds its animated-part table once at parse instead of searching by name every frame, which saves about 0.3 ms a frame at 64 wearers (profiled). Verified on this head: clippy -D warnings on game and launcher, 60 horizon-cosmetics tests; nothing draws the new slots yet.
2026-11-05 22:18:13 +01:00
Horizon agent 52b0be4adb Merge remote-tracking branch 'origin/agent/native-build-health' into agent/native-broken-on 2026-11-05 22:16:44 +01:00
Horizon agentandClaude Opus 5.5 38db573502 launcher: trace a compile error to a helper by its name when formatting changed it
The end-to-end build of 30 registry mods on 6475f62 stopped on
eatinganimation's `held-frame` helper (E0063, ItemRenderState's new fields)
without naming it: the staged item_render.rs carries the helper with a
trailing comma rustfmt added, so its tokens no longer equal the plan's
helper_source. A helper is now also the function of its name in the staged
file, when the pristine source has no function of that name; two mods'
helpers of one name still name neither.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 22:16:23 +01:00
Horizon agentandClaude Opus 5.5 d3a00abb07 launcher: mods page never waits on an update's folder lock; fetch the index before locking
The switch and remove buttons run on the window's thread: while an
automatic update holds the game folder they now say "Wait for the current
operation to finish." instead of freezing the launcher until its downloads
end. auto::run takes the lock after the registry index arrives.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 22:15:52 +01:00
Horizon agentandClaude Opus 5.5 4d4872cedd Redstone catalogue: Horizon probe and the parity harness
redstone_catalog_fixture builds the same contraptions in a map-backed
horizon-world level, clicks the same levers and buttons
(Behaviours::use_without_item) and runs tick_scheduled, run_block_events
and tick_block_entities, printing what VanillaRedstoneCatalogOracle prints.

tools/test_redstone_catalog_parity.py reads the catalogue, downloads the
archive's schematics into .local/lra-catalog/ (pinned to da4dd51, one
sha256 over all 188), turns each entry into a case (switches off except
those the catalogue held on, a neighbour update on every block, then input
steps: every vector up to 8 bits, random ones beyond, random changes for
stateful builds) and compares both sides step by step. verified entries
must match; partial and unverified ones are reported. Entries whose inputs
are analog levels, lecterns or bare wire are listed as unsupported for
now. Three shards; --full runs larger sweeps and every vector up to 16
bits.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 22:15:27 +01:00
Horizon agentandClaude Opus 5.5 603584ef2e Redstone catalogue: vanilla oracle that runs whole level ticks
VanillaRedstoneCatalogOracle builds each contraption (block states and
block entities the harness reads from its schematic) in the overworld of a
real flat-world server, one contraption per server tick. Comparators keep
their saved output, containers and lecterns get real items (set silently:
no comparator ticks, no lectern pulse), and the comparator level of every
block entity is printed for the Horizon probe. The game clock keeps
running between contraptions (LevelTicks orders chunks by trigger time);
the probe starts from the time printed.

Levers and buttons are driven with useWithoutItem, and each tick runs what
ServerLevel.tick runs for blocks: scheduled block and fluid ticks, block
events and block entities, so pistons move. After each tick it prints the
outputs that changed, and at the end of each step the tick it ended on,
whether the level went quiet and a hash of the contraption's box. A tick
budget per contraption (the probe counts the same) bounds machines that
never settle; containers at or next to hoppers, droppers, dispensers and
crafters are watched every tick, and a changed level marks the run as not
comparable (the probe does not move items). HORIZON_CATALOG_TRACE=1 adds
every block tick and every block at a step's end.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 22:15:27 +01:00
Horizon agentandClaude Opus 5.5 e9eed40a30 Redstone catalogue: commit the LRA catalogue (WTFPL) and a schematic reader
tools/lra-catalog/entries.jsonl is maybeachromium's catalogue of the Logical
Redstone Archive (#10103): input and output positions, encodings and checks
for every entry, one entry per line. They shared it under the WTFPL
(LICENSE). The archive's schematics have no licence and stay out of the
repository.

tools/lra_schem.py reads Sponge schematics (versions 1 to 3), written from
the format's specification.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 22:15:27 +01:00
TaquitoSuabe cf92894885 gfx: enable early depth occlusion culling on OpenGL (Backend::Gl)
Cull::supported previously gated early-depth occlusion culling to Vulkan and Metal. Enable wgpu::Backend::Gl when the device supports SHADER_EARLY_DEPTH_TEST alongside indirect first instance and storage buffer writes.
2026-11-05 14:14:46 -07:00
Florian ERNSTandCodex 0e05b77d95 Keep glow squid dark countdown running with NoAI
Co-Authored-By: Codex <[email protected]>
2026-11-05 22:00:30 +01:00
Horizon agent 20dc224154 Merge remote-tracking branch 'origin/main' into cg53-pr-10220 2026-11-05 21:58:37 +01:00
Horizon agentandClaude Opus 5.5 7a08446cfe sulfur cubes: the blast keeps blocks without mobGriefing; the cube sources ENTITY_INTERACT
SulfurCube.tickFuse explodes with ExplosionInteraction.TNT only when
mobGriefing is on (NONE otherwise), and equipItem's gameEvent has the
cube, not the player, as its source. Drop tests that checked constants.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 21:58:29 +01:00
MSedgeMC 4fea4cfd63 test: the shipped presets' disable_mob_generation, through the client seam
The three vanilla dimensions, built from the shipped data, must answer what
their noise settings say: the overworld and the nether add creature packs at
generation, the End does not. This is the value `spawn_generation_creatures`
now returns on, read through `VanillaTerrain`, so it covers the whole path from
`end.json` to the gate instead of just the JSON read.

With the setting read as a hardcoded false (the pre-change behaviour, where only
`Dim::End` decided) it fails as
`assertion left == right failed: disable_mob_generation of minecraft:the_end —
left: false, right: true`.
2026-11-05 20:58:11 +00:00
MSedgeMC 61e96ef269 fix: the noise settings' disable_mob_generation skips the creature packs
`NoiseBasedChunkGenerator.spawnOriginalMobs` reads
`NoiseGeneratorSettings.disableMobGeneration` and places nothing when it is
set. The shipped presets are explicit about it: `end` sets it true, the other
six set it false. Horizon never read the field and instead skipped the pass for
`Dim::End` alone, so a datapack dimension that used the End's noise settings -
or any preset asking for no creature packs - still got them, and the reason the
End was empty lived in a dimension check rather than in the data.

The setting now reaches the client seam: `Generator`'s settings carry it, the
pipeline trait exposes it next to `sea_level`, `VanillaTerrain` answers it, and
`spawn_generation_creatures` returns before doing any work when it is set. The
existing `Dim::End` check stays as well, so a preset that fails to load cannot
start filling the End; for the shipped data the two agree, since `end.json` is
the only preset that sets the flag.
2026-11-05 20:58:06 +00:00
Horizon agentandClaude Opus 5.5 0a2d899426 mobs: camel passengers follow Camel.canAddPassenger (size <= 2, babies too)
Vanilla's Camel.canAddPassenger accepts a rider while two or fewer ride,
with no baby check. Drop two tests that only checked their own inputs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 21:58:03 +01:00
Horizon agent 9e317340d9 Merge remote-tracking branch 'origin/main' into cg53-pr-10171 2026-11-05 21:58:03 +01:00
Horizon agentandClaude Opus 5.5 2228fd7be1 end: propagate the level events' error; drop a test that checked its own loop
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 21:57:23 +01:00
Horizon agent 188ad5b2d1 Merge remote-tracking branch 'origin/main' into cg53-pr-10122 2026-11-05 21:57:23 +01:00
Horizon agentandClaude Opus 5.5 9e239c1c0c perspective: rider body follows vanilla per vehicle kind; portal key guard only while inside
A boat sets its passenger's body to its yaw (AbstractBoat.clampRotation);
a living vehicle's rider is drawn from the vehicle's body with the head at
most 85 degrees off and a fifth of the offset past 50
(LivingEntityRenderer.solveBodyRot). Minecarts and cushions keep the
on-foot turn, as vanilla's LivingEntity.tick does. The inventory key is
held back only while the last tick found the player in the portal, not
during the effect's fade-out.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 21:57:16 +01:00
Horizon agent 78c6d024dd Merge remote-tracking branch 'origin/main' into cg53-pr-9994 2026-11-05 21:57:16 +01:00
martipopsandClaude Opus 5.5 2cce72df97 Merge upstream/main into controller/buttons-prompts-v2
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 15:56:51 -05:00
Horizon agent 54fd1a76d8 Merge remote-tracking branch 'origin/main' into cg53-pr-9986 2026-11-05 21:56:02 +01:00
HiuoyandClaude Opus 5.5 b5f50bc41d Native hooks: AnvilUpdateEvent, a mod's say in an anvil's result (#10230)
anvil_update fires where AnvilMenu.createResult ends, once vanilla has
worked out the result, while the left slot holds an item: on the host for
each player's menu (single player's server thread, a LAN or dedicated
host), before prepare_anvil, and in the window's own menu. A mod is told
the inputs, the rename box's text and vanilla's result. It may change the
output, the level cost and the material cost, or cancel, which leaves no
result (NeoForge's CommonHooks.onAnvilUpdate).

The host asks again after a rename that leaves the result as it was, as
setItemName runs createResult. The window's click prediction starts from
the station's cost and material cost, so a result a mod made can be taken
there as on the server. The window names the player as the server knows
them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 00:52:39 +04:00
Riccardo GuerrieroandClaude Opus 5.5 9ee63c0510 Aquifers keep their 12 nearby cells unpacked and reuse 8 of them a cell down
Part of #9996.

NoiseBasedAquifer::compute_substance is 4.7% of the overworld's proto
phase by itself. A sixth of that went to rebuilding the 12 grid cells
around a block whenever its aquifer cell changed: every location was
unpacked again from its packed long, and the neighbourhood was copied
as a 288-byte Option. The fill walks each column from the top down, so
a rebuild usually follows the cell just above.

Each drawn location is now also kept as the coordinates BlockPos.getX,
getY and getZ read back from it, and the 12 cells are arrays of grid
index, coordinates and horizontal distance to the column. One cell
down, the 8 cells at the old anchor's y and the y below move up two
slots and only the 4 new ones are loaded, in vanilla's loop order. A
cached fluid status is read inline; computing a missing one is out of
line. Default air, water and lava are classified by their IDs before
any block name is read.

Every block, pending fluid update, error and cached cell is the same as
before: the same operations in the same order, the same positional
random draws for the same cells. A test runs the previous search
against the new one over whole columns, scrambled blocks and positions
outside the grid, and the snapshots match byte for byte.

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WZzq8LUjYucp4kaN6UfSg7
2026-11-05 21:52:13 +01:00
MSedgeMC cf6a402c41 port_map: EntityRenderer.getBoundingBoxForCulling, the interpolated box
The ledger named the four renderer overrides but not the method they override;
it now says the box is the frame's, grown by 0.5, and points at the dropped
item's own copy of it.
2026-11-05 20:49:37 +00:00
MSedgeMC 4ebdcbf7b2 test: the dropped item's cull box over the frame
An item sliding from x = 0 to 1 in camera space: the box is -0.625..0.625 at the
start of the frame, -0.125..1.125 halfway through and 0.375..1.625 at its end,
where the old code had the last on every frame. A falling item's box floor is
65.0 halfway down a 66 -> 65 drop instead of the tick's 64.5.
2026-11-05 20:49:37 +00:00
MSedgeMC 0fdc892ef9 fix: cull a dropped item at the box the frame draws
EntityRenderer.shouldRender tests the frustum against
getBoundingBoxForCulling(entity, partialTick), which EntityRenderer leaves as
Entity.getInterpolatedBoundingBox(partialTick) - ItemEntity overrides neither -
grown by 0.5 all round.

The dropped-item pass built that box from the tick's own position, so for the
frames of a tick in which an item crossed the frustum edge it was culled at the
cell it was about to reach: an item sliding from x = 0 to 1 had its box at
0.375..1.625 (camera space) on every frame, where at the start of the frame it
stands at -0.625..0.625.

dropped_item_cull_box(previous, position, alpha, camera) is that box now, the
interpolated position grown by the item's 0.125 half-width and 0.5, with the
0.25 height below and 0.75 above.
2026-11-05 20:49:37 +00:00
Ludovic Marie 5849fa33b3 Merge pull request #10104 from horizon-rust/mods/in-game-browser
🤖 Agent: merging the in-game mod browser. The point to check is that install never trusts the download: each file goes through horizon_mods::index::check_file (size, SHA-256, signature, manifest against the index) before it reaches horizon-mods/, and revoked files are refused. Tests pin both cases with a real .hmod.
2026-11-05 21:46:40 +01:00
Horizon agentandClaude Opus 5.5 f23fd18f41 Cosmetics: companions, trails and auras in the Wardrobe
The Wardrobe's picture (horizon_cosmetics::preview, its live view
included) draws a companion at its anchor, an aura see-through at the
feet and a trail as a short run of particles behind; the Wardrobe gets a
row for each slot.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 21:42:38 +01:00
Horizon agentandClaude Opus 5.5 725efc0eca Cosmetics: draw companions, trails and auras in the game
A companion is posed on no bone: it follows its anchor with its lag,
keeps clear of the head and, for the player's own in first person, out of
the middle of the view. Trails spawn by distance walked and are drawn as
camera-facing particles; auras blend at the feet with their back faces
dropped. Trails and auras of every player go in one see-through span after
the players. Others' follow "Other players' cosmetics" and "Hide other
players' trails and auras". Nothing is an entity or reaches a server.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 21:42:37 +01:00
Horizon agentandClaude Opus 5.5 f17a44249f Cosmetics: "Hide other players' trails and auras"
The setting that was "Show other players' auras and trails" (off, for
items that didn't exist) becomes a hide switch, off by default: others'
trails and auras show like their other cosmetics unless the player hides
them. In the game's Horizon settings and the launcher's Wardrobe, the same
cosmeticsHideFx line of horizon-extras.txt; the old cosmeticsAuras line is
dropped on save.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 21:42:37 +01:00
Horizon agentandClaude Opus 5.5 7df444ab89 Cosmetics: how companions follow and trails spawn
follow.rs: a frame-rate independent lag toward the anchor, a snap after a
teleport, and the two rules that keep a companion out of the way (out of
a cone round a first-person crosshair, clear of the head). trail.rs:
particles spawned by distance walked only, placed from their age alone,
24 per player and 512 in all. fx_bench measures their CPU cost for 1, 16
and 64 wearers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 21:42:35 +01:00
Horizon agentandClaude Opus 5.5 2a76fd1a00 Cosmetics: companion, trail and aura item format
Three new slots that ride no bone: a companion floats beside the player,
a trail leaves particles where they walk, an aura glows faintly at their
feet or round the body. Their blocks, limits and checks live in fx.rs;
item.rs reads them, refuses a bone on them and measures their extent.
Nothing draws them yet.

Item::animate no longer looks its parts up by name every frame (a tree
search and string compares per animation, for every wearer): the animated
parts are found once, when the item is read. At 64 wearers in view that
was 0.3 ms a frame on an i7-7500U.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 21:42:35 +01:00
MSedgeMC 9794dd7ca9 port_map: Entity.getLightProbePosition, the eye over the frame
The ledger had no row for the probe the renderers read; it now names the mob and
projectile port and where the armour stand's override lives.
2026-11-05 20:40:55 +00:00
MSedgeMC 5d76ff0225 test: the mob and projectile probes over the frame
A mob walking 0.9 -> 1.1 (eye 1.62) reads the cell at 0 at the start of the frame
and 1 from the boundary on, where the old probe read the tick's cell 1 on every
frame; a falling one reads 71.62 then 70.62. An arrow falling 64 -> 65 reads
64.13, 64.63 and 65.0, so the cell at 65 only at the end of the frame, where the
old probe read 65.0 throughout.
2026-11-05 20:40:52 +00:00
MSedgeMC caa5c31dd0 fix: light a mob and a projectile from the block its eye is in that frame
EntityRenderer.getPackedLightCoords takes BlockPos.containing of
Entity.getLightProbePosition(partialTick), and Entity leaves that method as
getEyePosition: getEyeHeight() over getPosition(partialTick), which is
Mth.lerp(partialTick, xo, x) on each axis.

The mob pass probed floor(position) with position.y + eye_height(), and the
projectile pass the same on its own position: the tick's own position, so a mob
walking from x = 0.9 to 1.1 was drawn lit by the cell at 1 for every frame of the
tick, and a falling one by the cell it was about to reach, instead of the cell it
was drawn in.

Mob::light_probe(partial) and Projectile::light_probe_y(partial) now give the
probe of the frame, through mobs::living::eye_probe_cell (Mth.lerp plus the eye
height, floored per axis).

Old/new for a mob walking 0.9 -> 1.1 (eye 1.62): cell x = 1 on every frame before,
x = 0 at alpha 0 and 1 from the boundary on. For an arrow falling 64 -> 65: 65.0
before, 64.13 at alpha 0 and 64.63 at alpha 0.5, the cell at 65 only at alpha 1.
2026-11-05 20:40:49 +00:00
martipopsandClaude Opus 5.5 9dae50aefd Controllers: bindable actions, prompts over gameplay, and button art
Held mappings are named by an Action rather than read by key, per device, and more actions can be bound: hotbar
slots, fullscreen, toolbars, social and quick actions. Held drop repeats and throws the stack. Readers of the
movement keys see the left stick, and a bumper steps the hotbar through the wheel's gates. Prompts over gameplay
are pad-driven and keep what's behind them, the keyboard opens on every field, and focus follows its widget.
Buttons and the crosshair draw from Horizon's own art, one file per pad family, instead of text labels.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 15:37:30 -05:00
Ludovic Marie 86a9d9e669 Merge pull request #10236 from horizon-rust/fix/launcher-screenshot-memory
🤖 Agent: merging. The screenshot tiles, viewer and background no longer go through GPUI's never-evicted img(path) cache. Two 32k×16k screenshots on Home went from an 8.7 GiB peak to 277 MiB on the desktop. One risk to watch: the 2 s grace lets the budget overflow while more than 256 MiB of tiles is on screen at once (about 280 tiles with Show all), which is bounded by the list itself.
2026-11-05 21:37:05 +01:00
Horizon agentandClaude Opus 5.5 23e710f75d launcher: draw screenshots at their shown size, under a byte budget
Screenshot tiles (Overview's three newest, the Screenshots tab), the
viewer and Home's background went through gpui::img(path): GPUI decodes
the whole picture and keeps it, and its sprite-atlas copy, until the
launcher quits. One 32768x16384 screenshot is 2 GiB decoded; with two
of them Home alone reached 8.7 GiB (24 GiB reported on Linux).

- pictures (lib): PNGs are read a row at a time and box-filtered down to
  the drawn size, never held whole; other formats decode whole only
  under 256 MiB. Lru keeps made pictures under a byte budget, sparing
  those drawn in the last 2 s.
- picture_cache: makes them off the UI thread (3 at once), 640 px for
  tiles, 3840 px for the viewer and background, 256 MiB budget; evicted
  images are dropped from the window's sprite atlas.
- glass_shot_blur's blurred copies go through it too, from a shrunk read
  instead of image::open.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 21:35:43 +01:00
MSedgeMC 4465421885 port_map: ArmorStand.getLightProbePosition is ported, not skipped
The row said the probe was drawing-only and skipped; it is what selects the block
an armour stand is drawn with, so it now points at the code that walks it.
2026-11-05 20:35:23 +00:00
MSedgeMC 1d38cdeba7 test: the armour stand's probe cells and packed light
Four cases over the jar's values: a full stand's eye at 65.7775 (cell 65, where
the old base + 0.5 read 64), a small stand at 64.2 (65.1875, cell 65, where the
old read 64), a marker's box scanned for a 15 at (0, 65, 0) and falling back to
the first cell walked (0, 64, 0), y walked before z, the cell above the box left
out, and the marker branch ignoring the partial tick; and a burning stand's block
light at 15 with the sky light left as the probe cell's.
2026-11-05 20:35:23 +00:00
MSedgeMC 249341e7cc fix: light an armour stand from its own light probe
EntityRenderer.getPackedLightCoords reads the block Entity.getLightProbePosition
is in, and ArmorStand overrides that method:

- a marker walks BlockPos.betweenClosed of getDimensionsMarker(false) - the box it
  would have without the marker flag, 0.5 by 1.975, or 0.25 by 0.9875 when small -
  from position() with no partial tick, and returns the first cell whose
  max(block, sky) brightness is 15, else the brightest, ties going to the first
  cell BlockPos walks (x fastest, then y, then z);
- every other stand is Entity.getLightProbePosition, the eye: eyeHeight 1.7775 for
  a full stand and 0.9875 for a small one (BABY_DIMENSIONS), at the frame's
  interpolated position.

Horizon read floor(base + 0.5) for every stand, so a stand standing on a lit floor
in a dark room was drawn with the floor's light: a full stand on y = 64 read the
cell at 64 where its eye at 65.7775 is in the cell at 65, and a hologram marker on
a dark ceiling was lit from its feet's block instead of the brightest cell of the
box it would have without its flag.

EntityRenderer.getBlockLightLevel also puts a stand that isOnFire at 15 block
light, which was not read at all.
2026-11-05 20:35:23 +00:00
Florian ERNSTandCodex 85c277b5e2 Correct empty tag selector predicates
Co-Authored-By: Codex <[email protected]>
2026-11-05 21:31:34 +01:00
Horizon agentandClaude Opus 5.5 27c2e6457f Mods hot load: rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 21:26:56 +01:00
Horizon agentandClaude Opus 5.5 eb55d13be3 horizon-report: overhead counts the tick's own spans; other worker jobs counted, not timed
On a smoke server (2 ms ticks) the overhead line charged the chunk pump's spans of the
wait between ticks (1,788 a tick) to MSPT. Only the spans the tick records count now,
and a dedicated server records spans anyway for its slow-tick line (HORIZON_SPIKES,
on by default), so they are listed apart and left out of the report's own cost.
The 'other' worker jobs ran at 278k/s for 0.0 ms each: two thread-CPU clock reads
each cost more than the job; they are counted only. A sum of no players' rates no
longer reads -0.0.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 21:26:41 +01:00
MSedgeMC 309ddb48ed port_map: CreativeModeInventoryScreen.selectTab is the creative tab switch 2026-11-05 20:20:54 +00:00
MSedgeMC 87a0ccc90c test: the creative search box loses the focus with its tab
`Creative::select`: the box starts unfocused, the search tab focuses it, and
another tab takes the focus away.
2026-11-05 20:20:54 +00:00
MSedgeMC 1e36a5da1f fix: the creative search box loses the focus with its tab
`CreativeModeInventoryScreen.selectTab` shows and focuses the search box only
on the search tab; any other tab hides it, lets it lose the focus and clears
its query. Horizon focused the box when the search tab was selected but never
took the focus away, so the box stayed focused behind every other tab.

`Creative::select` now sets the focus from the tab it shows, keeping the blink
reset the search tab always had.
2026-11-05 20:20:54 +00:00
Ludovic Marie 3d33b0299d Merge pull request #10223 from horizon-rust/feat/shop-tab-button-label
🤖 Agent: the Wardrobe button opens the launcher's Shop tab now, so its label drops "on playhorizon.dev"; the key is renamed in place in all 86 tables (no stale key left), and the i18n test passes.
2026-11-05 21:20:36 +01:00
Horizon agentandClaude Opus 5.5 50d9f3d7f7 launcher: warn before installing a native mod that doesn't build on your Horizon
The registry's daily check (horizon-mods native-compat.yml) marks the
versions horizon-mixins refuses on the newest Nightly with `broken_on`
({horizon, code}) in index.json. The browser's fit line now says
"Doesn't build on the Horizon you play (CODE): a build leaves it out until
its author updates it" when that Horizon is the one the mod would build on.

- horizon-mods index: `Version.broken_on` (empty and absent by default).
- native_fit: `Fit::Broken { code }` for a hook mod, or a mod pinned to the
  played commit, when an entry names that commit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 21:19:51 +01:00
foudretbr f15ca8d82a Preparing ignores the player's global Java options
A collector chosen in JAVA_TOOL_OPTIONS or _JAVA_OPTIONS stopped every JVM
horizon-prepare starts, as they pass -XX:+UseSerialGC: "Multiple garbage
collectors selected", so the version was never prepared. java::command, which
they all go through, now removes those variables and JDK_JAVA_OPTIONS.

Fixes #10067
2026-11-05 21:17:58 +01:00
MSedgeMC 6ad03dd306 port_map: the dropped item's light probe and block light
The ItemEntityRenderer row names where its light comes from now, and
EntityRenderer.getBlockLightLevel's note covers dropped items beside mobs and
projectiles.
2026-11-05 20:14:11 +00:00
MSedgeMC 837ec11a1a test: the dropped item's probe, frame position and packed light
The three cases quote the old values: the failing probe cell of a falling item
(64 against the eye's 65), the tick's own position against the frame's halfway
one (64.7125 rather than the destination 65.2125), and a burning item's block
light (15 over a dark cell's 0, sky untouched).
2026-11-05 20:14:11 +00:00
MSedgeMC a26ffbb007 fix: light a dropped item from the block its eye is in
EntityType.ITEM declares eyeHeight(0.2125), and EntityRenderer.getPackedLightCoords
reads the block of Entity.getLightProbePosition, which Entity leaves as
getEyePosition: the item's position plus that eye height, at the position the frame
draws (getPosition(partialTick)), not this tick's own.

The dropped-item pass probed the cell of base + 0.1 at the tick's position, so a
falling item at y = 64.85 was lit by the cell at 64 (the floor's light) while its
eye at 65.0625 sits in the cell at 65, and the light jumped a frame early when an
item crossed a cell boundary.

EntityRenderer.getBlockLightLevel is isOnFire ? 15 : the level's block light, so a
burning item is now drawn at full block light with the level's sky light; the
item's fire ticks already clear for a fire-immune stack, which ItemEntity.fireImmune
makes never on fire.

A picked-up item's particle is not an entity and keeps the cell it is drawn in.
2026-11-05 20:14:11 +00:00
AxxioandAntigravity 46d83389ac launcher: serialize mod mutations per game home (fixes #10072)
Background automatic registry updates and manual mod operations (install,
remove, enable, disable) could mutate the same game home folder concurrently.
Because auto-update loads `mod-browser.json` before downloading and saves upon
completion, a manual install or removal occurring during the download would
be overwritten by the stale snapshot upon completion, losing manually added
mods or reviving removed mods. In addition, temporary save files could collide.

Introduce a per-game-home re-entrant mutex (`browser::lock::lock_home`)
covering mod mutation critical sections across background auto-updates and
manual operations. Use unique atomic temporary file paths during save to
prevent collision.

Co-Authored-By: Antigravity <[email protected]>
2026-11-05 21:11:12 +01:00
Horizon agentandClaude Opus 5.5 422c36b236 wire stream: eat's bites last 10 ticks, far from the server's 32
A loaded server catching up ran a 20-tick bite's remaining ticks in a burst and finished the apple.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 21:08:47 +01:00
Horizon agentandClaude Opus 5.5 2d0c715866 Launcher Wardrobe: the Shop button reads "Get more cosmetics"
It opens the launcher's Shop tab now, not the website, so the old label
"Get more cosmetics on playhorizon.dev" was wrong. The key is renamed in all 86
tables, in place.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 21:08:34 +01:00
Ludovic Marie f00c4c6392 Merge pull request #10139 from horizon-rust/feat/shop-tab-3
🤖 Agent: Pay checks that the checkout's uuid matches the recipient and only opens playhorizon.dev/shop/pay with a validated txn_ id; no card entry in the launcher.
2026-11-05 21:07:51 +01:00
Ludovic Marie 6e3ecedc2a Merge pull request #10138 from horizon-rust/feat/shop-tab-2
🤖 Agent: the Shop is a plain top-bar tab with no badge; it never opens by itself and isn't the start page; a catalogue failure shows one quiet line and Play is untouched; try-on uses #10153's Viewer (no second renderer).
2026-11-05 21:05:28 +01:00
Florian ERNSTandCodex 8089757807 Reject missing advancement selector criteria
Co-Authored-By: Codex <[email protected]>
2026-11-05 21:05:15 +01:00
Horizon agentandClaude Opus 5.5 532421c5f0 launcher: leave out the native mods that break a build, not the build
One mod that horizon-mixins refuses or that doesn't compile used to fail the
whole native build (#horizon-support 2026-11-05: eatinganimation's moved
target, hitbox_plus's support module, worldedit's moved chat.rs). The build
now leaves that mod out, with the mods that need it, and builds again:

- isolate.rs names the mod a horizon-mixins diagnostic is about (mod_id, its
  manifest, its support module's name or destination), or the mods the
  compiler's errors were traced to, and adds their dependents.
- A refusal costs one horizon-mixins run; a compile failure one compile, at
  most 3 times. A server engine export still fails as before.
- native-mods.json records `left_out`, so the same mods turned on read as up
  to date, and the Mods page says "X was left out: made for another Horizon
  version / clashes with Y / it needs Z, which was left out" (86 languages).
- MIXINS_REV → 5a64af9: horizon-mixins#27 resolves `horizon_client::…`
  targets that moved into horizon-client-lib, #28 follows workspace globs for
  a module's name and puts the mod id on module errors.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 20:58:01 +01:00
AxxioandAntigravity cfccbb8f40 hud: slice animated GUI sprites to first frame in atlas packing (fixes #9948)
Co-Authored-By: Antigravity <[email protected]>
2026-11-05 20:54:48 +01:00
Horizon agent e78ed4a7a1 Merge remote-tracking branch 'origin/main' into pvp/hud-editor-main 2026-11-05 20:43:04 +01:00
martipopsandClaude Opus 5.5 db65974cd8 Controllers: new default buttons
New players get the new layout. A file that bound a button by hand keeps it through the change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 14:40:02 -05:00
MSedgeMC eb6d139e34 test: a pack update keeps the player's mod choices
The fixture registry takes a pack version so the test can install
cozy 1.0.0, turn coords_hud off as the Mods page does, and install
cozy 1.1.0: the file is updated and the mod is still off. Turned back
on, 1.2.0 keeps it on. With the old unconditional set_enabled(m.on)
the update re-enables it and the test fails at 'the update kept it
off'.
2026-11-05 19:39:47 +00:00
MSedgeMC fc6f53a337 fix: a pack update keeps the player's mod choices
install_version_in() applied java::set_enabled(home, id, m.on) for
every mod of the pack on every install, and installing a registry pack
again is how it updates. horizon-mods.txt is the same list the Mods
page toggles, so a mod the player turned on or off in a pack profile
was reset to the pack author's 'on' by the next pack update.

The install now reads the pack's previous record (pack.json's 'id
version' entries) and carries each listed mod's current on/off choice
through the install, which turns a mod on as an explicit install. A
mod the new pack version adds takes the pack's own 'on', and a fresh
install has no previous record so every mod does, as before. The Java
mod path already keeps '.disabled' through updates the same way.
2026-11-05 19:39:47 +00:00
Ludovic Marie c58f498288 Merge pull request #10137 from horizon-rust/feat/shop-tab-1
🤖 Agent: shop price rules (sets partly owned, items inside a set in the basket, Supporter once) match the site's pricing.ts and are tested against hand-worked values; nothing player-visible yet. Requeued after merging main (#10153's lang tails).
2026-11-05 20:39:00 +01:00
DUzzL a13106bc0b Format container tooltip lines with numbered translation arguments 2026-11-05 20:35:02 +01:00
Ludovic Marie 1fdf875085 Merge pull request #9918 from oxi-does-dev/fix/interact-hit-location
🤖 Agent: Requeued on the same head: the parity run died in 1 s on the runner's missing .horizon-prepare.lock (environment, no harness ran), so it's rerunning (42040505129). The interact packet now carries the ray's hit point from the mob's feet. Union on main dd675b574 green.
2026-11-05 20:31:48 +01:00
Ludovic Marie bd340bf472 Merge pull request #9927 from CocofireHD/fix/9291-resolution-scale-shaders
🤖 Agent: With a scaled resolution, final now draws at the picture's size into its own target, and one linear copy stretches it to the window. Same-size output and packs without a final keep their old paths, so only resolution-scaled pack users take the extra pass. Union on main dd675b574 green (fmt, clippy, 4426 client tests including the render crate).
2026-11-05 20:31:43 +01:00
Ludovic Marie 6e98dae760 Merge pull request #9920 from DUzzL/fix/9899-boat-passenger
🤖 Agent: Matches LivingEntity.calculateEntityAnimation: a passenger calls walkAnimation.stop(), which zeroes speed, old speed and position, so the seat moving with the boat no longer swings the legs. Union on main dd675b574 green (fmt, clippy, 4426 client tests).
2026-11-05 20:31:39 +01:00
Ludovic Marie bff2562ec0 Merge pull request #9916 from CocofireHD/fix/9448-gl-depth-textureload
🤖 Agent: WebGPU allows binding a depth view as unfilterable-float, and textureLoad(...).r is the same texel, so Vulkan, DX12 and Metal read the same depth. The translation test now fails on any GL-run entry point that doesn't translate, except the one known glint case. Union on main dd675b574 green (fmt, clippy, 4426 client tests).
2026-11-05 20:31:35 +01:00
Florian ERNSTandCodex 16d11c6bb2 Require matching furnace output components before cooking
Co-Authored-By: Codex <[email protected]>
2026-11-05 20:28:16 +01:00
HiuoyandClaude Opus 5.5 b248489d15 Native hooks: PlayerEvent.BreakSpeed, how fast a player digs (#10198)
break_speed fires at the end of Player.getDestroySpeed, on the Game that
works out a dig: the window's own prediction, and the host's (single
player's server thread, a LAN or dedicated host) for each player. A mod is
told the block and vanilla's speed, and may change it;
BlockBehaviour.getDestroyProgress then divides it by the hardness.

tick_mining now takes its progress from destroy_progress, like the other
digs, so the speed is worked out in one place. The remote client asks it
for blocks of hardness 0 too, so a mod's speed of 0 stops those as well.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 23:28:08 +04:00
Ludovic Marie e3ad1ce3ee Merge pull request #9922 from MSedgeMC/test/animate-tick-neighbours
🤖 Agent: Merged main (9906 landed with the INF-seeded outline_center fold, kept from main) and ran marker_particles on this head: PASS, 2,187 cases and 462,718 lines matching the pinned client. The hive's loop now draws nextInt(1) again on the check that ends it, as BeehiveBlock.animateTick's for-condition does.
2026-11-05 20:27:42 +01:00
DUzzL 878342f98e Keep a synched horse rear until its flag clears 2026-11-05 20:22:16 +01:00
Riccardo GuerrieroandClaude Opus 5.5 6165c33ebe Climate searches pass distances along and stop a box's sum once it is too far
Part of #9997.

ClimateIndex::search, vanilla's Climate.RTree search, is about 5% of
overworld proto generation (callgrind, generate_proto, seed 12345) and 2%
of the nether's. About 21 boxes are measured per search: the root's two
children and the 19 below.

Three changes, none of which changes which leaf a search returns:

- The best leaf's distance goes down into each subtree and back up with
  its result. Vanilla measures the best leaf again at the start of every
  subtree and after it returns; the values are the same.
- The offset axis is always 0 in the target, so each box's squared
  offset distance is stored once instead of measured every time.
- When every bound in the tree and every target value is within 2^29,
  no term or sum of a distance can overflow, so a partial sum only grows.
  A box's sum then stops once it reaches the best distance so far: the
  search would only compare it with that distance and skip the box. The
  axes are added depth first, as surface biomes are points in depth.
  Otherwise the full wrapping sum is used as before.

The search still walks the same children in the same order from the
same previous leaf, and replaces the best only on a strictly smaller
distance, so ties go the same way. New tests compare it with a copy of
the old search over long target sequences that keep the previous
result, on the vanilla overworld and nether lists and on generated
lists full of ties, inverted spans, extremes and values at the bound.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WZzq8LUjYucp4kaN6UfSg7
2026-11-05 20:15:24 +01:00
Ludovic MarieandClaude Opus 5.5 e6d6ce8df2 Revert the command registries change: not shown to work in game
Commands typed in the window parse tags against the window's own tags,
which no server packet updates; that gap predates live switching and
needs its own change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 20:14:15 +01:00
foudretbr 1f15474046 Launcher: the proxy hint names SOCKS in every language
The Settings hint now reads (http://host:port, socks5://host:port). Each language keeps its translation, without the word HTTP and with the socks5 form added; i18n's tests want every string in every table.
2026-11-05 20:14:11 +01:00
foudretbr 114dbd0e21 Launcher: downloads can go through a SOCKS proxy
Turn on ureq's socks-proxy feature and stop refusing SOCKS addresses, in port-pack's parse and the launcher's settings. socks5h, curl's name for SOCKS5 with remote DNS, is read as socks5, which already works that way in ureq. A test runs a request through a stand-in SOCKS5 proxy.

Fixes #8143
2026-11-05 20:14:10 +01:00
DetimesandClaude Opus 5.5 6e083af988 Native mods: crates/horizon-mod-deps puts mysql, redis, tungstenite and rusty-opus in Cargo.lock (#10174)
A workspace member with no code whose dependencies are all optional: Cargo
locks them, so a native mod's crate may name them, and no build compiles them
(horizon-client's dependency tree, default and --no-default-features, is
unchanged). All four are pure Rust and use the rustls + ring already locked.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 19:11:58 +00:00
martipopsandClaude Opus 5.5 d8781cb9df Splitscreen: a player's pack reload no longer deletes P1's overlay
Fixes #10179.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 14:10:42 -05:00
andrescamilomartinezandAntigravity 2.0 313f65e049 fix(client): prevent taskbar overlap and window shift in borderless fullscreen
When entering borderless fullscreen on Windows while the window is
maximized, winit's WM_NCCALCSIZE handler forces the client area to
match the desktop work area (rcWork), which excludes the taskbar.
Because the window is sized to monitor dimensions, this clamps the
bottom edge above the taskbar and shifts the top edge offscreen by the
taskbar height (~48px), leaving the taskbar visible on ultrawide and
standard displays (upstream MC-311683).

Ensure that any transition to fullscreen unmaximizes the window first,
and explicitly pass the active monitor handle to Borderless mode so
the window covers the entire display area without vertical offset.

Fixes #9984

Co-Authored-By: Antigravity 2.0 <[email protected]>
2026-11-05 14:08:33 -05:00
Horizon agentandClaude Opus 5.5 abf29eb414 Multiplayer: a click lists its changed slots in the order vanilla's hash map iterates
MultiPlayerGameMode.handleContainerInput puts the changed slots into a new fastutil
Int2ObjectOpenHashMap and ServerboundContainerClickPacket writes them as it iterates: the key 0
first, then the table from its last position down. Horizon listed them in slot order. The wire
stream harness's inventory scenario caught it: a shift-click from slot 37 into slot 10 went out
37, 10 from vanilla and 10, 37 from Horizon, a byte-level difference any server can see.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 20:08:04 +01:00
Ludovic Marie 5b4823c198 Merge pull request #10093 from horizon-rust/emotes/3-network
🤖 Agent: emotes, part 3 (#10069): others see your emote through the cosmetics service, now based on main. CI verify is green. Polling is bounded: at most once a second, only while Horizon players are in view, and only with Other players' cosmetics on Shown. Sending is signed and only while Show me as Horizon is on. Without horizon-cosmetics#14 deployed, the service answers 404 and the game backs off.
2026-11-05 20:07:45 +01:00
Ludovic Marie c6ad84b481 Merge pull request #10092 from horizon-rust/emotes/2-play
🤖 Agent: emotes, part 2 (#10069): the wheel and playback, retargeted to main after #10091. CI verify is green. Two clients played every emote in game; captures are in the PR. Visual only, nothing sent to game servers. #10093 follows.
2026-11-05 20:05:22 +01:00
MSedgeMC 4b8abba21c test: an automatic update keeps a disabled mod off
Install ruby 1.0.0, turn it off in the Mods page, run the automatic
update to 1.1.0: the file is replaced and the mod is still off. Turned
back on, the next update keeps it on, and an explicit install of a
disabled mod turns it on again. Without the fix the first update
re-enables it and the test fails at 'the update kept it off'.
2026-11-05 19:04:13 +00:00
MSedgeMC e932cade0c fix: an automatic update keeps a disabled mod off
install_version_guarded() turned every mod it installed on with
java::set_enabled(home, id, true), and the automatic registry update
(browser/horizon/auto.rs update) installs through the same function.
A content mod the player had turned off in the Mods page therefore came
back on after every automatic update: 'off' is the absence of the
'mod.<id>:true' line in horizon-mods.txt, which the update re-added.

The guarded install no longer touches the flag. The player's own
install (install_version, which the browser and modpack paths use)
turns the mod on after the guarded install, as before, while the
automatic update leaves the mod's current on/off choice alone. A new
install still starts on because it goes through install_version.
2026-11-05 19:04:13 +00:00
Horizon agentandClaude Opus 5.5 4a5450c6f8 horizon-report: is_multiple_of (clippy)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 20:02:47 +01:00
MSedgeMC 404cf600c1 port_map: CreativeModeInventoryScreen.keyPressed is the chat key's search tab 2026-11-05 19:02:11 +00:00
MSedgeMC b66525f609 test: the creative chat key opens a focused, empty search tab
`Creative::search_tab` and `select` as `Game::key_pressed` uses them: the
search tab is found by its type, selecting it focuses the search box, and the
query the previous tab left is emptied.
2026-11-05 19:02:11 +00:00
MSedgeMC 1b8b51978d feat: the chat key opens the creative inventory's search tab
`CreativeModeInventoryScreen.keyPressed` takes the chat key on a tab that is
not the search as "show the search tab": it selects
`CreativeModeTabs.searchTab()` and sets `ignoreTextInput` so the letter the key
carries never reaches the search box. Horizon had neither: T on a creative tab
did nothing, and the letter went nowhere.

`Creative::search_tab` finds the search tab by its type, so the key selects it
through the same path a tab click uses (`select_creative_tab`, which clears the
quick-craft clicks as `selectTab` does). `Game::key_pressed` takes the chat key
there, and the `ignoreTextInput` flag it sets makes `type_chars_into_screen`
(`charTyped`) drop that one letter and no other.
2026-11-05 19:02:11 +00:00
Ludovic Marie 0508903777 Merge pull request #10091 from horizon-rust/emotes/1-format
🤖 Agent: emote format, the bottom of the emotes stack (#10069). pr_ready on the whole stack is green except the NixOS-only uname test. pose_body copies the target at full weight: 0.26–1.1 µs per emoting player and frame, measured. #10092 follows once this lands.
2026-11-05 20:01:16 +01:00
Horizon agentandClaude Opus 5.5 d216a5477d horizon-report: loose entities of every level; rustfmt
The items, orbs, projectiles, minecarts and boats were counted for the level in place
only (the End, on the SMP, when the report ends), so tick_boats' 3 ms had no count
beside it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 20:00:16 +01:00
Horizon agentandClaude Opus 5.5 216a6218af wire_stream: compare Horizon's Legacy combat feel with Modern, packet for packet
--clients horizon,horizon-legacy plays every scenario with two Horizon clients, the
second with the Legacy feel and every combat extra on, the 1.8 rules included, and
compares their serverbound streams as the vanilla comparison does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 19:59:02 +01:00
Horizon agentandClaude Opus 5.5 5f69e3a789 Mods: commands see the tags a live switch or a data pack added
The command registries took item and block tags from the resources the
game started with and were built once per world, so /execute and
/give refused a tag a switched mod added (seen in game: Unknown item tag
'c:gems/glint'). They now read the world's tags and are built again
after a live switch's data reload.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 19:57:44 +01:00
AxxioandAntigravity be42c5d25e game, mobs: align sulfur cube prime, vibrations and TNT drop decay with vanilla (fixes #9957)
Co-Authored-By: Antigravity <[email protected]>
2026-11-05 19:56:42 +01:00
Horizon agentandClaude Opus 5.5 ef4463a476 horizon-report: spans inside tick_lan and tick_sculk_entities
On the official SMP, 12.6 of tick_lan's 14.7 ms a tick and all of tick_sculk_entities'
6.4 ms had no span below them. Name the steps: guest ticks, entity states, the hub's
level pass (world sync, chunks, block changes, containers, entity tracking, player
state), the end of the tick and the host events; sculk's prepare, wardens, step-on and
entity game events. Spans cost nothing while off.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 19:56:42 +01:00
Horizon agentandClaude Opus 5.5 ee7a6dd9c8 wire stream: dig_teleport compares the ticks after the teleport aligned on its acceptance
The client sends /tp at a fixed tick, but a loaded server answers 2-6 ticks later from one run to the
next (seen with two identical Horizon clients). The ticks before the teleport compare one to one; from
the client's AcceptTeleportation on they align on it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 19:54:30 +01:00
Horizon agentandClaude Opus 5.5 e7c5cb7331 Merge main (via feat/shop-tab-2) into feat/shop-tab-3
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 19:54:27 +01:00
Horizon agentandClaude Opus 5.5 5583a6bd8b Merge main into feat/shop-tab-2; the Shop tries items on in the Wardrobe's 3D view
#10153 replaced the Wardrobe's still picture with viewer::Viewer. The Shop now
uses its own Viewer (drag to turn, scroll to zoom, Reset view) instead of the
still picture and its Turn button, so there is one renderer.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 19:54:15 +01:00
Horizon agentandClaude Opus 5.5 e15d46d371 Merge main into feat/shop-tab-1
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 19:52:52 +01:00
degepieandClaude Sonnet 5.5 dcffd6cae9 Fire the enter_block trigger from the server's block pass
23f8bd4fe removed the only consumer of MotionEvent::EnterBlock, and the event was pushed only in the client pass. The server pass pushes it now and the guest server tick triggers it for the acting player.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-11-05 23:52:32 +05:00
Horizon agentandClaude Opus 5.5 ab9929b5ab horizon-report: per-mob-type cost, level spans, chunk workers, network, overhead, stop/status
The official SMP is mob-bound (5,400 mobs, 73 ms a tick at 60 players) but the report
only said tick_creature_entities took the time, not which mobs. Format 2 adds, without
a restart and at a measured cost:
- mob_costs: one tick in four times every mob's tick and sums it by dimension and type
  (ms a tick, mobs ticked, us a mob);
- level_* spans: each dimension's share of the server tick;
- chunk_workers: streaming::job_stats while recording (load/generate/decorate/light...);
- network: wire bytes in/out per player and in all (connection counters, no new
  per-packet cost);
- overhead: spans and mob samples a tick at their price measured on the machine;
- horizon-report stop / status.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 19:47:54 +01:00
KBroneZandClaude Opus 5.5 7fcf18b6df test: /forceload add loads the chunk for the next command
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 20:44:38 +02:00
KBroneZandClaude Opus 5.5 ed01ae3029 fix: handle the container loot table event in the block update probe
Goes with "keep a placed template's container loot table".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 20:44:37 +02:00
KBroneZandClaude Opus 5.5 a9c7b82772 fix: load chunks in /forceload add, as vanilla does
`ServerLevel.setChunkForced` calls `getChunk` when a chunk becomes forced, so a
command right after `/forceload add` finds it loaded. Horizon only queued it, so
`execute in minecraft:the_nether run function` with `forceload add`, `place
jigsaw` failed with "That position is not loaded" and a skyblock pack's nether
island never appeared.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 20:44:37 +02:00
KBroneZandClaude Opus 5.5 694d953496 feat: read structure templates from data packs
A data pack's `data/<ns>/structure/*.nbt` were ignored. A jigsaw pool naming one
placed nothing, so `/place jigsaw` failed with "Failed to generate jigsaw" and
`/place template` said the template does not exist; that is how a skyblock pack
places its island. Packs also could not replace vanilla's templates.

The enabled packs' templates now join the worldgen overrides, highest pack first,
over the prepared ones, as `StructureTemplateManager` reads resources. `/place
template` and structure blocks look in the world's `generated` folder, the packs,
then the game's own.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 20:44:36 +02:00
KBroneZandClaude Opus 5.5 2f9ec5be67 test: a placed template's chest keeps its loot table
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 20:44:36 +02:00
KBroneZandClaude Opus 5.5 cf31690cae fix: keep a placed template's container loot table
`LevelAccess::load_block_entity` decoded a template's block entity, and that
drops `LootTable`, so a chest placed by `/place template`, `/place jigsaw` or a
structure block came out empty. A skyblock pack's starter chest has its items in
a loot table. Report the table as a level event and hand it to `take_loot_table`,
as `/setblock` and `/data` do (`RandomizableContainer.tryLoadLootTable`).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 20:44:36 +02:00
KBroneZandClaude Opus 5.5 3f782b89ee fix: upgrade a simple state provider to a state object, not a bare id
26.3 reads a direct block state provider as a state object. A bare string names
a provider in the `worldgen/block_state_provider` registry, so an upgraded
feature with a default-state `simple_state_provider` failed with
`unknown state provider minecraft:air` and was skipped at world start. Vanilla's
own features write `{"id": "minecraft:clay"}`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 20:44:35 +02:00
KBroneZandClaude Opus 5.5 5062da5567 fix: upgrade a 26.2 noise with no octaves to a silent 26.3 noise
Void packs turn off vanilla's iceberg noises with
`{"firstOctave": -1000, "amplitudes": []}`. The upgrader left this as it was and
called it "more than 32 octaves or negative amplitudes"; 26.3 cannot read it, so
the world failed to start with `invalid world generation field: base_octave`.
With no octaves the noise is 0 everywhere, which 26.3 writes as one muted octave:
`{"base_octave": 0, "amplitude_modifiers": [0.0]}`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 20:44:34 +02:00
KBroneZandClaude Opus 5.5 69ecedfc01 fix: read default_block and default_fluid in 26.3's block state form
`state()` in the generator read only `{"Name", "Properties"}`, or the id alone,
so noise settings with `{"id", "properties"}` failed the world start with
`invalid generator resources: block state`. That is the form 26.3 uses and the
one `horizon-port-pack upgrade-datapack` writes for any non-default state. Decode
these two fields with `BlockStateJsonCodec`, as the rest of worldgen does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 20:44:34 +02:00
DUzzL f2dfddea11 Handle F3 debug keys on the title screen 2026-11-05 19:44:34 +01:00
Horizon agentandClaude Opus 5.5 b5ed12bc33 wire stream: compare a click's state id as its lag behind the server; hotbar_swap swings at the sky
A run's state ids count every change its server made since the login, so a click numbered 53 in one
run and 55 in the other were both up to date. hotbar_swap now also taps a number key with a swing at
nothing, where vanilla sends the slot only on the next tick.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 19:43:04 +01:00
Horizon agentandClaude Opus 5.5 e5105f07eb Pick: aim with vanilla's table trigonometry, so a placement's cursor matches to the bit
GameRenderer.pick aims along Entity.calculateViewVector: float radians through Mth.sin and Mth.cos,
the 65536-entry table. Horizon aimed with exact double trigonometry, which moved the hit point a
placement sends (ServerboundUseItemOnPacket's cursor) and an interaction's point by up to ~1e-4 of a
block. The wire stream harness's bridge scenario showed it: same position and look, cursor z
0.7173712 from vanilla, 0.7173119 from Horizon. Anticheats that recompute the cursor from the ray
compare it to vanilla's arithmetic. The other view_vector callers (projectiles, dragon, boats...)
are left for their own checks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 19:42:49 +01:00
Ludovic Marie f2f95dad8a Merge pull request #10153 from horizon-rust/agent/wardrobe-3d
🤖 Agent: verified on the desktop at this tree: horizon-cosmetics fmt/clippy/tests (40) and the launcher's fmt/clippy/tests (695) green; pr_ready skipped because the desktop disk was under 45 GB. Point to watch: preview::render now takes a Camera (launcher is its only caller), and lib.rs will conflict by one line with the emote format PR #10091.
2026-11-05 19:38:53 +01:00
VaspyyyandClaude Opus 5.5 0e33837c78 Play a station's take sound once per shift-click
Shift-clicking a stonecutter result queued ui.stonecutter.take_result for
every item taken, and the server side played every copy at once: cutting
60 granite played the sound 60 times stacked. Vanilla's lastSoundTime
lets it through once per tick. The menu now queues each take sound once
per click (stonecutter, loom, cartography table), and lan_station_sounds
plays one sound per tick.

Fixes #10148

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 19:37:45 +01:00
makscoder 53aad00102 Snow, dripstone clay and spreading sculk lift what stands on them
`Block.pushEntitiesUp` runs when these blocks gain the collision they add on
top. The farmland/path to-dirt path already emits the `push_entities_up` block
event; the three growth paths did not, so a body standing on them sank into the
new block.
2026-11-05 21:35:45 +03:00
martipopsandClaude Opus 5.5 f513a784f0 Merge upstream/main into fix/pad-e2e-linux: #9946 edited the same file
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 13:34:34 -05:00
Ludovic Marie 56349b9713 Merge pull request #9926 from masa-masa-0314/feat/realms-settings-tab
🤖 Agent: Fixed to the 26.3 jar's DTOs: the region preference travels as its id (0/1/2) and the region as its nameId, so a Realm that has a preference parses again (it used to fail the whole RealmServer parse). preferredRegions reads regionDataList/regionName, and the configuration POST puts the slot under options with update_slot's RealmsSlotUpdateDto. New tests pin the POST body and the parsing; CI verify is green on this head.
2026-11-05 19:34:24 +01:00
Vinícius MoreiraandCodex 3c794c2825 Fix shader-pack color-buffer reads in translucent draws
Preserve the preceding scene color when a translucent shader draw reads
and writes the same buffer, rather than sampling a white fallback.
Reuse the existing snapshot mechanism to avoid attachment feedback.

Validated with Sildur’s Vibrant before-and-after captures and an
original asset-free GPU regression.

Co-Authored-By: Codex <[email protected]>
2026-11-05 15:33:25 -03:00
HiuoyandClaude Opus 5.5 29662d963c Native hooks: LivingDamageEvent.Pre and .Post (#10132)
living_damage_pre and living_damage_post fire at the end of
LivingEntity.actuallyHurt, for players, mobs and the ender dragon. Pre is
told what is about to come off the health, once armour, enchantments,
effects and absorption have taken their part, and a mod may change it.
Post is told what came off, 0 when nothing did. The translated actuallyHurt
in horizon-world gets two hook points whose defaults do nothing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 22:32:13 +04:00
Horizon agentandClaude Opus 5.5 4f0af1f5f2 Merge main (via feat/shop-tab-2) into feat/shop-tab-3
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 19:29:24 +01:00
Horizon agentandClaude Opus 5.5 75adf421e0 Merge main (via feat/shop-tab-1) into feat/shop-tab-2
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 19:29:22 +01:00
Horizon agentandClaude Opus 5.5 ec78951af1 Merge main into feat/shop-tab-1 (lang tables re-appended)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 19:29:20 +01:00
degepieandClaude Sonnet 5.5 c69d03f96e Resource packs: read a lang file leniently when merging the layers
#9560 made Language::load lenient, but the pack merge parsed each layer with strict JSON and dropped the ones that failed, so a lang file with # comments never got that far.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-11-05 23:25:08 +05:00
Horizon agentandClaude Opus 5.5 85056bcaaf Realms settings: rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 19:20:46 +01:00
Florian ERNSTandCodex de4029c1df Merge main and retain raid harness observations
Co-Authored-By: Codex <[email protected]>
2026-11-05 19:16:54 +01:00
Evrim CicekandClaude Opus 5.5 1bf04aa84f The vehicle's box is read now, and only for the vehicle ridden
From review, against vanilla (getAttackBoundingBox and getHitbox read the
current getVehicle()):
- A mob vehicle's box comes from the mob snapshot, which holds it as it is
  after its own tick this tick, not from the last positionRider.
- The seat positionRider records names its vehicle, and is used only while
  the mob rides that one: a dismount or a new vehicle no longer reads an old
  vehicle's box or attachment.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 21:13:30 +03:00
Federico TaglianiandCopilot App 8a2619be29 Make launcher titlebar match glass UI
Co-authored-by: Copilot App <[email protected]>
2026-11-05 19:11:13 +01:00
HiuoyandClaude Opus 5.5 8ec7d24add port-pack events: RecipesUpdatedEvent is looked up as RecipesReceivedEvent
1.21.1's client had every recipe and 27 of ATM10's mods listen to
RecipesUpdatedEvent to rebuild what they made of them. 26.1's client gets
only the recipe types a mod asks for, in RecipesReceivedEvent (NeoForge's
documentation, "Client-Side Recipes"), which recipes_received answers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 22:10:13 +04:00
Ludovic Marie ff4b4b17bd Merge pull request #9944 from MSedgeMC/fix/steering-stick-wear
🤖 Agent: wear() still fires the durability trigger after Unbreaking, and the broken stat now comes before the rod. The parity failure at ~17:00 was the playhorizon runner's dead .horizon-prepare.lock link, not this change: rerun on this head.
2026-11-05 19:09:06 +01:00
Ludovic Marie 120fe4d7e3 Merge pull request #9941 from MSedgeMC/fix/bundle-tooltip-display
🤖 Agent: The height probe and the draw share bundle_tooltip, so a hidden grid can't leave an empty box taller than its lines. The parity failure at ~17:00 was the playhorizon runner's dead .horizon-prepare.lock link, not this change: rerun on this head.
2026-11-05 19:09:02 +01:00
Ludovic Marie bebaecd65a Merge pull request #9939 from degepie/fix/name-tag-interact-trigger
🤖 Agent: The tag is copied before name_mob uses it up, like handleInteract's itemStackBefore. Follow-up gap, not this PR: vanilla also fires the trigger with an empty stack on non-item successes. The parity failure at ~17:00 was the playhorizon runner's dead .horizon-prepare.lock link, not this change: rerun on this head.
2026-11-05 19:08:58 +01:00
Ludovic Marie 674de01447 Merge pull request #9938 from degepie/fix/drink-consume-trigger
🤖 Agent: Same two calls finish_consuming makes, before the stack shrinks, as Consumable.onConsume orders them. drink_ominous_bottle awarded neither, so nothing counts twice. The parity failure at ~17:00 was the playhorizon runner's dead .horizon-prepare.lock link, not this change: rerun on this head.
2026-11-05 19:08:52 +01:00
Ludovic Marie d1dc1cb937 Merge pull request #9937 from degepie/fix/structure-tag-predicates
🤖 Agent: The pack-less structure tags are a OnceLock keyed on the first resource root, as StructureSpawns::shared is. location_structure_tags_hold_their_structures passes with prepared resources. The parity failure at ~17:00 was the playhorizon runner's dead .horizon-prepare.lock link, not this change: rerun on this head.
2026-11-05 19:08:48 +01:00
Ludovic Marie b0061bc2d3 Merge pull request #9946 from martipops/controller/pad-reach
🤖 Agent: On playhorizon (vscreen, lavapipe) every_screen_has_targets, pause_icon, erase_checkbox, controller_settings_pad and settings pass. The empty-label target can add a second target on a rect that already called target_as: drawn_targets dedups only against labelled widgets, not between targets. Nothing on main seems to do that today. Union run on playhorizon (main a1e0c0db0 + this gate's 11 PRs): fmt, clippy, all touched crates' tests pass.
2026-11-05 19:08:44 +01:00
Horizon agentandClaude Opus 5.5 b162cbaa46 Launcher: the Wardrobe's player in 3D, turned by drag, zoomed by the wheel
The Wardrobe's still picture becomes a live view of the player's own skin
(both layers, slim or wide arms) wearing their items, drawn by the same CPU
renderer (horizon_cosmetics::preview) only when something changed:

- drag to turn (followed past the view's edges while held), scroll to zoom
  up to 3x on the head, Reset view; hovering an owned item's card tries it
  on (Nothing takes the slot's off) until the pointer leaves;
- items animate (swing, bob, spin) on the game's 20-tick clock with
  Item::animate, as the game does, only while the window is in front and
  not under reduced motion (HORIZON_LAUNCHER_MOTION=always keeps them going);
- horizon_cosmetics::viewer::Stage decides each frame: nothing changed, no
  picture and no timer; at most 20 pictures a second while animating (one a
  tick), 60 while dragging. Pictures are drawn at the view's size in points
  (240x330, about 2 ms) and baked up to device pixels;
- preview::Camera holds the turn, tilt and zoom (with headroom above the
  head for tall hats and back items), Draw::item_posed keeps items on posed
  bones, and viewer::BodyMotion is the hook an emote plays through
  (Viewer::play), for the emote format in flight (#10091).

The view is a GPUI view of its own, so a new picture rebuilds it alone;
the pointer is handled by the page's element around it, since GPUI Fast
hit-tests the page's own elements. "Turn" gives way to "Reset view" and a
hint, in every language.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 19:08:41 +01:00
Ludovic Marie 0ad6666970 Merge pull request #9942 from TheMrZZ/fix/enderman-neighbour-placement
🤖 Agent: Deferring the attempt to the event phase lets update_from_neighbour_shapes see the real level and ticks, as EndermanLeaveBlockGoal.tick does. leave_block_goal_uses_real_neighbor_shapes_and_keeps_air_placement passes with prepared resources. Still missing: canSurvive and the BLOCK_PLACE game event. Union run on playhorizon (main a1e0c0db0 + this gate's 11 PRs): fmt, clippy, all touched crates' tests pass.
2026-11-05 19:08:39 +01:00
HiuoyandClaude Opus 5.5 62a47df275 Native hook catalog: NeoForge labels for five more hooks
frame, gui_render_post, tooltip_component, experience_orb_pickup and
item_dropped get the NeoForge event ATM10's mods listen to for the same
point: RenderFrameEvent.Pre, RenderGuiEvent.Post,
RenderTooltipEvent.GatherComponents, PlayerXpEvent.PickupXp and
ItemTossEvent. The bracketed notes say where a hook differs (#9921).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 22:07:18 +04:00
CocofireHDandopencode 232b0e85aa UI: a button's highlight goes when its screen leaves
Vanilla's Gui.setScreen calls Screen.clearFocus on the screen it leaves, so
a screen that comes back starts with no widget focused. Horizon kept the
focus a mouse click gave a button (the title's focused_button, pause_focus,
death_focus, the options' focus), so going back to the main menu redrew that
button highlighted (AbstractWidget.isHoveredOrFocused) with the pointer
somewhere else.

The front screen is now tracked with key_screen() (which the keyboard's focus
already uses for its own screen changes); when it changes, every menu drops
its click focus. The startup menu does the same for its title behind the
options, before the loading hands that title to the game.

Fixes #9273

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-05 19:06:06 +01:00
Horizon agent 1011abe707 Merge branch 'emotes/2-play' into emotes/3-network 2026-11-05 19:04:32 +01:00
Horizon agentandClaude Opus 5.5 6f88fac9a9 Emotes: the wheel's key release and click in emote_wheel.rs (keeps game.rs's release block whole)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 19:04:32 +01:00
Nikita Maximilian Grimm 02ba821bcf Merge remote-tracking branch 'origin/main' into hdr-note-serve-0-1-until-step-5
Co-Authored-By: GPT 6.1 Sol <[email protected]>

# Conflicts:
#	docs/api/shaders.md
2026-11-05 19:04:22 +01:00
Ludovic Marie baa9153c82 Merge pull request #9943 from CocofireHD/fix/9439-tank-refuse-fluid
🤖 Agent: native_fluid_at now allocates the block id String on every call, drains and tank queries included. That's fine for mod helpers, but worth an &'static str if a tick path ever calls it. The fragment stamps (tests.test_stamp_native_hooks passed in the union run). I ran the native_fluids tests on this head on playhorizon, 12 passed, the new dispatch test included.
2026-11-05 19:03:54 +01:00
Horizon agentandClaude Opus 5.5 a14ca6acf4 wire stream harness: headless slot clicks, the reset's self-teleport, signed block positions, two short bites
- click-slot without a renderer clicks the menu itself (the vanilla driver's handleContainerInput); horizon-ctl's
  clicks where the slot is drawn, which --headless never does.
- The reset's last teleport is the player onto itself: from the console `~` is the world spawn, which put the
  player inside bridge's stone.
- BlockPos.asLong decodes with each part signed.
- eat cuts its two bites short: when a full one ends is the server's entity event, late on a busy server.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 19:00:39 +01:00
Horizon agentandClaude Opus 5.5 cc7200cd7a Multiplayer: the held slot goes out with the packets vanilla's game mode guards, not with any click
MultiPlayerGameMode.ensureHasSentCarriedItem runs in attack, interact, useItem, useItemOn,
releaseUsingItem, dropItem, piercingAttack and continueDestroyBlock only. A swing at nothing,
the start of a dig or an offhand swap sends no slot: a number key pressed in that tick goes out
at the start of the next one (MultiPlayerGameMode.tick). Horizon sent the slot whenever the tick
had a click. The wire stream harness's hotbar_swap scenario caught it: a tap of 2 with a swing at
the air sent SetCarriedItem before the Punch, a tick before vanilla.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 18:59:36 +01:00
makscoder 05bc471106 A refused spectator opening gets the red action bar
ServerPlayer.openMenu, when createMenu returns null for a spectator, sends
container.spectatorCantOpen to the action bar (red). Horizon's refusal in
spectator_open_menu and the chest-boat guest handler was silent.
2026-11-05 20:59:03 +03:00
Ludovic Marie 936594e71b Merge pull request #10078 from horizon-rust/pvp/hud-layout
🤖 Agent: merging. The layout lines only add keys to horizon-extras.txt; files without them load as before (every widget at its default place and style). Verify is green; stacked on #10077.
2026-11-05 18:58:43 +01:00
Ludovic Marie 1e5bf4c58f Merge pull request #10077 from horizon-rust/pvp/mod-hud
🤖 Agent: merging. Every widget reads local state only (no packets, no server queries); with every widget off, draw_pvp_hud returns after Hud::any(), so a player who never turns one on pays nothing. Verify is green; pr_ready --quick on playhorizon fails only the render crash:: tests that SIGSEGV under llvmpipe there, and crash.rs is untouched.
2026-11-05 18:58:39 +01:00
Ludovic Marie 35fe345f50 Merge pull request #9923 from CocofireHD/fix/9500-shulker-close-open
🤖 Agent: open_menu already counts the guest as an opener (container_opener_event), so the raise in lan_open_container counted them twice and stopOpen never reached 0, for chests as well as shulker boxes. The author's lan_e2e shulker_menu check pins the lid open, then shut, on both host and guest. Union on main dd675b574 green.
2026-11-05 18:58:35 +01:00
Ludovic Marie a553d191a8 Merge pull request #9917 from CocofireHD/fix/8615-vibration-particles
🤖 Agent: The server now sends one minecraft:vibration LevelParticles (count 1, no spread) with the listener's PositionSource and arrival ticks, and only draws locally when the window is the client, so a server-thread world doesn't draw it twice. Tested at a729b3cfe in a union on main dd675b574 (fmt, clippy, 4426 client tests green); this head only adds the author's main merge (test helper conflict) on top.
2026-11-05 18:58:26 +01:00
AxxioandAntigravity 8c3f6e7d7b Seat camel husk jockey's parched in camel second seat (fixes #9961)
Co-Authored-By: Antigravity <[email protected]>
2026-11-05 18:57:44 +01:00
Evrim CicekandClaude Opus 5.5 934c4aaabf A mounted mob reaches from its vehicle's width, and a rider is hit from its seat up
Mob.getAttackBoundingBox widens the attacker's box on x and z to its
vehicle's before the melee reach is added, so a zombie on a horse, a
chicken jockey or a mob in a boat reaches as far as the side of what it
rides. LivingEntity.getHitbox starts a rider's box at its seat
(vehicle.getPassengerRidingPosition(this).y), so its legs can't be hit; for
a riding player that is 0.6 above its feet (Avatar.DEFAULT_VEHICLE_ATTACHMENT).

positionRider now records each mob rider's own vehicle attachment and its
vehicle's box (mobs, boats, minecarts, primed TNT, displays), which the
melee check and the slime's touch read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 20:50:13 +03:00
Claude cc474268e7 Launcher: a server on another computer, run over SSH
An Entry on the Servers list can name a host: its folder is then on that
computer, and Entry::command runs the same horizon-server arguments there
as `ssh HOST horizon-server --dir DIR ...`, with the system's ssh and the
player's keys (BatchMode, so no password is asked for or kept). Each
argument goes to the remote shell in single quotes, and a host that ssh
could read as an option is refused. remote::check asks the server there
for its status and says in plain words what is missing: a key, the host,
horizon-server, or a server in that folder.

Lists written before keep reading as they were; nothing on the page uses
remote entries yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Jh5A2PzdusBChqE38VZ3zD
2026-11-05 17:48:14 +00:00
DUzzL 4272d9ae41 Mobs on a ladder or vine are not pushable; no crit while climbing 2026-11-05 18:47:31 +01:00
Horizon agentandClaude Opus 5.5 e26dae0527 wire stream harness: 15 minutes for a loaded machine's server to start
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 18:45:08 +01:00
yahya-nusseibehandClaude Opus 5.5 45af2b23b2 Network NBT: no more tags than vanilla's quota admits
Vanilla reads network NBT with NbtAccounter's 2 MiB quota, which
charges each tag an estimate of its heap size: at least 8 bytes (EndTag,
the smallest; a byte tag in a list costs 13). Horizon counted only the
bytes read, plus up to 1,000,000 nodes, so a crafted server could send a
1 MB list of byte tags that vanilla refuses and have Horizon build ~1 M
32-byte Tags from it, about 16 times the memory.

- buf::Reader::nbt caps a network NBT value at NBT_QUOTA / 8 = 262,144
  tags (NBT_MAX_TAGS). Every value vanilla accepts has at most that
  many, so nothing vanilla accepts is refused, and one value's tags now
  take at most ~8 MiB here.
- custom::validate checks component values with the same reader, so it
  follows.
- Test: a list of 262,143 byte tags (with its root, 262,144) reads; one
  more is refused although it is a few hundred KB.

A full port of NbtAccounter's per-type charges would be closer still;
this bound needs only its smallest charge, so it can't refuse what
vanilla takes.

Found in yahya-nusseibeh's review of Horizon (L2).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-05 19:44:32 +02:00
Horizon agentandClaude Opus 5.5 186a478e21 test_wire_stream: load the decoder by path, so tools/ never shadows a tests/ module
tools/ first on sys.path made unittest discovery import tools/test_world_spawn_parity.py for
tests/test_world_spawn_parity.py.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 18:44:02 +01:00
DUzzL de7d92e134 Keep one item in item frames filled by /item or NBT 2026-11-05 18:43:51 +01:00
MSedgeMC 8c6933bda4 port_map: Hud.extractTabList is tab_list::visible
The row was absent; map-check: 150 rows, no problems.
2026-11-05 17:40:40 +00:00
MSedgeMC 4ea8baade5 test: F1 hides the player list even while Tab is held
tab_list::visible with the HUD hidden answers false for a held key with two
listed players and for a local server with a list objective, and the
scoreboard's LAN test asserts the same through the game's call.
2026-11-05 17:40:32 +00:00
MSedgeMC 15047ffac4 fix: F1 takes the player list away
Hud.extractRenderState calls extractTabList inside `if (!isHidden)`, so with
the HUD hidden (F1) the player list does not draw at all, whatever the
player-list key or the list objective says.

Horizon's hud_layers only skipped the tab list under F1 when the chat screen
was open: with no screen, holding Tab drew the list over the hidden HUD. The
gate is now in tab_list::visible, next to the key and the local-server rules
it already applied, so every caller gets it.
2026-11-05 17:40:23 +00:00
yahya-nusseibehandClaude Opus 5.5 3b71256fcd RCON: one password guess per connection
After a refused login the connection stayed open, so within the 30 s
login window a client could send as many AUTH packets as it liked: with
the 16 connections allowed at once, thousands of guesses a second
against an RCON port on 0.0.0.0, each adding a supervisor log line.
Vanilla does the same, but this module already departs from vanilla for
safety (constant-time comparison, login deadline, client cap), and this
belongs with those.

- A refused login (wrong or empty password) answers as before, waits
  REFUSED_PAUSE (1 s) and closes the connection. Its place stays taken
  meanwhile, so the client cap also bounds how fast passwords can be
  tried: at most 16 guesses a second instead of thousands.
- A command before the login is still refused without closing, and the
  wire protocol is unchanged; standard RCON clients connect again after
  a refusal.
- Tests: a wrong and an empty password each end their connection after
  the pause; a failed login after a successful one does too. The pause
  is a Limits field so tests use 200 ms.

Found in yahya-nusseibeh's review of Horizon (L3).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-05 19:37:23 +02:00
DUzzL b20ab60d01 Make a splash of harming teleport an enderman instead of hurting it 2026-11-05 18:35:16 +01:00
CocofireHDandopencode 1c712e434a Mod fluid tanks: prove a hook refusal through the dispatch
A focused test drives the public block_entity_accepts_fluid dispatch, as the game does: a water tank that refuses a mod's fluid leaves both insert passes empty and offers native_move_fluids no room, while water and a tank with no hook still fill.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-05 18:34:29 +01:00
yahya-nusseibehandClaude Opus 5.5 c0e2f62b35 Telemetry server: hub, intake and export off the async workers too
The server runs two async workers and keeps SQLite on tokio's blocking
pool (lib.rs, `blocking`), but /hub/vote, /hub/votes and /hub/report and
the token-protected /v1/export/* still called app.store() on the
workers. While a blocking-pool task holds the store mutex (a large batch,
the daily upkeep), two votes or reports from different addresses park
both workers on it and every handler stops answering, /healthz
included; per-address limits don't help, it takes two requests.
/hub/votes also held votes_cache's lock while waiting for the store.

- hub::vote and hub::votes run their store calls through blocking();
  votes reads the cache, releases its lock, counts on the blocking pool
  and stores the result after.
- intake::receive checks the day's count and queues the report in one
  blocking() call, under one store lock as before; report marks the
  issue filed through blocking().
- export::benchmarks and export::hardware read through blocking().
- A failed blocking task answers 503 "try again later" through each
  module's own refusal (the hub's keeps its CORS headers).

Behaviour is unchanged otherwise: the crate's 58 tests pass as they are.

Found in yahya-nusseibeh's review of Horizon (M1).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-05 19:32:12 +02:00
Riccardo GuerrieroandClaude Opus 5.5 817c37749f Perlin volumes skip the Ys a lerp does not read, and batch Ys across cells
Part of #9996.

The terrain noise (old_blended_noise) is a lerp between two 16-octave
smeared Perlin stacks. Lerp reads its first branch only where alpha is
not 1 and its second only where alpha is not 0, and in the overworld
about 37% of the coarse points have alpha 1 and 50% alpha 0. The lerp
now passes each branch a bit per Y of each column it reads, and a noise
volume skips the others (DensitySampler::sample_needed,
Noise::add_to_needed; samplers without it still sample everything).

When a column's Y cells change at every Y, as in the stacks'
high-frequency octaves, four Ys in four cells now go through the same
vectorized lane code as four Ys in one cell, each lane with its own
cell's corner terms. Gradient lookups are a loop rather than
array::map, which LLVM had left out of line.

Every value is computed by the same operations in the same order:
snapshots match byte for byte and the tests compare against the
unbatched, unmasked loops.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Fekd3xkgubE8VfXrALh2td
2026-11-05 18:29:50 +01:00
yahya-nusseibehandClaude Opus 5.5 4f49676a37 port-pack: cap every jar and pack entry it inflates
horizon-port-pack read jar entries with read_to_string/read_to_end and
no limit. zip stops an entry only at the size it declares, which can be
4 GiB (more with zip64), and the launcher runs check::check in its own
process on every Java mod a mod-browser search lists, without a click.
So one published mod with a highly compressible mods.toml could abort
the launcher of everyone whose search showed it. A 522 KB jar whose
mods.toml inflates to 512 MiB made `horizon-port-pack check` reach
594 MiB and abort; with this change it peaks at 9 MiB and refuses the
jar.

- crate::entry: read()/read_text() with a per-entry cap, and a Budget
  for a running total over one pass of an archive, so many entries
  under their cap can't add up either. Limits: 16 MiB per text entry
  (JSON, TOML, the manifest), 64 MiB per file kept as it is, 1 GiB per
  archive pass. Only bytes actually read count; an entry declaring more
  than what is left is refused unread.
- Every zip read in the crate goes through it: jar::read's metadata
  (a too-large file refuses the jar), carry's namespace scan and file
  copy, worldgen's piece counts, structure-set and biome tags,
  processors, upgrade::read_pack and import's JSON index. check.rs kept
  its 16 MiB per-class cap and now also refuses a jar whose classes
  together unpack to more than 1 GiB (a thousand entries just under the
  per-class cap was the same attack).
- Tests: the cap and the running total (declared or not, refused
  entries counting), UTF-8, and check() refusing a small jar whose
  mods.toml unpacks past 16 MiB.

No parity change: real mod files are far under these limits.

Found in yahya-nusseibeh's review of Horizon (H1).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-05 19:28:47 +02:00
Horizon agentandClaude Opus 5.5 0068b79fc4 parity: wire_stream runs when named (and nightly), alone
It drives a vanilla client on a virtual screen for ~15 minutes: no automatic selection on every client change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 18:26:04 +01:00
Horizon agentandClaude Opus 5.5 0744579340 wire stream harness: give a loaded machine's vanilla client 15 minutes to reach the world
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 18:25:03 +01:00
Horizon agentandClaude Opus 5.5 ce9d8ae6cf Emotes: the measured cost per frame in docs/cosmetics.md
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 18:23:53 +01:00
Horizon agentandClaude Opus 5.5 4a59bae751 Emotes: the wheel reads the cursor as it is (clippy)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 18:23:53 +01:00
Horizon agentandClaude Opus 5.5 95244a792a Emotes: others see yours (the cosmetics service), the wheel lists the account's
- Mine: while an emote plays, the game tells the cosmetics service, signed in as for "Show me
  as Horizon" and only while that is on: `POST /v1/emote`, with a session kept an hour
  (`account::Kept`). A loop is said again every 30 s.
- Theirs: `horizon_cosmetics::Feed`, a thread, asks `POST /v1/emotes` which Horizon players in
  view play one, at most once a second and only while some are in view, then fetches the item
  files once. On failure it waits 15 s, doubling up to 10 min. Their models play the emote from
  where it is; a player who moves ends theirs at once.
- Follows "Other players' cosmetics": with anything but Shown, nothing is asked and nothing is
  drawn.
- The wheel: the account's emotes (`GET /v1/me`'s `emotes`), read when it opens, at most every
  10 minutes.

Nothing reaches game servers; vanilla clients see nothing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 18:23:53 +01:00
yahya-nusseibehandClaude Opus 5.5 24987d6660 Launcher: rustls 0.23.46 for four RustSec advisories
launcher/Cargo.lock pinned rustls 0.23.45 (through ureq 2.12), which
cargo audit flags with RUSTSEC-2026-0346 (client session Debug impls
expose TLS secrets), -0347 (buffered data sent to the outer server after
an ECH rejection), -0348 (Brotli certificate decompression) and -0349
(post-quantum downgrade from an unauthenticated ServerHello), all fixed
in 0.23.46. The launcher carries Microsoft sign-in tokens over this
stack.

- `cargo update -p rustls --precise 0.23.46`: rustls alone moves.
- cargo audit on the new lockfile: no vulnerabilities (8 informational
  warnings: unmaintained or yanked crates, unchanged).

Found in yahya-nusseibeh's review of Horizon (L4).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-05 19:21:11 +02:00
Evrim CicekandClaude Opus 5.5 391ce2975d A nautilus's target is within 16 blocks, and pandas and polar bears panic on the same causes
From review, against vanilla:
- Sensor.isEntityTargetable is range(16) from feet to feet, a sphere; the
  nearby scan alone was a cube.
- Panda's PandaPanicGoal and an adult PolarBear's panic use
  #panic_environmental_causes as well; their own copies of it lacked freeze
  and sulfur_cube_hot. They now share armadillo::environmental.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 20:19:00 +03:00
yahya-nusseibehandClaude Opus 5.5 ce92f7e45a Launcher: open HORIZON_LAUNCHER_PROJECT once without remove_var
The mod page opened the project named by HORIZON_LAUNCHER_PROJECT (a
screenshot aid) and then called std::env::remove_var so it wouldn't open
again. By then the download, check and keyring threads are running, and
remove_var races getenv on any of them (glibc's DNS resolution
included): unsound, which is why edition 2024 makes it `unsafe`. The
launcher is on 2021, so it compiled silently.

- A static flag remembers that the page was opened this run; the
  environment is no longer written. Same behaviour: the page opens once,
  after the first search that finds it.

Found in yahya-nusseibeh's review of Horizon (L5).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-05 19:11:41 +02:00
DUzzL a7f6ecbdfb Send the carried slot before a drop (ensureHasSentCarriedItem) 2026-11-05 18:11:01 +01:00
Horizon agentandClaude Opus 5.5 f2f3363a0a wire stream harness: start a scenario once the client has taken the reset's teleport
A busy server ran hit_target's reset teleport on the scenario's first tick: the vanilla client's first move
said off the ground and every later move differed. The reset now ends with a teleport, and the scenario waits
until the client has accepted it and ticked 40 more times.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 18:09:23 +01:00
Horizon agentandClaude Opus 5.5 ff8997168c Emotes: horizon-ctl emote in the generated command reference
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 18:08:59 +01:00
Horizon agentandClaude Opus 5.5 2fed27fce9 Emotes: play one from the emote wheel, on your model and in the camera
- The wheel: Emote Wheel (`key.emote`, B by default, unbound on a controller; rebindable in
  Controls) shows up to 8 emotes around the middle of the screen. To play one: hold the key and
  release on an emote, click one, or press its number.
- The emote cancels (and fades out) on movement, an attack, mining, using an item, being hurt,
  riding, sleeping, gliding or sneaking. Only a standing player starts one.
- From first person, the camera turns to the front view and eases out from close for the emote,
  then comes back.
- Bones: `anim::setup` lays the emote over vanilla's animation, keyed bones only.
- Props: drawn with the cosmetics, growing in and out with the blend.
- `horizon-ctl emote [ID|stop]`.
- HORIZON_COSMETICS_LOCAL: the wheel offers the folder's emotes, and mannequins play its first
  one (the art and the bench).

Visual only: nothing reaches the game server.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 18:08:59 +01:00
Horizon agentandClaude Opus 5.5 44c14af45a Emotes: fully in, the bones take the target as it is (no quaternion round trip)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 18:08:58 +01:00
DUzzL 31c1bd1f8b Honor can_place_on for buckets and flint/fire charge in adventure mode 2026-11-05 18:08:40 +01:00
Horizon agentandClaude Opus 5.5 a6730f376e Native mods switch: rustfmt and a doc lint
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 18:07:43 +01:00
Horizon agentandClaude Opus 5.5 aa6fc60c4a Merge mods/in-game-browser into mods/hotload-content
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 18:06:51 +01:00
Horizon agentandClaude Opus 5.5 315d1e424d Mods: a live switch's data reaches the server with no data pack
The server built its world tables from the content root only when the
world had data packs; without one it kept the tables loaded at startup,
so a switched mod's recipes and tags never applied (seen in game:
/recipe give still said Unknown recipe). After a live switch the tables
are built from the new root. An install that waits for a restart says so
on the project page.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 18:06:51 +01:00
Horizon agentandClaude Opus 5.5 b1e41d46ec Mods browser: its search box says it searches mods
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 18:05:56 +01:00
Horizon agentandClaude Opus 5.5 071feb3308 Cosmetics: the emote format (keyframed bones, root, props)
An item of the new `emote` slot carries an `emote` block: per-part tracks of keys (rotation,
offset, a prop's scale) eased smooth (Catmull-Rom), linear, in_out or step, one-shot or looping,
faded in and out over `blend` ticks. `Item::parse` checks its limits (200 ticks, 16 tracks,
32 keys a track and 192 in all, 360° turns and 8 px moves, the root 720° and 16 px, props'
reach through every key). `Emote::pose_body` poses the six bones (keys replace a bone's
rotation, keyed bones only), `pose_props` the props, and `Playing` gives an emote's time and
weight, its cancel included. Visual only, nothing random.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 18:04:09 +01:00
MSedgeMC 7e5063a4d5 port_map: ScreenRectangle.intersection is the scissor stack's cut 2026-11-05 17:03:55 +00:00
MSedgeMC 185908fd38 test: nested GUI scissors clip to the intersection and restore the outer clip
`screen_draw::paint`: an inner scissor draws to the overlap of both, a scissor
with nothing around it is cut to the screen, and one that misses the outer
clip draws nothing while the outer clip takes over again when the inner closes.
2026-11-05 17:03:55 +00:00
MSedgeMC dd0bffd667 feat: a nested GUI scissor is intersected with the one around it
`GuiGraphicsExtractor.ScissorStack` pushes each clip intersected with the one
around it (the screen while there is none, `ScreenRectangle.empty()` when they
miss) and pops it back when it closes. `screen_draw::paint` kept a single clip
that each `Draw::Scissor` replaced: a nested scissor stopped the outer clip
applying at all, and the outer one never came back after the inner closed.

`paint` now keeps the stack: `Draw::Scissor` pushes the intersection with the
enclosing clip (or the screen) and `Draw::NoScissor` clips what was drawn since
and pops back to the enclosing clip. `intersect` is `ScreenRectangle.intersection`.
2026-11-05 17:03:54 +00:00
MSedgeMC c8f1559eb0 port_map: the region file version's configure row is ported
`RegionFileVersion.configure` was the row that named this gap: read from
server.properties and never applied. `Compression::configure` now applies it
from the dedicated server's startup and `compress_records` asks
`Compression::selected()`, so the row says `port` with what each half does.
2026-11-05 17:02:04 +00:00
Evrim CicekandClaude Opus 5.5 1f031f6bd3 A nautilus only goes for a pufferfish it sees, and frost and a hot sulfur cube panic armadillos
NautilusAi.findNearestValidAttackTarget picks the closest #nautilus_hostiles
in water from NEAREST_VISIBLE_LIVING_ENTITIES, the sensor's list of what the
nautilus can see; Horizon took any pufferfish within 16 blocks, through
walls. Its cooldown and 1 in 2 are drawn from the level's random
(level.getRandom()), not the nautilus's own.

Armadillo.actuallyHurt panics (rolls out) on #panic_environmental_causes,
which also holds freeze and sulfur_cube_hot; Horizon listed the other six.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 20:02:04 +03:00
MSedgeMC 2e7afbfe64 test: the region compression follows region-file-compression
Two cases, one per half of the port:

- `compression::tests::the_selected_region_version_follows_the_property` walks
  the four names `RegionFileVersion` registers (`gzip`, `deflate`, `none`,
  `lz4`) against `Compression::selected()`, checks the default before any is
  set is `deflate`, and checks that `zstd` and the wrong case `LZ4` are refused
  and leave the selection alone, as vanilla logs the value and keeps the one it
  had.
- `save::tests::a_chunk_is_written_with_the_selected_region_version` runs the
  function the save path calls, `compress_records`, once per name, and asserts
  the record's codec is the selected one and that its bytes decode back to the
  same chunk.

With `compress_records` back on `Compression::Zlib` the second test fails with
`left: Zlib, right: None` ("written with none") and would then fail on `lz4`
with `left: Zlib, right: Lz4`.

Also here: the helper-parity test next door now dates its expected records with
`Compression::selected()` rather than `Zlib`, so it keeps checking that the
helpers agree with one thread whatever version the process selected instead of
pinning the default.
2026-11-05 17:02:04 +00:00
MSedgeMC 5a3dc52e1f fix: region files are written with region-file-compression
Vanilla's `Main.main` hands `server.properties`' `region-file-compression` to
`RegionFileVersion.configure` right after the settings are saved, and every
`RegionFile` written afterwards uses the version it selected (`getSelected`):
`gzip`, `deflate` (the default), `none` or `lz4`. Horizon parsed the key into
`ServerProperties.region_file_compression` and nothing read it, so every chunk
region was written as `Compression::Zlib` whatever a server asked for.

`Compression::configure(name)` and `Compression::selected()` are the two halves
of `RegionFileVersion` over the same kind of process-wide selection, starting at
`VERSION_DEFLATE`, with `REGION_FILE_NAMES` in the registration order vanilla's
error message lists. A name is matched exactly, as `VERSIONS_BY_NAME` does; an
unknown one changes nothing and says so. The dedicated server's startup calls it
where vanilla does, before the EULA check, and logs vanilla's line for a value
the table does not know. `save.rs::compress_records` encodes each record with
the selection, so the chunks the save path writes carry it; the read side needs
no change, as `Compression` already decoded all four and the header's version
byte names each chunk's codec.
2026-11-05 17:02:04 +00:00
martipopsandClaude Opus 5.5 7a882e8442 pad_menu_e2e: pass on a fast machine and at 16:9, and say when the window isn't
focus_on looked a widget up before stepping, so a row scrolled out of view
ended the case: it now steps until the row is drawn. optimize_world's job
finished before the case could look on a fast machine: 60000 linked regions
take long enough and cost almost nothing. A tiling compositor sizes the hidden
window too, which fails every case that scrolls, so the harness warns.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 11:59:35 -05:00
Horizon agentandClaude Opus 5.5 1fcc43f60f Realms settings: the 26.3 JSON shapes for the region preference and the configuration
RegionSelectionPreference travels as its id (0, 1, 2) and the region as its nameId, so a
Realm with a preference no longer fails to parse as a String. PreferredRegionsDto reads
regionDataList / regionName / serviceQuality, RealmsConfigurationDto posts the slot under
options with update_slot's RealmsSlotUpdateDto (worldTemplateId, worldTemplateImage), a Realm
without a preference is automatic by owner (RegionSelectionPreferenceDto.DEFAULT), and the
labels use the realms.configuration.region* keys vanilla's settings tab shows.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 17:58:19 +01:00
Horizon agentandClaude Opus 5.5 b4644568dd wire stream harness: an absolute --bin, since horizon-ctl runs from the scratch home
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 17:57:02 +01:00
Horizon agentandClaude Opus 5.5 44c13ed7ac vanilla_client: this machine's natives when prepared.json lists another's
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 17:57:02 +01:00
Horizon agentandClaude Opus 5.5 6039812a76 VanillaTickScript: 26.3's SDL key and mouse codes
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 17:57:02 +01:00
Horizon agentandClaude Opus 5.5 93fc52b7ef wire stream harness: the vanilla client on the virtual screen, with Mesa's EGL
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 17:57:02 +01:00
Horizon agentandClaude Opus 5.5 875f62b6b5 Wire: compare the client's packets with vanilla's, tick by tick
tools/test_wire_stream_parity.py: a Horizon client and a real vanilla 26.3
client join a vanilla server through tools/net_capture.py and play the same
tick-indexed scenarios (walk, sprint-jump, strafe, w-tap, sneak, hits,
sprint combos, block-hit, rod, bow, eat, bridging, inventory, hotbar swaps,
a teleport during a dig). tools/wire_stream.py cuts each serverbound stream
into client ticks (ClientTickEnd ends one) and decodes the packets; every
tick must hold the same packets with the same fields.

Horizon plays a scenario from HORIZON_TICK_SCRIPT (game/tick_script.rs),
before each client tick; vanilla from VanillaTickScript.java, which hooks a
fixed point of Minecraft.tick (Tutorial.onLookAt) and presses the keys
through KeyMapping.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 17:57:02 +01:00
Horizon agentandClaude Opus 5.5 96d60b9daf Multiplayer: sneaking for a use is the input of the last tick, as vanilla's isShiftKeyDown
LocalPlayer.isShiftKeyDown reads input.keyPresses, which LocalPlayer.aiStep
sets from the keys: what the last ServerboundPlayerInputPacket said.
Minecraft.handleKeybinds runs before it, so a sneak pressed in the frame of
a right-click counts from the next tick. Horizon read the live Shift key for
ServerboundInteractPacket's secondary flag and for every secondary-use
check (isSecondaryUseActive): a click in that frame sent a sneaking
interaction a server saw before the sneak itself.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 17:56:54 +01:00
Horizon agentandClaude Opus 5.5 eb7cc8305c Multiplayer: a rider's sprint and a boat's paddles go out in vanilla's order
LocalPlayer.sendChanges sends a passenger's look, then the vehicle it
controls, and only then its sprint (sendIsSprintingIfNeeded); riding a
vehicle it doesn't control, it sends no sprint at all. AbstractBoat.tick
sends the paddles from the boat's own tick, before its passenger's
sendChanges. Horizon sent the sprint first and for any vehicle, and the
paddles last.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 17:56:53 +01:00
Horizon agentandClaude Opus 5.5 9e7b50b560 Multiplayer: a new held slot goes out at the start of the next tick, as vanilla
MultiPlayerGameMode.tick, first in Minecraft.tick and run with a screen
open too, sends the slot chosen since the last tick
(ensureHasSentCarriedItem); LocalPlayer.sendChanges sends no slot. Horizon
also sent it at the end of the tick that changed it, before the input and
the move: a number key pressed in a tick went out one tick early, on the
other side of the movement packet a translating proxy (ViaVersion to an
older server) turns into that tick's flying packet. An action in the same
tick still sends the slot first, as ensureHasSentCarriedItem does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 17:56:53 +01:00
Horizon agentandClaude Opus 5.5 635fa28928 Multiplayer: a teleport aborts the dig on the wire, and a new level counts predictions from 0, as vanilla
ClientPacketListener.handleMovePlayer accepts the teleport, then runs
MultiPlayerGameMode.stopDestroyBlock: a dig the teleport interrupts (an
anticheat's setback while mining or bridging, a /tp) sends
ABORT_DESTROY_BLOCK and starts the attack strength again. Horizon dropped
the dig without a word, so the server kept counting it.

BlockStatePredictionHandler lives in the ClientLevel: handleLogin (a proxy's
server switch) and handleRespawn into another dimension make a new one, so
the use and dig sequence numbers count from 0 again. Horizon counted on for
the whole connection.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 17:56:52 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 785a399740 docs: HDR design note: only the 0.1 shim goes with 0.1
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 17:53:11 +01:00
martipopsandClaude Opus 5.5 7ac3021dfb Show each controller's mapping source and uuid in pad status
A mapping bug report needs to know whether gilrs found an SDL row for a pad
and which id such a row would need. pad status now lists mapping_source
(sdl, driver or none) and uuid (32 hex digits, the id an SDL row starts with).

With splitscreen on, PadRouter holds the gilrs reader, so pad status said
backend:false and listed no controllers, unless the reader was lent to P1.
The router now leaves what it sees for the status of each game that has a
control socket; that list is only built when one is open.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 11:53:03 -05:00
Horizon agentandClaude Opus 5.5 ad73e4a692 Launcher Shop: a basket, gifts, the Supporter badge, and Pay in the browser
Add to basket on the chosen card, the basket's lines and total with the
service's rules (owned items and items inside a set in the basket cost
nothing), a gift to a friend looked up by name, the Supporter badge's
amount within the catalogue's range, and "Ask whoever pays before you
buy. Refunds within 14 days for any reason." by Pay, which starts
Paddle's checkout on the service and opens playhorizon.dev/shop/pay.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 17:52:57 +01:00
Horizon agentandClaude Opus 5.5 fac063268c Launcher: a plain Shop tab beside the Wardrobe
The catalogue's items, sets, collections and the Supporter badge as cards
with their prices, the chosen card tried on the account's own skin, and
the founder set's claim (one click where Paddle doesn't sell, else
Paddle's free checkout in the browser). No badge on the tab, it never
opens by itself, and a catalogue that doesn't load says so in one line.
The Wardrobe's one shop button now opens this tab.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 17:51:19 +01:00
Horizon agentandClaude Opus 5.5 56c896f51c docs/mods.md: which switches wait in a world
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 17:51:12 +01:00
HiuoyandClaude Opus 5.5 bb4d24e909 Native hook catalog: RenderLevelStageEvent labels for the world drawing hooks
81 of ATM10's mods listen to RenderLevelStageEvent to draw in the
world at a stage of the frame. Main has three hooks for that, none
labelled:

- world_submit (fragment world_canvas): quads and text right after
  the particles, before the hand;
- world_primitives (fragment): lines, boxes, beams and text at the end
  of the world pass, before the hand;
- world_drawn (API 58): the drawn world's colour, depth and matrices,
  once the whole level is drawn.

Each gets "neoforge": "RenderLevelStageEvent (...)", with where it
draws in words, so horizon-port-pack events maps the 81 mods to them.
world_drawn also gets side client and cancellable false; the fragments
had theirs. NeoForge's user documentation doesn't cover this event,
so the labels use only its name.

Fixes #10036

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 20:50:51 +04:00
Horizon agentandClaude Opus 5.5 aa95310c72 Mods: in a world, content mods of assets and data stay switchable
The Mods page froze every content mod's toggle while a world was open.
Those with only assets and data now reload live, so their toggles stay
active in a world this game serves; the others keep the lock. Each mod
is classified once when the mods are listed, and a native mod's toggle
no longer triggers a resource reload.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 17:50:21 +01:00
Horizon agent 621d47ab6d Merge commit 'dd675b574115536446647051e29dac76f1055bc5' into cg51/pr-9922
# Conflicts:
#	crates/horizon-client/examples/probe/marker_particles_probe.rs
#	crates/horizon-client/src/animate_tick.rs
#	tools/test_marker_particles_parity.py
2026-11-05 17:50:19 +01:00
CocofireHD 49e8906c56 Merge remote-tracking branch 'origin/main' into fix/8615-vibration-particles 2026-11-05 17:47:05 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 02b2f7b9c0 docs: HDR design note: serve shader API 0.1 until 0.2 is on by default
The note kept 0.1 at least until 0.2 leaves experimental, which would keep
today's HDR path (marks, the 16-bit unorm frame, Smooth Gradients) alive for
0.1 packs alone. The shader API is experimental, which stability.md lets
change in any release without notice. So step 5, which turns 0.2 on by
default, also stops serving 0.1: 0.1 packs are refused with stability.md's
message, the registry lists 0.2 packs, and Golden Hour's 0.2 port becomes the
example. Later experimental lines follow the same rule. The shader API page
tells pack authors.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 17:46:24 +01:00
martipopsandClaude Opus 5.5 1a7a3678bb Refuse a control socket path too long for a Unix socket up front
A --control-socket path over the system's limit was only found when the
game tried to listen, after loading resources: it logged the raw SUN_LEN
error, wrote a crash report and exited 1, leaving startedCleanly:false so
the next start reset fullscreen. It is a bad command line: exit 2 with a
plain message, before the game folder, options.txt or resources are
touched. horizon-ctl launch refuses it as usage too, without starting a
game or creating a log beside the socket. A .json endpoint is TCP and has
no such limit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 11:45:34 -05:00
HiuoyandClaude Opus 5.5 513312eb8b Native hook catalog: world_lifecycle also answers ServerStartingEvent
NeoForge documents ServerStartingEvent as fired after
ServerAboutToStartEvent and before ServerStartedEvent. Horizon has
server_about_to_start at the start of loadLevel, and world_lifecycle's
WorldOpenReady once the open has finished, before the tick loop.
Nothing of Horizon's runs between the two NeoForge events, so
WorldOpenReady answers both (30 ATM10 mods listen to Starting).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 20:43:53 +04:00
BnCattandClaude Opus 5.5 6c44743c21 Older servers: read 1.21.11's EitherHolder components
1.21.11 sends instrument, provides_trim_material, damage_type,
chicken/variant and zombie_nautilus/variant as EitherHolders (a flag,
then the entry's holder or its ResourceKey), as it does jukebox_playable;
26.1 made them plain holders. Horizon read the flag as the holder, so a
goat horn's slot came one byte long and the entity data around it one
byte short ("could not translate 1.21.11 packet 20/101").

The flag is read for all six now: a holder reads on as the pinned one,
a key (no pinned numbering for it) drops the component.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 19:43:38 +03:00
Evrim CicekandClaude Opus 5.5 7cead764e8 A held item's attack_range patch, and slimes' reach, follow it too
From review, against vanilla:
- getActiveItem().get(ATTACK_RANGE) includes the stack's patch: an added
  minecraft:attack_range (the codec's defaults: max_reach 3, min_reach 0,
  mob_factor 1) sets the reach, and !minecraft:attack_range takes a spear's
  away.
- AbstractCubeMob.dealDamage calls isWithinMeleeAttackRange, for the player
  it touches and for iron golems; it repeated the default formula.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 19:43:32 +03:00
IsT3RiK f719bbef1c Merge remote-tracking branch 'upstream/main' into traduction-fr
# Conflicts:
#	launcher/lang/fr.json
2026-11-05 17:43:05 +01:00
Horizon agentandClaude Opus 5.5 07c08509df Launcher Shop: prices, sets and the Supporter badge from the catalogue; the web checkout
The catalogue's shop fields (currencies, prices, sets, collections, the
Supporter badge), the player lookup and the web checkout in the wardrobe
client, and the basket's price rules in shop.rs, as the website applies
them. Nothing shows them yet: the Shop tab comes next.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 17:40:05 +01:00
Horizon agentandClaude Opus 5.5 bdfe6e4dd1 Mods hot load: rustfmt and clippy fixes
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 17:39:33 +01:00
DUzzL ef05a384d5 Burning players and mobs destroy powder snow they stand in 2026-11-05 17:29:34 +01:00
Evrim CicekandClaude Opus 5.5 d4a2f07032 A mob's melee reach follows the spear it holds
Mob.isWithinMeleeAttackRange takes the active item's attack_range when it
has one: the box is inflated by effectiveMaxRange (max_reach * mob_factor,
2.25 for every spear) instead of the default sqrt(2.04) - 0.6, and a target
within effectiveMinRange (min_reach * mob_factor, 1.0) is out of range.
Horizon always used the default, so a mob given a spear without a
SpearUseGoal (a vindicator, say) hit what stood right beside it and missed
what the spear reached.

New mob AI scenarios: a vindicator with a spear against a player out at the
spear's reach and right beside it, and zombies with a spear (their
SpearUseGoal; the probe now names it as vanilla does).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 19:29:23 +03:00
Horizon agentandClaude Opus 5.5 b90cebc01b Merge main into mods/native-switch
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 17:29:20 +01:00
Horizon agentandClaude Opus 5.5 d9e35999b6 Merge main into mods/hotload-content
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 17:29:04 +01:00
CocofireHDandopencode 40eeef6f80 lan_e2e: state the shulker lid check in the harness docs
Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-05 17:26:35 +01:00
Horizon agentandClaude Opus 5.5 249aa0d4c1 Merge mods/in-game-browser into mods/hotload-content
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 17:25:38 +01:00
Horizon agentandClaude Opus 5.5 8aba8be7a9 Mods browser: rustfmt and a clippy fix
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 17:24:58 +01:00
Horizon agentandClaude Opus 5.5 3fc5f274be PvP & HUD: one stand-in line per widget in the editor
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 17:22:42 +01:00
HiuoyandClaude Opus 5.5 0801940eeb Native hook catalog: label the screen input and render hooks, and item pickup
Seven more hooks whose doc comments already name their NeoForge event:
screen_render_post (ScreenEvent.Render.Post), the five screen input
hooks (ScreenEvent.MouseButtonPressed.Pre, .MouseButtonReleased.Pre,
.MouseScrolled.Pre, .KeyPressed.Pre, .CharacterTyped.Pre), all client,
and item_picked_up (ItemEntityPickupEvent.Pre, server), whose cancel
stands for setCanPickup(TriState.FALSE), with no forced pickup.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 20:21:57 +04:00
Horizon agentandClaude Opus 5.5 3f164ac06a PvP & HUD: the HUD editor in main's new screen and menu matches
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 17:18:51 +01:00
CocofireHD a729b3cfe6 Merge remote-tracking branch 'origin/main' into fix/8615-vibration-particles 2026-11-05 17:18:40 +01:00
Florian ERNSTandCodex 5cede4ff4e Record refreshed raid loss and harness compatibility proof
Co-Authored-By: Codex <[email protected]>
2026-11-05 17:17:06 +01:00
makscoder 574b20a662 Loot: guests roll a container's table on open; double chests roll both halves
The host's open unpacks the loot table it still holds; the guest's
(lan_open_container) read the slots without it, so a LAN or splitscreen
player met an empty container that kept its table, and
player_generates_container_loot never fired for them. unpack_opened_loot
now runs for the acting guest too.

ChestBlock.getMenuProvider unpacks both halves of a double chest; only
the clicked half was unpacked. After its own roll is consumed, the
partner's is unpacked too (the consumed own roll ends the recursion).
2026-11-05 19:15:23 +03:00
some-du6eandClaude Opus 5.5 2a767017b9 OptiFine custom colours: read optifine/color.properties
Every documented key: particle, fog and sky colours, potions, spawn eggs,
map, sheep, collar and dye colours (with their old names), text and its
colour codes, the loading screen, clouds, xporb.time, palette.format and
palette.block.* assignments.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 10:15:15 -06:00
HiuoyandClaude Opus 5.5 a8ea55f39a Native hooks: ClientPlayerNetworkEvent.LoggingIn, and LoggingOut's label
A client mod hears the window's player log in to a server, single
player's own included: native_client_player_network_logging_in(), at
the end of the Login arm (ClientPacketListener.handleLogin), after the
new client level. Respawns and dimension changes don't log in again.
Horizon makes the player with the server's first position, so
native_player() is None there; native_send_packet reaches the server.

client_disconnecting, which main fires before a connection is
discarded (Minecraft.disconnect), gets the catalog label
ClientPlayerNetworkEvent.LoggingOut, so horizon-port-pack events finds
it. The docs describe the pair.

Checked in game with horizon-ctl and a local probe mod: one login on
opening a world and one on reopening it, none on a trip to the Nether
or a respawn, and a logout on Save and Quit and on quitting the game.

Fixes #10008

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 20:15:14 +04:00
Horizon agentandClaude Opus 5.5 ece613e260 PvP & HUD: document the HUD editor; it frames only drawn widgets
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 17:10:41 +01:00
Horizon agentandClaude Opus 5.5 5b52010b12 PvP & HUD: a HUD editor to drag, snap and resize the widgets
Edit HUD on the PvP & HUD page (in a world) or the HUD editor key
(unbound at first) opens a screen over the game: each widget in a frame,
dragged anywhere with snapping to the screen's edges and middle and to
the other widgets, the wheel resizing it, a right click sending it back
to its corner; buttons for text colour, shadow, boxes and opacity, Reset
and Done. Widgets with nothing to show yet stand in by their name. The
layout is saved to horizon-extras.txt as it changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 17:10:40 +01:00
Horizon agentandClaude Opus 5.5 7b9daf7bbf PvP & HUD: document the widgets' style and layout
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 17:10:22 +01:00
Horizon agentandClaude Opus 5.5 afc6c3c43a PvP & HUD: free widget places, sizes, text colour, opacity and shadow
Each HUD widget can leave its corner for a place kept as fractions of the
screen's free room (so it stays on screen at any size) and a size from 50
to 200%, saved as hudPlace lines in horizon-extras.txt only once moved.
The page gains the boxes' opacity, the text colour and the text shadow.
Snapping (screen edges, middle, other boxes) is here for the editor.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 17:10:22 +01:00
Horizon agentandClaude Opus 5.5 69874aef48 PvP & HUD: no widget on, no HUD work; document the new widgets
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 17:10:17 +01:00
Horizon agentandClaude Opus 5.5 9f46032653 PvP & HUD: FPS, CPS, keystrokes, clock, reach and speed widgets
Six more widgets on the PvP & HUD page, off by default, each from the
client's own state: the frame counter, presses of the last second, the
movement and mouse keys while held, the local time, the distance of the
last hit the crosshair's ray measured, and horizontal speed sampled from
the player's position. Each caches its lines and rebuilds them only when
the shown value changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 17:10:17 +01:00
CocofireHDandopencode 463c970b13 Merge origin/main into fix/8615-vibration-particles
Resolved the one conflict, in game/lan_particles.rs: test_hub keeps the
shared helper the vibration test needs, and its guests now carry main's
Joined.expected field and bind through NetConfig::loopback. The
sculk_vibration LAN check survives main's rewrite of tools/lan_e2e.py.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-05 17:08:57 +01:00
umcekoandClaude Opus 5.5 0e80c10042 Projectiles owned by any non-player entity need mob_griefing
Projectile.mayInteract asks mob_griefing for every owner that isn't a
player, and canBreakBlockInAdventureMode lets no non-player owner break
a block without can_break. An owner UUID that names a loaded armor
stand, boat or other non-mob entity was treated as no owner, so a
burning arrow lit TNT and campfires with mob_griefing off. The arrow now
asks the level for the UUID, as getOwner does.

Fixes #9999

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 19:06:37 +03:00
Florian ERNSTandCodex 9fcd6133ed Merge main and preserve map, frame and raid harness records
Keep all observation kinds when resolving the three shared driver lists.

Co-Authored-By: Codex <[email protected]>
2026-11-05 17:06:23 +01:00
DUzzL 812702d809 Allay: aim deliveries at the player's eyes like vanilla 2026-11-05 17:04:45 +01:00
CocofireHDandopencode 58263dc4a0 lan_e2e: capture the shulker lid open and closed
The shulker_menu check now keeps host and guest screenshots of the lid before and after the guest closes the menu, so a stuck lid is visible as well as asserted.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-05 17:03:25 +01:00
Florian ERNSTandCodex bfc26f944a Validate loaded bed claims for raid villages
Keep stale home memories while checking the current HOME POI before
counting a loaded bed claim as a village center. Preserve unavailable
claims and existing job/meeting paths.

Co-Authored-By: Codex <[email protected]>
2026-11-05 16:57:12 +01:00
Evrim Cicek 7658a24435 Merge remote-tracking branch 'upstream/main' into fix/enderman-teleport-danger
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
2026-11-05 18:54:56 +03:00
Evrim CicekandClaude Opus 5.5 394db9011a An enderman's teleport: waterlogged ground, the sculk event, and hurts without AI
From review of the previous commit, against vanilla:
- Enderman.canRandomlyTeleportTo refuses ground that holds water (a
  waterlogged slab, leaves or roots).
- containsAnyLiquid and findBlocksIn scan the same closed range of cells.
- Both teleport sounds play at the feet (Entity.playSound).
- The game's sculk layer now hears GameEvent.TELEPORT from a mob.
- Enderman.hurtServer teleports inside every hurt, not only the rain's: the
  fire, lava and block hurts of baseTick and a fall's, and a NoAI
  enderman's, which never teleported. A lingering cloud's indirect_magic
  draws the chance too; only a thrown potion skips it.

New mob AI scenarios: enderman_rain_waterlogged (red on the previous commit,
it landed on the slabs at tick 2) and enderman_rain_noai.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 18:49:28 +03:00
Riccardo GuerrieroandClaude Opus 5.5 7a7cf79d4b A player's death makes a vibration and counts toward Killed By
ServerPlayer.die posts entity_die at the player, and with a kill credit
awards killed_by for the killer's type. A creeper, sulfur cube or wither
whose own blast kills the player is still credited, as vanilla's player
dies inside the explosion: ExplosionSource carries the mob's type.
New lan_e2e check death-credit.

Fixes #9955

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WZzq8LUjYucp4kaN6UfSg7
2026-11-05 16:48:54 +01:00
AxxioandAntigravity 150423300c end: drop destroyed blocks and award used stat on portal completion (fixes #9958)
When the end portal frame ring is completed by placing the last eye of ender, vanilla destroys blocks in the 3x3 portal opening so that their drops and break effects occur instead of silently deleting them. Also awards minecraft:used for minecraft:ender_eye upon insertion into a frame.

Co-Authored-By: Antigravity <[email protected]>
2026-11-05 16:47:53 +01:00
MSedgeMC 955558305a Merge remote-tracking branch 'upstream/main' into fix/prediction-snap
# Conflicts:
#	crates/horizon-client/src/game/remote.rs
2026-11-05 15:42:26 +00:00
Horizon agentandClaude Opus 5.5 c8a86851b9 Native mods: a trait mod turned off stops at once, and resumes when turned on
Options > Mods' switch now reaches the trait mods' dispatch: the game skips
a turned-off mod's Hooks from the next call, on the server thread and in
the window, and turning it on again resumes it with no rebuild while it is
in the build. Registration at start is untouched, so its content stays
registered; RenderHooks keep drawing until a restart; mixin code still
checks native_on.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 16:38:22 +01:00
DUzzL b1896906d1 Clip projectiles with their own collision context (scaffolding open from below) 2026-11-05 16:36:59 +01:00
Horizon agentandClaude Opus 5.5 408044e9a9 Mods: content mods of assets and data switch on and off without a restart
A content root is built per set of mods (.horizon-cache/mod-content/<stamp>),
so a running game keeps reading the root it was given while a switch
builds another; roots unused for a day are removed. Turning a mod on or
off in Options > Mods, or installing one from Browse Mods, applies at once
when the mod has only assets and data: the client reloads its resources,
then the server reloads its data packs as /reload does and resyncs its
players. A mod that adds blocks, items, mob effects, entity or menu types
or changes world generation keeps loading at start, and the Mods page
says to restart.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 16:36:58 +01:00
Horizon agentandClaude Opus 5.5 c0323c7707 Mods: browse Horizon's registry and install content mods from inside the game
Options > Mods > Browse Mods lists the registry's content mods for this
game (the index the launcher reads), searchable by name, tag and category,
with a page per project: authors, licence, categories, description and
changelog, Install or Update, its switch once installed, and its page on
the website. An install is checked as the launcher's (size, SHA-256,
signature, manifest against the index) and recorded in mod-browser.json,
so the launcher lists, updates and removes it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 16:32:54 +01:00
Horizon agentandClaude Opus 5.5 e95658709a Design note: mods from inside the game, loaded and unloaded in seconds
What content mods can switch live (assets and data), what needs a world
reload (registries, worldgen), the in-game mod browser, and the native
fast path: every installed native mod built in and switched at run time,
with the dylib and wasm paths left to arismoko's fork.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 16:31:45 +01:00
AxxioandAntigravity 3c154d3bb6 perspective, game: align riding body yaw and block inventory in nether portals (fixes #9793)
In vanilla (\LivingEntity.tick\), a passenger's body yaw matches its vehicle's rotation
(or living body yaw), clamped to keep head yaw within [-85°, 85°], and does not update
limb swing / walk distance from vehicle motion.

Furthermore, \Minecraft.handleKeybinds\ blocks opening the inventory (\keyInventory\)
while in a portal transition (\handlePortalTransitionEffect\), preventing repeatedly
reopening the inventory screen while standing in nether portals.

- Compute ridden vehicle yaw for boats, minecarts, mobs, and cushions in \perspective.rs\.
- Align rider body yaw to vehicle rotation, clamped to [-85°, 85°] of head yaw.
- Disable walk distance accumulation when riding so the player model does not swing limbs.
- Guard \KeyCode::KeyE\ in \game.rs\ and \
emote.rs\ when in portal transition.

Co-Authored-By: Antigravity <[email protected]>
2026-11-05 16:31:29 +01:00
Evrim CicekandClaude Opus 5.5 debd86ea59 Endermen teleport where vanilla lets them, and water hurts them when it should
Enderman.teleport is LivingEntity.randomTeleport(x, y, z, true,
#enderman_does_not_teleport_to). Horizon took the first full-collision
block as the ground and landed on top of it, checked water only at the
eyes and lava only at the feet, and never looked at the tag, so endermen
teleported onto magma, into fire, sweet berry bushes and wither roses.

Now, as vanilla: the ground is the first #entities_can_teleport_to block
below the spot, and the enderman lands at the spot's height less the blocks
it went down. The ground and every block in the new box must not be in
#enderman_does_not_teleport_to, and the box must hold no liquid. The
motion is kept (Entity.teleportTo -> snapTo), both teleport sounds play at
the new spot, and GameEvent.TELEPORT goes out from the old one.

The new rain scenarios also showed when the water's hurt comes. It is the
end of LivingEntity.aiStep, after pushEntities, with or without AI, not
baseTick. Enderman.hurtServer draws its 1 in 10 teleport chance after
super.hurtServer (its hurt sound's pitch first), also when the hurt
cooldown refused the damage, and teleports at once. Horizon instead forced
a teleport in rain without the draw, one tick late.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 18:25:33 +03:00
masa-masa-0314andZCode df263fef74 Realms: the settings tab and the region preference screen
The configuration screen's Settings tab (RealmsSettingsTab): the world's name and
description fields (the click moves the caret between them), the region preference line
and its Region Preference button, and the open/close toggle moved here from the footer
(vanilla keeps it on this tab). Leaving the tab — switching tabs or Back/Escape — saves
the changed name, description and preference through RealmsClient.updateConfiguration
(POST worlds/{id}/configuration with RealmsConfigurationDto's JSON: the active slot, its
settings, the preference and the description).

RealmsPreferredRegionSelectionScreen: the automatic choices and every manual region, a
click selects, Done writes the choice back to the tab and Cancel drops it.

RealmsClient gains getPreferredRegionSelections (GET regions/preferredRegions) and
updateConfiguration; RealmServer reads regionSelectionPreference; RegionSelectionPreferenceDto,
RegionDataDto and RealmSetting serialization join the DTOs.

The tier's render/simulation distance options and the region quality icons are the next
PRs (the icons need sprites the GUI atlas does not carry yet); the region rows say their
name for now.

Co-Authored-By: ZCode <[email protected]>
2026-11-05 23:24:38 +08:00
Mayonesu0andClaude Opus 5.5 dec18717de Merge upstream main: keep pregen and the renamed wheel key
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 16:24:31 +01:00
HiuoyandClaude Opus 5.5 f9a93d080a Native hooks: ClientTickEvent.Pre / .Post (client_tick_pre, client_tick_post)
A client mod hears each client tick of the window, at the top and the
end of vanilla's Minecraft.tick: native_client_tick_pre() before any of
the tick's work, native_client_tick_post() once the world's tick, the
chat queue, music and sounds are done. They fire 20 times a second on
the title screen, while paused and in a world alike, as vanilla's tick
runs, and never on a server (single player's ticks on its own thread).
The fragment labels them ClientTickEvent.Pre / .Post, client, not
cancellable.

Checked in game with horizon-ctl and a local probe mod: about 20 calls
a second of each, always paired, on the title screen, in a creative
world and under the pause menu.

Fixes #9971

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 19:10:36 +04:00
MSedgeMC caeaa41bdc Test: a landing change a mod cancels keeps the block as it was
The dispatch test asserts the hook's fields for a guest's trample, that a
cancel stops later mods, and that a change with no mods goes ahead.
2026-11-05 15:03:30 +00:00
MSedgeMC 15f071ff3b Landing: a cancelled trample keeps the block, its crop and its events
The landing's raw write is applied before the game finishes it: the game
now asks the hook there, and a cancel puts the previous block back and
skips the neighbour updates and the level events. The crop above trampled
farmland stays, because breaking it is the neighbour update and not a
write of its own.

`FallEvent::Write` carries the block it replaced, so the game has
something to put back. A guest's landing is asked with that guest's UUID.
2026-11-05 15:03:30 +00:00
MSedgeMC 4985864055 Native hooks: a landing's block change a mod can cancel
A player's landing tramples farmland into dirt and breaks turtle eggs with
no hook a server mod can cancel, so a land-claims mod cannot stop a guest
from trampling inside a claim (#9036, #9873 item 3).

`native_entity_change_block_v2` is asked with the player whose landing it
was, the position and both block names, and can cancel the change. It is
additive: `native_entity_change_block` keeps its signature and firing point.
2026-11-05 15:03:30 +00:00
umcekoandClaude Opus 5.5 9d9617fe29 Save and load primed TNT's owner
PrimedTnt.addAdditionalSaveData stores the igniter as an `owner` UUID
(EntityReference.store) and getOwner resolves it against the level when
asked. tnt_to_nbt now writes the live owner's UUID (a guest, the host
player or a mob), and a loaded TNT looks its saved owner up when it
explodes, so the blast is credited to the player or mob that lit it after
a reload, a portal trip or a chunk unload. An owner that is not online
keeps its UUID through the raw compound.

Fixes #9951

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 17:45:51 +03:00
IsT3RiK 3a298db1c0 Launcher French: translate the 38 strings left in English, address the player as vous throughout
38 entries still held their English text (the modpack coverage reasons, Performance mode, the
Vulkan fallback notice and a few command-line hints). 15 entries said tu while the other ~110
said vous, sometimes side by side on one page; they now say vous, as the game's own French
table does. Names that read the same in French (Mods, Console, Nightly, Snapshots...) stay.
2026-11-05 15:45:27 +01:00
HiuoyandClaude Opus 5.5 6d59c2da8d Port pack events: tests and the listener class fixture in tests/events.rs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 18:44:02 +04:00
HiuoyandClaude Opus 5.5 8a3eafbd61 Port pack: events, a mod's NeoForge listeners against the hooks (#9921)
`horizon-port-pack events <jar or folder>` reads class files for the
NeoForge events a mod listens to (an @SubscribeEvent method's parameter,
a method reference or lambda that takes one event) and the Capabilities
fields it reads, and looks each name up in native-hooks.json's `neoforge`
labels: a hook, another phase's hook, a hook whose label says why it
isn't quite one, or none. Over a folder of jars it ranks the names by
how many mods use them. Names NeoForge changed after 1.21.1 are looked
up under their new ones. It reads names, never code.

On ATM10's 498 jars it takes 8 s: 416 of 497 mods use 326 names, 6,616
uses in all. Main's catalog has a hook for 23% of them, and 43% with
#9924's labels. The top gameplay gaps are ClientTickEvent (95 and 46
mods), RenderLevelStageEvent (81) and ClientPlayerNetworkEvent (53, 35).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 18:42:14 +04:00
Evrim Cicek f732441c84 rustfmt 2026-11-05 17:38:11 +03:00
Mayonesu0andClaude Opus 5.5 429defdece Options: the wheel scrolls a settings list past its buttons
A vanilla list keeps the wheel: AbstractContainerWidget inherits
AbstractScrollArea.mouseScrolled, which scrolls and never reaches the rows, so
CycleButton.mouseScrolled only runs for a button outside a list. Horizon stepped
the row under the cursor, so scrolling a long page changed options.

"Scroll wheel changes settings" keeps that behaviour as an opt-in extra, off by
default. Its saved key is new because the old one was written as true by every
install, and its tooltip has a new key because other languages said "as in
vanilla"; they fall back to English until translated.

Fixes #9633

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 15:33:32 +01:00
HiuoyandClaude Opus 5.5 d9d4d19a1d Native hook catalog: label the hooks ATM10's mods listen to most
A scan of ATM10's 491 mods for their NeoForge event listeners (#9921)
showed nine unlabelled hooks behind events many of them use:
RegisterCommandsEvent (95 mods), ItemTooltipEvent (74), the item and
fluid block capabilities (129, 88), RenderHighlightEvent.Block (41),
ServerStartedEvent / ServerStoppedEvent (48, 41), LivingDropsEvent (36),
LivingKnockBackEvent and ExplosionEvent.Detonate. Each label comes from
the hook's own doc comment and firing point; `cancellable` matches its
mod_api contract, and block_highlight keeps the side and cancellable it
already had.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 18:32:46 +04:00
CocofireHDandopencode f8d0840cd7 lan_e2e: prove a sculk sensor's vibration reaches the host and a guest
VibrationSystem.Ticker.trySelectAndScheduleVibration sends one vibration particle to every player within 32 blocks; the check asserts both the host's window and a LAN guest make it (#8615).

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-05 15:30:45 +01:00
Evrim CicekandClaude Opus 5.5 068b87cc25 An iron golem's anger target is kept, saved and forgiven like a wolf's
Now that the golem keeps its anger target through updatePersistentAnger:
NeutralMob.playerDied ends anger at the dying player even once the golem
lost sight of them; the save writes the anger target, not the current
target; and anger at a mob is written with that mob's UUID and read back
as anger at a mob, so a reloaded golem that was fighting a monster is not
isAngryAtAllPlayers under universal_anger. A calm mob no longer keeps a
loaded angry_at in its save.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 17:24:18 +03:00
MSedgeMC e51fd1dff4 port_map: ItemStack.hurtAndConvertOnBreak is the steering stick's rod
The row was `missing`; the conversion was in `use_steering_stick` but
built a fresh plain rod and skipped `hurtAndBreak`.
2026-11-05 14:24:14 +00:00
MSedgeMC c135993a63 test: a steering stick that breaks leaves the rod the stick was
`transmuteCopyIgnoreEmpty`: at 18 + 7 of 25 the carrot on a stick becomes
a fishing rod that kept its custom name and starts whole, where the old
path wrote a fresh plain rod. Wear that Unbreaking took away (0) breaks
nothing, and a stack with no durability never breaks.
2026-11-05 14:24:12 +00:00
MSedgeMC c942e133da fix: a steering stick's wear goes through the item's durability rules
`FoodOnAStickItem.use` wears the stick with
`ItemStack.hurtAndConvertOnBreak`, which runs `hurtAndBreak` - and
`hurtAndBreak` asks `processDurabilityChange` first, so Unbreaking has its
say before any damage lands.

`use_steering_stick` added the whole 7 (carrot on a stick) or 1 (warped
fungus on a stick) straight onto `stack.damage`, so an Unbreaking stick
lasted exactly as long as a plain one, and it never reached the mods'
item-damaged hook.
2026-11-05 14:23:55 +00:00
CocofireHDandopencode 8df11ffe4c Mod fluid tanks: let a tank refuse a fluid
A mod tank can now refuse a fluid through native_block_entity_accepts_fluid (NeoForge's IFluidHandler.isFluidValid). The server asks it before filling a tank in both insert passes and before native_move_fluids counts a tank's room; every tank accepts every fluid unless a mod cancels. A water tank can take no create:chocolate, and a removed mod leaves no fluid id it cannot use behind.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-05 15:21:31 +01:00
MSedgeMC 3ef40bc92f port_map: BundleItem.getTooltipImage names the tooltip display rule
The row pointed at the geometry and carried the missing rule as a gap;
the grid now goes through `item_tooltip::bundle_tooltip`, which asks
`TooltipDisplay::shows("minecraft:bundle_contents")` before the layout.
2026-11-05 14:12:07 +00:00
MSedgeMC 875b657878 test: a bundle grid hidden by the tooltip display
`minecraft:bundle_contents` in `hidden_components`, and `hide_tooltip`,
both take the grid away; a display that hides `minecraft:lore` leaves it.
2026-11-05 14:11:58 +00:00
MSedgeMC 2ec1ce0480 fix: a bundle's contents grid follows the tooltip display
`BundleItem.getTooltipImage` returns nothing when the stack's
`minecraft:tooltip_display` hides `minecraft:bundle_contents`, so a
bundle given with `hide_tooltip` or with that component hidden drew no
grid. Horizon drew the grid for every bundle, and the tooltip box grew to
hold it.

`item_tooltip::bundle_tooltip` asks the display first and then defers to
`bundle::tooltip_layout`; the tooltip draw uses it for the height probe
and for the draw alike.
2026-11-05 14:11:58 +00:00
CocofireHDandopencode 4993bc83ac Shaders: upscale the scaled pack target to the full surface
A Resolution Scale leaves the pack's picture smaller than the window. Its final pass drew at the window's size, so a shader using gl_FragCoord as texel coordinates (Photon's final) only filled a quarter of the screen. Draw final at the picture's size into an offscreen texture and copy it once to the window with a screen-row fullscreen pass; the no-pack path and same-size output are unchanged.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-05 15:10:10 +01:00
Evrim CicekandClaude Opus 5.5 a5416ca075 Iron golems toss by knockback resistance and keep their anger like vanilla
IronGolem.doHurtTarget throws the target up only when the hit lands, by
0.4f * max(0, 1 - knockback resistance); Horizon threw everything it swung
at by 0.4. The hit and the toss are one event now, like the warden's
sonic boom.

IronGolem.aiStep ends in NeutralMob.updatePersistentAnger(level, true), the
same as a wolf's: every tick it holds a target, a monster too, the anger
timer restarts with a draw of the golem's random. Horizon only angered it at
a player who hit it, so a golem going after a monster drifted from vanilla's
random within a few ticks. The golem now uses the wolf's port, and its
player target goal asks isAngryAt.

LivingEntity.aiStep still drops motion below 0.003 and runs pushEntities
without AI; Horizon's NoAI branch skipped both, which the new mob AI
scenarios (a golem fighting a NoAI zombie, ravager and warden) showed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 17:09:34 +03:00
Florian ERNSTandCodex eb0193cb34 Normalize Enderman carried states before placement
Run neighbor-shape updates on the authoritative level before the existing eligibility checks, and clear the verified carrier after the handled placement.

Co-Authored-By: Codex <[email protected]>
2026-11-05 15:06:31 +01:00
CocofireHDandopencode 2816f17962 Shulker boxes: close the block entity when its menu is removed
A guest's use raised its opener count twice: open_menu counted the block entity's startOpen, then lan_open_container counted it again once the menu was sent. Closing lowered it once, to one, so stopOpen never ran and the lid stayed open with its raised collision (#9500).

The guest open path now relies on the count open_menu already made; the harness keeps the lid open and closed checks for both host and guest.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-05 15:01:27 +01:00
MSedgeMC afad8241b6 port_map: the two BeehiveBlock tick methods
animateTick and trySpawnDripParticles are block_animate_tick and beehive_drip;
the animateTick row records the attempt bound the loop re-reads.
2026-11-05 14:00:10 +00:00
MSedgeMC 677e00c72f test: a floor, a fluid and a whole sample box for the animateTick zoo
The tick cases' stand-in world stopped at y 32..96 and x/z -32..31, one block
short of every sample the loop takes and of the neighbours those samples read,
so a block at the edge compared air against the oracle's unending world: that
is where the beehive, the redstone ore and the pointed dripstone differences
came from. Fill the whole box plus a block either way, let a case name the
floor below y = 64, hand the loop the state's own fluid (a waterlogged block
has a water source, which it ticks and drips) and bind the client jar's block
tags in the oracle the way a datapack load does, which a bare bootstrap leaves
empty. Then add the families that read a neighbour, a shape or their own fluid:
dried ghast over soul sand, soul soil, stone and air; hives and a nest embedded
and hanging; a lit redstone ore and a deepslate one embedded and over air; and
stalactite tips over stone and water. 2,097 cases and 456,747 output lines
become 2,187 and 462,718.
2026-11-05 14:00:10 +00:00
MSedgeMC 9d11ae8abb fix: a hive asks the level random for its attempt bound until the loop ends
BeehiveBlock.animateTick is a `for (i = 0; i < random.nextInt(1) + 1; i++)`
loop, so the bound is drawn again on the check that ends it: a hive always
spends two nextInt(1) draws around its single trySpawnDripParticles attempt.
Horizon hoisted the bound out of the loop and spent one, which left the level
random a draw ahead of vanilla from the first hive the ambient loop touched:
every particle after it moved, and the honey drip of a hive hanging over air
came out at the wrong column.
2026-11-05 14:00:10 +00:00
MSedgeMC 25cb339d57 port_map: WitherRoseBlock.animateTick is block_animate_tick
The row records where the smell test sits now: three smoke puffs about the
centre of the pick shape, moved by the flower's per-column model offset.
2026-11-05 14:00:10 +00:00
MSedgeMC 3555aa81d3 test: a wither rose world in the marker-particles parity suite
The corpus had no wither rose case at all. Add a world that is all wither rose,
run the whole ClientLevel.animateTick loop over it and compare every smoke puff
by exact position bits in call order: twenty-seven cases, three seeds and
twenty-four at random ones. The probe now supplies the flower's per-column model
offset the way the client's mesh tables do (a case without it would compare a
shape the game never shows). 2,070 cases and 402,440 output lines become 2,097
and 456,747.
2026-11-05 14:00:10 +00:00
MSedgeMC af128082f4 fix: centre the wither rose's smoke on its pick shape
WitherRoseBlock.animateTick reads its centre off getShape(state, level, pos,
CollisionContext.empty()), a flower's own box whose bounds centre is 0.5/0.5
before the block's per-column model offset. AnimateContext::shape_y answers with
the collision shape, which is empty for every flower, so the centre came out as
0 and the whole smoke column sat half a block towards the block's north-west
corner. Take the centre from the pick shape's boxes (BlockFacts::outline) and
keep the model offset on top of it.
2026-11-05 14:00:10 +00:00
HiuoyandClaude Opus 5.5 2f9c963068 Native hook catalog: NeoForge labels for the core hooks (#9921)
The hooks that came before docs/native-hooks-neoforge.md had no
`neoforge`, `side` or `cancellable` keys, though NeoForge mods listen to
their events most: server and level ticks, block and item clicks,
attacks, joins, chunk loads, logins. Each value comes from the hook's
own doc comment or the vanilla method the proposal's table names, and
`cancellable` matches the hook's mod_api contract. Metadata only: no
name, signature or call site changes.

The proposal's status, its next-hooks table (most of it has landed)
and its capabilities and rollout notes now match main.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 17:59:50 +04:00
DUzzL 61a74fdd0a Stop the walk animation of a player riding a boat 2026-11-05 14:48:21 +01:00
martipopsandClaude Opus 5.5 e58ee9c714 Controllers: icon buttons and two checkboxes take the focus, and a screen with no targets fails a test
An icon-only button recorded nothing, so the pad could not reach the pause
menu's Report Bugs, Feedback and Player Reporting icons, or Realms' icons. An
active button with an empty label is now a focus target. So are the
Multiplayer warning's "Do not show again" box and Optimize World's Erase
Cached Data box, which A now ticks.

tools/pad_menu_e2e.py gains every_screen_has_targets, which walks the title's
submenus and every options page and fails on a focus screen with no targets.
Replay Studio is allowlisted because it registers nothing yet. New pause_icon
and erase_checkbox cases press the new targets. The pause menu now has
unlabelled widgets, so splitscreen_e2e's setup scenario reads labels with get.
controller_settings_pad's check for a Navigation page now matches exactly: it
tripped on the General page's Navigation Repeat rows.

docs/controllers.md no longer says every rectangle registers itself or that
both modes reach every screen.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 08:46:36 -05:00
CocofireHDandopencode 6492b16a7b Send minecraft:vibration particles from the server to nearby clients
Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-05 14:46:21 +01:00
CocofireHDandopencode 2cb008e118 Shaders: read depth as an unfilterable float so GLSL translates
The world's depth was bound as a depth texture and read with textureLoad, which naga's GLSL backend cannot emit, so TAA, the depth blit and the shader depth test failed to build on OpenGL. Bind those views as unfilterable float textures instead: wgpu accepts a depth view there, and textureLoad returns the same depth texel on every backend (Vulkan, DX12, Metal, GL).

The shader translation test now fails on any shader the GL backend runs that cannot translate, and pins fs_taa and fs_blit; the HUD's glint, which fails for an unrelated two-samplers-per-image naga limit, is the one allowed exception.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-05 14:45:43 +01:00
oxi-does-dev 0c897e3e33 Interact: send the point the ray hit, as vanilla does
MultiPlayerGameMode.interact sends EntityHitResult.getLocation() minus
entity.position() in every ServerboundInteractPacket. Horizon sent a
fixed [0.0, 1.0, 0.0] for mobs, a point in their middle: armour stands,
saddles, leashes and anything else that reads the click carried
interactAt to the wrong place, unlike vanilla, and a server's reach or
ghost-hand checks saw clicks that vanilla never makes.

mob_hit and the spear's attack_range pick now return the point along
the view ray where the mob's box was met, measured from its feet, and
every caller destructures the third element. A regression test checks
the packet's point against the ray.
2026-11-05 16:40:43 +03:00
degepieandClaude Opus 5.5 770e4461f8 Mobs: naming one or dyeing a sheep fires player_interacted_with_entity
ServerGamePacketListenerImpl.handleInteract fires the trigger with the
held stack as it was before the interaction whenever an item interaction
succeeds. NameTagItem.interactLivingEntity and DyeItem.interactLivingEntity
both succeed, but interact_mob returned after name_mob and after the sheep
dye before reaching the shared trigger call.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-11-05 18:40:41 +05:00
degepieandClaude Opus 5.5 a85fb7c819 Advancement predicates read structure tags
A location predicate whose `structures` named a tag
(`#minecraft:shipwreck`, or a data pack's own `#pack:village`) never
matched: the predicates' tag lookup had no `worldgen/structure` case and
the world's pack tags didn't hold that registry. The world's tables now
load the structure tags with the packs' over the game's own, like the
entity type, biome and damage type tags, and a world without packs
reads the game's own structure tags (loaded once).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-11-05 18:26:17 +05:00
degepieandClaude Opus 5.5 ab4f48abfe Potions: drinking one awards the used statistic and fires consume_item
Consumable.onConsume awards the item's used statistic and fires the
consume_item trigger for a server player before the consumable's listeners
run and the stack shrinks. A potion and an ominous bottle finish through
finish_drinking, which did neither; food, honey bottles and milk go
through finish_consuming, which already does both.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-11-05 18:26:14 +05:00
AkirabaneandClaude Opus 5.5 db3dcf2dcc dedicated: log each player-* request, and document a hand-over in order
Each player-* request writes one log line with what became of it: INFO
and what it did, or WARN and the reason it was refused. The line names
the player and, for the export, the import, the confirm, the release and
a named drop, the hand-over id, so the lines of one hand-over can be
found on both servers. The record and the request's body are never
written.

docs/server.md gives a hand-over in order, with the ids, the commit
point and what undoes itself, and the log lines.

tools/server_transfer_e2e.py runs a whole hand-over between two
dedicated servers over their real control sockets: two engines with
[transfer] on and Velocity forwarding, a small relay per engine that
answers the forwarding request as a proxy does, and horizon-bots
clients. It follows the documented order step by step, checks the
target's player file is on disk when the confirm answers and that the
source writes nothing for a player moved away, then aborts a hand-over
back and checks the player plays on.

Refs #7484

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 13:46:57 +01:00
AkirabaneandClaude Opus 5.5 bc2363c93b dedicated: player-import takes the place the request names, and the view follows
The request may name where the player arrives, over the record's
compound: `position`, `rotation`, `on_ground` and, now that #8693 keeps
a guest's motion, `velocity`. They belong to the `position` part. The
pitch is taken as Entity.setXRot takes it (modulo a turn, then cut to
+-90) and the yaw as it is; a velocity component over 10 blocks a tick
is taken as 0, as Entity.load takes a saved Motion. A position must be
within the same bounds as the record's own (Entity.load's clamp:
30,000,512 on x and z, 20,000,000 on y), and a rotation or velocity must
be finite numbers.

When the import moves the player to another chunk than the one its view
was built around, the view moves at once: the chunk centre is sent
before the position to confirm, and the chunks out of reach are
forgotten. The reply says so with "recentered".

Refs #7484

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 13:46:57 +01:00
AkirabaneandClaude Opus 5.5 f5aef42f71 lan: an arrival's join line uses the team and nickname display name
#9829 made the join and leave lines name a player as Player.getDisplayName
does (its team's format and its nickname). place_arrival, which announces
a player placed by player-import or player-confirm, still used the plain
name; it now uses player_display_name too, so an arrival is announced as
a login is.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 13:46:56 +01:00
AkirabaneandClaude Opus 5.5 a66f0fc57d dedicated: a durable commit point, retries after it, and the record's bounds
martipops's second review of #8730.

The commit point now covers the three files and a power loss: the
player file, advancements and statistics are each written to a new
file, synced, then renamed into place, and the folder is synced on Unix
(save::write_durable, write_dat_durable). Each write returns its error,
and the player stays held and imported when one fails. Ordinary saves
are unchanged.

A retry of `player-import` or `player-confirm` after the commit, whose
reply was lost, names the hand-over of a player already placed from it:
it is answered ok, placed, with `"retried": true`, and nothing is done
again. Another hand-over's id is still refused.

The record's health is refused over 1024 and its absorption over 2048
(max_health and max_absorption), its food exhaustion over 40
(FoodData.addExhaustion) and an effect amplifier outside 0 to 255, as
26.3's bytecode bounds them. A stack's count needs no check:
item_from_nbt already takes one outside ItemStack's 1 to 99 as 1.

docs/server.md says what a proxy does when `moved` is refused after the
commit (the freeze ran out, or an operator's /kill), and that it
confirms only while the export's `freeze_ticks_left` leaves it time.

Refs #7484

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 13:45:27 +01:00
AkirabaneandClaude Opus 5.5 4a5c6ab5d1 dedicated: player-import and player-confirm let a held player in
`player-import <uuid> <json>` applies a hand-over's record (from
`player-export <uuid> freeze`) to the held player on the target, whole or
in parts, and tells its client what changed. Unless the request says
`"place": false`, it then places the player as `player-confirm <uuid>
<hand-over>` does.

Placing is the hand-over's commit point: the player file, advancements
and statistics are written to disk synchronously, then the player is
placed, and only then does the reply say ok. A proxy releases the
source after that reply, so a crash of the target no longer leaves the
only current copy in memory. If the file cannot be written the player
stays held and imported.

The record's hand-over id is bound to the held player at the import: a
record without one (an export without freeze) is refused, a retry of the
same record is taken and another hand-over's is not. `player-confirm`
names it, and `player-drop <uuid> [hand-over]` may: a named drop must
name the imported record's. A held player that is leaving (dropped or
kicked, its connection not closed yet) can be neither imported nor
confirmed.

The import reads the whole compound as a file load does: the keys
Horizon does not model, the mods' attachments (given to the game's own
map) and the body's values. The body follows the parts: fall and motion
with `position` (with the saved vehicle), air, fire, freezing and food
exhaustion and timer with `health`. Values no player can have are
refused before anything changes: a position, rotation, motion, health,
absorption, saturation, experience progress or fall that is not a
number, a position past vanilla's clamp, a health of zero or less.

The client is sent the effects the import removed and the recipe book
it brought. Every player gets the arrival's list entry, since every
player is shown its entity; `announce` is the join line only. At
confirm the client is sent its position and inventory again, since what
it did since the import was not acted on.

The control endpoint refuses a request line longer than 16 MiB, which
the game would otherwise read and parse between two ticks.

Refs #7484

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 13:45:27 +01:00
AkirabaneandClaude Opus 5.5 6fae1fdc44 Config golden: add max_hold_seconds to the [transfer] section
The config reply pinned by api-v1-config.json lists [transfer] with its
defaults; this PR adds max_hold_seconds (60 by default), so the golden
lists it too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 13:45:17 +01:00
rdbtCVSandCodex gpt-6.1-sol 764795ae20 docs(server): regenerate held-player transfer setting reference
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-05 13:45:17 +01:00
AkirabaneandClaude Opus 5.5 62646f32ab lan: the login of an expected player is held, and player-drop lets it go
A login that used up a proxy's expectation (player-expect, taken as the
login starts) is held on the target server: it gets the whole join
sequence and the level around it, and its acknowledgements are handled,
but it is not placed. No join message, no list entry for the others, not
among the entities they are shown, not simulated, and nothing else it
sends is acted on. The world is loaded around a held player as around a
placed one. The log says "<name> is held: it arrives from another
server".

A held player nobody takes over is dropped after [transfer]'s
max_hold_seconds (60 by default, 1200 ticks), with one warning: a player
already kicked or dropped, whose connection is slow to close, is not
kicked again on every tick. horizon-server passes the setting with the
others.

A held player keeps a true picture of the others while it waits: a
player who joins meanwhile is added to its list, and one who leaves is
removed from it with its entity, or its client would drop the entity of
a player it has no entry for.

`player-drop <uuid>` forgets the player's expectation and disconnects
the held player without a save or a word to anyone. player-export
refuses a held player. Placing it is the import's work, next.

Refs #7484

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 13:45:16 +01:00
GabrielNichols 37ff7be994 Format the vanilla recipe source call 2026-11-05 03:30:41 -03:00
GabrielNichols 476c083e82 Send owned content recipes in the server book 2026-11-05 03:14:27 -03:00
GabrielNichols 055c47c98d Merge official main into Recipes content scenario 2026-11-05 02:45:32 -03:00
GabrielNichols 31756ebfe2 Index owned content recipes for book placement 2026-11-05 02:30:21 -03:00
GabrielNichols 0e6e28109f Keep the pilot fixture focused on an original content recipe 2026-11-05 01:56:50 -03:00
GabrielNichols e76cb1f87a Use edit-box input when replacing Recipes search 2026-11-05 01:45:55 -03:00
GabrielNichols e770447686 Keep Recipes search editable after an empty result 2026-11-05 01:11:59 -03:00
MSedgeMC 6510fd2039 The ledger knows Entity.isColliding is ported
The row described it as missing because the prediction only restored the block.
It is game/remote.rs::Game::block_meets_player now, and end_predictions acts on
the answer.
2026-11-04 16:07:38 +00:00
MSedgeMC 817722009c Test the collision and the snap a restored block makes
Entity.isColliding on a full block at the player's feet meets a 0.6 x 1.8 box
standing in it, and a block above its head, the one beside it or an empty shape
does not. The snap takes the remembered position while the teleport guard allows
it, and nothing when the block came back clear of the player or the player was
teleported at the same sequence.
2026-11-04 16:07:38 +00:00
MSedgeMC ea80bba0f7 A refused block change that comes back inside the player snaps it out
ClientLevel.syncBlockState is what BlockStatePredictionHandler.endPredictionsUpTo
calls once the server acknowledges a sequence: when the block takes a state it
did not have, the level restores it, and then, if the prediction remembered a
player position and the player was not teleported since
(lastTeleportSequence < sequence), it asks Entity.isColliding(pos, state) -- the
state's own collision shape at the player's pose against the player's box -- and
calls Player.absSnapTo with the remembered point when they meet.

Horizon's end_predictions restored the block and stopped there, so a break the
server refused under the player, or a placement it pulled back, left the player
standing inside it until its own physics pushed it out.

A prediction now remembers what vanilla's ServerVerifiedState does -- the
sequence, the state the server last gave the block, and the player's position
when the change was first predicted -- and keeps all three through a
retainKnownServerState that only bumps the sequence. end_predictions restores the
block, tests the shape against the player and snaps to the remembered point when
the guard allows it. remote_teleport takes BlockStatePredictionHandler.onTeleport,
so a prediction made before a teleport keeps its block but not its snap.
2026-11-04 16:07:37 +00:00
1076 changed files with 88201 additions and 8466 deletions

No files matched your search

+4 -1
View File
@@ -34,7 +34,10 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
- Contributors first: `gh pr list --state open --search <area>` before you start; never duplicate theirs.
- **No hardcoded user-facing strings.** Keep UI translation-ready from the start. In the launcher (`launcher/`), wrap visible text with `tr("...")` or `trf("... {arg} ...", &[("arg", &val)])` from `crate::i18n` instead of raw string literals or `format!`. In the game, use translation keys (`Text::tr`, `lang::get`), never hardcoded English strings.
- **Claim before you start**: comment `/claim` on the issue (no issue yet: open one, then claim it). An assigned
issue is taken: pick another. `/unclaim` when you stop; a claim with no open PR linking it is released after 5 days.
issue is taken, and so is one labelled `in progress` (claimed on Discord): pick another. `/unclaim` when you stop;
a claim with no open PR linking it is released after 5 days. The issues free to claim, without reading threads:
`gh issue list --search 'is:open no:assignee -label:"in progress" -label:tracker -label:proposal -label:"not claimable" -linked:pr'`
- **Controllers reach what registers.** Draw controls with a registering helper (`ui::button`, `settings_shell::foot_button`); `graphics_style::button` and `screen_draw::button` only paint, and registering one part of a screen ends its crosshair, so register all of it.
## Crates
Generated
+245
View File
@@ -147,6 +147,12 @@ dependencies = [
"x11rb",
]
[[package]]
name = "arcstr"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03918c3dbd7701a85c6b9887732e2921175f26c350b4563841d0958c21d57e6d"
[[package]]
name = "arrayref"
version = "0.3.9"
@@ -303,6 +309,21 @@ dependencies = [
"objc2 0.6.5",
]
[[package]]
name = "btoi"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b5ab9db53bcda568284df0fd39f6eac24ad6f7ba7ff1168b9e76eba6576b976"
dependencies = [
"num-traits",
]
[[package]]
name = "bufstream"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40e38929add23cdf8a366df9b0e088953150724bcbe5fc330b0d8eb3b328eec8"
[[package]]
name = "bumpalo"
version = "3.20.3"
@@ -329,6 +350,12 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
@@ -651,6 +678,15 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "crossbeam-queue"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.23"
@@ -768,6 +804,17 @@ version = "0.5.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e"
[[package]]
name = "derive_utils"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc05a5d33db20c784f873e84934ad94bb209a090987ac5f62fede2c178234f23"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "des"
version = "0.8.1"
@@ -1364,6 +1411,7 @@ dependencies = [
"serde_json",
"sha1",
"sha2",
"toml",
"unicode-bidi",
"ureq",
"wgpu",
@@ -1413,6 +1461,7 @@ dependencies = [
"horizon-client-render",
"horizon-command",
"horizon-core",
"horizon-cosmetics",
"horizon-dualsense",
"horizon-friends",
"horizon-hdr",
@@ -1607,6 +1656,16 @@ dependencies = [
"serde_json",
]
[[package]]
name = "horizon-mod-deps"
version = "0.1.0"
dependencies = [
"mysql",
"redis",
"rusty-opus",
"tungstenite",
]
[[package]]
name = "horizon-mods"
version = "0.1.0"
@@ -2002,6 +2061,15 @@ dependencies = [
"generic-array",
]
[[package]]
name = "io-enum"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f859dc81e05e585e93cfc84dffa4fd068d0651ca229c51bca6101c056b63b93a"
dependencies = [
"derive_utils",
]
[[package]]
name = "ipconfig"
version = "0.3.4"
@@ -2249,6 +2317,12 @@ version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "lru"
version = "0.16.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39"
[[package]]
name = "lz4_flex"
version = "0.14.0"
@@ -2338,6 +2412,59 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d"
[[package]]
name = "mysql"
version = "27.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "090068bc0a24762663338da78414148333d1ee246a7293c10a517ddf1548b291"
dependencies = [
"bufstream",
"bytes",
"crossbeam-queue",
"crossbeam-utils",
"flate2",
"io-enum",
"libc",
"lru",
"mysql_common",
"named_pipe",
"pem",
"percent-encoding",
"rustls",
"rustls-pemfile",
"socket2 0.6.5",
"twox-hash",
"url",
"webpki",
"webpki-roots 1.0.10",
]
[[package]]
name = "mysql_common"
version = "0.36.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20e7f6a15265801679ddf1ddb38fd74b4555a677a78378d4561fbe5f0b1d4d72"
dependencies = [
"base64",
"bitflags 2.13.2",
"btoi",
"byteorder",
"bytes",
"crc32fast",
"flate2",
"getrandom 0.3.4",
"num-bigint",
"num-traits",
"regex",
"saturating",
"serde",
"serde_json",
"sha1",
"sha2",
"thiserror 2.0.21",
"uuid",
]
[[package]]
name = "naga"
version = "30.0.1"
@@ -2375,6 +2502,15 @@ dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "named_pipe"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad9c443cce91fc3e12f017290db75dde490d685cdaaf508d7159d7cf41f0eb2b"
dependencies = [
"winapi",
]
[[package]]
name = "ndk"
version = "0.9.0"
@@ -2974,6 +3110,16 @@ dependencies = [
"hmac",
]
[[package]]
name = "pem"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
dependencies = [
"base64",
"serde_core",
]
[[package]]
name = "pem-rfc7468"
version = "0.7.0"
@@ -3301,6 +3447,22 @@ dependencies = [
"yasna",
]
[[package]]
name = "redis"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db7f3d75b5a3301746bff39a767bf4c5c42506b08513527308b3875bfb64a031"
dependencies = [
"arcstr",
"combine",
"itoa",
"percent-encoding",
"ryu",
"socket2 0.6.5",
"url",
"xxhash-rust",
]
[[package]]
name = "redox_syscall"
version = "0.4.1"
@@ -3442,6 +3604,15 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rustls-pemfile"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.1"
@@ -3468,6 +3639,12 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "rusty-opus"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46604e6809c06441da1cfdde1f0cc25a4f2bf42c7d005aa387c2875c02d78762"
[[package]]
name = "ruzstd"
version = "0.9.1"
@@ -3477,6 +3654,12 @@ dependencies = [
"twox-hash",
]
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "salsa20"
version = "0.10.2"
@@ -3495,6 +3678,12 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "saturating"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ece8e78b2f38ec51c51f5d475df0a7187ba5111b2a28bdc761ee05b075d40a71"
[[package]]
name = "scoped-tls"
version = "1.0.1"
@@ -3714,6 +3903,17 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "socks"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b"
dependencies = [
"byteorder",
"libc",
"winapi",
]
[[package]]
name = "spake2"
version = "0.4.0"
@@ -4092,6 +4292,18 @@ version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
[[package]]
name = "tungstenite"
version = "0.30.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22"
dependencies = [
"bytes",
"log",
"rand",
"thiserror 2.0.21",
]
[[package]]
name = "twox-hash"
version = "2.1.5"
@@ -4167,6 +4379,7 @@ dependencies = [
"once_cell",
"rustls",
"rustls-pki-types",
"socks",
"url",
"webpki-roots 0.26.11",
]
@@ -4434,6 +4647,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "webpki"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed63aea5ce73d0ff405984102c42de94fc55a6b75765d621c65262469b3c9b53"
dependencies = [
"ring",
"untrusted",
]
[[package]]
name = "webpki-roots"
version = "0.26.11"
@@ -4629,6 +4852,22 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-util"
version = "0.1.11"
@@ -4638,6 +4877,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows"
version = "0.54.0"
+1
View File
@@ -39,6 +39,7 @@ members = [
"crates/horizon-optifine",
"crates/horizon-shader-api",
"crates/horizon-release-sign",
"crates/horizon-mod-deps",
"xtask",
]
resolver = "2"
+62 -64
View File
@@ -1,77 +1,87 @@
<p align="center">
<img src="docs/media/banner.webp" alt="The Horizon website: the game you know, rebuilt in Rust" width="100%">
</p>
<p align="center">
<a href="https://github.com/horizon-rust/horizon/actions/workflows/ci.yml"><img src="https://github.com/horizon-rust/horizon/actions/workflows/ci.yml/badge.svg?branch=main" alt="CI"></a>
<img src="https://img.shields.io/badge/Minecraft-Java%2026.3-5D9C3F" alt="Minecraft Java 26.3">
<img src="https://img.shields.io/badge/Rust-stable-B7410E?logo=rust&logoColor=white" alt="Rust">
<img src="https://img.shields.io/badge/vanilla%20parity-100%25-F0795A" alt="Vanilla parity 100%">
<a href="#project-status"><img src="https://img.shields.io/badge/player--visible%20gaps-0%20open-F0795A" alt="Player-visible gaps open"></a>
<img src="https://img.shields.io/badge/platform-Linux%20%7C%20macOS%20%7C%20Windows-2A2470" alt="Linux, macOS and Windows">
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-3B2F7E" alt="MIT license"></a>
<img src="docs/media/banner.webp" alt="Horizon: the game you know, rebuilt in Rust" width="100%">
</p>
<p align="center">
<a href="https://playhorizon.dev"><b>Website</b></a> ·
<a href="#download"><b>Download</b></a> ·
<a href="https://playhorizon.dev/docs/"><b>Documentation</b></a> ·
<a href="https://github.com/horizon-rust/horizon/releases"><b>Release notes</b></a> ·
<a href="https://playhorizon.dev/docs/"><b>Docs</b></a> ·
<a href="https://discord.gg/playhorizon"><b>Discord</b></a> ·
<a href="ROADMAP.md"><b>Roadmap</b></a> ·
<a href="#project-status"><b>Project status</b></a> ·
<a href="#contributing"><b>Contributing</b></a>
</p>
<p align="center">
<img src="https://img.shields.io/badge/vanilla%20parity-100%25-F0795A" alt="Vanilla parity 100%">
<a href="#project-status"><img src="https://img.shields.io/badge/player--visible%20gaps-0%20open-F0795A" alt="Player-visible gaps open"></a>
</p>
# Horizon
Minecraft: Java Edition 26.3, rewritten in Rust. Same worlds, same rules, same terrain, checked against the Java game by more than 300 differential harnesses. Free, open source and unofficial.
**Minecraft: Java Edition 26.3, rewritten in Rust.** Your worlds, the same rules and the same terrain as the Java game, checked against it by hundreds of differential tests, in one native program with no Java. Free, open source (MIT) and unofficial: you play it with your own copy of Java Edition.
> Not an official Minecraft product, nor approved by or associated with Mojang or Microsoft. Horizon contains no Minecraft code, assets or data: you need your own copy of Java Edition 26.3.
## Download
Get **the Horizon launcher** from [playhorizon.dev](https://playhorizon.dev) or [the launcher releases](https://github.com/horizon-rust/horizon/releases?q=launcher&expanded=true) (Linux x86-64, macOS on Apple Silicon, macOS on Intel from the next Nightly, Windows x64). It installs and updates the game, prepares its files from your copy of Minecraft, and updates itself.
**You need** a Microsoft account that owns Java Edition ([why](docs/microsoft-account.md)) and a GPU with Vulkan or OpenGL 3.3 (Linux), Vulkan or DirectX 12 (Windows), or a Mac (Apple Silicon, or Intel from the next Nightly). An existing Minecraft install (official launcher, Prism, MultiMC...) is reused; without one, Play downloads 26.3 from Mojang for your account. Step by step: [Install and first launch](https://playhorizon.dev/docs/install).
<table>
<tr>
<td width="50%"><img src="docs/media/launcher-play.webp" alt="The launcher's Home page: the selected instance ready to play, the version picker, Join a friend and the instance's tabs"></td>
<td width="50%"><img src="docs/media/launcher-settings.webp" alt="The launcher's Settings page: Microsoft account, offline name, updates, appearance and accent colour"></td>
</tr>
</table>
<p align="center">
<img src="docs/media/readme/valley.webp" alt="A river valley with a plains village, savanna hills and a cherry grove, seen from above with a long view distance" width="100%">
</p>
## What works today
- **The game:** vanilla world generation, mobs, villagers, raids, redstone, every command, the HUD and the recipe book, on vanilla saves. Worlds from 1.8 to 26.2 are upgraded. [Status](#project-status).
- **Extras:** new menus, graphics presets, distant terrain up to 1024 chunks, controllers, zoom, minimap and more, off by default where they change the vanilla look. [Settings](https://playhorizon.dev/docs/settings).
- **Packs:** resource packs, and data packs with functions, predicates, item modifiers and world generation.
- **Multiplayer:** invite friends with a code, Open to LAN, join servers (online mode included). [Multiplayer](https://playhorizon.dev/docs/multiplayer).
- **Mods:** content mods (blocks, items, data) and native Rust mods, built by the launcher with nothing to install. Java mod jars do not run. [Mods](https://playhorizon.dev/docs/mods).
Everything below is in the current release.
In the Rust client, with vanilla assets and no mods or shaders:
- **The game.** Survival, creative, hardcore and spectator; vanilla world generation in all three dimensions, mobs, villagers, raids, redstone, every command, the HUD and the recipe book. Vanilla saves open as they are, and worlds from 1.8 to 26.2 are upgraded.
- **Multiplayer.** Join vanilla and Horizon servers (online mode included), open a world to LAN, or invite friends with a code from the launcher's Home page.
- **Graphics.** Distant terrain up to 1,024 chunks, graphics presets, HDR and controllers, all off by default where they would change the vanilla look ([settings](https://playhorizon.dev/docs/settings)).
- **Packs.** Resource packs, data packs and shader packs ([below](#mods-and-shader-packs)).
- **Mods.** Content mods and native Rust mods, installed from the launcher ([below](#mods-and-shader-packs)).
- **Servers.** Host one from the launcher, or run `horizon-server` on a VPS ([below](#host-a-server)).
- **The launcher.** It installs and updates the game, prepares its files from your copy of Minecraft, and updates itself.
<table>
<tr>
<td colspan="2"><img src="docs/media/sunset.webp" alt="Sunset over a savanna river valley and a plains village"></td>
<td width="50%"><img src="docs/media/readme/sunset.webp" alt="Sunset behind a cherry grove on a hill, with a river below"></td>
<td width="50%"><img src="docs/media/readme/nether.webp" alt="A crimson forest in the Nether, with a piglin among the fungi"></td>
</tr>
<tr>
<td width="50%"><img src="docs/media/survival.webp" alt="Survival at dusk: a zombie walks up, with the hotbar, health, hunger and experience"></td>
<td width="50%"><img src="docs/media/settings.webp" alt="Vanilla's Video Settings screen with Horizon's own buttons at the top: Horizon Graphics and Advanced Graphics"></td>
</tr>
<tr>
<td width="50%"><img src="docs/media/village.webp" alt="A plains village among savanna hills, with farms, villagers and an iron golem"></td>
<td width="50%"><img src="docs/media/nether.webp" alt="A crimson forest in the Nether with a zombified piglin"></td>
<td width="50%"><img src="docs/media/readme/survival.webp" alt="Survival in a village square: two villagers, flowers, the hotbar, health and hunger"></td>
<td width="50%"><img src="docs/media/readme/multiplayer.webp" alt="Two players in a LAN world: the other player's name tag and the player list"></td>
</tr>
</table>
<sub>Nightly 2026-11-05 on a Mac mini (Apple M4 Pro, Metal): vanilla's textures, no mods or shader packs, render distance 32 with distant terrain on.</sub>
## Download
Get **the Horizon launcher** from [playhorizon.dev](https://playhorizon.dev), or from [the launcher releases](https://github.com/horizon-rust/horizon/releases?q=launcher&expanded=true):
| Platform | File | Needs |
| --- | --- | --- |
| Windows x64 | `horizon-launcher-setup.exe` | Windows 10 or 11, a GPU with Vulkan or DirectX 12 |
| Windows on Arm | `horizon-launcher-setup-arm64.exe` | Windows 11 on Arm |
| macOS, Apple Silicon | `horizon-launcher-macos-arm64.dmg` | macOS 12 or later |
| macOS, Intel | `horizon-launcher-macos-x64.dmg` | macOS 12 or later |
| Linux x86-64 | `horizon-launcher-linux-x86_64.AppImage` or `.tar.gz` | a glibc distribution, X11 or Wayland, a GPU with Vulkan or OpenGL 3.3 |
**You also need a Microsoft account that owns Minecraft: Java Edition.** You sign in with it in the launcher, which plays only with an account that owns the game ([why](docs/microsoft-account.md)). Horizon contains no Minecraft code or assets: an existing install (official launcher, Prism, MultiMC...) is reused, and without one the launcher downloads 26.3 from Mojang for your account. Step by step: [Install and first launch](https://playhorizon.dev/docs/install).
<table>
<tr>
<td width="50%"><img src="docs/media/readme/launcher-home.webp" alt="The launcher's Home page: the Horizon instance, Play, the version picker, the friend invite field, screenshots and the patch log"></td>
<td width="50%"><img src="docs/media/readme/launcher-mods.webp" alt="The launcher's Mods page: Horizon's mod registry with Zoom selected, and tabs for modpacks, resource packs, shader packs and data packs"></td>
</tr>
</table>
## Mods and shader packs
- **Content mods** (`.hmod`): blocks, items, recipes, loot, models and textures, with no code.
- **Native mods** (`.hnmod`): Rust that the launcher compiles into the game for you, with nothing to install.
- **Java mods** (Fabric, NeoForge, Forge) don't run: Horizon has no Java. The launcher carries over the data and assets inside their jars, and imports modpacks (`.mrpack`, CurseForge zips, Prism instances) the same way ([modpacks](https://playhorizon.dev/docs/modpacks)).
- **Shader packs**: Iris/OptiFine-format packs (`shaders/` folders) and Horizon's own WGSL packs, under Video Settings → Shader Packs ([pack format](docs/shaders.md)).
Mods are off by default: a fresh install plays exactly like vanilla 26.3. The launcher's Mods page browses Horizon's registry and Modrinth. [Mods](https://playhorizon.dev/docs/mods) · [make a mod](docs/mods.md).
## Host a server
The launcher's **Servers** page creates, starts, backs up and updates a server, and invites friends over Tailscale. On a VPS, `horizon-server` installs with one command and needs no Java. [Host a server](https://playhorizon.dev/docs/hosting) ([reference](docs/hosting.md)).
## Modpacks
The launcher imports Modrinth and CurseForge packs and plays the mods Horizon can carry over: today, content mods made of data and assets, not Java code. [Modpacks](https://playhorizon.dev/docs/modpacks).
The launcher's **Servers** page creates, starts, backs up and updates a server, and invites friends. On a VPS, `horizon-server` installs with one command, needs no Java, and takes vanilla 26.3 and Horizon players alike. [Host a server](https://playhorizon.dev/docs/hosting) ([reference](docs/hosting.md)).
## Agent control
@@ -80,13 +90,11 @@ AI agents and scripts can play Horizon. `horizon-ctl`, shipped with the game, re
```sh
horizon-ctl launch --world Survival --seed 5 --wait # finds the version the launcher installed
horizon-ctl state # position, health, food, target, screen...
horizon-ctl find-block birch_log # the nearest logs
horizon-ctl goto -0.5 65 -2.5 --near 2.5 # walks there along a path
horizon-ctl break -1 65 -3 # mines it; replies with the drops
horizon-ctl open-inventory && horizon-ctl click-slot 0 # menus: slots, buttons by label, text fields
```
Commands block until done and reply with the result; `horizon-ctl help` lists them all. [Agent control](docs/native-client.md#agent-control-horizon-ctl-and-mcp).
`horizon-ctl help` lists every command. [Agent control](docs/native-client.md#agent-control-horizon-ctl-and-mcp).
## Build from source
@@ -105,13 +113,9 @@ cargo run --release -p horizon-client
Prepared files land in the gitignored `.local/`: never commit or share them. Options, controls and architecture: [the native client guide](docs/native-client.md); all developer docs: [docs/README.md](docs/README.md).
## Roadmap
What is in progress, what comes next and where to help: [ROADMAP.md](ROADMAP.md), or [playhorizon.dev/hub](https://playhorizon.dev/hub) with the work board and what shipped.
## Contributing
Issues and pull requests are welcome: read [CONTRIBUTING.md](CONTRIBUTING.md) (vanilla first, small pull requests). Questions: [Discord](https://discord.gg/playhorizon). Before a pull request, run the self-check and paste its output:
Issues and pull requests are welcome: read [CONTRIBUTING.md](CONTRIBUTING.md) (vanilla first, small pull requests), and see [ROADMAP.md](ROADMAP.md) or [playhorizon.dev/hub](https://playhorizon.dev/hub) for what's next. Questions: [Discord](https://discord.gg/playhorizon). Before a pull request, run the self-check and paste its output:
```sh
git config core.hooksPath .githooks
@@ -124,7 +128,7 @@ It runs rustfmt, clippy, the touched crates' tests, the Python tests, the public
**Player-visible gaps: 0 open** of the 833 the [port ledger](tools/port_ledger.py) found, reading every vanilla 26.3 method against Horizon's code: vanilla behaviour a player would notice that Horizon doesn't have yet. By area: Animals [0](https://github.com/horizon-rust/horizon/issues/3512); Monsters and bosses [0](https://github.com/horizon-rust/horizon/issues/3513); Entities, projectiles, vehicles and decorations [0](https://github.com/horizon-rust/horizon/issues/3514); Items, enchantments and inventories [0](https://github.com/horizon-rust/horizon/issues/3515); Blocks, redstone and fluids [0](https://github.com/horizon-rust/horizon/issues/3516); World, world generation and saves [0](https://github.com/horizon-rust/horizon/issues/3517); Screens, HUD and client packets [0](https://github.com/horizon-rust/horizon/issues/3518); Entity and block rendering, models [0](https://github.com/horizon-rust/horizon/issues/3519); Server and players on the server [0](https://github.com/horizon-rust/horizon/issues/3520); Mob AI, villagers and raids [0](https://github.com/horizon-rust/horizon/issues/3521). This number goes down as gaps close (`tools/parity_gaps.py`); the harness results below say that what is tested matches.
The table is the project's own audit: every subsystem matches vanilla in every harness that compares it. 100% means 100% of what the harnesses compare, not that nothing else differs.
The table is the project's own audit: every subsystem matches vanilla in every harness that compares it. 100% means 100% of what the harnesses compare, not that nothing else differs. What players still run into: [known issues](docs/known-issues.md).
### Java parity: 100%
@@ -147,14 +151,8 @@ Scores: 0 (absent), 0.25 (started), 0.5 (playable, major features missing), 0.75
| Audio | 1 | None: the sound engine, sound events, music, situational music, ambience and subtitles match vanilla |
| Block behaviours and ticking | 1 | None: block updates, random ticks, redstone, dispensers (6,000 cases), crafters (4,000), container spills (4,000), copper golems and the sculk family (435 scenarios) match |
### Known issues
Skins served over https show the default skin unless already cached; pack icons show the unknown-pack texture in game; item tooltips lack attribute lines.
## Licence
[MIT](LICENSE), for the project's original work; dependencies keep their own licences ([notices](THIRD_PARTY_NOTICES.md)). Minecraft is a trademark of Mojang Synergies AB; its code and assets belong to Mojang and Microsoft under the [Minecraft EULA](https://www.minecraft.net/en-us/eula).
[MIT](LICENSE), for the project's original work; dependencies keep their own licences ([notices](THIRD_PARTY_NOTICES.md)). Horizon is made by [Ludovic Marie](https://lu4ovic.com) ([@ludovic111](https://github.com/ludovic111)) and [its contributors](https://github.com/horizon-rust/horizon/graphs/contributors); support it on [GitHub Sponsors](https://github.com/sponsors/ludovic111).
## Author
Made by [Ludovic Marie](https://lu4ovic.com) ([@ludovic111](https://github.com/ludovic111)).
NOT AN OFFICIAL MINECRAFT PRODUCT. NOT APPROVED BY OR ASSOCIATED WITH MOJANG OR MICROSOFT. Minecraft is a trademark of Mojang Synergies AB; its code and assets belong to Mojang and Microsoft under the [Minecraft EULA](https://www.minecraft.net/en-us/eula).
+40 -2
View File
@@ -266,7 +266,7 @@ Shader packs were removed and are being rebuilt from scratch. They come back, of
A clean-room rewrite in `horizon-shaderpack`, from the packs' public format documentation only: reading packs, the preprocessor and `.properties` files are merged ([#1584](https://github.com/horizon-rust/horizon/issues/1584), [#1585](https://github.com/horizon-rust/horizon/issues/1585), [#1586](https://github.com/horizon-rust/horizon/issues/1586)); translating programs to WGSL through naga, buffers, uniforms and the terrain, entity, sky, weather and hand stages are in review ([#1698](https://github.com/horizon-rust/horizon/issues/1698) to [#1801](https://github.com/horizon-rust/horizon/issues/1801)).
More: [#1561](https://github.com/horizon-rust/horizon/issues/1561) · [#1771](https://github.com/horizon-rust/horizon/issues/1771) · [#1786](https://github.com/horizon-rust/horizon/issues/1786)
More: [#1561](https://github.com/horizon-rust/horizon/issues/1561) · [#1771](https://github.com/horizon-rust/horizon/issues/1771) · [#1786](https://github.com/horizon-rust/horizon/issues/1786) · [#9045](https://github.com/horizon-rust/horizon/issues/9045) · [#6233](https://github.com/horizon-rust/horizon/issues/6233) · [#6485](https://github.com/horizon-rust/horizon/issues/6485) · [#3734](https://github.com/horizon-rust/horizon/issues/3734) · [#3125](https://github.com/horizon-rust/horizon/issues/3125)
</details>
@@ -298,6 +298,8 @@ Far terrain drawn from the world you really built and explored, caves and builds
Keep a 3D mip store on disk built from real chunks, drawn by the GPU beside the seed preview for areas nobody has visited. Written from scratch and measured before it is kept. Builds on Far Detail and the far-terrain levels of detail already shipped; the open work on them is [#718](https://github.com/horizon-rust/horizon/issues/718).
More: [#4678](https://github.com/horizon-rust/horizon/issues/4678)
</details>
#### A batch of targeted optimisations
@@ -308,7 +310,7 @@ Several smaller speed-ups, each measured: idle block entities that sleep, cheape
Block entities and hoppers that skip work when idle (the hopper item scan is already skipped when no item is near); a particle frustum test first (also a parity gap), then particle quads built on the GPU; jigsaw shortcuts that skip blocked connectors and pool elements known not to fit, with layouts unchanged (trial chambers and ancient cities first); batched raw mouse input on Windows for 8000 Hz mice; a better atlas packer for huge resource packs (a 64x pack builds a 704 MiB atlas for 452 MiB of textures); sign-heavy scenes ([#1461](https://github.com/horizon-rust/horizon/issues/1461)). Profile first, keep what pays, show before and after numbers.
More: [docs/profiling.md](docs/profiling.md)
More: [docs/profiling.md](docs/profiling.md) · [#7752](https://github.com/horizon-rust/horizon/issues/7752) · [#8251](https://github.com/horizon-rust/horizon/issues/8251) · [#10229](https://github.com/horizon-rust/horizon/issues/10229)
</details>
@@ -322,6 +324,8 @@ Recognise mods built for versions before 26.1 in the mod browser, so they can be
A mapping table from intermediary and SRG names so pre-26.1 jars can be classified, and a rename for mod ids with dashes (`fabric-api`); the port step then treats them like newer jars ([docs/mod-browser.md](docs/mod-browser.md)).
More: [#10262](https://github.com/horizon-rust/horizon/issues/10262)
</details>
#### A modpack's own resource and shader packs
@@ -536,6 +540,8 @@ Contraptions and physics like the Create and Aeronautics mods, as a mod for Hori
Issue [#805](https://github.com/horizon-rust/horizon/issues/805) (porting QOL mods) closed with this answer: it would come as a native Horizon mod.
More: [#6302](https://github.com/horizon-rust/horizon/issues/6302) · [#5313](https://github.com/horizon-rust/horizon/issues/5313) · [#5556](https://github.com/horizon-rust/horizon/issues/5556)
</details>
#### Proximity voice chat as a native mod
@@ -558,6 +564,8 @@ Every past and future Minecraft version prepared for Horizon automatically. When
The port pipeline ([docs/version-ports.md](docs/version-ports.md), `tools/version_port.py`) detects a release, prepares it, diffs its classes against the pinned 26.3, regenerates the data-driven tables, runs every parity harness on it and writes a port report that becomes issues. The goal is as little human work as possible for any version, past or future. Detection and the first slices are merged; the scheduled agent that runs it every day isn't installed yet. Two versions in one build are out of scope.
More: [#4758](https://github.com/horizon-rust/horizon/issues/4758)
</details>
#### Controllers: tested on real pads, and in the launcher
@@ -568,6 +576,8 @@ Make controller support solid: tested with real PlayStation and Xbox pads and a
The game side is merged ([docs/controllers.md](docs/controllers.md)): pads read with gilrs, menus, rumble, glyphs, remapping and DualSense adaptive triggers, lightbar and haptics, but not yet confirmed on real pads or a Steam Deck (DualSense haptics stay behind a switch until then). Left: a test pass on real hardware, and the same focus navigation in the GPUI launcher.
More: [#10298](https://github.com/horizon-rust/horizon/issues/10298) · [#8678](https://github.com/horizon-rust/horizon/issues/8678)
</details>
#### The full debug screen
@@ -668,6 +678,16 @@ A shared resource store keyed by the preparer's digest, no prepare intermediates
</details>
#### A launcher screen readers can read
Screen readers read the launcher's buttons, lists and settings, and every part of it works from the keyboard.
<details><summary>Technical</summary>
Issue [#4537](https://github.com/horizon-rust/horizon/issues/4537): the launcher is GPUI (not Electron), so it exposes nothing to UI Automation, NSAccessibility or AT-SPI yet. Needs an accessibility tree from GPUI (an AccessKit bridge) and a role and label on each widget.
</details>
#### A visible beta label
A clear 'beta' label in the launcher and the game, so nobody is surprised that Horizon is still being built.
@@ -690,6 +710,18 @@ Issue [#1709](https://github.com/horizon-rust/horizon/issues/1709): aarch64 buil
</details>
#### The launcher builds Horizon on other systems
On a system Horizon has no download for, such as FreeBSD, the launcher offers to build the game on your computer, as it already does for native mods.
<details><summary>Technical</summary>
Issue [#4354](https://github.com/horizon-rust/horizon/issues/4354): when no release asset matches the platform, the launcher fetches the tagged source and runs the same pinned-toolchain `cargo build --locked` its native-mod builds use, with a warning about the time it takes. The build is unsigned, so the launcher marks the version as built locally.
More: [docs/native-mods.md](docs/native-mods.md)
</details>
#### Horizon on Windows ARM64
A native Horizon for Windows on ARM computers, such as Snapdragon laptops.
@@ -710,6 +742,8 @@ Versions of Horizon for Android, iOS and web browsers. Nothing official yet; con
No official build. Android was first closed as not planned, with touch controls, a mobile interface, packaging and loading the player's own assets all missing; the roadmap now includes it, iOS and a build for the browser as long-term goals. First pieces from contributors: `horizon-dualsense` compiling on iOS ([#1813](https://github.com/horizon-rust/horizon/issues/1813)) and helper-thread limits on Android ([#1751](https://github.com/horizon-rust/horizon/issues/1751)). Preparing without Java ([#1750](https://github.com/horizon-rust/horizon/issues/1750)) matters here too. Horizon ships none of Mojang's files on any platform.
More: [#9134](https://github.com/horizon-rust/horizon/issues/9134) · [#8069](https://github.com/horizon-rust/horizon/issues/8069) · [#4153](https://github.com/horizon-rust/horizon/issues/4153)
</details>
#### Horizon on Intel Macs
@@ -752,6 +786,8 @@ Record, replay and edit videos without leaving the game: record in a keypress, r
Three stages. Recording: capture gameplay in a keypress with an encoder thread and little frame-time cost. Replay: a replay mode for recorded sessions. Editing: cuts, camera paths, titles and export. None of it exists yet; the agent-control socket ([docs/agent-control.md](docs/agent-control.md)) already lets tools drive the game for scripted shots.
More: [#10025](https://github.com/horizon-rust/horizon/issues/10025)
</details>
#### Game menus in the launcher's style
@@ -842,6 +878,8 @@ Every download Horizon makes is checked against the project's signature, includi
Releases carry signed checksums, and the launcher and `horizon-server update` refuse anything unsigned ([#1011](https://github.com/horizon-rust/horizon/issues/1011), [#1014](https://github.com/horizon-rust/horizon/issues/1014)). Left: the first install through `install.sh` and `install.ps1` still trusts only the checksum GitHub publishes, because verifying Ed25519 signatures needs OpenSSL 3, which macOS doesn't ship ([docs/security.md](docs/security.md)).
More: [#10354](https://github.com/horizon-rust/horizon/issues/10354)
</details>
## Help wanted
+5
View File
@@ -0,0 +1,5 @@
# `keys.contains(&KeyCode::ShiftLeft)` reads a mapping by its default key: ask by `Action`, so the
# mapping is named and every device that can hold it counts.
disallowed-methods = [
{ path = "horizon_client_input::held::HeldKeys::contains", reason = "use `held(Action::..)`, or `raw` for a key that is no mapping's default", allow-invalid = true },
]
@@ -108,6 +108,16 @@ impl<T> CompiledChoices<T> {
multipart: self.multipart.iter().map(|c| c.map(&mut f)).collect(),
}
}
/// A variant that needs no random selection and produces exactly one model.
pub fn single_variant(&self, selected: &SelectedModels) -> Option<&T> {
let SelectedModels::Variant(index) = selected else {
return None;
};
match self.variants.get(*index)? {
Choice::Single(value) => Some(value),
Choice::Weighted { .. } => None,
}
}
/// The variant `particleMaterial` comes from: the first entry of a weighted
/// list (`WeightedVariants`) and, for a multipart root, of its first
/// selector whether or not that selector applies (`MultiPartModel`).
@@ -0,0 +1,22 @@
//! NBT path parser output and byte cursor for the pinned Java oracle.
use horizon_client_core::nbt_path::NbtPath;
use std::io::{BufRead, Write};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut args = std::env::args().skip(1);
let input = std::io::BufReader::new(std::fs::File::open(args.next().unwrap())?);
let mut output = std::io::BufWriter::new(std::fs::File::create(args.next().unwrap())?);
for line in input.lines() {
let text: String = serde_json::from_str(&line?)?;
match NbtPath::parse_prefix(&text) {
Ok((path, cursor)) => {
write!(output, "OK {cursor} ")?;
for value in path.as_str().as_bytes() {
write!(output, "{value:02x}")?;
}
writeln!(output)?;
}
Err(error) => writeln!(output, "ERR {} {}", error.cursor, error.key)?,
}
}
Ok(())
}
+34 -2
View File
@@ -111,7 +111,15 @@ impl Parser<'_> {
map.insert(key, self.value()?);
self.ws();
match self.peek() {
Some(b',') => self.i += 1,
// SnbtGrammar: an entry may be followed by a comma before the brace.
Some(b',') => {
self.i += 1;
self.ws();
if self.peek() == Some(b'}') {
self.i += 1;
return Ok(Value::Object(map));
}
}
Some(b'}') => {
self.i += 1;
return Ok(Value::Object(map));
@@ -140,7 +148,15 @@ impl Parser<'_> {
list.push(self.value()?);
self.ws();
match self.peek() {
Some(b',') => self.i += 1,
// SnbtGrammar: a list or array may end with a comma.
Some(b',') => {
self.i += 1;
self.ws();
if self.peek() == Some(b']') {
self.i += 1;
return Ok(Value::Array(list));
}
}
Some(b']') => {
self.i += 1;
return Ok(Value::Array(list));
@@ -497,6 +513,22 @@ pub fn name_text(tag: &Tag) -> Text {
#[cfg(test)]
mod tests {
use super::*;
/// SnbtGrammar: a compound or list may end with one comma; two in a row
/// stay an error.
#[test]
fn a_component_takes_one_trailing_comma() {
let parse = |s: &str| parse_snbt(s).map(|(v, _)| v);
assert_eq!(
parse("{text:\"a\",color:\"red\",}"),
parse("{text:\"a\",color:\"red\"}")
);
assert_eq!(
parse("[\"a\", {text:\"b\",} ,]"),
parse("[\"a\",{text:\"b\"}]")
);
assert!(parse("{text:\"a\",,}").is_err());
assert!(parse("[\"a\",,]").is_err());
}
#[test]
fn hex_color_follows_text_color_parse_color() {
+1 -1
View File
@@ -394,7 +394,7 @@ pub fn apply_deprecated(map: &mut HashMap<String, String>, json: &[u8]) {
/// characters in strings, `=` or `=>` and `;`), which vanilla reads lang files with (expected
/// from `Language.loadFromJson`, Gson). A file that is not JSON even so adds nothing; a value
/// that is not a string, number or boolean (`GsonHelper.convertToString`) ends the file there.
fn read_lang_file(bytes: &[u8], map: &mut HashMap<String, String>) {
pub fn read_lang_file(bytes: &[u8], map: &mut HashMap<String, String>) {
use horizon_world::datafix::json::{Json, parse_lenient};
if let Ok(entries) = serde_json::from_slice::<HashMap<String, String>>(bytes) {
map.extend(entries);
+50 -4
View File
@@ -81,7 +81,7 @@ fn elements(tag: &Tag) -> Option<Vec<Tag>> {
}
/// `DoubleTag.intValue` / `FloatTag.intValue` floor before the i32 conversion;
/// integral tags use their intValue narrowing. Byte arrays then keep the low byte.
fn int_value(t: &Tag) -> Option<i32> {
pub fn int_value(t: &Tag) -> Option<i32> {
Some(match t {
Tag::Byte(v) => i32::from(*v),
Tag::Short(v) => i32::from(*v),
@@ -726,11 +726,16 @@ fn parse_node(text: &str, i: usize, first: bool) -> Result<(Node, usize), PathEr
Some(b']') => Ok((Node::AllElements, j + 1)),
_ => {
let start = j;
if b.get(j) == Some(&b'-') {
// StringReader.readInt scans all allowed number characters
// before parsing, including malformed signs and decimal points.
while b
.get(j)
.is_some_and(|c| c.is_ascii_digit() || matches!(c, b'-' | b'.'))
{
j += 1;
}
while b.get(j).is_some_and(u8::is_ascii_digit) {
j += 1;
if start == j {
return Err(PathError::new("parsing.int.expected", vec![], start));
}
let n: i32 = text[start..j].parse().map_err(|_| {
PathError::new(
@@ -792,6 +797,13 @@ fn read_quoted(text: &str, i: usize) -> Result<(String, usize), PathError> {
let mut escaped = false;
for (o, c) in text[i + 1..].char_indices() {
if escaped {
if c != quote && c != '\\' {
return Err(PathError::new(
"parsing.quote.escape",
vec![c.to_string()],
i + 1 + o,
));
}
out.push(c);
escaped = false;
} else if c == '\\' {
@@ -840,6 +852,40 @@ fn parse_compound(text: &str, i: usize) -> Result<(Tag, usize), PathError> {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn quoted_escapes_and_index_tokens_match_brigadier() {
let cases = [
(r#""a\q""#, "parsing.quote.escape", vec!["q"], 3),
(r#"'a\x'"#, "parsing.quote.escape", vec!["x"], 3),
("[1.0]", "parsing.int.invalid", vec!["1.0"], 1),
("[.]", "parsing.int.invalid", vec!["."], 1),
("[--1]", "parsing.int.invalid", vec!["--1"], 1),
("[2147483648]", "parsing.int.invalid", vec!["2147483648"], 1),
("[+1]", "parsing.int.expected", vec![], 1),
(r#""é\q""#, "parsing.quote.escape", vec!["q"], 4),
(r#""é"[1.0]"#, "parsing.int.invalid", vec!["1.0"], 5),
];
let mut failures = Vec::new();
for (text, key, args, cursor) in cases {
match NbtPath::parse_prefix(text) {
Err(error) if error.key == key && error.args == args && error.cursor == cursor => {}
other => failures.push(format!("{text:?}: {other:?}")),
}
}
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
#[test]
fn valid_quoted_escapes_still_select_the_exact_field() {
let root = Tag::compound([("a\"b", Tag::Int(7)), ("a\\b", Tag::Int(8))]);
for (path, value) in [(r#""a\"b""#, 7), (r#""a\\b""#, 8)] {
assert_eq!(
NbtPath::parse(path).unwrap().get(&root).unwrap(),
vec![Tag::Int(value)]
);
}
}
#[test]
fn field_provenance_handles_quotes_and_filters_without_matching_lists() {
for text in [
+146
View File
@@ -0,0 +1,146 @@
//! Every input the game names: vanilla's key mappings (`Options.keyMappings`) and Horizon's
//! pad-only actions. Held state is asked by [`Action`] ([`crate::held::HeldKeys::held`]), not by a
//! key: code that wants a mapping says which one, and every device that can hold it counts.
use winit::keyboard::KeyCode;
macro_rules! actions {
($($variant:ident, $name:literal, $key:expr;)*) => {
/// A key mapping, or a pad-only action.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Action {
$($variant,)*
}
impl Action {
/// In `Options.keyMappings` order, then Horizon's own mappings, the pad-only actions last.
pub const ALL: &[Action] = &[$(Action::$variant,)*];
/// The saved name: vanilla's for a mapping, `pad.*` for Horizon's own.
pub fn name(self) -> &'static str {
match self {
$(Action::$variant => $name,)*
}
}
/// The default key the game's held state keeps this action under. `None` for what
/// isn't held as a key there: a mouse mapping, one with no default, a pad-only action,
/// and the debug keys, which act on the physical key (`debug_keys.rs`) and whose
/// defaults are other mappings' keys.
pub fn key(self) -> Option<KeyCode> {
match self {
$(Action::$variant => $key,)*
}
}
}
};
}
actions! {
Attack, "key.attack", None;
Use, "key.use", None;
Forward, "key.forward", Some(KeyCode::KeyW);
Left, "key.left", Some(KeyCode::KeyA);
Back, "key.back", Some(KeyCode::KeyS);
Right, "key.right", Some(KeyCode::KeyD);
Jump, "key.jump", Some(KeyCode::Space);
Sneak, "key.sneak", Some(KeyCode::ShiftLeft);
Sprint, "key.sprint", Some(KeyCode::ControlLeft);
Drop, "key.drop", Some(KeyCode::KeyQ);
Inventory, "key.inventory", Some(KeyCode::KeyE);
Chat, "key.chat", Some(KeyCode::KeyT);
Playerlist, "key.playerlist", Some(KeyCode::Tab);
PickItem, "key.pickItem", None;
Command, "key.command", Some(KeyCode::Slash);
Friends, "key.friends", Some(KeyCode::KeyO);
SocialInteractions, "key.socialInteractions", Some(KeyCode::KeyP);
ToggleGui, "key.toggleGui", Some(KeyCode::F1);
ToggleSpectatorShaderEffects, "key.toggleSpectatorShaderEffects", Some(KeyCode::F4);
Screenshot, "key.screenshot", Some(KeyCode::F2);
TogglePerspective, "key.togglePerspective", Some(KeyCode::F5);
SmoothCamera, "key.smoothCamera", None;
Fullscreen, "key.fullscreen", Some(KeyCode::F11);
SpectatorOutlines, "key.spectatorOutlines", None;
SpectatorHotbar, "key.spectatorHotbar", None;
SwapOffhand, "key.swapOffhand", Some(KeyCode::KeyF);
SaveToolbarActivator, "key.saveToolbarActivator", Some(KeyCode::KeyC);
LoadToolbarActivator, "key.loadToolbarActivator", Some(KeyCode::KeyX);
Advancements, "key.advancements", Some(KeyCode::KeyL);
QuickActions, "key.quickActions", Some(KeyCode::KeyG);
DebugOverlay, "key.debug.overlay", None;
DebugModifier, "key.debug.modifier", None;
Hotbar1, "key.hotbar.1", Some(KeyCode::Digit1);
Hotbar2, "key.hotbar.2", Some(KeyCode::Digit2);
Hotbar3, "key.hotbar.3", Some(KeyCode::Digit3);
Hotbar4, "key.hotbar.4", Some(KeyCode::Digit4);
Hotbar5, "key.hotbar.5", Some(KeyCode::Digit5);
Hotbar6, "key.hotbar.6", Some(KeyCode::Digit6);
Hotbar7, "key.hotbar.7", Some(KeyCode::Digit7);
Hotbar8, "key.hotbar.8", Some(KeyCode::Digit8);
Hotbar9, "key.hotbar.9", Some(KeyCode::Digit9);
DebugReloadChunk, "key.debug.reloadChunk", None;
DebugShowHitboxes, "key.debug.showHitboxes", None;
DebugClearChat, "key.debug.clearChat", None;
DebugCrash, "key.debug.crash", None;
DebugShowChunkBorders, "key.debug.showChunkBorders", None;
DebugShowAdvancedTooltips, "key.debug.showAdvancedTooltips", None;
DebugCopyRecreateCommand, "key.debug.copyRecreateCommand", None;
DebugSpectate, "key.debug.spectate", None;
DebugSwitchGameMode, "key.debug.switchGameMode", None;
DebugDebugOptions, "key.debug.debugOptions", None;
DebugFocusPause, "key.debug.focusPause", None;
DebugDumpDynamicTextures, "key.debug.dumpDynamicTextures", None;
DebugReloadResourcePacks, "key.debug.reloadResourcePacks", None;
DebugProfiling, "key.debug.profiling", None;
DebugCopyLocation, "key.debug.copyLocation", None;
DebugDumpVersion, "key.debug.dumpVersion", None;
DebugProfilingChart, "key.debug.profilingChart", None;
DebugFpsCharts, "key.debug.fpsCharts", None;
DebugNetworkCharts, "key.debug.networkCharts", None;
DebugLightmapTexture, "key.debug.lightmapTexture", None;
DebugImprovedTransparency, "key.debug.improvedTransparency", None;
Emote, "key.emote", Some(KeyCode::KeyB);
HotbarPrevious, "pad.hotbarPrevious", None;
HotbarNext, "pad.hotbarNext", None;
}
impl Action {
pub fn parse(name: &str) -> Option<Self> {
Self::ALL.iter().copied().find(|a| a.name() == name)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::keybinds::{self, HORIZON_MAPPINGS, KeyBinds, MAPPINGS, all_mappings};
#[test]
fn every_mapping_is_an_action_in_order() {
let names: Vec<&str> = Action::ALL.iter().map(|a| a.name()).collect();
let mappings: Vec<&str> = MAPPINGS.iter().map(|m| m.0).collect();
assert_eq!(names[..mappings.len()], mappings[..]);
let horizon: Vec<&str> = HORIZON_MAPPINGS.iter().map(|m| m.0).collect();
let end = mappings.len() + horizon.len();
assert_eq!(names[mappings.len()..end], horizon[..]);
for action in &names[end..] {
assert!(action.starts_with("pad."), "{action}");
}
}
#[test]
fn an_actions_key_is_its_mappings_default_key() {
for (name, _, category) in all_mappings() {
let action = Action::parse(name).unwrap();
let want = if *category == "debug" {
None
} else {
keybinds::key_code(KeyBinds::default_of(name))
};
assert_eq!(action.key(), want, "{name}");
}
}
#[test]
fn every_bindable_action_is_an_action() {
for (name, _) in crate::gamepad::ACTIONS {
assert!(Action::parse(name).is_some(), "{name}");
}
}
}
+293 -35
View File
@@ -1,11 +1,17 @@
//! Controllers: Horizon's own feature (vanilla 26.3 has no gamepad input).
//! The default layout is Bedrock Edition's; see docs/controllers.md.
//! Its default buttons are Horizon's own; analog walking and toggle sneak follow Bedrock Edition on
//! consoles. See docs/controllers.md.
//!
//! This module is the backend-free part: positional buttons, the action
//! bindings, deadzones and the look curve, button edges from successive pad
//! states, and `horizon-controller.txt`, kept next to `options.txt` so that
//! file stays exactly vanilla's. The game reads the pads with gilrs.
use std::{collections::BTreeMap, fs, path::Path};
use std::{
collections::BTreeMap,
fs,
path::Path,
time::{Duration, Instant},
};
pub const FILE: &str = "horizon-controller.txt";
@@ -296,7 +302,7 @@ pub const HOTBAR_NEXT: &str = "pad.hotbarNext";
/// always pause / back and isn't listed.
pub const ACTIONS: &[(&str, Option<Button>)] = &[
("key.jump", Some(Button::South)),
("key.drop", Some(Button::East)),
("key.drop", Some(Button::DPadDown)),
("key.swapOffhand", Some(Button::West)),
("key.inventory", Some(Button::North)),
(HOTBAR_PREVIOUS, Some(Button::LeftBumper)),
@@ -305,15 +311,31 @@ pub const ACTIONS: &[(&str, Option<Button>)] = &[
("key.attack", Some(Button::RightTrigger)),
("key.sprint", Some(Button::LeftStick)),
("key.sneak", Some(Button::RightStick)),
("key.togglePerspective", Some(Button::DPadUp)),
("key.togglePerspective", Some(Button::Select)),
("key.pickItem", Some(Button::DPadLeft)),
("key.chat", Some(Button::DPadRight)),
("key.advancements", Some(Button::DPadDown)),
("key.playerlist", Some(Button::Select)),
("key.chat", Some(Button::DPadUp)),
("key.advancements", None),
("key.playerlist", None),
("key.command", None),
("key.screenshot", None),
("key.toggleGui", None),
("key.smoothCamera", None),
// Horizon's emote wheel: no button is free by default; the stick's cursor picks.
("key.emote", None),
("key.fullscreen", None),
("key.socialInteractions", None),
("key.quickActions", None),
("key.saveToolbarActivator", None),
("key.loadToolbarActivator", None),
("key.hotbar.1", None),
("key.hotbar.2", None),
("key.hotbar.3", None),
("key.hotbar.4", None),
("key.hotbar.5", None),
("key.hotbar.6", None),
("key.hotbar.7", None),
("key.hotbar.8", None),
("key.hotbar.9", None),
];
/// How a bindable action reaches the game: one of its own handlers, shared
@@ -328,6 +350,10 @@ pub enum Route {
Sneak,
Screenshot,
SmoothCamera,
Fullscreen,
/// Does nothing by itself: what reads it asks whether the button is held
/// (`Minecraft.handleKeybinds`' toolbar keys, held with a hotbar key).
Held,
Key(winit::keyboard::KeyCode),
Mouse(winit::event::MouseButton),
}
@@ -340,6 +366,8 @@ pub fn route(action: &str) -> Option<Route> {
"key.sneak" => Some(Route::Sneak),
"key.screenshot" => Some(Route::Screenshot),
"key.smoothCamera" => Some(Route::SmoothCamera),
"key.fullscreen" => Some(Route::Fullscreen),
"key.saveToolbarActivator" | "key.loadToolbarActivator" => Some(Route::Held),
_ => {
let default = crate::keybinds::KeyBinds::default_of(action);
crate::keybinds::mouse_button(default)
@@ -359,6 +387,46 @@ pub fn action_caption(action: &str) -> &str {
}
}
/// How long a held drop button throws single items before it throws the rest of the stack.
pub const DROP_STACK_HOLD: Duration = Duration::from_secs(1);
/// What a held drop button does now, past its press.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DropStep {
/// Held key.drop (a repeat reaches `KeyMapping.click` as a press).
One,
/// Control + key.drop: a pad has no Control to hold, so holding on is how it asks.
Stack,
}
/// A held drop button's repeats: a tap throws one item, as Q does; held, it repeats at the pad's
/// repeat delay and interval; and held for [`DROP_STACK_HOLD`] it throws what's left.
#[derive(Clone, Copy, Debug)]
pub struct HeldDrop {
since: Instant,
next: Instant,
}
impl HeldDrop {
pub fn new(now: Instant, delay: Duration) -> Self {
Self {
since: now,
next: now + delay,
}
}
/// The step due at `now`, if any; at most one per call.
pub fn due(&mut self, now: Instant, interval: Duration) -> Option<DropStep> {
if now < self.next {
return None;
}
self.next = now + interval;
Some(if now.duration_since(self.since) >= DROP_STACK_HOLD {
DropStep::Stack
} else {
DropStep::One
})
}
}
/// Which button pictures prompts use.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum GlyphStyle {
@@ -402,13 +470,21 @@ impl GlyphStyle {
GlyphStyle::PlayStation,
GlyphStyle::Nintendo,
];
/// The style for a pad by its USB vendor (Sony, Nintendo; Microsoft,
/// Valve's Steam Deck and Steam Input's virtual pad, and everything else
/// look like Xbox).
pub fn of_vendor(vendor: Option<u16>) -> Self {
match vendor {
Some(0x054c) => GlyphStyle::PlayStation,
Some(0x057e) => GlyphStyle::Nintendo,
/// The style for a pad by its USB ids: Sony's and Nintendo's own, and a few officially licensed
/// third-party pads made for their consoles. Microsoft, Valve's Steam Deck and Steam Input's
/// virtual pad, and everything else look like Xbox.
pub fn of_device(vendor: Option<u16>, product: Option<u16>) -> Self {
match (vendor, product) {
(Some(0x054c), _) => GlyphStyle::PlayStation,
(Some(0x057e), _) => GlyphStyle::Nintendo,
// HORIPAD 4 FPS and FPS Plus; Razer Raiju; Nacon Revolution Pro.
(Some(0x0f0d), Some(0x0055 | 0x0066))
| (Some(0x1532), Some(0x1000))
| (Some(0x146b), Some(0x0d01)) => GlyphStyle::PlayStation,
// HORIPAD for Nintendo Switch; PowerA Wired Plus; PDP Faceoff Wired Pro.
(Some(0x0f0d), Some(0x00c1))
| (Some(0x20d6), Some(0xa711))
| (Some(0x0e6f), Some(0x0180)) => GlyphStyle::Nintendo,
_ => GlyphStyle::Xbox,
}
}
@@ -591,6 +667,24 @@ impl PadSettings {
}
}
}
// A file from before these defaults: its player's buttons stay where they were.
if !text.is_empty() && !text.lines().any(|line| line.starts_with("bindLayout:")) {
for (action, button) in [
("key.drop", Button::East),
("key.togglePerspective", Button::DPadUp),
("key.chat", Button::DPadRight),
("key.playerlist", Button::Select),
("key.advancements", Button::DPadDown),
] {
// A button saved by hand stays, even one that's now this action's default.
if !text
.lines()
.any(|line| line.starts_with(&format!("bind_{action}:")))
{
s.bindings.entry(action).or_insert(Some(button));
}
}
}
s
}
pub fn serialize(&self) -> String {
@@ -621,6 +715,7 @@ impl PadSettings {
button.map_or("none", Button::name)
));
}
text.push_str("bindLayout:1\n");
text
}
pub fn default_of(action: &str) -> Option<Button> {
@@ -697,6 +792,29 @@ pub fn stick_axes(
}
}
/// The tilt from which a stick holds a key in [`eight_direction_keys`]: holding one is full speed,
/// so the small tilts past the deadzone stay still.
const KEY_TILT: f32 = 0.3;
/// tan(22.5 degrees): where one of the 8 sectors meets the next.
const SECTOR_EDGE: f32 = 0.414_213_57;
/// The movement keys (forward, backward, left, right) a move vector (`[left, forward]`) holds: the
/// one of the 8 headings it is nearest, so it is at most 22.5 degrees off. An edge goes to the
/// straight heading.
pub fn eight_direction_keys([left, forward]: [f32; 2]) -> [bool; 4] {
if length([left, forward]) < KEY_TILT {
return [false; 4];
}
let along = forward.abs() > SECTOR_EDGE * left.abs();
let across = left.abs() > SECTOR_EDGE * forward.abs();
[
along && forward > 0.0,
along && forward < 0.0,
across && left > 0.0,
across && left < 0.0,
]
}
/// A radial deadzone: inside it the stick is centred; beyond it the rest of
/// the travel is rescaled to 0..1 in the same direction, so diagonals aren't
/// clipped and the first movement past the deadzone is small.
@@ -745,15 +863,6 @@ pub fn look_delta(
)
}
/// The hotbar slot after a bumper: one step with wrap-around.
pub fn hotbar_step(selected: usize, forward: bool) -> usize {
if forward {
(selected + 1) % 9
} else {
(selected + 8) % 9
}
}
/// Menu cursor speed in GUI pixels per second at full tilt.
pub const CURSOR_SPEED: f32 = 300.0;
@@ -1460,6 +1569,52 @@ mod tests {
}
}
#[test]
fn a_held_drop_repeats_then_throws_the_stack() {
let t = Instant::now();
let at = |ms: u64| t + Duration::from_millis(ms);
let (delay, interval) = (Duration::from_millis(350), Duration::from_millis(90));
let mut held = HeldDrop::new(t, delay);
assert_eq!(held.due(at(100), interval), None, "a tap is one item");
assert_eq!(held.due(at(349), interval), None);
assert_eq!(held.due(at(350), interval), Some(DropStep::One));
assert_eq!(held.due(at(400), interval), None, "not before the interval");
assert_eq!(held.due(at(440), interval), Some(DropStep::One));
assert_eq!(held.due(at(999), interval), Some(DropStep::One));
assert_eq!(held.due(at(1000), interval), None);
assert_eq!(held.due(at(1100), interval), Some(DropStep::Stack));
assert_eq!(held.due(at(1200), interval), Some(DropStep::Stack));
assert_eq!(held.due(at(1201), interval), None);
}
#[test]
fn the_later_actions_have_no_default_button_and_leave_saved_bindings() {
let defaults = PadSettings::default();
for action in [
"key.fullscreen",
"key.socialInteractions",
"key.quickActions",
"key.saveToolbarActivator",
"key.loadToolbarActivator",
"key.hotbar.1",
"key.hotbar.9",
] {
assert_eq!(defaults.binding(action), None, "{action}");
}
let read = PadSettings::parse(
"bindLayout:1
bind_key.jump:east
bind_key.hotbar.2:north
",
);
assert_eq!(read.binding("key.jump"), Some(Button::East));
assert_eq!(read.binding("key.inventory"), Some(Button::North));
assert_eq!(
read.actions_of(Button::North),
["key.inventory", "key.hotbar.2"]
);
}
#[test]
fn navigation_preferences_migrate_and_stay_with_each_player() {
let dir = std::env::temp_dir().join(format!(
@@ -1579,7 +1734,7 @@ mod tests {
}
#[test]
fn defaults_follow_bedrock() {
fn every_action_has_its_default() {
let s = PadSettings::default();
assert_eq!(s.actions_of(Button::South), ["key.jump"]);
assert_eq!(s.actions_of(Button::RightTrigger), ["key.attack"]);
@@ -1587,16 +1742,46 @@ mod tests {
assert_eq!(s.actions_of(Button::North), ["key.inventory"]);
assert_eq!(s.actions_of(Button::RightBumper), [HOTBAR_NEXT]);
assert!(s.actions_of(Button::Start).is_empty());
// Every default action names a vanilla mapping or a pad action.
// Every default action names a key mapping or a pad action.
for (action, _) in ACTIONS {
assert!(
action.starts_with("pad.")
|| crate::keybinds::MAPPINGS.iter().any(|m| m.0 == *action),
|| crate::keybinds::all_mappings().any(|m| m.0 == *action),
"{action}"
);
}
}
#[test]
fn a_file_from_before_these_defaults_keeps_its_buttons() {
// Saved settings that never bound D-pad down: it stays on the advancements.
let s = PadSettings::parse("vibration:false\nbind_key.chat:up\n");
assert_eq!(s.binding("key.advancements"), Some(Button::DPadDown));
assert_eq!(s.binding("key.chat"), Some(Button::DPadUp));
assert_eq!(s.binding("key.drop"), Some(Button::East));
assert_eq!(s.binding("key.togglePerspective"), Some(Button::DPadUp));
assert_eq!(s.binding("key.playerlist"), Some(Button::Select));
assert!(!s.conflicts("key.advancements"));
// Advancements moved by hand stays moved, and the file stays this way from then on.
let s = PadSettings::parse("bind_key.advancements:left\nbind_key.drop:down\n");
assert_eq!(s.binding("key.advancements"), Some(Button::DPadLeft));
assert_eq!(s.binding("key.drop"), Some(Button::DPadDown));
assert_eq!(PadSettings::parse(&s.serialize()), s);
}
#[test]
fn a_new_player_drops_on_d_pad_down_and_chats_on_d_pad_up() {
for s in [PadSettings::default(), PadSettings::parse("")] {
assert_eq!(s.binding("key.drop"), Some(Button::DPadDown));
assert_eq!(s.binding("key.chat"), Some(Button::DPadUp));
assert_eq!(s.binding("key.togglePerspective"), Some(Button::Select));
assert_eq!(s.binding("key.playerlist"), None);
assert_eq!(s.binding("key.advancements"), None);
}
let s = PadSettings::parse(&PadSettings::default().serialize());
assert_eq!(s, PadSettings::default());
}
#[test]
fn settings_round_trip_and_store_only_changes() {
let mut s = PadSettings::default();
@@ -1618,8 +1803,9 @@ mod tests {
assert_eq!(back.binding("key.drop"), None);
assert_eq!(back.actions_of(Button::East), ["key.jump"]);
// Garbage keeps the defaults.
let odd =
PadSettings::parse("lookSensitivity:NaN\nbind_key.nope:south\nbind_key.jump:x9\n");
let odd = PadSettings::parse(
"lookSensitivity:NaN\nbind_key.nope:south\nbind_key.jump:x9\nbindLayout:1\n",
);
assert_eq!(odd, PadSettings::default());
}
@@ -1676,6 +1862,63 @@ mod tests {
assert_eq!(Button::North.by_letter().label(GlyphStyle::Nintendo), "Y");
}
#[test]
fn a_stick_holds_the_keys_of_its_nearest_heading() {
// Forward, backward, left, right, by the heading counted from forward toward left.
let headings = [
[true, false, false, false],
[true, false, true, false],
[false, false, true, false],
[false, true, true, false],
[false, true, false, false],
[false, true, false, true],
[false, false, false, true],
[true, false, false, true],
];
for tenth in 0..3600 {
let degrees = tenth as f32 / 10.0;
if degrees % 45.0 == 22.5 {
continue;
}
for tilt in [0.31, 0.5, 1.0] {
let (s, c) = degrees.to_radians().sin_cos();
let keys = eight_direction_keys([tilt * s, tilt * c]);
let sector = ((degrees / 45.0).round() as usize) % 8;
assert_eq!(keys, headings[sector], "{degrees} at {tilt}");
let [left, forward] =
crate::input::keyboard_direction(keys[0], keys[1], keys[2], keys[3]);
let off = (left.atan2(forward).to_degrees() - degrees).rem_euclid(360.0);
assert!(off.min(360.0 - off) <= 22.5 + 1e-3, "{degrees}: {off}");
}
}
}
#[test]
fn a_sector_edge_goes_to_the_straight_heading() {
assert_eq!(
eight_direction_keys([0.414_213_57, 1.0]),
[true, false, false, false]
);
assert_eq!(
eight_direction_keys([0.4143, 1.0]),
[true, false, true, false]
);
assert_eq!(
eight_direction_keys([1.0, 0.414_213_57]),
[false, false, true, false]
);
}
#[test]
fn a_small_tilt_holds_no_key() {
assert_eq!(eight_direction_keys([0.0, 0.0]), [false; 4]);
assert_eq!(eight_direction_keys([0.2, 0.2]), [false; 4]);
assert_eq!(
eight_direction_keys([0.0, 0.31]),
[true, false, false, false]
);
}
#[test]
fn radial_deadzone_rescales() {
assert_eq!(radial([0.1, 0.05], 0.15), [0.0, 0.0]);
@@ -2206,12 +2449,27 @@ mod tests {
}
#[test]
fn hotbar_wraps_and_glyphs_follow_the_vendor() {
assert_eq!(hotbar_step(8, true), 0);
assert_eq!(hotbar_step(0, false), 8);
assert_eq!(hotbar_step(3, true), 4);
assert_eq!(GlyphStyle::of_vendor(Some(0x054c)), GlyphStyle::PlayStation);
assert_eq!(GlyphStyle::of_vendor(Some(0x28de)), GlyphStyle::Xbox);
assert_eq!(GlyphStyle::of_vendor(None), GlyphStyle::Xbox);
fn glyphs_follow_the_vendor() {
let of = |vendor, product| GlyphStyle::of_device(Some(vendor), Some(product));
// Any product of the consoles' makers.
assert_eq!(of(0x054c, 0x0ce6), GlyphStyle::PlayStation);
assert_eq!(of(0x054c, 0x0001), GlyphStyle::PlayStation);
assert_eq!(of(0x057e, 0x2009), GlyphStyle::Nintendo);
// A pad made for a console by another maker, and that maker's others.
assert_eq!(of(0x0f0d, 0x0066), GlyphStyle::PlayStation);
assert_eq!(of(0x0f0d, 0x00c1), GlyphStyle::Nintendo);
assert_eq!(of(0x0f0d, 0x0067), GlyphStyle::Xbox);
assert_eq!(of(0x20d6, 0xa711), GlyphStyle::Nintendo);
// Valve's virtual pad, Microsoft's, nothing known, nothing reported.
assert_eq!(of(0x28de, 0x11ff), GlyphStyle::Xbox);
assert_eq!(of(0x045e, 0x0b12), GlyphStyle::Xbox);
assert_eq!(of(0x1234, 0x5678), GlyphStyle::Xbox);
assert_eq!(GlyphStyle::of_device(None, None), GlyphStyle::Xbox);
// A vendor alone is enough only for the consoles' makers.
assert_eq!(
GlyphStyle::of_device(Some(0x054c), None),
GlyphStyle::PlayStation
);
assert_eq!(GlyphStyle::of_device(Some(0x0f0d), None), GlyphStyle::Xbox);
}
}
+32 -5
View File
@@ -3,15 +3,18 @@
//! device letting go (splitscreen moving the keyboard to another player, say)
//! can't release what another still presses.
use crate::actions::Action;
use std::collections::HashMap;
use winit::keyboard::KeyCode;
/// Where a press came from.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Source {
/// The keyboard and mouse, and the scripting socket acting as them.
/// The keyboard and mouse.
Keyboard,
Pad,
/// `horizon-ctl`'s `key` and `mouse` and the agent commands that walk and click.
Agent,
}
impl Source {
@@ -19,6 +22,7 @@ impl Source {
match self {
Source::Keyboard => 1,
Source::Pad => 2,
Source::Agent => 4,
}
}
}
@@ -29,9 +33,20 @@ pub struct HeldKeys {
}
impl HeldKeys {
pub fn contains(&self, key: &KeyCode) -> bool {
/// Whether anything holds this mapping: every device, whatever the mapping is bound to.
pub fn held(&self, action: Action) -> bool {
action.key().is_some_and(|key| self.raw(&key))
}
/// Whether a key that is no mapping's default is held (Right Shift, the arrows, Alt, the system
/// key), or the key a caller already holds as a key: most code wants [`HeldKeys::held`].
pub fn raw(&self, key: &KeyCode) -> bool {
self.held.contains_key(key)
}
/// The old way of asking, by a mapping's default key; clippy refuses it (`clippy.toml`). It
/// stays so that a change written against it says what to use instead.
pub fn contains(&self, key: &KeyCode) -> bool {
self.raw(key)
}
/// `source` holds `key`. True when nothing held it before.
pub fn press(&mut self, key: KeyCode, source: Source) -> bool {
let sources = self.held.entry(key).or_insert(0);
@@ -72,7 +87,7 @@ impl HeldKeys {
/// Held, by the keyboard unless a device already holds it: for callers
/// that don't say where the press came from.
pub fn insert(&mut self, key: KeyCode) -> bool {
if self.contains(&key) {
if self.raw(&key) {
return false;
}
self.press(key, Source::Keyboard)
@@ -132,13 +147,25 @@ mod tests {
assert!(!keys.press(KeyCode::Space, Source::Keyboard));
assert!(keys.press(KeyCode::KeyW, Source::Keyboard));
assert_eq!(keys.release_source(Source::Keyboard), vec![KeyCode::KeyW]);
assert!(keys.contains(&KeyCode::Space) && !keys.contains(&KeyCode::KeyW));
assert!(keys.held(Action::Jump) && !keys.held(Action::Forward));
assert!(keys.held_by(&KeyCode::Space, Source::Pad));
assert!(!keys.held_by(&KeyCode::Space, Source::Keyboard));
assert!(keys.release(&KeyCode::Space, Source::Pad));
assert!(keys.is_empty());
}
#[test]
fn a_script_holds_a_key_by_itself() {
let mut keys = HeldKeys::default();
keys.press(KeyCode::KeyW, Source::Agent);
keys.press(KeyCode::KeyW, Source::Keyboard);
assert!(!keys.release(&KeyCode::KeyW, Source::Keyboard));
assert!(keys.held(Action::Forward));
assert!(keys.release_source(Source::Pad).is_empty());
assert_eq!(keys.release_source(Source::Agent), vec![KeyCode::KeyW]);
assert!(!keys.held(Action::Forward));
}
#[test]
fn untagged_inserts_and_removes_act_as_before() {
let mut keys = HeldKeys::default();
@@ -149,7 +176,7 @@ mod tests {
keys.remove(&KeyCode::ShiftLeft),
"remove lets go for everyone"
);
assert!(!keys.contains(&KeyCode::ShiftLeft));
assert!(!keys.held(Action::Sneak));
assert!(keys.release(&KeyCode::KeyA, Source::Keyboard), "never held");
}
}
@@ -51,11 +51,6 @@ impl InteractionInput {
pub fn press(&mut self, button: Button) {
self.clicks[button as usize] += 1;
}
/// Whether a click waits for the next tick.
pub fn has_clicks(&self) -> bool {
self.clicks.iter().any(|&c| c > 0)
}
pub fn tick(&mut self, world: &mut impl InteractionWorld, attack_down: bool, use_down: bool) {
let [attack, use_item, pick] = std::mem::take(&mut self.clicks);
world.pick_hit_result();
+59 -14
View File
@@ -74,6 +74,17 @@ pub const MAPPINGS: &[(&str, &str, &str)] = &[
("key.debug.lightmapTexture", "key.keyboard.4", "debug"),
("key.debug.improvedTransparency", "key.keyboard.x", "debug"),
];
/// Horizon's own key mappings: bound in Controls like the vanilla ones, but kept in
/// `horizon-extras.txt` (`emoteKey`), never in `options.txt`, which stays exactly vanilla's
/// (`MAPPINGS` is what the options parity harness compares with the pinned client).
pub const HORIZON_MAPPINGS: &[(&str, &str, &str)] = &[
// The emote wheel (docs/cosmetics.md), on a key vanilla leaves free.
("key.emote", "key.keyboard.b", "multiplayer"),
];
/// Every mapping the game binds: vanilla's in `Options.keyMappings` order, then Horizon's.
pub fn all_mappings() -> impl Iterator<Item = &'static (&'static str, &'static str, &'static str)> {
MAPPINGS.iter().chain(HORIZON_MAPPINGS)
}
/// Mappings Options builds as `ToggleKeyMapping` (hold or toggle by option).
pub fn is_toggle(name: &str) -> bool {
matches!(name, "key.sneak" | "key.sprint" | "key.use" | "key.attack")
@@ -312,8 +323,7 @@ pub struct KeyBinds {
impl Default for KeyBinds {
fn default() -> Self {
Self {
keys: MAPPINGS
.iter()
keys: all_mappings()
.map(|(n, d, _)| (*n, (*d).to_owned()))
.collect(),
}
@@ -339,8 +349,7 @@ impl KeyBinds {
.map_or("key.keyboard.unknown", String::as_str)
}
pub fn default_of(name: &str) -> &'static str {
MAPPINGS
.iter()
all_mappings()
.find(|m| m.0 == name)
.map_or("key.keyboard.unknown", |m| m.1)
}
@@ -348,16 +357,24 @@ impl KeyBinds {
self.get(name) == Self::default_of(name)
}
pub fn set(&mut self, name: &str, key: &str) {
if let Some((n, _, _)) = MAPPINGS.iter().find(|m| m.0 == name) {
if let Some((n, _, _)) = all_mappings().find(|m| m.0 == name) {
self.keys.insert(n, key.to_owned());
}
}
/// The bindings `options.txt` holds: vanilla's mappings only.
pub fn saved(&self) -> Vec<(&'static str, String)> {
MAPPINGS
.iter()
.map(|(n, _, _)| (*n, self.get(n).to_owned()))
.collect()
}
/// Horizon's own bindings, for `horizon-extras.txt`.
pub fn horizon_saved(&self) -> Vec<(&'static str, String)> {
HORIZON_MAPPINGS
.iter()
.map(|(n, _, _)| (*n, self.get(n).to_owned()))
.collect()
}
pub fn reset_all(&mut self) {
*self = Self::default();
}
@@ -367,8 +384,7 @@ impl KeyBinds {
if key == "key.keyboard.unknown" {
return Vec::new();
}
MAPPINGS
.iter()
all_mappings()
.filter(|(n, _, _)| {
*n != name && self.get(n) == key && (!self.is_default(n) || !self.is_default(name))
})
@@ -382,8 +398,7 @@ impl KeyBinds {
/// Every mapping a physical input triggers, in list order: like
/// `KeyMapping.set`/`click`, one key drives all the mappings bound to it.
pub fn mappings_for<'a>(&'a self, key: &'a str) -> impl Iterator<Item = &'static str> + 'a {
MAPPINGS
.iter()
all_mappings()
.map(|m| m.0)
.filter(move |n| self.get(n) == key)
}
@@ -393,8 +408,7 @@ impl KeyBinds {
/// key and the binding itself); their defaults (A, S, D, T, L, 1 to 4...)
/// would otherwise turn the key back into its old gameplay meaning.
fn translated_mappings<'a>(&'a self, key: &'a str) -> impl Iterator<Item = &'static str> + 'a {
MAPPINGS
.iter()
all_mappings()
.filter(|m| m.2 != "debug")
.map(|m| m.0)
.filter(move |n| self.get(n) == key)
@@ -402,9 +416,7 @@ impl KeyBinds {
/// Whether a mapping (other than a debug key) whose default is `key` was
/// bound elsewhere.
fn default_moved(&self, key: &str) -> bool {
MAPPINGS
.iter()
.any(|(n, d, c)| *d == key && *c != "debug" && !self.is_default(n))
all_mappings().any(|(n, d, c)| *d == key && *c != "debug" && !self.is_default(n))
}
/// Translate a physical key into the default key of the mapping it is
/// bound to, so game code written against vanilla defaults follows the
@@ -440,6 +452,18 @@ impl KeyBinds {
}
vec![Ok(key)]
}
/// `Minecraft.handleKeybinds`: key.pickItem and key.spectatorHotbar share the middle button by
/// default and are separate mappings, so a press of what `input` is bound to acts only as the
/// one this player can use: the spectator hotbar for a spectator, pick block otherwise. (Pick
/// block does nothing a spectator would see.)
pub fn middle_acts(&self, input: &str, spectator: bool) -> bool {
let mapping = if spectator {
"key.spectatorHotbar"
} else {
"key.pickItem"
};
self.get(mapping) == input
}
/// Translate a mouse button the same way (keys bound to mouse buttons).
pub fn translate_mouse(&self, button: MouseButton) -> Option<Result<KeyCode, MouseButton>> {
self.translate_mouse_all(button).into_iter().next()
@@ -503,6 +527,27 @@ impl KeyBinds {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mappings_sharing_the_middle_button_act_as_themselves() {
let middle = "key.mouse.middle";
let z = "key.keyboard.z";
let mut keys = KeyBinds::default();
assert!(keys.middle_acts(middle, false) && keys.middle_acts(middle, true));
// The spectator hotbar on Z: the middle button only picks, and Z never picks.
keys.set("key.spectatorHotbar", z);
assert!(keys.middle_acts(middle, false));
assert!(!keys.middle_acts(middle, true));
assert!(keys.middle_acts(z, true));
assert!(!keys.middle_acts(z, false));
// Pick block on Z instead: the other way round.
keys.reset_all();
keys.set("key.pickItem", z);
assert!(keys.middle_acts(z, false));
assert!(!keys.middle_acts(z, true));
assert!(keys.middle_acts(middle, true));
assert!(!keys.middle_acts(middle, false));
}
#[test]
fn container_mouse_hotkeys_follow_slot_bindings_and_pick_item_priority() {
for button in [MouseButton::Back, MouseButton::Forward, MouseButton::Middle] {
+1
View File
@@ -3,6 +3,7 @@
//! options. horizon-client re-exports each module under its old path
//! (`horizon_client::keybinds`, ...).
#![forbid(unsafe_code)]
pub mod actions;
pub mod clipboard;
pub mod gamepad;
pub mod held;
+52 -17
View File
@@ -393,19 +393,38 @@ impl FullscreenState {
let reapply = self.set_exclusive(exclusive);
self.set_preferred_mode(preferred);
let mode_change = !in_video_screen && self.take_mode_change();
match request {
Some(false) => window.set_fullscreen(None),
Some(true) => window.set_fullscreen(Some(fullscreen_request(
if wanted && window.is_maximized() {
window.set_maximized(false);
window.set_fullscreen(Some(fullscreen_request(
window,
exclusive,
preferred.as_ref(),
))),
None if reapply || mode_change => window.set_fullscreen(Some(fullscreen_request(
window,
exclusive,
preferred.as_ref(),
))),
None => {}
)));
} else {
match request {
Some(false) => window.set_fullscreen(None),
Some(true) => {
if window.is_maximized() {
window.set_maximized(false);
}
window.set_fullscreen(Some(fullscreen_request(
window,
exclusive,
preferred.as_ref(),
)));
}
None if reapply || mode_change => {
if window.is_maximized() {
window.set_maximized(false);
}
window.set_fullscreen(Some(fullscreen_request(
window,
exclusive,
preferred.as_ref(),
)));
}
None => {}
}
}
}
@@ -592,13 +611,13 @@ pub fn fullscreen_request(
preferred: Option<&VideoMode>,
) -> winit::window::Fullscreen {
use winit::window::Fullscreen;
if !exclusive {
return Fullscreen::Borderless(None);
}
let Some(monitor) = window
let monitor = window
.current_monitor()
.or_else(|| window.primary_monitor())
else {
.or_else(|| window.primary_monitor());
if !exclusive {
return Fullscreen::Borderless(monitor);
}
let Some(monitor) = monitor else {
return Fullscreen::Borderless(None);
};
let wanted = preferred.and_then(|p| {
@@ -608,7 +627,7 @@ pub fn fullscreen_request(
});
match wanted.or_else(|| monitor_current_mode(&monitor)) {
Some(mode) => Fullscreen::Exclusive(mode),
None => Fullscreen::Borderless(None),
None => Fullscreen::Borderless(Some(monitor)),
}
}
@@ -628,6 +647,22 @@ pub fn icon() -> Option<winit::window::Icon> {
winit::window::Icon::from_rgba(bytes, info.width, info.height).ok()
}
/// Apply Horizon's icon to every window. Windows has a separate taskbar icon slot from the small
/// window icon; requiring the decoded icon there prevents a generic taskbar icon from appearing.
pub fn with_horizon_icon(
attributes: winit::window::WindowAttributes,
) -> winit::window::WindowAttributes {
let icon = icon();
#[cfg(target_os = "windows")]
let icon = Some(icon.expect("Horizon's icon must be available on Windows"));
let attributes = attributes.with_window_icon(icon.clone());
#[cfg(target_os = "windows")]
let attributes =
winit::platform::windows::WindowAttributesExtWindows::with_taskbar_icon(attributes, icon);
attributes
}
#[cfg(test)]
mod tests {
use super::*;
+2
View File
@@ -18,6 +18,8 @@ horizon-client-core = { path = "../horizon-client-core" }
horizon-client-input = { path = "../horizon-client-input" }
horizon-client-render = { path = "../horizon-client-render" }
horizon-command = { path = "../horizon-command" }
# Emotes pose the player model (`Mob::emote`, `mobs::anim::setup`).
horizon-cosmetics = { path = "../horizon-cosmetics" }
horizon-items = { path = "../horizon-items" }
horizon-core = { path = "../horizon-core" }
# `horizon-ctl mcp`: the Model Context Protocol, shared with the launcher and the server.
+3 -12
View File
@@ -6,13 +6,7 @@ use horizon_world::{
worldgen::{tags::TagDefinitions, terrain::TerrainBlockStates},
};
use serde_json::Value;
use std::{
collections::BTreeSet,
fs::File,
io::{self, BufReader},
path::Path,
sync::Arc,
};
use std::{collections::BTreeSet, io, path::Path, sync::Arc};
pub const MATERIALS: [&str; 6] = ["bedrock", "dirt", "stone", "sand", "oak_planks", "bricks"];
#[derive(Clone, Copy)]
@@ -190,15 +184,12 @@ fn invalid(message: &str) -> io::Error {
}
impl Assets {
pub fn load(root: &Path) -> Result<Self> {
let manifest: Value = serde_json::from_reader(BufReader::new(File::open(
root.join("horizon-resources.json"),
)?))?;
let manifest: Value = horizon_world::json_file::read(&root.join("horizon-resources.json"))?;
if manifest["data_version"].as_i64() != Some(i64::from(DATA_VERSION)) {
return Err(invalid("resources do not match Horizon's pinned data version").into());
}
let (registry, flags) = horizon_world::level::data::load_registry_with_flags(root)?;
let bundle: Value =
serde_json::from_reader(BufReader::new(File::open(root.join("block-tags.json"))?))?;
let bundle: Value = horizon_world::json_file::read(&root.join("block-tags.json"))?;
let mut definitions = TagDefinitions::default();
for (name, text) in bundle
.as_object()
+200 -54
View File
@@ -2,7 +2,9 @@
//! small mixer standing in for OpenAL: one channel per source with its gain,
//! pitch, linear distance attenuation (`AL_LINEAR_DISTANCE`, reference 0,
//! rolloff 1), looping, relative flag and source state, Ogg/Vorbis decoding
//! (streams on a worker thread) and OpenAL Soft's stereo panning.
//! (streams on a worker thread) and OpenAL Soft's mixing: mono sources panned
//! and attenuated, multi-channel buffers (stereo music and records) played
//! direct as OpenAL does.
use crate::Result;
use crate::sound_engine::{
Attenuation, Backend, Bound, ChannelId, InstanceId, PlayResult, SoundEngine, SoundInstance,
@@ -29,7 +31,7 @@ enum State {
}
/// An OpenAL source.
struct Channel {
samples: Option<(Arc<Vec<f32>>, u32)>,
samples: Option<(Arc<Vec<f32>>, u32, u16)>,
position: f64,
state: State,
gain: f32,
@@ -135,8 +137,8 @@ impl Drop for MixerBackend {
static SHARED_CHANNELS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
/// Two players' copies of one sound: started within this of each other.
const SAME_SOUND: std::time::Duration = std::time::Duration::from_millis(150);
/// Decoded samples and their rate (`None`: the file did not load).
type Samples = Option<(Arc<Vec<f32>>, u32)>;
/// Decoded interleaved samples, their rate and channel count (`None`: the file did not load).
type Samples = Option<(Arc<Vec<f32>>, u32, u16)>;
/// Decoded static sounds (`SoundBufferLibrary`'s buffers), dropped least
/// recently used first past `CACHE_BYTES`: kept forever, every sound heard in
/// a session stayed (up to 1.4 GiB of samples, villages playing hundreds).
@@ -177,7 +179,7 @@ impl SoundCache {
fn size(samples: &Samples) -> usize {
samples
.as_ref()
.map_or(0, |(s, _)| s.len() * std::mem::size_of::<f32>())
.map_or(0, |(s, _, _)| s.len() * std::mem::size_of::<f32>())
}
}
/// A static sound to decode for a channel waiting on it.
@@ -260,7 +262,7 @@ pub struct Audio {
#[cfg(feature = "audio")]
device_ticks: u32,
}
fn decode(path: &Path) -> Option<(Vec<f32>, u32)> {
fn decode(path: &Path) -> Option<(Vec<f32>, u32, u16)> {
use symphonia::core::{
audio::SampleBuffer, codecs::DecoderOptions, formats::FormatOptions, io::MediaSourceStream,
meta::MetadataOptions, probe::Hint,
@@ -284,7 +286,8 @@ fn decode(path: &Path) -> Option<(Vec<f32>, u32)> {
let mut decoder = symphonia::default::get_codecs()
.make(&track.codec_params, &DecoderOptions::default())
.ok()?;
let mut mono = Vec::new();
let mut samples = Vec::new();
let mut channels = 0u16;
while let Ok(packet) = format.next_packet() {
if packet.track_id() != id {
continue;
@@ -293,14 +296,34 @@ fn decode(path: &Path) -> Option<(Vec<f32>, u32)> {
continue;
};
let spec = *buffer.spec();
let channels = spec.channels.count().max(1);
let mut samples = SampleBuffer::<f32>::new(buffer.capacity() as u64, spec);
samples.copy_interleaved_ref(buffer);
for frame in samples.samples().chunks(channels) {
mono.push(frame.iter().sum::<f32>() / channels as f32);
let frame_channels = spec.channels.count().max(1);
let mut decoded = SampleBuffer::<f32>::new(buffer.capacity() as u64, spec);
decoded.copy_interleaved_ref(buffer);
if channels == 0 {
channels = frame_channels.min(2) as u16;
}
if frame_channels <= 2 {
samples.extend_from_slice(decoded.samples());
} else {
// OpenAL plays multi-channel buffers direct; a stereo output hears the odd/even split.
for frame in decoded.samples().chunks(frame_channels) {
let (mut left, mut right) = (0.0, 0.0);
let (mut lefts, mut rights) = (0.0f32, 0.0f32);
for (i, sample) in frame.iter().enumerate() {
if i % 2 == 0 {
left += sample;
lefts += 1.0;
} else {
right += sample;
rights += 1.0;
}
}
samples.push(left / lefts.max(1.0));
samples.push(right / rights.max(1.0));
}
}
}
Some((mono, rate))
(channels != 0).then_some((samples, rate, channels))
}
/// A sound file without its variant number (`mob/creeper/say2.ogg` is `mob/creeper/say`): each
/// player's game picks its own variant of the same event.
@@ -427,7 +450,7 @@ impl Backend for MixerBackend {
// A streamed sound loops by restarting its stream.
let mixer = self.mixer.clone();
std::thread::spawn(move || {
let samples = decode(&file).map(|(s, r)| (Arc::new(s), r));
let samples = decode(&file).map(|(s, r, c)| (Arc::new(s), r, c));
attach(&mixer, channel, samples, looping);
});
return;
@@ -448,7 +471,7 @@ impl Backend for MixerBackend {
// Queued twice before the first decode finished.
let cached = cache.lock().ok().and_then(|mut c| c.get(&job.key));
let samples = cached.unwrap_or_else(|| {
let samples = decode(&job.file).map(|(s, r)| (Arc::new(s), r));
let samples = decode(&job.file).map(|(s, r, c)| (Arc::new(s), r, c));
if let Ok(mut c) = cache.lock() {
c.insert(job.key, samples.clone());
}
@@ -584,9 +607,13 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
let mut voices = std::mem::take(&mut mixer.voices);
voices.clear();
for (id, c) in &mixer.channels {
let Some((_, source_rate)) = &c.samples else {
let Some((_, source_rate, source_channels)) = &c.samples else {
continue;
};
// OpenAL plays a multi-channel buffer direct: no panning and no distance attenuation
// (`CalcVoiceParams`' non-spatialized path, vanilla's HRTF-off stereo context). Vanilla's
// music and records are stereo. A mono buffer is the positional one.
let direct = *source_channels > 1;
if c.state != State::Playing {
continue;
}
@@ -611,24 +638,33 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
let d = if c.relative { at } else { away(listener) };
let distance = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt() as f32;
let gain = if camera.is_some() {
c.gain.clamp(0.0, 1.0) * distance_gain(c.linear, distance)
c.gain.clamp(0.0, 1.0)
* if direct {
1.0
} else {
distance_gain(c.linear, distance)
}
} else {
0.0
};
// Relative sources are already in OpenAL's listener space (-Z ahead).
let [mut left, mut right] = if c.relative {
stereo_gains(d[0] as f32, d[1] as f32, -d[2] as f32)
let [left, right] = if direct {
[gain, gain]
} else {
stereo_gains(dot(d, right), dot(d, up), dot(d, forward))
// Relative sources are already in OpenAL's listener space (-Z ahead).
let [mut left, mut right] = if c.relative {
stereo_gains(d[0] as f32, d[1] as f32, -d[2] as f32)
} else {
stereo_gains(dot(d, right), dot(d, up), dot(d, forward))
};
if shared {
// Several players on the speakers: half the stereo width, as left and right are
// nobody's in particular.
let middle = (left + right) / 2.0;
left = middle + (left - middle) / 2.0;
right = middle + (right - middle) / 2.0;
}
[left * gain, right * gain]
};
if shared {
// Several players on the speakers: half the stereo width, as left and right are
// nobody's in particular.
let middle = (left + right) / 2.0;
left = middle + (left - middle) / 2.0;
right = middle + (right - middle) / 2.0;
}
let [left, right] = [left, right].map(|pan| gain * pan);
let step = f64::from(*source_rate) / f64::from(rate) * f64::from(c.pitch.max(0.0));
voices.push((*id, [left, right, gain], step));
}
@@ -636,13 +672,15 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
let Some(c) = mixer.channels.get_mut(&id) else {
continue;
};
let Some((samples, _)) = c.samples.clone() else {
let Some((samples, _, source_channels)) = c.samples.clone() else {
continue;
};
let source_channels = source_channels as usize;
let frames = samples.len() / source_channels;
for frame in data.chunks_mut(channels) {
let mut i = c.position as usize;
if i + 1 >= samples.len() {
if c.looping && samples.len() > 1 {
if i + 1 >= frames {
if c.looping && frames > 1 {
c.position = 0.0;
i = 0;
} else {
@@ -651,13 +689,24 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
}
}
let t = (c.position - i as f64) as f32;
let s = samples[i] * (1.0 - t) + samples[i + 1] * t;
if channels >= 2 {
frame[0] += s * left;
frame[1] += s * right;
if source_channels > 1 {
let l = samples[i * 2] * (1.0 - t) + samples[(i + 1) * 2] * t;
let r = samples[i * 2 + 1] * (1.0 - t) + samples[(i + 1) * 2 + 1] * t;
if channels >= 2 {
frame[0] += l * left;
frame[1] += r * right;
} else {
frame[0] += (l + r) * 0.5 * gain;
}
} else {
// A mono output has no panning: the source's own gain.
frame[0] += s * gain;
let s = samples[i] * (1.0 - t) + samples[i + 1] * t;
if channels >= 2 {
frame[0] += s * left;
frame[1] += s * right;
} else {
// A mono output has no panning: the source's own gain.
frame[0] += s * gain;
}
}
c.position += step;
}
@@ -807,27 +856,50 @@ fn cards_changed(seen: Option<&str>, cards: Option<&str>) -> bool {
}
}
/// The buffer size to request for `open_device`. On Linux the ALSA backend's
/// `BufferSize::Default` means a ~100 ms buffer, so sound effects come out
/// about 100 ms late: request a short fixed buffer (1024 frames) by default.
/// An environment override `HORIZON_AUDIO_BUFFER` can specify `default` or a
/// custom frame count (e.g. 2048) when the default short buffer underruns
/// (e.g. PipeWire min-quantum >= 1024).
#[cfg(feature = "audio")]
fn target_buffer_size(
range: (u32, u32),
var: impl Fn(&str) -> Option<std::ffi::OsString>,
) -> Option<cpal::BufferSize> {
if let Some(val) = var("HORIZON_AUDIO_BUFFER").and_then(|v| v.into_string().ok()) {
if val == "default" {
return Some(cpal::BufferSize::Default);
}
if let Ok(frames) = val.parse::<u32>() {
return Some(cpal::BufferSize::Fixed(frames.clamp(range.0, range.1)));
}
}
#[cfg(target_os = "linux")]
{
Some(cpal::BufferSize::Fixed(1024u32.clamp(range.0, range.1)))
}
#[cfg(not(target_os = "linux"))]
{
None
}
}
/// The cpal `StreamConfig` to open an output stream with. On Linux the ALSA
/// backend's `BufferSize::Default` means a ~100 ms buffer, so sound effects
/// come out about 100 ms late: request a short fixed buffer there. Other
/// platforms keep cpal's default behaviour.
#[cfg(feature = "audio")]
fn audio_stream_config(config: &cpal::SupportedStreamConfig) -> cpal::StreamConfig {
#[cfg(target_os = "linux")]
{
let range = match config.buffer_size() {
cpal::SupportedBufferSize::Range { min, max } => (*min, *max),
cpal::SupportedBufferSize::Unknown => (256, 4096),
};
let target = 1024u32.clamp(range.0, range.1);
let mut sc = config.config();
sc.buffer_size = cpal::BufferSize::Fixed(target);
sc
}
#[cfg(not(target_os = "linux"))]
{
config.config()
let mut sc = config.config();
let range = match config.buffer_size() {
cpal::SupportedBufferSize::Range { min, max } => (*min, *max),
cpal::SupportedBufferSize::Unknown => (256, 4096),
};
if let Some(bs) = target_buffer_size(range, |name| std::env::var_os(name)) {
sc.buffer_size = bs;
}
sc
}
#[cfg(feature = "audio")]
@@ -1687,7 +1759,7 @@ mod tests {
fn channel(samples: Vec<f32>, linear: Option<f32>, at: [f32; 3], looping: bool) -> Channel {
Channel {
samples: Some((Arc::new(samples), 48_000)),
samples: Some((Arc::new(samples), 48_000, 1)),
position: 0.0,
state: State::Playing,
gain: 1.0,
@@ -1702,11 +1774,23 @@ mod tests {
}
}
/// A channel whose buffer is interleaved stereo.
fn stereo_channel(
samples: Vec<f32>,
linear: Option<f32>,
at: [f32; 3],
looping: bool,
) -> Channel {
let mut c = channel(Vec::new(), linear, at, looping);
c.samples = Some((Arc::new(samples), 48_000, 2));
c
}
#[test]
fn decoded_sounds_stay_within_the_budget_least_recently_used_out() {
let mut cache = SoundCache::default();
let quarter = CACHE_BYTES / 4 / std::mem::size_of::<f32>();
let sound = || Some((Arc::new(vec![0.0f32; quarter]), 44100));
let sound = || Some((Arc::new(vec![0.0f32; quarter]), 44100, 1));
for key in ["a", "b", "c", "d"] {
cache.insert(key.into(), sound());
}
@@ -1744,6 +1828,42 @@ mod tests {
);
}
#[test]
#[cfg(feature = "audio")]
fn buffer_size_override_and_defaults() {
let env = |pairs: &'static [(&'static str, &'static str)]| {
move |name: &str| {
pairs
.iter()
.find(|(k, _)| *k == name)
.map(|(_, v)| std::ffi::OsString::from(*v))
}
};
let range = (256, 4096);
assert_eq!(
target_buffer_size(range, env(&[("HORIZON_AUDIO_BUFFER", "default")])),
Some(cpal::BufferSize::Default)
);
assert_eq!(
target_buffer_size(range, env(&[("HORIZON_AUDIO_BUFFER", "2048")])),
Some(cpal::BufferSize::Fixed(2048))
);
assert_eq!(
target_buffer_size(range, env(&[("HORIZON_AUDIO_BUFFER", "100")])),
Some(cpal::BufferSize::Fixed(256))
);
assert_eq!(
target_buffer_size(range, env(&[("HORIZON_AUDIO_BUFFER", "10000")])),
Some(cpal::BufferSize::Fixed(4096))
);
#[cfg(target_os = "linux")]
assert_eq!(
target_buffer_size(range, env(&[])),
Some(cpal::BufferSize::Fixed(1024))
);
#[cfg(not(target_os = "linux"))]
assert_eq!(target_buffer_size(range, env(&[])), None);
}
#[test]
fn linear_attenuation_reaches_zero_at_the_max_distance() {
assert_eq!(distance_gain(None, 100.0), 1.0);
assert_eq!(distance_gain(Some(16.0), 0.0), 1.0);
@@ -1833,6 +1953,32 @@ mod tests {
}
}
#[test]
fn stereo_sounds_play_direct_without_panning_or_distance() {
// OpenAL Soft plays a multi-channel buffer direct (its non-spatialized path), so vanilla's
// stereo music and records keep their own left/right mix at full volume whichever way the
// camera faces and however far it is. Only mono buffers are panned and attenuated.
let wave = [0.1, -0.2, 0.3, -0.4, 0.5, -0.6]; // three stereo frames
let expected = [wave[0], wave[1], wave[2], wave[3]];
for (yaw, at, linear, listener) in [
(0.0, [100.0, 0.0, 0.0], Some(16.0), [0.0; 3]),
(180.0, [0.0, 0.0, -100.0], Some(16.0), [0.0; 3]),
(90.0, [2.0, 1.0, -3.0], Some(16.0), [5.0, 60.0, 5.0]),
(0.0, [0.0, 0.0, 0.0], None, [0.0; 3]),
] {
let mut m = Mixer {
listener,
listener_yaw: yaw,
..Mixer::default()
};
m.channels
.insert(1, stereo_channel(wave.to_vec(), linear, at, false));
let mut data = [0.0; 4];
mix(&mut m, &mut data, 2, 48_000);
assert_eq!(data, expected, "yaw {yaw}, source {at:?}");
}
}
#[test]
fn panning_follows_the_camera_rotation() {
let gains = |yaw: f32, pitch: f32, at: [f32; 3]| {
+1 -1
View File
@@ -288,7 +288,7 @@ impl Biomes {
.collect();
files.sort_by_cached_key(|f| format!("{}.json", f.name));
for file in files {
let v: Value = serde_json::from_slice(file.bytes()?)?;
let v: Value = horizon_world::json_file::parse(&file.path, file.bytes()?)?;
list.push(BiomeInfo::from_json(
format!("minecraft:{}", file.name),
&v,
@@ -3407,7 +3407,9 @@ impl BeamPart {
(at(b, self.end), [s.u0, v1]),
] {
out.push(Vertex {
normal: [0; 4],
// The beacon beam render types keep the default back-face culling: seen
// from inside a beam, its walls must not show.
normal: CULL_BACK,
pos: p.to_array(),
uv,
layer: 6,
@@ -4277,6 +4279,23 @@ mod tests {
/// submits the unit cube's two Y faces. Both are wound outward, so of each
/// face only the side looking at the camera draws: the portal shows one
/// surface from above and one from below, never both at once.
/// `RenderPipelines.BEACON_BEAM_OPAQUE` / `_TRANSLUCENT` keep the default
/// back-face culling, so a camera inside a beam sees no wall of it.
#[test]
fn beacon_beam_faces_cull_their_backs() {
let rect = Rect {
u0: 0.0,
v0: 0.0,
u1: 1.0,
v1: 1.0,
w: 16,
h: 16,
};
let mut out = Vec::new();
beacon_beam(Vec3::ZERO, &[(0xFFFFFF, 4)], 0.0, 1.0, rect, &mut out);
assert_eq!(out.len(), 32, "solid and glow, four quads each");
assert!(out.iter().all(|v| v.normal == CULL_BACK));
}
#[test]
fn the_end_portal_cube_is_one_sided() {
let mut out = Vec::new();
+35
View File
@@ -225,6 +225,8 @@ pub const BIOME_STRATEGY: Strategy = Strategy::biomes(8);
/// (what vanilla servers and clients send), more when content mods add states
/// past 65,536 (docs/modpacks.md). Set once, before the first chunk.
static BLOCK_BITS: std::sync::atomic::AtomicU8 = std::sync::atomic::AtomicU8::new(16);
/// States in the registry, set with [`BLOCK_BITS`].
static BLOCK_STATES: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(u32::MAX);
/// Block states are `PalettedContainer`s over the registry: vanilla's 16-bit
/// global palette, wider with enough content mods.
@@ -232,6 +234,35 @@ pub fn block_strategy() -> Strategy {
Strategy::blocks(BLOCK_BITS.load(std::sync::atomic::Ordering::Relaxed))
}
/// Whether the registry has this state. Every id is known until the registry is loaded.
pub fn known_block_state(id: u32) -> bool {
id < BLOCK_STATES.load(std::sync::atomic::Ordering::Relaxed)
}
/// A server's block container in our palette width. Ids past our registry (a
/// server or host with mods we lack) read as air: every per-state table is
/// indexed by them, and a palette's unused entries are scanned too.
pub fn known_blocks_only(blocks: PalettedContainer) -> PalettedContainer {
let ours = block_strategy();
let unknown = |id: u32| !known_block_state(id) || u64::from(id) >> ours.global_bits != 0;
let any_unknown = if blocks.is_global() {
blocks.has(unknown)
} else {
blocks.maybe_has(unknown)
};
if !any_unknown && blocks.strategy().global_bits == ours.global_bits {
return blocks;
}
let mut values = [0u32; 4096];
blocks.unpack(&mut values);
for value in &mut values {
if unknown(*value) {
*value = 0;
}
}
PalettedContainer::from_values(ours, &values)
}
/// Frees `columns` (`count` of them) on a short-lived thread of its own
/// when there are many: a teleport or a dimension change drops a few
/// thousand columns at once, and freeing their sections took the frame
@@ -250,6 +281,10 @@ pub fn free_columns<T: Send + 'static>(columns: T, count: usize) {
pub fn set_block_states(states: usize) {
let bits = horizon_core::paletted::ceil_log2(states).max(16);
BLOCK_BITS.store(bits, std::sync::atomic::Ordering::Relaxed);
BLOCK_STATES.store(
u32::try_from(states).unwrap_or(u32::MAX),
std::sync::atomic::Ordering::Relaxed,
);
forget_shared();
}
@@ -133,6 +133,13 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
),
],
),
(
"Play",
vec![option(
Opt::PadTriggerThreshold,
"How far a trigger is pulled before it counts as pressed.",
)],
),
(
"Menus",
vec![
@@ -565,7 +572,7 @@ impl ControllerUi {
&cols,
&lines,
self.scroll.amount,
Page::name,
|page| lang::get(page.name()),
|batch, setting: Setting, y| {
let item = setting.item;
let [rx, ry, rw, rh] = cols.row(y);
@@ -1266,6 +1273,7 @@ mod tests {
Opt::PadInvertY,
Opt::PadMoveDeadzone,
Opt::PadLookDeadzone,
Opt::PadTriggerThreshold,
Opt::PadGlyphs,
Opt::PadKeyboard,
Opt::PadPointerSpeed,
@@ -896,6 +896,13 @@ pub fn worldgen_overrides(
let mut warnings = Vec::new();
for pack in packs {
for file in pack.list_kinds(&overrides::PACK_KINDS) {
if let Some(id) = overrides::template_id(&file) {
match pack.read(&file) {
Some(nbt) => out.set_template(&id, nbt),
None => warnings.push(format!("{}: {file}: unreadable", pack.id)),
}
continue;
}
let read = || String::from_utf8(pack.read(&file)?).ok();
match out.add_file(&pack.id, &file, read) {
PackFile::Read | PackFile::Other => {}
@@ -956,6 +963,7 @@ mod tests {
for pack in ["a", "b", "off"] {
write(pack, "pack.mcmeta", "{}");
write(pack, "data/minecraft/worldgen/noise/cave.json", pack);
write(pack, "data/p/structure/room/a.nbt", pack);
write(
pack,
"data/minecraft/tags/worldgen/biome/is_hill.json",
@@ -992,6 +1000,8 @@ mod tests {
o.entries("noises").collect::<Vec<_>>(),
[("minecraft:cave", "a")]
);
assert_eq!(o.templates().len(), 1);
assert_eq!(&*o.templates()["p:room/a"], b"a");
assert_eq!(
o.entries("biomes").collect::<Vec<_>>(),
[("minecraft:plains", "{}")]
@@ -98,14 +98,42 @@ pub struct Source {
/// The [`MAX`] sources nearest `camera` within reach, as the shaders read them: camera-relative
/// position and level. Overwrites `out`.
pub fn select(sources: &[Source], camera: DVec3, out: &mut Vec<[f32; 4]>) {
select_iter(sources.iter().copied(), camera, out);
}
/// Streaming selection with the same stable distance order as [`select`]. Only the best
/// [`MAX`] sources are kept, and each source's distance is evaluated once.
pub fn select_iter(
sources: impl IntoIterator<Item = Source>,
camera: DVec3,
out: &mut Vec<[f32; 4]>,
) {
out.clear();
let key = |s: &Source| s.at.distance_squared(camera);
let mut near: Vec<&Source> = sources
.iter()
.filter(|s| s.level > 0 && key(s) <= REACH * REACH)
.collect();
near.sort_by(|a, b| key(a).total_cmp(&key(b)));
out.extend(near.into_iter().take(MAX).map(|s| {
let mut near = [(
0.0_f64,
Source {
at: DVec3::ZERO,
level: 0,
},
); MAX];
let mut len = 0;
for source in sources {
let distance = source.at.distance_squared(camera);
// The original <= test also rejects NaN and infinite distances.
if source.level == 0 || !(0.0..=REACH * REACH).contains(&distance) {
continue;
}
let at = near[..len].partition_point(|(d, _)| d.total_cmp(&distance).is_le());
if at == MAX {
continue;
}
// Insert after equal distances: the first MAX sources in input order win ties.
let end = len.min(MAX - 1);
near.copy_within(at..end, at + 1);
near[at] = (distance, source);
len = (len + 1).min(MAX);
}
out.extend(near[..len].iter().map(|(_, s)| {
let p = (s.at - camera).as_vec3();
[p.x, p.y, p.z, f32::from(s.level.min(15))]
}));
@@ -179,6 +207,104 @@ mod tests {
assert!(out.is_empty());
}
#[test]
fn streaming_selection_matches_stable_full_sort() {
let camera = DVec3::new(30_000_000.0, -64.0, -30_000_000.0);
let mut seed = 0x8469_d28b_314a_723fu64;
let mut sources = Vec::new();
for i in 0..2048 {
let offset = std::array::from_fn::<_, 3, _>(|_| {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
(seed % 241) as f64 - 120.0
});
sources.push(Source {
at: camera + DVec3::from(offset),
level: (i % 22) as u8,
});
}
sources.extend([
Source {
at: camera + DVec3::X * 96.0,
level: 14,
},
Source {
at: camera - DVec3::X * 96.0,
level: 15,
},
Source {
at: camera + DVec3::X * 96.0001,
level: 15,
},
Source {
at: DVec3::splat(f64::NAN),
level: 15,
},
Source {
at: DVec3::splat(f64::INFINITY),
level: 15,
},
]);
let reference = |input: &[Source], camera: DVec3| {
let mut selected: Vec<_> = input
.iter()
.filter(|s| s.level > 0 && s.at.distance_squared(camera) <= 96.0 * 96.0)
.collect();
selected.sort_by(|a, b| {
a.at.distance_squared(camera)
.total_cmp(&b.at.distance_squared(camera))
});
selected
.into_iter()
.take(16)
.map(|s| {
let p = (s.at - camera).as_vec3();
[p.x, p.y, p.z, f32::from(s.level.min(15))]
})
.collect::<Vec<_>>()
};
let mut out = vec![[999.0; 4]; 20];
for input in [sources.clone(), sources.into_iter().rev().collect()] {
for n in [0, 1, 15, 16, 17, 40, input.len()] {
select_iter(input[..n].iter().copied(), camera, &mut out);
assert_eq!(out, reference(&input[..n], camera));
}
select_iter(input.iter().copied(), DVec3::splat(f64::NAN), &mut out);
assert!(out.is_empty());
}
}
#[test]
fn equal_distances_keep_first_sources_and_reach_is_inclusive() {
// Every source ties: neither a different position nor a different light level
// may win the sixteenth slot just because it was inserted later.
let sources: Vec<_> = (0..40)
.map(|i| Source {
at: if i % 2 == 0 {
DVec3::X * 96.0
} else {
-DVec3::X * 96.0
},
level: (i % 15 + 1) as u8,
})
.collect();
let mut out = Vec::new();
select_iter(sources, DVec3::ZERO, &mut out);
assert_eq!(out.len(), 16);
for (i, light) in out.iter().enumerate() {
assert_eq!(
*light,
[
if i % 2 == 0 { 96.0 } else { -96.0 },
0.0,
0.0,
(i % 15 + 1) as f32
]
);
}
}
#[test]
fn light_falls_off_a_level_per_block() {
let torch = [[0.0, 0.0, 0.0, 14.0]];
@@ -3,7 +3,7 @@
//! celestial angles and star brightness for the overworld day cycle.
use crate::Result;
use serde_json::Value;
use std::{collections::HashMap, fs, path::Path};
use std::{collections::HashMap, path::Path};
#[derive(Clone, Copy, Debug)]
enum Easing {
@@ -151,13 +151,13 @@ impl Environment {
/// A dimension's base attributes, optionally driven by the day timeline.
pub fn load_dimension(root: &Path, dimension: &str, day_cycle: bool) -> Result<Self> {
let timeline: Value = if day_cycle {
serde_json::from_slice(&fs::read(root.join("data/minecraft/timeline/day.json"))?)?
horizon_world::json_file::read(&root.join("data/minecraft/timeline/day.json"))?
} else {
serde_json::json!({"period_ticks": 24000, "tracks": {}})
};
let dimension: Value = serde_json::from_slice(&fs::read(
root.join(format!("data/minecraft/dimension_type/{dimension}.json")),
)?)?;
let dimension: Value = horizon_world::json_file::read(
&root.join(format!("data/minecraft/dimension_type/{dimension}.json")),
)?;
let mut tracks = HashMap::new();
for (name, track) in timeline["tracks"].as_object().ok_or("invalid timeline")? {
let easing = match &track["ease"] {
+66 -28
View File
@@ -10,6 +10,9 @@ pub const FILE: &str = "horizon-extras.txt";
pub const SORT_KEY: &str = "horizon.sort";
/// The 3D skin layers line, here and in the `options.txt` that held it first.
const SKIN_LAYERS_KEY: &str = "threeDSkinLayers";
/// The emote wheel's key, here and in the `key_key.emote` line #10092 wrote to `options.txt`.
const EMOTE_KEY: &str = "emoteKey";
const OLD_EMOTE_LINE: &str = "key_key.emote";
/// The minimap's sizes in GUI pixels, and its zooms in blocks per pixel.
pub const MINIMAP_SIZES: [(u8, &str); 3] = [(64, "Small"), (96, "Medium"), (128, "Large")];
@@ -126,6 +129,10 @@ pub struct Extras {
pub sort: bool,
/// A vanilla key name: sorts the part under the cursor.
pub sort_key: String,
/// The emote wheel's key (`key.emote`), as the game last saved it. The live binding is
/// `Settings::keys`, bound in Controls with the vanilla ones; it is only kept in this file
/// so that `options.txt` stays vanilla's (`Settings::load` hands it over, `save` takes it back).
pub emote_key: String,
/// A new chat line slides in from the left while it fades in (`Chat::layout`).
pub chat_animation: bool,
/// The indicator bar at the left of a chat line (on by default, as vanilla).
@@ -164,10 +171,10 @@ pub struct Extras {
/// 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 their auras and trails (off by default).
/// `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_auras: bool,
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.
@@ -220,6 +227,7 @@ impl Default for Extras {
mouse_tweaks_wheel: true,
sort: false,
sort_key: "key.keyboard.unknown".into(),
emote_key: crate::keybinds::KeyBinds::default_of("key.emote").into(),
chat_animation: false,
chat_indicator: true,
no_ai: false,
@@ -230,12 +238,12 @@ impl Default for Extras {
splitscreen_two: 0,
splitscreen_three: 0,
splitscreen_names: true,
wheel_cycles_settings: true,
wheel_cycles_settings: false,
music_off: Vec::new(),
music_page: false,
cosmetics_mine: true,
cosmetics_others: None,
cosmetics_auras: false,
cosmetics_hide_fx: false,
cosmetics_solo: false,
cosmetics_owner: false,
horizon_marks: true,
@@ -324,6 +332,7 @@ impl Extras {
"mouseTweaksWheel" => e.mouse_tweaks_wheel = flag(e.mouse_tweaks_wheel),
"sort" => e.sort = flag(e.sort),
"sortKey" if value.starts_with("key.") => e.sort_key = value.to_owned(),
EMOTE_KEY if value.starts_with("key.") => e.emote_key = value.to_owned(),
"chatAnimation" => e.chat_animation = flag(e.chat_animation),
"chatIndicator" => e.chat_indicator = flag(e.chat_indicator),
horizon_core::no_ai::KEY => e.no_ai = flag(e.no_ai),
@@ -334,7 +343,11 @@ impl Extras {
"splitscreenTwo" => e.splitscreen_two = value.parse().unwrap_or(0).min(2),
"splitscreenThree" => e.splitscreen_three = value.parse().unwrap_or(0).min(2),
"splitscreenNames" => e.splitscreen_names = flag(e.splitscreen_names),
"wheelCyclesSettings" => e.wheel_cycles_settings = flag(e.wheel_cycles_settings),
// A new name: `wheelCyclesSettings` was written as true by every
// install while it was the default, so it says nothing of a choice.
"wheelCyclesListSettings" => {
e.wheel_cycles_settings = flag(e.wheel_cycles_settings)
}
"musicOff" => {
e.music_off = value
.split(',')
@@ -349,7 +362,7 @@ impl Extras {
e.cosmetics_others =
horizon_cosmetics::prefs::Others::parse(value).or(e.cosmetics_others)
}
"cosmeticsAuras" => e.cosmetics_auras = flag(e.cosmetics_auras),
"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),
@@ -363,7 +376,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:{}\ndiscordPresence:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\nwheelCyclesSettings:{}\nmusicOff:{}\nmusicPage:{}\ncosmeticsMine:{}\n{}cosmeticsAuras:{}\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:{}\ndiscordPresence:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\nwheelCyclesListSettings:{}\nmusicOff:{}\nmusicPage:{}\ncosmeticsMine:{}\n{}cosmeticsHideFx:{}\ncosmeticsSolo:{}\n{}:{}\n{}:{}\n{}:{}\n",
bool_text(self.pack_browser),
bool_text(self.pregen),
bool_text(self.fullbright),
@@ -417,7 +430,7 @@ impl Extras {
// Written once the player chose.
self.cosmetics_others
.map_or(String::new(), |o| format!("cosmeticsOthers:{}\n", o.name())),
bool_text(self.cosmetics_auras),
bool_text(self.cosmetics_hide_fx),
bool_text(self.cosmetics_solo),
horizon_cosmetics::prefs::MARKS,
bool_text(self.horizon_marks),
@@ -426,32 +439,44 @@ impl Extras {
SKIN_LAYERS_KEY,
bool_text(self.three_d_skin_layers),
);
text.push_str(&format!("{EMOTE_KEY}:{}\n", self.emote_key));
self.pvp.serialize(&mut text);
text
}
/// Adopts the settings `options.txt` held before they moved here, while
/// this file (`own`) has no line of its own: 3D skin layers was an
/// `options.txt` line. Vanilla's `Options.save` drops lines it doesn't
/// this file (`own`) has no line of its own: 3D skin layers and the emote key were
/// `options.txt` lines. Vanilla's `Options.save` drops lines it doesn't
/// know, so it is read there once, the first start after the update.
pub fn read_old_options(&mut self, own: &str, options_txt: &str) {
fn line(text: &str) -> Option<&str> {
fn line<'a>(text: &'a str, name: &str) -> Option<&'a str> {
text.lines().find_map(|l| {
l.split_once(':')
.filter(|(key, _)| *key == SKIN_LAYERS_KEY)
.filter(|(key, _)| *key == name)
.map(|(_, value)| value)
})
}
if line(own).is_none()
&& let Some(value) = line(options_txt)
if line(own, SKIN_LAYERS_KEY).is_none()
&& let Some(value) = line(options_txt, SKIN_LAYERS_KEY)
{
self.three_d_skin_layers = value == "true";
}
// The emote key was a `key_key.emote` line of options.txt (#10092): a rebound key is kept.
if line(own, EMOTE_KEY).is_none()
&& let Some(value) = line(options_txt, OLD_EMOTE_LINE).map(str::trim)
&& value.starts_with("key.")
&& (value == "key.keyboard.unknown"
|| crate::keybinds::key_code(value).is_some()
|| crate::keybinds::mouse_button(value).is_some())
{
self.emote_key = value.to_owned();
}
}
/// Binds the extra key `name` (`SORT_KEY`); false for
/// another name (a vanilla mapping).
pub fn set_key(&mut self, name: &str, value: String) -> bool {
match name {
SORT_KEY => self.sort_key = value,
crate::pvp::HUD_EDITOR_KEY => self.pvp.editor_key = value,
_ => return false,
}
true
@@ -598,7 +623,7 @@ pub enum Item {
/// Shown only while cosmetics are enabled (`horizon_cosmetics::enabled`).
CosmeticsMine,
CosmeticsOthers,
CosmeticsAuras,
CosmeticsHideFx,
CosmeticsSolo,
HorizonMarks,
HorizonMe,
@@ -638,7 +663,7 @@ pub fn rows() -> Vec<Result<Item, &'static str>> {
Ok(CosmeticsMine),
Ok(CosmeticsSolo),
Ok(CosmeticsOthers),
Ok(CosmeticsAuras),
Ok(CosmeticsHideFx),
Ok(HorizonMarks),
Ok(HorizonMe),
]);
@@ -672,7 +697,7 @@ impl Item {
Item::Pregen => "horizon.extras.pregen",
Item::CosmeticsMine => "horizon.extras.cosmetics_mine",
Item::CosmeticsOthers => "horizon.extras.cosmetics_others",
Item::CosmeticsAuras => "horizon.extras.cosmetics_auras",
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",
@@ -700,12 +725,12 @@ impl Item {
Item::Splitscreen => "horizon.extras.splitscreen.tooltip",
Item::ChatAnimation => "horizon.extras.chat_animation.tooltip",
Item::ChatIndicator => "horizon.extras.chat_indicator.tooltip",
Item::WheelCyclesSettings => "horizon.extras.wheel_cycles_settings.tooltip",
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::CosmeticsAuras => "horizon.extras.cosmetics_auras.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",
@@ -747,7 +772,7 @@ impl Item {
Item::Pregen => "Chunk pregeneration",
Item::CosmeticsMine => "My cosmetics",
Item::CosmeticsOthers => "Other players' cosmetics",
Item::CosmeticsAuras => "Others' auras and trails",
Item::CosmeticsHideFx => "Hide other players' trails and auras",
Item::CosmeticsSolo => "In single player",
Item::HorizonMarks => "Horizon logos",
Item::HorizonMe => "Show me as Horizon",
@@ -766,7 +791,7 @@ impl Item {
Fullbright | DynamicLights | OwnNameTag | HideBlockParticles | MouseTweaks | Sort
| AutoReconnect | NoAi | AgentControl | ReproKeys | DiscordPresence | Splitscreen
| ChatAnimation | WheelCyclesSettings | MusicPage | PackBrowser | CosmeticsMine
| CosmeticsOthers | CosmeticsAuras | CosmeticsSolo | HorizonMarks | HorizonMe
| CosmeticsOthers | CosmeticsHideFx | CosmeticsSolo | HorizonMarks | HorizonMe
| Pregen => {
return None;
}
@@ -834,7 +859,9 @@ impl Item {
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::CosmeticsAuras => "Auras and trails other players wear. Off by default.",
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."
}
@@ -872,7 +899,7 @@ impl Item {
| Item::PackBrowser => Some(Cost::None),
Item::CosmeticsMine
| Item::CosmeticsOthers
| Item::CosmeticsAuras
| Item::CosmeticsHideFx
| Item::CosmeticsSolo
| Item::HorizonMarks
| Item::HorizonMe => Some(Cost::Low),
@@ -902,7 +929,7 @@ impl Item {
Item::WheelCyclesSettings => e.wheel_cycles_settings,
Item::PackBrowser => e.pack_browser,
Item::Pregen => e.pregen,
Item::CosmeticsAuras => e.cosmetics_auras,
Item::CosmeticsHideFx => e.cosmetics_hide_fx,
Item::CosmeticsSolo => e.cosmetics_solo,
Item::HorizonMarks => e.horizon_marks,
Item::HorizonMe => e.horizon_listed,
@@ -967,7 +994,7 @@ impl Item {
let o = e.effective_others();
e.cosmetics_others = Some(if back { o.next().next() } else { o.next() });
}
Item::CosmeticsAuras => e.cosmetics_auras = !e.cosmetics_auras,
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,
@@ -979,6 +1006,7 @@ impl Item {
pub fn key_name(self) -> Option<&'static str> {
match self {
Item::SortKey => Some(SORT_KEY),
Item::Pvp(crate::pvp::Opt::HudEditorKey) => Some(crate::pvp::HUD_EDITOR_KEY),
_ => None,
}
}
@@ -996,7 +1024,7 @@ mod tests {
..Extras::default()
};
assert_eq!(
(e.effective_others(), e.cosmetics_auras, e.cosmetics_mine),
(e.effective_others(), e.cosmetics_hide_fx, e.cosmetics_mine),
(Others::Shown, false, true)
);
Item::CosmeticsOthers.cycle(&mut e, false);
@@ -1055,6 +1083,16 @@ mod tests {
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);
let on = Extras {
wheel_cycles_settings: true,
..Extras::default()
};
assert!(Extras::parse(&on.serialize()).wheel_cycles_settings);
}
#[test]
fn everything_is_off_by_default_and_round_trips() {
let d = Extras::default();
@@ -1063,7 +1101,7 @@ mod tests {
assert!(!d.mouse_tweaks && !d.sort && !d.own_name_tag && !d.ui_sound_reserve);
assert!(!d.hide_block_particles && !d.pregen);
assert!(!d.music_page && d.music_off.is_empty());
assert!(d.discord_presence && d.wheel_cycles_settings);
assert!(d.discord_presence && !d.wheel_cycles_settings);
assert_eq!(Extras::parse(""), d);
assert_eq!(Extras::parse(&d.serialize()), d);
let mut e = d.clone();
@@ -1107,7 +1145,7 @@ mod tests {
Item::Splitscreen.cycle(&mut e, false);
assert_eq!(Item::Splitscreen.switch(&e), Some(true));
Item::WheelCyclesSettings.cycle(&mut e, false);
assert_eq!(Item::WheelCyclesSettings.switch(&e), Some(false));
assert_eq!(Item::WheelCyclesSettings.switch(&e), Some(true));
e.music_off = vec![
"minecraft:music/game/calm1".into(),
"minecraft:music.menu".into(),
+21
View File
@@ -413,6 +413,13 @@ pub fn clear_distance_fog(fog: &mut FogData, input: &FogInputs) {
(fog.environmental_start, fog.environmental_end) = (1e9, 2e9);
}
}
/// Whether the frame draws no sky, only the clear to the fog colour: `LevelRenderer.addSkyPass`
/// leaves the sky out in lava and powder snow and while blindness or darkness is on (`Camera`'s
/// `doesMobEffectBlockSky`), and `GameRenderer.renderLevel` gives `LevelRenderer.render`
/// `shouldRenderSky` false while a boss bar makes world fog (the Ender Dragon's).
pub fn sky_pass_skipped(fog_type: FogType, effect_blocks_sky: bool, boss_world_fog: bool) -> bool {
matches!(fog_type, FogType::Lava | FogType::PowderSnow) || effect_blocks_sky || boss_world_fog
}
/// `AtmosphericFogEnvironment.updateRainFogState`.
pub fn update_rain_fog(
state: &mut FogState,
@@ -480,6 +487,20 @@ mod tests {
assert_eq!(fog(surface - 0.01, -90.0), FogType::Atmospheric);
}
#[test]
fn the_dragon_fights_world_fog_leaves_out_the_sky() {
use FogType::*;
// GameRenderer.renderLevel: no sky pass under a createWorldFog boss bar, in clear air
// as in water.
assert!(sky_pass_skipped(Atmospheric, false, true));
assert!(sky_pass_skipped(Water, false, true));
// LevelRenderer.addSkyPass's own conditions.
assert!(sky_pass_skipped(Lava, false, false));
assert!(sky_pass_skipped(PowderSnow, false, false));
assert!(sky_pass_skipped(Atmospheric, true, false));
assert!(!sky_pass_skipped(Atmospheric, false, false));
assert!(!sky_pass_skipped(Water, false, false));
}
#[test]
fn boss_world_fog_closes_in() {
// AtmosphericFogEnvironment.setupFog under a createWorldFog boss bar.
let sky = Sky {
+431 -19
View File
@@ -874,7 +874,8 @@ pub struct Frame<'a> {
pub cave_culling: bool,
/// Title screen: only the dedicated panorama cube map and the HUD.
pub title: bool,
/// Lava, powder snow, blindness or darkness blocks the sky pass.
/// Lava, powder snow, blindness, darkness or a boss bar's world fog leaves out the sky pass
/// (`fog::sky_pass_skipped`).
pub sky_blocked: bool,
/// Dimensions without a skybox (the Nether) clear to the fog colour.
pub skybox: bool,
@@ -1385,6 +1386,8 @@ pub struct Renderer {
hdr_lighting_setting: bool,
/// The shader pack's post-processing, and the dimension (legacy id) it was loaded for.
pack_post: Option<(i32, Option<pack_post::PackPost>)>,
/// A `shaders/` pack whose pipelines build on a thread of their own (`load_shader_pack`).
pack_loading: Option<PackLoading>,
/// The post passes of one of Horizon's own shader packs; never beside `pack_post`'s.
native: Option<native_pack::NativePack>,
/// Time the shader pack's passes on the GPU (F3, or `HORIZON_SHADER_PACK_TIMING`).
@@ -2115,10 +2118,22 @@ async fn open_with_retry(
);
match open_backend(backends, window, offscreen, true, layers, allow_cpu).await {
Ok(opened) => {
let info = opened.adapter.get_info();
if is_broken_haswell_vulkan(&info) {
log::warn!(
"Horizon renderer: skipping {} on Vulkan because Mesa hasvk suffers from \
vertex corruption on Haswell; falling back to next backend",
info.name
);
return Err(format!(
"Mesa hasvk on Haswell ({}) has broken vertex buffers under Vulkan; falling back",
info.name
));
}
log::warn!(
"Horizon renderer: using {} although its driver reports no Vulkan \
conformance version",
opened.adapter.get_info().name
info.name
);
Ok(opened)
}
@@ -2129,6 +2144,19 @@ async fn open_with_retry(
Ok(opened) => Ok(opened),
}
}
/// Intel Haswell (Gen 7.5) Mesa Vulkan driver (`hasvk`) reports 0.0.0.0 and suffers
/// from severe vertex buffer corruption and polygon tearing (#9912), but its OpenGL driver
/// (`crocus`) is fully conformant and stable. Ivy Bridge (`IVB`) keeps Vulkan (#4059).
pub(crate) fn is_broken_haswell_vulkan(info: &wgpu::AdapterInfo) -> bool {
info.backend == wgpu::Backend::Vulkan
&& info.vendor == 0x8086
&& (matches!(
info.device,
0x0400..=0x042f // Haswell Desktop / Mobile
| 0x0a00..=0x0a2f // Haswell ULT (Ultrabook)
| 0x0d00..=0x0d2f // Haswell CRW (Crystal Well)
) || ["HSW", "Haswell"].iter().any(|tag| info.name.contains(tag)))
}
/// Opens a device on `backends` alone: its surface, adapter and device, or what
/// stopped it. `allow_nonconformant` is `open_with_retry`'s second try; a
/// software adapter is taken only when `allow_cpu`.
@@ -4711,6 +4739,7 @@ impl Renderer {
frame_format,
hdr_marker: None,
pack_post: None,
pack_loading: None,
native: None,
pass_timing: pack_timing::PassTiming::logging(),
own_timing: None,
@@ -5795,6 +5824,31 @@ impl Renderer {
blocks.iter().map(move |block| (block, fully_inside))
})
}
/// The block entities of the sections a shader pack's shadow pass drew in the last frame
/// (`shadow_visible`), within the 64 blocks `visible_block_entities_in_frustum` keeps.
/// Our probe packs' Iris 1.11.7 shadow maps hold a block entity wherever the shadow pass
/// draws its section's terrain, whether or not the camera's view takes it in (#8464).
pub fn shadow_pass_block_entities(
&self,
camera: DVec3,
) -> impl Iterator<Item = &([i32; 3], u32)> + '_ {
block_entities_within_reach(
self.shadow_visible.iter().map(|v| v.key),
&self.block_entities,
camera,
self.layout.min_y(),
)
}
/// Whether a shader pack's shadow pass drew the section holding block `pos` in the last
/// frame (`shadow_pass_block_entities`).
pub fn shadow_pass_drew(&self, pos: [i32; 3]) -> bool {
let key = (
pos[0] >> 4,
(pos[1] - self.layout.min_y()) >> 4,
pos[2] >> 4,
);
self.shadow_visible.iter().any(|v| v.key == key)
}
/// Milliseconds since `key` was uploaded; a section not in `section_uploaded` is old.
fn section_elapsed_ms(&self, key: SectionKey, now: std::time::Instant) -> u64 {
self.section_uploaded.get(&key).map_or(u64::MAX, |t| {
@@ -6437,7 +6491,17 @@ impl Renderer {
world,
)
});
let post = match post.transpose() {
self.pack_loading = None;
self.install_shader_pack(dimension, post.transpose())
}
/// Runs the pack `set_shader_pack` or `load_shader_pack` built (`None`: no pack), or none when
/// it can't run, for the dimension `dimension`; the programs that run and their notes.
fn install_shader_pack(
&mut self,
dimension: i32,
post: std::result::Result<Option<pack_post::PackPost>, pack_post::Refused>,
) -> std::result::Result<Option<(Vec<String>, Vec<String>)>, pack_post::Refused> {
let post = match post {
Ok(post) => post,
Err(e) => {
self.pack_post = Some((dimension, None));
@@ -6456,8 +6520,138 @@ impl Renderer {
}
Ok(report)
}
/// As `set_shader_pack` with a pack, but its pipelines build on a thread of their own while
/// the game keeps drawing without one: the driver compiles a big pack's programs for seconds.
/// `poll_shader_pack` runs it once they are built. `label` and `started` come back with it.
pub fn load_shader_pack(
&mut self,
dimension: i32,
pack: horizon_shaders::Post,
block_ids: Vec<i32>,
label: String,
started: std::time::Instant,
) {
let device = self.device.clone();
let queue = self.queue.clone();
let adapter = self.adapter.clone();
let format = self.frame_format;
let atlas = self.pack_atlas.as_ref().map(|a| (a.view.clone(), a.size()));
let sprite_rects = self
.pack_atlas
.as_ref()
.map_or_else(Vec::new, |a| a.rects_table());
let material_maps = self
.pack_material_atlases
.as_ref()
.map(|a| [a[0].view.clone(), a[1].view.clone()]);
let lightmap = self.lightmap_view.clone();
let gui_size = self
.hud
.atlas_texture
.as_ref()
.map_or([1, 1], |t| [t.width(), t.height()]);
let celestial = self
.hud
.atlas_texture
.as_ref()
.map(|t| t.create_view(&Default::default()));
let flat = self.pack_terrain_flat;
let table = (self.flat.as_ref())
.filter(|_| flat)
.map(|f| f.draw_layout.clone());
let terrain = self.pack_atlas.is_some();
let clouds = self.cloud_bindings.clone();
let build = move || {
let instance = wgpu::vertex_attr_array![5=>Float32x4];
let vertex = wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<TerrainVertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &TERRAIN_ATTRIBUTES,
};
let instances = wgpu::VertexBufferLayout {
array_stride: 16,
step_mode: wgpu::VertexStepMode::Instance,
attributes: &instance,
};
let buffers = [Some(vertex), Some(instances)];
let world = pack_post::World {
atlas,
sprite_rects: &sprite_rects,
material_maps,
lightmap,
gui_size,
terrain: terrain.then(|| pack_post::Geometry {
buffers: if flat { &buffers[..1] } else { &buffers },
table: table.as_ref(),
depth_write: true,
atlas: None,
material_maps: true,
ids: None,
cull: true,
}),
block_ids: &block_ids,
celestial,
clouds: Some(&clouds),
weather: true,
hand: true,
};
pack_post::PackPost::new(&device, &queue, &adapter, pack, format, world)
};
// A pack is not the game's code: a panic building it turns the pack off (#3525).
let thread = std::thread::Builder::new()
.name("Shader pack".into())
.spawn(move || crate::crash::contain(build));
match thread {
Ok(thread) => {
// The last pack doesn't draw this dimension meanwhile.
self.pack_post = None;
self.pack_loading = Some(PackLoading {
dimension,
thread,
label,
started,
});
}
Err(e) => log::error!("shader pack {label}: no thread to build it on: {e}"),
}
}
/// The pack `load_shader_pack` builds, once its thread is done: it runs from now on, as
/// `set_shader_pack` would run it. Its dimension, label, start and what `set_shader_pack`
/// returns.
#[allow(clippy::type_complexity)]
pub fn poll_shader_pack(
&mut self,
) -> Option<(
i32,
String,
std::time::Instant,
std::result::Result<Option<(Vec<String>, Vec<String>)>, pack_post::Refused>,
)> {
if !self.pack_loading.as_ref()?.thread.is_finished() {
return None;
}
let loading = self.pack_loading.take()?;
let built = match loading.thread.join() {
Ok(Ok(built)) => built,
Ok(Err(panic)) => Err(pack_post::Refused {
reason: format!("building it panicked: {panic}"),
notes: Vec::new(),
}),
Err(_) => Err(pack_post::Refused {
reason: "its thread panicked".into(),
notes: Vec::new(),
}),
};
let report = self.install_shader_pack(loading.dimension, built.map(Some));
Some((loading.dimension, loading.label, loading.started, report))
}
/// Whether a `shaders/` pack is still building (`load_shader_pack`).
pub fn shader_pack_loading(&self) -> bool {
self.pack_loading.is_some()
}
/// Drops the running shader pack, of either kind.
pub fn drop_shader_packs(&mut self) {
self.pack_loading = None;
self.pack_post = None;
self.native = None;
self.pack_atlas = None;
@@ -6609,17 +6803,11 @@ impl Renderer {
if let Some(atlas) = &mut atlas {
atlas.separate_ao = self.separate_ao;
}
self.pack_material_atlases = atlas.as_ref().and_then(|_| {
self.pack_material_atlases = atlas.as_ref().and_then(|colour| {
let maps = self.pbr_maps.as_ref()?;
let make = |kind: usize, encoder: &mut wgpu::CommandEncoder| {
let arrays: Vec<_> = maps.iter().map(|pair| pair[kind].clone()).collect();
pack_atlas::PackAtlas::material(
&self.device,
encoder,
&arrays,
self.sprite_split,
layers,
)
colour.material_for(&self.device, encoder, &arrays, layers)
};
Some([make(0, &mut encoder)?, make(1, &mut encoder)?])
});
@@ -6690,7 +6878,8 @@ impl Renderer {
/// The dimension the shader pack was last set for.
pub fn shader_pack_dimension(&self) -> Option<i32> {
self.pack_post.as_ref().map(|p| p.0)
let loading = self.pack_loading.as_ref().map(|l| l.dimension);
loading.or_else(|| self.pack_post.as_ref().map(|p| p.0))
}
/// The Texture Filtering and Max Anisotropy options (`filtering`: 0 none, 1 RGSS, 2
/// anisotropic; `bit`: `maxAnisotropyBit`). All take LevelRenderer's chunk layer sampler
@@ -7636,9 +7825,7 @@ impl Renderer {
if let Some(v) = &shadow_view {
let radius = (frame.detail_distance / 16.0) as i32;
let light = ShadowCull {
light: Frustum {
planes: frustum(v.cull),
},
light: shadow_box(v),
casters: v.view_casters.then(|| shadow_casters(cull_proj, v.light)),
reach: v.distance,
};
@@ -11441,6 +11628,39 @@ impl Cull for ShadowCull {
self.casters.is_none()
}
}
/// The block entities of `sections` whose box, a block wider on every side, comes within the
/// 64 blocks block entities are drawn to (`Renderer::visible_block_entities_in_frustum`).
fn block_entities_within_reach<'a>(
sections: impl Iterator<Item = SectionKey> + 'a,
block_entities: &'a FxHashMap<SectionKey, Vec<([i32; 3], u32)>>,
camera: DVec3,
min_y: i32,
) -> impl Iterator<Item = &'a ([i32; 3], u32)> + 'a {
sections
.filter_map(move |key| {
let min = (DVec3::new(
f64::from(key.0 * 16 - 1),
f64::from(min_y + key.1 * 16 - 1),
f64::from(key.2 * 16 - 1),
) - camera)
.as_vec3();
let max = min + Vec3::splat(18.0);
let near = Vec3::ZERO.max(min).min(max).length_squared() <= 64.0 * 64.0;
near.then(|| block_entities.get(&key)).flatten()
})
.flatten()
}
/// The shadow camera's box that the shadow pass draws terrain in: the sides of `view.cull`, and
/// its near and far planes only with `depth_planes`.
fn shadow_box(view: &pack_post::ShadowView) -> Frustum {
let mut planes = frustum(view.cull);
if !view.depth_planes {
// `frustum`'s faces at depth 0 and 1 take in everything.
planes[4] = Vec4::W;
planes[5] = Vec4::W;
}
Frustum { planes }
}
/// Camera-relative clip planes (`Frustum`).
#[derive(Clone, Copy, Debug)]
pub struct Frustum {
@@ -11470,6 +11690,21 @@ impl Frustum {
.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 v = Vec3::new(
if p.x >= 0.0 { max.x } else { min.x },
if p.y >= 0.0 { max.y } else { min.y },
if p.z >= 0.0 { max.z } else { min.z },
);
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
@@ -11676,6 +11911,47 @@ fn frustum(m: Mat4) -> [Vec4; 6] {
#[cfg(test)]
mod tests {
#[test]
fn depth_culling_keeps_jittered_edges_and_depth_crossing_models() {
use super::*;
for (yaw, pitch) in [(0.0, 0.0), (73.0, -35.0), (-142.0, 79.0)] {
let pose = Mat4::from_translation(Vec3::new(0.03, -0.02, 0.01))
* crate::camera::view_matrix(yaw, pitch);
let matrix = world_projection(70.0, 16.0 / 9.0, 512.0) * pose;
let view = Frustum {
planes: frustum(matrix),
};
for size in [[1, 1], [320, 180], [1280, 720], [3840, 2160]] {
for (x, y) in [(-1.0, 1.0), (1.0, -1.0)] {
let jitter = Mat4::from_translation(Vec3::new(
x / size[0] as f32,
y / size[1] as f32,
0.0,
));
let inverse = (jitter * matrix).inverse();
for (x, y) in [(-1.0, -1.0), (1.0, 1.0), (-1.0, 1.0), (1.0, -1.0)] {
for z in [0.001, 0.5, 0.999] {
let p = inverse * Vec4::new(x, y, z, 1.0);
let p = p.truncate() / p.w;
assert!(view.intersects_depth(
p - Vec3::splat(1.0e-4),
p + Vec3::splat(1.0e-4)
));
}
}
}
}
let point = |p| pose.inverse().transform_point3(p);
for z in [10.0, -600.0] {
let p = point(Vec3::new(0.0, 0.0, z));
assert!(!view.intersects_depth(p - Vec3::splat(0.1), p + Vec3::splat(0.1)));
}
// An oversized model straddling the eye must stay, even with its origin behind.
let p = point(Vec3::ZERO);
assert!(view.intersects_depth(p - Vec3::splat(10.0), p + Vec3::splat(10.0)));
assert!(view.intersects_depth(Vec3::splat(f32::NAN), Vec3::ONE));
}
}
#[test]
fn opaque_gizmo_lines_upload_first_for_the_depth_writing_pipeline() {
let line = |argb: u32| [0.0, 0.0, 0.0, 1.0, 0.0, 0.0, f32::from_bits(argb), 2.5];
@@ -12227,7 +12503,8 @@ mod tests {
}
}
/// Every WGSL shader file translates to HLSL as wgpu's DirectX 12 backend
/// writes it for FXC (shader model 5.1), entry point by entry point.
/// writes it for FXC (shader model 5.1), entry point by entry point, and
/// every shader the OpenGL backend runs also translates to GLSL.
#[test]
fn shaders_translate_to_hlsl_for_directx_12_and_glsl_for_opengl() {
fn wgsl_files(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
@@ -12277,6 +12554,14 @@ mod tests {
}
}
let mut glsl_checked = 0;
// Shaders the OpenGL backend runs must translate: skipping the ones
// that fail hides exactly the bug where a shader GL needs cannot
// compile (#9448).
let mut glsl_failed = Vec::new();
let mut translated = std::collections::HashSet::new();
// naga's GLSL writer gives an image one sampler, and the HUD's glint
// samples one image through two; one failed entry does not hide the rest.
let known_unsupported = [("hud.wgsl", "fs_cit_glint")];
for (path, text) in sources {
// entity.wgsl comes after the overlay's constants (entity_shader).
let module = naga::front::wgsl::parse_str(&text)
@@ -12377,11 +12662,18 @@ mod tests {
Default::default(),
)
.and_then(|mut w| w.write());
// Some shaders use features GLSL cannot express (depth textureLoad): GL does
// not run them. The ones it does run must translate, checked below.
if glsl_result.is_err() {
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if let Err(e) = glsl_result {
if !known_unsupported.contains(&(name, entry.name.as_str())) {
glsl_failed.push(format!(
"{} {} (GLSL {version}): {e}",
path.display(),
entry.name
));
}
continue;
}
translated.insert((name.to_owned(), entry.name.clone()));
glsl_checked += 1;
assert!(
!glsl.contains("ARB_arrays_of_arrays"),
@@ -12392,6 +12684,19 @@ mod tests {
}
}
}
assert!(
glsl_failed.is_empty(),
"shaders the OpenGL backend runs must translate:\n{}",
glsl_failed.join("\n")
);
// These read the world's depth through `textureLoad`; before #9448 their
// depth binding could not translate, so TAA and the blit never came up.
for (file, entry) in [("world_post.wgsl", "fs_taa"), ("pack_post.wgsl", "fs_blit")] {
assert!(
translated.contains(&(file.to_owned(), entry.to_owned())),
"{file} {entry} must translate to GLSL"
);
}
assert!(glsl_checked >= 10, "{glsl_checked} GLSL translations");
}
#[test]
@@ -13140,6 +13445,36 @@ mod sprite_upload_tests {
assert_eq!(adapter_requested_split(&info, None), None);
}
#[test]
fn broken_haswell_vulkan_detects_haswell_and_leaves_ivy_bridge() {
let mut info = wgpu::AdapterInfo {
name: "Intel(R) HD Graphics 4400 (HSW GT2)".into(),
vendor: 0x8086,
device: 0x0a16,
device_type: wgpu::DeviceType::IntegratedGpu,
device_pci_bus_id: String::new(),
driver: "Mesa 26.2.5".into(),
driver_info: "Mesa 26.2.5".into(),
backend: wgpu::Backend::Vulkan,
subgroup_min_size: 0,
subgroup_max_size: 0,
transient_saves_memory: None,
limit_bucket: None,
};
assert!(is_broken_haswell_vulkan(&info));
// Ivy Bridge keeps Vulkan (#4059)
info.name = "Intel(R) HD Graphics 4000 (IVB GT2)".into();
info.device = 0x0166;
assert!(!is_broken_haswell_vulkan(&info));
// Haswell on OpenGL is stable (crocus)
info.name = "Intel(R) HD Graphics 4400 (HSW GT2)".into();
info.device = 0x0a16;
info.backend = wgpu::Backend::Gl;
assert!(!is_broken_haswell_vulkan(&info));
}
#[test]
fn mip_chain_allocates_exactly_its_size() {
for size in [16, 64] {
@@ -13383,6 +13718,7 @@ mod shadow_face_tests {
unculled: None,
every_face,
view_casters: true,
depth_planes: false,
}
}
@@ -13402,6 +13738,24 @@ mod shadow_face_tests {
mod shadow_caster_tests {
use super::*;
#[test]
fn the_shadow_pass_takes_the_block_entities_of_its_sections_within_64_blocks() {
// Sections 64 to 79 high (the world from -64): a chest in each of three. The shadow
// pass drew `near` and `far`; `left_out` lies as near but the pass didn't draw it, and
// `far`'s nearest block is 78.5 blocks away.
let (near, left_out, far) = ((0, 8, 0), (1, 8, 0), (5, 8, 0));
let mut block_entities = FxHashMap::default();
block_entities.insert(near, vec![([3, 70, 3], 7)]);
block_entities.insert(left_out, vec![([20, 70, 3], 7)]);
block_entities.insert(far, vec![([85, 70, 3], 7)]);
let camera = DVec3::new(0.5, 70.0, 0.5);
let drawn = [near, far].into_iter();
let taken: Vec<_> = block_entities_within_reach(drawn, &block_entities, camera, -64)
.copied()
.collect();
assert_eq!(taken, vec![([3, 70, 3], 7)]);
}
/// What the shadow pass keeps of a camera-relative section at `min`, the camera at the
/// origin looking along -X (yaw 90) with the scenes' 70 degree, 16:9 view, and the light in
/// direction `light`; the light's own box takes in everything.
@@ -13556,6 +13910,52 @@ mod shadow_caster_tests {
);
}
/// Our probe pack's Iris 1.11.7 shadow maps, under a noon sun with `shadowDistance` 128, hold
/// the terrain at every depth along the light whatever planes the pack gives (#10403): from
/// a cave at y -7, the surface's sections ~70 blocks toward the light with `shadowNearPlane`
/// 0.05 and `shadowFarPlane` 256; from y 600, the ground ~535 blocks away with no planes set;
/// and both with the planes at 200 and 210. Only the box's sides cull.
#[test]
fn the_shadow_pass_draws_terrain_outside_the_pack_s_planes() {
let kept = |near: Option<f32>, far: Option<f32>, y: f64, min: [f32; 3], depth: bool| {
let shadow = horizon_shaders::Shadow {
distance: 128.0,
near,
far,
..horizon_shaders::Shadow::default()
};
let camera = DVec3::new(13.5, y + 1.62, -13.5);
let view = pack_post::ShadowView {
cull: pack_uniforms::shadow_culling_at(Vec3::Y, camera, &shadow),
light: Vec3::Y,
distance: None,
unculled: None,
every_face: true,
view_casters: false,
depth_planes: depth,
};
let cull = ShadowCull {
light: shadow_box(&view),
casters: None,
reach: None,
};
let min = Vec3::from(min);
cull.intersects(min, min + 16.0)
};
// The section holding the surface (y 64..80) above the cave, and the ground under y 600.
let above = [-8.0, 64.0 - (-7.0 + 1.62), -8.0];
let below = [-8.0, 64.0 - (600.0 + 1.62), -8.0];
assert!(kept(Some(0.05), Some(256.0), -7.0, above, false));
assert!(kept(None, None, 600.0, below, false));
assert!(kept(Some(200.0), Some(210.0), -7.0, above, false));
assert!(kept(Some(200.0), Some(210.0), 600.0, below, false));
// A section 200 blocks off to the side is still outside the box.
let side = [200.0, 0.0, -8.0];
assert!(!kept(Some(0.05), Some(256.0), -7.0, side, false));
// Horizon's own packs keep culling by their planes.
assert!(!kept(Some(0.05), Some(256.0), -7.0, above, true));
}
/// The margin doesn't follow the field of view: from the same camera under a noon sun, our
/// probe's Iris 1.11.7 maps keep a section that needs a growth of 1.1224 blocks at a field of
/// view of 70 (77° flying) and drop ones that need 1.1252 to 1.1422 at 50 (55° flying) and
@@ -13589,3 +13989,15 @@ mod shadow_caster_tests {
assert!(!kept(50.0, 0.0, 0.0, [-2.0, 3.0, 0.0]));
}
}
/// A `shaders/` pack building on its own thread (`Renderer::load_shader_pack`): the dimension it
/// is for, the thread (a panic in it contained), and what the game named it with and when.
struct PackLoading {
dimension: i32,
#[allow(clippy::type_complexity)]
thread: std::thread::JoinHandle<
std::result::Result<std::result::Result<pack_post::PackPost, pack_post::Refused>, String>,
>,
label: String,
started: std::time::Instant,
}
+2 -2
View File
@@ -220,7 +220,7 @@ impl Cull {
/// write storage.
pub fn supported(adapter: &wgpu::Adapter, device: &wgpu::Device) -> Option<DepthTest> {
let depth_test = match adapter.get_info().backend {
wgpu::Backend::Vulkan
wgpu::Backend::Vulkan | wgpu::Backend::Gl
if device
.features()
.contains(wgpu::Features::SHADER_EARLY_DEPTH_TEST) =>
@@ -287,7 +287,7 @@ impl Cull {
0,
fragment,
wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Depth,
sample_type: wgpu::TextureSampleType::Float { filterable: false },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
@@ -114,8 +114,10 @@ fn see(t: u32) {
}
}
// Phase 1's depth, where the test runs in the shader (gfx/cull.rs).
@group(1) @binding(0) var depth: texture_depth_2d;
// Phase 1's depth, where the test runs in the shader (gfx/cull.rs). A depth
// view bound as an unfilterable float: naga's GLSL backend cannot `textureLoad`
// a depth texture, and the texel is the same value.
@group(1) @binding(0) var depth: texture_2d<f32>;
// The world pass's reversed depth test, greater or equal passing, made here:
// where it cannot be forced before a shader that writes storage, a fragment
@@ -123,7 +125,7 @@ fn see(t: u32) {
// runs only for the fragments that pass it.
@fragment
fn fs_box(in: Box) {
if in.position.z >= textureLoad(depth, vec2<u32>(in.position.xy), 0) {
if in.position.z >= textureLoad(depth, vec2<u32>(in.position.xy), 0).r {
see(in.test);
}
}
@@ -52,7 +52,9 @@ struct Params {
@group(0) @binding(0) var<uniform> params: Params;
@group(0) @binding(1) var linear_clamp: sampler;
@group(0) @binding(2) var input_depth: texture_depth_2d;
// A depth view bound as unfilterable float: naga's GLSL backend cannot
// `textureLoad` a depth texture, and the texel is the same value.
@group(0) @binding(2) var input_depth: texture_2d<f32>;
@group(0) @binding(3) var current_color: texture_2d<f32>;
@group(0) @binding(4) var previous_color: texture_2d<f32>;
@group(0) @binding(5) var dilated_depth_out: texture_storage_2d<r32float, write>;
@@ -333,7 +335,7 @@ fn load_input_depth(p: vec2<i32>) -> f32 {
if !is_on_screen(p, params.render_size) {
return 0.0;
}
return textureLoad(input_depth, p, 0);
return textureLoad(input_depth, p, 0).r;
}
@compute @workgroup_size(8, 8)
@@ -12,7 +12,9 @@ struct Params {
};
@group(0) @binding(0) var<uniform> params: Params;
@group(0) @binding(1) var depth: texture_depth_2d;
// A depth view bound as unfilterable float: naga's GLSL backend cannot
// `textureLoad` a depth texture, and the texel is the same value.
@group(0) @binding(1) var depth: texture_2d<f32>;
@group(0) @binding(2) var atlas: texture_2d<f32>;
@group(0) @binding(3) var atlas_sampler: sampler;
@@ -38,7 +40,7 @@ struct Out {
// xy: the motion vector in UV (to where the pixel was last frame, FSR's convention), z: 1.
@fragment fn fs_motion(in: Out) -> @location(0) vec4<f32> {
let stored = textureLoad(depth, vec2<i32>(in.clip.xy), 0);
let stored = textureLoad(depth, vec2<i32>(in.clip.xy), 0).r;
let tolerance = stored * 1e-3 + 1e-7;
// Not what the world shows here: a transparent texel, or behind something.
if in.translucent != 0u {
@@ -368,6 +368,7 @@ impl NativeFrame {
unculled: None,
every_face: false,
view_casters: false,
depth_planes: true,
})
}
@@ -511,26 +512,36 @@ impl NativeFrame {
/// Writes this frame's inputs, for a world of `size`.
pub fn write(&mut self, queue: &wgpu::Queue, frame: &Frame, size: [u32; 2]) {
let inputs = self.next_inputs(frame, size);
queue.write_buffer(&self.buffer, 0, bytemuck::bytes_of(&inputs));
}
/// This frame's inputs, moving the eased values and the frame count on.
fn next_inputs(&mut self, frame: &Frame, size: [u32; 2]) -> FrameInputs {
// Under `HORIZON_STILL` the world's ticks are the clock (`PackInputs::still_ticks`), and
// eased values sit at their targets.
let still = frame.pack.still_ticks;
let seconds = still.map_or_else(
|| self.start.elapsed().as_secs_f32() % 3600.0,
|ticks| (ticks as f32 / 20.0) % 3600.0,
);
// A scripted clock (`PackInputs::frame_timing`) comes first, and eased values
// move by its delta.
let now = std::time::Instant::now();
let since = match still {
Some(_) => f32::INFINITY,
None => self.wetness.1.map_or(0.0, |t| (now - t).as_secs_f32()),
let timing = frame.pack.frame_timing;
// Seconds, frame index, and the seconds eased values move by when not the
// measured ones.
let (seconds, index, step) = match (timing, frame.pack.still_ticks) {
(Some(t), _) => (t.elapsed, t.counter, Some(t.delta)),
(None, Some(ticks)) => (
(ticks as f32 / 20.0) % 3600.0,
ticks as u32,
Some(f32::INFINITY),
),
(None, None) => (
self.start.elapsed().as_secs_f32() % 3600.0,
self.frames,
None,
),
};
let since = step.unwrap_or_else(|| self.wetness.1.map_or(0.0, |t| (now - t).as_secs_f32()));
self.wetness = (wetness(self.wetness.0, frame.pack.rain, since), Some(now));
let mut inputs = frame_inputs(
frame,
size,
seconds,
still.map_or(self.frames, |ticks| ticks as u32),
self.shadow_camera.as_ref(),
);
let mut inputs = frame_inputs(frame, size, seconds, index, self.shadow_camera.as_ref());
inputs.wetness = self.wetness.0;
if !self.reflects {
inputs.has_reflection = 0;
@@ -541,8 +552,11 @@ impl NativeFrame {
inputs.previous_view_projection = view_proj;
// `write` skips title frames, so a gap means a pause or another world: the frame
// before says nothing about this one, and the eased values start over.
// A scripted clock's frames follow each other however long one takes to draw.
match self.last {
Some((previous, camera, time)) if (now - time).as_secs_f32() < MAX_FRAME_GAP => {
Some((previous, camera, time))
if timing.is_some() || (now - time).as_secs_f32() < MAX_FRAME_GAP =>
{
inputs.previous_view_projection = previous;
inputs.camera_motion = (frame.camera - camera).as_vec3().to_array();
inputs.delta_time = (now - time).as_secs_f32();
@@ -561,12 +575,11 @@ impl NativeFrame {
(f32::from(u8::from(inputs.eye_in == 1)), 0.25),
(f32::from(u8::from(inputs.eye_in == 2)), 0.25),
];
let dt = match still {
Some(_) => {
inputs.delta_time = 0.05;
f32::INFINITY
}
None => inputs.delta_time,
let dt = step.unwrap_or(inputs.delta_time);
inputs.delta_time = match (timing, step) {
(Some(t), _) => t.delta,
(None, Some(_)) => 0.05,
(None, None) => inputs.delta_time,
};
for (value, (target, half_life)) in self.eye.iter_mut().zip(targets) {
*value = eased(*value, target, half_life, dt);
@@ -576,7 +589,7 @@ impl NativeFrame {
inputs.in_water = water;
inputs.in_lava = lava;
self.frames = self.frames.wrapping_add(1);
queue.write_buffer(&self.buffer, 0, bytemuck::bytes_of(&inputs));
inputs
}
/// Takes the post stage's pending history reset. It stays pending if post rendering is
@@ -1319,6 +1332,48 @@ mod tests {
assert_eq!(wetness(0.25, 0.25, 5.0), 0.25);
}
/// A scripted clock comes before STILL's ticks, eased values and wetness move by its
/// delta, and a long wait between its frames is not a gap that starts them over.
#[test]
fn a_scripted_clock_drives_time_frame_index_and_eased_values() {
use crate::gfx::pack_uniforms::FrameTiming;
let Some((device, _)) = device() else {
return;
};
let hud = crate::hud::HudBatch::new([4, 4]);
let mut frame = crate::gfx::pack_uniforms::initial_history_tests::frame(&hud);
let mut native = NativeFrame::new(&device, None, Vec::new(), Default::default(), None);
frame.pack.still_ticks = Some(47_811);
frame.pack.eye_light = [0, 15];
frame.pack.rain = 1.0;
frame.pack.frame_timing = Some(FrameTiming::nth(1, 2000.0));
let first = native.next_inputs(&frame, [4, 4]);
assert_eq!(
(first.time, first.frame_index, first.delta_time),
(0.0, 1, 0.0)
);
// Wetness starts dry, as on a fresh pack; frame 1's delta of 0 leaves it there.
assert_eq!((first.eye_light_smooth[1], first.wetness), (1.0, 0.0));
assert!(native.take_history_reset());
native.last.as_mut().unwrap().2 -= std::time::Duration::from_secs(5);
frame.pack.eye_light = [0, 0];
frame.pack.frame_timing = Some(FrameTiming::nth(2, 2000.0));
let second = native.next_inputs(&frame, [4, 4]);
assert_eq!(
(second.time, second.frame_index, second.delta_time),
(2.0, 2, 2.0)
);
// Two seconds: the eye light's 2 s half-life halves it, and wetness rises
// by 1 - 0.5^(2/30) (a 30 s half-life while it rains harder).
assert_eq!(second.eye_light_smooth[1], 0.5);
assert!(
(second.wetness - 0.045_158_4).abs() < 1e-6,
"{}",
second.wetness
);
assert!(!native.take_history_reset());
}
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()?;
@@ -30,6 +30,7 @@ struct Stitched {
/// Each layer's sprite at the first level: its top left corner and its side.
rects: Vec<[u32; 3]>,
padding: u32,
material: bool,
pipeline: wgpu::RenderPipeline,
/// One per sprite array.
groups: Vec<wgpu::BindGroup>,
@@ -66,10 +67,19 @@ fn vs(@builtin(vertex_index) v: u32, @location(0) rect: vec4<i32>, @location(1)
out.source = source.xy;
return out;
}
fn sprite_texel(in: Out) -> vec2<i32> {
return clamp(vec2<i32>(floor(in.position.xy)) - in.rect.xy, vec2(0), vec2(in.rect.z - 1));
}
@fragment
fn fs(in: Out) -> @location(0) vec4<f32> {
let texel = clamp(vec2<i32>(floor(in.position.xy)) - in.rect.xy, vec2(0), vec2(in.rect.z - 1));
return textureLoad(sprites, texel, i32(in.source.x), i32(in.source.y));
return textureLoad(sprites, sprite_texel(in), i32(in.source.x), i32(in.source.y));
}
@fragment
fn fs_material(in: Out) -> @location(0) vec4<f32> {
let texel = sprite_texel(in);
let edge = i32(textureDimensions(sprites, 0).x);
let sampled = min((texel * 2 + 1) * edge / (2 * in.rect.z), vec2(edge - 1));
return textureLoad(sprites, sampled, i32(in.source.x), i32(in.source.y));
}
"#;
@@ -210,14 +220,72 @@ impl PackAtlas {
stitch_mip_level(&sides, mip_option).min(array_levels.saturating_sub(deepest + 1));
let max = device.limits().max_texture_dimension_2d;
let layout = stitch::stitch(&sides, mip, anisotropy_bit, max)?;
let rects = layout
.holders
.iter()
.zip(&sides)
.map(|(&[x, y], &side)| [x + layout.padding, y + layout.padding, side])
.collect();
Some(Self::with_rects(
device,
encoder,
arrays,
split,
sprite,
[layout.width.max(1), layout.height.max(1)],
rects,
layout.padding,
mip + 1,
false,
))
}
/// Companion maps use the colour atlas's coordinates, not a second packing.
pub fn material_for(
&self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
arrays: &[wgpu::Texture],
layers: u32,
) -> Option<Self> {
let Some(stitched) = &self.stitched else {
return Self::material(device, encoder, arrays, self.split, layers);
};
Some(Self::with_rects(
device,
encoder,
arrays,
self.split,
arrays[0].width(),
self.size(),
stitched.rects.clone(),
stitched.padding,
1,
true,
))
}
#[allow(clippy::too_many_arguments)]
fn with_rects(
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
arrays: &[wgpu::Texture],
split: u32,
sprite: u32,
size: [u32; 2],
rects: Vec<[u32; 3]>,
padding: u32,
levels: u32,
material: bool,
) -> Self {
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Shader pack block atlas"),
size: wgpu::Extent3d {
width: layout.width.max(1),
height: layout.height.max(1),
width: size[0],
height: size[1],
depth_or_array_layers: 1,
},
mip_level_count: mip + 1,
mip_level_count: levels,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
@@ -268,7 +336,7 @@ impl PackAtlas {
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some("fs"),
entry_point: Some(if material { "fs_material" } else { "fs" }),
compilation_options: Default::default(),
targets: &[Some(wgpu::TextureFormat::Rgba8Unorm.into())],
}),
@@ -293,12 +361,6 @@ impl PackAtlas {
})
})
.collect();
let rects = layout
.holders
.iter()
.zip(&sides)
.map(|(&[x, y], &side)| [x + layout.padding, y + layout.padding, side])
.collect::<Vec<_>>();
let layers = rects.len() as u32;
let atlas = Self {
texture,
@@ -309,13 +371,14 @@ impl PackAtlas {
separate_ao: false,
stitched: Some(Stitched {
rects,
padding: layout.padding,
padding,
material,
pipeline,
groups,
}),
};
atlas.copy(device, encoder, arrays, 0..layers);
Some(atlas)
atlas
}
/// Whether the sprites keep their own sizes ([`PackAtlas::stitched`]).
@@ -425,7 +488,11 @@ impl PackAtlas {
rect: [x, y, side, stitched.padding].map(|v| (v >> level) as i32),
source: [
layer % self.split,
(self.sprite / side).ilog2() + level,
if stitched.material {
0
} else {
(self.sprite / side).ilog2() + level
},
size[0],
size[1],
],
@@ -563,7 +630,7 @@ impl PackAtlas {
"layout(set = 1, binding = 0) readonly buffer HzBlockSlots { uint hz_block_slots[]; };\n\
layout(set = 1, binding = 1) readonly buffer HzSlotOrigins { vec4 hz_slot_origins[]; };\n",
);
// Of each sprite layer, `mc_Entity` (id, then 1 for fluids or -1) and
// Of each sprite layer, `mc_Entity` (id, then 1 for fluids or 0) and
// `at_midBlock.w` (its block's light), the game's table; and the arena page's
// vertices (`TerrainVertex`, six words each).
declarations.push_str(
@@ -610,13 +677,17 @@ impl PackAtlas {
// A vertex's place in its section goes down to the 32768th of a block below it: read back
// with a probe pack of ours, Iris 1.11.7's terrain `gl_Vertex` has that for every position
// we read (water tops at 8/9 - 0.001, plants' offsets), on all three axes (#8464). A fit,
// not Iris's code. Whole and half blocks, and every 1/16, are on that grid already.
// not Iris's code. Whole and half blocks, and every 1/16, are on that grid already. A place
// up to 1/32 of a 32768th (one float step below 16) under a grid line is on it: plants whose
// random offset is 9/15 of a block read exactly on a half block in Iris, and a 32768th
// under it in Horizon (our guess, not tested: Horizon's float sum lands just under the
// line) (#8464). Also a fit.
let body = format!(
" vec4 hz_origin = {origin};\n \
uint hz_layer = hz_v_layer & 32767u;\n \
{cell}\
hz_scale = vec2({:?}, {:?}) * {side};\n \
hz_Vertex = vec4(floor(hz_v_pos * 32768.0) / 32768.0 + hz_origin.xyz, 1.0);\n \
hz_Vertex = vec4(floor(hz_v_pos * 32768.0 + 0.03125) / 32768.0 + hz_origin.xyz, 1.0);\n \
hz_uv_scale = hz_origin.w > 0.0 ? uintBitsToFloat(floatBitsToUint(hz_origin.w) & 0xff800000u) : fract(abs(hz_origin.w));\n \
hz_mid = vec2(0.5);\n \
hz_MultiTexCoord0 = vec4((hz_cell + clamp(vec2(hz_v_uv) * hz_uv_scale, 0.0, 1.0)) * hz_scale, 0.0, 1.0);\n \
@@ -637,7 +708,7 @@ impl PackAtlas {
let entity = if flat {
"vec4(float(hz_block_ids[3u * hz_layer]), float(hz_block_ids[3u * hz_layer + 1u]), 0.0, 0.0)"
} else {
"vec4(-1.0, -1.0, 0.0, 0.0)"
"vec4(-1.0, 0.0, 0.0, 0.0)"
};
// The block a face belongs to: half a block behind its centre (the quad's diagonal);
// w, the light it gives (Iris's `BLOCK_EMISSION_ATTRIBUTE`, 0 to 15).
@@ -890,6 +961,144 @@ mod tests {
assert_eq!([at(9, 5), at(8, 4), at(10, 6)], [[100, 0, 0, 255]; 3]);
}
fn material_fixture(animated: bool) {
let instance = crate::gfx::test_gpu::instance();
// No adapter (a CI runner without a GPU): skip, as the module's other readback tests do.
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let (device, queue) =
pollster::block_on(adapter.request_device(&Default::default())).unwrap();
let colour_array = upscaled_array(&device, &queue, &[2, 4, 1]);
let mut encoder = device.create_command_encoder(&Default::default());
let colour = PackAtlas::stitched(
&device,
&mut encoder,
&[colour_array],
3,
4,
&[2, 4, 1],
0,
0,
)
.unwrap();
for kind in 0..2 {
// Different source resolution, with encoded channel boundaries and defaults.
let markers = if kind == 0 {
[
[128, 126, 255, 19],
crate::sprites::FLAT_NORMAL,
[130, 125, 254, 21],
]
} else {
[
[17, 240, 65, 255],
[18, 0, 66, 0],
crate::sprites::NO_SPECULAR,
]
};
let pixels: Vec<_> = markers.iter().flat_map(|m| m.repeat(4)).collect();
let array = wgpu::util::DeviceExt::create_texture_with_data(
&device,
&queue,
&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 2,
height: 2,
depth_or_array_layers: 3,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
},
wgpu::util::TextureDataOrder::LayerMajor,
&pixels,
);
let arrays = [array];
let material = colour
.material_for(&device, &mut encoder, &arrays, 3)
.unwrap();
assert_eq!(material.size(), [16, 8]);
assert_eq!(material.rects_table(), colour.rects_table());
assert_eq!(material.texture.mip_level_count(), 1);
queue.submit([encoder.finish()]);
encoder = device.create_command_encoder(&Default::default());
let changed = [243, 1, 229, 0];
if animated {
// Alter every source layer, but copy only the animated layer.
queue.write_texture(
arrays[0].as_image_copy(),
&changed.repeat(12),
wgpu::TexelCopyBufferLayout {
bytes_per_row: Some(8),
rows_per_image: Some(2),
offset: 0,
},
arrays[0].size(),
);
material.copy(&device, &mut encoder, &arrays, [0]);
}
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 256 * 8,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
encoder.copy_texture_to_buffer(
material.texture.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &read,
layout: wgpu::TexelCopyBufferLayout {
bytes_per_row: Some(256),
..Default::default()
},
},
material.texture.size(),
);
queue.submit([encoder.finish()]);
encoder = device.create_command_encoder(&Default::default());
read.slice(..).map_async(wgpu::MapMode::Read, |_| {});
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let data = read.slice(..).get_mapped_range().unwrap();
// Independent, hand-worked colour rectangles, including all padding pixels.
for (layer, [x, y, side]) in [[9usize, 1, 2], [1, 1, 4], [9, 5, 1]]
.into_iter()
.enumerate()
{
let expected = if animated && layer == 0 {
changed
} else {
markers[layer]
};
for py in y - 1..=y + side {
for px in x - 1..=x + side {
let at = py * 256 + px * 4;
assert_eq!(
&data[at..at + 4],
&expected,
"kind={kind}, layer={layer}, ({px},{py})"
);
}
}
}
}
queue.submit([encoder.finish()]);
}
#[test]
fn stitched_material_maps_share_colour_rectangles() {
material_fixture(false);
}
#[test]
fn animated_stitched_material_updates_only_its_rectangle() {
material_fixture(true);
}
#[test]
fn terrain_inputs_respects_separate_ao() {
let instance = crate::gfx::test_gpu::instance();
@@ -174,7 +174,8 @@ fn flat_draws_map_uvs_onto_a_stitched_sprite() {
/// Read back with a probe pack of ours, Iris 1.11.7's terrain `gl_Vertex` (camera on the block
/// grid) puts a water top, 8/9 - 0.001 of a block up, at 29094/32768, and plants offset by 13/15
/// and 0.1 of a block at 28398/32768 and 3276/32768 (#8464): each 32768th below the position.
/// Whole blocks and sixteenths stay where they are.
/// Whole blocks and sixteenths stay where they are, and so does a place float rounding put a hair
/// below a grid line: Iris reads plants offset by 9/15 exactly on a half block.
#[test]
fn terrain_vertices_go_down_to_the_32768th_below_them() {
let instance = crate::gfx::test_gpu::instance();
@@ -204,6 +205,15 @@ fn terrain_vertices_go_down_to_the_32768th_below_them() {
([0.1, water, 13.0 / 15.0], [3276.0, 29094.0, 28398.0]),
([13.0 / 15.0, 0.1, water], [28398.0, 3276.0, 29094.0]),
([1.0, 0.5, 15.9375], [32768.0, 16384.0, 522240.0]),
// One float step below 1, 0.5 and 9.5.
(
[
1.0 - f32::EPSILON / 2.0,
0.5 - f32::EPSILON / 4.0,
9.5 - f32::EPSILON * 8.0,
],
[32768.0, 16384.0, 311296.0],
),
];
let vertices: Vec<_> = cases
.iter()
@@ -16,14 +16,16 @@ const MAPPED: u8 = 3;
const ROW: u32 = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
const SHADER: &str = "
@group(0) @binding(0) var depth: texture_depth_2d;
// A depth view bound as unfilterable float: naga's GLSL backend cannot
// `textureLoad` a depth texture, and the texel is the same value.
@group(0) @binding(0) var depth: texture_2d<f32>;
@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
let p = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
return vec4<f32>(p * 2.0 - 1.0, 0.0, 1.0);
}
@fragment fn fs() -> @location(0) vec4<f32> {
let centre = vec2<i32>(textureDimensions(depth) / 2u);
return vec4<f32>(textureLoad(depth, centre, 0), 0.0, 0.0, 1.0);
return vec4<f32>(textureLoad(depth, centre, 0).r, 0.0, 0.0, 1.0);
}
";
@@ -64,7 +66,7 @@ impl CenterDepth {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Depth,
sample_type: wgpu::TextureSampleType::Float { filterable: false },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
+112 -11
View File
@@ -54,8 +54,12 @@ pub struct PackHand {
slots: wgpu::Buffer,
pub slot_group: wgpu::BindGroup,
stride: u64,
/// This frame's runs: first quad, quads, and the dynamic offset of their slot.
/// This frame's runs: first quad, quads, and the dynamic offset of their slot. The opaque
/// quads come first in the buffer, then the translucent ones (`translucent_runs`), which a
/// pack's `gbuffers_hand_water` draws after deferred.
pub runs: Vec<(u32, u32, u32)>,
pub translucent_runs: Vec<(u32, u32, u32)>,
order: Vec<Vertex>,
}
impl PackHand {
@@ -120,6 +124,8 @@ impl PackHand {
slot_group,
stride,
runs: Vec::new(),
translucent_runs: Vec::new(),
order: Vec::new(),
}
}
@@ -166,17 +172,25 @@ impl PackHand {
) {
self.quads = (hand.len() / 4) as u32;
self.runs.clear();
self.translucent_runs.clear();
if self.quads == 0 {
return;
}
let split = split(hand, items);
self.order = split.order;
let mut contents = Vec::new();
for (start, quads, id) in runs(self.quads, items) {
let slot = (self.runs.len() as u64).min(SLOTS - 1);
if slot as usize == self.runs.len() {
contents.extend_from_slice(&draw_slot(id));
for (i, &(first, quads, id, translucent)) in split.runs.iter().enumerate() {
let slot = (i as u64).min(SLOTS - 1);
if slot == i as u64 {
contents.extend_from_slice(&draw_slot(id, translucent));
contents.resize(((slot + 1) * self.stride) as usize, 0);
}
self.runs.push((start, quads, (slot * self.stride) as u32));
let run = (first, quads, (slot * self.stride) as u32);
if translucent {
self.translucent_runs.push(run);
} else {
self.runs.push(run);
}
}
queue.write_buffer(&self.slots, 0, &contents);
if self.quads > self.capacity {
@@ -185,7 +199,7 @@ impl PackHand {
Self::vertex_buffer(device, &self.layout, &self.uniform, self.capacity);
self.indices = super::pack_entities::quad_indices(device, self.capacity);
}
queue.write_buffer(&self.vertices, 0, bytemuck::cast_slice(hand));
queue.write_buffer(&self.vertices, 0, bytemuck::cast_slice(&self.order));
let matrices = [projection, projection.inverse()].map(|m| m.to_cols_array());
queue.write_buffer(&self.uniform, 0, bytemuck::cast_slice(&matrices));
}
@@ -198,11 +212,62 @@ impl PackHand {
/// The hand's runs a frame keeps slots for.
const SLOTS: u64 = 8;
/// `HzDraw` of a hand run: `renderStage` the solid hand's, `blockEntityId` -1 as outside block
/// entities, and `currentRenderedItemId`.
fn draw_slot(id: i32) -> [u8; SLOT as usize] {
/// The hand's quads reordered, opaque first and translucent after (each in their order), and
/// their runs: first quad, quads, `currentRenderedItemId`, translucent.
struct Split {
order: Vec<Vertex>,
runs: Vec<(u32, u32, i32, bool)>,
}
fn split(hand: &[Vertex], items: &[(u32, i32)]) -> Split {
let quads = (hand.len() / 4) as u32;
let mut ids = vec![0; quads as usize];
for (start, n, id) in runs(quads, items) {
ids[start as usize..(start + n) as usize].fill(id);
}
let kind = |q: usize| translucent_quad(&hand[q * 4..q * 4 + 4]);
let opaque: Vec<usize> = (0..quads as usize).filter(|&q| !kind(q)).collect();
let clear: Vec<usize> = (0..quads as usize).filter(|&q| kind(q)).collect();
let mut out = Split {
order: Vec::with_capacity(hand.len()),
runs: Vec::new(),
};
for &q in opaque.iter().chain(&clear) {
out.order.extend_from_slice(&hand[q * 4..q * 4 + 4]);
}
for (translucent, list, base) in [(false, &opaque, 0), (true, &clear, opaque.len())] {
let mut i = 0;
while i < list.len() {
let id = ids[list[i]];
let mut n = 1;
while i + n < list.len() && ids[list[i + n]] == id {
n += 1;
}
out.runs
.push(((base + i) as u32, n as u32, id, translucent));
i += n;
}
}
out
}
/// A held item's quad drawn from a translucent sheet (`item_render`: its overlay layer).
fn translucent_quad(quad: &[Vertex]) -> bool {
matches!(
quad[0].layer & 0xffff,
super::TRANSLUCENT_ITEM_OVERLAY | super::TRANSLUCENT_GLINT_ITEM_OVERLAY
)
}
/// `HzDraw` of a hand run: `renderStage` the solid or translucent hand's, `blockEntityId` -1 as
/// outside block entities, and `currentRenderedItemId`.
fn draw_slot(id: i32, translucent: bool) -> [u8; SLOT as usize] {
let mut bytes = [0u8; SLOT as usize];
let stage = horizon_shaders::render_stage("HAND_SOLID") as i32;
let stage = horizon_shaders::render_stage(if translucent {
"HAND_TRANSLUCENT"
} else {
"HAND_SOLID"
}) as i32;
bytes[0..4].copy_from_slice(&stage.to_le_bytes());
bytes[8..12].copy_from_slice(&(-1i32).to_le_bytes());
bytes[12..16].copy_from_slice(&id.to_le_bytes());
@@ -233,6 +298,42 @@ fn runs(quads: u32, items: &[(u32, i32)]) -> Vec<(u32, u32, i32)> {
mod tests {
use super::*;
/// The arm, then an item whose middle quad is translucent, then a second item: the opaque
/// quads keep their order first, the translucent one comes after them, and each run keeps
/// its item's id.
#[test]
fn translucent_quads_go_after_the_opaque_ones() {
let quad = |layer: u32, tag: f32| {
[Vertex {
layer,
pos: [tag, 0.0, 0.0],
..bytemuck::Zeroable::zeroed()
}; 4]
};
let glass = super::super::TRANSLUCENT_ITEM_OVERLAY;
let hand: Vec<Vertex> = [
quad(0, 0.),
quad(0, 1.),
quad(glass, 2.),
quad(0, 3.),
quad(0, 4.),
]
.concat();
// The arm is quad 0; item 7 quads 1..4; item 9 quad 4.
let split = split(&hand, &[(4, 7), (16, 9)]);
let order: Vec<f32> = split.order.chunks(4).map(|q| q[0].pos[0]).collect();
assert_eq!(order, [0., 1., 3., 4., 2.]);
assert_eq!(
split.runs,
[
(0, 1, 0, false),
(1, 2, 7, false),
(3, 1, 9, false),
(4, 1, 7, true)
]
);
}
/// Each item's quads draw with its id; geometry before the first item is the arm's (0).
#[test]
fn runs_split_the_hand_by_item() {
@@ -30,11 +30,12 @@ impl PackImage {
}
}
/// The images `image.*` declares.
pub(super) fn images(defs: Vec<CustomImage>) -> Vec<PackImage> {
/// The images `image.*` declares; `packed_float`: this device stores into `Rg11b10Ufloat`
/// (`format`).
pub(super) fn images(defs: Vec<CustomImage>, packed_float: bool) -> Vec<PackImage> {
defs.into_iter()
.map(|def| PackImage {
format: format(def.format),
format: format(def.format, packed_float),
dimension: if def.is_1d() {
wgpu::TextureViewDimension::D1
} else if def.is_3d() {
@@ -63,7 +64,10 @@ fn texture_dimension(view: wgpu::TextureViewDimension) -> wgpu::TextureDimension
}
/// The wgpu format of an image's internal format: a storage format of the same channels.
pub(super) fn format(f: Format) -> wgpu::TextureFormat {
/// `R11F_G11F_B10F` is itself where the device stores into it (`packed_float`): no alpha, so it
/// reads 1 there, as Iris 1.11.7 read it with a probe pack of ours (#10490); elsewhere an
/// `Rgba16Float` stand-in, which keeps an alpha.
pub(super) fn format(f: Format, packed_float: bool) -> wgpu::TextureFormat {
use wgpu::TextureFormat as T;
let pick = |r, rg, rgba| match f.channels {
1 => r,
@@ -78,6 +82,7 @@ pub(super) fn format(f: Format) -> wgpu::TextureFormat {
(Kind::Uint, 16) => pick(T::R16Uint, T::Rg16Uint, T::Rgba16Uint),
(Kind::Uint, _) => pick(T::R32Uint, T::Rg32Uint, T::Rgba32Uint),
(Kind::Float, 32) => pick(T::R32Float, T::Rg32Float, T::Rgba32Float),
(Kind::Float, 11) if packed_float => T::Rg11b10Ufloat,
(Kind::Float, _) => pick(T::R16Float, T::Rg16Float, T::Rgba16Float),
(Kind::Snorm, _) => pick(T::R8Snorm, T::Rg8Snorm, T::Rgba8Snorm),
(Kind::Unorm, 16) => pick(T::R16Unorm, T::Rg16Unorm, T::Rgba16Unorm),
@@ -87,8 +92,12 @@ pub(super) fn format(f: Format) -> wgpu::TextureFormat {
/// Whether this device can make `def`: its format's features, and a size in pixels within the
/// device's limits.
pub(super) fn makeable(device: &wgpu::Device, def: &CustomImage) -> Result<(), String> {
let format = format(def.format);
pub(super) fn makeable(
device: &wgpu::Device,
def: &CustomImage,
packed_float: bool,
) -> Result<(), String> {
let format = format(def.format, packed_float);
let missing = format.required_features() - device.features();
if !missing.is_empty() {
return Err(format!(
+424 -83
View File
@@ -34,27 +34,51 @@ mod packed;
#[cfg(test)]
mod world_target_tests;
#[cfg(test)]
mod celestial_blend_tests;
#[cfg(test)]
mod higher_shadowcolor_tests;
#[cfg(test)]
mod single_side_tests;
#[cfg(test)]
mod sky_disc_blend_tests;
#[cfg(test)]
mod undrawn_terrain_tests;
#[cfg(test)]
mod shadow_faces_tests;
#[cfg(test)]
mod r11f_image_tests;
#[cfg(test)]
mod forced_flip_tests;
#[cfg(test)]
mod weather_depth_tests;
#[cfg(test)]
mod float_into_integer_tests;
#[cfg(test)]
mod custom_type_tests;
#[cfg(test)]
mod sky_depth_tests;
#[cfg(test)]
mod runtime_length_tests;
#[cfg(test)]
mod translucent_color_tests;
#[cfg(test)]
mod noise_resolution_tests;
/// The texture behind a pack sampler name.
#[derive(Clone, Copy, PartialEq)]
enum Source {
@@ -113,6 +137,8 @@ enum SamplerKind {
NearestRepeat,
/// The block atlas: nearest texels, blended between the two nearest levels.
Atlas,
/// colortex buffers: linear, blended between the two nearest levels.
Buffer,
Comparison,
}
@@ -140,15 +166,18 @@ impl SamplerKind {
(true, false) => Self::Repeat,
(false, false) => Self::NearestRepeat,
},
Source::Color(_) | Source::ShadowColor(_) | Source::Dummy | Source::Lightmap => {
Self::Linear
}
// Iris 1.11.7 blends a buffer's two nearest mipmap levels (our probe packs, #9307:
// every colortex read at levels 1.25 and 2.5 in composite and final).
Source::Color(_) => Self::Buffer,
Source::ShadowColor(_) | Source::Dummy | Source::Lightmap => Self::Linear,
}
}
fn binding_type(self) -> wgpu::SamplerBindingType {
match self {
Self::Linear | Self::Repeat | Self::Atlas => wgpu::SamplerBindingType::Filtering,
Self::Linear | Self::Repeat | Self::Atlas | Self::Buffer => {
wgpu::SamplerBindingType::Filtering
}
Self::Nearest | Self::NearestRepeat => wgpu::SamplerBindingType::NonFiltering,
Self::Comparison => wgpu::SamplerBindingType::Comparison,
}
@@ -361,6 +390,9 @@ struct Pass {
name: String,
group: Group,
pipeline: Pipeline,
/// The same program as vanilla blends the sky's parts (`PostProgram::vanilla_sky_blends`):
/// without blending for the disc and the void, then with `STARS_BLEND` for the stars.
sky_parts: Option<[wgpu::RenderPipeline; 2]>,
layout: wgpu::BindGroupLayout,
ubo: wgpu::Buffer,
block: Vec<u8>,
@@ -433,6 +465,14 @@ struct Mips {
levels: Vec<(wgpu::TextureView, wgpu::BindGroup)>,
}
/// The picture a pack's `final` draws at the world's size while the window is another: it is
/// copied onto the window once, after the pack's passes, as the game's own post pass does
/// without a pack.
struct FinalTarget {
size: [u32; 2],
view: wgpu::TextureView,
}
struct Targets {
size: [u32; 2],
/// A and B of each buffer used.
@@ -464,7 +504,7 @@ pub(super) struct World<'a> {
pub lightmap: wgpu::TextureView,
/// Terrain's geometry, when the pack draws it.
pub terrain: Option<Geometry<'a>>,
/// Of each block sprite layer, `mc_Entity` (id, then 1 for fluids or -1) and `at_midBlock.w`
/// Of each block sprite layer, `mc_Entity` (id, then 1 for fluids or 0) and `at_midBlock.w`
/// (its block's light): three values a layer.
pub block_ids: &'a [i32],
/// The atlas of the sun and moon's sprites.
@@ -542,6 +582,10 @@ pub struct PackPost {
terrain_cutout: Option<Pass>,
/// What draws translucent terrain (`gbuffers_water` or its fallback), after `deferred`.
water: Option<Pass>,
/// The pack has no program for solid and cutout terrain, or for translucent terrain:
/// nothing draws it (`horizon_shaders::Post::terrain_undrawn`).
terrain_undrawn: bool,
water_undrawn: bool,
/// What draws the sky (`gbuffers_skybasic` or its fallback), before terrain, and its vertices.
sky: Option<Sky>,
/// What draws entities and block entities, after terrain, and their vertices.
@@ -570,6 +614,8 @@ pub struct PackPost {
/// What draws the first-person hand (`gbuffers_hand` or its fallback), after the opaque
/// world, and its vertices.
hand: Option<(Pass, super::pack_hand::PackHand)>,
/// `gbuffers_hand_water` (or its fallback): the hand's translucent quads, after deferred.
hand_water: Option<Pass>,
/// The pre-translucent copy: `depthtex1`, or `depthtex2` without a shader hand.
depth1: bool,
/// A program after the hand reads `depthtex2`: it is copied before the hand.
@@ -652,6 +698,8 @@ pub struct PackPost {
repeat: wgpu::Sampler,
nearest_repeat: wgpu::Sampler,
atlas_sampler: wgpu::Sampler,
/// colortex buffers' (`SamplerKind::Buffer`).
buffer_sampler: wgpu::Sampler,
/// None when it would write nothing: colortex0 holds integers and there is no depth to copy
/// nor buffer to clear.
blit: Option<(wgpu::RenderPipeline, wgpu::BindGroupLayout)>,
@@ -664,6 +712,12 @@ pub struct PackPost {
blit_group: Option<([wgpu::TextureView; 2], wgpu::BindGroup)>,
/// Shows colortex0 when the pack has no `final` (OQ-5).
copy: (wgpu::RenderPipeline, wgpu::BindGroupLayout),
/// Copies the picture `final` drew at the world's size onto the window: no flip, it is
/// already in the screen's rows.
upscale: (wgpu::RenderPipeline, wgpu::BindGroupLayout),
/// The picture `final` draws while the world's size is not the window's.
final_target: Option<FinalTarget>,
screen: wgpu::TextureFormat,
uniforms: FrameUniforms,
}
@@ -827,10 +881,11 @@ fn source(name: &str, shadow_space: bool, shadow_pass: bool, watershadow: bool)
}
}
/// 256×256 RGB white noise (6.4), from a fixed seed so every run looks the same.
fn noise(device: &wgpu::Device, queue: &wgpu::Queue) -> wgpu::TextureView {
/// `size`×`size` RGB white noise (6.4), from a fixed seed so every run looks the same.
fn noise(device: &wgpu::Device, queue: &wgpu::Queue, size: u32) -> wgpu::TextureView {
let size = size.min(device.limits().max_texture_dimension_2d);
let mut state = 0x2545_f491_4f6c_dd1du64;
let texels: Vec<u8> = (0..256 * 256 * 4)
let texels: Vec<u8> = (0..size as usize * size as usize * 4)
.map(|i| {
state ^= state << 13;
state ^= state >> 7;
@@ -838,7 +893,7 @@ fn noise(device: &wgpu::Device, queue: &wgpu::Queue) -> wgpu::TextureView {
if i % 4 == 3 { 255 } else { (state >> 56) as u8 }
})
.collect();
texture(device, queue, "Shader pack noisetex", 256, &texels)
texture(device, queue, "Shader pack noisetex", size, &texels)
}
fn texture(
@@ -1117,6 +1172,11 @@ pub(super) struct ShadowView {
/// maps fit keeping only those with `shadow.culling` unset (#8464): under a noon sun, only
/// the terrain above the ground the camera sees; with a low sun ahead, none behind the camera.
pub view_casters: bool,
/// Whether the projection's near and far planes in `cull` drop sections too. Our probes'
/// Iris 1.11.7 maps hold the terrain at every depth we put it along the light (from 535 blocks
/// beyond the shadow camera to about 120 before it), whatever `shadowNearPlane` and
/// `shadowFarPlane` the pack sets, or none (#10403): only its sides cull.
pub depth_planes: bool,
}
/// Set 3 of flat terrain, read by the vertex stage: the sprite layers' table (`mc_Entity`'s id
@@ -1247,8 +1307,9 @@ fn bindings(
let mut reads = 0u16;
let mut shadow_reads = 0u16;
let watershadow = interface.samplers.iter().any(|s| s.name == "watershadow");
// A gbuffers pass draws into its buffers and the depth buffer: reading them as well would
// be a feedback loop (undefined in GL, refused by wgpu), so those read a blank texture.
// Opaque gbuffers draws cannot sample their own attachments. Translucent draws can
// read colortex4 and above from the preceding deferred stage; those reads use a copy
// taken before the draw, so the sampled texture is never also an attachment.
let in_place = match geometry {
Some(_) => buffers.iter().fold(0u16, |m, &b| m | 1 << b),
None => 0,
@@ -1358,7 +1419,9 @@ fn bindings(
Source::Depth => geometry.is_some(),
// Translucent terrain reads the copy; opaque terrain would read itself.
Source::Depth1 | Source::Depth2 => group == Group::Gbuffers,
Source::Color(n) => in_place & 1 << n != 0,
Source::Color(n) => {
(group != Group::Translucent || n < 4) && in_place & 1 << n != 0
}
_ => false,
};
let mut sample_type = match src {
@@ -1486,13 +1549,20 @@ fn bindings(
}
(None, None) => stand_in(&i.name, notes),
};
if actual != format {
// An `R11F_G11F_B10F` image or buffer this device makes `RGBA16F` (a float stand-in
// that neither clamps nor drops NaN) takes `layout(r11f_g11f_b10f)` as `rgba16f`
// (`storage_formats_of`): Iris 1.11.7 stored into and read back such an image with a
// probe pack of ours (#10490), where Horizon refused the program.
let stand_in = format == wgpu::TextureFormat::Rg11b10Ufloat
&& actual == wgpu::TextureFormat::Rgba16Float;
if actual != format && !stand_in {
notes.push(format!(
"{name}: `{}` is {} but its texture is {actual:?}",
i.name, i.format
));
return None;
}
let format = actual;
let access = match i.access.as_str() {
"writeonly" => wgpu::StorageTextureAccess::WriteOnly,
"readonly" => wgpu::StorageTextureAccess::ReadOnly,
@@ -1523,6 +1593,51 @@ fn bindings(
Some((entries, samplers, slots, reads, shadow_reads, images))
}
/// Makes each storage image of `module` in group 0 declare the format its layout entry binds:
/// a `layout(r11f_g11f_b10f)` image whose texture is the `RGBA16F` stand-in (`bindings`).
fn storage_formats_of(module: &mut naga::Module, entries: &[wgpu::BindGroupLayoutEntry]) {
let globals: Vec<_> = module.global_variables.iter().map(|(h, _)| h).collect();
for handle in globals {
let var = &module.global_variables[handle];
let Some(naga::ResourceBinding { group: 0, binding }) = var.binding else {
continue;
};
let naga::TypeInner::Image {
dim,
arrayed,
class: naga::ImageClass::Storage { format, access },
} = module.types[var.ty].inner
else {
continue;
};
let bound = entries.iter().find_map(|e| match e.ty {
wgpu::BindingType::StorageTexture { format, .. } if e.binding == binding => {
Some(format)
}
_ => None,
});
if format == naga::StorageFormat::Rg11b10Ufloat
&& bound == Some(wgpu::TextureFormat::Rgba16Float)
{
let ty = module.types.insert(
naga::Type {
name: module.types[var.ty].name.clone(),
inner: naga::TypeInner::Image {
dim,
arrayed,
class: naga::ImageClass::Storage {
format: naga::StorageFormat::Rgba16Float,
access,
},
},
},
naga::Span::UNDEFINED,
);
module.global_variables[handle].ty = ty;
}
}
}
/// The wgpu format of an image's format qualifier.
fn image_format(qualifier: &str) -> Option<wgpu::TextureFormat> {
use wgpu::TextureFormat as T;
@@ -1638,10 +1753,15 @@ fn build_pass(
features, interface, &p.name, p.group, &p.buffers, geometry, formats, shadow, notes, pack,
)?;
let final_pass = p.group == Group::Final;
// Outputs into an integer buffer, and its integer kind (`packed::bitcast_outputs`).
let mut int_outputs = Vec::new();
let mut targets = Vec::new();
let mut writes = Vec::new();
// Outputs into a buffer of a small packed format, and the bits it keeps.
let mut packed_outputs = Vec::new();
// Each output's own `blend.<program>.<buffer>`, and whether its buffer blends at all: what
// the sky's part pipelines keep (`PostProgram::vanilla_sky_blends`).
let mut own_blends: Vec<(Option<wgpu::BlendState>, bool)> = Vec::new();
for &location in &interface.outputs {
let l = location as usize;
if targets.len() <= l {
@@ -1653,6 +1773,15 @@ fn build_pass(
}
} else if let Some(&buffer) = p.buffers.get(l) {
let format = formats[buffer as usize];
match format.sample_type(None, Some(features)) {
Some(wgpu::TextureSampleType::Uint) => {
int_outputs.push((location, naga::ScalarKind::Uint));
}
Some(wgpu::TextureSampleType::Sint) => {
int_outputs.push((location, naga::ScalarKind::Sint));
}
_ => {}
}
let mut state = target(format);
if !p.group.shadow()
&& pack.rgb & 1 << buffer != 0
@@ -1660,19 +1789,21 @@ fn build_pass(
{
t.write_mask = wgpu::ColorWrites::COLOR;
}
if let (Some(t), Some(b)) = (
&mut state,
p.buffer_blends
.iter()
.find(|(n, _)| *n == buffer)
.map_or(p.blend, |(_, b)| *b),
) && format
let own = p.buffer_blends.iter().find(|(n, _)| *n == buffer);
let blendable = format
.guaranteed_format_features(features)
.flags
.contains(wgpu::TextureFormatFeatureFlags::BLENDABLE)
.contains(wgpu::TextureFormatFeatureFlags::BLENDABLE);
if let (Some(t), Some(b)) = (&mut state, own.map_or(p.blend, |(_, b)| *b))
&& blendable
{
t.blend = Some(blend_state(b));
}
if own_blends.len() <= l {
own_blends.resize(l + 1, (None, false));
}
let own = own.and_then(|(_, b)| *b).filter(|_| blendable);
own_blends[l] = (own.map(blend_state), blendable);
if !p.group.shadow()
&& let Some(bits) = pack.packed[buffer as usize]
{
@@ -1732,6 +1863,7 @@ fn build_pass(
});
let module = |mut m: naga::Module| {
share_samplers(&mut m, &slots);
storage_formats_of(&mut m, &entries);
device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some(&p.name),
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(m)),
@@ -1739,55 +1871,76 @@ fn build_pass(
};
let mut fragment = program.fragment;
packed::quantise_outputs(&mut fragment, &packed_outputs);
packed::bitcast_outputs(&mut fragment, &int_outputs);
let (vs, fs) = (module(program.vertex), module(fragment));
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(&p.name),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &vs,
entry_point: Some("main"),
compilation_options: Default::default(),
// A geometry stage reads its vertices itself.
buffers: geometry
.filter(|_| p.geometry_triangles == 0)
.map_or(&[], |g| g.buffers),
},
// Rows flipped to GL's turn front faces clockwise.
primitive: wgpu::PrimitiveState {
front_face: wgpu::FrontFace::Cw,
cull_mode: geometry
.filter(|g| g.cull && p.geometry_triangles == 0)
.map(|_| wgpu::Face::Back),
..Default::default()
},
depth_stencil: geometry.map(|g| wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(g.depth_write),
depth_compare: Some(wgpu::CompareFunction::LessEqual),
stencil: Default::default(),
bias: if p.name == "gbuffers_damagedblock" {
wgpu::DepthBiasState {
constant: -10,
slope_scale: -1.0,
clamp: 0.0,
}
} else {
Default::default()
let descriptor = |targets: &[Option<wgpu::ColorTargetState>]| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(&p.name),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &vs,
entry_point: Some("main"),
compilation_options: Default::default(),
// A geometry stage reads its vertices itself.
buffers: geometry
.filter(|_| p.geometry_triangles == 0)
.map_or(&[], |g| g.buffers),
},
}),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &fs,
entry_point: Some("main"),
compilation_options: Default::default(),
targets: &targets,
}),
multiview_mask: None,
cache: None,
// Rows flipped to GL's turn front faces clockwise.
primitive: wgpu::PrimitiveState {
front_face: wgpu::FrontFace::Cw,
cull_mode: geometry
.filter(|g| g.cull && p.geometry_triangles == 0)
.map(|_| wgpu::Face::Back),
..Default::default()
},
depth_stencil: geometry.map(|g| wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(g.depth_write),
depth_compare: Some(wgpu::CompareFunction::LessEqual),
stencil: Default::default(),
bias: if p.name == "gbuffers_damagedblock" {
wgpu::DepthBiasState {
constant: -10,
slope_scale: -1.0,
clamp: 0.0,
}
} else {
Default::default()
},
}),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &fs,
entry_point: Some("main"),
compilation_options: Default::default(),
targets,
}),
multiview_mask: None,
cache: None,
})
};
let pipeline = descriptor(&targets);
// The program's own blend replaced by vanilla's for the part; a buffer's own
// `blend.<program>.<buffer>` still applies (Iris 1.11.7 blended the sky disc into that
// buffer only, with a probe pack of ours).
let sky_parts = p.vanilla_sky_blends.then(|| {
[None, Some(blend_state(horizon_shaders::STARS_BLEND))].map(|part| {
let targets: Vec<_> = (targets.iter().enumerate())
.map(|(l, t)| {
let (own, blendable) = own_blends.get(l).copied().unwrap_or((None, false));
t.clone().map(|t| wgpu::ColorTargetState {
blend: own.or(part.filter(|_| blendable)),
..t
})
})
.collect();
descriptor(&targets)
})
});
(layout, pipeline)
(layout, pipeline, sky_parts)
});
let (layout, pipeline) = match created {
let (layout, pipeline, sky_parts) = match created {
Ok(created) => created,
Err(e) => {
notes.push(format!("{}: skipped: {e}", p.name));
@@ -1825,6 +1978,7 @@ fn build_pass(
name: p.name,
group: p.group,
pipeline: Pipeline::Render(pipeline),
sky_parts,
layout,
samplers,
writes,
@@ -1839,7 +1993,13 @@ fn build_pass(
ids: geometry.is_some_and(|g| g.ids.is_some()),
groups: HashMap::new(),
portal_groups: HashMap::new(),
copies: image_buffers(&images) & reads,
copies: (image_buffers(&images)
| if p.group == Group::Translucent {
written
} else {
0
})
& reads,
reads: reads | image_buffers(&images),
buffers: !interface.buffers.is_empty(),
images,
@@ -1912,6 +2072,7 @@ fn build_compute(
});
let mut module = c.module;
share_samplers(&mut module, &slots);
storage_formats_of(&mut module, &entries);
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some(&name),
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
@@ -1957,6 +2118,7 @@ fn build_compute(
render: c.work_groups_render,
local,
},
sky_parts: None,
layout,
samplers,
writes: Vec::new(),
@@ -2041,6 +2203,7 @@ impl PackPost {
}
let textures = pack_textures::upload(device, queue, post.textures, &mut notes);
let shadow_config = post.config.shadow.clone();
let noise_size = post.config.noise_resolution.unwrap_or(256);
// shadowcolor2 to 7 only for a pack that names `HIGHER_SHADOWCOLOR`.
let shadow_colors = (1u16 << post.shadow_colors) - 1;
let mut custom = post.custom;
@@ -2083,9 +2246,14 @@ impl PackPost {
// An image this device can't make or store into: the programs using it are skipped.
let mut image_defs = std::mem::take(&mut post.images);
let mut left_out = Vec::new();
let packed_float = gpu.storable(adapter, wgpu::TextureFormat::Rg11b10Ufloat, features);
image_defs.retain(|def| {
let made = pack_images::makeable(device, def).and_then(|()| {
if gpu.storable(adapter, pack_images::format(def.format), features) {
let made = pack_images::makeable(device, def, packed_float).and_then(|()| {
if gpu.storable(
adapter,
pack_images::format(def.format, packed_float),
features,
) {
Ok(())
} else {
Err("this GPU can't store into its format".into())
@@ -2107,7 +2275,7 @@ impl PackPost {
));
left_out.push(def);
}
let images = pack_images::images(image_defs);
let images = pack_images::images(image_defs, packed_float);
let mut objects = std::mem::take(&mut post.buffer_objects);
if gpu.storage_stages().is_empty() {
objects.clear();
@@ -2262,6 +2430,7 @@ impl PackPost {
cull: g.cull,
});
let split = post.terrain.is_some() && post.terrain_cutout.is_some();
let (terrain_undrawn, water_undrawn) = (post.terrain_undrawn, post.water_undrawn);
let mut terrain = post
.terrain
.zip(terrain_geometry.as_ref())
@@ -2419,6 +2588,20 @@ impl PackPost {
};
build(p, Some(&g)).map(|pass| (pass, geometry))
});
// Translucent held items, drawn from the hand's own buffer.
let hand_water = post.hand_water.and_then(|p| {
let (_, geometry) = hand.as_ref()?;
let g = Geometry {
buffers: &entity_buffers,
table: Some(&geometry.layout),
depth_write: true,
atlas: world.celestial.clone(),
material_maps: false,
ids: None,
cull: false,
};
build(p, Some(&g))
});
let weather = post
.weather
.zip(weather_geometry.as_ref())
@@ -2539,6 +2722,9 @@ impl PackPost {
if let Some((h, _)) = &hand {
notes.push(format!("the hand draws with {}", h.name));
}
if let Some(h) = &hand_water {
notes.push(format!("translucent held items draw with {}", h.name));
}
if let Some([w, _]) = &weather {
notes.push(format!("rain and snow draw with {}", w.name));
}
@@ -2592,6 +2778,7 @@ impl PackPost {
.chain(&lines)
.chain(weather.iter().flatten())
.chain(hand.iter().map(|h| &h.0))
.chain(&hand_water)
};
let shadow_targets = shadow_formats.map(|f| {
let colors = every_pass().fold(0u16, |m, p| {
@@ -2645,6 +2832,7 @@ impl PackPost {
.chain(&water)
.chain(weather.iter().take(1).flatten())
.chain(hand.iter().map(|h| &h.0))
.chain(&hand_water)
.find(touched)
.map(|_| true)
.or_else(|| passes.iter().find(touched).map(|_| true));
@@ -2724,7 +2912,7 @@ impl PackPost {
},
&[
texture_entry(0, float),
texture_entry(1, wgpu::TextureSampleType::Depth),
texture_entry(1, wgpu::TextureSampleType::Float { filterable: false }),
],
&blit_targets,
// GL's window depth, written for depthtex0 and the terrain's depth test.
@@ -2751,6 +2939,20 @@ impl PackPost {
&[target(screen)],
None,
);
let upscale = helper(
device,
"vs_screen",
"fs_copy",
&[
texture_entry(0, float),
entry(
2,
wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
),
],
&[target(screen)],
None,
);
let filterable = |b: usize| {
formats[b].sample_type(None, Some(device.features()))
== Some(wgpu::TextureSampleType::Float { filterable: true })
@@ -2794,6 +2996,8 @@ impl PackPost {
terrain,
terrain_cutout,
water,
terrain_undrawn,
water_undrawn,
sky,
entities,
clouds,
@@ -2808,6 +3012,7 @@ impl PackPost {
weather_draw: None,
weather_generation: 0,
hand,
hand_water,
depth1,
depth2,
shadow_targets,
@@ -2848,7 +3053,7 @@ impl PackPost {
targets: None,
noise: pack_textures::noise(&textures)
.cloned()
.unwrap_or_else(|| noise(device, queue)),
.unwrap_or_else(|| noise(device, queue, noise_size)),
dummy: texture(device, queue, "Shader pack stand-in", 1, &white),
dummy_black: texture(device, queue, "Shader pack black stand-in", 1, &black),
dummy_normal: texture(
@@ -2950,8 +3155,18 @@ impl PackPost {
mipmap_filter: wgpu::MipmapFilterMode::Linear,
..Default::default()
}),
buffer_sampler: device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("Shader pack buffers"),
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
mipmap_filter: wgpu::MipmapFilterMode::Linear,
..Default::default()
}),
blit,
copy,
upscale,
final_target: None,
screen,
uniforms: {
let mut u = FrameUniforms::default();
u.atlas = world.atlas.map_or([0; 2], |a| a.1);
@@ -3158,6 +3373,7 @@ impl PackPost {
SamplerKind::Repeat => &self.repeat,
SamplerKind::NearestRepeat => &self.nearest_repeat,
SamplerKind::Atlas => &self.atlas_sampler,
SamplerKind::Buffer => &self.buffer_sampler,
SamplerKind::Comparison => shadow.map_or(&self.shadow.1, |t| &t.comparison),
};
entries.push(wgpu::BindGroupEntry {
@@ -3427,6 +3643,16 @@ impl PackPost {
open.as_mut().expect("open").1 = i;
}
let rp = &mut open.as_mut().expect("open").0;
// Each part with vanilla's blend for it: one program, three pipelines.
let part_pipeline =
pass.sky_parts
.as_ref()
.and_then(|[plain, stars]| match part.stage {
"SKY" | "VOID" => Some(plain),
"STARS" => Some(stars),
_ => None,
});
rp.set_pipeline(part_pipeline.unwrap_or(pass.render()));
if pass.draw {
self.draws.bind(rp, part.stage);
}
@@ -3765,8 +3991,14 @@ impl PackPost {
rp.set_bind_group(1, &geometry.group, &[]);
rp.set_vertex_buffer(0, geometry.vertices());
rp.set_index_buffer(geometry.indices.slice(..), wgpu::IndexFormat::Uint32);
// The arm and each held item: their own `currentRenderedItemId`.
for &(first, quads, offset) in &geometry.runs {
// The arm and each held item: their own `currentRenderedItemId`. Translucent
// quads wait for `gbuffers_hand_water` when the pack has one.
let late = self.hand_water.is_some();
let runs = geometry
.runs
.iter()
.chain(geometry.translucent_runs.iter().filter(|_| !late));
for &(first, quads, offset) in runs {
if pass.draw {
rp.set_bind_group(2, &geometry.slot_group, &[offset]);
}
@@ -3893,9 +4125,53 @@ impl PackPost {
terrain,
);
self.draw_entities(device, queue, encoder, size, true);
self.draw_hand_water(device, queue, encoder, size);
self.draw_weather(device, queue, encoder, size);
}
/// The hand's translucent quads (uploaded by `draw_hand` this frame), with
/// `gbuffers_hand_water`.
fn draw_hand_water(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
size: [u32; 2],
) {
let Some(mut pass) = self.hand_water.take() else {
return;
};
let hand = self.hand.take();
if let Some((_, geometry)) = hand
.as_ref()
.filter(|(_, g)| g.quads > 0 && !g.translucent_runs.is_empty())
{
let group = self.bind(device, queue, encoder, &mut pass, size);
{
let mut rp = self.begin(encoder, &pass);
rp.set_pipeline(pass.render());
rp.set_bind_group(0, &group, &[]);
if pass.buffers
&& let Some(g) = &self.buffers.group
{
rp.set_bind_group(4, g, &[]);
}
rp.set_bind_group(1, &geometry.group, &[]);
rp.set_vertex_buffer(0, geometry.vertices());
rp.set_index_buffer(geometry.indices.slice(..), wgpu::IndexFormat::Uint32);
for &(first, quads, offset) in &geometry.translucent_runs {
if pass.draw {
rp.set_bind_group(2, &geometry.slot_group, &[offset]);
}
rp.draw_indexed(first * 6..(first + quads) * 6, 0, 0..1);
}
}
self.mips_valid &= !pass.written;
}
self.hand = hand;
self.hand_water = Some(pass);
}
/// The rain, then the snow, `set_weather` gave for this frame, with `gbuffers_weather`.
fn draw_weather(
&mut self,
@@ -4006,12 +4282,13 @@ impl PackPost {
},
every_face: true,
view_casters: true,
depth_planes: false,
})
}
/// Whether the pack draws terrain itself.
/// Whether the pack draws terrain itself, or has nothing draw it.
pub fn has_terrain(&self) -> bool {
self.terrain.is_some() || self.terrain_cutout.is_some()
self.terrain.is_some() || self.terrain_cutout.is_some() || self.terrain_undrawn
}
/// Whether the pack's shadow pass draws terrain (`shadow_view`).
@@ -4039,7 +4316,7 @@ impl PackPost {
/// Whether the pack draws translucent terrain itself.
pub fn has_water(&self) -> bool {
self.water.is_some()
self.water.is_some() || self.water_undrawn
}
/// The names of the programs that run, in order.
@@ -4070,6 +4347,7 @@ impl PackPost {
.chain(&mut self.lines)
.chain(self.weather.iter_mut().flatten())
.chain(self.hand.iter_mut().map(|h| &mut h.0))
.chain(&mut self.hand_water)
.chain(&mut self.shadow_terrain)
.chain(&mut self.shadow_water)
.chain(&mut self.shadow_comp);
@@ -4091,6 +4369,14 @@ impl PackPost {
}
self.setup_pending = true;
self.rgb_unset = true;
// Free the old targets before making the new ones: a pack with large buffers can't
// hold both sets at once on a card it otherwise fits (a window resize ran out of video
// memory). The bind groups hold the old views too, and the GPU may still be using
// them, so wait for it; a resize is rare.
if self.targets.take().is_some() {
self.forget_groups();
let _ = device.poll(wgpu::PollType::wait_indefinitely());
}
let sizes: [[u32; 2]; BUFFERS] =
std::array::from_fn(|i| self.sizes[i].map_or(size, |s| s.resolve(size)));
let full_chain = |size: [u32; 2]| 32 - size[0].max(size[1]).max(1).leading_zeros();
@@ -4484,6 +4770,35 @@ impl PackPost {
terrain: &mut TerrainDraw<'_>,
) {
self.resize(device, size);
// A resolution scale leaves the picture smaller (or larger) than the window: the pack's
// `final` draws it at the picture's size, and one copy takes it to the window (#9291).
let final_view = if size != window && self.passes.iter().any(|p| p.group == Group::Final) {
if self.final_target.as_ref().is_none_or(|t| t.size != size) {
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Shader pack final"),
size: wgpu::Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: self.screen,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
self.final_target = Some(FinalTarget {
size,
view: texture.create_view(&Default::default()),
});
}
self.final_target.as_ref().map(|t| t.view.clone())
} else {
self.final_target = None;
None
};
if let Some((layout, ids, rects)) = &self.block_ids {
// A page that grew has a new buffer.
self.page_groups
@@ -4520,10 +4835,8 @@ impl PackPost {
.get_or_insert_with(|| super::pack_center_depth::CenterDepth::new(device))
.collect(device);
self.uniforms.update(frame, inputs, size);
// Until the camera's biome is known, `smooth` waits for the world's values.
if !inputs.biome_known {
self.custom.restart_smoothing();
}
// `smooth` starts on the first frame that knows the camera's biome.
self.custom.world_frame(inputs.biome_known);
self.uniforms.eval_custom(&mut self.custom);
if self.targets.is_none() {
return;
@@ -4788,8 +5101,8 @@ impl PackPost {
self.mips_valid |= stale;
let final_pass = pass.group == Group::Final;
// A pass writing buffers of a size of their own draws at that size (they share it).
// `final` draws the picture at the world's size; a copy takes it to the window.
let out = match pass.writes.first() {
_ if final_pass => window,
Some(&(_, b)) => self.targets.as_ref().map_or(size, |t| t.sizes[b]),
None => size,
};
@@ -4812,7 +5125,7 @@ impl PackPost {
overwrite
};
if final_pass {
attachments.push(attach(screen, overwrite));
attachments.push(attach(final_view.as_ref().unwrap_or(screen), overwrite));
shown = true;
} else if pass.sink {
attachments.push(attach(&targets.sink, ops));
@@ -4895,6 +5208,31 @@ impl PackPost {
rp.set_pipeline(&self.copy.0);
rp.set_bind_group(0, &group, &[]);
rp.draw(0..3, 0..1);
} else if let Some(picture) = &final_view {
// The picture at the world's size onto the window: one upscale, smoothly.
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Shader pack final"),
layout: &self.upscale.1,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(picture),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(&self.linear),
},
],
});
let mut rp = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Shader pack final"),
color_attachments: &[attach(screen, overwrite)],
timestamp_writes: self.timing.as_mut().and_then(|t| t.writes("final copy")),
..Default::default()
});
rp.set_pipeline(&self.upscale.0);
rp.set_bind_group(0, &group, &[]);
rp.draw(0..3, 0..1);
}
}
}
@@ -5594,3 +5932,6 @@ mod unknown_sampler_terrain_tests;
#[cfg(test)]
mod mipmap_box_tests;
#[cfg(test)]
mod colortex_mip_blend_tests;
@@ -9,7 +9,9 @@ fn vs_full(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
}
@group(0) @binding(0) var source: texture_2d<f32>;
@group(0) @binding(1) var world_depth: texture_depth_2d;
// A depth view read as an unfilterable float: naga's GLSL backend cannot
// `textureLoad` a depth texture, and the texel is the same either way.
@group(0) @binding(1) var world_depth: texture_2d<f32>;
struct World {
@location(0) color: vec4<f32>,
@@ -24,7 +26,7 @@ fn fs_blit(@builtin(position) p: vec4<f32>) -> World {
let at = vec2<i32>(i32(p.x), size.y - 1 - i32(p.y));
var out: World;
out.color = textureLoad(source, at, 0);
out.depth = 1.0 - textureLoad(world_depth, at, 0);
out.depth = 1.0 - textureLoad(world_depth, at, 0).r;
return out;
}
@@ -49,6 +51,13 @@ fn vs_uv(@builtin(vertex_index) i: u32) -> Full {
return Full(vec4<f32>(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, 0.0, 1.0), vec2<f32>(uv.x, 1.0 - uv.y));
}
// The screen's own rows: a picture a shader pack's `final` drew (not a GL-rows buffer).
@vertex
fn vs_screen(@builtin(vertex_index) i: u32) -> Full {
let uv = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
return Full(vec4<f32>(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, 0.0, 1.0), uv);
}
@fragment
fn fs_copy(v: Full) -> @location(0) vec4<f32> {
return textureSample(source, linear, v.uv);
@@ -0,0 +1,99 @@
//! The sun and moon blend as vanilla's `RenderPipelines.CELESTIAL` (`BlendFunction.OVERLAY`):
//! their colour is added to the sky's, and their own alpha replaces the buffer's. Iris 1.11.7
//! left the sun's alpha in a buffer the sky had written first, with a probe pack of ours
//! (#10490). Original fixture, no pack files.
use super::depth_test::{Gpu, SIZE, VERTEX, fixture_frame};
use super::*;
use crate::hud::HudBatch;
use wgpu::TextureFormat as T;
/// The sky writes alpha 1 into colortex1, then the sun (default blend) writes alpha 0.5 there;
/// final shows colortex1's alpha in red.
fn files() -> Vec<(String, String)> {
let program = |alpha: &str| {
format!(
"#version 120\n/* DRAWBUFFERS:1 */\n\
void main() {{ gl_FragData[0] = vec4(0.0, 0.0, 0.0, {alpha}); }}\n"
)
};
let vertex = "#version 120\nvoid main() { gl_Position = ftransform(); }\n";
vec![
("gbuffers_skybasic.vsh".into(), vertex.into()),
("gbuffers_skybasic.fsh".into(), program("1.0")),
("gbuffers_skytextured.vsh".into(), vertex.into()),
("gbuffers_skytextured.fsh".into(), program("0.5")),
("final.vsh".into(), VERTEX.into()),
(
"final.fsh".into(),
"#version 120\nuniform sampler2D colortex1;\nvarying vec2 uv;\n\
void main() { gl_FragColor = vec4(texture2D(colortex1, uv).a, 0.0, 0.0, 1.0); }\n"
.into(),
),
]
}
/// A quad across the whole view, `z` blocks in front of the camera.
fn quad(z: f32) -> [crate::mesher::Vertex; 4] {
[
[-400.0, -400.0],
[400.0, -400.0],
[400.0, 400.0],
[-400.0, 400.0],
]
.map(|[x, y]| crate::mesher::Vertex {
pos: [x, y, z],
uv: [0.0; 2],
layer: 0,
color: u32::MAX,
light: 0,
normal: [0; 4],
glint_uv: [0.0; 2],
})
}
#[test]
fn the_sun_leaves_its_own_alpha_over_the_sky() {
let Some(gpu) = Gpu::new() else { return };
let color = gpu.texture(T::Rgba8Unorm);
let depth = gpu.texture(T::Depth32Float);
// The world drew nothing: the far plane (reverse-Z) everywhere.
gpu.clear(&color, &depth, 0.0);
let sun = gpu.texture(T::Rgba8Unorm);
let mut post = gpu.load(
files(),
World {
atlas: None,
material_maps: None,
lightmap: color.clone(),
terrain: None,
block_ids: &[],
sprite_rects: &[],
celestial: Some(sun),
gui_size: SIZE,
clouds: None,
weather: false,
hand: false,
},
horizon_shaders::GameInputs {
sky: Some(super::super::pack_sky::inputs()),
celestial: Some(super::super::pack_sky::celestial_inputs()),
..Default::default()
},
);
assert!(post.sky.is_some(), "{:?}", post.notes);
let hud = HudBatch::new(SIZE);
let mut frame = fixture_frame(&hud);
frame.title = false;
frame.skybox = true;
let sun = quad(100.0);
frame.celestial = &sun;
let pixels = gpu.render(&mut post, &color, &depth, &frame, &mut |_, _, _, _| {});
// The top four rows see the sky disc (sky_depth_tests); the sun covers the whole view.
for (i, p) in pixels[..4 * SIZE[0] as usize].iter().enumerate() {
assert!(
p[0].abs_diff(128) <= 1,
"pixel {i}: colortex1 alpha {} / 255, expected the sun's 0.5",
p[0]
);
}
}
@@ -0,0 +1,61 @@
//! A buffer's mipmaps are read blended between the two nearest levels: our probe packs (#9307)
//! read every colortex at levels 2.5 and 1.25 in composite and final, in Iris 1.11.7, and got the
//! two nearest levels mixed.
use super::destination_tests::Fixture;
use super::*;
use std::io::{Cursor, Write};
const VERTEX: &str = "#version 120\nvarying vec2 uv;\nvoid main() { uv=gl_MultiTexCoord0.xy; gl_Position=ftransform(); }\n";
/// composite writes 1.0 into the left half of a 4x4 colortex1 and 0 into the right half, so at
/// (0.25, 0.5) level 1 reads 1.0 and level 2 (1x1) the mean, 0.5. final shows the read at
/// level 1.5 in red and at level 1.25 in green.
fn pack() -> Post {
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let mut add = |name: &str, source: &str| {
zip.start_file(
format!("shaders/{name}"),
zip::write::SimpleFileOptions::default(),
)
.unwrap();
zip.write_all(source.as_bytes()).unwrap();
};
add("composite.vsh", VERTEX);
add(
"composite.fsh",
"#version 330 compatibility\n/*\nconst int colortex1Format = RGBA16F;\n*/\n\
/* RENDERTARGETS: 1 */\nlayout(location = 0) out vec4 o;\n\
void main() {\n o = vec4(gl_FragCoord.x < 2.0 ? 1.0 : 0.0, 0.0, 0.0, 1.0);\n}\n",
);
add("final.vsh", VERTEX);
add(
"final.fsh",
"#version 330 compatibility\n/*\nconst bool colortex1MipmapEnabled = true;\n*/\n\
uniform sampler2D colortex1;\n\
void main() {\n vec2 at = vec2(0.25, 0.5);\n\
gl_FragColor = vec4(textureLod(colortex1, at, 1.5).r, textureLod(colortex1, at, 1.25).r, 0.0, 1.0);\n}\n",
);
let pack = horizon_shaders::Pack::from_zip_bytes(
"original-colortex-mip-blend",
zip.finish().unwrap().into_inner(),
)
.unwrap();
let mut macros = horizon_shaders::Macros::standard();
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
let post = horizon_shaders::load_post(&pack, "", &properties, &macros, &Default::default());
assert_eq!(post.programs.len(), 2, "{:?}", post.notes);
post
}
#[test]
fn a_buffer_blends_its_two_nearest_levels() {
let Some(gpu) = Fixture::new() else { return };
let mut post = gpu.build(pack());
let pixels = gpu.render(&mut post, [4, 4]);
// Level 1.5: 1.0 and 0.5 mixed 1:1, 0.75; level 1.25: mixed 3:1, 0.875.
let [r, g, ..] = pixels[0];
assert!(
r.abs_diff(191) <= 2 && g.abs_diff(223) <= 2,
"levels 1.5 and 1.25: got red {r}, green {g}, expected 191 and 223"
);
}
@@ -669,6 +669,31 @@ fn shadow_depth_filtering(nearest: &str) -> Option<Vec<[u8; 4]>> {
/// `shadow_depth_filtering`'s shadow map read by `reads`: `final`'s code after its declarations
/// of shadowtex0 and shadowtex1.
fn shadow_depth_reads(nearest: &str, reads: &str) -> Option<Vec<[u8; 4]>> {
// The shadow map's depth runs from 0 at its left edge to 1 at its right.
let ramp = [
[-1., -1., 0.],
[1., -1., 0.],
[1., 1., 0.],
[-1., -1., 0.],
[1., 1., 0.],
[-1., 1., 0.],
];
shadow_depth_map_reads(
nearest,
reads,
"#version 120\nvoid main(){gl_Position=vec4(gl_Vertex.xy,gl_Vertex.x,1.0);}",
ramp,
)
}
/// A shadow map drawn by `shadow_vsh` from the two triangles `positions` (the rest keeps its
/// clear depth, the far plane), read by `reads` as in `shadow_depth_reads`.
fn shadow_depth_map_reads(
nearest: &str,
reads: &str,
shadow_vsh: &str,
positions: [[f32; 3]; 6],
) -> Option<Vec<[u8; 4]>> {
let gpu = Gpu::new()?;
let color = gpu.texture(T::Rgba8Unorm);
let depth = gpu.texture(T::Depth32Float);
@@ -704,11 +729,7 @@ fn shadow_depth_reads(nearest: &str, reads: &str) -> Option<Vec<[u8; 4]>> {
uniform sampler2D shadowtex0;\nuniform sampler2D shadowtex1;\n{reads}"
),
),
// The shadow map's depth runs from 0 at its left edge to 1 at its right.
(
"shadow.vsh".into(),
"#version 120\nvoid main(){gl_Position=vec4(gl_Vertex.xy,gl_Vertex.x,1.0);}".into(),
),
("shadow.vsh".into(), shadow_vsh.into()),
(
"shadow.fsh".into(),
format!("#version 120\n{settings}void main(){{gl_FragData[0]=vec4(1.0);}}"),
@@ -740,14 +761,6 @@ fn shadow_depth_reads(nearest: &str, reads: &str) -> Option<Vec<[u8; 4]>> {
inputs,
);
assert!(post.has_shadow(), "{:?}", post.notes);
let positions: [[f32; 3]; 6] = [
[-1., -1., 0.],
[1., -1., 0.],
[1., 1., 0.],
[-1., -1., 0.],
[1., 1., 0.],
[-1., 1., 0.],
];
let vertices = gpu
.device
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
@@ -793,6 +806,40 @@ fn shadow_depth_reads_filter_directly_and_with_a_texel_offset() {
}
}
#[test]
fn shadow_depth_filter_weights_are_256ths_as_in_our_iris_readings() {
// The map is at the far plane but for texel (8, 8), which is nearer. Reads between texels
// (7, 7) and (8, 8) give (8, 8)'s weight: Iris 1.11.7's filtered reads on our NVIDIA GPU
// fit weights of 1/256 along each axis, rounded half up, and a corner's product weight
// rounded half up to 1/256 (#8464). Red: a read a + 0.25/256 of the way along x only
// moves a/256 (77). Green: at (77.25, 77.25)/256 along both, (8, 8) weighs 23/256
// (77 × 77 / 256 = 23.16; exact weights would give 23.31).
let reads = "void main() {\n\
float far = texelFetch(shadowtex0, ivec2(7, 7), 0).r;\n\
float near = texelFetch(shadowtex0, ivec2(8, 8), 0).r;\n\
float f = 77.25 / 256.0;\n\
float along = texture(shadowtex0, vec2(7.5 + f, 8.5) / 16.0).r;\n\
float corner = texture(shadowtex0, vec2(7.5 + f, 7.5 + f) / 16.0).r;\n\
float x = (far - along) / (far - near) * 256.0;\n\
float w = (far - corner) / (far - near) * 256.0;\n\
gl_FragColor = vec4(abs(x - 77.0) < 0.01 ? 1.0 : 0.0,\n\
abs(w - 23.0) < 0.01 ? 1.0 : 0.0, 0.0, 1.0);\n\
}\n";
// Texel (8, 8) of the 16² map: x and y from 0 to 1/8 in clip space, depth 0.
let bump = [
[0., 0., -1.],
[0.125, 0., -1.],
[0.125, 0.125, -1.],
[0., 0., -1.],
[0.125, 0.125, -1.],
[0., 0.125, -1.],
];
let vsh = "#version 120\nvoid main(){gl_Position=vec4(gl_Vertex.xyz,1.0);}";
if let Some(pixels) = shadow_depth_map_reads("", reads, vsh, bump) {
all_pixels(&pixels, [255, 255, 0, 255], "shadowtex filter weights");
}
}
#[test]
fn shadowtex_nearest_settings_read_the_nearest_texel() {
for (line, expected) in [
@@ -0,0 +1,67 @@
//! A program writing a float `vec4` into an integer buffer draws: its other buffers get what it
//! wrote, and the integer buffer the floats' bits, as Iris 1.11.7 on NVIDIA 615.78.08 did with
//! a probe pack of ours (#10490). Original fixture, no pack files.
use super::depth_test::{Gpu, SIZE, VERTEX, fixture_frame};
use super::*;
use crate::hud::HudBatch;
use wgpu::TextureFormat as T;
#[test]
fn a_float_output_into_an_integer_buffer_writes_its_bits() {
let Some(gpu) = Gpu::new() else { return };
let files = vec![
("composite.vsh".into(), VERTEX.into()),
(
"composite.fsh".into(),
"#version 130\n/*\nconst int colortex3Format = RGBA32UI;\n*/\n/* DRAWBUFFERS:03 */\n\
void main() {\n\
\x20 gl_FragData[0] = vec4(0.25, 0.5, 0.75, 1.0);\n\
\x20 gl_FragData[1] = vec4(1.5, 2.0, 7.0, 0.25);\n}\n"
.into(),
),
("final.vsh".into(), VERTEX.into()),
(
"final.fsh".into(),
"#version 130\nuniform sampler2D colortex0;\nuniform usampler2D colortex3;\n\
varying vec2 uv;\n\
void main() {\n\
\x20 uvec4 q = texelFetch(colortex3, ivec2(gl_FragCoord.xy), 0);\n\
\x20 bvec4 bits = equal(q, uvec4(0x3FC00000u, 0x40000000u, 0x40E00000u, 0x3E800000u));\n\
\x20 gl_FragColor = uv.y > 0.5 ? texture2D(colortex0, uv)\n\
\x20 : vec4(float(bits.x && bits.y), float(bits.z), float(bits.w), 1.0);\n}\n"
.into(),
),
];
let color = gpu.texture(T::Rgba8Unorm);
let depth = gpu.texture(T::Depth32Float);
gpu.clear(&color, &depth, 0.0);
let mut post = gpu.load(
files,
World {
atlas: None,
material_maps: None,
lightmap: color.clone(),
terrain: None,
block_ids: &[],
sprite_rects: &[],
celestial: None,
gui_size: SIZE,
clouds: None,
weather: false,
hand: false,
},
horizon_shaders::GameInputs::default(),
);
let hud = HudBatch::new(SIZE);
let mut frame = fixture_frame(&hud);
frame.title = false;
let pixels = gpu.render(&mut post, &color, &depth, &frame, &mut |_, _, _, _| {});
let width = SIZE[0] as usize;
let (top, bottom) = (pixels[0], pixels[(SIZE[1] as usize - 1) * width]);
assert!(
top[0].abs_diff(64) <= 1 && top[1].abs_diff(128) <= 1 && top[2].abs_diff(191) <= 1,
"colortex0 {top:?}, notes {:?}",
post.notes
);
assert_eq!(bottom, [255, 255, 255, 255], "colortex3's bits");
}
@@ -0,0 +1,63 @@
//! `noiseTextureResolution` sizes the `noisetex` Horizon makes, as Iris 1.11.7 did with probe packs
//! of ours (#10490): a declaration in a fragment shader counts, one in a vertex shader doesn't.
//! Original fixture, no pack files.
use super::depth_test::{Gpu, SIZE, VERTEX, fixture_frame};
use super::*;
use crate::hud::HudBatch;
use wgpu::TextureFormat as T;
/// The width of `noisetex` that `final` sees, with these lines added to `final.vsh` and `final.fsh`.
fn noise_width(vertex_extra: &str, fragment_extra: &str) -> u8 {
let Some(gpu) = Gpu::new() else { return 0 };
let vertex = VERTEX.replacen('\n', &format!("\n{vertex_extra}"), 1);
let files = vec![
("final.vsh".into(), vertex),
(
"final.fsh".into(),
format!(
"#version 130\n{fragment_extra}uniform sampler2D noisetex;\nvarying vec2 uv;\n\
void main() {{ gl_FragColor = vec4(float(textureSize(noisetex, 0).x) / 255.0, 0.0, 0.0, 1.0); }}\n"
),
),
];
let color = gpu.texture(T::Rgba8Unorm);
let depth = gpu.texture(T::Depth32Float);
gpu.clear(&color, &depth, 0.0);
let mut post = gpu.load(
files,
World {
atlas: None,
material_maps: None,
lightmap: color.clone(),
terrain: None,
block_ids: &[],
sprite_rects: &[],
celestial: None,
gui_size: SIZE,
clouds: None,
weather: false,
hand: false,
},
horizon_shaders::GameInputs::default(),
);
let hud = HudBatch::new(SIZE);
let mut frame = fixture_frame(&hud);
frame.title = false;
let pixels = gpu.render(&mut post, &color, &depth, &frame, &mut |_, _, _, _| {});
assert!(post.notes.is_empty(), "{:?}", post.notes);
pixels[0][0]
}
#[test]
fn a_fragment_shader_sets_the_noise_resolution() {
if Gpu::new().is_none() {
return;
}
let declared = "#define NOISE 64\nconst int noiseTextureResolution = NOISE;\n";
assert_eq!(noise_width("", declared), 64);
// Not from a vertex shader: the default 256, read as 255 (1.0).
assert_eq!(
noise_width("const int noiseTextureResolution = 64;\n", ""),
255
);
}
@@ -180,6 +180,124 @@ pub(super) fn quantise_outputs(module: &mut naga::Module, outputs: &[(u32, [u8;
f.body = out;
}
/// A program's `vec4` float output into an integer buffer (`RGBA32UI`...) writes the float's
/// bits there, and the program draws its other buffers. Horizon refused such a program, since
/// wgpu has no float output into an integer target. Iris 1.11.7 drew it, with a probe pack of
/// ours (#10490): its other buffers kept every bit written, NaN payloads included, and the
/// integer buffer read the floats' bits back (1.5 as 0x3FC00000). GL leaves the integer
/// buffer's value undefined, so those bits are what this driver (NVIDIA 615.78.08) wrote.
pub(super) fn bitcast_outputs(module: &mut naga::Module, outputs: &[(u32, naga::ScalarKind)]) {
let Some(ep) =
(module.entry_points.iter_mut()).find(|e| e.stage == naga::ShaderStage::Fragment)
else {
return;
};
let Some(result) = &ep.function.result else {
return;
};
let naga::TypeInner::Struct { members, span } = module.types[result.ty].inner.clone() else {
return;
};
let mut new_members = members.clone();
// (member, integer kind) of each output that changes.
let mut changed = Vec::new();
for (i, m) in members.iter().enumerate() {
let Some(naga::Binding::Location { location, .. }) = m.binding else {
continue;
};
let Some(&(_, kind)) = outputs.iter().find(|o| o.0 == location) else {
continue;
};
if !matches!(
module.types[m.ty].inner,
naga::TypeInner::Vector {
size: VectorSize::Quad,
scalar: naga::Scalar::F32
}
) {
continue;
}
let ty = module.types.insert(
naga::Type {
name: None,
inner: naga::TypeInner::Vector {
size: VectorSize::Quad,
scalar: naga::Scalar { kind, width: 4 },
},
},
naga::Span::UNDEFINED,
);
new_members[i].ty = ty;
if let Some(naga::Binding::Location {
interpolation,
sampling,
..
}) = &mut new_members[i].binding
{
*interpolation = Some(naga::Interpolation::Flat);
*sampling = None;
}
changed.push((i, kind));
}
if changed.is_empty() {
return;
}
let old_name = module.types[result.ty].name.clone();
let struct_ty = module.types.insert(
naga::Type {
name: old_name.map(|n| n + "_bits"),
inner: naga::TypeInner::Struct {
members: new_members,
span,
},
},
naga::Span::UNDEFINED,
);
let f = &mut ep.function;
if let Some(r) = &mut f.result {
r.ty = struct_ty;
}
let body = std::mem::take(&mut f.body);
let mut out = naga::Block::with_capacity(body.len() + 1);
for (statement, sp) in body.span_into_iter() {
let Statement::Return { value: Some(value) } = statement else {
out.push(statement, sp);
continue;
};
let Expression::Compose { components, .. } = &f.expressions[value] else {
out.push(statement, sp);
continue;
};
let mut components = components.clone();
let mut first = None;
for &(member, kind) in &changed {
let e = f.expressions.append(
Expression::As {
expr: components[member],
kind,
convert: None,
},
sp,
);
first.get_or_insert(e);
components[member] = e;
}
let value = f.expressions.append(
Expression::Compose {
ty: struct_ty,
components,
},
sp,
);
out.push(
Statement::Emit(naga::Range::new_from_bounds(first.unwrap_or(value), value)),
sp,
);
out.push(Statement::Return { value: Some(value) }, sp);
}
f.body = out;
}
#[cfg(test)]
mod tests {
use super::*;
@@ -0,0 +1,63 @@
//! An `R11F_G11F_B10F` image that a compute program declares `layout(r11f_g11f_b10f)` is stored
//! into and read back, as Iris 1.11.7 did with a probe pack of ours (#10490): its program is no
//! longer refused when the image is the `RGBA16F` stand-in. Original fixture, no pack files.
use super::depth_test::{Gpu, SIZE, VERTEX, fixture_frame};
use super::*;
use crate::hud::HudBatch;
use wgpu::TextureFormat as T;
#[test]
fn an_r11f_image_is_stored_into_and_read_back() {
let Some(gpu) = Gpu::new() else { return };
let files = vec![
(
"shaders.properties".into(),
"image.imgA = texA RGBA R11F_G11F_B10F FLOAT false false 4 4\n".into(),
),
(
"composite.csh".into(),
"#version 430\nlayout(local_size_x = 1) in;\nconst ivec3 workGroups = ivec3(1, 1, 1);\n\
layout(r11f_g11f_b10f) writeonly uniform image2D imgA;\n\
void main() { imageStore(imgA, ivec2(1, 1), vec4(0.25, 1.5, 3.0, 1.0)); }\n"
.into(),
),
("final.vsh".into(), VERTEX.into()),
(
"final.fsh".into(),
"#version 130\nuniform sampler2D texA;\nvarying vec2 uv;\n\
void main() { gl_FragColor = vec4(texelFetch(texA, ivec2(1, 1), 0).rgb / 4.0, 1.0); }\n"
.into(),
),
];
let color = gpu.texture(T::Rgba8Unorm);
let depth = gpu.texture(T::Depth32Float);
gpu.clear(&color, &depth, 0.0);
let mut post = gpu.load(
files,
World {
atlas: None,
material_maps: None,
lightmap: color.clone(),
terrain: None,
block_ids: &[],
sprite_rects: &[],
celestial: None,
gui_size: SIZE,
clouds: None,
weather: false,
hand: false,
},
horizon_shaders::GameInputs::default(),
);
let hud = HudBatch::new(SIZE);
let mut frame = fixture_frame(&hud);
frame.title = false;
let pixels = gpu.render(&mut post, &color, &depth, &frame, &mut |_, _, _, _| {});
// (0.25, 1.5, 3.0) / 4 = (0.0625, 0.375, 0.75).
let p = pixels[0];
assert!(
p[0].abs_diff(16) <= 1 && p[1].abs_diff(96) <= 1 && p[2].abs_diff(191) <= 1,
"{p:?}, notes {:?}",
post.notes
);
}
@@ -0,0 +1,50 @@
//! `.length()` of a runtime-sized array in a named buffer block reads the buffer's own length, as
//! Iris 1.11.7 did with a probe pack of ours (#10490): `bufferObject.0 = 32` holds 8 floats. The
//! program was refused before. Original fixture, no pack files.
use super::depth_test::{Gpu, SIZE, VERTEX, fixture_frame};
use super::*;
use crate::hud::HudBatch;
use wgpu::TextureFormat as T;
#[test]
fn a_runtime_arrays_length_is_the_buffers() {
let Some(gpu) = Gpu::new() else { return };
let files = vec![
("shaders.properties".into(), "bufferObject.0 = 32\n".into()),
("final.vsh".into(), VERTEX.into()),
(
"final.fsh".into(),
"#version 430 compatibility\nin vec2 uv;\n\
layout(std430, binding = 0) buffer Values { float v[]; } values;\n\
void main() { gl_FragColor = vec4(float(values.v.length()) / 32.0, 0.0, 0.0, 1.0); }\n"
.into(),
),
];
let color = gpu.texture(T::Rgba8Unorm);
let depth = gpu.texture(T::Depth32Float);
gpu.clear(&color, &depth, 0.0);
let mut post = gpu.load(
files,
World {
atlas: None,
material_maps: None,
lightmap: color.clone(),
terrain: None,
block_ids: &[],
sprite_rects: &[],
celestial: None,
gui_size: SIZE,
clouds: None,
weather: false,
hand: false,
},
horizon_shaders::GameInputs::default(),
);
let hud = HudBatch::new(SIZE);
let mut frame = fixture_frame(&hud);
frame.title = false;
let pixels = gpu.render(&mut post, &color, &depth, &frame, &mut |_, _, _, _| {});
// 32 bytes of floats: 8 entries, 8 / 32 = 0.25.
let p = pixels[0];
assert!(p[0].abs_diff(64) <= 1, "{p:?}, notes {:?}", post.notes);
}
@@ -0,0 +1,99 @@
//! The sky disc draws unblended, as vanilla draws it (`RenderPipelines.SKY`): what
//! `gbuffers_skybasic` writes there stays, alpha included. Iris 1.11.7 left exactly that with
//! probe packs of ours (#10490). Original fixture, no pack files.
use super::depth_test::{Gpu, SIZE, VERTEX, fixture_frame};
use super::*;
use crate::hud::HudBatch;
use wgpu::TextureFormat as T;
/// `gbuffers_skybasic` writes (0.2, 0.4, 0.6, 0.25) into colortex1, with `blend` as the
/// properties line; final shows colortex1's colour on the top half and its alpha on the bottom
/// half, each seeing the top of the view.
fn files(blend: &str) -> Vec<(String, String)> {
vec![
("shaders.properties".into(), blend.into()),
(
"gbuffers_skybasic.vsh".into(),
"#version 120\nvoid main() { gl_Position = ftransform(); }\n".into(),
),
(
"gbuffers_skybasic.fsh".into(),
"#version 120\n/* DRAWBUFFERS:1 */\n\
void main() { gl_FragData[0] = vec4(0.2, 0.4, 0.6, 0.25); }\n"
.into(),
),
("final.vsh".into(), VERTEX.into()),
(
"final.fsh".into(),
"#version 120\nuniform sampler2D colortex1;\nvarying vec2 uv;\n\
void main() {\n\
\x20 vec4 c = texture2D(colortex1, vec2(uv.x, uv.y > 0.5 ? uv.y : uv.y + 0.5));\n\
\x20 gl_FragColor = uv.y > 0.5 ? vec4(c.rgb, 1.0) : vec4(c.a, c.a, c.a, 1.0);\n}\n"
.into(),
),
]
}
/// The pack's picture of a frame whose world drew nothing.
fn picture(gpu: &Gpu, blend: &str) -> Vec<[u8; 4]> {
let color = gpu.texture(T::Rgba8Unorm);
let depth = gpu.texture(T::Depth32Float);
// The far plane everywhere (reverse-Z).
gpu.clear(&color, &depth, 0.0);
let mut post = gpu.load(
files(blend),
World {
atlas: None,
material_maps: None,
lightmap: color.clone(),
terrain: None,
block_ids: &[],
sprite_rects: &[],
celestial: None,
gui_size: SIZE,
clouds: None,
weather: false,
hand: false,
},
horizon_shaders::GameInputs {
sky: Some(super::super::pack_sky::inputs()),
..Default::default()
},
);
assert!(post.sky.is_some(), "{:?}", post.notes);
let hud = HudBatch::new(SIZE);
let mut frame = fixture_frame(&hud);
frame.title = false;
frame.skybox = true;
gpu.render(&mut post, &color, &depth, &frame, &mut |_, _, _, _| {})
}
/// The top four rows see the sky disc (sky_depth_tests); final repeats them in rows 8 to 11.
#[test]
fn the_sky_disc_keeps_what_the_program_wrote() {
let Some(gpu) = Gpu::new() else { return };
let width = SIZE[0] as usize;
let pixels = picture(&gpu, "");
for (i, p) in pixels[..4 * width].iter().enumerate() {
let near = |got: u8, want: u8| got.abs_diff(want) <= 1;
assert!(
near(p[0], 51) && near(p[1], 102) && near(p[2], 153),
"pixel {i}: colour {p:?}, expected (0.2, 0.4, 0.6)"
);
}
for (i, p) in pixels[8 * width..12 * width].iter().enumerate() {
assert!(p[0].abs_diff(64) <= 1, "pixel {i}: alpha {} / 255", p[0]);
}
// A pack's own `blend.gbuffers_skybasic` still blends the disc: alpha over the cleared 1.
let blended = picture(
&gpu,
"blend.gbuffers_skybasic = SRC_ALPHA ONE_MINUS_SRC_ALPHA ONE ZERO\n",
);
assert!(blended[0][0].abs_diff(51) > 8, "{:?}", blended[0]);
// So does a `blend.gbuffers_skybasic.colortex1` of its own, into that buffer.
let own = picture(
&gpu,
"blend.gbuffers_skybasic.colortex1 = SRC_ALPHA ONE_MINUS_SRC_ALPHA ONE ZERO\n",
);
assert!(own[0][0].abs_diff(51) > 8, "{:?}", own[0]);
}
@@ -0,0 +1,152 @@
//! A translucent draw reads the preceding scene even when it writes the same color buffer.
//! Original programs and independently calculated colors; no shader-pack assets.
use super::depth_test::{Gpu, SIZE, VERTEX, fixture_frame};
use super::*;
use crate::hud::HudBatch;
use wgpu::{TextureFormat as T, util::DeviceExt};
#[test]
fn translucent_draws_sample_the_color_written_by_deferred() {
let Some(gpu) = Gpu::new() else { return };
let positions: [[f32; 3]; 6] = [
[-1.0, -1.0, 0.0],
[1.0, -1.0, 0.0],
[1.0, 1.0, 0.0],
[-1.0, -1.0, 0.0],
[1.0, 1.0, 0.0],
[-1.0, 1.0, 0.0],
];
let vertices = gpu
.device
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Original translucent quad"),
contents: bytemuck::cast_slice(&positions),
usage: wgpu::BufferUsages::VERTEX,
});
const ATTRS: [wgpu::VertexAttribute; 1] = wgpu::vertex_attr_array![0=>Float32x3];
let layouts = [Some(wgpu::VertexBufferLayout {
array_stride: 12,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &ATTRS,
})];
// Odd and even numbers of deferred passes leave the current scene on different sides.
// Exercise two buffer numbers and the legacy sampler aliases too.
for (buffer, sampler) in [(4, "colortex4"), (7, "gaux4")] {
for stages in [1, 2] {
for blended in [false, true] {
let mut files = vec![
("deferred.vsh".into(), VERTEX.into()),
(
"deferred.fsh".into(),
format!(
"#version 120\n/* RENDERTARGETS: {buffer} */\nvoid main(){{gl_FragData[0]=vec4(0.25,0.5,0.75,1.0);}}"
),
),
(
"gbuffers_water.vsh".into(),
"#version 120\nvoid main(){gl_Position=gl_Vertex;}".into(),
),
(
"gbuffers_water.fsh".into(),
format!(
"#version 120\n/* RENDERTARGETS: {buffer} */\nuniform sampler2D {sampler};\nvoid main(){{gl_FragData[0]=vec4(texture2D({sampler},vec2(0.5)).rgb+vec3(0.125,0.0,0.0),0.5);}}"
),
),
("final.vsh".into(), VERTEX.into()),
(
"final.fsh".into(),
format!(
"#version 120\nuniform sampler2D colortex{buffer};varying vec2 uv;\nvoid main(){{gl_FragColor=vec4(texture2D(colortex{buffer},uv).rgb,1.0);}}"
),
),
(
"shaders.properties".into(),
format!(
"blend.gbuffers_water={}\n",
if blended {
"SRC_ALPHA ONE_MINUS_SRC_ALPHA ONE ONE_MINUS_SRC_ALPHA"
} else {
"off"
}
),
),
];
if stages == 2 {
files.extend([
("deferred1.vsh".into(), VERTEX.into()),
("deferred1.fsh".into(), format!(
"#version 120\n/* RENDERTARGETS: {buffer} */\nvoid main(){{gl_FragData[0]=vec4(0.5,0.75,0.25,1.0);}}"
)),
]);
}
let color = gpu.texture(T::Rgba8Unorm);
let depth = gpu.texture(T::Depth32Float);
gpu.clear(&color, &depth, 0.0);
let mut post = gpu.load(
files,
World {
atlas: None,
material_maps: None,
lightmap: color.clone(),
terrain: Some(Geometry {
buffers: &layouts,
table: None,
depth_write: true,
atlas: None,
material_maps: false,
ids: None,
cull: false,
}),
block_ids: &[],
sprite_rects: &[],
celestial: None,
gui_size: SIZE,
clouds: None,
weather: false,
hand: false,
},
horizon_shaders::GameInputs {
terrain: Some(horizon_shaders::GbuffersInputs {
declarations: "layout(location=0)in vec3 fixture_position;\n".into(),
body: "hz_Vertex=vec4(fixture_position,1.0);\n".into(),
..Default::default()
}),
..Default::default()
},
);
assert!(post.has_water(), "{:?}", post.notes);
let mut expected = if stages == 1 {
[0.25f32, 0.5, 0.75]
} else {
[0.5, 0.75, 0.25]
};
expected[0] += if blended { 0.0625 } else { 0.125 };
let expected = expected.map(|c| (c * 255.0).round() as u8);
let hud = HudBatch::new(SIZE);
for frame in 0..2 {
let pixels = gpu.render(
&mut post,
&color,
&depth,
&fixture_frame(&hud),
&mut |rp, list, _, _| {
if list == TerrainList::Translucent {
rp.set_vertex_buffer(0, vertices.slice(..));
rp.draw(0..6, 0..1);
}
},
);
for p in pixels {
assert!(
p[..3]
.iter()
.zip(expected)
.all(|(&a, b)| a.abs_diff(b) <= 2),
"buffer {buffer}, {stages} deferred stages, blended {blended}, frame {frame}: {p:?}, expected {expected:?}"
);
}
}
}
}
}
}
@@ -0,0 +1,49 @@
//! A pack with no program for terrain draws none (#10455): Iris 1.11.7 drew no terrain, solid
//! or translucent, into any buffer or depth with probe packs of ours having no gbuffers
//! programs. The game hands the pack its terrain, so it doesn't draw it either. The programs
//! here are original and cut down.
use super::destination_tests::Fixture;
use std::io::{Cursor, Write};
const VERTEX: &str = "#version 120\nvoid main() { gl_Position = ftransform(); }\n";
const WHITE: &str =
"#version 120\n/* DRAWBUFFERS:0 */\nvoid main() { gl_FragData[0] = vec4(1.0); }\n";
/// A pack with `programs` (each drawing white into colortex0) loaded for a game that can hand it
/// terrain: whether it takes solid and translucent terrain from the game.
fn takes_terrain(programs: &[&str]) -> Option<(bool, bool)> {
let gpu = Fixture::new()?;
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
for name in programs {
for (ext, source) in [("vsh", VERTEX), ("fsh", WHITE)] {
zip.start_file(
format!("shaders/{name}.{ext}"),
zip::write::SimpleFileOptions::default(),
)
.unwrap();
zip.write_all(source.as_bytes()).unwrap();
}
}
let pack = horizon_shaders::Pack::from_zip_bytes(
"undrawn-terrain",
zip.finish().unwrap().into_inner(),
)
.unwrap();
let mut macros = horizon_shaders::Macros::standard();
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
let inputs = horizon_shaders::GameInputs {
terrain: Some(Default::default()),
..Default::default()
};
let post = horizon_shaders::load_post(&pack, "", &properties, &macros, &inputs);
let post = gpu.try_build(post).ok()?;
Some((post.has_terrain(), post.has_water()))
}
#[test]
fn a_pack_without_terrain_programs_takes_terrain_from_the_game() {
let Some(taken) = takes_terrain(&["composite"]) else {
return;
};
assert_eq!(taken, (true, true));
}
@@ -43,6 +43,64 @@ fn a_pack_without_final_reads_the_replaced_world_colour() {
replacement(Change::Colour, false);
}
/// A Resolution Scale leaves the pack's picture smaller than the window. Its `final` draws at
/// the picture's size and one copy upsizes it to the window (#9291), so a final that fetches
/// its pixel by `gl_FragCoord` (as Photon's does) covers every pixel, not a quarter of them.
#[test]
fn a_scaled_picture_fills_the_whole_window() {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let Ok((device, queue)) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
required_limits: adapter.limits(),
..Default::default()
})) else {
return;
};
eprintln!("scaled picture adapter: {:?}", adapter.get_info());
let picture = [4, 2];
let window = [8, 4];
let colour = texture(&device, picture, T::Rgba8Unorm);
let depth = texture(&device, picture, T::Depth32Float);
clear(&device, &queue, &colour, &depth, [32, 64, 16, 255], 0.25);
let mut post = pack_with(
&device,
&queue,
&adapter,
&colour,
&[
(
"final.vsh",
"#version 120\nvoid main(){gl_Position=ftransform();}",
),
(
"final.fsh",
"#version 330 compatibility\nuniform sampler2D colortex0;\nvoid main(){gl_FragColor=texelFetch(colortex0,ivec2(gl_FragCoord.xy),0);}",
),
],
);
let hud = HudBatch::new(window);
let pixels = render(
&device,
&queue,
&mut post,
&colour,
&depth,
&frame(&hud),
picture,
window,
);
// Independently specified: the world's colour at every window pixel.
let wrong = pixels.iter().filter(|p| **p != [32, 64, 16, 255]).count();
assert_eq!(
wrong,
0,
"{wrong} of {} pixels are not the picture",
pixels.len()
);
}
fn replacement(change: Change, final_shader: bool) {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
@@ -62,7 +120,9 @@ fn replacement(change: Change, final_shader: bool) {
let mut post = pack(&device, &queue, &adapter, &colour, final_shader);
let hud = HudBatch::new(size);
let frame = frame(&hud);
let initial = render(&device, &queue, &mut post, &colour, &depth, &frame, size);
let initial = render(
&device, &queue, &mut post, &colour, &depth, &frame, size, size,
);
let expected = if final_shader {
[32, 64, 191, 255]
} else {
@@ -93,9 +153,13 @@ fn replacement(change: Change, final_shader: bool) {
0.75
};
clear(&device, &queue, &colour, &depth, pixel, reverse_depth);
let actual = render(&device, &queue, &mut post, &colour, &depth, &frame, size);
let actual = render(
&device, &queue, &mut post, &colour, &depth, &frame, size, size,
);
let mut fresh = pack(&device, &queue, &adapter, &colour, final_shader);
let reference = render(&device, &queue, &mut fresh, &colour, &depth, &frame, size);
let reference = render(
&device, &queue, &mut fresh, &colour, &depth, &frame, size, size,
);
// Independently specified RG channels and GL depth (1 - reverse-Z), in UNORM8.
let expected = if !final_shader {
[160, 96, 48, 255]
@@ -178,13 +242,8 @@ fn pack(
lightmap: &wgpu::TextureView,
final_shader: bool,
) -> PackPost {
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let options = zip::write::SimpleFileOptions::default();
zip.start_file("shaders/shaders.properties", options)
.unwrap();
zip.write_all(b"oldLighting=false\n").unwrap();
if final_shader {
for (name, source) in [
let programs: &[(&str, &str)] = if final_shader {
&[
(
"final.vsh",
"#version 120\nvarying vec2 uv;\nvoid main(){gl_Position=ftransform();uv=gl_MultiTexCoord0.xy;}",
@@ -193,10 +252,29 @@ fn pack(
"final.fsh",
"#version 120\nvarying vec2 uv;uniform sampler2D colortex0;uniform sampler2D depthtex0;\nvoid main(){gl_FragColor=vec4(texture2D(colortex0,uv).rg,texture2D(depthtex0,uv).r,1.0);}",
),
] {
zip.start_file(format!("shaders/{name}"), options).unwrap();
zip.write_all(source.as_bytes()).unwrap();
}
]
} else {
&[]
};
pack_with(device, queue, adapter, lightmap, programs)
}
/// A pack of `programs` (`name`, GLSL source) over `lightmap`.
fn pack_with(
device: &wgpu::Device,
queue: &wgpu::Queue,
adapter: &wgpu::Adapter,
lightmap: &wgpu::TextureView,
programs: &[(&str, &str)],
) -> PackPost {
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let options = zip::write::SimpleFileOptions::default();
zip.start_file("shaders/shaders.properties", options)
.unwrap();
zip.write_all(b"oldLighting=false\n").unwrap();
for (name, source) in programs {
zip.start_file(format!("shaders/{name}"), options).unwrap();
zip.write_all(source.as_bytes()).unwrap();
}
let pack = horizon_shaders::Pack::from_zip_bytes(
"original-world-inputs",
@@ -241,12 +319,13 @@ fn render(
depth: &wgpu::TextureView,
frame: &Frame,
size: [u32; 2],
window: [u32; 2],
) -> Vec<[u8; 4]> {
let screen = texture(device, size, T::Rgba8Unorm);
let screen = texture(device, window, T::Rgba8Unorm);
let stride = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: u64::from(stride * size[1]),
size: u64::from(stride * window[1]),
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
@@ -261,7 +340,7 @@ fn render(
frame,
&Default::default(),
size,
size,
window,
&[],
&mut |_, _, _, _| {},
);
@@ -285,7 +364,7 @@ fn render(
.unwrap()
.chunks_exact(stride as usize)
.flat_map(|row| {
row[..size[0] as usize * 4]
row[..window[0] as usize * 4]
.as_chunks::<4>()
.0
.iter()
@@ -94,6 +94,31 @@ pub struct PackInputs {
/// `frameCounter`) instead of the wall clock and the frames drawn, and smoothed values
/// (`wetness`, `eyeBrightnessSmooth`, `centerDepthSmooth`) at their targets.
pub still_ticks: Option<u64>,
/// A scripted clock (`screenshot-sequence`): taken before `still_ticks` and the wall
/// clock, and smoothed values move by its `delta`.
pub frame_timing: Option<FrameTiming>,
}
/// One frame of a scripted pack clock: the frame's number from 1, the seconds since frame 1,
/// and the seconds since the frame before (0 on frame 1).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct FrameTiming {
pub counter: u32,
pub elapsed: f32,
pub delta: f32,
}
impl FrameTiming {
/// Frame `n` (from 1) of frames `step_ms` milliseconds apart (`16.666667` for 60 fps). The
/// elapsed time is worked out in f64, so frame 10 000 is as exact as frame 2.
pub fn nth(n: u32, step_ms: f64) -> Self {
let step = step_ms / 1000.0;
Self {
counter: n,
elapsed: (f64::from(n.saturating_sub(1)) * step) as f32,
delta: if n <= 1 { 0.0 } else { step as f32 },
}
}
}
/// The `cloudHeight` uniform: the dimension's `visual/cloud_height`
@@ -349,8 +374,10 @@ pub struct FrameUniforms {
start: std::time::Instant,
last: Option<std::time::Instant>,
frame: u32,
/// The last frame's `PackInputs::still_ticks`, for `eval_custom`.
still_ticks: Option<u64>,
/// The tick `eval_custom` restarts the custom uniforms at: every frame's under STILL; on a
/// scripted clock's frame 1 only 0 (where a freshly parsed pack starts), so its later frames
/// carry their smoothing and randoms.
custom_restart: Option<u64>,
previous: Option<(glam::DVec3, Mat4, Mat4)>,
wetness: f32,
eye_smooth: [f32; 2],
@@ -369,7 +396,7 @@ impl Default for FrameUniforms {
start: std::time::Instant::now(),
last: None,
frame: 0,
still_ticks: None,
custom_restart: None,
previous: None,
wetness: 0.0,
eye_smooth: [0.0; 2],
@@ -552,7 +579,12 @@ fn invert_generic(m: Mat4) -> Mat4 {
/// `shadow_camera`'s view and projection in wgpu's clip space (depth 0..1), for culling.
pub fn shadow_culling(frame: &Frame, shadow: &Shadow) -> Mat4 {
let (view, gl) = shadow_camera(shadow_light(frame, shadow), frame.camera, shadow);
shadow_culling_at(shadow_light(frame, shadow), frame.camera, shadow)
}
/// `shadow_culling` for the direction toward the light and the camera's position.
pub fn shadow_culling_at(light: Vec3, camera: DVec3, shadow: &Shadow) -> Mat4 {
let (view, gl) = shadow_camera(light, camera, shadow);
// GL's depth -w..w to wgpu's 0..w.
let depth = Mat4::from_cols(
Vec4::X,
@@ -682,9 +714,16 @@ impl FrameUniforms {
let now = std::time::Instant::now();
let measured = self.last.map_or(0.0, |l| (now - l).as_secs_f32());
self.last = Some(now);
self.still_ticks = inputs.still_ticks;
self.custom_restart = match inputs.frame_timing {
Some(timing) => (timing.counter <= 1).then_some(0),
None => inputs.still_ticks,
};
// Smoothing over an endless step lands on its target.
let dt = inputs.still_ticks.map_or(measured, |_| f32::INFINITY);
let dt = match (inputs.frame_timing, inputs.still_ticks) {
(Some(timing), _) => timing.delta,
(None, Some(_)) => f32::INFINITY,
(None, None) => measured,
};
let aspect = size[0] as f32 / size[1].max(1) as f32;
// Packs take gbufferProjection for a plain perspective (they invert only its perspective
// terms): the view bob and the nausea spin belong to the model-view, as GL drew them.
@@ -776,13 +815,14 @@ impl FrameUniforms {
let (shadow_view, shadow_view_inverse) = shadow_model_view(frame, &self.shadow);
let day_time = inputs.day_time.rem_euclid(24000) as i32;
let world_day = inputs.day_time.div_euclid(24000);
let (seconds, frame_time, frame_counter) = match inputs.still_ticks {
Some(ticks) => (
let (seconds, frame_time, frame_counter) = match (inputs.frame_timing, inputs.still_ticks) {
(Some(timing), _) => (timing.elapsed, timing.delta, timing.counter as i32),
(None, Some(ticks)) => (
(ticks as f32 / 20.0) % 3600.0,
0.05,
(ticks % 720_720) as i32,
),
None => (
(None, None) => (
(now - self.start).as_secs_f32() % 3600.0,
measured,
self.frame as i32,
@@ -924,7 +964,7 @@ impl FrameUniforms {
/// Evaluates the pack's custom uniforms with this frame's values and puts them after
/// `NAMES`' values, in `custom.uniforms()` order.
pub fn eval_custom(&mut self, custom: &mut horizon_shaders::Custom) {
if let Some(tick) = self.still_ticks {
if let Some(tick) = self.custom_restart {
custom.restart_at(tick);
}
self.values.truncate(NAMES.len());
@@ -1293,7 +1333,7 @@ mod tests {
}
#[cfg(test)]
mod initial_history_tests {
pub(super) mod initial_history_tests {
use super::*;
use crate::hud::HudBatch;
fn float(u: &FrameUniforms, name: &str) -> [f32; 16] {
@@ -2188,6 +2228,145 @@ mod initial_history_tests {
assert_eq!(float(&u, "centerDepthSmooth")[0], 0.0);
}
/// A scripted clock's values reach the pack as they are, before STILL's ticks.
#[test]
fn a_scripted_clock_comes_before_the_still_ticks() {
let hud = HudBatch::new([800, 450]);
let f = frame(&hud);
for still_ticks in [None, Some(47_811)] {
let mut u = FrameUniforms::default();
for (n, elapsed, delta) in [(1, 0.0, 0.0), (64, 3.15, 0.05)] {
let input = PackInputs {
day_time: 6_001,
still_ticks,
frame_timing: Some(FrameTiming {
counter: n,
elapsed,
delta,
}),
..Default::default()
};
u.update(&f, &input, [800, 450]);
match u.values[index("frameCounter").unwrap()] {
Value::I(v) => assert_eq!(v[0], n as i32),
_ => panic!("integer uniform expected"),
}
assert_eq!(float(&u, "frameTimeCounter")[0], elapsed);
assert_eq!(float(&u, "frameTime")[0], delta);
match u.values[index("worldTime").unwrap()] {
Value::I(v) => assert_eq!(v[0], 6_001),
_ => panic!("integer uniform expected"),
}
}
}
}
/// Smoothed values move by a scripted clock's delta, also under STILL, which would
/// otherwise put them at their targets.
#[test]
fn a_scripted_clock_moves_smoothed_values_by_its_delta() {
let hud = HudBatch::new([800, 450]);
let f = frame(&hud);
let mut u = FrameUniforms::default();
let mut input = PackInputs {
rain: 1.0,
eye_light: [0, 15],
still_ticks: Some(47_811),
frame_timing: Some(FrameTiming::nth(1, 1000.0)),
..Default::default()
};
u.center_depth_raw = Some(1.0);
u.update(&f, &input, [800, 450]);
input.rain = 0.0;
input.eye_light = [0, 0];
u.center_depth_raw = Some(0.0);
// A second each: eyeBrightnessSmooth halves (1 s half-life),
// centerDepthSmooth halves ten times (0.1 s), wetness falls by 0.5^(1/20) (20 s).
for (n, eye, depth, wetness) in [
(2, 120, 1.0 / 1_024.0, 0.965_936_3),
(3, 60, 1.0 / 1_048_576.0, 0.933_033),
] {
input.frame_timing = Some(FrameTiming::nth(n, 1000.0));
u.update(&f, &input, [800, 450]);
match u.values[index("eyeBrightnessSmooth").unwrap()] {
Value::I(v) => assert_eq!(v[1], eye),
_ => panic!("integer uniform expected"),
}
assert_eq!(float(&u, "centerDepthSmooth")[0], depth);
assert!((float(&u, "wetness")[0] - wetness).abs() < 1e-6);
}
}
/// Custom uniforms start over on a scripted clock's frame 1 and carry their smoothing
/// and randoms through the frames after it, so a sequence drawn again repeats.
#[test]
fn custom_uniforms_start_over_on_a_scripted_frame_1() {
let properties = horizon_shaders::Properties::parse(
"uniform.float.s = smooth(rainStrength, 10.0)
uniform.float.r = random()
",
&horizon_shaders::Macros::standard(),
);
let mut custom = horizon_shaders::Custom::parse(&properties);
let hud = HudBatch::new([800, 450]);
let f = frame(&hud);
let mut u = FrameUniforms::default();
let mut run = |u: &mut FrameUniforms| -> Vec<[u32; 2]> {
(1..=3)
.map(|n| {
let input = PackInputs {
rain: if n == 1 { 0.0 } else { 1.0 },
still_ticks: Some(47_811),
frame_timing: Some(FrameTiming::nth(n, 1000.0)),
..Default::default()
};
u.update(&f, &input, [800, 450]);
u.eval_custom(&mut custom);
std::array::from_fn(|i| match u.values[NAMES.len() + i] {
Value::F(v) => v[0].to_bits(),
_ => panic!("float uniform expected"),
})
})
.collect()
};
let first = run(&mut u);
// The smoothed rain moves toward 1 over frames 2 and 3; the randoms differ
// each frame.
let s: Vec<f32> = first.iter().map(|v| f32::from_bits(v[0])).collect();
assert!(
s[0] == 0.0 && s[0] < s[1] && s[1] < s[2] && s[2] < 1.0,
"{s:?}"
);
assert!(first[0][1] != first[1][1] && first[1][1] != first[2][1]);
assert_eq!(run(&mut u), first);
}
#[test]
fn the_nth_scripted_frame_counts_from_1() {
assert_eq!(
FrameTiming::nth(1, 50.0),
FrameTiming {
counter: 1,
elapsed: 0.0,
delta: 0.0
}
);
assert_eq!(
FrameTiming::nth(2, 50.0),
FrameTiming {
counter: 2,
elapsed: 0.05,
delta: 0.05
}
);
assert_eq!(FrameTiming::nth(64, 50.0).elapsed, 3.15);
assert_eq!(FrameTiming::nth(10_000, 50.0).elapsed, 499.95);
assert_eq!(FrameTiming::nth(3, 1.0).elapsed, 0.002);
// 60 fps: frame 61 is one second in, not 61 × 16 ms or 61 × 17 ms.
let sixty = FrameTiming::nth(61, 1000.0 / 60.0);
assert_eq!((sixty.elapsed, sixty.delta), (1.0, 1.0 / 60.0));
}
/// Iris 1.11.7 (probe pack): `ambientLight` is the dimension type's (0.1 in the Nether,
/// 0.25 in the End), and `playerMood` and `constantMood` both the cave mood.
#[test]
@@ -2205,7 +2384,7 @@ mod initial_history_tests {
assert_eq!(float(&u, "constantMood")[0], 0.0235);
}
fn frame(hud: &HudBatch) -> Frame<'_> {
pub(in crate::gfx) fn frame(hud: &HudBatch) -> Frame<'_> {
Frame {
eye: glam::DVec3::ZERO,
eye_rotation: [0.0; 2],
@@ -43,9 +43,7 @@ struct TaaUniform {
jitter: [f32; 4],
}
/// TAA's GPU resources, absent when the device cannot compile the shader
/// (the OpenGL backend's naga GLSL emitter does not support `textureLoad`
/// from depth textures).
/// TAA's GPU resources, absent when the device cannot compile the shader.
struct TaaResources {
layout: wgpu::BindGroupLayout,
pipeline: wgpu::RenderPipeline,
@@ -146,9 +144,6 @@ impl Post {
cache: None,
})
};
// The OpenGL backend's GLSL translator does not support textureLoad
// from depth textures: on devices running through OpenGL (such as older
// Intel GPUs on Linux), TAA cannot compile its pipeline.
let taa_resources = {
let taa_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Horizon TAA bindings"),
@@ -161,7 +156,9 @@ impl Post {
count: None,
},
texture(2, float),
texture(3, wgpu::TextureSampleType::Depth),
// A depth view through an unfilterable float binding: the
// texel is the same, and the GLSL backend can read it.
texture(3, wgpu::TextureSampleType::Float { filterable: false }),
wgpu::BindGroupLayoutEntry {
binding: 4,
visibility: wgpu::ShaderStages::FRAGMENT,
@@ -1576,7 +1576,7 @@ impl GraphicsUi {
&cols,
&lines,
self.scroll.amount,
Page::name,
|page| lang::get(page.name()),
|batch, setting: Setting, y| {
let item = setting.item;
let rect = cols.row(y);
@@ -1840,9 +1840,10 @@ impl GraphicsUi {
self.scroll.release();
self.dragging.take().is_some()
}
/// The mouse wheel at the cursor. It scrolls the list, except that a hovered value
/// stepped by `<` `>` takes the step (`CycleButton.mouseScrolled`: up goes back)
/// while the "Scroll wheel changes settings" extra is on. A slider, a tick box and a
/// The mouse wheel at the cursor. It scrolls the list, as vanilla's does
/// (`AbstractScrollArea.mouseScrolled`), except that a hovered value stepped by
/// `<` `>` takes the step (up goes back) while the "Scroll wheel changes settings"
/// extra is on. A slider, a tick box and a
/// focused row don't take it, wherever the focus came from: the keyboard and a
/// controller step them with Left and Right.
pub fn wheel(
@@ -2526,6 +2527,25 @@ mod tests {
assert_eq!(ui.scroll.amount, 0.0);
}
#[test]
fn a_hovered_value_takes_the_wheel_only_with_the_extra_on() {
let mut s = Settings::default();
let mut ui = GraphicsUi::default();
let item = Item::Vanilla(Opt::AttackIndicator);
let r = row(&mut ui, &s, item);
let before = format!("{s:?}");
let actions = ui.wheel(&mut s, SIZE, (r[0] + 4.0, r[1] + 4.0), FB, -1.0);
assert_eq!(format!("{s:?}"), before, "the hovered value stays put");
assert!(actions.is_empty());
s.extras.wheel_cycles_settings = true;
let r = row(&mut ui, &s, item);
let before = format!("{s:?}");
let actions = ui.wheel(&mut s, SIZE, (r[0] + 4.0, r[1] + 4.0), FB, -1.0);
assert_ne!(format!("{s:?}"), before, "the hovered value steps");
assert!(!actions.is_empty());
}
#[test]
fn the_arrows_move_between_rows_in_view_and_over_to_the_page_list() {
let mut s = Settings::default();
+88 -9
View File
@@ -76,6 +76,7 @@ pub struct HudAtlas {
pub sprites: HashMap<String, Rect>,
/// Resolved player-profile names and UUIDs for inline head components.
pub object_skins: HashMap<String, String>,
pub object_skins_revision: u64,
/// Sprites with a 0 < alpha < 255 texel (`SpriteContents.transparency().hasTranslucent`),
/// which puts their particles on vanilla's translucent layer.
pub translucent: HashSet<String>,
@@ -786,21 +787,29 @@ impl HudAtlas {
.or_else(|| self.sprites.get(MISSING_SPRITE))
.copied()
}
pub fn clear_object_skins(&mut self) {
self.object_skins.clear();
self.object_skins_revision = Self::next_revision();
}
/// Keep a ready profile skin when another source only has its default.
pub fn set_object_skin(&mut self, key: String, skin: String) {
use std::collections::hash_map::Entry;
if self.object_skins.len() >= 1024 && !self.object_skins.contains_key(&key) {
self.object_skins.clear();
self.clear_object_skins();
}
match self.object_skins.entry(key) {
Entry::Occupied(entry)
if skin.starts_with("entity/player/") && self.sprites.contains_key(entry.get()) => {
}
Entry::Occupied(mut entry) => {
entry.insert(skin);
if entry.get() != &skin {
entry.insert(skin);
self.object_skins_revision = Self::next_revision();
}
}
Entry::Vacant(entry) => {
entry.insert(skin);
self.object_skins_revision = Self::next_revision();
}
}
}
@@ -920,6 +929,7 @@ impl HudAtlas {
height: 1,
sprites: HashMap::new(),
object_skins: HashMap::new(),
object_skins_revision: Self::next_revision(),
translucent: HashSet::new(),
flags: HashMap::new(),
sprite_animations: Vec::new(),
@@ -985,6 +995,7 @@ impl HudAtlas {
translucent: HashSet::new(),
flags: HashMap::new(),
object_skins: HashMap::new(),
object_skins_revision: Self::next_revision(),
sprite_animations: Vec::new(),
glint: (vec![0; 4], 1, 1),
glint_armor: (vec![0; 4], 1, 1),
@@ -1232,7 +1243,8 @@ impl HudAtlas {
.ok();
let animation = animation_of(mcmeta.as_deref());
// An animated strip keeps its first frame.
let frames = name.starts_with("block/") || name.starts_with("item/");
let frames =
animation.is_some() || name.starts_with("block/") || name.starts_with("item/");
let (w, h, pixels) = if frames {
first_frame(image, animation.as_ref())
} else {
@@ -1313,12 +1325,13 @@ impl HudAtlas {
edition.3 = pixels;
}
images.push(("horizon/white".into(), 4, 4, vec![255; 64]));
images.push((
crate::pad_cursor::SPRITE.into(),
crate::pad_cursor::SIZE,
crate::pad_cursor::SIZE,
crate::pad_cursor::pixels(),
));
// 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() {
if !images.iter().any(|i| i.0 == image.0) {
images.push(image);
}
}
if full {
Self::slots(&mut images, crate::trims::slot_size(root), mod_image);
// MissingTextureAtlasSprite.generateMissingImage: 16x16, magenta
@@ -1434,6 +1447,7 @@ impl HudAtlas {
height,
sprites,
object_skins: HashMap::new(),
object_skins_revision: Self::next_revision(),
translucent,
flags,
sprite_animations,
@@ -2888,6 +2902,28 @@ impl HudRenderer {
mod tests {
use super::*;
#[test]
fn object_skin_revision_tracks_changes_and_capacity_clear() {
let mut atlas = HudAtlas::boot();
atlas.set_object_skin("name:player".into(), "skins/server".into());
let revision = atlas.object_skins_revision;
atlas.set_object_skin("name:player".into(), "skins/server".into());
assert_eq!(atlas.object_skins_revision, revision);
atlas.set_object_skin("name:player".into(), "skins/inline".into());
assert_ne!(atlas.object_skins_revision, revision);
for i in 1..1024 {
atlas.set_object_skin(format!("name:{i}"), "skins/server".into());
}
let revision = atlas.object_skins_revision;
atlas.set_object_skin("name:overflow".into(), "skins/server".into());
assert_ne!(atlas.object_skins_revision, revision);
assert_eq!(atlas.object_skins.len(), 1);
let revision = atlas.object_skins_revision;
atlas.clear_object_skins();
assert_ne!(atlas.object_skins_revision, revision);
assert!(atlas.object_skins.is_empty());
}
/// The mod image slot is paid only by a game with a native mod loaded.
#[test]
fn the_mod_image_slot_exists_only_when_a_mod_is_loaded() {
@@ -3495,6 +3531,49 @@ mod tests {
let banner = atlas.sprites["custom/banner"];
assert_eq!(banner.texels((atlas.width, atlas.height)), (16, 32));
}
#[test]
fn an_animated_gui_sprite_packs_its_first_frame_and_animates() {
let root =
std::env::temp_dir().join(format!("horizon-atlas-gui-anim-{}", std::process::id()));
let sprites = root.join("assets/minecraft/textures/gui/sprites/icon");
fs::create_dir_all(&sprites).unwrap();
// A GUI sprite like icon/music_notes: 4 frames stacked vertically (16x64),
// with animation metadata, and a still 16x32 texture beside it.
write_strip(
&sprites.join("music_notes.png"),
16,
64,
&[
[10, 20, 30, 255],
[40, 50, 60, 255],
[70, 80, 90, 255],
[100, 110, 120, 255],
],
Some(r#"{"animation": {"frametime": 2}}"#),
);
write_strip(
&sprites.join("static_badge.png"),
16,
32,
&[[9, 9, 9, 255]],
None,
);
let atlas = HudAtlas::build(&root).unwrap();
let _ = fs::remove_dir_all(&root);
let rect = atlas.sprites["gui/sprites/icon/music_notes"];
// One frame, not the whole 16x64 strip squeezed into a slot.
assert_eq!(rect.texels((atlas.width, atlas.height)), (16, 16));
assert_eq!(texel_at(&atlas, rect, 0, 15), [10, 20, 30, 255]);
let (_, animation) = atlas
.sprite_animations
.iter()
.find(|(r, _)| r.u0 == rect.u0 && r.v0 == rect.v0)
.expect("the gui sprite animates");
assert_eq!(animation.frame_count(), 4);
// A still texture there stays whole.
let static_badge = atlas.sprites["gui/sprites/icon/static_badge"];
assert_eq!(static_badge.texels((atlas.width, atlas.height)), (16, 32));
}
/// A resource root with one plain item texture and these CIT files (relative path, contents).
fn cit_root(name: &str, files: &[(&str, &str)]) -> PathBuf {
let root =
+3
View File
@@ -63,6 +63,7 @@ pub mod menu_blur;
pub mod mesher;
pub mod minimap;
pub mod mipmap;
pub mod mod_browser;
pub mod mod_list;
pub mod model_library;
pub mod models;
@@ -76,6 +77,7 @@ pub mod pack_browser;
pub mod pack_screen;
pub mod packs;
pub mod pad_cursor;
pub mod pad_glyph;
pub mod par;
pub mod particles;
pub mod poi_sections;
@@ -95,6 +97,7 @@ pub mod settings;
pub mod settings_shell;
pub mod shader_options;
pub mod shader_pack_list;
pub mod shader_ui;
pub mod signs;
pub mod skins;
pub mod sound_engine;
+222 -28
View File
@@ -52,6 +52,8 @@ pub struct LightTables {
/// what `light_chunk` stores per cell, so a cell costs 2 bytes and the
/// flood fill never reads the (large) `cells` table.
info: Vec<u32>,
/// `info`'s low 16 bits.
packed: Vec<u16>,
/// `blocked(type_a, type_b, direction)` as a bit per direction.
block_masks: Vec<u8>,
}
@@ -73,6 +75,7 @@ impl LightTables {
faces: 2,
types: 1,
info: Vec::new(),
packed: Vec::new(),
block_masks: Vec::new(),
};
tables.rebuild_cache();
@@ -101,6 +104,9 @@ impl LightTables {
types <= 4096,
"light tables: {types} cell configurations exceed the 12-bit type field"
);
// The region `light_chunk` floods over is filled with 0, and every air
// cell of a section writes `info` of state 0 over it (`states_of`).
debug_assert_eq!(info[0], 0, "state 0 (air) must hold no light info");
let mut block_masks = vec![0u8; types * types];
for a in 0..types {
for b in 0..types {
@@ -121,6 +127,7 @@ impl LightTables {
}
}
self.types = types;
self.packed = info.iter().map(|&i| i as u16).collect();
self.info = info;
self.block_masks = block_masks;
}
@@ -212,6 +219,7 @@ impl LightTables {
faces,
types: 0,
info: Vec::new(),
packed: Vec::new(),
block_masks: Vec::new(),
};
tables.rebuild_cache();
@@ -276,7 +284,8 @@ struct LightScratch {
sky_queue: Vec<u32>,
present: Vec<bool>,
lowest: Vec<i32>,
ids: Box<[u32; 4096]>,
/// What `states_of` keeps between sections.
section: SectionScratch,
}
impl LightScratch {
@@ -289,7 +298,7 @@ impl LightScratch {
sky_queue: Vec::new(),
present: vec![false; W * W],
lowest: vec![0; W * W],
ids: Box::new([0u32; 4096]),
section: SectionScratch::new(),
}
}
@@ -307,10 +316,132 @@ impl LightScratch {
}
}
/// One section's values mapped to the packed light info, and the palette's
/// info (`states_of`, kept between sections).
struct SectionScratch {
stage: Box<[u16; 4096]>,
stage32: Box<[u32; 4096]>,
info: Vec<u32>,
packed: Vec<u16>,
}
impl SectionScratch {
fn new() -> Self {
Self {
stage: Box::new([0u16; 4096]),
stage32: Box::new([0u32; 4096]),
info: Vec::new(),
packed: Vec::new(),
}
}
}
thread_local! {
static SCRATCH: RefCell<LightScratch> = RefCell::new(LightScratch::new());
}
/// Where a section sits in the 48-wide region.
#[derive(Clone, Copy)]
struct At {
x: usize,
y: usize,
z: usize,
}
impl At {
/// The region index of the section's row `row` (`ly << 4 | lz`), the
/// start of a run of 16 cells along x.
#[inline]
fn row(self, row: usize) -> usize {
(self.y + (row >> 4)) * (W * W) + (self.z + (row & 15)) * W + self.x
}
}
/// Copy one section's block states into the region's `states` as the packed
/// light info `light_chunk` floods over, and seed `queue` (and `block`) with
/// the cells that emit light. The values are mapped to that info in one pass
/// over the section's packed storage into `stage` (index order), then copied a
/// row of 16 along x at a time: the region's rows are 48 apart, so the two
/// cannot be one.
fn states_of(
section: &Section,
tables: &LightTables,
at: At,
states: &mut [u16],
block: &mut [u8],
queue: &mut Vec<u32>,
scratch: &mut SectionScratch,
) {
if section.blocks.bits() == 0 {
// A section of one state (the caller has skipped the air ones): a row
// of the region is that state's info.
let info = tables.info[section.blocks.get(0) as usize];
for ly in 0..16 {
let mut p = at.row(ly << 4);
for _ in 0..16 {
states[p..p + 16].fill(info as u16);
if info > 0xFFFF {
for x in 0..16 {
block[p + x] = (info >> 16) as u8;
queue.push((p + x) as u32);
}
}
p += W;
}
}
return;
}
// The info of the ids the section stores: a list palette's ids are mapped
// once, a global palette's are state ids, which `tables.info` holds. Only
// a section whose palette emits light (an info's high bits) needs the
// emission of each cell.
let (info, packed, emits) = match section.blocks.palette() {
Some(list) => {
scratch.info.clear();
scratch
.info
.extend(list.iter().map(|&id| tables.info[id as usize]));
scratch.packed.clear();
scratch
.packed
.extend(scratch.info.iter().map(|&i| i as u16));
let emits = scratch.info.iter().any(|&i| i > 0xFFFF);
(&scratch.info[..], &scratch.packed[..], emits)
}
None => (&tables.info[..], &tables.packed[..], true),
};
if emits {
section
.blocks
.unpack_ids_mapped(&mut scratch.stage32[..], info);
for ly in 0..16 {
let mut p = at.row(ly << 4);
for lz in 0..16 {
let i = (ly << 8) | (lz << 4);
for x in 0..16 {
let info = scratch.stage32[i + x];
states[p + x] = info as u16;
if info > 0xFFFF {
block[p + x] = (info >> 16) as u8;
queue.push((p + x) as u32);
}
}
p += W;
}
}
return;
}
section
.blocks
.unpack_ids_mapped(&mut scratch.stage[..], packed);
for ly in 0..16 {
let mut p = at.row(ly << 4);
let (rows, _) = scratch.stage[(ly << 8)..(ly << 8) + 256].as_chunks::<16>();
for row in rows {
states[p..p + 16].copy_from_slice(row);
p += W;
}
}
}
/// `region[dz][dx]` holds the chunks around the centre (index 1,1).
/// `sky` is false in dimensions without skylight (the Nether).
pub fn light_chunk(
@@ -356,7 +487,7 @@ fn light_chunk_with_scratch(
let block = &mut scratch.block[..total];
let queue = &mut scratch.queue;
let sky_queue = &mut scratch.sky_queue;
let ids = &mut scratch.ids;
let section_scratch = &mut scratch.section;
for (dz, row) in region.iter().enumerate() {
let z_base = dz * 16;
@@ -367,31 +498,12 @@ fn light_chunk_with_scratch(
if section.is_uniform(0) {
continue;
}
section.unpack(ids);
let y_base = si * 16;
for ly in 0..16 {
let y = y_base + ly;
let y_offset = y * (W * W);
for lz in 0..16 {
let z = z_base + lz;
let p_row = y_offset + z * W + x_base;
let i_row = (ly << 8) | (lz << 4);
for lx in 0..16 {
let id = ids[i_row + lx];
if id == 0 {
continue;
}
let p = p_row + lx;
let info = tables.info[id as usize];
states[p] = info as u16;
let emission = info >> 16;
if emission > 0 {
block[p] = emission as u8;
queue.push(p as u32);
}
}
}
}
let at = At {
x: x_base,
y: si * 16,
z: z_base,
};
states_of(section, tables, at, states, block, queue, section_scratch);
}
}
}
@@ -2195,6 +2307,88 @@ mod tests {
pack_lighting(sections, h, &sky, &block, sky_light)
}
/// 600 states of every dampening, the last 300 of them light sources (a
/// section's palette must hold emitting values too, which take
/// `states_of`'s other pass).
fn info_tables() -> LightTables {
let cells = (0..600u32)
.map(|id| Cell {
dampening: (id % 16) as u8,
emission: if id >= 300 { 14 } else { 0 },
shaped: false,
faces: [EMPTY; 6],
})
.collect();
LightTables::from_parts(cells, vec![false, true, true, true], 2)
}
/// `states_of` writes what walking the section's `unpack`ed state ids
/// writes, for every palette the collector can hold: one value, a list of
/// up to 256 (four to eight bits wide) and the global palette beyond it,
/// with and without a light source in the palette.
#[test]
fn section_states_match_the_unpacked_ids() {
let tables = info_tables();
let at = At { x: 0, y: 0, z: 0 };
let mut ids = Box::new([0u32; 4096]);
// (distinct values, the bits the collector stores them in)
let cases = [
(1, 0),
(3, 4),
(20, 5),
(40, 6),
(80, 7),
(200, 8),
(300, 16),
];
for (distinct, bits) in cases {
for emitting in [false, true] {
let base = if emitting { 300 } else { 0 };
let values: Vec<u32> = (0..4096)
// A pattern that repeats each value the section holds.
.map(|i| base + ((i * 7 + i / 16) % distinct) as u32)
.collect();
let section = Section {
blocks: Section::from_blocks(values.as_slice().try_into().unwrap()),
..Default::default()
};
assert_eq!(
section.blocks.bits(),
bits,
"{distinct} values should be stored in {bits} bits"
);
let mut states = vec![0u16; W * W * 16];
let mut block = vec![0u8; W * W * 16];
let mut queue = Vec::new();
states_of(
&section,
&tables,
at,
&mut states,
&mut block,
&mut queue,
&mut SectionScratch::new(),
);
section.unpack(&mut ids);
let mut want_queue = Vec::new();
for (i, &id) in ids.iter().enumerate() {
let info = tables.info[id as usize];
let p = at.row(i >> 4) + (i & 15);
assert_eq!(
states[p], info as u16,
"{distinct} values, emitting {emitting}: state at {i}"
);
if info > 0xFFFF {
assert_eq!(block[p], (info >> 16) as u8, "emission at {i}");
want_queue.push(p as u32);
}
}
assert_eq!(queue, want_queue, "{distinct} values, emitting {emitting}");
}
}
}
#[test]
fn light_chunk_matches_old_algorithm() {
let t = tables();
+76 -20
View File
@@ -401,6 +401,7 @@ fn visibility(pad: &Padded, info: &[Info]) -> u64 {
}
/// The section's cells that are not `isSolidRender`, by row (`y << 4 | z`, bit
/// x), from the padded rows of `MeshScratch::solid_rows`.
#[cfg(test)]
fn open_rows(solid: &[u32]) -> [u16; 256] {
std::array::from_fn(|r| {
let (y, z) = ((r >> 4) as i32, (r & 15) as i32);
@@ -977,10 +978,11 @@ impl Padded {
// The section itself is unpacked in one pass and copied row by row;
// the border cells of the neighbouring sections are read in runs, from
// sections found once per layer.
own.fill(0);
let center = region[1][1];
if let Some(c) = center {
c.sections[sy as usize].unpack(own);
} else {
own.fill(0);
}
for y in -B..16 + B {
let wy = base_y + y;
@@ -1065,6 +1067,18 @@ fn fill_run(
},
}
}
// Direction order is the same as Direction::ALL; all values are evaluated at compile time.
const DIRECTIONS: [(Direction, [i32; 3], u8); 6] = [
direction_info(Direction::Down),
direction_info(Direction::Up),
direction_info(Direction::North),
direction_info(Direction::South),
direction_info(Direction::West),
direction_info(Direction::East),
];
const fn direction_info(d: Direction) -> (Direction, [i32; 3], u8) {
(d, d.normal(), d.opposite() as u8)
}
/// Per-worker temporary arrays. They never hold resource-pack references or
/// geometry still owned by the renderer, and total about 150 KiB per worker
/// (the leaf mask adds 8,000 cells, depths and a queue).
@@ -1080,21 +1094,63 @@ pub struct MeshScratch {
/// Fills `rows` with the padded section's `isSolidRender` blocks, one bit each (`MeshScratch::solid_rows`).
fn solid_rows(pad: &Padded, solid: &[bool], rows: &mut Vec<u32>) {
rows.clear();
rows.extend(pad.blocks.as_chunks::<P>().0.iter().map(|row| {
row.iter().enumerate().fold(0u32, |bits, (x, &id)| {
bits | u32::from(solid[id as usize]) << x
})
}));
for row in pad.blocks.as_chunks::<P>().0 {
let mut bits = 0;
for (x, &id) in row.iter().enumerate() {
bits |= u32::from(solid[id as usize]) << x;
}
rows.push(bits);
}
}
/// Whether the block at `(x, y, z)` (section-local) and its six neighbours
/// are all `isSolidRender`: none of its faces can show.
#[inline]
fn hidden(rows: &[u32], x: i32, y: i32, z: i32) -> bool {
let row = |y: i32, z: i32| rows[((y + B) as usize) * P + (z + B) as usize];
let r = row(y, z);
let around =
r & r << 1 & r >> 1 & row(y - 1, z) & row(y + 1, z) & row(y, z - 1) & row(y, z + 1);
around >> (x + B) & 1 != 0
/// Open and fully surrounded cells in each inner row.
fn row_masks(rows: &[u32]) -> ([u16; 256], [u16; 256]) {
let mut open = [0; 256];
let mut hidden = [0; 256];
for y in 0..16 {
for z in 0..16 {
let i = (y + B as usize) * P + z + B as usize;
let r = rows[i];
let around =
r & r << 1 & r >> 1 & rows[i - P] & rows[i + P] & rows[i - 1] & rows[i + 1];
open[y * 16 + z] = !(r >> B) as u16;
hidden[y * 16 + z] = (around >> B) as u16;
}
}
(open, hidden)
}
#[cfg(test)]
#[test]
fn row_masks_match_cell_definition() {
let mut seed = 42u32;
for _ in 0..100 {
let rows: Vec<u32> = (0..P * P)
.map(|_| {
seed ^= seed << 13;
seed ^= seed >> 17;
seed ^= seed << 5;
seed & ((1 << P) - 1)
})
.collect();
let (open, hidden) = row_masks(&rows);
for y in 0..16 {
for z in 0..16 {
let cell = |x: usize, y: usize, z: usize| (rows[y * P + z] >> x) & 1 != 0;
for x in 0..16 {
let (x, py, pz) = (x + B as usize, y + B as usize, z + B as usize);
let occupied = cell(x, py, pz);
let enclosed = occupied
&& cell(x - 1, py, pz)
&& cell(x + 1, py, pz)
&& cell(x, py - 1, pz)
&& cell(x, py + 1, pz)
&& cell(x, py, pz - 1)
&& cell(x, py, pz + 1);
assert_eq!((open[y * 16 + z] >> (x - B as usize)) & 1 != 0, !occupied);
assert_eq!((hidden[y * 16 + z] >> (x - B as usize)) & 1 != 0, enclosed);
}
}
}
}
}
#[cfg(test)]
mod leaf_culling_tests;
@@ -1463,7 +1519,8 @@ impl Mesher<'_> {
let pad = &scratch.padded;
solid_rows(pad, &self.tables.solid, &mut scratch.solid_rows);
let opaque = &scratch.solid_rows;
out.visibility = visibility_rows(&open_rows(opaque));
let (open, hidden) = row_masks(opaque);
out.visibility = visibility_rows(&open);
let base_y = center.min_y() + sy * 16;
if self.leaf_culling.is_interior() {
scratch
@@ -1497,7 +1554,7 @@ impl Mesher<'_> {
out.lights
.push(([center.x * 16 + x, base_y + y, center.z * 16 + z], id));
}
if hidden(opaque, x, y, z) {
if hidden[(y * 16 + z) as usize] >> x & 1 != 0 {
continue;
}
if state.fluid.kind() != FluidKind::Empty {
@@ -1538,12 +1595,11 @@ impl Mesher<'_> {
let interior_leaf = state.leaves
&& self.leaf_culling.is_interior()
&& scratch.leaf.interior(self.leaf_culling, x, y, z);
for d in Direction::ALL {
let [dx, dy, dz] = d.normal();
for (d, [dx, dy, dz], opposite) in DIRECTIONS {
let neighbor = pad.blocks[pi(x + dx, y + dy, z + dz)];
// `should_render_face`'s first test, without its lookups: a
// full occlusion face toward this block hides the face.
if full_faces[neighbor as usize] >> d.opposite() as u8 & 1 != 0 {
if full_faces[neighbor as usize] >> opposite & 1 != 0 {
continue;
}
let other = &info[neighbor as usize];
@@ -229,7 +229,7 @@ pub(super) fn tesselate(
let kind = me.fluid.kind();
let same = |s: &Info| s.fluid.kind().same_family(kind);
let neighbor = |d: Direction| {
let [dx, dy, dz] = d.normal();
let [dx, dy, dz] = super::DIRECTIONS[d as usize].1;
at([x + dx, y + dy, z + dz])
};
let (down, up) = (neighbor(Direction::Down), neighbor(Direction::Up));
@@ -238,12 +238,11 @@ pub(super) fn tesselate(
let render_down =
should_render(Direction::Down) && !occluded_by(Direction::Down, MAX_HEIGHT, down);
let sides = [
Direction::North,
Direction::South,
Direction::West,
Direction::East,
]
.map(should_render);
should_render(Direction::North),
should_render(Direction::South),
should_render(Direction::West),
should_render(Direction::East),
];
if !render_up && !render_down && !sides.iter().any(|&s| s) {
return;
}
@@ -356,9 +355,9 @@ pub(super) fn tesselate(
fluid_face(color, face)
};
let vertex = |(pos, uv)| TerrainVertex::new(pos, uv, layer, color, light);
target.extend(v.map(vertex));
target.extend([vertex(v[0]), vertex(v[1]), vertex(v[2]), vertex(v[3])]);
if back {
target.extend([v[0], v[3], v[2], v[1]].map(vertex));
target.extend([vertex(v[0]), vertex(v[3]), vertex(v[2]), vertex(v[1])]);
}
};
let (fx, fy, fz) = (x as f32, y as f32, z as f32);
@@ -0,0 +1,189 @@
//! What Options > Mods > Browse mods shows (docs/design/mod-hotload.md): the
//! registry's content mods this game installs, a search over them, and the one
//! whose page is open. The game fills it from a worker
//! (`horizon_client::mod_registry`, `game/mod_browser.rs`); the options screen
//! only reads it and asks for installs through its events.
/// One project of the registry, as the list and its page show it.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Listing {
pub id: String,
pub name: String,
pub summary: String,
/// Markdown, shown as plain text.
pub description: String,
pub authors: Vec<String>,
pub license: String,
pub categories: Vec<String>,
pub tags: Vec<String>,
/// The version this game would install, its date and changelog.
pub version: String,
pub published: String,
pub changelog: String,
pub downloads: u64,
/// The project's page on the website.
pub page: String,
/// The version installed in this game folder, if any.
pub installed: Option<String>,
}
impl Listing {
/// Whether a search for `query` (already lower case) finds it: its name,
/// id, summary, authors, categories or tags.
pub fn matches(&self, query: &str) -> bool {
let query = query.trim();
query.is_empty()
|| [&self.name, &self.id, &self.summary]
.into_iter()
.chain(&self.authors)
.chain(&self.categories)
.chain(&self.tags)
.any(|field| field.to_lowercase().contains(query))
}
/// Installed, but not the version the registry offers now.
pub fn has_update(&self) -> bool {
self.installed
.as_ref()
.is_some_and(|installed| *installed != self.version)
}
}
/// Where the browser is.
#[derive(Clone, Debug, Default, PartialEq)]
pub enum Status {
/// Not asked yet.
#[default]
Idle,
/// The index is being read.
Loading,
/// The projects are listed.
Ready,
/// The index couldn't be read: why, in English (shown inside a translated line).
Failed(String),
}
#[derive(Clone, Debug, Default)]
pub struct ModBrowser {
pub status: Status,
pub listings: Vec<Listing>,
/// The project an install runs for.
pub installing: Option<String>,
/// The last install's outcome, already translated, for the page it ran on.
pub message: Option<(String, bool)>,
}
impl ModBrowser {
/// The listings a search for `query` finds, by index into `listings`.
pub fn found(&self, query: &str) -> Vec<usize> {
let query = query.to_lowercase();
(0..self.listings.len())
.filter(|&i| self.listings[i].matches(&query))
.collect()
}
pub fn listing(&self, id: &str) -> Option<&Listing> {
self.listings.iter().find(|l| l.id == id)
}
}
/// A project's markdown description as lines of plain text for the page:
/// heading marks, emphasis and list bullets dropped, links shown as their text,
/// blank lines and fences left out.
pub fn plain_lines(markdown: &str) -> Vec<String> {
let mut out = Vec::new();
for line in markdown.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with("```") || line.starts_with("<!--") {
continue;
}
let line = line.trim_start_matches('#').trim_start();
let line = match line.strip_prefix("- ").or_else(|| line.strip_prefix("* ")) {
Some(rest) => format!("• {rest}"),
None => line.to_owned(),
};
let line = links_as_text(&line).replace("**", "").replace('`', "");
if !line.is_empty() {
out.push(line);
}
}
out
}
/// `[text](url)` → `text`, `![alt](url)` → nothing.
fn links_as_text(line: &str) -> String {
let mut out = String::with_capacity(line.len());
let mut rest = line;
while let Some(open) = rest.find('[') {
let Some(close) = rest[open..].find("](").map(|c| open + c) else {
break;
};
let Some(end) = rest[close..].find(')').map(|e| close + e) else {
break;
};
let image = open > 0 && rest[..open].ends_with('!');
out.push_str(&rest[..if image { open - 1 } else { open }]);
if !image {
out.push_str(&rest[open + 1..close]);
}
rest = &rest[end + 1..];
}
out.push_str(rest);
out
}
#[cfg(test)]
mod tests {
use super::*;
fn listing(id: &str, tags: &[&str]) -> Listing {
Listing {
id: id.into(),
name: id.to_uppercase(),
summary: format!("All about {id}"),
tags: tags.iter().map(|t| (*t).to_owned()).collect(),
version: "1.0".into(),
..Listing::default()
}
}
#[test]
fn a_search_finds_names_tags_and_categories_in_any_case() {
let mut pipes = listing("pipes", &["tech"]);
pipes.categories = vec!["technology".into()];
let browser = ModBrowser {
listings: vec![listing("ruby", &["gems"]), pipes],
..ModBrowser::default()
};
assert_eq!(browser.found(""), [0, 1]);
assert_eq!(browser.found("RUBY"), [0]);
assert_eq!(browser.found("gems"), [0]);
assert_eq!(browser.found("techno"), [1]);
assert!(browser.found("nothing like it").is_empty());
}
#[test]
fn an_update_is_another_version_than_the_installed_one() {
let mut l = listing("ruby", &[]);
assert!(!l.has_update());
l.installed = Some("1.0".into());
assert!(!l.has_update());
l.installed = Some("0.9".into());
assert!(l.has_update());
}
#[test]
fn markdown_reads_as_plain_lines() {
let text = "# Ruby\n\nAdds **rubies**. See [the wiki](https://x.y).\n\n![shot](https://x.y/a.png)\n- one\n* two\n```\ncode\n```\n";
assert_eq!(
plain_lines(text),
[
"Ruby",
"Adds rubies. See the wiki.",
"• one",
"• two",
"code"
]
);
}
}
+3 -3
View File
@@ -93,7 +93,7 @@ pub(crate) fn prepare_with_models(
models: &crate::model_library::ParsedModels,
) -> Result<PreparedModels> {
let facts: std::collections::BTreeMap<String, Value> =
serde_json::from_slice(&std::fs::read(root.join("model-states.json"))?)?;
horizon_world::json_file::read(&root.join("model-states.json"))?;
let blocks: Vec<&str> = facts
.keys()
.filter(|name| registry.default_state(name).is_some())
@@ -158,11 +158,11 @@ pub fn load_visibility(
collision::CachedCollisionRegistry,
registry::BlockTableFile,
};
use std::{collections::BTreeMap, fs};
use std::collections::BTreeMap;
let file = BlockTableFile::open(&root.join("occlusion.nbt"), registry)?;
let occlusion = CachedCollisionRegistry::from_table(file.table("occlusion")?)?;
let raw: BTreeMap<String, Value> =
serde_json::from_slice(&fs::read(root.join("render-definitions.json"))?)?;
horizon_world::json_file::read(&root.join("render-definitions.json"))?;
let definitions = raw
.into_iter()
.map(|(name, v)| {
@@ -23,6 +23,7 @@ use std::{
collections::{BTreeMap, BTreeSet},
fs,
path::Path,
sync::OnceLock,
};
pub struct BakedFace {
@@ -51,8 +52,11 @@ type VariantKey = (String, [u8; 3], bool);
pub struct StateModels {
/// Each definition's choices, a variant standing as its index in `baked`.
choices: Vec<CompiledChoices<u32>>,
/// All baked alternatives per definition, invalidated before mutable model access.
bounds: OnceLock<Vec<Option<[[f32; 3]; 2]>>>,
/// By state id: its definition in `choices` and the models it selects.
states: Vec<Option<(usize, SelectedModels)>>,
single_models: Vec<Option<u32>>,
/// Every transformed model once, with the variant it was baked from.
baked: Vec<(Variant, BakedModel)>,
/// Standalone models by id: their index in `baked`.
@@ -354,10 +358,19 @@ impl StateModels {
baked.push((variant, model));
}
// Every variant of every definition was baked above.
let choices = choices.iter().map(|c| c.map(|v| index[&key(v)])).collect();
let choices: Vec<_> = choices.iter().map(|c| c.map(|v| index[&key(v)])).collect();
let single_models = states
.iter()
.map(|state| {
let (choice, selected) = state.as_ref()?;
choices[*choice].single_variant(selected).copied()
})
.collect();
Ok(Self {
choices,
bounds: OnceLock::new(),
states,
single_models,
baked,
standalone: BTreeMap::new(),
})
@@ -408,6 +421,7 @@ impl StateModels {
}
/// Every baked model with the variant it was baked from, to change its faces.
pub fn models_mut(&mut self) -> impl Iterator<Item = (&Variant, &mut BakedModel)> {
self.bounds.take();
self.baked
.iter_mut()
.map(|(variant, model)| (&*variant, model))
@@ -432,6 +446,37 @@ impl StateModels {
let model = self.choices[*choice].particle_variant(selected)?;
self.baked[*model as usize].1.particle
}
/// Envelope of every weighted/multipart alternative in the state's definition.
/// Includes transformed resource-pack faces outside the unit cube. Empty or
/// non-finite geometry has no usable envelope and keeps the original draw path.
pub fn render_bounds(&self, state: BlockStateId) -> Option<[[f32; 3]; 2]> {
let (definition, _) = self.state(state)?;
let bounds = self.bounds.get_or_init(|| {
self.choices
.iter()
.map(|choices| {
let mut lo = glam::Vec3::splat(f32::INFINITY);
let mut hi = glam::Vec3::splat(f32::NEG_INFINITY);
let mut valid = true;
// `map` visits every choice, without consuming the render RNG.
choices.map(|&index| {
for face in &self.baked[index as usize].1.faces {
for p in face.quad.positions {
let p = glam::Vec3::from_array(p);
valid &= p.is_finite();
lo = lo.min(p);
hi = hi.max(p);
}
}
});
(valid && lo.is_finite() && hi.is_finite())
.then_some([lo.to_array(), hi.to_array()])
})
.collect()
});
bounds.get(*definition).copied().flatten()
}
pub fn collect_seeded(
&self,
state: BlockStateId,
@@ -452,6 +497,10 @@ impl StateModels {
out: &mut Vec<&'a BakedModel>,
) -> Result<()> {
out.clear();
if let Some(Some(model)) = self.single_models.get(state.0 as usize) {
out.push(&self.baked[*model as usize].1);
return Ok(());
}
self.each_seeded(state, position_seed(position), |(_, model)| out.push(model))
}
fn each_seeded<'a>(
@@ -619,3 +668,7 @@ mod tests {
Ok(())
}
}
#[cfg(test)]
#[path = "catalog/bounds_tests.rs"]
mod bounds_tests;
@@ -0,0 +1,115 @@
use super::*;
fn catalog(definition: &str) -> StateModels {
let definition = BlockStateDefinition::from_json(definition).unwrap();
let choices = CompiledChoices::new(&definition)
.unwrap()
.map(|v| if v.model == "horizon:a" { 0 } else { 1 });
let baked = [
([-16, 0, 0], [16, 16, 16], [0, 0, 0]),
([0, -8, -16], [32, 32, 16], [0, 90, 0]),
]
.into_iter()
.enumerate()
.map(|(i, (from, to, turns))| {
let faces: serde_json::Map<String, Value> =
["up", "down", "north", "south", "east", "west"]
.into_iter()
.map(|name| {
(
name.to_owned(),
serde_json::json!({"texture":"#all"}),
)
})
.collect();
let json =
serde_json::json!({"textures":{"all":"horizon:block/test"},"elements":[{"from":from,"to":to,"faces":faces}]}).to_string();
let model = horizon_assets::model::CuboidModel::from_json(&json).unwrap();
let name = format!("horizon:{}", if i == 0 { "a" } else { "b" });
let library =
horizon_assets::resolve::ModelLibrary::new(BTreeMap::from([(name.clone(), model)]))
.unwrap();
let baked = bake_resolved(
Path::new("/nonexistent-horizon-bounds-fixture"),
&mut Sprites::default(),
&library.resolve(&name).unwrap(),
turns,
false,
)
.unwrap();
(
Variant {
model: name,
rotations: turns,
uv_lock: false,
},
baked,
)
})
.collect();
StateModels {
choices: vec![choices],
states: vec![Some((0, SelectedModels::Variant(0)))],
baked,
..StateModels::default()
}
}
#[test]
fn envelopes_cover_weighted_and_multipart_geometry_and_mutation_invalidates_them() {
for (definition, selected) in [
(
r#"{"variants":{"": [{"model":"horizon:a","weight":1},{"model":"horizon:b","weight":3}]}}"#,
SelectedModels::Variant(0),
),
(
r#"{"multipart":[{"apply":{"model":"horizon:a"}},{"apply":{"model":"horizon:b"}}]}"#,
SelectedModels::Multipart(vec![0, 1]),
),
] {
let mut models = catalog(definition);
models.states[0] = Some((0, selected));
let [lo, hi] = models.render_bounds(BlockStateId(0)).unwrap();
assert!(
lo[0] <= -1.0 && hi[1] >= 2.0,
"pack geometry exceeds a unit cube"
);
let mut variants = BTreeSet::new();
for i in 0..128 {
let seed = position_seed(BlockPos::new(i * 13, i % 31, -i * 23));
for (variant, model) in models.collect_seeded(BlockStateId(0), seed).unwrap() {
variants.insert(variant.model.clone());
for point in model.faces.iter().flat_map(|f| f.quad.positions) {
assert!((0..3).all(|axis| point[axis] >= lo[axis] && point[axis] <= hi[axis]));
}
}
}
assert_eq!(variants.len(), 2);
models.models_mut().next().unwrap().1.faces[0]
.quad
.positions[0] = [100.0, 2.0, -5.0];
let [lo, hi] = models.render_bounds(BlockStateId(0)).unwrap();
assert_eq!(hi[0], 100.0);
assert_eq!(lo[2], -5.0);
// A resource replacement owns a fresh catalog rather than the previous cache.
let replacement = catalog(r#"{"variants":{"":{"model":"horizon:a"}}}"#);
assert_eq!(
replacement.render_bounds(BlockStateId(0)),
Some([[-1.0, 0.0, 0.0], [1.0, 1.0, 1.0]])
);
models.models_mut().next().unwrap().1.faces[0]
.quad
.positions[0][0] = f32::NAN;
assert!(models.render_bounds(BlockStateId(0)).is_none());
}
assert!(
StateModels::default()
.render_bounds(BlockStateId(0))
.is_none()
);
let mut empty = catalog(r#"{"variants":{"":{"model":"horizon:a"}}}"#);
for (_, model) in empty.models_mut() {
model.faces.clear();
}
assert!(empty.render_bounds(BlockStateId(0)).is_none());
}
File diff suppressed because it is too large. Load diff
+30 -3
View File
@@ -899,9 +899,24 @@ fn merge(kind: Merge, layers: &[Vec<u8>]) -> Option<Vec<u8>> {
.iter()
// SoundManager skips a sounds.json Gson rejects, whole.
.filter(|b| kind != Merge::Sounds || crate::sound_events::is_valid_file(b))
.filter_map(|b| match serde_json::from_slice(b) {
Ok(Value::Object(m)) => Some(m),
_ => None,
.filter_map(|b| {
match serde_json::from_slice(b) {
Ok(Value::Object(m)) => Some(m),
// `Language.loadFromJson` reads with Gson's lenient reader, so
// a lang file with `#` comments still counts; a file that
// strict JSON accepts keeps its bytes as they are.
_ if kind == Merge::Lang => {
let mut strings = std::collections::HashMap::new();
horizon_client_core::lang::read_lang_file(b, &mut strings);
Some(
strings
.into_iter()
.map(|(k, v)| (k, Value::String(v)))
.collect(),
)
}
_ => None,
}
})
.collect();
let list = |key: &str, layers: &mut dyn Iterator<Item = &Map<String, Value>>| {
@@ -1424,6 +1439,18 @@ mod tests {
parse(lang.unwrap()),
serde_json::json!({"a":"vanilla a","b":"pack b"})
);
// A layer with `#` comments is read leniently, not dropped.
let lenient = merge(
Merge::Lang,
&[
json(r#"{"a":"vanilla a"}"#),
json("# a pack comment\n{\"b\":\"pack b\", # trailing\n\"a\":\"pack a\"}"),
],
);
assert_eq!(
parse(lenient.unwrap()),
serde_json::json!({"a":"pack a","b":"pack b"})
);
// SoundManager: sounds add up; `replace` starts the event over.
let sounds = merge(
Merge::Sounds,
+11 -39
View File
@@ -1,43 +1,13 @@
//! The controller's menu cursor in inventories: a crosshair whose centre is
//! the point it clicks. Horizon's own art (pad_cursor.png).
//! the point it clicks. Horizon's own art, packed with the button glyphs (`pad_glyph`).
/// The atlas sprite.
pub const SPRITE: &str = "horizon/pad_cursor";
/// The texture's size in texels, and the size it's drawn at in GUI pixels
/// (an item's).
pub const SIZE: u32 = 32;
pub const SPRITE: &str = "horizon:gui/controller/crosshair_inv";
/// The size it's drawn at in GUI pixels (an item's); its texture is twice that.
pub const DRAWN: f32 = 16.0;
const PNG: &[u8] = include_bytes!("pad_cursor.png");
/// The texture as `SIZE` square RGBA texels.
pub fn pixels() -> Vec<u8> {
decode(PNG).expect("pad_cursor.png is a 32x32 PNG")
}
fn decode(bytes: &[u8]) -> Option<Vec<u8>> {
let mut decoder = png::Decoder::new(std::io::Cursor::new(bytes));
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
let mut reader = decoder.read_info().ok()?;
let info = reader.info();
if (info.width, info.height) != (SIZE, SIZE) {
return None;
}
let mut buf = vec![0; reader.output_buffer_size()?];
let frame = reader.next_frame(&mut buf).ok()?;
let channels = frame.color_type.samples();
Some(
buf.chunks(channels)
.take((SIZE * SIZE) as usize)
.flat_map(|p| match channels {
4 => [p[0], p[1], p[2], p[3]],
3 => [p[0], p[1], p[2], 255],
2 => [p[0], p[0], p[0], p[1]],
_ => [p[0], p[0], p[0], 255],
})
.collect(),
)
}
pub(crate) const PNG: &[u8] =
include_bytes!("../../horizon-client/assets/horizon/textures/gui/controller/crosshair_inv.png");
/// Where to draw the cursor (top-left, GUI pixels) so its centre lands on
/// the GUI point `at`.
@@ -51,10 +21,12 @@ mod tests {
#[test]
fn the_cursor_is_a_crosshair_centred_on_its_point() {
let px = pixels();
assert_eq!(px.len(), (SIZE * SIZE * 4) as usize);
let alpha = |x: u32, y: u32| px[((y * SIZE + x) * 4 + 3) as usize];
let middle = (SIZE / 2 - 1)..=(SIZE / 2);
let (_, w, h, px) = crate::pad_glyph::images()
.find(|i| i.0 == SPRITE)
.expect("the cursor is packed");
assert_eq!((w as f32, h as f32), (DRAWN * 2.0, DRAWN * 2.0));
let alpha = |x: u32, y: u32| px[((y * w + x) * 4 + 3) as usize];
let middle = (w / 2 - 1)..=(w / 2);
assert!(
middle
.clone()
@@ -0,0 +1,386 @@
//! A controller button as the player sees it: Horizon's own art where the pad's family has one
//! (`assets/horizon/textures/gui/controller/`, which a resource pack can replace), else the label
//! printed on the pad, in its colour, on a dark key. Whatever shows a button draws and measures it
//! here; `Button::label` stays for plain text and narration.
use crate::gamepad::{Button, GlyphStyle};
use crate::hud::{HudAtlas, HudBatch, rgba};
use crate::pad_cursor;
/// The art's height as drawn, in GUI pixels. Its texture is twice that, so it maps pixel for pixel
/// at GUI scale 2 and to whole pixels at 4.
const DRAWN_HEIGHT: f32 = 16.0;
/// The clear edge the art has all round.
const MARGIN: f32 = 2.0;
/// How tall a glyph shows, art or key: the text line and 2 above and below it.
pub const HEIGHT: f32 = DRAWN_HEIGHT - 2.0 * MARGIN;
/// How wide a face button's art shows; a declared button's label leaves it room.
pub const FACE_WIDTH: f32 = 12.0;
/// A picture of a button in a family (Auto reads as Xbox, as `Button::label` has it). Every family
/// has its own file for every button, some of them a copy of another's until theirs is drawn.
struct Art {
family: GlyphStyle,
button: Button,
/// The atlas sprite: where a pack's own file lands.
sprite: &'static str,
png: &'static [u8],
}
macro_rules! art {
($($family:ident $button:ident $file:literal,)*) => {
&[$(Art {
family: GlyphStyle::$family,
button: Button::$button,
sprite: concat!("horizon:gui/controller/", $file),
png: include_bytes!(concat!(
"../../horizon-client/assets/horizon/textures/gui/controller/",
$file,
".png"
)),
},)*]
};
}
/// All the art. A new picture is one line here and its file: a button not listed is text, and a
/// picture is measured from its texture, so it can be as wide as it needs.
static ART: &[Art] = art![
Xbox South "xb_a",
Xbox East "xb_b",
Xbox West "xb_x",
Xbox North "xb_y",
Xbox LeftBumper "xb_lb",
Xbox RightBumper "xb_rb",
Xbox LeftTrigger "xb_lt",
Xbox RightTrigger "xb_rt",
Xbox Select "xb_view",
Xbox Start "xb_menu",
Xbox LeftStick "xb_l3",
Xbox RightStick "xb_r3",
Xbox DPadUp "xb_up",
Xbox DPadDown "xb_down",
Xbox DPadLeft "xb_left",
Xbox DPadRight "xb_right",
PlayStation South "psx_cross",
PlayStation East "psx_circle",
PlayStation West "psx_square",
PlayStation North "psx_triangle",
PlayStation LeftBumper "psx_l1",
PlayStation RightBumper "psx_r1",
PlayStation LeftTrigger "psx_l2",
PlayStation RightTrigger "psx_r2",
PlayStation Select "psx_create",
PlayStation Start "psx_options",
PlayStation LeftStick "psx_l3",
PlayStation RightStick "psx_r3",
PlayStation DPadUp "psx_up",
PlayStation DPadDown "psx_down",
PlayStation DPadLeft "psx_left",
PlayStation DPadRight "psx_right",
Nintendo South "nx_b",
Nintendo East "nx_a",
Nintendo West "nx_y",
Nintendo North "nx_x",
Nintendo LeftBumper "nx_l",
Nintendo RightBumper "nx_r",
Nintendo LeftTrigger "nx_zl",
Nintendo RightTrigger "nx_zr",
Nintendo Select "nx_minus",
Nintendo Start "nx_plus",
Nintendo LeftStick "nx_l3",
Nintendo RightStick "nx_r3",
Nintendo DPadUp "nx_up",
Nintendo DPadDown "nx_down",
Nintendo DPadLeft "nx_left",
Nintendo DPadRight "nx_right",
];
fn find(button: Button, style: GlyphStyle) -> Option<&'static Art> {
let family = match style {
GlyphStyle::Auto => GlyphStyle::Xbox,
style => style,
};
ART.iter()
.find(|art| art.family == family && art.button == button)
}
/// The sprite of a button in a family, if it has one.
pub fn sprite(button: Button, style: GlyphStyle) -> Option<&'static str> {
find(button, style).map(|art| art.sprite)
}
/// The sprite this atlas can draw for the button; none (the boot screen's atlas) means text.
pub fn art(atlas: &HudAtlas, button: Button, style: GlyphStyle) -> Option<&'static str> {
sprite(button, style).filter(|name| atlas.sprites.contains_key(*name))
}
/// How wide the art shows: half its texture (IHDR's width, in a PNG's first bytes) less its edge.
/// A pack's own texture of another size is drawn in the same place.
fn art_width(art: &Art) -> f32 {
let texels = u32::from_be_bytes([art.png[16], art.png[17], art.png[18], art.png[19]]);
texels as f32 / 2.0 - 2.0 * MARGIN
}
/// How wide the glyph shows.
pub fn width(atlas: &HudAtlas, button: Button, style: GlyphStyle) -> f32 {
match find(button, style).filter(|a| atlas.sprites.contains_key(a.sprite)) {
Some(art) => art_width(art),
None => HudBatch::text_width(atlas, button.label(style)).ceil() + 4.0,
}
}
/// What the glyph covers with its label's text at `(x, y)`: `[x, y, w, h]`.
pub fn rect(atlas: &HudAtlas, button: Button, style: GlyphStyle, x: f32, y: f32) -> [f32; 4] {
[x - 1.0, y - 2.0, width(atlas, button, style), HEIGHT]
}
/// Draw the glyph over the 8 pixel text line at `(x, y)`, filling [`rect`]. A dark key backs text
/// only: the art brings its own.
pub fn draw(
batch: &mut HudBatch,
atlas: &HudAtlas,
button: Button,
style: GlyphStyle,
x: f32,
y: f32,
) {
let [left, top, wide, tall] = rect(atlas, button, style, x, y);
if let Some(name) = art(atlas, button, style) {
batch.sprite_scaled(
atlas,
name,
left - MARGIN,
top - MARGIN,
wide + 2.0 * MARGIN,
tall + 2.0 * MARGIN,
u32::MAX,
);
} else {
let [r, g, b] = button.color(style);
batch.fill(atlas, left, top, wide, tall, rgba(40, 40, 40, 255));
batch.text(
atlas,
button.label(style),
x + 1.0,
y,
rgba(r, g, b, 255),
false,
);
}
}
/// Horizon's art for the atlas: each `(sprite, width, height, RGBA texels)`.
pub fn images() -> impl Iterator<Item = (String, u32, u32, Vec<u8>)> {
ART.iter()
.map(|art| (art.sprite, art.png))
.chain([(pad_cursor::SPRITE, pad_cursor::PNG)])
.map(|(name, png)| {
let (w, h, pixels) = decode(png).expect("Horizon's controller art is PNG");
(name.to_owned(), w, h, pixels)
})
}
fn decode(bytes: &[u8]) -> Option<(u32, u32, Vec<u8>)> {
let mut decoder = png::Decoder::new(std::io::Cursor::new(bytes));
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
let mut reader = decoder.read_info().ok()?;
let (w, h) = (reader.info().width, reader.info().height);
let mut buf = vec![0; reader.output_buffer_size()?];
let frame = reader.next_frame(&mut buf).ok()?;
let channels = frame.color_type.samples();
let pixels = buf
.chunks(channels)
.take((w * h) as usize)
.flat_map(|p| match channels {
4 => [p[0], p[1], p[2], p[3]],
3 => [p[0], p[1], p[2], 255],
2 => [p[0], p[0], p[0], p[1]],
_ => [p[0], p[0], p[0], 255],
})
.collect();
Some((w, h, pixels))
}
#[cfg(test)]
mod tests {
use super::*;
fn atlas() -> HudAtlas {
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap()
}
fn file(button: Button, style: GlyphStyle) -> Option<&'static str> {
sprite(button, style).and_then(|name| name.strip_prefix("horizon:gui/controller/"))
}
/// The batch's quads as `[left, top, right, bottom]`.
fn quads(batch: &HudBatch) -> Vec<[f32; 4]> {
batch
.vertices
.as_chunks::<6>()
.0
.iter()
.map(|quad| {
let xs = quad.iter().map(|v| v.pos[0]);
let ys = quad.iter().map(|v| v.pos[1]);
[
xs.clone().fold(f32::MAX, f32::min),
ys.clone().fold(f32::MAX, f32::min),
xs.fold(f32::MIN, f32::max),
ys.fold(f32::MIN, f32::max),
]
})
.collect()
}
#[test]
fn xbox_and_auto_show_the_letters_and_shoulders() {
for style in [GlyphStyle::Xbox, GlyphStyle::Auto] {
for (button, expected) in [
(Button::South, "xb_a"),
(Button::East, "xb_b"),
(Button::West, "xb_x"),
(Button::North, "xb_y"),
(Button::LeftBumper, "xb_lb"),
(Button::RightBumper, "xb_rb"),
(Button::LeftTrigger, "xb_lt"),
(Button::RightTrigger, "xb_rt"),
(Button::Select, "xb_view"),
(Button::Start, "xb_menu"),
] {
assert_eq!(file(button, style), Some(expected));
}
}
}
#[test]
fn playstation_shows_its_symbols_by_position_and_its_own_shoulders() {
let style = GlyphStyle::PlayStation;
assert_eq!(file(Button::South, style), Some("psx_cross"));
assert_eq!(file(Button::East, style), Some("psx_circle"));
assert_eq!(file(Button::West, style), Some("psx_square"));
assert_eq!(file(Button::North, style), Some("psx_triangle"));
assert_eq!(file(Button::LeftBumper, style), Some("psx_l1"));
assert_eq!(file(Button::RightTrigger, style), Some("psx_r2"));
assert_eq!(file(Button::Select, style), Some("psx_create"));
}
#[test]
fn nintendo_shows_what_its_pad_prints_at_each_position() {
let style = GlyphStyle::Nintendo;
assert_eq!(file(Button::South, style), Some("nx_b"));
assert_eq!(file(Button::East, style), Some("nx_a"));
assert_eq!(file(Button::West, style), Some("nx_y"));
assert_eq!(file(Button::North, style), Some("nx_x"));
assert_eq!(file(Button::LeftTrigger, style), Some("nx_zl"));
assert_eq!(file(Button::Start, style), Some("nx_plus"));
}
#[test]
fn every_family_has_its_own_file_for_every_button() {
for (style, prefix) in [
(GlyphStyle::Xbox, "xb_"),
(GlyphStyle::PlayStation, "psx_"),
(GlyphStyle::Nintendo, "nx_"),
] {
for button in Button::ALL {
let name = file(button, style).unwrap();
assert!(name.starts_with(prefix), "{button:?} {name}");
}
}
}
#[test]
fn every_sprite_has_its_texture_at_twice_gui_size() {
let images: Vec<_> = images().collect();
assert_eq!(images.len(), ART.len() + 1, "the art and the cursor");
for (name, w, h, pixels) in &images {
assert_eq!(pixels.len(), (w * h * 4) as usize, "{name}");
assert_eq!(*h as f32, DRAWN_HEIGHT * 2.0, "{name}");
}
for art in ART {
let (_, w, ..) = images.iter().find(|i| i.0 == art.sprite).unwrap();
assert_eq!(art_width(art), *w as f32 / 2.0 - 4.0, "{}", art.sprite);
}
}
#[test]
fn no_button_has_two_pictures_in_a_family() {
for (i, art) in ART.iter().enumerate() {
assert!(
!ART[..i]
.iter()
.any(|a| a.family == art.family && a.button == art.button),
"{}",
art.sprite
);
}
}
#[test]
fn art_measures_from_its_shape_and_text_from_its_label() {
let atlas = atlas();
assert_eq!(width(&atlas, Button::South, GlyphStyle::Xbox), 12.0);
assert_eq!(width(&atlas, Button::LeftBumper, GlyphStyle::Xbox), 20.0);
assert_eq!(
width(&atlas, Button::LeftBumper, GlyphStyle::PlayStation),
20.0
);
assert_eq!(width(&atlas, Button::DPadUp, GlyphStyle::Nintendo), 12.0);
let mut no_art = atlas;
no_art
.sprites
.remove(sprite(Button::LeftTrigger, GlyphStyle::Nintendo).unwrap());
let label = HudBatch::text_width(&no_art, "ZL").ceil() + 4.0;
assert_eq!(
width(&no_art, Button::LeftTrigger, GlyphStyle::Nintendo),
label
);
}
#[test]
fn an_atlas_without_the_art_falls_back_to_text() {
let mut atlas = atlas();
atlas
.sprites
.remove(sprite(Button::South, GlyphStyle::Xbox).unwrap());
assert_eq!(art(&atlas, Button::South, GlyphStyle::Xbox), None);
let label = HudBatch::text_width(&atlas, "A").ceil() + 4.0;
assert_eq!(width(&atlas, Button::South, GlyphStyle::Xbox), label);
}
#[test]
fn art_is_one_quad_centred_on_the_text_line_with_no_key_behind_it() {
let atlas = atlas();
let mut batch = HudBatch::new([640, 480]);
draw(
&mut batch,
&atlas,
Button::South,
GlyphStyle::Xbox,
10.0,
20.0,
);
assert_eq!(quads(&batch), vec![[7.0, 16.0, 23.0, 32.0]]);
let mut batch = HudBatch::new([640, 480]);
draw(
&mut batch,
&atlas,
Button::LeftBumper,
GlyphStyle::Xbox,
10.0,
20.0,
);
assert_eq!(quads(&batch), vec![[7.0, 16.0, 31.0, 32.0]]);
}
#[test]
fn text_sits_on_a_key_covering_what_rect_says() {
let mut atlas = atlas();
let style = GlyphStyle::Nintendo;
atlas.sprites.remove(sprite(Button::South, style).unwrap());
let mut batch = HudBatch::new([640, 480]);
draw(&mut batch, &atlas, Button::South, style, 10.0, 20.0);
let [x, y, w, h] = rect(&atlas, Button::South, style, 10.0, 20.0);
assert_eq!(quads(&batch)[0], [x, y, x + w, y + h]);
}
}
+13 -9
View File
@@ -17,6 +17,7 @@ use horizon_core::{
geometry::{Aabb, Axis, Vec3, trig},
random::{LegacyRandom, RandomSource},
};
use horizon_protocol::entity_data::PositionSource;
use horizon_world::block_collision::BlockCollisionRegistry;
use std::{
array,
@@ -50,9 +51,10 @@ pub struct ParticleOptions {
/// `GeyserParticleOptions.waterBlocks` and `GeyserBaseParticleOptions`'
/// `waterBlocks` and `burstImpulseBase` (0 where the type has none).
pub geyser: Option<(i32, f32)>,
/// `VibrationParticleOption`: the listener the signal flies to and its
/// arrival time in ticks (`ParticleEngine::vibration` is the same particle).
pub vibration: Option<([f64; 3], i32)>,
/// `VibrationParticleOption`: the listener's `PositionSource`, the point
/// the signal flies to and its arrival time in ticks
/// (`ParticleEngine::vibration` is the same particle).
pub vibration: Option<(PositionSource, [f64; 3], i32)>,
/// A native mod's own particle type, by its namespaced name (`mymod:spark`):
/// `SimpleParticleType` plus the provider `particles/<name>.json` names.
pub mod_particle: Option<String>,
@@ -94,10 +96,11 @@ impl ParticleOptions {
..Self::simple(ty)
}
}
/// `VibrationParticleOption(destination, arrivalInTicks)`.
pub fn vibration(dest: [f64; 3], ticks: i32) -> Self {
/// `VibrationParticleOption(destination, arrivalInTicks)`: the listener's
/// position source and the point this engine flies the signal to.
pub fn vibration(source: PositionSource, dest: [f64; 3], ticks: i32) -> Self {
Self {
vibration: Some((dest, ticks)),
vibration: Some((source, dest, ticks)),
..Self::simple(ParticleType::Vibration)
}
}
@@ -2988,8 +2991,8 @@ impl ParticleEngine {
// the listener the signal flies to and its arrival time; the sprite
// is the set's own draw from the engine's random.
T::Vibration => {
let (dest, ticks) = o.vibration?;
let mut q = Self::vibration_particle(p, dest, ticks);
let (_, dest, ticks) = o.vibration.as_ref()?;
let mut q = Self::vibration_particle(p, *dest, *ticks);
q.set = set;
q.sprite = self.sprite_from_set(ty);
q
@@ -5644,7 +5647,8 @@ mod tests {
#[test]
fn a_vibration_option_makes_the_signal_its_provider_draws() {
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
let options = ParticleOptions::vibration([10.5, 70.5, -3.5], 20);
let options =
ParticleOptions::vibration(PositionSource::Block([10, 70, -4]), [10.5, 70.5, -3.5], 20);
// Within the camera's 32 blocks, so only the provider decides.
engine.add_with(&options, [0.5, 1.5, 0.5], [0.0; 3], false, false);
assert_eq!(engine.made().get("vibration"), Some(&1));
+316 -2
View File
@@ -575,8 +575,53 @@ pub struct Hud {
/// Hits in a row the server confirmed, until this player is hurt or 3 seconds pass.
pub combo: bool,
pub combo_corner: Corner,
/// Frames drawn in the last second.
pub fps: bool,
pub fps_corner: Corner,
/// Left and right clicks in the last second.
pub cps: bool,
pub cps_corner: Corner,
/// The movement keys, mouse buttons and jump, lit while held, with clicks per second.
pub keys: bool,
pub keys_corner: Corner,
/// The computer's local time.
pub clock: bool,
pub clock_corner: Corner,
/// How far the last hit landed from the eyes, for 3 seconds; it only measures.
pub reach: bool,
pub reach_corner: Corner,
/// Horizontal speed in blocks per second.
pub speed: bool,
pub speed_corner: Corner,
/// A dark box behind each widget.
pub background: bool,
/// The boxes' opacity, in percent.
pub opacity: u8,
/// The widgets' text colour (durability keeps its own).
pub text: Colour,
/// A shadow under the widgets' text, as vanilla's HUD text has.
pub shadow: bool,
}
impl Hud {
/// Whether any widget is on: with none, the HUD draws nothing and reads nothing.
pub fn any(&self) -> bool {
[
self.armour,
self.effects,
self.held,
self.ping,
self.coords,
self.server,
self.combo,
self.fps,
self.cps,
self.keys,
self.clock,
self.reach,
self.speed,
]
.contains(&true)
}
}
impl Default for Hud {
fn default() -> Self {
@@ -596,11 +641,35 @@ impl Default for Hud {
server_corner: Corner::TopLeft,
combo: false,
combo_corner: Corner::TopLeft,
fps: false,
fps_corner: Corner::TopLeft,
cps: false,
cps_corner: Corner::TopLeft,
keys: false,
keys_corner: Corner::BottomLeft,
clock: false,
clock_corner: Corner::TopRight,
reach: false,
reach_corner: Corner::TopLeft,
speed: false,
speed_corner: Corner::TopLeft,
background: true,
opacity: HUD_OPACITY,
text: Colour::White,
shadow: true,
}
}
}
pub use crate::extras::Corner;
/// The widgets' boxes at first: the extras' 0x78 alpha, 47%.
pub const HUD_OPACITY: u8 = 47;
pub const HUD_OPACITIES: (u8, u8) = (0, 100);
impl Hud {
/// The boxes' colour, ARGB.
pub fn background_argb(&self) -> u32 {
alpha(self.opacity) << 24
}
}
/// The value of the view options while they follow the world.
fn world_label() -> String {
crate::lang::get_or("horizon.pvp.view.world", "World's")
@@ -682,7 +751,38 @@ impl ChatOptions {
}
}
#[derive(Clone, Debug, Default, PartialEq)]
/// Combat feel (`docs/pvp.md`): how fights look. Legacy draws them as 1.8.9 did; what the
/// game sends to a server never changes, so a vanilla server and its anticheat see 26.3.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Combat {
/// Legacy (1.8.9) instead of Modern.
pub legacy: bool,
/// Keep the attack indicator under Legacy (1.8.9 had none).
pub indicator: bool,
/// Under Legacy, silence the attack sounds 1.9 added (1.8.9 played none on a hit).
pub sounds: bool,
/// The worlds this game hosts (single player and LAN) fight by 1.8.9's rules: no attack
/// cooldown, no sweep, 1.8 weapon damage, effects, enchantments and critical hits
/// (`horizon_client::legacy_combat`). Never used as a client of a server.
pub rules: bool,
}
impl Combat {
/// Whether this client leaves out sound `name` a server sent: under Legacy with its
/// sounds, the `entity.player.attack.*` family (strong, weak, crit, sweep, knockback,
/// nodamage), which 1.9 brought with the attack cooldown.
pub fn mutes_sound(&self, name: &str) -> bool {
let name = name.strip_prefix("minecraft:").unwrap_or(name);
self.legacy && self.sounds && name.starts_with("entity.player.attack.")
}
/// Whether this client leaves out particles of `ty` a server sent: under Legacy, the
/// sweep and the damage hearts, which 1.9 brought (1.8.9's hits show crits only).
pub fn hides_particle(&self, ty: crate::particles::ParticleType) -> bool {
use crate::particles::ParticleType as T;
self.legacy && matches!(ty, T::SweepAttack | T::DamageIndicator)
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct Pvp {
pub crosshair: Crosshair,
pub outline: Outline,
@@ -690,6 +790,28 @@ pub struct Pvp {
pub hud: Hud,
pub tweaks: Tweaks,
pub chat: ChatOptions,
/// Where each HUD widget sits and its size.
pub layout: crate::pvp_hud::Layout,
/// The key that opens the HUD editor from the game (unbound at first).
pub editor_key: String,
pub combat: Combat,
}
/// The HUD editor key's name for the controls (`Extras::set_key`).
pub const HUD_EDITOR_KEY: &str = "horizon.hud_editor";
impl Default for Pvp {
fn default() -> Self {
Self {
crosshair: Crosshair::default(),
outline: Outline::default(),
hit: Hit::default(),
hud: Hud::default(),
tweaks: Tweaks::default(),
chat: ChatOptions::default(),
layout: crate::pvp_hud::Layout::default(),
editor_key: "key.keyboard.unknown".into(),
combat: Combat::default(),
}
}
}
fn flag(value: &str, d: bool) -> bool {
match value {
@@ -743,6 +865,15 @@ impl Pvp {
"hudHeld" => u.held = flag(value, u.held),
"hudHeldCorner" => u.held_corner = Corner::parse(value, u.held_corner),
"hudBackground" => u.background = flag(value, u.background),
"hudOpacity" => u.opacity = num(value, HUD_OPACITIES, u.opacity),
"hudTextColour" => {
u.text = Colour::parse(value)
.filter(|c| *c != Colour::Inverted)
.unwrap_or(u.text)
}
"hudShadow" => u.shadow = flag(value, u.shadow),
"hudPlace" => self.layout.parse(value),
"hudEditorKey" if value.starts_with("key.") => self.editor_key = value.to_owned(),
"hudPing" => u.ping = flag(value, u.ping),
"hudPingCorner" => u.ping_corner = Corner::parse(value, u.ping_corner),
"hudCoords" => u.coords = flag(value, u.coords),
@@ -751,6 +882,18 @@ impl Pvp {
"hudServerCorner" => u.server_corner = Corner::parse(value, u.server_corner),
"hudCombo" => u.combo = flag(value, u.combo),
"hudComboCorner" => u.combo_corner = Corner::parse(value, u.combo_corner),
"hudFps" => u.fps = flag(value, u.fps),
"hudFpsCorner" => u.fps_corner = Corner::parse(value, u.fps_corner),
"hudCps" => u.cps = flag(value, u.cps),
"hudCpsCorner" => u.cps_corner = Corner::parse(value, u.cps_corner),
"hudKeys" => u.keys = flag(value, u.keys),
"hudKeysCorner" => u.keys_corner = Corner::parse(value, u.keys_corner),
"hudClock" => u.clock = flag(value, u.clock),
"hudClockCorner" => u.clock_corner = Corner::parse(value, u.clock_corner),
"hudReach" => u.reach = flag(value, u.reach),
"hudReachCorner" => u.reach_corner = Corner::parse(value, u.reach_corner),
"hudSpeed" => u.speed = flag(value, u.speed),
"hudSpeedCorner" => u.speed_corner = Corner::parse(value, u.speed_corner),
"numericEnchants" => t.numeric_enchants = flag(value, t.numeric_enchants),
"lowFire" => t.fire_lower = num(value, FIRE_LOWER, t.fire_lower),
"shieldSize" => t.shield_size = num(value, SHIELD_SIZES, t.shield_size),
@@ -767,6 +910,14 @@ impl Pvp {
"chatCompact" => self.chat.compact = flag(value, self.chat.compact),
"chatCopy" => self.chat.copy = flag(value, self.chat.copy),
"chatSearch" => self.chat.search = flag(value, self.chat.search),
"combatFeel" => match value {
"legacy" => self.combat.legacy = true,
"modern" => self.combat.legacy = false,
_ => {}
},
"combatIndicator" => self.combat.indicator = flag(value, self.combat.indicator),
"combatSounds" => self.combat.sounds = flag(value, self.combat.sounds),
"combatRules" => self.combat.rules = flag(value, self.combat.rules),
"viewTime" => {
t.time = match value {
"off" => None,
@@ -838,6 +989,35 @@ impl Pvp {
u.combo,
u.combo_corner.name(),
);
let _ = write!(
out,
"hudFps:{}\nhudFpsCorner:{}\nhudCps:{}\nhudCpsCorner:{}\nhudKeys:{}\nhudKeysCorner:{}\n",
u.fps,
u.fps_corner.name(),
u.cps,
u.cps_corner.name(),
u.keys,
u.keys_corner.name(),
);
let _ = write!(
out,
"hudClock:{}\nhudClockCorner:{}\nhudReach:{}\nhudReachCorner:{}\nhudSpeed:{}\nhudSpeedCorner:{}\n",
u.clock,
u.clock_corner.name(),
u.reach,
u.reach_corner.name(),
u.speed,
u.speed_corner.name(),
);
let _ = write!(
out,
"hudOpacity:{}\nhudTextColour:{}\nhudShadow:{}\n",
u.opacity,
u.text.name(),
u.shadow,
);
self.layout.serialize(out);
let _ = writeln!(out, "hudEditorKey:{}", self.editor_key);
let t = &self.tweaks;
let _ = write!(
out,
@@ -862,6 +1042,13 @@ impl Pvp {
"chatTimestamps:{}\nchatCompact:{}\nchatCopy:{}\nchatSearch:{}\n",
c.timestamps, c.compact, c.copy, c.search,
);
let c = &self.combat;
let feel = if c.legacy { "legacy" } else { "modern" };
let _ = write!(
out,
"combatFeel:{feel}\ncombatIndicator:{}\ncombatSounds:{}\ncombatRules:{}\n",
c.indicator, c.sounds, c.rules
);
}
}
@@ -955,7 +1142,27 @@ opts! {
HudCombo, "hud_combo", "Combo counter",
"Hits in a row the server confirmed, until you are hurt or 3 seconds pass without one.";
HudComboCorner, "hud_combo_corner", "Combo corner", "Where the combo shows.";
HudFps, "hud_fps", "FPS", "Frames drawn in the last second.";
HudFpsCorner, "hud_fps_corner", "FPS corner", "Where the FPS shows.";
HudCps, "hud_cps", "Clicks per second", "Your left and right clicks in the last second.";
HudCpsCorner, "hud_cps_corner", "CPS corner", "Where the clicks per second show.";
HudKeys, "hud_keys", "Keystrokes",
"Your movement keys, mouse buttons and jump, lit while you hold them, with clicks per second.";
HudKeysCorner, "hud_keys_corner", "Keystrokes corner", "Where the keystrokes show.";
HudClock, "hud_clock", "Clock", "Your computer's time.";
HudClockCorner, "hud_clock_corner", "Clock corner", "Where the clock shows.";
HudReach, "hud_reach", "Reach",
"How far from your eyes your last hit landed, for 3 seconds. It only measures: your reach stays vanilla's.";
HudReachCorner, "hud_reach_corner", "Reach corner", "Where the reach shows.";
HudSpeed, "hud_speed", "Speed", "How fast you move across the ground, in blocks per second.";
HudSpeedCorner, "hud_speed_corner", "Speed corner", "Where the speed shows.";
HudBackground, "hud_background", "Widget backgrounds", "A dark box behind each widget.";
HudOpacity, "hud_opacity", "Background opacity", "How dark the boxes behind the widgets are.";
HudTextColour, "hud_text_colour", "Text colour",
"The widgets' text colour. Durability keeps its green to red.";
HudShadow, "hud_shadow", "Text shadow", "A shadow under the widgets' text, as vanilla's HUD text has.";
HudEditorKey, "hud_editor_key", "HUD editor key",
"Opens the HUD editor from the game. Click, then press a key; Escape unbinds it.";
NumericEnchants, "numeric_enchants", "Enchantment levels as numbers",
"Tooltips and the enchanting table show Sharpness 5 instead of Sharpness V.";
FireLower, "fire_lower", "Lower fire",
@@ -980,6 +1187,14 @@ opts! {
"Clicking a message in the open chat copies its text, unless the click does something else.";
ChatSearch, "chat_search", "Chat search",
"Ctrl+F in the open chat: what you type shows only the messages holding it. Enter or Ctrl+F again stops.";
CombatFeel, "combat_feel", "Combat feel",
"Legacy draws fights as 1.8.9 did: the item in your hand, its swing, eating, the bow and a sword held up while you hold use, without the dip after each hit or the attack indicator. Only the look: what you send a server stays the same.";
CombatIndicator, "combat_indicator", "Attack indicator in Legacy",
"Keeps the attack indicator under Legacy, which 1.8.9 didn't have.";
CombatSounds, "combat_sounds", "1.8 hit sounds",
"Under Legacy, leaves out the attack sounds 1.9 added (strong, weak, sweep, critical...). Hurt sounds stay.";
CombatRules, "combat_rules", "1.8 combat in worlds you host",
"Single player and the worlds you open to LAN fight by 1.8.9's rules: no attack cooldown or sweep, 1.8 weapon damage, Strength, Sharpness and critical hits. Servers you join keep their own rules.";
}
/// The PvP & HUD page: headers (language keys) and options.
pub fn rows() -> Vec<Result<Opt, &'static str>> {
@@ -1030,7 +1245,23 @@ pub fn rows() -> Vec<Result<Opt, &'static str>> {
HudServerCorner,
HudCombo,
HudComboCorner,
HudFps,
HudFpsCorner,
HudCps,
HudCpsCorner,
HudKeys,
HudKeysCorner,
HudClock,
HudClockCorner,
HudReach,
HudReachCorner,
HudSpeed,
HudSpeedCorner,
HudBackground,
HudOpacity,
HudTextColour,
HudShadow,
HudEditorKey,
]
.map(Ok),
);
@@ -1043,6 +1274,8 @@ pub fn rows() -> Vec<Result<Opt, &'static str>> {
rows.extend([ViewTime, ViewWeather].map(Ok));
rows.push(Err("horizon.pvp.header.chat"));
rows.extend([ChatTimestamps, ChatCompact, ChatCopy, ChatSearch].map(Ok));
rows.push(Err("horizon.pvp.header.combat"));
rows.extend([CombatFeel, CombatIndicator, CombatSounds, CombatRules].map(Ok));
rows
}
impl Opt {
@@ -1054,10 +1287,19 @@ impl Opt {
OutlineColour | OutlineOpacity | OutlineThickness | OutlineFill => Some(Outline),
HitColour | HitOpacity => Some(Hit),
HudArmour | HudEffects | HudHeld | HudPing | HudCoords | HudServer | HudCombo
| HudBackground | NumericEnchants => None,
| HudFps | HudCps | HudKeys | HudClock | HudReach | HudSpeed | HudBackground
| HudTextColour | HudShadow | HudEditorKey | NumericEnchants => None,
HudOpacity => Some(HudBackground),
HudFpsCorner => Some(HudFps),
HudCpsCorner => Some(HudCps),
HudKeysCorner => Some(HudKeys),
HudClockCorner => Some(HudClock),
HudReachCorner => Some(HudReach),
HudSpeedCorner => Some(HudSpeed),
FireLower | ShieldSize | TotemSize | GlintColour | ClearWater | ClearLava => None,
ViewTime | ViewWeather => None,
ChatTimestamps | ChatCompact | ChatCopy | ChatSearch => None,
CombatFeel | CombatIndicator | CombatSounds | CombatRules => None,
HudPingCorner => Some(HudPing),
HudCoordsCorner => Some(HudCoords),
HudServerCorner => Some(HudServer),
@@ -1081,10 +1323,17 @@ impl Opt {
Opt::HudEffects => p.hud.effects,
Opt::HudHeld => p.hud.held,
Opt::HudBackground => p.hud.background,
Opt::HudShadow => p.hud.shadow,
Opt::HudPing => p.hud.ping,
Opt::HudCoords => p.hud.coords,
Opt::HudServer => p.hud.server,
Opt::HudCombo => p.hud.combo,
Opt::HudFps => p.hud.fps,
Opt::HudCps => p.hud.cps,
Opt::HudKeys => p.hud.keys,
Opt::HudClock => p.hud.clock,
Opt::HudReach => p.hud.reach,
Opt::HudSpeed => p.hud.speed,
Opt::NumericEnchants => p.tweaks.numeric_enchants,
Opt::ClearWater => p.tweaks.clear_water,
Opt::ClearLava => p.tweaks.clear_lava,
@@ -1092,6 +1341,9 @@ impl Opt {
Opt::ChatCompact => p.chat.compact,
Opt::ChatCopy => p.chat.copy,
Opt::ChatSearch => p.chat.search,
Opt::CombatIndicator => p.combat.indicator,
Opt::CombatSounds => p.combat.sounds,
Opt::CombatRules => p.combat.rules,
_ => return None,
})
}
@@ -1124,6 +1376,15 @@ impl Opt {
Opt::HudCoordsCorner => corner_label(p.hud.coords_corner),
Opt::HudServerCorner => corner_label(p.hud.server_corner),
Opt::HudComboCorner => corner_label(p.hud.combo_corner),
Opt::HudFpsCorner => corner_label(p.hud.fps_corner),
Opt::HudCpsCorner => corner_label(p.hud.cps_corner),
Opt::HudKeysCorner => corner_label(p.hud.keys_corner),
Opt::HudClockCorner => corner_label(p.hud.clock_corner),
Opt::HudReachCorner => corner_label(p.hud.reach_corner),
Opt::HudSpeedCorner => corner_label(p.hud.speed_corner),
Opt::HudOpacity => format!("{}%", p.hud.opacity),
Opt::HudTextColour => p.hud.text.label(),
Opt::HudEditorKey => crate::keybinds::display_name(&p.editor_key).plain(),
Opt::FireLower => match p.tweaks.fire_lower {
0 => crate::lang::get_or("Off", "Off"),
v => format!("{v}%"),
@@ -1134,6 +1395,10 @@ impl Opt {
Some(colour) => colour.label(),
None => crate::lang::get_or("Off", "Off"),
},
Opt::CombatFeel => match p.combat.legacy {
true => crate::lang::get_or("horizon.pvp.combat_feel.legacy", "Legacy (1.8.9)"),
false => crate::lang::get_or("horizon.pvp.combat_feel.modern", "Modern"),
},
Opt::ViewTime => p.tweaks.time.map_or_else(world_label, ViewTime::label),
Opt::ViewWeather => p
.tweaks
@@ -1191,6 +1456,11 @@ impl Opt {
Opt::HudHeld => u.held = !u.held,
Opt::HudHeldCorner => u.held_corner = corner(u.held_corner),
Opt::HudBackground => u.background = !u.background,
Opt::HudOpacity => u.opacity = step_by(u.opacity, HUD_OPACITIES, 10, back),
Opt::HudTextColour => u.text = u.text.step_solid(back),
Opt::HudShadow => u.shadow = !u.shadow,
// Bound through the controls' key capture (`Item::key_name`).
Opt::HudEditorKey => {}
Opt::HudPing => u.ping = !u.ping,
Opt::HudPingCorner => u.ping_corner = corner(u.ping_corner),
Opt::HudCoords => u.coords = !u.coords,
@@ -1199,6 +1469,18 @@ impl Opt {
Opt::HudServerCorner => u.server_corner = corner(u.server_corner),
Opt::HudCombo => u.combo = !u.combo,
Opt::HudComboCorner => u.combo_corner = corner(u.combo_corner),
Opt::HudFps => u.fps = !u.fps,
Opt::HudFpsCorner => u.fps_corner = corner(u.fps_corner),
Opt::HudCps => u.cps = !u.cps,
Opt::HudCpsCorner => u.cps_corner = corner(u.cps_corner),
Opt::HudKeys => u.keys = !u.keys,
Opt::HudKeysCorner => u.keys_corner = corner(u.keys_corner),
Opt::HudClock => u.clock = !u.clock,
Opt::HudClockCorner => u.clock_corner = corner(u.clock_corner),
Opt::HudReach => u.reach = !u.reach,
Opt::HudReachCorner => u.reach_corner = corner(u.reach_corner),
Opt::HudSpeed => u.speed = !u.speed,
Opt::HudSpeedCorner => u.speed_corner = corner(u.speed_corner),
Opt::NumericEnchants => p.tweaks.numeric_enchants = !p.tweaks.numeric_enchants,
Opt::FireLower => t.fire_lower = step_by(t.fire_lower, FIRE_LOWER, 10, back),
Opt::ShieldSize => t.shield_size = step_by(t.shield_size, SHIELD_SIZES, 10, back),
@@ -1216,6 +1498,10 @@ impl Opt {
Opt::ChatCompact => p.chat.compact = !p.chat.compact,
Opt::ChatCopy => p.chat.copy = !p.chat.copy,
Opt::ChatSearch => p.chat.search = !p.chat.search,
Opt::CombatFeel => p.combat.legacy = !p.combat.legacy,
Opt::CombatIndicator => p.combat.indicator = !p.combat.indicator,
Opt::CombatSounds => p.combat.sounds = !p.combat.sounds,
Opt::CombatRules => p.combat.rules = !p.combat.rules,
Opt::ViewWeather => {
let all: Vec<_> = std::iter::once(None)
.chain(ViewWeather::ALL.map(Some))
@@ -1518,4 +1804,32 @@ mod tests {
assert_eq!(en.get(key), Some(*english), "{key}");
}
}
#[test]
fn legacy_leaves_out_what_1_9_added_to_a_hit() {
use crate::particles::ParticleType as T;
let modern = Combat::default();
let legacy = Combat {
legacy: true,
..Combat::default()
};
let sounds = Combat {
sounds: true,
..legacy
};
for name in [
"minecraft:entity.player.attack.strong",
"entity.player.attack.sweep",
"minecraft:entity.player.attack.nodamage",
] {
assert!(sounds.mutes_sound(name), "{name}");
assert!(!legacy.mutes_sound(name), "only with the sounds option");
assert!(!modern.mutes_sound(name));
}
// 1.8.9 had the hurt sound: it stays.
assert!(!sounds.mutes_sound("minecraft:entity.player.hurt"));
assert!(legacy.hides_particle(T::SweepAttack) && legacy.hides_particle(T::DamageIndicator));
assert!(!legacy.hides_particle(T::Crit) && !legacy.hides_particle(T::EnchantedHit));
assert!(!modern.hides_particle(T::SweepAttack));
}
}
+451
View File
@@ -214,6 +214,320 @@ impl Combo {
}
}
/// Clicks per second: the presses of the last second, kept for the widgets that are on.
#[derive(Default)]
pub struct Clicks {
/// Press times in seconds, oldest first; at most [`Clicks::KEEP`].
times: std::collections::VecDeque<f64>,
}
impl Clicks {
/// More than any hand clicks in a second: the ring never grows past it.
const KEEP: usize = 64;
pub fn press(&mut self, now: f64) {
if self.times.len() == Self::KEEP {
self.times.pop_front();
}
self.times.push_back(now);
}
/// The presses in the second up to `now`.
pub fn per_second(&mut self, now: f64) -> u32 {
while self.times.front().is_some_and(|&t| now - t >= 1.0) {
self.times.pop_front();
}
self.times.len() as u32
}
}
/// Horizontal speed in blocks per second, from the player's own position sampled at most
/// every [`Speed::SAMPLE`] seconds (nothing is asked of the server).
#[derive(Default)]
pub struct Speed {
last: Option<([f64; 2], f64)>,
/// Tenths of a block per second.
tenths: u32,
}
impl Speed {
pub const SAMPLE: f64 = 0.25;
pub fn sample(&mut self, x: f64, z: f64, now: f64) -> u32 {
match self.last {
Some((_, at)) if now - at < Self::SAMPLE => {}
Some(([lx, lz], at)) => {
let d = ((x - lx).powi(2) + (z - lz).powi(2)).sqrt();
// A teleport or respawn isn't speed.
let v = d / (now - at);
self.tenths = if v > 200.0 {
0
} else {
(v * 10.0).round() as u32
};
self.last = Some(([x, z], now));
}
None => self.last = Some(([x, z], now)),
}
self.tenths
}
}
/// The reach display: how far the last hit this player made was, measured from the eyes to
/// the point the crosshair's ray met the target's box. It measures what the client already
/// computed for the attack and changes nothing about it.
#[derive(Default)]
pub struct Reach {
last: Option<(f64, f64)>,
}
impl Reach {
/// How long the last hit's distance stays shown.
pub const SHOWN: f64 = 3.0;
pub fn hit(&mut self, distance: f64, now: f64) {
self.last = Some((distance, now));
}
/// Hundredths of a block, while the last hit is recent.
pub fn hundredths(&self, now: f64) -> Option<u32> {
let (d, at) = self.last?;
(now - at <= Self::SHOWN).then(|| (d * 100.0).round() as u32)
}
}
/// `n` hundredths as "3.05".
pub fn hundredths(n: u32) -> String {
format!("{}.{:02}", n / 100, n % 100)
}
/// `n` tenths as "4.3".
pub fn tenths(n: u32) -> String {
format!("{}.{}", n / 10, n % 10)
}
/// The local time as "14:05".
pub fn clock(hour: u8, minute: u8) -> String {
format!("{hour:02}:{minute:02}")
}
/// The keystrokes widget's keys, in drawing order.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Key {
Forward,
Left,
Back,
Right,
Attack,
Use,
Jump,
}
impl Key {
pub const ALL: [Key; 7] = [
Key::Forward,
Key::Left,
Key::Back,
Key::Right,
Key::Attack,
Key::Use,
Key::Jump,
];
/// The key mapping it shows.
pub fn mapping(self) -> &'static str {
match self {
Key::Forward => "key.forward",
Key::Left => "key.left",
Key::Back => "key.back",
Key::Right => "key.right",
Key::Attack => "key.attack",
Key::Use => "key.use",
Key::Jump => "key.jump",
}
}
}
/// The keystrokes' key size and the gap between keys, in GUI pixels.
pub const KEY: f32 = 22.0;
pub const KEY_GAP: f32 = 2.0;
/// The space bar's height.
pub const SPACE: f32 = 12.0;
/// Where each key of the keystrokes widget sits in its box: W above A S D, the two mouse
/// buttons under them, the space bar at the bottom, as `(x, y, w, h)`.
pub fn key_rect(key: Key) -> (f32, f32, f32, f32) {
let step = KEY + KEY_GAP;
let full = 3.0 * KEY + 2.0 * KEY_GAP;
let half = (full - KEY_GAP) / 2.0;
match key {
Key::Forward => (step, 0.0, KEY, KEY),
Key::Left => (0.0, step, KEY, KEY),
Key::Back => (step, step, KEY, KEY),
Key::Right => (2.0 * step, step, KEY, KEY),
Key::Attack => (0.0, 2.0 * step, half, KEY),
Key::Use => (half + KEY_GAP, 2.0 * step, half, KEY),
Key::Jump => (0.0, 3.0 * step, full, SPACE),
}
}
/// The keystrokes widget's size.
pub fn keys_size() -> (f32, f32) {
let (_, y, w, h) = key_rect(Key::Jump);
(w, y + h)
}
/// The HUD widgets, for their places on screen ([`Layout`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum WidgetId {
Armour,
Effects,
Held,
Ping,
Coords,
Server,
Combo,
Fps,
Cps,
Keys,
Clock,
Reach,
Speed,
}
impl WidgetId {
pub const ALL: [WidgetId; 13] = [
WidgetId::Armour,
WidgetId::Effects,
WidgetId::Held,
WidgetId::Ping,
WidgetId::Coords,
WidgetId::Server,
WidgetId::Combo,
WidgetId::Fps,
WidgetId::Cps,
WidgetId::Keys,
WidgetId::Clock,
WidgetId::Reach,
WidgetId::Speed,
];
pub fn name(self) -> &'static str {
match self {
WidgetId::Armour => "armour",
WidgetId::Effects => "effects",
WidgetId::Held => "held",
WidgetId::Ping => "ping",
WidgetId::Coords => "coords",
WidgetId::Server => "server",
WidgetId::Combo => "combo",
WidgetId::Fps => "fps",
WidgetId::Cps => "cps",
WidgetId::Keys => "keys",
WidgetId::Clock => "clock",
WidgetId::Reach => "reach",
WidgetId::Speed => "speed",
}
}
pub fn index(self) -> usize {
self as usize
}
}
/// Where a widget sits: in its corner, stacked with the others there (`at` is `None`), or at a
/// place the player dragged it to, as fractions of the room the screen leaves around the box
/// (0 at the left or top edge, 1 at the right or bottom), so it stays on screen at any size.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Place {
pub at: Option<(f32, f32)>,
/// Its size in percent.
pub scale: u8,
}
impl Default for Place {
fn default() -> Self {
Self {
at: None,
scale: 100,
}
}
}
pub const SCALES: (u8, u8) = (50, 200);
/// Every widget's place.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Layout(pub [Place; 13]);
impl Layout {
pub fn get(&self, id: WidgetId) -> Place {
self.0[id.index()]
}
pub fn get_mut(&mut self, id: WidgetId) -> &mut Place {
&mut self.0[id.index()]
}
/// One `hudPlace` value: `armour,0.25,0.5,150`, or `armour,corner,,150`.
pub fn parse(&mut self, value: &str) {
let mut parts = value.split(',');
let Some(id) = parts
.next()
.and_then(|n| WidgetId::ALL.into_iter().find(|w| w.name() == n))
else {
return;
};
let (x, y, scale) = (parts.next(), parts.next(), parts.next());
let frac = |v: Option<&str>| {
v.and_then(|v| v.parse::<f32>().ok())
.filter(|v| v.is_finite())
.map(|v| v.clamp(0.0, 1.0))
};
let place = self.get_mut(id);
place.at = frac(x).zip(frac(y));
if let Some(s) = scale.and_then(|s| s.parse::<u8>().ok()) {
place.scale = s.clamp(SCALES.0, SCALES.1);
}
}
/// The `hudPlace` lines of the widgets that left their corner or their size.
pub fn serialize(&self, out: &mut String) {
use std::fmt::Write;
for id in WidgetId::ALL {
let p = self.get(id);
if p == Place::default() {
continue;
}
let _ = match p.at {
Some((x, y)) => writeln!(out, "hudPlace:{},{x:.4},{y:.4},{}", id.name(), p.scale),
None => writeln!(out, "hudPlace:{},corner,,{}", id.name(), p.scale),
};
}
}
}
/// A box `w` x `h` placed at fractions `(fx, fy)` of the room left on a `screen`.
pub fn from_fractions((fx, fy): (f32, f32), (w, h): (f32, f32), screen: (f32, f32)) -> (f32, f32) {
(
(fx * (screen.0 - w).max(0.0)).round(),
(fy * (screen.1 - h).max(0.0)).round(),
)
}
/// The fractions that put a `w` x `h` box at `(x, y)` on a `screen`, kept on screen.
pub fn to_fractions((x, y): (f32, f32), (w, h): (f32, f32), screen: (f32, f32)) -> (f32, f32) {
let frac = |v: f32, room: f32| {
if room > 0.0 {
(v / room).clamp(0.0, 1.0)
} else {
0.0
}
};
(frac(x, screen.0 - w), frac(y, screen.1 - h))
}
/// How near, in GUI pixels, an edge must come to another to snap to it.
pub const SNAP: f32 = 4.0;
/// Snaps a box's start `x` of length `w` along one axis of length `screen`: to the screen's
/// edges and middle, and to the edges of the `others` boxes (`(start, length)`), where one
/// is within [`SNAP`]. Returns the new start and the line it snapped to, if any.
pub fn snap(x: f32, w: f32, screen: f32, others: &[(f32, f32)]) -> (f32, Option<f32>) {
let mut best: Option<(f32, f32, f32)> = None;
let mut consider = |start: f32, line: f32| {
let d = (start - x).abs();
if d <= SNAP && best.is_none_or(|(b, _, _)| d < b) {
best = Some((d, start, line));
}
};
// The screen's edges, with the corners' margin, and its middle.
consider(2.0, 0.0);
consider(screen - 2.0 - w, screen);
consider((screen - w) / 2.0, screen / 2.0);
for &(o, ow) in others {
// Side by side, or lined up on the same edge.
consider(o + ow, o + ow);
consider(o - w, o);
consider(o, o);
consider(o + ow - w, o + ow);
}
match best {
Some((_, start, line)) => (start.clamp(0.0, (screen - w).max(0.0)), Some(line)),
None => (x.clamp(0.0, (screen - w).max(0.0)), None),
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -338,4 +652,141 @@ mod tests {
assert_eq!(h, 18.0 + 10.0 + 20.0 + 2.0 * PAD - 2.0);
assert!(size(&[]) == (0.0, 0.0));
}
#[test]
fn clicks_count_the_presses_of_the_last_second() {
let mut c = Clicks::default();
for t in [0.0, 0.1, 0.5, 0.9] {
c.press(t);
}
assert_eq!(c.per_second(0.95), 4);
// 1.0 s after the first press it has left the window.
assert_eq!(c.per_second(1.0), 3);
assert_eq!(c.per_second(1.6), 1);
assert_eq!(c.per_second(5.0), 0);
// The ring never keeps more than 64 presses.
for i in 0..200 {
c.press(10.0 + f64::from(i) * 0.001);
}
assert_eq!(c.per_second(10.2), 64);
}
#[test]
fn speed_is_horizontal_blocks_per_second_between_samples() {
let mut s = Speed::default();
assert_eq!(s.sample(0.0, 0.0, 0.0), 0);
// Sprinting: 5.612 blocks per second along x, with a jump (y is ignored).
assert_eq!(s.sample(0.5, 0.0, 0.1), 0, "too soon for a new sample");
assert_eq!(s.sample(1.403, 0.0, 0.25), 56);
// 3 east and 4 south in a second: 5 blocks per second.
assert_eq!(s.sample(1.403 + 3.0, 4.0, 1.25), 50);
// A teleport reads as standing still.
assert_eq!(s.sample(1000.0, 4.0, 1.5), 0);
assert_eq!(tenths(56), "5.6");
}
#[test]
fn reach_shows_the_last_hit_for_three_seconds() {
let mut r = Reach::default();
assert_eq!(r.hundredths(0.0), None);
r.hit(2.954, 1.0);
assert_eq!(r.hundredths(1.5), Some(295));
assert_eq!(hundredths(295), "2.95");
assert_eq!(hundredths(300), "3.00");
assert_eq!(r.hundredths(4.1), None);
}
#[test]
fn the_clock_reads_hours_and_minutes() {
assert_eq!(clock(14, 5), "14:05");
assert_eq!(clock(0, 0), "00:00");
}
#[test]
fn keystrokes_keys_tile_their_box_without_overlap() {
let (w, h) = keys_size();
assert_eq!((w, h), (70.0, 3.0 * 24.0 + 12.0));
for (i, a) in Key::ALL.iter().enumerate() {
let (x, y, kw, kh) = key_rect(*a);
assert!(
x >= 0.0 && y >= 0.0 && x + kw <= w && y + kh <= h,
"{a:?} inside"
);
for b in &Key::ALL[i + 1..] {
let (bx, by, bw, bh) = key_rect(*b);
let apart = x + kw <= bx || bx + bw <= x || y + kh <= by || by + bh <= y;
assert!(apart, "{a:?} and {b:?} overlap");
}
}
}
#[test]
fn places_round_trip_and_only_moved_widgets_are_written() {
let mut l = Layout::default();
let mut text = String::new();
l.serialize(&mut text);
assert_eq!(text, "", "a vanilla layout writes nothing");
*l.get_mut(WidgetId::Keys) = Place {
at: Some((0.25, 1.0)),
scale: 150,
};
l.get_mut(WidgetId::Fps).scale = 75;
l.serialize(&mut text);
assert_eq!(
text,
"hudPlace:fps,corner,,75\nhudPlace:keys,0.2500,1.0000,150\n"
);
let mut back = Layout::default();
for line in text.lines() {
back.parse(line.split_once(':').unwrap().1);
}
assert_eq!(back, l);
// Out of range values are clamped, unknown widgets and bad numbers ignored.
back.parse("speed,-3,7,250");
assert_eq!(back.get(WidgetId::Speed).at, Some((0.0, 1.0)));
assert_eq!(back.get(WidgetId::Speed).scale, 200);
back.parse("radar,0.5,0.5,100");
back.parse("clock,NaN,0.5,100");
assert_eq!(back.get(WidgetId::Clock).at, None);
}
#[test]
fn fractions_keep_a_box_on_screen_at_any_size() {
let screen = (480.0, 270.0);
let at = to_fractions((100.0, 50.0), (80.0, 20.0), screen);
assert_eq!(from_fractions(at, (80.0, 20.0), screen), (100.0, 50.0));
// A box at the right edge stays at the right edge on a wider screen.
let right = to_fractions((400.0, 0.0), (80.0, 20.0), screen);
assert_eq!(right, (1.0, 0.0));
assert_eq!(
from_fractions(right, (80.0, 20.0), (960.0, 540.0)),
(880.0, 0.0)
);
// Dragged past the edge: kept inside.
assert_eq!(
to_fractions((-30.0, 900.0), (80.0, 20.0), screen),
(0.0, 1.0)
);
}
#[test]
fn boxes_snap_to_screen_edges_middle_and_each_other() {
// Near the left margin.
assert_eq!(snap(4.5, 50.0, 480.0, &[]), (2.0, Some(0.0)));
// Centred: the middle of 480 for a 50-wide box starts at 215.
assert_eq!(snap(213.0, 50.0, 480.0, &[]), (215.0, Some(240.0)));
// Beside another box (100..160): its start goes to 160.
assert_eq!(
snap(163.0, 50.0, 480.0, &[(100.0, 60.0)]),
(160.0, Some(160.0))
);
// Lined up with its left edge.
assert_eq!(
snap(98.0, 50.0, 480.0, &[(100.0, 60.0)]),
(100.0, Some(100.0))
);
// Too far from anything: stays, but never off screen.
assert_eq!(snap(300.0, 50.0, 480.0, &[(100.0, 60.0)]), (300.0, None));
assert_eq!(snap(470.0, 50.0, 480.0, &[]), (430.0, None));
}
}
+148 -3
View File
@@ -975,11 +975,32 @@ impl TextField {
}
}
/// `ScreenRectangle.intersection`: the part of both rectangles, or
/// `ScreenRectangle.empty()` (`0, 0, 0, 0`, which clips everything) when they
/// do not overlap.
fn intersect(a: [i32; 4], b: [i32; 4]) -> [i32; 4] {
let rect = [
a[0].max(b[0]),
a[1].max(b[1]),
a[2].min(b[2]),
a[3].min(b[3]),
];
if rect[0] < rect[2] && rect[1] < rect[3] {
rect
} else {
[0, 0, 0, 0]
}
}
/// Draws the calls with the HUD batch: sprites nine-sliced, textures from
/// the atlas (`textures/gui/x.png` is `gui/x`; tiled textures from their
/// corner), scissor clipping, and the tooltip at the mouse.
pub fn paint(batch: &mut HudBatch, atlas: &HudAtlas, draws: &[Draw]) {
let mut clip: Option<(usize, [i32; 4])> = None;
// `GuiGraphicsExtractor.ScissorStack`: a scissor clips what is drawn since
// it opened, cut to the screen while none is open and to the one around it
// otherwise; closing it puts the enclosing one back.
let screen = [0, 0, batch.width as i32, batch.height as i32];
let mut clips: Vec<(usize, [i32; 4])> = Vec::new();
let mut tooltip = None;
for d in draws {
match d {
@@ -1083,10 +1104,11 @@ pub fn paint(batch: &mut HudBatch, atlas: &HudAtlas, draws: &[Draw]) {
);
}
Draw::Scissor { x0, y0, x1, y1 } => {
clip = Some((batch.vertices.len(), [*x0, *y0, *x1, *y1]))
let outer = clips.last().map_or(screen, |&(_, rect)| rect);
clips.push((batch.vertices.len(), intersect(outer, [*x0, *y0, *x1, *y1])));
}
Draw::NoScissor => {
if let Some((start, [x0, y0, x1, y1])) = clip.take() {
if let Some((start, [x0, y0, x1, y1])) = clips.pop() {
batch.clip_since(start, x0 as f32, y0 as f32, x1 as f32, y1 as f32);
}
}
@@ -1282,3 +1304,126 @@ mod button_label_tests {
batch.vertices.len()
}
}
#[cfg(test)]
mod scissor_tests {
use super::*;
fn atlas() -> HudAtlas {
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap()
}
/// The bounds of every quad's six vertices, in draw order.
fn quads(batch: &HudBatch) -> Vec<[f32; 4]> {
batch
.vertices
.as_chunks::<6>()
.0
.iter()
.map(|q| {
let (mut x0, mut y0) = (f32::MAX, f32::MAX);
let (mut x1, mut y1) = (f32::MIN, f32::MIN);
for v in q {
x0 = x0.min(v.pos[0]);
y0 = y0.min(v.pos[1]);
x1 = x1.max(v.pos[0]);
y1 = y1.max(v.pos[1]);
}
[x0, y0, x1, y1]
})
.collect()
}
fn fill(x0: i32, y0: i32, x1: i32, y1: i32) -> Draw {
Draw::Fill {
x0,
y0,
x1,
y1,
color: -1,
}
}
fn scissor(x0: i32, y0: i32, x1: i32, y1: i32) -> Draw {
Draw::Scissor { x0, y0, x1, y1 }
}
/// `GuiGraphicsExtractor.ScissorStack.push`: a scissor inside another is
/// intersected with it (`ScreenRectangle.intersection`), and the outer one
/// still clips what is drawn after the inner one closes.
#[test]
fn a_nested_scissor_is_intersected_with_the_one_around_it() {
let atlas = atlas();
let mut batch = HudBatch::new([320, 240]);
paint(
&mut batch,
&atlas,
&[
scissor(10, 10, 100, 100),
fill(0, 0, 200, 200),
scissor(50, 50, 300, 300),
fill(0, 0, 400, 400),
Draw::NoScissor,
fill(0, 0, 200, 200),
Draw::NoScissor,
],
);
assert_eq!(
quads(&batch),
[
[10.0, 10.0, 100.0, 100.0],
[50.0, 50.0, 100.0, 100.0],
[10.0, 10.0, 100.0, 100.0],
]
);
}
/// A scissor with no scissor around it is cut to the screen, as
/// `ScissorStack.push` intersects with the screen while the stack is empty.
#[test]
fn a_scissor_wider_than_the_screen_is_cut_to_it() {
let atlas = atlas();
let mut batch = HudBatch::new([320, 240]);
paint(
&mut batch,
&atlas,
&[
scissor(-50, -50, 500, 500),
fill(-100, -100, 600, 600),
Draw::NoScissor,
],
);
assert_eq!(quads(&batch), [[0.0, 0.0, 320.0, 240.0]]);
}
/// A scissor that misses the one around it intersects to
/// `ScreenRectangle.empty()`: nothing inside it is drawn, and the outer
/// scissor takes over again when it closes.
#[test]
fn a_scissor_outside_the_one_around_it_draws_nothing() {
let atlas = atlas();
let mut batch = HudBatch::new([320, 240]);
paint(
&mut batch,
&atlas,
&[
scissor(10, 10, 50, 50),
fill(0, 0, 200, 200),
scissor(60, 60, 90, 90),
fill(0, 0, 200, 200),
Draw::NoScissor,
fill(0, 0, 200, 200),
Draw::NoScissor,
fill(0, 0, 200, 200),
],
);
assert_eq!(
quads(&batch),
[
[10.0, 10.0, 50.0, 50.0],
[10.0, 10.0, 50.0, 50.0],
[0.0, 0.0, 200.0, 200.0],
]
);
}
}
+188 -5
View File
@@ -18,7 +18,7 @@ pub fn set_source_volumes(volumes: [f32; 11]) {
pub fn sound_source(event: &str) -> usize {
let e = event.trim_start_matches("minecraft:");
let index = |name: &str| SOUND_SOURCES.iter().position(|n| *n == name).unwrap_or(0);
const HOSTILE: [&str; 25] = [
const HOSTILE: [&str; 34] = [
"zombie",
"skeleton",
"creeper",
@@ -44,6 +44,16 @@ pub fn sound_source(event: &str) -> usize {
"silverfish",
"zombified_piglin",
"wither_skeleton",
"bogged",
"breeze",
"endermite",
"parched",
"warden",
"zombie_villager",
"vex",
"wither",
// Monster's common hurt/death (e.g. Giant) and other shared sounds.
"hostile",
];
if e == "block.potent_sulfur.noxious_gas" {
// PotentSulfurBlock.animateTick, its only caller, plays it as AMBIENT.
@@ -347,7 +357,7 @@ impl Settings {
.parent()
.map(|dir| crate::mod_list::ModList::load(&crate::logging::shared_dir(dir)))
.unwrap_or_default();
let settings = match fs::read(path) {
let mut settings = match fs::read(path) {
Ok(bytes) => {
let text = String::from_utf8_lossy(&bytes);
extras.read_old_options(&extras_text, &text);
@@ -367,6 +377,9 @@ impl Settings {
..Self::default()
},
};
// Horizon's own key bindings live in horizon-extras.txt, not in options.txt.
let emote = settings.extras.emote_key.clone();
settings.keys.set("key.emote", &emote);
settings.describe_for_crashes();
// Options.load: the backend asked for at startup, what a restart is measured from.
Self {
@@ -636,14 +649,17 @@ impl Settings {
pub fn with_option(&self, key: &str, value: &str) -> Result<Self, String> {
let mut file = self.to_file();
file.set_text(key, value)?;
Ok(Self {
let mut next = Self {
horizon: self.horizon.clone(),
controller: self.controller.clone(),
extras: self.extras.clone(),
mods: self.mods.clone(),
video_modes: self.video_modes.clone(),
..Self::from_file(file)
})
};
// Horizon's own bindings aren't in options.txt.
next.keys.set("key.emote", self.keys.get("key.emote"));
Ok(next)
}
/// The `options.txt` text (Options.save).
pub fn serialize(&self) -> String {
@@ -657,7 +673,9 @@ impl Settings {
if let Some(dir) = path.parent() {
self.horizon.save(dir);
self.controller.save(dir);
self.extras.save(dir);
let mut extras = self.extras.clone();
extras.emote_key = self.keys.get("key.emote").to_owned();
extras.save(dir);
self.mods.save(&crate::logging::shared_dir(dir));
}
}
@@ -814,6 +832,7 @@ pub enum Opt {
PadToggleSneak,
PadMoveDeadzone,
PadLookDeadzone,
PadTriggerThreshold,
PadGlyphs,
PadKeyboard,
/// `options.modelPart.*`: one skin layer (index into `options_file::MODEL_PARTS`).
@@ -855,6 +874,57 @@ mod tests {
horizon_narrator::Status::Off
);
}
/// `Monster.getSoundSource`: the verified families and inherited common sounds.
#[test]
fn hostile_families_keep_their_sound_category() {
for (event, source) in [
("entity.zombie.hurt", 5),
("entity.cow.hurt", 6),
("block.stone.break", 4),
("entity.player.hurt", 7),
("entity.arrow.hit", 7),
("music_disc.13", 2),
("item.goat_horn.sound.0", 2),
("weather.rain", 3),
("ambient.cave", 8),
] {
assert_eq!(sound_source(event), source, "{event}");
}
// Explicit sources still take precedence, including H331's arrow route.
let mut audio = crate::audio::Audio::silent(Path::new("/nonexistent/h068-audio"));
audio.capture = Some(Vec::new());
audio.play_on(6, "minecraft:entity.arrow.hit", Some([1.0; 3]), 1.0, 1.0);
audio.play_on(4, "minecraft:entity.warden.hurt", Some([1.0; 3]), 1.0, 1.0);
assert_eq!(
audio
.capture
.as_ref()
.unwrap()
.iter()
.map(|s| s.source)
.collect::<Vec<_>>(),
[6, 4]
);
for family in [
"bogged",
"breeze",
"endermite",
"parched",
"warden",
"zombie_villager",
"vex",
"wither",
"hostile",
] {
for suffix in ["hurt", "death"] {
for prefix in ["", "minecraft:"] {
let event = format!("{prefix}entity.{family}.{suffix}");
assert_eq!(sound_source(&event), 5, "{event}");
}
}
}
}
#[test]
fn three_d_skin_layers_move_from_options_txt_to_the_extras_file() {
// #8091 wrote the switch into options.txt; a player who had it on keeps
@@ -880,6 +950,119 @@ mod tests {
);
let _ = fs::remove_dir_all(&dir);
}
fn scratch(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!(
"horizon-{name}-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn options_txt_lists_only_vanillas_key_mappings() {
// Options.keyMappings has the friends key, then social interactions; Horizon's emote key
// (#10092 put it between them) is not in the file.
let out = Settings::default().serialize();
assert!(!out.contains("emote"), "{out}");
assert!(out.contains(
"\nkey_key.friends:key.keyboard.o\nkey_key.socialInteractions:key.keyboard.p\n"
));
let mut s = Settings::default();
s.keys.set("key.emote", "key.keyboard.g");
assert!(!s.serialize().contains("emote"));
}
#[test]
fn the_emote_key_is_kept_in_the_extras_file() {
let dir = scratch("emote-key");
let options = dir.join("options.txt");
// Untouched: no extras file is made for it.
Settings::default().save(&options);
assert!(!dir.join(crate::extras::FILE).exists());
assert_eq!(
Settings::load(&options).keys.get("key.emote"),
"key.keyboard.b"
);
// Rebound in Controls: saved to horizon-extras.txt, and options.txt stays vanilla's.
let mut s = Settings::load(&options);
s.keys.set("key.emote", "key.keyboard.g");
s.save(&options);
assert!(!fs::read_to_string(&options).unwrap().contains("emote"));
let extras = fs::read_to_string(dir.join(crate::extras::FILE)).unwrap();
assert!(
extras.lines().any(|l| l == "emoteKey:key.keyboard.g"),
"{extras}"
);
let back = Settings::load(&options);
assert_eq!(back.keys.get("key.emote"), "key.keyboard.g");
// horizon-ctl options-set rebuilds from options.txt text and keeps it.
assert_eq!(
back.with_option("fov", "90").unwrap().keys.get("key.emote"),
"key.keyboard.g"
);
// Reset to B and back to the default: the file says so.
let mut s = back;
s.keys.set(
"key.emote",
crate::keybinds::KeyBinds::default_of("key.emote"),
);
s.save(&options);
assert_eq!(
Settings::load(&options).keys.get("key.emote"),
"key.keyboard.b"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn the_emote_key_moves_from_options_txt_to_the_extras_file() {
// #10092 wrote `key_key.emote` into options.txt; a rebound key is kept, the next
// options.txt is vanilla's again.
let dir = scratch("emote-key-move");
let options = dir.join("options.txt");
fs::write(
&options,
"version:4000\nfov:0.5\nkey_key.emote:key.keyboard.h\nkey_key.jump:key.keyboard.j\n",
)
.unwrap();
let s = Settings::load(&options);
assert_eq!(s.keys.get("key.emote"), "key.keyboard.h");
assert_eq!(s.keys.get("key.jump"), "key.keyboard.j");
s.save(&options);
assert!(!fs::read_to_string(&options).unwrap().contains("emote"));
assert_eq!(
Settings::load(&options).keys.get("key.emote"),
"key.keyboard.h"
);
// Once horizon-extras.txt has its own line, a stale options.txt one is ignored.
fs::write(dir.join(crate::extras::FILE), "emoteKey:key.keyboard.k\n").unwrap();
fs::write(&options, "version:4000\nkey_key.emote:key.keyboard.h\n").unwrap();
assert_eq!(
Settings::load(&options).keys.get("key.emote"),
"key.keyboard.k"
);
// A value that is no key is not adopted; an unbound emote key is.
fs::remove_file(dir.join(crate::extras::FILE)).unwrap();
fs::write(&options, "version:4000\nkey_key.emote:banana\n").unwrap();
assert_eq!(
Settings::load(&options).keys.get("key.emote"),
"key.keyboard.b"
);
fs::write(
&options,
"version:4000\nkey_key.emote:key.keyboard.unknown\n",
)
.unwrap();
assert_eq!(
Settings::load(&options).keys.get("key.emote"),
"key.keyboard.unknown"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn an_option_set_keeps_horizon_files() {
let mut s = Settings::parse("fov:70\n");
@@ -170,6 +170,11 @@ impl<P: Copy, S: Copy> Lines<P, S> {
}
self.y += style::GROUP_GAP;
}
/// A group of rows under its page's own name (`list`'s `page_name`), for pages named at run
/// time, such as a shader pack's screens.
pub(crate) fn titled(&mut self, page: P, rows: Vec<S>) {
self.group(page, "", rows);
}
/// The lines and the content's height.
pub(crate) fn finish(self) -> (Vec<Line<P, S>>, f32) {
(self.lines, (self.y - style::GROUP_GAP).max(0.0))
@@ -292,14 +297,15 @@ pub(crate) fn plate(batch: &mut HudBatch, atlas: &HudAtlas, cols: &Columns, head
}
/// The list, clipped to its view: each group's panel, then its names and rows on it. `row`
/// draws a setting at its `y`.
/// draws a setting at its `y`; `page_name` names a page, and a group without a name of its own
/// (`Lines::titled`), in the player's language.
pub(crate) fn list<P: Copy, S: Copy>(
batch: &mut HudBatch,
atlas: &HudAtlas,
cols: &Columns,
lines: &[Line<P, S>],
scroll: f32,
page_name: impl Fn(P) -> &'static str,
page_name: impl Fn(P) -> String,
mut row: impl FnMut(&mut HudBatch, S, f32),
) {
let start = batch.vertices.len();
@@ -325,10 +331,11 @@ pub(crate) fn list<P: Copy, S: Copy>(
}
let Some(setting) = line.setting else {
let (name, color, dy) = match line.group {
Some((name, _)) => (name, style::MUTED, 3.0),
Some(("", _)) => (page_name(line.page), style::MUTED, 3.0),
Some((name, _)) => (lang::get(name), style::MUTED, 3.0),
None => (page_name(line.page), style::ACCENT, 4.0),
};
let name = Text::literal(lang::get(name));
let name = Text::literal(name);
batch.text_styled(atlas, &name, cols.list_x + 2.0, y + dy, color, true);
continue;
};
@@ -191,49 +191,6 @@ impl ShaderOptions {
.map(|c| Text::legacy(&c.replace("\\n", "\n")))
}
/// `option_label` within `width` as `measure` sizes it: the name shortened (`Bloom Up...: 2`)
/// when the whole doesn't fit, and whether it was.
pub fn option_label_within(
&self,
name: &str,
width: f32,
measure: impl Fn(&Text) -> f32,
) -> (Text, bool) {
let full = self.option_label(name);
if measure(&full) <= width {
return (full, false);
}
// The pack's text, cut a character at a time with the `§` codes it holds kept.
let key = format!("option.{name}");
let caption = self.lang.get(&key).unwrap_or(name);
let value = self.value_text(name);
let mut chars: Vec<char> = caption.chars().collect();
loop {
while chars.len() >= 2 && chars[chars.len() - 2] == '§' {
chars.truncate(chars.len() - 2);
}
if chars.pop().is_none() {
return (full, true);
}
// Without the spaces and codes it ends with.
let mut end = chars.len();
loop {
if end >= 2 && chars[end - 2] == '§' {
end -= 2;
} else if end > 0 && chars[end - 1].is_whitespace() {
end -= 1;
} else {
break;
}
}
let cut: String = chars[..end].iter().collect();
let label = Text::option_name_value(Text::legacy(&format!("{cut}...")), value.clone());
if measure(&label) <= width {
return (label, true);
}
}
}
/// `Name: value`, in the pack's words, the value yellow when it is not the default.
pub fn option_label(&self, name: &str) -> Text {
Text::option_name_value(
@@ -242,8 +199,13 @@ impl ShaderOptions {
)
}
/// Option `name`'s caption in the pack's words.
pub fn option_name(&self, name: &str) -> Text {
self.named(&format!("option.{name}"), name)
}
/// The value of option `name` as the pack names it, yellow when it is not the default.
fn value_text(&self, name: &str) -> Text {
pub fn value_text(&self, name: &str) -> Text {
let Some(option) = self.options.get(name) else {
return Text::literal(String::new());
};
@@ -350,7 +312,7 @@ mod tests {
}
#[test]
fn single_resets_comments_and_fitted_labels() {
fn single_resets_and_comments() {
let mut o = options();
o.lang = Lang::parse(
"option.LEVEL=Level of detail\noption.LEVEL.comment=How much detail.\\nMore costs more.\n",
@@ -364,18 +326,6 @@ mod tests {
Some("How much detail.\nMore costs more.")
);
assert!(o.comment("option.AO").is_none());
// One unit a character.
let measure = |t: &Text| t.plain().chars().count() as f32;
assert!(!o.option_label_within("LEVEL", 40.0, measure).1);
let (fitted, cut) = o.option_label_within("LEVEL", 12.0, measure);
assert!(cut);
assert_eq!(fitted.plain(), "Level...: 2");
o.lang = Lang::parse("option.LEVEL=§aLevel §lof detail§r\n");
let (fitted, _) = o.option_label_within("LEVEL", 12.0, measure);
assert_eq!(fitted.plain(), "Level...: 2");
assert_eq!(fitted.spans[0].style, Text::legacy("§aL").spans[0].style);
let (fitted, _) = o.option_label_within("LEVEL", 14.0, measure);
assert_eq!(fitted.plain(), "Level of...: 2");
}
#[test]
File diff suppressed because it is too large. Load diff
+31 -11
View File
@@ -258,19 +258,10 @@ fn root_style(component: &Tag) -> Vec<(&'static str, Tag)> {
.filter_map(|key| Some((*key, root?.get(key)?.clone())))
.collect()
}
/// Plain text of a stored text component (string, `{text: ...}` or JSON string).
/// `ComponentSerialization.CODEC`: modern NBT strings are literal text, not JSON.
fn component_text(tag: &Tag) -> String {
match tag {
Tag::String(s) => {
let s = s.to_string_lossy();
match serde_json::from_str::<serde_json::Value>(&s) {
Ok(serde_json::Value::String(text)) => text,
Ok(v) if v.get("text").is_some() => {
v["text"].as_str().unwrap_or_default().to_owned()
}
_ => s,
}
}
Tag::String(s) => s.to_string_lossy(),
// Component.getString: translations and key binds resolved.
Tag::Compound(_) | Tag::List(_) => {
scoreboard::comp_text(&scoreboard::tag_to_json(tag)).plain()
@@ -323,6 +314,35 @@ mod tests {
decoded.decode(&tag);
assert_eq!(decoded, data);
}
#[test]
fn modern_string_messages_keep_literal_quotes_and_json_text() {
let lines = [
"plain text",
"\"quoted text\"",
r#"{"text":"literal object","bold":true}"#,
"",
];
let mut saved = styled_sign();
let messages = Tag::List(lines.map(|line| Tag::String(line.into())).to_vec());
if let Tag::Compound(front) = saved.get_mut("front_text").unwrap() {
front.insert("messages".into(), messages.clone());
}
let mut data = SignData::default();
data.decode(&saved);
assert_eq!(data.front.lines, lines.map(str::to_owned));
let encoded = Tag::compound(data.encode());
assert_eq!(
encoded.get("front_text").unwrap().get("messages"),
Some(&messages)
);
let mut copied = SignData::default();
copied.decode(&encoded);
assert_eq!(copied.front.lines, data.front.lines);
assert_eq!(
front_messages(&Tag::compound(copied.encode())),
front_messages(&encoded)
);
}
/// A sign as a server sends it: a green "Online" and a plain line, the
/// way `ComponentSerialization.CODEC` writes them to NBT.
fn styled_sign() -> Tag {
+171 -15
View File
@@ -237,6 +237,7 @@ pub struct Resolver {
done: std::sync::mpsc::Receiver<(NameOrId, Option<ResolvedProfile>)>,
send: std::sync::mpsc::Sender<(NameOrId, Option<ResolvedProfile>)>,
last_sweep: std::time::Instant,
heads: HeadProfiles,
}
struct Entry {
touched: std::time::Instant,
@@ -304,6 +305,7 @@ impl Resolver {
done,
send,
last_sweep: std::time::Instant::now(),
heads: HeadProfiles::default(),
}
}
/// `CacheBuilder.expireAfterAccess` with `maximumSize`.
@@ -465,6 +467,64 @@ pub fn dynamic_nbt(profile: &horizon_world::nbt::Tag) -> Option<NameOrId> {
}
}
const MAX_HEAD_PROFILES: usize = 4096;
#[derive(Debug, PartialEq)]
struct HeadProfile {
info: ProfileInfo,
skin: Option<(String, String)>,
}
fn prepare_head(profile: &serde_json::Value) -> HeadProfile {
let info = parse_profile(profile);
let skin = info.textures.as_ref().and_then(|(value, signature)| {
let url = unpack(value, *signature, &TEXTURE_URIS).skin?;
let location = skin_location(&texture_hash(&url)?);
Some((url, location))
});
HeadProfile { info, skin }
}
#[derive(Default)]
struct HeadProfiles {
entries: std::collections::HashMap<serde_json::Value, std::sync::Arc<HeadProfile>>,
nbt: std::collections::HashMap<horizon_world::nbt::Tag, std::sync::Arc<serde_json::Value>>,
resolved: [std::collections::HashMap<String, std::sync::Arc<HeadProfile>>; 2],
}
impl HeadProfiles {
fn get(&mut self, profile: &serde_json::Value) -> std::sync::Arc<HeadProfile> {
if let Some(parsed) = self.entries.get(profile) {
return parsed.clone();
}
if self.entries.len() >= MAX_HEAD_PROFILES {
self.entries.clear();
}
let parsed = std::sync::Arc::new(prepare_head(profile));
self.entries.insert(profile.clone(), parsed.clone());
parsed
}
}
impl Resolver {
/// Cache by NBT value rather than address: equipment can be replaced or
/// mutated in place, and neither must leave a stale profile behind.
pub fn head_profile_nbt(
&mut self,
profile: &horizon_world::nbt::Tag,
) -> std::sync::Arc<serde_json::Value> {
if let Some(json) = self.heads.nbt.get(profile) {
return json.clone();
}
if self.heads.nbt.len() >= MAX_HEAD_PROFILES {
self.heads.nbt.clear();
}
let json = std::sync::Arc::new(crate::scoreboard::tag_to_json(profile));
self.heads.nbt.insert(profile.clone(), json.clone());
json
}
}
/// `PlayerSkinRenderCache.getOrDefault` for a head: the resolved skin once
/// its texture is in the atlas (`atlas`), else `partialProfile`'s default.
pub fn head_skin(
@@ -478,32 +538,54 @@ pub fn head_skin(
{
return sprite;
}
let p = parse_profile(profile);
let property = p.textures.or_else(|| {
let key = p.dynamic.as_ref()?;
let p = resolver.heads.get(profile);
// Dynamic resolution is deliberately not cached: pending answers and the
// resolver's expiring service results must still advance on every draw.
let resolved = p.info.dynamic.as_ref().and_then(|key| {
if p.info.textures.is_some() {
return None;
}
match resolver.state(key) {
Resolve::Done(Some(profile)) => profile.textures(),
Resolve::Done(Some(profile)) => {
let (_, value, signature) = profile
.properties
.iter()
.find(|(n, _, _)| n == "textures")?;
let cache = &mut resolver.heads.resolved[usize::from(signature.is_some())];
if let Some(parsed) = cache.get(value) {
return Some(parsed.clone());
}
if cache.len() >= MAX_HEAD_PROFILES {
cache.clear();
}
let property = if signature.is_some() {
serde_json::json!({"name": "textures", "value": value, "signature": ""})
} else {
serde_json::json!({"name": "textures", "value": value})
};
let parsed = std::sync::Arc::new(prepare_head(
&serde_json::json!({"properties": [property]}),
));
cache.insert(value.clone(), parsed.clone());
Some(parsed)
}
_ => None,
}
});
if let Some((value, signature)) = property
&& let Some(url) = unpack(&value, signature, &TEXTURE_URIS).skin
&& let Some(hash) = texture_hash(&url)
{
let location = skin_location(&hash);
match downloads.state(Kind::Skin, &url) {
Download::Ready if atlas(&location) => {
downloads.drawn(&location);
return location;
if let Some((url, location)) = resolved.as_deref().unwrap_or(&p).skin.as_ref() {
match downloads.state(Kind::Skin, url) {
Download::Ready if atlas(location) => {
downloads.drawn(location);
return location.clone();
}
// Its slot was evicted: offer the image again.
Download::Ready => {
downloads.again(&location);
downloads.again(location);
}
_ => {}
}
}
crate::tab_list::default_skin(p.id).to_owned()
crate::tab_list::default_skin(p.info.id).to_owned()
}
/// `PlayerSkin.Patch.apply` for a head: the sprite of a profile's `texture`
@@ -1137,6 +1219,80 @@ pub fn sha1(data: &[u8]) -> [u8; 20] {
#[cfg(test)]
mod tests {
#[test]
fn head_profile_cache_matches_uncached_and_is_bounded() {
use base64::Engine;
let mut cache = super::HeadProfiles::default();
for (url, slim) in [
("https://textures.minecraft.net/texture/abc", false),
("https://textures.minecraft.net/texture/abc", true),
("https://evil.example/texture/abc", false),
] {
let payload = serde_json::json!({"textures": {
"SKIN": {"url": url, "metadata": {"model": if slim { "slim" } else { "classic" }}},
"CAPE": {"url": "http://textures.minecraft.net/texture/cape"}
}});
let value = base64::engine::general_purpose::STANDARD.encode(payload.to_string());
for signed in [false, true] {
let mut property = serde_json::json!({"name": "textures", "value": value});
if signed {
property["signature"] = serde_json::Value::Null;
}
let profile = serde_json::json!({"properties": [property]});
let parsed = cache.get(&profile);
assert_eq!(*parsed, super::prepare_head(&profile));
let textures = super::unpack(
&value,
if signed {
super::Signature::Signed
} else {
super::Signature::Unsigned
},
&super::TEXTURE_URIS,
);
assert_eq!(textures.slim, slim && !url.contains("evil"));
assert_eq!(textures.cape.is_some(), !url.contains("evil"));
assert_eq!(parsed.skin.is_some(), !url.contains("evil"));
assert!(std::sync::Arc::ptr_eq(&parsed, &cache.get(&profile)));
}
}
for profile in [
serde_json::json!({"properties": [{"name": "textures", "value": "!bad"}]}),
serde_json::json!({"name": "Notch"}),
serde_json::json!({}),
] {
let parsed = cache.get(&profile);
assert_eq!(*parsed, super::prepare_head(&profile));
assert!(parsed.skin.is_none());
assert!(std::sync::Arc::ptr_eq(&parsed, &cache.get(&profile)));
}
cache.entries.clear();
for i in 0..super::MAX_HEAD_PROFILES {
cache.get(&serde_json::json!({"id": i}));
}
assert_eq!(cache.entries.len(), super::MAX_HEAD_PROFILES);
cache.get(&serde_json::json!({}));
assert_eq!(cache.entries.len(), 1);
}
#[test]
fn head_profile_nbt_cache_tracks_value_changes() {
let mut resolver = super::Resolver::new();
let mut tag = horizon_world::nbt::Tag::String("Notch".into());
let first = resolver.head_profile_nbt(&tag);
assert_eq!(*first, crate::scoreboard::tag_to_json(&tag));
assert!(std::sync::Arc::ptr_eq(
&first,
&resolver.head_profile_nbt(&tag)
));
tag = horizon_world::nbt::Tag::String("Alex".into());
assert_eq!(*resolver.head_profile_nbt(&tag), serde_json::json!("Alex"));
for i in 0..super::MAX_HEAD_PROFILES {
resolver.head_profile_nbt(&horizon_world::nbt::Tag::Int(i as i32));
}
assert!(resolver.heads.nbt.len() < super::MAX_HEAD_PROFILES);
}
/// A profile's `texture` names a pack texture, never a file outside the
/// packs: bad characters and `..` segments draw nothing.
#[test]
@@ -13,7 +13,7 @@ use crate::sound_events::{INTENTIONALLY_EMPTY, Sentinel, Sound, SoundRegistry};
use horizon_core::random::LegacyRandom;
use std::{
collections::HashMap,
sync::{Arc, RwLock},
sync::{Arc, OnceLock, RwLock},
};
/// `SoundSource` indices (vanilla order).
@@ -593,19 +593,25 @@ impl SoundInstance {
}
/// `AbstractSoundInstance.getVolume` (after `getOrResolve`).
pub fn get_volume(&mut self) -> f32 {
self.volume_with(self.volume)
}
fn volume_with(&mut self, volume: f32) -> f32 {
let sample = match &self.sound {
Some(s) => s.volume.sample(&mut self.random),
None => 1.0,
};
self.volume * sample
volume * sample
}
/// `AbstractSoundInstance.getPitch`.
pub fn get_pitch(&mut self) -> f32 {
self.pitch_with(self.pitch)
}
fn pitch_with(&mut self, pitch: f32) -> f32 {
let sample = match &self.sound {
Some(s) => s.pitch.sample(&mut self.random),
None => 1.0,
};
self.pitch * sample
pitch * sample
}
/// `AbstractTickableSoundInstance.stop`.
fn stop(&mut self) {
@@ -872,9 +878,36 @@ struct Handle {
channel: Option<ChannelId>,
streaming: bool,
stopped: bool,
/// Restore the unedited pitch when a later refresh no longer changes it.
pitch_edited: bool,
}
/// One operation about to apply a sound's parameters on the game thread.
/// `starting` is true for initial/delayed/loop-replay starts, false for live
/// tickable or category-volume updates. The identifier is the sound event,
/// not its selected file; source is the vanilla SoundSource index.
#[derive(Clone, Copy, Debug)]
#[non_exhaustive]
pub struct SoundPlayback<'a> {
/// Engine-local handle; not unique across split-screen engines.
pub instance: InstanceId,
pub identifier: &'a str,
pub source: usize,
pub position: [f64; 3],
pub relative: bool,
pub starting: bool,
}
pub type PlaybackHook = fn(&SoundPlayback<'_>, &mut f32, &mut f32, &mut bool);
static PLAYBACK_HOOK: OnceLock<PlaybackHook> = OnceLock::new();
/// Install native playback dispatch before constructing client audio engines.
/// No audio/device thread invokes this callback.
pub fn install_playback_hook(hook: PlaybackHook) -> Result<(), PlaybackHook> {
PLAYBACK_HOOK.set(hook)
}
pub struct SoundEngine {
pub registry: SoundRegistry,
playback_hook: Option<PlaybackHook>,
/// `loaded`: the library opened.
pub loaded: bool,
tick_count: i32,
@@ -912,6 +945,7 @@ impl SoundEngine {
pub fn new(registry: SoundRegistry) -> Self {
Self {
registry,
playback_hook: PLAYBACK_HOOK.get().copied(),
loaded: true,
tick_count: 0,
instances: HashMap::new(),
@@ -934,6 +968,42 @@ impl SoundEngine {
notifications: Vec::new(),
}
}
/// Isolate a fixture's dispatch from process-wide native installation.
#[cfg(any(test, feature = "test-support"))]
pub fn set_test_playback_hook(&mut self, hook: PlaybackHook) {
self.playback_hook = Some(hook);
}
fn playback_parameters(&self, id: InstanceId, starting: bool) -> Option<(f32, f32)> {
let instance = self.instances.get(&id)?;
let (mut volume, mut pitch) = (instance.volume, instance.pitch);
if let Some(hook) = self.playback_hook {
let sound = SoundPlayback {
instance: id,
identifier: &instance.identifier,
source: instance.source,
position: instance.position,
relative: instance.relative,
starting,
};
let mut cancel = false;
hook(&sound, &mut volume, &mut pitch, &mut cancel);
let volume_edited = volume.to_bits() != instance.volume.to_bits();
let pitch_edited = pitch.to_bits() != instance.pitch.to_bits();
if cancel
|| (volume_edited && (!volume.is_finite() || volume < 0.0))
|| (pitch_edited && (!pitch.is_finite() || pitch <= 0.0))
{
return None;
}
}
Some((volume, pitch))
}
fn cancel_playback(&mut self, id: InstanceId, backend: &mut impl Backend) {
if let Some(instance) = self.instances.get_mut(&id) {
instance.stop();
}
self.stop(id, backend);
}
/// Hand an instance to the engine. A retained instance stays until
/// `release`; others go once the engine no longer refers to them.
pub fn add(&mut self, instance: SoundInstance, retain: bool) -> InstanceId {
@@ -1027,10 +1097,27 @@ impl SoundEngine {
continue;
};
if source == instance.source || source == MASTER {
let (v, s) = (instance.get_volume(), instance.source);
let original_pitch = instance.pitch;
let Some((base_volume, base_pitch)) = self.playback_parameters(id, false) else {
self.cancel_playback(id, backend);
continue;
};
let instance = self.instances.get_mut(&id).expect("present");
let (v, s) = (instance.volume_with(base_volume), instance.source);
let pitch_edited = base_pitch.to_bits() != original_pitch.to_bits();
// Restore a prior edit, but an always-no-op hook samples exactly as before.
let restore_pitch = self.handles.get(&handle).is_some_and(|h| h.pitch_edited);
let pitch = (pitch_edited || restore_pitch)
.then(|| Self::calculate_pitch(instance.pitch_with(base_pitch)));
let volume = self.calculate_volume(v, s);
if let Some(h) = self.handles.get_mut(&handle) {
h.pitch_edited = pitch_edited;
}
if let Some(c) = self.handles.get(&handle).and_then(handle_channel) {
backend.set_volume(c, volume);
if let Some(pitch) = pitch {
backend.set_pitch(c, pitch);
}
}
}
}
@@ -1207,8 +1294,11 @@ impl SoundEngine {
if sound.sentinel != Sentinel::None {
return PlayResult::NotStarted;
}
let Some((base_volume, base_pitch)) = self.playback_parameters(id, true) else {
return PlayResult::NotStarted;
};
let instance = self.instances.get_mut(&id).expect("present");
let instance_volume = instance.get_volume();
let instance_volume = instance.volume_with(base_volume);
let attenuation_distance = sound.attenuation_distance(instance_volume);
if self.listening {
self.notify(id, subtitle, attenuation_distance);
@@ -1252,6 +1342,7 @@ impl SoundEngine {
channel: Some(channel),
streaming,
stopped: false,
pitch_edited: base_pitch.to_bits() != self.instances[&id].pitch.to_bits(),
},
);
self.delete_time.insert(id, self.tick_count + 20);
@@ -1264,7 +1355,7 @@ impl SoundEngine {
self.instance_by_source.push((source, id));
}
let instance = self.instances.get_mut(&id).expect("present");
let pitch = Self::calculate_pitch(instance.get_pitch());
let pitch = Self::calculate_pitch(instance.pitch_with(base_pitch));
let (attenuation, relative, tickable) =
(instance.attenuation, instance.relative, instance.tickable());
backend.set_pitch(channel, pitch);
@@ -1350,13 +1441,22 @@ impl SoundEngine {
self.stop(id, backend);
continue;
}
let (v, source) = (instance.get_volume(), instance.source);
let pitch = Self::calculate_pitch(instance.get_pitch());
let Some((base_volume, base_pitch)) = self.playback_parameters(id, false) else {
self.cancel_playback(id, backend);
continue;
};
let instance = self.instances.get_mut(&id).expect("present");
let pitch_edited = base_pitch.to_bits() != instance.pitch.to_bits();
let (v, source) = (instance.volume_with(base_volume), instance.source);
let pitch = Self::calculate_pitch(instance.pitch_with(base_pitch));
let position = instance.position;
let volume = self.calculate_volume(v, source);
let Some(handle) = self.channel_of(id) else {
continue;
};
if let Some(h) = self.handles.get_mut(&handle) {
h.pitch_edited = pitch_edited;
}
if let Some(c) = self.handles.get(&handle).and_then(handle_channel) {
backend.set_volume(c, volume);
backend.set_pitch(c, pitch);
@@ -1370,7 +1470,7 @@ impl SoundEngine {
&& let (Some(sound), Some(subtitle)) =
(instance.sound.clone(), instance.event.clone())
{
let distance = sound.attenuation_distance(instance.get_volume());
let distance = sound.attenuation_distance(instance.volume_with(base_volume));
self.notify(id, subtitle, distance);
}
}
@@ -1995,3 +2095,7 @@ mod entity_bound_tests {
assert_ne!(sound.position, replacement.state().position);
}
}
#[cfg(test)]
#[path = "sound_engine/playback_tests.rs"]
mod playback_tests;
@@ -0,0 +1,188 @@
use super::*;
use horizon_core::random::RandomSource;
fn setup() -> (SoundEngine, test_backend::Null) {
let mut registry = SoundRegistry::default();
registry.add_file(
"test",
br#"{"beep":{"subtitle":"test.beep","sounds":[{"name":"beep","volume":0.5,"pitch":0.5}]}}"#,
&|_, _| true,
);
let mut engine = SoundEngine::new(registry);
engine.playback_hook = None;
engine.listening = true;
(engine, test_backend::Null::default())
}
fn instance() -> SoundInstance {
SoundInstance::at(
"test:beep",
4,
0.8,
1.2,
LegacyRandom::new(1),
[2.0, 3.0, 4.0],
)
}
fn adjust(sound: &SoundPlayback<'_>, volume: &mut f32, pitch: &mut f32, _: &mut bool) {
assert_eq!(sound.identifier, "test:beep");
assert_eq!(sound.source, 4);
assert!(!sound.relative);
*volume *= 0.5;
*pitch = 2.0;
}
#[test]
fn played_parameters_reach_channels_without_changing_the_instance() {
let (mut engine, mut backend) = setup();
engine.set_test_playback_hook(adjust);
let (id, result) = engine.play_new(instance(), true, &mut backend);
assert_eq!(result, PlayResult::Started);
let channel = engine.channel(id).unwrap();
assert_eq!(backend.volume[&channel], 0.2);
assert_eq!(backend.pitch[&channel], 1.0);
assert_eq!(backend.position[&channel], [2.0, 3.0, 4.0]);
assert_eq!(engine.instances[&id].volume, 0.8);
assert_eq!(engine.instances[&id].pitch, 1.2);
assert_eq!(engine.notifications.len(), 1);
}
#[test]
fn delayed_and_manually_looped_plays_do_not_compound_edits() {
let (mut engine, mut backend) = setup();
engine.set_test_playback_hook(adjust);
let mut sound = instance();
sound.looping = true;
sound.delay = 2;
let id = engine.add(sound, true);
engine.play_delayed(id, 2);
engine.tick(false, &TickContext::default(), &mut backend);
assert!(backend.volume.is_empty() && engine.notifications.is_empty());
engine.tick(false, &TickContext::default(), &mut backend);
let first = engine.channel(id).unwrap();
assert_eq!(backend.volume[&first], 0.2);
backend.stopped.insert(first, true);
for _ in 0..22 {
engine.tick(false, &TickContext::default(), &mut backend);
}
let replay = engine.channel(id).unwrap();
assert_ne!(first, replay);
assert_eq!(backend.volume[&replay], 0.2);
assert_eq!(engine.instances[&id].volume, 0.8);
}
#[test]
fn tickable_and_category_updates_keep_transient_edits_and_can_cancel() {
let (mut engine, mut backend) = setup();
engine.set_test_playback_hook(adjust);
let mut sound = instance();
sound.ticking = Ticking::Native;
let (id, _) = engine.play_new(sound, true, &mut backend);
let channel = engine.channel(id).unwrap();
let sound = engine.instance_mut(id).unwrap();
sound.volume = 0.4;
sound.position = [8.0, 9.0, 10.0];
engine.tick(false, &TickContext::default(), &mut backend);
assert_eq!(backend.volume[&channel], 0.1);
assert_eq!(backend.position[&channel], [8.0, 9.0, 10.0]);
engine.set_test_playback_hook(|_, volume, _, _| *volume *= 0.5);
engine.update_category_volume(4, 0.5, &mut backend);
assert_eq!(backend.volume[&channel], 0.05);
assert_eq!(
backend.pitch[&channel], 0.6,
"a later no-pitch edit restores the base pitch"
);
engine.notifications.clear();
engine.set_test_playback_hook(|sound, _, _, cancel| {
assert!(!sound.starting);
*cancel = true;
});
engine.tick(false, &TickContext::default(), &mut backend);
assert!(backend.stopped(channel));
assert!(engine.notifications.is_empty());
}
#[test]
fn cancellation_and_invalid_edits_never_allocate_or_notify() {
let edits: [PlaybackHook; 6] = [
|_, _, _, cancel| *cancel = true,
|_, volume, _, _| *volume = f32::NAN,
|_, volume, _, _| *volume = f32::INFINITY,
|_, volume, _, _| *volume = -1.0,
|_, _, pitch, _| *pitch = 0.0,
|_, _, pitch, _| *pitch = f32::NEG_INFINITY,
];
for hook in edits {
let (mut engine, mut backend) = setup();
engine.set_test_playback_hook(hook);
let (_, result) = engine.play_new(instance(), false, &mut backend);
assert_eq!(result, PlayResult::NotStarted);
assert!(backend.stopped.is_empty() && engine.notifications.is_empty());
}
}
#[test]
fn no_op_dispatch_preserves_random_sampling_and_failed_pool_behavior() {
let (mut plain, mut plain_backend) = setup();
let (mut hooked, mut hooked_backend) = setup();
hooked.set_test_playback_hook(|_, _, _, _| {});
for (engine, backend) in [
(&mut plain, &mut plain_backend),
(&mut hooked, &mut hooked_backend),
] {
engine.limits = [0, 0];
let id = engine.add(instance(), true);
assert_eq!(engine.play(id, backend), PlayResult::NotStarted);
engine.limits = [247, 8];
assert_eq!(engine.play(id, backend), PlayResult::Started);
}
assert_eq!(plain_backend.volume, hooked_backend.volume);
assert_eq!(plain_backend.pitch, hooked_backend.pitch);
assert_eq!(plain.notifications, hooked.notifications);
assert_eq!(
plain.instances.get_mut(&1).unwrap().random.next_long(),
hooked.instances.get_mut(&1).unwrap().random.next_long()
);
}
#[test]
fn entity_bound_updates_are_intercepted_at_the_new_position() {
let (mut engine, mut backend) = setup();
let entity = SoundEntity::new(42, [2.0, 3.0, 4.0], false);
engine.set_test_playback_hook(|sound, volume, _, _| {
if !sound.starting {
assert_eq!(sound.position, [8.0, 9.0, 10.0]);
}
*volume *= 0.5;
});
let sound = SoundInstance::entity_bound(
"test:beep",
4,
0.8,
1.0,
LegacyRandom::new(1),
entity.clone(),
);
let (id, _) = engine.play_new(sound, true, &mut backend);
entity.update(SoundEntityState {
position: [8.0, 9.0, 10.0],
silent: false,
removed: false,
});
engine.tick(false, &TickContext::default(), &mut backend);
let channel = engine.channel(id).unwrap();
assert_eq!(backend.position[&channel], [8.0, 9.0, 10.0]);
assert_eq!(backend.volume[&channel], 0.2);
}
#[test]
fn a_volume_only_edit_preserves_legacy_zero_pitch_clamping() {
let (mut engine, mut backend) = setup();
engine.set_test_playback_hook(|_, volume, _, _| *volume *= 0.5);
let mut sound = instance();
sound.pitch = 0.0;
let (id, result) = engine.play_new(sound, true, &mut backend);
assert_eq!(result, PlayResult::Started);
let channel = engine.channel(id).unwrap();
assert_eq!(backend.volume[&channel], 0.2);
assert_eq!(backend.pitch[&channel], 0.5);
}
+197 -15
View File
@@ -22,6 +22,11 @@ thread_local! {
static NOTES: RefCell<Vec<(f32, String)>> = const { RefCell::new(Vec::new()) };
}
/// The most spans a thread holds before they are taken ([`take_raw`]): a worker thread
/// that records spans (`lod_reduce`, `lt_*`) while they are on for every thread is never
/// taken from, so it drops what it held instead of growing for as long as they stay on.
const UNTAKEN_MAX: usize = 1 << 14;
/// The shortest step a [`note`] keeps, in milliseconds.
pub const NOTE_MS: f32 = 10.0;
/// The notes a thread keeps between two [`take_notes`]: its slowest steps.
@@ -53,7 +58,7 @@ pub fn record_here(on: bool) {
/// Whether this thread records spans: all of them on, or [`record_here`].
#[inline]
pub fn recording_here() -> bool {
ENABLED.load(Ordering::Relaxed) || HERE.with(Cell::get)
ENABLED.load(Ordering::Relaxed) || HERE.with(Cell::get) || trace::on()
}
/// The thread's CPU time when spans are on for every thread (`HORIZON_SPIKES`): only they
/// print it.
@@ -101,16 +106,41 @@ impl Drop for Guard {
.and_then(|start| Some((thread_cpu()?, start)))
.map_or(ms, |(end, start)| (end - start).as_secs_f32() * 1000.0);
DEPTH.with(|d| d.set(depth));
SPANS.with(|s| {
s.borrow_mut().push(Span {
name,
ms,
cpu_ms,
depth,
})
trace::span(name, started, ms);
push(Span {
name,
ms,
cpu_ms,
depth,
});
}
}
/// Keeps a finished span for the thread's next [`take_raw`].
fn push(span: Span) {
SPANS.with(|s| {
let mut s = s.borrow_mut();
if s.len() >= UNTAKEN_MAX {
s.clear();
}
s.push(span);
});
}
/// Attach per-tick counts without interpreting them as durations.
pub fn counters(counts: &[(&str, u64)]) {
if !enabled() {
return;
}
NOTES.with(|n| {
n.borrow_mut().push((
0.0,
counts
.iter()
.map(|(name, count)| format!("{name}={count}"))
.collect::<Vec<_>>()
.join(", "),
))
});
}
/// Notes that one step inside a span took `ms` milliseconds doing `what` (one player's
/// packets, say), when this thread records spans and the step took at least [`NOTE_MS`]: a
/// span says where a slow tick went, its notes whose doing it was. Only the slowest few are
@@ -163,13 +193,12 @@ impl Laps {
.zip(*cpu)
.map_or(ms, |(end, start)| (end - start).as_secs_f32() * 1000.0);
let depth = DEPTH.with(Cell::get);
SPANS.with(|s| {
s.borrow_mut().push(Span {
name,
ms,
cpu_ms,
depth,
})
trace::span(name, *started, ms);
push(Span {
name,
ms,
cpu_ms,
depth,
});
(*started, *cpu) = (now, now_cpu);
}
@@ -271,6 +300,159 @@ pub fn describe(spans: &[Span]) -> String {
list.join(", ")
}
/// The run's timeline: every span ([`time`], [`guard`], [`Laps::lap`]) on every thread, as
/// Chrome trace events that Perfetto (ui.perfetto.dev) opens as they are. `HORIZON_TRACE=FILE`
/// writes it from the start; [`start`](trace::start) and [`stop`](trace::stop) write one for a
/// while (`horizon-trace` on a dedicated server's console, `trace` on the control socket). Spans
/// show on their threads where they ran, nested by time, so what ran beside what, and what a
/// thread waited for, can be read off. Off, a span costs one relaxed load more.
pub mod trace {
use std::{
cell::Cell,
fs::File,
io::{BufWriter, Write as _},
path::Path,
sync::{
Mutex, PoisonError,
atomic::{AtomicBool, AtomicU32, Ordering},
},
time::Instant,
};
static ON: AtomicBool = AtomicBool::new(false);
static SINK: Mutex<Option<Sink>> = Mutex::new(None);
/// Counts the traces started: a thread names itself again in each one.
static TRACES: AtomicU32 = AtomicU32::new(0);
static NEXT_THREAD: AtomicU32 = AtomicU32::new(1);
thread_local! {
/// This thread's id in traces (0: none yet).
static THREAD: Cell<u32> = const { Cell::new(0) };
/// The trace this thread last named itself in.
static NAMED_IN: Cell<u32> = const { Cell::new(0) };
}
/// How often a trace's buffer goes to the file, at most: a run that ends without
/// [`stop`] loses no more than this.
const FLUSH_EVERY: std::time::Duration = std::time::Duration::from_secs(1);
struct Sink {
file: BufWriter<File>,
origin: Instant,
/// Until when it records ([`start`]'s `seconds`).
until: Option<Instant>,
flushed: Instant,
/// Events written: the next one is preceded by a comma.
events: u64,
}
impl Sink {
fn event(&mut self, event: std::fmt::Arguments) {
let comma = if self.events == 0 { "" } else { ",\n" };
let _ = write!(self.file, "{comma}{{\"pid\":1,{event}}}");
self.events += 1;
}
}
/// Whether a trace is being written.
#[inline]
pub fn on() -> bool {
ON.load(Ordering::Relaxed)
}
/// Starts a trace written to `path` (replacing a trace being written), for `seconds` or
/// until [`stop`].
pub fn start(path: &Path, seconds: Option<u32>) -> std::io::Result<()> {
let mut file = BufWriter::with_capacity(1 << 18, File::create(path)?);
file.write_all(b"[\n")?;
let now = Instant::now();
let sink = Sink {
file,
origin: now,
until: seconds.map(|s| now + std::time::Duration::from_secs(u64::from(s))),
flushed: now,
events: 0,
};
let mut slot = SINK.lock().unwrap_or_else(PoisonError::into_inner);
if let Some(old) = slot.take() {
close(old);
}
*slot = Some(sink);
TRACES.fetch_add(1, Ordering::Relaxed);
ON.store(true, Ordering::Relaxed);
Ok(())
}
/// Starts the trace `HORIZON_TRACE` names, if it names one.
pub fn start_from_env() {
if let Some(path) = std::env::var_os("HORIZON_TRACE")
&& let Err(e) = start(Path::new(&path), None)
{
log::warn!("Horizon: HORIZON_TRACE: {e}");
}
}
/// Ends the trace being written: the file is complete JSON. The events written so far
/// and whether one was being written.
pub fn stop() -> Option<u64> {
ON.store(false, Ordering::Relaxed);
let sink = SINK.lock().unwrap_or_else(PoisonError::into_inner).take()?;
let events = sink.events;
close(sink);
Some(events)
}
/// Whether the trace being written has run its time ([`start`]'s `seconds`): the caller
/// then [`stop`]s it.
pub fn due() -> bool {
on() && SINK
.lock()
.unwrap_or_else(PoisonError::into_inner)
.as_ref()
.and_then(|s| s.until)
.is_some_and(|until| Instant::now() >= until)
}
fn close(mut sink: Sink) {
let _ = sink.file.write_all(b"\n]\n");
let _ = sink.file.flush();
}
/// A span of this thread from `started`, `ms` long.
pub(super) fn span(name: &'static str, started: Instant, ms: f32) {
if !on() {
return;
}
let tid = THREAD.with(|t| {
if t.get() == 0 {
t.set(NEXT_THREAD.fetch_add(1, Ordering::Relaxed));
}
t.get()
});
let trace = TRACES.load(Ordering::Relaxed);
let mut slot = SINK.lock().unwrap_or_else(PoisonError::into_inner);
let Some(sink) = slot.as_mut() else {
return;
};
if NAMED_IN.with(|n| n.replace(trace)) != trace {
let name = std::thread::current()
.name()
.unwrap_or("unnamed")
.replace(['"', '\\'], "_");
sink.event(format_args!(
r#""ph":"M","name":"thread_name","tid":{tid},"args":{{"name":"{name}"}}"#
));
}
let ts = started.saturating_duration_since(sink.origin).as_secs_f64() * 1e6;
sink.event(format_args!(
r#""ph":"X","name":"{name}","tid":{tid},"ts":{ts:.3},"dur":{:.3}"#,
f64::from(ms) * 1e3
));
let now = Instant::now();
if now.duration_since(sink.flushed) >= FLUSH_EVERY {
let _ = sink.file.flush();
sink.flushed = now;
}
}
}
/// The start's timeline: `HORIZON_STARTUP_TRACE=FILE` writes every named step of
/// the start (resource tables, the renderer, the title, a world's open, a
/// server's join, a dimension change) on whichever thread ran it, one Chrome
@@ -8,7 +8,7 @@ use crate::{
gamepad::{Button as PadButton, GlyphStyle},
graphics_style,
hud::{HudAtlas, HudBatch, rgba},
lang,
lang, pad_glyph,
text::Text,
ui,
};
@@ -411,30 +411,23 @@ impl SplitProfiles {
None => tr("horizon.splitscreen.profiles.move", "Move where you play"),
};
let keys = [PadButton::LeftBumper, PadButton::RightBumper];
let key_width = |b: PadButton| width(b.label(style)) + 4.0;
let key_width = |b: PadButton| pad_glyph::width(atlas, b, style);
let total = keys.iter().map(|&b| key_width(b) + 4.0).sum::<f32>() + 2.0 + width(&words);
let (mut x, y) = (((batch.width - total) / 2.0).floor(), 30.0);
for b in keys {
let [r, g, bl] = b.color(style);
let kw = key_width(b);
// A light edge around the dark key: it stands out on the menu's dark background.
batch.fill(
atlas,
x - 2.0,
y - 3.0,
kw + 2.0,
13.0,
rgba(170, 170, 170, 255),
);
batch.fill(atlas, x - 1.0, y - 2.0, kw, 11.0, rgba(40, 40, 40, 255));
batch.text(
atlas,
b.label(style),
x + 1.0,
y,
rgba(r, g, bl, 255),
false,
);
// A light edge around a dark key: it stands out on the menu's dark background.
if pad_glyph::art(atlas, b, style).is_none() {
batch.fill(
atlas,
x - 2.0,
y - 3.0,
kw + 2.0,
pad_glyph::HEIGHT + 2.0,
rgba(170, 170, 170, 255),
);
}
pad_glyph::draw(batch, atlas, b, style, x, y);
x += kw + 4.0;
}
batch.text(atlas, &words, x + 2.0, y, rgba(240, 240, 240, 255), true);
+113 -39
View File
@@ -544,6 +544,12 @@ pub struct Streaming {
/// Chunks come from a server (`insert_remote`/`forget`): nothing is
/// generated and columns are only unloaded when the server says so.
pub remote: bool,
/// This server sends bulk block edits as whole columns whose light is
/// from before its relight (a Horizon server): a resent resident column
/// is lit here. Any other server's packet carries the light vanilla
/// applies, so it is not lit again (a proxy's next server resends its
/// whole world at the same coordinates).
pub relight_resent: bool,
/// More areas to keep loaded (centre chunk, radius), e.g. around the
/// players of a server. Each is a square, like vanilla's ticket levels:
/// chunks finish out to `radius + 1` (Chebyshev) and generation reaches
@@ -836,7 +842,14 @@ impl<G: ChunkGenerator> Source for Jobs<G> {
Job::Mesh(..) => 5,
_ => 6,
};
(kind, crate::spans::thread_cpu().unwrap_or_default())
// The other jobs (saves, light views, freed meshes) are many and short: counted, not
// timed, so the clock reads don't cost more than they do.
let cpu = if kind == 6 {
std::time::Duration::ZERO
} else {
crate::spans::thread_cpu().unwrap_or_default()
};
(kind, cpu)
});
let _tag = horizon_threads::heap_tag(match &job.job {
Job::Generate(..) => "stream.generate",
@@ -865,8 +878,13 @@ impl<G: ChunkGenerator> Source for Jobs<G> {
} else {
kind
};
let cpu = crate::spans::thread_cpu().unwrap_or_default();
job_stats::add(kind, cpu.saturating_sub(started).as_nanos() as u64);
let nanos = if kind == 6 {
0
} else {
let cpu = crate::spans::thread_cpu().unwrap_or_default();
cpu.saturating_sub(started).as_nanos() as u64
};
job_stats::add(kind, nanos);
}
if let Some(done) = done {
self.shared.done.send(done);
@@ -1096,6 +1114,7 @@ impl Streaming {
generated_total: 0,
meshed_total: 0,
remote: false,
relight_resent: false,
extra: Vec::new(),
cover: None,
mesh: true,
@@ -1142,14 +1161,15 @@ impl Streaming {
}
/// A resident column a server sent again whole (it changed it in bulk):
/// its new blocks taken, the sections that did not change kept as they
/// were, and its light and its neighbours' worked out again here, off
/// the game thread, as light is for the blocks a server changes one at a
/// time; the sections that changed remesh (with theirs) once lit. Until
/// then the sections that changed show the light the packet carried (a
/// Horizon server sends the light from before its relight, then the
/// new light; another server's is already right, which matters where
/// the neighbours this light needs aren't all here).
pub fn replace_remote(&mut self, world: &mut World, mut chunk: Chunk) {
/// were, and (for a Horizon server's bulk edit) its light and its
/// neighbours' worked out again here, off the game thread, as light is for
/// the blocks a server changes one at a time; the sections that changed
/// remesh (with theirs) once lit. Until then the sections that changed
/// show the light the packet carried (a Horizon server sends the light
/// from before its relight, then the new light; another server's is already
/// right, which matters where the neighbours this light needs aren't all
/// here).
pub fn replace_remote(&mut self, world: &mut World, chunk: Chunk) {
let key = (chunk.x, chunk.z);
let Some(old) = world.chunks.get(&key).cloned() else {
return self.insert_remote(world, chunk);
@@ -1157,34 +1177,7 @@ impl Streaming {
if !self.lit.contains(&key) {
return self.insert_remote(world, chunk);
}
// The cells whose blocks changed, by section: they remesh with the neighbours within a block of them (as
// vanilla's `setBlocksDirty`), not every section round each section changed (which, for the few blocks a
// section often changes in a bulk edit, is mostly sections that did not).
let mut cells = crate::light::LightChanges::default();
cells.keyed(world.layout());
let kept = keep_unchanged_sections(&old, &mut chunk, &mut cells);
chunk.light_loaded = old.light_loaded;
chunk.lit = true;
world.chunks.insert(key, Arc::new(chunk));
world.revision = world.revision.wrapping_add(1);
// A section with new biomes and the same blocks remeshes now (its tint), with its neighbours, whose tint
// blends across the border: above and below too, as a block's tint can sample the biome quart of the
// section next to it (`BiomeManager.getBiome`'s zoom reaches a quart beyond the block).
Self::light_updated(world, key.0, key.1, &kept.biomes);
// A section with the same blocks and new light takes the packet's light (vanilla's
// `handleLevelChunkWithLight` always applies it) and remeshes as a light update would.
Self::light_updated(world, key.0, key.1, &kept.light);
if !kept.blocks {
return;
}
for dz in -1..=1 {
for dx in -1..=1 {
world.dirty_light.insert((key.0 + dx, key.1 + dz));
}
}
cells.dirty_sections(|section| {
world.dirty_sections.insert(section);
});
take_resent_column(world, key, &old, chunk, self.relight_resent);
}
/// A server's light update for a resident column: the sections whose
/// light changed and their neighbours remesh
@@ -3034,6 +3027,51 @@ fn keep_unchanged_sections(
}
out
}
/// Takes the server's new `chunk` over the resident column `old`: the sections
/// whose blocks did not change keep `old`'s (with `chunk`'s biomes and light),
/// and the sections whose blocks changed remesh. `relight` says the packet's
/// light is from before the server's relight (a Horizon server's bulk edit), so
/// the column and its neighbours are lit here; another server's packet already
/// carries the light vanilla applies, so no light job is queued for it.
fn take_resent_column(
world: &mut World,
key: (i32, i32),
old: &Chunk,
mut chunk: Chunk,
relight: bool,
) {
// The cells whose blocks changed, by section: they remesh with the neighbours within a block of them (as
// vanilla's `setBlocksDirty`), not every section round each section changed (which, for the few blocks a
// section often changes in a bulk edit, is mostly sections that did not).
let mut cells = crate::light::LightChanges::default();
cells.keyed(world.layout());
let kept = keep_unchanged_sections(old, &mut chunk, &mut cells);
chunk.light_loaded = old.light_loaded;
chunk.lit = true;
world.chunks.insert(key, Arc::new(chunk));
world.revision = world.revision.wrapping_add(1);
// A section with new biomes and the same blocks remeshes now (its tint), with its neighbours, whose tint
// blends across the border: above and below too, as a block's tint can sample the biome quart of the
// section next to it (`BiomeManager.getBiome`'s zoom reaches a quart beyond the block).
Streaming::light_updated(world, key.0, key.1, &kept.biomes);
// A section with the same blocks and new light takes the packet's light (vanilla's
// `handleLevelChunkWithLight` always applies it) and remeshes as a light update would.
Streaming::light_updated(world, key.0, key.1, &kept.light);
if !kept.blocks {
return;
}
if relight {
for dz in -1..=1 {
for dx in -1..=1 {
world.dirty_light.insert((key.0 + dx, key.1 + dz));
}
}
}
cells.dirty_sections(|section| {
world.dirty_sections.insert(section);
});
}
/// Whether two sections' packed light (4,096 cells) holds the same values.
fn same_light(a: &crate::chunk::Light, b: &crate::chunk::Light) -> bool {
a.same_as(b)
@@ -4203,6 +4241,42 @@ mod tests {
);
}
}
fn resident_column(world: &mut World, light: u8) -> Chunk {
let mut chunk = Chunk::empty(2, -3);
chunk.set(1, 10, 1, 1);
Arc::make_mut(&mut chunk.sections[0]).light = crate::chunk::Light::Uniform(light);
chunk.lit = true;
world.chunks.insert((2, -3), Arc::new(chunk.clone()));
chunk
}
/// A server's resent column uses the light in its packet, as vanilla's
/// `handleLevelChunkWithLight` does: only a Horizon server's bulk edit,
/// whose packet light is from before its relight, is relit here. A proxy's
/// next server resends its whole world at the same coordinates, and
/// relighting every column and its neighbours is work that world never
/// asked for (#10273).
#[test]
fn a_resent_column_is_relit_only_for_a_horizon_servers_bulk_edit() {
for relight in [false, true] {
let mut world = World::new(horizon_world::registry::BlockStateId(0), 0);
let old = resident_column(&mut world, 0x50);
let mut new = old.clone();
new.set(1, 10, 1, 2);
Arc::make_mut(&mut new.sections[0]).light = crate::chunk::Light::Uniform(0xF0);
take_resent_column(&mut world, (2, -3), &old, new, relight);
let chunk = &world.chunks[&(2, -3)];
assert_eq!(chunk.block(1, 10, 1).0, 2);
// The packet's light is what the column shows.
assert_eq!(chunk.sections[0].light.get(0), 0xF0);
// The changed section remeshes either way.
assert!(world.dirty_sections.contains(&(2, 0, -3)));
// Only a Horizon bulk edit queues light work, for the column and
// its eight neighbours.
assert_eq!(world.dirty_light.len(), if relight { 9 } else { 0 });
}
}
}
#[cfg(test)]
+197 -36
View File
@@ -12,7 +12,11 @@ use crate::{
scoreboard::{Comp, NumberFormat, Objective, RenderType, Scoreboard, comp_text},
text::Text,
};
use std::{borrow::Cow, cmp::Ordering, collections::HashMap};
use std::{
borrow::Cow,
cmp::Ordering,
collections::{HashMap, HashSet},
};
/// `GameType.SPECTATOR` id.
pub const SPECTATOR: i32 = 3;
@@ -124,10 +128,18 @@ pub fn face(
}
}
/// `Hud.extractTabList`: whether the list shows. A single-player world
/// shows it only with another listed player or a `list` objective.
pub fn visible(key_down: bool, local_server: bool, listed: usize, objective: bool) -> bool {
key_down && !(local_server && listed <= 1 && !objective)
/// `Hud.extractTabList`: whether the list shows. `Hud.extractRenderState`
/// only calls it inside `if (!isHidden)`, so F1 takes the list away whatever
/// else holds it up. A single-player world shows it only with another listed
/// player or a `list` objective.
pub fn visible(
hud_shown: bool,
key_down: bool,
local_server: bool,
listed: usize,
objective: bool,
) -> bool {
hud_shown && key_down && !(local_server && listed <= 1 && !objective)
}
/// `Options.getBackgroundColor(553648127)`: the row background.
@@ -146,12 +158,14 @@ fn compare_utf16(a: &str, b: &str) -> Ordering {
/// `String.compareToIgnoreCase` (per code point: upper then lower case).
fn compare_ignore_case(a: &str, b: &str) -> Ordering {
fn simple(c: char, upper: bool) -> char {
let mut it: Box<dyn Iterator<Item = char>> = if upper {
Box::new(c.to_uppercase())
let pair = if upper {
let mut it = c.to_uppercase();
(it.next(), it.next())
} else {
Box::new(c.to_lowercase())
let mut it = c.to_lowercase();
(it.next(), it.next())
};
match (it.next(), it.next()) {
match pair {
(Some(m), None) => m,
_ => c,
}
@@ -181,23 +195,25 @@ fn compare_ignore_case(a: &str, b: &str) -> Ordering {
/// list order first, spectators last, team name, profile name ignoring
/// case), at most 80 players.
pub fn sorted_players<'a>(players: &'a [TabPlayer], board: &Scoreboard) -> Vec<&'a TabPlayer> {
let team = |p: &TabPlayer| {
board
.players_team(&p.name)
.map_or("", |t| t.name.as_str())
.to_owned()
};
let mut out: Vec<&TabPlayer> = players.iter().collect();
out.sort_by(|a, b| {
let mut out: Vec<_> = players
.iter()
.map(|p| {
(
p,
board.players_team(&p.name).map_or("", |t| t.name.as_str()),
)
})
.collect();
out.sort_by(|(a, team_a), (b, team_b)| {
a.list_order
.wrapping_neg()
.cmp(&b.list_order.wrapping_neg())
.then_with(|| (a.game_mode == SPECTATOR).cmp(&(b.game_mode == SPECTATOR)))
.then_with(|| compare_utf16(&team(a), &team(b)))
.then_with(|| compare_utf16(team_a, team_b))
.then_with(|| compare_ignore_case(&a.name, &b.name))
});
out.truncate(80);
out
out.into_iter().map(|(p, _)| p).collect()
}
/// What `setVisible(true)` has the narrator say:
@@ -339,6 +355,26 @@ pub struct TabList {
pub footer: Option<Text>,
visible: bool,
health: HashMap<u128, HealthState>,
cache: Option<LayoutCache>,
order: Option<(Vec<TabPlayer>, Scoreboard, Vec<u128>)>,
}
#[derive(Clone)]
struct LayoutCache {
players: Vec<TabPlayer>,
board: Scoreboard,
objective: Option<Objective>,
header: Option<Text>,
footer: Option<Text>,
language: Option<std::sync::Arc<crate::lang::Language>>,
dimensions: (i32, bool, u32, u64),
flags: Vec<(bool, bool)>,
draws: Vec<GuiDraw>,
hearts: Vec<(usize, i32, i32, i32, u128, i32)>,
}
impl std::fmt::Debug for LayoutCache {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LayoutCache").finish_non_exhaustive()
}
}
struct Entry {
name: Text,
@@ -363,10 +399,87 @@ impl TabList {
self.header = None;
self.footer = None;
}
/// `extractRenderState`.
/// Share the cached display order with skin residency selection.
pub fn displayed_ids(&mut self, players: &[TabPlayer], board: &Scoreboard) -> &[u128] {
if self.order.as_ref().is_none_or(|(old, previous, _)| {
old.len() != players.len()
|| old.iter().zip(players).any(|(a, b)| {
a.uuid != b.uuid
|| a.name != b.name
|| a.list_order != b.list_order
|| a.game_mode != b.game_mode
})
|| previous != board
}) {
let ids = sorted_players(players, board)
.iter()
.map(|p| p.uuid)
.collect();
self.order = Some((players.to_vec(), board.clone(), ids));
}
&self.order.as_ref().unwrap().2
}
/// `extractRenderState`: retain text, measurements and geometry, not animated hearts.
pub fn layout(&mut self, i: &TabInputs, font: &Font) -> Vec<GuiDraw> {
let language = crate::lang::current();
let dimensions = (i.screen_width, i.online_mode, i.background, font.epoch());
let flags: Vec<_> = i
.players
.iter()
.map(|p| ((i.upside_down)(p.uuid), (i.horizon)(p.uuid)))
.collect();
let valid = self.cache.as_ref().is_some_and(|c| {
c.players == i.players
&& c.board == *i.board
&& c.objective.as_ref() == i.objective
&& c.header == self.header
&& c.footer == self.footer
&& c.dimensions == dimensions
&& c.flags == flags
&& match (&c.language, &language) {
(Some(a), Some(b)) => std::sync::Arc::ptr_eq(a, b),
(None, None) => true,
_ => false,
}
});
if !valid {
let mut hearts = Vec::new();
let draws = self.build_layout(i, font, &mut hearts);
self.cache = Some(LayoutCache {
players: i.players.to_vec(),
board: i.board.clone(),
objective: i.objective.cloned(),
header: self.header.clone(),
footer: self.footer.clone(),
language,
dimensions,
flags,
draws,
hearts,
});
}
let cache = self.cache.take().unwrap();
let mut out = Vec::with_capacity(cache.draws.len());
let mut start = 0;
for &(at, y, left, right, id, score) in &cache.hearts {
out.extend_from_slice(&cache.draws[start..at]);
self.hearts(y, left, right, id, score, i.gui_ticks, font, &mut out);
start = at;
}
out.extend_from_slice(&cache.draws[start..]);
self.cache = Some(cache);
out
}
fn build_layout(
&mut self,
i: &TabInputs,
font: &Font,
hearts: &mut Vec<(usize, i32, i32, i32, u128, i32)>,
) -> Vec<GuiDraw> {
let width = |t: &Text| font.width_text(t) as i32;
let players = sorted_players(i.players, i.board);
let ids = self.displayed_ids(i.players, i.board);
let by_id: HashMap<_, _> = i.players.iter().map(|p| (p.uuid, p)).collect();
let players: Vec<_> = ids.iter().map(|id| by_id[id]).collect();
let spacer = font.width(" ") as i32;
let mut max_name = 0;
let mut max_score = 0;
@@ -409,8 +522,8 @@ impl TabList {
entries.push(entry);
}
if !self.health.is_empty() {
self.health
.retain(|id, _| players.iter().any(|p| p.uuid == *id));
let displayed: HashSet<_> = players.iter().map(|p| p.uuid).collect();
self.health.retain(|id, _| displayed.contains(id));
}
let slots = players.len() as i32;
let mut rows = slots;
@@ -524,16 +637,7 @@ impl TabList {
let right = left + width_for_score;
if right - left > 5 {
if objective.render_type == RenderType::Hearts {
self.hearts(
yo,
left,
right,
p.uuid,
entry.score,
i.gui_ticks,
font,
&mut out,
);
hearts.push((out.len(), yo, left, right, p.uuid, entry.score));
} else if let Some(text) = &entry.formatted {
out.push(GuiDraw::Text {
text: text.clone(),
@@ -683,6 +787,60 @@ mod tests {
assert!(list.set_visible(true));
}
#[test]
fn cached_tab_layout_matches_rebuilt_layout_and_animated_health() {
let font = Font::metrics(std::path::Path::new("missing-tab-test-resources"));
let mut board = Scoreboard::default();
board.add_objective(
"hp",
"dummy",
serde_json::json!("hp"),
RenderType::Hearts,
false,
None,
);
board.add_team("red");
board.add_to_team("Alice", "red");
board.score_mut("Alice", "hp").value = 20;
let mut players = vec![TabPlayer::new(1, "Alice"), TabPlayer::new(2, "Bob")];
let mut cached = TabList::default();
let mut fresh = TabList::default();
cached.header = Some(Text::literal("Header"));
fresh.header = cached.header.clone();
for frame in 0..40 {
match frame {
2 => {
players[0].display_name =
Some(serde_json::json!({"text": "Changed", "obfuscated": true}))
}
3 => board.team_mut("red").unwrap().prefix = serde_json::json!("prefix"),
4 => board.score_mut("Alice", "hp").value = 9,
5 => players.push(TabPlayer::new(3, "Charlie")),
6 => {
players.pop();
}
_ => {}
}
let inputs = TabInputs {
players: &players,
board: &board,
objective: board.objective("hp"),
screen_width: if frame < 7 { 400 } else { 300 },
online_mode: true,
gui_ticks: frame,
background: 0x20ffffff,
upside_down: &|_| false,
horizon: &|_| false,
};
fresh.cache = None;
assert_eq!(
cached.layout(&inputs, &font),
fresh.layout(&inputs, &font),
"frame {frame}"
);
}
}
#[test]
fn order_ping_and_skins() {
let board = Scoreboard::default();
@@ -702,8 +860,11 @@ mod tests {
assert_eq!(ping_sprite(1000), "icon/ping_1");
// UUID.hashCode of 0 is 0: the first default skin.
assert_eq!(default_skin(0), "entity/player/slim/alex");
assert!(!visible(true, true, 1, false));
assert!(visible(true, true, 1, true));
assert!(visible(true, false, 1, false));
assert!(!visible(true, true, true, 1, false));
assert!(visible(true, true, true, 1, true));
assert!(visible(true, true, false, 1, false));
// F1 (`Hud.isHidden`) takes the list away however it was held up.
assert!(!visible(false, true, false, 2, false));
assert!(!visible(false, true, true, 1, true));
}
}
+43 -1
View File
@@ -614,7 +614,12 @@ fn button_with_room(
room: f32,
narration: impl FnOnce([f32; 4]) -> crate::widgets::Narratable,
) {
crate::widgets::record(&label.plain(), [x, y, w, h], active);
let plain = label.plain();
crate::widgets::record(&plain, [x, y, w, h], active);
// An icon button has no label to record, and A presses it all the same.
if active && plain.trim().is_empty() {
crate::widgets::target([x, y, w, h]);
}
crate::widgets::narratable(|| narration([x, y, w, h]).hovered(hover));
crate::widgets::widget_cursor(hover, active);
let sprite = if !active {
@@ -1846,6 +1851,43 @@ mod tests {
assert_eq!(scrolled_left(10.0, 40.0, 1_000), 10.0 - offset);
}
/// A controller reaches an icon button only through its target, and can't press a disabled one.
#[test]
fn an_active_icon_button_is_a_focus_target_and_a_disabled_one_is_not() {
let atlas = atlas();
let mut batch = HudBatch::new([640, 480]);
// Whatever this thread drew before is the last frame's, then gone.
crate::widgets::end_frame(u64::MAX - 1);
button(
&mut batch,
&atlas,
10.0,
20.0,
20.0,
20.0,
&Text::empty(),
true,
false,
);
button(
&mut batch,
&atlas,
40.0,
20.0,
20.0,
20.0,
&Text::empty(),
false,
false,
);
crate::widgets::end_frame(u64::MAX);
let targets: Vec<[f32; 4]> = crate::widgets::last_targets()
.iter()
.map(|t| t.rect)
.collect();
assert_eq!(targets, [[10.0, 20.0, 20.0, 20.0]]);
}
#[test]
fn a_glyph_leaves_a_label_that_fits_centred() {
let atlas = atlas();
+80 -2
View File
@@ -201,9 +201,14 @@ pub fn set_action_glyphs(on: bool) {
ACTION_GLYPHS.set(on);
}
/// How much of a declared button's left the glyph takes from its label.
/// How much of a declared button's left the glyph takes from its label: a face button's, the only
/// kind declared buttons show, and no family's is wider than its art.
pub fn action_glyph_room() -> f32 {
if ACTION_GLYPHS.get() { 14.0 } else { 0.0 }
if ACTION_GLYPHS.get() {
crate::pad_glyph::FACE_WIDTH + 3.0
} else {
0.0
}
}
/// An interactive rectangle (GUI pixels) a controller can focus and click.
@@ -244,6 +249,41 @@ pub fn clip_targets(mark: usize, y0: f32, y1: f32) {
});
}
/// How many labelled widgets and targets the frame has so far, for [`clip_frame`].
pub fn frame_mark() -> (usize, usize) {
let labelled = WIDGETS.with_borrow(|(current, _, _)| current.len());
(labelled, targets_mark())
}
/// Drop the labelled widgets and targets since `mark` whose middle isn't in `y0..y1` (GUI pixels):
/// what a scrolling body clipped away, where a press wouldn't land.
pub fn clip_frame((labelled, targets): (usize, usize), y0: f32, y1: f32) {
let shown = |y: f32, h: f32| (y0..y1).contains(&(y + h / 2.0));
WIDGETS.with_borrow_mut(|(current, _, _)| {
let mut at = 0;
current.retain(|w| {
at += 1;
at <= labelled || shown(w.y, w.h)
});
});
TARGETS.with_borrow_mut(|(current, _, _)| {
let mut at = 0;
current.retain(|t| {
at += 1;
at <= targets || shown(t.rect[1], t.rect[3])
});
});
}
/// A prompt comes up over what the frame drew so far: what it covers can't be reached, by the
/// pointer or a controller, so its widgets, targets, declared buttons and lists go.
pub fn cover_frame() {
WIDGETS.with_borrow_mut(|(current, _, _)| current.clear());
TARGETS.with_borrow_mut(|(current, _, _)| current.clear());
ACTIONS.with_borrow_mut(|(current, _)| current.clear());
SCROLL_AREAS.with_borrow_mut(|(current, _)| current.clear());
}
/// This frame drew a list with something to scroll, in `rect`, scrolled `amount`
/// of `max`: a focus step goes through its hidden rows before it leaves
/// (`gamepad::list_step`).
@@ -1349,4 +1389,42 @@ mod tests {
"the clipped row is gone, earlier ones stay"
);
}
#[test]
fn a_scrolled_body_loses_what_it_clips_and_nothing_before_it() {
let _ = take();
target([0.0, 0.0, 5.0, 5.0]);
record("warning", [0.0, 0.0, 5.0, 5.0], true);
let mark = frame_mark();
record("shown", [0.0, 10.0, 5.0, 10.0], true);
target([0.0, 10.0, 5.0, 10.0]);
// Half in view is still pressed at its middle, which a mouse could reach.
record("half", [0.0, 25.0, 5.0, 10.0], true);
target([0.0, 25.0, 5.0, 10.0]);
record("gone", [0.0, 40.0, 5.0, 10.0], true);
target([0.0, 40.0, 5.0, 10.0]);
clip_frame(mark, 8.0, 32.0);
end_frame(101);
let names: Vec<String> = last_labelled().into_iter().map(|w| w.label).collect();
assert_eq!(names, ["warning", "shown", "half"]);
assert_eq!(last_targets().len(), 3);
}
#[test]
fn a_prompt_covers_what_was_drawn_before_it() {
let _ = take();
record("under", [0.0, 0.0, 5.0, 5.0], true);
target([0.0, 10.0, 5.0, 5.0]);
declare_action(Action::Done, [0.0, 0.0, 5.0, 5.0], true);
scroll_area([0.0, 0.0, 5.0, 5.0], 0.0, 5.0);
cover_frame();
record("over", [0.0, 20.0, 5.0, 5.0], true);
set_action_screen(7);
end_frame(102);
let names: Vec<String> = last_labelled().into_iter().map(|w| w.label).collect();
assert_eq!(names, ["over"]);
assert!(last_targets().is_empty());
assert!(last_actions(7).is_empty());
assert!(last_scroll_areas().is_empty());
}
}
@@ -65,7 +65,11 @@ struct Taa {
jitter: vec4<f32>,
};
@group(0) @binding(2) var history: texture_2d<f32>;
@group(0) @binding(3) var depth: texture_depth_2d;
// The world's depth as an unfilterable float texture: naga's GLSL backend
// cannot emit `textureLoad` from a depth texture, and a depth view bound as
// `TextureSampleType::Float { filterable: false }` gives the same texel on
// every backend.
@group(0) @binding(3) var depth: texture_2d<f32>;
@group(0) @binding(4) var<uniform> taa: Taa;
// Catmull-Rom over the history in 5 bilinear taps: sharper than one under motion.
@@ -113,7 +117,7 @@ fn sample_history(uv: vec2<f32>) -> vec3<f32> {
let c = textureLoad(world, q, 0).rgb;
lo = min(lo, c);
hi = max(hi, c);
near = max(near, textureLoad(depth, q, 0));
near = max(near, textureLoad(depth, q, 0).r);
}
}
let ndc = vec2(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0);
+13 -1
View File
@@ -254,7 +254,11 @@ fn normalize_end(data: &mut [u8]) {
}
let disk = u16_at(data, end + 4);
let with_directory = u16_at(data, end + 6);
if disk == with_directory && u16_at(data, end + 8) == count && u16_at(data, end + 10) == count {
if disk == 0
&& with_directory == 0
&& u16_at(data, end + 8) == count
&& u16_at(data, end + 10) == count
{
return;
}
data[end + 4..end + 10].fill(0);
@@ -311,6 +315,14 @@ mod tests {
assert_eq!(zip.read("a/b.txt").unwrap(), b"hello hello hello hello");
}
#[test]
fn a_file_is_opened_with_equal_nonzero_disk_numbers() {
let mut bytes = archive();
let end = bytes.len() - END_HEADER;
bytes[end + 4..end + 8].fill(0xFF);
let zip = opened("end-disks", &bytes).unwrap();
assert_eq!(zip.read("a/b.txt").unwrap(), b"hello hello hello hello");
}
#[test]
fn a_file_that_is_no_archive_does_not_open() {
assert!(opened("empty", b"").is_err());
assert!(opened("garbage", b"PK not really").is_err());
+4 -4
View File
@@ -54,6 +54,8 @@ sha2 = "0.10.9"
sha1 = "0.10"
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1.0.151", features = ["raw_value"] }
# server.toml's [combat], which the dedicated server reads itself.
toml = "1.1"
# hlsl-out: the gfx.rs tests translate every shader as wgpu's DirectX 12 backend does. A normal feature,
# not a dev-dependency's, so builds, tests and examples share one build of naga, wgpu and this crate.
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out"] }
@@ -104,10 +106,6 @@ objc2-foundation = { version = "0.3", default-features = false, features = ["std
# DirectX 12 driver (`graphicsApi`, graphics.rs).
wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
[target.'cfg(windows)'.build-dependencies]
# The icon resource in the .exe (build.rs).
winresource = "0.1"
[features]
default = ["audio", "controllers"]
# Sound output. Without it (and `controllers`) the program links neither libasound nor libudev on
@@ -150,6 +148,8 @@ path = "examples/native_gui_overlay_probe.rs"
[build-dependencies]
horizon-mod-api-gen = { path = "../horizon-mod-api-gen" }
# The build script runs on the host, but embeds resources based on the target OS.
winresource = "0.1"
[lints]
workspace = true
@@ -72,10 +72,7 @@
"horizon.mods.settings": "Instellings",
"horizon.mods.in_world": " (los die wêreld om te verander)",
"horizon.mods.open_folder": "Maak Mod-lêergids oop",
"horizon.shader_packs.empty": "Geen shader packs: plaas dit in die shaderpacks-lêergids",
"horizon.shader_options.open": "Shader-opsies...",
"horizon.shader_options.profile": "Profiel",
"horizon.shader_options.loading": "Lees tans die shader-pakket se opsies...",
"horizon.agent.copy_details": "Kopieer verbindingbesonderhede",
"horizon.multiplayer.join": "Sluit aan",
"horizon.multiplayer.cancel_reconnecting": "Kanselleer Herkoppel",
@@ -326,7 +323,6 @@
"horizon.osk.close": "Maak toe",
"horizon.extras.chat_animation.tooltip": "'n Nuwe kletslyn gly van links af terwyl dit vervaag, in plaas daarvan om heel te lyk.",
"horizon.extras.chat_indicator.tooltip": "Die gekleurde balk aan die linkerkant van 'n kletslyn.",
"horizon.extras.wheel_cycles_settings.tooltip": "Die rolwiel oor 'n knoppie in die instellings verander die waarde daarvan, soos in vanielje. Af, die wiel blaai net deur die lys.",
"horizon.extras.pack_browser.tooltip": "Select Resource Packs kry 'n Voeg by-knoppie wat Modrinth deursoek en 'n pakket in jou pakke-lêergids installeer. Af, die speletjie kontak nooit Modrinth nie.",
"horizon.extras.splitscreen.tooltip": "In 'n wêreld kan 'n ander beheerder as 'n nuwe speler aansluit: druk A wanneer dit vra, kies dan 'n profiel en waar om te speel.",
"horizon.splitscreen.setup.monitor_unknown": "'n monitor",
@@ -472,9 +468,26 @@
"horizon.extras.cosmetics_mine.tooltip": "Versteek: elke Horizon-speler sien jou sonder jou skoonheidsmiddels, jy ingesluit. Dieselfde skakelaar as die lanseerder se klerekas.",
"horizon.extras.cosmetics_others": "Ander spelers se skoonheidsmiddels",
"horizon.extras.cosmetics_others.tooltip": "Wat ander Horizon-spelers dra: alles, net hul naamplaatjiestyle, of niks. Versteek, die speletjie vra nooit wat hulle dra nie.",
"horizon.extras.cosmetics_auras": "Ander se aura's en roetes",
"horizon.extras.cosmetics_auras.tooltip": "Auras en roetes wat ander spelers dra. Af by verstek.",
"horizon.extras.cosmetics_hide_fx": "Versteek ander spelers se spore en auras",
"horizon.extras.cosmetics_hide_fx.tooltip": "Ander spelers se spore en auras bly versteek; hul ander kosmetiese items wys steeds. By verstek af.",
"horizon.extras.cosmetics.shown": "Getoon",
"horizon.extras.cosmetics.hidden": "Versteek",
"horizon.extras.cosmetics.name_tags": "Slegs naamplaatjies"
"horizon.extras.cosmetics.name_tags": "Slegs naamplaatjies",
"Installed": "Geïnstalleer",
"Apply": "Pas toe",
"Reset all options": "Stel alle opsies terug",
"A Horizon shader pack.": "'n Horizon-skadupakket.",
"An OptiFine or Iris shader pack.": "'n OptiFine- of Iris-skadupakket.",
"In use: press to turn shaders off.": "In gebruik: druk om skadus af te skakel.",
"Press to use it.": "Druk om dit te gebruik.",
"Put shader pack .zip files or folders in it.": "Sit skadupakket-.zip-lêers of -vouers daarin.",
"Puts every option of this pack back to its default.": "Stel elke opsie van hierdie pakket terug na sy verstek.",
"Sets many options at once, as the pack groups them.": "Stel baie opsies op een slag, soos die pakket hulle groepeer.",
"No shader packs yet: open the pack folder and put some in it.": "Nog geen skadupakkette nie: maak die pakketvouer oop en sit 'n paar daarin.",
"Pick a shader pack. Picking the one in use turns shaders off.": "Kies 'n skadupakket. As jy die een in gebruik kies, word skadus afgeskakel.",
"Changed options apply with Apply, or when you leave.": "Gewysigde opsies word toegepas met Pas toe, of wanneer jy verlaat.",
"Pack settings": "Pakketopsies",
"Built-in settings": "Ingeboude opsies",
"%s settings": "%s-opsies",
"Settings made by the pack's author: they change only this pack.": "Opsies deur die pakket se skepper: hulle verander net hierdie pakket."
}
@@ -77,10 +77,7 @@
"horizon.mods.settings": "الإعدادات",
"horizon.mods.in_world": " (اترك العالم ليتغير)",
"horizon.mods.open_folder": "افتح مجلد التعديل",
"horizon.shader_packs.empty": "لا توجد حزم تظليل: ضعها في مجلدshaderpacks",
"horizon.shader_options.open": "خيارات التظليل...",
"horizon.shader_options.profile": "الملف الشخصي",
"horizon.shader_options.loading": "قراءة خيارات حزمة التظليل...",
"horizon.agent.copy_details": "نسخ تفاصيل الاتصال",
"horizon.multiplayer.join": "انضم",
"horizon.multiplayer.cancel_reconnecting": "إلغاء إعادة الاتصال",
@@ -341,7 +338,6 @@
"horizon.osk.close": "إغلاق",
"horizon.extras.chat_animation.tooltip": "ينزلق خط الدردشة الجديد من اليسار بينما يتلاشى للداخل، بدلاً من أن يظهر كاملاً.",
"horizon.extras.chat_indicator.tooltip": "الشريط الملون الموجود على يسار خط الدردشة.",
"horizon.extras.wheel_cycles_settings.tooltip": "عجلة التمرير فوق زر في الإعدادات تغير قيمته، كما هو الحال في الفانيليا. مع إيقاف التشغيل، تقوم العجلة بتمرير القائمة فقط.",
"horizon.extras.pack_browser.tooltip": "تحديد حزم الموارد يحصل على زر \"إضافة\" الذي يبحث في Modrinth ويقوم بتثبيت حزمة في مجلد الحزم الخاص بك. مع إيقاف التشغيل، لن تتصل اللعبة أبدًا بـ Modrinth.",
"horizon.extras.splitscreen.tooltip": "في عالم ما، يمكن لوحدة تحكم أخرى الانضمام كلاعب جديد: اضغط على A عندما يُطلب منك ذلك، ثم اختر ملفًا شخصيًا ومكان اللعب.",
"horizon.splitscreen.setup.monitor": "مراقب %s",
@@ -491,9 +487,26 @@
"horizon.extras.cosmetics_mine.tooltip": "مخفي: يراك كل لاعب في Horizon بدون مستحضرات التجميل الخاصة بك، بما في ذلك أنت. نفس مفتاح خزانة المشغل.",
"horizon.extras.cosmetics_others": "مستحضرات التجميل للاعبين الآخرين",
"horizon.extras.cosmetics_others.tooltip": "ما يرتديه لاعبو Horizon الآخرون: كل شيء، فقط أنماط بطاقة الاسم الخاصة بهم، أو لا شيء. مخفيًا، اللعبة لا تسأل أبدًا عما يرتدونه.",
"horizon.extras.cosmetics_auras": "هالات ومسارات الآخرين",
"horizon.extras.cosmetics_auras.tooltip": "الهالات والمسارات التي يرتديها اللاعبون الآخرون. إيقاف بشكل افتراضي.",
"horizon.extras.cosmetics_hide_fx": "إخفاء آثار وهالات اللاعبين الآخرين",
"horizon.extras.cosmetics_hide_fx.tooltip": "تبقى آثار وهالات اللاعبين الآخرين مخفية؛ وتظل مستحضراتهم الأخرى ظاهرة. معطّل افتراضيًا.",
"horizon.extras.cosmetics.shown": "معروض",
"horizon.extras.cosmetics.hidden": "مخفي",
"horizon.extras.cosmetics.name_tags": "علامات الاسم فقط"
"horizon.extras.cosmetics.name_tags": "علامات الاسم فقط",
"Installed": "المثبّتة",
"Apply": "تطبيق",
"Reset all options": "إعادة ضبط كل الخيارات",
"A Horizon shader pack.": "حزمة شيدر من Horizon.",
"An OptiFine or Iris shader pack.": "حزمة شيدر من OptiFine أو Iris.",
"In use: press to turn shaders off.": "قيد الاستخدام: اضغط لإيقاف الشيدرات.",
"Press to use it.": "اضغط لاستخدامها.",
"Put shader pack .zip files or folders in it.": "ضع ملفات .zip أو مجلدات حزم الشيدر فيه.",
"Puts every option of this pack back to its default.": "يعيد كل خيار في هذه الحزمة إلى قيمته الافتراضية.",
"Sets many options at once, as the pack groups them.": "يضبط عدة خيارات دفعة واحدة، كما تجمعها الحزمة.",
"No shader packs yet: open the pack folder and put some in it.": "لا توجد حزم شيدر بعد: افتح مجلد الحزم وضع بعضها فيه.",
"Pick a shader pack. Picking the one in use turns shaders off.": "اختر حزمة شيدر. اختيار الحزمة قيد الاستخدام يوقف الشيدرات.",
"Changed options apply with Apply, or when you leave.": "تُطبَّق الخيارات المعدّلة بزر تطبيق، أو عند المغادرة.",
"Pack settings": "إعدادات الحزمة",
"Built-in settings": "الإعدادات المدمجة",
"%s settings": "إعدادات %s",
"Settings made by the pack's author: they change only this pack.": "إعدادات وضعها مؤلف الحزمة: تؤثر على هذه الحزمة فقط."
}
@@ -71,10 +71,7 @@
"horizon.mods.settings": "Parametrlər",
"horizon.mods.in_world": " (Dəyişmək üçün dünyanı tərk et)",
"horizon.mods.open_folder": "Mod Qovluğunu açın",
"horizon.shader_packs.empty": "Şeyder paketləri yoxdur: onları shaderpacks qovluğuna qoyun",
"horizon.shader_options.open": "Shader Seçimləri...",
"horizon.shader_options.profile": "Profil",
"horizon.shader_options.loading": "Şeyder paketinin seçimləri oxunur...",
"horizon.agent.copy_details": "Bağlantı təfərrüatlarını kopyalayın",
"horizon.multiplayer.join": "Qoşulun",
"horizon.multiplayer.cancel_reconnecting": "Yenidən qoşulmanı ləğv edin",
@@ -317,7 +314,6 @@
"horizon.osk.close": "Bağlayın",
"horizon.extras.chat_animation.tooltip": "Yeni söhbət xətti bütöv görünmək əvəzinə soldan içəri sürüşür.",
"horizon.extras.chat_indicator.tooltip": "Söhbət xəttinin solunda olan rəngli bar.",
"horizon.extras.wheel_cycles_settings.tooltip": "Parametrlərdəki düymənin üzərindəki sürüşdürmə çarxı vanildə olduğu kimi dəyərini dəyişir. Söndürülmüş, təkər yalnız siyahını sürüşdürür.",
"horizon.extras.pack_browser.tooltip": "Resurs Paketlərini seçin Modrinth-də axtarış aparan və paketlər qovluğuna paket quraşdıran Əlavə et düyməsini alır. Off, oyun Modrinthlə heç vaxt əlaqə saxlamır.",
"horizon.extras.splitscreen.tooltip": "Dünyada başqa bir nəzarətçi yeni oyunçu kimi qoşula bilər: soruşduqda A düyməsini basın, sonra profil və harada oynayacağınızı seçin.",
"horizon.splitscreen.setup.monitor_unknown": "monitor",
@@ -463,9 +459,26 @@
"horizon.extras.cosmetics_mine.tooltip": "Gizli: hər Horizon oyunçu sizi kosmetikanız olmadan görür. Başlatma şkafı ilə eyni açar.",
"horizon.extras.cosmetics_others": "Digər oyunçuların kosmetikası",
"horizon.extras.cosmetics_others.tooltip": "Digər Horizon oyunçular nə geyinirlər: hər şey, yalnız ad etiketi üslubları və ya heç nə. Gizli, oyun heç vaxt nə geyindiklərini soruşmur.",
"horizon.extras.cosmetics_auras": "Başqalarının auraları və izləri",
"horizon.extras.cosmetics_auras.tooltip": "Digər oyunçuların geydiyi auralar və izlər. Defolt olaraq söndürülür.",
"horizon.extras.cosmetics_hide_fx": "Digər oyunçuların izlərini və auralarını gizlət",
"horizon.extras.cosmetics_hide_fx.tooltip": "Digər oyunçuların izləri və auraları gizli qalır; digər kosmetikaları görünməyə davam edir. Defolt olaraq söndürülüb.",
"horizon.extras.cosmetics.shown": "Göstərildi",
"horizon.extras.cosmetics.hidden": "Gizli",
"horizon.extras.cosmetics.name_tags": "Yalnız ad etiketləri"
"horizon.extras.cosmetics.name_tags": "Yalnız ad etiketləri",
"Installed": "Quraşdırılmış",
"Apply": "Tətbiq et",
"Reset all options": "Bütün seçimləri sıfırla",
"A Horizon shader pack.": "Horizon şeyder paketi.",
"An OptiFine or Iris shader pack.": "OptiFine və ya Iris şeyder paketi.",
"In use: press to turn shaders off.": "İstifadədədir: şeyderləri söndürmək üçün basın.",
"Press to use it.": "İstifadə etmək üçün basın.",
"Put shader pack .zip files or folders in it.": "Şeyder paketlərinin .zip fayllarını və ya qovluqlarını bura qoyun.",
"Puts every option of this pack back to its default.": "Bu paketin bütün seçimlərini ilkin dəyərinə qaytarır.",
"Sets many options at once, as the pack groups them.": "Paketin qruplaşdırdığı kimi, bir çox seçimi birdən təyin edir.",
"No shader packs yet: open the pack folder and put some in it.": "Hələ şeyder paketi yoxdur: paket qovluğunu açıb bir neçəsini qoyun.",
"Pick a shader pack. Picking the one in use turns shaders off.": "Şeyder paketi seçin. İstifadədə olanı seçmək şeyderləri söndürür.",
"Changed options apply with Apply, or when you leave.": "Dəyişdirilmiş seçimlər Tətbiq et ilə və ya çıxdıqda tətbiq olunur.",
"Pack settings": "Paket seçimləri",
"Built-in settings": "Daxili seçimlər",
"%s settings": "%s seçimləri",
"Settings made by the pack's author: they change only this pack.": "Paketin müəllifinin yaratdığı seçimlər: yalnız bu paketə təsir edir."
}
@@ -74,10 +74,7 @@
"horizon.mods.settings": "Көйләүҙәр",
"horizon.mods.in_world": " (донъяны үҙгәртергә ҡалдырырға)",
"horizon.mods.open_folder": "Мод папкаһын асыу",
"horizon.shader_packs.empty": "Шейдер пакеттары юҡ: уларҙы шейдерпакеттар папкаһына һалығыҙ",
"horizon.shader_options.open": "Шейдер варианттары...",
"horizon.shader_options.profile": "Профиль",
"horizon.shader_options.loading": "Шейдер пакетының варианттарын уҡыу...",
"horizon.agent.copy_details": "Тоташыу тураһында мәғлүмәтте күсерергә",
"horizon.multiplayer.join": "Ҡушылыу",
"horizon.multiplayer.cancel_reconnecting": "Яңынан тоташтырыуҙы юҡҡа сығарыу",
@@ -333,7 +330,6 @@
"horizon.osk.close": "Ябыҡ",
"horizon.extras.chat_animation.tooltip": "Яңы чат һыҙығы һулдан шыуып инә, ә ул һүнә, урынына бөтөн күренә.",
"horizon.extras.chat_indicator.tooltip": "Чат һыҙығының һул яғында төҫлө һыҙыҡ.",
"horizon.extras.wheel_cycles_settings.tooltip": "Көйләүҙәрҙәге төймә өҫтөндәге прокрутка тәгәрмәсе үҙенең ҡиммәтен үҙгәртә, ванильдәге кеүек. Һүндерелгән, тәгәрмәс исемлекте генә йөрөтә.",
"horizon.extras.pack_browser.tooltip": "Һайлау ресурс пакеттары ала өҫтәү төймәһе, эҙләү Modrinth һәм һеҙҙең пакеттар папкаһына пакет ҡуя. Һүндерелгән, уйын бер ҡасан да бәйләнешкә инә Modrinth.",
"horizon.extras.splitscreen.tooltip": "Донъяла, икенсе контроллер яңы уйынсы булараҡ ҡушыла ала: баҫығыҙ А, ҡасан ул һорай, һуңынан профиль һайлап һәм ҡайҙа уйнау.",
"horizon.splitscreen.setup.monitor": "Монитор %s",
@@ -489,9 +485,26 @@
"horizon.extras.cosmetics_mine.tooltip": "Йәшерен: һәр Horizon уйынсы һеҙҙең косметикаһыҙ күрә, һеҙ ҙә индерелгән. Шул уҡ переключатель стартер&#8217;s Гардероб.",
"horizon.extras.cosmetics_others": "Башҡа уйынсыларҙың косметикаһы",
"horizon.extras.cosmetics_others.tooltip": "Башҡа ниндәй Horizon уйынсылар кейә: барыһы ла, тик уларҙы исем биркаһы стилдәре, йәки бер нәмә лә. Йәшерен, уйын бер ҡасан да һорамай, улар нимә кейә.",
"horizon.extras.cosmetics_auras": "Башҡаларҙың ауралары һәм эҙҙәре",
"horizon.extras.cosmetics_auras.tooltip": "Аура һәм эҙҙәр башҡа уйынсылар кейә. Һүндерелгән по умолчанию.",
"horizon.extras.cosmetics_hide_fx": "Башҡа уйынсыларҙың эҙҙәрен һәм ауралары йәшереү",
"horizon.extras.cosmetics_hide_fx.tooltip": "Башҡа уйынсыларҙың эҙҙәре һәм ауралары йәшерен ҡала; уларҙың башҡа косметикаһы күренә. Ғәҙәттә һүндерелгән.",
"horizon.extras.cosmetics.shown": "Күрһәтелгән",
"horizon.extras.cosmetics.hidden": "Йәшерен",
"horizon.extras.cosmetics.name_tags": "Исем билдәләре генә"
"horizon.extras.cosmetics.name_tags": "Исем билдәләре генә",
"Installed": "Урынлаштырылған",
"Apply": "Ҡулланырға",
"Reset all options": "Бөтә көйләүҙәрҙе ташларға",
"A Horizon shader pack.": "Horizon шейдер пакеты.",
"An OptiFine or Iris shader pack.": "OptiFine йәки Iris шейдер пакеты.",
"In use: press to turn shaders off.": "Ҡулланыла: шейдерҙарҙы һүндереү өсөн баҫығыҙ.",
"Press to use it.": "Ҡулланыу өсөн баҫығыҙ.",
"Put shader pack .zip files or folders in it.": "Шейдер пакеттарының .zip файлдарын йәки папкаларын шунда һалығыҙ.",
"Puts every option of this pack back to its default.": "Был пакеттың һәр көйләүен ғәҙәти хәленә ҡайтара.",
"Sets many options at once, as the pack groups them.": "Пакет төркөмләгәнсә, күп көйләүҙе бер юлы урынлаштыра.",
"No shader packs yet: open the pack folder and put some in it.": "Шейдер пакеттары әлегә юҡ: пакеттар папкаһын асып, берәй нисәһен һалығыҙ.",
"Pick a shader pack. Picking the one in use turns shaders off.": "Шейдер пакетын һайлағыҙ. Ҡулланылғанын һайлау шейдерҙарҙы һүндерә.",
"Changed options apply with Apply, or when you leave.": "Үҙгәртелгән көйләүҙәр «Ҡулланырға» менән йәки сыҡҡанда ҡулланыла.",
"Pack settings": "Пакет көйләүҙәре",
"Built-in settings": "Эске көйләүҙәр",
"%s settings": "%s көйләүҙәре",
"Settings made by the pack's author: they change only this pack.": "Пакет авторы төҙөгән көйләүҙәр: улар тик был пакетты үҙгәртә."
}
@@ -72,10 +72,7 @@
"horizon.mods.settings": "Налады",
"horizon.mods.in_world": " (пакінуць свет змяніцца)",
"horizon.mods.open_folder": "Адкрыць тэчку Mod",
"horizon.shader_packs.empty": "Пакетаў шэйдэраў няма: змесціце іх у тэчку шэйдэраў",
"horizon.shader_options.open": "Параметры шэйдара...",
"horizon.shader_options.profile": "Профіль",
"horizon.shader_options.loading": "Чытанне параметраў пакета шэйдараў...",
"horizon.agent.copy_details": "Скапіруйце звесткі аб падключэнні",
"horizon.multiplayer.join": "Далучайцеся",
"horizon.multiplayer.cancel_reconnecting": "Адмяніць паўторнае падключэнне",
@@ -326,7 +323,6 @@
"horizon.osk.close": "Блізка",
"horizon.extras.chat_animation.tooltip": "Новы радок чата слізгае злева, пакуль ён знікае, замест таго, каб выглядаць цалкам.",
"horizon.extras.chat_indicator.tooltip": "Каляровая паласа злева ад радка чата.",
"horizon.extras.wheel_cycles_settings.tooltip": "Кола пракруткі над кнопкай у наладах змяняе яе значэнне, як у vanilla. Выключана, кола толькі пракручвае спіс.",
"horizon.extras.pack_browser.tooltip": "Select Resource Packs атрымлівае кнопку «Дадаць», якая выконвае пошук у Modrinth і ўсталёўвае пакет у вашу тэчку пакетаў. Выключана, гульня ніколі не звязваецца з Modrinth.",
"horizon.extras.splitscreen.tooltip": "У свеце іншы кантролер можа далучыцца як новы гулец: націсніце A, калі ён запытае, затым выберыце профіль і дзе гуляць.",
"horizon.splitscreen.setup.monitor": "Манітор %s",
@@ -475,9 +471,26 @@
"horizon.extras.cosmetics_mine.tooltip": "Схаваны: кожны гулец Horizon бачыць вас без вашай касметыкі, у тым ліку і вас. The same switch as the launcher's Wardrobe.",
"horizon.extras.cosmetics_others": "Касметыка іншых гульцоў",
"horizon.extras.cosmetics_others.tooltip": "Што носяць іншыя гульцы Horizon: усё, толькі стылі тэгаў з імёнамі або нічога. Hidden, the game never asks what they wear.",
"horizon.extras.cosmetics_auras": "Чужыя аўры і шлейфы",
"horizon.extras.cosmetics_auras.tooltip": "Auras and trails other players wear. Выключана па змаўчанні.",
"horizon.extras.cosmetics_hide_fx": "Схаваць сляды і аўры іншых гульцоў",
"horizon.extras.cosmetics_hide_fx.tooltip": "Сляды і аўры іншых гульцоў схаваныя; іх астатняя касметыка бачная. Па змаўчанні выкл.",
"horizon.extras.cosmetics.shown": "Паказана",
"horizon.extras.cosmetics.hidden": "Схаваны",
"horizon.extras.cosmetics.name_tags": "Толькі біркі з імёнамі"
"horizon.extras.cosmetics.name_tags": "Толькі біркі з імёнамі",
"Installed": "Усталяваныя",
"Apply": "Ужыць",
"Reset all options": "Скінуць усе налады",
"A Horizon shader pack.": "Пакет шэйдараў Horizon.",
"An OptiFine or Iris shader pack.": "Пакет шэйдараў OptiFine або Iris.",
"In use: press to turn shaders off.": "Выкарыстоўваецца: націсніце, каб выключыць шэйдары.",
"Press to use it.": "Націсніце, каб выкарыстаць.",
"Put shader pack .zip files or folders in it.": "Пакладзіце ў яе .zip-файлы або папкі пакетаў шэйдараў.",
"Puts every option of this pack back to its default.": "Вяртае ўсе налады гэтага пакета да прадвызначаных.",
"Sets many options at once, as the pack groups them.": "Задае адразу многія налады так, як іх групуе пакет.",
"No shader packs yet: open the pack folder and put some in it.": "Пакетаў шэйдараў пакуль няма: адкрыйце папку пакетаў і пакладзіце туды некалькі.",
"Pick a shader pack. Picking the one in use turns shaders off.": "Выберыце пакет шэйдараў. Выбар пакета, які выкарыстоўваецца, выключае шэйдары.",
"Changed options apply with Apply, or when you leave.": "Змененыя налады ўжываюцца кнопкай «Ужыць» або пры выхадзе.",
"Pack settings": "Налады пакета",
"Built-in settings": "Убудаваныя налады",
"%s settings": "Налады %s",
"Settings made by the pack's author: they change only this pack.": "Налады, зробленыя аўтарам пакета: яны змяняюць толькі гэты пакет."
}
@@ -70,10 +70,7 @@
"horizon.mods.settings": "Настройки",
"horizon.mods.in_world": " (оставете света да се промени)",
"horizon.mods.open_folder": "Отворете папката на Mod",
"horizon.shader_packs.empty": "Без шейдър пакети: поставете ги в папката shaderpacks",
"horizon.shader_options.open": "Опции за шейдър...",
"horizon.shader_options.profile": "Профил",
"horizon.shader_options.loading": "Четене на опциите на шейдър пакета...",
"horizon.agent.copy_details": "Копиране на подробности за връзката",
"horizon.multiplayer.join": "Присъединете се",
"horizon.multiplayer.cancel_reconnecting": "Отказ Повторно свързване",
@@ -324,7 +321,6 @@
"horizon.osk.close": "затвори",
"horizon.extras.chat_animation.tooltip": "Нова линия за чат се плъзга отляво, докато избледнява, вместо да се показва цяла.",
"horizon.extras.chat_indicator.tooltip": "Цветната лента отляво на линия за чат.",
"horizon.extras.wheel_cycles_settings.tooltip": "Колелцето за превъртане над бутон в настройките променя стойността му, както при vanilla. Изключено, колелото само превърта списъка.",
"horizon.extras.pack_browser.tooltip": "Select Resource Packs получава бутон за добавяне, който търси в Modrinth и инсталира пакет във вашата папка с пакети. Off, играта никога не се свързва с Modrinth.",
"horizon.extras.splitscreen.tooltip": "В един свят друг контролер може да се присъедини като нов играч: натиснете A, когато поиска, след това изберете профил и къде да играете.",
"horizon.splitscreen.setup.monitor": "Монитор %s",
@@ -470,10 +466,27 @@
"horizon.extras.cosmetics_mine.tooltip": "Скрит: всеки играч на Horizon ви вижда без вашата козметика, включително и вие. Същият превключвател като гардероба на стартера.",
"horizon.extras.cosmetics_others": "Козметика за други играчи",
"horizon.extras.cosmetics_others.tooltip": "Какво носят другите играчи на Horizon: всичко, само техните стилове на имената или нищо. Скрито, играта никога не пита какво носят.",
"horizon.extras.cosmetics_auras": "Аури и следи на други",
"horizon.extras.cosmetics_auras.tooltip": "Аури и пътеки, които другите играчи носят. Изключено по подразбиране.",
"horizon.extras.cosmetics_hide_fx": "Скриване на следите и аурите на другите играчи",
"horizon.extras.cosmetics_hide_fx.tooltip": "Следите и аурите на другите играчи остават скрити; другите им козметики се виждат. Изключено по подразбиране.",
"horizon.extras.cosmetics.shown": "Показано",
"horizon.extras.cosmetics.hidden": "Скрити",
"horizon.extras.cosmetics.name_tags": "Само етикети с имена",
"Shader Packs...": "Шейдър пакети..."
"Shader Packs...": "Шейдър пакети...",
"Installed": "Инсталирани",
"Apply": "Приложи",
"Reset all options": "Нулирай всички опции",
"A Horizon shader pack.": "Пакет шейдъри на Horizon.",
"An OptiFine or Iris shader pack.": "Пакет шейдъри на OptiFine или Iris.",
"In use: press to turn shaders off.": "В употреба: натиснете, за да изключите шейдърите.",
"Press to use it.": "Натиснете, за да го използвате.",
"Put shader pack .zip files or folders in it.": "Сложете в нея .zip файлове или папки с пакети шейдъри.",
"Puts every option of this pack back to its default.": "Връща всяка опция на този пакет към стойността по подразбиране.",
"Sets many options at once, as the pack groups them.": "Задава много опции наведнъж, както ги групира пакетът.",
"No shader packs yet: open the pack folder and put some in it.": "Още няма пакети шейдъри: отворете папката с пакети и сложете няколко.",
"Pick a shader pack. Picking the one in use turns shaders off.": "Изберете пакет шейдъри. Изборът на използвания го изключва.",
"Changed options apply with Apply, or when you leave.": "Променените опции се прилагат с Приложи или при излизане.",
"Pack settings": "Настройки на пакета",
"Built-in settings": "Вградени настройки",
"%s settings": "Настройки на %s",
"Settings made by the pack's author: they change only this pack.": "Настройки, направени от автора на пакета: променят само този пакет."
}
@@ -72,10 +72,7 @@
"horizon.mods.settings": "Arventennoù",
"horizon.mods.in_world": " (lezel ar bed da cheñch)",
"horizon.mods.open_folder": "Digeriñ an teuliad mod",
"horizon.shader_packs.empty": "Pakad skeudennoù ebet : lakait anezho e teuliad ar pakadoù skeudennoù",
"horizon.shader_options.open": "Dibarzhioù skeudenniñ...",
"horizon.shader_options.profile": "Profil",
"horizon.shader_options.loading": "O lenn dibaboù ar pakad skeudennoù...",
"horizon.agent.copy_details": "Eilañ munudoù ar c'hennask",
"horizon.multiplayer.join": "Kemer perzh",
"horizon.multiplayer.cancel_reconnecting": "Nullañ an adkennaskañ",
@@ -325,7 +322,6 @@
"horizon.osk.close": "Serriñ",
"horizon.extras.chat_animation.tooltip": "Ur linenn flapañ nevez a zeu eus an tu kleiz e-keit ma vez o vont da get, e-lec'h dont war wel a-bezh.",
"horizon.extras.chat_indicator.tooltip": "Ar varrenn livet a-gleiz d'ul linenn flapañ.",
"horizon.extras.wheel_cycles_settings.tooltip": "Ar rod riklañ war ur bouton en arventennoù a gemm e dalvoudegezh, evel e vanilha. Diweredekaet, ne ra ar rod nemet riklañ ar roll.",
"horizon.extras.pack_browser.tooltip": "Dibab pakadoù arc'hwelioù a resev ur bouton Ouzhpennañ a glask Modrinth hag a staliañ ur pakad en ho teuliad pakadoù. Diweredekaet, ne vez ket darempredet ar c'hoari gant Modrinth.",
"horizon.extras.splitscreen.tooltip": "En ur bed e c'hall ur c'hontroller all mont e-barzh evel c'hoarier nevez : pouezit war A pa c'houlenn, ha dibabit ur profil ha pelec'h c'hoari.",
"horizon.splitscreen.setup.monitor": "Heuliañ %s",
@@ -474,9 +470,26 @@
"horizon.extras.cosmetics_mine.tooltip": "Kuzhet : pep c'hoarier Horizon a wel ac'hanoc'h hep ho kosmetikoù, c'hwi en o zouez. Ar memes treuzfurmadur hag armel al lañser.",
"horizon.extras.cosmetics_others": "Kosmetikoù ar c'hoarierien all",
"horizon.extras.cosmetics_others.tooltip": "Ar pezh a vez gwisket gant ar c'hoarierien Horizon all : pep tra, o doareoù tikedenn anv hepken, pe netra. Kuzhet, ar c'hoari ne c'houlenn ket biskoazh petra a wiskont.",
"horizon.extras.cosmetics_auras": "Auraoù ha roudoù tud all",
"horizon.extras.cosmetics_auras.tooltip": "Auraoù ha roudoù a vez gwisket gant c'hoarierien all. Diweredekaet dre ziouer.",
"horizon.extras.cosmetics_hide_fx": "Kuzhat roudoù hag aouraoù ar c'hoarierien all",
"horizon.extras.cosmetics_hide_fx.tooltip": "Kuzhet e chom roudoù hag aouraoù ar c'hoarierien all; diskouezet e vez o c'hinkladurioù all. Diweredekaet dre ziouer.",
"horizon.extras.cosmetics.shown": "Diskouezet",
"horizon.extras.cosmetics.hidden": "Kuzhet",
"horizon.extras.cosmetics.name_tags": "Tikedennoù anvioù hepken"
"horizon.extras.cosmetics.name_tags": "Tikedennoù anvioù hepken",
"Installed": "Staliet",
"Apply": "Arloañ",
"Reset all options": "Adderaouekaat an holl zibarzhioù",
"A Horizon shader pack.": "Ur pakad skeudennerioù Horizon.",
"An OptiFine or Iris shader pack.": "Ur pakad skeudennerioù OptiFine pe Iris.",
"In use: press to turn shaders off.": "Implijet: pouezit evit lazhañ ar skeudennerioù.",
"Press to use it.": "Pouezit evit e implijout.",
"Put shader pack .zip files or folders in it.": "Lakait e-barzh restroù .zip pe teuliadoù pakadoù skeudennerioù.",
"Puts every option of this pack back to its default.": "Adlakaat a ra pep dibarzh eus ar pakad-mañ d'e dalvoudegezh dre ziouer.",
"Sets many options at once, as the pack groups them.": "Lakaat a ra kalz dibarzhioù war un dro, evel ma vez strollet gant ar pakad.",
"No shader packs yet: open the pack folder and put some in it.": "Pakad skeudennerioù ebet c'hoazh: digorit teuliad ar pakadoù ha lakait un nebeut e-barzh.",
"Pick a shader pack. Picking the one in use turns shaders off.": "Dibabit ur pakad skeudennerioù. Dibab an hini implijet a laz ar skeudennerioù.",
"Changed options apply with Apply, or when you leave.": "An dibarzhioù cheñchet a vez arloet gant Arloañ, pe pa guitait.",
"Pack settings": "Arventennoù ar pakad",
"Built-in settings": "Arventennoù enframmet",
"%s settings": "Arventennoù %s",
"Settings made by the pack's author: they change only this pack.": "Arventennoù graet gant oberer ar pakad: cheñch a reont nemet ar pakad-mañ."
}
@@ -58,10 +58,7 @@
"horizon.mods.settings": "Postavke",
"horizon.mods.in_world": " (ostavi svijet da se promijeni)",
"horizon.mods.open_folder": "Otvorite Mod folder",
"horizon.shader_packs.empty": "Nema paketa shadera: stavite ih u mapu shaderpacks",
"horizon.shader_options.open": "Opcije shadera...",
"horizon.shader_options.profile": "Profil",
"horizon.shader_options.loading": "Čitanje opcija paketa shadera...",
"horizon.agent.copy_details": "Kopiraj detalje veze",
"horizon.multiplayer.join": "Pridružite se",
"horizon.multiplayer.cancel_reconnecting": "Otkaži ponovno povezivanje",
@@ -281,7 +278,6 @@
"horizon.osk.close": "Zatvori",
"horizon.extras.chat_animation.tooltip": "Nova linija ćaskanja klizi s lijeve strane dok se gasi, umjesto da izgleda kao cijela.",
"horizon.extras.chat_indicator.tooltip": "Traka u boji lijevo od linije za ćaskanje.",
"horizon.extras.wheel_cycles_settings.tooltip": "Točkić za pomeranje preko dugmeta u podešavanjima menja svoju vrednost, kao u vaniliji. Isključeno, kotačić samo pomiče listu.",
"horizon.extras.pack_browser.tooltip": "Select Resource Packs dobija dugme Dodaj koje pretražuje Modrinth i instalira paket u fasciklu sa paketima. Isključeno, igra nikada ne kontaktira Modrinth.",
"horizon.extras.splitscreen.tooltip": "U svijetu se drugi kontrolor može pridružiti kao novi igrač: pritisnite A kada to zatraži, a zatim odaberite profil i gdje ćete igrati.",
"horizon.splitscreen.setup.monitor_unknown": "monitor",
@@ -415,10 +411,27 @@
"horizon.extras.cosmetics_mine.tooltip": "Skriveno: svaki Horizon igrač vas vidi bez vaše kozmetike, uključujući i vas. Isti prekidač kao Ormar lansera.",
"horizon.extras.cosmetics_others": "Kozmetika drugih igrača",
"horizon.extras.cosmetics_others.tooltip": "Šta drugi Horizon igrači nose: sve, samo stilove oznaka sa imenom ili ništa. Skriveno, igra nikad ne pita šta nose.",
"horizon.extras.cosmetics_auras": "Aure i tragovi drugih",
"horizon.extras.cosmetics_auras.tooltip": "Aure i staze koje drugi igrači nose. Podrazumevano isključeno.",
"horizon.extras.cosmetics_hide_fx": "Sakrij tragove i aure drugih igrača",
"horizon.extras.cosmetics_hide_fx.tooltip": "Tragovi i aure drugih igrača ostaju skriveni; njihova ostala kozmetika se i dalje vidi. Isključeno po zadanom.",
"horizon.extras.cosmetics.shown": "Prikazano",
"horizon.extras.cosmetics.hidden": "Skriveno",
"horizon.extras.cosmetics.name_tags": "Samo oznake sa imenom",
"Shader Packs...": "Shader paketi..."
"Shader Packs...": "Shader paketi...",
"Installed": "Instalirani",
"Apply": "Primijeni",
"Reset all options": "Resetuj sve opcije",
"A Horizon shader pack.": "Horizon paket šejdera.",
"An OptiFine or Iris shader pack.": "OptiFine ili Iris paket šejdera.",
"In use: press to turn shaders off.": "U upotrebi: pritisni da isključiš šejdere.",
"Press to use it.": "Pritisni da ga koristiš.",
"Put shader pack .zip files or folders in it.": "Stavi u njega .zip datoteke ili mape paketa šejdera.",
"Puts every option of this pack back to its default.": "Vraća svaku opciju ovog paketa na zadanu vrijednost.",
"Sets many options at once, as the pack groups them.": "Postavlja mnogo opcija odjednom, kako ih paket grupiše.",
"No shader packs yet: open the pack folder and put some in it.": "Još nema paketa šejdera: otvori mapu paketa i stavi nekoliko u nju.",
"Pick a shader pack. Picking the one in use turns shaders off.": "Odaberi paket šejdera. Odabir paketa koji se koristi isključuje šejdere.",
"Changed options apply with Apply, or when you leave.": "Promijenjene opcije se primjenjuju pomoću Primijeni ili kada izađeš.",
"Pack settings": "Postavke paketa",
"Built-in settings": "Ugrađene postavke",
"%s settings": "Postavke za %s",
"Settings made by the pack's author: they change only this pack.": "Postavke koje je napravio autor paketa: mijenjaju samo ovaj paket."
}
Loaded 100 of 1076 files, more files were not shown because too many files have changed in this diff. Show more